diff --git a/modules/server/default.nix b/modules/server/default.nix index 7fbe7e9..b0b1499 100644 --- a/modules/server/default.nix +++ b/modules/server/default.nix @@ -44,12 +44,14 @@ # ./trilium.nix # ./zerotier.nix ]; - # Server's 3x-ui is the controller panel at x.zeroq.su (nginx HTTP - # terminates TLS upstream, no SNI-routing on 443 needed here because - # there are other vhosts on the same port). Cert is still mounted in - # case 3x-ui is later reconfigured to terminate TLS itself (e.g. for - # direct node-API access); nginx doesn't have to use it. - xlib.services."3x-ui".certDomain = "x.zeroq.su"; + # Server's 3x-ui is the controller panel at x.zeroq.su, wired the same + # way as the VDS node: nginx stream SNI-routes x.zeroq.su → container:2049 + # (3x-ui terminates TLS itself with the mounted LE cert), and the default + # SNI → Xray REALITY via host:15380 → container:443 (see nginx.nix). + xlib.services."3x-ui" = { + certDomain = "x.zeroq.su"; + reality443Forwarding = true; + }; systemd.tmpfiles.rules = [ (xlib.helpers.mkTmpfile "d" "/mnt" "0755" "root" "root") (xlib.helpers.mkTmpfile "d" xlib.dirs.services-mnt-folder "0755" "root" "root") diff --git a/modules/server/nginx.nix b/modules/server/nginx.nix index a4e8a2b..34d36cf 100644 --- a/modules/server/nginx.nix +++ b/modules/server/nginx.nix @@ -5,12 +5,44 @@ xlib, ... }: -# Standard reverse-proxy: HTTP/S termination upstream, backend on the LAN. -# x.zeroq.su is the 3x-ui controller panel — see 3x-ui.nix for the -# /subs/, /subsjs/, /clash/ routing logic. +# SNI-fronted reverse proxy (vds/nginx.nix style): port 443 is owned by an +# nginx stream block that reads the ClientHello SNI and forwards the raw +# TLS stream. The HTTP vhosts no longer bind 443 — they listen on an +# internal HTTPS listener (127.0.0.1:8443) that the stream forwards onto, +# so all existing services behave exactly as before. +# +# Routing: +# x.zeroq.su → 127.0.0.1:2049 (3x-ui controller panel; TLS is +# terminated inside the container by the mounted LE +# cert, exactly like pubray1.zeroq.su on the VDS) +# immich/git/... → 127.0.0.1:8443 (nginx's own http {} listener: +# TLS termination + proxyPass unchanged) +# default → 127.0.0.1:15380 (Xray REALITY; podman DNATs +# host:15380 → container:443 so Xray sees its real +# configured port 443) +# +# ssl_preread reads SNI from the ClientHello; every SNI matching a known +# vhost goes to the internal web listener, x.zeroq.su goes to the panel, +# and anything else (REALITY fronting domains, direct-IP) goes to Xray. let server = "192.168.1.20"; + panelDomain = "x.zeroq.su"; + # Replaces the default 443 binding for every public vhost: 443 now + # belongs to the stream block, the internal https listener hosts the + # real server blocks. Port 80 stays public for ACME http-01 + redirects. + webListen = [ + { + addr = "0.0.0.0"; + port = 80; + } + { + addr = "127.0.0.1"; + port = 8443; + ssl = true; + } + ]; + mkProxy = { domain, @@ -22,6 +54,7 @@ let name = domain; value = { enableACME = true; + listen = webListen; locations."/" = { proxyPass = "http://${server}:${toString port}"; proxyWebsockets = true; @@ -82,6 +115,49 @@ let extraConfig = bigUploads; } ]; + + # Hardcoded vhosts served by the internal web listener (next to `sites`). + # Every one of them must appear in the SNI map → web. + extraWebDomains = [ + "office.zeroq.su" + "zeroq.su" + "vetymae.opencodes.zeroq.su" + "lamet.opencodes.zeroq.su" + "nextcloud.zeroq.su" + ]; + + # Goes inside the auto-generated `stream {}` block (services.nginx.streamConfig). + # x.zeroq.su → 3x-ui panel; known vhosts → nginx's own https listener; + # everything else (any SNI a REALITY client uses, e.g. + # media.mediavitrina.ru, or direct-IP) → Xray. + streamConfig = '' + ssl_preread on; + + map $ssl_preread_server_name $sni_backend { + default xray; + ${panelDomain} panel; + ${"\n" + lib.concatMapStringsSep "\n" (d: " ${d} web;") (extraWebDomains ++ (map (s: s.domain) sites))} + } + + upstream panel { + server 127.0.0.1:2049; + } + + upstream web { + server 127.0.0.1:8443; + } + + upstream xray { + server 127.0.0.1:15380; + } + + server { + listen 443; + proxy_pass $sni_backend; + proxy_timeout 600s; + proxy_connect_timeout 5s; + } + ''; in { services.nginx = { @@ -90,6 +166,8 @@ in recommendedOptimisation = true; recommendedProxySettings = true; recommendedTlsSettings = true; + # Lands inside the auto-generated `stream {}` block. + streamConfig = streamConfig; virtualHosts = (builtins.listToAttrs (map mkProxy sites)) // { "nextcloud.private" = { forceSSL = false; @@ -112,6 +190,7 @@ in "office.zeroq.su" = { forceSSL = true; enableACME = true; + listen = webListen; }; "pdf.private" = { forceSSL = false; @@ -137,30 +216,19 @@ in extraConfig = bigUploads; }; "x.zeroq.su" = { - forceSSL = true; + # ACME only — no https listener here: 443 is owned by the stream + # block, which routes the x.zeroq.su SNI to the panel inside the + # container (127.0.0.1:2049). 3x-ui terminates TLS itself using + # the cert this vhost renews (mounted at /root/cert/), and serves + # /subs/, /subsjs/, /clash/ straight from the panel, like the VDS. + # Don't add forceSSL: it would generate an HTTPS server block that + # conflicts with the stream listener. enableACME = true; - locations = { - "/" = { - proxyPass = "http://${server}:2049"; - proxyWebsockets = true; - }; - "/subs/" = { - proxyPass = "http://${server}:2096"; - proxyWebsockets = true; - }; - "/subsjs/" = { - proxyPass = "http://${server}:2096"; - proxyWebsockets = true; - }; - "/clash/" = { - proxyPass = "http://${server}:2096"; - proxyWebsockets = true; - }; - }; }; "zeroq.su" = { forceSSL = true; enableACME = true; + listen = webListen; root = pkgs.writeTextDir "index.html" '' @@ -177,6 +245,7 @@ in "vetymae.opencodes.zeroq.su" = { forceSSL = true; enableACME = true; + listen = webListen; locations."/" = { proxyPass = "http://100.86.62.4:4096"; proxyWebsockets = true; @@ -185,6 +254,7 @@ in "lamet.opencodes.zeroq.su" = { forceSSL = true; enableACME = true; + listen = webListen; locations."/" = { proxyPass = "http://100.106.21.39:6061"; proxyWebsockets = true; @@ -193,6 +263,7 @@ in "nextcloud.zeroq.su" = { forceSSL = true; enableACME = true; + listen = webListen; locations = { "/" = { proxyPass = "http://${server}:10000"; @@ -211,4 +282,4 @@ in 80 443 ]; -} +} \ No newline at end of file