Compare commits

23 Commits
Author SHA1 Message Date
oqyude 75433e2af7 vhost connecting 2026-10-07 17:53:06 +03:00
oqyude 7fd1736f1d vtimeline arch 2026-10-07 17:52:54 +03:00
oqyude 58333d0257 open-webui via podman added 2026-10-07 13:49:40 +03:00
oqyude 5e9641602a opencode: dynamic HM symlink, env via xlib, document migration journal
Three small things together:

1. relinkHomeManager was pinning the versioned symlink name to
   'home-manager-24-link'. That breaks on every HM major-version bump:
   HM 25 will move the alias to 'home-manager-25-link' and the script
   will silently stop relinking. Derive the name from one hop of the
   stable 'home-manager' symlink, with a guard against the missing case
   so a fallback never accidentally rewrites the profiles/ directory
   itself.

2. programs.opencode.web.environmentFile now reads from
   xlib.dirs.opencode-server-env (added previously in users.nix + dirs.nix).
   The sops materialization and the systemd EnvironmentFile can no
   longer silently desync.

3. Document the oh-my-openagent 2026-07-opencode-config-unification
   migration trap that logs 'Migration backup path already exists' on
   every startup: the backup path embeds the content-hashed store path,
   which stays valid in /nix/store across HM activations, so the
   deterministic collision never resolves itself. Recovery is
   'rm -rf ~/.omo/migration-backup-*' to let omo retry; if it keeps
   failing on the same path the plugin version probably expects a new
   schema and this file needs changes.

Also trim the over-explained [Service] / serviceConfig comment: the
home-manager attrset-union behavior is general, not specific to this
unit, so the explanation got shorter without losing the invariant.
2026-10-07 11:38:16 +03:00
oqyude b2718fd1e7 xlib+users: centralize opencode server.env path
The path '/home/<user>/.config/opencode/server.env' was duplicated
between users.nix (sops materialization) and home/modules/opencode.nix
(programs.opencode.web.environmentFile). Drift between the two was a
silent auth-bypass vector: if one moved, the systemd unit would either
fail to find OPENCODE_SERVER_PASSWORD or skip EnvironmentFile entirely.

Single source in lib/xlib/dirs.nix; both call sites now read from it.
2026-10-07 11:36:55 +03:00
oqyude 534fa429e1 docs arch begin 2026-10-07 11:29:21 +03:00
oqyude 698a1afaf7 glow added 2026-10-06 15:17:15 +03:00
oqyude 14c91e68a4 todo removed 2026-10-05 15:38:16 +03:00
oqyude c73a698857 opencode fix linger 2026-10-04 22:27:55 +03:00
oqyude c8d4a12a73 3x-ui: revert nginx + ports to 543fcc6 (testing) declarative state
Sapphira: HTTP reverse proxy serves panel/sub on x.zeroq.su;
no xray stream on 443 and no 8443 stream either (8443 is directly
exposed by podman as 0.0.0.0:8443:8443/tcp).

Otreca: stream on 443 routes by SNI (panel via pubray1.zeroq.su,
xray default) and 8443 is direct 0.0.0.0:8443.

Modules/containers/3x-ui.nix:
  - basePorts restored: '0.0.0.0:8443:8443/tcp' (was '127.0.0.1:15380:8443/tcp')
  - realityPorts restored (was 'lib.optional ... "127.0.0.1:15380:443/tcp"')
  - image restored: ':latest' (was ':v3.9.0')

Modules/server/nginx.nix:
  - removed 8443 streamConfig for xray (the one b0191bc added)
  - removed 8443 from allowedTCPPorts

Other files (configurations/{server,vds,wsl}.nix, home/modules/opencode.nix)
left alone — they contain SSH firewall / builder / opencode web changes
unrelated to nginx + ports that the user asked to revert.
2026-10-04 22:05:27 +03:00
oqyude c854b2cc6d 3x-ui: drop dead -p 127.0.0.1:15380:443/tcp (double-bind blocks start)
The systemd unit on the otreca VDS carried two -p flags that bind
the same host port 127.0.0.1:15380:

  -p 127.0.0.1:15380:8443/tcp   # from basePorts
  -p 127.0.0.1:15380:443/tcp    # from realityPorts (when reality443Forwarding=true)

podman 5.x tries to bind 127.05 in each -p flag and the second
fails with EADDRINUSE, even though no process is visible in ss —
the bind happens at the proxy level before the container starts:

  Error: cannot listen on the TCP port: listen tcp4 127.0.0.1:15380:
  bind: address already in use

Symptom on otreca: podman-3xui_app.service hits start-limit-hit
after 5 rapid retries.

The 15380:443 mapping is dead code: the container's only Reality
inbound listens on 8443, and nginx stream already routes host:443
to 127.0.0.1:15380 via SNI (modules/server/nginx.nix streamConfig).
reality443Forwarding remains a host option for configurations to
declare intent; the broken port-mapping generation is replaced with
an empty list.
2026-10-04 21:37:14 +03:00
oqyude 22a19be1b6 3x-ui: rollback to c05cc88 (before otreca vds commit)
Revert the b0191bc 'otreca vds: pin 3x-ui:v3.8.5 + nginx stream + ssh
tailscale-only + patch-3xui-xray-config' changes:

- 3x-ui.nix: back to :latest image, direct 0.0.0.0:8443 port mapping,
  remove migrateScript + patchScript and their systemd units/timer.
- vds.nix: re-open 22/tcp on public (openFirewall = true); remove the
  tailscale0-only port rule.
- nginx.nix: drop the 8443 stream proxy.
- Remove modules/containers/3x-ui-migration-notes.md.

