Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
19f6c625f8 | ||
|
|
f063378e6b | ||
|
|
96d754ef26 | ||
|
|
d6e46d30b7 |
@@ -30,13 +30,7 @@ async function handoffOnUnauthorized(): Promise<void> {
|
||||
redirectToPortalLogin(`${window.location.origin}/auth/callback`)
|
||||
return
|
||||
}
|
||||
// Cooldown / recent handoff — stop SSO storm (wrong JWT secret / issuer).
|
||||
if (cfg.required || isAuthEnabled()) {
|
||||
window.location.assign(
|
||||
`${window.location.origin}/auth/callback?error=jwt_rejected`,
|
||||
)
|
||||
return
|
||||
}
|
||||
// Match CFDM: on cooldown do not open sso_loop / jwt_rejected — caller handles.
|
||||
if (!cfg.required && !isAuthEnabled()) {
|
||||
window.location.href = '/login'
|
||||
}
|
||||
|
||||
@@ -146,6 +146,17 @@ export function redirectToPortalLogin(returnTo?: string): boolean {
|
||||
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. */
|
||||
export function redirectToPortalLogout(): void {
|
||||
clearToken()
|
||||
@@ -196,6 +207,8 @@ export function getClaims(): AccessClaims | null {
|
||||
if (!claims) return null
|
||||
if (claims.exp && claims.exp * 1000 < Date.now()) {
|
||||
clearToken()
|
||||
// Allow a fresh portal handoff after local JWT expiry.
|
||||
resetPortalHandoff()
|
||||
return null
|
||||
}
|
||||
return claims
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
getClaims,
|
||||
getToken,
|
||||
redirectToPortalLogin,
|
||||
redirectToPortalLoginInteractive,
|
||||
} from '@/lib/auth'
|
||||
|
||||
export interface RouterContext {
|
||||
@@ -28,12 +29,7 @@ export const Route = createRootRouteWithContext<RouterContext>()({
|
||||
const ok = redirectToPortalLogin(
|
||||
`${window.location.origin}/auth/callback`,
|
||||
)
|
||||
if (!ok) {
|
||||
throw redirect({
|
||||
to: '/auth/callback',
|
||||
search: { error: 'sso_loop' },
|
||||
})
|
||||
}
|
||||
if (!ok) redirectToPortalLoginInteractive()
|
||||
await new Promise(() => {})
|
||||
return
|
||||
}
|
||||
@@ -41,12 +37,7 @@ export const Route = createRootRouteWithContext<RouterContext>()({
|
||||
const ok = redirectToPortalLogin(
|
||||
`${window.location.origin}/auth/callback`,
|
||||
)
|
||||
if (!ok) {
|
||||
throw redirect({
|
||||
to: '/auth/callback',
|
||||
search: { error: 'sso_loop' },
|
||||
})
|
||||
}
|
||||
if (!ok) redirectToPortalLoginInteractive()
|
||||
await new Promise(() => {})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
getToken,
|
||||
permissionForPath,
|
||||
redirectToPortalLogin,
|
||||
redirectToPortalLoginInteractive,
|
||||
} from '@/lib/auth'
|
||||
|
||||
export const Route = createFileRoute('/_auth')({
|
||||
@@ -21,12 +22,7 @@ export const Route = createFileRoute('/_auth')({
|
||||
const ok = redirectToPortalLogin(
|
||||
`${window.location.origin}/auth/callback`,
|
||||
)
|
||||
if (!ok) {
|
||||
throw redirect({
|
||||
to: '/auth/callback',
|
||||
search: { error: 'sso_loop' },
|
||||
})
|
||||
}
|
||||
if (!ok) redirectToPortalLoginInteractive()
|
||||
await new Promise(() => {})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ import type { ColumnDef } from '@tanstack/react-table'
|
||||
import type { Filter, FilterFieldConfig } from '@/components/reui/filters'
|
||||
import {
|
||||
CloudIcon,
|
||||
MapPinIcon,
|
||||
PlusIcon,
|
||||
RefreshCwIcon,
|
||||
ServerIcon,
|
||||
@@ -75,12 +74,16 @@ const formSchema = z.object({
|
||||
type FormValues = z.infer<typeof formSchema>
|
||||
|
||||
const ROLES: { value: NodeRole; label: string }[] = [
|
||||
{ value: 'hub', label: 'hub' },
|
||||
{ value: 'gw', label: 'gw' },
|
||||
{ value: 'edge', label: 'edge' },
|
||||
{ value: 'ix', label: 'ix' },
|
||||
{ value: 'hub', label: 'Hub' },
|
||||
{ value: 'gw', label: 'Gateway' },
|
||||
{ value: 'edge', label: 'Edge' },
|
||||
{ value: 'ix', label: 'IX' },
|
||||
]
|
||||
|
||||
const ROLE_LABEL: Record<NodeRole, string> = Object.fromEntries(
|
||||
ROLES.map((r) => [r.value, r.label]),
|
||||
) as Record<NodeRole, string>
|
||||
|
||||
function NodesPage() {
|
||||
const qc = useQueryClient()
|
||||
const { data: nodes = [], isLoading, isError, error, refetch } = useQuery(
|
||||
@@ -302,7 +305,10 @@ function NodesPage() {
|
||||
key: 'locationCode',
|
||||
label: 'Локация',
|
||||
type: 'select',
|
||||
options: locations.map((l) => ({ value: l.code, label: l.code })),
|
||||
options: locations.map((l) => ({
|
||||
value: l.code,
|
||||
label: l.name,
|
||||
})),
|
||||
},
|
||||
{
|
||||
key: 'role',
|
||||
@@ -340,16 +346,35 @@ function NodesPage() {
|
||||
{
|
||||
accessorKey: 'locationCode',
|
||||
header: 'Локация',
|
||||
cell: ({ row }) => (
|
||||
<span className="flex items-center gap-1.5 text-sm">
|
||||
<MapPinIcon className="size-3.5" />
|
||||
{row.original.locationCode ?? '—'}
|
||||
</span>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const n = row.original
|
||||
const loc = locations.find((l) => l.id === n.locationId)
|
||||
const city = n.locationName ?? loc?.name ?? n.locationCode ?? '—'
|
||||
const countryCode = loc?.country ?? undefined
|
||||
return (
|
||||
<span className="flex items-center gap-2 text-sm">
|
||||
<CountryFlag code={countryCode ?? undefined} country={countryNameFromCode(countryCode)} />
|
||||
<span className="font-medium">{city}</span>
|
||||
{n.locationCode ? (
|
||||
<span className="text-muted-foreground font-mono text-xs">
|
||||
{n.locationCode}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'role',
|
||||
header: 'Роль',
|
||||
cell: ({ row }) => {
|
||||
const role = row.original.role as NodeRole
|
||||
return (
|
||||
<span className="text-sm">
|
||||
{ROLE_LABEL[role] ?? row.original.role}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'ip',
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
import { createFileRoute, redirect } from '@tanstack/react-router'
|
||||
import {
|
||||
authPortalUrl,
|
||||
clearPortalHandoffFlag,
|
||||
clearToken,
|
||||
ensureAuthConfig,
|
||||
firstAllowedPath,
|
||||
getClaims,
|
||||
getToken,
|
||||
markPortalHandoff,
|
||||
parseHashToken,
|
||||
redirectToPortalLogin,
|
||||
redirectToPortalLoginInteractive,
|
||||
setToken,
|
||||
} from '@/lib/auth'
|
||||
|
||||
@@ -32,24 +31,26 @@ export const Route = createFileRoute('/auth/callback')({
|
||||
beforeLoad: async ({ search }) => {
|
||||
await ensureAuthConfig()
|
||||
|
||||
// Dead-end errors → interactive portal login (no return_to storm).
|
||||
if (search.error === 'sso_loop' || search.error === 'jwt_rejected') {
|
||||
redirectToPortalLoginInteractive()
|
||||
await new Promise(() => {})
|
||||
return
|
||||
}
|
||||
|
||||
const { accessToken } = parseHashToken(window.location.hash)
|
||||
if (accessToken) {
|
||||
setToken(accessToken)
|
||||
// Start cooldown so a following API 401 cannot re-enter portal SSO storm.
|
||||
markPortalHandoff()
|
||||
// Match CFDM/VPS: clear handoff flag only — do not start a new cooldown
|
||||
// after a successful SSO (that caused false sso_loop on expiry re-login).
|
||||
clearPortalHandoffFlag()
|
||||
|
||||
const claims = getClaims()
|
||||
if (!claims) {
|
||||
clearToken()
|
||||
throw redirect({
|
||||
to: '/auth/callback',
|
||||
search: { error: 'jwt_rejected' },
|
||||
})
|
||||
redirectToPortalLoginInteractive()
|
||||
await new Promise(() => {})
|
||||
return
|
||||
}
|
||||
if (!claims.apps.includes('cdn')) {
|
||||
throw redirect({ to: '/access-denied' })
|
||||
@@ -58,10 +59,9 @@ export const Route = createFileRoute('/auth/callback')({
|
||||
const ok = await verifyTokenAccepted(accessToken)
|
||||
if (!ok) {
|
||||
clearToken()
|
||||
throw redirect({
|
||||
to: '/auth/callback',
|
||||
search: { error: 'jwt_rejected' },
|
||||
})
|
||||
redirectToPortalLoginInteractive()
|
||||
await new Promise(() => {})
|
||||
return
|
||||
}
|
||||
|
||||
const next = firstAllowedPath()
|
||||
@@ -79,7 +79,8 @@ export const Route = createFileRoute('/auth/callback')({
|
||||
}
|
||||
const ok = redirectToPortalLogin(`${window.location.origin}/auth/callback`)
|
||||
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(() => {})
|
||||
},
|
||||
@@ -87,22 +88,10 @@ export const Route = createFileRoute('/auth/callback')({
|
||||
})
|
||||
|
||||
function AuthCallbackPage() {
|
||||
const { error } = Route.useSearch()
|
||||
if (error === 'sso_loop' || error === 'jwt_rejected') {
|
||||
return (
|
||||
<div className="flex min-h-svh flex-col items-center justify-center gap-3 p-6 text-center">
|
||||
<h1 className="text-lg font-semibold">Сессия не принята</h1>
|
||||
<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
|
||||
// beforeLoad always navigates away; placeholder while assigning location.
|
||||
return (
|
||||
<div className="text-muted-foreground flex min-h-svh items-center justify-center p-6 text-sm">
|
||||
Перенаправление на Auth Portal…
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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=
|
||||
[email protected]
|
||||
# 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
|
||||
@@ -9,6 +9,7 @@ Self-hosted панель управления DNS флота (ноды A/AAAA +
|
||||
- Не замена CFDM: там домены/сервисы/LB; здесь флот CHR и failover CNAME
|
||||
- SSO: [`integrate-auth-portal.md`](./integrate-auth-portal.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)
|
||||
|
||||
См. [README](../README.md) и [AGENTS.md](../AGENTS.md).
|
||||
|
||||
@@ -12,6 +12,8 @@
|
||||
| SSO | [`integrate-auth-portal.md`](./integrate-auth-portal.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/).
|
||||
|
||||
```
|
||||
|
||||
Reference in New Issue
Block a user