mirror of
https://github.com/LamGC/quickly-conf-sshd.git
synced 2026-10-12 23:33:56 +00:00
Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2f2f6db4d6 | ||
|
|
be1e50fd54 | ||
|
|
5798bf8900 | ||
|
|
f37cb51e06
|
||
|
|
e381e78c0f
|
||
|
|
a7a4f7bd6d
|
||
|
|
b5a6cdce96
|
||
|
|
81651677f2
|
@@ -0,0 +1,45 @@
|
|||||||
|
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
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
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
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
node_modules/
|
||||||
|
cf-worker/generated/
|
||||||
|
cf-worker/public/
|
||||||
|
.wrangler/
|
||||||
|
*.log
|
||||||
|
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
@@ -1,98 +1,195 @@
|
|||||||
# Quickly Configuring SSH Server
|
# Quickly Configuring SSH Server
|
||||||
快速地将 SSH 密钥部署到服务器中,并定期更新。
|
|
||||||
|
|
||||||
这个脚本可以帮你做到这些事情:
|
在 Git 仓库中独立管理服务器 SSH 公钥,通过 Cloudflare Worker 发布,再由服务器定期同步。
|
||||||
- 快速配置服务器的 SSH Server;
|
|
||||||
- 定期自动更新 SSH 公钥到服务器中;
|
- **优先使用本仓库的 `keys/authorized_keys`**;主清单为空时,可按配置顺序读取备用 HTTPS 公钥源。没有默认 GitHub 账号 SSH Keys 来源。
|
||||||
- 集中管理注册到服务器的 SSH 公钥(例如使用 Github 管理 SSH 公钥);
|
- 网页、脚本与仓库公钥一起构建发布,不再依赖 GitHub Pages;备用公钥独立标记来源和内容摘要。
|
||||||
|
- 首页支持浅色 / 深色 / 跟随系统、手机与宽屏,以及 GitHub 风格的代码块复制按钮。
|
||||||
|
- POSIX Shell 客户端只替换自己的密钥管理区块,保留手工密钥;下载或校验失败保留上次成功的内容。
|
||||||
|
|
||||||
|
> 仓库初始没有服务器公钥,生产构建会拒绝空清单。先填写你独立管理的服务器**公钥**或显式配置备用源;不要上传私钥,也不要直接拿测试夹具上线。
|
||||||
|
|
||||||
## Quick Usage
|
## Quick Usage
|
||||||
部署密钥并设置定期更新:
|
|
||||||
```bash
|
以下地址是部署配置中的示例。Fork 后先修改 `deployment.json`,填写公钥并完成自己的 Worker 部署,再使用自己的域名。执行前建议下载并检查脚本。
|
||||||
curl -s https://ssh.lamgc.me | bash -s -- -c
|
|
||||||
```
|
安装公钥并设置定时同步:
|
||||||
如果不希望自动更新密钥,可以移除 `-c` 参数:
|
|
||||||
```bash
|
```sh
|
||||||
curl -s https://ssh.lamgc.me | bash -s
|
curl -fsSL 'https://ssh.lamgc.me/script.sh' | sh -s -- -c
|
||||||
```
|
|
||||||
跳过检查 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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
> Note: 更多用法请参见脚本帮助信息~
|
新安装默认每 15 分钟同步一次;已有的有效自定义计划会保留。只更新一次:
|
||||||
|
|
||||||
|
```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
|
||||||
```
|
```
|
||||||
|
|
||||||
获取 Shell 脚本:
|
`build:test` 只用于测试/预览,使用公开的 RFC 测试公钥,并在产物中标记 `testFixture: true`。生产验证脚本拒绝该产物;生产构建只使用仓库公钥和显式配置的备用源,不允许回退到测试夹具。
|
||||||
```
|
|
||||||
https://ssh.lamgc.me/script.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
获取 Shell 脚本(使用 Curl 访问):
|
测试覆盖构建配置与注入边界、逐条密钥验证、Worker 路由/缓存、主题/复制交互、Shell 参数/管理区块/失败保留/cron/自更新等。Shell 测试只在临时 HOME、假命令和测试文件中运行,不操作真实 SSH 配置或服务。
|
||||||
```
|
|
||||||
https://ssh.lamgc.me/
|
|
||||||
```
|
|
||||||
|
|
||||||
访问本页面(不使用 Curl 访问):
|
`npm run build:test` 之后若要部署正式版本,必须重新执行 `npm run build` 与 `node scripts/verify-release.mjs`。不得直接部署测试产物。
|
||||||
```
|
|
||||||
https://ssh.lamgc.me/
|
|
||||||
```
|
|
||||||
|
|
||||||
## Install
|
## License
|
||||||
### 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 * * *";
|
|
||||||
```
|
|
||||||
|
|
||||||
### Use Wrangler
|
Apache-2.0,见 [LICENSE](LICENSE)。
|
||||||
如果你安装了 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 处理程序,然后下一步;
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
创建完成后点击 Worker 名称右边的**快速编辑**,将 `cf-worker/src/index.js` 中的内容复制到编辑框中,把原本的代码覆盖掉,然后保存;
|
|
||||||

|
|
||||||
|
|
||||||
保存后需要访问一下 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 中的域名即可。
|
|
||||||

|
|
||||||

|
|
||||||

