Files
EvoFirewall/apps/api/src/services/install-links.ts
T
Denozordec 40030ce06c fix(api): fail-safe прод-старт, транзакции и санитизация install-скриптов
- прод-режим отказывается стартовать без AUTH_REQUIRED и реальных секретов
  (opt-out через EVOFW_ALLOW_UNSAFE)
- CORS: whitelist через CORS_ORIGINS вместо origin:true; CSP для раздаваемого SPA
- транзакции для setAgentPolicySets, reorderPolicyRules, replaceResolvedForRule,
  replaceIpListEntries
- install-скрипты: Zod-валидация имени ссылки, экранирование $ и контрольных
  символов в RouterOS-рендере
- constant-time сравнение enroll-seed
- опциональное шифрование токена EvoBGP в БД (EVOFW_SECRET_KEY, AES-256-GCM)
  и маскирование per-list api_token в ответах
- graceful shutdown (SIGTERM/SIGINT) + тесты
2026-09-20 18:51:47 +07:00

221 lines
6.1 KiB
TypeScript

import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import type { Db } from '@evofw/db'
import { repos } from '@evofw/db'
import { AppError } from '../plugins/error-handler.js'
import { resolveAgentScriptsDir } from './agent-scripts-path.js'
const scriptsDir = resolveAgentScriptsDir()
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')
}
/** Strip control characters that have no business inside generated scripts. */
function stripControlChars(s: string): string {
// eslint-disable-next-line no-control-regex
return s.replace(/[\x00-\x1f\x7f]/g, '')
}
function escapeRosString(s: string): string {
// RouterOS interpolates $var and substitutes $(cmd) inside double quotes.
return stripControlChars(s)
.replace(/\\/g, '\\\\')
.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 = stripControlChars(opts.cpUrl.replace(/\/$/, ''))
const escape = (s: string) => stripControlChars(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',
}
}