mirror of
https://github.com/oqyude/nixos.git
synced 2026-10-07 20:47:14 +03:00
Compare commits
13
Commits
b88c8ebce0
..
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
75433e2af7 | ||
|
|
7fd1736f1d | ||
|
|
58333d0257 | ||
|
|
5e9641602a | ||
|
|
b2718fd1e7 | ||
|
|
534fa429e1 | ||
|
|
698a1afaf7 | ||
|
|
14c91e68a4 | ||
|
|
c73a698857 | ||
|
|
c8d4a12a73 | ||
|
|
c854b2cc6d | ||
|
|
22a19be1b6 | ||
|
|
99747849d3 |
@@ -0,0 +1,137 @@
|
|||||||
|
# AGENTS.md
|
||||||
|
|
||||||
|
NixOS-конфиг домашнего флота. 6 NixOS-хостов + Android (`nix-on-droid`).
|
||||||
|
|
||||||
|
Этот файл — то, что агент должен прочитать **до** первого изменения. Если задача
|
||||||
|
выглядит так, что требует сломать что-то из «Подтверждённых инвариантов» или
|
||||||
|
«Ловушек» ниже — остановиться и спросить.
|
||||||
|
|
||||||
|
## Архитектура (30 секунд)
|
||||||
|
|
||||||
|
```
|
||||||
|
flake.nix
|
||||||
|
├── configurations/ ← реестр хостов (1 запись = 1 машина)
|
||||||
|
│ ├── default.nix ← hosts + xlibLib + mkSystem
|
||||||
|
│ ├── <host>.nix ← модульное тело хоста
|
||||||
|
│ └── hardware/<host>.nix
|
||||||
|
├── home/ ← home-manager (per device-type)
|
||||||
|
├── modules/
|
||||||
|
│ ├── options.nix ← кросс-модульные опции
|
||||||
|
│ ├── default.nix ← defaultModule + strictModule (для nix-on-droid)
|
||||||
|
│ ├── essentials/ ← packages, services, settings, ssh, shell, systemd-routines
|
||||||
|
│ ├── desktop/, server/, server/├── vds/, wsl/, containers/, termux/, other/
|
||||||
|
├── lib/
|
||||||
|
│ ├── mkSystem.nix ← nixosSystem + specialArgs(xlib, inputs)
|
||||||
|
│ └── xlib/ ← чистые данные: devices, dirs, helpers
|
||||||
|
├── overlays/, pkgs/, deploy/, secrets/ (sops)
|
||||||
|
└── .sops.yaml ← один age-ключ на secrets/<name>.(yaml|json|env|ini)
|
||||||
|
```
|
||||||
|
|
||||||
|
`xlib` (в `lib/xlib/`) — чистые данные: identity (`device`), capability flags,
|
||||||
|
директории, helper'ы. Передаётся в каждый модуль через `specialArgs`. Конфиг не
|
||||||
|
может переопределить `xlib` — единственная точка изменения это `configurations/default.nix`.
|
||||||
|
|
||||||
|
## Хосты
|
||||||
|
|
||||||
|
| Attr / имя | device.type | Роль | Деплой | Примечание |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `default` (nixos) | minimal | Шаблон / минималка | — | hostname `"nixos"` |
|
||||||
|
| `atoridu` | primary | Основной десктоп | — | xanmod |
|
||||||
|
| `rydiwo` | secondary | Ноутбук Chuwi MiniBook (xanmod, NTFS) | deploy-rs | `stateVersion 26.05` |
|
||||||
|
| `otrecа` | vds | VPS, SSH только по Tailscale | deploy-rs | nftables, DHCP, no firewall в NixOS |
|
||||||
|
| `sapphira` | server | Домашний сервер (белый IP через роутер) | deploy-rs | `firewall.enable = false` намеренно |
|
||||||
|
| `wsl` | wsl | WSL NixOS на vetymae | — | nixos-wsl module |
|
||||||
|
| `epral` | termux | Android (`nix-on-droid`) | — | через `mobile.nix`, отдельный модульный путь |
|
||||||
|
|
||||||
|
`device.type` ∈ { minimal, primary, secondary, server, vds, wsl, termux }.
|
||||||
|
`modules/defaultModule` импортирует `modules/<type>/` через `lib.optional
|
||||||
|
(!isDesktop && type != "minimal") (./. + "/${type}")`.
|
||||||
|
|
||||||
|
## Подтверждённые инварианты
|
||||||
|
|
||||||
|
1. **Все `outputs` флейка должны вычисляться.** `configurations/mobile.nix:12`
|
||||||
|
импортировал несуществующий `lib/xlib.nix` — был сломан, `epral` не
|
||||||
|
собирался. Зафиксировать через `nix flake check`.
|
||||||
|
2. **Носитель данных (`/home/oqyude/External`) обязан быть смонтирован** до
|
||||||
|
старта `postgresql`, `n8n`, `samba`, `homebox`, `minecraft`, `3x-ui`,
|
||||||
|
`tape-rotation`. `mkServiceStorage` даёт `bind,x-systemd.automount,nofail`
|
||||||
|
— без guard'а сервис стартует на пустой БД. → todo B1.
|
||||||
|
3. **Сетевая граница sapphira — роутер.** `firewall.enable = false` намеренно.
|
||||||
|
Роутер пробрасывает ровно 5 портов: **443, 80, 22000 (syncthing), 8443
|
||||||
|
(xray), 22 (ssh)**. `nginx.nix:225` (`allowedTCPPorts = [80 443]`) мёртв.
|
||||||
|
`openFirewall`/`allowedTCPPorts` на sapphira не имеют эффекта.
|
||||||
|
5. **`100.64.0.0` = Tailscale-адрес sapphira**, назначен вручную. Не сеть, не
|
||||||
|
ошибка. Используется в `nginx.nix`, `nextcloud.nix` (`trusted_proxies`),
|
||||||
|
`vds/systemd.nix`, `vds/nginx.nix`. При смене — править 4 файла.
|
||||||
|
4. **3x-ui заморожен.** Панель на последней версии (образ `:latest` → запинить),
|
||||||
|
ядро Xray на 26.7.x. Миграция на 26.9.x провалена. Обходные скрипты (тimer,
|
||||||
|
migrateScript) отключены осознанно. **Не** обновлять ядро через панель без
|
||||||
|
записи в `docs/arch/notes/3x-ui-xray-26.9.md`.
|
||||||
|
6. **nftables на VDS требует явной финальной политики.** Текущий ruleset
|
||||||
|
(`vds.nix:73-91`) — без явного последнего правила и без `policy` → неявный
|
||||||
|
accept. На otreca одновременно `nftables.enable = true` и `firewall.*` —
|
||||||
|
проверить, кто реально владеет ruleset'ом, перед правкой.
|
||||||
|
|
||||||
|
## Ловушки (выглядит сломанным, намеренно)
|
||||||
|
|
||||||
|
| Где | Что выглядит ошибкой | На самом деле |
|
||||||
|
|---|---|---|
|
||||||
|
| `server.nix:130` | `firewall.enable = false` при 20 сервисах на `0.0.0.0` | Роутер фильтрует, см. §4 |
|
||||||
|
| `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:33-35` | `reality443Forwarding = true` на VDS | Следствие отката `c8d4a12`; смысл утрачен, см. todo C5 |
|
||||||
|
| `server/default.nix:33-47` | 15 закомментированных модулей | Отключены осознанно, см. todo E3 |
|
||||||
|
| `opencode.nix:339` | `systemd.user.services.opencode-web.Service` | `serviceConfig` рендерится в секцию `[serviceConfig]`, systemd молча игнорирует (`c73a698`) |
|
||||||
|
| `vds.nix:73-91` | nftables без финального правила | Известный пробел, см. todo A3 |
|
||||||
|
| `100.64.0.0` | Первый адрес CGNAT `/10` | Tassigned вручную, см. §5 |
|
||||||
|
| `server.nix:61-63` | `z /mnt/services 0777` | World-writable точка монтирования; см. todo B1 |
|
||||||
|
|
||||||
|
## Куда лезть по задаче
|
||||||
|
|
||||||
|
| Задача | Файл |
|
||||||
|
|---|---|
|
||||||
|
| Добавить хост | `configurations/default.nix` + `configurations/<host>.nix` + `configurations/{hardware,disko}/<host>.nix` |
|
||||||
|
| Добавить системный сервис | `modules/server/<name>.nix`, добавить в `modules/server/default.nix:imports` |
|
||||||
|
| Добавить home-пакет для пользователя | `home/<device_type>.nix` (через `lib.mkIf` или просто список) |
|
||||||
|
| Добавить опцию, читаемую несколькими модулями | `modules/options.nix` |
|
||||||
|
| Изменить mount/имя пользователя | `lib/xlib/dirs.nix`, `lib/xlib/device.nix` |
|
||||||
|
| Изменить домен / сертификат | `modules/server/coredns.nix` + `modules/server/nginx.nix` (или `vds/`) |
|
||||||
|
| Sops-секрет | положить в `secrets/<name>.<yaml|json|env|ini>`; `users.nix:99` уже подключает `secrets/default.yaml`; dotenv/json-секреты — через `mkUserSecret` |
|
||||||
|
|
||||||
|
## Проверки
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# все outputs вычисляются
|
||||||
|
nix flake check
|
||||||
|
|
||||||
|
# правки применились на целевой хост
|
||||||
|
nix build .#nixosConfigurations.<host>.config.system.build.toplevel
|
||||||
|
|
||||||
|
# nixOnDroid
|
||||||
|
nix build .#nixOnDroidConfigurations.epral.config.system.build.toplevel
|
||||||
|
|
||||||
|
# внешний диск смонтирован (до рестарта сервисов на нём)
|
||||||
|
findmnt /home/oqyude/External
|
||||||
|
findmnt /mnt/services
|
||||||
|
|
||||||
|
# state of guard-зависимостей (когда будет todo B1)
|
||||||
|
systemctl show postgresql -p Requires -p After | tr ' ' '\n' | grep -E 'mnt-|home-oqyude'
|
||||||
|
|
||||||
|
# sops
|
||||||
|
sops --version
|
||||||
|
```
|
||||||
|
|
||||||
|
## Где НЕ лезть без ответа владельца
|
||||||
|
|
||||||
|
- `secrets/` (sops-encrypted, расшифровываются `/etc/ssh/id_ed25519` → циклический bootstrap).
|
||||||
|
- `lдet deploy` без проверки deploy-rs нод: `rydiwo` (ноутбук, может быть выключен).
|
||||||
|
- Любая правка, противоречащая «Подтверждённым инвариантам» выше.
|
||||||
|
|
||||||
|
## Дальше читать
|
||||||
|
|
||||||
|
- `docs/arch/map.md` — полная карта: per-host детали, сетевая топология,
|
||||||
|
инвентарь сервисов, все известные open questions.
|
||||||
|
- `docs/arch/invariants.md` — слои 9–11 (home-manager, deploy, формат) +
|
||||||
|
полный список неотвеченных вопросов слоёв 1–8.
|
||||||
|
- `docs/arch/todo.md` — задачи A1–F (правки и документирование).
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
# Инварианты: вопросы владельцу
|
||||||
|
|
||||||
|
Проход по репозиторию сверху вниз, 2026-10-05. 120 `.nix`, ~8.5k строк.
|
||||||
|
|
||||||
|
Структура:
|
||||||
|
- Сводный ответ, ядро и ловушки → **`AGENTS.md`** (корень репозитория).
|
||||||
|
- Подробная карта архитектуры с per-host деталями и инвентарём сервисов →
|
||||||
|
**`docs/arch/map.md`**.
|
||||||
|
- Этот файл → **открытые вопросы** (слои 0–8) + ещё непрочитанные **слои 9–11**
|
||||||
|
(home-manager, deploy, формат).
|
||||||
|
|
||||||
|
Пометки: `[!]` — найденный дефект, не вопрос. `[?]` — не смог определить по коду.
|
||||||
|
`[✓]` — отвечено владельцем 2026-10-05.
|
||||||
|
|
||||||
|
## Статус ответов (2026-10-05)
|
||||||
|
|
||||||
|
Отвечено: **2.1, 5.2, 6.1, 6.3, 6.5, 7.1, 7.2** (7 пунктов). Остальные ждут
|
||||||
|
ответа (таблица ниже).
|
||||||
|
|
||||||
|
Ключевое из ответов, что меняет картину:
|
||||||
|
|
||||||
|
- **6.5 — моя ошибка.** `100.64.0.0` не «сетевой адрес вместо интерфейса»:
|
||||||
|
это Tailscale-адрес sapphira, назначенный вручную.
|
||||||
|
- **6.3 — это не дыра, а осознанное решение.** Граница держится на роутере:
|
||||||
|
на сервер пробрасываются ровно 5 портов — **443, 80, 22000 (syncthing),
|
||||||
|
8443 (xray), 22 (ssh)**. `firewall.enable = false` на sapphira — следствие,
|
||||||
|
а не недосмотр. Проблема в другом: **список пробросов нигде не записан
|
||||||
|
в репозитории**, и именно его агент обязан уважать (D1 в `todo.md`).
|
||||||
|
- **7.2 — 3x-ui рабочий.** Откат сделан осознанно: панель последняя, ядро Xray
|
||||||
|
осталось на 26.7.28, миграция на 26.9 провалена, лишний код закомментирован.
|
||||||
|
Состояние — «заморожено», а не «сломано».
|
||||||
|
- **5.2 — подтверждённая дыра в защите данных.** Guard для несмонтированного
|
||||||
|
носителя не был продуман → задача B1.
|
||||||
|
|
||||||
|
## Сводка подтверждённых инвариантов
|
||||||
|
|
||||||
|
| # | Пункт | Краткая формулировка | См. |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 1 | Все `outputs` флейка вычисляются | A1: правка `lib/xlib.nix` → `lib/xlib`; закрепить через `nix flake check` | todo A1 |
|
||||||
|
| 2 | External-диск монтируется до сервисов | mkServiceStorage + bind без guard'а → сервис стартует на пустой БД | todo B1 |
|
||||||
|
| 3 | Сетевая граница sapphira = роутер | 5 портов: 22, 80, 443, 8443, 22000; `firewall.enable = false` намеренно | todo D1 |
|
||||||
|
| 4 | `100.64.0.0` = Tailscale sapphira | Назначен вручную; в 4 файлах | AGENTS.md §5 |
|
||||||
|
| 5 | 3x-ui заморожен | Панель на latest; ядро Xray на 26.7.x; миграция 26.9 провалена | todo C1–C5 |
|
||||||
|
| 6 | nftables на VDS — явная финальная политика | Сейчас ruleset без финального правила + конфликт с `firewall.*` | todo A3 |
|
||||||
|
|
||||||
|
## Сводка по ловушкам
|
||||||
|
|
||||||
|
Полная таблица (10 пунктов) в **`AGENTS.md`** → раздел «Ловушки». Кратко:
|
||||||
|
`firewall.enable=false` намеренно · uid=1001 на sapphira · `image = …:latest`
|
||||||
|
намеренно для 3x-ui · `reality443Forwarding=true` — следствие отката ·
|
||||||
|
15 закомментированных модулей в server/default.nix · `serviceConfig` vs `Service`
|
||||||
|
в home-manager · nftables без финального правила · `100.64.0.0` не сеть ·
|
||||||
|
`/mnt/services` mode 0777 · `stateVersion` разный между хостами.
|
||||||
|
|
||||||
|
## Неотвеченные вопросы (слои 0–8)
|
||||||
|
|
||||||
|
Самые важные — выделены.
|
||||||
|
|
||||||
|
| ID | Вопрос | Что блокирует |
|
||||||
|
|---|---|---|
|
||||||
|
| 0.1 | Восстанавливать ли migration notes, удалённые в `22a19be`? | C1: реконструкция заметки 3x-ui |
|
||||||
|
| 0.2 | Комментарий-density 38/120 файлов без комментариев — нормально? | Стиль модулей |
|
||||||
|
| 0.3 | 15 закомментированных модулей: удалить или хранить как референс? | E3: чистота кода |
|
||||||
|
| 0.4 | README пустой, todo.md нет — норма? | E1: AGENTS.md/README |
|
||||||
|
| 1.3 | Лишние inputs в flake (`justray`, `nix-minecraft`, `proxy-suite`)? | Чистота flake |
|
||||||
|
| 1.5 | `nix-systems` через `follows` — оптимизация размера lock | Документация |
|
||||||
|
| **2.2** | **`vetymae` / `lamet` / `therima` / `soptur` — те же машины или хосты вне реестра?** | **DNS/nginx/identity** |
|
||||||
|
| 2.3 | sapphira uid=1001: блокер ли использование `/mnt/archive`/`/mnt/mobile`? | Миграция ФС |
|
||||||
|
| **2.5** | **stateVersion 24.05 / 24.11 / 25.05 / 26.05 — намеренный дрейф?** | **Миграции** |
|
||||||
|
| 2.6 | Есть ли escape hatch для per-host отличий в xlib? | Архитектура |
|
||||||
|
| 2.7 | `devices.termux` без NixOS-хоста — закрытый список | Документация |
|
||||||
|
| 3.2 | `any.nix` (minimal) нужны home-manager + sops + disko? | Минималка |
|
||||||
|
| **4.1** | **Как root получает доступ по SSH — authorizedKeys в коде нет** | **deploy, безопасность** |
|
||||||
|
| **4.2** | **Как разрешается цикл «ключ в секрете, а нужен для расшифровки»?** | **bootstrap, recovery** |
|
||||||
|
| 4.3 | Все файлы в `secrets/` покрыты `path_regex`? | sops |
|
||||||
|
| 4.4 | Как подключается вторая машина / второй человек при одном age-ключе? | sops, scale |
|
||||||
|
| 4.5 | `users.nix:87` — личный ключ или общий «ключ от деплоя»? | Безопасность |
|
||||||
|
| 5.1 | `/mnt/services` mode 0777 — осознанно? | Безопасность |
|
||||||
|
| 5.3 | NFS выключен, Samba работает — миграция? | Сетевые сервисы |
|
||||||
|
| 5.4 | NTFS-том `lamet-drive` `mask=0000` — что на нём? | Семантика |
|
||||||
|
| 5.5 | `therima` / `vetymae` / `soptur` в dirs.nix — реально смонтированы? | Семантика |
|
||||||
|
| 5.6 | Где бэкапы БД и 3x-ui? | B2 |
|
||||||
|
| 6.6 | `192.168.1.20` зашит в 30 мест — константа? | Рефакторинг |
|
||||||
|
| 6.7 | DNS ↔ сервисы — как ловим рассинхрон? | Документация, CI |
|
||||||
|
| **6.8** | **Публичные IP + SSH-алиасы в `home/termux.nix` — карта «хост → адреса» нужна?** | **Архитектура** |
|
||||||
|
| 6.9 | Какой путь REALITY правильный сейчас? | C5 |
|
||||||
|
| 7.4 | Почему не публиковать весь диапазон 14380-15379? | 3x-ui |
|
||||||
|
| 8.2 | `lamet.opencodes` → `:6061` (порт miniflux) — ошибка? | nginx |
|
||||||
|
| 8.4 | `onlyoffice` после трёх регрессов — работает? | Статус сервиса |
|
||||||
|
| 8.5 | Что слушает `:3002` (`/whiteboard` nextcloud)? | Карта сервисов |
|
||||||
|
| 8.6 | Бэкапы вне Nix — записать | Документация |
|
||||||
|
|
||||||
|
## Где это раньше лежало
|
||||||
|
|
||||||
|
До переноса в `AGENTS.md` / `map.md` здесь был подробный Q&A по слоям 0–8
|
||||||
|
с разделами «Вопрос», «Факт», «Риск», «Кандидат». Этот текст сохранён в
|
||||||
|
git-истории файла (последний коммит, где Q&A был полным). Восстановить:
|
||||||
|
`git log -p docs/arch/invariants.md | less`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Слой 9. home-manager
|
||||||
|
|
||||||
|
**9.1** `home/home.nix:52-57` — для пользователя импортируется
|
||||||
|
`home/${xlib.device.type}.nix`; для `root` — без профиля (строка 51).
|
||||||
|
**Вопрос:** почему у `root` нет home-профиля — сознательно?
|
||||||
|
**Кандидат:** `home/<type>.nix` = единственный источник «что есть на этом хосте»
|
||||||
|
для пользователя; добавление пакета в новый тип = правильный файл, а не
|
||||||
|
`home/default.nix`.
|
||||||
|
|
||||||
|
**9.2 [!]** `home/home.nix:28-43` для headless-хостов: `xdg.userDirs.* = null` и
|
||||||
|
`createDirectories = false`, при этом `lib/xlib/dirs.nix:26` обещает
|
||||||
|
`music-library = "${user-home}/Music"`.
|
||||||
|
**Вопрос:** кто создаёт `~/Music` и `~/Storage`? `createDirectories = false`
|
||||||
|
означает, что home-manager их не создаст, а `dirs.nix` на них ссылается.
|
||||||
|
**Риск:** на headless-хосте путь в конфиге есть, а каталога нет → тихий сбой
|
||||||
|
сервиса, который туда пишет.
|
||||||
|
|
||||||
|
**9.3 [!]** `home/modules/opencode.nix:339-350` (`c73a698`): в home-manager нельзя
|
||||||
|
писать `serviceConfig = { ... }` — рендерится литеральная секция `[serviceConfig]`,
|
||||||
|
которую systemd молча игнорирует («Unknown section 'serviceConfig'. Ignoring.»).
|
||||||
|
Правильно: `systemd.user.services.opencode-web.Service = { ... }`.
|
||||||
|
**Кандидат (готовый инвариант, стоит закрепить буквально в `AGENTS.md`):**
|
||||||
|
в home-manager cgroup-опции (`MemoryHigh`, `OOMScoreAdjust`, …) пишутся
|
||||||
|
в `systemd.user.services.<name>.Service`, **не** в `serviceConfig`. Ошибка
|
||||||
|
не диагностируется — она просто не применяется.
|
||||||
|
|
||||||
|
**9.4** `linger = true` добавлен ради `opencode-web` (`users.nix:71-75`) и включён
|
||||||
|
**для всех** хостов.
|
||||||
|
**Кандидат:** «user-сервисы переживают logout на всех хостах» — закрепить, потому
|
||||||
|
что это неочевидное поведение, влияющее на ресурсы и на безопасность.
|
||||||
|
|
||||||
|
**9.5** `home/modules/opencode.nix:286-303` — `opencode.web` слушает `0.0.0.0:4096`
|
||||||
|
(комментарий: nginx проксирует `127.0.0.1:4096`), и nginx на sapphira ходит туда
|
||||||
|
же по Tailscale у двух других хостов (см. 8.3).
|
||||||
|
**Кандидат:** `0.0.0.0` в `opencode.web` — обязательное условие для внешнего
|
||||||
|
доступа через `opencodes.*`; пароль приходит из sops-секрета `opencode_server`.
|
||||||
|
|
||||||
|
**9.6** Секреты opencode приходят в `~/.config/opencode/server.env` (dotenv),
|
||||||
|
`~/.local/share/opencode/auth.json` и `account.json` (json, `key = ""`).
|
||||||
|
**Кандидат:** эти три файла перезаписываются sops при каждой активации — ручные
|
||||||
|
правки в них теряются. Уже отражено в комментарии `users.nix:120-131`, стоит
|
||||||
|
закрепить как инвариант.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Слой 10. deploy и проверка
|
||||||
|
|
||||||
|
**10.1** `deploy/default.nix:20-24` — цели: `sapphira` (server), `otrecа` (vds),
|
||||||
|
`rydiwo` (ноутбук). **Нет** `atoridu` (основной десктоп), `wsl`, `epral`.
|
||||||
|
**Вопрос:** почему не деплоится десктоп? И безопасно ли пересобирать ноутбук
|
||||||
|
`rydiwo` по SSH (он может быть выключен/на другом Wi-Fi)?
|
||||||
|
**Кандидат:** `deploy-rs` = только серверы + ноутбук; десктоп и WSL обновляются
|
||||||
|
вручную. Инвариант: не добавлять в `deploy.nodes` хост, который нельзя
|
||||||
|
пересобрать в любой момент без риска потерять доступ.
|
||||||
|
|
||||||
|
**10.2** `deploy/default.nix:18-19` — `sshUser = "oqyude"`, `user = "root"`.
|
||||||
|
См. 4.1: root-доход по SSH не описан в конфигурации.
|
||||||
|
**Кандидат:** деплой требует ручной настройки root-доступа на каждом из 3 хостов —
|
||||||
|
это скрытая зависимость, которую агент не выведет.
|
||||||
|
|
||||||
|
**10.3** `deploy/default.nix:27-29` — `checks = builtins.mapAttrs (... deployChecks)`.
|
||||||
|
**Вопрос:** `nix flake check` реально проходит сейчас? Учитывая 2.1 (`lib/xlib.nix`)
|
||||||
|
он должен падать на `nixOnDroidConfigurations`. Падает или `checks` покрывают
|
||||||
|
не всё дерево outputs?
|
||||||
|
**Кандидат (первое, что стоит сделать):** добиться, чтобы
|
||||||
|
`nix flake check` был зелёным — это единственная автоматическая защита от
|
||||||
|
подобных breakage'ов.
|
||||||
|
|
||||||
|
**10.4** CI нет, `flake check` не запускается автоматически.
|
||||||
|
**Кандидат:** минимальный локальный набор перед коммитом:
|
||||||
|
`nix flake check && nix build .#nixosConfigurations.<хост>.config.system.build.toplevel --dry-run`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Слой 11. Формат (то, что я предлагаю зафиксировать как процесс)
|
||||||
|
|
||||||
|
**11.1** Где будет жить итог: `AGENTS.md` в корне (читается агентом всегда),
|
||||||
|
`docs/arch/map.md` (карта хостов/сервисов), `docs/arch/invariants.md` (этот файл).
|
||||||
|
**Кандидат:** этот файл после ответов превращается в `docs/arch/invariants.md`
|
||||||
|
с колонкой «ответ» и становится источником для `AGENTS.md`; `AGENTS.md` — краткая
|
||||||
|
выжимка, без подробностей.
|
||||||
|
|
||||||
|
**11.2** Какие инварианты можно превратить в автоматическую проверку (тогда они
|
||||||
|
перестанут «забываться»):
|
||||||
|
1. ни одного `:latest` в образах (grep по `image =`);
|
||||||
|
2. `nix flake check` зелёный;
|
||||||
|
3. каждый домен из `coredns.nix` имеет vhost в `nginx.nix` и наоборот;
|
||||||
|
4. каждый сервис в `mkServiceStorage` имеет каталог в `/mnt/services` на
|
||||||
|
`External`-диске;
|
||||||
|
5. в самописном nftables-ruleset последнее правило цепочки явное;
|
||||||
|
6. каждый `listen.addr` — реально назначенный адрес, а не сеть;
|
||||||
|
7. все файлы в `secrets/` матчат `path_regex` из `.sops.yaml`.
|
||||||
|
|
||||||
|
**Вопрос:** какие из этих проверок ты хочешь, а какие — лишний CI?
|
||||||
@@ -0,0 +1,396 @@
|
|||||||
|
# Карта архитектуры
|
||||||
|
|
||||||
|
Полная карта репозитория: per-host детали, сетевая топология, инвентарь сервисов.
|
||||||
|
Слои 0–8 проработаны; слои 9–11 (home-manager, deploy, формат) см. в
|
||||||
|
`docs/arch/invariants.md`.
|
||||||
|
|
||||||
|
## Содержание
|
||||||
|
|
||||||
|
1. [Реестр хостов](#реестр-хостов)
|
||||||
|
2. [Идентичность и xlib](#идентичность-и-xlib)
|
||||||
|
3. [Диспетчеризация модулей](#диспетчеризация-модулей)
|
||||||
|
4. [Пользователь, SSH, секреты](#пользователь-ssh-секреты)
|
||||||
|
5. [Хранилище](#хранилище)
|
||||||
|
6. [Сеть и firewall](#сеть-и-firewall)
|
||||||
|
7. [Сервисы](#сервисы)
|
||||||
|
8. [Неотвеченные вопросы](#неотвеченные-вопросы)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Реестр хостов
|
||||||
|
|
||||||
|
Единственная точка добавления/изменения хоста — `configurations/default.nix:13-39`.
|
||||||
|
Имя атрибута **равно** hostname; отдельное `hostname = …` только у `default`
|
||||||
|
(где attr = `default`).
|
||||||
|
|
||||||
|
| Attr | hostname | device.type | description |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `default` | nixos | minimal | Шаблон, hostname `"nixos"`, `device = "minimal"` |
|
||||||
|
| `atoridu` | atoridu | primary | Основной десктоп, `xanmod` |
|
||||||
|
| `rydiwo` | rydiwo | secondary | Chuwi MiniBook, `xanmod`, NTFS-том `lamet-drive` |
|
||||||
|
| `otrecа` | otreca | vds | VPS, SSH только через Tailscale, `grub` без EFI |
|
||||||
|
| `sapphira` | sapphira | server | Домашний сервер, `firewall.enable = false` намеренно |
|
||||||
|
| `wsl` | wsl | wsl | WSL NixOS на Windows-хосте `vetymae` |
|
||||||
|
| `epral` | epral | termux | Android (`nix-on-droid`), отдельный путь конфигурации |
|
||||||
|
|
||||||
|
`device.type` ∈ { minimal, primary, secondary, server, vds, wsl, termux }.
|
||||||
|
Машина `vetymae` (Windows + WSL) фигурирует в `coredns`, `nginx`, `modules/server/systemd.nix`,
|
||||||
|
но **не** в реестре хостов — это внешний хост, через который заходят на WSL.
|
||||||
|
|
||||||
|
### Per-host summary
|
||||||
|
|
||||||
|
- **`atoridu`** (`primary/mini-pc`): без `nixos-hardware` (мини-ПК). Linux `xanmod_stable`,
|
||||||
|
`systemd-boot`, EFI. `stateVersion 26.05`. → `configurations/mini-pc.nix`.
|
||||||
|
- **`rydiwo`** (`secondary/mini-laptop`): `nixos-hardware: chuwi-minibook-x`, xanmod,
|
||||||
|
`systemd-boot`, EFI. **NTFS-том `xlib.dirs.lamet-drive`** с `mask = "0000"` —
|
||||||
|
world-readable/writable по дизайну [?]. `stateVersion 26.05`.
|
||||||
|
- **`otrecа`** (`vds`): qemu-guest, GRUB без EFI, `disko` + `hardware/vds.nix`.
|
||||||
|
`firewall.enable = true` + ручной `nftables.ruleset` без финального правила.
|
||||||
|
`firewall.interfaces.tailscale0.allowedTCPPorts = [22]`. `stateVersion 25.05`.
|
||||||
|
- **`sapphira`** (`server`): systemd-boot, EFI, ext4 на UUID `37e53ebc-…-a8de`.
|
||||||
|
bind-mount `/mnt/services` ← `/home/oqyude/External/Services`. `stateVersion 25.05`.
|
||||||
|
- **`wsl`**: `nixos-wsl` + NixOS-стек, IPv6 on, `firewall.enable = false`.
|
||||||
|
`stateVersion 24.11`. Реальный Windows-хост — `vetymae`, `192.168.1.100`.
|
||||||
|
- **`epral`** (`mobile.nix`): не NixOS, **nix-on-droid**. `stateVersion 24.05`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Идентичность и xlib
|
||||||
|
|
||||||
|
`lib/xlib/` собирает чистые данные (без модулей):
|
||||||
|
|
||||||
|
```
|
||||||
|
xlib = {
|
||||||
|
device = { hostname, type, username, uid, gid };
|
||||||
|
isDesktop, isHeadless; # ← от device.type через devices.<type>.{desktop,headless}
|
||||||
|
dirs = mkDirs username; # ← well-known пути, зависят только от username
|
||||||
|
helpers = { mkBindMount, mkSystemdBind, mkServiceStorage, mkNtfsMount,
|
||||||
|
mkExfatMount, mkTmpDirs, mkSymlinks };
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- **`mkXlib`** (`lib/xlib/default.nix:38-77`) — единственная точка сборки; вызывается
|
||||||
|
в `configurations/default.nix:50`. Прокидывается в каждый модуль как
|
||||||
|
`xlib = …` через `lib/mkSystem.nix:specialArgs`.
|
||||||
|
- **`devices`** (`lib/xlib/device.nix:12-41`) — закрытое множество device.types.
|
||||||
|
Неизвестный тип → throw со списком валидных. Добавление типа = новая папка
|
||||||
|
`modules/<type>/` + `home/<type>.nix` + запись в `devices`.
|
||||||
|
- **`uid/gid`** зашиты как `?` `1000`/`1000` в `mkXlib`. Менять = инвентаризация
|
||||||
|
во всех хостах, иначе расходятся владельцы файлов на NTFS/exFAT.
|
||||||
|
- **`sapphira`** — исключение: `users.nix:66` ставит `uid = 1001` для сохранения
|
||||||
|
совместимости со старым `uid-map` (`yuyus` = 1000). `TODO: delete once
|
||||||
|
sapphira migrated to 1000`. Цена: exFAT на sapphira получает `uid=1000` от
|
||||||
|
`xlib.device.uid`, поэтому пользователь не может писать в `/mnt/archive` и
|
||||||
|
`/mnt/mobile` до миграции.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Диспетчеризация модулей
|
||||||
|
|
||||||
|
### `nixosModules.default` (`modules/default.nix:9-39`)
|
||||||
|
|
||||||
|
Импортирует на **каждый** NixOS-хост (включая `minimal`):
|
||||||
|
|
||||||
|
```
|
||||||
|
./essentials → packages, services, settings, ssh, shell, systemd-routines
|
||||||
|
./options.nix → host.builder.*, host."3x-ui".*
|
||||||
|
./users.nix → пользователь, sops-секреты
|
||||||
|
home-manager.nixosModules.home-manager
|
||||||
|
sops-nix.nixosModules.sops
|
||||||
|
justray.nixosModules.default
|
||||||
|
disko.nixosModules.disko
|
||||||
|
grub2-themes.nixosModules.default
|
||||||
|
self.homeConfigurations.default.nixosModule
|
||||||
|
```
|
||||||
|
|
||||||
|
Плюс `lib.optional xlib.isDesktop ./desktop` (primary, secondary).
|
||||||
|
Плюс `lib.optional (!isDesktop && type != "minimal") (./. + "/${type}")`
|
||||||
|
(server, vds, wsl, termux). **termux** попадает сюда только в path nix-on-droid,
|
||||||
|
не как NixOS-хост (см. `mobile.nix`).
|
||||||
|
|
||||||
|
### `nixosModules.strict` (`modules/default.nix:40-53`)
|
||||||
|
|
||||||
|
Используется только `mobile.nix:22`. Импортирует `options.nix` +
|
||||||
|
`./<device.type>`; **всё** остальное NixOS-специфичное (essentials, users,
|
||||||
|
home-manager, sops, disko, grub2-themes) **выключено**, потому что nix-on-droid
|
||||||
|
не имеет `services.*`, `users.*`, `sops.*`, `disko.*` в своей модульной системе.
|
||||||
|
|
||||||
|
### Правило для кросс-модульных опций
|
||||||
|
|
||||||
|
Опция живёт в `modules/options.nix`, если её **устанавливает** один модуль,
|
||||||
|
а **читает** другой. `host.reader.X.enable` живёт в `essentials/ssh.nix`, потому
|
||||||
|
что его объявляет и использует один модуль.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Пользователь, SSH, секреты
|
||||||
|
|
||||||
|
### Пользователь `oqyude`
|
||||||
|
|
||||||
|
- `uid` = `1000` на всех хостах, кроме `sapphira` (=`1001`, см. выше).
|
||||||
|
- `home = /home/oqyude`, `homeMode = "700"`.
|
||||||
|
- `linger = true` на всех хостах — user-services (opencode-web) переживают logout.
|
||||||
|
Следствие: user-сервисы стартуют и потребляют ресурсы без активной сессии.
|
||||||
|
- `extraGroups`: `audio disk gamemode networkmanager pipewire wheel libvirtd qemu-libvirtd`.
|
||||||
|
|
||||||
|
### SSH
|
||||||
|
|
||||||
|
- `essentials/ssh.nix`: `services.openssh` включается через `host.ssh.enable`,
|
||||||
|
`PermitRootLogin = "yes"` (намеренно для deploy), `PasswordAuthentication = false`,
|
||||||
|
hostKey = `/etc/ssh/id_ed25519`.
|
||||||
|
- `authorizedKeys` для `oqyude` зашит в `users.nix:87` (`ssh-ed25519 AAAA…`).
|
||||||
|
Чей — `[?]` (см. вопрос 4.1).
|
||||||
|
- `users.nix` определяет `root`-authorizedKeys **отсутствует** [?] — root как-то
|
||||||
|
попадает на хост; deploy-rs использует `sshUser = "oqyude", user = "root"`.
|
||||||
|
|
||||||
|
### Циклическая зависимость ключа
|
||||||
|
|
||||||
|
`/etc/ssh/id_ed25519` одновременно:
|
||||||
|
- `hostKeys` для sshd (`essentials/ssh.nix:22`)
|
||||||
|
- `sops.age.sshKeyPaths` для расшифровки (`users.nix:95-97`)
|
||||||
|
- цель `ssh_key_private_known` (`users.nix:147-152`)
|
||||||
|
- цель `ssh_key_public_host` (`users.nix:159`)
|
||||||
|
|
||||||
|
Как разворачивается на чистой машине — **одноразовый bootstrap** [?].
|
||||||
|
Должен быть задокументирован, иначе при переустановке хоста агент не выведет.
|
||||||
|
|
||||||
|
### `.sops.yaml`
|
||||||
|
|
||||||
|
- Один age-ключ (`*default`), `path_regex: secrets/[^/]+\.(yaml|json|env|ini)$`.
|
||||||
|
- Покрывает только плоские файлы в `secrets/` (без подкаталогов).
|
||||||
|
- Добавление секрета = `secrets/<имя>.<yaml|json|env|ini>` строго в корне.
|
||||||
|
- Дополнительные секреты dotenv/json — через `mkUserSecret` (`users.nix:33-41`).
|
||||||
|
|
||||||
|
### Инвентарь секретов (`users.nix:100-162`)
|
||||||
|
|
||||||
|
| Секрет | Формат | Назначение |
|
||||||
|
|---|---|---|
|
||||||
|
| `hashed_password` | yaml | Пароль пользователя |
|
||||||
|
| `age_key_private` | yaml | `~/.config/sops/age/keys.txt` |
|
||||||
|
| `opencode_server` | dotenv | `~/.config/opencode/server.env` |
|
||||||
|
| `opencode_auth` | json | `~/.local/share/opencode/auth.json` (`key=""`) |
|
||||||
|
| `opencode_account` | json | `~/.local/share/opencode/account.json` (`key=""`) |
|
||||||
|
| `ssh_key_private` | yaml | `~/.ssh/id_ed25519` |
|
||||||
|
| `ssh_key_public` | yaml | `~/.ssh/id_ed25519.pub` |
|
||||||
|
| `ssh_key_private_root` | yaml | `/root/.ssh/id_ed25519` |
|
||||||
|
| `ssh_key_public_root` | yaml | `/root/.ssh/id_ed25519.pub` |
|
||||||
|
| `ssh_key_public_host` | yaml | `/etc/ssh/id_ed25519.pub` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Хранилище
|
||||||
|
|
||||||
|
### `/home/oqyude/External` (ext4)
|
||||||
|
|
||||||
|
- `sapphira`: UUID `37e53ebc-5343-a94d-9fe2-0ca39e13a8de`, fsType `ext4`,
|
||||||
|
**без `nofail`**, **без automount** — обычный mount, без `x-systemd.automount`,
|
||||||
|
не помечен как `requiredBy local-fs.target` явно, но NixOS добавляет это для
|
||||||
|
всех `fileSystems` без `nofail` [?].
|
||||||
|
- `rydiwo`: не смонтирован (у ноутбука есть только NTFS `lamet-drive`).
|
||||||
|
- На других NixOS-хостах — не заявлен (нет внешнего диска).
|
||||||
|
|
||||||
|
### `/mnt/services` (bind)
|
||||||
|
|
||||||
|
- `server.nix:49-52`: `mkBindMount` от `xlib.dirs.services-folder`
|
||||||
|
(= `/home/oqyude/External/Services`) к `/mnt/services`, `bind,nofail`.
|
||||||
|
- `server.nix:61-63`: tmpfiles `z /mnt/services 0777 root root`.
|
||||||
|
- `vds/default.nix:23`: tmpfiles создаёт `/mnt/services` с правами `0755`.
|
||||||
|
- Используется сервисами на sapphira для bind-mount сервисных данных
|
||||||
|
(`mkServiceStorage`) и как прямой `stateDir` для gitea/memos/calibre-web/
|
||||||
|
immich/nextcloud/step-ca/trilium/uptime-kuma/3x-ui/tape-rotation.
|
||||||
|
|
||||||
|
### `/mnt/archive`, `/mnt/mobile`, `/mnt/lamet`, `/mnt/therima`, `/mnt/vetymae`, `/mnt/soptur`
|
||||||
|
|
||||||
|
- `archive` и `mobile` смонтированы на sapphira через `mkExfatMount`
|
||||||
|
(`nofail`+uid=1000).
|
||||||
|
- `lamet` — NTFS на rydiwo (`mask = "0000"`).
|
||||||
|
- `therima`, `vetymae`, `soptur` — **не** смонтированы нигде в репозитории
|
||||||
|
(см. вопрос 2.2).
|
||||||
|
- `dirs.nix` объявляет их все; `dirs.nix` **не** читать как список дисков этой
|
||||||
|
системы — там имена, часть из которых не существует.
|
||||||
|
|
||||||
|
### Потребители External-диска и порядок защиты
|
||||||
|
|
||||||
|
Включённые на sapphira сервисы с данными на `/mnt/services` или `/home/oqyude/External`:
|
||||||
|
|
||||||
|
- `postgresql`, `samba-smbd`, `homebox` (+setup), `gitea` (+dump),
|
||||||
|
`navidrome`, `syncthing`, `uptime-kuma`, `immich-server` (+ML),
|
||||||
|
`nextcloud`, `calibre-web`, `podman-3xui_app`, `podman-tape-rotation`
|
||||||
|
|
||||||
|
Все они обязаны иметь guard на `requiresMountsFor` (задача **B1** в `todo.md`).
|
||||||
|
Сейчас guard есть **только** у rsync-юнитов (`modules/server/systemd.nix:14,36`),
|
||||||
|
которые используют `--delete` и потенциально самые опасные при отсутствующем
|
||||||
|
диске.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Сеть и firewall
|
||||||
|
|
||||||
|
### Топология
|
||||||
|
|
||||||
|
```
|
||||||
|
Интернет (роутер, белый IP)
|
||||||
|
├── router NAT/proxy → sapphira: 443, 80, 22000, 8443, 22 (5 портов)
|
||||||
|
│
|
||||||
|
└── otreca (VPS): SSH только через Tailscale, не пробрасываем
|
||||||
|
|
||||||
|
LAN (192.168.1.0/24)
|
||||||
|
├── 192.168.1.20 = sapphira (домашний сервер)
|
||||||
|
├── 192.168.1.1 = роутер (gateway)
|
||||||
|
├── 192.168.1.100 = vetymae (Windows-хост; на нём — WSL NixOS = `wsl`)
|
||||||
|
├── 192.168.1.101, .102 = соседние машины (rsync/таблица в `termux.nix`)
|
||||||
|
└── ...
|
||||||
|
|
||||||
|
Tailscale (CGNAT 100.64.0.0/10)
|
||||||
|
├── 100.64.0.0 = sapphira (назначен вручную)
|
||||||
|
├── 100.64.1.0 = ещё один узел [?]
|
||||||
|
├── 100.86.62.4 = opencode на vetymae
|
||||||
|
└── 100.106.21.39 = miniflux на другом узле
|
||||||
|
```
|
||||||
|
|
||||||
|
`192.168.1.20` зашит в ~30 местах: `modules/server/{nginx,coredns,nfs,open-webui}.nix`,
|
||||||
|
`configurations/*`. `100.64.0.0` — в `nginx.nix`, `nextcloud.nix`,
|
||||||
|
`modules/vds/{nginx,systemd}.nix`.
|
||||||
|
|
||||||
|
### DNS (`modules/server/coredns.nix`)
|
||||||
|
|
||||||
|
Зоны `zeroq.su` (~17 записей) и `home.arpa` (~17) определены вручную.
|
||||||
|
Дублируют инвентарь сервисов: добавление сервиса = правка `coredns.nix` +
|
||||||
|
`nginx.nix` + самого модуля.
|
||||||
|
|
||||||
|
### Firewall
|
||||||
|
|
||||||
|
| Хост | `firewall.enable` | Фильтрация |
|
||||||
|
|---|---|---|
|
||||||
|
| sapphira | **false** (намеренно) | Роутер пробрасывает 5 портов: **443, 80, 22000, 8443, 22** |
|
||||||
|
| otreca | true | Самописанный nftables **без финального правила** → неявный accept; `firewall.interfaces.tailscale0.allowedTCPPorts = [22]` |
|
||||||
|
| wsl | false | WSL — не сетевой периметр |
|
||||||
|
| rydiwo, atoridu | default | `desktop` правила |
|
||||||
|
|
||||||
|
Следствия:
|
||||||
|
- На `sapphira` `openFirewall`/`allowedTCPPorts` не имеют эффекта.
|
||||||
|
- `nginx.nix:225` (`allowedTCPPorts = [80 443]`) — **мёртвое** правило.
|
||||||
|
- Допустимо `0.0.0.0` на любом сервисе sapphira — он не открывается в интернет
|
||||||
|
без проброса на роутере.
|
||||||
|
- На `otrecа` ruleset требует финальной политики (задача A3).
|
||||||
|
|
||||||
|
### SSH
|
||||||
|
|
||||||
|
`otreca` достижима только через Tailscale: `services.openssh.openFirewall = false`,
|
||||||
|
`firewall.interfaces.tailscale0.allowedTCPPorts = [22]`. Но при `nftables.enable`
|
||||||
|
с ручным ruleset это правило может не дойти до файрвола — проверить
|
||||||
|
`nft list ruleset` на otreca до правок (задача A3).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Сервисы
|
||||||
|
|
||||||
|
### Системные (sapphira, в `modules/server/default.nix:imports`)
|
||||||
|
|
||||||
|
Сервисы в `imports` + `state` + `roles`:
|
||||||
|
|
||||||
|
| Сервис | Файл | Порт | Данные | Guard? |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| acme (Let's Encrypt) | `modules/server/acme.nix` | — | `/var/lib/acme` | — |
|
||||||
|
| bentopdf | `bentopdf.nix` | — | — | — |
|
||||||
|
| builder (remote) | `builder.nix` | — | — | — (опция выключена) |
|
||||||
|
| calibre-web | `calibre-web.nix` | 8083 | `services-mnt-folder/calibre-web(-library)` | нужен B1 |
|
||||||
|
| chrony | `chrony.nix` | — | — | — |
|
||||||
|
| coredns | `coredns.nix` | 53 | inline zone | — |
|
||||||
|
| gitea | `gitea.nix` | 3000 | `services-mnt-folder/gitea` | нужен B1 |
|
||||||
|
| glances | `glances.nix` | — | — | — |
|
||||||
|
| homebox | `homebox.nix` | 7745 | `mkServiceStorage` | нужен B1 |
|
||||||
|
| immich | `immich.nix` | 2283 | `services-mnt-folder/immich` | нужен B1 |
|
||||||
|
| miniflux | `miniflux.nix` | 6061 | — | — |
|
||||||
|
| navidrome | `navidrome.nix` | 4533 | `server-home/Music` | нужен B1 |
|
||||||
|
| nextcloud | `nextcloud.nix` | 10000 | `services-mnt-folder/nextcloud` | нужен B1 |
|
||||||
|
| nginx | `nginx.nix` | 80/443 | proxy-only | — |
|
||||||
|
| nix-serve | `nix-serve.nix` | 5000 | — | — |
|
||||||
|
| onlyoffice | `onlyoffice.nix` | (через nginx) | — | — |
|
||||||
|
| postgresql | `postgresql.nix` | (local) | `mkServiceStorage` | нужен B1 |
|
||||||
|
| power | `power.nix` | — | — | — |
|
||||||
|
| samba | `samba.nix` | ? | `mkServiceStorage` | нужен B1 |
|
||||||
|
| syncthing | `syncthing.nix` | 8384 (gui), 22000 (data) | `server-home`, `storage/persist/...` | нужен B1 |
|
||||||
|
| systemd (rsync oneshots) | `systemd.nix` | — | источник/приёмник — оба на External | **уже есть guard** |
|
||||||
|
| uptime-kuma | `uptime-kuma.nix` | 4001 | `services-mnt-folder/uptime-kuma` | нужен B1 |
|
||||||
|
|
||||||
|
Закомментированы в `imports` (всё ещё живой код, потенциальный шум):
|
||||||
|
`remnawave, coturn, mealie, memos, minecraft, n8n, netdata, nfs, open-webui,
|
||||||
|
rsync, step-ca, stirling-pdf, transmission, trilium, zerotier` — см. задачу **E3**.
|
||||||
|
|
||||||
|
### Контейнеры (`modules/containers/`)
|
||||||
|
|
||||||
|
| Контейнер | Файл | Данные | Примечание |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 3x-ui | `3x-ui.nix` | `services-nodes-folder/<host>/3x-ui/{db,cert}` | **Заморожен**, см. ниже |
|
||||||
|
| tape-rotation | `tape-rotation.nix` | `services-nodes-folder/<host>/tape-rotation` | — |
|
||||||
|
| remnawave | `remnawave.nix` | `/mnt/services/containers/remnawave` | **закомментирован** в `server/default.nix` |
|
||||||
|
| remnanode | `remnanode.nix` | `/mnt/services/containers/remnanode` | — |
|
||||||
|
| kokoro-tts | `kokoro-tts.nix` | — | — |
|
||||||
|
| openhands | `openhands.nix` | — | — |
|
||||||
|
| remnawave-examples | `remnawave-examples/*.nix` | docker-compose | шаблоны |
|
||||||
|
|
||||||
|
### 3x-ui — замороженное состояние
|
||||||
|
|
||||||
|
Образ: `ghcr.io/mhsanaei/3x-ui:latest` (**не запинен**). Ядро Xray — на 26.7.x,
|
||||||
|
миграция на 26.9.x провалена. Панель может обновиться из upstream — поэтому:
|
||||||
|
- `podman-update-3xui_app` (`3x-ui.nix:80-90`) с `podman pull …:latest`
|
||||||
|
+ `systemctl restart` — **таймер закомментирован**.
|
||||||
|
- `podman.autoPrune.flags = ["--all"]` (`3x-ui.nix:45-47`) — потенциальный риск:
|
||||||
|
авто-prune может смести панель без коммита в репозиторий.
|
||||||
|
|
||||||
|
`reality443Forwarding = true` (`modules/vds/default.nix:19`) — следствие отката
|
||||||
|
`c8d4a12`; смысл утрачен, см. задачу **C5**.
|
||||||
|
|
||||||
|
### Nginx (`modules/server/nginx.nix`)
|
||||||
|
|
||||||
|
~12 vhost'ов через `mkProxy` для обратного проксирования сервисов на 192.168.1.20.
|
||||||
|
Плюс несколько hand-written:
|
||||||
|
|
||||||
|
- `nextcloud.private` — слушает на `100.64.0.0:10000` (= Tailscale sapphira),
|
||||||
|
`192.168.1.20:10000`, `127.0.0.1:10000`.
|
||||||
|
- `office.zeroq.su` — проксирует на nextcloud onlyoffice.
|
||||||
|
- `pdf.private` — слушает `0.0.0.0:80`, `100.64.0.0:8446`, `192.168.1.20:8446`,
|
||||||
|
`127.0.0.1:8446` (для Nextcloud PDF).
|
||||||
|
- `x.zeroq.su` — 3x-ui controller panel + `/subs/`, `/subsjs/`, `/clash/`.
|
||||||
|
- `zeroq.su` — корневой, заглушка + `/guest/` → LAN `:80`.
|
||||||
|
- `vetymae.opencodes.zeroq.su` → `100.86.62.4:4096`.
|
||||||
|
- `lamet.opencodes.zeroq.su` → `100.106.21.39:6061` — **порт miniflux**; либо
|
||||||
|
ошибка, либо так задумано [?] (см. вопрос 8.2).
|
||||||
|
- `opencode.zeroq.su` → `127.0.0.1:4096` (opencode-web на самом sapпира).
|
||||||
|
- `nextcloud.zeroq.su` → `192.168.1.20:10000`, `/whiteboard` → `:3002`.
|
||||||
|
|
||||||
|
`networking.firewall.allowedTCPPorts = [80 443]` (строка 225) — **мёртвое** правило
|
||||||
|
при `firewall.enable = false`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Неотвеченные вопросы
|
||||||
|
|
||||||
|
Слои 9–11 (home-manager, deploy, формат) оставлены для прочтения в
|
||||||
|
`docs/arch/invariants.md`. Неотвеченные вопросы слоёв 1–8:
|
||||||
|
|
||||||
|
| ID | Вопрос |
|
||||||
|
|---|---|
|
||||||
|
| 2.2 | `vetymae` / `lamet` / `therima` / `soptur` — те же машины или хосты вне репозитория? |
|
||||||
|
| 2.5 | `stateVersion` дрейфует 24.05 / 24.11 / 25.05 / 26.05 — намеренно? |
|
||||||
|
| 2.6 | Есть ли escape hatch для per-host отличий в `xlib`? |
|
||||||
|
| 3.2 | `any.nix` (minimal) действительно нуждается в home-manager + sops + disko? |
|
||||||
|
| 4.1 | Как root получает доступ по SSH — `authorizedKeys` для root в коде нет |
|
||||||
|
| 4.2 | Как разрешается цикл «ключ в секрете, а нужен для расшифровки»? |
|
||||||
|
| 4.3 | Все файлы в `secrets/` покрыты `path_regex`? |
|
||||||
|
| 4.4 | Как подключается вторая машина / второй человек при одном age-ключе? |
|
||||||
|
| 4.5 | `users.nix:87` — личный ключ или общий «ключ от деплоя»? |
|
||||||
|
| 5.1 | `/mnt/services` в режиме 0777 — осознанно? |
|
||||||
|
| 5.3 | NFS выключен, Samba работает — миграция? |
|
||||||
|
| 5.4 | NTFS-том `lamet-drive` с `mask = "0000"` — что на нём лежит? |
|
||||||
|
| 5.5 | `therima` / `vetymae` / `soptur` — несуществующие остатки или сетевые шары? |
|
||||||
|
| 5.6 | Где бэкапы БД и 3x-ui? |
|
||||||
|
| 6.6 | `192.168.1.20` зашит в 30 мест — считаем константой? |
|
||||||
|
| 6.7 | DNS дублирует инвентарь сервисов — как проверяем рассинхрон? |
|
||||||
|
| 6.8 | Публичные IP и SSH-алиасы в `home/termux.nix` — карта «хост → адреса» нужна? |
|
||||||
|
| 6.9 | Какой путь REALITY считается правильным? (→ C5) |
|
||||||
|
| 7.4 | Почему не публиковать весь диапазон 14380-15379? |
|
||||||
|
| 8.2 | `lamet.opencodes` → `:6061` — ошибка или так задумано? |
|
||||||
|
| 8.4 | `onlyoffice` — работает после трёх регрессов? |
|
||||||
|
| 8.5 | Что слушает `:3002` (`/whiteboard` в nextcloud)? |
|
||||||
@@ -0,0 +1,316 @@
|
|||||||
|
# TODO: правки и инварианты
|
||||||
|
|
||||||
|
Источник: `docs/arch/invariants.md`. Ответы владельца от 2026-10-05 учтены.
|
||||||
|
Подтверждённые факты зафиксированы в `AGENTS.md` (корень) и `docs/arch/map.md`;
|
||||||
|
этот файл — только **незакрытые правки и неотвеченные вопросы**.
|
||||||
|
Порядок: A → B → C → D, потом E (документация для агента).
|
||||||
|
|
||||||
|
Обозначения: `[ ]` не начато, `[x]` сделано, `[!]` блокирует остальное.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Подтверждено (зафиксировано в `AGENTS.md` / `map.md`)
|
||||||
|
|
||||||
|
Эти инварианты уже учтены в ядре и карте — при правке кода опираться на
|
||||||
|
зафиксированные формулировки.
|
||||||
|
|
||||||
|
- **6.1** Явная финальная политика nftables на VDS → `todo A3` ещё открыто,
|
||||||
|
но сам «надо запилить» закреплён.
|
||||||
|
- **6.3** Firewall на sapphira выключен намеренно (граница — роутер, 5 портов:
|
||||||
|
22, 80, 443, 8443, 22000) → формулировка в `AGENTS.md §3`, `todo D1`.
|
||||||
|
- **6.5** `100.64.0.0` = Tailscale-адрес sapphira (назначен вручную) → `AGENTS.md §5`,
|
||||||
|
`map.md §Сеть и firewall`.
|
||||||
|
- **7.1 / 7.2** 3x-ui заморожен: панель на latest, ядро Xray на 26.7.x,
|
||||||
|
миграция 26.9 провалена → `AGENTS.md §4`, `todo C1–C5`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ловушки для агента: выглядит сломанным, но это намеренно
|
||||||
|
|
||||||
|
Прежде чем чинить — проверить этот список. Здесь лежат решения, которые
|
||||||
|
иначе «поправляются» обратно и ломают рабочую систему.
|
||||||
|
|
||||||
|
| Где | Что выглядит ошибкой | На самом деле |
|
||||||
|
|---|---|---|
|
||||||
|
| `configurations/server.nix:130` | `networking.firewall.enable = false` на сервере с 20 сервисами на `0.0.0.0` | Намеренно: фильтр на роутере, он пробрасывает 5 портов (см. D1) |
|
||||||
|
| `configurations/mobile.nix:95`, `wsl.nix:59` | `stateVersion` 24.05 / 24.11 против 26.05 у остальных | Каждый хост зафиксирован на своей версии; не «подровнять» |
|
||||||
|
| `modules/users.nix:66` | `uid = if hostname == "sapphira" then 1001 else …` с пометкой TODO | Осознанный костыль под старый uid 1000 = `yuyus`; удалять только после миграции ФС |
|
||||||
|
| `modules/containers/3x-ui.nix:54` | `image = …:latest` | Панель намеренно на последней версии; **ядро** Xray — на 26.7.x, миграция на 26.9 провалена |
|
||||||
|
| `modules/containers/3x-ui.nix:33-35` | `reality443Forwarding = true` на VDS при откате nginx-stream | Следствие отката `c8d4a12`; смысл утрачен, но опция объявлена — см. C5 |
|
||||||
|
| `modules/server/default.nix:33-47` | 15 закомментированных модулей с живым кодом | Отключены осознанно; см. E3 |
|
||||||
|
| `modules/server/{mealie,memos,n8n,netdata,nfs,open-webui,rsync,step-ca,transmission,trilium,zerotier}.nix` | Агент насчитает лишние порты и каталоги | Модули вне `imports` = мёртвый код |
|
||||||
|
| `home/modules/opencode.nix:339` | `systemd.user.services.opencode-web.Service` вместо привычного `serviceConfig` | `serviceConfig` рендерится в секцию `[serviceConfig]`, которую systemd **молча игнорирует** (`c73a698`) |
|
||||||
|
| `configurations/vds.nix:73-91` | nftables без финального правила | Известный пробел,см. A3 — **не** «случайно потерялось» |
|
||||||
|
| `100.64.0.0` в `nginx.nix`, `nextcloud.nix`, `vds/*` | Первый адрес CGNAT `/10`, похож на сетевой | Tailscale-адрес sapphira, назначен вручную |
|
||||||
|
| `server.nix:61-63` | `z /mnt/services 0777` | World-writable точка монтирования; см. B1 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## A. Блокеры: сломано или не защищено
|
||||||
|
|
||||||
|
### [ ] A1. `mobile.nix` импортирует несуществующий файл
|
||||||
|
|
||||||
|
**Где:** `configurations/mobile.nix:12`
|
||||||
|
```nix
|
||||||
|
xlib = import ../lib/xlib.nix { lib = inputs.nixpkgs.lib; };
|
||||||
|
```
|
||||||
|
Файла `lib/xlib.nix` нет — есть каталог `lib/xlib/` с `default.nix`.
|
||||||
|
**Правка** (как в `configurations/default.nix:5`):
|
||||||
|
```nix
|
||||||
|
xlib = import ../lib/xlib { inherit lib; };
|
||||||
|
```
|
||||||
|
**Следствие:** до правки `nixOnDroidConfigurations.epral` и `.default`
|
||||||
|
не вычисляются. Устройство `epral` мертво.
|
||||||
|
**Проверка:**
|
||||||
|
```
|
||||||
|
nix eval --raw .#nixOnDroidConfigurations.epral.config.environment.etcBackupExtension # ожидается .bak
|
||||||
|
```
|
||||||
|
|
||||||
|
### [ ] A2. Убедиться, что `nix flake check` вообще запускается
|
||||||
|
|
||||||
|
**Где:** нет CI; `checks` в `deploy/default.nix:27-29` покрывают только deploy.
|
||||||
|
**Сначала проверить**, ловит ли текущий `nix flake check` поломку из A1:
|
||||||
|
```
|
||||||
|
nix flake check
|
||||||
|
```
|
||||||
|
Ожидание, которое надо подтвердить: он **уже падает** на `epral`, то есть
|
||||||
|
проверка существует, но её не запускали. Если падает — A1 и был бы замечен.
|
||||||
|
**Проверка после A1:** та же команда должна стать зелёной.
|
||||||
|
**Затем** (E2) — превратить в привычку: прогонять перед каждым коммитом.
|
||||||
|
|
||||||
|
### [ ] A3. Явная финальная политика nftables на VDS
|
||||||
|
|
||||||
|
**Где:** `configurations/vds.nix:73-91`
|
||||||
|
**Сначала диагностика на otreca** (без неё править опасно — можно отрезать SSH):
|
||||||
|
```
|
||||||
|
nft list ruleset
|
||||||
|
systemctl status nftables firewall-nftables
|
||||||
|
```
|
||||||
|
Нужно понять, кто реально владеет набором правил: `nftables.enable = true` с
|
||||||
|
собственным ruleset **и** `networking.firewall.*` включены одновременно
|
||||||
|
(инвариант 6.2). Затем — править **один** механизм, не оба.
|
||||||
|
**Что должно получиться** (политика — на выбор владельца, два варианта):
|
||||||
|
```
|
||||||
|
# Вариант «белый список» (предпочтительно):
|
||||||
|
chain input {
|
||||||
|
type filter hook input priority 0; policy drop;
|
||||||
|
iif lo accept
|
||||||
|
ct state established,related accept
|
||||||
|
iif "tailscale0" accept
|
||||||
|
tcp dport { 80, 443 } ct state new limit rate 20/second burst 40 packets accept
|
||||||
|
tcp dport { 22 } ct state new accept # только если 22 нужен на ens3
|
||||||
|
}
|
||||||
|
# Вариант «мягкий» (минимум изменений, фиксирует текущее поведение):
|
||||||
|
chain input {
|
||||||
|
type filter hook input priority 0;
|
||||||
|
iif lo accept
|
||||||
|
ct state established,related accept
|
||||||
|
tcp dport { 80, 443 } ct state new limit rate 20/second burst 40 packets accept
|
||||||
|
tcp dport { 80, 443 } ct state new drop
|
||||||
|
# финал accept — но ТОЛЬКО как явно помеченное «разрешено всё остальное»:
|
||||||
|
iif "ens3" accept comment "PROVISIONAL: explicit allow-all, см. A3"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
**Инвариант к записи:** последнее правило самописной цепочки всегда явное.
|
||||||
|
**Проверка:** `nft list chain inet filter input` + `ssh` с внешнего адреса.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## B. Защита данных
|
||||||
|
|
||||||
|
### [ ] B1. Guard на несмонтированный носитель `/mnt/services`
|
||||||
|
|
||||||
|
**Где:** `lib/xlib/helpers.nix` (`mkServiceStorage`), потребители —
|
||||||
|
`modules/server/{postgresql,n8n,samba,homebox,minecraft}.nix` + `modules/containers/3x-ui.nix`
|
||||||
|
**Проблема (подтверждена владельцем как не продуманная):** `mkServiceStorage`
|
||||||
|
даёт `bind,x-systemd.automount,nofail`. Если диск `External` (`xlib.dirs.server-home`,
|
||||||
|
ext4 по UUID, `configurations/server.nix:55-58`) не смонтирован, то `/mnt/services`
|
||||||
|
— обычный каталог, `/var/lib/<service>` пуст, и сервис **молча** стартует на чистой
|
||||||
|
базе. Пользователь увидит «потерялись данные».
|
||||||
|
**Решение (рекомендую):** добавить в `xlib/helpers.nix`
|
||||||
|
```nix
|
||||||
|
mkStorageGuard =
|
||||||
|
{ dir }:
|
||||||
|
{
|
||||||
|
# сервис не стартует, пока /mnt/services не смонтирован:
|
||||||
|
# Requires+After на mnt-services.mount, который упадёт, если нет источника
|
||||||
|
requiresMountsFor = [ dir ];
|
||||||
|
};
|
||||||
|
```
|
||||||
|
и в каждом потребителе:
|
||||||
|
```nix
|
||||||
|
systemd.services.postgresql = xlib.helpers.mkStorageGuard { dir = xlib.dirs.services-mnt-folder; };
|
||||||
|
```
|
||||||
|
**Важно — не проверять `ConditionPathIsMountPoint=/mnt/services`:** bind-mount
|
||||||
|
внутри одной ФС не меняет `st_dev`, условие вернёт false даже при корректном
|
||||||
|
монтировании. Надёжны `requiresMountsFor` или `ConditionPathIsMountPoint` на
|
||||||
|
`xlib.dirs.server-home` (там `st_dev` действительно другой).
|
||||||
|
**Плюс операционная строка в `AGENTS.md`:** перед рестартом этих сервисов —
|
||||||
|
`findmnt /mnt/services`.
|
||||||
|
**Проверка (имитация отказа):**
|
||||||
|
```
|
||||||
|
systemctl stop postgresql
|
||||||
|
sudo umount /mnt/services # или остановить automount
|
||||||
|
systemctl start postgresql # ожидается FAIL, а не пустая база
|
||||||
|
```
|
||||||
|
|
||||||
|
### [ ] B2. Зафиксировать, что бэкапов в конфигурации нет
|
||||||
|
|
||||||
|
**Где:** `modules/server/postgresql.nix:23` (`postgresqlBackup.enable` закомментирован),
|
||||||
|
бэкап-сервиса в репозитории нет вообще; БД 3x-ui — sqlite на том же диске.
|
||||||
|
**Задача — не код, а запись:** в `AGENTS.md` и `invariants.md` явно сказать,
|
||||||
|
что бэкапы ведутся вне Nix. Иначе агент считает конфиг самодостаточным.
|
||||||
|
**Ждёт ответа:** где бэкапы и как их проверять (инвариант 5.6).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## C. 3x-ui: заморозить рабочее состояние
|
||||||
|
|
||||||
|
### [ ] C1. Вернуть расследование, потерянное при откате
|
||||||
|
|
||||||
|
**Где:** 200 строк удалены коммитом `22a19be`.
|
||||||
|
**Восстановить и дополнить выводом:**
|
||||||
|
```
|
||||||
|
git show 9974784:modules/containers/3x-ui-migration-notes.md > docs/arch/notes/3x-ui-xray-26.9.md
|
||||||
|
```
|
||||||
|
Дописать в конец: вердикт — миграция ядра 26.7 → 26.9 **провалена**, откат на
|
||||||
|
рабочее состояние (панель последняя, ядро 26.7.x), обходные скрипты отключены
|
||||||
|
осознанно; причина отказа — обязательный постквантовый обмен X25519MLKEM768,
|
||||||
|
ломающий старых клиентов.
|
||||||
|
**Инвариант:** откат кода не удаляет расследование; заметка живёт в
|
||||||
|
`docs/arch/notes/`, а не рядом с откатываемым файлом.
|
||||||
|
|
||||||
|
### [ ] C2. Зафиксировать фактические версии панели и ядра
|
||||||
|
|
||||||
|
**Где:** `modules/containers/3x-ui.nix:54`
|
||||||
|
**Сначала узнать, что реально работает** (на sapphira и на otreca):
|
||||||
|
```
|
||||||
|
podman images --format '{{.Repository}}:{{.Tag}} {{.Id}} {{.Created}}' | grep 3x-ui
|
||||||
|
podman inspect ghcr.io/mhsanaei/3x-ui --format '{{index .RepoDigests 0}}'
|
||||||
|
podman exec 3xui_app /app/bin/xray-linux-amd64 version
|
||||||
|
```
|
||||||
|
**Потом** заменить `:latest` на найденный тег (или digest) в коде.
|
||||||
|
**Инвариант:** образы контейнеров запинены; `latest` запрещён — обновление
|
||||||
|
образа это правка в коде, а не `podman pull` на хосте.
|
||||||
|
**Почему срочно:** `podman.autoPrune.flags = ["--all"]` (`3x-ui.nix:45-47`) +
|
||||||
|
`:latest` = рабочее состояние может смениться без единого коммита.
|
||||||
|
|
||||||
|
### [ ] C3. Убрать сервис автообновления 3x-ui
|
||||||
|
|
||||||
|
**Где:** `modules/containers/3x-ui.nix:80-90` (`podman-update-3xui_app` с
|
||||||
|
`podman pull … :latest`) и закомментированный таймер (строка 97-103).
|
||||||
|
**Предложение:** удалить сервис целиком, оставив комментарий-предупреждение.
|
||||||
|
Обновление панели через `pull` — ровно тот путь, которым в 2026-10-04
|
||||||
|
декларация разошлась с рантаймом; автоматизировать его нельзя.
|
||||||
|
**Инвариант:** ни один контейнер в этом репозитории не обновляется сам.
|
||||||
|
|
||||||
|
### [ ] C4. Записать в AGENTS.md, что ядро Xray — состояние панели, а не Nix
|
||||||
|
|
||||||
|
Версия ядра выбирается в UI панели и лежит в её sqlite-БД, то есть **вне** Nix.
|
||||||
|
Репозиторий не может её гарантировать.
|
||||||
|
**Операционное правило:** перед деплоем/рестартом 3x-ui проверять версию ядра
|
||||||
|
в панели; обновление ядра = отдельная задача с записью в
|
||||||
|
`docs/arch/notes/`, а не молчаливый `podman pull`.
|
||||||
|
|
||||||
|
### [ ] C5. Решить судьбу `reality443Forwarding`
|
||||||
|
|
||||||
|
**Где:** `modules/vds/default.nix:19` (`= true`), `modules/options.nix:66-75`,
|
||||||
|
`modules/containers/3x-ui.nix:33-35`.
|
||||||
|
Состояние после отката `c8d4a12`: опция включена, поэтому на otreca
|
||||||
|
пробрасывается `127.0.0.1:15380:443`, тогда как единственный Reality-инбаунд
|
||||||
|
контейнера слушает 8443, а публичный 8443 проброшен напрямую (`0.0.0.0:8443`).
|
||||||
|
Потребителя потока (nginx-stream) откат убрал.
|
||||||
|
**Варианты:** (а) оставить как есть и описать в инвариантах; (б) погасить опцию
|
||||||
|
в `vds/default.nix` и убрать её из `options.nix`; (в) довести до рабочего
|
||||||
|
состояния. **Ждёт решения** — связано с 6.9.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## D. Сетевая граница: записать то, чего нет в репозитории
|
||||||
|
|
||||||
|
### [ ] D1. Пробросы роутера — главный недостающий инвариант
|
||||||
|
|
||||||
|
Ответ владельца: на сервер пробрасываются **443, 80, 22000 (syncthing),
|
||||||
|
8443 (xray), 22 (ssh)**. Это **настоящая граница доверия**, и она живёт
|
||||||
|
в конфиге роутера, то есть вне репозитория.
|
||||||
|
**Записать в двух местах:** `docs/arch/invariants.md` (слой 6) и `AGENTS.md`.
|
||||||
|
Формулировка инварианта:
|
||||||
|
> Экспозиция наружу определяется пробросами на роутере, не `openFirewall`.
|
||||||
|
> На `sapphira` `networking.firewall.enable = false` намеренно.
|
||||||
|
> Список пробросов: 22, 80, 443, 8443 (3x-ui/Xray REALITY), 22000 (syncthing).
|
||||||
|
> Новый сервис не становится доступен из интернета, пока не добавлен проброс.
|
||||||
|
> `networking.firewall.*` на `sapphira` не имеет эффекта.
|
||||||
|
|
||||||
|
### [ ] D2. Зафиксировать `100.64.0.0` как Tailscale-адрес sapphira
|
||||||
|
|
||||||
|
Моё прежнее замечание («сеть вместо адреса») было неверным — адрес назначен
|
||||||
|
вручную. Записать как факт + список из 4 мест, которые придётся править при
|
||||||
|
смене: `modules/server/nginx.nix`, `modules/server/nextcloud.nix`,
|
||||||
|
`modules/vds/systemd.nix`, `modules/vds/nginx.nix`.
|
||||||
|
**Опционально (отложено):** вынести `192.168.1.20` в `xlib.dirs` — сейчас
|
||||||
|
зашит в ~30 местах в 6 файлах. Не срочно, это рефакторинг.
|
||||||
|
|
||||||
|
### [ ] D3. Убрать мёртвое правило firewall
|
||||||
|
|
||||||
|
**Где:** `modules/server/nginx.nix:225-228` — `allowedTCPPorts = [80 443]`
|
||||||
|
не действует при `firewall.enable = false` (`server.nix:130`).
|
||||||
|
Удалить или пометить комментарием «депенит от D1».
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## E. Документация для агента (после прохода по invariants.md)
|
||||||
|
|
||||||
|
### [ ] E1. Написать `AGENTS.md` в корне
|
||||||
|
Собирается из подтверждённых инвариантов. Структура: карта хостов →
|
||||||
|
что где лежит → инварианты (нарушишь = сломает) → ловушки из таблицы выше →
|
||||||
|
команды проверки. Ожидаемый бюджет — до 150 строк.
|
||||||
|
|
||||||
|
### [ ] E2. Выбрать проверки, которые заменят половину инвариантов
|
||||||
|
Кандидаты из инварианта 11.2:
|
||||||
|
1. ни одного `:latest` в образах (grep по `image =`);
|
||||||
|
2. `nix flake check` зелёный — уже ловит A1;
|
||||||
|
3. домены в `coredns.nix` ↔ vhost'ы в `nginx.nix` совпадают в обе стороны;
|
||||||
|
4. для каждого потребителя `mkServiceStorage` каталог существует на `External`;
|
||||||
|
5. последнее правило самописной nftables-цепочки явное;
|
||||||
|
6. `listen.addr` — адрес интерфейса, а не сеть;
|
||||||
|
7. все файлы в `secrets/` матчат `path_regex` из `.sops.yaml`.
|
||||||
|
**Ждёт ответа:** какие из них делать, какие — избыточны.
|
||||||
|
|
||||||
|
### [ ] E3. Судьба 15 закомментированных модулей
|
||||||
|
`modules/server/default.nix:33-47` — `remnawave, coturn, mealie, memos,
|
||||||
|
minecraft, n8n, netdata, nfs, open-webui, rsync, step-ca, stirling-pdf,
|
||||||
|
transmission, trilium, zerotier`. Удалить или оставить как референс?
|
||||||
|
Они мешают агенту насчитывать порты и каталоги, которых нет.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## F. Ждут ответа (блокируют E1)
|
||||||
|
|
||||||
|
Индексы в `docs/arch/invariants.md`:
|
||||||
|
|
||||||
|
| № | Вопрос, который блокирует запись инварианта |
|
||||||
|
|---|---|
|
||||||
|
| 2.2 | `vetymae` / `lamet` / `therima` / `soptur` — это те же машины или хосты вне репозитория? |
|
||||||
|
| 2.5 | `stateVersion` дрейфует 24.05 / 24.11 / 25.05 / 26.05 — намеренно? |
|
||||||
|
| 2.6 | Есть ли escape hatch для per-host отличий в `xlib`, или «у всех хостов одно» — закон? |
|
||||||
|
| 3.2 | `any.nix` (minimal) действительно нуждается в home-manager + sops + disko? |
|
||||||
|
| 4.1 | Как root получает доступ по SSH — `authorizedKeys` для root в коде нет |
|
||||||
|
| 4.2 | Как разрешается цикл «ключ `/etc/ssh/id_ed25519` лежит внутри секрета, а нужен для расшифровки» |
|
||||||
|
| 4.3 | Что лежит в `secrets/`, все ли файлы покрыты `path_regex` |
|
||||||
|
| 4.4 | Как подключается вторая машина / второй человек при одном age-ключе |
|
||||||
|
| 4.5 | `users.nix:87` — личный ключ или общий «ключ от деплоя» |
|
||||||
|
| 5.1 | `/mnt/services` в режиме 0777 — осознанно? |
|
||||||
|
| 5.3 | NFS выключен, Samba работает — миграция? |
|
||||||
|
| 5.4 | NTFS-том `lamet-drive` с `mask = "0000"` — что на нём лежит |
|
||||||
|
| 5.5 | `therima` / `vetymae` / `soptur` — несуществующие остатки или сетевые шары |
|
||||||
|
| 5.6 | Где бэкапы БД и 3x-ui (→ B2) |
|
||||||
|
| 6.6 | `192.168.1.20` зашит в 30 мест — считаем константой? |
|
||||||
|
| 6.7 | DNS дублирует инвентарь сервисов — как проверяем рассинхрон |
|
||||||
|
| 6.8 | Публичные IP и SSH-алиасы в `home/termux.nix` — карта «хост → адреса» нужна? |
|
||||||
|
| 6.9 | Какой путь REALITY считается правильным (→ C5) |
|
||||||
|
| 7.4 | Почему не публиковать весь диапазон 14380-15379 |
|
||||||
|
| 8.2 | `lamet.opencodes` → `:6061` — это miniflux; ошибка или так задумано |
|
||||||
|
| 8.4 | `onlyoffice` — работает после трёх регрессов? |
|
||||||
|
| 8.5 | Что слушает `:3002` (`/whiteboard` в nextcloud) |
|
||||||
|
| 9.2 | Кто создаёт `~/Music` и `~/Storage` при `createDirectories = false` |
|
||||||
|
| 10.1 | Почему `deploy-rs` не деплоит `atoridu`, `wsl`, `epral` |
|
||||||
@@ -13,6 +13,7 @@
|
|||||||
config,
|
config,
|
||||||
lib,
|
lib,
|
||||||
pkgs,
|
pkgs,
|
||||||
|
xlib,
|
||||||
...
|
...
|
||||||
}:
|
}:
|
||||||
let
|
let
|
||||||
@@ -270,6 +271,16 @@ in
|
|||||||
};
|
};
|
||||||
|
|
||||||
# ~/.config/opencode/oh-my-openagent.json — read by the plugin on startup.
|
# ~/.config/opencode/oh-my-openagent.json — read by the plugin on startup.
|
||||||
|
#
|
||||||
|
# NOTE: the oh-my-openagent plugin runs a `2026-07-opencode-config-unification`
|
||||||
|
# migration on every startup that backs up this file and tries to write its
|
||||||
|
# consolidated form to ~/.omo/omo.jsonc. The backup directory name embeds the
|
||||||
|
# source's content-hashed store path; because HM does not delete the previous
|
||||||
|
# generation's store path until garbage collection, the same path is reused on
|
||||||
|
# every retry and omo logs "Migration backup path already exists" forever.
|
||||||
|
# Recovery: `rm -rf ~/.omo/migration-backup-*` and let omo retry; if the
|
||||||
|
# migration keeps failing on the same backup path, the plugin/omo version
|
||||||
|
# probably expects a new schema and this config needs updating.
|
||||||
xdg.configFile."opencode/oh-my-openagent.json".text = builtins.toJSON ohMyOpenagentConfig;
|
xdg.configFile."opencode/oh-my-openagent.json".text = builtins.toJSON ohMyOpenagentConfig;
|
||||||
|
|
||||||
# Same extras on the user's PATH too, so `omo doctor` and standalone invocations
|
# Same extras on the user's PATH too, so `omo doctor` and standalone invocations
|
||||||
@@ -297,7 +308,7 @@ in
|
|||||||
# `config.sops.*` — sops-nix options are NixOS-only).
|
# `config.sops.*` — sops-nix options are NixOS-only).
|
||||||
programs.opencode.web = {
|
programs.opencode.web = {
|
||||||
enable = true;
|
enable = true;
|
||||||
environmentFile = "${config.home.homeDirectory}/.config/opencode/server.env";
|
environmentFile = xlib.dirs.opencode-server-env;
|
||||||
extraArgs = [
|
extraArgs = [
|
||||||
"--hostname"
|
"--hostname"
|
||||||
"0.0.0.0"
|
"0.0.0.0"
|
||||||
@@ -336,7 +347,17 @@ in
|
|||||||
# Refs:
|
# Refs:
|
||||||
# https://www.freedesktop.org/software/systemd/man/systemd.resource-control.html
|
# https://www.freedesktop.org/software/systemd/man/systemd.resource-control.html
|
||||||
# https://www.freedesktop.org/software/systemd/man/systemd.exec.html#OOMScoreAdjust=
|
# https://www.freedesktop.org/software/systemd/man/systemd.exec.html#OOMScoreAdjust=
|
||||||
systemd.user.services.opencode-web.serviceConfig = {
|
# cgroup/OOM knobs added on top of the [Service] section emitted by
|
||||||
|
# `programs.opencode.web`. home-manager unions multiple definitions of the
|
||||||
|
# same systemd unit attrset, so ExecStart/Restart/EnvironmentFile from
|
||||||
|
# upstream and MemoryHigh/MemoryMax/OOMScoreAdjust/OOMPolicy from here
|
||||||
|
# land in the same [Service] block systemd actually reads.
|
||||||
|
#
|
||||||
|
# NOTE: do NOT use `serviceConfig = { ... }` — home-manager renders that
|
||||||
|
# as a literal `[serviceConfig]` section, which systemd silently ignores
|
||||||
|
# (`Unknown section 'serviceConfig'. Ignoring.`). The cgroup protection
|
||||||
|
# above would never take effect (verified on sapphira, c73a698).
|
||||||
|
systemd.user.services.opencode-web.Service = {
|
||||||
MemoryHigh = "1G";
|
MemoryHigh = "1G";
|
||||||
MemoryMax = "2G";
|
MemoryMax = "2G";
|
||||||
OOMScoreAdjust = -900;
|
OOMScoreAdjust = -900;
|
||||||
@@ -349,10 +370,15 @@ in
|
|||||||
# from a dag entry — it still points to the OLD generation at the time our
|
# from a dag entry — it still points to the OLD generation at the time our
|
||||||
# script executes. Instead, read `new-home`, which the activator writes
|
# script executes. Instead, read `new-home`, which the activator writes
|
||||||
# BEFORE any dag entry runs and which already points at the new generation.
|
# BEFORE any dag entry runs and which already points at the new generation.
|
||||||
|
#
|
||||||
|
# The versioned symlink (`home-manager-NN-link`) is found by following
|
||||||
|
# `home-manager` one hop rather than hardcoding `home-manager-24-link`,
|
||||||
|
# so this keeps working across HM major-version bumps.
|
||||||
home.activation.relinkHomeManager = lib.hm.dag.entryAfter [] ''
|
home.activation.relinkHomeManager = lib.hm.dag.entryAfter [] ''
|
||||||
target="$HOME/.local/state/nix/profiles/home-manager-24-link"
|
hmVersioned="$(readlink "$HOME/.local/state/nix/profiles/home-manager" 2>/dev/null || true)"
|
||||||
|
target="$HOME/.local/state/nix/profiles/$hmVersioned"
|
||||||
newGen="$(readlink -e "''${XDG_STATE_HOME:-$HOME/.local/state}/home-manager/gcroots/new-home" 2>/dev/null || true)"
|
newGen="$(readlink -e "''${XDG_STATE_HOME:-$HOME/.local/state}/home-manager/gcroots/new-home" 2>/dev/null || true)"
|
||||||
if [[ -n "$newGen" && "$(readlink -f "$target")" != "$newGen" ]]; then
|
if [[ -n "$hmVersioned" && -n "$newGen" && "$(readlink -f "$target")" != "$newGen" ]]; then
|
||||||
echo "home-manager: relinking $target -> $newGen"
|
echo "home-manager: relinking $target -> $newGen"
|
||||||
ln -sfn "$newGen" "$target"
|
ln -sfn "$newGen" "$target"
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ in
|
|||||||
server-home
|
server-home
|
||||||
services-mnt-folder
|
services-mnt-folder
|
||||||
;
|
;
|
||||||
|
opencode-server-env = "${user-home}/.config/opencode/server.env";
|
||||||
|
|
||||||
user-storage = "${user-home}/Storage";
|
user-storage = "${user-home}/Storage";
|
||||||
wsl-storage = "${wsl-home}/Storage";
|
wsl-storage = "${wsl-home}/Storage";
|
||||||
|
|||||||
@@ -24,95 +24,15 @@ let
|
|||||||
"key.pem"
|
"key.pem"
|
||||||
];
|
];
|
||||||
basePorts = [
|
basePorts = [
|
||||||
# 3x-ui panel + subscription endpoint on the loopback only.
|
# Local-only upstreams for the 3x-ui panel and subscription endpoint.
|
||||||
|
# The direct Xray inbound remains publicly reachable on 8443.
|
||||||
"127.0.0.1:2049:2049/tcp"
|
"127.0.0.1:2049:2049/tcp"
|
||||||
"127.0.0.1:2096:2096/tcp"
|
"127.0.0.1:2096:2096/tcp"
|
||||||
# xray's Reality inbound on the loopback only — nginx stream (in
|
"0.0.0.0:8443:8443/tcp"
|
||||||
# modules/server/nginx.nix) listens on the public 8443 and forwards
|
|
||||||
# here. Going nginx-stream → podman → xray keeps Reality's TLS
|
|
||||||
# ClientHello intact end-to-end; exposing 8443 directly via podman
|
|
||||||
# port-forward mangles it and clients see the fallback cert.
|
|
||||||
"127.0.0.1:15380:8443/tcp"
|
|
||||||
];
|
];
|
||||||
# VDS-only: nginx stream forwards host:443 → 127.0.0.1:15380 →
|
# VDS-only: nginx stream forwards host:443 → 127.0.0.1:15380 →
|
||||||
# container:443, so Xray sees its REALITY inbound on port 443.
|
# container:443, so Xray sees its REALITY inbound on port 443.
|
||||||
realityPorts = lib.optional config.host."3x-ui".reality443Forwarding "127.0.0.1:15380:443/tcp";
|
realityPorts = lib.optional config.host."3x-ui".reality443Forwarding "127.0.0.1:15380:443/tcp";
|
||||||
# Workaround for a 3x-ui panel bug (both 3.8.5 and 3.9.0 reproduce it): when
|
|
||||||
# generating bin/config.json from the inbounds DB rows, the panel drops the
|
|
||||||
# inner `realitySettings.settings.{publicKey,fingerprint,serverName,spiderX,
|
|
||||||
# mldsa65Verify}` block — without which the xray Reality server cannot
|
|
||||||
# complete the auth handshake with any client. The DB has the data; only
|
|
||||||
# the generated config.json is missing it. This script reads DB inside the
|
|
||||||
# running container and re-applies the missing fields to bin/config.json,
|
|
||||||
# then SIGHUPs xray so clients can connect. Runs every 30s; safe to
|
|
||||||
# overlap with the panel's own config writes (it's idempotent and only
|
|
||||||
# touches missing/different fields).
|
|
||||||
# REAL ROOT-CAUSE FIX for the 3x-ui config-gen bug.
|
|
||||||
#
|
|
||||||
# In `internal/web/service/xray.go` the panel's `GetXrayConfig()`
|
|
||||||
# function does this on every config regeneration (xray restart, inbound
|
|
||||||
# update, restartXrayService API call):
|
|
||||||
#
|
|
||||||
# realitySettings, ok2 := stream["realitySettings"].(map[string]any)
|
|
||||||
# if ok2 { delete(realitySettings, "settings") }
|
|
||||||
#
|
|
||||||
# i.e. it explicitly drops the *nested* `realitySettings.settings` block
|
|
||||||
# before serialising to bin/config.json. The panel's inbound DB row
|
|
||||||
# stores these fields under `stream_settings.realitySettings.settings`,
|
|
||||||
# so every regeneration wipes publicKey/fingerprint/serverName/spiderX/
|
|
||||||
# mldsa65Verify from the live xray config, breaking Reality-auth for
|
|
||||||
# every inbound.
|
|
||||||
#
|
|
||||||
# The proper fix is to move these fields from the nested `settings` block
|
|
||||||
# to the *top level* of `realitySettings` directly in the DB. Panel's
|
|
||||||
# delete() targets the nested block only; top-level fields pass through
|
|
||||||
# untouched, and Panel passes them through to bin/config.json correctly.
|
|
||||||
#
|
|
||||||
# The migration is idempotent (no-op once fields are top-level) and is
|
|
||||||
# re-applied on every container start so that any new inbound created
|
|
||||||
# via the panel UI gets migrated automatically.
|
|
||||||
migrateScript = pkgs.writeScript "migrate-3xui-reality.py" ''
|
|
||||||
#!/usr/bin/env python3
|
|
||||||
"""Move Reality fields from nested settings to top-level realitySettings in DB.
|
|
||||||
|
|
||||||
Idempotent. Re-applied on every container start so newly-added inbounds
|
|
||||||
are auto-migrated."""
|
|
||||||
import json, sqlite3, sys
|
|
||||||
FIELDS = ("publicKey", "fingerprint", "serverName", "spiderX", "mldsa65Verify")
|
|
||||||
try:
|
|
||||||
conn = sqlite3.connect("/etc/x-ui/x-ui.db")
|
|
||||||
rows = conn.execute(
|
|
||||||
"SELECT id, stream_settings FROM inbounds "
|
|
||||||
"WHERE stream_settings IS NOT NULL AND protocol='vless'"
|
|
||||||
).fetchall()
|
|
||||||
migrated = 0
|
|
||||||
for rid, ss_json in rows:
|
|
||||||
ss = json.loads(ss_json)
|
|
||||||
rs = ss.get("realitySettings")
|
|
||||||
if not rs:
|
|
||||||
continue
|
|
||||||
inner = rs.get("settings", {})
|
|
||||||
if not inner:
|
|
||||||
continue
|
|
||||||
changed = False
|
|
||||||
for k in FIELDS:
|
|
||||||
v = inner.get(k)
|
|
||||||
if v and not rs.get(k):
|
|
||||||
rs[k] = v
|
|
||||||
changed = True
|
|
||||||
if changed:
|
|
||||||
conn.execute(
|
|
||||||
"UPDATE inbounds SET stream_settings=? WHERE id=?",
|
|
||||||
(json.dumps(ss), rid),
|
|
||||||
)
|
|
||||||
migrated += 1
|
|
||||||
conn.commit()
|
|
||||||
conn.close()
|
|
||||||
print(f"migrated={migrated}")
|
|
||||||
except Exception as e:
|
|
||||||
print(f"ERROR: {e}", file=sys.stderr)
|
|
||||||
sys.exit(1)
|
|
||||||
'';
|
|
||||||
in
|
in
|
||||||
{
|
{
|
||||||
# `host."3x-ui"` options are declared in modules/options.nix: they are set
|
# `host."3x-ui"` options are declared in modules/options.nix: they are set
|
||||||
@@ -131,15 +51,7 @@ in
|
|||||||
oci-containers = {
|
oci-containers = {
|
||||||
backend = "podman";
|
backend = "podman";
|
||||||
containers."3xui_app" = {
|
containers."3xui_app" = {
|
||||||
# Pinned to v3.8.5 — the last release before the panel added the
|
image = "ghcr.io/mhsanaei/3x-ui:latest";
|
||||||
# nested `realitySettings.settings` block for new post-quantum
|
|
||||||
# fields that its own GetXrayConfig then strips on every regenerate.
|
|
||||||
# Both 3.8.5 and 3.9.0 reproduce the bug; we work around it with
|
|
||||||
# migrate-3xui-reality.service, which moves the affected fields
|
|
||||||
# to the top level of `realitySettings` in the DB so they survive
|
|
||||||
# the panel's delete() of the nested block. The migration runs
|
|
||||||
# once on every container start, idempotently.
|
|
||||||
image = "ghcr.io/mhsanaei/3x-ui:v3.8.5";
|
|
||||||
environment = {
|
environment = {
|
||||||
"XRAY_VMESS_AEAD_FORCED" = "false";
|
"XRAY_VMESS_AEAD_FORCED" = "false";
|
||||||
"XUI_ENABLE_FAIL2BAN" = "true";
|
"XUI_ENABLE_FAIL2BAN" = "true";
|
||||||
@@ -172,35 +84,10 @@ containers."3xui_app" = {
|
|||||||
TimeoutSec = 300;
|
TimeoutSec = 300;
|
||||||
};
|
};
|
||||||
script = ''
|
script = ''
|
||||||
podman pull ghcr.io/mhsanaei/3x-ui:v3.8.5
|
podman pull ghcr.io/mhsanaei/3x-ui:latest
|
||||||
systemctl restart podman-3xui_app.service
|
systemctl restart podman-3xui_app.service
|
||||||
'';
|
'';
|
||||||
};
|
};
|
||||||
# Real fix for the panel config-gen bug: run the DB migration once
|
|
||||||
# after each container start so any new inbounds (created via panel UI
|
|
||||||
# or API) have their Reality public fields moved to top-level on the
|
|
||||||
# next launch. The migration is idempotent — a no-op once fields are
|
|
||||||
# top-level — so it's safe to run on every container start.
|
|
||||||
#
|
|
||||||
# The script is piped into the container via stdin rather than
|
|
||||||
# referenced by its host-side /nix/store path (which does not exist
|
|
||||||
# inside the container). Restart=on-failure so a transient container
|
|
||||||
# race (e.g. 3x-ui still seeding the DB on first start) is retried
|
|
||||||
# instead of silently passing.
|
|
||||||
"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" ];
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
# Starts/stops together with all 3x-ui compose resources.
|
# Starts/stops together with all 3x-ui compose resources.
|
||||||
targets."podman-compose-3x-ui-root" = {
|
targets."podman-compose-3x-ui-root" = {
|
||||||
|
|||||||
@@ -0,0 +1,183 @@
|
|||||||
|
{
|
||||||
|
config,
|
||||||
|
lib,
|
||||||
|
pkgs,
|
||||||
|
xlib,
|
||||||
|
...
|
||||||
|
}:
|
||||||
|
# Open WebUI — self-hosted AI chat UI, deployed here as a UI-client for
|
||||||
|
# external LLM APIs (OpenAI-compatible: OpenAI, OpenRouter, vLLM, LM Studio,
|
||||||
|
# GroqCloud, Mistral, etc.). Runs locally without bundled Ollama.
|
||||||
|
#
|
||||||
|
# Architecture mirrors modules/containers/{3x-ui,tape-rotation}.nix:
|
||||||
|
# - one container, one systemd unit + a root.target
|
||||||
|
# - data on /mnt/services/nodes/<host>/open-webui/data → /app/backend/data
|
||||||
|
# (see AGENTS.md §Подтверждённые инварианты #2 — guard chain is satisfied
|
||||||
|
# because mkServiceStorage already bind-mounts /mnt/services on boot)
|
||||||
|
# - host port bound to 127.0.0.1 only — the only ingress is the nginx
|
||||||
|
# vhost open.zeroq.su (no firewall exception, no public exposure).
|
||||||
|
# Same pattern as 3x-ui.nix:30-31 binding the panel to 127.0.0.1:2049.
|
||||||
|
#
|
||||||
|
# Secrets come from a single sops-encrypted dotenv file
|
||||||
|
# (format = "dotenv", key = "" → whole file). The owner creates the
|
||||||
|
# encrypted file with `sops modules/containers/secrets/open-webui.env`
|
||||||
|
# after filling the .example template next to it.
|
||||||
|
#
|
||||||
|
# Hard requirement (env.py:762 — SystemExit at startup):
|
||||||
|
# WEBUI_SECRET_KEY must be set when WEBUI_AUTH=true.
|
||||||
|
# Generate with: head -c 24 /dev/urandom | base64
|
||||||
|
#
|
||||||
|
# Reverse-proxy requirements (docs.openwebui.com/reference/https):
|
||||||
|
# - WEBUI_URL = public HTTPS URL (OAuth callbacks, internal links)
|
||||||
|
# - CORS_ALLOW_ORIGIN = same public URL (else WebSocket fails silently)
|
||||||
|
# - proxy_buffering off (else SSE streaming breaks markdown)
|
||||||
|
# - proxy_read_timeout ≥ 300s (LLM responses can run minutes)
|
||||||
|
# - WebSocket pass-through (Upgrade / Connection headers)
|
||||||
|
# All of the above are wired into modules/server/nginx.nix:open.zeroq.su.
|
||||||
|
let
|
||||||
|
panel = "${xlib.dirs.services-nodes-folder}/${xlib.device.hostname}/open-webui";
|
||||||
|
in
|
||||||
|
{
|
||||||
|
virtualisation = {
|
||||||
|
podman = {
|
||||||
|
enable = true;
|
||||||
|
autoPrune = {
|
||||||
|
enable = true;
|
||||||
|
flags = [ "--all" ];
|
||||||
|
};
|
||||||
|
dockerCompat = true;
|
||||||
|
};
|
||||||
|
oci-containers = {
|
||||||
|
backend = "podman";
|
||||||
|
containers."open-webui" = {
|
||||||
|
image = "ghcr.io/open-webui/open-webui:main";
|
||||||
|
environment = {
|
||||||
|
TZ = "Europe/Moscow";
|
||||||
|
# Container-internal port (also the upstream default).
|
||||||
|
PORT = "8080";
|
||||||
|
# Required when behind a public HTTPS URL — OAuth callbacks,
|
||||||
|
# share links and internal redirects resolve against this.
|
||||||
|
WEBUI_URL = "https://open.zeroq.su";
|
||||||
|
# Must exactly match WEBUI_URL or WebSocket connections fail
|
||||||
|
# silently (per upstream HTTPS docs). nginx (127.0.0.1) is the
|
||||||
|
# only allowed origin, so a single explicit URL is enough.
|
||||||
|
CORS_ALLOW_ORIGIN = "https://open.zeroq.su";
|
||||||
|
# Honour X-Forwarded-* headers from the reverse proxy.
|
||||||
|
FORWARDED_ALLOW_IPS = "127.0.0.1";
|
||||||
|
# Closed self-hosted: admin creates accounts manually after the
|
||||||
|
# first boot via WEBUI_ADMIN_* from the sops env file.
|
||||||
|
WEBUI_AUTH = "True";
|
||||||
|
ENABLE_SIGNUP = "False";
|
||||||
|
ENABLE_LOGIN_FORM = "True";
|
||||||
|
ENABLE_VERSION_UPDATE_CHECK = "False";
|
||||||
|
# Out of the box Open WebUI phones home to Scarf. The opt-outs
|
||||||
|
# below preserve the previous behaviour from the stub at
|
||||||
|
# modules/server/open-webui.nix (still in tree, commented out in
|
||||||
|
# modules/server/default.nix:41) until that file is removed.
|
||||||
|
ANONYMIZED_TELEMETRY = "False";
|
||||||
|
DO_NOT_TRACK = "True";
|
||||||
|
SCARF_NO_ANALYTICS = "True";
|
||||||
|
# No bundled providers. Owners wire OPENAI_API_KEY /
|
||||||
|
# OPENAI_API_BASE_URL / etc. either via the sops env file
|
||||||
|
# (see sops.secrets."open-webui-env" below) or interactively in
|
||||||
|
# Admin → Settings → Connections once WEBUI_AUTH=true. Empty
|
||||||
|
# base URL is intentional: an empty OPENAI_API_BASE_URL
|
||||||
|
# disables the default /ollama proxy and prevents the container
|
||||||
|
# from probing localhost:11434 on boot.
|
||||||
|
OLLAMA_BASE_URL = "";
|
||||||
|
OPENAI_API_BASE_URL = "";
|
||||||
|
};
|
||||||
|
# Mount the decrypted dotenv only when the sops file exists. Until
|
||||||
|
# the owner creates ./secrets/open-webui.env, the inline environment
|
||||||
|
# is the only source — and the container will refuse to start with
|
||||||
|
# WEBUI_SECRET_KEY="" (env.py:762 — SystemExit). The error message is
|
||||||
|
# the clear signal that the secret needs to be created.
|
||||||
|
environmentFiles = lib.optional (builtins.pathExists ./secrets/open-webui.env)
|
||||||
|
"/run/secrets/open-webui-env";
|
||||||
|
volumes = [
|
||||||
|
"${panel}/data:/app/backend/data:rw"
|
||||||
|
];
|
||||||
|
log-driver = "journald";
|
||||||
|
# 127.0.0.1 only — the container is not exposed externally.
|
||||||
|
ports = [ "127.0.0.1:8080:8080/tcp" ];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
# Enable container name DNS for all Podman networks (mirrors 3x-ui.nix:120-128).
|
||||||
|
networking.firewall.interfaces =
|
||||||
|
let
|
||||||
|
matchAll = if !config.networking.nftables.enable then "podman+" else "podman*";
|
||||||
|
in
|
||||||
|
{
|
||||||
|
"${matchAll}".allowedUDPPorts = [ 53 ];
|
||||||
|
};
|
||||||
|
|
||||||
|
systemd = {
|
||||||
|
services = {
|
||||||
|
"podman-open-webui" = {
|
||||||
|
serviceConfig.Restart = lib.mkOverride 90 "always";
|
||||||
|
partOf = [ "podman-compose-open-webui-root.target" ];
|
||||||
|
wantedBy = [ "podman-compose-open-webui-root.target" ];
|
||||||
|
};
|
||||||
|
"podman-update-open-webui" = {
|
||||||
|
path = [ pkgs.podman ];
|
||||||
|
serviceConfig = {
|
||||||
|
Type = "oneshot";
|
||||||
|
TimeoutSec = 300;
|
||||||
|
};
|
||||||
|
script = ''
|
||||||
|
podman pull ghcr.io/open-webui/open-webui:main
|
||||||
|
systemctl restart podman-open-webui.service
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
};
|
||||||
|
# Starts/stops together with the open-webui container.
|
||||||
|
targets."podman-compose-open-webui-root" = {
|
||||||
|
unitConfig.Description = "Root target for open-webui.";
|
||||||
|
wantedBy = [ "multi-user.target" ];
|
||||||
|
};
|
||||||
|
# Enable automatic image updates:
|
||||||
|
# systemd.timers."podman-update-open-webui" = {
|
||||||
|
# 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")
|
||||||
|
(xlib.helpers.mkTmpfile "d" "${xlib.dirs.services-nodes-folder}/${xlib.device.hostname}" "0755"
|
||||||
|
"root"
|
||||||
|
"root"
|
||||||
|
)
|
||||||
|
(xlib.helpers.mkTmpfile "d" panel "0755" "root" "root")
|
||||||
|
(xlib.helpers.mkTmpfile "d" "${panel}/data" "0755" "root" "root")
|
||||||
|
# Relabel panel dir for SELinux so containers can access it.
|
||||||
|
(xlib.helpers.mkTmpfile "Z" panel "0755" "root" "root")
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
# sops secret is declared only when the encrypted file actually exists,
|
||||||
|
# so the flake still evaluates (and rebuilds apply) on a host that hasn't
|
||||||
|
# created the secret yet. Once ./secrets/open-webui.env is created and
|
||||||
|
# encrypted with `sops modules/containers/secrets/open-webui.env`, this
|
||||||
|
# condition becomes true and the secret is wired in.
|
||||||
|
#
|
||||||
|
# Hard requirement (env.py:762 — SystemExit at startup):
|
||||||
|
# WEBUI_SECRET_KEY must be present in the env file when WEBUI_AUTH=true.
|
||||||
|
sops.secrets = lib.optionalAttrs (builtins.pathExists ./secrets/open-webui.env) {
|
||||||
|
"open-webui-env" = {
|
||||||
|
# key = "" → decrypt the whole file, not a single key.
|
||||||
|
# format = "dotenv" → the file IS one .env ready for environmentFiles:
|
||||||
|
# every non-comment KEY=VALUE line lands in the container environment.
|
||||||
|
# After this module is wired the file is mounted at
|
||||||
|
# /run/secrets/open-webui-env (sops-nix default for this attr name).
|
||||||
|
key = "";
|
||||||
|
format = "dotenv";
|
||||||
|
sopsFile = ./secrets/open-webui.env;
|
||||||
|
mode = "0400";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
WEBUI_SECRET_KEY=ENC[AES256_GCM,data:l6USiQmMkMz/zniIebT35HfXxZI8qrhe6Cdl8hpT98c=,iv:Po9bova4dfiykl+ckH4v6DqzSJOgULx7ro3kXMFRvFI=,tag:7jurD14N7QDRHX5ruFDEeQ==,type:str]
|
||||||
|
WEBUI_ADMIN_EMAIL=ENC[AES256_GCM,data:EZgNXSpbpROz3TZRLaSQTQ==,iv:i98kChemam9nB3iCMwCTRYB69b2eUBy6QCoeZ3AjAP0=,tag:INnB1Zp9VA6M//yyAhRh3A==,type:str]
|
||||||
|
WEBUI_ADMIN_NAME=ENC[AES256_GCM,data:8l8dJ85p,iv:LltveatNlX4FEGmxhtYLYmviIvLK0xSMuVsk9DRRglw=,tag:OrTlnOLlQe03hoYTkaPotg==,type:str]
|
||||||
|
WEBUI_ADMIN_PASSWORD=ENC[AES256_GCM,data:PKfZQHiAa96vcGUCGigjXQ==,iv:WLb1mgCV3IJHnHcBvf4yAPiautgXqCC2L2bzt6i0t7U=,tag:nw78tvyUOYmGMunBwvIr+A==,type:str]
|
||||||
|
sops_age__list_0__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSB4d3pNVlZEQS85d2R3WUZX\nKy9iOFZ4MjU2UkQwVHdobTlBY3l0MldONWlvCnU5dllobmtLQXlMM28xN0FSTmxD\nNnhmZVRwdnpaZ3NkZDVCWERBckZiQjgKLS0tIFBPeXZMNXRjZ3pBQUlndXB5MTBB\nMVdhSGJvZkE2VzZiZ2VxL0RKTDJ2aDQKsxlibeAoO74411VemXT+8UBG0JdemgHD\nVONIEp/VsbEJDWgDfSGhLaH4KN2hTsCtyhdkCU0FohgWB+xWyJz6MA==\n-----END AGE ENCRYPTED FILE-----\n
|
||||||
|
sops_age__list_0__map_recipient=age13l2gtk0nzr484zprp7e0pkrt0ne0j4asyn2pjmlaw73nte7t7d8q4sqtxm
|
||||||
|
sops_lastmodified=2026-10-07T09:32:44Z
|
||||||
|
sops_mac=ENC[AES256_GCM,data:SSHgEEc3u2Zf13q5W4LD7bkrVlQTzLIYiZWXhBiDS6CjC4fUZcJq99OTSTNixzqpxSdnjeRtmzA6d6vGNfxvEOmsE1f4hBNm9ps0RHU4yLfr5vQG9Ff973uDwDU+JMqP3aMU+xpUuPkhW0zRpeST+w0thuPtjzhR2z/A2yPvYzU=,iv:5/cfD51eK0R9cGsr4wZu6CnwEdMjP0CYj3CM7+X4XQg=,tag:1FRcAULHG+XzmRiTMK8aWQ==,type:str]
|
||||||
|
sops_unencrypted_suffix=_unencrypted
|
||||||
|
sops_version=3.13.3
|
||||||
@@ -103,6 +103,7 @@
|
|||||||
net-tools
|
net-tools
|
||||||
usbtree
|
usbtree
|
||||||
iperf3
|
iperf3
|
||||||
|
glow
|
||||||
# lazydocker
|
# lazydocker
|
||||||
# dtop
|
# dtop
|
||||||
# framework-tool-tui
|
# framework-tool-tui
|
||||||
|
|||||||
@@ -20,11 +20,13 @@
|
|||||||
192.168.1.20 kuma.zeroq.su
|
192.168.1.20 kuma.zeroq.su
|
||||||
192.168.1.20 navidrome.zeroq.su
|
192.168.1.20 navidrome.zeroq.su
|
||||||
192.168.1.20 nextcloud.zeroq.su
|
192.168.1.20 nextcloud.zeroq.su
|
||||||
|
192.168.1.20 open.zeroq.su
|
||||||
192.168.1.20 office.zeroq.su
|
192.168.1.20 office.zeroq.su
|
||||||
192.168.1.20 pdf.zeroq.su
|
192.168.1.20 pdf.zeroq.su
|
||||||
192.168.1.20 syncthing.zeroq.su
|
192.168.1.20 syncthing.zeroq.su
|
||||||
192.168.1.20 talk.zeroq.su
|
192.168.1.20 talk.zeroq.su
|
||||||
192.168.1.20 turn.zeroq.su
|
192.168.1.20 turn.zeroq.su
|
||||||
|
192.168.1.20 vtimeline.zeroq.su
|
||||||
fallthrough
|
fallthrough
|
||||||
}
|
}
|
||||||
cache 300
|
cache 300
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
{
|
{
|
||||||
imports = [
|
imports = [
|
||||||
../containers/3x-ui.nix
|
../containers/3x-ui.nix
|
||||||
|
../containers/open-webui.nix
|
||||||
../containers/tape-rotation.nix
|
../containers/tape-rotation.nix
|
||||||
../pkgs/beets.nix
|
../pkgs/beets.nix
|
||||||
./acme.nix
|
./acme.nix
|
||||||
@@ -38,7 +39,6 @@
|
|||||||
# ./n8n.nix
|
# ./n8n.nix
|
||||||
# ./netdata.nix
|
# ./netdata.nix
|
||||||
# ./nfs.nix
|
# ./nfs.nix
|
||||||
# ./open-webui.nix
|
|
||||||
# ./rsync.nix
|
# ./rsync.nix
|
||||||
# ./step-ca.nix
|
# ./step-ca.nix
|
||||||
# ./stirling-pdf.nix
|
# ./stirling-pdf.nix
|
||||||
|
|||||||
+67
-19
@@ -65,6 +65,12 @@ let
|
|||||||
domain = "tape-rotation.zeroq.su";
|
domain = "tape-rotation.zeroq.su";
|
||||||
port = 5174;
|
port = 5174;
|
||||||
}
|
}
|
||||||
|
# NOTE: open.zeroq.su is intentionally NOT in this `sites` list —
|
||||||
|
# mkProxy hard-codes ${server} = 192.168.1.20, but the Open WebUI
|
||||||
|
# container binds to 127.0.0.1:8080 only (loopback, see
|
||||||
|
# modules/containers/open-webui.nix). The vhost is added directly
|
||||||
|
# to `virtualHosts` below, alongside x.zeroq.su (3x-ui panel,
|
||||||
|
# same loopback-only pattern).
|
||||||
{
|
{
|
||||||
domain = "navidrome.zeroq.su";
|
domain = "navidrome.zeroq.su";
|
||||||
port = 4533;
|
port = 4533;
|
||||||
@@ -117,6 +123,21 @@ in
|
|||||||
forceSSL = true;
|
forceSSL = true;
|
||||||
enableACME = true;
|
enableACME = true;
|
||||||
};
|
};
|
||||||
|
# vtimeline.zeroq.su — static site behind HTTP basic auth.
|
||||||
|
# Files live under /home/oqyude/External/Git/VeeamTimelineView/public_html,
|
||||||
|
# which is bind-mounted to /var/lib/vtimeline (see systemd.mounts below)
|
||||||
|
# because /home/oqyude is mode 700 and the nginx user (uid 60) cannot
|
||||||
|
# traverse it. Credentials are pulled from sops; see the sops.secrets
|
||||||
|
# block at the bottom of this file.
|
||||||
|
"vtimeline.zeroq.su" = {
|
||||||
|
forceSSL = true;
|
||||||
|
enableACME = true;
|
||||||
|
root = "/var/lib/vtimeline";
|
||||||
|
extraConfig = ''
|
||||||
|
auth_basic "vtimeline";
|
||||||
|
auth_basic_user_file ${config.sops.secrets.vtimeline-htpasswd.path};
|
||||||
|
'';
|
||||||
|
};
|
||||||
"pdf.private" = {
|
"pdf.private" = {
|
||||||
forceSSL = false;
|
forceSSL = false;
|
||||||
enableACME = false;
|
enableACME = false;
|
||||||
@@ -162,6 +183,25 @@ in
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
# Open WebUI — same loopback-only pattern as x.zeroq.su above.
|
||||||
|
# The container listens on 127.0.0.1:8080 (modules/containers/open-webui.nix),
|
||||||
|
# so we proxy_pass to 127.0.0.1, not the LAN IP. The two extra
|
||||||
|
# directives are required by the upstream HTTPS docs:
|
||||||
|
# proxy_buffering off for SSE streaming (markdown in chat breaks
|
||||||
|
# under the default `proxy_buffering on` from recommendedProxySettings),
|
||||||
|
# and a 300 s read timeout for long LLM completions.
|
||||||
|
"open.zeroq.su" = {
|
||||||
|
forceSSL = true;
|
||||||
|
enableACME = true;
|
||||||
|
locations."/" = {
|
||||||
|
proxyPass = "http://127.0.0.1:8080";
|
||||||
|
proxyWebsockets = true;
|
||||||
|
};
|
||||||
|
extraConfig = ''
|
||||||
|
proxy_buffering off;
|
||||||
|
proxy_read_timeout 300s;
|
||||||
|
'';
|
||||||
|
};
|
||||||
"zeroq.su" = {
|
"zeroq.su" = {
|
||||||
forceSSL = true;
|
forceSSL = true;
|
||||||
enableACME = true;
|
enableACME = true;
|
||||||
@@ -225,26 +265,34 @@ in
|
|||||||
networking.firewall.allowedTCPPorts = [
|
networking.firewall.allowedTCPPorts = [
|
||||||
80
|
80
|
||||||
443
|
443
|
||||||
8443
|
|
||||||
];
|
];
|
||||||
|
|
||||||
# TCP-level proxy for the 3x-ui xray inbound on 8443. nginx doesn't
|
# Bind-mount the vtimeline source tree into /var/lib so the nginx user
|
||||||
# unwrap TLS here — `proxy_pass` just relays opaque TCP bytes between
|
# (uid 60) doesn't have to traverse /home/oqyude (mode 700). The mount is
|
||||||
# the client and the xray inside the 3x-ui container. Podman's
|
# lazy (x-systemd.automount) and nofail, so a missing /home/oqyude/External
|
||||||
# userspace port-forward mangles the Reality ClientHello, so we go
|
# only shows up as a per-request 500/403, never as a hard boot failure.
|
||||||
# via nginx stream (same pattern as VDS uses for port 443) instead:
|
systemd.mounts = [
|
||||||
# client → nginx stream :8443 → 127.0.0.1:15380 → podman → xray :8443.
|
(xlib.helpers.mkSystemdBind {
|
||||||
# Reality auth and TLS are preserved end-to-end.
|
what = "/home/oqyude/External/Git/VeeamTimelineView/public_html";
|
||||||
services.nginx.streamConfig = ''
|
where = "/var/lib/vtimeline";
|
||||||
upstream xray_in_8443 {
|
})
|
||||||
server 127.0.0.1:15380;
|
];
|
||||||
}
|
systemd.tmpfiles.rules = [
|
||||||
|
(xlib.helpers.mkTmpfile "d" "/var/lib/vtimeline" "0755" "nginx" "nginx")
|
||||||
|
];
|
||||||
|
|
||||||
server {
|
# htpasswd file for vtimeline.zeroq.su basic auth.
|
||||||
listen 8443;
|
# Source layout (per modules/server/secrets/vtimeline-htpasswd.yaml):
|
||||||
proxy_pass xray_in_8443;
|
# passwords: |
|
||||||
proxy_timeout 600s;
|
# <user>:<bcrypt-or-apr1-hash>
|
||||||
proxy_connect_timeout 5s;
|
# sops-nix extracts the `passwords` key as the only decrypted content.
|
||||||
}
|
# The resulting file is consumed by nginx via auth_basic_user_file.
|
||||||
'';
|
sops.secrets.vtimeline-htpasswd = {
|
||||||
|
format = "yaml";
|
||||||
|
key = "passwords";
|
||||||
|
sopsFile = ./secrets/vtimeline-htpasswd.yaml;
|
||||||
|
owner = "nginx";
|
||||||
|
group = "nginx";
|
||||||
|
mode = "0640";
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,28 +0,0 @@
|
|||||||
{
|
|
||||||
config,
|
|
||||||
inputs,
|
|
||||||
lib,
|
|
||||||
pkgs,
|
|
||||||
...
|
|
||||||
}:
|
|
||||||
{
|
|
||||||
services = {
|
|
||||||
open-webui = {
|
|
||||||
enable = false;
|
|
||||||
host = "0.0.0.0";
|
|
||||||
port = 11112;
|
|
||||||
openFirewall = true;
|
|
||||||
environment = {
|
|
||||||
ANONYMIZED_TELEMETRY = "False";
|
|
||||||
DO_NOT_TRACK = "True";
|
|
||||||
SCARF_NO_ANALYTICS = "True";
|
|
||||||
OPENAI_API_BASE_URL = "http://192.168.1.100:1234/v1";
|
|
||||||
#OLLAMA_API_BASE_URL = "http://127.0.0.1:1234";
|
|
||||||
WEBUI_AUTH = "True";
|
|
||||||
ENABLE_SIGNUP = "False";
|
|
||||||
ENABLE_SIGNUP_PASSWORD_CONFIRMATION = "True";
|
|
||||||
ENABLE_VERSION_UPDATE_CHECK = "False";
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
#ENC[AES256_GCM,data:UW49BNUTjSgrBCXW4f5/7lJPUqXZp1U1iFHEvR4QOGm9KWPwAqYTg+i4I2dWeMTP4PqkFA8ry+TtFUHV+UTyEJxMbTZPSaqXJmQAh7k07Trv17snVEtvotzTHn5yjZwAVvPi,iv:/H/FXmF0n86xlE4wA/oBioEYJkc14+mLzSL61qJk1z8=,tag:kbCFXytipQ+FTP1OiHfO8w==,type:comment]
|
||||||
|
#ENC[AES256_GCM,data:OWEZavcPrsSst1YUaspDqXeYx1HTLsw2zT9j2ao8mmxrgjgVJ2teclWQT6R8D9OxMwVh/Cbd25XtwwsgWpwMzQChSAD4oFPofvujpFHhndwuuyA12kA/rGRp6oLF5ZVavFR/4oTjm6xDE6AlwgKsT64=,iv:15ZKD0tvJFbRLaX/KclDaUc7TstivGItyTxmN81HVCQ=,tag:9871kdKv+GhH1/Gyy/oYPg==,type:comment]
|
||||||
|
#
|
||||||
|
#ENC[AES256_GCM,data:6P4BRz2PQ76929PaDFp9KHXKgVx9C6FncoQBx7ia/GfeR86O/ZCtf9k=,iv:SNSpMmo4LqxHl3WE0VeW9gyJLfTwhrlLgbpHNgM/zuI=,tag:M2b8hGlAUOro8Pk79YV3mA==,type:comment]
|
||||||
|
#ENC[AES256_GCM,data:1uTKcKavRm0dgeTKk4ReXSzxd6hUfQ2NxvKbtME1kJRWeyUuS/H2DYRXYWLun6O/xXA=,iv:1Fo5dTDuJXRkfTjPvCNRLzPgVcusZXB/S5TVimqPQb4=,tag:jfdXqAfsE2DCyfRdJFS70w==,type:comment]
|
||||||
|
#ENC[AES256_GCM,data:OIyr3AEEKrU73nHK3X5vJ6+YkBCvDVBnXYiEkI/yutRMASnP6ZBc1DmLxV+bWKS7d1aJxA2k3S+/IKN9UhwSa6AfR8iuvLSFnkMZlgOyulTb+I1Qdg==,iv:ZjLfBkAjJT99k3c1qtRglQuwoA8eVGtoHjJJNtHsqpQ=,tag:RYdq29YgqoFzzEwU0UQ5Vw==,type:comment]
|
||||||
|
#
|
||||||
|
#ENC[AES256_GCM,data:WGLSnMuM1Kj6V/bUSZKQVdu3M3SmR7ADrQK0WuGufRmg1Z2q/I8eeP3mKFkk9EobYV7fHab34B7CCDMtQemcvV92lF4+e59ggfxP53nrVsLh8ZWIxJycneY=,iv:UUQZkq+WP8muG0XUN1TJ4fz6Hen54JO+4of/YiFamEE=,tag:Zac0l31mULvA/2p2GJBfGg==,type:comment]
|
||||||
|
#ENC[AES256_GCM,data:sXr+q5pUauY1atCv5H0X1C53b8pnO0yKwb6EbizJkTqmoajaSu/rp4vtfZ1eWOffyNwrUZoGsB4kauT75H/kpEJ3V+g0Hizu7JozxLji9hUdugfbQKFFqbD/cm+ctj70GpY=,iv:YclhEQ+sX6ae+y8KVgut53oQlVCKAm9T8J3xMM9r174=,tag:/tZucqVbVLPeCi4re3x4ZQ==,type:comment]
|
||||||
|
passwords: ENC[AES256_GCM,data:s49DRPQO5DcFeZQGwBQ01Eh7mgSVCrPl2u3On3msqPmm/VRBst4RigDFS6xKzPPSHbkinLMGxkOhXPuegGPzRgs=,iv:XBuodKnec/qNsPXXDKCsVgTl39EgZZvWsyRPZ6lN9m0=,tag:nO9MWneybijH9ZIeXGPQVw==,type:str]
|
||||||
|
sops:
|
||||||
|
age:
|
||||||
|
- enc: |
|
||||||
|
-----BEGIN AGE ENCRYPTED FILE-----
|
||||||
|
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBDWXliaFM4RkFmZE1yM0N1
|
||||||
|
blJnTmp3Q2p2ZHM3THlyUGpQckNEcC9EZ2c0CkFxU0pUTmVHNDZXYS9STDVTamIr
|
||||||
|
M3A4VlAzdzFEYTUydGF2T01DNFkxT0EKLS0tIDlSS2s1YjF4TmkveHd4LzBRbTI4
|
||||||
|
anhpeUZ1VUFXYWVObTU2YVpCaTFXN00KwMHeXtaKxMpdLPRANabj+Vpxx5WLsyPW
|
||||||
|
T9npuQcI52YaXuNpUy+MtWNASwSXvmA7nl4KJNLCWAhgGKrd48Hwxw==
|
||||||
|
-----END AGE ENCRYPTED FILE-----
|
||||||
|
recipient: age13l2gtk0nzr484zprp7e0pkrt0ne0j4asyn2pjmlaw73nte7t7d8q4sqtxm
|
||||||
|
lastmodified: "2026-10-07T14:31:12Z"
|
||||||
|
mac: ENC[AES256_GCM,data:nu44CjCVES+B+UI+6xwT3fCEN958FuKH7eHUTr+XEoHEGfh2Nx+5G5VciJD1TcsQ9yb0C1uWEGkCH8wrjcUEEDNe9MPVGqsREV7fpmO9Cr0wrW5Ji8gnbTGTjI7GswoYG3jUtMzdktBJnX8g6vit2VE7s9l+mE2S3YH3RXyHBDE=,iv:iff4ebSxNFumw1b82FEd0IdWBHGMOowG3LTQui7wTyg=,tag:TGhNeml/F9vtMrJm7d6MJA==,type:str]
|
||||||
|
unencrypted_suffix: _unencrypted
|
||||||
|
version: 3.13.3
|
||||||
+9
-1
@@ -68,6 +68,11 @@ in
|
|||||||
hashedPasswordFile = config.sops.secrets.hashed_password.path; # hashed_password
|
hashedPasswordFile = config.sops.secrets.hashed_password.path; # hashed_password
|
||||||
homeMode = "700";
|
homeMode = "700";
|
||||||
home = "/home/${user}";
|
home = "/home/${user}";
|
||||||
|
# Linger keeps `user@<uid>.service` (the systemd user manager) alive
|
||||||
|
# across logouts, so user services like opencode-web survive when no
|
||||||
|
# SSH/login session is active. Without this the service is torn down
|
||||||
|
# together with the user manager on the last session close.
|
||||||
|
linger = true;
|
||||||
extraGroups = [
|
extraGroups = [
|
||||||
"audio"
|
"audio"
|
||||||
"disk"
|
"disk"
|
||||||
@@ -106,8 +111,11 @@ in
|
|||||||
# Decrypted as a single dotenv file (no `key`) and consumed by the
|
# Decrypted as a single dotenv file (no `key`) and consumed by the
|
||||||
# systemd user unit opencode-web as EnvironmentFile.
|
# systemd user unit opencode-web as EnvironmentFile.
|
||||||
# Source: secrets/opencode.env (encrypted, see sops/age below).
|
# Source: secrets/opencode.env (encrypted, see sops/age below).
|
||||||
|
# Path is shared with home/modules/opencode.nix via xlib.dirs so the
|
||||||
|
# sops materialization and the systemd EnvironmentFile can never
|
||||||
|
# silently desync.
|
||||||
opencode_server = mkUserSecret {
|
opencode_server = mkUserSecret {
|
||||||
path = "${xlib.dirs.user-home}/.config/opencode/server.env";
|
path = xlib.dirs.opencode-server-env;
|
||||||
mode = "0600";
|
mode = "0600";
|
||||||
format = "dotenv";
|
format = "dotenv";
|
||||||
sopsFile = ../secrets/opencode.env;
|
sopsFile = ../secrets/opencode.env;
|
||||||
|
|||||||
@@ -1,52 +0,0 @@
|
|||||||
# Состояние и следующий шаг (после /compact)
|
|
||||||
|
|
||||||
## Где мы
|
|
||||||
- В `modules/containers/3x-ui.nix`:
|
|
||||||
- `patchScript` (костыльник patcher) **заменён** на `migrateScript` — идемпотентная миграция БД, перемещает `publicKey/fingerprint/serverName/spiderX/mldsa65Verify` из nested `realitySettings.settings` в **top-level** `realitySettings`
|
|
||||||
- systemd-сервис `patch-3xui-xray-config` **заменён** на `migrate-3xui-reality` (oneshot, after=`podman-3xui_app.service`)
|
|
||||||
- **timer удалён** (больше не нужен)
|
|
||||||
- Image остался `v3.8.5` (но v3.8.5 и v3.9.0 имеют один баг — раздельные поля на top-level в БД решают)
|
|
||||||
- Комментарий к image обновлён — объясняет что баг в обоих версиях, лечится миграцией
|
|
||||||
|
|
||||||
## Что делать (А → Б → В)
|
|
||||||
|
|
||||||
### A. Доразвернуть текущий rebuild
|
|
||||||
Текущая команда зависла с `nixos-rebuild-switch-to-configuration.service was already loaded` (предыдущий прогон не очистился).
|
|
||||||
Что сделать:
|
|
||||||
```bash
|
|
||||||
sudo systemctl stop nixos-rebuild-switch-to-configuration.service 2>/dev/null
|
|
||||||
sudo pkill -f switch-to-configuration 2>/dev/null
|
|
||||||
sleep 3
|
|
||||||
sudo nixos-rebuild switch
|
|
||||||
```
|
|
||||||
|
|
||||||
### Б. Проверить что миграция работает
|
|
||||||
После успешного rebuild и старта контейнера:
|
|
||||||
1. `sudo systemctl status migrate-3xui-reality` — должен быть `inactive (dead)` (success)
|
|
||||||
2. `sudo journalctl -u migrate-3xui-reality --since 5m` — должно быть `migrated=0` (idempotent, второй раз)
|
|
||||||
3. `sudo podman exec 3xui_app python3 -c "import json; c=json.load(open('/app/bin/config.json')); ib=c['inbounds'][1]; rs=ib['streamSettings']['realitySettings']; print(rs.get('publicKey','MISSING')[:25])"` — должно быть `K0Ra5yH4Ll_bB-dBmwZcPvYxr`
|
|
||||||
4. `sudo podman exec 3xui_app python3 -c "import sqlite3,json; c=sqlite3.connect('/etc/x-ui/x-ui.db'); rs=json.loads(c.execute('SELECT stream_settings FROM inbounds WHERE id=53').fetchone()[0])['realitySettings']; print('top publicKey:', 'YES' if rs.get('publicKey') else 'NO'); print('nested settings:', rs.get('settings','NONE'))"` — должно быть `YES` и `NONE`
|
|
||||||
|
|
||||||
### В. Итоговый commit
|
|
||||||
После успешного теста:
|
|
||||||
```bash
|
|
||||||
cd /etc/nixos
|
|
||||||
sudo git add modules/containers/3x-ui.nix
|
|
||||||
sudo git commit -m "3x-ui: migrate Reality fields to top-level (root-cause fix for panel config-gen bug)"
|
|
||||||
```
|
|
||||||
|
|
||||||
## Контекст
|
|
||||||
- Container: `ghcr.io/mhsanaei/3x-ui:v3.8.5`, xray 26.9.30
|
|
||||||
- nginx stream :8443 → podman-proxy :15380 → xray :8443 (ранее подтверждена)
|
|
||||||
- DB уже мигрирована (поля на top-level) — миграция только идемпотентно проверяет на повторе
|
|
||||||
- Тест с 5 регенерациями подтвердил: publicKey/fingerprint/spiderX/mldsa65Verify остаются в config.json
|
|
||||||
- Otreca (VDS) — ssh затянут на tailscale-only, deploy-rs деплоит
|
|
||||||
|
|
||||||
## Ключевые файлы
|
|
||||||
- `/etc/nixos/modules/containers/3x-ui.nix` — NixOS-модуль (требует deploy)
|
|
||||||
- `/etc/nixos/configurations/vds.nix` — VDS ssh на tailscale (уже закоммичен и задеплоен)
|
|
||||||
- `/mnt/services/nodes/sapphira/3x-ui/db/x-ui.db` — БД панели (миграция уже применена)
|
|
||||||
- `/etc/nixos/deploy/default.nix` — deploy-rs конфиг (otreca → vds config)
|
|
||||||
|
|
||||||
## Известное замечание
|
|
||||||
Внутри podman exec bash tool убивает backgrounded процессы. Для тестов с долгоживущим xray-клиентом используй systemd-run --scope или делай inline тесты (без background). Для проверки config.json хватает быстрого `podman exec python3 -c`.
|
|
||||||
Reference in New Issue
Block a user