Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
883842636b | ||
|
|
b9f430de16 | ||
|
|
25e040a5dd | ||
|
|
f2df990746 | ||
|
|
77bc174e43 | ||
|
|
0e9349e508 | ||
|
|
0208aa4d7c | ||
|
|
42a2e18047 |
@@ -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]\\)\\)\")",
|
||||
|
||||
@@ -61,7 +61,16 @@ jobs:
|
||||
STAGING=".ci/docker/backend"
|
||||
rm -rf "$STAGING"
|
||||
mkdir -p "$STAGING/packages/contracts" "$STAGING/backend"
|
||||
cp package.json package-lock.json "$STAGING/"
|
||||
cp package-lock.json "$STAGING/"
|
||||
node <<'NODE'
|
||||
const fs = require("node:fs")
|
||||
const pkg = JSON.parse(fs.readFileSync("package.json", "utf8"))
|
||||
pkg.workspaces = ["packages/*", "backend"]
|
||||
pkg.dependencies = {}
|
||||
pkg.devDependencies = {}
|
||||
delete pkg.scripts
|
||||
fs.writeFileSync(".ci/docker/backend/package.json", `${JSON.stringify(pkg, null, 2)}\n`)
|
||||
NODE
|
||||
cp packages/contracts/package.json packages/contracts/tsconfig.json "$STAGING/packages/contracts/"
|
||||
cp -R packages/contracts/src "$STAGING/packages/contracts/"
|
||||
cp backend/package.json backend/tsconfig.json "$STAGING/backend/"
|
||||
@@ -70,6 +79,7 @@ jobs:
|
||||
cp -R backend/drizzle "$STAGING/backend/"
|
||||
fi
|
||||
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
@@ -177,6 +187,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: |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -135,6 +135,7 @@ sequenceDiagram
|
||||
- **Node.js 22** (как в `Dockerfile.frontend` и `backend/Dockerfile`).
|
||||
- **npm** с workspaces; установка из корня: `npm ci` или `npm install`.
|
||||
- Для нативной сборки `better-sqlite3` на Linux может понадобиться toolchain (`python3`, `make`, `g++`); в Docker-образе backend они уже ставятся.
|
||||
- Backend Docker-образ ставит только workspaces `backend` + `contracts` (без корневых Next/React deps); в production логи — JSON без `pino-pretty`.
|
||||
|
||||
### Запуск
|
||||
|
||||
@@ -219,7 +220,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` из корня).
|
||||
|
||||
### Прод-контейнеры
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ import { useDataSource } from "@/lib/data-source"
|
||||
import { listServers } from "@/shared/api/servers"
|
||||
import { toFrontendServer } from "@/entities/server/model/mappers"
|
||||
import { createBackupsAsync, deleteBackup, getBackupJob, getBackupScheduleSettings, listBackups, putBackupScheduleSettings, type BackupItem } from "@/shared/api/backups"
|
||||
import { requestBlob } from "@/shared/api/http-client"
|
||||
import { toast } from "sonner"
|
||||
import {
|
||||
Stepper,
|
||||
@@ -276,8 +277,7 @@ export default function BackupsPage() {
|
||||
}
|
||||
|
||||
async function handleDownload(id: string, fallbackFilename: string) {
|
||||
const res = await fetch(`${backendUrl.replace(/\/$/, "")}/api/backups/${id}/download`)
|
||||
if (!res.ok) throw new Error("Не удалось скачать файл")
|
||||
const res = await requestBlob(backendUrl, `/api/backups/${id}/download`)
|
||||
const blob = await res.blob()
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement("a")
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
XIcon, AlertCircleIcon,
|
||||
} from "lucide-react"
|
||||
import { useDataSource } from "@/lib/data-source"
|
||||
import { requestJson } from "@/shared/api/http-client"
|
||||
|
||||
// ─── types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -621,11 +622,7 @@ export default function BgpPage() {
|
||||
if (cancelled) return
|
||||
setLoading(true)
|
||||
setLiveError(null)
|
||||
fetch(`${backendUrl}/api/bgp/sessions`)
|
||||
.then(r => {
|
||||
if (!r.ok) throw new Error(`HTTP ${r.status}`)
|
||||
return r.json() as Promise<BackendBgpSession[]>
|
||||
})
|
||||
void requestJson<BackendBgpSession[]>(backendUrl, "/api/bgp/sessions")
|
||||
.then(data => {
|
||||
if (cancelled) return
|
||||
setLiveSessions(data.map(backendToFrontend))
|
||||
|
||||
+20
-17
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
} from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { useDataSource } from "@/lib/data-source"
|
||||
import { requestJson } from "@/shared/api/http-client"
|
||||
import { Flag } from "@/components/flag"
|
||||
import { readStoredRouteOptimizerSettings } from "@/lib/route-optimizer-data"
|
||||
|
||||
@@ -733,13 +734,14 @@ function InterfacesTab({
|
||||
const ra = readStoredRouteOptimizerSettings()
|
||||
setOptimizing(true)
|
||||
try {
|
||||
const r = await fetch(`${backendUrl}/api/servers/${filterServerId}/ospf/optimize`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ pingWeight: ra.pingWeight }),
|
||||
})
|
||||
if (!r.ok) throw new Error(`HTTP ${r.status}`)
|
||||
const data = await r.json() as BackendOspfOptimizeResponse
|
||||
const data = await requestJson<BackendOspfOptimizeResponse>(
|
||||
backendUrl,
|
||||
`/api/servers/${filterServerId}/ospf/optimize`,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify({ pingWeight: ra.pingWeight }),
|
||||
},
|
||||
)
|
||||
const byKey: Record<string, number> = {}
|
||||
data.interfaces.forEach((row) => {
|
||||
byKey[`${data.serverId}-${row.id}`] = row.optimalCost
|
||||
@@ -1120,8 +1122,7 @@ export default function OspfPage() {
|
||||
if (cancelled) return
|
||||
setLoading(true)
|
||||
setLiveError(null)
|
||||
fetch(`${backendUrl}/api/ospf/all`)
|
||||
.then(r => { if (!r.ok) throw new Error(`HTTP ${r.status}`); return r.json() as Promise<BackendOspfAll> })
|
||||
void requestJson<BackendOspfAll>(backendUrl, "/api/ospf/all")
|
||||
.then(data => {
|
||||
if (cancelled) return
|
||||
setLiveData(data); setFetchedAt(new Date()); setLoading(false)
|
||||
|
||||
@@ -875,8 +875,11 @@ export default function SettingsPage() {
|
||||
await evo.saveSettings(patch)
|
||||
setEvoKeyDraft("")
|
||||
markSaved()
|
||||
toast.success("Настройки EvoBGP сохранены")
|
||||
} catch (e) {
|
||||
setEvoSaveErr(e instanceof Error ? e.message : "Ошибка сохранения")
|
||||
const msg = e instanceof Error ? e.message : "Ошибка сохранения"
|
||||
setEvoSaveErr(msg)
|
||||
toast.error(msg)
|
||||
} finally {
|
||||
setEvoSaveBusy(false)
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Button } from "@/components/ui/button"
|
||||
import { servers as mockServers } from "@/lib/data"
|
||||
import { Flag } from "@/components/flag"
|
||||
import { useDataSource } from "@/lib/data-source"
|
||||
import { requestJson } from "@/shared/api/http-client"
|
||||
import {
|
||||
TrashIcon, RefreshCwIcon, CircleIcon, Loader2Icon,
|
||||
} from "lucide-react"
|
||||
@@ -259,12 +260,14 @@ function Terminal({
|
||||
if (isLive && server.backendId !== null) {
|
||||
setExecuting(true)
|
||||
try {
|
||||
const res = await fetch(`${backendUrl}/api/servers/${server.backendId}/exec`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ command: cmd }),
|
||||
})
|
||||
const data = await res.json() as { output?: string; error?: string }
|
||||
const data = await requestJson<{ output?: string; error?: string }>(
|
||||
backendUrl,
|
||||
`/api/servers/${server.backendId}/exec`,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify({ command: cmd }),
|
||||
},
|
||||
)
|
||||
const text = data.output ?? data.error ?? "(empty response)"
|
||||
const kind: TermLine["kind"] = text.startsWith("error:") ? "error" : "output"
|
||||
text.split("\n").forEach(line =>
|
||||
@@ -427,7 +430,7 @@ interface BackendServer {
|
||||
}
|
||||
|
||||
export default function TerminalPage() {
|
||||
const { mode, backendUrl } = useDataSource()
|
||||
const { mode, backendUrl, prefsHydrated } = useDataSource()
|
||||
const isLive = mode === "live"
|
||||
|
||||
// Server list state
|
||||
@@ -437,14 +440,13 @@ export default function TerminalPage() {
|
||||
|
||||
// Load servers from backend when in live mode
|
||||
useEffect(() => {
|
||||
if (!isLive) return
|
||||
if (!isLive || !prefsHydrated) return
|
||||
let cancelled = false
|
||||
queueMicrotask(() => {
|
||||
if (cancelled) return
|
||||
setServersLoading(true)
|
||||
fetch(`${backendUrl}/api/servers`)
|
||||
.then(r => r.json() as Promise<BackendServer[]>)
|
||||
.then(data => {
|
||||
void requestJson<BackendServer[]>(backendUrl, "/api/servers")
|
||||
.then((data) => {
|
||||
if (cancelled) return
|
||||
setLiveServers(data.map(s => ({
|
||||
uid: String(s.id),
|
||||
@@ -462,7 +464,7 @@ export default function TerminalPage() {
|
||||
.catch(() => { if (!cancelled) setServersLoading(false) })
|
||||
})
|
||||
return () => { cancelled = true }
|
||||
}, [isLive, backendUrl, refreshKey])
|
||||
}, [isLive, backendUrl, refreshKey, prefsHydrated])
|
||||
|
||||
const termServers: TermServer[] = isLive ? liveServers : mockServersToTermServers()
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
+19
-2
@@ -5,10 +5,23 @@ RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends python3 make g++ \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
WORKDIR /app
|
||||
# Do not set NODE_ENV=production here — npm would omit typescript needed for the build stage.
|
||||
COPY package.json package-lock.json ./
|
||||
COPY packages/contracts/package.json packages/contracts/
|
||||
COPY backend/package.json backend/
|
||||
RUN npm ci --workspace=@mmapp/contracts --workspace=mikrotik-manager-backend --include-workspace-root --ignore-scripts \
|
||||
# Drop root frontend deps (Next/React/UI) so backend image stays lean.
|
||||
RUN node -e "\
|
||||
const fs=require('fs');\
|
||||
const p=JSON.parse(fs.readFileSync('package.json','utf8'));\
|
||||
p.dependencies={};\
|
||||
p.devDependencies={};\
|
||||
delete p.scripts;\
|
||||
p.workspaces=['packages/*','backend'];\
|
||||
fs.writeFileSync('package.json', JSON.stringify(p,null,2)+'\\n');\
|
||||
"
|
||||
# Prefer npm ci; if lockfile rejects stripped root package.json, fall back to install.
|
||||
RUN (npm ci --workspace=@mmapp/contracts --workspace=mikrotik-manager-backend --ignore-scripts \
|
||||
|| npm install --workspace=@mmapp/contracts --workspace=mikrotik-manager-backend --ignore-scripts) \
|
||||
&& npm rebuild better-sqlite3
|
||||
|
||||
FROM deps AS build
|
||||
@@ -18,7 +31,9 @@ COPY packages/contracts packages/contracts
|
||||
COPY backend backend
|
||||
RUN npm run build -w @mmapp/contracts \
|
||||
&& npm run build -w mikrotik-manager-backend \
|
||||
&& npm prune --omit=dev
|
||||
&& npm prune --omit=dev \
|
||||
# npm may nest workspace deps (e.g. dotenv) under backend/node_modules — keep dir for COPY
|
||||
&& mkdir -p backend/node_modules
|
||||
|
||||
FROM node:22-bookworm-slim AS runner
|
||||
WORKDIR /app
|
||||
@@ -32,6 +47,8 @@ COPY --from=build /app/node_modules ./node_modules
|
||||
COPY --from=build /app/packages/contracts ./packages/contracts
|
||||
COPY --from=build /app/backend/dist ./backend/dist
|
||||
COPY --from=build /app/backend/package.json ./backend/package.json
|
||||
# Nested install from lockfile (dotenv etc.) — ESM resolves from /app/backend/dist → ../node_modules
|
||||
COPY --from=build /app/backend/node_modules ./backend/node_modules
|
||||
RUN mkdir -p /app/data
|
||||
EXPOSE 8000
|
||||
CMD ["node", "backend/dist/index.js"]
|
||||
|
||||
@@ -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,7 +23,7 @@
|
||||
"dotenv": "^16.4.7",
|
||||
"drizzle-orm": "^0.45.2",
|
||||
"fastify": "^5.8.5",
|
||||
"pino-pretty": "^13.1.3",
|
||||
"fastify-plugin": "^5.1.0",
|
||||
"undici": "^8.1.0",
|
||||
"zod": "^4.4.1"
|
||||
},
|
||||
@@ -29,6 +31,8 @@
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"@types/node": "^22.15.3",
|
||||
"drizzle-kit": "^0.31.10",
|
||||
"jose": "^6.2.11",
|
||||
"pino-pretty": "^13.1.3",
|
||||
"tsx": "^4.19.3",
|
||||
"typescript": "^5.8.3"
|
||||
}
|
||||
|
||||
+46
-2
@@ -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,
|
||||
}
|
||||
|
||||
+101
-62
@@ -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,109 @@ 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 usePrettyLogger =
|
||||
opts?.logger !== false && process.env.NODE_ENV !== "production"
|
||||
const app = Fastify({
|
||||
bodyLimit: 512 * 1024 * 1024,
|
||||
requestTimeout: 10 * 60 * 1000,
|
||||
logger:
|
||||
opts?.logger === false
|
||||
? false
|
||||
: usePrettyLogger
|
||||
? {
|
||||
transport: {
|
||||
target: "pino-pretty",
|
||||
options: {
|
||||
colorize: true,
|
||||
translateTime: "HH:MM:ss",
|
||||
ignore: "pid,hostname",
|
||||
},
|
||||
},
|
||||
}
|
||||
: true,
|
||||
})
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
@@ -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
|
||||
}
|
||||
@@ -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: "[email protected]",
|
||||
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: "[email protected]",
|
||||
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")
|
||||
@@ -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" })
|
||||
@@ -37,6 +37,12 @@ function normalizeBaseUrl(raw: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
/** Сырой API-ключ без префикса Bearer (иначе EvoBGP получит `Bearer Bearer …`). */
|
||||
function normalizeApiKey(raw: string): string {
|
||||
const trimmed = raw.trim()
|
||||
return trimmed.replace(/^Bearer\s+/i, "").trim()
|
||||
}
|
||||
|
||||
interface EvoCatalogRaw {
|
||||
modules: { items: Array<{ id: string; name: string; type: string }> }
|
||||
domains: {
|
||||
@@ -158,7 +164,7 @@ async function fetchEvoJson<T>(root: string, path: string, token: string): Promi
|
||||
function credentialsFromDb(): { root: string; apiKey: string } | null {
|
||||
const row = ensureEvobgpRow()
|
||||
const root = normalizeBaseUrl(row.baseUrl)
|
||||
const apiKey = row.apiKey.trim()
|
||||
const apiKey = normalizeApiKey(row.apiKey)
|
||||
if (!root || !apiKey) return null
|
||||
return { root, apiKey }
|
||||
}
|
||||
@@ -168,8 +174,8 @@ const evobgpRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
const row = ensureEvobgpRow()
|
||||
return reply.send({
|
||||
baseUrl: row.baseUrl ?? "",
|
||||
enabled: row.enabled ?? false,
|
||||
secretConfigured: Boolean(row.apiKey?.trim()),
|
||||
enabled: Boolean(row.enabled),
|
||||
secretConfigured: Boolean(normalizeApiKey(row.apiKey ?? "")),
|
||||
})
|
||||
})
|
||||
|
||||
@@ -183,10 +189,15 @@ const evobgpRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
let nextEnabled = cur.enabled
|
||||
let nextKey = cur.apiKey
|
||||
|
||||
if (parsed.data.baseUrl !== undefined) nextBase = parsed.data.baseUrl.trim()
|
||||
if (parsed.data.baseUrl !== undefined) {
|
||||
nextBase = normalizeBaseUrl(parsed.data.baseUrl)
|
||||
}
|
||||
if (parsed.data.enabled !== undefined) nextEnabled = parsed.data.enabled
|
||||
if (parsed.data.apiKey !== undefined) {
|
||||
nextKey = parsed.data.apiKey === null || parsed.data.apiKey === "" ? "" : parsed.data.apiKey.trim()
|
||||
nextKey =
|
||||
parsed.data.apiKey === null || parsed.data.apiKey === ""
|
||||
? ""
|
||||
: normalizeApiKey(parsed.data.apiKey)
|
||||
}
|
||||
|
||||
db.update(evobgpSettings)
|
||||
@@ -202,8 +213,8 @@ const evobgpRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
const row = ensureEvobgpRow()
|
||||
return reply.send({
|
||||
baseUrl: row.baseUrl ?? "",
|
||||
enabled: row.enabled ?? false,
|
||||
secretConfigured: Boolean(row.apiKey?.trim()),
|
||||
enabled: Boolean(row.enabled),
|
||||
secretConfigured: Boolean(normalizeApiKey(row.apiKey ?? "")),
|
||||
})
|
||||
})
|
||||
|
||||
@@ -223,7 +234,7 @@ const evobgpRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
const keyRaw =
|
||||
d.apiKey !== undefined && d.apiKey.trim() !== "" ? d.apiKey : row.apiKey
|
||||
const root = normalizeBaseUrl(urlRaw.trim())
|
||||
const token = keyRaw.trim()
|
||||
const token = normalizeApiKey(keyRaw)
|
||||
if (!root || !token) {
|
||||
return reply.status(400).send({
|
||||
error: "Нужны базовый URL и API-ключ (в форме или уже сохранённые в БД)",
|
||||
|
||||
@@ -161,11 +161,11 @@ async function listDnsRecordsByName(token: string, zoneId: string, fqdn: string)
|
||||
)
|
||||
}
|
||||
|
||||
async function upsertARecord(token: string, zoneId: string, fqdn: string, ip: string): Promise<void> {
|
||||
async function upsertARecord(token: string, zoneId: string, fqdn: string, ip: string): Promise<"updated" | "created" | "skipped_cname"> {
|
||||
const records = await listDnsRecordsByName(token, zoneId, fqdn)
|
||||
const existingA = records.find((record) => record.type === "A")
|
||||
if (existingA) {
|
||||
if (existingA.content === ip) return
|
||||
if (existingA.content === ip) return "updated"
|
||||
await cloudflareRequest<CfDnsRecord>(token, `/zones/${zoneId}/dns_records/${existingA.id}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({
|
||||
@@ -176,11 +176,12 @@ async function upsertARecord(token: string, zoneId: string, fqdn: string, ip: st
|
||||
proxied: false,
|
||||
}),
|
||||
})
|
||||
return
|
||||
return "updated"
|
||||
}
|
||||
|
||||
// CNAME на CN/SAN (алиас на канонический хост) — норма; A конфликтует с CNAME и для DNS-01 не нужен
|
||||
if (records.some((record) => record.type === "CNAME")) {
|
||||
throw new Error(`Для ${fqdn} уже есть CNAME в Cloudflare — A-запись не создана`)
|
||||
return "skipped_cname"
|
||||
}
|
||||
|
||||
await cloudflareRequest<{ id: string }>(token, `/zones/${zoneId}/dns_records`, {
|
||||
@@ -193,6 +194,7 @@ async function upsertARecord(token: string, zoneId: string, fqdn: string, ip: st
|
||||
proxied: false,
|
||||
}),
|
||||
})
|
||||
return "created"
|
||||
}
|
||||
|
||||
async function syncCertificateDomainRecords(
|
||||
@@ -200,11 +202,14 @@ async function syncCertificateDomainRecords(
|
||||
domains: string[],
|
||||
serverIp: string,
|
||||
defaultZoneId?: string,
|
||||
): Promise<void> {
|
||||
): Promise<{ skippedCname: string[] }> {
|
||||
const skippedCname: string[] = []
|
||||
for (const domain of domains) {
|
||||
const zoneId = await resolveZoneId(token, domain, defaultZoneId)
|
||||
await upsertARecord(token, zoneId, domain, serverIp)
|
||||
const result = await upsertARecord(token, zoneId, domain, serverIp)
|
||||
if (result === "skipped_cname") skippedCname.push(domain)
|
||||
}
|
||||
return { skippedCname }
|
||||
}
|
||||
|
||||
async function sleep(ms: number) {
|
||||
@@ -296,9 +301,26 @@ export async function issueCertificateWithCloudflareDns(params: {
|
||||
const finalized = await client.finalizeOrder(order, csr)
|
||||
const certPem = await client.getCertificate(finalized)
|
||||
|
||||
// A-sync опционален: DNS-01 уже завершён. CNAME на CN (msk2 → msk-gw02) не должен валить импорт.
|
||||
const clientRos = MikrotikClient.fromServer(params.server)
|
||||
const serverIp = await resolveServerPublicIp(params.server, clientRos)
|
||||
await syncCertificateDomainRecords(token, domains, serverIp, settings.defaultZoneId)
|
||||
try {
|
||||
params.onStep?.("dns_a_sync")
|
||||
const serverIp = await resolveServerPublicIp(params.server, clientRos)
|
||||
const { skippedCname } = await syncCertificateDomainRecords(
|
||||
token,
|
||||
domains,
|
||||
serverIp,
|
||||
settings.defaultZoneId,
|
||||
)
|
||||
if (skippedCname.length > 0) {
|
||||
params.onStep?.(
|
||||
`dns_a_sync_skip_cname:${skippedCname.join(",")}`,
|
||||
)
|
||||
}
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : "ошибка DNS A-sync"
|
||||
params.onStep?.(`dns_a_sync_warn:${msg}`)
|
||||
}
|
||||
|
||||
const trustStores = params.trustStore.filter(Boolean)
|
||||
const effectiveTrustStores = trustStores.length > 0 ? trustStores : ["www", "api"]
|
||||
|
||||
+11
-19
@@ -38,6 +38,7 @@ import {
|
||||
} from "lucide-react"
|
||||
import { useDataSource } from "@/lib/data-source"
|
||||
import { useEvoBGP } from "@/lib/evobgp-context"
|
||||
import { requestJson } from "@/shared/api/http-client"
|
||||
import {
|
||||
formatSidebarBadgeCount,
|
||||
mockSidebarBadgesByUrl,
|
||||
@@ -104,7 +105,7 @@ const navStructure: { label: string; items: NavItemBase[] }[] = [
|
||||
type LiveSidebarCounts = SidebarCountsDto & { greTunnels?: number; certificates?: number }
|
||||
|
||||
export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||
const { mode, backendUrl } = useDataSource()
|
||||
const { mode, backendUrl, prefsHydrated } = useDataSource()
|
||||
const evo = useEvoBGP()
|
||||
const [mounted, setMounted] = React.useState(false)
|
||||
const [liveCounts, setLiveCounts] = React.useState<LiveSidebarCounts | null>(null)
|
||||
@@ -118,30 +119,21 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||
}, [])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (mode !== "live") {
|
||||
setLiveCounts(null)
|
||||
if (!prefsHydrated || mode !== "live") {
|
||||
if (mode !== "live") setLiveCounts(null)
|
||||
return
|
||||
}
|
||||
let cancelled = false
|
||||
const load = async () => {
|
||||
try {
|
||||
const base = backendUrl.replace(/\/$/, "")
|
||||
const [cRes, gRes] = await Promise.all([
|
||||
fetch(`${base}/api/sidebar-counts`),
|
||||
fetch(`${base}/api/filters/gre-tunnels`),
|
||||
const [cJson, gJson] = await Promise.all([
|
||||
requestJson<SidebarCountsDto>(backendUrl, "/api/sidebar-counts"),
|
||||
requestJson<{ tunnels?: unknown[] }>(backendUrl, "/api/filters/gre-tunnels").catch(
|
||||
() => ({ tunnels: [] as unknown[] }),
|
||||
),
|
||||
])
|
||||
if (cancelled) return
|
||||
if (!cRes.ok) {
|
||||
setLiveCounts(null)
|
||||
return
|
||||
}
|
||||
const cJson = (await cRes.json()) as SidebarCountsDto
|
||||
let greN = 0
|
||||
if (gRes.ok) {
|
||||
const gJson = (await gRes.json()) as { tunnels?: unknown[] }
|
||||
greN = (gJson.tunnels ?? []).length
|
||||
}
|
||||
setLiveCounts({ ...cJson, greTunnels: greN })
|
||||
setLiveCounts({ ...cJson, greTunnels: (gJson.tunnels ?? []).length })
|
||||
} catch {
|
||||
if (!cancelled) setLiveCounts(null)
|
||||
}
|
||||
@@ -152,7 +144,7 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||
cancelled = true
|
||||
window.clearInterval(id)
|
||||
}
|
||||
}, [mode, backendUrl])
|
||||
}, [mode, backendUrl, prefsHydrated])
|
||||
|
||||
const navGroups = React.useMemo((): NavGroup[] => {
|
||||
function badgeFor(url: string): string | undefined {
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -9,6 +9,7 @@ import { cn } from "@/lib/utils"
|
||||
import { useDataSource } from "@/lib/data-source"
|
||||
import { filters, pingProbes, servers } from "@/lib/data"
|
||||
import type { SidebarCountsDto } from "@/lib/sidebar-badges"
|
||||
import { resolveApiUrl, requestJson } from "@/shared/api/http-client"
|
||||
|
||||
type MonitorMetric = {
|
||||
id: string
|
||||
@@ -78,11 +79,12 @@ function MetricCell({ metric }: { metric: MonitorMetric }) {
|
||||
|
||||
/** Live system monitor popover — app-shell-7. @see https://reui.io/preview/base/app-shell-7 */
|
||||
export function SystemMonitorPopover() {
|
||||
const { mode, backendUrl } = useDataSource()
|
||||
const { mode, backendUrl, prefsHydrated } = useDataSource()
|
||||
const [healthOk, setHealthOk] = useState<boolean | null>(null)
|
||||
const [counts, setCounts] = useState<SidebarCountsDto | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!prefsHydrated) return
|
||||
if (mode !== "live") {
|
||||
setHealthOk(true)
|
||||
setCounts({
|
||||
@@ -98,11 +100,10 @@ export function SystemMonitorPopover() {
|
||||
|
||||
let cancelled = false
|
||||
const load = async () => {
|
||||
const base = backendUrl.replace(/\/$/, "")
|
||||
try {
|
||||
const [hRes, cRes] = await Promise.all([
|
||||
fetch(`${base}/health`),
|
||||
fetch(`${base}/api/sidebar-counts`),
|
||||
const [hRes, counts] = await Promise.all([
|
||||
fetch(resolveApiUrl(backendUrl, "/health"), { signal: AbortSignal.timeout(3000) }),
|
||||
requestJson<SidebarCountsDto>(backendUrl, "/api/sidebar-counts"),
|
||||
])
|
||||
if (cancelled) return
|
||||
if (hRes.ok) {
|
||||
@@ -111,11 +112,7 @@ export function SystemMonitorPopover() {
|
||||
} else {
|
||||
setHealthOk(false)
|
||||
}
|
||||
if (cRes.ok) {
|
||||
setCounts((await cRes.json()) as SidebarCountsDto)
|
||||
} else {
|
||||
setCounts(null)
|
||||
}
|
||||
setCounts(counts)
|
||||
} catch {
|
||||
if (!cancelled) {
|
||||
setHealthOk(false)
|
||||
@@ -129,7 +126,7 @@ export function SystemMonitorPopover() {
|
||||
cancelled = true
|
||||
window.clearInterval(id)
|
||||
}
|
||||
}, [mode, backendUrl])
|
||||
}, [mode, backendUrl, prefsHydrated])
|
||||
|
||||
const serversCount = counts?.servers ?? 0
|
||||
const filtersCount = counts?.filterRules ?? 0
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
# CDN Manager + MikrotikManager + one Traefik (production).
|
||||
#
|
||||
# Hosts:
|
||||
# https://cdn.shnt.top → cdnmanager:8080
|
||||
# https://mm.shnt.top → mmapp-frontend:3000 → backend:8000 (internal rewrite)
|
||||
#
|
||||
# On server:
|
||||
# mkdir -p /opt/cdn-mm/{data/cdn,data/mm,state,updater}
|
||||
# cp deploy/docker-compose.cdn-mm.yml /opt/cdn-mm/docker-compose.yml
|
||||
# cp deploy/env.cdn-mm.example /opt/cdn-mm/.env # fill secrets
|
||||
# # targets.json:
|
||||
# # cp deploy/updater/targets.json.example /opt/cdn-mm/updater/targets.json
|
||||
# # (в CDNManager-репо скачайте тот же файл из MikrotikManager)
|
||||
# docker login git.shx.one
|
||||
# cd /opt/cdn-mm && docker compose pull && docker compose up -d
|
||||
#
|
||||
# DNS (Cloudflare DNS only, grey cloud):
|
||||
# A/AAAA cdn.shnt.top → VPS
|
||||
# A/AAAA mm.shnt.top → VPS
|
||||
#
|
||||
# Do not run a second Traefik (standalone CDNManager or MikrotikManager compose)
|
||||
# on the same host ports while this stack is up.
|
||||
|
||||
services:
|
||||
traefik:
|
||||
image: traefik:${TRAEFIK_IMAGE_TAG:-v3.7}
|
||||
container_name: cdn-mm-traefik
|
||||
restart: unless-stopped
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
ports:
|
||||
- "${TRAEFIK_HTTP_PORT:-80}:80"
|
||||
- "${TRAEFIK_HTTPS_PORT:-443}:443"
|
||||
environment:
|
||||
CF_DNS_API_TOKEN: ${CF_DNS_API_TOKEN:?set CF_DNS_API_TOKEN in .env}
|
||||
# Optional if DNS token lacks Zone:Read:
|
||||
# CF_ZONE_API_TOKEN: ${CF_ZONE_API_TOKEN:-}
|
||||
command:
|
||||
- --log.level=${TRAEFIK_LOG_LEVEL:-INFO}
|
||||
- --api.dashboard=false
|
||||
- --providers.docker=true
|
||||
- --providers.docker.exposedbydefault=false
|
||||
- --providers.docker.network=edge
|
||||
- --entrypoints.web.address=:80
|
||||
- --entrypoints.websecure.address=:443
|
||||
- --entrypoints.web.http.redirections.entrypoint.to=websecure
|
||||
- --entrypoints.web.http.redirections.entrypoint.scheme=https
|
||||
- --certificatesresolvers.letsencrypt.acme.email=${LETSENCRYPT_EMAIL:?set LETSENCRYPT_EMAIL in .env}
|
||||
- --certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json
|
||||
- --certificatesresolvers.letsencrypt.acme.dnschallenge=true
|
||||
- --certificatesresolvers.letsencrypt.acme.dnschallenge.provider=cloudflare
|
||||
- --certificatesresolvers.letsencrypt.acme.dnschallenge.delaybeforecheck=15
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
- traefik_letsencrypt:/letsencrypt
|
||||
networks:
|
||||
- edge
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
|
||||
# --- CDN Manager -----------------------------------------------------------
|
||||
cdnmanager:
|
||||
# cdnmanager и cdn-manager — один образ (алиас для drop-in).
|
||||
image: git.shx.one/denozord/cdnmanager:${CDN_IMAGE_TAG:-latest}
|
||||
pull_policy: always
|
||||
container_name: cdnmanager
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- traefik
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
DATABASE_URL: sqlite:/data/app.db
|
||||
STATIC_DIR: /app/static
|
||||
SERVER_PORT: "8080"
|
||||
NODE_ENV: production
|
||||
LOG_LEVEL: ${LOG_LEVEL:-info}
|
||||
CLOUDFLARE_API_TOKEN: ${CLOUDFLARE_API_TOKEN:?set CLOUDFLARE_API_TOKEN in .env}
|
||||
JWT_SECRET: ${JWT_SECRET:-}
|
||||
AUTH_REQUIRED: ${AUTH_REQUIRED:-true}
|
||||
AUTH_JWT_SECRET: ${AUTH_JWT_SECRET:?set AUTH_JWT_SECRET in .env}
|
||||
AUTH_ISSUER: ${AUTH_ISSUER:-https://auth.shnt.top}
|
||||
AUTH_PORTAL_URL: ${AUTH_PORTAL_URL:-https://auth.shnt.top}
|
||||
AUTH_AUDIT_INGEST_SECRET: ${AUTH_AUDIT_INGEST_SECRET:-}
|
||||
ADMIN_USERNAME: ${ADMIN_USERNAME:-admin}
|
||||
ADMIN_PASSWORD_HASH: ${ADMIN_PASSWORD_HASH:-}
|
||||
volumes:
|
||||
- ./data/cdn:/data
|
||||
networks:
|
||||
- edge
|
||||
labels:
|
||||
- traefik.enable=true
|
||||
- traefik.docker.network=edge
|
||||
- traefik.http.routers.cdnmanager.rule=Host(`${CDN_DOMAIN:-cdn.shnt.top}`)
|
||||
- traefik.http.routers.cdnmanager.entrypoints=websecure
|
||||
- traefik.http.routers.cdnmanager.tls=true
|
||||
- traefik.http.routers.cdnmanager.tls.certresolver=letsencrypt
|
||||
- traefik.http.services.cdnmanager.loadbalancer.server.port=8080
|
||||
healthcheck:
|
||||
test:
|
||||
[
|
||||
"CMD",
|
||||
"node",
|
||||
"-e",
|
||||
"fetch('http://127.0.0.1:8080/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))",
|
||||
]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 15s
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
|
||||
# --- MikrotikManager -------------------------------------------------------
|
||||
backend:
|
||||
image: git.shx.one/denozord/mikrotikmanager-backend:${MM_BACKEND_IMAGE_TAG:-latest}
|
||||
pull_policy: always
|
||||
container_name: mmapp-backend
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- traefik
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
PORT: "8000"
|
||||
DATABASE_PATH: /app/data/mikrotik.db
|
||||
CORS_ORIGIN: ${CORS_ORIGIN:-https://mm.shnt.top}
|
||||
AUTH_REQUIRED: ${AUTH_REQUIRED:-true}
|
||||
AUTH_JWT_SECRET: ${AUTH_JWT_SECRET:?set AUTH_JWT_SECRET in .env}
|
||||
AUTH_ISSUER: ${AUTH_ISSUER:-https://auth.shnt.top}
|
||||
AUTH_PORTAL_URL: ${AUTH_PORTAL_URL:-https://auth.shnt.top}
|
||||
volumes:
|
||||
- ./data/mm:/app/data
|
||||
networks:
|
||||
mmapp:
|
||||
aliases:
|
||||
- backend
|
||||
labels:
|
||||
mmapp.updater.managed: "true"
|
||||
mmapp.updater.target: backend
|
||||
mmapp.updater.image: git.shx.one/denozord/mikrotikmanager-backend:${MM_BACKEND_IMAGE_TAG:-latest}
|
||||
healthcheck:
|
||||
test:
|
||||
[
|
||||
"CMD",
|
||||
"node",
|
||||
"-e",
|
||||
"fetch('http://127.0.0.1:8000/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))",
|
||||
]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 15s
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
|
||||
frontend:
|
||||
image: git.shx.one/denozord/mikrotikmanager-frontend:${MM_FRONTEND_IMAGE_TAG:-latest}
|
||||
pull_policy: always
|
||||
container_name: mmapp-frontend
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- backend
|
||||
environment:
|
||||
BACKEND_INTERNAL_URL: http://backend:8000
|
||||
networks:
|
||||
mmapp:
|
||||
aliases:
|
||||
- frontend
|
||||
edge: {}
|
||||
labels:
|
||||
- mmapp.updater.managed=true
|
||||
- mmapp.updater.target=frontend
|
||||
- mmapp.updater.image=git.shx.one/denozord/mikrotikmanager-frontend:${MM_FRONTEND_IMAGE_TAG:-latest}
|
||||
- traefik.enable=true
|
||||
- traefik.docker.network=edge
|
||||
- traefik.http.routers.mmapp.rule=Host(`${MM_DOMAIN:-mm.shnt.top}`)
|
||||
- traefik.http.routers.mmapp.entrypoints=websecure
|
||||
- traefik.http.routers.mmapp.tls=true
|
||||
- traefik.http.routers.mmapp.tls.certresolver=letsencrypt
|
||||
- traefik.http.services.mmapp.loadbalancer.server.port=3000
|
||||
healthcheck:
|
||||
test:
|
||||
[
|
||||
"CMD",
|
||||
"node",
|
||||
"-e",
|
||||
"fetch('http://127.0.0.1:3000/dashboard').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))",
|
||||
]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 25s
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
|
||||
updater:
|
||||
image: git.shx.one/denozord/mikrotikmanager-updater:${MM_UPDATER_IMAGE_TAG:-latest}
|
||||
pull_policy: always
|
||||
container_name: mmapp-updater
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- frontend
|
||||
environment:
|
||||
REGISTRY: git.shx.one
|
||||
REGISTRY_USERNAME: ${REGISTRY_USERNAME:-}
|
||||
REGISTRY_PASSWORD: ${REGISTRY_PASSWORD:-}
|
||||
POLL_INTERVAL_SECONDS: ${POLL_INTERVAL_SECONDS:-300}
|
||||
HEALTH_TIMEOUT_SECONDS: ${HEALTH_TIMEOUT_SECONDS:-120}
|
||||
STOP_TIMEOUT_SECONDS: ${STOP_TIMEOUT_SECONDS:-30}
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
- ./state:/state
|
||||
- ./updater/targets.json:/etc/updater/targets.json:ro
|
||||
networks:
|
||||
- mmapp
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
|
||||
volumes:
|
||||
traefik_letsencrypt:
|
||||
name: cdn_mm_traefik_letsencrypt
|
||||
|
||||
networks:
|
||||
edge:
|
||||
name: edge
|
||||
mmapp:
|
||||
name: mmapp
|
||||
@@ -0,0 +1,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
|
||||
@@ -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
|
||||
@@ -0,0 +1,52 @@
|
||||
# Production .env for deploy/docker-compose.cdn-mm.yml
|
||||
# (CDN Manager + MikrotikManager + one Traefik).
|
||||
# Copy to /opt/cdn-mm/.env and fill secrets. Do not commit.
|
||||
|
||||
# --- Traefik / Let's Encrypt (Cloudflare DNS-01) ---
|
||||
# Token for ACME only (Zone DNS Edit). Separate from CLOUDFLARE_API_TOKEN below.
|
||||
CF_DNS_API_TOKEN=
|
||||
[email protected]
|
||||
# TRAEFIK_IMAGE_TAG=v3.7
|
||||
# TRAEFIK_HTTP_PORT=80
|
||||
# TRAEFIK_HTTPS_PORT=443
|
||||
# TRAEFIK_LOG_LEVEL=INFO
|
||||
|
||||
# --- Public hosts ---
|
||||
CDN_DOMAIN=cdn.shnt.top
|
||||
MM_DOMAIN=mm.shnt.top
|
||||
# Must match MM UI origin (https:// + MM_DOMAIN).
|
||||
CORS_ORIGIN=https://mm.shnt.top
|
||||
|
||||
# --- Images ---
|
||||
CDN_IMAGE_TAG=latest
|
||||
# drop-in alias (same manifest): git.shx.one/denozord/cdn-manager
|
||||
MM_BACKEND_IMAGE_TAG=latest
|
||||
MM_FRONTEND_IMAGE_TAG=latest
|
||||
MM_UPDATER_IMAGE_TAG=latest
|
||||
|
||||
# --- CDN Manager ---
|
||||
CLOUDFLARE_API_TOKEN=
|
||||
LOG_LEVEL=info
|
||||
NODE_ENV=production
|
||||
|
||||
# Portal SSO — used by CDN Manager and MikrotikManager backend
|
||||
AUTH_REQUIRED=true
|
||||
# Same HS256 secret as auth-portal JWT_SECRET (required)
|
||||
AUTH_JWT_SECRET=
|
||||
# Optional alias — CDN Manager also reads JWT_SECRET
|
||||
JWT_SECRET=
|
||||
AUTH_ISSUER=https://auth.shnt.top
|
||||
AUTH_PORTAL_URL=https://auth.shnt.top
|
||||
# Shared with auth-portal AUDIT_INGEST_SECRET (optional, CDN Manager)
|
||||
AUTH_AUDIT_INGEST_SECRET=
|
||||
|
||||
# Legacy local admin (CDN) — only when AUTH_REQUIRED=false
|
||||
ADMIN_USERNAME=admin
|
||||
ADMIN_PASSWORD_HASH=
|
||||
|
||||
# --- MikrotikManager updater (optional; private registry pull) ---
|
||||
REGISTRY_USERNAME=
|
||||
REGISTRY_PASSWORD=
|
||||
# POLL_INTERVAL_SECONDS=300
|
||||
# HEALTH_TIMEOUT_SECONDS=120
|
||||
# STOP_TIMEOUT_SECONDS=30
|
||||
@@ -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=
|
||||
[email protected]
|
||||
# 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
|
||||
@@ -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}}'"
|
||||
@@ -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
@@ -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
@@ -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"
|
||||
}
|
||||
+15
-1
@@ -26,12 +26,26 @@ export function isBackendUrlLocked(): boolean {
|
||||
return cfg.kind === "same-origin" || cfg.kind === "fixed"
|
||||
}
|
||||
|
||||
function isLoopbackHost(hostname: string): boolean {
|
||||
return hostname === "localhost" || hostname === "127.0.0.1"
|
||||
}
|
||||
|
||||
/** Prefer same-origin when the UI is not on loopback — never point the browser at localhost. */
|
||||
export function resolveStoredBackendUrl(stored: string | null): string {
|
||||
const cfg = configuredBackendUrl()
|
||||
if (cfg.kind === "fixed") return cfg.url
|
||||
if (cfg.kind === "same-origin" && typeof window !== "undefined") {
|
||||
if (cfg.kind === "same-origin") {
|
||||
if (typeof window !== "undefined") return window.location.origin
|
||||
return ""
|
||||
}
|
||||
if (typeof window !== "undefined" && !isLoopbackHost(window.location.hostname)) {
|
||||
return window.location.origin
|
||||
}
|
||||
const trimmed = stored?.trim().replace(/\/$/, "")
|
||||
if (trimmed && /^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/i.test(trimmed)) {
|
||||
if (typeof window !== "undefined" && !isLoopbackHost(window.location.hostname)) {
|
||||
return window.location.origin
|
||||
}
|
||||
}
|
||||
return trimmed || LOCAL_DEFAULT_BACKEND_URL
|
||||
}
|
||||
|
||||
+11
-11
@@ -9,6 +9,7 @@ import {
|
||||
LOCAL_DEFAULT_BACKEND_URL,
|
||||
resolveStoredBackendUrl,
|
||||
} from "@/lib/backend-url"
|
||||
import { resolveApiUrl } from "@/shared/api/http-client"
|
||||
|
||||
// ── types ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -49,8 +50,13 @@ function readStoredMode(): DataSourceMode {
|
||||
return defaultDataSourceMode()
|
||||
}
|
||||
|
||||
function readStoredBackendUrl(): string {
|
||||
if (typeof window === "undefined") return LOCAL_DEFAULT_BACKEND_URL
|
||||
function initialBackendUrl(): string {
|
||||
if (typeof window === "undefined") {
|
||||
const cfg = configuredBackendUrl()
|
||||
if (cfg.kind === "same-origin") return ""
|
||||
if (cfg.kind === "fixed") return cfg.url
|
||||
return LOCAL_DEFAULT_BACKEND_URL
|
||||
}
|
||||
return resolveStoredBackendUrl(localStorage.getItem(LS_BACKEND))
|
||||
}
|
||||
|
||||
@@ -60,7 +66,7 @@ function normalizeBackendUrl(url: string): string {
|
||||
|
||||
export function DataSourceProvider({ children }: { children: React.ReactNode }) {
|
||||
const [mode, setModeState] = useState<DataSourceMode>(defaultDataSourceMode)
|
||||
const [backendUrl, setBackendUrlState] = useState(LOCAL_DEFAULT_BACKEND_URL)
|
||||
const [backendUrl, setBackendUrlState] = useState(initialBackendUrl)
|
||||
const [prefsHydrated, setPrefsHydrated] = useState(false)
|
||||
const [backendStatus, setBackendStatus] = useState<boolean | undefined>(undefined)
|
||||
const backendUrlLocked = isBackendUrlLocked()
|
||||
@@ -68,10 +74,7 @@ export function DataSourceProvider({ children }: { children: React.ReactNode })
|
||||
|
||||
useEffect(() => {
|
||||
const storedMode = readStoredMode()
|
||||
let url = readStoredBackendUrl()
|
||||
if (configuredBackendUrl().kind === "same-origin") {
|
||||
url = window.location.origin
|
||||
}
|
||||
const url = resolveStoredBackendUrl(localStorage.getItem(LS_BACKEND))
|
||||
setModeState(storedMode)
|
||||
setBackendUrlState(url)
|
||||
setPrefsHydrated(true)
|
||||
@@ -91,10 +94,7 @@ export function DataSourceProvider({ children }: { children: React.ReactNode })
|
||||
}, [backendUrlLocked])
|
||||
|
||||
const checkBackend = useCallback(async () => {
|
||||
const healthUrl =
|
||||
configuredBackendUrl().kind === "same-origin"
|
||||
? "/health"
|
||||
: `${normalizeBackendUrl(backendUrl)}/health`
|
||||
const healthUrl = resolveApiUrl(backendUrl, "/health")
|
||||
try {
|
||||
const res = await fetch(healthUrl, { signal: AbortSignal.timeout(3000) })
|
||||
setBackendStatus(res.ok)
|
||||
|
||||
+56
-57
@@ -10,6 +10,7 @@ import {
|
||||
} from "react"
|
||||
import { useDataSource } from "@/lib/data-source"
|
||||
import type { Domain, IpRange, Asn } from "@/lib/data"
|
||||
import { ApiClientError, requestJson } from "@/shared/api/http-client"
|
||||
|
||||
export interface EvoBgpCommunityRow {
|
||||
id: string
|
||||
@@ -66,6 +67,12 @@ interface EvoBgpContextValue {
|
||||
|
||||
const EvoBgpContext = createContext<EvoBgpContextValue | null>(null)
|
||||
|
||||
function errorMessage(e: unknown, fallback: string): string {
|
||||
if (e instanceof ApiClientError) return e.message || fallback
|
||||
if (e instanceof Error) return e.message || fallback
|
||||
return fallback
|
||||
}
|
||||
|
||||
export function EvoBGPProvider({ children }: { children: React.ReactNode }) {
|
||||
const { mode, backendUrl, backendStatus } = useDataSource()
|
||||
const [baseUrl, setBaseUrlState] = useState("")
|
||||
@@ -86,24 +93,15 @@ export function EvoBGPProvider({ children }: { children: React.ReactNode }) {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const res = await fetch(`${backendUrl.replace(/\/$/, "")}/api/evobgp/catalog`, {
|
||||
method: "POST",
|
||||
})
|
||||
const text = await res.text()
|
||||
if (!res.ok) {
|
||||
let msg = res.statusText
|
||||
try {
|
||||
const j = JSON.parse(text) as { error?: string; detail?: string }
|
||||
msg = j.error ?? j.detail ?? msg
|
||||
} catch {
|
||||
if (text) msg = text
|
||||
}
|
||||
throw new Error(msg || "Ошибка EvoBGP")
|
||||
}
|
||||
setSnapshot(JSON.parse(text) as EvoBgpCatalogSnapshot)
|
||||
const data = await requestJson<EvoBgpCatalogSnapshot>(
|
||||
backendUrl,
|
||||
"/api/evobgp/catalog",
|
||||
{ method: "POST" },
|
||||
)
|
||||
setSnapshot(data)
|
||||
} catch (e) {
|
||||
setSnapshot(null)
|
||||
setError(e instanceof Error ? e.message : "Ошибка загрузки")
|
||||
setError(errorMessage(e, "Ошибка загрузки"))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
@@ -119,16 +117,19 @@ export function EvoBGPProvider({ children }: { children: React.ReactNode }) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
const res = await fetch(`${backendUrl.replace(/\/$/, "")}/api/evobgp/settings`)
|
||||
if (!res.ok) throw new Error(await res.text())
|
||||
const data = (await res.json()) as EvoBgpSettingsDto
|
||||
const data = await requestJson<EvoBgpSettingsDto>(
|
||||
backendUrl,
|
||||
"/api/evobgp/settings",
|
||||
)
|
||||
setBaseUrlState(data.baseUrl ?? "")
|
||||
setEnabledState(data.enabled ?? false)
|
||||
setSecretConfigured(data.secretConfigured ?? false)
|
||||
setEnabledState(Boolean(data.enabled))
|
||||
setSecretConfigured(Boolean(data.secretConfigured))
|
||||
setSettingsLoaded(true)
|
||||
await pullCatalog(data.enabled ?? false)
|
||||
} catch {
|
||||
setError(null)
|
||||
await pullCatalog(Boolean(data.enabled))
|
||||
} catch (e) {
|
||||
setSettingsLoaded(true)
|
||||
setError(errorMessage(e, "Не удалось загрузить настройки EvoBGP"))
|
||||
}
|
||||
}, [mode, backendStatus, backendUrl, pullCatalog])
|
||||
|
||||
@@ -140,27 +141,25 @@ export function EvoBGPProvider({ children }: { children: React.ReactNode }) {
|
||||
|
||||
const saveSettings = useCallback(
|
||||
async (patch: EvoBgpSavePayload) => {
|
||||
const res = await fetch(`${backendUrl.replace(/\/$/, "")}/api/evobgp/settings`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(patch),
|
||||
})
|
||||
const text = await res.text()
|
||||
if (!res.ok) {
|
||||
let msg = res.statusText
|
||||
try {
|
||||
const j = JSON.parse(text) as { error?: string }
|
||||
msg = j.error ?? msg
|
||||
} catch {
|
||||
if (text) msg = text
|
||||
}
|
||||
throw new Error(msg || "Не удалось сохранить")
|
||||
}
|
||||
const data = JSON.parse(text) as EvoBgpSettingsDto
|
||||
const data = await requestJson<EvoBgpSettingsDto>(
|
||||
backendUrl,
|
||||
"/api/evobgp/settings",
|
||||
{
|
||||
method: "PUT",
|
||||
body: JSON.stringify(patch),
|
||||
},
|
||||
)
|
||||
const nextEnabled = Boolean(data.enabled)
|
||||
setBaseUrlState(data.baseUrl ?? "")
|
||||
setEnabledState(data.enabled ?? false)
|
||||
setSecretConfigured(data.secretConfigured ?? false)
|
||||
await pullCatalog(data.enabled ?? false)
|
||||
setEnabledState(nextEnabled)
|
||||
setSecretConfigured(Boolean(data.secretConfigured))
|
||||
setError(null)
|
||||
// Каталог не должен ронять успех сохранения (401/502 на catalog ≠ «настройки не сохранились»)
|
||||
try {
|
||||
await pullCatalog(nextEnabled)
|
||||
} catch {
|
||||
/* pullCatalog already sets error state */
|
||||
}
|
||||
},
|
||||
[backendUrl, pullCatalog],
|
||||
)
|
||||
@@ -169,20 +168,20 @@ export function EvoBGPProvider({ children }: { children: React.ReactNode }) {
|
||||
await pullCatalog(enabled)
|
||||
}, [enabled, pullCatalog])
|
||||
|
||||
const testConnection = useCallback(async (draft?: EvoBgpTestDraft) => {
|
||||
try {
|
||||
const res = await fetch(`${backendUrl.replace(/\/$/, "")}/api/evobgp/test`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(draft ?? {}),
|
||||
})
|
||||
const data = (await res.json().catch(() => ({}))) as { ok?: boolean; error?: string }
|
||||
if (!res.ok) throw new Error(data.error ?? res.statusText)
|
||||
return { ok: true, message: "Соединение с EvoBGP установлено" }
|
||||
} catch (e) {
|
||||
return { ok: false, message: e instanceof Error ? e.message : "Ошибка" }
|
||||
}
|
||||
}, [backendUrl])
|
||||
const testConnection = useCallback(
|
||||
async (draft?: EvoBgpTestDraft) => {
|
||||
try {
|
||||
await requestJson<{ ok?: boolean }>(backendUrl, "/api/evobgp/test", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(draft ?? {}),
|
||||
})
|
||||
return { ok: true, message: "Соединение с EvoBGP установлено" }
|
||||
} catch (e) {
|
||||
return { ok: false, message: errorMessage(e, "Ошибка") }
|
||||
}
|
||||
},
|
||||
[backendUrl],
|
||||
)
|
||||
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
|
||||
Generated
+245
-19
@@ -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,7 +57,7 @@
|
||||
"dotenv": "^16.4.7",
|
||||
"drizzle-orm": "^0.45.2",
|
||||
"fastify": "^5.8.5",
|
||||
"pino-pretty": "^13.1.3",
|
||||
"fastify-plugin": "^5.1.0",
|
||||
"undici": "^8.1.0",
|
||||
"zod": "^4.4.1"
|
||||
},
|
||||
@@ -64,6 +65,8 @@
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"@types/node": "^22.15.3",
|
||||
"drizzle-kit": "^0.31.10",
|
||||
"jose": "^6.2.11",
|
||||
"pino-pretty": "^13.1.3",
|
||||
"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
|
||||
@@ -3900,6 +3951,70 @@
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": {
|
||||
"version": "1.8.1",
|
||||
"dev": true,
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@emnapi/wasi-threads": "1.1.0",
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": {
|
||||
"version": "1.8.1",
|
||||
"dev": true,
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
|
||||
"version": "1.1.0",
|
||||
"dev": true,
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": {
|
||||
"version": "1.1.1",
|
||||
"dev": true,
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@emnapi/core": "^1.7.1",
|
||||
"@emnapi/runtime": "^1.7.1",
|
||||
"@tybys/wasm-util": "^0.10.1"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": {
|
||||
"version": "0.10.1",
|
||||
"dev": true,
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": {
|
||||
"version": "2.8.1",
|
||||
"dev": true,
|
||||
"inBundle": true,
|
||||
"license": "0BSD",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
|
||||
"version": "4.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.4.tgz",
|
||||
@@ -5077,6 +5192,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 +5402,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",
|
||||
@@ -5672,6 +5805,7 @@
|
||||
"version": "2.0.20",
|
||||
"resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz",
|
||||
"integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/combined-stream": {
|
||||
@@ -5898,6 +6032,7 @@
|
||||
"version": "4.6.3",
|
||||
"resolved": "https://registry.npmjs.org/dateformat/-/dateformat-4.6.3.tgz",
|
||||
"integrity": "sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "*"
|
||||
@@ -6281,6 +6416,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",
|
||||
@@ -7159,6 +7303,7 @@
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-copy/-/fast-copy-4.0.3.tgz",
|
||||
"integrity": "sha512-58apWr0GUiDFM8+3afrO6eYwJBn9ZAhDOzG3L+/9llab/haCARS2UIfffmOurYLwbgDRs8n0rfr6qAAPEAuAQw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-decode-uri-component": {
|
||||
@@ -7256,6 +7401,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",
|
||||
@@ -7276,6 +7437,7 @@
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz",
|
||||
"integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-string-truncated-width": {
|
||||
@@ -7318,6 +7480,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 +7553,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 +7572,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",
|
||||
@@ -8040,6 +8234,7 @@
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/help-me/-/help-me-5.0.0.tgz",
|
||||
"integrity": "sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/hermes-estree": {
|
||||
@@ -8839,9 +9034,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"
|
||||
@@ -8851,6 +9046,7 @@
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz",
|
||||
"integrity": "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
@@ -9596,6 +9792,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 +9826,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 +10314,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",
|
||||
@@ -10426,6 +10643,7 @@
|
||||
"version": "13.1.3",
|
||||
"resolved": "https://registry.npmjs.org/pino-pretty/-/pino-pretty-13.1.3.tgz",
|
||||
"integrity": "sha512-ttXRkkOz6WWC95KeY9+xxWL6AtImwbyMHrL1mSwqwW9u+vLp/WIElvHvCSDg0xO/Dzrggz1zv3rN5ovTRVowKg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"colorette": "^2.0.7",
|
||||
@@ -10450,6 +10668,7 @@
|
||||
"version": "5.0.3",
|
||||
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-5.0.3.tgz",
|
||||
"integrity": "sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14.16"
|
||||
@@ -11792,6 +12011,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 +13690,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 +13875,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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import { configuredBackendUrl } from "@/lib/backend-url"
|
||||
import {
|
||||
getToken,
|
||||
isAuthEnabled,
|
||||
redirectToPortalLogin,
|
||||
redirectToPortalLoginInteractive,
|
||||
} from "@/lib/auth"
|
||||
|
||||
export class ApiClientError extends Error {
|
||||
constructor(
|
||||
@@ -15,27 +21,73 @@ function trimBaseUrl(baseUrl: string): string {
|
||||
return baseUrl.replace(/\/$/, "")
|
||||
}
|
||||
|
||||
function resolveRequestUrl(baseUrl: string, path: string): string {
|
||||
/** Absolute or same-origin-relative URL for backend API paths. */
|
||||
export function resolveApiUrl(baseUrl: string, path: string): string {
|
||||
if (path.startsWith("/") && configuredBackendUrl().kind === "same-origin") {
|
||||
return path
|
||||
}
|
||||
// Safety: never call browser localhost when the UI is served from a remote host
|
||||
if (typeof window !== "undefined") {
|
||||
const host = window.location.hostname
|
||||
const remoteUi = host !== "localhost" && host !== "127.0.0.1"
|
||||
const baseIsLocal =
|
||||
/^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/i.test(trimBaseUrl(baseUrl))
|
||||
if (remoteUi && (baseIsLocal || !baseUrl.trim())) {
|
||||
return path.startsWith("/") ? path : `/${path}`
|
||||
}
|
||||
}
|
||||
return trimBaseUrl(baseUrl) + path
|
||||
}
|
||||
|
||||
/** Attach portal JWT when present. */
|
||||
export function withAuthHeaders(init?: HeadersInit): Headers {
|
||||
const headers = new Headers(init)
|
||||
const token = typeof window !== "undefined" ? getToken() : null
|
||||
if (token && !headers.has("Authorization")) {
|
||||
headers.set("Authorization", `Bearer ${token}`)
|
||||
}
|
||||
return headers
|
||||
}
|
||||
|
||||
function handleUnauthorized(): never {
|
||||
if (typeof window !== "undefined" && isAuthEnabled()) {
|
||||
const ok = redirectToPortalLogin()
|
||||
if (!ok) redirectToPortalLoginInteractive()
|
||||
}
|
||||
throw new ApiClientError("Unauthorized", 401)
|
||||
}
|
||||
|
||||
async function parseErrorMessage(res: Response): Promise<string> {
|
||||
const payload = await res.json().catch(() => undefined)
|
||||
if (
|
||||
typeof payload === "object" &&
|
||||
payload !== null &&
|
||||
"error" in payload &&
|
||||
typeof (payload as { error?: unknown }).error === "string"
|
||||
) {
|
||||
return (payload as { error: string }).error
|
||||
}
|
||||
return res.statusText || `HTTP ${res.status}`
|
||||
}
|
||||
|
||||
export async function requestJson<T>(
|
||||
baseUrl: string,
|
||||
path: string,
|
||||
init?: RequestInit,
|
||||
): Promise<T> {
|
||||
const hasBody = init?.body != null
|
||||
const res = await fetch(resolveRequestUrl(baseUrl, path), {
|
||||
const headers = withAuthHeaders(init?.headers)
|
||||
if (hasBody && !headers.has("Content-Type")) {
|
||||
headers.set("Content-Type", "application/json")
|
||||
}
|
||||
|
||||
const res = await fetch(resolveApiUrl(baseUrl, path), {
|
||||
...init,
|
||||
headers: {
|
||||
...(hasBody ? { "Content-Type": "application/json" } : {}),
|
||||
...(init?.headers ?? {}),
|
||||
},
|
||||
headers,
|
||||
})
|
||||
|
||||
if (res.status === 401) handleUnauthorized()
|
||||
|
||||
if (res.status === 204) return undefined as T
|
||||
|
||||
const payload = await res.json().catch(() => undefined)
|
||||
@@ -52,3 +104,21 @@ export async function requestJson<T>(
|
||||
|
||||
return payload as T
|
||||
}
|
||||
|
||||
/** Binary/download endpoints (backup, backup file) with the same auth + URL rules. */
|
||||
export async function requestBlob(
|
||||
baseUrl: string,
|
||||
path: string,
|
||||
init?: RequestInit,
|
||||
): Promise<Response> {
|
||||
const headers = withAuthHeaders(init?.headers)
|
||||
const res = await fetch(resolveApiUrl(baseUrl, path), {
|
||||
...init,
|
||||
headers,
|
||||
})
|
||||
if (res.status === 401) handleUnauthorized()
|
||||
if (!res.ok) {
|
||||
throw new ApiClientError(await parseErrorMessage(res), res.status)
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
@@ -1,19 +1,7 @@
|
||||
import { ApiClientError } from "@/shared/api/http-client"
|
||||
import { configuredBackendUrl } from "@/lib/backend-url"
|
||||
import { ApiClientError, requestBlob } from "@/shared/api/http-client"
|
||||
|
||||
const MAX_RESTORE_BYTES = 512 * 1024 * 1024
|
||||
|
||||
function trimBaseUrl(baseUrl: string): string {
|
||||
return baseUrl.replace(/\/$/, "")
|
||||
}
|
||||
|
||||
function resolveDatabaseApiUrl(baseUrl: string, path: string): string {
|
||||
if (configuredBackendUrl().kind === "same-origin") {
|
||||
return path
|
||||
}
|
||||
return `${trimBaseUrl(baseUrl)}${path}`
|
||||
}
|
||||
|
||||
function parseFilename(contentDisposition: string | null, fallback: string): string {
|
||||
if (!contentDisposition) return fallback
|
||||
const utfMatch = /filename\*=UTF-8''([^;]+)/i.exec(contentDisposition)
|
||||
@@ -32,18 +20,7 @@ function parseFilename(contentDisposition: string | null, fallback: string): str
|
||||
export async function downloadSystemDatabaseBackup(
|
||||
baseUrl: string,
|
||||
): Promise<{ blob: Blob; filename: string }> {
|
||||
const res = await fetch(resolveDatabaseApiUrl(baseUrl, "/api/system/database/backup"))
|
||||
if (!res.ok) {
|
||||
const payload = await res.json().catch(() => undefined)
|
||||
const msg =
|
||||
typeof payload === "object" &&
|
||||
payload !== null &&
|
||||
"error" in payload &&
|
||||
typeof (payload as { error?: unknown }).error === "string"
|
||||
? (payload as { error: string }).error
|
||||
: res.statusText
|
||||
throw new ApiClientError(msg, res.status, payload)
|
||||
}
|
||||
const res = await requestBlob(baseUrl, "/api/system/database/backup")
|
||||
const blob = await res.blob()
|
||||
const filename = parseFilename(res.headers.get("Content-Disposition"), "mikrotik-manager.db")
|
||||
return { blob, filename }
|
||||
@@ -56,20 +33,9 @@ export async function restoreSystemDatabaseBackup(baseUrl: string, file: File):
|
||||
413,
|
||||
)
|
||||
}
|
||||
const res = await fetch(resolveDatabaseApiUrl(baseUrl, "/api/system/database/restore"), {
|
||||
await requestBlob(baseUrl, "/api/system/database/restore", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/octet-stream" },
|
||||
body: file,
|
||||
})
|
||||
if (!res.ok) {
|
||||
const payload = await res.json().catch(() => undefined)
|
||||
const msg =
|
||||
typeof payload === "object" &&
|
||||
payload !== null &&
|
||||
"error" in payload &&
|
||||
typeof (payload as { error?: unknown }).error === "string"
|
||||
? (payload as { error: string }).error
|
||||
: res.statusText
|
||||
throw new ApiClientError(msg, res.status, payload)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user