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