Files
ayurishchevandClaude Opus 5.5 8384c2c311 Задачи 034-035: публикация через Caddy, последний вход пользователя
034 Публикация стенда как https://rxipam.rxmsk.ru через общий Caddy хоста (Caddyfile вне репозитория):
    UI и /api/v1 доступны из интернета, /docs, /redoc, /openapi.json — только из частных сетей
    (RFC 1918/4193, loopback). Caddy проксирует на опубликованный порт приложения — общая Docker-сеть
    отклонена из-за коллизии имён app/db с Nextcloud. TRUSTED_PROXIES=172.16.0.0/12: реальный IP
    клиента из X-Forwarded-For. Интеграционный тест журнала больше не проверяет подделку XFF с хоста
    (хост за Caddy доверенный), логику покрывает unit-тест.
035 Дата и IP последнего входа в разделе «Пользователи»: users.last_login_at / last_login_ip
    (миграция 0014 с заполнением из журнала), запись при успешном входе, UserOut, колонка в UI.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 15:45:30 +03:00

9.6 KiB
Raw Permalink Blame History

Публикация IPAM через Caddy: rxipam.rxmsk.ru (изменение 034)

Отклонение при выполнении: схема с общей сетью app_internal (раздел 1) отменена из-за коллизии имён app/db — см. SUMMARY.md.

Context

IPAM сейчас доступен только из LAN по HTTP (192.168.5.9:8088). Его нужно опубликовать в интернет через общий Caddy (/opt/lvraid/apps/caddy) под rxipam.rxmsk.ru с TLS.

Решения пользователя:

  • из интернета доступны UI и /api/v1;
  • Swagger и OpenAPI (/docs, /redoc, /openapi.json) доступны только из частных сетей (RFC 1918 / RFC 4193, loopback);
  • CNAME rxipam → mkt.rxmsk.ru в Cloudflare пользователь создаёт сам.

Отличить вызов API из UI от прямого вызова на уровне прокси нельзя: браузер SPA сам ходит в /api/v1. Поэтому API в интернете защищают JWT, роли и лимиты входа приложения.

Факты разведки:

  • Caddy работает в контейнере caddy, сети app_external (172.18/16) и app_internal (172.19/16). Сертификаты — ACME DNS-01 через Cloudflare, работает и без открытого 80.
  • Caddy видит настоящие публичные IP клиентов (в логах 93.123.x, 13.210.x …), поэтому remote_ip для разграничения надёжен. LAN-клиенты, заходящие по публичному имени, приходят с 192.168.5.253: это шлюз MikroTik (hairpin NAT), он в частном диапазоне.
  • Приложение (ipam_control_006-app-1) — в своей сети ipam_control_006_default, Caddy его сейчас не видит.
  • X-Forwarded-For приложение учитывает только от TRUSTED_PROXIES (app/request_context.py::resolve_client_ip, справа налево). Сейчас список пуст. Без него после публикации все интернет-клиенты получат один IP — IP Caddy, тогда:
    • лимит 20 неудач на IP блокирует вход всем сразу;
    • в журнале исчезнут реальные адреса.

Изменения

1. Сеть: приложение в app_internal (репозиторий ipam_control)

  • Новый docker-compose.caddy.yml: сервису app добавить сеть app_internal (external) с алиасом ipam-app, сеть default сохранить. Отдельный файл нужен, чтобы стенд на машине без Caddy (без сети app_internal) продолжал подниматься базовым docker-compose.yml.
  • .env стенда:
    • COMPOSE_FILE=docker-compose.yml:docker-compose.caddy.yml — команды docker compose -p ipam_control_006 … из README и тестов не меняются;
    • TRUSTED_PROXIES=172.19.0.0/16 — подсеть app_internal. IP контейнера Caddy при пересоздании может смениться, поэтому /32 не подходит. Остальные контейнеры этой сети — свои сервисы, риск подделки XFF от них приемлем.
  • .env.example: закомментированные COMPOSE_FILE и TRUSTED_PROXIES с пояснением.
  • APP_BIND оставить 0.0.0.0: прямой LAN-доступ :8088 сохраняется, порт на шлюзе наружу не проброшен.

2. Caddyfile (/opt/lvraid/apps/caddy/Caddyfile)

  • Перед правкой — копия Caddyfile.bak-2026-09-27.
  • Править на месте: файл смонтирован в контейнер одиночным bind-mount :ro. Замена inode, как делают редакторы с atomic-rename, оставит контейнер со старым файлом — после правки сверить docker exec caddy cat /etc/caddy/Caddyfile.
  • Новый блок в стиле соседних (vault, artstore):
