Srv watch
Deploy / deploy (push) Successful in 11s

This commit is contained in:
2026-08-31 11:08:45 +05:00
parent e4f58e820f
commit 20e1d5a726
6 changed files with 1062 additions and 63 deletions
+139
View File
@@ -0,0 +1,139 @@
# SPEC: hub — экран «Сервисы» + экран «Прокси (Caddy)»
> Для агента-разработчика. Проект уже активен: Go API + Vite SPA, живёт под `hub`.
> Репозиторий — в Gitea на боксе, CI через act-runner (host-режим, от пользователя `homelab`).
> Факты окружения ниже проверены 2026-08-30 — менять не нужно, если не сказано иначе.
## 1. Что за проект
Личный хаб-дашборд для телефона (PWA). Три части:
1. **Клиент** — Vite SPA (сборка → `web/dist/`, раздаёт Caddy с SPA-fallback на `/index.html`).
2. **API** — Go, один бинарник `hub-api`, слушает **`127.0.0.1:8484`** (unit задаёт `PORT=8484`).
3. **Раздача/прокси** — Caddy, домен `hub.alexey-homelab.duckdns.org`:
- `/api/*` и `/homelab.v1.HomelabService/*` → `127.0.0.1:8484`
- всё остальное → `~/apps/hub/dist/` (SPA).
Деплой: push в `main` → Gitea Actions (`deploy.workflow.yml`) → `make web server` →
`~/apps/hub/dist/` + `~/apps/hub/hub-api` (атомарный mv!) → `systemctl --user restart hub-api`.
Юнит сервиса: `~/srv/hub-api.service` (user, symlink в `~/.config/systemd/user/`).
Уже есть в конфиге (`~/apps/hub/config.yaml`): `links` (карточки-ссылки), `monitor`
(cpu/disk/mem через скрипты), `exec` (greet). Это работает — не ломай.
## 2. Задача: экран «Сервисы»
Единый список всех сервисов бокса. Источник — **два разных мира**, их нужно объединить
в один список с полем `kind` и иконкой для отличия:
- `kind: native` — бинарники под user-systemd (rathole, OliveTin, hermes-*, hub-api...)
- `kind: docker` — контейнеры Docker, сгруппированные по compose-проектам
### 2.1. Native-сервисы
- Список: парсить **`~/srv/*.service`** (каждый файл = сервис). Часть файлов — symlink'и
на `~/.config/systemd/user/*.service` — это нормально, читай через symlink.
- Статус (машиночитаемо, всегда exit 0, не путай с `status`):
```
systemctl --user show <unit> -p ActiveState,SubState,LoadState,UnitFileState --value
```
- Кнопки: `systemctl --user start|stop|restart <unit>` — **без sudo**, от homelab.
- **Disabled-состояние**: если юнит не найден в systemd (`LoadState=not-found`) — это НЕ ошибка,
пользователь намеренно сделал `systemctl disable` (сейчас так: ntfy, olivetin, pairdrop,
uptime-kuma). Показывай бейдж «выключен» (disabled), серым, без алертов.
### 2.2. Docker-сервисы
- Список: **`docker ps -a`** (не юниты!). Контейнеров 37, юнитов на них нет/мало — источник
истины только Docker.
- Для каждого: `docker inspect -f '{{.State.Running}}|{{.State.Status}}' <name>` +
label проекта `com.docker.compose.project` (через `docker inspect -f '{{index .Config.Labels "com.docker.compose.project"}}'`).
- **Группировка**: контейнеры одного проекта — одна карточка проекта (itsaplan, pullmd,
affine, kurir, minepanel, searxng...), одиночные (gitea, homepage, samba...) — свои карточки.
- **Агрегированный статус проекта**: все рабочие running → «работает»; часть → «частично
(N/M)»; ни одного → «остановлен». Служебные контейнеры (`*-redis`, `*-postgres`,
`*-minio-init-1`, `affine_migration` — они `policy=no`, `running=false`) сворачивать,
не показывать как отдельные карточки. Иконка-отличие docker: 🐳 (или своя, по вкусу UI).
- Кнопки на проект: `docker start|stop <все контейнеры проекта>` (без sudo — homelab в группе docker).
- **Семантика stop**: `docker stop` + policy `unless-stopped` = контейнер не поднимется при
ребуте (docker считает его намеренно остановленным). Это ожидаемое поведение кнопки
«остановить до ребута» — задокументируй в UI тултипом, не «чини».
### 2.3. Редактирование unit-файла в браузере
- GET/PUT `~/srv/<name>.service` (write — через symlink в реальный юнит, ок).
- **После сохранения — обязательно `systemctl --user daemon-reload`**, иначе правки не применятся.
- ⚠️ Валидация имени сервиса: только `^[a-z0-9_.-]+$` — защита от path traversal.
- Предупреждение в UI: правка unit = смена ExecStart = выполнение произвольных команд;
restart для docker-обёрток (`oneshot` + `RemainAfterExit`) = stop+start, а не restart.
## 3. Задача: экран «Прокси» (Caddy)
Минимум: отдельная страница со списком доменов и возможностью **руками редактировать конфиг**.
Идеал: на странице сервиса поле «домен» — но это позже, начни с отдельной страницы.
**Как устроен Caddy на боксе (не менять схему!):**
- Рабочая копия конфига: **`~/caddy-sync/Caddyfile`** — её и правим (она в homelab-зоне, writable).
- Применение (NOPASSWD sudo уже настроен):
```
sudo -n /usr/local/sbin/caddy-sync # validate + копия в /etc/caddy/Caddyfile + backup
sudo -n /usr/local/sbin/caddy-reload # validate + reload + автоген ~/www/util/domains
sudo -n /usr/local/sbin/hosts-sync # /etc/hosts; ОБЯЗАТЕЛЕН при новом duckdns-домене
```
- Read: `sudo -n /usr/local/sbin/caddy-cat` (весь Caddyfile) или admin API
`http://127.0.0.1:2019/config/` (машиночитаемо, список хостов).
- **Запрещено трогать**: секцию ab-hl (внешний выход на VPS), rathole-туннели, knot-resolver.
Внешний VPS-выход — отдельная будущая задача.
**UI страницы «Прокси»:**
- Таблица сайтов: домен → куда проксируется (или file_server) → статус (из списка выше).
- Кнопка «Редактировать» → редактор (textarea/monaco-лайт) с **рабочей копией** `~/caddy-sync/Caddyfile`.
- Кнопка «Применить» → `caddy-sync` → `caddy-reload` → `hosts-sync`, показать вывод каждой
команды (это твой «результат валидации» — caddy-sync сам вернёт REJECT при ошибке).
- После успеха — обновить таблицу.
## 4. Контракт API (добавить в Go-бэкенд)
Предлагаемые эндпоинты (паттерн REST, JSON; auth — как уже сделано в проекте):
| Метод | Путь | Описание |
|---|---|---|
| GET | `/api/services` | список: `[{name, kind, group, status, substate, containers?}]` |
| POST | `/api/services/{name}/start` | native: systemctl start; docker: docker start (проект) |
| POST | `/api/services/{name}/stop` | аналогично |
| POST | `/api/services/{name}/restart` | native: restart; docker: stop+start |
| GET | `/api/services/{name}/file` | содержимое `~/srv/<name>.service` |
| PUT | `/api/services/{name}/file` | сохранить + `daemon-reload` |
| GET | `/api/caddy` | список сайтов + рабочий конфиг |
| PUT | `/api/caddy` | сохранить рабочий конфиг |
| POST | `/api/caddy/apply` | sync → reload → hosts-sync, вернуть вывод |
Имя `{name}` всегда валидировать: `^[a-z0-9_.-]+$`. Команды — фиксированные, без
интерполяции пользовательского ввода в shell (используй `exec.Command` с аргументами,
не shell-строку).
## 5. Критерии приёмки
1. Экран «Сервисы» показывает единый список: native из `~/srv` + docker-проекты из `docker ps -a`,
с иконками 🐳/⚙️ и агрегированными статусами.
2. Кнопки start/stop/restart работают для обоих видов без sudo; disabled-юниты показываются
серым как «выключен», не как ошибка.
3. Редактирование unit-файла сохраняет файл и делает daemon-reload; кривое имя сервиса не
проходит валидацию.
4. Экран «Прокси»: таблица доменов, редактор рабочей копии `~/caddy-sync/Caddyfile`,
«Применить» выполняет sync/reload/hosts-sync и показывает вывод; секция ab-hl не меняется.
5. Всё работает через существующий auth, без новых открытых портов; PWA-обновление
подхватывается на iOS (skipWaiting + clientsClaim, как в дизайн-доке).
6. Сборка/деплой проходят через существующий CI (`deploy.workflow.yml`), юнит `hub-api` переживает рестарт.
## 6. Справочные факты бокса (не перепроверять)
- Все сервисы живут от пользователя `homelab`; user-systemd: `systemctl --user` (XDG_RUNTIME_DIR
нужен только из root-сессий).
- `~/srv/README.md` — документация списка сервисов; обновить её, если меняется формат.
- Порт API: **8484** (не менять — Caddy смотрит туда).
- Сборка Go: `CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build` (тулчейн на боксе есть,
`~/apps/go/bin` в PATH CI).
- Фронт: Vite (node 22 / pnpm 11 в `~/.local/bin`).
- Сейчас в `~/srv` 26 юнитов: 18 docker-обёрток, 9 native (act-runner, hermes-dashboard,
hermes-gateway, hub-api, itsaplan-runner, olivetin, rathole, rathole-status, ttyd-rathole),
4 disabled (ntfy, olivetin, pairdrop, uptime-kuma — LoadState=not-found, это норма).