3 Commits
Author SHA1 Message Date
DenozordecandCursor 19f6c625f8 chore(deploy): прокинуть AUTH_* для MM в cdn-mm стек
quality / commitlint (push) Skipped
CD / update-wiki (push) Successful in 4s
quality / changes (push) Successful in 6s
quality / web (push) Skipped
quality / api (push) Skipped
quality / docker-check (push) Skipped
CD / quality (push) Successful in 7s
CD / publish (push) Successful in 37s
Backend MikrotikManager получает JWT SSO из общего .env стека.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-04 20:38:28 +07:00
DenozordecandCursor f063378e6b chore(deploy): добавить общий compose CDN и MikrotikManager с Traefik
quality / commitlint (push) Skipped
CD / update-wiki (push) Successful in 7s
quality / changes (push) Successful in 5s
quality / web (push) Skipped
quality / api (push) Skipped
quality / docker-check (push) Skipped
CD / quality (push) Successful in 6s
CD / publish (push) Successful in 31s
Один стек для cdn.shnt.top и mm.shnt.top за общим Traefik.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-04 19:07:49 +07:00
DenozordecandCursor 96d754ef26 fix(auth): при истечении сессии вести на portal вместо sso_loop
quality / commitlint (push) Skipped
CD / update-wiki (push) Successful in 5s
quality / changes (push) Successful in 5s
quality / api (push) Skipped
quality / docker-check (push) Skipped
quality / web (push) Successful in 1m1s
CD / quality (push) Successful in 1m9s
CD / publish (push) Successful in 1m36s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-04 18:18:12 +07:00
9 changed files with 336 additions and 56 deletions
+1 -7
View File
@@ -30,13 +30,7 @@ async function handoffOnUnauthorized(): Promise<void> {
redirectToPortalLogin(`${window.location.origin}/auth/callback`) redirectToPortalLogin(`${window.location.origin}/auth/callback`)
return return
} }
// Cooldown / recent handoff — stop SSO storm (wrong JWT secret / issuer). // Match CFDM: on cooldown do not open sso_loop / jwt_rejected — caller handles.
if (cfg.required || isAuthEnabled()) {
window.location.assign(
`${window.location.origin}/auth/callback?error=jwt_rejected`,
)
return
}
if (!cfg.required && !isAuthEnabled()) { if (!cfg.required && !isAuthEnabled()) {
window.location.href = '/login' window.location.href = '/login'
} }
+13
View File
@@ -146,6 +146,17 @@ export function redirectToPortalLogin(returnTo?: string): boolean {
return true return true
} }
/**
* Interactive portal login without return_to — breaks SSO storms when cooldown
* blocks silent handoff (expired portal session / rejected JWT). Same pattern as
* VPS Tracker / CFDM for invalid hash tokens.
*/
export function redirectToPortalLoginInteractive(): void {
clearToken()
resetPortalHandoff()
window.location.assign(authPortalUrl())
}
/** End portal SSO session (refresh cookie + portal token). Do not pass return_to. */ /** End portal SSO session (refresh cookie + portal token). Do not pass return_to. */
export function redirectToPortalLogout(): void { export function redirectToPortalLogout(): void {
clearToken() clearToken()
@@ -196,6 +207,8 @@ export function getClaims(): AccessClaims | null {
if (!claims) return null if (!claims) return null
if (claims.exp && claims.exp * 1000 < Date.now()) { if (claims.exp && claims.exp * 1000 < Date.now()) {
clearToken() clearToken()
// Allow a fresh portal handoff after local JWT expiry.
resetPortalHandoff()
return null return null
} }
return claims return claims
+3 -12
View File
@@ -5,6 +5,7 @@ import {
getClaims, getClaims,
getToken, getToken,
redirectToPortalLogin, redirectToPortalLogin,
redirectToPortalLoginInteractive,
} from '@/lib/auth' } from '@/lib/auth'
export interface RouterContext { export interface RouterContext {
@@ -28,12 +29,7 @@ export const Route = createRootRouteWithContext<RouterContext>()({
const ok = redirectToPortalLogin( const ok = redirectToPortalLogin(
`${window.location.origin}/auth/callback`, `${window.location.origin}/auth/callback`,
) )
if (!ok) { if (!ok) redirectToPortalLoginInteractive()
throw redirect({
to: '/auth/callback',
search: { error: 'sso_loop' },
})
}
await new Promise(() => {}) await new Promise(() => {})
return return
} }
@@ -41,12 +37,7 @@ export const Route = createRootRouteWithContext<RouterContext>()({
const ok = redirectToPortalLogin( const ok = redirectToPortalLogin(
`${window.location.origin}/auth/callback`, `${window.location.origin}/auth/callback`,
) )
if (!ok) { if (!ok) redirectToPortalLoginInteractive()
throw redirect({
to: '/auth/callback',
search: { error: 'sso_loop' },
})
}
await new Promise(() => {}) await new Promise(() => {})
return return
} }
+2 -6
View File
@@ -8,6 +8,7 @@ import {
getToken, getToken,
permissionForPath, permissionForPath,
redirectToPortalLogin, redirectToPortalLogin,
redirectToPortalLoginInteractive,
} from '@/lib/auth' } from '@/lib/auth'
export const Route = createFileRoute('/_auth')({ export const Route = createFileRoute('/_auth')({
@@ -21,12 +22,7 @@ export const Route = createFileRoute('/_auth')({
const ok = redirectToPortalLogin( const ok = redirectToPortalLogin(
`${window.location.origin}/auth/callback`, `${window.location.origin}/auth/callback`,
) )
if (!ok) { if (!ok) redirectToPortalLoginInteractive()
throw redirect({
to: '/auth/callback',
search: { error: 'sso_loop' },
})
}
await new Promise(() => {}) await new Promise(() => {})
return return
} }
+20 -31
View File
@@ -1,15 +1,14 @@
import { createFileRoute, redirect } from '@tanstack/react-router' import { createFileRoute, redirect } from '@tanstack/react-router'
import { import {
authPortalUrl,
clearPortalHandoffFlag, clearPortalHandoffFlag,
clearToken, clearToken,
ensureAuthConfig, ensureAuthConfig,
firstAllowedPath, firstAllowedPath,
getClaims, getClaims,
getToken, getToken,
markPortalHandoff,
parseHashToken, parseHashToken,
redirectToPortalLogin, redirectToPortalLogin,
redirectToPortalLoginInteractive,
setToken, setToken,
} from '@/lib/auth' } from '@/lib/auth'
@@ -32,24 +31,26 @@ export const Route = createFileRoute('/auth/callback')({
beforeLoad: async ({ search }) => { beforeLoad: async ({ search }) => {
await ensureAuthConfig() await ensureAuthConfig()
// Dead-end errors → interactive portal login (no return_to storm).
if (search.error === 'sso_loop' || search.error === 'jwt_rejected') { if (search.error === 'sso_loop' || search.error === 'jwt_rejected') {
redirectToPortalLoginInteractive()
await new Promise(() => {})
return return
} }
const { accessToken } = parseHashToken(window.location.hash) const { accessToken } = parseHashToken(window.location.hash)
if (accessToken) { if (accessToken) {
setToken(accessToken) setToken(accessToken)
// Start cooldown so a following API 401 cannot re-enter portal SSO storm. // Match CFDM/VPS: clear handoff flag only — do not start a new cooldown
markPortalHandoff() // after a successful SSO (that caused false sso_loop on expiry re-login).
clearPortalHandoffFlag() clearPortalHandoffFlag()
const claims = getClaims() const claims = getClaims()
if (!claims) { if (!claims) {
clearToken() clearToken()
throw redirect({ redirectToPortalLoginInteractive()
to: '/auth/callback', await new Promise(() => {})
search: { error: 'jwt_rejected' }, return
})
} }
if (!claims.apps.includes('cdn')) { if (!claims.apps.includes('cdn')) {
throw redirect({ to: '/access-denied' }) throw redirect({ to: '/access-denied' })
@@ -58,10 +59,9 @@ export const Route = createFileRoute('/auth/callback')({
const ok = await verifyTokenAccepted(accessToken) const ok = await verifyTokenAccepted(accessToken)
if (!ok) { if (!ok) {
clearToken() clearToken()
throw redirect({ redirectToPortalLoginInteractive()
to: '/auth/callback', await new Promise(() => {})
search: { error: 'jwt_rejected' }, return
})
} }
const next = firstAllowedPath() const next = firstAllowedPath()
@@ -79,7 +79,8 @@ export const Route = createFileRoute('/auth/callback')({
} }
const ok = redirectToPortalLogin(`${window.location.origin}/auth/callback`) const ok = redirectToPortalLogin(`${window.location.origin}/auth/callback`)
if (!ok) { if (!ok) {
throw redirect({ to: '/auth/callback', search: { error: 'sso_loop' } }) // Cooldown: fall back to interactive portal login instead of sso_loop page.
redirectToPortalLoginInteractive()
} }
await new Promise(() => {}) await new Promise(() => {})
}, },
@@ -87,22 +88,10 @@ export const Route = createFileRoute('/auth/callback')({
}) })
function AuthCallbackPage() { function AuthCallbackPage() {
const { error } = Route.useSearch() // beforeLoad always navigates away; placeholder while assigning location.
if (error === 'sso_loop' || error === 'jwt_rejected') { return (
return ( <div className="text-muted-foreground flex min-h-svh items-center justify-center p-6 text-sm">
<div className="flex min-h-svh flex-col items-center justify-center gap-3 p-6 text-center"> Перенаправление на Auth Portal
<h1 className="text-lg font-semibold">Сессия не принята</h1> </div>
<p className="text-muted-foreground max-w-md text-sm"> )
{error === 'jwt_rejected'
? 'API отклонил JWT (обычно разный AUTH_JWT_SECRET / AUTH_ISSUER с portal). Проверьте .env контейнера CDN Manager.'
: 'Повторный вход через portal остановлен (защита от цикла редиректов). Обычно это несовпадение JWT_SECRET / ISSUER или просроченный токен.'}{' '}
Войдите заново на portal, затем откройте CDN Manager.
</p>
<a className="text-primary text-sm underline" href={authPortalUrl()}>
Открыть Auth Portal
</a>
</div>
)
}
return null
} }
+242
View File
@@ -0,0 +1,242 @@
# CDN Manager + MikrotikManager + one Traefik (production).
#
# Hosts:
# https://cdn.shnt.top → cdnmanager:8080
# https://mm.shnt.top → mmapp-frontend:3000 → backend:8000 (internal rewrite)
#
# On server:
# mkdir -p /opt/cdn-mm/{data/cdn,data/mm,state,updater}
# cp deploy/docker-compose.cdn-mm.yml /opt/cdn-mm/docker-compose.yml
# cp deploy/env.cdn-mm.example /opt/cdn-mm/.env # fill secrets
# # targets.json:
# # cp deploy/updater/targets.json.example /opt/cdn-mm/updater/targets.json
# # (в CDNManager-репо скачайте тот же файл из MikrotikManager)
# docker login git.shx.one
# cd /opt/cdn-mm && docker compose pull && docker compose up -d
#
# DNS (Cloudflare DNS only, grey cloud):
# A/AAAA cdn.shnt.top → VPS
# A/AAAA mm.shnt.top → VPS
#
# Do not run a second Traefik (standalone CDNManager or MikrotikManager compose)
# on the same host ports while this stack is up.
services:
traefik:
image: traefik:${TRAEFIK_IMAGE_TAG:-v3.7}
container_name: cdn-mm-traefik
restart: unless-stopped
security_opt:
- no-new-privileges:true
ports:
- "${TRAEFIK_HTTP_PORT:-80}:80"
- "${TRAEFIK_HTTPS_PORT:-443}:443"
environment:
CF_DNS_API_TOKEN: ${CF_DNS_API_TOKEN:?set CF_DNS_API_TOKEN in .env}
# Optional if DNS token lacks Zone:Read:
# CF_ZONE_API_TOKEN: ${CF_ZONE_API_TOKEN:-}
command:
- --log.level=${TRAEFIK_LOG_LEVEL:-INFO}
- --api.dashboard=false
- --providers.docker=true
- --providers.docker.exposedbydefault=false
- --providers.docker.network=edge
- --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 LETSENCRYPT_EMAIL in .env}
- --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:
- edge
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
# --- CDN Manager -----------------------------------------------------------
cdnmanager:
# cdnmanager и cdn-manager — один образ (алиас для drop-in).
image: git.shx.one/denozord/cdnmanager:${CDN_IMAGE_TAG:-latest}
pull_policy: always
container_name: cdnmanager
restart: unless-stopped
depends_on:
- traefik
env_file:
- .env
environment:
DATABASE_URL: sqlite:/data/app.db
STATIC_DIR: /app/static
SERVER_PORT: "8080"
NODE_ENV: production
LOG_LEVEL: ${LOG_LEVEL:-info}
CLOUDFLARE_API_TOKEN: ${CLOUDFLARE_API_TOKEN:?set CLOUDFLARE_API_TOKEN in .env}
JWT_SECRET: ${JWT_SECRET:-}
AUTH_REQUIRED: ${AUTH_REQUIRED:-true}
AUTH_JWT_SECRET: ${AUTH_JWT_SECRET:?set AUTH_JWT_SECRET in .env}
AUTH_ISSUER: ${AUTH_ISSUER:-https://auth.shnt.top}
AUTH_PORTAL_URL: ${AUTH_PORTAL_URL:-https://auth.shnt.top}
AUTH_AUDIT_INGEST_SECRET: ${AUTH_AUDIT_INGEST_SECRET:-}
ADMIN_USERNAME: ${ADMIN_USERNAME:-admin}
ADMIN_PASSWORD_HASH: ${ADMIN_PASSWORD_HASH:-}
volumes:
- ./data/cdn:/data
networks:
- edge
labels:
- traefik.enable=true
- traefik.docker.network=edge
- traefik.http.routers.cdnmanager.rule=Host(`${CDN_DOMAIN:-cdn.shnt.top}`)
- traefik.http.routers.cdnmanager.entrypoints=websecure
- traefik.http.routers.cdnmanager.tls=true
- traefik.http.routers.cdnmanager.tls.certresolver=letsencrypt
- traefik.http.services.cdnmanager.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"
# --- MikrotikManager -------------------------------------------------------
backend:
image: git.shx.one/denozord/mikrotikmanager-backend:${MM_BACKEND_IMAGE_TAG:-latest}
pull_policy: always
container_name: mmapp-backend
restart: unless-stopped
depends_on:
- traefik
environment:
NODE_ENV: production
PORT: "8000"
DATABASE_PATH: /app/data/mikrotik.db
CORS_ORIGIN: ${CORS_ORIGIN:-https://mm.shnt.top}
AUTH_REQUIRED: ${AUTH_REQUIRED:-true}
AUTH_JWT_SECRET: ${AUTH_JWT_SECRET:?set AUTH_JWT_SECRET in .env}
AUTH_ISSUER: ${AUTH_ISSUER:-https://auth.shnt.top}
AUTH_PORTAL_URL: ${AUTH_PORTAL_URL:-https://auth.shnt.top}
volumes:
- ./data/mm:/app/data
networks:
mmapp:
aliases:
- backend
labels:
mmapp.updater.managed: "true"
mmapp.updater.target: backend
mmapp.updater.image: git.shx.one/denozord/mikrotikmanager-backend:${MM_BACKEND_IMAGE_TAG:-latest}
healthcheck:
test:
[
"CMD",
"node",
"-e",
"fetch('http://127.0.0.1:8000/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"
frontend:
image: git.shx.one/denozord/mikrotikmanager-frontend:${MM_FRONTEND_IMAGE_TAG:-latest}
pull_policy: always
container_name: mmapp-frontend
restart: unless-stopped
depends_on:
- backend
environment:
BACKEND_INTERNAL_URL: http://backend:8000
networks:
mmapp:
aliases:
- frontend
edge: {}
labels:
- mmapp.updater.managed=true
- mmapp.updater.target=frontend
- mmapp.updater.image=git.shx.one/denozord/mikrotikmanager-frontend:${MM_FRONTEND_IMAGE_TAG:-latest}
- traefik.enable=true
- traefik.docker.network=edge
- traefik.http.routers.mmapp.rule=Host(`${MM_DOMAIN:-mm.shnt.top}`)
- traefik.http.routers.mmapp.entrypoints=websecure
- traefik.http.routers.mmapp.tls=true
- traefik.http.routers.mmapp.tls.certresolver=letsencrypt
- traefik.http.services.mmapp.loadbalancer.server.port=3000
healthcheck:
test:
[
"CMD",
"node",
"-e",
"fetch('http://127.0.0.1:3000/dashboard').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))",
]
interval: 30s
timeout: 5s
retries: 3
start_period: 25s
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
updater:
image: git.shx.one/denozord/mikrotikmanager-updater:${MM_UPDATER_IMAGE_TAG:-latest}
pull_policy: always
container_name: mmapp-updater
restart: unless-stopped
depends_on:
- frontend
environment:
REGISTRY: git.shx.one
REGISTRY_USERNAME: ${REGISTRY_USERNAME:-}
REGISTRY_PASSWORD: ${REGISTRY_PASSWORD:-}
POLL_INTERVAL_SECONDS: ${POLL_INTERVAL_SECONDS:-300}
HEALTH_TIMEOUT_SECONDS: ${HEALTH_TIMEOUT_SECONDS:-120}
STOP_TIMEOUT_SECONDS: ${STOP_TIMEOUT_SECONDS:-30}
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- ./state:/state
- ./updater/targets.json:/etc/updater/targets.json:ro
networks:
- mmapp
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
volumes:
traefik_letsencrypt:
name: cdn_mm_traefik_letsencrypt
networks:
edge:
name: edge
mmapp:
name: mmapp
+52
View File
@@ -0,0 +1,52 @@
# Production .env for deploy/docker-compose.cdn-mm.yml
# (CDN Manager + MikrotikManager + one Traefik).
# Copy to /opt/cdn-mm/.env and fill secrets. Do not commit.
# --- Traefik / Let's Encrypt (Cloudflare DNS-01) ---
# Token for ACME only (Zone DNS Edit). Separate from CLOUDFLARE_API_TOKEN below.
CF_DNS_API_TOKEN=
LETSENCRYPT_EMAIL=admin@shnt.top
# TRAEFIK_IMAGE_TAG=v3.7
# TRAEFIK_HTTP_PORT=80
# TRAEFIK_HTTPS_PORT=443
# TRAEFIK_LOG_LEVEL=INFO
# --- Public hosts ---
CDN_DOMAIN=cdn.shnt.top
MM_DOMAIN=mm.shnt.top
# Must match MM UI origin (https:// + MM_DOMAIN).
CORS_ORIGIN=https://mm.shnt.top
# --- Images ---
CDN_IMAGE_TAG=latest
# drop-in alias (same manifest): git.shx.one/denozord/cdn-manager
MM_BACKEND_IMAGE_TAG=latest
MM_FRONTEND_IMAGE_TAG=latest
MM_UPDATER_IMAGE_TAG=latest
# --- CDN Manager ---
CLOUDFLARE_API_TOKEN=
LOG_LEVEL=info
NODE_ENV=production
# Portal SSO — used by CDN Manager and MikrotikManager backend
AUTH_REQUIRED=true
# Same HS256 secret as auth-portal JWT_SECRET (required)
AUTH_JWT_SECRET=
# Optional alias — CDN Manager also reads JWT_SECRET
JWT_SECRET=
AUTH_ISSUER=https://auth.shnt.top
AUTH_PORTAL_URL=https://auth.shnt.top
# Shared with auth-portal AUDIT_INGEST_SECRET (optional, CDN Manager)
AUTH_AUDIT_INGEST_SECRET=
# Legacy local admin (CDN) — only when AUTH_REQUIRED=false
ADMIN_USERNAME=admin
ADMIN_PASSWORD_HASH=
# --- MikrotikManager updater (optional; private registry pull) ---
REGISTRY_USERNAME=
REGISTRY_PASSWORD=
# POLL_INTERVAL_SECONDS=300
# HEALTH_TIMEOUT_SECONDS=120
# STOP_TIMEOUT_SECONDS=30
+1
View File
@@ -9,6 +9,7 @@ Self-hosted панель управления DNS флота (ноды A/AAAA +
- Не замена CFDM: там домены/сервисы/LB; здесь флот CHR и failover CNAME - Не замена CFDM: там домены/сервисы/LB; здесь флот CHR и failover CNAME
- SSO: [`integrate-auth-portal.md`](./integrate-auth-portal.md) - SSO: [`integrate-auth-portal.md`](./integrate-auth-portal.md)
- Docker + Traefik (prod): [`deploy-traefik.md`](./deploy-traefik.md) - Docker + Traefik (prod): [`deploy-traefik.md`](./deploy-traefik.md)
- Docker CDN + MikrotikManager (один Traefik): [`../deploy/docker-compose.cdn-mm.yml`](../deploy/docker-compose.cdn-mm.yml)
- Docker без Traefik: [`deploy-docker.md`](./deploy-docker.md) - Docker без Traefik: [`deploy-docker.md`](./deploy-docker.md)
См. [README](../README.md) и [AGENTS.md](../AGENTS.md). См. [README](../README.md) и [AGENTS.md](../AGENTS.md).
+2
View File
@@ -12,6 +12,8 @@
| SSO | [`integrate-auth-portal.md`](./integrate-auth-portal.md) | | SSO | [`integrate-auth-portal.md`](./integrate-auth-portal.md) |
| Релизы | [`releasing.md`](./releasing.md) | | Релизы | [`releasing.md`](./releasing.md) |
**CDN + MikrotikManager на одном Traefik:** [`deploy/docker-compose.cdn-mm.yml`](../deploy/docker-compose.cdn-mm.yml) + [`deploy/env.cdn-mm.example`](../deploy/env.cdn-mm.example) — `cdn.shnt.top` и `mm.shnt.top`, каталог `/opt/cdn-mm`.
Документация Traefik: [Expose Docker](https://doc.traefik.io/traefik/expose/docker/basic/), [ACME DNS challenge](https://doc.traefik.io/traefik/https/acme/). Документация Traefik: [Expose Docker](https://doc.traefik.io/traefik/expose/docker/basic/), [ACME DNS challenge](https://doc.traefik.io/traefik/https/acme/).
``` ```