feat(api, web): implement short install link functionality for agents
Build and Push EvoFirewall Docker Image / build-and-push (push) Successful in 1m37s
Build and Push EvoFirewall Docker Image / create-release (push) Skipped

- Added new API endpoints for creating, retrieving, and revoking install links for agents.
- Enhanced the agent installation process with short links accessible via `/agent-install/:id` and `/:slug`.
- Updated the README and documentation to reflect the new installation method and usage instructions.
- Refactored relevant components in the web application to support the new install link feature.
- Improved error handling and validation for install link operations.

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
Denozordec
2026-07-21 01:25:05 +07:00
co-authored by Cursor
parent 14f516d5cc
commit ef56da4d91
13 changed files with 677 additions and 117 deletions
+35 -3
View File
@@ -17,6 +17,10 @@ import { controlRoutes } from './routes/control.js'
import { agentRoutes } from './routes/agent.js'
import { refreshAllLists } from './services/lists/refresh.js'
import { repos } from '@evofw/db'
import {
isValidInstallSlug,
resolveAndRenderInstallScript,
} from './services/install-links.js'
export interface BuildAppOptions {
config?: AppConfig
@@ -68,11 +72,39 @@ export async function buildApp(opts: BuildAppOptions = {}) {
root: staticDir,
wildcard: false,
})
app.setNotFoundHandler(async (_request, reply) => {
return reply.sendFile('index.html')
})
}
app.setNotFoundHandler(async (request, reply) => {
const path = request.url.split('?')[0] ?? ''
const segment = path.startsWith('/') ? path.slice(1) : path
if (
request.method === 'GET' &&
segment &&
!segment.includes('/') &&
isValidInstallSlug(segment)
) {
const link = repos.getInstallLinkBySlug(app.db, segment)
if (link) {
const script = resolveAndRenderInstallScript(
app.db,
link,
config.publicBaseUrl,
config.enrollSeed,
)
return reply.type('text/x-shellscript').send(script)
}
}
if (config.staticDir !== null) {
return reply.sendFile('index.html')
}
return reply.code(404).send({
type: 'about:blank',
title: 'Not Found',
status: 404,
})
})
if (!opts.memory) {
await app.register(import('@fastify/schedule'))
const task = new AsyncTask(
+16
View File
@@ -8,6 +8,7 @@ import type { AppConfig } from '../config.js'
import { hashToken } from '../plugins/auth.js'
import { evaluateAgentPolicy } from '../services/policy/evaluate.js'
import { AppError } from '../plugins/error-handler.js'
import { resolveAndRenderInstallScript } from '../services/install-links.js'
const __dirname = dirname(fileURLToPath(import.meta.url))
const scriptsDir = join(__dirname, '../agent-scripts')
@@ -23,6 +24,21 @@ export const agentRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
return reply.type('text/x-shellscript').send(body)
})
app.get<{ Params: { id: string } }>(
'/agent-install/:id',
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(
app.db,
link,
config.publicBaseUrl,
config.enrollSeed,
)
return reply.type('text/x-shellscript').send(script)
},
)
app.get('/v1/agent/sync-script', async (_req, reply) => {
const body = readFileSync(join(scriptsDir, 'evofw-firewall.sh'), 'utf-8')
return reply.type('text/x-shellscript').send(body)
+40
View File
@@ -5,6 +5,7 @@ import {
createIpListBodySchema,
createPolicyRuleBodySchema,
createPolicySetBodySchema,
createInstallLinkBodySchema,
patchPolicySetBodySchema,
putAgentPolicySetsBodySchema,
patchAgentBodySchema,
@@ -25,6 +26,10 @@ import {
resolveAndStoreHostnameRule,
resolveHostnameToCidrs,
} from '../services/policy/resolve-hostname.js'
import {
mapInstallLink,
randomToken,
} from '../services/install-links.js'
import type { AppConfig } from '../config.js'
function mapAgent(a: NonNullable<ReturnType<typeof repos.getAgent>>) {
@@ -134,6 +139,41 @@ export const controlRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
}
})
// Install short-links
app.get('/install-links', async () => ({
items: repos
.listInstallLinks(app.db)
.map((row) => mapInstallLink(row, config.publicBaseUrl)),
}))
app.post('/install-links', async (req, reply) => {
const body = createInstallLinkBodySchema.parse(req.body)
const id = randomToken(10)
const slug = randomToken(16)
if (repos.getInstallLink(app.db, id) || 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))
})
app.delete<{ Params: { id: string } }>(
'/install-links/:id',
async (req) => {
const row = repos.getInstallLink(app.db, req.params.id)
if (!row) throw new AppError('NOT_FOUND', 'Install link not found', 404)
const updated = repos.revokeInstallLink(app.db, row.id)
return mapInstallLink(updated!, config.publicBaseUrl)
},
)
// Agents
app.get('/agents', async () => ({
items: repos.listAgents(app.db).map(mapAgent),
@@ -0,0 +1,62 @@
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 link 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
curl: { by_id: string; by_slug: string }
}
expect(body.id).toBeTruthy()
expect(body.slug).toBeTruthy()
expect(body.curl.by_id).toContain(`/agent-install/${body.id}`)
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_SEED='test-seed'")
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'")
})
})
+127
View File
@@ -0,0 +1,127 @@
import { readFileSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import type { Db } from '@evofw/db'
import { repos } from '@evofw/db'
import { AppError } from '../plugins/error-handler.js'
const __dirname = dirname(fileURLToPath(import.meta.url))
const scriptsDir = join(__dirname, '../agent-scripts')
const ID_ALPHABET =
'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'
export function randomToken(length: number): string {
const bytes = crypto.getRandomValues(new Uint8Array(length))
let out = ''
for (const b of bytes) {
out += ID_ALPHABET[b % ID_ALPHABET.length]
}
return out
}
/** Root path segments that must never be treated as install slugs. */
export const RESERVED_ROOT_SEGMENTS = new Set([
'api',
'v1',
'assets',
'health',
'ready',
'favicon.ico',
'index.html',
'robots.txt',
'agent-install',
'static',
])
const SLUG_RE = /^[A-Za-z0-9_-]{8,64}$/
export function isValidInstallSlug(segment: string): boolean {
if (!segment || segment.includes('/') || segment.includes('.')) return false
if (RESERVED_ROOT_SEGMENTS.has(segment.toLowerCase())) return false
return SLUG_RE.test(segment)
}
export function buildInstallUrls(baseUrl: string, id: string, slug: string) {
const base = baseUrl.replace(/\/$/, '')
const byId = `${base}/agent-install/${id}`
const bySlug = `${base}/${slug}`
return {
by_id: byId,
by_slug: bySlug,
curl: {
by_id: `curl -fsSL ${byId} | bash`,
by_slug: `curl -fsSL ${bySlug} | bash`,
},
}
}
export function mapInstallLink(
row: NonNullable<ReturnType<typeof repos.getInstallLink>>,
baseUrl: string,
) {
const urls = buildInstallUrls(baseUrl, row.id, row.slug)
return {
id: row.id,
slug: row.slug,
client_name: row.clientName,
platform: row.platform as 'linux' | 'mikrotik',
created_at: row.createdAt,
revoked_at: row.revokedAt,
last_used_at: row.lastUsedAt,
use_count: row.useCount,
urls: { by_id: urls.by_id, by_slug: urls.by_slug },
curl: urls.curl,
}
}
function loadInstallSh(): string {
return readFileSync(join(scriptsDir, 'install.sh'), 'utf-8')
}
/**
* Self-contained install script: env exports + full install.sh body.
*/
export function renderInstallScript(opts: {
cpUrl: string
seed: string
clientName: string
platform: string
}): string {
const cp = opts.cpUrl.replace(/\/$/, '')
const escape = (s: string) => s.replace(/'/g, `'\\''`)
const header = [
'#!/usr/bin/env bash',
'# EvoFirewall short install link — env pre-set',
'set -euo pipefail',
`export EVOFW_CP_URL='${escape(cp)}'`,
`export EVOFW_SEED='${escape(opts.seed)}'`,
`export EVOFW_CLIENT_NAME='${escape(opts.clientName)}'`,
`export EVOFW_PLATFORM='${escape(opts.platform)}'`,
'',
].join('\n')
// Drop the shebang from install.sh to avoid double shebang.
const body = loadInstallSh().replace(/^#!\/usr\/bin\/env bash\r?\n/, '')
return `${header}${body}`
}
export function resolveAndRenderInstallScript(
db: Db,
link: NonNullable<ReturnType<typeof repos.getInstallLink>>,
publicBaseUrl: string,
enrollSeedFallback: string,
): string {
if (link.revokedAt) {
throw new AppError('GONE', 'Install link revoked', 410)
}
const seed =
repos.getSetting(db, 'enroll_seed') || enrollSeedFallback
repos.touchInstallLink(db, link.id)
return renderInstallScript({
cpUrl: publicBaseUrl,
seed,
clientName: link.clientName,
platform: link.platform,
})
}