diff --git a/README.md b/README.md index dc7489d..5176fbc 100644 --- a/README.md +++ b/README.md @@ -52,10 +52,15 @@ pnpm dlx shadcn@latest add @reui/auth-13 --yes - Secrets: `ACTIONS_PAT`, `GITEA_TOKEN` - Образ: `git.shts.su/denozord/auth-portal` +Локально / без reverse proxy: + ```bash docker compose up -d --build ``` +**Production за Traefik** (удалённый сервер, Compose или `docker run`, HTTPS через Cloudflare DNS challenge): +[`docs/deploy-traefik.md`](docs/deploy-traefik.md) · пример [`deploy/docker-compose.traefik.yml`](deploy/docker-compose.traefik.yml) + ## Структура ``` diff --git a/deploy/docker-compose.traefik.yml b/deploy/docker-compose.traefik.yml new file mode 100644 index 0000000..d1a61a3 --- /dev/null +++ b/deploy/docker-compose.traefik.yml @@ -0,0 +1,67 @@ +# Auth Portal behind an existing Traefik (production). +# Docs: docs/deploy-traefik.md +# +# Usage on server: +# cp deploy/docker-compose.traefik.yml /opt/auth-portal/docker-compose.yml +# # edit networks.proxy.name if your Traefik network is not "proxy" +# docker compose --env-file .env up -d + +services: + app: + image: git.shts.su/denozord/auth-portal:${AUTH_IMAGE_TAG:-latest} + pull_policy: always + container_name: auth-portal + restart: unless-stopped + # Do not publish ports — Traefik reaches the container on the shared network. + # ports: + # - "8080:8080" + env_file: + - .env + environment: + DATABASE_URL: sqlite:/data/app.db + STATIC_DIR: /app/static + NODE_ENV: production + JWT_SECRET: ${JWT_SECRET:?set JWT_SECRET in .env} + JWT_TTL_HOURS: ${JWT_TTL_HOURS:-1} + REFRESH_TTL_DAYS: ${REFRESH_TTL_DAYS:-14} + ISSUER: ${ISSUER:-https://auth.shnt.top} + ADMIN_EMAIL: ${ADMIN_EMAIL:-admin@shnt.top} + ADMIN_PASSWORD: ${ADMIN_PASSWORD:?set ADMIN_PASSWORD in .env} + ADMIN_NAME: ${ADMIN_NAME:-Admin} + RETURN_TO_ALLOWLIST: ${RETURN_TO_ALLOWLIST:-.shnt.top} + LOG_LEVEL: ${LOG_LEVEL:-info} + volumes: + - ./data:/data + networks: + - proxy + labels: + - traefik.enable=true + - traefik.docker.network=proxy + - traefik.http.routers.auth-portal.rule=Host(`${AUTH_DOMAIN:-auth.shnt.top}`) + - traefik.http.routers.auth-portal.entrypoints=websecure + - traefik.http.routers.auth-portal.tls=true + - traefik.http.routers.auth-portal.tls.certresolver=${TRAEFIK_CERTRESOLVER:-letsencrypt} + - traefik.http.services.auth-portal.loadbalancer.server.port=8080 + healthcheck: + test: + [ + "CMD", + "node", + "-e", + "fetch('http://127.0.0.1:8080/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))", + ] + interval: 30s + timeout: 5s + retries: 3 + start_period: 15s + logging: + driver: json-file + options: + max-size: "10m" + max-file: "3" + +networks: + proxy: + external: true + # Rename if Traefik uses another network (traefik, web, edge, …): + name: proxy diff --git a/docs/deploy-traefik.md b/docs/deploy-traefik.md new file mode 100644 index 0000000..a4bf4b3 --- /dev/null +++ b/docs/deploy-traefik.md @@ -0,0 +1,370 @@ +# Развёртывание Auth Portal за Traefik + +Production: один контейнер (API + статика SPA), SQLite в томе, HTTPS через Traefik + Let's Encrypt (**DNS-01 challenge через Cloudflare**). + +Образ: `git.shts.su/denozord/auth-portal` (CI: `.gitea/workflows/docker.yml`). +Публичный URL по умолчанию: `https://auth.shnt.top`. + +Документация: +- Traefik Docker / TLS: [Expose Docker](https://doc.traefik.io/traefik/expose/docker/basic/), [ACME](https://doc.traefik.io/traefik/https/acme/) +- Cloudflare как DNS provider для Lego/Traefik: env `CF_DNS_API_TOKEN` + +## Предпосылки + +1. Зона `shnt.top` (или ваш домен) в Cloudflare. +2. Docker на сервере; сеть для Traefik (часто `proxy`). +3. Доступ к registry: `docker login git.shts.su`. +4. Каталоги: + + ```bash + mkdir -p /opt/traefik /opt/auth-portal/data + ``` + +--- + +## HTTPS: DNS challenge Cloudflare + +HTTP-01 на порту 80 не нужен: Traefik создаёт TXT `_acme-challenge…` в Cloudflare и получает сертификат Let's Encrypt. Удобно, если порт 80 закрыт, домен за CF proxy, или нужен wildcard. + +### 1. API-токен Cloudflare + +[Cloudflare Dashboard → My Profile → API Tokens → Create Token](https://dash.cloudflare.com/profile/api-tokens) + +Шаблон **Edit zone DNS** или Custom: + +| Permission | Access | +|------------|--------| +| Zone → DNS | Edit | +| Zone → Zone | Read (желательно) | + +**Zone Resources:** Include → Specific zone → `shnt.top` (минимальный scope). + +Скопируйте токен один раз → это `CF_DNS_API_TOKEN`. +Не путать с Global API Key и не класть токен в репозиторий. + +### 2. DNS-запись для портала + +В Cloudflare → DNS → Records: + +| Type | Name | Content | Proxy | +|------|------|---------|-------| +| `A` (или `AAAA`) | `auth` | публичный IP сервера | **DNS only** (серое облако) | + +Для origin за Traefik на VPS обычно **DNS only**. Orange cloud (proxied) возможен, но тогда SSL/режимы CF настраиваются отдельно; ACME DNS-01 от этого не зависит. + +Проверка: + +```bash +dig +short auth.shnt.top A +``` + +### 3. Traefik с `dnschallenge.provider=cloudflare` + +Если Traefik уже настроен так же — переходите к [разделу Auth Portal](#вариант-a--docker-compose). +Если нет — пример `/opt/traefik/docker-compose.yml`: + +```yaml +services: + traefik: + image: traefik:v3.7 + container_name: traefik + restart: unless-stopped + security_opt: + - no-new-privileges:true + ports: + - "80:80" + - "443:443" + environment: + CF_DNS_API_TOKEN: ${CF_DNS_API_TOKEN:?set CF_DNS_API_TOKEN} + # опционально: CF_ZONE_API_TOKEN с Zone:Read, если DNS-токен без Zone:Read + command: + - --log.level=INFO + - --providers.docker=true + - --providers.docker.exposedbydefault=false + - --providers.docker.network=proxy + - --entrypoints.web.address=:80 + - --entrypoints.websecure.address=:443 + - --entrypoints.web.http.redirections.entrypoint.to=websecure + - --entrypoints.web.http.redirections.entrypoint.scheme=https + - --certificatesresolvers.letsencrypt.acme.email=${LETSENCRYPT_EMAIL:?set email} + - --certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json + - --certificatesresolvers.letsencrypt.acme.dnschallenge=true + - --certificatesresolvers.letsencrypt.acme.dnschallenge.provider=cloudflare + - --certificatesresolvers.letsencrypt.acme.dnschallenge.delaybeforecheck=15 + volumes: + - /var/run/docker.sock:/var/run/docker.sock:ro + - traefik_letsencrypt:/letsencrypt + networks: + - proxy + +volumes: + traefik_letsencrypt: + name: traefik_letsencrypt + +networks: + proxy: + name: proxy +``` + +Файл `/opt/traefik/.env`: + +```env +CF_DNS_API_TOKEN=<токен из шага 1> +LETSENCRYPT_EMAIL=admin@shnt.top +``` + +Запуск Traefik: + +```bash +cd /opt/traefik +docker network create proxy 2>/dev/null || true +docker compose up -d +docker compose logs -f --tail=50 +``` + +Имя resolver'а в команде — `letsencrypt`. Его же указывают приложения в label: + +`traefik.http.routers.…tls.certresolver=letsencrypt` + +**Важно:** `CF_DNS_API_TOKEN` задаётся в **окружении контейнера Traefik**, не auth-portal. Том `traefik_letsencrypt` хранит `acme.json` — не удаляйте (`docker compose down -v`) без бэкапа. + +Проверка сети: + +```bash +docker network ls +docker inspect traefik --format '{{json .NetworkSettings.Networks}}' +``` + +--- + +## Переменные окружения Auth Portal + +Файл `/opt/auth-portal/.env` (не коммитить): + +```env +# Обязательно: общий секрет с VPS Tracker / CFDM (AUTH_JWT_SECRET) +JWT_SECRET=<длинная-случайная-строка> + +ISSUER=https://auth.shnt.top +JWT_TTL_HOURS=1 +REFRESH_TTL_DAYS=14 + +# Bootstrap admin — только при пустой БД +ADMIN_EMAIL=admin@shnt.top +ADMIN_PASSWORD=<сильный-пароль> +ADMIN_NAME=Admin + +# Origins приложений (SSO return_to). Доменный суффикс .shnt.top покрывает поддомены. +RETURN_TO_ALLOWLIST=.shnt.top,https://vps.shnt.top,https://cfdm.shnt.top + +DATABASE_URL=sqlite:/data/app.db +STATIC_DIR=/app/static +LOG_LEVEL=info +NODE_ENV=production + +# Публичный хост (для compose-labels) + имя ACME resolver Traefik +AUTH_DOMAIN=auth.shnt.top +TRAEFIK_CERTRESOLVER=letsencrypt +``` + +| Переменная | Назначение | +|------------|------------| +| `JWT_SECRET` | HS256 для access JWT; тот же секрет в приложениях | +| `ISSUER` | Должен совпадать с `AUTH_ISSUER` в приложениях | +| `RETURN_TO_ALLOWLIST` | Иначе SSO `return_to` отклоняется | +| `ADMIN_*` | Первый админ при пустом `/data/app.db` | +| `TRAEFIK_CERTRESOLVER` | Имя resolver'а Traefik (`letsencrypt`) | + +В production cookie refresh ставится с флагом `Secure` — нужен HTTPS (Traefik). + +--- + +## Вариант A — Docker Compose + +Готовый файл в репозитории: [`deploy/docker-compose.traefik.yml`](../deploy/docker-compose.traefik.yml). + +На сервере: + +```bash +cd /opt/auth-portal +# скопируйте deploy/docker-compose.traefik.yml → docker-compose.yml +# или: +curl -fsSL -o docker-compose.yml \ + https://git.shts.su/denozord/auth-portal/raw/branch/main/deploy/docker-compose.traefik.yml + +# поправьте networks.proxy.name под вашу сеть Traefik +nano docker-compose.yml +nano .env + +docker compose pull +docker compose up -d +docker compose ps +docker compose logs -f --tail=100 +``` + +После старта Traefik запросит сертификат (DNS TXT в Cloudflare). Первая выдача может занять 30–90 с. + +Проверка: + +```bash +curl -fsS https://auth.shnt.top/health +echo | openssl s_client -connect auth.shnt.top:443 -servername auth.shnt.top 2>/dev/null | openssl x509 -noout -issuer -dates -subject +``` + +Обновление образа: + +```bash +cd /opt/auth-portal +docker compose pull +docker compose up -d +``` + +Остановка: + +```bash +docker compose down +# данные в ./data сохраняются (не используйте -v без бэкапа) +``` + +--- + +## Вариант B — Docker CLI + +Подставьте имя сети Traefik вместо `proxy`, если нужно. + +```bash +cd /opt/auth-portal +set -a && source .env && set +a + +docker pull git.shts.su/denozord/auth-portal:latest + +docker rm -f auth-portal 2>/dev/null || true + +DOMAIN="${AUTH_DOMAIN:-auth.shnt.top}" +NETWORK=proxy # имя сети Traefik +RESOLVER="${TRAEFIK_CERTRESOLVER:-letsencrypt}" + +docker run -d \ + --name auth-portal \ + --restart unless-stopped \ + --network "$NETWORK" \ + --env-file /opt/auth-portal/.env \ + -v /opt/auth-portal/data:/data \ + -l traefik.enable=true \ + -l "traefik.docker.network=${NETWORK}" \ + -l "traefik.http.routers.auth-portal.rule=Host(\`${DOMAIN}\`)" \ + -l traefik.http.routers.auth-portal.entrypoints=websecure \ + -l traefik.http.routers.auth-portal.tls=true \ + -l "traefik.http.routers.auth-portal.tls.certresolver=${RESOLVER}" \ + -l traefik.http.services.auth-portal.loadbalancer.server.port=8080 \ + git.shts.su/denozord/auth-portal:latest +``` + +Порты хоста (`-p 8080:8080`) **не публикуйте**, если трафик только через Traefik. + +Обновление: + +```bash +docker pull git.shts.su/denozord/auth-portal:latest +docker stop auth-portal +docker rm auth-portal +# повторите docker run … +``` + +Логи / health: + +```bash +docker logs -f --tail=100 auth-portal +docker logs -f --tail=100 traefik +docker inspect --format='{{.State.Health.Status}}' auth-portal +curl -fsS https://auth.shnt.top/health +``` + +--- + +## Labels Traefik (справка) + +| Label | Значение | +|-------|----------| +| `traefik.enable` | `true` | +| `traefik.docker.network` | имя общей сети с Traefik | +| `…routers.auth-portal.rule` | `Host(\`auth.shnt.top\`)` | +| `…entrypoints` | `websecure` | +| `…tls` / `…tls.certresolver` | `true` / `letsencrypt` (имя из ACME resolver) | +| `…loadbalancer.server.port` | `8080` (порт внутри контейнера) | + +Если resolver называется иначе (`le`, `cf`), замените `certresolver=…` и `TRAEFIK_CERTRESOLVER`. + +--- + +## Связка с приложениями + +После деплоя портала в VPS Tracker / CFDM: + +```env +AUTH_REQUIRED=true +AUTH_JWT_SECRET=<тот же JWT_SECRET> +AUTH_ISSUER=https://auth.shnt.top +AUTH_PORTAL_URL=https://auth.shnt.top +``` + +UI: + +```env +VITE_AUTH_ENABLED=true +VITE_AUTH_PORTAL_URL=https://auth.shnt.top +``` + +Интеграции: [integrate-vps-tracker.md](integrate-vps-tracker.md), [integrate-cfdm.md](integrate-cfdm.md). + +Logout SSO: приложения редиректят на `https://auth.shnt.top/logout` (не на `/?return_to=…`). + +--- + +## Бэкап и откат + +```bash +# бэкап SQLite +cp /opt/auth-portal/data/app.db /opt/auth-portal/data/app.db.bak-$(date +%F) + +# бэкап ACME (сертификаты Traefik) +docker run --rm -v traefik_letsencrypt:/data -v "$PWD:/backup" alpine \ + tar czf /backup/traefik-acme-$(date +%F).tgz -C /data . + +# откат на тег релиза +docker pull git.shts.su/denozord/auth-portal:vX.Y.Z +# в compose: image: …:vX.Y.Z → up -d +``` + +Не удаляйте том/каталог `data` и том `traefik_letsencrypt` без необходимости. + +--- + +## Troubleshooting + +| Симптом | Что проверить | +|---------|----------------| +| 404 / Gateway Timeout | Контейнер в той же сети, что Traefik; `traefik.docker.network`; label `loadbalancer.server.port=8080` | +| ACME / TLS не выдаётся | `CF_DNS_API_TOKEN` в env **Traefik**; права `Zone:DNS:Edit`; `dnschallenge.provider=cloudflare`; логи `docker logs traefik` на `error` / `acme` | +| `invalid credentials` / Cloudflare API | Токен не Global Key; зона в scope токена; нет лишних пробелов/кавычек в `.env` | +| TXT не появляется | Токен без Edit на зоне; неверная зона (другой аккаунт CF) | +| Сертификат есть, сайт не открывается | `A` на IP сервера; firewall 443; record не указывает на старый IP | +| Login OK, SSO в app падает | `JWT_SECRET` / `ISSUER` совпадают; app в JWT `apps` | +| `return_to` rejected | origin приложения в `RETURN_TO_ALLOWLIST` | +| Cookie не держится | HTTPS; `NODE_ENV=production` → `Secure` | +| «Выйти» возвращает в app | Нужен образ портала с `/logout` и клиенты с `redirectToPortalLogout` | +| Пустой UI / 404 статики | В образе `STATIC_DIR=/app/static` (зашито в Dockerfile) | + +Полезные логи ACME: + +```bash +docker logs traefik 2>&1 | grep -iE 'acme|certificate|cloudflare|error' +``` + +Локальный smoke без Traefik (порт наружу): + +```bash +docker run --rm -p 8080:8080 --env-file .env -v "$PWD/data:/data" \ + git.shts.su/denozord/auth-portal:latest +curl -fsS http://127.0.0.1:8080/health +```