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

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