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
+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,
}