Compare commits

..
5 Commits
28 changed files with 598 additions and 35 deletions
+51
View File
@@ -0,0 +1,51 @@
# Беклог
Единый список будущих задач по серверу: то, что уже решили сделать, и идеи,
которые ещё надо обдумать. Это **источник истины по беклогу** — одна задача =
один файл в этом каталоге. Не план реализации: детальные проработки живут в
[`docs/drafts`](../drafts) (беклог даёт сводку и ссылку), а реализованное
переезжает в ADR/спеку и пункт беклога удаляется.
Приоритет — грубая оценка «ценность / стоимость», не обязательство к порядку.
Источники пунктов помечены:
_(ансибл-ревью)_ — ревью плейбуков [`docs/drafts/ansible-review.md`](../drafts/ansible-review.md) (2026-05-25);
_(tududi)_ — перенос из инбокса задач tududi (проект Pet Project Server);
_(sec-ревью)_ — три независимых security-ревью на модели fable (2026-07-13).
Драфты `timeweb.md` и `timeweb-migration-log.md` — исторические логи уже
завершённой миграции в Timeweb (cutover 2026-05-23), в беклог не входят.
## Высокий
- [Закрыть admin API Caddy](caddy-admin-api-localhost.md) — `admin :2019` слушает всю docker-сеть; любой контейнер может переписать маршрутизацию и снять forward_auth _(sec-ревью)_
- [Убрать группу `docker` у сервисных пользователей](service-users-docker-group.md) — `docker` = root на хосте; ~все app-аккаунты фактически root, изоляция «юзер на сервис» обнуляется _(sec-ревью)_
- [Изолировать/запаролить сессионный redis Authelia](authelia-redis-isolation.md) — стор сессий SSO в общей сети без пароля; запись в него = hijack SSO всех сервисов _(sec-ревью)_
- [Алерты на проблемные контейнеры](container-alerts.md) — wakapi крутился в restart-loop несколько дней незамеченным; healthcheck в compose + алерты Netdata
- [Рестарт контейнеров через handlers](ansible-handlers-restart.md) — `state: restarted` выполняется безусловно на каждом прогоне (лишний downtime), нет ни одного handler _(ансибл-ревью)_
## Средний
- [Секреты через `*_FILE`, а не inline `environment:`](secrets-env-to-file.md) — outline/wakapi/tududi/gitea/gramps/tuwunel держат секреты в env; видны в `docker inspect` и `/proc` _(sec-ревью)_
- [Захардить мониторинг-стек](harden-monitoring-stack.md) — netdata/dozzle: лишние привилегии + docker.sock + доверие Remote-User в общей сети; socket-proxy + monitoring_network _(sec-ревью)_
- [Ограничить `/metrics` Miniflux](miniflux-metrics-restrict.md) — `METRICS_ALLOWED_NETWORKS=0.0.0.0/0` + прокси без ограничения пути отдаёт метрики публично _(sec-ревью)_
- [Закрыть calibre-web forward-auth](calibre-forward-auth.md) — домен без forward_auth, защита на встроенной авторизации с дефолтом `admin/admin123` _(sec-ревью)_
- [Понизить log level Authelia до info](authelia-log-level.md) — `debug` в проде утекает детали аутентификации и OIDC через `docker logs`/dozzle _(sec-ревью)_
- [Включить автообновления безопасности](security-autoupdates.md) — патчи openssh/libssl только вручную; окна в недели между CVE и фиксом _(sec-ревью)_
- [Запретить SSH-вход под root](ssh-disable-root-login.md) — `PermitRootLogin yes` пускает root по ключу; наименьшие привилегии + defense-in-depth (пароль уже off) _(sec-ревью)_
- [Убрать устаревший инвентарь после миграции](stale-inventory-cleanup.md) — `production.yml` указывает на мёртвый YC-хост, живой — `timeweb.yml`; имена инвертированы
- [Gitea runner on-demand в Yandex Cloud](gitea-runner-on-demand.md) — раннер активен только во время сборки; webhook→Cloud Function стартует ВМ, probe/decide гасят по idle; экономия ~95%
- [Синхронизация общих Ansible-ролей](shared-roles-sync.md) — `owner`/`eget`/`secrets` дублируются между репозиториями и дрейфуют; канон в ansible-shared + rsync-таски invoke
- [Уведомления о размере приложений и диске](disk-usage-alerts.md) — алерты на заполнение диска и аномальный рост data-томов; сервер ресурсно ограничен _(tududi)_
- [Composable-роль `backup`](ansible-backup-role.md) — бэкап — самый чистый шов для extraction (одинаков у всех, различается только список targets) _(ансибл-ревью)_
- [Точечные фиксы идемпотентности и баг имени play](ansible-quick-fixes.md) — быстрые правки: имя play в wanderer, `changed_when` в netdata/eget, backup-targets через template _(ансибл-ревью)_
## Низкий
- [Мелкий хардненинг Caddy](caddy-hardening-nits.md) — HSTS-заголовок, убрать лишний `NET_ADMIN`, убрать leftover-листенер `:29999` _(sec-ревью)_
- [Мелкий хардненинг контейнеров](container-hardening-nits.md) — `no-new-privileges`, non-root `user:`, homepage off `:latest`, соглашение о `127.0.0.1:` портах _(sec-ревью)_
- [Унифицировать ACL техпанелей Authelia](authelia-acl-unify.md) — rssbridge (SSRF) и dashboard за `one_factor` без subject; свести к `group:admins` + `two_factor` _(sec-ревью)_
- [`vars_files` → `group_vars/all/`](ansible-group-vars.md) — убрать повторяющийся boilerplate `vars_files` из всех плейбуков _(ансибл-ревью)_
- [Причесать роль `owner` под конвенции](ansible-owner-role-cleanup.md) — `assert` вместо `fail`+`when`, `loop` вместо `with_*`, добавить `meta`/README _(ансибл-ревью)_
- [Инвентарь: `host_vars`, группы, точечный `become`](ansible-inventory-hostvars.md) — хост-специфику в `host_vars/server.yml`, хост в именованную группу, глобальный root → точечный become _(ансибл-ревью)_
- [Фоновая зачистка стиля и конфигурации](ansible-style-nits.md) — sudoers.d, профиль ansible-lint, `ansible.cfg`, кавычки, `cache_valid_time` _(ансибл-ревью)_
- [Logrotate для логов бэкапов](backup-logs-logrotate.md) — логи `backup-all.py`/restic копятся без ротации _(tududi)_
+17
View File
@@ -0,0 +1,17 @@
# Composable-роль `backup`
**Приоритет:** средний
Бэкап — самый чистый шов для extraction: `gobackup.yml` + `backup.sh` +
`backup-targets` + интеграция с restic одинаковы у всех сервисов, различается
только список целей. Вынести в роль `backup` с параметром «список targets» — она
не трогает индивидуальность сервиса. Это правильный размер абстракции (как уже
сделанный `owner`), а **не** мега-роль `docker_app` (та отклонена осознанно:
приложения реально разные, catch-all обрастает `when:`). Обкатать на одном
сервисе, затем раскатать.
Полный контекст: [docs/drafts/ansible-review.md](../drafts/ansible-review.md) §1.
Связано: roles/owner (образец), files/<app>/gobackup.template.yml,
files/<app>/backup.template.sh, playbook-backups.yml,
feedback_per_app_playbook_duplication.
+22
View File
@@ -0,0 +1,22 @@
# `vars_files` → `group_vars/all/`
**Приоритет:** низкий
В каждом плейбуке повторяется boilerplate:
```yaml
vars_files:
- vars/secrets.yml
- vars/vars.yml
```
Ansible автоматически подхватывает `group_vars/all.yml` и `group_vars/all/secrets.yml`
(vault) для группы `all`. Перенос `vars/vars.yml``group_vars/all/main.yml` и
`vars/secrets.yml``group_vars/all/vault.yml` убирает `vars_files` из всех
плейбуков. Низкий риск, адаптируется по одному плейбуку за раз. Учесть
`.crushignore`/проверку шифрования vault (маска `*secrets.yml`) и pre-commit-хук
проверки шифрования при переименовании.
Контекст: [docs/drafts/ansible-review.md](../drafts/ansible-review.md) §2.
Связано: все playbook-*.yml, vars/secrets.yml, vars/vars.yml, lefthook.yml.
+20
View File
@@ -0,0 +1,20 @@
# Рестарт контейнеров через handlers, а не безусловно
**Приоритет:** высокий
Ни в одном плейбуке нет `handlers:`. Вместо этого задача `state: restarted`
выполняется **всегда** — рестартит контейнер на каждом прогоне даже без
изменений (`playbook-caddyproxy.yml:106`, `playbook-netdata.yml:143`,
`playbook-authelia.yml:92`): не идемпотентно, лишний downtime. В
`playbook-gitea.yml` рестарта нет вовсе — несогласованность. Канонический
паттерн: шаблон конфига `notify`-ит handler, который делает
`docker_compose_v2: state: restarted` только при реальном изменении. Внедряется
инкрементально, по одному сервису. Заодно убрать мёртвый
`docker_compose_file_result` в `playbook-memos.yml:76` (регистрируется, нигде не
используется — задумывался под `when`/`notify`).
Топ-приоритет ансибл-ревью. Полный контекст:
[docs/drafts/ansible-review.md](../drafts/ansible-review.md) §3.
Связано: playbook-caddyproxy.yml, playbook-netdata.yml, playbook-authelia.yml,
playbook-gitea.yml, playbook-memos.yml.
@@ -0,0 +1,18 @@
# Инвентарь: `host_vars`, именованные группы, точечный `become`
**Приоритет:** низкий
- **`production.yml` и `timeweb.yml`** оба объявляют хост `server` под
`ungrouped:`, хост-специфичные данные (`application_dir`,
`mount_external_storage`, `ansible_host`, `ansible_user`) вписаны инлайн.
Конвенциональнее — `host_vars/server.yml` и хост в именованной группе. Два
инвентаря с одинаковым именем хоста + `hosts: all` — ошибка `-i` молча уедет не
туда. (`timeweb.yml` — артефакт завершённой миграции, заодно решить, нужен ли
он ещё.)
- `ansible_become: true` глобально — всё бежит под root. Для личного сервера
прагматично; точечный `become`/`become_user` ближе к наименьшим привилегиям, но
это низкий приоритет.
Контекст: [docs/drafts/ansible-review.md](../drafts/ansible-review.md) §6.
Связано: production.yml, timeweb.yml.
@@ -0,0 +1,20 @@
# Причесать роль `owner` под конвенции
**Приоритет:** низкий
Роль `owner` разошлась по стилю с `eget`/`secrets`:
- **`roles/owner/tasks/main.yml:2-10`** — валидация аргументов через `fail` +
`when`, причём две задачи с **идентичным именем**. `eget` для того же делает
`assert` (`roles/eget/tasks/main.yml:15`). Привести к одному стилю — `assert`
либо декларативный `meta/argument_specs.yml`.
- **`roles/owner/tasks/main.yml:32,53`** — устаревшие `with_items`/`with_dict`;
конвенция — `loop` (`loop: "{{ owner_ssh_keys }}"`,
`loop: "{{ owner_env_dict | dict2items }}"`).
- У `owner` нет `meta/main.yml` и README, тогда как у `eget` и `secrets` есть.
- Имена задач с точкой на конце (`"Prepare env variables."`) — ansible-lint в
строгом профиле это ловит.
Контекст: [docs/drafts/ansible-review.md](../drafts/ansible-review.md) §5.
Связано: roles/owner, roles/eget (образец), roles/secrets.
+25
View File
@@ -0,0 +1,25 @@
# Точечные фиксы идемпотентности и баг имени play
**Приоритет:** средний
Быстрые пойнтовые правки без структурных изменений:
- **`playbook-wanderer.yml:2`** — play назван `"Configure gramps application"`
при `app_name: "wanderer"` (копипаст из gramps). Поправить имя. _(баг)_
- **`playbook-netdata.yml:118-125`** — `changed_when: ...rc != 0` для read-only
запроса лишён смысла; должно быть `changed_when: false`. Лучше заменить
`shell: grep docker /etc/group` на модуль `ansible.builtin.getent` — уйдёт
`pipefail` и хрупкий парсинг.
- **`playbook-eget.yml:23-78`** — восемь `command` с `changed_when: false`, хотя
реально ставят/обновляют бинарники: прогон всегда «ok», теряется честность
`--diff`. Ставить через роль `eget` (она корректно проверяет версию) или через
проверку версии.
- **`playbook-memos.yml:57-67`** и аналоги — сборка `backup-targets` через
`lineinfile` в цикле не удаляет устаревшие строки при изменении списка; `mode:
"0750"` на файле-списке выглядит как copy-paste. Чище — `template`/`copy:
content` со всем списком.
Контекст: [docs/drafts/ansible-review.md](../drafts/ansible-review.md) §4, §7.
Связано: playbook-wanderer.yml, playbook-netdata.yml, playbook-eget.yml,
playbook-memos.yml, roles/eget.
+25
View File
@@ -0,0 +1,25 @@
# Фоновая зачистка стиля и конфигурации Ansible
**Приоритет:** низкий
Косметика и мелочи конфигурации, копятся в один проход:
- **sudoers**: `playbook-backups.yml:52-59` правит `/etc/sudoers` через
`lineinfile`. Конвенция — отдельный файл в `/etc/sudoers.d/` (через
`copy`/`template` с `validate: visudo -cf %s`).
- **`.ansible-lint.yml`** содержит только `exclude_paths`, профиль не задан явно,
хотя AGENTS.md заявляет «профиль production». Прописать `profile: production`
либо поправить документацию.
- **`ansible.cfg`** минимален — добавить `stdout_callback = yaml`,
`interpreter_python = auto_silent`, `force_handlers = true` (последнее нужно
вместе с переходом на handlers).
- Несогласованные кавычки и пути (`'directory'` vs `"directory"`,
`src: "./files/..."` vs `src: "files/..."`, одинарные кавычки в
`playbook-all-setup.yml`).
- **`playbook-system.yml:24`** — `apt` без `cache_valid_time`, обновляет кэш
каждый прогон.
Контекст: [docs/drafts/ansible-review.md](../drafts/ansible-review.md) §8.
Связано: playbook-backups.yml, .ansible-lint.yml, ansible.cfg,
playbook-system.yml, playbook-all-setup.yml.
+15
View File
@@ -0,0 +1,15 @@
# Унифицировать ACL техпанелей Authelia
**Приоритет:** низкий
В `files/authelia/configuration.template.yml` `rssbridge` и `start` (dashboard)
закрыты `one_factor` вообще без `subject`, тогда как
netdata/dozzle/goaccess/bifrost/wanderbase/remembos — `two_factor` +
`group:admins`. rssbridge умеет ходить по произвольным URL (SSRF-поверхность) и при
этом живёт за одним фактором, доступен любому пользователю из `users.yml`. Для
одного пользователя риск невелик, но логичнее свести техпанели к `group:admins` +
`two_factor`, а `one_factor` без субъекта оставить только там, где это осознанно.
Из sec-ревью (fable, 2026-07-13).
Связано: files/authelia/configuration.template.yml.
+13
View File
@@ -0,0 +1,13 @@
# Понизить log level Authelia с debug до info
**Приоритет:** средний
`files/authelia/configuration.template.yml``log.level: "debug"`. Debug-логи
Authelia содержат детали запросов аутентификации, имена пользователей, заголовки и
внутренние подробности OIDC-потоков — они не нужны в обычной эксплуатации и
расширяют поверхность утечки через `docker logs`/dozzle (который сам открыт
админам). Поставить `info` (или `warn`), debug включать точечно при отладке.
Из sec-ревью (fable, 2026-07-13).
Связано: files/authelia/configuration.template.yml.
+15
View File
@@ -0,0 +1,15 @@
# Изолировать/запаролить сессионный redis Authelia
**Приоритет:** высокий
`authelia_redis` — стор сессий SSO — живёт в общей `monitoring_network` без пароля
(`files/authelia/docker-compose.template.yml`), а в той же сети сидят transcriber,
gramps_redis, outline_redis/postgres, miniflux_postgres, netdata. Компрометация
любого из них даёт запись в стор сессий Authelia → hijack SSO для всех сервисов.
Вынести пару authelia_app+authelia_redis в собственную internal-сеть (как уже
сделано у gramps/outline/miniflux с их `*_network`) и/или включить `requirepass`;
из `monitoring_network` redis убрать, если netdata его реально не скрейпит.
Из sec-ревью (fable, 2026-07-13).
Связано: files/authelia/docker-compose.template.yml, playbook-docker.yml.
+13
View File
@@ -0,0 +1,13 @@
# Logrotate для логов бэкапов
**Приоритет:** низкий
Логи бэкапов на сервере (вывод `backup-all.py` / restic) копятся без ротации —
со временем растут неограниченно. Настроить logrotate (отдельный файл в
`/etc/logrotate.d/` через `copy`/`template`) с разумным retention и сжатием.
Завести в `playbook-backups.yml` рядом с настройкой самих бэкапов.
Из tududi (проект Pet Project Server).
Связано: playbook-backups.yml, files/backups/backup-all.py,
project_restic_intelligent_tiering.
+16
View File
@@ -0,0 +1,16 @@
# Закрыть admin API Caddy (localhost вместо всей сети)
**Приоритет:** высокий
`files/caddyproxy/Caddyfile.template:8``admin :2019`: admin API слушает на всех
интерфейсах контейнера, а в `web_proxy_network` сидят все приложения. Любой
скомпрометированный контейнер (rssbridge, tududi, calibre, ...) может POST-ом в
`http://caddyproxy:2019/config/` переписать маршрутизацию, снять `forward_auth`,
перехватить трафик или сертификаты. Порт реально нужен только netdata для метрик
(`files/netdata/go.d/prometheus.conf`). Фикс: вернуть `admin localhost:2019`, а
метрики отдать отдельным site-блоком `:2019 { metrics }` — netdata продолжит
скрейпить, admin API станет недоступен из сети.
Из sec-ревью (fable, 2026-07-13).
Связано: files/caddyproxy/Caddyfile.template, files/netdata/go.d/prometheus.conf.
+19
View File
@@ -0,0 +1,19 @@
# Мелкий хардненинг Caddy
**Приоритет:** низкий
- **HSTS**: заголовок `Strict-Transport-Security` не выставляется нигде в
`files/caddyproxy/Caddyfile.template`. Одна строка `header
Strict-Transport-Security "max-age=31536000"` в общем сниппете (рядом с
`access_log`) закрывает downgrade/SSL-strip для всех поддоменов разом.
- **Лишний `NET_ADMIN`** (`files/caddyproxy/docker-compose.template.yml`) —
reverse-proxy эта capability не нужна (требуется лишь для тюнинга UDP-буферов
HTTP/3, работает и без неё с warning). Убрать у edge-контейнера, торчащего в
интернет.
- **Leftover-листенер `:29999`** в `Caddyfile.template` (site `status.vakhrushev.me,
:29999`) — наследие прямого доступа к netdata; убрать, оставив только доменный
site-блок.
Из sec-ревью (fable, 2026-07-13).
Связано: files/caddyproxy/Caddyfile.template, files/caddyproxy/docker-compose.template.yml.
+17
View File
@@ -0,0 +1,17 @@
# Закрыть calibre-web forward-auth и проверить дефолтные креды
**Приоритет:** средний
`calibre.vakhrushev.me` проксируется на `calibre_web_app:8083` без блока
`forward_auth` (`files/caddyproxy/Caddyfile.template`), правила на этот домен нет и
в ACL Authelia — защита целиком на встроенной аутентификации calibre-web, которая
из коробки идёт с `admin`/`admin123`. Если пароль не сменён вручную, панель
управления библиотекой доступна в интернете с дефолтными кредами. Либо закрыть
домен `forward_auth` + правилом `group:admins`, либо подтвердить смену пароля
админа. Заодно перепроверить memos — не отключена ли публичная регистрация
первым-админом (`files/memos/docker-compose.template.yml`).
Из sec-ревью (fable, 2026-07-13).
Связано: files/caddyproxy/Caddyfile.template, files/authelia/configuration.template.yml,
files/calibre, files/memos.
+17
View File
@@ -0,0 +1,17 @@
# Алерты на проблемные контейнеры
**Приоритет:** высокий
wakapi однажды упал на миграциях и несколько дней крутился в restart-loop —
никто не узнал. Docker считал контейнер «running», пока процесс жив. Нужны два
слоя: (1) healthcheck + `start_period` в compose-шаблонах, чтобы Docker видел
реальное состояние (окно `start_period` даёт миграциям отработать); (2) алерты
через Netdata на restart-loop (счётчик перезапусков растёт) и на
`container_health_status != healthy` дольше M минут, канал нотификаций — один
(Telegram/ntfy — выбрать). restart policy оставляем `unless-stopped` (алерт +
ручное решение вместо `on-failure`, который не встаёт после ребута). Опционально
позже — Uptime Kuma для внешнего HTTP-чека по публичным URL.
Полный план: [docs/drafts/alerts.md](../drafts/alerts.md).
Связано: playbook-netdata.yml, compose-шаблоны всех сервисов, project_server_specs.
+22
View File
@@ -0,0 +1,22 @@
# Мелкий хардненинг контейнеров
**Приоритет:** низкий
- **`no-new-privileges`**: ни один compose-шаблон не задаёт `security_opt:
["no-new-privileges:true"]`. Добавить всем — одна строка, режет эскалацию через
setuid-бинарники в образах.
- **non-root `user:`**: без него работают caddyproxy, gramps (данные в
`/root/.gramps`), rssbridge, dozzle, homepage и valkey/postgres-сайдкары gramps.
Дотянуть там, где образ позволяет (rssbridge, valkey) — продолжение уже принятой
в проекте практики.
- **homepage на `:latest`** (`vars/homepage.yml`) — плавающий тег + ночной `docker
image prune` может подтянуть другое содержимое без отката. Перевести на
иммутабельный `<sha>-<timestamp>` по образцу transcriber
(`vars/transcriber.images.yml`).
- **Соглашение о портах**: зафиксировать в AGENTS.md, что новые `ports:` — только
с `127.0.0.1:`. Docker публикует порты мимо UFW, поэтому дисциплина
localhost-биндинга — единственный контроль (сейчас соблюдается: apprise, wanderer).
Из sec-ревью (fable, 2026-07-13).
Связано: все files/*/docker-compose*.template.yml, vars/homepage.yml, AGENTS.md.
+18
View File
@@ -0,0 +1,18 @@
# Уведомления о размере приложений и заполнении диска
**Приоритет:** средний
Сервер ресурсно ограничен (диск небольшой, RAM ~80% на штатной нагрузке —
`project_server_specs`), а рост данных сейчас никак не сигнализируется: узнаёшь,
только когда место кончилось. Нужны алерты на (1) заполнение диска выше порога
(общий том + data-тома приложений) и (2) аномальный рост размера отдельного
приложения (лог/БД/кэш распух). Реализация — через Netdata (disk space collector
+ алерты в `health.d/*.conf`), тот же канал нотификаций, что и у алертов на
контейнеры. Пороги калибровать под фактический объём диска.
Отдельный сигнал от [container-alerts](container-alerts.md) (там — здоровье
контейнеров, здесь — ёмкость). Канал нотификаций общий.
Из tududi (проект Pet Project Server).
Связано: playbook-netdata.yml, container-alerts, project_server_specs.
+20
View File
@@ -0,0 +1,20 @@
# Gitea runner on-demand в Yandex Cloud
**Приоритет:** средний
Self-hosted раннер Gitea Actions, активный только во время сборки. Сборок ~10/нед
по ~5 мин — ВМ 24/7 даёт утилизацию ~1% (≈$23/мес), on-demand — ≈$1/мес (экономия
~95%). Архитектура: push → Gitea webhook → Cloud Function (HMAC-валидация +
стейт-машина старта) → Compute API стартует ВМ → `act_runner` в docker забирает
джобу; на ВМ probe (телеметрия раз в 30с) + decide (решение раз в 1 мин) гасят ВМ
после idle-окна через Compute REST из metadata-токена. Три слоя страховки от
зависшей ВМ (soft idle-stop, probe-staleness, внешний алерт Cloud Monitoring на
uptime > 24ч). Плейбук `playbook-gitea-runner.yml` + набор invoke-тасков
(`runner-bootstrap`, `runner-deploy-function`, `runner-pl`, ...).
Дизайн проработан целиком (ресурсы YC, секреты, стоимость, принятые риски, план
внедрения из 11 шагов). Открытые вопросы: канал нотификаций, executor (docker),
webhook на PR. Полный дизайн:
[docs/drafts/gitea-runner-on-demand.md](../drafts/gitea-runner-on-demand.md).
Связано: playbook-gitea.yml, roles/owner, vars/secrets.yml, tasks.py.
+25
View File
@@ -0,0 +1,25 @@
# Захардить мониторинг-стек (netdata / dozzle / goaccess)
**Приоритет:** средний
Мониторинг-контейнеры — самые привилегированные на хосте и при этом торчат в общую
`web_proxy_network` рядом с приложениями:
- **netdata** (`files/netdata/docker-compose.template.yml`) — `SYS_ADMIN` +
`SYS_PTRACE` + `apparmor:unconfined`, плюс весь `/` и docker.sock: фактически
root-контейнер. Для метрик обычно хватает `SYS_PTRACE`; `SYS_ADMIN` и
`apparmor:unconfined` попробовать убрать (netdata деградирует мягко).
- **dozzle** (`files/dozzle/docker-compose.template.yml`) — RW docker.sock +
`DOZZLE_AUTH_PROVIDER: forward-proxy` (верит заголовку `Remote-User` от кого
угодно). Любой контейнер в `web_proxy_network` может напрямую сходить на
`dozzle_app:8080` с поддельным `Remote-User` и читать логи всех контейнеров (в
логах бывают токены/ссылки).
- docker.sock обоим пустить через `tecnativa/docker-socket-proxy` (только нужные
GET), напрямую не монтировать.
- перенести netdata/dozzle/goaccess в существующую `monitoring_network`
(`playbook-docker.yml`), а Caddy подключить к обеим сетям — уводит их из общей
сети с приложениями и устраняет lateral movement.
Из sec-ревью (fable, 2026-07-13).
Связано: files/netdata, files/dozzle, files/goaccess, files/caddyproxy, playbook-docker.yml.
+16
View File
@@ -0,0 +1,16 @@
# Ограничить эндпоинт `/metrics` Miniflux
**Приоритет:** средний
В `files/miniflux/docker-compose.template.yml` заданы `METRICS_COLLECTOR=1` и
`METRICS_ALLOWED_NETWORKS=0.0.0.0/0`, а Caddy проксирует весь
`miniflux.vakhrushev.me` без ограничения пути. За прокси Miniflux видит приватный
IP контейнера Caddy (попадает в `0.0.0.0/0`), поэтому
`https://miniflux.vakhrushev.me/metrics` отдаётся публично без аутентификации —
утечка эксплуатационной статистики. Сузить `METRICS_ALLOWED_NETWORKS` до подсети
`monitoring_network` (netdata скрейпит по внутреннему имени) или заблокировать
путь `/metrics` в Caddy.
Из sec-ревью (fable, 2026-07-13).
Связано: files/miniflux/docker-compose.template.yml, files/caddyproxy/Caddyfile.template.
+17
View File
@@ -0,0 +1,17 @@
# Секреты приложений через `*_FILE`, а не inline `environment:`
**Приоритет:** средний
miniflux сделан правильно — секреты через `*_FILE` + монтирование каталога
secrets `:ro` (`files/miniflux/docker-compose.template.yml`). Но outline
(DATABASE_URL с паролём, `SECRET_KEY`, `UTILS_SECRET`, OIDC secret, SMTP), wakapi
(salt, SMTP), tududi (session secret, OIDC secret, ключ LLM), gitea (SMTP),
gramps, tuwunel кладут секреты прямыми значениями в `environment:`. Такие
переменные читает любой член группы `docker`, любой процесс с доступом к
`/proc/<pid>/environ`, и они попадают в `docker inspect`. Где приложение умеет
`*_FILE`/конфиг-файл — постепенно перевести по образцу miniflux.
Из sec-ревью (fable, 2026-07-13). Связка с [service-users-docker-group](service-users-docker-group.md).
Связано: files/outline, files/wakapi, files/tududi, files/gitea, files/gramps,
files/tuwunel, files/miniflux (образец).
+13
View File
@@ -0,0 +1,13 @@
# Включить автообновления безопасности
**Приоритет:** средний
`playbook-system.yml:35``security_autoupdate_enabled: "no"`: хост с открытыми в
интернет 22/80/443/2222 получает патчи только при ручном запуске
`playbook-upgrade.yml`, т.е. окна в недели между публикацией CVE и патчем
openssh/libssl. Соразмерный фикс — включить unattended-upgrades только для
security-репозитория (одна переменная роли), без автоматических перезагрузок.
Из sec-ревью (fable, 2026-07-13).
Связано: playbook-system.yml, galaxy.roles/geerlingguy.security, playbook-upgrade.yml.
@@ -0,0 +1,18 @@
# Убрать группу `docker` у сервисных пользователей
**Приоритет:** высокий
Все приложения заводят системного пользователя с `owner_extra_groups: ["docker"]`
(во всех `playbook-*.yml`), но docker.sock реально монтируют только netdata и
dozzle. Членство в группе `docker` эквивалентно root на хосте (`docker run -v
/:/host`), поэтому выделенные «непривилегированные» аккаунты (authelia, outline,
gitea и т.д.) фактически все имеют root — изоляция «пользователь на сервис», ради
которой их и заводили, обнуляется. Контейнерами рулит Ansible от root (become),
сервисам группа не нужна. Убрать `docker` из `owner_extra_groups` у всех, кроме
тех, кому он действительно нужен для самостоятельного управления своим compose (и
тем — лучше через socket-proxy). За `primary_user` группу оставить для ручных
операций (`inv ssh`).
Из sec-ревью (fable, 2026-07-13). Усиливает риск из [secrets-env-to-file](secrets-env-to-file.md).
Связано: все playbook-*.yml (owner_extra_groups), roles/owner, playbook-docker.yml.
+21
View File
@@ -0,0 +1,21 @@
# Синхронизация общих Ansible-ролей между репозиториями
**Приоритет:** средний
Серверы управляются изолированными репозиториями (pet-project-server, buckland,
далее — торрент-бокс). Кастомные роли (`owner`, `eget`, `secrets`) концептуально
общие, но физически продублированы и дрейфуют (`owner` в buckland стал мёртвым
кодом). Решение под pets-подход: канон ролей в `~/projects/private/ansible-shared/`,
каждый репозиторий держит закоммиченную копию (она же «пин версии») и
синхронизируется через rsync-таски invoke — `roles-pull` / `roles-push` /
`roles-status`. Подписка на роли явная и пер-серверная (`SYNCED_ROLES` в
`tasks.py`). Обновление роли на сервере — осознанный коммит с читаемым диффом, не
скрытый сайд-эффект. Предохранитель от дрейфа — предупреждение в lefthook.
Отвергнуты: общий `roles_path`, симлинки, submodule/subtree, galaxy-из-git,
монорепо (все ломают самодостаточность или вводят лишнюю петлю).
Подход обсуждён, реализация не начата. Полный дизайн + план внедрения:
[docs/drafts/shared-roles-sync.md](../drafts/shared-roles-sync.md).
Связано: roles/owner, roles/eget, roles/secrets, tasks.py, lefthook.yml,
project_isolated_repos_shared_roles, project_shared_invoke_command_surface.
+26
View File
@@ -0,0 +1,26 @@
# Запретить SSH-вход под root
**Приоритет:** средний
`playbook-system.yml:34` переопределяет дефолт роли на
`security_ssh_permit_root_login: "yes"``PermitRootLogin yes`. Это управляет
**только** тем, пускать ли root по SSH, и не имеет отношения к паролю: вход по
паролю уже запрещён отдельной директивой `PasswordAuthentication no` (дефолт роли
`security_ssh_password_authentication: "no"`, не переопределён). То есть брутфорса
пароля тут нет — остаётся то, что root **явно разрешён к входу по ключу**.
Почему всё равно стоит закрыть:
- **Наименьшие привилегии.** Ansible ходит под непривилегированным `major` с
`become` (`production.yml`/`timeweb.yml`), root-логин для деплоя не нужен вовсе.
- **Defense-in-depth.** Если `PasswordAuthentication` когда-нибудь вернётся в
`yes` (правка, `Match`-блок, чужая роль), `PermitRootLogin yes` тут же откроет
root по паролю. `prohibit-password` держит root key-only независимо.
Рекомендация: поставить `"no"` (root по SSH недоступен, деплой под `major` не
страдает). Если нужен аварийный вход root по ключу — `"prohibit-password"`.
Из sec-ревью (fable, 2026-07-13); мотивировка уточнена после проверки конфига.
Связано: playbook-system.yml, production.yml, timeweb.yml,
galaxy.roles/geerlingguy.security.
+25
View File
@@ -0,0 +1,25 @@
# Убрать устаревший инвентарь после миграции на Timeweb
**Приоритет:** средний
После переезда на Timeweb (cutover 2026-05-23) имена инвентарей инвертировались:
- `timeweb.yml``92.53.105.41`**живой** хост, именно его использует `inv pl`
(`tasks.py:12` `HOSTS_FILE = "timeweb.yml"`).
- `production.yml``158.160.46.255`**мёртвый** старый YC-хост (ВМ
остановлена). При этом AGENTS.md всё ещё называет `production.yml` рабочим
инвентарём.
Ловушка: любой прямой запуск `ansible-playbook -i production.yml ...` (как в
примерах AGENTS.md) уедет на несуществующий/чужой хост. Плюс `production.yml`
содержит `application_dir`/`mount_external_storage`, которых нет в `timeweb.yml`
простой заменой файла эти переменные потеряются.
Сделать: сделать `production.yml` актуальным инвентарём Timeweb-хоста (перенести
недостающие host-vars), удалить `timeweb.yml`, поправить `HOSTS_FILE` в `tasks.py`
и примеры в AGENTS.md. Согласуется с задачей
[ansible-inventory-hostvars](ansible-inventory-hostvars.md) (host-специфику в
`host_vars/server.yml`, хост в именованную группу) — логично делать вместе.
Связано: production.yml, timeweb.yml, tasks.py, AGENTS.md,
docs/drafts/timeweb-migration-log.md, ansible-inventory-hostvars.
+54 -35
View File
@@ -14,6 +14,10 @@ VARS_FILE = "vars/vars.yml"
AUTHELIA_DOCKER = "docker run --rm -v $PWD:/data authelia/authelia:4.39.4 authelia" AUTHELIA_DOCKER = "docker run --rm -v $PWD:/data authelia/authelia:4.39.4 authelia"
# --- Базовый набор: единый интерфейс команд во всех репозиториях ---
# (набор и сигнатуры команд общие; реализация может отличаться по месту)
def _yq(query: str) -> str: def _yq(query: str) -> str:
result = subprocess.run( result = subprocess.run(
["yq", query, HOSTS_FILE], capture_output=True, text=True, check=True ["yq", query, HOSTS_FILE], capture_output=True, text=True, check=True
@@ -29,25 +33,6 @@ def _remote_host() -> str:
return _yq(".ungrouped.hosts.server.ansible_host") return _yq(".ungrouped.hosts.server.ansible_host")
def _application_dir() -> str:
"""Чтение application_dir: сначала из inventory (override), затем из vars/vars.yml."""
inv_value = _yq('.ungrouped.hosts.server.application_dir // ""')
if inv_value:
return inv_value
result = subprocess.run(
["yq", ".application_dir", VARS_FILE],
capture_output=True,
text=True,
check=True,
)
value = result.stdout.strip()
if not value or value == "null":
raise Exit(
f"application_dir не определён ни в inventory, ни в {VARS_FILE}", code=1
)
return value
def _rest_args() -> list[str]: def _rest_args() -> list[str]:
"""Возвращает аргументы после '--' из sys.argv""" """Возвращает аргументы после '--' из sys.argv"""
try: try:
@@ -74,7 +59,7 @@ def install_roles(ctx: Context) -> None:
@task @task
def pl(ctx: Context) -> None: def pl(ctx: Context) -> None:
"""Запустить плейбуки по имени: inv pl -- gitea miniflux""" """Запустить плейбуки по имени: inv pl -- <name> [name ...]"""
names = _rest_args() names = _rest_args()
if not names: if not names:
raise Exit("Укажи хотя бы один плейбук: inv pl -- <name> [name ...]", code=1) raise Exit("Укажи хотя бы один плейбук: inv pl -- <name> [name ...]", code=1)
@@ -88,34 +73,28 @@ def pl(ctx: Context) -> None:
@task @task
def ssh(ctx: Context) -> None: def ssh(ctx: Context) -> None:
"""SSH на удалённый сервер""" """SSH на удалённый сервер"""
subprocess.run(f"ssh {_remote_user()}@{_remote_host()}", shell=True) ctx.run(f"ssh {_remote_user()}@{_remote_host()}", pty=True)
@task @task
def zj(ctx: Context) -> None: def zj(ctx: Context) -> None:
"""Запуск zellij на удаленном сервере""" """Запуск zellij на удалённом сервере"""
subprocess.run( ctx.run(
f"ssh {_remote_user()}@{_remote_host()} -t zellij attach --create main", f"ssh {_remote_user()}@{_remote_host()} -t zellij attach --create main",
shell=True, pty=True,
) )
@task(aliases=["login"]) @task
def login_as_app(ctx: Context, app: str) -> None: def mc(ctx: Context) -> None:
"""SSH и переключиться на пользователя приложения: inv login gitea""" """Midnight Commander: слева локальная директория, справа удалённая домашняя"""
# sudo -i: login shell, -u: от имени пользователя ctx.run(f"mc . sh://{_remote_user()}@{_remote_host()}", pty=True)
# bash -i: интерактивный режим (job control), -l: login (читает профиль)
app_dir = f"{_application_dir()}/{app}"
subprocess.run(
f"""ssh {_remote_user()}@{_remote_host()} -t 'sudo -iu {app} bash -c "cd {app_dir} && exec bash -il"'""",
shell=True,
)
@task @task
def btop(ctx: Context) -> None: def btop(ctx: Context) -> None:
"""Запустить btop на удалённом сервере""" """Запустить btop на удалённом сервере"""
subprocess.run(f"ssh {_remote_user()}@{_remote_host()} -t btop", shell=True) ctx.run(f"ssh {_remote_user()}@{_remote_host()} -t btop", pty=True)
@task @task
@@ -130,6 +109,46 @@ def decrypt(ctx: Context, file: str) -> None:
ctx.run(f"uv run ansible-vault decrypt {file}") ctx.run(f"uv run ansible-vault decrypt {file}")
@task(aliases=["ee"])
def edit_encrypted(ctx: Context, path: str) -> None:
"""Редактировать vault-файл через ansible-vault edit (без расшифровки на диск)"""
ctx.run(f"uv run ansible-vault edit {path}", pty=True)
# --- Специфичные для этого репозитория задачи ---
def _application_dir() -> str:
"""Чтение application_dir: сначала из inventory (override), затем из vars/vars.yml."""
inv_value = _yq('.ungrouped.hosts.server.application_dir // ""')
if inv_value:
return inv_value
result = subprocess.run(
["yq", ".application_dir", VARS_FILE],
capture_output=True,
text=True,
check=True,
)
value = result.stdout.strip()
if not value or value == "null":
raise Exit(
f"application_dir не определён ни в inventory, ни в {VARS_FILE}", code=1
)
return value
@task(aliases=["login"])
def login_as_app(ctx: Context, app: str) -> None:
"""SSH и переключиться на пользователя приложения: inv login gitea"""
# sudo -i: login shell, -u: от имени пользователя
# bash -i: интерактивный режим (job control), -l: login (читает профиль)
app_dir = f"{_application_dir()}/{app}"
subprocess.run(
f"""ssh {_remote_user()}@{_remote_host()} -t 'sudo -iu {app} bash -c "cd {app_dir} && exec bash -il"'""",
shell=True,
)
@task @task
def authelia_cli(ctx: Context, args: str = "") -> None: def authelia_cli(ctx: Context, args: str = "") -> None:
"""Запустить authelia CLI в docker""" """Запустить authelia CLI в docker"""