feat: restore quick usage and privilege-aware setup with signed audit records

This commit is contained in:
2026-10-13 11:24:57 +08:00
parent d72aa6eda7
commit b6b0f3bb3d
10 changed files with 925 additions and 126 deletions
+39 -3
View File
@@ -9,7 +9,7 @@
> 初始生产清单和备用源为空,没有配置实际签名证书或发布凭据。生产构建不会偷偷使用测试密钥。仓库中的公钥文件不是 GitHub 账号登录凭据。 > 初始生产清单和备用源为空,没有配置实际签名证书或发布凭据。生产构建不会偷偷使用测试密钥。仓库中的公钥文件不是 GitHub 账号登录凭据。
## 使用与重新安装 ## Quick Usage 与重新安装
先配置并部署自己的 Worker,再使用自己的域名。以下地址只是本项目的部署配置示例。 先配置并部署自己的 Worker,再使用自己的域名。以下地址只是本项目的部署配置示例。
@@ -23,6 +23,28 @@ curl -fsSL 'https://ssh.lamgc.me/script.sh' | sh -s -- --install --download-scri
**这条命令信任下载到的可执行脚本。** HTTPS、同一 Worker 提供的摘要和后续公钥验签,不能证明首次安装器本身可信。高信任安装应先取得独立渠道核对过的脚本摘要和完整 GPG 指纹,下载、核验本地文件后执行安装。不要把从同一被攻陷来源获得的指纹当作独立信任依据。 **这条命令信任下载到的可执行脚本。** HTTPS、同一 Worker 提供的摘要和后续公钥验签,不能证明首次安装器本身可信。高信任安装应先取得独立渠道核对过的脚本摘要和完整 GPG 指纹,下载、核验本地文件后执行安装。不要把从同一被攻陷来源获得的指纹当作独立信任依据。
原版 Quick Usage 的一次部署、只安装钥匙、root 策略与帮助入口继续保留。普通运行会按权限检查 SSH Server;root 缺少服务端时可按需安装,非 root 不安装系统包。`-n` 跳过服务端检查/安装,`-o` 只处理当前用户的钥匙,不配置 SSH 服务。
一次部署,不设置定时任务:
```sh
curl -fsSL 'https://ssh.lamgc.me/' | sh -s
```
跳过 SSH Server 检查,只安装钥匙:
```sh
curl -fsSL 'https://ssh.lamgc.me/' | sh -s -- -o
```
显式设置 root 登录策略并部署钥匙(必须以 root 执行,可再加 `-c`):
```sh
curl -fsSL 'https://ssh.lamgc.me/' | sh -s -- -p yes
```
`-p yes` 仅设置 `PermitRootLogin yes`,不额外打开 `PasswordAuthentication`。使用 `-p no` 设置 `prohibit-password`。会先验证钥匙、备份和校验配置;不会自动重载 SSH 服务,请保留恢复会话并按输出手动检查。
日常同步、预览、状态使用已经落盘的脚本: 日常同步、预览、状态使用已经落盘的脚本:
```sh ```sh
@@ -140,6 +162,16 @@ ON 客户端检查固定列表 ID、有效期和持久化的最高序号/摘要
下载/验签失败或清单过期会保留最后成功的密钥并报错,**不会自动清空密钥造成锁死**。攻击者仍可阻断更新;离线机器无法保证及时撤权。删除公钥不会终止已有会话,也不会移除管理区块外的手工重复钥匙。部署签名私钥或服务器本地管理员权限失陷超出这层保护范围。 下载/验签失败或清单过期会保留最后成功的密钥并报错,**不会自动清空密钥造成锁死**。攻击者仍可阻断更新;离线机器无法保证及时撤权。删除公钥不会终止已有会话,也不会移除管理区块外的手工重复钥匙。部署签名私钥或服务器本地管理员权限失陷超出这层保护范围。
### 服务器本地验签审计
仅在本地验签为 ON 时,成功更新会把 `/ssh.keys` 原始响应与配套 `/ssh.keys.sig` 原始签名保存到 `$HOME/.conf-sshd/audit/`。这是实际签名覆盖的完整文档,不是去掉元数据后写入 authorized_keys 的管理区块。
- `current` 是严格格式的当前记录指针,指向 `a` 或 `b` 槽;槽内有 `ssh.keys`、`ssh.keys.sig` 和 `metadata`。两槽轮替,保留当前与上一份证据,不会随每次 cron 无限增长。
- metadata 记录成功安装时间、列表 ID、序号、来源、实际签名主/子钥完整指纹、本地固定的指纹和模式、文档及签名摘要、客户端格式版本。它是本地辅助信息,不是额外签名的远端证明。
- 下载或验签失败不覆盖当前成功证据。钥匙、回滚状态与审计指针无法跨文件一次性 rename;提交中断会保留恢复 journal,并且可能暂时没有 current 指针。没有 current 时,不应把任一槽猜作当前已安装证据;先检查错误并用同一固定客户端完成恢复。
- OFF 不额外下载或保存签名;成功执行 OFF 更新会取消旧记录的 current 标记,旧证据仅作历史。仅改远端默认值不会关闭本地 ON。
- 审计目录权限为 0700,文件为 0600;可结合本地固定证书离线复核签名。它不能防止服务器 root 篡改、删除证据,也不能证明包安装副作用、SSH 当前会话或整机快照未被回滚。
## 有序备用源 ## 有序备用源
有两份有意区分的配置: 有两份有意区分的配置:
@@ -164,7 +196,7 @@ Worker 上游请求有时间、流式字节数、数量限制,并拒绝重定
- `signedRelease`:签名列表身份和可公开发布的证书配置;不是私钥。 - `signedRelease`:签名列表身份和可公开发布的证书配置;不是私钥。
- `clientDefaults`:首次安装的验签默认值、完整指纹、primary/subkey 范围、列表 ID 和可选公开 Email;不覆盖现有本地设置。 - `clientDefaults`:首次安装的验签默认值、完整指纹、primary/subkey 范围、列表 ID 和可选公开 Email;不覆盖现有本地设置。
构建需要 Node.js 22+、npm、Git、OpenSSH 客户端和 POSIX sh;GPG 验证/测试需要 `gpg`。服务器需要 curl、ssh-keygen、awk、mktemp、stat、常用 POSIX 工具及 sha256sum/shasum/openssl 之一,ON 还需要 gpg;计划任务需要 crontab。脚本不会自动安装软件。 构建需要 Node.js 22+、npm、Git、OpenSSH 客户端和 POSIX sh;GPG 验证/测试需要 `gpg`。服务器需要 curl、ssh-keygen、awk、mktemp、stat、常用 POSIX 工具及 sha256sum/shasum/openssl 之一,ON 还需要 gpg;计划任务需要 crontab。普通部署可由 root 按需安装 openssh-server,以及启用计划时缺少的 cron/crontabs;支持的包管理器为 apt-get、dnf、yum。已有 sshd 配置无效不当作缺少软件重装。非 root 不安装系统包,缺少 ssh-keygen 时,正常 root 部署可在完成信任确认后先安装客户端工具以便验钥;`-o`、`-n` 和非 root 路径不会这样安装,其他工具仍须预先准备。
```sh ```sh
npm ci npm ci
@@ -214,7 +246,11 @@ GitHub 的通用 Verified 标记不是本项目授权名单。最终 merge/squas
仅操作当前用户 `$HOME/.ssh/authorized_keys` 的唯一配对管理区块。临时文件、锁、备份、同目录替换和写前快照检查避免可观察到的并发覆盖;不同编辑器不共享锁时仍无操作系统级原子比较交换保证。 仅操作当前用户 `$HOME/.ssh/authorized_keys` 的唯一配对管理区块。临时文件、锁、备份、同目录替换和写前快照检查避免可观察到的并发覆盖;不同编辑器不共享锁时仍无操作系统级原子比较交换保证。
`-p yes/no` 是独立 root 高级操作,仅修改 PermitRootLogin,验证 `sshd -t/-T` 后替换,遇 Include/Match 等复杂配置拒绝;不会自动 reload/restart。普通安装/同步不修改 SSH 服务策略。 `-p yes/no` 是显式 root 操作,可与普通部署、`--install`、`-c` 组合,不能与只更新钥匙的 `-o` 组合。只修改 PermitRootLogin,先验证公钥,再备份和验证 `sshd -t/-T`,最后替换配置;不改 PasswordAuthentication,不主动 reload/restart。默认部署不会改变登录策略。
支持没有 Include/Match 的简单配置,以及仅包含标准 `Include /etc/ssh/sshd_config.d/*.conf`(或相对路径 `sshd_config.d/*.conf`)的配置。标准目录及文件必须安全、可读取,目录清单和文件内容在提交前复查。主文件或包含文件出现 Match、嵌套 Include,或其他 Include 写法时明确拒绝自动改策略,交由管理员检查;不会声称几个模拟连接就覆盖所有条件规则。
普通部署会区分缺少 sshd 程序与现有配置无效。root 可按需安装 openssh-server,`-c` 时可按需安装 cron/crontabs;`-n` 跳过服务端检查/安装,`-o` 不执行这类系统操作。脚本不会主动执行服务 reload/restart,但发行版软件包的安装钩子可能启动服务或生成主机密钥。包安装不是可整体回滚的事务;后续失败会报告,已有钥匙和策略在验证前不改写。
目标为 Linux dash、Bash POSIX 与 BusyBox ash。测试不等于真实服务器登录、SELinux、发行版服务管理或自定义 AuthorizedKeysFile 已验证。异常强制终止可能留下锁,人工确认原进程状态后再处理。 目标为 Linux dash、Bash POSIX 与 BusyBox ash。测试不等于真实服务器登录、SELinux、发行版服务管理或自定义 AuthorizedKeysFile 已验证。异常强制终止可能留下锁,人工确认原进程状态后再处理。
+40 -9
View File
@@ -6,22 +6,49 @@ export function commandsFor(config) {
const fetch = `curl -fsSL '${config.baseUrl}/script.sh' | sh -s --`; const fetch = `curl -fsSL '${config.baseUrl}/script.sh' | sh -s --`;
const installed = 'sh "$HOME/.conf-sshd/conf-sshd.sh"'; const installed = 'sh "$HOME/.conf-sshd/conf-sshd.sh"';
return [ return [
{id:'install',title:'安装或重新安装',description:`下载并安装客户端,再同步公钥与设置计划;发现旧版本时先备份并保留本地配置。保留已有计划,新安装计划为 ${config.defaultCron || '*/15 * * * *'}。首次安装或明确重装都会信任你执行的脚本,推荐先独立核验安装脚本;启用或更换信任须在服务器本地终端确认完整指纹。`,command:`${fetch} --install --download-script -c`}, {id:'install',title:'部署密钥并设置定期更新',description:`部署当前用户公钥并安装固定客户端;发现旧脚本先备份并保留本地配置。保留已有计划,新计划为 ${config.defaultCron || '*/15 * * * *'}。root 可按需安装 SSH Server 与 cron,非 root 缺少依赖时退出。`,command:`${fetch} --install --download-script -c`},
{id:'once',title:'同步一次公钥',description:'使用已安装的固定脚本与本地信任设置同步公钥,不更换脚本或定时计划。',command:`${installed} -o`}, {id:'deploy-once',title:'部署一次,不设置定期更新',description:'与原版不带 -c 的用法一致:检查 SSH Server、按权限处理缺失依赖并部署公钥,不新增定时任务。',command:fetch},
{id:'keys-only',title:'只更新密钥,跳过 SSH Server 检查',description:'单独使用 -o,仅同步当前用户公钥,跳过 SSH Server 检查与软件包配置;不设置定时任务。日常同步优先使用下方已安装的本地脚本。',command:`${fetch} -o`},
{id:'root-on',title:'允许 root 密码登录并部署密钥',description:'须以 root 执行。设置 PermitRootLogin yes 并部署公钥;其他 SSH 认证设置仍可能限制密码登录,不自动重载服务。',command:`${fetch} -p yes`},
{id:'help',title:'查看帮助',description:'下载当前脚本并显示参数,不建立信任;执行前仍应检查下载内容。',command:`${fetch} -h`}, {id:'help',title:'查看帮助',description:'下载当前脚本并显示参数,不建立信任;执行前仍应检查下载内容。',command:`${fetch} -h`},
{id:'preview',title:'预览公钥同步',description:'使用已安装的脚本与本地信任设置检查更新,预览将要修改的文件,不应用更新。',command:`${installed} -o --dry-run`}, {id:'once',title:'使用本地脚本同步一次',description:'须先安装。使用固定客户端与本地信任设置,仅同步公钥,不检查 SSH Server、不更换脚本或定时计划。',command:`${installed} -o`,advanced:true},
{id:'root-off',title:'禁止 root 使用密码登录',description:'仅 root 可执行。使用已安装的脚本校验并修改 SSH 配置,不自动重载服务。',command:`${installed} -p no`,advanced:true}, {id:'preview',title:'预览公钥同步',description:'须先安装。使用本地客户端检查更新并预览将要修改的文件,不应用更新。',command:`${installed} -o --dry-run`,advanced:true},
{id:'root-on',title:'允许 root 使用密码登录',description:'仅 root 可执行,涉及登录安全;实际登录仍受其他 SSH 认证设置限制,不自动重载服务。',command:`${installed} -p yes`,advanced:true}, {id:'cron-custom',title:'自定义定期更新计划',description:'须先安装。示例每 6 小时同步一次;cron 表达式必须加引号。只管理此脚本自己的计划。',command:`${installed} -c '0 */6 * * *'`,advanced:true},
{id:'uninstall',title:'停止同步并卸载',description:'移除脚本及其定时任务,保留公钥与 SSH 配置。',command:`${installed} --uninstall`,advanced:true}, {id:'cron-stop',title:'停止定期更新',description:'须先安装。仅移除此脚本的定时计划,保留本地脚本、公钥与信任配置。',command:`${installed} -c false`,advanced:true},
{id:'skip-sshd-install',title:'跳过 SSH Server 自动安装',description:'使用 -n 跳过 SSH Server 的检查与自动安装,继续部署公钥。需要严格的日常密钥同步时使用 -o。',command:`${fetch} -n`,advanced:true},
{id:'root-off',title:'禁止 root 密码登录并部署密钥',description:'须先安装并以 root 执行。校验并设置 PermitRootLogin prohibit-password,然后部署公钥;不自动重载服务。',command:`${installed} -p no`,advanced:true},
{id:'reinstall',title:'更新脚本:明确重新安装',description:'代替原 -u 自更新入口:主动下载并重装,备份旧脚本,保留本地配置与既有计划。首次安装或明确重装都会信任你执行的脚本,推荐先独立核验安装脚本;启用或更换信任须在服务器本地终端确认完整指纹。',command:`${fetch} --install --download-script`,advanced:true},
{id:'status',title:'查看本地安装状态',description:'须先安装。查看客户端与定时计划状态,不更改配置。',command:`${installed} --status`,advanced:true},
{id:'uninstall',title:'停止同步并卸载',description:'须先安装。移除脚本及其定时任务,保留公钥与 SSH 配置。',command:`${installed} --uninstall`,advanced:true},
]; ];
} }
function signingConfigured(config) {
return Boolean(config.signedRelease || config.clientDefaults?.gpgVerification === 'on');
}
export function pathsFor(config) {
const baseUrl = config.baseUrl || '';
return [
{id:'keys',title:'获取 SSH 公钥',description:'当前公钥文档。开启本地验签后,须与对应独立签名一并核验。',url:`${baseUrl}/ssh.keys`},
{id:'script',title:'获取 Shell 脚本',description:'显式脚本地址,浏览器与命令行均返回脚本文本。',url:`${baseUrl}/script.sh`},
{id:'root-script',title:'使用 Curl 获取脚本',description:'兼容原版根地址入口:Curl 默认请求返回 Shell 脚本;显式请求 text/html 时返回首页。',url:`${baseUrl}/`},
{id:'homepage',title:'使用浏览器访问本页面',description:'同一根地址在浏览器中展示首页,可查看发布状态与复制命令。',url:`${baseUrl}/`},
...(signingConfigured(config) ? [{id:'signature',title:'获取公钥文档独立签名',description:'与 /ssh.keys 原始字节配对。当前来源没有可用签名时返回 404,不代表公钥被撤销。',url:`${baseUrl}/ssh.keys.sig`},
{id:'certificate',title:'获取公开签名证书',description:'配置后提供公开证书,否则返回 404。安装时仍须按独立可信的完整指纹核验。',url:`${baseUrl}/public-key.gpg`}] : []),
];
}
function renderPaths(paths) {
return paths.map(path => `<article class="command-card path-card"><div class="card-heading"><h3>${escapeHTML(path.title)}</h3></div><p class="command-description">${escapeHTML(path.description)}</p>${codeBlock(`path-${path.id}`,path.url,`复制“${path.title}”地址`)}</article>`).join('\n');
}
function codeBlock(id, text, label) { function codeBlock(id, text, label) {
return `<div class="code-block"><pre tabindex="0"><code id="${id}">${escapeHTML(text)}</code></pre><button type="button" class="copy-button" data-copy-target="${id}" aria-label="${escapeHTML(label)}" title="${escapeHTML(label)}"></button></div>`; return `<div class="code-block"><pre tabindex="0"><code id="${id}">${escapeHTML(text)}</code></pre><button type="button" class="copy-button" data-copy-target="${id}" aria-label="${escapeHTML(label)}" title="${escapeHTML(label)}"></button></div>`;
} }
export function renderCommands(commands) { export function renderCommands(commands) {
const card = command => `<article class="command-card"><div class="card-heading"><h3>${escapeHTML(command.title)}</h3></div><p class="command-description">${escapeHTML(command.description)}</p>${codeBlock(`command-${command.id}`,command.command,`复制“${command.title}”命令`)}</article>`; const card = command => `<article class="command-card"><div class="card-heading"><h3>${escapeHTML(command.title)}</h3></div><p class="command-description">${escapeHTML(command.description)}</p>${codeBlock(`command-${command.id}`,command.command,`复制“${command.title}”命令`)}</article>`;
return commands.filter(c=>!c.advanced).map(card).join('\n') + '<details class="advanced-commands"><summary>高级命令</summary><div class="command-list">' + commands.filter(c=>c.advanced).map(card).join('\n') + '</div></details>'; return commands.filter(c=>!c.advanced).map(card).join('\n') + '<details class="advanced-commands"><summary>更多用法与维护</summary><div class="command-list">' + commands.filter(c=>c.advanced).map(card).join('\n') + '</div></details>';
} }
/** The homepage projects metadata only. Key material remains confined to the APIs. */ /** The homepage projects metadata only. Key material remains confined to the APIs. */
@@ -42,18 +69,22 @@ export function renderPage(template, snapshot) {
} }
const certificatePresent = typeof snapshot.publicCertificateAvailable === 'boolean' ? snapshot.publicCertificateAvailable : typeof snapshot.publicCertificate === 'string' && snapshot.publicCertificate.length > 0; const certificatePresent = typeof snapshot.publicCertificateAvailable === 'boolean' ? snapshot.publicCertificateAvailable : typeof snapshot.publicCertificate === 'string' && snapshot.publicCertificate.length > 0;
const certificateStatus = certificatePresent ? '公开证书已提供(安装时核验)' : '未提供公开证书'; const certificateStatus = certificatePresent ? '公开证书已提供(安装时核验)' : '未提供公开证书';
const signatureEnabled = signingConfigured(snapshot);
const signatureStatus = signaturePresent ? '签名包已提供(待客户端验证)' : '未提供签名包'; const signatureStatus = signaturePresent ? '签名包已提供(待客户端验证)' : '未提供签名包';
const keyCount = unavailable ? 0 : (snapshot.keyCount ?? keys.length); const keyCount = unavailable ? 0 : (snapshot.keyCount ?? keys.length);
const publicSource = {type:source.type, ...(source.url ? {url:source.url} : {})}; const publicSource = {type:source.type, ...(source.url ? {url:source.url} : {})};
const publicData = {schema:2,revision,publishedAt,keyCount,sha256:snapshot.sha256 || null,keyRevision:snapshot.keyRevision || snapshot.sha256 || null,source:publicSource,sourceStatus,signaturePresent,signatureStatus,certificatePresent,certificateStatus,repositoryUrl,testFixture:Boolean(testFixture)}; const publicData = {schema:2,revision,publishedAt,keyCount,sha256:snapshot.sha256 || null,keyRevision:snapshot.keyRevision || snapshot.sha256 || null,source:publicSource,sourceStatus,signatureEnabled,signaturePresent,signatureStatus,certificatePresent,certificateStatus,repositoryUrl,testFixture:Boolean(testFixture)};
const replacements = { const replacements = {
RELEASE_JSON:JSON.stringify(publicData).replace(/</g,'\\u003c').replace(/\u2028/g,'\\u2028').replace(/\u2029/g,'\\u2029'), RELEASE_JSON:JSON.stringify(publicData).replace(/</g,'\\u003c').replace(/\u2028/g,'\\u2028').replace(/\u2029/g,'\\u2029'),
COMMANDS_HTML:renderCommands(commands),REVISION:escapeHTML(revision.slice(0,12)),PUBLISHED_AT:escapeHTML(publishedAt),KEY_COUNT:String(keyCount), COMMANDS_HTML:renderCommands(commands),PATHS_HTML:renderPaths(pathsFor(snapshot)),REVISION:escapeHTML(revision.slice(0,12)),PUBLISHED_AT:escapeHTML(publishedAt),KEY_COUNT:String(keyCount),
REPOSITORY_URL:escapeHTML(repositoryUrl),RELEASE_LABEL:testFixture ? '测试预览' : unavailable ? '等待发布源' : '发布元数据', REPOSITORY_URL:escapeHTML(repositoryUrl),RELEASE_LABEL:testFixture ? '测试预览' : unavailable ? '等待发布源' : '发布元数据',
PREVIEW_NOTICE:testFixture ? '<p class="preview-notice" role="note">测试预览:这里使用公开测试数据,不能部署到真实服务器。</p>' : '', PREVIEW_NOTICE:testFixture ? '<p class="preview-notice" role="note">测试预览:这里使用公开测试数据,不能部署到真实服务器。</p>' : '',
SIGNATURE_SECTION_START:'',SIGNATURE_SECTION_END:'',PATHS_INDEX:signatureEnabled ? '04' : '03',
VERIFICATION_DEFAULT:snapshot.clientDefaults?.gpgVerification === 'on' ? '本仓库默认开启客户端验签;既有服务器仍按本地已保存的设置执行。' : '客户端验签默认关闭,可在服务器本地明确启用;既有服务器仍按本地已保存的设置执行。',
CERTIFICATE_LINK:certificatePresent ? '<a class="text-link" href="/public-key.gpg">公开签名证书 <span aria-hidden="true">↗</span></a>' : '', CERTIFICATE_LINK:certificatePresent ? '<a class="text-link" href="/public-key.gpg">公开签名证书 <span aria-hidden="true">↗</span></a>' : '',
SOURCE_LABEL:escapeHTML(sourceLabel),SOURCE_STATUS:escapeHTML(sourceStatus),SIGNATURE_STATUS:escapeHTML(signatureStatus),CERTIFICATE_STATUS:escapeHTML(certificateStatus),CONTENT_REVISION:escapeHTML((snapshot.keyRevision || snapshot.sha256)?.slice(0,12) || '—'),KEYS_NOTICE:escapeHTML(notice), SOURCE_LABEL:escapeHTML(sourceLabel),SOURCE_STATUS:escapeHTML(sourceStatus),SIGNATURE_STATUS:escapeHTML(signatureStatus),CERTIFICATE_STATUS:escapeHTML(certificateStatus),CONTENT_REVISION:escapeHTML((snapshot.keyRevision || snapshot.sha256)?.slice(0,12) || '—'),KEYS_NOTICE:escapeHTML(notice),
}; };
if (!signatureEnabled) template = template.replace(/@@SIGNATURE_SECTION_START@@[\s\S]*?@@SIGNATURE_SECTION_END@@/g, '');
return template.replace(/@@([A-Z_]+)@@/g, (_, token) => { return template.replace(/@@([A-Z_]+)@@/g, (_, token) => {
if (!Object.hasOwn(replacements, token)) throw Error(`Unresolved page template token: ${token}`); if (!Object.hasOwn(replacements, token)) throw Error(`Unresolved page template token: ${token}`);
return replacements[token]; return replacements[token];
+374 -80
View File
@@ -13,7 +13,8 @@ DEFAULT_GPG_EMAIL='@@DEFAULT_GPG_EMAIL@@'
set -eu set -eu
umask 077 umask 077
LC_ALL=C LC_ALL=C
export LC_ALL PATH=${PATH:-/usr/bin:/bin}:/usr/local/sbin:/usr/sbin:/sbin
export LC_ALL PATH
BEGIN_KEYS='# --- BEGIN MANAGED BY CONF-SSHD SCRIPT ---' BEGIN_KEYS='# --- BEGIN MANAGED BY CONF-SSHD SCRIPT ---'
END_KEYS='# --- END MANAGED BY CONF-SSHD SCRIPT ---' END_KEYS='# --- END MANAGED BY CONF-SSHD SCRIPT ---'
BEGIN_CRON='# --- BEGIN CRON MANAGED BY CONF-SSHD SCRIPT ---' BEGIN_CRON='# --- BEGIN CRON MANAGED BY CONF-SSHD SCRIPT ---'
@@ -24,6 +25,9 @@ cron_value=
dry_run=no dry_run=no
no_install=no no_install=no
root_password= root_password=
apt_index_updated=no
cron_prepared=no
auth_prepared=no
temps= temps=
lock_owned=no lock_owned=no
created_state=no created_state=no
@@ -51,7 +55,9 @@ fail() { printf 'conf-sshd: %s\n' "$*" >&2; exit 1; }
usage() { usage() {
cat <<'HELP' cat <<'HELP'
Usage: sh conf-sshd.sh [options] Usage: sh conf-sshd.sh [options]
(no options), -o, --only-update-keys Validate and sync managed public keys only (no options) Check/setup sshd for this user's privileges,
then validate and sync managed keys
-o, --only-update-keys Keys only; never install packages or edit sshd
-c, --cron ["five fields"|false] Sync and schedule; bare -c keeps an existing -c, --cron ["five fields"|false] Sync and schedule; bare -c keeps an existing
schedule, otherwise the configured default. schedule, otherwise the configured default.
-c false only removes this script's schedule. -c false only removes this script's schedule.
@@ -75,13 +81,14 @@ Usage: sh conf-sshd.sh [options]
--fallback-urls-file FILE Ordered one-HTTPS-endpoint-per-line fallbacks --fallback-urls-file FILE Ordered one-HTTPS-endpoint-per-line fallbacks
-u, --update-self Refused: reinstall explicitly with --install -u, --update-self Refused: reinstall explicitly with --install
--uninstall Remove own schedule/script; preserve keys/config --uninstall Remove own schedule/script; preserve keys/config
-p, --allow-root-passwd yes|no Explicit root-only sshd_config edit; never reload -p, --allow-root-passwd yes|no Root-only policy edit plus key sync; may use -c
-n, --no-install-sshd Compatibility flag; packages are never installed or --install; never reload sshd
-n, --no-install-sshd Skip SSH package bootstrap/server installation
--dry-run Validate and show changes without applying them --dry-run Validate and show changes without applying them
--status Show local installation and schedule status --status Show local installation and schedule status
-h, --help Show this help -h, --help Show this help
-u, --uninstall, -p and --status are exclusive operations. -o cannot use -c or -p. -u, --uninstall and --status are exclusive operations. -o cannot use -c or -p.
Trust/URL changes require explicit installation, or keys-only -o enrollment. Trust/URL changes require explicit installation, or keys-only -o enrollment.
--install -c also schedules; --install always validates and syncs keys. --install -c also schedules; --install always validates and syncs keys.
Running an installer from Worker explicitly trusts that bootstrap code. For higher Running an installer from Worker explicitly trusts that bootstrap code. For higher
@@ -104,11 +111,19 @@ Strict mode needs GnuPG and a correct clock. A missing rollback-state file fails
closed. With --min-sequence omitted, initial enrollment cannot detect a previously closed. With --min-sequence omitted, initial enrollment cannot detect a previously
published, still-unexpired release being replayed; obtain a current floor out of band. published, still-unexpired release being replayed; obtain a current floor out of band.
Expired/invalid responses retain last installed keys; expiry is not automatic revocation. Expired/invalid responses retain last installed keys; expiry is not automatic revocation.
When verification is ON, two private audit slots retain successful original key
responses/signatures and signer/source/time metadata. Only the current pointer
marks a completed installation; an absent pointer with a pending journal means
recovery is needed. OFF key updates invalidate current audit without fetching a
signature. These files are locally editable by their owner, not tamper-proof.
Local state uses atomic renames and a pending journal, not a power-loss fsync guarantee. Local state uses atomic renames and a pending journal, not a power-loss fsync guarantee.
Requires curl, ssh-keygen, awk, standard POSIX tools, mktemp, stat, and one of Requires curl, ssh-keygen, awk, standard POSIX tools, mktemp, stat, and one of
sha256sum, shasum or openssl. Scheduling also requires crontab. Downloads require sha256sum, shasum or openssl. Scheduling also requires crontab. Downloads require
HTTPS; existing keys survive any validation/download failure. HTTPS; existing keys survive any validation/download failure.
No SSH service is installed, restarted, or reloaded. Keep recovery access ready. Normal root runs can install missing OpenSSH/cron using apt-get, dnf or yum.
Non-root users never install packages or change system SSH policy. -o is keys only.
Package hooks may start services or generate host keys. This script never directly
starts, restarts or reloads sshd. Keep recovery access ready.
The payload SHA-256 detects inconsistency; trust comes from HTTPS and its source. The payload SHA-256 detects inconsistency; trust comes from HTTPS and its source.
HELP HELP
} }
@@ -133,7 +148,9 @@ while [ "$#" -gt 0 ]; do
trust_options=yes ;; trust_options=yes ;;
--gpg-verification|--public-key-file|--fingerprint|--gpg-fingerprint|--gpg-pin-mode|--gpg-email|--keyserver|--list-id|--min-sequence|--fallback-urls-file|--primary-url) --gpg-verification|--public-key-file|--fingerprint|--gpg-fingerprint|--gpg-pin-mode|--gpg-email|--keyserver|--list-id|--min-sequence|--fallback-urls-file|--primary-url)
option=$1 option=$1
[ "$#" -ge 2 ] && [ -n "$2" ] || fail "$option requires a value." if [ "$#" -lt 2 ] || [ -z "$2" ]; then
fail "$option requires a value."
fi
shift shift
case $option in case $option in
--gpg-verification) [ -z "$requested_verification" ] || fail "Duplicate $option."; requested_verification=$1 ;; --gpg-verification) [ -z "$requested_verification" ] || fail "Duplicate $option."; requested_verification=$1 ;;
@@ -152,7 +169,7 @@ while [ "$#" -gt 0 ]; do
--uninstall) select_mode uninstall ;; --uninstall) select_mode uninstall ;;
--status) select_mode status ;; --status) select_mode status ;;
-p|--allow-root-passwd) -p|--allow-root-passwd)
select_mode config [ -z "$root_password" ] || fail 'Duplicate -p option.'
[ "$#" -ge 2 ] || fail '-p requires yes or no.' [ "$#" -ge 2 ] || fail '-p requires yes or no.'
shift; case $1 in yes|no) root_password=$1 ;; *) fail '-p requires yes or no.' ;; esac ;; shift; case $1 in yes|no) root_password=$1 ;; *) fail '-p requires yes or no.' ;; esac ;;
-c|--cron) -c|--cron)
@@ -168,6 +185,9 @@ while [ "$#" -gt 0 ]; do
shift shift
done done
[ "$cron_requested" = no ] || [ "$mode" = sync ] || [ "$mode" = install ] || fail '-c cannot be combined with this operation.' [ "$cron_requested" = no ] || [ "$mode" = sync ] || [ "$mode" = install ] || fail '-c cannot be combined with this operation.'
if [ -n "$root_password" ] && [ "$mode" != sync ] && [ "$mode" != install ]; then
fail '-p cannot be combined with this operation.'
fi
[ "$trust_options" = no ] || [ "$mode" = install ] || [ "$mode" = keys ] || [ "$mode" = sync ] || fail 'Trust and URL settings require explicit installation or keys-only enrollment.' [ "$trust_options" = no ] || [ "$mode" = install ] || [ "$mode" = keys ] || [ "$mode" = sync ] || fail 'Trust and URL settings require explicit installation or keys-only enrollment.'
case $requested_verification in ''|on|off) : ;; *) fail '--gpg-verification requires on or off.' ;; esac case $requested_verification in ''|on|off) : ;; *) fail '--gpg-verification requires on or off.' ;; esac
[ "$download_script" = no ] || [ "$mode" = install ] || fail '--download-script requires --install.' [ "$download_script" = no ] || [ "$mode" = install ] || fail '--download-script requires --install.'
@@ -176,6 +196,7 @@ case $requested_verification in ''|on|off) : ;; *) fail '--gpg-verification requ
need() { command -v "$1" >/dev/null 2>&1 || fail "Missing dependency: $1. Install it explicitly before continuing."; } need() { command -v "$1" >/dev/null 2>&1 || fail "Missing dependency: $1. Install it explicitly before continuing."; }
for dep in awk cat chmod cmp cp dd grep id mkdir mktemp mv od rm rmdir sed stat tail tr wc; do need "$dep"; done for dep in awk cat chmod cmp cp dd grep id mkdir mktemp mv od rm rmdir sed stat tail tr wc; do need "$dep"; done
uid=$(id -u) uid=$(id -u)
if [ -n "$root_password" ] && [ "$uid" != 0 ]; then fail '-p requires root.'; fi
# Reject paths that could alter line-based cron records. Spaces and quotes are safe. # Reject paths that could alter line-based cron records. Spaces and quotes are safe.
case ${HOME:-} in /*) : ;; *) fail 'HOME must be an absolute directory.' ;; esac case ${HOME:-} in /*) : ;; *) fail 'HOME must be an absolute directory.' ;; esac
[ "$(printf '%s' "$HOME" | tr -d '\040-\176\200-\377'; printf .)" = . ] || fail 'HOME must not contain control characters.' [ "$(printf '%s' "$HOME" | tr -d '\040-\176\200-\377'; printf .)" = . ] || fail 'HOME must not contain control characters.'
@@ -190,6 +211,8 @@ local_config=$state/local.conf
replay_state=$state/replay.state replay_state=$state/replay.state
pending_bundle=$state/pending.bundle pending_bundle=$state/pending.bundle
pending_state=$state/pending.state pending_state=$state/pending.state
pending_source=$state/pending.source
audit_dir=$state/audit
# GNU/Linux and BSD/macOS stat. No filesystem changes precede ownership checks. # GNU/Linux and BSD/macOS stat. No filesystem changes precede ownership checks.
if stat -c '%u %a %h' "$HOME" >/dev/null 2>&1; then if stat -c '%u %a %h' "$HOME" >/dev/null 2>&1; then
@@ -224,6 +247,7 @@ check_path "$local_config" file
check_path "$replay_state" file check_path "$replay_state" file
check_path "$pending_bundle" file check_path "$pending_bundle" file
check_path "$pending_state" file check_path "$pending_state" file
check_path "$pending_source" file
validate_cron() { validate_cron() {
printf '%s\n' "$1" | awk ' printf '%s\n' "$1" | awk '
@@ -387,6 +411,68 @@ install_script() {
say 'Installed local script. Scheduled runs update keys only.' say 'Installed local script. Scheduled runs update keys only.'
} }
sshd_present() { command -v sshd >/dev/null 2>&1; }
cron_present() { command -v crontab >/dev/null 2>&1; }
ssh_client_present() { command -v ssh-keygen >/dev/null 2>&1; }
select_package_manager() {
if command -v apt-get >/dev/null 2>&1; then package_manager=apt-get
elif command -v dnf >/dev/null 2>&1; then package_manager=dnf
elif command -v yum >/dev/null 2>&1; then package_manager=yum
else fail 'No supported package manager (apt-get, dnf or yum); install the missing dependency explicitly.'; fi
}
install_dependency() {
[ "$uid" = 0 ] || fail 'Missing system dependency requires root; no privilege escalation is attempted.'
select_package_manager
case "$1:$package_manager" in
client:apt-get) package=openssh-client ;;
client:*) package=openssh-clients ;;
server:*) package=openssh-server ;;
cron:apt-get) package=cron ;;
cron:*) package=crontabs ;;
*) fail 'Unknown dependency request.' ;;
esac
if [ "$dry_run" = yes ]; then say "Would install $package using $package_manager."; return; fi
say "Installing missing dependency $package using $package_manager. Package hooks may start services or generate host keys; this script never reloads sshd."
case $package_manager in
apt-get)
if [ "$apt_index_updated" = no ]; then
apt-get update || fail 'Package index update failed. No managed key/policy writes were attempted; package metadata may have changed.'
apt_index_updated=yes
fi
apt-get install -y "$package" || fail 'Package installation failed or was partial. No managed key/policy writes were attempted; inspect package state before retrying.' ;;
dnf) dnf install -y "$package" || fail 'Package installation failed or was partial; inspect dnf before retrying.' ;;
yum) yum install -y "$package" || fail 'Package installation failed or was partial; inspect yum before retrying.' ;;
esac
}
ensure_ssh_client() {
if ssh_client_present; then return; fi
if [ "$mode" = keys ] || [ "$no_install" = yes ] || [ "$uid" != 0 ]; then
fail 'Missing ssh-keygen: install OpenSSH client tools explicitly (keys-only, -n and non-root runs never bootstrap packages).'
fi
install_dependency client
if [ "$dry_run" = yes ]; then fail 'Cannot validate keys in a dry run without ssh-keygen; install the client dependency before retrying.'; fi
ssh_client_present || fail 'OpenSSH client package completed but ssh-keygen is still unavailable; check PATH and package status.'
}
ensure_sshd() {
if [ "$no_install" = yes ]; then say 'SSHD installation/check skipped (-n).'; return; fi
if ! sshd_present; then
[ "$uid" = 0 ] || fail 'SSHD is not installed and this is a non-root run. Ask an administrator to install it, or use -o for keys only.'
install_dependency server
if [ "$dry_run" = yes ]; then return; fi
sshd_present || fail 'Server package completed but sshd is still unavailable; check PATH and package status.'
fi
if [ "$uid" = 0 ]; then
sshd -T >/dev/null 2>&1 || fail 'SSHD exists but its configuration cannot be evaluated (invalid configuration or permissions). No reinstall was attempted; inspect sshd -T.'
fi
}
ensure_cron() {
if cron_present; then return; fi
[ "$uid" = 0 ] || fail 'crontab is missing and this is a non-root run; ask an administrator to install cron.'
install_dependency cron
if [ "$dry_run" = yes ]; then return; fi
cron_present || fail 'Cron package completed but crontab is still unavailable; check PATH and package status.'
}
# Rewrite only an exact paired managed block or the exact known legacy command. # Rewrite only an exact paired managed block or the exact known legacy command.
# Unexpected cron errors and ambiguous/malformed managed entries fail closed. # Unexpected cron errors and ambiguous/malformed managed entries fail closed.
prepare_cron() { prepare_cron() {
@@ -500,7 +586,7 @@ validate_payload() {
esac esac
done < "$payload" done < "$payload"
} }
apply_payload() { prepare_authorized_keys() {
[ -d "$ssh_dir" ] || { mkdir "$ssh_dir"; created_ssh=yes; } [ -d "$ssh_dir" ] || { mkdir "$ssh_dir"; created_ssh=yes; }
# Existing secure directory permissions need not be changed in dry-run mode. # Existing secure directory permissions need not be changed in dry-run mode.
if [ "$dry_run" = no ]; then chmod 700 "$ssh_dir"; fi if [ "$dry_run" = no ]; then chmod 700 "$ssh_dir"; fi
@@ -550,6 +636,11 @@ apply_payload() {
printf '%s\n' "$END_KEYS" printf '%s\n' "$END_KEYS"
} > "$new_auth" } > "$new_auth"
fi fi
check_path "$auth_file" file
check_expected_file "$auth_file" "$auth_snapshot"
auth_prepared=yes
}
commit_authorized_keys() {
check_path "$auth_file" file check_path "$auth_file" file
check_expected_file "$auth_file" "$auth_snapshot" check_expected_file "$auth_file" "$auth_snapshot"
if [ "$auth_snapshot" != absent ] && cmp -s "$auth_snapshot" "$new_auth"; then if [ "$auth_snapshot" != absent ] && cmp -s "$auth_snapshot" "$new_auth"; then
@@ -561,28 +652,92 @@ apply_payload() {
fi fi
} }
edit_sshd_config() { apply_payload() {
if [ "$auth_prepared" = no ]; then prepare_authorized_keys; fi
if [ "$policy" = off ]; then invalidate_audit_current; fi
commit_authorized_keys
}
# Support the standard global drop-in directory only. Never evaluate arbitrary
# Include globs from configuration or assume a handful of Match contexts suffice.
inspect_config_directives() {
awk -v allow="$2" -v standard="$include_dir/*.conf" '
function directive(line, name) {
sub(/^[[:space:]]*/,"",line)
if(substr(line,1,1)=="#") return ""
name=line
if(substr(name,1,1)=="\"") {name=substr(name,2);sub(/\".*$/,"",name)}
else sub(/[[:space:]=].*$/,"",name)
return tolower(name)
}
{
name=directive($0)
if(name=="match") exit 1
if(name=="include") {
if(allow!="yes") exit 1
line=$0; sub(/^[[:space:]]*/,"",line)
token=line; sub(/[[:space:]].*$/,"",token)
if(tolower(token)!="include") exit 1
sub(/^[^[:space:]]+[[:space:]]+/,"",line)
sub(/[[:space:]]+#.*$/,"",line); sub(/[[:space:]]*$/,"",line)
if(line!=standard && line!="sshd_config.d/*.conf") exit 1
includes=1
}
}
END {if(includes) print "yes"}
' "$1"
}
list_standard_includes() {
: > "$1"
check_path "$include_dir" dir
if [ -d "$include_dir" ]; then
for include_file in "$include_dir"/*.conf; do
if [ ! -e "$include_file" ] && [ ! -L "$include_file" ]; then continue; fi
check_path "$include_file" file
[ "$(printf '%s' "$include_file" | tr -d '\040-\176\200-\377'; printf .)" = . ] || fail 'Unsupported control character in SSH drop-in filename.'
printf '%s\n' "$include_file" >> "$1"
done
fi
}
snapshot_standard_includes() {
new_temp "$state"; include_inventory=$TEMP
list_standard_includes "$include_inventory"
if [ -d "$include_dir" ]; then include_directory_state=present; else include_directory_state=absent; fi
new_temp "$state"; include_snapshots=$TEMP
while IFS= read -r include_file; do
new_temp "$state"; include_snapshot=$TEMP
cp "$include_file" "$include_snapshot"
inspect_config_directives "$include_snapshot" no >/dev/null || fail 'SSH drop-ins contain Match or nested Include; review root-login policy manually.'
printf '%s\t%s\n' "$include_file" "$include_snapshot" >> "$include_snapshots"
done < "$include_inventory"
}
check_standard_includes() {
new_temp "$state"; checked_inventory=$TEMP
list_standard_includes "$checked_inventory"
if [ "$include_directory_state" = absent ] && [ -d "$include_dir" ]; then fail 'SSH drop-in directory appeared concurrently; retry after reviewing it.'; fi
if [ "$include_directory_state" = present ] && [ ! -d "$include_dir" ]; then fail 'SSH drop-in directory disappeared concurrently; retry after reviewing it.'; fi
cmp -s "$include_inventory" "$checked_inventory" || fail 'SSH drop-in filename inventory changed concurrently; retry after reviewing it.'
tab=$(printf '\t')
while IFS="$tab" read -r include_file include_snapshot; do
check_path "$include_file" file
check_expected_file "$include_file" "$include_snapshot"
done < "$include_snapshots"
}
prepare_sshd_config() {
[ "$uid" = 0 ] || fail '-p requires root.' [ "$uid" = 0 ] || fail '-p requires root.'
need sshd need sshd
config=/etc/ssh/sshd_config config=/etc/ssh/sshd_config
check_path /etc/ssh dir check_path /etc/ssh dir
check_path "$config" file check_path "$config" file
[ -f "$config" ] || fail 'sshd_config does not exist; install/configure sshd yourself first.' [ -f "$config" ] || fail 'sshd_config does not exist; install/configure sshd yourself first.'
new_temp /etc/ssh; config_snapshot=$TEMP new_temp /etc/ssh; sshd_snapshot=$TEMP
cp "$config" "$config_snapshot" cp "$config" "$sshd_snapshot"
# Editing a global directive cannot reliably predict Include/Match overrides. include_dir=/etc/ssh/sshd_config.d
if awk ' allowed_includes=$(inspect_config_directives "$sshd_snapshot" yes) || fail 'sshd_config uses Match or an unsupported Include. Only the standard sshd_config.d/*.conf global include is supported; review other layouts manually.'
{ name=$0; sub(/^[[:space:]]*/,"",name) if [ "$allowed_includes" = yes ]; then snapshot_standard_includes; fi
if(substr(name,1,1)=="\"") { name=substr(name,2); sub(/\".*$/,"",name) }
else sub(/[[:space:]=].*$/,"",name)
name=tolower(name); if(name=="include" || name=="match") { found=1; exit }
}
END { exit !found }
' "$config_snapshot"; then
fail 'sshd_config uses Include or Match. Review and change root-login policy manually; nothing changed.'
fi
sshd -t -f "$config" || fail 'Existing sshd_config is invalid.' sshd -t -f "$config" || fail 'Existing sshd_config is invalid.'
new_temp /etc/ssh; config_tmp=$TEMP new_temp /etc/ssh; sshd_staged=$TEMP
if [ "$root_password" = yes ]; then setting=yes; else setting=prohibit-password; fi if [ "$root_password" = yes ]; then setting=yes; else setting=prohibit-password; fi
awk -v value="$setting" ' awk -v value="$setting" '
BEGIN { print "PermitRootLogin " value } BEGIN { print "PermitRootLogin " value }
@@ -591,13 +746,17 @@ edit_sshd_config() {
else sub(/[[:space:]=].*$/,"",name) else sub(/[[:space:]=].*$/,"",name)
if(tolower(name)!="permitrootlogin") print if(tolower(name)!="permitrootlogin") print
} }
' "$config_snapshot" > "$config_tmp" ' "$sshd_snapshot" > "$sshd_staged"
sshd -t -f "$config_tmp" || fail 'Staged sshd_config failed validation.' sshd -t -f "$sshd_staged" || fail 'Staged sshd_config failed validation.'
new_temp "$state"; effective=$TEMP new_temp "$state"; effective=$TEMP
sshd -T -f "$config_tmp" > "$effective" || fail 'Cannot evaluate staged sshd_config.' sshd -T -f "$sshd_staged" > "$effective" || fail 'Cannot evaluate staged sshd_config.'
actual=$(awk '$1=="permitrootlogin" {print $2}' "$effective") actual=$(awk '$1=="permitrootlogin" {print $2}' "$effective")
case "$setting:$actual" in yes:yes|prohibit-password:prohibit-password|prohibit-password:without-password) : ;; *) fail 'Effective root-login policy does not match the requested setting.' ;; esac case "$setting:$actual" in yes:yes|prohibit-password:prohibit-password|prohibit-password:without-password) : ;; *) fail 'Effective root-login policy does not match the requested setting.' ;; esac
replace_file "$config" "$config_tmp" 600 "$config_snapshot" if [ "$allowed_includes" = yes ]; then check_standard_includes; fi
}
commit_sshd_config() {
if [ "$allowed_includes" = yes ]; then check_standard_includes; fi
replace_file "$config" "$sshd_staged" 600 "$sshd_snapshot"
say 'Root-login policy validated. No service reload was performed; test recovery access before applying it.' say 'Root-login policy validated. No service reload was performed; test recovery access before applying it.'
[ "$root_password" != yes ] || say 'PermitRootLogin yes does not enable PasswordAuthentication if it is disabled elsewhere.' [ "$root_password" != yes ] || say 'PermitRootLogin yes does not enable PasswordAuthentication if it is disabled elsewhere.'
} }
@@ -606,7 +765,7 @@ edit_sshd_config() {
validate_url() { validate_url() {
[ "${#1}" -le 2048 ] || return 1 [ "${#1}" -le 2048 ] || return 1
printf '%s\n' "$1" | grep -Eq '^https://([A-Za-z0-9]([A-Za-z0-9.-]*[A-Za-z0-9])?|\[[0-9A-Fa-f:]+\])(:[0-9]{1,5})?(/[!-~]*)?$' || return 1 printf '%s\n' "$1" | grep -Eq '^https://([A-Za-z0-9]([A-Za-z0-9.-]*[A-Za-z0-9])?|\[[0-9A-Fa-f:]+\])(:[0-9]{1,5})?(/[!-~]*)?$' || return 1
case $1 in *'?'*|*'#'*|*'\'*) return 1 ;; esac case $1 in *'?'*|*'#'*|*\\*) return 1 ;; esac
} }
strict_text() { strict_text() {
last_lf "$1" || fail 'Local data must end with LF.' last_lf "$1" || fail 'Local data must end with LF.'
@@ -662,7 +821,7 @@ hkps://keyserver.ubuntu.com"
fi fi
validate_urls "$urls" validate_urls "$urls"
validate_discovery_settings validate_discovery_settings
if [ "$policy" = required ] && [ "$mode" != uninstall ] && [ "$mode" != config ] && [ "$cron_value" != false ] && [ "$requested_verification" != off ]; then if [ "$policy" = required ] && [ "$mode" != uninstall ] && [ "$cron_value" != false ] && [ "$requested_verification" != off ]; then
trust_key=$state/trust-$pinned_fingerprint-$pin_mode.asc trust_key=$state/trust-$pinned_fingerprint-$pin_mode.asc
check_path "$trust_key" file check_path "$trust_key" file
[ -f "$trust_key" ] || [ "$mode" = install ] || [ "$trust_options" = yes ] || fail 'Dedicated pinned certificate is missing; reinstall explicitly to recover it.' [ -f "$trust_key" ] || [ "$mode" = install ] || [ "$trust_options" = yes ] || fail 'Dedicated pinned certificate is missing; reinstall explicitly to recover it.'
@@ -708,7 +867,8 @@ $key_description.primary"
} }
validate_discovery_settings() { validate_discovery_settings() {
if [ "$gpg_email" != - ]; then if [ "$gpg_email" != - ]; then
[ "${#gpg_email}" -le 254 ] && printf '%s\n' "$gpg_email" | grep -Eq '^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,63}$' || fail 'Invalid WKD email address.' if [ "${#gpg_email}" -le 254 ] && printf '%s\n' "$gpg_email" | grep -Eq '^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,63}$'; then :
else fail 'Invalid WKD email address.'; fi
fi fi
new_temp "$state"; server_list=$TEMP new_temp "$state"; server_list=$TEMP
printf '%s\n' "$keyservers" > "$server_list" printf '%s\n' "$keyservers" > "$server_list"
@@ -731,11 +891,12 @@ accept_certificate_candidate() {
# or a rejected certificate from contaminating the final verifier. # or a rejected certificate from contaminating the final verifier.
reset_gpg_home reset_gpg_home
new_temp "$state"; candidate_export=$TEMP new_temp "$state"; candidate_export=$TEMP
if ( lock_owned=no; created_state=no; created_ssh=no; temps=; if ( lock_owned=no; created_state=no; created_ssh=no; temps=; candidate_result=0
trap 'candidate_result=$?; gpg_home=; cleanup "$candidate_result"' 0 trap 'candidate_result=$?; gpg_home=; cleanup "$candidate_result"' 0
import_pinned_key "$1" import_pinned_key "$1"
gpg_local --armor --export "$certificate_primary" > "$candidate_export" || fail 'Candidate certificate cannot be exported by this GnuPG version.' gpg_local --armor --export "$certificate_primary" > "$candidate_export" || fail 'Candidate certificate cannot be exported by this GnuPG version.'
[ -s "$candidate_export" ] && [ "$(wc -c < "$candidate_export" | tr -d ' ')" -le 1048576 ] || fail 'Candidate public certificate export is empty or oversized.' ); then if [ -s "$candidate_export" ] && [ "$(wc -c < "$candidate_export" | tr -d ' ')" -le 1048576 ]; then :
else fail 'Candidate public certificate export is empty or oversized.'; fi ); then
import_pinned_key "$candidate_export" import_pinned_key "$candidate_export"
staged_trust=$candidate_export staged_trust=$candidate_export
trust_key=$state/trust-$pinned_fingerprint-$pin_mode.asc trust_key=$state/trust-$pinned_fingerprint-$pin_mode.asc
@@ -867,7 +1028,9 @@ prepare_install_config() {
requested_fingerprint=${requested_fingerprint:-$old_pin} requested_fingerprint=${requested_fingerprint:-$old_pin}
requested_list_id=${requested_list_id:-$old_list} requested_list_id=${requested_list_id:-$old_list}
fi fi
[ -n "$requested_fingerprint" ] && [ -n "$requested_list_id" ] || fail 'Enabling verification requires --gpg-fingerprint and --list-id (or configured defaults).' if [ -z "$requested_fingerprint" ] || [ -z "$requested_list_id" ]; then
fail 'Enabling verification requires --gpg-fingerprint and --list-id (or configured defaults).'
fi
requested_pin_mode=${requested_pin_mode:-$pin_mode} requested_pin_mode=${requested_pin_mode:-$pin_mode}
case $requested_pin_mode in primary|subkey) : ;; *) fail '--gpg-pin-mode must be primary or subkey.' ;; esac case $requested_pin_mode in primary|subkey) : ;; *) fail '--gpg-pin-mode must be primary or subkey.' ;; esac
requested_fingerprint=$(printf '%s' "$requested_fingerprint" | tr 'a-f' 'A-F') requested_fingerprint=$(printf '%s' "$requested_fingerprint" | tr 'a-f' 'A-F')
@@ -887,7 +1050,8 @@ prepare_install_config() {
discover_certificate discover_certificate
if [ "$old_pin" != - ] && [ -n "$requested_min_sequence" ]; then if [ "$old_pin" != - ] && [ -n "$requested_min_sequence" ]; then
floor=$requested_min_sequence floor=$requested_min_sequence
printf '%s\n' "$floor" | grep -Eq '^[1-9][0-9]{0,9}$' && [ "$floor" -le 2147483647 ] || fail 'Minimum sequence must be 1..2147483647.' if printf '%s\n' "$floor" | grep -Eq '^[1-9][0-9]{0,9}$' && [ "$floor" -le 2147483647 ]; then :
else fail 'Minimum sequence must be 1..2147483647.'; fi
new_temp "$state"; replay_snapshot=$TEMP new_temp "$state"; replay_snapshot=$TEMP
cp "$replay_state" "$replay_snapshot" cp "$replay_state" "$replay_snapshot"
read_replay_state "$replay_snapshot" read_replay_state "$replay_snapshot"
@@ -897,9 +1061,12 @@ prepare_install_config() {
else cp "$replay_snapshot" "$staged_replay"; fi else cp "$replay_snapshot" "$staged_replay"; fi
fi fi
if [ "$old_pin" = - ]; then if [ "$old_pin" = - ]; then
[ ! -e "$replay_state" ] && [ ! -e "$pending_state" ] && [ ! -e "$pending_bundle" ] || fail 'Existing security state requires manual recovery; cannot silently enroll over it.' if [ -e "$replay_state" ] || [ -e "$pending_state" ] || [ -e "$pending_bundle" ]; then
fail 'Existing security state requires manual recovery; cannot silently enroll over it.'
fi
floor=${requested_min_sequence:-1} floor=${requested_min_sequence:-1}
printf '%s\n' "$floor" | grep -Eq '^[1-9][0-9]{0,9}$' && [ "$floor" -le 2147483647 ] || fail 'Minimum sequence must be 1..2147483647.' if printf '%s\n' "$floor" | grep -Eq '^[1-9][0-9]{0,9}$' && [ "$floor" -le 2147483647 ]; then :
else fail 'Minimum sequence must be 1..2147483647.'; fi
new_temp "$state"; staged_replay=$TEMP new_temp "$state"; staged_replay=$TEMP
write_replay_record "$((floor - 1))" - "$staged_replay" write_replay_record "$((floor - 1))" - "$staged_replay"
fi fi
@@ -968,7 +1135,8 @@ validate_signed_document() {
[ "$release_list" = "$list_id" ] || fail 'Signed list identity does not match local enrollment.' [ "$release_list" = "$list_id" ] || fail 'Signed list identity does not match local enrollment.'
now=$(date +%s) now=$(date +%s)
if [ "$enforce_freshness" = yes ]; then if [ "$enforce_freshness" = yes ]; then
[ "$issued_at" -le "$((now + 300))" ] && [ "$expires_at" -gt "$now" ] || fail 'Signed release is expired or issued too far in the future.' if [ "$issued_at" -le "$((now + 300))" ] && [ "$expires_at" -gt "$now" ]; then :
else fail 'Signed release is expired or issued too far in the future.'; fi
fi fi
fi fi
digest=$(sha256 "$response") digest=$(sha256 "$response")
@@ -995,6 +1163,8 @@ verify_signature() {
if($5 !~ /^(0|[1-9][0-9]*)$/ || length($5)>10 || $5+0>now+300 || $6 !~ /^(0|[1-9][0-9]*)$/ || length($6)>10 || ($6!=0 && $6+0<=now)) bad=1 } if($5 !~ /^(0|[1-9][0-9]*)$/ || length($5)>10 || $5+0>now+300 || $6 !~ /^(0|[1-9][0-9]*)$/ || length($6)>10 || ($6!=0 && $6+0<=now)) bad=1 }
END {if(bad || good!=1) exit 1} END {if(bad || good!=1) exit 1}
' "$gpg_status" || fail 'Signature status lacks one valid, unexpired signature from the locally pinned primary/subkey.' ' "$gpg_status" || fail 'Signature status lacks one valid, unexpired signature from the locally pinned primary/subkey.'
actual_signer=$(awk '$2=="VALIDSIG" {print $3}' "$gpg_status")
actual_primary=$(awk '$2=="VALIDSIG" {print $12}' "$gpg_status")
} }
check_replay() { check_replay() {
[ "$sequence" -ge "$recorded_sequence" ] || fail 'Signed sequence rollback rejected.' [ "$sequence" -ge "$recorded_sequence" ] || fail 'Signed sequence rollback rejected.'
@@ -1032,7 +1202,124 @@ read_pending_bundle() {
make_pending_bundle "$rebuilt" make_pending_bundle "$rebuilt"
cmp -s "$rebuilt" "$pending_bundle" || fail 'Noncanonical pending package.' cmp -s "$rebuilt" "$pending_bundle" || fail 'Noncanonical pending package.'
} }
check_audit_paths() {
check_path "$audit_dir" dir
check_path "$audit_dir/current" file
check_path "$audit_dir/previous" file
for audit_slot_name in a b; do
check_path "$audit_dir/$audit_slot_name" dir
for audit_name in ssh.keys ssh.keys.sig metadata; do
check_path "$audit_dir/$audit_slot_name/$audit_name" file
done
done
}
read_audit_pointer() {
pointer_slot=
pointer_snapshot=absent
if [ -f "$1" ]; then
new_temp "$state"; pointer_snapshot=$TEMP
cp "$1" "$pointer_snapshot"
[ "$(wc -c < "$pointer_snapshot" | tr -d ' ')" = 34 ] || fail 'Malformed audit pointer.'
awk 'NR==1 {if($0!="conf-sshd-audit-pointer-v1") exit 1} NR==2 {if($0!="slot a" && $0!="slot b") exit 1} END {if(NR!=2) exit 1}' "$pointer_snapshot" || fail 'Malformed audit pointer.'
last_lf "$pointer_snapshot" || fail 'Malformed audit pointer ending.'
pointer_slot=$(sed -n '2s/^slot //p' "$pointer_snapshot")
fi
}
audit_replace() {
check_path "$1" file
chmod 600 "$2"
mv -f "$2" "$1"
}
audit_pointer() {
new_temp "$audit_dir"; new_pointer=$TEMP
printf 'conf-sshd-audit-pointer-v1\nslot %s\n' "$2" > "$new_pointer"
audit_replace "$1" "$new_pointer"
}
prepare_audit_slot() {
check_audit_paths
if [ "$dry_run" = yes ]; then return; fi
[ -d "$audit_dir" ] || mkdir "$audit_dir"
chmod 700 "$audit_dir"
read_audit_pointer "$audit_dir/current"
audit_old_current=$pointer_slot
audit_current_snapshot=$pointer_snapshot
read_audit_pointer "$audit_dir/previous"
audit_old_previous=$pointer_slot
audit_previous_snapshot=$pointer_snapshot
audit_base=${audit_old_current:-$audit_old_previous}
if [ "$audit_base" = a ]; then audit_slot=b; else audit_slot=a; fi
# This slot is about to be reused; stop advertising it as history first.
if [ "$audit_old_previous" = "$audit_slot" ]; then
check_expected_file "$audit_dir/previous" "$audit_previous_snapshot"
rm -f "$audit_dir/previous"
audit_previous_snapshot=absent
fi
[ -d "$audit_dir/$audit_slot" ] || mkdir "$audit_dir/$audit_slot"
chmod 700 "$audit_dir/$audit_slot"
new_temp "$audit_dir/$audit_slot"; audit_document=$TEMP
cp "$response" "$audit_document"
new_temp "$audit_dir/$audit_slot"; audit_signature=$TEMP
cp "$signature" "$audit_signature"
audit_replace "$audit_dir/$audit_slot/ssh.keys" "$audit_document"
audit_replace "$audit_dir/$audit_slot/ssh.keys.sig" "$audit_signature"
}
invalidate_audit_current() {
if [ "$dry_run" = yes ]; then return; fi
check_audit_paths
[ -d "$audit_dir" ] || return 0
if [ "$policy" != required ]; then
read_audit_pointer "$audit_dir/current"
audit_old_current=$pointer_slot
audit_current_snapshot=$pointer_snapshot
read_audit_pointer "$audit_dir/previous"
audit_previous_snapshot=$pointer_snapshot
fi
check_expected_file "$audit_dir/current" "$audit_current_snapshot"
check_expected_file "$audit_dir/previous" "$audit_previous_snapshot"
if [ -n "$audit_old_current" ]; then
audit_pointer "$audit_dir/previous" "$audit_old_current"
rm -f "$audit_dir/current"
fi
}
publish_audit_slot() {
if [ "$dry_run" = yes ]; then say 'Would publish the verified original key document and detached signature to the bounded local audit.'; return; fi
check_audit_paths
if cmp -s "$response" "$audit_dir/$audit_slot/ssh.keys" && cmp -s "$signature" "$audit_dir/$audit_slot/ssh.keys.sig"; then :
else fail 'Audit staging changed; current audit remains uncommitted.'; fi
new_temp "$audit_dir/$audit_slot"; audit_metadata=$TEMP
printf 'conf-sshd-audit-v1\ninstalled-at %s\nclient-version conf-sshd-client-security-v1\nlist-id %s\nsequence %s\nsource %s\nsigner-fingerprint %s\nprimary-fingerprint %s\npin-mode %s\npinned-fingerprint %s\ndocument-sha256 %s\nsignature-sha256 %s\n' \
"$(date +%s)" "$list_id" "$sequence" "$accepted_source" "$actual_signer" "$actual_primary" "$pin_mode" "$pinned_fingerprint" "$digest" "$(sha256 "$signature")" > "$audit_metadata"
audit_replace "$audit_dir/$audit_slot/metadata" "$audit_metadata"
check_expected_file "$audit_dir/current" absent
audit_pointer "$audit_dir/current" "$audit_slot"
}
write_pending_source() {
new_temp "$state"; source_record=$TEMP
printf 'conf-sshd-pending-source-v1\nsequence %s\ndocument-sha256 %s\nsignature-sha256 %s\nsource %s\n' \
"$sequence" "$digest" "$(sha256 "$signature")" "$accepted_source" > "$source_record"
replace_file "$pending_source" "$source_record" 600
}
read_pending_source() {
accepted_source=local-pending:original-source-unavailable
if [ ! -f "$pending_source" ]; then return; fi
[ "$(wc -c < "$pending_source" | tr -d ' ')" -le 2400 ] || fail 'Pending source context is oversized.'
strict_text "$pending_source"
awk -v seq="$sequence" -v doc="$digest" -v sig="$(sha256 "$signature")" '
NR==1 {if($0!="conf-sshd-pending-source-v1") exit 1}
NR==2 {if($0!="sequence " seq) exit 1}
NR==3 {if($0!="document-sha256 " doc) exit 1}
NR==4 {if($0!="signature-sha256 " sig) exit 1}
NR==5 {if(substr($0,1,7)!="source " || length($0)<8) exit 1}
END {if(NR!=5) exit 1}
' "$pending_source" || fail 'Pending source context does not match its exact verified package.'
accepted_source=$(sed -n '5s/^source //p' "$pending_source")
if [ "$accepted_source" != local-pending:original-source-unavailable ]; then
validate_url "$accepted_source" || fail 'Pending audit source is not a configured-format HTTPS URL.'
fi
}
finish_verified_release() { finish_verified_release() {
if [ "$auth_prepared" = no ]; then prepare_authorized_keys; fi
# Commit a complete verified package before advancing any anti-rollback state # Commit a complete verified package before advancing any anti-rollback state
# or replacing keys. Atomic renames survive process interruption, not every # or replacing keys. Atomic renames survive process interruption, not every
# power-loss/filesystem failure; no portable fsync promise is made here. # power-loss/filesystem failure; no portable fsync promise is made here.
@@ -1043,24 +1330,28 @@ finish_verified_release() {
new_temp "$state"; promoted_state=$TEMP new_temp "$state"; promoted_state=$TEMP
write_replay_record "$recorded_sequence" "$recorded_digest" "$promoted_state" write_replay_record "$recorded_sequence" "$recorded_digest" "$promoted_state"
replace_file "$replay_state" "$promoted_state" 600 replace_file "$replay_state" "$promoted_state" 600
rm -f "$pending_state" "$pending_bundle" rm -f "$pending_state" "$pending_bundle" "$pending_source"
fi fi
if [ ! -f "$pending_state" ]; then if [ ! -f "$pending_state" ]; then
new_temp "$state"; pending_tmp=$TEMP new_temp "$state"; pending_tmp=$TEMP
make_pending_bundle "$pending_tmp" make_pending_bundle "$pending_tmp"
replace_file "$pending_bundle" "$pending_tmp" 600 replace_file "$pending_bundle" "$pending_tmp" 600
write_pending_source
new_temp "$state"; pending_record=$TEMP new_temp "$state"; pending_record=$TEMP
write_replay_record "$sequence" "$digest" "$pending_record" write_replay_record "$sequence" "$digest" "$pending_record"
replace_file "$pending_state" "$pending_record" 600 absent replace_file "$pending_state" "$pending_record" 600 absent
fi fi
fi fi
apply_payload prepare_audit_slot
invalidate_audit_current
commit_authorized_keys
if [ "$dry_run" = no ]; then if [ "$dry_run" = no ]; then
new_temp "$state"; final_state=$TEMP new_temp "$state"; final_state=$TEMP
write_replay_record "$sequence" "$digest" "$final_state" write_replay_record "$sequence" "$digest" "$final_state"
replace_file "$replay_state" "$final_state" 600 replace_file "$replay_state" "$final_state" 600
rm -f "$pending_state" "$pending_bundle" publish_audit_slot
fi rm -f "$pending_state" "$pending_bundle" "$pending_source"
else publish_audit_slot; fi
} }
sync_keys() { sync_keys() {
need ssh-keygen need ssh-keygen
@@ -1094,16 +1385,21 @@ sync_keys() {
enforce_freshness=yes enforce_freshness=yes
check_replay check_replay
if [ -n "$pending_sequence" ]; then if [ -n "$pending_sequence" ]; then
[ "$sequence" = "$pending_sequence" ] && [ "$digest" = "$pending_digest" ] || fail 'Pending package does not match its rollback record.' if [ "$sequence" != "$pending_sequence" ] || [ "$digest" != "$pending_digest" ]; then
fail 'Pending package does not match its rollback record.'
fi
elif [ "$sequence" = "$recorded_sequence" ] && [ "$digest" = "$recorded_digest" ]; then elif [ "$sequence" = "$recorded_sequence" ] && [ "$digest" = "$recorded_digest" ]; then
# Crash while removing an old journal after promoting its # Crash while removing an old journal after promoting its
# floor: committed state already authenticates these bytes. # floor: committed state already authenticates these bytes.
pending_sequence=$sequence pending_sequence=$sequence
pending_digest=$digest pending_digest=$digest
fi fi
if ( lock_owned=no; created_state=no; created_ssh=no; temps=; if ( lock_owned=no; created_state=no; created_ssh=no; temps=; child_result=0
trap 'child_result=$?; gpg_home=; cleanup "$child_result"' 0 trap 'child_result=$?; gpg_home=; cleanup "$child_result"' 0
validate_response ); then validate_response ); then
# Subshell checks do not propagate machine-status fields.
verify_signature
read_pending_source
if [ "$validation_only" = no ]; then if [ "$validation_only" = no ]; then
finish_verified_release finish_verified_release
say 'Recovered the same verified pending release; no fallback was fetched.' say 'Recovered the same verified pending release; no fallback was fetched.'
@@ -1135,11 +1431,11 @@ sync_keys() {
say "Signature unavailable from $source_url; trying next locally configured source." >&2 say "Signature unavailable from $source_url; trying next locally configured source." >&2
continue continue
fi fi
if ( lock_owned=no; created_state=no; created_ssh=no; gpg_home_saved=$gpg_home; gpg_home=; temps=; if ( lock_owned=no; created_state=no; created_ssh=no; gpg_home_saved=$gpg_home; gpg_home=; temps=; child_result=0
# Preserve the caller keyring but never delete it in this subshell. # Preserve the caller keyring but never delete it in this subshell.
gpg_home=$gpg_home_saved gpg_home=$gpg_home_saved
trap 'child_result=$?; gpg_home=; cleanup "$child_result"' 0 trap 'child_result=$?; gpg_home=; cleanup "$child_result"' 0
validate_response ); then accepted=yes; break; fi validate_response ); then accepted=yes; accepted_source=$source_url; break; fi
fi fi
say "Rejected source $source_url; trying next locally configured source." >&2 say "Rejected source $source_url; trying next locally configured source." >&2
done < "$urls" done < "$urls"
@@ -1150,56 +1446,54 @@ sync_keys() {
fi fi
} }
if [ "$mode" = uninstall ] || [ "$mode" = config ] || { [ "$mode" = sync ] && [ "$cron_requested" = yes ] && [ "$cron_value" = false ]; }; then if [ "$mode" = uninstall ] || { [ "$mode" = sync ] && [ "$cron_requested" = yes ] && [ "$cron_value" = false ] && [ -z "$root_password" ]; }; then
policy=off policy=off
config_snapshot=absent config_snapshot=absent
else load_local_config; fi else load_local_config; fi
case $mode in case $mode in
install)
validate_build
prepare_install_config
prepare_script
if [ "$cron_requested" = yes ]; then
prepare_cron
if [ -z "$cron_value" ]; then cron_value=${existing_schedule:-$DEFAULT_CRON}; fi
fi
validation_only=yes
sync_keys
# Activate policy only after replacing any legacy scheduled executable.
install_script
commit_install_config
if [ "$policy" = required ]; then finish_verified_release; else apply_payload; fi
if [ "$cron_requested" = yes ]; then apply_cron "$cron_value"; fi ;;
uninstall) uninstall)
if command -v crontab >/dev/null 2>&1; then prepare_cron; apply_cron false; fi if cron_present; then prepare_cron; apply_cron false; fi
for uninstall_file in "$target_script" "$target_script.conf-sshd.bak"; do for uninstall_file in "$target_script" "$target_script.conf-sshd.bak"; do
check_path "$uninstall_file" file check_path "$uninstall_file" file
if [ "$dry_run" = yes ]; then say "Would remove $uninstall_file"; else rm -f "$uninstall_file"; fi if [ "$dry_run" = yes ]; then say "Would remove $uninstall_file"; else rm -f "$uninstall_file"; fi
done done
say 'Uninstalled managed scheduling/script. Authorized keys, trust and rollback state are preserved.' ;; say 'Uninstalled managed scheduling/script. Authorized keys, trust and rollback state are preserved.' ;;
config) edit_sshd_config ;; sync|keys|install)
sync|keys)
script_tmp= script_tmp=
if [ "$cron_requested" = yes ]; then if [ "$mode" = sync ] && [ "$cron_requested" = yes ] && [ "$cron_value" = false ] && [ -z "$root_password" ]; then
prepare_cron if cron_present; then prepare_cron; apply_cron false
if [ "$cron_value" = false ]; then apply_cron false; exit 0; fi else say 'crontab is not installed; no managed schedule was changed.'; fi
validate_build exit 0
if [ -z "$cron_value" ]; then cron_value=${existing_schedule:-$DEFAULT_CRON}; fi
fi fi
if [ "$trust_options" = yes ] || { [ "$config_snapshot" = absent ] && [ "$DEFAULT_GPG_VERIFY" = on ]; }; then
validate_build validate_build
if [ "$mode" = install ] || [ "$trust_options" = yes ] || { [ "$config_snapshot" = absent ] && [ "$DEFAULT_GPG_VERIFY" = on ]; }; then
prepare_install_config prepare_install_config
fi
if [ "$mode" = install ] || { [ "$cron_requested" = yes ] && [ "$cron_value" != false ] && [ ! -f "$target_script" ]; }; then
prepare_script
fi
# Only client prerequisites may bootstrap before key verification. Trust
# consent has already happened; daemon/config/key writes remain staged.
ensure_ssh_client
validation_only=yes validation_only=yes
fi
if [ "$cron_requested" = yes ] && [ ! -f "$target_script" ]; then prepare_script; fi
sync_keys sync_keys
if [ "$config_prepared" = yes ]; then prepare_authorized_keys
if [ -n "$script_tmp" ]; then install_script; script_tmp=; fi if [ "$mode" != keys ]; then ensure_sshd; fi
commit_install_config
if [ "$policy" = required ]; then finish_verified_release; else apply_payload; fi
fi
if [ "$cron_requested" = yes ]; then if [ "$cron_requested" = yes ]; then
if [ "$cron_value" != false ]; then ensure_cron; fi
if cron_present; then
prepare_cron
cron_prepared=yes
if [ -z "$cron_value" ]; then cron_value=${existing_schedule:-$DEFAULT_CRON}; fi
elif [ "$dry_run" = yes ]; then
say 'Would inspect the existing crontab and configure scheduling after cron is installed.'
fi
fi
if [ -n "$root_password" ]; then prepare_sshd_config; fi
# Activate policy only after replacing any legacy scheduled executable.
if [ -n "$script_tmp" ]; then install_script; fi if [ -n "$script_tmp" ]; then install_script; fi
apply_cron "$cron_value" if [ "$config_prepared" = yes ]; then commit_install_config; fi
fi ;; if [ "$policy" = required ]; then finish_verified_release; else apply_payload; fi
if [ -n "$root_password" ]; then commit_sshd_config; fi
if [ "$cron_prepared" = yes ]; then apply_cron "$cron_value"; fi ;;
esac esac
+3 -3
View File
@@ -108,8 +108,8 @@ h1 { margin: 0; font-size: clamp(30px, 3.4vw, 42px); font-weight: 650; letter-sp
.text-link span { margin-left: 4px; } .text-link span { margin-left: 4px; }
.keys-notice { margin: 0 0 15px; padding: 12px 15px; border: 1px solid var(--line); border-radius: 8px; background: var(--surface-soft); color: var(--muted); font-size: 12px; } .keys-notice { margin: 0 0 15px; padding: 12px 15px; border: 1px solid var(--line); border-radius: 8px; background: var(--surface-soft); color: var(--muted); font-size: 12px; }
.keys-notice:empty { display: none; } .keys-notice:empty { display: none; }
.command-list { display: grid; gap: 14px; min-width: 0; } .command-list, .path-list { display: grid; gap: 14px; min-width: 0; }
.command-list { grid-template-columns: repeat(2, minmax(0, 1fr)); } .command-list, .path-list { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.command-card { min-width: 0; border: 1px solid var(--line); border-radius: 12px; background: var(--surface); overflow: hidden; } .command-card { min-width: 0; border: 1px solid var(--line); border-radius: 12px; background: var(--surface); overflow: hidden; }
.card-heading { display: flex; align-items: flex-start; gap: 12px; justify-content: space-between; min-width: 0; } .card-heading { display: flex; align-items: flex-start; gap: 12px; justify-content: space-between; min-width: 0; }
.card-heading h3 { min-width: 0; overflow-wrap: anywhere; font-size: 13px; font-weight: 600; margin: 0; line-height: 1.65; } .card-heading h3 { min-width: 0; overflow-wrap: anywhere; font-size: 13px; font-weight: 600; margin: 0; line-height: 1.65; }
@@ -203,7 +203,7 @@ dialog::backdrop { background: rgb(10 22 15 / 52%); }
.section-actions { width: 100%; justify-content: space-between; } .section-actions { width: 100%; justify-content: space-between; }
.commands-section > .section-heading > .text-link { margin-top: -3px; } .commands-section > .section-heading > .text-link { margin-top: -3px; }
.command-card { padding: 14px; } .command-card { padding: 14px; }
.command-list { grid-template-columns: minmax(0, 1fr); gap: 12px; } .command-list, .path-list { grid-template-columns: minmax(0, 1fr); gap: 12px; }
.card-heading { gap: 8px; } .card-heading { gap: 8px; }
pre { padding-left: 12px; padding-block: 16px; } pre { padding-left: 12px; padding-block: 16px; }
.copy-button { width: 40px; height: 40px; } .copy-button { width: 40px; height: 40px; }
+22 -13
View File
@@ -37,7 +37,7 @@
<p class="eyebrow"><span class="status-dot" aria-hidden="true"></span> SSH KEY DISTRIBUTION</p> <p class="eyebrow"><span class="status-dot" aria-hidden="true"></span> SSH KEY DISTRIBUTION</p>
<h1 id="page-title">公钥集中管理,<br>服务器快速就绪。</h1> <h1 id="page-title">公钥集中管理,<br>服务器快速就绪。</h1>
<p class="hero-description">查看发布状态,选择命令,配置 SSH。<br class="wide-only">查看当前公钥来源与内容版本,按需部署。</p> <p class="hero-description">查看发布状态,选择命令,配置 SSH。<br class="wide-only">查看当前公钥来源与内容版本,按需部署。</p>
<div class="hero-links"><a href="#key-status">发布状态 <span aria-hidden="true">↓</span></a><a href="#quick-start">快速开始 <span aria-hidden="true">↓</span></a></div> <div class="hero-links"><a href="#quick-start">Quick Usage <span aria-hidden="true">↓</span></a><a href="#key-status">Keys <span aria-hidden="true">↓</span></a><a href="#paths">Paths <span aria-hidden="true">↓</span></a></div>
</div> </div>
<aside class="release-card" aria-labelledby="release-title"> <aside class="release-card" aria-labelledby="release-title">
<div class="release-heading"><h2 id="release-title">版本与公钥来源</h2><span class="release-badge">@@RELEASE_LABEL@@</span></div> <div class="release-heading"><h2 id="release-title">版本与公钥来源</h2><span class="release-badge">@@RELEASE_LABEL@@</span></div>
@@ -46,8 +46,6 @@
<div><dt>公钥版本</dt><dd><code class="release-revision">@@CONTENT_REVISION@@</code></dd></div> <div><dt>公钥版本</dt><dd><code class="release-revision">@@CONTENT_REVISION@@</code></dd></div>
<div><dt>公钥来源</dt><dd class="source-label">@@SOURCE_LABEL@@</dd></div> <div><dt>公钥来源</dt><dd class="source-label">@@SOURCE_LABEL@@</dd></div>
<div><dt>来源状态</dt><dd class="source-status">@@SOURCE_STATUS@@</dd></div> <div><dt>来源状态</dt><dd class="source-status">@@SOURCE_STATUS@@</dd></div>
<div><dt>签名状态</dt><dd class="signature-status">@@SIGNATURE_STATUS@@</dd></div>
<div><dt>证书状态</dt><dd class="certificate-status">@@CERTIFICATE_STATUS@@</dd></div>
<div><dt>发布时间</dt><dd><time datetime="@@PUBLISHED_AT@@">@@PUBLISHED_AT@@</time></dd></div> <div><dt>发布时间</dt><dd><time datetime="@@PUBLISHED_AT@@">@@PUBLISHED_AT@@</time></dd></div>
<div><dt>公钥数量</dt><dd><strong class="key-count">@@KEY_COUNT@@</strong><span class="count-unit"> 个</span></dd></div> <div><dt>公钥数量</dt><dd><strong class="key-count">@@KEY_COUNT@@</strong><span class="count-unit"> 个</span></dd></div>
</dl> </dl>
@@ -55,29 +53,40 @@
</aside> </aside>
</section> </section>
<section id="quick-start" class="content-section commands-section" aria-labelledby="commands-title">
<div class="section-heading"><div><p class="section-index">01 / QUICK USAGE</p><h2 id="commands-title">Quick Usage · 快速使用</h2><p>远程快捷命令会立即执行下载脚本。推荐先检查并独立核验安装脚本;高信任安装与仓库配置见 <a class="text-link" href="@@REPOSITORY_URL@@#readme" target="_blank" rel="noopener noreferrer">README</a>。修改 SSH 配置时保留当前会话以验证新连接。</p></div><a class="text-link" href="/script.sh">查看脚本 <span aria-hidden="true">↗</span></a></div>
<div class="command-list">@@COMMANDS_HTML@@</div>
<p class="command-note">每条命令独立使用,按说明区分远程首次执行与已安装的本地脚本。普通部署按当前权限处理 SSH Server:root 可安装缺失依赖,非 root 不会尝试提权;-o 跳过 SSH Server 配置。软件包安装钩子可能启动服务,脚本本身不自动重载 SSH。日常同步使用固定本地脚本,不更新可执行代码或信任指纹;原 -u 改用明确重新安装。</p>
</section>
<section id="key-status" class="content-section" aria-labelledby="keys-title"> <section id="key-status" class="content-section" aria-labelledby="keys-title">
<div class="section-heading"> <div class="section-heading">
<div><p class="section-index">01 / RELEASE STATUS</p><h2 id="keys-title">公钥发布状态</h2><p>首页仅展示来源、状态、数量与内容摘要,不展示公钥列表、指纹或注释。</p></div> <div><p class="section-index">02 / KEYS</p><h2 id="keys-title">Keys · 公钥状态</h2><p>首页仅展示来源、状态、数量与内容摘要,不展示公钥列表、指纹或注释。</p></div>
<div class="section-actions"><a class="text-link" href="/ssh.keys.sig">独立签名接口 <span aria-hidden="true">↗</span></a><a class="text-link" href="/ssh.keys">公钥原始接口 <span aria-hidden="true">↗</span></a>@@CERTIFICATE_LINK@@</div> <div class="section-actions"><a class="text-link" href="/ssh.keys">公钥原始接口 <span aria-hidden="true">↗</span></a></div>
</div> </div>
<p class="keys-notice" role="note">@@KEYS_NOTICE@@</p> <p class="keys-notice" role="note">@@KEYS_NOTICE@@</p>
<div class="public-notice"><span class="notice-icon" aria-hidden="true">i</span><p>公钥不是秘密:原始 API 与公开 Git / Gist 仍可能提供公钥内容。私钥应留在离线环境,本页面不会要求上传或输入私钥。</p></div> <div class="public-notice"><span class="notice-icon" aria-hidden="true">i</span><p>公钥不是秘密:原始 API 与公开 Git / Gist 仍可能提供公钥内容。私钥应留在离线环境,本页面不会要求上传或输入私钥。</p></div>
</section> </section>
<section id="quick-start" class="content-section commands-section" aria-labelledby="commands-title"> @@SIGNATURE_SECTION_START@@
<div class="section-heading"><div><p class="section-index">02 / QUICK START</p><h2 id="commands-title">按需选择,复制即用</h2><p>首次安装的快捷命令会立即执行下载脚本。推荐先检查并独立核验安装脚本;高信任安装可按下方指引操作。修改 SSH 配置时保留当前会话以验证新连接。</p></div><a class="text-link" href="/script.sh">查看脚本 <span aria-hidden="true">↗</span></a></div> <section id="signature" class="content-section" aria-labelledby="trust-title">
<div class="command-list">@@COMMANDS_HTML@@</div> <div class="section-heading"><div><p class="section-index">03 / SIGNATURE</p><h2 id="trust-title">Signature · 签名与本地信任</h2><p>签名文件存在,不代表 Worker 已验证签名。@@VERIFICATION_DEFAULT@@</p></div><a class="text-link" href="@@REPOSITORY_URL@@#readme" target="_blank" rel="noopener noreferrer">完整安装说明 <span aria-hidden="true">↗</span></a></div>
<p class="command-note">每条命令独立使用。同步、预览及高级命令须先安装。脚本一旦落盘,日常同步不会替换可执行代码或本地信任指纹;更新脚本须在服务器重新安装。</p> <dl class="release-details">
</section> <div><dt>签名文件</dt><dd class="signature-status">@@SIGNATURE_STATUS@@</dd></div>
<section id="trust-guidance" class="content-section" aria-labelledby="trust-title"> <div><dt>公开证书</dt><dd class="certificate-status">@@CERTIFICATE_STATUS@@</dd></div>
<div class="section-heading"><div><p class="section-index">03 / LOCAL TRUST</p><h2 id="trust-title">先核验,再建立本地信任</h2><p>签名文件存在,不代表 Worker 已验证签名。客户端验签默认关闭,须在服务器本地明确启用。</p></div><a class="text-link" href="@@REPOSITORY_URL@@#readme" target="_blank" rel="noopener noreferrer">完整安装说明 <span aria-hidden="true">↗</span></a></div> </dl>
<div class="section-actions"><a class="text-link" href="/ssh.keys.sig">独立签名接口 <span aria-hidden="true">↗</span></a>@@CERTIFICATE_LINK@@</div>
<ol class="trust-steps"> <ol class="trust-steps">
<li>推荐从独立可信渠道取得安装脚本预期 SHA-256,下载、检查脚本并比对摘要后再执行。首次安装或明确重装都会信任你执行的脚本;与脚本同源取得的摘要无法建立独立信任。签名证书 pin 仍须独立核验。</li> <li>推荐从独立可信渠道取得安装脚本预期 SHA-256,下载、检查脚本并比对摘要后再执行。首次安装或明确重装都会信任你执行的脚本;与脚本同源取得的摘要无法建立独立信任。签名证书 pin 仍须独立核验。</li>
<li>可使用安装参数 --install --gpg-verification on,以 --gpg-fingerprint 固定独立核验的完整指纹,并指定 --list-id。--gpg-pin-mode 默认 primary,信任主钥及其有效绑定的签名子钥,也可选 subkey 固定签名子钥;安装或更换信任时须在服务器本地终端确认完整指纹,不能跳过确认;远端发布内容不能关闭本地验签。</li> <li>可使用安装参数 --install --gpg-verification on,以 --gpg-fingerprint 固定独立核验的完整指纹,并指定 --list-id。--gpg-pin-mode 默认 primary,信任主钥及其有效绑定的签名子钥,也可选 subkey 固定签名子钥;安装或更换信任时须在服务器本地终端确认完整指纹,不能跳过确认;远端发布内容不能关闭本地验签。</li>
<li>关闭验签时,客户端仅获取 <a class="text-link" href="/ssh.keys">/ssh.keys</a>,不请求签名或证书;启用后还须取得 <a class="text-link" href="/ssh.keys.sig">/ssh.keys.sig</a>,两者均返回 200,客户端使用已验证并保存的本地证书核验公钥文档原始字节,全部通过才接受。证书仅在本地安装流程中发现并固定,日常同步不联网刷新证书;获取或验证失败保留已有公钥。签名私钥始终离线保管。</li> <li>关闭验签时,客户端仅获取 <a class="text-link" href="/ssh.keys">/ssh.keys</a>,不请求签名或证书;启用后还须取得 <a class="text-link" href="/ssh.keys.sig">/ssh.keys.sig</a>,两者均返回 200,客户端使用已验证并保存的本地证书核验公钥文档原始字节,全部通过才接受。证书仅在本地安装流程中发现并固定,日常同步不联网刷新证书;获取或验证失败保留已有公钥。签名私钥始终离线保管。</li>
</ol> </ol>
</section> </section>
<noscript><p class="noscript-notice">JavaScript 已关闭,发布状态与命令仍可查看并手动选择复制。启用 JavaScript 后可使用复制按钮与外观设置。</p></noscript> @@SIGNATURE_SECTION_END@@
<section id="paths" class="content-section" aria-labelledby="paths-title">
<div class="section-heading"><div><p class="section-index">@@PATHS_INDEX@@ / PATHS</p><h2 id="paths-title">Paths · 访问地址</h2><p>保留原版的公钥、脚本与根地址入口;每个地址可以单独复制。</p></div></div>
<div class="path-list">@@PATHS_HTML@@</div>
</section>
<noscript><p class="noscript-notice">JavaScript 已关闭,发布状态、命令与访问地址仍可查看并手动选择复制。启用 JavaScript 后可使用复制按钮与外观设置。</p></noscript>
</main> </main>
<footer class="site-footer"><span>Quickly Configuring SSH Server</span><div><a href="/ssh.keys.sig">ssh.keys.sig</a><a href="/ssh.keys">ssh.keys</a><a href="/script.sh">script.sh</a><a href="@@REPOSITORY_URL@@" target="_blank" rel="noopener noreferrer">源代码<span class="sr-only">(新窗口打开)</span></a></div></footer> <footer class="site-footer"><span>Quickly Configuring SSH Server</span><div><a href="/ssh.keys.sig">ssh.keys.sig</a><a href="/ssh.keys">ssh.keys</a><a href="/script.sh">script.sh</a><a href="@@REPOSITORY_URL@@" target="_blank" rel="noopener noreferrer">源代码<span class="sr-only">(新窗口打开)</span></a></div></footer>
+4 -2
View File
@@ -1,7 +1,7 @@
import { createServer } from "node:http"; import { createServer } from "node:http";
import { readFile } from "node:fs/promises"; import { readFile } from "node:fs/promises";
import { createHash } from "node:crypto"; import { createHash } from "node:crypto";
import { commandsFor, renderPage } from "../cf-worker/src/view.js"; import { commandsFor, pathsFor, renderPage } from "../cf-worker/src/view.js";
/** Real page rendering and real local assets, with no live Worker or upstream calls. */ /** Real page rendering and real local assets, with no live Worker or upstream calls. */
export async function startBrowserFixture() { export async function startBrowserFixture() {
@@ -20,7 +20,7 @@ export async function startBrowserFixture() {
label: comment, label: comment,
fingerprint: "SHA256:" + createHash("sha256").update(Buffer.from(encoded, "base64")).digest("base64").replace(/=+$/, ""), 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 config = { baseUrl: "https://keys.example.test", repositoryUrl: "https://github.com/example/quickly-conf-sshd", signedRelease: {listId: "fixture-signing-list"}, clientDefaults: {gpgVerification: "off"} };
const base = { const base = {
schema: 1, schema: 1,
revision: "0123456789abcdef0123456789abcdef01234567", revision: "0123456789abcdef0123456789abcdef01234567",
@@ -31,12 +31,14 @@ export async function startBrowserFixture() {
testFixture: true, testFixture: true,
...config, ...config,
commands: commandsFor(config), commands: commandsFor(config),
paths: pathsFor(config),
keys: [key], keys: [key],
signedMetadata: null, signedMetadata: null,
}; };
const snapshots = { const snapshots = {
repository: { ...base, publicCertificate: "HOMEPAGE-MUST-NOT-LEAK-CERTIFICATE", source: { type: "repository" } }, repository: { ...base, publicCertificate: "HOMEPAGE-MUST-NOT-LEAK-CERTIFICATE", source: { type: "repository" } },
gist: { ...base, keyRevision: "d".repeat(64), signedMetadata: {listId: "fixture-signing-list", sequence: 42, documentSha256: "d".repeat(64)}, keysText: text, signedDocument: text, signature: "-----BEGIN PGP SIGNATURE-----\nComment: HOMEPAGE-MUST-NOT-LEAK-SIGNATURE\n\nAQID\n-----END PGP SIGNATURE-----\n", signedBundle: text, source: {type: "gist", id: "f".repeat(32), gistRevision: "e".repeat(40), url: "https://gist.github.com/example/" + "f".repeat(32) + "/" + "e".repeat(40), extraPrivateComment: comment} }, gist: { ...base, keyRevision: "d".repeat(64), signedMetadata: {listId: "fixture-signing-list", sequence: 42, documentSha256: "d".repeat(64)}, keysText: text, signedDocument: text, signature: "-----BEGIN PGP SIGNATURE-----\nComment: HOMEPAGE-MUST-NOT-LEAK-SIGNATURE\n\nAQID\n-----END PGP SIGNATURE-----\n", signedBundle: text, source: {type: "gist", id: "f".repeat(32), gistRevision: "e".repeat(40), url: "https://gist.github.com/example/" + "f".repeat(32) + "/" + "e".repeat(40), extraPrivateComment: comment} },
"signature-disabled": { ...base, signedRelease: null, signatureAvailable: true, signedMetadata: {listId: "runtime-metadata-only"}, paths: pathsFor({...config, signedRelease: null}), source: {type: "repository"} },
fallback: { ...base, source: { type: "fallback", url: "https://keys.example.test/team.keys?comment=<script>window.__injected=true</script>" } }, 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" } }, unavailable: { ...base, keys: [], keyCount: 0, sha256: null, source: { type: "unavailable" } },
pending: { ...base, keys: [], keyCount: 0, sha256: null, source: { type: "pending" } }, pending: { ...base, keys: [], keyCount: 0, sha256: null, source: { type: "pending" } },
+15
View File
@@ -81,6 +81,7 @@ test("real browser homepage integration", { skip: process.env.BROWSER_TESTS !==
await page.goto(fixture.origin + "/repository"); await page.goto(fixture.origin + "/repository");
await page.waitForSelector("html.enhanced"); await page.waitForSelector("html.enhanced");
await noPageOverflow(page, width); await noPageOverflow(page, width);
assert.deepEqual(await page.locator("main > section.content-section").evaluateAll((sections) => sections.map((section) => section.id)), ["quick-start", "key-status", "signature", "paths"]);
const install = fixture.snapshots.repository.commands.find((command) => command.id === "install").command; const install = fixture.snapshots.repository.commands.find((command) => command.id === "install").command;
assert.equal(await page.locator("#command-install").textContent(), install); assert.equal(await page.locator("#command-install").textContent(), install);
assert.equal(await page.locator(".key-card, [data-copy-target^='key-'], #copy-all-keys, #copy-all-commands").count(), 0); assert.equal(await page.locator(".key-card, [data-copy-target^='key-'], #copy-all-keys, #copy-all-commands").count(), 0);
@@ -137,6 +138,14 @@ test("real browser homepage integration", { skip: process.env.BROWSER_TESTS !==
await page.waitForFunction((id) => document.querySelector(`[data-copy-target="command-${id}"]`).dataset.state === "success", command.id); await page.waitForFunction((id) => document.querySelector(`[data-copy-target="command-${id}"]`).dataset.state === "success", command.id);
assert.equal(await page.evaluate(() => window.__clipboardTest.values.at(-1)), command.command); assert.equal(await page.evaluate(() => window.__clipboardTest.values.at(-1)), command.command);
} }
for (const path of fixture.snapshots.repository.paths) {
assert.equal(await page.locator(`#path-${path.id}`).textContent(), path.url);
const button = page.locator(`[data-copy-target="path-${path.id}"]`);
await button.focus();
await page.keyboard.press("Enter");
await page.waitForFunction((id) => document.querySelector(`[data-copy-target="path-${id}"]`).dataset.state === "success", path.id);
assert.equal(await page.evaluate(() => window.__clipboardTest.values.at(-1)), path.url);
}
await noPageOverflow(page, width); await noPageOverflow(page, width);
await page.evaluate(() => { window.__clipboardTest.reject = true; }); await page.evaluate(() => { window.__clipboardTest.reject = true; });
@@ -200,6 +209,10 @@ test("real browser homepage integration", { skip: process.env.BROWSER_TESTS !==
// Repository appearance is already captured in both viewports above: 12 screenshots in total. // Repository appearance is already captured in both viewports above: 12 screenshots in total.
if (state !== "repository") await capture(page, "source-" + state); if (state !== "repository") await capture(page, "source-" + state);
} }
await page.goto(fixture.origin + "/signature-disabled");
assert.deepEqual(await page.locator("main > section.content-section").evaluateAll((sections) => sections.map((section) => section.id)), ["quick-start", "key-status", "paths"]);
assert.equal(await page.locator("#signature, #path-signature, #path-certificate").count(), 0, "runtime signature metadata must not enable the Signature section");
assert.equal(await page.locator("#path-root-script").textContent(), fixture.snapshots.repository.baseUrl + "/");
assert.deepEqual(externalRequests, []); assert.deepEqual(externalRequests, []);
} finally { await context.close(); } } finally { await context.close(); }
}); });
@@ -214,6 +227,8 @@ test("real browser homepage integration", { skip: process.env.BROWSER_TESTS !==
assert.equal(await page.locator("#key-status").isVisible(), true); assert.equal(await page.locator("#key-status").isVisible(), true);
assert.equal(await page.locator(".key-card, [data-copy-target^='key-'], #copy-all-keys").count(), 0); assert.equal(await page.locator(".key-card, [data-copy-target^='key-'], #copy-all-keys").count(), 0);
assert.equal(await page.locator("#command-install").isVisible(), true); assert.equal(await page.locator("#command-install").isVisible(), true);
assert.equal(await page.locator("#path-root-script").isVisible(), true);
assert.equal(await page.locator("#path-homepage").textContent(), fixture.snapshots.repository.baseUrl + "/");
assert.equal(await page.locator('[data-copy-target="command-install"]').isVisible(), false); assert.equal(await page.locator('[data-copy-target="command-install"]').isVisible(), false);
assert.equal(await page.locator("#theme-picker").isVisible(), false); assert.equal(await page.locator("#theme-picker").isVisible(), false);
assert.match(await page.locator("noscript").textContent(), /手动选择复制/); assert.match(await page.locator("noscript").textContent(), /手动选择复制/);
+2 -1
View File
@@ -37,7 +37,8 @@ test('empty revocation must be explicit; protocol is deterministic',async()=>{
test('homepage excludes keys and commands use explicit install and fixed local sync',async()=>{ test('homepage excludes keys and commands use explicit install and fixed local sync',async()=>{
const commands=commandsFor(config); const commands=commandsFor(config);
assert.ok(commands.find(x=>x.id==='install').command.includes('--install')); assert.ok(commands.find(x=>x.id==='install').command.includes('--install'));
assert.ok(commands.find(x=>x.id==='root-on').advanced); assert.equal(Boolean(commands.find(x=>x.id==='root-on').advanced), false);
assert.ok(commands.find(x=>x.id==='root-off').advanced);
assert.ok(commandsFor({...config,defaultCron:'0 */6 * * *'})[0].description.includes('0 */6 * * *')); assert.ok(commandsFor({...config,defaultCron:'0 */6 * * *'})[0].description.includes('0 */6 * * *'));
assert.ok(commands.find(x=>x.id==='uninstall').advanced); assert.ok(commands.find(x=>x.id==='uninstall').advanced);
assert.ok(commands.every(x=>!x.command.includes('github.io'))); assert.ok(commands.every(x=>!x.command.includes('github.io')));
+355 -5
View File
@@ -98,7 +98,8 @@ else
cp "$1" "$TEST_CRONTAB" cp "$1" "$TEST_CRONTAB"
fi fi
''') ''')
for cmd in ('sshd', 'apt-get', 'yum', 'dnf', 'systemctl', 'service'): self.mock('sshd', '#!/bin/sh\nif [ \"$#\" = 1 ] && [ \"$1\" = -T ]; then exit 0; fi\necho \"UNEXPECTED sshd $*\" >> \"$TEST_CALLS\"\nexit 98\n')
for cmd in ('apt-get', 'yum', 'dnf', 'systemctl', 'service'):
self.mock(cmd, f'#!/bin/sh\necho "UNEXPECTED {cmd} $*" >> "$TEST_CALLS"\nexit 98\n') self.mock(cmd, f'#!/bin/sh\necho "UNEXPECTED {cmd} $*" >> "$TEST_CALLS"\nexit 98\n')
def tearDown(self): def tearDown(self):
@@ -130,7 +131,7 @@ fi
lines.append(f'{path.name}() {{ {shlex.quote(str(path))} "$@"; }}') lines.append(f'{path.name}() {{ {shlex.quote(str(path))} "$@"; }}')
# Hyphenated command names such as apt-get are not POSIX function # Hyphenated command names such as apt-get are not POSIX function
# identifiers and have no BusyBox applets; their PATH mocks remain. # identifiers and have no BusyBox applets; their PATH mocks remain.
for name in ('id', 'crontab'): for name in ('id', 'crontab', 'sshd', 'apt-get', 'dnf', 'yum'):
lines.extend([ lines.extend([
f'if [ "$({name} --conf-sshd-isolation-probe)" != conf-sshd-mock:{name} ]; then', f'if [ "$({name} --conf-sshd-isolation-probe)" != conf-sshd-mock:{name} ]; then',
f' printf \'%s\\n\' \'TEST ISOLATION FAILED: {name} mock was bypassed\' >&2', f' printf \'%s\\n\' \'TEST ISOLATION FAILED: {name} mock was bypassed\' >&2',
@@ -180,7 +181,7 @@ fi
subprocess.run([*SHELL, '-n', str(self.script)], check=True) subprocess.run([*SHELL, '-n', str(self.script)], check=True)
def test_isolation_probe_aborts_before_installer_when_mock_is_bypassed(self): def test_isolation_probe_aborts_before_installer_when_mock_is_bypassed(self):
for name in ('id', 'crontab'): for name in ('id', 'crontab', 'sshd', 'apt-get', 'dnf', 'yum'):
with self.subTest(name=name): with self.subTest(name=name):
mock_path = self.bin / name mock_path = self.bin / name
original = mock_path.read_text() original = mock_path.read_text()
@@ -535,6 +536,7 @@ fi
fixture = SCRIPT.replace('config=/etc/ssh/sshd_config', f"config='{config}'") 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('check_path /etc/ssh dir', f"check_path '{config_dir}' dir")
fixture = fixture.replace('new_temp /etc/ssh;', f"new_temp '{config_dir}';") fixture = fixture.replace('new_temp /etc/ssh;', f"new_temp '{config_dir}';")
fixture = fixture.replace('/etc/ssh', str(config_dir))
self.assertNotIn('/etc/ssh', fixture, 'All root-policy paths must stay inside test fixtures') self.assertNotIn('/etc/ssh', fixture, 'All root-policy paths must stay inside test fixtures')
self.script.write_text(fixture) self.script.write_text(fixture)
self.mock('id', '#!/bin/sh\nprintf "0\\n"\n') self.mock('id', '#!/bin/sh\nprintf "0\\n"\n')
@@ -544,6 +546,11 @@ fi
printf 'sshd %s\\n' "$*" >> "$TEST_CALLS" printf 'sshd %s\\n' "$*" >> "$TEST_CALLS"
operation=$1 operation=$1
shift shift
if [ "$#" = 0 ] && [ "$operation" = -T ]; then
[ "${TEST_DEFAULT_SSHD_FAIL:-0}" = 0 ] || exit 1
echo 'permitrootlogin no'
exit 0
fi
[ "$1" = -f ] || exit 90 [ "$1" = -f ] || exit 90
config=$2 config=$2
[ "${TEST_SSHD_FAIL:-}" != "$operation" ] || exit 1 [ "${TEST_SSHD_FAIL:-}" != "$operation" ] || exit 1
@@ -616,13 +623,13 @@ fi
self.assertTrue(config.read_text().startswith('PermitRootLogin yes\n')) 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.assertIn('sshd -T -f', self.calls.read_text()) self.assertIn('sshd -T -f', self.calls.read_text())
self.assertFalse(self.auth.exists()) self.assertEqual(self.auth.read_bytes(), BEGIN + KEY + END)
self.assert_clean() self.assert_clean()
self.assertEqual(list(config.parent.glob('.conf-sshd.*')), []) self.assertEqual(list(config.parent.glob('.conf-sshd.*')), [])
def test_root_policy_include_match_invalid_effective_and_dry_run(self): def test_root_policy_include_match_invalid_effective_and_dry_run(self):
config = self.mock_root_config() config = self.mock_root_config()
for text in ['Include sshd_config.d/*.conf\n', ' Match User test\n', for text in ['Include other/*.conf\n', ' Match User test\n',
'Port 2222\nINCLUDE other\n', 'mAtCh all\n', 'Port 2222\nINCLUDE other\n', 'mAtCh all\n',
'Include=other\n', 'Match=User root\n', '"Include"=other\n', 'Include=other\n', 'Match=User root\n', '"Include"=other\n',
'"Match" User root\n', '"Match"User root\n']: '"Match" User root\n', '"Match"User root\n']:
@@ -735,6 +742,182 @@ fi
self.assertFalse(self.auth.exists()) self.assertFalse(self.auth.exists())
def simulate_dependencies(self, *, manager='apt-get', uid='0', sshd=True, client=True, cron=True):
real_stat=shutil.which('stat')
self.mock('id',f'#!/bin/sh\nprintf "%s\\n" "{uid}"\n')
self.mock('stat',f'#!/bin/sh\n"{real_stat}" "$@" | sed \'s/^[0-9][0-9]*/{uid}/\'\n')
fixture=self.script.read_text()
for function,command,marker,present in [('sshd_present','sshd','sshd-ready',sshd),('cron_present','crontab','cron-ready',cron),('ssh_client_present','ssh-keygen','client-ready',client)]:
old=f'{function}() {{ command -v {command} >/dev/null 2>&1; }}'
new=f'{function}() {{ [ -f "$TEST_ROOT/{marker}" ]; }}'
self.assertIn(old,fixture)
fixture=fixture.replace(old,new)
path=self.root/marker
if present: path.touch()
elif path.exists(): path.unlink()
for tool in ('apt-get','dnf','yum'):
fixture=fixture.replace(f'command -v {tool} >/dev/null 2>&1',f'[ "$TEST_PACKAGE_MANAGER" = {tool} ]')
self.script.write_text(fixture)
self.env['TEST_PACKAGE_MANAGER']=manager
self.mock('sshd','''#!/bin/sh
printf 'sshd %s\\n' "$*" >> "$TEST_CALLS"
[ "${TEST_DAEMON_INVALID:-0}" = 0 ] || exit 1
[ "$#" = 1 ] && [ "$1" = -T ] || exit 98
''')
for tool in ('apt-get','dnf','yum'):
self.mock(tool,f'''#!/bin/sh
printf 'PKG {tool} %s\\n' "$*" >> "$TEST_CALLS"
[ "${{TEST_PACKAGE_ERROR:-0}}" = 0 ] || exit 77
last=
for arg do last=$arg; done
case $last in
openssh-client|openssh-clients) : > "$TEST_ROOT/client-ready" ;;
openssh-server) : > "$TEST_ROOT/sshd-ready" ;;
cron|crontabs) : > "$TEST_ROOT/cron-ready" ;;
esac
''')
def test_root_apt_installs_missing_server_and_cron_only_after_valid_keys(self):
self.simulate_dependencies(sshd=False,cron=False)
self.run_script('-c')
calls=self.calls.read_text()
self.assertIn('PKG apt-get install -y openssh-server',calls)
self.assertIn('PKG apt-get install -y cron',calls)
self.assertEqual(calls.count('PKG apt-get update\n'),1)
self.assertLess(calls.index('/ssh.keys'),calls.index('PKG apt-get update'))
self.assertNotIn('upgrade',calls)
self.assertNotIn('systemctl',calls)
self.assertNotIn('service ',calls)
self.assertEqual(self.auth.read_bytes(),BEGIN+KEY+END)
def test_root_yum_and_dnf_install_recognized_packages(self):
for manager in ('yum','dnf'):
with self.subTest(manager=manager):
self.script.write_text(SCRIPT)
self.simulate_dependencies(manager=manager,sshd=False,cron=False)
self.calls.write_text('')
self.run_script('-c')
calls=self.calls.read_text()
self.assertIn(f'PKG {manager} install -y openssh-server',calls)
self.assertIn(f'PKG {manager} install -y crontabs',calls)
self.assertNotIn('PKG apt-get',calls)
def test_nonroot_missing_daemon_fails_without_packages_but_keys_only_works(self):
self.simulate_dependencies(uid='1001',sshd=False)
self.run_script(ok=False)
self.assertFalse(self.auth.exists())
self.assertNotIn('PKG ',self.calls.read_text())
self.run_script('-o')
self.assertEqual(self.auth.read_bytes(),BEGIN+KEY+END)
self.assertNotIn('sshd -T',self.calls.read_text())
def test_no_install_skips_missing_daemon_and_never_bootstraps_missing_client(self):
self.simulate_dependencies(sshd=False)
self.run_script('-n')
self.assertNotIn('PKG ',self.calls.read_text())
(self.root/'client-ready').unlink()
self.run_script('-n',ok=False)
self.assertNotIn('PKG ',self.calls.read_text())
self.run_script('-o',ok=False)
self.assertNotIn('PKG ',self.calls.read_text())
def test_existing_invalid_daemon_is_not_treated_as_missing_or_reinstalled(self):
self.simulate_dependencies(sshd=True)
original=self.old_auth()
result=self.run_script(ok=False,env={'TEST_DAEMON_INVALID':'1'})
self.assertIn('configuration cannot be evaluated',result.stderr)
self.assertNotIn('PKG ',self.calls.read_text())
self.assertEqual(self.auth.read_bytes(),original)
def test_invalid_keys_do_not_install_server_or_cron(self):
self.simulate_dependencies(sshd=False,cron=False)
self.payload.write_bytes(b'not a public-key document\n')
self.run_script('-c',ok=False)
self.assertNotIn('PKG ',self.calls.read_text())
self.assertFalse(self.auth.exists())
self.assertNotIn(CB,self.cron.read_text())
def test_missing_client_bootstrap_is_root_normal_only_and_precedes_key_validation(self):
self.simulate_dependencies(client=False,sshd=False)
self.run_script()
calls=self.calls.read_text()
self.assertLess(calls.index('install -y openssh-client'),calls.index('/ssh.keys'))
self.assertLess(calls.index('/ssh.keys'),calls.index('install -y openssh-server'))
self.assertEqual(calls.count('PKG apt-get update\n'),1)
def test_package_failure_or_unsupported_manager_preserves_keys_and_cron(self):
self.simulate_dependencies(sshd=False,cron=False)
original=self.old_auth()
before=self.cron.read_bytes()
result=self.run_script('-c',ok=False,env={'TEST_PACKAGE_ERROR':'1'})
self.assertIn('may have changed',result.stderr)
self.assertEqual(self.auth.read_bytes(),original)
self.assertEqual(self.cron.read_bytes(),before)
self.run_script('-c',ok=False,env={'TEST_PACKAGE_MANAGER':'unsupported'})
self.assertEqual(self.auth.read_bytes(),original)
def test_root_policy_combines_with_install_and_cron_then_updates_keys(self):
config=self.mock_root_config()
self.run_script('--install','-p','no','-c','5 2 * * *')
self.assertTrue(config.read_text().startswith('PermitRootLogin prohibit-password\n'))
self.assertEqual(self.auth.read_bytes(),BEGIN+KEY+END)
self.assertIn('5 2 * * * /bin/sh',self.cron.read_text())
self.assertTrue(self.installed.exists())
calls=self.calls.read_text()
self.assertNotIn('systemctl',calls)
self.assertNotIn('service ',calls)
def test_standard_global_include_preserved_and_effective_policy_checked(self):
config=self.mock_root_config('Include sshd_config.d/*.conf\nPort 2222\n')
directory=config.parent/'sshd_config.d'
directory.mkdir(mode=0o700)
drop=directory/'10-settings.conf'
drop.write_text('PasswordAuthentication no\nPermitRootLogin yes\n')
original=drop.read_bytes()
self.run_script('-p','no')
self.assertIn('Include sshd_config.d/*.conf\n',config.read_text())
self.assertTrue(config.read_text().startswith('PermitRootLogin prohibit-password\n'))
self.assertEqual(drop.read_bytes(),original)
self.assertIn('sshd -T -f',self.calls.read_text())
self.assert_clean()
def test_nested_include_match_and_symlink_dropins_fail_closed(self):
config=self.mock_root_config('Include sshd_config.d/*.conf\n')
directory=config.parent/'sshd_config.d'
directory.mkdir(mode=0o700)
drop=directory/'10-settings.conf'
original=config.read_bytes()
for body in ('Match User root\nPermitRootLogin yes\n','Include nested/*.conf\n','"Match" User root\n'):
drop.write_text(body)
self.run_script('-p','no',ok=False)
self.assertEqual(config.read_bytes(),original)
self.assertFalse(self.auth.exists())
drop.unlink()
outside=self.root/'outside-settings'
outside.write_text('PermitRootLogin yes\n')
drop.symlink_to(outside)
self.run_script('-p','no',ok=False)
self.assertEqual(config.read_bytes(),original)
self.assertEqual(outside.read_text(),'PermitRootLogin yes\n')
def test_concurrent_include_inventory_change_blocks_root_policy_commit(self):
config=self.mock_root_config('Include sshd_config.d/*.conf\n')
directory=config.parent/'sshd_config.d'
directory.mkdir(mode=0o700)
(directory/'10-settings.conf').write_text('PasswordAuthentication no\n')
old=config.read_bytes()
mock=self.bin/'sshd'
text=mock.read_text()
injected=f'''config=$2
if [ "$operation" = -T ]; then printf 'PermitRootLogin yes\\n' > {shlex.quote(str(directory/'20-racing.conf'))}; fi
'''
mock.write_text(text.replace('config=$2\n',injected,1))
result=self.run_script('-p','no',ok=False)
self.assertIn('inventory changed concurrently',result.stderr)
self.assertEqual(config.read_bytes(),old)
self.assertFalse(self.auth.exists())
class SignatureTests(ShellTests): class SignatureTests(ShellTests):
"""Pure sandbox OpenPGP/TTY fixtures; all network paths intercepted.""" """Pure sandbox OpenPGP/TTY fixtures; all network paths intercepted."""
@classmethod @classmethod
@@ -1296,6 +1479,173 @@ fi
self.assert_clean() self.assert_clean()
@property
def audit(self):
return self.home/'.conf-sshd/audit'
def audit_pointer(self,name='current'):
path=self.audit/name
if not path.exists(): return None
lines=path.read_text().splitlines()
self.assertEqual(lines[0],'conf-sshd-audit-pointer-v1')
self.assertIn(lines[1],('slot a','slot b'))
return self.audit/lines[1].split()[1]
def audit_snapshot(self):
return {str(path.relative_to(self.audit)):path.read_bytes() for path in self.audit.rglob('*') if path.is_file()}
def test_audit_preserves_exact_pair_metadata_actual_signer_and_bounded_history(self):
self.enroll()
first=self.audit_pointer()
self.assertEqual((first/'ssh.keys').read_bytes(),self.payload.read_bytes())
self.assertEqual((first/'ssh.keys.sig').read_bytes(),self.signature.read_bytes())
metadata=(first/'metadata').read_text()
self.assertIn('source '+ORIGIN+'/ssh.keys\n',metadata)
self.assertIn('signer-fingerprint '+self.fixtures['subkeys'][0]+'\n',metadata)
self.assertIn('primary-fingerprint '+self.fixtures['primary']+'\n',metadata)
self.assertIn('pinned-fingerprint '+self.fixtures['primary']+'\n',metadata)
self.assertIn('document-sha256 '+hashlib.sha256(self.payload.read_bytes()).hexdigest()+'\n',metadata)
self.assertRegex(metadata,r'installed-at [0-9]+\n')
self.set_release('sibling')
self.run_script('-o')
current=self.audit_pointer()
self.assertNotEqual(current,first)
self.assertEqual(self.audit_pointer('previous'),first)
self.assertIn('signer-fingerprint '+self.fixtures['subkeys'][1]+'\n',(current/'metadata').read_text())
self.run_script('-o')
self.assertEqual(sorted(path.name for path in self.audit.iterdir() if path.is_dir()),['a','b'])
for path in self.audit.rglob('*'):
self.assertEqual(path.stat().st_mode & 0o777,0o700 if path.is_dir() else 0o600)
self.assert_clean()
def test_failed_fetch_or_signature_leaves_current_and_history_byte_identical(self):
self.enroll()
self.run_script('-o')
original=self.audit_snapshot()
self.run_script('-o',ok=False,env={'TEST_SIG_STATUS':'404'})
self.assertEqual(self.audit_snapshot(),original)
self.set_release('wrongsigner')
self.run_script('-o',ok=False)
self.assertEqual(self.audit_snapshot(),original)
def test_off_updates_invalidate_current_without_new_signature_or_audit_record(self):
self.enroll()
old=self.audit_pointer()
old_pair=((old/'ssh.keys').read_bytes(),(old/'ssh.keys.sig').read_bytes())
self.calls.write_text('')
self.run_tty('-o','--gpg-verification','off')
self.assertIsNone(self.audit_pointer())
self.assertEqual(self.audit_pointer('previous'),old)
self.assertEqual(((old/'ssh.keys').read_bytes(),(old/'ssh.keys.sig').read_bytes()),old_pair)
self.assertNotIn('/ssh.keys.sig',self.calls.read_text())
self.assertNotIn('/public-key.gpg',self.calls.read_text())
self.assertFalse((self.home/'.conf-sshd/pending.source').exists())
def test_audit_failure_windows_recover_without_mixed_or_falsely_current_pair(self):
self.enroll()
real_mv=shutil.which('mv')
destinations=['audit-slot','auth','replay','audit-current']
for destination in destinations:
with self.subTest(destination=destination):
before_slot=self.audit_pointer()
before_pair=((before_slot/'ssh.keys').read_bytes(),(before_slot/'ssh.keys.sig').read_bytes())
self.set_release('next')
self.old_auth(b'# manual key before commit\n')
self.mock('mv',f'''#!/bin/sh
last=
for arg do last=$arg; done
case "$TEST_AUDIT_FAILURE:$last" in
audit-slot:"$HOME/.conf-sshd/audit/"*/ssh.keys.sig|auth:"$HOME/.ssh/authorized_keys"|replay:"$HOME/.conf-sshd/replay.state"|audit-current:"$HOME/.conf-sshd/audit/current") exit 74 ;;
esac
exec "{real_mv}" "$@"
''')
self.run_script('-o',ok=False,env={'TEST_AUDIT_FAILURE':destination})
self.assertTrue((self.home/'.conf-sshd/pending.state').exists())
if destination=='audit-slot':
self.assertEqual(self.audit_pointer(),before_slot)
else:
self.assertIsNone(self.audit_pointer())
self.assertEqual(self.audit_pointer('previous'),before_slot)
self.assertEqual(((before_slot/'ssh.keys').read_bytes(),(before_slot/'ssh.keys.sig').read_bytes()),before_pair)
(self.bin/'mv').unlink()
self.calls.write_text('')
self.run_script('-o')
recovered=self.audit_pointer()
self.assertEqual((recovered/'ssh.keys').read_bytes(),self.payload.read_bytes())
self.assertEqual((recovered/'ssh.keys.sig').read_bytes(),self.signature.read_bytes())
metadata=(recovered/'metadata').read_text()
self.assertIn('source '+ORIGIN+'/ssh.keys\n',metadata)
self.assertIn('signer-fingerprint '+self.fixtures['subkeys'][0]+'\n',metadata)
self.assertNotIn('curl ',self.calls.read_text())
self.assertFalse((self.home/'.conf-sshd/pending.source').exists())
self.assert_clean()
def test_legacy_pending_without_source_context_records_truthful_unknown_source(self):
self.enroll()
self.set_release('next')
real_mv=shutil.which('mv')
self.old_auth(b'# retained manual\n')
self.mock('mv',f'''#!/bin/sh
last=
for arg do last=$arg; done
[ "$last" != "$HOME/.ssh/authorized_keys" ] || exit 74
exec "{real_mv}" "$@"
''')
self.run_script('-o',ok=False)
(self.bin/'mv').unlink()
(self.home/'.conf-sshd/pending.source').unlink()
self.run_script('-o')
self.assertIn('source local-pending:original-source-unavailable\n',(self.audit_pointer()/'metadata').read_text())
def test_pending_source_context_must_match_document_and_signature(self):
self.enroll()
self.set_release('next')
real_mv=shutil.which('mv')
self.old_auth(b'# retained manual\n')
self.mock('mv',f'''#!/bin/sh
last=
for arg do last=$arg; done
[ "$last" != "$HOME/.ssh/authorized_keys" ] || exit 74
exec "{real_mv}" "$@"
''')
self.run_script('-o',ok=False)
(self.bin/'mv').unlink()
context=self.home/'.conf-sshd/pending.source'
context.write_text(context.read_text().replace('sequence 6\n','sequence 999\n'))
original=self.auth.read_bytes()
self.run_script('-o',ok=False)
self.assertEqual(self.auth.read_bytes(),original)
self.assertIsNone(self.audit_pointer())
def test_audit_symlinks_are_refused_without_touching_external_targets(self):
self.enroll()
current=self.audit_pointer()
other='b' if current.name=='a' else 'a'
outside=self.root/'audit-outside'
outside.mkdir()
(outside/'ssh.keys').write_text('outside unchanged\n')
(self.audit/other).symlink_to(outside,target_is_directory=True)
original=self.auth.read_bytes()
self.set_release('next')
self.run_script('-o',ok=False)
self.assertEqual(self.auth.read_bytes(),original)
self.assertEqual((outside/'ssh.keys').read_text(),'outside unchanged\n')
self.assertEqual(self.audit_pointer(),current)
def test_signature_consent_precedes_any_root_client_bootstrap(self):
self.simulate_dependencies(client=False,sshd=False)
self.enroll(answer='cancel',ok=False)
self.assertNotIn('PKG ',self.calls.read_text())
self.assertNotIn('curl ',self.calls.read_text())
self.assertFalse(self.auth.exists())
self.enroll()
calls=self.calls.read_text()
self.assertIn('install -y openssh-client',calls)
self.assertIn('install -y openssh-server',calls)
self.assertLess(calls.index('/public-key.gpg'),calls.index('install -y openssh-client'))
self.assertLess(calls.index('/ssh.keys.sig'),calls.index('install -y openssh-server'))
if __name__ == '__main__': if __name__ == '__main__':
suite=unittest.TestSuite() suite=unittest.TestSuite()
suite.addTests(unittest.defaultTestLoader.loadTestsFromTestCase(ShellTests)) suite.addTests(unittest.defaultTestLoader.loadTestsFromTestCase(ShellTests))
+69 -8
View File
@@ -2,7 +2,7 @@ import assert from "node:assert/strict";
import { readFile } from "node:fs/promises"; import { readFile } from "node:fs/promises";
import test from "node:test"; import test from "node:test";
import vm from "node:vm"; import vm from "node:vm";
import { commandsFor, renderPage } from "../cf-worker/src/view.js"; import { commandsFor, pathsFor, renderPage } from "../cf-worker/src/view.js";
import { makeSignedDocument, parsePublishedSignedRelease } from "../cf-worker/src/signed-release.js"; import { makeSignedDocument, parsePublishedSignedRelease } from "../cf-worker/src/signed-release.js";
const script = await readFile(new URL("../site/assets/app.js", import.meta.url), "utf8"); const script = await readFile(new URL("../site/assets/app.js", import.meta.url), "utf8");
@@ -78,14 +78,14 @@ function setup({ commandTexts = commands.map(({command}) => command), savedTheme
} }
function snapshot(overrides = {}) { function snapshot(overrides = {}) {
return {revision: "a".repeat(40), publishedAt: "2026-10-12T00:00:00Z", keys: [], keyCount: 1, sha256: "b".repeat(64), keyRevision: "c".repeat(64), commands, ...config, ...overrides}; return {revision: "a".repeat(40), publishedAt: "2026-10-12T00:00:00Z", keys: [], keyCount: 1, sha256: "b".repeat(64), keyRevision: "c".repeat(64), commands, ...config, signedRelease: {listId: "test-list"}, ...overrides};
} }
function embeddedMetadata(page) { function embeddedMetadata(page) {
return JSON.parse(page.match(/<script id="release-data" type="application\/json">([\s\S]*?)<\/script>/)[1]); return JSON.parse(page.match(/<script id="release-data" type="application\/json">([\s\S]*?)<\/script>/)[1]);
} }
test("homepage prerenders metadata and commands, never a public-key listing", () => { test("homepage prerenders metadata and commands, never a public-key listing", () => {
for (const token of ["RELEASE_JSON", "COMMANDS_HTML", "REVISION", "PUBLISHED_AT", "KEY_COUNT", "REPOSITORY_URL", "RELEASE_LABEL", "PREVIEW_NOTICE", "SOURCE_LABEL", "SOURCE_STATUS", "SIGNATURE_STATUS", "CERTIFICATE_STATUS", "CERTIFICATE_LINK", "CONTENT_REVISION", "KEYS_NOTICE"]) { for (const token of ["RELEASE_JSON", "COMMANDS_HTML", "PATHS_HTML", "REVISION", "PUBLISHED_AT", "KEY_COUNT", "REPOSITORY_URL", "RELEASE_LABEL", "PREVIEW_NOTICE", "SOURCE_LABEL", "SOURCE_STATUS", "SIGNATURE_STATUS", "CERTIFICATE_STATUS", "CERTIFICATE_LINK", "SIGNATURE_SECTION_START", "SIGNATURE_SECTION_END", "VERIFICATION_DEFAULT", "PATHS_INDEX", "CONTENT_REVISION", "KEYS_NOTICE"]) {
assert.ok(html.includes(`@@${token}@@`), `${token} placeholder is required`); assert.ok(html.includes(`@@${token}@@`), `${token} placeholder is required`);
} }
assert.match(html, /<html lang="zh-CN">/); assert.match(html, /<html lang="zh-CN">/);
@@ -108,7 +108,7 @@ test("homepage projection excludes raw keys, fingerprints, comments and signed d
assert.doesNotMatch(page, new RegExp(secretMarker)); assert.doesNotMatch(page, new RegExp(secretMarker));
assert.doesNotMatch(page, /ssh-ed25519|SHA256:|data-copy-target="key-|id="key-\d|class="key-card"/); assert.doesNotMatch(page, /ssh-ed25519|SHA256:|data-copy-target="key-|id="key-\d|class="key-card"/);
const embedded = embeddedMetadata(page); const embedded = embeddedMetadata(page);
assert.deepEqual(Object.keys(embedded).sort(), ["schema", "revision", "publishedAt", "keyCount", "sha256", "keyRevision", "source", "sourceStatus", "signaturePresent", "signatureStatus", "certificatePresent", "certificateStatus", "repositoryUrl", "testFixture"].sort()); assert.deepEqual(Object.keys(embedded).sort(), ["schema", "revision", "publishedAt", "keyCount", "sha256", "keyRevision", "source", "sourceStatus", "signatureEnabled", "signaturePresent", "signatureStatus", "certificatePresent", "certificateStatus", "repositoryUrl", "testFixture"].sort());
assert.deepEqual(embedded.source, {type: "gist", url: "https://gist.github.com/example/example"}); assert.deepEqual(embedded.source, {type: "gist", url: "https://gist.github.com/example/example"});
assert.equal(embedded.keyCount, 1); assert.equal(embedded.keyCount, 1);
assert.equal(embedded.keyRevision, "c".repeat(64)); assert.equal(embedded.keyRevision, "c".repeat(64));
@@ -194,17 +194,31 @@ test("commands use an explicit installer and fixed installed client; no self-upd
assert.equal(byId.help.command, "curl -fsSL 'https://example.test/script.sh' | sh -s -- -h"); assert.equal(byId.help.command, "curl -fsSL 'https://example.test/script.sh' | sh -s -- -h");
assert.match(byId.help.description, /不建立信任/); assert.match(byId.help.description, /不建立信任/);
assert.match(byId.install.description, /备份.*保留本地配置/); assert.match(byId.install.description, /备份.*保留本地配置/);
assert.match(byId.install.description, /首次安装或明确重装都会信任你执行的脚本/); assert.match(byId.reinstall.description, /首次安装或明确重装都会信任你执行的脚本/);
assert.match(byId.install.description, /推荐先独立核验安装脚本/); assert.match(byId.reinstall.description, /推荐先独立核验安装脚本/);
assert.doesNotMatch(byId.install.description, /重装须使用.*本地脚本/); assert.doesNotMatch(byId.install.description, /重装须使用.*本地脚本/);
assert.deepEqual(commands.filter(({advanced}) => !advanced).map(({id}) => id), ["install", "deploy-once", "keys-only", "root-on", "help"]);
assert.equal(byId["deploy-once"].command, "curl -fsSL 'https://example.test/script.sh' | sh -s --");
assert.equal(byId["keys-only"].command, "curl -fsSL 'https://example.test/script.sh' | sh -s -- -o");
assert.equal(byId["root-on"].command, "curl -fsSL 'https://example.test/script.sh' | sh -s -- -p yes");
assert.equal(byId["skip-sshd-install"].command, "curl -fsSL 'https://example.test/script.sh' | sh -s -- -n");
assert.equal(byId["cron-custom"].command, 'sh "$HOME/.conf-sshd/conf-sshd.sh" -c \'0 */6 * * *\'');
assert.equal(byId["cron-stop"].command, 'sh "$HOME/.conf-sshd/conf-sshd.sh" -c false');
assert.equal(byId.status.command, 'sh "$HOME/.conf-sshd/conf-sshd.sh" --status');
assert.equal(byId.reinstall.command, "curl -fsSL 'https://example.test/script.sh' | sh -s -- --install --download-script");
assert.match(byId.install.description, /root 可按需安装 SSH Server 与 cron,非 root 缺少依赖时退出/);
assert.match(byId["deploy-once"].description, /不新增定时任务/);
assert.match(byId["keys-only"].description, /跳过 SSH Server 检查与软件包配置/);
assert.match(byId["root-on"].description, /须以 root 执行.*并部署公钥.*不自动重载/);
assert.match(byId.reinstall.description, /代替原 -u/);
assert.equal(byId.update, undefined); assert.equal(byId.update, undefined);
for (const id of ["root-off", "root-on", "uninstall"]) { for (const id of ["root-off", "uninstall"]) {
assert.equal(byId[id].advanced, true); assert.equal(byId[id].advanced, true);
assert.ok(byId[id].command.startsWith('sh "$HOME/.conf-sshd/conf-sshd.sh"')); assert.ok(byId[id].command.startsWith('sh "$HOME/.conf-sshd/conf-sshd.sh"'));
} }
assert.ok(commands.every(({command}) => !/(?:^|\s)-u(?:\s|$)|--update/.test(command))); assert.ok(commands.every(({command}) => !/(?:^|\s)-u(?:\s|$)|--update/.test(command)));
assert.match(html, /预期 SHA-256.*pin/); assert.match(html, /预期 SHA-256.*pin/);
assert.match(html, /验签默认关闭/); assert.match(renderPage(html, snapshot()), /验签默认关闭/);
assert.match(html, /--install --gpg-verification on/); assert.match(html, /--install --gpg-verification on/);
assert.match(html, /远端发布内容不能关闭本地验签/); assert.match(html, /远端发布内容不能关闭本地验签/);
assert.match(html, /本地终端确认完整指纹,不能跳过确认/); assert.match(html, /本地终端确认完整指纹,不能跳过确认/);
@@ -216,6 +230,53 @@ test("commands use an explicit installer and fixed installed client; no self-upd
assert.doesNotMatch(html, /\/key-release/); assert.doesNotMatch(html, /\/key-release/);
}); });
test("Quick Usage and Paths retain original categories without repository setup instructions", () => {
assert.match(html, /Quick Usage · 快速使用/);
assert.match(html, /Paths · 访问地址/);
assert.doesNotMatch(html, /<h[1-6][^>]*>Install|Fork 本仓库|wrangler publish/);
const paths = pathsFor({...config, signedRelease: {listId: "test-list"}});
assert.deepEqual(paths.slice(0, 4).map(({id, url}) => ({id, url})), [
{id: "keys", url: "https://example.test/ssh.keys"},
{id: "script", url: "https://example.test/script.sh"},
{id: "root-script", url: "https://example.test/"},
{id: "homepage", url: "https://example.test/"},
]);
assert.match(paths.find(({id}) => id === "root-script").description, /Curl.*Shell.*text\/html/);
assert.equal(paths.find(({id}) => id === "signature").url, "https://example.test/ssh.keys.sig");
assert.equal(paths.find(({id}) => id === "certificate").url, "https://example.test/public-key.gpg");
const page = renderPage(html, snapshot());
for (const path of paths) {
assert.ok(page.includes(`id="path-${path.id}">${path.url}</code>`));
assert.ok(page.includes(`data-copy-target="path-${path.id}"`));
}
assert.doesNotMatch(page, /copy-all-paths|copy-all-commands/);
});
test("section order and Signature visibility depend only on trusted signing configuration", () => {
for (const [state, enabled] of [
[{signedRelease: null, clientDefaults: {gpgVerification: "off"}}, false],
[{signedRelease: null, clientDefaults: {gpgVerification: "off"}, signatureAvailable: true, signedMetadata: {listId: "untrusted-metadata"}, publicCertificate: "opaque-cert"}, false],
[{signedRelease: {listId: "configured"}, clientDefaults: {gpgVerification: "off"}, signatureAvailable: false}, true],
[{signedRelease: null, clientDefaults: {gpgVerification: "on"}, signatureAvailable: false}, true],
]) {
const page = renderPage(html, snapshot(state));
const sections = [...page.matchAll(/<section id="([^"]+)" class="content-section/g)].map((match) => match[1]);
assert.deepEqual(sections, enabled ? ["quick-start", "key-status", "signature", "paths"] : ["quick-start", "key-status", "paths"]);
assert.equal(embeddedMetadata(page).signatureEnabled, enabled);
assert.ok(page.includes(`href="${config.repositoryUrl}#readme"`), "installation instructions remain linked even without Signature");
assert.equal(page.includes('id="path-signature"'), enabled);
assert.equal(page.includes('id="path-certificate"'), enabled);
assert.doesNotMatch(page, /@@[A-Z_]+@@/);
if (enabled) {
assert.match(page, /Signature · 签名与本地信任/);
assert.match(page, /未提供签名包/);
if (state.clientDefaults.gpgVerification === "on") assert.match(page, /本仓库默认开启客户端验签/);
else assert.match(page, /客户端验签默认关闭/);
}
}
assert.deepEqual(pathsFor(config).map(({id}) => id), ["keys", "script", "root-script", "homepage"]);
});
test("default follows system and explicit light/dark/system choices persist", () => { test("default follows system and explicit light/dark/system choices persist", () => {
const page = setup(); const page = setup();
const select = page.elements.get("theme-picker"); const select = page.elements.get("theme-picker");