feat: try rework

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

View file

@ -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;
}

View file

@ -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;
};
};
};

View file

@ -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;

View file

@ -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

View file

@ -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 ];
};
}

View file

@ -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;

View file

@ -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;
}

View file

@ -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 ];
};
}

View file

@ -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 = {