Add secure Node image webhook service

This commit is contained in:
2026-08-06 11:21:54 +00:00
parent 24073326b3
commit f04b81c606
22 changed files with 1116 additions and 0 deletions
+123
View File
@@ -0,0 +1,123 @@
import { createServer } from 'node:http';
import { readFile } from 'node:fs/promises';
import { loadConfig } from './config.mjs';
import { verifyAdminSignature, verifyHexHmac } from './auth.mjs';
import { DeploymentError, GitService } from './git-service.mjs';
function json(response, status, value) {
const body = JSON.stringify(value);
response.writeHead(status, {
'content-type': 'application/json; charset=utf-8',
'content-length': Buffer.byteLength(body),
'cache-control': 'no-store',
});
response.end(body);
}
async function readBody(request, limit) {
const chunks = [];
let size = 0;
for await (const chunk of request) {
size += chunk.length;
if (size > limit) {
throw new DeploymentError('Request body too large', 413);
}
chunks.push(chunk);
}
return Buffer.concat(chunks);
}
function parseJson(body) {
try {
return JSON.parse(body.toString('utf8'));
} catch {
throw new DeploymentError('Invalid JSON body', 400);
}
}
export async function createApp(config = loadConfig()) {
const service = new GitService(config);
await service.initialize();
const server = createServer(async (request, response) => {
const url = new URL(request.url, 'http://image-hook');
try {
if (request.method === 'GET' && url.pathname === '/healthz') {
return json(response, 200, { ok: true });
}
if (request.method === 'GET' && url.pathname === '/v1/status') {
return json(response, 200, service.publicStatus());
}
if (request.method === 'GET' && url.pathname === '/v1/inventory') {
const inventory = JSON.parse(await readFile(`${config.repositoryPath}/hook/inventory.v2.json`, 'utf8'));
return json(response, 200, inventory);
}
if (request.method === 'POST' && url.pathname === '/v1/hooks/gitea') {
if (!request.headers['content-type']?.toLowerCase().startsWith('application/json')) {
throw new DeploymentError('Content-Type must be application/json', 415);
}
const body = await readBody(request, config.maxBodyBytes);
if (!verifyHexHmac(config.webhookSecret, body, request.headers['x-gitea-signature'])) {
return json(response, 401, { ok: false, reason: 'invalid signature' });
}
if (request.headers['x-gitea-event'] !== 'push') {
return json(response, 202, { ok: true, ignored: true, reason: 'unsupported event' });
}
const payload = parseJson(body);
if (payload.repository?.full_name !== config.repositoryFullName) {
return json(response, 403, { ok: false, reason: 'unexpected repository' });
}
if (typeof payload.ref !== 'string' || !payload.ref.startsWith('refs/heads/')) {
return json(response, 202, { ok: true, ignored: true, reason: 'non-branch ref' });
}
if (typeof payload.after !== 'string' || !/^[0-9a-f]{40,64}$/i.test(payload.after)) {
throw new DeploymentError('Invalid target commit', 400);
}
const result = await service.deployWebhook({
deliveryId: request.headers['x-gitea-delivery'],
branch: payload.ref.slice('refs/heads/'.length),
after: payload.after,
});
return json(response, 200, { ok: true, ...result });
}
if (request.method === 'POST' && url.pathname === '/v1/admin/deploy') {
if (!request.headers['content-type']?.toLowerCase().startsWith('application/json')) {
throw new DeploymentError('Content-Type must be application/json', 415);
}
const body = await readBody(request, config.maxBodyBytes);
const timestamp = request.headers['x-image-timestamp'];
const requestId = request.headers['x-image-request-id'];
if (!verifyAdminSignature({
secret: config.adminSecret,
timestamp,
requestId,
body,
supplied: request.headers['x-image-signature'],
})) {
return json(response, 401, { ok: false, reason: 'invalid admin signature' });
}
const payload = parseJson(body);
const result = await service.deployAdmin({ requestId, branch: payload.branch, expectedCommit: payload.commit });
return json(response, 200, { ok: true, ...result });
}
return json(response, 404, { ok: false, reason: 'not found' });
} catch (error) {
await service.recordError(error).catch(() => undefined);
console.error(JSON.stringify({ level: 'error', message: error.message, at: new Date().toISOString() }));
return json(response, error instanceof DeploymentError ? error.status : 500, {
ok: false,
reason: error instanceof DeploymentError ? error.message : 'internal error',
});
}
});
return { server, service };
}
if (process.argv[1] === new URL(import.meta.url).pathname) {
const config = loadConfig();
const { server } = await createApp(config);
server.listen(config.port, '0.0.0.0', () => {
console.log(JSON.stringify({ level: 'info', message: 'image hook listening', port: config.port }));
});
}