# IPAM Manager
rxipam.rxmsk.ru {
    log {
        output file /var/log/caddy/rxipam.log {
            roll_size 100mb
            roll_keep 5
            roll_keep_for 720h
        }
        format json
        level INFO
    }

    # Swagger/OpenAPI — только из частных сетей (RFC 1918/4193, loopback); из интернета — как будто их нет
    @docs_external {
        path /docs /docs/* /redoc /redoc/* /openapi.json
        not remote_ip private_ranges
    }
    respond @docs_external 404

    reverse_proxy ipam-app:8000

    header {
        Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
        -Server
    }
}
  • remote_ip (TCP-пир), а не client_ip: у Caddy нет trusted_proxies, поэтому заголовки клиента на решение не влияют.
  • XFF к приложению Caddy формирует сам и не доверяет входящему. Приложение берёт из него реальный IP (п. 1).
  • level INFO — access-лог для разбора инцидентов (у соседей ERROR). CSP и прочие заголовки ставит приложение.
  • Применение:
    • docker exec caddy caddy validate --config /etc/caddy/Caddyfile;
    • docker exec caddy caddy reload --config /etc/caddy/Caddyfile, без рестарта: остальные сайты не прерываются.

3. Документация (правила проекта)

  • docs/changes/034-caddy-publication/PLAN.md — этот план; по завершении — SUMMARY.md.
  • README.md, раздел «Публикация и эксплуатация»:
    • пример Caddy заменить фактической схемой: docker-compose.caddy.yml, COMPOSE_FILE, TRUSTED_PROXIES, ограничение Swagger;
    • добавить строку 034 в историю изменений.
  • План 031 частично перекрыт. Часть 2 (свой Caddy-профиль) заменяется общим Caddy. Часть 1 (DOCS_ENABLED=false по умолчанию) остаётся как вторая линия защиты — отдельной задачей, в этот объём не входит.

Замечания (без действий в этом объёме)

  • LAN-пользователи через публичное имя идут через hairpin и для приложения выглядят одним IP 192.168.5.253. Они делят лимит «20 неудач на IP» и неразличимы в журнале. Лечится split DNS на MikroTik (rxipam.rxmsk.ru → 192.168.5.9 внутри LAN): трафик пойдёт на Caddy напрямую с реальным LAN-IP. Рекомендация пользователю.
  • Если шлюз когда-либо начнёт делать SNAT входящего интернет-трафика (masquerade на WAN→LAN), интернет-клиенты станут «частными» и получат Swagger. Это проверяется пунктом «из интернета» ниже; при смене конфигурации шлюза — перепроверять.

Порядок выполнения

  1. Пользователь: CNAME rxipam → mkt.rxmsk.ru (proxy в Cloudflare выключен, как у соседних: иначе remote_ip = IP Cloudflare).
  2. ipam_control: docker-compose.caddy.yml, .env (COMPOSE_FILE, TRUSTED_PROXIES), docker compose -p ipam_control_006 up -d; проверить docker exec caddy wget -qO- http://ipam-app:8000/healthz.
  3. Caddy: бэкап, правка блока, validate, reload.
  4. Проверки (ниже), затем документация и коммит в ipam_control (Caddyfile вне репозитория).

Проверка

  • DNS: getent hosts rxipam.rxmsk.ru → 62.176.10.113. Сертификат выпущен: docker logs caddy без ошибок ACME, curl -sI https://rxipam.rxmsk.ru/ → 200, HSTS присутствует.
  • Из LAN (частный IP): / 200, /api/v1/auth/me без токена 401, /docs и /openapi.json 200.
  • Из интернета (мобильный интернет пользователя или внешний хост):
    • / 200 и вход в UI работает;
    • /docs, /docs/, /redoc, /openapi.json, /openapi.json?x=1, //docs, /%64ocs → 404;
    • в rxipam.log у этих запросов remote_ip публичный, status 404.
  • Реальный IP в приложении: войти из интернета → в журнале (событие входа) client_ip — публичный адрес, не 172.19.x. Вход из LAN через FQDN даёт 192.168.5.253.
  • Регрессия: venv/bin/python -m pytest -q (15 passed) — тесты идут на 127.0.0.1:8088, мимо Caddy.
  • Остальные сайты Caddy после reload отвечают: curl -sI https://vault.rxmsk.ru, https://artstore.rxmsk.ru → не 5xx.

Откат

  • Caddy: вернуть Caddyfile.bak-2026-09-27 (на месте) и caddy reload.
  • ipam_control: убрать COMPOSE_FILE/TRUSTED_PROXIES из .env, docker compose -p ipam_control_006 up -d.