mirror of
https://github.com/LamGC/quickly-conf-sshd.git
synced 2026-10-12 23:33:56 +00:00
feat: independently publish server SSH keys with safe sync and Worker homepage
This commit is contained in:
@@ -0,0 +1,85 @@
|
||||
import { createServer } from "node:http";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { createHash } from "node:crypto";
|
||||
import { commandsFor, renderPage } from "../cf-worker/src/view.js";
|
||||
|
||||
/** Real page rendering and real local assets, with no live Worker or upstream calls. */
|
||||
export async function startBrowserFixture() {
|
||||
const [template, style, script, fixtureKey] = await Promise.all([
|
||||
readFile(new URL("../site/index.html", import.meta.url), "utf8"),
|
||||
readFile(new URL("../site/assets/style.css", import.meta.url)),
|
||||
readFile(new URL("../site/assets/app.js", import.meta.url)),
|
||||
readFile(new URL("./fixtures/keys.pub", import.meta.url), "utf8"),
|
||||
]);
|
||||
const [type, encoded] = fixtureKey.trim().split(/\s+/);
|
||||
const comment = 'CI test <img src=x onerror="window.__injected=true"> & "quoted" 中文 ' + "long-comment-".repeat(90);
|
||||
const text = `${type} ${encoded} ${comment}`;
|
||||
const key = {
|
||||
type,
|
||||
text,
|
||||
label: comment,
|
||||
fingerprint: "SHA256:" + createHash("sha256").update(Buffer.from(encoded, "base64")).digest("base64").replace(/=+$/, ""),
|
||||
};
|
||||
const config = { baseUrl: "https://keys.example.test", repositoryUrl: "https://github.com/example/quickly-conf-sshd" };
|
||||
const base = {
|
||||
schema: 1,
|
||||
revision: "0123456789abcdef0123456789abcdef01234567",
|
||||
publishedAt: "2026-10-12T12:34:56.000Z",
|
||||
keyCount: 1,
|
||||
allowEmpty: false,
|
||||
sha256: createHash("sha256").update(text + "\n").digest("hex"),
|
||||
testFixture: true,
|
||||
...config,
|
||||
commands: commandsFor(config),
|
||||
keys: [key],
|
||||
};
|
||||
const snapshots = {
|
||||
repository: { ...base, source: { type: "repository" } },
|
||||
fallback: { ...base, source: { type: "fallback", url: "https://keys.example.test/team.keys?comment=<script>window.__injected=true</script>" } },
|
||||
unavailable: { ...base, keys: [], keyCount: 0, sha256: null, source: { type: "unavailable" } },
|
||||
pending: { ...base, keys: [], keyCount: 0, sha256: null, source: { type: "pending" } },
|
||||
"explicit-empty": { ...base, allowEmpty: true, keys: [], keyCount: 0, sha256: createHash("sha256").update("").digest("hex"), source: { type: "explicit-empty" } },
|
||||
};
|
||||
const pages = new Map(Object.entries(snapshots).map(([name, snapshot]) => ["/" + name, renderPage(template, snapshot)]));
|
||||
const requests = [];
|
||||
const server = createServer((request, response) => {
|
||||
const pathname = new URL(request.url, "http://127.0.0.1").pathname;
|
||||
requests.push(pathname);
|
||||
response.setHeader("Cache-Control", "no-store");
|
||||
response.setHeader("X-Content-Type-Options", "nosniff");
|
||||
response.setHeader("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'");
|
||||
if (pathname === "/assets/style.css" || pathname === "/assets/app.js") {
|
||||
const isStyle = pathname.endsWith(".css");
|
||||
response.setHeader("Content-Type", isStyle ? "text/css; charset=utf-8" : "text/javascript; charset=utf-8");
|
||||
response.end(isStyle ? style : script);
|
||||
return;
|
||||
}
|
||||
if (pathname === "/favicon.ico") {
|
||||
response.statusCode = 204;
|
||||
response.end();
|
||||
return;
|
||||
}
|
||||
const page = pages.get(pathname === "/" ? "/repository" : pathname);
|
||||
if (page) {
|
||||
response.statusCode = pathname === "/unavailable" ? 503 : 200;
|
||||
response.setHeader("Content-Type", "text/html; charset=utf-8");
|
||||
response.end(page);
|
||||
return;
|
||||
}
|
||||
response.statusCode = 404;
|
||||
response.end("Fixture route not found\n");
|
||||
});
|
||||
await new Promise((resolve, reject) => {
|
||||
server.once("error", reject);
|
||||
server.listen(0, "127.0.0.1", () => { server.off("error", reject); resolve(); });
|
||||
});
|
||||
return {
|
||||
origin: `http://127.0.0.1:${server.address().port}`,
|
||||
snapshots,
|
||||
requests,
|
||||
close: () => new Promise((resolve, reject) => {
|
||||
server.close((error) => error ? reject(error) : resolve());
|
||||
server.closeAllConnections();
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { access, mkdir } from "node:fs/promises";
|
||||
import { constants } from "node:fs";
|
||||
import { delimiter, join } from "node:path";
|
||||
import test from "node:test";
|
||||
import { startBrowserFixture } from "./browser-fixture.mjs";
|
||||
|
||||
async function browserExecutable() {
|
||||
const candidates = process.env.CHROMIUM_PATH ? [process.env.CHROMIUM_PATH] : [
|
||||
"/usr/bin/google-chrome", "/usr/bin/google-chrome-stable", "/usr/bin/chromium", "/usr/bin/chromium-browser",
|
||||
...(process.env.PATH || "").split(delimiter).flatMap((directory) => ["google-chrome", "google-chrome-stable", "chromium", "chromium-browser"].map((name) => join(directory, name))),
|
||||
];
|
||||
for (const candidate of new Set(candidates)) {
|
||||
try { await access(candidate, constants.X_OK); return candidate; } catch { /* Check the next installed browser. */ }
|
||||
}
|
||||
throw new Error("BROWSER_TESTS=1 requires an installed Chromium/Google Chrome. Set CHROMIUM_PATH to its executable.");
|
||||
}
|
||||
|
||||
async function capture(page, name) {
|
||||
if (!process.env.BROWSER_ARTIFACTS_DIR) return;
|
||||
await mkdir(process.env.BROWSER_ARTIFACTS_DIR, { recursive: true });
|
||||
await page.screenshot({ path: join(process.env.BROWSER_ARTIFACTS_DIR, name + ".png"), fullPage: true });
|
||||
}
|
||||
|
||||
async function guardedContext(browser, fixture, options = {}) {
|
||||
const context = await browser.newContext(options);
|
||||
const externalRequests = [];
|
||||
await context.route("**/*", (route) => {
|
||||
if (new URL(route.request().url()).origin === fixture.origin) return route.continue();
|
||||
externalRequests.push(route.request().url());
|
||||
return route.abort("blockedbyclient");
|
||||
});
|
||||
return { context, externalRequests };
|
||||
}
|
||||
|
||||
async function installClipboardMock(context) {
|
||||
await context.addInitScript(() => {
|
||||
window.__clipboardTest = { values: [], reject: false };
|
||||
Object.defineProperty(navigator, "clipboard", {
|
||||
configurable: true,
|
||||
value: { writeText: async (text) => {
|
||||
if (window.__clipboardTest.reject) throw new DOMException("Fixture clipboard denied", "NotAllowedError");
|
||||
window.__clipboardTest.values.push(text);
|
||||
} },
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function noPageOverflow(page, width) {
|
||||
const metrics = await page.evaluate(() => ({
|
||||
viewport: window.innerWidth,
|
||||
document: document.documentElement.scrollWidth,
|
||||
body: document.body.scrollWidth,
|
||||
}));
|
||||
assert.equal(metrics.viewport, width);
|
||||
assert.ok(metrics.document <= width && metrics.body <= width, JSON.stringify(metrics));
|
||||
}
|
||||
|
||||
// Opt in on a CI runner with a real browser. Ordinary npm test requires no browser or downloads.
|
||||
test("real browser homepage integration", { skip: process.env.BROWSER_TESTS !== "1", timeout: 180_000 }, async (t) => {
|
||||
const { chromium } = await import("playwright-core");
|
||||
const executablePath = await browserExecutable();
|
||||
const fixture = await startBrowserFixture();
|
||||
let browser;
|
||||
try {
|
||||
browser = await chromium.launch({ executablePath, headless: true, args: ["--no-sandbox"] });
|
||||
|
||||
for (const width of [390, 1440]) {
|
||||
await t.test(`${width}px layout, themes, clipboard, keyboard and fallback`, async () => {
|
||||
const { context, externalRequests } = await guardedContext(browser, fixture, {
|
||||
viewport: { width, height: 900 },
|
||||
colorScheme: "light",
|
||||
hasTouch: width < 600,
|
||||
isMobile: width < 600,
|
||||
});
|
||||
try {
|
||||
await installClipboardMock(context);
|
||||
const page = await context.newPage();
|
||||
const errors = [];
|
||||
page.on("pageerror", (error) => errors.push(error.message));
|
||||
await page.goto(fixture.origin + "/repository");
|
||||
await page.waitForSelector("html.enhanced");
|
||||
await noPageOverflow(page, width);
|
||||
assert.equal(await page.locator("#key-0").textContent(), fixture.snapshots.repository.keys[0].text);
|
||||
assert.equal(await page.locator(".key-card img").count(), 0, "key comments must remain text");
|
||||
assert.equal(await page.evaluate(() => window.__injected), undefined);
|
||||
assert.equal(await page.locator(".key-card pre").evaluate((pre) => pre.scrollWidth > pre.clientWidth), true);
|
||||
assert.equal(await page.locator("#theme-picker").inputValue(), "system");
|
||||
assert.equal(await page.locator("html").getAttribute("data-theme"), null);
|
||||
const lightBackground = await page.locator("body").evaluate((body) => getComputedStyle(body).backgroundColor);
|
||||
await capture(page, `homepage-${width}-light`);
|
||||
|
||||
await page.selectOption("#theme-picker", "dark");
|
||||
assert.equal(await page.locator("html").getAttribute("data-theme"), "dark");
|
||||
const darkBackground = await page.locator("body").evaluate((body) => getComputedStyle(body).backgroundColor);
|
||||
assert.notEqual(darkBackground, lightBackground);
|
||||
await capture(page, `homepage-${width}-dark`);
|
||||
await page.reload();
|
||||
assert.equal(await page.locator("#theme-picker").inputValue(), "dark");
|
||||
assert.equal(await page.locator("html").getAttribute("data-theme"), "dark");
|
||||
await page.emulateMedia({ colorScheme: "dark" });
|
||||
await page.selectOption("#theme-picker", "light");
|
||||
assert.equal(await page.locator("body").evaluate((body) => getComputedStyle(body).backgroundColor), lightBackground);
|
||||
await page.selectOption("#theme-picker", "system");
|
||||
assert.equal(await page.locator("html").getAttribute("data-theme"), null);
|
||||
assert.equal(await page.locator("body").evaluate((body) => getComputedStyle(body).backgroundColor), darkBackground);
|
||||
await page.emulateMedia({ colorScheme: "light" });
|
||||
assert.equal(await page.locator("body").evaluate((body) => getComputedStyle(body).backgroundColor), lightBackground);
|
||||
assert.equal(await page.evaluate(() => localStorage.getItem("quickly-conf-sshd-theme")), "system");
|
||||
|
||||
const keyButton = page.locator('[data-copy-target="key-0"]');
|
||||
if (width < 600) {
|
||||
assert.equal(await keyButton.evaluate((button) => getComputedStyle(button).opacity), "1", "touch copy buttons stay visible");
|
||||
} else {
|
||||
await page.locator("#page-title").hover();
|
||||
await page.waitForFunction(() => getComputedStyle(document.querySelector('[data-copy-target="key-0"]')).opacity === "0");
|
||||
}
|
||||
await keyButton.focus();
|
||||
await page.waitForFunction(() => getComputedStyle(document.querySelector('[data-copy-target="key-0"]')).opacity === "1");
|
||||
await page.keyboard.press("Enter");
|
||||
await page.waitForFunction(() => document.querySelector('[data-copy-target="key-0"]').dataset.state === "success");
|
||||
assert.equal(await page.evaluate(() => window.__clipboardTest.values[0]), fixture.snapshots.repository.keys[0].text);
|
||||
assert.match(await keyButton.getAttribute("aria-label"), /已复制/);
|
||||
await page.waitForFunction(() => document.getElementById("copy-status").textContent.includes("已复制"));
|
||||
|
||||
await page.locator("#copy-all-keys").click();
|
||||
const allKeys = fixture.snapshots.repository.keys.map((key) => key.text).join("\n") + "\n";
|
||||
assert.equal(await page.evaluate(() => window.__clipboardTest.values[1]), allKeys);
|
||||
assert.equal(await page.locator("#copy-all-commands").count(), 0);
|
||||
await page.locator("details summary").click();
|
||||
await page.locator('[data-copy-target="command-uninstall"]').focus();
|
||||
await page.keyboard.press("Enter");
|
||||
const uninstall = fixture.snapshots.repository.commands.find((command) => command.id === "uninstall").command;
|
||||
assert.equal(await page.evaluate(() => window.__clipboardTest.values.at(-1)), uninstall);
|
||||
await noPageOverflow(page, width);
|
||||
|
||||
await page.evaluate(() => { window.__clipboardTest.reject = true; });
|
||||
await keyButton.focus();
|
||||
await page.keyboard.press("Enter");
|
||||
await page.waitForSelector("#copy-fallback[open]");
|
||||
assert.equal(await page.locator("#fallback-text").inputValue(), fixture.snapshots.repository.keys[0].text);
|
||||
assert.deepEqual(await page.locator("#fallback-text").evaluate((textarea) => ({
|
||||
start: textarea.selectionStart, end: textarea.selectionEnd, focused: document.activeElement === textarea,
|
||||
})), { start: 0, end: fixture.snapshots.repository.keys[0].text.length, focused: true });
|
||||
assert.equal(await keyButton.getAttribute("data-state"), null, "failed copy must not show success");
|
||||
await capture(page, `homepage-${width}-manual-copy`);
|
||||
await page.keyboard.press("Escape");
|
||||
assert.equal(await page.locator("#copy-fallback").evaluate((dialog) => dialog.open), false);
|
||||
assert.equal(await keyButton.evaluate((button) => document.activeElement === button), true);
|
||||
await page.keyboard.press("Enter");
|
||||
await page.waitForSelector("#copy-fallback[open]");
|
||||
await page.locator("#close-copy-fallback").click();
|
||||
assert.equal(await keyButton.evaluate((button) => document.activeElement === button), true);
|
||||
assert.deepEqual(errors, []);
|
||||
assert.deepEqual(externalRequests, []);
|
||||
} finally { await context.close(); }
|
||||
});
|
||||
}
|
||||
|
||||
await t.test("all source states render the same snapshot into metadata, keys and copy data", async () => {
|
||||
const { context, externalRequests } = await guardedContext(browser, fixture, { viewport: { width: 390, height: 844 } });
|
||||
try {
|
||||
await installClipboardMock(context);
|
||||
const page = await context.newPage();
|
||||
for (const [state, sourceText] of [["repository", "可用"], ["fallback", "备用源可用"], ["unavailable", "暂不可用"], ["pending", "暂不可用"], ["explicit-empty", "明确撤销"]]) {
|
||||
const response = await page.goto(fixture.origin + "/" + state);
|
||||
assert.equal(response.status(), state === "unavailable" ? 503 : 200);
|
||||
await page.waitForSelector("html.enhanced");
|
||||
assert.equal(await page.locator(".source-status").textContent(), sourceText);
|
||||
const snapshot = fixture.snapshots[state];
|
||||
assert.equal(await page.locator(".key-count").textContent(), String(snapshot.keys.length));
|
||||
assert.equal(await page.locator("#copy-all-keys").isDisabled(), snapshot.keys.length === 0);
|
||||
const embedded = await page.locator("#release-data").evaluate((element) => JSON.parse(element.textContent));
|
||||
assert.equal(embedded.source.type, state);
|
||||
assert.deepEqual(embedded.keys, snapshot.keys);
|
||||
assert.equal(embedded.keyRevision, snapshot.sha256);
|
||||
assert.equal(await page.locator(".release-revision").first().textContent(), snapshot.revision.slice(0, 12));
|
||||
assert.equal(await page.locator(".release-revision").nth(1).textContent(), snapshot.sha256?.slice(0, 12) || "—");
|
||||
if (state === "fallback") {
|
||||
assert.equal(await page.locator(".source-label").textContent(), "备用源:" + snapshot.source.url);
|
||||
assert.equal(await page.locator(".source-label script").count(), 0);
|
||||
await page.locator("#copy-all-keys").click();
|
||||
assert.equal(await page.evaluate(() => window.__clipboardTest.values[0]), snapshot.keys.map((key) => key.text).join("\n") + "\n");
|
||||
}
|
||||
if (state === "unavailable" || state === "pending") {
|
||||
assert.match(await page.locator(".keys-notice").textContent(), /未发布撤销指令.*保留上次成功密钥/);
|
||||
assert.equal(await page.locator(".key-card").count(), 0);
|
||||
}
|
||||
if (state === "explicit-empty") assert.match(await page.locator(".keys-notice").textContent(), /明确撤销全部受管理公钥/);
|
||||
assert.equal(await page.evaluate(() => window.__injected), undefined);
|
||||
await noPageOverflow(page, 390);
|
||||
await capture(page, "source-" + state);
|
||||
}
|
||||
assert.deepEqual(externalRequests, []);
|
||||
} finally { await context.close(); }
|
||||
});
|
||||
|
||||
await t.test("no-JavaScript page retains keys, commands, source states and system theme", async () => {
|
||||
const { context, externalRequests } = await guardedContext(browser, fixture, {
|
||||
viewport: { width: 390, height: 844 }, javaScriptEnabled: false, colorScheme: "dark",
|
||||
});
|
||||
try {
|
||||
const page = await context.newPage();
|
||||
await page.goto(fixture.origin + "/repository");
|
||||
assert.equal(await page.locator("#key-0").isVisible(), true);
|
||||
assert.equal(await page.locator("#command-install").isVisible(), true);
|
||||
assert.equal(await page.locator("#copy-all-keys").isVisible(), false);
|
||||
assert.equal(await page.locator("#theme-picker").isVisible(), false);
|
||||
assert.match(await page.locator("noscript").textContent(), /手动选择复制/);
|
||||
await page.locator("details summary").click();
|
||||
assert.equal(await page.locator("#command-uninstall").isVisible(), true);
|
||||
assert.equal(await page.locator(".repository-link").getAttribute("href"), fixture.snapshots.repository.repositoryUrl);
|
||||
await capture(page, "homepage-no-javascript");
|
||||
await page.goto(fixture.origin + "/unavailable");
|
||||
assert.equal(await page.locator(".source-status").textContent(), "暂不可用");
|
||||
assert.match(await page.locator(".keys-notice").textContent(), /保留上次成功密钥/);
|
||||
assert.deepEqual(externalRequests, []);
|
||||
} finally { await context.close(); }
|
||||
});
|
||||
} finally {
|
||||
await browser?.close();
|
||||
await fixture.close();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {readFile,mkdtemp,cp,writeFile,rm,mkdir} from 'node:fs/promises';
|
||||
import {tmpdir} from 'node:os';
|
||||
import {join} from 'node:path';
|
||||
import {renderPage} from '../cf-worker/src/view.js';
|
||||
import {validateConfig,parseKeys,makeKeysText,commandsFor,renderKeys,build,root,digest} from '../scripts/build.mjs';
|
||||
const config = {baseUrl:'https://ssh.example.com',repositoryUrl:'https://github.com/example/keys',defaultCron:'*/15 * * * *',allowEmpty:false};
|
||||
const fixture = await readFile(new URL('./fixtures/keys.pub',import.meta.url),'utf8');
|
||||
|
||||
test('deployment configuration rejects shell injection and ambiguous origins',()=>{
|
||||
assert.deepEqual(validateConfig(config),config);
|
||||
for(const baseUrl of ['http://x.example','https://[email protected]','https://x.example/','https://x.example/a','https://x.example;touch /tmp/x','https://x.example\n','https://x.example$(id)','https://x.example:443']) assert.throws(()=>validateConfig({...config,baseUrl}));
|
||||
for(const defaultCron of ['* * * * *\nevil','61 * * * *','*/0 * * * *','*/99 * * * *','* * * 13 *','* * * * * echo x']) assert.throws(()=>validateConfig({...config,defaultCron}));
|
||||
assert.throws(()=>validateConfig({...config,allowEmpty:'false'}));
|
||||
assert.throws(()=>validateConfig({...config,sshKeyUrl:'https://github.com/user.keys'}));
|
||||
});
|
||||
test('keys are structurally validated, canonicalized and fingerprinted',async()=>{
|
||||
const keys = await parseKeys('# a comment\r\n'+fixture.replace(/\n/g,'\r\n'));
|
||||
assert.equal(keys.length,1);assert.match(keys[0].fingerprint,/^SHA256:/);
|
||||
assert.equal(keys[0].text+'\n',fixture);
|
||||
assert.equal((await parseKeys(fixture.trimEnd())).length,1);
|
||||
});
|
||||
test('rejects malformed keys, options, duplicate identities, private material and NUL',async()=>{
|
||||
const key = fixture.trim();const [type,blob] = key.split(' ');
|
||||
for(const input of ['ssh-ed25519 not-a-key','ssh-rsa '+blob,`command="evil" ${key}`,`${key}\n${type} ${blob} different-comment`,'-----BEGIN OPENSSH PRIVATE KEY-----',key+'\0',`ssh-dss ${blob}`]) await assert.rejects(parseKeys(input));
|
||||
});
|
||||
test('empty revocation must be explicit; protocol is deterministic',async()=>{
|
||||
assert.throws(()=>makeKeysText([], 'a'.repeat(40),false),/No server public keys/);
|
||||
const empty = makeKeysText([], 'a'.repeat(40),true);
|
||||
assert.match(empty.text,/# key-count 0\n# allow-empty yes\n/);assert.equal(empty.sha256,digest(''));
|
||||
const keys=await parseKeys(fixture);const bundle=makeKeysText(keys,'b'.repeat(40),false);
|
||||
assert.ok(bundle.text.endsWith(fixture));assert.equal(bundle.sha256,digest(fixture));
|
||||
assert.throws(()=>makeKeysText(keys,'main',false),/commit SHA/);
|
||||
});
|
||||
test('HTML escapes comments; commands use explicit script endpoint and separate actions',async()=>{
|
||||
const keys=await parseKeys(fixture.trim()+' <script>alert(1)</script>');
|
||||
const html=renderKeys(keys);assert.ok(html.includes('<script>'));assert.ok(!html.includes('<script>'));
|
||||
const commands=commandsFor(config);assert.ok(commands.find(x=>x.id==='install').command.endsWith(' -c'));
|
||||
assert.ok(commands.find(x=>x.id==='root-on').advanced);assert.ok(commands.find(x=>x.id==='uninstall').advanced);
|
||||
assert.ok(commands.every(x=>!x.command.includes('github.io')&&!x.command.includes('.keys')));
|
||||
});
|
||||
test('build creates consistent assets/release; production empty is blocked',async()=>{
|
||||
const directory=await mkdtemp(join(tmpdir(),'conf-sshd-build-'));
|
||||
try {
|
||||
for(const path of ['site','tests/fixtures','keys']) {await mkdir(join(directory,path),{recursive:true});await cp(join(root,path),join(directory,path),{recursive:true});}
|
||||
await cp(join(root,'conf-sshd.sh'),join(directory,'conf-sshd.sh'));
|
||||
await writeFile(join(directory,'deployment.json'),JSON.stringify(config));
|
||||
const options={directory,revision:'c'.repeat(40),publishedAt:'2026-10-12T00:00:00.000Z'};
|
||||
await assert.rejects(build(options),/No server public keys/);
|
||||
const release=await build({...options,testFixture:true});assert.equal(release.testFixture,true);
|
||||
const html=await readFile(join(directory,'cf-worker/public/index.html'),'utf8');
|
||||
assert.ok(!html.includes('@@'));assert.ok(html.includes('cccccccccccc'));assert.match(html,/\/assets\/app\.[a-f0-9]{12}\.js/);
|
||||
const generated=await readFile(join(directory,'cf-worker/generated/release.js'),'utf8');
|
||||
assert.ok(generated.includes('# conf-sshd-v1'));assert.ok(generated.includes('https://ssh.example.com'));assert.ok(!generated.includes('@@BASE_URL@@'));
|
||||
await writeFile(join(directory,'keys/authorized_keys'),fixture);
|
||||
assert.equal((await build(options)).testFixture,false);
|
||||
} finally {await rm(directory,{recursive:true,force:true});}
|
||||
});
|
||||
|
||||
test('fallback configuration allows an empty primary only with explicit bounded HTTPS sources',async()=>{
|
||||
assert.throws(()=>validateConfig({...config,fallbackSources:['http://keys.example.com']}));
|
||||
assert.throws(()=>validateConfig({...config,fallbackSources:['https://user:[email protected]']}));
|
||||
assert.throws(()=>validateConfig({...config,fallbackSources:Array(6).fill('https://keys.example.com')}));
|
||||
const directory=await mkdtemp(join(tmpdir(),'conf-sshd-fallback-build-'));
|
||||
try {
|
||||
for(const path of ['site','tests/fixtures','keys']) {await mkdir(join(directory,path),{recursive:true});await cp(join(root,path),join(directory,path),{recursive:true});}
|
||||
await cp(join(root,'conf-sshd.sh'),join(directory,'conf-sshd.sh'));
|
||||
await writeFile(join(directory,'deployment.json'),JSON.stringify({...config,fallbackSources:['https://keys.example.com/server.keys']}));
|
||||
const release=await build({directory,revision:'d'.repeat(40)});
|
||||
assert.equal(release.needsFallback,true);assert.equal(release.sha256,null);assert.equal(release.keyCount,0);
|
||||
assert.ok((await readFile(join(directory,'cf-worker/public/index.html'),'utf8')).includes('未发布撤销指令'));
|
||||
} finally {await rm(directory,{recursive:true,force:true});}
|
||||
});
|
||||
|
||||
test('template rendering preserves literal dollar replacements and token-like key comments',async()=>{
|
||||
const tricky=fixture.trim()+" $& $` $' $$ @@REVISION@@ @@KEYS_HTML@@";
|
||||
const keys=await parseKeys(tricky);
|
||||
const template='<div>@@KEYS_HTML@@</div><script type="application/json">@@RELEASE_JSON@@</script>';
|
||||
const html=renderPage(template,{revision:'e'.repeat(40),publishedAt:'2026-10-12',keys,commands:[],repositoryUrl:config.repositoryUrl,sha256:'f'.repeat(64)});
|
||||
const json=JSON.parse(html.match(/<script type="application\/json">([\s\S]*?)<\/script>/)[1]);
|
||||
assert.equal(json.keys[0].text,tricky);assert.ok(html.includes('@@KEYS_HTML@@'));assert.ok(html.includes('$&'));
|
||||
assert.throws(()=>renderPage('@@UNKNOWN@@',{revision:'e'.repeat(40),keys:[]}),/Unresolved/);
|
||||
});
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAINdamAGCsQq31Uv+08lkBzoO4XLz2qYjJa8CGmj3B1Ea TEST-ONLY-RFC8032-public-key
|
||||
@@ -0,0 +1,303 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {readFile} from 'node:fs/promises';
|
||||
import {createHash, generateKeyPairSync} from 'node:crypto';
|
||||
import {FallbackUnavailableError, KEY_SOURCE_LIMITS, makeKeyBundle, parsePublicKeys, resolveKeys, validateFallbackSources} from '../cf-worker/src/key-source.js';
|
||||
|
||||
const fixture = await readFile(new URL('./fixtures/keys.pub', import.meta.url), 'utf8');
|
||||
const revision = 'a'.repeat(40);
|
||||
const urls = ['https://keys.example.org/first.keys', 'https://keys.example.org/second.keys', 'https://keys.example.org/third.keys'];
|
||||
const emptyRelease = {keys: [], allowEmpty: false, revision, fallbackSources: urls};
|
||||
const fixtureKeys = await parsePublicKeys(fixture);
|
||||
const hash = text => createHash('sha256').update(text).digest('hex');
|
||||
const string = value => {
|
||||
const bytes = typeof value === 'string' ? Buffer.from(value) : Buffer.from(value);
|
||||
const prefix = Buffer.alloc(4); prefix.writeUInt32BE(bytes.length);
|
||||
return Buffer.concat([prefix, bytes]);
|
||||
};
|
||||
const wire = (...fields) => Buffer.concat(fields.map(string));
|
||||
const keyLine = (type, bytes, comment = '') => `${type} ${bytes.toString('base64')}${comment ? ` ${comment}` : ''}\n`;
|
||||
const secondFixture = keyLine('ssh-ed25519', wire('ssh-ed25519', Buffer.from('3d4017c3e843895a92b70aa74d1b7ebc9c982ccf2ec4968cc0cd55f12af4660c', 'hex')), 'second');
|
||||
const keyData = Buffer.from(fixture.split(/\s+/)[1], 'base64');
|
||||
const response = text => new Response(text, {headers: {'Content-Type': 'text/plain; charset=utf-8'}});
|
||||
async function unavailable(fetcher, options = {}, release = emptyRelease) {
|
||||
let caught;
|
||||
try { await resolveKeys(release, fetcher, options); } catch (error) { caught = error; }
|
||||
assert.ok(caught instanceof FallbackUnavailableError);
|
||||
assert.equal(caught.code, 'KEY_SOURCES_UNAVAILABLE');
|
||||
return caught;
|
||||
}
|
||||
|
||||
test('repository keys win without any upstream request and have complete protocol metadata', async () => {
|
||||
const result = await resolveKeys({...emptyRelease, keys: fixtureKeys}, () => assert.fail('Repository keys must not fetch'));
|
||||
assert.deepEqual(result.source, {type: 'repository'});
|
||||
assert.equal(result.revision, revision);
|
||||
assert.equal(result.keyCount, 1);
|
||||
assert.equal(result.sha256, hash(fixture));
|
||||
assert.equal(result.keyRevision, result.sha256);
|
||||
assert.equal(result.keysText, `# conf-sshd-v1\n# revision ${revision}\n# key-count 1\n# allow-empty no\n# sha256 ${hash(fixture)}\n# end-metadata\n${fixture}`);
|
||||
assert.ok(Number.isFinite(Date.parse(result.fetchedAt)));
|
||||
});
|
||||
|
||||
test('explicit empty revocation suppresses fallback; repository still wins when nonempty', async () => {
|
||||
const result = await resolveKeys({...emptyRelease, allowEmpty: true}, () => assert.fail('Revocation must not fetch'));
|
||||
assert.deepEqual(result.source, {type: 'explicit-empty'});
|
||||
assert.equal(result.keyCount, 0);
|
||||
assert.equal(result.sha256, hash(''));
|
||||
assert.match(result.keysText, /# allow-empty yes\n/);
|
||||
const populated = await resolveKeys({...emptyRelease, keys: fixtureKeys, allowEmpty: true}, () => assert.fail('Must not fetch'));
|
||||
assert.equal(populated.source.type, 'repository');
|
||||
assert.equal(populated.keyCount, 1);
|
||||
});
|
||||
|
||||
test('first valid nonempty source wins in order, without aggregation or redirect/cache reuse', async () => {
|
||||
const visited = [];
|
||||
const result = await resolveKeys(emptyRelease, async (url, init) => {
|
||||
visited.push(url);
|
||||
assert.equal(init.cache, 'no-store'); assert.equal(init.redirect, 'manual'); assert.equal(init.method, 'GET');
|
||||
assert.match(init.headers['Cache-Control'], /no-store/); assert.ok(init.signal instanceof AbortSignal);
|
||||
return response(fixture);
|
||||
});
|
||||
assert.deepEqual(visited, [urls[0]]);
|
||||
assert.deepEqual(result.source, {type: 'fallback', url: urls[0]});
|
||||
assert.equal(result.keyCount, 1);
|
||||
assert.equal(result.sha256, hash(fixture));
|
||||
});
|
||||
|
||||
test('malformed and empty sources are skipped before the next valid source', async () => {
|
||||
const visited = [];
|
||||
const result = await resolveKeys(emptyRelease, async url => {
|
||||
visited.push(url);
|
||||
return response([ '<html>upstream problem</html>', '# no keys\n\n', fixture ][urls.indexOf(url)]);
|
||||
});
|
||||
assert.deepEqual(visited, urls);
|
||||
assert.equal(result.source.url, urls[2]);
|
||||
});
|
||||
|
||||
for (const status of [301, 302, 307, 308, 400, 404, 429, 500, 503]) {
|
||||
test(`HTTP ${status} is skipped without following redirects`, async () => {
|
||||
const visited = [];
|
||||
const result = await resolveKeys(emptyRelease, async url => {
|
||||
visited.push(url);
|
||||
return url === urls[0] ? new Response('Do not return this body: SECRET', {status, headers: {Location: 'https://unconfigured.example/keys'}}) : response(fixture);
|
||||
});
|
||||
assert.deepEqual(visited, urls.slice(0, 2));
|
||||
assert.equal(result.source.url, urls[1]);
|
||||
});
|
||||
}
|
||||
|
||||
test('a response reported as redirected is rejected even when status is 200', async () => {
|
||||
const redirected = response(fixture);
|
||||
Object.defineProperty(redirected, 'redirected', {value: true});
|
||||
const result = await resolveKeys(emptyRelease, async url => url === urls[0] ? redirected : response(secondFixture));
|
||||
assert.equal(result.source.url, urls[1]);
|
||||
assert.equal(result.sha256, hash(secondFixture));
|
||||
});
|
||||
|
||||
test('fetch timeout is bounded even when an upstream ignores AbortSignal', async () => {
|
||||
const visited = [];
|
||||
const result = await resolveKeys(emptyRelease, async url => {
|
||||
visited.push(url);
|
||||
if (url === urls[0]) return new Promise(() => {});
|
||||
return response(fixture);
|
||||
}, {timeoutMs: 20});
|
||||
assert.deepEqual(visited, urls.slice(0, 2));
|
||||
assert.equal(result.source.url, urls[1]);
|
||||
});
|
||||
|
||||
test('timeout includes streamed body and cancels it before falling back', async () => {
|
||||
let cancelled = false;
|
||||
const body = new ReadableStream({start(controller) {controller.enqueue(new TextEncoder().encode(fixture));}, cancel() {cancelled = true;}});
|
||||
const result = await resolveKeys(emptyRelease, async url => url === urls[0] ? new Response(body) : response(secondFixture), {timeoutMs: 20});
|
||||
assert.equal(cancelled, true);
|
||||
assert.equal(result.source.url, urls[1]);
|
||||
});
|
||||
|
||||
test('all failed sources return typed, bounded diagnostics without secret bodies or errors', async () => {
|
||||
const error = await unavailable(async url => {
|
||||
if (url === urls[0]) throw new Error('Credential SECRET network detail');
|
||||
if (url === urls[1]) return new Response('SECRET body', {status: 503});
|
||||
return response('# no keys\n');
|
||||
});
|
||||
assert.deepEqual(error.diagnostics, [{index: 0, code: 'network-error'}, {index: 1, code: 'http-error', status: 503}, {index: 2, code: 'empty'}]);
|
||||
assert.doesNotMatch(JSON.stringify(error), /SECRET|https:/);
|
||||
});
|
||||
|
||||
test('no repository keys and no configured sources is unavailable without fetching', async () => {
|
||||
const error = await unavailable(() => assert.fail('No implicit GitHub or other source'), {}, {...emptyRelease, fallbackSources: []});
|
||||
assert.deepEqual(error.diagnostics, []);
|
||||
});
|
||||
|
||||
test('streamed size is enforced even without or with a misleading Content-Length', async () => {
|
||||
for (const headers of [{}, {'Content-Length': '1'}]) {
|
||||
let cancelled = false;
|
||||
const body = new ReadableStream({start(controller) {controller.enqueue(new Uint8Array(70)); controller.enqueue(new Uint8Array(70));}, cancel() {cancelled = true;}});
|
||||
const error = await unavailable(async () => new Response(body, {headers}), {maxBytes: 128}, {...emptyRelease, fallbackSources: [urls[0]]});
|
||||
assert.equal(error.diagnostics[0].code, 'too-large');
|
||||
assert.equal(cancelled, true);
|
||||
}
|
||||
});
|
||||
|
||||
test('oversized declared length is rejected without reading the body', async () => {
|
||||
const error = await unavailable(async () => new Response(fixture, {headers: {'Content-Length': String(KEY_SOURCE_LIMITS.maxBytes + 1)}}), {}, {...emptyRelease, fallbackSources: [urls[0]]});
|
||||
assert.equal(error.diagnostics[0].code, 'too-large');
|
||||
});
|
||||
|
||||
test('invalid UTF-8 is rejected instead of silently replaced', async () => {
|
||||
const error = await unavailable(async () => new Response(Uint8Array.from([0xc3, 0x28])), {}, {...emptyRelease, fallbackSources: [urls[0]]});
|
||||
assert.equal(error.diagnostics[0].code, 'invalid-utf8');
|
||||
});
|
||||
|
||||
test('changing upstream changes content revision while build revision stays fixed; no stale fallback', async () => {
|
||||
let body = fixture;
|
||||
const fetcher = async () => response(body);
|
||||
const release = {...emptyRelease, fallbackSources: [urls[0]]};
|
||||
const first = await resolveKeys(release, fetcher);
|
||||
body = secondFixture;
|
||||
const second = await resolveKeys(release, fetcher);
|
||||
assert.notEqual(first.keyRevision, second.keyRevision);
|
||||
assert.equal(first.revision, second.revision);
|
||||
body = '';
|
||||
await assert.rejects(resolveKeys(release, fetcher), FallbackUnavailableError);
|
||||
});
|
||||
|
||||
test('octet-stream responses are accepted only after strict key parsing', async () => {
|
||||
const result = await resolveKeys(emptyRelease, async () => new Response(fixture, {headers: {'Content-Type': 'application/octet-stream'}}));
|
||||
assert.equal(result.keyCount, 1);
|
||||
});
|
||||
|
||||
test('sources are trusted HTTPS config only, bounded, without credentials or fragments', () => {
|
||||
assert.deepEqual(validateFallbackSources(urls), urls);
|
||||
assert.deepEqual(validateFallbackSources(), []);
|
||||
for (const invalid of [null, {}, new Array(6).fill(urls[0]), ['http://example.org/key'], ['https://user:[email protected]/key'], ['https://example.org/key#fragment'], ['https://example.org/key#'], ['https://example.org/key\n'], ['data:text/plain,key'], ['//example.org/key']]) assert.throws(() => validateFallbackSources(invalid));
|
||||
});
|
||||
|
||||
test('Unicode comments and CRLF are canonicalized, with a known SHA256 fingerprint', async () => {
|
||||
const input = fixture.trim().split(/\s+/).slice(0, 2).join('\t') + '\t 家用服务器 🔐\t张三\r\n';
|
||||
const [key] = await parsePublicKeys(input);
|
||||
assert.equal(key.label, '家用服务器 🔐 张三');
|
||||
assert.equal(key.fingerprint, 'SHA256:bbXpuKG6zhzdmnxq256TlqzFBzRl2f6OOg722cYNbU8');
|
||||
assert.equal(key.text, `${fixture.trim().split(/\s+/).slice(0, 2).join(' ')} 家用服务器 🔐 张三`);
|
||||
const bundle = await makeKeyBundle([key], revision, false);
|
||||
assert.equal(bundle.sha256, hash(key.text + '\n'));
|
||||
});
|
||||
|
||||
test('duplicates, options, certificates, private keys, invalid controls, and junk are rejected', async () => {
|
||||
const cert = fixture.replaceAll('ssh-ed25519', '[email protected]');
|
||||
for (const invalid of [fixture + fixture, `restrict ${fixture}`, `command="id" ${fixture}`, cert, '-----BEGIN OPENSSH PRIVATE KEY-----\n', fixture + '<html>oops</html>', fixture.replace('TEST-ONLY', 'bad\u0000comment'), fixture.replace('TEST-ONLY', 'bad\rcomment'), fixture.replace('TEST-ONLY', 'bad\u0085comment'), fixture.replace('TEST-ONLY', 'bad\ud800comment')]) {
|
||||
await assert.rejects(parsePublicKeys(invalid));
|
||||
}
|
||||
});
|
||||
|
||||
test('wrong wire type, truncated fields, trailing data, noncanonical base64, invalid key sizes fail', async () => {
|
||||
const wrongType = keyLine('ssh-rsa', keyData);
|
||||
const truncated = keyLine('ssh-ed25519', keyData.subarray(0, keyData.length - 1));
|
||||
const trailing = keyLine('ssh-ed25519', Buffer.concat([keyData, Buffer.from([0])]));
|
||||
const short = keyLine('ssh-ed25519', wire('ssh-ed25519', Buffer.alloc(31)));
|
||||
const long = keyLine('ssh-ed25519', wire('ssh-ed25519', Buffer.alloc(33)));
|
||||
const badBase64 = 'ssh-ed25519 A===\n';
|
||||
const hugeField = keyLine('ssh-ed25519', Buffer.from([255, 255, 255, 255]));
|
||||
for (const input of [wrongType, truncated, trailing, short, long, badBase64, hugeField]) await assert.rejects(parsePublicKeys(input));
|
||||
});
|
||||
|
||||
test('public key count and line/input size limits are enforced', async () => {
|
||||
await assert.rejects(parsePublicKeys(fixture + secondFixture, {maxKeys: 1}), /Too many/);
|
||||
await assert.rejects(parsePublicKeys(fixture.trim() + 'x'.repeat(8192)), /line exceeds/);
|
||||
await assert.rejects(parsePublicKeys('#'.repeat(KEY_SOURCE_LIMITS.maxBytes + 1)), /size limit/);
|
||||
await assert.rejects(resolveKeys(emptyRelease, () => assert.fail(), {timeoutMs: KEY_SOURCE_LIMITS.timeoutMs + 1}), /limit/);
|
||||
});
|
||||
|
||||
function mpint(bytes) {return bytes[0] & 128 ? Buffer.concat([Buffer.from([0]), bytes]) : bytes;}
|
||||
function rsaLine(jwk, exponent, modulus) {
|
||||
return keyLine('ssh-rsa', wire('ssh-rsa', exponent || mpint(Buffer.from(jwk.e, 'base64url')), modulus || mpint(Buffer.from(jwk.n, 'base64url'))));
|
||||
}
|
||||
test('RSA requires complete positive canonical mpints and at least 2048 bits', async () => {
|
||||
const {publicKey} = generateKeyPairSync('rsa', {modulusLength: 2048});
|
||||
const jwk = publicKey.export({format: 'jwk'});
|
||||
assert.equal((await parsePublicKeys(rsaLine(jwk)))[0].type, 'ssh-rsa');
|
||||
const modulus = mpint(Buffer.from(jwk.n, 'base64url'));
|
||||
for (const [e, n] of [[Buffer.from([0, 3]), modulus], [Buffer.from([128]), modulus], [Buffer.from([2]), modulus], [Buffer.from([1]), modulus], [Buffer.from([3]), Buffer.from([0])], [Buffer.from([3]), Buffer.from([127, 3])], [Buffer.from([3]), modulus.subarray(1)], [Buffer.from([3]), Buffer.concat([Buffer.from([0]), modulus])]]) await assert.rejects(parsePublicKeys(rsaLine(jwk, e, n)));
|
||||
});
|
||||
|
||||
for (const [name, curve] of [['nistp256', 'P-256'], ['nistp384', 'P-384'], ['nistp521', 'P-521']]) {
|
||||
test(`ECDSA ${name} requires a matching curve and a valid point`, async () => {
|
||||
const {publicKey} = generateKeyPairSync('ec', {namedCurve: curve});
|
||||
const jwk = publicKey.export({format: 'jwk'});
|
||||
const type = `ecdsa-sha2-${name}`;
|
||||
const point = Buffer.concat([Buffer.from([4]), Buffer.from(jwk.x, 'base64url'), Buffer.from(jwk.y, 'base64url')]);
|
||||
assert.equal((await parsePublicKeys(keyLine(type, wire(type, name, point))))[0].type, type);
|
||||
await assert.rejects(parsePublicKeys(keyLine(type, wire(type, 'wrong', point))));
|
||||
await assert.rejects(parsePublicKeys(keyLine(type, wire(type, name, point.subarray(1)))));
|
||||
const offCurve = Buffer.alloc(point.length); offCurve[0] = 4;
|
||||
await assert.rejects(parsePublicKeys(keyLine(type, wire(type, name, offCurve))), /Invalid ECDSA point/);
|
||||
});
|
||||
}
|
||||
|
||||
test('OpenSSH security-key Ed25519 and ECDSA formats validate applications and trailing data', async () => {
|
||||
const edType = '[email protected]';
|
||||
const publicBytes = keyData.subarray(keyData.length - 32);
|
||||
const ed = keyLine(edType, wire(edType, publicBytes, 'ssh:example'));
|
||||
assert.equal((await parsePublicKeys(ed))[0].type, edType);
|
||||
await assert.rejects(parsePublicKeys(keyLine(edType, wire(edType, publicBytes, ''))));
|
||||
await assert.rejects(parsePublicKeys(keyLine(edType, wire(edType, publicBytes, 'ssh:\u0000'))));
|
||||
await assert.rejects(parsePublicKeys(keyLine(edType, wire(edType, publicBytes))));
|
||||
const {publicKey} = generateKeyPairSync('ec', {namedCurve: 'P-256'});
|
||||
const jwk = publicKey.export({format: 'jwk'});
|
||||
const ecType = '[email protected]';
|
||||
const point = Buffer.concat([Buffer.from([4]), Buffer.from(jwk.x, 'base64url'), Buffer.from(jwk.y, 'base64url')]);
|
||||
assert.equal((await parsePublicKeys(keyLine(ecType, wire(ecType, 'nistp256', point, 'ssh:example'))))[0].type, ecType);
|
||||
});
|
||||
|
||||
test('bundle rejects implicit empty and invalid deployment metadata', async () => {
|
||||
await assert.rejects(makeKeyBundle([], revision, false));
|
||||
await assert.rejects(makeKeyBundle(fixtureKeys, 'not-a-revision', false));
|
||||
await assert.rejects(makeKeyBundle(fixtureKeys, revision, 'yes'));
|
||||
});
|
||||
|
||||
test('fallback URL configuration rejects queries, local/private names, and normalized duplicates', () => {
|
||||
for (const url of ['https://keys.example.org/key?token=secret', 'https://keys.example.org/key?', 'https://localhost/key', 'https://thing.local/key', 'https://metadata.google.internal/key', 'https://home.lan/key', 'https://192.168.1.1/key', 'https://127.1/key', 'https://[::1]/key', 'https://[2001:db8::1]/key', 'https://2130706433/key', 'https://0x7f000001/key', 'https://single-label/key']) assert.throws(() => validateFallbackSources([url]));
|
||||
assert.throws(() => validateFallbackSources(['https://KEYS.example.org:443/key', 'https://keys.example.org/key']), /Duplicate/);
|
||||
assert.deepEqual(validateFallbackSources(['https://KEYS.example.org:443/key']), ['https://keys.example.org/key']);
|
||||
});
|
||||
|
||||
test('public-key parser honors an aborted signal before and during validation', async () => {
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
await assert.rejects(parsePublicKeys(fixture, {signal: controller.signal}), /timeout/);
|
||||
let reads = 0;
|
||||
const signal = {get aborted() {return ++reads >= 3;}};
|
||||
await assert.rejects(parsePublicKeys(fixture + secondFixture, {signal}), /timeout/);
|
||||
});
|
||||
|
||||
test('whole serialized protocol, including metadata, is limited to the client response cap', async () => {
|
||||
const text = 'x'.repeat(KEY_SOURCE_LIMITS.maxBytes - 100);
|
||||
await assert.rejects(makeKeyBundle([{text}], revision, false), /too-large/);
|
||||
const bundle = await makeKeyBundle([{text: 'x'.repeat(KEY_SOURCE_LIMITS.maxBytes - 300)}], revision, false);
|
||||
assert.ok(Buffer.byteLength(bundle.text) <= KEY_SOURCE_LIMITS.maxBytes);
|
||||
});
|
||||
|
||||
test('an upstream fitting the input cap but exceeding the serialized output cap is skipped', async () => {
|
||||
const publicBytes = keyData.subarray(keyData.length - 32);
|
||||
const lines = Array.from({length: 128}, (_, index) => {
|
||||
const bytes = Buffer.from(publicBytes); bytes[31] ^= index;
|
||||
const prefix = keyLine('ssh-ed25519', wire('ssh-ed25519', bytes)).trim() + ' ';
|
||||
return prefix + 'x'.repeat(8190 - prefix.length) + '\n';
|
||||
}).join('');
|
||||
assert.ok(Buffer.byteLength(lines) <= KEY_SOURCE_LIMITS.maxBytes);
|
||||
const error = await unavailable(async () => response(lines), {}, {...emptyRelease, fallbackSources: [urls[0]]});
|
||||
assert.equal(error.diagnostics[0].code, 'too-large');
|
||||
const result = await resolveKeys(emptyRelease, async url => response(url === urls[0] ? lines : fixture));
|
||||
assert.equal(result.source.url, urls[1]);
|
||||
assert.equal(result.sha256, hash(fixture));
|
||||
});
|
||||
|
||||
test('ordinary private-key advice in comments is allowed, while real private formats are rejected anywhere', async () => {
|
||||
const advice = '# Do not copy private keys into this repository.\n';
|
||||
assert.deepEqual(await parsePublicKeys(advice), []);
|
||||
const publicWithComment = fixture.trim().split(/\s+/).slice(0, 2).join(' ') + ' private key remains on my laptop\n';
|
||||
assert.equal((await parsePublicKeys(advice + publicWithComment))[0].label, 'private key remains on my laptop');
|
||||
for (const marker of ['-----BEGIN OPENSSH PRIVATE KEY-----', '-----BEGIN RSA PRIVATE KEY-----', '-----BEGIN EC PRIVATE KEY-----', '-----BEGIN PRIVATE KEY-----', '-----BEGIN ENCRYPTED PRIVATE KEY-----', 'PuTTY-User-Key-File-3: ssh-ed25519']) {
|
||||
await assert.rejects(parsePublicKeys('# ' + marker + '\n' + fixture), /Private keys are never accepted/);
|
||||
await assert.rejects(parsePublicKeys(publicWithComment.trim() + ' ' + marker + '\n'), /Private keys are never accepted/);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const root = fileURLToPath(new URL('..', import.meta.url));
|
||||
const candidates = [['POSIX sh', '/bin/sh'], ['bash POSIX mode', 'bash --posix'], ['BusyBox ash', 'busybox sh']];
|
||||
for (const [label, shell] of candidates) {
|
||||
const executable = shell.split(' ')[0];
|
||||
const available = spawnSync(executable, executable === 'busybox' ? ['sh', '-c', ':'] : ['-c', ':']).status === 0;
|
||||
test(`isolated installer regression suite (${label})`, { skip: !available }, () => {
|
||||
const result = spawnSync('python3', ['tests/shell.test.py'], {
|
||||
cwd: root, encoding: 'utf8', timeout: 90_000,
|
||||
env: { ...process.env, CONF_SSHD_TEST_SHELL: shell },
|
||||
});
|
||||
assert.equal(result.error, undefined, result.error?.message);
|
||||
assert.equal(result.status, 0, result.stdout + result.stderr);
|
||||
});
|
||||
}
|
||||
test('ShellCheck POSIX diagnostics', { skip: spawnSync('shellcheck', ['--version']).status !== 0 }, () => {
|
||||
const result = spawnSync('shellcheck', ['--shell=sh', 'conf-sshd.sh'], { cwd: root, encoding: 'utf8' });
|
||||
assert.equal(result.status, 0, result.stdout + result.stderr);
|
||||
});
|
||||
@@ -0,0 +1,669 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Installer integration tests. Every run uses a private HOME and mocked network/cron.
|
||||
No SSH service, real account, real network, package manager, or production key is used.
|
||||
"""
|
||||
import hashlib
|
||||
import base64
|
||||
import struct
|
||||
import signal
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import shlex
|
||||
import subprocess
|
||||
import tempfile
|
||||
import time
|
||||
import unittest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
SOURCE = (ROOT / 'conf-sshd.sh').read_text()
|
||||
SHELL = shlex.split(os.environ.get('CONF_SSHD_TEST_SHELL', '/bin/sh'))
|
||||
ORIGIN = 'https://keys.example.test'
|
||||
SCRIPT = SOURCE.replace('@@BASE_URL@@', ORIGIN).replace('@@DEFAULT_CRON@@', '*/15 * * * *')
|
||||
KEY = (ROOT / 'tests/fixtures/keys.pub').read_bytes().strip() + b'\n'
|
||||
BEGIN = b'# --- BEGIN MANAGED BY CONF-SSHD SCRIPT ---\n'
|
||||
END = b'# --- END MANAGED BY CONF-SSHD SCRIPT ---\n'
|
||||
CB = '# --- BEGIN CRON MANAGED BY CONF-SSHD SCRIPT ---\n'
|
||||
CE = '# --- END CRON MANAGED BY CONF-SSHD SCRIPT ---\n'
|
||||
|
||||
|
||||
def response(payload=KEY, allow='no', count=None):
|
||||
count = len(payload.splitlines()) if count is None else count
|
||||
return (f'# conf-sshd-v1\n# revision {"a" * 40}\n# key-count {count}\n'
|
||||
f'# allow-empty {allow}\n# sha256 {hashlib.sha256(payload).hexdigest()}\n'
|
||||
'# end-metadata\n').encode() + payload
|
||||
|
||||
|
||||
class ShellTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.TemporaryDirectory(prefix='conf-sshd-tests-')
|
||||
self.root = Path(self.tmp.name)
|
||||
self.home = self.root / "home with 'quote"
|
||||
self.home.mkdir(mode=0o700)
|
||||
self.bin = self.root / 'bin'
|
||||
self.bin.mkdir(mode=0o700)
|
||||
self.script = self.root / 'installer.sh'
|
||||
self.script.write_text(SCRIPT)
|
||||
self.payload = self.root / 'response'
|
||||
self.payload.write_bytes(response())
|
||||
self.update = self.root / 'update'
|
||||
self.update.write_text(SCRIPT)
|
||||
self.cron = self.root / 'crontab'
|
||||
self.cron.write_text('[email protected]\n17 * * * * echo untouched\n')
|
||||
self.calls = self.root / 'calls'
|
||||
self.calls.write_text('')
|
||||
self.env = dict(os.environ, HOME=str(self.home), PATH=f'{self.bin}:/usr/bin:/bin',
|
||||
TEST_ROOT=str(self.root), TEST_RESPONSE=str(self.payload),
|
||||
TEST_UPDATE=str(self.update), TEST_CRONTAB=str(self.cron),
|
||||
TEST_CALLS=str(self.calls))
|
||||
self.mock('curl', '''#!/bin/sh
|
||||
printf 'curl %s\\n' "$*" >> "$TEST_CALLS"
|
||||
[ "${TEST_CURL_ERROR:-0}" = 0 ] || exit "$TEST_CURL_ERROR"
|
||||
if [ "${TEST_CURL_WAIT:-0}" = 1 ]; then
|
||||
: > "$TEST_ROOT/waiting"
|
||||
while [ ! -f "$TEST_ROOT/release" ]; do sleep 0.02; done
|
||||
fi
|
||||
out=
|
||||
while [ "$#" -gt 0 ]; do
|
||||
case "$1" in --output) shift; out=$1 ;; https://*) url=$1 ;; esac
|
||||
shift
|
||||
done
|
||||
case $url in */ssh.keys) cp "$TEST_RESPONSE" "$out" ;; */script.sh) cp "$TEST_UPDATE" "$out" ;; *) exit 99 ;; esac
|
||||
''')
|
||||
self.mock('crontab', '''#!/bin/sh
|
||||
printf 'crontab %s\\n' "$*" >> "$TEST_CALLS"
|
||||
[ "${TEST_CRON_ERROR:-0}" = 0 ] || { echo 'permission denied' >&2; exit 1; }
|
||||
if [ "$1" = -l ]; then
|
||||
if [ -f "$TEST_CRONTAB" ]; then cat "$TEST_CRONTAB"; else echo "no crontab for test-user" >&2; exit 1; fi
|
||||
else
|
||||
[ "${TEST_CRON_WRITE_ERROR:-0}" = 0 ] || exit 1
|
||||
cp "$1" "$TEST_CRONTAB"
|
||||
fi
|
||||
''')
|
||||
for cmd in ('sshd', 'apt-get', 'yum', 'dnf', 'systemctl', 'service'):
|
||||
self.mock(cmd, f'#!/bin/sh\necho "UNEXPECTED {cmd} $*" >> "$TEST_CALLS"\nexit 98\n')
|
||||
|
||||
def tearDown(self):
|
||||
self.tmp.cleanup()
|
||||
|
||||
def mock(self, name, text):
|
||||
path = self.bin / name
|
||||
path.write_text(text)
|
||||
path.chmod(0o700)
|
||||
return path
|
||||
|
||||
def run_script(self, *args, ok=True, env=None, script=None):
|
||||
completed = subprocess.run([*SHELL, str(script or self.script), *args],
|
||||
env=self.env | (env or {}), capture_output=True, text=True,
|
||||
timeout=15)
|
||||
if ok:
|
||||
self.assertEqual(completed.returncode, 0, completed.stdout + completed.stderr)
|
||||
else:
|
||||
self.assertNotEqual(completed.returncode, 0, completed.stdout + completed.stderr)
|
||||
self.assertNotIn('UNEXPECTED', self.calls.read_text())
|
||||
return completed
|
||||
|
||||
@property
|
||||
def auth(self):
|
||||
return self.home / '.ssh/authorized_keys'
|
||||
|
||||
@property
|
||||
def installed(self):
|
||||
return self.home / '.conf-sshd/conf-sshd.sh'
|
||||
|
||||
def old_auth(self, data=b'# unmanaged\n'):
|
||||
self.auth.parent.mkdir(mode=0o700, exist_ok=True)
|
||||
self.auth.write_bytes(data)
|
||||
self.auth.chmod(0o600)
|
||||
return data
|
||||
|
||||
def assert_clean(self):
|
||||
self.assertFalse((self.home / '.conf-sshd/lock').exists())
|
||||
self.assertEqual(list(self.home.glob('**/.conf-sshd.*')), [])
|
||||
|
||||
def test_posix_parse_and_build_tokens_unique(self):
|
||||
self.assertEqual(SOURCE.count('@@BASE_URL@@'), 1)
|
||||
self.assertEqual(SOURCE.count('@@DEFAULT_CRON@@'), 1)
|
||||
subprocess.run([*SHELL, '-n', str(self.script)], check=True)
|
||||
|
||||
def test_default_sync_has_no_system_or_cron_side_effects(self):
|
||||
old_cron = self.cron.read_bytes()
|
||||
self.run_script()
|
||||
self.assertEqual(self.auth.read_bytes(), BEGIN + KEY + END)
|
||||
self.assertEqual(self.cron.read_bytes(), old_cron)
|
||||
self.assertFalse(self.installed.exists())
|
||||
self.assertEqual(self.auth.stat().st_mode & 0o777, 0o600)
|
||||
self.assertEqual(self.auth.parent.stat().st_mode & 0o777, 0o700)
|
||||
self.assert_clean()
|
||||
|
||||
def test_unmanaged_no_final_newline_is_not_lost(self):
|
||||
original = self.old_auth(b'command="echo keep",no-pty ssh-ed25519 LOCAL user')
|
||||
self.run_script('-o')
|
||||
self.assertEqual(self.auth.read_bytes(), original + b'\n' + BEGIN + KEY + END)
|
||||
self.assertEqual(Path(str(self.auth) + '.conf-sshd.bak').read_bytes(), original)
|
||||
|
||||
def test_existing_block_preserves_unmanaged_bytes_and_final_no_newline(self):
|
||||
prefix = b'# first\nssh-rsa unmanaged-prefix\n'
|
||||
suffix = b'command="keep" ssh-rsa unmanaged-suffix'
|
||||
self.old_auth(prefix + BEGIN + b'old-managed\n' + END + suffix)
|
||||
self.run_script('-o')
|
||||
self.assertEqual(self.auth.read_bytes(), prefix + BEGIN + KEY + END + suffix)
|
||||
|
||||
def test_idempotent_sync_and_bounded_backup(self):
|
||||
original = self.old_auth()
|
||||
self.run_script('-o')
|
||||
before = self.auth.read_bytes()
|
||||
self.run_script('-o')
|
||||
self.assertEqual(self.auth.read_bytes(), before)
|
||||
self.assertEqual(Path(str(self.auth) + '.conf-sshd.bak').read_bytes(), original)
|
||||
self.assertEqual(len(list(self.auth.parent.glob('*.bak'))), 1)
|
||||
self.assert_clean()
|
||||
|
||||
def test_marker_corruption_fails_closed(self):
|
||||
cases = [BEGIN, END, END + BEGIN, BEGIN + BEGIN + END, BEGIN + END + BEGIN + END,
|
||||
BEGIN[:-1] + b' extra\n' + END, BEGIN + END[:-1] + b' extra\n']
|
||||
for original in cases:
|
||||
with self.subTest(original=original):
|
||||
self.old_auth(original)
|
||||
self.run_script('-o', ok=False)
|
||||
self.assertEqual(self.auth.read_bytes(), original)
|
||||
self.assert_clean()
|
||||
|
||||
def test_http_errors_keep_last_good_and_cleanup(self):
|
||||
original = self.old_auth()
|
||||
for error in ('22', '28', '6', '60'):
|
||||
with self.subTest(error=error):
|
||||
self.run_script('-o', ok=False, env={'TEST_CURL_ERROR': error})
|
||||
self.assertEqual(self.auth.read_bytes(), original)
|
||||
self.assert_clean()
|
||||
calls = self.calls.read_text()
|
||||
for option in ('--fail', '--proto =https', '--connect-timeout 10', '--max-time 30', '--retry 2'):
|
||||
self.assertIn(option, calls)
|
||||
self.assertNotIn('--location', calls)
|
||||
|
||||
def test_empty_set_requires_explicit_permission(self):
|
||||
original = self.old_auth(BEGIN + KEY + END + b'# keep\n')
|
||||
self.payload.write_bytes(response(b''))
|
||||
self.run_script('-o', ok=False)
|
||||
self.assertEqual(self.auth.read_bytes(), original)
|
||||
self.payload.write_bytes(response(b'', allow='yes'))
|
||||
self.run_script('-o')
|
||||
self.assertEqual(self.auth.read_bytes(), BEGIN + END + b'# keep\n')
|
||||
|
||||
def test_metadata_and_payload_corruption(self):
|
||||
original = self.old_auth()
|
||||
good = response()
|
||||
cases = [good[:-1], good.replace(b'# conf-sshd-v1', b'# conf-sshd-v2'),
|
||||
good.replace(b'# key-count 1', b'# key-count 01'),
|
||||
good.replace(b'# key-count 1', b'# key-count 2'),
|
||||
good.replace(b'# allow-empty no', b'# allow-empty maybe'),
|
||||
good.replace(b'# revision ' + b'a' * 40, b'# revision ' + b'a' * 39),
|
||||
good.replace(b'# end-metadata', b'# extra\n# end-metadata'),
|
||||
good.replace(b'AAAAC', b'BAAAC'), good.replace(b'\n', b'\r\n'),
|
||||
response(KEY + b'\x00\n'), response(b'\n')]
|
||||
for value in cases:
|
||||
with self.subTest(value=value[:70]):
|
||||
self.payload.write_bytes(value)
|
||||
self.run_script('-o', ok=False)
|
||||
self.assertEqual(self.auth.read_bytes(), original)
|
||||
self.assert_clean()
|
||||
|
||||
def test_duplicate_option_malformed_and_unsupported_keys(self):
|
||||
original = self.old_auth()
|
||||
no_comment = b' '.join(KEY.split()[:2])
|
||||
cases = [KEY + KEY, KEY + no_comment + b' changed-comment\n',
|
||||
b'command="id" ' + KEY, b'ssh-rsa AAAA bad\n',
|
||||
KEY.replace(b'ssh-ed25519 ', b'ssh-rsa '),
|
||||
KEY.replace(b' ', b' ', 1), KEY.rstrip() + b' \n',
|
||||
KEY.replace(b'ssh-ed25519', b'ssh-dss'),
|
||||
KEY.replace(b'ssh-ed25519', b'[email protected]')]
|
||||
for value in cases:
|
||||
with self.subTest(value=value[:70]):
|
||||
self.payload.write_bytes(response(value))
|
||||
self.run_script('-o', ok=False)
|
||||
self.assertEqual(self.auth.read_bytes(), original)
|
||||
self.assert_clean()
|
||||
|
||||
def test_printable_comments_can_contain_spaces_and_symbols(self):
|
||||
payload = b' '.join(KEY.split()[:2]) + ' 中文 comment with spaces $;# ! \'"\n'.encode()
|
||||
self.payload.write_bytes(response(payload))
|
||||
self.run_script('-o')
|
||||
self.assertEqual(self.auth.read_bytes(), BEGIN + payload + END)
|
||||
|
||||
def test_synthetic_rsa_and_ecdsa_and_weak_rsa(self):
|
||||
for kind, bits in [('rsa', 2048), ('ecdsa', 256), ('ecdsa', 384), ('ecdsa', 521), ('rsa', 1024)]:
|
||||
with self.subTest(kind=kind, bits=bits):
|
||||
keyfile = self.root / 'ephemeral-test-key'
|
||||
subprocess.run(['ssh-keygen', '-q', '-t', kind, '-b', str(bits), '-N', '', '-f', str(keyfile)], check=True)
|
||||
payload = keyfile.with_suffix('.pub').read_bytes()
|
||||
keyfile.unlink() # Never retained or installed; fixture public key only.
|
||||
keyfile.with_suffix('.pub').unlink()
|
||||
self.payload.write_bytes(response(payload))
|
||||
self.run_script('-o', ok=(bits != 1024))
|
||||
self.assert_clean()
|
||||
|
||||
def test_symlink_auth_ssh_home_and_backup_are_rejected(self):
|
||||
outside = self.root / 'outside'
|
||||
outside.write_bytes(b'untouched')
|
||||
self.auth.parent.mkdir(mode=0o700)
|
||||
self.auth.symlink_to(outside)
|
||||
self.run_script('-o', ok=False)
|
||||
self.assertEqual(outside.read_bytes(), b'untouched')
|
||||
self.auth.unlink()
|
||||
self.old_auth()
|
||||
backup = Path(str(self.auth) + '.conf-sshd.bak')
|
||||
backup.symlink_to(outside)
|
||||
original = self.auth.read_bytes()
|
||||
self.run_script('-o', ok=False)
|
||||
self.assertEqual(self.auth.read_bytes(), original)
|
||||
backup.unlink()
|
||||
self.auth.unlink()
|
||||
self.auth.parent.rmdir()
|
||||
self.auth.parent.symlink_to(self.root, target_is_directory=True)
|
||||
self.run_script('-o', ok=False)
|
||||
linked_home = self.root / 'linked-home'
|
||||
linked_home.symlink_to(self.home, target_is_directory=True)
|
||||
self.run_script('-o', ok=False, env={'HOME': str(linked_home)})
|
||||
|
||||
def test_group_writable_and_hardlinked_files_are_rejected(self):
|
||||
original = self.old_auth()
|
||||
self.auth.chmod(0o666)
|
||||
self.run_script('-o', ok=False)
|
||||
self.assertEqual(self.auth.read_bytes(), original)
|
||||
self.auth.chmod(0o600)
|
||||
os.link(self.auth, self.root / 'hardlink')
|
||||
self.run_script('-o', ok=False)
|
||||
self.assertEqual(self.auth.read_bytes(), original)
|
||||
|
||||
def test_unicode_home_path_is_safe(self):
|
||||
unicode_home = self.root / '测试 home'
|
||||
unicode_home.mkdir(mode=0o700)
|
||||
self.run_script('-c', env={'HOME': str(unicode_home)})
|
||||
self.assertEqual((unicode_home / '.ssh/authorized_keys').read_bytes(), BEGIN + KEY + END)
|
||||
self.assertIn(str(unicode_home), self.cron.read_text())
|
||||
|
||||
def test_home_control_characters_rejected_before_writes(self):
|
||||
for suffix in ['\ninside', '\n', '\r', '\t', '\\literal']:
|
||||
with self.subTest(suffix=repr(suffix)):
|
||||
unsafe_home = self.root / ('unsafe' + suffix)
|
||||
unsafe_home.mkdir(mode=0o700)
|
||||
self.run_script('-o', ok=False, env={'HOME': str(unsafe_home)})
|
||||
self.assertEqual(list(unsafe_home.iterdir()), [])
|
||||
|
||||
def test_wrong_owner_is_rejected(self):
|
||||
real_stat = shutil.which('stat')
|
||||
self.mock('stat', f'''#!/bin/sh
|
||||
"{real_stat}" "$@" | sed 's/^[0-9][0-9]*/99999/'
|
||||
''')
|
||||
self.run_script('-o', ok=False)
|
||||
self.assertFalse(self.auth.exists())
|
||||
|
||||
def test_cron_bare_default_custom_preservation_and_safe_quotes(self):
|
||||
unrelated = self.cron.read_text()
|
||||
self.run_script('-c')
|
||||
text = self.cron.read_text()
|
||||
self.assertTrue(text.startswith(unrelated))
|
||||
self.assertIn("*/15 * * * * /bin/sh '", text)
|
||||
self.assertIn("'\\''quote/.conf-sshd/conf-sshd.sh' -o", text)
|
||||
self.assertNotIn('-u', text)
|
||||
self.assertEqual(self.installed.stat().st_mode & 0o777, 0o700)
|
||||
text = text.replace('*/15 * * * *', '7 1 * * 2')
|
||||
self.cron.write_text(text)
|
||||
self.run_script('-c')
|
||||
self.assertEqual(self.cron.read_text(), text)
|
||||
self.run_script('-c', '*/10 2-8/2 * 1,6 0-7')
|
||||
self.assertIn('*/10 2-8/2 * 1,6 0-7 /bin/sh', self.cron.read_text())
|
||||
self.run_script('-c', 'false', env={'TEST_CURL_ERROR': '22'})
|
||||
self.assertEqual(self.cron.read_text(), unrelated)
|
||||
self.assert_clean()
|
||||
|
||||
def test_cron_legacy_migrates_custom_schedule_only_exact_target(self):
|
||||
unrelated = '5 * * * * /bin/sh /other/conf-sshd.sh -o\n# conf-sshd.sh note\n'
|
||||
legacy = f'3 4 * * 1 "/bin/bash {self.installed} -o" >> {self.home}/.conf-sshd/run.log 2>&1\n'
|
||||
self.cron.write_text(unrelated + legacy)
|
||||
self.run_script('-c')
|
||||
text = self.cron.read_text()
|
||||
self.assertTrue(text.startswith(unrelated))
|
||||
self.assertIn('3 4 * * 1 /bin/sh', text)
|
||||
self.assertNotIn('"/bin/bash', text)
|
||||
self.assertEqual(text.count(CB), 1)
|
||||
|
||||
def test_cron_errors_and_bad_managed_markers_do_not_clobber(self):
|
||||
cases = [CB, CE, CB + CE, CB + '* * * * * unknown command\n' + CE,
|
||||
CB + CE + CB + CE]
|
||||
for text in cases:
|
||||
with self.subTest(text=text):
|
||||
self.cron.write_text(text)
|
||||
self.run_script('-c', ok=False)
|
||||
self.assertEqual(self.cron.read_text(), text)
|
||||
self.assertFalse(self.auth.exists())
|
||||
self.assert_clean()
|
||||
original = self.cron.read_bytes()
|
||||
self.run_script('-c', ok=False, env={'TEST_CRON_ERROR': '1'})
|
||||
self.assertEqual(self.cron.read_bytes(), original)
|
||||
|
||||
def test_cron_no_existing_crontab(self):
|
||||
self.cron.unlink()
|
||||
self.run_script('-c')
|
||||
self.assertIn('*/15 * * * *', self.cron.read_text())
|
||||
|
||||
def test_cron_expression_rejects_injection_ranges_and_missing_fields(self):
|
||||
cases = ['* * * *', '60 * * * *', '* 24 * * *', '* * 0 * *', '* * * 13 *',
|
||||
'* * * * 8', '*/0 * * * *', '10-2 * * * *', '@daily',
|
||||
'* * * * *; touch /tmp/bad', '* * * * *\n* * * * *', '* * * * *%id']
|
||||
original = self.cron.read_bytes()
|
||||
for value in cases:
|
||||
with self.subTest(value=value):
|
||||
self.run_script('-c', value, ok=False)
|
||||
self.assertEqual(self.cron.read_bytes(), original)
|
||||
self.assertFalse(self.auth.exists())
|
||||
|
||||
def test_argument_conflicts_missing_values_unknown(self):
|
||||
for args in [('-u', '-o'), ('-o', '-c'), ('-p',), ('-p', 'maybe'),
|
||||
('--uninstall', '-c'), ('-c', '-c'), ('--unknown',),
|
||||
('-c', ''), ('--dry-run', '--dry-run'), ('--status', '-c')]:
|
||||
with self.subTest(args=args):
|
||||
self.run_script(*args, ok=False)
|
||||
self.assertFalse(self.auth.exists())
|
||||
|
||||
def test_dry_run_no_persistent_changes(self):
|
||||
old_cron = self.cron.read_bytes()
|
||||
self.run_script('-c', '--dry-run')
|
||||
self.assertFalse(self.auth.exists())
|
||||
self.assertFalse(self.installed.exists())
|
||||
self.assertEqual(self.cron.read_bytes(), old_cron)
|
||||
self.assertFalse((self.home / '.ssh').exists())
|
||||
self.assertFalse((self.home / '.conf-sshd').exists())
|
||||
self.old_auth()
|
||||
state = self.home / '.conf-sshd'
|
||||
state.mkdir(mode=0o755)
|
||||
self.run_script('-o', '--dry-run')
|
||||
self.assertEqual(state.stat().st_mode & 0o777, 0o755)
|
||||
self.assertEqual(self.auth.read_bytes(), b'# unmanaged\n')
|
||||
|
||||
def test_concurrent_run_refuses_and_signal_cleanup(self):
|
||||
env = self.env | {'TEST_CURL_WAIT': '1'}
|
||||
first = subprocess.Popen([*SHELL, str(self.script), '-o'], env=env,
|
||||
stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
|
||||
try:
|
||||
deadline = time.monotonic() + 5
|
||||
while not (self.root / 'waiting').exists() and time.monotonic() < deadline:
|
||||
time.sleep(0.02)
|
||||
self.assertTrue((self.root / 'waiting').exists())
|
||||
completed = self.run_script('-o', ok=False)
|
||||
self.assertIn('Another run holds', completed.stderr)
|
||||
(self.root / 'release').touch()
|
||||
stdout, stderr = first.communicate(timeout=10)
|
||||
self.assertEqual(first.returncode, 0, stdout + stderr)
|
||||
finally:
|
||||
if first.poll() is None:
|
||||
(self.root / 'release').touch()
|
||||
first.terminate()
|
||||
first.communicate(timeout=10)
|
||||
self.assert_clean()
|
||||
# A stale/foreign lock is never auto-removed.
|
||||
lock = self.home / '.conf-sshd/lock'
|
||||
lock.mkdir()
|
||||
(lock / 'pid').write_text('999999')
|
||||
self.run_script('-o', ok=False)
|
||||
self.assertTrue(lock.exists())
|
||||
|
||||
def test_update_self_is_explicit_atomic_and_fail_closed(self):
|
||||
self.run_script('-c')
|
||||
original = self.installed.read_bytes()
|
||||
keys = self.auth.read_bytes()
|
||||
cron = self.cron.read_bytes()
|
||||
cases = ['#!/bin/sh\necho invalid\n', SCRIPT + '\nif\n',
|
||||
SCRIPT.replace('# conf-sshd-script-v2', '# other'),
|
||||
SCRIPT.replace(ORIGIN, 'https://different.example.test'),
|
||||
SCRIPT + '\n# conf-sshd-script-v2\n', SCRIPT + '\n# @@UNBUILT@@\n']
|
||||
for text in cases:
|
||||
with self.subTest(text=text[:40]):
|
||||
self.update.write_text(text)
|
||||
self.run_script('-u', ok=False)
|
||||
self.assertEqual(self.installed.read_bytes(), original)
|
||||
self.assert_clean()
|
||||
self.run_script('-u', ok=False, env={'TEST_CURL_ERROR': '22'})
|
||||
self.update.write_text(SCRIPT + '\n# new release\n')
|
||||
self.run_script('-u')
|
||||
self.assertEqual(self.installed.read_bytes(), self.update.read_bytes())
|
||||
self.assertEqual(Path(str(self.installed) + '.conf-sshd.bak').read_bytes(), original)
|
||||
self.assertEqual(self.auth.read_bytes(), keys)
|
||||
self.assertEqual(self.cron.read_bytes(), cron)
|
||||
self.assert_clean()
|
||||
|
||||
def test_update_without_install_fails(self):
|
||||
self.run_script('-u', ok=False)
|
||||
self.assertFalse(self.installed.exists())
|
||||
|
||||
def test_uninstall_preserves_keys_unrelated_files_and_cron(self):
|
||||
original_cron = self.cron.read_text()
|
||||
self.run_script('-c')
|
||||
original_keys = self.auth.read_bytes()
|
||||
keep = self.home / '.conf-sshd/keep-user-file'
|
||||
keep.write_text('keep')
|
||||
self.run_script('--uninstall')
|
||||
self.assertEqual(self.auth.read_bytes(), original_keys)
|
||||
self.assertEqual(self.cron.read_text(), original_cron)
|
||||
self.assertFalse(self.installed.exists())
|
||||
self.assertEqual(keep.read_text(), 'keep')
|
||||
self.assert_clean()
|
||||
|
||||
def test_root_policy_requires_root_without_touching_sshd(self):
|
||||
if os.getuid() == 0:
|
||||
self.skipTest('Non-root refusal test requires an unprivileged test runner.')
|
||||
result = self.run_script('-p', 'no', ok=False)
|
||||
self.assertIn('requires root', result.stderr)
|
||||
|
||||
def mock_root_config(self, text='Port 2222\n# preserve comment\nPermitRootLogin no\n'):
|
||||
config_dir = self.root / 'fake-etc-ssh'
|
||||
config_dir.mkdir(mode=0o700, exist_ok=True)
|
||||
config = config_dir / 'sshd_config'
|
||||
config.write_text(text)
|
||||
config.chmod(0o600)
|
||||
# Rewrite fixture copy only; the shipped script has no environment override.
|
||||
fixture = SCRIPT.replace('config=/etc/ssh/sshd_config', f"config='{config}'")
|
||||
fixture = fixture.replace('check_path /etc/ssh dir', f"check_path '{config_dir}' dir")
|
||||
fixture = fixture.replace('new_temp /etc/ssh;', f"new_temp '{config_dir}';")
|
||||
self.assertNotIn('/etc/ssh', fixture, 'All root-policy paths must stay inside test fixtures')
|
||||
self.script.write_text(fixture)
|
||||
self.mock('id', '#!/bin/sh\nprintf "0\\n"\n')
|
||||
real_stat = shutil.which('stat')
|
||||
self.mock('stat', f'#!/bin/sh\n"{real_stat}" "$@" | sed \'s/^[0-9][0-9]*/0/\'\n')
|
||||
self.mock('sshd', '''#!/bin/sh
|
||||
printf 'sshd %s\\n' "$*" >> "$TEST_CALLS"
|
||||
operation=$1
|
||||
shift
|
||||
[ "$1" = -f ] || exit 90
|
||||
config=$2
|
||||
[ "${TEST_SSHD_FAIL:-}" != "$operation" ] || exit 1
|
||||
if [ "$operation" = -T ]; then
|
||||
if [ "${TEST_SSHD_OVERRIDE:-0}" = 1 ]; then echo 'permitrootlogin no'
|
||||
else awk 'tolower($1)=="permitrootlogin" {print "permitrootlogin " $2; exit}' "$config"; fi
|
||||
fi
|
||||
''')
|
||||
return config
|
||||
|
||||
def test_key_comment_marker_phrase_can_sync_and_revoke(self):
|
||||
payload = b' '.join(KEY.split()[:2]) + b' MANAGED BY CONF-SSHD SCRIPT\n'
|
||||
self.payload.write_bytes(response(payload))
|
||||
self.run_script('-o')
|
||||
self.run_script('-o')
|
||||
self.payload.write_bytes(response(b'', allow='yes'))
|
||||
self.run_script('-o')
|
||||
self.assertEqual(self.auth.read_bytes(), BEGIN + END)
|
||||
|
||||
def test_external_authorized_keys_edit_during_backup_is_not_overwritten(self):
|
||||
original = self.old_auth(b'# original user key\n')
|
||||
real_cp = shutil.which('cp')
|
||||
self.mock('cp', f'''#!/bin/sh
|
||||
"{real_cp}" "$@" || exit $?
|
||||
case "$1:$2" in "$HOME/.ssh/authorized_keys:$HOME/.ssh/.conf-sshd."*)
|
||||
printf '# concurrent manual key\\n' >> "$HOME/.ssh/authorized_keys" ;;
|
||||
esac
|
||||
''')
|
||||
result = self.run_script('-o', ok=False)
|
||||
self.assertIn('changed concurrently', result.stderr)
|
||||
self.assertEqual(self.auth.read_bytes(), original + b'# concurrent manual key\n')
|
||||
self.assertFalse(Path(str(self.auth) + '.conf-sshd.bak').exists())
|
||||
self.assert_clean()
|
||||
|
||||
def test_new_authorized_keys_created_concurrently_is_preserved(self):
|
||||
real_cat = shutil.which('cat')
|
||||
self.mock('cat', f'''#!/bin/sh
|
||||
"{real_cat}" "$@" || exit $?
|
||||
if [ "$#" = 1 ] && [ -f "$1" ] && grep -q '^ssh-ed25519 ' "$1"; then
|
||||
if [ ! -e "$HOME/.ssh/authorized_keys" ]; then
|
||||
printf '# concurrently created user key\\n' > "$HOME/.ssh/authorized_keys"
|
||||
fi
|
||||
fi
|
||||
''')
|
||||
result = self.run_script('-o', ok=False)
|
||||
self.assertIn('appeared concurrently', result.stderr)
|
||||
self.assertEqual(self.auth.read_bytes(), b'# concurrently created user key\n')
|
||||
self.assert_clean()
|
||||
|
||||
def test_piped_install_ignores_same_named_unrelated_cwd_file(self):
|
||||
decoy = self.root / 'sh'
|
||||
decoy.write_text(SCRIPT + '\n# UNTRUSTED CWD DECOY\n')
|
||||
completed = subprocess.run([*SHELL, '-s', '--', '-c'], input=SCRIPT,
|
||||
cwd=self.root, env=self.env, capture_output=True, text=True, timeout=15)
|
||||
self.assertEqual(completed.returncode, 0, completed.stdout + completed.stderr)
|
||||
self.assertNotIn('UNTRUSTED CWD DECOY', self.installed.read_text())
|
||||
self.assertEqual(self.installed.read_text(), SCRIPT)
|
||||
self.assertIn('/script.sh', self.calls.read_text())
|
||||
self.assert_clean()
|
||||
|
||||
def test_root_policy_staged_validation_backup_no_reload(self):
|
||||
config = self.mock_root_config('Port 2222\n# preserve comment\nPermitRootLogin=no\n"PermitRootLogin" no\n')
|
||||
original = config.read_bytes()
|
||||
self.run_script('-p', 'no')
|
||||
self.assertTrue(config.read_text().startswith('PermitRootLogin prohibit-password\n'))
|
||||
self.assertIn('Port 2222\n# preserve comment\n', config.read_text())
|
||||
self.assertEqual(Path(str(config) + '.conf-sshd.bak').read_bytes(), original)
|
||||
self.assertEqual(config.read_text().count('PermitRootLogin'), 1)
|
||||
self.run_script('-p', 'yes')
|
||||
self.assertTrue(config.read_text().startswith('PermitRootLogin yes\n'))
|
||||
self.assertIn('sshd -t -f', self.calls.read_text())
|
||||
self.assertIn('sshd -T -f', self.calls.read_text())
|
||||
self.assertFalse(self.auth.exists())
|
||||
self.assert_clean()
|
||||
self.assertEqual(list(config.parent.glob('.conf-sshd.*')), [])
|
||||
|
||||
def test_root_policy_include_match_invalid_effective_and_dry_run(self):
|
||||
config = self.mock_root_config()
|
||||
for text in ['Include sshd_config.d/*.conf\n', ' Match User test\n',
|
||||
'Port 2222\nINCLUDE other\n', 'mAtCh all\n',
|
||||
'Include=other\n', 'Match=User root\n', '"Include"=other\n',
|
||||
'"Match" User root\n', '"Match"User root\n']:
|
||||
with self.subTest(text=text):
|
||||
config.write_text(text)
|
||||
self.run_script('-p', 'no', ok=False)
|
||||
self.assertEqual(config.read_text(), text)
|
||||
config.write_text('PermitRootLogin no\n# unchanged\n')
|
||||
original = config.read_bytes()
|
||||
for env in [{'TEST_SSHD_FAIL': '-t'}, {'TEST_SSHD_FAIL': '-T'}, {'TEST_SSHD_OVERRIDE': '1'}]:
|
||||
self.run_script('-p', 'yes', ok=False, env=env)
|
||||
self.assertEqual(config.read_bytes(), original)
|
||||
self.assertFalse(Path(str(config) + '.conf-sshd.bak').exists())
|
||||
self.assert_clean()
|
||||
self.run_script('-p', 'yes', '--dry-run')
|
||||
self.assertEqual(config.read_bytes(), original)
|
||||
self.assertEqual(list(config.parent.glob('.conf-sshd.*')), [])
|
||||
|
||||
def test_security_key_public_blob_and_canonical_base64(self):
|
||||
def sshstring(value):
|
||||
return struct.pack('>I', len(value)) + value
|
||||
public = base64.b64decode(KEY.split()[1])[-32:]
|
||||
kind = b'[email protected]'
|
||||
blob = sshstring(kind) + sshstring(public) + sshstring(b'ssh:fixturex')
|
||||
payload = kind + b' ' + base64.b64encode(blob) + b' public-fixture-only\n'
|
||||
self.payload.write_bytes(response(payload))
|
||||
self.run_script('-o')
|
||||
original = self.auth.read_bytes()
|
||||
# Different non-zero padding bits can decode to the same byte string.
|
||||
padded = base64.b64encode(blob)
|
||||
self.assertTrue(padded.endswith(b'='))
|
||||
alphabet = b'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
|
||||
offset = -3 if padded.endswith(b'==') else -2
|
||||
altered = bytearray(padded)
|
||||
altered[offset] = alphabet[alphabet.index(altered[offset]) + 1]
|
||||
invalid = kind + b' ' + bytes(altered) + b' invalid-padding\n'
|
||||
self.payload.write_bytes(response(invalid))
|
||||
self.run_script('-o', ok=False)
|
||||
self.assertEqual(self.auth.read_bytes(), original)
|
||||
# Build an SK-ECDSA public fixture from a transient ordinary ECDSA key.
|
||||
keyfile = self.root / 'ephemeral-ecdsa'
|
||||
subprocess.run(['ssh-keygen', '-q', '-t', 'ecdsa', '-b', '256', '-N', '', '-f', str(keyfile)], check=True)
|
||||
ec = base64.b64decode(keyfile.with_suffix('.pub').read_bytes().split()[1])
|
||||
keyfile.unlink()
|
||||
keyfile.with_suffix('.pub').unlink()
|
||||
kind = b'[email protected]'
|
||||
old_type_len = struct.unpack('>I', ec[:4])[0]
|
||||
blob = sshstring(kind) + ec[4 + old_type_len:] + sshstring(b'ssh:test-only')
|
||||
payload = kind + b' ' + base64.b64encode(blob) + b' synthetic-security-key\n'
|
||||
self.payload.write_bytes(response(payload))
|
||||
self.run_script('-o')
|
||||
|
||||
def test_custom_build_default_and_update_preserve_configuration(self):
|
||||
custom = SCRIPT.replace("DEFAULT_CRON='*/15 * * * *'", "DEFAULT_CRON='8 3 * * *'")
|
||||
self.script.write_text(custom)
|
||||
self.update.write_text(custom)
|
||||
self.run_script('-c')
|
||||
self.assertIn('8 3 * * * /bin/sh', self.cron.read_text())
|
||||
self.update.write_text(custom + '\n# updated\n')
|
||||
self.run_script('-u')
|
||||
self.update.write_text(SCRIPT)
|
||||
self.run_script('-u', ok=False)
|
||||
|
||||
def test_signal_removes_own_lock_and_temporary_files(self):
|
||||
first = subprocess.Popen([*SHELL, str(self.script), '-o'],
|
||||
env=self.env | {'TEST_CURL_WAIT': '1'},
|
||||
stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True,
|
||||
start_new_session=True)
|
||||
try:
|
||||
deadline = time.monotonic() + 5
|
||||
while not (self.root / 'waiting').exists() and time.monotonic() < deadline:
|
||||
time.sleep(0.02)
|
||||
self.assertTrue((self.root / 'waiting').exists())
|
||||
os.killpg(first.pid, signal.SIGTERM)
|
||||
first.communicate(timeout=10)
|
||||
self.assertNotEqual(first.returncode, 0)
|
||||
self.assert_clean()
|
||||
self.assertFalse(self.auth.exists())
|
||||
finally:
|
||||
if first.poll() is None:
|
||||
os.killpg(first.pid, signal.SIGKILL)
|
||||
first.communicate(timeout=10)
|
||||
|
||||
def test_concurrent_external_cron_edit_is_not_overwritten(self):
|
||||
self.mock('crontab', '''#!/bin/sh
|
||||
if [ "$1" = -l ]; then
|
||||
if [ -f "$TEST_ROOT/cron-read" ]; then
|
||||
printf '42 * * * * echo concurrent-editor\\n' >> "$TEST_CRONTAB"
|
||||
fi
|
||||
touch "$TEST_ROOT/cron-read"
|
||||
cat "$TEST_CRONTAB"
|
||||
else
|
||||
echo 'UNEXPECTED overwrote concurrent cron' >> "$TEST_CALLS"
|
||||
exit 98
|
||||
fi
|
||||
''')
|
||||
self.run_script('-c', ok=False)
|
||||
self.assertIn('concurrent-editor', self.cron.read_text())
|
||||
self.assertNotIn(CB, self.cron.read_text())
|
||||
self.assert_clean()
|
||||
|
||||
def test_help_status_and_unbuilt_source(self):
|
||||
self.run_script('--help')
|
||||
self.run_script('--status')
|
||||
self.cron.unlink()
|
||||
self.run_script('--status')
|
||||
self.assertFalse((self.home / '.conf-sshd').exists())
|
||||
self.script.write_text(SOURCE)
|
||||
self.run_script('-o', ok=False)
|
||||
self.assertFalse(self.auth.exists())
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main(verbosity=2)
|
||||
@@ -0,0 +1,225 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import test from "node:test";
|
||||
import vm from "node:vm";
|
||||
|
||||
const script = await readFile(new URL("../site/assets/app.js", import.meta.url), "utf8");
|
||||
const html = await readFile(new URL("../site/index.html", import.meta.url), "utf8");
|
||||
const css = await readFile(new URL("../site/assets/style.css", import.meta.url), "utf8");
|
||||
|
||||
class Element {
|
||||
constructor(id, text = "") {
|
||||
this.id = id;
|
||||
this.textContent = text;
|
||||
this.dataset = {};
|
||||
this.attributes = new Map();
|
||||
this.listeners = new Map();
|
||||
this.classList = { add: (name) => this.attributes.set("class", name) };
|
||||
this.disabled = false;
|
||||
this.open = false;
|
||||
this.value = "";
|
||||
this.focused = false;
|
||||
this.selected = false;
|
||||
}
|
||||
setAttribute(name, value) { this.attributes.set(name, value); }
|
||||
getAttribute(name) { return this.attributes.get(name) ?? null; }
|
||||
removeAttribute(name) { this.attributes.delete(name); }
|
||||
addEventListener(name, handler) { this.listeners.set(name, handler); }
|
||||
dispatch(name) { return this.listeners.get(name)?.({ target: this }); }
|
||||
focus() { this.focused = true; }
|
||||
select() { this.selected = true; }
|
||||
showModal() { this.open = true; }
|
||||
close() { this.open = false; this.dispatch("close"); }
|
||||
}
|
||||
|
||||
function setup({ keys = ["ssh-ed25519 AAAA exact comment"], savedTheme, brokenStorage = false, clipboard = async () => {}, rawRelease } = {}) {
|
||||
const root = new Element("root");
|
||||
const ids = ["theme-picker", "copy-status", "copy-fallback", "fallback-text", "close-copy-fallback", "copy-all-keys", "release-data"];
|
||||
const elements = new Map(ids.map((id) => [id, new Element(id)]));
|
||||
const keyElements = keys.map((text, index) => new Element(`key-${index}`, text));
|
||||
const codeElements = [...keyElements, new Element("command-install", "curl -fsSL https://example.test/script.sh | sh -s -- -c")];
|
||||
const pres = codeElements.map((code) => new Element(`pre-${code.id}`));
|
||||
const buttons = codeElements.map((code) => {
|
||||
const button = new Element(`copy-${code.id}`);
|
||||
button.dataset.copyTarget = code.id;
|
||||
button.setAttribute("aria-label", `复制 ${code.id}`);
|
||||
return button;
|
||||
});
|
||||
for (const element of codeElements) elements.set(element.id, element);
|
||||
elements.get("release-data").textContent = rawRelease ?? JSON.stringify({ keys: keys.map((text) => ({ text })) });
|
||||
elements.get("copy-all-keys").setAttribute("aria-label", "复制全部公钥");
|
||||
const document = {
|
||||
documentElement: root,
|
||||
getElementById: (id) => elements.get(id) || null,
|
||||
querySelectorAll: (selector) => ({
|
||||
".code-block pre": pres,
|
||||
"[data-copy-target]": buttons,
|
||||
".key-card pre code": keyElements,
|
||||
})[selector] || [],
|
||||
};
|
||||
const stored = new Map(savedTheme ? [["quickly-conf-sshd-theme", savedTheme]] : []);
|
||||
const timers = new Map();
|
||||
let timerId = 0;
|
||||
const context = vm.createContext({
|
||||
document,
|
||||
navigator: clipboard === null ? {} : { clipboard: { writeText: clipboard } },
|
||||
localStorage: {
|
||||
getItem: (key) => { if (brokenStorage) throw new Error("Denied"); return stored.get(key) || null; },
|
||||
setItem: (key, value) => { if (brokenStorage) throw new Error("Denied"); stored.set(key, value); },
|
||||
},
|
||||
setTimeout: (callback) => { timers.set(++timerId, callback); return timerId; },
|
||||
clearTimeout: (id) => timers.delete(id),
|
||||
});
|
||||
vm.runInContext(script, context);
|
||||
return { root, elements, keyElements, codeElements, buttons, pres, stored, flushTimers: () => {
|
||||
const pending = [...timers.values()];
|
||||
timers.clear();
|
||||
pending.forEach((callback) => callback());
|
||||
} };
|
||||
}
|
||||
|
||||
test("homepage prerenders content, release metadata, and independent local assets", () => {
|
||||
for (const token of ["RELEASE_JSON", "KEYS_HTML", "COMMANDS_HTML", "REVISION", "PUBLISHED_AT", "KEY_COUNT", "REPOSITORY_URL", "RELEASE_LABEL", "PREVIEW_NOTICE", "SOURCE_LABEL", "SOURCE_STATUS", "CONTENT_REVISION", "KEYS_NOTICE"]) {
|
||||
assert.ok(html.includes(`@@${token}@@`), `${token} placeholder is required`);
|
||||
}
|
||||
assert.match(html, /<html lang="zh-CN">/);
|
||||
assert.match(html, /href="\/assets\/style\.css"/);
|
||||
assert.match(html, /src="\/assets\/app\.js" defer/);
|
||||
assert.match(html, /<script id="release-data" type="application\/json">/);
|
||||
assert.match(html, /aria-live="polite"/);
|
||||
assert.match(html, /<noscript>/);
|
||||
assert.match(html, /不代表所有服务器均已同步/);
|
||||
assert.match(html, /不会要求上传或输入私钥/);
|
||||
assert.doesNotMatch(html, /github\.io|https?:[^"']+\.(?:css|js)["']/);
|
||||
assert.doesNotMatch(html, /copy-all-commands/);
|
||||
});
|
||||
|
||||
test("default follows system and explicit light/dark/system choices persist", () => {
|
||||
const page = setup();
|
||||
const select = page.elements.get("theme-picker");
|
||||
assert.equal(select.value, "system");
|
||||
assert.equal(page.root.getAttribute("data-theme"), null);
|
||||
for (const theme of ["dark", "light", "system"]) {
|
||||
select.value = theme;
|
||||
select.dispatch("change");
|
||||
assert.equal(page.root.getAttribute("data-theme"), theme === "system" ? null : theme);
|
||||
assert.equal(page.stored.get("quickly-conf-sshd-theme"), theme);
|
||||
}
|
||||
assert.equal(setup({ savedTheme: "dark" }).root.getAttribute("data-theme"), "dark");
|
||||
assert.equal(setup({ savedTheme: "unexpected" }).root.getAttribute("data-theme"), null);
|
||||
});
|
||||
|
||||
test("storage restrictions do not prevent theme selection or enhancement", () => {
|
||||
const page = setup({ brokenStorage: true });
|
||||
const select = page.elements.get("theme-picker");
|
||||
select.value = "dark";
|
||||
select.dispatch("change");
|
||||
assert.equal(page.root.getAttribute("data-theme"), "dark");
|
||||
assert.equal(page.root.getAttribute("class"), "enhanced");
|
||||
for (const pre of page.pres) assert.equal(pre.getAttribute("tabindex"), "0");
|
||||
});
|
||||
|
||||
test("individual key and command copies retain every byte, including comments and punctuation", async () => {
|
||||
const exact = `ssh-ed25519 ${"A".repeat(2000)} user <script> & \"quoted\" 中文`;
|
||||
const copied = [];
|
||||
const page = setup({ keys: [exact], clipboard: async (text) => copied.push(text) });
|
||||
await page.buttons[0].dispatch("click");
|
||||
await page.buttons[1].dispatch("click");
|
||||
assert.deepEqual(copied, [exact, page.codeElements[1].textContent]);
|
||||
assert.equal(page.buttons[0].dataset.state, "success");
|
||||
assert.match(page.buttons[0].getAttribute("aria-label"), /已复制/);
|
||||
assert.equal(page.buttons[0].disabled, false);
|
||||
assert.equal(page.buttons[0].getAttribute("aria-busy"), null);
|
||||
page.flushTimers();
|
||||
assert.equal(page.elements.get("copy-status").textContent, "已复制完整内容。");
|
||||
assert.equal(page.buttons[0].dataset.state, undefined);
|
||||
assert.equal(page.buttons[0].getAttribute("aria-label"), "复制 key-0");
|
||||
});
|
||||
|
||||
test("copy all contains only raw public keys and a final LF, never commands", async () => {
|
||||
const keys = ["ssh-ed25519 AAAA key one", "ssh-rsa BBBB key two"];
|
||||
const copied = [];
|
||||
const page = setup({ keys, clipboard: async (text) => copied.push(text) });
|
||||
await page.elements.get("copy-all-keys").dispatch("click");
|
||||
assert.deepEqual(copied, [`${keys.join("\n")}\n`]);
|
||||
assert.ok(!copied[0].includes("curl"));
|
||||
});
|
||||
|
||||
test("missing or malformed release metadata falls back to prerendered keys", async () => {
|
||||
for (const rawRelease of ["not json", "{}", '{"keys":[null]}']) {
|
||||
const copied = [];
|
||||
const page = setup({ keys: ["ssh-ed25519 AAAA fallback"], rawRelease, clipboard: async (text) => copied.push(text) });
|
||||
await page.elements.get("copy-all-keys").dispatch("click");
|
||||
assert.deepEqual(copied, ["ssh-ed25519 AAAA fallback\n"]);
|
||||
}
|
||||
assert.equal(setup({ keys: [] }).elements.get("copy-all-keys").disabled, true);
|
||||
});
|
||||
|
||||
test("denied or unavailable clipboard offers exact text for manual copy and returns focus", async () => {
|
||||
for (const clipboard of [null, async () => { throw new Error("NotAllowedError"); }]) {
|
||||
const exact = "ssh-ed25519 AAAA comment <not markup>";
|
||||
const page = setup({ keys: [exact], clipboard });
|
||||
await page.buttons[0].dispatch("click");
|
||||
const dialog = page.elements.get("copy-fallback");
|
||||
const textarea = page.elements.get("fallback-text");
|
||||
assert.equal(dialog.open, true);
|
||||
assert.equal(textarea.value, exact);
|
||||
assert.equal(textarea.selected, true);
|
||||
assert.equal(textarea.focused, true);
|
||||
assert.equal(page.buttons[0].dataset.state, undefined);
|
||||
assert.equal(page.buttons[0].disabled, false);
|
||||
page.flushTimers();
|
||||
assert.match(page.elements.get("copy-status").textContent, /手动复制/);
|
||||
page.elements.get("close-copy-fallback").dispatch("click");
|
||||
assert.equal(dialog.open, false);
|
||||
assert.equal(page.buttons[0].focused, true);
|
||||
}
|
||||
});
|
||||
|
||||
test("repeated clicks while clipboard is pending make a single request and retries can recover", async () => {
|
||||
let resolveCopy;
|
||||
let calls = 0;
|
||||
const page = setup({ clipboard: () => {
|
||||
calls += 1;
|
||||
return new Promise((resolve) => { resolveCopy = resolve; });
|
||||
} });
|
||||
const first = page.buttons[0].dispatch("click");
|
||||
assert.equal(page.buttons[0].disabled, true);
|
||||
await page.buttons[0].dispatch("click");
|
||||
assert.equal(calls, 1);
|
||||
resolveCopy();
|
||||
await first;
|
||||
assert.equal(page.buttons[0].disabled, false);
|
||||
const second = page.buttons[0].dispatch("click");
|
||||
resolveCopy();
|
||||
await second;
|
||||
assert.equal(calls, 2);
|
||||
assert.equal(page.buttons[0].dataset.state, "success");
|
||||
});
|
||||
|
||||
test("unavailable or explicitly revoked key sets leave copy-all disabled and make no clipboard request", async () => {
|
||||
for (const sourceStatus of ["暂不可用", "明确撤销"]) {
|
||||
const copied = [];
|
||||
const page = setup({ keys: [], rawRelease: JSON.stringify({ keys: [], sourceStatus }), clipboard: async (text) => copied.push(text) });
|
||||
assert.equal(page.elements.get("copy-all-keys").disabled, true);
|
||||
await page.elements.get("copy-all-keys").dispatch("click");
|
||||
assert.deepEqual(copied, []);
|
||||
}
|
||||
assert.match(html, /公钥来源/);
|
||||
assert.match(html, /代码版本/);
|
||||
assert.match(html, /公钥版本/);
|
||||
assert.match(html, /class="keys-notice" role="note"/);
|
||||
});
|
||||
|
||||
test("CSS covers system theme, narrow viewports, touch copies, focus, and reduced motion", () => {
|
||||
assert.match(css, /prefers-color-scheme: dark/);
|
||||
assert.match(css, /:root\[data-theme="dark"\]/);
|
||||
assert.match(css, /max-width: 680px/);
|
||||
assert.match(css, /hover: hover/);
|
||||
assert.match(css, /pointer: fine/);
|
||||
assert.match(css, /focus-within/);
|
||||
assert.match(css, /focus-visible/);
|
||||
assert.match(css, /overflow-x: auto/);
|
||||
assert.match(css, /prefers-reduced-motion/);
|
||||
assert.doesNotMatch(script, /innerHTML|insertAdjacentHTML|\beval\s*\(|\bfetch\s*\(/);
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {createHandler} from '../cf-worker/src/router.js';
|
||||
import {parsePublicKeys,makeKeyBundle} from '../cf-worker/src/key-source.js';
|
||||
import {readFile} from 'node:fs/promises';
|
||||
const fixture=await readFile(new URL('./fixtures/keys.pub',import.meta.url),'utf8');
|
||||
const keys=await parsePublicKeys(fixture);
|
||||
const revision='a'.repeat(40);
|
||||
const bundle=await makeKeyBundle(keys,revision,false);
|
||||
const release={revision,publishedAt:'2026-10-12',keyCount:1,allowEmpty:false,sha256:bundle.sha256,script:'#!/bin/sh\n',keysText:bundle.text,keys,fallbackSources:[],repositoryUrl:'https://github.com/test/repo',commands:[],pageTemplate:await readFile(new URL('../site/index.html',import.meta.url),'utf8')};
|
||||
const fetch=createHandler(release);
|
||||
const requests=[];
|
||||
const env={ASSETS:{fetch:async request=>{requests.push(new URL(request.url).pathname);return new Response('asset:'+new URL(request.url).pathname,{headers:{'Content-Type':request.url.endsWith('.css')?'text/css':'text/html'}})}}};
|
||||
const request=(path,init={})=>new Request('https://ssh.example.com'+path,init);
|
||||
test('explicit endpoints ignore user agent and are never cached',async()=>{
|
||||
for(const [path,body] of [['/script.sh',release.script],['/ssh.keys',release.keysText]]) {
|
||||
const response=await fetch(request(path,{headers:{Accept:'text/html'}}),env);
|
||||
assert.equal(await response.text(),body);assert.equal(response.headers.get('Cache-Control'),'no-store');assert.match(response.headers.get('Content-Type'),/^text\/plain/);
|
||||
}
|
||||
});
|
||||
test('root serves browser HTML and legacy curl script without content mixing',async()=>{
|
||||
for(const agent of ['curl/8.1.0','libcurl/8.1.0']){
|
||||
const response=await fetch(request('/',{headers:{'User-Agent':agent,Accept:'*/*'}}),env);
|
||||
assert.equal(await response.text(),release.script);assert.equal(response.headers.get('Vary'),'Accept, User-Agent');
|
||||
}
|
||||
const explicit=await fetch(request('/',{headers:{'User-Agent':'curl/8','Accept':'text/html'}}),env);
|
||||
assert.match(await explicit.text(),/<html lang="zh-CN">/);
|
||||
assert.match((await fetch(request('/'),env)).headers.get('Content-Type'),/^text\/html/);
|
||||
});
|
||||
test('metadata is current release; invalid methods and paths fail rather than returning HTML',async()=>{
|
||||
const metadata=await (await fetch(request('/release.json'),env)).json();assert.equal(metadata.revision,release.revision);assert.equal(metadata.keyCount,1);
|
||||
for(const path of ['/script.s','/ssh.key','/index.html','/assets/../../script.sh','/api/keys']) {
|
||||
if(path==='/assets/../../script.sh') continue; // URL canonicalization is intentionally standard.
|
||||
const response=await fetch(request(path),env);assert.equal(response.status,404);assert.match(response.headers.get('Content-Type'),/^text\/plain/);
|
||||
}
|
||||
const post=await fetch(request('/ssh.keys',{method:'POST'}),env);assert.equal(post.status,405);assert.equal(post.headers.get('Allow'),'GET, HEAD');
|
||||
});
|
||||
test('HEAD returns headers without body and missing page fails with 503',async()=>{
|
||||
for(const path of ['/','/script.sh','/ssh.keys','/release.json']) assert.equal(await (await fetch(request(path,{method:'HEAD'}),env)).text(),'');
|
||||
assert.equal((await createHandler({...release,pageTemplate:null})(request('/'),env)).status,503);
|
||||
});
|
||||
test('fingerprinted assets cached while security headers retained',async()=>{
|
||||
const response=await fetch(request('/assets/app.012345abcdef.js'),env);assert.match(response.headers.get('Cache-Control'),/immutable/);assert.equal(response.headers.get('X-Content-Type-Options'),'nosniff');
|
||||
const missing=await fetch(request('/assets/missing.js'),{ASSETS:{fetch:async()=>new Response('missing',{status:404})}});assert.equal(missing.status,404);
|
||||
});
|
||||
|
||||
test('fallback page and key metadata represent one effective snapshot; unavailable does not revoke',async()=>{
|
||||
const fallback={...release,keys:[],keyCount:0,sha256:null,keysText:null,fallbackSources:['https://keys.example.com/server.keys']};
|
||||
const handler=createHandler(fallback,{fetchImpl:async()=>new Response(fixture)});
|
||||
const html=await (await handler(request('/'),env)).text();
|
||||
assert.ok(html.includes('备用源可用'));assert.ok(html.includes('https://keys.example.com/server.keys'));assert.ok(html.includes(keys[0].fingerprint));
|
||||
const match=html.match(/<script id="release-data" type="application\/json">([\s\S]*?)<\/script>/);
|
||||
const embedded=JSON.parse(match[1]);assert.equal(embedded.sha256,bundle.sha256);assert.equal(embedded.keyCount,1);assert.equal(embedded.keys[0].text,keys[0].text);
|
||||
const metadata=await (await handler(request('/release.json'),env)).json();assert.equal(metadata.keyRevision,bundle.sha256);assert.equal(metadata.source.type,'fallback');
|
||||
assert.equal(await (await handler(request('/ssh.keys'),env)).text(),bundle.text);
|
||||
const unavailable=createHandler(fallback,{fetchImpl:async()=>new Response('',{status:503})});
|
||||
for(const path of ['/ssh.keys','/release.json','/'])assert.equal((await unavailable(request(path),env)).status,503);
|
||||
const failureHTML=await (await unavailable(request('/'),env)).text();assert.ok(failureHTML.includes('未发布撤销指令'));assert.ok(!failureHTML.includes('此版本明确撤销全部受管理公钥'));
|
||||
assert.equal((await unavailable(request('/script.sh'),env)).status,200);
|
||||
assert.equal(await (await unavailable(request('/',{headers:{'User-Agent':'curl/8'}}),env)).text(),release.script);
|
||||
});
|
||||
test('explicit empty revocation suppresses fallback in page, metadata and keys',async()=>{
|
||||
const handler=createHandler({...release,keys:[],keyCount:0,allowEmpty:true,fallbackSources:['https://keys.example.com/server.keys']},{fetchImpl:async()=>{throw Error('must not fetch')}});
|
||||
const html=await (await handler(request('/'),env)).text();assert.ok(html.includes('明确撤销'));assert.ok(!html.includes('未发布撤销指令'));
|
||||
const keysText=await (await handler(request('/ssh.keys'),env)).text();assert.match(keysText,/# key-count 0\n# allow-empty yes/);
|
||||
});
|
||||
Reference in New Issue
Block a user