420 lines
12 KiB
TypeScript
420 lines
12 KiB
TypeScript
/**
|
|
* 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<void> {
|
|
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;
|
|
}
|
|
}
|