diff --git a/.agent/checkpoints.json b/.agent/checkpoints.json index ef6c32f..f9989bf 100644 --- a/.agent/checkpoints.json +++ b/.agent/checkpoints.json @@ -25,12 +25,13 @@ { "id": "T8", "title": "C3: убрать сервис автообновления 3x-ui", "status": "pending", "origin": "user:direct" }, { "id": "T9", "title": "C4: записать, что ядро Xray — состояние панели, а не Nix", "status": "pending", "origin": "user:direct" }, { "id": "T10", "title": "C5: решить судьбу reality443Forwarding", "status": "pending", "origin": "user:direct" }, - { "id": "T11", "title": "D1: пробросы роутера — главный недостающий инвариант", "status": "pending", "origin": "user:direct" }, - { "id": "T12", "title": "D2: зафиксировать 100.64.0.0 как Tailscale-адрес sapphira", "status": "pending", "origin": "user:direct" }, + { "id": "T11", "title": "D1: пробросы роутера — главный недостающий инвариант", "status": "completed", "origin": "user:direct" }, + { "id": "T12", "title": "D2: зафиксировать 100.64.0.0 как Tailscale-адрес sapphira", "status": "completed", "origin": "user:direct" }, { "id": "T13", "title": "D3: убрать мёртвое правило firewall на sapphira", "status": "pending", "origin": "user:direct" }, { "id": "T14", "title": "E1: написать AGENTS.md в корне (с metaagent-шапкой)", "status": "completed", "origin": "user:direct" }, { "id": "T15", "title": "E2: выбрать проверки, которые заменят половину инвариантов", "status": "pending", "origin": "user:direct" }, - { "id": "T16", "title": "E3: судьба 15 закомментированных модулей", "status": "pending", "origin": "user:direct" } + { "id": "T16", "title": "E3: судьба 15 закомментированных модулей", "status": "pending", "origin": "user:direct" }, + { "id": "T17", "title": "Review 2026-10-10 followup: B1 (R1.4 врёт), I1–I3 sync", "status": "pending", "origin": "user:direct (post-review)", "priority": "high", "fixes": ["T12", "T1", "T13", "T16"], "report": ".agent/reviews/2026-10-10-review-dev-diff-vs-16644fc.md" } ], - "last_updated": "2026-10-09T20:30" + "last_updated": "2026-10-10T22:30:00" } diff --git a/.agent/decisions/0002-backups-external.md b/.agent/decisions/0002-backups-external.md new file mode 100644 index 0000000..02570ba --- /dev/null +++ b/.agent/decisions/0002-backups-external.md @@ -0,0 +1,70 @@ +# Backups — external to Nix, not declared in this repo + +**Status:** documented (not complete — awaiting answer to open question 5.6) +**Date:** 2026-10-09 +**Related:** open question 5.6 in `.agent/roadmap/sources.md`, task T5/B2 in `.agent/tasks/manifest.json` + +## TL;DR + +Резервные копии критичных сервисов sapphira (PostgreSQL, Immich, Nextcloud, Gitea, Homebox, Navidrome, Calibre-Web, 3x-ui-панель, TapeRotation) **не управляются через Nix-конфиг**. Они выполняются внешней по отношению к этому репозиторию системой, спецификация которой здесь не зафиксирована. + +## Что декларируется в Nix (для ориентира) + +| Сервис | Данные | Где лежат (внешний диск) | Guard есть (T4) | +|---|---|---|---| +| PostgreSQL | БД | `xlib.dirs.services-mnt-folder/postgresql` | ✓ | +| Immich | медиа + метаданные | `xlib.dirs.services-mnt-folder/immich` | ✓ | +| Nextcloud | файлы + БД | `xlib.dirs.services-mnt-folder/nextcloud` | ✓ | +| Gitea | git-репы + БД | `${xlib.dirs.services-mnt-folder}/gitea` | ✓ | +| Homebox | SQLite | `${xlib.dirs.services-mnt-folder}/homebox` | ✓ | +| Navidrome | плейлисты + метаданные | `${xlib.dirs.server-home}/Music` | ✓ | +| Calibre-Web | библиотека + БД | `${xlib.dirs.services-mnt-folder}/calibre-web(-library)` | ✓ | +| 3x-ui панель | конфиг + sqlite-БД | `${services-nodes-folder}/${hostname}/3x-ui` | ✓ | +| TapeRotation | SQLite + uploads | `${services-nodes-folder}/${hostname}/tape-rotation` | ✓ | +| Syncthing | config + data | `${xlib.dirs.server-home}` | ✓ | + +Все эти пути — на `xlib.dirs.server-home` (т.е. на `/home/oqyude/External`, реальная ФС, не bind-mount). Storage guard (T4) гарантирует, что сервисы не стартуют на пустой БД, если External не смонтирован — это уменьшает окно для silent data corruption, но **не заменяет бэкапы**. + +## Что НЕ декларируется в Nix (нужно уточнить) + +Открытый вопрос 5.6: «Где бэкапы и как проверять?» + +Конкретно неизвестно: +- **Где физически** лежат бэкапы (другой диск? NAS? offsite? S3?) +- **Какая схема** (full / incremental / snapshot / pg_dump / tar / rsync / btrfs-send) +- **Какая частота** и **какой retention** (30 дней? 90? год?) +- **Какие сервисы** покрыты (все 9 из таблицы выше? только PostgreSQL?) +- **Как проверять восстановление** (drill раз в квартал? никогда?) +- **Шифрование** бэкапов at-rest (gpg? LUKS? clear?) +- **Offsite-копия** (есть? куда?) + +## Что из Nix-репо с этим связано + +- `modules/containers/tape-rotation.nix` — панель **трекинга** физических tape-картриджей, не система бэкапов. База данных SQLite в `services-nodes-folder/.../tape-rotation/db/`. Сама панель бесполезна без процесса, который физически пишет на ленты. +- `lib/xlib/helpers.nix:mkServiceStorage` — описывает, как сервисы размещают данные на External, но **не описывает**, как эти данные бэкапятся. +- `R1.2` (storage guard) — защищает от «сервис стартанул на пустой БД», но **не от** «External-диск умер, и бэкапов тоже нет». + +## Что нужно сделать (когда появится ответ на 5.6) + +1. **Описать систему бэкапов** в этом файле (или новом `0002-backups.md`): + - Где лежат + - Какой retention + - Как проверяются +2. **Если есть скрипты** — добавить их в `modules/server/` или `pkgs/` с явным комментарием «backup script — not auto-tested, owner responsibility». +3. **Добавить CI-check** (T15): по возможности автоматически проверять, что бэкап-каталог не пустой (если это определимо из Nix-репо). +4. **Если retention > 30 дней** — рассмотреть R1.x-инвариант «бэкапы верифицируются N раз в год», чтобы это не «забывалось». + +## Текущее состояние (по умолчанию) + +> В этом репозитории **не декларируется** ни одна бэкап-стратегия. +> Если бэкапы есть — они живут вне `S:/Git/nixos`. +> Если их нет — это риск, который должен явно зафиксировать владелец +> (открытый вопрос 5.6 в `.agent/roadmap/sources.md`). + +--- + +**См. также:** +- `.agent/roadmap/sources.md` — открытый вопрос 5.6 +- `.agent/rules/project-rules.md` — R1.2 (storage guard) +- `.agent/tasks/manifest.json` — T5/B2 (этот документ) +- ADR-0001 — sops-пути (для секретов бэкапов, если есть) diff --git a/.agent/decisions/index.json b/.agent/decisions/index.json index 7333a54..dd76e86 100644 --- a/.agent/decisions/index.json +++ b/.agent/decisions/index.json @@ -1,6 +1,6 @@ { "version": "3.0.0", - "updated_at": "2026-10-09T20:30", + "updated_at": "2026-10-09T22:30", "decisions": [ { "id": "0001", @@ -9,6 +9,34 @@ "date": "2026-10-09", "file": ".agent/decisions/0001-sops-secrets-paths.md", "tags": ["sops", "secrets", "security", "invariant"] + }, + { + "id": "0002", + "title": "Backups — external to Nix repo, awaiting 5.6 answer", + "status": "draft", + "date": "2026-10-09", + "file": ".agent/decisions/0002-backups-external.md", + "tags": ["backups", "documentation", "T5"], + "task": "T5", + "blocked_by": [ + "user: open question 5.6 (where are backups, how are they verified)" + ] + } + ], + "proposals": [ + { + "id": "T3-A", + "title": "nftables fix для otreca (R1.6: явная policy drop, убрать firewall/nftables конфликт, SSH только на tailscale0)", + "status": "proposed", + "date": "2026-10-09", + "files": [ + ".agent/decisions/proposals/vds-nftables-fix.md" + ], + "tags": ["nftables", "vds", "otrecа", "security", "R1.6", "T3"], + "task": "T3", + "blocked_by": [ + "user: SSH-доступ на otreca должен быть восстановлен до apply" + ] } ] } diff --git a/.agent/decisions/notes/3x-ui-xray-26.9.md b/.agent/decisions/notes/3x-ui-xray-26.9.md new file mode 100644 index 0000000..d5b7dd6 --- /dev/null +++ b/.agent/decisions/notes/3x-ui-xray-26.9.md @@ -0,0 +1,209 @@ +# 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. + +## Вердикт + +Миграция 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). Повторять миграцию без отдельной задачи запрещено. \ No newline at end of file diff --git a/.agent/decisions/proposals/vds-nftables-fix.md b/.agent/decisions/proposals/vds-nftables-fix.md new file mode 100644 index 0000000..1e00825 --- /dev/null +++ b/.agent/decisions/proposals/vds-nftables-fix.md @@ -0,0 +1,236 @@ +# Proposal: nftables ruleset для otreca (задача T3 / A3) + +**Статус:** proposed (не применён) +**Дата:** 2026-10-09 +**Связано с:** T3 (manifest), R1.6 (project-rules.md), `configurations/vds.nix:67-92` + +## Контекст + +`configurations/vds.nix` декларирует nftables-ruleset, но: + +1. **Нет финальной политики** на `chain input` (line 75-90) — implicit `accept` + на «всё остальное». Это проявление R1.6 «явная финальная политика требуется». +2. **`networking.firewall.enable = true` (line 68) и `networking.nftables.enable = true` (line 71)** + включены одновременно. R1.6 явно фиксирует это как конфликт + («проверить, кто реально владеет ruleset'ом, перед правкой»). +3. **SSH на 22 открыт только через `networking.firewall.interfaces.tailscale0.allowedTCPPorts = [ 22 ]`** + (line 55). Если Tailscale-демон на otreca упал ИЛИ nftables-ruleset + перезаписал `firewall.interfaces.*` правила, SSH-мёртв. + +## Что произошло при попытке диагностики (2026-10-09) + +- `ssh otreca-tailscale` из WSL: **Name or service not known** (нет Tailscale magic DNS в WSL) +- `ssh oqyude@100.64.1.0` (Tailscale IP напрямую): **Connection timed out** (5s) +- `ping 100.64.1.0`: **no response** +- `ssh oqyude@109.248.161.5` (public IP, port 22): **Connection timed out** +- `ping 109.248.161.5`: **OK** (33ms) — хост жив +- Xray-трафик sapphira → otreca `109.248.161.5:443` (XHTTP): **работает** (journal sapphira) + +То есть otreca отвечает по 443 (Xray REALITY inbound) и по ICMP, но **port 22 +полностью недоступен**. Это и есть T3-баг, видимый снаружи: либо Tailscale-демон +на otreca упал, либо nftables-ruleset дропает 22 на ens3. + +## Текущий state (vds.nix:67-92) + +```nix +networking = { + firewall = { + enable = true; # ← NixOS-managed firewall (есть allowedTCPPorts и пр.) + allowPing = true; + }; + nftables = { + enable = true; # ← самописный ruleset + ruleset = '' + table inet filter { + chain input { + type filter hook input priority 0; + + # loopback + iif lo accept + + # уже установленные + ct state established,related accept + + # РЕЖЕМ SYN СРАЗУ + tcp flags syn tcp dport {80,443} limit rate 20/second burst 40 packets accept + tcp flags syn tcp dport {80,443} drop + + # остальное по необходимости ← НИКАКОГО "остального" + } + } + ''; + }; + firewall.interfaces.tailscale0.allowedTCPPorts = [ 22 ]; # SSH на Tailscale + ... +}; +services.openssh.openFirewall = false; # SSH не открыт в firewall +services.tailscale.openFirewall = true; # Tailscale открыт в firewall +``` + +## Предлагаемое решение (Вариант A — рекомендую) + +**Идея:** отдать всё nftables, убрать дубликат, добавить явный `policy drop`. + +```nix +networking = { + # Всё управляется nftables ниже; стандартный firewall выключаем, + # чтобы не было конфликта приоритетов (R1.6). + firewall.enable = false; + firewall.allowedTCPPorts = lib.mkForce []; # гарантируем пусто + firewall.interfaces = lib.mkForce {}; # гарантируем пусто + allowPing = true; # ICMP через nftables ниже + + nftables = { + enable = true; + ruleset = '' + table inet filter { + chain input { + type filter hook input priority 0; + policy drop; # ← ЯВНЫЙ final drop (R1.6 fix) + + # loopback + iif lo accept + + # уже установленные / связанные + ct state established,related accept + + # ICMP (нужен для path MTU discovery) + ip protocol icmp accept + + # traceroute + udp dport 33434-33534 accept + + # SSH — ТОЛЬКО на Tailscale (R1.6: не на публичном интерфейсе) + iifname "tailscale0" tcp dport 22 accept + + # Xray REALITY inbound (используется sapphira → otreca как relay) + tcp dport 443 accept + + # log для диагностики (видно в journal: journalctl -k | grep nft-drop) + log prefix "nft-drop: " flags all counter drop + } + } + ''; + }; + + enableIPv6 = false; + interfaces.ens3.useDHCP = true; +}; +``` + +### Что меняется + +| Было | Станет | +|---|---| +| `firewall.enable = true` + `firewall.interfaces.tailscale0.allowedTCPPorts = [22]` | `firewall.enable = false` (всё через nftables) | +| `chain input` без `policy` (implicit accept) | `policy drop;` явно | +| Нет ICMP-правила (работает через `firewall.allowPing = true`) | `ip protocol icmp accept` в ruleset | +| Нет traceroute | `udp dport 33434-33534 accept` | +| `tcp dport {80,443} rate-limit + drop` (только SYN, не остальной TCP) | `tcp dport 443 accept` (только 443, 80 закрыт) | +| Нет `log` правила | `log prefix "nft-drop: " ... drop` для отладки | + +### Что НЕ меняется + +- `services.openssh.openFirewall = false` (остаётся — SSH не открываем через firewall, потому что firewall выключен) +- `services.tailscale.enable = true; openFirewall = true` (Tailscale-интерфейс создаётся и маршрутизируется NixOS, openFirewall не имеет эффекта при `firewall.enable = false` но оставлен для ясности) +- `enableIPv6 = false` +- `interfaces.ens3.useDHCP = true` +- `system.stateVersion = "25.05"` + +## Альтернативы (для полноты) + +### Вариант B — минимальный фикс (только закрыть gap) + +```nix +networking.nftables.ruleset = '' + table inet filter { + chain input { + type filter hook input priority 0; + policy drop; # ← ТОЛЬКО ЭТО + ...остальное как было... + } + } +''; +``` + +- **Плюс:** минимальное изменение. +- **Минус:** не разрешает конфликт `firewall.enable` + `nftables.enable`. Если NixOS при apply добавит правила из firewall-блока после nftables — поведение непредсказуемо. + +### Вариант C — задокументировать, не править + +Дописать в `configurations/vds.nix` комментарий-предупреждение; создать ADR +в `.agent/decisions/0002-nftables-vds-gap.md`. + +- **Плюс:** zero risk, сдвигает проблему. +- **Минус:** проблема остаётся; deploy-rs всё ещё может случайно стереть ruleset при apply. + +## Деплой + +**Предусловие:** SSH-доступ на otreca должен быть восстановлен. Варианты: +- Через VDS-провайдера (KVM/IPMI/serial console) +- Если Tailscale-демон на otreca мёртв, но SSH-ключ уже на месте — попросить + провайдера выполнить `systemctl restart tailscaled` или + `nft flush ruleset && iptables -F` для emergency-разблокировки + +**Команда деплоя** (после восстановления SSH): +```bash +deploy . otreca +# или +nixos-rebuild switch --target-host otreca-tailscale --flake .#otreca +``` + +**Проверка после apply:** +```bash +ssh otreca-tailscale "sudo nft list ruleset | head -30" # видим policy drop + правила +ssh otreca-tailscale "sudo iptables -L" # должно быть пусто +ssh otreca-tailscale "echo OK" # SSH работает +ssh sapphira "curl -m 5 https://media.mediavitrina.ru/generate_204 -o /dev/null -w '%{http_code}\n'" + # Xray REALITY inbound на 443 всё ещё работает +``` + +## Риск и откат + +**Риск:** +- Если восстановление SSH сделано неправильно, можно потерять доступ к otreca + до конца сессии провайдера. **Это самая опасная часть всей задачи** — + не сам nftables-fix, а путь к нему. +- Если в ruleset опечатка и блокирует нужное — после `nixos-rebuild switch` + правила применяются мгновенно. До восстановления SSH-доступа единственный + путь назад — через KVM/IPMI/serial console провайдера. + +**Откат:** +```bash +# Если есть SSH: +ssh otreca-tailscale "sudo nixos-rebuild switch --rollback" + +# Если SSH потерян: +# → KVM/IPMI провайдера → serial console → загрузить предыдущее поколение +# (systemd-boot: выбрать в GRUB; grub: тоже) +``` + +## Чеклист перед apply + +- [ ] SSH на otreca восстановлен (через Tailscale или KVM) +- [ ] Локальный smoke-test: `nix build .#nixosConfigurations.otreca.config.system.build.toplevel --dry-run` — зелёный +- [ ] Включена serial console в GRUB (если ещё нет) — для emergency recovery +- [ ] Прокатили `nixos-rebuild switch` и проверили: + - [ ] `nft list ruleset` показывает `policy drop` и все ожидаемые правила + - [ ] `iptables -L` пуст + - [ ] SSH через Tailscale работает + - [ ] Xray REALITY на 443 работает (через sapphira как клиент) + - [ ] nginx на 80 (если используется на otreca) — открыт, если нет — закрыт +- [ ] После успешного apply: снять snapshot/отметку «стабильная конфигурация», чтобы иметь точку отката + +## Обратное (если откатимся) + +- Правки только в `configurations/vds.nix:67-92` (ruleset) и + `configurations/vds.nix:55` (firewall.interfaces.* — очищаем) +- Никаких других файлов не трогаем +- Восстановление = revert `git revert` + `nixos-rebuild switch` + +## Связанные задачи + +- T3 (A3 в манифесте) — этот proposal закрывает основную часть +- R1.6 — фиксирует конфликт firewall/nftables и требование явной политики +- Возможный follow-up: добавить CI-check «последнее правило chain input — + policy или явно accept/drop» (кандидат #5 из `analysis-report.md §5`) diff --git a/.agent/reviews/2026-10-10-review-dev-diff-vs-16644fc.md b/.agent/reviews/2026-10-10-review-dev-diff-vs-16644fc.md new file mode 100644 index 0000000..3ac9205 --- /dev/null +++ b/.agent/reviews/2026-10-10-review-dev-diff-vs-16644fc.md @@ -0,0 +1,119 @@ +# Review of dev branch diff vs 16644fc + +**Date:** 2026-10-10 +**Reviewer:** Sisyphus (Sisyphus-Junior + oracle lanes; 3 oracle lanes INCONCLUSIVE due to model infra outage) +**Baseline:** `16644fc metaagent: install v3.0.0, migrate docs/arch/* → .agent/` +**Branch:** `dev` +**Diff:** 33 files changed, 155 insertions(+), 91 deletions(-) +**Scope:** `git diff HEAD` (staged + unstaged) — full audit of work done during `phases.execution = in_progress` + +## Overall Verdict: **FAILED** + +| # | Lane | Type | Verdict | Confidence | Note | +|---|------|------|---------|------------|------| +| 1 | Goal & Constraint | oracle | INCONCLUSIVE | — | All 3 fallback models unavailable (gpt-5.6-sol → gemini-3.1-pro → claude-opus-5) | +| 2 | QA Execution | Sisyphus-Junior | PASS | LOW | Static analysis only; `nix` not installed on Windows host | +| 3 | Code Quality | oracle | INCONCLUSIVE | — | Same model infra issue | +| 4 | Security | oracle | INCONCLUSIVE | — | Same model infra issue | +| 5 | Context Mining | Sisyphus-Junior | **FAIL** | HIGH | 1 BLOCKING + 3 IMPORTANT + 2 MINOR findings | + +Aggregate: 3 INCONCLUSIVE + 1 FAIL + 1 PASS-low. Formally INCONCLUSIVE per +`/review-work` protocol, but **substantively FAILED** due to BLOCKING finding in +lane 5. Lanes 1/3/4 should be re-run once oracle models are reachable again. + +--- + +## Blocking Issues (MUST fix) + +### B1. R1.4 в project-rules.md и AGENTS.md содержит неверный список файлов для `100.64.0.0` (Tailscale-адрес sapphira) +- **Files:** `.agent/rules/project-rules.md:22-24`, `AGENTS.md:18-20` +- **Current (WRONG):** «Используется в `nginx.nix`, `nextcloud.nix` (`trusted_proxies`), `vds/systemd.nix`, **`vds/nginx.nix`**. При смене — править 4 файла.» +- **Actual (`grep -rn '100\.64\.0\.0' --include='*.nix'`):** `home/termux.nix:256`, `modules/server/nextcloud.nix:73`, `modules/server/nginx.nix:109,253`, `modules/vds/systemd.nix:10` +- **Why:** `vds/nginx.nix` no longer has `100.64.0.0` (upstream `server = "100.64.0.0"` was removed in `ef38dc4`). `home/termux.nix:256` has it (added in `958247b soft coding`) but R1.4 doesn't mention it. T12 was marked `completed` in both `checkpoints.json` AND `manifest.json` with stale invariant text. +- **Impact:** Anyone following R1.4 to "edit the 4 files" will open `vds/nginx.nix` (nothing to change) and miss `home/termux.nix:256` (one of 2 live SSH host entries). On Tailscale address change, this silently breaks SSH in `home/termux.nix:255-268`. +- **Fix:** Replace `vds/nginx.nix` → `home/termux.nix` in both lines of R1.4 (project-rules.md and AGENTS.md). Re-open T12 (mark `pending`), re-verify, re-close. + +--- + +## Important Issues (should fix before merge) + +### I1. "15 закомментированных модулей" в документации — фактически 14 +- **Files:** `AGENTS.md:97`, `project-rules.md:97`, `manifest.json:264` (T16 acceptance) +- **Actual (`git show 16644fc:modules/server/default.nix | grep -c '^ # '`):** exactly 14 commented imports. 13 are in `archive/`, but **stirling-pdf.nix** was DELETED in `5dd7a58 nix flake update` (17-line `enable=false` stub; functionality absorbed into `bentopdf.nix` in same commit). **open-webui.nix** was never commented — was at `modules/server/open-webui.nix` (58333d0), migrated to `modules/containers/open-webui.nix`, still active via `modules/server/default.nix:5`. +- **Impact:** Documentation lies about 2 modules. T16 acceptance criterion misstates scope. +- **Fix:** + - Update `modules/server/default.nix:37-40` comment to explain «14 archived, 1 (stirling-pdf) deleted in 5dd7a58, 1 (open-webui) still active in containers/». + - `project-rules.md:97` «15 закомментированных модулей» → «14 закомментированных модулей (1 удалён, 1 активен)». + - Same in `AGENTS.md:97`. + - `manifest.json:264` T16 acceptance criterion — rewrite to reflect real scope. + +### I2. T1 и T13 фактически исправлены, но `manifest.json` всё ещё помечает их как "pending" +- **Files:** `.agent/tasks/manifest.json:22` (T1.status="pending"), `:222` (T13.status="pending") +- **What's done:** + - **T1:** diff fixes `configurations/mobile.nix:12` import (`lib/xlib.nix` → `lib/xlib`). Verified by static analysis: 0 residual `lib/xlib.nix` (with `.nix`) imports in active code. Resolves to `lib/xlib/default.nix`. + - **T13:** diff removes `networking.firewall.allowedTCPPorts = [ 80 443 ]` from `modules/server/nginx.nix`, replaces with R1.3 comment. +- **Why pending:** T1 acceptance criterion #1 requires `nix flake check` to pass on `.#nixOnDroidConfigurations.epral` — never run (`nix` unavailable on Windows host). T13 needs `nix flake check` green on sapphira config too. +- **Fix:** Commit `.ci/checks.sh` (currently untracked), run on atoridu or sapphira, attach output, then mark T1/T13 as `completed` in `manifest.json`. + +### I3. T1 acceptance criterion #1 never executed — `nix flake check` not run +- **Files:** `manifest.json:27` (T1), `project-rules.md:13` (R1.1) +- **What:** `nix` not installed on this Windows host. `.ci/checks.sh` exists (136 lines, implements 3 of 7 candidates from `analysis-report.md §5`) but is **untracked** (`.ci/` directory in `?? .ci/` from `git status`). R1.1 says «Закреплено через `nix flake check`» — no evidence of this in current session. +- **Impact:** T1 fix is correct statically, but acceptance criterion #1 is formally unmet. `.ci/checks.sh` must be committed and executed on any NixOS host before T1 can be marked `completed`. +- **Fix:** Commit `.ci/checks.sh` separately, run on atoridu (`bash .ci/checks.sh` or CI job), attach output to T1. + +--- + +## Minor Issues (non-blocking) + +### M1. R1.3 ссылается на `nginx.nix:225` — stale line number +- **Files:** `project-rules.md:20`, `AGENTS.md:20` +- **Fact:** After diff, `nginx.nix` is 377 lines; `allowedTCPPorts` is gone. Line 225 is now inside the `extraConfig` of `tty.zeroq.su` vhost. +- **Fix:** Remove line number, replace with «nginx.nix (networking.firewall)». + +### M2. R1.2 lists 7 services, 2 of which (n8n, minecraft) are now in archive +- **File:** `project-rules.md:16-17` +- **Fact:** T4 actually covers 12 active services (postgresql, samba, homebox, gitea, navidrome, syncthing, uptime-kuma, immich, nextcloud, calibre-web, 3x-ui, tape-rotation). n8n and minecraft are now in archive. +- **Fix:** Update R1.2 to list the 12 actual services (or split into «active» / «archived»). + +--- + +## Positive Findings (что сделано корректно) + +- **T1 import fix** — `configurations/mobile.nix:12` correctly imports `../lib/xlib` (Nix resolves to `lib/xlib/default.nix`). No residual `lib/xlib.nix` (with `.nix`) anywhere. +- **T4 storage guard** — `mkStorageGuard` defined in `lib/xlib/helpers.nix:180-183`, anchored on `xlib.dirs.server-home` (= `/home/oqyude/External`), correctly avoiding the «bind-mount shares st_dev» trap documented in R1.2. Applied to exactly 12 services: postgresql:27, samba:76, homebox:33, gitea:32, navidrome:33, syncthing:18, uptime-kuma:26, immich:26, nextcloud:207, calibre-web:67, 3x-ui:76, tape-rotation backend:105 + frontend:114. Merge via `//` does not clobber upstream `serviceConfig` (NixOS submodule semantics). +- **T6 3x-ui migration notes** — `.agent/decisions/notes/3x-ui-xray-26.9.md` created (209 lines; original was 201, extended with explicit Verdict section). Verdict at lines 203-209: «Миграция 26.7.x → 26.9.x **провалена**. Причина: изменения в X25519MLKEM768 несовместимы с REALITY-инбаундом». R1.8 added in `project-rules.md:36-40`. +- **T8 3x-ui auto-update** — `podman-update-3xui_app` service and timer block fully removed. Rationale comment remains. +- **T9 R1.8** — `project-rules.md:36-40` explicitly says «версия ядра Xray — состояние UI-панели 3x-ui, не Nix». +- **T11 R1.3** — Router ports wording: 5 ports (22, 80, 443, 8443 xray, 22000 syncthing). T11 marked `completed` in checkpoints.json + manifest.json. +- **T13 nginx** — `networking.firewall.allowedTCPPorts = [ 80 443 ]` removed; replaced with R1.3 comment. +- **T16 archive** — 13 files renamed via `git mv` (`similarity index 100%`, content unchanged). Imports in `modules/server/default.nix:7-41` cleaned. (Issue I1 documents the documentation drift around the count.) +- **sops path compliance (R1.7)** — All 6 nextcloud-spreed-signaling secrets use `config.sops.secrets..path`, no `path = "..."` override. ADR-0001 holds. +- **CI** — `.ci/checks.sh` is well-formed (136 lines, `set -euo pipefail`, balanced bash arrays, proper exit codes). + +--- + +## Architectural Observations (для следующих итераций, не блокеры) + +- **mkStorageGuard** takes `xlib` as a parameter rather than being curried. A `mkGuardedService` wrapper (function that wraps the whole `systemd.services.` block) would be safer — eliminates "forgot to wire it" human error. Refactor opportunity, MINOR. +- **`podman-update-taperotation` in `modules/containers/tape-rotation.nix:144-155`** — oneshot service without timer (timer commented out). Dead code. Either wire up timer or remove service. MINOR. +- **`nextcloud-spreed-signaling` disabled but 6 sops secrets still declared** (nextcloud-talk-secret, internal-secret, hashkey, blockkey, turn-secret, turn-api-key). Safe (sops-nix materializes, but they're unused), tech debt. When the service is re-enabled — secrets already in place. + +--- + +## Recommended Fix Order + +1. **Now (BLOCKING):** Fix R1.4 in `project-rules.md` and `AGENTS.md`: `vds/nginx.nix` → `home/termux.nix`. This is a documented invariant — if it lies, everything that depends on it (future Tailscale changes, new vhosts) goes wrong. +2. **Now (verification gap):** Run `bash .ci/checks.sh` on atoridu or sapphira. If green — mark T1, T2, T13, T8, T9 as `completed` in `manifest.json`. If red — there's a hidden bug in the diff. +3. **Before merge (docs sync):** Update AGENTS.md:97, project-rules.md:97, manifest.json:264 about «15 → 14 + 1 deleted + 1 active». Add rationale in `modules/server/default.nix:37-40`. +4. **Before merge (stale refs):** Remove `nginx.nix:225` → «nginx.nix (networking.firewall)». Update R1.2 from 7 services to 12 actual. +5. **Later (refactor):** Introduce `mkGuardedService` or type-checked wrapper around `mkStorageGuard` to remove the "forgot to attach" failure mode. + +--- + +## INCONCLUSIVE Lanes — Context + +Oracle agents (Goal, Code Quality, Security) failed with `ProviderModelNotFoundError` on all 3 fallback models. This is **infrastructure**, not a diff issue. If re-run is desired, use `task(category="ultrabrain", ...)` or `task(category="unspecified-high", ...)` to route to alternative models. Findings from Context Mining + QA + my own reading of all critical files were sufficient for an actionable verdict — recommend fixing B1, I1–I3 first, then deciding whether to re-run Oracle passes. + +## Worktree Cleanup + +Review worktree at `C:\Users\oqyude\AppData\Local\Temp\opencode\review-dev` (branch `review/dev-baseline`, HEAD 16644fc) was created, used, and removed. Main worktree `S:\Git\nixos` was never modified by the review process. Full diff preserved in main worktree (33 modified + 4 untracked). diff --git a/.agent/rules/project-rules.md b/.agent/rules/project-rules.md index ab19125..d3b1e8b 100644 --- a/.agent/rules/project-rules.md +++ b/.agent/rules/project-rules.md @@ -33,6 +33,11 @@ 7. **sops-пути — через `config.sops.secrets..path`.** Любой `path =` override на sops-блоке делает хардкод-потребителя молча сломанным: rebuild зелёный, сервис стартует, контент пустой. См. ADR-0001. +8. **Версия ядра Xray — состояние UI-панели 3x-ui, не Nix.** Ядро + ставится через UI панели (UI → xray version) и хранится в её + sqlite-БД. Перед любым деплоем/ребутом 3x-ui на sapphira — + проверить версию ядра в панели. Nix декларирует панель (`:latest`), + но не ядро. ### R2. home-manager `Service` ≠ `serviceConfig` @@ -87,7 +92,7 @@ nix build .#nixosConfigurations.<хост>.config.system.build.toplevel --dry-ru | `server.nix:130` | `firewall.enable = false` при 20 сервисах на `0.0.0.0` | Роутер фильтрует, см. R1.3 | | `mobile.nix:95`, `wsl.nix:59` | `stateVersion` 24.05 / 24.11 vs 26.05 | Каждый хост зафиксирован на своей версии | | `users.nix:66` | `uid = if hostname == "sapphira" then 1001 else …` | Костыль под 1000 = удалённый `yuyus`; удалять только после миграции ФС | -| `3x-ui.nix:54` | `image = …:latest` | Панель намеренно latest; ядро Xray — на 26.7.x | +| `3x-ui.nix:54` | `image = …:latest` | Панель намеренно latest; ядро Xray — состояние панели, см. R1.8 | | `3x-ui.nix:33-35` | `reality443Forwarding = true` на VDS | Следствие отката `c8d4a12`; смысл утрачен, см. задачу C5 | | `server/default.nix:33-47` | 15 закомментированных модулей | Отключены осознанно, см. задачу E3 | | `opencode.nix:339` | `systemd.user.services.opencode-web.Service` | `serviceConfig` рендерится в секцию `[serviceConfig]`, systemd молча игнорирует (`c73a698`); см. R2 | diff --git a/.agent/tasks/manifest.json b/.agent/tasks/manifest.json index a8ee556..b65f63e 100644 --- a/.agent/tasks/manifest.json +++ b/.agent/tasks/manifest.json @@ -4,14 +4,14 @@ "target_repo": "S:/Git/nixos", "goal": "Установить metaagent, перенести накопленные данные (AGENTS.md, docs/arch/*) в структуру .agent/.", "project_type": "existing", - "date": "2026-10-09T20:30", + "date": "2026-10-10T22:30", "phases": { "init": "completed", "analyse": "pending", "roadmap": "pending", "design": "skipped", "decomposition": "pending", - "execution": "pending", + "execution": "in_progress", "metastate": "pending", "handoff": "pending" }, @@ -187,7 +187,7 @@ "id": "T11", "title": "D1: пробросы роутера — главный недостающий инвариант", "type": "documentation", - "status": "pending", + "status": "completed", "origin": "user:direct", "depends_on": ["T3"], "acceptance_criteria": [ @@ -201,7 +201,7 @@ "id": "T12", "title": "D2: зафиксировать 100.64.0.0 как Tailscale-адрес sapphira", "type": "documentation", - "status": "pending", + "status": "completed", "origin": "user:direct", "depends_on": [], "acceptance_criteria": [ @@ -265,6 +265,33 @@ ], "files": ["modules/server/default.nix"], "blocks": [] + }, + { + "id": "T17", + "title": "Review 2026-10-10: разобрать B1 (R1.4 врёт), синхронизировать I1–I3", + "type": "review", + "priority": "high", + "status": "pending", + "origin": "user:direct (post-review followup)", + "depends_on": [], + "acceptance_criteria": [ + ".agent/reviews/2026-10-10-review-dev-diff-vs-16644fc.md прочитан целиком", + "B1 (R1.4 в .agent/rules/project-rules.md:22-24 и AGENTS.md:18-20) исправлен: 'vds/nginx.nix' → 'home/termux.nix' в обоих файлах", + "T12 в checkpoints.json И manifest.json откачен в 'pending', после повторной проверки — обратно в 'completed'", + "I1 синхронизирован: AGENTS.md:97, project-rules.md:97, manifest.json:264 (T16) обновлены про '14 archived + 1 deleted (stirling-pdf в 5dd7a58) + 1 active (open-webui в containers/)'", + "I2: T1 и T13 в manifest.json переведены в 'completed' ПОСЛЕ успешного 'nix flake check' на atoridu/sapphira (см. .ci/checks.sh)", + "I3: .ci/checks.sh закоммичен, вывод 'nix flake check' зелёный приложен к T1" + ], + "files": [ + ".agent/reviews/2026-10-10-review-dev-diff-vs-16644fc.md", + ".agent/rules/project-rules.md", + "AGENTS.md", + ".agent/tasks/manifest.json", + ".agent/checkpoints.json", + "modules/server/default.nix" + ], + "fixes": ["T12 (B1: R1.4 stale content)", "T1 (I2: status pending after fix)", "T13 (I2: status pending after fix)", "T16 (I1: count drift in acceptance)"], + "notes": "Создано после /review-work на diff vs 16644fc (33 файла, 155+/91-). 3 Oracle-лейна INCONCLUSIVE по model infra outage; verdict основан на Context Mining (HIGH) + QA (LOW, nix unavailable) + ручном чтении критических файлов. Если Oracle-проход запустить повторно через category=ultrabrain/unspecified-high, и он найдёт новые issues — обновить review и acceptance." } ], "backlog_count": 23, diff --git a/.ci/checks.sh b/.ci/checks.sh new file mode 100644 index 0000000..3516a1b --- /dev/null +++ b/.ci/checks.sh @@ -0,0 +1,136 @@ +#!/usr/bin/env bash +# Pre-commit + CI checks for the nixos flake. +# Runs the most useful invariants from analysis-report.md §5. +# +# Usage: +# .ci/checks.sh # run all +# .ci/checks.sh --no-build # skip nix flake check (faster, no network) +# +# Exit codes: +# 0 — all checks passed +# 1 — at least one check failed (stderr has details) +# +# Checks implemented: +# #2 nix flake check (all outputs evaluate) +# #7 secrets/ files match .sops.yaml path_regex +# #1 no `:latest` in container images (with R1.5 whitelist: 3x-ui is frozen on :latest) +# +# Not yet implemented (candidates from analysis-report.md §5): +# #3 coredns domains ↔ nginx vhosts bidirectional match +# #4 mkServiceStorage consumers have existing External dir +# #5 last nftables chain rule is explicit (drop/reject/policy) +# #6 listen.addr is interface, not network (e.g. 0.0.0.0 is OK, 192.168.0.0/24 is not) + +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "$REPO_ROOT" + +LATEST_ALLOWLIST=( + "ghcr.io/mhsanaei/3x-ui:latest" +) + +SKIP_BUILD=false +for arg in "$@"; do + case "$arg" in + --no-build) SKIP_BUILD=true ;; + *) echo "Unknown arg: $arg" >&2; exit 2 ;; + esac +done + +PASS=0 +FAIL=0 +report() { + if [ "$1" -eq 0 ]; then + echo " PASS: $2" + PASS=$((PASS + 1)) + else + echo " FAIL: $2" >&2 + FAIL=$((FAIL + 1)) + fi +} + +check_nix_flake_check() { + if [ "$SKIP_BUILD" = true ]; then + echo "SKIP: nix flake check (--no-build)" + return 0 + fi + if ! command -v nix >/dev/null 2>&1; then + echo "SKIP: nix not in PATH" + return 0 + fi + echo "Check #2: nix flake check ..." + if nix --extra-experimental-features "nix-command flakes" flake check 2>&1 | tail -50; then + report 0 "nix flake check" + else + report 1 "nix flake check" + fi +} + +check_sops_path_regex() { + echo "Check #7: secrets/ files match .sops.yaml path_regex ..." + local regex + regex=$(awk -F'path_regex:[[:space:]]*' '/path_regex:/ {print $2; exit}' .sops.yaml) + if [ -z "${regex:-}" ]; then + echo " SKIP: no path_regex found in .sops.yaml" + return 0 + fi + local mismatches=() + while IFS= read -r -d '' f; do + if ! printf '%s\n' "$f" | grep -Eqx "${regex}"; then + mismatches+=("$f") + fi + done < <(find secrets -type f -print0 2>/dev/null) + if [ "${#mismatches[@]}" -eq 0 ]; then + report 0 "sops path_regex ($regex)" + else + echo " Files NOT matching $regex:" >&2 + printf ' %s\n' "${mismatches[@]}" >&2 + report 1 "sops path_regex ($regex)" + fi +} + +check_no_latest_images() { + echo "Check #1: no :latest in container images (whitelist allowed) ..." + local latest_lines + latest_lines=$(grep -rn --include='*.nix' -E 'image\s*=\s*"[^"]+:latest"' modules/ 2>/dev/null || true) + if [ -z "$latest_lines" ]; then + report 0 "no :latest images" + return 0 + fi + local latest_images + latest_images=$(printf '%s\n' "$latest_lines" | sed -E 's/.*image\s*=\s*"([^"]+)".*/\1/') + local violations=() + while IFS= read -r img; do + [ -z "$img" ] && continue + local allowed=false + for w in "${LATEST_ALLOWLIST[@]}"; do + if [ "$img" = "$w" ]; then + allowed=true + break + fi + done + if [ "$allowed" = false ]; then + violations+=("$img") + fi + done <<< "$latest_images" + if [ "${#violations[@]}" -eq 0 ]; then + report 0 "no :latest images (whitelist honoured)" + else + echo " Images using :latest (not in whitelist):" >&2 + printf ' %s\n' "${violations[@]}" >&2 + echo " Add to LATEST_ALLOWLIST in .ci/checks.sh if intentional." >&2 + report 1 "no :latest images" + fi +} + +echo "=== nixos flake checks ===" +check_nix_flake_check +check_sops_path_regex +check_no_latest_images +echo "===" +echo "PASS: $PASS FAIL: $FAIL" +if [ "$FAIL" -gt 0 ]; then + exit 1 +fi +exit 0 diff --git a/modules/containers/remnawave.nix b/archive/containers/remnawave.nix similarity index 100% rename from modules/containers/remnawave.nix rename to archive/containers/remnawave.nix diff --git a/modules/server/coturn.nix b/archive/server-modules/coturn.nix similarity index 100% rename from modules/server/coturn.nix rename to archive/server-modules/coturn.nix diff --git a/modules/server/mealie.nix b/archive/server-modules/mealie.nix similarity index 100% rename from modules/server/mealie.nix rename to archive/server-modules/mealie.nix diff --git a/modules/server/memos.nix b/archive/server-modules/memos.nix similarity index 100% rename from modules/server/memos.nix rename to archive/server-modules/memos.nix diff --git a/modules/server/minecraft.nix b/archive/server-modules/minecraft.nix similarity index 100% rename from modules/server/minecraft.nix rename to archive/server-modules/minecraft.nix diff --git a/modules/server/n8n.nix b/archive/server-modules/n8n.nix similarity index 100% rename from modules/server/n8n.nix rename to archive/server-modules/n8n.nix diff --git a/modules/server/netdata.nix b/archive/server-modules/netdata.nix similarity index 100% rename from modules/server/netdata.nix rename to archive/server-modules/netdata.nix diff --git a/modules/server/nfs.nix b/archive/server-modules/nfs.nix similarity index 100% rename from modules/server/nfs.nix rename to archive/server-modules/nfs.nix diff --git a/modules/server/rsync.nix b/archive/server-modules/rsync.nix similarity index 100% rename from modules/server/rsync.nix rename to archive/server-modules/rsync.nix diff --git a/modules/server/step-ca.nix b/archive/server-modules/step-ca.nix similarity index 100% rename from modules/server/step-ca.nix rename to archive/server-modules/step-ca.nix diff --git a/modules/server/transmission.nix b/archive/server-modules/transmission.nix similarity index 100% rename from modules/server/transmission.nix rename to archive/server-modules/transmission.nix diff --git a/modules/server/trilium.nix b/archive/server-modules/trilium.nix similarity index 100% rename from modules/server/trilium.nix rename to archive/server-modules/trilium.nix diff --git a/modules/server/zerotier.nix b/archive/server-modules/zerotier.nix similarity index 100% rename from modules/server/zerotier.nix rename to archive/server-modules/zerotier.nix diff --git a/configurations/mobile.nix b/configurations/mobile.nix index c4f99f0..e127931 100644 --- a/configurations/mobile.nix +++ b/configurations/mobile.nix @@ -9,7 +9,7 @@ let # (essentials, users.nix, home-manager, sops-nix, disko, grub2-themes) # and nixpkgs.overlays are skipped so it evaluates under nix-on-droid's # module system (class = "nixOnDroid"). - xlib = import ../lib/xlib.nix { lib = inputs.nixpkgs.lib; }; + xlib = import ../lib/xlib { lib = inputs.nixpkgs.lib; }; nixOnDroidModule = { lib, diff --git a/lib/xlib/helpers.nix b/lib/xlib/helpers.nix index 1bc93d9..79e9cd2 100644 --- a/lib/xlib/helpers.nix +++ b/lib/xlib/helpers.nix @@ -158,4 +158,27 @@ in mkExfatMount mkSymlinks ; + + # Storage guard. Returns a systemd serviceConfig fragment that prevents + # a service from starting when the external storage filesystem + # (`xlib.dirs.server-home` = `/home/$user/External`) is not actually + # mounted. Without this guard, services whose `stateDir` / `dataDir` / + # bind mount source is a subdir of `/mnt/services` would happily start + # on an empty bind mount and create a fresh empty database — silent + # data loss. See T4 (B1) in `.agent/tasks/manifest.json` and R1.2 in + # `.agent/rules/project-rules.md`. + # + # Why this anchor: bind mounts under `/mnt/services` are inside the + # same filesystem as External, so `ConditionPathIsMountPoint` on those + # paths always reports "yes" (st_dev matches) — useless. We anchor on + # `server-home` (the real mount) instead. + # + # Usage in a service module: + # systemd.services..serviceConfig = xlib.helpers.mkStorageGuard xlib; + # or merge with an existing serviceConfig: + # serviceConfig = xlib.helpers.mkStorageGuard xlib // { ...other fields... }; + mkStorageGuard = xlib: { + RequiresMountsFor = [ xlib.dirs.server-home ]; + ConditionPathIsMountPoint = [ "!${xlib.dirs.server-home}" ]; + }; } diff --git a/modules/containers/3x-ui.nix b/modules/containers/3x-ui.nix index 195a0b9..bb9d6d6 100644 --- a/modules/containers/3x-ui.nix +++ b/modules/containers/3x-ui.nix @@ -73,34 +73,24 @@ in systemd = { services = { "podman-3xui_app" = { - serviceConfig.Restart = lib.mkOverride 90 "always"; + serviceConfig = xlib.helpers.mkStorageGuard xlib // { + Restart = lib.mkOverride 90 "always"; + }; partOf = [ "podman-compose-3x-ui-root.target" ]; wantedBy = [ "podman-compose-3x-ui-root.target" ]; }; - "podman-update-3xui_app" = { - path = [ pkgs.podman ]; - serviceConfig = { - Type = "oneshot"; - TimeoutSec = 300; - }; - script = '' - podman pull ghcr.io/mhsanaei/3x-ui:latest - systemctl restart podman-3xui_app.service - ''; - }; + # Auto-update was removed intentionally: the `podman pull` path on the + # auto-update timer caused the declarative Nix state to diverge from the + # runtime state in 2026-10-04 (see + # .agent/decisions/notes/3x-ui-xray-26.9.md). The 3x-ui panel image and + # the Xray core are now updated manually through the panel UI, never + # via Nix. }; # 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" = { - # wantedBy = [ "timers.target" ]; - # timerConfig = { - # OnCalendar = "weekly"; - # Persistent = true; - # }; - # }; tmpfiles.rules = [ (xlib.helpers.mkTmpfile "d" xlib.dirs.services-mnt-folder "0755" "root" "root") (xlib.helpers.mkTmpfile "d" xlib.dirs.services-nodes-folder "0755" "root" "root") diff --git a/modules/containers/tape-rotation.nix b/modules/containers/tape-rotation.nix index 35c08b0..df98f58 100644 --- a/modules/containers/tape-rotation.nix +++ b/modules/containers/tape-rotation.nix @@ -102,14 +102,18 @@ in systemd = { services = { "podman-taperotation-backend" = { - serviceConfig.Restart = lib.mkOverride 90 "always"; + serviceConfig = xlib.helpers.mkStorageGuard xlib // { + Restart = lib.mkOverride 90 "always"; + }; after = [ "podman-network-taperotation_default.service" ]; requires = [ "podman-network-taperotation_default.service" ]; partOf = [ "podman-compose-tape-rotation-root.target" ]; wantedBy = [ "podman-compose-tape-rotation-root.target" ]; }; "podman-taperotation-frontend" = { - serviceConfig.Restart = lib.mkOverride 90 "always"; + serviceConfig = xlib.helpers.mkStorageGuard xlib // { + Restart = lib.mkOverride 90 "always"; + }; after = [ "podman-network-taperotation_default.service" "podman-taperotation-backend.service" diff --git a/modules/server/calibre-web.nix b/modules/server/calibre-web.nix index 0d00c92..78d1732 100644 --- a/modules/server/calibre-web.nix +++ b/modules/server/calibre-web.nix @@ -42,27 +42,30 @@ in # }; }; - systemd.tmpfiles.rules = - xlib.helpers.mkTmpDirs { - dir = libraryDir; - mode = "0755"; - user = "calibre-web"; - group = "calibre-web"; - types = [ - "d" - "Z" - ]; - } - ++ xlib.helpers.mkTmpDirs { - dir = sourceDir; - mode = "0755"; - user = "calibre-web"; - group = "calibre-web"; - types = [ - "d" - "Z" - ]; - }; + systemd = { + tmpfiles.rules = + xlib.helpers.mkTmpDirs { + dir = libraryDir; + mode = "0755"; + user = "calibre-web"; + group = "calibre-web"; + types = [ + "d" + "Z" + ]; + } + ++ xlib.helpers.mkTmpDirs { + dir = sourceDir; + mode = "0755"; + user = "calibre-web"; + group = "calibre-web"; + types = [ + "d" + "Z" + ]; + }; + services.calibre-web.serviceConfig = xlib.helpers.mkStorageGuard xlib; + }; fileSystems = xlib.helpers.mkBindMount { what = sourceDir; diff --git a/modules/server/default.nix b/modules/server/default.nix index e700fa5..30685c8 100644 --- a/modules/server/default.nix +++ b/modules/server/default.nix @@ -34,20 +34,10 @@ ./ttyd.nix ./vtimeline.nix ./uptime-kuma.nix - # ../containers/remnawave.nix - # ./coturn.nix - # ./mealie.nix - # ./memos.nix - # ./minecraft.nix - # ./n8n.nix - # ./netdata.nix - # ./nfs.nix - # ./rsync.nix - # ./step-ca.nix - # ./stirling-pdf.nix - # ./transmission.nix - # ./trilium.nix - # ./zerotier.nix + # 14 modules archived to ../archive/{server-modules,containers}/ on + # 2026-10-09 (task E3 / T16). Reason: each was disabled individually + # over time; restoring requires re-enabling the import AND ensuring + # data mount + secrets are in place. Re-enable in a separate task. ]; # 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 diff --git a/modules/server/gitea.nix b/modules/server/gitea.nix index 520bd7c..ec35caa 100644 --- a/modules/server/gitea.nix +++ b/modules/server/gitea.nix @@ -22,10 +22,13 @@ }; }; - systemd.tmpfiles.rules = xlib.helpers.mkTmpDirs { - dir = config.services.gitea.stateDir; - mode = "0755"; - user = "gitea"; - group = "gitea"; + systemd = { + tmpfiles.rules = xlib.helpers.mkTmpDirs { + dir = config.services.gitea.stateDir; + mode = "0755"; + user = "gitea"; + group = "gitea"; + }; + services.gitea.serviceConfig = xlib.helpers.mkStorageGuard xlib; }; } diff --git a/modules/server/homebox.nix b/modules/server/homebox.nix index 28711ce..82c7d42 100644 --- a/modules/server/homebox.nix +++ b/modules/server/homebox.nix @@ -29,5 +29,7 @@ in }; }; - systemd = storage.systemd; + systemd = storage.systemd // { + services.homebox.serviceConfig = xlib.helpers.mkStorageGuard xlib; + }; } diff --git a/modules/server/immich.nix b/modules/server/immich.nix index 98c4897..7e220c1 100644 --- a/modules/server/immich.nix +++ b/modules/server/immich.nix @@ -19,9 +19,12 @@ }; }; - systemd.tmpfiles.rules = [ - (xlib.helpers.mkTmpfile "z" config.services.immich.mediaLocation "0755" "immich" "immich") - ]; + systemd = { + tmpfiles.rules = [ + (xlib.helpers.mkTmpfile "z" config.services.immich.mediaLocation "0755" "immich" "immich") + ]; + services.immich.serviceConfig = xlib.helpers.mkStorageGuard xlib; + }; users.users.immich.extraGroups = [ "video" diff --git a/modules/server/navidrome.nix b/modules/server/navidrome.nix index 03aae9a..ab90758 100644 --- a/modules/server/navidrome.nix +++ b/modules/server/navidrome.nix @@ -23,10 +23,13 @@ in }; }; }; - systemd.mounts = [ - (xlib.helpers.mkSystemdBind { - what = libraryDir; - where = pointDir; - }) - ]; + systemd = { + mounts = [ + (xlib.helpers.mkSystemdBind { + what = libraryDir; + where = pointDir; + }) + ]; + services.navidrome.serviceConfig = xlib.helpers.mkStorageGuard xlib; + }; } diff --git a/modules/server/nextcloud.nix b/modules/server/nextcloud.nix index a0501f4..3a0bb2f 100644 --- a/modules/server/nextcloud.nix +++ b/modules/server/nextcloud.nix @@ -200,9 +200,12 @@ # }; # }; - systemd.tmpfiles.rules = [ - (xlib.helpers.mkTmpfile "z" config.services.nextcloud.home "0750" "nextcloud" "nextcloud") - ]; + systemd = { + tmpfiles.rules = [ + (xlib.helpers.mkTmpfile "z" config.services.nextcloud.home "0750" "nextcloud" "nextcloud") + ]; + services.nextcloud.serviceConfig = xlib.helpers.mkStorageGuard xlib; + }; environment.systemPackages = [ pkgs.nc4nix # Packaging helper for Nextcloud apps diff --git a/modules/server/nginx.nix b/modules/server/nginx.nix index d7470dd..264bcba 100644 --- a/modules/server/nginx.nix +++ b/modules/server/nginx.nix @@ -365,10 +365,8 @@ in }; }; }; - networking.firewall.allowedTCPPorts = [ - 80 - 443 - ]; + # networking.firewall is intentionally unused on sapphira (R1.3): + # the network boundary is the router, not the host firewall. # Note: the previous vtimeline-htpasswd sops declaration lived here. It # was removed when authelia replaced nginx's auth_basic (see the vtimeline diff --git a/modules/server/postgresql.nix b/modules/server/postgresql.nix index 1b8e1d7..1f0d4d4 100644 --- a/modules/server/postgresql.nix +++ b/modules/server/postgresql.nix @@ -23,5 +23,7 @@ in # postgresqlBackup.enable = true; }; - systemd = storage.systemd; + systemd = storage.systemd // { + services.postgresql.serviceConfig = xlib.helpers.mkStorageGuard xlib; + }; } diff --git a/modules/server/samba.nix b/modules/server/samba.nix index 49cb6f1..8a3e956 100644 --- a/modules/server/samba.nix +++ b/modules/server/samba.nix @@ -72,5 +72,7 @@ in }; }; - systemd = storage.systemd; + systemd = storage.systemd // { + services.samba.serviceConfig = xlib.helpers.mkStorageGuard xlib; + }; } diff --git a/modules/server/syncthing.nix b/modules/server/syncthing.nix index c8989e7..08bd682 100644 --- a/modules/server/syncthing.nix +++ b/modules/server/syncthing.nix @@ -14,4 +14,6 @@ group = "users"; user = "${xlib.device.username}"; }; + + systemd.services.syncthing.serviceConfig = xlib.helpers.mkStorageGuard xlib; } diff --git a/modules/server/uptime-kuma.nix b/modules/server/uptime-kuma.nix index 14cf89b..3a12c9f 100644 --- a/modules/server/uptime-kuma.nix +++ b/modules/server/uptime-kuma.nix @@ -19,9 +19,12 @@ in }; }; - systemd.tmpfiles.rules = [ - (xlib.helpers.mkTmpfile "z" sourceDir "0755" "nobody" "nogroup") - ]; + systemd = { + tmpfiles.rules = [ + (xlib.helpers.mkTmpfile "z" sourceDir "0755" "nobody" "nogroup") + ]; + services.uptime-kuma.serviceConfig = xlib.helpers.mkStorageGuard xlib; + }; fileSystems = xlib.helpers.mkBindMount { what = sourceDir;