#!/usr/bin/env node /* * BotFork Shortcut — one command, nothing to configure. * botfork build (if needed) + start the server + connect. * Stops the server again when your session ends. * botfork tool ... OSINT tools run directly, no server needed. * botfork stop|status|version|help * * Named .txt on purpose. Server assets live in ~/.cache/botfork * (binary, PID, log); the Ed25519 host key is kept in ~/.config/botfork. */ 'use strict'; const { spawn, spawnSync } = require('node:child_process'); const fs = require('node:fs'); const { join } = require('node:path'); const { homedir } = require('node:os'); const net = require('node:net'); const VERSION = '0.2.21'; const H = homedir(); const CFG = join(process.env.XDG_CONFIG_HOME || join(H, '.config'), 'botfork'); const CACHE = join(process.env.XDG_CACHE_HOME || join(H, '.cache'), 'botfork'); const HOSTKEY = join(CFG, 'hostkey'); const KNOWN = join(CFG, 'known_hosts'); const PIDFILE = join(CACHE, 'server.pid'); const LOGFILE = join(CACHE, 'server.log'); const BIN = join(CACHE, 'botfork'); const HOST = process.env.BOTFORK_HOST || 'localhost'; const PORT = parseInt(process.env.BOTFORK_PORT || '2222', 10); const USER = process.env.BOTFORK_USER || 'app'; const SRC = join(__dirname, 'server-src'); const C = { none: '\x1b[0m', bold: '\x1b[1m', dim: '\x1b[2m', red: '\x1b[31m', green: '\x1b[32m', yellow: '\x1b[33m', cyan: '\x1b[36m', magenta: '\x1b[35m', white: '\x1b[37m' }; const say = (c, s) => console.log(C[c] + s + C.none); const info = s => say('cyan', ' ℹ ' + s); const ok = s => say('green', ' ✓ ' + s); const warn = s => say('yellow', ' ⚠ ' + s); const bad = s => { say('red', ' ✗ ' + s); process.exit(1); }; const sleep = ms => new Promise(r => setTimeout(r, ms)); const mk = d => { fs.mkdirSync(d, { recursive: true }); return d; }; const sh = (cmd, args, o) => spawnSync(cmd, args, o || { encoding: 'utf8' }); const mtime = f => { try { return Number(sh('stat', ['-c', '%Y', f]).stdout.trim() || 0); } catch { return 0; } }; // ---- server lifecycle ------------------------------------------------------ const SERVER_SRC = [ 'cmd/botfork/main.go', 'internal/server/server.go', 'internal/server/config.go', 'internal/server/ratelimit.go', 'internal/cli/cli.go', 'internal/cli/menu.go', 'internal/cli/direct.go', 'internal/tools/tools.go', 'internal/keyutil/keyutil.go', ]; function serverDir() { if (process.env.BOTFORK_SERVER_DIR) return process.env.BOTFORK_SERVER_DIR; if (fs.existsSync(join(SRC, 'go.mod'))) return SRC; const cwd = process.cwd(); if (fs.existsSync(join(cwd, 'go.mod')) && /xdbi\/botfork/.test(fs.readFileSync(join(cwd, 'go.mod'), 'utf8'))) return cwd; return null; } function build() { const dir = serverDir() || bad('cannot build: run inside the botfork repo or set BOTFORK_SERVER_DIR'); info('building the server (first run)...'); const r = sh('go', ['build', '-ldflags=-s -w', '-o', BIN, './cmd/botfork/'], { cwd: dir, stdio: 'inherit' }); if (r.status !== 0) bad('build failed'); } function ensureBinary() { if (!fs.existsSync(BIN)) return build(); const dir = serverDir(); const newest = dir ? Math.max(...SERVER_SRC.map(f => mtime(join(dir, f)))) : 0; if (newest > mtime(BIN)) { info('source is newer — rebuilding'); build(); } } function ensureHostKey() { if (fs.existsSync(HOSTKEY) && fs.readFileSync(HOSTKEY, 'utf8').trim()) return; const dir = serverDir() || bad('cannot generate host key: run inside the botfork repo or set BOTFORK_SERVER_DIR'); info('generating a stable SSH host key...'); const out = sh('go', ['run', './cmd/genkey'], { cwd: dir }); const line = out.status === 0 && out.stdout.split('\n').find(l => l.startsWith('SSH_HOST_KEY_B64=')); if (!line) bad('host key generator failed'); fs.writeFileSync(join(mk(CFG), 'hostkey'), line.slice('SSH_HOST_KEY_B64='.length).trim() + '\n', { mode: 0o600 }); } function live(pid) { try { const s = fs.readFileSync('/proc/' + pid + '/stat', 'utf8'); // Linux only; zombie = dead return !s.slice(s.lastIndexOf(')') + 2).startsWith('Z'); } catch { try { process.kill(pid, 0); return true; } catch { return false; } } } function running() { try { const p = parseInt(fs.readFileSync(PIDFILE, 'utf8').trim(), 10); return p > 1 && live(p) ? p : null; } catch { return null; } } const portOpen = (host, port, ms) => new Promise(res => { const s = net.connect(port, host); s.setTimeout(ms || 2000); const done = v => { s.destroy(); res(v); }; s.once('connect', () => done(true)); s.once('error', () => done(false)); s.once('timeout', () => done(false)); }); async function start() { if (running()) return false; // another session claimed it — just connect ensureHostKey(); ensureBinary(); mk(CACHE); const log = fs.openSync(LOGFILE, 'a+'); const child = spawn(BIN, [], { detached: true, stdio: ['ignore', log, log], env: { ...process.env, ADDR: process.env.ADDR || `127.0.0.1:${PORT}`, SSH_HOST_KEY_B64: fs.readFileSync(HOSTKEY, 'utf8').trim() }, }); child.unref(); fs.writeFileSync(PIDFILE, String(child.pid) + '\n'); let up = false; for (let i = 0; i < 100; i++) { if (!live(child.pid)) break; if (await portOpen('127.0.0.1', PORT, 150)) { up = true; break; } await sleep(100); } if (!up) { warn('server failed to start — last log lines:'); say('dim', fs.existsSync(LOGFILE) ? fs.readFileSync(LOGFILE, 'utf8').trim().split('\n').slice(-15).join('\n') : ''); try { process.kill(child.pid, 'SIGKILL'); } catch {} process.exit(1); } return true; } function stop() { const p = running(); if (!p) return info('server is not running'); info(`stopping server (pid ${p})...`); try { process.kill(p, 'SIGTERM'); } catch {} for (let i = 0; i < 20 && live(p); i++) sh('sleep', ['0.2']); if (live(p)) { warn('SIGTERM ignored — sending SIGKILL'); try { process.kill(p, 'SIGKILL'); } catch {} sh('sleep', ['0.2']); } try { fs.unlinkSync(PIDFILE); } catch {} live(p) ? bad('could not stop the server process') : ok('server stopped'); } // ---- ssh connect ----------------------------------------------------------- function runSsh(args) { return new Promise(resolve => { const child = spawn(process.env.BOTFORK_SSH_BIN || 'ssh', args, { stdio: ['inherit', 'inherit', 'pipe'] }); let err = ''; child.stderr.on('data', d => { err += d; }); child.on('error', e => resolve({ code: 1, message: e.message })); child.on('exit', code => resolve({ code: code == null ? 1 : code, err, message: '' })); }); } async function connect() { const args = ['-o', 'StrictHostKeyChecking=accept-new', '-o', `UserKnownHostsFile=${KNOWN}`, '-o', 'ConnectTimeout=10', '-p', String(PORT), `${USER}@${HOST}`]; for (let pass = 0; ; pass++) { const r = await runSsh(args); if (r.code === 0) return; if (pass === 0 && r.code === 255 && /REMOTE HOST IDENTIFICATION HAS CHANGED/i.test(r.err)) { warn('host key changed — clearing cached key and retrying...'); sh('ssh-keygen', ['-f', KNOWN, '-R', `[${HOST}]:${PORT}`], { stdio: 'ignore' }); continue; } bad(`connection failed: ${r.message || 'exit ' + r.code}`); } } // ---- ui -------------------------------------------------------------------- function help() { const LOGO = [ ' \\ | /', ' \\ | /', ' -----------(*)----------', ' / | \\', ' / | \\', ].join('\n'); console.log(C.magenta + '\n' + LOGO); say('none', ''); say('magenta', ` botfork v${VERSION} · one command — server + CLI over SSH`); say('none', ''); say('bold', ' commands:'); for (const [cmd, desc] of [ ['botfork', 'build (if needed) + start + connect, stops on exit'], ['botfork stop', 'stop the server'], ['botfork status', 'is everything up?'], ['botfork version', 'show version'], ['botfork help', 'this help'], ]) say('none', ` ${cmd.padEnd(20)}${desc}`); say('bold', ''); say('bold', ' osint tools (run directly, no server):'); for (const [cmd, desc] of [ ['tool', 'interactive arrow-key menu'], ['tool list [category]', 'list tools'], ['tool add ', 'download tools'], ['tool show ', 'tool link & details'], ['tool run ', 'run a tool directly'], ]) say('none', ` ${cmd.padEnd(22)}${desc}`); say('dim', ' categories: username · email · domain · ip · phone'); say('bold', ''); say('bold', ' env:'); say('dim', ' BOTFORK_HOST · BOTFORK_PORT · BOTFORK_USER · BOTFORK_TOKEN · BOTFORK_AUTHORIZED_KEYS · BOTFORK_SERVER_DIR · BOTFORK_SSH_BIN'); say('none', ''); } const [cmd, ...rest] = process.argv.slice(2); if (cmd === 'version' || cmd === '-v' || cmd === '--version') { console.log('botfork v' + VERSION); process.exit(0); } if (cmd === 'help' || cmd === '-h' || cmd === '--help') { help(); process.exit(0); } if (cmd === 'stop') { stop(); process.exit(0); } if (cmd === 'tool') { ensureBinary(); const r = spawnSync(BIN, ['tool', ...rest], { stdio: 'inherit' }); process.exit(r.status == null ? 1 : r.status); } if (cmd === 'status') { const p = running(); (async () => { const reachable = await portOpen(HOST, PORT); if (p) ok(`server is running (pid ${p})`); else info('server is not running'); if (reachable) ok(`${HOST}:${PORT} is reachable`); else warn(`${HOST}:${PORT} is unreachable`); process.exit(p && reachable ? 0 : 1); })(); } else { // default: full automatic session (async () => { const claimed = await start(); if (process.env.BOTFORK_NO_CONNECT !== '1') await connect(); if (claimed) { info('session ended — stopping the server'); stop(); } })(); }