feat: реализовать EvoFirewall V1 control plane
Build and Push EvoFirewall Docker Image / build-and-push (push) Failing after 25s
Build and Push EvoFirewall Docker Image / create-release (push) Skipped

API, UI, Linux/MikroTik agents, IP lists, политики, stats, CI и интеграция с auth-portal/EvoBGP.

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
Denozordec
2026-07-20 19:50:54 +07:00
co-authored by Cursor
parent d71b45d86f
commit ebadf70e2b
107 changed files with 15196 additions and 99 deletions
+8
View File
@@ -0,0 +1,8 @@
import { defineConfig } from 'drizzle-kit'
export default defineConfig({
schema: './src/schema.ts',
out: './drizzle',
dialect: 'sqlite',
dbCredentials: { url: 'data/app.db' },
})
View File
+109
View File
@@ -0,0 +1,109 @@
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL DEFAULT '',
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
);
CREATE TABLE IF NOT EXISTS agents (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
hostname TEXT,
platform TEXT NOT NULL DEFAULT 'linux',
token_prefix TEXT NOT NULL,
token_hash TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
policy_mode TEXT NOT NULL DEFAULT 'blacklist',
policy_generation INTEGER NOT NULL DEFAULT 1,
last_seen_at TEXT,
last_seen_ip TEXT,
last_apply_at TEXT,
last_apply_status TEXT,
last_apply_error TEXT,
last_apply_prefix_count INTEGER DEFAULT 0,
last_apply_packets_dropped INTEGER NOT NULL DEFAULT 0,
last_apply_packets_accepted INTEGER NOT NULL DEFAULT 0,
last_apply_kernel_method TEXT,
client_version TEXT,
settings_json TEXT NOT NULL DEFAULT '{}',
created_by_user_id TEXT,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
approved_at TEXT,
revoked_at TEXT,
CHECK (status IN ('pending', 'approved', 'revoked')),
CHECK (platform IN ('linux', 'mikrotik')),
CHECK (policy_mode IN ('blacklist', 'whitelist')),
CHECK (length(trim(name)) > 0)
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_agents_token_hash ON agents (token_hash);
CREATE INDEX IF NOT EXISTS idx_agents_status ON agents (status);
CREATE TABLE IF NOT EXISTS ip_lists (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
type TEXT NOT NULL,
config_json TEXT NOT NULL DEFAULT '{}',
content_hash TEXT,
refreshed_at TEXT,
last_error TEXT,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
CHECK (type IN ('static', 'json_url', 'domains', 'evobgp_community'))
);
CREATE TABLE IF NOT EXISTS ip_list_entries (
id TEXT PRIMARY KEY,
list_id TEXT NOT NULL REFERENCES ip_lists (id) ON DELETE CASCADE,
cidr TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_ip_list_entries_list_cidr ON ip_list_entries (list_id, cidr);
CREATE TABLE IF NOT EXISTS policy_rules (
id TEXT PRIMARY KEY,
agent_id TEXT REFERENCES agents (id) ON DELETE CASCADE,
priority INTEGER NOT NULL,
action TEXT NOT NULL,
list_id TEXT REFERENCES ip_lists (id) ON DELETE CASCADE,
cidr TEXT,
comment TEXT,
created_by_user_id TEXT,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
CHECK (action IN ('allow', 'deny')),
CHECK (priority >= 1 AND priority <= 10000)
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_policy_rules_agent_priority ON policy_rules (agent_id, priority);
CREATE TABLE IF NOT EXISTS ip_overrides (
id TEXT PRIMARY KEY,
agent_id TEXT NOT NULL REFERENCES agents (id) ON DELETE CASCADE,
cidr TEXT NOT NULL,
action TEXT NOT NULL,
comment TEXT,
created_by_user_id TEXT,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
CHECK (action IN ('allow', 'deny'))
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_ip_overrides_agent_cidr ON ip_overrides (agent_id, cidr);
CREATE TABLE IF NOT EXISTS agent_stats_samples (
id TEXT PRIMARY KEY,
agent_id TEXT NOT NULL REFERENCES agents (id) ON DELETE CASCADE,
packets_dropped INTEGER NOT NULL DEFAULT 0,
packets_accepted INTEGER NOT NULL DEFAULT 0,
prefix_count INTEGER NOT NULL DEFAULT 0,
kernel_method TEXT,
recorded_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
);
CREATE INDEX IF NOT EXISTS idx_agent_stats_agent_time ON agent_stats_samples (agent_id, recorded_at);
INSERT OR IGNORE INTO settings (key, value) VALUES ('enroll_seed', '');
INSERT OR IGNORE INTO settings (key, value) VALUES ('evobgp_api_url', '');
INSERT OR IGNORE INTO settings (key, value) VALUES ('evobgp_api_token', '');
INSERT OR IGNORE INTO settings (key, value) VALUES ('list_refresh_cron', '*/5 * * * *');
INSERT OR IGNORE INTO settings (key, value) VALUES ('agent_sync_interval_sec', '60');
+21 -4
View File
@@ -1,13 +1,30 @@
{
"name": "@evofw/db",
"private": true,
"version": "0.0.0",
"private": true,
"type": "module",
"files": ["dist", "migrations", "package.json"],
"exports": {
".": "./src/index.ts"
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"scripts": {
"build": "echo \"db: scaffold — skip\"",
"lint": "echo \"db: scaffold — skip\""
"build": "tsup src/index.ts --format esm --dts",
"dev": "tsup src/index.ts --format esm --dts --watch",
"db:generate": "drizzle-kit generate",
"db:push": "drizzle-kit push"
},
"dependencies": {
"@evofw/shared": "workspace:*",
"better-sqlite3": "^11.10.0",
"drizzle-orm": "^0.44.2"
},
"devDependencies": {
"@types/better-sqlite3": "^7.6.13",
"drizzle-kit": "^0.31.1",
"tsup": "^8.5.0",
"typescript": "^5.8.3"
}
}
+73
View File
@@ -0,0 +1,73 @@
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { readFileSync, readdirSync, mkdirSync } from 'node:fs'
import Database from 'better-sqlite3'
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>>
const __dirname = dirname(fileURLToPath(import.meta.url))
export function resolveDatabasePath(databaseUrl: string): string {
const url = databaseUrl.startsWith('sqlite:')
? databaseUrl.slice('sqlite:'.length)
: databaseUrl
return url
}
export function createDb(databaseUrl: string): { db: Db; sqlite: Sqlite } {
const path = resolveDatabasePath(databaseUrl)
const dir = dirname(path)
if (dir && dir !== '.') {
try {
mkdirSync(dir, { recursive: true })
} catch {
/* exists */
}
}
const sqlite = new Database(path)
sqlite.pragma('journal_mode = WAL')
sqlite.pragma('synchronous = NORMAL')
sqlite.pragma('foreign_keys = ON')
const db = drizzle(sqlite, { schema })
return { db, sqlite }
}
export function createMemoryDb(): { db: Db; sqlite: Sqlite } {
const sqlite = new Database(':memory:')
sqlite.pragma('foreign_keys = ON')
const db = drizzle(sqlite, { schema })
return { db, sqlite }
}
export function runMigrations(sqlite: Sqlite): void {
const migrationsDir = join(__dirname, '..', 'migrations')
const files = readdirSync(migrationsDir)
.filter((f) => f.endsWith('.sql'))
.sort()
sqlite.exec(
`CREATE TABLE IF NOT EXISTS _migrations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
applied_at TEXT NOT NULL DEFAULT (datetime('now'))
)`,
)
for (const file of files) {
const applied = sqlite
.prepare('SELECT 1 FROM _migrations WHERE name = ?')
.get(file)
if (applied) continue
const sql = readFileSync(join(migrationsDir, file), 'utf-8')
sqlite.exec(sql)
sqlite.prepare('INSERT INTO _migrations (name) VALUES (?)').run(file)
}
}
export function healthCheck(sqlite: Sqlite): void {
sqlite.prepare('SELECT 1').get()
}
+3 -1
View File
@@ -1 +1,3 @@
export {}
export * from './schema.js'
export * from './client.js'
export * from './repositories/index.js'
+289
View File
@@ -0,0 +1,289 @@
import { eq, and, desc, isNull, sql } from 'drizzle-orm'
import type { Db } from '../client.js'
import {
agents,
ipLists,
ipListEntries,
policyRules,
ipOverrides,
agentStatsSamples,
settings,
} from '../schema.js'
export function listAgents(db: Db) {
return db.select().from(agents).orderBy(desc(agents.createdAt)).all()
}
export function getAgent(db: Db, id: string) {
return db.select().from(agents).where(eq(agents.id, id)).get()
}
export function getAgentByTokenHash(db: Db, tokenHash: string) {
return db.select().from(agents).where(eq(agents.tokenHash, tokenHash)).get()
}
export function insertAgent(
db: Db,
row: typeof agents.$inferInsert,
) {
db.insert(agents).values(row).run()
return getAgent(db, row.id)
}
export function updateAgent(
db: Db,
id: string,
patch: Partial<typeof agents.$inferInsert>,
) {
db.update(agents).set(patch).where(eq(agents.id, id)).run()
return getAgent(db, id)
}
export function deleteAgent(db: Db, id: string) {
db.delete(agents).where(eq(agents.id, id)).run()
}
export function bumpAgentGeneration(db: Db, id: string) {
db.update(agents)
.set({ policyGeneration: sql`${agents.policyGeneration} + 1` })
.where(eq(agents.id, id))
.run()
}
export function listIpLists(db: Db) {
return db.select().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()
}
export function insertIpList(db: Db, row: typeof ipLists.$inferInsert) {
db.insert(ipLists).values(row).run()
return getIpList(db, row.id)
}
export function updateIpList(
db: Db,
id: string,
patch: Partial<typeof ipLists.$inferInsert>,
) {
db.update(ipLists)
.set({ ...patch, updatedAt: new Date().toISOString() })
.where(eq(ipLists.id, id))
.run()
return getIpList(db, id)
}
export function deleteIpList(db: Db, id: string) {
db.delete(ipLists).where(eq(ipLists.id, id)).run()
}
export function listIpListEntries(db: Db, listId: string) {
return db
.select()
.from(ipListEntries)
.where(eq(ipListEntries.listId, listId))
.all()
}
export function replaceIpListEntries(db: Db, listId: string, cidrs: string[]) {
db.delete(ipListEntries).where(eq(ipListEntries.listId, listId)).run()
const now = new Date().toISOString()
for (const cidr of cidrs) {
db.insert(ipListEntries)
.values({
id: crypto.randomUUID(),
listId,
cidr,
createdAt: now,
})
.run()
}
}
export function listPolicyRules(db: Db, agentId?: string | null) {
if (agentId === undefined) {
return db.select().from(policyRules).orderBy(policyRules.priority).all()
}
if (agentId === null) {
return db
.select()
.from(policyRules)
.where(isNull(policyRules.agentId))
.orderBy(policyRules.priority)
.all()
}
return db
.select()
.from(policyRules)
.where(eq(policyRules.agentId, agentId))
.orderBy(policyRules.priority)
.all()
}
export function getPolicyRule(db: Db, id: string) {
return db.select().from(policyRules).where(eq(policyRules.id, id)).get()
}
export function insertPolicyRule(
db: Db,
row: typeof policyRules.$inferInsert,
) {
db.insert(policyRules).values(row).run()
return getPolicyRule(db, row.id)
}
export function deletePolicyRule(db: Db, id: string) {
db.delete(policyRules).where(eq(policyRules.id, id)).run()
}
export function listOverrides(db: Db, agentId: string) {
return db
.select()
.from(ipOverrides)
.where(eq(ipOverrides.agentId, agentId))
.all()
}
export function insertOverride(
db: Db,
row: typeof ipOverrides.$inferInsert,
) {
db.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 insertStatsSample(
db: Db,
row: typeof agentStatsSamples.$inferInsert,
) {
db.insert(agentStatsSamples).values(row).run()
}
export function listStatsSamples(db: Db, agentId: string, limit = 100) {
return db
.select()
.from(agentStatsSamples)
.where(eq(agentStatsSamples.agentId, agentId))
.orderBy(desc(agentStatsSamples.recordedAt))
.limit(limit)
.all()
}
export function listRecentStats(db: Db, limit = 500) {
return db
.select()
.from(agentStatsSamples)
.orderBy(desc(agentStatsSamples.recordedAt))
.limit(limit)
.all()
}
export function getSetting(db: Db, key: string): string {
const row = db.select().from(settings).where(eq(settings.key, key)).get()
return row?.value ?? ''
}
export function setSetting(db: Db, key: string, value: string) {
const now = new Date().toISOString()
const existing = db.select().from(settings).where(eq(settings.key, key)).get()
if (existing) {
db.update(settings)
.set({ value, updatedAt: now })
.where(eq(settings.key, key))
.run()
} else {
db.insert(settings).values({ key, value, updatedAt: now }).run()
}
}
export function listSettings(db: Db) {
return db.select().from(settings).all()
}
export function cloneRulesFrom(
db: Db,
sourceAgentId: string,
targetAgentId: string,
includeOverrides: boolean,
) {
const source = getAgent(db, sourceAgentId)
const target = getAgent(db, targetAgentId)
if (!source || !target) return null
db.delete(policyRules).where(eq(policyRules.agentId, targetAgentId)).run()
const rules = listPolicyRules(db, sourceAgentId)
for (const r of rules) {
db.insert(policyRules)
.values({
id: crypto.randomUUID(),
agentId: targetAgentId,
priority: r.priority,
action: r.action,
listId: r.listId,
cidr: r.cidr,
comment: r.comment,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
})
.run()
}
if (includeOverrides) {
db.delete(ipOverrides).where(eq(ipOverrides.agentId, targetAgentId)).run()
for (const o of listOverrides(db, sourceAgentId)) {
db.insert(ipOverrides)
.values({
id: crypto.randomUUID(),
agentId: targetAgentId,
cidr: o.cidr,
action: o.action,
comment: o.comment,
createdAt: new Date().toISOString(),
})
.run()
}
}
updateAgent(db, targetAgentId, {
policyMode: source.policyMode,
policyGeneration: (target.policyGeneration ?? 1) + 1,
})
return getAgent(db, targetAgentId)
}
export const repos = {
listAgents,
getAgent,
getAgentByTokenHash,
insertAgent,
updateAgent,
deleteAgent,
bumpAgentGeneration,
listIpLists,
getIpList,
insertIpList,
updateIpList,
deleteIpList,
listIpListEntries,
replaceIpListEntries,
listPolicyRules,
getPolicyRule,
insertPolicyRule,
deletePolicyRule,
listOverrides,
insertOverride,
deleteOverride,
insertStatsSample,
listStatsSamples,
listRecentStats,
getSetting,
setSetting,
listSettings,
cloneRulesFrom,
}
+152
View File
@@ -0,0 +1,152 @@
import { sqliteTable, text, integer, uniqueIndex, index } from 'drizzle-orm/sqlite-core'
import { sql } from 'drizzle-orm'
export const settings = sqliteTable('settings', {
key: text('key').primaryKey(),
value: text('value').notNull().default(''),
updatedAt: text('updated_at')
.notNull()
.default(sql`(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))`),
})
export const agents = sqliteTable(
'agents',
{
id: text('id').primaryKey(),
name: text('name').notNull(),
hostname: text('hostname'),
platform: text('platform').notNull().default('linux'), // linux | mikrotik
tokenPrefix: text('token_prefix').notNull(),
tokenHash: text('token_hash').notNull(),
status: text('status').notNull().default('pending'), // pending | approved | revoked
policyMode: text('policy_mode').notNull().default('blacklist'), // blacklist | whitelist
policyGeneration: integer('policy_generation').notNull().default(1),
lastSeenAt: text('last_seen_at'),
lastSeenIp: text('last_seen_ip'),
lastApplyAt: text('last_apply_at'),
lastApplyStatus: text('last_apply_status'),
lastApplyError: text('last_apply_error'),
lastApplyPrefixCount: integer('last_apply_prefix_count').default(0),
lastApplyPacketsDropped: integer('last_apply_packets_dropped').notNull().default(0),
lastApplyPacketsAccepted: integer('last_apply_packets_accepted').notNull().default(0),
lastApplyKernelMethod: text('last_apply_kernel_method'),
clientVersion: text('client_version'),
settingsJson: text('settings_json').notNull().default('{}'),
createdByUserId: text('created_by_user_id'),
createdAt: text('created_at')
.notNull()
.default(sql`(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))`),
approvedAt: text('approved_at'),
revokedAt: text('revoked_at'),
},
(t) => ({
tokenHashIdx: uniqueIndex('idx_agents_token_hash').on(t.tokenHash),
statusIdx: index('idx_agents_status').on(t.status),
}),
)
export const ipLists = sqliteTable('ip_lists', {
id: text('id').primaryKey(),
name: text('name').notNull(),
type: text('type').notNull(), // static | json_url | domains | evobgp_community
configJson: text('config_json').notNull().default('{}'),
contentHash: text('content_hash'),
refreshedAt: text('refreshed_at'),
lastError: text('last_error'),
createdAt: text('created_at')
.notNull()
.default(sql`(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))`),
updatedAt: text('updated_at')
.notNull()
.default(sql`(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))`),
})
export const ipListEntries = sqliteTable(
'ip_list_entries',
{
id: text('id').primaryKey(),
listId: text('list_id')
.notNull()
.references(() => ipLists.id, { onDelete: 'cascade' }),
cidr: text('cidr').notNull(),
createdAt: text('created_at')
.notNull()
.default(sql`(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))`),
},
(t) => ({
listCidr: uniqueIndex('idx_ip_list_entries_list_cidr').on(t.listId, t.cidr),
}),
)
export const policyRules = sqliteTable(
'policy_rules',
{
id: text('id').primaryKey(),
agentId: text('agent_id').references(() => agents.id, { onDelete: 'cascade' }), // null = tenant default
priority: integer('priority').notNull(),
action: text('action').notNull(), // allow | deny
listId: text('list_id').references(() => ipLists.id, { onDelete: 'cascade' }),
cidr: text('cidr'),
comment: text('comment'),
createdByUserId: text('created_by_user_id'),
createdAt: text('created_at')
.notNull()
.default(sql`(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))`),
updatedAt: text('updated_at')
.notNull()
.default(sql`(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))`),
},
(t) => ({
agentPriority: uniqueIndex('idx_policy_rules_agent_priority').on(t.agentId, t.priority),
}),
)
export const ipOverrides = sqliteTable(
'ip_overrides',
{
id: text('id').primaryKey(),
agentId: text('agent_id')
.notNull()
.references(() => agents.id, { onDelete: 'cascade' }),
cidr: text('cidr').notNull(),
action: text('action').notNull(), // allow | deny
comment: text('comment'),
createdByUserId: text('created_by_user_id'),
createdAt: text('created_at')
.notNull()
.default(sql`(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))`),
},
(t) => ({
agentCidr: uniqueIndex('idx_ip_overrides_agent_cidr').on(t.agentId, t.cidr),
}),
)
export const agentStatsSamples = sqliteTable(
'agent_stats_samples',
{
id: text('id').primaryKey(),
agentId: text('agent_id')
.notNull()
.references(() => agents.id, { onDelete: 'cascade' }),
packetsDropped: integer('packets_dropped').notNull().default(0),
packetsAccepted: integer('packets_accepted').notNull().default(0),
prefixCount: integer('prefix_count').notNull().default(0),
kernelMethod: text('kernel_method'),
recordedAt: text('recorded_at')
.notNull()
.default(sql`(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))`),
},
(t) => ({
agentTime: index('idx_agent_stats_agent_time').on(t.agentId, t.recordedAt),
}),
)
export const schema = {
settings,
agents,
ipLists,
ipListEntries,
policyRules,
ipOverrides,
agentStatsSamples,
}
+14
View File
@@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"declaration": true,
"strict": true,
"skipLibCheck": true,
"esModuleInterop": true,
"outDir": "dist",
"rootDir": "src"
},
"include": ["src"]
}