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 <[email protected]>
This commit is contained in:
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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`
|
||||
}
|
||||
Reference in New Issue
Block a user