/** * setup/git.ts — Provision a local Bastille git jail. * * Creates a thin jail with git + rsync, mirrors from REMOTE_GIT_URL * plus any GIT_MIRROR_URLS, or creates an empty bare repo as fallback. * Fully idempotent — safe to rerun. */ import { execSync, spawnSync } from 'child_process'; import fs from 'fs'; import path from 'path'; import { CODE_SERVICE_INTERNAL_DOMAIN, CODE_HOSTING_MODE, FEATURE_GIT, GIT_DEFAULT_REPO_NAME, GIT_JAIL_NAME, GIT_MIRROR_URLS, GIT_JAIL_IP, GIT_STORAGE_ROOT, PLATFORM_RUNTIME_HOME, REMOTE_GIT_URL, SUBNET_BASE, } from '../src/config.js'; import { logger } from '../src/logger.js'; import { readEnvFile } from '../src/env.js'; import { loadPackageList, mountPkgCacheInJail } from './packages.js'; import { getPlatform } from './platform.js'; import { emitStatus } from './status.js'; import { maybeEnableTailscaleInJail } from './tailscale.js'; import { bastille, bastilleTimed, jailExists, jailRoot, detectFreeBSDRelease, } from './bastille-helpers.js'; const LOG = 'logs/setup.log'; const CLONE_TIMEOUT_MS = 30 * 60 * 1000; function repoNameFromRemote(remote: string): string | null { const trimmed = remote.trim(); if (!trimmed) return null; const hashIndex = trimmed.indexOf('#'); const clean = hashIndex >= 0 ? trimmed.slice(0, hashIndex) : trimmed; try { const url = new URL(clean); const base = path.posix.basename(url.pathname); return base || null; } catch { // Fall through to SCP-style parsing. } const colonIndex = clean.lastIndexOf(':'); if (colonIndex !== -1 && clean.slice(colonIndex + 1).includes('/')) { const base = path.posix.basename(clean.slice(colonIndex + 1)); return base || null; } const base = path.posix.basename(clean); return base || null; } function repoExistsInJail(jail: string, repoPath: string): boolean { const { ok } = bastille('cmd', jail, 'test', '-d', repoPath); return ok; } function repoHasRefsInJail(jail: string, repoPath: string): boolean { const refs = bastille('cmd', jail, 'git', '--git-dir', repoPath, 'show-ref'); return refs.ok && refs.output.trim().length > 0; } function repoHeadLooksInvalid(jail: string, repoPath: string): boolean { const head = bastille('cmd', jail, 'cat', path.posix.join(repoPath, 'HEAD')); return !head.ok || head.output.includes('refs/heads/.invalid'); } function setupGitJailSsh(jailName: string): void { const root = jailRoot(jailName); // Generate host keys (idempotent — won't overwrite existing) bastille('cmd', jailName, 'ssh-keygen', '-A'); // Write sshd_config — keys only, no passwords, no root login const sshdDir = path.join(root, 'etc', 'ssh'); fs.mkdirSync(sshdDir, { recursive: true }); const sshdConfig = [ 'Port 22', 'PermitRootLogin no', 'PasswordAuthentication no', 'PubkeyAuthentication yes', 'AuthorizedKeysFile .ssh/authorized_keys', '', ].join('\n'); fs.writeFileSync(path.join(sshdDir, 'sshd_config'), sshdConfig); // Create git user with restricted shell (git-shell) const checkUser = bastille('cmd', jailName, 'id', 'git'); if (!checkUser.ok) { bastille( 'cmd', jailName, 'pw', 'useradd', 'git', '-c', 'Git Service', '-s', '/usr/local/bin/git-shell', '-d', GIT_STORAGE_ROOT, '-m', ); logger.info({ jailName }, 'Created git user with git-shell'); } // Deploy operator public key into git user's authorized_keys const env = readEnvFile(['SSH_PUBLIC_KEY', 'GIT_SSH_KEY_PATH', 'HOME']); let pubKey: string | null = null; if (env['SSH_PUBLIC_KEY']) { pubKey = env['SSH_PUBLIC_KEY']; } else { const keyPath = env['GIT_SSH_KEY_PATH'] ? env['GIT_SSH_KEY_PATH'] + '.pub' : path.join( env['HOME'] || PLATFORM_RUNTIME_HOME, '.ssh', 'id_ed25519.pub', ); if (fs.existsSync(keyPath)) { pubKey = fs.readFileSync(keyPath, 'utf-8').trim(); } } if (pubKey) { const sshDir = path.join(root, GIT_STORAGE_ROOT.replace(/^\//, ''), '.ssh'); fs.mkdirSync(sshDir, { recursive: true }); const akPath = path.join(sshDir, 'authorized_keys'); // Only append if key not already present const existing = fs.existsSync(akPath) ? fs.readFileSync(akPath, 'utf-8') : ''; if (!existing.includes(pubKey)) { fs.appendFileSync(akPath, pubKey + '\n'); } bastille( 'cmd', jailName, 'chown', '-R', 'git:git', `${GIT_STORAGE_ROOT}/.ssh`, ); bastille('cmd', jailName, 'chmod', '700', `${GIT_STORAGE_ROOT}/.ssh`); bastille( 'cmd', jailName, 'chmod', '600', `${GIT_STORAGE_ROOT}/.ssh/authorized_keys`, ); logger.info({ jailName }, 'Deployed SSH public key for git user'); } else { logger.warn( { jailName }, 'No SSH public key found — set SSH_PUBLIC_KEY in .env or provide ~/.ssh/id_ed25519.pub', ); } // Enable and start sshd bastille('cmd', jailName, 'sysrc', 'sshd_enable=YES'); bastille('cmd', jailName, 'service', 'sshd', 'restart'); } export async function run(_args: string[]): Promise { const jailName = GIT_JAIL_NAME; const hostname = CODE_SERVICE_INTERNAL_DOMAIN; const repoPath = path.posix.join(GIT_STORAGE_ROOT, GIT_DEFAULT_REPO_NAME); const extraMirrorUrls = GIT_MIRROR_URLS.filter( (url) => url && url !== REMOTE_GIT_URL, ); const runBastille = (args: string[]) => bastille(...args); // Feature gate — skip if git hosting is explicitly disabled if (!FEATURE_GIT || CODE_HOSTING_MODE === 'external') { emitStatus('SETUP_GIT', { STATUS: 'skipped', REASON: !FEATURE_GIT ? 'feature_disabled' : 'external_hosting', LOG, }); logger.info( 'Git jail skipped — FEATURE_GIT=%s, CODE_HOSTING_MODE=%s', FEATURE_GIT, CODE_HOSTING_MODE, ); return; } // Platform gate if (getPlatform() !== 'freebsd') { emitStatus('SETUP_GIT', { STATUS: 'failed', ERROR: 'unsupported_platform', LOG, }); process.exit(1); } try { const exists = jailExists(jailName); // ── Create jail if missing ────────────────────────────────────── if (!exists) { const release = detectFreeBSDRelease(); const gateway = process.env.WARDEN_GATEWAY || `${SUBNET_BASE}.1`; const bridge = process.env.WARDEN_BRIDGE || 'warden0'; logger.info({ jailName, ip: GIT_JAIL_IP, release }, 'Creating git jail'); const create = bastille( 'create', // thin jail (no -T) to avoid ZFS disk amplification '-B', '-g', gateway, jailName, release, `${GIT_JAIL_IP}/24`, bridge, ); if (!create.ok) { throw new Error(`bastille create failed: ${create.output}`); } // Set hostname and restart so it takes effect bastille('config', jailName, 'set', 'host.hostname', hostname); bastille('restart', jailName); } else { logger.info({ jailName }, 'Git jail already exists, skipping creation'); } // ── Install packages ──────────────────────────────────────────── mountPkgCacheInJail(jailName); const packages = loadPackageList('git-jail.txt'); const pkg = bastille('pkg', jailName, 'install', '-y', ...packages); if (!pkg.ok) { logger.warn({ output: pkg.output }, 'Package install had warnings'); } maybeEnableTailscaleInJail(runBastille, jailName, jailName); // ── SSH setup (git-shell user + key-only auth) ──────────────────── setupGitJailSsh(jailName); // ── Create storage root ───────────────────────────────────────── bastille('cmd', jailName, 'install', '-d', '-m', '755', GIT_STORAGE_ROOT); // ── Create or mirror repository ───────────────────────────────── const repoExists = repoExistsInJail(jailName, repoPath); if ( repoExists && REMOTE_GIT_URL && (repoHeadLooksInvalid(jailName, repoPath) || !repoHasRefsInJail(jailName, repoPath)) ) { // A prior mirror clone may have been interrupted (e.g. SSH remote without keys), // leaving a bare repo skeleton with no refs and an invalid HEAD. // If a remote is configured, prefer repairing by re-cloning the mirror. logger.warn( { repoPath, remote: REMOTE_GIT_URL }, 'Bare repo appears incomplete — removing and re-cloning mirror', ); bastille('cmd', jailName, 'rm', '-rf', repoPath); } if (!repoExistsInJail(jailName, repoPath)) { let mirrored = false; if (REMOTE_GIT_URL) { logger.info({ url: REMOTE_GIT_URL }, 'Attempting mirror clone'); const clone = bastilleTimed( CLONE_TIMEOUT_MS, 'cmd', jailName, 'git', 'clone', '--mirror', REMOTE_GIT_URL, repoPath, ); if (clone.ok) { mirrored = true; } else { logger.warn( { output: clone.output }, 'Mirror clone failed — falling back to empty bare repo', ); } } if (!mirrored) { const init = bastille( 'cmd', jailName, 'git', 'init', '--bare', repoPath, ); if (!init.ok) { throw new Error(`git init --bare failed: ${init.output}`); } } } else { logger.info({ repoPath }, 'Bare repo already exists, skipping'); } if (extraMirrorUrls.length) { for (const mirrorUrl of extraMirrorUrls) { const repoName = repoNameFromRemote(mirrorUrl); if (!repoName) { logger.warn( { mirrorUrl }, 'Unable to derive repo name for mirror URL', ); continue; } if (repoName === GIT_DEFAULT_REPO_NAME) { continue; } const mirrorRepoPath = path.posix.join(GIT_STORAGE_ROOT, repoName); if (repoExistsInJail(jailName, mirrorRepoPath)) { logger.info( { mirrorRepoPath }, 'Mirror repo already exists, skipping', ); continue; } logger.info({ mirrorUrl, mirrorRepoPath }, 'Attempting mirror clone'); const clone = bastilleTimed( CLONE_TIMEOUT_MS, 'cmd', jailName, 'git', 'clone', '--mirror', mirrorUrl, mirrorRepoPath, ); if (!clone.ok) { logger.warn( { mirrorUrl, output: clone.output }, 'Mirror clone failed — skipping extra repo', ); } } } // ── Enable on boot ────────────────────────────────────────────── try { execSync(`sysrc jail_list+="${jailName}"`, { stdio: 'ignore' }); } catch { logger.warn('sysrc jail_list update failed — jail may not start on boot'); } // ── Validate ──────────────────────────────────────────────────── const gitVer = bastille('cmd', jailName, 'git', '--version'); if (!gitVer.ok) { throw new Error('Validation failed: git not installed in jail'); } const isBare = bastille( 'cmd', jailName, 'git', '--git-dir', repoPath, 'rev-parse', '--is-bare-repository', ); if (!isBare.ok || !isBare.output.includes('true')) { throw new Error( `Validation failed: ${repoPath} is not a valid bare repository`, ); } emitStatus('SETUP_GIT', { STATUS: 'success', JAIL_NAME: jailName, JAIL_IP: GIT_JAIL_IP, GIT_STORAGE_ROOT, GIT_REPO: GIT_DEFAULT_REPO_NAME, MIRROR_SOURCE: REMOTE_GIT_URL || 'none', MIRROR_EXTRAS: extraMirrorUrls.join(',') || 'none', LOG, }); logger.info( { jailName, ip: GIT_JAIL_IP, repoPath }, 'Git jail provisioned', ); } catch (error) { const message = error instanceof Error ? error.message : String(error); emitStatus('SETUP_GIT', { STATUS: 'failed', ERROR: message, LOG, }); throw error; } }