/** * SSH Git Server * Handles SSH connections for git push/pull operations */ import { logger } from "@/lib/logger"; import { spawn } from "child_process"; import { generateKeyPairSync } from "crypto"; import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs"; import { dirname, join } from "path"; import ssh2 from "ssh2"; const { Server } = ssh2; const GIT_PROCESS_TIMEOUT_MS = parseInt(process.env.GIT_PROCESS_TIMEOUT_SECS || "3600", 10) * 1000; /** * Per-IP SSH authentication rate limiter. * Tracks failed attempts and blocks IPs that exceed the threshold. */ const SSH_RATE_LIMIT: { maxFailures: number; windowMs: number; blockMs: number; maxEntries: number; map: Map< string, { failures: number; firstFailure: number; blockedUntil: number } >; } = { maxFailures: 5, windowMs: 60_000, // 1-minute window blockMs: 5 * 60_000, // 5-minute block after threshold maxEntries: 10_000, // Max tracked IPs to prevent memory leak map: new Map(), }; function checkSshRateLimit(ip: string): { allowed: boolean; retryAfterMs?: number; } { const now = Date.now(); const entry = SSH_RATE_LIMIT.map.get(ip); if (entry) { // Currently blocked? if (entry.blockedUntil > now) { return { allowed: false, retryAfterMs: entry.blockedUntil - now }; } // Window expired — reset if (now - entry.firstFailure > SSH_RATE_LIMIT.windowMs) { SSH_RATE_LIMIT.map.delete(ip); } } return { allowed: true }; } function recordSshFailure(ip: string): void { const now = Date.now(); const entry = SSH_RATE_LIMIT.map.get(ip); if (!entry || now - entry.firstFailure > SSH_RATE_LIMIT.windowMs) { // Evict oldest entries if map is full if (SSH_RATE_LIMIT.map.size >= SSH_RATE_LIMIT.maxEntries) { let oldestKey = ""; let oldestTime = Infinity; for (const [key, val] of SSH_RATE_LIMIT.map) { if (val.firstFailure < oldestTime) { oldestTime = val.firstFailure; oldestKey = key; } } if (oldestKey) SSH_RATE_LIMIT.map.delete(oldestKey); } SSH_RATE_LIMIT.map.set(ip, { failures: 1, firstFailure: now, blockedUntil: 0, }); return; } entry.failures += 1; if (entry.failures >= SSH_RATE_LIMIT.maxFailures) { entry.blockedUntil = now + SSH_RATE_LIMIT.blockMs; logger.warn( { ip, failures: entry.failures }, "SSH IP blocked due to repeated auth failures", ); } } // Periodic cleanup of stale entries (every 5 minutes) setInterval(() => { const now = Date.now(); for (const [ip, entry] of SSH_RATE_LIMIT.map) { if ( now - entry.firstFailure > SSH_RATE_LIMIT.windowMs && entry.blockedUntil < now ) { SSH_RATE_LIMIT.map.delete(ip); } } }, 5 * 60_000).unref(); export interface SSHServerConfig { port: number; hostKeyPath: string; reposPath: string; authenticateUser: ( username: string, key: Buffer, ) => Promise<{ valid: boolean; userId?: string; canRead?: boolean; canWrite?: boolean; }>; authorizeRepo: ( userId: string, repoPath: string, operation: "read" | "write", ) => Promise; onPush?: (userId: string, repoPath: string, refs: string[]) => Promise; } export function createSSHServer( config: SSHServerConfig, ): InstanceType { const { port, hostKeyPath, reposPath, authenticateUser, authorizeRepo, onPush, } = config; // Generate or load host key const hostKey = loadOrGenerateHostKey(hostKeyPath); const server = new Server( { hostKeys: [hostKey], }, (client, info) => { let userId: string | undefined; let username: string | undefined; let canWrite = false; const clientIp = info?.ip || info?.header?.identRaw || "unknown"; client.on("authentication", async (ctx) => { // Rate-limit check const rl = checkSshRateLimit(clientIp); if (!rl.allowed) { logger.warn({ ip: clientIp }, "SSH auth rejected — IP rate-limited"); ctx.reject(["publickey"]); return; } if (ctx.method === "publickey") { try { const result = await authenticateUser(ctx.username, ctx.key.data); if (result.valid) { userId = result.userId; username = ctx.username; canWrite = result.canWrite ?? false; ctx.accept(); return; } } catch (error) { logger.error({ err: error }, "SSH auth error"); } } recordSshFailure(clientIp); ctx.reject(["publickey"]); }); client.on("ready", () => { logger.info({ username }, "SSH client connected"); client.on("session", (accept, reject) => { const session = accept(); session.on("exec", async (accept, reject, info) => { const command = info.command; logger.debug({ command }, "SSH exec"); // Parse git command const gitCommand = parseGitCommand(command); if (!gitCommand) { reject(); return; } const { operation, repoPath: requestedPath } = gitCommand; // Normalize and validate repo path const normalizedPath = normalizeRepoPath(requestedPath); const fullRepoPath = join(reposPath, normalizedPath); // Check authorization const isWrite = operation === "git-receive-pack"; const authorized = userId ? await authorizeRepo( userId, normalizedPath, isWrite ? "write" : "read", ) : false; if (!authorized) { const channel = accept(); channel.stderr.write("Permission denied\n"); channel.exit(1); channel.close(); return; } if (isWrite && !canWrite) { const channel = accept(); channel.stderr.write("Write access denied\n"); channel.exit(1); channel.close(); return; } // Ensure repo exists if (!existsSync(fullRepoPath)) { const channel = accept(); channel.stderr.write("Repository not found\n"); channel.exit(1); channel.close(); return; } // Execute git command const channel = accept(); const gitProcess = spawn(operation, [fullRepoPath], { stdio: ["pipe", "pipe", "pipe"], env: { ...process.env, REMOTE_USER: userId || "anonymous", GL_ID: userId || "anonymous", // GitLab compatibility }, }); // Kill git process if it runs too long const gitTimer = setTimeout(() => { logger.warn({ fullRepoPath, operation }, "SSH git process timed out, killing"); gitProcess.kill("SIGKILL"); }, GIT_PROCESS_TIMEOUT_MS); gitProcess.on("close", () => clearTimeout(gitTimer)); gitProcess.on("error", () => clearTimeout(gitTimer)); // Track refs for push hook let receivedRefs: string[] = []; channel.pipe(gitProcess.stdin); gitProcess.stdout.pipe(channel); gitProcess.stderr.pipe(channel.stderr); // Capture refs on push if (isWrite) { gitProcess.stdin.on("data", (data: Buffer) => { const lines = data.toString().split("\n"); for (const line of lines) { // Format: oldsha newsha refname const match = line.match( /^([a-f0-9]+)\s+([a-f0-9]+)\s+(.+)$/, ); if (match) { receivedRefs.push(match[3]); } } }); } gitProcess.on("close", async (code) => { // Call push hook on successful push if ( isWrite && code === 0 && userId && receivedRefs.length > 0 && onPush ) { try { await onPush(userId, normalizedPath, receivedRefs); } catch (error) { logger.error({ err: error }, "Push hook error"); } } channel.exit(code || 0); channel.close(); }); gitProcess.on("error", (error) => { logger.error({ err: error }, "Git process error"); channel.stderr.write(`Error: ${error.message}\n`); channel.exit(1); channel.close(); }); }); session.on("shell", (accept, reject) => { // Don't allow shell access reject(); }); }); }); client.on("error", (error) => { logger.error({ err: error }, "SSH client error"); }); client.on("end", () => { logger.info({ username }, "SSH client disconnected"); }); }, ); return server; } /** * Parse a git SSH command */ function parseGitCommand( command: string, ): { operation: string; repoPath: string } | null { // Format: git-upload-pack '/path/to/repo.git' or git-receive-pack '/path/to/repo.git' const match = command.match( /^(git-upload-pack|git-receive-pack)\s+'?([^']+)'?$/, ); if (!match) return null; return { operation: match[1], repoPath: match[2], }; } /** * Normalize repository path (remove leading slash, ensure .git suffix) */ function normalizeRepoPath(path: string): string { let normalized = path.replace(/^\/+/, ""); if (!normalized.endsWith(".git")) { normalized += ".git"; } return normalized; } /** * Load or generate SSH host key */ function loadOrGenerateHostKey(keyPath: string): string { if (existsSync(keyPath)) { return readFileSync(keyPath, "utf8"); } // Ensure directory exists const dir = dirname(keyPath); if (!existsSync(dir)) { mkdirSync(dir, { recursive: true }); } // Generate new key pair const { privateKey } = generateKeyPairSync("rsa", { modulusLength: 4096, privateKeyEncoding: { type: "pkcs1", format: "pem", }, publicKeyEncoding: { type: "pkcs1", format: "pem", }, }); writeFileSync(keyPath, privateKey, { mode: 0o600 }); logger.info({ keyPath }, "Generated new SSH host key"); return privateKey; } /** * Start the SSH server */ export async function startSSHServer(config: SSHServerConfig): Promise { const server = createSSHServer(config); return new Promise((resolve, reject) => { server.listen(config.port, "0.0.0.0", () => { logger.info({ port: config.port }, "SSH Git server listening"); resolve(); }); server.on("error", (error: Error) => { logger.error({ err: error }, "SSH server error"); reject(error); }); }); } /** * Validate SSH public key format */ export function validateSSHPublicKey(key: string): boolean { const keyTypes = [ "ssh-rsa", "ssh-ed25519", "ecdsa-sha2-nistp256", "ecdsa-sha2-nistp384", "ecdsa-sha2-nistp521", ]; const parts = key.trim().split(" "); if (parts.length < 2) return false; if (!keyTypes.includes(parts[0])) return false; // Check if base64 part is valid try { Buffer.from(parts[1], "base64"); return true; } catch { return false; } } /** * Parse SSH public key to get type and fingerprint */ export async function parseSSHPublicKey(key: string): Promise<{ type: string; fingerprint: string; comment?: string; } | null> { const parts = key.trim().split(" "); if (parts.length < 2) return null; const type = parts[0]; const data = parts[1]; const comment = parts.slice(2).join(" ") || undefined; // Generate fingerprint const nodeCrypto = await import("crypto"); const buffer = Buffer.from(data, "base64"); const hash = nodeCrypto.createHash("sha256").update(buffer).digest("base64"); const fingerprint = `SHA256:${hash.replace(/=+$/, "")}`; return { type, fingerprint, comment }; }