feat(ipsec): управление IKEv2/IPsec VPN и клиентами из одного окна
Docker images / prepare-release (push) Successful in 15s
Docker images / backend-test (push) Successful in 2m32s
Docker images / frontend-image (push) Successful in 4m19s
Docker images / updater-image (push) Successful in 50s
Docker images / backend-image (push) Successful in 2m40s
Docker images / notify-webhook (push) Skipped
Docker images / publish-release (push) Successful in 8s

- мастер инициализации сервера: CA и серверный сертификаты, peer/profile/proposal, пул, mode-config, policy-template, managed NAT masquerade
- клиенты по сертификату (RSA) и PSK: статический IP или из пула, онлайн-статус по active-peers
- скачивание .p12 и strongSwan .sswan с инструкцией, перекачка с новой passphrase
- история изменений (config_revisions, секция ipsec) и restore только managed-объектов
- привязка IPsec-клиентов к пользователям приложения по Common Name
- страница /ipsec с KPI и вкладками Клиенты/Сервер/CLI, сайдбар, command palette
This commit is contained in:
Denozordec committed 2026-09-12 20:20:56 +07:00
1 parent 564aae21f0
commit 7755d77340
35 files changed
+4154 -13

No files matched your search

+2 -1
View File
@@ -1,4 +1,4 @@
export type InterfaceType = "ether" | "gre" | "wg" | "other"
export type InterfaceType = "ether" | "gre" | "wg" | "ipsec" | "other"
export function mapRosInterfaceType(raw: string | undefined | null, name?: string): InterfaceType {
const t = String(raw ?? "").trim().toLowerCase()
@@ -10,6 +10,7 @@ export function mapRosInterfaceType(raw: string | undefined | null, name?: strin
const n = String(name ?? "").trim().toLowerCase()
if (n.startsWith("gre") || n.includes("gre-tunnel")) return "gre"
if (n.startsWith("wg-") || n.startsWith("wireguard")) return "wg"
if (n.startsWith("ipsec")) return "ipsec"
if (n.startsWith("ether") || n.startsWith("sfp")) return "ether"
return "other"
}
+7 -1
View File
@@ -28,7 +28,7 @@ export function peerDisplayName(opts: {
return truncPeerKey(opts.publicKey)
}
/** Ether/GRE — пустой ключ. WG — обязательный public-key. */
/** Ether/GRE — пустой ключ. WG — обязательный public-key. IPsec — CN сертификата клиента. */
export function normalizeBindingPeer(
type: InterfaceType,
peerPublicKey: string | undefined,
@@ -40,5 +40,11 @@ export function normalizeBindingPeer(
}
return key
}
if (type === "ipsec") {
if (!key) {
throw new PeerBindError("Для IPsec укажите клиента (CN сертификата)", 400)
}
return key
}
return ""
}
@@ -43,6 +43,7 @@ import {
peerDisplayName,
} from "../peer-bind.js"
import { listWireGuardPeersForCatalog } from "../../../services/wireguard-live.js"
import { listIpsecClientsForCatalog } from "../../../services/ipsec-live.js"
export class UsersServiceError extends Error {
constructor(
@@ -305,7 +306,10 @@ export async function listInterfaceCatalog(serverId: number): Promise<CatalogInt
peersByIface.set(peer.interfaceName, list)
}
return ifaces.map((iface) => {
// IKEv2-клиенты — псевдо-интерфейс «ipsec-vpn» с пирами = клиенты (ключ = CN сертификата).
const ipsecLive = await listIpsecClientsForCatalog(serverId)
const entries: CatalogInterface[] = ifaces.map((iface) => {
const ifaceBind = bindings.find((b) => b.interfaceName === iface.name && !(b.peerPublicKey ?? ""))
const owner = ifaceBind ? usersById.get(ifaceBind.userId) : undefined
const base: CatalogInterface = {
@@ -335,7 +339,37 @@ export async function listInterfaceCatalog(serverId: number): Promise<CatalogInt
}
}),
}
}).sort((a, b) => a.name.localeCompare(b.name))
})
if (ipsecLive.clients.length > 0) {
const ifaceBind = bindings.find((b) => b.interfaceName === "ipsec-vpn" && !(b.peerPublicKey ?? ""))
const owner = ifaceBind ? usersById.get(ifaceBind.userId) : undefined
entries.push({
name: "ipsec-vpn",
type: "ipsec",
running: true,
disabled: false,
boundUserId: ifaceBind?.userId ?? null,
boundUserLogin: owner?.login ?? null,
peersError: ipsecLive.error,
peers: ipsecLive.clients.map((c) => {
const cn = c.commonName ?? c.name
const bind = bindings.find((b) => b.interfaceName === "ipsec-vpn" && b.peerPublicKey === cn)
const peerOwner = bind ? usersById.get(bind.userId) : undefined
return {
publicKey: cn,
name: c.name,
comment: c.comment ?? "",
allowedIps: c.staticIp ? [c.staticIp] : [],
latestHandshake: c.activeSince,
boundUserId: bind?.userId ?? null,
boundUserLogin: peerOwner?.login ?? null,
}
}),
})
}
return entries.sort((a, b) => a.name.localeCompare(b.name))
}
export { parseRawInterfaces, mapRosInterfaceType }