From 5798bf8900f7a54be88aa979ffb00a260f361f08 Mon Sep 17 00:00:00 2001 From: LamGC Date: Mon, 12 Oct 2026 23:11:42 +0800 Subject: [PATCH] feat: independently publish server SSH keys with safe sync and Worker homepage --- .github/workflows/check.yml | 37 + .github/workflows/deploy.yml | 40 + .github/workflows/update-access-url.yml | 28 - .gitignore | 8 + README.md | 263 ++-- cf-worker/src/index.js | 107 +- cf-worker/src/key-source.js | 223 ++++ cf-worker/src/router.js | 70 + cf-worker/src/view.js | 49 + cf-worker/wrangler.toml | 7 +- conf-sshd.sh | 782 +++++++----- deployment.json | 7 + keys/authorized_keys | 3 + package-lock.json | 1561 +++++++++++++++++++++++ package.json | 19 + scripts/build.mjs | 108 ++ scripts/verify-release.mjs | 29 + site/assets/app.js | 137 ++ site/assets/style.css | 223 ++++ site/index.html | 87 ++ tests/browser-fixture.mjs | 85 ++ tests/browser.test.mjs | 224 ++++ tests/build.test.mjs | 84 ++ tests/fixtures/keys.pub | 1 + tests/key-source.test.mjs | 303 +++++ tests/shell.test.mjs | 23 + tests/shell.test.py | 669 ++++++++++ tests/site.test.mjs | 225 ++++ tests/worker.test.mjs | 66 + 29 files changed, 4943 insertions(+), 525 deletions(-) create mode 100644 .github/workflows/check.yml create mode 100644 .github/workflows/deploy.yml delete mode 100644 .github/workflows/update-access-url.yml create mode 100644 .gitignore create mode 100644 cf-worker/src/key-source.js create mode 100644 cf-worker/src/router.js create mode 100644 cf-worker/src/view.js create mode 100644 deployment.json create mode 100644 keys/authorized_keys create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 scripts/build.mjs create mode 100644 scripts/verify-release.mjs create mode 100644 site/assets/app.js create mode 100644 site/assets/style.css create mode 100644 site/index.html create mode 100644 tests/browser-fixture.mjs create mode 100644 tests/browser.test.mjs create mode 100644 tests/build.test.mjs create mode 100644 tests/fixtures/keys.pub create mode 100644 tests/key-source.test.mjs create mode 100644 tests/shell.test.mjs create mode 100644 tests/shell.test.py create mode 100644 tests/site.test.mjs create mode 100644 tests/worker.test.mjs 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 处理程序,然后下一步; - -![Create a new worker](docs/Create-a-new-worker.png) - -创建完成后点击 Worker 名称右边的**快速编辑**,将 `cf-worker/src/index.js` 中的内容复制到编辑框中,把原本的代码覆盖掉,然后保存; -![Copy Code](docs/copy-js-code-to-worker.png) - -保存后需要访问一下 Worker,检查是否有误(如果不检查仔细,把其他人的 SSH 密钥设置到服务器里了,那就寄咯): -- 直接访问 Worker 地址,看看是否能跳转到你 Fork 的仓库中(地址栏显示了 `https://{你的 Github 用户名}.github.io/{Fork的仓库名}/`); -- 访问 `{Worker地址}/ssh.keys`,应该显示你的在 Github 设置了的所有 SSH 公钥,如果是空的或者不是你的密钥,那就检查一下是不是 Github 用户名填错了; -- 访问 `{Worker地址}/script.sh`,应该返回一个脚本,脚本开头的配置应与你在 Worker 开头的有关配置一致。 - -检查无误后就算安装完成了,然后修改本 Readme 的上面几个部分,把域名都改成自己的,保存后提交 Push 上去就好。 -(如果你不会用 Git,那就直接去 Github 网页上改 Readme.md) - -### (可选)使用自定义域名 -在 Worker 详情中,点击“触发器”,然后点击“添加自定义域”,按照提示输入已添加在 Cloudflare 中的域名即可。 -![Add Custom Domain](docs/add-custom-domain.png) -![Input Custom Domain](docs/input-custom-domain.png) -![Added Custom Domain](docs/added-custom-domain.png) +客户端会检测写入期间可观察到的手工修改并停止;其他编辑器若不遵守同一锁,仍无法保证操作系统级的原子比较并交换。请避免同时手工编辑授权文件。 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 `
${escapeHTML(text)}
`; +} +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 + + + + + +
+ + +
+ @@PREVIEW_NOTICE@@ +
+
+

SSH KEY DISTRIBUTION

+

公钥集中管理,
服务器快速就绪。

+

核对公钥,选择命令,配置 SSH。
查看当前公钥来源与内容版本,按需部署。

+ +
+ +
+ +
+
+

01 / PUBLIC KEYS

SSH 公钥

部署前,请核对指纹及密钥归属。

+
原始文件
+
+

@@KEYS_NOTICE@@

+
@@KEYS_HTML@@
+

这里展示的是公开密钥。私钥应始终由你保管,本页面不会要求上传或输入私钥。

+
+ +
+

02 / QUICK START

按需选择,复制即用

以下命令会在你执行后修改服务器配置。请先检查脚本,并保留当前 SSH 会话以验证新连接。

查看脚本
+
@@COMMANDS_HTML@@
+

每条命令独立使用。高级选项涉及 SSH 登录策略或卸载,请阅读说明后操作。

+
+ +
+ + +
+ +

+ +

手动复制完整内容

+

浏览器未允许自动复制。内容已选中,请按 Ctrl+C 或 ⌘C,也可以长按文本进行复制。

+ + +

关闭此窗口后,可继续查看页面。

+
+ + + 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(/