docs arch begin

This commit is contained in:
2026-10-07 11:29:21 +03:00
parent 698a1afaf7
commit 534fa429e1
4 changed files with 1044 additions and 0 deletions
+396
View File
@@ -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)? |