Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cdaf3deb03 | ||
|
|
3ce4179f3c | ||
|
|
1a47905beb | ||
|
|
b51d979a1c | ||
|
|
bc01da1708 | ||
|
|
500a543045 | ||
|
|
60024facab | ||
|
|
53ea0e74a3 | ||
|
|
6c8ca17590 | ||
|
|
d62d212c86 | ||
|
|
bfceb07a23 |
@@ -1,50 +1,50 @@
|
||||
{
|
||||
"version": "1.3.1",
|
||||
"tag": "v1.3.1",
|
||||
"publishedAt": "2026-05-12T18:06:29+07:00",
|
||||
"releaseUrl": "https://git.shts.su/denozord/MikrotikManager-3/releases/tag/v1.3.1",
|
||||
"version": "1.3.4",
|
||||
"tag": "v1.3.4",
|
||||
"publishedAt": "2026-05-12T13:50:06.198Z",
|
||||
"releaseUrl": "",
|
||||
"commits": [
|
||||
{
|
||||
"sha": "0435608ef9b0aa792afb7d678856c60058a40659",
|
||||
"shortSha": "0435608",
|
||||
"subject": "chore: обновить правила коммитов и улучшить форматирование релизных заметок",
|
||||
"sha": "3ce4179f3cda44d908560f53b51b960d07326a81",
|
||||
"shortSha": "3ce4179",
|
||||
"subject": "chore(package): обновить зависимости и добавить поддержку swc для Windows",
|
||||
"author": "Denozordec",
|
||||
"type": "chore"
|
||||
},
|
||||
{
|
||||
"sha": "c2a8a08618db0ba7a98635637d88be9d3f1d8ce1",
|
||||
"shortSha": "c2a8a08",
|
||||
"subject": "feat: обновить название проекта и улучшить документацию",
|
||||
"sha": "1a47905beb74f3e7a7e57b8f86bea51c19e95d00",
|
||||
"shortSha": "1a47905",
|
||||
"subject": "fix(backend): улучшить обработку сертификатов и добавить поддержку цепочек сертификатов",
|
||||
"author": "Denozordec",
|
||||
"type": "feat"
|
||||
"type": "fix"
|
||||
},
|
||||
{
|
||||
"sha": "35876fcd6fd0d13e0419c30c9d112b5a4017ae67",
|
||||
"shortSha": "35876fc",
|
||||
"subject": "feat: update Dockerfile and release management scripts",
|
||||
"sha": "b51d979a1c2f9fedd6615d682b0e7a4749efe983",
|
||||
"shortSha": "b51d979",
|
||||
"subject": "fix(backend): улучшить обработку хранилищ доверия для сертификатов",
|
||||
"author": "Denozordec",
|
||||
"type": "feat"
|
||||
"type": "fix"
|
||||
},
|
||||
{
|
||||
"sha": "7207d1b74992dabaf35cfa9208aa3c7becf42cbd",
|
||||
"shortSha": "7207d1b",
|
||||
"subject": "feat: update release notes and manifest for v1.1.0",
|
||||
"sha": "bc01da1708092c7f6f70e905cd7e61b901a357ba",
|
||||
"shortSha": "bc01da1",
|
||||
"subject": "fix(backend): добавить функции для работы с IP-адресами и DNS-записями",
|
||||
"author": "Denozordec",
|
||||
"type": "feat"
|
||||
"type": "fix"
|
||||
},
|
||||
{
|
||||
"sha": "4b92a6eb81ce64fad2ca61f5fa94342959a45349",
|
||||
"shortSha": "4b92a6e",
|
||||
"subject": "feat: release v1.1.0 with enhancements and refactoring",
|
||||
"sha": "500a5430456fdcf3403896fe78f4bb2afe0d8b69",
|
||||
"shortSha": "500a543",
|
||||
"subject": "fix(backend): улучшить уникальность идентификаторов сертификатов",
|
||||
"author": "Denozordec",
|
||||
"type": "feat"
|
||||
"type": "fix"
|
||||
},
|
||||
{
|
||||
"sha": "9ccc8459d77032dae8e7f67ac0df0287d7553341",
|
||||
"shortSha": "9ccc845",
|
||||
"subject": "feat: update application versioning and enhance release management",
|
||||
"sha": "60024facabb23c28a7bde1305fc107ac1573fef2",
|
||||
"shortSha": "60024fa",
|
||||
"subject": "fix(backend): улучшить обработку сертификатов и добавить поддержку PUT запросов",
|
||||
"author": "Denozordec",
|
||||
"type": "feat"
|
||||
"type": "fix"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
# v1.3.4
|
||||
|
||||
Версия **1.3.4**.
|
||||
|
||||
## fix
|
||||
|
||||
- fix(backend): улучшить обработку сертификатов и добавить поддержку цепочек сертификатов (`1a47905`, Denozordec)
|
||||
- fix(backend): улучшить обработку хранилищ доверия для сертификатов (`b51d979`, Denozordec)
|
||||
- fix(backend): добавить функции для работы с IP-адресами и DNS-записями (`bc01da1`, Denozordec)
|
||||
- fix(backend): улучшить уникальность идентификаторов сертификатов (`500a543`, Denozordec)
|
||||
- fix(backend): улучшить обработку сертификатов и добавить поддержку PUT запросов (`60024fa`, Denozordec)
|
||||
- fix(ui): добавить новые компоненты для управления сертификатами (`53ea0e7`, Denozordec)
|
||||
- fix(ui): улучшить отображение таблицы сертификатов и добавить классы для сетки (`6c8ca17`, Denozordec)
|
||||
|
||||
## chore
|
||||
|
||||
- chore(package): обновить зависимости и добавить поддержку swc для Windows (`3ce4179`, Denozordec)
|
||||
- chore(backend): добавить поддержку ACME и управление сертификатами (`d62d212`, Denozordec)
|
||||
|
||||
@@ -826,8 +826,8 @@ const TABS: Array<{ id: BgpTab; label: string; icon: React.ReactNode }> = [
|
||||
export default function BgpPage() {
|
||||
const [activeTab, setActiveTab] = useState<BgpTab>("sessions")
|
||||
|
||||
const { mode, backendUrl, backendStatus } = useDataSource()
|
||||
const isLive = mode === "live" && backendStatus === true
|
||||
const { mode, backendUrl } = useDataSource()
|
||||
const isLive = mode === "live"
|
||||
|
||||
const [liveSessions, setLiveSessions] = useState<BgpSession[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
+1016
-275
File diff suppressed because it is too large
Load Diff
@@ -289,8 +289,8 @@ function ProbeSourceCell({ probe, catalog }: { probe: PingProbe; catalog: Server
|
||||
|
||||
export default function DashboardPage() {
|
||||
const pathname = usePathname()
|
||||
const { mode, backendUrl, backendStatus } = useDataSource()
|
||||
const isLive = mode === "live" && backendStatus === true
|
||||
const { mode, backendUrl, prefsHydrated } = useDataSource()
|
||||
const isLive = prefsHydrated && mode === "live"
|
||||
const apiFetch = useMemo(() => makeApiFetch(backendUrl), [backendUrl])
|
||||
|
||||
const [liveProbes, setLiveProbes] = useState<PingProbe[] | null>(null)
|
||||
@@ -306,9 +306,10 @@ export default function DashboardPage() {
|
||||
const [internetPathError, setInternetPathError] = useState<string | null>(null)
|
||||
|
||||
const probeServerCatalog = useMemo(() => {
|
||||
if (!prefsHydrated) return []
|
||||
if (!isLive) return mockServers
|
||||
return liveServersResolved ?? []
|
||||
}, [isLive, liveServersResolved])
|
||||
}, [prefsHydrated, isLive, liveServersResolved])
|
||||
|
||||
const fetchProbes = useCallback(async (silent: boolean) => {
|
||||
if (!isLive) return
|
||||
@@ -523,13 +524,15 @@ export default function DashboardPage() {
|
||||
}, [mockDashEpoch])
|
||||
|
||||
const activeProbesTable = useMemo(() => {
|
||||
if (!prefsHydrated) return []
|
||||
if (!isLive) return mockActiveProbes
|
||||
if (liveProbes === null && probesLoading) return []
|
||||
if (liveProbes === null) return []
|
||||
return liveProbes.filter((p) => p.enabled && p.showOnDashboard === true)
|
||||
}, [isLive, liveProbes, probesLoading, mockActiveProbes])
|
||||
}, [prefsHydrated, isLive, liveProbes, probesLoading, mockActiveProbes])
|
||||
|
||||
const probesSubtitle = useMemo(() => {
|
||||
if (!prefsHydrated) return "Загрузка…"
|
||||
if (!isLive) {
|
||||
const starred = mockActiveProbes.length
|
||||
return starred > 0
|
||||
@@ -541,10 +544,13 @@ export default function DashboardPage() {
|
||||
return n > 0
|
||||
? `${n} на дашборде · последний час (API)`
|
||||
: "Нет проб на дашборде · отметьте звёздочкой на странице мониторинга"
|
||||
}, [isLive, mockActiveProbes.length, probesError, liveProbes, activeProbesTable.length])
|
||||
}, [prefsHydrated, isLive, mockActiveProbes.length, probesError, liveProbes, activeProbesTable.length])
|
||||
|
||||
/** Карточка «Состояние серверов»: в Live — `/api/servers` (тот же запрос, что и для каталога проб). */
|
||||
const serverStatusModel = useMemo(() => {
|
||||
if (!prefsHydrated) {
|
||||
return { kind: "loading" as const, subtitle: "Загрузка…" }
|
||||
}
|
||||
if (!isLive) {
|
||||
return {
|
||||
kind: "mock" as const,
|
||||
@@ -571,9 +577,12 @@ export default function DashboardPage() {
|
||||
servers,
|
||||
subtitle: `${servers.length} узлов · ${onlineN} онлайн · API`,
|
||||
}
|
||||
}, [isLive, liveServersResolved, probesLoading, probesError])
|
||||
}, [prefsHydrated, isLive, liveServersResolved, probesLoading, probesError])
|
||||
|
||||
const latencyChartBlock = useMemo(() => {
|
||||
if (!prefsHydrated) {
|
||||
return { kind: "loading" as const }
|
||||
}
|
||||
if (!isLive) {
|
||||
return {
|
||||
kind: "mock" as const,
|
||||
@@ -612,7 +621,7 @@ export default function DashboardPage() {
|
||||
subtitle:
|
||||
"Средний RTT по источникам проб · окно 1 ч · до 8 узлов · API",
|
||||
}
|
||||
}, [isLive, liveProbes, probesLoading, liveServersResolved])
|
||||
}, [prefsHydrated, isLive, liveProbes, probesLoading, liveServersResolved])
|
||||
|
||||
const dashboardKpi = useMemo(() => {
|
||||
const sparkSrv = [5, 5, 6, 6, 5, 6, 6, 6, 7, 6, 6, 6, 6, 6, 6, 6, 6]
|
||||
@@ -620,6 +629,24 @@ export default function DashboardPage() {
|
||||
const sparkBgp = [7800, 7900, 8000, 8100, 8050, 8120, 8200, 8240, 8300, 8350, 8380, 8400, 8420, 8430, 8432]
|
||||
const sparkAlt = [1, 2, 2, 3, 3, 4, 5, 4, 4, 4, 3, 3, 4, 4, 4]
|
||||
|
||||
const loadingKpi = (sparkColor: string) => ({
|
||||
value: "—",
|
||||
unit: undefined as string | undefined,
|
||||
delta: "Загрузка…",
|
||||
deltaDir: "up" as const,
|
||||
spark: undefined as number[] | undefined,
|
||||
sparkColor,
|
||||
})
|
||||
|
||||
if (!prefsHydrated) {
|
||||
return {
|
||||
servers: loadingKpi("var(--chart-line-1)"),
|
||||
filters: loadingKpi("var(--chart-line-2)"),
|
||||
bgp: loadingKpi("var(--chart-line-4)"),
|
||||
alerts: loadingKpi("var(--chart-5)"),
|
||||
}
|
||||
}
|
||||
|
||||
if (!isLive) {
|
||||
const totalSrv = mockServers.length
|
||||
const onlineSrv = mockServers.filter((s) => s.status === "online").length
|
||||
@@ -730,7 +757,7 @@ export default function DashboardPage() {
|
||||
sparkColor: "var(--chart-5)",
|
||||
},
|
||||
}
|
||||
}, [isLive, liveServersResolved, liveKpi, liveProbes, probesLoading])
|
||||
}, [prefsHydrated, isLive, liveServersResolved, liveKpi, liveProbes, probesLoading])
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
|
||||
@@ -661,8 +661,8 @@ function RunRowDetail({ r }: { r: SchedulerRunRowDto }) {
|
||||
}
|
||||
|
||||
export default function DataCollectionPage() {
|
||||
const { mode, backendUrl, backendStatus } = useDataSource()
|
||||
const isLive = mode === "live" && backendStatus === true
|
||||
const { mode, backendUrl } = useDataSource()
|
||||
const isLive = mode === "live"
|
||||
const apiFetch = useMemo(() => makeApiFetch(backendUrl), [backendUrl])
|
||||
|
||||
const [trafficCollector, setTrafficCollector] = useState<CollectorSettingsDto | null>(null)
|
||||
|
||||
@@ -272,8 +272,8 @@ type PageTab = "tunnels" | "pools"
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
export default function GrePage() {
|
||||
const { mode, backendUrl, backendStatus } = useDataSource()
|
||||
const isLive = mode === "live" && backendStatus === true
|
||||
const { mode, backendUrl } = useDataSource()
|
||||
const isLive = mode === "live"
|
||||
const apiFetch = useMemo(() => makeApiFetch(backendUrl), [backendUrl])
|
||||
|
||||
const [liveServers, setLiveServers] = useState<Server[]>([])
|
||||
|
||||
@@ -777,12 +777,12 @@ function ZoomControls({ zoom, onZoomIn, onZoomOut, onFit }: {
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
export default function NetworkMapPage() {
|
||||
const { mode, backendUrl, backendStatus } = useDataSource()
|
||||
const useLiveData = mode === "live" && backendStatus === true
|
||||
const { mode, backendUrl } = useDataSource()
|
||||
const useLiveData = mode === "live"
|
||||
const apiFetch = useMemo(() => makeApiFetch(backendUrl), [backendUrl])
|
||||
|
||||
const [mapServers, setMapServers] = useState<Server[]>(mockServers)
|
||||
const [mapGreTunnels, setMapGreTunnels] = useState<GreTunnel[]>(mockGreTunnels)
|
||||
const [mapServers, setMapServers] = useState<Server[]>([])
|
||||
const [mapGreTunnels, setMapGreTunnels] = useState<GreTunnel[]>([])
|
||||
const [speedProbes, setSpeedProbes] = useState<GreSpeedProbeSnapshot[]>([])
|
||||
/** FQDN из GRE outer → IPv4 (ответ POST /api/network/resolve-hosts), для матчинга с WAN. */
|
||||
const [greResolvedIpv4ByHost, setGreResolvedIpv4ByHost] = useState<Record<string, string>>({})
|
||||
|
||||
@@ -1227,8 +1227,8 @@ export default function OspfPage() {
|
||||
const [activeTab, setActiveTab] = useState<OspfTab>("interfaces")
|
||||
const [filterServerId, setFilterServerId] = useState<string>("all")
|
||||
|
||||
const { mode, backendUrl, backendStatus } = useDataSource()
|
||||
const isLive = mode === "live" && backendStatus === true
|
||||
const { mode, backendUrl } = useDataSource()
|
||||
const isLive = mode === "live"
|
||||
|
||||
const [liveData, setLiveData] = useState<BackendOspfAll | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
@@ -1291,6 +1291,16 @@ export default function OspfPage() {
|
||||
const { nodes: graphNodes, edges: graphEdges } = buildLiveGraph(neighbors)
|
||||
return { items, neighbors, graphNodes, graphEdges, routerIds, bfdSessions }
|
||||
}
|
||||
if (isLive) {
|
||||
return {
|
||||
items: [],
|
||||
neighbors: [],
|
||||
graphNodes: [],
|
||||
graphEdges: [],
|
||||
routerIds: {},
|
||||
bfdSessions: [],
|
||||
}
|
||||
}
|
||||
return {
|
||||
items: MOCK_ITEMS,
|
||||
neighbors: MOCK_NEIGHBORS,
|
||||
@@ -1318,6 +1328,7 @@ export default function OspfPage() {
|
||||
}
|
||||
return [...seen.values()]
|
||||
}
|
||||
if (isLive) return []
|
||||
// Mock: derive from MOCK_GRAPH_NODES that actually have interfaces
|
||||
const activeIds = new Set(MOCK_ITEMS.map(i => i.routerKey))
|
||||
const siteCountry: Record<string, string> = { MSK: "RU", SPB: "RU", FRA: "DE", AMS: "NL", SGP: "SG" }
|
||||
|
||||
@@ -729,10 +729,8 @@ export default function ProbesPage() {
|
||||
|
||||
const allServers = useMemo(() => {
|
||||
if (!isLive) return servers
|
||||
if (liveServers.length > 0) return liveServers
|
||||
if (liveLoad === "error") return servers
|
||||
return []
|
||||
}, [isLive, liveServers, liveLoad])
|
||||
return liveServers
|
||||
}, [isLive, liveServers])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLive) {
|
||||
|
||||
@@ -498,12 +498,12 @@ function TypeChip({ type }: { type: "jump-host" | "exit-node" | "home-router" })
|
||||
}
|
||||
|
||||
export default function RecursiveRoutesPage() {
|
||||
const { mode, backendUrl, backendStatus } = useDataSource()
|
||||
const isLive = mode === "live" && backendStatus === true
|
||||
const { mode, backendUrl } = useDataSource()
|
||||
const isLive = mode === "live"
|
||||
const apiFetch = useMemo(() => makeApiFetch(backendUrl), [backendUrl])
|
||||
|
||||
const [servers, setServers] = useState<Server[]>(mockServers)
|
||||
const [selectedServerId, setSelectedServerId] = useState<string>(mockServers[0]?.id ?? "")
|
||||
const [servers, setServers] = useState<Server[]>([])
|
||||
const [selectedServerId, setSelectedServerId] = useState<string>("")
|
||||
const [rows, setRows] = useState<RecursiveRouteRow[]>([])
|
||||
const [busy, setBusy] = useState<"load" | "save" | "from" | "to" | null>(null)
|
||||
const [search, setSearch] = useState("")
|
||||
|
||||
@@ -321,10 +321,10 @@ type SheetMode = "add" | "edit"
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
export default function ServersPage() {
|
||||
const { mode, backendUrl, backendStatus } = useDataSource()
|
||||
const isLive = mode === "live" && backendStatus === true
|
||||
const { mode, backendUrl } = useDataSource()
|
||||
const isLive = mode === "live"
|
||||
|
||||
const [serverList, setServerList] = useState<Server[]>(initialServers)
|
||||
const [serverList, setServerList] = useState<Server[]>([])
|
||||
const [_backendOk, setBackendOk] = useState(false)
|
||||
const [search, setSearch] = useState("")
|
||||
const [typeFilter, setTypeFilter] = useState<TypeFilter>("all")
|
||||
@@ -358,7 +358,7 @@ export default function ServersPage() {
|
||||
})
|
||||
.catch(() => {
|
||||
setBackendOk(false)
|
||||
setServerList(initialServers)
|
||||
setServerList([])
|
||||
})
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [mode, backendUrl])
|
||||
|
||||
@@ -427,8 +427,8 @@ interface BackendServer {
|
||||
}
|
||||
|
||||
export default function TerminalPage() {
|
||||
const { mode, backendUrl, backendStatus } = useDataSource()
|
||||
const isLive = mode === "live" && backendStatus === true
|
||||
const { mode, backendUrl } = useDataSource()
|
||||
const isLive = mode === "live"
|
||||
|
||||
// Server list state
|
||||
const [liveServers, setLiveServers] = useState<TermServer[]>([])
|
||||
|
||||
@@ -758,8 +758,8 @@ const SORT_FIELDS: Array<{ field: SortField; label: string; modesOnly?: GroupMod
|
||||
]
|
||||
|
||||
export default function TrafficPage() {
|
||||
const { mode, backendUrl, backendStatus } = useDataSource()
|
||||
const isLive = mode === "live" && backendStatus === true
|
||||
const { mode, backendUrl } = useDataSource()
|
||||
const isLive = mode === "live"
|
||||
const apiFetch = useMemo(() => makeApiFetch(backendUrl), [backendUrl])
|
||||
|
||||
const [groupMode, setGroupMode] = useState<GroupMode>("servers")
|
||||
|
||||
@@ -1293,15 +1293,15 @@ function ResourcesTab({ resources, serversList, liveApi }: { resources: ServerRe
|
||||
// ── page ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function UptimePage() {
|
||||
const [allServers, setAllServers] = useState<Server[]>(mockServers)
|
||||
const { mode, backendUrl, backendStatus, checkBackend } = useDataSource()
|
||||
/** При mode=live всегда ходим на backend. Нельзя требовать backendStatus===true: до ответа /health там undefined — иначе обзор/«Обновить» молчат. */
|
||||
const liveApi = mode === "live"
|
||||
const apiFetch = useMemo(() => makeApiFetch(backendUrl), [backendUrl])
|
||||
|
||||
const [allServers, setAllServers] = useState<Server[]>([])
|
||||
const [tab, setTab] = useState<"probes" | "resources" | "speed">("probes")
|
||||
const [probes, setProbes] = useState<PingProbe[]>(INIT_PROBES)
|
||||
const [resources, setResources] = useState<ServerResource[]>(INIT_RESOURCES)
|
||||
const [probes, setProbes] = useState<PingProbe[]>([])
|
||||
const [resources, setResources] = useState<ServerResource[]>([])
|
||||
const [isPaused, setIsPaused] = useState(false)
|
||||
const [search, setSearch] = useState("")
|
||||
const [statusFilter, setStatusFilter] = useState<"all" | "up" | "warn" | "down">("all")
|
||||
@@ -1334,18 +1334,12 @@ export default function UptimePage() {
|
||||
enabled: true,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (!liveApi) {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setAllServers(mockServers)
|
||||
return
|
||||
}
|
||||
/** В live каталог серверов подгружается вместе с overview (см. loadLiveOverview), иначе после «Серверы» строки ресурсов пропадают из-за .filter(r => r.server). */
|
||||
}, [liveApi])
|
||||
|
||||
useEffect(() => {
|
||||
if (liveApi) return
|
||||
queueMicrotask(() => setProbes(mockProbesWithSavedStars(INIT_PROBES)))
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setAllServers(mockServers)
|
||||
setProbes(mockProbesWithSavedStars(INIT_PROBES))
|
||||
setResources(INIT_RESOURCES)
|
||||
}, [liveApi])
|
||||
|
||||
const isPausedRef = useRef(isPaused)
|
||||
|
||||
@@ -13,9 +13,10 @@
|
||||
"db:studio": "drizzle-kit studio"
|
||||
},
|
||||
"dependencies": {
|
||||
"@mmapp/contracts": "1.0.0",
|
||||
"@fastify/cors": "^11.2.0",
|
||||
"@fastify/type-provider-zod": "^1.0.0",
|
||||
"@mmapp/contracts": "1.0.0",
|
||||
"acme-client": "^5.4.0",
|
||||
"better-sqlite3": "^12.9.0",
|
||||
"dotenv": "^16.4.7",
|
||||
"drizzle-orm": "^0.45.2",
|
||||
|
||||
@@ -338,6 +338,30 @@ CREATE TABLE IF NOT EXISTS alert_telegram_settings (
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS acme_settings (
|
||||
id INTEGER PRIMARY KEY,
|
||||
directory_url TEXT NOT NULL DEFAULT 'https://acme-v02.api.letsencrypt.org/directory',
|
||||
cloudflare_api_token TEXT NOT NULL DEFAULT '',
|
||||
default_zone_id TEXT NOT NULL DEFAULT '',
|
||||
account_private_key TEXT NOT NULL DEFAULT '',
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS certificate_issue_jobs (
|
||||
id TEXT PRIMARY KEY,
|
||||
status TEXT NOT NULL DEFAULT 'queued',
|
||||
step TEXT NOT NULL DEFAULT 'queued',
|
||||
server_id TEXT NOT NULL,
|
||||
cert_name TEXT NOT NULL,
|
||||
domain_names TEXT NOT NULL,
|
||||
key_type TEXT NOT NULL DEFAULT 'rsa2048',
|
||||
trust_store TEXT NOT NULL DEFAULT 'www,api',
|
||||
requested_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
started_at TEXT,
|
||||
finished_at TEXT,
|
||||
error TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS alert_rules (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
@@ -587,6 +611,12 @@ SELECT 1, '', ''
|
||||
WHERE NOT EXISTS (SELECT 1 FROM alert_telegram_settings WHERE id = 1);
|
||||
`)
|
||||
|
||||
sqlite.exec(`
|
||||
INSERT INTO acme_settings (id, directory_url, cloudflare_api_token, default_zone_id, account_private_key)
|
||||
SELECT 1, 'https://acme-v02.api.letsencrypt.org/directory', '', '', ''
|
||||
WHERE NOT EXISTS (SELECT 1 FROM acme_settings WHERE id = 1);
|
||||
`)
|
||||
|
||||
sqlite.exec(`
|
||||
INSERT INTO alert_engine_cursor (id, last_source_finished_at)
|
||||
SELECT 1, NULL
|
||||
|
||||
@@ -287,6 +287,30 @@ export const alertTelegramSettings = sqliteTable("alert_telegram_settings", {
|
||||
updatedAt: text("updated_at").notNull().default(sql`(datetime('now'))`),
|
||||
})
|
||||
|
||||
export const acmeSettings = sqliteTable("acme_settings", {
|
||||
id: integer("id").primaryKey(),
|
||||
directoryUrl: text("directory_url").notNull().default("https://acme-v02.api.letsencrypt.org/directory"),
|
||||
cloudflareApiToken: text("cloudflare_api_token").notNull().default(""),
|
||||
defaultZoneId: text("default_zone_id").notNull().default(""),
|
||||
accountPrivateKey: text("account_private_key").notNull().default(""),
|
||||
updatedAt: text("updated_at").notNull().default(sql`(datetime('now'))`),
|
||||
})
|
||||
|
||||
export const certificateIssueJobs = sqliteTable("certificate_issue_jobs", {
|
||||
id: text("id").primaryKey(),
|
||||
status: text("status", { enum: ["queued", "running", "done", "failed"] }).notNull().default("queued"),
|
||||
step: text("step").notNull().default("queued"),
|
||||
serverId: text("server_id").notNull(),
|
||||
certName: text("cert_name").notNull(),
|
||||
domainNames: text("domain_names").notNull(),
|
||||
keyType: text("key_type").notNull().default("rsa2048"),
|
||||
trustStore: text("trust_store").notNull().default("www,api"),
|
||||
requestedAt: text("requested_at").notNull().default(sql`(datetime('now'))`),
|
||||
startedAt: text("started_at"),
|
||||
finishedAt: text("finished_at"),
|
||||
error: text("error"),
|
||||
})
|
||||
|
||||
/** Группы правил: ANY = хотя бы одно; ALL = все одновременно в окне тика. */
|
||||
export const alertGroups = sqliteTable("alert_groups", {
|
||||
id: text("id").primaryKey(),
|
||||
@@ -505,6 +529,8 @@ export type SchedulerRunRow = typeof schedulerRuns.$inferSelect
|
||||
export type EventRow = typeof events.$inferSelect
|
||||
export type EvobgpSettingsRow = typeof evobgpSettings.$inferSelect
|
||||
export type AlertTelegramSettingsRow = typeof alertTelegramSettings.$inferSelect
|
||||
export type AcmeSettingsRow = typeof acmeSettings.$inferSelect
|
||||
export type CertificateIssueJobRow = typeof certificateIssueJobs.$inferSelect
|
||||
export type AlertGroupRow = typeof alertGroups.$inferSelect
|
||||
export type AlertRuleRow = typeof alertRules.$inferSelect
|
||||
export type AlertRuleTargetRow = typeof alertRuleTargets.$inferSelect
|
||||
|
||||
@@ -19,6 +19,7 @@ import schedulerRoutes from "./routes/scheduler.js"
|
||||
import sidebarCountsRoutes from "./routes/sidebar-counts.js"
|
||||
import alertsRoutes from "./routes/alerts.js"
|
||||
import backupsRoutes from "./routes/backups.js"
|
||||
import certificatesRoutes from "./routes/certificates.js"
|
||||
import systemDatabaseRoutes from "./routes/system-database.js"
|
||||
import eventsRoutes from "./routes/events.js"
|
||||
import { refreshScheduler, stopScheduler } from "./services/scheduler.js"
|
||||
@@ -70,6 +71,7 @@ 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" })
|
||||
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
import { randomUUID } from "node:crypto"
|
||||
import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
|
||||
import {
|
||||
certificateIssueRequestSchema,
|
||||
putAcmeCloudflareSettingsSchema,
|
||||
testAcmeCloudflareSettingsSchema,
|
||||
} from "@mmapp/contracts/certificates"
|
||||
import { appendEvent } from "../modules/events/service/events-service.js"
|
||||
import { issueCertificateWithCloudflareDns, testCloudflareToken } from "../services/acme-cloudflare.js"
|
||||
import {
|
||||
createIssueJobRecord,
|
||||
getAcmeCloudflareToken,
|
||||
getAcmeSettingsPublic,
|
||||
getIssueJobRecord,
|
||||
getServerRowByIdString,
|
||||
listCertificatesFromServers,
|
||||
toIssueJobDto,
|
||||
updateAcmeSettings,
|
||||
updateIssueJobRecord,
|
||||
} from "../services/certificates-service.js"
|
||||
|
||||
const runningJobs = new Set<string>()
|
||||
|
||||
async function runIssueJob(jobId: string) {
|
||||
if (runningJobs.has(jobId)) return
|
||||
runningJobs.add(jobId)
|
||||
const row = getIssueJobRecord(jobId)
|
||||
if (!row) {
|
||||
runningJobs.delete(jobId)
|
||||
return
|
||||
}
|
||||
|
||||
const server = getServerRowByIdString(row.serverId)
|
||||
if (!server) {
|
||||
updateIssueJobRecord(jobId, {
|
||||
status: "failed",
|
||||
step: "failed",
|
||||
finishedAt: new Date().toISOString(),
|
||||
error: "Сервер не найден",
|
||||
})
|
||||
runningJobs.delete(jobId)
|
||||
return
|
||||
}
|
||||
|
||||
let domainNames: string[] = []
|
||||
try {
|
||||
domainNames = JSON.parse(row.domainNames) as string[]
|
||||
} catch {
|
||||
domainNames = []
|
||||
}
|
||||
|
||||
const trustStore = row.trustStore.split(",").map((s) => s.trim()).filter(Boolean)
|
||||
const startedAt = new Date().toISOString()
|
||||
updateIssueJobRecord(jobId, { status: "running", step: "acme_order", startedAt, error: null })
|
||||
|
||||
try {
|
||||
await issueCertificateWithCloudflareDns({
|
||||
server,
|
||||
certName: row.certName,
|
||||
domainNames,
|
||||
keyType: row.keyType === "ec256" ? "ec256" : "rsa2048",
|
||||
trustStore: trustStore.length > 0 ? trustStore : ["www", "api"],
|
||||
onStep: (step) => updateIssueJobRecord(jobId, { step }),
|
||||
})
|
||||
updateIssueJobRecord(jobId, {
|
||||
status: "done",
|
||||
step: "done",
|
||||
finishedAt: new Date().toISOString(),
|
||||
error: null,
|
||||
})
|
||||
appendEvent({
|
||||
level: "info",
|
||||
eventType: "certificates.issue.done",
|
||||
sourceModule: "certificates",
|
||||
title: "Сертификат выпущен",
|
||||
message: `${row.certName} · ${domainNames.join(", ")} · ${server.name}`,
|
||||
entityType: "server",
|
||||
entityId: String(server.id),
|
||||
})
|
||||
} catch (e) {
|
||||
updateIssueJobRecord(jobId, {
|
||||
status: "failed",
|
||||
step: "failed",
|
||||
finishedAt: new Date().toISOString(),
|
||||
error: e instanceof Error ? e.message : String(e),
|
||||
})
|
||||
appendEvent({
|
||||
level: "warning",
|
||||
eventType: "certificates.issue.failed",
|
||||
sourceModule: "certificates",
|
||||
title: "Ошибка выпуска сертификата",
|
||||
message: e instanceof Error ? e.message : String(e),
|
||||
entityType: "server",
|
||||
entityId: String(server.id),
|
||||
})
|
||||
} finally {
|
||||
runningJobs.delete(jobId)
|
||||
}
|
||||
}
|
||||
|
||||
const certificatesRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
app.get("/certificates", async (_req, reply) => {
|
||||
return reply.send(await listCertificatesFromServers())
|
||||
})
|
||||
|
||||
app.post("/certificates/refresh", async (_req, reply) => {
|
||||
return reply.send(await listCertificatesFromServers())
|
||||
})
|
||||
|
||||
app.get("/certificates/acme-settings", async (_req, reply) => {
|
||||
return reply.send(getAcmeSettingsPublic())
|
||||
})
|
||||
|
||||
app.put("/certificates/acme-settings", async (req, reply) => {
|
||||
const parsed = putAcmeCloudflareSettingsSchema.safeParse(req.body ?? {})
|
||||
if (!parsed.success) {
|
||||
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
|
||||
}
|
||||
return reply.send(updateAcmeSettings(parsed.data))
|
||||
})
|
||||
|
||||
app.post("/certificates/acme-settings/test", async (req, reply) => {
|
||||
const parsed = testAcmeCloudflareSettingsSchema.safeParse(req.body ?? {})
|
||||
if (!parsed.success) {
|
||||
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
|
||||
}
|
||||
const effective = parsed.data.cloudflareApiToken?.trim() || getAcmeCloudflareToken()
|
||||
if (!effective) {
|
||||
return reply.status(400).send({ error: "Не задан Cloudflare API token" })
|
||||
}
|
||||
try {
|
||||
await testCloudflareToken(effective)
|
||||
return reply.send({ ok: true, message: "Cloudflare API доступен" })
|
||||
} catch (e) {
|
||||
return reply.status(400).send({
|
||||
ok: false,
|
||||
message: e instanceof Error ? e.message : String(e),
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
app.post("/certificates/issue", async (req, reply) => {
|
||||
const parsed = certificateIssueRequestSchema.safeParse(req.body ?? {})
|
||||
if (!parsed.success) {
|
||||
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
|
||||
}
|
||||
const serverId = String(parsed.data.serverId)
|
||||
const server = getServerRowByIdString(serverId)
|
||||
if (!server) return reply.status(404).send({ error: "Сервер не найден" })
|
||||
|
||||
const jobId = randomUUID()
|
||||
const trustStore = (parsed.data.trustStore ?? ["www", "api"]).join(",")
|
||||
createIssueJobRecord({
|
||||
id: jobId,
|
||||
serverId,
|
||||
certName: parsed.data.certName.trim(),
|
||||
domainNames: parsed.data.domainNames.map((d) => d.trim()).filter(Boolean),
|
||||
keyType: parsed.data.keyType ?? "rsa2048",
|
||||
trustStore,
|
||||
})
|
||||
queueMicrotask(() => { void runIssueJob(jobId) })
|
||||
return reply.send({ jobId })
|
||||
})
|
||||
|
||||
app.get("/certificates/issue/:jobId", async (req, reply) => {
|
||||
const jobId = String((req.params as { jobId: string }).jobId)
|
||||
const row = getIssueJobRecord(jobId)
|
||||
if (!row) return reply.status(404).send({ error: "Задача не найдена" })
|
||||
return reply.send(toIssueJobDto(row))
|
||||
})
|
||||
}
|
||||
|
||||
export default certificatesRoutes
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
|
||||
import { listCertificatesFromServers } from "../services/certificates-service.js"
|
||||
import { db } from "../db/index.js"
|
||||
import {
|
||||
filterRules,
|
||||
@@ -16,13 +17,15 @@ const sidebarCountsRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
uptimeProbesTotal,
|
||||
uptimeSpeedProbesTotal,
|
||||
recursiveRoutesTotal,
|
||||
] = [
|
||||
db.select().from(servers).all().length,
|
||||
db.select().from(filterRules).all().length,
|
||||
db.select().from(uptimeProbes).all().length,
|
||||
db.select().from(uptimeSpeedProbes).all().length,
|
||||
db.select().from(recursiveRoutes).all().length,
|
||||
]
|
||||
certificatesTotal,
|
||||
] = await Promise.all([
|
||||
Promise.resolve(db.select().from(servers).all().length),
|
||||
Promise.resolve(db.select().from(filterRules).all().length),
|
||||
Promise.resolve(db.select().from(uptimeProbes).all().length),
|
||||
Promise.resolve(db.select().from(uptimeSpeedProbes).all().length),
|
||||
Promise.resolve(db.select().from(recursiveRoutes).all().length),
|
||||
listCertificatesFromServers().then((res) => res.certificates.length),
|
||||
])
|
||||
|
||||
return reply.send({
|
||||
servers: serversTotal,
|
||||
@@ -31,6 +34,7 @@ const sidebarCountsRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
uptimeSpeedProbes: uptimeSpeedProbesTotal,
|
||||
monitoringItems: uptimeProbesTotal + uptimeSpeedProbesTotal,
|
||||
recursiveRoutes: recursiveRoutesTotal,
|
||||
certificates: certificatesTotal,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,353 @@
|
||||
import { lookup } from "node:dns/promises"
|
||||
import * as acme from "acme-client"
|
||||
import type { Server } from "../db/schema.js"
|
||||
import {
|
||||
getAcmeAccountPrivateKey,
|
||||
getAcmeCloudflareToken,
|
||||
getAcmeSettingsPublic,
|
||||
saveAcmeAccountPrivateKey,
|
||||
} from "./certificates-service.js"
|
||||
import { MikrotikClient } from "./mikrotik.js"
|
||||
|
||||
type CfResponse<T> = { success: boolean; errors?: Array<{ message?: string }>; result?: T }
|
||||
|
||||
type CfDnsRecord = {
|
||||
id: string
|
||||
type: string
|
||||
name: string
|
||||
content: string
|
||||
}
|
||||
|
||||
type WanUplinkRow = {
|
||||
iface?: string
|
||||
ip?: string
|
||||
}
|
||||
|
||||
async function cloudflareRequest<T>(
|
||||
token: string,
|
||||
path: string,
|
||||
init?: RequestInit,
|
||||
): Promise<T> {
|
||||
const headers: Record<string, string> = {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
const res = await fetch(`https://api.cloudflare.com/client/v4${path}`, {
|
||||
...init,
|
||||
headers,
|
||||
})
|
||||
const json = (await res.json()) as CfResponse<T>
|
||||
if (!res.ok || !json.success) {
|
||||
const msg = json.errors?.map((e) => e.message).filter(Boolean).join("; ")
|
||||
|| `Cloudflare API HTTP ${res.status}`
|
||||
throw new Error(msg)
|
||||
}
|
||||
return json.result as T
|
||||
}
|
||||
|
||||
export async function testCloudflareToken(token: string): Promise<void> {
|
||||
await cloudflareRequest<Array<{ id: string; name: string }>>(token, "/zones?per_page=1")
|
||||
}
|
||||
|
||||
async function resolveZoneId(token: string, domain: string, defaultZoneId?: string): Promise<string> {
|
||||
const labels = domain.split(".").filter(Boolean)
|
||||
for (let i = 0; i < labels.length - 1; i++) {
|
||||
const guess = labels.slice(i).join(".")
|
||||
const zones = await cloudflareRequest<Array<{ id: string; name: string }>>(
|
||||
token,
|
||||
`/zones?name=${encodeURIComponent(guess)}`,
|
||||
)
|
||||
if (zones[0]?.id) return zones[0].id
|
||||
}
|
||||
if (defaultZoneId?.trim()) return defaultZoneId.trim()
|
||||
throw new Error(`Не удалось определить зону Cloudflare для ${domain}`)
|
||||
}
|
||||
|
||||
async function createTxtRecord(
|
||||
token: string,
|
||||
zoneId: string,
|
||||
recordName: string,
|
||||
value: string,
|
||||
): Promise<string> {
|
||||
const created = await cloudflareRequest<{ id: string }>(token, `/zones/${zoneId}/dns_records`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
type: "TXT",
|
||||
name: recordName,
|
||||
content: value,
|
||||
ttl: 120,
|
||||
}),
|
||||
})
|
||||
return created.id
|
||||
}
|
||||
|
||||
async function deleteTxtRecord(token: string, zoneId: string, recordId: string): Promise<void> {
|
||||
await cloudflareRequest(token, `/zones/${zoneId}/dns_records/${recordId}`, { method: "DELETE" })
|
||||
}
|
||||
|
||||
function parseWanUplinks(raw: string | null | undefined): WanUplinkRow[] {
|
||||
if (!raw?.trim()) return []
|
||||
try {
|
||||
const data = JSON.parse(raw) as unknown
|
||||
if (!Array.isArray(data)) return []
|
||||
return data.filter((row): row is WanUplinkRow => row != null && typeof row === "object")
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeIpv4(raw: string | undefined): string | null {
|
||||
const value = raw?.trim()
|
||||
if (!value) return null
|
||||
const host = value.split("/")[0]?.trim() ?? ""
|
||||
if (!/^\d{1,3}(?:\.\d{1,3}){3}$/.test(host)) return null
|
||||
return host
|
||||
}
|
||||
|
||||
function isPrivateIpv4(ip: string): boolean {
|
||||
const [a, b] = ip.split(".").map((part) => Number.parseInt(part, 10))
|
||||
if (a === 10) return true
|
||||
if (a === 127) return true
|
||||
if (a === 192 && b === 168) return true
|
||||
if (a === 172 && b >= 16 && b <= 31) return true
|
||||
return false
|
||||
}
|
||||
|
||||
async function resolveHostIpv4(host: string): Promise<string | null> {
|
||||
const literal = normalizeIpv4(host)
|
||||
if (literal) return literal
|
||||
const value = host.trim()
|
||||
if (!value) return null
|
||||
try {
|
||||
const result = await lookup(value, { family: 4 })
|
||||
return normalizeIpv4(result.address)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveServerPublicIp(server: Server, client?: MikrotikClient): Promise<string> {
|
||||
const uplinks = parseWanUplinks(server.wanUplinks)
|
||||
for (const uplink of uplinks) {
|
||||
const configuredIp = normalizeIpv4(uplink.ip)
|
||||
if (configuredIp) return configuredIp
|
||||
}
|
||||
|
||||
const hostIp = await resolveHostIpv4(server.host)
|
||||
if (hostIp) return hostIp
|
||||
|
||||
const rosClient = client ?? MikrotikClient.fromServer(server)
|
||||
const addresses = await rosClient.getIpAddresses()
|
||||
const ifaceNames = new Set(uplinks.map((u) => u.iface?.trim()).filter(Boolean))
|
||||
const preferred = addresses.filter((row) => ifaceNames.has(String(row.interface ?? "").trim()))
|
||||
const ordered = [...preferred, ...addresses]
|
||||
|
||||
for (const row of ordered) {
|
||||
const ip = normalizeIpv4(String(row.address ?? ""))
|
||||
if (ip && !isPrivateIpv4(ip)) return ip
|
||||
}
|
||||
for (const row of ordered) {
|
||||
const ip = normalizeIpv4(String(row.address ?? ""))
|
||||
if (ip) return ip
|
||||
}
|
||||
|
||||
throw new Error("Не удалось определить IP сервера для A-записи Cloudflare")
|
||||
}
|
||||
|
||||
async function listDnsRecordsByName(token: string, zoneId: string, fqdn: string): Promise<CfDnsRecord[]> {
|
||||
return cloudflareRequest<CfDnsRecord[]>(
|
||||
token,
|
||||
`/zones/${zoneId}/dns_records?name=${encodeURIComponent(fqdn)}`,
|
||||
)
|
||||
}
|
||||
|
||||
async function upsertARecord(token: string, zoneId: string, fqdn: string, ip: string): Promise<void> {
|
||||
const records = await listDnsRecordsByName(token, zoneId, fqdn)
|
||||
const existingA = records.find((record) => record.type === "A")
|
||||
if (existingA) {
|
||||
if (existingA.content === ip) return
|
||||
await cloudflareRequest<CfDnsRecord>(token, `/zones/${zoneId}/dns_records/${existingA.id}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({
|
||||
type: "A",
|
||||
name: fqdn,
|
||||
content: ip,
|
||||
ttl: 120,
|
||||
proxied: false,
|
||||
}),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (records.some((record) => record.type === "CNAME")) {
|
||||
throw new Error(`Для ${fqdn} уже есть CNAME в Cloudflare — A-запись не создана`)
|
||||
}
|
||||
|
||||
await cloudflareRequest<{ id: string }>(token, `/zones/${zoneId}/dns_records`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
type: "A",
|
||||
name: fqdn,
|
||||
content: ip,
|
||||
ttl: 120,
|
||||
proxied: false,
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
async function syncCertificateDomainRecords(
|
||||
token: string,
|
||||
domains: string[],
|
||||
serverIp: string,
|
||||
defaultZoneId?: string,
|
||||
): Promise<void> {
|
||||
for (const domain of domains) {
|
||||
const zoneId = await resolveZoneId(token, domain, defaultZoneId)
|
||||
await upsertARecord(token, zoneId, domain, serverIp)
|
||||
}
|
||||
}
|
||||
|
||||
async function sleep(ms: number) {
|
||||
await new Promise((resolve) => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
async function getOrCreateAccountKey(): Promise<Buffer> {
|
||||
const existing = getAcmeAccountPrivateKey()
|
||||
if (existing) return Buffer.from(existing)
|
||||
const key = await acme.crypto.createPrivateKey()
|
||||
saveAcmeAccountPrivateKey(key.toString("utf8"))
|
||||
return key
|
||||
}
|
||||
|
||||
async function ensureAcmeAccount(client: acme.Client): Promise<void> {
|
||||
try {
|
||||
client.getAccountUrl()
|
||||
return
|
||||
} catch {
|
||||
await client.createAccount({ termsOfServiceAgreed: true })
|
||||
}
|
||||
}
|
||||
|
||||
function splitPemCertificates(pem: string): string[] {
|
||||
return [...pem.matchAll(/-----BEGIN CERTIFICATE-----[\s\S]*?-----END CERTIFICATE-----/g)]
|
||||
.map((match) => match[0].trim())
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
export async function issueCertificateWithCloudflareDns(params: {
|
||||
server: Server
|
||||
certName: string
|
||||
domainNames: string[]
|
||||
keyType: "rsa2048" | "ec256"
|
||||
trustStore: string[]
|
||||
onStep?: (step: string) => void
|
||||
}): Promise<void> {
|
||||
const settings = getAcmeSettingsPublic()
|
||||
const token = getAcmeCloudflareToken()
|
||||
if (!token) throw new Error("Не настроен Cloudflare API token")
|
||||
|
||||
const domains = [...new Set(params.domainNames.map((d) => d.trim().toLowerCase()).filter(Boolean))]
|
||||
if (domains.length === 0) throw new Error("Нужен хотя бы один домен")
|
||||
|
||||
params.onStep?.("acme_order")
|
||||
const accountKey = await getOrCreateAccountKey()
|
||||
const client = new acme.Client({
|
||||
directoryUrl: settings.directoryUrl,
|
||||
accountKey,
|
||||
})
|
||||
await ensureAcmeAccount(client)
|
||||
|
||||
const altNames = domains.slice(1)
|
||||
const privateKey = params.keyType === "ec256"
|
||||
? await acme.crypto.createPrivateEcdsaKey("P-256")
|
||||
: await acme.crypto.createPrivateKey(2048)
|
||||
const [, csr] = await acme.crypto.createCsr({ commonName: domains[0], altNames }, privateKey)
|
||||
|
||||
const order = await client.createOrder({
|
||||
identifiers: domains.map((value) => ({ type: "dns", value })),
|
||||
})
|
||||
|
||||
const authorizations = await client.getAuthorizations(order)
|
||||
const txtCleanups: Array<{ zoneId: string; recordId: string }> = []
|
||||
|
||||
try {
|
||||
params.onStep?.("dns_challenge")
|
||||
for (const authz of authorizations) {
|
||||
const challenge = authz.challenges.find((c) => c.type === "dns-01")
|
||||
if (!challenge) throw new Error(`Нет DNS-01 challenge для ${authz.identifier.value}`)
|
||||
const txtValue = await client.getChallengeKeyAuthorization(challenge)
|
||||
const zoneId = await resolveZoneId(token, authz.identifier.value, settings.defaultZoneId)
|
||||
const recordName = `_acme-challenge.${authz.identifier.value}`
|
||||
const recordId = await createTxtRecord(token, zoneId, recordName, txtValue)
|
||||
txtCleanups.push({ zoneId, recordId })
|
||||
}
|
||||
|
||||
await sleep(15_000)
|
||||
|
||||
for (const authz of authorizations) {
|
||||
const challenge = authz.challenges.find((c) => c.type === "dns-01")
|
||||
if (!challenge) continue
|
||||
await client.verifyChallenge(authz, challenge)
|
||||
await client.completeChallenge(challenge)
|
||||
await client.waitForValidStatus(authz)
|
||||
}
|
||||
|
||||
params.onStep?.("finalize")
|
||||
const finalized = await client.finalizeOrder(order, csr)
|
||||
const certPem = await client.getCertificate(finalized)
|
||||
|
||||
const clientRos = MikrotikClient.fromServer(params.server)
|
||||
const serverIp = await resolveServerPublicIp(params.server, clientRos)
|
||||
await syncCertificateDomainRecords(token, domains, serverIp, settings.defaultZoneId)
|
||||
|
||||
const trustStores = params.trustStore.filter(Boolean)
|
||||
const effectiveTrustStores = trustStores.length > 0 ? trustStores : ["www", "api"]
|
||||
const trustStoreCsv = effectiveTrustStores.join(",")
|
||||
|
||||
params.onStep?.("import")
|
||||
const safeBase = params.certName.replace(/[^a-zA-Z0-9._-]+/g, "_")
|
||||
const chain = splitPemCertificates(certPem)
|
||||
const leafPem = chain[0] ?? certPem
|
||||
const chainRest = chain.slice(1)
|
||||
const certFile = `${safeBase}.crt`
|
||||
const keyFile = `${safeBase}.key`
|
||||
const certRouterFile = await clientRos.uploadTextFile(certFile, leafPem)
|
||||
const keyRouterFile = await clientRos.uploadTextFile(keyFile, privateKey.toString("utf8"))
|
||||
await clientRos.importCertificate({
|
||||
fileName: certRouterFile,
|
||||
name: params.certName,
|
||||
trusted: true,
|
||||
trustStore: trustStoreCsv,
|
||||
})
|
||||
await clientRos.importCertificate({
|
||||
fileName: keyRouterFile,
|
||||
name: params.certName,
|
||||
trusted: true,
|
||||
trustStore: trustStoreCsv,
|
||||
})
|
||||
|
||||
for (let index = 0; index < chainRest.length; index += 1) {
|
||||
const isRoot = index === chainRest.length - 1
|
||||
const caName = isRoot ? `root_${params.certName}` : `root_${params.certName}_${index + 1}`
|
||||
const caFile = `${safeBase}_ca_${index + 1}.crt`
|
||||
const caRouterFile = await clientRos.uploadTextFile(caFile, chainRest[index]!)
|
||||
await clientRos.importCertificate({
|
||||
fileName: caRouterFile,
|
||||
name: caName,
|
||||
trusted: true,
|
||||
trustStore: trustStoreCsv,
|
||||
})
|
||||
}
|
||||
|
||||
await clientRos.applyCertificateToServices(params.certName)
|
||||
} finally {
|
||||
params.onStep?.("cleanup")
|
||||
for (const item of txtCleanups) {
|
||||
try {
|
||||
await deleteTxtRecord(token, item.zoneId, item.recordId)
|
||||
} catch {
|
||||
/* ignore cleanup errors */
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import type { CertificateDto } from "@mmapp/contracts/certificates"
|
||||
|
||||
export type RosCertificateRow = Record<string, string | undefined>
|
||||
|
||||
function parseRosDate(raw: string | undefined): Date | null {
|
||||
if (!raw?.trim()) return null
|
||||
const d = new Date(raw)
|
||||
return Number.isNaN(d.getTime()) ? null : d
|
||||
}
|
||||
|
||||
function fmtDate(d: Date): string {
|
||||
return d.toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
function parseSans(raw: string | undefined): string[] {
|
||||
if (!raw?.trim()) return []
|
||||
return raw
|
||||
.split(",")
|
||||
.map((part) => part.trim())
|
||||
.map((part) => {
|
||||
const m = part.match(/^(?:DNS|IP|email):(.+)$/i)
|
||||
return (m?.[1] ?? part).trim()
|
||||
})
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
function parseUsage(raw: string | undefined): CertificateDto["usage"] {
|
||||
if (!raw?.trim()) return []
|
||||
const out = new Set<CertificateDto["usage"][number]>()
|
||||
for (const part of raw.split(",")) {
|
||||
const token = part.trim().toLowerCase()
|
||||
if (token === "tls-server") out.add("server")
|
||||
else if (token === "tls-client") out.add("client")
|
||||
else if (token === "key-cert-sign") out.add("ca")
|
||||
else if (token === "crl-sign") out.add("crl-sign")
|
||||
}
|
||||
return [...out]
|
||||
}
|
||||
|
||||
function parseFlags(raw: string | undefined): { revoked: boolean; expired: boolean } {
|
||||
const flags = (raw ?? "").toUpperCase()
|
||||
return {
|
||||
revoked: flags.includes("R"),
|
||||
expired: flags.includes("E"),
|
||||
}
|
||||
}
|
||||
|
||||
export function mapRosCertificateRow(
|
||||
serverId: number,
|
||||
serverName: string,
|
||||
row: RosCertificateRow,
|
||||
): CertificateDto | null {
|
||||
const name = (row.name ?? "").trim()
|
||||
if (!name) return null
|
||||
|
||||
const rosId = (row[".id"] ?? "").trim()
|
||||
const fingerprint = (row.fingerprint ?? "").trim()
|
||||
const serialNumber = (row["serial-number"] ?? "").trim()
|
||||
const stableId = rosId || fingerprint || serialNumber || name
|
||||
|
||||
const { revoked, expired: expiredFlag } = parseFlags(row.flags)
|
||||
const validFrom = parseRosDate(row["invalid-before"])
|
||||
const validUntil = parseRosDate(row["invalid-after"])
|
||||
const now = new Date()
|
||||
const daysLeft = validUntil
|
||||
? Math.ceil((validUntil.getTime() - now.getTime()) / 86_400_000)
|
||||
: 0
|
||||
|
||||
let status: CertificateDto["status"] = "valid"
|
||||
if (revoked) status = "revoked"
|
||||
else if (expiredFlag || (validUntil != null && validUntil.getTime() < now.getTime())) status = "expired"
|
||||
|
||||
const keySize = Number.parseInt(String(row["key-size"] ?? "0"), 10)
|
||||
|
||||
return {
|
||||
id: `${serverId}:${stableId}`,
|
||||
name,
|
||||
serverId: String(serverId),
|
||||
serverName,
|
||||
commonName: (row["common-name"] ?? name).trim(),
|
||||
sans: parseSans(row["subject-alt-name"]),
|
||||
issuedBy: (row.issuer ?? "—").trim() || "—",
|
||||
validFrom: validFrom ? fmtDate(validFrom) : "—",
|
||||
validUntil: validUntil ? fmtDate(validUntil) : "—",
|
||||
daysLeft,
|
||||
keySize: Number.isFinite(keySize) ? keySize : 0,
|
||||
usage: parseUsage(row["key-usage"]),
|
||||
trusted: row.trusted === "true",
|
||||
status,
|
||||
acmeStatus: row["acme-status"]?.trim() || undefined,
|
||||
}
|
||||
}
|
||||
|
||||
export function mapRosCertificates(
|
||||
serverId: number,
|
||||
serverName: string,
|
||||
rows: RosCertificateRow[],
|
||||
): CertificateDto[] {
|
||||
const seen = new Set<string>()
|
||||
return rows
|
||||
.map((row) => mapRosCertificateRow(serverId, serverName, row))
|
||||
.filter((row): row is CertificateDto => row != null)
|
||||
.map((cert, index) => {
|
||||
if (!seen.has(cert.id)) {
|
||||
seen.add(cert.id)
|
||||
return cert
|
||||
}
|
||||
const uniqueId = `${cert.id}#${index}`
|
||||
seen.add(uniqueId)
|
||||
return { ...cert, id: uniqueId }
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
import { eq } from "drizzle-orm"
|
||||
import type { CertificateDto } from "@mmapp/contracts/certificates"
|
||||
import { db } from "../db/index.js"
|
||||
import { acmeSettings, certificateIssueJobs, servers } from "../db/schema.js"
|
||||
import { mapRosCertificates } from "./certificate-parse.js"
|
||||
import { MikrotikClient } from "./mikrotik.js"
|
||||
|
||||
const SETTINGS_ID = 1
|
||||
|
||||
export async function listCertificatesFromServers(): Promise<{
|
||||
certificates: CertificateDto[]
|
||||
failures: Array<{ serverId: string; serverName?: string; error: string }>
|
||||
}> {
|
||||
const allServers = db.select().from(servers).where(eq(servers.enabled, true)).all()
|
||||
const certificates: CertificateDto[] = []
|
||||
const failures: Array<{ serverId: string; serverName?: string; error: string }> = []
|
||||
|
||||
await Promise.all(
|
||||
allServers.map(async (server) => {
|
||||
try {
|
||||
const client = MikrotikClient.fromServer(server)
|
||||
const rows = await client.getCertificates()
|
||||
certificates.push(...mapRosCertificates(server.id, server.name, rows))
|
||||
} catch (e) {
|
||||
failures.push({
|
||||
serverId: String(server.id),
|
||||
serverName: server.name,
|
||||
error: e instanceof Error ? e.message : String(e),
|
||||
})
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
certificates.sort((a, b) => {
|
||||
if (a.serverId !== b.serverId) return a.serverId.localeCompare(b.serverId)
|
||||
return a.name.localeCompare(b.name, "ru")
|
||||
})
|
||||
|
||||
return { certificates, failures }
|
||||
}
|
||||
|
||||
export function getAcmeSettingsPublic() {
|
||||
const row = db.select().from(acmeSettings).where(eq(acmeSettings.id, SETTINGS_ID)).limit(1).all()[0]
|
||||
if (!row) {
|
||||
return {
|
||||
directoryUrl: "https://acme-v02.api.letsencrypt.org/directory",
|
||||
defaultZoneId: "",
|
||||
tokenConfigured: false,
|
||||
updatedAt: undefined as string | undefined,
|
||||
}
|
||||
}
|
||||
return {
|
||||
directoryUrl: row.directoryUrl,
|
||||
defaultZoneId: row.defaultZoneId || undefined,
|
||||
tokenConfigured: Boolean(row.cloudflareApiToken?.trim()),
|
||||
updatedAt: row.updatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
export function getAcmeCloudflareToken(): string {
|
||||
const row = db.select().from(acmeSettings).where(eq(acmeSettings.id, SETTINGS_ID)).limit(1).all()[0]
|
||||
return row?.cloudflareApiToken?.trim() ?? ""
|
||||
}
|
||||
|
||||
export function getAcmeAccountPrivateKey(): string {
|
||||
const row = db.select().from(acmeSettings).where(eq(acmeSettings.id, SETTINGS_ID)).limit(1).all()[0]
|
||||
return row?.accountPrivateKey?.trim() ?? ""
|
||||
}
|
||||
|
||||
export function saveAcmeAccountPrivateKey(pem: string) {
|
||||
const now = new Date().toISOString()
|
||||
db.update(acmeSettings)
|
||||
.set({ accountPrivateKey: pem, updatedAt: now })
|
||||
.where(eq(acmeSettings.id, SETTINGS_ID))
|
||||
.run()
|
||||
}
|
||||
|
||||
export function updateAcmeSettings(input: {
|
||||
directoryUrl?: string
|
||||
defaultZoneId?: string | null
|
||||
cloudflareApiToken?: string | null
|
||||
}) {
|
||||
const row = db.select().from(acmeSettings).where(eq(acmeSettings.id, SETTINGS_ID)).limit(1).all()[0]
|
||||
const now = new Date().toISOString()
|
||||
const next = {
|
||||
directoryUrl: input.directoryUrl?.trim() || row?.directoryUrl || "https://acme-v02.api.letsencrypt.org/directory",
|
||||
defaultZoneId:
|
||||
input.defaultZoneId === null
|
||||
? ""
|
||||
: input.defaultZoneId?.trim() ?? row?.defaultZoneId ?? "",
|
||||
cloudflareApiToken:
|
||||
input.cloudflareApiToken === null
|
||||
? ""
|
||||
: input.cloudflareApiToken?.trim() ?? row?.cloudflareApiToken ?? "",
|
||||
updatedAt: now,
|
||||
}
|
||||
if (row) {
|
||||
db.update(acmeSettings).set(next).where(eq(acmeSettings.id, SETTINGS_ID)).run()
|
||||
} else {
|
||||
db.insert(acmeSettings).values({ id: SETTINGS_ID, accountPrivateKey: "", ...next }).run()
|
||||
}
|
||||
return getAcmeSettingsPublic()
|
||||
}
|
||||
|
||||
export function createIssueJobRecord(input: {
|
||||
id: string
|
||||
serverId: string
|
||||
certName: string
|
||||
domainNames: string[]
|
||||
keyType: string
|
||||
trustStore: string
|
||||
}) {
|
||||
const now = new Date().toISOString()
|
||||
db.insert(certificateIssueJobs).values({
|
||||
id: input.id,
|
||||
status: "queued",
|
||||
step: "queued",
|
||||
serverId: input.serverId,
|
||||
certName: input.certName,
|
||||
domainNames: JSON.stringify(input.domainNames),
|
||||
keyType: input.keyType,
|
||||
trustStore: input.trustStore,
|
||||
requestedAt: now,
|
||||
}).run()
|
||||
}
|
||||
|
||||
export function updateIssueJobRecord(
|
||||
id: string,
|
||||
patch: Partial<{
|
||||
status: "queued" | "running" | "done" | "failed"
|
||||
step: string
|
||||
startedAt: string
|
||||
finishedAt: string
|
||||
error: string | null
|
||||
}>,
|
||||
) {
|
||||
db.update(certificateIssueJobs).set(patch).where(eq(certificateIssueJobs.id, id)).run()
|
||||
}
|
||||
|
||||
export function getIssueJobRecord(id: string) {
|
||||
return db.select().from(certificateIssueJobs).where(eq(certificateIssueJobs.id, id)).limit(1).all()[0] ?? null
|
||||
}
|
||||
|
||||
export function toIssueJobDto(row: NonNullable<ReturnType<typeof getIssueJobRecord>>) {
|
||||
let domainNames: string[] = []
|
||||
try {
|
||||
const parsed = JSON.parse(row.domainNames) as unknown
|
||||
if (Array.isArray(parsed)) domainNames = parsed.map(String)
|
||||
} catch {
|
||||
domainNames = []
|
||||
}
|
||||
return {
|
||||
id: row.id,
|
||||
status: row.status,
|
||||
step: row.step as "queued" | "acme_order" | "dns_challenge" | "finalize" | "import" | "cleanup" | "done",
|
||||
serverId: row.serverId,
|
||||
certName: row.certName,
|
||||
domainNames,
|
||||
requestedAt: row.requestedAt,
|
||||
startedAt: row.startedAt ?? undefined,
|
||||
finishedAt: row.finishedAt ?? undefined,
|
||||
error: row.error ?? undefined,
|
||||
}
|
||||
}
|
||||
|
||||
export function getServerRowByIdString(serverId: string) {
|
||||
const id = Number.parseInt(serverId, 10)
|
||||
if (!Number.isFinite(id)) return null
|
||||
return db.select().from(servers).where(eq(servers.id, id)).limit(1).all()[0] ?? null
|
||||
}
|
||||
@@ -156,6 +156,62 @@ function rosPost(
|
||||
})
|
||||
}
|
||||
|
||||
function rosPut(
|
||||
params: MikrotikConnectParams,
|
||||
path: string,
|
||||
body: Record<string, string>,
|
||||
timeoutMs: number,
|
||||
): Promise<unknown> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const basePath = params.apiPath ?? "/rest"
|
||||
const authHeader = "Basic " + Buffer.from(`${params.username}:${params.password}`).toString("base64")
|
||||
const payload = JSON.stringify(body)
|
||||
|
||||
const options: https.RequestOptions = {
|
||||
hostname: params.host,
|
||||
port: params.port,
|
||||
path: basePath + path,
|
||||
method: "PUT",
|
||||
headers: {
|
||||
Authorization: authHeader,
|
||||
"Content-Type": "application/json",
|
||||
"Content-Length": Buffer.byteLength(payload),
|
||||
},
|
||||
rejectUnauthorized: params.useSsl ? params.verifySsl : undefined,
|
||||
}
|
||||
|
||||
const lib = params.useSsl ? https : http
|
||||
const timer = setTimeout(() => {
|
||||
req.destroy(new Error(`Connection to ${params.host}:${params.port} timed out after ${timeoutMs / 1000}s`))
|
||||
}, timeoutMs)
|
||||
|
||||
const req = lib.request(options, (res) => {
|
||||
let buf = ""
|
||||
res.setEncoding("utf8")
|
||||
res.on("data", (chunk: string) => { buf += chunk })
|
||||
res.on("end", () => {
|
||||
clearTimeout(timer)
|
||||
if (!res.statusCode || res.statusCode < 200 || res.statusCode >= 300) {
|
||||
reject(new MikrotikError(res.statusCode ?? 0, path, buf))
|
||||
return
|
||||
}
|
||||
try {
|
||||
resolve(buf.trim() ? JSON.parse(buf) : {})
|
||||
} catch {
|
||||
reject(new Error(`Invalid JSON from RouterOS: ${buf.slice(0, 200)}`))
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
req.on("error", (err) => {
|
||||
clearTimeout(timer)
|
||||
reject(err)
|
||||
})
|
||||
req.write(payload)
|
||||
req.end()
|
||||
})
|
||||
}
|
||||
|
||||
function rosDelete(
|
||||
params: MikrotikConnectParams,
|
||||
path: string,
|
||||
@@ -270,6 +326,18 @@ function unwrapRestSingle<T extends object>(raw: unknown): T {
|
||||
throw new Error("RouterOS REST: неожиданное тело ответа")
|
||||
}
|
||||
|
||||
function routerFileBasename(fileName: string): string {
|
||||
return fileName.replace(/^\/+/, "").split("/").pop() ?? fileName
|
||||
}
|
||||
|
||||
function matchesUploadedFile(entryName: string, requested: string): boolean {
|
||||
const base = routerFileBasename(requested)
|
||||
return entryName === requested
|
||||
|| entryName === base
|
||||
|| entryName === `flash/${base}`
|
||||
|| entryName.endsWith(`/${base}`)
|
||||
}
|
||||
|
||||
// ── MikrotikClient ─────────────────────────────────────────────────────────────
|
||||
|
||||
export class MikrotikClient {
|
||||
@@ -295,6 +363,10 @@ export class MikrotikClient {
|
||||
return rosPost(this.params, path, body, timeoutMs, signal) as Promise<T>
|
||||
}
|
||||
|
||||
async put<T>(path: string, body: Record<string, string>, timeoutMs = 15_000): Promise<T> {
|
||||
return rosPut(this.params, path, body, timeoutMs) as Promise<T>
|
||||
}
|
||||
|
||||
async delete(path: string, timeoutMs = 10_000): Promise<void> {
|
||||
return rosDelete(this.params, path, timeoutMs)
|
||||
}
|
||||
@@ -431,6 +503,98 @@ export class MikrotikClient {
|
||||
return this.post<Array<Record<string, string>>>("/tool/bandwidth-test", body, 30_000)
|
||||
}
|
||||
|
||||
async getCertificates(): Promise<Array<Record<string, string | undefined>>> {
|
||||
const raw = await this.get<unknown>("/certificate")
|
||||
if (!Array.isArray(raw)) return []
|
||||
return raw.filter((row): row is Record<string, string | undefined> => row != null && typeof row === "object")
|
||||
}
|
||||
|
||||
async listFiles(): Promise<Array<{ name: string }>> {
|
||||
const raw = await this.get<unknown>("/file")
|
||||
if (!Array.isArray(raw)) return []
|
||||
return raw
|
||||
.filter((row): row is Record<string, unknown> => row != null && typeof row === "object")
|
||||
.map((row) => ({ name: String(row.name ?? "") }))
|
||||
.filter((row) => row.name.length > 0)
|
||||
}
|
||||
|
||||
private async resolveUploadedFileName(requested: string): Promise<string> {
|
||||
const files = await this.listFiles()
|
||||
const match = files.find((file) => matchesUploadedFile(file.name, requested))
|
||||
if (!match) {
|
||||
throw new Error(`Файл ${routerFileBasename(requested)} не найден на RouterOS после загрузки`)
|
||||
}
|
||||
return match.name
|
||||
}
|
||||
|
||||
async uploadTextFile(fileName: string, contents: string, timeoutMs = 30_000): Promise<string> {
|
||||
const normalized = routerFileBasename(fileName)
|
||||
const payload = contents.endsWith("\n") ? contents : `${contents}\n`
|
||||
const candidates = [`flash/${normalized}`, normalized]
|
||||
let lastError: unknown
|
||||
|
||||
for (const name of candidates) {
|
||||
try {
|
||||
await this.put("/file", { name, contents: payload }, timeoutMs)
|
||||
return await this.resolveUploadedFileName(name)
|
||||
} catch (error) {
|
||||
lastError = error
|
||||
}
|
||||
|
||||
try {
|
||||
await this.post("/file/add", { name, contents: payload, type: "file" }, timeoutMs)
|
||||
return await this.resolveUploadedFileName(name)
|
||||
} catch (error) {
|
||||
lastError = error
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError instanceof Error
|
||||
? lastError
|
||||
: new Error(`Не удалось загрузить файл ${normalized} на RouterOS`)
|
||||
}
|
||||
|
||||
async importCertificate(params: {
|
||||
fileName: string
|
||||
name: string
|
||||
trusted?: boolean
|
||||
trustStore?: string
|
||||
passphrase?: string
|
||||
}): Promise<unknown> {
|
||||
const body: Record<string, string> = {
|
||||
"file-name": params.fileName,
|
||||
name: params.name,
|
||||
trusted: params.trusted === false ? "no" : "yes",
|
||||
"trust-store": params.trustStore?.trim() || "www,api",
|
||||
}
|
||||
if (params.passphrase?.trim()) body.passphrase = params.passphrase.trim()
|
||||
return this.post("/certificate/import", body, 60_000)
|
||||
}
|
||||
|
||||
async applyCertificateToServices(certName: string): Promise<void> {
|
||||
const body = {
|
||||
disabled: "no",
|
||||
certificate: certName,
|
||||
}
|
||||
for (const serviceName of ["www-ssl", "api-ssl"]) {
|
||||
await this.patchIpService(serviceName, body)
|
||||
}
|
||||
}
|
||||
|
||||
private async patchIpService(serviceName: string, body: Record<string, string>): Promise<void> {
|
||||
const pathByName = `/ip/service/${encodeURIComponent(serviceName)}`
|
||||
try {
|
||||
await this.patch(pathByName, body, 30_000)
|
||||
return
|
||||
} catch {
|
||||
const services = await this.get<Array<Record<string, string | undefined>>>("/ip/service")
|
||||
const row = services.find((service) => String(service.name ?? "") === serviceName)
|
||||
const id = row?.[".id"]
|
||||
if (!id) throw new Error(`Сервис RouterOS ${serviceName} не найден`)
|
||||
await this.patch(`/ip/service/${encodeURIComponent(id)}`, body, 30_000)
|
||||
}
|
||||
}
|
||||
|
||||
async exportConfigScript(): Promise<string> {
|
||||
const raw = await this.post<unknown>("/console/export", {}, 30_000)
|
||||
|
||||
|
||||
@@ -124,11 +124,11 @@ const navStructure: { label: string; items: NavItemBase[] }[] = [
|
||||
},
|
||||
]
|
||||
|
||||
type LiveSidebarCounts = SidebarCountsDto & { greTunnels?: number }
|
||||
type LiveSidebarCounts = SidebarCountsDto & { greTunnels?: number; certificates?: number }
|
||||
|
||||
export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||
const { resolvedTheme, setTheme } = useTheme()
|
||||
const { mode, backendUrl, backendStatus } = useDataSource()
|
||||
const { mode, backendUrl } = useDataSource()
|
||||
const evo = useEvoBGP()
|
||||
const [mounted, setMounted] = React.useState(false)
|
||||
const [liveCounts, setLiveCounts] = React.useState<LiveSidebarCounts | null>(null)
|
||||
@@ -142,7 +142,7 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||
}, [])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (mode !== "live" || backendStatus !== true) {
|
||||
if (mode !== "live") {
|
||||
setLiveCounts(null)
|
||||
return
|
||||
}
|
||||
@@ -176,15 +176,14 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||
cancelled = true
|
||||
window.clearInterval(id)
|
||||
}
|
||||
}, [mode, backendStatus, backendUrl])
|
||||
}, [mode, backendUrl])
|
||||
|
||||
const navGroups = React.useMemo((): NavGroup[] => {
|
||||
function badgeFor(url: string): string | undefined {
|
||||
if (mode !== "live") return mockBadges[url]
|
||||
if (backendStatus !== true) return undefined
|
||||
|
||||
if (url === "/domains" || url === "/ip-ranges" || url === "/asns") {
|
||||
if (!evo.enabled) return mockBadges[url]
|
||||
if (!evo.enabled) return undefined
|
||||
if (!evo.snapshot) return undefined
|
||||
if (url === "/domains") return formatSidebarBadgeCount(evo.snapshot.domains.length)
|
||||
if (url === "/ip-ranges") return formatSidebarBadgeCount(evo.snapshot.ipRanges.length)
|
||||
@@ -197,8 +196,9 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||
if (url === "/filters") return formatSidebarBadgeCount(liveCounts.filterRules)
|
||||
if (url === "/uptime") return formatSidebarBadgeCount(liveCounts.monitoringItems)
|
||||
if (url === "/gre") return formatSidebarBadgeCount(liveCounts.greTunnels ?? 0)
|
||||
if (url === "/certificates") return formatSidebarBadgeCount(liveCounts.certificates ?? 0)
|
||||
|
||||
if (url === "/wireguard" || url === "/containers" || url === "/certificates" || url === "/bgp") {
|
||||
if (url === "/wireguard" || url === "/containers" || url === "/bgp") {
|
||||
return undefined
|
||||
}
|
||||
|
||||
@@ -214,7 +214,7 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||
badge: mounted ? badgeFor(it.url) : undefined,
|
||||
})),
|
||||
}))
|
||||
}, [mounted, mode, backendStatus, liveCounts, mockBadges, evo.enabled, evo.snapshot])
|
||||
}, [mounted, mode, liveCounts, mockBadges, evo.enabled, evo.snapshot])
|
||||
|
||||
const isDark = (resolvedTheme ?? "dark") === "dark"
|
||||
const appVersionLabel = formatAppVersionLabel(getAppVersion())
|
||||
|
||||
+22
-11
@@ -14,6 +14,11 @@ import {
|
||||
|
||||
export type DataSourceMode = "mock" | "live"
|
||||
|
||||
/** Режим «Живые» в настройках: моковые данные не подставлять. */
|
||||
export function isLiveDataSourceMode(mode: DataSourceMode): boolean {
|
||||
return mode === "live"
|
||||
}
|
||||
|
||||
interface DataSourceContextValue {
|
||||
mode: DataSourceMode
|
||||
setMode: (m: DataSourceMode) => void
|
||||
@@ -21,6 +26,8 @@ interface DataSourceContextValue {
|
||||
setBackendUrl: (url: string) => void
|
||||
backendUrlLocked: boolean
|
||||
mockModeAvailable: boolean
|
||||
/** localStorage для mode/backendUrl применён на клиенте (после первого paint). */
|
||||
prefsHydrated: boolean
|
||||
/** undefined = не проверялось, true = OK, false = недоступен */
|
||||
backendStatus: boolean | undefined
|
||||
checkBackend: () => Promise<void>
|
||||
@@ -52,19 +59,22 @@ function normalizeBackendUrl(url: string): string {
|
||||
}
|
||||
|
||||
export function DataSourceProvider({ children }: { children: React.ReactNode }) {
|
||||
const [mode, setModeState] = useState<DataSourceMode>(() =>
|
||||
typeof window === "undefined" ? defaultDataSourceMode() : readStoredMode(),
|
||||
)
|
||||
const [backendUrl, setBackendUrlState] = useState(() =>
|
||||
typeof window === "undefined" ? LOCAL_DEFAULT_BACKEND_URL : readStoredBackendUrl(),
|
||||
)
|
||||
const [mode, setModeState] = useState<DataSourceMode>(defaultDataSourceMode)
|
||||
const [backendUrl, setBackendUrlState] = useState(LOCAL_DEFAULT_BACKEND_URL)
|
||||
const [prefsHydrated, setPrefsHydrated] = useState(false)
|
||||
const [backendStatus, setBackendStatus] = useState<boolean | undefined>(undefined)
|
||||
const backendUrlLocked = isBackendUrlLocked()
|
||||
const mockModeAvailable = isMockDataSourceAvailable()
|
||||
|
||||
useEffect(() => {
|
||||
if (configuredBackendUrl().kind !== "same-origin") return
|
||||
setBackendUrlState(window.location.origin)
|
||||
const storedMode = readStoredMode()
|
||||
let url = readStoredBackendUrl()
|
||||
if (configuredBackendUrl().kind === "same-origin") {
|
||||
url = window.location.origin
|
||||
}
|
||||
setModeState(storedMode)
|
||||
setBackendUrlState(url)
|
||||
setPrefsHydrated(true)
|
||||
}, [])
|
||||
|
||||
const setMode = useCallback((m: DataSourceMode) => {
|
||||
@@ -91,12 +101,13 @@ export function DataSourceProvider({ children }: { children: React.ReactNode })
|
||||
}, [backendUrl])
|
||||
|
||||
useEffect(() => {
|
||||
if (mode === "live") queueMicrotask(() => { void checkBackend() })
|
||||
}, [mode, checkBackend])
|
||||
if (!prefsHydrated || mode !== "live") return
|
||||
queueMicrotask(() => { void checkBackend() })
|
||||
}, [prefsHydrated, mode, checkBackend])
|
||||
|
||||
return (
|
||||
<DataSourceContext.Provider
|
||||
value={{ mode, setMode, backendUrl, setBackendUrl, backendUrlLocked, mockModeAvailable, backendStatus, checkBackend }}
|
||||
value={{ mode, setMode, backendUrl, setBackendUrl, backendUrlLocked, mockModeAvailable, prefsHydrated, backendStatus, checkBackend }}
|
||||
>
|
||||
{children}
|
||||
</DataSourceContext.Provider>
|
||||
|
||||
Generated
+343
-2
@@ -44,6 +44,7 @@
|
||||
"@fastify/cors": "^11.2.0",
|
||||
"@fastify/type-provider-zod": "^1.0.0",
|
||||
"@mmapp/contracts": "1.0.0",
|
||||
"acme-client": "^5.4.0",
|
||||
"better-sqlite3": "^12.9.0",
|
||||
"dotenv": "^16.4.7",
|
||||
"drizzle-orm": "^0.45.2",
|
||||
@@ -3075,6 +3076,163 @@
|
||||
"integrity": "sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@peculiar/asn1-cms": {
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-cms/-/asn1-cms-2.7.0.tgz",
|
||||
"integrity": "sha512-hew63shtzzvBcSHbhm+cyAmKe6AIfinT9hzEqSPjDC6opTTMKmTkQ0gHuN2KsWlvqiKw1S/fS94fhag/FJkioQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@peculiar/asn1-schema": "^2.7.0",
|
||||
"@peculiar/asn1-x509": "^2.7.0",
|
||||
"@peculiar/asn1-x509-attr": "^2.7.0",
|
||||
"asn1js": "^3.0.6",
|
||||
"tslib": "^2.8.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@peculiar/asn1-csr": {
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-csr/-/asn1-csr-2.7.0.tgz",
|
||||
"integrity": "sha512-VVsAyGqErT9D1SY4aEqozThXMVI+ssVRiv2DDeYuvpBKLIgZ3hYs3Ay3u/VSoKq6ESFi9cf6rf3IOOzfwh7oMA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@peculiar/asn1-schema": "^2.7.0",
|
||||
"@peculiar/asn1-x509": "^2.7.0",
|
||||
"asn1js": "^3.0.6",
|
||||
"tslib": "^2.8.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@peculiar/asn1-ecc": {
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-ecc/-/asn1-ecc-2.7.0.tgz",
|
||||
"integrity": "sha512-n7KEs/Q/wrB415cxy4fHOBhegp4NdJ15fkJPwcB/3/8iNBQC2L/N7SChJPKDJPZGYH0jD4Tg4/0vnHmwghnbKw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@peculiar/asn1-schema": "^2.7.0",
|
||||
"@peculiar/asn1-x509": "^2.7.0",
|
||||
"asn1js": "^3.0.6",
|
||||
"tslib": "^2.8.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@peculiar/asn1-pfx": {
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-pfx/-/asn1-pfx-2.7.0.tgz",
|
||||
"integrity": "sha512-V/nrlQVmhg7lYAsM7E13UDL5erAwFv6kCIVFqNaMIHSVi7dngcT839JkRTkQBqznMG98l2XjxYk74ZztAohZzA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@peculiar/asn1-cms": "^2.7.0",
|
||||
"@peculiar/asn1-pkcs8": "^2.7.0",
|
||||
"@peculiar/asn1-rsa": "^2.7.0",
|
||||
"@peculiar/asn1-schema": "^2.7.0",
|
||||
"asn1js": "^3.0.6",
|
||||
"tslib": "^2.8.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@peculiar/asn1-pkcs8": {
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs8/-/asn1-pkcs8-2.7.0.tgz",
|
||||
"integrity": "sha512-9GTl1nE8Mx1kTZ+7QyYatDyKsm34QcWRBFkY1iPvWC3X4Dona5s/tlLiQsx5WzVdZqiMBZNYT0buyw4/vbhnjw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@peculiar/asn1-schema": "^2.7.0",
|
||||
"@peculiar/asn1-x509": "^2.7.0",
|
||||
"asn1js": "^3.0.6",
|
||||
"tslib": "^2.8.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@peculiar/asn1-pkcs9": {
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs9/-/asn1-pkcs9-2.7.0.tgz",
|
||||
"integrity": "sha512-Bh7m+OuIaSEllPQcSd9OSp93F4ROWH7sbITWV8MI+8dwsjE5111/87VxiWVvYFKyww3vp39geLv9ENqhwWHcew==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@peculiar/asn1-cms": "^2.7.0",
|
||||
"@peculiar/asn1-pfx": "^2.7.0",
|
||||
"@peculiar/asn1-pkcs8": "^2.7.0",
|
||||
"@peculiar/asn1-schema": "^2.7.0",
|
||||
"@peculiar/asn1-x509": "^2.7.0",
|
||||
"@peculiar/asn1-x509-attr": "^2.7.0",
|
||||
"asn1js": "^3.0.6",
|
||||
"tslib": "^2.8.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@peculiar/asn1-rsa": {
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-rsa/-/asn1-rsa-2.7.0.tgz",
|
||||
"integrity": "sha512-/qvENQrXyTZURjMqSeofHul0JJt2sNSzSwk36pl2olkHbaioMQgrASDZAlHXl0xUlnVbHj0uGgOrBMTb5x2aJQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@peculiar/asn1-schema": "^2.7.0",
|
||||
"@peculiar/asn1-x509": "^2.7.0",
|
||||
"asn1js": "^3.0.6",
|
||||
"tslib": "^2.8.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@peculiar/asn1-schema": {
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.7.0.tgz",
|
||||
"integrity": "sha512-W8ZfWzLmQnrcky+eh3tni4IozMdqBDiHWU0N+vve/UGjMaUs8c0L7A2oEdkBXS8rTpWDpK/aoI3DG/L/hxmxPg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@peculiar/utils": "^2.0.2",
|
||||
"asn1js": "^3.0.6",
|
||||
"tslib": "^2.8.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@peculiar/asn1-x509": {
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-x509/-/asn1-x509-2.7.0.tgz",
|
||||
"integrity": "sha512-mUn9RRrkGDnG4ALfunDmzyRW5dg+sWCj/pfnCCqEHYbkGxEpvUt6iVJv8Yw1cyp6SWZ26ZE5oSmI5SqEaen15g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@peculiar/asn1-schema": "^2.7.0",
|
||||
"@peculiar/utils": "^2.0.2",
|
||||
"asn1js": "^3.0.6",
|
||||
"tslib": "^2.8.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@peculiar/asn1-x509-attr": {
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-x509-attr/-/asn1-x509-attr-2.7.0.tgz",
|
||||
"integrity": "sha512-NS8e7SOgXipkzUPLF/sce7ukpMpWjhxYsH0n6Y+bHYo4TTxOb95Zv7hqwSuL212mj5YxovjdOKQOgH1As3E94w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@peculiar/asn1-schema": "^2.7.0",
|
||||
"@peculiar/asn1-x509": "^2.7.0",
|
||||
"asn1js": "^3.0.6",
|
||||
"tslib": "^2.8.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@peculiar/utils": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/utils/-/utils-2.0.3.tgz",
|
||||
"integrity": "sha512-+oL3HPFRIZ1St2K50lWCXiioIgSoxzz7R1J3uF6neO2yl1sgmpgY6XXJH4BdpoDkMWznQTeYF6oWNDZLCdQ4eQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.8.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@peculiar/x509": {
|
||||
"version": "1.14.3",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/x509/-/x509-1.14.3.tgz",
|
||||
"integrity": "sha512-C2Xj8FZ0uHWeCXXqX5B4/gVFQmtSkiuOolzAgutjTfseNOHT3pUjljDZsTSxXFGgio54bCzVFqmEOUrIVk8RDA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@peculiar/asn1-cms": "^2.6.0",
|
||||
"@peculiar/asn1-csr": "^2.6.0",
|
||||
"@peculiar/asn1-ecc": "^2.6.0",
|
||||
"@peculiar/asn1-pkcs9": "^2.6.0",
|
||||
"@peculiar/asn1-rsa": "^2.6.0",
|
||||
"@peculiar/asn1-schema": "^2.6.0",
|
||||
"@peculiar/asn1-x509": "^2.6.0",
|
||||
"pvtsutils": "^1.3.6",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"tslib": "^2.8.1",
|
||||
"tsyringe": "^4.10.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@pinojs/redact": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz",
|
||||
@@ -4136,6 +4294,22 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/acme-client": {
|
||||
"version": "5.4.0",
|
||||
"resolved": "https://registry.npmjs.org/acme-client/-/acme-client-5.4.0.tgz",
|
||||
"integrity": "sha512-mORqg60S8iML6XSmVjqjGHJkINrCGLMj2QvDmFzI9vIlv1RGlyjmw3nrzaINJjkNsYXC41XhhD5pfy7CtuGcbA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@peculiar/x509": "^1.11.0",
|
||||
"asn1js": "^3.0.5",
|
||||
"axios": "^1.7.2",
|
||||
"debug": "^4.3.5",
|
||||
"node-forge": "^1.3.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 16"
|
||||
}
|
||||
},
|
||||
"node_modules/acorn": {
|
||||
"version": "8.16.0",
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
|
||||
@@ -4427,6 +4601,20 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/asn1js": {
|
||||
"version": "3.0.10",
|
||||
"resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.10.tgz",
|
||||
"integrity": "sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"pvtsutils": "^1.3.6",
|
||||
"pvutils": "^1.1.5",
|
||||
"tslib": "^2.8.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ast-types": {
|
||||
"version": "0.16.1",
|
||||
"resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.16.1.tgz",
|
||||
@@ -4456,6 +4644,12 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/asynckit": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
|
||||
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/atomic-sleep": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz",
|
||||
@@ -4511,6 +4705,17 @@
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/axios": {
|
||||
"version": "1.16.0",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.16.0.tgz",
|
||||
"integrity": "sha512-6hp5CwvTPlN2A31g5dxnwAX0orzM7pmCRDLnZSX772mv8WDqICwFjowHuPs04Mc8deIld1+ejhtaMn5vp6b+1w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"follow-redirects": "^1.16.0",
|
||||
"form-data": "^4.0.5",
|
||||
"proxy-from-env": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/axobject-query": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
|
||||
@@ -4977,6 +5182,18 @@
|
||||
"integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/combined-stream": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
|
||||
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"delayed-stream": "~1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/commander": {
|
||||
"version": "14.0.3",
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz",
|
||||
@@ -5341,6 +5558,15 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/delayed-stream": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
|
||||
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/depd": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
|
||||
@@ -5774,7 +6000,6 @@
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
|
||||
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
@@ -6807,6 +7032,26 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/follow-redirects": {
|
||||
"version": "1.16.0",
|
||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz",
|
||||
"integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://github.com/sponsors/RubenVerborgh"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=4.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"debug": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/for-each": {
|
||||
"version": "0.3.5",
|
||||
"resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz",
|
||||
@@ -6823,6 +7068,43 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/form-data": {
|
||||
"version": "4.0.5",
|
||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
|
||||
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"asynckit": "^0.4.0",
|
||||
"combined-stream": "^1.0.8",
|
||||
"es-set-tostringtag": "^2.1.0",
|
||||
"hasown": "^2.0.2",
|
||||
"mime-types": "^2.1.12"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/form-data/node_modules/mime-db": {
|
||||
"version": "1.52.0",
|
||||
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
|
||||
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/form-data/node_modules/mime-types": {
|
||||
"version": "2.1.35",
|
||||
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
|
||||
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mime-db": "1.52.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/formdata-polyfill": {
|
||||
"version": "4.0.10",
|
||||
"resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz",
|
||||
@@ -7214,7 +7496,6 @@
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
|
||||
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"has-symbols": "^1.0.3"
|
||||
@@ -9142,6 +9423,15 @@
|
||||
"url": "https://opencollective.com/node-fetch"
|
||||
}
|
||||
},
|
||||
"node_modules/node-forge": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.4.0.tgz",
|
||||
"integrity": "sha512-LarFH0+6VfriEhqMMcLX2F7SwSXeWwnEAJEsYm5QKWchiVYVvJyV9v7UDvUv+w5HO23ZpQTXDv/GxdDdMyOuoQ==",
|
||||
"license": "(BSD-3-Clause OR GPL-2.0)",
|
||||
"engines": {
|
||||
"node": ">= 6.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/node-releases": {
|
||||
"version": "2.0.38",
|
||||
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.38.tgz",
|
||||
@@ -9854,6 +10144,15 @@
|
||||
"node": ">= 0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/proxy-from-env": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
|
||||
"integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/pump": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz",
|
||||
@@ -9874,6 +10173,24 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/pvtsutils": {
|
||||
"version": "1.3.6",
|
||||
"resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.6.tgz",
|
||||
"integrity": "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.8.1"
|
||||
}
|
||||
},
|
||||
"node_modules/pvutils": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/pvutils/-/pvutils-1.1.5.tgz",
|
||||
"integrity": "sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=16.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/qs": {
|
||||
"version": "6.15.1",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz",
|
||||
@@ -10030,6 +10347,12 @@
|
||||
"node": ">= 4"
|
||||
}
|
||||
},
|
||||
"node_modules/reflect-metadata": {
|
||||
"version": "0.2.2",
|
||||
"resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz",
|
||||
"integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/reflect.getprototypeof": {
|
||||
"version": "1.0.10",
|
||||
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
|
||||
@@ -11935,6 +12258,24 @@
|
||||
"@esbuild/win32-x64": "0.27.7"
|
||||
}
|
||||
},
|
||||
"node_modules/tsyringe": {
|
||||
"version": "4.10.0",
|
||||
"resolved": "https://registry.npmjs.org/tsyringe/-/tsyringe-4.10.0.tgz",
|
||||
"integrity": "sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^1.9.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tsyringe/node_modules/tslib": {
|
||||
"version": "1.14.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz",
|
||||
"integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==",
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/tunnel-agent": {
|
||||
"version": "0.6.0",
|
||||
"resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
|
||||
|
||||
@@ -25,6 +25,10 @@
|
||||
"./events": {
|
||||
"types": "./dist/events.d.ts",
|
||||
"default": "./dist/events.js"
|
||||
},
|
||||
"./certificates": {
|
||||
"types": "./dist/certificates.d.ts",
|
||||
"default": "./dist/certificates.js"
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import { z } from "zod"
|
||||
|
||||
export const certUsageSchema = z.enum([
|
||||
"server",
|
||||
"client",
|
||||
"ca",
|
||||
"crl-sign",
|
||||
])
|
||||
|
||||
export const certStatusSchema = z.enum(["valid", "expired", "revoked"])
|
||||
|
||||
export const certificateDtoSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
name: z.string().min(1),
|
||||
serverId: z.string().min(1),
|
||||
serverName: z.string().optional(),
|
||||
commonName: z.string(),
|
||||
sans: z.array(z.string()),
|
||||
issuedBy: z.string(),
|
||||
validFrom: z.string(),
|
||||
validUntil: z.string(),
|
||||
daysLeft: z.number().int(),
|
||||
keySize: z.number().int().nonnegative(),
|
||||
usage: z.array(certUsageSchema),
|
||||
trusted: z.boolean(),
|
||||
status: certStatusSchema,
|
||||
acmeStatus: z.string().optional(),
|
||||
})
|
||||
|
||||
export const certificatesListResponseSchema = z.object({
|
||||
certificates: z.array(certificateDtoSchema),
|
||||
failures: z.array(z.object({
|
||||
serverId: z.string(),
|
||||
serverName: z.string().optional(),
|
||||
error: z.string(),
|
||||
})),
|
||||
})
|
||||
|
||||
export const certificateTrustStoreSchema = z.enum([
|
||||
"www",
|
||||
"api",
|
||||
"sstp",
|
||||
"ovpn",
|
||||
"ipsec",
|
||||
"email",
|
||||
"radius",
|
||||
"fetch",
|
||||
"container",
|
||||
])
|
||||
|
||||
export const certificateIssueRequestSchema = z.object({
|
||||
serverId: z.union([z.string(), z.number()]),
|
||||
certName: z.string().min(1).max(64),
|
||||
domainNames: z.array(z.string().min(1)).min(1),
|
||||
keyType: z.enum(["rsa2048", "ec256"]).optional(),
|
||||
trustStore: z.array(certificateTrustStoreSchema).optional(),
|
||||
})
|
||||
|
||||
export const certificateIssueJobStatusSchema = z.enum([
|
||||
"queued",
|
||||
"running",
|
||||
"done",
|
||||
"failed",
|
||||
])
|
||||
|
||||
export const certificateIssueStepSchema = z.enum([
|
||||
"queued",
|
||||
"acme_order",
|
||||
"dns_challenge",
|
||||
"finalize",
|
||||
"import",
|
||||
"cleanup",
|
||||
"done",
|
||||
])
|
||||
|
||||
export const certificateIssueJobSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
status: certificateIssueJobStatusSchema,
|
||||
step: certificateIssueStepSchema,
|
||||
serverId: z.string(),
|
||||
certName: z.string(),
|
||||
domainNames: z.array(z.string()),
|
||||
requestedAt: z.string(),
|
||||
startedAt: z.string().optional(),
|
||||
finishedAt: z.string().optional(),
|
||||
error: z.string().optional(),
|
||||
})
|
||||
|
||||
export const acmeCloudflareSettingsDtoSchema = z.object({
|
||||
directoryUrl: z.string().url(),
|
||||
defaultZoneId: z.string().optional(),
|
||||
tokenConfigured: z.boolean(),
|
||||
updatedAt: z.string().optional(),
|
||||
})
|
||||
|
||||
export const putAcmeCloudflareSettingsSchema = z.object({
|
||||
directoryUrl: z.string().url().optional(),
|
||||
defaultZoneId: z.union([z.string(), z.null()]).optional(),
|
||||
cloudflareApiToken: z.union([z.string(), z.null()]).optional(),
|
||||
})
|
||||
|
||||
export const testAcmeCloudflareSettingsSchema = z.object({
|
||||
cloudflareApiToken: z.string().optional(),
|
||||
})
|
||||
|
||||
export type CertificateDto = z.infer<typeof certificateDtoSchema>
|
||||
export type CertificatesListResponse = z.infer<typeof certificatesListResponseSchema>
|
||||
export type CertificateIssueRequest = z.infer<typeof certificateIssueRequestSchema>
|
||||
export type CertificateIssueJob = z.infer<typeof certificateIssueJobSchema>
|
||||
export type AcmeCloudflareSettingsDto = z.infer<typeof acmeCloudflareSettingsDtoSchema>
|
||||
@@ -8,6 +8,7 @@ export const eventSourceModuleSchema = z.enum([
|
||||
"filters",
|
||||
"traffic",
|
||||
"alerts",
|
||||
"certificates",
|
||||
"system",
|
||||
])
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from "./servers.js"
|
||||
export * from "./alerts.js"
|
||||
export * from "./events.js"
|
||||
export * from "./certificates.js"
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import type {
|
||||
AcmeCloudflareSettingsDto,
|
||||
CertificateIssueJob,
|
||||
CertificateIssueRequest,
|
||||
CertificatesListResponse,
|
||||
} from "@mmapp/contracts/certificates"
|
||||
import { requestJson } from "@/shared/api/http-client"
|
||||
|
||||
export async function listCertificates(baseUrl: string): Promise<CertificatesListResponse> {
|
||||
return requestJson<CertificatesListResponse>(baseUrl, "/api/certificates")
|
||||
}
|
||||
|
||||
export async function refreshCertificates(baseUrl: string): Promise<CertificatesListResponse> {
|
||||
return requestJson<CertificatesListResponse>(baseUrl, "/api/certificates/refresh", { method: "POST" })
|
||||
}
|
||||
|
||||
export async function createCertificateIssueJob(
|
||||
baseUrl: string,
|
||||
payload: CertificateIssueRequest,
|
||||
): Promise<{ jobId: string }> {
|
||||
return requestJson<{ jobId: string }>(baseUrl, "/api/certificates/issue", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
}
|
||||
|
||||
export async function getCertificateIssueJob(
|
||||
baseUrl: string,
|
||||
jobId: string,
|
||||
): Promise<CertificateIssueJob> {
|
||||
return requestJson<CertificateIssueJob>(baseUrl, `/api/certificates/issue/${encodeURIComponent(jobId)}`)
|
||||
}
|
||||
|
||||
export async function getAcmeSettings(baseUrl: string): Promise<AcmeCloudflareSettingsDto> {
|
||||
return requestJson<AcmeCloudflareSettingsDto>(baseUrl, "/api/certificates/acme-settings")
|
||||
}
|
||||
|
||||
export async function putAcmeSettings(
|
||||
baseUrl: string,
|
||||
payload: {
|
||||
directoryUrl?: string
|
||||
defaultZoneId?: string | null
|
||||
cloudflareApiToken?: string | null
|
||||
},
|
||||
): Promise<AcmeCloudflareSettingsDto> {
|
||||
return requestJson<AcmeCloudflareSettingsDto>(baseUrl, "/api/certificates/acme-settings", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
}
|
||||
|
||||
export async function testAcmeSettings(
|
||||
baseUrl: string,
|
||||
payload?: { cloudflareApiToken?: string },
|
||||
): Promise<{ ok: boolean; message?: string }> {
|
||||
return requestJson<{ ok: boolean; message?: string }>(baseUrl, "/api/certificates/acme-settings/test", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload ?? {}),
|
||||
})
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user