import { spawn, ChildProcess } from 'child_process'; import fs from 'fs'; import * as process from 'node:process'; import os from 'os'; import path from 'path'; import { Command } from 'commander'; // Ensure process.kill is configurable for testing by defining own property try { Object.defineProperty(process, 'kill', { configurable: true, writable: true, value: process.kill, }); } catch { // ignore } /** Options for launching a Teleport application bot (tbot) */ export interface TeleportBotAgentOptions { /** Name of the Teleport application */ appName: string; /** Join token for the bot (required on first start, optional afterwards) */ joinToken?: string; /** Working directory where 'data' and 'dest' folders will be created */ workDir: string; /** TTL of short-lived machine certificates (e.g. '8h') */ certificateTtl?: string; /** Interval at which short-lived certificates are renewed (must be < TTL, e.g. '1h') */ renewalInterval?: string; } /** Result of launching the Teleport bot */ export interface TeleportBotAgentResult { /** PID of the launched tbot process */ pid: number; /** Application name used */ appName: string; /** Proxy server used */ proxyServer: string; /** Path to generated certificate file */ certPath: string; /** Path to generated key file */ keyPath: string; /** Path to tbot log file */ logPath: string; } /** * Launches a Teleport application bot (tbot) in background (detached), * creating 'data' and 'dest' subdirectories under the provided workDir, * and streaming stdout/stderr to console. */ export async function teleportBotAgent({ appName, joinToken, workDir, certificateTtl = '8h', renewalInterval = '1h', }: TeleportBotAgentOptions): Promise { // Ensure workDir exists and add .gitignore to ignore contents fs.mkdirSync(workDir, { recursive: true }); const gitignorePath = path.join(workDir, '.gitignore'); if (!fs.existsSync(gitignorePath)) { fs.writeFileSync(gitignorePath, '*\n!.gitignore\n'); } // Prepare data and dest subdirectories const dataDir = path.join(workDir, 'data'); fs.mkdirSync(dataDir, { recursive: true }); // If stale lock file exists in dataDir, warn and remove it const lockFile = path.join(dataDir, 'lock'); if (fs.existsSync(lockFile)) { console.warn(`Warning: removing stale lock file at ${lockFile}`); try { fs.unlinkSync(lockFile); } catch { /* ignore */ } } const destDir = path.join(workDir, 'dest'); fs.mkdirSync(destDir, { recursive: true }); const pidFile = path.join(workDir, 'tbot.pid'); const proxyServer = 'teleport.ftprod.fr:443'; // If a previous tbot run exists, kill its process and remove PID if (fs.existsSync(pidFile)) { try { const oldPid = parseInt(fs.readFileSync(pidFile, 'utf8'), 10); if (!isNaN(oldPid)) process.kill(oldPid); } catch { // ignore errors } try { fs.unlinkSync(pidFile); } catch { /* ignore */ } } // Build tbot start arguments const args: string[] = [ 'start', 'application', `--storage=file://${dataDir}`, `--destination=file://${destDir}`, `--app=${appName}`, ]; if (joinToken) { args.push(`--token=${joinToken}`); } // join-method is always required args.push('--join-method=token', `--proxy-server=${proxyServer}`, '--specific-tls-extensions'); // Optional certificate TTL and renewal interval if (certificateTtl) { args.push(`--certificate-ttl=${certificateTtl}`); } if (renewalInterval) { args.push(`--renewal-interval=${renewalInterval}`); } console.log(`Launching tbot command: tbot ${args.join(' ')}`); const logPath = path.join(workDir, 'tbot.log'); const outFd = fs.openSync(logPath, 'a'); const errFd = fs.openSync(logPath, 'a'); const bot: ChildProcess = spawn('tbot', args, { detached: true, stdio: ['ignore', outFd, errFd], }); fs.closeSync(outFd); fs.closeSync(errFd); bot.unref(); try { fs.writeFileSync(pidFile, `${bot.pid}`); } catch (_err) {} const certPath = path.join(destDir, 'tls.crt'); const keyPath = path.join(destDir, 'tls.key'); return { pid: bot.pid ?? -1, appName, proxyServer, certPath, keyPath, logPath }; } /** CLI registration for teleportBotAgent */ export const cli = { command: 'start-app', description: 'Lance un Teleport application bot en arrière-plan', builder: (cmd: Command) => cmd .requiredOption('--app ', 'Nom de l’application Teleport') .option('--token ', 'Join token pour le bot') .option('--workDir ', "Répertoire de travail pour 'data' et 'dest'") .option('--certificate-ttl ', 'TTL of short-lived machine certificates (e.g. 8h)', '8h') .option('--renewal-interval ', 'Interval at which certificates are renewed (must be < TTL, e.g. 1h)', '1h'), handler: async (opts: { app: string; token?: string; workDir?: string; certificateTtl?: string; renewalInterval?: string; }) => { try { const workDir = opts.workDir ?? os.homedir() + '/.ftprod-ai/tbot'; // eslint-disable-next-line @typescript-eslint/no-var-requires const { teleportBotAgent: run } = require('./teleportBotAgent'); const res = await run({ appName: opts.app, joinToken: opts.token, workDir, certificateTtl: opts.certificateTtl, renewalInterval: opts.renewalInterval, }); console.log('Bot lancé:', JSON.stringify(res)); } catch (err: unknown) { const msg = err instanceof Error ? err.message : String(err); console.error('Erreur lancement tbot:', msg); process.exit(1); } }, };