Docker images / prepare-release (push) Successful in 7s
Docker images / backend-test (push) Successful in 1m51s
Docker images / frontend-image (push) Successful in 2m54s
Docker images / updater-image (push) Successful in 40s
Docker images / backend-image (push) Successful in 2m11s
Docker images / notify-webhook (push) Skipped
Docker images / publish-release (push) Successful in 10s
Co-authored-by: Cursor <cursoragent@cursor.com>
259 lines
9.1 KiB
TypeScript
259 lines
9.1 KiB
TypeScript
import type { MikrotikClient } from "./mikrotik.js"
|
|
import {
|
|
ipsecManagedComment,
|
|
selectIke2Peers,
|
|
selectSharedIke2Identity,
|
|
type Ike2IdentityLike,
|
|
type PeerLike,
|
|
} from "./ipsec-config.js"
|
|
|
|
export function toRosBody(obj: Record<string, string | number | boolean | undefined | null>): Record<string, string> {
|
|
const out: Record<string, string> = {}
|
|
for (const [k, v] of Object.entries(obj)) {
|
|
if (v == null) continue
|
|
const s = String(v)
|
|
if (s === "") continue
|
|
out[k] = s
|
|
}
|
|
return out
|
|
}
|
|
|
|
type RosRow = Record<string, string | undefined>
|
|
|
|
async function listByPath(client: MikrotikClient, path: string): Promise<RosRow[]> {
|
|
const raw = await client.get<unknown>(path).catch(() => [])
|
|
return Array.isArray(raw) ? (raw as RosRow[]) : []
|
|
}
|
|
|
|
/** PUT (создать) или PATCH по имени — мастер инициализации идемпотентен. */
|
|
export async function putOrPatchByName(
|
|
client: MikrotikClient,
|
|
path: string,
|
|
name: string,
|
|
body: Record<string, string>,
|
|
): Promise<"created" | "patched"> {
|
|
const rows = await listByPath(client, path)
|
|
const existing = rows.find((r) => String(r.name ?? "").trim() === name)
|
|
if (existing?.[".id"]) {
|
|
await client.patch(`${path}/${encodeURIComponent(existing[".id"])}`, body)
|
|
return "patched"
|
|
}
|
|
await client.put(path, body)
|
|
return "created"
|
|
}
|
|
|
|
async function findRosId(client: MikrotikClient, path: string, name: string): Promise<string | null> {
|
|
const rows = await listByPath(client, path)
|
|
return rows.find((r) => String(r.name ?? "").trim() === name)?.[".id"] ?? null
|
|
}
|
|
|
|
// ── инициализация IKEv2-сервера ─────────────────────────────────────────────
|
|
|
|
export async function ensureIpsecProfile(client: MikrotikClient, name: string, comment: string): Promise<void> {
|
|
await putOrPatchByName(client, "/ip/ipsec/profile", name, toRosBody({
|
|
name,
|
|
"hash-algorithm": "sha256",
|
|
"enc-algorithm": "aes-256,aes-192,aes-128",
|
|
"dh-group": "modp2048,modp1536,modp1024",
|
|
"nat-traversal": "yes",
|
|
comment,
|
|
}))
|
|
}
|
|
|
|
export async function ensureIpsecProposal(client: MikrotikClient, name: string, comment: string): Promise<void> {
|
|
await putOrPatchByName(client, "/ip/ipsec/proposal", name, toRosBody({
|
|
name,
|
|
"auth-algorithms": "sha256,sha1",
|
|
"enc-algorithms": "aes-256-cbc,aes-192-cbc,aes-128-cbc",
|
|
"pfs-group": "modp2048",
|
|
comment,
|
|
}))
|
|
}
|
|
|
|
export async function ensureIpsecPool(client: MikrotikClient, name: string, ranges: string, comment: string): Promise<void> {
|
|
await putOrPatchByName(client, "/ip/pool", name, toRosBody({ name, ranges, comment }))
|
|
}
|
|
|
|
/** Общий mode-config: адрес клиентам из пула. */
|
|
export async function ensureSharedModeConfig(
|
|
client: MikrotikClient,
|
|
name: string,
|
|
poolName: string,
|
|
dns: string | undefined,
|
|
comment: string,
|
|
): Promise<void> {
|
|
await putOrPatchByName(client, "/ip/ipsec/mode-config", name, toRosBody({
|
|
name,
|
|
"address-pool": poolName,
|
|
"static-dns": dns,
|
|
comment,
|
|
}))
|
|
}
|
|
|
|
export async function ensurePolicyTemplate(
|
|
client: MikrotikClient,
|
|
dstCidr: string,
|
|
proposalName: string,
|
|
comment: string,
|
|
): Promise<void> {
|
|
const rows = await listByPath(client, "/ip/ipsec/policy")
|
|
const managed = rows.find((r) => (r.comment ?? "").trim() === comment && (r.template === "true" || r.template === "yes"))
|
|
if (managed?.[".id"]) {
|
|
await client.patch(`/ip/ipsec/policy/${encodeURIComponent(managed[".id"])}`, toRosBody({
|
|
"src-address": "0.0.0.0/0",
|
|
"dst-address": dstCidr,
|
|
proposal: proposalName,
|
|
comment,
|
|
}))
|
|
return
|
|
}
|
|
await client.put("/ip/ipsec/policy", toRosBody({
|
|
"src-address": "0.0.0.0/0",
|
|
"dst-address": dstCidr,
|
|
proposal: proposalName,
|
|
template: "yes",
|
|
comment,
|
|
}))
|
|
}
|
|
|
|
export async function ensureIpsecPeer(
|
|
client: MikrotikClient,
|
|
name: string,
|
|
serverCertName: string,
|
|
profileName: string,
|
|
comment: string,
|
|
): Promise<void> {
|
|
await putOrPatchByName(client, "/ip/ipsec/peer", name, toRosBody({
|
|
name,
|
|
address: "0.0.0.0/0",
|
|
"exchange-mode": "ike2",
|
|
passive: "yes",
|
|
certificate: serverCertName,
|
|
"send-cert": "always",
|
|
profile: profileName,
|
|
comment,
|
|
}))
|
|
}
|
|
|
|
/** Managed srcnat masquerade: интернет клиентам VPN. */
|
|
export async function ensureNatRule(client: MikrotikClient, poolCidr: string, comment: string): Promise<void> {
|
|
const rows = await listByPath(client, "/ip/firewall/nat")
|
|
const existing = rows.find(
|
|
(r) => (r.comment ?? "").trim() === comment && r.chain === "srcnat",
|
|
)
|
|
if (existing?.[".id"]) return
|
|
await client.put("/ip/firewall/nat", toRosBody({
|
|
chain: "srcnat",
|
|
action: "masquerade",
|
|
"src-address": poolCidr,
|
|
"out-interface-list": "WAN",
|
|
comment,
|
|
}))
|
|
}
|
|
|
|
// ── клиенты ─────────────────────────────────────────────────────────────────
|
|
|
|
export async function putUserModeConfig(
|
|
client: MikrotikClient,
|
|
name: string,
|
|
staticIp: string,
|
|
comment: string,
|
|
): Promise<void> {
|
|
await putOrPatchByName(client, "/ip/ipsec/mode-config", name, toRosBody({
|
|
name,
|
|
address: staticIp,
|
|
comment,
|
|
}))
|
|
}
|
|
|
|
export async function deleteUserModeConfig(client: MikrotikClient, name: string): Promise<void> {
|
|
const id = await findRosId(client, "/ip/ipsec/mode-config", name)
|
|
if (id) await client.delete(`/ip/ipsec/mode-config/${encodeURIComponent(id)}`)
|
|
}
|
|
|
|
export interface IdentityFields {
|
|
peerName: string
|
|
modeConfig: string
|
|
comment: string
|
|
authMethod: "certificate" | "pre-shared-key"
|
|
/** cert: серверный сертификат (router представляет его клиенту). */
|
|
certificate?: string
|
|
/** cert: строгий мэтч конкретного клиента по его сертификату. */
|
|
remoteCertificate?: string
|
|
/** psk. */
|
|
secret?: string
|
|
remoteId?: string
|
|
/** Вставить identity перед указанной (per-client match-by=certificate должен идти раньше общей). */
|
|
placeBefore?: string
|
|
}
|
|
|
|
export function identityRosBody(f: IdentityFields): Record<string, string> {
|
|
return toRosBody({
|
|
peer: f.peerName,
|
|
"auth-method": f.authMethod === "certificate" ? "rsa-key" : "pre-shared-key",
|
|
certificate: f.certificate,
|
|
"remote-certificate": f.remoteCertificate,
|
|
"match-by": f.authMethod === "certificate" ? "certificate" : undefined,
|
|
secret: f.secret,
|
|
"remote-id": f.remoteId,
|
|
"mode-config": f.modeConfig,
|
|
"generate-policy": "port-strict",
|
|
"place-before": f.placeBefore,
|
|
comment: f.comment,
|
|
})
|
|
}
|
|
|
|
export async function putIdentity(client: MikrotikClient, fields: IdentityFields): Promise<void> {
|
|
await client.put("/ip/ipsec/identity", identityRosBody(fields))
|
|
}
|
|
|
|
/**
|
|
* Общая listener-identity на IKEv2 peer для всех клиентов (reuse существующей, иначе создать).
|
|
* Без `remote-certificate`/`match-by` — RouterOS принимает любой клиентский серт, подписанный доверенным CA.
|
|
*/
|
|
export async function ensureSharedIke2Identity(
|
|
client: MikrotikClient,
|
|
args: { peerName: string; serverCertName: string; modeConfigName?: string; comment: string },
|
|
): Promise<string | undefined> {
|
|
const identities = await listByPath(client, "/ip/ipsec/identity")
|
|
const peers = await listByPath(client, "/ip/ipsec/peer")
|
|
const peerNames = selectIke2Peers(peers as PeerLike[]).map((p) => (p.name ?? "").trim()).filter(Boolean)
|
|
const existing = selectSharedIke2Identity(
|
|
identities as Ike2IdentityLike[],
|
|
peerNames.length > 0 ? peerNames : [args.peerName],
|
|
)
|
|
if (existing?.[".id"]) return String(existing[".id"])
|
|
|
|
await putIdentity(client, {
|
|
peerName: args.peerName,
|
|
modeConfig: args.modeConfigName ?? "",
|
|
comment: args.comment,
|
|
authMethod: "certificate",
|
|
certificate: args.serverCertName,
|
|
})
|
|
return undefined
|
|
}
|
|
|
|
export async function patchIdentity(client: MikrotikClient, rosId: string, body: Record<string, string>): Promise<void> {
|
|
await client.patch(`/ip/ipsec/identity/${encodeURIComponent(rosId)}`, body)
|
|
}
|
|
|
|
export async function deleteIdentity(client: MikrotikClient, rosId: string): Promise<void> {
|
|
await client.delete(`/ip/ipsec/identity/${encodeURIComponent(rosId)}`)
|
|
}
|
|
|
|
export async function patchPeer(client: MikrotikClient, rosId: string, body: Record<string, string>): Promise<void> {
|
|
await client.patch(`/ip/ipsec/peer/${encodeURIComponent(rosId)}`, body)
|
|
}
|
|
|
|
export async function deletePeer(client: MikrotikClient, rosId: string): Promise<void> {
|
|
await client.delete(`/ip/ipsec/peer/${encodeURIComponent(rosId)}`)
|
|
}
|
|
|
|
/** Снять с identity персональный mode-config (вернуть выдачу из пула) безопасно: пустой patch не шлём. */
|
|
export async function clearIdentityModeConfig(client: MikrotikClient, rosId: string, sharedModeConfig: string): Promise<void> {
|
|
await patchIdentity(client, rosId, toRosBody({ "mode-config": sharedModeConfig }))
|
|
}
|
|
|
|
export { listByPath, findRosId, ipsecManagedComment }
|