feat: try rework

This commit is contained in:
Katharina Heidenreich 2026-04-04 11:42:19 +02:00
parent 1ddbd3b8b6
commit ecf10628c3
51 changed files with 1941 additions and 445 deletions

1
.gitignore vendored
View file

@ -1,4 +1,3 @@
users/ users/
ssh_keys/ ssh_keys/
secret/
results results

7
.sops.yaml Normal file
View file

@ -0,0 +1,7 @@
creation_rules:
- path_regex: ^secrets/.*(?:$|\.(ya?ml|json|env|txt|key|pub))$
key_groups:
- age:
# Replace these placeholders with your real recipients.
- age1g5q3hwnpgsas682jkq0zmee3zqggucfe0v5ec0a6pv7wzexadehqne66cj
- age1qmnmge7atpg5k0zdaky0tuux2rgtehxfhtnshcjpyl0n2hx2udhqe62wyj

3
.vscode/settings.json vendored Normal file
View file

@ -0,0 +1,3 @@
{
"nixEnvSelector.nixFile": "${workspaceFolder}/configuration.nix"
}

View file

@ -1,28 +1,67 @@
let let
lib = import <nixpkgs/lib>;
net = import ./network.nix; net = import ./network.nix;
services = import ./services.nix;
web = import ../intermediate/web.nix;
in in
rec { [
[ {
{ type = "web";
type = "web"; domain = net.devices.remote_proxy.domain;
domain = "${net.devices.remote_proxy.domain}"; endpoint = "/";
endpoint = "/"; force_ssl = true;
force_ssl = true; port = 443;
port = 443; content = web.storePayloads.home;
content = web.home; }
} {
{ type = "proxy";
type = "proxy"; domain = net.devices.remote_proxy.domain;
domain = "torrent.${net.local_domain}"; endpoint = "/_matrix/";
endpoint = "/"; force_ssl = true;
forceSsl = false; port = 443;
port = 80; content = {
content = { type = "service";
type = "service"; ip = net.devices.pi.ip;
url = "localhost"; port = services.continuwuity.port;
port = services.torrent.port; proxyWebsockets = true;
}; };
} }
] {
} type = "proxy";
domain = net.devices.remote_proxy.domain;
endpoint = "/_matrix/";
force_ssl = true;
port = 8448;
content = {
type = "service";
ip = net.devices.pi.ip;
port = services.continuwuity.port;
proxyWebsockets = true;
};
}
{
type = "proxy";
domain = "torrent.${net.local_domain}";
endpoint = "/";
force_ssl = false;
port = 80;
content = {
type = "service";
ip = net.devices.pi.ip;
port = services.qbittorrent.port;
proxyWebsockets = false;
};
}
{
type = "proxy";
domain = "wiki.${net.local_domain}";
endpoint = "/";
force_ssl = false;
port = 80;
content = {
type = "service";
ip = net.devices.pi.ip;
port = services.kiwix.port;
proxyWebsockets = true;
};
}
]

View file

@ -1,8 +1,10 @@
rec { rec {
secrets = import ../intermediate/secrets.nix;
network = { network = {
subnet = "192.168.2.0/24"; subnet = "192.168.2.0/24";
subnet_base = "192.168.2.0"; subnet_base = "192.168.2.0";
gateway = ips.router; gateway = devices.router.ip;
cidr = 24; cidr = 24;
}; };
@ -11,6 +13,7 @@ rec {
type = "local"; type = "local";
ip = "192.168.2.100"; ip = "192.168.2.100";
}; };
"self" = devices.pi;
"desktop" = { "desktop" = {
type = "local"; type = "local";
ip = "192.168.2.101"; ip = "192.168.2.101";
@ -28,11 +31,11 @@ rec {
ip = "193.31.24.99"; ip = "193.31.24.99";
domain = "nudelerde.de"; domain = "nudelerde.de";
auto_ssh = { auto_ssh = {
enable = true;
sshPort = 22; sshPort = 22;
sshUser = "root"; sshUser = "root";
key = secret.remote_proxy_key; key = secrets.byName.autossh_remote_proxy_key.path;
known_hosts = secret.remote_proxy_known_hosts; known_hosts = secrets.byName.autossh_remote_proxy_known_hosts.path;
forwards = [];
}; };
}; };
}; };
@ -50,5 +53,4 @@ rec {
]; ];
local_domain = "home"; local_domain = "home";
extern_domain = "nudelerde.de";
} }

7
config/openssh.nix Normal file
View file

@ -0,0 +1,7 @@
let
secrets = import ../intermediate/secrets.nix;
users = builtins.attrNames secrets.source.openssh.users;
in
rec {
ssh_users = users;
}

34
config/secrets.nix Normal file
View file

@ -0,0 +1,34 @@
{
autossh = {
remote_proxy = {
key = {
file = ../secrets/autossh/remote_proxy_key;
owner = "autossh-remote_proxy";
mode = "0400";
};
known_hosts = {
file = ../secrets/autossh/remote_proxy_known_hosts;
owner = "autossh-remote_proxy";
mode = "0400";
};
};
};
qbittorrent = {
file = ../secrets/qbittorrent/vpn.json;
};
openssh = {
users = {
nudelerde = {
pub_keys = {
file = ../secrets/openssh/nudelerde/pub_keys;
path = "/home/nudelerde/.ssh/authorized_keys";
owner = "nudelerde";
group = "users";
mode = "0600";
};
};
};
};
}

35
config/services.nix Normal file
View file

@ -0,0 +1,35 @@
# Config-backed service registry.
# NOTE: VPN credentials are temporarily stored here and will be moved
# to the dedicated secret system in a later migration step.
let
storage_data = import ../config/storage.nix;
secrets = import ../intermediate/secrets.nix;
in
rec {
continuwuity = {
port = 6167;
};
qbittorrent = {
port = 8085;
root_dir = "${storage_data.ssd.path}/qbittorrent";
vpn = {
username_file = secrets.byName.qbittorrent_vpn_username.path;
password_file = secrets.byName.qbittorrent_vpn_password.path;
};
};
kiwix = {
port = 8086;
root_dir = "${storage_data.ssd.path}/kiwix";
urls = [
"https://ftp.fau.de/kiwix/zim/wikipedia/wikipedia_en_all_nopic_2025-08.zim"
"https://download.kiwix.org/zim/wikipedia/wikipedia_de_all_nopic_2026-01.zim"
];
};
matrix = {
trusted_servers = [ "matrix.org" ];
};
}

View file

@ -3,24 +3,23 @@ rec {
path = "/"; path = "/";
type = "ext4"; type = "ext4";
source = "/dev/disk/by-label/NIXOS_SD"; source = "/dev/disk/by-label/NIXOS_SD";
options = ["noatime"]; options = [ "noatime" ];
}; };
ssd = { ssd = {
path = "/mnt/ssd"; path = "/mnt/ssd";
type = "ext4"; type = "ext4";
source = "/dev/disk/by-uuid/e44fedd5-150c-4af6-a2a0-0476da78e651"; source = "/dev/disk/by-uuid/e44fedd5-150c-4af6-a2a0-0476da78e651";
options = ["noatime"]; options = [ "noatime" ];
}; };
varlib-storage = { varlib-storage = {
path = "/var/lib"; path = "/var/lib";
type = "ext4"; type = "ext4";
source = "/dev/disk/by-uuid/c9aacddc-00ab-4d36-8a04-1051586b071c"; source = "/dev/disk/by-uuid/c9aacddc-00ab-4d36-8a04-1051586b071c";
options = ["noatime"]; options = [ "noatime" ];
extra = { extra = {
neededForBoot = true; neededForBoot = true;
}; };
}; };
} }
#rwxrwxrwx 1 root root 10 Jan 1 1970 a3ffb02e-fe9f-4bce-bd94-af0294ebff8f -> ../../sda1
#lrwxrwxrwx 1 root root 10 Jan 1 1970 c9aacddc-00ab-4d36-8a04-1051586b071c -> ../../sda2

9
config/web.nix Normal file
View file

@ -0,0 +1,9 @@
# Declarative web store config.
# Keep only root declarations here; parsing/loading happens in intermediate/web.nix.
rec {
stores = {
home = {
root = ../data/web;
};
};
}

View file

