Author SHA1 Message Date
LamGC 8726db82ff feat: 增强了 Shell 脚本的兼容性。
Issue #5
2026-01-13 08:27:31 +00:00
29 changed files with 559 additions and 5038 deletions
-45
View File
@@ -1,45 +0,0 @@
name: Validate repository keys and clients
on:
pull_request:
push:
branches: [main]
permissions:
contents: read
jobs:
check:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version: '22'
cache: npm
- run: npm ci
- name: Install shell test interpreters
run: |
if ! command -v busybox >/dev/null || ! command -v shellcheck >/dev/null; then
sudo apt-get update
sudo apt-get install -y busybox-static shellcheck
fi
busybox sh -c 'echo "BusyBox shell ready"'
shellcheck --version
- name: Run unit, shell, and real-browser tests
run: npm test
env:
BROWSER_TESTS: '1'
BROWSER_ARTIFACTS_DIR: /tmp/quickly-browser-artifacts
- name: Build isolated test fixture (never deployed)
run: npm run build:test
- name: Validate Worker bundle without publishing
run: npx wrangler deploy --dry-run --config cf-worker/wrangler.toml --outdir /tmp/worker-dry-run
- name: Upload browser evidence
if: always()
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: browser-evidence
path: /tmp/quickly-browser-artifacts
if-no-files-found: ignore
retention-days: 7
-48
View File
@@ -1,48 +0,0 @@
name: Publish server key release
on:
push:
branches: [main]
workflow_dispatch:
permissions:
contents: read
concurrency:
group: production-key-release
cancel-in-progress: false
jobs:
deploy:
# Deliberate opt-in prevents accidental deployment when this PR is merged.
# Configure production credentials and real server keys before enabling.
if: github.ref == 'refs/heads/main' && vars.ENABLE_WORKER_DEPLOY == 'true'
runs-on: ubuntu-latest
timeout-minutes: 15
environment: production
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version: '22'
cache: npm
- run: npm ci
- name: Install shell test interpreters
run: |
if ! command -v busybox >/dev/null || ! command -v shellcheck >/dev/null; then
sudo apt-get update
sudo apt-get install -y busybox-static shellcheck
fi
busybox sh -c 'echo "BusyBox shell ready"'
shellcheck --version
- run: npm test
env:
BROWSER_TESTS: '1'
- name: Build production release (empty keys fail unless explicitly allowed)
run: npm run build
- run: node scripts/verify-release.mjs
- name: Deploy the validated production bundle
run: npx wrangler deploy --config cf-worker/wrangler.toml
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
- name: Verify published revision and public key payload
run: node scripts/verify-release.mjs --remote
+28
View File
@@ -0,0 +1,28 @@
name: Update access url
on:
workflow_dispatch:
permissions:
contents: write
jobs:
update-url:
timeout-minutes: 5
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Update access url
run: |
sed -i "s/ssh.lamgc.me/$REPLACEMENT_VALUE/g" README.md
env:
# 要替换的个人访问地址,例如:ssh.lamgc.me
REPLACEMENT_VALUE: ${{ secrets.ACCESS_URL }}
- name: Commit changes
uses: stefanzweifel/git-auto-commit-action@v4
with:
commit_message: Update access url to ${{ secrets.ACCESS_URL }}
commit_options: '--no-verify'
file_pattern: README.md
-8
View File
@@ -1,8 +0,0 @@
node_modules/
cf-worker/generated/
cf-worker/public/
.wrangler/
*.log
__pycache__/
*.pyc
+83 -180
View File
@@ -1,195 +1,98 @@
# Quickly Configuring SSH Server # Quickly Configuring SSH Server
快速地将 SSH 密钥部署到服务器中,并定期更新。
在 Git 仓库中独立管理服务器 SSH 公钥,通过 Cloudflare Worker 发布,再由服务器定期同步。 这个脚本可以帮你做到这些事情:
- 快速配置服务器的 SSH Server;
- **优先使用本仓库的 `keys/authorized_keys`**;主清单为空时,可按配置顺序读取备用 HTTPS 公钥源。没有默认 GitHub 账号 SSH Keys 来源。 - 定期自动更新 SSH 公钥到服务器中;
- 网页、脚本与仓库公钥一起构建发布,不再依赖 GitHub Pages;备用公钥独立标记来源和内容摘要。 - 集中管理注册到服务器的 SSH 公钥(例如使用 Github 管理 SSH 公钥);
- 首页支持浅色 / 深色 / 跟随系统、手机与宽屏,以及 GitHub 风格的代码块复制按钮。
- POSIX Shell 客户端只替换自己的密钥管理区块,保留手工密钥;下载或校验失败保留上次成功的内容。
> 仓库初始没有服务器公钥,生产构建会拒绝空清单。先填写你独立管理的服务器**公钥**或显式配置备用源;不要上传私钥,也不要直接拿测试夹具上线。
## Quick Usage ## Quick Usage
部署密钥并设置定期更新:
以下地址是部署配置中的示例。Fork 后先修改 `deployment.json`,填写公钥并完成自己的 Worker 部署,再使用自己的域名。执行前建议下载并检查脚本。 ```bash
curl -s https://ssh.lamgc.me | bash -s -- -c
安装公钥并设置定时同步: ```
如果不希望自动更新密钥,可以移除 `-c` 参数:
```sh ```bash
curl -fsSL 'https://ssh.lamgc.me/script.sh' | sh -s -- -c curl -s https://ssh.lamgc.me | bash -s
```
跳过检查 SSH Server,只需要安装秘钥的话,就需要单独使用 `-o` 参数:
```bash
curl -s https://ssh.lamgc.me | bash -s -- -o
```
添加 `-p yes` 参数可以设置允许 Root 用户使用密码登录 SSH(需使用 Root 用户执行脚本):
```bash
curl -s https://ssh.lamgc.me | bash -s -- -p yes
```
要查看脚本的帮助信息,请使用 `-h` 参数:
```bash
curl -s https://ssh.lamgc.me | bash -s -- -h
``` ```
新安装默认每 15 分钟同步一次;已有的有效自定义计划会保留。只更新一次: > Note: 更多用法请参见脚本帮助信息~
```sh
curl -fsSL 'https://ssh.lamgc.me/script.sh' | sh -s -- -o
```
预览、帮助与本地状态:
```sh
curl -fsSL 'https://ssh.lamgc.me/script.sh' | sh -s -- -o --dry-run
```
```sh
curl -fsSL 'https://ssh.lamgc.me/script.sh' | sh -s -- -h
```
```sh
sh "$HOME/.conf-sshd/conf-sshd.sh" --status
```
指定计划、取消计划、单独更新已安装脚本:
```sh
sh "$HOME/.conf-sshd/conf-sshd.sh" -c '0 */6 * * *'
```
```sh
sh "$HOME/.conf-sshd/conf-sshd.sh" -c false
```
```sh
sh "$HOME/.conf-sshd/conf-sshd.sh" -u
```
卸载定时同步与脚本,保留密钥及 SSH 服务配置:
```sh
sh "$HOME/.conf-sshd/conf-sshd.sh" --uninstall
```
这些命令用途不同,按需单独执行。网页只提供逐块复制,不把安装、卸载、密码策略合成“执行全部”。
## 公钥清单与发布
将普通 OpenSSH 公钥逐行放入 `keys/authorized_keys`,可附注释和空行。构建会规范化换行与编码,逐条调用 `ssh-keygen` 校验,拒绝重复钥匙、私钥、无效编码和未知行。
支持 ED25519、RSA(至少 2048 位)、ECDSA nistp256/384/521 和 OpenSSH 安全密钥类型。第一版不接受 DSA、证书或 `command=`、`environment=` 等 authorized_keys 选项。私钥应始终保留在你的客户端或安全密钥设备上。
配置文件 `deployment.json`:
- `baseUrl`:Worker 的正式 HTTPS origin,不带路径或末尾斜杠。
- `repositoryUrl`:自己的项目仓库地址。
- `defaultCron`:新安装的默认同步计划,默认 `*/15 * * * *`。
- `allowEmpty`:默认 `false`;明确设为 `true` 时,空主清单表示撤销全部,禁止回退到备用源。
- `fallbackSources`:默认空数组。可填写最多 5 个公开 HTTPS URL,按顺序尝试;不能带凭据、查询参数或片段,不接受请求端传入的地址。
页面展示的是**当前已发布版本**,不是每台服务器的实时同步状态。提交合并后还要等待部署成功;服务器会在下一次成功同步时应用。
### 有序备用源
只有仓库清单为空,且 `allowEmpty` 不是 `true` 时,Worker 才依次读取 `fallbackSources`。取第一个成功且有效的非空公钥集合,不聚合多个源。仓库清单非空始终优先;明确撤销全部时不调用任何备用源。
每个备用源有 3 秒超时(含正文读取)、1 MiB 正文限制、最多 1000 个公钥,并拒绝重定向。非 2xx、空正文、无效 UTF-8、私钥/选项/证书、损坏密钥或重复密钥会跳过并尝试下一个。全部失败时 `/ssh.keys` 和 `/release.json` 返回 503,首页说明暂不可用;客户端保留上次成功的密钥。
不设置 Worker 备用公钥缓存,不使用陈旧内容兜底;每个相关请求重新获取,要求上游也正确提供当前数据。首页的一次响应中,公钥、复制内容、来源、数量与内容摘要来自同一个结果。不同时间请求可能看到备用源的不同版本,因此页面分别显示代码修订与公钥摘要,不能只用 Git 提交号代表动态公钥版本。
撤销备用源公钥取决于上游更新、下一次成功请求和服务器同步周期;这不是即时断开连接的机制。主清单为空、未允许明确撤销且未配置备用源时,生产构建仍会拒绝。
### 明确撤销全部管理密钥
确实需要删除全部受管理密钥时,在同一提交中清空公钥清单,并显式将 `allowEmpty` 改为 `true`。发布与新客户端都会检查数量、版本和内容摘要,只有合法的明确空清单才会清空管理区块。
先确认其他恢复登录方式可用。手工放在管理区块之外的密钥不会删除;同一把钥匙若还存在于其他 authorized_keys 来源,仍可能允许登录。删除密钥也不会终止已有 SSH 会话。
网络故障、错误正文、未知协议或校验失败都会保留本地旧密钥并报错。因此断网时不能保证及时撤权。旧版本客户端不支持明确空清单撤权,应先升级。
### 安全边界
服务器密钥与 GitHub 账号认证密钥相互独立。在仓库里保存公钥不会把它注册为 GitHub 账号登录凭据。
但能修改并发布这个项目的人,以及被配置为备用公钥源的发布者,都能改变使用它的服务器的授权密钥。请保护仓库主分支、CI、Cloudflare 账号和部署权限。HTTPS 保护传输,SHA-256 用于检测截断或版本不一致,**不能抵抗发布源本身被攻陷**。`curl | sh` 仍意味着信任发布的可执行脚本;定时任务只拉公钥,不会自动更新脚本。
## 安装与部署
### 前提
构建环境需要 Node.js 22+、npm、Git、OpenSSH 客户端工具(`ssh-keygen`)及 POSIX `sh`。依赖固定在 `package-lock.json`。
客户端需要 `sh`、`curl`、`ssh-keygen`、`awk`、`mktemp`、`stat` 和常用 Unix 工具,以及 `sha256sum` / `shasum` / `openssl` 之一;设置定时同步还需 `crontab`。请自行安装所需软件。脚本不安装包、不启动或重载服务;`-n` 保留为兼容参数。
目标为 Linux 上的 dash、Bash 的 sh 用法和 BusyBox ash。真实发行版服务管理、SELinux 标签或自定义 `AuthorizedKeysFile` 不应仅凭单元测试视为已验证。默认目标文件是当前用户的 `$HOME/.ssh/authorized_keys`;自定义 SSH 授权路径需要人工核对。
### 一次性配置
1. Fork / 克隆仓库,填入自己的服务器公钥和 `deployment.json`。
2. 按需要修改 `cf-worker/wrangler.toml` 中的 Worker 名称,并为 `baseUrl` 配置 Worker 自定义域名。无需启用 GitHub Pages。
3. 安装依赖、运行检查、构建生产产物:
```sh
npm ci
npm test
npm run build
node scripts/verify-release.mjs
```
4. 在 GitHub 的生产环境中配置最小权限的 Cloudflare 部署 token 和账号 ID,对应 `CLOUDFLARE_API_TOKEN`、`CLOUDFLARE_ACCOUNT_ID`。凭据只存放在 Secrets,不能提交到代码或公钥清单。
5. 准备好正式公钥、域名和权限后,将仓库变量 `ENABLE_WORKER_DEPLOY` 设为 `true`。此后 main 的更新会运行生产发布流程,也可手动触发。
默认关闭自动生产发布,防止合并本次改动时意外上线。PR 验证不会读取部署凭据或发布 Worker。
生产流程先测试和严格构建,再部署,最后核对 `/release.json` 和 `/ssh.keys` 的版本与摘要。没有正式密钥、没有备用源且未显式允许空清单时会失败;不会偷偷改用 GitHub Keys 或测试密钥。
发布脚本、网页、仓库公钥和备用源配置属于同一构建产物;动态备用公钥有单独的内容摘要。若要回滚历史版本,先查看公钥差异:回滚可能重新启用已撤销的密钥。部署失败保留旧发布,但不能把这描述为新密钥已生效。
## Paths ## Paths
获取 SSH 公钥:
| 路径 | 内容 | ```
| --- | --- | https://ssh.lamgc.me/ssh.keys
| `/` | 浏览器首页;旧 curl/libcurl 请求仍返回脚本(显式请求 HTML 除外) |
| `/script.sh` | Shell 脚本,推荐所有新命令使用这个明确地址 |
| `/ssh.keys` | 带注释元数据的普通公钥文本 |
| `/release.json` | 当前代码版本、有效公钥来源、数量、摘要和空清单状态 |
动态入口不缓存旧数据;带内容哈希的静态 CSS / JS 可以长期缓存。未知路径返回 404,不能用首页 HTML 代替密钥或脚本错误。
## 客户端保护措施
- 所有参数先解析;未知选项、缺参数或冲突用途会报错。
- 下载具有 HTTPS 限制、HTTP 失败检测、超时、有限重试和大小限制。
- 响应包含协议版本、Git revision、密钥数量、明确空清单标记和 SHA-256;所有公钥逐条校验。
- 每个用户独占运行锁;临时文件使用不可预测名称、严格权限和异常清理。
- 只识别唯一且配对的原有 BEGIN / END 管理标记,损坏或重复时停止,避免误吞手工密钥。
- 同目录原子替换,写入前保存一个受限备份;重复执行内容相同时不重写文件。
- 拒绝符号链接、不合适的所有权/写权限和异常文件类型。
- `-c` 只管理自己的 cron 区块;原版已知命令可迁移,无法可靠判断的记录会停止并提示。
- 正常密钥同步不会修改 `sshd_config`,也不会重载 SSH 服务。
如果运行被不可捕获地终止,锁可能残留。先检查 `.conf-sshd/lock/pid` 对应的进程及工作状态,再人工处理锁,不能仅凭锁的年龄删除。
### Root 密码登录(高级)
保留 `-p yes` / `-p no`,必须单独显式使用并以 root 运行。不属于安装/同步默认流程。它只设置 `PermitRootLogin`,不会替你打开其他认证策略。
脚本会检查原配置、生成临时配置、验证语法与生效值、备份后替换,**不会自动 reload/restart**。发现 Include / Match 等无法安全判断的组合时,会拒绝修改并请你人工处理。应用服务变更前应保留恢复会话并测试新连接。
## 从旧版本迁移
1. 先建立独立服务器公钥清单并发布新 Worker;或显式配置备用源;不会自动导入 GitHub 账号公钥。
2. 检查页面公钥、版本和 `/ssh.keys`,确认是你的服务器密钥。
3. 显式更新已安装脚本,或从新的 `/script.sh` 下载并检查后重新运行 `-c`。
4. 验证自定义 cron 仍保留,手工密钥不变,另开 SSH 会话验证登录。
旧客户端对非空的 `/ssh.keys` 仍有基本格式兼容,但不具备新验证、明确空清单撤权与其他保护。不要因为 Worker 更新了就认为所有客户端已升级。
## 开发与测试
```sh
npm ci
npm test
npm run build:test
npx wrangler deploy --dry-run --config cf-worker/wrangler.toml
``` ```
`build:test` 只用于测试/预览,使用公开的 RFC 测试公钥,并在产物中标记 `testFixture: true`。生产验证脚本拒绝该产物;生产构建只使用仓库公钥和显式配置的备用源,不允许回退到测试夹具。 获取 Shell 脚本:
```
https://ssh.lamgc.me/script.sh
```
测试覆盖构建配置与注入边界、逐条密钥验证、Worker 路由/缓存、主题/复制交互、Shell 参数/管理区块/失败保留/cron/自更新等。Shell 测试只在临时 HOME、假命令和测试文件中运行,不操作真实 SSH 配置或服务。 获取 Shell 脚本(使用 Curl 访问):
```
https://ssh.lamgc.me/
```
`npm run build:test` 之后若要部署正式版本,必须重新执行 `npm run build` 与 `node scripts/verify-release.mjs`。不得直接部署测试产物。 访问本页面(不使用 Curl 访问):
```
https://ssh.lamgc.me/
```
## License ## Install
### Configuration
先 Fork 本仓库,Fork 后在仓库设置中启用 Github Pages,分支指向 main 分支,目录为仓库根目录。
配置好后将仓库克隆到本地,然后修改 `cf-worker/src/index.js` 中的前五个变量:
```javascript
// 改成你自己的 Github 用户名,注意是登录 Github 的那个用户名.
const githubUserName = "LamGC";
// 改成你 Fork 后的仓库名,记得要开启 Github Pages 功能.
const githubInstSshProjectName = "quickly-conf-sshd";
// 如果可以,建议在此设置备用的 SSH 公钥, 以防 Github 无法使用.
const backupSshKeys = ``;
// Worker 的访问地址, 如果不填的话默认为请求的地址, 填了就会用这里的地址(要去 Worker 的触发器那绑定, 否则无效).
const defaultBaseUrl = "";
// Cron 表达式, 默认 1 天执行一次更新.
const cronExpression = "0 0 * * *";
```
Apache-2.0,见 [LICENSE](LICENSE)。 ### Use Wrangler
如果你安装了 Cloudflare 的 Wrangler,可以直接在 `cf-worker` 目录中执行:
```bash
wrangler publish
```
执行后将会自动创建 Worker 并上传 worker js 代码,Worker 的名字是 `quickly-conf-ssh-worker`。
客户端会检测写入期间可观察到的手工修改并停止;其他编辑器若不遵守同一锁,仍无法保证操作系统级的原子比较并交换。请避免同时手工编辑授权文件。 ### Manual Install
如果你没有安装 Wrangler,那就手动在 Cloudflare Workers 中新建一个 Worker,名字你喜欢就好(也可以使用 `quickly-conf-ssh-worker`),选择 HTTP 处理程序,然后下一步;
![Create a new worker](docs/Create-a-new-worker.png)
创建完成后点击 Worker 名称右边的**快速编辑**,将 `cf-worker/src/index.js` 中的内容复制到编辑框中,把原本的代码覆盖掉,然后保存;
![Copy Code](docs/copy-js-code-to-worker.png)
保存后需要访问一下 Worker,检查是否有误(如果不检查仔细,把其他人的 SSH 密钥设置到服务器里了,那就寄咯):
- 直接访问 Worker 地址,看看是否能跳转到你 Fork 的仓库中(地址栏显示了 `https://{你的 Github 用户名}.github.io/{Fork的仓库名}/`);
- 访问 `{Worker地址}/ssh.keys`,应该显示你的在 Github 设置了的所有 SSH 公钥,如果是空的或者不是你的密钥,那就检查一下是不是 Github 用户名填错了;
- 访问 `{Worker地址}/script.sh`,应该返回一个脚本,脚本开头的配置应与你在 Worker 开头的有关配置一致。
检查无误后就算安装完成了,然后修改本 Readme 的上面几个部分,把域名都改成自己的,保存后提交 Push 上去就好。
(如果你不会用 Git,那就直接去 Github 网页上改 Readme.md)
### (可选)使用自定义域名
在 Worker 详情中,点击“触发器”,然后点击“添加自定义域”,按照提示输入已添加在 Cloudflare 中的域名即可。
![Add Custom Domain](docs/add-custom-domain.png)
![Input Custom Domain](docs/input-custom-domain.png)
![Added Custom Domain](docs/added-custom-domain.png)
+104 -3
View File
@@ -1,4 +1,105 @@
import release from '../generated/release.js'; // 改成你自己的 Github 用户名,注意是登录 Github 的那个用户名.
import { createHandler } from './router.js'; const githubUserName = "LamGC";
// 改成你 Fork 后的仓库名,记得要开启 Github Pages 功能.
const githubInstSshProjectName = "quickly-conf-sshd";
// 如果可以,建议在此设置备用的 SSH 公钥, 以防 Github 无法使用.
const backupSshKeys = ``;
// Worker 的访问地址, 如果不填的话默认为请求的地址, 填了就会用这里的地址(要去 Worker 的触发器那绑定, 否则无效).
const defaultBaseUrl = "";
// Cron 表达式, 默认 1 天执行一次更新.
const cronExpression = "0 0 * * *";
export default { fetch: createHandler(release) }; // 下面的东西一般不用改.
const baseRepoPageUrl = `https://${githubUserName.toLowerCase()}.github.io/${githubInstSshProjectName}/`;
const installScriptUrl = `${baseRepoPageUrl}/conf-sshd.sh`;
// 如果出现 Github 无法使用的情况, 可以修改 sshKeyUrl 来变更位置.
// 也可以添加额外的 SSH 公钥地址(比如 KeyBase).
const sshKeyUrls = [
`https://github.com/${githubUserName}.keys`
];
// 下面是脚本的占位符.
const SCRIPT_PH_SSH_KEY_URL = "{{ SSH_KEY_URL }}"
const SCRIPT_PH_SCRIPT_URL = "{{ SCRIPT_URL }}"
const SCRIPT_PH_DEFAULT_CRON = "{{ DEFAULT_CRON }}"
async function sendScriptContent(baseUrl) {
let scriptResp = await fetch(new Request(installScriptUrl));
if (scriptResp.ok) {
let scriptContent = await scriptResp.text();
scriptContent = scriptContent.replace(SCRIPT_PH_SSH_KEY_URL, `${baseUrl}/ssh.keys`)
.replace(SCRIPT_PH_SCRIPT_URL, `${baseUrl}/script.sh`)
.replace(SCRIPT_PH_DEFAULT_CRON, cronExpression)
return new Response(scriptContent, {
headers: {
"content-type": "text/plain; charset=utf-8"
}
});
} else {
return new Response("Failed to get install script.", {
status: 500,
statusText: "Failed to get install script",
headers: {
"content-type": "text/plain; charset=utf-8"
}
});
}
}
export default {
async fetch(request, env) {
const { pathname, host } = new URL(request.url);
const baseUrl = defaultBaseUrl || `https://${host}`;
if (pathname === "/ssh.keys") {
for (let url of sshKeyUrls) {
let response = await fetch(new Request(url));
if (response.ok) {
let keys = await response.text()
return new Response(keys, {
headers: {
"content-type": "text/plain; charset=utf-8"
}
});
}
}
if (backupSshKeys.length > 0) {
return new Response(backupSshKeys, {
headers: {
"content-type": "text/plain; charset=utf-8"
}
});
} else {
return new Response("Failed to get keys.", {
status: 500,
statusText: "Failed to get keys",
headers: {
"content-type": "text/plain; charset=utf-8"
}
});
}
} else if (pathname === "/") {
const userAgent = request.headers.get("User-Agent");
if (userAgent != null && userAgent.match(/curl|libcurl/) !== null) {
return await sendScriptContent(baseUrl);
} else {
return new Response("", {
status: 301,
statusText: "Redirect",
headers: {
"Location": baseRepoPageUrl
}
});
}
} else if (pathname === "/script.sh") {
return await sendScriptContent(baseUrl);
} else {
return new Response("Not found.", {
status: 404,
statusText: "Not Found"
})
}
}
}
-223
View File
@@ -1,223 +0,0 @@
// Runtime fallback is deliberately stateless: a request never reuses earlier keys.
export const KEY_SOURCE_LIMITS = Object.freeze({sources: 5, timeoutMs: 3000, maxBytes: 1024 * 1024, maxKeys: 1000});
const encoder = new TextEncoder();
const decoder = new TextDecoder('utf-8', {fatal: true});
const keyTypes = new Set(['ssh-ed25519', 'ssh-rsa', 'ecdsa-sha2-nistp256', 'ecdsa-sha2-nistp384', 'ecdsa-sha2-nistp521', '[email protected]', '[email protected]']);
const curves = {nistp256: {name: 'P-256', size: 32}, nistp384: {name: 'P-384', size: 48}, nistp521: {name: 'P-521', size: 66}};
class SourceError extends Error {
constructor(code, status) { super(code); this.code = code; if (status !== undefined) this.status = status; }
}
export class FallbackUnavailableError extends Error {
constructor(diagnostics = []) {
super('No configured SSH key source returned a valid nonempty key set.');
this.name = 'FallbackUnavailableError';
this.code = 'KEY_SOURCES_UNAVAILABLE';
// Keep diagnostics safe to log or return: no response content, URL secrets, or exception text.
this.diagnostics = diagnostics.slice(0, KEY_SOURCE_LIMITS.sources).map(({index, code, status}) => ({index, code, ...(status === undefined ? {} : {status})}));
}
}
export function validateFallbackSources(sources = []) {
if (!Array.isArray(sources) || sources.length > KEY_SOURCE_LIMITS.sources) throw new Error('fallbackSources must contain at most five HTTPS URLs.');
const seen = new Set();
return sources.map(value => {
if (typeof value !== 'string' || value.length > 2048 || /[\s\u0000-\u001f\u007f-\u009f]/u.test(value)) throw new Error('Each fallback source must be a public-host HTTPS URL without credentials, queries, or fragments.');
let url;
try { url = new URL(value); } catch { throw new Error('Invalid fallback source URL.'); }
if (url.protocol !== 'https:' || !url.hostname || url.username || url.password || url.search || value.includes('?') || url.hash || value.includes('#') || !value.startsWith('https://')) throw new Error('Each fallback source must be a public-host HTTPS URL without credentials, queries, or fragments.');
// Configuration is trusted, but exclude local names and IP literals before any network call.
// A hostname allowlist is not a DNS-resolution guarantee; source operators remain trusted.
if (url.hostname.length > 253 || !/^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z](?:[a-z0-9-]{0,61}[a-z0-9])?$/i.test(url.hostname) || /\.(?:localhost|local|localdomain|internal|intranet|lan|home|corp|onion)$/i.test(url.hostname)) throw new Error('Fallback sources must use public HTTPS hostnames, not local names or IP addresses.');
if (seen.has(url.href)) throw new Error('Duplicate fallback source URL.');
seen.add(url.href);
return url.href;
});
}
function encodeBase64(bytes) {
let binary = '';
for (const byte of bytes) binary += String.fromCharCode(byte);
return btoa(binary);
}
function decodeBase64(encoded) {
if (!/^[A-Za-z0-9+/]+={0,2}$/.test(encoded) || encoded.length % 4 === 1) throw new Error('Invalid public key encoding.');
let decoded;
try { decoded = Uint8Array.from(atob(encoded), c => c.charCodeAt(0)); } catch { throw new Error('Invalid public key encoding.'); }
if (encodeBase64(decoded).replace(/=+$/, '') !== encoded.replace(/=+$/, '')) throw new Error('Invalid public key encoding.');
return decoded;
}
async function sha256(bytes) { return new Uint8Array(await crypto.subtle.digest('SHA-256', bytes)); }
function hex(bytes) { return Array.from(bytes, byte => byte.toString(16).padStart(2, '0')).join(''); }
class WireReader {
constructor(bytes) { this.bytes = bytes; this.offset = 0; }
string() {
if (this.offset + 4 > this.bytes.length) throw new Error('Truncated public key data.');
const length = new DataView(this.bytes.buffer, this.bytes.byteOffset + this.offset, 4).getUint32(0);
this.offset += 4;
if (length > this.bytes.length - this.offset) throw new Error('Truncated public key field.');
const value = this.bytes.subarray(this.offset, this.offset + length);
this.offset += length;
return value;
}
text() { return decoder.decode(this.string()); }
end() { if (this.offset !== this.bytes.length) throw new Error('Unexpected trailing public key data.'); }
}
function positiveMpint(bytes) {
if (!bytes.length || (bytes[0] & 128) || (bytes[0] === 0 && (bytes.length === 1 || !(bytes[1] & 128)))) throw new Error('RSA fields must be positive, minimally encoded mpints.');
return bytes[0] === 0 ? bytes.subarray(1) : bytes;
}
async function validatePoint(reader, curveName) {
if (reader.text() !== curveName) throw new Error('ECDSA curve does not match key type.');
const point = reader.string(), curve = curves[curveName];
if (point.length !== 1 + 2 * curve.size || point[0] !== 4) throw new Error('Invalid uncompressed ECDSA point.');
// Web Crypto also rejects off-curve points, rather than just checking wire lengths.
try { await crypto.subtle.importKey('raw', point, {name: 'ECDSA', namedCurve: curve.name}, false, ['verify']); }
catch { throw new Error('Invalid ECDSA point.'); }
}
function validateApplication(reader) {
const value = reader.string();
if (!value.length || value.length > 1024 || /[\u0000-\u001f\u007f-\u009f]/u.test(decoder.decode(value))) throw new Error('Invalid security-key application.');
}
async function validateWire(type, bytes) {
const reader = new WireReader(bytes);
if (reader.text() !== type) throw new Error('Public key type does not match key data.');
if (type === 'ssh-ed25519' || type === '[email protected]') {
if (reader.string().length !== 32) throw new Error('Ed25519 public keys must contain 32 bytes.');
if (type.startsWith('sk-')) validateApplication(reader);
} else if (type === 'ssh-rsa') {
const e = positiveMpint(reader.string()), n = positiveMpint(reader.string());
const bits = (n.length - 1) * 8 + 32 - Math.clz32(n[0]);
if (bits < 2048 || bits > 16384) throw new Error('RSA keys must have 2048 to 16384 bits.');
if (e.length > 8 || (e.length === 1 && e[0] < 3) || !(e.at(-1) & 1) || !(n.at(-1) & 1)) throw new Error('Invalid RSA exponent or modulus.');
} else if (type.startsWith('ecdsa-sha2-')) {
await validatePoint(reader, type.slice('ecdsa-sha2-'.length));
} else if (type === '[email protected]') {
await validatePoint(reader, 'nistp256');
validateApplication(reader);
} else throw new Error('Unsupported public key type.');
reader.end();
}
/** Parse plain OpenSSH keys, never authorized_keys options, certificates, or executable content. */
export async function parsePublicKeys(input, {maxKeys = KEY_SOURCE_LIMITS.maxKeys, signal} = {}) {
if (signal?.aborted) throw new SourceError('timeout');
if (!Number.isInteger(maxKeys) || maxKeys < 1 || maxKeys > KEY_SOURCE_LIMITS.maxKeys) throw new Error('Invalid public key count limit.');
if (typeof input !== 'string' || encoder.encode(input).length > KEY_SOURCE_LIMITS.maxBytes) throw new Error('Public key input exceeds its size limit.');
const normalized = input.replace(/\r\n/g, '\n');
if (/[\u0000-\u0008\u000b-\u001f\u007f-\u009f\ufffd]/u.test(normalized) || /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/u.test(normalized)) throw new Error('Public key input contains invalid control characters or Unicode.');
if (/-----BEGIN [^-\r\n]*PRIVATE KEY-----|PuTTY-User-Key-File-[0-9]+:/i.test(normalized)) throw new Error('Private keys are never accepted.');
const keys = [], seen = new Set();
for (const raw of normalized.split('\n')) {
if (signal?.aborted) throw new SourceError('timeout');
const line = raw.replace(/^[ \t]+|[ \t]+$/g, '');
if (!line || line.startsWith('#')) continue;
if (encoder.encode(line).length > 8192) throw new Error('Public key line exceeds its size limit.');
const match = /^(\S+)[ \t]+([A-Za-z0-9+/]+={0,2})(?:[ \t]+(.*))?$/u.exec(line);
if (!match || !keyTypes.has(match[1])) throw new Error('Expected a supported plain OpenSSH public key without options or certificates.');
if (keys.length >= maxKeys) throw new Error('Too many public keys.');
const [, type, encoded, rawComment = ''] = match;
const comment = rawComment.replace(/[ \t]+/g, ' ').trim();
const bytes = decodeBase64(encoded), identity = encodeBase64(bytes);
if (seen.has(identity)) throw new Error('Duplicate public key.');
await validateWire(type, bytes);
if (signal?.aborted) throw new SourceError('timeout');
seen.add(identity);
keys.push({text: `${type} ${identity}${comment ? ` ${comment}` : ''}`, type, label: comment || `Server key ${keys.length + 1}`, fingerprint: `SHA256:${encodeBase64(await sha256(bytes)).replace(/=+$/, '')}`});
}
return keys;
}
export async function makeKeyBundle(keys, revision, allowEmpty) {
if (!/^[0-9a-f]{40}$/.test(revision)) throw new Error('Release revision must be a 40-character Git commit SHA.');
if (typeof allowEmpty !== 'boolean') throw new Error('allowEmpty must be an explicit boolean.');
if (!keys.length && !allowEmpty) throw new Error('An empty key set requires explicit revocation.');
const payload = keys.map(key => `${key.text}\n`).join('');
const digest = hex(await sha256(encoder.encode(payload)));
const text = `# conf-sshd-v1\n# revision ${revision}\n# key-count ${keys.length}\n# allow-empty ${allowEmpty ? 'yes' : 'no'}\n# sha256 ${digest}\n# end-metadata\n${payload}`;
if (encoder.encode(text).length > KEY_SOURCE_LIMITS.maxBytes) throw new SourceError('too-large');
return {sha256: digest, text};
}
async function snapshot(keys, release, source) {
const bundle = await makeKeyBundle(keys, release.revision, release.allowEmpty);
return {keys, keysText: bundle.text, sha256: bundle.sha256, keyCount: keys.length, allowEmpty: release.allowEmpty, source, revision: release.revision, keyRevision: bundle.sha256, fetchedAt: new Date().toISOString()};
}
function boundedOption(options, name) {
const value = options[name] ?? KEY_SOURCE_LIMITS[name];
if (!Number.isInteger(value) || value < 1 || value > KEY_SOURCE_LIMITS[name]) throw new Error(`Invalid ${name} limit.`);
return value;
}
async function readBody(response, signal, maxBytes) {
const declaredLength = response.headers.get('Content-Length');
if (declaredLength && /^\d+$/.test(declaredLength) && Number(declaredLength) > maxBytes) throw new SourceError('too-large');
if (!response.body) return '';
const reader = response.body.getReader();
// Grow one bounded buffer, so an upstream sending tiny chunks cannot grow an unbounded chunk list.
let bytes = new Uint8Array(Math.min(16384, maxBytes));
let total = 0, complete = false;
const cancel = () => { void reader.cancel().catch(() => {}); };
signal.addEventListener('abort', cancel, {once: true});
try {
while (true) {
if (signal.aborted) throw new SourceError('timeout');
const {value, done} = await reader.read();
if (signal.aborted) throw new SourceError('timeout');
if (done) { complete = true; break; }
const nextTotal = total + value.byteLength;
if (nextTotal > maxBytes) throw new SourceError('too-large');
if (nextTotal > bytes.length) {
const expanded = new Uint8Array(Math.min(maxBytes, Math.max(nextTotal, bytes.length * 2)));
expanded.set(bytes);
bytes = expanded;
}
bytes.set(value, total);
total = nextTotal;
}
try { return decoder.decode(bytes.subarray(0, total)); } catch { throw new SourceError('invalid-utf8'); }
} finally {
signal.removeEventListener('abort', cancel);
if (!complete) cancel();
reader.releaseLock();
}
}
async function fetchSource(url, fetchImpl, limits) {
const controller = new AbortController();
let timer, response;
const timeout = new Promise((_, reject) => {
timer = setTimeout(() => { reject(new SourceError('timeout')); controller.abort(); }, limits.timeoutMs);
});
const operation = (async () => {
try {
response = await fetchImpl(url, {method: 'GET', redirect: 'manual', cache: 'no-store', headers: {'Accept': 'text/plain, application/octet-stream;q=0.9', 'Cache-Control': 'no-cache, no-store', 'Pragma': 'no-cache'}, signal: controller.signal});
if (controller.signal.aborted) throw new SourceError('timeout');
if (response.redirected || (response.status >= 300 && response.status < 400)) throw new SourceError('redirect', response.status);
if (!response.ok) throw new SourceError('http-error', response.status);
const body = await readBody(response, controller.signal, limits.maxBytes);
let keys;
try { keys = await parsePublicKeys(body, {maxKeys: limits.maxKeys, signal: controller.signal}); } catch { throw new SourceError('invalid-keys'); }
if (!keys.length) throw new SourceError('empty');
return keys;
} finally {
if (response?.body && !response.body.locked) void response.body.cancel().catch(() => {});
}
})();
try { return await Promise.race([operation, timeout]); }
finally { clearTimeout(timer); }
}
/** Only release-configured URLs are accepted; callers must never derive release data from a request. */
export async function resolveKeys(release, fetchImpl = globalThis.fetch, internalOptions = {}) {
if (!release || !Array.isArray(release.keys) || typeof release.allowEmpty !== 'boolean') throw new Error('Invalid deployed key release.');
if (release.keys.length) return snapshot(release.keys, release, {type: 'repository'});
// An explicit empty release means revoke. It must never be repopulated from a fallback.
if (release.allowEmpty) return snapshot([], release, {type: 'explicit-empty'});
const sources = validateFallbackSources(release.fallbackSources);
const limits = {timeoutMs: boundedOption(internalOptions, 'timeoutMs'), maxBytes: boundedOption(internalOptions, 'maxBytes'), maxKeys: boundedOption(internalOptions, 'maxKeys')};
const diagnostics = [];
for (const [index, url] of sources.entries()) {
try { return await snapshot(await fetchSource(url, fetchImpl, limits), release, {type: 'fallback', url}); }
catch (error) { diagnostics.push({index, code: error instanceof SourceError ? error.code : 'network-error', ...(error instanceof SourceError && error.status !== undefined ? {status: error.status} : {})}); }
}
throw new FallbackUnavailableError(diagnostics);
}
-70
View File
@@ -1,70 +0,0 @@
import { resolveKeys } from './key-source.js';
import { renderPage } from './view.js';
const securityHeaders = {
'X-Content-Type-Options': 'nosniff',
'Referrer-Policy': 'no-referrer',
'Content-Security-Policy': "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'",
};
/** Repository keys are bundled; configured empty-primary fallback resolves one snapshot per response. */
export function createHandler(release, {fetchImpl = globalThis.fetch} = {}) {
return async function fetch(request, env) {
const url = new URL(request.url);
const headers = new Headers(securityHeaders);
headers.set('Cache-Control', 'no-store');
headers.set('X-Conf-Sshd-Revision', release.revision);
headers.set('X-Conf-Sshd-Test-Fixture', String(Boolean(release.testFixture)));
if (!['GET', 'HEAD'].includes(request.method)) {
headers.set('Allow', 'GET, HEAD');
return new Response('Method not allowed.\n', {status: 405, headers});
}
const respond = (body, type, status = 200) => {
headers.set('Content-Type', type);
return new Response(request.method === 'HEAD' ? null : body, {status, headers});
};
if (url.pathname === '/script.sh') return respond(release.script, 'text/plain; charset=utf-8');
let snapshot;
if (['/ssh.keys','/release.json','/'].includes(url.pathname)) {
const accept = request.headers.get('Accept') || '';
const legacyScript = url.pathname === '/' && !accept.toLowerCase().includes('text/html') && /(?:curl|libcurl)\//i.test(request.headers.get('User-Agent') || '');
if (!legacyScript) {
try {
snapshot = {...release, ...await resolveKeys(release, fetchImpl)};
headers.set('X-Conf-Sshd-Key-Digest', snapshot.sha256);
headers.set('X-Conf-Sshd-Key-Source', snapshot.source.type);
} catch (error) {
if (error.code !== 'KEY_SOURCES_UNAVAILABLE') throw error;
if (url.pathname !== '/') return respond('No valid public key source is currently available. Existing server keys must be preserved.\n', 'text/plain; charset=utf-8', 503);
snapshot = {...release,keys:[],keyCount:0,sha256:null,source:{type:'unavailable'}};
}
}
}
if (url.pathname === '/ssh.keys') return respond(snapshot.keysText, 'text/plain; charset=utf-8');
if (url.pathname === '/release.json') {
return respond(JSON.stringify({schema:1,revision:snapshot.revision,publishedAt:snapshot.publishedAt,
keyCount:snapshot.keyCount,allowEmpty:snapshot.allowEmpty,sha256:snapshot.sha256,keyRevision:snapshot.sha256,
source:snapshot.source,fetchedAt:snapshot.fetchedAt,testFixture:Boolean(release.testFixture)})+'\n','application/json; charset=utf-8');
}
if (url.pathname === '/') {
headers.set('Vary', 'Accept, User-Agent');
const accept = request.headers.get('Accept') || '';
const agent = request.headers.get('User-Agent') || '';
// Explicit HTML wins; keep historic curl | sh entry point as a compatibility alias.
if (!accept.toLowerCase().includes('text/html') && /(?:curl|libcurl)\//i.test(agent)) {
return respond(release.script, 'text/plain; charset=utf-8');
}
if (!release.pageTemplate) return respond('Homepage unavailable.\n', 'text/plain; charset=utf-8', 503);
return respond(renderPage(release.pageTemplate,snapshot),'text/html; charset=utf-8',snapshot.source.type === 'unavailable' ? 503 : 200);
}
// Only fingerprinted static assets are exposed; API typos never fall back to HTML.
if (/^\/assets\/[a-zA-Z0-9._-]+\.(?:css|js)$/.test(url.pathname)) {
const asset = await env.ASSETS.fetch(request);
if (!asset.ok) return respond('Not found.\n', 'text/plain; charset=utf-8', 404);
for (const [key, value] of asset.headers) headers.set(key, value);
for (const [key, value] of Object.entries(securityHeaders)) headers.set(key, value);
headers.set('Cache-Control', /\.[a-f0-9]{12}\./.test(url.pathname) ? 'public, max-age=31536000, immutable' : 'no-cache');
return new Response(request.method === 'HEAD' ? null : asset.body, {status: 200, headers});
}
return respond('Not found.\n', 'text/plain; charset=utf-8', 404);
};
}
-49
View File
@@ -1,49 +0,0 @@
export const escapeHTML = value => String(value).replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
export function commandsFor(config) {
const fetch = `curl -fsSL '${config.baseUrl}/script.sh' | sh -s --`;
return [
{id:'install',title:'安装并定时同步',description:`更新当前用户的公钥并设置定时同步;保留已有自定义计划,新安装计划为 ${config.defaultCron || '*/15 * * * *'}。`,command:`${fetch} -c`},
{id:'once',title:'仅更新一次公钥',description:'更新为当前发布的公钥,不设置定时任务。',command:`${fetch} -o`},
{id:'help',title:'查看帮助',description:'先了解全部参数与行为,不修改配置。',command:`${fetch} -h`},
{id:'preview',title:'预览公钥更新',description:'校验当前发布并预览将要修改的文件,不应用更新。',command:`${fetch} -o --dry-run`},
{id:'update',title:'单独更新已安装脚本',description:'显式更新客户端脚本;定时公钥同步不会更新可执行代码。',command:'sh "$HOME/.conf-sshd/conf-sshd.sh" -u',advanced:true},
{id:'root-off',title:'禁止 root 使用密码登录',description:'仅 root 可执行。校验并修改 SSH 配置,不自动重载服务。',command:`${fetch} -p no`,advanced:true},
{id:'root-on',title:'允许 root 使用密码登录',description:'仅 root 可执行,涉及登录安全;实际登录仍受其他 SSH 认证设置限制,不自动重载服务。',command:`${fetch} -p yes`,advanced:true},
{id:'uninstall',title:'停止同步并卸载',description:'移除脚本及其定时任务,保留公钥与 SSH 配置。',command:'sh "$HOME/.conf-sshd/conf-sshd.sh" --uninstall',advanced:true},
];
}
function codeBlock(id, text, label) {
return `<div class="code-block"><pre tabindex="0"><code id="${id}">${escapeHTML(text)}</code></pre><button type="button" class="copy-button" data-copy-target="${id}" aria-label="${escapeHTML(label)}" title="${escapeHTML(label)}"></button></div>`;
}
export function renderKeys(keys) {
return keys.length ? keys.map((key,i) => `<article class="key-card"><div class="card-heading"><h3>${escapeHTML(key.label)}</h3><span class="key-type">${escapeHTML(key.type)}</span></div><p class="fingerprint">${escapeHTML(key.fingerprint)}</p>${codeBlock(`key-${i}`,key.text,`复制第 ${i + 1} 个公钥`)}</article>`).join('\n') : '<p class="empty-state">此版本明确撤销全部受管理公钥,手工管理的公钥仍保留。</p>';
}
export function renderCommands(commands) {
const card = command => `<article class="command-card"><div class="card-heading"><h3>${escapeHTML(command.title)}</h3></div><p class="command-description">${escapeHTML(command.description)}</p>${codeBlock(`command-${command.id}`,command.command,`复制“${command.title}”命令`)}</article>`;
return commands.filter(c=>!c.advanced).map(card).join('\n') + '<details class="advanced-commands"><summary>高级命令</summary><div class="command-list">' + commands.filter(c=>c.advanced).map(card).join('\n') + '</div></details>';
}
/** All visible keys/metadata and clipboard data come from one resolved response snapshot. */
export function renderPage(template, snapshot) {
const {revision, publishedAt, keys = [], commands = [], testFixture, repositoryUrl, source = {type:'repository'}} = snapshot;
const unavailable = source.type === 'unavailable' || source.type === 'pending';
const sourceLabel = source.type === 'fallback' ? `备用源:${source.url}` : source.type === 'explicit-empty' ? '仓库清单(明确撤销)' : unavailable ? '等待可用公钥源' : '仓库清单';
const sourceStatus = source.type === 'fallback' ? '备用源可用' : source.type === 'explicit-empty' ? '明确撤销' : unavailable ? '暂不可用' : '可用';
const notice = unavailable ? '公钥源暂不可用,未发布撤销指令;现有服务器保留上次成功密钥。' : source.type === 'fallback' ? '当前使用按配置顺序选中的首个有效备用源;后续请求会重新获取。' : source.type === 'explicit-empty' ? '此版本明确撤销全部受管理公钥;手工公钥与已有会话不受影响。' : '';
const publicData = {schema:1,revision,publishedAt,keyCount:keys.length,sha256:snapshot.sha256 || null,keyRevision:snapshot.sha256 || null,source,sourceStatus,keys,commands,baseUrl:snapshot.baseUrl,repositoryUrl,testFixture:Boolean(testFixture)};
const replacements = {
RELEASE_JSON:JSON.stringify(publicData).replace(/</g,'\\u003c').replace(/\u2028/g,'\\u2028').replace(/\u2029/g,'\\u2029'),
KEYS_HTML:unavailable ? '<p class="empty-state">当前无法获取有效公钥。请稍后重试或检查备用源配置。</p>' : renderKeys(keys),
COMMANDS_HTML:renderCommands(commands),REVISION:escapeHTML(revision.slice(0,12)),PUBLISHED_AT:escapeHTML(publishedAt),KEY_COUNT:String(keys.length),
REPOSITORY_URL:escapeHTML(repositoryUrl),RELEASE_LABEL:testFixture ? '测试预览' : unavailable ? '公钥暂不可用' : '已发布',
PREVIEW_NOTICE:testFixture ? '<p class="preview-notice" role="note">测试预览:这里使用公开测试密钥,不能部署到真实服务器。</p>' : '',
SOURCE_LABEL:escapeHTML(sourceLabel),SOURCE_STATUS:escapeHTML(sourceStatus),CONTENT_REVISION:escapeHTML(snapshot.sha256?.slice(0,12) || '—'),KEYS_NOTICE:escapeHTML(notice),
};
return template.replace(/@@([A-Z_]+)@@/g, (_, token) => {
if (!Object.hasOwn(replacements, token)) throw Error(`Unresolved page template token: ${token}`);
return replacements[token];
});
}
+1 -6
View File
@@ -1,8 +1,3 @@
name = "quickly-conf-ssh-worker" name = "quickly-conf-ssh-worker"
main = "src/index.js" main = "src/index.js"
compatibility_date = "2026-10-01" compatibility_date = "2023-01-26"
[assets]
directory = "./public"
binding = "ASSETS"
run_worker_first = true
+343 -483
View File
@@ -1,507 +1,367 @@
#!/bin/sh #!/bin/sh
# conf-sshd-script-v2 set -e
# Generated configuration. Never source or eval downloaded data.
BASE_URL='@@BASE_URL@@'
DEFAULT_CRON='@@DEFAULT_CRON@@'
set -eu ########## 一些配置 ##########
umask 077
LC_ALL=C
export LC_ALL
BEGIN_KEYS='# --- BEGIN MANAGED BY CONF-SSHD SCRIPT ---'
END_KEYS='# --- END MANAGED BY CONF-SSHD SCRIPT ---'
BEGIN_CRON='# --- BEGIN CRON MANAGED BY CONF-SSHD SCRIPT ---'
END_CRON='# --- END CRON MANAGED BY CONF-SSHD SCRIPT ---'
mode=sync
cron_requested=no
cron_value=
dry_run=no
no_install=no
root_password=
temps=
lock_owned=no
created_state=no
created_ssh=no
say() { printf '%s\n' "$*"; } # 默认获取 SSH key 的地方,一般是 Github.
fail() { printf 'conf-sshd: %s\n' "$*" >&2; exit 1; } sshkey_url="{{ SSH_KEY_URL }}"
usage() { # 默认的 Cron 执行计划, 每天凌晨 0 点执行
cat <<'HELP' default_cron="{{ DEFAULT_CRON }}"
Usage: sh conf-sshd.sh [options] # 脚本 Url
(no options), -o, --only-update-keys Validate and sync managed public keys only script_url="{{ SCRIPT_URL }}"
-c, --cron ["five fields"|false] Sync and schedule; bare -c keeps an existing
schedule, otherwise the configured default.
-c false only removes this script's schedule.
-u, --update-self Explicitly update the installed script only
--uninstall Remove own schedule/script; preserve keys/config
-p, --allow-root-passwd yes|no Explicit root-only sshd_config edit; never reload
-n, --no-install-sshd Compatibility flag; packages are never installed
--dry-run Validate and show changes without applying them
--status Show local installation and schedule status
-h, --help Show this help
-u, --uninstall, -p and --status are exclusive operations. -o cannot use -c or -p. ############ 脚本区 ##########
Requires curl, ssh-keygen, awk, standard POSIX tools, mktemp, stat, and one of
sha256sum, shasum or openssl. Scheduling also requires crontab. Downloads require # 初始化参数变量
HTTPS; existing keys survive any validation/download failure. arg_help="false"
No SSH service is installed, restarted, or reloaded. Keep recovery access ready. arg_cron="false"
The payload SHA-256 detects inconsistency; trust comes from HTTPS and its source. arg_cron_val=""
HELP arg_only_update_keys="false"
} arg_update_self="false"
select_mode() { arg_uninstall="false"
[ "$mode" = sync ] || fail 'Conflicting or duplicate operation options.' arg_no_install_sshd="false"
mode=$1 arg_allow_root_passwd="false"
} arg_allow_root_passwd_val=""
while [ "$#" -gt 0 ]; do
case $1 in # 解析参数
while [ $# -gt 0 ]; do
case "$1" in
-h|--help) -h|--help)
if [ "$#" -ne 1 ] || [ "$mode" != sync ] || [ "$cron_requested" != no ]; then arg_help="true"
fail '--help must be used alone.' shift
fi ;;
usage; exit 0 ;;
-o|--only-update-keys) select_mode keys ;;
-u|--update-self) select_mode update ;;
--uninstall) select_mode uninstall ;;
--status) select_mode status ;;
-p|--allow-root-passwd)
select_mode config
[ "$#" -ge 2 ] || fail '-p requires yes or no.'
shift; case $1 in yes|no) root_password=$1 ;; *) fail '-p requires yes or no.' ;; esac ;;
-c|--cron) -c|--cron)
[ "$cron_requested" = no ] || fail 'Duplicate -c option.' arg_cron="true"
cron_requested=yes arg_cron_val="$2"
if [ "$#" -gt 1 ]; then shift 2
case $2 in -*) : ;; *) shift; cron_value=$1; [ -n "$cron_value" ] || fail 'Empty cron expression.' ;; esac ;;
fi ;; -o|--only-update-keys)
--dry-run) [ "$dry_run" = no ] || fail 'Duplicate --dry-run.'; dry_run=yes ;; arg_only_update_keys="true"
-n|--no-install-sshd) [ "$no_install" = no ] || fail 'Duplicate -n.'; no_install=yes ;; shift
*) fail "Unknown option or unexpected argument: $1" ;; ;;
-u|--update-self)
arg_update_self="true"
shift
;;
--uninstall)
arg_uninstall="true"
shift
;;
-n|--no-install-sshd)
arg_no_install_sshd="true"
shift
;;
-p|--allow-root-passwd)
arg_allow_root_passwd="true"
arg_allow_root_passwd_val="$2"
shift 2
;;
*)
shift
;;
esac esac
shift
done done
[ "$cron_requested" = no ] || [ "$mode" = sync ] || fail '-c cannot be combined with this operation.'
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 if [ "$arg_help" = "true" ]; then
uid=$(id -u) echo "Usage: $0 [options]"
# Reject paths that could alter line-based cron records. Spaces and quotes are safe. echo "Options:"
case ${HOME:-} in /*) : ;; *) fail 'HOME must be an absolute directory.' ;; esac echo " -h, --help Print this help message."
[ "$(printf '%s' "$HOME" | tr -d '\040-\176\200-\377'; printf .)" = . ] || fail 'HOME must not contain control characters.' echo ""
case $HOME in *\\*) fail 'HOME paths containing backslashes are not supported.' ;; esac echo "Available to any user: "
HOME=${HOME%/} echo " -c, --cron \"<cron> | false\" Configure Crontab to automatically update ssh keys."
[ -n "$HOME" ] || fail 'HOME must not be the filesystem root.' echo " Cron expression MUST be quoted (e.g., \"* 0 * * *\")."
state=$HOME/.conf-sshd echo " If 'false' is specified, Crontab settings will be deleted."
ssh_dir=$HOME/.ssh echo ""
auth_file=$ssh_dir/authorized_keys echo " -o, --only-update-keys Only update SSH keys, do not configure ssh server."
target_script=$state/conf-sshd.sh echo " -u, --update-self Update this script to the latest version."
echo " --uninstall Uninstall this script (remove cron and local files)."
# GNU/Linux and BSD/macOS stat. No filesystem changes precede ownership checks. echo ""
if stat -c '%u %a %h' "$HOME" >/dev/null 2>&1; then echo "only available when the script is executed as root:"
stat_info() { stat -c '%u %a %h' "$1"; } echo " -n, --no-install-sshd Do not install SSH Server."
else echo " -p, --allow-root-passwd <yes | no> Allow Root to log in with a password."
stat_info() { stat -f '%u %Lp %l' "$1"; } echo ""
fi
check_path() {
checked=$1
[ ! -L "$checked" ] || fail "Refusing symlink: $checked"
if [ -e "$checked" ]; then
if [ "$2" = dir ]; then [ -d "$checked" ] || fail "Not a directory: $checked"
else [ -f "$checked" ] || fail "Not a regular file: $checked"; fi
info=$(stat_info "$checked") || fail "Cannot inspect: $checked"
# stat produces three numeric fields.
# shellcheck disable=SC2086
set -- $info
[ "$1" = "$uid" ] || fail "Not owned by current user: $checked"
case $2 in *[!0-7]*|'') fail "Cannot inspect permissions: $checked" ;; esac
# Group/world writable paths allow races before an atomic replacement.
[ "$((0$2 & 0022))" -eq 0 ] || fail "Group/world writable path: $checked"
if [ -f "$checked" ]; then [ "$3" = 1 ] || fail "Refusing multiply linked file: $checked"; fi
fi
}
check_path "$HOME" dir
[ -d "$HOME" ] || fail 'HOME does not exist.'
check_path "$state" dir
check_path "$ssh_dir" dir
check_path "$auth_file" file
check_path "$target_script" file
validate_cron() {
printf '%s\n' "$1" | awk '
function number(s) { return s ~ /^[0-9]+$/ }
function field(s,lo,hi, n,a,i,p,b,r,k,step) {
if(s ~ /[^0-9*\/,\-]/ || s == "") return 0
n=split(s,a,",")
for(i=1;i<=n;i++) {
p=split(a[i],b,"/"); if(p>2 || b[1]=="") return 0
if(p==2 && (!number(b[2]) || b[2]+0<1 || b[2]+0>hi+1)) return 0
if(b[1]=="*") continue
r=split(b[1],k,"-"); if(r>2 || !number(k[1])) return 0
if(k[1]+0<lo || k[1]+0>hi) return 0
if(r==2 && (!number(k[2]) || k[2]+0<k[1]+0 || k[2]+0>hi)) return 0
}
return 1
}
NR!=1 || NF!=5 { exit 1 }
{ if(!field($1,0,59)||!field($2,0,23)||!field($3,1,31)||!field($4,1,12)||!field($5,0,7)) exit 1
print $1 " " $2 " " $3 " " $4 " " $5 }
END { if(NR!=1) exit 1 }'
}
if [ "$cron_requested" = yes ] && [ -n "$cron_value" ] && [ "$cron_value" != false ]; then
cron_value=$(validate_cron "$cron_value") || fail 'Invalid cron expression; use five quoted numeric fields.'
fi
cleanup() {
result=$?
trap - 0 HUP INT TERM
old_ifs=$IFS; IFS='
'
for path in $temps; do rm -f "$path"; done
IFS=$old_ifs
if [ "$lock_owned" = yes ]; then rm -f "$state/lock/pid"; rmdir "$state/lock" 2>/dev/null || :; fi
if [ "$dry_run" = yes ] || [ "$mode" = uninstall ]; then
if [ "$created_ssh" = yes ]; then rmdir "$ssh_dir" 2>/dev/null || :; fi
if [ "$created_state" = yes ] || [ "$mode" = uninstall ]; then rmdir "$state" 2>/dev/null || :; fi
fi
exit "$result"
}
new_temp() {
TEMP=$(mktemp "$1/.conf-sshd.XXXXXX") || fail 'Cannot create secure temporary file.'
temps="$temps
$TEMP"
chmod 600 "$TEMP"
}
last_lf() { [ "$(tail -c 1 "$1" | od -An -tu1 | tr -d ' \n')" = 10 ]; }
quote_sh() { printf "'"; printf '%s' "$1" | sed "s/'/'\\\\''/g"; printf "'"; }
cron_command="/bin/sh $(quote_sh "$target_script") -o"
legacy_command="\"/bin/bash $target_script -o\" >> $state/run.log 2>&1"
if [ "$mode" = status ]; then
[ "$dry_run" = no ] || fail '--status does not use --dry-run.'
if [ -f "$target_script" ]; then say "Installed script: $target_script"; else say 'Script is not installed for scheduled use.'; fi
if [ -f "$auth_file" ]; then say "Authorized keys: $auth_file (not modified)"; else say 'No authorized_keys file.'; fi
if command -v crontab >/dev/null 2>&1; then
if status_cron=$(crontab -l 2>&1); then
say 'Current crontab (read-only):'; say "$status_cron"
else
case $status_cron in 'no crontab for '*) say 'No crontab is installed for this user.' ;;
*) fail "Cannot read current crontab: $status_cron" ;; esac
fi
else say 'crontab is not installed.'; fi
exit 0 exit 0
fi fi
[ -d "$state" ] || { mkdir "$state"; created_state=yes; } reload_sshd_service() {
if [ "$dry_run" = no ]; then chmod 700 "$state"; fi if command -v systemctl >/dev/null 2>&1; then
trap cleanup 0 systemctl reload sshd
trap 'exit 129' HUP elif command -v service >/dev/null 2>&1; then
trap 'exit 130' INT service sshd reload || service ssh reload
trap 'exit 143' TERM elif [ -f /etc/init.d/sshd ]; then
if mkdir "$state/lock" 2>/dev/null; then /etc/init.d/sshd reload
lock_owned=yes; printf '%s\n' "$$" > "$state/lock/pid" elif [ -f /etc/init.d/ssh ]; then
else /etc/init.d/ssh reload
fail "Another run holds $state/lock. If it is stale, verify the recorded process before removing that directory." else
return 1
fi
}
update_sshkeys() {
case "$sshkey_url" in
""|"{{"*)
echo "ERROR: sshkey_url is not configured."
exit 1
;;
esac
echo "Downloading SSH public key from '$sshkey_url'"
mkdir -p ~/.ssh
chmod 700 ~/.ssh
dl_tmp_file="$HOME/.ssh/authorized_keys.dl.tmp"
if ! curl -sL "$sshkey_url" -o "$dl_tmp_file"; then
echo "Failed to download SSH public key at $(date '+%Y-%m-%d %H:%M:%S')"
rm -f "$dl_tmp_file"
exit 1
fi
if [ ! -s "$dl_tmp_file" ] || ! grep -qE "(ssh-rsa|ssh-ed25519|ecdsa-sha2-nistp)" "$dl_tmp_file"; then
echo "Downloaded file is empty or does not contain valid SSH key types at $(date '+%Y-m-d %H:%M:%S')"
echo "Aborting update to prevent lockout."
rm -f "$dl_tmp_file"
exit 1
fi
echo "-------------------- SSH Keys --------------------"
cat "$dl_tmp_file"
echo "--------------------------------------------------"
auth_file="$HOME/.ssh/authorized_keys"
new_auth_file="$HOME/.ssh/authorized_keys.new.tmp"
# 受管理文本块标记
begin_marker="# --- BEGIN MANAGED BY CONF-SSHD SCRIPT ---"
end_marker="# --- END MANAGED BY CONF-SSHD SCRIPT ---"
managed_block_found="false"
inside_managed_block="false"
touch "$auth_file"
: > "$new_auth_file"
while IFS= read -r line || [ -n "$line" ]; do
if [ "$line" = "$begin_marker" ]; then
managed_block_found="true"
inside_managed_block="true"
{
echo "$begin_marker"
cat "$dl_tmp_file"
echo "$end_marker"
} >> "$new_auth_file"
elif [ "$line" = "$end_marker" ]; then
inside_managed_block="false"
elif [ "$inside_managed_block" = "false" ]; then
echo "$line" >> "$new_auth_file"
fi
done < "$auth_file"
if [ "$managed_block_found" = "false" ]; then
if [ -s "$new_auth_file" ]; then
if [ "$(tail -c 1 "$new_auth_file")" != "" ]; then
# 最后一个字符不是换行符,添加一个
echo "" >> "$new_auth_file"
fi
fi
{
echo "$begin_marker"
cat "$dl_tmp_file"
echo "$end_marker"
} >> "$new_auth_file"
fi
mv "$new_auth_file" "$auth_file"
rm -f "$dl_tmp_file"
chmod 600 "$auth_file"
echo "SSH public key updated successfully (managed block only) at $(date '+%Y-%m-%d %H:%M:%S')"
}
# 检查是否指定了 --uninstall
if [ "$arg_uninstall" = "true" ]; then
echo "Uninstalling conf-sshd (disabling auto-updates)..."
if [ "$(command -v crontab)" != "" ]; then
echo "Removing Crontab entry..."
( (crontab -l 2>/dev/null || true) | grep -F -v "conf-sshd.sh") | crontab -
else
echo "Crontab utility not found, skipping Crontab removal."
fi
script_dir="$HOME/.conf-sshd"
if [ -d "$script_dir" ]; then
echo "Removing script files from $script_dir..."
rm -rf "$script_dir"
else
echo "Script directory $script_dir not found, skipping removal."
fi
echo "Uninstall complete."
echo "Note: authorized_keys managed block and sshd_config were NOT affected."
exit 0
fi fi
# Inputs are origin-only build constants; also reject unbuilt or corrupted scripts. # 检查是否只更新密钥.
validate_build() { if [ "$arg_only_update_keys" = "true" ]; then
printf '%s\n' "$BASE_URL" | grep -Eq '^https://([A-Za-z0-9]([A-Za-z0-9.-]*[A-Za-z0-9])?|\[[0-9A-Fa-f:]+\])(:[0-9]{1,5})?$' || fail 'BASE_URL must be a configured HTTPS origin.' update_sshkeys
validate_cron "$DEFAULT_CRON" >/dev/null || fail 'Invalid or unbuilt default cron configuration.' exit 0
} fi
download() {
need curl # 检查是否指定了 --update-self
# No redirects: a configured origin must serve these endpoints directly. if [ "$arg_update_self" = "true" ]; then
curl --fail --silent --show-error --proto '=https' --tlsv1.2 \ echo "Updating conf-sshd script..."
--connect-timeout 10 --max-time 30 --retry 2 --retry-delay 1 \ mkdir -p ~/.conf-sshd
--retry-max-time 65 --max-filesize 1048576 --output "$2" "$1" || fail "Download failed: $1 (existing files unchanged)." target_script="$HOME/.conf-sshd/conf-sshd.sh"
[ "$(wc -c < "$2" | tr -d ' ')" -le 1048576 ] || fail 'Downloaded response is too large.'
} if [ -f "$target_script" ]; then
sha256() { cp "$target_script" "$target_script.bak"
if command -v sha256sum >/dev/null 2>&1; then sha256sum "$1" | awk '{print $1}' fi
elif command -v shasum >/dev/null 2>&1; then shasum -a 256 "$1" | awk '{print $1}'
elif command -v openssl >/dev/null 2>&1; then openssl dgst -sha256 "$1" | awk '{print $NF}' # 下载到临时文件
else fail 'Install sha256sum, shasum, or openssl before syncing keys.'; fi if ! curl -sL "$script_url" -o "$target_script.tmp"; then
} echo "Script download failed at $(date '+%Y-%m-%d %H:%M:%S')"
check_expected_file() { rm -f "$target_script.tmp"
if [ "$2" = absent ]; then exit 1
if [ -e "$1" ] || [ -L "$1" ]; then fi
fail "File appeared concurrently; retry after reviewing: $1"
mv "$target_script.tmp" "$target_script"
chmod +x "$target_script"
echo "Script updated successfully at $(date '+%Y-%m-%d %H:%M:%S')"
exit 0
fi
# 检查 SSHD 是否安装.
if ! command -v sshd >/dev/null 2>&1 && [ ! -x /usr/sbin/sshd ] && [ "$arg_no_install_sshd" = "false" ]; then
if [ "$(id -u)" -eq 0 ]; then
echo "The ssh server is not installed, and the script is executed as root, so it will be installed."
if [ -f /etc/redhat-release ]; then
yum install -y openssh-server
elif [ -f /etc/debian_version ]; then
apt-get update
apt-get install -y openssh-server
elif [ -f /etc/alpine-release ]; then
apk add openssh
fi fi
echo "The ssh server has been installed."
else else
if [ ! -f "$1" ] || ! cmp -s "$1" "$2"; then echo "The ssh server is not installed, but the script is executed as a non-root user and cannot be installed."
fail "File changed concurrently; retry after reviewing: $1" exit 1
fi
fi fi
} else
replace_file() { echo "The ssh server is already installed."
destination=$1; staged=$2; file_mode=$3 fi
check_path "$destination" file
check_path "$destination.conf-sshd.bak" file
if [ "$#" -ge 4 ]; then check_expected_file "$destination" "$4"; fi
chmod "$file_mode" "$staged"
if [ "$dry_run" = yes ]; then say "Would atomically replace $destination (mode $file_mode)."; return; fi
if [ -f "$destination" ]; then
new_temp "${destination%/*}"; backup_tmp=$TEMP
cp "$destination" "$backup_tmp"; chmod 600 "$backup_tmp"
if [ "$#" -ge 4 ]; then check_expected_file "$destination" "$4"; fi
mv -f "$backup_tmp" "$destination.conf-sshd.bak"
fi
if [ "$#" -ge 4 ]; then check_expected_file "$destination" "$4"; fi
mv -f "$staged" "$destination"
}
validate_script() {
sh -n "$1" || fail 'Downloaded script has invalid shell syntax.'
[ "$(grep -Fxc '# conf-sshd-script-v2' "$1" || :)" = 1 ] || fail 'Downloaded script is missing its unique version marker.'
[ "$(grep -Fxc "BASE_URL='$BASE_URL'" "$1" || :)" = 1 ] || fail 'Downloaded script changed its configured origin.'
[ "$(grep -Fxc "DEFAULT_CRON='$DEFAULT_CRON'" "$1" || :)" = 1 ] || fail 'Downloaded script has invalid default cron configuration.'
if grep -Eq '@@[A-Z_]+@@' "$1"; then fail 'Downloaded script contains unexpanded build tokens.'; fi
}
install_script() {
validate_build
new_temp "$state"; script_tmp=$TEMP
# In a piped invocation $0 can name an unrelated cwd file. Always fetch the
# configured origin, rather than guessing whether $0 is this script.
download "$BASE_URL/script.sh" "$script_tmp"
validate_script "$script_tmp"
replace_file "$target_script" "$script_tmp" 700
}
# Rewrite only an exact paired managed block or the exact known legacy command. # 检查是否指定了 --allow-root-passwd
# Unexpected cron errors and ambiguous/malformed managed entries fail closed. if [ "$arg_allow_root_passwd" = "true" ]; then
prepare_cron() { # 检查当前用户是否为 root
need crontab if [ "$(id -u)" -eq 0 ]; then
case $HOME in *%*) fail 'Cron does not safely support a percent sign in HOME.' ;; esac allow_root_passwd=$(echo "$arg_allow_root_passwd_val" | tr '[:upper:]' '[:lower:]')
new_temp "$state"; old_cron=$TEMP
new_temp "$state"; cron_errors=$TEMP sshd_config_file="/etc/ssh/sshd_config"
if crontab -l > "$old_cron" 2> "$cron_errors"; then : new_sshd_permit_root_login_setting=""
elif grep -Eq '^no crontab for [^[:cntrl:]]+$' "$cron_errors" && [ ! -s "$old_cron" ]; then :
else fail 'Cannot read current crontab; refusing to replace it.'; fi
new_temp "$state"; clean_cron=$TEMP
new_temp "$state"; saved_schedule=$TEMP
if last_lf "$old_cron"; then cron_lastlf=1; else cron_lastlf=0; fi
CONF_CRON_COMMAND=$cron_command CONF_LEGACY_COMMAND=$legacy_command \
awk -v begin="$BEGIN_CRON" -v end="$END_CRON" \
-v saved="$saved_schedule" -v lastlf="$cron_lastlf" '
BEGIN { command=ENVIRON["CONF_CRON_COMMAND"]; legacy=ENVIRON["CONF_LEGACY_COMMAND"] }
function extract(line, rest,i,t,s) {
rest=line; s=""
for(i=1;i<=5;i++) {
sub(/^[ \t]+/,"",rest)
if(!match(rest,/^[^ \t]+/)) return ""
t=substr(rest,1,RLENGTH); rest=substr(rest,RLENGTH+1)
s=s (i==1 ? "" : " ") t
}
sub(/^[ \t]+/,"",rest)
return rest==command || rest==legacy ? s : ""
}
{ lines[NR]=$0
if($0==begin) { if(block || began++) bad=1; block=1; drop[NR]=1; next }
if($0==end) { if(!block || ended++) bad=1; block=0; drop[NR]=1; next }
if($0 ~ /^[ \t]*#[ \t-]*(BEGIN|END)[ \t]+CRON MANAGED BY CONF-SSHD SCRIPT/) { bad=1; next }
schedule=extract($0)
if(block && schedule=="") bad=1
if(schedule!="") { if(found++) bad=1; print schedule > saved; drop[NR]=1 }
}
END {
if(block || began!=ended || (began && !found) || bad) exit 1
for(i=1;i<=NR;i++) if(!drop[i]) { printf "%s",lines[i]; if(i<NR || lastlf) printf "\n" }
}' "$old_cron" > "$clean_cron" || fail 'Malformed, ambiguous, or unfamiliar managed cron entry; left unchanged.'
existing_schedule=$(cat "$saved_schedule")
if [ -n "$existing_schedule" ]; then
existing_schedule=$(validate_cron "$existing_schedule") || fail 'Existing managed cron expression is invalid.'
fi
}
apply_cron() {
if [ "$1" != false ]; then
if [ -s "$clean_cron" ] && ! last_lf "$clean_cron"; then printf '\n' >> "$clean_cron"; fi
printf '%s\n%s %s\n%s\n' "$BEGIN_CRON" "$1" "$cron_command" "$END_CRON" >> "$clean_cron"
fi
if cmp -s "$old_cron" "$clean_cron"; then say 'Managed cron is unchanged.'; return; fi
if [ "$dry_run" = yes ]; then say "Would set managed cron: $1"; return; fi
# Detect an independent crontab editor rather than overwrite concurrent edits.
new_temp "$state"; check_cron=$TEMP
if crontab -l > "$check_cron" 2> "$cron_errors"; then :
elif grep -Eq '^no crontab for [^[:cntrl:]]+$' "$cron_errors" && [ ! -s "$check_cron" ]; then :
else fail 'Cannot recheck current crontab.'; fi
cmp -s "$old_cron" "$check_cron" || fail 'Crontab changed concurrently; retry after reviewing it.'
crontab "$clean_cron" || fail 'Could not install crontab.'
say "Managed cron set: $1"
}
sync_keys() { if [ "$allow_root_passwd" = "yes" ]; then
need ssh-keygen new_sshd_permit_root_login_setting="PermitRootLogin yes"
validate_build echo "Setting: Root user is allowed to log in with password."
new_temp "$state"; response=$TEMP elif [ "$allow_root_passwd" = "no" ]; then
download "$BASE_URL/ssh.keys" "$response" new_sshd_permit_root_login_setting="PermitRootLogin prohibit-password"
last_lf "$response" || fail 'Key response must end with LF.' echo "Setting: Root user is prohibited from logging in with password."
new_temp "$state"; invalid_bytes=$TEMP
tr -d '\012\040-\176\200-\377' < "$response" > "$invalid_bytes"
[ ! -s "$invalid_bytes" ] || fail 'Key response contains forbidden control bytes.'
new_temp "$state"; metadata=$TEMP
awk '
NR==1 { if($0!="# conf-sshd-v1") exit 1 }
NR==2 { if(substr($0,1,11)!="# revision " || length($0)!=51 || substr($0,12) ~ /[^0-9a-fA-F]/) exit 1; print substr($0,12) }
NR==3 { if($0 !~ /^# key-count (0|[1-9][0-9]*)$/) exit 1; n=substr($0,13); if(n+0>10000) exit 1; print n }
NR==4 { if($0!="# allow-empty yes" && $0!="# allow-empty no") exit 1; print substr($0,15) }
NR==5 { if(substr($0,1,9)!="# sha256 " || length($0)!=73 || substr($0,10) ~ /[^0-9a-f]/) exit 1; print substr($0,10) }
NR==6 { if($0!="# end-metadata") exit 1 }
END { if(NR<6) exit 1 }' "$response" > "$metadata" || fail 'Invalid key-response metadata.'
revision=$(sed -n '1p' "$metadata")
key_count=$(sed -n '2p' "$metadata")
allow_empty=$(sed -n '3p' "$metadata")
expected_hash=$(sed -n '4p' "$metadata")
new_temp "$state"; payload=$TEMP
tail -n +7 "$response" > "$payload"
[ "$(sha256 "$payload")" = "$expected_hash" ] || fail 'Payload SHA-256 mismatch.'
[ "$(wc -l < "$payload" | tr -d ' ')" = "$key_count" ] || fail 'Payload key count mismatch.'
if [ "$key_count" = 0 ]; then [ "$allow_empty" = yes ] || fail 'Empty key set was not explicitly allowed.'; fi
awk '
{
if($0 ~ /[ \t]$/ || $0 !~ /^[^ ]+ [^ ]+( [^ ].*)?$/ || NF<2) exit 1
if($1!="ssh-ed25519" && $1!="ssh-rsa" && $1!="ecdsa-sha2-nistp256" && $1!="ecdsa-sha2-nistp384" && $1!="ecdsa-sha2-nistp521" && $1!="[email protected]" && $1!="[email protected]") exit 1
if($2 !~ /^[A-Za-z0-9+\/]+={0,2}$/) {
# POSIX awk implementations without interval-expression support.
blob=$2; sub(/==?$/,"",blob); if(blob=="" || blob ~ /[^A-Za-z0-9+\/]/) exit 1
}
if(length($2)%4!=0 || seen[$1 " " $2]++) exit 1
if($2 ~ /==$/ && substr($2,length($2)-2,1) !~ /^[AQgw]$/) exit 1
if($2 ~ /[^=]=$/ && substr($2,length($2)-1,1) !~ /^[AEIMQUYcgkosw048]$/) exit 1
}' "$payload" || fail 'Invalid, duplicate, unsupported, or noncanonical public key line.'
new_temp "$state"; one_key=$TEMP
new_temp "$state"; key_info=$TEMP
while IFS= read -r key_line; do
printf '%s\n' "$key_line" > "$one_key"
ssh-keygen -l -f "$one_key" > "$key_info" 2>/dev/null || fail 'ssh-keygen rejected a public key.'
case $key_line in ssh-rsa\ *)
bits=$(awk 'NR==1 {print $1}' "$key_info")
case $bits in ''|*[!0-9]*) fail 'Cannot determine RSA key size.' ;; esac
[ "$bits" -ge 2048 ] || fail 'RSA keys must be at least 2048 bits.' ;;
esac
done < "$payload"
[ -d "$ssh_dir" ] || { mkdir "$ssh_dir"; created_ssh=yes; }
# Existing secure directory permissions need not be changed in dry-run mode.
if [ "$dry_run" = no ]; then chmod 700 "$ssh_dir"; fi
check_path "$auth_file" file
new_temp "$ssh_dir"; new_auth=$TEMP
auth_snapshot=absent
if [ -f "$auth_file" ]; then
new_temp "$state"; auth_snapshot=$TEMP
cp "$auth_file" "$auth_snapshot"
new_temp "$state"; bounds=$TEMP
bytes=$(wc -c < "$auth_snapshot" | tr -d ' ')
awk -v begin="$BEGIN_KEYS" -v end="$END_KEYS" -v size="$bytes" '
{ start=offset+0; offset+=length($0)+1
if($0==begin) { if(inblock || began++) bad=1; inblock=1; first=start }
else if($0==end) { if(!inblock || ended++) bad=1; inblock=0; last=offset }
else if($0 ~ /^[ \t]*#[ \t-]*(BEGIN|END)[ \t]+MANAGED BY CONF-SSHD SCRIPT/) bad=1
}
END { if(inblock || began!=ended || bad) exit 1
if(began) print first " " (last>size ? size : last); else print "absent" }
' "$auth_snapshot" > "$bounds" || fail 'Malformed or duplicate authorized_keys managed markers; left unchanged.'
positions=$(cat "$bounds")
if [ "$positions" = absent ]; then
cat "$auth_snapshot" > "$new_auth"
if [ -s "$new_auth" ] && ! last_lf "$new_auth"; then printf '\n' >> "$new_auth"; fi
{
printf '%s\n' "$BEGIN_KEYS"
cat "$payload"
printf '%s\n' "$END_KEYS"
} >> "$new_auth"
else else
# Validated awk output: two decimal offsets. echo "Please specify 'yes' or 'no' for --allow-root-passwd."
# shellcheck disable=SC2086 exit 1
set -- $positions fi
dd if="$auth_snapshot" bs=1 count="$1" 2>/dev/null > "$new_auth"
{ if grep -qE '^#?PermitRootLogin' "$sshd_config_file"; then
printf '%s\n' "$BEGIN_KEYS" sed "s@^#\?PermitRootLogin.*@$new_sshd_permit_root_login_setting@g" "$sshd_config_file" > "$sshd_config_file.tmp" && mv "$sshd_config_file.tmp" "$sshd_config_file"
cat "$payload" else
printf '%s\n' "$END_KEYS" echo "$new_sshd_permit_root_login_setting" >> "$sshd_config_file"
dd if="$auth_snapshot" bs=1 skip="$2" 2>/dev/null fi
} >> "$new_auth"
if ! reload_sshd_service; then
echo "----------------------------------------------------"
echo "WARNING: Failed to reload sshd service!"
echo "Please check service status manually."
echo "----------------------------------------------------"
else
echo "SSHD config updated. Now you can use password to login root user."
fi
else
echo "The script is executed as a non-root user and cannot set whether to allow root to log in with a password."
exit 1
fi
fi
# 更新密钥.
update_sshkeys
# 检查是否指定了 --cron
if [ "$arg_cron" = "true" ]; then
# 检查 Crontab 是否已安装
if [ "$(command -v crontab)" = "" ]; then
if [ "$(id -u)" -eq 0 ]; then
echo "The crontab is not installed, and the script is executed as a root user, so it will be installed."
if [ -f /etc/redhat-release ]; then
yum install -y crontabs
elif [ -f /etc/debian_version ]; then
apt-get update
apt-get install -y cron
elif [ -f /etc/alpine-release ]; then
apk add cron
fi
echo "The crontab has been installed."
else
echo "The crontab is not installed, but the script is executed as a non-root user and cannot be installed."
exit 1
fi fi
else else
{ echo "The crontab is already installed."
printf '%s\n' "$BEGIN_KEYS"
cat "$payload"
printf '%s\n' "$END_KEYS"
} > "$new_auth"
fi fi
check_path "$auth_file" file cron=$(echo "$arg_cron_val" | tr '[:upper:]' '[:lower:]')
check_expected_file "$auth_file" "$auth_snapshot" if [ "$cron" = "false" ]; then
if [ "$auth_snapshot" != absent ] && cmp -s "$auth_snapshot" "$new_auth"; then # 检查 Crontab 是否已经设置
if [ "$dry_run" = no ]; then chmod 600 "$auth_file"; fi if [ "$( (crontab -l 2>/dev/null || true) | grep -F -c "conf-sshd.sh" )" -eq 0 ]; then
say "Keys unchanged ($key_count keys, revision $revision)." echo "Crontab already clean. Will not be configured."
else exit 0
replace_file "$auth_file" "$new_auth" 600 "$auth_snapshot" else
say "Validated $key_count keys at revision $revision." (crontab -l 2>/dev/null || true) | grep -F -v "conf-sshd.sh" | crontab -
fi echo "Crontab has been removed."
} exit 0
edit_sshd_config() {
[ "$uid" = 0 ] || fail '-p requires root.'
need sshd
config=/etc/ssh/sshd_config
check_path /etc/ssh dir
check_path "$config" file
[ -f "$config" ] || fail 'sshd_config does not exist; install/configure sshd yourself first.'
new_temp /etc/ssh; config_snapshot=$TEMP
cp "$config" "$config_snapshot"
# Editing a global directive cannot reliably predict Include/Match overrides.
if awk '
{ name=$0; sub(/^[[:space:]]*/,"",name)
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.'
new_temp /etc/ssh; config_tmp=$TEMP
if [ "$root_password" = yes ]; then setting=yes; else setting=prohibit-password; fi
awk -v value="$setting" '
BEGIN { print "PermitRootLogin " value }
{ name=$0; sub(/^[[:space:]]*/,"",name)
if(substr(name,1,1)=="\"") { name=substr(name,2); sub(/\".*$/,"",name) }
else sub(/[[:space:]=].*$/,"",name)
if(tolower(name)!="permitrootlogin") print
}
' "$config_snapshot" > "$config_tmp"
sshd -t -f "$config_tmp" || fail 'Staged sshd_config failed validation.'
new_temp "$state"; effective=$TEMP
sshd -T -f "$config_tmp" > "$effective" || fail 'Cannot evaluate staged sshd_config.'
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
replace_file "$config" "$config_tmp" 600 "$config_snapshot"
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.'
}
case $mode in
update)
validate_build
[ -f "$target_script" ] || fail 'No installed script. Install scheduling with -c first.'
new_temp "$state"; updated_script=$TEMP
download "$BASE_URL/script.sh" "$updated_script"
validate_script "$updated_script"
replace_file "$target_script" "$updated_script" 700
say 'Script update validated. Keys, cron and sshd configuration were not changed.' ;;
uninstall)
if command -v crontab >/dev/null 2>&1; then prepare_cron; apply_cron false; fi
for uninstall_file in "$target_script" "$target_script.conf-sshd.bak"; do
check_path "$uninstall_file" file
if [ "$dry_run" = yes ]; then say "Would remove $uninstall_file"; else rm -f "$uninstall_file"; fi
done
say 'Uninstalled managed scheduling/script. Authorized keys and sshd configuration are preserved.' ;;
config) edit_sshd_config ;;
sync|keys)
if [ "$cron_requested" = yes ]; then
prepare_cron
if [ "$cron_value" = false ]; then apply_cron false; exit 0; fi
validate_build
if [ -z "$cron_value" ]; then cron_value=${existing_schedule:-$DEFAULT_CRON}; fi
fi fi
sync_keys else
if [ "$cron_requested" = yes ]; then install_script; apply_cron "$cron_value"; fi ;; if [ "$cron" = "" ]; then
esac cron=$default_cron
fi
# 将当前脚本移动到 ~/.conf-sshd/conf-sshd.sh 中.
mkdir -p ~/.conf-sshd
target_script="$HOME/.conf-sshd/conf-sshd.sh"
if [ ! -f "$0" ]; then
echo "Downloading conf-sshd script..."
if ! curl -sL "$script_url" -o "$target_script.tmp"; then
echo "Script download failed at $(date '+%Y-m-d %H:%M:%S')"
rm -f "$target_script.tmp"
exit 1
fi
mv "$target_script.tmp" "$target_script"
else
echo "Copying conf-sshd script..."
cp "$0" "$target_script"
fi
chmod +x "$target_script"
echo "Install conf-sshd script successfully."
# 将当前脚本追加到当前用户的 Crontab 中
echo "Configuring Crontab..."
cron_command="/bin/sh $target_script -o >> $HOME/.conf-sshd/run.log 2>&1"
cron_job="$cron $cron_command"
( (crontab -l 2>/dev/null || true) | grep -F -v "conf-sshd.sh") | { cat; echo "$cron_job"; } | crontab -
echo "Crontab has been configured.(Cron: '$cron')"
fi
fi
-7
View File
@@ -1,7 +0,0 @@
{
"baseUrl": "https://ssh.lamgc.me",
"repositoryUrl": "https://github.com/LamGC/quickly-conf-sshd",
"defaultCron": "*/15 * * * *",
"allowEmpty": false,
"fallbackSources": []
}
-3
View File
@@ -1,3 +0,0 @@
# Add independently managed SERVER public keys here, one ordinary OpenSSH key per line.
# Do not copy private keys or automatically import GitHub account authentication keys.
# Empty production releases are rejected unless deployment.json explicitly sets allowEmpty=true.
-1561
View File
File diff suppressed because it is too large Load Diff
-19
View File
@@ -1,19 +0,0 @@
{
"name": "quickly-conf-sshd",
"version": "2.0.0",
"private": true,
"type": "module",
"engines": {
"node": ">=22"
},
"scripts": {
"test": "node --test tests/*.test.mjs",
"build": "node scripts/build.mjs",
"build:test": "node scripts/build.mjs --test-fixture",
"check": "npm test && npm run build:test"
},
"devDependencies": {
"playwright-core": "1.58.2",
"wrangler": "4.149.0"
}
}
-108
View File
@@ -1,108 +0,0 @@
import { readFile, writeFile, mkdir, rm, mkdtemp } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { resolve, dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { createHash } from 'node:crypto';
import { execFileSync } from 'node:child_process';
export const root = resolve(dirname(fileURLToPath(import.meta.url)), '..');
export const digest = data => createHash('sha256').update(data).digest('hex');
import { escapeHTML, commandsFor, renderKeys, renderCommands, renderPage } from '../cf-worker/src/view.js';
export { escapeHTML, commandsFor, renderKeys, renderCommands };
import { validateFallbackSources, parsePublicKeys } from '../cf-worker/src/key-source.js';
export function validateConfig(config) {
if (!config || typeof config !== 'object' || Array.isArray(config)) throw Error('Deployment configuration must be an object.');
for (const key of Object.keys(config)) if (!['baseUrl','repositoryUrl','defaultCron','allowEmpty','fallbackSources'].includes(key)) throw Error(`Unknown configuration: ${key}`);
if (typeof config.baseUrl !== 'string' || !/^https:\/\/[a-zA-Z0-9.-]+(?::[0-9]+)?$/.test(config.baseUrl)) throw Error('baseUrl must be an HTTPS origin without paths, credentials, or shell metacharacters.');
const url = new URL(config.baseUrl);
if (url.origin !== config.baseUrl || !url.hostname.includes('.') || url.hostname.includes('..')) throw Error('baseUrl must be a canonical HTTPS origin.');
if (typeof config.repositoryUrl !== 'string' || !/^https:\/\/github\.com\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(config.repositoryUrl)) throw Error('repositoryUrl must identify a GitHub repository.');
if (typeof config.allowEmpty !== 'boolean') throw Error('allowEmpty must be an explicit boolean.');
validateFallbackSources(Object.hasOwn(config, 'fallbackSources') ? config.fallbackSources : []);
const cron = config.defaultCron;
if (typeof cron !== 'string' || cron.trim() !== cron || !/^[0-9*,/ -]+$/.test(cron) || cron.split(/ +/).length !== 5) throw Error('defaultCron must contain five numeric cron fields.');
const bounds = [[0,59],[0,23],[1,31],[1,12],[0,7]];
cron.split(/ +/).forEach((field, i) => {
for (const part of field.split(',')) {
const match = /^(\*|\d+(?:-\d+)?)(?:\/(\d+))?$/.exec(part);
if (!match || (match[2] && (+match[2] < 1 || +match[2] > bounds[i][1] + 1))) throw Error('Invalid cron expression.');
if (match[1] !== '*') {
const [a,b = a] = match[1].split('-').map(Number);
if (a < bounds[i][0] || b > bounds[i][1] || a > b) throw Error('Cron value outside its field range.');
}
}
});
return config;
}
export async function parseKeys(input) {
// Use the exact runtime parser, then additionally verify against the build's OpenSSH tools.
const keys = await parsePublicKeys(input);
const directory = await mkdtemp(join(tmpdir(), 'conf-sshd-key-check-'));
try {
for (const key of keys) {
const file = join(directory, 'key.pub');
await writeFile(file, key.text + '\n', {mode:0o600});
try { execFileSync('ssh-keygen',['-l','-E','sha256','-f',file],{stdio:['ignore','pipe','pipe']}); }
catch { throw Error('ssh-keygen rejected a public key (OpenSSH client tools are required).'); }
}
} finally { await rm(directory,{recursive:true,force:true}); }
return keys;
}
export function makeKeysText(keys, revision, allowEmpty) {
if (!/^[0-9a-f]{40}$/.test(revision)) throw Error('Release revision must be a 40-character Git commit SHA.');
if (!keys.length && !allowEmpty) throw Error('No server public keys configured. Add keys/authorized_keys, or explicitly allow an empty revocation release.');
const payload = keys.map(key => key.text + '\n').join('');
const sha256 = digest(payload);
const text = `# conf-sshd-v1\n# revision ${revision}\n# key-count ${keys.length}\n# allow-empty ${allowEmpty ? 'yes' : 'no'}\n# sha256 ${sha256}\n# end-metadata\n${payload}`;
if (Buffer.byteLength(text) > 1024 * 1024) throw Error('Serialized key release exceeds client response limit.');
return {sha256,text};
}
export async function build({directory = root, outDirectory = join(directory,'cf-worker'), testFixture = false, revision, publishedAt} = {}) {
const config = validateConfig(JSON.parse(await readFile(join(directory,'deployment.json'),'utf8')));
const input = await readFile(join(directory,testFixture ? 'tests/fixtures/keys.pub' : 'keys/authorized_keys'),'utf8');
const keys = await parseKeys(input);
revision ||= process.env.GITHUB_SHA || execFileSync('git',['rev-parse','HEAD'],{cwd:directory,encoding:'utf8'}).trim();
publishedAt ||= new Date().toISOString();
const fallbackSources = validateFallbackSources(config.fallbackSources ?? []);
const needsFallback = keys.length === 0 && !config.allowEmpty && fallbackSources.length > 0;
const bundle = needsFallback ? {sha256:null,text:null} : makeKeysText(keys,revision,config.allowEmpty);
let script = await readFile(join(directory,'conf-sshd.sh'),'utf8');
for (const [name,value] of Object.entries({'BASE_URL':config.baseUrl,'DEFAULT_CRON':config.defaultCron})) {
if (!script.includes(`@@${name}@@`)) throw Error(`Script is missing @@${name}@@.`);
script = script.replaceAll(`@@${name}@@`,value);
}
if (/@@[A-Z_]+@@/.test(script)) throw Error('Unresolved script template token.');
if (!script.includes('# conf-sshd-script-v2')) throw Error('Missing script format marker.');
execFileSync('sh',['-n'],{input:script,stdio:['pipe','pipe','pipe']});
const release = {schema:1,revision,publishedAt,keyCount:keys.length,allowEmpty:config.allowEmpty,sha256:bundle.sha256,testFixture,baseUrl:config.baseUrl,repositoryUrl:config.repositoryUrl,fallbackSources,needsFallback,keys,commands:commandsFor(config)};
let html = await readFile(join(directory,'site/index.html'),'utf8');
const publicDir = join(outDirectory,'public'), generatedDir = join(outDirectory,'generated');
await rm(publicDir,{recursive:true,force:true});
await mkdir(join(publicDir,'assets'),{recursive:true}); await mkdir(generatedDir,{recursive:true});
for (const [file,ext] of [['style','css'],['app','js']]) {
const body = await readFile(join(directory,`site/assets/${file}.${ext}`));
const name = `${file}.${digest(body).slice(0,12)}.${ext}`;
await writeFile(join(publicDir,'assets',name),body);
html = html.replaceAll(`/assets/${file}.${ext}`,`/assets/${name}`);
}
release.pageTemplate = html;
await writeFile(join(publicDir,'index.html'),renderPage(html,{...release,source:{type:needsFallback ? 'pending' : keys.length ? 'repository' : 'explicit-empty'}}));
await writeFile(join(generatedDir,'release.js'),`// Generated release; do not edit.\nexport default ${JSON.stringify({...release,script,keysText:bundle.text})};\n`);
await writeFile(join(generatedDir,'manifest.json'),JSON.stringify({...release,keys:undefined,commands:undefined,pageTemplate:undefined},null,2)+'\n');
return release;
}
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
try {
const args = process.argv.slice(2);
if (args.some(arg=>arg !== '--test-fixture')) throw Error('Usage: node scripts/build.mjs [--test-fixture]');
const release = await build({testFixture:args.includes('--test-fixture')});
console.log(`Built ${release.testFixture ? 'TEST FIXTURE — NEVER DEPLOY' : 'production'} release ${release.revision}: ${release.keyCount} key(s).`);
} catch (error) { console.error(error.message); process.exitCode = 1; }
}
-29
View File
@@ -1,29 +0,0 @@
import {readFile} from 'node:fs/promises';
import {createHash} from 'node:crypto';
import {parsePublicKeys} from '../cf-worker/src/key-source.js';
const manifest=JSON.parse(await readFile(new URL('../cf-worker/generated/manifest.json',import.meta.url),'utf8'));
if (manifest.testFixture) throw Error('Refusing to deploy a test-fixture build. Run the production build with real server public keys.');
if (!process.argv.includes('--remote')) { console.log(`Production release verified: ${manifest.revision}`); }
else {
const timeout=()=>AbortSignal.timeout(20000);
const metadataResponse=await fetch(manifest.baseUrl+'/release.json',{cache:'no-store',signal:timeout()});
if(!metadataResponse.ok) throw Error(`Metadata endpoint returned ${metadataResponse.status}`);
const metadata=await metadataResponse.json();
if(metadata.revision!==manifest.revision||metadata.testFixture) throw Error('Remote release does not match this build.');
if(!/^[0-9a-f]{64}$/.test(metadata.sha256)) throw Error('Remote key digest is invalid.');
if(manifest.needsFallback) {
if(metadata.source?.type!=='fallback'||!manifest.fallbackSources.includes(metadata.source.url)||metadata.keyCount<1)throw Error('Unexpected effective fallback source.');
} else if(metadata.sha256!==manifest.sha256) throw Error('Remote repository keys differ from this build.');
const keysResponse=await fetch(manifest.baseUrl+'/ssh.keys',{cache:'no-store',signal:timeout()});
if(!keysResponse.ok) throw Error(`Keys endpoint returned ${keysResponse.status}`);
const keysText=await keysResponse.text();
const match=/^# conf-sshd-v1\n# revision ([0-9a-f]{40})\n# key-count (0|[1-9][0-9]*)\n# allow-empty (yes|no)\n# sha256 ([0-9a-f]{64})\n# end-metadata\n([\s\S]*)$/.exec(keysText);
if(!match||match[1]!==manifest.revision)throw Error('Remote key manifest invalid.');
const [, , count, empty, hash, payload]=match;
if(createHash('sha256').update(payload).digest('hex')!==hash) throw Error('Remote key body does not match its response digest.');
const keys=await parsePublicKeys(payload);
if(keys.length!==Number(count)||(!keys.length&&(!manifest.allowEmpty||empty!=='yes')))throw Error('Remote key count/empty policy invalid.');
if(!manifest.needsFallback&&hash!==manifest.sha256)throw Error('Remote repository key payload differs from this build.');
// Dynamic fallback may legitimately change between the two requests; each response is checked independently.
console.log(`Remote code release verified: ${metadata.revision}; key payload ${hash} (${count} keys).`);
}
-137
View File
@@ -1,137 +0,0 @@
(() => {
"use strict";
const themeKey = "quickly-conf-sshd-theme";
const root = document.documentElement;
const themePicker = document.getElementById("theme-picker");
const status = document.getElementById("copy-status");
const dialog = document.getElementById("copy-fallback");
const fallbackText = document.getElementById("fallback-text");
const closeFallback = document.getElementById("close-copy-fallback");
const allKeysButton = document.getElementById("copy-all-keys");
const validThemes = new Set(["system", "light", "dark"]);
const resetTimers = new WeakMap();
let fallbackTrigger = null;
let statusTimer;
function applyTheme(theme) {
const choice = validThemes.has(theme) ? theme : "system";
if (choice === "system") root.removeAttribute("data-theme");
else root.setAttribute("data-theme", choice);
if (themePicker) themePicker.value = choice;
return choice;
}
let savedTheme = "system";
try {
savedTheme = localStorage.getItem(themeKey) || "system";
} catch {
// Storage may be unavailable in private or restricted browsing contexts.
}
applyTheme(savedTheme);
themePicker?.addEventListener("change", () => {
const choice = applyTheme(themePicker.value);
try {
localStorage.setItem(themeKey, choice);
} catch {
// The selected theme still applies for this visit.
}
});
function announce(message) {
if (!status) return;
clearTimeout(statusTimer);
status.textContent = "";
statusTimer = setTimeout(() => { status.textContent = message; }, 30);
}
function showManualCopy(text, trigger) {
announce("自动复制未成功。请在手动复制窗口中复制已选中的完整内容。");
if (!dialog || !fallbackText) {
announce("自动复制未成功。请手动选择页面中的完整内容并复制。");
return;
}
fallbackTrigger = trigger;
fallbackText.value = text;
if (!dialog.open) {
if (typeof dialog.showModal === "function") dialog.showModal();
else dialog.setAttribute("open", "");
}
fallbackText.focus();
fallbackText.select();
}
closeFallback?.addEventListener("click", () => {
if (typeof dialog.close === "function") dialog.close();
else {
dialog.removeAttribute("open");
fallbackTrigger?.focus();
}
});
dialog?.addEventListener("close", () => { fallbackTrigger?.focus(); });
async function copy(text, button) {
if (button.disabled || typeof text !== "string" || text.length === 0) return;
const label = button.dataset.copyLabel || button.getAttribute("aria-label") || "复制";
button.dataset.copyLabel = label;
clearTimeout(resetTimers.get(button));
delete button.dataset.state;
button.setAttribute("aria-label", label);
button.disabled = true;
button.setAttribute("aria-busy", "true");
try {
if (!navigator.clipboard || typeof navigator.clipboard.writeText !== "function") {
throw new Error("Clipboard unavailable");
}
await navigator.clipboard.writeText(text);
button.dataset.state = "success";
button.setAttribute("aria-label", `${label},已复制`);
button.setAttribute("title", "已复制");
announce("已复制完整内容。");
resetTimers.set(button, setTimeout(() => {
delete button.dataset.state;
button.setAttribute("aria-label", label);
button.setAttribute("title", label);
}, 2000));
} catch {
button.setAttribute("title", "自动复制未成功,点击重试");
showManualCopy(text, button);
} finally {
button.disabled = false;
button.removeAttribute("aria-busy");
}
}
document.querySelectorAll(".code-block pre").forEach((pre) => {
pre.setAttribute("tabindex", "0");
});
document.querySelectorAll("[data-copy-target]").forEach((button) => {
const target = document.getElementById(button.dataset.copyTarget);
if (!target) {
button.disabled = true;
button.setAttribute("title", "复制内容不可用");
return;
}
button.addEventListener("click", () => copy(target.textContent, button));
});
let keys = [];
try {
const release = JSON.parse(document.getElementById("release-data")?.textContent || "{}");
if (Array.isArray(release.keys) && release.keys.every((key) => typeof key.text === "string" && key.text.length > 0)) {
keys = release.keys.map((key) => key.text);
}
} catch {
// Prerendered content remains usable if release metadata is unavailable.
}
if (keys.length === 0) {
keys = Array.from(document.querySelectorAll(".key-card pre code"), (code) => code.textContent).filter(Boolean);
}
if (allKeysButton) {
allKeysButton.disabled = keys.length === 0;
if (keys.length === 0) allKeysButton.setAttribute("title", "暂无可复制的公钥");
allKeysButton.addEventListener("click", () => copy(`${keys.join("\n")}\n`, allKeysButton));
}
root.classList.add("enhanced");
})();
-228
View File
@@ -1,228 +0,0 @@
:root {
color-scheme: light;
--page: #f7f9f8;
--surface: #ffffff;
--surface-soft: #f0f4f2;
--code: #f6f8f7;
--text: #1c2923;
--muted: #59685f;
--subtle: #66766c;
--line: #dce5df;
--accent: #216f4f;
--accent-soft: #e8f4ec;
--accent-line: #c5dfce;
--shadow: 0 5px 22px rgb(24 48 34 / 3%);
--focus: #237e59;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans", "PingFang SC", "Microsoft YaHei", sans-serif;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
}
:root[data-theme="dark"] {
color-scheme: dark;
--page: #101713;
--surface: #17201b;
--surface-soft: #1e2a22;
--code: #111b15;
--text: #e4ede7;
--muted: #afbeb3;
--subtle: #a1b4a7;
--line: #2d3c32;
--accent: #91d8ac;
--accent-soft: #203b2b;
--accent-line: #345941;
--shadow: 0 5px 22px rgb(0 0 0 / 9%);
--focus: #8ce0ad;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
color-scheme: dark;
--page: #101713;
--surface: #17201b;
--surface-soft: #1e2a22;
--code: #111b15;
--text: #e4ede7;
--muted: #afbeb3;
--subtle: #a1b4a7;
--line: #2d3c32;
--accent: #91d8ac;
--accent-soft: #203b2b;
--accent-line: #345941;
--shadow: 0 5px 22px rgb(0 0 0 / 9%);
--focus: #8ce0ad;
}
}
* { box-sizing: border-box; }
html { scroll-padding-top: 28px; }
body { margin: 0; background: var(--page); color: var(--text); font-size: 14px; line-height: 1.65; }
button, select, textarea { font: inherit; }
a { color: inherit; text-decoration: none; }
a:hover { color: var(--accent); }
button, select { color: inherit; }
button, select, summary { cursor: pointer; }
:focus-visible { outline: 3px solid var(--focus); outline-offset: 4px; }
[hidden], [data-enhance], .copy-button { display: none !important; }
.enhanced [data-enhance] { display: inline-flex !important; }
.enhanced .copy-button { display: inline-flex !important; }
.page-shell { width: min(1120px, calc(100% - 96px)); margin-inline: auto; }
.site-header { display: flex; justify-content: space-between; align-items: center; gap: 24px; min-height: 104px; border-bottom: 1px solid var(--line); }
.brand { display: flex; align-items: center; gap: 13px; min-width: 0; }
.brand-mark { display: grid; place-items: center; flex: 0 0 42px; height: 42px; border: 1px solid var(--accent-line); border-radius: 12px; background: var(--accent-soft); color: var(--accent); font: 700 21px ui-monospace, SFMono-Regular, Consolas, monospace; letter-spacing: -2px; padding-right: 2px; }
.brand strong { display: block; font-size: 14px; font-weight: 650; line-height: 1.4; }
.brand-caption { color: var(--subtle); font: 11px ui-monospace, SFMono-Regular, Consolas, monospace; }
.header-actions { display: flex; align-items: center; gap: 22px; flex-shrink: 0; }
.repository-link { font-size: 13px; font-weight: 600; white-space: nowrap; }
.repository-link > span { margin-left: 3px; }
.theme-control { align-items: center; gap: 8px; color: var(--muted); font-size: 12px; }
.theme-control select { min-height: 36px; max-width: 120px; padding: 4px 26px 4px 10px; border: 1px solid var(--line); border-radius: 7px; background: var(--surface); font-size: 12px; }
.hero { display: grid; grid-template-columns: 1fr 350px; align-items: center; gap: 72px; padding: 63px 0 65px; }
.eyebrow, .section-index { font: 11px/1.5 ui-monospace, SFMono-Regular, Consolas, monospace; font-weight: 550; letter-spacing: 1.5px; color: var(--accent); }
.eyebrow { display: flex; align-items: center; gap: 9px; margin: 0 0 18px; }
.status-dot { width: 6px; height: 6px; border-radius: 100%; background: var(--accent); box-shadow: 0 0 0 4px var(--accent-soft); }
h1 { margin: 0; font-size: clamp(30px, 3.4vw, 42px); font-weight: 650; letter-spacing: -1.1px; line-height: 1.45; }
.hero-description { margin: 18px 0 21px; color: var(--muted); line-height: 1.85; }
.hero-links { display: flex; gap: 25px; color: var(--accent); font-size: 13px; font-weight: 600; }
.hero-links a { display: flex; align-items: center; gap: 11px; }
.release-card { background: var(--surface); border: 1px solid var(--line); border-radius: 16px; padding: 22px 24px 18px; box-shadow: var(--shadow); min-width: 0; }
.release-heading { display: flex; justify-content: space-between; align-items: center; gap: 12px; }
.release-heading h2 { font-size: 14px; margin: 0; font-weight: 650; }
.release-badge { color: var(--accent); background: var(--accent-soft); border: 1px solid var(--accent-line); padding: 2px 8px; border-radius: 5px; font: 10px/1.6 ui-monospace, SFMono-Regular, Consolas, monospace; }
.release-details { margin: 20px 0 14px; }
.release-details > div { display: grid; grid-template-columns: 70px minmax(0, 1fr); gap: 12px; margin-top: 12px; }
.release-details dt { font-size: 12px; color: var(--subtle); }
.release-details dd { margin: 0; font-size: 12px; min-width: 0; overflow-wrap: anywhere; }
.release-revision { font-size: 11px; }
.source-label { overflow-wrap: anywhere; }
.source-status { color: var(--accent); font-weight: 600; }
.release-details time { font-size: 11px; font-variant-numeric: tabular-nums; }
.key-count { font-size: 20px; line-height: 1; font-weight: 600; }
.count-unit { color: var(--subtle); }
.release-note { margin: 17px 0 0; padding-top: 14px; border-top: 1px solid var(--line); color: var(--subtle); font-size: 11px; line-height: 1.7; }
.content-section { border-top: 1px solid var(--line); padding-top: 32px; margin-bottom: 40px; scroll-margin-top: 24px; }
.section-heading { display: flex; align-items: flex-end; justify-content: space-between; gap: 24px; margin-bottom: 20px; }
.section-index { color: var(--subtle); font-size: 10px; margin: 0 0 8px; }
.section-heading h2 { font-size: 23px; font-weight: 650; letter-spacing: -0.45px; line-height: 1.4; margin: 0; }
.section-heading p:not(.section-index) { color: var(--muted); font-size: 12px; margin: 7px 0 0; max-width: 690px; }
.section-actions { display: flex; align-items: center; gap: 18px; flex-shrink: 0; padding-bottom: 2px; }
.text-link { white-space: nowrap; color: var(--muted); font-size: 12px; }
.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:empty { display: none; }
.key-list, .command-list { display: grid; gap: 14px; min-width: 0; }
.command-list { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.key-card, .command-card { min-width: 0; border: 1px solid var(--line); border-radius: 12px; background: var(--surface); overflow: hidden; }
.key-card { padding: 18px; }
.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; }
.key-type { flex-shrink: 0; max-width: 40%; overflow-wrap: anywhere; border: 1px solid var(--line); background: var(--surface-soft); border-radius: 4px; padding: 1px 7px; color: var(--muted); font: 10px/1.6 ui-monospace, SFMono-Regular, Consolas, monospace; }
.fingerprint { color: var(--subtle); font: 11px/1.65 ui-monospace, SFMono-Regular, Consolas, monospace; margin: 6px 0 12px; overflow-wrap: anywhere; }
.code-block { position: relative; min-width: 0; border: 1px solid var(--line); border-radius: 7px; background: var(--code); }
pre { overflow-x: auto; margin: 0; padding: 14px 62px 14px 14px; white-space: pre; scrollbar-width: thin; scrollbar-color: var(--line) transparent; }
code { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace; font-size: 11px; line-height: 1.8; }
.copy-button { --icon-center: 50%; align-items: center; justify-content: center; position: relative; width: 36px; height: 36px; min-width: 36px; padding: 0; border: 1px solid var(--line); border-radius: 6px; background: var(--surface); color: var(--muted); }
.code-block > .copy-button { position: absolute; top: 7px; right: 7px; }
.copy-button::before, .copy-button::after { content: ""; display: block; position: absolute; width: 11px; height: 11px; border: 1.5px solid currentColor; border-radius: 2px; }
.copy-button::before { left: calc(var(--icon-center) - 8px); top: 9px; }
.copy-button::after { left: calc(var(--icon-center) - 4px); top: 13px; background: inherit; }
.copy-button:hover { color: var(--accent); border-color: var(--accent-line); background: var(--accent-soft); }
.copy-button:disabled { cursor: progress; }
.copy-button[data-state="success"] { color: var(--accent); background: var(--accent-soft); border-color: var(--accent-line); }
.copy-button[data-state="success"]::before { width: 7px; height: 13px; left: calc(var(--icon-center) - 4px); top: 8px; border: solid currentColor; border-width: 0 2px 2px 0; border-radius: 0; transform: rotate(45deg); }
.copy-button[data-state="success"]::after { display: none; }
.copy-all { --icon-center: 19px; width: auto; gap: 8px; padding: 0 12px 0 38px; color: var(--accent); font-size: 12px; font-weight: 600; }
.copy-all:disabled { opacity: 0.55; }
.public-notice { display: flex; align-items: flex-start; gap: 9px; margin-top: 15px; color: var(--subtle); font-size: 11px; }
.public-notice p { margin: 0; }
.notice-icon { display: grid; place-items: center; flex: 0 0 14px; height: 14px; margin-top: 2px; border: 1px solid var(--subtle); border-radius: 50%; font: 10px/1 serif; }
.command-card { display: flex; flex-direction: column; padding: 17px; }
.command-description { color: var(--muted); font-size: 12px; line-height: 1.75; margin: 6px 0 13px; }
.command-card .code-block { margin-top: auto; }
.command-card code { font-size: 10.5px; }
.advanced-commands { grid-column: 1 / -1; border: 1px solid var(--line); border-radius: 10px; background: var(--surface); }
.advanced-commands summary { padding: 15px 18px; color: var(--muted); font-size: 12px; font-weight: 600; }
.advanced-commands summary::marker { color: var(--subtle); }
.advanced-commands[open] summary { border-bottom: 1px solid var(--line); }
.advanced-commands > .command-list { padding: 15px; }
.command-note { font-size: 11px; color: var(--subtle); margin: 14px 0 0; }
.empty-state { padding: 26px; border: 1px dashed var(--line); border-radius: 10px; color: var(--muted); text-align: center; }
.preview-notice { margin: 24px 0 0; padding: 12px 16px; border: 1px solid var(--accent-line); border-radius: 8px; background: var(--accent-soft); color: var(--accent); font-size: 12px; }
.noscript-notice { padding: 16px; border: 1px solid var(--line); border-radius: 8px; color: var(--muted); font-size: 12px; }
.site-footer { display: flex; justify-content: space-between; align-items: center; gap: 20px; padding: 24px 0 32px; border-top: 1px solid var(--line); color: var(--subtle); font-size: 11px; }
.site-footer > div { display: flex; gap: 22px; }
.site-footer > div a { font-family: ui-monospace, SFMono-Regular, Consolas, monospace; }
.sr-only { position: absolute !important; width: 1px !important; height: 1px !important; padding: 0 !important; margin: -1px !important; overflow: hidden !important; clip: rect(0, 0, 0, 0); white-space: nowrap !important; border: 0 !important; }
.skip-link { position: fixed; z-index: 20; top: -100px; left: 16px; padding: 9px 16px; background: var(--surface); border: 1px solid var(--focus); border-radius: 6px; }
.skip-link:focus { top: 12px; }
dialog { width: min(640px, calc(100% - 32px)); max-height: calc(100% - 48px); padding: 25px; background: var(--surface); color: var(--text); border: 1px solid var(--line); border-radius: 14px; box-shadow: 0 20px 90px rgb(0 0 0 / 20%); }
dialog::backdrop { background: rgb(10 22 15 / 52%); }
.fallback-heading { display: flex; justify-content: space-between; align-items: center; gap: 16px; }
.fallback-heading h2 { margin: 0; font-size: 18px; }
.dialog-close { width: 36px; height: 36px; font-size: 24px; line-height: 1; border: 1px solid var(--line); border-radius: 6px; background: var(--surface-soft); }
#fallback-description { color: var(--muted); font-size: 13px; margin: 14px 0; }
#fallback-text { display: block; width: 100%; min-height: 150px; resize: vertical; padding: 12px; border: 1px solid var(--line); border-radius: 6px; background: var(--code); color: var(--text); font: 12px/1.8 ui-monospace, SFMono-Regular, Consolas, monospace; }
.fallback-note { color: var(--subtle); font-size: 11px; margin: 12px 0 0; }
@media (hover: hover) and (pointer: fine) {
.code-block > .copy-button { opacity: 0; transition: opacity 120ms ease; }
.code-block:hover > .copy-button, .code-block:focus-within > .copy-button, .code-block > .copy-button[data-state="success"] { opacity: 1; }
}
/* Keep copies discoverable on touch/hybrid devices and narrow layouts, even
when the browser reports a fine pointer or changes its emulated media. */
@media (hover: none), (pointer: coarse), (any-pointer: coarse), (max-width: 680px) {
.code-block > .copy-button { opacity: 1; transition: none; }
}
@media (max-width: 900px) {
.page-shell { width: calc(100% - 56px); }
.hero { grid-template-columns: 1fr 305px; gap: 36px; padding-block: 45px; }
.release-card { padding: 19px; }
.brand strong { max-width: 230px; font-size: 13px; }
.header-actions { gap: 16px; }
.theme-control label { display: none; }
h1 { font-size: 31px; }
.wide-only { display: none; }
}
@media (max-width: 680px) {
.page-shell { width: calc(100% - 36px); }
.site-header { min-height: 108px; flex-wrap: wrap; align-content: center; gap: 12px; padding-block: 17px; }
.brand { gap: 10px; }
.brand strong { max-width: none; font-size: 12px; }
.brand-mark { height: 35px; flex-basis: 35px; border-radius: 9px; font-size: 18px; }
.brand-caption { font-size: 10px; }
.header-actions { width: 100%; justify-content: space-between; }
.theme-control label { display: inline; }
.theme-control select { min-height: 34px; }
.repository-link { font-size: 12px; }
.hero { grid-template-columns: minmax(0, 1fr); gap: 27px; padding: 35px 0; }
h1 { font-size: clamp(28px, 7vw, 36px); letter-spacing: -0.7px; }
.hero-description { font-size: 13px; margin: 13px 0 17px; }
.eyebrow { font-size: 10px; margin-bottom: 14px; }
.hero-links { font-size: 12px; }
.release-details { margin: 15px 0 0; }
.release-details > div { grid-template-columns: 72px minmax(0, 1fr); margin-top: 10px; }
.release-note { margin-top: 15px; padding-top: 12px; }
.content-section { padding-top: 25px; margin-bottom: 31px; }
.section-heading { align-items: flex-start; flex-wrap: wrap; gap: 15px; margin-bottom: 17px; }
.section-heading h2 { font-size: 21px; }
.section-actions { width: 100%; justify-content: space-between; }
.commands-section > .section-heading > .text-link { margin-top: -3px; }
.key-card, .command-card { padding: 14px; }
.command-list { grid-template-columns: minmax(0, 1fr); gap: 12px; }
.card-heading { gap: 8px; }
.fingerprint { font-size: 10px; }
pre { padding-left: 12px; padding-block: 16px; }
.copy-button { width: 40px; height: 40px; }
.copy-button::before { top: 11px; }
.copy-button::after { top: 15px; }
.copy-button[data-state="success"]::before { top: 10px; }
.copy-all { width: auto; }
.public-notice { font-size: 11px; }
.site-footer { flex-direction: column; align-items: flex-start; gap: 12px; padding: 22px 0 26px; }
dialog { padding: 20px; }
}
@media (prefers-reduced-motion: reduce) { *, *::before, *::after { transition: none !important; scroll-behavior: auto !important; } }
@media print {
.header-actions, .hero-links, .section-actions, .copy-button, .enhanced .copy-button, .commands-section .text-link, .site-footer, dialog { display: none !important; }
.page-shell { width: 100%; }
.hero { padding-block: 24px; }
pre { white-space: pre-wrap; overflow-wrap: anywhere; }
.key-card, .command-card { break-inside: avoid; }
}
-87
View File
@@ -1,87 +0,0 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<meta name="description" content="查看当前已发布的 SSH 公钥,复制服务器配置命令。Quickly Configuring SSH Server。">
<title>SSH 公钥与快速部署 · Quickly Configuring SSH Server</title>
<link rel="stylesheet" href="/assets/style.css">
<script src="/assets/app.js" defer></script>
</head>
<body>
<a class="skip-link" href="#public-keys">跳到 SSH 公钥</a>
<div class="page-shell">
<header class="site-header">
<a class="brand" href="/" aria-label="Quickly Configuring SSH Server 首页">
<span class="brand-mark" aria-hidden="true">&gt;_</span>
<span><strong>Quickly Configuring SSH Server</strong><span class="brand-caption">quickly-conf-sshd</span></span>
</a>
<nav class="header-actions" aria-label="项目与显示设置">
<div class="theme-control" data-enhance>
<label for="theme-picker">外观</label>
<select id="theme-picker" aria-label="选择页面外观">
<option value="system">跟随系统</option>
<option value="light">浅色</option>
<option value="dark">深色</option>
</select>
</div>
<a class="repository-link" href="@@REPOSITORY_URL@@" target="_blank" rel="noopener noreferrer">GitHub <span aria-hidden="true">↗</span><span class="sr-only">(新窗口打开)</span></a>
</nav>
</header>
<main>
@@PREVIEW_NOTICE@@
<section class="hero" aria-labelledby="page-title">
<div class="hero-copy">
<p class="eyebrow"><span class="status-dot" aria-hidden="true"></span> SSH KEY DISTRIBUTION</p>
<h1 id="page-title">公钥集中管理,<br>服务器快速就绪。</h1>
<p class="hero-description">核对公钥,选择命令,配置 SSH。<br class="wide-only">查看当前公钥来源与内容版本,按需部署。</p>
<div class="hero-links"><a href="#public-keys">查看公钥 <span aria-hidden="true">↓</span></a><a href="#quick-start">快速开始 <span aria-hidden="true">↓</span></a></div>
</div>
<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>
<dl class="release-details">
<div><dt>代码版本</dt><dd><code class="release-revision">@@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-status">@@SOURCE_STATUS@@</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>
</dl>
<p class="release-note">此处展示代码发布与当前公钥来源,不代表所有服务器均已同步。</p>
</aside>
</section>
<section id="public-keys" class="content-section" aria-labelledby="keys-title">
<div class="section-heading">
<div><p class="section-index">01 / PUBLIC KEYS</p><h2 id="keys-title">SSH 公钥</h2><p>部署前,请核对指纹及密钥归属。</p></div>
<div class="section-actions"><a class="text-link" href="/ssh.keys">原始文件 <span aria-hidden="true">↗</span></a><button id="copy-all-keys" class="copy-button copy-all" type="button" aria-label="复制全部公钥" title="复制全部公钥" data-enhance><span>复制全部</span></button></div>
</div>
<p class="keys-notice" role="note">@@KEYS_NOTICE@@</p>
<div class="key-list">@@KEYS_HTML@@</div>
<div class="public-notice"><span class="notice-icon" aria-hidden="true">i</span><p>这里展示的是公开密钥。私钥应始终由你保管,本页面不会要求上传或输入私钥。</p></div>
</section>
<section id="quick-start" class="content-section commands-section" aria-labelledby="commands-title">
<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>
<div class="command-list">@@COMMANDS_HTML@@</div>
<p class="command-note">每条命令独立使用。高级选项涉及 SSH 登录策略或卸载,请阅读说明后操作。</p>
</section>
<noscript><p class="noscript-notice">JavaScript 已关闭,公钥与命令仍可查看并手动选择复制。启用 JavaScript 后可使用复制按钮与外观设置。</p></noscript>
</main>
<footer class="site-footer"><span>Quickly Configuring SSH Server</span><div><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>
</div>
<p id="copy-status" class="sr-only" role="status" aria-live="polite" aria-atomic="true"></p>
<dialog id="copy-fallback" aria-labelledby="fallback-title" aria-describedby="fallback-description">
<div class="fallback-heading"><h2 id="fallback-title">手动复制完整内容</h2><button id="close-copy-fallback" class="dialog-close" type="button" aria-label="关闭手动复制窗口">×</button></div>
<p id="fallback-description">浏览器未允许自动复制。内容已选中,请按 Ctrl+C 或 ⌘C,也可以长按文本进行复制。</p>
<label class="sr-only" for="fallback-text">要复制的完整内容</label>
<textarea id="fallback-text" readonly spellcheck="false" wrap="off"></textarea>
<p class="fallback-note">关闭此窗口后,可继续查看页面。</p>
</dialog>
<script id="release-data" type="application/json">@@RELEASE_JSON@@</script>
</body>
</html>
-85
View File
@@ -1,85 +0,0 @@
import { createServer } from "node:http";
import { readFile } from "node:fs/promises";
import { createHash } from "node:crypto";
import { commandsFor, renderPage } from "../cf-worker/src/view.js";
/** Real page rendering and real local assets, with no live Worker or upstream calls. */
export async function startBrowserFixture() {
const [template, style, script, fixtureKey] = await Promise.all([
readFile(new URL("../site/index.html", import.meta.url), "utf8"),
readFile(new URL("../site/assets/style.css", import.meta.url)),
readFile(new URL("../site/assets/app.js", import.meta.url)),
readFile(new URL("./fixtures/keys.pub", import.meta.url), "utf8"),
]);
const [type, encoded] = fixtureKey.trim().split(/\s+/);
const comment = 'CI test <img src=x onerror="window.__injected=true"> & "quoted" 中文 ' + "long-comment-".repeat(90);
const text = `${type} ${encoded} ${comment}`;
const key = {
type,
text,
label: comment,
fingerprint: "SHA256:" + createHash("sha256").update(Buffer.from(encoded, "base64")).digest("base64").replace(/=+$/, ""),
};
const config = { baseUrl: "https://keys.example.test", repositoryUrl: "https://github.com/example/quickly-conf-sshd" };
const base = {
schema: 1,
revision: "0123456789abcdef0123456789abcdef01234567",
publishedAt: "2026-10-12T12:34:56.000Z",
keyCount: 1,
allowEmpty: false,
sha256: createHash("sha256").update(text + "\n").digest("hex"),
testFixture: true,
...config,
commands: commandsFor(config),
keys: [key],
};
const snapshots = {
repository: { ...base, source: { type: "repository" } },
fallback: { ...base, source: { type: "fallback", url: "https://keys.example.test/team.keys?comment=<script>window.__injected=true</script>" } },
unavailable: { ...base, keys: [], keyCount: 0, sha256: null, source: { type: "unavailable" } },
pending: { ...base, keys: [], keyCount: 0, sha256: null, source: { type: "pending" } },
"explicit-empty": { ...base, allowEmpty: true, keys: [], keyCount: 0, sha256: createHash("sha256").update("").digest("hex"), source: { type: "explicit-empty" } },
};
const pages = new Map(Object.entries(snapshots).map(([name, snapshot]) => ["/" + name, renderPage(template, snapshot)]));
const requests = [];
const server = createServer((request, response) => {
const pathname = new URL(request.url, "http://127.0.0.1").pathname;
requests.push(pathname);
response.setHeader("Cache-Control", "no-store");
response.setHeader("X-Content-Type-Options", "nosniff");
response.setHeader("Content-Security-Policy", "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'");
if (pathname === "/assets/style.css" || pathname === "/assets/app.js") {
const isStyle = pathname.endsWith(".css");
response.setHeader("Content-Type", isStyle ? "text/css; charset=utf-8" : "text/javascript; charset=utf-8");
response.end(isStyle ? style : script);
return;
}
if (pathname === "/favicon.ico") {
response.statusCode = 204;
response.end();
return;
}
const page = pages.get(pathname === "/" ? "/repository" : pathname);
if (page) {
response.statusCode = pathname === "/unavailable" ? 503 : 200;
response.setHeader("Content-Type", "text/html; charset=utf-8");
response.end(page);
return;
}
response.statusCode = 404;
response.end("Fixture route not found\n");
});
await new Promise((resolve, reject) => {
server.once("error", reject);
server.listen(0, "127.0.0.1", () => { server.off("error", reject); resolve(); });
});
return {
origin: `http://127.0.0.1:${server.address().port}`,
snapshots,
requests,
close: () => new Promise((resolve, reject) => {
server.close((error) => error ? reject(error) : resolve());
server.closeAllConnections();
}),
};
}
-224
View File
@@ -1,224 +0,0 @@
import assert from "node:assert/strict";
import { access, mkdir } from "node:fs/promises";
import { constants } from "node:fs";
import { delimiter, join } from "node:path";
import test from "node:test";
import { startBrowserFixture } from "./browser-fixture.mjs";
async function browserExecutable() {
const candidates = process.env.CHROMIUM_PATH ? [process.env.CHROMIUM_PATH] : [
"/usr/bin/google-chrome", "/usr/bin/google-chrome-stable", "/usr/bin/chromium", "/usr/bin/chromium-browser",
...(process.env.PATH || "").split(delimiter).flatMap((directory) => ["google-chrome", "google-chrome-stable", "chromium", "chromium-browser"].map((name) => join(directory, name))),
];
for (const candidate of new Set(candidates)) {
try { await access(candidate, constants.X_OK); return candidate; } catch { /* Check the next installed browser. */ }
}
throw new Error("BROWSER_TESTS=1 requires an installed Chromium/Google Chrome. Set CHROMIUM_PATH to its executable.");
}
async function capture(page, name) {
if (!process.env.BROWSER_ARTIFACTS_DIR) return;
await mkdir(process.env.BROWSER_ARTIFACTS_DIR, { recursive: true });
await page.screenshot({ path: join(process.env.BROWSER_ARTIFACTS_DIR, name + ".png"), fullPage: true });
}
async function guardedContext(browser, fixture, options = {}) {
const context = await browser.newContext(options);
const externalRequests = [];
await context.route("**/*", (route) => {
if (new URL(route.request().url()).origin === fixture.origin) return route.continue();
externalRequests.push(route.request().url());
return route.abort("blockedbyclient");
});
return { context, externalRequests };
}
async function installClipboardMock(context) {
await context.addInitScript(() => {
window.__clipboardTest = { values: [], reject: false };
Object.defineProperty(navigator, "clipboard", {
configurable: true,
value: { writeText: async (text) => {
if (window.__clipboardTest.reject) throw new DOMException("Fixture clipboard denied", "NotAllowedError");
window.__clipboardTest.values.push(text);
} },
});
});
}
async function noPageOverflow(page, width) {
const metrics = await page.evaluate(() => ({
viewport: window.innerWidth,
document: document.documentElement.scrollWidth,
body: document.body.scrollWidth,
}));
assert.equal(metrics.viewport, width);
assert.ok(metrics.document <= width && metrics.body <= width, JSON.stringify(metrics));
}
// Opt in on a CI runner with a real browser. Ordinary npm test requires no browser or downloads.
test("real browser homepage integration", { skip: process.env.BROWSER_TESTS !== "1", timeout: 180_000 }, async (t) => {
const { chromium } = await import("playwright-core");
const executablePath = await browserExecutable();
const fixture = await startBrowserFixture();
let browser;
try {
browser = await chromium.launch({ executablePath, headless: true, args: ["--no-sandbox"] });
for (const width of [390, 1440]) {
await t.test(`${width}px layout, themes, clipboard, keyboard and fallback`, async () => {
const { context, externalRequests } = await guardedContext(browser, fixture, {
viewport: { width, height: 900 },
colorScheme: "light",
hasTouch: width < 600,
isMobile: width < 600,
});
try {
await installClipboardMock(context);
const page = await context.newPage();
const errors = [];
page.on("pageerror", (error) => errors.push(error.message));
await page.goto(fixture.origin + "/repository");
await page.waitForSelector("html.enhanced");
await noPageOverflow(page, width);
assert.equal(await page.locator("#key-0").textContent(), fixture.snapshots.repository.keys[0].text);
assert.equal(await page.locator(".key-card img").count(), 0, "key comments must remain text");
assert.equal(await page.evaluate(() => window.__injected), undefined);
assert.equal(await page.locator(".key-card pre").evaluate((pre) => pre.scrollWidth > pre.clientWidth), true);
assert.equal(await page.locator("#theme-picker").inputValue(), "system");
assert.equal(await page.locator("html").getAttribute("data-theme"), null);
const lightBackground = await page.locator("body").evaluate((body) => getComputedStyle(body).backgroundColor);
await capture(page, `homepage-${width}-light`);
await page.selectOption("#theme-picker", "dark");
assert.equal(await page.locator("html").getAttribute("data-theme"), "dark");
const darkBackground = await page.locator("body").evaluate((body) => getComputedStyle(body).backgroundColor);
assert.notEqual(darkBackground, lightBackground);
await capture(page, `homepage-${width}-dark`);
await page.reload();
assert.equal(await page.locator("#theme-picker").inputValue(), "dark");
assert.equal(await page.locator("html").getAttribute("data-theme"), "dark");
await page.emulateMedia({ colorScheme: "dark" });
await page.selectOption("#theme-picker", "light");
assert.equal(await page.locator("body").evaluate((body) => getComputedStyle(body).backgroundColor), lightBackground);
await page.selectOption("#theme-picker", "system");
assert.equal(await page.locator("html").getAttribute("data-theme"), null);
assert.equal(await page.locator("body").evaluate((body) => getComputedStyle(body).backgroundColor), darkBackground);
await page.emulateMedia({ colorScheme: "light" });
assert.equal(await page.locator("body").evaluate((body) => getComputedStyle(body).backgroundColor), lightBackground);
assert.equal(await page.evaluate(() => localStorage.getItem("quickly-conf-sshd-theme")), "system");
const keyButton = page.locator('[data-copy-target="key-0"]');
if (width < 600) {
assert.equal(await keyButton.evaluate((button) => getComputedStyle(button).opacity), "1", "touch copy buttons stay visible");
} else {
await page.locator("#page-title").hover();
await page.waitForFunction(() => getComputedStyle(document.querySelector('[data-copy-target="key-0"]')).opacity === "0");
}
await keyButton.focus();
await page.waitForFunction(() => getComputedStyle(document.querySelector('[data-copy-target="key-0"]')).opacity === "1");
await page.keyboard.press("Enter");
await page.waitForFunction(() => document.querySelector('[data-copy-target="key-0"]').dataset.state === "success");
assert.equal(await page.evaluate(() => window.__clipboardTest.values[0]), fixture.snapshots.repository.keys[0].text);
assert.match(await keyButton.getAttribute("aria-label"), /已复制/);
await page.waitForFunction(() => document.getElementById("copy-status").textContent.includes("已复制"));
await page.locator("#copy-all-keys").click();
const allKeys = fixture.snapshots.repository.keys.map((key) => key.text).join("\n") + "\n";
assert.equal(await page.evaluate(() => window.__clipboardTest.values[1]), allKeys);
assert.equal(await page.locator("#copy-all-commands").count(), 0);
await page.locator("details summary").click();
await page.locator('[data-copy-target="command-uninstall"]').focus();
await page.keyboard.press("Enter");
const uninstall = fixture.snapshots.repository.commands.find((command) => command.id === "uninstall").command;
assert.equal(await page.evaluate(() => window.__clipboardTest.values.at(-1)), uninstall);
await noPageOverflow(page, width);
await page.evaluate(() => { window.__clipboardTest.reject = true; });
await keyButton.focus();
await page.keyboard.press("Enter");
await page.waitForSelector("#copy-fallback[open]");
assert.equal(await page.locator("#fallback-text").inputValue(), fixture.snapshots.repository.keys[0].text);
assert.deepEqual(await page.locator("#fallback-text").evaluate((textarea) => ({
start: textarea.selectionStart, end: textarea.selectionEnd, focused: document.activeElement === textarea,
})), { start: 0, end: fixture.snapshots.repository.keys[0].text.length, focused: true });
assert.equal(await keyButton.getAttribute("data-state"), null, "failed copy must not show success");
await capture(page, `homepage-${width}-manual-copy`);
await page.keyboard.press("Escape");
assert.equal(await page.locator("#copy-fallback").evaluate((dialog) => dialog.open), false);
assert.equal(await keyButton.evaluate((button) => document.activeElement === button), true);
await page.keyboard.press("Enter");
await page.waitForSelector("#copy-fallback[open]");
await page.locator("#close-copy-fallback").click();
assert.equal(await keyButton.evaluate((button) => document.activeElement === button), true);
assert.deepEqual(errors, []);
assert.deepEqual(externalRequests, []);
} finally { await context.close(); }
});
}
await t.test("all source states render the same snapshot into metadata, keys and copy data", async () => {
const { context, externalRequests } = await guardedContext(browser, fixture, { viewport: { width: 390, height: 844 } });
try {
await installClipboardMock(context);
const page = await context.newPage();
for (const [state, sourceText] of [["repository", "可用"], ["fallback", "备用源可用"], ["unavailable", "暂不可用"], ["pending", "暂不可用"], ["explicit-empty", "明确撤销"]]) {
const response = await page.goto(fixture.origin + "/" + state);
assert.equal(response.status(), state === "unavailable" ? 503 : 200);
await page.waitForSelector("html.enhanced");
assert.equal(await page.locator(".source-status").textContent(), sourceText);
const snapshot = fixture.snapshots[state];
assert.equal(await page.locator(".key-count").textContent(), String(snapshot.keys.length));
assert.equal(await page.locator("#copy-all-keys").isDisabled(), snapshot.keys.length === 0);
const embedded = await page.locator("#release-data").evaluate((element) => JSON.parse(element.textContent));
assert.equal(embedded.source.type, state);
assert.deepEqual(embedded.keys, snapshot.keys);
assert.equal(embedded.keyRevision, snapshot.sha256);
assert.equal(await page.locator(".release-revision").first().textContent(), snapshot.revision.slice(0, 12));
assert.equal(await page.locator(".release-revision").nth(1).textContent(), snapshot.sha256?.slice(0, 12) || "—");
if (state === "fallback") {
assert.equal(await page.locator(".source-label").textContent(), "备用源:" + snapshot.source.url);
assert.equal(await page.locator(".source-label script").count(), 0);
await page.locator("#copy-all-keys").click();
assert.equal(await page.evaluate(() => window.__clipboardTest.values[0]), snapshot.keys.map((key) => key.text).join("\n") + "\n");
}
if (state === "unavailable" || state === "pending") {
assert.match(await page.locator(".keys-notice").textContent(), /未发布撤销指令.*保留上次成功密钥/);
assert.equal(await page.locator(".key-card").count(), 0);
}
if (state === "explicit-empty") assert.match(await page.locator(".keys-notice").textContent(), /明确撤销全部受管理公钥/);
assert.equal(await page.evaluate(() => window.__injected), undefined);
await noPageOverflow(page, 390);
await capture(page, "source-" + state);
}
assert.deepEqual(externalRequests, []);
} finally { await context.close(); }
});
await t.test("no-JavaScript page retains keys, commands, source states and system theme", async () => {
const { context, externalRequests } = await guardedContext(browser, fixture, {
viewport: { width: 390, height: 844 }, javaScriptEnabled: false, colorScheme: "dark",
});
try {
const page = await context.newPage();
await page.goto(fixture.origin + "/repository");
assert.equal(await page.locator("#key-0").isVisible(), true);
assert.equal(await page.locator("#command-install").isVisible(), true);
assert.equal(await page.locator("#copy-all-keys").isVisible(), false);
assert.equal(await page.locator("#theme-picker").isVisible(), false);
assert.match(await page.locator("noscript").textContent(), /手动选择复制/);
await page.locator("details summary").click();
assert.equal(await page.locator("#command-uninstall").isVisible(), true);
assert.equal(await page.locator(".repository-link").getAttribute("href"), fixture.snapshots.repository.repositoryUrl);
await capture(page, "homepage-no-javascript");
await page.goto(fixture.origin + "/unavailable");
assert.equal(await page.locator(".source-status").textContent(), "暂不可用");
assert.match(await page.locator(".keys-notice").textContent(), /保留上次成功密钥/);
assert.deepEqual(externalRequests, []);
} finally { await context.close(); }
});
} finally {
await browser?.close();
await fixture.close();
}
});
-84
View File
@@ -1,84 +0,0 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {readFile,mkdtemp,cp,writeFile,rm,mkdir} from 'node:fs/promises';
import {tmpdir} from 'node:os';
import {join} from 'node:path';
import {renderPage} from '../cf-worker/src/view.js';
import {validateConfig,parseKeys,makeKeysText,commandsFor,renderKeys,build,root,digest} from '../scripts/build.mjs';
const config = {baseUrl:'https://ssh.example.com',repositoryUrl:'https://github.com/example/keys',defaultCron:'*/15 * * * *',allowEmpty:false};
const fixture = await readFile(new URL('./fixtures/keys.pub',import.meta.url),'utf8');
test('deployment configuration rejects shell injection and ambiguous origins',()=>{
assert.deepEqual(validateConfig(config),config);
for(const baseUrl of ['http://x.example','https://[email protected]','https://x.example/','https://x.example/a','https://x.example;touch /tmp/x','https://x.example\n','https://x.example$(id)','https://x.example:443']) assert.throws(()=>validateConfig({...config,baseUrl}));
for(const defaultCron of ['* * * * *\nevil','61 * * * *','*/0 * * * *','*/99 * * * *','* * * 13 *','* * * * * echo x']) assert.throws(()=>validateConfig({...config,defaultCron}));
assert.throws(()=>validateConfig({...config,allowEmpty:'false'}));
assert.throws(()=>validateConfig({...config,sshKeyUrl:'https://github.com/user.keys'}));
});
test('keys are structurally validated, canonicalized and fingerprinted',async()=>{
const keys = await parseKeys('# a comment\r\n'+fixture.replace(/\n/g,'\r\n'));
assert.equal(keys.length,1);assert.match(keys[0].fingerprint,/^SHA256:/);
assert.equal(keys[0].text+'\n',fixture);
assert.equal((await parseKeys(fixture.trimEnd())).length,1);
});
test('rejects malformed keys, options, duplicate identities, private material and NUL',async()=>{
const key = fixture.trim();const [type,blob] = key.split(' ');
for(const input of ['ssh-ed25519 not-a-key','ssh-rsa '+blob,`command="evil" ${key}`,`${key}\n${type} ${blob} different-comment`,'-----BEGIN OPENSSH PRIVATE KEY-----',key+'\0',`ssh-dss ${blob}`]) await assert.rejects(parseKeys(input));
});
test('empty revocation must be explicit; protocol is deterministic',async()=>{
assert.throws(()=>makeKeysText([], 'a'.repeat(40),false),/No server public keys/);
const empty = makeKeysText([], 'a'.repeat(40),true);
assert.match(empty.text,/# key-count 0\n# allow-empty yes\n/);assert.equal(empty.sha256,digest(''));
const keys=await parseKeys(fixture);const bundle=makeKeysText(keys,'b'.repeat(40),false);
assert.ok(bundle.text.endsWith(fixture));assert.equal(bundle.sha256,digest(fixture));
assert.throws(()=>makeKeysText(keys,'main',false),/commit SHA/);
});
test('HTML escapes comments; commands use explicit script endpoint and separate actions',async()=>{
const keys=await parseKeys(fixture.trim()+' <script>alert(1)</script>');
const html=renderKeys(keys);assert.ok(html.includes('&lt;script&gt;'));assert.ok(!html.includes('<script>'));
const commands=commandsFor(config);assert.ok(commands.find(x=>x.id==='install').command.endsWith(' -c'));
assert.ok(commands.find(x=>x.id==='root-on').advanced);assert.ok(commandsFor({...config,defaultCron:'0 */6 * * *'})[0].description.includes('0 */6 * * *'));assert.ok(commands.find(x=>x.id==='uninstall').advanced);
assert.ok(commands.every(x=>!x.command.includes('github.io')&&!x.command.includes('.keys')));
});
test('build creates consistent assets/release; production empty is blocked',async()=>{
const directory=await mkdtemp(join(tmpdir(),'conf-sshd-build-'));
try {
for(const path of ['site','tests/fixtures','keys']) {await mkdir(join(directory,path),{recursive:true});await cp(join(root,path),join(directory,path),{recursive:true});}
await cp(join(root,'conf-sshd.sh'),join(directory,'conf-sshd.sh'));
await writeFile(join(directory,'deployment.json'),JSON.stringify(config));
const options={directory,revision:'c'.repeat(40),publishedAt:'2026-10-12T00:00:00.000Z'};
await assert.rejects(build(options),/No server public keys/);
const release=await build({...options,testFixture:true});assert.equal(release.testFixture,true);
const html=await readFile(join(directory,'cf-worker/public/index.html'),'utf8');
assert.ok(!html.includes('@@'));assert.ok(html.includes('cccccccccccc'));assert.match(html,/\/assets\/app\.[a-f0-9]{12}\.js/);
const generated=await readFile(join(directory,'cf-worker/generated/release.js'),'utf8');
assert.ok(generated.includes('# conf-sshd-v1'));assert.ok(generated.includes('https://ssh.example.com'));assert.ok(!generated.includes('@@BASE_URL@@'));
await writeFile(join(directory,'keys/authorized_keys'),fixture);
assert.equal((await build(options)).testFixture,false);
} finally {await rm(directory,{recursive:true,force:true});}
});
test('fallback configuration allows an empty primary only with explicit bounded HTTPS sources',async()=>{
assert.throws(()=>validateConfig({...config,fallbackSources:['http://keys.example.com']}));
assert.throws(()=>validateConfig({...config,fallbackSources:['https://user:[email protected]']}));
assert.throws(()=>validateConfig({...config,fallbackSources:Array(6).fill('https://keys.example.com')}));
const directory=await mkdtemp(join(tmpdir(),'conf-sshd-fallback-build-'));
try {
for(const path of ['site','tests/fixtures','keys']) {await mkdir(join(directory,path),{recursive:true});await cp(join(root,path),join(directory,path),{recursive:true});}
await cp(join(root,'conf-sshd.sh'),join(directory,'conf-sshd.sh'));
await writeFile(join(directory,'deployment.json'),JSON.stringify({...config,fallbackSources:['https://keys.example.com/server.keys']}));
const release=await build({directory,revision:'d'.repeat(40)});
assert.equal(release.needsFallback,true);assert.equal(release.sha256,null);assert.equal(release.keyCount,0);
assert.ok((await readFile(join(directory,'cf-worker/public/index.html'),'utf8')).includes('未发布撤销指令'));
} finally {await rm(directory,{recursive:true,force:true});}
});
test('template rendering preserves literal dollar replacements and token-like key comments',async()=>{
const tricky=fixture.trim()+" $& $` $' $$ @@REVISION@@ @@KEYS_HTML@@";
const keys=await parseKeys(tricky);
const template='<div>@@KEYS_HTML@@</div><script type="application/json">@@RELEASE_JSON@@</script>';
const html=renderPage(template,{revision:'e'.repeat(40),publishedAt:'2026-10-12',keys,commands:[],repositoryUrl:config.repositoryUrl,sha256:'f'.repeat(64)});
const json=JSON.parse(html.match(/<script type="application\/json">([\s\S]*?)<\/script>/)[1]);
assert.equal(json.keys[0].text,tricky);assert.ok(html.includes('@@KEYS_HTML@@'));assert.ok(html.includes('$&amp;'));
assert.throws(()=>renderPage('@@UNKNOWN@@',{revision:'e'.repeat(40),keys:[]}),/Unresolved/);
});
-1
View File
@@ -1 +0,0 @@
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAINdamAGCsQq31Uv+08lkBzoO4XLz2qYjJa8CGmj3B1Ea TEST-ONLY-RFC8032-public-key
-303
View File
@@ -1,303 +0,0 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {readFile} from 'node:fs/promises';
import {createHash, generateKeyPairSync} from 'node:crypto';
import {FallbackUnavailableError, KEY_SOURCE_LIMITS, makeKeyBundle, parsePublicKeys, resolveKeys, validateFallbackSources} from '../cf-worker/src/key-source.js';
const fixture = await readFile(new URL('./fixtures/keys.pub', import.meta.url), 'utf8');
const revision = 'a'.repeat(40);
const urls = ['https://keys.example.org/first.keys', 'https://keys.example.org/second.keys', 'https://keys.example.org/third.keys'];
const emptyRelease = {keys: [], allowEmpty: false, revision, fallbackSources: urls};
const fixtureKeys = await parsePublicKeys(fixture);
const hash = text => createHash('sha256').update(text).digest('hex');
const string = value => {
const bytes = typeof value === 'string' ? Buffer.from(value) : Buffer.from(value);
const prefix = Buffer.alloc(4); prefix.writeUInt32BE(bytes.length);
return Buffer.concat([prefix, bytes]);
};
const wire = (...fields) => Buffer.concat(fields.map(string));
const keyLine = (type, bytes, comment = '') => `${type} ${bytes.toString('base64')}${comment ? ` ${comment}` : ''}\n`;
const secondFixture = keyLine('ssh-ed25519', wire('ssh-ed25519', Buffer.from('3d4017c3e843895a92b70aa74d1b7ebc9c982ccf2ec4968cc0cd55f12af4660c', 'hex')), 'second');
const keyData = Buffer.from(fixture.split(/\s+/)[1], 'base64');
const response = text => new Response(text, {headers: {'Content-Type': 'text/plain; charset=utf-8'}});
async function unavailable(fetcher, options = {}, release = emptyRelease) {
let caught;
try { await resolveKeys(release, fetcher, options); } catch (error) { caught = error; }
assert.ok(caught instanceof FallbackUnavailableError);
assert.equal(caught.code, 'KEY_SOURCES_UNAVAILABLE');
return caught;
}
test('repository keys win without any upstream request and have complete protocol metadata', async () => {
const result = await resolveKeys({...emptyRelease, keys: fixtureKeys}, () => assert.fail('Repository keys must not fetch'));
assert.deepEqual(result.source, {type: 'repository'});
assert.equal(result.revision, revision);
assert.equal(result.keyCount, 1);
assert.equal(result.sha256, hash(fixture));
assert.equal(result.keyRevision, result.sha256);
assert.equal(result.keysText, `# conf-sshd-v1\n# revision ${revision}\n# key-count 1\n# allow-empty no\n# sha256 ${hash(fixture)}\n# end-metadata\n${fixture}`);
assert.ok(Number.isFinite(Date.parse(result.fetchedAt)));
});
test('explicit empty revocation suppresses fallback; repository still wins when nonempty', async () => {
const result = await resolveKeys({...emptyRelease, allowEmpty: true}, () => assert.fail('Revocation must not fetch'));
assert.deepEqual(result.source, {type: 'explicit-empty'});
assert.equal(result.keyCount, 0);
assert.equal(result.sha256, hash(''));
assert.match(result.keysText, /# allow-empty yes\n/);
const populated = await resolveKeys({...emptyRelease, keys: fixtureKeys, allowEmpty: true}, () => assert.fail('Must not fetch'));
assert.equal(populated.source.type, 'repository');
assert.equal(populated.keyCount, 1);
});
test('first valid nonempty source wins in order, without aggregation or redirect/cache reuse', async () => {
const visited = [];
const result = await resolveKeys(emptyRelease, async (url, init) => {
visited.push(url);
assert.equal(init.cache, 'no-store'); assert.equal(init.redirect, 'manual'); assert.equal(init.method, 'GET');
assert.match(init.headers['Cache-Control'], /no-store/); assert.ok(init.signal instanceof AbortSignal);
return response(fixture);
});
assert.deepEqual(visited, [urls[0]]);
assert.deepEqual(result.source, {type: 'fallback', url: urls[0]});
assert.equal(result.keyCount, 1);
assert.equal(result.sha256, hash(fixture));
});
test('malformed and empty sources are skipped before the next valid source', async () => {
const visited = [];
const result = await resolveKeys(emptyRelease, async url => {
visited.push(url);
return response([ '<html>upstream problem</html>', '# no keys\n\n', fixture ][urls.indexOf(url)]);
});
assert.deepEqual(visited, urls);
assert.equal(result.source.url, urls[2]);
});
for (const status of [301, 302, 307, 308, 400, 404, 429, 500, 503]) {
test(`HTTP ${status} is skipped without following redirects`, async () => {
const visited = [];
const result = await resolveKeys(emptyRelease, async url => {
visited.push(url);
return url === urls[0] ? new Response('Do not return this body: SECRET', {status, headers: {Location: 'https://unconfigured.example/keys'}}) : response(fixture);
});
assert.deepEqual(visited, urls.slice(0, 2));
assert.equal(result.source.url, urls[1]);
});
}
test('a response reported as redirected is rejected even when status is 200', async () => {
const redirected = response(fixture);
Object.defineProperty(redirected, 'redirected', {value: true});
const result = await resolveKeys(emptyRelease, async url => url === urls[0] ? redirected : response(secondFixture));
assert.equal(result.source.url, urls[1]);
assert.equal(result.sha256, hash(secondFixture));
});
test('fetch timeout is bounded even when an upstream ignores AbortSignal', async () => {
const visited = [];
const result = await resolveKeys(emptyRelease, async url => {
visited.push(url);
if (url === urls[0]) return new Promise(() => {});
return response(fixture);
}, {timeoutMs: 20});
assert.deepEqual(visited, urls.slice(0, 2));
assert.equal(result.source.url, urls[1]);
});
test('timeout includes streamed body and cancels it before falling back', async () => {
let cancelled = false;
const body = new ReadableStream({start(controller) {controller.enqueue(new TextEncoder().encode(fixture));}, cancel() {cancelled = true;}});
const result = await resolveKeys(emptyRelease, async url => url === urls[0] ? new Response(body) : response(secondFixture), {timeoutMs: 20});
assert.equal(cancelled, true);
assert.equal(result.source.url, urls[1]);
});
test('all failed sources return typed, bounded diagnostics without secret bodies or errors', async () => {
const error = await unavailable(async url => {
if (url === urls[0]) throw new Error('Credential SECRET network detail');
if (url === urls[1]) return new Response('SECRET body', {status: 503});
return response('# no keys\n');
});
assert.deepEqual(error.diagnostics, [{index: 0, code: 'network-error'}, {index: 1, code: 'http-error', status: 503}, {index: 2, code: 'empty'}]);
assert.doesNotMatch(JSON.stringify(error), /SECRET|https:/);
});
test('no repository keys and no configured sources is unavailable without fetching', async () => {
const error = await unavailable(() => assert.fail('No implicit GitHub or other source'), {}, {...emptyRelease, fallbackSources: []});
assert.deepEqual(error.diagnostics, []);
});
test('streamed size is enforced even without or with a misleading Content-Length', async () => {
for (const headers of [{}, {'Content-Length': '1'}]) {
let cancelled = false;
const body = new ReadableStream({start(controller) {controller.enqueue(new Uint8Array(70)); controller.enqueue(new Uint8Array(70));}, cancel() {cancelled = true;}});
const error = await unavailable(async () => new Response(body, {headers}), {maxBytes: 128}, {...emptyRelease, fallbackSources: [urls[0]]});
assert.equal(error.diagnostics[0].code, 'too-large');
assert.equal(cancelled, true);
}
});
test('oversized declared length is rejected without reading the body', async () => {
const error = await unavailable(async () => new Response(fixture, {headers: {'Content-Length': String(KEY_SOURCE_LIMITS.maxBytes + 1)}}), {}, {...emptyRelease, fallbackSources: [urls[0]]});
assert.equal(error.diagnostics[0].code, 'too-large');
});
test('invalid UTF-8 is rejected instead of silently replaced', async () => {
const error = await unavailable(async () => new Response(Uint8Array.from([0xc3, 0x28])), {}, {...emptyRelease, fallbackSources: [urls[0]]});
assert.equal(error.diagnostics[0].code, 'invalid-utf8');
});
test('changing upstream changes content revision while build revision stays fixed; no stale fallback', async () => {
let body = fixture;
const fetcher = async () => response(body);
const release = {...emptyRelease, fallbackSources: [urls[0]]};
const first = await resolveKeys(release, fetcher);
body = secondFixture;
const second = await resolveKeys(release, fetcher);
assert.notEqual(first.keyRevision, second.keyRevision);
assert.equal(first.revision, second.revision);
body = '';
await assert.rejects(resolveKeys(release, fetcher), FallbackUnavailableError);
});
test('octet-stream responses are accepted only after strict key parsing', async () => {
const result = await resolveKeys(emptyRelease, async () => new Response(fixture, {headers: {'Content-Type': 'application/octet-stream'}}));
assert.equal(result.keyCount, 1);
});
test('sources are trusted HTTPS config only, bounded, without credentials or fragments', () => {
assert.deepEqual(validateFallbackSources(urls), urls);
assert.deepEqual(validateFallbackSources(), []);
for (const invalid of [null, {}, new Array(6).fill(urls[0]), ['http://example.org/key'], ['https://user:[email protected]/key'], ['https://example.org/key#fragment'], ['https://example.org/key#'], ['https://example.org/key\n'], ['data:text/plain,key'], ['//example.org/key']]) assert.throws(() => validateFallbackSources(invalid));
});
test('Unicode comments and CRLF are canonicalized, with a known SHA256 fingerprint', async () => {
const input = fixture.trim().split(/\s+/).slice(0, 2).join('\t') + '\t 家用服务器 🔐\t张三\r\n';
const [key] = await parsePublicKeys(input);
assert.equal(key.label, '家用服务器 🔐 张三');
assert.equal(key.fingerprint, 'SHA256:bbXpuKG6zhzdmnxq256TlqzFBzRl2f6OOg722cYNbU8');
assert.equal(key.text, `${fixture.trim().split(/\s+/).slice(0, 2).join(' ')} 家用服务器 🔐 张三`);
const bundle = await makeKeyBundle([key], revision, false);
assert.equal(bundle.sha256, hash(key.text + '\n'));
});
test('duplicates, options, certificates, private keys, invalid controls, and junk are rejected', async () => {
const cert = fixture.replaceAll('ssh-ed25519', '[email protected]');
for (const invalid of [fixture + fixture, `restrict ${fixture}`, `command="id" ${fixture}`, cert, '-----BEGIN OPENSSH PRIVATE KEY-----\n', fixture + '<html>oops</html>', fixture.replace('TEST-ONLY', 'bad\u0000comment'), fixture.replace('TEST-ONLY', 'bad\rcomment'), fixture.replace('TEST-ONLY', 'bad\u0085comment'), fixture.replace('TEST-ONLY', 'bad\ud800comment')]) {
await assert.rejects(parsePublicKeys(invalid));
}
});
test('wrong wire type, truncated fields, trailing data, noncanonical base64, invalid key sizes fail', async () => {
const wrongType = keyLine('ssh-rsa', keyData);
const truncated = keyLine('ssh-ed25519', keyData.subarray(0, keyData.length - 1));
const trailing = keyLine('ssh-ed25519', Buffer.concat([keyData, Buffer.from([0])]));
const short = keyLine('ssh-ed25519', wire('ssh-ed25519', Buffer.alloc(31)));
const long = keyLine('ssh-ed25519', wire('ssh-ed25519', Buffer.alloc(33)));
const badBase64 = 'ssh-ed25519 A===\n';
const hugeField = keyLine('ssh-ed25519', Buffer.from([255, 255, 255, 255]));
for (const input of [wrongType, truncated, trailing, short, long, badBase64, hugeField]) await assert.rejects(parsePublicKeys(input));
});
test('public key count and line/input size limits are enforced', async () => {
await assert.rejects(parsePublicKeys(fixture + secondFixture, {maxKeys: 1}), /Too many/);
await assert.rejects(parsePublicKeys(fixture.trim() + 'x'.repeat(8192)), /line exceeds/);
await assert.rejects(parsePublicKeys('#'.repeat(KEY_SOURCE_LIMITS.maxBytes + 1)), /size limit/);
await assert.rejects(resolveKeys(emptyRelease, () => assert.fail(), {timeoutMs: KEY_SOURCE_LIMITS.timeoutMs + 1}), /limit/);
});
function mpint(bytes) {return bytes[0] & 128 ? Buffer.concat([Buffer.from([0]), bytes]) : bytes;}
function rsaLine(jwk, exponent, modulus) {
return keyLine('ssh-rsa', wire('ssh-rsa', exponent || mpint(Buffer.from(jwk.e, 'base64url')), modulus || mpint(Buffer.from(jwk.n, 'base64url'))));
}
test('RSA requires complete positive canonical mpints and at least 2048 bits', async () => {
const {publicKey} = generateKeyPairSync('rsa', {modulusLength: 2048});
const jwk = publicKey.export({format: 'jwk'});
assert.equal((await parsePublicKeys(rsaLine(jwk)))[0].type, 'ssh-rsa');
const modulus = mpint(Buffer.from(jwk.n, 'base64url'));
for (const [e, n] of [[Buffer.from([0, 3]), modulus], [Buffer.from([128]), modulus], [Buffer.from([2]), modulus], [Buffer.from([1]), modulus], [Buffer.from([3]), Buffer.from([0])], [Buffer.from([3]), Buffer.from([127, 3])], [Buffer.from([3]), modulus.subarray(1)], [Buffer.from([3]), Buffer.concat([Buffer.from([0]), modulus])]]) await assert.rejects(parsePublicKeys(rsaLine(jwk, e, n)));
});
for (const [name, curve] of [['nistp256', 'P-256'], ['nistp384', 'P-384'], ['nistp521', 'P-521']]) {
test(`ECDSA ${name} requires a matching curve and a valid point`, async () => {
const {publicKey} = generateKeyPairSync('ec', {namedCurve: curve});
const jwk = publicKey.export({format: 'jwk'});
const type = `ecdsa-sha2-${name}`;
const point = Buffer.concat([Buffer.from([4]), Buffer.from(jwk.x, 'base64url'), Buffer.from(jwk.y, 'base64url')]);
assert.equal((await parsePublicKeys(keyLine(type, wire(type, name, point))))[0].type, type);
await assert.rejects(parsePublicKeys(keyLine(type, wire(type, 'wrong', point))));
await assert.rejects(parsePublicKeys(keyLine(type, wire(type, name, point.subarray(1)))));
const offCurve = Buffer.alloc(point.length); offCurve[0] = 4;
await assert.rejects(parsePublicKeys(keyLine(type, wire(type, name, offCurve))), /Invalid ECDSA point/);
});
}
test('OpenSSH security-key Ed25519 and ECDSA formats validate applications and trailing data', async () => {
const edType = '[email protected]';
const publicBytes = keyData.subarray(keyData.length - 32);
const ed = keyLine(edType, wire(edType, publicBytes, 'ssh:example'));
assert.equal((await parsePublicKeys(ed))[0].type, edType);
await assert.rejects(parsePublicKeys(keyLine(edType, wire(edType, publicBytes, ''))));
await assert.rejects(parsePublicKeys(keyLine(edType, wire(edType, publicBytes, 'ssh:\u0000'))));
await assert.rejects(parsePublicKeys(keyLine(edType, wire(edType, publicBytes))));
const {publicKey} = generateKeyPairSync('ec', {namedCurve: 'P-256'});
const jwk = publicKey.export({format: 'jwk'});
const ecType = '[email protected]';
const point = Buffer.concat([Buffer.from([4]), Buffer.from(jwk.x, 'base64url'), Buffer.from(jwk.y, 'base64url')]);
assert.equal((await parsePublicKeys(keyLine(ecType, wire(ecType, 'nistp256', point, 'ssh:example'))))[0].type, ecType);
});
test('bundle rejects implicit empty and invalid deployment metadata', async () => {
await assert.rejects(makeKeyBundle([], revision, false));
await assert.rejects(makeKeyBundle(fixtureKeys, 'not-a-revision', false));
await assert.rejects(makeKeyBundle(fixtureKeys, revision, 'yes'));
});
test('fallback URL configuration rejects queries, local/private names, and normalized duplicates', () => {
for (const url of ['https://keys.example.org/key?token=secret', 'https://keys.example.org/key?', 'https://localhost/key', 'https://thing.local/key', 'https://metadata.google.internal/key', 'https://home.lan/key', 'https://192.168.1.1/key', 'https://127.1/key', 'https://[::1]/key', 'https://[2001:db8::1]/key', 'https://2130706433/key', 'https://0x7f000001/key', 'https://single-label/key']) assert.throws(() => validateFallbackSources([url]));
assert.throws(() => validateFallbackSources(['https://KEYS.example.org:443/key', 'https://keys.example.org/key']), /Duplicate/);
assert.deepEqual(validateFallbackSources(['https://KEYS.example.org:443/key']), ['https://keys.example.org/key']);
});
test('public-key parser honors an aborted signal before and during validation', async () => {
const controller = new AbortController();
controller.abort();
await assert.rejects(parsePublicKeys(fixture, {signal: controller.signal}), /timeout/);
let reads = 0;
const signal = {get aborted() {return ++reads >= 3;}};
await assert.rejects(parsePublicKeys(fixture + secondFixture, {signal}), /timeout/);
});
test('whole serialized protocol, including metadata, is limited to the client response cap', async () => {
const text = 'x'.repeat(KEY_SOURCE_LIMITS.maxBytes - 100);
await assert.rejects(makeKeyBundle([{text}], revision, false), /too-large/);
const bundle = await makeKeyBundle([{text: 'x'.repeat(KEY_SOURCE_LIMITS.maxBytes - 300)}], revision, false);
assert.ok(Buffer.byteLength(bundle.text) <= KEY_SOURCE_LIMITS.maxBytes);
});
test('an upstream fitting the input cap but exceeding the serialized output cap is skipped', async () => {
const publicBytes = keyData.subarray(keyData.length - 32);
const lines = Array.from({length: 128}, (_, index) => {
const bytes = Buffer.from(publicBytes); bytes[31] ^= index;
const prefix = keyLine('ssh-ed25519', wire('ssh-ed25519', bytes)).trim() + ' ';
return prefix + 'x'.repeat(8190 - prefix.length) + '\n';
}).join('');
assert.ok(Buffer.byteLength(lines) <= KEY_SOURCE_LIMITS.maxBytes);
const error = await unavailable(async () => response(lines), {}, {...emptyRelease, fallbackSources: [urls[0]]});
assert.equal(error.diagnostics[0].code, 'too-large');
const result = await resolveKeys(emptyRelease, async url => response(url === urls[0] ? lines : fixture));
assert.equal(result.source.url, urls[1]);
assert.equal(result.sha256, hash(fixture));
});
test('ordinary private-key advice in comments is allowed, while real private formats are rejected anywhere', async () => {
const advice = '# Do not copy private keys into this repository.\n';
assert.deepEqual(await parsePublicKeys(advice), []);
const publicWithComment = fixture.trim().split(/\s+/).slice(0, 2).join(' ') + ' private key remains on my laptop\n';
assert.equal((await parsePublicKeys(advice + publicWithComment))[0].label, 'private key remains on my laptop');
for (const marker of ['-----BEGIN OPENSSH PRIVATE KEY-----', '-----BEGIN RSA PRIVATE KEY-----', '-----BEGIN EC PRIVATE KEY-----', '-----BEGIN PRIVATE KEY-----', '-----BEGIN ENCRYPTED PRIVATE KEY-----', 'PuTTY-User-Key-File-3: ssh-ed25519']) {
await assert.rejects(parsePublicKeys('# ' + marker + '\n' + fixture), /Private keys are never accepted/);
await assert.rejects(parsePublicKeys(publicWithComment.trim() + ' ' + marker + '\n'), /Private keys are never accepted/);
}
});
-23
View File
@@ -1,23 +0,0 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
const root = fileURLToPath(new URL('..', import.meta.url));
const candidates = [['POSIX sh', '/bin/sh'], ['bash POSIX mode', 'bash --posix'], ['BusyBox ash', 'busybox sh']];
for (const [label, shell] of candidates) {
const executable = shell.split(' ')[0];
const available = spawnSync(executable, executable === 'busybox' ? ['sh', '-c', ':'] : ['-c', ':']).status === 0;
test(`isolated installer regression suite (${label})`, { skip: !available }, () => {
const result = spawnSync('python3', ['tests/shell.test.py'], {
cwd: root, encoding: 'utf8', timeout: 90_000,
env: { ...process.env, CONF_SSHD_TEST_SHELL: shell },
});
assert.equal(result.error, undefined, result.error?.message);
assert.equal(result.status, 0, result.stdout + result.stderr);
});
}
test('ShellCheck POSIX diagnostics', { skip: spawnSync('shellcheck', ['--version']).status !== 0 }, () => {
const result = spawnSync('shellcheck', ['--shell=sh', 'conf-sshd.sh'], { cwd: root, encoding: 'utf8' });
assert.equal(result.status, 0, result.stdout + result.stderr);
});
-725
View File
@@ -1,725 +0,0 @@
#!/usr/bin/env python3
"""Installer integration tests. Every run uses a private HOME and mocked network/cron.
No SSH service, real account, real network, package manager, or production key is used.
"""
import hashlib
import base64
import struct
import signal
import os
import re
from pathlib import Path
import shutil
import shlex
import subprocess
import tempfile
import time
import unittest
ROOT = Path(__file__).resolve().parents[1]
SOURCE = (ROOT / 'conf-sshd.sh').read_text()
SHELL = shlex.split(os.environ.get('CONF_SSHD_TEST_SHELL', '/bin/sh'))
ORIGIN = 'https://keys.example.test'
SCRIPT = SOURCE.replace('@@BASE_URL@@', ORIGIN).replace('@@DEFAULT_CRON@@', '*/15 * * * *')
KEY = (ROOT / 'tests/fixtures/keys.pub').read_bytes().strip() + b'\n'
BEGIN = b'# --- BEGIN MANAGED BY CONF-SSHD SCRIPT ---\n'
END = b'# --- END MANAGED BY CONF-SSHD SCRIPT ---\n'
CB = '# --- BEGIN CRON MANAGED BY CONF-SSHD SCRIPT ---\n'
CE = '# --- END CRON MANAGED BY CONF-SSHD SCRIPT ---\n'
def response(payload=KEY, allow='no', count=None):
count = len(payload.splitlines()) if count is None else count
return (f'# conf-sshd-v1\n# revision {"a" * 40}\n# key-count {count}\n'
f'# allow-empty {allow}\n# sha256 {hashlib.sha256(payload).hexdigest()}\n'
'# end-metadata\n').encode() + payload
class ShellTests(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory(prefix='conf-sshd-tests-')
self.root = Path(self.tmp.name)
self.home = self.root / "home with 'quote"
self.home.mkdir(mode=0o700)
self.bin = self.root / 'bin'
self.bin.mkdir(mode=0o700)
self.script = self.root / 'installer.sh'
self.script.write_text(SCRIPT)
self.payload = self.root / 'response'
self.payload.write_bytes(response())
self.update = self.root / 'update'
self.update.write_text(SCRIPT)
self.cron = self.root / 'crontab'
self.cron.write_text('[email protected]\n17 * * * * echo untouched\n')
self.calls = self.root / 'calls'
self.calls.write_text('')
self.env = dict(os.environ, HOME=str(self.home), PATH=f'{self.bin}:/usr/bin:/bin',
TEST_ROOT=str(self.root), TEST_RESPONSE=str(self.payload),
TEST_UPDATE=str(self.update), TEST_CRONTAB=str(self.cron),
TEST_CALLS=str(self.calls))
real_id = shutil.which('id')
self.assertIsNotNone(real_id)
self.mock('id', f'#!/bin/sh\nexec {shlex.quote(real_id)} "$@"\n')
self.mock('curl', '''#!/bin/sh
printf 'curl %s\\n' "$*" >> "$TEST_CALLS"
[ "${TEST_CURL_ERROR:-0}" = 0 ] || exit "$TEST_CURL_ERROR"
if [ "${TEST_CURL_WAIT:-0}" = 1 ]; then
: > "$TEST_ROOT/waiting"
while [ ! -f "$TEST_ROOT/release" ]; do sleep 0.02; done
fi
out=
while [ "$#" -gt 0 ]; do
case "$1" in --output) shift; out=$1 ;; https://*) url=$1 ;; esac
shift
done
case $url in */ssh.keys) cp "$TEST_RESPONSE" "$out" ;; */script.sh) cp "$TEST_UPDATE" "$out" ;; *) exit 99 ;; esac
''')
self.mock('crontab', '''#!/bin/sh
printf 'crontab %s\\n' "$*" >> "$TEST_CALLS"
[ "${TEST_CRON_ERROR:-0}" = 0 ] || { echo 'permission denied' >&2; exit 1; }
if [ "$1" = -l ]; then
if [ -f "$TEST_CRONTAB" ]; then cat "$TEST_CRONTAB"; else echo "no crontab for test-user" >&2; exit 1; fi
else
[ "${TEST_CRON_WRITE_ERROR:-0}" = 0 ] || exit 1
cp "$1" "$TEST_CRONTAB"
fi
''')
for cmd in ('sshd', 'apt-get', 'yum', 'dnf', 'systemctl', 'service'):
self.mock(cmd, f'#!/bin/sh\necho "UNEXPECTED {cmd} $*" >> "$TEST_CALLS"\nexit 98\n')
def tearDown(self):
self.tmp.cleanup()
def mock(self, name, text):
path = self.bin / name
# Probes use invalid real-tool arguments, so a failed interception can
# never mutate the actual account or crontab. Every mock answers before
# its ordinary test-specific implementation runs.
first, rest = text.split('\n', 1)
text = (first + '\n'
'if [ "${1-}" = --conf-sshd-isolation-probe ]; then\n'
f' printf \'%s\\n\' {shlex.quote("conf-sshd-mock:" + name)}\n'
' exit 0\n'
'fi\n' + rest)
path.write_text(text)
path.chmod(0o700)
return path
def isolation_prelude(self):
# BusyBox FEATURE_SH_STANDALONE may run applets before consulting PATH.
# Shell functions take precedence over applets, and an absolute mock
# path cannot resolve to an applet. Build fresh for every invocation so
# test-specific id/stat/cp/cat/dd/mv mocks are intercepted as well.
lines = []
for path in sorted(self.bin.iterdir()):
if re.fullmatch(r'[A-Za-z_][A-Za-z0-9_]*', path.name):
lines.append(f'{path.name}() {{ {shlex.quote(str(path))} "$@"; }}')
# Hyphenated command names such as apt-get are not POSIX function
# identifiers and have no BusyBox applets; their PATH mocks remain.
for name in ('id', 'crontab'):
lines.extend([
f'if [ "$({name} --conf-sshd-isolation-probe)" != conf-sshd-mock:{name} ]; then',
f' printf \'%s\\n\' \'TEST ISOLATION FAILED: {name} mock was bypassed\' >&2',
' exit 97',
'fi',
])
return '\n'.join(lines) + '\n'
def script_command(self, *args, script=None):
# Preserve the production script's arguments and $0 while loading it
# in a shell whose command resolution has passed the isolation probes.
return [*SHELL, '-c', self.isolation_prelude() + '. "$0"',
str(script or self.script), *args]
def run_script(self, *args, ok=True, env=None, script=None):
completed = subprocess.run(self.script_command(*args, script=script),
env=self.env | (env or {}), capture_output=True, text=True,
timeout=15)
if ok:
self.assertEqual(completed.returncode, 0, completed.stdout + completed.stderr)
else:
self.assertNotEqual(completed.returncode, 0, completed.stdout + completed.stderr)
self.assertNotIn('UNEXPECTED', self.calls.read_text())
return completed
@property
def auth(self):
return self.home / '.ssh/authorized_keys'
@property
def installed(self):
return self.home / '.conf-sshd/conf-sshd.sh'
def old_auth(self, data=b'# unmanaged\n'):
self.auth.parent.mkdir(mode=0o700, exist_ok=True)
self.auth.write_bytes(data)
self.auth.chmod(0o600)
return data
def assert_clean(self):
self.assertFalse((self.home / '.conf-sshd/lock').exists())
self.assertEqual(list(self.home.glob('**/.conf-sshd.*')), [])
def test_posix_parse_and_build_tokens_unique(self):
self.assertEqual(SOURCE.count('@@BASE_URL@@'), 1)
self.assertEqual(SOURCE.count('@@DEFAULT_CRON@@'), 1)
subprocess.run([*SHELL, '-n', str(self.script)], check=True)
def test_isolation_probe_aborts_before_installer_when_mock_is_bypassed(self):
for name in ('id', 'crontab'):
with self.subTest(name=name):
mock_path = self.bin / name
original = mock_path.read_text()
try:
# Simulate a broken interception without calling real tools.
mock_path.write_text('#!/bin/sh\nprintf "wrong-tool\\n"\n')
result = self.run_script('-c', ok=False)
self.assertEqual(result.returncode, 97)
self.assertIn(f'TEST ISOLATION FAILED: {name}', result.stderr)
self.assertFalse((self.home / '.conf-sshd').exists())
self.assertFalse(self.auth.exists())
self.assertEqual(self.calls.read_text(), '')
finally:
mock_path.write_text(original)
def test_default_sync_has_no_system_or_cron_side_effects(self):
old_cron = self.cron.read_bytes()
self.run_script()
self.assertEqual(self.auth.read_bytes(), BEGIN + KEY + END)
self.assertEqual(self.cron.read_bytes(), old_cron)
self.assertFalse(self.installed.exists())
self.assertEqual(self.auth.stat().st_mode & 0o777, 0o600)
self.assertEqual(self.auth.parent.stat().st_mode & 0o777, 0o700)
self.assert_clean()
def test_unmanaged_no_final_newline_is_not_lost(self):
original = self.old_auth(b'command="echo keep",no-pty ssh-ed25519 LOCAL user')
self.run_script('-o')
self.assertEqual(self.auth.read_bytes(), original + b'\n' + BEGIN + KEY + END)
self.assertEqual(Path(str(self.auth) + '.conf-sshd.bak').read_bytes(), original)
def test_existing_block_preserves_unmanaged_bytes_and_final_no_newline(self):
prefix = b'# first\nssh-rsa unmanaged-prefix\n'
suffix = b'command="keep" ssh-rsa unmanaged-suffix'
self.old_auth(prefix + BEGIN + b'old-managed\n' + END + suffix)
self.run_script('-o')
self.assertEqual(self.auth.read_bytes(), prefix + BEGIN + KEY + END + suffix)
def test_idempotent_sync_and_bounded_backup(self):
original = self.old_auth()
self.run_script('-o')
before = self.auth.read_bytes()
self.run_script('-o')
self.assertEqual(self.auth.read_bytes(), before)
self.assertEqual(Path(str(self.auth) + '.conf-sshd.bak').read_bytes(), original)
self.assertEqual(len(list(self.auth.parent.glob('*.bak'))), 1)
self.assert_clean()
def test_marker_corruption_fails_closed(self):
cases = [BEGIN, END, END + BEGIN, BEGIN + BEGIN + END, BEGIN + END + BEGIN + END,
BEGIN[:-1] + b' extra\n' + END, BEGIN + END[:-1] + b' extra\n']
for original in cases:
with self.subTest(original=original):
self.old_auth(original)
self.run_script('-o', ok=False)
self.assertEqual(self.auth.read_bytes(), original)
self.assert_clean()
def test_http_errors_keep_last_good_and_cleanup(self):
original = self.old_auth()
for error in ('22', '28', '6', '60'):
with self.subTest(error=error):
self.run_script('-o', ok=False, env={'TEST_CURL_ERROR': error})
self.assertEqual(self.auth.read_bytes(), original)
self.assert_clean()
calls = self.calls.read_text()
for option in ('--fail', '--proto =https', '--connect-timeout 10', '--max-time 30', '--retry 2'):
self.assertIn(option, calls)
self.assertNotIn('--location', calls)
def test_empty_set_requires_explicit_permission(self):
original = self.old_auth(BEGIN + KEY + END + b'# keep\n')
self.payload.write_bytes(response(b''))
self.run_script('-o', ok=False)
self.assertEqual(self.auth.read_bytes(), original)
self.payload.write_bytes(response(b'', allow='yes'))
self.run_script('-o')
self.assertEqual(self.auth.read_bytes(), BEGIN + END + b'# keep\n')
def test_metadata_and_payload_corruption(self):
original = self.old_auth()
good = response()
cases = [good[:-1], good.replace(b'# conf-sshd-v1', b'# conf-sshd-v2'),
good.replace(b'# key-count 1', b'# key-count 01'),
good.replace(b'# key-count 1', b'# key-count 2'),
good.replace(b'# allow-empty no', b'# allow-empty maybe'),
good.replace(b'# revision ' + b'a' * 40, b'# revision ' + b'a' * 39),
good.replace(b'# end-metadata', b'# extra\n# end-metadata'),
good.replace(b'AAAAC', b'BAAAC'), good.replace(b'\n', b'\r\n'),
response(KEY + b'\x00\n'), response(b'\n')]
for value in cases:
with self.subTest(value=value[:70]):
self.payload.write_bytes(value)
self.run_script('-o', ok=False)
self.assertEqual(self.auth.read_bytes(), original)
self.assert_clean()
def test_duplicate_option_malformed_and_unsupported_keys(self):
original = self.old_auth()
no_comment = b' '.join(KEY.split()[:2])
cases = [KEY + KEY, KEY + no_comment + b' changed-comment\n',
b'command="id" ' + KEY, b'ssh-rsa AAAA bad\n',
KEY.replace(b'ssh-ed25519 ', b'ssh-rsa '),
KEY.replace(b' ', b' ', 1), KEY.rstrip() + b' \n',
KEY.replace(b'ssh-ed25519', b'ssh-dss'),
KEY.replace(b'ssh-ed25519', b'[email protected]')]
for value in cases:
with self.subTest(value=value[:70]):
self.payload.write_bytes(response(value))
self.run_script('-o', ok=False)
self.assertEqual(self.auth.read_bytes(), original)
self.assert_clean()
def test_printable_comments_can_contain_spaces_and_symbols(self):
payload = b' '.join(KEY.split()[:2]) + ' 中文 comment with spaces $;# ! \'"\n'.encode()
self.payload.write_bytes(response(payload))
self.run_script('-o')
self.assertEqual(self.auth.read_bytes(), BEGIN + payload + END)
def test_synthetic_rsa_and_ecdsa_and_weak_rsa(self):
for kind, bits in [('rsa', 2048), ('ecdsa', 256), ('ecdsa', 384), ('ecdsa', 521), ('rsa', 1024)]:
with self.subTest(kind=kind, bits=bits):
keyfile = self.root / 'ephemeral-test-key'
subprocess.run(['ssh-keygen', '-q', '-t', kind, '-b', str(bits), '-N', '', '-f', str(keyfile)], check=True)
payload = keyfile.with_suffix('.pub').read_bytes()
keyfile.unlink() # Never retained or installed; fixture public key only.
keyfile.with_suffix('.pub').unlink()
self.payload.write_bytes(response(payload))
self.run_script('-o', ok=(bits != 1024))
self.assert_clean()
def test_symlink_auth_ssh_home_and_backup_are_rejected(self):
outside = self.root / 'outside'
outside.write_bytes(b'untouched')
self.auth.parent.mkdir(mode=0o700)
self.auth.symlink_to(outside)
self.run_script('-o', ok=False)
self.assertEqual(outside.read_bytes(), b'untouched')
self.auth.unlink()
self.old_auth()
backup = Path(str(self.auth) + '.conf-sshd.bak')
backup.symlink_to(outside)
original = self.auth.read_bytes()
self.run_script('-o', ok=False)
self.assertEqual(self.auth.read_bytes(), original)
backup.unlink()
self.auth.unlink()
self.auth.parent.rmdir()
self.auth.parent.symlink_to(self.root, target_is_directory=True)
self.run_script('-o', ok=False)
linked_home = self.root / 'linked-home'
linked_home.symlink_to(self.home, target_is_directory=True)
self.run_script('-o', ok=False, env={'HOME': str(linked_home)})
def test_group_writable_and_hardlinked_files_are_rejected(self):
original = self.old_auth()
self.auth.chmod(0o666)
self.run_script('-o', ok=False)
self.assertEqual(self.auth.read_bytes(), original)
self.auth.chmod(0o600)
os.link(self.auth, self.root / 'hardlink')
self.run_script('-o', ok=False)
self.assertEqual(self.auth.read_bytes(), original)
def test_unicode_home_path_is_safe(self):
unicode_home = self.root / '测试 home'
unicode_home.mkdir(mode=0o700)
self.run_script('-c', env={'HOME': str(unicode_home)})
self.assertEqual((unicode_home / '.ssh/authorized_keys').read_bytes(), BEGIN + KEY + END)
self.assertIn(str(unicode_home), self.cron.read_text())
def test_home_control_characters_rejected_before_writes(self):
for suffix in ['\ninside', '\n', '\r', '\t', '\\literal']:
with self.subTest(suffix=repr(suffix)):
unsafe_home = self.root / ('unsafe' + suffix)
unsafe_home.mkdir(mode=0o700)
self.run_script('-o', ok=False, env={'HOME': str(unsafe_home)})
self.assertEqual(list(unsafe_home.iterdir()), [])
def test_wrong_owner_is_rejected(self):
real_stat = shutil.which('stat')
self.mock('stat', f'''#!/bin/sh
"{real_stat}" "$@" | sed 's/^[0-9][0-9]*/99999/'
''')
self.run_script('-o', ok=False)
self.assertFalse(self.auth.exists())
def test_cron_bare_default_custom_preservation_and_safe_quotes(self):
unrelated = self.cron.read_text()
self.run_script('-c')
text = self.cron.read_text()
self.assertTrue(text.startswith(unrelated))
self.assertIn("*/15 * * * * /bin/sh '", text)
self.assertIn("'\\''quote/.conf-sshd/conf-sshd.sh' -o", text)
self.assertNotIn('-u', text)
self.assertEqual(self.installed.stat().st_mode & 0o777, 0o700)
text = text.replace('*/15 * * * *', '7 1 * * 2')
self.cron.write_text(text)
self.run_script('-c')
self.assertEqual(self.cron.read_text(), text)
self.run_script('-c', '*/10 2-8/2 * 1,6 0-7')
self.assertIn('*/10 2-8/2 * 1,6 0-7 /bin/sh', self.cron.read_text())
self.run_script('-c', 'false', env={'TEST_CURL_ERROR': '22'})
self.assertEqual(self.cron.read_text(), unrelated)
self.assert_clean()
def test_cron_legacy_migrates_custom_schedule_only_exact_target(self):
unrelated = '5 * * * * /bin/sh /other/conf-sshd.sh -o\n# conf-sshd.sh note\n'
legacy = f'3 4 * * 1 "/bin/bash {self.installed} -o" >> {self.home}/.conf-sshd/run.log 2>&1\n'
self.cron.write_text(unrelated + legacy)
self.run_script('-c')
text = self.cron.read_text()
self.assertTrue(text.startswith(unrelated))
self.assertIn('3 4 * * 1 /bin/sh', text)
self.assertNotIn('"/bin/bash', text)
self.assertEqual(text.count(CB), 1)
def test_cron_errors_and_bad_managed_markers_do_not_clobber(self):
cases = [CB, CE, CB + CE, CB + '* * * * * unknown command\n' + CE,
CB + CE + CB + CE]
for text in cases:
with self.subTest(text=text):
self.cron.write_text(text)
self.run_script('-c', ok=False)
self.assertEqual(self.cron.read_text(), text)
self.assertFalse(self.auth.exists())
self.assert_clean()
original = self.cron.read_bytes()
self.run_script('-c', ok=False, env={'TEST_CRON_ERROR': '1'})
self.assertEqual(self.cron.read_bytes(), original)
def test_cron_no_existing_crontab(self):
self.cron.unlink()
self.run_script('-c')
self.assertIn('*/15 * * * *', self.cron.read_text())
def test_cron_expression_rejects_injection_ranges_and_missing_fields(self):
cases = ['* * * *', '60 * * * *', '* 24 * * *', '* * 0 * *', '* * * 13 *',
'* * * * 8', '*/0 * * * *', '10-2 * * * *', '@daily',
'* * * * *; touch /tmp/bad', '* * * * *\n* * * * *', '* * * * *%id']
original = self.cron.read_bytes()
for value in cases:
with self.subTest(value=value):
self.run_script('-c', value, ok=False)
self.assertEqual(self.cron.read_bytes(), original)
self.assertFalse(self.auth.exists())
def test_argument_conflicts_missing_values_unknown(self):
for args in [('-u', '-o'), ('-o', '-c'), ('-p',), ('-p', 'maybe'),
('--uninstall', '-c'), ('-c', '-c'), ('--unknown',),
('-c', ''), ('--dry-run', '--dry-run'), ('--status', '-c')]:
with self.subTest(args=args):
self.run_script(*args, ok=False)
self.assertFalse(self.auth.exists())
def test_dry_run_no_persistent_changes(self):
old_cron = self.cron.read_bytes()
self.run_script('-c', '--dry-run')
self.assertFalse(self.auth.exists())
self.assertFalse(self.installed.exists())
self.assertEqual(self.cron.read_bytes(), old_cron)
self.assertFalse((self.home / '.ssh').exists())
self.assertFalse((self.home / '.conf-sshd').exists())
self.old_auth()
state = self.home / '.conf-sshd'
state.mkdir(mode=0o755)
self.run_script('-o', '--dry-run')
self.assertEqual(state.stat().st_mode & 0o777, 0o755)
self.assertEqual(self.auth.read_bytes(), b'# unmanaged\n')
def test_concurrent_run_refuses_and_signal_cleanup(self):
env = self.env | {'TEST_CURL_WAIT': '1'}
first = subprocess.Popen(self.script_command('-o'), env=env,
stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
try:
deadline = time.monotonic() + 5
while not (self.root / 'waiting').exists() and time.monotonic() < deadline:
time.sleep(0.02)
self.assertTrue((self.root / 'waiting').exists())
completed = self.run_script('-o', ok=False)
self.assertIn('Another run holds', completed.stderr)
(self.root / 'release').touch()
stdout, stderr = first.communicate(timeout=10)
self.assertEqual(first.returncode, 0, stdout + stderr)
finally:
if first.poll() is None:
(self.root / 'release').touch()
first.terminate()
first.communicate(timeout=10)
self.assert_clean()
# A stale/foreign lock is never auto-removed.
lock = self.home / '.conf-sshd/lock'
lock.mkdir()
(lock / 'pid').write_text('999999')
self.run_script('-o', ok=False)
self.assertTrue(lock.exists())
def test_update_self_is_explicit_atomic_and_fail_closed(self):
self.run_script('-c')
original = self.installed.read_bytes()
keys = self.auth.read_bytes()
cron = self.cron.read_bytes()
cases = ['#!/bin/sh\necho invalid\n', SCRIPT + '\nif\n',
SCRIPT.replace('# conf-sshd-script-v2', '# other'),
SCRIPT.replace(ORIGIN, 'https://different.example.test'),
SCRIPT + '\n# conf-sshd-script-v2\n', SCRIPT + '\n# @@UNBUILT@@\n']
for text in cases:
with self.subTest(text=text[:40]):
self.update.write_text(text)
self.run_script('-u', ok=False)
self.assertEqual(self.installed.read_bytes(), original)
self.assert_clean()
self.run_script('-u', ok=False, env={'TEST_CURL_ERROR': '22'})
self.update.write_text(SCRIPT + '\n# new release\n')
self.run_script('-u')
self.assertEqual(self.installed.read_bytes(), self.update.read_bytes())
self.assertEqual(Path(str(self.installed) + '.conf-sshd.bak').read_bytes(), original)
self.assertEqual(self.auth.read_bytes(), keys)
self.assertEqual(self.cron.read_bytes(), cron)
self.assert_clean()
def test_update_without_install_fails(self):
self.run_script('-u', ok=False)
self.assertFalse(self.installed.exists())
def test_uninstall_preserves_keys_unrelated_files_and_cron(self):
original_cron = self.cron.read_text()
self.run_script('-c')
original_keys = self.auth.read_bytes()
keep = self.home / '.conf-sshd/keep-user-file'
keep.write_text('keep')
self.run_script('--uninstall')
self.assertEqual(self.auth.read_bytes(), original_keys)
self.assertEqual(self.cron.read_text(), original_cron)
self.assertFalse(self.installed.exists())
self.assertEqual(keep.read_text(), 'keep')
self.assert_clean()
def test_root_policy_requires_root_without_touching_sshd(self):
if os.getuid() == 0:
self.skipTest('Non-root refusal test requires an unprivileged test runner.')
result = self.run_script('-p', 'no', ok=False)
self.assertIn('requires root', result.stderr)
def mock_root_config(self, text='Port 2222\n# preserve comment\nPermitRootLogin no\n'):
config_dir = self.root / 'fake-etc-ssh'
config_dir.mkdir(mode=0o700, exist_ok=True)
config = config_dir / 'sshd_config'
config.write_text(text)
config.chmod(0o600)
# Rewrite fixture copy only; the shipped script has no environment override.
fixture = SCRIPT.replace('config=/etc/ssh/sshd_config', f"config='{config}'")
fixture = fixture.replace('check_path /etc/ssh dir', f"check_path '{config_dir}' dir")
fixture = fixture.replace('new_temp /etc/ssh;', f"new_temp '{config_dir}';")
self.assertNotIn('/etc/ssh', fixture, 'All root-policy paths must stay inside test fixtures')
self.script.write_text(fixture)
self.mock('id', '#!/bin/sh\nprintf "0\\n"\n')
real_stat = shutil.which('stat')
self.mock('stat', f'#!/bin/sh\n"{real_stat}" "$@" | sed \'s/^[0-9][0-9]*/0/\'\n')
self.mock('sshd', '''#!/bin/sh
printf 'sshd %s\\n' "$*" >> "$TEST_CALLS"
operation=$1
shift
[ "$1" = -f ] || exit 90
config=$2
[ "${TEST_SSHD_FAIL:-}" != "$operation" ] || exit 1
if [ "$operation" = -T ]; then
if [ "${TEST_SSHD_OVERRIDE:-0}" = 1 ]; then echo 'permitrootlogin no'
else awk 'tolower($1)=="permitrootlogin" {print "permitrootlogin " $2; exit}' "$config"; fi
fi
''')
return config
def test_key_comment_marker_phrase_can_sync_and_revoke(self):
payload = b' '.join(KEY.split()[:2]) + b' MANAGED BY CONF-SSHD SCRIPT\n'
self.payload.write_bytes(response(payload))
self.run_script('-o')
self.run_script('-o')
self.payload.write_bytes(response(b'', allow='yes'))
self.run_script('-o')
self.assertEqual(self.auth.read_bytes(), BEGIN + END)
def test_external_authorized_keys_edit_during_backup_is_not_overwritten(self):
original = self.old_auth(b'# original user key\n')
real_cp = shutil.which('cp')
self.mock('cp', f'''#!/bin/sh
"{real_cp}" "$@" || exit $?
case "$1:$2" in "$HOME/.ssh/authorized_keys:$HOME/.ssh/.conf-sshd."*)
printf '# concurrent manual key\\n' >> "$HOME/.ssh/authorized_keys" ;;
esac
''')
result = self.run_script('-o', ok=False)
self.assertIn('changed concurrently', result.stderr)
self.assertEqual(self.auth.read_bytes(), original + b'# concurrent manual key\n')
self.assertFalse(Path(str(self.auth) + '.conf-sshd.bak').exists())
self.assert_clean()
def test_new_authorized_keys_created_concurrently_is_preserved(self):
real_cat = shutil.which('cat')
self.mock('cat', f'''#!/bin/sh
"{real_cat}" "$@" || exit $?
if [ "$#" = 1 ] && [ -f "$1" ] && grep -q '^ssh-ed25519 ' "$1"; then
if [ ! -e "$HOME/.ssh/authorized_keys" ]; then
printf '# concurrently created user key\\n' > "$HOME/.ssh/authorized_keys"
fi
fi
''')
result = self.run_script('-o', ok=False)
self.assertIn('appeared concurrently', result.stderr)
self.assertEqual(self.auth.read_bytes(), b'# concurrently created user key\n')
self.assert_clean()
def test_piped_install_ignores_same_named_unrelated_cwd_file(self):
decoy = self.root / 'sh'
decoy.write_text(SCRIPT + '\n# UNTRUSTED CWD DECOY\n')
completed = subprocess.run([*SHELL, '-s', '--', '-c'], input=self.isolation_prelude() + SCRIPT,
cwd=self.root, env=self.env, capture_output=True, text=True, timeout=15)
self.assertEqual(completed.returncode, 0, completed.stdout + completed.stderr)
self.assertNotIn('UNTRUSTED CWD DECOY', self.installed.read_text())
self.assertEqual(self.installed.read_text(), SCRIPT)
self.assertIn('/script.sh', self.calls.read_text())
self.assert_clean()
def test_root_policy_staged_validation_backup_no_reload(self):
config = self.mock_root_config('Port 2222\n# preserve comment\nPermitRootLogin=no\n"PermitRootLogin" no\n')
original = config.read_bytes()
self.run_script('-p', 'no')
self.assertTrue(config.read_text().startswith('PermitRootLogin prohibit-password\n'))
self.assertIn('Port 2222\n# preserve comment\n', config.read_text())
self.assertEqual(Path(str(config) + '.conf-sshd.bak').read_bytes(), original)
self.assertEqual(config.read_text().count('PermitRootLogin'), 1)
self.run_script('-p', 'yes')
self.assertTrue(config.read_text().startswith('PermitRootLogin yes\n'))
self.assertIn('sshd -t -f', self.calls.read_text())
self.assertIn('sshd -T -f', self.calls.read_text())
self.assertFalse(self.auth.exists())
self.assert_clean()
self.assertEqual(list(config.parent.glob('.conf-sshd.*')), [])
def test_root_policy_include_match_invalid_effective_and_dry_run(self):
config = self.mock_root_config()
for text in ['Include sshd_config.d/*.conf\n', ' Match User test\n',
'Port 2222\nINCLUDE other\n', 'mAtCh all\n',
'Include=other\n', 'Match=User root\n', '"Include"=other\n',
'"Match" User root\n', '"Match"User root\n']:
with self.subTest(text=text):
config.write_text(text)
self.run_script('-p', 'no', ok=False)
self.assertEqual(config.read_text(), text)
config.write_text('PermitRootLogin no\n# unchanged\n')
original = config.read_bytes()
for env in [{'TEST_SSHD_FAIL': '-t'}, {'TEST_SSHD_FAIL': '-T'}, {'TEST_SSHD_OVERRIDE': '1'}]:
self.run_script('-p', 'yes', ok=False, env=env)
self.assertEqual(config.read_bytes(), original)
self.assertFalse(Path(str(config) + '.conf-sshd.bak').exists())
self.assert_clean()
self.run_script('-p', 'yes', '--dry-run')
self.assertEqual(config.read_bytes(), original)
self.assertEqual(list(config.parent.glob('.conf-sshd.*')), [])
def test_security_key_public_blob_and_canonical_base64(self):
def sshstring(value):
return struct.pack('>I', len(value)) + value
public = base64.b64decode(KEY.split()[1])[-32:]
kind = b'[email protected]'
blob = sshstring(kind) + sshstring(public) + sshstring(b'ssh:fixturex')
payload = kind + b' ' + base64.b64encode(blob) + b' public-fixture-only\n'
self.payload.write_bytes(response(payload))
self.run_script('-o')
original = self.auth.read_bytes()
# Different non-zero padding bits can decode to the same byte string.
padded = base64.b64encode(blob)
self.assertTrue(padded.endswith(b'='))
alphabet = b'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
offset = -3 if padded.endswith(b'==') else -2
altered = bytearray(padded)
altered[offset] = alphabet[alphabet.index(altered[offset]) + 1]
invalid = kind + b' ' + bytes(altered) + b' invalid-padding\n'
self.payload.write_bytes(response(invalid))
self.run_script('-o', ok=False)
self.assertEqual(self.auth.read_bytes(), original)
# Build an SK-ECDSA public fixture from a transient ordinary ECDSA key.
keyfile = self.root / 'ephemeral-ecdsa'
subprocess.run(['ssh-keygen', '-q', '-t', 'ecdsa', '-b', '256', '-N', '', '-f', str(keyfile)], check=True)
ec = base64.b64decode(keyfile.with_suffix('.pub').read_bytes().split()[1])
keyfile.unlink()
keyfile.with_suffix('.pub').unlink()
kind = b'[email protected]'
old_type_len = struct.unpack('>I', ec[:4])[0]
blob = sshstring(kind) + ec[4 + old_type_len:] + sshstring(b'ssh:test-only')
payload = kind + b' ' + base64.b64encode(blob) + b' synthetic-security-key\n'
self.payload.write_bytes(response(payload))
self.run_script('-o')
def test_custom_build_default_and_update_preserve_configuration(self):
custom = SCRIPT.replace("DEFAULT_CRON='*/15 * * * *'", "DEFAULT_CRON='8 3 * * *'")
self.script.write_text(custom)
self.update.write_text(custom)
self.run_script('-c')
self.assertIn('8 3 * * * /bin/sh', self.cron.read_text())
self.update.write_text(custom + '\n# updated\n')
self.run_script('-u')
self.update.write_text(SCRIPT)
self.run_script('-u', ok=False)
def test_signal_removes_own_lock_and_temporary_files(self):
first = subprocess.Popen(self.script_command('-o'),
env=self.env | {'TEST_CURL_WAIT': '1'},
stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True,
start_new_session=True)
try:
deadline = time.monotonic() + 5
while not (self.root / 'waiting').exists() and time.monotonic() < deadline:
time.sleep(0.02)
self.assertTrue((self.root / 'waiting').exists())
os.killpg(first.pid, signal.SIGTERM)
first.communicate(timeout=10)
self.assertNotEqual(first.returncode, 0)
self.assert_clean()
self.assertFalse(self.auth.exists())
finally:
if first.poll() is None:
os.killpg(first.pid, signal.SIGKILL)
first.communicate(timeout=10)
def test_concurrent_external_cron_edit_is_not_overwritten(self):
self.mock('crontab', '''#!/bin/sh
if [ "$1" = -l ]; then
if [ -f "$TEST_ROOT/cron-read" ]; then
printf '42 * * * * echo concurrent-editor\\n' >> "$TEST_CRONTAB"
fi
touch "$TEST_ROOT/cron-read"
cat "$TEST_CRONTAB"
else
echo 'UNEXPECTED overwrote concurrent cron' >> "$TEST_CALLS"
exit 98
fi
''')
self.run_script('-c', ok=False)
self.assertIn('concurrent-editor', self.cron.read_text())
self.assertNotIn(CB, self.cron.read_text())
self.assert_clean()
def test_help_status_and_unbuilt_source(self):
self.run_script('--help')
self.run_script('--status')
self.cron.unlink()
self.run_script('--status')
self.assertFalse((self.home / '.conf-sshd').exists())
self.script.write_text(SOURCE)
self.run_script('-o', ok=False)
self.assertFalse(self.auth.exists())
if __name__ == '__main__':
unittest.main(verbosity=2)
-233
View File
@@ -1,233 +0,0 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import test from "node:test";
import vm from "node:vm";
const script = await readFile(new URL("../site/assets/app.js", import.meta.url), "utf8");
const html = await readFile(new URL("../site/index.html", import.meta.url), "utf8");
const css = await readFile(new URL("../site/assets/style.css", import.meta.url), "utf8");
class Element {
constructor(id, text = "") {
this.id = id;
this.textContent = text;
this.dataset = {};
this.attributes = new Map();
this.listeners = new Map();
this.classList = { add: (name) => this.attributes.set("class", name) };
this.disabled = false;
this.open = false;
this.value = "";
this.focused = false;
this.selected = false;
}
setAttribute(name, value) { this.attributes.set(name, value); }
getAttribute(name) { return this.attributes.get(name) ?? null; }
removeAttribute(name) { this.attributes.delete(name); }
addEventListener(name, handler) { this.listeners.set(name, handler); }
dispatch(name) { return this.listeners.get(name)?.({ target: this }); }
focus() { this.focused = true; }
select() { this.selected = true; }
showModal() { this.open = true; }
close() { this.open = false; this.dispatch("close"); }
}
function setup({ keys = ["ssh-ed25519 AAAA exact comment"], savedTheme, brokenStorage = false, clipboard = async () => {}, rawRelease } = {}) {
const root = new Element("root");
const ids = ["theme-picker", "copy-status", "copy-fallback", "fallback-text", "close-copy-fallback", "copy-all-keys", "release-data"];
const elements = new Map(ids.map((id) => [id, new Element(id)]));
const keyElements = keys.map((text, index) => new Element(`key-${index}`, text));
const codeElements = [...keyElements, new Element("command-install", "curl -fsSL https://example.test/script.sh | sh -s -- -c")];
const pres = codeElements.map((code) => new Element(`pre-${code.id}`));
const buttons = codeElements.map((code) => {
const button = new Element(`copy-${code.id}`);
button.dataset.copyTarget = code.id;
button.setAttribute("aria-label", `复制 ${code.id}`);
return button;
});
for (const element of codeElements) elements.set(element.id, element);
elements.get("release-data").textContent = rawRelease ?? JSON.stringify({ keys: keys.map((text) => ({ text })) });
elements.get("copy-all-keys").setAttribute("aria-label", "复制全部公钥");
const document = {
documentElement: root,
getElementById: (id) => elements.get(id) || null,
querySelectorAll: (selector) => ({
".code-block pre": pres,
"[data-copy-target]": buttons,
".key-card pre code": keyElements,
})[selector] || [],
};
const stored = new Map(savedTheme ? [["quickly-conf-sshd-theme", savedTheme]] : []);
const timers = new Map();
let timerId = 0;
const context = vm.createContext({
document,
navigator: clipboard === null ? {} : { clipboard: { writeText: clipboard } },
localStorage: {
getItem: (key) => { if (brokenStorage) throw new Error("Denied"); return stored.get(key) || null; },
setItem: (key, value) => { if (brokenStorage) throw new Error("Denied"); stored.set(key, value); },
},
setTimeout: (callback) => { timers.set(++timerId, callback); return timerId; },
clearTimeout: (id) => timers.delete(id),
});
vm.runInContext(script, context);
return { root, elements, keyElements, codeElements, buttons, pres, stored, flushTimers: () => {
const pending = [...timers.values()];
timers.clear();
pending.forEach((callback) => callback());
} };
}
test("homepage prerenders content, release metadata, and independent local assets", () => {
for (const token of ["RELEASE_JSON", "KEYS_HTML", "COMMANDS_HTML", "REVISION", "PUBLISHED_AT", "KEY_COUNT", "REPOSITORY_URL", "RELEASE_LABEL", "PREVIEW_NOTICE", "SOURCE_LABEL", "SOURCE_STATUS", "CONTENT_REVISION", "KEYS_NOTICE"]) {
assert.ok(html.includes(`@@${token}@@`), `${token} placeholder is required`);
}
assert.match(html, /<html lang="zh-CN">/);
assert.match(html, /href="\/assets\/style\.css"/);
assert.match(html, /src="\/assets\/app\.js" defer/);
assert.match(html, /<script id="release-data" type="application\/json">/);
assert.match(html, /aria-live="polite"/);
assert.match(html, /<noscript>/);
assert.match(html, /不代表所有服务器均已同步/);
assert.match(html, /不会要求上传或输入私钥/);
assert.doesNotMatch(html, /github\.io|https?:[^"']+\.(?:css|js)["']/);
assert.doesNotMatch(html, /copy-all-commands/);
});
test("default follows system and explicit light/dark/system choices persist", () => {
const page = setup();
const select = page.elements.get("theme-picker");
assert.equal(select.value, "system");
assert.equal(page.root.getAttribute("data-theme"), null);
for (const theme of ["dark", "light", "system"]) {
select.value = theme;
select.dispatch("change");
assert.equal(page.root.getAttribute("data-theme"), theme === "system" ? null : theme);
assert.equal(page.stored.get("quickly-conf-sshd-theme"), theme);
}
assert.equal(setup({ savedTheme: "dark" }).root.getAttribute("data-theme"), "dark");
assert.equal(setup({ savedTheme: "unexpected" }).root.getAttribute("data-theme"), null);
});
test("storage restrictions do not prevent theme selection or enhancement", () => {
const page = setup({ brokenStorage: true });
const select = page.elements.get("theme-picker");
select.value = "dark";
select.dispatch("change");
assert.equal(page.root.getAttribute("data-theme"), "dark");
assert.equal(page.root.getAttribute("class"), "enhanced");
for (const pre of page.pres) assert.equal(pre.getAttribute("tabindex"), "0");
});
test("individual key and command copies retain every byte, including comments and punctuation", async () => {
const exact = `ssh-ed25519 ${"A".repeat(2000)} user <script> & \"quoted\" 中文`;
const copied = [];
const page = setup({ keys: [exact], clipboard: async (text) => copied.push(text) });
await page.buttons[0].dispatch("click");
await page.buttons[1].dispatch("click");
assert.deepEqual(copied, [exact, page.codeElements[1].textContent]);
assert.equal(page.buttons[0].dataset.state, "success");
assert.match(page.buttons[0].getAttribute("aria-label"), /已复制/);
assert.equal(page.buttons[0].disabled, false);
assert.equal(page.buttons[0].getAttribute("aria-busy"), null);
page.flushTimers();
assert.equal(page.elements.get("copy-status").textContent, "已复制完整内容。");
assert.equal(page.buttons[0].dataset.state, undefined);
assert.equal(page.buttons[0].getAttribute("aria-label"), "复制 key-0");
});
test("copy all contains only raw public keys and a final LF, never commands", async () => {
const keys = ["ssh-ed25519 AAAA key one", "ssh-rsa BBBB key two"];
const copied = [];
const page = setup({ keys, clipboard: async (text) => copied.push(text) });
await page.elements.get("copy-all-keys").dispatch("click");
assert.deepEqual(copied, [`${keys.join("\n")}\n`]);
assert.ok(!copied[0].includes("curl"));
});
test("missing or malformed release metadata falls back to prerendered keys", async () => {
for (const rawRelease of ["not json", "{}", '{"keys":[null]}']) {
const copied = [];
const page = setup({ keys: ["ssh-ed25519 AAAA fallback"], rawRelease, clipboard: async (text) => copied.push(text) });
await page.elements.get("copy-all-keys").dispatch("click");
assert.deepEqual(copied, ["ssh-ed25519 AAAA fallback\n"]);
}
assert.equal(setup({ keys: [] }).elements.get("copy-all-keys").disabled, true);
});
test("denied or unavailable clipboard offers exact text for manual copy and returns focus", async () => {
for (const clipboard of [null, async () => { throw new Error("NotAllowedError"); }]) {
const exact = "ssh-ed25519 AAAA comment <not markup>";
const page = setup({ keys: [exact], clipboard });
await page.buttons[0].dispatch("click");
const dialog = page.elements.get("copy-fallback");
const textarea = page.elements.get("fallback-text");
assert.equal(dialog.open, true);
assert.equal(textarea.value, exact);
assert.equal(textarea.selected, true);
assert.equal(textarea.focused, true);
assert.equal(page.buttons[0].dataset.state, undefined);
assert.equal(page.buttons[0].disabled, false);
page.flushTimers();
assert.match(page.elements.get("copy-status").textContent, /手动复制/);
page.elements.get("close-copy-fallback").dispatch("click");
assert.equal(dialog.open, false);
assert.equal(page.buttons[0].focused, true);
}
});
test("repeated clicks while clipboard is pending make a single request and retries can recover", async () => {
let resolveCopy;
let calls = 0;
const page = setup({ clipboard: () => {
calls += 1;
return new Promise((resolve) => { resolveCopy = resolve; });
} });
const first = page.buttons[0].dispatch("click");
assert.equal(page.buttons[0].disabled, true);
await page.buttons[0].dispatch("click");
assert.equal(calls, 1);
resolveCopy();
await first;
assert.equal(page.buttons[0].disabled, false);
const second = page.buttons[0].dispatch("click");
resolveCopy();
await second;
assert.equal(calls, 2);
assert.equal(page.buttons[0].dataset.state, "success");
});
test("unavailable or explicitly revoked key sets leave copy-all disabled and make no clipboard request", async () => {
for (const sourceStatus of ["暂不可用", "明确撤销"]) {
const copied = [];
const page = setup({ keys: [], rawRelease: JSON.stringify({ keys: [], sourceStatus }), clipboard: async (text) => copied.push(text) });
assert.equal(page.elements.get("copy-all-keys").disabled, true);
await page.elements.get("copy-all-keys").dispatch("click");
assert.deepEqual(copied, []);
}
assert.match(html, /公钥来源/);
assert.match(html, /代码版本/);
assert.match(html, /公钥版本/);
assert.match(html, /class="keys-notice" role="note"/);
});
test("CSS covers system theme, narrow viewports, touch copies, focus, and reduced motion", () => {
assert.match(css, /prefers-color-scheme: dark/);
assert.match(css, /:root\[data-theme="dark"\]/);
assert.match(css, /max-width: 680px/);
assert.match(css, /hover: hover/);
assert.match(css, /pointer: fine/);
assert.match(css, /focus-within/);
assert.match(css, /focus-visible/);
assert.match(css, /overflow-x: auto/);
assert.match(css, /prefers-reduced-motion/);
assert.doesNotMatch(script, /innerHTML|insertAdjacentHTML|\beval\s*\(|\bfetch\s*\(/);
});
test("touch and narrow layouts explicitly override desktop copy-button hiding", () => {
const override = css.match(/@media \(hover: none\), \(pointer: coarse\), \(any-pointer: coarse\), \(max-width: 680px\) \{[\s\S]*?\n\}/);
assert.ok(override, "touch, hybrid, and narrow layouts need an explicit visibility rule");
assert.match(override[0], /\.code-block > \.copy-button \{ opacity: 1; transition: none; \}/);
assert.ok(override.index > css.indexOf("@media (hover: hover) and (pointer: fine)"), "touch visibility must win the CSS cascade");
});
-66
View File
@@ -1,66 +0,0 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {createHandler} from '../cf-worker/src/router.js';
import {parsePublicKeys,makeKeyBundle} from '../cf-worker/src/key-source.js';
import {readFile} from 'node:fs/promises';
const fixture=await readFile(new URL('./fixtures/keys.pub',import.meta.url),'utf8');
const keys=await parsePublicKeys(fixture);
const revision='a'.repeat(40);
const bundle=await makeKeyBundle(keys,revision,false);
const release={revision,publishedAt:'2026-10-12',keyCount:1,allowEmpty:false,sha256:bundle.sha256,script:'#!/bin/sh\n',keysText:bundle.text,keys,fallbackSources:[],repositoryUrl:'https://github.com/test/repo',commands:[],pageTemplate:await readFile(new URL('../site/index.html',import.meta.url),'utf8')};
const fetch=createHandler(release);
const requests=[];
const env={ASSETS:{fetch:async request=>{requests.push(new URL(request.url).pathname);return new Response('asset:'+new URL(request.url).pathname,{headers:{'Content-Type':request.url.endsWith('.css')?'text/css':'text/html'}})}}};
const request=(path,init={})=>new Request('https://ssh.example.com'+path,init);
test('explicit endpoints ignore user agent and are never cached',async()=>{
for(const [path,body] of [['/script.sh',release.script],['/ssh.keys',release.keysText]]) {
const response=await fetch(request(path,{headers:{Accept:'text/html'}}),env);
assert.equal(await response.text(),body);assert.equal(response.headers.get('Cache-Control'),'no-store');assert.match(response.headers.get('Content-Type'),/^text\/plain/);
}
});
test('root serves browser HTML and legacy curl script without content mixing',async()=>{
for(const agent of ['curl/8.1.0','libcurl/8.1.0']){
const response=await fetch(request('/',{headers:{'User-Agent':agent,Accept:'*/*'}}),env);
assert.equal(await response.text(),release.script);assert.equal(response.headers.get('Vary'),'Accept, User-Agent');
}
const explicit=await fetch(request('/',{headers:{'User-Agent':'curl/8','Accept':'text/html'}}),env);
assert.match(await explicit.text(),/<html lang="zh-CN">/);
assert.match((await fetch(request('/'),env)).headers.get('Content-Type'),/^text\/html/);
});
test('metadata is current release; invalid methods and paths fail rather than returning HTML',async()=>{
const metadata=await (await fetch(request('/release.json'),env)).json();assert.equal(metadata.revision,release.revision);assert.equal(metadata.keyCount,1);
for(const path of ['/script.s','/ssh.key','/index.html','/assets/../../script.sh','/api/keys']) {
if(path==='/assets/../../script.sh') continue; // URL canonicalization is intentionally standard.
const response=await fetch(request(path),env);assert.equal(response.status,404);assert.match(response.headers.get('Content-Type'),/^text\/plain/);
}
const post=await fetch(request('/ssh.keys',{method:'POST'}),env);assert.equal(post.status,405);assert.equal(post.headers.get('Allow'),'GET, HEAD');
});
test('HEAD returns headers without body and missing page fails with 503',async()=>{
for(const path of ['/','/script.sh','/ssh.keys','/release.json']) assert.equal(await (await fetch(request(path,{method:'HEAD'}),env)).text(),'');
assert.equal((await createHandler({...release,pageTemplate:null})(request('/'),env)).status,503);
});
test('fingerprinted assets cached while security headers retained',async()=>{
const response=await fetch(request('/assets/app.012345abcdef.js'),env);assert.match(response.headers.get('Cache-Control'),/immutable/);assert.equal(response.headers.get('X-Content-Type-Options'),'nosniff');
const missing=await fetch(request('/assets/missing.js'),{ASSETS:{fetch:async()=>new Response('missing',{status:404})}});assert.equal(missing.status,404);
});
test('fallback page and key metadata represent one effective snapshot; unavailable does not revoke',async()=>{
const fallback={...release,keys:[],keyCount:0,sha256:null,keysText:null,fallbackSources:['https://keys.example.com/server.keys']};
const handler=createHandler(fallback,{fetchImpl:async()=>new Response(fixture)});
const html=await (await handler(request('/'),env)).text();
assert.ok(html.includes('备用源可用'));assert.ok(html.includes('https://keys.example.com/server.keys'));assert.ok(html.includes(keys[0].fingerprint));
const match=html.match(/<script id="release-data" type="application\/json">([\s\S]*?)<\/script>/);
const embedded=JSON.parse(match[1]);assert.equal(embedded.sha256,bundle.sha256);assert.equal(embedded.keyCount,1);assert.equal(embedded.keys[0].text,keys[0].text);
const metadata=await (await handler(request('/release.json'),env)).json();assert.equal(metadata.keyRevision,bundle.sha256);assert.equal(metadata.source.type,'fallback');
assert.equal(await (await handler(request('/ssh.keys'),env)).text(),bundle.text);
const unavailable=createHandler(fallback,{fetchImpl:async()=>new Response('',{status:503})});
for(const path of ['/ssh.keys','/release.json','/'])assert.equal((await unavailable(request(path),env)).status,503);
const failureHTML=await (await unavailable(request('/'),env)).text();assert.ok(failureHTML.includes('未发布撤销指令'));assert.ok(!failureHTML.includes('此版本明确撤销全部受管理公钥'));
assert.equal((await unavailable(request('/script.sh'),env)).status,200);
assert.equal(await (await unavailable(request('/',{headers:{'User-Agent':'curl/8'}}),env)).text(),release.script);
});
test('explicit empty revocation suppresses fallback in page, metadata and keys',async()=>{
const handler=createHandler({...release,keys:[],keyCount:0,allowEmpty:true,fallbackSources:['https://keys.example.com/server.keys']},{fetchImpl:async()=>{throw Error('must not fetch')}});
const html=await (await handler(request('/'),env)).text();assert.ok(html.includes('明确撤销'));assert.ok(!html.includes('未发布撤销指令'));
const keysText=await (await handler(request('/ssh.keys'),env)).text();assert.match(keysText,/# key-count 0\n# allow-empty yes/);
});