commit b18be621d34539c6a3458fa1194f12cbc59a2767 Author: 严浩 Date: Sun Mar 8 17:15:48 2026 +0800 Add credential proxy server runtime Create the host-side proxy runtime so containers can forward HTTPS Git credential requests to the host helper. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus diff --git a/helper.mjs b/helper.mjs new file mode 100644 index 0000000..6accd51 --- /dev/null +++ b/helper.mjs @@ -0,0 +1,98 @@ +#!/usr/bin/env node + +import fs from 'node:fs/promises'; +import http from 'node:http'; +import https from 'node:https'; +import path from 'node:path'; +import process from 'node:process'; +import { fileURLToPath } from 'node:url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const operation = process.argv[2] ?? ''; +const supportedOperations = new Set(['get', 'store', 'erase']); + +if (!supportedOperations.has(operation)) { + process.exit(0); +} + +const proxyUrl = process.env.GIT_CRED_PROXY_URL ?? 'http://host.docker.internal:18765'; +const tokenFile = + process.env.GIT_CRED_PROXY_TOKEN_FILE ?? path.join(__dirname, 'state', 'token'); + +function readStdin() { + return new Promise((resolve, reject) => { + const chunks = []; + + process.stdin.on('data', (chunk) => { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + }); + + process.stdin.on('end', () => { + resolve(Buffer.concat(chunks).toString('utf8')); + }); + + process.stdin.on('error', reject); + }); +} + +function request(url, body, token) { + const client = url.protocol === 'https:' ? https : http; + + return new Promise((resolve, reject) => { + const req = client.request( + url, + { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'text/plain; charset=utf-8', + 'Content-Length': Buffer.byteLength(body), + }, + }, + (res) => { + const chunks = []; + + res.on('data', (chunk) => { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + }); + + res.on('end', () => { + resolve({ + statusCode: res.statusCode ?? 0, + body: Buffer.concat(chunks).toString('utf8'), + }); + }); + }, + ); + + req.on('error', reject); + req.end(body); + }); +} + +async function main() { + const token = (process.env.GIT_CRED_PROXY_TOKEN ?? (await fs.readFile(tokenFile, 'utf8'))).trim(); + const body = await readStdin(); + const url = new URL(`/${operation}`, proxyUrl); + const response = await request(url, body, token); + + if (response.statusCode === 200) { + process.stdout.write(response.body); + return; + } + + if (response.body) { + process.stderr.write(response.body.trimEnd() + '\n'); + } else { + process.stderr.write(`Proxy request failed with status ${response.statusCode}\n`); + } + + process.exit(1); +} + +main().catch((error) => { + process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); + process.exit(1); +}); diff --git a/server.mjs b/server.mjs new file mode 100644 index 0000000..828af84 --- /dev/null +++ b/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);