diff --git a/host/server.mjs b/host/server.mjs new file mode 100644 index 0000000..828af84 --- /dev/null +++ b/host/server.mjs @@ -0,0 +1,205 @@ +#!/usr/bin/env node + +import http from 'node:http'; +import process from 'node:process'; +import { spawn } from 'node:child_process'; + +const host = process.env.GIT_CRED_PROXY_HOST ?? '127.0.0.1'; +const port = Number(process.env.GIT_CRED_PROXY_PORT ?? '18765'); +const token = process.env.GIT_CRED_PROXY_TOKEN ?? ''; + +if (!token) { + process.stderr.write('Missing GIT_CRED_PROXY_TOKEN\n'); + process.exit(1); +} + +const allowedProtocols = new Set( + (process.env.GIT_CRED_PROXY_PROTOCOLS ?? 'https') + .split(',') + .map((value) => value.trim().toLowerCase()) + .filter(Boolean), +); + +const allowedHosts = new Set( + (process.env.GIT_CRED_PROXY_ALLOWED_HOSTS ?? '') + .split(',') + .map((value) => value.trim()) + .filter(Boolean), +); + +const actionMap = new Map([ + ['get', 'fill'], + ['store', 'approve'], + ['erase', 'reject'], + ['fill', 'fill'], + ['approve', 'approve'], + ['reject', 'reject'], +]); + +function parseCredentialBody(body) { + const result = {}; + + for (const line of body.split('\n')) { + if (!line || !line.includes('=')) { + continue; + } + + const index = line.indexOf('='); + const key = line.slice(0, index); + const value = line.slice(index + 1); + result[key] = value; + } + + return result; +} + +function readBody(req) { + return new Promise((resolve, reject) => { + const chunks = []; + let size = 0; + + req.on('data', (chunk) => { + const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + size += buffer.length; + + if (size > 64 * 1024) { + reject(new Error('Request body too large')); + req.destroy(); + return; + } + + chunks.push(buffer); + }); + + req.on('end', () => { + resolve(Buffer.concat(chunks).toString('utf8')); + }); + + req.on('error', reject); + }); +} + +function runGitCredential(action, input) { + return new Promise((resolve, reject) => { + const child = spawn('git', ['credential', action], { + env: { + ...process.env, + GIT_TERMINAL_PROMPT: '0', + }, + stdio: ['pipe', 'pipe', 'pipe'], + }); + + let stdout = ''; + let stderr = ''; + + child.stdout.on('data', (chunk) => { + stdout += chunk.toString(); + }); + + child.stderr.on('data', (chunk) => { + stderr += chunk.toString(); + }); + + child.on('error', reject); + child.on('close', (code) => { + resolve({ code: code ?? 1, stdout, stderr }); + }); + + child.stdin.end(input); + }); +} + +function sendText(res, statusCode, body) { + res.writeHead(statusCode, { + 'Content-Type': 'text/plain; charset=utf-8', + }); + res.end(body); +} + +function isMissingCredentialError(stderr) { + const normalized = stderr.toLowerCase(); + return ( + normalized.includes('terminal prompts disabled') || + normalized.includes('could not read username') || + normalized.includes('could not read password') + ); +} + +const server = http.createServer(async (req, res) => { + try { + const url = new URL(req.url ?? '/', 'http://localhost'); + + if (req.method === 'GET' && url.pathname === '/healthz') { + sendText(res, 200, 'ok\n'); + return; + } + + if (req.method !== 'POST') { + sendText(res, 405, 'Method Not Allowed\n'); + return; + } + + const authHeader = req.headers.authorization ?? ''; + if (authHeader !== `Bearer ${token}`) { + sendText(res, 401, 'Unauthorized\n'); + return; + } + + const action = actionMap.get(url.pathname.replace(/^\//, '')); + if (!action) { + sendText(res, 404, 'Not Found\n'); + return; + } + + const body = await readBody(req); + const attrs = parseCredentialBody(body); + const protocol = attrs.protocol?.toLowerCase(); + const requestHost = attrs.host; + + if (allowedProtocols.size > 0) { + if (!protocol) { + sendText(res, 400, 'Missing protocol\n'); + return; + } + + if (!allowedProtocols.has(protocol)) { + sendText(res, 403, 'Protocol not allowed\n'); + return; + } + } + + if (allowedHosts.size > 0 && requestHost && !allowedHosts.has(requestHost)) { + sendText(res, 403, 'Host not allowed\n'); + return; + } + + const result = await runGitCredential(action, body); + + if (result.code === 0) { + sendText(res, 200, result.stdout); + return; + } + + if (action === 'fill' && isMissingCredentialError(result.stderr)) { + sendText(res, 200, ''); + return; + } + + sendText(res, 502, result.stderr || `git credential ${action} failed\n`); + } catch (error) { + sendText(res, 500, `${error instanceof Error ? error.message : String(error)}\n`); + } +}); + +server.listen(port, host, () => { + process.stdout.write(`Git credential proxy listening on http://${host}:${port}\n`); +}); + +function shutdown() { + server.close(() => { + process.exit(0); + }); +} + +process.on('SIGINT', shutdown); +process.on('SIGTERM', shutdown); diff --git a/host/start.sh b/host/start.sh new file mode 100755 index 0000000..707e3b2 --- /dev/null +++ b/host/start.sh @@ -0,0 +1,85 @@ +#!/bin/sh +set -eu + +script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +state_dir="$script_dir/state" +pid_file="$state_dir/server.pid" +log_file="$state_dir/server.log" +token_file="$state_dir/token" +config_file="$state_dir/config.env" + +host="${GIT_CRED_PROXY_HOST:-127.0.0.1}" +port="${GIT_CRED_PROXY_PORT:-18765}" +protocols="${GIT_CRED_PROXY_PROTOCOLS:-https}" +allowed_hosts="${GIT_CRED_PROXY_ALLOWED_HOSTS:-}" +public_url="${GIT_CRED_PROXY_PUBLIC_URL:-http://host.docker.internal:${port}}" + +mkdir -p "$state_dir" + +if [ -f "$pid_file" ]; then + old_pid=$(cat "$pid_file" 2>/dev/null || true) + if [ -n "${old_pid:-}" ] && kill -0 "$old_pid" 2>/dev/null; then + printf 'Proxy already running: pid=%s\n' "$old_pid" + printf 'Container URL: %s\n' "$public_url" + printf 'Token file: %s\n' "$token_file" + exit 0 + fi + rm -f "$pid_file" +fi + +if [ ! -f "$token_file" ]; then + if ! command -v openssl >/dev/null 2>&1; then + printf 'openssl is required to create the token file\n' >&2 + exit 1 + fi + + umask 077 + openssl rand -hex 32 > "$token_file" +fi + +runtime='' +if [ -n "${GIT_CRED_PROXY_RUNTIME:-}" ]; then + runtime="$GIT_CRED_PROXY_RUNTIME" +elif command -v bun >/dev/null 2>&1; then + runtime='bun' +elif command -v node >/dev/null 2>&1; then + runtime='node' +else + printf 'Either bun or node is required to start the proxy\n' >&2 + exit 1 +fi + +token=$(tr -d '\r\n' < "$token_file") + +cat > "$config_file" <>"$log_file" 2>&1 & + +pid=$! +printf '%s\n' "$pid" > "$pid_file" + +sleep 1 + +if ! kill -0 "$pid" 2>/dev/null; then + printf 'Proxy failed to start. Check %s\n' "$log_file" >&2 + exit 1 +fi + +printf 'Proxy started\n' +printf 'Host listen URL: http://%s:%s\n' "$host" "$port" +printf 'Container URL: %s\n' "$public_url" +printf 'Token file: %s\n' "$token_file" +printf 'Log file: %s\n' "$log_file" +printf 'Next: run /workspaces/host-git-cred-proxy/container/configure-git.sh inside the container\n' diff --git a/host/status.sh b/host/status.sh new file mode 100755 index 0000000..ecae8e8 --- /dev/null +++ b/host/status.sh @@ -0,0 +1,48 @@ +#!/bin/sh +set -eu + +script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +state_dir="$script_dir/state" +pid_file="$state_dir/server.pid" +log_file="$state_dir/server.log" +config_file="$state_dir/config.env" + +host='127.0.0.1' +port='18765' +public_url="http://host.docker.internal:${port}" + +if [ -f "$config_file" ]; then + . "$config_file" + host="${GIT_CRED_PROXY_HOST:-$host}" + port="${GIT_CRED_PROXY_PORT:-$port}" + public_url="${GIT_CRED_PROXY_PUBLIC_URL:-$public_url}" +fi + +printf 'Host listen URL: http://%s:%s\n' "$host" "$port" +printf 'Container URL: %s\n' "$public_url" + +if [ ! -f "$pid_file" ]; then + printf 'Status: stopped\n' + exit 1 +fi + +pid=$(cat "$pid_file" 2>/dev/null || true) + +if [ -z "${pid:-}" ] || ! kill -0 "$pid" 2>/dev/null; then + printf 'Status: stale pid file\n' + exit 1 +fi + +printf 'Status: running (pid=%s)\n' "$pid" + +if command -v curl >/dev/null 2>&1; then + if curl -fsS "http://${host}:${port}/healthz" >/dev/null 2>&1; then + printf 'Health: ok\n' + else + printf 'Health: check failed\n' + fi +fi + +if [ -f "$log_file" ]; then + printf 'Log file: %s\n' "$log_file" +fi diff --git a/host/stop.sh b/host/stop.sh new file mode 100755 index 0000000..9d59590 --- /dev/null +++ b/host/stop.sh @@ -0,0 +1,27 @@ +#!/bin/sh +set -eu + +script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +pid_file="$script_dir/state/server.pid" + +if [ ! -f "$pid_file" ]; then + printf 'Proxy is not running\n' + exit 0 +fi + +pid=$(cat "$pid_file" 2>/dev/null || true) + +if [ -z "${pid:-}" ]; then + rm -f "$pid_file" + printf 'Stale pid file removed\n' + exit 0 +fi + +if kill -0 "$pid" 2>/dev/null; then + kill "$pid" + printf 'Stopped proxy pid=%s\n' "$pid" +else + printf 'Proxy process was not running, removing stale pid file\n' +fi + +rm -f "$pid_file"