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

This commit is contained in:
2026-10-12 23:11:42 +08:00
parent f37cb51e06
commit 5798bf8900
29 changed files with 4943 additions and 525 deletions
+85
View File
@@ -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();
}),
};
}
+224
View File
@@ -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();
}
});
+84
View File
@@ -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('&lt;script&gt;'));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('$&amp;'));
assert.throws(()=>renderPage('@@UNKNOWN@@',{revision:'e'.repeat(40),keys:[]}),/Unresolved/);
});
+1
View File
@@ -0,0 +1 @@
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAINdamAGCsQq31Uv+08lkBzoO4XLz2qYjJa8CGmj3B1Ea TEST-ONLY-RFC8032-public-key
+303
View File
@@ -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/);
}
});
+23
View File
@@ -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);
});
+669
View File
@@ -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)
+225
View File
@@ -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*\(/);
});
+66
View File
@@ -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/);
});