diff --git a/modules/containers/3x-ui.nix b/modules/containers/3x-ui.nix index 18e3388..195a0b9 100644 --- a/modules/containers/3x-ui.nix +++ b/modules/containers/3x-ui.nix @@ -24,198 +24,15 @@ let "key.pem" ]; basePorts = [ - # 3x-ui panel + subscription endpoint on the loopback only. + # Local-only upstreams for the 3x-ui panel and subscription endpoint. + # The direct Xray inbound remains publicly reachable on 8443. "127.0.0.1:2049:2049/tcp" "127.0.0.1:2096:2096/tcp" - # xray's Reality inbound on the loopback only — nginx stream (in - # modules/server/nginx.nix) listens on the public 8443 and forwards - # here. Going nginx-stream → podman → xray keeps Reality's TLS - # ClientHello intact end-to-end; exposing 8443 directly via podman - # port-forward mangles it and clients see the fallback cert. - "127.0.0.1:15380:8443/tcp" + "0.0.0.0:8443:8443/tcp" ]; - # VDS-only: nginx stream forwards host:443 → 127.0.0.1:15380 via SNI - # (see modules/server/nginx.nix). The container's only Reality inbound - # listens on 8443, so nginx's SNI-routed connection to host:15380 - # lands on the correct inbound. A `-p ...:15380:443/tcp` mapping is - # therefore unnecessary and was removed: podman 5.x refuses two - # `-p` flags that bind the same host port (the second `-p - # 127.0.0.1:15380:443/tcp` produced - # `Error: cannot listen on the TCP port: listen tcp4 127.0.0.1:15380: - # bind: address already in use` and a start-limit-hit loop). - # - # Removed 2026-10-04. The reality443Forwarding host option is kept - # so configurations can continue to declare the intent; only the - # broken port-mapping generation is gone. - realityPorts = [ ]; - # Workaround for a 3x-ui panel bug (both 3.8.5 and 3.9.0 reproduce it): when - # generating bin/config.json from the inbounds DB rows, the panel drops the - # inner `realitySettings.settings.{publicKey,fingerprint,serverName,spiderX, - # mldsa65Verify}` block — without which the xray Reality server cannot - # complete the auth handshake with any client. The DB has the data; only - # the generated config.json is missing it. This script reads DB inside the - # running container and re-applies the missing fields to bin/config.json, - # then SIGHUPs xray so clients can connect. Runs every 30s; safe to - # overlap with the panel's own config writes (it's idempotent and only - # touches missing/different fields). - # - # Both migrateScript (one-shot at container start) and patchScript + - # timer (every 10s) are commented out as of 2026-10-04: the user is - # switching the xray-core version through the panel UI instead of - # patching config.json from NixOS. See ./3x-ui-migration-notes.md for - # the full investigation, the panel bug reference, and how to re-enable - # the workarounds if a future panel build re-introduces the issue. - # - # REAL ROOT-CAUSE FIX for the 3x-ui config-gen bug. - # - # In `internal/web/service/xray.go` the panel's `GetXrayConfig()` - # function does this on every config regeneration (xray restart, inbound - # update, restartXrayService API call): - # - # realitySettings, ok2 := stream["realitySettings"].(map[string]any) - # if ok2 { delete(realitySettings, "settings") } - # - # i.e. it explicitly drops the *nested* `realitySettings.settings` block - # before serialising to bin/config.json. The panel's inbound DB row - # stores these fields under `stream_settings.realitySettings.settings`, - # so every regeneration wipes publicKey/fingerprint/serverName/spiderX/ - # mldsa65Verify from the live xray config, breaking Reality-auth for - # every inbound. - # - # The proper fix is to move these fields from the nested `settings` block - # to the *top level* of `realitySettings` directly in the DB. Panel's - # delete() targets the nested block only; top-level fields pass through - # untouched, and Panel passes them through to bin/config.json correctly. - # - # The migration is idempotent (no-op once fields are top-level) and is - # re-applied on every container start so that any new inbound created - # via the panel UI gets migrated automatically. - # - # migrateScript = pkgs.writeScript "migrate-3xui-reality.py" '' - # #!/usr/bin/env python3 - # """Move Reality fields from nested settings to top-level realitySettings in DB. - # - # Idempotent. Re-applied on every container start so newly-added inbounds - # are auto-migrated.""" - # import json, sqlite3, sys - # FIELDS = ("publicKey", "fingerprint", "serverName", "spiderX", "mldsa65Verify") - # try: - # conn = sqlite3.connect("/etc/x-ui/x-ui.db") - # rows = conn.execute( - # "SELECT id, stream_settings FROM inbounds " - # "WHERE stream_settings IS NOT NULL AND protocol='vless'" - # ).fetchall() - # migrated = 0 - # for rid, ss_json in rows: - # ss = json.loads(ss_json) - # rs = ss.get("realitySettings") - # if not rs: - # continue - # inner = rs.get("settings", {}) - # if not inner: - # continue - # changed = False - # for k in FIELDS: - # v = inner.get(k) - # if v and not rs.get(k): - # rs[k] = v - # changed = True - # if changed: - # conn.execute( - # "UPDATE inbounds SET stream_settings=? WHERE id=?", - # (json.dumps(ss), rid), - # ) - # migrated += 1 - # conn.commit() - # conn.close() - # print(f"migrated={migrated}") - # except Exception as e: - # print(f"ERROR: {e}", file=sys.stderr) - # sys.exit(1) - # ''; - # Patches /app/bin/config.json inside the running container so every reality - # inbound has top-level publicKey/fingerprint/serverName/spiderX/mldsa65Verify - # sourced from the panel's DB. The 3x-ui panel's GetXrayConfig (in - # internal/web/service/xray.go) explicitly drops the nested - # `realitySettings.settings` block before serialising bin/config.json - # (a bug in v3.8.5 and v3.9.0). After every panel regeneration (xray - # restart, inbound update, restartXray API call) xray is left without the - # public fields and REALITY auth fails for every inbound. Re-running the - # DB-only migration at container start is not enough: the panel overwrites - # stream_settings back to nested-only within seconds for active inbounds. - # We re-read the source-of-truth nested block from /etc/x-ui/x-ui.db and - # re-inject the missing top-level fields into the rendered config.json, - # then SIGHUP xray so it picks up the patch without dropping live - # connections. Idempotent. Runs every 10s via systemd timer; the - # migrateScript service covers first boot. - # - # patchScript = pkgs.writeScript "patch-3xui-xray-config.py" '' - # #!/usr/bin/env python3 - # """Patch /app/bin/config.json so every reality inbound has the public fields - # xray needs to complete the REALITY handshake. Idempotent: no-op once - # top-level fields are present.""" - # import json, os, signal, sqlite3, sys - # CFG = "/app/bin/config.json" - # FIELDS = ("publicKey", "fingerprint", "serverName", "spiderX", "mldsa65Verify") - # try: - # conn = sqlite3.connect("/etc/x-ui/x-ui.db") - # rows = conn.execute( - # "SELECT id, port, stream_settings FROM inbounds " - # "WHERE stream_settings IS NOT NULL AND protocol='vless'" - # ).fetchall() - # port_to_src = {} - # for _, port, ss_json in rows: - # ss = json.loads(ss_json) - # rs = ss.get("realitySettings") or {} - # inner = rs.get("settings") or {} - # src = {k: v for k in FIELDS if (v := rs.get(k) or inner.get(k))} - # if src: - # port_to_src[port] = src - # conn.close() - # if not port_to_src: - # print("no-source") - # sys.exit(0) - # with open(CFG) as f: - # cfg = json.load(f) - # patched = [] - # for ib in cfg.get("inbounds", []): - # port = ib.get("port") - # src = port_to_src.get(port) - # if not src: - # continue - # ss = ib.setdefault("streamSettings", {}) - # rs = ss.setdefault("realitySettings", {}) - # ib_changed = False - # for k, v in src.items(): - # if rs.get(k) != v: - # rs[k] = v - # ib_changed = True - # if ib_changed: - # patched.append(port) - # if not patched: - # print("clean") - # sys.exit(0) - # tmp = CFG + ".tmp" - # with open(tmp, "w") as f: - # json.dump(cfg, f, indent=2) - # os.replace(tmp, CFG) - # sent = 0 - # for entry in os.listdir("/proc"): - # if not entry.isdigit(): - # continue - # try: - # with open(f"/proc/{entry}/comm") as f: - # comm = f.read().strip() - # if comm.startswith("xray"): - # os.kill(int(entry), signal.SIGHUP) - # sent += 1 - # except (FileNotFoundError, ProcessLookupError, ValueError): - # continue - # print(f"patched ports={patched} sighup={sent}") - # except Exception as e: - # print(f"ERROR: {e}", file=sys.stderr) - # sys.exit(1) - # ''; + # VDS-only: nginx stream forwards host:443 → 127.0.0.1:15380 → + # container:443, so Xray sees its REALITY inbound on port 443. + realityPorts = lib.optional config.host."3x-ui".reality443Forwarding "127.0.0.1:15380:443/tcp"; in { # `host."3x-ui"` options are declared in modules/options.nix: they are set @@ -233,17 +50,8 @@ in }; oci-containers = { backend = "podman"; -containers."3xui_app" = { - # Pinned to v3.9.0 (latest stable at 2026-10-03) as of the 2026-10-04 - # clean regress. The xray-core version is no longer managed here — - # it is switched through the panel UI (Settings → Xray version), - # which writes to /app/bin/xray-linux-amd64 inside the container. - # The migrateScript + patchScript + service + timer workarounds for - # the panel's GetXrayConfig bug are commented out in this file; - # see ./3x-ui-migration-notes.md for the full investigation and - # how to re-enable them if a future panel build reintroduces the - # issue. - image = "ghcr.io/mhsanaei/3x-ui:v3.9.0"; + containers."3xui_app" = { + image = "ghcr.io/mhsanaei/3x-ui:latest"; environment = { "XRAY_VMESS_AEAD_FORCED" = "false"; "XUI_ENABLE_FAIL2BAN" = "true"; @@ -276,82 +84,21 @@ containers."3xui_app" = { TimeoutSec = 300; }; script = '' - podman pull ghcr.io/mhsanaei/3x-ui:v3.8.5 + podman pull ghcr.io/mhsanaei/3x-ui:latest systemctl restart podman-3xui_app.service ''; }; - # Real fix for the panel config-gen bug: run the DB migration once - # after each container start so any new inbounds (created via panel UI - # or API) have their Reality public fields moved to top-level on the - # next launch. The migration is idempotent — a no-op once fields are - # top-level — so it's safe to run on every container start. - # - # The script is piped into the container via stdin rather than - # referenced by its host-side /nix/store path (which does not exist - # inside the container). Restart=on-failure so a transient container - # race (e.g. 3x-ui still seeding the DB on first start) is retried - # instead of silently passing. - # - # COMMENTED OUT 2026-10-04 — user switched to changing the xray-core - # version through the panel UI instead of patching config.json from - # NixOS. See ./3x-ui-migration-notes.md. To re-enable, uncomment - # this service AND the corresponding migrateScript in the `let` - # block above. - # - # "migrate-3xui-reality" = { - # path = [ pkgs.podman ]; - # serviceConfig = { - # Type = "oneshot"; - # RemainAfterExit = true; - # Restart = "on-failure"; - # RestartSec = 5; - # }; - # script = '' - # ${pkgs.podman}/bin/podman exec -i 3xui_app python3 < ${migrateScript} - # ''; - # after = [ "podman-3xui_app.service" ]; - # wantedBy = [ "podman-compose-3x-ui-root.target" ]; - # }; - # Continuous patch: every 10s the timer re-injects the public - # reality fields into /app/bin/config.json and SIGHUPs xray so the - # panel's GetXrayConfig bug cannot keep auth broken for more than - # one timer interval. The migrate-3xui-reality service above only - # covers first boot; the panel overwrites stream_settings back to - # nested-only within seconds, so a oneshot at container start is - # not enough. - # - # COMMENTED OUT 2026-10-04 — same reason as migrate-3xui-reality - # above. Uncomment this service, the corresponding patchScript in - # the `let` block, and the timer below to re-enable the per-10s - # patch loop. See ./3x-ui-migration-notes.md. - # - # "patch-3xui-xray-config" = { - # path = [ pkgs.podman ]; - # serviceConfig = { - # Type = "oneshot"; - # TimeoutSec = 30; - # }; - # script = '' - # ${pkgs.podman}/bin/podman exec -i 3xui_app python3 < ${patchScript} - # ''; - # }; }; # Starts/stops together with all 3x-ui compose resources. targets."podman-compose-3x-ui-root" = { unitConfig.Description = "Root target generated by compose2nix."; wantedBy = [ "multi-user.target" ]; }; - # COMMENTED OUT 2026-10-04 — see ./3x-ui-migration-notes.md and the - # commented-out patch-3xui-xray-config service above. Uncomment to - # re-enable the per-10s config.json patch loop. - # - # timers."patch-3xui-xray-config" = { + # timers."podman-update-3xui_app" = { # wantedBy = [ "timers.target" ]; # timerConfig = { - # OnBootSec = "20s"; - # OnUnitActiveSec = "10s"; - # Persistent = false; - # Unit = "patch-3xui-xray-config.service"; + # OnCalendar = "weekly"; + # Persistent = true; # }; # }; tmpfiles.rules = [ diff --git a/modules/server/nginx.nix b/modules/server/nginx.nix index b42a393..59c1377 100644 --- a/modules/server/nginx.nix +++ b/modules/server/nginx.nix @@ -225,26 +225,5 @@ in networking.firewall.allowedTCPPorts = [ 80 443 - 8443 ]; - - # TCP-level proxy for the 3x-ui xray inbound on 8443. nginx doesn't - # unwrap TLS here — `proxy_pass` just relays opaque TCP bytes between - # the client and the xray inside the 3x-ui container. Podman's - # userspace port-forward mangles the Reality ClientHello, so we go - # via nginx stream (same pattern as VDS uses for port 443) instead: - # client → nginx stream :8443 → 127.0.0.1:15380 → podman → xray :8443. - # Reality auth and TLS are preserved end-to-end. - services.nginx.streamConfig = '' - upstream xray_in_8443 { - server 127.0.0.1:15380; - } - - server { - listen 8443; - proxy_pass xray_in_8443; - proxy_timeout 600s; - proxy_connect_timeout 5s; - } - ''; }