Files
EvoFirewall/apps/api/src/services/install-links.ts
T
DenozordecandCursor d5784b9f35
Build and Push EvoFirewall Docker Image / build-and-push (push) Successful in 1m48s
Build and Push EvoFirewall Docker Image / create-release (push) Skipped
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]>
2026-07-21 01:45:38 +07:00

212 lines
5.7 KiB
TypeScript

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 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,
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 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,
agent_id: row.agentId ?? null,
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')
}
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.
*/
export function renderInstallScript(opts: {
cpUrl: string
seed: string
clientName: string
platform: string
installLinkId: 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)}'`,
`export EVOFW_INSTALL_LINK_ID='${escape(opts.installLinkId)}'`,
'',
].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}`
}
/**
* 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
repos.touchInstallLink(db, link.id)
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',
}
}