feat(mcp): подготовить сервер и доступ к приёмке
This commit is contained in:
commit
bf3d50e1ff
3835 files changed
+537122
No files matched your search
@@ -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))
|
||||
})
|
||||
@@ -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()
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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) })
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
@@ -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>
|
||||
@@ -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")
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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")
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user