357 lines
11 KiB
Nix
357 lines
11 KiB
Nix
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 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;
|
|
}
|