From d5784b9f354410492867f69d859cbd1986cf5427 Mon Sep 17 00:00:00 2001 From: Denozordec Date: Tue, 21 Jul 2026 01:45:38 +0700 Subject: [PATCH] feat(api, web): enhance agent installation process with invited status and policy support - Updated the agent enrollment process to include an 'invited' status, allowing for better tracking of agent states. - Implemented support for install links that can now include an `install_link_id`, facilitating the transition from invited to pending status upon enrollment. - Enhanced the MikroTik installation script to include the `EvofwInstallLinkId` for better tracking and management. - Added new API endpoints for fetching agent policies and serving MikroTik-specific installation scripts. - Improved the web UI to reflect the new agent statuses and provide copyable installation commands for agents. Co-authored-by: Cursor --- apps/api/src/agent-scripts/install.sh | 9 +- .../src/agent-scripts/mikrotik-install.rsc | 92 ++++++++---- apps/api/src/app.ts | 6 +- apps/api/src/plugins/auth.ts | 1 + apps/api/src/routes/agent.ts | 63 +++++++- apps/api/src/routes/control.ts | 84 +++++++++-- apps/api/src/services/install-links.test.ts | 140 +++++++++++++++++- apps/api/src/services/install-links.ts | 106 +++++++++++-- .../src/services/policy/mikrotik-rsc.test.ts | 64 ++++++++ apps/api/src/services/policy/mikrotik-rsc.ts | 52 +++++++ .../src/components/agents/add-agent-sheet.tsx | 40 +++-- apps/web/src/components/status-badge.tsx | 2 + apps/web/src/hooks/use-copy-to-clipboard.ts | 35 +++++ apps/web/src/routes/_auth/agents/index.tsx | 65 +++++++- apps/web/tsconfig.json | 1 - docs/agents.md | 43 +++++- packages/db/migrations/004_agent_invited.sql | 64 ++++++++ packages/db/src/repositories/index.ts | 15 ++ packages/db/src/schema.ts | 4 +- packages/shared/src/contracts.ts | 11 +- 20 files changed, 793 insertions(+), 104 deletions(-) create mode 100644 apps/api/src/services/policy/mikrotik-rsc.test.ts create mode 100644 apps/api/src/services/policy/mikrotik-rsc.ts create mode 100644 apps/web/src/hooks/use-copy-to-clipboard.ts create mode 100644 packages/db/migrations/004_agent_invited.sql diff --git a/apps/api/src/agent-scripts/install.sh b/apps/api/src/agent-scripts/install.sh index 286dab4..79cfbbf 100644 --- a/apps/api/src/agent-scripts/install.sh +++ b/apps/api/src/agent-scripts/install.sh @@ -43,8 +43,13 @@ CLIENT_TOKEN="$(gen_token)" HOSTNAME="$(hostname -f 2>/dev/null || hostname)" CP_URL="${EVOFW_CP_URL%/}" -ENROLL_BODY=$(printf '{"name":"%s","hostname":"%s","platform":"%s","token":"%s","client_version":"install.sh/1"}' \ - "$EVOFW_CLIENT_NAME" "$HOSTNAME" "$PLATFORM" "$CLIENT_TOKEN") +if [[ -n "${EVOFW_INSTALL_LINK_ID:-}" ]]; then + ENROLL_BODY=$(printf '{"name":"%s","hostname":"%s","platform":"%s","token":"%s","client_version":"install.sh/1","install_link_id":"%s"}' \ + "$EVOFW_CLIENT_NAME" "$HOSTNAME" "$PLATFORM" "$CLIENT_TOKEN" "$EVOFW_INSTALL_LINK_ID") +else + ENROLL_BODY=$(printf '{"name":"%s","hostname":"%s","platform":"%s","token":"%s","client_version":"install.sh/1"}' \ + "$EVOFW_CLIENT_NAME" "$HOSTNAME" "$PLATFORM" "$CLIENT_TOKEN") +fi ENROLL_TMP=$(mktemp) trap 'rm -f "$ENROLL_TMP"' EXIT diff --git a/apps/api/src/agent-scripts/mikrotik-install.rsc b/apps/api/src/agent-scripts/mikrotik-install.rsc index a9aeea4..62bfd2e 100644 --- a/apps/api/src/agent-scripts/mikrotik-install.rsc +++ b/apps/api/src/agent-scripts/mikrotik-install.rsc @@ -1,46 +1,84 @@ -# EvoFirewall MikroTik install (RouterOS 7+) -# Usage: import after setting globals, or paste into terminal. -# Required globals before import (or edit below): -# :global EvofwCpUrl "https://fw.example.com" -# :global EvofwSeed "YOUR_SEED" -# :global EvofwName "mt-01" +# EvoFirewall MikroTik install (RouterOS 7.21+) +# Short-link sets EvofwCpUrl / EvofwSeed / EvofwName / EvofwInstallLinkId before body. +# Legacy: set globals, then /import file-name=mikrotik-install.rsc +# +# Blacklist: drop EVOFW_DENY on input+forward +# Whitelist: accept EVOFW_ALLOW + drop others on forward only (input stays open for Winbox/SSH) :global EvofwCpUrl :global EvofwSeed :global EvofwName +:global EvofwInstallLinkId -:if ([:typeof $EvofwCpUrl] = "nothing") do={ :error "EvofwCpUrl required" } -:if ([:typeof $EvofwSeed] = "nothing") do={ :error "EvofwSeed required" } -:if ([:typeof $EvofwName] = "nothing") do={ :set EvofwName [/system identity get name] } +:if ([:typeof $EvofwCpUrl] = "nothing" || [:len $EvofwCpUrl] = 0) do={ :error "EvofwCpUrl required" } +:if ([:typeof $EvofwSeed] = "nothing" || [:len $EvofwSeed] = 0) do={ :error "EvofwSeed required" } +:if ([:typeof $EvofwName] = "nothing" || [:len $EvofwName] = 0) do={ :set EvofwName [/system identity get name] } :local token ("evofw_" . [/certificate scep-server nonce generate]) :if ([:len $token] < 20) do={ - :set token ("evofw_" . [:tostr [/system clock get time]] . [:tostr [/system resource get cpu-load]]) + :set token ("evofw_" . [:tostr [/system clock get time]] . [:tostr [/system resource get cpu-load]] . [:tostr [/system resource get free-memory]]) } -:local body ("{\"name\":\"" . $EvofwName . "\",\"hostname\":\"" . [/system identity get name] . "\",\"platform\":\"mikrotik\",\"token\":\"" . $token . "\",\"client_version\":\"rsc/1\"}") +:local body ("{\"name\":\"" . $EvofwName . "\",\"hostname\":\"" . [/system identity get name] . "\",\"platform\":\"mikrotik\",\"token\":\"" . $token . "\",\"client_version\":\"rsc/1\"") +:if ([:typeof $EvofwInstallLinkId] != "nothing" && [:len $EvofwInstallLinkId] > 0) do={ + :set body ($body . ",\"install_link_id\":\"" . $EvofwInstallLinkId . "\"") +} +:set body ($body . "}") -/tool fetch url=($EvofwCpUrl . "/v1/agent/enroll") http-method=post http-header-field=("Content-Type: application/json,X-EvoFW-Seed: " . $EvofwSeed) http-data=$body keep-result=no +:do { + /tool fetch url=($EvofwCpUrl . "/v1/agent/enroll") http-method=post http-header-field=("Content-Type: application/json,X-EvoFW-Seed: " . $EvofwSeed) http-data=$body keep-result=no +} on-error={ + :error "evofw: enroll failed — check EvofwCpUrl / EvofwSeed / connectivity" +} -# Persist credentials for scheduler script -/system script remove [find name="evofw-env"] -/system script add name=evofw-env source=(" :global EvofwCpUrl \"" . $EvofwCpUrl . "\"; :global EvofwToken \"" . $token . "\" ") +# Persist credentials +:do { /system script remove [find name="evofw-env"] } on-error={} +/system script add name=evofw-env policy=read,write,policy,test source=(" :global EvofwCpUrl \"" . $EvofwCpUrl . "\"; :global EvofwToken \"" . $token . "\" ") -/system script remove [find name="evofw-sync"] +# Filter rules (idempotent by comment) +:do { /ip firewall filter remove [find comment~"^evofw-"] } on-error={} + +/ip firewall filter add chain=input action=drop src-address-list=EVOFW_DENY comment=evofw-bl-drop-input disabled=no +/ip firewall filter add chain=forward action=drop src-address-list=EVOFW_DENY comment=evofw-bl-drop-forward disabled=no +/ip firewall filter add chain=forward action=accept src-address-list=EVOFW_ALLOW comment=evofw-wl-accept-forward disabled=yes +/ip firewall filter add chain=forward action=drop comment=evofw-wl-drop-forward disabled=yes + +# Sync: fetch policy.rsc → import address-lists + toggle mode +:do { /system script remove [find name="evofw-sync"] } on-error={} /system script add name=evofw-sync policy=read,write,policy,test source={ :global EvofwCpUrl :global EvofwToken - :if ([:typeof $EvofwCpUrl] = "nothing" || [:typeof $EvofwToken] = "nothing") do={ /system script run evofw-env } - :local tmp [/file get [find name="evofw-policy.json"] name] - /tool fetch url=($EvofwCpUrl . "/v1/agent/policy") http-header-field=("Authorization: Bearer " . $EvofwToken) dst-path=evofw-policy.json - # Address-lists: EVOFW_DENY / EVOFW_ALLOW — operator should map filter rules once: - # /ip firewall filter add chain=input src-address-list=EVOFW_DENY action=drop comment=evofw - # whitelist: policy drop + accept EVOFW_ALLOW - :log info "evofw: policy fetched — apply address-lists via controller export or manual parse" - /tool fetch url=($EvofwCpUrl . "/v1/agent/heartbeat") http-method=post http-header-field=("Authorization: Bearer " . $EvofwToken . ",Content-Type: application/json") http-data="{\"source\":\"mikrotik\"}" keep-result=no + :if ([:typeof $EvofwCpUrl] = "nothing" || [:typeof $EvofwToken] = "nothing") do={ + /system script run evofw-env + } + :if ([:typeof $EvofwCpUrl] = "nothing" || [:typeof $EvofwToken] = "nothing") do={ + :log error "evofw: missing EvofwCpUrl/EvofwToken" + :error "evofw env missing" + } + :do { + /tool fetch url=($EvofwCpUrl . "/v1/agent/policy.rsc") http-header-field=("Authorization: Bearer " . $EvofwToken) dst-path=evofw-policy.rsc + /import file-name=evofw-policy.rsc + } on-error={ + :log warning "evofw: policy sync failed (pending approval or network)" + } + :local denyCnt [:len [/ip firewall address-list find list=EVOFW_DENY]] + :local allowCnt [:len [/ip firewall address-list find list=EVOFW_ALLOW]] + :local cnt ($denyCnt + $allowCnt) + :local report ("{\"status\":\"ok\",\"prefix_count\":" . $cnt . ",\"kernel_method\":\"address-list\",\"source\":\"mikrotik\"}") + :do { + /tool fetch url=($EvofwCpUrl . "/v1/agent/apply-report") http-method=post http-header-field=("Authorization: Bearer " . $EvofwToken . ",Content-Type: application/json") http-data=$report keep-result=no + } on-error={} + :do { + /tool fetch url=($EvofwCpUrl . "/v1/agent/heartbeat") http-method=post http-header-field=("Authorization: Bearer " . $EvofwToken . ",Content-Type: application/json") http-data="{\"source\":\"mikrotik\"}" keep-result=no + } on-error={} + :log info ("evofw: sync done deny=" . $denyCnt . " allow=" . $allowCnt) } -/system scheduler remove [find name="evofw-sync"] -/system scheduler add name=evofw-sync interval=1m on-event=evofw-sync +:do { /system scheduler remove [find name="evofw-sync"] } on-error={} +/system scheduler add name=evofw-sync interval=1m on-event=evofw-sync policy=read,write,policy,test -:put ("EvoFirewall enrolled as " . $EvofwName . " — approve in UI, ensure filter rules for EVOFW_* lists") +:do { /system script run evofw-sync } on-error={ + :log info "evofw: initial sync skipped (approve agent in UI)" +} + +:put ("EvoFirewall enrolled as " . $EvofwName . " — approve in UI; scheduler evofw-sync every 1m") diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 265eded..012687d 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -19,7 +19,7 @@ import { refreshAllLists } from './services/lists/refresh.js' import { repos } from '@evofw/db' import { isValidInstallSlug, - resolveAndRenderInstallScript, + resolveAndRenderInstall, } from './services/install-links.js' export interface BuildAppOptions { @@ -85,13 +85,13 @@ export async function buildApp(opts: BuildAppOptions = {}) { ) { const link = repos.getInstallLinkBySlug(app.db, segment) if (link) { - const script = resolveAndRenderInstallScript( + const { body, contentType } = resolveAndRenderInstall( app.db, link, config.publicBaseUrl, config.enrollSeed, ) - return reply.type('text/x-shellscript').send(script) + return reply.type(contentType).send(body) } } diff --git a/apps/api/src/plugins/auth.ts b/apps/api/src/plugins/auth.ts index 19cffa5..611b730 100644 --- a/apps/api/src/plugins/auth.ts +++ b/apps/api/src/plugins/auth.ts @@ -58,6 +58,7 @@ function isAgentPath(url: string): boolean { const path = url.split('?')[0] ?? url return ( path === '/v1/agent/policy' || + path === '/v1/agent/policy.rsc' || path === '/v1/agent/apply-report' || path === '/v1/agent/heartbeat' ) diff --git a/apps/api/src/routes/agent.ts b/apps/api/src/routes/agent.ts index 2dc463a..706052c 100644 --- a/apps/api/src/routes/agent.ts +++ b/apps/api/src/routes/agent.ts @@ -7,8 +7,9 @@ import { enrollBodySchema, applyReportBodySchema } from '@evofw/shared' import type { AppConfig } from '../config.js' import { hashToken } from '../plugins/auth.js' import { evaluateAgentPolicy } from '../services/policy/evaluate.js' +import { renderMikrotikPolicyRsc } from '../services/policy/mikrotik-rsc.js' import { AppError } from '../plugins/error-handler.js' -import { resolveAndRenderInstallScript } from '../services/install-links.js' +import { resolveAndRenderInstall } from '../services/install-links.js' const __dirname = dirname(fileURLToPath(import.meta.url)) const scriptsDir = join(__dirname, '../agent-scripts') @@ -29,13 +30,13 @@ export const agentRoutes: FastifyPluginAsync<{ config: AppConfig }> = async ( async (req, reply) => { const link = repos.getInstallLink(app.db, req.params.id) if (!link) throw new AppError('NOT_FOUND', 'Install link not found', 404) - const script = resolveAndRenderInstallScript( + const { body, contentType } = resolveAndRenderInstall( app.db, link, config.publicBaseUrl, config.enrollSeed, ) - return reply.type('text/x-shellscript').send(script) + return reply.type(contentType).send(body) }, ) @@ -60,12 +61,52 @@ export const agentRoutes: FastifyPluginAsync<{ config: AppConfig }> = async ( throw new AppError('UNAUTHORIZED', 'Invalid enroll seed', 401) } const body = enrollBodySchema.parse(req.body) - const id = crypto.randomUUID() const tokenHash = hashToken(body.token) - const existing = repos.getAgentByTokenHash(app.db, tokenHash) - if (existing) { + const existingByToken = repos.getAgentByTokenHash(app.db, tokenHash) + if (existingByToken) { throw new AppError('CONFLICT', 'Token already enrolled', 409) } + + if (body.install_link_id) { + const link = repos.getInstallLink(app.db, body.install_link_id) + if (!link) { + throw new AppError('NOT_FOUND', 'Install link not found', 404) + } + if (link.revokedAt) { + throw new AppError('GONE', 'Install link revoked', 410) + } + if (!link.agentId) { + throw new AppError('CONFLICT', 'Install link has no agent', 409) + } + const invited = repos.getAgent(app.db, link.agentId) + if (!invited) { + throw new AppError('NOT_FOUND', 'Agent not found', 404) + } + if (invited.status !== 'invited' && invited.status !== 'pending') { + throw new AppError( + 'CONFLICT', + `Agent status is ${invited.status}`, + 409, + ) + } + const agent = repos.updateAgent(app.db, invited.id, { + name: body.name, + hostname: body.hostname ?? null, + platform: body.platform ?? invited.platform, + tokenPrefix: body.token.slice(0, 12), + tokenHash, + status: 'pending', + clientVersion: body.client_version ?? null, + }) + return reply.code(201).send({ + client_id: agent!.id, + id: agent!.id, + status: agent!.status, + name: agent!.name, + }) + } + + const id = crypto.randomUUID() const agent = repos.insertAgent(app.db, { id, name: body.name, @@ -114,6 +155,16 @@ export const agentRoutes: FastifyPluginAsync<{ config: AppConfig }> = async ( } }) + app.get('/v1/agent/policy.rsc', async (req, reply) => { + const agentId = req.agentId! + const policy = evaluateAgentPolicy(app.db, agentId) + repos.updateAgent(app.db, agentId, { + lastSeenAt: new Date().toISOString(), + lastSeenIp: req.ip, + }) + return reply.type('text/plain').send(renderMikrotikPolicyRsc(policy)) + }) + app.post('/v1/agent/apply-report', async (req) => { const agentId = req.agentId! const body = applyReportBodySchema.parse(req.body) diff --git a/apps/api/src/routes/control.ts b/apps/api/src/routes/control.ts index 48540c6..155df86 100644 --- a/apps/api/src/routes/control.ts +++ b/apps/api/src/routes/control.ts @@ -29,10 +29,15 @@ import { import { mapInstallLink, randomToken, + buildInstallUrls, } from '../services/install-links.js' +import { hashToken } from '../plugins/auth.js' import type { AppConfig } from '../config.js' -function mapAgent(a: NonNullable>) { +function mapAgent( + a: NonNullable>, + opts?: { installCurl?: string | null; installLinkId?: string | null }, +) { return { id: a.id, name: a.name, @@ -55,6 +60,8 @@ function mapAgent(a: NonNullable>) { created_at: a.createdAt, approved_at: a.approvedAt, revoked_at: a.revokedAt, + install_curl: opts?.installCurl ?? null, + install_link_id: opts?.installLinkId ?? null, } } @@ -148,20 +155,49 @@ export const controlRoutes: FastifyPluginAsync<{ config: AppConfig }> = async ( app.post('/install-links', async (req, reply) => { const body = createInstallLinkBodySchema.parse(req.body) - const id = randomToken(10) + const linkId = randomToken(10) const slug = randomToken(16) - if (repos.getInstallLink(app.db, id) || repos.getInstallLinkBySlug(app.db, slug)) { + if ( + repos.getInstallLink(app.db, linkId) || + repos.getInstallLinkBySlug(app.db, slug) + ) { throw new AppError('CONFLICT', 'Retry create (id collision)', 409) } - const row = repos.insertInstallLink(app.db, { - id, - slug, - clientName: body.name.trim(), - platform: body.platform ?? 'linux', - createdAt: new Date().toISOString(), - useCount: 0, - }) - return reply.code(201).send(mapInstallLink(row!, config.publicBaseUrl)) + + const agentId = crypto.randomUUID() + const inviteToken = `invite:${agentId}` + const now = new Date().toISOString() + const name = body.name.trim() + const platform = body.platform ?? 'linux' + + app.sqlite.transaction(() => { + repos.insertAgent(app.db, { + id: agentId, + name, + hostname: null, + platform, + tokenPrefix: inviteToken.slice(0, 12), + tokenHash: hashToken(inviteToken), + status: 'invited', + policyMode: 'blacklist', + policyGeneration: 1, + clientVersion: null, + settingsJson: '{}', + createdAt: now, + }) + repos.insertInstallLink(app.db, { + id: linkId, + slug, + clientName: name, + platform, + agentId, + createdAt: now, + useCount: 0, + }) + })() + + const row = repos.getInstallLink(app.db, linkId)! + return reply.code(201).send(mapInstallLink(row, config.publicBaseUrl)) }) app.delete<{ Params: { id: string } }>( @@ -175,9 +211,27 @@ export const controlRoutes: FastifyPluginAsync<{ config: AppConfig }> = async ( ) // Agents - app.get('/agents', async () => ({ - items: repos.listAgents(app.db).map(mapAgent), - })) + app.get('/agents', async () => { + const all = repos.listAgents(app.db) + return { + items: all.map((a) => { + const link = repos.getInstallLinkByAgentId(app.db, a.id) + if (!link || link.revokedAt) { + return mapAgent(a) + } + const urls = buildInstallUrls( + config.publicBaseUrl, + link.id, + link.slug, + link.platform === 'mikrotik' ? 'mikrotik' : 'linux', + ) + return mapAgent(a, { + installCurl: urls.curl.by_slug, + installLinkId: link.id, + }) + }), + } + }) app.get<{ Params: { id: string } }>('/agents/:id', async (req) => { const a = repos.getAgent(app.db, req.params.id) diff --git a/apps/api/src/services/install-links.test.ts b/apps/api/src/services/install-links.test.ts index 7e8aa7b..1bcf7d5 100644 --- a/apps/api/src/services/install-links.test.ts +++ b/apps/api/src/services/install-links.test.ts @@ -24,7 +24,7 @@ describe('install-links', () => { await app.close() }) - it('creates link and serves scripts by id and slug', async () => { + it('creates invited agent and serves scripts by id and slug', async () => { const app = await appPromise await app.ready() @@ -37,12 +37,27 @@ describe('install-links', () => { const body = created.json() as { id: string slug: string + agent_id: string curl: { by_id: string; by_slug: string } } expect(body.id).toBeTruthy() expect(body.slug).toBeTruthy() + expect(body.agent_id).toBeTruthy() expect(body.curl.by_id).toContain(`/agent-install/${body.id}`) + const agents = await app.inject({ method: 'GET', url: '/api/v1/agents' }) + expect(agents.statusCode).toBe(200) + const list = agents.json() as { + items: { + id: string + status: string + install_curl?: string | null + }[] + } + const invited = list.items.find((a) => a.id === body.agent_id) + expect(invited?.status).toBe('invited') + expect(invited?.install_curl).toContain(body.slug) + const byId = await app.inject({ method: 'GET', url: `/agent-install/${body.id}`, @@ -50,7 +65,7 @@ describe('install-links', () => { expect(byId.statusCode).toBe(200) expect(byId.headers['content-type']).toContain('text/x-shellscript') expect(byId.body).toContain("EVOFW_CLIENT_NAME='web-01'") - expect(byId.body).toContain("EVOFW_SEED='test-seed'") + expect(byId.body).toContain(`EVOFW_INSTALL_LINK_ID='${body.id}'`) const bySlug = await app.inject({ method: 'GET', @@ -59,4 +74,125 @@ describe('install-links', () => { expect(bySlug.statusCode).toBe(200) expect(bySlug.body).toContain("EVOFW_CP_URL='https://fw.example.com'") }) + + it('enroll with install_link_id updates invited agent to pending', async () => { + const app = await appPromise + await app.ready() + + const created = await app.inject({ + method: 'POST', + url: '/api/v1/install-links', + payload: { name: 'web-02', platform: 'linux' }, + }) + const link = created.json() as { id: string; agent_id: string } + + const enroll = await app.inject({ + method: 'POST', + url: '/v1/agent/enroll', + headers: { + 'content-type': 'application/json', + 'x-evofw-seed': 'test-seed', + }, + payload: { + name: 'web-02', + hostname: 'host-02', + platform: 'linux', + token: 'evofw_test_token_1234567890abcd', + install_link_id: link.id, + }, + }) + expect(enroll.statusCode).toBe(201) + const enrolled = enroll.json() as { id: string; status: string } + expect(enrolled.id).toBe(link.agent_id) + expect(enrolled.status).toBe('pending') + + const agents = await app.inject({ method: 'GET', url: '/api/v1/agents' }) + const list = agents.json() as { items: { id: string; status: string }[] } + const row = list.items.find((a) => a.id === link.agent_id) + expect(row?.status).toBe('pending') + }) + + it('mikrotik install link serves RSC and fetch/import one-liner', async () => { + const app = await appPromise + await app.ready() + + const created = await app.inject({ + method: 'POST', + url: '/api/v1/install-links', + payload: { name: 'mt-01', platform: 'mikrotik' }, + }) + expect(created.statusCode).toBe(201) + const body = created.json() as { + id: string + slug: string + agent_id: string + curl: { by_id: string; by_slug: string } + } + expect(body.curl.by_id).toContain('/tool fetch url=') + expect(body.curl.by_id).toContain('/import file-name=evofw-install.rsc') + expect(body.curl.by_id).not.toContain('| bash') + + const byId = await app.inject({ + method: 'GET', + url: `/agent-install/${body.id}`, + }) + expect(byId.statusCode).toBe(200) + expect(byId.headers['content-type']).toContain('text/plain') + expect(byId.body).toContain(':global EvofwCpUrl "https://fw.example.com"') + expect(byId.body).toContain(`:global EvofwInstallLinkId "${body.id}"`) + expect(byId.body).toContain('evofw-bl-drop-input') + expect(byId.body).toContain('/v1/agent/policy.rsc') + }) + + it('approved agent can fetch policy.rsc with address-list commands', async () => { + const app = await appPromise + await app.ready() + + const created = await app.inject({ + method: 'POST', + url: '/api/v1/install-links', + payload: { name: 'mt-policy', platform: 'mikrotik' }, + }) + const link = created.json() as { id: string; agent_id: string } + const token = 'evofw_mt_policy_token_abcdefghij' + + const enroll = await app.inject({ + method: 'POST', + url: '/v1/agent/enroll', + headers: { + 'content-type': 'application/json', + 'x-evofw-seed': 'test-seed', + }, + payload: { + name: 'mt-policy', + platform: 'mikrotik', + token, + install_link_id: link.id, + }, + }) + expect(enroll.statusCode).toBe(201) + + await app.inject({ + method: 'POST', + url: `/api/v1/agents/${link.agent_id}/approve`, + }) + + // add a deny override so policy has a CIDR + await app.inject({ + method: 'POST', + url: `/api/v1/agents/${link.agent_id}/overrides`, + payload: { action: 'deny', cidr: '203.0.113.0/24' }, + }) + + const rsc = await app.inject({ + method: 'GET', + url: '/v1/agent/policy.rsc', + headers: { authorization: `Bearer ${token}` }, + }) + expect(rsc.statusCode).toBe(200) + expect(rsc.headers['content-type']).toContain('text/plain') + expect(rsc.body).toContain('address-list') + expect(rsc.body).toContain('EVOFW_DENY') + expect(rsc.body).toContain('203.0.113.0/24') + }) }) diff --git a/apps/api/src/services/install-links.ts b/apps/api/src/services/install-links.ts index 0c115f1..9456b0f 100644 --- a/apps/api/src/services/install-links.ts +++ b/apps/api/src/services/install-links.ts @@ -42,10 +42,31 @@ export function isValidInstallSlug(segment: string): boolean { return SLUG_RE.test(segment) } -export function buildInstallUrls(baseUrl: string, id: string, slug: string) { +export type InstallPlatform = 'linux' | 'mikrotik' + +function mikrotikFetchImport(url: string): string { + return `/tool fetch url="${url}" dst-path=evofw-install.rsc; /import file-name=evofw-install.rsc` +} + +export function buildInstallUrls( + baseUrl: string, + id: string, + slug: string, + platform: InstallPlatform = 'linux', +) { const base = baseUrl.replace(/\/$/, '') const byId = `${base}/agent-install/${id}` const bySlug = `${base}/${slug}` + if (platform === 'mikrotik') { + return { + by_id: byId, + by_slug: bySlug, + curl: { + by_id: mikrotikFetchImport(byId), + by_slug: mikrotikFetchImport(bySlug), + }, + } + } return { by_id: byId, by_slug: bySlug, @@ -60,12 +81,14 @@ export function mapInstallLink( row: NonNullable>, baseUrl: string, ) { - const urls = buildInstallUrls(baseUrl, row.id, row.slug) + const platform = (row.platform === 'mikrotik' ? 'mikrotik' : 'linux') as InstallPlatform + const urls = buildInstallUrls(baseUrl, row.id, row.slug, platform) return { id: row.id, slug: row.slug, client_name: row.clientName, - platform: row.platform as 'linux' | 'mikrotik', + platform, + agent_id: row.agentId ?? null, created_at: row.createdAt, revoked_at: row.revokedAt, last_used_at: row.lastUsedAt, @@ -79,6 +102,14 @@ function loadInstallSh(): string { return readFileSync(join(scriptsDir, 'install.sh'), 'utf-8') } +function loadMikrotikInstallRsc(): string { + return readFileSync(join(scriptsDir, 'mikrotik-install.rsc'), 'utf-8') +} + +function escapeRosString(s: string): string { + return s.replace(/\\/g, '\\\\').replace(/"/g, '\\"') +} + /** * Self-contained install script: env exports + full install.sh body. */ @@ -87,6 +118,7 @@ export function renderInstallScript(opts: { seed: string clientName: string platform: string + installLinkId: string }): string { const cp = opts.cpUrl.replace(/\/$/, '') const escape = (s: string) => s.replace(/'/g, `'\\''`) @@ -98,6 +130,7 @@ export function renderInstallScript(opts: { `export EVOFW_SEED='${escape(opts.seed)}'`, `export EVOFW_CLIENT_NAME='${escape(opts.clientName)}'`, `export EVOFW_PLATFORM='${escape(opts.platform)}'`, + `export EVOFW_INSTALL_LINK_ID='${escape(opts.installLinkId)}'`, '', ].join('\n') @@ -106,22 +139,73 @@ export function renderInstallScript(opts: { return `${header}${body}` } +/** + * Personalized MikroTik RSC: globals + mikrotik-install.rsc body. + */ +export function renderMikrotikInstallScript(opts: { + cpUrl: string + seed: string + clientName: string + installLinkId: string +}): string { + const cp = opts.cpUrl.replace(/\/$/, '') + const header = [ + '# EvoFirewall short install link — globals pre-set (RouterOS 7.21+)', + `:global EvofwCpUrl "${escapeRosString(cp)}"`, + `:global EvofwSeed "${escapeRosString(opts.seed)}"`, + `:global EvofwName "${escapeRosString(opts.clientName)}"`, + `:global EvofwInstallLinkId "${escapeRosString(opts.installLinkId)}"`, + '', + ].join('\n') + return `${header}${loadMikrotikInstallRsc()}` +} + +export type ResolvedInstall = { + body: string + contentType: string +} + +/** @deprecated use resolveAndRenderInstall */ export function resolveAndRenderInstallScript( db: Db, link: NonNullable>, publicBaseUrl: string, enrollSeedFallback: string, ): string { + return resolveAndRenderInstall(db, link, publicBaseUrl, enrollSeedFallback) + .body +} + +export function resolveAndRenderInstall( + db: Db, + link: NonNullable>, + publicBaseUrl: string, + enrollSeedFallback: string, +): ResolvedInstall { if (link.revokedAt) { throw new AppError('GONE', 'Install link revoked', 410) } - const seed = - repos.getSetting(db, 'enroll_seed') || enrollSeedFallback + const seed = repos.getSetting(db, 'enroll_seed') || enrollSeedFallback repos.touchInstallLink(db, link.id) - return renderInstallScript({ - cpUrl: publicBaseUrl, - seed, - clientName: link.clientName, - platform: link.platform, - }) + if (link.platform === 'mikrotik') { + return { + body: renderMikrotikInstallScript({ + cpUrl: publicBaseUrl, + seed, + clientName: link.clientName, + installLinkId: link.id, + }), + contentType: 'text/plain', + } + } + return { + body: renderInstallScript({ + cpUrl: publicBaseUrl, + seed, + clientName: link.clientName, + platform: link.platform, + installLinkId: link.id, + }), + contentType: 'text/x-shellscript', + } } diff --git a/apps/api/src/services/policy/mikrotik-rsc.test.ts b/apps/api/src/services/policy/mikrotik-rsc.test.ts new file mode 100644 index 0000000..4b1ba66 --- /dev/null +++ b/apps/api/src/services/policy/mikrotik-rsc.test.ts @@ -0,0 +1,64 @@ +import { describe, it, expect } from 'vitest' +import { renderMikrotikPolicyRsc, isIpv4Cidr } from './mikrotik-rsc.js' +import type { EvaluatedPolicy } from './evaluate.js' + +function basePolicy( + overrides: Partial = {}, +): EvaluatedPolicy { + return { + generation: 3, + hash: 'sha256:abc', + policyMode: 'blacklist', + denyCidrs: ['1.2.3.0/24', '2001:db8::/32', '10.0.0.1/32'], + allowCidrs: ['8.8.8.8/32', 'fe80::1/128'], + syncIntervalSec: 60, + ...overrides, + } +} + +describe('mikrotik-rsc', () => { + it('isIpv4Cidr skips IPv6', () => { + expect(isIpv4Cidr('1.2.3.0/24')).toBe(true) + expect(isIpv4Cidr('2001:db8::/32')).toBe(false) + }) + + it('renders blacklist: lists + BL enabled / WL disabled', () => { + const rsc = renderMikrotikPolicyRsc(basePolicy()) + expect(rsc).toContain('# evofw hash=sha256:abc mode=blacklist gen=3') + expect(rsc).toContain( + '/ip firewall address-list remove [find list=EVOFW_DENY]', + ) + expect(rsc).toContain( + '/ip firewall address-list remove [find list=EVOFW_ALLOW]', + ) + expect(rsc).toContain( + 'add list=EVOFW_DENY address=1.2.3.0/24 comment=evofw', + ) + expect(rsc).toContain( + 'add list=EVOFW_DENY address=10.0.0.1/32 comment=evofw', + ) + expect(rsc).not.toContain('2001:db8') + expect(rsc).toContain( + 'add list=EVOFW_ALLOW address=8.8.8.8/32 comment=evofw', + ) + expect(rsc).toContain( + 'set [find comment=evofw-bl-drop-input] disabled=no', + ) + expect(rsc).toContain( + 'set [find comment=evofw-wl-accept-forward] disabled=yes', + ) + }) + + it('renders whitelist: WL enabled / BL disabled', () => { + const rsc = renderMikrotikPolicyRsc( + basePolicy({ policyMode: 'whitelist' }), + ) + expect(rsc).toContain('mode=whitelist') + expect(rsc).toContain( + 'set [find comment=evofw-bl-drop-forward] disabled=yes', + ) + expect(rsc).toContain( + 'set [find comment=evofw-wl-drop-forward] disabled=no', + ) + }) +}) diff --git a/apps/api/src/services/policy/mikrotik-rsc.ts b/apps/api/src/services/policy/mikrotik-rsc.ts new file mode 100644 index 0000000..1b31b87 --- /dev/null +++ b/apps/api/src/services/policy/mikrotik-rsc.ts @@ -0,0 +1,52 @@ +import type { EvaluatedPolicy } from './evaluate.js' + +/** Skip IPv6 (contains ':') — same as Linux agent. */ +export function isIpv4Cidr(cidr: string): boolean { + const t = cidr.trim() + return t.length > 0 && !t.includes(':') +} + +function escAddress(cidr: string): string { + // CIDRs are alphanumeric + . / - ; quote if anything odd + const t = cidr.trim() + if (/^[0-9./-]+$/.test(t)) return t + return `"${t.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"` +} + +/** + * RouterOS 7.x script: rebuild EVOFW_* address-lists and toggle filter mode. + * Device: /tool fetch → /import (no JSON parse on router). + */ +export function renderMikrotikPolicyRsc(policy: EvaluatedPolicy): string { + const isBl = policy.policyMode === 'blacklist' + const blDisabled = isBl ? 'no' : 'yes' + const wlDisabled = isBl ? 'yes' : 'no' + + const lines: string[] = [ + `# evofw hash=${policy.hash} mode=${policy.policyMode} gen=${policy.generation}`, + '/ip firewall address-list remove [find list=EVOFW_DENY]', + '/ip firewall address-list remove [find list=EVOFW_ALLOW]', + ] + + for (const c of policy.denyCidrs) { + if (!isIpv4Cidr(c)) continue + lines.push( + `/ip firewall address-list add list=EVOFW_DENY address=${escAddress(c)} comment=evofw`, + ) + } + for (const c of policy.allowCidrs) { + if (!isIpv4Cidr(c)) continue + lines.push( + `/ip firewall address-list add list=EVOFW_ALLOW address=${escAddress(c)} comment=evofw`, + ) + } + + lines.push( + `:do { /ip firewall filter set [find comment=evofw-bl-drop-input] disabled=${blDisabled} } on-error={}`, + `:do { /ip firewall filter set [find comment=evofw-bl-drop-forward] disabled=${blDisabled} } on-error={}`, + `:do { /ip firewall filter set [find comment=evofw-wl-accept-forward] disabled=${wlDisabled} } on-error={}`, + `:do { /ip firewall filter set [find comment=evofw-wl-drop-forward] disabled=${wlDisabled} } on-error={}`, + ) + + return `${lines.join('\n')}\n` +} diff --git a/apps/web/src/components/agents/add-agent-sheet.tsx b/apps/web/src/components/agents/add-agent-sheet.tsx index 9e3f14f..428502f 100644 --- a/apps/web/src/components/agents/add-agent-sheet.tsx +++ b/apps/web/src/components/agents/add-agent-sheet.tsx @@ -1,5 +1,5 @@ import { useEffect, useState } from 'react' -import { useMutation } from '@tanstack/react-query' +import { useMutation, useQueryClient } from '@tanstack/react-query' import { toast } from 'sonner' import { Copy } from 'lucide-react' import { apiFetch } from '@/lib/api' @@ -23,6 +23,7 @@ import { SheetHeader, SheetTitle, } from '@evofw/ui/components/sheet' +import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard' /** * Create agent install invite — Sheet. @@ -44,12 +45,9 @@ interface AddAgentSheetProps { onOpenChange: (open: boolean) => void } -async function copyText(text: string) { - await navigator.clipboard.writeText(text) - toast.success('Скопировано') -} - export function AddAgentSheet({ open, onOpenChange }: AddAgentSheetProps) { + const qc = useQueryClient() + const { copyToClipboard } = useCopyToClipboard() const [name, setName] = useState('web-01') const [platform, setPlatform] = useState('linux') const [created, setCreated] = useState(null) @@ -70,13 +68,20 @@ export function AddAgentSheet({ open, onOpenChange }: AddAgentSheetProps) { }), onSuccess: (link) => { setCreated(link) - toast.success('Ссылка создана') + toast.success('Агент создан') + void qc.invalidateQueries({ queryKey: ['agents'] }) }, onError: (e: Error) => toast.error(e.message), }) const canCreate = Boolean(name.trim()) && !create.isPending + function handleCopy(text: string) { + if (!text) return + copyToClipboard(text) + toast.success('Скопировано') + } + return ( @@ -86,8 +91,8 @@ export function AddAgentSheet({ open, onOpenChange }: AddAgentSheetProps) { {created - ? 'Скопируйте one-liner и выполните на хосте. Затем одобрите агента в списке.' - : 'Создайте короткую install-ссылку с именем клиента.'} + ? 'Агент уже в списке (Invited). Скопируйте one-liner и выполните на хосте.' + : 'Создайте агента и короткую install-ссылку.'} @@ -139,9 +144,7 @@ export function AddAgentSheet({ open, onOpenChange }: AddAgentSheetProps) { variant="outline" size="sm" className="self-start" - onClick={() => - void copyText(created.curl?.by_id ?? '') - } + onClick={() => handleCopy(created.curl?.by_id ?? '')} > Копировать @@ -159,9 +162,7 @@ export function AddAgentSheet({ open, onOpenChange }: AddAgentSheetProps) { variant="outline" size="sm" className="self-start" - onClick={() => - void copyText(created.curl?.by_slug ?? '') - } + onClick={() => handleCopy(created.curl?.by_slug ?? '')} > Копировать @@ -182,7 +183,7 @@ export function AddAgentSheet({ open, onOpenChange }: AddAgentSheetProps) { setCreated(null) }} > - Ещё ссылка + Ещё агент @@ -191,11 +192,8 @@ export function AddAgentSheet({ open, onOpenChange }: AddAgentSheetProps) { - )} diff --git a/apps/web/src/components/status-badge.tsx b/apps/web/src/components/status-badge.tsx index a18b671..590a0ba 100644 --- a/apps/web/src/components/status-badge.tsx +++ b/apps/web/src/components/status-badge.tsx @@ -16,6 +16,7 @@ const STATUS_VARIANT: Record = { allow: 'success-light', pending_push: 'secondary', pending: 'warning-light', + invited: 'info-light', warning: 'warning-light', degraded: 'warning-light', conflict: 'destructive-light', @@ -54,6 +55,7 @@ const STATUS_LABELS: Record = { synced: 'Синхронизировано', pending_push: 'Ожидает отправки', pending: 'Pending', + invited: 'Invited', approved: 'Approved', revoked: 'Revoked', enabled: 'Включён', diff --git a/apps/web/src/hooks/use-copy-to-clipboard.ts b/apps/web/src/hooks/use-copy-to-clipboard.ts new file mode 100644 index 0000000..4b38c8f --- /dev/null +++ b/apps/web/src/hooks/use-copy-to-clipboard.ts @@ -0,0 +1,35 @@ +import { useState } from "react" + +export function useCopyToClipboard({ + timeout = 2000, + onCopy, +}: { + timeout?: number + onCopy?: () => void +} = {}) { + const [isCopied, setIsCopied] = useState(false) + + const copyToClipboard = (value: string) => { + if (typeof window === "undefined" || !navigator.clipboard.writeText) { + return + } + + if (!value) return + + navigator.clipboard.writeText(value).then(() => { + setIsCopied(true) + + if (onCopy) { + onCopy() + } + + if (timeout !== 0) { + setTimeout(() => { + setIsCopied(false) + }, timeout) + } + }, console.error) + } + + return { isCopied, copyToClipboard } +} \ No newline at end of file diff --git a/apps/web/src/routes/_auth/agents/index.tsx b/apps/web/src/routes/_auth/agents/index.tsx index a7bccd0..182359a 100644 --- a/apps/web/src/routes/_auth/agents/index.tsx +++ b/apps/web/src/routes/_auth/agents/index.tsx @@ -1,7 +1,7 @@ import { createFileRoute, Link } from '@tanstack/react-router' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { toast } from 'sonner' -import { Check, Plus, Trash2 } from 'lucide-react' +import { Check, Copy, Plus, Trash2 } from 'lucide-react' import { useCallback, useMemo, useState } from 'react' import type { ColumnDef } from '@tanstack/react-table' import type { Filter, FilterFieldConfig } from '@/components/reui/filters' @@ -20,15 +20,21 @@ import { ConfirmDialog } from '@/components/confirm-dialog' import { AddAgentSheet } from '@/components/agents/add-agent-sheet' import { agentsQueryOptions } from '@/queries' import { apiFetch } from '@/lib/api' +import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard' import { Button } from '@evofw/ui/components/button' +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from '@evofw/ui/components/tooltip' import type { Agent } from '@evofw/shared' /** * Agents list — ResourcePage (Frame + tabs + Filters + DataGrid). * Preview: https://reui.io/preview/base/data-grid-filtering-2 + * Copyable install: https://reui.io/preview/base/settings-14 * Empty: https://reui.io/preview/base/empty-state-7 * Create Sheet: https://reui.io/preview/base/sheet-1 · sheet-8 - * Docs: https://reui.io/blocks · https://reui.io/components/sheet */ export const Route = createFileRoute('/_auth/agents/')({ component: AgentsPage, @@ -37,6 +43,7 @@ export const Route = createFileRoute('/_auth/agents/')({ function AgentsPage() { const qc = useQueryClient() const agentsQ = useQuery(agentsQueryOptions()) + const { copyToClipboard } = useCopyToClipboard() const [createOpen, setCreateOpen] = useState(false) const [filters, setFilters] = useState([]) const [activeTab, setActiveTab] = useState('all') @@ -88,6 +95,7 @@ function AgentsPage() { label: 'Статус', type: 'select', options: [ + { value: 'invited', label: 'invited' }, { value: 'approved', label: 'approved' }, { value: 'pending', label: 'pending' }, { value: 'revoked', label: 'revoked' }, @@ -118,6 +126,15 @@ function AgentsPage() { return item.status === tabId }, []) + const handleCopyCurl = useCallback( + (curl: string) => { + if (!curl) return + copyToClipboard(curl) + toast.success('Скопировано') + }, + [copyToClipboard], + ) + const columns: ColumnDef[] = useMemo( () => [ { @@ -152,6 +169,39 @@ function AgentsPage() { ), cell: ({ row }) => , }, + { + id: 'install', + enableSorting: false, + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const curl = row.original.install_curl + if (!curl) { + return + } + return ( + + handleCopyCurl(curl)} + /> + } + > + + {curl} + + + {curl} + + + ) + }, + }, { accessorKey: 'policy_mode', header: ({ column }) => ( @@ -217,7 +267,7 @@ function AgentsPage() { }, }, ], - [approve, revoke], + [approve, revoke, handleCopyCurl], ) const addButton = ( @@ -231,7 +281,7 @@ function AgentsPage() { @@ -248,8 +298,9 @@ function AgentsPage() { getFilterFieldValue={getFilterFieldValue} tabs={[ { id: 'all', label: 'Все' }, - { id: 'approved', label: 'Approved' }, + { id: 'invited', label: 'Invited' }, { id: 'pending', label: 'Pending' }, + { id: 'approved', label: 'Approved' }, { id: 'revoked', label: 'Revoked' }, ]} activeTab={activeTab} @@ -262,7 +313,7 @@ function AgentsPage() { emptyState={{ title: 'Нет агентов', description: - 'Создайте install-ссылку, выполните curl на хосте и одобрите запрос.', + 'Создайте агента — он появится в списке как Invited с командой установки.', action: addButton, }} /> @@ -275,7 +326,7 @@ function AgentsPage() { if (!open) setDeleteId(null) }} title="Удалить агента?" - description="Агент и связанные назначения будут удалены." + description="Агент, install-ссылка и связанные назначения будут удалены." onConfirm={() => { if (deleteId) remove.mutate(deleteId) }} diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 350942f..04e5a91 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -15,7 +15,6 @@ "noUnusedLocals": false, "noUnusedParameters": false, "noFallthroughCasesInSwitch": true, - "baseUrl": ".", "paths": { "@/*": ["./src/*"], "@evofw/ui/*": ["../../packages/ui/src/*"], diff --git a/docs/agents.md b/docs/agents.md index e6dd005..1a1d118 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -2,17 +2,25 @@ ## Short install (рекомендуется) -В UI `/agents` → **Добавить агента** создаёт install-ссылку. На хосте: +В UI `/agents` → **Добавить агента**: +1. Создаётся агент со статусом **Invited** (сразу виден в таблице) + install-ссылка. +2. Скопируйте one-liner (колонка Install или Sheet): + +**Linux:** ```bash curl -fsSL https:///agent-install/ | bash -# или короткий slug: -curl -fsSL https:/// | bash ``` -Скрипт уже содержит `EVOFW_CP_URL`, `EVOFW_SEED`, `EVOFW_CLIENT_NAME`. После enroll одобрите агента во вкладке Pending. +**MikroTik:** +``` +/tool fetch url="https:///agent-install/" dst-path=evofw-install.rsc; /import file-name=evofw-install.rsc +``` -API (auth): `POST /api/v1/install-links` `{ "name": "web-01", "platform": "linux" }`. +3. После enroll статус станет **Pending** — одобрите агента (Approve). +4. **Approved** — агент синхронизирует политику. + +API (auth): `POST /api/v1/install-links` `{ "name": "web-01", "platform": "linux" | "mikrotik" }`. ## Linux (legacy one-liner) @@ -24,15 +32,36 @@ curl -fsSL https:///v1/agent/install.sh | \ bash ``` +Создаёт нового агента со статусом Pending (без Invited). + Файлы: `/etc/evofw/agent.conf`, `/usr/local/sbin/evofw-firewall.sh`, timer `evofw-firewall.timer` (default 1min). Backend auto-detect: nft → ipset → iptables. Whitelist: nft chain policy drop + allow set. Blacklist: policy accept + deny set. -## MikroTik +## MikroTik (RouterOS 7.21+) -Скачайте `/v1/agent/mikrotik-install.rsc`, задайте globals `EvofwCpUrl`, `EvofwSeed`, `EvofwName`, import. Scheduler каждую минуту тянет policy. Настройте filter на address-list `EVOFW_DENY` / `EVOFW_ALLOW`. +В UI `/agents` → **Добавить агента** → platform **MikroTik**. Скопируйте one-liner: + +``` +/tool fetch url="https:///agent-install/" dst-path=evofw-install.rsc; /import file-name=evofw-install.rsc +``` + +Или короткий slug: `https:///`. + +Install RSC: + +1. Enroll (с `install_link_id` → агент Invited → Pending). +2. Создаёт filter-правила `evofw-*` и address-list `EVOFW_DENY` / `EVOFW_ALLOW`. +3. Scheduler `evofw-sync` каждую минуту: `GET /v1/agent/policy.rsc` → `/import` (списки + режим). + +**Blacklist:** `drop` по `EVOFW_DENY` в `input` и `forward`. +**Whitelist:** `accept` по `EVOFW_ALLOW` + catch-all `drop` только в `forward` (input не закрывается — Winbox/SSH). + +Legacy: скачайте `/v1/agent/mikrotik-install.rsc`, задайте globals `EvofwCpUrl`, `EvofwSeed`, `EvofwName`, опционально `EvofwInstallLinkId`, затем `/import`. + +Одобрите агента в UI — после Approve sync начнёт применять политику. ## Force sync diff --git a/packages/db/migrations/004_agent_invited.sql b/packages/db/migrations/004_agent_invited.sql new file mode 100644 index 0000000..24c9aec --- /dev/null +++ b/packages/db/migrations/004_agent_invited.sql @@ -0,0 +1,64 @@ +-- Allow invited agents (created in UI before enroll) + link install invites to agents. +-- Rebuild agents to widen status CHECK (FK temporarily off). + +PRAGMA foreign_keys = OFF; + +CREATE TABLE agents_v2 ( + 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 ('invited', 'pending', 'approved', 'revoked')), + CHECK (platform IN ('linux', 'mikrotik')), + CHECK (policy_mode IN ('blacklist', 'whitelist')), + CHECK (length(trim(name)) > 0) +); + +INSERT INTO agents_v2 ( + id, name, hostname, platform, token_prefix, token_hash, status, policy_mode, + policy_generation, last_seen_at, last_seen_ip, last_apply_at, last_apply_status, + last_apply_error, last_apply_prefix_count, last_apply_packets_dropped, + last_apply_packets_accepted, last_apply_kernel_method, client_version, + settings_json, created_by_user_id, created_at, approved_at, revoked_at +) +SELECT + id, name, hostname, platform, token_prefix, token_hash, status, policy_mode, + policy_generation, last_seen_at, last_seen_ip, last_apply_at, last_apply_status, + last_apply_error, last_apply_prefix_count, last_apply_packets_dropped, + last_apply_packets_accepted, last_apply_kernel_method, client_version, + settings_json, created_by_user_id, created_at, approved_at, revoked_at +FROM agents; + +DROP TABLE agents; +ALTER TABLE agents_v2 RENAME TO agents; + +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); + +PRAGMA foreign_keys = ON; + +-- Bind install links to agents +ALTER TABLE agent_install_links ADD COLUMN agent_id TEXT REFERENCES agents (id) ON DELETE CASCADE; + +CREATE INDEX IF NOT EXISTS idx_agent_install_links_agent + ON agent_install_links (agent_id); diff --git a/packages/db/src/repositories/index.ts b/packages/db/src/repositories/index.ts index c5670c7..fb65468 100644 --- a/packages/db/src/repositories/index.ts +++ b/packages/db/src/repositories/index.ts @@ -459,6 +459,20 @@ export function getInstallLinkBySlug(db: Db, slug: string) { .get() } +export function getInstallLinkByAgentId(db: Db, agentId: string) { + return db + .select() + .from(agentInstallLinks) + .where( + and( + eq(agentInstallLinks.agentId, agentId), + sql`${agentInstallLinks.revokedAt} IS NULL`, + ), + ) + .orderBy(desc(agentInstallLinks.createdAt)) + .get() +} + export function insertInstallLink( db: Db, row: typeof agentInstallLinks.$inferInsert, @@ -538,6 +552,7 @@ export const repos = { listInstallLinks, getInstallLink, getInstallLinkBySlug, + getInstallLinkByAgentId, insertInstallLink, revokeInstallLink, touchInstallLink, diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index 5594575..3c1abab 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -18,7 +18,7 @@ export const agents = sqliteTable( 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 + status: text('status').notNull().default('pending'), // invited | pending | approved | revoked policyMode: text('policy_mode').notNull().default('blacklist'), // blacklist | whitelist policyGeneration: integer('policy_generation').notNull().default(1), lastSeenAt: text('last_seen_at'), @@ -202,6 +202,7 @@ export const agentInstallLinks = sqliteTable( slug: text('slug').notNull(), clientName: text('client_name').notNull(), platform: text('platform').notNull().default('linux'), // linux | mikrotik + agentId: text('agent_id').references(() => agents.id, { onDelete: 'cascade' }), createdAt: text('created_at') .notNull() .default(sql`(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))`), @@ -211,6 +212,7 @@ export const agentInstallLinks = sqliteTable( }, (t) => ({ slugIdx: uniqueIndex('idx_agent_install_links_slug').on(t.slug), + agentIdx: index('idx_agent_install_links_agent').on(t.agentId), }), ) diff --git a/packages/shared/src/contracts.ts b/packages/shared/src/contracts.ts index fbf2b49..ac47297 100644 --- a/packages/shared/src/contracts.ts +++ b/packages/shared/src/contracts.ts @@ -1,7 +1,12 @@ import { z } from 'zod' export const agentPlatformSchema = z.enum(['linux', 'mikrotik']) -export const agentStatusSchema = z.enum(['pending', 'approved', 'revoked']) +export const agentStatusSchema = z.enum([ + 'invited', + 'pending', + 'approved', + 'revoked', +]) export const policyModeSchema = z.enum(['blacklist', 'whitelist']) export const policyActionSchema = z.enum(['allow', 'deny']) export const ipListTypeSchema = z.enum([ @@ -34,6 +39,8 @@ export const agentSchema = z.object({ created_at: z.string(), approved_at: z.string().nullable().optional(), revoked_at: z.string().nullable().optional(), + install_curl: z.string().nullable().optional(), + install_link_id: z.string().nullable().optional(), }) export const ipListSchema = z.object({ @@ -151,6 +158,7 @@ export const enrollBodySchema = z.object({ platform: agentPlatformSchema.optional().default('linux'), token: z.string().min(16), client_version: z.string().optional(), + install_link_id: z.string().optional(), }) export const applyReportBodySchema = z.object({ @@ -192,6 +200,7 @@ export const installLinkSchema = z.object({ slug: z.string(), client_name: z.string(), platform: agentPlatformSchema, + agent_id: z.string().nullable().optional(), created_at: z.string(), revoked_at: z.string().nullable().optional(), last_used_at: z.string().nullable().optional(),