Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
43ab17b253 | ||
|
|
4b8cf83ee9 | ||
|
|
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)
|
||||
|
||||
@@ -20,7 +20,7 @@ import { cn } from "@/lib/utils"
|
||||
import { useDataSource } from "@/lib/data-source"
|
||||
import { listServers } from "@/shared/api/servers"
|
||||
import { toFrontendServer } from "@/entities/server/model/mappers"
|
||||
import { createBackupsAsync, deleteBackup, getBackupJob, listBackups, type BackupItem } from "@/shared/api/backups"
|
||||
import { createBackupsAsync, deleteBackup, getBackupJob, getBackupScheduleSettings, listBackups, putBackupScheduleSettings, type BackupItem } from "@/shared/api/backups"
|
||||
import { toast } from "sonner"
|
||||
|
||||
// ─── small UI helpers ─────────────────────────────────────────────────────────
|
||||
@@ -116,6 +116,7 @@ export default function BackupsPage() {
|
||||
const [opBusy, setOpBusy] = useState(false)
|
||||
const [opError, setOpError] = useState<string | null>(null)
|
||||
const [backupJobId, setBackupJobId] = useState<string | null>(null)
|
||||
const [scheduleSaveBusy, setScheduleSaveBusy] = useState(false)
|
||||
|
||||
// Schedule
|
||||
const [schedule, setSchedule] = useState(defaultSchedule)
|
||||
@@ -140,7 +141,29 @@ export default function BackupsPage() {
|
||||
}
|
||||
|
||||
function handleSave() {
|
||||
toast.success("Настройки сохранены")
|
||||
if (scheduleSaveBusy) return
|
||||
setScheduleSaveBusy(true)
|
||||
setOpError(null)
|
||||
void (async () => {
|
||||
try {
|
||||
await putBackupScheduleSettings(backendUrl, {
|
||||
enabled: schedule.enabled,
|
||||
frequency: schedule.frequency,
|
||||
hour: schedule.hour,
|
||||
minute: schedule.minute,
|
||||
weekDay: schedule.weekDay,
|
||||
monthDay: schedule.monthDay,
|
||||
keepCount: schedule.keepCount,
|
||||
format: schedule.format,
|
||||
serverIds: [...selectedServers],
|
||||
})
|
||||
toast.success("Настройки сохранены")
|
||||
} catch (e) {
|
||||
setOpError(e instanceof Error ? e.message : "Ошибка сохранения расписания")
|
||||
} finally {
|
||||
setScheduleSaveBusy(false)
|
||||
}
|
||||
})()
|
||||
}
|
||||
|
||||
// Manual backup sheet
|
||||
@@ -171,14 +194,28 @@ export default function BackupsPage() {
|
||||
setLoading(true)
|
||||
setOpError(null)
|
||||
try {
|
||||
const [serversRows, backupsRows] = await Promise.all([
|
||||
const [serversRows, backupsRows, scheduleRow] = await Promise.all([
|
||||
listServers(backendUrl),
|
||||
listBackups(backendUrl),
|
||||
getBackupScheduleSettings(backendUrl),
|
||||
])
|
||||
const mappedServers = serversRows.map((s) => toFrontendServer(s))
|
||||
setLiveServers(mappedServers)
|
||||
setBackupList(backupsRows.map(mapApiBackupToUi))
|
||||
setSelectedServers(new Set(mappedServers.map((s) => s.id)))
|
||||
setSchedule({
|
||||
enabled: scheduleRow.enabled,
|
||||
frequency: scheduleRow.frequency,
|
||||
hour: scheduleRow.hour,
|
||||
minute: scheduleRow.minute,
|
||||
weekDay: scheduleRow.weekDay,
|
||||
monthDay: scheduleRow.monthDay,
|
||||
keepCount: scheduleRow.keepCount,
|
||||
format: scheduleRow.format,
|
||||
})
|
||||
const serverIds = scheduleRow.serverIds.length > 0
|
||||
? scheduleRow.serverIds
|
||||
: mappedServers.map((s) => s.id)
|
||||
setSelectedServers(new Set(serverIds.filter((id) => mappedServers.some((s) => s.id === id))))
|
||||
} catch (e) {
|
||||
setOpError(e instanceof Error ? e.message : "Ошибка загрузки данных")
|
||||
} finally {
|
||||
@@ -711,7 +748,7 @@ export default function BackupsPage() {
|
||||
|
||||
{/* Save button */}
|
||||
<div className="lg:col-span-2 flex items-center gap-3">
|
||||
<Button onClick={handleSave} className="gap-2">
|
||||
<Button onClick={handleSave} className="gap-2" disabled={scheduleSaveBusy}>
|
||||
Сохранить настройки
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -31,6 +31,8 @@ import {
|
||||
type AlertEngineRunSnapshot,
|
||||
type GreBgpSnapshotRunSnapshot,
|
||||
type InternetPathRunSnapshot,
|
||||
type CertificatesRenewRunSnapshot,
|
||||
type BackupsRunSnapshot,
|
||||
type PingRunSnapshot,
|
||||
type ResourcesRunSnapshot,
|
||||
type SchedulerRunSnapshot,
|
||||
@@ -464,6 +466,73 @@ function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (snap.job === "certificates_renew") {
|
||||
const c = snap as CertificatesRenewRunSnapshot
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{c.skipped ? (
|
||||
<p className="text-xs text-amber-600 dark:text-amber-400">Прогон пропущен: предыдущая проверка ещё выполнялась или задача отключена.</p>
|
||||
) : null}
|
||||
<dl className="grid grid-cols-2 gap-3 text-xs sm:grid-cols-4">
|
||||
<div>
|
||||
<dt className="text-muted-foreground">Проверено</dt>
|
||||
<dd className="font-mono font-medium">{c.checked}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-muted-foreground">Обновлено</dt>
|
||||
<dd className="font-mono font-medium">{c.renewed}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-muted-foreground">Пропущено</dt>
|
||||
<dd className="font-mono font-medium">{c.skippedTargets}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-muted-foreground">Ошибки</dt>
|
||||
<dd className="font-mono font-medium">{c.errors.length}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
{c.errors.length ? (
|
||||
<div className="rounded-md border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-xs text-amber-800 dark:text-amber-200 space-y-1">
|
||||
{c.errors.map((e, i) => (
|
||||
<p key={i} className="break-words">{e}</p>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (snap.job === "backups") {
|
||||
const b = snap as BackupsRunSnapshot
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<dl className="grid grid-cols-2 gap-3 text-xs sm:grid-cols-4">
|
||||
<div>
|
||||
<dt className="text-muted-foreground">Слот расписания</dt>
|
||||
<dd className="font-mono font-medium">{b.due ? "да" : "нет"}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-muted-foreground">Создано</dt>
|
||||
<dd className="font-mono font-medium">{b.created}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-muted-foreground">Ошибки</dt>
|
||||
<dd className="font-mono font-medium">{b.failures}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-muted-foreground">Удалено старых</dt>
|
||||
<dd className="font-mono font-medium">{b.pruned}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
{b.errors?.length ? (
|
||||
<div className="rounded-md border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-xs text-amber-800 dark:text-amber-200 space-y-1">
|
||||
{b.errors.map((e, i) => (
|
||||
<p key={i} className="break-words">{e}</p>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (snap.job === "alert_engine") {
|
||||
const a = snap as AlertEngineRunSnapshot
|
||||
const transitionRu = (t: AlertEngineRuleDiagSnapshot["hitTransition"]) => {
|
||||
@@ -661,8 +730,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)
|
||||
@@ -683,6 +752,9 @@ export default function DataCollectionPage() {
|
||||
const [draftSpeedEnabled, setDraftSpeedEnabled] = useState(true)
|
||||
const [draftInternetPathEnabled, setDraftInternetPathEnabled] = useState(true)
|
||||
const [internetPathIntervalDraft, setInternetPathIntervalDraft] = useState("300")
|
||||
const [draftCertRenewEnabled, setDraftCertRenewEnabled] = useState(true)
|
||||
const [certRenewIntervalDraft, setCertRenewIntervalDraft] = useState("21600")
|
||||
const [renewBeforeDaysDraft, setRenewBeforeDaysDraft] = useState("30")
|
||||
const [schedulerRuns, setSchedulerRuns] = useState<SchedulerRunRowDto[]>([])
|
||||
const [runFilterJobKey, setRunFilterJobKey] = useState<string>("")
|
||||
const [runNowJobKey, setRunNowJobKey] = useState<string | null>(null)
|
||||
@@ -714,11 +786,12 @@ export default function DataCollectionPage() {
|
||||
runFilterJobKey && SCHEDULER_JOB_KEYS.includes(runFilterJobKey as (typeof SCHEDULER_JOB_KEYS)[number])
|
||||
? `?limit=80&jobKey=${encodeURIComponent(runFilterJobKey)}`
|
||||
: "?limit=80"
|
||||
const [traffic, serversApi, uptime, internetPath, runsRes] = await Promise.all([
|
||||
const [traffic, serversApi, uptime, internetPath, certRenew, runsRes] = await Promise.all([
|
||||
apiFetch<CollectorSettingsDto>("/api/traffic/settings"),
|
||||
apiFetch<CollectorSettingsDto>("/api/servers-api-ping/settings"),
|
||||
apiFetch<UptimeSettingsDto>("/api/uptime/settings"),
|
||||
apiFetch<CollectorSettingsDto>("/api/internet-path/settings"),
|
||||
apiFetch<{ enabled: boolean; intervalSec: number; renewBeforeDays: number }>("/api/certificates/renew-settings"),
|
||||
apiFetch<{ runs: SchedulerRunRowDto[] }>(`/api/scheduler/runs${runsQuery}`),
|
||||
])
|
||||
setTrafficCollector(traffic)
|
||||
@@ -740,6 +813,9 @@ export default function DataCollectionPage() {
|
||||
setDraftSpeedEnabled(!!(uptime.speedEnabled ?? uptime.enabled))
|
||||
setDraftInternetPathEnabled(!!internetPath.enabled)
|
||||
setInternetPathIntervalDraft(String(internetPath.intervalSec ?? 300))
|
||||
setDraftCertRenewEnabled(!!certRenew.enabled)
|
||||
setCertRenewIntervalDraft(String(certRenew.intervalSec ?? 21600))
|
||||
setRenewBeforeDaysDraft(String(certRenew.renewBeforeDays ?? 30))
|
||||
} catch (e) {
|
||||
setCollectorError(e instanceof Error ? e.message : "Не удалось загрузить данные")
|
||||
} finally {
|
||||
@@ -781,8 +857,10 @@ export default function DataCollectionPage() {
|
||||
if (draftPingEnabled) n += 1
|
||||
if (draftSpeedEnabled) n += 1
|
||||
if (draftInternetPathEnabled) n += 1
|
||||
if (draftCertRenewEnabled) n += 1
|
||||
return n
|
||||
}, [
|
||||
draftCertRenewEnabled,
|
||||
draftInternetPathEnabled,
|
||||
draftPingEnabled,
|
||||
draftResourcesEnabled,
|
||||
@@ -936,7 +1014,7 @@ export default function DataCollectionPage() {
|
||||
<tbody className="divide-y divide-border">
|
||||
{SCHEDULER_JOB_KEYS.map((jobKey) => {
|
||||
const j = schedulerJobsByKey[jobKey]
|
||||
const fixedSchedule = jobKey === "gre_bgp" || jobKey === "alert_engine"
|
||||
const fixedSchedule = jobKey === "gre_bgp" || jobKey === "alert_engine" || jobKey === "backups"
|
||||
const en = fixedSchedule
|
||||
? Boolean(j?.enabled ?? true)
|
||||
: jobKey === "traffic"
|
||||
@@ -949,7 +1027,9 @@ export default function DataCollectionPage() {
|
||||
? draftPingEnabled
|
||||
: jobKey === "uptime_speed"
|
||||
? draftSpeedEnabled
|
||||
: draftInternetPathEnabled
|
||||
: jobKey === "certificates_renew"
|
||||
? draftCertRenewEnabled
|
||||
: draftInternetPathEnabled
|
||||
const iv = fixedSchedule
|
||||
? String(j?.intervalSec ?? (jobKey === "gre_bgp" ? 30 : 20))
|
||||
: jobKey === "traffic"
|
||||
@@ -962,7 +1042,9 @@ export default function DataCollectionPage() {
|
||||
? uptimeIntervalDraft
|
||||
: jobKey === "uptime_speed"
|
||||
? uptimeSpeedIntervalDraft
|
||||
: internetPathIntervalDraft
|
||||
: jobKey === "certificates_renew"
|
||||
? certRenewIntervalDraft
|
||||
: internetPathIntervalDraft
|
||||
const setIv = fixedSchedule
|
||||
? () => {}
|
||||
: jobKey === "traffic"
|
||||
@@ -975,7 +1057,9 @@ export default function DataCollectionPage() {
|
||||
? setUptimeIntervalDraft
|
||||
: jobKey === "uptime_speed"
|
||||
? setUptimeSpeedIntervalDraft
|
||||
: setInternetPathIntervalDraft
|
||||
: jobKey === "certificates_renew"
|
||||
? setCertRenewIntervalDraft
|
||||
: setInternetPathIntervalDraft
|
||||
const defSec = fixedSchedule
|
||||
? Number(j?.intervalSec ?? (jobKey === "gre_bgp" ? 30 : 20))
|
||||
: jobKey === "traffic"
|
||||
@@ -988,7 +1072,9 @@ export default function DataCollectionPage() {
|
||||
? 15
|
||||
: jobKey === "uptime_speed"
|
||||
? 60
|
||||
: 300
|
||||
: jobKey === "certificates_renew"
|
||||
? 21600
|
||||
: 300
|
||||
return (
|
||||
<tr key={jobKey} className="hover:bg-muted/40">
|
||||
<td className="px-5 py-3 align-top">
|
||||
@@ -1009,6 +1095,7 @@ export default function DataCollectionPage() {
|
||||
else if (jobKey === "uptime_resources") setDraftResourcesEnabled(v)
|
||||
else if (jobKey === "uptime_ping") setDraftPingEnabled(v)
|
||||
else if (jobKey === "uptime_speed") setDraftSpeedEnabled(v)
|
||||
else if (jobKey === "certificates_renew") setDraftCertRenewEnabled(v)
|
||||
else setDraftInternetPathEnabled(v)
|
||||
}}
|
||||
/>
|
||||
@@ -1111,6 +1198,15 @@ export default function DataCollectionPage() {
|
||||
inputMode="numeric"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground mb-1.5">Обновлять сертификаты за (дней)</p>
|
||||
<Input
|
||||
value={renewBeforeDaysDraft}
|
||||
onChange={(e) => setRenewBeforeDaysDraft(e.target.value)}
|
||||
className="h-8 text-sm"
|
||||
inputMode="numeric"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-[11px] text-muted-foreground leading-snug">
|
||||
Для ping-проб у отдельных записей в мониторинге можно задать свой интервал (0 = глобальный «Интервал ping» в таблице).
|
||||
@@ -1157,6 +1253,8 @@ export default function DataCollectionPage() {
|
||||
retentionDays: uRet,
|
||||
}),
|
||||
})
|
||||
const certRenewInt = Math.max(300, Number.parseInt(certRenewIntervalDraft, 10) || 21600)
|
||||
const renewBeforeDays = Math.max(1, Math.min(90, Number.parseInt(renewBeforeDaysDraft, 10) || 30))
|
||||
await apiFetch("/api/internet-path/settings", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({
|
||||
@@ -1164,6 +1262,14 @@ export default function DataCollectionPage() {
|
||||
intervalSec: ipInt,
|
||||
}),
|
||||
})
|
||||
await apiFetch("/api/certificates/renew-settings", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({
|
||||
enabled: draftCertRenewEnabled,
|
||||
intervalSec: certRenewInt,
|
||||
renewBeforeDays,
|
||||
}),
|
||||
})
|
||||
await loadCollectors()
|
||||
} catch (e) {
|
||||
setCollectorError(e instanceof Error ? e.message : "Не удалось сохранить")
|
||||
|
||||
@@ -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,59 @@ 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',
|
||||
source TEXT NOT NULL DEFAULT 'manual',
|
||||
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 certificate_renew_settings (
|
||||
id INTEGER PRIMARY KEY,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
interval_sec INTEGER NOT NULL DEFAULT 21600,
|
||||
renew_before_days INTEGER NOT NULL DEFAULT 30,
|
||||
last_collected_at TEXT,
|
||||
last_duration_ms INTEGER,
|
||||
last_error TEXT,
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS backup_schedule_settings (
|
||||
id INTEGER PRIMARY KEY,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
frequency TEXT NOT NULL DEFAULT 'daily',
|
||||
hour INTEGER NOT NULL DEFAULT 3,
|
||||
minute INTEGER NOT NULL DEFAULT 0,
|
||||
week_day INTEGER NOT NULL DEFAULT 0,
|
||||
month_day INTEGER NOT NULL DEFAULT 1,
|
||||
keep_count INTEGER NOT NULL DEFAULT 7,
|
||||
format TEXT NOT NULL DEFAULT 'rsc',
|
||||
server_ids_json TEXT NOT NULL DEFAULT '[]',
|
||||
last_run_at TEXT,
|
||||
last_duration_ms INTEGER,
|
||||
last_error TEXT,
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS alert_rules (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
@@ -587,6 +640,29 @@ 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);
|
||||
`)
|
||||
|
||||
const certIssueJobCols = sqlite.prepare(`PRAGMA table_info('certificate_issue_jobs')`).all() as Array<{ name?: string }>
|
||||
if (!certIssueJobCols.some((c) => c.name === "source")) {
|
||||
sqlite.exec(`ALTER TABLE certificate_issue_jobs ADD COLUMN source TEXT NOT NULL DEFAULT 'manual'`)
|
||||
}
|
||||
|
||||
sqlite.exec(`
|
||||
INSERT INTO certificate_renew_settings (id, enabled, interval_sec, renew_before_days)
|
||||
SELECT 1, 1, 21600, 30
|
||||
WHERE NOT EXISTS (SELECT 1 FROM certificate_renew_settings WHERE id = 1);
|
||||
`)
|
||||
|
||||
sqlite.exec(`
|
||||
INSERT INTO backup_schedule_settings (id, enabled, frequency, hour, minute, week_day, month_day, keep_count, format, server_ids_json)
|
||||
SELECT 1, 1, 'daily', 3, 0, 0, 1, 7, 'rsc', '[]'
|
||||
WHERE NOT EXISTS (SELECT 1 FROM backup_schedule_settings WHERE id = 1);
|
||||
`)
|
||||
|
||||
sqlite.exec(`
|
||||
INSERT INTO alert_engine_cursor (id, last_source_finished_at)
|
||||
SELECT 1, NULL
|
||||
|
||||
@@ -287,6 +287,59 @@ 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"),
|
||||
source: text("source", { enum: ["manual", "scheduler"] }).notNull().default("manual"),
|
||||
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"),
|
||||
})
|
||||
|
||||
export const certificateRenewSettings = sqliteTable("certificate_renew_settings", {
|
||||
id: integer("id").primaryKey(),
|
||||
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
|
||||
intervalSec: integer("interval_sec").notNull().default(21600),
|
||||
renewBeforeDays: integer("renew_before_days").notNull().default(30),
|
||||
lastCollectedAt: text("last_collected_at"),
|
||||
lastDurationMs: integer("last_duration_ms"),
|
||||
lastError: text("last_error"),
|
||||
updatedAt: text("updated_at").notNull().default(sql`(datetime('now'))`),
|
||||
})
|
||||
|
||||
export const backupScheduleSettings = sqliteTable("backup_schedule_settings", {
|
||||
id: integer("id").primaryKey(),
|
||||
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
|
||||
frequency: text("frequency", { enum: ["daily", "weekly", "monthly"] }).notNull().default("daily"),
|
||||
hour: integer("hour").notNull().default(3),
|
||||
minute: integer("minute").notNull().default(0),
|
||||
weekDay: integer("week_day").notNull().default(0),
|
||||
monthDay: integer("month_day").notNull().default(1),
|
||||
keepCount: integer("keep_count").notNull().default(7),
|
||||
format: text("format", { enum: ["rsc", "backup"] }).notNull().default("rsc"),
|
||||
serverIdsJson: text("server_ids_json").notNull().default("[]"),
|
||||
lastRunAt: text("last_run_at"),
|
||||
lastDurationMs: integer("last_duration_ms"),
|
||||
lastError: text("last_error"),
|
||||
updatedAt: text("updated_at").notNull().default(sql`(datetime('now'))`),
|
||||
})
|
||||
|
||||
/** Группы правил: ANY = хотя бы одно; ALL = все одновременно в окне тика. */
|
||||
export const alertGroups = sqliteTable("alert_groups", {
|
||||
id: text("id").primaryKey(),
|
||||
@@ -505,6 +558,10 @@ 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 CertificateRenewSettingsRow = typeof certificateRenewSettings.$inferSelect
|
||||
export type BackupScheduleSettingsRow = typeof backupScheduleSettings.$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" })
|
||||
|
||||
|
||||
@@ -1,23 +1,21 @@
|
||||
import { randomUUID } from "node:crypto"
|
||||
import { mkdir, readFile, rm, stat, writeFile } from "node:fs/promises"
|
||||
import { readFile, rm } from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import { z } from "zod"
|
||||
import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
|
||||
import { putBackupScheduleSettingsSchema } from "@mmapp/contracts/backups"
|
||||
import { listServersRead } from "../modules/servers/service/servers-service.js"
|
||||
import { getServerRowById } from "../modules/servers/repository/servers-repository.js"
|
||||
import { MikrotikClient } from "../services/mikrotik.js"
|
||||
import { appendEvent } from "../modules/events/service/events-service.js"
|
||||
|
||||
type BackupMeta = {
|
||||
id: string
|
||||
serverId: string
|
||||
serverName: string
|
||||
filename: string
|
||||
sizeBytes: number
|
||||
createdAt: string
|
||||
kind: "manual"
|
||||
notes?: string
|
||||
}
|
||||
import { refreshScheduler } from "../services/scheduler.js"
|
||||
import {
|
||||
getBackupsDir,
|
||||
getBackupScheduleSettings,
|
||||
readBackupIndex,
|
||||
runBackupForServer,
|
||||
updateBackupScheduleSettings,
|
||||
writeBackupIndex,
|
||||
type BackupMeta,
|
||||
} from "../services/backup-service.js"
|
||||
|
||||
type BackupJobStatus = "queued" | "running" | "done" | "failed"
|
||||
type BackupJob = {
|
||||
@@ -32,36 +30,8 @@ type BackupJob = {
|
||||
failures: Array<{ serverId: string; error: string }>
|
||||
}
|
||||
|
||||
const BACKUPS_DIR = path.resolve(process.cwd(), "storage", "backups")
|
||||
const INDEX_PATH = path.join(BACKUPS_DIR, "index.json")
|
||||
const backupJobs = new Map<string, BackupJob>()
|
||||
|
||||
async function ensureStorage() {
|
||||
await mkdir(BACKUPS_DIR, { recursive: true })
|
||||
}
|
||||
|
||||
async function readIndex(): Promise<BackupMeta[]> {
|
||||
await ensureStorage()
|
||||
try {
|
||||
const raw = await readFile(INDEX_PATH, "utf8")
|
||||
const parsed = JSON.parse(raw) as unknown
|
||||
if (!Array.isArray(parsed)) return []
|
||||
return parsed as BackupMeta[]
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
async function writeIndex(rows: BackupMeta[]): Promise<void> {
|
||||
await ensureStorage()
|
||||
await writeFile(INDEX_PATH, JSON.stringify(rows, null, 2), "utf8")
|
||||
}
|
||||
|
||||
function fmtTs(d = new Date()): string {
|
||||
const p = (n: number) => String(n).padStart(2, "0")
|
||||
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}_${p(d.getHours())}-${p(d.getMinutes())}-${p(d.getSeconds())}`
|
||||
}
|
||||
|
||||
const CreateBackupBodySchema = z.object({
|
||||
serverIds: z.array(z.union([z.string(), z.number()])).min(1),
|
||||
notes: z.string().max(500).optional(),
|
||||
@@ -74,42 +44,13 @@ const BackupJobIdParamSchema = z.object({
|
||||
jobId: z.string().min(1),
|
||||
})
|
||||
|
||||
async function runBackupForServer(id: string, notes?: string): Promise<BackupMeta> {
|
||||
const serverIdNum = Number.parseInt(id, 10)
|
||||
if (!Number.isFinite(serverIdNum)) {
|
||||
throw new Error("Невалидный id сервера")
|
||||
}
|
||||
const row = getServerRowById(serverIdNum)
|
||||
if (!row) {
|
||||
throw new Error("Сервер не найден")
|
||||
}
|
||||
const client = MikrotikClient.fromServer(row)
|
||||
const script = await client.exportConfigScript()
|
||||
const ts = fmtTs()
|
||||
const safeServer = row.name.replace(/[^a-zA-Z0-9._-]+/g, "_")
|
||||
const filename = `${safeServer}_${ts}.rsc`
|
||||
const filePath = path.join(BACKUPS_DIR, filename)
|
||||
await writeFile(filePath, script, "utf8")
|
||||
const st = await stat(filePath)
|
||||
return {
|
||||
id: randomUUID(),
|
||||
serverId: String(row.id),
|
||||
serverName: row.name,
|
||||
filename,
|
||||
sizeBytes: st.size,
|
||||
createdAt: new Date().toISOString(),
|
||||
kind: "manual",
|
||||
notes,
|
||||
}
|
||||
}
|
||||
|
||||
async function processBackupJob(job: BackupJob, ids: string[], notes?: string) {
|
||||
job.status = "running"
|
||||
job.startedAt = new Date().toISOString()
|
||||
const indexRows = await readIndex()
|
||||
const indexRows = await readBackupIndex()
|
||||
for (const id of ids) {
|
||||
try {
|
||||
const meta = await runBackupForServer(id, notes)
|
||||
const meta = await runBackupForServer(id, "manual", notes)
|
||||
indexRows.unshift(meta)
|
||||
job.created.push(meta)
|
||||
} catch (err) {
|
||||
@@ -119,7 +60,7 @@ async function processBackupJob(job: BackupJob, ids: string[], notes?: string) {
|
||||
job.completed += 1
|
||||
}
|
||||
}
|
||||
await writeIndex(indexRows)
|
||||
await writeBackupIndex(indexRows)
|
||||
job.status = "done"
|
||||
job.finishedAt = new Date().toISOString()
|
||||
appendEvent({
|
||||
@@ -141,11 +82,25 @@ async function processBackupJob(job: BackupJob, ids: string[], notes?: string) {
|
||||
|
||||
const backupsRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
app.get("/backups", async (_req, reply) => {
|
||||
const rows = await readIndex()
|
||||
const rows = await readBackupIndex()
|
||||
rows.sort((a, b) => b.createdAt.localeCompare(a.createdAt))
|
||||
return reply.send(rows)
|
||||
})
|
||||
|
||||
app.get("/backups/schedule", async (_req, reply) => {
|
||||
return reply.send(getBackupScheduleSettings())
|
||||
})
|
||||
|
||||
app.put("/backups/schedule", async (req, reply) => {
|
||||
const parsed = putBackupScheduleSettingsSchema.safeParse(req.body ?? {})
|
||||
if (!parsed.success) {
|
||||
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
|
||||
}
|
||||
const result = updateBackupScheduleSettings(parsed.data)
|
||||
refreshScheduler()
|
||||
return reply.send(result)
|
||||
})
|
||||
|
||||
app.post("/backups/create", { schema: { body: CreateBackupBodySchema } }, async (req, reply) => {
|
||||
const inputIds = req.body.serverIds.map((x) => String(x))
|
||||
const notes = req.body.notes?.trim() || undefined
|
||||
@@ -216,10 +171,10 @@ const backupsRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
})
|
||||
|
||||
app.get("/backups/:id/download", { schema: { params: BackupIdParamSchema } }, async (req, reply) => {
|
||||
const rows = await readIndex()
|
||||
const rows = await readBackupIndex()
|
||||
const hit = rows.find((r) => r.id === req.params.id)
|
||||
if (!hit) return reply.status(404).send({ error: "Бэкап не найден" })
|
||||
const filePath = path.join(BACKUPS_DIR, hit.filename)
|
||||
const filePath = path.join(getBackupsDir(), hit.filename)
|
||||
const content = await readFile(filePath, "utf8").catch(() => null)
|
||||
if (content == null) return reply.status(404).send({ error: "Файл бэкапа не найден" })
|
||||
reply.header("Content-Type", "text/plain; charset=utf-8")
|
||||
@@ -228,12 +183,12 @@ const backupsRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
})
|
||||
|
||||
app.delete("/backups/:id", { schema: { params: BackupIdParamSchema } }, async (req, reply) => {
|
||||
const rows = await readIndex()
|
||||
const rows = await readBackupIndex()
|
||||
const idx = rows.findIndex((r) => r.id === req.params.id)
|
||||
if (idx < 0) return reply.status(404).send({ error: "Бэкап не найден" })
|
||||
const [hit] = rows.splice(idx, 1)
|
||||
await writeIndex(rows)
|
||||
await rm(path.join(BACKUPS_DIR, hit.filename), { force: true })
|
||||
await writeBackupIndex(rows)
|
||||
await rm(path.join(getBackupsDir(), hit.filename), { force: true })
|
||||
return reply.status(204).send()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import { randomUUID } from "node:crypto"
|
||||
import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
|
||||
import {
|
||||
certificateIssueRequestSchema,
|
||||
putAcmeCloudflareSettingsSchema,
|
||||
putCertificateRenewSettingsSchema,
|
||||
testAcmeCloudflareSettingsSchema,
|
||||
} from "@mmapp/contracts/certificates"
|
||||
import { refreshScheduler } from "../services/scheduler.js"
|
||||
import { testCloudflareToken } from "../services/acme-cloudflare.js"
|
||||
import { queueCertificateIssueJob } from "../services/certificate-issue-runner.js"
|
||||
import {
|
||||
createIssueJobRecord,
|
||||
getAcmeCloudflareToken,
|
||||
getAcmeSettingsPublic,
|
||||
getCertificateRenewSettings,
|
||||
getIssueJobRecord,
|
||||
getServerRowByIdString,
|
||||
listCertificatesFromServers,
|
||||
toIssueJobDto,
|
||||
updateAcmeSettings,
|
||||
updateCertificateRenewSettings,
|
||||
} from "../services/certificates-service.js"
|
||||
|
||||
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.get("/certificates/renew-settings", async (_req, reply) => {
|
||||
return reply.send(getCertificateRenewSettings())
|
||||
})
|
||||
|
||||
app.put("/certificates/renew-settings", async (req, reply) => {
|
||||
const parsed = putCertificateRenewSettingsSchema.safeParse(req.body ?? {})
|
||||
if (!parsed.success) {
|
||||
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
|
||||
}
|
||||
const result = updateCertificateRenewSettings(parsed.data)
|
||||
refreshScheduler()
|
||||
return reply.send(result)
|
||||
})
|
||||
|
||||
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,
|
||||
source: "manual",
|
||||
})
|
||||
queueCertificateIssueJob(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,142 @@
|
||||
import { appendEvent } from "../modules/events/service/events-service.js"
|
||||
import type { BackupsRunSnapshot } from "../types/scheduler-run-snapshot.js"
|
||||
import { SCHEDULER_RUN_SNAPSHOT_VERSION } from "../types/scheduler-run-snapshot.js"
|
||||
import {
|
||||
getBackupScheduleSettings,
|
||||
isBackupDue,
|
||||
pruneBackupsForServer,
|
||||
readBackupIndex,
|
||||
resolveBackupServerIds,
|
||||
runBackupForServer,
|
||||
touchBackupScheduleRunMeta,
|
||||
writeBackupIndex,
|
||||
} from "./backup-service.js"
|
||||
|
||||
let collecting = false
|
||||
|
||||
export function getBackupSchedulerCollectorState(): { running: boolean } {
|
||||
return { running: collecting }
|
||||
}
|
||||
|
||||
export async function collectScheduledBackupsOnce(): Promise<BackupsRunSnapshot> {
|
||||
const sampledAt = new Date().toISOString()
|
||||
const settings = getBackupScheduleSettings()
|
||||
const snapshot: BackupsRunSnapshot = {
|
||||
v: SCHEDULER_RUN_SNAPSHOT_VERSION,
|
||||
job: "backups",
|
||||
sampledAt,
|
||||
due: false,
|
||||
created: 0,
|
||||
failures: 0,
|
||||
pruned: 0,
|
||||
errors: [],
|
||||
}
|
||||
|
||||
if (collecting) {
|
||||
snapshot.skipped = true
|
||||
return snapshot
|
||||
}
|
||||
|
||||
if (!settings.enabled) {
|
||||
snapshot.skipped = true
|
||||
return snapshot
|
||||
}
|
||||
|
||||
const due = isBackupDue(new Date(), settings, settings.lastRunAt)
|
||||
snapshot.due = due
|
||||
if (!due) {
|
||||
return snapshot
|
||||
}
|
||||
|
||||
if (settings.format !== "rsc") {
|
||||
snapshot.skipped = true
|
||||
snapshot.errors = ["Формат backup пока не поддерживается, используйте rsc"]
|
||||
touchBackupScheduleRunMeta({
|
||||
lastRunAt: sampledAt,
|
||||
lastDurationMs: 0,
|
||||
lastError: snapshot.errors[0],
|
||||
})
|
||||
return snapshot
|
||||
}
|
||||
|
||||
collecting = true
|
||||
const started = Date.now()
|
||||
const serverIds = resolveBackupServerIds(settings)
|
||||
const indexRows = await readBackupIndex()
|
||||
|
||||
appendEvent({
|
||||
level: "info",
|
||||
eventType: "backups.job.started",
|
||||
sourceModule: "backups",
|
||||
title: "Запущен плановый бэкап",
|
||||
message: `Серверов в очереди: ${serverIds.length}`,
|
||||
entityType: "backup_job",
|
||||
entityId: "scheduler",
|
||||
payload: { serverIds, scheduled: true },
|
||||
})
|
||||
|
||||
try {
|
||||
for (const id of serverIds) {
|
||||
try {
|
||||
const meta = await runBackupForServer(id, "auto")
|
||||
indexRows.unshift(meta)
|
||||
snapshot.created += 1
|
||||
} catch (e) {
|
||||
const message = e instanceof Error ? e.message : String(e)
|
||||
snapshot.failures += 1
|
||||
snapshot.errors?.push(`${id}: ${message}`)
|
||||
}
|
||||
}
|
||||
|
||||
await writeBackupIndex(indexRows)
|
||||
|
||||
for (const id of serverIds) {
|
||||
snapshot.pruned += await pruneBackupsForServer(id, settings.keepCount)
|
||||
}
|
||||
|
||||
touchBackupScheduleRunMeta({
|
||||
lastRunAt: sampledAt,
|
||||
lastDurationMs: Date.now() - started,
|
||||
lastError: snapshot.errors?.length ? snapshot.errors.join("; ") : null,
|
||||
})
|
||||
|
||||
appendEvent({
|
||||
level: snapshot.failures > 0 ? "warning" : "info",
|
||||
eventType: "backups.job.done",
|
||||
sourceModule: "backups",
|
||||
title: snapshot.failures > 0 ? "Плановый бэкап завершен с ошибками" : "Плановый бэкап завершен",
|
||||
message: `Создано: ${snapshot.created}, ошибок: ${snapshot.failures}`,
|
||||
entityType: "backup_job",
|
||||
entityId: "scheduler",
|
||||
payload: {
|
||||
total: serverIds.length,
|
||||
completed: snapshot.created + snapshot.failures,
|
||||
failures: snapshot.errors,
|
||||
pruned: snapshot.pruned,
|
||||
},
|
||||
})
|
||||
|
||||
return snapshot
|
||||
} catch (e) {
|
||||
const message = e instanceof Error ? e.message : String(e)
|
||||
snapshot.fatalError = message
|
||||
snapshot.errors?.push(message)
|
||||
touchBackupScheduleRunMeta({
|
||||
lastRunAt: sampledAt,
|
||||
lastDurationMs: Date.now() - started,
|
||||
lastError: message,
|
||||
})
|
||||
appendEvent({
|
||||
level: "critical",
|
||||
eventType: "backups.job.failed",
|
||||
sourceModule: "backups",
|
||||
title: "Плановый бэкап прерван",
|
||||
message,
|
||||
entityType: "backup_job",
|
||||
entityId: "scheduler",
|
||||
})
|
||||
return snapshot
|
||||
} finally {
|
||||
collecting = false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
import { randomUUID } from "node:crypto"
|
||||
import { mkdir, readFile, rm, stat, writeFile } from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import { eq } from "drizzle-orm"
|
||||
import type { BackupScheduleSettingsDto } from "@mmapp/contracts/backups"
|
||||
import { db } from "../db/index.js"
|
||||
import { backupScheduleSettings } from "../db/schema.js"
|
||||
import { getServerRowById } from "../modules/servers/repository/servers-repository.js"
|
||||
import { listServersRead } from "../modules/servers/service/servers-service.js"
|
||||
import { MikrotikClient } from "./mikrotik.js"
|
||||
|
||||
const SETTINGS_ID = 1
|
||||
const BACKUPS_DIR = path.resolve(process.cwd(), "storage", "backups")
|
||||
const INDEX_PATH = path.join(BACKUPS_DIR, "index.json")
|
||||
|
||||
export type BackupMeta = {
|
||||
id: string
|
||||
serverId: string
|
||||
serverName: string
|
||||
filename: string
|
||||
sizeBytes: number
|
||||
createdAt: string
|
||||
kind: "manual" | "auto"
|
||||
notes?: string
|
||||
}
|
||||
|
||||
export async function ensureBackupStorage(): Promise<void> {
|
||||
await mkdir(BACKUPS_DIR, { recursive: true })
|
||||
}
|
||||
|
||||
export async function readBackupIndex(): Promise<BackupMeta[]> {
|
||||
await ensureBackupStorage()
|
||||
try {
|
||||
const raw = await readFile(INDEX_PATH, "utf8")
|
||||
const parsed = JSON.parse(raw) as unknown
|
||||
if (!Array.isArray(parsed)) return []
|
||||
return parsed as BackupMeta[]
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export async function writeBackupIndex(rows: BackupMeta[]): Promise<void> {
|
||||
await ensureBackupStorage()
|
||||
await writeFile(INDEX_PATH, JSON.stringify(rows, null, 2), "utf8")
|
||||
}
|
||||
|
||||
function fmtTs(d = new Date()): string {
|
||||
const p = (n: number) => String(n).padStart(2, "0")
|
||||
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}_${p(d.getHours())}-${p(d.getMinutes())}-${p(d.getSeconds())}`
|
||||
}
|
||||
|
||||
function parseServerIds(raw: string): string[] {
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as unknown
|
||||
if (!Array.isArray(parsed)) return []
|
||||
return parsed.map(String).filter(Boolean)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
function getBackupScheduleSettingsRow() {
|
||||
return db.select().from(backupScheduleSettings).where(eq(backupScheduleSettings.id, SETTINGS_ID)).limit(1).all()[0]
|
||||
?? {
|
||||
id: SETTINGS_ID,
|
||||
enabled: true,
|
||||
frequency: "daily" as const,
|
||||
hour: 3,
|
||||
minute: 0,
|
||||
weekDay: 0,
|
||||
monthDay: 1,
|
||||
keepCount: 7,
|
||||
format: "rsc" as const,
|
||||
serverIdsJson: "[]",
|
||||
lastRunAt: null,
|
||||
lastDurationMs: null,
|
||||
lastError: null,
|
||||
updatedAt: new Date().toISOString(),
|
||||
}
|
||||
}
|
||||
|
||||
export function getBackupScheduleSettings(): BackupScheduleSettingsDto {
|
||||
const row = getBackupScheduleSettingsRow()
|
||||
return {
|
||||
enabled: row.enabled,
|
||||
frequency: row.frequency,
|
||||
hour: row.hour,
|
||||
minute: row.minute,
|
||||
weekDay: row.weekDay,
|
||||
monthDay: row.monthDay,
|
||||
keepCount: row.keepCount,
|
||||
format: row.format,
|
||||
serverIds: parseServerIds(row.serverIdsJson),
|
||||
lastRunAt: row.lastRunAt ?? null,
|
||||
lastDurationMs: row.lastDurationMs ?? null,
|
||||
lastError: row.lastError ?? null,
|
||||
updatedAt: row.updatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
export function updateBackupScheduleSettings(patch: Partial<{
|
||||
enabled: boolean
|
||||
frequency: "daily" | "weekly" | "monthly"
|
||||
hour: number
|
||||
minute: number
|
||||
weekDay: number
|
||||
monthDay: number
|
||||
keepCount: number
|
||||
format: "rsc" | "backup"
|
||||
serverIds: string[]
|
||||
}>) {
|
||||
const prev = getBackupScheduleSettingsRow()
|
||||
const now = new Date().toISOString()
|
||||
const next = {
|
||||
enabled: patch.enabled ?? prev.enabled,
|
||||
frequency: patch.frequency ?? prev.frequency,
|
||||
hour: patch.hour ?? prev.hour,
|
||||
minute: patch.minute ?? prev.minute,
|
||||
weekDay: patch.weekDay ?? prev.weekDay,
|
||||
monthDay: patch.monthDay ?? prev.monthDay,
|
||||
keepCount: patch.keepCount ?? prev.keepCount,
|
||||
format: patch.format ?? prev.format,
|
||||
serverIdsJson: patch.serverIds ? JSON.stringify(patch.serverIds) : prev.serverIdsJson,
|
||||
updatedAt: now,
|
||||
}
|
||||
if (db.select().from(backupScheduleSettings).where(eq(backupScheduleSettings.id, SETTINGS_ID)).limit(1).all()[0]) {
|
||||
db.update(backupScheduleSettings).set(next).where(eq(backupScheduleSettings.id, SETTINGS_ID)).run()
|
||||
} else {
|
||||
db.insert(backupScheduleSettings).values({ id: SETTINGS_ID, ...next }).run()
|
||||
}
|
||||
return getBackupScheduleSettings()
|
||||
}
|
||||
|
||||
export function touchBackupScheduleRunMeta(patch: {
|
||||
lastRunAt?: string
|
||||
lastDurationMs?: number
|
||||
lastError?: string | null
|
||||
}) {
|
||||
const prev = getBackupScheduleSettingsRow()
|
||||
db.update(backupScheduleSettings).set({
|
||||
lastRunAt: patch.lastRunAt ?? prev.lastRunAt,
|
||||
lastDurationMs: patch.lastDurationMs ?? prev.lastDurationMs,
|
||||
lastError: patch.lastError === undefined ? prev.lastError : patch.lastError,
|
||||
updatedAt: new Date().toISOString(),
|
||||
}).where(eq(backupScheduleSettings.id, SETTINGS_ID)).run()
|
||||
}
|
||||
|
||||
export function resolveBackupServerIds(settings: BackupScheduleSettingsDto): string[] {
|
||||
const enabled = new Set(listServersRead().map((s) => String(s.id)))
|
||||
const requested = settings.serverIds.length > 0 ? settings.serverIds : [...enabled]
|
||||
return [...new Set(requested)].filter((id) => enabled.has(id))
|
||||
}
|
||||
|
||||
function sameLocalSlot(a: Date, b: Date): boolean {
|
||||
return a.getFullYear() === b.getFullYear()
|
||||
&& a.getMonth() === b.getMonth()
|
||||
&& a.getDate() === b.getDate()
|
||||
&& a.getHours() === b.getHours()
|
||||
&& a.getMinutes() === b.getMinutes()
|
||||
}
|
||||
|
||||
export function isBackupDue(now: Date, settings: BackupScheduleSettingsDto, lastRunAt: string | null | undefined): boolean {
|
||||
if (!settings.enabled) return false
|
||||
const slot = new Date(now)
|
||||
slot.setSeconds(0, 0)
|
||||
slot.setHours(settings.hour, settings.minute, 0, 0)
|
||||
|
||||
if (settings.frequency === "weekly") {
|
||||
const currentDow = (now.getDay() + 6) % 7
|
||||
if (currentDow !== settings.weekDay) return false
|
||||
} else if (settings.frequency === "monthly") {
|
||||
if (now.getDate() !== settings.monthDay) return false
|
||||
}
|
||||
|
||||
if (now < slot) return false
|
||||
if (lastRunAt) {
|
||||
const prev = new Date(lastRunAt)
|
||||
if (Number.isNaN(prev.getTime())) return true
|
||||
if (settings.frequency === "daily" && sameLocalSlot(prev, slot)) return false
|
||||
if (settings.frequency === "weekly" && sameLocalSlot(prev, slot)) return false
|
||||
if (settings.frequency === "monthly" && prev.getFullYear() === slot.getFullYear() && prev.getMonth() === slot.getMonth() && prev.getDate() === slot.getDate()) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
export async function runBackupForServer(
|
||||
id: string,
|
||||
kind: BackupMeta["kind"],
|
||||
notes?: string,
|
||||
): Promise<BackupMeta> {
|
||||
const serverIdNum = Number.parseInt(id, 10)
|
||||
if (!Number.isFinite(serverIdNum)) {
|
||||
throw new Error("Невалидный id сервера")
|
||||
}
|
||||
const row = getServerRowById(serverIdNum)
|
||||
if (!row) {
|
||||
throw new Error("Сервер не найден")
|
||||
}
|
||||
const client = MikrotikClient.fromServer(row)
|
||||
const script = await client.exportConfigScript()
|
||||
const ts = fmtTs()
|
||||
const safeServer = row.name.replace(/[^a-zA-Z0-9._-]+/g, "_")
|
||||
const filename = `${safeServer}_${ts}.rsc`
|
||||
const filePath = path.join(BACKUPS_DIR, filename)
|
||||
await writeFile(filePath, script, "utf8")
|
||||
const st = await stat(filePath)
|
||||
return {
|
||||
id: randomUUID(),
|
||||
serverId: String(row.id),
|
||||
serverName: row.name,
|
||||
filename,
|
||||
sizeBytes: st.size,
|
||||
createdAt: new Date().toISOString(),
|
||||
kind,
|
||||
notes,
|
||||
}
|
||||
}
|
||||
|
||||
export async function pruneBackupsForServer(serverId: string, keepCount: number): Promise<number> {
|
||||
const rows = await readBackupIndex()
|
||||
const forServer = rows.filter((r) => r.serverId === serverId)
|
||||
if (forServer.length <= keepCount) return 0
|
||||
const sorted = [...forServer].sort((a, b) => b.createdAt.localeCompare(a.createdAt))
|
||||
const toDelete = sorted.slice(keepCount)
|
||||
const deleteIds = new Set(toDelete.map((r) => r.id))
|
||||
for (const hit of toDelete) {
|
||||
await rm(path.join(BACKUPS_DIR, hit.filename), { force: true })
|
||||
}
|
||||
const next = rows.filter((r) => !deleteIds.has(r.id))
|
||||
await writeBackupIndex(next)
|
||||
return toDelete.length
|
||||
}
|
||||
|
||||
export function getBackupsDir(): string {
|
||||
return BACKUPS_DIR
|
||||
}
|
||||
|
||||
export function getBackupIndexPath(): string {
|
||||
return INDEX_PATH
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { appendEvent } from "../modules/events/service/events-service.js"
|
||||
import { issueCertificateWithCloudflareDns } from "./acme-cloudflare.js"
|
||||
import {
|
||||
getIssueJobRecord,
|
||||
getServerRowByIdString,
|
||||
updateIssueJobRecord,
|
||||
} from "./certificates-service.js"
|
||||
|
||||
const runningJobs = new Set<string>()
|
||||
|
||||
export async function runCertificateIssueJob(jobId: string): Promise<void> {
|
||||
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()
|
||||
const isScheduler = row.source === "scheduler"
|
||||
updateIssueJobRecord(jobId, { status: "running", step: "acme_order", startedAt, error: null })
|
||||
|
||||
if (isScheduler) {
|
||||
appendEvent({
|
||||
level: "info",
|
||||
eventType: "certificates.renew.started",
|
||||
sourceModule: "certificates",
|
||||
title: "Запущено автообновление сертификата",
|
||||
message: `${row.certName} · ${domainNames.join(", ")} · ${server.name}`,
|
||||
entityType: "server",
|
||||
entityId: String(server.id),
|
||||
})
|
||||
}
|
||||
|
||||
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: isScheduler ? "certificates.renew.done" : "certificates.issue.done",
|
||||
sourceModule: "certificates",
|
||||
title: isScheduler ? "Сертификат обновлён" : "Сертификат выпущен",
|
||||
message: `${row.certName} · ${domainNames.join(", ")} · ${server.name}`,
|
||||
entityType: "server",
|
||||
entityId: String(server.id),
|
||||
})
|
||||
} catch (e) {
|
||||
const message = e instanceof Error ? e.message : String(e)
|
||||
updateIssueJobRecord(jobId, {
|
||||
status: "failed",
|
||||
step: "failed",
|
||||
finishedAt: new Date().toISOString(),
|
||||
error: message,
|
||||
})
|
||||
appendEvent({
|
||||
level: "warning",
|
||||
eventType: isScheduler ? "certificates.renew.failed" : "certificates.issue.failed",
|
||||
sourceModule: "certificates",
|
||||
title: isScheduler ? "Ошибка автообновления сертификата" : "Ошибка выпуска сертификата",
|
||||
message,
|
||||
entityType: "server",
|
||||
entityId: String(server.id),
|
||||
})
|
||||
} finally {
|
||||
runningJobs.delete(jobId)
|
||||
}
|
||||
}
|
||||
|
||||
export function queueCertificateIssueJob(jobId: string): void {
|
||||
queueMicrotask(() => { void runCertificateIssueJob(jobId) })
|
||||
}
|
||||
@@ -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,158 @@
|
||||
import { randomUUID } from "node:crypto"
|
||||
import { appendEvent } from "../modules/events/service/events-service.js"
|
||||
import type { CertificatesRenewRunSnapshot } from "../types/scheduler-run-snapshot.js"
|
||||
import { SCHEDULER_RUN_SNAPSHOT_VERSION } from "../types/scheduler-run-snapshot.js"
|
||||
import { mapRosCertificates } from "./certificate-parse.js"
|
||||
import { runCertificateIssueJob } from "./certificate-issue-runner.js"
|
||||
import {
|
||||
createIssueJobRecord,
|
||||
getCertificateRenewSettings,
|
||||
getServerRowByIdString,
|
||||
hasActiveIssueJob,
|
||||
listManagedCertificateTargets,
|
||||
touchCertificateRenewRunMeta,
|
||||
} from "./certificates-service.js"
|
||||
import { MikrotikClient } from "./mikrotik.js"
|
||||
|
||||
let collecting = false
|
||||
|
||||
export function getCertificateRenewCollectorState(): { running: boolean } {
|
||||
return { running: collecting }
|
||||
}
|
||||
|
||||
export async function collectCertificatesRenewOnce(): Promise<CertificatesRenewRunSnapshot> {
|
||||
const sampledAt = new Date().toISOString()
|
||||
const settings = getCertificateRenewSettings()
|
||||
if (collecting) {
|
||||
return {
|
||||
v: SCHEDULER_RUN_SNAPSHOT_VERSION,
|
||||
job: "certificates_renew",
|
||||
sampledAt,
|
||||
skipped: true,
|
||||
checked: 0,
|
||||
renewed: 0,
|
||||
skippedTargets: 0,
|
||||
errors: [],
|
||||
}
|
||||
}
|
||||
|
||||
collecting = true
|
||||
const started = Date.now()
|
||||
const snapshot: CertificatesRenewRunSnapshot = {
|
||||
v: SCHEDULER_RUN_SNAPSHOT_VERSION,
|
||||
job: "certificates_renew",
|
||||
sampledAt,
|
||||
checked: 0,
|
||||
renewed: 0,
|
||||
skippedTargets: 0,
|
||||
errors: [],
|
||||
targets: [],
|
||||
}
|
||||
|
||||
try {
|
||||
if (!settings.enabled) {
|
||||
snapshot.skipped = true
|
||||
return snapshot
|
||||
}
|
||||
|
||||
const targets = listManagedCertificateTargets()
|
||||
for (const target of targets) {
|
||||
snapshot.checked += 1
|
||||
const item = {
|
||||
serverId: target.serverId,
|
||||
certName: target.certName,
|
||||
action: "ok" as "ok" | "renewed" | "skipped" | "error",
|
||||
daysLeft: null as number | null,
|
||||
message: "",
|
||||
}
|
||||
|
||||
if (hasActiveIssueJob(target.serverId, target.certName)) {
|
||||
item.action = "skipped"
|
||||
item.message = "Уже выполняется выпуск"
|
||||
snapshot.skippedTargets += 1
|
||||
snapshot.targets?.push(item)
|
||||
appendEvent({
|
||||
level: "info",
|
||||
eventType: "certificates.renew.skipped",
|
||||
sourceModule: "certificates",
|
||||
title: "Автообновление пропущено",
|
||||
message: `${target.certName}: уже выполняется выпуск`,
|
||||
entityType: "server",
|
||||
entityId: target.serverId,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
const server = getServerRowByIdString(target.serverId)
|
||||
if (!server) {
|
||||
item.action = "error"
|
||||
item.message = "Сервер не найден"
|
||||
snapshot.errors.push(`${target.certName}: сервер не найден`)
|
||||
snapshot.targets?.push(item)
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
const client = MikrotikClient.fromServer(server)
|
||||
const rows = await client.getCertificates()
|
||||
const mapped = mapRosCertificates(server.id, server.name, rows)
|
||||
const hit = mapped.find((c) => c.name === target.certName)
|
||||
if (!hit) {
|
||||
item.action = "error"
|
||||
item.message = "Сертификат не найден на устройстве"
|
||||
snapshot.errors.push(`${target.certName}: не найден на ${server.name}`)
|
||||
snapshot.targets?.push(item)
|
||||
continue
|
||||
}
|
||||
|
||||
item.daysLeft = hit.daysLeft
|
||||
if (hit.daysLeft > settings.renewBeforeDays) {
|
||||
item.action = "ok"
|
||||
item.message = `До истечения ${hit.daysLeft} дн.`
|
||||
snapshot.targets?.push(item)
|
||||
continue
|
||||
}
|
||||
|
||||
const jobId = randomUUID()
|
||||
createIssueJobRecord({
|
||||
id: jobId,
|
||||
serverId: target.serverId,
|
||||
certName: target.certName,
|
||||
domainNames: target.domainNames,
|
||||
keyType: target.keyType,
|
||||
trustStore: target.trustStore,
|
||||
source: "scheduler",
|
||||
})
|
||||
await runCertificateIssueJob(jobId)
|
||||
snapshot.renewed += 1
|
||||
item.action = "renewed"
|
||||
item.message = "Запущено обновление"
|
||||
snapshot.targets?.push(item)
|
||||
} catch (e) {
|
||||
const message = e instanceof Error ? e.message : String(e)
|
||||
item.action = "error"
|
||||
item.message = message
|
||||
snapshot.errors.push(`${target.certName}: ${message}`)
|
||||
snapshot.targets?.push(item)
|
||||
}
|
||||
}
|
||||
|
||||
touchCertificateRenewRunMeta({
|
||||
lastCollectedAt: sampledAt,
|
||||
lastDurationMs: Date.now() - started,
|
||||
lastError: snapshot.errors.length > 0 ? snapshot.errors.join("; ") : null,
|
||||
})
|
||||
return snapshot
|
||||
} catch (e) {
|
||||
const message = e instanceof Error ? e.message : String(e)
|
||||
snapshot.fatalError = message
|
||||
touchCertificateRenewRunMeta({
|
||||
lastCollectedAt: sampledAt,
|
||||
lastDurationMs: Date.now() - started,
|
||||
lastError: message,
|
||||
})
|
||||
return snapshot
|
||||
} finally {
|
||||
collecting = false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
import { desc, eq, inArray } from "drizzle-orm"
|
||||
import type { CertificateDto, CertificateRenewSettingsDto } from "@mmapp/contracts/certificates"
|
||||
import { db } from "../db/index.js"
|
||||
import {
|
||||
acmeSettings,
|
||||
certificateIssueJobs,
|
||||
certificateRenewSettings,
|
||||
servers,
|
||||
} from "../db/schema.js"
|
||||
import { mapRosCertificates } from "./certificate-parse.js"
|
||||
import { MikrotikClient } from "./mikrotik.js"
|
||||
|
||||
const SETTINGS_ID = 1
|
||||
|
||||
export type ManagedCertificateTarget = {
|
||||
serverId: string
|
||||
certName: string
|
||||
domainNames: string[]
|
||||
keyType: string
|
||||
trustStore: string
|
||||
finishedAt: string
|
||||
}
|
||||
|
||||
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
|
||||
source?: "manual" | "scheduler"
|
||||
}) {
|
||||
const now = new Date().toISOString()
|
||||
db.insert(certificateIssueJobs).values({
|
||||
id: input.id,
|
||||
status: "queued",
|
||||
step: "queued",
|
||||
source: input.source ?? "manual",
|
||||
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
|
||||
}
|
||||
|
||||
function getCertificateRenewSettingsRow() {
|
||||
return db.select().from(certificateRenewSettings).where(eq(certificateRenewSettings.id, SETTINGS_ID)).limit(1).all()[0]
|
||||
?? {
|
||||
id: SETTINGS_ID,
|
||||
enabled: true,
|
||||
intervalSec: 21600,
|
||||
renewBeforeDays: 30,
|
||||
lastCollectedAt: null,
|
||||
lastDurationMs: null,
|
||||
lastError: null,
|
||||
updatedAt: new Date().toISOString(),
|
||||
}
|
||||
}
|
||||
|
||||
export function getCertificateRenewSettings(): CertificateRenewSettingsDto {
|
||||
const row = getCertificateRenewSettingsRow()
|
||||
return {
|
||||
enabled: row.enabled,
|
||||
intervalSec: row.intervalSec,
|
||||
renewBeforeDays: row.renewBeforeDays,
|
||||
lastCollectedAt: row.lastCollectedAt ?? null,
|
||||
lastDurationMs: row.lastDurationMs ?? null,
|
||||
lastError: row.lastError ?? null,
|
||||
updatedAt: row.updatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
export function updateCertificateRenewSettings(patch: {
|
||||
enabled?: boolean
|
||||
intervalSec?: number
|
||||
renewBeforeDays?: number
|
||||
}) {
|
||||
const prev = getCertificateRenewSettingsRow()
|
||||
const now = new Date().toISOString()
|
||||
const next = {
|
||||
enabled: patch.enabled ?? prev.enabled,
|
||||
intervalSec: patch.intervalSec ?? prev.intervalSec,
|
||||
renewBeforeDays: patch.renewBeforeDays ?? prev.renewBeforeDays,
|
||||
updatedAt: now,
|
||||
}
|
||||
if (db.select().from(certificateRenewSettings).where(eq(certificateRenewSettings.id, SETTINGS_ID)).limit(1).all()[0]) {
|
||||
db.update(certificateRenewSettings).set(next).where(eq(certificateRenewSettings.id, SETTINGS_ID)).run()
|
||||
} else {
|
||||
db.insert(certificateRenewSettings).values({ id: SETTINGS_ID, ...next }).run()
|
||||
}
|
||||
return getCertificateRenewSettings()
|
||||
}
|
||||
|
||||
export function touchCertificateRenewRunMeta(patch: {
|
||||
lastCollectedAt?: string
|
||||
lastDurationMs?: number
|
||||
lastError?: string | null
|
||||
}) {
|
||||
const prev = getCertificateRenewSettingsRow()
|
||||
db.update(certificateRenewSettings).set({
|
||||
lastCollectedAt: patch.lastCollectedAt ?? prev.lastCollectedAt,
|
||||
lastDurationMs: patch.lastDurationMs ?? prev.lastDurationMs,
|
||||
lastError: patch.lastError === undefined ? prev.lastError : patch.lastError,
|
||||
updatedAt: new Date().toISOString(),
|
||||
}).where(eq(certificateRenewSettings.id, SETTINGS_ID)).run()
|
||||
}
|
||||
|
||||
export function listManagedCertificateTargets(): ManagedCertificateTarget[] {
|
||||
const rows = db.select().from(certificateIssueJobs)
|
||||
.where(eq(certificateIssueJobs.status, "done"))
|
||||
.orderBy(desc(certificateIssueJobs.finishedAt), desc(certificateIssueJobs.requestedAt))
|
||||
.all()
|
||||
|
||||
const byKey = new Map<string, ManagedCertificateTarget>()
|
||||
for (const row of rows) {
|
||||
const key = `${row.serverId}::${row.certName}`
|
||||
if (byKey.has(key)) continue
|
||||
let domainNames: string[] = []
|
||||
try {
|
||||
const parsed = JSON.parse(row.domainNames) as unknown
|
||||
if (Array.isArray(parsed)) domainNames = parsed.map(String)
|
||||
} catch {
|
||||
domainNames = []
|
||||
}
|
||||
byKey.set(key, {
|
||||
serverId: row.serverId,
|
||||
certName: row.certName,
|
||||
domainNames,
|
||||
keyType: row.keyType,
|
||||
trustStore: row.trustStore,
|
||||
finishedAt: row.finishedAt ?? row.requestedAt,
|
||||
})
|
||||
}
|
||||
return [...byKey.values()]
|
||||
}
|
||||
|
||||
export function hasActiveIssueJob(serverId: string, certName: string): boolean {
|
||||
const row = db.select().from(certificateIssueJobs)
|
||||
.where(inArray(certificateIssueJobs.status, ["queued", "running"]))
|
||||
.orderBy(desc(certificateIssueJobs.requestedAt))
|
||||
.all()
|
||||
.find((r) => r.serverId === serverId && r.certName === certName)
|
||||
return Boolean(row)
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -42,6 +42,10 @@ import {
|
||||
collectInternetPathSnapshotOnce,
|
||||
getInternetPathSettings,
|
||||
} from "./internet-path-collector.js"
|
||||
import { collectCertificatesRenewOnce } from "./certificate-renew-collector.js"
|
||||
import { getCertificateRenewSettings } from "./certificates-service.js"
|
||||
import { collectScheduledBackupsOnce } from "./backup-scheduler-collector.js"
|
||||
import { getBackupScheduleSettings } from "./backup-service.js"
|
||||
import {
|
||||
endSchedulerJob,
|
||||
isSchedulerJobRunning,
|
||||
@@ -57,6 +61,8 @@ export const JOB_KEYS = [
|
||||
"uptime_speed",
|
||||
"internet_path",
|
||||
"gre_bgp",
|
||||
"certificates_renew",
|
||||
"backups",
|
||||
"alert_engine",
|
||||
] as const
|
||||
export type SchedulerJobKey = (typeof JOB_KEYS)[number]
|
||||
@@ -119,6 +125,12 @@ async function runSchedulerJobBody(jobKey: SchedulerJobKey): Promise<void> {
|
||||
case "internet_path":
|
||||
snapshot = await collectInternetPathSnapshotOnce()
|
||||
break
|
||||
case "certificates_renew":
|
||||
snapshot = await collectCertificatesRenewOnce()
|
||||
break
|
||||
case "backups":
|
||||
snapshot = await collectScheduledBackupsOnce()
|
||||
break
|
||||
case "alert_engine": {
|
||||
const r = await runAlertEngineOnce()
|
||||
snapshot = {
|
||||
@@ -322,6 +334,30 @@ export function refreshScheduler(): void {
|
||||
}, greBgpMs),
|
||||
)
|
||||
|
||||
const certRenew = getCertificateRenewSettings()
|
||||
if (certRenew.enabled) {
|
||||
const certRenewMs = Math.max(300_000, certRenew.intervalSec * 1000)
|
||||
void executeSchedulerJob("certificates_renew").catch(() => {})
|
||||
timers.set(
|
||||
"certificates_renew",
|
||||
setInterval(() => {
|
||||
void executeSchedulerJob("certificates_renew").catch(() => {})
|
||||
}, certRenewMs),
|
||||
)
|
||||
}
|
||||
|
||||
const backupSchedule = getBackupScheduleSettings()
|
||||
if (backupSchedule.enabled) {
|
||||
const backupMs = 60_000
|
||||
void executeSchedulerJob("backups").catch(() => {})
|
||||
timers.set(
|
||||
"backups",
|
||||
setInterval(() => {
|
||||
void executeSchedulerJob("backups").catch(() => {})
|
||||
}, backupMs),
|
||||
)
|
||||
}
|
||||
|
||||
const alertMs = 20_000
|
||||
void executeSchedulerJob("alert_engine").catch(() => {})
|
||||
timers.set(
|
||||
@@ -353,6 +389,8 @@ export function getSchedulerStatus() {
|
||||
const uptime = getUptimeSettings()
|
||||
const apiPing = getServersApiPingSettings()
|
||||
const internetPath = getInternetPathSettings()
|
||||
const certRenew = getCertificateRenewSettings()
|
||||
const backupSchedule = getBackupScheduleSettings()
|
||||
|
||||
const resOn = uptime.resourcesEnabled ?? uptime.enabled
|
||||
const pingOn = uptime.pingEnabled ?? uptime.enabled
|
||||
@@ -366,6 +404,8 @@ export function getSchedulerStatus() {
|
||||
uptime_speed: { enabled: spdOn, intervalSec: uptime.speedIntervalSec },
|
||||
internet_path: { enabled: internetPath.enabled, intervalSec: internetPath.intervalSec },
|
||||
gre_bgp: { enabled: true, intervalSec: 30 },
|
||||
certificates_renew: { enabled: certRenew.enabled, intervalSec: certRenew.intervalSec },
|
||||
backups: { enabled: backupSchedule.enabled, intervalSec: 60 },
|
||||
alert_engine: { enabled: true, intervalSec: 20 },
|
||||
}
|
||||
|
||||
|
||||
@@ -192,6 +192,40 @@ export interface InternetPathRunSnapshot {
|
||||
fatalError?: string
|
||||
}
|
||||
|
||||
export interface CertificatesRenewTargetSnapshot {
|
||||
serverId: string
|
||||
certName: string
|
||||
action: "ok" | "renewed" | "skipped" | "error"
|
||||
daysLeft: number | null
|
||||
message: string
|
||||
}
|
||||
|
||||
export interface CertificatesRenewRunSnapshot {
|
||||
v: typeof SCHEDULER_RUN_SNAPSHOT_VERSION
|
||||
job: "certificates_renew"
|
||||
sampledAt: string
|
||||
skipped?: boolean
|
||||
fatalError?: string
|
||||
checked: number
|
||||
renewed: number
|
||||
skippedTargets: number
|
||||
errors: string[]
|
||||
targets?: CertificatesRenewTargetSnapshot[]
|
||||
}
|
||||
|
||||
export interface BackupsRunSnapshot {
|
||||
v: typeof SCHEDULER_RUN_SNAPSHOT_VERSION
|
||||
job: "backups"
|
||||
sampledAt: string
|
||||
skipped?: boolean
|
||||
due: boolean
|
||||
created: number
|
||||
failures: number
|
||||
pruned: number
|
||||
errors?: string[]
|
||||
fatalError?: string
|
||||
}
|
||||
|
||||
export type SchedulerRunSnapshot =
|
||||
| TrafficRunSnapshot
|
||||
| ResourcesRunSnapshot
|
||||
@@ -200,4 +234,6 @@ export type SchedulerRunSnapshot =
|
||||
| ServersRestPingRunSnapshot
|
||||
| GreBgpSnapshotRunSnapshot
|
||||
| InternetPathRunSnapshot
|
||||
| CertificatesRenewRunSnapshot
|
||||
| BackupsRunSnapshot
|
||||
| AlertEngineRunSnapshot
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,94 @@
|
||||
[
|
||||
{
|
||||
"id": "bec5c501-6d3f-4921-9774-5f0e3d526d87",
|
||||
"serverId": "6",
|
||||
"serverName": "ihor.msk.rt.shx.su",
|
||||
"filename": "ihor.msk.rt.shx.su_2026-05-12_21-45-18.rsc",
|
||||
"sizeBytes": 711251,
|
||||
"createdAt": "2026-05-12T14:45:18.144Z",
|
||||
"kind": "auto"
|
||||
},
|
||||
{
|
||||
"id": "2c1d9c24-b612-4756-8352-afd127b2fa81",
|
||||
"serverId": "5",
|
||||
"serverName": "servhost.nsk.rt.shx.su",
|
||||
"filename": "servhost.nsk.rt.shx.su_2026-05-12_21-45-14.rsc",
|
||||
"sizeBytes": 707990,
|
||||
"createdAt": "2026-05-12T14:45:14.994Z",
|
||||
"kind": "auto"
|
||||
},
|
||||
{
|
||||
"id": "3eac2f70-a5e8-428c-a50d-59223061827b",
|
||||
"serverId": "4",
|
||||
"serverName": "veesp.swe.rt.shx.su",
|
||||
"filename": "veesp.swe.rt.shx.su_2026-05-12_21-45-08.rsc",
|
||||
"sizeBytes": 2195,
|
||||
"createdAt": "2026-05-12T14:45:08.312Z",
|
||||
"kind": "auto"
|
||||
},
|
||||
{
|
||||
"id": "2c600052-70ff-4525-910d-725d0f4d2cfb",
|
||||
"serverId": "3",
|
||||
"serverName": "vpsville.msk.rt.shx.su",
|
||||
"filename": "vpsville.msk.rt.shx.su_2026-05-12_21-45-07.rsc",
|
||||
"sizeBytes": 710694,
|
||||
"createdAt": "2026-05-12T14:45:07.786Z",
|
||||
"kind": "auto"
|
||||
},
|
||||
{
|
||||
"id": "b97be1bb-9c72-44a8-be27-333ddb58ca24",
|
||||
"serverId": "2",
|
||||
"serverName": "Gateway",
|
||||
"filename": "Gateway_2026-05-12_21-45-05.rsc",
|
||||
"sizeBytes": 1712337,
|
||||
"createdAt": "2026-05-12T14:45:05.250Z",
|
||||
"kind": "auto"
|
||||
},
|
||||
{
|
||||
"id": "891217c8-faa2-40d7-b093-e325f41d8a3d",
|
||||
"serverId": "6",
|
||||
"serverName": "ihor.msk.rt.shx.su",
|
||||
"filename": "ihor.msk.rt.shx.su_2026-05-12_21-44-21.rsc",
|
||||
"sizeBytes": 711251,
|
||||
"createdAt": "2026-05-12T14:44:21.828Z",
|
||||
"kind": "auto"
|
||||
},
|
||||
{
|
||||
"id": "f6bc0abc-c783-4d6f-92b6-d0cc489e6772",
|
||||
"serverId": "5",
|
||||
"serverName": "servhost.nsk.rt.shx.su",
|
||||
"filename": "servhost.nsk.rt.shx.su_2026-05-12_21-44-17.rsc",
|
||||
"sizeBytes": 707990,
|
||||
"createdAt": "2026-05-12T14:44:17.008Z",
|
||||
"kind": "auto"
|
||||
},
|
||||
{
|
||||
"id": "3f8b71df-5879-4fda-98f9-316353db3657",
|
||||
"serverId": "4",
|
||||
"serverName": "veesp.swe.rt.shx.su",
|
||||
"filename": "veesp.swe.rt.shx.su_2026-05-12_21-44-14.rsc",
|
||||
"sizeBytes": 2195,
|
||||
"createdAt": "2026-05-12T14:44:14.981Z",
|
||||
"kind": "auto"
|
||||
},
|
||||
{
|
||||
"id": "0431c73a-a2a2-4100-8940-a7e560649f53",
|
||||
"serverId": "3",
|
||||
"serverName": "vpsville.msk.rt.shx.su",
|
||||
"filename": "vpsville.msk.rt.shx.su_2026-05-12_21-44-14.rsc",
|
||||
"sizeBytes": 710694,
|
||||
"createdAt": "2026-05-12T14:44:14.434Z",
|
||||
"kind": "auto"
|
||||
},
|
||||
{
|
||||
"id": "523315c7-4db9-4e09-8bfb-3f36dd692485",
|
||||
"serverId": "2",
|
||||
"serverName": "Gateway",
|
||||
"filename": "Gateway_2026-05-12_21-44-11.rsc",
|
||||
"sizeBytes": 1712337,
|
||||
"createdAt": "2026-05-12T14:44:11.677Z",
|
||||
"kind": "auto"
|
||||
},
|
||||
{
|
||||
"id": "b820bdc8-d184-4615-92e1-40df3872d554",
|
||||
"serverId": "2",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,45 @@
|
||||
# synthetic export generated by MikrotikManager
|
||||
# generated-at: 2026-05-12T14:44:14.588Z
|
||||
|
||||
/system identity
|
||||
set name="veesp.swe.rt.shx.su"
|
||||
|
||||
/interface
|
||||
:put "interface name="ether1" mtu=1500 disabled=no"
|
||||
:put "interface name="MSK-DC" mtu=1350 disabled=no"
|
||||
:put "interface name="MSK-IHOR" mtu=1400 disabled=no"
|
||||
:put "interface name="MSK-VPSVILLE" mtu=1400 disabled=no"
|
||||
:put "interface name="NSK-SERVHOST" mtu=1400 disabled=no"
|
||||
:put "interface name="br1" mtu=1500 disabled=no"
|
||||
:put "interface name="gre-tunnel1" mtu=1450 disabled=no"
|
||||
:put "interface name="lo" mtu=65536 disabled=no"
|
||||
:put "interface name="wg-tunnel" mtu=1420 disabled=no"
|
||||
:put "interface name="wg1" mtu=1280 disabled=no"
|
||||
|
||||
/ip address
|
||||
add address=62.182.194.146/24 interface="ether1"
|
||||
add address=10.200.100.26/30 interface="MSK-IHOR"
|
||||
add address=10.200.200.2/30 interface="*5"
|
||||
add address=10.200.40.2/30 interface="MSK-VPSVILLE"
|
||||
add address=10.205.1.2/30 interface="wg1"
|
||||
add address=10.200.100.2/30 interface="*11" comment="Interface: MSK-SELECTEL (GRE) to selectel.msk.rt.shx.su"
|
||||
add address=10.200.100.38/30 interface="MSK-DC" comment="Interface: MSK-DC (GRE) to dc.msk.rt.shx.su"
|
||||
add address=10.200.100.14/30 interface="MSK-VPSVILLE" comment="Interface: MSK-VPSVILLE (GRE) to vpsville.msk.rt.shx.su"
|
||||
add address=10.101.1.2/30 interface="gre-tunnel1"
|
||||
add address=10.200.100.54/30 interface="NSK-SERVHOST" comment="Interface: NSK-SERVHOST (GRE) to servhost.nsk.rt.shx.su"
|
||||
|
||||
/ip route
|
||||
add dst-address=192.168.0.0/16 gateway=10.200.100.13%MSK-VPSVILLE distance=1
|
||||
add dst-address=0.0.0.0/0 gateway=62.182.194.1 distance=1
|
||||
add dst-address=10.101.1.0/30 gateway=gre-tunnel1 distance=0
|
||||
add dst-address=10.200.100.12/30 gateway=MSK-VPSVILLE distance=0
|
||||
add dst-address=10.200.100.24/30 gateway=MSK-IHOR distance=0
|
||||
add dst-address=10.200.100.36/30 gateway=MSK-DC distance=0
|
||||
add dst-address=10.200.100.52/30 gateway=NSK-SERVHOST distance=0
|
||||
add dst-address=10.205.1.0/30 gateway=wg1 distance=0
|
||||
add dst-address=62.182.194.0/24 gateway=br1 distance=0
|
||||
add dst-address=192.168.0.0/16 gateway=10.200.100.53 distance=1
|
||||
|
||||
/ip firewall filter
|
||||
add chain=input action=drop protocol=udp dst-port=53
|
||||
add chain=input action=accept protocol=udp dst-port=13231
|
||||
@@ -0,0 +1,45 @@
|
||||
# synthetic export generated by MikrotikManager
|
||||
# generated-at: 2026-05-12T14:45:07.934Z
|
||||
|
||||
/system identity
|
||||
set name="veesp.swe.rt.shx.su"
|
||||
|
||||
/interface
|
||||
:put "interface name="ether1" mtu=1500 disabled=no"
|
||||
:put "interface name="MSK-DC" mtu=1350 disabled=no"
|
||||
:put "interface name="MSK-IHOR" mtu=1400 disabled=no"
|
||||
:put "interface name="MSK-VPSVILLE" mtu=1400 disabled=no"
|
||||
:put "interface name="NSK-SERVHOST" mtu=1400 disabled=no"
|
||||
:put "interface name="br1" mtu=1500 disabled=no"
|
||||
:put "interface name="gre-tunnel1" mtu=1450 disabled=no"
|
||||
:put "interface name="lo" mtu=65536 disabled=no"
|
||||
:put "interface name="wg-tunnel" mtu=1420 disabled=no"
|
||||
:put "interface name="wg1" mtu=1280 disabled=no"
|
||||
|
||||
/ip address
|
||||
add address=62.182.194.146/24 interface="ether1"
|
||||
add address=10.200.100.26/30 interface="MSK-IHOR"
|
||||
add address=10.200.200.2/30 interface="*5"
|
||||
add address=10.200.40.2/30 interface="MSK-VPSVILLE"
|
||||
add address=10.205.1.2/30 interface="wg1"
|
||||
add address=10.200.100.2/30 interface="*11" comment="Interface: MSK-SELECTEL (GRE) to selectel.msk.rt.shx.su"
|
||||
add address=10.200.100.38/30 interface="MSK-DC" comment="Interface: MSK-DC (GRE) to dc.msk.rt.shx.su"
|
||||
add address=10.200.100.14/30 interface="MSK-VPSVILLE" comment="Interface: MSK-VPSVILLE (GRE) to vpsville.msk.rt.shx.su"
|
||||
add address=10.101.1.2/30 interface="gre-tunnel1"
|
||||
add address=10.200.100.54/30 interface="NSK-SERVHOST" comment="Interface: NSK-SERVHOST (GRE) to servhost.nsk.rt.shx.su"
|
||||
|
||||
/ip route
|
||||
add dst-address=192.168.0.0/16 gateway=10.200.100.13%MSK-VPSVILLE distance=1
|
||||
add dst-address=0.0.0.0/0 gateway=62.182.194.1 distance=1
|
||||
add dst-address=10.101.1.0/30 gateway=gre-tunnel1 distance=0
|
||||
add dst-address=10.200.100.12/30 gateway=MSK-VPSVILLE distance=0
|
||||
add dst-address=10.200.100.24/30 gateway=MSK-IHOR distance=0
|
||||
add dst-address=10.200.100.36/30 gateway=MSK-DC distance=0
|
||||
add dst-address=10.200.100.52/30 gateway=NSK-SERVHOST distance=0
|
||||
add dst-address=10.205.1.0/30 gateway=wg1 distance=0
|
||||
add dst-address=62.182.194.0/24 gateway=br1 distance=0
|
||||
add dst-address=192.168.0.0/16 gateway=10.200.100.53 distance=1
|
||||
|
||||
/ip firewall filter
|
||||
add chain=input action=drop protocol=udp dst-port=53
|
||||
add chain=input action=accept protocol=udp dst-port=13231
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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())
|
||||
|
||||
@@ -80,16 +80,32 @@ state_set_error() {
|
||||
mv "$tmp" "$STATE_FILE"
|
||||
}
|
||||
|
||||
cleanup_legacy_lock_dirs() {
|
||||
local locks_dir="/state/locks"
|
||||
[[ -d "$locks_dir" ]] || return 0
|
||||
find "$locks_dir" -mindepth 1 -maxdepth 1 -type d -exec rm -rf {} + 2>/dev/null || true
|
||||
}
|
||||
|
||||
with_target_lock() {
|
||||
local target_id="$1"
|
||||
shift
|
||||
local lock_dir="/state/locks/${target_id}"
|
||||
mkdir -p "$(dirname "$lock_dir")"
|
||||
if ! mkdir "$lock_dir" 2>/dev/null; then
|
||||
log warn "target=${target_id} action=skip reason=lock_busy"
|
||||
return 0
|
||||
local lock_file="/state/locks/${target_id}.lock"
|
||||
mkdir -p "$(dirname "$lock_file")"
|
||||
|
||||
if [[ -f "$lock_file" ]]; then
|
||||
local lock_pid=""
|
||||
lock_pid="$(tr -d '[:space:]' <"$lock_file" 2>/dev/null || true)"
|
||||
if [[ -n "$lock_pid" ]] && kill -0 "$lock_pid" 2>/dev/null; then
|
||||
log warn "target=${target_id} action=skip reason=lock_busy pid=${lock_pid}"
|
||||
return 0
|
||||
fi
|
||||
|
||||
log warn "target=${target_id} action=stale_lock_removed previous_pid=${lock_pid:-unknown}"
|
||||
rm -f "$lock_file"
|
||||
fi
|
||||
trap 'rmdir "'"$lock_dir"'" 2>/dev/null || true' RETURN
|
||||
|
||||
printf '%s\n' "$$" >"$lock_file"
|
||||
trap 'rm -f "'"$lock_file"'"' RETURN
|
||||
"$@"
|
||||
}
|
||||
|
||||
@@ -483,6 +499,7 @@ main() {
|
||||
|
||||
validate_targets_file
|
||||
state_init
|
||||
cleanup_legacy_lock_dirs
|
||||
registry_login
|
||||
|
||||
log info "action=startup targets_file=${TARGETS_FILE} state_file=${STATE_FILE} poll_interval=${POLL_INTERVAL_SECONDS}"
|
||||
|
||||
+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>
|
||||
|
||||
@@ -72,6 +72,31 @@ export interface InternetPathRunSnapshot {
|
||||
fatalError?: string
|
||||
}
|
||||
|
||||
export interface CertificatesRenewRunSnapshot {
|
||||
v: number
|
||||
job: "certificates_renew"
|
||||
sampledAt: string
|
||||
skipped?: boolean
|
||||
fatalError?: string
|
||||
checked: number
|
||||
renewed: number
|
||||
skippedTargets: number
|
||||
errors: string[]
|
||||
}
|
||||
|
||||
export interface BackupsRunSnapshot {
|
||||
v: number
|
||||
job: "backups"
|
||||
sampledAt: string
|
||||
skipped?: boolean
|
||||
due: boolean
|
||||
created: number
|
||||
failures: number
|
||||
pruned: number
|
||||
errors?: string[]
|
||||
fatalError?: string
|
||||
}
|
||||
|
||||
export type SchedulerRunSnapshot =
|
||||
| TrafficRunSnapshot
|
||||
| ResourcesRunSnapshot
|
||||
@@ -80,6 +105,8 @@ export type SchedulerRunSnapshot =
|
||||
| ServersRestPingRunSnapshot
|
||||
| GreBgpSnapshotRunSnapshot
|
||||
| InternetPathRunSnapshot
|
||||
| CertificatesRenewRunSnapshot
|
||||
| BackupsRunSnapshot
|
||||
| AlertEngineRunSnapshot
|
||||
|
||||
export interface TrafficServerSnapshot {
|
||||
|
||||
@@ -9,6 +9,8 @@ export const SCHEDULER_JOB_KEYS = [
|
||||
"uptime_speed",
|
||||
"internet_path",
|
||||
"gre_bgp",
|
||||
"certificates_renew",
|
||||
"backups",
|
||||
"alert_engine",
|
||||
] as const
|
||||
export type SchedulerJobKey = (typeof SCHEDULER_JOB_KEYS)[number]
|
||||
@@ -21,6 +23,8 @@ export const SCHEDULER_JOB_LABELS: Record<string, string> = {
|
||||
uptime_speed: "Uptime: speed",
|
||||
internet_path: "Internet Path",
|
||||
gre_bgp: "GRE + BGP",
|
||||
certificates_renew: "Сертификаты: автообновление",
|
||||
backups: "Бэкапы",
|
||||
alert_engine: "Оповещения",
|
||||
}
|
||||
|
||||
@@ -35,6 +39,10 @@ export const SCHEDULER_JOB_DESCRIPTIONS: Record<string, string> = {
|
||||
internet_path: "Снимок данных для карты интернет-маршрута на dashboard (WAN/JH/EN, route+runtime, speed-пробы).",
|
||||
gre_bgp:
|
||||
"Опрос GRE-туннелей и BGP-сессий на включённых серверах, запись сэмплов в SQLite для движка оповещений.",
|
||||
certificates_renew:
|
||||
"Проверка сертификатов, выпущенных через UI, и автообновление через ACME DNS-01 (Cloudflare) до истечения срока.",
|
||||
backups:
|
||||
"Плановые бэкапы RouterOS по расписанию со страницы «Бэкапы»; тик планировщика раз в минуту.",
|
||||
alert_engine:
|
||||
"Оценка правил по данным из SQLite (сэмплы пишут джобы сбора, в т.ч. «GRE + BGP» и «Серверы: REST API»).",
|
||||
}
|
||||
|
||||
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,14 @@
|
||||
"./events": {
|
||||
"types": "./dist/events.d.ts",
|
||||
"default": "./dist/events.js"
|
||||
},
|
||||
"./certificates": {
|
||||
"types": "./dist/certificates.d.ts",
|
||||
"default": "./dist/certificates.js"
|
||||
},
|
||||
"./backups": {
|
||||
"types": "./dist/backups.d.ts",
|
||||
"default": "./dist/backups.js"
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { z } from "zod"
|
||||
|
||||
export const backupFrequencySchema = z.enum(["daily", "weekly", "monthly"])
|
||||
export const backupFormatSchema = z.enum(["rsc", "backup"])
|
||||
|
||||
export const backupScheduleSettingsDtoSchema = z.object({
|
||||
enabled: z.boolean(),
|
||||
frequency: backupFrequencySchema,
|
||||
hour: z.number().int().min(0).max(23),
|
||||
minute: z.number().int().min(0).max(59),
|
||||
weekDay: z.number().int().min(0).max(6),
|
||||
monthDay: z.number().int().min(1).max(28),
|
||||
keepCount: z.number().int().min(1).max(365),
|
||||
format: backupFormatSchema,
|
||||
serverIds: z.array(z.string()),
|
||||
lastRunAt: z.string().nullable().optional(),
|
||||
lastDurationMs: z.number().int().nullable().optional(),
|
||||
lastError: z.string().nullable().optional(),
|
||||
updatedAt: z.string().optional(),
|
||||
})
|
||||
|
||||
export const putBackupScheduleSettingsSchema = z.object({
|
||||
enabled: z.boolean().optional(),
|
||||
frequency: backupFrequencySchema.optional(),
|
||||
hour: z.number().int().min(0).max(23).optional(),
|
||||
minute: z.number().int().min(0).max(59).optional(),
|
||||
weekDay: z.number().int().min(0).max(6).optional(),
|
||||
monthDay: z.number().int().min(1).max(28).optional(),
|
||||
keepCount: z.number().int().min(1).max(365).optional(),
|
||||
format: backupFormatSchema.optional(),
|
||||
serverIds: z.array(z.string()).optional(),
|
||||
})
|
||||
|
||||
export type BackupScheduleSettingsDto = z.infer<typeof backupScheduleSettingsDtoSchema>
|
||||
@@ -0,0 +1,127 @@
|
||||
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 const certificateRenewSettingsDtoSchema = z.object({
|
||||
enabled: z.boolean(),
|
||||
intervalSec: z.number().int().min(300),
|
||||
renewBeforeDays: z.number().int().min(1).max(90),
|
||||
lastCollectedAt: z.string().nullable().optional(),
|
||||
lastDurationMs: z.number().int().nullable().optional(),
|
||||
lastError: z.string().nullable().optional(),
|
||||
updatedAt: z.string().optional(),
|
||||
})
|
||||
|
||||
export const putCertificateRenewSettingsSchema = z.object({
|
||||
enabled: z.boolean().optional(),
|
||||
intervalSec: z.number().int().min(300).optional(),
|
||||
renewBeforeDays: z.number().int().min(1).max(90).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>
|
||||
export type CertificateRenewSettingsDto = z.infer<typeof certificateRenewSettingsDtoSchema>
|
||||
@@ -8,6 +8,7 @@ export const eventSourceModuleSchema = z.enum([
|
||||
"filters",
|
||||
"traffic",
|
||||
"alerts",
|
||||
"certificates",
|
||||
"system",
|
||||
])
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
export * from "./servers.js"
|
||||
export * from "./alerts.js"
|
||||
export * from "./events.js"
|
||||
export * from "./certificates.js"
|
||||
export * from "./backups.js"
|
||||
|
||||
+16
-1
@@ -1,4 +1,5 @@
|
||||
import { requestJson } from "@/shared/api/http-client"
|
||||
import type { BackupScheduleSettingsDto } from "@mmapp/contracts/backups"
|
||||
|
||||
export type BackupItem = {
|
||||
id: string
|
||||
@@ -7,7 +8,7 @@ export type BackupItem = {
|
||||
filename: string
|
||||
sizeBytes: number
|
||||
createdAt: string
|
||||
kind: "manual"
|
||||
kind: "manual" | "auto"
|
||||
notes?: string
|
||||
}
|
||||
|
||||
@@ -66,3 +67,17 @@ export async function getBackupJob(baseUrl: string, jobId: string): Promise<Back
|
||||
export async function deleteBackup(baseUrl: string, id: string): Promise<void> {
|
||||
await requestJson<void>(baseUrl, `/api/backups/${id}`, { method: "DELETE" })
|
||||
}
|
||||
|
||||
export async function getBackupScheduleSettings(baseUrl: string): Promise<BackupScheduleSettingsDto> {
|
||||
return requestJson<BackupScheduleSettingsDto>(baseUrl, "/api/backups/schedule")
|
||||
}
|
||||
|
||||
export async function putBackupScheduleSettings(
|
||||
baseUrl: string,
|
||||
payload: Partial<BackupScheduleSettingsDto>,
|
||||
): Promise<BackupScheduleSettingsDto> {
|
||||
return requestJson<BackupScheduleSettingsDto>(baseUrl, "/api/backups/schedule", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import type {
|
||||
AcmeCloudflareSettingsDto,
|
||||
CertificateIssueJob,
|
||||
CertificateIssueRequest,
|
||||
CertificateRenewSettingsDto,
|
||||
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 ?? {}),
|
||||
})
|
||||
}
|
||||
|
||||
export async function getCertificateRenewSettings(baseUrl: string): Promise<CertificateRenewSettingsDto> {
|
||||
return requestJson<CertificateRenewSettingsDto>(baseUrl, "/api/certificates/renew-settings")
|
||||
}
|
||||
|
||||
export async function putCertificateRenewSettings(
|
||||
baseUrl: string,
|
||||
payload: {
|
||||
enabled?: boolean
|
||||
intervalSec?: number
|
||||
renewBeforeDays?: number
|
||||
},
|
||||
): Promise<CertificateRenewSettingsDto> {
|
||||
return requestJson<CertificateRenewSettingsDto>(baseUrl, "/api/certificates/renew-settings", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user