Compare commits

...

10 Commits

Author SHA1 Message Date
Raphaël Numbus 24f62ec057 Lots of changes to the directories organisation, more work needed. 2026-05-15 10:02:29 +02:00
Raphaël Numbus 73adb395c0 Migrated from multi repos to monorepo architecture. 2026-05-02 12:52:08 +02:00
Raphaël Billet 72668492f5 Updates to the TUI. Need to continue working on a unified input validation function. 2026-04-28 09:00:24 +02:00
Raphaël Numbus baa6bdd28b Added Users & Groups logic : big revamp needed (AI draft) 2026-04-26 22:20:28 +02:00
Raphaël Numbus 267af96309 Finalized the compatibility check function. 2026-04-26 11:26:47 +02:00
Raphaël Numbus 0f4f203fa3 Fixed increment logic. 2026-04-26 11:20:26 +02:00
Raphaël Numbus 5edabe57fb Fixed inverted logic. 2026-04-26 11:17:36 +02:00
Raphaël Numbus e3550acc10 Fixed bad syntax. 2026-04-26 11:15:42 +02:00
Raphaël Numbus d0ba62cb9d Fixed bad syntax. 2026-04-26 11:09:48 +02:00
Raphaël Numbus 640b183ed2 Added a compatibility check 2026-04-26 11:08:46 +02:00
181 changed files with 10109 additions and 227 deletions
+1
View File
@@ -1,4 +1,5 @@
config/
web/ux/
test*
.DS_Store
.env
+1 -1
View File
@@ -1,5 +1,5 @@
/config/
web/ux/
config/
test*
.DS_Store
.env
+81
View File
@@ -0,0 +1,81 @@
{
description = "Numbus - Simplified NixOS deployments";
inputs = {
# Nix unstable packages
nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";
# Nix stable packages
nixpkgs-stable.url = "github:nixos/nixpkgs/nixos-25.11";
# Numbus
numbus.url = "https://gittea.dev/numbus/numbus";
# Disko
disko.url = "github:nix-community/disko";
disko.inputs.nixpkgs.follows = "nixpkgs";
# Sops-nix
sops-nix.url = "github:Mic92/sops-nix";
sops-nix.inputs.nixpkgs.follows = "nixpkgs";
# Flatpaks
nix-flatpak.url = "github:gmodena/nix-flatpak";
};
outputs = { self, nixpkgs, ... }@inputs:
let
system = "x86_64-linux";
pkgs = nixpkgs.legacyPackages.${system};
# Helper for defining systems
mkNumbus = { deviceModule, extraModules ? [], nixpkgsRef, deviceType }: nixpkgsRef.lib.nixosSystem {
inherit system;
specialArgs = { inherit inputs deviceType; };
modules = [
inputs.disko.nixosModules.disko
inputs.sops-nix.nixosModules.sops
self.nixosModules.common
deviceModule
] ++ extraModules;
};
in {
nixosModules = {
common = ./modules/common/default.nix;
server = ./modules/server/default.nix;
backup = ./modules/backup/default.nix;
console = ./modules/console/default.nix;
computer = ./modules/computer/default.nix;
tv = ./modules/tv/default.nix;
};
nixosConfigurations = {
numbus-server = mkNumbus {
deviceModule = self.nixosModules.server;
nixpkgsRef = inputs.nixpkgs-stable;
deviceType = "server";
};
numbus-backup = mkNumbus {
deviceModule = self.nixosModules.backup;
nixpkgsRef = inputs.nixpkgs-stable;
deviceType = "backup";
};
numbus-computer = mkNumbus {
deviceModule = self.nixosModules.computer;
extraModules = [ inputs.nix-flatpak.nixosModules.nix-flatpak ];
nixpkgsRef = inputs.nixpkgs;
deviceType = "computer";
};
numbus-console = mkNumbus {
deviceModule = self.nixosModules.console;
nixpkgsRef = inputs.nixpkgs;
deviceType = "console";
};
numbus-tv = mkNumbus {
deviceModule = self.nixosModules.tv;
nixpkgsRef = inputs.nixpkgs;
extraModules = [ inputs.nix-flatpak.nixosModules.nix-flatpak ];
deviceType = "tv";
};
};
};
}
+13
View File
@@ -0,0 +1,13 @@
{ ... }:
{
imports = [
./hardware/default.nix
./mail/default.nix
./misc/default.nix
./networking/default.nix
./packages/default.nix
./services/default.nix
./global.nix
];
}
+11
View File
@@ -0,0 +1,11 @@
{
description = "Numbus Server Module";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.11";
};
outputs = { self, nixpkgs }: {
nixosModules.numbus = import ./modules/default.nix;
};
}
+53
View File
@@ -0,0 +1,53 @@
{ lib, ... }:
with lib;
{
options.numbus = {
owner = mkOption {
type = types.str;
example = "Alex";
default = "Numbus";
description = "The name of the person who owns this server";
};
language = mkOption {
type = types.str;
example = "FR";
default = "FR";
description = "The language for this server";
};
locale = mkOption {
type = types.str;
example = "fr_FR";
default = "fr_FR";
description = "The default locale for this server";
};
services = {
domain = mkOption {
type = types.str;
example = "numbus.eu";
description = "The root domain name (i.e. example.com) that your services will use";
};
dns = mkOption {
type = types.enum [ "pi-hole" "adguard" ];
default = "pi-hole";
example = "pi-hole";
description = "The preferred DNS resolver service (pi-hole or adguard) that other services should depend on";
};
};
email = {
administratorEmail = mkOption {
type = types.str;
example = "admin@your-domain.com";
description = "The email that will be used to send critical notifications such as hardware failures, services errors, ACME updates, etc";
};
userEmail = mkOption {
type = types.str;
example = "user@your-domain.com";
description = "The email that will be used by services to send notifications";
};
};
};
}
+8
View File
@@ -0,0 +1,8 @@
{ ... }:
{
imports = [
./firewall.nix
./networking.nix
];
}
+13
View File
@@ -0,0 +1,13 @@
{ config, pkgs, lib, ... }:
{
config = {
networking.nftables.enable = true;
networking.firewall = {
enable = true;
allowPing = true;
allowedTCPPorts = [ 53 80 443 ];
allowedUDPPorts = [ 53 443 ];
};
};
}
+60
View File
@@ -0,0 +1,60 @@
{ config, pkgs, lib, ... }:
with lib;
let
cfg = config.numbus.networking;
in
{
options.numbus.networking = {
ipAddress = mkOption {
description = "The IP address that this server will use";
type = types.str;
example = "192.168.1.100";
};
interface = mkOption {
description = "The interface that this server will use to connect to the network";
type = types.str;
example = "enp1s0";
};
routerIpAddress = mkOption {
description = "The IP address of the router of your network";
type = types.str;
example = "192.168.1.1";
};
networkSubnet = mkOption {
description = "The subnet of your network";
type = types.str;
default = "";
example = "192.168.1.0/24";
};
dnsServers = mkOption {
description = "The list of DNS servers that this server will use";
type = types.listOf types.str;
default = [ "${cfg.ipAddress}" "9.9.9.9" ];
example = [ "${cfg.ipAddress}" "9.9.9.9" ];
};
};
config = {
networking.hostName = "numbus-server";
networking.networkmanager.enable = false;
# Allow rootless containers to bind to port 53 and up
boot.kernel.sysctl."net.ipv4.ip_unprivileged_port_start" = 53;
networking.bridges.br0.interfaces = [ "${cfg.interface}" ];
networking.interfaces."${cfg.interface}".useDHCP = false;
networking.interfaces.br0.useDHCP = false;
networking.nameservers = cfg.dnsServers;
networking.interfaces.br0.ipv4.addresses = [{
address = "${cfg.ipAddress}";
prefixLength = 24;
}];
networking.defaultGateway = {
address = "${cfg.routerIpAddress}";
interface = "br0";
};
};
}
+10
View File
@@ -0,0 +1,10 @@
{ ... }:
{
imports = [
./packages.nix
./podman.nix
./ssh.nix
./terminal.nix
];
}
+25
View File
@@ -0,0 +1,25 @@
{ config, pkgs, ... }:
{
nixpkgs.config.allowUnfree = true;
environment.systemPackages = with pkgs; [
git
ncdu
fastfetch
tpm2-tss
sops
age
powertop
pciutils
hdparm
hd-idle
hddtemp
smartmontools
cpufrequtils
intel-gpu-tools
snapraid
mergerfs
mergerfs-tools
];
}
+16
View File
@@ -0,0 +1,16 @@
{ pkgs, ... }:
{
virtualisation.podman.enable = true;
virtualisation.podman.defaultNetwork.settings.dns_enabled = true;
virtualisation.containers.containersConf.settings = {
network.default_rootless_network_cmd = "slirp4netns";
};
environment.systemPackages = with pkgs; [
podman-compose
podman-tui
slirp4netns
];
}
+5
View File
@@ -0,0 +1,5 @@
{ config, ... }:
{
services.openssh.enable = true;
}
+24
View File
@@ -0,0 +1,24 @@
{ config, pkgs, ... }:
{
environment.systemPackages = with pkgs; [
fish
fishPlugins.fzf-fish
fishPlugins.grc
grc
fzf
];
programs.fish = {
enable = true;
interactiveShellInit = ''
set fish_greeting # Disable greeting
fastfetch
echo -e "\n\nWelcome to your Numbus-Server !\n\n- This system is managed by NixOS\n- All changes are futile\n- Please consider buying support if you can't get your server running\n- Have a nice day and enjoy !"
'';
shellAliases = {
nixup = "cd /etc/nixos/ && sudo nix flake update && sudo nixos-rebuild --flake . switch --upgrade && cd -";
nixwitch = "cd /etc/nixos/ && sudo nix flake update && sudo nixos-rebuild --flake . switch && cd -";
};
};
}
@@ -0,0 +1,171 @@
{ config, pkgs, lib, ... }:
with lib;
let
# Container config
name = "authelia";
# Version tagging
autheliaVersion = "v4.39.16";
databaseVersion = "18.3";
# Helper
helper = import ../service-helper.nix { inherit config pkgs lib; };
cfg = config.numbus-server.services.authelia;
# Derive Base DN from domain (e.g., example.com -> dc=example,dc=com)
domainParts = splitString "." config.numbus-server.services.domain;
baseDN = concatStringsSep "," (map (p: "dc=${p}") domainParts);
# Generate dynamic access control rules based on groups and allowedApps
mkGroupRule = groupName: appName:
let
app = config.numbus-server.service.${appName} or {};
in
if app ? subdomain && app ? domain then ''
- domain: "${app.subdomain}.${app.domain}"
policy: two_factor
subject: "group:${groupName}"''
else "";
allGroupRules = concatStringsSep "\n" (filter (s: s != "") (flatten (mapAttrsToList (groupName: groupCfg:
map (appName: mkGroupRule groupName appName) (groupCfg.allowedApps or [])
) (config.numbus-server.groups or {}))));
defaultRedirectionUrl =
if config.numbus-server.services.homepage.enable then
"https://${config.numbus-server.services.homepage.subdomain}.${config.numbus-server.services.domain}"
else if config.numbus-server.services.dashy.enable then
"https://${config.numbus-server.services.dashy.subdomain}.${config.numbus-server.services.domain}"
else null;
in
helper.mkPodmanService {
inherit name;
pod = name;
description = "Authelia, your own unified login provider";
defaultPort = "9091";
dependencies = [
"sops-install-secrets.service"
"traefik.service"
"${config.numbus-server.services.dns}.service"
];
middlewares = [
"secureHeaders"
];
dirPermissions = [
"100999:100 ${cfg.configDir}"
];
secrets = [
"authelia/db_name"
"authelia/db_username"
"authelia/db_password"
"authelia/jwt_secret"
"authelia/session_secret"
"authelia/storage_secret"
];
composeText = ''
services:
authelia-server:
image: ghcr.io/authelia/authelia:${autheliaVersion}
container_name: authelia-server
hostname: authelia-server
user: '1000:1000'
networks:
authelia:
ipv4_address: 10.89.251.253
ports:
- "${cfg.port}:9091/tcp"
volumes:
- ${cfg.configDir}/server:/config
security_opt:
- no-new-privileges:true
cap_drop:
- NET_RAW
restart: unless-stopped
authelia-database:
container_name: authelia-database
hostname: authelia-database
image: docker.io/library/postgres:${databaseVersion}
user: '1000:1000'
networks:
authelia:
ipv4_address: 10.89.251.252
environment:
POSTGRES_DB: ${config.sops.placeholder."authelia/db_name"}
POSTGRES_USER: ${config.sops.placeholder."authelia/db_username"}
POSTGRES_PASSWORD: ${config.sops.placeholder."authelia/db_password"}
POSTGRES_INITDB_ARGS: '--data-checksums'
volumes:
- ${cfg.configDir}/database:/var/lib/postgresql/data
shm_size: 128mb
healthcheck:
disable: false
security_opt:
- no-new-privileges:true
cap_drop:
- NET_RAW
restart: unless-stopped
networks:
authelia:
driver: bridge
name: authelia
ipam:
config:
- subnet: "10.89.251.0/24"
gateway: "10.89.251.254"
'';
extraConfig = {
sops.templates."authelia-config" = {
gid = "100";
uid = "100999";
mode = "0400";
content = ''
authelia:
identity_validation:
reset_password:
jwt_secret: "${config.sops.placeholder."authelia/jwt_secret"}"
jwt_lifespan: "5 minutes"
jwt_algorithm: "HS256"
storage:
encryption_key: "${config.sops.placeholder."authelia/storage_secret"}"
postgres:
address: "tcp://authelia-database:5432"
database: "${config.sops.placeholder."authelia/db_name"}"
username: "${config.sops.placeholder."authelia/db_username"}"
password: "${config.sops.placeholder."authelia/db_password"}"
session:
secret: "${config.sops.placeholder."authelia/session_secret"}"
cookies:
- domain: "${config.numbus-server.services.domain}"
authelia_url: "https://${cfg.subdomain}.${config.numbus-server.services.domain}"
${optionalString (defaultRedirectionUrl != null) "default_redirection_url: \"${defaultRedirectionUrl}\""}
authentication_backend:
ldap:
implementation: "lldap"
address: "ldap://host.containers.internal:3890"
base_dn: "${baseDN}"
user: "UID=authelia,OU=people,${baseDN}"
password: "${config.sops.placeholder."lldap/"}"
notifier:
smtp:
address: submission://${config.numbus-server.mail.smtpHost}:${config.numbus-server.mail.smtpPort}
username: ${config.numbus-server.mail.smtpUsername}
password: ${config.sops.placeholder.smtpPassword}
sender: ${config.numbus-server.mail.fromAddress}
tls:
server_name: ${config.numbus-server.mail.smtpHost}
minimum_version: TLS1.2
skip_verify: false
access_control:
default_policy: 'deny'
rules:
- domain: "*.${config.numbus-server.service.domain}"
policy: two_factor
subject: "group:admin"
${allGroupRules}
'';
path = "/etc/authelia/authelia.yaml";
};
};
}
@@ -0,0 +1,74 @@
{ config, pkgs, lib, ... }:
with lib;
let
# Container config
name = "crafty";
# Version tagging
craftyVersion = "v4.10.1";
# Storage optimization
spindown = config.numbus-server.hardware.HddSpindown;
optimizedDir = if spindown.enable && (spindown.optimize == "compatible" || (isList spindown.optimize && elem name spindown.optimize))
then cfg.configDir
else cfg.dataDir;
# Helper
helper = import ../service-helper.nix { inherit config pkgs lib; };
cfg = config.numbus-server.services.crafty;
in
helper.mkPodmanService {
inherit name;
description = "Crafty controller, one place to manage your minecraft servers";
defaultPort = "8443";
scheme = "https";
dataDirEnabled = optimizedDir == cfg.dataDir;
middlewares = [
"secureHeaders"
];
dirPermissions = [
"100999:100 ${cfg.configDir}"
"100999:100 ${cfg.configDir}/log"
"100999:100 ${cfg.configDir}/config"
"100999:100 ${optimizedDir}/import"
"100999:100 ${optimizedDir}/backups"
"100999:100 ${optimizedDir}/servers"
];
composeText = ''
services:
crafty:
image: registry.gitlab.com/crafty-controller/crafty-4:${craftyVersion}
container_name: crafty
user: '1000:1000'
networks:
crafty:
ipv4_address: 10.89.250.253
ports:
- "${cfg.port}:8443/tcp"
- "19132:19132/udp"
- "25500-25600:25500-25600"
volumes:
- ${optimizedDir}/backups:/crafty/backups
- ${optimizedDir}/servers:/crafty/servers
- ${optimizedDir}/import:/crafty/import
- ${cfg.configDir}/logs:/crafty/logs
- ${cfg.configDir}/config:/crafty/app/config
environment:
- TZ=${time.timeZone}
security_opt:
- no-new-privileges:true
cap_drop:
- NET_RAW
restart: unless-stopped
networks:
crafty:
driver: bridge
name: crafty
ipam:
config:
- subnet: "10.89.250.0/24"
gateway: "10.89.250.254"
'';
}
@@ -0,0 +1,97 @@
{ config, pkgs, lib, ... }:
with lib;
let
# Container config
name = "dashy";
# Version tagging
dashyVersion = "v3.2.3";
# Helper
helper = import ../service-helper.nix { inherit config pkgs lib; };
cfg = config.numbus-server.services.dashy;
in
helper.mkPodmanService {
inherit name;
description = "Dashy, the ultimate dashboard for your homelab";
defaultPort = "8999";
configDirEnabled = false;
dataDirEnabled = false;
middlewares = [
"secureHeaders"
];
composeText = ''
services:
dashy:
image: lissy93/dashy:${dashyVersion}
container_name: dashy
hostname: dashy
user: '1000:1000'
networks:
dashy:
ipv4_address: 10.89.235.253
ports:
- ${cfg.port}:8080
volumes:
- ${config.sops."dashy/config".path}:/app/user-data/conf.yml
environment:
- UID=1000
- GID=1000
- NODE_ENV=production
healthcheck:
test: ['CMD', 'node', '/app/services/healthcheck']
interval: 1m30s
timeout: 10s
retries: 3
start_period: 40s
security_opt:
- no-new-privileges:true
cap_drop:
- NET_RAW
restart: unless-stopped
networks:
dashy:
driver: bridge
name: dashy
ipam:
config:
- subnet: "10.89.235.0/24"
gateway: "10.89.235.254"
'';
extraConfig = {
sops.templates."dashy/config" = {
gid = "100";
uid = "100999";
mode = "0440" ;
content = ''
pageInfo:
title: My Homelab
sections:
- name: Example Section
icon: far fa-rocket
items:
- title: GitHub
description: Dashy source code and docs
icon: fab fa-github
url: https://github.com/Lissy93/dashy
- title: Issues
description: View open issues, or raise a new one
icon: fas fa-bug
url: https://github.com/Lissy93/dashy/issues
- name: Local Services
items:
- title: Firewall
icon: favicon
url: http://192.168.1.1/
- title: Game Server
icon: https://i.ibb.co/710B3Yc/space-invader-x256.png
url: http://192.168.130.1/
'';
path = "/etc/dashy/dashy.yaml";
};
};
}
@@ -0,0 +1,29 @@
{ ... }:
{
imports = [
# Good
./gitea.nix
./immich.nix
./nextcloud.nix
./passbolt.nix
./traefik.nix
# Testing needed
./authelia.nix
./crafty.nix
./dashy.nix
./frigate.nix
./home-assistant.nix
./homepage.nix
./it-tools.nix
./jellyfin.nix
./lldap.nix
./n8n.nix
./netbird.nix
./netbootxyz.nix
./ntfy.nix
./odoo.nix
./uptime-kuma.nix
./vscodium.nix
];
}
@@ -0,0 +1,83 @@
{ config, pkgs, lib, ... }:
with lib;
let
# Container config
name = "frigate";
# Version tagging
frigateVersion = "0.16.4";
# Helper
helper = import ../service-helper.nix { inherit config pkgs lib; };
cfg = config.numbus-server.services.frigate;
in
helper.mkPodmanService {
inherit name;
pod = "home-assistant";
description = "Frigate, your fully-local NVR (Network Video Recorder)";
defaultPort = "8971";
scheme = "https";
dependencies = [
"sops-install-secrets.service"
"traefik.service"
"authelia.service"
"home-assistant.service"
"${config.numbus-server.services.dns}.service"
];
middlewares = [
"secureHeaders"
];
dirPermissions = [
"1000:100 ${cfg.configDir}"
"1000:100 ${cfg.dataDir}"
];
composeText = ''
services:
frigate:
image: ghcr.io/blakeblackshear/frigate:${frigateVersion}
container_name: frigate
hostname: frigate
shm_size: "256mb"
networks:
home-assistant:
ipv4_address: 10.89.230.253
ports:
- "${cfg.port}:8971/tcp"
volumes:
- ${cfg.configDir}:/config
- ${cfg.dataDir}:/media/frigate
- /etc/localtime:/etc/localtime:ro
- type: tmpfs
target: /tmp/cache
tmpfs:
size: 1000000000
environment:
- FRIGATE_MQTT_USER=${config.sops.placeholder."home-assistant/mqtt_username"}
- FRIGATE_MQTT_PASSWORD=${config.sops.placeholder."home-assistant/mqtt_password"}
${lib.optionalString (cfg.devices != []) ''
devices:
${lib.concatStringsSep "\n" (map (d: " - \"${d}\"") cfg.devices)}
''}
security_opt:
- no-new-privileges:true
cap_drop:
- NET_RAW
stop_grace_period: 30s
restart: unless-stopped
networks:
home-assistant:
external: true
'';
extraOptions = {
devices = mkOption {
type = types.listOf types.str;
default = [];
example = [ "/dev/dri:/dev/dri" "/dev/bus/usb:/dev/bus/usb" "/dev/apex_0:/dev/apex_0" ];
description = "List of devices to map into the container. /dev/dri is used for graphics acceleration, /dev/bus/usb for USB Coral TPUs, and /dev/apex_0 for PCI coral TPUs";
};
};
}
@@ -0,0 +1,103 @@
{ config, pkgs, lib, ... }:
with lib;
let
# Container config
name = "gitea";
# Version tagging
giteaVersion = "1.25.4-rootless";
databaseVersion = "18-alpine";
# Storage optimization
spindown = config.numbus-server.hardware.HddSpindown;
optimizedDir = if spindown.enable && (spindown.optimize == "compatible" || (isList spindown.optimize && elem name spindown.optimize))
then cfg.configDir
else cfg.dataDir;
# Helper
helper = import ../service-helper.nix { inherit config pkgs lib; };
cfg = config.numbus-server.services.gitea;
in
helper.mkPodmanService {
inherit name;
pod = "false";
description = "Gitea, your own self-hosted git platform";
defaultPort = "3000";
dataDirEnabled = optimizedDir == cfg.dataDir;
middlewares = [
"secureHeaders"
];
dirPermissions = [
"100999:100 ${cfg.configDir}"
"100999:100 ${optimizedDir}/data"
"100999:100 ${cfg.configDir}/config"
"100999:100 ${cfg.configDir}/database"
];
secrets = [
"gitea/db_name"
"gitea/db_username"
"gitea/db_password"
];
composeText = ''
services:
gitea-database:
image: docker.io/library/postgres:${databaseVersion}
container_name: gitea-database
hostname: gitea-database
user: '1000:1000'
networks:
gitea:
ipv4_address: 10.89.240.253
volumes:
- ${cfg.configDir}/database:/var/lib/postgresql
environment:
- POSTGRES_DB=${config.sops.placeholder."gitea/db_name"}
- POSTGRES_USER=${config.sops.placeholder."gitea/db_username"}
- POSTGRES_PASSWORD=${config.sops.placeholder."gitea/db_password"}
security_opt:
- no-new-privileges:true
cap_drop:
- NET_RAW
restart: unless-stopped
gitea-server:
image: docker.gitea.com/gitea:${giteaVersion}
container_name: gitea-server
hostname: gitea-server
user: '1000:1000'
networks:
gitea:
ipv4_address: 10.89.240.252
ports:
- "${cfg.port}:3000/tcp"
volumes:
- ${optimizedDir}/data:/var/lib/gitea
- ${cfg.configDir}/config:/etc/gitea
- /etc/localtime:/etc/localtime:ro
environment:
- GITEA__database__DB_TYPE=postgres
- GITEA__database__HOST=gitea-database:5432
- GITEA__database__NAME=${config.sops.placeholder."gitea/db_name"}
- GITEA__database__USER=${config.sops.placeholder."gitea/db_username"}
- GITEA__database__PASSWD=${config.sops.placeholder."gitea/db_password"}
- GITEA__server__SSH_PORT=2424
- GITEA__server__ROOT_URL=https://${cfg.subdomain}.${config.numbus-server.services.domain}
depends_on:
- gitea-database
security_opt:
- no-new-privileges:true
cap_drop:
- NET_RAW
restart: unless-stopped
networks:
gitea:
driver: bridge
name: gitea
ipam:
config:
- subnet: "10.89.240.0/24"
gateway: "10.89.240.254"
'';
}
@@ -0,0 +1,177 @@
{ config, pkgs, lib, ... }:
with lib;
let
# Container config
name = "home-assistant";
# Version tagging
homeAssistantVersion = "2026.2.3";
mqttVersion = "2.1-alpine";
# Helper
helper = import ../service-helper.nix { inherit config pkgs lib; };
cfg = config.numbus-server.services.home-assistant;
in
helper.mkPodmanService {
inherit name;
description = "Home Assistant, libre house control and much more";
defaultPort = "8123";
dataDirEnabled = false;
middlewares = [
"secureHeaders"
];
dirPermissions = [
"1000:100 ${cfg.configDir}"
"1000:100 ${cfg.configDir}/config"
"100999:100 ${cfg.configDir}/mqtt"
];
secrets = [
"home-assistant/mqtt_user"
"home-assistant/mqtt_password"
];
# Compose file good
composeText = ''
services:
home-assistant:
image: ghcr.io/home-assistant/home-assistant:${homeAssistantVersion}
container_name: home-assistant
hostname: home-assistant
networks:
home-assistant:
ipv4_address: 10.89.230.252
ports:
- "${cfg.port}:8123/tcp"
volumes:
- ${cfg.configDir}/config:/config
- /etc/localtime:/etc/localtime:ro
- /run/dbus:/run/dbus:ro
${lib.optionalString (cfg.devices != []) ''
devices:
${lib.concatStringsSep "\n" (map (d: " - \"${d}\"") cfg.devices)}
''}
security_opt:
- no-new-privileges:true
cap_drop:
- NET_RAW
restart: unless-stopped
home-assistant-mqtt:
image: docker.io/library/eclipse-mosquitto:${mqttVersion}
container_name: home-assistant-mqtt
hostname: home-assistant-mqtt
user: '1000:1000'
networks:
home-assistant:
ipv4_address: 10.89.230.252
volumes:
- ${cfg.configDir}/mqtt:/mosquitto
security_opt:
- no-new-privileges:true
cap_drop:
- NET_RAW
restart: unless-stopped
networks:
home-assistant:
driver: bridge
name: home-assistant
ipam:
config:
- subnet: "10.89.230.0/24"
gateway: "10.89.230.254"
'';
extraOptions = {
devices = mkOption {
type = types.listOf types.str;
default = [];
example = [ "/dev/serial/by-id/Sonoff_Zigbee_3.0-id-port0:/dev/ttyUSB0" ];
description = "List of devices to map into the container. /dev/ttyUSB0 is used for Zigbee dongles";
};
};
extraConfig = {
systemd.services."${name}-quirk" = {
description = "Podman container quirk : ${name}";
after = [ "${name}.service" ];
onFailure = [ "service-failure-notify@%n.service" ];
startLimitBurst = 5;
startLimitIntervalSec = 600;
path = [ pkgs.coreutils pkgs.systemd ];
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
};
script = ''
mkdir -p /var/lib/numbus-server/${name}
if [[ -e ${cfg.configDir}/config/configuration.yaml ]]; then
if grep -qF "10.89.230.1/32" ${cfg.configDir}/config/configuration.yaml; then
exit 0
elif grep -qF "use_x_forwarded_for" ${cfg.configDir}/config/configuration.yaml && ! grep -qF "10.89.230.1/32" ${cfg.configDir}/config/configuration.yaml; then
tmp=$(mktemp)
head -n -6 ${cfg.configDir}/config/configuration.yaml > "$tmp"
mv "$tmp" ${cfg.configDir}/config/configuration.yaml
fi
fi
until [[ -e ${cfg.configDir}/config/configuration.yaml ]]; do
sleep 15
done
cat << 'EOF' >> ${cfg.configDir}/config/configuration.yaml
http:
use_x_forwarded_for: true
trusted_proxies: 10.89.230.1
zha:
EOF
systemctl restart ${name}.service
'';
};
};
systemd.services."mqtt-quirk" = {
description = "Podman container quirk : Home-assistant MQTT";
after = [ "sops-install-secrets.service" ];
before = [ "${name}.service" ];
onFailure = [ "service-failure-notify@%n.service" ];
startLimitBurst = 5;
startLimitIntervalSec = 600;
path = [ pkgs.coreutils pkgs.mosquitto ];
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
};
script = ''
if [[ -e ${cfg.configDir}/mqtt/mosquitto.conf && ${cfg.configDir}/mqtt/password.txt ]]; then
if grep -qF "listener 1883" ${cfg.configDir}/mqtt/mosquitto.conf; then
exit 0
else
rm ${cfg.configDir}/mqtt/mosquitto.conf
rm ${cfg.configDir}/mqtt/password.txt
touch ${cfg.configDir}/mqtt/mosquitto.conf
touch ${cfg.configDir}/mqtt/password.txt
fi
fi
cat << EOF >> ${cfg.configDir}/mqtt/mosquitto.conf
persistence true
persistence_location /mosquitto/data/
log_dest file /mosquitto/log/mosquitto.log
listener 1883
## Authentication ##
allow_anonymous false
password_file /mosquitto/password.txt
EOF
HOME_ASSISTANT_MQTT_USER=$(cat /run/secrets/home-assistant/mqtt_user)
HOME_ASSISTANT_MQTT_PASSWORD=$(cat /run/secrets/home-assistant/mqtt_password)
mosquitto_passwd -b ${cfg.configDir}/mqtt/password.txt "$HOME_ASSISTANT_MQTT_USER" "$HOME_ASSISTANT_MQTT_PASSWORD"
chmod 0400 ${cfg.configDir}/mqtt/password.txt
'';
};
}
@@ -0,0 +1,63 @@
{ config, pkgs, lib, ... }:
with lib;
let
# Container config
name = "homepage";
# Version tagging
homepageVersion = "v1.10.1";
# Helper
helper = import ../service-helper.nix { inherit config pkgs lib; };
cfg = config.numbus-server.services.homepage;
in
helper.mkPodmanService {
inherit name;
description = "Homepage, a modern and highly customizable application dashboard";
defaultPort = "3003";
middlewares = [
"secureHeaders"
];
dirPermissions = [
"100999:100 ${cfg.configDir}"
"100999:100 ${cfg.configDir}/config"
"100999:100 ${cfg.configDir}/images"
"100999:100 ${cfg.configDir}/icons"
];
composeText = ''
services:
homepage:
image: ghcr.io/gethomepage/homepage:${homepageVersion}
container_name: homepage
hostname: homepage
user: '1000:1000'
networks:
homepage:
ports:
- "${cfg.port}:3000/tcp"
volumes:
- ${cfg.configDir}/config:/app/config
- ${cfg.configDir}/images:/app/public/images
- ${cfg.configDir}/icons:/app/public/icons
environment:
PUID: 1000
PGID: 1000
HOMEPAGE_ALLOWED_HOSTS: ${cfg.subdomain}.${config.numbus-server.services.domain}
security_opt:
- no-new-privileges:true
cap_drop:
- NET_RAW
restart: unless-stopped
networks:
homepage:
driver: bridge
name: homepage
ipam:
config:
- subnet: "10.89.220.0/24"
gateway: "10.89.220.254"
'';
}
@@ -0,0 +1,190 @@
{ config, pkgs, lib, ... }:
with lib;
let
# Container configuration
name = "immich";
# Version tagging
immichVersion = "v2.5.6";
redisVersion = "9@sha256:546304417feac0874c3dd576e0952c6bb8f06bb4093ea0c9ca303c73cf458f63";
databaseVersion = "14-vectorchord0.4.3-pgvectors0.2.0@sha256:bcf63357191b76a916ae5eb93464d65c07511da41e3bf7a8416db519b40b1c23";
# Helper
helper = import ../service-helper.nix { inherit config pkgs lib; };
cfg = config.numbus-server.services.immich;
in
helper.mkPodmanService {
inherit name;
description = "Immich, Google Photos but better";
defaultPort = "2283";
middlewares = [
"immichSecureHeaders"
];
dirPermissions = [
"100999:100 ${cfg.configDir}"
"100999:100 ${cfg.configDir}/redis"
"100999:100 ${cfg.configDir}/model-cache"
"100999:100 ${cfg.configDir}/machine-learning-cache"
"100999:100 ${cfg.configDir}/machine-learning-config"
"100999:100 ${cfg.configDir}/database"
"100999:100 ${cfg.dataDir}"
];
secrets = [
"immich/redis_hostname"
"immich/db_hostname"
"immich/db_name"
"immich/db_username"
"immich/db_password"
];
# Compose file good
composeText = ''
services:
immich-server:
container_name: immich-server
hostname: immich-server
image: ghcr.io/immich-app/immich-server:${immichVersion}
user: '1000:1000'
networks:
immich:
ipv4_address: 10.89.210.253
ports:
- "${cfg.port}:2283/tcp"
volumes:
- $UPLOAD_LOCATION:/data
- /etc/localtime:/etc/localtime:ro
environment:
TZ: $TZ
REDIS_HOSTNAME: ${config.sops.placeholder."immich/redis_hostname"}
DB_HOSTNAME: ${config.sops.placeholder."immich/db_hostname"}
DB_DATABASE_NAME: ${config.sops.placeholder."immich/db_name"}
DB_USERNAME: ${config.sops.placeholder."immich/db_username"}
DB_PASSWORD: ${config.sops.placeholder."immich/db_password"}
IMMICH_TRUSTED_PROXIES: 10.89.210.1
depends_on:
- immich-redis
- immich-database
healthcheck:
disable: false
security_opt:
- no-new-privileges:true
cap_drop:
- NET_RAW
restart: unless-stopped
immich-machine-learning:
container_name: immich-machine-learning
hostname: immich-machine-learning
image: ghcr.io/immich-app/immich-machine-learning:${immichVersion}
user: '1000:1000'
networks:
immich:
ipv4_address: 10.89.210.252
volumes:
- ${cfg.configDir}/model-cache:/cache
- ${cfg.configDir}/machine-learning-config:/usr/src/.config
- ${cfg.configDir}/machine-learning-cache:/usr/src/.cache/
healthcheck:
disable: false
security_opt:
- no-new-privileges:true
cap_drop:
- NET_RAW
restart: unless-stopped
immich-redis:
container_name: immich-redis
hostname: immich-redis
image: docker.io/valkey/valkey:${redisVersion}
user: '1000:1000'
networks:
immich:
ipv4_address: 10.89.210.251
volumes:
- ${cfg.configDir}/redis:/data
healthcheck:
test: redis-cli ping || exit 1
security_opt:
- no-new-privileges:true
cap_drop:
- NET_RAW
restart: unless-stopped
immich-database:
container_name: immich-database
hostname: immich-database
image: ghcr.io/immich-app/postgres:${databaseVersion}
user: '1000:1000'
networks:
immich:
ipv4_address: 10.89.210.250
environment:
POSTGRES_DB: ${config.sops.placeholder."immich/db_name"}
POSTGRES_USER: ${config.sops.placeholder."immich/db_username"}
POSTGRES_PASSWORD: ${config.sops.placeholder."immich/db_password"}
POSTGRES_INITDB_ARGS: '--data-checksums'
volumes:
- $DB_DATA_LOCATION:/var/lib/postgresql/data
shm_size: 128mb
healthcheck:
disable: false
security_opt:
- no-new-privileges:true
cap_drop:
- NET_RAW
restart: unless-stopped
networks:
immich:
driver: bridge
name: immich
ipam:
config:
- subnet: "10.89.210.0/24"
gateway: "10.89.210.254"
'';
extraConfig = {
sops.templates."immich/env" = {
gid = "100";
uid = "1000";
mode = "0400";
content = ''
DB_DATA_LOCATION=${cfg.configDir}/database
UPLOAD_LOCATION=${cfg.dataDir}
'';
path = "/etc/podman/immich/.env";
};
sops.templates."traefik/rules/immich-secureHeaders" = {
gid = "100";
uid = "100999";
mode = "0400";
content = ''
http:
middlewares:
immichSecureHeaders:
headers:
FrameDeny: true
AccessControlAllowMethods: 'GET,POST,PUT,DELETE,OPTIONS'
AccessControlAllowOriginList:
- https://${cfg.subdomain}.${config.numbus-server.services.domain}
- origin-list-or-null
AccessControlMaxAge: 100
AddVaryHeader: true
BrowserXssFilter: true
ContentTypeNosniff: true
ForceSTSHeader: true
STSIncludeSubdomains: true
STSPreload: true
ContentSecurityPolicy: "default-src 'self'; base-uri 'self'; img-src 'self' https://static.immich.cloud https://tiles.immich.cloud data: blob:; connect-src 'self' https://${cfg.subdomain}.${config.numbus-server.services.domain} wss://${cfg.subdomain}.${config.numbus-server.services.domain} https://static.immich.cloud https://tiles.immich.cloud; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; script-src 'self' 'unsafe-inline' 'unsafe-eval'; worker-src 'self' blob: https://${cfg.subdomain}.${config.numbus-server.services.domain}; frame-ancestors 'self';"
CustomFrameOptionsValue: SAMEORIGIN
ReferrerPolicy: same-origin
PermissionsPolicy: vibrate 'self'
STSSeconds: 315360000
'';
path = "/etc/traefik/rules/immich-secureHeaders.yaml";
};
};
}
@@ -0,0 +1,54 @@
{ config, pkgs, lib, ... }:
with lib;
let
# Container config
name = "it-tools";
# Version tagging
it-toolsVersion = "2024.10.22-7ca5933";
# Helper
helper = import ../service-helper.nix { inherit config pkgs lib; };
cfg = config.numbus-server.services.it-tools;
in
helper.mkPodmanService {
inherit name;
description = "IT-tools, useful tools when doing IT";
pod = "false";
defaultPort = "8880";
configDirEnabled = false;
dataDirEnabled = false;
middlewares = [
"secureHeaders"
];
# Compose file good
composeText = ''
services:
it-tools:
image: docker.io/corentinth/it-tools:${it-toolsVersion}
container_name: it-tools
hostname: it-tools
user: '1000:1000'
networks:
it-tools:
ipv4_address: 10.89.200.253
ports:
- "${cfg.port}:80/tcp"
security_opt:
- no-new-privileges:true
cap_drop:
- NET_RAW
restart: unless-stopped
networks:
it-tools:
driver: bridge
name: it-tools
ipam:
config:
- subnet: "10.89.200.0/24"
gateway: "10.89.200.254"
'';
}
@@ -0,0 +1,69 @@
{ config, pkgs, lib, ... }:
with lib;
let
# Container config
name = "jellyfin";
# Version tagging
jellyfinVersion = "10.11.6";
# Helper
helper = import ../service-helper.nix { inherit config pkgs lib; };
cfg = config.numbus-server.services.jellyfin;
in
helper.mkPodmanService {
inherit name;
description = "Jellyfin : A self-hosted media server to stream your movies and music";
defaultPort = "8096";
scheme = "https"; #TODO CHECK
middlewares = [
"secureHeaders"
];
dirPermissions = [
"100999:100 ${cfg.dataDir}"
"100999:100 ${cfg.configDir}"
"100999:100 ${cfg.dataDir}/media"
"100999:100 ${cfg.dataDir}/fonts"
"100999:100 ${cfg.configDir}/cache"
"100999:100 ${cfg.configDir}/config"
];
composeText = ''
services:
jellyfin:
image: docker.io/jellyfin/jellyfin:${jellyfinVersion}
container_name: jellyfin
hostname: jellyfin
user: '1000:1000'
networks:
jellyfin:
ipv4_address: 10.89.190.253
ports:
- "${cfg.port}:8096/tcp"
volumes:
- ${cfg.configDir}/config:/config
- ${cfg.configDir}/cache:/cache
- type: bind
source: ${cfg.dataDir}/media
target: /media
- type: bind
source: ${cfg.dataDir}/fonts
target: /usr/local/share/fonts/custom
read_only: true
cap_drop:
- NET_RAW
security_opt:
- no-new-privileges:true
restart: unless-stopped
networks:
jellyfin:
driver: bridge
name: jellyfin
ipam:
config:
- subnet: "10.89.190.0/24"
gateway: "10.89.190.254"
'';
}
@@ -0,0 +1,84 @@
{ config, pkgs, lib, ... }:
with lib;
let
# Container config
name = "lldap";
# Version tagging
lldapVersion = "v0.6.2";
# Helper
helper = import ../service-helper.nix { inherit config pkgs lib; };
cfg = config.numbus-server.services.lldap;
# Derive Base DN from domain (e.g., example.com -> dc=example,dc=com)
domainParts = splitString "." config.numbus-server.services.domain;
baseDN = concatStringsSep "," (map (p: "dc=${p}") domainParts);
in
helper.mkPodmanService {
inherit name;
pod = "false";
description = "LLDAP, unified user management";
defaultPort = "17170";
dependencies = [
"sops-install-secrets.service"
"network-online.target"
];
middlewares = [
"secureHeaders"
];
dirPermissions = [
"100999:100 ${cfg.configDir}"
];
secrets = [
"lldap/jwt_secret"
"lldap/key_seed"
"lldap/admin_password"
];
composeText = ''
services:
lldap:
image: lldap/lldap:${lldapVersion}
container_name: lldap
hostname: lldap
user: '1000:1000'
networks:
lldap:
ipv4_address: 10.89.185.253
ports:
- "3890:3890"
- "${cfg.port}:17170"
volumes:
- ${cfg.configDir}:/data
environment:
- UID=1000
- GID=1000
- TZ=${config.time.timeZone}
- LLDAP_LDAP_BASE_DN=${baseDN}
- LLDAP_JWT_SECRET="${config.sops.placeholder."lldap/jwt_secret"}"
- LLDAP_KEY_SEED="${config.sops.placeholder."lldap/key_seed"}"
- LLDAP_LDAP_USER_PASS="${config.sops.placeholder."lldap/admin_password"}"
- LLDAP_SMTP_OPTIONS__ENABLE_PASSWORD_RESET=true
- LLDAP_SMTP_OPTIONS__SERVER=${config.numbus-server.mail.smtpServer}
- LLDAP_SMTP_OPTIONS__PORT=${config.numbus-server.mail.smtpPort}
- LLDAP_SMTP_OPTIONS__SMTP_ENCRYPTION=${config.numbus-server.mail.smtpEncryption}
- LLDAP_SMTP_OPTIONS__USER=${config.numbus-server.mail.smtpUsername}
- LLDAP_SMTP_OPTIONS__PASSWORD=${config.sops.placeholder."mail/smtpPassword"}
- LLDAP_SMTP_OPTIONS__FROM=no-reply <${config.numbus-server.mail.fromAddress}>
security_opt:
- no-new-privileges:true
cap_drop:
- NET_RAW
restart: unless-stopped
networks:
lldap:
driver: bridge
name: lldap
ipam:
config:
- subnet: "10.89.185.0/24"
gateway: "10.89.185.254"
'';
}
@@ -0,0 +1,72 @@
{ config, pkgs, lib, ... }:
with lib;
let
# Container config
name = "n8n";
# Version tagging
n8nVersion = "2.11.4";
# Storage optimization
spindown = config.numbus-server.hardware.HddSpindown;
optimizedDir = if spindown.enable && (spindown.optimize == "compatible" || (isList spindown.optimize && elem name spindown.optimize))
then cfg.configDir
else cfg.dataDir;
# Helper
helper = import ../service-helper.nix { inherit config pkgs lib; };
cfg = config.numbus-server.services.n8n;
in
helper.mkPodmanService {
inherit name;
pod = "false";
description = "n8n, the ultimate automation platform";
defaultPort = "5678";
scheme = "https";
middlewares = [
"secureHeaders"
];
dirPermissions = [
"100999:100 ${optimizedDir}"
];
composeText = ''
services:
n8n:
image: docker.n8n.io/n8nio/n8n:${n8nVersion}
container_name: n8n
hostname: n8n
user: '1000:1000'
networks:
n8n:
ipv4_address: 10.89.180.253
ports:
- "${cfg.port}:5678"
volumes:
- ${optimizedDir}:/home/node/.n8n
environment:
- N8N_ENFORCE_SETTINGS_FILE_PERMISSIONS=true
- N8N_HOST=${cfg.subdomain}.${config.numbus-server.services.domain}
- N8N_PORT=5678
- N8N_PROTOCOL=https
- N8N_RUNNERS_ENABLED=true
- NODE_ENV=production
- WEBHOOK_URL=https://${cfg.subdomain}.${config.numbus-server.services.domain}/
- GENERIC_TIMEZONE=${time.timeZone}
- TZ=${time.timeZone}
security_opt:
- no-new-privileges:true
cap_drop:
- NET_RAW
restart: unless-stopped
networks:
n8n:
driver: bridge
name: n8n
ipam:
config:
- subnet: "10.89.180.0/24"
gateway: "10.89.180.254"
'';
}
@@ -0,0 +1,203 @@
{ config, pkgs, lib, ... }:
with lib;
let
# Container config
name = "netbird";
# Version tagging
netbirdDashboardVersion = "";
netbirdServerVersion = "";
# Helper
helper = import ../service-helper.nix { inherit config pkgs lib; };
cfg = config.numbus-server.services.netbird;
in
helper.mkPodmanService {
inherit name;
pod = "false";
description = "NetBird, an all-in-one ZTNA remote access platform";
defaultPort = "8888";
reverseProxied = false;
dependencies = [
"sops-install-secrets.service"
"traefik.service"
"${config.numbus-server.services.dns}.service"
];
dirPermissions = [
"100999:100 ${cfg.configDir}"
];
secrets = [
"netbird/auth_key"
"netbird/encryption_key"
];
composeText = ''
services:
netbird-dashboard:
image: netbirdio/dashboard:${netbirdDashboardVersion}
container_name: netbird-dashboard
hostname: netbird-dashboard
user: '1000:1000'
networks:
netbird:
ipv4_address: 10.89.175.253
ports:
- "${defaultPort}:8080/tcp"
environment:
# Endpoints
- NETBIRD_MGMT_API_ENDPOINT=https://${cfg.subdomain}.${config.numbus-server.services.domain}
- NETBIRD_MGMT_GRPC_API_ENDPOINT=https://${cfg.subdomain}.${config.numbus-server.services.domain}
# OIDC - using embedded IdP
- AUTH_AUDIENCE=netbird-dashboard
- AUTH_CLIENT_ID=netbird-dashboard
- AUTH_CLIENT_SECRET=
- AUTH_AUTHORITY=https://${cfg.subdomain}.${config.numbus-server.services.domain}/oauth2
- USE_AUTH0=false
- AUTH_SUPPORTED_SCOPES=openid profile email groups
- AUTH_REDIRECT_URI=/nb-auth
- AUTH_SILENT_REDIRECT_URI=/nb-silent-auth
# SSL
- NGINX_SSL_PORT=443
- LETSENCRYPT_DOMAIN=none
logging:
driver: "json-file"
options:
max-size: "500m"
max-file: "2"
security_opt:
- no-new-privileges:true
cap_drop:
- NET_RAW
restart: unless-stopped
netbird-server:
image: netbirdio/netbird-server:${netbirdServerVersion}
container_name: netbird-server
hostname: netbird-server
user: '1000:1000'
networks:
netbird:
ipv4_address: 10.89.175.252
ports:
- "8889:8081/tcp"
- "3478:3478/udp"
volumes:
- ${config.sops.templates."netbird-config".path}:/etc/netbird/config.yaml
- ${cfg.configDir}:/var/lib/netbird
command: ["--config", "/etc/netbird/config.yaml"]
logging:
driver: "json-file"
options:
max-size: "500m"
max-file: "2"
security_opt:
- no-new-privileges:true
cap_drop:
- NET_RAW
restart: unless-stopped
networks:
netbird:
driver: bridge
name: netbird
ipam:
config:
- subnet: "10.89.175.0/24"
gateway: "10.89.175.254"
'';
extraConfig = {
sops.templates."netbird-config" = {
gid = "100";
uid = "100999";
mode = "0400";
content = ''
server:
listenAddress: ":80"
exposedAddress: "https://${cfg.subdomain}.${config.numbus-server.services.domain}:443"
stunPorts:
- 3478
metricsPort: 9090
healthcheckAddress: ":9000"
logLevel: "info"
logFile: "console"
authSecret: "${config.sops.placeholder."netbird/auth_key"}"
dataDir: "/var/lib/netbird"
auth:
issuer: "https://${cfg.subdomain}.${config.numbus-server.services.domain}/oauth2"
signKeyRefreshEnabled: true
dashboardRedirectURIs:
- "https://${cfg.subdomain}.${config.numbus-server.services.domain}/nb-auth"
- "https://${cfg.subdomain}.${config.numbus-server.services.domain}/nb-silent-auth"
cliRedirectURIs:
- "http://localhost:53000/"
reverseProxy:
trustedHTTPProxies:
- "10.89.175.1/32"
store:
engine: "sqlite"
encryptionKey: "${config.sops.placeholder."netbird/encryption_key"}"
'';
path = "/etc/netbird/netbird.yaml";
};
sops.templates."traefik/rules/${name}" = {
gid = "100";
uid = "1000";
mode = "0400";
content = ''
http:
routers:
${name}-dashboard:
rule: "Host(`${cfg.subdomain}.${config.numbus-server.services.domain}`)"
entrypoints:
- "websecure"
middlewares:
- secureHeaders
tls:
certresolver: "cloudflare"
options: "secureTLS"
priority: 1
${name}-grpc:
rule: "Host(`${cfg.subdomain}.${config.numbus-server.services.domain}`) && (PathPrefix(`/signalexchange.SignalExchange/`) || PathPrefix(`/management.ManagementService/`))"
entrypoints:
- "websecure"
service: ${name}-server-h2c
middlewares:
- secureHeaders
tls:
certresolver: "cloudflare"
options: "secureTLS"
${name}-backend:
rule: "Host(`${cfg.subdomain}.${config.numbus-server.services.domain}`) && (PathPrefix(`/relay`) || PathPrefix(`/ws-proxy/`) || PathPrefix(`/api`) || PathPrefix(`/oauth2`))"
entrypoints:
- "websecure"
service: ${name}-server
middlewares:
- secureHeaders
tls:
certresolver: "cloudflare"
options: "secureTLS"
services:${cfg.port}
${name}-dashboard:
loadBalancer:
servers:
- url: "http://host.containers.internal:${cfg.port}"
${name}-server:
loadBalancer:
servers:
- url: "http://host.containers.internal:8889"
${name}-server-h2c:
loadBalancer:
servers:
- url: "h2c://host.containers.internal:3478"
'';
path = "/etc/traefik/rules/${name}";
};
};
}
@@ -0,0 +1,75 @@
{ config, pkgs, lib, ... }:
with lib;
let
# Container config
name = "netbootxyz";
# Version tagging
netbootxyzVersion = "3.0.0";
# Storage optimization
spindown = config.numbus-server.hardware.HddSpindown;
optimizedDir = if spindown.enable && (spindown.optimize == "compatible" || (isList spindown.optimize && elem name spindown.optimize))
then cfg.configDir
else cfg.dataDir;
# Helper
helper = import ../service-helper.nix { inherit config pkgs lib; };
cfg = config.numbus-server.services.netbootxyz;
in
helper.mkPodmanService {
inherit name;
description = "Netboot.xyz, forget about flashing isos on USB sticks with PXE boot";
pod = "false";
defaultPort = "3004";
configDirEnabled = optimizedDir == cfg.configDir;
dataDirEnabled = optimizedDir == cfg.dataDir;
middlewares = [
"secureHeaders"
];
dirPermissions = [
"100999:100 ${cfg.configDir}"
"100999:100 ${optimizedDir}"
"100999:100 ${cfg.configDir}/config"
"100999:100 ${optimizedDir}/assets"
];
composeText = ''
services:
netbootxyz:
image: ghcr.io/netbootxyz/netbootxyz:${netbootxyzVersion}
container_name: netbootxyz
hostname: netbootxyz
user: '1000:1000'
networks:
netbootxyz:
ipv4_address: 10.89.170.253
ports:
- "${cfg.port}:3000/tcp"
- "69:69/udp"
- "8008:80/tcp"
volumes:
- ${cfg.configDir}/config:/config
- ${optimizedDir}/assets:/assets
environment:
- PUID=1000
- PGID=1000
- TZ=${time.timeZone}
- PORT_RANGE=30000:30010
- SUBFOLDER=/
security_opt:
- no-new-privileges:true
cap_drop:
- NET_RAW
restart: unless-stopped
networks:
netbootxyz:
driver: bridge
name: netbootxyz
ipam:
config:
- subnet: "10.89.170.0/24"
gateway: "10.89.170.254"
'';
}
@@ -0,0 +1,384 @@
{ config, pkgs, lib, ... }:
with lib;
let
# Version tagging
nextcloudVersion = "33.0.0";
redisVersion = "8.6-alpine";
databaseVersion = "11.8";
onlyofficeVersion = "9.2";
whiteboardVersion = "v1.5.6";
# Helper
helper = import ../service-helper.nix { inherit config pkgs lib; };
cfg = config.numbus-server.services.nextcloud;
# Container config
name = "nextcloud";
in
helper.mkPodmanService {
inherit name;
description = "Nextcloud, your own online office suite";
defaultPort = "1100";
middlewares = [
"nextcloudSecureHeaders"
];
secrets = [
"nextcloud/db_name"
"nextcloud/db_username"
"nextcloud/db_password"
"nextcloud/redis_password"
"nextcloud/onlyoffice_secret"
"nextcloud/whiteboard_secret"
];
dirPermissions = [
"100032:100 ${cfg.dataDir}"
"100032:100 ${cfg.configDir}"
"100032:100 ${cfg.configDir}/web"
"100999:100 ${cfg.configDir}/redis"
"100999:100 ${cfg.configDir}/database"
"1000:100 ${cfg.configDir}/onlyoffice"
"1000:100 ${cfg.configDir}/onlyoffice/log"
"1000:100 ${cfg.configDir}/onlyoffice/cache"
"1000:100 ${cfg.configDir}/onlyoffice/data"
"1000:100 ${cfg.configDir}/onlyoffice/database"
];
# Compose file good
composeText = ''
services:
nextcloud-server:
image: docker.io/library/nextcloud:${nextcloudVersion}
container_name: nextcloud-server
hostname: nextcloud-server
networks:
nextcloud:
ipv4_address: 10.89.160.253
ports:
- "${cfg.port}:80/tcp"
volumes:
- ${cfg.configDir}/web:/var/www/html
- ${cfg.dataDir}:/mnt/ncdata
environment:
MYSQL_HOST: nextcloud-database:3306
MYSQL_DATABASE: ${config.sops.placeholder."nextcloud/db_name"}
MYSQL_USER: ${config.sops.placeholder."nextcloud/db_username"}
MYSQL_PASSWORD: ${config.sops.placeholder."nextcloud/db_password"}
REDIS_HOST_PASSWORD: ${config.sops.placeholder."nextcloud/redis_password"}
REDIS_HOST: nextcloud-redis
NEXTCLOUD_TRUSTED_DOMAINS: ${cfg.subdomain}.${config.numbus-server.services.domain}
NEXTCLOUD_DATA_DIR: /mnt/ncdata
SMTP_SECURE: tls
SMTP_HOST: ${config.numbus-server.mail.smtpServer}
SMTP_PORT: ${toString config.numbus-server.mail.smtpPort}
SMTP_NAME: ${config.numbus-server.mail.smtpUsername}
SMTP_PASSWORD: ${config.sops.placeholder.smtpPassword}
MAIL_FROM_ADDRESS: no-reply
MAIL_DOMAIN: ${config.numbus-server.services.domain}
APACHE_DISABLE_REWRITE_IP: 1
OVERWRITEPROTOCOL: https
TRUSTED_PROXIES: 10.89.160.1
NC_default_phone_region: "${config.numbus-server.language}"
NC_default_language: "${config.numbus-server.language}"
NC_default_locale: "${config.numbus-server.locale}"
NC_default_timezone: "${config.time.timeZone}"
NC_maintenance_window_start: "1"
depends_on:
- nextcloud-database
security_opt:
- no-new-privileges:true
cap_drop:
- NET_RAW
restart: unless-stopped
nextcloud-redis:
image: docker.io/library/redis:${redisVersion}
container_name: nextcloud-redis
hostname: nextcloud-redis
user: '1000:1000'
networks:
nextcloud:
ipv4_address: 10.89.160.252
volumes:
- ${cfg.configDir}/redis:/data
command: redis-server --requirepass ${config.sops.placeholder."nextcloud/redis_password"} --save 60 1 --loglevel warning
security_opt:
- no-new-privileges:true
cap_drop:
- NET_RAW
restart: unless-stopped
nextcloud-database:
image: docker.io/library/mariadb:${databaseVersion}
container_name: nextcloud-database
hostname: nextcloud-database
user: '1000:1000'
networks:
nextcloud:
ipv4_address: 10.89.160.251
volumes:
- ${cfg.configDir}/database:/var/lib/mysql
environment:
MARIADB_DATABASE: ${config.sops.placeholder."nextcloud/db_name"}
MARIADB_USER: ${config.sops.placeholder."nextcloud/db_username"}
MARIADB_PASSWORD: ${config.sops.placeholder."nextcloud/db_password"}
MARIADB_RANDOM_ROOT_PASSWORD: true
security_opt:
- no-new-privileges:true
cap_drop:
- NET_RAW
command:
- "--transaction-isolation=READ-COMMITTED"
- "--binlog-format=ROW"
restart: unless-stopped
nextcloud-onlyoffice:
image: docker.io/onlyoffice/documentserver:${onlyofficeVersion}
container_name: nextcloud-onlyoffice
hostname: nextcloud-onlyoffice
networks:
nextcloud:
ipv4_address: 10.89.160.250
ports:
- "9980:80/tcp"
volumes:
- ${cfg.configDir}/onlyoffice/log:/var/log/onlyoffice
- ${cfg.configDir}/onlyoffice/cache:/var/lib/onlyoffice
- ${cfg.configDir}/onlyoffice/data:/var/www/onlyoffice/Data
- ${cfg.configDir}/onlyoffice/database:/var/lib/postgresql
environment:
- JWT_SECRET=${config.sops.placeholder."nextcloud/onlyoffice_secret"}
- REDIS_SERVER_PASS=${config.sops.placeholder."nextcloud/redis_password"}
- REDIS_SERVER_HOST=nextcloud-redis
- REDIS_SERVER_PORT=6379
- ADMINPANEL_ENABLED=false
- EXAMPLE_ENABLED=false
- METRICS_ENABLED=false
cap_drop:
- NET_RAW
restart: unless-stopped
nextcloud-whiteboard:
image: ghcr.io/nextcloud-releases/whiteboard:${whiteboardVersion}
container_name: nextcloud-whiteboard
hostname: nextcloud-whiteboard
user: '1000:1000'
ports:
- "3002:3002/tcp"
environment:
NEXTCLOUD_URL: https://${cfg.subdomain}.${config.numbus-server.services.domain}
JWT_SECRET_KEY: ${config.sops.placeholder."nextcloud/whiteboard_secret"}
security_opt:
- no-new-privileges:true
cap_drop:
- NET_RAW
restart: unless-stopped
networks:
nextcloud:
driver: bridge
name: nextcloud
ipam:
config:
- subnet: "10.89.160.0/24"
gateway: "10.89.160.254"
'';
extraOptions = {
onlyoffice = {
subdomain = mkOption {
type = types.str;
default = "onlyoffice";
example = "onlyoffice";
description = "The subdomain that onlyoffice for nextcloud will use";
};
};
whiteboard = {
subdomain = mkOption {
type = types.str;
default = "whiteboard";
example = "whiteboard";
description = "The subdomain that whiteboard for nextcloud will use";
};
};
};
extraConfig = {
sops.templates."traefik/rules/nextcloud-onlyoffice" = {
gid = "100";
uid = "100999";
mode = "0400";
content = ''
http:
routers:
nextcloud-onlyoffice:
rule: "Host(`${cfg.onlyoffice.subdomain}.${config.numbus-server.services.domain}`)"
entrypoints:
- "websecure"
service: nextcloud-onlyoffice
tls:
certresolver: "cloudflare"
options: "secureTLS"
services:
nextcloud-onlyoffice:
loadBalancer:
servers:
- url: "http://host.containers.internal:9980"
'';
path = "/etc/traefik/rules/nextcloud-onlyoffice.yaml";
};
sops.templates."traefik/rules/nextcloud-whiteboard" = {
gid = "100";
uid = "100999";
mode = "0400";
content = ''
http:
routers:
nextcloud-whiteboard:
rule: "Host(`${cfg.whiteboard.subdomain}.${config.numbus-server.services.domain}`)"
entrypoints:
- "websecure"
service: nextcloud-whiteboard
middlewares:
- "secureHeaders"
tls:
certresolver: "cloudflare"
options: "secureTLS"
services:
nextcloud-whiteboard:
loadBalancer:
servers:
- url: "http://host.containers.internal:3002"
'';
path = "/etc/traefik/rules/nextcloud-whiteboard.yaml";
};
sops.templates."traefik/rules/nextcloud-secureHeaders" = {
gid = "100";
uid = "100999";
mode = "0400";
content = ''
http:
middlewares:
nextcloudSecureHeaders:
headers:
FrameDeny: false
CustomFrameOptionsValue: "SAMEORIGIN"
AddVaryHeader: true
BrowserXssFilter: true
ContentTypeNosniff: true
ForceSTSHeader: true
STSSeconds: 315360000
STSIncludeSubdomains: true
STSPreload: true
AccessControlAllowMethods: "GET,OPTIONS,PUT"
AccessControlAllowOriginList:
- origin-list-or-null
AccessControlMaxAge: 100
ReferrerPolicy: same-origin
PermissionsPolicy: "vibrate=()"
ContentSecurityPolicy: >-
default-src https://${cfg.onlyoffice.subdomain}.${config.numbus-server.services.domain} 'self';
script-src https://${cfg.onlyoffice.subdomain}.${config.numbus-server.services.domain} 'self' 'unsafe-inline';
style-src 'self' 'unsafe-inline';
connect-src 'self';
img-src 'self' data:;
font-src 'self' data:;
frame-src https://${cfg.onlyoffice.subdomain}.${config.numbus-server.services.domain} 'self';
frame-ancestors https://${cfg.onlyoffice.subdomain}.${config.numbus-server.services.domain} 'self';
object-src 'none';
base-uri 'self';
'';
path = "/etc/traefik/rules/nextcloud-secureHeaders";
};
systemd.services."${name}-quirk" = {
description = "Podman container quirk : ${name}";
wantedBy = [ "multi-user.target" ];
after = [ "${name}.service" "${name}-secrets.service" ];
onFailure = [ "service-failure-notify@%n.service" ];
startLimitBurst = 5;
startLimitIntervalSec = 600;
path = [ pkgs.coreutils pkgs.sudo pkgs.podman pkgs.systemd pkgs.gnugrep ];
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
};
script = ''
OCC="sudo -u numbus-admin podman exec --user www-data nextcloud-server php occ"
[[ ! -e /var/lib/numbus-server/${name}/.env ]] && systemctl start ${name}-secrets.service
until [[ -e /var/lib/numbus-server/${name}/.env ]]; do
echo "Waiting for secrets generation..."
sleep 5
done
source /var/lib/numbus-server/${name}/.env
until $OCC status | grep -iq "installed: true" >/dev/null 2>&1; do
echo "Waiting for Nextcloud to be up and running..."
sleep 60
done
$OCC db:add-missing-indices
$OCC maintenance:repair --include-expensive
INSTALL_APPS_LIST=( "calendar" "contacts" "mail" "notes" "onlyoffice" "cookbook" "whiteboard" )
DISABLE_APPS_LIST=( "activity" "federation" "webhook_listeners" "photos" "recommendations" "sharebymail" "teams" "support" "richdocumentscode" )
for app in ''${INSTALL_APPS_LIST[@]}; do
if ! $OCC --no-warnings app:list | grep -iq "$app:"; then
$OCC --no-warnings app:install "$app"
fi
if $OCC --no-warnings app:list --disabled | grep -iq "$app:"; then
$OCC --no-warnings app:enable "$app"
fi
done
for app in ''${DISABLE_APPS_LIST[@]}; do
if $OCC --no-warnings app:list --enabled | grep -iq "$app:"; then
$OCC --no-warnings app:disable "$app"
fi
done
$OCC --no-warnings config:system:set onlyoffice DocumentServerInternalUrl --value="https://${cfg.onlyoffice.subdomain}.${config.numbus-server.services.domain}/"
$OCC --no-warnings config:system:set onlyoffice DocumentServerUrl --value="https://${cfg.onlyoffice.subdomain}.${config.numbus-server.services.domain}/"
$OCC --no-warnings config:system:set onlyoffice jwt_secret --value="$ONLYOFFICE_PASSWORD"
$OCC --no-warnings config:app:set whiteboard collabBackendUrl --value="https://${cfg.whiteboard.subdomain}.${config.numbus-server.services.domain}"
$OCC --no-warnings config:app:set whiteboard jwt_secret_key --value="$WHITEBOARD_PASSWORD"
if [[ ! -f /var/lib/numbus-server/${name}/croned.true ]]; then
$OCC background:cron
sudo -u numbus-admin podman exec --user www-data nextcloud-server php -f /var/www/html/cron.php
touch /var/lib/numbus-server/${name}/croned.true
fi
if [[ ! -f /var/lib/numbus-server/${name}/scanned.true ]]; then
$OCC files:scan --all
$OCC files:repair-tree
touch /var/lib/numbus-server/${name}/scanned.true
fi
'';
};
systemd.services."${name}-cron" = {
description = "Podman container crontab : ${name}";
after = [ "${name}.service" "${name}-quirk.service" ];
onFailure = [ "service-failure-notify@%n.service" ];
path = [ pkgs.sudo pkgs.podman ];
serviceConfig = {
Type = "oneshot";
ExecCondition = ''${pkgs.sudo}/bin/sudo -u numbus-admin podman exec --user www-data nextcloud-server php occ status'';
ExecStart = "${pkgs.sudo}/bin/sudo -u numbus-admin podman exec --user www-data nextcloud-server php -f /var/www/html/cron.php";
};
};
systemd.timers."${name}-cron" = {
description = "Timer for Nextcloud cron";
wantedBy = [ "timers.target" ];
timerConfig = {
OnBootSec = "5m";
OnUnitActiveSec = "5m";
Unit = "${name}-cron.service";
};
};
};
}
@@ -0,0 +1,62 @@
{ config, pkgs, lib, ... }:
with lib;
let
# Container config
name = "ntfy";
# Version tagging
ntfyVersion = "v2.18.0";
# Helper
helper = import ../service-helper.nix { inherit config pkgs lib; };
cfg = config.numbus-server.services.ntfy;
in
helper.mkPodmanService {
inherit name;
description = "Ntfy, get notified easily";
defaultPort = "8099";
middlewares = [
"secureHeaders"
];
dirPermissions = [
"100999:100 ${cfg.configDir}"
"100999:100 ${cfg.configDir}/cache"
"100999:100 ${cfg.configDir}/config"
];
composeText = ''
services:
ntfy:
image: docker.io/binwiederhier/ntfy
container_name: ntfy
hostname: ntfy
user: "1000:1000"
networks:
ntfy:
ipv4_address: 10.89.150.253
ports:
- "${cfg.port}:80/tcp"
command:
- serve
volumes:
- ${cfg.config}/cache:/var/cache/ntfy
- ${cfg.config}/config:/etc/ntfy
environment:
- TZ=${time.timeZone}
security_opt:
- no-new-privileges:true
cap_drop:
- NET_RAW
restart: unless-stopped
networks:
ntfy:
driver: bridge
name: ntfy
ipam:
config:
- subnet: "10.89.150.0/24"
gateway: "10.89.150.254"
'';
}
@@ -0,0 +1,118 @@
{ config, pkgs, lib, ... }:
with lib;
let
# Container config
name = "odoo";
# Version tagging
odooVersion = "10.11.6";
databaseVersion = "15.17";
# Storage optimization
spindown = config.numbus-server.hardware.HddSpindown;
optimizedDir = if spindown.enable && (spindown.optimize == "compatible" || (isList spindown.optimize && elem name spindown.optimize))
then cfg.configDir
else cfg.dataDir;
# Helper
helper = import ../service-helper.nix { inherit config pkgs lib; };
cfg = config.numbus-server.services.odoo;
in
helper.mkPodmanService {
inherit name;
description = "Odoo : An open ERP (Enterprise resource planning) solution";
defaultPort = "8069";
configDirEnabled = optimizedDir == cfg.configDir;
dataDirEnabled = optimizedDir == cfg.dataDir;
middlewares = [
"secureHeaders"
];
dirPermissions = [
"100999:100 ${optimizedDir}"
"100999:100 ${optimizedDir}/odoo"
"100999:100 ${cfg.configDir}/addons"
"100999:100 ${cfg.configDir}/config"
"100999:100 ${cfg.configDir}/database"
];
composeText = ''
services:
odoo-database:
image: docker.io/library/postgres:${databaseVersion}
container_name: odoo-database
hostname: odoo-database
user: '1000:1000'
shm_size: 128mb
networks:
odoo:
ipv4_address: 10.89.190.253
volumes:
- ${cfg.configDir}/database:/var/lib/postgresql/data
environment:
- POSTGRES_DB=${config.sops.placeholder."odoo/db_name"}
- POSTGRES_PASSWORD=${config.sops.placeholder."odoo/db_password"}
- POSTGRES_USER=${config.sops.placeholder."odoo/db_username"}
- PGDATA=/var/lib/postgresql/data
cap_drop:
- NET_RAW
security_opt:
- no-new-privileges:true
restart: unless-stopped
odoo-server:
image: docker.io/library/odoo:${odooVersion}
container_name: odoo-server
hostname: odoo-server
user: '1000:1000'
networks:
odoo:
ipv4_address: 10.89.190.252
ports:
- "${cfg.port}:8069/tcp"
volumes:
- ${optimizedDir}/odoo:/var/lib/odoo
- ${cfg.configDir}/config:/etc/odoo
- ${cfg.configDir}/addons:/mnt/extra-addons
environment:
- HOST=odoo-database
- USER=${config.sops.placeholder."odoo/db_username"}
- PASSWORD=${config.sops.placeholder."odoo/db_password"}
depends_on:
- odoo-database
cap_drop:
- NET_RAW
security_opt:
- no-new-privileges:true
restart: unless-stopped
networks:
odoo:
driver: bridge
name: odoo
ipam:
config:
- subnet: "10.89.190.0/24"
gateway: "10.89.190.254"
'';
extraConfig = {
sops.secrets."odoo/db_name" = {
sopsFile = /etc/nixos/secrets/podman/odoo.yaml;
gid = "100";
uid = "1000";
mode = "0400";
};
sops.secrets."odoo/db_username" = {
sopsFile = /etc/nixos/secrets/podman/odoo.yaml;
gid = "100";
uid = "1000";
mode = "0400";
};
sops.secrets."odoo/db_password" = {
sopsFile = /etc/nixos/secrets/podman/odoo.yaml;
gid = "100";
uid = "1000";
mode = "0400";
};
};
}
@@ -0,0 +1,112 @@
{ config, pkgs, lib, ... }:
with lib;
let
# Container config
name = "passbolt";
# Version tagging
passboltVersion = "5.9.0-1-ce-non-root";
databaseVersion = "12.2";
# Storage optimization
spindown = config.numbus-server.hardware.HddSpindown;
optimizedDir = if spindown.enable && (spindown.optimize == "compatible" || (isList spindown.optimize && elem name spindown.optimize))
then cfg.configDir
else cfg.dataDir;
# Helper
helper = import ../service-helper.nix { inherit config pkgs lib; };
cfg = config.numbus-server.services.passbolt;
in
helper.mkPodmanService {
inherit name;
description = "Passbolt, your password manager";
defaultPort = "4433";
scheme = "https";
dataDirEnabled = false;
middlewares = [ "secureHeaders" ];
dirPermissions = [
"100032:100 ${cfg.configDir}"
"100032:100 ${cfg.configDir}/gpg"
"100032:100 ${cfg.configDir}/jwt"
"100999:100 ${cfg.configDir}/database"
];
secrets = [
"passbolt/db_name"
"passbolt/db_username"
"passbolt/db_password"
];
# Compose file good
composeText = ''
services:
passbolt-server:
image: docker.io/passbolt/passbolt:${passboltVersion}
container_name: passbolt-server
hostname: passbolt-server
user: '33:33'
networks:
passbolt:
ports:
- "${cfg.port}:4433/tcp"
volumes:
- ${cfg.configDir}/gpg:/etc/passbolt/gpg
- ${cfg.configDir}/jwt:/etc/passbolt/jwt
environment:
APP_DEFAULT_TIMEZONE: ${config.time.timeZone}
APP_FULL_BASE_URL: https://${cfg.subdomain}.${config.numbus-server.services.domain}
DATASOURCES_DEFAULT_HOST: "passbolt-database"
DATASOURCES_DEFAULT_USERNAME: ${config.sops.placeholder."passbolt/db_username"}
DATASOURCES_DEFAULT_PASSWORD: ${config.sops.placeholder."passbolt/db_password"}
DATASOURCES_DEFAULT_DATABASE: ${config.sops.placeholder."passbolt/db_name"}
EMAIL_DEFAULT_FROM_NAME: "Passbolt"
EMAIL_TRANSPORT_DEFAULT_HOST: ${config.numbus-server.mail.smtpServer}
EMAIL_TRANSPORT_DEFAULT_PORT: ${toString config.numbus-server.mail.smtpPort}
EMAIL_TRANSPORT_DEFAULT_USERNAME: ${config.numbus-server.mail.smtpUsername}
EMAIL_TRANSPORT_DEFAULT_PASSWORD: ${config.sops.placeholder."mail/smtpPassword"}
EMAIL_TRANSPORT_DEFAULT_TLS: true
EMAIL_DEFAULT_FROM: passbolt-noreply@${config.numbus-server.services.domain}
PASSBOLT_SSL_FORCE: true
command:
[
"/usr/bin/wait-for.sh",
"-t",
"0",
"passbolt-database:3306",
"--",
"/docker-entrypoint.sh"
]
depends_on:
- passbolt-database
security_opt:
- no-new-privileges:true
cap_drop:
- NET_RAW
restart: unless-stopped
passbolt-database:
image: docker.io/library/mariadb:${databaseVersion}
container_name: passbolt-database
hostname: passbolt-database
user: '1000:1000'
networks:
passbolt:
volumes:
- ${cfg.configDir}/database:/var/lib/mysql
environment:
MYSQL_RANDOM_ROOT_PASSWORD: "true"
MYSQL_DATABASE: ${config.sops."passbolt/db_name"}
MYSQL_USER: ${config.sops."passbolt/db_username"}
MYSQL_PASSWORD: ${config.sops."passbolt/db_password"}
security_opt:
- no-new-privileges:true
cap_drop:
- NET_RAW
restart: unless-stopped
networks:
passbolt:
name: passbolt
driver: bridge
'';
}
@@ -0,0 +1,178 @@
{ config, pkgs, lib, ... }:
with lib;
let
# Container config
name = "traefik";
# Version tagging
traefikVersion = "v3.6.8";
# Helper
helper = import ../service-helper.nix { inherit config pkgs lib; };
cfg = config.numbus-server.services.traefik;
in
helper.mkPodmanService {
inherit name;
description = "Traefik reverse proxy, one to rule them all";
defaultPort = "7780";
pod = "false";
startDelay = 10;
dataDirEnabled = false;
middlewares = [
"secureHeaders"
];
dependencies = [
"sops-install-secrets.service"
"network-online.target"
];
dirPermissions = [
"100999:100 ${cfg.configDir}"
"100999:100 ${cfg.configDir}/certs"
];
# Compose file good
composeText = ''
services:
traefik:
image: docker.io/library/traefik:${traefikVersion}
container_name: traefik
hostname: traefik
user: '1000:1000'
network_mode: pasta
ports:
- "${cfg.port}:8080/tcp"
- "443:443/tcp"
volumes:
- ${config.sops.templates."traefik/config".path}:/etc/traefik/traefik.yaml:ro
- ${cfg.configDir}/certs:/var/traefik/certs
- /etc/traefik/rules:/etc/traefik/rules:ro
environment:
- CF_DNS_API_TOKEN=${config.sops.placeholder."traefik/cloudflare_api_token"}
cap_add:
- NET_BIND_SERVICE
security_opt:
- no-new-privileges:true
restart: unless-stopped
'';
extraConfig = {
sops.secrets."traefik/cloudflare_api_token" = {
sopsFile = /etc/nixos/secrets/podman/traefik.yaml;
gid = "100";
uid = "1000";
mode = "0400";
};
sops.templates."traefik/config"= {
gid = "100";
uid = "100999";
mode = "0400";
content = ''
global:
checkNewVersion: false
sendAnonymousUsage: false
log:
level: ${cfg.logLevel}
accesslog: {}
api:
dashboard: true
insecure: false
entryPoints:
web:
address: :80
http:
redirections:
entryPoint:
to: websecure
scheme: https
websecure:
address: :443
forwardedHeaders:
trustedIPs:
- "127.0.0.1/32"
- "10.0.0.0/8"
- "192.168.0.0/16"
- "172.16.0.0/12"
certificatesResolvers:
cloudflare:
acme:
email: ${config.numbus-server.mail.adminAddress}
storage: /var/traefik/certs/cloudflare-acme.json
caServer: "https://acme-v02.api.letsencrypt.org/directory"
dnsChallenge:
provider: cloudflare
resolvers:
- "1.1.1.1:53"
- "9.9.9.9:53"
serversTransport:
insecureSkipVerify: true
providers:
file:
directory: "/etc/traefik/rules"
watch: true
'';
path = "/etc/traefik/traefik.yaml";
};
sops.templates."traefik/rules/secureHeaders" = {
gid = "100";
uid = "100999";
mode = "0400";
content = ''
http:
middlewares:
secureHeaders:
headers:
FrameDeny: true
AccessControlAllowMethods: 'GET,OPTIONS,PUT'
AccessControlAllowOriginList:
- origin-list-or-null
AccessControlMaxAge: 100
AddVaryHeader: true
BrowserXssFilter: true
ContentTypeNosniff: true
ForceSTSHeader: true
STSIncludeSubdomains: true
STSPreload: true
ContentSecurityPolicy: default-src 'self' 'unsafe-inline'
CustomFrameOptionsValue: SAMEORIGIN
ReferrerPolicy: same-origin
PermissionsPolicy: vibrate 'self'
STSSeconds: 315360000
'';
path = "/etc/traefik/rules/secureHeaders.yaml";
};
sops.templates."traefik/rules/secureTLS" = {
gid = "100";
uid = "100999";
mode = "0400";
content = ''
tls:
options:
secureTLS:
minVersion: VersionTLS12
sniStrict: true
curvePreferences:
- CurveP521
- CurveP384
cipherSuites:
- TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256
- TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384
- TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256
'';
path = "/etc/traefik/rules/secureTLS.yaml";
};
};
extraOptions = {
enable.default = true;
logLevel = mkOption {
type = types.enum [ "TRACE" "DEBUG" "INFO" "WARN" "ERROR" "FATAL" ];
default = "ERROR";
example = "ERROR";
description = "The level of detail Traefik should print in the logs.";
};
};
}
@@ -0,0 +1,54 @@
{ config, pkgs, lib, ... }:
with lib;
let
# Container config
name = "uptimeKuma";
# Version tagging
uptimeKumaVersion = "2.2.0-rootless";
# Storage optimization
spindown = config.numbus-server.hardware.HddSpindown;
optimizedDir = if spindown.enable && (spindown.optimize == "compatible" || (isList spindown.optimize && elem name spindown.optimize))
then cfg.configDir
else cfg.dataDir;
# Helper
helper = import ../service-helper.nix { inherit config pkgs lib; };
cfg = config.numbus-server.services.uptimeKuma;
in
helper.mkPodmanService {
inherit name;
description = "Uptime-Kuma, \"don't let your server down !\" monitoring tools";
defaultPort = "3001";
scheme = "http";
middlewares = [ "secureHeaders" ];
dirPermissions = [ "100999:100 ${optimizedDir}" ];
composeText = ''
services:
uptimekuma:
image: docker.io/louislam/uptime-kuma:${uptimeKumaVersion}
container_name: uptime-kuma
hostname: uptime-kuma
user: '1000:1000'
networks:
uptime-kuma:
ipv4_address: 10.89.100.253
ports:
- "${cfg.port}:3001/tcp"
volumes:
- ${optimizedDir}:/app/data
security_opt:
- no-new-privileges:true
restart: unless-stopped
networks:
uptime-kuma:
driver: bridge
ipam:
config:
- subnet: "10.89.100.0/24"
gateway: "10.89.100.254"
'';
}
@@ -0,0 +1,81 @@
{ config, pkgs, lib, ... }:
with lib;
let
# Container config
name = "vscodium";
# Version tagging
vscodiumVersion = "1.110.11607-ls15";
# Storage optimization
spindown = config.numbus-server.hardware.HddSpindown;
optimizedDir = if spindown.enable && (spindown.optimize == "compatible" || (isList spindown.optimize && elem name spindown.optimize))
then cfg.configDir
else cfg.dataDir;
# Helper
helper = import ../service-helper.nix { inherit config pkgs lib; };
cfg = config.numbus-server.services.vscodium;
in
helper.mkPodmanService {
inherit name;
description = "VScodium, an open-source version of VScode in your web browser";
defaultPort = "8000";
configDirEnabled = optimizedDir == cfg.configDir;
dataDirEnabled = optimizedDir == cfg.dataDir;
middlewares = [ "secureHeaders" ];
dirPermissions = [
"100999:100 ${optimizedDir}"
"100999:100 ${cfg.configDir}"
"100999:100 ${optimizedDir}/workspace"
"100999:100 ${cfg.configDir}/config"
];
composeText = ''
services:
vscodium:
image: lscr.io/linuxserver/vscodium-web:${vscodiumVersion}
container_name: vscodium
hostname: vscodium
user: '1000:1000'
networks:
vscodium:
ipv4_address: 10.89.50.253
ports:
- "${defaultPort}:8000"
volumes:
- ${cfg.configDir}/config:/config
- ${optimizedDir}/workspace:/workspace
environment:
- PUID=1000
- PGID=1000
- TZ=${time.timeZone}
- CONNECTION_TOKEN=${config.sops.placeholder."vscodium/connection_token"}
shm_size: "1gb"
cap_add:
- IPC_LOCK
cap_drop:
- NET_RAW
security_opt:
- no-new-privileges:true
restart: unless-stopped
networks:
vscodium:
name: vscodium
driver: bridge
ipam:
config:
- subnet: "10.89.50.0/24"
gateway: "10.89.50.254"
'';
extraConfig = {
sops.secrets."vscodium/connection_token" = {
sopsFile = /etc/nixos/secrets/podman/vscodium.yaml;
gid = "100";
uid = "1000";
mode = "0400";
};
};
}
+53
View File
@@ -0,0 +1,53 @@
{ config, pkgs, lib, ... }:
with lib;
let
# Version tagging
adguardVersion = "latest";
# Helper
helper = import ../service-helper.nix { inherit config pkgs lib; };
cfg = config.numbus-server.services.adguard;
# Container config
name = "adguard";
in
helper.mkPodmanService {
inherit name;
description = "AdGuard, feature-rich DNS service";
defaultPort = "3000";
scheme = "http";
dataDirEnabled = false;
startDelay = 10;
dependencies = [
"network.target"
];
middlewares = [
"secureHeaders"
];
dirPermissions = [
"100999:100 ${cfg.configDir}"
];
composeText = ''
services:
adguardhome:
image: adguard/adguardhome:${adguardVersion}
container_name: adguard
hostname: adguard
network_mode: pasta
user: '1000:1000'
ports:
- "${cfg.port}:3000/tcp"
- "53:53/tcp"
- "53:53/udp"
volumes:
- ${cfg.configDir}/work:/opt/adguardhome/work
- ${cfg.configDir}/config:/opt/adguardhome/conf
cap_add:
- SYS_NICE
security_opt:
- no-new-privileges:true
restart: unless-stopped
'';
}
+10
View File
@@ -0,0 +1,10 @@
{ ... }:
{
imports = [
# To test
./adguard.nix
# Tested
./pi-hole.nix
];
}
+71
View File
@@ -0,0 +1,71 @@
{ config, pkgs, lib, ... }:
with lib;
let
# Version tagging
piholeVersion = "2026.02.0";
# Helper
helper = import ../service-helper.nix { inherit config pkgs lib; };
cfg = config.numbus-server.services.pi-hole;
# Container config
name = "pi-hole";
# DNS config
dnsConfig = ''
'';
in
helper.mkPodmanService {
inherit name;
description = "Pi-Hole, the ads black hole";
defaultPort = "4443";
scheme = "https";
dataDirEnabled = false;
startDelay = 10;
dependencies = [
"network.target"
];
middlewares = [
"secureHeaders"
];
dirPermissions = [
"100999:100 ${cfg.configDir}"
];
secrets = [
"pi-hole/web_password"
];
# Compose file good
composeText = ''
services:
pi-hole:
image: docker.io/pihole/pihole:${piholeVersion}
container_name: pi-hole
hostname: pi-hole
network_mode: pasta
ports:
- "${cfg.port}:443/tcp"
- "53:53/tcp"
- "53:53/udp"
volumes:
- ${cfg.configDir}:/etc/pihole
environment:
PIHOLE_UID: '1000'
PIHOLE_GID: '1000'
TZ: ${config.time.timeZone}
FTLCONF_webserver_domain: ${cfg.subdomain}.${config.numbus-server.services.domain}
FTLCONF_dns_domain_name: "${config.numbus-server.services.domain}"
FTLCONF_webserver_api_password: ${config.sops.placeholder."pi-hole/web_password"}
FTLCONF_dns_upstreams: 9.9.9.9;149.112.112.112
FTLCONF_dns_listeningMode: "BIND"
FTLCONF_dns_domain_local: "true"
FTLCONF_dhcp_active: "false"
FTLCONF_ntp_ipv4_active: "false"
FTLCONF_ntp_ipv6_active: "false"
FTLCONF_ntp_sync_active: "false"
cap_add:
- SYS_NICE
restart: unless-stopped
'';
}
+91
View File
@@ -0,0 +1,91 @@
{ config, lib, pkgs, ... }:
with lib;
let
cfg = config.numbus-server.services.clamav;
onAccessPaths = lib.mapAttrsToList (n: v: v.dataDir) (lib.filterAttrs (n: v:
v ? enable && v.enable && v ? dataDir && v.dataDir != null && v.dataDir != false
) config.numbus-server.services);
clamonacc_virus_notifier = pkgs.writeScript "clamonacc_virus_notifier.sh" ''
#!${pkgs.bash}/bin/bash
echo "CLAM_VIRUSEVENT_VIRUSNAME=\"$CLAM_VIRUSEVENT_VIRUSNAME\"" > /var/lib/clamav/virus_event.env
echo "CLAM_VIRUSEVENT_FILENAME=\"$CLAM_VIRUSEVENT_FILENAME\"" >> /var/lib/clamav/virus_event.env
/run/wrappers/bin/sudo /run/current-system/sw/bin/systemctl start clamav-virus-notify.service
'';
in
{
options.numbus-server.services.clamav = {
enable = mkEnableOption "ClamAV open-source anti-virus software";
};
config = mkIf cfg.enable {
environment.systemPackages = [ pkgs.clamav pkgs.curl ];
system.activationScripts.clamav-quarantine = ''
mkdir -p /quarantine
chown clamav:clamav /quarantine
chmod 440 /quarantine
'';
security.sudo.extraRules = [{
users = [ "clamav" ];
commands = [{
command = "/run/current-system/sw/bin/systemctl start clamav-virus-notify.service";
options = [ "NOPASSWD" ];
}];
}];
services.clamav = {
updater.enable = true;
clamonacc.enable = true;
scanner = {
enable = true;
interval = "*-*-* 04:00:00"; # Everyday at 4am
scanDirectories = [
"/etc"
"/home"
"/var/lib"
"/var/tmp"
"/tmp"
];
};
daemon = {
enable = true;
settings = {
OnAccessPrevention = true;
OnAccessIncludePath = onAccessPaths;
VirusEvent = "${clamonacc_virus_notifier}";
};
};
};
systemd.services.clamav-periodic-scan = mkIf (onAccessPaths != []) {
description = "Periodic ClamAV virus scan";
after = [ "clamav-daemon.service" "clamav-freshclam.service" ];
requires = [ "clamav-daemon.service" ];
wants = [ "clamav-freshclam.service" ];
onFailure = [ "clamav-virus-notify.service" ];
serviceConfig = {
Type = "oneshot";
ExecStart = "${pkgs.clamav}/bin/clamdscan --multiscan --fdpass --infected --allmatch --move=/quarantine ${lib.escapeShellArgs onAccessPaths}";
Slice = "system-clamav.slice";
};
};
systemd.timers.clamav-periodic-scan = mkIf (onAccessPaths != []) {
description = "Timer for ClamAV periodic scan";
wantedBy = [ "timers.target" ];
timerConfig = {
OnCalendar = "*-1/3-01 04:00:00";
Persistent = true;
Unit = "clamav-periodic-scan.service";
};
};
};
}
@@ -0,0 +1,11 @@
{ ... }:
{
imports = [
# To add
# ./backup-client.nix
# To test
./clamav.nix
./virtualization.nix
];
}
@@ -0,0 +1,17 @@
{ config, lib, ... }:
with lib;
let
cfg = config.numbus-server.services.virtualization;
in
{
options.numbus-server.services.virtualization = {
enable = mkEnableOption "QEMU/KVM virtualization software";
};
config = mkIf cfg.enable {
virtualisation.libvirtd.enable = true;
};
}
+12
View File
@@ -0,0 +1,12 @@
{ ... }:
{
imports = [
# To test
./hardware/default.nix
./mail/default.nix
./misc/default.nix
./packages/default.nix
./global.nix
];
}
+42
View File
@@ -0,0 +1,42 @@
{ lib, deviceType, ... }:
with lib;
let
cfg = config.numbus;
country = "";
language = "";
in
{
options.numbus = {
owner = mkOption {
type = types.str;
example = "Alex";
default = "Numbus";
description = "The name of the person who owns this ${deviceType}.";
};
internationalization = {
country = mkOption {
type = types.str;
example = "FR";
default = country;
description = "The country where this ${deviceType} is located.";
};
language = mkOption {
type = types.str;
example = "fr";
default = language;
description = "The language for this ${deviceType}.";
};
locale = mkOption {
type = types.str;
example = "fr_FR";
default = "fr_FR";
description = "The locale for this ${deviceType}.";
};
};
};
}
+31
View File
@@ -0,0 +1,31 @@
{ config, deviceType, ... }:
{
config = mkMerge [
({
boot = {
plymouth.enable = true;
# Enable "Silent boot"
consoleLogLevel = 3;
initrd.verbose = false;
loader.timeout = 1;
};
})
( mkIf (deviceType == "computer" || deviceType == "tv") {
# Bootloader options
boot = {
initrd.systemd.enable = true;
loader.systemd-boot.enable = true;
loader.efi.canTouchEfiVariables = true;
kernelParams = [
"quiet"
"udev.log_level=3"
"systemd.show_status=auto"
"pcie_aspm=force"
"consoleblank=60"
];
};
})
];
}
+9
View File
@@ -0,0 +1,9 @@
{ config, ... }:
{
config = {
hardware.enableRedistributableFirmware = true;
hardware.cpu.intel.updateMicrocode = true;
hardware.cpu.amd.updateMicrocode = true;
};
}
+11
View File
@@ -0,0 +1,11 @@
{ ... }:
{
imports = [
# To test
./disks/default.nix
./boot-params.nix
./cpu.nix
./graphics.nix
];
}
+192
View File
@@ -0,0 +1,192 @@
{ config, lib, ... }:
with lib;
let
cfg = config.numbus.hardware.disks.boot;
bootCount = builtins.length cfg.list;
singleDiskConfig = {
disko.devices.disk.main = {
type = "disk";
device = head cfg.list;
content = {
type = cfg.partitionTableScheme;
partitions = {
ESP = {
size = cfg.partition.boot.size;
type = cfg.partition.boot.esp;
content = {
type = "filesystem";
format = cfg.partition.boot.filesystem;
mountpoint = "/boot";
mountOptions = [ "umask=0077" ];
};
};
swap = {
size = cfg.partition.swap.size;
content = {
type = "swap";
randomEncryption = cfg.partition.swap.encrypt;
};
};
luks = {
size = cfg.partition.root.size;
content = {
type = "luks";
name = "boot";
settings.keyFile = "/run/secrets/disks/boot";
content = {
type = "filesystem";
format = cfg.partition.root.filesystem;
mountpoint = "/";
};
};
};
};
};
};
};
raid1DiskConfig = {
disko.devices.disk = lib.listToAttrs (lib.imap0 (i: device: {
name = "boot-${toString i}";
value = {
type = "disk";
inherit device;
content = {
type = cfg.partitionTableScheme;
partitions = {
ESP = { size = cfg.partition.boot.size; type = cfg.partition.boot.esp; content = { type = "mdraid"; name = "boot"; }; };
swap = { size = cfg.partition.swap.size; content = { type = "mdraid"; name = "swap"; }; };
mdadm = { size = cfg.partition.root.size; content = { type = "mdraid"; name = "raid1"; }; };
};
};
};
}) cfg.list);
disko.devices.mdadm = {
boot = {
type = "mdadm";
level = 1;
metadata = "1.0";
content = {
type = "filesystem";
format = cfg.partition.boot.filesystem;
mountpoint = "/boot";
mountOptions = [ "umask=0077" ];
};
};
swap = {
type = "mdadm";
level = 1;
content = {
type = "swap";
randomEncryption = cfg.partition.swap.encrypt;
};
};
raid1 = {
type = "mdadm";
level = 1;
content = {
type = "luks";
name = "boot";
settings.keyFile = "/run/secrets/disks/boot";
content = {
type = "filesystem";
format = cfg.partition.root.filesystem;
mountpoint = "/";
};
};
};
};
};
in
{
options.numbus.hardware.disks = {
boot = {
list = mkOption {
type = types.listOf types.str;
example = [ "/dev/disk/by-id/nvme_SAMSUNG_MZVPYEHCO_159Ejz224G0000" "/dev/disk/by-id/ata-San_Disk_159Ejz224G" ];
description = "A set of by-id path of disk(s) that will be used as boot disk(s). At least one disk must be set.";
};
partitionTableScheme = mkOption {
type = types.enum [ "gpt" "mbr" ];
default = "gpt";
example = "gpt";
description = "The scheme of the partition table. Use \"gpt\" for modern devices and \"mbr\" for legacy ones.";
};
partition = {
root = {
filesystem = mkOption {
type = types.enum [ "ext4" "btrfs" "xfs" ];
default = "ext4";
example = "ext4";
description = "The filesystem to use for the root partition of the boot disk(s).";
};
size = mkOption {
type = types.str;
default = "100%";
example = "100%";
description = "The size of the root partition. Use G for GBs and M for MBs.";
};
};
boot = {
filesystem = mkOption {
type = types.enum [ "vfat" ];
default = "vfat";
example = "vfat";
description = "The filesystem to use for the boot partition of the boot disk(s).";
};
esp = mkOption {
type = types.enum [ "EF00" "EF02" ];
default = "EF00";
example = "EF00";
description = "The ESP type to use for the boot partition. Use EF02 for UEFI and EF00 for BIOS.";
};
size = mkOption {
type = types.str;
default = "1G";
example = "1G";
description = "The size of the boot partition.";
};
};
swap = {
enable = mkOption {
type = types.bool;
default = true;
example = true;
description = "Wether to create a swap partition. Useful for servers that don't have a lot of RAM.";
};
encrypt = mkOption {
type = types.bool;
default = true;
example = true;
description = "Wether to encrypt randomly the swap partition. Disable if you need hibernation";
};
size = mkOption {
type = types.str;
default = "16G";
example = "16G";
description = "Size of the swap partition. Use G for GBs and M for MBs.";
};
};
};
};
};
config = mkMerge [
{
sops.secrets."disks/boot" = {
sopsFile = "/etc/nixos/secrets/disks/boot.yaml";
gid = "0";
uid = "0";
mode = "0400";
};
}
(mkIf (bootCount == 1) singleDiskConfig)
(mkIf (bootCount == 2) raid1DiskConfig)
];
}
+107
View File
@@ -0,0 +1,107 @@
{ config, lib, pkgs, ... }:
with lib;
let
cfg = config.numbus.hardware.disks.content;
contentCount = builtins.length cfg.list;
parityCount = builtins.length config.numbus.hardware.disks.parity.list;
contentDisks = lib.imap0 (i: device: {
name = "content-${toString i}";
value = {
type = "disk";
inherit device;
content = {
type = cfg.partitionTableScheme;
partitions.luks = {
size = cfg.partition.size;
content = {
type = "luks";
name = "content-${toString i}";
settings.keyFile = "/run/secrets/disks/content-${toString i}";
initrdUnlock = false;
content = {
type = "filesystem";
format = cfg.partition.filesystem;
mountpoint = "/mnt/content-${toString i}";
mountOptions = [ "noauto" "nofail" ];
};
};
};
};
};
}) cfg.list;
in
{
options.numbus.hardware.disks = {
content = {
list = mkOption {
type = types.listOf types.str;
example = [ "/dev/disk/by-id/ata_Hitachi_MZVPYEHCO_159Ejz224G0000" "/dev/disk/by-id/ata-WD_159Ejz224G" ];
default = [];
description = "A set of by-id path of disk(s) that will be used as content disk(s).";
};
partitionTableScheme = mkOption {
type = types.enum [ "gpt" "mbr" ];
default = "gpt";
example = "gpt";
description = "The scheme of the partition table. Use \"gpt\" for modern devices and \"mbr\" for legacy ones.";
};
partition = {
filesystem = mkOption {
type = types.enum [ "ext4" "btrfs" "xfs" ];
default = "xfs";
example = "xfs";
description = "The filesystem to use for the main partition of the content disk(s).";
};
size = mkOption {
type = types.str;
default = "100%";
example = "100%";
description = "The size of the main partition. Use G for GBs and M for MBs.";
};
};
};
};
config = mkIf (contentCount > 0 && (parityCount != 1 && contentCount != 1)) {
disko.devices.disk = builtins.listToAttrs contentDisks;
sops.secrets = listToAttrs (map (i:
nameValuePair "disks/content-${toString i}" {
sopsFile = "/etc/nixos/secrets/disks/content.yaml";
gid = "0";
uid = "0";
mode = "0400";
}
) (range 0 (contentCount - 1)));
systemd.services.mount-content-disks = {
description = "Mount content disks.";
before = [ "mnt-data.mount" ];
requiredBy = [ "mnt-data.mount" ];
requires = [ "sops-install-secrets.service" ];
path = [ pkgs.cryptsetup pkgs.util-linux ];
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
};
script = let
mountContentDisk = i: ''
if [ ! -e /dev/mapper/content-${toString i} ]; then
cryptsetup luksOpen --key-file /run/secrets/disks/content-${toString i} /dev/disk/by-partlabel/disk-content-${toString i}-luks content-${toString i}
fi
mkdir -p /mnt/content-${toString i}
if ! mountpoint -q /mnt/content-${toString i}; then
mount -t ${cfg.partition.filesystem} /dev/mapper/content-${toString i} /mnt/content-${toString i}
fi
'';
in ''
${concatMapStrings mountContentDisk (range 0 (contentCount - 1))}
'';
};
};
}
+12
View File
@@ -0,0 +1,12 @@
{ ... }:
{
imports = [
./boot.nix
./content.nix
./mergerfs-snapraid.nix
./mirror.nix
./parity.nix
./spindown.nix
];
}
@@ -0,0 +1,41 @@
{ config, lib, ... }:
with lib;
let
cfg = config.numbus.hardware.disks;
contentCount = builtins.length cfg.content.list;
parityCount = builtins.length cfg.parity.list;
in
{
config = mkIf (contentCount >= 2 && parityCount >= 1) {
services.snapraid = {
enable = true;
contentFiles = map (i: "/mnt/content-${toString i}/snapraid.content") (range 0 (contentCount - 1));
parityFiles = map (i: "/mnt/parity-${toString i}/snapraid.parity") (range 0 (parityCount - 1));
dataDisks = listToAttrs (imap0 (i: _: nameValuePair "d${toString i}" "/mnt/content-${toString i}") cfg.content.list);
};
fileSystems."/mnt/data" = {
device = concatStringsSep ":" (map (i: "/mnt/content-${toString i}") (range 0 (contentCount - 1)));
fsType = "fuse.mergerfs";
options = [
"category.create=ff"
"cache.files=partial"
"dropcacheonclose=true"
"defaults"
"noauto"
"nofail"
"allow_other"
"moveonenospc=1"
"minfreespace=50G"
"func.getattr=newest"
"fsname=mergerfs_data"
"x-mount.mkdir"
"x-systemd.automount"
];
};
};
}
+75
View File
@@ -0,0 +1,75 @@
{ config, lib, pkgs, ... }:
with lib;
let
cfg = config.numbus.hardware.disks;
contentCount = builtins.length cfg.content.list;
parityCount = builtins.length cfg.parity.list;
dataMirror = {
disko.devices.disk = listToAttrs (imap0 (i: device: {
name = "mirror-${toString i}";
value = {
type = "disk";
inherit device;
content = {
type = cfg.partitionTableScheme;
partitions.raid = {
size = cfg.content.partition.size;
content = {
type = "mdraid";
name = "mirror";
};
};
};
};
}) (cfg.content.list ++ cfg.parity.list));
disko.devices.mdadm.mirror = {
type = "mdadm";
level = 1;
content = {
type = "luks";
name = "mirror";
settings.keyFile = "/run/secrets/disks/mirror";
initrdUnlock = false;
content = {
type = "filesystem";
format = cfg.content.partition.filesystem;
mountpoint = "/mnt/data";
mountOptions = [ "noauto" "nofail" ];
};
};
};
};
in
{
config = mkIf (contentCount == 1 && parityCount == 1) (mkMerge [
dataMirror
{
systemd.services.mount-mirror = {
description = "Mount the disks mirror.";
before = [ "mnt-data.mount" ];
requiredBy = [ "mnt-data.mount" ];
requires = [ "sops-install-secrets.service" ];
path = [ pkgs.cryptsetup pkgs.util-linux ];
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
};
script = ''
if [ ! -e /dev/mapper/mirror ]; then
cryptsetup open /dev/md/mirror mirror --key-file /run/secrets/disks/mirror
fi
mkdir -p /mnt/data
if ! mountpoint -q /mnt/data; then
mount -t ${cfg.content.partition.filesystem} /dev/mapper/mirror /mnt/data
fi
'';
};
}
]);
}
+107
View File
@@ -0,0 +1,107 @@
{ config, lib, pkgs, ... }:
with lib;
let
cfg = config.numbus.hardware.disks.parity;
parityCount = builtins.length cfg.list;
parityDisks = lib.imap0 (i: device: {
name = "parity-${toString i}";
value = {
type = "disk";
inherit device;
content = {
type = cfg.partitionTableScheme;
partitions.luks = {
size = cfg.partition.size;
content = {
type = "luks";
name = "parity-${toString i}";
settings.keyFile = "/run/secrets/disks/parity-${toString i}";
initrdUnlock = false;
content = {
type = "filesystem";
format = cfg.partition.filesystem;
mountpoint = "/mnt/parity-${toString i}";
mountOptions = [ "noauto" "nofail" ];
};
};
};
};
};
}) cfg.list;
in
{
options.numbus.hardware.disks = {
parity = {
list = mkOption {
type = types.listOf types.str;
example = [ "/dev/disk/by-id/ata_WDC_MZVPYEHCO_159Ejz224G0000" "/dev/disk/by-id/ata-San_Disk_159Ejz224G" ];
default = [];
description = "A set of by-id path of disk(s) that will be used as parity disk(s).";
};
partitionTableScheme = mkOption {
type = types.enum [ "gpt" "mbr" ];
default = "gpt";
example = "gpt";
description = "The scheme of the partition table. Use \"gpt\" for modern devices and \"mbr\" for legacy ones.";
};
partition = {
filesystem = mkOption {
type = types.enum [ "ext4" "btrfs" "xfs" ];
default = "xfs";
example = "xfs";
description = "The filesystem to use for the main partition of the parity disk(s).";
};
size = mkOption {
type = types.str;
default = "100%";
example = "100%";
description = "The size of the main partition. Use G for GBs and M for MBs.";
};
};
};
};
config = mkIf (parityCount > 1) {
disko.devices.disk = builtins.listToAttrs parityDisks;
sops.secrets = listToAttrs (map (i:
nameValuePair "disks/parity-${toString i}" {
sopsFile = "/etc/nixos/secrets/disks/parity.yaml";
gid = "0";
uid = "0";
mode = "0400";
}
) (range 0 (parityCount - 1)));
systemd.services.mount-parity-disks = {
description = "Mount parity disks.";
before = [ "mnt-data.mount" ];
requiredBy = [ "mnt-data.mount" ];
requires = [ "sops-install-secrets.service" ];
path = [ pkgs.cryptsetup pkgs.util-linux ];
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
};
script = let
mountparityDisk = i: ''
if [ ! -e /dev/mapper/parity-${toString i} ]; then
cryptsetup luksOpen --key-file /run/secrets/disks/parity-${toString i} /dev/disk/by-partlabel/disk-parity-${toString i}-luks parity-${toString i}
fi
mkdir -p /mnt/parity-${toString i}
if ! mountpoint -q /mnt/parity-${toString i}; then
mount -t ${cfg.partition.filesystem} /dev/mapper/parity-${toString i} /mnt/parity-${toString i}
fi
'';
in
''
${concatMapStrings mountparityDisk (range 0 (parityCount - 1))}
'';
};
};
}
@@ -0,0 +1,46 @@
{ config, lib, ... }:
with lib;
let
hardDrives = config.numbus.hardware.spindown.list;
cfg = config.numbus.hardware;
in
{
config = mkIf (cfg.HddSpindown.enable == true) {
systemd.services.hd-idle = {
description = "External HD spin down daemon";
wantedBy = [ "multi-user.target" ];
serviceConfig = {
Type = "simple";
ExecStart =
let
idleTime = toString 1800;
hardDriveParameter = lib.strings.concatMapStringsSep " " (x: "-a ${x} -i ${idleTime}") hardDrives;
in
"${pkgs.hd-idle}/bin/hd-idle -i 0 ${hardDriveParameter}";
};
};
};
options.numbus = {
hardware = {
spindown = {
enable = mkEnableOption "hard drives spin down when inactive in order to save power.";
list = mkOption {
description = "The list of compatible hard drives that will spin down.";
type = types.listOf types.str;
default = [];
example = [ "/dev/disk/by-id/ata_Hitachi_MZVPYEHCO_159Ejz224G0000" "/dev/disk/by-id/ata-WD_159Ejz224G" ];
};
optimize = mkOption {
description = "Optimize services to reduce HDD wakeups when spindown is enabled. Can be set to \"compatible\" to optimize all compatible services, or a list of service names to optimize.";
type = types.nullOr (types.either (types.enum [ "compatible" ]) (types.listOf types.str));
default = "compatible";
example = "[ \"crafty\" \"gitea\" ]";
};
};
};
};
}
+53
View File
@@ -0,0 +1,53 @@
{ config, lib, pkgs, ... }:
let
cfg = config.numbus.hardware;
in
{
options.numbus.hardware = {
nvidia = {
enable = mkEnableOption "Wether to install the NVIDIA driver. Required for better performance with NVIDIA graphics cards."
}
}
config = mkMerge [
({
# Enable OpenGL
hardware.graphics = {
enable = true;
};
})
( mkIf (cfg.nvidia.enable == true) {
# Load nvidia driver for Xorg and Wayland
services.xserver.videoDrivers = [ "nvidia" ];
hardware.nvidia = {
# Modesetting is required.
modesetting.enable = true;
# Nvidia power management. Experimental, and can cause sleep/suspend to fail.
# Enable this if you have graphical corruption issues or application crashes after waking
# up from sleep. This fixes it by saving the entire VRAM memory to /tmp/ instead
# of just the bare essentials.
powerManagement.enable = false;
# Fine-grained power management. Turns off GPU when not in use.
# Experimental and only works on modern Nvidia GPUs (Turing or newer).
powerManagement.finegrained = false;
# Use the NVidia open source kernel module (not to be confused with the
# independent third-party "nouveau" open source driver).
# Support is limited to the Turing and later architectures. Full list of
# supported GPUs is at:
# https://github.com/NVIDIA/open-gpu-kernel-modules#compatible-gpus
# Only available from driver 515.43.04+
open = false;
nvidiaSettings = true;
# Optionally, you may need to select the appropriate driver version for your specific GPU.
package = config.boot.kernelPackages.nvidiaPackages.stable;
};
})
];
}
+111
View File
@@ -0,0 +1,111 @@
{ config, lib, pkgs, ... }:
let
cfg = config.numbus.hardware.pcie-coral;
gasket-driver = { stdenv, lib, fetchFromGitHub, kernel }: stdenv.mkDerivation rec {
pname = "gasket";
version = "1.0-18";
src = fetchFromGitHub {
owner = "google";
repo = "gasket-driver";
rev = "97aeba584efd18983850c36dcf7384b0185284b3";
sha256 = "pJwrrI7jVKFts4+bl2xmPIAD01VKFta2SRuElerQnTo=";
};
makeFlags = [
"-C"
"${kernel.dev}/lib/modules/${kernel.modDirVersion}/build"
"M=$(PWD)"
];
buildFlags = [ "modules" ];
installFlags = [ "INSTALL_MOD_PATH=${placeholder "out"}" ];
installTargets = [ "modules_install" ];
sourceRoot = "source/src";
hardeningDisable = [ "pic" "format" ];
nativeBuildInputs = kernel.moduleBuildDependencies;
meta = with lib; {
description = "The Coral Gasket Driver allows usage of the Coral EdgeTPU on Linux systems.";
homepage = "https://github.com/google/gasket-driver";
license = licenses.gpl2;
maintainers = [ maintainers.kylehendricks ];
platforms = platforms.linux;
};
};
libedgetpu-pkg = { stdenv, lib, fetchFromGitHub, libusb1, abseil-cpp, flatbuffers, xxd }:
let
flatbuffers_1_12 = flatbuffers.overrideAttrs (oldAttrs: rec {
version = "1.12.0";
NIX_CFLAGS_COMPILE = "-Wno-error=class-memaccess -Wno-error=maybe-uninitialized";
cmakeFlags = (oldAttrs.cmakeFlags or []) ++ ["-DFLATBUFFERS_BUILD_SHAREDLIB=ON"];
NIX_CXXSTDLIB_COMPILE = "-std=c++17";
configureFlags = (oldAttrs.configureFlags or []) ++ ["--enable-shared"];
src = fetchFromGitHub {
owner = "google";
repo = "flatbuffers";
rev = "v${version}";
sha256 = "sha256-L1B5Y/c897Jg9fGwT2J3+vaXsZ+lfXnskp8Gto1p/Tg=";
};
});
in stdenv.mkDerivation rec {
pname = "libedgetpu";
version = "grouper";
src = fetchFromGitHub {
owner = "google-coral";
repo = pname;
rev = "release-${version}";
sha256 = "sha256-73hwItimf88Iqnb40lk4ul/PzmCNIfdt6Afi+xjNiBE=";
};
makeFlags = ["-f" "makefile_build/Makefile" "libedgetpu" ];
buildInputs = [
libusb1
abseil-cpp
flatbuffers_1_12
];
nativeBuildInputs = [
xxd
];
NIX_CXXSTDLIB_COMPILE = "-std=c++17";
TFROOT = "${fetchFromGitHub {
owner = "tensorflow";
repo = "tensorflow";
rev = "v2.7.4";
sha256 = "sha256-liDbUAdaVllB0b74aBeqNxkYNu/zPy7k3CevzRF5dk0=";
}}";
enableParallelBuilding = false;
installPhase = ''
mkdir -p $out/lib
cp out/direct/k8/libedgetpu.so.1.0 $out/lib
ln -s $out/lib/libedgetpu.so.1.0 $out/lib/libedgetpu.so.1
mkdir -p $out/lib/udev/rules.d
cp debian/edgetpu-accelerator.rules $out/lib/udev/rules.d/99-edgetpu-accelerator.rules
'';
};
gasket = config.boot.kernelPackages.callPackage gasket-driver {};
libedgetpu = pkgs.callPackage libedgetpu-pkg {};
in
{
options.numbus.hardware.pcie-coral = lib.mkEnableOption "PCIe Coral TPU support";
config = lib.mkIf cfg.enable {
services.udev.packages = [ "libedgetpu" ];
users.groups.plugdev = {};
boot.extraModulePackages = [ "gasket" ];
};
}
+89
View File
@@ -0,0 +1,89 @@
{ config, lib, pkgs, ... }:
with lib;
let
cfg = config.numbus-server.services.clamav;
clamav_notifier = pkgs.writeScript "clamav-notify.sh" ''
#!${pkgs.bash}/bin/bash
# Check if triggered by Real-time event (file exists)
if [ -f /var/lib/clamav/virus_event.env ]; then
source /var/lib/clamav/virus_event.env
rm /var/lib/clamav/virus_event.env
fi
ADMIN_EMAIL="${config.numbus-server.mail.adminAddress}"
USER_EMAIL="${config.numbus-server.mail.userAddress}"
OWNER_NAME="${config.numbus-server.owner}"
if [ -n "$CLAM_VIRUSEVENT_VIRUSNAME" ]; then
# --- Real-time / VirusEvent Mode ---
SUBJECT="Numbus Server Alert: Virus Detected (Real-time)"
# Retrieve logs from clamav-daemon
LOGS=$(journalctl -u clamav-daemon.service -n 50 --no-pager | grep "FOUND")
TECH_BODY="
ClamAV Real-time Alert:
Server owner: $OWNER_NAME
Virus detected: $CLAM_VIRUSEVENT_VIRUSNAME
File: $CLAM_VIRUSEVENT_FILENAME
Logs:
$LOGS
Action taken: Access blocked (OnAccessPrevention).
Please investigate manually.
"
FRIENDLY_BODY="Cher/Chère $OWNER_NAME,
L'antivirus de votre serveur a détecté et bloqué une menace en temps réel.
Fichier : $CLAM_VIRUSEVENT_FILENAME
Votre administrateur a été notifié.
"
else
# --- Scheduled Scan Summary Mode ---
SUBJECT="Numbus Server Alert: Virus Detected during Scheduled Scan"
# Retrieve logs (clamdscan prints FOUND when a virus is detected)
LOGS=$(journalctl -u clamav-periodic-scan.service -n 100 --no-pager | grep "FOUND")
TECH_BODY="
ClamAV Scan Alert:
Server owner: $OWNER_NAME
Viruses detected:
$LOGS
Action taken: Detection only.
Please investigate manually.
"
FRIENDLY_BODY="Cher/Chère $OWNER_NAME,
L'antivirus de votre serveur a détecté une menace potentielle lors de l'analyse périodique.
Votre administrateur a été notifié avec les détails techniques.
Nous vous conseillons d'être prudent avec vos fichiers récents.
"
fi
printf "Subject: [ADMIN] %s\n\n%s" "$SUBJECT" "$TECH_BODY" | /run/wrappers/bin/sendmail -t "$ADMIN_EMAIL"
printf "Subject: [Alerte] Menace détectée sur votre serveur Numbus\n\n%s\n\nMerci de votre confiance,\nL'équipe de support,\nNumbus-Server." "$FRIENDLY_BODY" | /run/wrappers/bin/sendmail -t "$USER_EMAIL"
'';
in
{
config = mkIf cfg.enable {
systemd.services.clamav-virus-notify = {
description = "Email notification for ClamAV virus detection";
serviceConfig = {
Type = "oneshot";
ExecStart = "${clamav_notifier}";
};
};
};
}
+10
View File
@@ -0,0 +1,10 @@
{ ... }:
{
imports = [
# To test
./disk-space.nix
./smart.nix
./smtp.nix
];
}
+130
View File
@@ -0,0 +1,130 @@
{ config, lib, pkgs, ... }:
with lib;
let
cfg = config.numbus-server.services.disk-space-checker;
disk_space_notifier = pkgs.writeScript "disk-space-notifier.sh" ''
#!${pkgs.bash}/bin/bash
ALERT_FILE="/var/lib/numbus-server/disk_alert.env"
if [ ! -f "$ALERT_FILE" ]; then
exit 0
fi
source "$ALERT_FILE"
rm "$ALERT_FILE"
# Update the timestamp for this specific path to prevent spamming
SAFE_PATH=$(echo "$DISK_ALERT_PATH" | tr '/' '_')
date +%s > "/var/lib/numbus-server/last_alert_$SAFE_PATH.ts"
ADMIN_EMAIL="${config.numbus-server.mail.adminAddress}"
USER_EMAIL="${config.numbus-server.mail.userAddress}"
OWNER_NAME="${config.numbus-server.owner}"
SUBJECT="Numbus Server Alert: Low Disk Space Detected"
TECH_BODY="
Disk Space Alert:
Server owner: $OWNER_NAME
The following mount point has exceeded the safety threshold:
Mount: $DISK_ALERT_PATH
Usage: $DISK_ALERT_USAGE%
Full partition details:
$(df -h "$DISK_ALERT_PATH")
Action required: Please investigate and clear space or expand the storage capacity.
"
FRIENDLY_BODY="Cher/Chère $OWNER_NAME,
L'espace de stockage de votre serveur Numbus est presque saturé.
Disque concerné : $DISK_ALERT_PATH ($DISK_ALERT_USAGE% utilisé)
Votre administrateur a été notifié avec les détails techniques.
Nous vous conseillons d'éviter d'ajouter des fichiers volumineux pour garantir le bon fonctionnement de vos services.
Contactez votre administrateur afin d'évoquer les possibilités d'expansion du stockage.
"
printf "Subject: [ADMIN] %s\n\n%s" "$SUBJECT" "$TECH_BODY" | /run/wrappers/bin/sendmail -t "$ADMIN_EMAIL"
printf "Subject: [Alerte] Espace disque presque saturé sur votre serveur Numbus\n\n%s\n\nMerci de votre confiance,\nL'équipe de support,\nNumbus-Server." "$FRIENDLY_BODY" | /run/wrappers/bin/sendmail -t "$USER_EMAIL"
'';
disk_space_checker = pkgs.writeScript "disk-space-checker.sh" ''
#!${pkgs.bash}/bin/bash
# Safety threshold in percentage
THRESHOLD=90
# Paths to monitor (Root and MergerFS data pool)
PATHS=("/" "/mnt/data")
ALERT_FILE="/var/lib/numbus-server/disk_alert.env"
for path in "''${PATHS[@]}"; do
# Skip if path does not exist (e.g. if mergerfs is not mounted yet)
if [ ! -d "$path" ]; then
continue
fi
# Anti-spam logic: Check if we alerted on this path recently (7 days = 604800 seconds)
SAFE_PATH=$(echo "$path" | tr '/' '_')
TS_FILE="/var/lib/numbus-server/last_alert_$SAFE_PATH.ts"
NOW=$(date +%s)
if [ -f "$TS_FILE" ]; then
LAST_SENT=$(cat "$TS_FILE")
DIFF=$((NOW - LAST_SENT))
if [ "$DIFF" -lt 604800 ]; then
echo "Alert for $path was sent recently. Skipping notification to avoid spam."
continue
fi
fi
# Extract usage percentage using df
USAGE=$(df -h "$path" | awk 'NR==2 {print $5}' | sed 's/%//')
if [ "$USAGE" -ge "$THRESHOLD" ]; then
echo "DISK_ALERT_PATH=$path" > "$ALERT_FILE"
echo "DISK_ALERT_USAGE=$USAGE" >> "$ALERT_FILE"
echo "Threshold exceeded for $path ($USAGE%). Triggering notification."
# Trigger the notification service
/run/current-system/sw/bin/systemctl start disk-space-notifier.service
# We exit after the first alert to avoid multiple overlapping emails in one run
exit 0
fi
done
'';
in
{
config = mkIf cfg.enable {
systemd.services.disk-space-notifier = {
description = "Email notification for low disk space";
serviceConfig = {
Type = "oneshot";
ExecStart = "${disk_space_notifier}";
};
};
systemd.services.disk-space-checker = {
description = "Check for low disk space";
serviceConfig = {
Type = "oneshot";
ExecStart = "${disk_space_checker}";
};
};
systemd.timers.disk-space-checker = {
description = "Run disk space check every day";
timerConfig = {
OnCalendar = "daily";
Persistent = true;
};
wantedBy = [ "timers.target" ];
};
};
}
+61
View File
@@ -0,0 +1,61 @@
{ config, pkgs, ... }:
let
smartd_notifier = pkgs.writeScript "smartd-notify.sh" ''
#!${pkgs.bash}/bin/bash
# 1. Send Technical Email to Admin
ADMIN_EMAIL="${config.numbus-server.mail.adminAddress}"
SUBJECT="Numbus Server Alert: $SMARTD_FAILTYPE on $SMARTD_DEVICE"
TECH_BODY="
SMARTD Alert Details:
Server owner: $OWNER_NAME
Device: $SMARTD_DEVICE
Type: $SMARTD_DEVICETYPE
Failure Type: $SMARTD_FAILTYPE
Message: $SMARTD_MESSAGE
Full Message:
$SMARTD_FULLMESSAGE
"
printf "Subject: [ADMIN] $SUBJECT\n\n$TECH_BODY" | /run/wrappers/bin/sendmail -t "$ADMIN_EMAIL"
# 2. Send Friendly Email to Owner
USER_EMAIL="${config.numbus-server.mail.userAddress}"
OWNER_NAME="${config.numbus-server.owner}"
FRIENDLY_BODY="Cher/Chère $OWNER_NAME,
Votre serveur a automatiquement détecté une panne matérielle de disque dur.
Ce genre de panne est tout à fait normal selon l'âge de votre matériel et n'entraîne
dans la grande majorité des cas aucune perte de données grâce au système de
stockage redondant préventif.
Votre administrateur a été notifié de cette panne. Il vous recontactera dans de très
brefs délais afin de procéder au remplacement, si nécessaire, du disque dur défaillant.
Merci de votre confiance,
L'équipe de support,
Numbus-Server."
printf "Subject: [Alerte] Défaillance matérielle sur votre serveur Numbus\n\n$FRIENDLY_BODY" | /run/wrappers/bin/sendmail -t "$USER_EMAIL"
'';
in
{
services.smartd = {
enable = true;
defaults.autodetected = "-a -o on -S on -s (S/../.././00|L/../../6/01) -n standby,q -M exec ${smartd_notifier}";
notifications = {
wall = {
enable = true;
};
mail = {
enable = true;
sender = config.numbus-server.mail.fromAddress;
recipient = "${config.numbus-server.mail.userAddress},${config.numbus-server.mail.adminAddress}";
};
};
};
}
+99
View File
@@ -0,0 +1,99 @@
{ config, pkgs, lib, ... }:
with lib;
let
cfg = config.numbus.mail;
in
{
options.numbus.mail = {
enable = mkEnableOption "Email sending functionality";
userAddress = mkOption {
description = "The address of the user this server will send emails to";
type = types.str;
example = "user@your-domain.com";
};
adminAddress = mkOption {
description = "The address of the admin this server will send emails to";
type = types.str;
example = "admin@your-domain.com";
};
smtpUsername = mkOption {
description = "The username/email that will be use to authenticate to the SMTP server";
type = types.str;
example = "your-smtp-enabled-address@your-domain.com";
};
smtpPasswordPath = mkOption {
description = "The path to a file containing the password that will be use to authenticate to the SMTP server";
type = types.path;
example = /run/secrets/system/mail/smtpPassword;
};
fromAddress = mkOption {
description = "This server will send emails from this address";
type = types.str;
default = "numbus-server-noreply@${config.numbus.services.domain}";
example = "numbus-server-noreply@your-domain.com";
};
smtpServer = mkOption {
description = "The SMTP server address your server will use to send emails";
type = types.str;
default = "smtp.gmail.com";
example = "smtp.your-provider.com";
};
smtpPort = mkOption {
description = "The SMTP port your server will connect to to send emails";
type = types.port;
default = 587;
example = 587;
};
smtpEncryption = mkOption {
description = "The encryption method for SMTP : NONE (NOT RECOMMENDED), TLS (port 465, also called SSL), or STARTTLS (port 587). STARTTLS is recommended.";
type = types.enum [ "NONE" "TLS" "STARTTLS" ];
default = "STARTTLS";
example = "STARTTLS";
};
};
config = mkIf cfg.enable {
sops.secrets."smtpPassword" = {
sopsFile = /etc/nixos/secrets/system/mail.yaml;
owner = "numbus-admin";
mode = "0600";
};
environment.etc."aliases" ={
mode = "0440";
text = ''
root: ${cfg.userAddress}, ${cfg.adminAddress}
'';
};
programs.msmtp = {
enable = true;
defaults = {
aliases = "/etc/aliases";
timeout = 60;
syslog = "on";
};
accounts.default = {
auth = true;
host = cfg.smtpServer;
port = cfg.smtpPort;
from = cfg.fromAddress;
user = cfg.smtpUsername;
tls = true;
tls_starttls = true;
passwordeval = "${pkgs.coreutils}/bin/cat ${cfg.smtpPasswordPath}";
};
};
};
}
+55
View File
@@ -0,0 +1,55 @@
{ config, pkgs, ... }:
let
systemd_notifier = pkgs.writeScript "systemd-email-notify.sh" ''
#!${pkgs.bash}/bin/bash
# The failing service name is passed as the first argument
UNIT=$1
# 1. Send Technical Email to Admin
ADMIN_EMAIL="${config.numbus-server.mail.adminAddress}"
SUBJECT="Numbus Server Alert: Service $UNIT Failed"
# Retrieve recent logs for context
LOGS=$(journalctl -u "$UNIT" -n 20 --no-pager)
TECH_BODY="
Systemd Service Failure Alert:
Server owner: ${config.numbus-server.owner}
Service: $UNIT
Recent Logs:
$LOGS
"
printf "Subject: [ADMIN] $SUBJECT\n\n$TECH_BODY" | /run/wrappers/bin/sendmail -t "$ADMIN_EMAIL"
# 2. Send Friendly Email to Owner
USER_EMAIL="${config.numbus-server.mail.userAddress}"
OWNER_NAME="${config.numbus-server.owner}"
FRIENDLY_BODY="Cher/Chère $OWNER_NAME,
Votre serveur a détecté une défaillance du service $UNIT.
Le système a tenté de gérer l'erreur, mais une intervention peut être nécessaire.
Votre administrateur a été notifié de cet incident avec les détails techniques nécessaires.
Il interviendra si une action manuelle est requise.
Merci de votre confiance,
L'équipe de support,
Numbus-Server."
printf "Subject: [Alerte] Erreur sur votre serveur Numbus\n\n$FRIENDLY_BODY" | /run/wrappers/bin/sendmail -t "$USER_EMAIL"
'';
in
{
systemd.services."service-failure-notify@" = {
description = "Email notification for failed service %i";
onFailure = [ ];
serviceConfig = {
Type = "oneshot";
ExecStart = "${systemd_notifier} %i";
};
};
}
+15
View File
@@ -0,0 +1,15 @@
{ config, deviceType, ... }:
{
config = mkIf (deviceType == "computer" || deviceType == "tv" ) {
# Enable sound with pipewire.
services.pulseaudio.enable = false;
security.rtkit.enable = true;
services.pipewire = {
enable = true;
alsa.enable = true;
alsa.support32Bit = true;
pulse.enable = true;
};
};
}
+13
View File
@@ -0,0 +1,13 @@
{ ... }:
{
imports = [
# To test
./audio.nix
./internationalisation.nix
./power.nix
./printer.nix
./update.nix
./users.nix
];
}
@@ -0,0 +1,30 @@
{ config, lib, ... }:
with lib;
let
cfg = config.numbus.internationalization;
in
{
config = {
i18n.defaultLocale = "${cfg.locale}.UTF-8";
i18n.extraLocaleSettings = {
LC_ADDRESS = "${cfg.locale}.UTF-8";
LC_IDENTIFICATION = "${cfg.locale}.UTF-8";
LC_MEASUREMENT = "${cfg.locale}.UTF-8";
LC_MONETARY = "${cfg.locale}.UTF-8";
LC_NAME = "${cfg.locale}.UTF-8";
LC_NUMERIC = "${cfg.locale}.UTF-8";
LC_PAPER = "${cfg.locale}.UTF-8";
LC_TELEPHONE = "${cfg.locale}.UTF-8";
LC_TIME = "${cfg.locale}.UTF-8";
};
console.keyMap = toLower cfg.language;
services.xserver.xkb = {
layout = toLower cfg.language;
variant = "";
};
};
}
+12
View File
@@ -0,0 +1,12 @@
{ config, lib, pkgs, ... }:
{
config = {
services.autoaspm.enable = true;
powerManagement.powertop.enable = true;
boot.kernelParams = [
"pcie_aspm=force"
"consoleblank=60"
];
};
}
+8
View File
@@ -0,0 +1,8 @@
{ config, ... }:
{
config = mkIf (deviceType == "computer" || deviceType == "tv" ) {
# Enable CUPS to print documents.
services.printing.enable = true;
};
}
+23
View File
@@ -0,0 +1,23 @@
{ config, inputs, ... }:
{
config = {
system.autoUpgrade = {
enable = true;
allowReboot = false;
flake = inputs.self.outPath;
flags = [ "--print-build-logs" ];
dates = "02:00";
randomizedDelaySec = "45min";
};
nix.gc = {
automatic = true;
dates = "weekly";
options = "--delete-older-than 7d";
};
nix.settings.experimental-features = [ "nix-command" "flakes" ];
nix.settings.auto-optimise-store = true;
};
}
+20
View File
@@ -0,0 +1,20 @@
{ config, pkgs, ... }:
let
cfg = config.numbus;
in
{
users.users.numbus-admin = {
shell = pkgs.fish;
isNormalUser = true;
description = cfg.owner;
extraGroups = [ "wheel" ];
uid = 1000;
initialPassword = "changeMe!";
# required for auto start before user login
linger = true;
# required for rootless container with multiple users
autoSubUidGidRange = true;
};
}
+13
View File
@@ -0,0 +1,13 @@
{ ... }:
{
imports = [
# To test
./fail2ban.nix
./flatpaks.nix
./numbus-cli.nix
./ssh.nix
./terminal.nix
./updates.nix
];
}
+5
View File
@@ -0,0 +1,5 @@
{ config, ... }:
{
services.fail2ban.enable = true;
}
+16
View File
@@ -0,0 +1,16 @@
{ config, lib, ... }:
with lib;
{
config = mkIf (services.flatpak.packages != []) {
services.flatpak.enable = true;
services.flatpak.update.auto.enable = true;
services.flatpak.uninstallUnmanaged = true;
services.flatpak.remotes = mkOptionDefault [{
name = "flathub";
location = "https://dl.flathub.org/repo/flathub.flatpakrepo";
}];
};
}
+155
View File
@@ -0,0 +1,155 @@
{ pkgs, lib, ... }:
let
# Base script header and common setup for all device types
baseScriptHeader = ''
#!/usr/bin/env bash
set -euo pipefail
# The device type is baked into the script at build time
readonly NUMBUS_DEVICE_TYPE="${deviceType}"
# Common utility function for consistent output
numbus_echo() {
echo "[Numbus CLI - $NUMBUS_DEVICE_TYPE] $*"
}
'';
# --- Device-specific script definitions ---
serverScript = baseScriptHeader + ''
case "$1" in
test)
numbus_echo "Hello World! This is a Numbus Server."
;;
status)
numbus_echo "Checking system status for Server..."
numbus_echo "--- Podman Containers ---"
podman ps || numbus_echo "No Podman containers found or Podman not running."
systemctl list-units --type=service "numbus-*" --no-pager || numbus_echo "No Numbus services found."
;;
upgrade)
numbus_echo "Pulling latest configuration and upgrading for Server..."
# Add server-specific upgrade logic here (e.g., nixos-rebuild switch)
;;
*)
numbus_echo "Numbus CLI (Server edition)"
echo ""
echo "Usage: numbus <command>"
echo ""
echo "Commands:"
echo " test - Print a test message"
numbus_echo " status - Show status of Numbus services (Podman, systemd)"
numbus_echo " upgrade - Upgrade the server configuration"
;;
esac
'';
backupScript = baseScriptHeader + ''
case "$1" in
test)
numbus_echo "Hello World! This is a Numbus Backup Server."
;;
status)
numbus_echo "Checking system status for Backup Server..."
systemctl list-units --type=service "numbus-*" --no-pager || numbus_echo "No Numbus services found."
# Add backup-specific status checks here (e.g., SnapRAID status, rsync jobs)
;;
restore)
numbus_echo "Starting interactive restore wizard for Backup Server..."
# Add backup-specific restore logic here
;;
upgrade)
numbus_echo "Pulling latest configuration and upgrading for Backup Server..."
# Add backup-specific upgrade logic here
;;
*)
numbus_echo "Numbus CLI (Backup Server edition)"
echo ""
echo "Usage: numbus <command>"
echo ""
echo "Commands:"
numbus_echo " test - Print a test message"
numbus_echo " status - Show status of Numbus services"
numbus_echo " restore - Start interactive restore wizard"
numbus_echo " upgrade - Upgrade the backup server configuration"
;;
esac
'';
computerScript = baseScriptHeader + ''
case "$1" in
test)
numbus_echo "Hello World! This is a Numbus Computer."
;;
status)
numbus_echo "Checking system status for Computer..."
systemctl list-units --type=service "numbus-*" --no-pager || numbus_echo "No Numbus services found."
# Add computer-specific status checks (e.g., GPU status, Flatpak updates)
;;
upgrade)
numbus_echo "Pulling latest configuration and upgrading for Computer..."
# Add computer-specific upgrade logic here
;;
*)
numbus_echo "Numbus CLI (Computer edition)"
echo ""
echo "Usage: numbus <command>"
echo ""
echo "Commands:"
numbus_echo " test - Print a test message"
numbus_echo " status - Show status of Numbus services"
numbus_echo " upgrade - Upgrade the computer configuration"
;;
esac
'';
tvScript = baseScriptHeader + ''
case "$1" in
test)
numbus_echo "Hello World! This is a Numbus TV."
;;
status)
numbus_echo "Checking system status for TV..."
systemctl list-units --type=service "numbus-*" --no-pager || numbus_echo "No Numbus services found."
# Add TV-specific status checks (e.g., media server status, remote connectivity)
;;
remote)
numbus_echo "Pairing a new Bluetooth remote for TV..."
# Add TV-specific remote pairing logic here
;;
upgrade)
numbus_echo "Pulling latest configuration and upgrading for TV..."
# Add TV-specific upgrade logic here
;;
*)
numbus_echo "Numbus CLI (TV edition)"
echo ""
echo "Usage: numbus <command>"
echo ""
numbus_echo "Commands:"
numbus_echo " test - Print a test message"
numbus_echo " status - Show status of Numbus services"
numbus_echo " remote - Pair a new Bluetooth remote"
numbus_echo " upgrade - Upgrade the TV configuration"
;;
esac
'';
# Use lib.switch to select the correct script based on deviceType
selectedScript = lib.switch deviceType {
server = serverScript;
backup = backupScript;
computer = computerScript;
tv = tvScript;
} (throw "Unknown Numbus device type: ${deviceType}"); # Fail if an unknown deviceType is encountered
# Define the numbus-cli package using the selected script
numbus = pkgs.writeShellScriptBin "numbus" selectedScript;
in {
environment.systemPackages = [ numbus ];
# Add a useful alias so people can check the type via env
environment.variables.NUMBUS_DEVICE_TYPE = deviceType;
}
+21
View File
@@ -0,0 +1,21 @@
{ config, ... }:
{
config.services.openssh = {
enable = true;
settings = {
PasswordAuthentication = false;
KbdInteractiveAuthentication = false;
PermitRootLogin = "no";
};
AllowUsers = [ "numbus-admin" ];
ports = [ 245 ]
};
config.sops.secrets."authorizedSshPublicKeys" = {
sopsFile = /etc/nixos/secrets/system/ssh.yaml;
mode = "0440";
owner = "numbus-admin";
path = "/home/numbus-admin/.ssh/authorized_keys";
};
}
+24
View File
@@ -0,0 +1,24 @@
{ config, pkgs, ... }:
{
environment.systemPackages = with pkgs; [
fish
fishPlugins.fzf-fish
fishPlugins.grc
grc
fzf
];
programs.fish = {
enable = true;
interactiveShellInit = ''
set fish_greeting # Disable greeting
fastfetch
echo -e "\n\nWelcome to Numbus !\n\n- This system is managed by NixOS\n- All changes are futile\n- Please consider buying support to get assistance\n- Have a nice day and enjoy !"
'';
shellAliases = {
nixup = "cd /etc/nixos/ && sudo nix flake update && sudo nixos-rebuild --flake . switch --upgrade && cd -";
nixwitch = "cd /etc/nixos/ && sudo nix flake update && sudo nixos-rebuild --flake . switch && cd -";
};
};
}
+23
View File
@@ -0,0 +1,23 @@
{ config, inputs, ... }:
{
config = {
system.autoUpgrade = {
enable = true;
allowReboot = false;
flake = inputs.self.outPath;
flags = [ "--print-build-logs" ];
dates = "21:00";
randomizedDelaySec = "45min";
};
nix.gc = {
automatic = true;
dates = "weekly";
options = "--delete-older-than 7d";
};
nix.settings.experimental-features = [ "nix-command" "flakes" ];
nix.settings.auto-optimise-store = true;
};
}
@@ -0,0 +1,28 @@
{ pkgs, ... }:
let
cockpit-numbus = pkgs.stdenv.mkDerivation {
name = "cockpit-numbus";
src = ./cockpit-numbus;
installPhase = ''
mkdir -p $out/share/cockpit/numbus
cp -r * $out/share/cockpit/numbus
'';
};
in
{
services.cockpit = {
enable = true;
port = 9090;
openFirewall = false;
settings = {
WebService = {
AllowUnencrypted = true;
};
};
};
# Link the extension into the system cockpit path
environment.systemPackages = [ cockpit-numbus ];
}
+9
View File
@@ -0,0 +1,9 @@
{ ... }:
{
imports=[
./hardware/default.nix
./misc/default.nix
./packages/default.nix
];
}
+32
View File
@@ -0,0 +1,32 @@
{ lib, ... }:
with lib;
{
options.numbus-computer = {
owner = mkOption {
type = types.str;
example = "Alex";
default = "Numbus";
description = "The name of the person who owns this computer";
};
language = mkOption {
type = types.str;
example = "FR";
default = "FR";
description = "The language for this computer";
};
keyboardLayout = mkOption {
type = types.str;
example = "FR";
default = "FR";
description = "The keyboard layout for this computer";
};
locale = mkOption {
type = types.str;
example = "fr_FR";
default = "fr_FR";
description = "The default locale for this computer";
};
};
}
+11
View File
@@ -0,0 +1,11 @@
{ ... }:
{
imports=[
./audio.nix
./internationalization.nix
./networking.nix
./printer.nix
./users.nix
];
}
+12
View File
@@ -0,0 +1,12 @@
{ config, ... }:
{
# Enable networking
networking.networkmanager.enable = true;
networking.hostName = "numbus-computer";
# Open ports in the firewall.
networking.firewall.allowedTCPPorts = [ ];
networking.firewall.allowedUDPPorts = [ ];
networking.firewall.enable = true;
}
+10
View File
@@ -0,0 +1,10 @@
{ ... }:
{
imports=[
./desktop-environment.nix
./flatpaks.nix
./terminal.nix
./updates.nix
];
}
@@ -0,0 +1,75 @@
{ config, pkgs, lib, ... }:
with lib;
let
cfg = config.numbus-computer.packages.desktop;
in
{
options.numbus-computer.packages.desktop = {
gnome = {
enable = mkEnableOption "Wether to enable the GNOME desktop environment.";
extensions = mkOption {
type = types.listOf types.str ;
default = [];
example = [ "dash-to-dock" "caffeine" "clipboard-history" "appindicator-support" ];
description = "Extensions to add to the Gnome desktop environment to improve your experience.";
};
};
kde_plasma = {
enable = mkEnableOption "Wether to enable the KDE Plasma desktop environment.";
};
hyprland = {
enable = mkEnableOption "Wether to enable the hyprland desktop environment.";
};
};
config = mkMerge [
# GNOME
(mkIf (cfg.gnome.enable == true) {
services.xserver.enable = false;
services.displayManager.gdm.enable = true;
services.desktopManager.gnome.enable = true;
services.gnome.core-apps.enable = false;
services.gnome.core-developer-tools.enable = false;
services.gnome.games.enable = false;
environment.gnome.excludePackages = with pkgs; [ gnome-tour gnome-user-docs ];
})
# GNOME extensions
(mkIf (cfg.gnome.enable == true && cfg.desktop.gnome.extensions != [ ]) {
environment.systemPackages = map (ext: pkgs.gnomeExtensions.${ext}) cfg.desktop.gnome.extensions;
})
# KDE Plasma
(mkIf (cfg.kde_plasma.enable == true) {
services = {
desktopManager.plasma6.enable = true;
displayManager.sddm.enable = true;
displayManager.sddm.wayland.enable = true;
};
environment.systemPackages = with pkgs; [
kdePackages.discover
kdePackages.kcalc
kdePackages.kcharselect
kdePackages.kclock
kdePackages.kcolorchooser
kdePackages.kolourpaint
kdePackages.sddm-kcm
kdiff3
wayland-utils
wl-clipboard
];
})
# Hyprland
(mkIf (cfg.hyprland.enable == true) {
programs.hyprland.enable = true;
environment.systemPackages = [
pkgs.kitty
];
})
];
}
+15
View File
@@ -0,0 +1,15 @@
{ ... }:
{
imports = [
./dns-services/default.nix
./hardware/default.nix
./mail/default.nix
./misc/default.nix
./networking/default.nix
./packages/default.nix
./system-services/default.nix
./web-applications/default.nix
./global.nix
];
}
+9
View File
@@ -0,0 +1,9 @@
{ ... }:
{
imports = [
# To test
./firewall.nix
./networking.nix
];
}
+13
View File
@@ -0,0 +1,13 @@
{ config, pkgs, lib, ... }:
{
config = {
networking.nftables.enable = true;
networking.firewall = {
enable = true;
allowPing = true;
allowedTCPPorts = [ 53 80 443 ];
allowedUDPPorts = [ 53 443 ];
};
};
}
+60
View File
@@ -0,0 +1,60 @@
{ config, pkgs, lib, ... }:
with lib;
let
cfg = config.numbus-server.networking;
in
{
options.numbus-server.networking = {
ipAddress = mkOption {
description = "The IP address that this server will use";
type = types.str;
example = "192.168.1.100";
};
interface = mkOption {
description = "The interface that this server will use to connect to the network";
type = types.str;
example = "enp1s0";
};
routerIpAddress = mkOption {
description = "The IP address of the router of your network";
type = types.str;
example = "192.168.1.1";
};
networkSubnet = mkOption {
description = "The subnet of your network";
type = types.str;
default = "";
example = "192.168.1.0/24";
};
dnsServers = mkOption {
description = "The list of DNS servers that this server will use";
type = types.listOf types.str;
default = [ "${cfg.ipAddress}" "9.9.9.9" ];
example = [ "${cfg.ipAddress}" "9.9.9.9" ];
};
};
config = {
networking.hostName = "numbus-server";
networking.networkmanager.enable = false;
# Allow rootless containers to bind to port 53 and up
boot.kernel.sysctl."net.ipv4.ip_unprivileged_port_start" = 53;
networking.bridges.br0.interfaces = [ cfg.interface ];
networking.interfaces."${cfg.interface}".useDHCP = false;
networking.interfaces.br0.useDHCP = false;
networking.nameservers = cfg.dnsServers;
networking.interfaces.br0.ipv4.addresses = [{
address = cfg.ipAddress;
prefixLength = 24;
}];
networking.defaultGateway = {
address = cfg.routerIpAddress;
interface = "br0";
};
};
}
@@ -0,0 +1,32 @@
{ config, pkgs, ... }:
{
nixpkgs.config.allowUnfree = true;
environment.systemPackages = with pkgs; [
# Secrets
age
sops
# HDD tools
hdparm
hd-idle
hddtemp
smartmontools
ncdu
# CPU tools
cpufrequtils
intel-gpu-tools
# Filesystem tools
snapraid
mergerfs
mergerfs-tools
# Powersave tools
powertop
# PCI devices tools
pciutils
tpm2-tss
# Misc
git
fastfetch
];
}
+9
View File
@@ -0,0 +1,9 @@
{ ... }:
{
imports = [
# To test
./packages.nix
./podman.nix
];
}
+16
View File
@@ -0,0 +1,16 @@
{ pkgs, ... }:
{
virtualisation.podman.enable = true;
virtualisation.podman.defaultNetwork.settings.dns_enabled = true;
virtualisation.containers.containersConf.settings = {
network.default_rootless_network_cmd = "slirp4netns";
};
environment.systemPackages = with pkgs; [
podman-compose
podman-tui
slirp4netns
];
}
+181
View File
@@ -0,0 +1,181 @@
{ lib, config, pkgs }:
with lib;
{
mkPodmanService = {
name,
description,
defaultPort ? "0",
defaultSubdomain ? name,
pod ? "false",
reverseProxied ? true,
composeText,
scheme ? "http",
middlewares ? null,
dependencies ? [ "sops-install-secrets.service" "traefik.service" "authelia.service" "${config.numbus-server.services.dns}.service" ],
extraOptions ? {},
extraConfig ? {},
configDirEnabled ? true,
dataDirEnabled ? true,
startDelay ? 180,
dirPermissions ? [],
secrets ? [],
envFile ? null,
...
}:
let
cfg = config.numbus-server.services.${name};
in
{
options.numbus-server.services.${name} = recursiveUpdate ({
enable = mkEnableOption description;
subdomain = mkOption {
type = types.str;
default = defaultSubdomain;
example = defaultSubdomain;
description = "The subdomain that ${name} will use";
};
port = mkOption {
type = types.str;
default = defaultPort;
example = defaultPort;
description = "The port that ${name} will use.";
};
reverseProxied = mkOption {
type = types.bool;
default = reverseProxied;
example = reverseProxied;
description = "Whether to create a basic Traefik reverse proxy configuration for this service. You might need to set it to false for custom configurations or services that don't need to be reverse proxied.";
};
} // (optionalAttrs configDirEnabled {
configDir = mkOption {
type = types.str;
default = "/mnt/config/${name}";
example = "/mnt/config/${name}";
description = "The directory where ${name}'s configuration files will be stored";
};
}) // (optionalAttrs dataDirEnabled {
dataDir = mkOption {
type = types.str;
default = "/mnt/data/${name}";
example = "/mnt/data/${name}";
description = "The directory where ${name}'s data will be stored";
};
})) extraOptions;
config = mkIf cfg.enable (mkMerge [
{
# Compose file
sops.templates."podman/${name}" = {
gid = "100";
uid = "1000";
mode = "0400";
content = composeText;
path = "/etc/podman/${name}/compose.yaml";
};
# Traefik config
sops.templates."traefik/rules/${name}" = mkIf cfg.reverseProxied {
gid = "100";
uid = "100999";
mode = "0400";
content = ''
http:
routers:
${name}:
rule: "Host(`${cfg.subdomain}.${config.numbus-server.services.domain}`)"
entrypoints:
- "websecure"
service: ${name}
middlewares:
${concatStringsSep "\n" (map (m: " - ${m}") middlewares)}
tls:
certresolver: "cloudflare"
options: "secureTLS"
services:
${name}:
loadBalancer:
servers:
- url: "${scheme}://host.containers.internal:${cfg.port}"
'';
path = "/etc/traefik/rules/${name}.yaml";
};
# Secrets config
sops.secrets = genAttrs secrets (secretPath: {
sopsFile = "/etc/nixos/secrets/podman/${name}.yaml";
gid = "100";
uid = "1000";
mode = "0400";
});
# SystemD config
systemd.services."${name}" = {
description = "Podman container : ${name}";
after = dependencies;
wantedBy = [ "multi-user.target" ];
onFailure = [ "service-failure-notify@%n.service" ];
startLimitBurst = 5;
startLimitIntervalSec = 600;
path = [ pkgs.podman pkgs.podman-compose pkgs.slirp4netns pkgs.su pkgs.sudo pkgs.coreutils ];
serviceConfig = {
Type = "exec";
TimeoutStartSec = "1000";
ExecStartPre = [
"${pkgs.bash}/bin/bash -c 'sleep $((RANDOM % ${toString startDelay}))'"
"${pkgs.bash}/bin/bash -c 'export PATH=/run/wrappers/bin:$PATH; exec ${pkgs.sudo}/bin/sudo -u numbus-admin podman-compose -f /etc/podman/${name}/compose.yaml pull'"
];
ExecStart = "${pkgs.bash}/bin/bash -c 'export PATH=/run/wrappers/bin:$PATH; exec ${pkgs.sudo}/bin/sudo -u numbus-admin podman-compose --in-pod ${toString pod} -f /etc/podman/${name}/compose.yaml up --remove-orphans'";
ExecStop = "${pkgs.bash}/bin/bash -c 'export PATH=/run/wrappers/bin:$PATH; exec ${pkgs.sudo}/bin/sudo -u numbus-admin podman-compose --in-pod ${toString pod} -f /etc/podman/${name}/compose.yaml down'";
Restart = "on-failure";
RestartSec = "3m";
};
};
# Permissions config
systemd.services."${name}-permissions" = mkIf (dirPermissions != []) {
description = "Podman container : ${name} : check and fix permissions";
before = [ "${name}.service" ];
wantedBy = [ "multi-user.target" "${name}.service" ];
onFailure = [ "service-failure-notify@%n.service" ];
startLimitBurst = 5;
startLimitIntervalSec = 600;
path = [ pkgs.coreutils ];
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
Restart = "on-failure";
RestartSec = "5m";
};
script = ''
${concatStringsSep "\n" (map (perm: ''
set -- ${perm}
WANTED_PERMISSIONS=$1
FOLDER_PATH=$2
if [[ ! -e "$FOLDER_PATH" ]]; then
mkdir -p "$FOLDER_PATH"
elif [[ ! -d "$FOLDER_PATH" ]]; then
rm "$FOLDER_PATH"
mkdir -p "$FOLDER_PATH"
fi
ACTUAL_PERMISSIONS=$(stat -c '%u:%g' "$FOLDER_PATH")
if [[ "$ACTUAL_PERMISSIONS" != "$WANTED_PERMISSIONS" ]]; then
chown -R "$WANTED_PERMISSIONS" "$FOLDER_PATH"
fi
'') dirPermissions)}
exit 0
'';
};
}
extraConfig
]);
};
}
@@ -0,0 +1,171 @@
{ config, pkgs, lib, ... }:
with lib;
let
# Container config
name = "authelia";
# Version tagging
autheliaVersion = "v4.39.16";
databaseVersion = "18.3";
# Helper
helper = import ../service-helper.nix { inherit config pkgs lib; };
cfg = config.numbus-server.services.authelia;
# Derive Base DN from domain (e.g., example.com -> dc=example,dc=com)
domainParts = splitString "." config.numbus-server.services.domain;
baseDN = concatStringsSep "," (map (p: "dc=${p}") domainParts);
# Generate dynamic access control rules based on groups and allowedApps
mkGroupRule = groupName: appName:
let
app = config.numbus-server.service.${appName} or {};
in
if app ? subdomain && app ? domain then ''
- domain: "${app.subdomain}.${app.domain}"
policy: two_factor
subject: "group:${groupName}"''
else "";
allGroupRules = concatStringsSep "\n" (filter (s: s != "") (flatten (mapAttrsToList (groupName: groupCfg:
map (appName: mkGroupRule groupName appName) (groupCfg.allowedApps or [])
) (config.numbus-server.groups or {}))));
defaultRedirectionUrl =
if config.numbus-server.services.homepage.enable then
"https://${config.numbus-server.services.homepage.subdomain}.${config.numbus-server.services.domain}"
else if config.numbus-server.services.dashy.enable then
"https://${config.numbus-server.services.dashy.subdomain}.${config.numbus-server.services.domain}"
else null;
in
helper.mkPodmanService {
inherit name;
pod = name;
description = "Authelia, your own unified login provider";
defaultPort = "9091";
dependencies = [
"sops-install-secrets.service"
"traefik.service"
"${config.numbus-server.services.dns}.service"
];
middlewares = [
"secureHeaders"
];
dirPermissions = [
"100999:100 ${cfg.configDir}"
];
secrets = [
"authelia/db_name"
"authelia/db_username"
"authelia/db_password"
"authelia/jwt_secret"
"authelia/session_secret"
"authelia/storage_secret"
];
composeText = ''
services:
authelia-server:
image: ghcr.io/authelia/authelia:${autheliaVersion}
container_name: authelia-server
hostname: authelia-server
user: '1000:1000'
networks:
authelia:
ipv4_address: 10.89.251.253
ports:
- "${cfg.port}:9091/tcp"
volumes:
- ${cfg.configDir}/server:/config
security_opt:
- no-new-privileges:true
cap_drop:
- NET_RAW
restart: unless-stopped
authelia-database:
container_name: authelia-database
hostname: authelia-database
image: docker.io/library/postgres:${databaseVersion}
user: '1000:1000'
networks:
authelia:
ipv4_address: 10.89.251.252
environment:
POSTGRES_DB: ${config.sops.placeholder."authelia/db_name"}
POSTGRES_USER: ${config.sops.placeholder."authelia/db_username"}
POSTGRES_PASSWORD: ${config.sops.placeholder."authelia/db_password"}
POSTGRES_INITDB_ARGS: '--data-checksums'
volumes:
- ${cfg.configDir}/database:/var/lib/postgresql/data
shm_size: 128mb
healthcheck:
disable: false
security_opt:
- no-new-privileges:true
cap_drop:
- NET_RAW
restart: unless-stopped
networks:
authelia:
driver: bridge
name: authelia
ipam:
config:
- subnet: "10.89.251.0/24"
gateway: "10.89.251.254"
'';
extraConfig = {
sops.templates."authelia-config" = {
gid = "100";
uid = "100999";
mode = "0400";
content = ''
authelia:
identity_validation:
reset_password:
jwt_secret: "${config.sops.placeholder."authelia/jwt_secret"}"
jwt_lifespan: "5 minutes"
jwt_algorithm: "HS256"
storage:
encryption_key: "${config.sops.placeholder."authelia/storage_secret"}"
postgres:
address: "tcp://authelia-database:5432"
database: "${config.sops.placeholder."authelia/db_name"}"
username: "${config.sops.placeholder."authelia/db_username"}"
password: "${config.sops.placeholder."authelia/db_password"}"
session:
secret: "${config.sops.placeholder."authelia/session_secret"}"
cookies:
- domain: "${config.numbus-server.services.domain}"
authelia_url: "https://${cfg.subdomain}.${config.numbus-server.services.domain}"
${optionalString (defaultRedirectionUrl != null) "default_redirection_url: \"${defaultRedirectionUrl}\""}
authentication_backend:
ldap:
implementation: "lldap"
address: "ldap://host.containers.internal:3890"
base_dn: "${baseDN}"
user: "UID=authelia,OU=people,${baseDN}"
password: "${config.sops.placeholder."lldap/"}"
notifier:
smtp:
address: submission://${config.numbus-server.mail.smtpHost}:${config.numbus-server.mail.smtpPort}
username: ${config.numbus-server.mail.smtpUsername}
password: ${config.sops.placeholder.smtpPassword}
sender: ${config.numbus-server.mail.fromAddress}
tls:
server_name: ${config.numbus-server.mail.smtpHost}
minimum_version: TLS1.2
skip_verify: false
access_control:
default_policy: 'deny'
rules:
- domain: "*.${config.numbus-server.service.domain}"
policy: two_factor
subject: "group:admin"
${allGroupRules}
'';
path = "/etc/authelia/authelia.yaml";
};
};
}
@@ -0,0 +1,74 @@
{ config, pkgs, lib, ... }:
with lib;
let
# Container config
name = "crafty";
# Version tagging
craftyVersion = "v4.10.1";
# Storage optimization
spindown = config.numbus-server.hardware.HddSpindown;
optimizedDir = if spindown.enable && (spindown.optimize == "compatible" || (isList spindown.optimize && elem name spindown.optimize))
then cfg.configDir
else cfg.dataDir;
# Helper
helper = import ../service-helper.nix { inherit config pkgs lib; };
cfg = config.numbus-server.services.crafty;
in
helper.mkPodmanService {
inherit name;
description = "Crafty controller, one place to manage your minecraft servers";
defaultPort = "8443";
scheme = "https";
dataDirEnabled = optimizedDir == cfg.dataDir;
middlewares = [
"secureHeaders"
];
dirPermissions = [
"100999:100 ${cfg.configDir}"
"100999:100 ${cfg.configDir}/log"
"100999:100 ${cfg.configDir}/config"
"100999:100 ${optimizedDir}/import"
"100999:100 ${optimizedDir}/backups"
"100999:100 ${optimizedDir}/servers"
];
composeText = ''
services:
crafty:
image: registry.gitlab.com/crafty-controller/crafty-4:${craftyVersion}
container_name: crafty
user: '1000:1000'
networks:
crafty:
ipv4_address: 10.89.250.253
ports:
- "${cfg.port}:8443/tcp"
- "19132:19132/udp"
- "25500-25600:25500-25600"
volumes:
- ${optimizedDir}/backups:/crafty/backups
- ${optimizedDir}/servers:/crafty/servers
- ${optimizedDir}/import:/crafty/import
- ${cfg.configDir}/logs:/crafty/logs
- ${cfg.configDir}/config:/crafty/app/config
environment:
- TZ=${time.timeZone}
security_opt:
- no-new-privileges:true
cap_drop:
- NET_RAW
restart: unless-stopped
networks:
crafty:
driver: bridge
name: crafty
ipam:
config:
- subnet: "10.89.250.0/24"
gateway: "10.89.250.254"
'';
}
@@ -0,0 +1,97 @@
{ config, pkgs, lib, ... }:
with lib;
let
# Container config
name = "dashy";
# Version tagging
dashyVersion = "v3.2.3";
# Helper
helper = import ../service-helper.nix { inherit config pkgs lib; };
cfg = config.numbus-server.services.dashy;
in
helper.mkPodmanService {
inherit name;
description = "Dashy, the ultimate dashboard for your homelab";
defaultPort = "8999";
configDirEnabled = false;
dataDirEnabled = false;
middlewares = [
"secureHeaders"
];
composeText = ''
services:
dashy:
image: lissy93/dashy:${dashyVersion}
container_name: dashy
hostname: dashy
user: '1000:1000'
networks:
dashy:
ipv4_address: 10.89.235.253
ports:
- ${cfg.port}:8080
volumes:
- ${config.sops."dashy/config".path}:/app/user-data/conf.yml
environment:
- UID=1000
- GID=1000
- NODE_ENV=production
healthcheck:
test: ['CMD', 'node', '/app/services/healthcheck']
interval: 1m30s
timeout: 10s
retries: 3
start_period: 40s
security_opt:
- no-new-privileges:true
cap_drop:
- NET_RAW
restart: unless-stopped
networks:
dashy:
driver: bridge
name: dashy
ipam:
config:
- subnet: "10.89.235.0/24"
gateway: "10.89.235.254"
'';
extraConfig = {
sops.templates."dashy/config" = {
gid = "100";
uid = "100999";
mode = "0440" ;
content = ''
pageInfo:
title: My Homelab
sections:
- name: Example Section
icon: far fa-rocket
items:
- title: GitHub
description: Dashy source code and docs
icon: fab fa-github
url: https://github.com/Lissy93/dashy
- title: Issues
description: View open issues, or raise a new one
icon: fas fa-bug
url: https://github.com/Lissy93/dashy/issues
- name: Local Services
items:
- title: Firewall
icon: favicon
url: http://192.168.1.1/
- title: Game Server
icon: https://i.ibb.co/710B3Yc/space-invader-x256.png
url: http://192.168.130.1/
'';
path = "/etc/dashy/dashy.yaml";
};
};
}
@@ -0,0 +1,29 @@
{ ... }:
{
imports = [
# Good
./gitea.nix
./immich.nix
./nextcloud.nix
./passbolt.nix
./traefik.nix
# Testing needed
./authelia.nix
./crafty.nix
./dashy.nix
./frigate.nix
./home-assistant.nix
./homepage.nix
./it-tools.nix
./jellyfin.nix
./lldap.nix
./n8n.nix
./netbird.nix
./netbootxyz.nix
./ntfy.nix
./odoo.nix
./uptime-kuma.nix
./vscodium.nix
];
}
@@ -0,0 +1,83 @@
{ config, pkgs, lib, ... }:
with lib;
let
# Container config
name = "frigate";
# Version tagging
frigateVersion = "0.16.4";
# Helper
helper = import ../service-helper.nix { inherit config pkgs lib; };
cfg = config.numbus-server.services.frigate;
in
helper.mkPodmanService {
inherit name;
pod = "home-assistant";
description = "Frigate, your fully-local NVR (Network Video Recorder)";
defaultPort = "8971";
scheme = "https";
dependencies = [
"sops-install-secrets.service"
"traefik.service"
"authelia.service"
"home-assistant.service"
"${config.numbus-server.services.dns}.service"
];
middlewares = [
"secureHeaders"
];
dirPermissions = [
"1000:100 ${cfg.configDir}"
"1000:100 ${cfg.dataDir}"
];
composeText = ''
services:
frigate:
image: ghcr.io/blakeblackshear/frigate:${frigateVersion}
container_name: frigate
hostname: frigate
shm_size: "256mb"
networks:
home-assistant:
ipv4_address: 10.89.230.253
ports:
- "${cfg.port}:8971/tcp"
volumes:
- ${cfg.configDir}:/config
- ${cfg.dataDir}:/media/frigate
- /etc/localtime:/etc/localtime:ro
- type: tmpfs
target: /tmp/cache
tmpfs:
size: 1000000000
environment:
- FRIGATE_MQTT_USER=${config.sops.placeholder."home-assistant/mqtt_username"}
- FRIGATE_MQTT_PASSWORD=${config.sops.placeholder."home-assistant/mqtt_password"}
${lib.optionalString (cfg.devices != []) ''
devices:
${lib.concatStringsSep "\n" (map (d: " - \"${d}\"") cfg.devices)}
''}
security_opt:
- no-new-privileges:true
cap_drop:
- NET_RAW
stop_grace_period: 30s
restart: unless-stopped
networks:
home-assistant:
external: true
'';
extraOptions = {
devices = mkOption {
type = types.listOf types.str;
default = [];
example = [ "/dev/dri:/dev/dri" "/dev/bus/usb:/dev/bus/usb" "/dev/apex_0:/dev/apex_0" ];
description = "List of devices to map into the container. /dev/dri is used for graphics acceleration, /dev/bus/usb for USB Coral TPUs, and /dev/apex_0 for PCI coral TPUs";
};
};
}
@@ -0,0 +1,103 @@
{ config, pkgs, lib, ... }:
with lib;
let
# Container config
name = "gitea";
# Version tagging
giteaVersion = "1.25.4-rootless";
databaseVersion = "18-alpine";
# Storage optimization
spindown = config.numbus-server.hardware.HddSpindown;
optimizedDir = if spindown.enable && (spindown.optimize == "compatible" || (isList spindown.optimize && elem name spindown.optimize))
then cfg.configDir
else cfg.dataDir;
# Helper
helper = import ../service-helper.nix { inherit config pkgs lib; };
cfg = config.numbus-server.services.gitea;
in
helper.mkPodmanService {
inherit name;
pod = "false";
description = "Gitea, your own self-hosted git platform";
defaultPort = "3000";
dataDirEnabled = optimizedDir == cfg.dataDir;
middlewares = [
"secureHeaders"
];
dirPermissions = [
"100999:100 ${cfg.configDir}"
"100999:100 ${optimizedDir}/data"
"100999:100 ${cfg.configDir}/config"
"100999:100 ${cfg.configDir}/database"
];
secrets = [
"gitea/db_name"
"gitea/db_username"
"gitea/db_password"
];
composeText = ''
services:
gitea-database:
image: docker.io/library/postgres:${databaseVersion}
container_name: gitea-database
hostname: gitea-database
user: '1000:1000'
networks:
gitea:
ipv4_address: 10.89.240.253
volumes:
- ${cfg.configDir}/database:/var/lib/postgresql
environment:
- POSTGRES_DB=${config.sops.placeholder."gitea/db_name"}
- POSTGRES_USER=${config.sops.placeholder."gitea/db_username"}
- POSTGRES_PASSWORD=${config.sops.placeholder."gitea/db_password"}
security_opt:
- no-new-privileges:true
cap_drop:
- NET_RAW
restart: unless-stopped
gitea-server:
image: docker.gitea.com/gitea:${giteaVersion}
container_name: gitea-server
hostname: gitea-server
user: '1000:1000'
networks:
gitea:
ipv4_address: 10.89.240.252
ports:
- "${cfg.port}:3000/tcp"
volumes:
- ${optimizedDir}/data:/var/lib/gitea
- ${cfg.configDir}/config:/etc/gitea
- /etc/localtime:/etc/localtime:ro
environment:
- GITEA__database__DB_TYPE=postgres
- GITEA__database__HOST=gitea-database:5432
- GITEA__database__NAME=${config.sops.placeholder."gitea/db_name"}
- GITEA__database__USER=${config.sops.placeholder."gitea/db_username"}
- GITEA__database__PASSWD=${config.sops.placeholder."gitea/db_password"}
- GITEA__server__SSH_PORT=2424
- GITEA__server__ROOT_URL=https://${cfg.subdomain}.${config.numbus-server.services.domain}
depends_on:
- gitea-database
security_opt:
- no-new-privileges:true
cap_drop:
- NET_RAW
restart: unless-stopped
networks:
gitea:
driver: bridge
name: gitea
ipam:
config:
- subnet: "10.89.240.0/24"
gateway: "10.89.240.254"
'';
}
@@ -0,0 +1,177 @@
{ config, pkgs, lib, ... }:
with lib;
let
# Container config
name = "home-assistant";
# Version tagging
homeAssistantVersion = "2026.2.3";
mqttVersion = "2.1-alpine";
# Helper
helper = import ../service-helper.nix { inherit config pkgs lib; };
cfg = config.numbus-server.services.home-assistant;
in
helper.mkPodmanService {
inherit name;
description = "Home Assistant, libre house control and much more";
defaultPort = "8123";
dataDirEnabled = false;
middlewares = [
"secureHeaders"
];
dirPermissions = [
"1000:100 ${cfg.configDir}"
"1000:100 ${cfg.configDir}/config"
"100999:100 ${cfg.configDir}/mqtt"
];
secrets = [
"home-assistant/mqtt_user"
"home-assistant/mqtt_password"
];
# Compose file good
composeText = ''
services:
home-assistant:
image: ghcr.io/home-assistant/home-assistant:${homeAssistantVersion}
container_name: home-assistant
hostname: home-assistant
networks:
home-assistant:
ipv4_address: 10.89.230.252
ports:
- "${cfg.port}:8123/tcp"
volumes:
- ${cfg.configDir}/config:/config
- /etc/localtime:/etc/localtime:ro
- /run/dbus:/run/dbus:ro
${lib.optionalString (cfg.devices != []) ''
devices:
${lib.concatStringsSep "\n" (map (d: " - \"${d}\"") cfg.devices)}
''}
security_opt:
- no-new-privileges:true
cap_drop:
- NET_RAW
restart: unless-stopped
home-assistant-mqtt:
image: docker.io/library/eclipse-mosquitto:${mqttVersion}
container_name: home-assistant-mqtt
hostname: home-assistant-mqtt
user: '1000:1000'
networks:
home-assistant:
ipv4_address: 10.89.230.252
volumes:
- ${cfg.configDir}/mqtt:/mosquitto
security_opt:
- no-new-privileges:true
cap_drop:
- NET_RAW
restart: unless-stopped
networks:
home-assistant:
driver: bridge
name: home-assistant
ipam:
config:
- subnet: "10.89.230.0/24"
gateway: "10.89.230.254"
'';
extraOptions = {
devices = mkOption {
type = types.listOf types.str;
default = [];
example = [ "/dev/serial/by-id/Sonoff_Zigbee_3.0-id-port0:/dev/ttyUSB0" ];
description = "List of devices to map into the container. /dev/ttyUSB0 is used for Zigbee dongles";
};
};
extraConfig = {
systemd.services."${name}-quirk" = {
description = "Podman container quirk : ${name}";
after = [ "${name}.service" ];
onFailure = [ "service-failure-notify@%n.service" ];
startLimitBurst = 5;
startLimitIntervalSec = 600;
path = [ pkgs.coreutils pkgs.systemd ];
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
};
script = ''
mkdir -p /var/lib/numbus-server/${name}
if [[ -e ${cfg.configDir}/config/configuration.yaml ]]; then
if grep -qF "10.89.230.1/32" ${cfg.configDir}/config/configuration.yaml; then
exit 0
elif grep -qF "use_x_forwarded_for" ${cfg.configDir}/config/configuration.yaml && ! grep -qF "10.89.230.1/32" ${cfg.configDir}/config/configuration.yaml; then
tmp=$(mktemp)
head -n -6 ${cfg.configDir}/config/configuration.yaml > "$tmp"
mv "$tmp" ${cfg.configDir}/config/configuration.yaml
fi
fi
until [[ -e ${cfg.configDir}/config/configuration.yaml ]]; do
sleep 15
done
cat << 'EOF' >> ${cfg.configDir}/config/configuration.yaml
http:
use_x_forwarded_for: true
trusted_proxies: 10.89.230.1
zha:
EOF
systemctl restart ${name}.service
'';
};
};
systemd.services."mqtt-quirk" = {
description = "Podman container quirk : Home-assistant MQTT";
after = [ "sops-install-secrets.service" ];
before = [ "${name}.service" ];
onFailure = [ "service-failure-notify@%n.service" ];
startLimitBurst = 5;
startLimitIntervalSec = 600;
path = [ pkgs.coreutils pkgs.mosquitto ];
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
};
script = ''
if [[ -e ${cfg.configDir}/mqtt/mosquitto.conf && ${cfg.configDir}/mqtt/password.txt ]]; then
if grep -qF "listener 1883" ${cfg.configDir}/mqtt/mosquitto.conf; then
exit 0
else
rm ${cfg.configDir}/mqtt/mosquitto.conf
rm ${cfg.configDir}/mqtt/password.txt
touch ${cfg.configDir}/mqtt/mosquitto.conf
touch ${cfg.configDir}/mqtt/password.txt
fi
fi
cat << EOF >> ${cfg.configDir}/mqtt/mosquitto.conf
persistence true
persistence_location /mosquitto/data/
log_dest file /mosquitto/log/mosquitto.log
listener 1883
## Authentication ##
allow_anonymous false
password_file /mosquitto/password.txt
EOF
HOME_ASSISTANT_MQTT_USER=$(cat /run/secrets/home-assistant/mqtt_user)
HOME_ASSISTANT_MQTT_PASSWORD=$(cat /run/secrets/home-assistant/mqtt_password)
mosquitto_passwd -b ${cfg.configDir}/mqtt/password.txt "$HOME_ASSISTANT_MQTT_USER" "$HOME_ASSISTANT_MQTT_PASSWORD"
chmod 0400 ${cfg.configDir}/mqtt/password.txt
'';
};
}
@@ -0,0 +1,63 @@
{ config, pkgs, lib, ... }:
with lib;
let
# Container config
name = "homepage";
# Version tagging
homepageVersion = "v1.10.1";
# Helper
helper = import ../service-helper.nix { inherit config pkgs lib; };
cfg = config.numbus-server.services.homepage;
in
helper.mkPodmanService {
inherit name;
description = "Homepage, a modern and highly customizable application dashboard";
defaultPort = "3003";
middlewares = [
"secureHeaders"
];
dirPermissions = [
"100999:100 ${cfg.configDir}"
"100999:100 ${cfg.configDir}/config"
"100999:100 ${cfg.configDir}/images"
"100999:100 ${cfg.configDir}/icons"
];
composeText = ''
services:
homepage:
image: ghcr.io/gethomepage/homepage:${homepageVersion}
container_name: homepage
hostname: homepage
user: '1000:1000'
networks:
homepage:
ports:
- "${cfg.port}:3000/tcp"
volumes:
- ${cfg.configDir}/config:/app/config
- ${cfg.configDir}/images:/app/public/images
- ${cfg.configDir}/icons:/app/public/icons
environment:
PUID: 1000
PGID: 1000
HOMEPAGE_ALLOWED_HOSTS: ${cfg.subdomain}.${config.numbus-server.services.domain}
security_opt:
- no-new-privileges:true
cap_drop:
- NET_RAW
restart: unless-stopped
networks:
homepage:
driver: bridge
name: homepage
ipam:
config:
- subnet: "10.89.220.0/24"
gateway: "10.89.220.254"
'';
}
@@ -0,0 +1,190 @@
{ config, pkgs, lib, ... }:
with lib;
let
# Container configuration
name = "immich";
# Version tagging
immichVersion = "v2.5.6";
redisVersion = "9@sha256:546304417feac0874c3dd576e0952c6bb8f06bb4093ea0c9ca303c73cf458f63";
databaseVersion = "14-vectorchord0.4.3-pgvectors0.2.0@sha256:bcf63357191b76a916ae5eb93464d65c07511da41e3bf7a8416db519b40b1c23";
# Helper
helper = import ../service-helper.nix { inherit config pkgs lib; };
cfg = config.numbus-server.services.immich;
in
helper.mkPodmanService {
inherit name;
description = "Immich, Google Photos but better";
defaultPort = "2283";
middlewares = [
"immichSecureHeaders"
];
dirPermissions = [
"100999:100 ${cfg.configDir}"
"100999:100 ${cfg.configDir}/redis"
"100999:100 ${cfg.configDir}/model-cache"
"100999:100 ${cfg.configDir}/machine-learning-cache"
"100999:100 ${cfg.configDir}/machine-learning-config"
"100999:100 ${cfg.configDir}/database"
"100999:100 ${cfg.dataDir}"
];
secrets = [
"immich/redis_hostname"
"immich/db_hostname"
"immich/db_name"
"immich/db_username"
"immich/db_password"
];
# Compose file good
composeText = ''
services:
immich-server:
container_name: immich-server
hostname: immich-server
image: ghcr.io/immich-app/immich-server:${immichVersion}
user: '1000:1000'
networks:
immich:
ipv4_address: 10.89.210.253
ports:
- "${cfg.port}:2283/tcp"
volumes:
- $UPLOAD_LOCATION:/data
- /etc/localtime:/etc/localtime:ro
environment:
TZ: $TZ
REDIS_HOSTNAME: ${config.sops.placeholder."immich/redis_hostname"}
DB_HOSTNAME: ${config.sops.placeholder."immich/db_hostname"}
DB_DATABASE_NAME: ${config.sops.placeholder."immich/db_name"}
DB_USERNAME: ${config.sops.placeholder."immich/db_username"}
DB_PASSWORD: ${config.sops.placeholder."immich/db_password"}
IMMICH_TRUSTED_PROXIES: 10.89.210.1
depends_on:
- immich-redis
- immich-database
healthcheck:
disable: false
security_opt:
- no-new-privileges:true
cap_drop:
- NET_RAW
restart: unless-stopped
immich-machine-learning:
container_name: immich-machine-learning
hostname: immich-machine-learning
image: ghcr.io/immich-app/immich-machine-learning:${immichVersion}
user: '1000:1000'
networks:
immich:
ipv4_address: 10.89.210.252
volumes:
- ${cfg.configDir}/model-cache:/cache
- ${cfg.configDir}/machine-learning-config:/usr/src/.config
- ${cfg.configDir}/machine-learning-cache:/usr/src/.cache/
healthcheck:
disable: false
security_opt:
- no-new-privileges:true
cap_drop:
- NET_RAW
restart: unless-stopped
immich-redis:
container_name: immich-redis
hostname: immich-redis
image: docker.io/valkey/valkey:${redisVersion}
user: '1000:1000'
networks:
immich:
ipv4_address: 10.89.210.251
volumes:
- ${cfg.configDir}/redis:/data
healthcheck:
test: redis-cli ping || exit 1
security_opt:
- no-new-privileges:true
cap_drop:
- NET_RAW
restart: unless-stopped
immich-database:
container_name: immich-database
hostname: immich-database
image: ghcr.io/immich-app/postgres:${databaseVersion}
user: '1000:1000'
networks:
immich:
ipv4_address: 10.89.210.250
environment:
POSTGRES_DB: ${config.sops.placeholder."immich/db_name"}
POSTGRES_USER: ${config.sops.placeholder."immich/db_username"}
POSTGRES_PASSWORD: ${config.sops.placeholder."immich/db_password"}
POSTGRES_INITDB_ARGS: '--data-checksums'
volumes:
- $DB_DATA_LOCATION:/var/lib/postgresql/data
shm_size: 128mb
healthcheck:
disable: false
security_opt:
- no-new-privileges:true
cap_drop:
- NET_RAW
restart: unless-stopped
networks:
immich:
driver: bridge
name: immich
ipam:
config:
- subnet: "10.89.210.0/24"
gateway: "10.89.210.254"
'';
extraConfig = {
sops.templates."immich/env" = {
gid = "100";
uid = "1000";
mode = "0400";
content = ''
DB_DATA_LOCATION=${cfg.configDir}/database
UPLOAD_LOCATION=${cfg.dataDir}
'';
path = "/etc/podman/immich/.env";
};
sops.templates."traefik/rules/immich-secureHeaders" = {
gid = "100";
uid = "100999";
mode = "0400";
content = ''
http:
middlewares:
immichSecureHeaders:
headers:
FrameDeny: true
AccessControlAllowMethods: 'GET,POST,PUT,DELETE,OPTIONS'
AccessControlAllowOriginList:
- https://${cfg.subdomain}.${config.numbus-server.services.domain}
- origin-list-or-null
AccessControlMaxAge: 100
AddVaryHeader: true
BrowserXssFilter: true
ContentTypeNosniff: true
ForceSTSHeader: true
STSIncludeSubdomains: true
STSPreload: true
ContentSecurityPolicy: "default-src 'self'; base-uri 'self'; img-src 'self' https://static.immich.cloud https://tiles.immich.cloud data: blob:; connect-src 'self' https://${cfg.subdomain}.${config.numbus-server.services.domain} wss://${cfg.subdomain}.${config.numbus-server.services.domain} https://static.immich.cloud https://tiles.immich.cloud; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; script-src 'self' 'unsafe-inline' 'unsafe-eval'; worker-src 'self' blob: https://${cfg.subdomain}.${config.numbus-server.services.domain}; frame-ancestors 'self';"
CustomFrameOptionsValue: SAMEORIGIN
ReferrerPolicy: same-origin
PermissionsPolicy: vibrate 'self'
STSSeconds: 315360000
'';
path = "/etc/traefik/rules/immich-secureHeaders.yaml";
};
};
}
@@ -0,0 +1,54 @@
{ config, pkgs, lib, ... }:
with lib;
let
# Container config
name = "it-tools";
# Version tagging
it-toolsVersion = "2024.10.22-7ca5933";
# Helper
helper = import ../service-helper.nix { inherit config pkgs lib; };
cfg = config.numbus-server.services.it-tools;
in
helper.mkPodmanService {
inherit name;
description = "IT-tools, useful tools when doing IT";
pod = "false";
defaultPort = "8880";
configDirEnabled = false;
dataDirEnabled = false;
middlewares = [
"secureHeaders"
];
# Compose file good
composeText = ''
services:
it-tools:
image: docker.io/corentinth/it-tools:${it-toolsVersion}
container_name: it-tools
hostname: it-tools
user: '1000:1000'
networks:
it-tools:
ipv4_address: 10.89.200.253
ports:
- "${cfg.port}:80/tcp"
security_opt:
- no-new-privileges:true
cap_drop:
- NET_RAW
restart: unless-stopped
networks:
it-tools:
driver: bridge
name: it-tools
ipam:
config:
- subnet: "10.89.200.0/24"
gateway: "10.89.200.254"
'';
}
@@ -0,0 +1,69 @@
{ config, pkgs, lib, ... }:
with lib;
let
# Container config
name = "jellyfin";
# Version tagging
jellyfinVersion = "10.11.6";
# Helper
helper = import ../service-helper.nix { inherit config pkgs lib; };
cfg = config.numbus-server.services.jellyfin;
in
helper.mkPodmanService {
inherit name;
description = "Jellyfin : A self-hosted media server to stream your movies and music";
defaultPort = "8096";
scheme = "https"; #TODO CHECK
middlewares = [
"secureHeaders"
];
dirPermissions = [
"100999:100 ${cfg.dataDir}"
"100999:100 ${cfg.configDir}"
"100999:100 ${cfg.dataDir}/media"
"100999:100 ${cfg.dataDir}/fonts"
"100999:100 ${cfg.configDir}/cache"
"100999:100 ${cfg.configDir}/config"
];
composeText = ''
services:
jellyfin:
image: docker.io/jellyfin/jellyfin:${jellyfinVersion}
container_name: jellyfin
hostname: jellyfin
user: '1000:1000'
networks:
jellyfin:
ipv4_address: 10.89.190.253
ports:
- "${cfg.port}:8096/tcp"
volumes:
- ${cfg.configDir}/config:/config
- ${cfg.configDir}/cache:/cache
- type: bind
source: ${cfg.dataDir}/media
target: /media
- type: bind
source: ${cfg.dataDir}/fonts
target: /usr/local/share/fonts/custom
read_only: true
cap_drop:
- NET_RAW
security_opt:
- no-new-privileges:true
restart: unless-stopped
networks:
jellyfin:
driver: bridge
name: jellyfin
ipam:
config:
- subnet: "10.89.190.0/24"
gateway: "10.89.190.254"
'';
}
@@ -0,0 +1,84 @@
{ config, pkgs, lib, ... }:
with lib;
let
# Container config
name = "lldap";
# Version tagging
lldapVersion = "v0.6.2";
# Helper
helper = import ../service-helper.nix { inherit config pkgs lib; };
cfg = config.numbus-server.services.lldap;
# Derive Base DN from domain (e.g., example.com -> dc=example,dc=com)
domainParts = splitString "." config.numbus-server.services.domain;
baseDN = concatStringsSep "," (map (p: "dc=${p}") domainParts);
in
helper.mkPodmanService {
inherit name;
pod = "false";
description = "LLDAP, unified user management";
defaultPort = "17170";
dependencies = [
"sops-install-secrets.service"
"network-online.target"
];
middlewares = [
"secureHeaders"
];
dirPermissions = [
"100999:100 ${cfg.configDir}"
];
secrets = [
"lldap/jwt_secret"
"lldap/key_seed"
"lldap/admin_password"
];
composeText = ''
services:
lldap:
image: lldap/lldap:${lldapVersion}
container_name: lldap
hostname: lldap
user: '1000:1000'
networks:
lldap:
ipv4_address: 10.89.185.253
ports:
- "3890:3890"
- "${cfg.port}:17170"
volumes:
- ${cfg.configDir}:/data
environment:
- UID=1000
- GID=1000
- TZ=${config.time.timeZone}
- LLDAP_LDAP_BASE_DN=${baseDN}
- LLDAP_JWT_SECRET="${config.sops.placeholder."lldap/jwt_secret"}"
- LLDAP_KEY_SEED="${config.sops.placeholder."lldap/key_seed"}"
- LLDAP_LDAP_USER_PASS="${config.sops.placeholder."lldap/admin_password"}"
- LLDAP_SMTP_OPTIONS__ENABLE_PASSWORD_RESET=true
- LLDAP_SMTP_OPTIONS__SERVER=${config.numbus-server.mail.smtpServer}
- LLDAP_SMTP_OPTIONS__PORT=${config.numbus-server.mail.smtpPort}
- LLDAP_SMTP_OPTIONS__SMTP_ENCRYPTION=${config.numbus-server.mail.smtpEncryption}
- LLDAP_SMTP_OPTIONS__USER=${config.numbus-server.mail.smtpUsername}
- LLDAP_SMTP_OPTIONS__PASSWORD=${config.sops.placeholder."mail/smtpPassword"}
- LLDAP_SMTP_OPTIONS__FROM=no-reply <${config.numbus-server.mail.fromAddress}>
security_opt:
- no-new-privileges:true
cap_drop:
- NET_RAW
restart: unless-stopped
networks:
lldap:
driver: bridge
name: lldap
ipam:
config:
- subnet: "10.89.185.0/24"
gateway: "10.89.185.254"
'';
}
@@ -0,0 +1,72 @@
{ config, pkgs, lib, ... }:
with lib;
let
# Container config
name = "n8n";
# Version tagging
n8nVersion = "2.11.4";
# Storage optimization
spindown = config.numbus-server.hardware.HddSpindown;
optimizedDir = if spindown.enable && (spindown.optimize == "compatible" || (isList spindown.optimize && elem name spindown.optimize))
then cfg.configDir
else cfg.dataDir;
# Helper
helper = import ../service-helper.nix { inherit config pkgs lib; };
cfg = config.numbus-server.services.n8n;
in
helper.mkPodmanService {
inherit name;
pod = "false";
description = "n8n, the ultimate automation platform";
defaultPort = "5678";
scheme = "https";
middlewares = [
"secureHeaders"
];
dirPermissions = [
"100999:100 ${optimizedDir}"
];
composeText = ''
services:
n8n:
image: docker.n8n.io/n8nio/n8n:${n8nVersion}
container_name: n8n
hostname: n8n
user: '1000:1000'
networks:
n8n:
ipv4_address: 10.89.180.253
ports:
- "${cfg.port}:5678"
volumes:
- ${optimizedDir}:/home/node/.n8n
environment:
- N8N_ENFORCE_SETTINGS_FILE_PERMISSIONS=true
- N8N_HOST=${cfg.subdomain}.${config.numbus-server.services.domain}
- N8N_PORT=5678
- N8N_PROTOCOL=https
- N8N_RUNNERS_ENABLED=true
- NODE_ENV=production
- WEBHOOK_URL=https://${cfg.subdomain}.${config.numbus-server.services.domain}/
- GENERIC_TIMEZONE=${time.timeZone}
- TZ=${time.timeZone}
security_opt:
- no-new-privileges:true
cap_drop:
- NET_RAW
restart: unless-stopped
networks:
n8n:
driver: bridge
name: n8n
ipam:
config:
- subnet: "10.89.180.0/24"
gateway: "10.89.180.254"
'';
}
@@ -0,0 +1,203 @@
{ config, pkgs, lib, ... }:
with lib;
let
# Container config
name = "netbird";
# Version tagging
netbirdDashboardVersion = "";
netbirdServerVersion = "";
# Helper
helper = import ../service-helper.nix { inherit config pkgs lib; };
cfg = config.numbus-server.services.netbird;
in
helper.mkPodmanService {
inherit name;
pod = "false";
description = "NetBird, an all-in-one ZTNA remote access platform";
defaultPort = "8888";
reverseProxied = false;
dependencies = [
"sops-install-secrets.service"
"traefik.service"
"${config.numbus-server.services.dns}.service"
];
dirPermissions = [
"100999:100 ${cfg.configDir}"
];
secrets = [
"netbird/auth_key"
"netbird/encryption_key"
];
composeText = ''
services:
netbird-dashboard:
image: netbirdio/dashboard:${netbirdDashboardVersion}
container_name: netbird-dashboard
hostname: netbird-dashboard
user: '1000:1000'
networks:
netbird:
ipv4_address: 10.89.175.253
ports:
- "${defaultPort}:8080/tcp"
environment:
# Endpoints
- NETBIRD_MGMT_API_ENDPOINT=https://${cfg.subdomain}.${config.numbus-server.services.domain}
- NETBIRD_MGMT_GRPC_API_ENDPOINT=https://${cfg.subdomain}.${config.numbus-server.services.domain}
# OIDC - using embedded IdP
- AUTH_AUDIENCE=netbird-dashboard
- AUTH_CLIENT_ID=netbird-dashboard
- AUTH_CLIENT_SECRET=
- AUTH_AUTHORITY=https://${cfg.subdomain}.${config.numbus-server.services.domain}/oauth2
- USE_AUTH0=false
- AUTH_SUPPORTED_SCOPES=openid profile email groups
- AUTH_REDIRECT_URI=/nb-auth
- AUTH_SILENT_REDIRECT_URI=/nb-silent-auth
# SSL
- NGINX_SSL_PORT=443
- LETSENCRYPT_DOMAIN=none
logging:
driver: "json-file"
options:
max-size: "500m"
max-file: "2"
security_opt:
- no-new-privileges:true
cap_drop:
- NET_RAW
restart: unless-stopped
netbird-server:
image: netbirdio/netbird-server:${netbirdServerVersion}
container_name: netbird-server
hostname: netbird-server
user: '1000:1000'
networks:
netbird:
ipv4_address: 10.89.175.252
ports:
- "8889:8081/tcp"
- "3478:3478/udp"
volumes:
- ${config.sops.templates."netbird-config".path}:/etc/netbird/config.yaml
- ${cfg.configDir}:/var/lib/netbird
command: ["--config", "/etc/netbird/config.yaml"]
logging:
driver: "json-file"
options:
max-size: "500m"
max-file: "2"
security_opt:
- no-new-privileges:true
cap_drop:
- NET_RAW
restart: unless-stopped
networks:
netbird:
driver: bridge
name: netbird
ipam:
config:
- subnet: "10.89.175.0/24"
gateway: "10.89.175.254"
'';
extraConfig = {
sops.templates."netbird-config" = {
gid = "100";
uid = "100999";
mode = "0400";
content = ''
server:
listenAddress: ":80"
exposedAddress: "https://${cfg.subdomain}.${config.numbus-server.services.domain}:443"
stunPorts:
- 3478
metricsPort: 9090
healthcheckAddress: ":9000"
logLevel: "info"
logFile: "console"
authSecret: "${config.sops.placeholder."netbird/auth_key"}"
dataDir: "/var/lib/netbird"
auth:
issuer: "https://${cfg.subdomain}.${config.numbus-server.services.domain}/oauth2"
signKeyRefreshEnabled: true
dashboardRedirectURIs:
- "https://${cfg.subdomain}.${config.numbus-server.services.domain}/nb-auth"
- "https://${cfg.subdomain}.${config.numbus-server.services.domain}/nb-silent-auth"
cliRedirectURIs:
- "http://localhost:53000/"
reverseProxy:
trustedHTTPProxies:
- "10.89.175.1/32"
store:
engine: "sqlite"
encryptionKey: "${config.sops.placeholder."netbird/encryption_key"}"
'';
path = "/etc/netbird/netbird.yaml";
};
sops.templates."traefik/rules/${name}" = {
gid = "100";
uid = "1000";
mode = "0400";
content = ''
http:
routers:
${name}-dashboard:
rule: "Host(`${cfg.subdomain}.${config.numbus-server.services.domain}`)"
entrypoints:
- "websecure"
middlewares:
- secureHeaders
tls:
certresolver: "cloudflare"
options: "secureTLS"
priority: 1
${name}-grpc:
rule: "Host(`${cfg.subdomain}.${config.numbus-server.services.domain}`) && (PathPrefix(`/signalexchange.SignalExchange/`) || PathPrefix(`/management.ManagementService/`))"
entrypoints:
- "websecure"
service: ${name}-server-h2c
middlewares:
- secureHeaders
tls:
certresolver: "cloudflare"
options: "secureTLS"
${name}-backend:
rule: "Host(`${cfg.subdomain}.${config.numbus-server.services.domain}`) && (PathPrefix(`/relay`) || PathPrefix(`/ws-proxy/`) || PathPrefix(`/api`) || PathPrefix(`/oauth2`))"
entrypoints:
- "websecure"
service: ${name}-server
middlewares:
- secureHeaders
tls:
certresolver: "cloudflare"
options: "secureTLS"
services:${cfg.port}
${name}-dashboard:
loadBalancer:
servers:
- url: "http://host.containers.internal:${cfg.port}"
${name}-server:
loadBalancer:
servers:
- url: "http://host.containers.internal:8889"
${name}-server-h2c:
loadBalancer:
servers:
- url: "h2c://host.containers.internal:3478"
'';
path = "/etc/traefik/rules/${name}";
};
};
}
@@ -0,0 +1,75 @@
{ config, pkgs, lib, ... }:
with lib;
let
# Container config
name = "netbootxyz";
# Version tagging
netbootxyzVersion = "3.0.0";
# Storage optimization
spindown = config.numbus-server.hardware.HddSpindown;
optimizedDir = if spindown.enable && (spindown.optimize == "compatible" || (isList spindown.optimize && elem name spindown.optimize))
then cfg.configDir
else cfg.dataDir;
# Helper
helper = import ../service-helper.nix { inherit config pkgs lib; };
cfg = config.numbus-server.services.netbootxyz;
in
helper.mkPodmanService {
inherit name;
description = "Netboot.xyz, forget about flashing isos on USB sticks with PXE boot";
pod = "false";
defaultPort = "3004";
configDirEnabled = optimizedDir == cfg.configDir;
dataDirEnabled = optimizedDir == cfg.dataDir;
middlewares = [
"secureHeaders"
];
dirPermissions = [
"100999:100 ${cfg.configDir}"
"100999:100 ${optimizedDir}"
"100999:100 ${cfg.configDir}/config"
"100999:100 ${optimizedDir}/assets"
];
composeText = ''
services:
netbootxyz:
image: ghcr.io/netbootxyz/netbootxyz:${netbootxyzVersion}
container_name: netbootxyz
hostname: netbootxyz
user: '1000:1000'
networks:
netbootxyz:
ipv4_address: 10.89.170.253
ports:
- "${cfg.port}:3000/tcp"
- "69:69/udp"
- "8008:80/tcp"
volumes:
- ${cfg.configDir}/config:/config
- ${optimizedDir}/assets:/assets
environment:
- PUID=1000
- PGID=1000
- TZ=${time.timeZone}
- PORT_RANGE=30000:30010
- SUBFOLDER=/
security_opt:
- no-new-privileges:true
cap_drop:
- NET_RAW
restart: unless-stopped
networks:
netbootxyz:
driver: bridge
name: netbootxyz
ipam:
config:
- subnet: "10.89.170.0/24"
gateway: "10.89.170.254"
'';
}
@@ -0,0 +1,384 @@
{ config, pkgs, lib, ... }:
with lib;
let
# Version tagging
nextcloudVersion = "33.0.0";
redisVersion = "8.6-alpine";
databaseVersion = "11.8";
onlyofficeVersion = "9.2";
whiteboardVersion = "v1.5.6";
# Helper
helper = import ../service-helper.nix { inherit config pkgs lib; };
cfg = config.numbus-server.services.nextcloud;
# Container config
name = "nextcloud";
in
helper.mkPodmanService {
inherit name;
description = "Nextcloud, your own online office suite";
defaultPort = "1100";
middlewares = [
"nextcloudSecureHeaders"
];
secrets = [
"nextcloud/db_name"
"nextcloud/db_username"
"nextcloud/db_password"
"nextcloud/redis_password"
"nextcloud/onlyoffice_secret"
"nextcloud/whiteboard_secret"
];
dirPermissions = [
"100032:100 ${cfg.dataDir}"
"100032:100 ${cfg.configDir}"
"100032:100 ${cfg.configDir}/web"
"100999:100 ${cfg.configDir}/redis"
"100999:100 ${cfg.configDir}/database"
"1000:100 ${cfg.configDir}/onlyoffice"
"1000:100 ${cfg.configDir}/onlyoffice/log"
"1000:100 ${cfg.configDir}/onlyoffice/cache"
"1000:100 ${cfg.configDir}/onlyoffice/data"
"1000:100 ${cfg.configDir}/onlyoffice/database"
];
# Compose file good
composeText = ''
services:
nextcloud-server:
image: docker.io/library/nextcloud:${nextcloudVersion}
container_name: nextcloud-server
hostname: nextcloud-server
networks:
nextcloud:
ipv4_address: 10.89.160.253
ports:
- "${cfg.port}:80/tcp"
volumes:
- ${cfg.configDir}/web:/var/www/html
- ${cfg.dataDir}:/mnt/ncdata
environment:
MYSQL_HOST: nextcloud-database:3306
MYSQL_DATABASE: ${config.sops.placeholder."nextcloud/db_name"}
MYSQL_USER: ${config.sops.placeholder."nextcloud/db_username"}
MYSQL_PASSWORD: ${config.sops.placeholder."nextcloud/db_password"}
REDIS_HOST_PASSWORD: ${config.sops.placeholder."nextcloud/redis_password"}
REDIS_HOST: nextcloud-redis
NEXTCLOUD_TRUSTED_DOMAINS: ${cfg.subdomain}.${config.numbus-server.services.domain}
NEXTCLOUD_DATA_DIR: /mnt/ncdata
SMTP_SECURE: tls
SMTP_HOST: ${config.numbus-server.mail.smtpServer}
SMTP_PORT: ${toString config.numbus-server.mail.smtpPort}
SMTP_NAME: ${config.numbus-server.mail.smtpUsername}
SMTP_PASSWORD: ${config.sops.placeholder.smtpPassword}
MAIL_FROM_ADDRESS: no-reply
MAIL_DOMAIN: ${config.numbus-server.services.domain}
APACHE_DISABLE_REWRITE_IP: 1
OVERWRITEPROTOCOL: https
TRUSTED_PROXIES: 10.89.160.1
NC_default_phone_region: "${config.numbus-server.language}"
NC_default_language: "${config.numbus-server.language}"
NC_default_locale: "${config.numbus-server.locale}"
NC_default_timezone: "${config.time.timeZone}"
NC_maintenance_window_start: "1"
depends_on:
- nextcloud-database
security_opt:
- no-new-privileges:true
cap_drop:
- NET_RAW
restart: unless-stopped
nextcloud-redis:
image: docker.io/library/redis:${redisVersion}
container_name: nextcloud-redis
hostname: nextcloud-redis
user: '1000:1000'
networks:
nextcloud:
ipv4_address: 10.89.160.252
volumes:
- ${cfg.configDir}/redis:/data
command: redis-server --requirepass ${config.sops.placeholder."nextcloud/redis_password"} --save 60 1 --loglevel warning
security_opt:
- no-new-privileges:true
cap_drop:
- NET_RAW
restart: unless-stopped
nextcloud-database:
image: docker.io/library/mariadb:${databaseVersion}
container_name: nextcloud-database
hostname: nextcloud-database
user: '1000:1000'
networks:
nextcloud:
ipv4_address: 10.89.160.251
volumes:
- ${cfg.configDir}/database:/var/lib/mysql
environment:
MARIADB_DATABASE: ${config.sops.placeholder."nextcloud/db_name"}
MARIADB_USER: ${config.sops.placeholder."nextcloud/db_username"}
MARIADB_PASSWORD: ${config.sops.placeholder."nextcloud/db_password"}
MARIADB_RANDOM_ROOT_PASSWORD: true
security_opt:
- no-new-privileges:true
cap_drop:
- NET_RAW
command:
- "--transaction-isolation=READ-COMMITTED"
- "--binlog-format=ROW"
restart: unless-stopped
nextcloud-onlyoffice:
image: docker.io/onlyoffice/documentserver:${onlyofficeVersion}
container_name: nextcloud-onlyoffice
hostname: nextcloud-onlyoffice
networks:
nextcloud:
ipv4_address: 10.89.160.250
ports:
- "9980:80/tcp"
volumes:
- ${cfg.configDir}/onlyoffice/log:/var/log/onlyoffice
- ${cfg.configDir}/onlyoffice/cache:/var/lib/onlyoffice
- ${cfg.configDir}/onlyoffice/data:/var/www/onlyoffice/Data
- ${cfg.configDir}/onlyoffice/database:/var/lib/postgresql
environment:
- JWT_SECRET=${config.sops.placeholder."nextcloud/onlyoffice_secret"}
- REDIS_SERVER_PASS=${config.sops.placeholder."nextcloud/redis_password"}
- REDIS_SERVER_HOST=nextcloud-redis
- REDIS_SERVER_PORT=6379
- ADMINPANEL_ENABLED=false
- EXAMPLE_ENABLED=false
- METRICS_ENABLED=false
cap_drop:
- NET_RAW
restart: unless-stopped
nextcloud-whiteboard:
image: ghcr.io/nextcloud-releases/whiteboard:${whiteboardVersion}
container_name: nextcloud-whiteboard
hostname: nextcloud-whiteboard
user: '1000:1000'
ports:
- "3002:3002/tcp"
environment:
NEXTCLOUD_URL: https://${cfg.subdomain}.${config.numbus-server.services.domain}
JWT_SECRET_KEY: ${config.sops.placeholder."nextcloud/whiteboard_secret"}
security_opt:
- no-new-privileges:true
cap_drop:
- NET_RAW
restart: unless-stopped
networks:
nextcloud:
driver: bridge
name: nextcloud
ipam:
config:
- subnet: "10.89.160.0/24"
gateway: "10.89.160.254"
'';
extraOptions = {
onlyoffice = {
subdomain = mkOption {
type = types.str;
default = "onlyoffice";
example = "onlyoffice";
description = "The subdomain that onlyoffice for nextcloud will use";
};
};
whiteboard = {
subdomain = mkOption {
type = types.str;
default = "whiteboard";
example = "whiteboard";
description = "The subdomain that whiteboard for nextcloud will use";
};
};
};
extraConfig = {
sops.templates."traefik/rules/nextcloud-onlyoffice" = {
gid = "100";
uid = "100999";
mode = "0400";
content = ''
http:
routers:
nextcloud-onlyoffice:
rule: "Host(`${cfg.onlyoffice.subdomain}.${config.numbus-server.services.domain}`)"
entrypoints:
- "websecure"
service: nextcloud-onlyoffice
tls:
certresolver: "cloudflare"
options: "secureTLS"
services:
nextcloud-onlyoffice:
loadBalancer:
servers:
- url: "http://host.containers.internal:9980"
'';
path = "/etc/traefik/rules/nextcloud-onlyoffice.yaml";
};
sops.templates."traefik/rules/nextcloud-whiteboard" = {
gid = "100";
uid = "100999";
mode = "0400";
content = ''
http:
routers:
nextcloud-whiteboard:
rule: "Host(`${cfg.whiteboard.subdomain}.${config.numbus-server.services.domain}`)"
entrypoints:
- "websecure"
service: nextcloud-whiteboard
middlewares:
- "secureHeaders"
tls:
certresolver: "cloudflare"
options: "secureTLS"
services:
nextcloud-whiteboard:
loadBalancer:
servers:
- url: "http://host.containers.internal:3002"
'';
path = "/etc/traefik/rules/nextcloud-whiteboard.yaml";
};
sops.templates."traefik/rules/nextcloud-secureHeaders" = {
gid = "100";
uid = "100999";
mode = "0400";
content = ''
http:
middlewares:
nextcloudSecureHeaders:
headers:
FrameDeny: false
CustomFrameOptionsValue: "SAMEORIGIN"
AddVaryHeader: true
BrowserXssFilter: true
ContentTypeNosniff: true
ForceSTSHeader: true
STSSeconds: 315360000
STSIncludeSubdomains: true
STSPreload: true
AccessControlAllowMethods: "GET,OPTIONS,PUT"
AccessControlAllowOriginList:
- origin-list-or-null
AccessControlMaxAge: 100
ReferrerPolicy: same-origin
PermissionsPolicy: "vibrate=()"
ContentSecurityPolicy: >-
default-src https://${cfg.onlyoffice.subdomain}.${config.numbus-server.services.domain} 'self';
script-src https://${cfg.onlyoffice.subdomain}.${config.numbus-server.services.domain} 'self' 'unsafe-inline';
style-src 'self' 'unsafe-inline';
connect-src 'self';
img-src 'self' data:;
font-src 'self' data:;
frame-src https://${cfg.onlyoffice.subdomain}.${config.numbus-server.services.domain} 'self';
frame-ancestors https://${cfg.onlyoffice.subdomain}.${config.numbus-server.services.domain} 'self';
object-src 'none';
base-uri 'self';
'';
path = "/etc/traefik/rules/nextcloud-secureHeaders";
};
systemd.services."${name}-quirk" = {
description = "Podman container quirk : ${name}";
wantedBy = [ "multi-user.target" ];
after = [ "${name}.service" "${name}-secrets.service" ];
onFailure = [ "service-failure-notify@%n.service" ];
startLimitBurst = 5;
startLimitIntervalSec = 600;
path = [ pkgs.coreutils pkgs.sudo pkgs.podman pkgs.systemd pkgs.gnugrep ];
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
};
script = ''
OCC="sudo -u numbus-admin podman exec --user www-data nextcloud-server php occ"
[[ ! -e /var/lib/numbus-server/${name}/.env ]] && systemctl start ${name}-secrets.service
until [[ -e /var/lib/numbus-server/${name}/.env ]]; do
echo "Waiting for secrets generation..."
sleep 5
done
source /var/lib/numbus-server/${name}/.env
until $OCC status | grep -iq "installed: true" >/dev/null 2>&1; do
echo "Waiting for Nextcloud to be up and running..."
sleep 60
done
$OCC db:add-missing-indices
$OCC maintenance:repair --include-expensive
INSTALL_APPS_LIST=( "calendar" "contacts" "mail" "notes" "onlyoffice" "cookbook" "whiteboard" )
DISABLE_APPS_LIST=( "activity" "federation" "webhook_listeners" "photos" "recommendations" "sharebymail" "teams" "support" "richdocumentscode" )
for app in ''${INSTALL_APPS_LIST[@]}; do
if ! $OCC --no-warnings app:list | grep -iq "$app:"; then
$OCC --no-warnings app:install "$app"
fi
if $OCC --no-warnings app:list --disabled | grep -iq "$app:"; then
$OCC --no-warnings app:enable "$app"
fi
done
for app in ''${DISABLE_APPS_LIST[@]}; do
if $OCC --no-warnings app:list --enabled | grep -iq "$app:"; then
$OCC --no-warnings app:disable "$app"
fi
done
$OCC --no-warnings config:system:set onlyoffice DocumentServerInternalUrl --value="https://${cfg.onlyoffice.subdomain}.${config.numbus-server.services.domain}/"
$OCC --no-warnings config:system:set onlyoffice DocumentServerUrl --value="https://${cfg.onlyoffice.subdomain}.${config.numbus-server.services.domain}/"
$OCC --no-warnings config:system:set onlyoffice jwt_secret --value="$ONLYOFFICE_PASSWORD"
$OCC --no-warnings config:app:set whiteboard collabBackendUrl --value="https://${cfg.whiteboard.subdomain}.${config.numbus-server.services.domain}"
$OCC --no-warnings config:app:set whiteboard jwt_secret_key --value="$WHITEBOARD_PASSWORD"
if [[ ! -f /var/lib/numbus-server/${name}/croned.true ]]; then
$OCC background:cron
sudo -u numbus-admin podman exec --user www-data nextcloud-server php -f /var/www/html/cron.php
touch /var/lib/numbus-server/${name}/croned.true
fi
if [[ ! -f /var/lib/numbus-server/${name}/scanned.true ]]; then
$OCC files:scan --all
$OCC files:repair-tree
touch /var/lib/numbus-server/${name}/scanned.true
fi
'';
};
systemd.services."${name}-cron" = {
description = "Podman container crontab : ${name}";
after = [ "${name}.service" "${name}-quirk.service" ];
onFailure = [ "service-failure-notify@%n.service" ];
path = [ pkgs.sudo pkgs.podman ];
serviceConfig = {
Type = "oneshot";
ExecCondition = ''${pkgs.sudo}/bin/sudo -u numbus-admin podman exec --user www-data nextcloud-server php occ status'';
ExecStart = "${pkgs.sudo}/bin/sudo -u numbus-admin podman exec --user www-data nextcloud-server php -f /var/www/html/cron.php";
};
};
systemd.timers."${name}-cron" = {
description = "Timer for Nextcloud cron";
wantedBy = [ "timers.target" ];
timerConfig = {
OnBootSec = "5m";
OnUnitActiveSec = "5m";
Unit = "${name}-cron.service";
};
};
};
}
@@ -0,0 +1,62 @@
{ config, pkgs, lib, ... }:
with lib;
let
# Container config
name = "ntfy";
# Version tagging
ntfyVersion = "v2.18.0";
# Helper
helper = import ../service-helper.nix { inherit config pkgs lib; };
cfg = config.numbus-server.services.ntfy;
in
helper.mkPodmanService {
inherit name;
description = "Ntfy, get notified easily";
defaultPort = "8099";
middlewares = [
"secureHeaders"
];
dirPermissions = [
"100999:100 ${cfg.configDir}"
"100999:100 ${cfg.configDir}/cache"
"100999:100 ${cfg.configDir}/config"
];
composeText = ''
services:
ntfy:
image: docker.io/binwiederhier/ntfy
container_name: ntfy
hostname: ntfy
user: "1000:1000"
networks:
ntfy:
ipv4_address: 10.89.150.253
ports:
- "${cfg.port}:80/tcp"
command:
- serve
volumes:
- ${cfg.config}/cache:/var/cache/ntfy
- ${cfg.config}/config:/etc/ntfy
environment:
- TZ=${time.timeZone}
security_opt:
- no-new-privileges:true
cap_drop:
- NET_RAW
restart: unless-stopped
networks:
ntfy:
driver: bridge
name: ntfy
ipam:
config:
- subnet: "10.89.150.0/24"
gateway: "10.89.150.254"
'';
}
@@ -0,0 +1,118 @@
{ config, pkgs, lib, ... }:
with lib;
let
# Container config
name = "odoo";
# Version tagging
odooVersion = "10.11.6";
databaseVersion = "15.17";
# Storage optimization
spindown = config.numbus-server.hardware.HddSpindown;
optimizedDir = if spindown.enable && (spindown.optimize == "compatible" || (isList spindown.optimize && elem name spindown.optimize))
then cfg.configDir
else cfg.dataDir;
# Helper
helper = import ../service-helper.nix { inherit config pkgs lib; };
cfg = config.numbus-server.services.odoo;
in
helper.mkPodmanService {
inherit name;
description = "Odoo : An open ERP (Enterprise resource planning) solution";
defaultPort = "8069";
configDirEnabled = optimizedDir == cfg.configDir;
dataDirEnabled = optimizedDir == cfg.dataDir;
middlewares = [
"secureHeaders"
];
dirPermissions = [
"100999:100 ${optimizedDir}"
"100999:100 ${optimizedDir}/odoo"
"100999:100 ${cfg.configDir}/addons"
"100999:100 ${cfg.configDir}/config"
"100999:100 ${cfg.configDir}/database"
];
composeText = ''
services:
odoo-database:
image: docker.io/library/postgres:${databaseVersion}
container_name: odoo-database
hostname: odoo-database
user: '1000:1000'
shm_size: 128mb
networks:
odoo:
ipv4_address: 10.89.190.253
volumes:
- ${cfg.configDir}/database:/var/lib/postgresql/data
environment:
- POSTGRES_DB=${config.sops.placeholder."odoo/db_name"}
- POSTGRES_PASSWORD=${config.sops.placeholder."odoo/db_password"}
- POSTGRES_USER=${config.sops.placeholder."odoo/db_username"}
- PGDATA=/var/lib/postgresql/data
cap_drop:
- NET_RAW
security_opt:
- no-new-privileges:true
restart: unless-stopped
odoo-server:
image: docker.io/library/odoo:${odooVersion}
container_name: odoo-server
hostname: odoo-server
user: '1000:1000'
networks:
odoo:
ipv4_address: 10.89.190.252
ports:
- "${cfg.port}:8069/tcp"
volumes:
- ${optimizedDir}/odoo:/var/lib/odoo
- ${cfg.configDir}/config:/etc/odoo
- ${cfg.configDir}/addons:/mnt/extra-addons
environment:
- HOST=odoo-database
- USER=${config.sops.placeholder."odoo/db_username"}
- PASSWORD=${config.sops.placeholder."odoo/db_password"}
depends_on:
- odoo-database
cap_drop:
- NET_RAW
security_opt:
- no-new-privileges:true
restart: unless-stopped
networks:
odoo:
driver: bridge
name: odoo
ipam:
config:
- subnet: "10.89.190.0/24"
gateway: "10.89.190.254"
'';
extraConfig = {
sops.secrets."odoo/db_name" = {
sopsFile = /etc/nixos/secrets/podman/odoo.yaml;
gid = "100";
uid = "1000";
mode = "0400";
};
sops.secrets."odoo/db_username" = {
sopsFile = /etc/nixos/secrets/podman/odoo.yaml;
gid = "100";
uid = "1000";
mode = "0400";
};
sops.secrets."odoo/db_password" = {
sopsFile = /etc/nixos/secrets/podman/odoo.yaml;
gid = "100";
uid = "1000";
mode = "0400";
};
};
}
@@ -0,0 +1,112 @@
{ config, pkgs, lib, ... }:
with lib;
let
# Container config
name = "passbolt";
# Version tagging
passboltVersion = "5.9.0-1-ce-non-root";
databaseVersion = "12.2";
# Storage optimization
spindown = config.numbus-server.hardware.HddSpindown;
optimizedDir = if spindown.enable && (spindown.optimize == "compatible" || (isList spindown.optimize && elem name spindown.optimize))
then cfg.configDir
else cfg.dataDir;
# Helper
helper = import ../service-helper.nix { inherit config pkgs lib; };
cfg = config.numbus-server.services.passbolt;
in
helper.mkPodmanService {
inherit name;
description = "Passbolt, your password manager";
defaultPort = "4433";
scheme = "https";
dataDirEnabled = false;
middlewares = [ "secureHeaders" ];
dirPermissions = [
"100032:100 ${cfg.configDir}"
"100032:100 ${cfg.configDir}/gpg"
"100032:100 ${cfg.configDir}/jwt"
"100999:100 ${cfg.configDir}/database"
];
secrets = [
"passbolt/db_name"
"passbolt/db_username"
"passbolt/db_password"
];
# Compose file good
composeText = ''
services:
passbolt-server:
image: docker.io/passbolt/passbolt:${passboltVersion}
container_name: passbolt-server
hostname: passbolt-server
user: '33:33'
networks:
passbolt:
ports:
- "${cfg.port}:4433/tcp"
volumes:
- ${cfg.configDir}/gpg:/etc/passbolt/gpg
- ${cfg.configDir}/jwt:/etc/passbolt/jwt
environment:
APP_DEFAULT_TIMEZONE: ${config.time.timeZone}
APP_FULL_BASE_URL: https://${cfg.subdomain}.${config.numbus-server.services.domain}
DATASOURCES_DEFAULT_HOST: "passbolt-database"
DATASOURCES_DEFAULT_USERNAME: ${config.sops.placeholder."passbolt/db_username"}
DATASOURCES_DEFAULT_PASSWORD: ${config.sops.placeholder."passbolt/db_password"}
DATASOURCES_DEFAULT_DATABASE: ${config.sops.placeholder."passbolt/db_name"}
EMAIL_DEFAULT_FROM_NAME: "Passbolt"
EMAIL_TRANSPORT_DEFAULT_HOST: ${config.numbus-server.mail.smtpServer}
EMAIL_TRANSPORT_DEFAULT_PORT: ${toString config.numbus-server.mail.smtpPort}
EMAIL_TRANSPORT_DEFAULT_USERNAME: ${config.numbus-server.mail.smtpUsername}
EMAIL_TRANSPORT_DEFAULT_PASSWORD: ${config.sops.placeholder."mail/smtpPassword"}
EMAIL_TRANSPORT_DEFAULT_TLS: true
EMAIL_DEFAULT_FROM: passbolt-noreply@${config.numbus-server.services.domain}
PASSBOLT_SSL_FORCE: true
command:
[
"/usr/bin/wait-for.sh",
"-t",
"0",
"passbolt-database:3306",
"--",
"/docker-entrypoint.sh"
]
depends_on:
- passbolt-database
security_opt:
- no-new-privileges:true
cap_drop:
- NET_RAW
restart: unless-stopped
passbolt-database:
image: docker.io/library/mariadb:${databaseVersion}
container_name: passbolt-database
hostname: passbolt-database
user: '1000:1000'
networks:
passbolt:
volumes:
- ${cfg.configDir}/database:/var/lib/mysql
environment:
MYSQL_RANDOM_ROOT_PASSWORD: "true"
MYSQL_DATABASE: ${config.sops."passbolt/db_name"}
MYSQL_USER: ${config.sops."passbolt/db_username"}
MYSQL_PASSWORD: ${config.sops."passbolt/db_password"}
security_opt:
- no-new-privileges:true
cap_drop:
- NET_RAW
restart: unless-stopped
networks:
passbolt:
name: passbolt
driver: bridge
'';
}
@@ -0,0 +1,178 @@
{ config, pkgs, lib, ... }:
with lib;
let
# Container config
name = "traefik";
# Version tagging
traefikVersion = "v3.6.8";
# Helper
helper = import ../service-helper.nix { inherit config pkgs lib; };
cfg = config.numbus-server.services.traefik;
in
helper.mkPodmanService {
inherit name;
description = "Traefik reverse proxy, one to rule them all";
defaultPort = "7780";
pod = "false";
startDelay = 10;
dataDirEnabled = false;
middlewares = [
"secureHeaders"
];
dependencies = [
"sops-install-secrets.service"
"network-online.target"
];
dirPermissions = [
"100999:100 ${cfg.configDir}"
"100999:100 ${cfg.configDir}/certs"
];
# Compose file good
composeText = ''
services:
traefik:
image: docker.io/library/traefik:${traefikVersion}
container_name: traefik
hostname: traefik
user: '1000:1000'
network_mode: pasta
ports:
- "${cfg.port}:8080/tcp"
- "443:443/tcp"
volumes:
- ${config.sops.templates."traefik/config".path}:/etc/traefik/traefik.yaml:ro
- ${cfg.configDir}/certs:/var/traefik/certs
- /etc/traefik/rules:/etc/traefik/rules:ro
environment:
- CF_DNS_API_TOKEN=${config.sops.placeholder."traefik/cloudflare_api_token"}
cap_add:
- NET_BIND_SERVICE
security_opt:
- no-new-privileges:true
restart: unless-stopped
'';
extraConfig = {
sops.secrets."traefik/cloudflare_api_token" = {
sopsFile = /etc/nixos/secrets/podman/traefik.yaml;
gid = "100";
uid = "1000";
mode = "0400";
};
sops.templates."traefik/config"= {
gid = "100";
uid = "100999";
mode = "0400";
content = ''
global:
checkNewVersion: false
sendAnonymousUsage: false
log:
level: ${cfg.logLevel}
accesslog: {}
api:
dashboard: true
insecure: false
entryPoints:
web:
address: :80
http:
redirections:
entryPoint:
to: websecure
scheme: https
websecure:
address: :443
forwardedHeaders:
trustedIPs:
- "127.0.0.1/32"
- "10.0.0.0/8"
- "192.168.0.0/16"
- "172.16.0.0/12"
certificatesResolvers:
cloudflare:
acme:
email: ${config.numbus-server.mail.adminAddress}
storage: /var/traefik/certs/cloudflare-acme.json
caServer: "https://acme-v02.api.letsencrypt.org/directory"
dnsChallenge:
provider: cloudflare
resolvers:
- "1.1.1.1:53"
- "9.9.9.9:53"
serversTransport:
insecureSkipVerify: true
providers:
file:
directory: "/etc/traefik/rules"
watch: true
'';
path = "/etc/traefik/traefik.yaml";
};
sops.templates."traefik/rules/secureHeaders" = {
gid = "100";
uid = "100999";
mode = "0400";
content = ''
http:
middlewares:
secureHeaders:
headers:
FrameDeny: true
AccessControlAllowMethods: 'GET,OPTIONS,PUT'
AccessControlAllowOriginList:
- origin-list-or-null
AccessControlMaxAge: 100
AddVaryHeader: true
BrowserXssFilter: true
ContentTypeNosniff: true
ForceSTSHeader: true
STSIncludeSubdomains: true
STSPreload: true
ContentSecurityPolicy: default-src 'self' 'unsafe-inline'
CustomFrameOptionsValue: SAMEORIGIN
ReferrerPolicy: same-origin
PermissionsPolicy: vibrate 'self'
STSSeconds: 315360000
'';
path = "/etc/traefik/rules/secureHeaders.yaml";
};
sops.templates."traefik/rules/secureTLS" = {
gid = "100";
uid = "100999";
mode = "0400";
content = ''
tls:
options:
secureTLS:
minVersion: VersionTLS12
sniStrict: true
curvePreferences:
- CurveP521
- CurveP384
cipherSuites:
- TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256
- TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384
- TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256
'';
path = "/etc/traefik/rules/secureTLS.yaml";
};
};
extraOptions = {
enable.default = true;
logLevel = mkOption {
type = types.enum [ "TRACE" "DEBUG" "INFO" "WARN" "ERROR" "FATAL" ];
default = "ERROR";
example = "ERROR";
description = "The level of detail Traefik should print in the logs.";
};
};
}
@@ -0,0 +1,54 @@
{ config, pkgs, lib, ... }:
with lib;
let
# Container config
name = "uptimeKuma";
# Version tagging
uptimeKumaVersion = "2.2.0-rootless";
# Storage optimization
spindown = config.numbus-server.hardware.HddSpindown;
optimizedDir = if spindown.enable && (spindown.optimize == "compatible" || (isList spindown.optimize && elem name spindown.optimize))
then cfg.configDir
else cfg.dataDir;
# Helper
helper = import ../service-helper.nix { inherit config pkgs lib; };
cfg = config.numbus-server.services.uptimeKuma;
in
helper.mkPodmanService {
inherit name;
description = "Uptime-Kuma, \"don't let your server down !\" monitoring tools";
defaultPort = "3001";
scheme = "http";
middlewares = [ "secureHeaders" ];
dirPermissions = [ "100999:100 ${optimizedDir}" ];
composeText = ''
services:
uptimekuma:
image: docker.io/louislam/uptime-kuma:${uptimeKumaVersion}
container_name: uptime-kuma
hostname: uptime-kuma
user: '1000:1000'
networks:
uptime-kuma:
ipv4_address: 10.89.100.253
ports:
- "${cfg.port}:3001/tcp"
volumes:
- ${optimizedDir}:/app/data
security_opt:
- no-new-privileges:true
restart: unless-stopped
networks:
uptime-kuma:
driver: bridge
ipam:
config:
- subnet: "10.89.100.0/24"
gateway: "10.89.100.254"
'';
}
@@ -0,0 +1,81 @@
{ config, pkgs, lib, ... }:
with lib;
let
# Container config
name = "vscodium";
# Version tagging
vscodiumVersion = "1.110.11607-ls15";
# Storage optimization
spindown = config.numbus-server.hardware.HddSpindown;
optimizedDir = if spindown.enable && (spindown.optimize == "compatible" || (isList spindown.optimize && elem name spindown.optimize))
then cfg.configDir
else cfg.dataDir;
# Helper
helper = import ../service-helper.nix { inherit config pkgs lib; };
cfg = config.numbus-server.services.vscodium;
in
helper.mkPodmanService {
inherit name;
description = "VScodium, an open-source version of VScode in your web browser";
defaultPort = "8000";
configDirEnabled = optimizedDir == cfg.configDir;
dataDirEnabled = optimizedDir == cfg.dataDir;
middlewares = [ "secureHeaders" ];
dirPermissions = [
"100999:100 ${optimizedDir}"
"100999:100 ${cfg.configDir}"
"100999:100 ${optimizedDir}/workspace"
"100999:100 ${cfg.configDir}/config"
];
composeText = ''
services:
vscodium:
image: lscr.io/linuxserver/vscodium-web:${vscodiumVersion}
container_name: vscodium
hostname: vscodium
user: '1000:1000'
networks:
vscodium:
ipv4_address: 10.89.50.253
ports:
- "${defaultPort}:8000"
volumes:
- ${cfg.configDir}/config:/config
- ${optimizedDir}/workspace:/workspace
environment:
- PUID=1000
- PGID=1000
- TZ=${time.timeZone}
- CONNECTION_TOKEN=${config.sops.placeholder."vscodium/connection_token"}
shm_size: "1gb"
cap_add:
- IPC_LOCK
cap_drop:
- NET_RAW
security_opt:
- no-new-privileges:true
restart: unless-stopped
networks:
vscodium:
name: vscodium
driver: bridge
ipam:
config:
- subnet: "10.89.50.0/24"
gateway: "10.89.50.254"
'';
extraConfig = {
sops.secrets."vscodium/connection_token" = {
sopsFile = /etc/nixos/secrets/podman/vscodium.yaml;
gid = "100";
uid = "1000";
mode = "0400";
};
};
}
+9
View File
@@ -0,0 +1,9 @@
{ ... }:
{
imports = [
./applications/default.nix
./dns/default.nix
./system/default.nix
];
}
+53
View File
@@ -0,0 +1,53 @@
{ config, pkgs, lib, ... }:
with lib;
let
# Version tagging
adguardVersion = "latest";
# Helper
helper = import ../service-helper.nix { inherit config pkgs lib; };
cfg = config.numbus-server.services.adguard;
# Container config
name = "adguard";
in
helper.mkPodmanService {
inherit name;
description = "AdGuard, feature-rich DNS service";
defaultPort = "3000";
scheme = "http";
dataDirEnabled = false;
startDelay = 10;
dependencies = [
"network.target"
];
middlewares = [
"secureHeaders"
];
dirPermissions = [
"100999:100 ${cfg.configDir}"
];
composeText = ''
services:
adguardhome:
image: adguard/adguardhome:${adguardVersion}
container_name: adguard
hostname: adguard
network_mode: pasta
user: '1000:1000'
ports:
- "${cfg.port}:3000/tcp"
- "53:53/tcp"
- "53:53/udp"
volumes:
- ${cfg.configDir}/work:/opt/adguardhome/work
- ${cfg.configDir}/config:/opt/adguardhome/conf
cap_add:
- SYS_NICE
security_opt:
- no-new-privileges:true
restart: unless-stopped
'';
}
+10
View File
@@ -0,0 +1,10 @@
{ ... }:
{
imports = [
# To test
./adguard.nix
# Tested
./pi-hole.nix
];
}
+71
View File
@@ -0,0 +1,71 @@
{ config, pkgs, lib, ... }:
with lib;
let
# Version tagging
piholeVersion = "2026.02.0";
# Helper
helper = import ../service-helper.nix { inherit config pkgs lib; };
cfg = config.numbus-server.services.pi-hole;
# Container config
name = "pi-hole";
# DNS config
dnsConfig = ''
'';
in
helper.mkPodmanService {
inherit name;
description = "Pi-Hole, the ads black hole";
defaultPort = "4443";
scheme = "https";
dataDirEnabled = false;
startDelay = 10;
dependencies = [
"network.target"
];
middlewares = [
"secureHeaders"
];
dirPermissions = [
"100999:100 ${cfg.configDir}"
];
secrets = [
"pi-hole/web_password"
];
# Compose file good
composeText = ''
services:
pi-hole:
image: docker.io/pihole/pihole:${piholeVersion}
container_name: pi-hole
hostname: pi-hole
network_mode: pasta
ports:
- "${cfg.port}:443/tcp"
- "53:53/tcp"
- "53:53/udp"
volumes:
- ${cfg.configDir}:/etc/pihole
environment:
PIHOLE_UID: '1000'
PIHOLE_GID: '1000'
TZ: ${config.time.timeZone}
FTLCONF_webserver_domain: ${cfg.subdomain}.${config.numbus-server.services.domain}
FTLCONF_dns_domain_name: "${config.numbus-server.services.domain}"
FTLCONF_webserver_api_password: ${config.sops.placeholder."pi-hole/web_password"}
FTLCONF_dns_upstreams: 9.9.9.9;149.112.112.112
FTLCONF_dns_listeningMode: "BIND"
FTLCONF_dns_domain_local: "true"
FTLCONF_dhcp_active: "false"
FTLCONF_ntp_ipv4_active: "false"
FTLCONF_ntp_ipv6_active: "false"
FTLCONF_ntp_sync_active: "false"
cap_add:
- SYS_NICE
restart: unless-stopped
'';
}
+19
View File
@@ -0,0 +1,19 @@
{ config, ... }:
{
options.numbus = {
services = {
domain = mkOption {
type = types.str;
example = "numbus.eu";
description = "The root domain name (i.e. example.com) that your services will use";
};
dns = mkOption {
type = types.enum [ "pi-hole" "adguard" ];
default = "pi-hole";
example = "pi-hole";
description = "The preferred DNS resolver service (pi-hole or adguard) that other services should depend on";
};
};
};
}
@@ -0,0 +1,5 @@
{ ... }:
{
}
+91
View File
@@ -0,0 +1,91 @@
{ config, lib, pkgs, ... }:
with lib;
let
cfg = config.numbus-server.services.clamav;
onAccessPaths = lib.mapAttrsToList (n: v: v.dataDir) (lib.filterAttrs (n: v:
v ? enable && v.enable && v ? dataDir && v.dataDir != null && v.dataDir != false
) config.numbus-server.services);
clamonacc_virus_notifier = pkgs.writeScript "clamonacc_virus_notifier.sh" ''
#!${pkgs.bash}/bin/bash
echo "CLAM_VIRUSEVENT_VIRUSNAME=\"$CLAM_VIRUSEVENT_VIRUSNAME\"" > /var/lib/clamav/virus_event.env
echo "CLAM_VIRUSEVENT_FILENAME=\"$CLAM_VIRUSEVENT_FILENAME\"" >> /var/lib/clamav/virus_event.env
/run/wrappers/bin/sudo /run/current-system/sw/bin/systemctl start clamav-virus-notify.service
'';
in
{
options.numbus-server.services.clamav = {
enable = mkEnableOption "ClamAV open-source anti-virus software";
};
config = mkIf cfg.enable {
environment.systemPackages = [ pkgs.clamav pkgs.curl ];
system.activationScripts.clamav-quarantine = ''
mkdir -p /quarantine
chown clamav:clamav /quarantine
chmod 440 /quarantine
'';
security.sudo.extraRules = [{
users = [ "clamav" ];
commands = [{
command = "/run/current-system/sw/bin/systemctl start clamav-virus-notify.service";
options = [ "NOPASSWD" ];
}];
}];
services.clamav = {
updater.enable = true;
clamonacc.enable = true;
scanner = {
enable = true;
interval = "*-*-* 04:00:00"; # Everyday at 4am
scanDirectories = [
"/etc"
"/home"
"/var/lib"
"/var/tmp"
"/tmp"
];
};
daemon = {
enable = true;
settings = {
OnAccessPrevention = true;
OnAccessIncludePath = onAccessPaths;
VirusEvent = "${clamonacc_virus_notifier}";
};
};
};
systemd.services.clamav-periodic-scan = mkIf (onAccessPaths != []) {
description = "Periodic ClamAV virus scan";
after = [ "clamav-daemon.service" "clamav-freshclam.service" ];
requires = [ "clamav-daemon.service" ];
wants = [ "clamav-freshclam.service" ];
onFailure = [ "clamav-virus-notify.service" ];
serviceConfig = {
Type = "oneshot";
ExecStart = "${pkgs.clamav}/bin/clamdscan --multiscan --fdpass --infected --allmatch --move=/quarantine ${lib.escapeShellArgs onAccessPaths}";
Slice = "system-clamav.slice";
};
};
systemd.timers.clamav-periodic-scan = mkIf (onAccessPaths != []) {
description = "Timer for ClamAV periodic scan";
wantedBy = [ "timers.target" ];
timerConfig = {
OnCalendar = "*-1/3-01 04:00:00";
Persistent = true;
Unit = "clamav-periodic-scan.service";
};
};
};
}
@@ -0,0 +1,10 @@
{ ... }:
{
imports = [
# To test
./backup-client.nix
./clamav.nix
./virtualization.nix
];
}
@@ -0,0 +1,17 @@
{ config, lib, ... }:
with lib;
let
cfg = config.numbus-server.services.virtualization;
in
{
options.numbus-server.services.virtualization = {
enable = mkEnableOption "QEMU/KVM virtualization software";
};
config = mkIf cfg.enable {
virtualisation.libvirtd.enable = true;
};
}
+144
View File
@@ -0,0 +1,144 @@
{ config, pkgs, inputs, ... }:
{
imports = [ ];
# Bootloader options
boot.initrd.systemd.enable = true;
boot.loader.systemd-boot.enable = true;
boot.loader.efi.canTouchEfiVariables = true;
# Boot splash instead of log messages
boot = {
plymouth.enable = true;
# Enable "Silent boot"
consoleLogLevel = 3;
initrd.verbose = false;
kernelParams = [
"quiet"
"udev.log_level=3"
"systemd.show_status=auto"
"pcie_aspm=off"
];
loader.timeout = 1;
};
# Hardware settings
hardware.enableRedistributableFirmware = true;
hardware.cpu.intel.updateMicrocode = true;
hardware.cpu.amd.updateMicrocode = true;
# Enable networking
networking.networkmanager.enable = true;
networking.hostName = "nix-tv"; # Define your hostname.
# Set your time zone.
time.timeZone = "Europe/Paris";
# Select internationalisation properties.
i18n.defaultLocale = "fr_FR.UTF-8";
i18n.extraLocaleSettings = {
LC_ADDRESS = "fr_FR.UTF-8";
LC_IDENTIFICATION = "fr_FR.UTF-8";
LC_MEASUREMENT = "fr_FR.UTF-8";
LC_MONETARY = "fr_FR.UTF-8";
LC_NAME = "fr_FR.UTF-8";
LC_NUMERIC = "fr_FR.UTF-8";
LC_PAPER = "fr_FR.UTF-8";
LC_TELEPHONE = "fr_FR.UTF-8";
LC_TIME = "fr_FR.UTF-8";
};
# Disable the X11 windowing system.
services.xserver.enable = false;
services.xserver.xkb.layout = "fr";
# Enable the KDE Plasma Desktop Environment.
services.displayManager.sddm.enable = true;
services.displayManager.sddm.wayland.enable = true;
services.desktopManager.plasma6.enable = true;
services.displayManager.autoLogin.enable = true;
services.displayManager.autoLogin.user = "nix-tv";
# Configure console keymap
console.keyMap = "fr";
# Enable CUPS to print documents.
services.printing.enable = true;
# Enable sound with pipewire.
services.pulseaudio.enable = false;
security.rtkit.enable = true;
services.pipewire = {
enable = true;
alsa.enable = true;
alsa.support32Bit = true;
pulse.enable = true;
};
# Define a user account. Don't forget to set a password with passwd.
users.users.nix-tv = {
isNormalUser = true;
description = "Nix TV";
extraGroups = [ "networkmanager" "wheel" ];
uid = 1000;
initialPassword = "changeMe!";
openssh.authorizedKeys.keys = [
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAINjX8YqPMXpuqIrEClMHD2Ol+gqj3+28rYXGWybVNpim raphael@MacBook-Pro-de-Raphael.local"
];
};
# Login message
environment.loginShellInit = ''
if [ "$(id -u)" -eq 1000 ]; then
if [ -n "$SSH_TTY" ]; then
fastfetch
echo -e "\n\nWelcome to your Nix TV !\n\n- This system is managed by NixOS\n- All changes are futile\n- Please consider buying support if you can't get your TV running\n- Have a nice day and enjoy !"
fi
fi
'';
# Allow unfree packages
nixpkgs.config.allowUnfree = true;
environment.systemPackages = with pkgs; [
fastfetch
];
# Install flatpak
services.flatpak.enable = true;
# Enable the OpenSSH daemon.
services.openssh.enable = true;
# Open ports in the firewall.
networking.firewall.allowedTCPPorts = [ ];
networking.firewall.allowedUDPPorts = [ ];
networking.firewall.enable = true;
# Enable auto updates
system.autoUpgrade = {
enable = true;
allowReboot = false;
flake = inputs.self.outPath;
flags = [ "--print-build-logs" ];
dates = "20:00";
randomizedDelaySec = "45min";
};
nix.gc = {
automatic = true;
dates = "weekly";
options = "--delete-older-than 7d";
};
# Enable NixOS flakes
nix.settings.experimental-features = [ "nix-command" "flakes" ];
# Enable auto nix-store optimization
nix.settings.auto-optimise-store = true;
system.stateVersion = "25.11";
}
+23
View File
@@ -0,0 +1,23 @@
{
description = "Flake file for Nix TV project";
inputs = {
nixpkgs.url = "github:nixos/nixpkgs?ref=nixos-unstable";
nix-flatpak.url = "github:gmodena/nix-flatpak/?ref=latest";
};
outputs = { self, nixpkgs, nix-flatpak, ... }@inputs: {
nixosConfigurations."nix-tv" = nixpkgs.lib.nixosSystem {
specialArgs = { inherit inputs; };
system = "x86_64-linux";
modules = [
# Nix flatpak
nix-flatpak.nixosModules.nix-flatpak
./flatpak.nix
# Core configuration
./configuration.nix
./hardware-configuration.nix
];
};
};
}
+18
View File
@@ -0,0 +1,18 @@
{ lib, ... }:
{
services.flatpak.remotes = lib.mkOptionDefault [{
name = "flathub";
location = "https://dl.flathub.org/repo/flathub.flatpakrepo";
}];
services.flatpak.update.auto.enable = true;
services.flatpak.uninstallUnmanaged = true;
services.flatpak.packages = [
"io.gitlab.librewolf-community"
"app.grayjay.Grayjay"
"com.github.unrud.VideoDownloader"
"com.github.tchx84.Flatseal"
];
}
+1399
View File
@@ -0,0 +1,1399 @@
#!/usr/bin/env nix-shell
#!nix-shell -i bash -p bash nano coreutils gnused gum fastfetch xkcdpass sops ssh-to-age age sshpass envsubst pciutils usbutils mosquitto curl jq yq python3
# --- UTILITY FUNCTIONS --->
echod() {
MESSAGE=${1}
if [[ ${DEBUG} -eq 1 ]]; then
echo -e ${MESSAGE}
fi
}
ssh_to_host() {
local COMMAND="${1}"
ssh -i "${EXTRA_FILES_PATH}/home/numbus-admin/.ssh/id_ed25519" "${TARGET_USER}@${LIVE_TARGET_IP}" "${COMMAND}"
}"Invalid IP address format."
get_valid_input() {
local VAR_NAME="${1}"
local HEADER="${2}"
local PLACEHOLDER="${3}"
local REGEX="${4}"
local MANDATORY="${5:-true}"
local SENSITIVE="${6:-false}"
if [[ "${MANDATORY}" == "true" ]]; then
local PROMPT="(Required) > "
elif [[ "${MANDATORY}" == "false" ]]; then
local PROMPT="(Optional) > "
fi
while true; do
local INPUT=$(gum input --header "${HEADER}" --prompt "${PROMPT}" --placeholder "${PLACEHOLDER}")
# Handle empty input
if [[ -z "${INPUT}" ]]; then
if [[ "${MANDATORY}" == true ]]; then
gum style --foreground "#ff0000" -- "✖ This field is mandatory."
continue
else
INPUT=""
break
fi
fi
# Handle Regex Validation
if [[ -n "${REGEX}" ]]; then
if [[ "${INPUT}" =~ ${REGEX} ]]; then
export "${VAR_NAME}"="${INPUT}"
break
else
gum style --foreground "#ff0000" -- "✖ Invalid format. Please try again."
fi
else
export "${VAR_NAME}"="${INPUT}"
break
fi
done
}
# --- UTILITY FUNCTIONS ---<
# --- GLOBAL FUNCTIONS --->
cleanup() {
echo -e "\n ✅ Cleaning up..."
rm -${DIR_RM_FLAGS} ${TMP_FILES_PATH}/
if [[ ${WEB_MODE} -eq 1 && -n "${BRIDGE_PID:-}" ]]; then
kill ${BRIDGE_PID}
fi
}
compatibility_check() {
TEST_FAIL=0
if [[ -r /etc/os-release ]] && grep -qi '^ID=nixos\b' /etc/os-release; then
echod "\n ✅ NixOS system detected."
else
TEST_FAIL=$((TEST_FAIL + 1))
echo -e "\n ❌ You are not on a NixOS based system. This is required to continue."
fi
if [[ "$(uname -m)" == "x86_64" ]]; then
echod "\n ✅ x86_64 system detected."
else
TEST_FAIL=$((TEST_FAIL + 1))
echo -e "\n ❌ You are not on a x86_64 based system. This is required to continue."
fi
if [[ ${TEST_FAIL} -gt 0 ]]; then
COMPATIBILITY_OVERRIDE=$(gum choose --header "Some compatibility checks failed. The installation will very likely fail. Continue ?" \
"No" \
"Yes, I know what I am doing")
[[ "${COMPATIBILITY_OVERRIDE}" == "No" ]] && exit 1
[[ "${COMPATIBILITY_OVERRIDE}" != "No" ]] && echo -e "\n ⚠️ Continuing anyways, this is not supported by Numbus."
fi
return 0
}
hierarchy_preparation() {
echod "\n 🔄 Preparing the folder hierarchy for the final configuration..."
if [[ -e config/* ]]; then
echo " ⚠️ It seems you have already run this script. Previously generated files need to be cleaned up."
OLD_CONFIG_PATH="trash/$(date +"%Y-%m-%d-%Hh%M")/"
mkdir -${MKDIR_FLAGS} ${OLD_CONFIG_PATH}
mv -${MV_FLAGS} config/ ${OLD_CONFIG_PATH}
echo " ✅ Your files have been moved to the ${OLD_CONFIG_PATH} directory. You can retrieve them there if needed."
fi
# Script folders
mkdir -${MKDIR_FLAGS} ${TMP_FILES_PATH}/config
mkdir -${MKDIR_FLAGS} ${TMP_FILES_PATH}/logs
mkdir -${MKDIR_FLAGS} ${TMP_FILES_PATH}/tmp
[[ ${WEB_MODE} -eq 1 ]] && mkdir -${MKDIR_FLAGS} ${TMP_FILES_PATH}/web
# Secrets
mkdir -${MKDIR_FLAGS} ${EXTRA_FILES_PATH}/home/numbus-admin/.ssh/
mkdir -${MKDIR_FLAGS} ${EXTRA_FILES_PATH}/var/lib/sops-nix/
mkdir -${MKDIR_FLAGS} ${EXTRA_FILES_PATH}/etc/nixos/secrets/disks
mkdir -${MKDIR_FLAGS} ${EXTRA_FILES_PATH}/etc/nixos/secrets/system
if [[ "${DEVICE_TYPE}" == "server" || "${DEVICE_TYPE}" == "backup" ]]; then
mkdir -${MKDIR_FLAGS} ${EXTRA_FILES_PATH}/etc/nixos/secrets/podman
fi
echod "\n ✅ Folder hierarchy ready"
}
hardware_detection() {
local TMPFILE="/tmp/nixos-installation-hw-detection"
ssh_to_host 'bash -s' << SSHEND
TARGET_GRAPHICS_BRAND=()
for brand in Intel AMD NVIDIA; do
if lspci -nn 2>/dev/null | grep -i "vga" | grep -iq "\${brand}"; then
TARGET_GRAPHICS="true"
TARGET_GRAPHICS_BRAND+=("\${brand}")
else
TARGET_GRAPHICS="false"
fi
done
ls /dev/dri/ > /dev/null 2>&1 | grep -iq "renderD128" && TARGET_GRAPHICS_RENDERER="true" || TARGET_GRAPHICS_RENDERER="false"
lsusb > /dev/null 2>&1 | grep -iq "google" && TARGET_USB_CORAL="true" || TARGET_USB_CORAL="false"
lspci -nn > /dev/null 2>&1 | grep -iq "089a" && TARGET_PCIE_CORAL="true" || TARGET_PCIE_CORAL="false"
ls /dev/serial/by-id/ > /dev/null 2>&1 | grep -i "zigbee" && TARGET_ZIGBEE_DEVICE=\$(ls /dev/serial/by-id/ > /dev/null 2>&1 | grep -i "zigbee" | head -n 1) || TARGET_ZIGBEE_DEVICE=""
TARGET_INTERFACE=\$(ip -4 route show default | awk '{print \$5}' | head -n1)
if ls -l /sys/class/tpm/tpm0/ > /dev/null 2>&1; then
TARGET_TPM="true"
TARGET_TPM_VERSION=\$(cat /sys/class/tpm/tpm0/tpm_version_major)
else
TARGET_TPM="false"
TARGET_TPM_VERSION="N/A"
fi
HDD=1
DISK_DEVPATH=()
DISK_NAME=()
DISK_TYPE=()
DISK_HEALTH=()
DISK_ID=()
for DISK in \$(lsblk -x SIZE -d -n -e 7,11 -o NAME); do
# Disk name and simple path
DISK_DEVPATH+=("/dev/\$DISK")
DISK_NAME+=("\$DISK")
# Disk type
HDD=\$(cat /sys/block/\$DISK/queue/rotational)
TRANSPORT_PROTOCOL=\$(lsblk -x SIZE -d -n -e 7,11 -o TRAN /dev/\$DISK)
if [[ "\$DISK" == "nvme*" ]]; then DISK_TYPE+=("NVMe");
elif [[ "\$TRANSPORT_PROTOCOL" == "usb" ]]; then DISK_TYPE+=("USB");
elif [[ "\$HDD" -eq 1 ]]; then DISK_TYPE+=("HDD");
elif [[ "\$HDD" -eq 0 ]]; then DISK_TYPE+=("SSD");
else DISK_TYPE+=("Other")
fi
# Disk health
if [[ \$(echo "${LIVE_TARGET_PASSWORD}" | sudo -S smartctl -H /dev/\$DISK 2>/dev/null | grep 'self-assessment' | awk '{print \$6}') == "PASSED" ]]; then
DISK_HEALTH+=("PASSED")
else
DISK_HEALTH+=("N/A")
fi
# Disk ID
DISK_ID+=("\$(ls -l /dev/disk/by-id | grep -m1 "../../\$DISK" | awk '{print "/dev/disk/by-id/" \$9}')")
DISK_SIZE+=("\$(lsblk -x SIZE -d -n -e 7,11 -o SIZE /dev/\$DISK)")
done
echo "# Hardware detection results on \$(date)" > "${TMPFILE}"
for var in \
TARGET_GRAPHICS \
TARGET_GRAPHICS_RENDERER \
TARGET_USB_CORAL \
TARGET_PCIE_CORAL \
TARGET_ZIGBEE_DEVICE \
TARGET_INTERFACE \
TARGET_TPM \
TARGET_TPM_VERSION; do
echo "export \${var}=\${!var}" >> "${TMPFILE}"
done
for var in \
TARGET_GRAPHICS_BRAND \
DISK_DEVPATH \
DISK_NAME \
DISK_TYPE \
DISK_HEALTH \
DISK_ID \
DISK_SIZE; do
declare -p \${var} | sed 's/^declare /declare -g /' >> "${TMPFILE}"
done
SSHEND
scp -i "${EXTRA_FILES_PATH}/home/numbus-admin/.ssh/id_ed25519" "${TARGET_USER}@${LIVE_TARGET_IP}":"${TMPFILE}" "${TMPFILE}" &> /dev/null
source "${TMPFILE}"
local DISK_FLAT_ARRAY=()
for i in "${!DISK_NAME[@]}"; do
DISK_FLAT_ARRAY+=("${DISK_NAME[$i]}" "${DISK_DEVPATH[$i]}" "${DISK_TYPE[$i]}" "${DISK_HEALTH[$i]}" "${DISK_ID[$i]}" "${DISK_SIZE[$i]}")
done
jq -n \
--argjson graphics_enabled "${TARGET_GRAPHICS:-false}" \
--argjson graphics_renderer "${TARGET_GRAPHICS_RENDERER:-false}" \
--argjson tpu_usb "${TARGET_USB_CORAL:-false}" \
--argjson tpu_pcie "${TARGET_PCIE_CORAL:-false}" \
--argjson tpm_enabled "${TARGET_TPM:-false}" \
--arg tpm_version "${TARGET_TPM_VERSION:-N/A}" \
--arg zigbee_device "${TARGET_ZIGBEE_DEVICE:-}" \
--arg interface "${TARGET_INTERFACE:-}" \
--argjson brands "$(jq -n '$ARGS.positional' --args ${TARGET_GRAPHICS_BRAND[@]:-})" \
'
{
graphics: { enabled: $graphics_enabled, brands: $brands, renderer: $graphics_renderer },
tpu: { usb: $tpu_usb, pcie: $tpu_pcie },
tpm: { enabled: $tpm_enabled, version: $tpm_version },
zigbee: { device: $zigbee_device },
network: { interface: $interface },
disks: [
$ARGS.positional | range(0; length; 6) as $i | {
name: .[$i], path: .[$i+1], type: .[$i+2], health: .[$i+3], id: .[$i+4], size: .[$i+5]
}
]
}' --args "${DISK_FLAT_ARRAY[@]:-}" > ${HARDWARE_DATA_PATH}
if ssh_to_host "sudo nixos-generate-config --no-filesystems --show-hardware-config" > ${EXTRA_FILES_PATH}/etc/nixos/hardware-configuration.nix; then
echo -e "\n✅ Hardware configuration generated"
else
echo -e "\n❌ Failed to generate hardware configuration"
exit 1
fi
}
# --- GLOBAL FUNCTIONS ---<
# --- MAIN WEB FUNCTIONS --->
launch_configurator() {
echo -e "\n 🚀 Launching Numbus Configurator..."
python3 "${BRIDGE_SCRIPT}" > /dev/null 2>&1 &
export BRIDGE_PID=$!
echo -e "\n ➡️ Open your browser at: $(gum style --foreground 212 "http://localhost:${WEBSERVER_PORT}")"
xdg-open "http://localhost:${WEBSERVER_PORT}" 2>/dev/null || open "http://localhost:${WEBSERVER_PORT}" 2>/dev/null || true
}
# --- MAIN WEB FUNCTIONS ---<
# --- MAIN TUI FUNCTIONS --->
preparation() {
echo -e "\n ➡️ This script will now guide you through the configuration and gather the necessary information."
echo ""
RAW_DEVICE_TYPE=$(gum choose --header "Choose the device you want to deploy :" \
"Numbus Server : Professional-grade hosting, strictly kept under your roof." \
"Numbus Backup Server : Automated, high-efficiency protection for your entire ecosystem." \
"Numbus Computer : A modern, privacy-respecting machine built for work, creation, and play — without the corporate bloat." \
"Numbus TV : A premium cinematic experience free from trackers and forced subscriptions.")
case "${RAW_DEVICE_TYPE}" in
"Numbus Server : "* ) DEVICE_TYPE="server" ;;
"Numbus Backup Server : "* ) DEVICE_TYPE="backup" ;;
"Numbus Computer : "* ) DEVICE_TYPE="computer" ;;
"Numbus TV : "* ) DEVICE_TYPE="tv" ;;
esac
RAW_DEPLOYMENT_MODE=$(gum choose --header "Choose your preferred deployment mode :" \
"Interactive : You don't already have a configuration." \
"Non-interactive : You have a valid configuration hosted on a Git platform.")
case "${RAW_DEPLOYMENT_MODE}" in
"Interactive : "* ) DEPLOYMENT_MODE="interactive" ;;
"Non-interactive : "* ) DEPLOYMENT_MODE="non-interactive" ;;
esac
if [[ "${DEPLOYMENT_MODE}" == "non-interactive" ]]; then
git_url() {
IMPORTED_CONFIG_URL=$(gum input --placeholder "https://yourgitplatform.tld/your-user/repo-containing-the-configuration" --header "Please provide the URL to the git repository containing your configuration :")
}
git_url
until git clone "${IMPORTED_CONFIG_URL}" imported_configuration; do
echo -e "\n ⚠️ This did not work correctly."
echo -e "\n Is this URL correct [y/n] ? ${IMPORTED_CONFIG_URL}"
read URL
if [[ "${URL^^}" == "N" ]]; then
git_url
fi
echo -e "\n You will be prompted for your credentials again. Make sure that they are correct."
done
fi
echo ""
gum format -- \
"➡️ To continue, you need to start the target device in a NixOS live environment :
1. Download the NixOS iso from the **[official website](https://nixos.org/download/)**.
2. Flash it to a USB stick. (use a flashing tool like **[Rufus](https://rufus.ie/en/#download)**, **[BalenaEtcher](https://etcher.balena.io/#download-etcher)**, **[Impression](https://flathub.org/en/apps/io.gitlab.adhami3310.Impression)**, ...)
3. Make sure your computer allows booting from USB drives and is in UEFI mode.
4. Boot into the NixOS live environment.
5. Launch a terminal. Set a password using \`passwd\` and find the IP address using \`ip a\`"
echo ""
gum confirm "Is the device ready ?" || { echo "❌ You need to prepare the device. The script cannot continue."; exit 1; }
# LIVE TARGET SETTINGS
user_input "LIVE_TARGET_IP" " Please provide the IP address of the target host :" "For example : 192.168.1.100" "${IP_REGEX}"
user_input "LIVE_TARGET_PASSWORD" " Please enter the password for '${TARGET_USER}@${LIVE_TARGET_IP}' :" "${LIVE_TARGET_IP}'s password" "" "" "true"
# INTERNATIONALIZATION SETTINGS
user_input "INTERNATIONALIZATION_TIMEZONE" " Please provide the wanted timezone :" "For example : Europe/Paris, Europe/Berlin, Europe/London, etc"
user_input "INTERNATIONALIZATION_LANGUAGE" " Please provide the wanted language :" "For example : French, Deutsch, English, etc"
user_input "INTERNATIONALIZATION_COUNTRY" " Please provide your country :" "For example : France, Germany, Great-Britain, etc"
}
configuration() {
if [[ "${DEVICE_TYPE}" == "server" ]]; then
# Users & Groups
user_input "SERVER_OWNER_NAME" " Please provide the name of the owner of this server :" "For example : Steve"
user_input "SERVER_ADMIN_EMAIL" " Please provide a valid ADMIN email address (ACME, system failures notifications, etc) :" "For example : myemail@mydomain.mytld" "${EMAIL_REGEX}"
user_input "AUTHORIZED_SSH_PUBLIC_KEY" " Please provide the SSH public key of an authorized device (or a comma-separated list) :" "For example : ssh-ed25519 AAAAC3Nzam0uYewNAbxL8Fci8 user@your-pc or ssh-* * *, ssh-* * *, etc" "${SSH_KEY_REGEX}" "Invalid SSH key format (must start with ssh-...)."
echo -e "\n\n ➡️ You will access your services via a domain name (e.g. cloud.mydomain.com) and containers need credentials to create those subdomains"
# TRAEFIK SETTINGS
user_input "DOMAIN_NAME" " Please provide the domain name (FQDN) your home server will use :" "For example : yourdomain.com" "${DOMAIN_REGEX}"
user_input "CLOUDFLARE_DNS_API_TOKEN" " Please provide a cloudflare API token with DNS zone permission :" "For example : bA7hdvCOuXGytlNKohi3ZGtlVpf5CHpLuCMiJrE" "" "" "true"
echo -e "\n\n ➡️ Some services will be able to send you emails. For that you need an email that supports sending emails (like Gmail for example)"
# SMTP SETTINGS
user_input "SMTP_SERVER_USERNAME" " Please provide a valid sender email address :" "For example : myemail@gmail.com" "${EMAIL_REGEX}"
user_input "SMTP_SERVER_PASSWORD" " Please provide the password of this email address :" "abcd efgh ijkl mnop" "" "" "true"
user_input "SMTP_SERVER_HOST" " Please provide the SMTP server endpoint :" "For Gmail : smtp.gmail.com" "${DOMAIN_REGEX}" "Invalid domain name format."
user_input "SMTP_SERVER_PORT" " Please provide the smtp TLS port :" "For Gmail : 587" "${PORT_REGEX}" "Invalid port number."
echo -e "\n\n ➡️ This server will connect to your local network and you will configure its IP address\n"
# NETWORK SETTINGS
user_input "NETWORK_SUBNET" " Please provide your network subnet :" "For example 192.168.1.0/24" "${SUBNET_REGEX}" "Invalid subnet format (e.g. 192.168.1.1/24)."
user_input "NETWORK_ROUTER_IP" " Please provide the ip address of your router :" "Most likely 192.168.1.1 or 192.168.1.254" "${IP_REGEX}" "Invalid IP address format."
user_input "HOME_SERVER_IP" " Please choose the ip address that your server will use (i.e. any address in the 192.168.1.1/24 range that is not in use.) :" "For example 192.168.1.5" "${IP_REGEX}" "Invalid IP address format."
elif [[ "${DEVICE_TYPE}" == "backup" ]]; then
:
elif [[ "${DEVICE_TYPE}" == "computer" ]]; then
:
elif [[ "${DEVICE_TYPE}" == "tv" ]]; then
:
fi
}
setup_ssh() {
echod "\n ✅ Generating new SSH key for numbus-admin..."
chmod 700 ${EXTRA_FILES_PATH}/home/numbus-admin/.ssh/
ssh-keygen -t "ed25519" -C "numbus-admin@numbus-server" -f "${EXTRA_FILES_PATH}/home/numbus-admin/.ssh/id_ed25519" -N "" -q
if [[ ${DEBUG} -eq 1 ]]; then
echo -e "\n ➡️ Copying SSH key to target host '${TARGET_USER}@${LIVE_TARGET_IP}'..."
fi
if sshpass -p "${LIVE_TARGET_PASSWORD}" ssh-copy-id -o StrictHostKeyChecking=no -i "${EXTRA_FILES_PATH}/home/numbus-admin/.ssh/id_ed25519" "${TARGET_USER}@${LIVE_TARGET_IP}"; then
if [[ ${DEBUG} -eq 1 ]]; then
echo -e "\n ✅ SSH key copied successfully"
fi
else
echo -e "\n ❌ Failed to copy SSH key. Please check the host IP and password."
exit 1
fi
}
services_selection() {
services_choice() {
local SERVICES_LIST=( "${1[@]}" )
local SERVICES_DESCRIPTION=( "${2[@]}" )
local FINAL_VARIABLE="${3}"
local HEADER="${4}"
local LIMIT="${5:---no-limit}"
local SELECTED_SERVICES=()
local SELECTED_SERVICES_DESCRIPTION=()
local SELECTED_SERVICES_DESCRIPTION=$(gum choose ${LIMIT} --header "${HEADER}" "${SERVICES_DESCRIPTION[@]}")
for i in ${!SERVICES_LIST[@]}; do
if printf '%s' "${SELECTED_SERVICES_DESCRIPTION}" | grep -iq "${SERVICES_LIST[${i}]}"; then
SELECTED_SERVICES+=("${SERVICES_LIST[${i}]}")
fi
done
export "${FINAL_VARIABLE}=(${SELECTED_SERVICES[@]})"
}
echo -e "\n\n ➡️ You will now select the services you want installed on your server:"
services_choice "${DNS_SERVICES_LIST[@]}" "${DNS_SERVICES_DESCRIPTION[@]}" "SELECTED_DNS_SERVICE" "Choose your preferred DNS service :" "--limit=1"
services_choice "${WEB_APPLICATIONS_LIST[@]}" "${WEB_APPLICATIONS_DESCRIPTION[@]}" "SELECTED_WEB_APPLICATIONS" "Choose the web applications you want to install :"
services_choice "${SYSTEM_SERVICES_LIST[@]}" "${SYSTEM_SERVICES_DESCRIPTION[@]}" "SELECTED_SYSTEM_SERVICES" "Choose the system services you want to install :"
gum confirm "Do you want to edit the default subdomain of your services ?" || { echo -e "\n\n✅ Continuing..."; return 0; }
for service in ${SELECTED_WEB_APPLICATIONS[@]} ${SELECTED_DNS_SERVICE[@]}; do
if gum confirm "Change the subdomain of ${service} ?"; then
SELECTED_WEB_APPLICATIONS_SUBDOMAIN+=( "$(gum input --placeholder "${service}" --header "Please provide the desired subdomain for ${service}:")" )
fi
done
return 0
}
users_and_groups() {
declare -A ACL_GROUPS
declare -A ACL_USERS
compute_acl_services() {
EXCLUDED_SERVICES=( "clamav" ) # Those are the services that don't have a web page or don't support SSO
local ALL_SERVICES=("${SELECTED_DNS_SERVICE[@]}" "${SELECTED_WEB_APPLICATIONS[@]}" "${SELECTED_SYSTEM_SERVICES[@]}")
for i in "${!ALL_SERVICES[@]}"; do
for excluded in "${EXCLUDED_SERVICES[@]}"; do
if [[ "${ALL_SERVICES[${i}]}" == "${excluded}" ]]; then
unset "ALL_SERVICES[${i}]"
fi
done
done
}
show_groups_table() {
if [[ ${#ACL_GROUPS[@]} -eq 0 ]]; then
gum style --italic --foreground "#6272a4" -- "No groups configured."
return
fi
# We use CSV format with quotes to handle comma-separated services correctly
local csv="Group Name,Allowed Services\n"
for g in "${!ACL_GROUPS[@]}"; do
csv+="\"$g\",\"${ACL_GROUPS[$g]}\"\n"
done
printf "%b" "$csv" | gum table
}
add_group() {
if [[ ${#ACL_GROUPS[@]} -ge 10 ]]; then
gum style --foreground "#ffb86c" -- "⚠ Maximum of 10 groups reached."
sleep 2; return
fi
local group_name
get_valid_input group_name "Group Name" "^[a-zA-Z0-9_-]+$" true ""
if [[ -n "${ACL_GROUPS[$group_name]}" ]]; then
gum style --foreground "#ff0000" -- "✖ Group already exists."
sleep 2; return
fi
gum style --foreground "#50fa7b" -- "Select services for $group_name (Space to select, Enter to confirm):"
local chosen_services
chosen_services=$(gum choose --no-limit "${ACL_SERVICES[@]}" | paste -sd "," -)
ACL_GROUPS["$group_name"]="$chosen_services"
gum style --foreground "#50fa7b" -- "✔ Group '$group_name' created."
sleep 1
}
edit_group() {
if [[ ${#ACL_GROUPS[@]} -eq 0 ]]; then return; fi
local group_keys=("${!ACL_GROUPS[@]}")
gum style -- "Select a group to edit:"
local group_name=$(gum choose "${group_keys[@]}")
if [[ -z "$group_name" ]]; then return; fi
if [[ "$group_name" == "admin" ]]; then
gum style --foreground "#ff0000" -- "✖ The admin group cannot be modified."
sleep 2; return
fi
gum style --foreground "#50fa7b" -- "Select NEW services for $group_name:"
local chosen_services=$(gum choose --no-limit "${ACL_SERVICES[@]}" | paste -sd "," -)
ACL_GROUPS["$group_name"]="$chosen_services"
gum style --foreground "#50fa7b" -- "✔ Group '$group_name' updated."
sleep 1
}
remove_group() {
if [[ ${#ACL_GROUPS[@]} -eq 0 ]]; then return; fi
local group_keys=("${!ACL_GROUPS[@]}")
gum style -- "Select a group to REMOVE:"
local group_name=$(gum choose "${group_keys[@]}")
if [[ -z "$group_name" ]]; then return; fi
if [[ "$group_name" == "admin" ]]; then
gum style --foreground "#ff0000" -- "✖ The admin group cannot be removed."
sleep 2; return
fi
gum style --foreground "#ff5555" --bold -- "Are you sure you want to delete '$group_name'?"
if gum confirm; then
unset ACL_GROUPS["$group_name"]
gum style --foreground "#50fa7b" -- "✔ Group deleted."
sleep 1
fi
}
manage_groups_menu() {
while true; do
clear
gum style --border double --margin "1" --padding "0 1" --border-foreground "#8be9fd" -- "Group Management (${#ACL_GROUPS[@]}/10)"
show_groups_table
local action=$(gum choose "Add Group" "Edit Group" "Remove Group" "Back")
case "$action" in
"Add Group") add_group ;;
"Edit Group") edit_group ;;
"Remove Group") remove_group ;;
"Back"|"") break ;;
esac
done
}
show_users_table() {
if [[ ${#ACL_USERS[@]} -eq 0 ]]; then
gum style --italic --foreground "#6272a4" -- "No users configured."
return
fi
local csv="Username,Name,Email,Health Alerts,ACL Type,ACL Value\n"
for u in "${!ACL_USERS[@]}"; do
IFS='|' read -r name email phone health type input <<< "${ACL_USERS[$u]}"
csv+="\"$u\",\"$name\",\"$email\",\"$health\",\"$type\",\"$input\"\n"
done
printf "%b" "$csv" | gum table
}
add_user() {
if [[ ${#ACL_USERS[@]} -ge 20 ]]; then
gum style --foreground "#ffb86c" -- "⚠ Maximum of 20 users reached."
sleep 2; return
fi
local name username email phone health_alert acl_type acl_value
get_valid_input username "Username" "^[a-z0-9_-]+$" true ""
if [[ -n "${ACL_USERS[$username]}" ]]; then
gum style --foreground "#ff0000" -- "✖ Username already exists."
sleep 2; return
fi
get_valid_input name "Full Name" "" true ""
get_valid_input email "Email Address" "$EMAIL_REGEX" true ""
get_valid_input phone "Phone Number (E.164, optional)" "$PHONE_REGEX" false ""
gum style -- "Inform about server health?"
if gum confirm; then health_alert="true"; else health_alert="false"; fi
gum style -- "How should ACL be managed for $username?"
acl_type=$(gum choose "Assign to Group" "Manual Service Selection")
if [[ "$acl_type" == "Assign to Group" ]]; then
acl_type="group"
local group_keys=("${!ACL_GROUPS[@]}")
acl_value=$(gum choose "${group_keys[@]}")
else
acl_type="manual"
gum style --foreground "#50fa7b" -- "Select services for $username:"
acl_value=$(gum choose --no-limit "${ACL_SERVICES[@]}" | paste -sd "," -)
fi
ACL_USERS["$username"]="$name|$email|$phone|$health_alert|$acl_type|$acl_value"
gum style --foreground "#50fa7b" -- "✔ User '$username' created."
sleep 1
}
edit_user() {
if [[ ${#ACL_USERS[@]} -eq 0 ]]; then return; fi
local user_keys=("${!ACL_USERS[@]}")
gum style -- "Select a user to edit:"
local username=$(gum choose "${user_keys[@]}")
if [[ -z "$username" ]]; then return; fi
# Extract current values
IFS='|' read -r curr_name curr_email curr_phone curr_health curr_type curr_val <<< "${ACL_USERS[$username]}"
local name email phone health_alert acl_type acl_value
gum style --foreground "#f1fa8c" -- "Editing User: $username"
get_valid_input name "Full Name" "" true "$curr_name"
get_valid_input email "Email Address" "$EMAIL_REGEX" true "$curr_email"
get_valid_input phone "Phone Number" "$PHONE_REGEX" false "$curr_phone"
if [[ "$username" == "admin" ]]; then
gum style --foreground "#ffb86c" -- "Admin health alerts and ACL settings cannot be changed."
health_alert="true"
acl_type="group"
acl_value="admin"
sleep 2
else
gum style -- "Inform about server health? (Currently: $curr_health)"
if gum confirm; then health_alert="true"; else health_alert="false"; fi
gum style -- "How should ACL be managed? (Currently: $curr_type)"
acl_type=$(gum choose "Assign to Group" "Manual Service Selection")
if [[ "$acl_type" == "Assign to Group" ]]; then
acl_type="group"
local group_keys=("${!ACL_GROUPS[@]}")
acl_value=$(gum choose "${group_keys[@]}")
else
acl_type="manual"
gum style --foreground "#50fa7b" -- "Select services for $username:"
acl_value=$(gum choose --no-limit "${ACL_SERVICES[@]}" | paste -sd "," -)
fi
fi
ACL_USERS["$username"]="$name|$email|$phone|$health_alert|$acl_type|$acl_value"
gum style --foreground "#50fa7b" -- "✔ User '$username' updated."
sleep 1
}
remove_user() {
if [[ ${#ACL_USERS[@]} -eq 0 ]]; then return; fi
local user_keys=("${!ACL_USERS[@]}")
gum style -- "Select a user to REMOVE:"
local username=$(gum choose "${user_keys[@]}")
if [[ -z "$username" ]]; then return; fi
if [[ "$username" == "admin" ]]; then
gum style --foreground "#ff0000" -- "✖ The admin user cannot be removed."
sleep 2; return
fi
gum style --foreground "#ff5555" --bold -- "Are you sure you want to delete '$username'?"
if gum confirm; then
unset ACL_USERS["$username"]
gum style --foreground "#50fa7b" -- "✔ User deleted."
sleep 1
fi
}
manage_users_menu() {
while true; do
clear
gum style --border double --margin "1" --padding "0 1" --border-foreground "#ff79c6" -- "User Management (${#ACL_USERS[@]}/20)"
show_users_table
local action=$(gum choose "Add User" "Edit User" "Remove User" "Back")
case "$action" in
"Add User") add_user ;;
"Edit User") edit_user ;;
"Remove User") remove_user ;;
"Back"|"") break ;;
esac
done
}
setup_admin_user() {
if [[ -n "${ACL_USERS["admin"]}" ]]; then return; fi
gum style --border rounded --padding "1 2" --margin "1" --border-foreground "#ff79c6" -- "Initial Setup: Administrator User"
local name email phone
get_valid_input name "Admin Full Name" "" true ""
get_valid_input email "Admin Email Address" "$EMAIL_REGEX" true ""
get_valid_input phone "Admin Phone Number (optional)" "$PHONE_REGEX" false ""
ACL_USERS["admin"]="$name|$email|$phone|true|group|admin"
gum style --foreground "#50fa7b" -- "✔ Administrator configured."
sleep 1
}
export_data() {
clear
gum style --foreground "#50fa7b" --bold -- "--- Provisioning Data Payload ---"
echo "GROUPS:"
for group in "${!ACL_GROUPS[@]}"; do
echo " $group -> Allowed: ${ACL_GROUPS[$group]}"
done
echo ""
echo "USERS (Name|Email|Phone|Alert|AclType|AclValue):"
for user in "${!ACL_USERS[@]}"; do
echo " $user -> ${ACL_USERS[$user]}"
done
}
compute_acl_services
ACL_GROUPS["admin"]=$(printf "%s," "${ACL_SERVICES[@]}" | sed 's/,$//')
setup_admin_user
while true; do
clear
gum style --border double --margin "1" --padding "1 2" --border-foreground "#bd93f9" -- "Numbus Deployment - Access Management"
gum style -- "Current state: ${#ACL_GROUPS[@]}/10 Groups | ${#ACL_USERS[@]}/20 Users"
echo ""
local choice=$(gum choose "1. Manage Groups" "2. Manage Users" "3. Finish & Apply Configuration")
case "$choice" in
"1. Manage Groups") manage_groups_menu ;;
"2. Manage Users") manage_users_menu ;;
"3. Finish & Apply Configuration")
export_data
break
;;
esac
done
}
disks_selection() {
gum style --border normal --margin "1" --padding "1 2" --border-foreground 212 "
⚠️ $(gum style --foreground 212 'WARNING:') You will choose the disks to install NixOS on.
!! PLEASE MAKE SURE YOU BACKED UP ANY IMPORTANT DATA !!
!! ALL DATA WILL BE WIPED ON THE DISKS YOU CHOOSE !!
Please press CTRL+C to abort.
"
gum confirm "Do you understand and wish to proceed?" || { echo -e "\n\n❌ Aborting as requested."; exit 1; }
echo -e "\n\n 🔎 Fetching and analyzing disks from target host... (This may take a moment)"
if [[ "${#DISK_NAME[@]}" -eq 0 ]]; then
echo -e "\n❌ No disks found on the target host. Aborting."
exit 1
fi
local HEADER=$(printf " %-12s %-12s %-12s %-12s %s" "Device" "Type" "Size" "SMART" "Path")
for i in ${!DISK_NAME[@]}; do
local GUM_PRINTED_ELEMENT=$(printf "%-12s %-12s %-12s %-12s %s" \
"${DISK_NAME[${i}]}" "${DISK_TYPE[${i}]}" "${DISK_SIZE[${i}]}" \
"${DISK_HEALTH[${i}]}" "${DISK_DEVPATH[${i}]}")
local GUM_PRINTED_ELEMENTS+=("${GUM_PRINTED_ELEMENT}")
done
echo ""
gum style --foreground 212 "➡️ Please choose one (stripe) or two (mirror) disks for your NixOS boot installation :"
local SELECTED_BOOT_DISK=$(gum choose --limit 2 --header "${HEADER}" "${GUM_PRINTED_ELEMENTS[@]}")
for i in ${!DISK_NAME[@]}; do
if printf '%s' "$SELECTED_BOOT_DISK" | grep -iqw "${DISK_NAME[${i}]}"; then
BOOT_DISKS_ID_LIST+=("\"${DISK_ID[${i}]:-${DISK_DEVPATH[${i}]}}\"")
BOOT_DISKS_NAME+=("${DISK_NAME[${i}]}")
unset "GUM_PRINTED_ELEMENTS[${i}]"
fi
done
echo ""
gum style --foreground 212 "➡️ Please choose data and parity disks (up to 9 total) :"
local SELECTED_DATA_DISK=$(gum choose --limit 9 --header "$HEADER" "${GUM_PRINTED_ELEMENTS[@]}")
for i in ${!DISK_NAME[@]}; do
if printf '%s' "$SELECTED_DATA_DISK" | grep -iq "${DISK_NAME[${i}]}"; then
DATA_DISKS_ID+=("${DISK_ID[${i}]:-${DISK_DEVPATH[${i}]}}")
DATA_DISKS_TYPE+=("${DISK_TYPE[${i}]}")
fi
done
if [[ "${#DATA_DISKS_ID[@]}" -eq 1 ]]; then
export PARITY_DISK_NUMBER=0
export CONTENT_DISK_NUMBER=1
export PARITY_DISK_LIST=()
export CONTENT_DISK_LIST=("\"${DATA_DISKS_ID[0]}\"")
else
export PARITY_DISK_NUMBER=$(((${#DATA_DISKS_ID[@]} + 2) / 3))
export CONTENT_DISK_NUMBER=$((${#DATA_DISKS_ID[@]} - PARITY_DISK_NUMBER))
for i in $(seq 0 $(($CONTENT_DISK_NUMBER - 1))); do
CONTENT_DISK_LIST+=("\"${DATA_DISKS_ID[${i}]}\"")
done
for i in $(seq $CONTENT_DISK_NUMBER $((${#DATA_DISKS_ID[@]} - 1))); do
PARITY_DISK_LIST+=("\"${DATA_DISKS_ID[${i}]}\"")
done
fi
if [[ "${#DATA_DISKS_ID[@]}" -gt 0 ]]; then
for i in ${!DATA_DISKS_ID[@]}; do
if [[ "${DATA_DISKS_TYPE[${i}]}" == "HDD" ]]; then
SPINDOWN_DISKS_LIST+=("\"${DATA_DISKS_ID[${i}]}\"")
fi
done
fi
export SPINDOWN_DISKS_LIST
export BOOT_DISKS_ID_LIST
export PARITY_DISK_LIST
export CONTENT_DISK_LIST
}
server_config_generation() {
echo -e "\n # Server settings" >> ${CONFIGURATION_PATH}
echo -e " time.timeZone = \"${INTERNATIONALIZATION_TIMEZONE}\";" >> ${CONFIGURATION_PATH}
echo -e " numbus.locale = \"${LOCALE}\";" >> ${CONFIGURATION_PATH}
echo -e " numbus.language = \"${INTERNATIONALIZATION_LANGUAGE}\";" >> ${CONFIGURATION_PATH}
echo -e " numbus.owner = \"${SERVER_OWNER_NAME}\";" >> ${CONFIGURATION_PATH}
}
network_config_generation() {
echo -e "\n # Network settings" >> ${CONFIGURATION_PATH}
echo -e " numbus.networking.ipAddress = \"${HOME_SERVER_IP}\";" >> ${CONFIGURATION_PATH}
echo -e " numbus.networking.interface = \"${TARGET_INTERFACE}\";" >> ${CONFIGURATION_PATH}
echo -e " numbus.networking.routerIpAddress = \"${NETWORK_ROUTER_IP}\";" >> ${CONFIGURATION_PATH}
}
services_config_generation() {
echo -e "\n # DNS settings" >> ${CONFIGURATION_PATH}
echo -e " numbus.services.dns = \"${SELECTED_DNS_SERVICE[0]}\";" >> ${CONFIGURATION_PATH}
echo -e " numbus.services.${SELECTED_DNS_SERVICE[0]}.enable = true;" >> ${CONFIGURATION_PATH}
echo -e "\n # Services settings" >> ${CONFIGURATION_PATH}
echo -e " numbus.services.domain = \"${DOMAIN_NAME}\";" >> ${CONFIGURATION_PATH}
i=0
for service in "${SELECTED_WEB_APPLICATIONS[@]}"; do
if [[ -v SELECTED_WEB_APPLICATIONS_SUBDOMAIN && -n "${SELECTED_WEB_APPLICATIONS_SUBDOMAIN[${i}]}" ]]; then
echo -e " numbus.services.${service}.subdomain = \"${SELECTED_WEB_APPLICATIONS_SUBDOMAIN[${i}]}\";" >> ${CONFIGURATION_PATH}
fi
echo -e " numbus.services.${service}.enable = true;" >> ${CONFIGURATION_PATH}
i=$((i + 1))
done
if [[ -v SELECTED_DNS_SERVICE_SUBDOMAIN && -n "${SELECTED_DNS_SERVICE_SUBDOMAIN[0]}" ]]; then
echo -e " numbus.services.${SELECTED_DNS_SERVICE[0]}.subdomain = \"${SELECTED_DNS_SERVICE_SUBDOMAIN[0]}\";" >> ${CONFIGURATION_PATH}
fi
if [[ "${TARGET_GRAPHICS_RENDERER}" == "true" ]]; then
FRIGATE_DEVICES+=" \"/dev/dri/D128\""
fi
if [[ "${TARGET_USB_CORAL}" == "true" ]]; then
FRIGATE_DEVICES+=" \"/dev/bus/usb\""
elif [[ "${TARGET_PCIE_CORAL}" == "true" ]]; then
FRIGATE_DEVICES+=" \"/dev/apex_0\""
fi
if [[ -n "${TARGET_ZIGBEE_DEVICE}" ]]; then
HOME_ASSISTANT_DEVICES+=" \"${TARGET_ZIGBEE_DEVICE}\""
fi
if [[ -n "${FRIGATE_DEVICES:-}" ]]; then
echo -e " numbus.services.frigate.devices = [${FRIGATE_DEVICES} ];" >> ${CONFIGURATION_PATH}
fi
if [[ -n "${HOME_ASSISTANT_DEVICES:-}" ]]; then
echo -e " numbus.services.home-assistant.devices = [${HOME_ASSISTANT_DEVICES} ];" >> ${CONFIGURATION_PATH}
fi
}
mail_config_generation() {
echo -e "\n # Mail settings" >> ${CONFIGURATION_PATH}
echo -e " numbus.mail.enable = true;" >> ${CONFIGURATION_PATH}
echo -e " numbus.mail.userAddress = \"${SERVER_USER_EMAIL}\";" >> ${CONFIGURATION_PATH}
echo -e " numbus.mail.adminAddress = \"${SERVER_ADMIN_EMAIL}\";" >> ${CONFIGURATION_PATH}
echo -e " numbus.mail.smtpUsername = \"${SMTP_SERVER_USERNAME}\";" >> ${CONFIGURATION_PATH}
echo -e " numbus.mail.smtpPasswordPath = config.sops.secrets.smtpPassword.path;" >> ${CONFIGURATION_PATH}
if [[ "${SMTP_SERVER_HOST}" != "smtp.gmail.com" ]]; then
echo -e " numbus.mail.smtpServer = \"${SMTP_SERVER_HOST}\";" >> ${CONFIGURATION_PATH}
fi
if [[ "${SMTP_SERVER_PORT}" != "587" ]]; then
echo -e " numbus.mail.smtpPort = ${SMTP_SERVER_PORT};" >> ${CONFIGURATION_PATH}
fi
}
disk_config_generation() {
echo -e "\n # Hardware settings" >> ${CONFIGURATION_PATH}
if [[ "${TARGET_PCIE_CORAL}" == "true" ]]; then
echo " numbus.hardware.pcie-coral.enable = true;" >> ${CONFIGURATION_PATH}
fi
echo -e " numbus.hardware.bootDisksList = [ ${BOOT_DISKS_ID_LIST[@]} ];" >> ${CONFIGURATION_PATH}
echo -e " numbus.hardware.dataDisksList = [ ${CONTENT_DISK_LIST[@]} ];" >> ${CONFIGURATION_PATH}
echo -e " numbus.hardware.parityDisksList = [ ${PARITY_DISK_LIST[@]} ];" >> ${CONFIGURATION_PATH}
echo -e " numbus.hardware.spindownDisksList = [ ${SPINDOWN_DISKS_LIST[@]} ];" >> ${CONFIGURATION_PATH}
echo "}" >> ${CONFIGURATION_PATH}
}
keys_generation() {
for i in $(seq 1 "${#BOOT_DISKS_ID_LIST[@]}"); do
PASS="$(xkcdpass)"
echo -n "$PASS" > "${EXTRA_FILES_PATH}/etc/secrets/disks/boot-${i}"
chmod 600 "${EXTRA_FILES_PATH}/etc/secrets/disks/boot-${i}"
ssh_to_host 'bash -s' << EOF
echo "$LIVE_TARGET_PASSWORD" | sudo -S mkdir -p /etc/secrets/disks/
echo "$LIVE_TARGET_PASSWORD" | sudo -S bash -c "printf '%s' '$PASS' > /etc/secrets/disks/boot-${i}"
echo "$LIVE_TARGET_PASSWORD" | sudo -S chmod 600 /etc/secrets/disks/boot-${i}
EOF
done
for i in $(seq 1 "$CONTENT_DISK_NUMBER"); do
PASS="$(xkcdpass)"
echo -n "$PASS" > "${EXTRA_FILES_PATH}/etc/secrets/disks/content-${i}"
chmod 600 "${EXTRA_FILES_PATH}/etc/secrets/disks/content-${i}"
ssh_to_host 'bash -s' << EOF
echo "$LIVE_TARGET_PASSWORD" | sudo -S bash -c "printf '%s' '$PASS' > /etc/secrets/disks/content-${i}"
echo "$LIVE_TARGET_PASSWORD" | sudo -S chmod 600 /etc/secrets/disks/content-${i}
EOF
done
for i in $(seq 1 "$PARITY_DISK_NUMBER"); do
PASS="$(xkcdpass)"
echo -n "$PASS" > "${EXTRA_FILES_PATH}/etc/secrets/disks/parity-${i}"
chmod 600 "${EXTRA_FILES_PATH}/etc/secrets/disks/parity-${i}"
ssh_to_host 'bash -s' << EOF
echo "$LIVE_TARGET_PASSWORD" | sudo -S bash -c "printf '%s' '$PASS' > /etc/secrets/disks/parity-${i}"
echo "$LIVE_TARGET_PASSWORD" | sudo -S chmod 600 /etc/secrets/disks/parity-${i}
EOF
done
local SSH_KEYS_FORMATTED=""
if [[ "$(declare -p AUTHORIZED_SSH_PUBLIC_KEY 2>/dev/null)" =~ "declare -a" ]]; then
for key in "${AUTHORIZED_SSH_PUBLIC_KEY[@]}"; do
SSH_KEYS_FORMATTED+=" $key"$'\n'
done
else
SSH_KEYS_FORMATTED=" $AUTHORIZED_SSH_PUBLIC_KEY"$'\n'
fi
export SSH_KEYS_FORMATTED
echo -e "\n ✅ Generating sops-nix keys..."
ssh-to-age -private-key -i ${EXTRA_FILES_PATH}/home/numbus-admin/.ssh/id_ed25519 > ${EXTRA_FILES_PATH}/var/lib/sops-nix/key.txt
export SOPS_PUBLIC_KEY=$(age-keygen -y ${EXTRA_FILES_PATH}/var/lib/sops-nix/key.txt)
echo -e "\n ✅ Generating sops-nix configuration files..."
envsubst < templates/nix-config/sops-nix/.sops.yaml > ${EXTRA_FILES_PATH}/etc/nixos/.sops.yaml
echo -e "\n ✅ Encrypting secrets in the correct file..."
envsubst < "templates/nix-config/sops-nix/secrets.yaml" \
| sops encrypt --filename-override secrets.yaml \
--input-type yaml --output-type yaml \
--age $SOPS_PUBLIC_KEY \
--output ${EXTRA_FILES_PATH}/etc/nixos/secrets/secrets.yaml
}
sum_up() {
DISK_RECAP_CONTENT=$(cat << EOF
### Disk Configuration Summary
Please review the selected disk layout before proceeding.
**Boot Disks (${#BOOT_DISKS_ID_LIST[@]}) :**
* **Boot 1:** \`${BOOT_DISKS_ID_LIST[0]}\`
$( [[ -n "${BOOT_DISKS_ID_LIST[1]:-}" ]] && echo "* **Boot 2:** \`${BOOT_DISKS_ID_LIST[1]}\`" )
**Data Disks ($CONTENT_DISK_NUMBER) :**
$( [[ $CONTENT_DISK_NUMBER -gt 0 ]] && j=1 && for i in $(seq 0 $(($CONTENT_DISK_NUMBER - 1))); do echo "* **Data ${j}:** \`${DATA_DISKS_ID[${i}]}\`" && j=$((j + 1)); done )
$( [[ $CONTENT_DISK_NUMBER -eq 0 ]] && echo "* *Not configured*" )
**Parity Disks ($PARITY_DISK_NUMBER) :**
$( [[ $PARITY_DISK_NUMBER -gt 0 ]] && j=1 && for i in $(seq $CONTENT_DISK_NUMBER $((${#DATA_DISKS_ID[@]} - 1))); do echo "* **Parity ${j}:** \`${DATA_DISKS_ID[${i}]}\`" && j=$((j + 1)); done )
$( [[ $PARITY_DISK_NUMBER -eq 0 ]] && echo "* *Not configured*" )
EOF
)
gum style --border normal --margin "1" --padding "1 2" --border-foreground 212 "$(gum format <<< "${DISK_RECAP_CONTENT}")"
gum confirm "➡️ Proceed with this disk configuration?" || { echo -e "\n\n❌ Aborting as requested."; exit 1; }
SERVICES_RECAP_CONTENT=$(cat << EOF
### Services Configuration Summary
Please review the selected services before proceeding.
**DNS Service (${#SELECTED_DNS_SERVICE[@]}) :**
$(echo "* \`${SELECTED_DNS_SERVICE[0]^}\`")
**Web Applications (${#SELECTED_WEB_APPLICATIONS[@]}) :**
$(for app in "${SELECTED_WEB_APPLICATIONS[@]}"; do echo "* \`${app^}\`"; done)
**System Services (${#SELECTED_SYSTEM_SERVICES[@]}) :**
$(for service in "${SELECTED_SYSTEM_SERVICES[@]}"; do echo "* \`${service^}\`"; done)
EOF
)
gum style --border normal --margin "1" --padding "1 2" --border-foreground 212 "$(gum format <<< "${SERVICES_RECAP_CONTENT}")"
gum confirm "➡️ Proceed with this services configuration?" || { echo -e "\n\n❌ Aborting as requested."; exit 1; }
DISK_RECAP_CONTENT=$(cat << EOF
### Secrets Summary
Please save the following secrets to a secure place (i.e. your local password manager, or a hidden sheet of paper).
**Boot Disks (${#BOOT_DISKS_ID_LIST[@]}) :**
* **Disk 1 Secret Key :** \`$( cat ${EXTRA_FILES_PATH}/etc/secrets/disks/boot-1 )\`
$( [[ -n "${BOOT_DISKS_ID_LIST[1]:-}" ]] && echo "* **Disk 2 secret key :** \`$( cat ${EXTRA_FILES_PATH}/etc/secrets/disks/boot-2 )\`" )
**Data Disks ($CONTENT_DISK_NUMBER):**
$( [[ $CONTENT_DISK_NUMBER -eq 0 ]] && echo "* *Not configured*" )
$( [[ $CONTENT_DISK_NUMBER -gt 0 ]] && j=1 && for i in $(seq 0 $(($CONTENT_DISK_NUMBER - 1))); do echo "* **Disk ${j} Secret Key :** \`$( cat ${EXTRA_FILES_PATH}/etc/secrets/disks/content-${j} )\`" && j=$((j + 1)); done )
**Parity Disks ($PARITY_DISK_NUMBER):**
$( [[ $PARITY_DISK_NUMBER -eq 0 ]] && echo "* *Not configured*" )
$( [[ $PARITY_DISK_NUMBER -gt 0 ]] && j=1 && for i in $(seq $CONTENT_DISK_NUMBER $((${#DATA_DISKS_ID[@]} - 1))); do echo "* **Disk ${j} Secret Key :** \`$( cat ${EXTRA_FILES_PATH}/etc/secrets/disks/parity-${j} )\`" && j=$((j + 1)); done )
EOF
)
gum style --border normal --margin "1" --padding "1 2" --border-foreground 212 "$(gum format <<< "${DISK_RECAP_CONTENT}")"
gum confirm "✅ I have stored these credentials in a safe place" || { echo -e "\n\n❌ Please store these credentials in a safe place as you will need them later."; exit 1; }
gum confirm "➡️ Would you like to manually edit the configuration (⚠️ advanced users only)" || { echo -e "\n\n✅ continuing with the installation..."; return 0; }
nano ${EXTRA_FILES_PATH}/etc/nixos/configuration.nix
}
cloudflare_dns_setup() {
gum confirm "➡️ This script can automatically create DNS records for your services. Proceed? (recommended)" || { echo -e "\n\n ⚠️ skipping the DNS records creation step..."; return 0; }
local ZONE_ID
local RECORD_COUNT
local IS_MATCHING
local DNS_RECORDS
create_records() {
local SUBDOMAIN="${1}"
local CREATION_STATUS
CREATION_STATUS=$(curl -s -X POST "https://api.cloudflare.com/client/v4/zones/${ZONE_ID}/dns_records" \
-H "Authorization: Bearer ${CLOUDFLARE_DNS_API_TOKEN}" \
-H "Content-Type: application/json" \
--data "{\"type\":\"A\",\"name\":\"${SUBDOMAIN}\",\"content\":\"${HOME_SERVER_IP}\",\"ttl\":1,\"proxied\":false}" | jq -r '.success')
if [[ "${CREATION_STATUS}" == "true" ]]; then
echo " ✅ Successfully created a DNS record for ${SUBDOMAIN}"
else
echo -e "❌ Failed to create a DNS record for ${SUBDOMAIN}. Check documentation to \n
learn how you can create them manually."
fi
}
erase_records() {
local SUBDOMAIN="${1}"
gum style --border normal --margin "1" --padding "1 2" --border-foreground 212 "
⚠️ $(gum style --foreground 212 'WARNING:') One or more existing type A DNS records found for \`${SUBDOMAIN}\`.
This script can clear those DNS records for you and create the correct ones needed for the server.
If you are unsure that these records are actually in use, please select \"no\"."
gum confirm "Select \"yes\" to clear ALL EXISTING type A DNS records for this subdomain and automatically create the correct ones." \
|| { echo -e "\n ⚠️ DNS records for ${SUBDOMAIN} will not be updated"; return 0; }
RECORD_IDS=$(curl -s -X GET "https://api.cloudflare.com/client/v4/zones/${ZONE_ID}/dns_records?name=${SUBDOMAIN}&type=A" \
-H "Authorization: Bearer ${CLOUDFLARE_DNS_API_TOKEN}" \
-H "Content-Type: application/json" | jq -r '.result[].id')
for id in ${RECORD_IDS}; do
curl -s -X DELETE "https://api.cloudflare.com/client/v4/zones/${ZONE_ID}/dns_records/${id}" \
-H "Authorization: Bearer ${CLOUDFLARE_DNS_API_TOKEN}" \
-H "Content-Type: application/json" > /dev/null 2>&1
done
create_records "${SUBDOMAIN}"
}
echo -e "\n\n ☁️ Configuring Cloudflare DNS records..."
i=0
for service in "${SELECTED_WEB_APPLICATIONS[@]}"; do
if [[ -n "${SELECTED_WEB_APPLICATIONS_SUBDOMAIN[${i}]:-}" ]]; then
SELECTED_SERVICES_DNS+=( "${SELECTED_WEB_APPLICATIONS_SUBDOMAIN[${i}]}.${DOMAIN_NAME}" )
else
SELECTED_SERVICES_DNS+=( "${service}.${DOMAIN_NAME}" )
fi
i=$((i + 1))
[[ "${service}" == "nextcloud" ]] && SELECTED_SERVICES_DNS+=( "onlyoffice.${DOMAIN_NAME}" "whiteboard.${DOMAIN_NAME}" )
done
if [[ -n "${SELECTED_DNS_SERVICE_SUBDOMAIN[0]:-}" ]]; then
SELECTED_SERVICES_DNS+=( "${SELECTED_DNS_SERVICE_SUBDOMAIN[0]}.${DOMAIN_NAME}" )
else
SELECTED_SERVICES_DNS+=( "${SELECTED_DNS_SERVICE}.${DOMAIN_NAME}" )
fi
# Get Zone ID
ZONE_ID=$(curl -s -X GET "https://api.cloudflare.com/client/v4/zones?name=${DOMAIN_NAME}" \
-H "Authorization: Bearer ${CLOUDFLARE_DNS_API_TOKEN}" \
-H "Content-Type: application/json" | jq -r '.result[0].id')
if [[ "${ZONE_ID}" == "null" || -z "${ZONE_ID}" ]]; then
echo -e "\n\n ⚠️ Could not fetch Zone ID for ${DOMAIN_NAME}. Please check your Cloudflare \"DNS ZONE\" API token"
echo "Check the Numbus-Server documentation to learn how to get one."
fi
# Check for existing records and create them if non-existent
for service_domain in "${SELECTED_SERVICES_DNS[@]}"; do
DNS_RECORDS=$(curl -s -X GET "https://api.cloudflare.com/client/v4/zones/${ZONE_ID}/dns_records?name=${service_domain}&type=A" \
-H "Authorization: Bearer ${CLOUDFLARE_DNS_API_TOKEN}" \
-H "Content-Type: application/json")
RECORD_COUNT=$(echo "${DNS_RECORDS}" | jq '.result | length')
if [[ "${RECORD_COUNT}" -eq 0 ]]; then
echo -e "\n ⚠️ No DNS record found for ${service_domain}"
create_records "${service_domain}"
elif [[ "${RECORD_COUNT}" -eq 1 ]]; then
if [[ $(echo "${DNS_RECORDS}" | jq ".result[0].content == \"${HOME_SERVER_IP}\"") == "true" ]]; then
echo -e "\n ✅ DNS record already configured for ${service_domain}"
else
echo -e "\n ⚠️ No DNS record found for ${service_domain}"
erase_records "${service_domain}"
fi
elif [[ "${RECORD_COUNT}" -gt 1 ]]; then
erase_records "${service_domain}"
fi
done
}
export_configuration() {
cp -${FILES_CP_FLAGS} deploy.conf ${EXTRA_FILES_PATH}/var/lib/numbus-server/numbus-server.conf
local CONFIG_EXPORT_DIR="${EXTRA_FILES_PATH}/var/lib/numbus-server/"
local CONFIG_EXPORT_FILE="${CONFIG_EXPORT_DIR}/numbus-server.conf"
echo "export TARGET_INTERFACE=\"${TARGET_INTERFACE}\"" >> $CONFIG_EXPORT_FILE
echo -e "\n# SERVER SETTINGS" >> $CONFIG_EXPORT_FILE
echo "export SERVER_OWNER_NAME=\"${SERVER_OWNER_NAME:-User}\"" >> $CONFIG_EXPORT_FILE
echo -e "\n# DISK SETTINGS" >> $CONFIG_EXPORT_FILE
echo "export BOOT_DISKS_ID_LIST=\"(${BOOT_DISKS_ID_LIST[@]})\"" >> $CONFIG_EXPORT_FILE
echo "export DATA_DISKS_ID=\"(${DATA_DISKS_ID[@]})\"" >> $CONFIG_EXPORT_FILE
echo "export DATA_DISKS_TYPE=\"(${DATA_DISKS_TYPE[@]})\"" >> $CONFIG_EXPORT_FILE
echo "export SPINDOWN_DISKS_LIST=\"(${SPINDOWN_DISKS_LIST[@]})\"" >> $CONFIG_EXPORT_FILE
echo "export CONTENT_DISK_NUMBER=\"${CONTENT_DISK_NUMBER}\"" >> $CONFIG_EXPORT_FILE
echo "export PARITY_DISK_NUMBER=\"${PARITY_DISK_NUMBER}\"" >> $CONFIG_EXPORT_FILE
echo -e "\n# TPM SETTINGS" >> $CONFIG_EXPORT_FILE
echo "export TARGET_TPM=\"${TARGET_TPM}\"" >> $CONFIG_EXPORT_FILE
echo "export TARGET_TPM_VERSION=\"${TARGET_TPM_VERSION:-}\"" >> $CONFIG_EXPORT_FILE
}
deploy() {
git -C . add -f "${EXTRA_FILES_PATH}/"
git -C . add -f "templates/"
git -C . add -f "deploy.conf"
echo -e "\n\n🔄 Deploying to the remote server..."
nix flake update --flake ./${EXTRA_FILES_PATH}/etc/nixos
nix run github:nix-community/nixos-anywhere -- \
--flake ${EXTRA_FILES_PATH}/etc/nixos#numbus-server \
--extra-files ${EXTRA_FILES_PATH} \
--chown "/home/numbus-admin/" 1000:1000 \
--target-host ${TARGET_USER}@${LIVE_TARGET_IP}
echo -e "\n\n✅ Installation successfull !"
sleep 1
}
postrun_action() {
TARGET_USER="numbus-admin"
LIVE_TARGET_IP="${HOME_SERVER_IP}"
LIVE_TARGET_PASSWORD="changeMe!"
echo -e "\n\n Now the remote machine will reboot. You will need to input the boot disk(s) passphrase.
This will be the only time you will have to do so, it will be automatic in the future."
gum spin --title "Rebooting the remote..." -- sleep 120
gum confirm "➡️ Select \"yes\" once the machine rebooted and you unlocked the disks." || { echo -e "\n\n❌ Aborting as requested."; exit 1; }
FOUND="false"
i="0"
while [[ "${FOUND}" == "false" ]]; do
if ping -c1 -W1 $HOME_SERVER_IP >/dev/null 2>&1; then
FOUND="true"
echo -e "\n✅ Ping ${HOME_SERVER_IP} successful ! Continuing..."
else
i=$((i + 1))
if [[ "${i}" -gt 150 ]]; then
echo -e "\n\n❌ Could not connect to the server after 150 retries. \
This is most likely due to a networking issue. Please double check your network settings. Aborting."
exit 1
fi
fi
done
if [[ "${TARGET_TPM}" == "true" && ${TARGET_TPM_VERSION} -eq 2 ]]; then
gum style --border normal --margin "1" --padding "1 2" --border-foreground 212 "
A TPM version 2 has been detected on the system. You can choose to enable automatic disk decryption on boot.
Enabling automatic disk decryption on boot means that you won't have to enter your disk password everytime you start your server.
This comes in very handy if you don't plan to leave your server accessible with a keyboard or if you don't have an IP KVM.
Note : This feature is currently vulnerable to on-site attacks. This means that an attacker with physical access to your machine
could steal the password from the TPM, and therefore have access to all your date.
Do you want to enable automatic disk decryption on boot ?"
if gum confirm "➡️ I understand, 'yes' to proceed."; then
sshpass -p "${LIVE_TARGET_PASSWORD}" ssh -i "${EXTRA_FILES_PATH}/home/numbus-admin/.ssh/id_ed25519" "${TARGET_USER}@${LIVE_TARGET_IP}" 'bash -s' << EOF
echo "Enrolling boot disk key to TPM..."
BOOT_DISKS_NAME=(${BOOT_DISKS_NAME[@]})
DEBUG=${DEBUG}
DISK_PATH=""
j=1
for i in \${!BOOT_DISKS_NAME[@]}; do
if echo "\${BOOT_DISKS_NAME[\${i}]}" | grep -iq "nvme"; then
[[ "\${DEBUG}" == "true" ]] && echo "NVMe detected..."
DISK_PATH="/dev/\${BOOT_DISKS_NAME[\${i}]}p2"
else
[[ "\${DEBUG}" == "true" ]] && echo "Non-NVMe drive detected..."
DISK_PATH="/dev/\${BOOT_DISKS_NAME[\${i}]}2"
fi
[[ "\${DEBUG}" == "true" ]] && echo "Issuing enroll command for disk \${DISK_PATH}..."
echo ${LIVE_TARGET_PASSWORD} | sudo -S systemd-cryptenroll --tpm2-device=auto --tpm2-pcrs=0+7 --unlock-key-file=/etc/secrets/disks/boot-\${j} \${DISK_PATH}
j=\$((j + 1))
done
echo "Getting PCRS 15 hash..."
PCR_HASH=\$(echo ${LIVE_TARGET_PASSWORD} | sudo -S systemd-analyze pcrs 15 --json=short)
echo ${LIVE_TARGET_PASSWORD} | sudo -S sed -i "s|PCR_HASH|\${PCR_HASH}|" /etc/nixos/configuration.nix
EOF
else
echo "Skipping TPM configuration."
fi
else
echo "No supported TPM detected (TPM version 2 required). Skipping TPM configuration."
fi
gum style --border normal --margin "1" --padding "1 2" --border-foreground 212 "
⚠️ $(gum style --foreground 212 'WARNING:') You will now set the password of the numbus-admin user.
You will almost never user it. Consider using a very strong password : you can write it down
securely on a hidden sheet of paper or add it to your password manager (locally with Passbolt
with any other online password manager provider)."
gum confirm "➡️ I understand, 'yes' to proceed." || { echo -e "\n\n❌ Aborting as requested."; exit 1; }
echo $LIVE_TARGET_PASSWORD | sudo -S passwd numbus-admin
}
nix_update() {
echo -e "\n\n🔄 Updating NixOS on the remote server..."
nixos-rebuild --target-host numbus-admin@${LIVE_TARGET_IP} \
--use-remote-sudo switch --flake ${EXTRA_FILES_PATH}/etc/nixos#numbus-server
}
# --- MAIN FUNCTIONS ---<
# --- DEFAULT VARIABLES --->
WEBSERVER_PORT=${WEBSERVER_PORT:-8088}
LIVE_DATA_PATH="/run/numbus/web/live_settings.json"
HARDWARE_DATA_PATH="/run/numbus/web/hardware.json"
BRIDGE_SCRIPT="web/logic/bridge.py"
CONFIG_FILE="config/numbus.yaml"
TARGET_USER="nixos"
TMP_FILES_PATH="/run/user/$(id -u)/numbus-$(date +"%Y-%m-%d-%Hh%M")"
EXTRA_FILES_PATH="${TMP_FILES_PATH}/config"
if [[ ${DEBUG-0} -eq 1 ]]; then
FILES_CP_FLAGS="vau"
FILES_RM_FLAGS="vf"
DIR_RM_FLAGS="rvf"
MKDIR_FLAGS="pv"
MV_FLAGS="vu"
else
DEBUG=0
FILES_CP_FLAGS="au"
FILES_RM_FLAGS="f"
DIR_RM_FLAGS="rf"
MKDIR_FLAGS="p"
MV_FLAGS="u"
fi
IP_REGEX='^([0-9]{1,3}\.){3}[0-9]{1,3}$'
SUBNET_REGEX='^([0-9]{1,3}\.){3}[0-9]{1,3}/[0-9]{1,2}$'
DOMAIN_REGEX='^([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$'
EMAIL_REGEX='^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'
PORT_REGEX='^[0-9]{1,5}$'
SSH_KEY_REGEX='^ssh-[a-z0-9]+ [A-Za-z0-9+/]+.*'
PHONE_REGEX='^\+[1-9][0-9]{7,14}$'
GUM_INPUT_PADDING="1 1"
GUM_INPUT_HEADER_FOREGROUND="212"
GUM_INPUT_CURSOR_FOREGROUND="212"
GUM_INPUT_TIMEOUT="3600"
# --- DEFAULTS VARIABLES ---<
# --- PRE MAIN LOGIC --->
set -euo pipefail
clear
trap cleanup EXIT
compatibility_check
# --- PRE MAIN LOGIC ---<
# --- MAIN LOGIC --->
echo """
_ ____ ____ ______ __ ______
/ |/ / / / / |/ / _ )/ / / / __/
/ / /_/ / /|_/ / _ / /_/ /\ \
/_/|_/\____/_/ /_/____/\____/___/
"""
DEPLOY_MODE=$(gum choose --header "Choose your preferred configuration interface :" "Through my browser (Recommended for beginners)" "Through my terminal (TUI)")
if [[ "$DEPLOY_MODE" == "Through my terminal (TUI)" ]]; then
WEB_MODE=0
preparation
configuration
else
WEB_MODE=1
launch_configurator
hierarchy_preparation
echod "\n ⏳ Waiting for device credentials from web UI..."
while [ ! -f configurator/.discovery_ready ]; do
sleep 5
done
echod "\n ✅ Credentials received."
INTERNATIONALIZATION_LANGUAGE=$(jq -r '.language' ${LIVE_DATA_PATH})
COUNTRY=$(jq -r '.country' ${LIVE_DATA_PATH})
INTERNATIONALIZATION_TIMEZONE=$(jq -r '.timeZone' ${LIVE_DATA_PATH})
DEVICE_TYPE=$(jq -r '.device' ${LIVE_DATA_PATH})
DEPLOYMENT_MODE=$(jq -r '.deploymentMode' ${LIVE_DATA_PATH})
if [[ "${DEPLOYMENT_MODE}" == "non-interactive" ]]; then
REPLICATION_HARDWARE=$(jq -r '.replicationHardware' ${LIVE_DATA_PATH})
REPLICATION_STRATEGY=$(jq -r '.replicationStrategy' ${LIVE_DATA_PATH})
REPLICATION_SECRETS=$(jq -r '.replicationSecrets' ${LIVE_DATA_PATH})
fi
LIVE_IP=$(jq -r '.liveIp' ${LIVE_DATA_PATH})
LIVE_PASSWORD=$(jq -r '.livePassword' ${LIVE_DATA_PATH})
fi
# --- MAIN LOGIC ---<
# 3. Load Credentials and run Discovery
setup_ssh
hardware_detection
if [[ ${DEBUG} -eq 1 ]]; then
echo -e "\n ✅ Discovery complete. Hardware data sent to Configurator."
fi
# 4. Wait for Final Configuration Submission
if [[ ${DEBUG} -eq 1 ]]; then
echo -e "\n ⏳ Waiting for final configuration deployment signal..."
fi
while [ ! -f configurator/.deploy_signal ]; do
sleep 1
done
# 5. Execute Deployment
echo -e "\n🚀 Starting deployment sequence..."
deploy > deploy-out.log 2> deploy-err.log
+356 -226
View File
@@ -1,53 +1,106 @@
#!/usr/bin/env nix-shell
#!nix-shell -i bash -p bash nano coreutils gnused gum fastfetch xkcdpass sops ssh-to-age age sshpass envsubst pciutils usbutils mosquitto curl jq yq python3
cleanup() {
rm -${DIR_RM_FLAGS} /run/numbus/logs
rm -${DIR_RM_FLAGS} /run/numbus/web
rm -${DIR_RM_FLAGS} /run/numbus/config
kill ${BRIDGE_PID}
# --- UTILITY FUNCTIONS --->
echod() {
MESSAGE=${1}
if [[ ${DEBUG} -eq 1 ]]; then
echo -e ${MESSAGE}
fi
}
launch_configurator() {
echo -e "\n 🚀 Launching Numbus Configurator..."
python3 "${BRIDGE_SCRIPT}" > /dev/null 2>&1 &
export BRIDGE_PID=$!
echo -e "\n ➡️ Open your browser at: $(gum style --foreground 212 "http://localhost:${PORT}")"
xdg-open "http://localhost:${PORT}" 2>/dev/null || open "http://localhost:${PORT}" 2>/dev/null || true
ssh_to_host() {
local COMMAND="${1}"
ssh -i "${EXTRA_FILES_PATH}/home/numbus-admin/.ssh/id_ed25519" "${TARGET_USER}@${LIVE_TARGET_IP}" "${COMMAND}"
}
preparation() {
SELECTED_DEVICE_TYPE=$(gum choose --header "Choose the device you want to deploy :" \
"Numbus Server : Professional-grade hosting, strictly kept under your roof." \
"Numbus Backup Server : Automated, high-efficiency protection for your entire ecosystem." \
"Numbus Computer : A modern, privacy-respecting machine built for work, creation, and play — without the corporate bloat." \
"Numbus TV : A premium cinematic experience free from trackers and forced subscriptions.")
get_valid_input() {
local VAR_NAME="${1}"
local HEADER="${2}"
local PLACEHOLDER="${3}"
local REGEX="${4}"
local MANDATORY="${5:-true}"
local SENSITIVE="${6:-false}"
SELECTED_DEPLOYMENT_MODE=$(gum choose --header "Choose your preferred deployment mode :" \
"Interactive : You don't already have a configuration." \
"Non-interactive : You have a valid configuration hosted on a Git platform.")
if [[ "${MANDATORY}" == "true" ]]; then
local PROMPT="(Required) > "
elif [[ "${MANDATORY}" == "false" ]]; then
local PROMPT="(Optional) > "
fi
git_url() {
IMPORTED_CONFIG_URL=$(gum input --placeholder "https://yourgitplatform.tld/your-user/repo-containing-the-configuration" --header "Please provide the URL to the git repository containing your configuration :")
}
while true; do
local INPUT=$(gum input --header "${HEADER}" --prompt "${PROMPT}" --placeholder "${PLACEHOLDER}")
git_url
until git clone "${IMPORTED_CONFIG_URL}" imported_configuration; do
echo -e "\n ⚠️ This did not work correctly."
echo -e "\n Is this URL correct [y/n] ? ${IMPORTED_CONFIG_URL}"
read URL
if [[ "${URL^^}" == "N" ]];
git_url
# Handle empty input
if [[ -z "${INPUT}" ]]; then
if [[ "${MANDATORY}" == true ]]; then
gum style --foreground "#ff0000" -- "✖ This field is mandatory."
continue
else
INPUT=""
break
fi
fi
echo -e "\n You will be prompted for your credentials again. Make sure that they are correct."
# Handle Regex Validation
if [[ -n "${REGEX}" ]]; then
if [[ "${INPUT}" =~ ${REGEX} ]]; then
export "${VAR_NAME}"="${INPUT}"
break
else
gum style --foreground "#ff0000" -- "✖ Invalid format. Please try again."
fi
else
export "${VAR_NAME}"="${INPUT}"
break
fi
done
}
# --- UTILITY FUNCTIONS ---<
# --- GLOBAL FUNCTIONS --->
cleanup() {
echo -e "\n ✅ Cleaning up..."
rm -${DIR_RM_FLAGS} ${TMP_FILES_PATH}/
if ps -p ${BRIDGE_PID:-} > /dev/null; then
kill ${BRIDGE_PID}
fi
}
compatibility_check() {
TEST_FAIL=0
if [[ -r /etc/os-release ]] && grep -qi '^ID=nixos\b' /etc/os-release; then
echo -e "\n ✅ NixOS system detected."
else
TEST_FAIL=$((TEST_FAIL + 1))
echo -e "\n ❌ You are not on a NixOS based system. This is required to continue."
fi
if [[ "$(uname -m)" == "x86_64" ]]; then
echo -e "\n ✅ x86_64 system detected."
else
TEST_FAIL=$((TEST_FAIL + 1))
echo -e "\n ❌ You are not on a x86_64 based system. This is required to continue."
fi
if [[ ${TEST_FAIL} -gt 0 ]]; then
COMPATIBILITY_OVERRIDE=$(gum choose --header "Some compatibility checks failed. The installation will very likely fail. Continue ?" \
"No" \
"Yes, I know what I am doing")
[[ "${COMPATIBILITY_OVERRIDE}" == "No" ]] && exit 1
[[ "${COMPATIBILITY_OVERRIDE}" != "No" ]] && echo -e "\n ⚠️ Continuing anyways, this is not supported by Numbus."
fi
return 0
}
hierarchy_preparation() {
echod "\n 🔄 Preparing the folder hierarchy for the final configuration..."
@@ -60,44 +113,25 @@ hierarchy_preparation() {
echo " ✅ Your files have been moved to the ${OLD_CONFIG_PATH} directory. You can retrieve them there if needed."
fi
# Script folders
mkdir -${MKDIR_FLAGS} ${TMP_FILES_PATH}/config
mkdir -${MKDIR_FLAGS} ${TMP_FILES_PATH}/logs
mkdir -${MKDIR_FLAGS} ${TMP_FILES_PATH}/tmp
[[ ${WEB_MODE} -eq 1 ]] && mkdir -${MKDIR_FLAGS} ${TMP_FILES_PATH}/web
# Secrets
mkdir -${MKDIR_FLAGS} ${EXTRA_FILES_PATH}/home/numbus-admin/.ssh/
mkdir -${MKDIR_FLAGS} ${EXTRA_FILES_PATH}/var/lib/sops-nix/
mkdir -${MKDIR_FLAGS} ${EXTRA_FILES_PATH}/etc/nixos/secrets/disks
if [[ "${SELECTED_DEVICE_TYPE}" == "" ]]; then
mkdir -${MKDIR_FLAGS} ${EXTRA_FILES_PATH}/etc/nixos/secrets/system
if [[ "${DEVICE_TYPE}" == "server" || "${DEVICE_TYPE}" == "backup" ]]; then
mkdir -${MKDIR_FLAGS} ${EXTRA_FILES_PATH}/etc/nixos/secrets/podman
mkdir -${MKDIR_FLAGS} ${EXTRA_FILES_PATH}/etc/nixos/secrets/system
fi
mkdir -${MKDIR_FLAGS} to-keep-preciously/
}
setup_ssh() {
echod "\n ✅ Generating new SSH key for numbus-admin..."
chmod 700 ${EXTRA_FILES_PATH}/home/numbus-admin/.ssh/
ssh-keygen -t "ed25519" -C "numbus-admin@numbus-server" -f "${EXTRA_FILES_PATH}/home/numbus-admin/.ssh/id_ed25519" -N "" -q
if [[ ${DEBUG} -eq 1 ]]; then
echo -e "\n ➡️ Copying SSH key to target host '${TARGET_USER}@${LIVE_TARGET_IP}'..."
fi
if sshpass -p "${LIVE_TARGET_PASSWD}" ssh-copy-id -o StrictHostKeyChecking=no -i "${EXTRA_FILES_PATH}/home/numbus-admin/.ssh/id_ed25519" "${TARGET_USER}@${LIVE_TARGET_IP}"; then
if [[ ${DEBUG} -eq 1 ]]; then
echo -e "\n ✅ SSH key copied successfully"
fi
else
echo -e "\n ❌ Failed to copy SSH key. Please check the host IP and password."
exit 1
fi
}
ssh_to_host() {
local COMMAND="${1}"
ssh -i "${EXTRA_FILES_PATH}/home/numbus-admin/.ssh/id_ed25519" "${TARGET_USER}@${LIVE_TARGET_IP}" "${COMMAND}"
echod "\n ✅ Folder hierarchy ready"
}
hardware_detection() {
### --> Get hardware information
local TMPFILE="/tmp/nixos-installation-hw-detection"
ssh_to_host 'bash -s' << SSHEND
@@ -150,7 +184,7 @@ for DISK in \$(lsblk -x SIZE -d -n -e 7,11 -o NAME); do
fi
# Disk health
if [[ \$(echo "${LIVE_TARGET_PASSWD}" | sudo -S smartctl -H /dev/\$DISK 2>/dev/null | grep 'self-assessment' | awk '{print \$6}') == "PASSED" ]]; then
if [[ \$(echo "${LIVE_TARGET_PASSWORD}" | sudo -S smartctl -H /dev/\$DISK 2>/dev/null | grep 'self-assessment' | awk '{print \$6}') == "PASSED" ]]; then
DISK_HEALTH+=("PASSED")
else
DISK_HEALTH+=("N/A")
@@ -184,19 +218,15 @@ for var in \
declare -p \${var} | sed 's/^declare /declare -g /' >> "${TMPFILE}"
done
SSHEND
### Get hardware information <--
scp -i "${EXTRA_FILES_PATH}/home/numbus-admin/.ssh/id_ed25519" "${TARGET_USER}@${LIVE_TARGET_IP}":"${TMPFILE}" "${TMPFILE}" &> /dev/null
source "${TMPFILE}"
### Transform the bash variables into JSON -->
# We prepare the disk data as a flat array to pass to jq
local DISK_FLAT_ARRAY=()
for i in "${!DISK_NAME[@]}"; do
DISK_FLAT_ARRAY+=("${DISK_NAME[$i]}" "${DISK_DEVPATH[$i]}" "${DISK_TYPE[$i]}" "${DISK_HEALTH[$i]}" "${DISK_ID[$i]}" "${DISK_SIZE[$i]}")
done
# Generate the JSON file for the configurator
jq -n \
--argjson graphics_enabled "${TARGET_GRAPHICS:-false}" \
--argjson graphics_renderer "${TARGET_GRAPHICS_RENDERER:-false}" \
@@ -220,16 +250,160 @@ SSHEND
}
]
}' --args "${DISK_FLAT_ARRAY[@]:-}" > ${HARDWARE_DATA_PATH}
### Transform the bash variables into JSON <--
### --> Generate hardware-configuration.nix
if ssh_to_host "sudo nixos-generate-config --no-filesystems --show-hardware-config" > ${EXTRA_FILES_PATH}/etc/nixos/hardware-configuration.nix; then
echo -e "\n✅ Hardware configuration generated"
else
echo -e "\n❌ Failed to generate hardware configuration"
exit 1
fi
### Generate hardware-configuration.nix <--
}
# --- GLOBAL FUNCTIONS ---<
# --- MAIN WEB FUNCTIONS --->
launch_gui() {
echo -e "\n ➡️ You will now proceed to the configuration of your device through your browser"
echo -e "\n 🚀 Launching Numbus Configurator..."
python3 "${BRIDGE_SCRIPT}" > /dev/null 2>&1 &
export BRIDGE_PID=$!
xdg-open "http://localhost:${WEBSERVER_PORT}" 2>/dev/null || open "http://localhost:${WEBSERVER_PORT}" 2>/dev/null || true
sleep 5
echo -e "\n ➡️ If it doesn't automatically, open your browser at: $(gum style --foreground 212 "http://localhost:${WEBSERVER_PORT}")"
}
# --- MAIN WEB FUNCTIONS ---<
# --- MAIN TUI FUNCTIONS --->
preparation() {
echo -e "\n ➡️ This script will now guide you through the configuration and gather the necessary information."
echo ""
RAW_DEVICE_TYPE=$(gum choose --header "Choose the device you want to deploy :" \
"Numbus Server : Professional-grade hosting, strictly kept under your roof." \
"Numbus Backup Server : Automated, high-efficiency protection for your entire ecosystem." \
"Numbus Computer : A modern, privacy-respecting machine built for work, creation, and play — without the corporate bloat." \
"Numbus TV : A premium cinematic experience free from trackers and forced subscriptions." \
"Numbus Game Console : An unbreakable Steam bigscreen experience.")
case "${RAW_DEVICE_TYPE}" in
"Numbus Server : "* ) DEVICE_TYPE="server" ;;
"Numbus Backup Server : "* ) DEVICE_TYPE="backup" ;;
"Numbus Computer : "* ) DEVICE_TYPE="computer" ;;
"Numbus TV : "* ) DEVICE_TYPE="tv" ;;
"Numbus Game Console : "* ) DEVICE_TYPE="console" ;;
esac
RAW_DEPLOYMENT_MODE=$(gum choose --header "Choose your preferred deployment mode :" \
"Interactive : You don't already have a configuration." \
"Non-interactive : You have a valid configuration hosted on a Git platform.")
case "${RAW_DEPLOYMENT_MODE}" in
"Interactive : "* ) DEPLOYMENT_MODE="interactive" ;;
"Non-interactive : "* ) DEPLOYMENT_MODE="non-interactive" ;;
esac
if [[ "${DEPLOYMENT_MODE}" == "non-interactive" ]]; then
git_url() {
IMPORTED_CONFIG_URL=$(gum input --placeholder "https://yourgitplatform.tld/your-user/repo-containing-the-configuration" --header "Please provide the URL to the git repository containing your configuration :")
}
git_url
until git clone "${IMPORTED_CONFIG_URL}" imported_configuration; do
echo -e "\n ⚠️ This did not work correctly."
echo -e "\n Is this URL correct [y/n] ? ${IMPORTED_CONFIG_URL}"
read URL
if [[ "${URL^^}" == "N" ]]; then
git_url
fi
echo -e "\n You will be prompted for your credentials again. Make sure that they are correct."
done
fi
echo ""
gum format -- \
"➡️ To continue, you need to start the target device in a NixOS live environment :
1. Download the NixOS iso from the **[official website](https://nixos.org/download/)**.
2. Flash it to a USB stick. (use a flashing tool like **[Rufus](https://rufus.ie/en/#download)**, **[BalenaEtcher](https://etcher.balena.io/#download-etcher)**, **[Impression](https://flathub.org/en/apps/io.gitlab.adhami3310.Impression)**, ...)
3. Make sure your computer allows booting from USB drives and is in UEFI mode.
4. Boot into the NixOS live environment.
5. Launch a terminal. Set a password using \`passwd\` and find the IP address using \`ip a\`"
echo ""
gum confirm "Is the device ready ?" || { echo "❌ You need to prepare the device. The script cannot continue."; exit 1; }
# LIVE TARGET SETTINGS
user_input "LIVE_TARGET_IP" " Please provide the IP address of the target host :" "For example : 192.168.1.100" "${IP_REGEX}"
user_input "LIVE_TARGET_PASSWORD" " Please enter the password for '${TARGET_USER}@${LIVE_TARGET_IP}' :" "${LIVE_TARGET_IP}'s password" "" "" "true"
# INTERNATIONALIZATION SETTINGS
user_input "INTERNATIONALIZATION_TIMEZONE" " Please provide the wanted timezone :" "For example : Europe/Paris, Europe/Berlin, Europe/London, etc"
user_input "INTERNATIONALIZATION_LANGUAGE" " Please provide the wanted language :" "For example : French, Deutsch, English, etc"
user_input "INTERNATIONALIZATION_COUNTRY" " Please provide your country :" "For example : France, Germany, Great-Britain, etc"
}
configuration() {
if [[ "${DEVICE_TYPE}" == "server" ]]; then
# Users & Groups
user_input "SERVER_OWNER_NAME" " Please provide the name of the owner of this server :" "For example : Steve"
user_input "SERVER_ADMIN_EMAIL" " Please provide a valid ADMIN email address (ACME, system failures notifications, etc) :" "For example : myemail@mydomain.mytld" "${EMAIL_REGEX}"
user_input "AUTHORIZED_SSH_PUBLIC_KEY" " Please provide the SSH public key of an authorized device (or a comma-separated list) :" "For example : ssh-ed25519 AAAAC3Nzam0uYewNAbxL8Fci8 user@your-pc or ssh-* * *, ssh-* * *, etc" "${SSH_KEY_REGEX}" "Invalid SSH key format (must start with ssh-...)."
echo -e "\n\n ➡️ You will access your services via a domain name (e.g. cloud.mydomain.com) and containers need credentials to create those subdomains"
# TRAEFIK SETTINGS
user_input "DOMAIN_NAME" " Please provide the domain name (FQDN) your home server will use :" "For example : yourdomain.com" "${DOMAIN_REGEX}"
user_input "CLOUDFLARE_DNS_API_TOKEN" " Please provide a cloudflare API token with DNS zone permission :" "For example : bA7hdvCOuXGytlNKohi3ZGtlVpf5CHpLuCMiJrE" "" "" "true"
echo -e "\n\n ➡️ Some services will be able to send you emails. For that you need an email that supports sending emails (like Gmail for example)"
# SMTP SETTINGS
user_input "SMTP_SERVER_USERNAME" " Please provide a valid sender email address :" "For example : myemail@gmail.com" "${EMAIL_REGEX}"
user_input "SMTP_SERVER_PASSWORD" " Please provide the password of this email address :" "abcd efgh ijkl mnop" "" "" "true"
user_input "SMTP_SERVER_HOST" " Please provide the SMTP server endpoint :" "For Gmail : smtp.gmail.com" "${DOMAIN_REGEX}" "Invalid domain name format."
user_input "SMTP_SERVER_PORT" " Please provide the smtp TLS port :" "For Gmail : 587" "${PORT_REGEX}" "Invalid port number."
echo -e "\n\n ➡️ This server will connect to your local network and you will configure its IP address\n"
# NETWORK SETTINGS
user_input "NETWORK_SUBNET" " Please provide your network subnet :" "For example 192.168.1.0/24" "${SUBNET_REGEX}" "Invalid subnet format (e.g. 192.168.1.1/24)."
user_input "NETWORK_ROUTER_IP" " Please provide the ip address of your router :" "Most likely 192.168.1.1 or 192.168.1.254" "${IP_REGEX}" "Invalid IP address format."
user_input "HOME_SERVER_IP" " Please choose the ip address that your server will use (i.e. any address in the 192.168.1.1/24 range that is not in use.) :" "For example 192.168.1.5" "${IP_REGEX}" "Invalid IP address format."
elif [[ "${DEVICE_TYPE}" == "backup" ]]; then
:
elif [[ "${DEVICE_TYPE}" == "computer" ]]; then
:
elif [[ "${DEVICE_TYPE}" == "tv" ]]; then
:
fi
}
setup_ssh() {
echod "\n ✅ Generating new SSH key for numbus-admin..."
chmod 700 ${EXTRA_FILES_PATH}/home/numbus-admin/.ssh/
ssh-keygen -t "ed25519" -C "numbus-admin@numbus-server" -f "${EXTRA_FILES_PATH}/home/numbus-admin/.ssh/id_ed25519" -N "" -q
if [[ ${DEBUG} -eq 1 ]]; then
echo -e "\n ➡️ Copying SSH key to target host '${TARGET_USER}@${LIVE_TARGET_IP}'..."
fi
if sshpass -p "${LIVE_TARGET_PASSWORD}" ssh-copy-id -o StrictHostKeyChecking=no -i "${EXTRA_FILES_PATH}/home/numbus-admin/.ssh/id_ed25519" "${TARGET_USER}@${LIVE_TARGET_IP}"; then
if [[ ${DEBUG} -eq 1 ]]; then
echo -e "\n ✅ SSH key copied successfully"
fi
else
echo -e "\n ❌ Failed to copy SSH key. Please check the host IP and password."
exit 1
fi
}
services_selection() {
@@ -263,10 +437,12 @@ services_selection() {
gum confirm "Do you want to edit the default subdomain of your services ?" || { echo -e "\n\n✅ Continuing..."; return 0; }
for service in ${SELECTED_WEB_APPLICATIONS[@]} ${SELECTED_DNS_SERVICE[@]}; do
SELECTED_WEB_APPLICATIONS_SUBDOMAIN+=( "$(gum input --placeholder "${service}" --header "Please provide the desired subdomain for ${service}:")" )
if gum confirm "Change the subdomain of ${service} ?"; then
SELECTED_WEB_APPLICATIONS_SUBDOMAIN+=( "$(gum input --placeholder "${service}" --header "Please provide the desired subdomain for ${service}:")" )
fi
done
export SELECTED_WEB_APPLICATIONS_SUBDOMAIN
return 0
}
disks_selection() {
@@ -350,61 +526,49 @@ disks_selection() {
}
server_config_generation() {
echo -e "\n # Server settings" >> ${CONFIGURATION_PATH}
echo -e " time.timeZone = \"${TIMEZONE}\";" >> ${CONFIGURATION_PATH}
echo -e " numbus.locale = \"${LOCALE}\";" >> ${CONFIGURATION_PATH}
echo -e " numbus.language = \"${LANGUAGE}\";" >> ${CONFIGURATION_PATH}
echo -e " numbus.owner = \"${SERVER_OWNER_NAME}\";" >> ${CONFIGURATION_PATH}
}
network_config_generation() {
echo -e "\n # Network settings" >> ${CONFIGURATION_PATH}
echo -e " numbus.networking.ipAddress = \"${HOME_SERVER_IP}\";" >> ${CONFIGURATION_PATH}
echo -e " numbus.networking.interface = \"${TARGET_INTERFACE}\";" >> ${CONFIGURATION_PATH}
echo -e " numbus.networking.routerIpAddress = \"${NETWORK_ROUTER_IP}\";" >> ${CONFIGURATION_PATH}
}
services_config_generation() {
echo -e "\n # DNS settings" >> ${CONFIGURATION_PATH}
echo -e " numbus.services.dns = \"${SELECTED_DNS_SERVICE[0]}\";" >> ${CONFIGURATION_PATH}
echo -e " numbus.services.${SELECTED_DNS_SERVICE[0]}.enable = true;" >> ${CONFIGURATION_PATH}
echo -e "\n # Services settings" >> ${CONFIGURATION_PATH}
echo -e " numbus.services.domain = \"${DOMAIN_NAME}\";" >> ${CONFIGURATION_PATH}
i=0
for service in "${SELECTED_WEB_APPLICATIONS[@]}"; do
if [[ -v SELECTED_WEB_APPLICATIONS_SUBDOMAIN && -n "${SELECTED_WEB_APPLICATIONS_SUBDOMAIN[${i}]}" ]]; then
echo -e " numbus.services.${service}.subdomain = \"${SELECTED_WEB_APPLICATIONS_SUBDOMAIN[${i}]}\";" >> ${CONFIGURATION_PATH}
fi
echo -e " numbus.services.${service}.enable = true;" >> ${CONFIGURATION_PATH}
i=$((i + 1))
done
if [[ -v SELECTED_DNS_SERVICE_SUBDOMAIN && -n "${SELECTED_DNS_SERVICE_SUBDOMAIN[0]}" ]]; then
echo -e " numbus.services.${SELECTED_DNS_SERVICE[0]}.subdomain = \"${SELECTED_DNS_SERVICE_SUBDOMAIN[0]}\";" >> ${CONFIGURATION_PATH}
fi
if [[ "${TARGET_GRAPHICS_RENDERER}" == "true" ]]; then
FRIGATE_DEVICES+=" \"/dev/dri/D128\""
fi
if [[ "${TARGET_USB_CORAL}" == "true" ]]; then
FRIGATE_DEVICES+=" \"/dev/bus/usb\""
elif [[ "${TARGET_PCIE_CORAL}" == "true" ]]; then
FRIGATE_DEVICES+=" \"/dev/apex_0\""
fi
if [[ -n "${TARGET_ZIGBEE_DEVICE}" ]]; then
HOME_ASSISTANT_DEVICES+=" \"${TARGET_ZIGBEE_DEVICE}\""
fi
if [[ -n "${FRIGATE_DEVICES:-}" ]]; then
echo -e " numbus.services.frigate.devices = [${FRIGATE_DEVICES} ];" >> ${CONFIGURATION_PATH}
fi
if [[ -n "${HOME_ASSISTANT_DEVICES:-}" ]]; then
echo -e " numbus.services.home-assistant.devices = [${HOME_ASSISTANT_DEVICES} ];" >> ${CONFIGURATION_PATH}
fi
echod "\n 📝 Generating structured settings.json..."
# Create a temporary JSON file with all the collected variables
# This file will be read by the Nix configuration using builtins.fromJSON
jq -n \
--arg tz "$INTERNATIONALIZATION_TIMEZONE" \
--arg lang "$INTERNATIONALIZATION_LANGUAGE" \
--arg owner "$SERVER_OWNER_NAME" \
--arg ip "$HOME_SERVER_IP" \
--arg iface "$TARGET_INTERFACE" \
--arg router "$NETWORK_ROUTER_IP" \
--arg domain "$DOMAIN_NAME" \
--argjson cockpit_enabled "true" \
--arg dns "${SELECTED_DNS_SERVICE[0]}" \
--argjson apps "$(printf '%s\n' "${SELECTED_WEB_APPLICATIONS[@]}" | jq -R . | jq -s .)" \
'{
system: {
timeZone: $tz,
language: $lang,
owner: $owner
},
network: {
ipAddress: $ip,
interface: $iface,
routerIp: $router
},
services: {
domain: $domain,
dnsProvider: $dns,
enabledApps: $apps,
managementConsole: $cockpit_enabled
}
}' > "${EXTRA_FILES_PATH}/etc/nixos/settings.json"
echo -e "{\n numbus.settings = builtins.fromJSON (builtins.readFile ./settings.json);\n}" > "${CONFIGURATION_PATH}"
# Ensure the settings file is writable by the management service
# and that the directory is prepared for local git tracking
chmod 664 "${EXTRA_FILES_PATH}/etc/nixos/settings.json"
}
# The existing network_config_generation and services_config_generation functions
# are now redundant as the logic is centralized in the JSON export.
mail_config_generation() {
echo -e "\n # Mail settings" >> ${CONFIGURATION_PATH}
echo -e " numbus.mail.enable = true;" >> ${CONFIGURATION_PATH}
@@ -439,9 +603,9 @@ keys_generation() {
echo -n "$PASS" > "${EXTRA_FILES_PATH}/etc/secrets/disks/boot-${i}"
chmod 600 "${EXTRA_FILES_PATH}/etc/secrets/disks/boot-${i}"
ssh_to_host 'bash -s' << EOF
echo "$LIVE_TARGET_PASSWD" | sudo -S mkdir -p /etc/secrets/disks/
echo "$LIVE_TARGET_PASSWD" | sudo -S bash -c "printf '%s' '$PASS' > /etc/secrets/disks/boot-${i}"
echo "$LIVE_TARGET_PASSWD" | sudo -S chmod 600 /etc/secrets/disks/boot-${i}
echo "$LIVE_TARGET_PASSWORD" | sudo -S mkdir -p /etc/secrets/disks/
echo "$LIVE_TARGET_PASSWORD" | sudo -S bash -c "printf '%s' '$PASS' > /etc/secrets/disks/boot-${i}"
echo "$LIVE_TARGET_PASSWORD" | sudo -S chmod 600 /etc/secrets/disks/boot-${i}
EOF
done
for i in $(seq 1 "$CONTENT_DISK_NUMBER"); do
@@ -449,8 +613,8 @@ EOF
echo -n "$PASS" > "${EXTRA_FILES_PATH}/etc/secrets/disks/content-${i}"
chmod 600 "${EXTRA_FILES_PATH}/etc/secrets/disks/content-${i}"
ssh_to_host 'bash -s' << EOF
echo "$LIVE_TARGET_PASSWD" | sudo -S bash -c "printf '%s' '$PASS' > /etc/secrets/disks/content-${i}"
echo "$LIVE_TARGET_PASSWD" | sudo -S chmod 600 /etc/secrets/disks/content-${i}
echo "$LIVE_TARGET_PASSWORD" | sudo -S bash -c "printf '%s' '$PASS' > /etc/secrets/disks/content-${i}"
echo "$LIVE_TARGET_PASSWORD" | sudo -S chmod 600 /etc/secrets/disks/content-${i}
EOF
done
for i in $(seq 1 "$PARITY_DISK_NUMBER"); do
@@ -458,8 +622,8 @@ EOF
echo -n "$PASS" > "${EXTRA_FILES_PATH}/etc/secrets/disks/parity-${i}"
chmod 600 "${EXTRA_FILES_PATH}/etc/secrets/disks/parity-${i}"
ssh_to_host 'bash -s' << EOF
echo "$LIVE_TARGET_PASSWD" | sudo -S bash -c "printf '%s' '$PASS' > /etc/secrets/disks/parity-${i}"
echo "$LIVE_TARGET_PASSWD" | sudo -S chmod 600 /etc/secrets/disks/parity-${i}
echo "$LIVE_TARGET_PASSWORD" | sudo -S bash -c "printf '%s' '$PASS' > /etc/secrets/disks/parity-${i}"
echo "$LIVE_TARGET_PASSWORD" | sudo -S chmod 600 /etc/secrets/disks/parity-${i}
EOF
done
@@ -670,32 +834,20 @@ cloudflare_dns_setup() {
done
}
export_configuration() {
cp -${FILES_CP_FLAGS} deploy.conf ${EXTRA_FILES_PATH}/var/lib/numbus-server/numbus-server.conf
local CONFIG_EXPORT_DIR="${EXTRA_FILES_PATH}/var/lib/numbus-server/"
local CONFIG_EXPORT_FILE="${CONFIG_EXPORT_DIR}/numbus-server.conf"
echo "export TARGET_INTERFACE=\"${TARGET_INTERFACE}\"" >> $CONFIG_EXPORT_FILE
echo -e "\n# SERVER SETTINGS" >> $CONFIG_EXPORT_FILE
echo "export SERVER_OWNER_NAME=\"${SERVER_OWNER_NAME:-User}\"" >> $CONFIG_EXPORT_FILE
echo -e "\n# DISK SETTINGS" >> $CONFIG_EXPORT_FILE
echo "export BOOT_DISKS_ID_LIST=\"(${BOOT_DISKS_ID_LIST[@]})\"" >> $CONFIG_EXPORT_FILE
echo "export DATA_DISKS_ID=\"(${DATA_DISKS_ID[@]})\"" >> $CONFIG_EXPORT_FILE
echo "export DATA_DISKS_TYPE=\"(${DATA_DISKS_TYPE[@]})\"" >> $CONFIG_EXPORT_FILE
echo "export SPINDOWN_DISKS_LIST=\"(${SPINDOWN_DISKS_LIST[@]})\"" >> $CONFIG_EXPORT_FILE
echo "export CONTENT_DISK_NUMBER=\"${CONTENT_DISK_NUMBER}\"" >> $CONFIG_EXPORT_FILE
echo "export PARITY_DISK_NUMBER=\"${PARITY_DISK_NUMBER}\"" >> $CONFIG_EXPORT_FILE
echo -e "\n# TPM SETTINGS" >> $CONFIG_EXPORT_FILE
echo "export TARGET_TPM=\"${TARGET_TPM}\"" >> $CONFIG_EXPORT_FILE
echo "export TARGET_TPM_VERSION=\"${TARGET_TPM_VERSION:-}\"" >> $CONFIG_EXPORT_FILE
}
deploy() {
git -C . add -f "${EXTRA_FILES_PATH}/"
git -C . add -f "templates/"
git -C . add -f "deploy.conf"
# Initialize a git repo in the configuration to be deployed
# This allows the Management UI on the appliance to commit changes
# and provide a local history/rollback UI to the user.
if [ ! -d "${EXTRA_FILES_PATH}/etc/nixos/.git" ]; then
git -C "${EXTRA_FILES_PATH}/etc/nixos" init -q
git -C "${EXTRA_FILES_PATH}/etc/nixos" add .
git -C "${EXTRA_FILES_PATH}/etc/nixos" commit -m "Initial bootstrap via Numbus Deploy" -q
fi
echo -e "\n\n🔄 Deploying to the remote server..."
nix flake update --flake ./${EXTRA_FILES_PATH}/etc/nixos
nix run github:nix-community/nixos-anywhere -- \
@@ -711,7 +863,7 @@ deploy() {
postrun_action() {
TARGET_USER="numbus-admin"
LIVE_TARGET_IP="${HOME_SERVER_IP}"
LIVE_TARGET_PASSWD="changeMe!"
LIVE_TARGET_PASSWORD="changeMe!"
echo -e "\n\n Now the remote machine will reboot. You will need to input the boot disk(s) passphrase.
This will be the only time you will have to do so, it will be automatic in the future."
@@ -748,7 +900,7 @@ postrun_action() {
Do you want to enable automatic disk decryption on boot ?"
if gum confirm "➡️ I understand, 'yes' to proceed."; then
sshpass -p "${LIVE_TARGET_PASSWD}" ssh -i "${EXTRA_FILES_PATH}/home/numbus-admin/.ssh/id_ed25519" "${TARGET_USER}@${LIVE_TARGET_IP}" 'bash -s' << EOF
sshpass -p "${LIVE_TARGET_PASSWORD}" ssh -i "${EXTRA_FILES_PATH}/home/numbus-admin/.ssh/id_ed25519" "${TARGET_USER}@${LIVE_TARGET_IP}" 'bash -s' << EOF
echo "Enrolling boot disk key to TPM..."
BOOT_DISKS_NAME=(${BOOT_DISKS_NAME[@]})
@@ -765,14 +917,14 @@ for i in \${!BOOT_DISKS_NAME[@]}; do
DISK_PATH="/dev/\${BOOT_DISKS_NAME[\${i}]}2"
fi
[[ "\${DEBUG}" == "true" ]] && echo "Issuing enroll command for disk \${DISK_PATH}..."
echo ${LIVE_TARGET_PASSWD} | sudo -S systemd-cryptenroll --tpm2-device=auto --tpm2-pcrs=0+7 --unlock-key-file=/etc/secrets/disks/boot-\${j} \${DISK_PATH}
echo ${LIVE_TARGET_PASSWORD} | sudo -S systemd-cryptenroll --tpm2-device=auto --tpm2-pcrs=0+7 --unlock-key-file=/etc/secrets/disks/boot-\${j} \${DISK_PATH}
j=\$((j + 1))
done
echo "Getting PCRS 15 hash..."
PCR_HASH=\$(echo ${LIVE_TARGET_PASSWD} | sudo -S systemd-analyze pcrs 15 --json=short)
PCR_HASH=\$(echo ${LIVE_TARGET_PASSWORD} | sudo -S systemd-analyze pcrs 15 --json=short)
echo ${LIVE_TARGET_PASSWD} | sudo -S sed -i "s|PCR_HASH|\${PCR_HASH}|" /etc/nixos/configuration.nix
echo ${LIVE_TARGET_PASSWORD} | sudo -S sed -i "s|PCR_HASH|\${PCR_HASH}|" /etc/nixos/configuration.nix
EOF
else
echo "Skipping TPM configuration."
@@ -789,7 +941,7 @@ securely on a hidden sheet of paper or add it to your password manager (locally
gum confirm "➡️ I understand, 'yes' to proceed." || { echo -e "\n\n❌ Aborting as requested."; exit 1; }
echo $LIVE_TARGET_PASSWD | sudo -S passwd numbus-admin
echo $LIVE_TARGET_PASSWORD | sudo -S passwd numbus-admin
}
nix_update() {
@@ -798,27 +950,22 @@ nix_update() {
nixos-rebuild --target-host numbus-admin@${LIVE_TARGET_IP} \
--use-remote-sudo switch --flake ${EXTRA_FILES_PATH}/etc/nixos#numbus-server
}
# --- MAIN FUNCTIONS ---<
echod() {
MESSAGE=${1}
if [[ ${DEBUG} -eq 1 ]]; then
echo -e ${MESSAGE}
fi
}
# --- DEFAULTS --->
# --- DEFAULT VARIABLES --->
WEBSERVER_PORT=${WEBSERVER_PORT:-8088}
LIVE_DATA_PATH="/run/numbus/web/live_settings.json"
HARDWARE_DATA_PATH="/run/numbus/web/hardware.json"
LIVE_DATA_PATH="/run/user/$(id -u)/numbus/web/live_settings.json"
HARDWARE_DATA_PATH="/run/user/$(id -u)/numbus/web/hardware.json"
BRIDGE_SCRIPT="web/logic/bridge.py"
CONFIG_FILE="config/numbus.yaml"
CONFIG_FILE="../config/numbus.yaml"
TARGET_USER="nixos"
EXTRA_FILES_PATH="/run/numbus/config"
TMP_FILES_PATH="/run/user/$(id -u)/numbus-$(date +"%Y-%m-%d-%Hh%M")"
EXTRA_FILES_PATH="${TMP_FILES_PATH}/config"
if [[ ${DEBUG-0} -eq 1 ]]; then
FILES_CP_FLAGS="vau"
@@ -834,73 +981,56 @@ else
MKDIR_FLAGS="p"
MV_FLAGS="u"
fi
# --- DEFAULTS ---<
# --- MAIN PART ---
IP_REGEX='^([0-9]{1,3}\.){3}[0-9]{1,3}$'
SUBNET_REGEX='^([0-9]{1,3}\.){3}[0-9]{1,3}/[0-9]{1,2}$'
DOMAIN_REGEX='^([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$'
EMAIL_REGEX='^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'
PORT_REGEX='^[0-9]{1,5}$'
SSH_KEY_REGEX='^ssh-[a-z0-9]+ [A-Za-z0-9+/]+.*'
PHONE_REGEX='^\+[1-9][0-9]{7,14}$'
GUM_INPUT_PADDING="1 1"
GUM_INPUT_HEADER_FOREGROUND="212"
GUM_INPUT_CURSOR_FOREGROUND="212"
GUM_INPUT_TIMEOUT="3600"
# --- DEFAULTS VARIABLES ---<
# --- PRE MAIN LOGIC --->
set -euo pipefail
clear
trap cleanup EXIT
compatibility_check
# --- PRE MAIN LOGIC ---<
# --- MAIN LOGIC --->
echo """
_ ____ ____ ______ __ ______
/ |/ / / / / |/ / _ )/ / / / __/
/ / /_/ / /|_/ / _ / /_/ /\ \
/_/|_/\____/_/ /_/____/\____/___/
"""
DEPLOY_MODE=$(gum choose --header "Choose your preferred configuration interface :" "Through my browser (Recommended for beginners)" "Through my terminal (TUI)")
if [[ "$DEPLOY_MODE" == "Through my terminal" ]]; then
preparation
hierarchy_preparation
setup_ssh
DEPLOYMENT_STRATEGY=$(gum choose --header "Choose your preferred deployment strategy :" \
"I don't have a configuration" \
"I have a valid configuration hosted on a Git platform")
if [[ "${DEPLOYMENT_STRATEGY}" == "I don't have a configuration" ]]; then
BRIDGE_SCRIPT="../web/logic/interactive.py"
launch_gui
else
launch_configurator
hierarchy_preparation
echod "\n ⏳ Waiting for device credentials from web UI..."
while [ ! -f configurator/.discovery_ready ]; do
sleep 5
done
echod "\n ✅ Credentials received."
LANGUAGE=$(jq -r '.language' ${LIVE_DATA_PATH})
COUNTRY=$(jq -r '.country' ${LIVE_DATA_PATH})
TIMEZONE=$(jq -r '.timeZone' ${LIVE_DATA_PATH})
DEVICE_TYPE=$(jq -r '.device' ${LIVE_DATA_PATH})
DEPLOYMENT_MODE=$(jq -r '.deploymentMode' ${LIVE_DATA_PATH})
if [[ "${DEPLOYMENT_MODE}" == "non-interactive" ]]; then
REPLICATION_HARDWARE=$(jq -r '.replicationHardware' ${LIVE_DATA_PATH})
REPLICATION_STRATEGY=$(jq -r '.replicationStrategy' ${LIVE_DATA_PATH})
REPLICATION_SECRETS=$(jq -r '.replicationSecrets' ${LIVE_DATA_PATH})
DEPLOYMENT_MODE=$(gum choose --header "Choose your preferred deployment mode :" \
"Through my web browser (Recommended for beginners)" \
"Through my terminal (TUI)")
if [[ "${DEPLOYMENT_MODE}" == "Through my web browser (Recommended for beginners)" ]]; then
BRIDGE_SCRIPT="../web/logic/non-interactive.py"
launch_gui
else
launch_tui
fi
LIVE_IP=$(jq -r '.liveIp' ${LIVE_DATA_PATH})
LIVE_PASSWORD=$(jq -r '.livePassword' ${LIVE_DATA_PATH})
fi
# 3. Load Credentials and run Discovery
setup_ssh
hardware_detection
if [[ ${DEBUG} -eq 1 ]]; then
echo -e "\n ✅ Discovery complete. Hardware data sent to Configurator."
fi
# 4. Wait for Final Configuration Submission
if [[ ${DEBUG} -eq 1 ]]; then
echo -e "\n ⏳ Waiting for final configuration deployment signal..."
fi
while [ ! -f configurator/.deploy_signal ]; do
sleep 1
done
# 5. Execute Deployment
echo -e "\n🚀 Starting deployment sequence..."
deploy > deploy-out.log 2> deploy-err.log
fi
+6
View File
@@ -0,0 +1,6 @@
# Terminology for the variables used
|Variable|Meaning|Possible values|
|-|-------|-|
|DEPLOYMENT_STRATEGY|Either deploy the machine with a config you already have or let the script guide you through the config options|**interactive** or **non-interactive**|
|DEPLOYMENT_MODE|Either configure the machine through your terminal (TUI) or through a slick web UI (GUI)|**TUI** (only available for **non-interactive** strategy) or **GUI**|
+55
View File
@@ -0,0 +1,55 @@
import http.server
import json
import os
import sys
### Variables -->
SECRET_PATH = "/run/user/{}/numbus".format(os.getuid()) if os.path.exists("/run/user/{}".format(os.getuid())) else "../secrets"
os.makedirs(SECRET_PATH, exist_ok=True)
LOGS_DIR = "../web/logs/"
PAGES_DIR = "../web/pages/"
CONFIG_DIR = "../web/config/"
SIGNALS_DIR = "../web/signal/"
### <-- Variables
class BridgeHandler(http.server.SimpleHTTPRequestHandler):
def do_GET(self):
# Route for logs: /logs?type=out or /logs?type=err
if self.path.startswith('/logs'):
log_type = "out" if "type=err" not in self.path else "err"
log_path = os.path.join(LOGS_DIR, f'deploy-{log_type}.log')
self.send_response(200)
self.send_header('Content-type', 'text/plain')
self.send_header('Access-Control-Allow-Origin', '*')
self.end_headers()
if os.path.exists(log_path):
with open(log_path, 'r') as f:
# Read last 50 lines for better context during errors
self.wfile.write("".join(f.readlines()[-50:]).encode())
return
return http.server.SimpleHTTPRequestHandler.do_GET(self)
def do_POST(self):
content_length = int(self.headers['Content-Length'])
post_data = self.rfile.read(content_length)
if self.path == '/discovery':
# Store secrets in memory-backed filesystem
with open(os.path.join(SECRET_PATH, "live_settings.json"), "wb") as f:
f.write(post_data)
self.send_response(200)
self.end_headers()
# Signal Bash that discovery data is ready
with open(os.path.join(SIGNALS_DIR, ".discovery_ready"), "w") as f: f.write("1")
elif self.path == '/deploy':
with open(os.path.join(CONFIG_DIR, "numbus.yaml"), "wb") as f:
f.write(post_data)
self.send_response(200)
self.end_headers()
with open(os.path.join(SIGNALS_DIR, ".deploy_signal"), "w") as f: f.write("1")
os.chdir(PAGES_DIR)
http.server.HTTPServer(('localhost', 8088), BridgeHandler).serve_forever()