|
|
||||||
|
|||||||
+3
-104
@@ -1,105 +1,4 @@
|
|||||||
// 改成你自己的 Github 用户名,注意是登录 Github 的那个用户名.
|
import release from '../generated/release.js';
|
||||||
const githubUserName = "LamGC";
|
import { createHandler } from './router.js';
|
||||||
// 改成你 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"
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,223 @@
|
|||||||
|
// Runtime fallback is deliberately stateless: a request never reuses earlier keys.
|
||||||
|
export const KEY_SOURCE_LIMITS = Object.freeze({sources: 5, timeoutMs: 3000, maxBytes: 1024 * 1024, maxKeys: 1000});
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
const decoder = new TextDecoder('utf-8', {fatal: true});
|
||||||
|
const keyTypes = new Set(['ssh-ed25519', 'ssh-rsa', 'ecdsa-sha2-nistp256', 'ecdsa-sha2-nistp384', 'ecdsa-sha2-nistp521', '[email protected]', '[email protected]']);
|
||||||
|
const curves = {nistp256: {name: 'P-256', size: 32}, nistp384: {name: 'P-384', size: 48}, nistp521: {name: 'P-521', size: 66}};
|
||||||
|
|
||||||
|
class SourceError extends Error {
|
||||||
|
constructor(code, status) { super(code); this.code = code; if (status !== undefined) this.status = status; }
|
||||||
|
}
|
||||||
|
export class FallbackUnavailableError extends Error {
|
||||||
|
constructor(diagnostics = []) {
|
||||||
|
super('No configured SSH key source returned a valid nonempty key set.');
|
||||||
|
this.name = 'FallbackUnavailableError';
|
||||||
|
this.code = 'KEY_SOURCES_UNAVAILABLE';
|
||||||
|
// Keep diagnostics safe to log or return: no response content, URL secrets, or exception text.
|
||||||
|
this.diagnostics = diagnostics.slice(0, KEY_SOURCE_LIMITS.sources).map(({index, code, status}) => ({index, code, ...(status === undefined ? {} : {status})}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateFallbackSources(sources = []) {
|
||||||
|
if (!Array.isArray(sources) || sources.length > KEY_SOURCE_LIMITS.sources) throw new Error('fallbackSources must contain at most five HTTPS URLs.');
|
||||||
|
const seen = new Set();
|
||||||
|
return sources.map(value => {
|
||||||
|
if (typeof value !== 'string' || value.length > 2048 || /[\s\u0000-\u001f\u007f-\u009f]/u.test(value)) throw new Error('Each fallback source must be a public-host HTTPS URL without credentials, queries, or fragments.');
|
||||||
|
let url;
|
||||||
|
try { url = new URL(value); } catch { throw new Error('Invalid fallback source URL.'); }
|
||||||
|
if (url.protocol !== 'https:' || !url.hostname || url.username || url.password || url.search || value.includes('?') || url.hash || value.includes('#') || !value.startsWith('https://')) throw new Error('Each fallback source must be a public-host HTTPS URL without credentials, queries, or fragments.');
|
||||||
|
// Configuration is trusted, but exclude local names and IP literals before any network call.
|
||||||
|
// A hostname allowlist is not a DNS-resolution guarantee; source operators remain trusted.
|
||||||
|
if (url.hostname.length > 253 || !/^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z](?:[a-z0-9-]{0,61}[a-z0-9])?$/i.test(url.hostname) || /\.(?:localhost|local|localdomain|internal|intranet|lan|home|corp|onion)$/i.test(url.hostname)) throw new Error('Fallback sources must use public HTTPS hostnames, not local names or IP addresses.');
|
||||||
|
if (seen.has(url.href)) throw new Error('Duplicate fallback source URL.');
|
||||||
|
seen.add(url.href);
|
||||||
|
return url.href;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function encodeBase64(bytes) {
|
||||||
|
let binary = '';
|
||||||
|
for (const byte of bytes) binary += String.fromCharCode(byte);
|
||||||
|
return btoa(binary);
|
||||||
|
}
|
||||||
|
function decodeBase64(encoded) {
|
||||||
|
if (!/^[A-Za-z0-9+/]+={0,2}$/.test(encoded) || encoded.length % 4 === 1) throw new Error('Invalid public key encoding.');
|
||||||
|
let decoded;
|
||||||
|
try { decoded = Uint8Array.from(atob(encoded), c => c.charCodeAt(0)); } catch { throw new Error('Invalid public key encoding.'); }
|
||||||
|
if (encodeBase64(decoded).replace(/=+$/, '') !== encoded.replace(/=+$/, '')) throw new Error('Invalid public key encoding.');
|
||||||
|
return decoded;
|
||||||
|
}
|
||||||
|
async function sha256(bytes) { return new Uint8Array(await crypto.subtle.digest('SHA-256', bytes)); }
|
||||||
|
function hex(bytes) { return Array.from(bytes, byte => byte.toString(16).padStart(2, '0')).join(''); }
|
||||||
|
|
||||||
|
class WireReader {
|
||||||
|
constructor(bytes) { this.bytes = bytes; this.offset = 0; }
|
||||||
|
string() {
|
||||||
|
if (this.offset + 4 > this.bytes.length) throw new Error('Truncated public key data.');
|
||||||
|
const length = new DataView(this.bytes.buffer, this.bytes.byteOffset + this.offset, 4).getUint32(0);
|
||||||
|
this.offset += 4;
|
||||||
|
if (length > this.bytes.length - this.offset) throw new Error('Truncated public key field.');
|
||||||
|
const value = this.bytes.subarray(this.offset, this.offset + length);
|
||||||
|
this.offset += length;
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
text() { return decoder.decode(this.string()); }
|
||||||
|
end() { if (this.offset !== this.bytes.length) throw new Error('Unexpected trailing public key data.'); }
|
||||||
|
}
|
||||||
|
function positiveMpint(bytes) {
|
||||||
|
if (!bytes.length || (bytes[0] & 128) || (bytes[0] === 0 && (bytes.length === 1 || !(bytes[1] & 128)))) throw new Error('RSA fields must be positive, minimally encoded mpints.');
|
||||||
|
return bytes[0] === 0 ? bytes.subarray(1) : bytes;
|
||||||
|
}
|
||||||
|
async function validatePoint(reader, curveName) {
|
||||||
|
if (reader.text() !== curveName) throw new Error('ECDSA curve does not match key type.');
|
||||||
|
const point = reader.string(), curve = curves[curveName];
|
||||||
|
if (point.length !== 1 + 2 * curve.size || point[0] !== 4) throw new Error('Invalid uncompressed ECDSA point.');
|
||||||
|
// Web Crypto also rejects off-curve points, rather than just checking wire lengths.
|
||||||
|
try { await crypto.subtle.importKey('raw', point, {name: 'ECDSA', namedCurve: curve.name}, false, ['verify']); }
|
||||||
|
catch { throw new Error('Invalid ECDSA point.'); }
|
||||||
|
}
|
||||||
|
function validateApplication(reader) {
|
||||||
|
const value = reader.string();
|
||||||
|
if (!value.length || value.length > 1024 || /[\u0000-\u001f\u007f-\u009f]/u.test(decoder.decode(value))) throw new Error('Invalid security-key application.');
|
||||||
|
}
|
||||||
|
async function validateWire(type, bytes) {
|
||||||
|
const reader = new WireReader(bytes);
|
||||||
|
if (reader.text() !== type) throw new Error('Public key type does not match key data.');
|
||||||
|
if (type === 'ssh-ed25519' || type === '[email protected]') {
|
||||||
|
if (reader.string().length !== 32) throw new Error('Ed25519 public keys must contain 32 bytes.');
|
||||||
|
if (type.startsWith('sk-')) validateApplication(reader);
|
||||||
|
} else if (type === 'ssh-rsa') {
|
||||||
|
const e = positiveMpint(reader.string()), n = positiveMpint(reader.string());
|
||||||
|
const bits = (n.length - 1) * 8 + 32 - Math.clz32(n[0]);
|
||||||
|
if (bits < 2048 || bits > 16384) throw new Error('RSA keys must have 2048 to 16384 bits.');
|
||||||
|
if (e.length > 8 || (e.length === 1 && e[0] < 3) || !(e.at(-1) & 1) || !(n.at(-1) & 1)) throw new Error('Invalid RSA exponent or modulus.');
|
||||||
|
} else if (type.startsWith('ecdsa-sha2-')) {
|
||||||
|
await validatePoint(reader, type.slice('ecdsa-sha2-'.length));
|
||||||
|
} else if (type === '[email protected]') {
|
||||||
|
await validatePoint(reader, 'nistp256');
|
||||||
|
validateApplication(reader);
|
||||||
|
} else throw new Error('Unsupported public key type.');
|
||||||
|
reader.end();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse plain OpenSSH keys, never authorized_keys options, certificates, or executable content. */
|
||||||
|
export async function parsePublicKeys(input, {maxKeys = KEY_SOURCE_LIMITS.maxKeys, signal} = {}) {
|
||||||
|
if (signal?.aborted) throw new SourceError('timeout');
|
||||||
|
if (!Number.isInteger(maxKeys) || maxKeys < 1 || maxKeys > KEY_SOURCE_LIMITS.maxKeys) throw new Error('Invalid public key count limit.');
|
||||||
|
if (typeof input !== 'string' || encoder.encode(input).length > KEY_SOURCE_LIMITS.maxBytes) throw new Error('Public key input exceeds its size limit.');
|
||||||
|
const normalized = input.replace(/\r\n/g, '\n');
|
||||||
|
if (/[\u0000-\u0008\u000b-\u001f\u007f-\u009f\ufffd]/u.test(normalized) || /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/u.test(normalized)) throw new Error('Public key input contains invalid control characters or Unicode.');
|
||||||
|
if (/-----BEGIN [^-\r\n]*PRIVATE KEY-----|PuTTY-User-Key-File-[0-9]+:/i.test(normalized)) throw new Error('Private keys are never accepted.');
|
||||||
|
const keys = [], seen = new Set();
|
||||||
|
for (const raw of normalized.split('\n')) {
|
||||||
|
if (signal?.aborted) throw new SourceError('timeout');
|
||||||
|
const line = raw.replace(/^[ \t]+|[ \t]+$/g, '');
|
||||||
|
if (!line || line.startsWith('#')) continue;
|
||||||
|
if (encoder.encode(line).length > 8192) throw new Error('Public key line exceeds its size limit.');
|
||||||
|
const match = /^(\S+)[ \t]+([A-Za-z0-9+/]+={0,2})(?:[ \t]+(.*))?$/u.exec(line);
|
||||||
|
if (!match || !keyTypes.has(match[1])) throw new Error('Expected a supported plain OpenSSH public key without options or certificates.');
|
||||||
|
if (keys.length >= maxKeys) throw new Error('Too many public keys.');
|
||||||
|
const [, type, encoded, rawComment = ''] = match;
|
||||||
|
const comment = rawComment.replace(/[ \t]+/g, ' ').trim();
|
||||||
|
const bytes = decodeBase64(encoded), identity = encodeBase64(bytes);
|
||||||
|
if (seen.has(identity)) throw new Error('Duplicate public key.');
|
||||||
|
await validateWire(type, bytes);
|
||||||
|
if (signal?.aborted) throw new SourceError('timeout');
|
||||||
|
seen.add(identity);
|
||||||
|
keys.push({text: `${type} ${identity}${comment ? ` ${comment}` : ''}`, type, label: comment || `Server key ${keys.length + 1}`, fingerprint: `SHA256:${encodeBase64(await sha256(bytes)).replace(/=+$/, '')}`});
|
||||||
|
}
|
||||||
|
return keys;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function makeKeyBundle(keys, revision, allowEmpty) {
|
||||||
|
if (!/^[0-9a-f]{40}$/.test(revision)) throw new Error('Release revision must be a 40-character Git commit SHA.');
|
||||||
|
if (typeof allowEmpty !== 'boolean') throw new Error('allowEmpty must be an explicit boolean.');
|
||||||
|
if (!keys.length && !allowEmpty) throw new Error('An empty key set requires explicit revocation.');
|
||||||
|
const payload = keys.map(key => `${key.text}\n`).join('');
|
||||||
|
const digest = hex(await sha256(encoder.encode(payload)));
|
||||||
|
const text = `# conf-sshd-v1\n# revision ${revision}\n# key-count ${keys.length}\n# allow-empty ${allowEmpty ? 'yes' : 'no'}\n# sha256 ${digest}\n# end-metadata\n${payload}`;
|
||||||
|
if (encoder.encode(text).length > KEY_SOURCE_LIMITS.maxBytes) throw new SourceError('too-large');
|
||||||
|
return {sha256: digest, text};
|
||||||
|
}
|
||||||
|
async function snapshot(keys, release, source) {
|
||||||
|
const bundle = await makeKeyBundle(keys, release.revision, release.allowEmpty);
|
||||||
|
return {keys, keysText: bundle.text, sha256: bundle.sha256, keyCount: keys.length, allowEmpty: release.allowEmpty, source, revision: release.revision, keyRevision: bundle.sha256, fetchedAt: new Date().toISOString()};
|
||||||
|
}
|
||||||
|
function boundedOption(options, name) {
|
||||||
|
const value = options[name] ?? KEY_SOURCE_LIMITS[name];
|
||||||
|
if (!Number.isInteger(value) || value < 1 || value > KEY_SOURCE_LIMITS[name]) throw new Error(`Invalid ${name} limit.`);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
async function readBody(response, signal, maxBytes) {
|
||||||
|
const declaredLength = response.headers.get('Content-Length');
|
||||||
|
if (declaredLength && /^\d+$/.test(declaredLength) && Number(declaredLength) > maxBytes) throw new SourceError('too-large');
|
||||||
|
if (!response.body) return '';
|
||||||
|
const reader = response.body.getReader();
|
||||||
|
// Grow one bounded buffer, so an upstream sending tiny chunks cannot grow an unbounded chunk list.
|
||||||
|
let bytes = new Uint8Array(Math.min(16384, maxBytes));
|
||||||
|
let total = 0, complete = false;
|
||||||
|
const cancel = () => { void reader.cancel().catch(() => {}); };
|
||||||
|
signal.addEventListener('abort', cancel, {once: true});
|
||||||
|
try {
|
||||||
|
while (true) {
|
||||||
|
if (signal.aborted) throw new SourceError('timeout');
|
||||||
|
const {value, done} = await reader.read();
|
||||||
|
if (signal.aborted) throw new SourceError('timeout');
|
||||||
|
if (done) { complete = true; break; }
|
||||||
|
const nextTotal = total + value.byteLength;
|
||||||
|
if (nextTotal > maxBytes) throw new SourceError('too-large');
|
||||||
|
if (nextTotal > bytes.length) {
|
||||||
|
const expanded = new Uint8Array(Math.min(maxBytes, Math.max(nextTotal, bytes.length * 2)));
|
||||||
|
expanded.set(bytes);
|
||||||
|
bytes = expanded;
|
||||||
|
}
|
||||||
|
bytes.set(value, total);
|
||||||
|
total = nextTotal;
|
||||||
|
}
|
||||||
|
try { return decoder.decode(bytes.subarray(0, total)); } catch { throw new SourceError('invalid-utf8'); }
|
||||||
|
} finally {
|
||||||
|
signal.removeEventListener('abort', cancel);
|
||||||
|
if (!complete) cancel();
|
||||||
|
reader.releaseLock();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function fetchSource(url, fetchImpl, limits) {
|
||||||
|
const controller = new AbortController();
|
||||||
|
let timer, response;
|
||||||
|
const timeout = new Promise((_, reject) => {
|
||||||
|
timer = setTimeout(() => { reject(new SourceError('timeout')); controller.abort(); }, limits.timeoutMs);
|
||||||
|
});
|
||||||
|
const operation = (async () => {
|
||||||
|
try {
|
||||||
|
response = await fetchImpl(url, {method: 'GET', redirect: 'manual', cache: 'no-store', headers: {'Accept': 'text/plain, application/octet-stream;q=0.9', 'Cache-Control': 'no-cache, no-store', 'Pragma': 'no-cache'}, signal: controller.signal});
|
||||||
|
if (controller.signal.aborted) throw new SourceError('timeout');
|
||||||
|
if (response.redirected || (response.status >= 300 && response.status < 400)) throw new SourceError('redirect', response.status);
|
||||||
|
if (!response.ok) throw new SourceError('http-error', response.status);
|
||||||
|
const body = await readBody(response, controller.signal, limits.maxBytes);
|
||||||
|
let keys;
|
||||||
|
try { keys = await parsePublicKeys(body, {maxKeys: limits.maxKeys, signal: controller.signal}); } catch { throw new SourceError('invalid-keys'); }
|
||||||
|
if (!keys.length) throw new SourceError('empty');
|
||||||
|
return keys;
|
||||||
|
} finally {
|
||||||
|
if (response?.body && !response.body.locked) void response.body.cancel().catch(() => {});
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
try { return await Promise.race([operation, timeout]); }
|
||||||
|
finally { clearTimeout(timer); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Only release-configured URLs are accepted; callers must never derive release data from a request. */
|
||||||
|
export async function resolveKeys(release, fetchImpl = globalThis.fetch, internalOptions = {}) {
|
||||||
|
if (!release || !Array.isArray(release.keys) || typeof release.allowEmpty !== 'boolean') throw new Error('Invalid deployed key release.');
|
||||||
|
if (release.keys.length) return snapshot(release.keys, release, {type: 'repository'});
|
||||||
|
// An explicit empty release means revoke. It must never be repopulated from a fallback.
|
||||||
|
if (release.allowEmpty) return snapshot([], release, {type: 'explicit-empty'});
|
||||||
|
const sources = validateFallbackSources(release.fallbackSources);
|
||||||
|
const limits = {timeoutMs: boundedOption(internalOptions, 'timeoutMs'), maxBytes: boundedOption(internalOptions, 'maxBytes'), maxKeys: boundedOption(internalOptions, 'maxKeys')};
|
||||||
|
const diagnostics = [];
|
||||||
|
for (const [index, url] of sources.entries()) {
|
||||||
|
try { return await snapshot(await fetchSource(url, fetchImpl, limits), release, {type: 'fallback', url}); }
|
||||||
|
catch (error) { diagnostics.push({index, code: error instanceof SourceError ? error.code : 'network-error', ...(error instanceof SourceError && error.status !== undefined ? {status: error.status} : {})}); }
|
||||||
|
}
|
||||||
|
throw new FallbackUnavailableError(diagnostics);
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { resolveKeys } from './key-source.js';
|
||||||
|
import { renderPage } from './view.js';
|
||||||
|
const securityHeaders = {
|
||||||
|
'X-Content-Type-Options': 'nosniff',
|
||||||
|
'Referrer-Policy': 'no-referrer',
|
||||||
|
'Content-Security-Policy': "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Repository keys are bundled; configured empty-primary fallback resolves one snapshot per response. */
|
||||||
|
export function createHandler(release, {fetchImpl = globalThis.fetch} = {}) {
|
||||||
|
return async function fetch(request, env) {
|
||||||
|
const url = new URL(request.url);
|
||||||
|
const headers = new Headers(securityHeaders);
|
||||||
|
headers.set('Cache-Control', 'no-store');
|
||||||
|
headers.set('X-Conf-Sshd-Revision', release.revision);
|
||||||
|
headers.set('X-Conf-Sshd-Test-Fixture', String(Boolean(release.testFixture)));
|
||||||
|
if (!['GET', 'HEAD'].includes(request.method)) {
|
||||||
|
headers.set('Allow', 'GET, HEAD');
|
||||||
|
return new Response('Method not allowed.\n', {status: 405, headers});
|
||||||
|
}
|
||||||
|
const respond = (body, type, status = 200) => {
|
||||||
|
headers.set('Content-Type', type);
|
||||||
|
return new Response(request.method === 'HEAD' ? null : body, {status, headers});
|
||||||
|
};
|
||||||
|
if (url.pathname === '/script.sh') return respond(release.script, 'text/plain; charset=utf-8');
|
||||||
|
let snapshot;
|
||||||
|
if (['/ssh.keys','/release.json','/'].includes(url.pathname)) {
|
||||||
|
const accept = request.headers.get('Accept') || '';
|
||||||
|
const legacyScript = url.pathname === '/' && !accept.toLowerCase().includes('text/html') && /(?:curl|libcurl)\//i.test(request.headers.get('User-Agent') || '');
|
||||||
|
if (!legacyScript) {
|
||||||
|
try {
|
||||||
|
snapshot = {...release, ...await resolveKeys(release, fetchImpl)};
|
||||||
|
headers.set('X-Conf-Sshd-Key-Digest', snapshot.sha256);
|
||||||
|
headers.set('X-Conf-Sshd-Key-Source', snapshot.source.type);
|
||||||
|
} catch (error) {
|
||||||
|
if (error.code !== 'KEY_SOURCES_UNAVAILABLE') throw error;
|
||||||
|
if (url.pathname !== '/') return respond('No valid public key source is currently available. Existing server keys must be preserved.\n', 'text/plain; charset=utf-8', 503);
|
||||||
|
snapshot = {...release,keys:[],keyCount:0,sha256:null,source:{type:'unavailable'}};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (url.pathname === '/ssh.keys') return respond(snapshot.keysText, 'text/plain; charset=utf-8');
|
||||||
|
if (url.pathname === '/release.json') {
|
||||||
|
return respond(JSON.stringify({schema:1,revision:snapshot.revision,publishedAt:snapshot.publishedAt,
|
||||||
|
keyCount:snapshot.keyCount,allowEmpty:snapshot.allowEmpty,sha256:snapshot.sha256,keyRevision:snapshot.sha256,
|
||||||
|
source:snapshot.source,fetchedAt:snapshot.fetchedAt,testFixture:Boolean(release.testFixture)})+'\n','application/json; charset=utf-8');
|
||||||
|
}
|
||||||
|
if (url.pathname === '/') {
|
||||||
|
headers.set('Vary', 'Accept, User-Agent');
|
||||||
|
const accept = request.headers.get('Accept') || '';
|
||||||
|
const agent = request.headers.get('User-Agent') || '';
|
||||||
|
// Explicit HTML wins; keep historic curl | sh entry point as a compatibility alias.
|
||||||
|
if (!accept.toLowerCase().includes('text/html') && /(?:curl|libcurl)\//i.test(agent)) {
|
||||||
|
return respond(release.script, 'text/plain; charset=utf-8');
|
||||||
|
}
|
||||||
|
if (!release.pageTemplate) return respond('Homepage unavailable.\n', 'text/plain; charset=utf-8', 503);
|
||||||
|
return respond(renderPage(release.pageTemplate,snapshot),'text/html; charset=utf-8',snapshot.source.type === 'unavailable' ? 503 : 200);
|
||||||
|
}
|
||||||
|
// Only fingerprinted static assets are exposed; API typos never fall back to HTML.
|
||||||
|
if (/^\/assets\/[a-zA-Z0-9._-]+\.(?:css|js)$/.test(url.pathname)) {
|
||||||
|
const asset = await env.ASSETS.fetch(request);
|
||||||
|
if (!asset.ok) return respond('Not found.\n', 'text/plain; charset=utf-8', 404);
|
||||||
|
for (const [key, value] of asset.headers) headers.set(key, value);
|
||||||
|
for (const [key, value] of Object.entries(securityHeaders)) headers.set(key, value);
|
||||||
|
headers.set('Cache-Control', /\.[a-f0-9]{12}\./.test(url.pathname) ? 'public, max-age=31536000, immutable' : 'no-cache');
|
||||||
|
return new Response(request.method === 'HEAD' ? null : asset.body, {status: 200, headers});
|
||||||
|
}
|
||||||
|
return respond('Not found.\n', 'text/plain; charset=utf-8', 404);
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
export const escapeHTML = value => String(value).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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,3 +1,8 @@
|
|||||||
name = "quickly-conf-ssh-worker"
|
name = "quickly-conf-ssh-worker"
|
||||||
main = "src/index.js"
|
main = "src/index.js"
|
||||||
compatibility_date = "2023-01-26"
|
compatibility_date = "2026-10-01"
|
||||||
|
|
||||||
|
[assets]
|
||||||
|
directory = "./public"
|
||||||
|
binding = "ASSETS"
|
||||||
|
run_worker_first = true
|
||||||
|
|||||||
+490
-188
@@ -1,205 +1,507 @@
|
|||||||
#!/bin/bash
|
#!/bin/sh
|
||||||
|
# conf-sshd-script-v2
|
||||||
|
# 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
|
||||||
|
|
||||||
# 默认获取 SSH key 的地方,一般是 Github.
|
say() { printf '%s\n' "$*"; }
|
||||||
sshkey_url="{{ SSH_KEY_URL }}"
|
fail() { printf 'conf-sshd: %s\n' "$*" >&2; exit 1; }
|
||||||
# 默认的 Cron 执行计划, 每天凌晨 0 点执行
|
usage() {
|
||||||
default_cron="{{ DEFAULT_CRON }}"
|
cat <<'HELP'
|
||||||
# 脚本 Url
|
Usage: sh conf-sshd.sh [options]
|
||||||
script_url="{{ SCRIPT_URL }}"
|
(no options), -o, --only-update-keys Validate and sync managed public keys only
|
||||||
|
-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
|
||||||
script_params=$*
|
sha256sum, shasum or openssl. Scheduling also requires crontab. Downloads require
|
||||||
has_param() {
|
HTTPS; existing keys survive any validation/download failure.
|
||||||
for param in $script_params; do
|
No SSH service is installed, restarted, or reloaded. Keep recovery access ready.
|
||||||
for tParam in $@; do
|
The payload SHA-256 detects inconsistency; trust comes from HTTPS and its source.
|
||||||
if [ "$tParam" == "$param" ]; then
|
HELP
|
||||||
echo "true"
|
|
||||||
return
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
done
|
|
||||||
echo "false"
|
|
||||||
}
|
}
|
||||||
|
select_mode() {
|
||||||
get_param_value() {
|
[ "$mode" = sync ] || fail 'Conflicting or duplicate operation options.'
|
||||||
local find=false
|
mode=$1
|
||||||
for param in $script_params; do
|
|
||||||
if [ "$find" == "true" ]; then
|
|
||||||
if [[ $param == -* ]]; then
|
|
||||||
return
|
|
||||||
fi
|
|
||||||
echo $param
|
|
||||||
return
|
|
||||||
fi
|
|
||||||
for tParam in $@; do
|
|
||||||
if [ "$tParam" == "$param" ]; then
|
|
||||||
find=true
|
|
||||||
break
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
done
|
|
||||||
}
|
}
|
||||||
|
while [ "$#" -gt 0 ]; do
|
||||||
# 帮助信息.
|
case $1 in
|
||||||
if [ $(has_param "-h" "--help") == "true" ]; then
|
-h|--help)
|
||||||
echo "Usage: $0 [options]"
|
if [ "$#" -ne 1 ] || [ "$mode" != sync ] || [ "$cron_requested" != no ]; then
|
||||||
echo "Options:"
|
fail '--help must be used alone.'
|
||||||
echo " -h, --help Print this help message."
|
|
||||||
echo ""
|
|
||||||
echo "Available to any user: "
|
|
||||||
echo " -c, --cron [cron | false] Configure Crontab to automatically update ssh keys,"
|
|
||||||
echo " Cron expression can be specified, If false is specified, "
|
|
||||||
echo " Crontab settings will be deleted automatically."
|
|
||||||
echo ""
|
|
||||||
echo " -o, --only-update-keys Only update SSH keys, do not configure ssh server."
|
|
||||||
echo " -u, --update-self Update this script to the latest version."
|
|
||||||
echo ""
|
|
||||||
echo "only available when the script is executed as root:"
|
|
||||||
echo " -n, --no-install-sshd Do not install SSH Server."
|
|
||||||
echo " -p, --allow-root-passwd <yes | no> Allow Root to log in with a password."
|
|
||||||
echo ""
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
update_sshkeys() {
|
|
||||||
if [ "$sshkey_url" == "" ]; then
|
|
||||||
echo "Please specify the URL of the SSH public key."
|
|
||||||
exit 1
|
|
||||||
fi
|
fi
|
||||||
echo "Downloading SSH public key from '$sshkey_url'"
|
usage; exit 0 ;;
|
||||||
mkdir -p ~/.ssh
|
-o|--only-update-keys) select_mode keys ;;
|
||||||
local ssh_keys=$(curl -s $sshkey_url)
|
-u|--update-self) select_mode update ;;
|
||||||
if [ $? -ne 0 ] || [ "$ssh_keys" == "" ]; then
|
--uninstall) select_mode uninstall ;;
|
||||||
echo "Failed to download SSH public key at $(date '+%Y-%m-%d %H:%M:%S')"
|
--status) select_mode status ;;
|
||||||
exit 1
|
-p|--allow-root-passwd)
|
||||||
fi
|
select_mode config
|
||||||
echo "-------------------- SSH Keys --------------------"
|
[ "$#" -ge 2 ] || fail '-p requires yes or no.'
|
||||||
echo "$ssh_keys"
|
shift; case $1 in yes|no) root_password=$1 ;; *) fail '-p requires yes or no.' ;; esac ;;
|
||||||
echo "--------------------------------------------------"
|
-c|--cron)
|
||||||
echo "$ssh_keys" > ~/.ssh/authorized_keys
|
[ "$cron_requested" = no ] || fail 'Duplicate -c option.'
|
||||||
chmod 600 ~/.ssh/authorized_keys
|
cron_requested=yes
|
||||||
# 输出更新成功,需要附带时间日期
|
if [ "$#" -gt 1 ]; then
|
||||||
echo "SSH public key updated successfully at $(date '+%Y-%m-%d %H:%M:%S')"
|
case $2 in -*) : ;; *) shift; cron_value=$1; [ -n "$cron_value" ] || fail 'Empty cron expression.' ;; esac
|
||||||
}
|
fi ;;
|
||||||
|
--dry-run) [ "$dry_run" = no ] || fail 'Duplicate --dry-run.'; dry_run=yes ;;
|
||||||
|
-n|--no-install-sshd) [ "$no_install" = no ] || fail 'Duplicate -n.'; no_install=yes ;;
|
||||||
|
*) fail "Unknown option or unexpected argument: $1" ;;
|
||||||
|
esac
|
||||||
|
shift
|
||||||
|
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."; }
|
||||||
if [ $(has_param "-o" "--only-update-keys") == "true" ]; then
|
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
|
||||||
update_sshkeys
|
uid=$(id -u)
|
||||||
exit 0
|
# Reject paths that could alter line-based cron records. Spaces and quotes are safe.
|
||||||
fi
|
case ${HOME:-} in /*) : ;; *) fail 'HOME must be an absolute directory.' ;; esac
|
||||||
|
[ "$(printf '%s' "$HOME" | tr -d '\040-\176\200-\377'; printf .)" = . ] || fail 'HOME must not contain control characters.'
|
||||||
|
case $HOME in *\\*) fail 'HOME paths containing backslashes are not supported.' ;; esac
|
||||||
|
HOME=${HOME%/}
|
||||||
|
[ -n "$HOME" ] || fail 'HOME must not be the filesystem root.'
|
||||||
|
state=$HOME/.conf-sshd
|
||||||
|
ssh_dir=$HOME/.ssh
|
||||||
|
auth_file=$ssh_dir/authorized_keys
|
||||||
|
target_script=$state/conf-sshd.sh
|
||||||
|
|
||||||
# 检查是否指定了 --update-self
|
# GNU/Linux and BSD/macOS stat. No filesystem changes precede ownership checks.
|
||||||
if [ $(has_param "-u" "--update-self") == "true" ]; then
|
if stat -c '%u %a %h' "$HOME" >/dev/null 2>&1; then
|
||||||
echo "Updating conf-sshd script..."
|
stat_info() { stat -c '%u %a %h' "$1"; }
|
||||||
cp $0 ~/.conf-sshd/conf-sshd.sh.bak
|
|
||||||
curl -s $script_url > $0 || cp ~/.conf-sshd/conf-sshd.sh.bak $0 && echo "Script update failed at $(date '+%Y-%m-%d %H:%M:%S')" && exit 1
|
|
||||||
chmod +x ~/.conf-sshd/conf-sshd.sh
|
|
||||||
echo "Script updated successfully at $(date '+%Y-%m-%d %H:%M:%S')"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
# 检查 /usr/sbin/sshd 是否存在,且 /usr/sbin/sshd 执行后退出代码为 0
|
|
||||||
/usr/sbin/sshd -T > /dev/null
|
|
||||||
if [ $? -ne 0 ] && [ $(has_param "-n" "--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
|
|
||||||
fi
|
|
||||||
echo "The ssh server has been installed."
|
|
||||||
else
|
|
||||||
echo "The ssh server is not installed, but the script is executed as a non-root user and cannot be installed."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
else
|
else
|
||||||
echo "The ssh server is already installed."
|
stat_info() { stat -f '%u %Lp %l' "$1"; }
|
||||||
|
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
|
fi
|
||||||
|
|
||||||
# 检查是否指定了 --allow-root-passwd
|
cleanup() {
|
||||||
if [ $(has_param "-p" "--allow-root-passwd") == "true" ]; then
|
result=$?
|
||||||
# 检查当前用户是否为 root
|
trap - 0 HUP INT TERM
|
||||||
if [ $(id -u) -eq 0 ]; then
|
old_ifs=$IFS; IFS='
|
||||||
# 获取参数值
|
'
|
||||||
allow_root_passwd=$(get_param_value "-p" "--allow-root-passwd" | tr '[:upper:]' '[:lower:]')
|
for path in $temps; do rm -f "$path"; done
|
||||||
if [ "$allow_root_passwd" == "yes" ]; then
|
IFS=$old_ifs
|
||||||
# 设置允许 root 使用密码登录
|
if [ "$lock_owned" = yes ]; then rm -f "$state/lock/pid"; rmdir "$state/lock" 2>/dev/null || :; fi
|
||||||
sed -i 's/^#?PermitRootLogin.*/PermitRootLogin yes/g' /etc/ssh/sshd_config
|
if [ "$dry_run" = yes ] || [ "$mode" = uninstall ]; then
|
||||||
echo "Root user is allowed to log in with password."
|
if [ "$created_ssh" = yes ]; then rmdir "$ssh_dir" 2>/dev/null || :; fi
|
||||||
elif [ "$allow_root_passwd" == "no" ]; then
|
if [ "$created_state" = yes ] || [ "$mode" = uninstall ]; then rmdir "$state" 2>/dev/null || :; fi
|
||||||
# 设置禁止 root 使用密码登录
|
|
||||||
sed -i 's/^#?PermitRootLogin.*/PermitRootLogin prohibit-password/g' /etc/ssh/sshd_config
|
|
||||||
echo "Root user is prohibited from logging in with password."
|
|
||||||
else
|
|
||||||
echo "Please specify whether to allow root to log in with a password."
|
|
||||||
exit 1
|
|
||||||
fi
|
fi
|
||||||
else
|
exit "$result"
|
||||||
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
|
new_temp() {
|
||||||
fi
|
TEMP=$(mktemp "$1/.conf-sshd.XXXXXX") || fail 'Cannot create secure temporary file.'
|
||||||
fi
|
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
|
||||||
update_sshkeys
|
[ "$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
|
||||||
# 检查是否指定了 --cron
|
if [ -f "$auth_file" ]; then say "Authorized keys: $auth_file (not modified)"; else say 'No authorized_keys file.'; fi
|
||||||
if [ $(has_param "-c" "--cron") == "true" ]; then
|
if command -v crontab >/dev/null 2>&1; then
|
||||||
# 检查 Crontab 是否已安装
|
if status_cron=$(crontab -l 2>&1); then
|
||||||
if [ "$(command -v crontab)" == "" ]; then
|
say 'Current crontab (read-only):'; say "$status_cron"
|
||||||
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
|
|
||||||
fi
|
|
||||||
echo "The crontab has been installed."
|
|
||||||
else
|
else
|
||||||
echo "The crontab is not installed, but the script is executed as a non-root user and cannot be installed."
|
case $status_cron in 'no crontab for '*) say 'No crontab is installed for this user.' ;;
|
||||||
exit 1
|
*) fail "Cannot read current crontab: $status_cron" ;; esac
|
||||||
fi
|
fi
|
||||||
else
|
else say 'crontab is not installed.'; fi
|
||||||
echo "The crontab is already installed."
|
|
||||||
fi
|
|
||||||
cron=$(get_param_value "-c" "--cron" | tr '[:upper:]' '[:lower:]')
|
|
||||||
if [ "$cron" == "false" ]; then
|
|
||||||
# 检查 Crontab 是否已经设置
|
|
||||||
if [ "$(crontab -l | grep "conf-sshd.sh")" == "" ]; then
|
|
||||||
echo "Crontab will not be configured."
|
|
||||||
exit 0
|
exit 0
|
||||||
else
|
|
||||||
crontab -l | grep -v "conf-sshd.sh" | crontab -
|
|
||||||
echo "Crontab has been removed."
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
if [ "$cron" == "" ]; then
|
|
||||||
cron=$default_cron
|
|
||||||
fi
|
|
||||||
# 将当前脚本移动到 ~/.conf-sshd/conf-sshd.sh 中.
|
|
||||||
mkdir -p ~/.conf-sshd
|
|
||||||
# 检查当前脚本是否为文件
|
|
||||||
if [ ! -f $0 ]; then
|
|
||||||
echo "Downloading conf-sshd script..."
|
|
||||||
curl -o ~/.conf-sshd/conf-sshd.sh $script_url
|
|
||||||
else
|
|
||||||
echo "Copying conf-sshd script..."
|
|
||||||
cp $0 ~/.conf-sshd/conf-sshd.sh
|
|
||||||
fi
|
|
||||||
chmod +x ~/.conf-sshd/conf-sshd.sh
|
|
||||||
echo "Install conf-sshd script successfully."
|
|
||||||
# 将当前脚本追加到当前用户的 Crontab 中
|
|
||||||
crontab -l > ~/.conf-sshd/crontab.old
|
|
||||||
echo "$cron \"/bin/bash ~/.conf-sshd/conf-sshd.sh -o\" >> ~/.conf-sshd/run.log" >> ~/.conf-sshd/crontab.old
|
|
||||||
crontab ~/.conf-sshd/crontab.old
|
|
||||||
rm ~/.conf-sshd/crontab.old
|
|
||||||
echo "Crontab has been configured.(Cron: '$cron')"
|
|
||||||
fi
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
[ -d "$state" ] || { mkdir "$state"; created_state=yes; }
|
||||||
|
if [ "$dry_run" = no ]; then chmod 700 "$state"; fi
|
||||||
|
trap cleanup 0
|
||||||
|
trap 'exit 129' HUP
|
||||||
|
trap 'exit 130' INT
|
||||||
|
trap 'exit 143' TERM
|
||||||
|
if mkdir "$state/lock" 2>/dev/null; then
|
||||||
|
lock_owned=yes; printf '%s\n' "$$" > "$state/lock/pid"
|
||||||
|
else
|
||||||
|
fail "Another run holds $state/lock. If it is stale, verify the recorded process before removing that directory."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Inputs are origin-only build constants; also reject unbuilt or corrupted scripts.
|
||||||
|
validate_build() {
|
||||||
|
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.'
|
||||||
|
validate_cron "$DEFAULT_CRON" >/dev/null || fail 'Invalid or unbuilt default cron configuration.'
|
||||||
|
}
|
||||||
|
download() {
|
||||||
|
need curl
|
||||||
|
# No redirects: a configured origin must serve these endpoints directly.
|
||||||
|
curl --fail --silent --show-error --proto '=https' --tlsv1.2 \
|
||||||
|
--connect-timeout 10 --max-time 30 --retry 2 --retry-delay 1 \
|
||||||
|
--retry-max-time 65 --max-filesize 1048576 --output "$2" "$1" || fail "Download failed: $1 (existing files unchanged)."
|
||||||
|
[ "$(wc -c < "$2" | tr -d ' ')" -le 1048576 ] || fail 'Downloaded response is too large.'
|
||||||
|
}
|
||||||
|
sha256() {
|
||||||
|
if command -v sha256sum >/dev/null 2>&1; then sha256sum "$1" | awk '{print $1}'
|
||||||
|
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
|
||||||
|
}
|
||||||
|
check_expected_file() {
|
||||||
|
if [ "$2" = absent ]; then
|
||||||
|
if [ -e "$1" ] || [ -L "$1" ]; then
|
||||||
|
fail "File appeared concurrently; retry after reviewing: $1"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
if [ ! -f "$1" ] || ! cmp -s "$1" "$2"; then
|
||||||
|
fail "File changed concurrently; retry after reviewing: $1"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
replace_file() {
|
||||||
|
destination=$1; staged=$2; file_mode=$3
|
||||||
|
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.
|
||||||
|
# Unexpected cron errors and ambiguous/malformed managed entries fail closed.
|
||||||
|
prepare_cron() {
|
||||||
|
need crontab
|
||||||
|
case $HOME in *%*) fail 'Cron does not safely support a percent sign in HOME.' ;; esac
|
||||||
|
new_temp "$state"; old_cron=$TEMP
|
||||||
|
new_temp "$state"; cron_errors=$TEMP
|
||||||
|
if crontab -l > "$old_cron" 2> "$cron_errors"; then :
|
||||||
|
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() {
|
||||||
|
need ssh-keygen
|
||||||
|
validate_build
|
||||||
|
new_temp "$state"; response=$TEMP
|
||||||
|
download "$BASE_URL/ssh.keys" "$response"
|
||||||
|
last_lf "$response" || fail 'Key response must end with LF.'
|
||||||
|
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
|
||||||
|
# Validated awk output: two decimal offsets.
|
||||||
|
# shellcheck disable=SC2086
|
||||||
|
set -- $positions
|
||||||
|
dd if="$auth_snapshot" bs=1 count="$1" 2>/dev/null > "$new_auth"
|
||||||
|
{
|
||||||
|
printf '%s\n' "$BEGIN_KEYS"
|
||||||
|
cat "$payload"
|
||||||
|
printf '%s\n' "$END_KEYS"
|
||||||
|
dd if="$auth_snapshot" bs=1 skip="$2" 2>/dev/null
|
||||||
|
} >> "$new_auth"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
{
|
||||||
|
printf '%s\n' "$BEGIN_KEYS"
|
||||||
|
cat "$payload"
|
||||||
|
printf '%s\n' "$END_KEYS"
|
||||||
|
} > "$new_auth"
|
||||||
|
fi
|
||||||
|
check_path "$auth_file" file
|
||||||
|
check_expected_file "$auth_file" "$auth_snapshot"
|
||||||
|
if [ "$auth_snapshot" != absent ] && cmp -s "$auth_snapshot" "$new_auth"; then
|
||||||
|
if [ "$dry_run" = no ]; then chmod 600 "$auth_file"; fi
|
||||||
|
say "Keys unchanged ($key_count keys, revision $revision)."
|
||||||
|
else
|
||||||
|
replace_file "$auth_file" "$new_auth" 600 "$auth_snapshot"
|
||||||
|
say "Validated $key_count keys at revision $revision."
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
sync_keys
|
||||||
|
if [ "$cron_requested" = yes ]; then install_script; apply_cron "$cron_value"; fi ;;
|
||||||
|
esac
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"baseUrl": "https://ssh.lamgc.me",
|
||||||
|
"repositoryUrl": "https://github.com/LamGC/quickly-conf-sshd",
|
||||||
|
"defaultCron": "*/15 * * * *",
|
||||||
|
"allowEmpty": false,
|
||||||
|
"fallbackSources": []
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
# 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.
|
||||||
Generated
+1561
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
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; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
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).`);
|
||||||
|
}
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
(() => {
|
||||||
|
"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");
|
||||||
|
})();
|
||||||
@@ -0,0 +1,228 @@
|
|||||||
|
: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; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
<!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">>_</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>
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import { createServer } from "node:http";
|
||||||
|
import { readFile } from "node:fs/promises";
|
||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import { commandsFor, renderPage } from "../cf-worker/src/view.js";
|
||||||
|
|
||||||
|
/** Real page rendering and real local assets, with no live Worker or upstream calls. */
|
||||||
|
export async function startBrowserFixture() {
|
||||||
|
const [template, style, script, fixtureKey] = await Promise.all([
|
||||||
|
readFile(new URL("../site/index.html", import.meta.url), "utf8"),
|
||||||
|
readFile(new URL("../site/assets/style.css", import.meta.url)),
|
||||||
|
readFile(new URL("../site/assets/app.js", import.meta.url)),
|
||||||
|
readFile(new URL("./fixtures/keys.pub", import.meta.url), "utf8"),
|
||||||
|
]);
|
||||||
|
const [type, encoded] = fixtureKey.trim().split(/\s+/);
|
||||||
|
const comment = 'CI test <img src=x onerror="window.__injected=true"> & "quoted" 中文 ' + "long-comment-".repeat(90);
|
||||||
|
const text = `${type} ${encoded} ${comment}`;
|
||||||
|
const key = {
|
||||||
|
type,
|
||||||
|
text,
|
||||||
|
label: comment,
|
||||||
|
fingerprint: "SHA256:" + createHash("sha256").update(Buffer.from(encoded, "base64")).digest("base64").replace(/=+$/, ""),
|
||||||
|
};
|
||||||
|
const config = { baseUrl: "https://keys.example.test", repositoryUrl: "https://github.com/example/quickly-conf-sshd" };
|
||||||
|
const base = {
|
||||||
|
schema: 1,
|
||||||
|
revision: "0123456789abcdef0123456789abcdef01234567",
|
||||||
|
publishedAt: "2026-10-12T12:34:56.000Z",
|
||||||
|
keyCount: 1,
|
||||||
|
allowEmpty: false,
|
||||||
|
sha256: createHash("sha256").update(text + "\n").digest("hex"),
|
||||||
|
testFixture: true,
|
||||||
|
...config,
|
||||||
|
commands: commandsFor(config),
|
||||||
|
keys: [key],
|
||||||
|
};
|
||||||
|
const snapshots = {
|
||||||
|
repository: { ...base, source: { type: "repository" } },
|
||||||
|
fallback: { ...base, source: { type: "fallback", url: "https://keys.example.test/team.keys?comment=<script>window.__injected=true</script>" } },
|
||||||
|
unavailable: { ...base, keys: [], keyCount: 0, sha256: null, source: { type: "unavailable" } },
|
||||||
|
pending: { ...base, keys: [], keyCount: 0, sha256: null, source: { type: "pending" } },
|
||||||
|
"explicit-empty": { ...base, allowEmpty: true, keys: [], keyCount: 0, sha256: createHash("sha256").update("").digest("hex"), source: { type: "explicit-empty" } },
|
||||||
|
};
|
||||||
|
const pages = new Map(Object.entries(snapshots).map(([name, snapshot]) => ["/" + name, renderPage(template, snapshot)]));
|
||||||
|
const requests = [];
|
||||||
|
const server = createServer((request, response) => {
|
||||||
|
const pathname = new URL(request.url, "http://127.0.0.1").pathname;
|
||||||
|
requests.push(pathname);
|
||||||
|
response.setHeader("Cache-Control", "no-store");
|
||||||
|
response.setHeader("X-Content-Type-Options", "nosniff");
|
||||||
|
response.setHeader("Content-Security-Policy", "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'");
|
||||||
|
if (pathname === "/assets/style.css" || pathname === "/assets/app.js") {
|
||||||
|
const isStyle = pathname.endsWith(".css");
|
||||||
|
response.setHeader("Content-Type", isStyle ? "text/css; charset=utf-8" : "text/javascript; charset=utf-8");
|
||||||
|
response.end(isStyle ? style : script);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (pathname === "/favicon.ico") {
|
||||||
|
response.statusCode = 204;
|
||||||
|
response.end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const page = pages.get(pathname === "/" ? "/repository" : pathname);
|
||||||
|
if (page) {
|
||||||
|
response.statusCode = pathname === "/unavailable" ? 503 : 200;
|
||||||
|
response.setHeader("Content-Type", "text/html; charset=utf-8");
|
||||||
|
response.end(page);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
response.statusCode = 404;
|
||||||
|
response.end("Fixture route not found\n");
|
||||||
|
});
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
server.once("error", reject);
|
||||||
|
server.listen(0, "127.0.0.1", () => { server.off("error", reject); resolve(); });
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
origin: `http://127.0.0.1:${server.address().port}`,
|
||||||
|
snapshots,
|
||||||
|
requests,
|
||||||
|
close: () => new Promise((resolve, reject) => {
|
||||||
|
server.close((error) => error ? reject(error) : resolve());
|
||||||
|
server.closeAllConnections();
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,224 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { access, mkdir } from "node:fs/promises";
|
||||||
|
import { constants } from "node:fs";
|
||||||
|
import { delimiter, join } from "node:path";
|
||||||
|
import test from "node:test";
|
||||||
|
import { startBrowserFixture } from "./browser-fixture.mjs";
|
||||||
|
|
||||||
|
async function browserExecutable() {
|
||||||
|
const candidates = process.env.CHROMIUM_PATH ? [process.env.CHROMIUM_PATH] : [
|
||||||
|
"/usr/bin/google-chrome", "/usr/bin/google-chrome-stable", "/usr/bin/chromium", "/usr/bin/chromium-browser",
|
||||||
|
...(process.env.PATH || "").split(delimiter).flatMap((directory) => ["google-chrome", "google-chrome-stable", "chromium", "chromium-browser"].map((name) => join(directory, name))),
|
||||||
|
];
|
||||||
|
for (const candidate of new Set(candidates)) {
|
||||||
|
try { await access(candidate, constants.X_OK); return candidate; } catch { /* Check the next installed browser. */ }
|
||||||
|
}
|
||||||
|
throw new Error("BROWSER_TESTS=1 requires an installed Chromium/Google Chrome. Set CHROMIUM_PATH to its executable.");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function capture(page, name) {
|
||||||
|
if (!process.env.BROWSER_ARTIFACTS_DIR) return;
|
||||||
|
await mkdir(process.env.BROWSER_ARTIFACTS_DIR, { recursive: true });
|
||||||
|
await page.screenshot({ path: join(process.env.BROWSER_ARTIFACTS_DIR, name + ".png"), fullPage: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function guardedContext(browser, fixture, options = {}) {
|
||||||
|
const context = await browser.newContext(options);
|
||||||
|
const externalRequests = [];
|
||||||
|
await context.route("**/*", (route) => {
|
||||||
|
if (new URL(route.request().url()).origin === fixture.origin) return route.continue();
|
||||||
|
externalRequests.push(route.request().url());
|
||||||
|
return route.abort("blockedbyclient");
|
||||||
|
});
|
||||||
|
return { context, externalRequests };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function installClipboardMock(context) {
|
||||||
|
await context.addInitScript(() => {
|
||||||
|
window.__clipboardTest = { values: [], reject: false };
|
||||||
|
Object.defineProperty(navigator, "clipboard", {
|
||||||
|
configurable: true,
|
||||||
|
value: { writeText: async (text) => {
|
||||||
|
if (window.__clipboardTest.reject) throw new DOMException("Fixture clipboard denied", "NotAllowedError");
|
||||||
|
window.__clipboardTest.values.push(text);
|
||||||
|
} },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function noPageOverflow(page, width) {
|
||||||
|
const metrics = await page.evaluate(() => ({
|
||||||
|
viewport: window.innerWidth,
|
||||||
|
document: document.documentElement.scrollWidth,
|
||||||
|
body: document.body.scrollWidth,
|
||||||
|
}));
|
||||||
|
assert.equal(metrics.viewport, width);
|
||||||
|
assert.ok(metrics.document <= width && metrics.body <= width, JSON.stringify(metrics));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Opt in on a CI runner with a real browser. Ordinary npm test requires no browser or downloads.
|
||||||
|
test("real browser homepage integration", { skip: process.env.BROWSER_TESTS !== "1", timeout: 180_000 }, async (t) => {
|
||||||
|
const { chromium } = await import("playwright-core");
|
||||||
|
const executablePath = await browserExecutable();
|
||||||
|
const fixture = await startBrowserFixture();
|
||||||
|
let browser;
|
||||||
|
try {
|
||||||
|
browser = await chromium.launch({ executablePath, headless: true, args: ["--no-sandbox"] });
|
||||||
|
|
||||||
|
for (const width of [390, 1440]) {
|
||||||
|
await t.test(`${width}px layout, themes, clipboard, keyboard and fallback`, async () => {
|
||||||
|
const { context, externalRequests } = await guardedContext(browser, fixture, {
|
||||||
|
viewport: { width, height: 900 },
|
||||||
|
colorScheme: "light",
|
||||||
|
hasTouch: width < 600,
|
||||||
|
isMobile: width < 600,
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await installClipboardMock(context);
|
||||||
|
const page = await context.newPage();
|
||||||
|
const errors = [];
|
||||||
|
page.on("pageerror", (error) => errors.push(error.message));
|
||||||
|
await page.goto(fixture.origin + "/repository");
|
||||||
|
await page.waitForSelector("html.enhanced");
|
||||||
|
await noPageOverflow(page, width);
|
||||||
|
assert.equal(await page.locator("#key-0").textContent(), fixture.snapshots.repository.keys[0].text);
|
||||||
|
assert.equal(await page.locator(".key-card img").count(), 0, "key comments must remain text");
|
||||||
|
assert.equal(await page.evaluate(() => window.__injected), undefined);
|
||||||
|
assert.equal(await page.locator(".key-card pre").evaluate((pre) => pre.scrollWidth > pre.clientWidth), true);
|
||||||
|
assert.equal(await page.locator("#theme-picker").inputValue(), "system");
|
||||||
|
assert.equal(await page.locator("html").getAttribute("data-theme"), null);
|
||||||
|
const lightBackground = await page.locator("body").evaluate((body) => getComputedStyle(body).backgroundColor);
|
||||||
|
await capture(page, `homepage-${width}-light`);
|
||||||
|
|
||||||
|
await page.selectOption("#theme-picker", "dark");
|
||||||
|
assert.equal(await page.locator("html").getAttribute("data-theme"), "dark");
|
||||||
|
const darkBackground = await page.locator("body").evaluate((body) => getComputedStyle(body).backgroundColor);
|
||||||
|
assert.notEqual(darkBackground, lightBackground);
|
||||||
|
await capture(page, `homepage-${width}-dark`);
|
||||||
|
await page.reload();
|
||||||
|
assert.equal(await page.locator("#theme-picker").inputValue(), "dark");
|
||||||
|
assert.equal(await page.locator("html").getAttribute("data-theme"), "dark");
|
||||||
|
await page.emulateMedia({ colorScheme: "dark" });
|
||||||
|
await page.selectOption("#theme-picker", "light");
|
||||||
|
assert.equal(await page.locator("body").evaluate((body) => getComputedStyle(body).backgroundColor), lightBackground);
|
||||||
|
await page.selectOption("#theme-picker", "system");
|
||||||
|
assert.equal(await page.locator("html").getAttribute("data-theme"), null);
|
||||||
|
assert.equal(await page.locator("body").evaluate((body) => getComputedStyle(body).backgroundColor), darkBackground);
|
||||||
|
await page.emulateMedia({ colorScheme: "light" });
|
||||||
|
assert.equal(await page.locator("body").evaluate((body) => getComputedStyle(body).backgroundColor), lightBackground);
|
||||||
|
assert.equal(await page.evaluate(() => localStorage.getItem("quickly-conf-sshd-theme")), "system");
|
||||||
|
|
||||||
|
const keyButton = page.locator('[data-copy-target="key-0"]');
|
||||||
|
if (width < 600) {
|
||||||
|
assert.equal(await keyButton.evaluate((button) => getComputedStyle(button).opacity), "1", "touch copy buttons stay visible");
|
||||||
|
} else {
|
||||||
|
await page.locator("#page-title").hover();
|
||||||
|
await page.waitForFunction(() => getComputedStyle(document.querySelector('[data-copy-target="key-0"]')).opacity === "0");
|
||||||
|
}
|
||||||
|
await keyButton.focus();
|
||||||
|
await page.waitForFunction(() => getComputedStyle(document.querySelector('[data-copy-target="key-0"]')).opacity === "1");
|
||||||
|
await page.keyboard.press("Enter");
|
||||||
|
await page.waitForFunction(() => document.querySelector('[data-copy-target="key-0"]').dataset.state === "success");
|
||||||
|
assert.equal(await page.evaluate(() => window.__clipboardTest.values[0]), fixture.snapshots.repository.keys[0].text);
|
||||||
|
assert.match(await keyButton.getAttribute("aria-label"), /已复制/);
|
||||||
|
await page.waitForFunction(() => document.getElementById("copy-status").textContent.includes("已复制"));
|
||||||
|
|
||||||
|
await page.locator("#copy-all-keys").click();
|
||||||
|
const allKeys = fixture.snapshots.repository.keys.map((key) => key.text).join("\n") + "\n";
|
||||||
|
assert.equal(await page.evaluate(() => window.__clipboardTest.values[1]), allKeys);
|
||||||
|
assert.equal(await page.locator("#copy-all-commands").count(), 0);
|
||||||
|
await page.locator("details summary").click();
|
||||||
|
await page.locator('[data-copy-target="command-uninstall"]').focus();
|
||||||
|
await page.keyboard.press("Enter");
|
||||||
|
const uninstall = fixture.snapshots.repository.commands.find((command) => command.id === "uninstall").command;
|
||||||
|
assert.equal(await page.evaluate(() => window.__clipboardTest.values.at(-1)), uninstall);
|
||||||
|
await noPageOverflow(page, width);
|
||||||
|
|
||||||
|
await page.evaluate(() => { window.__clipboardTest.reject = true; });
|
||||||
|
await keyButton.focus();
|
||||||
|
await page.keyboard.press("Enter");
|
||||||
|
await page.waitForSelector("#copy-fallback[open]");
|
||||||
|
assert.equal(await page.locator("#fallback-text").inputValue(), fixture.snapshots.repository.keys[0].text);
|
||||||
|
assert.deepEqual(await page.locator("#fallback-text").evaluate((textarea) => ({
|
||||||
|
start: textarea.selectionStart, end: textarea.selectionEnd, focused: document.activeElement === textarea,
|
||||||
|
})), { start: 0, end: fixture.snapshots.repository.keys[0].text.length, focused: true });
|
||||||
|
assert.equal(await keyButton.getAttribute("data-state"), null, "failed copy must not show success");
|
||||||
|
await capture(page, `homepage-${width}-manual-copy`);
|
||||||
|
await page.keyboard.press("Escape");
|
||||||
|
assert.equal(await page.locator("#copy-fallback").evaluate((dialog) => dialog.open), false);
|
||||||
|
assert.equal(await keyButton.evaluate((button) => document.activeElement === button), true);
|
||||||
|
await page.keyboard.press("Enter");
|
||||||
|
await page.waitForSelector("#copy-fallback[open]");
|
||||||
|
await page.locator("#close-copy-fallback").click();
|
||||||
|
assert.equal(await keyButton.evaluate((button) => document.activeElement === button), true);
|
||||||
|
assert.deepEqual(errors, []);
|
||||||
|
assert.deepEqual(externalRequests, []);
|
||||||
|
} finally { await context.close(); }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await t.test("all source states render the same snapshot into metadata, keys and copy data", async () => {
|
||||||
|
const { context, externalRequests } = await guardedContext(browser, fixture, { viewport: { width: 390, height: 844 } });
|
||||||
|
try {
|
||||||
|
await installClipboardMock(context);
|
||||||
|
const page = await context.newPage();
|
||||||
|
for (const [state, sourceText] of [["repository", "可用"], ["fallback", "备用源可用"], ["unavailable", "暂不可用"], ["pending", "暂不可用"], ["explicit-empty", "明确撤销"]]) {
|
||||||
|
const response = await page.goto(fixture.origin + "/" + state);
|
||||||
|
assert.equal(response.status(), state === "unavailable" ? 503 : 200);
|
||||||
|
await page.waitForSelector("html.enhanced");
|
||||||
|
assert.equal(await page.locator(".source-status").textContent(), sourceText);
|
||||||
|
const snapshot = fixture.snapshots[state];
|
||||||
|
assert.equal(await page.locator(".key-count").textContent(), String(snapshot.keys.length));
|
||||||
|
assert.equal(await page.locator("#copy-all-keys").isDisabled(), snapshot.keys.length === 0);
|
||||||
|
const embedded = await page.locator("#release-data").evaluate((element) => JSON.parse(element.textContent));
|
||||||
|
assert.equal(embedded.source.type, state);
|
||||||
|
assert.deepEqual(embedded.keys, snapshot.keys);
|
||||||
|
assert.equal(embedded.keyRevision, snapshot.sha256);
|
||||||
|
assert.equal(await page.locator(".release-revision").first().textContent(), snapshot.revision.slice(0, 12));
|
||||||
|
assert.equal(await page.locator(".release-revision").nth(1).textContent(), snapshot.sha256?.slice(0, 12) || "—");
|
||||||
|
if (state === "fallback") {
|
||||||
|
assert.equal(await page.locator(".source-label").textContent(), "备用源:" + snapshot.source.url);
|
||||||
|
assert.equal(await page.locator(".source-label script").count(), 0);
|
||||||
|
await page.locator("#copy-all-keys").click();
|
||||||
|
assert.equal(await page.evaluate(() => window.__clipboardTest.values[0]), snapshot.keys.map((key) => key.text).join("\n") + "\n");
|
||||||
|
}
|
||||||
|
if (state === "unavailable" || state === "pending") {
|
||||||
|
assert.match(await page.locator(".keys-notice").textContent(), /未发布撤销指令.*保留上次成功密钥/);
|
||||||
|
assert.equal(await page.locator(".key-card").count(), 0);
|
||||||
|
}
|
||||||
|
if (state === "explicit-empty") assert.match(await page.locator(".keys-notice").textContent(), /明确撤销全部受管理公钥/);
|
||||||
|
assert.equal(await page.evaluate(() => window.__injected), undefined);
|
||||||
|
await noPageOverflow(page, 390);
|
||||||
|
await capture(page, "source-" + state);
|
||||||
|
}
|
||||||
|
assert.deepEqual(externalRequests, []);
|
||||||
|
} finally { await context.close(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
await t.test("no-JavaScript page retains keys, commands, source states and system theme", async () => {
|
||||||
|
const { context, externalRequests } = await guardedContext(browser, fixture, {
|
||||||
|
viewport: { width: 390, height: 844 }, javaScriptEnabled: false, colorScheme: "dark",
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const page = await context.newPage();
|
||||||
|
await page.goto(fixture.origin + "/repository");
|
||||||
|
assert.equal(await page.locator("#key-0").isVisible(), true);
|
||||||
|
assert.equal(await page.locator("#command-install").isVisible(), true);
|
||||||
|
assert.equal(await page.locator("#copy-all-keys").isVisible(), false);
|
||||||
|
assert.equal(await page.locator("#theme-picker").isVisible(), false);
|
||||||
|
assert.match(await page.locator("noscript").textContent(), /手动选择复制/);
|
||||||
|
await page.locator("details summary").click();
|
||||||
|
assert.equal(await page.locator("#command-uninstall").isVisible(), true);
|
||||||
|
assert.equal(await page.locator(".repository-link").getAttribute("href"), fixture.snapshots.repository.repositoryUrl);
|
||||||
|
await capture(page, "homepage-no-javascript");
|
||||||
|
await page.goto(fixture.origin + "/unavailable");
|
||||||
|
assert.equal(await page.locator(".source-status").textContent(), "暂不可用");
|
||||||
|
assert.match(await page.locator(".keys-notice").textContent(), /保留上次成功密钥/);
|
||||||
|
assert.deepEqual(externalRequests, []);
|
||||||
|
} finally { await context.close(); }
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
await browser?.close();
|
||||||
|
await fixture.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import {readFile,mkdtemp,cp,writeFile,rm,mkdir} from 'node:fs/promises';
|
||||||
|
import {tmpdir} from 'node:os';
|
||||||
|
import {join} from 'node:path';
|
||||||
|
import {renderPage} from '../cf-worker/src/view.js';
|
||||||
|
import {validateConfig,parseKeys,makeKeysText,commandsFor,renderKeys,build,root,digest} from '../scripts/build.mjs';
|
||||||
|
const config = {baseUrl:'https://ssh.example.com',repositoryUrl:'https://github.com/example/keys',defaultCron:'*/15 * * * *',allowEmpty:false};
|
||||||
|
const fixture = await readFile(new URL('./fixtures/keys.pub',import.meta.url),'utf8');
|
||||||
|
|
||||||
|
test('deployment configuration rejects shell injection and ambiguous origins',()=>{
|
||||||
|
assert.deepEqual(validateConfig(config),config);
|
||||||
|
for(const baseUrl of ['http://x.example','https://[email protected]','https://x.example/','https://x.example/a','https://x.example;touch /tmp/x','https://x.example\n','https://x.example$(id)','https://x.example:443']) assert.throws(()=>validateConfig({...config,baseUrl}));
|
||||||
|
for(const defaultCron of ['* * * * *\nevil','61 * * * *','*/0 * * * *','*/99 * * * *','* * * 13 *','* * * * * echo x']) assert.throws(()=>validateConfig({...config,defaultCron}));
|
||||||
|
assert.throws(()=>validateConfig({...config,allowEmpty:'false'}));
|
||||||
|
assert.throws(()=>validateConfig({...config,sshKeyUrl:'https://github.com/user.keys'}));
|
||||||
|
});
|
||||||
|
test('keys are structurally validated, canonicalized and fingerprinted',async()=>{
|
||||||
|
const keys = await parseKeys('# a comment\r\n'+fixture.replace(/\n/g,'\r\n'));
|
||||||
|
assert.equal(keys.length,1);assert.match(keys[0].fingerprint,/^SHA256:/);
|
||||||
|
assert.equal(keys[0].text+'\n',fixture);
|
||||||
|
assert.equal((await parseKeys(fixture.trimEnd())).length,1);
|
||||||
|
});
|
||||||
|
test('rejects malformed keys, options, duplicate identities, private material and NUL',async()=>{
|
||||||
|
const key = fixture.trim();const [type,blob] = key.split(' ');
|
||||||
|
for(const input of ['ssh-ed25519 not-a-key','ssh-rsa '+blob,`command="evil" ${key}`,`${key}\n${type} ${blob} different-comment`,'-----BEGIN OPENSSH PRIVATE KEY-----',key+'\0',`ssh-dss ${blob}`]) await assert.rejects(parseKeys(input));
|
||||||
|
});
|
||||||
|
test('empty revocation must be explicit; protocol is deterministic',async()=>{
|
||||||
|
assert.throws(()=>makeKeysText([], 'a'.repeat(40),false),/No server public keys/);
|
||||||
|
const empty = makeKeysText([], 'a'.repeat(40),true);
|
||||||
|
assert.match(empty.text,/# key-count 0\n# allow-empty yes\n/);assert.equal(empty.sha256,digest(''));
|
||||||
|
const keys=await parseKeys(fixture);const bundle=makeKeysText(keys,'b'.repeat(40),false);
|
||||||
|
assert.ok(bundle.text.endsWith(fixture));assert.equal(bundle.sha256,digest(fixture));
|
||||||
|
assert.throws(()=>makeKeysText(keys,'main',false),/commit SHA/);
|
||||||
|
});
|
||||||
|
test('HTML escapes comments; commands use explicit script endpoint and separate actions',async()=>{
|
||||||
|
const keys=await parseKeys(fixture.trim()+' <script>alert(1)</script>');
|
||||||
|
const html=renderKeys(keys);assert.ok(html.includes('<script>'));assert.ok(!html.includes('<script>'));
|
||||||
|
const commands=commandsFor(config);assert.ok(commands.find(x=>x.id==='install').command.endsWith(' -c'));
|
||||||
|
assert.ok(commands.find(x=>x.id==='root-on').advanced);assert.ok(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('$&'));
|
||||||
|
assert.throws(()=>renderPage('@@UNKNOWN@@',{revision:'e'.repeat(40),keys:[]}),/Unresolved/);
|
||||||
|
});
|
||||||
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAINdamAGCsQq31Uv+08lkBzoO4XLz2qYjJa8CGmj3B1Ea TEST-ONLY-RFC8032-public-key
|
||||||
@@ -0,0 +1,303 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import {readFile} from 'node:fs/promises';
|
||||||
|
import {createHash, generateKeyPairSync} from 'node:crypto';
|
||||||
|
import {FallbackUnavailableError, KEY_SOURCE_LIMITS, makeKeyBundle, parsePublicKeys, resolveKeys, validateFallbackSources} from '../cf-worker/src/key-source.js';
|
||||||
|
|
||||||
|
const fixture = await readFile(new URL('./fixtures/keys.pub', import.meta.url), 'utf8');
|
||||||
|
const revision = 'a'.repeat(40);
|
||||||
|
const urls = ['https://keys.example.org/first.keys', 'https://keys.example.org/second.keys', 'https://keys.example.org/third.keys'];
|
||||||
|
const emptyRelease = {keys: [], allowEmpty: false, revision, fallbackSources: urls};
|
||||||
|
const fixtureKeys = await parsePublicKeys(fixture);
|
||||||
|
const hash = text => createHash('sha256').update(text).digest('hex');
|
||||||
|
const string = value => {
|
||||||
|
const bytes = typeof value === 'string' ? Buffer.from(value) : Buffer.from(value);
|
||||||
|
const prefix = Buffer.alloc(4); prefix.writeUInt32BE(bytes.length);
|
||||||
|
return Buffer.concat([prefix, bytes]);
|
||||||
|
};
|
||||||
|
const wire = (...fields) => Buffer.concat(fields.map(string));
|
||||||
|
const keyLine = (type, bytes, comment = '') => `${type} ${bytes.toString('base64')}${comment ? ` ${comment}` : ''}\n`;
|
||||||
|
const secondFixture = keyLine('ssh-ed25519', wire('ssh-ed25519', Buffer.from('3d4017c3e843895a92b70aa74d1b7ebc9c982ccf2ec4968cc0cd55f12af4660c', 'hex')), 'second');
|
||||||
|
const keyData = Buffer.from(fixture.split(/\s+/)[1], 'base64');
|
||||||
|
const response = text => new Response(text, {headers: {'Content-Type': 'text/plain; charset=utf-8'}});
|
||||||
|
async function unavailable(fetcher, options = {}, release = emptyRelease) {
|
||||||
|
let caught;
|
||||||
|
try { await resolveKeys(release, fetcher, options); } catch (error) { caught = error; }
|
||||||
|
assert.ok(caught instanceof FallbackUnavailableError);
|
||||||
|
assert.equal(caught.code, 'KEY_SOURCES_UNAVAILABLE');
|
||||||
|
return caught;
|
||||||
|
}
|
||||||
|
|
||||||
|
test('repository keys win without any upstream request and have complete protocol metadata', async () => {
|
||||||
|
const result = await resolveKeys({...emptyRelease, keys: fixtureKeys}, () => assert.fail('Repository keys must not fetch'));
|
||||||
|
assert.deepEqual(result.source, {type: 'repository'});
|
||||||
|
assert.equal(result.revision, revision);
|
||||||
|
assert.equal(result.keyCount, 1);
|
||||||
|
assert.equal(result.sha256, hash(fixture));
|
||||||
|
assert.equal(result.keyRevision, result.sha256);
|
||||||
|
assert.equal(result.keysText, `# conf-sshd-v1\n# revision ${revision}\n# key-count 1\n# allow-empty no\n# sha256 ${hash(fixture)}\n# end-metadata\n${fixture}`);
|
||||||
|
assert.ok(Number.isFinite(Date.parse(result.fetchedAt)));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('explicit empty revocation suppresses fallback; repository still wins when nonempty', async () => {
|
||||||
|
const result = await resolveKeys({...emptyRelease, allowEmpty: true}, () => assert.fail('Revocation must not fetch'));
|
||||||
|
assert.deepEqual(result.source, {type: 'explicit-empty'});
|
||||||
|
assert.equal(result.keyCount, 0);
|
||||||
|
assert.equal(result.sha256, hash(''));
|
||||||
|
assert.match(result.keysText, /# allow-empty yes\n/);
|
||||||
|
const populated = await resolveKeys({...emptyRelease, keys: fixtureKeys, allowEmpty: true}, () => assert.fail('Must not fetch'));
|
||||||
|
assert.equal(populated.source.type, 'repository');
|
||||||
|
assert.equal(populated.keyCount, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('first valid nonempty source wins in order, without aggregation or redirect/cache reuse', async () => {
|
||||||
|
const visited = [];
|
||||||
|
const result = await resolveKeys(emptyRelease, async (url, init) => {
|
||||||
|
visited.push(url);
|
||||||
|
assert.equal(init.cache, 'no-store'); assert.equal(init.redirect, 'manual'); assert.equal(init.method, 'GET');
|
||||||
|
assert.match(init.headers['Cache-Control'], /no-store/); assert.ok(init.signal instanceof AbortSignal);
|
||||||
|
return response(fixture);
|
||||||
|
});
|
||||||
|
assert.deepEqual(visited, [urls[0]]);
|
||||||
|
assert.deepEqual(result.source, {type: 'fallback', url: urls[0]});
|
||||||
|
assert.equal(result.keyCount, 1);
|
||||||
|
assert.equal(result.sha256, hash(fixture));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('malformed and empty sources are skipped before the next valid source', async () => {
|
||||||
|
const visited = [];
|
||||||
|
const result = await resolveKeys(emptyRelease, async url => {
|
||||||
|
visited.push(url);
|
||||||
|
return response([ '<html>upstream problem</html>', '# no keys\n\n', fixture ][urls.indexOf(url)]);
|
||||||
|
});
|
||||||
|
assert.deepEqual(visited, urls);
|
||||||
|
assert.equal(result.source.url, urls[2]);
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const status of [301, 302, 307, 308, 400, 404, 429, 500, 503]) {
|
||||||
|
test(`HTTP ${status} is skipped without following redirects`, async () => {
|
||||||
|
const visited = [];
|
||||||
|
const result = await resolveKeys(emptyRelease, async url => {
|
||||||
|
visited.push(url);
|
||||||
|
return url === urls[0] ? new Response('Do not return this body: SECRET', {status, headers: {Location: 'https://unconfigured.example/keys'}}) : response(fixture);
|
||||||
|
});
|
||||||
|
assert.deepEqual(visited, urls.slice(0, 2));
|
||||||
|
assert.equal(result.source.url, urls[1]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('a response reported as redirected is rejected even when status is 200', async () => {
|
||||||
|
const redirected = response(fixture);
|
||||||
|
Object.defineProperty(redirected, 'redirected', {value: true});
|
||||||
|
const result = await resolveKeys(emptyRelease, async url => url === urls[0] ? redirected : response(secondFixture));
|
||||||
|
assert.equal(result.source.url, urls[1]);
|
||||||
|
assert.equal(result.sha256, hash(secondFixture));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('fetch timeout is bounded even when an upstream ignores AbortSignal', async () => {
|
||||||
|
const visited = [];
|
||||||
|
const result = await resolveKeys(emptyRelease, async url => {
|
||||||
|
visited.push(url);
|
||||||
|
if (url === urls[0]) return new Promise(() => {});
|
||||||
|
return response(fixture);
|
||||||
|
}, {timeoutMs: 20});
|
||||||
|
assert.deepEqual(visited, urls.slice(0, 2));
|
||||||
|
assert.equal(result.source.url, urls[1]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('timeout includes streamed body and cancels it before falling back', async () => {
|
||||||
|
let cancelled = false;
|
||||||
|
const body = new ReadableStream({start(controller) {controller.enqueue(new TextEncoder().encode(fixture));}, cancel() {cancelled = true;}});
|
||||||
|
const result = await resolveKeys(emptyRelease, async url => url === urls[0] ? new Response(body) : response(secondFixture), {timeoutMs: 20});
|
||||||
|
assert.equal(cancelled, true);
|
||||||
|
assert.equal(result.source.url, urls[1]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('all failed sources return typed, bounded diagnostics without secret bodies or errors', async () => {
|
||||||
|
const error = await unavailable(async url => {
|
||||||
|
if (url === urls[0]) throw new Error('Credential SECRET network detail');
|
||||||
|
if (url === urls[1]) return new Response('SECRET body', {status: 503});
|
||||||
|
return response('# no keys\n');
|
||||||
|
});
|
||||||
|
assert.deepEqual(error.diagnostics, [{index: 0, code: 'network-error'}, {index: 1, code: 'http-error', status: 503}, {index: 2, code: 'empty'}]);
|
||||||
|
assert.doesNotMatch(JSON.stringify(error), /SECRET|https:/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('no repository keys and no configured sources is unavailable without fetching', async () => {
|
||||||
|
const error = await unavailable(() => assert.fail('No implicit GitHub or other source'), {}, {...emptyRelease, fallbackSources: []});
|
||||||
|
assert.deepEqual(error.diagnostics, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('streamed size is enforced even without or with a misleading Content-Length', async () => {
|
||||||
|
for (const headers of [{}, {'Content-Length': '1'}]) {
|
||||||
|
let cancelled = false;
|
||||||
|
const body = new ReadableStream({start(controller) {controller.enqueue(new Uint8Array(70)); controller.enqueue(new Uint8Array(70));}, cancel() {cancelled = true;}});
|
||||||
|
const error = await unavailable(async () => new Response(body, {headers}), {maxBytes: 128}, {...emptyRelease, fallbackSources: [urls[0]]});
|
||||||
|
assert.equal(error.diagnostics[0].code, 'too-large');
|
||||||
|
assert.equal(cancelled, true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('oversized declared length is rejected without reading the body', async () => {
|
||||||
|
const error = await unavailable(async () => new Response(fixture, {headers: {'Content-Length': String(KEY_SOURCE_LIMITS.maxBytes + 1)}}), {}, {...emptyRelease, fallbackSources: [urls[0]]});
|
||||||
|
assert.equal(error.diagnostics[0].code, 'too-large');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('invalid UTF-8 is rejected instead of silently replaced', async () => {
|
||||||
|
const error = await unavailable(async () => new Response(Uint8Array.from([0xc3, 0x28])), {}, {...emptyRelease, fallbackSources: [urls[0]]});
|
||||||
|
assert.equal(error.diagnostics[0].code, 'invalid-utf8');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('changing upstream changes content revision while build revision stays fixed; no stale fallback', async () => {
|
||||||
|
let body = fixture;
|
||||||
|
const fetcher = async () => response(body);
|
||||||
|
const release = {...emptyRelease, fallbackSources: [urls[0]]};
|
||||||
|
const first = await resolveKeys(release, fetcher);
|
||||||
|
body = secondFixture;
|
||||||
|
const second = await resolveKeys(release, fetcher);
|
||||||
|
assert.notEqual(first.keyRevision, second.keyRevision);
|
||||||
|
assert.equal(first.revision, second.revision);
|
||||||
|
body = '';
|
||||||
|
await assert.rejects(resolveKeys(release, fetcher), FallbackUnavailableError);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('octet-stream responses are accepted only after strict key parsing', async () => {
|
||||||
|
const result = await resolveKeys(emptyRelease, async () => new Response(fixture, {headers: {'Content-Type': 'application/octet-stream'}}));
|
||||||
|
assert.equal(result.keyCount, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sources are trusted HTTPS config only, bounded, without credentials or fragments', () => {
|
||||||
|
assert.deepEqual(validateFallbackSources(urls), urls);
|
||||||
|
assert.deepEqual(validateFallbackSources(), []);
|
||||||
|
for (const invalid of [null, {}, new Array(6).fill(urls[0]), ['http://example.org/key'], ['https://user:[email protected]/key'], ['https://example.org/key#fragment'], ['https://example.org/key#'], ['https://example.org/key\n'], ['data:text/plain,key'], ['//example.org/key']]) assert.throws(() => validateFallbackSources(invalid));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Unicode comments and CRLF are canonicalized, with a known SHA256 fingerprint', async () => {
|
||||||
|
const input = fixture.trim().split(/\s+/).slice(0, 2).join('\t') + '\t 家用服务器 🔐\t张三\r\n';
|
||||||
|
const [key] = await parsePublicKeys(input);
|
||||||
|
assert.equal(key.label, '家用服务器 🔐 张三');
|
||||||
|
assert.equal(key.fingerprint, 'SHA256:bbXpuKG6zhzdmnxq256TlqzFBzRl2f6OOg722cYNbU8');
|
||||||
|
assert.equal(key.text, `${fixture.trim().split(/\s+/).slice(0, 2).join(' ')} 家用服务器 🔐 张三`);
|
||||||
|
const bundle = await makeKeyBundle([key], revision, false);
|
||||||
|
assert.equal(bundle.sha256, hash(key.text + '\n'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('duplicates, options, certificates, private keys, invalid controls, and junk are rejected', async () => {
|
||||||
|
const cert = fixture.replaceAll('ssh-ed25519', '[email protected]');
|
||||||
|
for (const invalid of [fixture + fixture, `restrict ${fixture}`, `command="id" ${fixture}`, cert, '-----BEGIN OPENSSH PRIVATE KEY-----\n', fixture + '<html>oops</html>', fixture.replace('TEST-ONLY', 'bad\u0000comment'), fixture.replace('TEST-ONLY', 'bad\rcomment'), fixture.replace('TEST-ONLY', 'bad\u0085comment'), fixture.replace('TEST-ONLY', 'bad\ud800comment')]) {
|
||||||
|
await assert.rejects(parsePublicKeys(invalid));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('wrong wire type, truncated fields, trailing data, noncanonical base64, invalid key sizes fail', async () => {
|
||||||
|
const wrongType = keyLine('ssh-rsa', keyData);
|
||||||
|
const truncated = keyLine('ssh-ed25519', keyData.subarray(0, keyData.length - 1));
|
||||||
|
const trailing = keyLine('ssh-ed25519', Buffer.concat([keyData, Buffer.from([0])]));
|
||||||
|
const short = keyLine('ssh-ed25519', wire('ssh-ed25519', Buffer.alloc(31)));
|
||||||
|
const long = keyLine('ssh-ed25519', wire('ssh-ed25519', Buffer.alloc(33)));
|
||||||
|
const badBase64 = 'ssh-ed25519 A===\n';
|
||||||
|
const hugeField = keyLine('ssh-ed25519', Buffer.from([255, 255, 255, 255]));
|
||||||
|
for (const input of [wrongType, truncated, trailing, short, long, badBase64, hugeField]) await assert.rejects(parsePublicKeys(input));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('public key count and line/input size limits are enforced', async () => {
|
||||||
|
await assert.rejects(parsePublicKeys(fixture + secondFixture, {maxKeys: 1}), /Too many/);
|
||||||
|
await assert.rejects(parsePublicKeys(fixture.trim() + 'x'.repeat(8192)), /line exceeds/);
|
||||||
|
await assert.rejects(parsePublicKeys('#'.repeat(KEY_SOURCE_LIMITS.maxBytes + 1)), /size limit/);
|
||||||
|
await assert.rejects(resolveKeys(emptyRelease, () => assert.fail(), {timeoutMs: KEY_SOURCE_LIMITS.timeoutMs + 1}), /limit/);
|
||||||
|
});
|
||||||
|
|
||||||
|
function mpint(bytes) {return bytes[0] & 128 ? Buffer.concat([Buffer.from([0]), bytes]) : bytes;}
|
||||||
|
function rsaLine(jwk, exponent, modulus) {
|
||||||
|
return keyLine('ssh-rsa', wire('ssh-rsa', exponent || mpint(Buffer.from(jwk.e, 'base64url')), modulus || mpint(Buffer.from(jwk.n, 'base64url'))));
|
||||||
|
}
|
||||||
|
test('RSA requires complete positive canonical mpints and at least 2048 bits', async () => {
|
||||||
|
const {publicKey} = generateKeyPairSync('rsa', {modulusLength: 2048});
|
||||||
|
const jwk = publicKey.export({format: 'jwk'});
|
||||||
|
assert.equal((await parsePublicKeys(rsaLine(jwk)))[0].type, 'ssh-rsa');
|
||||||
|
const modulus = mpint(Buffer.from(jwk.n, 'base64url'));
|
||||||
|
for (const [e, n] of [[Buffer.from([0, 3]), modulus], [Buffer.from([128]), modulus], [Buffer.from([2]), modulus], [Buffer.from([1]), modulus], [Buffer.from([3]), Buffer.from([0])], [Buffer.from([3]), Buffer.from([127, 3])], [Buffer.from([3]), modulus.subarray(1)], [Buffer.from([3]), Buffer.concat([Buffer.from([0]), modulus])]]) await assert.rejects(parsePublicKeys(rsaLine(jwk, e, n)));
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const [name, curve] of [['nistp256', 'P-256'], ['nistp384', 'P-384'], ['nistp521', 'P-521']]) {
|
||||||
|
test(`ECDSA ${name} requires a matching curve and a valid point`, async () => {
|
||||||
|
const {publicKey} = generateKeyPairSync('ec', {namedCurve: curve});
|
||||||
|
const jwk = publicKey.export({format: 'jwk'});
|
||||||
|
const type = `ecdsa-sha2-${name}`;
|
||||||
|
const point = Buffer.concat([Buffer.from([4]), Buffer.from(jwk.x, 'base64url'), Buffer.from(jwk.y, 'base64url')]);
|
||||||
|
assert.equal((await parsePublicKeys(keyLine(type, wire(type, name, point))))[0].type, type);
|
||||||
|
await assert.rejects(parsePublicKeys(keyLine(type, wire(type, 'wrong', point))));
|
||||||
|
await assert.rejects(parsePublicKeys(keyLine(type, wire(type, name, point.subarray(1)))));
|
||||||
|
const offCurve = Buffer.alloc(point.length); offCurve[0] = 4;
|
||||||
|
await assert.rejects(parsePublicKeys(keyLine(type, wire(type, name, offCurve))), /Invalid ECDSA point/);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('OpenSSH security-key Ed25519 and ECDSA formats validate applications and trailing data', async () => {
|
||||||
|
const edType = '[email protected]';
|
||||||
|
const publicBytes = keyData.subarray(keyData.length - 32);
|
||||||
|
const ed = keyLine(edType, wire(edType, publicBytes, 'ssh:example'));
|
||||||
|
assert.equal((await parsePublicKeys(ed))[0].type, edType);
|
||||||
|
await assert.rejects(parsePublicKeys(keyLine(edType, wire(edType, publicBytes, ''))));
|
||||||
|
await assert.rejects(parsePublicKeys(keyLine(edType, wire(edType, publicBytes, 'ssh:\u0000'))));
|
||||||
|
await assert.rejects(parsePublicKeys(keyLine(edType, wire(edType, publicBytes))));
|
||||||
|
const {publicKey} = generateKeyPairSync('ec', {namedCurve: 'P-256'});
|
||||||
|
const jwk = publicKey.export({format: 'jwk'});
|
||||||
|
const ecType = '[email protected]';
|
||||||
|
const point = Buffer.concat([Buffer.from([4]), Buffer.from(jwk.x, 'base64url'), Buffer.from(jwk.y, 'base64url')]);
|
||||||
|
assert.equal((await parsePublicKeys(keyLine(ecType, wire(ecType, 'nistp256', point, 'ssh:example'))))[0].type, ecType);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('bundle rejects implicit empty and invalid deployment metadata', async () => {
|
||||||
|
await assert.rejects(makeKeyBundle([], revision, false));
|
||||||
|
await assert.rejects(makeKeyBundle(fixtureKeys, 'not-a-revision', false));
|
||||||
|
await assert.rejects(makeKeyBundle(fixtureKeys, revision, 'yes'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('fallback URL configuration rejects queries, local/private names, and normalized duplicates', () => {
|
||||||
|
for (const url of ['https://keys.example.org/key?token=secret', 'https://keys.example.org/key?', 'https://localhost/key', 'https://thing.local/key', 'https://metadata.google.internal/key', 'https://home.lan/key', 'https://192.168.1.1/key', 'https://127.1/key', 'https://[::1]/key', 'https://[2001:db8::1]/key', 'https://2130706433/key', 'https://0x7f000001/key', 'https://single-label/key']) assert.throws(() => validateFallbackSources([url]));
|
||||||
|
assert.throws(() => validateFallbackSources(['https://KEYS.example.org:443/key', 'https://keys.example.org/key']), /Duplicate/);
|
||||||
|
assert.deepEqual(validateFallbackSources(['https://KEYS.example.org:443/key']), ['https://keys.example.org/key']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('public-key parser honors an aborted signal before and during validation', async () => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
controller.abort();
|
||||||
|
await assert.rejects(parsePublicKeys(fixture, {signal: controller.signal}), /timeout/);
|
||||||
|
let reads = 0;
|
||||||
|
const signal = {get aborted() {return ++reads >= 3;}};
|
||||||
|
await assert.rejects(parsePublicKeys(fixture + secondFixture, {signal}), /timeout/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('whole serialized protocol, including metadata, is limited to the client response cap', async () => {
|
||||||
|
const text = 'x'.repeat(KEY_SOURCE_LIMITS.maxBytes - 100);
|
||||||
|
await assert.rejects(makeKeyBundle([{text}], revision, false), /too-large/);
|
||||||
|
const bundle = await makeKeyBundle([{text: 'x'.repeat(KEY_SOURCE_LIMITS.maxBytes - 300)}], revision, false);
|
||||||
|
assert.ok(Buffer.byteLength(bundle.text) <= KEY_SOURCE_LIMITS.maxBytes);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an upstream fitting the input cap but exceeding the serialized output cap is skipped', async () => {
|
||||||
|
const publicBytes = keyData.subarray(keyData.length - 32);
|
||||||
|
const lines = Array.from({length: 128}, (_, index) => {
|
||||||
|
const bytes = Buffer.from(publicBytes); bytes[31] ^= index;
|
||||||
|
const prefix = keyLine('ssh-ed25519', wire('ssh-ed25519', bytes)).trim() + ' ';
|
||||||
|
return prefix + 'x'.repeat(8190 - prefix.length) + '\n';
|
||||||
|
}).join('');
|
||||||
|
assert.ok(Buffer.byteLength(lines) <= KEY_SOURCE_LIMITS.maxBytes);
|
||||||
|
const error = await unavailable(async () => response(lines), {}, {...emptyRelease, fallbackSources: [urls[0]]});
|
||||||
|
assert.equal(error.diagnostics[0].code, 'too-large');
|
||||||
|
const result = await resolveKeys(emptyRelease, async url => response(url === urls[0] ? lines : fixture));
|
||||||
|
assert.equal(result.source.url, urls[1]);
|
||||||
|
assert.equal(result.sha256, hash(fixture));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ordinary private-key advice in comments is allowed, while real private formats are rejected anywhere', async () => {
|
||||||
|
const advice = '# Do not copy private keys into this repository.\n';
|
||||||
|
assert.deepEqual(await parsePublicKeys(advice), []);
|
||||||
|
const publicWithComment = fixture.trim().split(/\s+/).slice(0, 2).join(' ') + ' private key remains on my laptop\n';
|
||||||
|
assert.equal((await parsePublicKeys(advice + publicWithComment))[0].label, 'private key remains on my laptop');
|
||||||
|
for (const marker of ['-----BEGIN OPENSSH PRIVATE KEY-----', '-----BEGIN RSA PRIVATE KEY-----', '-----BEGIN EC PRIVATE KEY-----', '-----BEGIN PRIVATE KEY-----', '-----BEGIN ENCRYPTED PRIVATE KEY-----', 'PuTTY-User-Key-File-3: ssh-ed25519']) {
|
||||||
|
await assert.rejects(parsePublicKeys('# ' + marker + '\n' + fixture), /Private keys are never accepted/);
|
||||||
|
await assert.rejects(parsePublicKeys(publicWithComment.trim() + ' ' + marker + '\n'), /Private keys are never accepted/);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { spawnSync } from 'node:child_process';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
const root = fileURLToPath(new URL('..', import.meta.url));
|
||||||
|
const candidates = [['POSIX sh', '/bin/sh'], ['bash POSIX mode', 'bash --posix'], ['BusyBox ash', 'busybox sh']];
|
||||||
|
for (const [label, shell] of candidates) {
|
||||||
|
const executable = shell.split(' ')[0];
|
||||||
|
const available = spawnSync(executable, executable === 'busybox' ? ['sh', '-c', ':'] : ['-c', ':']).status === 0;
|
||||||
|
test(`isolated installer regression suite (${label})`, { skip: !available }, () => {
|
||||||
|
const result = spawnSync('python3', ['tests/shell.test.py'], {
|
||||||
|
cwd: root, encoding: 'utf8', timeout: 90_000,
|
||||||
|
env: { ...process.env, CONF_SSHD_TEST_SHELL: shell },
|
||||||
|
});
|
||||||
|
assert.equal(result.error, undefined, result.error?.message);
|
||||||
|
assert.equal(result.status, 0, result.stdout + result.stderr);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
test('ShellCheck POSIX diagnostics', { skip: spawnSync('shellcheck', ['--version']).status !== 0 }, () => {
|
||||||
|
const result = spawnSync('shellcheck', ['--shell=sh', 'conf-sshd.sh'], { cwd: root, encoding: 'utf8' });
|
||||||
|
assert.equal(result.status, 0, result.stdout + result.stderr);
|
||||||
|
});
|
||||||
@@ -0,0 +1,725 @@
|
|||||||
|
#!/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)
|
||||||
@@ -0,0 +1,233 @@
|
|||||||
|
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");
|
||||||
|
});
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import {createHandler} from '../cf-worker/src/router.js';
|
||||||
|
import {parsePublicKeys,makeKeyBundle} from '../cf-worker/src/key-source.js';
|
||||||
|
import {readFile} from 'node:fs/promises';
|
||||||
|
const fixture=await readFile(new URL('./fixtures/keys.pub',import.meta.url),'utf8');
|
||||||
|
const keys=await parsePublicKeys(fixture);
|
||||||
|
const revision='a'.repeat(40);
|
||||||
|
const bundle=await makeKeyBundle(keys,revision,false);
|
||||||
|
const release={revision,publishedAt:'2026-10-12',keyCount:1,allowEmpty:false,sha256:bundle.sha256,script:'#!/bin/sh\n',keysText:bundle.text,keys,fallbackSources:[],repositoryUrl:'https://github.com/test/repo',commands:[],pageTemplate:await readFile(new URL('../site/index.html',import.meta.url),'utf8')};
|
||||||
|
const fetch=createHandler(release);
|
||||||
|
const requests=[];
|
||||||
|
const env={ASSETS:{fetch:async request=>{requests.push(new URL(request.url).pathname);return new Response('asset:'+new URL(request.url).pathname,{headers:{'Content-Type':request.url.endsWith('.css')?'text/css':'text/html'}})}}};
|
||||||
|
const request=(path,init={})=>new Request('https://ssh.example.com'+path,init);
|
||||||
|
test('explicit endpoints ignore user agent and are never cached',async()=>{
|
||||||
|
for(const [path,body] of [['/script.sh',release.script],['/ssh.keys',release.keysText]]) {
|
||||||
|
const response=await fetch(request(path,{headers:{Accept:'text/html'}}),env);
|
||||||
|
assert.equal(await response.text(),body);assert.equal(response.headers.get('Cache-Control'),'no-store');assert.match(response.headers.get('Content-Type'),/^text\/plain/);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
test('root serves browser HTML and legacy curl script without content mixing',async()=>{
|
||||||
|
for(const agent of ['curl/8.1.0','libcurl/8.1.0']){
|
||||||
|
const response=await fetch(request('/',{headers:{'User-Agent':agent,Accept:'*/*'}}),env);
|
||||||
|
assert.equal(await response.text(),release.script);assert.equal(response.headers.get('Vary'),'Accept, User-Agent');
|
||||||
|
}
|
||||||
|
const explicit=await fetch(request('/',{headers:{'User-Agent':'curl/8','Accept':'text/html'}}),env);
|
||||||
|
assert.match(await explicit.text(),/<html lang="zh-CN">/);
|
||||||
|
assert.match((await fetch(request('/'),env)).headers.get('Content-Type'),/^text\/html/);
|
||||||
|
});
|
||||||
|
test('metadata is current release; invalid methods and paths fail rather than returning HTML',async()=>{
|
||||||
|
const metadata=await (await fetch(request('/release.json'),env)).json();assert.equal(metadata.revision,release.revision);assert.equal(metadata.keyCount,1);
|
||||||
|
for(const path of ['/script.s','/ssh.key','/index.html','/assets/../../script.sh','/api/keys']) {
|
||||||
|
if(path==='/assets/../../script.sh') continue; // URL canonicalization is intentionally standard.
|
||||||
|
const response=await fetch(request(path),env);assert.equal(response.status,404);assert.match(response.headers.get('Content-Type'),/^text\/plain/);
|
||||||
|
}
|
||||||
|
const post=await fetch(request('/ssh.keys',{method:'POST'}),env);assert.equal(post.status,405);assert.equal(post.headers.get('Allow'),'GET, HEAD');
|
||||||
|
});
|
||||||
|
test('HEAD returns headers without body and missing page fails with 503',async()=>{
|
||||||
|
for(const path of ['/','/script.sh','/ssh.keys','/release.json']) assert.equal(await (await fetch(request(path,{method:'HEAD'}),env)).text(),'');
|
||||||
|
assert.equal((await createHandler({...release,pageTemplate:null})(request('/'),env)).status,503);
|
||||||
|
});
|
||||||
|
test('fingerprinted assets cached while security headers retained',async()=>{
|
||||||
|
const response=await fetch(request('/assets/app.012345abcdef.js'),env);assert.match(response.headers.get('Cache-Control'),/immutable/);assert.equal(response.headers.get('X-Content-Type-Options'),'nosniff');
|
||||||
|
const missing=await fetch(request('/assets/missing.js'),{ASSETS:{fetch:async()=>new Response('missing',{status:404})}});assert.equal(missing.status,404);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('fallback page and key metadata represent one effective snapshot; unavailable does not revoke',async()=>{
|
||||||
|
const fallback={...release,keys:[],keyCount:0,sha256:null,keysText:null,fallbackSources:['https://keys.example.com/server.keys']};
|
||||||
|
const handler=createHandler(fallback,{fetchImpl:async()=>new Response(fixture)});
|
||||||
|
const html=await (await handler(request('/'),env)).text();
|
||||||
|
assert.ok(html.includes('备用源可用'));assert.ok(html.includes('https://keys.example.com/server.keys'));assert.ok(html.includes(keys[0].fingerprint));
|
||||||
|
const match=html.match(/<script id="release-data" type="application\/json">([\s\S]*?)<\/script>/);
|
||||||
|
const embedded=JSON.parse(match[1]);assert.equal(embedded.sha256,bundle.sha256);assert.equal(embedded.keyCount,1);assert.equal(embedded.keys[0].text,keys[0].text);
|
||||||
|
const metadata=await (await handler(request('/release.json'),env)).json();assert.equal(metadata.keyRevision,bundle.sha256);assert.equal(metadata.source.type,'fallback');
|
||||||
|
assert.equal(await (await handler(request('/ssh.keys'),env)).text(),bundle.text);
|
||||||
|
const unavailable=createHandler(fallback,{fetchImpl:async()=>new Response('',{status:503})});
|
||||||
|
for(const path of ['/ssh.keys','/release.json','/'])assert.equal((await unavailable(request(path),env)).status,503);
|
||||||
|
const failureHTML=await (await unavailable(request('/'),env)).text();assert.ok(failureHTML.includes('未发布撤销指令'));assert.ok(!failureHTML.includes('此版本明确撤销全部受管理公钥'));
|
||||||
|
assert.equal((await unavailable(request('/script.sh'),env)).status,200);
|
||||||
|
assert.equal(await (await unavailable(request('/',{headers:{'User-Agent':'curl/8'}}),env)).text(),release.script);
|
||||||
|
});
|
||||||
|
test('explicit empty revocation suppresses fallback in page, metadata and keys',async()=>{
|
||||||
|
const handler=createHandler({...release,keys:[],keyCount:0,allowEmpty:true,fallbackSources:['https://keys.example.com/server.keys']},{fetchImpl:async()=>{throw Error('must not fetch')}});
|
||||||
|
const html=await (await handler(request('/'),env)).text();assert.ok(html.includes('明确撤销'));assert.ok(!html.includes('未发布撤销指令'));
|
||||||
|
const keysText=await (await handler(request('/ssh.keys'),env)).text();assert.match(keysText,/# key-count 0\n# allow-empty yes/);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user