Files
MikrotikManager/backend/src/services/traffic-flow-settings-execution.ts
T

115 lines
6.3 KiB
TypeScript

import { isDeepStrictEqual } from "node:util"
import { trafficFlowSettingsPatchSchema, type TrafficFlowSettingsPatch } from "@mmapp/contracts/traffic-flow"
export const flowSettingsPatchSchema = trafficFlowSettingsPatchSchema.strict().refine(value => Object.values(value).some(item => item !== undefined))
/** Private, complete configuration, including keys and peer JSON. Exclude only ingestion
* telemetry and updatedAt. Keep unknown future configuration fields in this object. */
export type FlowSettingsConfiguration = Record<string, unknown> & { hostPublicKey: string; hostPrivateKey: string }
export type FlowSettingsCommand = { kind: "patch"; patch: TrafficFlowSettingsPatch } | { kind: "ensure-keys" }
export type FlowSettingsPlan = { command: FlowSettingsCommand; before: FlowSettingsConfiguration }
export type FlowSettingsPhase = "configuration" | "listener"
export type FlowSettingsReceipt = {
status: "applied" | "partial" | "unknown"
configurationConfirmed: boolean
listener: "not-requested" | "request-acknowledged" | "unknown"
listenerReadinessConfirmed: false
created?: boolean
publicKey?: string
attempts: number
retries: 0
}
export type FlowSettingsDependencies = {
/** Must cover the full server catalog, global permission, protections and live grant. */
authorize: () => Promise<void>
/** Serialize with other settings/key/overlay writers, not only this factory. */
exclusive: <T>(work: () => Promise<T>) => Promise<T>
readConfiguration: () => Promise<FlowSettingsConfiguration>
/** Persist one owned intent before dispatch; must refuse an existing/unknown intent. */
intent: (phase: FlowSettingsPhase) => Promise<void>
/** Atomic compare-and-set of the complete configuration. False means no write.
* Timestamp/cache invalidation belong to this adapter. Never retry an exception. */
compareAndSet: (expected: FlowSettingsConfiguration, desired: FlowSettingsConfiguration) => Promise<boolean>
generateKeys: () => { publicKey: string; privateKey: string }
/** The full REST stop/drain/flush/start operation, including enabled=false.
* The adapter must await fence before each stop/flush/prune/spawn dispatch,
* including after its internal awaits. An outer fence alone is insufficient. */
startListener: (fence: () => Promise<void>) => Promise<void>
}
const conflict = () => Object.assign(new Error("Настройки NetFlow изменились или не подтверждены"), { statusCode: 409 })
function configuration(value: FlowSettingsConfiguration): FlowSettingsConfiguration {
if (!value || typeof value !== "object" || typeof value.hostPublicKey !== "string" || typeof value.hostPrivateKey !== "string") throw conflict()
return structuredClone(value)
}
function command(value: FlowSettingsCommand): FlowSettingsCommand {
if (value.kind === "ensure-keys") {
if (Object.keys(value).some(key => key !== "kind")) throw conflict()
return { kind: "ensure-keys" }
}
if (value.kind !== "patch" || Object.keys(value).some(key => !["kind", "patch"].includes(key))) throw conflict()
const parsed = flowSettingsPatchSchema.parse(value.patch)
return { kind: "patch", patch: Object.fromEntries(Object.entries(parsed).filter(([, value]) => value !== undefined)) }
}
/** Local candidate only: no runtime imports, registration, default dependencies or
* secret export. An acknowledged listener request never proves UDP readiness. */
export function createFlowSettingsExecution(deps: FlowSettingsDependencies) {
async function prepare(input: FlowSettingsCommand): Promise<FlowSettingsPlan> {
const parsed = command(input)
await deps.authorize()
const before = configuration(await deps.readConfiguration())
await deps.authorize()
return { command: parsed, before }
}
async function execute(prepared: FlowSettingsPlan): Promise<FlowSettingsReceipt> {
const plan = { command: command(prepared.command), before: configuration(prepared.before) }
return deps.exclusive(async () => {
const fence = async (expected: FlowSettingsConfiguration) => {
await deps.authorize()
if (!isDeepStrictEqual(configuration(await deps.readConfiguration()), expected)) throw conflict()
await deps.authorize()
}
await fence(plan.before)
const keysAlreadyPresent = Boolean(plan.before.hostPublicKey && plan.before.hostPrivateKey)
if (plan.command.kind === "ensure-keys" && keysAlreadyPresent) return {
status: "applied", configurationConfirmed: true, listener: "not-requested", listenerReadinessConfirmed: false,
publicKey: plan.before.hostPublicKey, created: false, attempts: 0, retries: 0,
}
const desired = configuration(plan.before)
if (plan.command.kind === "patch") Object.assign(desired, plan.command.patch)
else {
const pair = deps.generateKeys()
if (typeof pair.publicKey !== "string" || typeof pair.privateKey !== "string" || !pair.publicKey || !pair.privateKey) throw conflict()
desired.hostPublicKey = pair.publicKey
desired.hostPrivateKey = pair.privateKey
}
await deps.intent("configuration")
await fence(plan.before)
const receipt: FlowSettingsReceipt = { status: "unknown", configurationConfirmed: false,
listener: "not-requested", listenerReadinessConfirmed: false, attempts: 1, retries: 0 }
try {
if (!await deps.compareAndSet(plan.before, desired)) throw conflict()
await fence(desired)
} catch { return receipt }
receipt.configurationConfirmed = true
if (plan.command.kind === "ensure-keys") return { ...receipt, status: "applied", publicKey: desired.hostPublicKey, created: true }
// Every accepted patch, including display-only and no-op updates, has this
// effect in REST. A denied listener fence stops here without starting it.
receipt.status = "partial"
try {
await deps.intent("listener")
await fence(desired)
} catch { return receipt }
receipt.listener = "unknown"
receipt.attempts++
try {
await deps.startListener(() => fence(desired))
await fence(desired)
receipt.listener = "request-acknowledged"
receipt.status = "applied"
} catch { /* No replay or arbitrary exception text in model-facing output. */ }
return receipt
})
}
return { prepare, execute }
}