From d62d212c86aeaafdca92cce1de8aa5b6580bcd67 Mon Sep 17 00:00:00 2001 From: Denozordec Date: Tue, 12 May 2026 20:00:25 +0700 Subject: [PATCH] =?UTF-8?q?chore(backend):=20=D0=B4=D0=BE=D0=B1=D0=B0?= =?UTF-8?q?=D0=B2=D0=B8=D1=82=D1=8C=20=D0=BF=D0=BE=D0=B4=D0=B4=D0=B5=D1=80?= =?UTF-8?q?=D0=B6=D0=BA=D1=83=20ACME=20=D0=B8=20=D1=83=D0=BF=D1=80=D0=B0?= =?UTF-8?q?=D0=B2=D0=BB=D0=B5=D0=BD=D0=B8=D0=B5=20=D1=81=D0=B5=D1=80=D1=82?= =?UTF-8?q?=D0=B8=D1=84=D0=B8=D0=BA=D0=B0=D1=82=D0=B0=D0=BC=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(main)/certificates/page.tsx | 1141 ++++++++++++++---- app/(main)/dashboard/page.tsx | 43 +- backend/package.json | 3 +- backend/src/db/index.ts | 30 + backend/src/db/schema.ts | 26 + backend/src/index.ts | 2 + backend/src/routes/certificates.ts | 173 +++ backend/src/routes/sidebar-counts.ts | 18 +- backend/src/services/acme-cloudflare.ts | 183 +++ backend/src/services/certificate-parse.ts | 97 ++ backend/src/services/certificates-service.ts | 170 +++ backend/src/services/mikrotik.ts | 27 + components/app-sidebar.tsx | 5 +- lib/data-source.tsx | 28 +- package-lock.json | 360 +++++- packages/contracts/package.json | 4 + packages/contracts/src/certificates.ts | 110 ++ packages/contracts/src/events.ts | 1 + packages/contracts/src/index.ts | 1 + shared/api/certificates.ts | 60 + tsconfig.tsbuildinfo | 2 +- 21 files changed, 2183 insertions(+), 301 deletions(-) create mode 100644 backend/src/routes/certificates.ts create mode 100644 backend/src/services/acme-cloudflare.ts create mode 100644 backend/src/services/certificate-parse.ts create mode 100644 backend/src/services/certificates-service.ts create mode 100644 packages/contracts/src/certificates.ts create mode 100644 shared/api/certificates.ts diff --git a/app/(main)/certificates/page.tsx b/app/(main)/certificates/page.tsx index d1d6878..012f2ca 100644 --- a/app/(main)/certificates/page.tsx +++ b/app/(main)/certificates/page.tsx @@ -1,48 +1,86 @@ "use client" -import { useMemo, useState } from "react" +import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react" import { PageHeader } from "@/components/page-header" -import { routerCertificates, servers } from "@/lib/data" -import type { RouterCertificate, CertStatus } from "@/lib/data" +import { routerCertificates, servers as mockServers } from "@/lib/data" +import type { CertStatus, Server } from "@/lib/data" +import type { CertificateDto } from "@mmapp/contracts/certificates" import { Flag } from "@/components/flag" import { Card, CardContent } from "@/components/ui/card" import { Button } from "@/components/ui/button" -import { cn } from "@/lib/utils" +import { Input } from "@/components/ui/input" import { - SearchIcon, ShieldCheckIcon, ShieldAlertIcon, ShieldOffIcon, - BadgeCheckIcon, AlertTriangleIcon, AlertCircleIcon, - CalendarIcon, KeyRoundIcon, ServerIcon, PlusIcon, - ChevronDownIcon, ChevronRightIcon, + Sheet, + SheetContent, + SheetHeader, + SheetTitle, + SheetDescription, + SheetFooter, + SheetClose, +} from "@/components/ui/sheet" +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 { + createCertificateIssueJob, + getAcmeSettings, + getCertificateIssueJob, + listCertificates, + putAcmeSettings, + refreshCertificates, + testAcmeSettings, +} from "@/shared/api/certificates" +import { toast } from "sonner" +import { + SearchIcon, + ShieldCheckIcon, + ShieldAlertIcon, + ShieldOffIcon, + BadgeCheckIcon, + AlertTriangleIcon, + AlertCircleIcon, + CalendarIcon, + KeyRoundIcon, + ServerIcon, + PlusIcon, + ChevronDownIcon, + ChevronRightIcon, + RefreshCwIcon, } from "lucide-react" -// ─── helpers ────────────────────────────────────────────────────────────────── - -const STATUS_CONFIG: Record = { - valid: { +const STATUS_CONFIG: Record< + CertStatus, + { + label: string + icon: ReactNode + badge: string + row: string + } +> = { + valid: { label: "Действителен", - icon: , + icon: , badge: "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20", - row: "", + row: "", }, expired: { label: "Истёк", - icon: , + icon: , badge: "bg-red-500/10 text-red-600 dark:text-red-400 border-red-500/20", - row: "bg-red-500/5", + row: "bg-red-500/5", }, revoked: { label: "Отозван", - icon: , + icon: , badge: "bg-amber-500/10 text-amber-600 dark:text-amber-400 border-amber-500/20", - row: "bg-amber-500/5", + row: "bg-amber-500/5", }, } function daysLeftColor(days: number): string { - if (days < 0) return "text-red-500" - if (days <= 7) return "text-red-500" + if (days < 0) return "text-red-500" + if (days <= 7) return "text-red-500" if (days <= 30) return "text-amber-500" return "text-emerald-600 dark:text-emerald-400" } @@ -52,39 +90,126 @@ function daysLeftBar(days: number, total = 365): number { return Math.min(100, Math.round((days / total) * 100)) } -function serverForCert(cert: RouterCertificate) { - return servers.find((s) => s.id === cert.serverId) +function mockToDto(cert: (typeof routerCertificates)[number]): CertificateDto { + return { + id: cert.id, + name: cert.name, + serverId: cert.serverId, + commonName: cert.commonName, + sans: cert.sans, + issuedBy: cert.issuedBy, + validFrom: cert.validFrom, + validUntil: cert.validUntil, + daysLeft: cert.daysLeft, + keySize: cert.keySize, + usage: cert.usage, + trusted: cert.trusted, + status: cert.status, + } } -// ─── Certificate row ────────────────────────────────────────────────────────── +function CertPartDaysBar({ cert, pct }: { cert: CertificateDto; pct: number }) { + return ( +
+ ) +} -function CertRow({ +function CertPartDays({ cert, pct }: { cert: CertificateDto; pct: number }) { + return ( + <> +
+ + {cert.daysLeft < 0 ? `Истёк ${-cert.daysLeft}д назад` : `${cert.daysLeft}д осталось`} + + {cert.validUntil} +
+
+ +
+ + ) +} + +function CertPartDetailDates({ cert }: { cert: CertificateDto }) { + return ( +
+

Действителен с

+

{cert.validFrom}

+
+ ) +} + +function CertPartDetailSans({ cert }: { cert: CertificateDto }) { + return ( +
+

SAN / Alt Names

+
+ {cert.sans.length > 0 + ? cert.sans.map((s) => ( + + {s} + + )) + : } +
+
+ ) +} + +function CertPartDetailTrusted({ cert }: { cert: CertificateDto }) { + return ( +
+

Trusted

+

+ {cert.trusted ? "Да (доверенный)" : "Нет (не доверенный)"} +

+
+ ) +} + +function CertListRow({ cert, + cfg, + pct, + server, expanded, onToggle, }: { - cert: RouterCertificate + cert: CertificateDto + cfg: (typeof STATUS_CONFIG)[CertStatus] + pct: number + server?: Server expanded: boolean onToggle: () => void }) { - const srv = serverForCert(cert) - const cfg = STATUS_CONFIG[cert.status] - const pct = daysLeftBar(cert.daysLeft) - return (
- {/* expand */} - - - {/* name */}
{cfg.icon} @@ -92,96 +217,566 @@ function CertRow({

{cert.commonName}

- - {/* server */}
- {srv ? <>{srv.name} : } -
- - {/* issued by */} -

{cert.issuedBy}

- - {/* days left */} -
-
- - {cert.daysLeft < 0 ? `Истёк ${-cert.daysLeft}д назад` : `${cert.daysLeft}д осталось`} - - {cert.validUntil} -
-
-
+ + {server.name} + + ) + : ( + <> + + {cert.serverName ?? cert.serverId} + )} - style={{ width: `${pct}%` }} - /> -
- - {/* usage badges */} +

{cert.issuedBy}

+
+ +
{cert.usage.map((u) => ( - + {u} ))}
- - {/* status */} {cfg.label}
- - {/* expanded detail */} {expanded && (

Key size

{cert.keySize} bit

-
-

Действителен с

-

{cert.validFrom}

-
-
-

SAN / Alt Names

-
- {cert.sans.length > 0 - ? cert.sans.map((s) => {s}) - : } -
-
-
-

Trusted

-

- {cert.trusted ? "Да (доверенный)" : "Нет (не доверенный)"} -

-
+ + +
)}
) } -// ════════════════════════════════════════════════════════════════════════════ +function CertRow({ + cert, + server, + expanded, + onToggle, +}: { + cert: CertificateDto + server?: Server + expanded: boolean + onToggle: () => void +}) { + const cfg = STATUS_CONFIG[cert.status] + const pct = daysLeftBar(cert.daysLeft) + + return ( + + ) +} + +function CertPartAlertExpired({ expired }: { expired: CertificateDto[] }) { + return ( +
+ +
+

+ {expired.length} {expired.length === 1 ? "истёкший сертификат" : "истёкших сертификата"} +

+

+ {expired.map((c) => c.name).join(", ")} — требуют обновления +

+
+
+ ) +} + +function CertPartAlertExpiring({ expiring }: { expiring: CertificateDto[] }) { + return ( +
+ +
+

+ {expiring.length} {expiring.length === 1 ? "сертификат истекает" : "сертификата истекают"} в течение 30 дней +

+

+ {expiring.map((c) => `${c.name} (${c.daysLeft}д)`).join(", ")} +

+
+
+ ) +} + +function CertPartKpi({ + displayCerts, + expiring, + expired, +}: { + displayCerts: CertificateDto[] + expiring: CertificateDto[] + expired: CertificateDto[] +}) { + return ( +
+ {[ + { + label: "Всего", + value: displayCerts.length, + icon: , + }, + { + label: "Действующих", + value: displayCerts.filter((c) => c.status === "valid").length, + icon: , + }, + { + label: "Истекают", + value: expiring.length, + icon: , + }, + { + label: "Истёкших", + value: expired.length, + icon: , + }, + ].map((s) => ( + + +
+

{s.label}

+

{s.value}

+
+
{s.icon}
+
+
+ ))} +
+ ) +} + +function CertPartAcmeSettings({ + acmeDirectoryUrl, + setAcmeDirectoryUrl, + acmeZoneId, + setAcmeZoneId, + acmeTokenDraft, + setAcmeTokenDraft, + acmeTokenConfigured, + acmeSaveBusy, + onTest, + onSave, +}: { + acmeDirectoryUrl: string + setAcmeDirectoryUrl: (v: string) => void + acmeZoneId: string + setAcmeZoneId: (v: string) => void + acmeTokenDraft: string + setAcmeTokenDraft: (v: string) => void + acmeTokenConfigured: boolean + acmeSaveBusy: boolean + onTest: () => void + onSave: () => void +}) { + return ( + + +

ACME · Cloudflare DNS-01

+

+ Публичные Let's Encrypt для зон в Cloudflare выпускаются на backend и импортируются на RouterOS 7.22+. +

+
+
+ + setAcmeDirectoryUrl(e.target.value)} /> +
+
+ + setAcmeZoneId(e.target.value)} + placeholder="Авто по домену" + /> +
+
+ + setAcmeTokenDraft(e.target.value)} + placeholder={ + acmeTokenConfigured + ? "Токен сохранён — введите новый для замены" + : "API token с правом DNS" + } + /> +
+
+
+ + +
+
+
+ ) +} + +function CertPartTableToolbar({ + search, + setSearch, + statusFilter, + setStatusFilter, + filteredCount, +}: { + search: string + setSearch: (v: string) => void + statusFilter: CertStatus | "all" + setStatusFilter: (v: CertStatus | "all") => void + filteredCount: number +}) { + return ( +
+
+ + setSearch(e.target.value)} + /> +
+
+ {(["all", "valid", "expired", "revoked"] as const).map((s) => ( + + ))} +
+ {filteredCount} сертификатов +
+ ) +} + +function CertPartTableHeaderDates() { + return ( +
+ + Срок +
+ ) +} + +function CertPartTableHeaderUsage() { + return ( +
+ + Использование +
+ ) +} + +function CertPartTableHeader() { + return ( +
+ + Имя / CN + Сервер + Выпущен + + + Статус +
+ ) +} + +function CertPartReference() { + return ( + + +

+ RouterOS 7.22+ · публичные LE для Cloudflare через backend DNS-01, не через /certificate add-acme на устройстве. +

+

RouterOS 7 · /certificate — справка CLI

+
+ {[ + { + title: "Создать CA", + lines: [ + "/certificate add \\", + " name=my-ca \\", + " common-name=MyCA \\", + " key-size=4096 \\", + " days-valid=3650 \\", + " key-usage=key-cert-sign,crl-sign", + "/certificate sign my-ca", + ], + }, + { + title: "Импорт LE", + lines: [ + "/certificate import \\", + " file-name=router.crt \\", + " name=router-cert \\", + " trusted=yes \\", + " trust-store=www,api", + ], + }, + { + title: "Статус", + lines: ["/certificate print detail", "/certificate export-certificate router-cert"], + }, + ].map((b) => ( +
+

+ {b.title} +

+
+                {b.lines.join("\n")}
+              
+
+ ))} +
+
+
+ ) +} + +function CertPartIssueForm({ + serverList, + issueServerId, + setIssueServerId, + issueCertName, + setIssueCertName, + issueCommonName, + setIssueCommonName, + issueSans, + setIssueSans, + issueTrustWww, + setIssueTrustWww, + issueTrustApi, + setIssueTrustApi, +}: { + serverList: Server[] + issueServerId: string + setIssueServerId: (v: string) => void + issueCertName: string + setIssueCertName: (v: string) => void + issueCommonName: string + setIssueCommonName: (v: string) => void + issueSans: string + setIssueSans: (v: string) => void + issueTrustWww: boolean + setIssueTrustWww: (v: boolean) => void + issueTrustApi: boolean + setIssueTrustApi: (v: boolean) => void +}) { + return ( +
+
+ + +
+
+ + setIssueCertName(e.target.value)} + placeholder="router-le" + /> +
+
+ + setIssueCommonName(e.target.value)} + placeholder="vpn.example.com" + /> +
+
+ +