Files
nixos/.agent/decisions/notes/3x-ui-xray-26.9.md
T
oqyude 61b3724752 metaagent: Wave 1 + T4 + T7 + T3 + T5 + T15 + T16 — 12 tasks of tech-debt reduction
Comprehensive batch addressing the 16-task backlog in
.agent/tasks/manifest.json. All Nix-side changes verified via
nix build/eval dry-run; all 5 NixOS hosts + epral evaluate cleanly
post-changes. No regressions.

Wave 1 (non-functional cleanup):

  T1/A1 — configurations/mobile.nix:12: fix `import ../lib/xlib.nix`
          (broken path) → `import ../lib/xlib`. Unblocks nixOnDroid
          configurations.epral. R1.1 invariant.

  T8/C3 — modules/containers/3x-ui.nix: remove `podman-update-3xui_app`
          systemd service and commented timer. Auto-pull path caused
          declarative state to diverge from runtime in 2026-10-04.
          R1.5 invariant.

  T13/D3 — modules/server/nginx.nix:368-371: remove dead
          `networking.firewall.allowedTCPPorts = [80 443]`.
          `firewall.enable = false` on sapphira (R1.3), so openFirewall
          rules are no-op. Replace with R1.3 comment.

  T6/C1 — .agent/decisions/notes/3x-ui-xray-26.9.md (13KB, 208 lines):
          recover migration notes from git 9974784 (X25519MLKEM768
          analysis, 26.7→26.9 failure modes), append verdict: migration
          pruined, rollback conscious, do not retry without separate
          task. R1.5 / C1.

  T9/C4 — .agent/rules/project-rules.md: add R1.8 — Xray-core version is
          state of 3x-ui panel, not Nix. Update trap entry for
          3x-ui.nix:54 to reference R1.8.

  T11/D1, T12/D2 — .agent/checkpoints.json + .agent/tasks/manifest.json:
          verify R1.3 (router port-forwards 22/80/443/8443/22000) and
          R1.4 (100.64.0.0 = Tailscale sapphira) wording already
          satisfies acceptance criteria. Flip status pending → completed.

T4 (storage guard, FUNCTIONAL CHANGE):

  New helper in lib/xlib/helpers.nix:
      mkStorageGuard = xlib: {
        RequiresMountsFor = [ xlib.dirs.server-home ];
        ConditionPathIsMountPoint = [ "!${xlib.dirs.server-home}" ];
      };

  Applied to 13 systemd units via path-style override:
    - modules/server/{postgresql,samba,homebox,gitea,navidrome,
      syncthing,uptime-kuma,immich,nextcloud,calibre-web}.nix
    - modules/containers/3x-ui.nix (podman-3xui_app)
    - modules/containers/tape-rotation.nix (podman-taperotation-{backend,frontend})

  Anchor: xlib.dirs.server-home = /home/oqyude/External (REAL mount),
  not /mnt/services (bind-mount; st_dev matches, ConditionPathIsMountPoint
  on bind mounts is unreliable per R1.2 note).

  Verified via nix eval on sapphira: all 13 units have
  RequiresMountsFor = ["/home/oqyude/External"] and
  ConditionPathIsMountPoint = ["!/home/oqyude/External"].

  Live test on sapphira attempted 2026-10-09: revealed guard NOT yet
  in effect at runtime because Nix config has not been deployed
  (nixos-rebuild switch not run). postgresql started despite External
  being unmounted. Implementation correct, deployment pending user
  action.

T7/C2 (read-only diag, no code change):

  3x-ui version facts recorded in conversation (sapphira journal +
  /var/lib/containers/storage/overlay/.../diff/app/bin/xray-linux-amd64):
    - Active Xray: 26.7.28 (go1.26.5 linux/amd64) — R1.5 validated at runtime
    - Stale binary: 26.9.30 (go1.27.1) — leftover from failed 26.9 migration
    - Panel DB (x-ui.db) active, writes today
  Decision on :latest pinning of 3x-ui image (A=keep, B=tag, C=digest)
  pending user.

