mirror of
https://github.com/LamGC/quickly-conf-sshd.git
synced 2026-10-13 07:43:57 +00:00
feat: pin client GPG trust and publish signed Git/Gist key releases
This commit is contained in:
@@ -0,0 +1,92 @@
|
||||
// Optional JSON webhook for a separately configured email delivery service.
|
||||
// This sends deployment configuration, not observed server/fleet state.
|
||||
import {readFile} from 'node:fs/promises';
|
||||
import {resolve} from 'node:path';
|
||||
import {fileURLToPath} from 'node:url';
|
||||
|
||||
export function notificationEnabled(value) {
|
||||
if (value === undefined || value === '' || value === 'false') return false;
|
||||
if (value === 'true') return true;
|
||||
throw Error('ENABLE_MAIL_NOTIFICATION must be unset, false, or exactly true.');
|
||||
}
|
||||
|
||||
function httpsUrl(value, name) {
|
||||
let url;
|
||||
try { url = new URL(value); } catch { throw Error(`${name} must be an HTTPS URL.`); }
|
||||
if (url.protocol !== 'https:' || url.username || url.password || url.hash) throw Error(`${name} must be HTTPS without URL credentials or fragments.`);
|
||||
return url.href;
|
||||
}
|
||||
|
||||
export function deploymentPayload(config, env) {
|
||||
if (!config || typeof config !== 'object' || Array.isArray(config)) throw Error('Deployment configuration must be an object.');
|
||||
const status = env.DEPLOYMENT_STATUS;
|
||||
if (!['success','failure','cancelled'].includes(status)) throw Error('DEPLOYMENT_STATUS must be success, failure, or cancelled.');
|
||||
const phase = env.DEPLOYMENT_PHASE;
|
||||
if (!['deployment','remote-verification','complete'].includes(phase)) throw Error('DEPLOYMENT_PHASE must identify the attempted deployment phase.');
|
||||
if (!/^[0-9a-f]{40}$/.test(env.GITHUB_SHA || '')) throw Error('GITHUB_SHA must be an exact 40-hex commit SHA.');
|
||||
if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(env.GITHUB_REPOSITORY || '') || !/^\d+$/.test(env.GITHUB_RUN_ID || '')) throw Error('A valid GitHub repository and run ID are required.');
|
||||
const mode = config.keySource?.type ?? 'git';
|
||||
if (!['git','gist'].includes(mode)) throw Error('Unsupported deployment key source mode.');
|
||||
const listId = config.signedRelease?.listId ?? null;
|
||||
if (listId !== null && (typeof listId !== 'string' || !/^[A-Za-z0-9._-]{1,64}$/.test(listId))) throw Error('Invalid deployment list identity.');
|
||||
const fallbackSources = config.fallbackSources ?? [];
|
||||
if (!Array.isArray(fallbackSources) || fallbackSources.length > 5) throw Error('Invalid deployment fallback sources.');
|
||||
const to = env.MAIL_NOTIFICATION_TO || null;
|
||||
if (to !== null && (to.length > 254 || !/^[^\s@,<>]+@[^\s@,<>]+\.[^\s@,<>]+$/.test(to))) throw Error('MAIL_NOTIFICATION_TO must be one email address.');
|
||||
return {
|
||||
schema:1,
|
||||
event:'worker-deployment',
|
||||
status,
|
||||
phase,
|
||||
repository:env.GITHUB_REPOSITORY,
|
||||
revision:env.GITHUB_SHA,
|
||||
workflowRunUrl:`https://github.com/${env.GITHUB_REPOSITORY}/actions/runs/${env.GITHUB_RUN_ID}`,
|
||||
to,
|
||||
configuration:{
|
||||
baseUrl:httpsUrl(config.baseUrl, 'Deployment baseUrl'),
|
||||
mode,
|
||||
listId,
|
||||
signedRelease:Boolean(config.signedRelease),
|
||||
fallbackSources:fallbackSources.map(value => httpsUrl(value,'Deployment fallback source')),
|
||||
},
|
||||
configurationScope:'deployment configuration only; not measured server state',
|
||||
};
|
||||
}
|
||||
|
||||
export async function notifyDeployment({env = process.env, config, fetchImpl = globalThis.fetch} = {}) {
|
||||
if (!notificationEnabled(env.ENABLE_MAIL_NOTIFICATION)) return {sent:false,reason:'disabled'};
|
||||
if (env.GITHUB_ACTIONS !== 'true' || env.CI !== 'true') throw Error('Deployment notification sending is allowed only in GitHub Actions CI.');
|
||||
if (!env.MAIL_NOTIFICATION_WEBHOOK) throw Error('MAIL_NOTIFICATION_WEBHOOK is required when notifications are enabled.');
|
||||
const endpoint = httpsUrl(env.MAIL_NOTIFICATION_WEBHOOK,'Notification webhook');
|
||||
const payload = deploymentPayload(config,env);
|
||||
let response;
|
||||
try {
|
||||
response = await fetchImpl(endpoint, {
|
||||
method:'POST',
|
||||
headers:{'content-type':'application/json'},
|
||||
body:JSON.stringify(payload),
|
||||
redirect:'error',
|
||||
signal:AbortSignal.timeout(10_000),
|
||||
});
|
||||
} catch {
|
||||
// Provider errors may contain secret URL tokens. Do not print them or retry
|
||||
// automatically: a timed-out request may already have sent the email.
|
||||
throw Error('Deployment notification request failed; check the webhook service.');
|
||||
}
|
||||
if (!response.ok) throw Error(`Deployment notification was rejected (HTTP ${response.status}).`);
|
||||
// Do not log response bodies, the recipient, or the secret webhook URL.
|
||||
return {sent:true};
|
||||
}
|
||||
|
||||
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
||||
try {
|
||||
if (process.argv.length !== 2) throw Error('Usage: node scripts/notify-deployment.mjs');
|
||||
let config;
|
||||
if (notificationEnabled(process.env.ENABLE_MAIL_NOTIFICATION)) {
|
||||
try { config = JSON.parse(await readFile(new URL('../deployment.json',import.meta.url),'utf8')); }
|
||||
catch { throw Error('Cannot read deployment notification configuration.'); }
|
||||
}
|
||||
const result = await notifyDeployment({config});
|
||||
console.log(result.sent ? 'Deployment notification webhook accepted the request.' : 'Deployment notification disabled.');
|
||||
} catch (error) { console.error(error.message); process.exitCode = 1; }
|
||||
}
|
||||
Reference in New Issue
Block a user