let lib = import ; 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 if route.type == "inline" then let inlineBody = if builtins.isString route.content then route.content else route.content.body; inlineStatus = if builtins.isAttrs route.content && route.content ? status then route.content.status else 200; inlineContentType = if builtins.isAttrs route.content && route.content ? contentType then route.content.contentType else "text/plain; charset=utf-8"; in { name = "= ${route.endpoint}"; value = { return = "${toString inlineStatus} ${builtins.toJSON inlineBody}"; extraConfig = '' default_type ${inlineContentType}; ''; }; } 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; }; baseVirtualHostsData = builtins.listToAttrs (lib.mapAttrsToList mkVirtualHost groupedByHost); tlsDomains = lib.unique (map (route: route.domain) (lib.filter (route: route.force_ssl) mappedEndpoints)); mkRedirectVhost = domain: { name = "redirect_${sanitizeHostKey domain}_80"; value = { serverName = domain; listen = [ { addr = "0.0.0.0"; port = 80; } ]; locations."/" = { return = "301 https://$host$request_uri"; }; locations."^~ /.well-known/acme-challenge/" = { root = "/var/lib/acme/acme-challenge"; extraConfig = '' auth_basic off; auth_request off; ''; }; }; }; redirectVirtualHostsData = builtins.listToAttrs (map mkRedirectVhost tlsDomains); virtualHostsData = baseVirtualHostsData // redirectVirtualHostsData; nginxUsedPorts = lib.unique ( (map (route: route.port) mappedEndpoints) ++ lib.optional (tlsDomains != []) 80 ); acmeDomains = lib.unique (map (route: route.domain) (lib.filter (route: route.force_ssl) mappedEndpoints)); in rec { inherit validatedEndpoints mappedEndpoints virtualHostsData nginxUsedPorts acmeDomains; }