@ -5,30 +5,21 @@
... ...
}: let }: let
nixosHardwareVersion = "7f1836531b126cfcf584e7d7d71bf8758bb58969"; nixosHardwareVersion = "7f1836531b126cfcf584e7d7d71bf8758bb58969";
sopsNixVersion = "8f093d0d2f08f37317778bd94db5951d6cce6c46";
timeZone = "Europe/Berlin"; timeZone = "Europe/Berlin";
defaultLocale = "en_US.UTF-8"; defaultLocale = "en_US.UTF-8";
storageConfig = import ./data/storage.nix; storageModel = import ./intermediate/storage.nix;
fileSystemDefinition = lib.mapAttrs' (
name: value: {
name = storageConfig.${name}.path;
value = {
device = storageConfig.${name}.source;
fsType = storageConfig.${name}.type;
options = storageConfig.${name}.options;
} // (storageConfig.${name}.extra or {});
}) storageConfig;
in { in {
imports = [ imports = [
"${fetchTarball "https://github.com/NixOS/nixos-hardware/archive/${nixosHardwareVersion}.tar.gz"}/raspberry-pi/4" "${fetchTarball "https://github.com/NixOS/nixos-hardware/archive/${nixosHardwareVersion}.tar.gz"}/raspberry-pi/4"
./network/static-ip.nix "${fetchTarball "https://github.com/Mic92/sops-nix/archive/${sopsNixVersion}.tar.gz"}/modules/sops"
./system
./services ./services
./users
./programs ./programs
./secret
]; ];
fileSystems = fileSystemDefinition; fileSystems = storageModel.fileSystems;
networking.hostName = "raspberry"; networking.hostName = "raspberry";
@ -95,6 +86,8 @@ in {
options = "--delete-older-than +5"; # Keep last 5 generations options = "--delete-older-than +5"; # Keep last 5 generations
}; };
nix.settings.experimental-features = [ "nix-command" "flakes" ];
# Enable GPU acceleration # Enable GPU acceleration
hardware.raspberry-pi."4".fkms-3d.enable = true; hardware.raspberry-pi."4".fkms-3d.enable = true;

View file

@ -1,108 +0,0 @@
let
lib = import <nixpkgs/lib>;
in
rec {
network = {
subnet = "192.168.2.0/24";
subnet_base = "192.168.2.0";
gateway = ips.router;
cidr = 24;
};
ips = {
pi = "192.168.2.100";
desktop = "192.168.2.101";
router = "192.168.2.1";
remoteProxy = "193.31.24.99";
};
dhcp = {
pool_start = "192.168.2.50";
pool_end = "192.168.2.90";
default_lease = 3600;
max_lease = 86400;
reservations = [{
ip-address = ips.desktop;
hw-address = "30:9c:23:81:91:ea";
hostname = "desktop";
}];
};
fallback_dns_servers = [
"1.1.1.1"
"8.8.8.8"
];
local_domain = "home";
services = {
"pi" = {
ip = ips.pi;
};
"desktop" = {
ip = ips.desktop;
};
"torrent" = {
ip = ips.pi;
reverse_proxy = {
port = 8085;
};
};
"wiki" = {
ip = ips.pi;
reverse_proxy = {
port = 8086;
};
};
"router" = {
ip = ips.router;
};
"remoteProxy" = {
ip = ips.remoteProxy;
};
"continuwuity" = {
ip = ips.pi;
reverse_proxy = {
port = 6167;
ssl = true;
allowExternConnections = true;
listen = [
{
port = 80;
}
{
port = 443;
ssl = true;
}
{
port = 8448;
ssl = true;
}];
endpoints = ["/_matrix/"];
};
domainOverride = "nudelerde.de";
};
};
_serviceNames = (builtins.attrNames services);
_dnsMappingObjects = builtins.listToAttrs (
map (name: {
name = "${name}.${local_domain}";
value = services.${name}.ip;
})
_serviceNames
);
_predOnlyLocalObjs = (name: value: !(value ? domainOverride));
dnsMappings = lib.filterAttrs _predOnlyLocalObjs _dnsMappingObjects;
reverse_proxy = lib.filterAttrs (name: value: value ? reverse_proxy) services;
_portsUsedInService = (service: if service ? reverse_proxy
then if service.reverse_proxy ? listen
then map (obj: obj.port) service.reverse_proxy.listen
else if service.reverse_proxy ? ssl && service.reverse_proxy.ssl
then [80 443]
else [80]
else [80]);
usedPorts = lib.unique (lib.concatLists (map _portsUsedInService (builtins.attrValues services)));
}

View file

@ -1,29 +0,0 @@
let
lib = import <nixpkgs/lib>;
storage_data = import ./storage.nix;
in
rec {
qbittorrent = {
root_dir = "${storage_data.ssd.path}/qbittorrent";
vpn = {
username = "KNLdup50RYT1911K";
password = "FQCd6rfszoze0BJGgBhMHa3pIzpUdtyt";
};
};
kiwix = {
root_dir = "${storage_data.ssd.path}/kiwix";
urls = [
"https://ftp.fau.de/kiwix/zim/wikipedia/wikipedia_en_all_nopic_2025-08.zim"
"https://download.kiwix.org/zim/wikipedia/wikipedia_de_all_nopic_2026-01.zim"
];
};
matrix = {
trusted_servers = [ "matrix.org" ];
};
autossh = {
key_path = "/etc/auto-ssh_secrets/key";
known_hosts = "/etc/auto-ssh_secrets/known_hosts";
forwards = [];
};
}

View file

@ -1,34 +0,0 @@
let
allKeyDir = "/etc/nixos/ssh_keys";
readKeyFile = filePath:
let
content = builtins.readFile filePath;
lines = builtins.filter (line: line != "") (
builtins.filter builtins.isString (
builtins.split "\n" content
)
);
in lines;
getUserKeys = username:
let
userDir = "${allKeyDir}/${username}";
in
if builtins.pathExists userDir then
let
files = builtins.attrNames (builtins.readDir userDir);
allKeys = builtins.concatMap (file:
readKeyFile "${userDir}/${file}"
) files;
in allKeys
else [];
users = builtins.attrNames (builtins.readDir allKeyDir);
in
rec {
keys = builtins.listToAttrs (map (user: {
name = user;
value = getUserKeys user;
}) users);
ssh_users = users;
getKeys = getUserKeys;
}

View file

@ -1,34 +0,0 @@
let
lib = import <nixpkgs/lib>;
mapFileNameToContent = fileName: {
status = 200;
contentType = "text/html";
content = builtins.readFile fileName;
};
findFiles = dir:
let content = builtins.readDir dir;
processEntry = name: type:
if type == "directory" then
findFiles (dir + "/${name}")
else if type == "regular" then
[ (dir + "/${name}") ]
else
[];
in
lib.concatLists (lib.mapAttrsToList processEntry content);
removePrefix = str: prefix: if builtins.substring 0 (builtins.stringLength prefix) str == prefix
then builtins.substring (builtins.stringLength prefix) (builtins.stringLength str - builtins.stringLength prefix) str
else str;
contentFn = basePath:
let
files = findFiles basePath;
baseLength = builtins.stringLength basePath;
files' = builtins.filter (file: builtins.match ".*\\.html$" file != null) files;
in builtins.listToAttrs (map (file: {
name = removePrefix file basePath;
value = mapFileNameToContent file;
}) files');
in
rec {
"web.nudelerde.de" = contentFn "/etc/nixos/data/web/";
}

40
intermediate/dhcp.nix Normal file
View file

@ -0,0 +1,40 @@
let
lib = import <nixpkgs/lib>;
net = import ../config/network.nix;
networkDevicesValidation = import ../validation/network_devices.nix;
devices = networkDevicesValidation.getDevices net;
localDevices = networkDevicesValidation.getLocalDevices devices;
reservationRecords = lib.concatLists (lib.mapAttrsToList (deviceName: device:
if device ? reservation then
let
reservation = networkDevicesValidation.validateReservationShape deviceName device.reservation;
in
[
{
ip-address = device.ip;
hw-address = reservation.hw_address;
hostname = reservation.hostname;
}
]
else
[]
) localDevices);
ensureUnique = fieldName: values:
let
uniqueValues = lib.unique values;
in
if builtins.length uniqueValues != builtins.length values then
throw "Duplicate DHCP reservation ${fieldName} found."
else
null;
_uniqueIps = ensureUnique "ip-address" (map (reservation: reservation.ip-address) reservationRecords);
_uniqueHwAddresses = ensureUnique "hw-address" (map (reservation: reservation.hw-address) reservationRecords);
_uniqueHostnames = ensureUnique "hostname" (map (reservation: reservation.hostname) reservationRecords);
in
rec {
reservations = reservationRecords;
}

49
intermediate/dns.nix Normal file
View file

@ -0,0 +1,49 @@
let
lib = import <nixpkgs/lib>;
net = import ../config/network.nix;
end = import ../config/endpoints.nix;
endpointValidation = import ../validation/endpoints.nix;
networkDevicesValidation = import ../validation/network_devices.nix;
localDomain =
if net ? local_domain && builtins.isString net.local_domain && net.local_domain != "" then
net.local_domain
else
throw "config/network.nix must define local_domain as a non-empty string.";
localIngressIp =
if net ? devices && builtins.isAttrs net.devices && net.devices ? self && net.devices.self ? ip && builtins.isString net.devices.self.ip then
net.devices.self.ip
else
throw "config/network.nix must define devices.self.ip as local ingress IP for local endpoint DNS mapping.";
endpoints = endpointValidation.validateEndpointsShape end;
devices = networkDevicesValidation.getDevices net;
localDevices = networkDevicesValidation.getLocalDevices devices;
matchesLocalDomain = domain:
domain == localDomain || lib.hasSuffix ".${localDomain}" domain;
deviceMappings = builtins.listToAttrs (lib.mapAttrsToList (name: device: {
name = "${name}.${localDomain}";
value = device.ip;
}) localDevices);
localEndpointDomains = lib.unique (map (endpoint: endpoint.domain) (lib.filter (endpoint: matchesLocalDomain endpoint.domain) endpoints));
endpointMappings = builtins.listToAttrs (map (domain: {
name = domain;
value = localIngressIp;
}) localEndpointDomains);
mergedMappings = deviceMappings // endpointMappings;
_localEndpointConflicts = map (domain:
if deviceMappings ? ${domain} && deviceMappings.${domain} != endpointMappings.${domain} then
throw "DNS mapping conflict for '${domain}' between device-derived and endpoint-derived values."
else
null
) (builtins.attrNames endpointMappings);
in
rec {
dnsMappings = mergedMappings;
}

297
intermediate/nginx.nix Normal file
View file

@ -0,0 +1,297 @@
let
lib = import <nixpkgs/lib>;
net = import ../config/network.nix;
end = import ../config/endpoints.nix;
endpointValidation = import ../validation/endpoints.nix;
autoSshValidation = import ../validation/auto_ssh.nix;
endpoints = endpointValidation.validateEndpointsShape end;
subnet =
if net ? network && builtins.isAttrs net.network && net.network ? subnet && builtins.isString net.network.subnet then
net.network.subnet
else
throw "config/network.nix must define network.subnet as a string.";
localDomain =
if net ? local_domain && builtins.isString net.local_domain then
net.local_domain
else
throw "config/network.nix must define local_domain as a string.";
devices = autoSshValidation.getDevices net;
autoSshDevices = autoSshValidation.getAutoSshDevices devices;
autoSshDomains = autoSshValidation.getAutoSshDomains autoSshDevices;
matchesDomain = domain: candidate:
domain == candidate || lib.hasSuffix ".${candidate}" domain;
classifyExposure = domain:
let
isLocal = matchesDomain domain localDomain;
isRemote = lib.any (candidate: matchesDomain domain candidate) autoSshDomains;
in
if isLocal && isRemote then
throw "Domain '${domain}' is both local and remote according to config/network.nix."
else if isLocal then
"local"
else if isRemote then
"external"
else
throw "Domain '${domain}' is neither local nor mapped to an auto_ssh remote domain.";
validateEndpointSemantics = index: endpoint:
let
_forceSslPort =
if endpoint.force_ssl && endpoint.port == 80 then
throw "Endpoint at index ${toString index} cannot use force_ssl=true on port 80."
else
null;
_proxyType =
if endpoint.type == "proxy" && endpoint.content.type != "service" then
throw "Proxy endpoint at index ${toString index} must use content.type = 'service'."
else
null;
in
endpoint // {
exposure = classifyExposure endpoint.domain;
};
validatedEndpoints = lib.imap0 validateEndpointSemantics endpoints;
normalizeWebPrefix = endpoint:
if endpoint == "/" then
"/"
else if lib.hasSuffix "/" endpoint then
endpoint
else
throw "Web endpoint '${endpoint}' must end with '/' so it can be used as a file prefix.";
joinWebPath = endpointPrefix: relativePath:
let
prefix = normalizeWebPrefix endpointPrefix;
relativeNoContext = builtins.unsafeDiscardStringContext relativePath;
_ =
if builtins.isString relativeNoContext && relativeNoContext != "" && builtins.substring 0 1 relativeNoContext != "/" then
null
else
throw "Web store file path '${toString relativeNoContext}' must be a non-empty relative path.";
in
builtins.unsafeDiscardStringContext "${prefix}${relativeNoContext}";
expandWebEndpoint = endpoint:
if endpoint.type != "web" then
[ endpoint ]
else
let
files =
if endpoint.content ? files && builtins.isList endpoint.content.files then
endpoint.content.files
else
throw "Web endpoint '${endpoint.domain}${endpoint.endpoint}' must define content.files as a list.";
_ =
if files != [] then
null
else
throw "Web endpoint '${endpoint.domain}${endpoint.endpoint}' resolves to zero files. Check config/web.nix roots and data files.";
in
map (file:
endpoint // {
endpoint = joinWebPath endpoint.endpoint file.path;
content = {
status = file.status;
contentType = file.contentType;
filePath = file.filePath;
};
}
) files;
mappedEndpoints = lib.concatLists (map expandWebEndpoint validatedEndpoints);
hostKey = endpoint: "${endpoint.domain}|${toString endpoint.port}|${if endpoint.force_ssl then "tls" else "plain"}";
hostKeyWithoutTls = endpoint: "${endpoint.domain}|${toString endpoint.port}";
groupedByHost = lib.foldl' (acc: endpoint:
let
key = hostKey endpoint;
current = if acc ? ${key} then acc.${key} else [];
in
acc // { ${key} = current ++ [ endpoint ]; }
) {} mappedEndpoints;
groupedByHostWithoutTls = lib.foldl' (acc: endpoint:
let
key = hostKeyWithoutTls endpoint;
current = if acc ? ${key} then acc.${key} else [];
in
acc // { ${key} = current ++ [ endpoint.force_ssl ]; }
) {} mappedEndpoints;
groupedByDomain = lib.foldl' (acc: endpoint:
let
key = endpoint.domain;
current = if acc ? ${key} then acc.${key} else [];
in
acc // { ${key} = current ++ [ endpoint.force_ssl ]; }
) {} mappedEndpoints;
_tlsConsistency = lib.mapAttrs (_: tlsValues:
let
uniqueValues = lib.unique tlsValues;
in
if builtins.length uniqueValues > 1 then
throw "Found incompatible TLS definitions for the same domain+port."
else
null
) groupedByHostWithoutTls;
_domainTlsConsistency = lib.mapAttrs (domain: tlsValues:
let
uniqueValues = lib.unique tlsValues;
in
if builtins.length uniqueValues > 1 then
throw "Found incompatible TLS definitions for domain '${domain}'."
else
null
) groupedByDomain;
ensureUniquePaths = key: routes:
let
paths = map (route: route.endpoint) routes;
uniquePaths = lib.unique paths;
in
if builtins.length paths != builtins.length uniquePaths then
throw "Duplicate endpoint paths found for host key ${key}."
else
routes;
ensureExposureConsistency = key: routes:
let
exposures = lib.unique (map (route: route.exposure) routes);
in
if builtins.length exposures != 1 then
throw "Mixed exposure policy found for host key ${key}."
else
routes;
sortRoutes = routes:
builtins.sort (a: b: builtins.stringLength a.endpoint > builtins.stringLength b.endpoint) routes;
sanitizeHostKey = key:
builtins.replaceStrings [ "." "|" ":" "/" ] [ "_" "_" "_" "_" ] key;
escaped = value:
builtins.replaceStrings [ "\\" "\"" ] [ "\\\\" "\\\"" ] value;
mkLocation = route:
if route.type == "proxy" then
let
localNodeIp =
if devices ? self && devices.self ? ip && builtins.isString devices.self.ip then
devices.self.ip
else
throw "config/network.nix must define devices.self.ip as a string.";
proxyIp =
if route.content.ip == localNodeIp then
"127.0.0.1"
else
route.content.ip;
in
{
name = route.endpoint;
value = {
proxyPass = "http://${proxyIp}:${toString route.content.port}";
proxyWebsockets = route.content.proxyWebsockets;
};
}
else
let
statusValue =
if route.content ? status && builtins.isInt route.content.status then
route.content.status
else
throw "Web endpoint '${route.domain}${route.endpoint}' must define content.status as int.";
contentTypeValue =
if route.content ? contentType && builtins.isString route.content.contentType && route.content.contentType != "" then
route.content.contentType
else
throw "Web endpoint '${route.domain}${route.endpoint}' must define content.contentType as non-empty string.";
filePathValue =
if route.content ? filePath && (builtins.isString route.content.filePath || builtins.isPath route.content.filePath) then
route.content.filePath
else
throw "Web endpoint '${route.domain}${route.endpoint}' must define content.filePath as string or path.";
_status =
if statusValue == 200 then
null
else
throw "Web endpoint '${route.domain}${route.endpoint}' must use status 200 for static file serving.";
in
{
name = "= ${route.endpoint}";
value = {
alias = toString filePathValue;
extraConfig = ''
types { }
default_type ${contentTypeValue};
'';
};
};
mkVirtualHost = key: routes:
let
checkedRoutes = ensureExposureConsistency key (ensureUniquePaths key routes);
sortedRoutes = sortRoutes checkedRoutes;
headRoute = builtins.head sortedRoutes;
hostName = sanitizeHostKey key;
locations = builtins.listToAttrs (map mkLocation sortedRoutes);
base = {
serverName = headRoute.domain;
listen = [
({
addr = "0.0.0.0";
port = headRoute.port;
} // lib.optionalAttrs headRoute.force_ssl { ssl = true; })
];
locations = locations;
};
exposureConfig =
if headRoute.exposure == "external" then
{
extraConfig = ''
allow all;
'';
}
else
{
extraConfig = ''
allow ${subnet};
deny all;
'';
};
sslConfig =
if headRoute.force_ssl then
{
enableACME = true;
forceSSL = true;
}
else
{};
in
{
name = hostName;
value = base // exposureConfig // sslConfig;
};
virtualHostsData = builtins.listToAttrs (lib.mapAttrsToList mkVirtualHost groupedByHost);
nginxUsedPorts =
lib.unique (map (route: route.port) mappedEndpoints);
acmeDomains =
lib.unique (map (route: route.domain) (lib.filter (route: route.force_ssl) mappedEndpoints));
in
rec {
inherit validatedEndpoints mappedEndpoints virtualHostsData nginxUsedPorts acmeDomains;
}

78
intermediate/remote.nix Normal file
View file

@ -0,0 +1,78 @@
let
lib = import <nixpkgs/lib>;
net = import ../config/network.nix;
end = import ../config/endpoints.nix;
endpointValidation = import ../validation/endpoints.nix;
autoSshValidation = import ../validation/auto_ssh.nix;
endpoints = endpointValidation.validateEndpointsShape end;
devices = autoSshValidation.getDevices net;
autoSshDevices = autoSshValidation.getAutoSshDevices devices;
matchesDomain = endpointDomain: remoteDomain:
if builtins.isString endpointDomain && builtins.isString remoteDomain then
endpointDomain == remoteDomain || lib.hasSuffix ".${remoteDomain}" endpointDomain
else
throw "Endpoint and remote domains must be strings.";
getRemotePortMap = device:
autoSshValidation.getRemotePortMap device;
resolveRemotePort = remotePortMap: endpoint:
let
localPort =
if endpoint ? port then
endpoint.port
else
throw "Endpoint is missing required field: port.";
overrides = lib.filter (entry: entry.localPort == localPort) remotePortMap;
in
if overrides != [] then
(builtins.head overrides).remotePort
else
localPort;
mapEndpointToForward = remotePortMap: endpoint: {
remote = resolveRemotePort remotePortMap endpoint;
localAddress = "localhost";
localPort = endpoint.port;
};
endpointForHttpRedirect = endpoint:
endpoint // { port = 80; };
portsByRemote = lib.mapAttrs (_: device:
let
remoteDomain =
if device ? domain then
device.domain
else
throw "Auto SSH device is missing required field: domain.";
remotePortMap = getRemotePortMap device;
matchedEndpoints =
lib.filter (endpoint:
if endpoint.force_ssl && endpoint.port == 80 then
throw "Endpoint '${endpoint.domain}${endpoint.endpoint}' cannot use force_ssl=true on port 80."
else if endpoint.type == "proxy" && endpoint.content.type != "service" then
throw "Proxy endpoint '${endpoint.domain}${endpoint.endpoint}' must use content.type = 'service'."
else
matchesDomain endpoint.domain remoteDomain
) endpoints;
forwards = lib.concatMap (endpoint:
let
baseForward = mapEndpointToForward remotePortMap endpoint;
httpRedirectForward = mapEndpointToForward remotePortMap (endpointForHttpRedirect endpoint);
in
if endpoint.force_ssl then
[ baseForward httpRedirectForward ]
else
[ baseForward ]
) matchedEndpoints;
in
lib.unique forwards
) autoSshDevices;
in
rec {
inherit portsByRemote;
}

163
intermediate/secrets.nix Normal file
View file

@ -0,0 +1,163 @@
let
lib = import <nixpkgs/lib>;
secretsConfig = (import ../validation/secrets.nix).getSecretsConfig (import ../config/secrets.nix);
isSopsEncrypted = filePath:
let
content = builtins.readFile filePath;
in
lib.hasInfix "\nsops:" content
|| lib.hasInfix "\n\"sops\":" content
|| lib.hasInfix "\"sops\":" content
|| builtins.substring 0 5 content == "sops:";
extractJsonKeys = filePath:
let
content = builtins.readFile (builtins.toString filePath);
parsed = builtins.fromJSON content;
recurseKeys = prefix: obj:
if builtins.isAttrs obj then
lib.concatMap (name:
if name == "sops" then
[]
else
let
newPrefix = if prefix == "" then name else "${prefix}.${name}";
in
recurseKeys newPrefix obj.${name}
) (builtins.attrNames obj)
else
[ prefix ];
in
recurseKeys "" parsed;
getRuntimePath = path:
"/run/secrets/${builtins.concatStringsSep "_" path}";
defaultMetadata = {
path = null;
owner = null;
group = null;
mode = null;
};
# Step 1: Convert path/string leaves to a normalized attrset shape.
normalizeLeaf = path: node:
if builtins.isString node || builtins.isPath node then
{
file = node;
keys = null;
metadata = defaultMetadata;
}
else if builtins.isAttrs node && node ? file then
{
file = node.file;
keys = node.keys or null;
metadata = {
path = node.path or null;
owner = node.owner or null;
group = node.group or null;
mode = node.mode or null;
};
}
else
throw "Invalid secret leaf at ${builtins.concatStringsSep "." path}: must be string, path, or attrset with 'file'";
# Step 2: Flatten normalized leaf structure into entries.
flattenTree = path: node:
if builtins.isAttrs node && !(node ? file) then
lib.concatMap (name:
flattenTree (path ++ [ name ]) node.${name}
) (builtins.attrNames node)
else
let
normalized = normalizeLeaf path node;
in
[ {
inherit path;
file = normalized.file;
keys = normalized.keys;
metadata = normalized.metadata;
} ];
entries = flattenTree [] secretsConfig;
# Step 3a: Look for keys when type is JSON and keys are not explicitly provided.
enrichedEntries = map (entry:
let
isJson = builtins.match ".*\\.json$" (builtins.toString entry.file) != null;
extractedKeys = if isJson && entry.keys == null then extractJsonKeys entry.file else null;
in
entry // { keys = if extractedKeys != null then extractedKeys else entry.keys; }
) entries;
isReady = entry:
if !builtins.pathExists entry.file then
false
else if builtins.match ".*\\.(ya?ml|json)$" (builtins.toString entry.file) != null then
true
else
isSopsEncrypted entry.file;
readyEntries = builtins.filter isReady enrichedEntries;
missingEntries = builtins.filter (entry: !(isReady entry)) enrichedEntries;
# Step 3b: Expand key lists to per-secret entries and build sops.secrets attrset.
mkSopsSecrets = sourceEntries:
let
expanded = lib.concatMap (entry:
if entry.keys != null && entry.keys != [] then
map (key: entry // { singleKey = key; }) entry.keys
else
[ (entry // { singleKey = null; }) ]
) sourceEntries;
mkEntry = entry:
let
normalizedKeyName = if entry.singleKey != null then
builtins.replaceStrings [ "." ] [ "_" ] entry.singleKey
else
null;
secretName = if entry.singleKey != null then
builtins.concatStringsSep "_" (entry.path ++ [ normalizedKeyName ])
else
builtins.concatStringsSep "_" entry.path;
isJson = builtins.match ".*\\.json$" (builtins.toString entry.file) != null;
isYaml = builtins.match ".*\\.ya?ml$" (builtins.toString entry.file) != null;
sopsKey =
if entry.singleKey == null then
null
else
builtins.replaceStrings [ "." ] [ "/" ] entry.singleKey;
in
{
name = secretName;
value = {
sopsFile = entry.file;
# Compatibility: current sops-install-secrets key traversal expects
# YAML map shape for nested key extraction. JSON documents are valid
# YAML, so parse .json via "yaml" to support nested keys.
format = if isJson || isYaml then "yaml" else "binary";
path = if entry.metadata.path != null then
entry.metadata.path
else
getRuntimePath (entry.path ++ lib.optional (normalizedKeyName != null) normalizedKeyName);
owner = if entry.metadata.owner != null then entry.metadata.owner else "root";
group = if entry.metadata.group != null then entry.metadata.group else "root";
mode = if entry.metadata.mode != null then entry.metadata.mode else "0400";
} // lib.optionalAttrs (sopsKey != null) {
key = sopsKey;
};
};
in
builtins.listToAttrs (map mkEntry expanded);
in
{
source = secretsConfig;
byName = mkSopsSecrets readyEntries;
missing = missingEntries;
}

27
intermediate/storage.nix Normal file
View file

@ -0,0 +1,27 @@
let
lib = import <nixpkgs/lib>;
storageValidation = import ../validation/storage.nix;
storageConfig = storageValidation.getStorageConfig (import ../config/storage.nix);
mkFileSystem = name: entry:
let
extraAttrs =
if entry ? extra then
entry.extra
else
{};
in
{
name = entry.path;
value = {
device = entry.source;
fsType = entry.type;
options = entry.options;
} // extraAttrs;
};
fileSystems = builtins.listToAttrs (lib.mapAttrsToList mkFileSystem storageConfig);
in
rec {
inherit fileSystems;
}

102
intermediate/web.nix Normal file
View file

@ -0,0 +1,102 @@
let
lib = import <nixpkgs/lib>;
webConfig = import ../config/web.nix;
webValidation = import ../validation/web.nix;
stores = webValidation.getStores webConfig;
stripPrefix = prefix: value:
let
prefixLen = builtins.stringLength prefix;
valueLen = builtins.stringLength value;
in
if valueLen >= prefixLen && builtins.substring 0 prefixLen value == prefix then
builtins.substring prefixLen (valueLen - prefixLen) value
else
value;
trimLeadingSlash = value:
if builtins.stringLength value > 0 && builtins.substring 0 1 value == "/" then
builtins.substring 1 (builtins.stringLength value - 1) value
else
value;
findFiles = dir:
let
entries = builtins.readDir dir;
processEntry = name: type:
let
path = dir + "/${name}";
in
if type == "directory" then
findFiles path
else if type == "regular" then
[ path ]
else
[];
in
lib.concatLists (lib.mapAttrsToList processEntry entries);
detectContentType = relativePath:
if lib.hasSuffix ".html" relativePath then "text/html"
else if lib.hasSuffix ".css" relativePath then "text/css"
else if lib.hasSuffix ".js" relativePath then "application/javascript"
else if lib.hasSuffix ".json" relativePath then "application/json"
else if lib.hasSuffix ".svg" relativePath then "image/svg+xml"
else if lib.hasSuffix ".png" relativePath then "image/png"
else if lib.hasSuffix ".jpg" relativePath || lib.hasSuffix ".jpeg" relativePath then "image/jpeg"
else if lib.hasSuffix ".gif" relativePath then "image/gif"
else if lib.hasSuffix ".webp" relativePath then "image/webp"
else if lib.hasSuffix ".ico" relativePath then "image/x-icon"
else if lib.hasSuffix ".txt" relativePath then "text/plain"
else if lib.hasSuffix ".xml" relativePath then "application/xml"
else if lib.hasSuffix ".pdf" relativePath then "application/pdf"
else if lib.hasSuffix ".wasm" relativePath then "application/wasm"
else "application/octet-stream";
mkStorePath = storeName: rootPath:
builtins.path {
path = rootPath;
name = "web-store-${storeName}";
};
mkStoreFileEntries = storeName: store:
let
storeRoot = mkStorePath storeName store.root;
files = findFiles storeRoot;
mkFileEntry = filePath:
let
filePathString = toString filePath;
relative = trimLeadingSlash (stripPrefix (toString storeRoot) filePathString);
in
{
path = relative;
filePath = filePath;
contentType = detectContentType relative;
status = 200;
};
entries = map mkFileEntry files;
sortedEntries = builtins.sort (a: b: a.path < b.path) entries;
paths = map (entry: entry.path) sortedEntries;
uniquePaths = lib.unique paths;
_ =
if builtins.length paths == builtins.length uniquePaths then
null
else
throw "config/web.nix store '${storeName}' produces duplicate relative file paths in root '${toString store.root}'.";
in
sortedEntries;
storeFilesByName = lib.mapAttrs mkStoreFileEntries stores;
storePayloads = lib.mapAttrs (_: files: {
type = "store";
files = files;
}) storeFilesByName;
in
rec {
inherit stores storeFilesByName storePayloads;
}

56
secrets/README.md Normal file
View file

@ -0,0 +1,56 @@
# Encrypted Secrets
This directory is intended for encrypted secret files managed with sops.
Phase 1 notes:
- Keep encrypted files in git under `secrets/`.
- Do not commit plaintext secret material.
- Update `.sops.yaml` recipients before creating real secrets.
Typical next step:
1. Set real age recipients in `.sops.yaml`.
2. Fill the template YAML files with real secret values.
3. Encrypt them in place using `sops`.
## Phase 3 expected file
Create these encrypted files:
- `secrets/autossh/remote_proxy_key`
- `secrets/autossh/remote_proxy_known_hosts`
- `secrets/openssh/authorized_keys`
Expected YAML keys:
- `qbittorrent.vpn.username`
- `qbittorrent.vpn.password`
These are materialized at runtime to:
- `/run/secrets/autossh/remote_proxy_key`
- `/run/secrets/autossh/remote_proxy_known_hosts`
- `/run/secrets/openssh_authorized_keys`
File secrets are stored as encrypted whole files, so the decrypted runtime content is exactly the file body. That is the right choice for bigger files like SSH private keys and known_hosts files.
`config/secrets.nix` is the source of truth for the tree, and `config/sops.nix` is derived from it.
## How to encrypt them
Fill in the placeholders, then run:
```bash
sops -e -i secrets/autossh/remote_proxy_key
sops -e -i secrets/autossh/remote_proxy_known_hosts
sops -e -i secrets/openssh/authorized_keys
```
For scalar secrets such as `secrets/qbittorrent/vpn.yaml`, use the same command and keep the YAML structure.
## Template files to fill
- `secrets/autossh/remote_proxy.yaml`
- `secrets/qbittorrent/vpn.yaml`
- `secrets/openssh/authorized_keys.yaml`
After editing, encrypt each file in place with `sops -e -i <file>`.

View file

@ -0,0 +1,18 @@
{
"data": "ENC[AES256_GCM,data:Iym3BrpihMwVIVxaMIRDP0PDmrdAKv+MBhn9PVoejrnHmPjhlFKGVzVYaH7ch2KlggiNNO8ywQfkGjbJyLKjs8489pCmOAOoc3HoVGbf5qvo34ZdBQfv3G+GRaKQXAuQOK61is3fNMo5cVBuRFdjba10pnUQrbEahNyhzCH6TwFUyX2YcUo5zoc5RNjMGt9UfS0Prr95Lnejpu3YGJ+yQnKBFFAnjjJaOtLj//sUfF2bngZk/KmqHqaAj0GM6ZB5wJi76Z3q1Z+jUfYSIqv8chqLFdo5s+mttEtYDAdqeTKmyMrpRMtLQ09H1Rwuu10Uklk2VW7PU7+pwJfH3VVRAqW63rM2woQeEg9Gs4AQzo0sLhCmHw6CppIe1Hi3ZzHBYQbu+rFlWHS9ohHgWx1OQEENtYH1ZKy2Gt9BgbmgY6QGbGnuh+ROlyNp8EQaLXoEg8KSfC7i8VhqBkBUp8UZaQWvmYI9SOd5sRESMONBOwLfDL+zsZi+7lDtiKqUQgXpW4lhiL9toqjeUlqmAk2P/Dz+54rM5WeNf/gh,iv:RxTg1LCac+vbluKNQD5Zdf8BZISIWkqRGUqV80CBIv4=,tag:jmkYsdgMBAJUfxMxnuc13g==,type:str]",
"sops": {
"age": [
{
"recipient": "age1g5q3hwnpgsas682jkq0zmee3zqggucfe0v5ec0a6pv7wzexadehqne66cj",
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBNWjRJbGZNeEU0MTVhZUlU\nbnJPYlNOcU5raUtUUnlDTmdzWWgwRDExZVMwCk1IUUNnbnQ2UU94N0psR3V6bGlD\neXUySktnZDRPeWtER3VNNDFhUThtMmsKLS0tIGFtRW5BZ1VyZHdOdHA0T3dWcFhL\nSmpBTzBBdEZMMlVjYTBObXFXSElWN3cKvwUP6fse0T8+cF1EMOnlK9J/gDLokTCk\nI2WzU8fTLxpO/ioieQSm0MtpGm30hyXk8JbhSgZ1rRw1tQTQynLSfw==\n-----END AGE ENCRYPTED FILE-----\n"
},
{
"recipient": "age1qmnmge7atpg5k0zdaky0tuux2rgtehxfhtnshcjpyl0n2hx2udhqe62wyj",
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBmMmNsVThSNGhrQ1VWckkx\neER2c3BLbVIxTTFJaGVhNVlOT202UHhCaERBCjk5bXZmZjhlOFRSWHh3cnhDbGFm\nN3Boc1pzWVR2cE94a0xFSnBybFpVQ2cKLS0tIG81RzVtZXVIcGo4Q083YzNoZm9x\nOEkvaDVFdE0wb1Y0NnlOR0dlM2V4Vm8Kl8GsimjzsnLDnvF0I2B9K79Ohj7aoITo\nlJ8O8BlixGhK3LmxAaHDihiqnV+YMWROorF+8z1eJ+wtBOU1ON8F4Q==\n-----END AGE ENCRYPTED FILE-----\n"
}
],
"lastmodified": "2026-04-04T13:36:38Z",
"mac": "ENC[AES256_GCM,data:6stnsbcCiaiAkjZ4kWZ8FHxLuoxsH5vPoZclhnSY0GmFlhD17i+qJBiD21KBO5fhnOmQdku3x18XeHvzjbommlmDRaaIcTk71phkP9c5eVOQSuQCe/02xTBDGriCTT/NFgoLBIM5VGp1ItI8BxNEL/Lp29ugjQqCVqHV9sXYihc=,iv:lL0UfNYe4Y0S7whNts5BSc/AYxpeuWbwifRFqqiz7tI=,tag:XWVlf5+QDetmkgQlmPgyIg==,type:str]",
"version": "3.12.1"
}
}

View file

@ -0,0 +1,18 @@
{
"data": "ENC[AES256_GCM,data:1ZzjJFqw28vXu6cw9efESy11132ntsaZwBmNqupqWju+y2iT2qq1wE+meGVH/e59YMC0ptNpsm+RbcfvC81vFvTFu46FUXPw2fXPb5UQmyDZxN7q+AM8SuOCqSy6,iv:FguIXmdsNUvhoqME97e5OIY+LMy5uuNd+d295U4lTuE=,tag:QlKokl5sOAtgwu06pnuRgA==,type:str]",
"sops": {
"age": [
{
"recipient": "age1g5q3hwnpgsas682jkq0zmee3zqggucfe0v5ec0a6pv7wzexadehqne66cj",
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBMOFZDME1qTXlPVFp4Y3No\nQzJvSHJrOUVGMGRtNkpOeXVDYkRjT1NJSmhVCk9zVUxTZFA3RXUwbURiS2ltUGFF\ncms4YmZuRzgxR05ybEJxRUt5MHFyaHcKLS0tIExzRE9peG1TOU9EY21CejFWeHJY\nQ09VRjd0OURiYWlRenVLeEVTNnEva00KXqvLbJUtdQPp0miPg2dXBHEX3z8WpdoL\n64Wc3iIMRZmOruWdlij5cstcV4pDIAkDHk5rVvOthXOhGWARFSySbQ==\n-----END AGE ENCRYPTED FILE-----\n"
},
{
"recipient": "age1qmnmge7atpg5k0zdaky0tuux2rgtehxfhtnshcjpyl0n2hx2udhqe62wyj",
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBiSVhGUHVJNVZVRXhxZzhx\ncEVSNWZBQW8vQXlYdnFUQTg4Q2VlY0lZckRnCkRKNnF5R3ZlUElWN0FOL1hrNHAr\nT2JPaDJ3bzVhZzZONHBkRHBueW16eVEKLS0tIEFxU2pWMVRqaC9BeGRsaWppT1lO\nMzI4UmkybmROL3dKK0xEaWpCM1VPQzQKWsLEb0Z7fbTVF8WQ8a1Lom8Bh7FQ2cPB\nIwIqWRM1L0oXNHyBoFkmHO434DZ8SXlyUYegBwrbVZGedRQFLQ4ibg==\n-----END AGE ENCRYPTED FILE-----\n"
}
],
"lastmodified": "2026-04-04T09:12:02Z",
"mac": "ENC[AES256_GCM,data:C6jOqg6lFy8A2UTIfZXjtNB/F+0UZJV8/F1sOm4aGgR5B4e2JT+3vL/A62m6pvrj1zSuoD1dH47y3OcpmO/FmsnYcCTdWW2MPEOA63NkHJMScaLJry8JDHpColG8WYmJfGb9QwLJ1O64vpUNXNscmUFs7PomBgJnMCmTyo3twKk=,iv:wiAJw8Vk70b/gnfcgPlT3exsnnp87mGoIXMtzqd5m88=,tag:oT9TPTNSDX9y0cJJCP/JkQ==,type:str]",
"version": "3.12.1"
}
}

View file

@ -0,0 +1,18 @@
{
"data": "ENC[AES256_GCM,data:nRYC+xLnrS3nWrAeohjrIxZpmxWJgw8KxyPOTlnsQLZ3TnGFETPwtF1d6zkqOA4PRmx7j9FrvBSVXMuPCOjk+buy9VWg0FbUBbPXkvZIlYujO65PNDhtan0nHVmMLmc=,iv:xbAu2PamJZQL5mRk6ltgvqbuWoqW6cBPcFMAbUTxtw0=,tag:BRlpJK++1zo26gZNuTdo0A==,type:str]",
"sops": {
"age": [
{
"recipient": "age1g5q3hwnpgsas682jkq0zmee3zqggucfe0v5ec0a6pv7wzexadehqne66cj",
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSB2TjFGc3o2WDd6LzFndmRX\ncTFOenBZMVhHQTFwZ3ZhbUZoQ3d0U0NFVlFNCnBac3lhV0MrTTVOMGM0UVdzZWph\nR2dMMm1Wd0Y5RTZaR3JsZlkyUUExSkEKLS0tIDF1Ykk3WUlFR1NobjlMb0t2amQr\nS0pxRTExd2RUbnpQS01jRFhOeTZqbEUKAqTUCqboGDjhxZhbtRzNGCFdmNqfRnd2\nNk0r7MyT0HWOcJ7RR3iuYaDOM+mTWPcVkg49qjlBvqDf7V48/BP0Yw==\n-----END AGE ENCRYPTED FILE-----\n"
},
{
"recipient": "age1qmnmge7atpg5k0zdaky0tuux2rgtehxfhtnshcjpyl0n2hx2udhqe62wyj",
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBoUHkvcWN4cmY1TlJoN3Zu\nc1ZyWGdLSk9VR2hVTmNkemZpbVd2ZTY5UTJJCmFCK2RQNnEyQkNPU0VTUHowT3NP\nckFkZWRtWU1nWVZibkcvZXhXZ0ZRaG8KLS0tIEZZU0c4eDljRlNIbmFHZXprSGc2\nQTFHZW02cWVZeXJja3FvbnhQNkozaTgKQV71qhDor/GFMtxh0Hq/7cRIBYHLvVG+\nSXV6kleiGrv1KdHLrhzJWdOBIhHRcC1ttQESCPRo9Wu7YBCN0KMUmQ==\n-----END AGE ENCRYPTED FILE-----\n"
}
],
"lastmodified": "2026-04-04T09:18:27Z",
"mac": "ENC[AES256_GCM,data:7GX0/YPMQpv/BHGc3RCqtxVHs2q44s1BWLim4eNuOH9AwMSQ5DohexuWS8hh0zGzthG8Nhv/X+R1Z0B3yXJyWkYiRuuAfO5RKMRWYNWQuCg1dsvkdDA+75D1Ffx6JuyzW+veqNJltDhEE6Jy9S9k14pGUXhjOmufxANUFjQULWo=,iv:9uW4ivEvdQI7ZA5+yJTKVAUrNpbN8lOLo/mJire1d0s=,tag:qgUZPZLFR/u+Dmuqyl764Q==,type:str]",
"version": "3.12.1"
}
}

View file

@ -0,0 +1,22 @@
{
"vpn": {
"username": "ENC[AES256_GCM,data:xEt09ZfPxp2n1G4c1R2X0Q==,iv:7PrsBmyMKcZKKvBhMSRBCKRmiPDjaBIUp+eQEOVVsFM=,tag:njOQl6plAdWe721cqI222w==,type:str]",
"password": "ENC[AES256_GCM,data:i96WgN69BCmV0yGBtpa2BYtpeyy9rSQqt2RW0hJHlog=,iv:1s24mv/jot4mOAn77DqI6Iw/Tzkl0g0cz95jOwWwiRE=,tag:Gqlv24hGgyRTw0EpNf8JGQ==,type:str]"
},
"sops": {
"age": [
{
"recipient": "age1g5q3hwnpgsas682jkq0zmee3zqggucfe0v5ec0a6pv7wzexadehqne66cj",
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBIL3VaMG53OFZrWVdQLzM0\nSkxBckVvRHNKZ3kxMTBwNHhEY3R0R2RjM1RvCkxyditvRzhNYUhVMlNXMWRtb2pF\nZGFZZkF1WlFUMlFHN1hGTmVibmtndzgKLS0tIFp3USsvMityTXBWU3g4MWV4dmkx\nVldLdkh4QXJsZWhyalVlaUwrMGVoQ2sKYFNkF41ba+rv0MsJ3PCw+HFejMsAv9MK\nt1GhHCXdTXqtw2DgjdvFePf8CHgTwBdVt7iLL2BDOE79S2PkXXEbng==\n-----END AGE ENCRYPTED FILE-----\n"
},
{
"recipient": "age1qmnmge7atpg5k0zdaky0tuux2rgtehxfhtnshcjpyl0n2hx2udhqe62wyj",
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBOS1FFR2tuMkZkbFZzR0E3\nMVdHRTJaNXZMNXJrdTFJZVhFU3lsamUxckhJCnBYQXFPbjNpWTZzb0R4d0duQi8v\nODU1Y0RmWXl3UXJHTDVIdVROTDlMRkEKLS0tIFFGNENjMUR5OVROWi9aTEsvWDNG\naEx0WmZtUVZuN01FU0wrUnY3SCtuQ1UKKxh+u5LBjDJsrYxXrWAY56HO7d1THJ0f\noLzDgVPZMc9Eh+uwiV3/B3U6d9IPMPOEEHaG0KUpx0lffrFxjqreEg==\n-----END AGE ENCRYPTED FILE-----\n"
}
],
"lastmodified": "2026-04-04T12:05:03Z",
"mac": "ENC[AES256_GCM,data:drwuU+ixqj6BtG7sDbn+skge5PxaXUzwLObiDCUd59yXQ3yIKatDk5E0Z4F4jliV3O/5Gq96u/GxAfojCeyBnfuXQ9TaX0ooxUj7uzGRn84DaD5f60gAn/h4H/HwfDUIEzw0jdME8zJ/tIetOIpwXXk5IWdnSUd+/qQE+6vX2HU=,iv:4lEJiE7JrRI5wnhw4DcNwbeek9bdrCyJykv0JZhNJDI=,tag:WSX+5vHaZVrdZCrfs5/VjA==,type:str]",
"unencrypted_suffix": "_unencrypted",
"version": "3.12.1"
}
}

View file

@ -1,57 +1,145 @@
{ config, pkgs, lib, ... }: { config, pkgs, lib, ... }:
let let
net = import ../data/network.nix; net = import ../config/network.nix;
serv = import ../data/services.nix; rem = import ../intermediate/remote.nix;
autoSshValidation = import ../validation/auto_ssh.nix;
autoForwards = map (port: { devices = autoSshValidation.getDevices net;
remote = port;
localAddress = "localhost";
localPort = port;
}) net.usedPorts;
fordwards = lib.unique (serv.autossh.forwards ++ autoForwards);
forwardStrings = map (port: "-R ${toString port.remote}:${port.localAddress}:${toString port.localPort}") fordwards; portsByRemote =
forwardString = builtins.concatStringsSep " " forwardStrings; if rem ? portsByRemote && builtins.isAttrs rem.portsByRemote then
rem.portsByRemote
else
throw "intermediate/remote.nix must export portsByRemote as an attrset.";
sshHost = net.services.remoteProxy.ip; autoSshDevices = autoSshValidation.getAutoSshDevices devices;
sshPort = 22;
sshUser = "root"; getAutoSsh = deviceName: device:
sshKeyPath = serv.autossh.key_path; autoSshValidation.getAutoSshConfig deviceName device;
trustedHostsFile = serv.autossh.known_hosts;
validateForwards = deviceName: forwards:
if !builtins.isList forwards then
throw "portsByRemote.${deviceName} must be a list of forward objects."
else if !lib.all (forward:
builtins.isAttrs forward
&& forward ? remote
&& forward ? localAddress
&& forward ? localPort
&& builtins.isInt forward.remote
&& builtins.isString forward.localAddress
&& builtins.isInt forward.localPort
) forwards then
throw "Each forward in portsByRemote.${deviceName} must be { remote = int; localAddress = string; localPort = int; }."
else
forwards;
getForwards = deviceName:
if portsByRemote ? ${deviceName} then
validateForwards deviceName portsByRemote.${deviceName}
else
throw "Missing mapped forwards for auto_ssh device '${deviceName}' in intermediate/remote.nix portsByRemote.";
enabledAutoSshDevices =
lib.filterAttrs (deviceName: device:
let
_ =
if autoSshValidation.isSafeName deviceName then
null
else
throw "Auto SSH device name '${deviceName}' is not safe for Linux user/systemd names. Use lowercase letters, numbers, '_' or '-' and start with a letter or '_'.";
autoSsh = getAutoSsh deviceName device;
in
if !(autoSsh ? enable) then
throw "Auto SSH device '${deviceName}' is missing required field: auto_ssh.enable."
else if !builtins.isBool autoSsh.enable then
throw "Auto SSH device '${deviceName}' field auto_ssh.enable must be a bool."
else
autoSsh.enable
) autoSshDevices;
getField = deviceName: autoSsh: fieldName: typeCheck: typeName:
if !(autoSsh ? ${fieldName}) then
throw "Auto SSH device '${deviceName}' is missing required field: auto_ssh.${fieldName}."
else if !typeCheck autoSsh.${fieldName} then
throw "Auto SSH device '${deviceName}' field auto_ssh.${fieldName} must be ${typeName}."
else
autoSsh.${fieldName};
mkForwardString = forwards:
builtins.concatStringsSep " " (map (forward:
"-R ${toString forward.remote}:${forward.localAddress}:${toString forward.localPort}"
) forwards);
mkService = deviceName: device:
let
autoSsh = getAutoSsh deviceName device;
sshHost =
if device ? ip && builtins.isString device.ip then
device.ip
else
throw "Auto SSH device '${deviceName}' must define ip as string.";
sshPort = getField deviceName autoSsh "sshPort" builtins.isInt "an int";
sshUser = getField deviceName autoSsh "sshUser" builtins.isString "a string";
sshKeyPath = getField deviceName autoSsh "key" (value: builtins.isString value || builtins.isPath value) "a string or path";
trustedHostsFile = getField deviceName autoSsh "known_hosts" (value: builtins.isString value || builtins.isPath value) "a string or path";
forwards = getForwards deviceName;
_ = if forwards == [] then throw "Auto SSH device '${deviceName}' has no mapped forwards. Add matching endpoints or disable this remote." else null;
forwardString = mkForwardString forwards;
serviceName = "autossh-${deviceName}";
in {
name = serviceName;
value = {
description = "AutoSSH reverse tunnel for ${deviceName}";
after = [ "network.target" "network-online.target" ];
wants = [ "network-online.target" ];
wantedBy = [ "multi-user.target" ];
serviceConfig = {
Type = "simple";
User = serviceName;
Restart = "always";
RestartSec = 10;
ExecStart = ''
${pkgs.autossh}/bin/autossh \
-N \
-T \
-M 0 \
-o ServerAliveInterval=10 \
-o ExitOnForwardFailure=yes \
-o UserKnownHostsFile=${trustedHostsFile} \
${forwardString} \
-i ${sshKeyPath} \
-p ${toString sshPort} \
${sshUser}@${sshHost}
'';
};
};
};
generatedServices = builtins.listToAttrs (lib.mapAttrsToList mkService enabledAutoSshDevices);
generatedUsers = builtins.listToAttrs (lib.mapAttrsToList (deviceName: _: {
name = "autossh-${deviceName}";
value = {
isSystemUser = true;
group = "autossh-${deviceName}";
extraGroups = [ "keys" ];
description = "AutoSSH user for ${deviceName}";
};
}) enabledAutoSshDevices);
generatedGroups = builtins.listToAttrs (lib.mapAttrsToList (deviceName: _: {
name = "autossh-${deviceName}";
value = {};
}) enabledAutoSshDevices);
in in
{ {
environment.systemPackages = with pkgs; [ environment.systemPackages = with pkgs; [
autossh autossh
moreutils
]; ];
systemd.services.autossh-tunnel = { users.users = generatedUsers;
description = "Autossh Reverse SSH Tunnel"; users.groups = generatedGroups;
after = [ "network.target" "network-online.target" ]; systemd.services = generatedServices;
wants = [ "network-online.target" ]; }
serviceConfig = {
Type = "simple";
User = "autossh-tunnel";
Restart = "always";
RestartSec = 10;
ExecStart = ''
${pkgs.autossh}/bin/autossh \
-N \
-T \
-M 0 \
-o ServerAliveInterval=10 \
-o ExitOnForwardFailure=yes \
-o UserKnownHostsFile=${trustedHostsFile} \
${forwardString} \
-i ${sshKeyPath} \
-p ${toString sshPort} \
${sshUser}@${sshHost}
'';
};
wantedBy = [ "multi-user.target" ];
};
}

View file

@ -1,20 +1,29 @@
{ config, pkgs, ... }: { config, pkgs, ... }:
let let
net = import ../data/network.nix; net = import ../config/network.nix;
serv = import ../data/services.nix; serv = import ../config/services.nix;
serviceValidation = import ../validation/service/continuwuity.nix;
serverName =
if net ? devices && builtins.isAttrs net.devices && net.devices ? remote_proxy && net.devices.remote_proxy ? domain && builtins.isString net.devices.remote_proxy.domain then
net.devices.remote_proxy.domain
else
throw "config/network.nix must define devices.remote_proxy.domain as string for continuwuity.";
trustedServers = serviceValidation.getTrustedServers serv;
in in
{ {
services.matrix-continuwuity = { services.matrix-continuwuity = {
enable = true; enable = true;
settings = { settings = {
global = { global = {
server_name = net.services.continuwuity.domainOverride; server_name = serverName;
allow_registration = true; allow_registration = true;
allow_encryption = true; allow_encryption = true;
allow_federation = true; allow_federation = true;
max_request_size = 20 * 1024 * 1024; # 20 MiB max_request_size = 20 * 1024 * 1024; # 20 MiB
trusted_servers = serv.matrix.trusted_servers; trusted_servers = trustedServers;
}; };
}; };
}; };

View file

@ -1,7 +1,20 @@
{ config, pkgs, ... }: { config, pkgs, ... }:
let let
net = import ../data/network.nix; net = import ../config/network.nix;
dhcpModel = import ../intermediate/dhcp.nix;
routerIp =
if net ? devices && builtins.isAttrs net.devices && net.devices ? router && net.devices.router ? ip && builtins.isString net.devices.router.ip then
net.devices.router.ip
else
throw "config/network.nix must define devices.router.ip as string.";
dnsServerIp =
if net ? devices && builtins.isAttrs net.devices && net.devices ? self && net.devices.self ? ip && builtins.isString net.devices.self.ip then
net.devices.self.ip
else
throw "config/network.nix must define devices.self.ip as string.";
in in
{ {
services.kea.dhcp4 = { services.kea.dhcp4 = {
@ -23,11 +36,11 @@ in
option-data = [ option-data = [
{ {
name = "routers"; name = "routers";
data = net.ips.router; data = routerIp;
} }
{ {
name = "domain-name-servers"; name = "domain-name-servers";
data = builtins.concatStringsSep ", " ([net.ips.pi] ++ net.fallback_dns_servers); data = builtins.concatStringsSep ", " ([dnsServerIp] ++ net.fallback_dns_servers);
} }
{ {
name = "domain-name"; name = "domain-name";
@ -39,7 +52,7 @@ in
} }
]; ];
reservations = net.dhcp.reservations; reservations = dhcpModel.reservations;
}]; }];
valid-lifetime = net.dhcp.default_lease; valid-lifetime = net.dhcp.default_lease;

View file

@ -1,9 +1,14 @@
{ config, pkgs, lib, ... }: { config, pkgs, lib, ... }:
let let
service_data = import ../data/services.nix; serviceValidation = import ../validation/service/kiwix.nix;
kiwix = service_data.kiwix; service_data = import ../config/services.nix;
kiwix = serviceValidation.getKiwix service_data;
rootDir = kiwix.root_dir;
zimUrls = kiwix.urls; zimUrls = kiwix.urls;
updater = pkgs.writeShellScriptBin "kiwix-updater" '' updater = pkgs.writeShellScriptBin "kiwix-updater" ''
set -e set -e
@ -14,13 +19,13 @@ let
download() { download() {
local url=''$1 local url=''$1
local filename=''$(basename "''$url") local filename=''$(basename "''$url")
local filepath="${kiwix.root_dir}"/''$filename local filepath="${rootDir}"/''$filename
if [ -f "''$filepath" ]; then if [ -f "''$filepath" ]; then
echo "''$filepath exists!" echo "''$filepath exists!"
return 0 return 0
fi fi
cd ${kiwix.root_dir} cd ${rootDir}
${pkgs.wget}/bin/wget --continue --quiet "''$url" -O "''$filename.tmp" ${pkgs.wget}/bin/wget --continue --quiet "''$url" -O "''$filename.tmp"
mv ''$filename.tmp ''$filename mv ''$filename.tmp ''$filename
} }
@ -29,7 +34,7 @@ let
{ {
echo '<?xml version="1.0" encoding="UTF-8"?>' echo '<?xml version="1.0" encoding="UTF-8"?>'
echo '<library>' echo '<library>'
for zim in "${kiwix.root_dir}"/*.zim; do for zim in "${rootDir}"/*.zim; do
if [ -f "''$zim" ]; then if [ -f "''$zim" ]; then
filename=''$(basename "''$zim") filename=''$(basename "''$zim")
size=''$(stat -c%s "''$zim") size=''$(stat -c%s "''$zim")
@ -58,8 +63,8 @@ EOF
fi fi
done done
echo '</library>' echo '</library>'
} > "${kiwix.root_dir}/library.xml.tmp" } > "${rootDir}/library.xml.tmp"
mv "${kiwix.root_dir}/library.xml.tmp" "${kiwix.root_dir}/library.xml" mv "${rootDir}/library.xml.tmp" "${rootDir}/library.xml"
} }
for url in "''${URLS[@]}"; do for url in "''${URLS[@]}"; do

View file

@ -1,19 +1,23 @@
{ config, pkgs, lib, ... }: { config, pkgs, lib, ... }:
let let
net = import ../data/network.nix; serviceValidation = import ../validation/service/kiwix.nix;
service_data = import ../data/services.nix; service_data = import ../config/services.nix;
kiwix = service_data.kiwix; kiwix = serviceValidation.getKiwix service_data;
rootDir = kiwix.root_dir;
webPort = kiwix.port;
in { in {
systemd.tmpfiles.rules = [ systemd.tmpfiles.rules = [
"d ${kiwix.root_dir} 0755 root root - -" "d ${rootDir} 0755 root root - -"
"d ${kiwix.root_dir}/data 0755 root root - -" "d ${rootDir}/data 0755 root root - -"
]; ];
virtualisation.oci-containers.containers = { virtualisation.oci-containers.containers = {
kiwix-serve = { kiwix-serve = {
image = "ghcr.io/kiwix/kiwix-serve:3.8.2"; image = "ghcr.io/kiwix/kiwix-serve:3.8.2";
ports = ["8086:8080"]; ports = ["${toString webPort}:8080"];
volumes = ["${kiwix.root_dir}/:/data:ro"]; volumes = ["${rootDir}/:/data:ro"];
cmd = [ cmd = [
"--monitorLibrary" "--monitorLibrary"
"--library" "/data/library.xml" "--library" "/data/library.xml"
@ -31,6 +35,6 @@ in {
}; };
networking.firewall = { networking.firewall = {
allowedTCPPorts = [8086]; allowedTCPPorts = [ webPort ];
}; };
} }

View file

@ -1,92 +1,36 @@
{ config, pkgs, lib, ... }: { config, pkgs, lib, ... }:
let let
network = import ../data/network.nix; nginxModel = import ../intermediate/nginx.nix;
web = import ../data/web.nix;
virtualHostFn = name: service: let
domain = if service ? domainOverride
then service.domainOverride
else "${name}.${network.local_domain}";
locationList = if service.reverse_proxy ? endpoints
then service.reverse_proxy.endpoints
else ["/"];
locationsData = builtins.listToAttrs (map (endpointName: {
name = endpointName;
value = {
proxyPass = "http://127.0.0.1:${builtins.toString service.reverse_proxy.port}";
proxyWebsockets = true;
};
}) locationList);
serverAlias = lib.optionalAttrs (service.reverse_proxy ? aliases) {
serverAliases = map (alias: "${alias}.${domain}") service.reverse_proxy.aliases;
};
myExtraConfig = if service.reverse_proxy ? extraConfig
then service.reverse_proxy.extraConfig
else {};
listenConfig = if service.reverse_proxy ? listen
then service.reverse_proxy.listen
else if service.reverse_proxy ? ssl && service.reverse_proxy.ssl
then [ {port = 80;} {port = 443; ssl=true;} ]
else [ {port = 80;} ];
sslConfig = if service.reverse_proxy ? ssl && service.reverse_proxy.ssl
then {
enableACME = true;
forceSSL = true;
}
else {};
externConnections = if service.reverse_proxy ? allowExternConnections && service.reverse_proxy.allowExternConnections
then {
extraConfig = ''
allow all;
'';
}
else {};
in
{
serverName = "${domain}";
listen = map (obj: ({
addr = if obj ? addr then obj.addr else "0.0.0.0";
port = obj.port;
} // (if obj ? ssl then {ssl = obj.ssl;} else {}))) listenConfig;
locations = locationsData;
extraConfig = ''
allow ${network.network.subnet};
deny all;
'';
} // serverAlias // sslConfig // externConnections // myExtraConfig;
rproxyServices = builtins.mapAttrs (virtualHostFn) network.reverse_proxy;
serviceNamesMessage = builtins.toString (builtins.attrNames network.reverse_proxy);
webHosts = lib.mapAttrs' (name: description: {
name = "${name}.web";
value = {
serverName = "${name}";
listen = [ {addr = "0.0.0.0"; port = 80;} {addr = "0.0.0.0"; port = 443; ssl = true;}];
locations = lib.mapAttrs' (endpointName: endpointValue: {
name = endpointName;
value = {
extraConfig = ''
default_type ${endpointValue.contentType};
return ${toString endpointValue.status} "${endpointValue.content}";
'';
};
}) description;
};
}) web;
fallback = {
serverName = "_";
listen = [ {addr = "0.0.0.0"; port = 80;}];
locations."/" = {
return = "404";
extraConfig = ''
add_header Content-Type text/plain;
'';
};
extraConfig = '' virtualHostsData = nginxModel.virtualHostsData;
return 404 "This domain is not configured. Available services: ${serviceNamesMessage}"; validatedEndpoints = nginxModel.validatedEndpoints;
''; tlsEndpoints = lib.filter (endpoint: endpoint.force_ssl) validatedEndpoints;
}; localTlsEndpoints = lib.filter (endpoint: endpoint.force_ssl && endpoint.exposure == "local") validatedEndpoints;
virtualHosts' = builtins.attrValues (rproxyServices // webHosts // {fallback = fallback;}); localTlsDomains = lib.unique (map (endpoint: endpoint.domain) localTlsEndpoints);
acmeEmailConfigured =
config.security.acme ? defaults
&& builtins.isAttrs config.security.acme.defaults
&& config.security.acme.defaults ? email
&& builtins.isString config.security.acme.defaults.email
&& config.security.acme.defaults.email != "";
virtualHosts' = virtualHostsData;
in { in {
assertions = [
{
assertion = validatedEndpoints != [];
message = "No endpoints configured. config/endpoints.nix must contain at least one endpoint.";
}
{
assertion = localTlsEndpoints == [];
message = "ACME-managed TLS is only supported for external domains. Local domains with force_ssl=true are not allowed: ${builtins.concatStringsSep ", " localTlsDomains}";
}
{
assertion = tlsEndpoints == [] || acmeEmailConfigured;
message = "TLS endpoints exist, but security.acme.defaults.email is missing or empty.";
}
];
services.nginx = { services.nginx = {
enable = true; enable = true;
@ -98,7 +42,7 @@ in {
virtualHosts = virtualHosts'; virtualHosts = virtualHosts';
}; };
networking.firewall.allowedTCPPorts = network.usedPorts; networking.firewall.allowedTCPPorts = nginxModel.nginxUsedPorts;
security.acme = { security.acme = {
acceptTerms = true; acceptTerms = true;

View file

@ -1,17 +1,15 @@
#{ config, pkgs, lib, ... }: { ... }:
let let
ssh_data = import ../data/ssh.nix; opensshConfig = import ../config/openssh.nix;
usersWithKeys = opensshConfig.ssh_users;
in { in {
services.openssh = { services.openssh = {
enable = true; enable = true;
settings = { settings = {
PasswordAuthentication = true; PasswordAuthentication = true;
PermitRootLogin = "no"; PermitRootLogin = "no";
AllowUsers = ssh_data.ssh_users; AllowUsers = usersWithKeys;
}; };
}; };
users.users = builtins.mapAttrs (username: value: {
openssh.authorizedKeys.keys = ssh_data.keys.${username};
}) ssh_data.keys;
} }

View file

@ -1,14 +1,21 @@
{ config, pkgs, lib, ... }: { config, pkgs, lib, ... }:
let let
net = import ../data/network.nix; serviceValidation = import ../validation/service/qbittorrent.nix;
serviceData = import ../data/services.nix; serviceData = import ../config/services.nix;
qbt = serviceData.qbittorrent; qbt = serviceValidation.getQbittorrent serviceData;
webPort = qbt.port;
rootDir = qbt.root_dir;
vpnUserPath = qbt.vpn.username_file;
vpnPasswordPath = qbt.vpn.password_file;
in { in {
systemd.tmpfiles.rules = [ systemd.tmpfiles.rules = [
"d ${qbt.root_dir} 0755 root root - -" "d ${rootDir} 0755 root root - -"
"d ${qbt.root_dir}/gluetun 0755 root root - -" "d ${rootDir}/gluetun 0755 root root - -"
"d ${qbt.root_dir}/downloads 0755 root root - -" "d ${rootDir}/downloads 0755 root root - -"
"d ${qbt.root_dir}/config 0755 root root - -" "d ${rootDir}/config 0755 root root - -"
]; ];
environment.etc."qbittorrent-compose/docker-compose.yml" = { environment.etc."qbittorrent-compose/docker-compose.yml" = {
@ -22,18 +29,18 @@ services:
- NET_ADMIN - NET_ADMIN
network_mode: bridge network_mode: bridge
ports: ports:
- 127.0.0.1:8085:8085 # qBittorrent - 127.0.0.1:${toString webPort}:${toString webPort} # qBittorrent
devices: devices:
- /dev/net/tun:/dev/net/tun - /dev/net/tun:/dev/net/tun
volumes: volumes:
- ${qbt.root_dir}/gluetun/:/gluetun - ${rootDir}/gluetun/:/gluetun
environment: environment:
- VPN_SERVICE_PROVIDER=protonvpn - VPN_SERVICE_PROVIDER=protonvpn
- SERVER_HOSTNAME=node-nl-28.protonvpn.net,node-ch-06.protonvpn.net,node-nl-13.protonvpn.net,node-ch-06.protonvpn.net,node-es-04.protonvpn.net - SERVER_HOSTNAME=node-nl-28.protonvpn.net,node-ch-06.protonvpn.net,node-nl-13.protonvpn.net,node-ch-06.protonvpn.net,node-es-04.protonvpn.net
- UPDATER_PERIOD=24h - UPDATER_PERIOD=24h
- OPENVPN_USER=${qbt.vpn.username} - OPENVPN_USER=$${OPENVPN_USER:-DUMMY_NOT_USED}
- OPENVPN_PASSWORD=${qbt.vpn.password} - OPENVPN_PASSWORD=$${OPENVPN_PASSWORD:-DUMMY_NOT_USED}
- DOT_PROVIDERS=cloudflare,google - DOT_PROVIDERS=cloudflare,google
- BLOCK_ADS=off - BLOCK_ADS=off
@ -50,10 +57,10 @@ services:
- PUID=1000 - PUID=1000
- PGID=1000 - PGID=1000
- TZ=Europe/Berlin - TZ=Europe/Berlin
- WEBUI_PORT=8085 - WEBUI_PORT=${toString webPort}
volumes: volumes:
- ${qbt.root_dir}/config/:/config - ${rootDir}/config/:/config
- ${qbt.root_dir}/downloads/:/downloads - ${rootDir}/downloads/:/downloads
''; '';
}; };
systemd.services.qbittorrent-stack = { systemd.services.qbittorrent-stack = {
@ -64,20 +71,27 @@ services:
serviceConfig = { serviceConfig = {
Type = "oneshot"; Type = "oneshot";
RemainAfterExit = true; RemainAfterExit = true;
WorkingDirectory = qbt.root_dir; WorkingDirectory = rootDir;
ExecStart = "${pkgs.writeShellScript "torrent-start" '' ExecStart = "${pkgs.writeShellScript "torrent-start" ''
set -e set -eu
export OPENVPN_USER="$(${pkgs.coreutils}/bin/cat ${vpnUserPath} | ${pkgs.coreutils}/bin/tr -d '\r\n')"
export OPENVPN_PASSWORD="$(${pkgs.coreutils}/bin/cat ${vpnPasswordPath} | ${pkgs.coreutils}/bin/tr -d '\r\n')"
# Copy compose file to working directory # Copy compose file to working directory
cp /etc/qbittorrent-compose/docker-compose.yml ${qbt.root_dir}/ cp /etc/qbittorrent-compose/docker-compose.yml ${rootDir}/
cd ${qbt.root_dir} cd ${rootDir}
${pkgs.docker-compose}/bin/docker-compose up -d ${pkgs.docker-compose}/bin/docker-compose up -d
''}"; ''}";
ExecStop = "${pkgs.writeShellScript "torrent-stop" '' ExecStop = "${pkgs.writeShellScript "torrent-stop" ''
cd ${qbt.root_dir} cd ${rootDir}
${pkgs.docker-compose}/bin/docker-compose down ${pkgs.docker-compose}/bin/docker-compose down
''}"; ''}";
ExecReload = "${pkgs.writeShellScript "torrent-reload" '' ExecReload = "${pkgs.writeShellScript "torrent-reload" ''
cd ${qbt.root_dir} set -eu
export OPENVPN_USER="$(${pkgs.coreutils}/bin/cat ${vpnUserPath} | ${pkgs.coreutils}/bin/tr -d '\r\n')"
export OPENVPN_PASSWORD="$(${pkgs.coreutils}/bin/cat ${vpnPasswordPath} | ${pkgs.coreutils}/bin/tr -d '\r\n')"
cd ${rootDir}
${pkgs.docker-compose}/bin/docker-compose restart ${pkgs.docker-compose}/bin/docker-compose restart
''}"; ''}";
@ -87,6 +101,6 @@ services:
}; };
networking.firewall = { networking.firewall = {
allowedTCPPorts = [8085]; allowedTCPPorts = [ webPort ];
}; };
} }

View file

@ -1,7 +1,8 @@
{ config, pkgs, ... }: { config, pkgs, ... }:
let let
net = import ../data/network.nix; net = import ../config/network.nix;
dnsModel = import ../intermediate/dns.nix;
in in
{ {
services.unbound = { services.unbound = {
@ -16,9 +17,9 @@ in
local-zone = "\"${net.local_domain}.\" static"; local-zone = "\"${net.local_domain}.\" static";
local-data = local-data =
(map (name: (map (name:
let ip = net.dnsMappings.${name}; in let ip = dnsModel.dnsMappings.${name}; in
"\"${name}. IN A ${ip}\"" "\"${name}. IN A ${ip}\""
) (builtins.attrNames net.dnsMappings)); ) (builtins.attrNames dnsModel.dnsMappings));
}; };
forward-zone = { forward-zone = {

9
system/default.nix Normal file
View file

@ -0,0 +1,9 @@
{ config, pkgs, ... }:
{
imports = [
./sops.nix
./static-ip.nix
./users.nix
];
}

14
system/sops.nix Normal file
View file

@ -0,0 +1,14 @@
{ lib, ... }:
let
secretData = import ../intermediate/secrets.nix;
in
{
sops = {
age.keyFile = "/var/lib/sops-nix/key.txt";
secrets = secretData.byName;
};
warnings = lib.optional (secretData.missing != [])
"Some SOPS source files are missing or not yet encrypted; no runtime secrets will be provisioned for: ${builtins.concatStringsSep ", " (map (item: builtins.concatStringsSep "_" item.path) secretData.missing)}";
}

View file

@ -1,15 +1,15 @@
{ config, pkgs, ... }: { config, pkgs, ... }:
let let
net = import ../data/network.nix; net = import ../config/network.nix;
in in
{ {
networking.interfaces.eth0.ipv4.addresses = [{ networking.interfaces.eth0.ipv4.addresses = [{
address = net.ips.pi; address = net.devices.self.ip;
prefixLength = net.network.cidr; prefixLength = net.network.cidr;
}]; }];
networking.defaultGateway = net.ips.router; networking.defaultGateway = net.network.gateway;
networking.nameservers = net.fallback_dns_servers; networking.nameservers = net.fallback_dns_servers;
} }

8
system/users.nix Normal file
View file

@ -0,0 +1,8 @@
{ config, pkgs, lib, ... }:
{
users.users.nudelerde = {
isNormalUser = true;
extraGroups = ["wheel" "docker"];
hashedPassword = "$y$j9T$NiaiVxQKs0C1V4VdCFKBO.$P6RfBDTyJfPJJzKyHf9PJEy9Ku5M6AU57U98nVD6wP6";
};
}

28
validation/README.md Normal file
View file

@ -0,0 +1,28 @@
# Validation Layer
This folder contains **shape/type validation only**.
## Rule of Responsibility
- `validation/*`: syntax checks, required fields, allowed keys, and value types.
- `intermediate/*` and service modules: semantic checks (contradictions/conflicts/business rules).
Examples:
- Shape/type (validation): endpoint has `content` attrset, `port` is int, unknown keys are rejected.
- Semantic (kept outside): `force_ssl = true` with `port = 80`, duplicate routes on same host key, incompatible TLS groupings.
## Files
- `validation/endpoints.nix`
: Validates endpoint schema and content schema for `proxy` and `web`.
- `validation/auto_ssh.nix`
: Validates `devices`/`auto_ssh` shapes and remote port map structure.
- `validation/network_devices.nix`
: Validates local device shapes and DHCP reservation field shapes used by intermediate DHCP/DNS models.
- `validation/storage.nix`
: Validates storage config entry shapes consumed by intermediate storage derivation.
- `validation/secrets.nix`
: Validates keystore entry/reference shapes consumed by config modules before service migration.
- `validation/service/*`
: Validates service-backed config shapes consumed by service modules, such as `kiwix`, `qbittorrent`, and `continuwuity`.
## Usage Pattern
Import validators and run them first, then apply semantic checks locally.

61
validation/auto_ssh.nix Normal file
View file

@ -0,0 +1,61 @@
let
lib = import <nixpkgs/lib>;
getDevices = net:
if net ? devices && builtins.isAttrs net.devices then
net.devices
else
throw "config/network.nix must define devices as an attrset.";
getAutoSshDevices = devices:
lib.filterAttrs (_: device:
if !builtins.isAttrs device then
throw "Every device in config/network.nix.devices must be an attrset."
else if !(device ? type) then
throw "Every device in config/network.nix.devices must define a type."
else
device.type == "auto_ssh"
) devices;
getAutoSshDomains = autoSshDevices:
map (device:
if !(device ? domain) || !builtins.isString device.domain || device.domain == "" then
throw "Every auto_ssh device in config/network.nix must define domain as a non-empty string."
else
device.domain
) (builtins.attrValues autoSshDevices);
getAutoSshConfig = deviceName: device:
if !(device ? auto_ssh) then
throw "Auto SSH device '${deviceName}' is missing required field: auto_ssh."
else if !builtins.isAttrs device.auto_ssh then
throw "Auto SSH device '${deviceName}' field auto_ssh must be an attrset."
else
device.auto_ssh;
getRemotePortMap = device:
if !(device ? auto_ssh) then
[]
else if !builtins.isAttrs device.auto_ssh then
throw "Device auto_ssh must be an attrset when present."
else if !(device.auto_ssh ? remotePortMap) then
[]
else if !builtins.isList device.auto_ssh.remotePortMap then
throw "Device auto_ssh.remotePortMap must be a list of { localPort = int; remotePort = int; }."
else if !lib.all (entry:
builtins.isAttrs entry
&& entry ? localPort
&& entry ? remotePort
&& builtins.isInt entry.localPort
&& builtins.isInt entry.remotePort
) device.auto_ssh.remotePortMap then
throw "Every remotePortMap entry must be { localPort = int; remotePort = int; }."
else
device.auto_ssh.remotePortMap;
isSafeName = name:
builtins.match "^[a-z_][a-z0-9_-]*$" name != null;
in
{
inherit getDevices getAutoSshDevices getAutoSshDomains getAutoSshConfig getRemotePortMap isSafeName;
}

141
validation/endpoints.nix Normal file
View file

@ -0,0 +1,141 @@
let
lib = import <nixpkgs/lib>;
allowedEndpointKeys = [ "type" "domain" "endpoint" "port" "force_ssl" "content" ];
allowedProxyContentKeys = [ "type" "ip" "port" "proxyWebsockets" ];
allowedWebContentKeys = [ "type" "files" ];
allowedWebFileKeys = [ "path" "filePath" "contentType" "status" ];
ensureNoUnknownKeys = context: obj: allowedKeys:
let
unknown = builtins.filter (key: !(lib.elem key allowedKeys)) (builtins.attrNames obj);
in
if unknown != [] then
throw "${context} contains unknown keys: ${builtins.concatStringsSep ", " unknown}"
else
obj;
ensurePath = value:
builtins.isString value
&& builtins.substring 0 1 value == "/";
ensurePort = value:
builtins.isInt value && value > 0 && value <= 65535;
validateEndpointShape = index: endpoint:
let
_ = if !builtins.isAttrs endpoint then throw "Endpoint at index ${toString index} must be an attrset." else null;
__ = ensureNoUnknownKeys "Endpoint at index ${toString index}" endpoint allowedEndpointKeys;
___ =
if endpoint ? forceSsl then
throw "Endpoint at index ${toString index} uses forceSsl. Use force_ssl instead."
else
null;
typeValue =
if endpoint ? type && builtins.isString endpoint.type then
endpoint.type
else
throw "Endpoint at index ${toString index} must define type as a string.";
_type =
if lib.elem typeValue [ "proxy" "web" ] then
null
else
throw "Endpoint at index ${toString index} type must be 'proxy' or 'web'.";
_domain =
if endpoint ? domain && builtins.isString endpoint.domain && endpoint.domain != "" then
null
else
throw "Endpoint at index ${toString index} must define a non-empty domain string.";
_endpoint =
if endpoint ? endpoint && ensurePath endpoint.endpoint then
null
else
throw "Endpoint at index ${toString index} must define endpoint as a path starting with '/'.";
_port =
if endpoint ? port && ensurePort endpoint.port then
null
else
throw "Endpoint at index ${toString index} must define port as int in range 1..65535.";
_forceSsl =
if endpoint ? force_ssl && builtins.isBool endpoint.force_ssl then
null
else
throw "Endpoint at index ${toString index} must define force_ssl as a bool.";
contentValue =
if endpoint ? content && builtins.isAttrs endpoint.content then
endpoint.content
else
throw "Endpoint at index ${toString index} must define content as an attrset.";
_content =
if typeValue == "proxy" then
let
____ = ensureNoUnknownKeys "Proxy content at endpoint index ${toString index}" contentValue allowedProxyContentKeys;
in
if !(contentValue ? type) || !builtins.isString contentValue.type then
throw "Proxy endpoint at index ${toString index} must define content.type as a string."
else if !(contentValue ? ip) || !builtins.isString contentValue.ip || contentValue.ip == "" then
throw "Proxy endpoint at index ${toString index} must define content.ip as a non-empty string."
else if !(contentValue ? port) || !ensurePort contentValue.port then
throw "Proxy endpoint at index ${toString index} must define content.port as int in range 1..65535."
else if !(contentValue ? proxyWebsockets) || !builtins.isBool contentValue.proxyWebsockets then
throw "Proxy endpoint at index ${toString index} must define content.proxyWebsockets as a bool."
else
null
else
let
____ = ensureNoUnknownKeys "Web content at endpoint index ${toString index}" contentValue allowedWebContentKeys;
filesValue =
if contentValue ? files && builtins.isList contentValue.files then
contentValue.files
else
throw "Web endpoint at index ${toString index} must define content.files as a list.";
_____ =
if contentValue ? type && contentValue.type == "store" then
null
else
throw "Web endpoint at index ${toString index} must define content.type = 'store'.";
______ = lib.imap0 (fileIndex: file:
let
_______ =
if builtins.isAttrs file then
null
else
throw "Web endpoint at index ${toString index} file at position ${toString fileIndex} must be an attrset.";
________ = ensureNoUnknownKeys "Web endpoint at index ${toString index} file at position ${toString fileIndex}" file allowedWebFileKeys;
_________ =
if file ? path && builtins.isString file.path && file.path != "" && builtins.substring 0 1 file.path != "/" then
null
else
throw "Web endpoint at index ${toString index} file at position ${toString fileIndex} must define path as a relative path string.";
__________ =
if file ? filePath && (builtins.isString file.filePath || builtins.isPath file.filePath) then
null
else
throw "Web endpoint at index ${toString index} file at position ${toString fileIndex} must define filePath as string or path.";
___________ =
if file ? contentType && builtins.isString file.contentType && file.contentType != "" then
null
else
throw "Web endpoint at index ${toString index} file at position ${toString fileIndex} must define contentType as non-empty string.";
____________ =
if file ? status && builtins.isInt file.status then
null
else
throw "Web endpoint at index ${toString index} file at position ${toString fileIndex} must define status as int.";
in
file
) filesValue;
in
null;
in
endpoint;
validateEndpointsShape = endpoints:
if !builtins.isList endpoints then
throw "config/endpoints.nix must evaluate to a list of endpoint attrsets."
else
lib.imap0 validateEndpointShape endpoints;
in
{
inherit validateEndpointShape validateEndpointsShape;
}

View file

@ -0,0 +1,40 @@
let
lib = import <nixpkgs/lib>;
getDevices = net:
if net ? devices && builtins.isAttrs net.devices then
net.devices
else
throw "config/network.nix must define devices as an attrset.";
getLocalDevices = devices:
lib.filterAttrs (deviceName: device:
if !builtins.isAttrs device then
throw "Device '${deviceName}' must be an attrset."
else if !(device ? type) || !builtins.isString device.type then
throw "Device '${deviceName}' must define type as a string."
else if !(device ? ip) || !builtins.isString device.ip || device.ip == "" then
throw "Device '${deviceName}' must define ip as a non-empty string."
else
device.type == "local"
) devices;
validateReservationShape = deviceName: reservation:
let
_ = if !builtins.isAttrs reservation then throw "Device '${deviceName}' reservation must be an attrset." else null;
_hw =
if reservation ? hw_address && builtins.isString reservation.hw_address && reservation.hw_address != "" then
null
else
throw "Device '${deviceName}' reservation must define hw_address as a non-empty string.";
_host =
if reservation ? hostname && builtins.isString reservation.hostname && reservation.hostname != "" then
null
else
throw "Device '${deviceName}' reservation must define hostname as a non-empty string.";
in
reservation;
in
{
inherit getDevices getLocalDevices validateReservationShape;
}

71
validation/secrets.nix Normal file
View file

@ -0,0 +1,71 @@
let
lib = import <nixpkgs/lib>;
helperNames = [ "getRuntimePath" "getSopsKey" ];
validateLeaf = context: leaf:
if builtins.isString leaf || builtins.isPath leaf then
# String or path: file path (binary or single-value secret)
leaf
else if builtins.isAttrs leaf then
# Object: must have 'file', may have 'keys' list and optional deployment metadata
let
_ =
if leaf ? file && (builtins.isPath leaf.file || builtins.isString leaf.file) then
null
else
throw "${context}.file must be a path or string.";
__ =
if !(leaf ? keys) || (builtins.isList leaf.keys && builtins.all builtins.isString leaf.keys) then
null
else
throw "${context}.keys must be a list of strings when provided.";
___ =
if !(leaf ? path) || builtins.isString leaf.path then
null
else
throw "${context}.path must be a string when provided.";
____ =
if !(leaf ? owner) || builtins.isString leaf.owner then
null
else
throw "${context}.owner must be a string when provided.";
_____ =
if !(leaf ? group) || builtins.isString leaf.group then
null
else
throw "${context}.group must be a string when provided.";
______ =
if !(leaf ? mode) || builtins.isString leaf.mode then
null
else
throw "${context}.mode must be a string when provided.";
in
leaf
else
throw "${context} must be a string, path, or an attrset with 'file' key.";
validateTree = context: node:
if builtins.isAttrs node then
lib.mapAttrs (name: value:
if lib.elem name helperNames then
value
else
validateTree "${context}.${name}" value
) node
else
throw "${context} must be an attrset at non-leaf level.";
getSecretsConfig = secretsConfig:
let
_ =
if builtins.isAttrs secretsConfig then
null
else
throw "config/secrets.nix must evaluate to an attrset.";
__ = validateTree "config/secrets.nix" secretsConfig;
in
secretsConfig;
in
rec {
inherit getSecretsConfig validateLeaf validateTree;
}

View file

@ -0,0 +1,34 @@
let
ensureAttrset = context: value:
if builtins.isAttrs value then
value
else
throw "${context} must be an attrset.";
ensureString = context: value:
if builtins.isString value then
value
else
throw "${context} must be a string.";
ensureNonEmptyString = context: value:
if builtins.isString value && value != "" then
value
else
throw "${context} must be a non-empty string.";
ensureInt = context: value:
if builtins.isInt value then
value
else
throw "${context} must be an int.";
ensureList = context: value:
if builtins.isList value then
value
else
throw "${context} must be a list.";
in
rec {
inherit ensureAttrset ensureString ensureNonEmptyString ensureInt ensureList;
}

View file

@ -0,0 +1,20 @@
let
common = import ./common.nix;
in
rec {
getTrustedServers = serviceData:
let
matrix =
if serviceData ? matrix then
common.ensureAttrset "config/services.nix matrix" serviceData.matrix
else
throw "config/services.nix must define matrix attrset.";
trustedServers =
if matrix ? trusted_servers then
common.ensureList "config/services.nix matrix.trusted_servers" matrix.trusted_servers
else
throw "config/services.nix matrix.trusted_servers must exist.";
in
trustedServers;
}

View file

@ -0,0 +1,36 @@
let
common = import ./common.nix;
in
rec {
getKiwix = serviceData:
let
kiwix =
if serviceData ? kiwix then
common.ensureAttrset "config/services.nix kiwix" serviceData.kiwix
else
throw "config/services.nix must define kiwix attrset.";
rootDir =
if kiwix ? root_dir then
common.ensureNonEmptyString "config/services.nix kiwix.root_dir" kiwix.root_dir
else
throw "config/services.nix kiwix.root_dir must exist.";
webPort =
if kiwix ? port then
common.ensureInt "config/services.nix kiwix.port" kiwix.port
else
throw "config/services.nix kiwix.port must exist.";
zimUrls =
if kiwix ? urls then
common.ensureList "config/services.nix kiwix.urls" kiwix.urls
else
throw "config/services.nix kiwix.urls must exist.";
in
{
root_dir = rootDir;
port = webPort;
urls = zimUrls;
};
}

View file

@ -0,0 +1,51 @@
let
common = import ./common.nix;
in
rec {
getQbittorrent = serviceData:
let
qbt =
if serviceData ? qbittorrent then
common.ensureAttrset "config/services.nix qbittorrent" serviceData.qbittorrent
else
throw "config/services.nix must define qbittorrent attrset.";
webPort =
if qbt ? port then
common.ensureInt "config/services.nix qbittorrent.port" qbt.port
else
throw "config/services.nix qbittorrent.port must exist.";
rootDir =
if qbt ? root_dir then
common.ensureNonEmptyString "config/services.nix qbittorrent.root_dir" qbt.root_dir
else
throw "config/services.nix qbittorrent.root_dir must exist.";
vpn =
if qbt ? vpn then
common.ensureAttrset "config/services.nix qbittorrent.vpn" qbt.vpn
else
throw "config/services.nix qbittorrent.vpn must exist.";
usernameFile =
if vpn ? username_file then
common.ensureNonEmptyString "config/services.nix qbittorrent.vpn.username_file" vpn.username_file
else
throw "config/services.nix qbittorrent.vpn.username_file must exist.";
passwordFile =
if vpn ? password_file then
common.ensureNonEmptyString "config/services.nix qbittorrent.vpn.password_file" vpn.password_file
else
throw "config/services.nix qbittorrent.vpn.password_file must exist.";
in
{
port = webPort;
root_dir = rootDir;
vpn = {
username_file = usernameFile;
password_file = passwordFile;
};
};
}

57
validation/storage.nix Normal file
View file

@ -0,0 +1,57 @@
let
lib = import <nixpkgs/lib>;
validateStorageEntry = name: entry:
let
_ =
if builtins.isAttrs entry then
null
else
throw "config/storage.nix entry '${name}' must be an attrset.";
__ =
if entry ? path && builtins.isString entry.path && entry.path != "" then
null
else
throw "config/storage.nix entry '${name}' must define path as a non-empty string.";
___ =
if entry ? source && builtins.isString entry.source && entry.source != "" then
null
else
throw "config/storage.nix entry '${name}' must define source as a non-empty string.";
____ =
if entry ? type && builtins.isString entry.type && entry.type != "" then
null
else
throw "config/storage.nix entry '${name}' must define type as a non-empty string.";
_____ =
if entry ? options && builtins.isList entry.options then
null
else
throw "config/storage.nix entry '${name}' must define options as a list.";
______ =
if lib.all builtins.isString entry.options then
null
else
throw "config/storage.nix entry '${name}' options must be a list of strings.";
_______ =
if !(entry ? extra) || builtins.isAttrs entry.extra then
null
else
throw "config/storage.nix entry '${name}' field extra must be an attrset when present.";
in
entry;
getStorageConfig = config:
let
_ =
if builtins.isAttrs config then
null
else
throw "config/storage.nix must evaluate to an attrset.";
__ = lib.mapAttrs validateStorageEntry config;
in
config;
in
rec {
inherit getStorageConfig validateStorageEntry;
}

40
validation/web.nix Normal file
View file

@ -0,0 +1,40 @@
let
lib = import <nixpkgs/lib>;
ensureStoreName = name:
if builtins.match "^[A-Za-z_][A-Za-z0-9_-]*$" name != null then
name
else
throw "config/web.nix store name '${name}' is invalid. Use letters, numbers, '_' or '-', starting with a letter or '_' .";
validateStore = name: store:
let
_ = ensureStoreName name;
__ =
if builtins.isAttrs store then
null
else
throw "config/web.nix stores.${name} must be an attrset.";
___ =
if store ? root && builtins.isPath store.root then
null
else
throw "config/web.nix stores.${name}.root must be a Nix path.";
in
store;
getStores = webConfig:
let
stores =
if builtins.isAttrs webConfig && webConfig ? stores && builtins.isAttrs webConfig.stores then
webConfig.stores
else
throw "config/web.nix must define stores as an attrset.";
_ = lib.mapAttrs validateStore stores;
in
stores;
in
rec {
inherit getStores;
}