Compare commits

...
3 Commits
Author SHA1 Message Date
DenozordecandCursor 0e9349e508 feat(auth): интегрировать SSO auth-portal
Docker images / prepare-release (push) Successful in 5s
Docker images / backend-image (push) Successful in 2m52s
Docker images / frontend-image (push) Successful in 2m23s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 38s
Docker images / publish-release (push) Successful in 8s
JWT на backend, handoff/callback на UI, RBAC mm:*, AUTH_* в compose.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-04 20:38:28 +07:00
DenozordecandCursor 0208aa4d7c chore(deploy): добавить Traefik-стеки и общий compose с CDNManager
Docker images / prepare-release (push) Successful in 5s
Docker images / backend-image (push) Successful in 3m23s
Docker images / frontend-image (push) Successful in 2m1s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 38s
Docker images / publish-release (push) Successful in 6s
Единый стек CDN+MM, вариант рядом с CDN Traefik и CLI-скрипт запуска.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-04 19:07:49 +07:00
Denozordec 42a2e18047 docs: обновить раздел о прод-развёртывании Docker и изменить ссылки на репозиторий
Docker images / prepare-release (push) Successful in 23s
Docker images / backend-image (push) Failing after 1m30s
Docker images / frontend-image (push) Successful in 3m45s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 50s
Docker images / publish-release (push) Skipped
2026-09-04 18:54:26 +07:00
28 changed files with 2227 additions and 130 deletions
+3 -3
View File
@@ -12,9 +12,9 @@
"Bash(curl -s -o /dev/null -w '%{http_code}' http://localhost:__TRACKED_VAR__/dashboard)",
"Bash(curl -s -o /dev/null -w '%{http_code}' http://localhost:3333__TRACKED_VAR__)",
"Bash(curl -s -o /dev/null -w '%{http_code}' http://localhost:59959__TRACKED_VAR__)",
"WebFetch(domain:git.shts.su)",
"Bash(curl -s \"https://git.shts.su/denozord/router-lists-ui/raw/branch/v5/frontend/src/RouteOptimizerPage.jsx\")",
"Bash(curl -s \"https://git.shts.su/denozord/router-lists-ui/raw/branch/v5/frontend/src/OspfToolsPage.jsx\")",
"WebFetch(domain:git.shx.one)",
"Bash(curl -s \"https://git.shx.one/denozord/router-lists-ui/raw/branch/v5/frontend/src/RouteOptimizerPage.jsx\")",
"Bash(curl -s \"https://git.shx.one/denozord/router-lists-ui/raw/branch/v5/frontend/src/OspfToolsPage.jsx\")",
"Bash(node -e ' *)",
"Bash(powershell -Command \"Get-Item 'C:\\\\Users\\\\shats\\\\.claude\\\\projects\\\\C--Users-shats-Dev-MikrotikManager-3\\\\b1dbd554-4665-40c0-bb5b-19d63bb494a0.jsonl'\")",
"Bash(node -e \"const { createRequire } = require\\('module'\\); const r = createRequire\\(__filename\\); const lucide = r\\('lucide-react'\\); ['SlackIcon','WebhookIcon'].forEach\\(n => console.log\\(n, !!lucide[n]\\)\\)\")",
+1
View File
@@ -177,6 +177,7 @@ jobs:
NEXT_PUBLIC_BACKEND_URL=same-origin
NEXT_PUBLIC_DEFAULT_DATA_SOURCE=live
NEXT_PUBLIC_ALLOW_MOCK_DATA=false
NEXT_PUBLIC_AUTH_PORTAL_URL=https://auth.shnt.top
NEXT_PUBLIC_APP_VERSION=${{ needs.prepare-release.outputs.version }}
NEXT_PUBLIC_RELEASE_URL=${{ needs.prepare-release.outputs.release_url }}
tags: |
+2
View File
@@ -14,12 +14,14 @@ ARG NEXT_PUBLIC_DEFAULT_DATA_SOURCE=live
ARG NEXT_PUBLIC_ALLOW_MOCK_DATA=false
ARG NEXT_PUBLIC_APP_VERSION=dev
ARG NEXT_PUBLIC_RELEASE_URL=
ARG NEXT_PUBLIC_AUTH_PORTAL_URL=https://auth.shnt.top
ENV BACKEND_INTERNAL_URL=$BACKEND_INTERNAL_URL
ENV NEXT_PUBLIC_BACKEND_URL=$NEXT_PUBLIC_BACKEND_URL
ENV NEXT_PUBLIC_DEFAULT_DATA_SOURCE=$NEXT_PUBLIC_DEFAULT_DATA_SOURCE
ENV NEXT_PUBLIC_ALLOW_MOCK_DATA=$NEXT_PUBLIC_ALLOW_MOCK_DATA
ENV NEXT_PUBLIC_APP_VERSION=$NEXT_PUBLIC_APP_VERSION
ENV NEXT_PUBLIC_RELEASE_URL=$NEXT_PUBLIC_RELEASE_URL
ENV NEXT_PUBLIC_AUTH_PORTAL_URL=$NEXT_PUBLIC_AUTH_PORTAL_URL
COPY packages/contracts packages/contracts
COPY next.config.ts tsconfig.json postcss.config.mjs components.json ./
COPY app app
+56 -1
View File
@@ -219,7 +219,62 @@ npm --prefix backend run db:studio
## Прод-развёртывание Docker
Эталон: `deploy/docker-compose.yml`. Рабочий каталог для команд compose — `deploy/` (или укажите `-f deploy/docker-compose.yml` из корня репозитория).
Эталон без reverse-proxy: `deploy/docker-compose.yml` (порты `3000` / `8000` на хост).
Стек с Traefik + HTTPS (Let's Encrypt DNS-01 / Cloudflare), по аналогии с CDNManager: [`deploy/docker-compose.traefik.yml`](deploy/docker-compose.traefik.yml) + [`deploy/env.traefik.example`](deploy/env.traefik.example). На сервере публикуются только `:80`/`:443`; frontend получает HTTPS, `/api` и `/health` проксируются на backend внутри сети `mmapp`. Домен по умолчанию: `mm.shnt.top`.
### CDN Manager + MikrotikManager (один Traefik)
Полный стек: Traefik + `cdn.shnt.top` + `mm.shnt.top` в одном Compose.
| Файл | Назначение |
|------|------------|
| [`deploy/docker-compose.cdn-mm.yml`](deploy/docker-compose.cdn-mm.yml) | Traefik + CDN Manager + MM backend/frontend/updater |
| [`deploy/env.cdn-mm.example`](deploy/env.cdn-mm.example) | общий `.env` |
```bash
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
cp deploy/updater/targets.json.example /opt/cdn-mm/updater/targets.json
# заполнить CF_DNS_API_TOKEN, CLOUDFLARE_API_TOKEN, AUTH_JWT_SECRET, CORS_ORIGIN, …
docker login git.shx.one
cd /opt/cdn-mm && docker compose pull && docker compose up -d
curl -fsS https://cdn.shnt.top/health
curl -fsS https://mm.shnt.top/health
```
Данные: `./data/cdn` (CDN), `./data/mm` (MM). Сеть Traefik: `edge`. Не запускайте параллельно standalone `docker-compose.traefik.yml` CDNManager или MM на тех же 80/443.
**Если на сервере уже крутится CDNManager Traefik** (`cdnmanager-traefik`, сеть `cdnmanager`) — **не** поднимайте второй Traefik. Варианты:
| Способ | Файл |
|--------|------|
| Compose без своего Traefik | [`deploy/docker-compose.traefik-cdn.yml`](deploy/docker-compose.traefik-cdn.yml) |
| Plain `docker` CLI (скрипт) | [`deploy/run-beside-cdn-traefik.sh`](deploy/run-beside-cdn-traefik.sh) |
Frontend вешается в сеть `cdnmanager` с Traefik-labels; backend/updater остаются в `mmapp`. Сертификат для `MM_DOMAIN` выпускает уже работающий Traefik CDNManager (тот же `letsencrypt` / Cloudflare DNS-01).
```bash
# Compose (рекомендуется)
mkdir -p /opt/mmapp/{data,state,updater}
cp deploy/docker-compose.traefik-cdn.yml /opt/mmapp/docker-compose.yml
cp deploy/env.traefik.example /opt/mmapp/.env # MM_DOMAIN + CORS_ORIGIN
cp deploy/updater/targets.json.example /opt/mmapp/updater/targets.json
cd /opt/mmapp && docker compose pull && docker compose up -d
# Или одной CLI-командой (скрипт сам сделает network/pull/run/connect):
curl -fsSL -o /tmp/run-beside-cdn-traefik.sh \
https://git.shx.one/denozord/MikrotikManager/raw/branch/main/deploy/run-beside-cdn-traefik.sh
chmod +x /tmp/run-beside-cdn-traefik.sh
sudo MM_DOMAIN=mm.shnt.top CORS_ORIGIN=https://mm.shnt.top /tmp/run-beside-cdn-traefik.sh
```
DNS: `A`/`AAAA` для `mm.shnt.top` → IP VPS, Cloudflare **DNS only**. Проверка: `curl -fsS https://mm.shnt.top/health`.
SSO auth-portal: [`docs/integrate-auth-portal.md`](docs/integrate-auth-portal.md) (app id `mm`).
Рабочий каталог для команд compose — `deploy/` (или `-f deploy/docker-compose.yml` / `-f deploy/docker-compose.traefik.yml` / `-f deploy/docker-compose.traefik-cdn.yml` / `-f deploy/docker-compose.cdn-mm.yml` из корня).
### Прод-контейнеры
+20 -17
View File
@@ -1,5 +1,6 @@
import type { CSSProperties, ReactNode } from "react"
import { AppSidebar } from "@/components/app-sidebar"
import { AuthGuard } from "@/components/auth-guard"
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar"
import { CommandPalette } from "@/components/command-palette"
import { ReleaseNotesModal } from "@/components/release-notes-modal"
@@ -11,22 +12,24 @@ const SKIP_TO_CONTENT_CLASS =
export default function MainLayout({ children }: { children: ReactNode }) {
return (
<DataSourceProvider>
<EvoBGPProvider>
<SidebarProvider
style={{ "--sidebar-width": "240px" } as CSSProperties}
>
<a href="#main-content" className={SKIP_TO_CONTENT_CLASS}>
К содержимому
</a>
<AppSidebar />
<SidebarInset id="main-content" className="h-svh overflow-hidden">
{children}
</SidebarInset>
<CommandPalette />
<ReleaseNotesModal />
</SidebarProvider>
</EvoBGPProvider>
</DataSourceProvider>
<AuthGuard>
<DataSourceProvider>
<EvoBGPProvider>
<SidebarProvider
style={{ "--sidebar-width": "240px" } as CSSProperties}
>
<a href="#main-content" className={SKIP_TO_CONTENT_CLASS}>
К содержимому
</a>
<AppSidebar />
<SidebarInset id="main-content" className="h-svh overflow-hidden">
{children}
</SidebarInset>
<CommandPalette />
<ReleaseNotesModal />
</SidebarProvider>
</EvoBGPProvider>
</DataSourceProvider>
</AuthGuard>
)
}
+19
View File
@@ -0,0 +1,19 @@
"use client"
export default function AccessDeniedPage() {
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">
У вашей учётной записи нет приложения MikrotikManager (`mm`) или
необходимых прав. Обратитесь к администратору auth-portal.
</p>
<a
href="/auth/callback"
className="text-primary text-sm underline-offset-4 hover:underline"
>
Войти снова
</a>
</div>
)
}
+98
View File
@@ -0,0 +1,98 @@
"use client"
import { useEffect, useState } from "react"
import { useRouter } from "next/navigation"
import {
clearPortalHandoffFlag,
clearToken,
ensureAuthConfig,
firstAllowedPath,
getClaims,
getToken,
parseHashToken,
redirectToPortalLogin,
redirectToPortalLoginInteractive,
setToken,
} from "@/lib/auth"
export default function AuthCallbackPage() {
const router = useRouter()
const [message, setMessage] = useState("Перенаправление на Auth Portal…")
useEffect(() => {
let cancelled = false
void (async () => {
await ensureAuthConfig()
if (cancelled) return
const params = new URLSearchParams(window.location.search)
const error = params.get("error")
if (error === "sso_loop" || error === "jwt_rejected") {
redirectToPortalLoginInteractive()
return
}
const { accessToken } = parseHashToken(window.location.hash)
if (accessToken) {
setToken(accessToken)
clearPortalHandoffFlag()
const claims = getClaims()
if (!claims) {
clearToken()
redirectToPortalLoginInteractive()
return
}
if (!claims.apps.includes("mm")) {
setMessage("Нет доступа к приложению")
router.replace("/access-denied")
return
}
try {
const res = await fetch("/api/auth/config", {
headers: { Authorization: `Bearer ${accessToken}` },
})
if (res.status === 401) {
clearToken()
redirectToPortalLoginInteractive()
return
}
} catch {
/* ignore network — proceed */
}
const next = firstAllowedPath()
if (next === "/access-denied") {
router.replace("/access-denied")
return
}
router.replace(next)
return
}
if (getToken() && getClaims()) {
clearPortalHandoffFlag()
if (!getClaims()!.apps.includes("mm")) {
router.replace("/access-denied")
return
}
router.replace(firstAllowedPath())
return
}
const ok = redirectToPortalLogin(`${window.location.origin}/auth/callback`)
if (!ok) redirectToPortalLoginInteractive()
})()
return () => {
cancelled = true
}
}, [router])
return (
<div className="text-muted-foreground flex min-h-svh items-center justify-center p-6 text-sm">
{message}
</div>
)
}
+7
View File
@@ -6,3 +6,10 @@ PORT=8000
# Allowed CORS origin (Next.js frontend)
CORS_ORIGIN=http://localhost:3000
# Portal SSO (false = open API for local/dev)
AUTH_REQUIRED=false
# Same HS256 secret as auth-portal JWT_SECRET when AUTH_REQUIRED=true
AUTH_JWT_SECRET=dev-secret-change-me
AUTH_ISSUER=https://auth.shnt.top
AUTH_PORTAL_URL=http://localhost:5175
+5 -1
View File
@@ -10,10 +10,12 @@
"start": "node dist/index.js",
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
"db:studio": "drizzle-kit studio"
"db:studio": "drizzle-kit studio",
"test:auth": "tsx src/lib/permissions.test.ts && tsx src/plugins/auth.smoke.test.ts"
},
"dependencies": {
"@fastify/cors": "^11.2.0",
"@fastify/jwt": "^10.2.2",
"@fastify/type-provider-zod": "^1.0.0",
"@mmapp/contracts": "1.0.0",
"acme-client": "^5.4.0",
@@ -21,6 +23,7 @@
"dotenv": "^16.4.7",
"drizzle-orm": "^0.45.2",
"fastify": "^5.8.5",
"fastify-plugin": "^5.1.0",
"pino-pretty": "^13.1.3",
"undici": "^8.1.0",
"zod": "^4.4.1"
@@ -29,6 +32,7 @@
"@types/better-sqlite3": "^7.6.13",
"@types/node": "^22.15.3",
"drizzle-kit": "^0.31.10",
"jose": "^6.2.11",
"tsx": "^4.19.3",
"typescript": "^5.8.3"
}
+46 -2
View File
@@ -3,17 +3,61 @@ import { z } from "zod"
config()
function boolEnv(v: string | undefined, fallback: boolean): boolean {
if (v === undefined || v === "") return fallback
return v === "1" || v.toLowerCase() === "true"
}
const isProd = process.env.NODE_ENV === "production"
const envSchema = z.object({
DATABASE_PATH: z.string().default("./mikrotik.db"),
PORT: z.coerce.number().int().positive().default(8000),
CORS_ORIGIN: z.string().default("http://localhost:3000"),
AUTH_REQUIRED: z.boolean().default(false),
AUTH_JWT_SECRET: z.string().default(""),
AUTH_ISSUER: z.string().default("https://auth.shnt.top"),
AUTH_PORTAL_URL: z.string().default("http://localhost:5175"),
})
const parsed = envSchema.safeParse(process.env)
const raw = {
DATABASE_PATH: process.env.DATABASE_PATH,
PORT: process.env.PORT,
CORS_ORIGIN: process.env.CORS_ORIGIN,
AUTH_REQUIRED: boolEnv(process.env.AUTH_REQUIRED, false),
AUTH_JWT_SECRET:
process.env.AUTH_JWT_SECRET?.trim() ||
process.env.JWT_SECRET?.trim() ||
(isProd ? "" : "dev-secret-change-me"),
AUTH_ISSUER:
process.env.AUTH_ISSUER?.trim() ||
process.env.ISSUER?.trim() ||
"https://auth.shnt.top",
AUTH_PORTAL_URL: (
process.env.AUTH_PORTAL_URL ??
process.env.NEXT_PUBLIC_AUTH_PORTAL_URL ??
"http://localhost:5175"
).replace(/\/$/, ""),
}
const parsed = envSchema.safeParse(raw)
if (!parsed.success) {
console.error("❌ Invalid environment variables:", parsed.error.flatten().fieldErrors)
process.exit(1)
}
export const env = parsed.data
if (parsed.data.AUTH_REQUIRED && parsed.data.AUTH_JWT_SECRET.length < 8) {
console.error("❌ AUTH_JWT_SECRET / JWT_SECRET required when AUTH_REQUIRED=true")
process.exit(1)
}
export const env = {
DATABASE_PATH: parsed.data.DATABASE_PATH,
PORT: parsed.data.PORT,
CORS_ORIGIN: parsed.data.CORS_ORIGIN,
authRequired: parsed.data.AUTH_REQUIRED,
jwtSecret: parsed.data.AUTH_JWT_SECRET || "dev-secret-change-me",
authIssuer: parsed.data.AUTH_ISSUER,
authPortalUrl: parsed.data.AUTH_PORTAL_URL,
}
+97 -62
View File
@@ -1,11 +1,12 @@
import Fastify from "fastify"
import Fastify, { type FastifyInstance } from "fastify"
import cors from "@fastify/cors"
import { serializerCompiler, validatorCompiler } from "@fastify/type-provider-zod"
import { env } from "./config.js"
import authPlugin, { requireAuth } from "./plugins/auth.js"
import serversRoutes from "./routes/servers.js"
import bgpRoutes from "./routes/bgp.js"
import ospfRoutes from "./routes/ospf.js"
import execRoutes from "./routes/exec.js"
import bgpRoutes from "./routes/bgp.js"
import ospfRoutes from "./routes/ospf.js"
import execRoutes from "./routes/exec.js"
import filtersRoutes from "./routes/filters.js"
import recursiveRoutes from "./routes/recursive-routes.js"
import trafficRoutes from "./routes/traffic.js"
@@ -24,71 +25,105 @@ import systemDatabaseRoutes from "./routes/system-database.js"
import eventsRoutes from "./routes/events.js"
import { refreshScheduler, stopScheduler } from "./services/scheduler.js"
// ── app factory ────────────────────────────────────────────────────────────────
export async function buildApp(opts?: {
logger?: boolean
startScheduler?: boolean
}): Promise<FastifyInstance> {
const app = Fastify({
bodyLimit: 512 * 1024 * 1024,
requestTimeout: 10 * 60 * 1000,
logger:
opts?.logger === false
? false
: {
transport: {
target: "pino-pretty",
options: {
colorize: true,
translateTime: "HH:MM:ss",
ignore: "pid,hostname",
},
},
},
})
const app = Fastify({
bodyLimit: 512 * 1024 * 1024,
requestTimeout: 10 * 60 * 1000,
logger: {
transport: {
target: "pino-pretty",
options: { colorize: true, translateTime: "HH:MM:ss", ignore: "pid,hostname" },
},
},
})
app.setValidatorCompiler(validatorCompiler)
app.setSerializerCompiler(serializerCompiler)
// Use Zod for request validation and response serialization
app.setValidatorCompiler(validatorCompiler)
app.setSerializerCompiler(serializerCompiler)
await app.register(cors, {
origin: env.CORS_ORIGIN,
methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
})
// CORS — allow Next.js frontend
await app.register(cors, {
origin: env.CORS_ORIGIN,
/** PATCH — для /api/uptime/probes/:id (звезда на дашборде); без этого браузер режет preflight */
methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
})
await app.register(authPlugin)
// ── routes ─────────────────────────────────────────────────────────────────────
app.get("/health", async () => ({
status: "ok",
timestamp: new Date().toISOString(),
version: process.env.APP_VERSION ?? "dev",
}))
app.get("/health", async () => ({
status: "ok",
timestamp: new Date().toISOString(),
version: process.env.APP_VERSION ?? "dev",
}))
app.get("/api/auth/config", async () => ({
required: env.authRequired,
portal_url: env.authPortalUrl,
issuer: env.authIssuer,
}))
await app.register(serversRoutes, { prefix: "/api/servers" })
await app.register(bgpRoutes, { prefix: "/api" })
await app.register(ospfRoutes, { prefix: "/api" })
await app.register(execRoutes, { prefix: "/api" })
await app.register(filtersRoutes, { prefix: "/api" })
await app.register(recursiveRoutes, { prefix: "/api" })
await app.register(trafficRoutes, { prefix: "/api" })
await app.register(serversApiPingRoutes, { prefix: "/api" })
await app.register(uptimeRoutes, { prefix: "/api" })
await app.register(networkRoutes, { prefix: "/api" })
await app.register(internetPathRoutes, { prefix: "/api" })
await app.register(evobgpRoutes, { prefix: "/api" })
await app.register(probesRoutes, { prefix: "/api" })
await app.register(schedulerRoutes, { prefix: "/api" })
await app.register(sidebarCountsRoutes, { prefix: "/api" })
await app.register(alertsRoutes, { prefix: "/api" })
await app.register(backupsRoutes, { prefix: "/api" })
await app.register(certificatesRoutes, { prefix: "/api" })
await app.register(systemDatabaseRoutes, { prefix: "/api" })
await app.register(eventsRoutes, { prefix: "/api" })
if (env.authRequired) {
app.addHook("preHandler", async (request, reply) => {
const pathname = request.url.split("?")[0] ?? request.url
if (!pathname.startsWith("/api/")) return
if (pathname === "/api/auth/config") return
await requireAuth(request, reply)
if (reply.sent) return
})
}
refreshScheduler()
app.addHook("onClose", async () => {
stopScheduler()
})
await app.register(serversRoutes, { prefix: "/api/servers" })
await app.register(bgpRoutes, { prefix: "/api" })
await app.register(ospfRoutes, { prefix: "/api" })
await app.register(execRoutes, { prefix: "/api" })
await app.register(filtersRoutes, { prefix: "/api" })
await app.register(recursiveRoutes, { prefix: "/api" })
await app.register(trafficRoutes, { prefix: "/api" })
await app.register(serversApiPingRoutes, { prefix: "/api" })
await app.register(uptimeRoutes, { prefix: "/api" })
await app.register(networkRoutes, { prefix: "/api" })
await app.register(internetPathRoutes, { prefix: "/api" })
await app.register(evobgpRoutes, { prefix: "/api" })
await app.register(probesRoutes, { prefix: "/api" })
await app.register(schedulerRoutes, { prefix: "/api" })
await app.register(sidebarCountsRoutes, { prefix: "/api" })
await app.register(alertsRoutes, { prefix: "/api" })
await app.register(backupsRoutes, { prefix: "/api" })
await app.register(certificatesRoutes, { prefix: "/api" })
await app.register(systemDatabaseRoutes, { prefix: "/api" })
await app.register(eventsRoutes, { prefix: "/api" })
// ── start ──────────────────────────────────────────────────────────────────────
if (opts?.startScheduler !== false) {
refreshScheduler()
app.addHook("onClose", async () => {
stopScheduler()
})
}
try {
await app.listen({ port: env.PORT, host: "0.0.0.0" })
console.log(`\n🚀 MikroTik Manager Backend running at http://localhost:${env.PORT}`)
console.log(` Docs / test: http://localhost:${env.PORT}/health`)
} catch (err) {
app.log.error(err)
process.exit(1)
return app
}
const isMain =
process.argv[1] &&
(process.argv[1].endsWith("index.ts") || process.argv[1].endsWith("index.js"))
if (isMain) {
try {
const app = await buildApp()
await app.listen({ port: env.PORT, host: "0.0.0.0" })
console.log(
`\n🚀 MikroTik Manager Backend running at http://localhost:${env.PORT}`,
)
console.log(` Docs / test: http://localhost:${env.PORT}/health`)
} catch (err) {
console.error(err)
process.exit(1)
}
}
+25
View File
@@ -0,0 +1,25 @@
import assert from "node:assert/strict"
import { hasPermission, permissionForRequest } from "./permissions.js"
assert.equal(hasPermission(["mm:servers:write"], "mm:servers:read"), true)
assert.equal(hasPermission(["mm:servers:admin"], "mm:servers:write"), true)
assert.equal(hasPermission(["mm:servers:read"], "mm:servers:write"), false)
assert.equal(
permissionForRequest("GET", "/api/servers"),
"mm:servers:read",
)
assert.equal(
permissionForRequest("POST", "/api/servers"),
"mm:servers:write",
)
assert.equal(
permissionForRequest("GET", "/api/system/database/backup"),
"mm:settings:admin",
)
assert.equal(
permissionForRequest("GET", "/api/unknown-thing"),
"mm:dashboard:read",
)
console.log("permissions.test.ts: ok")
+173
View File
@@ -0,0 +1,173 @@
/**
* Portal JWT RBAC helpers (mirrors @authportal/shared hasPermission).
* Format: mm:<section>:<read|write|admin>
*/
export type AuthUser = {
id: string
email: string
name: string
apps: string[]
permissions: string[]
isAdmin?: boolean
}
export function hasPermission(
granted: readonly string[],
required: string,
): boolean {
if (granted.includes(required)) return true
const parts = required.split(":")
if (parts.length !== 3) return false
const [app, section, action] = parts
if (action === "read") {
return (
granted.includes(`${app}:${section}:write`) ||
granted.includes(`${app}:${section}:admin`)
)
}
if (action === "write") {
return granted.includes(`${app}:${section}:admin`)
}
return false
}
type Rule = {
methods: string[]
match: (path: string) => boolean
permission: string
}
const RULES: Rule[] = [
{
methods: ["GET", "POST", "PUT", "PATCH", "DELETE"],
match: (p) =>
p.startsWith("/api/system") ||
p.startsWith("/api/scheduler") ||
p.startsWith("/api/evobgp"),
permission: "mm:settings:admin",
},
{
methods: ["GET"],
match: (p) =>
p.startsWith("/api/sidebar-counts") || p.startsWith("/api/events"),
permission: "mm:dashboard:read",
},
{
methods: ["GET"],
match: (p) => p.startsWith("/api/servers"),
permission: "mm:servers:read",
},
{
methods: ["POST", "PUT", "PATCH", "DELETE"],
match: (p) => p.startsWith("/api/servers"),
permission: "mm:servers:write",
},
{
methods: ["GET"],
match: (p) => p.startsWith("/api/filters"),
permission: "mm:filters:read",
},
{
methods: ["POST", "PUT", "PATCH", "DELETE"],
match: (p) => p.startsWith("/api/filters"),
permission: "mm:filters:write",
},
{
methods: ["GET"],
match: (p) => p.startsWith("/api/bgp"),
permission: "mm:bgp:read",
},
{
methods: ["POST", "PUT", "PATCH", "DELETE"],
match: (p) => p.startsWith("/api/bgp"),
permission: "mm:bgp:write",
},
{
methods: ["GET"],
match: (p) => p.startsWith("/api/uptime"),
permission: "mm:uptime:read",
},
{
methods: ["POST", "PUT", "PATCH", "DELETE"],
match: (p) => p.startsWith("/api/uptime"),
permission: "mm:uptime:write",
},
{
methods: ["GET"],
match: (p) => p.startsWith("/api/traffic"),
permission: "mm:traffic:read",
},
{
methods: ["POST", "PUT", "PATCH", "DELETE"],
match: (p) => p.startsWith("/api/traffic"),
permission: "mm:traffic:write",
},
{
methods: ["GET"],
match: (p) => p.startsWith("/api/alerts"),
permission: "mm:alerts:read",
},
{
methods: ["POST", "PUT", "PATCH", "DELETE"],
match: (p) => p.startsWith("/api/alerts"),
permission: "mm:alerts:write",
},
{
methods: ["GET"],
match: (p) => p.startsWith("/api/backups"),
permission: "mm:backups:read",
},
{
methods: ["POST", "PUT", "PATCH", "DELETE"],
match: (p) => p.startsWith("/api/backups"),
permission: "mm:backups:write",
},
{
methods: ["GET"],
match: (p) => p.startsWith("/api/certificates"),
permission: "mm:certificates:read",
},
{
methods: ["POST", "PUT", "PATCH", "DELETE"],
match: (p) => p.startsWith("/api/certificates"),
permission: "mm:certificates:write",
},
{
methods: ["GET"],
match: (p) =>
p.startsWith("/api/network") ||
p.startsWith("/api/ospf") ||
p.startsWith("/api/recursive") ||
p.startsWith("/api/probes") ||
p.startsWith("/api/internet-path") ||
p.startsWith("/api/exec"),
permission: "mm:network:read",
},
{
methods: ["POST", "PUT", "PATCH", "DELETE"],
match: (p) =>
p.startsWith("/api/network") ||
p.startsWith("/api/ospf") ||
p.startsWith("/api/recursive") ||
p.startsWith("/api/probes") ||
p.startsWith("/api/internet-path") ||
p.startsWith("/api/exec"),
permission: "mm:network:write",
},
]
/** Resolve required permission for method+path, or null if public / unknown. */
export function permissionForRequest(
method: string,
path: string,
): string | null {
const m = method.toUpperCase()
const pathname = path.split("?")[0] ?? path
for (const rule of RULES) {
if (!rule.methods.includes(m)) continue
if (rule.match(pathname)) return rule.permission
}
if (pathname.startsWith("/api/")) return "mm:dashboard:read"
return null
}
+72
View File
@@ -0,0 +1,72 @@
/**
* Smoke: AUTH_REQUIRED gate via Fastify inject.
* Run: AUTH_REQUIRED=true AUTH_JWT_SECRET=test-secret-at-least-8 tsx src/plugins/auth.smoke.test.ts
*/
import assert from "node:assert/strict"
import { SignJWT } from "jose"
process.env.AUTH_REQUIRED = "true"
process.env.AUTH_JWT_SECRET = "test-secret-at-least-8"
process.env.AUTH_ISSUER = "https://auth.test.local"
process.env.AUTH_PORTAL_URL = "http://localhost:5175"
process.env.CORS_ORIGIN = "http://localhost:3000"
process.env.DATABASE_PATH = ":memory:"
process.env.NODE_ENV = "test"
// Dynamic import after env is set
const { buildApp } = await import("../index.js")
const secret = new TextEncoder().encode("test-secret-at-least-8")
async function mint(payload: Record<string, unknown>): Promise<string> {
return new SignJWT(payload)
.setProtectedHeader({ alg: "HS256" })
.setIssuer("https://auth.test.local")
.setExpirationTime("1h")
.sign(secret)
}
const app = await buildApp({ logger: false, startScheduler: false })
const health = await app.inject({ method: "GET", url: "/health" })
assert.equal(health.statusCode, 200)
const cfg = await app.inject({ method: "GET", url: "/api/auth/config" })
assert.equal(cfg.statusCode, 200)
assert.equal(cfg.json().required, true)
const noToken = await app.inject({ method: "GET", url: "/api/sidebar-counts" })
assert.equal(noToken.statusCode, 401)
const badApp = await mint({
sub: "u1",
email: "a@b.c",
name: "A",
apps: ["cdn"],
permissions: ["cdn:dashboard:read"],
})
const forbiddenApp = await app.inject({
method: "GET",
url: "/api/sidebar-counts",
headers: { authorization: `Bearer ${badApp}` },
})
assert.equal(forbiddenApp.statusCode, 403)
const okToken = await mint({
sub: "u1",
email: "a@b.c",
name: "A",
apps: ["mm"],
permissions: ["mm:dashboard:read"],
})
const ok = await app.inject({
method: "GET",
url: "/api/sidebar-counts",
headers: { authorization: `Bearer ${okToken}` },
})
// May be 200 or 500 if DB missing — must not be 401/403
assert.notEqual(ok.statusCode, 401)
assert.notEqual(ok.statusCode, 403)
await app.close()
console.log("auth.smoke.test.ts: ok")
+119
View File
@@ -0,0 +1,119 @@
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"
import fp from "fastify-plugin"
import { env } from "../config.js"
import {
hasPermission,
permissionForRequest,
type AuthUser,
} from "../lib/permissions.js"
declare module "fastify" {
interface FastifyRequest {
authUser?: AuthUser
}
}
declare module "@fastify/jwt" {
interface FastifyJWT {
payload: {
sub: string
email?: string
name?: string
apps?: string[]
permissions?: string[]
is_admin?: boolean
iss?: string
exp?: number
}
user: {
sub: string
email?: string
name?: string
apps?: string[]
permissions?: string[]
is_admin?: boolean
iss?: string
exp?: number
}
}
}
async function authPlugin(app: FastifyInstance) {
if (env.authRequired && env.jwtSecret.length < 8) {
throw new Error("AUTH_JWT_SECRET / JWT_SECRET required when AUTH_REQUIRED=true")
}
await app.register(import("@fastify/jwt"), {
secret: env.jwtSecret,
...(env.authRequired
? {
verify: {
allowedIss: [env.authIssuer],
},
}
: {}),
})
if (env.authRequired) {
app.log.info(
{ issuer: env.authIssuer, portal: env.authPortalUrl },
"AUTH_REQUIRED=true — portal JWT middleware enabled",
)
} else {
app.log.info("AUTH_REQUIRED=false — /api/* open without JWT")
}
}
/**
* Protect /api/* when AUTH_REQUIRED=true.
* Public: /health, /api/auth/config
*/
export async function requireAuth(
request: FastifyRequest,
reply: FastifyReply,
): Promise<void> {
if (!env.authRequired) return
const pathname = (request.url.split("?")[0] ?? request.url)
if (pathname === "/api/auth/config") return
const authHeader = request.headers.authorization ?? ""
const token = authHeader.startsWith("Bearer ") ? authHeader.slice(7) : ""
if (!token) {
return reply.code(401).send({ error: "Unauthorized" })
}
try {
await request.jwtVerify()
} catch {
return reply.code(401).send({ error: "Unauthorized" })
}
const payload = request.user
const apps = Array.isArray(payload.apps) ? payload.apps.map(String) : []
const permissions = Array.isArray(payload.permissions)
? payload.permissions.map(String)
: []
if (!apps.includes("mm")) {
return reply
.code(403)
.send({ error: "Нет доступа к приложению MikrotikManager" })
}
request.authUser = {
id: String(payload.sub),
email: String(payload.email ?? ""),
name: String(payload.name ?? ""),
apps,
permissions,
isAdmin: Boolean(payload.is_admin),
}
const required = permissionForRequest(request.method, pathname)
if (required && !hasPermission(permissions, required)) {
return reply.code(403).send({ error: `Недостаточно прав: ${required}` })
}
}
export default fp(authPlugin, { name: "auth" })
+51
View File
@@ -0,0 +1,51 @@
"use client"
import { useEffect, useState, type ReactNode } from "react"
import {
ensureAuthConfig,
getClaims,
getToken,
isAuthEnabled,
redirectToPortalLogin,
redirectToPortalLoginInteractive,
} from "@/lib/auth"
export function AuthGuard({ children }: { children: ReactNode }) {
const [ready, setReady] = useState(false)
useEffect(() => {
let cancelled = false
void (async () => {
await ensureAuthConfig()
if (cancelled) return
if (!isAuthEnabled()) {
setReady(true)
return
}
const claims = getClaims()
if (!getToken() || !claims) {
const ok = redirectToPortalLogin()
if (!ok) redirectToPortalLoginInteractive()
return
}
if (!claims.apps.includes("mm")) {
window.location.assign("/access-denied")
return
}
setReady(true)
})()
return () => {
cancelled = true
}
}, [])
if (!ready) {
return (
<div className="text-muted-foreground flex min-h-svh items-center justify-center p-6 text-sm">
Проверка сессии
</div>
)
}
return children
}
+42 -3
View File
@@ -5,6 +5,7 @@ import Link from "next/link"
import { useTheme } from "@/components/theme-provider"
import {
ChevronsUpDownIcon,
LogOutIcon,
MonitorIcon,
MoonIcon,
PaletteIcon,
@@ -13,6 +14,12 @@ import {
} from "lucide-react"
import { cn } from "@/lib/utils"
import {
ensureAuthConfig,
getClaims,
isAuthEnabled,
redirectToPortalLogout,
} from "@/lib/auth"
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
import { Button } from "@/components/ui/button"
import {
@@ -96,11 +103,37 @@ function ThemeSegmentedToggle() {
)
}
function initials(name: string, email: string): string {
const base = (name || email || "?").trim()
const parts = base.split(/\s+/).filter(Boolean)
if (parts.length >= 2) {
return (parts[0]![0]! + parts[1]![0]!).toUpperCase()
}
return base.slice(0, 2).toUpperCase()
}
export function NavUser() {
const { isMobile } = useSidebar()
const name = "Оператор"
const email = "локальный доступ"
const fallback = "ОП"
const [name, setName] = useState("Оператор")
const [email, setEmail] = useState("локальный доступ")
const [showLogout, setShowLogout] = useState(false)
useEffect(() => {
void ensureAuthConfig().then(() => {
const claims = getClaims()
if (claims) {
setName(claims.name || claims.email || "Пользователь")
setEmail(claims.email || "")
setShowLogout(isAuthEnabled())
} else if (isAuthEnabled()) {
setName("Сессия")
setEmail("требуется вход")
setShowLogout(true)
}
})
}, [])
const fallback = initials(name, email)
return (
<SidebarMenu>
@@ -155,6 +188,12 @@ export function NavUser() {
<ThemeSegmentedToggle />
</div>
</DropdownMenuItem>
{showLogout ? (
<DropdownMenuItem onClick={() => redirectToPortalLogout()}>
<LogOutIcon aria-hidden />
Выйти
</DropdownMenuItem>
) : null}
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
+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
+138
View File
@@ -0,0 +1,138 @@
# MikrotikManager behind an existing CDNManager Traefik (no second Traefik).
#
# Prerequisite: CDNManager stack is up — network `cdnmanager` and container
# `cdnmanager-traefik` already publish :80/:443 and watch Docker labels.
#
# On server:
# mkdir -p /opt/mmapp/data /opt/mmapp/state /opt/mmapp/updater
# cp deploy/docker-compose.traefik-cdn.yml /opt/mmapp/docker-compose.yml
# cp deploy/env.traefik.example /opt/mmapp/.env
# # set MM_DOMAIN / CORS_ORIGIN; CF_* / LETSENCRYPT_* not required here
# cp deploy/updater/targets.json.example /opt/mmapp/updater/targets.json
# docker login git.shx.one
# cd /opt/mmapp && docker compose pull && docker compose up -d
#
# Equivalent plain CLI: deploy/run-beside-cdn-traefik.sh
#
# Traffic:
# Internet → CDNManager Traefik (:80/:443) → mmapp-frontend:3000 (network cdnmanager)
# └─ rewrite /api,/health → backend:8000 (network mmapp)
services:
backend:
image: git.shx.one/denozord/mikrotikmanager-backend:${MM_BACKEND_IMAGE_TAG:-latest}
pull_policy: always
container_name: mmapp-backend
restart: unless-stopped
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:/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
cdnmanager: {}
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=cdnmanager
- 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"
networks:
mmapp:
name: mmapp
cdnmanager:
external: true
name: cdnmanager
+173
View File
@@ -0,0 +1,173 @@
# MikrotikManager + Traefik in one Compose stack (production).
# Docs: README.md (раздел «Прод-развёртывание Docker»)
#
# On server:
# mkdir -p /opt/mmapp/data /opt/mmapp/state
# cp deploy/docker-compose.traefik.yml /opt/mmapp/docker-compose.yml
# cp deploy/env.traefik.example /opt/mmapp/.env # fill secrets
# cp deploy/updater/targets.json.example /opt/mmapp/updater/targets.json
# docker login git.shx.one
# cd /opt/mmapp && docker compose pull && docker compose up -d
#
# Traffic:
# Internet → :80/:443 (Traefik) → frontend:3000
# └─ rewrite /api,/health → backend:8000 (internal)
# Backend is not published on the host — only Traefik exposes 80/443.
services:
traefik:
image: traefik:${TRAEFIK_IMAGE_TAG:-v3.7}
container_name: mmapp-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=mmapp
- --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:
- mmapp
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
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
# No host ports — frontend reaches backend on the Compose network.
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:/app/data
networks:
- mmapp
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
# No host ports — only Traefik publishes 80/443.
environment:
BACKEND_INTERNAL_URL: http://backend:8000
networks:
- mmapp
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=mmapp
- 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: mmapp_traefik_letsencrypt
networks:
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
+37
View File
@@ -0,0 +1,37 @@
# Production .env for MikrotikManager Traefik deploys. Do not commit.
# Use with:
# deploy/docker-compose.traefik.yml — own Traefik (standalone)
# deploy/docker-compose.traefik-cdn.yml — reuse CDNManager Traefik (network cdnmanager)
# deploy/run-beside-cdn-traefik.sh — plain docker CLI beside CDNManager
# --- Public host ---
MM_DOMAIN=mm.shnt.top
# Must match the public HTTPS origin of the UI (same as MM_DOMAIN with https://).
CORS_ORIGIN=https://mm.shnt.top
# --- Portal SSO (MM backend) ---
AUTH_REQUIRED=true
AUTH_JWT_SECRET=
AUTH_ISSUER=https://auth.shnt.top
AUTH_PORTAL_URL=https://auth.shnt.top
# --- Traefik / Let's Encrypt (only for docker-compose.traefik.yml standalone) ---
# Not required when attaching to CDNManager Traefik (traefik-cdn / run-beside script).
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
# --- Images ---
MM_BACKEND_IMAGE_TAG=latest
MM_FRONTEND_IMAGE_TAG=latest
MM_UPDATER_IMAGE_TAG=latest
# --- Updater (optional; needed for private registry pull) ---
REGISTRY_USERNAME=
REGISTRY_PASSWORD=
# POLL_INTERVAL_SECONDS=300
# HEALTH_TIMEOUT_SECONDS=120
# STOP_TIMEOUT_SECONDS=30
+185
View File
@@ -0,0 +1,185 @@
#!/usr/bin/env bash
# Run MikrotikManager beside an already-running CDNManager Traefik stack.
# Does NOT start a second Traefik — attaches frontend to network `cdnmanager`.
#
# Usage (on the VPS):
# curl -fsSL -o /tmp/run-beside-cdn-traefik.sh \
# https://git.shx.one/denozord/MikrotikManager/raw/branch/main/deploy/run-beside-cdn-traefik.sh
# chmod +x /tmp/run-beside-cdn-traefik.sh
# sudo MM_DOMAIN=mm.shnt.top /tmp/run-beside-cdn-traefik.sh
#
# Or copy this file to the server and run it.
#
# Env overrides:
# MM_DOMAIN=mm.shnt.top
# CORS_ORIGIN=https://mm.shnt.top
# MM_ROOT=/opt/mmapp
# MM_BACKEND_IMAGE_TAG=latest
# MM_FRONTEND_IMAGE_TAG=latest
# MM_UPDATER_IMAGE_TAG=latest
# REGISTRY_USERNAME=… REGISTRY_PASSWORD=… # optional private pull
set -euo pipefail
MM_DOMAIN="${MM_DOMAIN:-mm.shnt.top}"
CORS_ORIGIN="${CORS_ORIGIN:-https://${MM_DOMAIN}}"
MM_ROOT="${MM_ROOT:-/opt/mmapp}"
MM_BACKEND_IMAGE_TAG="${MM_BACKEND_IMAGE_TAG:-latest}"
MM_FRONTEND_IMAGE_TAG="${MM_FRONTEND_IMAGE_TAG:-latest}"
MM_UPDATER_IMAGE_TAG="${MM_UPDATER_IMAGE_TAG:-latest}"
REGISTRY="${REGISTRY:-git.shx.one}"
BACKEND_IMAGE="${REGISTRY}/denozord/mikrotikmanager-backend:${MM_BACKEND_IMAGE_TAG}"
FRONTEND_IMAGE="${REGISTRY}/denozord/mikrotikmanager-frontend:${MM_FRONTEND_IMAGE_TAG}"
UPDATER_IMAGE="${REGISTRY}/denozord/mikrotikmanager-updater:${MM_UPDATER_IMAGE_TAG}"
echo "==> Check CDNManager Traefik network"
if ! docker network inspect cdnmanager >/dev/null 2>&1; then
echo "ERROR: Docker network 'cdnmanager' not found." >&2
echo "Start CDNManager Traefik stack first (deploy/docker-compose.traefik.yml)." >&2
exit 1
fi
if ! docker inspect cdnmanager-traefik >/dev/null 2>&1; then
echo "WARN: container 'cdnmanager-traefik' not found — labels may not be routed." >&2
fi
echo "==> Prepare dirs under ${MM_ROOT}"
mkdir -p "${MM_ROOT}/data" "${MM_ROOT}/state" "${MM_ROOT}/updater"
TARGETS="${MM_ROOT}/updater/targets.json"
if [[ ! -f "${TARGETS}" ]]; then
cat >"${TARGETS}" <<'EOF'
{
"targets": [
{
"id": "backend",
"container_name": "mmapp-backend",
"image": "git.shx.one/denozord/mikrotikmanager-backend:latest",
"health": {
"type": "http",
"url": "http://backend:8000/health",
"expect_status": 200
}
},
{
"id": "frontend",
"container_name": "mmapp-frontend",
"image": "git.shx.one/denozord/mikrotikmanager-frontend:latest",
"health": {
"type": "http",
"url": "http://frontend:3000/dashboard",
"expect_status": 200
}
}
]
}
EOF
echo " wrote ${TARGETS}"
fi
echo "==> Ensure internal network mmapp"
docker network inspect mmapp >/dev/null 2>&1 || docker network create mmapp >/dev/null
if [[ -n "${REGISTRY_USERNAME:-}" && -n "${REGISTRY_PASSWORD:-}" ]]; then
echo "==> docker login ${REGISTRY}"
echo "${REGISTRY_PASSWORD}" | docker login "${REGISTRY}" -u "${REGISTRY_USERNAME}" --password-stdin
fi
echo "==> Pull images"
docker pull "${BACKEND_IMAGE}"
docker pull "${FRONTEND_IMAGE}"
docker pull "${UPDATER_IMAGE}"
stop_rm() {
local name="$1"
if docker inspect "${name}" >/dev/null 2>&1; then
docker stop "${name}" >/dev/null || true
docker rm "${name}" >/dev/null || true
fi
}
echo "==> Recreate mmapp-backend"
stop_rm mmapp-backend
docker run -d \
--name mmapp-backend \
--restart unless-stopped \
--network mmapp \
--network-alias backend \
-e NODE_ENV=production \
-e PORT=8000 \
-e DATABASE_PATH=/app/data/mikrotik.db \
-e "CORS_ORIGIN=${CORS_ORIGIN}" \
-e "AUTH_REQUIRED=${AUTH_REQUIRED:-true}" \
-e "AUTH_JWT_SECRET=${AUTH_JWT_SECRET:?set AUTH_JWT_SECRET}" \
-e "AUTH_ISSUER=${AUTH_ISSUER:-https://auth.shnt.top}" \
-e "AUTH_PORTAL_URL=${AUTH_PORTAL_URL:-https://auth.shnt.top}" \
-v "${MM_ROOT}/data:/app/data" \
--label mmapp.updater.managed=true \
--label mmapp.updater.target=backend \
--label "mmapp.updater.image=${BACKEND_IMAGE}" \
--health-cmd="node -e \"fetch('http://127.0.0.1:8000/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))\"" \
--health-interval=30s \
--health-timeout=5s \
--health-retries=3 \
--health-start-period=15s \
--log-driver json-file \
--log-opt max-size=10m \
--log-opt max-file=3 \
"${BACKEND_IMAGE}"
echo "==> Recreate mmapp-frontend (mmapp + cdnmanager)"
stop_rm mmapp-frontend
docker run -d \
--name mmapp-frontend \
--restart unless-stopped \
--network mmapp \
--network-alias frontend \
-e BACKEND_INTERNAL_URL=http://backend:8000 \
--label mmapp.updater.managed=true \
--label mmapp.updater.target=frontend \
--label "mmapp.updater.image=${FRONTEND_IMAGE}" \
--label traefik.enable=true \
--label traefik.docker.network=cdnmanager \
--label "traefik.http.routers.mmapp.rule=Host(\`${MM_DOMAIN}\`)" \
--label traefik.http.routers.mmapp.entrypoints=websecure \
--label traefik.http.routers.mmapp.tls=true \
--label traefik.http.routers.mmapp.tls.certresolver=letsencrypt \
--label traefik.http.services.mmapp.loadbalancer.server.port=3000 \
--health-cmd="node -e \"fetch('http://127.0.0.1:3000/dashboard').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))\"" \
--health-interval=30s \
--health-timeout=5s \
--health-retries=3 \
--health-start-period=25s \
--log-driver json-file \
--log-opt max-size=10m \
--log-opt max-file=3 \
"${FRONTEND_IMAGE}"
docker network connect cdnmanager mmapp-frontend
echo "==> Recreate mmapp-updater"
stop_rm mmapp-updater
docker run -d \
--name mmapp-updater \
--restart unless-stopped \
--network mmapp \
-e "REGISTRY=${REGISTRY}" \
-e "REGISTRY_USERNAME=${REGISTRY_USERNAME:-}" \
-e "REGISTRY_PASSWORD=${REGISTRY_PASSWORD:-}" \
-e "POLL_INTERVAL_SECONDS=${POLL_INTERVAL_SECONDS:-300}" \
-e "HEALTH_TIMEOUT_SECONDS=${HEALTH_TIMEOUT_SECONDS:-120}" \
-e "STOP_TIMEOUT_SECONDS=${STOP_TIMEOUT_SECONDS:-30}" \
-v /var/run/docker.sock:/var/run/docker.sock \
-v "${MM_ROOT}/state:/state" \
-v "${TARGETS}:/etc/updater/targets.json:ro" \
--log-driver json-file \
--log-opt max-size=10m \
--log-opt max-file=3 \
"${UPDATER_IMAGE}"
echo
echo "OK. UI: https://${MM_DOMAIN}"
echo "DNS: A/AAAA for ${MM_DOMAIN} → this VPS, Cloudflare proxy OFF (DNS only)."
echo "Check: curl -fsS https://${MM_DOMAIN}/health"
echo " docker ps --filter name=mmapp-"
echo " docker network inspect cdnmanager --format '{{range .Containers}}{{.Name}} {{end}}'"
+60
View File
@@ -0,0 +1,60 @@
# Интеграция auth-portal ↔ MikrotikManager
App id: **`mm`**. Зеркало на стороне портала: [`auth-portal/docs/integrate-mikrotikmanager.md`](https://git.shx.one/denozord/auth-portal/src/branch/main/docs/integrate-mikrotikmanager.md).
## Flow
```
Browser → MikrotikManager UI (нет token)
→ redirect AUTH_PORTAL_URL/?return_to=…/auth/callback
→ login
→ redirect return_to#access_token=…
→ /auth/callback сохраняет token (localStorage: mmapp_token)
→ API Authorization: Bearer <JWT>
```
## Permissions
| Permission | UI / API |
|------------|----------|
| `mm:dashboard:read` | `/dashboard`, sidebar-counts |
| `mm:servers:read` / `write` | `/servers` |
| `mm:filters:read` / `write` | filters / GRE |
| `mm:bgp:read` / `write` | BGP |
| `mm:uptime:read` / `write` | `/uptime` |
| `mm:traffic:read` / `write` | `/traffic` |
| `mm:alerts:read` / `write` | `/alerts` |
| `mm:backups:read` / `write` | `/backups` |
| `mm:certificates:read` / `write` | certificates |
| `mm:network:read` / `write` | network / OSPF |
| `mm:settings:admin` | `/settings`, system DB, scheduler |
## Env
```env
# backend
AUTH_REQUIRED=true
AUTH_JWT_SECRET=<тот же JWT_SECRET портала>
AUTH_ISSUER=https://auth.shnt.top
AUTH_PORTAL_URL=https://auth.shnt.top
CORS_ORIGIN=https://mm.shnt.top
```
```env
# frontend build (Docker)
NEXT_PUBLIC_AUTH_PORTAL_URL=https://auth.shnt.top
```
UI читает `GET /api/auth/config` (через Next rewrite) для `required` / `portal_url`.
В portal Admin → Apps выдайте app `mm` и нужные `mm:*`. URL в App Switcher: `https://mm.shnt.top`.
## Checklist
1. Общий `JWT_SECRET` / `AUTH_JWT_SECRET` и одинаковый `AUTH_ISSUER`
2. Origin MM в `RETURN_TO_ALLOWLIST` портала
3. Пользователю выдан app `mm`
4. `AUTH_REQUIRED=true` на backend
5. Logout → `{AUTH_PORTAL_URL}/logout`
Prod: [`deploy/docker-compose.cdn-mm.yml`](../deploy/docker-compose.cdn-mm.yml) или standalone Traefik compose.
+46 -19
View File
@@ -8,6 +8,21 @@ import {
parseAppSwitcherConfig,
type AppSwitcherConfig,
} from "@/lib/app-switcher-config"
import {
ensureAuthConfig,
getAuthConfigSync,
getClaims,
} from "@/lib/auth"
function filterByJwtApps(config: AppSwitcherConfig): AppSwitcherConfig {
const claims = getClaims()
if (!claims?.apps?.length) return config
const allowed = new Set(claims.apps)
const apps = config.apps.filter(
(a) => a.id === "mm" || allowed.has(a.id),
)
return { ...config, apps: apps.length > 0 ? apps : config.apps }
}
export function useAppSwitcherConfig(): {
config: AppSwitcherConfig
@@ -16,31 +31,43 @@ export function useAppSwitcherConfig(): {
const [config, setConfig] = useState<AppSwitcherConfig>(() =>
mergeWithLocalApp(DEFAULT_APP_SWITCHER_CONFIG),
)
const [isLoading, setIsLoading] = useState(Boolean(authPortalBaseUrl()))
const [isLoading, setIsLoading] = useState(true)
useEffect(() => {
const base = authPortalBaseUrl()
if (!base) {
setConfig(mergeWithLocalApp(DEFAULT_APP_SWITCHER_CONFIG))
setIsLoading(false)
return
}
let cancelled = false
setIsLoading(true)
fetch(`${base}/api/v1/app-switcher`)
.then((res) => (res.ok ? res.json() : Promise.reject()))
.then((raw: unknown) => {
void (async () => {
await ensureAuthConfig()
if (cancelled) return
const portal =
getAuthConfigSync()?.portalUrl || authPortalBaseUrl() || null
if (!portal) {
setConfig(filterByJwtApps(mergeWithLocalApp(DEFAULT_APP_SWITCHER_CONFIG)))
setIsLoading(false)
return
}
setIsLoading(true)
try {
const res = await fetch(`${portal}/api/v1/app-switcher`)
if (!res.ok) throw new Error("switcher fetch failed")
const raw: unknown = await res.json()
if (cancelled) return
const parsed = parseAppSwitcherConfig(raw)
setConfig(mergeWithLocalApp(parsed ?? DEFAULT_APP_SWITCHER_CONFIG))
})
.catch(() => {
if (!cancelled) setConfig(mergeWithLocalApp(DEFAULT_APP_SWITCHER_CONFIG))
})
.finally(() => {
setConfig(
filterByJwtApps(mergeWithLocalApp(parsed ?? DEFAULT_APP_SWITCHER_CONFIG)),
)
} catch {
if (!cancelled) {
setConfig(
filterByJwtApps(mergeWithLocalApp(DEFAULT_APP_SWITCHER_CONFIG)),
)
}
} finally {
if (!cancelled) setIsLoading(false)
})
}
})()
return () => {
cancelled = true
+264
View File
@@ -0,0 +1,264 @@
/** Portal JWT storage + claims helpers for MikrotikManager. */
const TOKEN_KEY = "mmapp_token"
const HANDOFF_KEY = "mmapp_auth_401_handoff"
const HANDOFF_AT_KEY = "mmapp_portal_handoff_at"
const HANDOFF_COOLDOWN_MS = 12_000
export type AccessClaims = {
sub: string
email: string
name: string
apps: string[]
permissions: string[]
is_admin?: boolean
iss?: string
exp?: number
}
export type RuntimeAuthConfig = {
required: boolean
portalUrl: string
}
let runtimeConfig: RuntimeAuthConfig | null = null
let runtimeConfigPromise: Promise<RuntimeAuthConfig> | null = null
function envPortalUrl(): string {
return (
process.env.NEXT_PUBLIC_AUTH_PORTAL_URL?.trim() || "http://localhost:5175"
).replace(/\/$/, "")
}
function envAuthEnabled(): boolean {
const v = process.env.NEXT_PUBLIC_AUTH_ENABLED?.trim().toLowerCase()
return v === "true" || v === "1"
}
/** Load auth mode from API (Docker-friendly). Falls back to NEXT_PUBLIC_*. */
export async function ensureAuthConfig(): Promise<RuntimeAuthConfig> {
if (runtimeConfig) return runtimeConfig
if (runtimeConfigPromise) return runtimeConfigPromise
runtimeConfigPromise = (async () => {
try {
const res = await fetch("/api/auth/config")
if (res.ok) {
const data = (await res.json()) as {
required?: boolean
portal_url?: string
}
runtimeConfig = {
required: Boolean(data.required) || envAuthEnabled(),
portalUrl: (data.portal_url || envPortalUrl()).replace(/\/$/, ""),
}
return runtimeConfig
}
} catch {
/* use env defaults */
}
runtimeConfig = {
required: envAuthEnabled(),
portalUrl: envPortalUrl(),
}
return runtimeConfig
})().finally(() => {
runtimeConfigPromise = null
})
return runtimeConfigPromise
}
export function getAuthConfigSync(): RuntimeAuthConfig | null {
return runtimeConfig
}
export function getToken(): string | null {
if (typeof window === "undefined") return null
return localStorage.getItem(TOKEN_KEY)
}
export function setToken(token: string) {
localStorage.setItem(TOKEN_KEY, token)
}
export function clearToken() {
localStorage.removeItem(TOKEN_KEY)
}
export function isAuthEnabled(): boolean {
if (runtimeConfig) return runtimeConfig.required
return envAuthEnabled()
}
export function authPortalUrl(): string {
if (runtimeConfig?.portalUrl) return runtimeConfig.portalUrl
return envPortalUrl()
}
export function isPortalHandoffCoolingDown(): boolean {
if (typeof window === "undefined") return false
const raw = sessionStorage.getItem(HANDOFF_AT_KEY)
if (!raw) return false
const at = Number(raw)
if (!Number.isFinite(at)) return false
return Date.now() - at < HANDOFF_COOLDOWN_MS
}
export function markPortalHandoff(): void {
sessionStorage.setItem(HANDOFF_KEY, "1")
sessionStorage.setItem(HANDOFF_AT_KEY, String(Date.now()))
}
export function clearPortalHandoffFlag(): void {
sessionStorage.removeItem(HANDOFF_KEY)
}
export function resetPortalHandoff(): void {
sessionStorage.removeItem(HANDOFF_KEY)
sessionStorage.removeItem(HANDOFF_AT_KEY)
}
export function redirectToPortalLogin(returnTo?: string): boolean {
if (isPortalHandoffCoolingDown()) {
clearToken()
return false
}
markPortalHandoff()
const callback = returnTo ?? `${window.location.origin}/auth/callback`
const url = new URL(authPortalUrl())
url.searchParams.set("return_to", callback)
window.location.assign(url.toString())
return true
}
export function redirectToPortalLoginInteractive(): void {
clearToken()
resetPortalHandoff()
window.location.assign(authPortalUrl())
}
export function redirectToPortalLogout(): void {
clearToken()
resetPortalHandoff()
window.location.assign(`${authPortalUrl()}/logout`)
}
export function parseHashToken(hash: string): {
accessToken: string | null
expiresAt: string | null
} {
const raw = hash.startsWith("#") ? hash.slice(1) : hash
const params = new URLSearchParams(raw)
return {
accessToken: params.get("access_token"),
expiresAt: params.get("expires_at"),
}
}
export function decodeClaims(token: string): AccessClaims | null {
try {
const parts = token.split(".")
if (parts.length < 2) return null
const json = atob(parts[1]!.replace(/-/g, "+").replace(/_/g, "/"))
const payload = JSON.parse(json) as Record<string, unknown>
return {
sub: String(payload.sub ?? ""),
email: String(payload.email ?? ""),
name: String(payload.name ?? ""),
apps: Array.isArray(payload.apps) ? payload.apps.map(String) : [],
permissions: Array.isArray(payload.permissions)
? payload.permissions.map(String)
: [],
is_admin: Boolean(payload.is_admin),
iss: payload.iss ? String(payload.iss) : undefined,
exp: typeof payload.exp === "number" ? payload.exp : undefined,
}
} catch {
return null
}
}
export function getClaims(): AccessClaims | null {
const token = getToken()
if (!token) return null
const claims = decodeClaims(token)
if (!claims) return null
if (claims.exp && claims.exp * 1000 < Date.now()) {
clearToken()
resetPortalHandoff()
return null
}
return claims
}
export function hasPermission(
granted: readonly string[],
required: string,
): boolean {
if (granted.includes(required)) return true
const parts = required.split(":")
if (parts.length !== 3) return false
const [app, section, action] = parts
if (action === "read") {
return (
granted.includes(`${app}:${section}:write`) ||
granted.includes(`${app}:${section}:admin`)
)
}
if (action === "write") {
return granted.includes(`${app}:${section}:admin`)
}
return false
}
export function can(required: string): boolean {
if (!isAuthEnabled()) return true
const claims = getClaims()
if (!claims) return false
if (!claims.apps.includes("mm")) return false
return hasPermission(claims.permissions, required)
}
export function permissionForPath(pathname: string): string | null {
if (pathname === "/" || pathname.startsWith("/dashboard")) {
return "mm:dashboard:read"
}
if (pathname.startsWith("/servers")) return "mm:servers:read"
if (pathname.startsWith("/filters") || pathname.startsWith("/gre")) {
return "mm:filters:read"
}
if (pathname.startsWith("/bgp")) return "mm:bgp:read"
if (pathname.startsWith("/uptime")) return "mm:uptime:read"
if (pathname.startsWith("/traffic")) return "mm:traffic:read"
if (pathname.startsWith("/alerts")) return "mm:alerts:read"
if (pathname.startsWith("/backups")) return "mm:backups:read"
if (pathname.startsWith("/certificates")) return "mm:certificates:read"
if (
pathname.startsWith("/network") ||
pathname.startsWith("/ospf") ||
pathname.startsWith("/route-optimizer")
) {
return "mm:network:read"
}
if (pathname.startsWith("/settings")) return "mm:settings:admin"
return "mm:dashboard:read"
}
export function firstAllowedPath(): string {
const candidates = [
"/dashboard",
"/servers",
"/filters",
"/uptime",
"/traffic",
"/alerts",
"/backups",
"/settings",
]
for (const path of candidates) {
const perm = permissionForPath(path)
if (!perm || can(perm)) return path
}
return "/access-denied"
}
+172 -18
View File
@@ -49,6 +49,7 @@
"version": "1.0.0",
"dependencies": {
"@fastify/cors": "^11.2.0",
"@fastify/jwt": "^10.2.2",
"@fastify/type-provider-zod": "^1.0.0",
"@mmapp/contracts": "1.0.0",
"acme-client": "^5.4.0",
@@ -56,6 +57,7 @@
"dotenv": "^16.4.7",
"drizzle-orm": "^0.45.2",
"fastify": "^5.8.5",
"fastify-plugin": "^5.1.0",
"pino-pretty": "^13.1.3",
"undici": "^8.1.0",
"zod": "^4.4.1"
@@ -64,6 +66,7 @@
"@types/better-sqlite3": "^7.6.13",
"@types/node": "^22.15.3",
"drizzle-kit": "^0.31.10",
"jose": "^6.2.11",
"tsx": "^4.19.3",
"typescript": "^5.8.3"
}
@@ -2018,6 +2021,45 @@
],
"license": "MIT"
},
"node_modules/@fastify/jwt": {
"version": "10.2.2",
"resolved": "https://registry.npmjs.org/@fastify/jwt/-/jwt-10.2.2.tgz",
"integrity": "sha512-UOYY5db2ttuWk2FcN5L6rawE0OFa4+QRJdsYEiHCBmf1GFLC9/k73f/mmv2dxIhS0b/02/62T0Hs6sk+w18Tyg==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "MIT",
"dependencies": {
"@fastify/error": "^4.2.0",
"@lukeed/ms": "^2.0.2",
"fast-jwt": "^6.2.4",
"fastify-plugin": "^6.0.0",
"steed": "^1.1.3"
}
},
"node_modules/@fastify/jwt/node_modules/fastify-plugin": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/fastify-plugin/-/fastify-plugin-6.0.0.tgz",
"integrity": "sha512-fZOty7z3O7vOliF6d8bHE3wiEh1KcNnKEQensSgTk9C1DvN6nRLS++XVd86v33Hw/8u9Un8A1zDrQ8ujcQDHEg==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "MIT"
},
"node_modules/@fastify/merge-json-schemas": {
"version": "0.2.1",
"resolved": "https://registry.npmjs.org/@fastify/merge-json-schemas/-/merge-json-schemas-0.2.1.tgz",
@@ -2814,6 +2856,15 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@lukeed/ms": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/@lukeed/ms/-/ms-2.0.2.tgz",
"integrity": "sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/@mmapp/contracts": {
"resolved": "packages/contracts",
"link": true
@@ -5077,6 +5128,18 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/asn1.js": {
"version": "5.4.1",
"resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-5.4.1.tgz",
"integrity": "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==",
"license": "MIT",
"dependencies": {
"bn.js": "^4.0.0",
"inherits": "^2.0.1",
"minimalistic-assert": "^1.0.0",
"safer-buffer": "^2.1.0"
}
},
"node_modules/asn1js": {
"version": "3.0.10",
"resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.10.tgz",
@@ -5275,6 +5338,12 @@
"readable-stream": "^3.4.0"
}
},
"node_modules/bn.js": {
"version": "4.12.5",
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.5.tgz",
"integrity": "sha512-3aRg6/JxfffFD+OlOjOFR3Vo79l39ooBTFucxx+MT3dhCtzn3EmiUPQo+6/OZuI2jbXi3YKgmiTFBgChQMwIRQ==",
"license": "MIT"
},
"node_modules/body-parser": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz",
@@ -6281,6 +6350,15 @@
"node": ">= 0.4"
}
},
"node_modules/ecdsa-sig-formatter": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
"integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
"license": "Apache-2.0",
"dependencies": {
"safe-buffer": "^5.0.1"
}
},
"node_modules/eciesjs": {
"version": "0.4.18",
"resolved": "https://registry.npmjs.org/eciesjs/-/eciesjs-0.4.18.tgz",
@@ -7256,6 +7334,22 @@
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
"license": "MIT"
},
"node_modules/fast-jwt": {
"version": "6.3.3",
"resolved": "https://registry.npmjs.org/fast-jwt/-/fast-jwt-6.3.3.tgz",
"integrity": "sha512-pQDXx7IHeZT4jSmpE9o80RrBqfrG4fPrl8anazSM5vErIdK1iCc13z/EWX+H0j7liWSRnwTpHswIKMeLYGAckw==",
"license": "Apache-2.0",
"dependencies": {
"@lukeed/ms": "^2.0.2",
"asn1.js": "^5.4.1",
"ecdsa-sig-formatter": "^1.0.11",
"mnemonist": "^0.40.0",
"safe-regex2": "^5.1.0"
},
"engines": {
"node": ">=20"
}
},
"node_modules/fast-levenshtein": {
"version": "2.0.6",
"resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz",
@@ -7318,6 +7412,18 @@
"fast-string-width": "^3.0.2"
}
},
"node_modules/fastfall": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/fastfall/-/fastfall-1.5.1.tgz",
"integrity": "sha512-KH6p+Z8AKPXnmA7+Iz2Lh8ARCMr+8WNPVludm1LGkZoD2MjY6LVnRMtTKhkdzI+jr0RzQWXKzKyBJm1zoHEL4Q==",
"license": "MIT",
"dependencies": {
"reusify": "^1.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/fastify": {
"version": "5.8.5",
"resolved": "https://registry.npmjs.org/fastify/-/fastify-5.8.5.tgz",
@@ -7379,6 +7485,16 @@
"node": ">=10"
}
},
"node_modules/fastparallel": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/fastparallel/-/fastparallel-2.4.1.tgz",
"integrity": "sha512-qUmhxPgNHmvRjZKBFUNI0oZuuH9OlSIOXmJ98lhKPxMZZ7zS/Fi0wRHOihDSz0R1YiIOjxzOY4bq65YTcdBi2Q==",
"license": "ISC",
"dependencies": {
"reusify": "^1.0.4",
"xtend": "^4.0.2"
}
},
"node_modules/fastq": {
"version": "1.20.1",
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz",
@@ -7388,6 +7504,16 @@
"reusify": "^1.0.4"
}
},
"node_modules/fastseries": {
"version": "1.7.2",
"resolved": "https://registry.npmjs.org/fastseries/-/fastseries-1.7.2.tgz",
"integrity": "sha512-dTPFrPGS8SNSzAt7u/CbMKCJ3s01N04s4JFbORHcmyvVfVKmbhMD1VtRbh5enGHxkaQDqWyLefiKOGGmohGDDQ==",
"license": "ISC",
"dependencies": {
"reusify": "^1.0.0",
"xtend": "^4.0.0"
}
},
"node_modules/fetch-blob": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz",
@@ -8839,9 +8965,9 @@
}
},
"node_modules/jose": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/jose/-/jose-6.2.2.tgz",
"integrity": "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ==",
"version": "6.2.11",
"resolved": "https://registry.npmjs.org/jose/-/jose-6.2.11.tgz",
"integrity": "sha512-A5NPn7g8EAzGU3IzRs+Yiq8K5n3ypYS75M5+KKiVHdUexfpWK1kP4ZMq7QnTGDoMj6TJ1dtcEJjW60yZDXS4hg==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/panva"
@@ -9596,6 +9722,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/minimalistic-assert": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz",
"integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==",
"license": "ISC"
},
"node_modules/minimatch": {
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
@@ -9624,6 +9756,15 @@
"integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",
"license": "MIT"
},
"node_modules/mnemonist": {
"version": "0.40.4",
"resolved": "https://registry.npmjs.org/mnemonist/-/mnemonist-0.40.4.tgz",
"integrity": "sha512-ZAv+KNavneRVzu4tUeOgzkScI3W5BGwZ3rkxIpKtzzVgfTtWQFN1CgX0U72cyvyh3iTuHL3SiSmrQxTlryEIcw==",
"license": "MIT",
"dependencies": {
"obliterator": "^2.0.4"
}
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@ -10103,6 +10244,12 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/obliterator": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/obliterator/-/obliterator-2.0.5.tgz",
"integrity": "sha512-42CPE9AhahZRsMNslczq0ctAEtqk8Eka26QofnqC346BZdHDySk3LWka23LI7ULIw11NmltpiLagIq8gBozxTw==",
"license": "MIT"
},
"node_modules/on-exit-leak-free": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz",
@@ -11792,6 +11939,19 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/steed": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/steed/-/steed-1.1.3.tgz",
"integrity": "sha512-EUkci0FAUiE4IvGTSKcDJIQ/eRUP2JJb56+fvZ4sdnguLTqIdKjSxUe138poW8mkvKWXW2sFPrgTsxqoISnmoA==",
"license": "MIT",
"dependencies": {
"fastfall": "^1.5.0",
"fastparallel": "^2.2.0",
"fastq": "^1.3.0",
"fastseries": "^1.7.0",
"reusify": "^1.0.0"
}
},
"node_modules/stop-iteration-iterator": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz",
@@ -13458,6 +13618,15 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/xtend": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
"integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
"license": "MIT",
"engines": {
"node": ">=0.4"
}
},
"node_modules/y18n": {
"version": "5.0.8",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
@@ -13634,21 +13803,6 @@
"dependencies": {
"zod": "^4.4.1"
}
},
"node_modules/@next/swc-win32-x64-msvc": {
"version": "16.2.4",
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.2.4.tgz",
"integrity": "sha512-kMVGgsqhO5YTYODD9IPGGhA6iprWidQckK3LmPeW08PIFENRmgfb4MjXHO+p//d+ts2rpjvK5gXWzXSMrPl9cw==",
"cpu": [
"x64"
],
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
}
}
}
+22 -4
View File
@@ -1,4 +1,10 @@
import { configuredBackendUrl } from "@/lib/backend-url"
import {
getToken,
isAuthEnabled,
redirectToPortalLogin,
redirectToPortalLoginInteractive,
} from "@/lib/auth"
export class ApiClientError extends Error {
constructor(
@@ -28,14 +34,26 @@ export async function requestJson<T>(
init?: RequestInit,
): Promise<T> {
const hasBody = init?.body != null
const headers = new Headers(init?.headers)
if (hasBody && !headers.has("Content-Type")) {
headers.set("Content-Type", "application/json")
}
const token = typeof window !== "undefined" ? getToken() : null
if (token && !headers.has("Authorization")) {
headers.set("Authorization", `Bearer ${token}`)
}
const res = await fetch(resolveRequestUrl(baseUrl, path), {
...init,
headers: {
...(hasBody ? { "Content-Type": "application/json" } : {}),
...(init?.headers ?? {}),
},
headers,
})
if (res.status === 401 && typeof window !== "undefined" && isAuthEnabled()) {
const ok = redirectToPortalLogin()
if (!ok) redirectToPortalLoginInteractive()
throw new ApiClientError("Unauthorized", 401)
}
if (res.status === 204) return undefined as T
const payload = await res.json().catch(() => undefined)