From 99747849d30eb98b35c75bacdc5a24e78ad539c3 Mon Sep 17 00:00:00 2001 From: oqyude Date: Sun, 4 Oct 2026 21:03:48 +0300 Subject: [PATCH] 3x-ui regress --- modules/containers/3x-ui-migration-notes.md | 201 +++++++++++++++ modules/containers/3x-ui.nix | 265 +++++++++++++++----- 2 files changed, 398 insertions(+), 68 deletions(-) create mode 100644 modules/containers/3x-ui-migration-notes.md diff --git a/modules/containers/3x-ui-migration-notes.md b/modules/containers/3x-ui-migration-notes.md new file mode 100644 index 0000000..0f52863 --- /dev/null +++ b/modules/containers/3x-ui-migration-notes.md @@ -0,0 +1,201 @@ +# 3x-ui: миграция Xray-core 26.7 → 26.9 — что известно + +> Дата регресса: 2026-10-04. Цель: зафиксировать всё, что мы нашли про переход +> с ядра 26.7.x на 26.9.x, чтобы будущие сессии не повторяли ту же работу. + +## TL;DR + +- На ядре **26.7.28** всё работало (последний известный рабочий билд). +- На ядре **26.9.x** REALITY-клиенты не подключаются. Это касается и xray-core + 26.9.8, 26.9.9, 26.9.30. +- В 3x-ui **v3.9.0** (latest, released 2026-10-03) панель поставляется с + xray-core **26.9.30**. Ядро меняется в UI панели: Settings → Xray version. +- Текущий код в `modules/containers/3x-ui.nix` зафиксирован на **v3.8.5**, но + в реальности запущен **v3.9.0** (подтянут вручную через `podman pull`, + см. ниже). + +## Как переключать ядро из UI панели + +1. Зайти в панель `https://:2049` (или твой реальный хост:порт). +2. Panel Settings → Xray version → выбрать нужный тег (например `v26.7.28`). +3. Save → панель скачает бинарь xray-core из GitHub releases + `https://github.com/XTLS/Xray-core/releases/download//Xray-linux-64.zip` + в `/app/bin/xray-linux-amd64` и перезапустит xray. +4. Проверить: `podman exec 3xui_app /app/bin/xray-linux-amd64 version`. + +В таблице `nodes` БД панели хранится колонка `xray_version` — это то, что +панель показывает как «текущая установленная версия». + +## Ключевые изменения в Xray 26.9.x (по сравнению с 26.7.x) + +Изменения, которые мы нашли в исходниках, в changelog'е 3x-ui, и в +пользовательских исследованиях (`~/External/Git/temp/xray-research.md`): + +### 1. Обязательный постквантовый обмен ключами (X25519MLKEM768) + +- Начиная с **xray-core 26.9.8** сервер **требует**, чтобы первый key share + клиента был X25519MLKEM768 (постквантовый KEM). Если клиент не отправляет + его первым, соединение разрывается с `authentication failed`. +- Поддержка у клиентов: последние версии xray-core 26.9.x, свежие Mihomo / + Clash. Старые клиенты ломаются. +- Это само по себе объясняет часть регрессов у пользователей. + +### 2. Поведение `minClientVer` + +- В 26.7.x при пустом `minClientVer` xray накладывал встроенный минимум + (примерно 26.3.27). В 26.9.x пустое поле ограничение **не накладывает**. +- Это не баг, но меняет поведение: некоторые клиенты, которые раньше + проходили по умолчанию, теперь проходят без явной отметки версии. + +### 3. Серверный `decryption` и клиентский `encryption` + +- На стороне сервера, в `clients[].settings.decryption`, теперь хранится + спецификация ML-KEM обмена в формате: + ``` + mlkem768x25519plus.{native|xorpub|random}.{1rtt|0rtt|}.... + ``` + На стороне inbound валидируется `s[2]` как число секунд (например `600s`), + на стороне outbound — как `1rtt` или `0rtt`. +- В share-link `vless://` параметр `encryption=...` несёт то же значение в + клиентском формате (`0rtt`/`1rtt`). Парсеры клиентов должны его понимать. +- См. валидацию в `infra/conf/vless.go::VLessOutboundConfig.Build()` и + `infra/conf/vless.go::VLessInboundConfig.Build()` в репозитории XTLS. + +### 4. Поле `mldsa65Verify` / `mldsa65Seed` + +- Это дополнительная постквантовая подпись поверх обычного REALITY (на базе + ML-DSA-65). +- По умолчанию панель кладёт оба поля в `realitySettings`. В share-link + они не передаются — клиент их не использует (они серверные). +- Если клиент сам не использует mldsa65Verify, отсутствие поля в share-link + не блокирует подключение. + +### 5. Поле `serverNames` + +- Должно быть массивом строк. Панель всегда пишет массив, так что для нас это + не источник проблем. + +## Что нашёл 3x-ui (changelog v3.9.0 vs v3.8.5) + +### Главное изменение, влияющее на нас + +> «⚙️ **Xray-core v26.9.30** — stored XDNS masks and WireGuard outbound +> settings are migrated to the new core's shape automatically.» + +То есть в v3.9.0 панель **обязательно поставляется с ядром 26.9.30**, и при +старте выполняет миграции (XDNS, WireGuard). Про миграцию +`realitySettings.settings` **ничего не сказано**. + +### Фиксы v3.9.0, которые теоретически могли бы помочь + +- `#6691` — JSON subscriptions for REALITY with Host SNI no longer ship a + config the client core refuses to start. Это про **подписки**, не про + **config.json inbound'а**. +- `#6694` — Spider settings in a REALITY spiderX query are kept in share + links and JSON subscriptions. Тоже про подписки. +- `#6686` — `config.json` is written after a hot apply, so config backups no + longer upload stale rules. Это про backup, не про сам config-gen. + +**Итог**: ни одного исправления бага `GetXrayConfig` для **config.json** +inbound'а нет ни в v3.8.5, ни в v3.9.0. + +## Подтверждённый баг: `GetXrayConfig` стирает `realitySettings.settings` + +Источник: `internal/web/service/xray.go` в репозитории MHSanaei/3x-ui: + +```go +realitySettings, ok2 := stream["realitySettings"].(map[string]any) +if ok2 { delete(realitySettings, "settings") } +``` + +Панель явно удаляет nested-блок `realitySettings.settings` при каждой +регенерации config.json. Поля, которые там лежат (а их кладёт туда сама же +панель при создании inbound'а в новых билдах): +`publicKey`, `fingerprint`, `serverName`, `spiderX`, `mldsa65Verify`. + +После удаления блока эти поля не появляются на top-level `realitySettings`, +поэтому `/app/bin/config.json` отдаётся xray-core без них, и xray не может +завершить REALITY-handshake для inbound'а. + +**Воспроизведено** на этой системе: для id=42 и id=50 в +`stream_settings` БД **нет** top-level `publicKey`/`fingerprint`/... — +панель переписывает их обратно в nested-only в течение нескольких секунд +после любого изменения inbound'а. + +Тест с маркером `_migration_marker`: записали в `stream_settings` для +id=40 (`enable=0`), через 5 секунд панель его стёрла. + +## Перезапись БД панелью — где и когда + +Панель перезаписывает `inbounds.stream_settings` для **активных** inbounds +(id=42 и id=50 в нашей системе) при любом из: + +- изменении inbound'а через UI / API +- вызове `restartXrayService` API +- периодическом фоновом цикле панели (мы наблюдали в течение секунд) + +Disabled inbound (id=40 в нашей системе) панель не трогает. + +Это значит, что **миграция БД при старте контейнера не решает проблему**: +после первой же фоновой регенерации панель снова стирает миграцию, и +config.json опять без публичных полей. + +## Подтверждённый рабочий workaround (был в HEAD до регресса) + +`patchScript` + systemd timer, который каждые 10 с: + +1. Читает `/etc/x-ui/x-ui.db` (источник истины для панели). +2. Извлекает значения `publicKey`, `fingerprint`, `serverName`, `spiderX`, + `mldsa65Verify` для каждого `vless` inbound'а. Предпочитает top-level, + fallback на nested `settings.{...}`. +3. Читает `/app/bin/config.json` внутри контейнера. +4. Для каждого inbound'а в config.json, матчит по `port` к БД, и если + каких-то полей нет или они отличаются — вписывает их. +5. Атомарно переписывает config.json (через `os.replace` на + `config.json.tmp`) — чтобы не было torn-write при гонке с записью + панели. +6. Шлёт SIGHUP всем процессам `xray-linux-amd64` внутри контейнера, чтобы + xray перечитал config.json в памяти (без разрыва активных соединений). + +Это перекрывает баг панели, потому что правка идёт в **выходной артефакт** +(`/app/bin/config.json`), а не в БД. Панель может писать туда же, но +следующий тик таймера (через ≤10 с) снова всё поправит. + +## Регресс кода — что сделано + +Файл `modules/containers/3x-ui.nix` откатан к чистому виду: + +- `image = "ghcr.io/mhsanaei/3x-ui:v3.9.0"` — соответствует реально + запущенному контейнеру (latest от 2026-10-03). +- `migrateScript`, `patchScript`, `migrate-3xui-reality.service`, + `patch-3xui-xray-config.service`, `patch-3xui-xray-config.timer` — + закомментированы. Никаких внешних патчей config.json из NixOS больше не + делается. +- Ядро xray-core теперь переключается **только через UI панели**. +- В комментарии к image записано предупреждение про баг `GetXrayConfig` и + рабочий workaround, чтобы будущие сессии не переизобретали. + +## Полезные ссылки + +- Changelog 3x-ui v3.9.0: https://github.com/MHSanaei/3x-ui/releases/tag/v3.9.0 +- Все релизы 3x-ui: https://github.com/MHSanaei/3x-ui/releases +- Все релизы xray-core: https://github.com/XTLS/Xray-core/releases +- `infra/conf/vless.go` в xray-core — парсинг server-side decryption / + client-side encryption (`mlkem768x25519plus.*.*.*`). +- `internal/web/service/xray.go` в 3x-ui — `delete(realitySettings, "settings")`, + источник бага. +- Этот документ — `modules/containers/3x-ui-migration-notes.md`. +- Исследование пользователя — `~/External/Git/temp/xray-research.md`. + +## Что делать дальше (когда понадобится) + +1. Если после переключения ядра через UI панель работает и xray-core + показывает `26.7.28` через `podman exec 3xui_app /app/bin/xray-linux-amd64 + version` — все готово, никакого кода менять не нужно. +2. Если панель всё равно ломает config.json даже на 26.7.x (мы не видели + такого, но возможно после какого-то будущего обновления панели) — + раскомментировать `patchScript` + service + timer в файле и сделать + `nixos-rebuild switch`. +3. Если нужна поддержка нескольких ядер одновременно (например, test + env) — выделить отдельный контейнер с зафиксированной версией через + `containers."3xui_test"` с отдельным volume на DB. \ No newline at end of file diff --git a/modules/containers/3x-ui.nix b/modules/containers/3x-ui.nix index f9b93c8..2324836 100644 --- a/modules/containers/3x-ui.nix +++ b/modules/containers/3x-ui.nix @@ -47,6 +47,14 @@ let # 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()` @@ -71,48 +79,132 @@ let # 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) - ''; + # + # 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) + # ''; in { # `host."3x-ui"` options are declared in modules/options.nix: they are set @@ -131,15 +223,16 @@ in oci-containers = { backend = "podman"; 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"; + # 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"; environment = { "XRAY_VMESS_AEAD_FORCED" = "false"; "XUI_ENABLE_FAIL2BAN" = "true"; @@ -187,31 +280,67 @@ containers."3xui_app" = { # 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. - "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" ]; - }; + # + # 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" ]; }; - # timers."podman-update-3xui_app" = { + # 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" = { # wantedBy = [ "timers.target" ]; # timerConfig = { - # OnCalendar = "weekly"; - # Persistent = true; + # OnBootSec = "20s"; + # OnUnitActiveSec = "10s"; + # Persistent = false; + # Unit = "patch-3xui-xray-config.service"; # }; # }; tmpfiles.rules = [