/** * setup/forgejo.ts — Install and configure Forgejo in the git jail. * * Runs inside the existing ${TENANT_ID}-git jail created by setup/git.ts. * Gated by FEATURE_GITEA=YES or CODE_HOSTING_MODE=gitea. * Uses PostgreSQL in the db jail, listens on port 3000. */ import { spawnSync } from 'child_process'; import fs from 'fs'; import path from 'path'; import { CODE_SERVICE_INTERNAL_DOMAIN, CODE_HOSTING_MODE, FEATURE_GITEA, FORGEJO_DB_URL, GIT_DEFAULT_REPO_NAME, GIT_JAIL_NAME, GIT_JAIL_IP, GIT_STORAGE_ROOT, TENANT_ID, } from '../src/config.js'; import { logger } from '../src/logger.js'; import { loadPackageList, mountPkgCacheInJail } from './packages.js'; import { getPlatform } from './platform.js'; import { emitStatus } from './status.js'; import { bastille, bastilleTimed, jailExists } from './bastille-helpers.js'; const LOG = 'logs/setup.log'; const FORGEJO_PORT = 3000; const FORGEJO_USER = 'git'; const FORGEJO_CONF_DIR = '/usr/local/etc/forgejo/conf'; const FORGEJO_DATA_DIR = '/usr/local/share/forgejo/data'; const FORGEJO_LOG_DIR = '/usr/local/share/forgejo/log'; const CONFIG_MARKER = '; Forgejo configuration — generated by setup/forgejo.ts'; const PKG_TIMEOUT_MS = 30 * 60 * 1000; const MIGRATE_TIMEOUT_MS = 5 * 60 * 1000; const SERVICE_TIMEOUT_MS = 60 * 1000; const HEALTH_TIMEOUT_MS = 10 * 1000; type ForgejoDbConfig = { host: string; name: string; user: string; password: string; }; function parseForgejoDbUrl(dbUrl: string): ForgejoDbConfig { let parsed: URL; try { parsed = new URL(dbUrl); } catch (error) { throw new Error(`Invalid FORGEJO_DB_URL: ${String(error)}`); } const name = parsed.pathname.replace(/^\//, ''); if (!name) { throw new Error('FORGEJO_DB_URL missing database name'); } return { host: parsed.port ? `${parsed.hostname}:${parsed.port}` : parsed.hostname, name: decodeURIComponent(name), user: decodeURIComponent(parsed.username || ''), password: decodeURIComponent(parsed.password || ''), }; } function generateAppIni( hostname: string, repoRoot: string, dbConfig: ForgejoDbConfig, ): string { return `${CONFIG_MARKER} ; Edit /usr/local/etc/forgejo/conf/app.ini inside the git jail to customize. APP_NAME = ${TENANT_ID} Git RUN_USER = ${FORGEJO_USER} RUN_MODE = prod WORK_PATH = /usr/local/share/forgejo [server] DOMAIN = ${hostname} ROOT_URL = http://${hostname}:${FORGEJO_PORT}/ HTTP_PORT = ${FORGEJO_PORT} SSH_DOMAIN = ${hostname} SSH_PORT = 22 DISABLE_SSH = true LFS_START_SERVER = false OFFLINE_MODE = true [database] DB_TYPE = postgres HOST = ${dbConfig.host} NAME = ${dbConfig.name} USER = ${dbConfig.user} PASSWD = ${dbConfig.password} SSL_MODE = disable [repository] ROOT = ${repoRoot} [security] INSTALL_LOCK = true [service] DISABLE_REGISTRATION = true REQUIRE_SIGNIN_VIEW = false [log] MODE = file LEVEL = warn ROOT_PATH = ${FORGEJO_LOG_DIR} [picture] DISABLE_GRAVATAR = true [actions] ENABLED = false `; } export async function run(_args: string[]): Promise { const jailName = GIT_JAIL_NAME; const dbUrl = FORGEJO_DB_URL; // Feature gate if (!FEATURE_GITEA && CODE_HOSTING_MODE !== 'gitea') { emitStatus('SETUP_FORGEJO', { STATUS: 'skipped', REASON: 'feature_disabled', LOG, }); logger.info( 'Forgejo skipped — FEATURE_GITEA disabled and CODE_HOSTING_MODE != gitea', ); return; } // Platform gate if (getPlatform() !== 'freebsd') { emitStatus('SETUP_FORGEJO', { STATUS: 'failed', ERROR: 'unsupported_platform', LOG, }); process.exit(1); } if (!dbUrl) { emitStatus('SETUP_FORGEJO', { STATUS: 'failed', ERROR: 'missing_forgejo_db_url', LOG, }); throw new Error('FORGEJO_DB_URL is not set — run setup --step db first'); } // Git jail must exist (created by setup/git.ts) if (!jailExists(jailName)) { emitStatus('SETUP_FORGEJO', { STATUS: 'failed', ERROR: 'git_jail_missing', HINT: 'Run setup --step git first', LOG, }); throw new Error( `Git jail ${jailName} does not exist — run setup --step git first`, ); } try { // ── Install Forgejo package ─────────────────────────────────────── mountPkgCacheInJail(jailName); const packages = loadPackageList('forgejo-jail.txt'); const pkg = bastilleTimed( PKG_TIMEOUT_MS, 'pkg', jailName, 'install', '-y', ...packages, ); if (!pkg.ok) { logger.warn( { output: pkg.output }, 'Forgejo package install had warnings', ); } // ── Create data/log directories ─────────────────────────────────── for (const dir of [FORGEJO_DATA_DIR, FORGEJO_LOG_DIR]) { bastille( 'cmd', jailName, 'install', '-d', '-o', FORGEJO_USER, '-g', FORGEJO_USER, '-m', '755', dir, ); } // ── Ensure repository root is writable by forgejo user ──────────── bastille( 'cmd', jailName, 'install', '-d', '-o', FORGEJO_USER, '-g', FORGEJO_USER, '-m', '755', GIT_STORAGE_ROOT, ); bastille( 'cmd', jailName, 'chown', '-R', `${FORGEJO_USER}:${FORGEJO_USER}`, GIT_STORAGE_ROOT, ); // ── Write app.ini (replace default sample config) ────────────────── const jailRoot = `/usr/local/bastille/jails/${jailName}/root`; const destConf = path.join(jailRoot, FORGEJO_CONF_DIR, 'app.ini'); let shouldWriteConfig = true; if (fs.existsSync(destConf)) { try { const existing = fs.readFileSync(destConf, 'utf-8'); if (existing.includes(CONFIG_MARKER)) { shouldWriteConfig = false; } } catch { // If unreadable, overwrite. } } if (shouldWriteConfig) { logger.info('Writing Forgejo app.ini'); // Ensure conf directory exists bastille( 'cmd', jailName, 'install', '-d', '-o', FORGEJO_USER, '-g', FORGEJO_USER, '-m', '755', FORGEJO_CONF_DIR, ); if (fs.existsSync(destConf)) { const backup = path.join( process.cwd(), 'tmp', `forgejo-app.ini.backup-${Date.now()}`, ); fs.mkdirSync(path.dirname(backup), { recursive: true }); fs.copyFileSync(destConf, backup); } const dbConfig = parseForgejoDbUrl(dbUrl); if (!dbConfig.user || !dbConfig.password) { throw new Error('FORGEJO_DB_URL must include a username and password'); } fs.mkdirSync(path.dirname(destConf), { recursive: true }); fs.writeFileSync( destConf, generateAppIni( CODE_SERVICE_INTERNAL_DOMAIN, GIT_STORAGE_ROOT, dbConfig, ), ); // Fix ownership inside jail bastille( 'cmd', jailName, 'chown', `${FORGEJO_USER}:${FORGEJO_USER}`, `${FORGEJO_CONF_DIR}/app.ini`, ); } else { logger.info( 'Forgejo app.ini already managed, skipping config generation', ); } // ── Migrate DB schema (required for rc.d configcheck) ────────────── const migrate = bastilleTimed( MIGRATE_TIMEOUT_MS, 'cmd', jailName, 'su', '-m', FORGEJO_USER, '-c', `FORGEJO_CUSTOM=${path.posix.dirname(FORGEJO_CONF_DIR)} /usr/local/sbin/forgejo migrate`, ); if (!migrate.ok) { throw new Error(`Forgejo migrate failed: ${migrate.output}`); } // ── Enable and start service ────────────────────────────────────── bastille('sysrc', jailName, 'forgejo_enable=YES'); // rc.d preflight runs `forgejo doctor check`, which can fail even with // DISABLE_SSH=true (e.g. authorized_keys check). We manage health via // explicit API probe below, so disable the rc.d configcheck gate. bastille('sysrc', jailName, 'forgejo_configcheck_enable=NO'); const start = bastilleTimed( SERVICE_TIMEOUT_MS, 'service', jailName, 'forgejo', 'restart', ); if (!start.ok) { logger.warn( { output: start.output }, 'Forgejo service start had warnings', ); } // ── Validate ────────────────────────────────────────────────────── // Give Forgejo a moment to bind await new Promise((resolve) => setTimeout(resolve, 2000)); const health = bastilleTimed( HEALTH_TIMEOUT_MS, 'cmd', jailName, 'fetch', '-qo', '-', `http://127.0.0.1:${FORGEJO_PORT}/api/v1/version`, ); if (!health.ok) { logger.warn( 'Forgejo health check failed — service may still be starting', ); } else { logger.info({ response: health.output }, 'Forgejo is responding'); } emitStatus('SETUP_FORGEJO', { STATUS: 'success', JAIL_NAME: jailName, JAIL_IP: GIT_JAIL_IP, FORGEJO_PORT: FORGEJO_PORT, FORGEJO_URL: `http://${CODE_SERVICE_INTERNAL_DOMAIN}:${FORGEJO_PORT}`, CONFIG: `${FORGEJO_CONF_DIR}/app.ini`, LOG, }); logger.info( { jailName, port: FORGEJO_PORT, url: `http://${CODE_SERVICE_INTERNAL_DOMAIN}:${FORGEJO_PORT}`, }, 'Forgejo provisioned', ); } catch (error) { const message = error instanceof Error ? error.message : String(error); emitStatus('SETUP_FORGEJO', { STATUS: 'failed', ERROR: message, LOG, }); throw error; } }