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

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:
Denozordec committed 2026-10-26 13:10:58 +07:00
1 parent aa475848fd
commit 9fa6269174
242 files changed
+19872 -3057

No files matched your search

@@ -0,0 +1,41 @@
-- Baselines and replay watermark outlive raw sample retention and UI resets.
CREATE TABLE agent_telemetry_state (
agent_id TEXT PRIMARY KEY REFERENCES agents(id) ON DELETE CASCADE,
sequence INTEGER NOT NULL,
report_id TEXT NOT NULL,
boot_id TEXT NOT NULL,
traffic_epoch TEXT NOT NULL,
observed_at TEXT NOT NULL,
received_at TEXT NOT NULL,
dropped_baseline INTEGER,
accepted_baseline INTEGER
);
ALTER TABLE agents ADD COLUMN telemetry_version INTEGER;
ALTER TABLE agents ADD COLUMN last_telemetry_at TEXT;
ALTER TABLE agents ADD COLUMN applied_hash TEXT;
ALTER TABLE agents ADD COLUMN applied_revision INTEGER;
ALTER TABLE agents ADD COLUMN desired_hash TEXT;
ALTER TABLE agents ADD COLUMN desired_hash_generation INTEGER;
ALTER TABLE agents ADD COLUMN last_policy_delivered_at TEXT;
ALTER TABLE agents ADD COLUMN capabilities_json TEXT NOT NULL DEFAULT '[]';
ALTER TABLE agents ADD COLUMN quality_json TEXT NOT NULL DEFAULT '{"partial":true,"truncated":false,"unsupported":["telemetry_v2"],"lost_reports":0}';
ALTER TABLE agents ADD COLUMN apply_error_json TEXT;
ALTER TABLE agents ADD COLUMN detail_counts_json TEXT;
ALTER TABLE agent_stats_samples ADD COLUMN telemetry_version INTEGER NOT NULL DEFAULT 1;
ALTER TABLE agent_stats_samples ADD COLUMN report_id TEXT;
ALTER TABLE agent_stats_samples ADD COLUMN sequence INTEGER;
ALTER TABLE agent_stats_samples ADD COLUMN observed_at TEXT;
ALTER TABLE agent_stats_samples ADD COLUMN interval_ms INTEGER;
ALTER TABLE agent_stats_samples ADD COLUMN delta_dropped INTEGER;
ALTER TABLE agent_stats_samples ADD COLUMN delta_accepted INTEGER;
ALTER TABLE agent_stats_samples ADD COLUMN quality_json TEXT;
ALTER TABLE agent_stats_samples ADD COLUMN detail_counts_json TEXT;
CREATE UNIQUE INDEX idx_stats_agent_report ON agent_stats_samples(agent_id, report_id);
ALTER TABLE agent_ip_block_stats ADD COLUMN counter_epoch TEXT;
ALTER TABLE agent_port_block_stats ADD COLUMN counter_epoch TEXT;
CREATE INDEX idx_ip_stats_last_seen ON agent_ip_block_stats(last_seen_at);
CREATE INDEX idx_port_stats_last_seen ON agent_port_block_stats(last_seen_at);
@@ -0,0 +1,27 @@
ALTER TABLE agent_telemetry_state ADD COLUMN dropped_baseline_at TEXT;
ALTER TABLE agent_telemetry_state ADD COLUMN accepted_baseline_at TEXT;
ALTER TABLE agent_stats_samples ADD COLUMN dropped_interval_start TEXT;
ALTER TABLE agent_stats_samples ADD COLUMN accepted_interval_start TEXT;
ALTER TABLE agent_stats_samples ADD COLUMN dropped_present INTEGER;
ALTER TABLE agent_stats_samples ADD COLUMN accepted_present INTEGER;
ALTER TABLE agent_stats_samples ADD COLUMN analytics_processed INTEGER NOT NULL DEFAULT 0;
CREATE INDEX idx_stats_agent_cursor ON agent_stats_samples(agent_id, recorded_at, id);
CREATE INDEX idx_stats_recorded_at ON agent_stats_samples(recorded_at);
CREATE INDEX idx_stats_analytics_pending ON agent_stats_samples(analytics_processed, recorded_at, id);
CREATE TABLE agent_stats_rollups (
agent_id TEXT NOT NULL REFERENCES agents(id) ON DELETE CASCADE,
resolution TEXT NOT NULL CHECK(resolution IN ('1m','1h','1d')),
bucket_start TEXT NOT NULL,
packets_dropped REAL NOT NULL DEFAULT 0,
packets_accepted REAL NOT NULL DEFAULT 0,
dropped_coverage_ms INTEGER NOT NULL DEFAULT 0,
accepted_coverage_ms INTEGER NOT NULL DEFAULT 0,
partial INTEGER NOT NULL DEFAULT 0,
truncated INTEGER NOT NULL DEFAULT 0,
estimated INTEGER NOT NULL DEFAULT 0,
unsupported_json TEXT NOT NULL DEFAULT '[]',
lost_reports INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY(agent_id, resolution, bucket_start)
);
CREATE INDEX idx_stats_rollups_resolution_time ON agent_stats_rollups(resolution, bucket_start);
@@ -0,0 +1,9 @@
CREATE TABLE audit_outbox (
event_id TEXT PRIMARY KEY NOT NULL,
payload_json TEXT NOT NULL CHECK(length(payload_json) <= 16384),
attempts INTEGER NOT NULL DEFAULT 0 CHECK(attempts >= 0),
next_attempt_ms INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
last_error TEXT
);
CREATE INDEX idx_audit_outbox_due ON audit_outbox(next_attempt_ms, created_at);
@@ -0,0 +1,5 @@
ALTER TABLE ip_overrides ADD COLUMN reason TEXT;
ALTER TABLE ip_overrides ADD COLUMN created_by_name TEXT;
ALTER TABLE ip_overrides ADD COLUMN expires_at TEXT;
ALTER TABLE ip_overrides ADD COLUMN expired_at TEXT;
CREATE INDEX idx_overrides_agent_expiry ON ip_overrides(agent_id, expired_at, expires_at);
+22
View File
@@ -0,0 +1,22 @@
CREATE TABLE webhook_endpoints (
id TEXT PRIMARY KEY, name TEXT NOT NULL, url TEXT NOT NULL,
secret_cipher TEXT NOT NULL, enabled INTEGER NOT NULL DEFAULT 1,
generation INTEGER NOT NULL DEFAULT 1,
events_json TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL
);
CREATE TABLE webhook_deliveries (
id TEXT PRIMARY KEY, endpoint_id TEXT NOT NULL REFERENCES webhook_endpoints(id) ON DELETE CASCADE,
event_type TEXT NOT NULL, resource_id TEXT NOT NULL, payload_json TEXT NOT NULL,
endpoint_generation INTEGER NOT NULL DEFAULT 1,
status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending','dead')),
attempts INTEGER NOT NULL DEFAULT 0, next_attempt_at TEXT NOT NULL,
created_at TEXT NOT NULL, last_error TEXT
);
CREATE INDEX idx_webhook_due ON webhook_deliveries(status,next_attempt_at);
CREATE TABLE webhook_transitions (
endpoint_id TEXT NOT NULL REFERENCES webhook_endpoints(id) ON DELETE CASCADE,
event_type TEXT NOT NULL, resource_id TEXT NOT NULL, state TEXT NOT NULL,
last_emitted_at TEXT, PRIMARY KEY(endpoint_id,event_type,resource_id)
);
CREATE TABLE webhook_metrics (id INTEGER PRIMARY KEY CHECK(id=1), dropped INTEGER NOT NULL DEFAULT 0);
INSERT INTO webhook_metrics(id) VALUES (1);
@@ -0,0 +1,42 @@
CREATE TABLE policy_snapshots (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
source_agent_id TEXT,
source_generation INTEGER NOT NULL,
hash TEXT NOT NULL,
content_hash TEXT NOT NULL UNIQUE,
payload_json TEXT NOT NULL CHECK(length(CAST(payload_json AS BLOB)) <= 8388608),
payload_bytes INTEGER NOT NULL CHECK(payload_bytes >= 0 AND payload_bytes <= 8388608),
created_at TEXT NOT NULL
);
CREATE TABLE policy_rollouts (
id TEXT PRIMARY KEY,
snapshot_id TEXT NOT NULL REFERENCES policy_snapshots(id),
state TEXT NOT NULL CHECK(state IN ('canary','confirmed','rolled_back')),
version INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE policy_rollout_targets (
rollout_id TEXT NOT NULL REFERENCES policy_rollouts(id) ON DELETE CASCADE,
agent_id TEXT NOT NULL REFERENCES agents(id) ON DELETE CASCADE,
previous_snapshot_id TEXT NOT NULL REFERENCES policy_snapshots(id),
baseline_generation INTEGER NOT NULL,
previous_delivered_hash TEXT,
previous_delivered_revision INTEGER,
previous_applied_hash TEXT,
previous_applied_revision INTEGER,
is_canary INTEGER NOT NULL CHECK(is_canary IN (0,1)),
phase TEXT NOT NULL CHECK(phase IN ('pending','candidate','rollback','released')),
desired_revision INTEGER,
activated_at TEXT,
PRIMARY KEY(rollout_id,agent_id)
);
CREATE TABLE policy_pins (
agent_id TEXT PRIMARY KEY REFERENCES agents(id) ON DELETE CASCADE,
snapshot_id TEXT NOT NULL REFERENCES policy_snapshots(id),
rollout_id TEXT NOT NULL REFERENCES policy_rollouts(id)
);
CREATE INDEX idx_policy_rollout_targets_agent ON policy_rollout_targets(agent_id);
CREATE INDEX idx_policy_rollouts_created ON policy_rollouts(created_at,id);
CREATE INDEX idx_policy_snapshots_created ON policy_snapshots(created_at,id);
@@ -0,0 +1,19 @@
ALTER TABLE agent_install_links ADD COLUMN expires_at TEXT;
ALTER TABLE agent_install_links ADD COLUMN consumed_at TEXT;
ALTER TABLE agent_install_links ADD COLUMN consumed_token_hash TEXT;
UPDATE agent_install_links SET expires_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '+1 day') WHERE expires_at IS NULL;
CREATE TABLE agent_token_rotations (
agent_id TEXT PRIMARY KEY REFERENCES agents(id) ON DELETE CASCADE,
request_id TEXT NOT NULL UNIQUE,
created_at TEXT NOT NULL,
expires_at TEXT NOT NULL,
completed_at TEXT,
previous_token_hash TEXT,
next_token_hash TEXT
);
CREATE TABLE agent_credential_receipts (
agent_id TEXT PRIMARY KEY REFERENCES agents(id) ON DELETE CASCADE,
request_id TEXT NOT NULL,
token_hash TEXT NOT NULL,
completed_at TEXT NOT NULL
);
@@ -0,0 +1,13 @@
CREATE TABLE agent_labels_catalog (
id TEXT PRIMARY KEY,
name TEXT NOT NULL CHECK(length(name) BETWEEN 1 AND 64),
name_key TEXT NOT NULL UNIQUE,
color TEXT,
created_at TEXT NOT NULL
);
CREATE TABLE agent_labels (
agent_id TEXT NOT NULL REFERENCES agents(id) ON DELETE CASCADE,
label_id TEXT NOT NULL REFERENCES agent_labels_catalog(id) ON DELETE CASCADE,
PRIMARY KEY(agent_id,label_id)
);
CREATE INDEX idx_agent_labels_label ON agent_labels(label_id,agent_id);
+19 -5
View File
@@ -6,7 +6,7 @@ import { drizzle } from 'drizzle-orm/better-sqlite3'
import { schema } from './schema.js'
export type Sqlite = Database.Database
export type Db = ReturnType<typeof drizzle<typeof schema>>
export type Db = Omit<ReturnType<typeof drizzle<typeof schema>>, '$client'>
const __dirname = dirname(fileURLToPath(import.meta.url))
@@ -28,6 +28,7 @@ export function createDb(databaseUrl: string): { db: Db; sqlite: Sqlite } {
}
}
const sqlite = new Database(path)
sqlite.function('evofw_lower', { deterministic: true }, (value) => String(value ?? '').toLowerCase())
sqlite.pragma('journal_mode = WAL')
sqlite.pragma('synchronous = NORMAL')
sqlite.pragma('foreign_keys = ON')
@@ -37,13 +38,14 @@ export function createDb(databaseUrl: string): { db: Db; sqlite: Sqlite } {
export function createMemoryDb(): { db: Db; sqlite: Sqlite } {
const sqlite = new Database(':memory:')
sqlite.function('evofw_lower', { deterministic: true }, (value) => String(value ?? '').toLowerCase())
sqlite.pragma('foreign_keys = ON')
const db = drizzle(sqlite, { schema })
return { db, sqlite }
}
export function runMigrations(sqlite: Sqlite): void {
const migrationsDir = join(__dirname, '..', 'migrations')
export function runMigrations(sqlite: Sqlite, migrationsDir = join(__dirname, '..', 'migrations')): void {
if (sqlite.inTransaction) throw new Error('Migrations require an outermost transaction boundary')
const files = readdirSync(migrationsDir)
.filter((f) => f.endsWith('.sql'))
.sort()
@@ -63,8 +65,20 @@ export function runMigrations(sqlite: Sqlite): void {
if (applied) continue
const sql = readFileSync(join(migrationsDir, file), 'utf-8')
sqlite.exec(sql)
sqlite.prepare('INSERT INTO _migrations (name) VALUES (?)').run(file)
// Rebuilding a referenced table with FK enforcement enabled would cascade
// deletions. Toggle outside the transaction, then validate before committing.
const foreignKeys = sqlite.pragma('foreign_keys', { simple: true })
sqlite.pragma('foreign_keys = OFF')
try {
sqlite.transaction(() => {
sqlite.exec(sql)
const violations = sqlite.pragma('foreign_key_check') as unknown[]
if (violations.length) throw new Error(`Migration ${file} violates foreign keys`)
sqlite.prepare('INSERT INTO _migrations (name) VALUES (?)').run(file)
})()
} finally {
sqlite.pragma(`foreign_keys = ${foreignKeys ? 'ON' : 'OFF'}`)
}
}
}
+5 -1
View File
@@ -1,6 +1,7 @@
import { eq, desc } from 'drizzle-orm'
import { eq, desc, sql } from 'drizzle-orm'
import type { Db } from '../client.js'
import { agents } from '../schema.js'
import { assertAgentPolicyMutable, PolicyPinnedError } from './rollouts.js'
export function listAgents(db: Db) {
return db.select().from(agents).orderBy(desc(agents.createdAt)).all()
@@ -24,10 +25,13 @@ export function updateAgent(
id: string,
patch: Partial<typeof agents.$inferInsert>,
) {
if (patch.defaultAction !== undefined) assertAgentPolicyMutable(db, id)
db.update(agents).set(patch).where(eq(agents.id, id)).run()
return getAgent(db, id)
}
export function deleteAgent(db: Db, id: string) {
assertAgentPolicyMutable(db, id)
if (db.get(sql`SELECT 1 FROM policy_rollout_targets t JOIN policy_rollouts r ON r.id=t.rollout_id WHERE t.agent_id=${id} AND r.state != 'rolled_back' LIMIT 1`)) throw new PolicyPinnedError()
db.delete(agents).where(eq(agents.id, id)).run()
}
+82
View File
@@ -0,0 +1,82 @@
import { and, eq, gte, inArray, lt, sql } from 'drizzle-orm'
import type { Db } from '../client.js'
import { agentStatsRollups, agentStatsSamples, agentIpBlockStats, agentPortBlockStats } from '../schema.js'
export type StatsRollupInsert = typeof agentStatsRollups.$inferInsert
/** Called inside the ingest transaction; replay checks precede this additive write. */
export function addStatsRollups(db: Db, rows: StatsRollupInsert[]) {
for (let offset = 0; offset < rows.length; offset += 100) {
db.insert(agentStatsRollups).values(rows.slice(offset, offset + 100)).onConflictDoUpdate({
target: [agentStatsRollups.agentId, agentStatsRollups.resolution, agentStatsRollups.bucketStart],
set: {
packetsDropped: sql`${agentStatsRollups.packetsDropped} + excluded.packets_dropped`,
packetsAccepted: sql`${agentStatsRollups.packetsAccepted} + excluded.packets_accepted`,
droppedCoverageMs: sql`${agentStatsRollups.droppedCoverageMs} + excluded.dropped_coverage_ms`,
acceptedCoverageMs: sql`${agentStatsRollups.acceptedCoverageMs} + excluded.accepted_coverage_ms`,
partial: sql`max(${agentStatsRollups.partial}, excluded.partial)`,
truncated: sql`max(${agentStatsRollups.truncated}, excluded.truncated)`,
estimated: sql`max(${agentStatsRollups.estimated}, excluded.estimated)`,
unsupportedJson: sql`(SELECT json_group_array(value) FROM (SELECT value FROM json_each(${agentStatsRollups.unsupportedJson}) UNION SELECT value FROM json_each(excluded.unsupported_json) LIMIT 16))`,
lostReports: sql`${agentStatsRollups.lostReports} + excluded.lost_reports`,
},
}).run()
}
}
/** At most 100 selected agents * 1440 buckets; no raw-history scan. */
export function listStatsRollups(db: Db, agentIds: string[], resolution: string, from: string, to: string) {
if (!agentIds.length) return []
return db.select({
bucketStart: agentStatsRollups.bucketStart,
packetsDropped: sql<number>`sum(${agentStatsRollups.packetsDropped})`.mapWith(Number),
packetsAccepted: sql<number>`sum(${agentStatsRollups.packetsAccepted})`.mapWith(Number),
droppedCoverageMs: sql<number>`sum(${agentStatsRollups.droppedCoverageMs})`.mapWith(Number),
acceptedCoverageMs: sql<number>`sum(${agentStatsRollups.acceptedCoverageMs})`.mapWith(Number),
droppedAgents: sql<number>`sum(${agentStatsRollups.droppedCoverageMs} > 0)`.mapWith(Number),
acceptedAgents: sql<number>`sum(${agentStatsRollups.acceptedCoverageMs} > 0)`.mapWith(Number),
partial: sql<number>`max(${agentStatsRollups.partial})`.mapWith(Number),
truncated: sql<number>`max(${agentStatsRollups.truncated})`.mapWith(Number),
estimated: sql<number>`max(${agentStatsRollups.estimated})`.mapWith(Number),
lostReports: sql<number>`sum(${agentStatsRollups.lostReports})`.mapWith(Number),
unsupportedJson: sql<string>`(SELECT json_group_array(value) FROM (SELECT DISTINCT value FROM agent_stats_rollups flags, json_each(flags.unsupported_json) WHERE flags.bucket_start=${agentStatsRollups.bucketStart} AND flags.resolution=${resolution} AND ${inArray(sql`flags.agent_id`, agentIds)} LIMIT 16))`,
}).from(agentStatsRollups).where(and(inArray(agentStatsRollups.agentId, agentIds), eq(agentStatsRollups.resolution, resolution), gte(agentStatsRollups.bucketStart, from), lt(agentStatsRollups.bucketStart, to)))
.groupBy(agentStatsRollups.bucketStart).orderBy(agentStatsRollups.bucketStart).all()
}
export function listRawStatsPage(db: Db, agentId: string, from: string, to: string, limit: number, cursor?: { at: string; id: string }) {
return db.select().from(agentStatsSamples).where(and(
eq(agentStatsSamples.agentId, agentId), gte(agentStatsSamples.recordedAt, from), lt(agentStatsSamples.recordedAt, to),
cursor ? sql`(${agentStatsSamples.recordedAt}, ${agentStatsSamples.id}) > (${cursor.at}, ${cursor.id})` : undefined,
)).orderBy(agentStatsSamples.recordedAt, agentStatsSamples.id).limit(limit + 1).all()
}
export function listPendingAnalytics(db: Db, limit = 100) {
return db.select().from(agentStatsSamples).where(eq(agentStatsSamples.analyticsProcessed, 0))
.orderBy(agentStatsSamples.recordedAt, agentStatsSamples.id).limit(limit).all()
}
export function previousStatsSample(db: Db, sample: typeof agentStatsSamples.$inferSelect) {
return db.select().from(agentStatsSamples).where(and(eq(agentStatsSamples.agentId, sample.agentId),
sql`(${agentStatsSamples.recordedAt}, ${agentStatsSamples.id}) < (${sample.recordedAt}, ${sample.id})`))
.orderBy(sql`${agentStatsSamples.recordedAt} DESC`, sql`${agentStatsSamples.id} DESC`).limit(1).get()
}
export function markAnalyticsProcessed(db: Db, id: string) {
return db.update(agentStatsSamples).set({ analyticsProcessed: 1 }).where(and(eq(agentStatsSamples.id, id), eq(agentStatsSamples.analyticsProcessed, 0))).run().changes
}
/** One small deletion statement; caller yields between batches. */
export function deleteAnalyticsBatch(db: Db, kind: 'raw' | '1m' | '1h' | '1d' | 'ip' | 'port', cutoff: string, limit = 1000): number {
if (kind === 'raw') return db.run(sql`DELETE FROM agent_stats_samples WHERE id IN (SELECT id FROM agent_stats_samples WHERE recorded_at < ${cutoff} AND analytics_processed=1 LIMIT ${limit})`).changes
if (kind === 'ip') return db.run(sql`DELETE FROM agent_ip_block_stats WHERE id IN (SELECT id FROM agent_ip_block_stats WHERE last_seen_at < ${cutoff} LIMIT ${limit})`).changes
if (kind === 'port') return db.run(sql`DELETE FROM agent_port_block_stats WHERE id IN (SELECT id FROM agent_port_block_stats WHERE last_seen_at < ${cutoff} LIMIT ${limit})`).changes
return db.run(sql`DELETE FROM agent_stats_rollups WHERE rowid IN (SELECT rowid FROM agent_stats_rollups WHERE resolution=${kind} AND bucket_start < ${cutoff} LIMIT ${limit})`).changes
}
/** Keep detailed tables bounded per agent using their packet-order indexes. */
export function enforceDetailBudget(db: Db, agentId: string, maxIp = 2000, maxPort = 5000) {
const ip = db.delete(agentIpBlockStats).where(and(eq(agentIpBlockStats.agentId, agentId), sql`${agentIpBlockStats.id} IN (SELECT id FROM agent_ip_block_stats WHERE agent_id=${agentId} ORDER BY packets DESC, last_seen_at DESC, id LIMIT -1 OFFSET ${maxIp})`)).run().changes
const port = db.delete(agentPortBlockStats).where(and(eq(agentPortBlockStats.agentId, agentId), sql`${agentPortBlockStats.id} IN (SELECT id FROM agent_port_block_stats WHERE agent_id=${agentId} ORDER BY packets DESC, last_seen_at DESC, id LIMIT -1 OFFSET ${maxPort})`)).run().changes
return { ip, port }
}
+27 -6
View File
@@ -8,12 +8,12 @@ export {
} from './agents.js'
export {
listIpLists,
listIpLists, listIpListsSummary,
getIpList,
insertIpList,
updateIpList,
deleteIpList,
listIpListEntries,
listIpListEntries, getIpListEntrySummary,
mapIpListEntriesByListIds,
mapIpListNames,
countEntriesByListIds,
@@ -50,6 +50,7 @@ export {
mapResolvedCidrsByRuleIds,
replaceResolvedForRule,
listOverrides,
listActiveOverrides, expireOverridesForAgent, nextOverrideExpiry,
insertOverride,
deleteOverride,
cloneRulesFrom,
@@ -119,12 +120,12 @@ import {
deleteAgent,
} from './agents.js'
import {
listIpLists,
listIpLists, listIpListsSummary,
getIpList,
insertIpList,
updateIpList,
deleteIpList,
listIpListEntries,
listIpListEntries, getIpListEntrySummary,
mapIpListEntriesByListIds,
mapIpListNames,
countEntriesByListIds,
@@ -160,6 +161,7 @@ import {
mapResolvedCidrsByRuleIds,
replaceResolvedForRule,
listOverrides,
listActiveOverrides, expireOverridesForAgent, nextOverrideExpiry,
insertOverride,
deleteOverride,
cloneRulesFrom,
@@ -207,7 +209,25 @@ import {
touchInstallLink,
} from './install-links.js'
export { getTelemetryState, hasTelemetryReport, upsertTelemetryState, resetDisplayedStats } from './telemetry.js'
import { getTelemetryState, hasTelemetryReport, upsertTelemetryState, resetDisplayedStats } from './telemetry.js'
export { addStatsRollups, listStatsRollups, listRawStatsPage, deleteAnalyticsBatch, enforceDetailBudget, listPendingAnalytics, previousStatsSample, markAnalyticsProcessed } from './analytics.js'
export type { StatsRollupInsert } from './analytics.js'
import { addStatsRollups, listStatsRollups, listRawStatsPage, deleteAnalyticsBatch, enforceDetailBudget, listPendingAnalytics, previousStatsSample, markAnalyticsProcessed } from './analytics.js'
import * as rollouts from './rollouts.js'
import * as labels from './labels.js'
import * as listPages from './list-pages.js'
export * from './list-pages.js'
export * from './labels.js'
export * from './rollouts.js'
export const repos = {
...rollouts,
...labels,
...listPages,
addStatsRollups, listStatsRollups, listRawStatsPage, deleteAnalyticsBatch, enforceDetailBudget, listPendingAnalytics, previousStatsSample, markAnalyticsProcessed,
getTelemetryState, hasTelemetryReport, upsertTelemetryState, resetDisplayedStats,
listAgents,
getAgent,
getAgentByTokenHash,
@@ -218,12 +238,12 @@ export const repos = {
bumpAgentsForSet,
bumpAllApprovedAgents,
bumpAgentsForList,
listIpLists,
listIpLists, listIpListsSummary,
getIpList,
insertIpList,
updateIpList,
deleteIpList,
listIpListEntries,
listIpListEntries, getIpListEntrySummary,
mapIpListEntriesByListIds,
mapIpListNames,
countEntriesByListIds,
@@ -253,6 +273,7 @@ export const repos = {
mapResolvedCidrsByRuleIds,
replaceResolvedForRule,
listOverrides,
listActiveOverrides, expireOverridesForAgent, nextOverrideExpiry,
insertOverride,
deleteOverride,
insertStatsSample,
@@ -34,6 +34,8 @@ export function getInstallLinkByAgentId(db: Db, agentId: string) {
and(
eq(agentInstallLinks.agentId, agentId),
sql`${agentInstallLinks.revokedAt} IS NULL`,
sql`${agentInstallLinks.expiresAt} > ${new Date().toISOString()}`,
sql`${agentInstallLinks.consumedAt} IS NULL`,
),
)
.orderBy(desc(agentInstallLinks.createdAt))
@@ -45,7 +47,7 @@ export function mapActiveInstallLinksByAgentId(db: Db) {
const rows = db
.select()
.from(agentInstallLinks)
.where(sql`${agentInstallLinks.revokedAt} IS NULL`)
.where(and(sql`${agentInstallLinks.revokedAt} IS NULL`, sql`${agentInstallLinks.expiresAt} > ${new Date().toISOString()}`, sql`${agentInstallLinks.consumedAt} IS NULL`))
.orderBy(desc(agentInstallLinks.createdAt))
.all()
const map = new Map<string, (typeof rows)[number]>()
+21
View File
@@ -0,0 +1,21 @@
import { sql } from 'drizzle-orm'
import type { Db } from '../client.js'
export type AgentLabelRow = { id: string; name: string; name_key: string; color: string | null; created_at: string }
export function listAgentLabels(db: Db, limit: number, offset: number) { return db.all<AgentLabelRow>(sql`SELECT * FROM agent_labels_catalog ORDER BY name_key,id LIMIT ${limit} OFFSET ${offset}`) }
export function countAgentLabels(db: Db) { return db.get<{ n: number }>(sql`SELECT count(*) AS n FROM agent_labels_catalog`)!.n }
export function getAgentLabel(db: Db, id: string) { return db.get<AgentLabelRow>(sql`SELECT * FROM agent_labels_catalog WHERE id=${id}`) }
export function getAgentLabelByName(db: Db, key: string) { return db.get<AgentLabelRow>(sql`SELECT * FROM agent_labels_catalog WHERE name_key=${key}`) }
export function insertAgentLabel(db: Db, row: AgentLabelRow) { db.run(sql`INSERT INTO agent_labels_catalog VALUES (${row.id},${row.name},${row.name_key},${row.color},${row.created_at})`) }
export function updateAgentLabel(db: Db, row: AgentLabelRow) { db.run(sql`UPDATE agent_labels_catalog SET name=${row.name},name_key=${row.name_key},color=${row.color} WHERE id=${row.id}`) }
export function deleteAgentLabel(db: Db, id: string) { return db.run(sql`DELETE FROM agent_labels_catalog WHERE id=${id}`).changes }
export function agentLabelIds(db: Db, id: string) { return db.all<{ label_id: string }>(sql`SELECT label_id FROM agent_labels WHERE agent_id=${id} ORDER BY label_id`).map((row) => row.label_id) }
export function replaceAgentLabels(db: Db, id: string, labels: string[]) {
db.run(sql`DELETE FROM agent_labels WHERE agent_id=${id}`)
for (const labelId of labels) db.run(sql`INSERT INTO agent_labels VALUES (${id},${labelId})`)
}
export function agentIdsWithLabels(db: Db, labelIds: string[], limit?: number) {
if (labelIds.length === 0) return []
const values = sql.join(labelIds.map((id) => sql`${id}`), sql`, `)
const suffix = limit === undefined ? sql`` : sql` LIMIT ${limit}`
return db.all<{ agent_id: string }>(sql`SELECT agent_id FROM agent_labels WHERE label_id IN (${values}) GROUP BY agent_id HAVING count(DISTINCT label_id)=${labelIds.length} ORDER BY agent_id${suffix}`).map((row) => row.agent_id)
}
@@ -0,0 +1,54 @@
import { sql } from 'drizzle-orm'
import type { Db } from '../client.js'
import { isManualListType, type ListDetailPageQuery } from '@evofw/shared'
/** Project only bounded page rows. JSON1 filters source items before LIMIT;
* config_json and unbounded resolved arrays never cross the SQLite boundary. */
export function getListDetailPage(db: Db, listId: string, query: ListDetailPageQuery) {
const metadata = db.get<{ id: string; name: string; type: string; content_hash: string | null; refreshed_at: string | null; last_error: string | null; created_at: string; updated_at: string; entry_count: number }>(sql`
SELECT id,name,type,content_hash,refreshed_at,last_error,created_at,updated_at,
(SELECT count(*) FROM ip_list_entries WHERE list_id=${listId}) AS entry_count
FROM ip_lists WHERE id=${listId}`)
if (!metadata) return null
const source = isManualListType(metadata.type) ? sql`
SELECT CAST(j.key AS INTEGER) AS ordinal,
CASE WHEN j.type='object' THEN json_extract(j.value,'$.kind')
WHEN coalesce(json_type(CASE WHEN json_valid(l.config_json) THEN l.config_json ELSE '{}' END,'$.items')='array' AND json_array_length(CASE WHEN json_valid(l.config_json) THEN l.config_json ELSE '{}' END,'$.items')>0,0)=0 THEN 'hostname'
WHEN instr(CAST(j.value AS TEXT),'/')>0 THEN 'cidr'
WHEN instr(CAST(j.value AS TEXT),':')>0 OR (instr(CAST(j.value AS TEXT),'.')>0 AND CAST(j.value AS TEXT) NOT GLOB '*[^0-9.]*') THEN 'ip' ELSE 'hostname' END AS kind,
CASE WHEN j.type='object' THEN json_extract(j.value,'$.value') ELSE trim(CAST(j.value AS TEXT)) END AS value,
CASE WHEN j.type='object' THEN coalesce(json_array_length(j.value,'$.resolved_cidrs'),0) ELSE 0 END AS stored_count,
CASE WHEN j.type='object' THEN (SELECT json_group_array(value) FROM (SELECT value FROM json_each(j.value,'$.resolved_cidrs') LIMIT 3)) ELSE '[]' END AS resolved_json
FROM ip_lists l,json_each(CASE WHEN json_valid(l.config_json) THEN l.config_json ELSE '{}' END,
CASE WHEN json_type(CASE WHEN json_valid(l.config_json) THEN l.config_json ELSE '{}' END,'$.items')='array' AND json_array_length(CASE WHEN json_valid(l.config_json) THEN l.config_json ELSE '{}' END,'$.items')>0 THEN '$.items' ELSE '$.domains' END) j
WHERE l.id=${listId} AND j.type IN ('object','text')` : sql`
SELECT rowid AS ordinal,'cidr' AS kind,cidr AS value,1 AS stored_count,json_array(cidr) AS resolved_json FROM ip_list_entries WHERE list_id=${listId}`
const cte = sql`WITH source AS (${source}), items AS (
SELECT s.*,CASE WHEN s.kind='list' THEN (SELECT name FROM ip_lists WHERE id=s.value) ELSE NULL END AS list_name,
CASE WHEN s.kind='list' THEN coalesce((SELECT name FROM ip_lists WHERE id=s.value),'') || ' ' || s.value ELSE s.value END AS filter_value
FROM source s WHERE s.kind IN ('ip','cidr','hostname','list') AND typeof(s.value)='text' AND length(s.value)>0
)`
const clauses = [sql`1=1`]
if (query.q.trim()) clauses.push(sql`instr(evofw_lower(filter_value || ' ' || kind),evofw_lower(${query.q.trim()}))>0`)
for (const filter of query.filters) {
if (filter.values.length === 0 || filter.values.every((value) => value.trim() === '')) continue
const field = filter.field === 'value' ? sql`filter_value` : sql`kind`
const values = filter.values
const any = (parts: ReturnType<typeof sql>[]) => sql`(${sql.join(parts, sql` OR `)})`
switch (filter.operator) {
case 'is': case 'is_any_of': clauses.push(any(values.map((value) => sql`${field}=${value} COLLATE BINARY`))); break
case 'is_not': case 'is_not_any_of': clauses.push(sql`NOT ${any(values.map((value) => sql`${field}=${value} COLLATE BINARY`))}`); break
case 'contains': clauses.push(any(values.filter((value) => value.trim()).map((value) => sql`instr(evofw_lower(${field}),evofw_lower(${value.trim()}))>0`))); break
case 'not_contains': clauses.push(sql`NOT ${any(values.map((value) => sql`instr(evofw_lower(${field}),evofw_lower(${value}))>0`))}`); break
case 'starts_with': clauses.push(any(values.map((value) => sql`evofw_lower(substr(${field},1,length(${value})))=evofw_lower(${value})`))); break
case 'ends_with': clauses.push(any(values.map((value) => sql`(length(${value})=0 OR evofw_lower(substr(${field},-length(${value})))=evofw_lower(${value}))`))); break
case 'empty': clauses.push(sql`${field}=''`); break
case 'not_empty': clauses.push(sql`${field}!=''`); break
}
}
const where = sql.join(clauses, sql` AND `)
const total = db.get<{ n: number }>(sql`${cte} SELECT count(*) AS n FROM items WHERE ${where}`)!.n
const rows = db.all<{ kind: 'ip' | 'cidr' | 'hostname' | 'list'; value: string; list_name: string | null; stored_count: number; resolved_json: string }>(sql`
${cte} SELECT kind,value,list_name,stored_count,resolved_json FROM items WHERE ${where} ORDER BY ordinal LIMIT ${query.limit} OFFSET ${query.offset}`)
return { metadata, total, rows }
}
+32 -4
View File
@@ -1,10 +1,16 @@
import { eq, desc, count, inArray } from 'drizzle-orm'
import { eq, desc, count, inArray, sql } from 'drizzle-orm'
import type { Db } from '../client.js'
import { ipLists, ipListEntries } from '../schema.js'
import { ipLists, ipListEntries, policyRules, agentPortRules } from '../schema.js'
import { canonicalCidr, listNestedChildIds } from '@evofw/shared'
export function listIpLists(db: Db) {
return db.select().from(ipLists).orderBy(desc(ipLists.createdAt)).all()
}
export function listIpListsSummary(db: Db) {
return db.select({ id: ipLists.id, name: ipLists.name, type: ipLists.type, contentHash: ipLists.contentHash,
refreshedAt: ipLists.refreshedAt, lastError: ipLists.lastError, createdAt: ipLists.createdAt, updatedAt: ipLists.updatedAt,
}).from(ipLists).orderBy(desc(ipLists.createdAt)).all()
}
export function getIpList(db: Db, id: string) {
return db.select().from(ipLists).where(eq(ipLists.id, id)).get()
@@ -28,6 +34,13 @@ export function updateIpList(
}
export function deleteIpList(db: Db, id: string) {
const parents = listIpLists(db).filter((list) => list.id !== id && listNestedChildIds(list.configJson).includes(id))
if (parents.length) throw new Error('List is referenced by a nested list')
// Policy/port rules reference lists with ON DELETE SET NULL in older DBs.
// Refuse deletion while a policy depends on the list instead of widening it.
const used = db.select({ id: policyRules.id }).from(policyRules).where(eq(policyRules.listId, id)).get()
?? db.select({ id: agentPortRules.id }).from(agentPortRules).where(eq(agentPortRules.listId, id)).get()
if (used) throw new Error('List is referenced by a policy rule')
db.delete(ipLists).where(eq(ipLists.id, id)).run()
}
@@ -39,10 +52,17 @@ export function listIpListEntries(db: Db, listId: string) {
.all()
}
export function getIpListEntrySummary(db: Db, listId: string) {
return db.select({ content_hash: ipLists.contentHash,
count: sql<number>`(SELECT count(*) FROM ${ipListEntries} WHERE ${ipListEntries.listId} = ${listId})`.mapWith(Number),
}).from(ipLists).where(eq(ipLists.id, listId)).get()
}
/** Entries for many lists in one query (agent policy hot path). */
export function mapIpListEntriesByListIds(
db: Db,
listIds: string[],
maximumEntries = Number.MAX_SAFE_INTEGER,
): Map<string, string[]> {
const map = new Map<string, string[]>()
for (const id of listIds) map.set(id, [])
@@ -51,7 +71,9 @@ export function mapIpListEntriesByListIds(
.select({ listId: ipListEntries.listId, cidr: ipListEntries.cidr })
.from(ipListEntries)
.where(inArray(ipListEntries.listId, listIds))
.limit(Math.min(maximumEntries + 1, Number.MAX_SAFE_INTEGER))
.all()
if (rows.length > maximumEntries) throw new RangeError('Policy source membership budget exceeded')
for (const r of rows) map.get(r.listId)?.push(r.cidr)
return map
}
@@ -88,13 +110,17 @@ export function countEntriesByListIds(
}
export function replaceIpListEntries(db: Db, listId: string, cidrs: string[]) {
const normalized = [...new Set(cidrs.map(canonicalCidr))].sort()
const previous = listIpListEntries(db, listId).map((row) => row.cidr).sort()
if (JSON.stringify(normalized) === JSON.stringify(previous)) return false
db.transaction((tx) => {
tx.delete(ipListEntries).where(eq(ipListEntries.listId, listId)).run()
if (cidrs.length === 0) return
if (normalized.length === 0) return
const now = new Date().toISOString()
for (let offset = 0; offset < normalized.length; offset += 200) {
tx.insert(ipListEntries)
.values(
cidrs.map((cidr) => ({
normalized.slice(offset, offset + 200).map((cidr) => ({
id: crypto.randomUUID(),
listId,
cidr,
@@ -102,5 +128,7 @@ export function replaceIpListEntries(db: Db, listId: string, cidrs: string[]) {
})),
)
.run()
}
})
return true
}
+106 -49
View File
@@ -5,26 +5,33 @@ import {
policySets,
agentPolicySets,
policyRules,
agentPortRules,
policyRuleResolved,
ipOverrides,
SHARED_POLICY_SET_ID,
} from '../schema.js'
import { getAgent, updateAgent } from './agents.js'
import { getPolicyPin, assertAgentPolicyMutable } from './rollouts.js'
export function bumpAgentGeneration(db: Db, id: string) {
if (getPolicyPin(db, id)) return
db.update(agents)
.set({ policyGeneration: sql`${agents.policyGeneration} + 1` })
.where(eq(agents.id, id))
.run()
}
export function bumpAgentsForSet(db: Db, setId: string) {
export function bumpAgentsForSet(db: Db, setId: string, alreadyBumped: Set<string> = new Set()) {
const rows = db
.select({ agentId: agentPolicySets.agentId })
.from(agentPolicySets)
.where(eq(agentPolicySets.setId, setId))
.all()
for (const r of rows) bumpAgentGeneration(db, r.agentId)
for (const r of rows) {
if (alreadyBumped.has(r.agentId)) continue
bumpAgentGeneration(db, r.agentId)
alreadyBumped.add(r.agentId)
}
}
export function bumpAllApprovedAgents(db: Db) {
@@ -37,16 +44,20 @@ export function bumpAllApprovedAgents(db: Db) {
}
/** Bump agents that have a policy rule referencing this IP list. */
export function bumpAgentsForList(db: Db, listId: string) {
export function bumpAgentsForList(db: Db, listId: string, alreadyBumped: Set<string> = new Set()) {
const rules = db
.select({ setId: policyRules.setId })
.from(policyRules)
.where(eq(policyRules.listId, listId))
.all()
const setIds = [...new Set(rules.map((r) => r.setId))]
// Unused list (no rules) — do not churn all approved agents.
if (setIds.length === 0) return
for (const setId of setIds) bumpAgentsForSet(db, setId)
for (const setId of setIds) bumpAgentsForSet(db, setId, alreadyBumped)
const portRules = db.select({ agentId: agentPortRules.agentId }).from(agentPortRules).where(eq(agentPortRules.listId, listId)).all()
for (const rule of portRules) {
if (alreadyBumped.has(rule.agentId)) continue
bumpAgentGeneration(db, rule.agentId)
alreadyBumped.add(rule.agentId)
}
}
/* ── Policy sets ── */
@@ -164,6 +175,7 @@ export function listSetsForAgent(db: Db, agentId: string) {
/** Replace agent↔set assignments; set_ids order = sort. */
export function setAgentPolicySets(db: Db, agentId: string, setIds: string[]) {
assertAgentPolicyMutable(db, agentId)
for (const setId of setIds) {
if (!getPolicySet(db, setId)) {
throw new Error(`policy set not found: ${setId}`)
@@ -177,25 +189,19 @@ export function setAgentPolicySets(db: Db, agentId: string, setIds: string[]) {
.values({ agentId, setId, sort: i * 10 })
.run()
})
bumpAgentGeneration(tx, agentId)
})
bumpAgentGeneration(db, agentId)
}
export function ensureSharedSetAssigned(db: Db, agentId: string) {
const existing = db
.select()
.from(agentPolicySets)
.where(
and(
eq(agentPolicySets.agentId, agentId),
eq(agentPolicySets.setId, SHARED_POLICY_SET_ID),
),
)
.get()
if (existing) return
db.insert(agentPolicySets)
.values({ agentId, setId: SHARED_POLICY_SET_ID, sort: 0 })
.run()
return db.transaction((tx) => {
const inserted = tx.insert(agentPolicySets)
.values({ agentId, setId: SHARED_POLICY_SET_ID, sort: 0 })
.onConflictDoNothing()
.run().changes > 0
if (inserted) bumpAgentGeneration(tx, agentId)
return inserted
})
}
/* ── Policy rules ── */
@@ -259,35 +265,34 @@ export function updatePolicyRule(
}
/** Renumber priorities 10, 20, … in given order. */
export function reorderPolicyRules(
db: Db,
setId: string,
orderedIds: string[],
) {
export function reorderPolicyRules(db: Db, setId: string, orderedIds: string[]) {
const existing = listPolicyRules(db, setId)
const existingIds = new Set(existing.map((r) => r.id))
if (
orderedIds.length !== existing.length ||
orderedIds.some((id) => !existingIds.has(id))
) {
const byId = new Map(existing.map((rule) => [rule.id, rule.priority]))
if (orderedIds.length !== existing.length || new Set(orderedIds).size !== existing.length || orderedIds.some((id) => !byId.has(id))) {
throw new Error('ordered_ids must list every rule in the set exactly once')
}
// Temporary priorities to avoid UNIQUE collisions
const atPriority = new Map(existing.map((rule) => [rule.priority, rule.id]))
const step = orderedIds.length <= 1000 ? 10 : 1
if (orderedIds.every((id, i) => byId.get(id) === (i + 1) * step)) return
let spare = 1
while (atPriority.has(spare) && spare <= 10000) spare++
if (spare > 10000) throw new Error('No spare priority available for reorder')
db.transaction((tx) => {
const move = (id: string, priority: number) => {
const old = byId.get(id)!
tx.update(policyRules).set({ priority, updatedAt: new Date().toISOString() }).where(eq(policyRules.id, id)).run()
atPriority.delete(old)
atPriority.set(priority, id)
byId.set(id, priority)
return old
}
orderedIds.forEach((id, i) => {
tx.update(policyRules)
.set({ priority: 9000 + i, updatedAt: new Date().toISOString() })
.where(eq(policyRules.id, id))
.run()
})
orderedIds.forEach((id, i) => {
tx.update(policyRules)
.set({
priority: (i + 1) * 10,
updatedAt: new Date().toISOString(),
})
.where(eq(policyRules.id, id))
.run()
const target = (i + 1) * step
if (byId.get(id) === target) return
const occupant = atPriority.get(target)
if (occupant) move(occupant, spare)
spare = move(id, target)
while (atPriority.has(spare)) spare++
})
})
}
@@ -323,6 +328,7 @@ export function listResolvedForRule(db: Db, ruleId: string) {
export function mapResolvedCidrsByRuleIds(
db: Db,
ruleIds: string[],
maximumEntries = Number.MAX_SAFE_INTEGER,
): Map<string, string[]> {
const map = new Map<string, string[]>()
for (const id of ruleIds) map.set(id, [])
@@ -334,7 +340,9 @@ export function mapResolvedCidrsByRuleIds(
})
.from(policyRuleResolved)
.where(inArray(policyRuleResolved.ruleId, ruleIds))
.limit(Math.min(maximumEntries + 1, Number.MAX_SAFE_INTEGER))
.all()
if (rows.length > maximumEntries) throw new RangeError('Policy source membership budget exceeded')
for (const r of rows) map.get(r.ruleId)?.push(r.cidr)
return map
}
@@ -367,16 +375,55 @@ export function listOverrides(db: Db, agentId: string) {
.all()
}
export function listActiveOverrides(db: Db, agentId: string, now = new Date().toISOString()) {
return db.select().from(ipOverrides).where(and(eq(ipOverrides.agentId, agentId), sql`${ipOverrides.expiredAt} IS NULL`, sql`(${ipOverrides.expiresAt} IS NULL OR ${ipOverrides.expiresAt} > ${now})`)).all()
}
/** Expiration is recorded once and changes desired generation before compilation. */
export function expireOverridesForAgent(db: Db, agentId: string, now = new Date().toISOString()) {
return db.transaction((tx) => {
const changed = tx.update(ipOverrides).set({ expiredAt: now }).where(and(eq(ipOverrides.agentId, agentId), sql`${ipOverrides.expiredAt} IS NULL`, sql`${ipOverrides.expiresAt} IS NOT NULL AND ${ipOverrides.expiresAt} <= ${now}`)).run().changes
if (changed) bumpAgentGeneration(tx, agentId)
pruneExpiredOverrides(tx, agentId, now)
return changed
})
}
function pruneExpiredOverrides(db: Db, agentId: string, now: string) {
const cutoff = new Date(Date.parse(now) - 7 * 86_400_000).toISOString()
db.delete(ipOverrides).where(and(eq(ipOverrides.agentId, agentId), sql`${ipOverrides.expiredAt} IS NOT NULL`,
sql`(${ipOverrides.expiresAt} <= ${cutoff} OR ${ipOverrides.id} IN (SELECT id FROM ip_overrides WHERE agent_id=${agentId} AND expired_at IS NOT NULL ORDER BY expires_at DESC, id DESC LIMIT -1 OFFSET 100))`)).run()
}
function overrideLimitError() {
return Object.assign(new Error('At most 1000 active overrides per agent'), { code: 'OVERRIDE_LIMIT' })
}
export function nextOverrideExpiry(db: Db, agentId: string) {
return db.select({ at: sql<string | null>`min(${ipOverrides.expiresAt})` }).from(ipOverrides)
.where(and(eq(ipOverrides.agentId, agentId), sql`${ipOverrides.expiredAt} IS NULL`, sql`${ipOverrides.expiresAt} IS NOT NULL`)).get()?.at ?? null
}
export function insertOverride(
db: Db,
row: typeof ipOverrides.$inferInsert,
) {
db.insert(ipOverrides).values(row).run()
assertAgentPolicyMutable(db, row.agentId)
db.transaction((tx) => {
const now = new Date().toISOString()
expireOverridesForAgent(tx, row.agentId, now)
const active = tx.select({ n: count() }).from(ipOverrides).where(and(eq(ipOverrides.agentId, row.agentId), sql`${ipOverrides.expiredAt} IS NULL`, sql`(${ipOverrides.expiresAt} IS NULL OR ${ipOverrides.expiresAt} > ${now})`)).get()?.n ?? 0
if (active >= 1000) throw overrideLimitError()
tx.delete(ipOverrides).where(and(eq(ipOverrides.agentId, row.agentId), eq(ipOverrides.cidr, row.cidr), sql`(${ipOverrides.expiredAt} IS NOT NULL OR ${ipOverrides.expiresAt} <= ${new Date().toISOString()})`)).run()
tx.insert(ipOverrides).values(row).run()
})
return db.select().from(ipOverrides).where(eq(ipOverrides.id, row.id)).get()
}
export function deleteOverride(db: Db, id: string) {
db.delete(ipOverrides).where(eq(ipOverrides.id, id)).run()
export function deleteOverride(db: Db, id: string, agentId?: string) {
const owner = agentId ?? db.select().from(ipOverrides).where(eq(ipOverrides.id, id)).get()?.agentId
if (owner) assertAgentPolicyMutable(db, owner)
return db.delete(ipOverrides).where(agentId ? and(eq(ipOverrides.id, id), eq(ipOverrides.agentId, agentId)) : eq(ipOverrides.id, id)).run()
}
/** Clone set assignments (+ optional overrides) and policy mode from source agent. */
@@ -386,10 +433,15 @@ export function cloneRulesFrom(
targetAgentId: string,
includeOverrides: boolean,
) {
if (sourceAgentId === targetAgentId) throw new Error('Cannot clone an agent onto itself')
assertAgentPolicyMutable(db, targetAgentId)
const source = getAgent(db, sourceAgentId)
const target = getAgent(db, targetAgentId)
if (!source || !target) return null
return db.transaction(() => {
const sourceOverrides = includeOverrides ? listActiveOverrides(db, sourceAgentId) : []
if (sourceOverrides.length > 1000) throw overrideLimitError()
const sourceSets = listSetsForAgent(db, sourceAgentId)
setAgentPolicySets(
db,
@@ -399,7 +451,7 @@ export function cloneRulesFrom(
if (includeOverrides) {
db.delete(ipOverrides).where(eq(ipOverrides.agentId, targetAgentId)).run()
for (const o of listOverrides(db, sourceAgentId)) {
for (const o of sourceOverrides) {
db.insert(ipOverrides)
.values({
id: crypto.randomUUID(),
@@ -407,6 +459,10 @@ export function cloneRulesFrom(
cidr: o.cidr,
action: o.action,
comment: o.comment,
reason: o.reason,
createdByUserId: o.createdByUserId,
createdByName: o.createdByName,
expiresAt: o.expiresAt,
createdAt: new Date().toISOString(),
})
.run()
@@ -418,4 +474,5 @@ export function cloneRulesFrom(
policyGeneration: (target.policyGeneration ?? 1) + 1,
})
return getAgent(db, targetAgentId)
})
}
+6
View File
@@ -1,6 +1,7 @@
import { and, asc, eq } from 'drizzle-orm'
import type { Db } from '../client.js'
import { agentHostFirewallSnapshots, agentPortRules } from '../schema.js'
import { assertAgentPolicyMutable } from './rollouts.js'
export type AgentPortRuleRow = typeof agentPortRules.$inferSelect
export type AgentPortRuleInsert = typeof agentPortRules.$inferInsert
@@ -30,6 +31,7 @@ export function getAgentPortRule(db: Db, id: string) {
}
export function insertAgentPortRule(db: Db, row: AgentPortRuleInsert) {
assertAgentPolicyMutable(db, row.agentId)
db.insert(agentPortRules).values(row).run()
return getAgentPortRule(db, row.id)
}
@@ -41,6 +43,8 @@ export function updateAgentPortRule(
Omit<AgentPortRuleInsert, 'id' | 'agentId' | 'createdAt'>
>,
) {
const owner = getAgentPortRule(db, id)?.agentId
if (owner) assertAgentPolicyMutable(db, owner)
db.update(agentPortRules)
.set({
...patch,
@@ -52,6 +56,8 @@ export function updateAgentPortRule(
}
export function deleteAgentPortRule(db: Db, id: string) {
const owner = getAgentPortRule(db, id)?.agentId
if (owner) assertAgentPolicyMutable(db, owner)
db.delete(agentPortRules).where(eq(agentPortRules.id, id)).run()
}
+69
View File
@@ -0,0 +1,69 @@
import { sql } from 'drizzle-orm'
import type { Db } from '../client.js'
export type PolicySnapshotRow = {
id: string; name: string; source_agent_id: string | null; source_generation: number
hash: string; content_hash: string; payload_json: string; payload_bytes: number; created_at: string
}
export type PolicyRolloutRow = { id: string; snapshot_id: string; state: 'canary' | 'confirmed' | 'rolled_back'; version: number; created_at: string; updated_at: string }
export type PolicyRolloutTargetRow = {
rollout_id: string; agent_id: string; previous_snapshot_id: string; baseline_generation: number
previous_delivered_hash: string | null; previous_delivered_revision: number | null
previous_applied_hash: string | null; previous_applied_revision: number | null
is_canary: number; phase: 'pending' | 'candidate' | 'rollback' | 'released'; desired_revision: number | null; activated_at: string | null
}
export class PolicyPinnedError extends Error {
readonly code = 'POLICY_PINNED'
readonly statusCode = 409
constructor() { super('Release the policy pin before changing this agent policy') }
}
export function getPolicyPin(db: Db, agentId: string) {
return db.get<{ agent_id: string; snapshot_id: string; rollout_id: string }>(sql`SELECT * FROM policy_pins WHERE agent_id = ${agentId}`)
}
export function assertAgentPolicyMutable(db: Db, agentId: string) {
if (getPolicyPin(db, agentId)) throw new PolicyPinnedError()
}
export function getPolicySnapshot(db: Db, id: string) { return db.get<PolicySnapshotRow>(sql`SELECT * FROM policy_snapshots WHERE id = ${id}`) }
export function getPolicySnapshotHash(db: Db, id: string) { return db.get<{ hash: string }>(sql`SELECT hash FROM policy_snapshots WHERE id = ${id}`)?.hash }
export function findPolicySnapshot(db: Db, contentHash: string) { return db.get<PolicySnapshotRow>(sql`SELECT * FROM policy_snapshots WHERE content_hash = ${contentHash}`) }
export function listPolicySnapshots(db: Db, limit: number, offset: number) {
return db.all<Omit<PolicySnapshotRow, 'payload_json'>>(sql`SELECT id,name,source_agent_id,source_generation,hash,content_hash,payload_bytes,created_at FROM policy_snapshots ORDER BY created_at DESC,id DESC LIMIT ${limit} OFFSET ${offset}`)
}
export function policySnapshotCapacity(db: Db) {
return db.get<{ count: number; bytes: number }>(sql`SELECT count(*) AS count,coalesce(sum(length(CAST(payload_json AS BLOB))),0) AS bytes FROM policy_snapshots`)!
}
export function insertPolicySnapshot(db: Db, row: PolicySnapshotRow) {
db.run(sql`INSERT INTO policy_snapshots VALUES (${row.id},${row.name},${row.source_agent_id},${row.source_generation},${row.hash},${row.content_hash},${row.payload_json},${row.payload_bytes},${row.created_at})`)
}
export function deletePolicySnapshot(db: Db, id: string) {
return db.run(sql`DELETE FROM policy_snapshots WHERE id = ${id} AND NOT EXISTS (SELECT 1 FROM policy_rollouts WHERE snapshot_id = ${id}) AND NOT EXISTS (SELECT 1 FROM policy_rollout_targets WHERE previous_snapshot_id = ${id}) AND NOT EXISTS (SELECT 1 FROM policy_pins WHERE snapshot_id = ${id})`).changes
}
export function insertPolicyRollout(db: Db, row: PolicyRolloutRow) {
db.run(sql`INSERT INTO policy_rollouts VALUES (${row.id},${row.snapshot_id},${row.state},${row.version},${row.created_at},${row.updated_at})`)
}
export function getPolicyRollout(db: Db, id: string) { return db.get<PolicyRolloutRow>(sql`SELECT * FROM policy_rollouts WHERE id = ${id}`) }
export function listPolicyRollouts(db: Db, limit: number, offset: number) { return db.all<PolicyRolloutRow>(sql`SELECT * FROM policy_rollouts ORDER BY created_at DESC,id DESC LIMIT ${limit} OFFSET ${offset}`) }
export function countActivePolicyRollouts(db: Db) { return db.get<{ n: number }>(sql`SELECT count(*) AS n FROM policy_rollouts WHERE state = 'canary'`)!.n }
export function countPolicyRollouts(db: Db) { return db.get<{ n: number }>(sql`SELECT count(*) AS n FROM policy_rollouts`)!.n }
export function deletePolicyRollout(db: Db, id: string) {
return db.run(sql`DELETE FROM policy_rollouts WHERE id=${id} AND state != 'canary' AND NOT EXISTS (SELECT 1 FROM policy_pins WHERE rollout_id=${id})`).changes
}
export function listPolicyRolloutTargets(db: Db, rolloutId: string) { return db.all<PolicyRolloutTargetRow>(sql`SELECT * FROM policy_rollout_targets WHERE rollout_id = ${rolloutId} ORDER BY agent_id LIMIT 100`) }
export function insertPolicyRolloutTarget(db: Db, row: PolicyRolloutTargetRow) {
db.run(sql`INSERT INTO policy_rollout_targets VALUES (${row.rollout_id},${row.agent_id},${row.previous_snapshot_id},${row.baseline_generation},${row.previous_delivered_hash},${row.previous_delivered_revision},${row.previous_applied_hash},${row.previous_applied_revision},${row.is_canary},${row.phase},${row.desired_revision},${row.activated_at})`)
}
export function activatePolicyPin(db: Db, rolloutId: string, agentId: string, snapshotId: string, phase: 'candidate' | 'rollback', now: string) {
db.run(sql`INSERT INTO policy_pins VALUES (${agentId},${snapshotId},${rolloutId}) ON CONFLICT(agent_id) DO UPDATE SET snapshot_id=excluded.snapshot_id,rollout_id=excluded.rollout_id`)
db.run(sql`UPDATE agents SET policy_generation = policy_generation + 1, desired_hash=(SELECT hash FROM policy_snapshots WHERE id=${snapshotId}), desired_hash_generation=policy_generation+1,last_policy_delivered_at=NULL WHERE id = ${agentId}`)
const revision = db.get<{ revision: number }>(sql`SELECT policy_generation AS revision FROM agents WHERE id = ${agentId}`)!.revision
db.run(sql`UPDATE policy_rollout_targets SET phase=${phase},desired_revision=${revision},activated_at=${now} WHERE rollout_id=${rolloutId} AND agent_id=${agentId}`)
return revision
}
export function transitionPolicyRollout(db: Db, id: string, version: number, state: PolicyRolloutRow['state'], now: string) {
return db.run(sql`UPDATE policy_rollouts SET state=${state},version=version+1,updated_at=${now} WHERE id=${id} AND version=${version}`).changes
}
export function releasePolicyPin(db: Db, agentId: string, rolloutId: string, hash: string) {
db.run(sql`DELETE FROM policy_pins WHERE agent_id=${agentId} AND rollout_id=${rolloutId}`)
db.run(sql`UPDATE policy_rollout_targets SET phase='released' WHERE rollout_id=${rolloutId} AND agent_id=${agentId}`)
db.run(sql`UPDATE agents SET policy_generation=policy_generation+1,desired_hash=${hash},desired_hash_generation=policy_generation+1,last_policy_delivered_at=NULL WHERE id=${agentId}`)
}
+125 -140
View File
@@ -1,4 +1,4 @@
import { and, eq, desc, sql, inArray, lt } from 'drizzle-orm'
import { and, or, eq, desc, sql, inArray, lt, gt } from 'drizzle-orm'
import type { Db } from '../client.js'
import {
agentIpBlockStats,
@@ -64,6 +64,49 @@ export type UpsertIpBlockStatsOptions = {
mode?: 'absolute' | 'presence'
/** Override re-hit gap (tests). Default PRESENCE_REHIT_STALE_MS. */
presenceStaleMs?: number
counterEpoch?: string
onOverflow?: () => void
onUnknownInterval?: () => void
}
function addCounter(previous: number, delta: number, onOverflow?: () => void): number {
const value = previous + delta
if (Number.isSafeInteger(value)) return value
if (!onOverflow) throw new Error('Counter total exceeds safe integer range')
onOverflow()
return previous
}
// Keep parameter count and SELECT result cardinality bounded by submitted keys.
// Maps are updated after each write, preserving sequential duplicate semantics.
const DETAIL_PREFETCH_SIZE = 200
function chunks<T>(rows: T[]): T[][] {
const result: T[][] = []
for (let offset = 0; offset < rows.length; offset += DETAIL_PREFETCH_SIZE) result.push(rows.slice(offset, offset + DETAIL_PREFETCH_SIZE))
return result
}
function ipStatWriter(db: Db) {
return db.insert(agentIpBlockStats).values({
id: sql.placeholder('id'), agentId: sql.placeholder('agentId'), ip: sql.placeholder('ip'),
packets: sql.placeholder('packets'), lastReportedPackets: sql.placeholder('lastReportedPackets'),
counterEpoch: sql.placeholder('counterEpoch'), firstSeenAt: sql.placeholder('firstSeenAt'), lastSeenAt: sql.placeholder('lastSeenAt'),
}).onConflictDoUpdate({ target: [agentIpBlockStats.agentId, agentIpBlockStats.ip], set: {
packets: sql`excluded.packets`, lastReportedPackets: sql`excluded.last_reported_packets`,
counterEpoch: sql`excluded.counter_epoch`, lastSeenAt: sql`excluded.last_seen_at`,
} }).prepare()
}
function portStatWriter(db: Db) {
return db.insert(agentPortBlockStats).values({
id: sql.placeholder('id'), agentId: sql.placeholder('agentId'), ip: sql.placeholder('ip'),
port: sql.placeholder('port'), protocol: sql.placeholder('protocol'),
packets: sql.placeholder('packets'), lastReportedPackets: sql.placeholder('lastReportedPackets'),
counterEpoch: sql.placeholder('counterEpoch'), firstSeenAt: sql.placeholder('firstSeenAt'), lastSeenAt: sql.placeholder('lastSeenAt'),
}).onConflictDoUpdate({ target: [agentPortBlockStats.agentId, agentPortBlockStats.ip, agentPortBlockStats.port, agentPortBlockStats.protocol], set: {
packets: sql`excluded.packets`, lastReportedPackets: sql`excluded.last_reported_packets`,
counterEpoch: sql`excluded.counter_epoch`, lastSeenAt: sql`excluded.last_seen_at`,
} }).prepare()
}
/**
@@ -72,99 +115,60 @@ export type UpsertIpBlockStatsOptions = {
* - presence (MikroTik): last_seen while in HITS; packets = entries into HITS (not per-sync).
*/
export function upsertIpBlockStats(
db: Db,
agentId: string,
hits: IpHitInput[],
now = new Date().toISOString(),
db: Db, agentId: string, hits: IpHitInput[], now = new Date().toISOString(),
opts: UpsertIpBlockStatsOptions = {},
) {
const mode = opts.mode ?? 'absolute'
const staleMs = opts.presenceStaleMs ?? PRESENCE_REHIT_STALE_MS
const nowMs = Date.parse(now)
for (const hit of hits) {
const ip = hit.ip.trim()
if (!ip) continue
const reported = Math.max(0, Math.floor(hit.packets))
const existing = db
.select()
.from(agentIpBlockStats)
.where(
and(
eq(agentIpBlockStats.agentId, agentId),
eq(agentIpBlockStats.ip, ip),
),
)
.get()
if (mode === 'presence') {
if (!existing) {
db.insert(agentIpBlockStats)
.values({
id: crypto.randomUUID(),
agentId,
ip,
packets: 1,
lastReportedPackets: 1,
firstSeenAt: now,
lastSeenAt: now,
})
.run()
continue
if (hits.length === 0) return
db.transaction((tx) => {
const mode = opts.mode ?? 'absolute'
const staleMs = opts.presenceStaleMs ?? PRESENCE_REHIT_STALE_MS
const nowMs = Date.parse(now)
const keys = [...new Set(hits.map((hit) => hit.ip.trim()).filter(Boolean))]
const existingByIp = new Map<string, typeof agentIpBlockStats.$inferSelect>()
for (const batch of chunks(keys)) {
for (const row of tx.select().from(agentIpBlockStats).where(and(eq(agentIpBlockStats.agentId, agentId), inArray(agentIpBlockStats.ip, batch))).all()) existingByIp.set(row.ip, row)
}
const write = ipStatWriter(tx)
for (const hit of hits) {
const ip = hit.ip.trim()
if (!ip) continue
const reported = Math.max(0, Math.floor(hit.packets))
const existing = existingByIp.get(ip)
let row: typeof agentIpBlockStats.$inferSelect
if (mode === 'presence') {
if (!existing) {
row = { id: crypto.randomUUID(), agentId, ip, packets: 1, lastReportedPackets: 1, counterEpoch: null, firstSeenAt: now, lastSeenAt: now }
} else {
const prevSeen = Date.parse(existing.lastSeenAt)
const gapMs = Number.isFinite(prevSeen) ? nowMs - prevSeen : staleMs + 1
const isRehit = gapMs > staleMs
row = { ...existing, lastSeenAt: now,
packets: isRehit ? addCounter(existing.packets, 1, opts.onOverflow) : existing.packets,
lastReportedPackets: isRehit ? addCounter(existing.lastReportedPackets, 1, opts.onOverflow) : existing.lastReportedPackets }
}
} else if (!existing) {
if (opts.counterEpoch !== undefined) opts.onUnknownInterval?.()
row = { id: crypto.randomUUID(), agentId, ip, packets: opts.counterEpoch === undefined ? reported : 0,
lastReportedPackets: reported, counterEpoch: opts.counterEpoch ?? null, firstSeenAt: now, lastSeenAt: now }
} else {
const previous = existing.lastReportedPackets
if (opts.counterEpoch !== undefined && (existing.counterEpoch !== opts.counterEpoch || reported < previous)) opts.onUnknownInterval?.()
const delta = opts.counterEpoch !== undefined
? (existing.counterEpoch === opts.counterEpoch && reported >= previous ? reported - previous : 0)
: (reported >= previous ? reported - previous : reported)
row = { ...existing, packets: addCounter(existing.packets, delta, opts.onOverflow), lastReportedPackets: reported,
counterEpoch: opts.counterEpoch ?? existing.counterEpoch, lastSeenAt: delta > 0 ? now : existing.lastSeenAt }
}
const prevSeen = Date.parse(existing.lastSeenAt)
const gapMs = Number.isFinite(prevSeen) ? nowMs - prevSeen : staleMs + 1
const isRehit = gapMs > staleMs
db.update(agentIpBlockStats)
.set({
lastSeenAt: now,
...(isRehit
? {
packets: (existing.packets ?? 0) + 1,
lastReportedPackets: (existing.lastReportedPackets ?? 0) + 1,
}
: {}),
})
.where(eq(agentIpBlockStats.id, existing.id))
.run()
continue
write.run(row)
existingByIp.set(ip, row)
}
if (!existing) {
db.insert(agentIpBlockStats)
.values({
id: crypto.randomUUID(),
agentId,
ip,
packets: reported,
lastReportedPackets: reported,
firstSeenAt: now,
lastSeenAt: now,
})
.run()
continue
}
const prevReported = existing.lastReportedPackets ?? 0
const delta =
reported >= prevReported ? reported - prevReported : reported
const packets = (existing.packets ?? 0) + delta
db.update(agentIpBlockStats)
.set({
packets,
lastReportedPackets: reported,
...(delta > 0 ? { lastSeenAt: now } : {}),
})
.where(eq(agentIpBlockStats.id, existing.id))
.run()
}
})
}
export function listIpBlockStats(db: Db, agentId: string, limit = 200) {
return db
.select()
.from(agentIpBlockStats)
.where(eq(agentIpBlockStats.agentId, agentId))
.where(and(eq(agentIpBlockStats.agentId, agentId), gt(agentIpBlockStats.packets, 0)))
.orderBy(desc(agentIpBlockStats.packets))
.limit(limit)
.all()
@@ -202,63 +206,44 @@ export type PortHitInput = {
* baselines are not auto-reset on Traffic flush — only on stats/reset delete.
*/
export function upsertPortBlockStats(
db: Db,
agentId: string,
hits: PortHitInput[],
now = new Date().toISOString(),
db: Db, agentId: string, hits: PortHitInput[], now = new Date().toISOString(),
opts: { counterEpoch?: string; onOverflow?: () => void; onUnknownInterval?: () => void } = {},
) {
for (const hit of hits) {
const ip = hit.ip.trim()
const protocol = String(hit.protocol).toLowerCase()
if (!ip || (protocol !== 'tcp' && protocol !== 'udp')) continue
const port = Math.floor(hit.port)
if (port < 1 || port > 65535) continue
const reported = Math.max(0, Math.floor(hit.packets))
const existing = db
.select()
.from(agentPortBlockStats)
.where(
and(
eq(agentPortBlockStats.agentId, agentId),
eq(agentPortBlockStats.ip, ip),
eq(agentPortBlockStats.port, port),
eq(agentPortBlockStats.protocol, protocol),
),
)
.get()
if (!existing) {
db.insert(agentPortBlockStats)
.values({
id: crypto.randomUUID(),
agentId,
ip,
port,
protocol,
packets: reported,
lastReportedPackets: reported,
firstSeenAt: now,
lastSeenAt: now,
})
.run()
continue
if (hits.length === 0) return
db.transaction((tx) => {
const normalized = hits.map((hit) => ({ ip: hit.ip.trim(), protocol: String(hit.protocol).toLowerCase(), port: Math.floor(hit.port), reported: Math.max(0, Math.floor(hit.packets)) }))
.filter((hit) => hit.ip && (hit.protocol === 'tcp' || hit.protocol === 'udp') && hit.port >= 1 && hit.port <= 65535)
const keyOf = (hit: { ip: string; port: number; protocol: string }) => JSON.stringify([hit.ip, hit.port, hit.protocol])
const keys = [...new Map(normalized.map((hit) => [keyOf(hit), hit])).values()]
const existingByKey = new Map<string, typeof agentPortBlockStats.$inferSelect>()
for (const batch of chunks(keys)) {
const matches = batch.map((hit) => and(eq(agentPortBlockStats.ip, hit.ip), eq(agentPortBlockStats.port, hit.port), eq(agentPortBlockStats.protocol, hit.protocol)))
for (const row of tx.select().from(agentPortBlockStats).where(and(eq(agentPortBlockStats.agentId, agentId), or(...matches))).all()) existingByKey.set(keyOf(row), row)
}
const prevReported = existing.lastReportedPackets ?? 0
const delta =
reported >= prevReported ? reported - prevReported : reported
const packets = (existing.packets ?? 0) + delta
db.update(agentPortBlockStats)
.set({
packets,
lastReportedPackets: reported,
...(delta > 0 ? { lastSeenAt: now } : {}),
})
.where(eq(agentPortBlockStats.id, existing.id))
.run()
}
const write = portStatWriter(tx)
for (const hit of normalized) {
const key = keyOf(hit)
const existing = existingByKey.get(key)
const { ip, port, protocol, reported } = hit
let row: typeof agentPortBlockStats.$inferSelect
if (!existing) {
if (opts.counterEpoch !== undefined) opts.onUnknownInterval?.()
row = { id: crypto.randomUUID(), agentId, ip, port, protocol, packets: opts.counterEpoch === undefined ? reported : 0,
lastReportedPackets: reported, counterEpoch: opts.counterEpoch ?? null, firstSeenAt: now, lastSeenAt: now }
} else {
const previous = existing.lastReportedPackets
if (opts.counterEpoch !== undefined && (existing.counterEpoch !== opts.counterEpoch || reported < previous)) opts.onUnknownInterval?.()
const delta = opts.counterEpoch !== undefined
? (existing.counterEpoch === opts.counterEpoch && reported >= previous ? reported - previous : 0)
: (reported >= previous ? reported - previous : reported)
row = { ...existing, packets: addCounter(existing.packets, delta, opts.onOverflow), lastReportedPackets: reported,
counterEpoch: opts.counterEpoch ?? existing.counterEpoch, lastSeenAt: delta > 0 ? now : existing.lastSeenAt }
}
write.run(row)
existingByKey.set(key, row)
}
})
}
export type PortBlockAggregateRow = {
port: number
protocol: string
@@ -279,7 +264,7 @@ export function listPortBlockStatsAggregate(
lastSeenAt: sql<string>`max(${agentPortBlockStats.lastSeenAt})`,
})
.from(agentPortBlockStats)
.where(eq(agentPortBlockStats.agentId, agentId))
.where(and(eq(agentPortBlockStats.agentId, agentId), gt(agentPortBlockStats.packets, 0)))
.groupBy(agentPortBlockStats.port, agentPortBlockStats.protocol)
.orderBy(sql`sum(${agentPortBlockStats.packets}) desc`)
.limit(limit)
+25
View File
@@ -0,0 +1,25 @@
import { and, eq } from 'drizzle-orm'
import type { Db } from '../client.js'
import { agentTelemetryState, agentStatsSamples, agentIpBlockStats, agentPortBlockStats, agents } from '../schema.js'
export function getTelemetryState(db: Db, agentId: string) {
return db.select().from(agentTelemetryState).where(eq(agentTelemetryState.agentId, agentId)).get()
}
export function hasTelemetryReport(db: Db, agentId: string, reportId: string) {
return !!db.select({ id: agentStatsSamples.id }).from(agentStatsSamples)
.where(and(eq(agentStatsSamples.agentId, agentId), eq(agentStatsSamples.reportId, reportId))).get()
}
export function upsertTelemetryState(db: Db, row: typeof agentTelemetryState.$inferInsert) {
db.insert(agentTelemetryState).values(row).onConflictDoUpdate({ target: agentTelemetryState.agentId, set: row }).run()
}
/** Reset displayed totals only. Device baselines, dedup watermark and history survive. */
export function resetDisplayedStats(db: Db, agentId: string) {
db.transaction((tx) => {
tx.update(agents).set({ totalPacketsDropped: 0, totalPacketsAccepted: 0 }).where(eq(agents.id, agentId)).run()
tx.update(agentIpBlockStats).set({ packets: 0 }).where(eq(agentIpBlockStats.agentId, agentId)).run()
tx.update(agentPortBlockStats).set({ packets: 0 }).where(eq(agentPortBlockStats.agentId, agentId)).run()
})
}
+66 -1
View File
@@ -1,4 +1,4 @@
import { sqliteTable, text, integer, uniqueIndex, index } from 'drizzle-orm/sqlite-core'
import { sqliteTable, text, integer, real, primaryKey, uniqueIndex, index } from 'drizzle-orm/sqlite-core'
import { sql } from 'drizzle-orm'
export const settings = sqliteTable('settings', {
@@ -35,6 +35,17 @@ export const agents = sqliteTable(
totalPacketsAccepted: integer('total_packets_accepted').notNull().default(0),
lastApplyKernelMethod: text('last_apply_kernel_method'),
clientVersion: text('client_version'),
telemetryVersion: integer('telemetry_version'),
lastTelemetryAt: text('last_telemetry_at'),
appliedHash: text('applied_hash'),
appliedRevision: integer('applied_revision'),
desiredHash: text('desired_hash'),
desiredHashGeneration: integer('desired_hash_generation'),
lastPolicyDeliveredAt: text('last_policy_delivered_at'),
capabilitiesJson: text('capabilities_json').notNull().default('[]'),
qualityJson: text('quality_json').notNull().default('{"partial":true,"truncated":false,"unsupported":["telemetry_v2"],"lost_reports":0}'),
applyErrorJson: text('apply_error_json'),
detailCountsJson: text('detail_counts_json'),
settingsJson: text('settings_json').notNull().default('{}'),
createdByUserId: text('created_by_user_id'),
createdAt: text('created_at')
@@ -171,6 +182,10 @@ export const ipOverrides = sqliteTable(
cidr: text('cidr').notNull(),
action: text('action').notNull(), // allow | deny
comment: text('comment'),
reason: text('reason'),
createdByName: text('created_by_name'),
expiresAt: text('expires_at'),
expiredAt: text('expired_at'),
createdByUserId: text('created_by_user_id'),
createdAt: text('created_at')
.notNull()
@@ -192,6 +207,20 @@ export const agentStatsSamples = sqliteTable(
packetsAccepted: integer('packets_accepted').notNull().default(0),
prefixCount: integer('prefix_count').notNull().default(0),
kernelMethod: text('kernel_method'),
telemetryVersion: integer('telemetry_version').notNull().default(1),
reportId: text('report_id'),
sequence: integer('sequence'),
observedAt: text('observed_at'),
intervalMs: integer('interval_ms'),
deltaDropped: integer('delta_dropped'),
deltaAccepted: integer('delta_accepted'),
droppedIntervalStart: text('dropped_interval_start'),
acceptedIntervalStart: text('accepted_interval_start'),
droppedPresent: integer('dropped_present'),
acceptedPresent: integer('accepted_present'),
analyticsProcessed: integer('analytics_processed').notNull().default(0),
qualityJson: text('quality_json'),
detailCountsJson: text('detail_counts_json'),
recordedAt: text('recorded_at')
.notNull()
.default(sql`(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))`),
@@ -212,6 +241,7 @@ export const agentIpBlockStats = sqliteTable(
ip: text('ip').notNull(),
packets: integer('packets').notNull().default(0),
lastReportedPackets: integer('last_reported_packets').notNull().default(0),
counterEpoch: text('counter_epoch'),
firstSeenAt: text('first_seen_at')
.notNull()
.default(sql`(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))`),
@@ -241,6 +271,7 @@ export const agentPortBlockStats = sqliteTable(
protocol: text('protocol').notNull(), // tcp | udp
packets: integer('packets').notNull().default(0),
lastReportedPackets: integer('last_reported_packets').notNull().default(0),
counterEpoch: text('counter_epoch'),
firstSeenAt: text('first_seen_at')
.notNull()
.default(sql`(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))`),
@@ -324,6 +355,9 @@ export const agentInstallLinks = sqliteTable(
{
id: text('id').primaryKey(),
slug: text('slug').notNull(),
expiresAt: text('expires_at'),
consumedAt: text('consumed_at'),
consumedTokenHash: text('consumed_token_hash'),
clientName: text('client_name').notNull(),
platform: text('platform').notNull().default('linux'), // linux | mikrotik
agentId: text('agent_id').references(() => agents.id, { onDelete: 'cascade' }),
@@ -369,6 +403,35 @@ export const auditLog = sqliteTable(
export const SHARED_POLICY_SET_ID = 'set-shared-default'
export const agentTelemetryState = sqliteTable('agent_telemetry_state', {
agentId: text('agent_id').primaryKey().references(() => agents.id, { onDelete: 'cascade' }),
sequence: integer('sequence').notNull(),
reportId: text('report_id').notNull(),
bootId: text('boot_id').notNull(),
trafficEpoch: text('traffic_epoch').notNull(),
observedAt: text('observed_at').notNull(),
receivedAt: text('received_at').notNull(),
droppedBaseline: integer('dropped_baseline'),
acceptedBaseline: integer('accepted_baseline'),
droppedBaselineAt: text('dropped_baseline_at'),
acceptedBaselineAt: text('accepted_baseline_at'),
})
export const agentStatsRollups = sqliteTable('agent_stats_rollups', {
agentId: text('agent_id').notNull().references(() => agents.id, { onDelete: 'cascade' }),
resolution: text('resolution').notNull(),
bucketStart: text('bucket_start').notNull(),
packetsDropped: real('packets_dropped').notNull().default(0),
packetsAccepted: real('packets_accepted').notNull().default(0),
droppedCoverageMs: integer('dropped_coverage_ms').notNull().default(0),
acceptedCoverageMs: integer('accepted_coverage_ms').notNull().default(0),
partial: integer('partial').notNull().default(0),
truncated: integer('truncated').notNull().default(0),
estimated: integer('estimated').notNull().default(0),
unsupportedJson: text('unsupported_json').notNull().default('[]'),
lostReports: integer('lost_reports').notNull().default(0),
}, (t) => ({ key: primaryKey({ columns: [t.agentId, t.resolution, t.bucketStart] }) }))
export const schema = {
settings,
agents,
@@ -380,6 +443,8 @@ export const schema = {
policyRuleResolved,
ipOverrides,
agentStatsSamples,
agentTelemetryState,
agentStatsRollups,
agentIpBlockStats,
agentPortBlockStats,
agentPortRules,
+48 -16
View File
@@ -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>
+30
View File
@@ -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>
+16
View File
@@ -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>
+37
View File
@@ -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>
+16
View File
@@ -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 }
+12
View File
@@ -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'
+10 -51
View File
@@ -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)
+21
View File
@@ -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' })
})
})
+92
View File
@@ -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
}
+18 -10
View File
@@ -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
+3
View File
@@ -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