feat: реализовать EvoFirewall V1 control plane
API, UI, Linux/MikroTik agents, IP lists, политики, stats, CI и интеграция с auth-portal/EvoBGP. Co-authored-by: Cursor <[email protected]>
This commit is contained in:
@@ -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()
|
||||
}
|
||||
@@ -1 +1,3 @@
|
||||
export {}
|
||||
export * from './schema.js'
|
||||
export * from './client.js'
|
||||
export * from './repositories/index.js'
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
Reference in New Issue
Block a user