From b0191bc7d18aad12be1340af318afbea07056f3b Mon Sep 17 00:00:00 2001 From: oqyude Date: Sun, 4 Oct 2026 04:04:27 +0300 Subject: [PATCH] otreca vds: pin 3x-ui:v3.8.5 + nginx stream + ssh tailscale-only + patch-3xui-xray-config --- configurations/vds.nix | 7 ++- modules/containers/3x-ui.nix | 117 +++++++++++++++++++++++++++++++++-- modules/server/nginx.nix | 21 +++++++ todo.md | 52 ++++++++++++++++ 4 files changed, 190 insertions(+), 7 deletions(-) create mode 100644 todo.md diff --git a/configurations/vds.nix b/configurations/vds.nix index 4b704e7..dd0a2a8 100644 --- a/configurations/vds.nix +++ b/configurations/vds.nix @@ -42,12 +42,17 @@ }; host.ssh.enable = true; - services.openssh.openFirewall = true; + # SSH is reachable only over Tailscale (not on the public internet). + # This otreca VDS is reached by deploy-rs and by oqyude over the + # tailnet, so exposing 22 to ens3 is pure attack surface. + services.openssh.openFirewall = false; services.tailscale = { enable = true; openFirewall = true; }; + # Open port 22 only on the tailscale interface. + networking.firewall.interfaces.tailscale0.allowedTCPPorts = [ 22 ]; networking = { nameservers = [ "1.1.1.1" diff --git a/modules/containers/3x-ui.nix b/modules/containers/3x-ui.nix index 195a0b9..b8ac2c0 100644 --- a/modules/containers/3x-ui.nix +++ b/modules/containers/3x-ui.nix @@ -24,15 +24,95 @@ let "key.pem" ]; basePorts = [ - # Local-only upstreams for the 3x-ui panel and subscription endpoint. - # The direct Xray inbound remains publicly reachable on 8443. + # 3x-ui panel + subscription endpoint on the loopback only. "127.0.0.1:2049:2049/tcp" "127.0.0.1:2096:2096/tcp" - "0.0.0.0:8443:8443/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" ]; # 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"; + # 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). + # 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) + ''; in { # `host."3x-ui"` options are declared in modules/options.nix: they are set @@ -50,8 +130,16 @@ in }; oci-containers = { backend = "podman"; - containers."3xui_app" = { - image = "ghcr.io/mhsanaei/3x-ui:latest"; +containers."3xui_app" = { + # Pinned to v3.8.5 — the last release before the panel added the + # nested `realitySettings.settings` block for new post-quantum + # fields that its own GetXrayConfig then strips on every regenerate. + # Both 3.8.5 and 3.9.0 reproduce the bug; we work around it with + # migrate-3xui-reality.service, which moves the affected fields + # to the top level of `realitySettings` in the DB so they survive + # the panel's delete() of the nested block. The migration runs + # once on every container start, idempotently. + image = "ghcr.io/mhsanaei/3x-ui:v3.8.5"; environment = { "XRAY_VMESS_AEAD_FORCED" = "false"; "XUI_ENABLE_FAIL2BAN" = "true"; @@ -84,10 +172,27 @@ in TimeoutSec = 300; }; script = '' - podman pull ghcr.io/mhsanaei/3x-ui:latest + podman pull ghcr.io/mhsanaei/3x-ui:v3.8.5 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. + "migrate-3xui-reality" = { + path = [ pkgs.podman ]; + serviceConfig = { + Type = "oneshot"; + RemainAfterExit = true; + }; + script = '' + ${pkgs.podman}/bin/podman exec -i 3xui_app python3 ${migrateScript} || true + ''; + after = [ "podman-3xui_app.service" ]; + wantedBy = [ "podman-compose-3x-ui-root.target" ]; + }; }; # Starts/stops together with all 3x-ui compose resources. targets."podman-compose-3x-ui-root" = { diff --git a/modules/server/nginx.nix b/modules/server/nginx.nix index 59c1377..b42a393 100644 --- a/modules/server/nginx.nix +++ b/modules/server/nginx.nix @@ -225,5 +225,26 @@ 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; + } + ''; } diff --git a/todo.md b/todo.md new file mode 100644 index 0000000..0166e8d --- /dev/null +++ b/todo.md @@ -0,0 +1,52 @@ +# Состояние и следующий шаг (после /compact) + +## Где мы +- В `modules/containers/3x-ui.nix`: + - `patchScript` (костыльник patcher) **заменён** на `migrateScript` — идемпотентная миграция БД, перемещает `publicKey/fingerprint/serverName/spiderX/mldsa65Verify` из nested `realitySettings.settings` в **top-level** `realitySettings` + - systemd-сервис `patch-3xui-xray-config` **заменён** на `migrate-3xui-reality` (oneshot, after=`podman-3xui_app.service`) + - **timer удалён** (больше не нужен) + - Image остался `v3.8.5` (но v3.8.5 и v3.9.0 имеют один баг — раздельные поля на top-level в БД решают) +- Комментарий к image обновлён — объясняет что баг в обоих версиях, лечится миграцией + +## Что делать (А → Б → В) + +### A. Доразвернуть текущий rebuild +Текущая команда зависла с `nixos-rebuild-switch-to-configuration.service was already loaded` (предыдущий прогон не очистился). +Что сделать: +```bash +sudo systemctl stop nixos-rebuild-switch-to-configuration.service 2>/dev/null +sudo pkill -f switch-to-configuration 2>/dev/null +sleep 3 +sudo nixos-rebuild switch +``` + +### Б. Проверить что миграция работает +После успешного rebuild и старта контейнера: +1. `sudo systemctl status migrate-3xui-reality` — должен быть `inactive (dead)` (success) +2. `sudo journalctl -u migrate-3xui-reality --since 5m` — должно быть `migrated=0` (idempotent, второй раз) +3. `sudo podman exec 3xui_app python3 -c "import json; c=json.load(open('/app/bin/config.json')); ib=c['inbounds'][1]; rs=ib['streamSettings']['realitySettings']; print(rs.get('publicKey','MISSING')[:25])"` — должно быть `K0Ra5yH4Ll_bB-dBmwZcPvYxr` +4. `sudo podman exec 3xui_app python3 -c "import sqlite3,json; c=sqlite3.connect('/etc/x-ui/x-ui.db'); rs=json.loads(c.execute('SELECT stream_settings FROM inbounds WHERE id=53').fetchone()[0])['realitySettings']; print('top publicKey:', 'YES' if rs.get('publicKey') else 'NO'); print('nested settings:', rs.get('settings','NONE'))"` — должно быть `YES` и `NONE` + +### В. Итоговый commit +После успешного теста: +```bash +cd /etc/nixos +sudo git add modules/containers/3x-ui.nix +sudo git commit -m "3x-ui: migrate Reality fields to top-level (root-cause fix for panel config-gen bug)" +``` + +## Контекст +- Container: `ghcr.io/mhsanaei/3x-ui:v3.8.5`, xray 26.9.30 +- nginx stream :8443 → podman-proxy :15380 → xray :8443 (ранее подтверждена) +- DB уже мигрирована (поля на top-level) — миграция только идемпотентно проверяет на повторе +- Тест с 5 регенерациями подтвердил: publicKey/fingerprint/spiderX/mldsa65Verify остаются в config.json +- Otreca (VDS) — ssh затянут на tailscale-only, deploy-rs деплоит + +## Ключевые файлы +- `/etc/nixos/modules/containers/3x-ui.nix` — NixOS-модуль (требует deploy) +- `/etc/nixos/configurations/vds.nix` — VDS ssh на tailscale (уже закоммичен и задеплоен) +- `/mnt/services/nodes/sapphira/3x-ui/db/x-ui.db` — БД панели (миграция уже применена) +- `/etc/nixos/deploy/default.nix` — deploy-rs конфиг (otreca → vds config) + +## Известное замечание +Внутри podman exec bash tool убивает backgrounded процессы. Для тестов с долгоживущим xray-клиентом используй systemd-run --scope или делай inline тесты (без background). Для проверки config.json хватает быстрого `podman exec python3 -c`.