Files
EvoFirewall/apps/api/src/services/install-links.test.ts
T
Denozordec d552f4f326
Build and Push EvoFirewall Docker Image / build-and-push (push) Successful in 1m45s
Build and Push EvoFirewall Docker Image / create-release (push) Skipped
feat(api): implement self-update mechanism for Linux agents
- Added a `maybe_self_update` function in `evofw-firewall.sh` to allow agents to pull the latest version of the sync script from the server, enhancing the agent's ability to stay updated.
- Updated the `/v1/agent/sync-script` endpoint to return ETag and script SHA256 headers, enabling efficient caching and conditional requests.
- Modified the agent policy response to include `script_sha256`, providing visibility into the current version of the sync script.
- Enhanced tests to verify the self-update functionality and ensure correct behavior of the sync script endpoint.

These changes improve the maintainability and reliability of Linux agents by enabling automatic updates of critical scripts.
2026-08-15 16:56:00 +07:00

331 lines
10 KiB
TypeScript

import { describe, it, expect, afterAll } from 'vitest'
import { buildApp } from '../app.js'
import type { AppConfig } from '../config.js'
const testConfig: AppConfig = {
databaseUrl: 'sqlite::memory:',
jwtSecret: 'test',
jwtTtlHours: 24,
serverPort: 8080,
staticDir: null,
logLevel: 'error',
authRequired: false,
authIssuer: 'https://auth.test',
authPortalUrl: 'http://localhost:5175',
publicBaseUrl: 'https://fw.example.com',
enrollSeed: 'test-seed',
}
describe('install-links', () => {
const appPromise = buildApp({ memory: true, config: testConfig })
afterAll(async () => {
const app = await appPromise
await app.close()
})
it('creates invited agent and serves scripts by id and slug', async () => {
const app = await appPromise
await app.ready()
const created = await app.inject({
method: 'POST',
url: '/api/v1/install-links',
payload: { name: 'web-01', platform: 'linux' },
})
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.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}`,
})
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_INSTALL_LINK_ID='${body.id}'`)
const bySlug = await app.inject({
method: 'GET',
url: `/${body.slug}`,
})
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-deny-drop-input')
expect(byId.body).toContain('/v1/agent/policy')
expect(byId.body).toContain(':deserialize')
expect(byId.body).toContain('evofw-sync-body')
expect(byId.body).toContain('remove [find name="evofw-env"]')
})
it('approved agent can fetch empty policy without rule sets', async () => {
const app = await appPromise
await app.ready()
const created = await app.inject({
method: 'POST',
url: '/api/v1/install-links',
payload: { name: 'empty-policy', platform: 'linux' },
})
const link = created.json() as { id: string; agent_id: string }
const token = 'evofw_empty_policy_token_abcdefgh'
const enroll = await app.inject({
method: 'POST',
url: '/v1/agent/enroll',
headers: {
'content-type': 'application/json',
'x-evofw-seed': 'test-seed',
},
payload: {
name: 'empty-policy',
platform: 'linux',
token,
install_link_id: link.id,
},
})
expect(enroll.statusCode).toBe(201)
await app.inject({
method: 'POST',
url: `/api/v1/agents/${link.agent_id}/approve`,
})
const policy = await app.inject({
method: 'GET',
url: '/v1/agent/policy',
headers: { authorization: `Bearer ${token}` },
})
expect(policy.statusCode).toBe(200)
const body = policy.json() as {
deny_cidrs: string[]
allow_cidrs: string[]
default_action: string
policy_mode: string
apply_version: number
hash: string
script_sha256: string
}
expect(body.deny_cidrs).toEqual([])
expect(body.allow_cidrs).toEqual([])
expect(body.default_action).toBe('accept')
expect(body.policy_mode).toBe('blacklist')
expect(body.apply_version).toBe(3)
expect(body.hash).toMatch(/^sha256:/)
expect(body.script_sha256).toMatch(/^[a-f0-9]{64}$/)
const agents = await app.inject({ method: 'GET', url: '/api/v1/agents' })
const row = (
agents.json() as { items: { id: string; last_seen_at: string | null }[] }
).items.find((a) => a.id === link.agent_id)
expect(row?.last_seen_at).toBeTruthy()
})
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')
})
it('sync-script serves ETag and 304 on If-None-Match', async () => {
const app = await appPromise
await app.ready()
const first = await app.inject({
method: 'GET',
url: '/v1/agent/sync-script',
})
expect(first.statusCode).toBe(200)
expect(first.body.startsWith('#!')).toBe(true)
expect(first.body).toContain('maybe_self_update')
const etag = String(first.headers.etag ?? '')
const sha = String(first.headers['x-evofw-script-sha256'] ?? '')
expect(etag).toMatch(/^"[a-f0-9]{64}"$/)
expect(sha).toBe(etag.replaceAll('"', ''))
const cached = await app.inject({
method: 'GET',
url: '/v1/agent/sync-script',
headers: { 'if-none-match': etag },
})
expect(cached.statusCode).toBe(304)
const miss = await app.inject({
method: 'GET',
url: '/v1/agent/sync-script',
headers: { 'if-none-match': '"deadbeef"' },
})
expect(miss.statusCode).toBe(200)
expect(miss.body).toBe(first.body)
const created = await app.inject({
method: 'POST',
url: '/api/v1/install-links',
payload: { name: 'script-sha-policy', platform: 'linux' },
})
const link = created.json() as { id: string; agent_id: string }
const token = 'evofw_script_sha_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: 'script-sha-policy',
platform: 'linux',
token,
install_link_id: link.id,
},
})
expect(enroll.statusCode).toBe(201)
await app.inject({
method: 'POST',
url: `/api/v1/agents/${link.agent_id}/approve`,
})
const policy = await app.inject({
method: 'GET',
url: '/v1/agent/policy',
headers: { authorization: `Bearer ${token}` },
})
expect(policy.statusCode).toBe(200)
expect((policy.json() as { script_sha256: string }).script_sha256).toBe(sha)
})
})