Update CDN Manager: Add integration for new settings route, enhance permissions for nodes and aliases, and improve Docker configuration documentation. Update UI components for better navigation and user experience.
quality / commitlint (push) Skipped
CD / update-wiki (push) Successful in 6s
quality / changes (push) Successful in 5s
quality / docker-check (push) Skipped
quality / web (push) Successful in 56s
quality / api (push) Successful in 41s
CD / quality (push) Successful in 1m47s
CD / publish (push) Successful in 33s
quality / commitlint (push) Skipped
CD / update-wiki (push) Successful in 6s
quality / changes (push) Successful in 5s
quality / docker-check (push) Skipped
quality / web (push) Successful in 56s
quality / api (push) Successful in 41s
CD / quality (push) Successful in 1m47s
CD / publish (push) Successful in 33s
This commit is contained in:
@@ -42,6 +42,10 @@ pnpm --filter web build
|
||||
|
||||
Env: `CLOUDFLARE_API_TOKEN` (Zone DNS Edit + Zone Read). Локально `AUTH_REQUIRED=false`, логин `admin` / `admin`.
|
||||
|
||||
SSO (prod): [`docs/integrate-auth-portal.md`](docs/integrate-auth-portal.md) — `AUTH_REQUIRED=true`, общий `AUTH_JWT_SECRET` с portal, app id `cdn`.
|
||||
|
||||
Docker CLI на сервере: [`docs/deploy-traefik.md`](docs/deploy-traefik.md) (Traefik + HTTPS) · [`docs/deploy-docker.md`](docs/deploy-docker.md) (plain).
|
||||
|
||||
## UI (ReUI PRO + DNA CFDM)
|
||||
|
||||
Surface: **frame**. Contract: [`docs/ui-design-contract.md`](docs/ui-design-contract.md).
|
||||
@@ -70,3 +74,5 @@ GET /api/v1/cloudflare/zones
|
||||
## CI / Docker
|
||||
|
||||
Gitea: `.gitea/workflows/{ci,cd,quality}.yaml`. Образы: `deploy/docker` (`cdnmanager` + alias `cdn-manager`).
|
||||
Prod: [`docs/deploy-traefik.md`](docs/deploy-traefik.md) — `deploy/docker-compose.traefik.yml` + `deploy/env.traefik.example`.
|
||||
Без Traefik: [`docs/deploy-docker.md`](docs/deploy-docker.md) · Compose: `docker-compose.yml` / `deploy/compose/docker-compose.example.yaml`.
|
||||
|
||||
@@ -18,6 +18,24 @@ pnpm --filter web dev # :5176
|
||||
CLOUDFLARE_API_TOKEN= # Zone DNS Edit + Zone Read
|
||||
```
|
||||
|
||||
## Auth Portal
|
||||
|
||||
SSO: [`docs/integrate-auth-portal.md`](docs/integrate-auth-portal.md).
|
||||
|
||||
## Docker (сервер)
|
||||
|
||||
**Рекомендуется (HTTPS + Traefik):** [`docs/deploy-traefik.md`](docs/deploy-traefik.md)
|
||||
|
||||
```bash
|
||||
mkdir -p /opt/cdnmanager/data
|
||||
cp deploy/docker-compose.traefik.yml /opt/cdnmanager/docker-compose.yml
|
||||
cp deploy/env.traefik.example /opt/cdnmanager/.env # fill secrets
|
||||
docker login git.shx.one
|
||||
cd /opt/cdnmanager && docker compose pull && docker compose up -d
|
||||
```
|
||||
|
||||
Без Traefik: [`docs/deploy-docker.md`](docs/deploy-docker.md).
|
||||
|
||||
## Структура
|
||||
|
||||
```
|
||||
|
||||
@@ -44,6 +44,55 @@ const RULES: Rule[] = [
|
||||
match: (p) => p.startsWith("/api/v1/settings"),
|
||||
permission: "cdn:settings:admin",
|
||||
},
|
||||
{
|
||||
methods: ["GET"],
|
||||
match: (p) => p.startsWith("/api/v1/dashboard"),
|
||||
permission: "cdn:dashboard:read",
|
||||
},
|
||||
{
|
||||
methods: ["GET"],
|
||||
match: (p) => p === "/api/v1/topology",
|
||||
permission: "cdn:topology:read",
|
||||
},
|
||||
{
|
||||
methods: ["GET"],
|
||||
match: (p) =>
|
||||
p.startsWith("/api/v1/nodes") || p.startsWith("/api/v1/locations"),
|
||||
permission: "cdn:nodes:read",
|
||||
},
|
||||
{
|
||||
methods: ["POST", "PUT", "PATCH", "DELETE"],
|
||||
match: (p) => p.startsWith("/api/v1/nodes"),
|
||||
permission: "cdn:nodes:write",
|
||||
},
|
||||
{
|
||||
methods: ["GET"],
|
||||
match: (p) => p.startsWith("/api/v1/aliases"),
|
||||
permission: "cdn:aliases:read",
|
||||
},
|
||||
{
|
||||
methods: ["POST", "PUT", "PATCH", "DELETE"],
|
||||
match: (p) => p.startsWith("/api/v1/aliases"),
|
||||
permission: "cdn:aliases:write",
|
||||
},
|
||||
{
|
||||
methods: ["POST"],
|
||||
match: (p) =>
|
||||
/\/api\/v1\/zones\/[^/]+\/(sync|apply)/.test(p) ||
|
||||
p.includes("/ignored-orphans"),
|
||||
permission: "cdn:sync:write",
|
||||
},
|
||||
{
|
||||
methods: ["GET"],
|
||||
match: (p) =>
|
||||
p.startsWith("/api/v1/zones") || p.startsWith("/api/v1/cloudflare"),
|
||||
permission: "cdn:zones:read",
|
||||
},
|
||||
{
|
||||
methods: ["POST", "PUT", "PATCH", "DELETE"],
|
||||
match: (p) => p.startsWith("/api/v1/zones"),
|
||||
permission: "cdn:zones:write",
|
||||
},
|
||||
];
|
||||
|
||||
/** Resolve required permission for method+path, or null if public / unknown. */
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
} from 'lucide-react'
|
||||
import { AppSwitcher } from '@/components/app-switcher'
|
||||
import { NavUser } from '@/components/nav-user'
|
||||
import { can, permissionForPath } from '@/lib/auth'
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
@@ -52,6 +53,12 @@ function isNavActive(
|
||||
|
||||
export function AppSidebar() {
|
||||
const pathname = useRouterState({ select: (s) => s.location.pathname })
|
||||
const visibleNav = mainNav.filter((item) => {
|
||||
const path =
|
||||
'matchPrefix' in item && item.matchPrefix ? item.matchPrefix : item.to
|
||||
const perm = permissionForPath(path)
|
||||
return !perm || can(perm)
|
||||
})
|
||||
|
||||
return (
|
||||
<Sidebar collapsible="icon">
|
||||
@@ -63,7 +70,7 @@ export function AppSidebar() {
|
||||
<SidebarGroupLabel>CDN Manager</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{mainNav.map((item) => (
|
||||
{visibleNav.map((item) => (
|
||||
<SidebarMenuItem key={item.to}>
|
||||
<SidebarMenuButton
|
||||
tooltip={item.label}
|
||||
|
||||
@@ -38,7 +38,7 @@ export const DEFAULT_APP_SWITCHER_CONFIG: AppSwitcherConfig = {
|
||||
{
|
||||
id: 'cdn',
|
||||
name: 'CDN Manager',
|
||||
subtitle: 'Управление CDN',
|
||||
subtitle: 'Флот DNS: ноды и алиасы',
|
||||
url: 'https://cdn.shnt.top',
|
||||
icon: 'cloud',
|
||||
},
|
||||
|
||||
@@ -234,12 +234,23 @@ export function permissionForPath(pathname: string): string | null {
|
||||
if (pathname === '/' || pathname.startsWith('/dashboard')) {
|
||||
return 'cdn:dashboard:read'
|
||||
}
|
||||
if (pathname.startsWith('/nodes')) return 'cdn:nodes:read'
|
||||
if (pathname.startsWith('/aliases')) return 'cdn:aliases:read'
|
||||
if (pathname.startsWith('/topology')) return 'cdn:topology:read'
|
||||
if (pathname.startsWith('/zones')) return 'cdn:zones:read'
|
||||
if (pathname.startsWith('/settings')) return 'cdn:settings:admin'
|
||||
return 'cdn:dashboard:read'
|
||||
}
|
||||
|
||||
export function firstAllowedPath(): string {
|
||||
const candidates = ['/', '/settings/appearance']
|
||||
const candidates = [
|
||||
'/',
|
||||
'/nodes',
|
||||
'/aliases',
|
||||
'/topology',
|
||||
'/zones',
|
||||
'/settings/appearance',
|
||||
]
|
||||
for (const path of candidates) {
|
||||
const perm = permissionForPath(path)
|
||||
if (!perm || can(perm)) return path
|
||||
|
||||
@@ -14,6 +14,7 @@ const routeTitles: Record<string, string> = {
|
||||
const SETTINGS_SECTIONS: Record<string, string> = {
|
||||
'/settings/appearance': 'Внешний вид',
|
||||
'/settings/cloudflare': 'Cloudflare',
|
||||
'/settings/integrations': 'Интеграции',
|
||||
}
|
||||
|
||||
/** Drop consecutive repeats so «Настройки» does not stack after tab switches. */
|
||||
|
||||
@@ -19,6 +19,7 @@ import { Route as AuthNodesRouteImport } from './routes/_auth/nodes'
|
||||
import { Route as AuthAliasesRouteImport } from './routes/_auth/aliases'
|
||||
import { Route as AuthSettingsRouteRouteImport } from './routes/_auth/settings/route'
|
||||
import { Route as AuthSettingsIndexRouteImport } from './routes/_auth/settings/index'
|
||||
import { Route as AuthSettingsIntegrationsRouteImport } from './routes/_auth/settings/integrations'
|
||||
import { Route as AuthSettingsCloudflareRouteImport } from './routes/_auth/settings/cloudflare'
|
||||
import { Route as AuthSettingsAppearanceRouteImport } from './routes/_auth/settings/appearance'
|
||||
|
||||
@@ -71,6 +72,12 @@ const AuthSettingsIndexRoute = AuthSettingsIndexRouteImport.update({
|
||||
path: '/',
|
||||
getParentRoute: () => AuthSettingsRouteRoute,
|
||||
} as any)
|
||||
const AuthSettingsIntegrationsRoute =
|
||||
AuthSettingsIntegrationsRouteImport.update({
|
||||
id: '/integrations',
|
||||
path: '/integrations',
|
||||
getParentRoute: () => AuthSettingsRouteRoute,
|
||||
} as any)
|
||||
const AuthSettingsCloudflareRoute = AuthSettingsCloudflareRouteImport.update({
|
||||
id: '/cloudflare',
|
||||
path: '/cloudflare',
|
||||
@@ -93,6 +100,7 @@ export interface FileRoutesByFullPath {
|
||||
'/auth/callback': typeof AuthCallbackRoute
|
||||
'/settings/appearance': typeof AuthSettingsAppearanceRoute
|
||||
'/settings/cloudflare': typeof AuthSettingsCloudflareRoute
|
||||
'/settings/integrations': typeof AuthSettingsIntegrationsRoute
|
||||
'/settings/': typeof AuthSettingsIndexRoute
|
||||
}
|
||||
export interface FileRoutesByTo {
|
||||
@@ -105,6 +113,7 @@ export interface FileRoutesByTo {
|
||||
'/': typeof AuthIndexRoute
|
||||
'/settings/appearance': typeof AuthSettingsAppearanceRoute
|
||||
'/settings/cloudflare': typeof AuthSettingsCloudflareRoute
|
||||
'/settings/integrations': typeof AuthSettingsIntegrationsRoute
|
||||
'/settings': typeof AuthSettingsIndexRoute
|
||||
}
|
||||
export interface FileRoutesById {
|
||||
@@ -120,6 +129,7 @@ export interface FileRoutesById {
|
||||
'/_auth/': typeof AuthIndexRoute
|
||||
'/_auth/settings/appearance': typeof AuthSettingsAppearanceRoute
|
||||
'/_auth/settings/cloudflare': typeof AuthSettingsCloudflareRoute
|
||||
'/_auth/settings/integrations': typeof AuthSettingsIntegrationsRoute
|
||||
'/_auth/settings/': typeof AuthSettingsIndexRoute
|
||||
}
|
||||
export interface FileRouteTypes {
|
||||
@@ -135,6 +145,7 @@ export interface FileRouteTypes {
|
||||
| '/auth/callback'
|
||||
| '/settings/appearance'
|
||||
| '/settings/cloudflare'
|
||||
| '/settings/integrations'
|
||||
| '/settings/'
|
||||
fileRoutesByTo: FileRoutesByTo
|
||||
to:
|
||||
@@ -147,6 +158,7 @@ export interface FileRouteTypes {
|
||||
| '/'
|
||||
| '/settings/appearance'
|
||||
| '/settings/cloudflare'
|
||||
| '/settings/integrations'
|
||||
| '/settings'
|
||||
id:
|
||||
| '__root__'
|
||||
@@ -161,6 +173,7 @@ export interface FileRouteTypes {
|
||||
| '/_auth/'
|
||||
| '/_auth/settings/appearance'
|
||||
| '/_auth/settings/cloudflare'
|
||||
| '/_auth/settings/integrations'
|
||||
| '/_auth/settings/'
|
||||
fileRoutesById: FileRoutesById
|
||||
}
|
||||
@@ -242,6 +255,13 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof AuthSettingsIndexRouteImport
|
||||
parentRoute: typeof AuthSettingsRouteRoute
|
||||
}
|
||||
'/_auth/settings/integrations': {
|
||||
id: '/_auth/settings/integrations'
|
||||
path: '/integrations'
|
||||
fullPath: '/settings/integrations'
|
||||
preLoaderRoute: typeof AuthSettingsIntegrationsRouteImport
|
||||
parentRoute: typeof AuthSettingsRouteRoute
|
||||
}
|
||||
'/_auth/settings/cloudflare': {
|
||||
id: '/_auth/settings/cloudflare'
|
||||
path: '/cloudflare'
|
||||
@@ -262,12 +282,14 @@ declare module '@tanstack/react-router' {
|
||||
interface AuthSettingsRouteRouteChildren {
|
||||
AuthSettingsAppearanceRoute: typeof AuthSettingsAppearanceRoute
|
||||
AuthSettingsCloudflareRoute: typeof AuthSettingsCloudflareRoute
|
||||
AuthSettingsIntegrationsRoute: typeof AuthSettingsIntegrationsRoute
|
||||
AuthSettingsIndexRoute: typeof AuthSettingsIndexRoute
|
||||
}
|
||||
|
||||
const AuthSettingsRouteRouteChildren: AuthSettingsRouteRouteChildren = {
|
||||
AuthSettingsAppearanceRoute: AuthSettingsAppearanceRoute,
|
||||
AuthSettingsCloudflareRoute: AuthSettingsCloudflareRoute,
|
||||
AuthSettingsIntegrationsRoute: AuthSettingsIntegrationsRoute,
|
||||
AuthSettingsIndexRoute: AuthSettingsIndexRoute,
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { ExternalLinkIcon, LayoutGridIcon } from 'lucide-react'
|
||||
import { authPortalUrl, isAuthEnabled } from '@/lib/auth'
|
||||
import { useAppSwitcherConfig } from '@/hooks/use-app-switcher'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import {
|
||||
Frame,
|
||||
FrameDescription,
|
||||
FrameHeader,
|
||||
FramePanel,
|
||||
FrameTitle,
|
||||
} from '@/components/reui/frame'
|
||||
import { Button } from '@cdnmanager/ui/components/button'
|
||||
import {
|
||||
Item,
|
||||
ItemActions,
|
||||
ItemContent,
|
||||
ItemDescription,
|
||||
ItemGroup,
|
||||
ItemMedia,
|
||||
ItemTitle,
|
||||
} from '@cdnmanager/ui/components/item'
|
||||
|
||||
export const Route = createFileRoute('/_auth/settings/integrations')({
|
||||
component: SettingsIntegrationsPage,
|
||||
})
|
||||
|
||||
function SettingsIntegrationsPage() {
|
||||
const { config: appSwitcher } = useAppSwitcherConfig()
|
||||
const portalAppsUrl = `${authPortalUrl().replace(/\/$/, '')}/admin/apps`
|
||||
const portalConnected = isAuthEnabled()
|
||||
|
||||
return (
|
||||
<div className="flex w-full flex-col gap-5">
|
||||
<section className="flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<h2 className="text-sm font-semibold">Auth Portal</h2>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
SSO, App Switcher и права <code className="text-xs">cdn:*</code>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Frame>
|
||||
<FrameHeader className="sr-only">
|
||||
<FrameTitle>App Switcher</FrameTitle>
|
||||
<FrameDescription>
|
||||
Ссылки приложений настраиваются на auth-portal
|
||||
</FrameDescription>
|
||||
</FrameHeader>
|
||||
<FramePanel className="p-0!">
|
||||
<ItemGroup className="gap-0">
|
||||
<Item className="items-center gap-3 border-0 px-3.5 py-3 sm:px-4">
|
||||
<ItemMedia variant="icon">
|
||||
<LayoutGridIcon aria-hidden="true" />
|
||||
</ItemMedia>
|
||||
<ItemContent className="min-w-0 gap-0">
|
||||
<ItemTitle className="w-full min-w-0 gap-2">
|
||||
<span className="truncate">App Switcher</span>
|
||||
<Badge variant={portalConnected ? 'success' : 'secondary'}>
|
||||
{portalConnected ? 'SSO' : 'Локальный режим'}
|
||||
</Badge>
|
||||
</ItemTitle>
|
||||
<ItemDescription className="line-clamp-2">
|
||||
{appSwitcher.apps.length} приложений · меню «
|
||||
{appSwitcher.menuLabel}» · редактор только на портале
|
||||
</ItemDescription>
|
||||
</ItemContent>
|
||||
<ItemActions className="ml-auto shrink-0">
|
||||
{portalConnected ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
render={<a href={portalAppsUrl} />}
|
||||
>
|
||||
Открыть на портале
|
||||
<ExternalLinkIcon
|
||||
data-icon="inline-end"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</Button>
|
||||
) : null}
|
||||
</ItemActions>
|
||||
</Item>
|
||||
</ItemGroup>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { CloudIcon, PaletteIcon } from 'lucide-react'
|
||||
import { CloudIcon, LayoutGridIcon, PaletteIcon } from 'lucide-react'
|
||||
import { SettingsShell, type SettingsTabConfig } from '@/components/reui-kit'
|
||||
|
||||
export const Route = createFileRoute('/_auth/settings')({
|
||||
@@ -19,12 +19,18 @@ const SETTINGS_TABS: SettingsTabConfig[] = [
|
||||
label: 'Cloudflare',
|
||||
icon: <CloudIcon className="size-4" aria-hidden="true" />,
|
||||
},
|
||||
{
|
||||
id: 'integrations',
|
||||
to: '/settings/integrations',
|
||||
label: 'Интеграции',
|
||||
icon: <LayoutGridIcon className="size-4" aria-hidden="true" />,
|
||||
},
|
||||
]
|
||||
|
||||
function SettingsLayout() {
|
||||
return (
|
||||
<SettingsShell
|
||||
description="Внешний вид и параметры Cloudflare DNS"
|
||||
description="Внешний вид, Cloudflare DNS и auth-portal"
|
||||
tabs={SETTINGS_TABS}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -6,11 +6,13 @@ services:
|
||||
- "8080:8080"
|
||||
environment:
|
||||
DATABASE_URL: sqlite:/data/app.db
|
||||
CLOUDFLARE_API_TOKEN: ${CLOUDFLARE_API_TOKEN:-}
|
||||
JWT_SECRET: ${JWT_SECRET:-change-me-in-production}
|
||||
AUTH_REQUIRED: ${AUTH_REQUIRED:-false}
|
||||
AUTH_JWT_SECRET: ${AUTH_JWT_SECRET:-${JWT_SECRET:-change-me-in-production}}
|
||||
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:-}
|
||||
LOG_LEVEL: info
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
# CDN Manager + Traefik in one Compose stack (production).
|
||||
# Docs: docs/deploy-traefik.md
|
||||
#
|
||||
# On server:
|
||||
# mkdir -p /opt/cdnmanager/data
|
||||
# cp deploy/docker-compose.traefik.yml /opt/cdnmanager/docker-compose.yml
|
||||
# cp deploy/env.traefik.example /opt/cdnmanager/.env # fill secrets
|
||||
# docker login git.shx.one
|
||||
# cd /opt/cdnmanager && docker compose pull && docker compose up -d
|
||||
|
||||
services:
|
||||
traefik:
|
||||
image: traefik:${TRAEFIK_IMAGE_TAG:-v3.7}
|
||||
container_name: cdnmanager-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=cdnmanager
|
||||
- --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:
|
||||
- cdnmanager
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
|
||||
app:
|
||||
# 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
|
||||
# No host ports — only Traefik publishes 80/443.
|
||||
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:/data
|
||||
networks:
|
||||
- cdnmanager
|
||||
labels:
|
||||
- traefik.enable=true
|
||||
- traefik.docker.network=cdnmanager
|
||||
- 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"
|
||||
|
||||
volumes:
|
||||
traefik_letsencrypt:
|
||||
name: cdnmanager_traefik_letsencrypt
|
||||
|
||||
networks:
|
||||
cdnmanager:
|
||||
name: cdnmanager
|
||||
@@ -0,0 +1,36 @@
|
||||
# Production .env for deploy/docker-compose.traefik.yml (CDN Manager + Traefik).
|
||||
# Copy to /opt/cdnmanager/.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]
|
||||
CDN_DOMAIN=cdn.shnt.top
|
||||
# TRAEFIK_IMAGE_TAG=v3.7
|
||||
# TRAEFIK_HTTP_PORT=80
|
||||
# TRAEFIK_HTTPS_PORT=443
|
||||
# TRAEFIK_LOG_LEVEL=INFO
|
||||
|
||||
# --- Image ---
|
||||
CDN_IMAGE_TAG=latest
|
||||
# drop-in alias (same manifest): git.shx.one/denozord/cdn-manager
|
||||
|
||||
# --- CDN Manager (API + SPA) ---
|
||||
CLOUDFLARE_API_TOKEN=
|
||||
LOG_LEVEL=info
|
||||
NODE_ENV=production
|
||||
|
||||
# Portal SSO (prod default AUTH_REQUIRED=true)
|
||||
AUTH_REQUIRED=true
|
||||
# Same HS256 secret as auth-portal JWT_SECRET (required)
|
||||
AUTH_JWT_SECRET=
|
||||
# Optional alias — app 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)
|
||||
AUTH_AUDIT_INGEST_SECRET=
|
||||
|
||||
# Legacy local admin — only when AUTH_REQUIRED=false
|
||||
ADMIN_USERNAME=admin
|
||||
ADMIN_PASSWORD_HASH=
|
||||
@@ -5,15 +5,18 @@ services:
|
||||
- "8080:8080"
|
||||
environment:
|
||||
DATABASE_URL: sqlite:/data/app.db
|
||||
CLOUDFLARE_API_TOKEN: ${CLOUDFLARE_API_TOKEN:-}
|
||||
JWT_SECRET: ${JWT_SECRET:-change-me-in-production}
|
||||
AUTH_REQUIRED: ${AUTH_REQUIRED:-false}
|
||||
AUTH_JWT_SECRET: ${AUTH_JWT_SECRET:-${JWT_SECRET:-change-me-in-production}}
|
||||
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:-}
|
||||
LOG_LEVEL: info
|
||||
STATIC_DIR: /app/static
|
||||
SERVER_PORT: "8080"
|
||||
volumes:
|
||||
- ./data:/data
|
||||
restart: unless-stopped
|
||||
|
||||
@@ -7,5 +7,21 @@ Self-hosted панель управления DNS флота (ноды A/AAAA +
|
||||
- Модель: zones → nodes / aliases → sync desired↔Cloudflare (DNS-only)
|
||||
- UI DNA дашборда: как CFDM (`OpsDashboard`, `AttentionQueue`, hybrid KPI)
|
||||
- Не замена CFDM: там домены/сервисы/LB; здесь флот CHR и failover CNAME
|
||||
- SSO: [`integrate-auth-portal.md`](./integrate-auth-portal.md)
|
||||
- Docker + Traefik (prod): [`deploy-traefik.md`](./deploy-traefik.md)
|
||||
- Docker без Traefik: [`deploy-docker.md`](./deploy-docker.md)
|
||||
|
||||
См. [README](../README.md) и [AGENTS.md](../AGENTS.md).
|
||||
|
||||
## Docker + Traefik (кратко)
|
||||
|
||||
```bash
|
||||
mkdir -p /opt/cdnmanager/data
|
||||
cp deploy/docker-compose.traefik.yml /opt/cdnmanager/docker-compose.yml
|
||||
cp deploy/env.traefik.example /opt/cdnmanager/.env
|
||||
# заполнить CF_DNS_API_TOKEN, CLOUDFLARE_API_TOKEN, AUTH_JWT_SECRET, …
|
||||
docker login git.shx.one
|
||||
cd /opt/cdnmanager && docker compose pull && docker compose up -d
|
||||
```
|
||||
|
||||
Compose: [`deploy/docker-compose.traefik.yml`](../deploy/docker-compose.traefik.yml). Env: [`deploy/env.traefik.example`](../deploy/env.traefik.example).
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
# Запуск CDN Manager на сервере (Docker CLI)
|
||||
|
||||
Без Traefik (plain `docker run` / простой Compose). **Production HTTPS:** [`deploy-traefik.md`](./deploy-traefik.md).
|
||||
|
||||
## Traefik (кратко)
|
||||
|
||||
```bash
|
||||
mkdir -p /opt/cdnmanager/data
|
||||
cp deploy/docker-compose.traefik.yml /opt/cdnmanager/docker-compose.yml
|
||||
cp deploy/env.traefik.example /opt/cdnmanager/.env
|
||||
docker login git.shx.one
|
||||
cd /opt/cdnmanager && docker compose pull && docker compose up -d
|
||||
```
|
||||
|
||||
Один alpine-контейнер: API + SPA + SQLite. Образы `cdnmanager` и `cdn-manager` — один манифест (drop-in alias).
|
||||
|
||||
Registry по умолчанию: `git.shx.one/denozord/…` (подставьте свой owner при необходимости).
|
||||
|
||||
## Требования
|
||||
|
||||
- Docker Engine 24+ (или Docker Desktop)
|
||||
- Cloudflare API token: **Zone DNS Edit** + **Zone Read**
|
||||
- Для SSO: auth-portal с тем же `JWT_SECRET` / `AUTH_ISSUER` и origin CDNManager в `RETURN_TO_ALLOWLIST`
|
||||
|
||||
## 1. Pull образа
|
||||
|
||||
```bash
|
||||
docker pull git.shx.one/denozord/cdnmanager:latest
|
||||
|
||||
# эквивалент (тот же образ):
|
||||
docker pull git.shx.one/denozord/cdn-manager:latest
|
||||
```
|
||||
|
||||
Тег релиза: `vX.Y.Z` или `X.Y.Z` (см. Gitea Releases).
|
||||
|
||||
## 2. Запуск одной командой (`docker run`)
|
||||
|
||||
### Dev / без portal (локальный admin)
|
||||
|
||||
```bash
|
||||
docker volume create cdnmanager-data
|
||||
|
||||
docker run -d \
|
||||
--name cdnmanager \
|
||||
--restart unless-stopped \
|
||||
-p 8080:8080 \
|
||||
-v cdnmanager-data:/data \
|
||||
-e DATABASE_URL=sqlite:/data/app.db \
|
||||
-e STATIC_DIR=/app/static \
|
||||
-e SERVER_PORT=8080 \
|
||||
-e AUTH_REQUIRED=false \
|
||||
-e JWT_SECRET=change-me-in-production \
|
||||
-e ADMIN_USERNAME=admin \
|
||||
-e CLOUDFLARE_API_TOKEN=cf_xxxxxxxx \
|
||||
git.shx.one/denozord/cdnmanager:latest
|
||||
```
|
||||
|
||||
Логин UI: `admin` / `admin` (если `ADMIN_PASSWORD_HASH` пуст — dev default).
|
||||
|
||||
### Production + auth-portal SSO
|
||||
|
||||
```bash
|
||||
docker volume create cdnmanager-data
|
||||
|
||||
docker run -d \
|
||||
--name cdnmanager \
|
||||
--restart unless-stopped \
|
||||
-p 8080:8080 \
|
||||
-v cdnmanager-data:/data \
|
||||
-e DATABASE_URL=sqlite:/data/app.db \
|
||||
-e STATIC_DIR=/app/static \
|
||||
-e SERVER_PORT=8080 \
|
||||
-e AUTH_REQUIRED=true \
|
||||
-e JWT_SECRET='<тот же секрет что у auth-portal>' \
|
||||
-e AUTH_JWT_SECRET='<тот же секрет>' \
|
||||
-e AUTH_ISSUER=https://auth.shnt.top \
|
||||
-e AUTH_PORTAL_URL=https://auth.shnt.top \
|
||||
-e AUTH_AUDIT_INGEST_SECRET='<AUDIT_INGEST_SECRET портала>' \
|
||||
-e CLOUDFLARE_API_TOKEN=cf_xxxxxxxx \
|
||||
-e LOG_LEVEL=info \
|
||||
git.shx.one/denozord/cdnmanager:latest
|
||||
```
|
||||
|
||||
Проверка:
|
||||
|
||||
```bash
|
||||
curl -sS http://127.0.0.1:8080/health
|
||||
# {"status":"ok",…}
|
||||
```
|
||||
|
||||
Откройте `http://<host>:8080` → редирект на auth-portal → после логина handoff на `/auth/callback`.
|
||||
|
||||
## 3. Docker Compose
|
||||
|
||||
Файлы в репозитории:
|
||||
|
||||
- корневой [`docker-compose.yml`](../docker-compose.yml)
|
||||
- пример [`deploy/compose/docker-compose.example.yaml`](../deploy/compose/docker-compose.example.yaml)
|
||||
|
||||
Создайте `.env` рядом с compose:
|
||||
|
||||
```env
|
||||
JWT_SECRET=change-me-strong
|
||||
AUTH_REQUIRED=true
|
||||
AUTH_JWT_SECRET=change-me-strong
|
||||
AUTH_ISSUER=https://auth.shnt.top
|
||||
AUTH_PORTAL_URL=https://auth.shnt.top
|
||||
AUTH_AUDIT_INGEST_SECRET=change-me-audit
|
||||
CLOUDFLARE_API_TOKEN=cf_xxxxxxxx
|
||||
ADMIN_USERNAME=admin
|
||||
```
|
||||
|
||||
```bash
|
||||
# из корня репозитория или каталога с compose
|
||||
docker compose pull
|
||||
docker compose up -d
|
||||
|
||||
docker compose ps
|
||||
docker compose logs -f --tail=100
|
||||
```
|
||||
|
||||
Остановка:
|
||||
|
||||
```bash
|
||||
docker compose down
|
||||
# данные в volume сохраняются; удалить volume:
|
||||
# docker compose down -v
|
||||
```
|
||||
|
||||
## 4. За reverse proxy (Traefik / Caddy)
|
||||
|
||||
Пробросьте HTTPS на контейнер `:8080`. Пример labels Traefik (Host `cdn.shnt.top`):
|
||||
|
||||
```yaml
|
||||
labels:
|
||||
- traefik.enable=true
|
||||
- traefik.http.routers.cdnmanager.rule=Host(`cdn.shnt.top`)
|
||||
- traefik.http.routers.cdnmanager.entrypoints=websecure
|
||||
- traefik.http.routers.cdnmanager.tls=true
|
||||
- traefik.http.services.cdnmanager.loadbalancer.server.port=8080
|
||||
```
|
||||
|
||||
На auth-portal добавьте в `RETURN_TO_ALLOWLIST`: `https://cdn.shnt.top`.
|
||||
|
||||
В Admin → Apps выставьте URL App Switcher для `cdn` на этот origin.
|
||||
|
||||
## 5. Обновление
|
||||
|
||||
```bash
|
||||
docker pull git.shx.one/denozord/cdnmanager:latest
|
||||
docker stop cdnmanager && docker rm cdnmanager
|
||||
# затем снова docker run … (тот же volume /data)
|
||||
```
|
||||
|
||||
Или:
|
||||
|
||||
```bash
|
||||
docker compose pull
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Миграции SQLite применяются при старте API автоматически.
|
||||
|
||||
## 6. Сборка своего образа (опционально)
|
||||
|
||||
См. [`deploy/docker/README.md`](../deploy/docker/README.md) — `docker buildx bake` из `deploy/docker`.
|
||||
|
||||
## Переменные окружения
|
||||
|
||||
| Переменная | Обязательно | Описание |
|
||||
|------------|-------------|----------|
|
||||
| `DATABASE_URL` | да | `sqlite:/data/app.db` |
|
||||
| `STATIC_DIR` | да (prod) | `/app/static` — SPA |
|
||||
| `SERVER_PORT` | нет | default `8080` в образе / `8081` в local `.env` |
|
||||
| `CLOUDFLARE_API_TOKEN` | для sync | Zone DNS Edit + Zone Read |
|
||||
| `AUTH_REQUIRED` | prod SSO | `true` |
|
||||
| `AUTH_JWT_SECRET` / `JWT_SECRET` | при SSO | = portal |
|
||||
| `AUTH_ISSUER` | при SSO | = portal `ISSUER` |
|
||||
| `AUTH_PORTAL_URL` | при SSO | URL UI портала |
|
||||
| `AUTH_AUDIT_INGEST_SECRET` | опц. | dual-write audit |
|
||||
| `ADMIN_*` | только `AUTH_REQUIRED=false` | локальный admin |
|
||||
|
||||
Подробнее SSO: [`integrate-auth-portal.md`](./integrate-auth-portal.md).
|
||||
@@ -0,0 +1,162 @@
|
||||
# Развёртывание CDN Manager + Traefik (один Docker Compose)
|
||||
|
||||
Один стек: **Traefik** (HTTPS, Let's Encrypt DNS-01 через Cloudflare) + **cdnmanager** (API + SPA, SQLite).
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Образ | `git.shx.one/denozord/cdnmanager` (алиас `cdn-manager`) |
|
||||
| Runtime | `node:22-alpine`, API + SPA + SQLite |
|
||||
| Публичный URL | `https://cdn.shnt.top` |
|
||||
| Compose | [`deploy/docker-compose.traefik.yml`](../deploy/docker-compose.traefik.yml) |
|
||||
| Env-шаблон | [`deploy/env.traefik.example`](../deploy/env.traefik.example) |
|
||||
| SSO | [`integrate-auth-portal.md`](./integrate-auth-portal.md) |
|
||||
| Релизы | [`releasing.md`](./releasing.md) |
|
||||
|
||||
Документация Traefik: [Expose Docker](https://doc.traefik.io/traefik/expose/docker/basic/), [ACME DNS challenge](https://doc.traefik.io/traefik/https/acme/).
|
||||
|
||||
```
|
||||
Internet → :80/:443 (Traefik) → cdnmanager:8080
|
||||
↑
|
||||
Cloudflare DNS TXT (ACME)
|
||||
```
|
||||
|
||||
## Предпосылки
|
||||
|
||||
1. Зона домена в Cloudflare (например `shnt.top`).
|
||||
2. Docker Engine + Compose plugin на VPS.
|
||||
3. Свободные порты **80** и **443** на хосте (этот стек сам поднимает Traefik).
|
||||
4. `docker login git.shx.one`.
|
||||
5. Для SSO — работающий auth-portal с тем же `JWT_SECRET` / `AUTH_ISSUER`.
|
||||
|
||||
> Если на сервере уже крутится другой Traefik на 80/443 — либо остановите его, либо смените `TRAEFIK_HTTP_PORT` / `TRAEFIK_HTTPS_PORT`. Два Traefik на одних портах не запустятся.
|
||||
|
||||
---
|
||||
|
||||
## 1. Cloudflare: токены и DNS
|
||||
|
||||
### Два разных токена
|
||||
|
||||
| Переменная | Куда | Назначение |
|
||||
|------------|------|------------|
|
||||
| `CF_DNS_API_TOKEN` | контейнер **Traefik** | ACME DNS-01 (TXT `_acme-challenge`) |
|
||||
| `CLOUDFLARE_API_TOKEN` | контейнер **app** | Zone DNS Edit + Zone Read для sync флота |
|
||||
|
||||
Можно один токен с нужными правами продублировать в обе переменные, либо разделить (рекомендуется least privilege).
|
||||
|
||||
[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 → ваша зона. Не Global API Key, не в Git.
|
||||
|
||||
### DNS-запись
|
||||
|
||||
| Type | Name | Content | Proxy |
|
||||
|------|------|---------|-------|
|
||||
| `A` / `AAAA` | `cdn` | IP VPS | **DNS only** (серое облако) |
|
||||
|
||||
```bash
|
||||
dig +short cdn.shnt.top A
|
||||
```
|
||||
|
||||
Traefik для сертификата создаёт TXT `_acme-challenge.cdn…` через Cloudflare API.
|
||||
|
||||
---
|
||||
|
||||
## 2. Файлы на сервере
|
||||
|
||||
```bash
|
||||
mkdir -p /opt/cdnmanager/data
|
||||
cd /opt/cdnmanager
|
||||
|
||||
# из репозитория:
|
||||
# cp deploy/docker-compose.traefik.yml docker-compose.yml
|
||||
# cp deploy/env.traefik.example .env
|
||||
|
||||
curl -fsSL -o docker-compose.yml \
|
||||
https://git.shx.one/denozord/CDNManager/raw/branch/main/deploy/docker-compose.traefik.yml
|
||||
curl -fsSL -o .env \
|
||||
https://git.shx.one/denozord/CDNManager/raw/branch/main/deploy/env.traefik.example
|
||||
|
||||
nano .env # заполнить секреты
|
||||
```
|
||||
|
||||
### Обязательные переменные в `.env`
|
||||
|
||||
| Переменная | Назначение |
|
||||
|------------|------------|
|
||||
| `CF_DNS_API_TOKEN` | Cloudflare token для ACME DNS-01 (Traefik) |
|
||||
| `LETSENCRYPT_EMAIL` | Email для Let's Encrypt |
|
||||
| `CDN_DOMAIN` | Хост в Traefik rule, по умолчанию `cdn.shnt.top` |
|
||||
| `CLOUDFLARE_API_TOKEN` | Token приложения для DNS sync |
|
||||
| `AUTH_JWT_SECRET` / `JWT_SECRET` | = `JWT_SECRET` auth-portal (HS256) |
|
||||
| `AUTH_ISSUER` | `https://auth.shnt.top` |
|
||||
| `AUTH_PORTAL_URL` | URL UI портала |
|
||||
|
||||
Опционально: `CDN_IMAGE_TAG`, `TRAEFIK_IMAGE_TAG`, `TRAEFIK_HTTP_PORT`, `TRAEFIK_HTTPS_PORT`, `AUTH_AUDIT_INGEST_SECRET`, `AUTH_REQUIRED` (default `true`).
|
||||
|
||||
На стороне портала добавьте origin в `RETURN_TO_ALLOWLIST` (`https://cdn.shnt.top`) и выдайте app **`cdn`** + права `cdn:*`. App Switcher URL: тот же origin.
|
||||
|
||||
---
|
||||
|
||||
## 3. Запуск
|
||||
|
||||
```bash
|
||||
cd /opt/cdnmanager
|
||||
docker login git.shx.one
|
||||
docker compose pull
|
||||
docker compose up -d
|
||||
docker compose ps
|
||||
docker compose logs -f --tail=100
|
||||
```
|
||||
|
||||
Проверка:
|
||||
|
||||
```bash
|
||||
curl -fsS https://cdn.shnt.top/health
|
||||
# {"status":"ok",…}
|
||||
```
|
||||
|
||||
Откройте `https://cdn.shnt.top` → редирект на auth-portal → handoff на `/auth/callback`.
|
||||
|
||||
---
|
||||
|
||||
## 4. Обновление
|
||||
|
||||
```bash
|
||||
cd /opt/cdnmanager
|
||||
docker compose pull
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Миграции SQLite применяются при старте API. Volume `./data` сохраняется.
|
||||
|
||||
---
|
||||
|
||||
## 5. Остановка
|
||||
|
||||
```bash
|
||||
docker compose down
|
||||
# с удалением ACME volume (сертификаты перевыпустятся):
|
||||
# docker compose down -v
|
||||
```
|
||||
|
||||
Данные SQLite в `./data` на хосте остаются, пока не удалите каталог.
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Симптом | Причина |
|
||||
|---------|---------|
|
||||
| Порт 80/443 занят | другой Traefik/nginx; смените порты или хост |
|
||||
| ACME timeout | `CF_DNS_API_TOKEN` без DNS Edit; Cloudflare Proxy на A-записи (нужен DNS only) |
|
||||
| SSO loop / 401 | разный `AUTH_JWT_SECRET` / `AUTH_ISSUER` vs portal |
|
||||
| 403 «Нет доступа» | у пользователя нет app `cdn` |
|
||||
| return_to rejected | `https://cdn.shnt.top` не в `RETURN_TO_ALLOWLIST` портала |
|
||||
| Sync CF падает | пустой/`CLOUDFLARE_API_TOKEN` без Zone DNS Edit |
|
||||
|
||||
Без Traefik (plain `docker run` / простой Compose): [`deploy-docker.md`](./deploy-docker.md).
|
||||
@@ -0,0 +1,57 @@
|
||||
# Интеграция auth-portal ↔ CDN Manager
|
||||
|
||||
App id: **`cdn`**. Зеркало на стороне портала: [`auth-portal/docs/integrate-cdnmanager.md`](../../auth-portal/docs/integrate-cdnmanager.md) (соседний репозиторий).
|
||||
|
||||
## Flow
|
||||
|
||||
```
|
||||
Browser → CDNManager UI (нет token)
|
||||
→ redirect AUTH_PORTAL_URL/?return_to=…/auth/callback
|
||||
→ login
|
||||
→ redirect return_to#access_token=…
|
||||
→ /auth/callback сохраняет token (localStorage: cdnmanager_token)
|
||||
→ API Authorization: Bearer <JWT>
|
||||
```
|
||||
|
||||
## Permissions
|
||||
|
||||
| Permission | UI |
|
||||
|------------|-----|
|
||||
| `cdn:dashboard:read` | `/` |
|
||||
| `cdn:nodes:read` / `write` | `/nodes` |
|
||||
| `cdn:aliases:read` / `write` | `/aliases` (+ Retarget) |
|
||||
| `cdn:zones:read` / `write` | `/zones` |
|
||||
| `cdn:sync:write` | Sync / Apply |
|
||||
| `cdn:topology:read` | `/topology` |
|
||||
| `cdn:settings:admin` | `/settings/*` |
|
||||
|
||||
## Env
|
||||
|
||||
```env
|
||||
AUTH_REQUIRED=true
|
||||
AUTH_JWT_SECRET=<тот же JWT_SECRET портала>
|
||||
AUTH_ISSUER=https://auth.shnt.top
|
||||
AUTH_PORTAL_URL=https://auth.shnt.top
|
||||
AUTH_AUDIT_INGEST_SECRET=<AUDIT_INGEST_SECRET портала>
|
||||
CLOUDFLARE_API_TOKEN=
|
||||
```
|
||||
|
||||
```env
|
||||
# apps/web/.env.local (dev SPA)
|
||||
VITE_AUTH_ENABLED=true
|
||||
VITE_AUTH_PORTAL_URL=https://auth.shnt.top
|
||||
```
|
||||
|
||||
В Docker SPA обслуживается API (`STATIC_DIR`): достаточно server-side `AUTH_*` — UI читает `GET /api/v1/auth/config`.
|
||||
|
||||
В portal Admin → Apps выдайте app `cdn` и нужные `cdn:*`. URL в App Switcher: origin CDNManager (`https://cdn.shnt.top`).
|
||||
|
||||
## Checklist
|
||||
|
||||
1. Общий `JWT_SECRET` / `AUTH_JWT_SECRET` и одинаковый `AUTH_ISSUER`
|
||||
2. Origin CDNManager в `RETURN_TO_ALLOWLIST` портала
|
||||
3. Пользователю выдан app `cdn`
|
||||
4. `AUTH_REQUIRED=true` в контейнере CDNManager
|
||||
5. Logout → `{AUTH_PORTAL_URL}/logout`
|
||||
|
||||
Запуск на сервере: [`deploy-traefik.md`](./deploy-traefik.md) (рекомендуется) или [`deploy-docker.md`](./deploy-docker.md).
|
||||
Reference in New Issue
Block a user