feat(mcp): подготовить сервер и доступ к приёмке

This commit is contained in:
Denozordec committed 2026-11-03 03:12:03 +07:00
commit bf3d50e1ff
3835 files changed
+537122

No files matched your search

+81
View File
@@ -0,0 +1,81 @@
{
"name": "@mmapp/contracts",
"version": "1.0.0",
"private": true,
"type": "module",
"files": [
"dist"
],
"scripts": {
"build": "tsc -p tsconfig.json"
},
"exports": {
"./mcp-routing": {
"types": "./dist/mcp-routing.d.ts",
"default": "./dist/mcp-routing.js"
},
"./mcp": {
"types": "./dist/mcp.d.ts",
"default": "./dist/mcp.js"
},
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"./evobgp": {
"types": "./dist/evobgp.d.ts",
"default": "./dist/evobgp.js"
},
"./servers": {
"types": "./dist/servers.d.ts",
"default": "./dist/servers.js"
},
"./alerts": {
"types": "./dist/alerts.d.ts",
"default": "./dist/alerts.js"
},
"./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"
},
"./wireguard": {
"types": "./dist/wireguard.d.ts",
"default": "./dist/wireguard.js"
},
"./ipsec": {
"types": "./dist/ipsec.d.ts",
"default": "./dist/ipsec.js"
},
"./users": {
"types": "./dist/users.d.ts",
"default": "./dist/users.js"
},
"./traffic-flow": {
"types": "./dist/traffic-flow.d.ts",
"default": "./dist/traffic-flow.js"
},
"./geoip": {
"types": "./dist/geoip.d.ts",
"default": "./dist/geoip.js"
},
"./statistics": {
"types": "./dist/statistics.d.ts",
"default": "./dist/statistics.js"
},
"./containers": {
"types": "./dist/containers.d.ts",
"default": "./dist/containers.js"
}
},
"dependencies": {
"zod": "^4.4.1"
}
}
@@ -0,0 +1,12 @@
import assert from "node:assert/strict"
import {test} from "node:test"
import {normalizeAlertsConfig,type PutRulesBody} from "./alerts.js"
const rule={id:"r",name:"Server",type:"server" as const,target:"legacy",condition:"down",severity:"critical" as const,enabled:false,cooldown:"5м" as const,chatId:""}
test("Alerts normalization preserves policy and produces shared summaries/ordered OR arrays",()=>{
const result=normalizeAlertsConfig({rules:[{...rule,targets:[" a ","b","c"],conditions:[" x ","y","z","q"],confirmStabilitySec:0,recoveryMode:"never",recoveryStabilitySec:0}],groups:[]})
assert.deepEqual(result.rules[0].targets,["a","b","c"]);assert.deepEqual(result.rules[0].conditions,["x","y","z","q"]);assert.equal(result.rules[0].target,"a, b +1");assert.equal(result.rules[0].condition,"x · y · +2");assert.equal(result.rules[0].enabled,false);assert.equal(result.rules[0].recoveryMode,"never");assert.equal(result.rules[0].confirmStabilitySec,null);assert.equal(result.rules[0].recoveryStabilitySec,null);assert.equal(result.rules[0].cooldown,"5м")
})
test("known empty differs from omitted groups; legacy target/condition retained",()=>{assert.deepEqual(normalizeAlertsConfig({rules:[],groups:[]}),{rules:[],groups:[]});const result=normalizeAlertsConfig({rules:[{...rule,groupId:"g"}]});assert.equal(result.groups,undefined);assert.deepEqual(result.rules[0].targets,["legacy"]);assert.deepEqual(result.rules[0].conditions,["down"]);assert.equal(result.rules[0].groupId,"g")})
test("duplicates, dangling groups, whitespace-only objects/conditions and invalid delays reject",()=>{
for(const payload of [{rules:[rule,rule]},{rules:[{...rule,groupId:"missing"}],groups:[]},{rules:[{...rule,targets:[" "]}]},{rules:[{...rule,conditions:[" "]}]},{rules:[{...rule,confirmStabilitySec:1.5}]},{rules:[],groups:[{id:"g",name:"Group",combineMode:"any",enabled:false,cooldownOverride:null},{id:"g",name:"Group",combineMode:"any",enabled:false,cooldownOverride:null}]}])assert.throws(()=>normalizeAlertsConfig(payload as PutRulesBody))
})
+145
View File
@@ -0,0 +1,145 @@
import { z } from "zod"
export const alertTypeSchema = z.enum([
"gre-tunnel",
"bgp-peer",
"bgp-prefix",
"gre-client",
"server",
"rtt",
"loss",
"traffic",
])
export const alertSeveritySchema = z.enum(["critical", "warning", "info"])
export const alertCooldownSchema = z.enum(["1м", "5м", "15м", "1ч", "4ч", "24ч"])
export const recoveryModeSchema = z.enum(["always", "never", "conditional"])
export const combineModeSchema = z.enum(["any", "all"])
export const alertGroupSchema = z.object({
id: z.string().min(1),
name: z.string().min(1),
combineMode: combineModeSchema,
enabled: z.boolean(),
cooldownOverride: z.union([alertCooldownSchema, z.null()]),
})
export const alertRuleSchema = z
.object({
id: z.string().min(1),
name: z.string().min(1),
type: alertTypeSchema,
target: z.string().optional(),
targets: z.array(z.string().min(1)).optional(),
groupId: z.string().nullable().optional(),
condition: z.string().optional(),
conditions: z.array(z.string().min(1)).optional(),
severity: alertSeveritySchema,
enabled: z.boolean(),
cooldown: alertCooldownSchema,
confirmStabilitySec: z.union([z.number().int().min(0).max(86400), z.null()]).optional(),
recoveryMode: recoveryModeSchema.optional(),
recoveryStabilitySec: z.union([z.number().int().min(0).max(86400), z.null()]).optional(),
chatId: z.string(),
})
.refine((r) => (r.targets != null && r.targets.length > 0) || Boolean(r.target?.trim()), {
message: "Нужен хотя бы один объект: targets или target",
path: ["targets"],
})
.refine((r) => (r.conditions != null && r.conditions.length > 0) || Boolean(r.condition?.trim()), {
message: "Нужно хотя бы одно условие: conditions или condition",
path: ["conditions"],
})
export const putRulesSchema = z.object({
rules: z.array(alertRuleSchema),
groups: z.array(alertGroupSchema).optional(),
})
export const putTelegramSchema = z.object({
token: z.union([z.string(), z.null()]).optional(),
chatId: z.string().optional(),
messageThreadId: z.union([z.number().int().positive().max(2147483647), z.null()]).optional(),
})
export const rulePreviewTelegramSchema = z.object({
name: z.string().min(1),
targets: z.array(z.string().min(1)).min(1),
conditionLine: z.string().min(1),
severity: alertSeveritySchema,
cooldown: alertCooldownSchema,
})
export const testTelegramSchema = z.object({
token: z.string().optional(),
chatId: z.string().optional(),
messageThreadId: z.number().int().positive().max(2147483647).optional(),
rulePreview: rulePreviewTelegramSchema.optional(),
})
export type AlertGroup = z.infer<typeof alertGroupSchema>
export type AlertRule = z.infer<typeof alertRuleSchema>
export type PutRulesBody = z.infer<typeof putRulesSchema>
export type PutTelegramBody = z.infer<typeof putTelegramSchema>
export type RulePreviewTelegram = z.infer<typeof rulePreviewTelegramSchema>
export type TestTelegramBody = z.infer<typeof testTelegramSchema>
// Public response schemas: configured credentials do not prove Telegram connectivity/delivery.
const alertTimestampSchema = z.string().min(1).refine(value => Number.isFinite(Date.parse(value)), "Некорректное время оповещения")
export const telegramPublicSchema = z.object({
chatId:z.string(),tokenConfigured:z.boolean(),connected:z.boolean(),messageThreadId:z.number().int().positive().max(2147483647).nullable(),
}).strict().refine(value => value.connected === (value.tokenConfigured && Boolean(value.chatId.trim())), "Противоречивое состояние параметров Telegram")
export const alertRuleResponseSchema = z.object({
id:z.string().min(1),name:z.string().min(1),type:alertTypeSchema,target:z.string(),targets:z.array(z.string().min(1)),groupId:z.string().nullable(),condition:z.string(),conditions:z.array(z.string().min(1)),severity:alertSeveritySchema,enabled:z.boolean(),cooldown:alertCooldownSchema,
confirmStabilitySec:z.number().int().min(0).max(86400).nullable(),recoveryMode:recoveryModeSchema,recoveryStabilitySec:z.number().int().min(0).max(86400).nullable(),chatId:z.string(),lastFiredAt:alertTimestampSchema.nullable(),
}).strict()
export const alertHistoryResponseSchema = z.object({id:z.string().min(1),ruleName:z.string(),severity:alertSeveritySchema,message:z.string(),sent:z.boolean(),firedAt:alertTimestampSchema,source:z.enum(["db","live"])}).strict()
export const alertTargetIssueSchema = z.object({
kind:z.enum(["server","traffic","probe"]),target:z.string().min(1),reason:z.enum(["ambiguous","disabled-object","disabled-source"]),
}).strict()
export type AlertTargetIssue=z.output<typeof alertTargetIssueSchema>
export const alertsMetaResponseSchema = z.object({
servers:z.array(z.string()),greClients:z.array(z.string()),probeTargets:z.array(z.string()),rttLossTargets:z.array(z.string()),trafficServers:z.array(z.string()),
serversDetail:z.array(z.object({name:z.string(),host:z.string(),site:z.string(),country:z.string(),type:z.string()}).strict()),
targetIssues:z.array(alertTargetIssueSchema).optional(),
}).strict()
const alertsConfigResponseShape = {groups:z.array(alertGroupSchema.strict()),rules:z.array(alertRuleResponseSchema)}
function validConfig(value:{groups:{id:string}[];rules:{id:string;groupId:string|null}[]}):boolean {
const groups=new Set(value.groups.map(row=>row.id)),rules=new Set(value.rules.map(row=>row.id))
return groups.size===value.groups.length&&rules.size===value.rules.length&&value.rules.every(row=>row.groupId===null||groups.has(row.groupId))
}
export const alertsRevisionSchema=z.string().regex(/^[a-f0-9]{64}$/)
export const alertsResponseSchema = z.object({revision:alertsRevisionSchema,telegram:telegramPublicSchema,...alertsConfigResponseShape,history:z.array(alertHistoryResponseSchema),meta:alertsMetaResponseSchema}).strict().refine(validConfig,"Повторяющиеся ID или неизвестная группа правила")
export const alertsRulesReceiptSchema = z.object({ok:z.literal(true),revision:alertsRevisionSchema,...alertsConfigResponseShape}).strict().refine(validConfig,"Повторяющиеся ID или неизвестная группа правила")
export const telegramSettingsReceiptSchema = z.object({ok:z.literal(true),telegram:telegramPublicSchema}).strict()
export const telegramTestReceiptSchema = z.object({ok:z.literal(true)}).strict()
export type AlertsResponse = z.output<typeof alertsResponseSchema>
/** Normalize the stored rule representation without altering OR/AND or cooldown policies. */
export function normalizeAlertsConfig(input:PutRulesBody){
const parsed=putRulesSchema.strict().safeParse(input)
if(!parsed.success)throw Error("Проверьте правила и группы оповещений")
const groups=parsed.data.groups?.map(group=>({...group,name:group.name.trim()||group.id}))
const groupIds=groups===undefined?undefined:new Set(groups.map(group=>group.id))
const ids=new Set<string>()
if(groups!==undefined&&groupIds!.size!==groups.length)throw Error("Повторяющиеся ID групп")
const rules=parsed.data.rules.map(rule=>{
if(ids.has(rule.id))throw Error("Повторяющиеся ID правил")
ids.add(rule.id)
const targets=(rule.targets?.length?rule.targets:[rule.target??""]).map(value=>value.trim()).filter(Boolean)
const conditions=(rule.conditions?.length?rule.conditions:[rule.condition??""]).map(value=>value.trim()).filter(Boolean)
if(!targets.length||!conditions.length)throw Error("Нужны непустые объекты и условия правила")
const groupId=rule.groupId?.trim()||null
if(groupId!==null&&groupIds!==undefined&&!groupIds.has(groupId))throw Error("Неизвестная группа правила")
const target=targets.length<=2?targets.join(", "):targets[0]+", "+targets[1]+" +"+(targets.length-2)
const condition=conditions.length===1?conditions[0]!:conditions.length<=3?conditions.join(" · "):conditions[0]+" · "+conditions[1]+" · +"+(conditions.length-2)
return {...rule,target,targets,condition,conditions,groupId,confirmStabilitySec:rule.confirmStabilitySec||null,recoveryMode:rule.recoveryMode??"always",recoveryStabilitySec:rule.recoveryStabilitySec||null}
})
return {...(groups!==undefined?{groups}:{}),rules}
}
const outboxCount=z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER)
export const alertsOutboxOverviewSchema=z.object({counts:z.object({total:outboxCount,pending:outboxCount,processing:outboxCount,unknown:outboxCount,sent:outboxCount,failed:outboxCount,unrecognized:outboxCount}).strict(),items:z.array(z.object({id:z.string().min(1),resolutionVersion:z.number().int().nonnegative().max(2147483647),status:z.enum(["processing","unknown"]),ruleName:z.string().nullable(),createdAt:alertTimestampSchema,attemptedAt:alertTimestampSchema}).strict()).max(20),itemsLimit:z.literal(20)}).strict().refine(value=>value.counts.total===value.counts.pending+value.counts.processing+value.counts.unknown+value.counts.sent+value.counts.failed+value.counts.unrecognized&&value.items.length===Math.min(20,value.counts.processing+value.counts.unknown)&&value.items.filter(item=>item.status==="processing").length<=value.counts.processing&&value.items.filter(item=>item.status==="unknown").length<=value.counts.unknown&&new Set(value.items.map(item=>item.id)).size===value.items.length,"Неполное состояние очереди")
export type AlertsOutboxOverview=z.output<typeof alertsOutboxOverviewSchema>
export const alertsOutboxRetrySchema=z.object({expectedVersion:z.number().int().nonnegative().max(2147483646),acknowledgedDuplicateRisk:z.literal(true)}).strict()
export const alertsOutboxRetryReceiptSchema=z.object({ok:z.literal(true),id:z.string().min(1),status:z.literal("pending"),resolutionVersion:z.number().int().min(1).max(2147483647)}).strict()
+128
View File
@@ -0,0 +1,128 @@
import { z } from "zod"
export const backupFrequencySchema = z.enum(["daily", "weekly", "monthly"])
export const backupFormatSchema = z.enum(["rsc", "backup"])
export const backupStorageProviderSchema = z.enum(["local", "s3"])
export const backupObjectStorageSchema = z.enum(["local", "s3", "both"])
export const backupLocalFileStatusSchema = z.enum(["available", "missing", "unknown", "not-stored"])
export type BackupLocalFileStatus = z.infer<typeof backupLocalFileStatusSchema>
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>
export const backupStorageSettingsDtoSchema = z.object({
provider: backupStorageProviderSchema,
s3Endpoint: z.string(),
s3Region: z.string(),
s3Bucket: z.string(),
s3Prefix: z.string(),
s3AccessKeyId: z.string(),
secretConfigured: z.boolean(),
s3ForcePathStyle: z.boolean(),
keepLocalCopy: z.boolean(),
lastTestAt: z.string().nullable().optional(),
lastTestError: z.string().nullable().optional(),
updatedAt: z.string().optional(),
})
export const putBackupStorageSettingsSchema = z.object({
provider: backupStorageProviderSchema.optional(),
s3Endpoint: z.string().optional(),
s3Region: z.string().optional(),
s3Bucket: z.string().optional(),
s3Prefix: z.string().optional(),
s3AccessKeyId: z.string().optional(),
s3SecretAccessKey: z.string().optional(),
s3ForcePathStyle: z.boolean().optional(),
keepLocalCopy: z.boolean().optional(),
})
export const backupItemDtoSchema = z.object({
id: z.string().min(1),
serverId: z.number().int().positive().safe().nullable(),
serverName: z.string(),
filename: z.string().min(1),
sizeBytes: z.number().int().nonnegative().safe(),
createdAt: z.string().datetime({ offset: true }),
kind: z.enum(["manual", "auto"]),
notes: z.string().optional(),
storage: backupObjectStorageSchema,
s3Key: z.string().nullable().optional(),
uploadError: z.string().nullable().optional(),
localFileStatus: backupLocalFileStatusSchema.optional(),
})
export type BackupStorageSettingsDto = z.infer<typeof backupStorageSettingsDtoSchema>
export type PutBackupStorageSettings = z.infer<typeof putBackupStorageSettingsSchema>
export type BackupItemDto = z.infer<typeof backupItemDtoSchema>
const backupServerIdSchema = z.union([
z.string().regex(/^[1-9][0-9]*$/).refine(value => Number.isSafeInteger(Number(value))),
z.number().int().positive().safe(),
])
export const createBackupRequestSchema = z.object({
requestId: z.string().uuid().optional(),
serverIds: z.array(backupServerIdSchema).min(1).refine(ids => new Set(ids.map(String)).size === ids.length, "Дубли серверов"),
notes: z.string().trim().max(500).optional(),
})
export const backupJobStatusSchema = z.enum(["queued", "running", "done", "failed"])
const backupProgressSchema = z.object({
status: backupJobStatusSchema,
total: z.number().int().positive().safe(),
completed: z.number().int().nonnegative().safe(),
})
export const createBackupJobResponseSchema = backupProgressSchema.extend({ jobId: z.string().min(1), serverIds: z.array(z.string().min(1)).min(1) }).superRefine((job, ctx) => {
if (job.serverIds.length !== job.total || new Set(job.serverIds).size !== job.total) ctx.addIssue({ code: "custom", message: "Цели не соответствуют числу устройств" })
if (job.completed > job.total || (job.status === "done" && job.completed !== job.total)) ctx.addIssue({ code: "custom", message: "Некорректный прогресс задачи" })
})
export const backupJobResponseSchema = backupProgressSchema.extend({
id: z.string().min(1),
serverIds: z.array(z.string().regex(/^[1-9][0-9]*$/)).min(1),
notes: z.string().optional(),
requestedAt: z.string().datetime({ offset: true }),
startedAt: z.string().datetime({ offset: true }).optional(),
finishedAt: z.string().datetime({ offset: true }).optional(),
created: z.array(backupItemDtoSchema),
failures: z.array(z.object({ serverId: z.string().min(1), error: z.string().min(1) })),
}).superRefine((job, ctx) => {
if (job.completed > job.total || (job.status === "done" && job.completed !== job.total)) ctx.addIssue({ code: "custom", message: "Некорректный прогресс задачи" })
if (["done", "failed"].includes(job.status) && !job.finishedAt) ctx.addIssue({ code: "custom", message: "Завершение не подтверждено временем" })
if (job.created.some(item => item.serverId === null || !job.serverIds.includes(String(item.serverId))) || job.failures.some(failure => failure.serverId !== "job" && !job.serverIds.includes(failure.serverId))) ctx.addIssue({ code: "custom", message: "Результат относится к другому устройству" })
const deviceFailures = job.failures.filter(failure => failure.serverId !== "job")
if (job.created.length + deviceFailures.length !== job.completed) ctx.addIssue({ code: "custom", message: "Результаты не соответствуют прогрессу" })
if (new Set(job.created.map(item => item.id)).size !== job.created.length) ctx.addIssue({ code: "custom", message: "Дубли резервных копий" })
})
export const backupsListResponseSchema = z.array(backupItemDtoSchema).refine(items => new Set(items.map(item => item.id)).size === items.length, "Дубли резервных копий")
export const restoreBackupResponseSchema = z.object({ backupId: z.string().min(1), serverId: z.number().int().positive().safe(), filename: z.string().min(1), serverName: z.string().min(1) })
export const syncBackupsResponseSchema = z.object({ imported: z.number().int().nonnegative().safe(), skipped: z.number().int().nonnegative().safe() })
export type CreateBackupJobResponse = z.infer<typeof createBackupJobResponseSchema>
export type BackupJobStatusResponse = z.infer<typeof backupJobResponseSchema>
export type CreateBackupRequest = z.infer<typeof createBackupRequestSchema>
+162
View File
@@ -0,0 +1,162 @@
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(),
/** Имя CA, которым серт подписан на устройстве (read-only поле `ca` RouterOS). */
signedByCertName: 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().regex(/^[1-9][0-9]*$/).refine(value => Number.isSafeInteger(Number(value))), z.number().int().positive().max(Number.MAX_SAFE_INTEGER)]),
requestId: z.string().uuid().optional(),
certName: z.string().trim().min(1).max(64),
domainNames: z.array(z.string().trim().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",
"dns_a_sync",
"import",
"cleanup",
"done",
"failed",
])
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>
export const certificateObservedSchema = certificateDtoSchema.extend({
rosId: z.string().min(1).nullable(),
validFrom: z.string().datetime({ offset: true }).nullable(),
validUntil: z.string().datetime({ offset: true }).nullable(),
rawValidFrom: z.string().nullable(), rawValidUntil: z.string().nullable(),
daysLeft: z.number().int().nullable(), keySize: z.number().int().positive().nullable(),
trusted: z.boolean().nullable(), status: z.enum(["valid", "expired", "revoked", "unknown"]),
}).superRefine((value,ctx) => {
if ((value.validUntil === null) !== (value.daysLeft === null)) ctx.addIssue({code:"custom",message:"Срок не соответствует дате"})
if (value.status === "valid" && (!value.validFrom || !value.validUntil || value.daysLeft === null || value.daysLeft < 0 || Date.parse(value.validFrom)>Date.parse(value.validUntil))) ctx.addIssue({code:"custom",message:"Действительность не подтверждена датами"})
})
export const certificateServerStateSchema = z.object({
serverId: z.string().regex(/^[1-9]\d*$/).refine(value => Number.isSafeInteger(Number(value))),
serverName: z.string(), collectedAt: z.string().datetime({ offset: true }),
certificates: z.array(certificateObservedSchema),
error: z.string().min(1).nullable(),
}).superRefine((value,ctx) => {
if (value.error && value.certificates.length) ctx.addIssue({code:"custom",message:"Ошибка загрузки содержит сертификаты"})
if (new Set(value.certificates.map(row=>row.id)).size !== value.certificates.length) ctx.addIssue({code:"custom",message:"Дубли сертификатов"})
if (value.certificates.some(row=>row.serverId !== value.serverId || !row.id.startsWith(value.serverId + ":"))) ctx.addIssue({code:"custom",message:"Чужой контекст сертификата"})
})
export const certificatesStateSchema = z.object({
collectedAt: z.string().datetime({ offset: true }), servers: z.array(certificateServerStateSchema),
}).superRefine((value,ctx) => {
if(new Set(value.servers.map(server=>server.serverId)).size!==value.servers.length)ctx.addIssue({code:"custom",message:"Дубли устройств"})
})
export type CertificateObserved = z.infer<typeof certificateObservedSchema>
export type CertificateServerState = z.infer<typeof certificateServerStateSchema>
export type CertificatesState = z.infer<typeof certificatesStateSchema>
+39
View File
@@ -0,0 +1,39 @@
import { z } from "zod"
const serverId = z.number().int().positive().safe()
const time = z.string().datetime({ offset: true })
export const containerStateRowSchema = z.object({
id: z.string().min(1), rosId: z.string().min(1), name: z.string().min(1),
serverId: z.string().regex(/^[1-9]\d*$/),
image: z.string(), tag: z.string(), status: z.enum(["running", "stopped", "error", "unknown"]),
envs: z.array(z.object({ key: z.string().min(1), value: z.string() })),
mounts: z.array(z.object({ dst: z.string().min(1), src: z.string().optional() })),
interfaces: z.array(z.string().min(1)), cmd: z.string().optional(),
startOnBoot: z.boolean().nullable(), comment: z.string(), uptime: z.string().optional(),
cpu: z.number().finite().min(0).max(100).optional(), memMb: z.number().finite().nonnegative().optional(),
})
export const containerServerStateSchema = z.object({
serverId, collectedAt: time, availability: z.enum(["available", "unsupported", "error"]),
containers: z.array(containerStateRowSchema),
failures: z.array(z.object({ resource: z.enum(["containers", "envs", "mounts"]), error: z.string().min(1) })),
}).superRefine((value, ctx) => {
const ids = new Set<string>()
for (const row of value.containers) {
if (row.serverId !== String(value.serverId) || row.id !== value.serverId + "-" + row.rosId || ids.has(row.rosId)) ctx.addIssue({ code: "custom", message: "Контейнеры не соответствуют устройству или содержат дубли" })
ids.add(row.rosId)
}
const resources = value.failures.map(f => f.resource)
if (new Set(resources).size !== resources.length) ctx.addIssue({ code: "custom", message: "Дубли ошибок ресурсов" })
if (value.availability !== "available" && value.containers.length) ctx.addIssue({ code: "custom", message: "Неуспешная загрузка содержит контейнеры" })
if (value.availability === "error" && !resources.includes("containers") || value.availability === "available" && resources.includes("containers") || value.availability === "unsupported" && resources.length) ctx.addIssue({ code: "custom", message: "Состояние не соответствует ошибкам" })
})
export const containersStateSchema = z.object({
collectedAt: time, servers: z.array(containerServerStateSchema),
}).superRefine((value, ctx) => {
if (new Set(value.servers.map(s => s.serverId)).size !== value.servers.length) ctx.addIssue({ code: "custom", message: "Дубли устройств" })
})
export type ContainerStateRow = z.infer<typeof containerStateRowSchema>
export type ContainerServerState = z.infer<typeof containerServerStateSchema>
export type ContainersState = z.infer<typeof containersStateSchema>
export const containerMutationAckSchema = z.object({ ok: z.literal(true) })
+59
View File
@@ -0,0 +1,59 @@
import { z } from "zod"
export const eventLevelSchema = z.enum(["critical", "warning", "info"])
export const eventSourceModuleSchema = z.enum([
"scheduler",
"backups",
"filters",
"traffic",
"alerts",
"certificates",
"system",
])
export const eventItemSchema = z.object({
id: z.string().min(1),
createdAt: z.string(),
level: eventLevelSchema,
eventType: z.string().min(1),
sourceModule: eventSourceModuleSchema,
title: z.string().min(1),
message: z.string().min(1),
entityType: z.union([z.string(), z.null()]),
entityId: z.union([z.string(), z.null()]),
payload: z.record(z.string(), z.unknown()).default({}),
})
export const eventListSchema = z.array(eventItemSchema)
export const listEventsQuerySchema = z.object({
limit: z.coerce.number().int().min(1).max(100).default(20),
level: eventLevelSchema.optional(),
sourceModule: eventSourceModuleSchema.optional(),
from: z.string().optional(),
to: z.string().optional(),
})
export const appendEventSchema = z.object({
level: eventLevelSchema,
eventType: z.string().min(1),
sourceModule: eventSourceModuleSchema,
title: z.string().min(1),
message: z.string().min(1),
entityType: z.string().optional(),
entityId: z.string().optional(),
payload: z.record(z.string(), z.unknown()).optional(),
createdAt: z.string().optional(),
})
export const appendEventsSchema = z.object({
events: z.array(appendEventSchema).min(1).max(200),
})
export type EventLevel = z.infer<typeof eventLevelSchema>
export type EventSourceModule = z.infer<typeof eventSourceModuleSchema>
export type EventItem = z.infer<typeof eventItemSchema>
export type ListEventsQuery = z.infer<typeof listEventsQuerySchema>
export type AppendEventBody = z.infer<typeof appendEventSchema>
export type AppendEventsBody = z.infer<typeof appendEventsSchema>
+19
View File
@@ -0,0 +1,19 @@
import {z} from "zod"
export function normalizeEvoBgpBaseUrl(raw:string):string {
const value=raw.trim()
if(!value)return ""
let url:URL
try{url=new URL(/^[a-z][a-z0-9+.-]*:\/\//i.test(value)?value:"https://"+value)}catch{throw new Error("Укажите корректный URL EvoBGP")}
if(!["http:","https:"].includes(url.protocol)||!url.hostname||url.username||url.password||url.pathname!=="/"||url.search||url.hash)throw new Error("Укажите HTTP(S) URL EvoBGP без пути, ключа, query и fragment")
return url.origin
}
export function normalizeEvoBgpApiKey(raw:string):string {return raw.trim().replace(/^Bearer\s+/i,"").trim()}
const baseInput=z.string().transform((raw,ctx)=>{try{return normalizeEvoBgpBaseUrl(raw)}catch(error){ctx.addIssue({code:"custom",message:error instanceof Error?error.message:"Неверный URL EvoBGP"});return z.NEVER}})
const keyInput=z.string().refine(raw=>!/[\r\n]/.test(raw),"API-ключ должен быть одной строкой").transform(normalizeEvoBgpApiKey)
export const evoBgpSettingsSchema=z.object({baseUrl:z.string().refine(value=>{try{return value===normalizeEvoBgpBaseUrl(value)}catch{return false}},"Некорректный сохранённый URL EvoBGP"),enabled:z.boolean(),secretConfigured:z.boolean()}).strict()
export const evoBgpSettingsPatchSchema=z.object({baseUrl:baseInput.optional(),enabled:z.boolean().optional(),apiKey:z.union([keyInput,z.null()]).optional()}).strict().refine(patch=>Object.values(patch).some(value=>value!==undefined),"Укажите изменённые настройки EvoBGP")
export const evoBgpTestDraftSchema=z.object({baseUrl:baseInput.optional(),apiKey:keyInput.optional()}).strict()
export const evoBgpSaveReceiptSchema=evoBgpSettingsSchema.extend({ok:z.literal(true),savedFields:z.array(z.enum(["baseUrl","enabled","apiKey"])).min(1).refine(fields=>new Set(fields).size===fields.length)})
export type EvoBgpSettingsDto=z.infer<typeof evoBgpSettingsSchema>
export type EvoBgpSavePayload=z.input<typeof evoBgpSettingsPatchSchema>
export type EvoBgpTestDraft=z.input<typeof evoBgpTestDraftSchema>
+51
View File
@@ -0,0 +1,51 @@
import { z } from "zod"
export const geoipSettingsDtoSchema = z.object({
enabled: z.boolean(),
updateIntervalSec: z.number().int().positive(),
lastCheckAt: z.string().nullable(),
lastSuccessAt: z.string().nullable(),
lastError: z.string().nullable(),
countryBuildAt: z.string().nullable(),
asnBuildAt: z.string().nullable(),
updatedAt: z.string(),
})
export const geoipSettingsPatchSchema = z.object({
enabled: z.boolean().optional(),
updateIntervalSec: z
.number()
.int()
.min(6 * 3600)
.max(30 * 86400)
.optional(),
})
export const geoipStatusDtoSchema = z.object({
ready: z.boolean(),
countryLoaded: z.boolean(),
asnLoaded: z.boolean(),
countryFile: z.string(),
asnFile: z.string(),
dir: z.string(),
running: z.boolean(),
settings: geoipSettingsDtoSchema,
})
export const geoipUpdateSnapshotDtoSchema = z.object({
v: z.number(),
job: z.literal("geoip_update"),
sampledAt: z.string(),
skipped: z.boolean().optional(),
fatalError: z.string().optional(),
checked: z.number().int(),
downloaded: z.number().int(),
skippedUnchanged: z.number().int(),
bytes: z.number().int().nonnegative(),
errors: z.array(z.string()),
})
export type GeoipSettingsDto = z.infer<typeof geoipSettingsDtoSchema>
export type GeoipSettingsPatch = z.infer<typeof geoipSettingsPatchSchema>
export type GeoipStatusDto = z.infer<typeof geoipStatusDtoSchema>
export type GeoipUpdateSnapshotDto = z.infer<typeof geoipUpdateSnapshotDtoSchema>
+21
View File
@@ -0,0 +1,21 @@
export * from "./servers.js"
export * from "./mcp.js"
export * from "./logs.js"
export * from "./alerts.js"
export * from "./events.js"
export * from "./certificates.js"
export * from "./backups.js"
export * from "./wireguard.js"
export * from "./ipsec.js"
export * from "./users.js"
export * from "./traffic-flow.js"
export * from "./geoip.js"
export * from "./statistics.js"
export * from "./ospf-optimizer.js"
export * from "./evobgp.js"
export * from "./uptime-speed.js"
export * from "./uptime-probes.js"
+247
View File
@@ -0,0 +1,247 @@
import { z } from "zod"
export const ipsecAuthMethodSchema = z.enum(["certificate", "pre-shared-key"])
/**
* Минимальная длина пароля .p12. Ограничение RouterOS REST:
* `Failure: If used, passphrase must be at least 8 chars long!` (см. /certificate/export-certificate).
*/
export const IPSEC_MIN_PASSPHRASE = 8
/** Клиент IKEv2 — /ip/ipsec/identity (+ опциональный персональный mode-config). */
export const ipsecClientDtoSchema = z.object({
id: z.string().min(1),
rosId: z.string().min(1),
serverId: z.string().min(1),
serverName: z.string(),
/** Управляемое имя клиента (из managed-комментария identity). */
name: z.string(),
authMethod: ipsecAuthMethodSchema,
/** cert — клиент по сертификату (может быть без персональной identity); psk — pre-shared-key identity. */
kind: z.enum(["cert", "psk"]).default("cert"),
certificateName: z.string().optional(),
/** Имя клиентского сертификата (роль client). Для psk — undefined. */
certName: z.string().optional(),
/** CA, которым подписан клиентский сертификат (read-only поле `ca`). */
signedBy: z.string().optional(),
commonName: z.string().optional(),
remoteId: z.string().optional(),
/** Статический IP клиента (персональный mode-config), undefined — из общего пула. */
staticIp: z.string().optional(),
modeConfigName: z.string().optional(),
peerName: z.string().optional(),
online: z.boolean(),
/** One native session ID matches several clients; online/address are not confirmed. */
sessionAmbiguous: z.boolean().optional(),
activeAddress: z.string().optional(),
activeSince: z.string().optional(),
disabled: z.boolean(),
comment: z.string().optional(),
/** Создан менеджером (managed-комментарий). */
managed: z.boolean(),
}).refine(c => !c.sessionAmbiguous || (!c.online && !c.activeAddress && !c.activeSince), {
message: "Неоднозначная сессия IPsec не может подтверждать online или активный адрес",
})
/** Слушатель IKEv2 — /ip/ipsec/peer (passive). */
export const ipsecPeerDtoSchema = z.object({
id: z.string().min(1),
rosId: z.string().min(1),
serverId: z.string().min(1),
serverName: z.string(),
name: z.string(),
address: z.string().optional(),
exchangeMode: z.string().optional(),
passive: z.boolean().optional(),
certificate: z.string().optional(),
profile: z.string().optional(),
disabled: z.boolean(),
comment: z.string().optional(),
managed: z.boolean(),
})
export const ipsecModeConfigDtoSchema = z.object({
id: z.string().min(1),
rosId: z.string().min(1),
serverId: z.string().min(1),
name: z.string(),
/** Общий пул (shared) или персональный статический адрес (per-user). */
addressPool: z.string().optional(),
address: z.string().optional(),
splitDns: z.string().optional(),
staticDns: z.string().optional(),
comment: z.string().optional(),
managed: z.boolean(),
})
export const ipsecPoolDtoSchema = z.object({
id: z.string().min(1),
rosId: z.string().min(1),
serverId: z.string().min(1),
name: z.string(),
ranges: z.string(),
comment: z.string().optional(),
managed: z.boolean(),
})
export const ipsecCertInfoDtoSchema = z.object({
name: z.string(),
commonName: z.string().optional(),
keySize: z.string().optional(),
fingerprint: z.string().optional(),
expiresAt: z.string().optional(),
trusted: z.boolean().optional(),
hasPrivateKey: z.boolean().optional(),
role: z.enum(["ca", "server", "client", "other"]).optional(),
/** Имя CA, которым серт подписан на устройстве (read-only поле `ca` RouterOS). */
signedBy: z.string().optional(),
managed: z.boolean(),
})
/** Сводка IKEv2-сервера на одном роутере. */
export const ipsecServerSummaryDtoSchema = z.object({
serverId: z.string().min(1),
serverName: z.string(),
serverCountry: z.string().optional(),
/** Managed-набор (CA + серверный серт + peer + mode-config) инициализирован. */
initialized: z.boolean(),
/** Существующий IKEv2-сервер пригоден: есть peer + CA + серверный серт. */
ike2Ready: z.boolean(),
serverEndpoint: z.string().optional(),
/** Primary managed peer (обратная совместимость); для полного списка — `peers`. */
peer: ipsecPeerDtoSchema.optional(),
/** Все peers роутера (managed + существующие). */
peers: z.array(ipsecPeerDtoSchema).default([]),
pool: ipsecPoolDtoSchema.optional(),
sharedModeConfig: ipsecModeConfigDtoSchema.optional(),
caCert: ipsecCertInfoDtoSchema.optional(),
serverCert: ipsecCertInfoDtoSchema.optional(),
natRuleManaged: z.boolean(),
clientsTotal: z.number().int().nonnegative(),
clientsOnline: z.number().int().nonnegative(),
clientsSessionAmbiguous: z.number().int().nonnegative().optional(),
/** Все сертификаты роутера (managed + существующие). */
certs: z.array(ipsecCertInfoDtoSchema).optional(),
})
export const ipsecPeerPatchSchema = z.object({
name: z.string().min(1).max(64).optional(),
address: z.string().optional(),
exchangeMode: z.string().optional(),
passive: z.boolean().optional(),
certificate: z.string().optional(),
profile: z.string().optional(),
disabled: z.boolean().optional(),
})
export const ipsecCertDeleteRequestSchema = z.object({
name: z.string().min(1),
})
export const ipsecListResponseSchema = z.object({
servers: z.array(ipsecServerSummaryDtoSchema),
clients: z.array(ipsecClientDtoSchema),
failures: z
.array(
z.object({
serverId: z.string(),
serverName: z.string().optional(),
error: z.string(),
}),
)
.optional(),
})
export const ipsecInitRequestSchema = z.object({
serverId: z.union([z.string(), z.number()]),
/** Домен или IP для CN/SAN серверного сертификата (к нему подключаются клиенты). */
serverEndpoint: z.string().min(1),
/** Подсеть пула клиентов; из неё pool ranges .2–.254. */
poolCidr: z.string().regex(/^[0-9a-fA-F.:/]+$/).default("10.77.0.0/24"),
dns: z.string().optional(),
caDaysValid: z.number().int().positive().default(3650),
serverDaysValid: z.number().int().positive().default(3650),
clientDaysValid: z.number().int().positive().default(1825),
/** Managed srcnat masquerade, чтобы у клиентов был интернет. */
createNatRule: z.boolean().default(true),
})
export const ipsecUserCreateRequestSchema = z.object({
serverId: z.union([z.string(), z.number()]),
/** Отображаемое имя клиента; из него slug для серта/CN. */
name: z.string().min(1).max(64),
/** Peer (сервер), к которому привязать identity; без — primary managed peer выбранного сервера. */
peerName: z.string().optional(),
authMethod: ipsecAuthMethodSchema.default("certificate"),
psk: z.string().min(8).optional(),
remoteId: z.string().optional(),
/** Конкретный IP клиента; без — выдаётся из пула. */
staticIp: z.string().optional(),
/** Пароль на экспортируемый .p12 (RouterOS требует ≥ IPSEC_MIN_PASSPHRASE). */
passphrase: z.string().min(IPSEC_MIN_PASSPHRASE).optional(),
daysValid: z.number().int().positive().optional(),
})
export const ipsecUserPatchSchema = z.object({
name: z.string().min(1).max(64).optional(),
/** null — вернуть выдачу из пула. */
staticIp: z.string().nullable().optional(),
psk: z.string().min(8).optional(),
remoteId: z.string().optional(),
disabled: z.boolean().optional(),
})
export const ipsecExistingCertificateExportIntentSchema = z.object({
privateKeyExport: z.literal(true),
caPrivateKeyExport: z.boolean().default(false),
}).strict()
export const ipsecExistingCertificateExportRequestKeySchema = z.string().uuid()
export const ipsecExistingCertificateExportBodySchema = ipsecExistingCertificateExportIntentSchema.extend({
passphrase: z.string().min(IPSEC_MIN_PASSPHRASE).max(16384),
}).strict()
export const ipsecCertExportRequestSchema = ipsecExistingCertificateExportBodySchema.extend({
serverId: z.union([z.string(), z.number()]),
clientId: z.string().min(1).max(256),
}).strict()
/** Экспорт .p12 существующего клиентского сертификата по имени (client1/anakondra и т.п.). */
export const ipsecCertExportByNameRequestSchema = ipsecExistingCertificateExportBodySchema.extend({
name: z.string().min(1).max(256),
}).strict()
/** Бандл для авторизации клиента: .p12 (+ strongSwan .sswan + инструкция). */
export const ipsecCertBundleSchema = z.object({
user: z.string(),
serverEndpoint: z.string().optional(),
filename: z.string(),
contentB64: z.string(),
mime: z.string().default("application/x-pkcs12"),
passphrase: z.string().optional(),
sswanFilename: z.string().optional(),
sswanContent: z.string().optional(),
instructions: z.string().optional(),
})
export const ipsecUserCreatedSchema = z.object({
client: ipsecClientDtoSchema,
/** Одноразовый бандл сертификата (для cert-клиентов). */
bundle: ipsecCertBundleSchema.optional(),
})
export type IpsecAuthMethod = z.infer<typeof ipsecAuthMethodSchema>
export type IpsecClientDto = z.infer<typeof ipsecClientDtoSchema>
export type IpsecPeerDto = z.infer<typeof ipsecPeerDtoSchema>
export type IpsecPeerPatch = z.infer<typeof ipsecPeerPatchSchema>
export type IpsecCertDeleteRequest = z.infer<typeof ipsecCertDeleteRequestSchema>
export type IpsecModeConfigDto = z.infer<typeof ipsecModeConfigDtoSchema>
export type IpsecPoolDto = z.infer<typeof ipsecPoolDtoSchema>
export type IpsecCertInfoDto = z.infer<typeof ipsecCertInfoDtoSchema>
export type IpsecServerSummaryDto = z.infer<typeof ipsecServerSummaryDtoSchema>
export type IpsecListResponse = z.infer<typeof ipsecListResponseSchema>
export type IpsecInitRequest = z.infer<typeof ipsecInitRequestSchema>
export type IpsecUserCreateRequest = z.infer<typeof ipsecUserCreateRequestSchema>
export type IpsecUserPatch = z.infer<typeof ipsecUserPatchSchema>
export type IpsecCertExportRequest = z.infer<typeof ipsecCertExportRequestSchema>
export type IpsecCertExportByNameRequest = z.infer<typeof ipsecCertExportByNameRequestSchema>
export type IpsecCertBundle = z.infer<typeof ipsecCertBundleSchema>
export type IpsecUserCreated = z.infer<typeof ipsecUserCreatedSchema>
+40
View File
@@ -0,0 +1,40 @@
import { z } from "zod"
export const logLevels = ["critical", "error", "warning", "info", "debug", "unknown"] as const
export const logLevelSchema = z.enum(logLevels)
export type LogLevel = z.infer<typeof logLevelSchema>
const id = z.string().regex(/^\d+$/)
const instant = z.string().datetime({ offset: true })
export const logEntrySchema = z.object({
id, serverId: z.number().int(), serverName: z.string(), host: z.string(),
routerId: z.string(), rawTime: z.string(), eventAt: instant.nullable(), receivedAt: instant,
effectiveAt: instant, timeKind: z.enum(["router", "receipt"]), topics: z.array(z.string()),
level: logLevelSchema, message: z.string(), generation: z.number().int(),
})
export type LogEntry = z.infer<typeof logEntrySchema>
export const logFiltersSchema = z.object({
serverId: z.coerce.number().int().positive().optional(), from: instant.default(() => new Date(Date.now() - 900000).toISOString()), to: instant.optional(),
levels: z.string().max(100).default("").refine(v => !v || v.split(",").every(x => (logLevels as readonly string[]).includes(x))),
topics: z.string().max(500).default(""), q: z.string().max(500).default(""),
preset: z.enum(["", "login-failure", "interfaces", "routing", "firewall"]).default(""),
}).refine(v => !v.from || !v.to || Date.parse(v.from) <= Date.parse(v.to), "Некорректный период")
export type LogFilters = z.infer<typeof logFiltersSchema>
export const logPageQuerySchema = logFiltersSchema.safeExtend({
cursor: z.string().max(200).optional(), watermark: id.optional(),
limit: z.coerce.number().int().min(1).max(200).default(200),
})
export const logStateSchema = z.object({ serverId: z.number().int(), serverName: z.string(), host: z.string(),
lastCollectedAt: instant.nullable(), lastError: z.string().nullable(), gapAt: instant.nullable(),
nextPollAt: instant.nullable(), failures: z.number().int(), generation: z.number().int() })
export const logPageSchema = z.object({ entries: z.array(logEntrySchema), nextCursor: z.string().nullable(), watermark: id })
export type LogPage = z.infer<typeof logPageSchema>
export const logSettingsSchema = z.object({ enabled: z.boolean(), intervalSec: z.number().int().min(5).max(3600), retentionDays: z.number().int().min(1).max(365) })
export type LogSettings = z.infer<typeof logSettingsSchema>
export const logSettingsPatchSchema = logSettingsSchema.partial().strict().refine(v => Object.keys(v).length > 0)
export const logSummarySchema = z.object({ total: z.number(), levels: z.record(logLevelSchema, z.number()),
buckets: z.array(z.object({ at: instant, critical: z.number(), error: z.number(), warning: z.number(), info: z.number(), debug: z.number(), unknown: z.number() })),
settings: logSettingsSchema, states: z.array(logStateSchema) })
export type LogSummary = z.infer<typeof logSummarySchema>
export const logStreamSchema = z.object({ entries: z.array(logEntrySchema), cursor: id, state: logStateSchema.nullable(), enabled: z.boolean() })
export type LogStream = z.infer<typeof logStreamSchema>
export type LogStreamReset = { reason: "database-maintenance" }
@@ -0,0 +1,8 @@
import assert from "node:assert/strict"
import { routeAiProfileSchema } from "./mcp-routing.js"
const raw = { name: "context", serverId: 1, community: "65000:10", family: "ipv4", table: "policy-only", candidates: [{ id: "a", wanInterface: "wan", downstreamInterface: "down", jumpHostId: 2, exitNodeId: 3, gateway: "192.0.2.1", gatewayRef: { resource: "ipv4_route", id: "*a" } }] }
assert.equal(routeAiProfileSchema.parse(raw).probeVrf, "main")
assert.deepEqual(routeAiProfileSchema.parse({ ...raw, probeVrf: "tenant-vrf", bgpContext: { connectionId: "*c", instance: "tenant-bgp", inputChain: "tenant-in" } }).bgpContext, { connectionId: "*c", instance: "tenant-bgp", inputChain: "tenant-in", vrf: "main" })
assert.equal(routeAiProfileSchema.parse({ ...raw, probeVrf: "probe-vrf", bgpContext: { connectionId: "*c", instance: "tenant-bgp", inputChain: "tenant-in", vrf: "bgp-vrf" } }).bgpContext?.vrf, "bgp-vrf")
for (const bgpContext of [{ connectionId: "name", instance: "i", inputChain: "in" }, { connectionId: "*c", instance: "i", inputChain: "in", direction: "output" }, { connectionId: "*c", instance: "i\n", inputChain: "in\u0000" }]) assert.equal(routeAiProfileSchema.safeParse({ ...raw, bgpContext }).success, false)
console.log("Route AI profile: installation table/probe VRF/input binding remain separate; strict native identity and control-character rejection")
+47
View File
@@ -0,0 +1,47 @@
import { z } from "zod"
const sid = z.number().int().positive().max(Number.MAX_SAFE_INTEGER)
const name = z.string().trim().min(1).max(100).refine(value => !/[\u0000-\u001f\u007f-\u009f\u2028\u2029]/.test(value))
export const routeAiWeightsSchema = z.object({ ping: z.number().min(0).max(100).default(60), speed: z.number().min(0).max(100).default(30), loss: z.number().min(0).max(100).default(10) }).strict().refine(weights => Math.abs(weights.ping + weights.speed + weights.loss - 100) < 0.0001, "Сумма весов должна быть 100")
export const routeAiCandidateSchema = z.object({
id: name, wanInterface: name, jumpHostId: sid, exitNodeId: sid,
gateway: name, gatewayRef: z.object({ resource: z.enum(["gre", "ipv4_route", "ipv6_route"]), id: name }).strict(),
downstreamInterface: name,
wanRemoteInterface: name.optional(), downstreamRemoteInterface: name.optional(),
controlProbe: z.object({ targetInterface: name, targetAddress: name.optional(), expectedHops: z.array(z.object({ serverId: sid, interface: name, address: name.optional() }).strict()).min(1).max(8), sourceAddress: name.optional() }).strict().optional(),
}).strict()
export const routeAiProfileSchema = z.object({
name, serverId: sid, community: z.string().regex(/^\d{1,10}:\d{1,10}$/), family: z.enum(["ipv4", "ipv6"]), table: name.default("main"),
// A route installation table is not a diagnostic VRF. Never derive one from the other.
probeVrf: name.default("main"),
// BGP transport VRF is independent of both installation table and probe VRF.
bgpContext: z.object({ connectionId: z.string().regex(/^\*[A-Za-z0-9]+$/).max(200), instance: name, inputChain: name, vrf: name.default("main") }).strict().optional(),
candidates: z.array(routeAiCandidateSchema).min(1).max(100).refine(candidates => new Set(candidates.map(candidate => candidate.id)).size === candidates.length, "Повторяющиеся пути"),
pinnedCandidateId: name.nullable().default(null), excludedCandidateIds: z.array(name).max(100).default([]),
weights: routeAiWeightsSchema.default(() => ({ ping: 60, speed: 30, loss: 10 })),
maxPingMs: z.number().positive().nullable().default(null), maxLossPct: z.number().min(0).max(100).nullable().default(null), minBandwidthMbps: z.number().nonnegative().nullable().default(null),
pingFreshnessSec: z.number().int().min(10).max(3600).default(300), bandwidthFreshnessSec: z.number().int().min(60).max(86400).default(3600),
switchThreshold: z.number().min(0).max(100).default(15), hysteresis: z.number().min(0).max(100).default(10),
confirmationSamples: z.number().int().min(1).max(10).default(3), cooldownSec: z.number().int().min(60).max(86400).default(600), maxSwitchesPerHour: z.number().int().min(1).max(10).default(3),
autoRecovery: z.boolean().default(false),
}).strict().superRefine((profile, context) => {
const ids = new Set(profile.candidates.map(candidate => candidate.id))
if (profile.autoRecovery && profile.candidates.some(candidate => !candidate.controlProbe)) context.addIssue({ code: "custom", message: "Автовосстановление требует контрольных проверок всех путей" })
for (const candidate of profile.candidates) if (candidate.controlProbe?.expectedHops.some(hop => ![profile.serverId, candidate.jumpHostId, candidate.exitNodeId].includes(hop.serverId))) context.addIssue({ code: "custom", message: "Контрольный hop должен принадлежать устройству выбранного пути" })
if (profile.pinnedCandidateId && !ids.has(profile.pinnedCandidateId)) context.addIssue({ code: "custom", message: "Закреплён неизвестный путь" })
if (profile.excludedCandidateIds.some(id => !ids.has(id))) context.addIssue({ code: "custom", message: "Исключён неизвестный путь" })
})
export const routeMeasurementSchema = z.object({
srcServerId: sid, dstServerId: sid, srcInterface: name, dstInterface: name, family: z.enum(["ipv4", "ipv6"]), vrf: name.default("main"),
dstAddress: z.union([z.ipv4(), z.ipv6()]).optional(),
bandwidth: z.boolean().default(false), durationSec: z.number().int().min(3).max(30).default(5), maxMbps: z.number().positive().max(10000).optional(),
}).strict().refine(input => !input.bandwidth || input.maxMbps !== undefined, "Bandwidth-тест требует ограничения скорости")
export type RouteAiProfile = z.infer<typeof routeAiProfileSchema>
export interface RouteObservation {
id: string; srcServerId: number; dstServerId: number; srcInterface: string; dstInterface: string;
family: "ipv4" | "ipv6"; table: string; pingMs: number | null; lossPct: number | null; dlMbps: number | null; ulMbps: number | null;
pingAt: string | null; bandwidthAt: string | null; contextVerified: boolean;
/** False for legacy/tampered data or measurements of a previous endpoint epoch. */
sourceVerified?: boolean;
/** Publication ordering is distinct from independent ping/bandwidth times. */
recordedAt?: string;
}
+173
View File
@@ -0,0 +1,173 @@
import { z } from "zod"
const identifier = z.string().trim().min(1).max(200)
const serverId = z.number().int().positive().max(Number.MAX_SAFE_INTEGER)
const instant = z.string().datetime({ offset: true })
const uniqueStrings = z.array(identifier).max(1000).refine(values => new Set(values).size === values.length, "Повторяющиеся значения")
const serverIds = z.array(serverId).max(1000).refine(values => new Set(values).size === values.length, "Повторяющиеся серверы")
export const mcpModeSchema = z.enum(["observer", "approval", "autonomous"])
export const mcpLimitsSchema = z.object({
callsPerMinute: z.number().int().min(1).max(10000).default(60),
concurrentReads: z.number().int().min(1).max(100).default(4),
mutationsPerHour: z.number().int().min(1).max(10000).default(10),
serversPerPlan: z.number().int().min(1).max(1000).default(5),
objectsPerPlan: z.number().int().min(1).max(10000).default(100),
}).strict()
export const mcpScopeSchema = z.object({
serverIds,
operationIds: uniqueStrings,
allowGlobal: z.boolean().default(false),
}).strict()
export const mcpTemporaryGrantSchema = z.object({
id: identifier,
startsAt: instant,
expiresAt: instant,
scope: mcpScopeSchema,
}).strict().refine(grant => {
const duration = Date.parse(grant.expiresAt) - Date.parse(grant.startsAt)
return duration > 0 && duration <= 24 * 60 * 60 * 1000
}, "Временный доступ должен действовать не более 24 часов")
export const mcpConnectionPolicySchema = z.object({
id: identifier,
version: z.number().int().positive(),
status: z.enum(["active", "paused", "revoked"]),
expiresAt: instant,
mode: mcpModeSchema,
permissions: uniqueStrings,
scope: mcpScopeSchema,
dangerousPolicy: z.enum(["approval", "explicit-allow"]).default("approval"),
autonomousDangerousOperationIds: uniqueStrings.default([]),
limits: mcpLimitsSchema.default(() => mcpLimitsSchema.parse({})),
}).strict()
export const mcpTargetSchema = z.discriminatedUnion("scope", [
z.object({ scope: z.literal("servers"), serverIds: serverIds.refine(ids => ids.length > 0, "Выберите серверы") }).strict(),
z.object({ scope: z.literal("global") }).strict(),
z.object({ scope: z.literal("connection") }).strict(),
])
export const mcpObjectRefSchema = z.object({
kind: identifier,
id: identifier,
serverId: serverId.optional(),
}).strict()
export const mcpProtectedObjectSchema = mcpObjectRefSchema.extend({
protection: z.enum(["deny", "human-approval-required"]),
}).strict()
export type McpObjectRef = z.infer<typeof mcpObjectRefSchema>
export const mcpOperationDescriptorSchema = z.object({
id: identifier,
toolName: z.string().regex(/^mm_[a-z0-9_]+$/),
version: z.number().int().min(1).max(2147483647),
title: identifier,
section: identifier,
effect: z.enum(["read", "write", "diagnostic"]),
risk: z.enum(["ordinary", "dangerous"]),
scope: z.enum(["servers", "global", "connection"]),
requiredPermissions: uniqueStrings,
implementation: z.enum(["planned", "available"]),
annotations: z.object({
readOnlyHint: z.boolean(),
destructiveHint: z.boolean(),
idempotentHint: z.boolean(),
openWorldHint: z.boolean(),
}).strict(),
}).strict()
// This payload is hypothetical evidence for the human-only simulator, never authorization.
// Future executors must construct runtime evidence from trusted repositories and locks.
export const mcpPolicyPreviewRequestSchema = z.object({
operationId: identifier,
policy: mcpConnectionPolicySchema,
ownerPermissions: uniqueStrings,
target: mcpTargetSchema,
temporaryGrant: mcpTemporaryGrantSchema.optional(),
objectCount: z.number().int().nonnegative().max(1000000).default(0),
objects: z.array(mcpObjectRefSchema).max(1000).default([]),
protections: z.array(mcpProtectedObjectSchema).max(1000).default([]),
usage: z.object({
callsInMinute: z.number().int().nonnegative().default(0),
concurrentReads: z.number().int().nonnegative().default(0),
mutationsInHour: z.number().int().nonnegative().default(0),
}).strict().default(() => ({ callsInMinute: 0, concurrentReads: 0, mutationsInHour: 0 })),
auditAvailable: z.boolean().default(true),
}).strict()
export const mcpPolicyReasonCodeSchema = z.enum([
"UNKNOWN_OPERATION", "CONNECTION_INACTIVE", "CONNECTION_EXPIRED", "OPERATION_DENIED",
"OWNER_PERMISSION_MISSING", "CONNECTION_PERMISSION_MISSING", "TARGET_SCOPE_MISMATCH",
"SERVER_DENIED", "GLOBAL_DENIED", "GRANT_INACTIVE", "GRANT_SCOPE_DENIED",
"INVALID_OBJECT_SCOPE", "OBSERVER_WRITE_DENIED", "RATE_LIMITED", "BUDGET_EXCEEDED",
"PLAN_LIMIT_EXCEEDED", "PROTECTED_OBJECT", "AUDIT_UNAVAILABLE", "APPROVAL_REQUIRED",
])
export const mcpPolicyDecisionSchema = z.object({
decision: z.enum(["denied", "approval-required", "allowed"]),
reasons: z.array(z.object({ code: mcpPolicyReasonCodeSchema, message: z.string() }).strict()),
limits: mcpLimitsSchema,
}).strict()
export const mcpPolicyPreviewResponseSchema = z.object({
simulation: z.literal(true),
executionAvailable: z.literal(false),
evaluatedAt: instant,
policyVersion: z.number().int().positive(),
result: mcpPolicyDecisionSchema,
}).strict()
export const mcpCatalogResponseSchema = z.object({
stage: z.enum(["foundation", "runtime"]),
executionAvailable: z.boolean(),
operations: z.array(mcpOperationDescriptorSchema),
}).strict()
export type McpLimits = z.infer<typeof mcpLimitsSchema>
export type McpConnectionPolicy = z.infer<typeof mcpConnectionPolicySchema>
export type McpOperationDescriptor = z.infer<typeof mcpOperationDescriptorSchema>
export type McpPolicyPreviewRequest = z.infer<typeof mcpPolicyPreviewRequestSchema>
export type McpPolicyDecision = z.infer<typeof mcpPolicyDecisionSchema>
export type McpPolicyReasonCode = z.infer<typeof mcpPolicyReasonCodeSchema>
export const mcpConnectionCreateSchema = z.object({
name: z.string().trim().min(1).max(100),
expiresAt: instant,
mode: mcpModeSchema.default("observer"),
permissions: uniqueStrings,
scope: mcpScopeSchema,
dangerousPolicy: z.enum(["approval", "explicit-allow"]).default("approval"),
autonomousDangerousOperationIds: uniqueStrings.default([]),
limits: mcpLimitsSchema.default(() => mcpLimitsSchema.parse({})),
}).strict()
export const mcpConnectionSchema = z.object({
id: z.string().uuid(), name: z.string(), ownerId: z.string(),
policy: mcpConnectionPolicySchema, createdAt: instant, updatedAt: instant,
lastUsedAt: instant.nullable(), generationValid: z.boolean(),
ownerValidUntil: instant.optional(),
}).strict()
export const mcpConnectionsSchema = z.object({ connections: z.array(mcpConnectionSchema) }).strict()
export const mcpSettingsSchema = z.object({
enabled: z.boolean(), requestsRetentionDays: z.number().int().min(1).max(365),
auditRetentionDays: z.number().int().min(30).max(3650),
}).strict()
export const mcpSettingsPatchSchema = mcpSettingsSchema.partial().strict().refine(value => Object.keys(value).length > 0)
export const mcpPageQuerySchema = z.object({
limit: z.coerce.number().int().min(1).max(100).default(50),
cursor: z.union([z.string().uuid(), z.string().regex(/^[1-9]\d{0,18}$/)]).optional(),
connectionId: z.string().uuid().optional(),
status: z.string().max(50).optional(),
tool: z.string().max(200).optional(),
userId: z.string().trim().min(1).max(200).optional(),
serverId: z.coerce.number().int().positive().max(Number.MAX_SAFE_INTEGER).optional(), from: instant.optional(), to: instant.optional(),
}).strict()
export type McpConnection = z.infer<typeof mcpConnectionSchema>
export type McpConnectionCreate = z.infer<typeof mcpConnectionCreateSchema>
const pollOwnerCapacitySchema=z.object({used:z.number().int().min(0).max(8),limit:z.literal(8),remaining:z.number().int().min(0).max(8)}).strict().refine(value=>value.used+value.remaining<=value.limit)
const pollGlobalCapacitySchema=z.object({used:z.number().int().min(0).max(128),limit:z.literal(128),remaining:z.number().int().min(0).max(128)}).strict().refine(value=>value.used+value.remaining<=value.limit)
/** Heavy reservations. Retained archive bytes/rows can further reduce the
* remaining capacity; this observation never grants an execution admission. */
export const mcpPollStorageCapacitySchema=z.discriminatedUnion("status",[
z.object({status:z.literal("unavailable")}).strict(),
z.object({status:z.literal("observed"),owner:pollOwnerCapacitySchema,
global:z.object({exhausted:z.boolean(),usage:pollGlobalCapacitySchema.nullable()}).strict()
.refine(value=>value.usage===null||value.exhausted===(value.usage.remaining===0)),
retentionDays:z.number().int().min(1).max(365)}).strict(),
])
export type McpPollStorageCapacity=z.infer<typeof mcpPollStorageCapacitySchema>
+9
View File
@@ -0,0 +1,9 @@
import {z} from "zod"
export const ospfOptimizerMetricSchema=z.object({
value:z.number().finite().nonnegative().nullable(),
source:z.enum(["speed-probe","interface-traffic","unknown"]),
sourceId:z.string().min(1).nullable(),
measuredAt:z.string().datetime({offset:true}).nullable(),
}).refine(metric=>metric.value===null ? metric.source==="unknown"&&metric.sourceId===null&&metric.measuredAt===null : metric.source!=="unknown"&&metric.sourceId!==null)
export const ospfOptimizerObservedSchema=z.object({ping:ospfOptimizerMetricSchema,dl:ospfOptimizerMetricSchema,ul:ospfOptimizerMetricSchema}).refine(observed=>observed.ping.source!=="interface-traffic")
export type OspfOptimizerObserved=z.infer<typeof ospfOptimizerObservedSchema>
+8
View File
@@ -0,0 +1,8 @@
import assert from "node:assert/strict"
import { serverCreateSchema, serverUpdateSchema } from "./servers.js"
assert.deepEqual(serverUpdateSchema.parse({ name: "Renamed" }), { name: "Renamed" })
assert.deepEqual(serverUpdateSchema.parse({ enabled: false }), { enabled: false })
assert.deepEqual(serverUpdateSchema.parse({}), {})
assert.equal(serverCreateSchema.parse({ host: "192.0.2.1" }).port, 443)
assert.equal(serverCreateSchema.parse({ host: "192.0.2.1" }).username, "admin")
console.log("Server PATCH preserves omitted credentials, endpoint, TLS and metadata; create defaults remain valid")
+121
View File
@@ -0,0 +1,121 @@
import { z } from "zod"
export const serverTypeSchema = z.enum(["jump-host", "exit-node", "home-router"])
export const serverStatusSchema = z.enum(["online", "offline"])
export const wanUplinkSchema = z.object({
id: z.string(),
name: z.string(),
isp: z.string(),
iface: z.string(),
ip: z.string(),
maxDl: z.number(),
maxUl: z.number(),
})
export const serverCreateSchema = z.object({
host: z.string().min(1, "Host is required"),
port: z.number().int().positive().default(443),
username: z.string().default("admin"),
password: z.string().default(""),
useSsl: z.boolean().default(true),
verifySsl: z.boolean().default(false),
name: z.string().default(""),
type: serverTypeSchema.default("home-router"),
site: z.string().default(""),
country: z.string().default(""),
asn: z.string().default(""),
comment: z.string().default(""),
enabled: z.boolean().default(true),
lanSubnet: z.string().default(""),
wanUplinks: z.array(wanUplinkSchema).default([]),
})
// Updates must preserve omitted connection parameters and credentials. Creation
// defaults inside optional fields would otherwise reset them under Zod 4.
export const serverUpdateSchema = z.object({
host: z.string().min(1).optional(), port: z.number().int().positive().optional(),
username: z.string().optional(), password: z.string().optional(),
useSsl: z.boolean().optional(), verifySsl: z.boolean().optional(),
name: z.string().optional(), type: serverTypeSchema.optional(), site: z.string().optional(),
country: z.string().optional(), asn: z.string().optional(), comment: z.string().optional(),
enabled: z.boolean().optional(), lanSubnet: z.string().optional(), wanUplinks: z.array(wanUplinkSchema).optional(),
})
export const serverIdParamSchema = z.object({
id: z.coerce.number().int().positive(),
})
export const snapshotsQuerySchema = z.object({
limit: z.coerce.number().int().positive().max(500).default(50),
})
export const testConnectionSchema = z.object({
host: z.string().min(1),
port: z.number().int().positive().default(443),
useSsl: z.boolean().default(true),
verifySsl: z.boolean().default(false),
apiPath: z.string().default("/rest"),
username: z.string().default("admin"),
password: z.string().default(""),
})
export const serverReadSchema = z.object({
id: z.number().int(),
name: z.string(),
host: z.string(),
port: z.number().int(),
useSsl: z.boolean(),
verifySsl: z.boolean(),
username: z.string(),
password: z.string(),
type: serverTypeSchema,
site: z.string(),
country: z.string(),
asn: z.string(),
comment: z.string(),
enabled: z.boolean(),
lanSubnet: z.string(),
wanUplinks: z.array(wanUplinkSchema),
createdAt: z.string(),
updatedAt: z.string(),
status: z.union([serverStatusSchema, z.null()]),
latency: z.union([z.number(), z.null()]),
os: z.union([z.string(), z.null()]),
model: z.union([z.string(), z.null()]),
uptime: z.union([z.string(), z.null()]),
cpuLoad: z.union([z.number(), z.null()]),
freeMemory: z.union([z.number(), z.null()]),
totalMemory: z.union([z.number(), z.null()]),
identityName: z.union([z.string(), z.null()]),
sessions: z.number().int(),
polledAt: z.union([z.string(), z.null()]),
})
export const snapshotReadSchema = z.object({
id: z.number().int(),
serverId: z.number().int(),
polledAt: z.string(),
status: serverStatusSchema,
latencyMs: z.union([z.number(), z.null()]),
rosVersion: z.union([z.string(), z.null()]),
boardName: z.union([z.string(), z.null()]),
uptime: z.union([z.string(), z.null()]),
cpuLoad: z.union([z.number().int(), z.null()]),
freeMemory: z.union([z.number().int(), z.null()]),
totalMemory: z.union([z.number().int(), z.null()]),
identityName: z.union([z.string(), z.null()]),
})
export const serverListSchema = z.array(serverReadSchema)
export const snapshotListSchema = z.array(snapshotReadSchema)
export type ServerType = z.infer<typeof serverTypeSchema>
export type WanUplink = z.infer<typeof wanUplinkSchema>
export type ServerCreate = z.infer<typeof serverCreateSchema>
export type ServerUpdate = z.infer<typeof serverUpdateSchema>
export type ServerRead = z.infer<typeof serverReadSchema>
export type SnapshotRead = z.infer<typeof snapshotReadSchema>
export type ServerIdParam = z.infer<typeof serverIdParamSchema>
export type SnapshotsQuery = z.infer<typeof snapshotsQuerySchema>
export type TestConnectionRequest = z.infer<typeof testConnectionSchema>
+102
View File
@@ -0,0 +1,102 @@
import { z } from "zod"
export const STATISTICS_UNBOUND_USER_ID = "__unbound__"
export const STATISTICS_WAN_MARK = "WAN · интернет"
export const STATISTICS_DUP_MARK = "дубль"
export const statisticsPivotDimSchema = z.enum([
"country",
"service",
"asn",
"server",
"user",
"iface",
])
export const statisticsBreakdownRowSchema = z.object({
id: z.string(),
label: z.string(),
bytes: z.number().nonnegative(),
packets: z.number().nonnegative(),
bps: z.number().nonnegative(),
percent: z.number().nonnegative(),
})
export const statisticsSeriesPointSchema = z.object({
t: z.string(),
bytes: z.number().nonnegative(),
})
export const statisticsKpisSchema = z.object({
bytes: z.number().nonnegative(),
packets: z.number().nonnegative(),
avgBps: z.number().nonnegative(),
users: z.number().int().nonnegative(),
servers: z.number().int().nonnegative(),
ifaces: z.number().int().nonnegative(),
topCountry: z.string(),
topService: z.string(),
})
export const statisticsQuerySchema = z.object({
from: z.string().min(1),
to: z.string().min(1),
serverId: z.coerce.number().int().positive().optional(),
userId: z.string().min(1).optional(),
iface: z.string().min(1).optional(),
country: z.string().min(2).max(2).optional(),
service: z.string().min(1).optional(),
asn: z.coerce.number().int().optional(),
planes: z.enum(["unique", "all"]).default("unique"),
})
export const statisticsDtoSchema = z.object({
from: z.string(),
to: z.string(),
grain: z.enum(["hour", "day"]),
kpis: statisticsKpisSchema,
series: z.array(statisticsSeriesPointSchema),
users: z.array(statisticsBreakdownRowSchema),
servers: z.array(statisticsBreakdownRowSchema),
interfaces: z.array(statisticsBreakdownRowSchema),
countries: z.array(statisticsBreakdownRowSchema),
services: z.array(statisticsBreakdownRowSchema),
asns: z.array(statisticsBreakdownRowSchema),
})
export const statisticsPivotQuerySchema = statisticsQuerySchema.extend({
row: statisticsPivotDimSchema,
col: statisticsPivotDimSchema,
metric: z.enum(["bytes", "packets"]).default("bytes"),
})
export const statisticsPivotColumnSchema = z.object({
id: z.string(),
label: z.string(),
total: z.number().nonnegative(),
})
export const statisticsPivotRowSchema = z.object({
id: z.string(),
label: z.string(),
total: z.number().nonnegative(),
cells: z.record(z.string(), z.number().nonnegative()),
})
export const statisticsPivotDtoSchema = z.object({
rowDim: statisticsPivotDimSchema,
colDim: statisticsPivotDimSchema,
metric: z.enum(["bytes", "packets"]),
columns: z.array(statisticsPivotColumnSchema),
rows: z.array(statisticsPivotRowSchema),
otherBytes: z.number().nonnegative(),
})
export type StatisticsBreakdownRow = z.infer<typeof statisticsBreakdownRowSchema>
export type StatisticsSeriesPoint = z.infer<typeof statisticsSeriesPointSchema>
export type StatisticsKpis = z.infer<typeof statisticsKpisSchema>
export type StatisticsQuery = z.infer<typeof statisticsQuerySchema>
export type StatisticsDto = z.infer<typeof statisticsDtoSchema>
export type StatisticsPivotDim = z.infer<typeof statisticsPivotDimSchema>
export type StatisticsPivotQuery = z.infer<typeof statisticsPivotQuerySchema>
export type StatisticsPivotDto = z.infer<typeof statisticsPivotDtoSchema>
+434
View File
@@ -0,0 +1,434 @@
import { z } from "zod"
export const flowHostPeerSchema = z.object({
serverId: z.number().int().positive(),
name: z.string(),
publicKey: z.string().min(1),
allowedIps: z.array(z.string().min(1)).min(1),
address: z.string().min(1),
endpoint: z.string().optional(),
})
export const trafficFlowHostStatusSchema = z.object({
configured: z.boolean(),
reachable: z.boolean(),
interfaceUp: z.boolean(),
configMatches: z.boolean().nullable(),
publicKey: z.string(),
listenPort: z.number().int().nonnegative(),
addresses: z.array(z.string()),
peers: z.array(z.object({
publicKey: z.string(), allowedIps: z.array(z.string()),
lastHandshakeAt: z.number().int().nonnegative().nullable(),
receivedBytes: z.number().nonnegative(), sentBytes: z.number().nonnegative(),
})),
error: z.string().nullable(),
})
export type TrafficFlowHostStatus = z.infer<typeof trafficFlowHostStatusSchema>
const flowLossU64Maximum = "18446744073709551615"
const flowLossCountSchema = z.string().refine(value => value.length <= 20 && /^(?:0|[1-9][0-9]*)$/.test(value) && BigInt(value) <= 18446744073709551615n, "Invalid canonical U64 counter")
const flowLossTimestampSchema = z.string().refine(value => value.length === 24 && Number.isFinite(Date.parse(value)) && new Date(value).toISOString() === value, "Invalid canonical loss timestamp")
export const flowIngestQualitySchema = z.object({
version: z.literal(1), collectorEpoch: z.string().uuid(), scope: z.literal("collector-global"), exportCoverage: z.literal("unverified"),
localAdmission: z.enum(["no-local-loss-observed", "partial", "loss-quantity-unknown"]),
records: flowLossCountSchema.nullable(), bytes: flowLossCountSchema.nullable(), packets: flowLossCountSchema.nullable(),
knownLowerBounds: z.object({ records: flowLossCountSchema, bytes: flowLossCountSchema, packets: flowLossCountSchema }),
firstAt: flowLossTimestampSchema.nullable(), lastAt: flowLossTimestampSchema.nullable(), intervalUnknown: z.boolean(),
reasons: z.object({ "aggregate-capacity": z.boolean(), "ring-capacity": z.boolean(), "key-width": z.boolean(), "unsafe-numeric": z.boolean() }),
counterOverflow: z.boolean(), rawRowsPolicy: z.literal("topN-and-pending-cap-not-complete"), lifetimeScope: z.literal("current-collector-isolate"),
}).superRefine((value, context) => {
const fail = () => context.addIssue({ code: "custom", message: "Inconsistent collector loss accounting" })
const keys = ["records", "bytes", "packets"] as const
if (keys.some(key => value[key] !== null && value[key] !== value.knownLowerBounds[key])) fail()
if (value.records === null && (!value.counterOverflow || value.knownLowerBounds.records !== flowLossU64Maximum)) fail()
if (value.counterOverflow && !keys.some(key => value[key] === null && value.knownLowerBounds[key] === flowLossU64Maximum)) fail()
const lost = value.knownLowerBounds.records !== "0"
if (Object.values(value.reasons).some(Boolean) !== lost) fail()
if (!lost) {
if (value.localAdmission !== "no-local-loss-observed" || value.counterOverflow || value.intervalUnknown
|| keys.some(key => value[key] !== "0" || value.knownLowerBounds[key] !== "0") || value.firstAt !== null || value.lastAt !== null) fail()
return
}
const unknownQuantity = value.counterOverflow || keys.some(key => value[key] === null)
if (value.localAdmission !== (unknownQuantity ? "loss-quantity-unknown" : "partial")) fail()
if (value.intervalUnknown) {
if (value.firstAt !== null || value.lastAt !== null) fail()
} else if (value.firstAt === null || value.lastAt === null || value.firstAt > value.lastAt) fail()
})
export const trafficFlowSettingsDtoSchema = z.object({
dataQuality:flowIngestQualitySchema.nullable().optional(),
enabled: z.boolean(),
collectorIp: z.string().min(1),
flowListenPort: z.number().int().positive(),
wgListenPort: z.number().int().positive(),
prefix: z.string().min(1),
publicEndpoint: z.string(),
hostPublicKey: z.string(),
hasHostPrivateKey: z.boolean(),
hubServerId: z.number().int().positive().nullable(),
retentionHours: z.number().int().positive(),
topN: z.number().int().positive(),
mapServiceMinSharePct: z.number().min(0).max(100),
lastDatagramAt: z.string().nullable(),
lastExporterIp: z.string().nullable(),
lastError: z.string().nullable(),
packetsReceived: z.number().int().nonnegative(),
listenerBound: z.boolean(),
listenerAddress: z.string().nullable(),
peers: z.array(flowHostPeerSchema),
})
// The current overlay allocator and host setup operate on IPv4.
export const trafficFlowCollectorIpSchema = z.ipv4("Укажите IPv4-адрес коллектора")
export const trafficFlowOverlayPrefixSchema = z.cidrv4("Укажите IPv4-префикс в формате CIDR")
export const trafficFlowPublicEndpointSchema = z.string().refine(value => {
if (value === "" || z.ipv4().safeParse(value).success || z.ipv6().safeParse(value).success) return true
const hostname = value.endsWith(".") ? value.slice(0, -1) : value
return hostname.length <= 253 && /[a-z]/i.test(hostname) && hostname.split(".").every(label => /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/i.test(label))
}, "Endpoint: укажите IP или DNS-имя без порта и протокола")
export const trafficFlowSettingsPatchSchema = z.object({
enabled: z.boolean().optional(),
collectorIp: trafficFlowCollectorIpSchema.optional(),
flowListenPort: z.number().int().positive().max(65535).optional(),
wgListenPort: z.number().int().positive().max(65535).optional(),
prefix: trafficFlowOverlayPrefixSchema.optional(),
publicEndpoint: trafficFlowPublicEndpointSchema.optional(),
hubServerId: z.number().int().positive().nullable().optional(),
retentionHours: z.number().int().positive().max(2147483647).optional(),
topN: z.number().int().positive().max(1000).optional(),
mapServiceMinSharePct: z.number().min(0).max(100).optional(),
})
export const trafficFlowOverlayRequestSchema = z.object({
serverId: z.union([z.string(), z.number()]),
publicEndpoint: z.string().optional(),
/** GRE allow-fast-path=no: inner IPFIX через CPU. По умолчанию выкл (нагрузка на CPU). */
disableGreFastPath: z.boolean().optional(),
})
export const trafficFlowHostFileSchema = z.object({
id: z.string(),
label: z.string(),
filename: z.string(),
code: z.string(),
})
export const trafficFlowOverlayResultSchema = z.object({
ok: z.boolean(),
serverId: z.number().int(),
interfaceName: z.string(),
address: z.string(),
publicKey: z.string(),
linuxPeerBlock: z.string(),
trafficFlow: z.boolean(),
steps: z.array(z.string()),
hostFiles: z.array(trafficFlowHostFileSchema),
hostStatus: trafficFlowHostStatusSchema.optional(),
stage: z.enum(["manual", "host-pending", "applied"]).optional(),
hostError: z.string().optional(),
})
export const flowTalkerDtoSchema = z.object({
serverId: z.string(),
serverName: z.string(),
src: z.string(),
dst: z.string(),
proto: z.number().int(),
protoName: z.string(),
srcPort: z.number().int(),
dstPort: z.number().int(),
bytes: z.number().nonnegative(),
packets: z.number().nonnegative(),
bps: z.number().nonnegative(),
inIface: z.string(),
inIfaceIndex: z.string().optional(),
outIface: z.string().optional(),
nextHop: z.string().optional(),
application: z.string().optional(),
category: z.string().optional(),
service: z.string().optional(),
dstCountry: z.string().optional(),
dstAsn: z.number().int().optional(),
clientId: z.string().optional(),
clientName: z.string().optional(),
clientIp: z.string().optional(),
internetPeer: z.string().optional(),
internetPeerPort: z.number().int().optional(),
direction: z.enum(["to_client", "from_client", "transit"]).optional(),
enId: z.string().optional(),
enName: z.string().optional(),
plane: z.string().optional(),
})
export const flowStatsDtoSchema = z.object({
dataQuality:flowIngestQualitySchema.nullable().optional(),
exportersOnline: z.number().int().nonnegative(),
bytesPerMin: z.number().nonnegative(),
uniqueSrc: z.number().int().nonnegative(),
uniqueDst: z.number().int().nonnegative(),
topProto: z.string(),
talkers: z.array(flowTalkerDtoSchema),
lastExporterIp: z.string().nullable().optional(),
lastError: z.string().nullable().optional(),
packetsReceived: z.number().int().nonnegative().optional(),
lastDatagramAt: z.string().nullable().optional(),
listenerBound: z.boolean().optional(),
listenerAddress: z.string().nullable().optional(),
})
export type FlowHostPeer = z.infer<typeof flowHostPeerSchema>
export type TrafficFlowSettingsDto = z.infer<typeof trafficFlowSettingsDtoSchema>
export type TrafficFlowSettingsPatch = z.infer<typeof trafficFlowSettingsPatchSchema>
export type TrafficFlowOverlayResult = z.infer<typeof trafficFlowOverlayResultSchema>
export type TrafficFlowHostFile = z.infer<typeof trafficFlowHostFileSchema>
export const flowBreakdownRowSchema = z.object({
id: z.string(),
label: z.string(),
bytes: z.number().nonnegative(),
packets: z.number().nonnegative(),
bps: z.number().nonnegative(),
percent: z.number().nonnegative(),
})
export const flowIfaceChipSchema = z.object({
name: z.string(),
index: z.string(),
bps: z.number().nonnegative(),
})
export const flowEntityCardSchema = z.object({
id: z.string(),
name: z.string(),
subtitle: z.string(),
site: z.string(),
country: z.string(),
status: z.enum(["online", "offline", "degraded"]),
rxNow: z.number(),
txNow: z.number(),
sessions: z.number().int().nonnegative(),
rxSeries: z.array(z.number()),
txSeries: z.array(z.number()),
bytes: z.number().nonnegative(),
})
export const flowMapEdgeSchema = z.object({
fromId: z.string(),
fromLabel: z.string(),
fromCountry: z.string(),
toCountry: z.string(),
toAsn: z.number().int().nonnegative(),
category: z.string(),
bytes: z.number().nonnegative(),
bps: z.number().nonnegative(),
})
export const flowPathRowSchema = z.object({
id: z.string(),
clientId: z.string(),
clientName: z.string(),
ifaces: z.string(),
serverId: z.string(),
serverName: z.string(),
inIface: z.string(),
outIface: z.string(),
enId: z.string(),
enName: z.string(),
dst: z.string(),
service: z.string(),
category: z.string(),
plane: z.string(),
bytes: z.number().nonnegative(),
packets: z.number().nonnegative(),
bps: z.number().nonnegative(),
})
export const flowAnalyticsDtoSchema = z.object({
bpsNow: z.number().nonnegative(),
bytes: z.number().nonnegative(),
packets: z.number().nonnegative(),
conversations: z.number().int().nonnegative(),
conversationsRaw: z.number().int().nonnegative().optional(),
uniqueSrc: z.number().int().nonnegative(),
uniqueDst: z.number().int().nonnegative(),
topProto: z.string(),
topCategory: z.string().optional(),
rxSeries: z.array(z.number()),
txSeries: z.array(z.number()),
applications: z.array(flowBreakdownRowSchema),
protocols: z.array(flowBreakdownRowSchema),
sources: z.array(flowBreakdownRowSchema),
destinations: z.array(flowBreakdownRowSchema),
interfaces: z.array(flowBreakdownRowSchema),
asns: z.array(flowBreakdownRowSchema).optional(),
countries: z.array(flowBreakdownRowSchema).optional(),
categories: z.array(flowBreakdownRowSchema).optional(),
services: z.array(flowBreakdownRowSchema).optional(),
mapEdges: z.array(flowMapEdgeSchema).optional(),
conversationsList: z.array(flowTalkerDtoSchema),
paths: z.array(flowPathRowSchema).optional(),
ifaces: z.array(flowIfaceChipSchema),
live: z.boolean(),
dedupApplied: z.boolean().optional(),
degraded: z.boolean().optional(),
bytesPayload: z.number().nonnegative().optional(),
bytesOverlay: z.number().nonnegative().optional(),
bytesMesh: z.number().nonnegative().optional(),
bytesWire: z.number().nonnegative().optional(),
bpsOverlay: z.number().nonnegative().optional(),
bpsWire: z.number().nonnegative().optional(),
excludeMeshApplied: z.boolean().optional(),
excludeOverlayApplied: z.boolean().optional(),
})
export const flowExportersDtoSchema = z.object({
exporters: z.array(flowEntityCardSchema),
lastExporterIp: z.string().nullable().optional(),
lastError: z.string().nullable().optional(),
packetsReceived: z.number().int().nonnegative().optional(),
lastDatagramAt: z.string().nullable().optional(),
listenerBound: z.boolean().optional(),
listenerAddress: z.string().nullable().optional(),
})
export const flowClientsDtoSchema = z.object({
clients: z.array(flowEntityCardSchema),
})
export const flowMonthlyDtoSchema = z.object({
month: z.string(),
bytes: z.number().nonnegative(),
countries: z.array(flowBreakdownRowSchema),
services: z.array(flowBreakdownRowSchema),
asns: z.array(flowBreakdownRowSchema),
})
export const flowPurgeDtoSchema = z.object({
ok: z.literal(true),
deleted: z.object({
buckets: z.number().int().nonnegative(),
minuteStats: z.number().int().nonnegative(),
minuteDims: z.number().int().nonnegative(),
dailyDims: z.number().int().nonnegative(),
hourFacts: z.number().int().nonnegative().optional(),
dailyFacts: z.number().int().nonnegative().optional(),
}),
fileBytesBefore: z.number().int().nonnegative().nullable(),
fileBytesAfter: z.number().int().nonnegative().nullable(),
vacuumed: z.boolean(),
listenerError: z.string().optional(),
partitionError: z.string().optional(),
eventError: z.string().optional(),
})
export const flowFactsRebuildDtoSchema = z.object({
ok: z.literal(true),
buckets: z.number().int().nonnegative(),
facts: z.number().int().nonnegative(),
days: z.array(z.string()),
})
export const flowMapHopKindSchema = z.enum(["gre", "wan", "iface"])
export const flowMapHopDtoSchema = z.object({
fromId: z.string(),
fromLabel: z.string(),
toId: z.string(),
toLabel: z.string(),
kind: flowMapHopKindSchema,
iface: z.string().optional(),
bytes: z.number().nonnegative(),
bps: z.number().nonnegative(),
bpsFwd: z.number().nonnegative(),
bpsRev: z.number().nonnegative(),
})
export const flowMapServiceDtoSchema = z.object({
id: z.string(),
label: z.string(),
category: z.string(),
bytes: z.number().nonnegative(),
bps: z.number().nonnegative(),
share: z.number().min(0).max(1),
})
export const flowMapServiceEdgeDtoSchema = z.object({
fromId: z.string(),
toId: z.string(),
bytes: z.number().nonnegative(),
bps: z.number().nonnegative(),
bpsFwd: z.number().nonnegative(),
bpsRev: z.number().nonnegative(),
clientId: z.string().optional(),
clientName: z.string().optional(),
clients: z.array(z.object({
id: z.string(),
name: z.string(),
})).optional(),
})
export const flowMapServicePathDtoSchema = z.object({
clientId: z.string(),
clientName: z.string(),
viaId: z.string(),
viaName: z.string(),
enId: z.string(),
enName: z.string(),
serviceId: z.string(),
bytes: z.number().nonnegative(),
bps: z.number().nonnegative(),
})
/** Сервисы внутри одной страны: id вида `cc:us|svc:google`, рёбра от `countryId`. */
export const flowMapCountryServiceGroupDtoSchema = z.object({
countryId: z.string(),
services: z.array(flowMapServiceDtoSchema),
edges: z.array(flowMapServiceEdgeDtoSchema),
paths: z.array(flowMapServicePathDtoSchema),
})
export const flowMapHopsDtoSchema = z.object({
hops: z.array(flowMapHopDtoSchema),
live: z.boolean(),
rangeMinutes: z.number().int().positive(),
windowSec: z.number().positive(),
totalBytes: z.number().nonnegative().optional(),
namedBytes: z.number().nonnegative().optional(),
unclassifiedBytes: z.number().nonnegative().optional(),
asnLoaded: z.boolean().optional(),
countryLoaded: z.boolean().optional(),
services: z.array(flowMapServiceDtoSchema).optional(),
serviceEdges: z.array(flowMapServiceEdgeDtoSchema).optional(),
servicePaths: z.array(flowMapServicePathDtoSchema).optional(),
countries: z.array(flowMapServiceDtoSchema).optional(),
countryEdges: z.array(flowMapServiceEdgeDtoSchema).optional(),
countryPaths: z.array(flowMapServicePathDtoSchema).optional(),
countryServiceGroups: z.array(flowMapCountryServiceGroupDtoSchema).optional(),
mapServiceMinSharePct: z.number().min(0).max(100).optional(),
dedupApplied: z.boolean(),
excludeMeshApplied: z.boolean(),
excludeOverlayApplied: z.boolean(),
})
export type FlowTalkerDto = z.infer<typeof flowTalkerDtoSchema>
export type FlowStatsDto = z.infer<typeof flowStatsDtoSchema>
export type FlowBreakdownRow = z.infer<typeof flowBreakdownRowSchema>
export type FlowIfaceChip = z.infer<typeof flowIfaceChipSchema>
export type FlowEntityCard = z.infer<typeof flowEntityCardSchema>
export type FlowMapEdge = z.infer<typeof flowMapEdgeSchema>
export type FlowPathRow = z.infer<typeof flowPathRowSchema>
export type FlowAnalyticsDto = z.infer<typeof flowAnalyticsDtoSchema>
export type FlowExportersDto = z.infer<typeof flowExportersDtoSchema>
export type FlowClientsDto = z.infer<typeof flowClientsDtoSchema>
export type FlowMonthlyDto = z.infer<typeof flowMonthlyDtoSchema>
export type FlowPurgeDto = z.infer<typeof flowPurgeDtoSchema>
export type FlowFactsRebuildDto = z.infer<typeof flowFactsRebuildDtoSchema>
export type FlowMapHopKind = z.infer<typeof flowMapHopKindSchema>
export type FlowMapHop = z.infer<typeof flowMapHopDtoSchema>
export type FlowMapService = z.infer<typeof flowMapServiceDtoSchema>
export type FlowMapServiceEdge = z.infer<typeof flowMapServiceEdgeDtoSchema>
export type FlowMapServicePath = z.infer<typeof flowMapServicePathDtoSchema>
export type FlowMapCountryServiceGroup = z.infer<typeof flowMapCountryServiceGroupDtoSchema>
export type FlowMapHopsDto = z.infer<typeof flowMapHopsDtoSchema>
+12
View File
@@ -0,0 +1,12 @@
import { z } from "zod"
const serverId = z.union([z.number(), z.string().regex(/^[1-9][0-9]*$/).transform(Number)]).pipe(z.number().int().positive().max(Number.MAX_SAFE_INTEGER))
const interval = z.union([z.number(), z.string().regex(/^[0-9]+$/).transform(Number), z.literal("").transform(() => 0), z.null().transform(() => 0)]).pipe(z.number().int().min(0).max(2147483647))
export const pingProbeWriteSchema = z.object({
id: z.string().trim().min(1), srcServerId: serverId,
srcInterface: z.string().trim().default(""), name: z.string().trim().min(1), target: z.string().trim().min(1),
filter: z.string().trim().transform(value => value || "—").default("—"), enabled: z.boolean(),
showOnDashboard: z.boolean().optional(), intervalSec: interval.optional(),
})
export const pingProbesWriteSchema = z.object({ probes: z.array(pingProbeWriteSchema).refine(rows => new Set(rows.map(row => row.id)).size === rows.length, "Повторный ID Ping-пробы") })
export const probeCatalogSavedSchema = z.object({ ok: z.literal(true), warning: z.string().min(1).optional() })
export type PingProbeWrite = z.infer<typeof pingProbeWriteSchema>
+71
View File
@@ -0,0 +1,71 @@
import { z } from "zod"
const rate = z.number().finite().nonnegative().nullable()
export const speedHistoryRunSchema = z.object({
id:z.string().min(1), probeId:z.string().nullable().optional(),
srcServerId:z.string().regex(/^[1-9][0-9]*$/), dstServerId:z.string().regex(/^[1-9][0-9]*$/),
srcInterface:z.string(), dstInterface:z.string(),
protocol:z.enum(["tcp","udp"]), direction:z.enum(["transmit","receive","both"]), durationSec:z.number().int().positive(),
txAvgMbps:rate, rxAvgMbps:rate, status:z.enum(["done","error"]), error:z.string().nullable().optional(),
createdAt:z.string().datetime({offset:true}),
srcAddress:z.string().nullable().optional(),dstAddress:z.string().nullable().optional(),
srcInterfaceAddress:z.string().nullable().optional(),dstInterfaceAddress:z.string().nullable().optional(),
afterBtPing:z.object({rttMs:rate,lossPct:z.number().finite().min(0).max(100).nullable(),error:z.string().nullable()}).nullable().optional(),
})
export const speedHistoryResponseSchema=z.object({runs:z.array(speedHistoryRunSchema).superRefine((rows,ctx)=>{
const ids=new Set<string>();for(const row of rows){if(ids.has(row.id))ctx.addIssue({code:"custom",message:"Повторный ID истории скорости"});ids.add(row.id)}
})})
export type SpeedHistoryRun=z.infer<typeof speedHistoryRunSchema>
const probeTimestamp = z.string().datetime({ offset: true }).nullable()
export const speedProbeReadSchema = z.object({
id: z.string().min(1), srcServerId: z.string().regex(/^[1-9][0-9]*$/), dstServerId: z.string().regex(/^[1-9][0-9]*$/),
srcInterface: z.string(), dstInterface: z.string(), protocol: z.enum(["tcp", "udp"]), direction: z.enum(["both", "transmit", "receive"]),
durationSec: z.string().regex(/^[0-9]+$/).refine(value => Number.isSafeInteger(Number(value)) && Number(value) >= 3), enabled: z.boolean(),
lastRunAt: probeTimestamp, lastTxAvgMbps: rate, lastRxAvgMbps: rate, lastStatus: z.enum(["done", "error"]).nullable(), lastError: z.string().nullable(),
lastPingRttMs: rate.optional(), lastPingLossPct: z.number().finite().min(0).max(100).nullable().optional(),
lastPingAt: probeTimestamp.optional(), lastPingError: z.string().nullable().optional(),
})
export const speedProbesResponseSchema = z.object({ probes: z.array(speedProbeReadSchema).refine(rows => new Set(rows.map(row => row.id)).size === rows.length, "Повторный ID speed-пробы") })
export type SpeedProbeRead = z.infer<typeof speedProbeReadSchema>
const speedPositiveInteger = z.union([z.number(), z.string().regex(/^[1-9][0-9]*$/).transform(Number)]).pipe(z.number().int().positive().max(Number.MAX_SAFE_INTEGER))
export const speedProbeWriteSchema = z.object({
id: z.string().trim().min(1), srcServerId: speedPositiveInteger, dstServerId: speedPositiveInteger,
srcInterface: z.string().trim(), dstInterface: z.string().trim(),
protocol: z.enum(["tcp", "udp"]), direction: z.enum(["both", "transmit", "receive"]),
durationSec: speedPositiveInteger.pipe(z.number().min(3).max(2147483647)), enabled: z.boolean(),
}).refine(row => row.srcServerId !== row.dstServerId, "Источник и назначение должны отличаться")
export const speedProbesWriteSchema = z.object({ probes: z.array(speedProbeWriteSchema).refine(rows => new Set(rows.map(row => row.id)).size === rows.length, "Повторный ID speed-пробы") })
export const speedProbesSavedSchema = z.object({ ok: z.literal(true), warning: z.string().min(1).optional() })
export type SpeedProbeWrite = z.infer<typeof speedProbeWriteSchema>
/** A changed device pair has no measurements; configuration writes cannot invent them. */
export const emptySpeedProbeObservations = {
lastRunAt: null, lastTxAvgMbps: null, lastRxAvgMbps: null, lastStatus: null, lastError: null,
lastPingRttMs: null, lastPingLossPct: null, lastPingAt: null, lastPingError: null,
}
const speedCommandText = z.string().refine(value => !value.includes(String.fromCharCode(0)), "Недопустимый символ в параметре теста")
const speedOptionalId = speedCommandText.trim().transform(value => value || undefined).optional()
export const speedTestCommandSchema = z.object({
runId: speedOptionalId, probeId: speedOptionalId,
srcServerId: speedPositiveInteger, dstServerId: speedPositiveInteger,
srcInterface: speedCommandText.trim().default(""), dstInterface: speedCommandText.trim().default(""),
protocol: z.enum(["tcp", "udp"]).default("tcp"),
direction: z.enum(["transmit", "receive", "both"]).default("both"),
durationSec: speedPositiveInteger.pipe(z.number().min(3).max(2147483647)).default(10),
maxMbps: z.number().int().min(1).max(1000).optional(),
}).refine(row => row.srcServerId !== row.dstServerId, "Источник и назначение должны отличаться")
export type SpeedTestCommand = z.infer<typeof speedTestCommandSchema>
export type SpeedMeasurementParameters = {
srcServerId: string | number; dstServerId: string | number; srcInterface?: string; dstInterface?: string;
protocol: "tcp" | "udp"; direction: "both" | "transmit" | "receive"; durationSec: string | number
}
/** Compare validated configuration across HTTP read/write forms; enable/order are not measurement parameters. */
export function sameSpeedMeasurement(a: SpeedMeasurementParameters, b: SpeedMeasurementParameters): boolean {
return String(a.srcServerId) === String(b.srcServerId) && String(a.dstServerId) === String(b.dstServerId)
&& (a.srcInterface ?? "").trim() === (b.srcInterface ?? "").trim()
&& (a.dstInterface ?? "").trim() === (b.dstInterface ?? "").trim()
&& a.protocol === b.protocol && a.direction === b.direction && Number(a.durationSec) === Number(b.durationSec)
}
+9
View File
@@ -0,0 +1,9 @@
import assert from "node:assert/strict"
import { appUserCreateSchema, appUserUpdateSchema, interfaceTypeSchema } from "./users.js"
assert.deepEqual(appUserUpdateSchema.parse({ name: "Renamed" }), { name: "Renamed" })
assert.deepEqual(appUserUpdateSchema.parse({}), {})
assert.deepEqual(appUserUpdateSchema.parse({ active: false }), { active: false })
const created = appUserCreateSchema.parse({ name: "A", login: "a", email: "a@example.invalid" })
assert.equal(created.role, "viewer"); assert.equal(created.active, true); assert.deepEqual(created.sections, []); assert.deepEqual(created.servers, [])
for (const type of ["bridge", "vlan", "vxlan", "ipsec", "other"]) assert.equal(interfaceTypeSchema.parse(type), type)
console.log("User PATCH preserves omitted role, activity and permissions; creation defaults and native interface types remain valid")
+145
View File
@@ -0,0 +1,145 @@
import { z } from "zod"
export const appUserRoleSchema = z.enum(["admin", "operator", "viewer"])
export const permLevelSchema = z.enum(["none", "read", "write"])
export const interfaceTypeSchema = z.enum(["ether", "bridge", "vlan", "vxlan", "gre", "wg", "ipsec", "other"])
export const sectionPermSchema = z.object({
section: z.string().min(1),
level: permLevelSchema,
})
export const serverPermSchema = z.object({
serverId: z.string().min(1),
level: permLevelSchema,
})
export const userBindingSchema = z.object({
id: z.string().min(1),
userId: z.string().min(1),
serverId: z.number().int().positive(),
serverName: z.string(),
serverSite: z.string(),
serverCountry: z.string(),
interfaceName: z.string().min(1),
interfaceType: interfaceTypeSchema,
peerPublicKey: z.string().default(""),
peerName: z.string().default(""),
comment: z.string(),
createdAt: z.string(),
updatedAt: z.string(),
})
export const appUserReadSchema = z.object({
id: z.string().min(1),
name: z.string(),
login: z.string(),
email: z.string(),
role: appUserRoleSchema,
active: z.boolean(),
avatar: z.string(),
lastSeen: z.string().nullable(),
sections: z.array(sectionPermSchema),
servers: z.array(serverPermSchema),
bindings: z.array(userBindingSchema),
createdAt: z.string(),
updatedAt: z.string(),
})
export const appUserCreateSchema = z.object({
name: z.string().min(1),
login: z.string().min(1),
email: z.string().min(1),
role: appUserRoleSchema.default("viewer"),
active: z.boolean().default(true),
avatar: z.string().optional(),
lastSeen: z.string().nullable().optional(),
sections: z.array(sectionPermSchema).default([]),
servers: z.array(serverPermSchema).default([]),
})
// Zod applies inner defaults even through optional fields. PATCH must not turn
// an omitted role/permissions into the creation defaults.
export const appUserUpdateSchema = appUserCreateSchema.extend({
role: appUserRoleSchema, active: z.boolean(),
sections: z.array(sectionPermSchema), servers: z.array(serverPermSchema),
}).partial()
export const appUserIdParamSchema = z.object({
id: z.string().min(1),
})
export const bindingIdParamSchema = z.object({
id: z.string().min(1),
bindingId: z.string().min(1),
})
export const userBindingCreateSchema = z.object({
serverId: z.coerce.number().int().positive(),
interfaceName: z.string().min(1),
interfaceType: interfaceTypeSchema.optional(),
peerPublicKey: z.string().optional(),
peerName: z.string().optional(),
comment: z.string().optional(),
})
export const interfaceCatalogQuerySchema = z.object({
serverId: z.coerce.number().int().positive(),
})
export const catalogPeerSchema = z.object({
publicKey: z.string(),
name: z.string(),
comment: z.string(),
allowedIps: z.array(z.string()),
latestHandshake: z.string().optional(),
boundUserId: z.string().nullable(),
boundUserLogin: z.string().nullable(),
})
export const catalogInterfaceSchema = z.object({
name: z.string(),
type: interfaceTypeSchema,
running: z.boolean(),
disabled: z.boolean(),
boundUserId: z.string().nullable(),
boundUserLogin: z.string().nullable(),
peers: z.array(catalogPeerSchema).optional(),
peersError: z.string().optional(),
})
export const appUserListSchema = z.array(appUserReadSchema)
export type AppUserRole = z.infer<typeof appUserRoleSchema>
export type PermLevel = z.infer<typeof permLevelSchema>
export type InterfaceType = z.infer<typeof interfaceTypeSchema>
export type SectionPerm = z.infer<typeof sectionPermSchema>
export type ServerPerm = z.infer<typeof serverPermSchema>
export type UserBinding = z.infer<typeof userBindingSchema>
export type AppUserRead = z.infer<typeof appUserReadSchema>
export type AppUserCreate = z.infer<typeof appUserCreateSchema>
export type AppUserUpdate = z.infer<typeof appUserUpdateSchema>
export type UserBindingCreate = z.infer<typeof userBindingCreateSchema>
export type CatalogPeer = z.infer<typeof catalogPeerSchema>
export type CatalogInterface = z.infer<typeof catalogInterfaceSchema>
export const catalogInterfaceStateSchema = catalogInterfaceSchema.extend({
name: z.string().trim().min(1),
running: z.boolean().nullable(),
disabled: z.boolean().nullable(),
}).superRefine((iface, ctx) => {
if ((iface.type === "wg" || iface.type === "ipsec") && iface.peers === undefined && !iface.peersError) ctx.addIssue({ code: "custom", message: "Не подтверждён срез пиров." })
if (iface.peers && (iface.peers.some(peer => !peer.publicKey.trim()) || new Set(iface.peers.map(peer => peer.publicKey)).size !== iface.peers.length)) ctx.addIssue({ code: "custom", message: "Неоднозначные ключи пиров." })
})
export const interfaceCatalogStateSchema = z.object({
serverId: z.number().int().positive(),
source: z.enum(["snapshot", "traffic-history", "unknown"]),
sampledAt: z.string().refine(value => Number.isFinite(Date.parse(value)), "Некорректное время среза.").nullable(),
inventoryComplete: z.boolean(),
interfaces: z.array(catalogInterfaceStateSchema).refine(rows => new Set(rows.map(row => row.name)).size === rows.length, "Повторяющиеся интерфейсы."),
failures: z.array(z.object({ resource: z.enum(["interfaces", "wireguard", "ipsec"]), error: z.string().min(1) })),
}).superRefine((state, ctx) => {
if (state.inventoryComplete !== (state.source === "snapshot") || state.source === "unknown" && state.sampledAt !== null) ctx.addIssue({ code: "custom", message: "Противоречивые метаданные каталога." })
})
export type CatalogInterfaceState = z.infer<typeof catalogInterfaceStateSchema>
export type InterfaceCatalogState = z.infer<typeof interfaceCatalogStateSchema>
+184
View File
@@ -0,0 +1,184 @@
import { z } from "zod"
/** The client's remote endpoint belongs to the exporting interface, not its peer. */
export function resolveWgClientEndpoint(clientEndpoint: string | undefined, serverHost: string | undefined, listenPort: number): string | undefined {
const value = clientEndpoint?.trim() || serverHost?.trim()
if (!value || /[\s/]/.test(value) || !Number.isInteger(listenPort) || listenPort < 1 || listenPort > 65535) return undefined
const explicit = value.match(/^(\[[^\]]+\]|[^:]+):(\d+)$/)
if (explicit) return Number(explicit[2]) >= 1 && Number(explicit[2]) <= 65535 ? value : undefined
if (value.startsWith("[")) return value.endsWith("]") ? `${value}:${listenPort}` : undefined
if (value.includes(":")) return value.split(":").length > 2 && /^[\da-f:]+$/i.test(value) ? `[${value}]:${listenPort}` : undefined
return `${value}:${listenPort}`
}
export const wgStatusSchema = z.enum(["up", "down"])
export const wgInterfaceAddressSchema = z.union([z.literal(""), z.cidrv4(), z.cidrv6()])
export const wgPeerDtoSchema = z.object({
id: z.string().min(1),
rosId: z.string().min(1),
publicKey: z.string(),
allowedIps: z.array(z.string()),
endpoint: z.string().optional(),
latestHandshake: z.string().optional(),
transferRx: z.number().nonnegative().optional(),
transferTx: z.number().nonnegative().optional(),
persistentKeepalive: z.number().int().nonnegative().optional(),
persistent: z.boolean().optional(),
comment: z.string().optional(),
disabled: z.boolean().optional(),
name: z.string().optional(),
clientAddress: z.string().optional(),
clientDns: z.string().optional(),
clientEndpoint: z.string().optional(),
})
export const wgIfaceDtoSchema = z.object({
id: z.string().min(1),
rosId: z.string().min(1),
name: z.string().min(1),
serverId: z.string().min(1),
serverName: z.string(),
serverCountry: z.string().optional(),
listenPort: z.number().int().positive(),
mtu: z.number().int().positive(),
publicKey: z.string().optional(),
privateKey: z.string().optional(),
address: z.string().optional(),
peers: z.array(wgPeerDtoSchema),
comment: z.string(),
enabled: z.boolean(),
status: wgStatusSchema,
})
export const wgListResponseSchema = z.object({
interfaces: z.array(wgIfaceDtoSchema),
failures: z
.array(
z.object({
serverId: z.string(),
serverName: z.string().optional(),
error: z.string(),
}),
)
.optional(),
})
export const wgCreatePeerSchema = z.object({
publicKey: z.string().min(1),
allowedAddresses: z.array(z.string().min(1)).min(1),
endpointAddress: z.string().optional(),
endpointPort: z.number().int().positive().optional(),
persistentKeepalive: z.number().int().nonnegative().optional(),
comment: z.string().optional(),
name: z.string().optional(),
privateKey: z.enum(["auto", "none"]).or(z.string().min(1)).optional(),
clientAddress: z.string().optional(),
clientDns: z.string().optional(),
clientEndpoint: z.string().optional(),
disabled: z.boolean().optional(),
})
export const wgCreateInterfaceSchema = z.object({
serverId: z.union([z.string(), z.number()]),
name: z.string().min(1).max(64),
listenPort: z.number().int().positive().default(13231),
mtu: z.number().int().positive().default(1420),
comment: z.string().optional(),
privateKey: z.string().min(1).optional(),
address: wgInterfaceAddressSchema.optional(),
disabled: z.boolean().optional(),
peer: wgCreatePeerSchema.optional(),
})
export const wgPatchInterfaceSchema = z.object({
address: wgInterfaceAddressSchema.optional(),
name: z.string().min(1).max(64).optional(),
listenPort: z.number().int().positive().optional(),
mtu: z.number().int().positive().optional(),
comment: z.string().optional(),
disabled: z.boolean().optional(),
privateKey: z.string().min(1).optional(),
})
export const wgCreatePeerRequestSchema = wgCreatePeerSchema.extend({
serverId: z.union([z.string(), z.number()]),
interfaceName: z.string().min(1),
})
export const wgPatchPeerSchema = z.object({
publicKey: z.string().min(1).optional(),
allowedAddresses: z.array(z.string().min(1)).min(1).optional(),
endpointAddress: z.string().optional(),
endpointPort: z.number().int().positive().optional(),
persistentKeepalive: z.number().int().nonnegative().optional(),
comment: z.string().optional(),
name: z.string().optional(),
disabled: z.boolean().optional(),
clientAddress: z.string().optional(),
clientDns: z.string().optional(),
clientEndpoint: z.string().optional(),
})
export const wgImportFormatSchema = z.enum(["auto", "rsc", "conf"])
export const wgImportRequestSchema = z.object({
serverId: z.union([z.string(), z.number()]),
content: z.string().min(1),
format: wgImportFormatSchema.optional().default("auto"),
dryRun: z.boolean().optional().default(false),
})
export const wgExportFormatSchema = z.enum(["rsc", "conf", "peer-conf"])
export const wgExportRequestSchema = z.object({
serverId: z.union([z.string(), z.number()]),
interfaceName: z.string().min(1),
format: wgExportFormatSchema,
peerId: z.string().optional(),
includePrivateKey: z.boolean().optional().default(false),
})
export const wgImportPreviewSchema = z.object({
format: z.enum(["rsc", "conf"]),
interface: z.object({
name: z.string(),
listenPort: z.number().int().positive().optional(),
mtu: z.number().int().positive().optional(),
privateKey: z.string().optional(),
comment: z.string().optional(),
address: z.string().optional(),
disabled: z.boolean().optional(),
}),
peers: z.array(wgCreatePeerSchema),
})
export const wgImportResponseSchema = z.object({
dryRun: z.boolean(),
preview: wgImportPreviewSchema,
applied: z
.object({
interfaceName: z.string(),
peersCreated: z.number().int().nonnegative(),
})
.optional(),
})
export const wgExportResponseSchema = z.object({
format: wgExportFormatSchema,
filename: z.string(),
content: z.string(),
})
export type WgPeerDto = z.infer<typeof wgPeerDtoSchema>
export type WgIfaceDto = z.infer<typeof wgIfaceDtoSchema>
export type WgListResponse = z.infer<typeof wgListResponseSchema>
export type WgCreateInterface = z.infer<typeof wgCreateInterfaceSchema>
export type WgPatchInterface = z.infer<typeof wgPatchInterfaceSchema>
export type WgCreatePeerRequest = z.infer<typeof wgCreatePeerRequestSchema>
export type WgPatchPeer = z.infer<typeof wgPatchPeerSchema>
export type WgImportRequest = z.infer<typeof wgImportRequestSchema>
export type WgExportRequest = z.infer<typeof wgExportRequestSchema>
export type WgImportPreview = z.infer<typeof wgImportPreviewSchema>
export type WgImportResponse = z.infer<typeof wgImportResponseSchema>
export type WgExportResponse = z.infer<typeof wgExportResponseSchema>
+19
View File
@@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"lib": ["ES2022"],
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"outDir": "dist",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}