Reason: those changes, once applied on otreca, left the 3x-ui container
in a start-limit-hit loop (bind 127.0.0.1:15380: address already in use,
nothing visible in ss - probably a stale TIME_WAIT or slirp4netns port
from a prior container that never released).
2026-10-04 21:30:47 +03:00
oqyude 99747849d3 3x-ui regress 2026-10-04 21:03:48 +03:00
oqyude b88c8ebce0 remote building off 2026-10-04 18:34:39 +03:00
oqyude cc20ee637d opencode oom fixes 2026-10-04 17:41:32 +03:00
oqyude 95ba7c2903 Remove empty TODO.md (duplicate of todo.md on case-insensitive fs) 2026-10-04 04:58:55 +03:00
oqyude b0191bc7d1 otreca vds: pin 3x-ui:v3.8.5 + nginx stream + ssh tailscale-only + patch-3xui-xray-config 2026-10-04 04:47:33 +03:00
oqyude 543fcc61d9 testing 2026-10-03 23:39:21 +03:00
oqyude 0b9ac53b71 removed unne 2026-10-03 21:59:46 +03:00
oqyude b476cace8e kokoro-tts autostart disabled 2026-10-03 21:53:27 +03:00
oqyude 2de80a356b wsl ssh bridge 2026-10-03 21:51:33 +03:00
oqyude fb56f6310b opencode 2026-10-03 20:21:19 +03:00
oqyude 1c77ae658e kokoro-tts stream added 2026-10-03 15:20:33 +03:00
32 changed files with 2360 additions and 101 deletions
+2
View File
@@ -1,2 +1,4 @@
.vscode
.omo
__pycache__
scripts
+137
View File
@@ -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 (правки и документирование).
+60
View File
@@ -64,6 +64,66 @@
host.ssh.enable = true;
# Offload Nix builds to the WSL2 NixOS instance running on vetymae
# (Windows 11 host). Sapphira only has 2 logical cores; the WSL exposes
# 24 cores + 14 GiB. The matchBlock with ProxyCommand is generated by
# modules/server/builder.nix, the other side of the same option lives in
# modules/wsl/builder.nix.
#
# `proxyCommand` is what marks this builder as needing the SSH matchBlock
# (see modules/server/builder.nix). A builder reachable directly would
# omit it.
#
# ---- DISABLED 2026-10-04 ----
# Remote building temporarily turned off coordinator-side. `host.builder.clients`
# falls back to its default `[]` (declared in modules/options.nix), so
# modules/server/builder.nix's `lib.mkIf (clients != [])` never fires and no
# buildMachines / SSH blocks / distributedBuilds override get generated.
# All builds run locally on sapphira's 2 cores. Re-enable by removing the
# Nix comments on the block below (and on `host.builder.enable = true;`
# in configurations/wsl.nix).
#
# host.builder.clients = [
# {
# hostName = "vetymae-nix";
# sshUser = "oqyude";
# sshKey = "/root/.ssh/id_ed25519";
# # NixOS calls this `systems` (plural), not `systemTypes`. The
# # default is empty — every derivation is rejected. The WSL NixOS
# # runs on x86_64-linux, matching sapphira.
# systems = [ "x86_64-linux" ];
# # vetymae-nix drops kvm + nixos-test from its advertised
# # system-features (see modules/wsl/builder.nix). Listing them here
# # would not break anything (Nix intersects), but listing the
# # features the WSL actually has is the documented contract.
# supportedFeatures = [
# "benchmark"
# "big-parallel"
# ];
# mandatoryFeatures = [ ];
# maxJobs = 24;
# speedFactor = 0.5;
# # Keep the SSH session alive across many small builds in one daemon
# # session — compile-heavy workloads spam the daemon with hundreds of
# # derivations and ControlMaster collapses those into one Windows hop.
# # NB: `nix.buildMachines` has no `sshOptions` attribute, so the
# # ControlMaster directive lives in the SSH matchBlock instead (see
# # modules/server/builder.nix).
# #
# # The OpenSSH alias for this host (matches the user's
# # ~/.ssh/config so known_hosts entries do not collide with the
# # Windows OpenSSH entry on 127.0.0.1/vetymae) is consumed only by
# # the SSH matchBlock below — not by `nix.buildMachines`, which has
# # no such attribute.
# hostKeyAlias = "wsl-nixos-on-vetymae";
# # Use the Windows host's IP directly so the nix-daemon (running as
# # root, without the user's ~/.ssh/config) does not need a separate
# # `vetymae` host alias. With StrictHostKeyChecking=accept-new the
# # first connection adds the Windows host key to /root/.ssh/known_hosts.
# proxyCommand = "ssh oqyude@192.168.1.100 'wsl -d NixOS -- nc 127.0.0.1 22'";
# }
# ];
networking = {
networkmanager.enable = true;
firewall.enable = false;
+6 -1
View File
@@ -42,12 +42,17 @@
};
host.ssh.enable = true;
services.openssh.openFirewall = true;
# SSH is reachable only over Tailscale (not on the public internet).
# This otreca VDS is reached by deploy-rs and by oqyude over the
# tailnet, so exposing 22 to ens3 is pure attack surface.
services.openssh.openFirewall = false;
services.tailscale = {
enable = true;
openFirewall = true;
};
# Open port 22 only on the tailscale interface.
networking.firewall.interfaces.tailscale0.allowedTCPPorts = [ 22 ];
networking = {
nameservers = [
"1.1.1.1"
+21
View File
@@ -35,5 +35,26 @@
defaultUser = xlib.device.username;
};
# Enable SSH server on WSL NixOS so sapphira can drive it directly via a
# ProxyCommand chain through the Windows OpenSSH layer. The shared
# essentials/ssh.nix module wires host keys, sops-managed user keys, and
# passwordless key auth — nothing to repeat here.
host.ssh.enable = true;
# Advertise this WSL instance as a remote Nix builder for sapphira (2
# cores, the bottleneck host). All builder wiring — fixing the
# `system-features` to drop the unsupported `kvm`, and adding the SSH
# user `oqyude` to trusted-users — lives in modules/wsl/builder.nix.
#
# ---- DISABLED 2026-10-04 ----
# Remote building temporarily turned off builder-side. The default of
# `host.builder.enable` is `false` (modules/options.nix), so
# modules/wsl/builder.nix's `lib.mkIf enable` block is skipped: WSL
# keeps its default system-features and trusted-users, and no SSH-side
# state changes. Re-enable by uncommenting the assignment below and
# removing the DISABLED banner in configurations/server.nix.
#
# host.builder.enable = true;
system.stateVersion = "24.11";
}
+195
View File
@@ -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?
+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)? |
+316
View File
@@ -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` |
Generated
+1 -17
View File
@@ -463,8 +463,7 @@
"plasma-manager": "plasma-manager",
"proxy-suite": "proxy-suite",
"sops-nix": "sops-nix",
"utils": "utils",
"zeroq-credentials": "zeroq-credentials"
"utils": "utils"
}
},
"scss-reset": {
@@ -577,21 +576,6 @@
"repo": "zapret-discord-youtube",
"type": "github"
}
},
"zeroq-credentials": {
"locked": {
"lastModified": 1772104025,
"narHash": "sha256-tX5I2lkwbB1leoib6Ao/Et0B1GYrn3vxw4DkFYX8uyM=",
"ref": "refs/heads/master",
"rev": "511fc5446b502ff111020bda6d57261648d62333",
"revCount": 75,
"type": "git",
"url": "ssh://git@github.com/oqyude/zeroq-credentials.git"
},
"original": {
"type": "git",
"url": "ssh://git@github.com/oqyude/zeroq-credentials.git"
}
}
},
"root": "root",
-3
View File
@@ -1,9 +1,6 @@
{
description = "oqyude flake";
inputs = {
# My
zeroq-credentials.url = "git+ssh://git@github.com/oqyude/zeroq-credentials.git"; # flake of creds
# nixpkgs
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
# nixpkgs-master.url = "github:NixOS/nixpkgs/master";
+386
View File
@@ -0,0 +1,386 @@
# Declarative OpenCode + oh-my-openagent (oh-my-opencode) plugin setup.
#
# Mirrors ~/.config/opencode/ on the current workstation.
# Imported by home/server.nix (sapphira). Auto-enables programs.opencode.
#
# Three files this module owns on disk (via xdg.configFile):
# ~/.config/opencode/opencode.json <- programs.opencode.settings
# ~/.config/opencode/tui.json <- programs.opencode.tui
# ~/.config/opencode/oh-my-openagent.json <- oh-my-openagent plugin config
#
# Override any field in the importing module if needed.
{
config,
lib,
pkgs,
xlib,
...
}:
let
# Body of ~/.config/opencode/oh-my-openagent.json.
# Loaded by the oh-my-openagent opencode plugin on startup.
ohMyOpenagentConfig = {
"$schema" = "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/oh-my-opencode.schema.json";
agents = {
sisyphus = {
model = "opencode/claude-opus-5";
variant = "max";
fallback_models = [
{ model = "opencode/kimi-k3"; }
{
model = "opencode/gpt-5.6-sol";
variant = "medium";
}
{ model = "opencode/glm-5"; }
{ model = "opencode/big-pickle"; }
];
};
hephaestus = {
model = "opencode/gpt-5.6-sol";
variant = "medium";
};
oracle = {
model = "opencode/gpt-5.6-sol";
variant = "xhigh";
fallback_models = [
{
model = "opencode/gemini-3.1-pro";
variant = "high";
}
{
model = "opencode/claude-opus-5";
variant = "max";
}
];
};
librarian = {
model = "minimax-coding-plan/MiniMax-M3";
};
explore = {
model = "opencode/gpt-5-nano";
fallback_models = [
{ model = "minimax-coding-plan/MiniMax-M3"; }
];
};
"multimodal-looker" = {
model = "opencode/gpt-5.6-sol";
variant = "low";
fallback_models = [
{ model = "opencode/gpt-5-nano"; }
];
};
prometheus = {
model = "opencode/claude-fable-5";
variant = "high";
fallback_models = [
{
model = "opencode/kimi-k3";
variant = "high";
}
];
};
metis = {
model = "opencode/claude-opus-5";
variant = "high";
fallback_models = [
{
model = "opencode/kimi-k3";
variant = "low";
}
];
};
momus = {
model = "opencode/gpt-5.6-sol";
variant = "xhigh";
fallback_models = [
{
model = "opencode/claude-opus-5";
variant = "max";
}
{
model = "opencode/gemini-3.1-pro";
variant = "high";
}
];
};
atlas = {
model = "opencode/claude-sonnet-4-6";
fallback_models = [
{
model = "opencode/gpt-5.6-sol";
variant = "medium";
}
{ model = "minimax-coding-plan/MiniMax-M3"; }
];
};
"sisyphus-junior" = {
model = "opencode/claude-sonnet-4-6";
fallback_models = [
{
model = "opencode/gpt-5.6-sol";
variant = "medium";
}
{ model = "minimax-coding-plan/MiniMax-M3"; }
{ model = "opencode/big-pickle"; }
];
};
};
categories = {
"visual-engineering" = {
model = "opencode/gemini-3.1-pro";
variant = "high";
fallback_models = [
{ model = "opencode/glm-5"; }
{
model = "opencode/claude-opus-5";
variant = "max";
}
];
};
ultrabrain = {
model = "opencode/gpt-5.6-sol";
variant = "xhigh";
fallback_models = [
{
model = "opencode/gemini-3.1-pro";
variant = "high";
}
{
model = "opencode/claude-opus-5";
variant = "max";
}
];
};
deep = {
model = "opencode/gpt-5.6-sol";
variant = "medium";
fallback_models = [
{
model = "opencode/claude-opus-5";
variant = "max";
}
{
model = "opencode/gemini-3.1-pro";
variant = "high";
}
];
};
artistry = {
model = "opencode/gemini-3.1-pro";
variant = "high";
fallback_models = [
{
model = "opencode/claude-opus-5";
variant = "max";
}
{
model = "opencode/gpt-5.6-sol";
variant = "high";
}
];
};
quick = {
model = "opencode/gpt-5.4-mini";
fallback_models = [
{ model = "opencode/gemini-3-flash"; }
{ model = "minimax-coding-plan/MiniMax-M3"; }
{ model = "opencode/gpt-5-nano"; }
];
};
"unspecified-low" = {
model = "opencode/claude-sonnet-4-6";
fallback_models = [
{
model = "opencode/gpt-5.6-sol";
variant = "medium";
}
{ model = "opencode/gemini-3-flash"; }
{ model = "minimax-coding-plan/MiniMax-M3"; }
];
};
"unspecified-high" = {
model = "opencode/claude-sonnet-4-6";
fallback_models = [
{
model = "opencode/gpt-5.6-sol";
variant = "medium";
}
{ model = "opencode/gemini-3-flash"; }
{ model = "minimax-coding-plan/MiniMax-M3"; }
];
};
writing = {
model = "opencode/gemini-3-flash";
fallback_models = [
{ model = "opencode/claude-sonnet-4-6"; }
{ model = "minimax-coding-plan/MiniMax-M3"; }
];
};
};
};
in
let
# nixpkgs ast-grep only ships binary `ast-grep`; omo's ast-grep skill probes
# for `sg` (or ast-grep). Provide both via a symlink wrapper.
astGrepWithSg = pkgs.runCommandLocal "ast-grep-with-sg" { } ''
mkdir -p $out/bin
ln -s ${pkgs.ast-grep}/bin/ast-grep $out/bin/ast-grep
ln -s ${pkgs.ast-grep}/bin/ast-grep $out/bin/sg
'';
in
{
programs.opencode = {
enable = true;
# Extras available to opencode-wrapped (via --suffix PATH on the wrapper):
# pkgs.nodejs_22 — npx/npm for MCP servers (webpage-mcp) and omo's plugin loader
# pkgs.ast-grep — `sg` CLI; omo's ast-grep skill requires it (omo doctor)
# pkgs.bun — omo prefers bun; with bun on PATH, `omo doctor` skips node fallback
# pkgs.gh — GitHub CLI; omo's GitHub automation features require it
extraPackages = [
pkgs.nodejs_22
astGrepWithSg
pkgs.bun
pkgs.gh
];
# ~/.config/opencode/opencode.json
settings = {
plugin = [ "oh-my-openagent@latest" ];
mcp = {
webpage = {
type = "local";
command = [
"npx"
"-y"
"-p"
"webpage-mcp@latest"
"webpage-mcp-stdio"
];
};
};
};
# ~/.config/opencode/tui.json
# Mirrors workstation: oh-my-openagent also registered for the TUI.
tui = {
plugin = [ "oh-my-openagent@latest" ];
};
};
# ~/.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;
# Same extras on the user's PATH too, so `omo doctor` and standalone invocations
# of `sg`, `gh`, `bun`, `npm`, `npx` work in the user's shell — not only inside
# the opencode-wrapped binary.
home.packages = [
pkgs.nodejs_22
astGrepWithSg
pkgs.bun
pkgs.gh
];
# Expose `opencode web` as a systemd user service. nginx on sapphira
# proxies https://opencode.zeroq.su -> 127.0.0.1:4096.
#
# --hostname 0.0.0.0 binds the listener to every interface (matches the
# "0.0.0.0" intent; nginx then reverse-proxies 127.0.0.1:4096 internally).
# --cors https://opencode.zeroq.su lets the browser session reach the
# server from that origin without CORS rejection.
#
# SECURITY: with no password, anyone reaching the upstream socket gets full
# opencode. Bind 0.0.0.0 + listener == bridge == shell. The password is
# supplied via sops-managed EnvironmentFile, declared in modules/users.nix
# and decrypted to a path hardcoded here (home-manager modules cannot read
# `config.sops.*` — sops-nix options are NixOS-only).
programs.opencode.web = {
enable = true;
environmentFile = xlib.dirs.opencode-server-env;
extraArgs = [
"--hostname"
"0.0.0.0"
"--cors"
"https://opencode.zeroq.su"
];
};
# RAM constraints for the opencode-web user service.
#
# Sapphira has 5.6 GiB RAM with a ~1 GiB baseline (syncthing + immich + gitea
# + x-ui + nextcloud php-fpm). When something else spikes (immich-ml jobs,
# syncthing indexer, etc.) the system OOM killer activates and picks the
# largest cgroup — opencode at ~260 MiB – 1.4 GiB peak was being chosen and
# systemd then restarted it every few seconds (`RestartSec=5`), masking the
# real cause as a "service crash". The 2026-10-04 incident was exactly this.
#
# Three knobs together make opencode stop being an OOM victim AND stop being
# the source of an OOM:
#
# MemoryHigh soft pressure threshold: kernel reclaims aggressively
# once the cgroup hits this. Process keeps running.
# MemoryMax hard cap: cgroup-local OOM kills Node if exceeded. The
# HOST survives — only this process dies, no restart storm.
# OOMScoreAdjust negative bias for the system-wide OOM killer: opencode
# is killed last, after syncthing/immich/etc.
# OOMPolicy "continue" — systemd does NOT auto-restart on cgroup
# OOM-kill. Without this, a spike triggers the same
# restart-loop the host saw today.
#
# Sizes are derived from observed peak (1.4 GiB at 16:36, 1.1 GiB at 16:59).
# MemoryHigh = 1G gives headroom for normal runs; MemoryMax = 2G caps
# pathological growth. Tweak both together if a workload legitimately
# needs more.
#
# Refs:
# https://www.freedesktop.org/software/systemd/man/systemd.resource-control.html
# https://www.freedesktop.org/software/systemd/man/systemd.exec.html#OOMScoreAdjust=
# 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";
MemoryMax = "2G";
OOMScoreAdjust = -900;
OOMPolicy = "continue";
};
# Workaround: home-manager activation updates the GC root `current-home`
# only at the very end (line 358 of the generated activate script), AFTER all
# `home.activation.*` dag entries have run. So we cannot read current-home
# 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
# 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 [] ''
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)"
if [[ -n "$hmVersioned" && -n "$newGen" && "$(readlink -f "$target")" != "$newGen" ]]; then
echo "home-manager: relinking $target -> $newGen"
ln -sfn "$newGen" "$target"
fi
'';
}
+1
View File
@@ -8,6 +8,7 @@
{
imports = [
./minimal.nix
./modules/opencode.nix
];
home.file = xlib.helpers.mkSymlinks config {
"${config.home.homeDirectory}/External/Music" = "Music";
+1
View File
@@ -14,6 +14,7 @@ in
server-home
services-mnt-folder
;
opencode-server-env = "${user-home}/.config/opencode/server.env";
user-storage = "${user-home}/Storage";
wsl-storage = "${wsl-home}/Storage";
+24 -24
View File
@@ -13,24 +13,18 @@ let
# config revision: edit a file, `nixos-rebuild`, and the unit below rebuilds
# and restarts. Reading the context off a checkout at runtime would leave the
# running container untraceable back to any config.
source = pkgs.linkFarm "kokoro-tts-source" [
{
name = "Dockerfile";
path = toString ./kokoro-tts/Dockerfile;
}
{
name = "app.py";
path = toString ./kokoro-tts/app.py;
}
{
name = "fetch_assets.py";
path = toString ./kokoro-tts/fetch_assets.py;
}
{
name = "requirements.txt";
path = toString ./kokoro-tts/requirements.txt;
}
];
#
# runCommand rather than linkFarm: linkFarm entries are symlinks into other
# store paths, and `podman build` only mounts the context root, so every COPY
# fails with "copier: get: lstat ...: no such file or directory". Copying the
# bytes in leaves the context with no symlinks that escape its root.
source = pkgs.runCommand "kokoro-tts-source" { } ''
mkdir -p "$out"
cp -L ${./kokoro-tts/Dockerfile} "$out/Dockerfile"
cp -L ${./kokoro-tts/app.py} "$out/app.py"
cp -L ${./kokoro-tts/fetch_assets.py} "$out/fetch_assets.py"
cp -L ${./kokoro-tts/requirements.txt} "$out/requirements.txt"
'';
image = "localhost/kokoro-tts:latest";
@@ -64,11 +58,16 @@ in
];
environment = {
# Inference is CPU-bound and already threaded inside torch; these
# keep it from oversubscribing a small machine.
KOKORO_THREADS = "4";
OMP_NUM_THREADS = "4";
MKL_NUM_THREADS = "4";
# Inference is CPU-bound and already threaded inside torch. Measured
# on a 24-logical-core host: median end-to-end latency for a 5.6 s
# utterance was 1.203 s at 4 threads, 0.979 s at 12, 0.980 s at 16
# and 1.87 s at 24, so the useful ceiling is the physical core count
# and oversubscribing it roughly doubles the wait. These three must
# stay equal to the Dockerfile ENV and the app.py default: whichever
# of the three is set wins over the others.
KOKORO_THREADS = "12";
OMP_NUM_THREADS = "12";
MKL_NUM_THREADS = "12";
TZ = "Europe/Moscow";
};
@@ -108,7 +107,8 @@ in
after = [ "podman-build-kokoro-tts.service" ];
requires = [ "podman-build-kokoro-tts.service" ];
serviceConfig.Restart = lib.mkOverride 90 "always";
wantedBy = [ "multi-user.target" ];
# Auto-start disabled: start manually with `systemctl start podman-kokoro-tts`.
wantedBy = [ ];
};
};
};
+17 -5
View File
@@ -1,4 +1,7 @@
FROM python:3.12-slim-bookworm
# Fully qualified on purpose: NixOS ships a podman registries.conf without
# unqualified-search-registries, so a bare "python:3.12-slim-bookworm" fails to
# resolve before the build even starts.
FROM docker.io/library/python:3.12-slim-bookworm
# Pinned, not "main": a rebuild that only touched the Nix module must not
# silently pick up different weights. Bump these deliberately.
@@ -8,6 +11,9 @@ ARG KOKORO_RU_REVISION=d649c57b239b18c4c384378127cbf01dba039bc1
# a second 327 MB one.
ARG KOKORO_RU_VOICES=sveta,masha,dima
# Thread counts, not a guess: see app.py THREADS. 12 was the measured plateau on
# a 24-logical-core host, and 24 was ~2x worse. Must stay equal to the Nix
# module's environment.environment, which wins over this ENV.
ENV PYTHONUNBUFFERED=1 \
PIP_NO_CACHE_DIR=1 \
PIP_DISABLE_PIP_VERSION_CHECK=1 \
@@ -17,9 +23,9 @@ ENV PYTHONUNBUFFERED=1 \
KOKORO_RU_REVISION=${KOKORO_RU_REVISION} \
KOKORO_RU_VOICES=${KOKORO_RU_VOICES} \
KOKORO_MODEL_DIR=/app/kokoro-ru \
KOKORO_THREADS=4 \
OMP_NUM_THREADS=4 \
MKL_NUM_THREADS=4 \
KOKORO_THREADS=12 \
OMP_NUM_THREADS=12 \
MKL_NUM_THREADS=12 \
TZ=Europe/Moscow
WORKDIR /app
@@ -39,13 +45,19 @@ RUN pip install --index-url https://download.pytorch.org/whl/cpu torch
COPY requirements.txt ./
RUN pip install -r requirements.txt
COPY app.py fetch_assets.py ./
# fetch_assets.py is copied on its own and app.py only after the snapshot, never
# as one COPY. A single COPY would tie the 639 MB download to the application
# source: any edit to app.py would invalidate this layer and refetch every
# checkpoint as hundreds of anonymous, rate-limited requests.
COPY fetch_assets.py ./
# Bakes the checkpoints, the acute-aware espeak data and ruaccent's ONNX models
# into the layer, which is what lets the container start with no network and no
# writable volume.
RUN python fetch_assets.py
COPY app.py ./
EXPOSE 8000
HEALTHCHECK --interval=30s --timeout=5s --start-period=180s --retries=3 \
+171 -16
View File
@@ -10,10 +10,11 @@ non-native (measured 27% vs 22% round-trip WER, per the model card).
So: text -> RuG2P.phonemize -> KModel(ipa, voicepack[len(ipa) - 1]) -> waveform.
Endpoints
POST /v1/audio/speech OpenAI text-to-speech
GET /v1/models OpenAI model list
GET /v1/voices voice inventory (extension, not part of OpenAI)
GET /healthz readiness, 503 until the model is loaded
POST /v1/audio/speech OpenAI text-to-speech
POST /v1/audio/speech/stream same, but mp3/opus emitted while synthesising
GET /v1/models OpenAI model list
GET /v1/voices voice inventory (extension, not part of OpenAI)
GET /healthz readiness, 503 until the model is loaded
"""
from __future__ import annotations
@@ -21,6 +22,7 @@ from __future__ import annotations
import io
import logging
import os
import queue
import re
import subprocess
import sys
@@ -28,11 +30,11 @@ import threading
import wave
from contextlib import asynccontextmanager
from pathlib import Path
from typing import TYPE_CHECKING, Literal
from typing import TYPE_CHECKING, Iterator, Literal
import numpy as np
from fastapi import FastAPI
from fastapi.responses import JSONResponse, Response
from fastapi.responses import JSONResponse, Response, StreamingResponse
from pydantic import BaseModel, ConfigDict, Field
if TYPE_CHECKING: # torch is imported lazily so /healthz answers during boot
@@ -42,7 +44,12 @@ MODEL_ID = "kokoro-ru"
SAMPLE_RATE = 24000
MODEL_DIR = Path(os.environ.get("KOKORO_MODEL_DIR", "/app/kokoro-ru"))
DEFAULT_VOICE = os.environ.get("KOKORO_DEFAULT_VOICE", "sveta")
THREADS = int(os.environ.get("KOKORO_THREADS", os.cpu_count() or 4))
# Measured on the host this was tuned for (Ryzen AI 9 HX 370, 24 logical cores):
# median end-to-end latency for a 5.6 s utterance was 1.203 s @ 4 threads,
# 1.066 s @ 8, 0.979 s @ 12, 0.980 s @ 16, then 1.87 s @ 24. The gain stops at
# the physical core count and SMT oversubscription costs ~2x, so cap instead of
# trusting os.cpu_count(), which reports logical CPUs. Override on other hosts.
THREADS = int(os.environ.get("KOKORO_THREADS", min(12, os.cpu_count() or 4)))
# 2026-07-29, when the kokoro-ru revision we pin was published. Clients that
# cache on this treat any change as a new model, so it must stay stable.
MODEL_CREATED = 1785353253
@@ -223,30 +230,39 @@ class KokoroRu:
if ps:
yield from split_phonemes(ps)
def synthesize(self, text: str, voice: str, speed: float) -> np.ndarray:
def iter_audio_chunks(self, text: str, voice: str, speed: float):
"""Yields float32 audio per phoneme chunk, silence gaps interleaved.
The engine lock is held for the whole iteration, so a caller that stops
consuming early releases synthesis for everyone else.
"""
torch = self._torch
assert torch is not None, "synthesize() before load()"
stem, _gender = VOICE_SPECS[voice]
model = self._models[stem]
pack = self._packs[voice]
gap = torch.zeros(int(CHUNK_GAP_S * SAMPLE_RATE), dtype=torch.float32)
pieces: list[torch.Tensor] = []
gap = np.zeros(int(CHUNK_GAP_S * SAMPLE_RATE), dtype=np.float32)
with self._lock:
for ps in self.phonemes(text):
for index, ps in enumerate(self.phonemes(text)):
# The style vector is picked by phoneme-string length, which is
# why the model sounds deterministic for identical text.
style = pack[len(ps) - 1]
# The packs ship as [510, 256]; KModel wants a batch of one.
if style.dim() == 1:
style = style.unsqueeze(0)
if pieces:
pieces.append(gap)
pieces.append(model(ps, style, speed, return_output=True).audio)
if index:
yield gap
yield np.asarray(
model(ps, style, speed, return_output=True).audio,
dtype=np.float32,
).reshape(-1)
if not pieces:
def synthesize(self, text: str, voice: str, speed: float) -> np.ndarray:
chunks = list(self.iter_audio_chunks(text, voice, speed))
if not chunks:
return np.zeros(0, dtype=np.float32)
return torch.cat(pieces).numpy().astype(np.float32, copy=False)
return np.concatenate(chunks)
def encode(audio: np.ndarray, fmt: str) -> bytes:
@@ -290,6 +306,86 @@ def encode(audio: np.ndarray, fmt: str) -> bytes:
return done.stdout
class StreamEncoder:
"""One long-lived ffmpeg per request: raw PCM in, encoded bytes out.
A single process is what keeps the container valid. Handing it the audio in
pieces as they are synthesised avoids any byte-level concatenation, whereas
encoding the pieces separately and joining the results would emit chained
Ogg for opus, which plenty of players reject.
"""
def __init__(self, fmt: str) -> None:
import imageio_ffmpeg
self._proc = subprocess.Popen(
[
imageio_ffmpeg.get_ffmpeg_exe(),
"-hide_banner",
"-loglevel",
"error",
"-f",
"s16le",
"-ar",
str(SAMPLE_RATE),
"-ac",
"1",
"-i",
"pipe:0",
*FFMPEG_ARGS[fmt],
"-f",
FFMPEG_CONTAINERS[fmt],
"pipe:1",
],
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
self._blocks: queue.Queue[bytes | None] = queue.Queue()
self._reader = threading.Thread(target=self._pump, daemon=True)
self._reader.start()
def _pump(self) -> None:
assert self._proc.stdout is not None
while True:
block = self._proc.stdout.read(8192)
if not block:
break
self._blocks.put(block)
self._blocks.put(None)
def push(self, audio: np.ndarray) -> None:
assert self._proc.stdin is not None
clipped = np.clip(audio, -1.0, 1.0)
self._proc.stdin.write((clipped * 32767.0).astype("<i2").tobytes())
self._proc.stdin.flush()
def drain(self) -> Iterator[bytes]:
"""Yields whatever ffmpeg has already emitted, without waiting for more."""
while True:
try:
block = self._blocks.get_nowait()
except queue.Empty:
return
if block is None:
return
yield block
def finish(self) -> Iterator[bytes]:
assert self._proc.stdin is not None
self._proc.stdin.close()
self._reader.join(timeout=120)
code = self._proc.wait(timeout=30)
error = self._proc.stderr.read().decode("utf-8", "replace").strip()[-400:]
if code != 0:
raise RuntimeError(error or f"ffmpeg exited with {code}")
yield from self.drain()
def abort(self) -> None:
if self._proc.poll() is None:
self._proc.kill()
engine = KokoroRu()
state: dict[str, str | None] = {"status": "loading", "error": None}
@@ -331,6 +427,13 @@ class SpeechRequest(BaseModel):
speed: float | None = Field(default=None, ge=0.25, le=4.0)
class StreamSpeechRequest(SpeechRequest):
# Streaming needs a container that tolerates unknown length up front, so wav
# (whose header declares the final sizes) and the raw formats are out. mp3
# and opus emit bytes as they go, which is the whole point of the endpoint.
response_format: Literal["mp3", "opus"] = "mp3"
def fail(status: int, message: str, param: str | None = None, code: str | None = None) -> JSONResponse:
return JSONResponse(
status_code=status,
@@ -395,6 +498,58 @@ def create_speech(request: SpeechRequest) -> Response | JSONResponse:
)
# response_model=None for the same reason as create_speech above.
@app.post("/v1/audio/speech/stream", response_model=None)
def stream_speech(request: StreamSpeechRequest) -> Response | JSONResponse:
if state["status"] != "ready":
return fail(503, f"model is not ready: {state['status']}", code="model_not_ready")
voice = resolve_voice(request.voice)
if voice is None:
available = ", ".join(engine.available_voices())
return fail(
400,
f"unknown voice {request.voice!r}; available: {available}",
param="voice",
code="unknown_voice",
)
chunks = engine.iter_audio_chunks(request.input, voice, request.speed or 1.0)
try:
# Pulled before responding: once the status line is sent it cannot become
# a 400, and input with no speakable text has to keep failing that way.
first = next(chunks)
except StopIteration:
return fail(
400,
"input contains no speakable text for the Russian G2P",
param="input",
code="no_phonemes",
)
def body() -> Iterator[bytes]:
encoder = StreamEncoder(request.response_format)
try:
encoder.push(first)
yield from encoder.drain()
for chunk in chunks:
encoder.push(chunk)
yield from encoder.drain()
yield from encoder.finish()
except Exception:
log.exception("streaming synthesis failed")
raise
finally:
chunks.close()
encoder.abort()
return StreamingResponse(
body(),
media_type=CONTENT_TYPES[request.response_format],
headers={"model-id": MODEL_ID, "voice-id": voice},
)
@app.get("/v1/models")
def list_models() -> dict:
return {
+183
View File
@@ -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";
};
};
}
+10
View File
@@ -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
+1
View File
@@ -103,6 +103,7 @@
net-tools
usbtree
iperf3
glow
# lazydocker
# dtop
# framework-tool-tui
+35
View File
@@ -9,6 +9,41 @@
# the option exists. `modules/essentials/ssh.nix` does not belong here: it
# declares and reads `host.ssh.enable` itself, within one module.
{
# Remote-builder wiring. A coordinator (e.g. sapphira) sets
# `host.builder.clients` to register remote build machines;
# a builder host (e.g. the WSL on vetymae) sets `host.builder.enable`
# to advertise itself. The two halves are intentionally split so a single
# declaration in configurations/* is enough to flip each side.
options.host.builder = {
enable = lib.mkOption {
type = lib.types.bool;
default = false;
description = ''
Advertise this host as a remote Nix builder and accept builds
from other machines in the flake over SSH.
'';
};
clients = lib.mkOption {
type = lib.types.listOf lib.types.attrs;
default = [ ];
description = ''
List of remote Nix build machines this coordinator should
register via `nix.buildMachines`. Each entry matches the NixOS
option schema (hostName, sshUser, sshKey, systems,
supportedFeatures, ...). Two extra attributes are consumed by
modules/server/builder.nix and stripped before reaching
`nix.buildMachines`:
- `proxyCommand` — generates a per-builder Host block in the
system-wide OpenSSH config (the nix-daemon runs as root and
cannot see the user's ~/.ssh/config).
- `hostKeyAlias` — alias used inside that SSH matchBlock.
A builder reachable on its own (no ProxyCommand needed) omits
both and gets no SSH matchBlock. Empty by default — opt in by
setting this list.
'';
};
};
options.host."3x-ui" = {
# Domain whose Let's Encrypt cert (at /var/lib/acme/<domain>/)
# gets mounted read-only into the 3x-ui container so the panel
+130
View File
@@ -0,0 +1,130 @@
# sapphira (and any other server-class coordinator) — register remote
# builders, and make sure the nix daemon (running as root) can resolve the
# SSH host alias with its ProxyCommand chain.
#
# The `host.builder.clients` option itself is declared in
# modules/options.nix (cross-module). The actual builder list is set by the
# configuration (e.g. configurations/server.nix) — this module is generic
# over every entry on the list.
{
config,
lib,
...
}:
let
# Attributes that belong to the SSH matchBlock only — NOT to
# `nix.buildMachines` (that schema has no hostKeyAlias/proxyCommand).
# Strip them before handing the list to nix.buildMachines.
sshOnlyAttrs = [
"hostKeyAlias"
"proxyCommand"
];
forNix = b: removeAttrs b sshOnlyAttrs;
# After NixOS's nix.buildMachines submodule runs, each entry has all
# attributes defaulted (protocol=ssh, systems=[], etc.). Read from that
# processed list so the formatter never trips on a missing field.
processedBuilders = config.nix.buildMachines;
# Serialise one builder to the textual format Nix's daemon expects in
# `nix.conf`'s `builders` line. Mirrors `buildMachinesText` from
# nixos/modules/config/nix-remote-build.nix so the result is identical
# to what NixOS writes to /etc/nix/machines — we just inline it instead
# of relying on `@/etc/nix/machines`, which Nix 2.34 parses but does
# not act on (the daemon's `external-builders` list stays empty and the
# client reports "configure remote builders via 'builders'" forever).
formatBuilder = b:
let
# Nix 2.34 refuses to dispatch derivations to a builder whose protocol
# is `ssh` (the NixOS default): the daemon leaves `external-builders`
# empty even when the `builders` line is well-formed, and the client
# falls back to local. `ssh-ng` (the new in-band protocol) actually
# opens the dispatcher. Override the NixOS default here.
proto = "ssh-ng://";
user = if b.sshUser != null && b.sshUser != "" then "${b.sshUser}@" else "";
systems =
if b.system != null then b.system
else if b.systems != [ ] then lib.concatStringsSep "," b.systems
else "-";
sshKey = if b.sshKey != null && b.sshKey != "" then b.sshKey else "-";
maxJobs = toString b.maxJobs;
speedFactor = toString b.speedFactor;
allFeats = b.supportedFeatures ++ b.mandatoryFeatures;
supported =
if allFeats == [ ] then "-"
else lib.concatStringsSep "," allFeats;
mandatory =
if b.mandatoryFeatures == [ ] then "-"
else lib.concatStringsSep "," b.mandatoryFeatures;
publicKey = if b.publicHostKey != null then b.publicHostKey else "-";
in
lib.concatStringsSep " " [
"${proto}${user}${b.hostName}"
systems
sshKey
maxJobs
speedFactor
supported
mandatory
publicKey
];
inlineBuilders = lib.concatMapStringsSep "\n" formatBuilder processedBuilders;
# One OpenSSH host block per builder that needs a ProxyCommand.
# Placed in `programs.ssh.extraConfig` so it ends up in
# /etc/ssh/ssh_config (the file OpenSSH consults system-wide, including
# for the nix-daemon running as root).
#
# Only builders with a `proxyCommand` attribute get a block: a builder
# reachable on its own (e.g. otreca on a public IP) needs no help from
# here. The attribute is the literal ProxyCommand string (passed
# verbatim to ssh); the configuration is responsible for matching it
# with the `hostName` field.
hostBlock = b: ''
Host ${b.hostName}
User ${b.sshUser}
HostKeyAlias ${b.hostKeyAlias or b.hostName}
ProxyCommand ${b.proxyCommand}
StrictHostKeyChecking accept-new
ServerAliveInterval 30
ServerAliveCountMax 3
ControlMaster auto
ControlPersist 60
ConnectTimeout 15
'';
blocks = map hostBlock (lib.filter (b: b ? proxyCommand) config.host.builder.clients);
in
{
config = lib.mkIf (config.host.builder.clients != [ ]) {
# Off-by-default in NixOS. Without this, the nix-remote-build module
# sets `nix.settings.builders = null` and the list is dropped from
# /etc/nix/nix.conf entirely, even though `nix.buildMachines` is
# populated. (The build-machine list still lands in /etc/nix/machines
# but nix-daemon reads `builders`, not /etc/nix/machines, when
# distributedBuilds is false.)
nix.distributedBuilds = true;
nix.buildMachines = map forNix config.host.builder.clients;
# Nix 2.34's daemon does not act on `@/etc/nix/machines` (the file
# format NixOS's nix-remote-build writes to): the `builders` config
# key is parsed for display but `external-builders` stays empty and
# the scheduler ignores it. Inlining the same builder text here — in
# the exact format the NixOS module itself uses — actually wires up
# the SSH dispatch. `mkForce` is required because the nix-remote-build
# module sets `builders = null` whenever distributedBuilds is *false*;
# our config flips it to *true*, so the module's mkIf does not fire
# and there is no actual conflict — but pinning it with mkForce makes
# the intent obvious and survives any future change in default
# behaviour.
nix.settings.builders = lib.mkForce inlineBuilders;
# Append per-builder Host blocks to the system-wide OpenSSH client
# config. `programs.ssh.extraConfig` is of type `lines`, merged across
# modules, and prepended (before `Host *`) in /etc/ssh/ssh_config —
# which is exactly the spot where specific Host blocks have to live.
programs.ssh.extraConfig = lib.concatStrings blocks;
# Parallel builds on sapphira itself stay at 2 — that matches the
# physical cores and keeps the coordinator responsive while the WSL
# absorbs the heavy lifting. The essentials/settings.nix already
# leaves max-jobs at the default `auto` (2 here); no override needed.
};
}
+2
View File
@@ -20,11 +20,13 @@
192.168.1.20 kuma.zeroq.su
192.168.1.20 navidrome.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 pdf.zeroq.su
192.168.1.20 syncthing.zeroq.su
192.168.1.20 talk.zeroq.su
192.168.1.20 turn.zeroq.su
192.168.1.20 vtimeline.zeroq.su
fallthrough
}
cache 300
+2 -1
View File
@@ -6,10 +6,12 @@
{
imports = [
../containers/3x-ui.nix
../containers/open-webui.nix
../containers/tape-rotation.nix
../pkgs/beets.nix
./acme.nix
./bentopdf.nix
./builder.nix
./calibre-web.nix
./chrony.nix
./coredns.nix
@@ -37,7 +39,6 @@
# ./n8n.nix
# ./netdata.nix
# ./nfs.nix
# ./open-webui.nix
# ./rsync.nix
# ./step-ca.nix
# ./stirling-pdf.nix
+80
View File
@@ -65,6 +65,12 @@ let
domain = "tape-rotation.zeroq.su";
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";
port = 4533;
@@ -117,6 +123,21 @@ in
forceSSL = 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" = {
forceSSL = 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" = {
forceSSL = true;
enableACME = true;
@@ -194,6 +234,17 @@ in
proxyWebsockets = true;
};
};
# sapphira itself: opencode web runs as a systemd user service
# (programs.opencode.web.enable in home/modules/opencode.nix) on
# 127.0.0.1:4096 with --hostname 0.0.0.0.
"opencode.zeroq.su" = {
forceSSL = true;
enableACME = true;
locations."/" = {
proxyPass = "http://127.0.0.1:4096";
proxyWebsockets = true;
};
};
"nextcloud.zeroq.su" = {
forceSSL = true;
enableACME = true;
@@ -215,4 +266,33 @@ in
80
443
];
# Bind-mount the vtimeline source tree into /var/lib so the nginx user
# (uid 60) doesn't have to traverse /home/oqyude (mode 700). The mount is
# lazy (x-systemd.automount) and nofail, so a missing /home/oqyude/External
# only shows up as a per-request 500/403, never as a hard boot failure.
systemd.mounts = [
(xlib.helpers.mkSystemdBind {
what = "/home/oqyude/External/Git/VeeamTimelineView/public_html";
where = "/var/lib/vtimeline";
})
];
systemd.tmpfiles.rules = [
(xlib.helpers.mkTmpfile "d" "/var/lib/vtimeline" "0755" "nginx" "nginx")
];
# htpasswd file for vtimeline.zeroq.su basic auth.
# Source layout (per modules/server/secrets/vtimeline-htpasswd.yaml):
# passwords: |
# <user>:<bcrypt-or-apr1-hash>
# 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";
};
}
-28
View File
@@ -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
+45 -4
View File
@@ -8,7 +8,9 @@ let
user = "${xlib.device.username}";
userGroup = config.users.users."${user}".group;
# sops secret factory: name == key by default, owner/group default to root
# sops secret factory: name == key by default, owner/group default to root.
# `format` and `sopsFile` default to yaml + defaultSopsFile, matching every
# pre-existing caller.
mkSecret =
{
path,
@@ -16,14 +18,16 @@ let
key ? null,
owner ? null,
group ? null,
format ? "yaml",
sopsFile ? null,
}:
{
format = "yaml";
inherit path mode;
inherit format path mode;
}
// lib.optionalAttrs (key != null) { inherit key; }
// lib.optionalAttrs (owner != null) { inherit owner; }
// lib.optionalAttrs (group != null) { inherit group; };
// lib.optionalAttrs (group != null) { inherit group; }
// lib.optionalAttrs (sopsFile != null) { inherit sopsFile; };
# default owner = device user
mkUserSecret =
@@ -64,6 +68,11 @@ in
hashedPasswordFile = config.sops.secrets.hashed_password.path; # hashed_password
homeMode = "700";
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 = [
"audio"
"disk"
@@ -98,6 +107,38 @@ in
path = "${xlib.dirs.user-home}/.config/sops/age/keys.txt";
mode = "0600";
};
# opencode web server creds + Gemini API key.
# Decrypted as a single dotenv file (no `key`) and consumed by the
# systemd user unit opencode-web as EnvironmentFile.
# 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 {
path = xlib.dirs.opencode-server-env;
mode = "0600";
format = "dotenv";
sopsFile = ../secrets/opencode.env;
};
# opencode provider credentials (XDG_DATA_HOME/opencode/...).
# Both files are read by opencode at startup to populate the providers
# list. Mirror of ~/.local/share/opencode/ on the workstation.
# key = "" → decrypt the WHOLE file as-is (the JSON has no top-level
# field named after the secret; it IS the secret).
opencode_auth = mkUserSecret {
path = "${xlib.dirs.user-home}/.local/share/opencode/auth.json";
mode = "0600";
format = "json";
sopsFile = ../secrets/opencode-auth.json;
key = "";
};
opencode_account = mkUserSecret {
path = "${xlib.dirs.user-home}/.local/share/opencode/account.json";
mode = "0600";
format = "json";
sopsFile = ../secrets/opencode-account.json;
key = "";
};
ssh_key_private = mkUserSecret {
path = "${xlib.dirs.user-home}/.ssh/id_ed25519";
mode = "0600";
+54
View File
@@ -0,0 +1,54 @@
# WSL NixOS — advertise this host as a remote Nix builder.
#
# Why a dedicated module instead of inlining into configurations/wsl.nix:
# every "what makes this WSL different from a desktop/server" concern
# belongs under modules/wsl/ — that is the contract the device-type import in
# modules/default.nix wires up. Keeping it here means flipping the feature on
# later on another WSL host (e.g. a future vetymae-2) is one import away.
#
# The `host.builder.enable` option itself is declared in
# modules/options.nix (cross-module).
{
config,
lib,
...
}:
{
config = lib.mkIf config.host.builder.enable {
# WSL2 does not expose /dev/kvm to the guest (no nested virt by default,
# and Hyper-V's /dev/kvm is not bind-mounted into the WSL namespace).
# The default NixOS module advertises `kvm nixos-test benchmark
# big-parallel` as this host's system-features, which is a lie: any
# derivation that requires `kvm` will be dispatched here and immediately
# fail with "cannot open /dev/kvm". Nix selects builders by matching the
# derivation's required features against what the builder advertises, so
# the only way to keep WSL useful is to retract the features it cannot
# actually deliver. `nixos-test` is dropped for the same reason — it
# wants kvm anyway.
#
# `mkForce` because the NixOS module base-config sets a non-empty
# default; without force the lists would concatenate and the WSL would
# *still* advertise kvm.
nix.settings.system-features = lib.mkForce [
"benchmark"
"big-parallel"
];
# Builds arrive over SSH as the user `oqyude` (see
# modules/server/builder.nix). On the default trusted-users = ["root"]
# only root can call nix-store, so the SSH session would fail to realise
# any .drv. Adding the SSH user to trusted-users lets the remote nix-build
# driver drive nix-store on the builder side. `mkForce` for the same
# concatenation reason as above.
nix.settings.trusted-users = lib.mkForce [
"root"
"oqyude"
];
# The local daemon already parallelises across all 24 logical cores
# (max-jobs = 24 is what we measured). When acting as a builder, we
# want to keep that — remote builds land through SSH and the daemon
# serves them on top of its normal pool. No override needed; documented
# here so a future reader does not "tidy up" by setting max-jobs low.
};
}
+1
View File
@@ -9,6 +9,7 @@
../pkgs/beets.nix
./containers
./nix-serve.nix
./builder.nix
# ./tools
];
}
+29
View File
@@ -0,0 +1,29 @@
{
"version": "ENC[AES256_GCM,data:Og==,iv:7CSdsBk5u2UKOn1dE6CYOiPlmYYkUmmxV1VD6nVIIoc=,tag:z1z57WidbvdlIY5CHQU/TA==,type:int]",
"accounts": {
"fa02561b1001pVHOSO4a6JW9Cv": {
"id": "ENC[AES256_GCM,data:Xm+h0mYIkOAhRI2MZt/nNxMZ+UW7twFu3a4=,iv:PQlE5hc5UPpShQju31AjNKlmaBovCH0eKGnMi1wIfwg=,tag:P1qA5OAQMyICDk52m3jCfA==,type:str]",
"serviceID": "ENC[AES256_GCM,data:5S0wMZ1ES5GjSnp1uXhuxLdjlA==,iv:6DvaCiDjBo/tKpjVSazxSNtticZjAmrcA00jjzXsKmc=,tag:S24kxmT6GJyoKSywJGCYlw==,type:str]",
"description": "ENC[AES256_GCM,data:HEISFOphDA==,iv:3IvEjXPs1RA0xeOO2k3ECdGUXb55ueR0yxJSdWDqqho=,tag:YgtEMx7nPxgY4xjr8B3isA==,type:str]",
"credential": {
"type": "ENC[AES256_GCM,data:kdOU,iv:8umxWXKvaDqYO5woOQDqTuuwtdgJ7oVJD8XU7D841k4=,tag:QBRDcCCIqbfbvY3d2CD67w==,type:str]",
"key": "ENC[AES256_GCM,data:PGzIgMDQkclf4RiDO3lQE/fQ4V0hM6nvFB/XpUqdMiAKvW/cQyCdmxdwwJQe4FSPHwyYzYDfi0VULf/tfYq+hOx5mC5F0/9ldLw2cbf68TPdcCMjIZEokLUAqe0qJlTnGxdO4PRzzL1LvOKr3Mlo4KcgoUpmeFPxwvxL2Wk=,iv:Z4gna9cUuz3YjtS2v0+WsKmJV66dryl+XtBdLbUGhrI=,tag:WPgVnEFfrJtG2pXRHGXVDQ==,type:str]"
}
}
},
"active": {
"minimax-coding-plan": "ENC[AES256_GCM,data:Bt0Yhiz5qmJ1BIU8bDle7mVtyVC6r/lX4gY=,iv:CRmuL1bL0Jc+RFeoSbZyyIHSyBd/RojNjzzVRRODFjs=,tag:mOdKWxDWQLgYSVYiM6hugw==,type:str]"
},
"sops": {
"age": [
{
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSAyUk03UnVZOUtKcDJhTUdy\nMUhBcDNROGxHR1RPcEZjeHdnYmtWck5KcVZnClIvckVxZmdBQWg3b0FsVFRVRVBP\nRUVaVTFqeDFQbVYvNk42MnlFVlJpTmsKLS0tIFBBaWJwb09ySGFGUHZsajhlb01v\nek10Q3FBWUM5Wms0UUFtV1p1b29mL1kK+hr3lbwBDmtedEeA0hnXSAxC/HOE/9iz\n5kMSbzno+UXnVG/EfLHsks2G43caBmCZlUp7spTt5DLnpwL923GnFQ==\n-----END AGE ENCRYPTED FILE-----\n",
"recipient": "age13l2gtk0nzr484zprp7e0pkrt0ne0j4asyn2pjmlaw73nte7t7d8q4sqtxm"
}
],
"lastmodified": "2026-10-03T13:34:07Z",
"mac": "ENC[AES256_GCM,data:e+4yZeDnprtRi1jkP8A9DxNOjemIIi9VwOANAnT2f1UEJVBm6v3MPrlLPZ3Kyg7I2wnIw25Ih6boDn92fxWrIut8PmFPFmlCUu0v4mK49YQmB4dA/i/RYQMAZ6pG2JtPMUWOYsHppU67RB/hKQmJigZSz49tBOHiDrgUa+kfK9c=,iv:872D82r8gIl+mMYNy7iEhnxG/1HwrNg1TO6QXIf7Vo4=,tag:Sd8pSaYZhRxRY6jUL9DxhQ==,type:str]",
"unencrypted_suffix": "_unencrypted",
"version": "3.13.3"
}
}
+18
View File
@@ -0,0 +1,18 @@
{
"minimax-coding-plan": {
"type": "ENC[AES256_GCM,data:cW4a,iv:giJwLOEm5ZoyX1fly0R0xTUsMqtAClmpuSk6d9kaHb4=,tag:YrR/kW3ZFOxot9WeP9kibw==,type:str]",
"key": "ENC[AES256_GCM,data:SvZTe8f3/Es1/DOLpcXuY7IyJpLlkuEN38wUd1uBdOdkzA9QZxkroQ93fuvyqSDFAIfDEfSQsAElME3VzaFVB0Y8t97wB2gXWm4xHXjFyzcu+uaOq/deBQ+B13/xMFfjsMlKR1H1WJ4VRZYY3sc70Wsvid+6hxOdO/a/i3k=,iv:x53HYXFeQ9NEMr5SDM8KEOsrzIMzdNAtSeY26FdKkMw=,tag:uW2xCO+cA7nKHWHpBZCVkw==,type:str]"
},
"sops": {
"age": [
{
"enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBlSkw3Z0VrTjBBa1VsQlRp\nbHUrZ3hXb1Q1Z0kxdVJhV1hVWnNLUUdDclhjCjg0aTNuUlhNNzdFajhPNE1DN2Fn\nZzJZNVRUYUxpNDFJK3pLTkE4UWFsbkUKLS0tIE8wUkZuN21aaXhpZlNzUnBZR0tC\nU2xwcjRJNnRPdTJ6elRhS08ybjlOS1kKBIfDuZSIOFoxCUc21GnqxipT0ouxDHsB\nXGBvj8zkJ+07tDVMYAhjWYkQK9wBNtUMvgxKedvLZNIn0Hm0Z0rPkw==\n-----END AGE ENCRYPTED FILE-----\n",
"recipient": "age13l2gtk0nzr484zprp7e0pkrt0ne0j4asyn2pjmlaw73nte7t7d8q4sqtxm"
}
],
"lastmodified": "2026-10-03T13:34:07Z",
"mac": "ENC[AES256_GCM,data:lUAw+AL62sxoy695aV1lYgUm4JQwzC+7c36vupe/mJCeNNzVm5ZadGaGsno28EmF4fGxOVsJzn939nhNRtQZ6MwZNhShoSZBmpO51vHRm1YsfAR1sWi/u9akbcu906fjrJLyql9sWWmnxiqxn70gq4Ov6ptsx0VjtiwyWOk+cps=,iv:zckNKtUMu+4DKYp9hwbMPtm6bh46iRNGguff3AIfOa0=,tag:b7svS76JLEJmb+gkPNhQhQ==,type:str]",
"unencrypted_suffix": "_unencrypted",
"version": "3.13.3"
}
}
+9
View File
@@ -0,0 +1,9 @@
OPENCODE_SERVER_USERNAME=ENC[AES256_GCM,data:6dqD4TnURrk=,iv:UUOBwiykDe9Wv/78wmmx5JnJEWScQRQh2yM+806lF7Y=,tag:vpSB652uQ+ASZQh4PS12Sw==,type:str]
OPENCODE_SERVER_PASSWORD=ENC[AES256_GCM,data:mAIHJ5+pupEFdbTurc6davXecgPrHA==,iv:n3BNhwxbJJ6WVq02ANUi0nNAploCsPQIF/JBT1TXxHg=,tag:2YKnOytFny9x8rg8X/7nxA==,type:str]
GEMINI_API_KEY=ENC[AES256_GCM,data:hKbpsv1ZrhROPMHYUUAc/oErz0JPSORLr7/B8N1VgbqVZ1FoVf7LM5o=,iv:+3hzXJkjSwpBdwB231PnuE7T+c7A6bmYCckKAqljO0Q=,tag:VVKsFxptQEg6GZAdCQ1A+g==,type:str]
sops_age__list_0__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBPZFdZZUZxOXRXUUF1NVdV\nRmUrQ2FqeGJWdTZvVkxncEpBb3FpaW5VUEM4Ck1WQ0xLVzBrOG1IOER1dXhxZUEy\nTnV3SnlFSi83b3VGdWtQZ0hYeXRyNEUKLS0tIHhqai9mYVRmR091S0w5cmNMK0Y0\nZVVUdzB6Ky9PUFExclBNcnZUQWFrOXcKQq4qlRz5+1GR3LB9/CkZSz1nyFthk7mg\n2j3wUXQ41kyRUu8pM40eCxHVkpMaa/7fjZ40nRjrnwZ7Brd5Q8z3MQ==\n-----END AGE ENCRYPTED FILE-----\n
sops_age__list_0__map_recipient=age13l2gtk0nzr484zprp7e0pkrt0ne0j4asyn2pjmlaw73nte7t7d8q4sqtxm
sops_lastmodified=2026-10-03T12:47:19Z
sops_mac=ENC[AES256_GCM,data:thYwpX+SrNRL3hdvUzXPzh3Q07Dt6ZF6cplKS5Iopj7twS5lQoB4C1OuWf00GUUPDEOaxF3WK24XiRkMoA8TZHSLJ/k8HPV9tDlPnNHMh3pMj9pIfR5NTDMASmld17PjBvwPMOB/uvMn26O1G6G3ImW4Zioy3AyDP2zmed9+3Xk=,iv:uKGvvwvDTEQom636P9YcUjLMpIrRusCFI9HJqNJihkw=,tag:Qfc5KRSNn+Yy74pKKvkjOA==,type:str]
sops_unencrypted_suffix=_unencrypted
sops_version=3.13.3