11 KiB
SPEC: hub — экран «Сервисы» + экран «Прокси (Caddy)»
Для агента-разработчика. Проект уже активен: Go API + Vite SPA, живёт под
hub. Репозиторий — в Gitea на боксе, CI через act-runner (host-режим, от пользователяhomelab). Факты окружения ниже проверены 2026-08-30 — менять не нужно, если не сказано иначе.
1. Что за проект
Личный хаб-дашборд для телефона (PWA). Три части:
- Клиент — Vite SPA (сборка →
web/dist/, раздаёт Caddy с SPA-fallback на/index.html). - API — Go, один бинарник
hub-api, слушает127.0.0.1:8484(unit задаётPORT=8484). - Раздача/прокси — 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+ policyunless-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 APIhttp://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. Критерии приёмки
- Экран «Сервисы» показывает единый список: native из
~/srv+ docker-проекты изdocker ps -a, с иконками 🐳/⚙️ и агрегированными статусами. - Кнопки start/stop/restart работают для обоих видов без sudo; disabled-юниты показываются серым как «выключен», не как ошибка.
- Редактирование unit-файла сохраняет файл и делает daemon-reload; кривое имя сервиса не проходит валидацию.
- Экран «Прокси»: таблица доменов, редактор рабочей копии
~/caddy-sync/Caddyfile, «Применить» выполняет sync/reload/hosts-sync и показывает вывод; секция ab-hl не меняется. - Всё работает через существующий auth, без новых открытых портов; PWA-обновление подхватывается на iOS (skipWaiting + clientsClaim, как в дизайн-доке).
- Сборка/деплой проходят через существующий 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). - Сейчас в
~/srv26 юнитов: 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, это норма).