feat: independently publish server SSH keys with safe sync and Worker homepage

This commit is contained in:
2026-10-12 23:11:42 +08:00
parent f37cb51e06
commit 5798bf8900
29 changed files with 4943 additions and 525 deletions
+3 -104
View File
@@ -1,105 +1,4 @@
// 改成你自己的 Github 用户名,注意是登录 Github 的那个用户名.
const githubUserName = "LamGC";
// 改成你 Fork 后的仓库名,记得要开启 Github Pages 功能.
const githubInstSshProjectName = "quickly-conf-sshd";
// 如果可以,建议在此设置备用的 SSH 公钥, 以防 Github 无法使用.
const backupSshKeys = ``;
// Worker 的访问地址, 如果不填的话默认为请求的地址, 填了就会用这里的地址(要去 Worker 的触发器那绑定, 否则无效).
const defaultBaseUrl = "";
// Cron 表达式, 默认 1 天执行一次更新.
const cronExpression = "0 0 * * *";
import release from '../generated/release.js';
import { createHandler } from './router.js';
// 下面的东西一般不用改.
const baseRepoPageUrl = `https://${githubUserName.toLowerCase()}.github.io/${githubInstSshProjectName}/`;
const installScriptUrl = `${baseRepoPageUrl}/conf-sshd.sh`;
// 如果出现 Github 无法使用的情况, 可以修改 sshKeyUrl 来变更位置.
// 也可以添加额外的 SSH 公钥地址(比如 KeyBase).
const sshKeyUrls = [
`https://github.com/${githubUserName}.keys`
];
// 下面是脚本的占位符.
const SCRIPT_PH_SSH_KEY_URL = "{{ SSH_KEY_URL }}"
const SCRIPT_PH_SCRIPT_URL = "{{ SCRIPT_URL }}"
const SCRIPT_PH_DEFAULT_CRON = "{{ DEFAULT_CRON }}"
async function sendScriptContent(baseUrl) {
let scriptResp = await fetch(new Request(installScriptUrl));
if (scriptResp.ok) {
let scriptContent = await scriptResp.text();
scriptContent = scriptContent.replace(SCRIPT_PH_SSH_KEY_URL, `${baseUrl}/ssh.keys`)
.replace(SCRIPT_PH_SCRIPT_URL, `${baseUrl}/script.sh`)
.replace(SCRIPT_PH_DEFAULT_CRON, cronExpression)
return new Response(scriptContent, {
headers: {
"content-type": "text/plain; charset=utf-8"
}
});
} else {
return new Response("Failed to get install script.", {
status: 500,
statusText: "Failed to get install script",
headers: {
"content-type": "text/plain; charset=utf-8"
}
});
}
}
export default {
async fetch(request, env) {
const { pathname, host } = new URL(request.url);
const baseUrl = defaultBaseUrl || `https://${host}`;
if (pathname === "/ssh.keys") {
for (let url of sshKeyUrls) {
let response = await fetch(new Request(url));
if (response.ok) {
let keys = await response.text()
return new Response(keys, {
headers: {
"content-type": "text/plain; charset=utf-8"
}
});
}
}
if (backupSshKeys.length > 0) {
return new Response(backupSshKeys, {
headers: {
"content-type": "text/plain; charset=utf-8"
}
});
} else {
return new Response("Failed to get keys.", {
status: 500,
statusText: "Failed to get keys",
headers: {
"content-type": "text/plain; charset=utf-8"
}
});
}
} else if (pathname === "/") {
const userAgent = request.headers.get("User-Agent");
if (userAgent != null && userAgent.match(/curl|libcurl/) !== null) {
return await sendScriptContent(baseUrl);
} else {
return new Response("", {
status: 301,
statusText: "Redirect",
headers: {
"Location": baseRepoPageUrl
}
});
}
} else if (pathname === "/script.sh") {
return await sendScriptContent(baseUrl);
} else {
return new Response("Not found.", {
status: 404,
statusText: "Not Found"
})
}
}
}
export default { fetch: createHandler(release) };
+223
View File
@@ -0,0 +1,223 @@
// Runtime fallback is deliberately stateless: a request never reuses earlier keys.
export const KEY_SOURCE_LIMITS = Object.freeze({sources: 5, timeoutMs: 3000, maxBytes: 1024 * 1024, maxKeys: 1000});
const encoder = new TextEncoder();
const decoder = new TextDecoder('utf-8', {fatal: true});
const keyTypes = new Set(['ssh-ed25519', 'ssh-rsa', 'ecdsa-sha2-nistp256', 'ecdsa-sha2-nistp384', 'ecdsa-sha2-nistp521', '[email protected]', '[email protected]']);
const curves = {nistp256: {name: 'P-256', size: 32}, nistp384: {name: 'P-384', size: 48}, nistp521: {name: 'P-521', size: 66}};
class SourceError extends Error {
constructor(code, status) { super(code); this.code = code; if (status !== undefined) this.status = status; }
}
export class FallbackUnavailableError extends Error {
constructor(diagnostics = []) {
super('No configured SSH key source returned a valid nonempty key set.');
this.name = 'FallbackUnavailableError';
this.code = 'KEY_SOURCES_UNAVAILABLE';
// Keep diagnostics safe to log or return: no response content, URL secrets, or exception text.
this.diagnostics = diagnostics.slice(0, KEY_SOURCE_LIMITS.sources).map(({index, code, status}) => ({index, code, ...(status === undefined ? {} : {status})}));
}
}
export function validateFallbackSources(sources = []) {
if (!Array.isArray(sources) || sources.length > KEY_SOURCE_LIMITS.sources) throw new Error('fallbackSources must contain at most five HTTPS URLs.');
const seen = new Set();
return sources.map(value => {
if (typeof value !== 'string' || value.length > 2048 || /[\s\u0000-\u001f\u007f-\u009f]/u.test(value)) throw new Error('Each fallback source must be a public-host HTTPS URL without credentials, queries, or fragments.');
let url;
try { url = new URL(value); } catch { throw new Error('Invalid fallback source URL.'); }
if (url.protocol !== 'https:' || !url.hostname || url.username || url.password || url.search || value.includes('?') || url.hash || value.includes('#') || !value.startsWith('https://')) throw new Error('Each fallback source must be a public-host HTTPS URL without credentials, queries, or fragments.');
// Configuration is trusted, but exclude local names and IP literals before any network call.
// A hostname allowlist is not a DNS-resolution guarantee; source operators remain trusted.
if (url.hostname.length > 253 || !/^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z](?:[a-z0-9-]{0,61}[a-z0-9])?$/i.test(url.hostname) || /\.(?:localhost|local|localdomain|internal|intranet|lan|home|corp|onion)$/i.test(url.hostname)) throw new Error('Fallback sources must use public HTTPS hostnames, not local names or IP addresses.');
if (seen.has(url.href)) throw new Error('Duplicate fallback source URL.');
seen.add(url.href);
return url.href;
});
}
function encodeBase64(bytes) {
let binary = '';
for (const byte of bytes) binary += String.fromCharCode(byte);
return btoa(binary);
}
function decodeBase64(encoded) {
if (!/^[A-Za-z0-9+/]+={0,2}$/.test(encoded) || encoded.length % 4 === 1) throw new Error('Invalid public key encoding.');
let decoded;
try { decoded = Uint8Array.from(atob(encoded), c => c.charCodeAt(0)); } catch { throw new Error('Invalid public key encoding.'); }
if (encodeBase64(decoded).replace(/=+$/, '') !== encoded.replace(/=+$/, '')) throw new Error('Invalid public key encoding.');
return decoded;
}
async function sha256(bytes) { return new Uint8Array(await crypto.subtle.digest('SHA-256', bytes)); }
function hex(bytes) { return Array.from(bytes, byte => byte.toString(16).padStart(2, '0')).join(''); }
class WireReader {
constructor(bytes) { this.bytes = bytes; this.offset = 0; }
string() {
if (this.offset + 4 > this.bytes.length) throw new Error('Truncated public key data.');
const length = new DataView(this.bytes.buffer, this.bytes.byteOffset + this.offset, 4).getUint32(0);
this.offset += 4;
if (length > this.bytes.length - this.offset) throw new Error('Truncated public key field.');
const value = this.bytes.subarray(this.offset, this.offset + length);
this.offset += length;
return value;
}
text() { return decoder.decode(this.string()); }
end() { if (this.offset !== this.bytes.length) throw new Error('Unexpected trailing public key data.'); }
}
function positiveMpint(bytes) {
if (!bytes.length || (bytes[0] & 128) || (bytes[0] === 0 && (bytes.length === 1 || !(bytes[1] & 128)))) throw new Error('RSA fields must be positive, minimally encoded mpints.');
return bytes[0] === 0 ? bytes.subarray(1) : bytes;
}
async function validatePoint(reader, curveName) {
if (reader.text() !== curveName) throw new Error('ECDSA curve does not match key type.');
const point = reader.string(), curve = curves[curveName];
if (point.length !== 1 + 2 * curve.size || point[0] !== 4) throw new Error('Invalid uncompressed ECDSA point.');
// Web Crypto also rejects off-curve points, rather than just checking wire lengths.
try { await crypto.subtle.importKey('raw', point, {name: 'ECDSA', namedCurve: curve.name}, false, ['verify']); }
catch { throw new Error('Invalid ECDSA point.'); }
}
function validateApplication(reader) {
const value = reader.string();
if (!value.length || value.length > 1024 || /[\u0000-\u001f\u007f-\u009f]/u.test(decoder.decode(value))) throw new Error('Invalid security-key application.');
}
async function validateWire(type, bytes) {
const reader = new WireReader(bytes);
if (reader.text() !== type) throw new Error('Public key type does not match key data.');
if (type === 'ssh-ed25519' || type === '[email protected]') {
if (reader.string().length !== 32) throw new Error('Ed25519 public keys must contain 32 bytes.');
if (type.startsWith('sk-')) validateApplication(reader);
} else if (type === 'ssh-rsa') {
const e = positiveMpint(reader.string()), n = positiveMpint(reader.string());
const bits = (n.length - 1) * 8 + 32 - Math.clz32(n[0]);
if (bits < 2048 || bits > 16384) throw new Error('RSA keys must have 2048 to 16384 bits.');
if (e.length > 8 || (e.length === 1 && e[0] < 3) || !(e.at(-1) & 1) || !(n.at(-1) & 1)) throw new Error('Invalid RSA exponent or modulus.');
} else if (type.startsWith('ecdsa-sha2-')) {
await validatePoint(reader, type.slice('ecdsa-sha2-'.length));
} else if (type === '[email protected]') {
await validatePoint(reader, 'nistp256');
validateApplication(reader);
} else throw new Error('Unsupported public key type.');
reader.end();
}
/** Parse plain OpenSSH keys, never authorized_keys options, certificates, or executable content. */
export async function parsePublicKeys(input, {maxKeys = KEY_SOURCE_LIMITS.maxKeys, signal} = {}) {
if (signal?.aborted) throw new SourceError('timeout');
if (!Number.isInteger(maxKeys) || maxKeys < 1 || maxKeys > KEY_SOURCE_LIMITS.maxKeys) throw new Error('Invalid public key count limit.');
if (typeof input !== 'string' || encoder.encode(input).length > KEY_SOURCE_LIMITS.maxBytes) throw new Error('Public key input exceeds its size limit.');
const normalized = input.replace(/\r\n/g, '\n');
if (/[\u0000-\u0008\u000b-\u001f\u007f-\u009f\ufffd]/u.test(normalized) || /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/u.test(normalized)) throw new Error('Public key input contains invalid control characters or Unicode.');
if (/-----BEGIN [^-\r\n]*PRIVATE KEY-----|PuTTY-User-Key-File-[0-9]+:/i.test(normalized)) throw new Error('Private keys are never accepted.');
const keys = [], seen = new Set();
for (const raw of normalized.split('\n')) {
if (signal?.aborted) throw new SourceError('timeout');
const line = raw.replace(/^[ \t]+|[ \t]+$/g, '');
if (!line || line.startsWith('#')) continue;
if (encoder.encode(line).length > 8192) throw new Error('Public key line exceeds its size limit.');
const match = /^(\S+)[ \t]+([A-Za-z0-9+/]+={0,2})(?:[ \t]+(.*))?$/u.exec(line);
if (!match || !keyTypes.has(match[1])) throw new Error('Expected a supported plain OpenSSH public key without options or certificates.');
if (keys.length >= maxKeys) throw new Error('Too many public keys.');
const [, type, encoded, rawComment = ''] = match;
const comment = rawComment.replace(/[ \t]+/g, ' ').trim();
const bytes = decodeBase64(encoded), identity = encodeBase64(bytes);
if (seen.has(identity)) throw new Error('Duplicate public key.');
await validateWire(type, bytes);
if (signal?.aborted) throw new SourceError('timeout');
seen.add(identity);
keys.push({text: `${type} ${identity}${comment ? ` ${comment}` : ''}`, type, label: comment || `Server key ${keys.length + 1}`, fingerprint: `SHA256:${encodeBase64(await sha256(bytes)).replace(/=+$/, '')}`});
}
return keys;
}
export async function makeKeyBundle(keys, revision, allowEmpty) {
if (!/^[0-9a-f]{40}$/.test(revision)) throw new Error('Release revision must be a 40-character Git commit SHA.');
if (typeof allowEmpty !== 'boolean') throw new Error('allowEmpty must be an explicit boolean.');
if (!keys.length && !allowEmpty) throw new Error('An empty key set requires explicit revocation.');
const payload = keys.map(key => `${key.text}\n`).join('');
const digest = hex(await sha256(encoder.encode(payload)));
const text = `# conf-sshd-v1\n# revision ${revision}\n# key-count ${keys.length}\n# allow-empty ${allowEmpty ? 'yes' : 'no'}\n# sha256 ${digest}\n# end-metadata\n${payload}`;
if (encoder.encode(text).length > KEY_SOURCE_LIMITS.maxBytes) throw new SourceError('too-large');
return {sha256: digest, text};
}
async function snapshot(keys, release, source) {
const bundle = await makeKeyBundle(keys, release.revision, release.allowEmpty);
return {keys, keysText: bundle.text, sha256: bundle.sha256, keyCount: keys.length, allowEmpty: release.allowEmpty, source, revision: release.revision, keyRevision: bundle.sha256, fetchedAt: new Date().toISOString()};
}
function boundedOption(options, name) {
const value = options[name] ?? KEY_SOURCE_LIMITS[name];
if (!Number.isInteger(value) || value < 1 || value > KEY_SOURCE_LIMITS[name]) throw new Error(`Invalid ${name} limit.`);
return value;
}
async function readBody(response, signal, maxBytes) {
const declaredLength = response.headers.get('Content-Length');
if (declaredLength && /^\d+$/.test(declaredLength) && Number(declaredLength) > maxBytes) throw new SourceError('too-large');
if (!response.body) return '';
const reader = response.body.getReader();
// Grow one bounded buffer, so an upstream sending tiny chunks cannot grow an unbounded chunk list.
let bytes = new Uint8Array(Math.min(16384, maxBytes));
let total = 0, complete = false;
const cancel = () => { void reader.cancel().catch(() => {}); };
signal.addEventListener('abort', cancel, {once: true});
try {
while (true) {
if (signal.aborted) throw new SourceError('timeout');
const {value, done} = await reader.read();
if (signal.aborted) throw new SourceError('timeout');
if (done) { complete = true; break; }
const nextTotal = total + value.byteLength;
if (nextTotal > maxBytes) throw new SourceError('too-large');
if (nextTotal > bytes.length) {
const expanded = new Uint8Array(Math.min(maxBytes, Math.max(nextTotal, bytes.length * 2)));
expanded.set(bytes);
bytes = expanded;
}
bytes.set(value, total);
total = nextTotal;
}
try { return decoder.decode(bytes.subarray(0, total)); } catch { throw new SourceError('invalid-utf8'); }
} finally {
signal.removeEventListener('abort', cancel);
if (!complete) cancel();
reader.releaseLock();
}
}
async function fetchSource(url, fetchImpl, limits) {
const controller = new AbortController();
let timer, response;
const timeout = new Promise((_, reject) => {
timer = setTimeout(() => { reject(new SourceError('timeout')); controller.abort(); }, limits.timeoutMs);
});
const operation = (async () => {
try {
response = await fetchImpl(url, {method: 'GET', redirect: 'manual', cache: 'no-store', headers: {'Accept': 'text/plain, application/octet-stream;q=0.9', 'Cache-Control': 'no-cache, no-store', 'Pragma': 'no-cache'}, signal: controller.signal});
if (controller.signal.aborted) throw new SourceError('timeout');
if (response.redirected || (response.status >= 300 && response.status < 400)) throw new SourceError('redirect', response.status);
if (!response.ok) throw new SourceError('http-error', response.status);
const body = await readBody(response, controller.signal, limits.maxBytes);
let keys;
try { keys = await parsePublicKeys(body, {maxKeys: limits.maxKeys, signal: controller.signal}); } catch { throw new SourceError('invalid-keys'); }
if (!keys.length) throw new SourceError('empty');
return keys;
} finally {
if (response?.body && !response.body.locked) void response.body.cancel().catch(() => {});
}
})();
try { return await Promise.race([operation, timeout]); }
finally { clearTimeout(timer); }
}
/** Only release-configured URLs are accepted; callers must never derive release data from a request. */
export async function resolveKeys(release, fetchImpl = globalThis.fetch, internalOptions = {}) {
if (!release || !Array.isArray(release.keys) || typeof release.allowEmpty !== 'boolean') throw new Error('Invalid deployed key release.');
if (release.keys.length) return snapshot(release.keys, release, {type: 'repository'});
// An explicit empty release means revoke. It must never be repopulated from a fallback.
if (release.allowEmpty) return snapshot([], release, {type: 'explicit-empty'});
const sources = validateFallbackSources(release.fallbackSources);
const limits = {timeoutMs: boundedOption(internalOptions, 'timeoutMs'), maxBytes: boundedOption(internalOptions, 'maxBytes'), maxKeys: boundedOption(internalOptions, 'maxKeys')};
const diagnostics = [];
for (const [index, url] of sources.entries()) {
try { return await snapshot(await fetchSource(url, fetchImpl, limits), release, {type: 'fallback', url}); }
catch (error) { diagnostics.push({index, code: error instanceof SourceError ? error.code : 'network-error', ...(error instanceof SourceError && error.status !== undefined ? {status: error.status} : {})}); }
}
throw new FallbackUnavailableError(diagnostics);
}
+70
View File
@@ -0,0 +1,70 @@
import { resolveKeys } from './key-source.js';
import { renderPage } from './view.js';
const securityHeaders = {
'X-Content-Type-Options': 'nosniff',
'Referrer-Policy': 'no-referrer',
'Content-Security-Policy': "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'",
};
/** Repository keys are bundled; configured empty-primary fallback resolves one snapshot per response. */
export function createHandler(release, {fetchImpl = globalThis.fetch} = {}) {
return async function fetch(request, env) {
const url = new URL(request.url);
const headers = new Headers(securityHeaders);
headers.set('Cache-Control', 'no-store');
headers.set('X-Conf-Sshd-Revision', release.revision);
headers.set('X-Conf-Sshd-Test-Fixture', String(Boolean(release.testFixture)));
if (!['GET', 'HEAD'].includes(request.method)) {
headers.set('Allow', 'GET, HEAD');
return new Response('Method not allowed.\n', {status: 405, headers});
}
const respond = (body, type, status = 200) => {
headers.set('Content-Type', type);
return new Response(request.method === 'HEAD' ? null : body, {status, headers});
};
if (url.pathname === '/script.sh') return respond(release.script, 'text/plain; charset=utf-8');
let snapshot;
if (['/ssh.keys','/release.json','/'].includes(url.pathname)) {
const accept = request.headers.get('Accept') || '';
const legacyScript = url.pathname === '/' && !accept.toLowerCase().includes('text/html') && /(?:curl|libcurl)\//i.test(request.headers.get('User-Agent') || '');
if (!legacyScript) {
try {
snapshot = {...release, ...await resolveKeys(release, fetchImpl)};
headers.set('X-Conf-Sshd-Key-Digest', snapshot.sha256);
headers.set('X-Conf-Sshd-Key-Source', snapshot.source.type);
} catch (error) {
if (error.code !== 'KEY_SOURCES_UNAVAILABLE') throw error;
if (url.pathname !== '/') return respond('No valid public key source is currently available. Existing server keys must be preserved.\n', 'text/plain; charset=utf-8', 503);
snapshot = {...release,keys:[],keyCount:0,sha256:null,source:{type:'unavailable'}};
}
}
}
if (url.pathname === '/ssh.keys') return respond(snapshot.keysText, 'text/plain; charset=utf-8');
if (url.pathname === '/release.json') {
return respond(JSON.stringify({schema:1,revision:snapshot.revision,publishedAt:snapshot.publishedAt,
keyCount:snapshot.keyCount,allowEmpty:snapshot.allowEmpty,sha256:snapshot.sha256,keyRevision:snapshot.sha256,
source:snapshot.source,fetchedAt:snapshot.fetchedAt,testFixture:Boolean(release.testFixture)})+'\n','application/json; charset=utf-8');
}
if (url.pathname === '/') {
headers.set('Vary', 'Accept, User-Agent');
const accept = request.headers.get('Accept') || '';
const agent = request.headers.get('User-Agent') || '';
// Explicit HTML wins; keep historic curl | sh entry point as a compatibility alias.
if (!accept.toLowerCase().includes('text/html') && /(?:curl|libcurl)\//i.test(agent)) {
return respond(release.script, 'text/plain; charset=utf-8');
}
if (!release.pageTemplate) return respond('Homepage unavailable.\n', 'text/plain; charset=utf-8', 503);
return respond(renderPage(release.pageTemplate,snapshot),'text/html; charset=utf-8',snapshot.source.type === 'unavailable' ? 503 : 200);
}
// Only fingerprinted static assets are exposed; API typos never fall back to HTML.
if (/^\/assets\/[a-zA-Z0-9._-]+\.(?:css|js)$/.test(url.pathname)) {
const asset = await env.ASSETS.fetch(request);
if (!asset.ok) return respond('Not found.\n', 'text/plain; charset=utf-8', 404);
for (const [key, value] of asset.headers) headers.set(key, value);
for (const [key, value] of Object.entries(securityHeaders)) headers.set(key, value);
headers.set('Cache-Control', /\.[a-f0-9]{12}\./.test(url.pathname) ? 'public, max-age=31536000, immutable' : 'no-cache');
return new Response(request.method === 'HEAD' ? null : asset.body, {status: 200, headers});
}
return respond('Not found.\n', 'text/plain; charset=utf-8', 404);
};
}
+49
View File
@@ -0,0 +1,49 @@
export const escapeHTML = value => String(value).replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
export function commandsFor(config) {
const fetch = `curl -fsSL '${config.baseUrl}/script.sh' | sh -s --`;
return [
{id:'install',title:'安装并定时同步',description:'更新当前用户的公钥并设置定时同步;保留已有自定义计划,新安装默认每 15 分钟一次。',command:`${fetch} -c`},
{id:'once',title:'仅更新一次公钥',description:'更新为当前发布的公钥,不设置定时任务。',command:`${fetch} -o`},
{id:'help',title:'查看帮助',description:'先了解全部参数与行为,不修改配置。',command:`${fetch} -h`},
{id:'preview',title:'预览公钥更新',description:'校验当前发布并预览将要修改的文件,不应用更新。',command:`${fetch} -o --dry-run`},
{id:'update',title:'单独更新已安装脚本',description:'显式更新客户端脚本;定时公钥同步不会更新可执行代码。',command:'sh "$HOME/.conf-sshd/conf-sshd.sh" -u',advanced:true},
{id:'root-off',title:'禁止 root 使用密码登录',description:'仅 root 可执行。校验并修改 SSH 配置,不自动重载服务。',command:`${fetch} -p no`,advanced:true},
{id:'root-on',title:'允许 root 使用密码登录',description:'仅 root 可执行,涉及登录安全;实际登录仍受其他 SSH 认证设置限制,不自动重载服务。',command:`${fetch} -p yes`,advanced:true},
{id:'uninstall',title:'停止同步并卸载',description:'移除脚本及其定时任务,保留公钥与 SSH 配置。',command:'sh "$HOME/.conf-sshd/conf-sshd.sh" --uninstall',advanced:true},
];
}
function codeBlock(id, text, label) {
return `<div class="code-block"><pre tabindex="0"><code id="${id}">${escapeHTML(text)}</code></pre><button type="button" class="copy-button" data-copy-target="${id}" aria-label="${escapeHTML(label)}" title="${escapeHTML(label)}"></button></div>`;
}
export function renderKeys(keys) {
return keys.length ? keys.map((key,i) => `<article class="key-card"><div class="card-heading"><h3>${escapeHTML(key.label)}</h3><span class="key-type">${escapeHTML(key.type)}</span></div><p class="fingerprint">${escapeHTML(key.fingerprint)}</p>${codeBlock(`key-${i}`,key.text,`复制第 ${i + 1} 个公钥`)}</article>`).join('\n') : '<p class="empty-state">此版本明确撤销全部受管理公钥,手工管理的公钥仍保留。</p>';
}
export function renderCommands(commands) {
const card = command => `<article class="command-card"><div class="card-heading"><h3>${escapeHTML(command.title)}</h3></div><p class="command-description">${escapeHTML(command.description)}</p>${codeBlock(`command-${command.id}`,command.command,`复制“${command.title}”命令`)}</article>`;
return commands.filter(c=>!c.advanced).map(card).join('\n') + '<details class="advanced-commands"><summary>高级命令</summary><div class="command-list">' + commands.filter(c=>c.advanced).map(card).join('\n') + '</div></details>';
}
/** All visible keys/metadata and clipboard data come from one resolved response snapshot. */
export function renderPage(template, snapshot) {
const {revision, publishedAt, keys = [], commands = [], testFixture, repositoryUrl, source = {type:'repository'}} = snapshot;
const unavailable = source.type === 'unavailable' || source.type === 'pending';
const sourceLabel = source.type === 'fallback' ? `备用源:${source.url}` : source.type === 'explicit-empty' ? '仓库清单(明确撤销)' : unavailable ? '等待可用公钥源' : '仓库清单';
const sourceStatus = source.type === 'fallback' ? '备用源可用' : source.type === 'explicit-empty' ? '明确撤销' : unavailable ? '暂不可用' : '可用';
const notice = unavailable ? '公钥源暂不可用,未发布撤销指令;现有服务器保留上次成功密钥。' : source.type === 'fallback' ? '当前使用按配置顺序选中的首个有效备用源;后续请求会重新获取。' : source.type === 'explicit-empty' ? '此版本明确撤销全部受管理公钥;手工公钥与已有会话不受影响。' : '';
const publicData = {schema:1,revision,publishedAt,keyCount:keys.length,sha256:snapshot.sha256 || null,keyRevision:snapshot.sha256 || null,source,sourceStatus,keys,commands,baseUrl:snapshot.baseUrl,repositoryUrl,testFixture:Boolean(testFixture)};
const replacements = {
RELEASE_JSON:JSON.stringify(publicData).replace(/</g,'\\u003c').replace(/\u2028/g,'\\u2028').replace(/\u2029/g,'\\u2029'),
KEYS_HTML:unavailable ? '<p class="empty-state">当前无法获取有效公钥。请稍后重试或检查备用源配置。</p>' : renderKeys(keys),
COMMANDS_HTML:renderCommands(commands),REVISION:escapeHTML(revision.slice(0,12)),PUBLISHED_AT:escapeHTML(publishedAt),KEY_COUNT:String(keys.length),
REPOSITORY_URL:escapeHTML(repositoryUrl),RELEASE_LABEL:testFixture ? '测试预览' : unavailable ? '公钥暂不可用' : '已发布',
PREVIEW_NOTICE:testFixture ? '<p class="preview-notice" role="note">测试预览:这里使用公开测试密钥,不能部署到真实服务器。</p>' : '',
SOURCE_LABEL:escapeHTML(sourceLabel),SOURCE_STATUS:escapeHTML(sourceStatus),CONTENT_REVISION:escapeHTML(snapshot.sha256?.slice(0,12) || '—'),KEYS_NOTICE:escapeHTML(notice),
};
return template.replace(/@@([A-Z_]+)@@/g, (_, token) => {
if (!Object.hasOwn(replacements, token)) throw Error(`Unresolved page template token: ${token}`);
return replacements[token];
});
}
+6 -1
View File
@@ -1,3 +1,8 @@
name = "quickly-conf-ssh-worker"
main = "src/index.js"
compatibility_date = "2023-01-26"
compatibility_date = "2026-10-01"
[assets]
directory = "./public"
binding = "ASSETS"
run_worker_first = true