diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml
new file mode 100644
index 0000000..93a5b64
--- /dev/null
+++ b/.github/workflows/check.yml
@@ -0,0 +1,37 @@
+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@11d5960a326750d5838078e36cf38b85af677262 # v4
+ - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
+ with:
+ node-version: '22'
+ cache: npm
+ - run: npm ci
+ - name: Install shell test interpreters
+ run: sudo apt-get update && sudo apt-get install -y busybox shellcheck
+ - 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@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
+ with:
+ name: browser-evidence
+ path: /tmp/quickly-browser-artifacts
+ if-no-files-found: ignore
+ retention-days: 7
diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml
new file mode 100644
index 0000000..70507ba
--- /dev/null
+++ b/.github/workflows/deploy.yml
@@ -0,0 +1,40 @@
+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@11d5960a326750d5838078e36cf38b85af677262 # v4
+ - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
+ with:
+ node-version: '22'
+ cache: npm
+ - run: npm ci
+ - name: Install shell test interpreters
+ run: sudo apt-get update && sudo apt-get install -y busybox shellcheck
+ - 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
diff --git a/.github/workflows/update-access-url.yml b/.github/workflows/update-access-url.yml
deleted file mode 100644
index 1a76a56..0000000
--- a/.github/workflows/update-access-url.yml
+++ /dev/null
@@ -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
\ No newline at end of file
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..2bf4e30
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,8 @@
+node_modules/
+cf-worker/generated/
+cf-worker/public/
+.wrangler/
+*.log
+
+__pycache__/
+*.pyc
diff --git a/README.md b/README.md
index dc9a33b..1002c2e 100644
--- a/README.md
+++ b/README.md
@@ -1,98 +1,195 @@
# Quickly Configuring SSH Server
-快速地将 SSH 密钥部署到服务器中,并定期更新。
-这个脚本可以帮你做到这些事情:
-- 快速配置服务器的 SSH Server;
-- 定期自动更新 SSH 公钥到服务器中;
-- 集中管理注册到服务器的 SSH 公钥(例如使用 Github 管理 SSH 公钥);
+在 Git 仓库中独立管理服务器 SSH 公钥,通过 Cloudflare Worker 发布,再由服务器定期同步。
+
+- **优先使用本仓库的 `keys/authorized_keys`**;主清单为空时,可按配置顺序读取备用 HTTPS 公钥源。没有默认 GitHub 账号 SSH Keys 来源。
+- 网页、脚本与仓库公钥一起构建发布,不再依赖 GitHub Pages;备用公钥独立标记来源和内容摘要。
+- 首页支持浅色 / 深色 / 跟随系统、手机与宽屏,以及 GitHub 风格的代码块复制按钮。
+- POSIX Shell 客户端只替换自己的密钥管理区块,保留手工密钥;下载或校验失败保留上次成功的内容。
+
+> 仓库初始没有服务器公钥,生产构建会拒绝空清单。先填写你独立管理的服务器**公钥**或显式配置备用源;不要上传私钥,也不要直接拿测试夹具上线。
## Quick Usage
-部署密钥并设置定期更新:
-```bash
-curl -s https://ssh.lamgc.me | bash -s -- -c
-```
-如果不希望自动更新密钥,可以移除 `-c` 参数:
-```bash
-curl -s https://ssh.lamgc.me | bash -s
-```
-跳过检查 SSH Server,只需要安装秘钥的话,就需要单独使用 `-o` 参数:
-```bash
-curl -s https://ssh.lamgc.me | bash -s -- -o
-```
-添加 `-p yes` 参数可以设置允许 Root 用户使用密码登录 SSH(需使用 Root 用户执行脚本):
-```bash
-curl -s https://ssh.lamgc.me | bash -s -- -p yes
-```
-要查看脚本的帮助信息,请使用 `-h` 参数:
-```bash
-curl -s https://ssh.lamgc.me | bash -s -- -h
+
+以下地址是部署配置中的示例。Fork 后先修改 `deployment.json`,填写公钥并完成自己的 Worker 部署,再使用自己的域名。执行前建议下载并检查脚本。
+
+安装公钥并设置定时同步:
+
+```sh
+curl -fsSL 'https://ssh.lamgc.me/script.sh' | sh -s -- -c
```
-> 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
-获取 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 脚本:
-```
-https://ssh.lamgc.me/script.sh
-```
+`build:test` 只用于测试/预览,使用公开的 RFC 测试公钥,并在产物中标记 `testFixture: true`。生产验证脚本拒绝该产物;生产构建只使用仓库公钥和显式配置的备用源,不允许回退到测试夹具。
-获取 Shell 脚本(使用 Curl 访问):
-```
-https://ssh.lamgc.me/
-```
+测试覆盖构建配置与注入边界、逐条密钥验证、Worker 路由/缓存、主题/复制交互、Shell 参数/管理区块/失败保留/cron/自更新等。Shell 测试只在临时 HOME、假命令和测试文件中运行,不操作真实 SSH 配置或服务。
-访问本页面(不使用 Curl 访问):
-```
-https://ssh.lamgc.me/
-```
+`npm run build:test` 之后若要部署正式版本,必须重新执行 `npm run build` 与 `node scripts/verify-release.mjs`。不得直接部署测试产物。
-## Install
-### Configuration
-先 Fork 本仓库,Fork 后在仓库设置中启用 Github Pages,分支指向 main 分支,目录为仓库根目录。
-配置好后将仓库克隆到本地,然后修改 `cf-worker/src/index.js` 中的前五个变量:
-```javascript
-// 改成你自己的 Github 用户名,注意是登录 Github 的那个用户名.
-const githubUserName = "LamGC";
-// 改成你 Fork 后的仓库名,记得要开启 Github Pages 功能.
-const githubInstSshProjectName = "quickly-conf-sshd";
-// 如果可以,建议在此设置备用的 SSH 公钥, 以防 Github 无法使用.
-const backupSshKeys = ``;
-// Worker 的访问地址, 如果不填的话默认为请求的地址, 填了就会用这里的地址(要去 Worker 的触发器那绑定, 否则无效).
-const defaultBaseUrl = "";
-// Cron 表达式, 默认 1 天执行一次更新.
-const cronExpression = "0 0 * * *";
-```
+## License
-### Use Wrangler
-如果你安装了 Cloudflare 的 Wrangler,可以直接在 `cf-worker` 目录中执行:
-```bash
-wrangler publish
-```
-执行后将会自动创建 Worker 并上传 worker js 代码,Worker 的名字是 `quickly-conf-ssh-worker`。
+Apache-2.0,见 [LICENSE](LICENSE)。
-### 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 中的域名即可。
-
-
-
+客户端会检测写入期间可观察到的手工修改并停止;其他编辑器若不遵守同一锁,仍无法保证操作系统级的原子比较并交换。请避免同时手工编辑授权文件。
diff --git a/cf-worker/src/index.js b/cf-worker/src/index.js
index fb21f6b..04145c9 100644
--- a/cf-worker/src/index.js
+++ b/cf-worker/src/index.js
@@ -1,105 +1,4 @@
-// 改成你自己的 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 * * *";
+import release from '../generated/release.js';
+import { createHandler } from './router.js';
-// 下面的东西一般不用改.
-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"
- })
- }
- }
-}
+export default { fetch: createHandler(release) };
diff --git a/cf-worker/src/key-source.js b/cf-worker/src/key-source.js
new file mode 100644
index 0000000..04b2189
--- /dev/null
+++ b/cf-worker/src/key-source.js
@@ -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', 'sk-ssh-ed25519@openssh.com', 'sk-ecdsa-sha2-nistp256@openssh.com']);
+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 === 'sk-ssh-ed25519@openssh.com') {
+ 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 === 'sk-ecdsa-sha2-nistp256@openssh.com') {
+ 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])|(? 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);
+}
diff --git a/cf-worker/src/router.js b/cf-worker/src/router.js
new file mode 100644
index 0000000..a6876cf
--- /dev/null
+++ b/cf-worker/src/router.js
@@ -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);
+ };
+}
diff --git a/cf-worker/src/view.js b/cf-worker/src/view.js
new file mode 100644
index 0000000..d32abfd
--- /dev/null
+++ b/cf-worker/src/view.js
@@ -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:'更新当前用户的公钥并设置定时同步;保留已有自定义计划,新安装默认每 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 `
`;
+}
+export function renderKeys(keys) {
+ return keys.length ? keys.map((key,i) => `${escapeHTML(key.label)}
${escapeHTML(key.type)}${escapeHTML(key.fingerprint)}
${codeBlock(`key-${i}`,key.text,`复制第 ${i + 1} 个公钥`)}`).join('\n') : '此版本明确撤销全部受管理公钥,手工管理的公钥仍保留。
';
+}
+export function renderCommands(commands) {
+ const card = command => `${escapeHTML(command.title)}
${escapeHTML(command.description)}
${codeBlock(`command-${command.id}`,command.command,`复制“${command.title}”命令`)}`;
+ return commands.filter(c=>!c.advanced).map(card).join('\n') + '高级命令
' + commands.filter(c=>c.advanced).map(card).join('\n') + '
';
+}
+
+
+/** 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(/当前无法获取有效公钥。请稍后重试或检查备用源配置。' : 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 ? '测试预览:这里使用公开测试密钥,不能部署到真实服务器。
' : '',
+ 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];
+ });
+}
diff --git a/cf-worker/wrangler.toml b/cf-worker/wrangler.toml
index 956a932..a4e8405 100644
--- a/cf-worker/wrangler.toml
+++ b/cf-worker/wrangler.toml
@@ -1,3 +1,8 @@
name = "quickly-conf-ssh-worker"
main = "src/index.js"
-compatibility_date = "2023-01-26"
+compatibility_date = "2026-10-01"
+
+[assets]
+directory = "./public"
+binding = "ASSETS"
+run_worker_first = true
diff --git a/conf-sshd.sh b/conf-sshd.sh
index d55c80f..7f43f4e 100755
--- a/conf-sshd.sh
+++ b/conf-sshd.sh
@@ -1,323 +1,487 @@
-#!/bin/bash
-set -e
-set -o pipefail
+#!/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.
-sshkey_url="{{ SSH_KEY_URL }}"
-# 默认的 Cron 执行计划, 每天凌晨 0 点执行
-default_cron="{{ DEFAULT_CRON }}"
-# 脚本 Url
-script_url="{{ SCRIPT_URL }}"
+say() { printf '%s\n' "$*"; }
+fail() { printf 'conf-sshd: %s\n' "$*" >&2; exit 1; }
+usage() {
+ cat <<'HELP'
+Usage: sh conf-sshd.sh [options]
+ (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 every 15 minutes.
+ -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
-############ 脚本区 ##########
-
-script_params=("$@")
-has_param() {
- for param in "${script_params[@]}"; do
- for tParam in "$@"; do
- if [ "$tParam" == "$param" ]; then
- echo "true"
- return
- fi
- done
- done
- echo "false"
+-u, --uninstall, -p and --status are exclusive operations. -o cannot use -c or -p.
+Requires curl, ssh-keygen, awk, standard POSIX tools, mktemp, stat, and one of
+sha256sum, shasum or openssl. Scheduling also requires crontab. Downloads require
+HTTPS; existing keys survive any validation/download failure.
+No SSH service is installed, restarted, or reloaded. Keep recovery access ready.
+The payload SHA-256 detects inconsistency; trust comes from HTTPS and its source.
+HELP
}
-
-get_param_value() {
- local find=false
- 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
+select_mode() {
+ [ "$mode" = sync ] || fail 'Conflicting or duplicate operation options.'
+ mode=$1
}
+while [ "$#" -gt 0 ]; do
+ case $1 in
+ -h|--help) [ "$#" -eq 1 ] && [ "$mode" = sync ] && [ "$cron_requested" = no ] || fail '--help must be used alone.'; usage; exit 0 ;;
+ -o|--only-update-keys) select_mode keys ;;
+ -u|--update-self) select_mode update ;;
+ --uninstall) select_mode uninstall ;;
+ --status) select_mode status ;;
+ -p|--allow-root-passwd)
+ select_mode config
+ [ "$#" -ge 2 ] || fail '-p requires yes or no.'
+ shift; case $1 in yes|no) root_password=$1 ;; *) fail '-p requires yes or no.' ;; esac ;;
+ -c|--cron)
+ [ "$cron_requested" = no ] || fail 'Duplicate -c option.'
+ cron_requested=yes
+ if [ "$#" -gt 1 ]; then
+ 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.'
-# 帮助信息.
-if [ "$(has_param "-h" "--help")" == "true" ]; then
- echo "Usage: $0 [options]"
- echo "Options:"
- echo " -h, --help Print this help message."
- echo ""
- echo "Available to any user: "
- echo " -c, --cron \" | false\" Configure Crontab to automatically update ssh keys."
- echo " Cron expression MUST be quoted (e.g., \"* 0 * * *\")."
- echo " If 'false' is specified, Crontab settings will be deleted."
- 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 " --uninstall Uninstall this script (remove cron and local files)."
- 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 Allow Root to log in with a password."
- echo ""
- exit 0
-fi
+need() { command -v "$1" >/dev/null 2>&1 || fail "Missing dependency: $1. Install it explicitly before continuing."; }
+for dep in awk cat chmod cmp cp dd grep id mkdir mktemp mv od rm rmdir sed stat tail tr wc; do need "$dep"; done
+uid=$(id -u)
+# Reject paths that could alter line-based cron records. Spaces and quotes are safe.
+case ${HOME:-} in /*) : ;; *) fail 'HOME must be an absolute directory.' ;; esac
+[ "$(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_sshkeys() {
- if [ "$sshkey_url" == "" ] || [[ "$sshkey_url" == "{{"* ]]; then
- echo "ERROR: sshkey_url is not configured."
- exit 1
- fi
-
- echo "Downloading SSH public key from '$sshkey_url'"
- mkdir -p ~/.ssh
- chmod 700 ~/.ssh
-
- local dl_tmp_file=~/.ssh/authorized_keys.dl.tmp
- if ! curl -sL "$sshkey_url" -o "$dl_tmp_file"; then
- echo "Failed to download SSH public key at $(date '+%Y-%m-%d %H:%M:%S')"
- rm -f "$dl_tmp_file"
- exit 1
- fi
-
- if [ ! -s "$dl_tmp_file" ] || ! grep -qE "(ssh-rsa|ssh-ed25519|ecdsa-sha2-nistp)" "$dl_tmp_file"; then
- echo "Downloaded file is empty or does not contain valid SSH key types at $(date '+%Y-m-d %H:%M:%S')"
- echo "Aborting update to prevent lockout."
- rm -f "$dl_tmp_file"
- exit 1
- fi
-
- echo "-------------------- SSH Keys --------------------"
- cat "$dl_tmp_file"
- echo "--------------------------------------------------"
-
- local auth_file=~/.ssh/authorized_keys
- local new_auth_file=~/.ssh/authorized_keys.new.tmp
-
- # 受管理文本块标记
- local begin_marker="# --- BEGIN MANAGED BY CONF-SSHD SCRIPT ---"
- local end_marker="# --- END MANAGED BY CONF-SSHD SCRIPT ---"
-
- local managed_block_found=false
- local inside_managed_block=false
-
- touch "$auth_file"
- true > "$new_auth_file"
-
- while IFS= read -r line; do
- if [ "$line" == "$begin_marker" ]; then
- managed_block_found=true
- inside_managed_block=true
-
- {
- echo "$begin_marker"
- cat "$dl_tmp_file"
- echo "$end_marker"
- } >> "$new_auth_file"
-
- elif [ "$line" == "$end_marker" ]; then
- inside_managed_block=false
- elif [ "$inside_managed_block" == "false" ]; then
- echo "$line" >> "$new_auth_file"
- fi
- done < "$auth_file"
-
- if [ "$managed_block_found" == "false" ]; then
- if [ -s "$new_auth_file" ]; then
- if [ "$(tail -c 1 "$new_auth_file")" != "" ]; then
- # 最后一个字符不是换行符,添加一个
- echo "" >> "$new_auth_file"
- fi
- fi
-
- {
- echo "$begin_marker"
- cat "$dl_tmp_file"
- echo "$end_marker"
- } >> "$new_auth_file"
- fi
-
- mv "$new_auth_file" "$auth_file"
- rm -f "$dl_tmp_file"
-
- chmod 600 "$auth_file"
- echo "SSH public key updated successfully (managed block only) at $(date '+%Y-%m-%d %H:%M:%S')"
-}
-
-# 检查是否指定了 --uninstall
-if [ "$(has_param "--uninstall")" == "true" ]; then
- echo "Uninstalling conf-sshd (disabling auto-updates)..."
-
- if [ "$(command -v crontab)" != "" ]; then
- echo "Removing Crontab entry..."
- ( (crontab -l 2>/dev/null || true) | grep -F -v "conf-sshd.sh") | crontab -
- else
- echo "Crontab utility not found, skipping Crontab removal."
- fi
-
- script_dir="$HOME/.conf-sshd"
- if [ -d "$script_dir" ]; then
- echo "Removing script files from $script_dir..."
- rm -rf "$script_dir"
- else
- echo "Script directory $script_dir not found, skipping removal."
- fi
-
- echo "Uninstall complete."
- echo "Note: authorized_keys managed block and sshd_config were NOT affected."
- exit 0
-fi
-
-# 检查是否只更新密钥.
-if [ "$(has_param "-o" "--only-update-keys")" == "true" ]; then
- update_sshkeys
- exit 0
-fi
-
-# 检查是否指定了 --update-self
-if [ "$(has_param "-u" "--update-self")" == "true" ]; then
- echo "Updating conf-sshd script..."
- mkdir -p ~/.conf-sshd
- target_script=~/.conf-sshd/conf-sshd.sh
-
- if [ -f "$target_script" ]; then
- cp "$target_script" "$target_script.bak"
- fi
-
- # 下载到临时文件
- if ! curl -sL "$script_url" -o "$target_script.tmp"; then
- echo "Script download failed at $(date '+%Y-%m-%d %H:%M:%S')"
- rm -f "$target_script.tmp"
- exit 1
- fi
-
- mv "$target_script.tmp" "$target_script"
-
- chmod +x "$target_script"
- echo "Script updated successfully at $(date '+%Y-%m-%d %H:%M:%S')"
- exit 0
-fi
-
-# 检查 SSHD 是否安装.
-if ! /usr/sbin/sshd -T > /dev/null 2>&1 && [ "$(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
+# GNU/Linux and BSD/macOS stat. No filesystem changes precede ownership checks.
+if stat -c '%u %a %h' "$HOME" >/dev/null 2>&1; then
+ stat_info() { stat -c '%u %a %h' "$1"; }
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]+0hi) return 0
+ if(r==2 && (!number(k[2]) || k[2]+0hi)) 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
-# 检查是否指定了 --allow-root-passwd
-if [ "$(has_param "-p" "--allow-root-passwd")" == "true" ]; then
- # 检查当前用户是否为 root
- if [ "$(id -u)" -eq 0 ]; then
- allow_root_passwd=$(get_param_value "-p" "--allow-root-passwd" | tr '[:upper:]' '[:lower:]')
-
- sshd_config_file="/etc/ssh/sshd_config"
- new_sshd_permit_root_login_setting=""
-
- if [ "$allow_root_passwd" == "yes" ]; then
- new_sshd_permit_root_login_setting="PermitRootLogin yes"
- echo "Setting: Root user is allowed to log in with password."
- elif [ "$allow_root_passwd" == "no" ]; then
- new_sshd_permit_root_login_setting="PermitRootLogin prohibit-password"
- echo "Setting: Root user is prohibited from logging in with password."
- else
- echo "Please specify 'yes' or 'no' for --allow-root-passwd."
- exit 1
- fi
-
- if grep -qE '^#?PermitRootLogin' "$sshd_config_file"; then
- sed -i "s@^#?PermitRootLogin.*@$new_sshd_permit_root_login_setting@g" "$sshd_config_file"
- else
- echo "$new_sshd_permit_root_login_setting" >> "$sshd_config_file"
- fi
- echo "SSHD config updated. Please restart sshd service to apply changes."
-
- else
- echo "The script is executed as a non-root user and cannot set whether to allow root to log in with a password."
- exit 1
+cleanup() {
+ result=$?
+ trap - 0 HUP INT TERM
+ old_ifs=$IFS; IFS='
+'
+ for path in $temps; do rm -f "$path"; done
+ IFS=$old_ifs
+ if [ "$lock_owned" = yes ]; then rm -f "$state/lock/pid"; rmdir "$state/lock" 2>/dev/null || :; fi
+ if [ "$dry_run" = yes ] || [ "$mode" = uninstall ]; then
+ if [ "$created_ssh" = yes ]; then rmdir "$ssh_dir" 2>/dev/null || :; fi
+ if [ "$created_state" = yes ] || [ "$mode" = uninstall ]; then rmdir "$state" 2>/dev/null || :; fi
fi
+ exit "$result"
+}
+new_temp() {
+ TEMP=$(mktemp "$1/.conf-sshd.XXXXXX") || fail 'Cannot create secure temporary file.'
+ temps="$temps
+$TEMP"
+ chmod 600 "$TEMP"
+}
+last_lf() { [ "$(tail -c 1 "$1" | od -An -tu1 | tr -d ' \n')" = 10 ]; }
+quote_sh() { printf "'"; printf '%s' "$1" | sed "s/'/'\\\\''/g"; printf "'"; }
+cron_command="/bin/sh $(quote_sh "$target_script") -o"
+legacy_command="\"/bin/bash $target_script -o\" >> $state/run.log 2>&1"
+
+if [ "$mode" = status ]; then
+ [ "$dry_run" = no ] || fail '--status does not use --dry-run.'
+ if [ -f "$target_script" ]; then say "Installed script: $target_script"; else say 'Script is not installed for scheduled use.'; fi
+ if [ -f "$auth_file" ]; then say "Authorized keys: $auth_file (not modified)"; else say 'No authorized_keys file.'; fi
+ if command -v crontab >/dev/null 2>&1; then
+ if status_cron=$(crontab -l 2>&1); then
+ say 'Current crontab (read-only):'; say "$status_cron"
+ else
+ case $status_cron in 'no crontab for '*) say 'No crontab is installed for this user.' ;;
+ *) fail "Cannot read current crontab: $status_cron" ;; esac
+ fi
+ else say 'crontab is not installed.'; fi
+ exit 0
fi
-# 更新密钥.
-update_sshkeys
-
-# 检查是否指定了 --cron
-if [ "$(has_param "-c" "--cron")" == "true" ]; then
- # 检查 Crontab 是否已安装
- if [ "$(command -v crontab)" == "" ]; then
- if [ "$(id -u)" -eq 0 ]; then
- echo "The crontab is not installed, and the script is executed as a root user, so it will be installed."
- if [ -f /etc/redhat-release ]; then
- yum install -y crontabs
- elif [ -f /etc/debian_version ]; then
- apt-get update
- apt-get install -y cron
- fi
- echo "The crontab has been installed."
- else
- echo "The crontab is not installed, but the script is executed as a non-root user and cannot be installed."
- exit 1
- fi
- else
- echo "The crontab is already installed."
- fi
- cron=$(get_param_value "-c" "--cron" | tr '[:upper:]' '[:lower:]')
- if [ "$cron" == "false" ]; then
- # 检查 Crontab 是否已经设置
- if [ "$( (crontab -l 2>/dev/null || true) | grep -F -c "conf-sshd.sh" )" -eq 0 ]; then
- echo "Crontab already clean. Will not be configured."
- exit 0
- else
- (crontab -l 2>/dev/null || true) | grep -F -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
- target_script=~/.conf-sshd/conf-sshd.sh
- if [ ! -f "$0" ]; then
- echo "Downloading conf-sshd script..."
- if ! curl -sL "$script_url" -o "$target_script.tmp"; then
- echo "Script download failed at $(date '+%Y-m-d %H:%M:%S')"
- rm -f "$target_script.tmp"
- exit 1
- fi
- mv "$target_script.tmp" "$target_script"
- else
- echo "Copying conf-sshd script..."
- cp "$0" "$target_script"
- fi
-
- chmod +x "$target_script"
- echo "Install conf-sshd script successfully."
- # 将当前脚本追加到当前用户的 Crontab 中
- echo "Configuring Crontab..."
- cron_command="\"/bin/bash $target_script -o\" >> $HOME/.conf-sshd/run.log 2>&1"
- cron_job="$cron $cron_command"
-
- ( (crontab -l 2>/dev/null || true) | grep -F -v "conf-sshd.sh") | { cat; echo "$cron_job"; } | crontab -
-
- echo "Crontab has been configured.(Cron: '$cron')"
- fi
+[ -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
+ [ ! -e "$1" ] && [ ! -L "$1" ] || fail "File appeared concurrently; retry after reviewing: $1"
+ else
+ [ -f "$1" ] && cmp -s "$1" "$2" || fail "File changed concurrently; retry after reviewing: $1"
+ 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 "$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!="sk-ssh-ed25519@openssh.com" && $1!="sk-ecdsa-sha2-nistp256@openssh.com") 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" >> "$new_auth"; cat "$payload" >> "$new_auth"; 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" >> "$new_auth"; cat "$payload" >> "$new_auth"; printf '%s\n' "$END_KEYS" >> "$new_auth"
+ dd if="$auth_snapshot" bs=1 skip="$2" 2>/dev/null >> "$new_auth"
+ fi
+ else
+ printf '%s\n' "$BEGIN_KEYS" > "$new_auth"; cat "$payload" >> "$new_auth"; 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
diff --git a/deployment.json b/deployment.json
new file mode 100644
index 0000000..3b759f9
--- /dev/null
+++ b/deployment.json
@@ -0,0 +1,7 @@
+{
+ "baseUrl": "https://ssh.lamgc.me",
+ "repositoryUrl": "https://github.com/LamGC/quickly-conf-sshd",
+ "defaultCron": "*/15 * * * *",
+ "allowEmpty": false,
+ "fallbackSources": []
+}
diff --git a/keys/authorized_keys b/keys/authorized_keys
new file mode 100644
index 0000000..b932339
--- /dev/null
+++ b/keys/authorized_keys
@@ -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.
diff --git a/package-lock.json b/package-lock.json
new file mode 100644
index 0000000..a7596df
--- /dev/null
+++ b/package-lock.json
@@ -0,0 +1,1561 @@
+{
+ "name": "quickly-conf-sshd",
+ "version": "2.0.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "quickly-conf-sshd",
+ "version": "2.0.0",
+ "devDependencies": {
+ "playwright-core": "1.58.2",
+ "wrangler": "4.149.0"
+ },
+ "engines": {
+ "node": ">=22"
+ }
+ },
+ "node_modules/@cloudflare/kv-asset-handler": {
+ "version": "0.5.0",
+ "resolved": "https://registry.npmjs.org/@cloudflare/kv-asset-handler/-/kv-asset-handler-0.5.0.tgz",
+ "integrity": "sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg==",
+ "dev": true,
+ "license": "MIT OR Apache-2.0",
+ "engines": {
+ "node": ">=22.0.0"
+ }
+ },
+ "node_modules/@cloudflare/unenv-preset": {
+ "version": "2.16.2",
+ "resolved": "https://registry.npmjs.org/@cloudflare/unenv-preset/-/unenv-preset-2.16.2.tgz",
+ "integrity": "sha512-JBP1+Z7ZSNG/d4mRP+y8VC5dka3tZVMLEZRvS+rzQ4DGV1EoxRFQckcJTTkXbHSQiTj0DtNI01Zwb/V2fX0mvQ==",
+ "dev": true,
+ "license": "MIT OR Apache-2.0",
+ "peerDependencies": {
+ "unenv": "2.0.0-rc.24",
+ "workerd": ">1.20260305.0 <2.0.0-0"
+ },
+ "peerDependenciesMeta": {
+ "workerd": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@cloudflare/workerd-darwin-64": {
+ "version": "1.20261006.1",
+ "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20261006.1.tgz",
+ "integrity": "sha512-lCX6avJO2It9AyDEYitjmOtJRVp/xmaW0TziyoLAnfxlzozyyRbSFfIIMMCiDx/sTP1++E4VLbV+7BHSD33f0Q==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=16"
+ }
+ },
+ "node_modules/@cloudflare/workerd-darwin-arm64": {
+ "version": "1.20261006.1",
+ "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20261006.1.tgz",
+ "integrity": "sha512-PyFGjOaldtppnLuKsdkoYWXfLVpDjuf8u+uoykX+1Xvxo2EPmCVViq0iDKm+U7c7PRno85ehS3/MPVXyk2HwHw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=16"
+ }
+ },
+ "node_modules/@cloudflare/workerd-linux-64": {
+ "version": "1.20261006.1",
+ "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20261006.1.tgz",
+ "integrity": "sha512-Ntf9S8eesmVufdjaT2kzWlz6Ng9XGYTc6cHQVRKAg59lFufwsIabGeMT6U4KkrpEeSIxgHq8aPtuLbZXE19tCA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=16"
+ }
+ },
+ "node_modules/@cloudflare/workerd-linux-arm64": {
+ "version": "1.20261006.1",
+ "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20261006.1.tgz",
+ "integrity": "sha512-JyeDBymVWw5tVRebSsVE8ojDYxd6UlZrAseHU8G+NpIyd5wF61tXlF/axLeCUqu0pOhBFHb8I5QKi0jwrCK41Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=16"
+ }
+ },
+ "node_modules/@cloudflare/workerd-windows-64": {
+ "version": "1.20261006.1",
+ "resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20261006.1.tgz",
+ "integrity": "sha512-nWP0URNqOBN9fa/gZ7eh/w051DwjOXN8j4lDZDYVXyQPsux6sV3DfyYZT+CjR/rt9nKBSmJT9QE9/bWT18+84w==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=16"
+ }
+ },
+ "node_modules/@cspotcode/source-map-support": {
+ "version": "0.8.1",
+ "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz",
+ "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/trace-mapping": "0.3.9"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@emnapi/runtime": {
+ "version": "1.11.3",
+ "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz",
+ "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@esbuild/aix-ppc64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz",
+ "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz",
+ "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz",
+ "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-x64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz",
+ "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-arm64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz",
+ "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-x64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz",
+ "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-arm64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz",
+ "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-x64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz",
+ "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz",
+ "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz",
+ "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ia32": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz",
+ "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-loong64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz",
+ "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-mips64el": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz",
+ "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==",
+ "cpu": [
+ "mips64el"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ppc64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz",
+ "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-riscv64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz",
+ "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-s390x": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz",
+ "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-x64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz",
+ "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-arm64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz",
+ "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-x64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz",
+ "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-arm64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz",
+ "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-x64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz",
+ "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openharmony-arm64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz",
+ "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/sunos-x64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz",
+ "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-arm64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz",
+ "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-ia32": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz",
+ "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-x64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz",
+ "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@img/colour": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
+ "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@img/sharp-darwin-arm64": {
+ "version": "0.35.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.5.tgz",
+ "integrity": "sha512-QRUlFQ0WxvdWyqqG/WtI3iupfD5rBzmCHXSdPsY91sAtVtTo7Q4cb6zOccZ3gqEqkr0f1As1ehLqmEpDsRf+lg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-darwin-arm64": "1.3.4"
+ }
+ },
+ "node_modules/@img/sharp-darwin-x64": {
+ "version": "0.35.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.5.tgz",
+ "integrity": "sha512-+BR255RhDlpygUpOc/Jdt1nT6DQ3XG/ERo5wbcdOf5Q320dKtPCKPLR1LJs9VGXRaMa8l1uUa0tkCNOXiAxZUw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-darwin-x64": "1.3.4"
+ }
+ },
+ "node_modules/@img/sharp-freebsd-wasm32": {
+ "version": "0.35.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.5.tgz",
+ "integrity": "sha512-Y/z91nEZ4uIBX5X3nfTovjU9lHNKFYbL2lpHCLVNmXQK03VIZvXBBt0KxbPGp2SdGSF+2mQU4e+hQaWOt86iAw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "dependencies": {
+ "@img/sharp-wasm32": "0.35.5"
+ },
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-darwin-arm64": {
+ "version": "1.3.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.4.tgz",
+ "integrity": "sha512-5R89nBYiRdUlSWJxPhO+GVtaXzXSxKnRu/xqMn3KTA3L9EB9Oy/P+Nn2f2vlhPuUdy/Zusb2DarbyTpGCfEDuw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-darwin-x64": {
+ "version": "1.3.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.4.tgz",
+ "integrity": "sha512-iR2OKH80yi0U+dUplyh3/xdpFvps6YkCwsXenIJxqxR1v9o+xtKTGbS9H7cps+2Vxjc8B1j96p75NmTGjIhtpQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-arm": {
+ "version": "1.3.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.4.tgz",
+ "integrity": "sha512-LmRtTsOHuvM2+wlO2Db37dx5MiZhB0FvSunciw48YjdOkZz9KAiRbm8ujeMOA1INqmei5NapFxYEK1D1ZSidmw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-arm64": {
+ "version": "1.3.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.4.tgz",
+ "integrity": "sha512-Y3dgX/6lE2QhQb+Gxy0WZxfg9MEm/JBjamZpS2IklP7xIQoKN4hzAm7KcMVGtaVDt3neE9OKBC7vAfonA/Lr1A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-ppc64": {
+ "version": "1.3.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.4.tgz",
+ "integrity": "sha512-Le6boB8Tai0Nis+gIxIpKx68UDVVIqdR8Tin5Yf1z2LJJQLDJvCDRqRu+jC2qCoD+eIomonmOwB4smBRxfVpYQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-riscv64": {
+ "version": "1.3.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.4.tgz",
+ "integrity": "sha512-aHkkIEHPRdQEegJN20MLmGtxYD9R2wQr3Cwpddnu5+YKMt6Uzax7S9h5gpZTo8wyrGuZSlfQ63OevL5mTyOC7Q==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-s390x": {
+ "version": "1.3.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.4.tgz",
+ "integrity": "sha512-ra/mB6MikESDUO7Yg+Mi95bFBb9GsObURuhnOv3OqknjGe9sZrG8tCe9q0xSIGrtLgvgw0gKnFWcK4blSgQOuQ==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-x64": {
+ "version": "1.3.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.4.tgz",
+ "integrity": "sha512-GJ//SSXbnwSDes02umB3nDJLFcQzw8a18V8fyhqr6tV515tOEMdImjjxj1AoafMRz56F3PHgftnj1QEKSU1zkw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linuxmusl-arm64": {
+ "version": "1.3.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.4.tgz",
+ "integrity": "sha512-hvulFwtjUcagsis6BBxHwGFwWoNZjgYmULGVrZcyfNbjA8hKILbRxGg15/7w5HDyXHXUos/j6baAWqnCyQ2DWA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linuxmusl-x64": {
+ "version": "1.3.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.4.tgz",
+ "integrity": "sha512-6zXKeE/p39I1AmA3cJG35eyBGNqNddLnUXjhwBnsGjFPWqf5VKkDBEqaEkPDoTEtkxwi2vv8Tcr2mDyP4So7Fg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-linux-arm": {
+ "version": "0.35.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.5.tgz",
+ "integrity": "sha512-LEaXK2WdXVK5ykcw0buWyPMsmLLL2vpHLD6yrNSW+JGEL3BZPA4tpKN6iaMc4AxTTAoaX/sU1rOL51lcIz48ZQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-arm": "1.3.4"
+ }
+ },
+ "node_modules/@img/sharp-linux-arm64": {
+ "version": "0.35.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.5.tgz",
+ "integrity": "sha512-LYVx5JTsOM2CBzmxreh+nl64/3H6Xb09iSLknqH47z2T2DFFxDeFLP5y4dJwe6H7uGQlHPyEEtIqyo3DYsRwdQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-arm64": "1.3.4"
+ }
+ },
+ "node_modules/@img/sharp-linux-ppc64": {
+ "version": "0.35.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.5.tgz",
+ "integrity": "sha512-QVxAAq8evVRI9ia2vqgwrmWucn5Dfv+JdWzj75pD8omHLPSP7f8p20O8jxzjCcuCEQEOtYOZUmX1hkiZ0kdevA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-ppc64": "1.3.4"
+ }
+ },
+ "node_modules/@img/sharp-linux-riscv64": {
+ "version": "0.35.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.5.tgz",
+ "integrity": "sha512-LtdreXguaavKODPIfzJ4kffx7UNt1omwtK0rch4EBbbSTXPnxWmYSayXdLJw0fJzQ97kHt1gL/yh4tvU+nCyRQ==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-riscv64": "1.3.4"
+ }
+ },
+ "node_modules/@img/sharp-linux-s390x": {
+ "version": "0.35.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.5.tgz",
+ "integrity": "sha512-UZasTOFiYzotTsGOCu42BfUzP6Tu6Do/947iRm1RsLKvlllxwGcn4RN27LibGWceix4Y+Pmw3jsnTcCQIgWjqA==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-s390x": "1.3.4"
+ }
+ },
+ "node_modules/@img/sharp-linux-x64": {
+ "version": "0.35.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.5.tgz",
+ "integrity": "sha512-SxFtLTeJInhAA9Q836kux2vZNeOBQEx658qvbboZScr0wIARym3IcGmW7KpVD5sbVg0Ojy+udFQdayYIZyoNog==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-x64": "1.3.4"
+ }
+ },
+ "node_modules/@img/sharp-linuxmusl-arm64": {
+ "version": "0.35.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.5.tgz",
+ "integrity": "sha512-9HbMclmI1zlNkFRs3z9/eBtDjfD0sGlrX1z6b1qwmiFY5ElDLh4BC0LPBdVp7z1DXFiKlIcznf+ZlsuZzLxQqg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linuxmusl-arm64": "1.3.4"
+ }
+ },
+ "node_modules/@img/sharp-linuxmusl-x64": {
+ "version": "0.35.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.5.tgz",
+ "integrity": "sha512-4KOphqB035HrVdqLZfCgMzzERrQkkzOwRhl4OAkRO1YCldbaFjySXMaK534Mo0V+LndnlJk+sbUyLeU0ULyD1A==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linuxmusl-x64": "1.3.4"
+ }
+ },
+ "node_modules/@img/sharp-wasm32": {
+ "version": "0.35.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.5.tgz",
+ "integrity": "sha512-Ptsga1su4tQx+LLF1ECS9U6nz5kmrXKo6XVbtR48Ke3ZRxxgaWBu7IDtEe1quo8hiupwm6WFqxVlXaSf7IINGQ==",
+ "dev": true,
+ "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
+ "optional": true,
+ "dependencies": {
+ "@emnapi/runtime": "^1.11.3"
+ },
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-webcontainers-wasm32": {
+ "version": "0.35.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.5.tgz",
+ "integrity": "sha512-hfhF/FmoQyTUkA0bIKFOtw536BQSeBMe6BF6QyWlrPxT754+TFLaZ7sKKTfvvM0yJgKgaYTwnFCIZ/GuDw5SUA==",
+ "cpu": [
+ "wasm32"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "dependencies": {
+ "@img/sharp-wasm32": "0.35.5"
+ },
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-win32-arm64": {
+ "version": "0.35.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.5.tgz",
+ "integrity": "sha512-X4t7g+7ZA5DKblCBEXGjUqqemj4vczING/5viFwAL8h4N3qYeyjwdCvRLHi4EdOUI+2Z7UFlp1VM+p/AuEtm6Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0 AND LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-win32-ia32": {
+ "version": "0.35.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.5.tgz",
+ "integrity": "sha512-5Zm82LoBc43nhwNybZlG7Y1KO//Zhsn306fQl29ZOuStHLGTo3BWL83q3cznX0poxSAMuYL1On/BHBxkBeKr6A==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "Apache-2.0 AND LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-win32-x64": {
+ "version": "0.35.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.5.tgz",
+ "integrity": "sha512-x76eH0vEiHlcMQu8Y8IenntaACtddpT6W0wmXtWrnKcnKI7ME5DdgqhAD6SEWOEl1v2zDvkZDhFA9KnURwpfqg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0 AND LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@jridgewell/resolve-uri": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
+ "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@jridgewell/sourcemap-codec": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz",
+ "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@jridgewell/trace-mapping": {
+ "version": "0.3.9",
+ "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz",
+ "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/resolve-uri": "^3.0.3",
+ "@jridgewell/sourcemap-codec": "^1.4.10"
+ }
+ },
+ "node_modules/@poppinss/colors": {
+ "version": "4.1.6",
+ "resolved": "https://registry.npmjs.org/@poppinss/colors/-/colors-4.1.6.tgz",
+ "integrity": "sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "kleur": "^4.1.5"
+ }
+ },
+ "node_modules/@poppinss/dumper": {
+ "version": "0.6.5",
+ "resolved": "https://registry.npmjs.org/@poppinss/dumper/-/dumper-0.6.5.tgz",
+ "integrity": "sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@poppinss/colors": "^4.1.5",
+ "@sindresorhus/is": "^7.0.2",
+ "supports-color": "^10.0.0"
+ }
+ },
+ "node_modules/@poppinss/exception": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/@poppinss/exception/-/exception-1.2.3.tgz",
+ "integrity": "sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@sindresorhus/is": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-7.2.0.tgz",
+ "integrity": "sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sindresorhus/is?sponsor=1"
+ }
+ },
+ "node_modules/@speed-highlight/core": {
+ "version": "1.2.24",
+ "resolved": "https://registry.npmjs.org/@speed-highlight/core/-/core-1.2.24.tgz",
+ "integrity": "sha512-qeW2e1l78afw8VhRPfPQ1Gjj+KU5XFQ/OFV5ti6eTa9bruO7mJyZtA4vw0ofqmA3tKCkROE9xLk3VZoeRc98nw==",
+ "dev": true,
+ "license": "CC0-1.0"
+ },
+ "node_modules/blake3-wasm": {
+ "version": "2.1.5",
+ "resolved": "https://registry.npmjs.org/blake3-wasm/-/blake3-wasm-2.1.5.tgz",
+ "integrity": "sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/cookie": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
+ "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/detect-libc": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
+ "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/error-stack-parser-es": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/error-stack-parser-es/-/error-stack-parser-es-1.0.5.tgz",
+ "integrity": "sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/antfu"
+ }
+ },
+ "node_modules/esbuild": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz",
+ "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "bin": {
+ "esbuild": "bin/esbuild"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "@esbuild/aix-ppc64": "0.28.2",
+ "@esbuild/android-arm": "0.28.2",
+ "@esbuild/android-arm64": "0.28.2",
+ "@esbuild/android-x64": "0.28.2",
+ "@esbuild/darwin-arm64": "0.28.2",
+ "@esbuild/darwin-x64": "0.28.2",
+ "@esbuild/freebsd-arm64": "0.28.2",
+ "@esbuild/freebsd-x64": "0.28.2",
+ "@esbuild/linux-arm": "0.28.2",
+ "@esbuild/linux-arm64": "0.28.2",
+ "@esbuild/linux-ia32": "0.28.2",
+ "@esbuild/linux-loong64": "0.28.2",
+ "@esbuild/linux-mips64el": "0.28.2",
+ "@esbuild/linux-ppc64": "0.28.2",
+ "@esbuild/linux-riscv64": "0.28.2",
+ "@esbuild/linux-s390x": "0.28.2",
+ "@esbuild/linux-x64": "0.28.2",
+ "@esbuild/netbsd-arm64": "0.28.2",
+ "@esbuild/netbsd-x64": "0.28.2",
+ "@esbuild/openbsd-arm64": "0.28.2",
+ "@esbuild/openbsd-x64": "0.28.2",
+ "@esbuild/openharmony-arm64": "0.28.2",
+ "@esbuild/sunos-x64": "0.28.2",
+ "@esbuild/win32-arm64": "0.28.2",
+ "@esbuild/win32-ia32": "0.28.2",
+ "@esbuild/win32-x64": "0.28.2"
+ }
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/kleur": {
+ "version": "4.1.5",
+ "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz",
+ "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/miniflare": {
+ "version": "5.20261006.1-alpha",
+ "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-5.20261006.1-alpha.tgz",
+ "integrity": "sha512-6ylv8tamkrspDI2R/cAYfv+YWmfGNdXe76Pi9TauEUWsFVXn3LNF9Yk8oduzf1a+TYsSRYTqlOE7+nGGRsMHdw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@cspotcode/source-map-support": "0.8.1",
+ "sharp": "0.35.5",
+ "undici": "7.29.1",
+ "workerd": "1.20261006.1",
+ "ws": "8.21.0",
+ "youch": "4.1.0-beta.10"
+ },
+ "engines": {
+ "node": ">=22.0.0"
+ }
+ },
+ "node_modules/path-to-regexp": {
+ "version": "6.3.0",
+ "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz",
+ "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/pathe": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
+ "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/playwright-core": {
+ "version": "1.58.2",
+ "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz",
+ "integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "playwright-core": "cli.js"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/semver": {
+ "version": "7.8.5",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
+ "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/sharp": {
+ "version": "0.35.5",
+ "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.5.tgz",
+ "integrity": "sha512-Ywn4OnzGukp7CDMrp08RQ50YKmuwG47brZgIVPTvBaaAfQlRlygrRqSrxdCiL9M+LlzLBiJ68IR1QqvzHyjC7g==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@img/colour": "^1.1.0",
+ "detect-libc": "^2.1.2",
+ "semver": "^7.8.5"
+ },
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-darwin-arm64": "0.35.5",
+ "@img/sharp-darwin-x64": "0.35.5",
+ "@img/sharp-freebsd-wasm32": "0.35.5",
+ "@img/sharp-libvips-darwin-arm64": "1.3.4",
+ "@img/sharp-libvips-darwin-x64": "1.3.4",
+ "@img/sharp-libvips-linux-arm": "1.3.4",
+ "@img/sharp-libvips-linux-arm64": "1.3.4",
+ "@img/sharp-libvips-linux-ppc64": "1.3.4",
+ "@img/sharp-libvips-linux-riscv64": "1.3.4",
+ "@img/sharp-libvips-linux-s390x": "1.3.4",
+ "@img/sharp-libvips-linux-x64": "1.3.4",
+ "@img/sharp-libvips-linuxmusl-arm64": "1.3.4",
+ "@img/sharp-libvips-linuxmusl-x64": "1.3.4",
+ "@img/sharp-linux-arm": "0.35.5",
+ "@img/sharp-linux-arm64": "0.35.5",
+ "@img/sharp-linux-ppc64": "0.35.5",
+ "@img/sharp-linux-riscv64": "0.35.5",
+ "@img/sharp-linux-s390x": "0.35.5",
+ "@img/sharp-linux-x64": "0.35.5",
+ "@img/sharp-linuxmusl-arm64": "0.35.5",
+ "@img/sharp-linuxmusl-x64": "0.35.5",
+ "@img/sharp-webcontainers-wasm32": "0.35.5",
+ "@img/sharp-win32-arm64": "0.35.5",
+ "@img/sharp-win32-ia32": "0.35.5",
+ "@img/sharp-win32-x64": "0.35.5"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/supports-color": {
+ "version": "10.2.2",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz",
+ "integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/supports-color?sponsor=1"
+ }
+ },
+ "node_modules/tslib": {
+ "version": "2.8.1",
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
+ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
+ "dev": true,
+ "license": "0BSD",
+ "optional": true
+ },
+ "node_modules/undici": {
+ "version": "7.29.1",
+ "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.1.tgz",
+ "integrity": "sha512-RYONW2MeafgYlkVOKYKkA/Ag7BmXqgIWCa8t1m0JcxrQg9pI9lEqRhAOruOBCbAohOa/gkCF+iPi9hrgvTzu6Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=20.18.1"
+ }
+ },
+ "node_modules/unenv": {
+ "version": "2.0.0-rc.24",
+ "resolved": "https://registry.npmjs.org/unenv/-/unenv-2.0.0-rc.24.tgz",
+ "integrity": "sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "pathe": "^2.0.3"
+ }
+ },
+ "node_modules/workerd": {
+ "version": "1.20261006.1",
+ "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20261006.1.tgz",
+ "integrity": "sha512-8MadBuffZ5NdpLwZjUmsqT5QlaKXq4gjgT6wzcKeynZTICNXyePhc+5QVoKiK7+MMhFTLNrRO2KXLlpk8Q6BkA==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "workerd": "bin/workerd"
+ },
+ "engines": {
+ "node": ">=16"
+ },
+ "optionalDependencies": {
+ "@cloudflare/workerd-darwin-64": "1.20261006.1",
+ "@cloudflare/workerd-darwin-arm64": "1.20261006.1",
+ "@cloudflare/workerd-linux-64": "1.20261006.1",
+ "@cloudflare/workerd-linux-arm64": "1.20261006.1",
+ "@cloudflare/workerd-windows-64": "1.20261006.1"
+ }
+ },
+ "node_modules/wrangler": {
+ "version": "4.149.0",
+ "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.149.0.tgz",
+ "integrity": "sha512-OzK7xmB5r5iLKb3cIT3783g13fe6T7xyeKu9LEdUyl+DKAM2KP/jkjnE0OUhyw+mB/XJM9ycjgF3MWm8PR9amg==",
+ "dev": true,
+ "license": "MIT OR Apache-2.0",
+ "dependencies": {
+ "@cloudflare/kv-asset-handler": "0.5.0",
+ "@cloudflare/unenv-preset": "2.16.2",
+ "blake3-wasm": "2.1.5",
+ "esbuild": "0.28.2",
+ "miniflare": "5.20261006.1-alpha",
+ "path-to-regexp": "6.3.0",
+ "unenv": "2.0.0-rc.24",
+ "workerd": "1.20261006.1"
+ },
+ "bin": {
+ "cf-wrangler": "bin/cf-wrangler.js",
+ "wrangler": "bin/wrangler.js",
+ "wrangler2": "bin/wrangler.js"
+ },
+ "engines": {
+ "node": ">=22.0.0"
+ },
+ "optionalDependencies": {
+ "fsevents": "2.3.3"
+ },
+ "peerDependencies": {
+ "@cloudflare/workers-types": "^5.20261006.1"
+ },
+ "peerDependenciesMeta": {
+ "@cloudflare/workers-types": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/ws": {
+ "version": "8.21.0",
+ "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz",
+ "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10.0.0"
+ },
+ "peerDependencies": {
+ "bufferutil": "^4.0.1",
+ "utf-8-validate": ">=5.0.2"
+ },
+ "peerDependenciesMeta": {
+ "bufferutil": {
+ "optional": true
+ },
+ "utf-8-validate": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/youch": {
+ "version": "4.1.0-beta.10",
+ "resolved": "https://registry.npmjs.org/youch/-/youch-4.1.0-beta.10.tgz",
+ "integrity": "sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@poppinss/colors": "^4.1.5",
+ "@poppinss/dumper": "^0.6.4",
+ "@speed-highlight/core": "^1.2.7",
+ "cookie": "^1.0.2",
+ "youch-core": "^0.3.3"
+ }
+ },
+ "node_modules/youch-core": {
+ "version": "0.3.3",
+ "resolved": "https://registry.npmjs.org/youch-core/-/youch-core-0.3.3.tgz",
+ "integrity": "sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@poppinss/exception": "^1.2.2",
+ "error-stack-parser-es": "^1.0.5"
+ }
+ }
+ }
+}
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..61305a5
--- /dev/null
+++ b/package.json
@@ -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"
+ }
+}
diff --git a/scripts/build.mjs b/scripts/build.mjs
new file mode 100644
index 0000000..2ffcd1b
--- /dev/null
+++ b/scripts/build.mjs
@@ -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; }
+}
diff --git a/scripts/verify-release.mjs b/scripts/verify-release.mjs
new file mode 100644
index 0000000..aa039f1
--- /dev/null
+++ b/scripts/verify-release.mjs
@@ -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).`);
+}
diff --git a/site/assets/app.js b/site/assets/app.js
new file mode 100644
index 0000000..08e549b
--- /dev/null
+++ b/site/assets/app.js
@@ -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");
+})();
diff --git a/site/assets/style.css b/site/assets/style.css
new file mode 100644
index 0000000..5c50993
--- /dev/null
+++ b/site/assets/style.css
@@ -0,0 +1,223 @@
+: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; }
+}
+@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; }
+}
diff --git a/site/index.html b/site/index.html
new file mode 100644
index 0000000..5175c61
--- /dev/null
+++ b/site/index.html
@@ -0,0 +1,87 @@
+
+
+
+
+
+
+
+ SSH 公钥与快速部署 · Quickly Configuring SSH Server
+
+
+
+
+ 跳到 SSH 公钥
+
+
+
+
+ @@PREVIEW_NOTICE@@
+
+
+
SSH KEY DISTRIBUTION
+
公钥集中管理,
服务器快速就绪。
+
核对公钥,选择命令,配置 SSH。
查看当前公钥来源与内容版本,按需部署。
+
+
+
+
+
+
+
+
01 / PUBLIC KEYS
SSH 公钥
部署前,请核对指纹及密钥归属。
+
+
+ @@KEYS_NOTICE@@
+ @@KEYS_HTML@@
+ i这里展示的是公开密钥。私钥应始终由你保管,本页面不会要求上传或输入私钥。
+
+
+
+ 02 / QUICK START
按需选择,复制即用
以下命令会在你执行后修改服务器配置。请先检查脚本,并保留当前 SSH 会话以验证新连接。
查看脚本 ↗
+ @@COMMANDS_HTML@@
+ 每条命令独立使用。高级选项涉及 SSH 登录策略或卸载,请阅读说明后操作。
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/browser-fixture.mjs b/tests/browser-fixture.mjs
new file mode 100644
index 0000000..60b0b7b
--- /dev/null
+++ b/tests/browser-fixture.mjs
@@ -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
& "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=" } },
+ 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();
+ }),
+ };
+}
diff --git a/tests/browser.test.mjs b/tests/browser.test.mjs
new file mode 100644
index 0000000..7aeeeb5
--- /dev/null
+++ b/tests/browser.test.mjs
@@ -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();
+ }
+});
diff --git a/tests/build.test.mjs b/tests/build.test.mjs
new file mode 100644
index 0000000..5402404
--- /dev/null
+++ b/tests/build.test.mjs
@@ -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://user@x.example','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()+' ');
+ const html=renderKeys(keys);assert.ok(html.includes('<script>'));assert.ok(!html.includes('';
+ 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(/