feat: harden firewall policies telemetry and operations
quality / changes (push) Successful in 7s
quality / commitlint (push) Skipped
quality / openapi (push) Successful in 30s
quality / web (push) Successful in 1m3s
quality / api (push) Successful in 1m15s
quality / linux-agent-smoke (push) Failing after 27s
quality / docker-check (push) Failing after 2m28s
CD / quality (push) Failing after 5m52s
CD / publish (push) Skipped
quality / changes (push) Successful in 7s
quality / commitlint (push) Skipped
quality / openapi (push) Successful in 30s
quality / web (push) Successful in 1m3s
quality / api (push) Successful in 1m15s
quality / linux-agent-smoke (push) Failing after 27s
quality / docker-check (push) Failing after 2m28s
CD / quality (push) Failing after 5m52s
CD / publish (push) Skipped
Implement reliable agent apply, telemetry v2, analytics retention, guarded rollouts, enrollment, labels and webhooks. Improve ReUI frontend data safety and bounded list loading, and add Docker smoke, recovery and resource checks. Document local verification and pending native acceptance.
This commit is contained in:
1 parent
aa475848fd
commit
9fa6269174
242 files changed
+19872
-3057
No files matched your search
@@ -1,4 +1,6 @@
|
||||
import { z } from 'zod'
|
||||
import { canonicalCidrSchema, canonicalIpSchema } from './network.js'
|
||||
import { agentDiagnosticsSchema, applyErrorSchema, safeCounterSchema, telemetryV2EnvelopeSchema } from './contracts/telemetry.js'
|
||||
|
||||
export const agentPlatformSchema = z.enum(['linux', 'mikrotik'])
|
||||
export const agentStatusSchema = z.enum([
|
||||
@@ -67,6 +69,7 @@ export const agentSchema = z.object({
|
||||
revoked_at: z.string().nullable().optional(),
|
||||
install_curl: z.string().nullable().optional(),
|
||||
install_link_id: z.string().nullable().optional(),
|
||||
diagnostics: agentDiagnosticsSchema.optional(),
|
||||
})
|
||||
|
||||
export const ipListSchema = z.object({
|
||||
@@ -89,7 +92,7 @@ export const policyRuleSchema = z.object({
|
||||
action: policyActionSchema,
|
||||
enabled: z.boolean().optional().default(true),
|
||||
list_id: z.string().nullable().optional(),
|
||||
cidr: z.string().nullable().optional(),
|
||||
cidr: canonicalCidrSchema.nullable().optional(),
|
||||
hostname: z.string().nullable().optional(),
|
||||
resolved_count: z.number().int().optional(),
|
||||
comment: z.string().nullable().optional(),
|
||||
@@ -117,6 +120,12 @@ export const ipOverrideSchema = z.object({
|
||||
action: policyActionSchema,
|
||||
comment: z.string().nullable().optional(),
|
||||
created_at: z.string(),
|
||||
reason: z.string().nullable().optional(),
|
||||
created_by_user_id: z.string().nullable().optional(),
|
||||
created_by_name: z.string().nullable().optional(),
|
||||
expires_at: z.string().nullable().optional(),
|
||||
expired: z.boolean().optional(),
|
||||
expiration_enforcement: z.literal('next_policy_sync').optional(),
|
||||
})
|
||||
|
||||
export const createIpListBodySchema = z.object({
|
||||
@@ -124,7 +133,7 @@ export const createIpListBodySchema = z.object({
|
||||
/** Prefer static | json_url | evobgp_community; domains accepted for legacy. */
|
||||
type: ipListTypeSchema,
|
||||
config: z.record(z.string(), z.unknown()).optional(),
|
||||
entries: z.array(z.string()).optional(),
|
||||
entries: z.array(z.string()).max(100_000).optional(),
|
||||
})
|
||||
|
||||
export const createPolicySetBodySchema = z.object({
|
||||
@@ -150,7 +159,7 @@ export const createPolicyRuleBodySchema = z
|
||||
action: policyActionSchema,
|
||||
enabled: z.boolean().optional().default(true),
|
||||
list_id: z.string().nullable().optional(),
|
||||
cidr: z.string().nullable().optional(),
|
||||
cidr: canonicalCidrSchema.nullable().optional(),
|
||||
hostname: z.string().nullable().optional(),
|
||||
comment: z.string().nullable().optional(),
|
||||
})
|
||||
@@ -187,9 +196,14 @@ export const agentIdsBodySchema = z.object({
|
||||
})
|
||||
|
||||
export const createOverrideBodySchema = z.object({
|
||||
cidr: z.string().min(1),
|
||||
cidr: canonicalCidrSchema,
|
||||
action: policyActionSchema,
|
||||
comment: z.string().nullable().optional(),
|
||||
reason: z.string().trim().min(1).max(1024).optional(),
|
||||
expires_at: z.string().datetime({ offset: true }).nullable().optional(),
|
||||
ttl_seconds: z.number().int().min(60).max(30 * 86_400).optional(),
|
||||
}).superRefine((body, ctx) => {
|
||||
if (body.expires_at !== undefined && body.ttl_seconds !== undefined) ctx.addIssue({ code: 'custom', message: 'Use expires_at or ttl_seconds, not both' })
|
||||
})
|
||||
|
||||
export const patchAgentBodySchema = z
|
||||
@@ -227,15 +241,15 @@ export const enrollBodySchema = z.object({
|
||||
})
|
||||
|
||||
export const applyReportIpHitSchema = z.object({
|
||||
ip: z.string().min(1).max(64),
|
||||
packets: z.number().int().nonnegative(),
|
||||
ip: canonicalIpSchema.or(canonicalCidrSchema),
|
||||
packets: safeCounterSchema,
|
||||
})
|
||||
|
||||
export const applyReportPortHitSchema = z.object({
|
||||
ip: z.string().min(1).max(64),
|
||||
ip: canonicalIpSchema,
|
||||
port: z.number().int().min(1).max(65535),
|
||||
protocol: z.enum(['tcp', 'udp']),
|
||||
packets: z.number().int().nonnegative(),
|
||||
packets: safeCounterSchema,
|
||||
})
|
||||
|
||||
export const hostFwOwnershipSchema = z.enum(['evofw', 'foreign'])
|
||||
@@ -280,13 +294,14 @@ export const applyReportHostFirewallSchema = z.object({
|
||||
listeners: z.array(z.unknown()).max(200).optional().default([]),
|
||||
})
|
||||
|
||||
export const applyReportBodySchema = z.object({
|
||||
const applyReportBaseSchema = z.object({
|
||||
status: z.string(),
|
||||
prefix_count: z.number().int().optional(),
|
||||
packets_dropped: z.number().int().optional(),
|
||||
packets_accepted: z.number().int().optional(),
|
||||
prefix_count: safeCounterSchema.optional(),
|
||||
packets_dropped: safeCounterSchema.optional(),
|
||||
packets_accepted: safeCounterSchema.optional(),
|
||||
kernel_method: z.string().optional(),
|
||||
error: z.string().optional(),
|
||||
error: z.string().max(2048).optional(),
|
||||
apply_error: applyErrorSchema.nullable().optional(),
|
||||
source: z.string().optional(),
|
||||
/** Linux nft/ipset per-element drop counters (top-N, packets > 0). */
|
||||
ip_hits: z.array(applyReportIpHitSchema).max(200).optional(),
|
||||
@@ -296,6 +311,21 @@ export const applyReportBodySchema = z.object({
|
||||
host_firewall: applyReportHostFirewallSchema.optional(),
|
||||
})
|
||||
|
||||
export const applyReportV2BodySchema = applyReportBaseSchema.extend(telemetryV2EnvelopeSchema.shape).superRefine((body, ctx) => {
|
||||
const counts = body.detail_counts
|
||||
if (counts && (counts.ip_sent > counts.ip_found || counts.port_sent > counts.port_found || counts.ip_sent !== (body.ip_hits?.length ?? 0) || counts.port_sent !== (body.port_hits?.length ?? 0))) {
|
||||
ctx.addIssue({ code: 'custom', message: 'detail_counts must match supplied hit arrays and found totals' })
|
||||
}
|
||||
})
|
||||
export const applyReportLegacyBodySchema = applyReportBaseSchema.extend({ telemetry_version: z.literal(1).optional() })
|
||||
export const applyReportBodySchema = z.union([applyReportV2BodySchema, applyReportLegacyBodySchema]).superRefine((body, ctx) => {
|
||||
const ipKeys = (body.ip_hits ?? []).map((hit) => hit.ip)
|
||||
const portKeys = (body.port_hits ?? []).map((hit) => `${hit.ip}:${hit.protocol}:${hit.port}`)
|
||||
if (new Set(ipKeys).size !== ipKeys.length || new Set(portKeys).size !== portKeys.length) ctx.addIssue({ code: 'custom', message: 'Duplicate telemetry hit key' })
|
||||
})
|
||||
export type ApplyReportBody = z.infer<typeof applyReportBodySchema>
|
||||
export type ApplyReportV2Body = z.infer<typeof applyReportV2BodySchema>
|
||||
|
||||
export const agentPortRuleActionSchema = z.enum(['open', 'close'])
|
||||
export const agentPortRuleProtocolSchema = z.enum(['tcp', 'udp', 'both'])
|
||||
export const agentPortRuleSrcKindSchema = z.enum(['all', 'cidr', 'list'])
|
||||
@@ -308,7 +338,7 @@ export const agentPortRuleSchema = z.object({
|
||||
port_start: z.number().int().min(1).max(65535),
|
||||
port_end: z.number().int().min(1).max(65535),
|
||||
src_kind: agentPortRuleSrcKindSchema,
|
||||
src_cidr: z.string().nullable().optional(),
|
||||
src_cidr: canonicalCidrSchema.nullable().optional(),
|
||||
list_id: z.string().nullable().optional(),
|
||||
list_name: z.string().nullable().optional(),
|
||||
enabled: z.boolean(),
|
||||
@@ -325,7 +355,7 @@ export const createAgentPortRuleBodySchema = z
|
||||
port_start: z.number().int().min(1).max(65535),
|
||||
port_end: z.number().int().min(1).max(65535).optional(),
|
||||
src_kind: agentPortRuleSrcKindSchema.default('all'),
|
||||
src_cidr: z.string().min(1).max(64).optional(),
|
||||
src_cidr: canonicalCidrSchema.optional(),
|
||||
list_id: z.string().min(1).optional(),
|
||||
enabled: z.boolean().optional().default(true),
|
||||
comment: z.string().max(500).optional(),
|
||||
@@ -363,7 +393,7 @@ export const updateAgentPortRuleBodySchema = z
|
||||
port_start: z.number().int().min(1).max(65535).optional(),
|
||||
port_end: z.number().int().min(1).max(65535).optional(),
|
||||
src_kind: agentPortRuleSrcKindSchema.optional(),
|
||||
src_cidr: z.string().min(1).max(64).nullable().optional(),
|
||||
src_cidr: canonicalCidrSchema.nullable().optional(),
|
||||
list_id: z.string().min(1).nullable().optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
comment: z.string().max(500).nullable().optional(),
|
||||
@@ -523,6 +553,8 @@ export const installLinkSchema = z.object({
|
||||
platform: agentPlatformSchema,
|
||||
agent_id: z.string().nullable().optional(),
|
||||
created_at: z.string(),
|
||||
expires_at: z.string().nullable(),
|
||||
consumed_at: z.string().nullable(),
|
||||
revoked_at: z.string().nullable().optional(),
|
||||
last_used_at: z.string().nullable().optional(),
|
||||
use_count: z.number().int(),
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export const enrollmentCredentialBodySchema = z.object({
|
||||
name: z.string().trim().min(1).max(255),
|
||||
hostname: z.string().max(255).optional(),
|
||||
platform: z.enum(['linux', 'mikrotik']).default('linux'),
|
||||
token: z.string().min(16).max(256),
|
||||
client_version: z.string().max(100).optional(),
|
||||
install_link_id: z.string().min(8).max(128),
|
||||
}).strict()
|
||||
export const tokenRotationExchangeBodySchema = z.object({
|
||||
request_id: z.string().uuid(),
|
||||
next_token: z.string().regex(/^evofw_[A-Za-z0-9_-]{32,128}$/),
|
||||
}).strict()
|
||||
export const credentialStatusSchema = z.object({
|
||||
client_id: z.string(),
|
||||
rotation: z.object({ request_id: z.string().uuid(), expires_at: z.string().datetime() }).nullable(),
|
||||
completed_rotation: z.object({ request_id: z.string().uuid() }).nullable(),
|
||||
})
|
||||
export const adminCredentialStatusSchema = z.object({
|
||||
agent_id: z.string(),
|
||||
supported: z.boolean(),
|
||||
client_version: z.string().nullable(),
|
||||
agent_status: z.enum(['invited', 'pending', 'approved', 'revoked']),
|
||||
rotation: credentialStatusSchema.shape.rotation,
|
||||
completed_rotation: credentialStatusSchema.shape.completed_rotation,
|
||||
})
|
||||
export type AdminCredentialStatus = z.infer<typeof adminCredentialStatusSchema>
|
||||
export const tokenRotationAckSchema = z.object({
|
||||
ok: z.literal(true), request_id: z.string().uuid(), client_id: z.string(),
|
||||
})
|
||||
export type EnrollmentCredentialBody = z.infer<typeof enrollmentCredentialBodySchema>
|
||||
export type TokenRotationExchangeBody = z.infer<typeof tokenRotationExchangeBodySchema>
|
||||
@@ -0,0 +1,30 @@
|
||||
import { z } from 'zod'
|
||||
import { canonicalIpSchema } from '../network.js'
|
||||
import { agentDiagnosticsSchema } from './telemetry.js'
|
||||
|
||||
export const explainQuerySchema = z.object({
|
||||
ip: canonicalIpSchema,
|
||||
protocol: z.enum(['tcp', 'udp']).optional(),
|
||||
port: z.coerce.number().int().min(1).max(65535).optional(),
|
||||
chain: z.enum(['input', 'forward']).default('input'),
|
||||
dnat: z.enum(['true', 'false']).default('false').transform((value) => value === 'true'),
|
||||
connection_state: z.enum(['new', 'established', 'related']).default('new'),
|
||||
}).superRefine((value, ctx) => {
|
||||
if ((value.port === undefined) !== (value.protocol === undefined)) ctx.addIssue({ code: 'custom', message: 'Supply port and protocol together' })
|
||||
})
|
||||
export const explainStageSchema = z.enum(['stateful', 'deny', 'port_close', 'port_open', 'port_implicit_close', 'allow', 'default', 'external', 'unsupported', 'transport_unknown'])
|
||||
export const explainMatchSchema = z.object({
|
||||
rule_id: z.string().nullable(), set_id: z.string().nullable(), list_id: z.string().nullable(), override_id: z.string().nullable(),
|
||||
cidr: z.string().nullable(), label: z.string(), stage: explainStageSchema,
|
||||
})
|
||||
export const explainResponseSchema = z.object({
|
||||
agent_id: z.string(), query: z.object({ ip: z.string(), protocol: z.enum(['tcp', 'udp']).nullable(), port: z.number().nullable(), chain: z.enum(['input', 'forward']), dnat: z.boolean(), connection_state: z.enum(['new', 'established', 'related']) }),
|
||||
desired: z.object({ revision: z.number().int(), hash: z.string(), decision: z.enum(['accept', 'drop']).nullable(), stage: explainStageSchema, match: explainMatchSchema.nullable() }),
|
||||
applied: z.object({ revision: z.number().nullable(), hash: z.string().nullable(), same_as_desired: z.boolean().nullable(), status: agentDiagnosticsSchema.shape.status }),
|
||||
matches: z.array(explainMatchSchema).max(100), matches_total: z.number().int(), truncated: z.boolean(),
|
||||
limits: z.array(z.string()),
|
||||
external_firewall: z.object({ observed_at: z.string().nullable(), rule_count: z.number().int(), complete: z.literal(false) }),
|
||||
})
|
||||
export type ExplainQuery = z.infer<typeof explainQuerySchema>
|
||||
export type ExplainMatch = z.infer<typeof explainMatchSchema>
|
||||
export type ExplainResponse = z.infer<typeof explainResponseSchema>
|
||||
@@ -0,0 +1,16 @@
|
||||
import { z } from 'zod'
|
||||
const id = z.string().min(1).max(128)
|
||||
const unique = (rows: string[]) => new Set(rows).size === rows.length
|
||||
const labelIds = z.array(id).max(20).refine(unique, 'Duplicate labels')
|
||||
export const agentLabelBodySchema = z.object({ name: z.string().trim().min(1).max(64), color: z.enum(['primary', 'success', 'warning', 'destructive', 'secondary', 'info']).nullable().optional() }).strict()
|
||||
export const patchAgentLabelSchema = agentLabelBodySchema.partial().refine((body) => Object.keys(body).length > 0, 'Supply a label change')
|
||||
export const putAgentLabelsSchema = z.object({ label_ids: labelIds }).strict()
|
||||
export const agentLabelFilterSchema = z.object({ label_ids: z.preprocess((value) => typeof value === 'string' ? value.split(',').filter(Boolean) : value, labelIds.optional()) }).passthrough()
|
||||
const selector = z.object({ agent_ids: z.array(id).min(1).max(100).refine(unique, 'Duplicate agents').optional(), label_ids: labelIds.refine((rows) => rows.length > 0).optional() }).strict()
|
||||
.refine((body) => Boolean(body.agent_ids) !== Boolean(body.label_ids), 'Choose agent IDs or label IDs')
|
||||
export const previewLabelAssignmentSchema = z.object({ selector, mode: z.enum(['add', 'replace']), set_ids: z.array(id).max(100).refine(unique, 'Duplicate sets') }).strict()
|
||||
export const applyLabelAssignmentSchema = previewLabelAssignmentSchema.extend({ expected: z.array(z.object({ agent_id: id, generation: z.number().int().positive().max(Number.MAX_SAFE_INTEGER) }).strict()).min(1).max(100).refine((rows) => unique(rows.map((row) => row.agent_id)), 'Duplicate expected agents') })
|
||||
export type PreviewLabelAssignment = z.infer<typeof previewLabelAssignmentSchema>
|
||||
export type ApplyLabelAssignment = z.infer<typeof applyLabelAssignmentSchema>
|
||||
export type AgentLabel = { id: string; name: string; color: string | null; created_at: string }
|
||||
export type LabelAssignmentPreview = { mode: 'add' | 'replace'; items: { agent_id: string; generation: number; previous_set_ids: string[]; set_ids: string[]; changed: boolean }[]; total: number }
|
||||
@@ -0,0 +1,26 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
/** Opt-in bounded POST /lists/:id/entries response; default retains full detail. */
|
||||
export const listImportQuerySchema = z.object({ return: z.literal('summary').optional() }).strict()
|
||||
export const listImportSummarySchema = z.object({ count: z.number().int().nonnegative(), content_hash: z.string().nullable(), changed: z.boolean() }).strict()
|
||||
export type ListImportSummary = z.infer<typeof listImportSummarySchema>
|
||||
const detailFilter = z.object({
|
||||
field: z.enum(['value', 'kind']),
|
||||
operator: z.enum(['is', 'is_not', 'is_any_of', 'is_not_any_of', 'contains', 'not_contains', 'starts_with', 'ends_with', 'empty', 'not_empty']),
|
||||
values: z.array(z.string().max(256)).max(20),
|
||||
}).passthrough()
|
||||
export const listDetailPageQuerySchema = z.object({
|
||||
view: z.literal('page'), limit: z.coerce.number().int().min(1).max(200).default(100),
|
||||
offset: z.coerce.number().int().min(0).max(10_000_000).default(0), q: z.string().max(256).default(''),
|
||||
filters: z.preprocess((value) => {
|
||||
if (typeof value !== 'string') return value
|
||||
try { return JSON.parse(value) } catch { return value }
|
||||
}, z.array(detailFilter).max(10)).default([]),
|
||||
}).strict()
|
||||
export type ListDetailPageQuery = z.infer<typeof listDetailPageQuerySchema>
|
||||
export type ListDetailPageItem = { kind: 'ip' | 'cidr' | 'hostname' | 'list'; value: string; list_name: string | null; resolved_count: number; resolved_cidrs: string[]; resolved_truncated: boolean }
|
||||
export type ListDetailPage = {
|
||||
id: string; name: string; type: 'static' | 'domains' | 'json_url' | 'evobgp_community'; config_json: string
|
||||
content_hash: string | null; refreshed_at: string | null; last_error: string | null; entry_count: number; created_at: string; updated_at: string
|
||||
total: number; limit: number; offset: number; items: ListDetailPageItem[]; entries: string[]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { z } from 'zod'
|
||||
import { POLICY_BUDGET_ERROR } from '../policy-budget.js'
|
||||
|
||||
export const agentPolicyQuerySchema = z.object({ transport: z.literal('routeros').optional() })
|
||||
/** RouterOS fetch cannot read non-2xx bodies. This opt-in envelope is never a policy. */
|
||||
export const policyBudgetErrorEnvelopeSchema = z.object({ error: z.object({ code: z.literal(POLICY_BUDGET_ERROR), message: z.string().max(512) }) })
|
||||
export type PolicyBudgetErrorEnvelope = z.infer<typeof policyBudgetErrorEnvelopeSchema>
|
||||
@@ -0,0 +1,37 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
const id = z.string().min(1).max(128)
|
||||
const ids = z.array(id).min(1).max(100).refine((rows) => new Set(rows).size === rows.length, 'Duplicate IDs')
|
||||
export const createPolicySnapshotSchema = z.object({ source_agent_id: id, name: z.string().trim().min(1).max(120) }).strict()
|
||||
export const policySnapshotDiffSchema = z.object({ agent_ids: ids }).strict()
|
||||
export const policyPageSchema = z.object({ limit: z.coerce.number().int().min(1).max(50).default(20), offset: z.coerce.number().int().min(0).max(10_000).default(0) })
|
||||
export const createPolicyRolloutSchema = z.object({
|
||||
snapshot_id: id,
|
||||
targets: z.array(z.object({ agent_id: id, expected_generation: z.number().int().positive().max(Number.MAX_SAFE_INTEGER) }).strict()).min(1).max(100)
|
||||
.refine((rows) => new Set(rows.map((row) => row.agent_id)).size === rows.length, 'Duplicate targets'),
|
||||
canary_ids: ids,
|
||||
}).strict().refine((body) => body.canary_ids.every((value) => body.targets.some((row) => row.agent_id === value)), 'Canaries must be targets')
|
||||
export const policyRolloutTransitionSchema = z.object({ expected_version: z.number().int().positive().max(Number.MAX_SAFE_INTEGER) }).strict()
|
||||
export const releasePolicyPinSchema = z.object({ rollout_id: id, expected_generation: z.number().int().positive().max(Number.MAX_SAFE_INTEGER) }).strict()
|
||||
export type CreatePolicyRollout = z.infer<typeof createPolicyRolloutSchema>
|
||||
export type PolicySnapshotSummary = {
|
||||
id: string; name: string; source_agent_id: string | null; source_generation: number; hash: string
|
||||
content_hash: string; payload_bytes: number; created_at: string
|
||||
}
|
||||
export type PolicySnapshot = PolicySnapshotSummary & { payload: {
|
||||
hash: string; applyVersion: number; defaultAction: 'accept' | 'drop'; policyMode: 'blacklist' | 'whitelist'
|
||||
denyCidrs: string[]; allowCidrs: string[]
|
||||
portRules: { id: string; action: 'open' | 'close'; protocol: 'tcp' | 'udp'; portStart: number; portEnd: number; srcCidrs: string[] }[]
|
||||
conflictsDropped: number; chain: { setId: string | null; setName: string | null; ruleId: string | null; action: 'allow' | 'deny'; sourceKind: 'list' | 'cidr' | 'hostname' | 'override'; sourceLabel: string; cidrCount: number }[]
|
||||
summary: { sets: number; rulesDeny: number; rulesAllow: number; cidrsDeny: number; cidrsAllow: number; overrides: number; conflictsDropped: number; portRules: number }
|
||||
} }
|
||||
export type PolicySnapshotDiff = { snapshot_id: string; items: {
|
||||
agent_id: string; expected_generation: number; before_hash: string; candidate_hash: string; added: number; removed: number
|
||||
examples: { change: string; cidr: string }[]; truncated: boolean; default_action_changed: boolean; port_rules_changed: boolean; conflict: string | null
|
||||
}[] }
|
||||
export type PolicyRolloutTarget = {
|
||||
agent_id: string; is_canary: boolean; phase: 'pending' | 'candidate' | 'rollback' | 'released'
|
||||
desired_revision: number | null; desired_hash: string | null; applied_revision: number | null; applied_hash: string | null
|
||||
acknowledged: boolean; previous_desired_snapshot_id: string
|
||||
}
|
||||
export type PolicyRollout = { id: string; snapshot_id: string; state: 'canary' | 'confirmed' | 'rolled_back'; version: number; created_at: string; updated_at: string; targets: PolicyRolloutTarget[] }
|
||||
@@ -0,0 +1,57 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export const safeCounterSchema = z.number().int().min(0).max(Number.MAX_SAFE_INTEGER)
|
||||
export const applyErrorSchema = z.object({
|
||||
code: z.string().min(1).max(128),
|
||||
message: z.string().min(1).max(2048),
|
||||
stage: z.string().max(128).optional(),
|
||||
retryable: z.boolean().optional(),
|
||||
})
|
||||
export const telemetryQualitySchema = z.object({
|
||||
partial: z.boolean(),
|
||||
truncated: z.boolean(),
|
||||
unsupported: z.array(z.string().min(1).max(128)).max(64),
|
||||
/** Losses since the preceding durable enqueue. Retries retain this immutable interval count. */
|
||||
lost_reports: safeCounterSchema,
|
||||
})
|
||||
export const telemetryV2EnvelopeSchema = z.object({
|
||||
telemetry_version: z.literal(2),
|
||||
report_id: z.string().min(1).max(128),
|
||||
sequence: safeCounterSchema,
|
||||
boot_id: z.string().min(1).max(128),
|
||||
observed_at: z.string().datetime({ offset: true }),
|
||||
interval_ms: safeCounterSchema.refine((value) => value > 0, 'interval_ms must be positive'),
|
||||
counter_epoch: z.object({
|
||||
traffic: z.string().min(1).max(128),
|
||||
ip: z.string().min(1).max(128).optional(),
|
||||
port: z.string().min(1).max(128).optional(),
|
||||
}),
|
||||
applied_hash: z.string().regex(/^sha256:[0-9a-f]{64}$/).optional(),
|
||||
applied_revision: safeCounterSchema.optional(),
|
||||
agent_version: z.string().min(1).max(128).optional(),
|
||||
capabilities: z.array(z.string().min(1).max(128)).max(64),
|
||||
quality: telemetryQualitySchema,
|
||||
detail_counts: z.object({
|
||||
ip_found: safeCounterSchema, ip_sent: safeCounterSchema.max(200),
|
||||
port_found: safeCounterSchema, port_sent: safeCounterSchema.max(500),
|
||||
}).optional(),
|
||||
})
|
||||
|
||||
export const agentDiagnosticsSchema = z.object({
|
||||
desired_revision: safeCounterSchema,
|
||||
desired_hash: z.string().nullable(),
|
||||
applied_revision: safeCounterSchema.nullable(),
|
||||
applied_hash: z.string().nullable(),
|
||||
agent_version: z.string().nullable(),
|
||||
capabilities: z.array(z.string()),
|
||||
last_contact_at: z.string().nullable(),
|
||||
last_telemetry_at: z.string().nullable(),
|
||||
last_apply_at: z.string().nullable(),
|
||||
apply_error: applyErrorSchema.nullable(),
|
||||
status: z.enum(['pending', 'applying', 'in_sync', 'failed', 'drift', 'unsupported']),
|
||||
telemetry_version: z.number().nullable(),
|
||||
quality: telemetryQualitySchema,
|
||||
detail_counts: z.object({ ip_found: safeCounterSchema, ip_sent: safeCounterSchema, port_found: safeCounterSchema, port_sent: safeCounterSchema }).nullable().optional(),
|
||||
})
|
||||
export type AgentDiagnostics = z.infer<typeof agentDiagnosticsSchema>
|
||||
export type TelemetryQuality = z.infer<typeof telemetryQualitySchema>
|
||||
@@ -0,0 +1,20 @@
|
||||
import { z } from 'zod'
|
||||
import { telemetryQualitySchema } from './telemetry.js'
|
||||
|
||||
export const statsResolutionSchema = z.enum(['1m', '1h', '1d'])
|
||||
export const timeseriesPointSchema = z.object({
|
||||
timestamp: z.string().datetime(),
|
||||
packets_dropped: z.number().nonnegative().nullable(),
|
||||
packets_accepted: z.number().nonnegative().nullable(),
|
||||
pps: z.number().nonnegative().nullable(),
|
||||
coverage: z.number().min(0).max(1),
|
||||
estimated: z.boolean(),
|
||||
quality: telemetryQualitySchema,
|
||||
})
|
||||
export const timeseriesResponseSchema = z.object({
|
||||
from: z.string().datetime(), to: z.string().datetime(), resolution: statsResolutionSchema,
|
||||
items: z.array(timeseriesPointSchema).max(1440), agent_ids: z.array(z.string()).max(100),
|
||||
})
|
||||
export type StatsResolution = z.infer<typeof statsResolutionSchema>
|
||||
export type TimeseriesPoint = z.infer<typeof timeseriesPointSchema>
|
||||
export type TimeseriesResponse = z.infer<typeof timeseriesResponseSchema>
|
||||
@@ -0,0 +1,14 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
const count = z.number().int().nonnegative()
|
||||
export const uiSummarySchema = z.object({
|
||||
updated_at: z.string().datetime(),
|
||||
dashboard: z.object({
|
||||
agents_total: count, agents_approved: count, agents_online: count, agents_pending: count,
|
||||
packets_dropped: count, packets_accepted: count, lists_total: count,
|
||||
}).optional(),
|
||||
agents: z.object({ approved: count, online: count, pending: count, offline: count }).optional(),
|
||||
lists: z.object({ total: count, failed: count }).optional(),
|
||||
policies: z.object({ sets_total: count, rules_total: count }).optional(),
|
||||
})
|
||||
export type UiSummary = z.infer<typeof uiSummarySchema>
|
||||
@@ -0,0 +1,16 @@
|
||||
import { z } from 'zod'
|
||||
export const webhookEventTypeSchema = z.enum(['agent.offline', 'agent.apply_failed', 'agent.drift', 'source.error'])
|
||||
export const webhookBodySchema = z.object({
|
||||
name: z.string().trim().min(1).max(100),
|
||||
url: z.string().url().max(2048),
|
||||
secret: z.string().min(32).max(256),
|
||||
events: z.array(webhookEventTypeSchema).min(1).max(4),
|
||||
enabled: z.boolean().default(true),
|
||||
}).strict()
|
||||
export const webhookPatchSchema = webhookBodySchema.partial().strict()
|
||||
export type WebhookBody = z.infer<typeof webhookBodySchema>
|
||||
export type WebhookEventType = z.infer<typeof webhookEventTypeSchema>
|
||||
export interface WebhookEndpoint { id: string; name: string; url: string; enabled: boolean; events: WebhookEventType[]; secret_configured: boolean; created_at: string; updated_at: string }
|
||||
export interface WebhookDelivery { id: string; event_type: WebhookEventType; resource_id: string; status: 'pending' | 'dead'; attempts: number; next_attempt_at: string; created_at: string; last_error: string | null }
|
||||
export interface WebhooksResponse { items: WebhookEndpoint[]; configured: boolean; limits: { endpoints: number; pending: number }; queue: { pending: number; dead: number; dropped: number } }
|
||||
export interface WebhookDeliveriesResponse { items: WebhookDelivery[]; total: number }
|
||||
@@ -1,4 +1,16 @@
|
||||
export * from './network.js'
|
||||
export * from './policy-budget.js'
|
||||
export * from './contracts/policy-response.js'
|
||||
export * from './contracts/enrollment.js'
|
||||
export * from './contracts/rollouts.js'
|
||||
export * from './contracts/webhooks.js'
|
||||
export * from './contracts/labels.js'
|
||||
export * from './contracts/list-import.js'
|
||||
export * from './contracts.js'
|
||||
export * from './contracts/telemetry.js'
|
||||
export * from './contracts/ui-summary.js'
|
||||
export * from './contracts/timeseries.js'
|
||||
export * from './contracts/explain.js'
|
||||
export * from './contracts/audit.js'
|
||||
export * from './list-entries.js'
|
||||
export * from './permissions.js'
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { z } from 'zod'
|
||||
import { canonicalIp, canonicalCidr, canonicalHostname } from './network.js'
|
||||
|
||||
/** DB type codes — never show raw in UI. */
|
||||
export const IP_LIST_SOURCE_LABELS = {
|
||||
@@ -38,45 +39,12 @@ export const listEntryKindLabels: Record<ListEntryKind, string> = {
|
||||
list: 'Вложенный список',
|
||||
}
|
||||
|
||||
const IPV4 =
|
||||
/^(?:(?:25[0-5]|2[0-4]\d|1?\d?\d)\.){3}(?:25[0-5]|2[0-4]\d|1?\d?\d)$/
|
||||
const IPV4_CIDR =
|
||||
/^(?:(?:25[0-5]|2[0-4]\d|1?\d?\d)\.){3}(?:25[0-5]|2[0-4]\d|1?\d?\d)\/(?:3[0-2]|[12]?\d)$/
|
||||
const IPV6 =
|
||||
/^(?:[0-9a-fA-F]{0,4}:){2,7}[0-9a-fA-F]{0,4}$|^::(?:[0-9a-fA-F]{0,4}:){0,6}[0-9a-fA-F]{0,4}$|^(?:[0-9a-fA-F]{0,4}:){1,7}:$/
|
||||
const IPV6_CIDR =
|
||||
/^(.+)\/(?:12[0-8]|1[01]\d|[1-9]?\d)$/
|
||||
const HOSTNAME =
|
||||
/^(?=.{1,253}$)(?!-)[a-zA-Z0-9-]{1,63}(?<!-)(\.(?!-)[a-zA-Z0-9-]{1,63}(?<!-))+$/
|
||||
|
||||
export function parseListEntry(raw: string): ListConfigItem {
|
||||
const value = raw.trim().replace(/\.$/, '')
|
||||
const value = raw.trim()
|
||||
if (!value) throw new Error('Пустая строка')
|
||||
|
||||
if (IPV4_CIDR.test(value)) {
|
||||
return { kind: 'cidr', value }
|
||||
}
|
||||
if (IPV4.test(value)) {
|
||||
return { kind: 'ip', value }
|
||||
}
|
||||
|
||||
const v6Cidr = IPV6_CIDR.exec(value)
|
||||
if (v6Cidr && IPV6.test(v6Cidr[1]!)) {
|
||||
return { kind: 'cidr', value }
|
||||
}
|
||||
if (IPV6.test(value) && !value.includes('/')) {
|
||||
return { kind: 'ip', value }
|
||||
}
|
||||
|
||||
const host = value.toLowerCase()
|
||||
if (HOSTNAME.test(host) || (host.includes('.') && !host.includes(' '))) {
|
||||
if (/^https?:\/\//i.test(host)) {
|
||||
throw new Error('URL не является записью списка — создайте список «JSON по URL»')
|
||||
}
|
||||
return { kind: 'hostname', value: host }
|
||||
}
|
||||
|
||||
throw new Error(`Не удалось распознать: ${raw.trim()}`)
|
||||
if (value.includes('/')) return { kind: 'cidr', value: canonicalCidr(value) }
|
||||
try { return { kind: 'ip', value: canonicalIp(value) } } catch { /* try hostname */ }
|
||||
return { kind: 'hostname', value: canonicalHostname(value) }
|
||||
}
|
||||
|
||||
/** Split plaintext paste into non-empty lines / tokens. */
|
||||
@@ -87,18 +55,9 @@ export function splitListPlaintext(text: string): string[] {
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
export function normalizeItemToCidrs(
|
||||
item: ListConfigItem,
|
||||
resolvedHostCidrs?: string[],
|
||||
): string[] {
|
||||
if (item.kind === 'ip') {
|
||||
return [item.value.includes(':') ? `${item.value}/128` : `${item.value}/32`]
|
||||
}
|
||||
if (item.kind === 'cidr') {
|
||||
return [item.value]
|
||||
}
|
||||
// hostname | list — CIDRs come from resolution / nested list materialization
|
||||
return resolvedHostCidrs ?? item.resolved_cidrs ?? []
|
||||
export function normalizeItemToCidrs(item: ListConfigItem, resolvedHostCidrs?: string[]): string[] {
|
||||
if (item.kind === 'ip' || item.kind === 'cidr') return [canonicalCidr(item.value)]
|
||||
return (resolvedHostCidrs ?? item.resolved_cidrs ?? []).map(canonicalCidr)
|
||||
}
|
||||
|
||||
export function readManualItems(
|
||||
@@ -159,9 +118,9 @@ export type ListEntryInput = z.infer<typeof listEntryInputSchema>
|
||||
export const listEntriesBodySchema = z
|
||||
.object({
|
||||
/** Legacy plaintext tokens (auto-classified; not for kind=list). */
|
||||
values: z.array(z.string().min(1)).optional(),
|
||||
values: z.array(z.string().min(1)).max(100_000).optional(),
|
||||
/** Structured entries including nested list refs. */
|
||||
items: z.array(listEntryInputSchema).optional(),
|
||||
items: z.array(listEntryInputSchema).max(100_000).optional(),
|
||||
})
|
||||
.superRefine((body, ctx) => {
|
||||
const hasValues = Boolean(body.values?.length)
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { canonicalIp, canonicalCidr } from './network.js'
|
||||
import { parseListEntry } from './list-entries.js'
|
||||
|
||||
describe('canonical networks', () => {
|
||||
it('masks host bits, compresses IPv6 and handles mapped IPv4', () => {
|
||||
expect(canonicalCidr('192.0.2.129/24')).toBe('192.0.2.0/24')
|
||||
expect(canonicalCidr('2001:0DB8:0000:0000:0000:1:2:3/64')).toBe('2001:db8::/64')
|
||||
expect(canonicalIp('::FFFF:192.0.2.1')).toBe('::ffff:c000:201')
|
||||
expect(canonicalCidr('::')).toBe('::/128')
|
||||
})
|
||||
it.each(['1:2:3', '1::2::3', '1:2:3:4:5:6:7:8:9', ':::1', '192.168.001.1', '300.1.1.1', '::1%eth0', '::1/129', '1.2.3.4/33', '1.2.3.4/-1', '1.2.3.4/'])('rejects invalid %s', (value) => {
|
||||
expect(() => canonicalCidr(value)).toThrow()
|
||||
expect(() => parseListEntry(value)).toThrow()
|
||||
})
|
||||
it('rejects arbitrary dotted text masquerading as a hostname', () => {
|
||||
expect(() => parseListEntry('invalid/path.example')).toThrow()
|
||||
expect(() => parseListEntry('https://example.com')).toThrow()
|
||||
expect(parseListEntry('Example.COM.')).toEqual({ kind: 'hostname', value: 'example.com' })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,92 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
function ipv4Words(value: string): number[] | null {
|
||||
const parts = value.split('.')
|
||||
if (parts.length !== 4 || parts.some((p) => !/^(0|[1-9]\d{0,2})$/.test(p) || Number(p) > 255)) return null
|
||||
return parts.map(Number)
|
||||
}
|
||||
|
||||
function parseAddress(value: string): { bits: 32 | 128; value: bigint } {
|
||||
const v4 = ipv4Words(value)
|
||||
if (v4) return { bits: 32, value: v4.reduce((n, p) => (n << 8n) | BigInt(p), 0n) }
|
||||
if (!value.includes(':') || value.includes('%') || value.includes(':::')) throw new Error('Invalid IP address')
|
||||
let text = value.toLowerCase()
|
||||
if (text.includes('.')) {
|
||||
const lastColon = text.lastIndexOf(':')
|
||||
const tail = ipv4Words(text.slice(lastColon + 1))
|
||||
if (!tail) throw new Error('Invalid IPv4-mapped IPv6 address')
|
||||
text = `${text.slice(0, lastColon)}:${((tail[0]! << 8) | tail[1]!).toString(16)}:${((tail[2]! << 8) | tail[3]!).toString(16)}`
|
||||
}
|
||||
const halves = text.split('::')
|
||||
if (halves.length > 2) throw new Error('Invalid IPv6 address')
|
||||
const left = halves[0] ? halves[0].split(':') : []
|
||||
const right = halves.length === 2 && halves[1] ? halves[1].split(':') : []
|
||||
if ([...left, ...right].some((p) => !/^[0-9a-f]{1,4}$/.test(p))) throw new Error('Invalid IPv6 address')
|
||||
const count = left.length + right.length
|
||||
if ((halves.length === 1 && count !== 8) || (halves.length === 2 && count >= 8)) throw new Error('Invalid IPv6 address')
|
||||
const words = halves.length === 2 ? [...left, ...Array<string>(8 - count).fill('0'), ...right] : left
|
||||
return { bits: 128, value: words.reduce((n, p) => (n << 16n) | BigInt(parseInt(p, 16)), 0n) }
|
||||
}
|
||||
|
||||
function formatAddress(value: bigint, bits: 32 | 128): string {
|
||||
if (bits === 32) return [24n, 16n, 8n, 0n].map((shift) => String((value >> shift) & 255n)).join('.')
|
||||
const words = Array.from({ length: 8 }, (_, i) => Number((value >> BigInt((7 - i) * 16)) & 65535n).toString(16))
|
||||
let start = -1, length = 1
|
||||
for (let i = 0; i < words.length;) {
|
||||
if (words[i] !== '0') { i++; continue }
|
||||
let end = i
|
||||
while (end < words.length && words[end] === '0') end++
|
||||
if (end - i > length) { start = i; length = end - i }
|
||||
i = end
|
||||
}
|
||||
if (start < 0) return words.join(':')
|
||||
return `${words.slice(0, start).join(':')}::${words.slice(start + length).join(':')}`
|
||||
}
|
||||
|
||||
/** Strict IP parsing, RFC 5952 IPv6 spelling, and network-bit masking. */
|
||||
export function canonicalIp(value: string): string {
|
||||
const parsed = parseAddress(value.trim())
|
||||
return formatAddress(parsed.value, parsed.bits)
|
||||
}
|
||||
|
||||
/** Bare IP addresses become /32 or /128. Invalid input always throws. */
|
||||
export function canonicalCidr(value: string): string {
|
||||
const parts = value.trim().split('/')
|
||||
if (parts.length > 2) throw new Error('Invalid CIDR')
|
||||
const parsed = parseAddress(parts[0]!)
|
||||
const prefix = parts.length === 1 ? parsed.bits : Number(parts[1])
|
||||
if (parts.length === 2 && !/^(0|[1-9]\d{0,2})$/.test(parts[1]!)) throw new Error('Invalid CIDR prefix')
|
||||
if (!Number.isInteger(prefix) || prefix < 0 || prefix > parsed.bits) throw new Error('Invalid CIDR prefix')
|
||||
const shift = BigInt(parsed.bits - prefix)
|
||||
return `${formatAddress((parsed.value >> shift) << shift, parsed.bits)}/${prefix}`
|
||||
}
|
||||
|
||||
/** Canonical evaluator primitive shared by policy explain and network input. */
|
||||
export function cidrContains(ip: string, cidr: string): boolean {
|
||||
const address = parseAddress(canonicalIp(ip))
|
||||
const [network, prefix] = canonicalCidr(cidr).split('/')
|
||||
const parsed = parseAddress(network!)
|
||||
if (parsed.bits !== address.bits) return false
|
||||
const shift = BigInt(parsed.bits - Number(prefix))
|
||||
return (parsed.value >> shift) === (address.value >> shift)
|
||||
}
|
||||
|
||||
export const canonicalCidrSchema = z.string().transform((value, ctx) => {
|
||||
try { return canonicalCidr(value) } catch {
|
||||
ctx.addIssue({ code: 'custom', message: 'Некорректный IP или CIDR' })
|
||||
return z.NEVER
|
||||
}
|
||||
})
|
||||
|
||||
export const canonicalIpSchema = z.string().transform((value, ctx) => {
|
||||
try { return canonicalIp(value) } catch {
|
||||
ctx.addIssue({ code: 'custom', message: 'Некорректный IP' })
|
||||
return z.NEVER
|
||||
}
|
||||
})
|
||||
|
||||
export function canonicalHostname(value: string): string {
|
||||
const host = value.trim().replace(/\.$/, '').toLowerCase()
|
||||
if (host.length > 253 || !host.includes('.') || host.split('.').some((p) => !/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(p)) || /^[\d.]+$/.test(host)) throw new Error('Invalid hostname')
|
||||
return host
|
||||
}
|
||||
@@ -28,36 +28,44 @@ export function permissionForRequest(
|
||||
const path = url.split('?')[0] ?? url
|
||||
const m = method.toUpperCase()
|
||||
const write = m !== 'GET' && m !== 'HEAD' && m !== 'OPTIONS'
|
||||
const under = (base: string) => path === base || path.startsWith(`${base}/`)
|
||||
if (path === '/api/v1/diagnostics') return 'fw:settings:admin'
|
||||
if (under('/api/v1/webhooks')) return 'fw:settings:admin'
|
||||
if (path === '/api/v1/agent-labels/assign-policy-sets') return 'fw:policies:write'
|
||||
if (path === '/api/v1/agent-labels/preview-assignment') return 'fw:policies:read'
|
||||
if (m === 'POST' && /^\/api\/v1\/policies\/snapshots\/[^/]+\/diff$/.test(path)) return 'fw:policies:read'
|
||||
if (under('/api/v1/agent-labels')) return write ? 'fw:agents:write' : 'fw:agents:read'
|
||||
|
||||
// Agent statistics live under /agents/:id/… — classify as stats, not agents.
|
||||
if (
|
||||
/^\/api\/v1\/agents\/[^/]+\/(stats|blocked-ips|blocked-ports)$/.test(path)
|
||||
/^\/api\/v1\/agents\/[^/]+\/(stats(?:\/raw)?|blocked-ips|blocked-ports)$/.test(path)
|
||||
) {
|
||||
return 'fw:stats:read'
|
||||
}
|
||||
if (path.startsWith('/api/v1/agents') || path.startsWith('/api/v1/install-links')) {
|
||||
if (under('/api/v1/agents') || under('/api/v1/install-links')) {
|
||||
return write ? 'fw:agents:write' : 'fw:agents:read'
|
||||
}
|
||||
if (path.startsWith('/api/v1/lists')) {
|
||||
if (under('/api/v1/lists')) {
|
||||
return write ? 'fw:lists:write' : 'fw:lists:read'
|
||||
}
|
||||
if (
|
||||
path.startsWith('/api/v1/rules') ||
|
||||
path.startsWith('/api/v1/policies') ||
|
||||
path.startsWith('/api/v1/policy-sets')
|
||||
under('/api/v1/rules') ||
|
||||
under('/api/v1/policies') ||
|
||||
under('/api/v1/policy-sets')
|
||||
) {
|
||||
return write ? 'fw:policies:write' : 'fw:policies:read'
|
||||
}
|
||||
if (path.startsWith('/api/v1/stats') || path.startsWith('/api/v1/dashboard')) {
|
||||
if (under('/api/v1/stats')) {
|
||||
return 'fw:stats:read'
|
||||
}
|
||||
if (path.startsWith('/api/v1/integrations')) {
|
||||
if (under('/api/v1/dashboard')) return 'fw:dashboard:read'
|
||||
if (under('/api/v1/integrations')) {
|
||||
return write ? 'fw:settings:admin' : 'fw:lists:read'
|
||||
}
|
||||
if (path.startsWith('/api/v1/settings') || path.startsWith('/api/v1/install-context')) {
|
||||
if (under('/api/v1/settings') || under('/api/v1/install-context')) {
|
||||
return write ? 'fw:settings:admin' : 'fw:settings:read'
|
||||
}
|
||||
if (path.startsWith('/api/v1/audit')) {
|
||||
if (under('/api/v1/audit')) {
|
||||
return 'fw:audit:read'
|
||||
}
|
||||
return null
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
/** Controller and agent limits; imported source lists may be larger than a compiled policy. */
|
||||
export const POLICY_BUDGET = Object.freeze({ effectiveMemberships: 50_000, rawMemberships: 200_000, serializedRules: 1_000, jsonBytes: 8 * 1024 * 1024 })
|
||||
export const POLICY_BUDGET_ERROR = 'POLICY_BUDGET_EXCEEDED' as const
|
||||
Reference in new issue
Block a user