- 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]>
212 lines
5.7 KiB
TypeScript
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',
|
|
}
|
|
}
|