diff --git a/config/endpoints.nix b/config/endpoints.nix index db34a8f..84a8c69 100644 --- a/config/endpoints.nix +++ b/config/endpoints.nix @@ -1,4 +1,5 @@ let piTunnelEndpoints = import ../config/endpoints/pi_tunnel.nix; + livekitLocalEndpoints = import ../config/endpoints/livekit_local.nix; in -piTunnelEndpoints ++ [] \ No newline at end of file +piTunnelEndpoints ++ livekitLocalEndpoints \ No newline at end of file diff --git a/config/endpoints/livekit_local.nix b/config/endpoints/livekit_local.nix new file mode 100644 index 0000000..0b19f65 --- /dev/null +++ b/config/endpoints/livekit_local.nix @@ -0,0 +1,29 @@ +let + serviceConfig = import ../services.nix; + cfg = serviceConfig.livekit; +in +[ + { + type = "proxy"; + listenPort = 443; + domain = cfg.domain; + endpoint = "/livekit/jwt/"; + force_ssl = true; + content = { + host = "127.0.0.1"; + port = cfg.jwt_port; + }; + } + { + type = "proxy"; + listenPort = 443; + domain = cfg.domain; + endpoint = "/livekit/sfu/"; + force_ssl = true; + content = { + host = "127.0.0.1"; + port = cfg.port; + websocket = true; + }; + } +] diff --git a/config/endpoints/pi_tunnel.nix b/config/endpoints/pi_tunnel.nix index 9f8a690..96e4187 100644 --- a/config/endpoints/pi_tunnel.nix +++ b/config/endpoints/pi_tunnel.nix @@ -1,13 +1,38 @@ let - ports = [80 443 8448]; - entry = port: + forwards = [{ + port = 80; + domain = "vikunja.nudelerde.de"; + } + { + port = 443; + domain = "vikunja.nudelerde.de"; + tls = true; + } + { + port = 80; + domain = "nudelerde.de"; + } + { + port = 443; + domain = "nudelerde.de"; + tls = true; + } + { + port = 8448; + domain = "nudelerde.de"; + tls = true; + } + ]; + entry = forward: { type = "forwarding"; - listenPort = port; + listenPort = forward.port; + domain = forward.domain; content = { - port = 10000 + port; + port = 10000 + forward.port; + tls = forward.tls or false; }; }; in - map entry ports + map entry forwards diff --git a/config/services.nix b/config/services.nix index d1c9cf3..edf0a4a 100644 --- a/config/services.nix +++ b/config/services.nix @@ -1,6 +1,19 @@ { nginx = { enable = true; - acmeEmail = null; + acmeEmail = "katharina.heidenreich02@gmail.com"; + }; + + nginxProxyOffset = 20000; + + livekit = { + enable = true; + domain = "livekit.nudelerde.de"; + keyFile = "/var/lib/livekit/livekit.keys"; + port = 7880; + jwt_port = 8081; + trusted_homeservers = [ "nudelerde.de" ]; + rtc_port_range_start = 50000; + rtc_port_range_end = 51000; }; } \ No newline at end of file diff --git a/intermediate/nginx.nix b/intermediate/nginx.nix index 18c561d..e0af85b 100644 --- a/intermediate/nginx.nix +++ b/intermediate/nginx.nix @@ -1,36 +1,188 @@ let lib = import ; - endpoints = (import ../validation/endpoints.nix).getValidatedEndpoints (import ../config/endpoints.nix); - net = import ../config/network.nix; - tunnelPolicy = import ../validation/tunnel_ports.nix; + servicesConfig = import ../config/services.nix; + endpointsConfig = (import ../validation/endpoints.nix).getEndpointsConfig (import ../config/endpoints.nix); - normalizeEndpoint = endpoint: - endpoint // { - content = endpoint.content // { - host = if endpoint.type == "forwarding" then net.tunnel.host else endpoint.content.host; + tunnelOffset = 10000; + proxyOffset = servicesConfig.nginxProxyOffset; + + forwardingEndpoints = builtins.filter (endpoint: endpoint.type == "forwarding") endpointsConfig; + proxyEndpoints = builtins.filter (endpoint: endpoint.type == "proxy") endpointsConfig; + + forwardingTlsEndpoints = builtins.filter (endpoint: endpoint.content.tls or false) forwardingEndpoints; + forwardingHttpEndpoints = builtins.filter (endpoint: !(endpoint.content.tls or false)) forwardingEndpoints; + proxyTlsEndpoints = builtins.filter (endpoint: endpoint.force_ssl or false) proxyEndpoints; + + forwardingTarget = endpoint: "127.0.0.1:${toString endpoint.content.port}"; + proxyBackendPort = endpoint: proxyOffset + endpoint.listenPort; + proxyBackendTarget = endpoint: "127.0.0.1:${toString (proxyBackendPort endpoint)}"; + + mkTlsRouteEntryForForwarding = endpoint: { + domain = endpoint.domain; + listenPort = endpoint.listenPort; + upstream = forwardingTarget endpoint; + }; + + mkTlsRouteEntryForProxy = endpoint: { + domain = endpoint.domain; + listenPort = endpoint.listenPort; + upstream = proxyBackendTarget endpoint; + }; + + tlsRouteEntriesRaw = + map mkTlsRouteEntryForForwarding forwardingTlsEndpoints + ++ map mkTlsRouteEntryForProxy proxyTlsEndpoints; + + groupedTlsRoutes = + lib.foldl' + (acc: entry: + let + key = toString entry.listenPort; + previous = acc.${key} or []; + in + acc // { + ${key} = previous ++ [ entry ]; + } + ) + {} + tlsRouteEntriesRaw; + + dedupeRouteEntriesByDomain = entries: + builtins.attrValues (builtins.listToAttrs (map (entry: { + name = entry.domain; + value = entry; + }) entries)); + + mkTlsRouteMapForListenPort = listenPort: + let + routesForPort = groupedTlsRoutes.${toString listenPort} or []; + routeEntries = dedupeRouteEntriesByDomain routesForPort; + defaultRoute = if routeEntries == [] then null else (builtins.head routeEntries).upstream; + in + if defaultRoute == null then null else { + inherit listenPort routeEntries defaultRoute; }; - }; - normalizedEndpoints = map normalizeEndpoint endpoints; + tlsListenPorts = lib.unique (map (entry: entry.listenPort) tlsRouteEntriesRaw); + tlsRouteMaps = builtins.filter (mapDef: mapDef != null) (map mkTlsRouteMapForListenPort tlsListenPorts); - _forwardPortChecks = map (endpoint: - if endpoint.content.host == net.tunnel.host && !(tunnelPolicy.isAllowedTunnelPort endpoint.content.port) then - throw "Forwarding endpoint listenPort=${toString endpoint.listenPort} targets tunnel-backed local port ${toString endpoint.content.port}, which is not listed in config/network.nix tunnel.allowedPorts." - else - null - ) normalizedEndpoints; + renderTlsServer = tlsRouteMap: + let + portString = toString tlsRouteMap.listenPort; + in + '' + map $ssl_preread_server_name $proxy_upstream_${portString} { + ${lib.concatStringsSep "\n" (map (entry: "${entry.domain} ${entry.upstream};") tlsRouteMap.routeEntries)} + default ${tlsRouteMap.defaultRoute}; + } - mkStreamServer = endpoint: '' server { - listen ${toString endpoint.listenPort}; - proxy_pass ${endpoint.content.host}:${toString endpoint.content.port}; + listen ${portString}; + proxy_pass $proxy_upstream_${portString}; + ssl_preread on; } ''; - streamConfig = lib.concatStringsSep "\n" (map mkStreamServer normalizedEndpoints); + groupedProxyEndpoints = + lib.foldl' + (acc: endpoint: + let + key = "${endpoint.domain}:${toString endpoint.listenPort}"; + previous = acc.${key} or { + domain = endpoint.domain; + publicListenPort = endpoint.listenPort; + listenPort = proxyBackendPort endpoint; + locations = {}; + forceSSL = false; + }; + location = { + proxyPass = "http://${endpoint.content.host}:${toString endpoint.content.port}/"; + } // lib.optionalAttrs (endpoint.content ? websocket && endpoint.content.websocket) { + proxyWebsockets = true; + }; + in + acc // { + ${key} = previous // { + listenPort = proxyBackendPort endpoint; + forceSSL = previous.forceSSL || (endpoint.force_ssl or false); + locations = previous.locations // { + ${endpoint.endpoint} = location; + }; + }; + } + ) + {} + proxyEndpoints; + + proxyBackendVirtualHosts = lib.mapAttrs (_: hostConfig: { + serverName = hostConfig.domain; + listen = [ + { + addr = "127.0.0.1"; + port = hostConfig.listenPort; + ssl = true; + } + ]; + onlySSL = true; + extraConfig = '' + absolute_redirect off; + port_in_redirect off; + ''; + locations = hostConfig.locations; + } // lib.optionalAttrs (hostConfig.forceSSL && servicesConfig.nginx.acmeEmail != null) { + useACMEHost = hostConfig.domain; + }) groupedProxyEndpoints; + + forwardingHttpVirtualHosts = builtins.listToAttrs (map (endpoint: { + name = "forward-http-${endpoint.domain}-${toString endpoint.listenPort}"; + value = { + serverName = endpoint.domain; + listen = [ + { + addr = "0.0.0.0"; + port = endpoint.listenPort; + } + ]; + locations."/".proxyPass = "http://${forwardingTarget endpoint}"; + }; + }) forwardingHttpEndpoints); + + forwardingHttpDomains = lib.unique (map (endpoint: endpoint.domain) forwardingHttpEndpoints); + + localServiceAcmeDomains = + lib.unique + (map + (hostConfig: hostConfig.domain) + (builtins.attrValues (lib.filterAttrs (_: hostConfig: hostConfig.forceSSL) groupedProxyEndpoints))); + + localAcmeHttpVirtualHosts = builtins.listToAttrs (map (domain: { + name = "acme-http-${domain}-80"; + value = { + serverName = domain; + listen = [ + { + addr = "0.0.0.0"; + port = 80; + } + ]; + locations."^~ /.well-known/acme-challenge/" = { + root = "/var/lib/acme/acme-challenge"; + extraConfig = '' + auth_basic off; + auth_request off; + ''; + }; + locations."/" = { + return = "301 https://$host$request_uri"; + }; + }; + }) (builtins.filter (domain: !(builtins.elem domain forwardingHttpDomains)) localServiceAcmeDomains)); + + virtualHosts = forwardingHttpVirtualHosts // localAcmeHttpVirtualHosts // proxyBackendVirtualHosts; + + streamConfig = lib.concatStringsSep "\n\n" (map renderTlsServer tlsRouteMaps); + publicListenPorts = lib.unique (map (endpoint: endpoint.listenPort) endpointsConfig); in { - validatedEndpoints = normalizedEndpoints; - inherit streamConfig; - nginxUsedPorts = lib.unique (map (endpoint: endpoint.listenPort) normalizedEndpoints); + inherit endpointsConfig forwardingEndpoints proxyEndpoints streamConfig virtualHosts proxyOffset publicListenPorts localServiceAcmeDomains; } \ No newline at end of file diff --git a/services/default.nix b/services/default.nix index e38eec5..5881602 100644 --- a/services/default.nix +++ b/services/default.nix @@ -3,5 +3,6 @@ imports = [ ./openssh.nix ./nginx.nix + ./livekit.nix ]; } \ No newline at end of file diff --git a/services/livekit.nix b/services/livekit.nix new file mode 100644 index 0000000..c3c75b8 --- /dev/null +++ b/services/livekit.nix @@ -0,0 +1,52 @@ +{ lib, pkgs, ... }: +let + serviceConfig = import ../config/services.nix; + cfg = serviceConfig.livekit; + keyFile = cfg.keyFile; + publicUrl = "wss://${cfg.domain}/livekit/sfu/"; + trustedHomeservers = + if builtins.isList cfg.trusted_homeservers then + cfg.trusted_homeservers + else + throw "config/services.nix livekit.trusted_homeservers must be a list of domains."; + trustedHomeserversEnv = builtins.concatStringsSep "," trustedHomeservers; +in +{ + services.livekit = lib.mkIf cfg.enable { + enable = true; + settings.room.auto_create = false; + inherit keyFile; + openFirewall = true; + settings = { + port = cfg.port; + rtc = { + port_range_start = cfg.rtc_port_range_start; + port_range_end = cfg.rtc_port_range_end; + }; + }; + }; + + services.lk-jwt-service = lib.mkIf cfg.enable { + enable = true; + livekitUrl = publicUrl; + inherit keyFile; + port = cfg.jwt_port; + }; + + systemd.services.livekit-key = lib.mkIf cfg.enable { + before = [ "lk-jwt-service.service" "livekit.service" ]; + wantedBy = [ "multi-user.target" ]; + path = with pkgs; [ livekit coreutils gawk ]; + script = '' + echo "Key missing, generating key" + mkdir -p "$(dirname "${keyFile}")" + echo "lk-jwt-service: $(livekit-server generate-keys | tail -1 | awk '{print $3}')" > "${keyFile}" + ''; + serviceConfig.Type = "oneshot"; + unitConfig.ConditionPathExists = "!${keyFile}"; + }; + + systemd.services.lk-jwt-service = lib.mkIf cfg.enable { + environment.LIVEKIT_FULL_ACCESS_HOMESERVERS = trustedHomeserversEnv; + }; +} \ No newline at end of file diff --git a/services/nginx.nix b/services/nginx.nix index 4e17fbe..bfe680c 100644 --- a/services/nginx.nix +++ b/services/nginx.nix @@ -1,25 +1,42 @@ { config, lib, ... }: let + nginxConfig = import ../intermediate/nginx.nix; serviceConfig = import ../config/services.nix; - nginxModel = import ../intermediate/nginx.nix; + acmeDomains = nginxConfig.localServiceAcmeDomains; + 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 != ""; in { - assertions = [ - { - assertion = nginxModel.validatedEndpoints != []; - message = "No endpoints configured. Add endpoint declarations under config/endpoints/."; - } - ]; + config = lib.mkIf serviceConfig.nginx.enable { + assertions = lib.optional (acmeDomains != [] && !acmeEmailConfigured) { + assertion = false; + message = "TLS local proxy endpoints exist, but security.acme.defaults.email is missing or empty."; + }; - services.nginx = { - enable = serviceConfig.nginx.enable; + services.nginx = { + enable = true; + recommendedProxySettings = true; + recommendedTlsSettings = true; + virtualHosts = nginxConfig.virtualHosts; + streamConfig = nginxConfig.streamConfig; + }; - recommendedProxySettings = true; - recommendedTlsSettings = true; - recommendedOptimisation = true; - recommendedGzipSettings = true; - streamConfig = nginxModel.streamConfig; + security.acme = { + acceptTerms = true; + defaults.email = serviceConfig.nginx.acmeEmail; + certs = builtins.listToAttrs (map (domain: { + name = domain; + value = { + webroot = "/var/lib/acme/acme-challenge"; + group = "nginx"; + }; + }) acmeDomains); + }; + + networking.firewall.allowedTCPPorts = nginxConfig.publicListenPorts; }; - - networking.firewall.allowedTCPPorts = nginxModel.nginxUsedPorts; } \ No newline at end of file diff --git a/validation/endpoints.nix b/validation/endpoints.nix index 3222dc1..8bd01c9 100644 --- a/validation/endpoints.nix +++ b/validation/endpoints.nix @@ -1,78 +1,63 @@ let lib = import ; + tunnelPorts = import ./tunnel_ports.nix; - allowedEndpointKeys = [ "type" "listenPort" "content" ]; - allowedContentKeys = [ "host" "port" ]; + assertAttrset = context: value: + if builtins.isAttrs value then + value + else + throw "${context} must be an attrset."; - ensureNoUnknownKeys = context: obj: allowedKeys: + assertString = context: value: + if builtins.isString value && value != "" then + value + else + throw "${context} must be a non-empty string."; + + assertInt = context: value: + if builtins.isInt value then + value + else + throw "${context} must be an int."; + + validateForwarding = index: endpoint: let - unknown = builtins.filter (key: !(lib.elem key allowedKeys)) (builtins.attrNames obj); - in - if unknown != [] then - throw "${context} contains unknown keys: ${builtins.concatStringsSep ", " unknown}" - else - obj; - - ensurePort = value: - builtins.isInt value && value > 0 && value <= 65535; - - validateEndpointShape = index: endpoint: - let - _ = if builtins.isAttrs endpoint then null else throw "Endpoint at index ${toString index} must be an attrset."; - __ = ensureNoUnknownKeys "Endpoint at index ${toString index}" endpoint allowedEndpointKeys; - typeValue = - if endpoint ? type && builtins.isString endpoint.type then - endpoint.type - else - throw "Endpoint at index ${toString index} must define type as a string."; - _type = - if lib.elem typeValue [ "forwarding" "proxy" ] then - null - else - throw "Endpoint at index ${toString index} type must be 'forwarding' or 'proxy'."; - _listenPort = - if endpoint ? listenPort && ensurePort endpoint.listenPort then - null - else - throw "Endpoint at index ${toString index} must define listenPort as int in range 1..65535."; - contentValue = - if endpoint ? content then - endpoint.content - else - throw "Endpoint at index ${toString index} must define content."; - _content = - let - _attrs = if builtins.isAttrs contentValue then null else throw "Endpoint at index ${toString index} must define content as an attrset."; - ____ = ensureNoUnknownKeys "Endpoint content at index ${toString index}" contentValue allowedContentKeys; - in - if !(contentValue ? port) || !ensurePort contentValue.port then - throw "Endpoint at index ${toString index} must define content.port as int in range 1..65535." - else if typeValue == "proxy" && (!(contentValue ? host) || !builtins.isString contentValue.host || contentValue.host == "") then - throw "Proxy endpoint at index ${toString index} must define content.host as non-empty string." - else if typeValue == "forwarding" && contentValue ? host then - throw "Forwarding endpoint at index ${toString index} must not define content.host." - else - null; + content = assertAttrset "config/endpoints.nix[${toString index}].content" endpoint.content; + _ = assertInt "config/endpoints.nix[${toString index}].content.port" content.port; + __ = if tunnelPorts.isAllowedTunnelPort content.port then null else throw "config/endpoints.nix[${toString index}].content.port is not in config/network.nix tunnel.allowedPorts."; + ___ = if !(content ? tls) || builtins.isBool content.tls then null else throw "config/endpoints.nix[${toString index}].content.tls must be a bool."; in endpoint; - validateEndpointsShape = endpoints: - if !builtins.isList endpoints then - throw "config/endpoints.nix must evaluate to a list of endpoint attrsets." - else - lib.imap0 validateEndpointShape endpoints; - - validateUniqueHostPath = endpoints: + validateProxy = index: endpoint: let - keyFor = endpoint: toString endpoint.listenPort; - keys = map keyFor endpoints; + content = assertAttrset "config/endpoints.nix[${toString index}].content" endpoint.content; + _ = assertString "config/endpoints.nix[${toString index}].endpoint" endpoint.endpoint; + __ = assertString "config/endpoints.nix[${toString index}].content.host" content.host; + ___ = assertInt "config/endpoints.nix[${toString index}].content.port" content.port; + ____ = if !(endpoint ? force_ssl) || builtins.isBool endpoint.force_ssl then null else throw "config/endpoints.nix[${toString index}].force_ssl must be a bool."; + _____ = if !(content ? websocket) || builtins.isBool content.websocket then null else throw "config/endpoints.nix[${toString index}].content.websocket must be a bool."; in - if builtins.length keys == builtins.length (lib.unique keys) then - endpoints + endpoint; + + validateEndpoint = index: endpoint: + let + _ = assertAttrset "config/endpoints.nix[${toString index}]" endpoint; + __ = if endpoint ? type && (endpoint.type == "forwarding" || endpoint.type == "proxy") then null else throw "config/endpoints.nix[${toString index}].type must be \"forwarding\" or \"proxy\"."; + ___ = assertInt "config/endpoints.nix[${toString index}].listenPort" endpoint.listenPort; + ____ = assertString "config/endpoints.nix[${toString index}].domain" endpoint.domain; + in + if endpoint.type == "forwarding" then + validateForwarding index endpoint else - throw "Duplicate listenPort detected in config/endpoints.nix."; + validateProxy index endpoint; + + getEndpointsConfig = endpoints: + if builtins.isList endpoints then + lib.imap0 validateEndpoint endpoints + else + throw "config/endpoints.nix must evaluate to a list."; in { - getValidatedEndpoints = endpoints: - validateUniqueHostPath (validateEndpointsShape endpoints); + inherit getEndpointsConfig; } \ No newline at end of file