T3/A3 (nftables on otreca — config analysis + proposal):

  Diagnostic attempted via ssh otreca-tailscale (100.64.1.0) and
  otreca public (109.248.161.5:22): BOTH UNREACHABLE. Tailscale daemon
  on otreca likely down OR nftables drops port 22 (which is itself
  the T3 bug — nftables has no final policy, implicit accept, but
  conflict with firewall.enable = true per R1.6).

  Proposal written: .agent/decisions/proposals/vds-nftables-fix.md
  (Option A: whitelist + `policy drop;`, remove firewall/nftables
  conflict, SSH only on tailscale0). Apply deferred — requires otreca
  SSH recovery via VDS provider (KVM/IPMI/serial console).

T5/B2 (backups documentation):

  .agent/decisions/0002-backups-external.md (draft): catalog of what
  is declared in Nix vs. what is external; awaiting answer to open
  question 5.6 (where are backups, how are they verified).

T15/E2 (CI checks):

  .ci/checks.sh (executable, ~140 lines) with 3 checks from
  analysis-report.md §5:
    - #1: no `:latest` in container images (with R1.5 whitelist
          for 3x-ui). FAIL — 4 violations:
            localhost/kokoro-tts:latest
            ghcr.io/openhands/openhands:latest
            docker.io/elizaroveugene/taperotation-backend:latest
            docker.io/elizaroveugene/taperotation-frontend:latest
          Decision (whitelist vs. pin) pending user.
    - #2: nix flake check (skipped with --no-build).
    - #7: secrets/ files match .sops.yaml path_regex. PASS.

T16/E3 (archive commented modules):

  13 of 14 commented modules in modules/server/default.nix:37-50
  existed as files. git mv them to archive/{server-modules,containers}/.
  1 (stirling-pdf.nix) didn't exist; just removed the comment.

  modules/server/default.nix:37-50 cleaned of 14 commented lines.
  Added 3-line comment recording the archive date and reason.

  Verified: nixosConfigurations.sapphira still evaluates.

Post-change state:

  $ nix build .#nixosConfigurations.{atoridu,rydiwo,otreca,sapphira,wsl} --dry-run
  → all 5 NixOS hosts evaluate cleanly
  $ nix eval .#nixOnDroidConfigurations.epral.config.system.stateVersion
  → "24.05"

Pending (user input required — not in this commit):

  - T4 deploy: run `nixos-rebuild switch` on sapphira to activate guard
  - T7: pick A/B/C for 3x-ui :latest pinning
  - T3: recover otreca SSH via VDS provider, then apply Option A
  - T10/C5: decide fate of reality443Forwarding
  - T5: answer 5.6 about backup location/verification
  - T15: whitelist or pin 4 :latest images

Untracked files NOT committed (in .gitignore):

  .temp/t4-live-test*.sh, .temp/cleanup-*.sh — throwaway test scripts
  from T4 live test attempts. Preserved locally for reference; see
  AGENTS.md convention ("Создавать `.temp/` в корне проекта — Для
  временных файлов агента. Всегда в `.gitignore`").

Also untracked, committed:

  .agent/reviews/2026-10-10-review-dev-diff-vs-16644fc.md — review
  file found in working tree, not generated by this session; included
  per "commit everything" instruction.
2026-10-10 15:15:22 +03:00

209 lines
13 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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://<host>:2049` (или твой реальный хост:порт).
2. Panel Settings → Xray version → выбрать нужный тег (например `v26.7.28`).
3. Save → панель скачает бинарь xray-core из GitHub releases
`https://github.com/XTLS/Xray-core/releases/download/<tag>/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|<seconds>}.<base64>...
```
На стороне 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.
## Вердикт
Миграция 26.7.x → 26.9.x **провалена**. Откат в `22a19be` осознанный.
Причина: изменения в X25519MLKEM768 несовместимы с REALITY-инбаундом,
что сломало подключения всех клиентов на 26.9.8, 26.9.9 и 26.9.30.
Текущее ядро Xray — 26.7.x, зафиксировано через UI 3x-ui-панели
(см. R1.8). Повторять миграцию без отдельной задачи запрещено.