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

40
intermediate/dhcp.nix Normal file
View file

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

49
intermediate/dns.nix Normal file
View file

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

297
intermediate/nginx.nix Normal file
View file

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

78
intermediate/remote.nix Normal file
View file

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

163
intermediate/secrets.nix Normal file
View file

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

27
intermediate/storage.nix Normal file
View file

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

102
intermediate/web.nix Normal file
View file

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