feat(api, web): enhance agent installation process with invited status and policy support
Build and Push EvoFirewall Docker Image / build-and-push (push) Successful in 1m48s
Build and Push EvoFirewall Docker Image / create-release (push) Skipped

- 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 <[email protected]>
This commit is contained in:
Denozordec
2026-07-21 01:45:38 +07:00
co-authored by Cursor
parent ef56da4d91
commit d5784b9f35
20 changed files with 793 additions and 104 deletions
+7 -2
View File
@@ -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
+65 -27
View File
@@ -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")
+3 -3
View File
@@ -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)
}
}
+1
View File
@@ -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'
)
+57 -6
View File
@@ -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)
+69 -15
View File
@@ -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<ReturnType<typeof repos.getAgent>>) {
function mapAgent(
a: NonNullable<ReturnType<typeof repos.getAgent>>,
opts?: { installCurl?: string | null; installLinkId?: string | null },
) {
return {
id: a.id,
name: a.name,
@@ -55,6 +60,8 @@ function mapAgent(a: NonNullable<ReturnType<typeof repos.getAgent>>) {
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)
+138 -2
View File
@@ -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')
})
})
+95 -11
View File
@@ -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<ReturnType<typeof repos.getInstallLink>>,
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<ReturnType<typeof repos.getInstallLink>>,
publicBaseUrl: string,
enrollSeedFallback: string,
): string {
return resolveAndRenderInstall(db, link, publicBaseUrl, enrollSeedFallback)
.body
}
export function resolveAndRenderInstall(
db: Db,
link: NonNullable<ReturnType<typeof repos.getInstallLink>>,
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',
}
}
@@ -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> = {},
): 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',
)
})
})
@@ -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`
}