feat: try rework
This commit is contained in:
parent
1ddbd3b8b6
commit
ecf10628c3
51 changed files with 1941 additions and 445 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -1,4 +1,3 @@
|
|||
users/
|
||||
ssh_keys/
|
||||
secret/
|
||||
results
|
||||
|
|
|
|||
7
.sops.yaml
Normal file
7
.sops.yaml
Normal 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
3
.vscode/settings.json
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"nixEnvSelector.nixFile": "${workspaceFolder}/configuration.nix"
|
||||
}
|
||||
|
|
@ -1,28 +1,67 @@
|
|||
let
|
||||
lib = import <nixpkgs/lib>;
|
||||
net = import ./network.nix;
|
||||
services = import ./services.nix;
|
||||
web = import ../intermediate/web.nix;
|
||||
in
|
||||
rec {
|
||||
[
|
||||
{
|
||||
type = "web";
|
||||
domain = "${net.devices.remote_proxy.domain}";
|
||||
endpoint = "/";
|
||||
force_ssl = true;
|
||||
port = 443;
|
||||
content = web.home;
|
||||
}
|
||||
{
|
||||
type = "proxy";
|
||||
domain = "torrent.${net.local_domain}";
|
||||
endpoint = "/";
|
||||
forceSsl = false;
|
||||
port = 80;
|
||||
content = {
|
||||
type = "service";
|
||||
url = "localhost";
|
||||
port = services.torrent.port;
|
||||
};
|
||||
}
|
||||
]
|
||||
}
|
||||
[
|
||||
{
|
||||
type = "web";
|
||||
domain = net.devices.remote_proxy.domain;
|
||||
endpoint = "/";
|
||||
force_ssl = true;
|
||||
port = 443;
|
||||
content = web.storePayloads.home;
|
||||
}
|
||||
{
|
||||
type = "proxy";
|
||||
domain = net.devices.remote_proxy.domain;
|
||||
endpoint = "/_matrix/";
|
||||
force_ssl = true;
|
||||
port = 443;
|
||||
content = {
|
||||
type = "service";
|
||||
ip = net.devices.pi.ip;
|
||||
port = services.continuwuity.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;
|
||||
};
|
||||
}
|
||||
]
|
||||
|
|
@ -1,8 +1,10 @@
|
|||
rec {
|
||||
secrets = import ../intermediate/secrets.nix;
|
||||
|
||||
network = {
|
||||
subnet = "192.168.2.0/24";
|
||||
subnet_base = "192.168.2.0";
|
||||
gateway = ips.router;
|
||||
gateway = devices.router.ip;
|
||||
cidr = 24;
|
||||
};
|
||||
|
||||
|
|
@ -11,6 +13,7 @@ rec {
|
|||
type = "local";
|
||||
ip = "192.168.2.100";
|
||||
};
|
||||
"self" = devices.pi;
|
||||
"desktop" = {
|
||||
type = "local";
|
||||
ip = "192.168.2.101";
|
||||
|
|
@ -28,11 +31,11 @@ rec {
|
|||
ip = "193.31.24.99";
|
||||
domain = "nudelerde.de";
|
||||
auto_ssh = {
|
||||
enable = true;
|
||||
sshPort = 22;
|
||||
sshUser = "root";
|
||||
key = secret.remote_proxy_key;
|
||||
known_hosts = secret.remote_proxy_known_hosts;
|
||||
forwards = [];
|
||||
key = secrets.byName.autossh_remote_proxy_key.path;
|
||||
known_hosts = secrets.byName.autossh_remote_proxy_known_hosts.path;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
|
@ -50,5 +53,4 @@ rec {
|
|||
];
|
||||
|
||||
local_domain = "home";
|
||||
extern_domain = "nudelerde.de";
|
||||
}
|
||||
7
config/openssh.nix
Normal file
7
config/openssh.nix
Normal 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
34
config/secrets.nix
Normal 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
35
config/services.nix
Normal 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" ];
|
||||
};
|
||||
|
||||
}
|
||||
|
|
@ -3,24 +3,23 @@ rec {
|
|||
path = "/";
|
||||
type = "ext4";
|
||||
source = "/dev/disk/by-label/NIXOS_SD";
|
||||
options = ["noatime"];
|
||||
options = [ "noatime" ];
|
||||
};
|
||||
|
||||
ssd = {
|
||||
path = "/mnt/ssd";
|
||||
type = "ext4";
|
||||
source = "/dev/disk/by-uuid/e44fedd5-150c-4af6-a2a0-0476da78e651";
|
||||
options = ["noatime"];
|
||||
options = [ "noatime" ];
|
||||
};
|
||||
|
||||
varlib-storage = {
|
||||
path = "/var/lib";
|
||||
type = "ext4";
|
||||
source = "/dev/disk/by-uuid/c9aacddc-00ab-4d36-8a04-1051586b071c";
|
||||
options = ["noatime"];
|
||||
options = [ "noatime" ];
|
||||
extra = {
|
||||
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
9
config/web.nix
Normal 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;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
|
@ -5,30 +5,21 @@
|
|||
...
|
||||
}: let
|
||||
nixosHardwareVersion = "7f1836531b126cfcf584e7d7d71bf8758bb58969";
|
||||
sopsNixVersion = "8f093d0d2f08f37317778bd94db5951d6cce6c46";
|
||||
|
||||
timeZone = "Europe/Berlin";
|
||||
defaultLocale = "en_US.UTF-8";
|
||||
storageConfig = import ./data/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;
|
||||
storageModel = import ./intermediate/storage.nix;
|
||||
in {
|
||||
imports = [
|
||||
"${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
|
||||
./users
|
||||
./programs
|
||||
./secret
|
||||
];
|
||||
|
||||
fileSystems = fileSystemDefinition;
|
||||
fileSystems = storageModel.fileSystems;
|
||||
|
||||
networking.hostName = "raspberry";
|
||||
|
||||
|
|
@ -95,6 +86,8 @@ in {
|
|||
options = "--delete-older-than +5"; # Keep last 5 generations
|
||||
};
|
||||
|
||||
nix.settings.experimental-features = [ "nix-command" "flakes" ];
|
||||
|
||||
# Enable GPU acceleration
|
||||
hardware.raspberry-pi."4".fkms-3d.enable = true;
|
||||
|
||||
|
|
|
|||
108
data/network.nix
108
data/network.nix
|
|
@ -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)));
|
||||
}
|
||||
|
|
@ -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 = [];
|
||||
};
|
||||
}
|
||||
|
||||
34
data/ssh.nix
34
data/ssh.nix
|
|
@ -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;
|
||||
}
|
||||
34
data/web.nix
34
data/web.nix
|
|
@ -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
40
intermediate/dhcp.nix
Normal 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
49
intermediate/dns.nix
Normal 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
297
intermediate/nginx.nix
Normal 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
78
intermediate/remote.nix
Normal 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
163
intermediate/secrets.nix
Normal 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
27
intermediate/storage.nix
Normal 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
102
intermediate/web.nix
Normal 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
56
secrets/README.md
Normal 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>`.
|
||||
18
secrets/autossh/remote_proxy_key
Normal file
18
secrets/autossh/remote_proxy_key
Normal 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"
|
||||
}
|
||||
}
|
||||
18
secrets/autossh/remote_proxy_known_hosts
Normal file
18
secrets/autossh/remote_proxy_known_hosts
Normal 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"
|
||||
}
|
||||
}
|
||||
18
secrets/openssh/nudelerde/pub_keys
Normal file
18
secrets/openssh/nudelerde/pub_keys
Normal 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"
|
||||
}
|
||||
}
|
||||
22
secrets/qbittorrent/vpn.json
Normal file
22
secrets/qbittorrent/vpn.json
Normal 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"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,57 +1,145 @@
|
|||
{ config, pkgs, lib, ... }:
|
||||
|
||||
let
|
||||
net = import ../data/network.nix;
|
||||
serv = import ../data/services.nix;
|
||||
net = import ../config/network.nix;
|
||||
rem = import ../intermediate/remote.nix;
|
||||
autoSshValidation = import ../validation/auto_ssh.nix;
|
||||
|
||||
autoForwards = map (port: {
|
||||
remote = port;
|
||||
localAddress = "localhost";
|
||||
localPort = port;
|
||||
}) net.usedPorts;
|
||||
fordwards = lib.unique (serv.autossh.forwards ++ autoForwards);
|
||||
devices = autoSshValidation.getDevices net;
|
||||
|
||||
forwardStrings = map (port: "-R ${toString port.remote}:${port.localAddress}:${toString port.localPort}") fordwards;
|
||||
forwardString = builtins.concatStringsSep " " forwardStrings;
|
||||
portsByRemote =
|
||||
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;
|
||||
sshPort = 22;
|
||||
sshUser = "root";
|
||||
sshKeyPath = serv.autossh.key_path;
|
||||
trustedHostsFile = serv.autossh.known_hosts;
|
||||
autoSshDevices = autoSshValidation.getAutoSshDevices devices;
|
||||
|
||||
getAutoSsh = deviceName: device:
|
||||
autoSshValidation.getAutoSshConfig deviceName device;
|
||||
|
||||
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
|
||||
{
|
||||
environment.systemPackages = with pkgs; [
|
||||
autossh
|
||||
moreutils
|
||||
];
|
||||
|
||||
systemd.services.autossh-tunnel = {
|
||||
description = "Autossh Reverse SSH Tunnel";
|
||||
after = [ "network.target" "network-online.target" ];
|
||||
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" ];
|
||||
};
|
||||
users.users = generatedUsers;
|
||||
users.groups = generatedGroups;
|
||||
systemd.services = generatedServices;
|
||||
}
|
||||
|
|
@ -1,20 +1,29 @@
|
|||
{ config, pkgs, ... }:
|
||||
|
||||
let
|
||||
net = import ../data/network.nix;
|
||||
serv = import ../data/services.nix;
|
||||
net = import ../config/network.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
|
||||
{
|
||||
services.matrix-continuwuity = {
|
||||
enable = true;
|
||||
settings = {
|
||||
global = {
|
||||
server_name = net.services.continuwuity.domainOverride;
|
||||
server_name = serverName;
|
||||
allow_registration = true;
|
||||
allow_encryption = true;
|
||||
allow_federation = true;
|
||||
max_request_size = 20 * 1024 * 1024; # 20 MiB
|
||||
trusted_servers = serv.matrix.trusted_servers;
|
||||
trusted_servers = trustedServers;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,7 +1,20 @@
|
|||
{ config, pkgs, ... }:
|
||||
|
||||
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
|
||||
{
|
||||
services.kea.dhcp4 = {
|
||||
|
|
@ -23,11 +36,11 @@ in
|
|||
option-data = [
|
||||
{
|
||||
name = "routers";
|
||||
data = net.ips.router;
|
||||
data = routerIp;
|
||||
}
|
||||
{
|
||||
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";
|
||||
|
|
@ -39,7 +52,7 @@ in
|
|||
}
|
||||
];
|
||||
|
||||
reservations = net.dhcp.reservations;
|
||||
reservations = dhcpModel.reservations;
|
||||
}];
|
||||
|
||||
valid-lifetime = net.dhcp.default_lease;
|
||||
|
|
|
|||
|
|
@ -1,9 +1,14 @@
|
|||
{ config, pkgs, lib, ... }:
|
||||
|
||||
let
|
||||
service_data = import ../data/services.nix;
|
||||
kiwix = service_data.kiwix;
|
||||
serviceValidation = import ../validation/service/kiwix.nix;
|
||||
service_data = import ../config/services.nix;
|
||||
kiwix = serviceValidation.getKiwix service_data;
|
||||
|
||||
rootDir = kiwix.root_dir;
|
||||
|
||||
zimUrls = kiwix.urls;
|
||||
|
||||
updater = pkgs.writeShellScriptBin "kiwix-updater" ''
|
||||
set -e
|
||||
|
||||
|
|
@ -14,13 +19,13 @@ let
|
|||
download() {
|
||||
local url=''$1
|
||||
local filename=''$(basename "''$url")
|
||||
local filepath="${kiwix.root_dir}"/''$filename
|
||||
local filepath="${rootDir}"/''$filename
|
||||
|
||||
if [ -f "''$filepath" ]; then
|
||||
echo "''$filepath exists!"
|
||||
return 0
|
||||
fi
|
||||
cd ${kiwix.root_dir}
|
||||
cd ${rootDir}
|
||||
${pkgs.wget}/bin/wget --continue --quiet "''$url" -O "''$filename.tmp"
|
||||
mv ''$filename.tmp ''$filename
|
||||
}
|
||||
|
|
@ -29,7 +34,7 @@ let
|
|||
{
|
||||
echo '<?xml version="1.0" encoding="UTF-8"?>'
|
||||
echo '<library>'
|
||||
for zim in "${kiwix.root_dir}"/*.zim; do
|
||||
for zim in "${rootDir}"/*.zim; do
|
||||
if [ -f "''$zim" ]; then
|
||||
filename=''$(basename "''$zim")
|
||||
size=''$(stat -c%s "''$zim")
|
||||
|
|
@ -58,8 +63,8 @@ EOF
|
|||
fi
|
||||
done
|
||||
echo '</library>'
|
||||
} > "${kiwix.root_dir}/library.xml.tmp"
|
||||
mv "${kiwix.root_dir}/library.xml.tmp" "${kiwix.root_dir}/library.xml"
|
||||
} > "${rootDir}/library.xml.tmp"
|
||||
mv "${rootDir}/library.xml.tmp" "${rootDir}/library.xml"
|
||||
}
|
||||
|
||||
for url in "''${URLS[@]}"; do
|
||||
|
|
|
|||
|
|
@ -1,19 +1,23 @@
|
|||
{ config, pkgs, lib, ... }:
|
||||
let
|
||||
net = import ../data/network.nix;
|
||||
service_data = import ../data/services.nix;
|
||||
kiwix = service_data.kiwix;
|
||||
serviceValidation = import ../validation/service/kiwix.nix;
|
||||
service_data = import ../config/services.nix;
|
||||
kiwix = serviceValidation.getKiwix service_data;
|
||||
|
||||
rootDir = kiwix.root_dir;
|
||||
|
||||
webPort = kiwix.port;
|
||||
in {
|
||||
systemd.tmpfiles.rules = [
|
||||
"d ${kiwix.root_dir} 0755 root root - -"
|
||||
"d ${kiwix.root_dir}/data 0755 root root - -"
|
||||
"d ${rootDir} 0755 root root - -"
|
||||
"d ${rootDir}/data 0755 root root - -"
|
||||
];
|
||||
|
||||
virtualisation.oci-containers.containers = {
|
||||
kiwix-serve = {
|
||||
image = "ghcr.io/kiwix/kiwix-serve:3.8.2";
|
||||
ports = ["8086:8080"];
|
||||
volumes = ["${kiwix.root_dir}/:/data:ro"];
|
||||
ports = ["${toString webPort}:8080"];
|
||||
volumes = ["${rootDir}/:/data:ro"];
|
||||
cmd = [
|
||||
"--monitorLibrary"
|
||||
"--library" "/data/library.xml"
|
||||
|
|
@ -31,6 +35,6 @@ in {
|
|||
};
|
||||
|
||||
networking.firewall = {
|
||||
allowedTCPPorts = [8086];
|
||||
allowedTCPPorts = [ webPort ];
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,92 +1,36 @@
|
|||
{ config, pkgs, lib, ... }:
|
||||
let
|
||||
network = import ../data/network.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;
|
||||
'';
|
||||
};
|
||||
nginxModel = import ../intermediate/nginx.nix;
|
||||
|
||||
extraConfig = ''
|
||||
return 404 "This domain is not configured. Available services: ${serviceNamesMessage}";
|
||||
'';
|
||||
};
|
||||
virtualHosts' = builtins.attrValues (rproxyServices // webHosts // {fallback = fallback;});
|
||||
virtualHostsData = nginxModel.virtualHostsData;
|
||||
validatedEndpoints = nginxModel.validatedEndpoints;
|
||||
tlsEndpoints = lib.filter (endpoint: endpoint.force_ssl) validatedEndpoints;
|
||||
localTlsEndpoints = lib.filter (endpoint: endpoint.force_ssl && endpoint.exposure == "local") validatedEndpoints;
|
||||
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 {
|
||||
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 = {
|
||||
enable = true;
|
||||
|
||||
|
|
@ -98,7 +42,7 @@ in {
|
|||
virtualHosts = virtualHosts';
|
||||
};
|
||||
|
||||
networking.firewall.allowedTCPPorts = network.usedPorts;
|
||||
networking.firewall.allowedTCPPorts = nginxModel.nginxUsedPorts;
|
||||
|
||||
security.acme = {
|
||||
acceptTerms = true;
|
||||
|
|
|
|||
|
|
@ -1,17 +1,15 @@
|
|||
#{ config, pkgs, lib, ... }:
|
||||
{ ... }:
|
||||
let
|
||||
ssh_data = import ../data/ssh.nix;
|
||||
opensshConfig = import ../config/openssh.nix;
|
||||
usersWithKeys = opensshConfig.ssh_users;
|
||||
|
||||
in {
|
||||
services.openssh = {
|
||||
enable = true;
|
||||
settings = {
|
||||
PasswordAuthentication = true;
|
||||
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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,14 +1,21 @@
|
|||
{ config, pkgs, lib, ... }:
|
||||
let
|
||||
net = import ../data/network.nix;
|
||||
serviceData = import ../data/services.nix;
|
||||
qbt = serviceData.qbittorrent;
|
||||
serviceValidation = import ../validation/service/qbittorrent.nix;
|
||||
serviceData = import ../config/services.nix;
|
||||
qbt = serviceValidation.getQbittorrent serviceData;
|
||||
|
||||
webPort = qbt.port;
|
||||
|
||||
rootDir = qbt.root_dir;
|
||||
|
||||
vpnUserPath = qbt.vpn.username_file;
|
||||
vpnPasswordPath = qbt.vpn.password_file;
|
||||
in {
|
||||
systemd.tmpfiles.rules = [
|
||||
"d ${qbt.root_dir} 0755 root root - -"
|
||||
"d ${qbt.root_dir}/gluetun 0755 root root - -"
|
||||
"d ${qbt.root_dir}/downloads 0755 root root - -"
|
||||
"d ${qbt.root_dir}/config 0755 root root - -"
|
||||
"d ${rootDir} 0755 root root - -"
|
||||
"d ${rootDir}/gluetun 0755 root root - -"
|
||||
"d ${rootDir}/downloads 0755 root root - -"
|
||||
"d ${rootDir}/config 0755 root root - -"
|
||||
];
|
||||
|
||||
environment.etc."qbittorrent-compose/docker-compose.yml" = {
|
||||
|
|
@ -22,18 +29,18 @@ services:
|
|||
- NET_ADMIN
|
||||
network_mode: bridge
|
||||
ports:
|
||||
- 127.0.0.1:8085:8085 # qBittorrent
|
||||
- 127.0.0.1:${toString webPort}:${toString webPort} # qBittorrent
|
||||
devices:
|
||||
- /dev/net/tun:/dev/net/tun
|
||||
volumes:
|
||||
- ${qbt.root_dir}/gluetun/:/gluetun
|
||||
- ${rootDir}/gluetun/:/gluetun
|
||||
environment:
|
||||
- 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
|
||||
- UPDATER_PERIOD=24h
|
||||
|
||||
- OPENVPN_USER=${qbt.vpn.username}
|
||||
- OPENVPN_PASSWORD=${qbt.vpn.password}
|
||||
- OPENVPN_USER=$${OPENVPN_USER:-DUMMY_NOT_USED}
|
||||
- OPENVPN_PASSWORD=$${OPENVPN_PASSWORD:-DUMMY_NOT_USED}
|
||||
|
||||
- DOT_PROVIDERS=cloudflare,google
|
||||
- BLOCK_ADS=off
|
||||
|
|
@ -50,10 +57,10 @@ services:
|
|||
- PUID=1000
|
||||
- PGID=1000
|
||||
- TZ=Europe/Berlin
|
||||
- WEBUI_PORT=8085
|
||||
- WEBUI_PORT=${toString webPort}
|
||||
volumes:
|
||||
- ${qbt.root_dir}/config/:/config
|
||||
- ${qbt.root_dir}/downloads/:/downloads
|
||||
- ${rootDir}/config/:/config
|
||||
- ${rootDir}/downloads/:/downloads
|
||||
'';
|
||||
};
|
||||
systemd.services.qbittorrent-stack = {
|
||||
|
|
@ -64,20 +71,27 @@ services:
|
|||
serviceConfig = {
|
||||
Type = "oneshot";
|
||||
RemainAfterExit = true;
|
||||
WorkingDirectory = qbt.root_dir;
|
||||
WorkingDirectory = rootDir;
|
||||
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
|
||||
cp /etc/qbittorrent-compose/docker-compose.yml ${qbt.root_dir}/
|
||||
cd ${qbt.root_dir}
|
||||
cp /etc/qbittorrent-compose/docker-compose.yml ${rootDir}/
|
||||
cd ${rootDir}
|
||||
${pkgs.docker-compose}/bin/docker-compose up -d
|
||||
''}";
|
||||
ExecStop = "${pkgs.writeShellScript "torrent-stop" ''
|
||||
cd ${qbt.root_dir}
|
||||
cd ${rootDir}
|
||||
${pkgs.docker-compose}/bin/docker-compose down
|
||||
''}";
|
||||
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
|
||||
''}";
|
||||
|
||||
|
|
@ -87,6 +101,6 @@ services:
|
|||
};
|
||||
|
||||
networking.firewall = {
|
||||
allowedTCPPorts = [8085];
|
||||
allowedTCPPorts = [ webPort ];
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
{ config, pkgs, ... }:
|
||||
|
||||
let
|
||||
net = import ../data/network.nix;
|
||||
net = import ../config/network.nix;
|
||||
dnsModel = import ../intermediate/dns.nix;
|
||||
in
|
||||
{
|
||||
services.unbound = {
|
||||
|
|
@ -16,9 +17,9 @@ in
|
|||
local-zone = "\"${net.local_domain}.\" static";
|
||||
local-data =
|
||||
(map (name:
|
||||
let ip = net.dnsMappings.${name}; in
|
||||
let ip = dnsModel.dnsMappings.${name}; in
|
||||
"\"${name}. IN A ${ip}\""
|
||||
) (builtins.attrNames net.dnsMappings));
|
||||
) (builtins.attrNames dnsModel.dnsMappings));
|
||||
};
|
||||
|
||||
forward-zone = {
|
||||
|
|
|
|||
9
system/default.nix
Normal file
9
system/default.nix
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
{ config, pkgs, ... }:
|
||||
|
||||
{
|
||||
imports = [
|
||||
./sops.nix
|
||||
./static-ip.nix
|
||||
./users.nix
|
||||
];
|
||||
}
|
||||
14
system/sops.nix
Normal file
14
system/sops.nix
Normal 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)}";
|
||||
}
|
||||
|
|
@ -1,15 +1,15 @@
|
|||
{ config, pkgs, ... }:
|
||||
|
||||
let
|
||||
net = import ../data/network.nix;
|
||||
net = import ../config/network.nix;
|
||||
in
|
||||
{
|
||||
networking.interfaces.eth0.ipv4.addresses = [{
|
||||
address = net.ips.pi;
|
||||
address = net.devices.self.ip;
|
||||
prefixLength = net.network.cidr;
|
||||
}];
|
||||
|
||||
networking.defaultGateway = net.ips.router;
|
||||
networking.defaultGateway = net.network.gateway;
|
||||
|
||||
networking.nameservers = net.fallback_dns_servers;
|
||||
}
|
||||
8
system/users.nix
Normal file
8
system/users.nix
Normal 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
28
validation/README.md
Normal 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
61
validation/auto_ssh.nix
Normal 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
141
validation/endpoints.nix
Normal 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;
|
||||
}
|
||||
40
validation/network_devices.nix
Normal file
40
validation/network_devices.nix
Normal 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
71
validation/secrets.nix
Normal 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;
|
||||
}
|
||||
34
validation/service/common.nix
Normal file
34
validation/service/common.nix
Normal 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;
|
||||
}
|
||||
20
validation/service/continuwuity.nix
Normal file
20
validation/service/continuwuity.nix
Normal 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;
|
||||
}
|
||||
36
validation/service/kiwix.nix
Normal file
36
validation/service/kiwix.nix
Normal 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;
|
||||
};
|
||||
}
|
||||
51
validation/service/qbittorrent.nix
Normal file
51
validation/service/qbittorrent.nix
Normal 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
57
validation/storage.nix
Normal 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
40
validation/web.nix
Normal 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;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue