# 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 -p ActiveState,SubState,LoadState,UnitFileState --value ``` - Кнопки: `systemctl --user start|stop|restart ` — **без 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}}' ` + 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/.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/.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, это норма).