const http = require('http'); const { spawn } = require('child_process'); const { timingSafeEqual } = require('crypto'); const fs = require('fs'); const path = require('path'); const PORT = Number(process.env.PORT || 9050); const ENV_FILE = path.join(__dirname, '.env'); function loadEnvFile() { try { const raw = fs.readFileSync(ENV_FILE, 'utf8'); for (const line of raw.split('\n')) { const trimmed = line.trim(); if (!trimmed || trimmed.startsWith('#')) continue; const i = trimmed.indexOf('='); if (i <= 0) continue; const key = trimmed.slice(0, i).trim(); const val = trimmed.slice(i + 1).trim(); process.env[key] = val; } } catch (_) { /* optional */ } } loadEnvFile(); function token() { return String(process.env.DEPLOY_TOKEN || '').trim(); } function allowedSlugs() { loadEnvFile(); return new Set( String(process.env.ALLOWED_SLUGS || '') .split(',') .map((s) => s.trim()) .filter(Boolean), ); } function safeEqual(a, b) { const x = Buffer.from(a); const y = Buffer.from(b); if (x.length !== y.length) return false; return timingSafeEqual(x, y); } function readJson(req) { return new Promise((resolve, reject) => { const chunks = []; req.on('data', (c) => chunks.push(c)); req.on('end', () => { try { const raw = Buffer.concat(chunks).toString('utf8') || '{}'; resolve(JSON.parse(raw)); } catch (e) { reject(e); } }); req.on('error', reject); }); } function send(res, status, body) { const data = JSON.stringify(body); res.writeHead(status, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data), }); res.end(data); } function assertAuth(req, res) { const provided = String(req.headers['x-deploy-token'] || '').trim(); const expected = token(); if (!expected || !provided || !safeEqual(provided, expected)) { send(res, 401, { error: 'unauthorized' }); return false; } return true; } function runScript(script, args) { return new Promise((resolve, reject) => { const child = spawn(script, args, { stdio: ['ignore', 'pipe', 'pipe'], }); let stdout = ''; let stderr = ''; child.stdout.on('data', (d) => { stdout += d.toString(); }); child.stderr.on('data', (d) => { stderr += d.toString(); }); child.on('error', reject); child.on('close', (code) => { if (code === 0) resolve({ stdout, stderr }); else reject(new Error(stderr || stdout || `exit ${code}`)); }); }); } function readDeployStatus(slug) { const file = `/var/log/websites/deploy-${slug}.status.json`; try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return null; } } const server = http.createServer(async (req, res) => { if (req.method === 'GET' && req.url === '/health') { return send(res, 200, { ok: true, allowed: [...allowedSlugs()] }); } if (req.method === 'GET' && req.url?.startsWith('/deploy-status')) { if (!assertAuth(req, res)) return; const url = new URL(req.url, 'http://127.0.0.1'); const slug = String(url.searchParams.get('slug') || '').trim(); if (!slug || !allowedSlugs().has(slug)) { return send(res, 400, { error: 'unknown or disallowed slug' }); } const status = readDeployStatus(slug); if (!status) { return send(res, 404, { error: 'no deploy status yet', slug }); } return send(res, 200, status); } if (req.method === 'POST' && req.url === '/deploy') { if (!assertAuth(req, res)) return; let body; try { body = await readJson(req); } catch { return send(res, 400, { error: 'invalid json' }); } const slug = String(body.slug || '').trim(); const wait = body.wait === true || body.wait === 'true'; if (!slug || !allowedSlugs().has(slug)) { return send(res, 400, { error: 'unknown or disallowed slug' }); } if (!wait) { const child = spawn('/opt/websites-agent/deploy.sh', [slug], { detached: true, stdio: 'ignore', }); child.unref(); return send(res, 202, { status: 'accepted', slug }); } try { const result = await runScript('/opt/websites-agent/deploy.sh', [slug]); const status = readDeployStatus(slug) || { slug, status: 'success', detail: 'Deploy finished', at: new Date().toISOString(), }; return send(res, 200, { status: 'success', slug, detail: status.detail || null, at: status.at || null, log: (result.stdout || '').slice(-2000), }); } catch (err) { const status = readDeployStatus(slug); const detail = (status && status.detail) || (err instanceof Error ? err.message.slice(0, 2000) : String(err)); return send(res, 500, { status: 'failed', slug, error: 'deploy failed', detail, }); } } if (req.method === 'POST' && req.url === '/provision') { if (!assertAuth(req, res)) return; let body; try { body = await readJson(req); } catch { return send(res, 400, { error: 'invalid json' }); } const slug = String(body.slug || '').trim(); const host = String(body.host || '').trim().toLowerCase(); const gitRepoUrl = String(body.gitRepoUrl || body.git_repo_url || '').trim(); if (!slug || !host || !gitRepoUrl) { return send(res, 400, { error: 'slug, host, and gitRepoUrl are required' }); } if (!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(slug)) { return send(res, 400, { error: 'invalid slug' }); } try { const result = await runScript('/opt/websites-agent/provision.sh', [ slug, host, gitRepoUrl, ]); // Reload allowlist from .env (provision.sh appends slug) loadEnvFile(); return send(res, 200, { status: 'provisioned', slug, host, log: (result.stdout || '').slice(-2000), }); } catch (err) { return send(res, 500, { error: 'provision failed', detail: err instanceof Error ? err.message.slice(0, 2000) : String(err), }); } } if (req.method === 'POST' && req.url === '/ssl') { if (!assertAuth(req, res)) return; let body; try { body = await readJson(req); } catch { return send(res, 400, { error: 'invalid json' }); } const host = String(body.host || '').trim().toLowerCase(); const slug = String(body.slug || '').trim(); if (!host || !/^[a-z0-9.-]+$/.test(host)) { return send(res, 400, { error: 'valid host is required' }); } if (slug && !allowedSlugs().has(slug)) { return send(res, 400, { error: 'unknown or disallowed slug' }); } try { const result = await runScript('/opt/websites-agent/ssl.sh', [host]); return send(res, 200, { status: 'issued', host, log: (result.stdout || '').slice(-2000), }); } catch (err) { return send(res, 500, { error: 'ssl issue failed', detail: err instanceof Error ? err.message.slice(0, 2000) : String(err), }); } } return send(res, 404, { error: 'not found' }); }); server.listen(PORT, '0.0.0.0', () => { console.log('websites-deploy-agent listening on :' + PORT); });