From fc7a0caf81097e7cd55e7071e43627f852ad6b6a Mon Sep 17 00:00:00 2001 From: gdz66601 Date: Thu, 24 Sep 2026 18:54:18 +0800 Subject: [PATCH] feat: add isolated HTTP port forwarding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增独立预览 Worker 的 HTTP 端口转发,隔离主站登录态。 --- .github/workflows/deploy.yml | 1 + .gitignore | 1 + DEPLOYMENT.md | 15 ++ README.md | 15 +- frontend/src/dashboard.ts | 28 ++++ frontend/src/forward-page.css | 26 +++ frontend/src/forward-page.ts | 206 ++++++++++++++++++++++++ scripts/cloudflare-access.ts | 7 +- scripts/deploy.ts | 24 ++- scripts/deployment-config.ts | 28 +++- src/backend/durable-object.ts | 20 +++ src/backend/session.ts | 37 +++++ src/forwarding/body.ts | 20 +++ src/forwarding/channel.ts | 114 ++++++++++++++ src/forwarding/http.ts | 254 ++++++++++++++++++++++++++++++ src/forwarding/routes.ts | 37 +++++ src/forwarding/security.ts | 45 ++++++ src/forwarding/state.ts | 80 ++++++++++ src/preview-worker.ts | 65 ++++++++ src/ssh/channel.ts | 16 ++ src/types.ts | 6 +- src/worker.ts | 2 + tests/browser/forwarding.spec.ts | 82 ++++++++++ tests/deployment.test.ts | 16 +- tests/forwarding-auth.test.ts | 110 +++++++++++++ tests/forwarding-http.test.ts | 44 ++++++ tests/forwarding-protocol.test.ts | 62 ++++++++ wrangler.preview.toml | 15 ++ 28 files changed, 1362 insertions(+), 14 deletions(-) create mode 100644 frontend/src/forward-page.css create mode 100644 frontend/src/forward-page.ts create mode 100644 src/forwarding/body.ts create mode 100644 src/forwarding/channel.ts create mode 100644 src/forwarding/http.ts create mode 100644 src/forwarding/routes.ts create mode 100644 src/forwarding/security.ts create mode 100644 src/forwarding/state.ts create mode 100644 src/preview-worker.ts create mode 100644 tests/browser/forwarding.spec.ts create mode 100644 tests/forwarding-auth.test.ts create mode 100644 tests/forwarding-http.test.ts create mode 100644 tests/forwarding-protocol.test.ts create mode 100644 wrangler.preview.toml diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 6dda556..6c7836b 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -48,6 +48,7 @@ jobs: D1_DATABASE_ID: ${{ vars.D1_DATABASE_ID }} # 域名本身不是敏感信息,推荐使用 Variable;同时兼容已将它保存为 Secret 的仓库。 CUSTOM_DOMAIN: ${{ secrets.CUSTOM_DOMAIN || vars.CUSTOM_DOMAIN }} + PREVIEW_DOMAIN: ${{ vars.PREVIEW_DOMAIN }} steps: - name: Check out repository uses: actions/checkout@v4 diff --git a/.gitignore b/.gitignore index e4e2dbd..a0c322f 100644 --- a/.gitignore +++ b/.gitignore @@ -12,5 +12,6 @@ deliverables/ frontend/dist-* scripts-tmp/ .wrangler.generated.toml +.wrangler.preview.generated.toml test-results/ playwright-report/ diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md index 3cb3baa..f69c864 100644 --- a/DEPLOYMENT.md +++ b/DEPLOYMENT.md @@ -11,6 +11,7 @@ | `AUTH_PROVIDER` | Variable | `cloudflare`(默认) | `github` | | `CLOUDFLARE_API_TOKEN` | Secret | 必填 | 必填 | | `CUSTOM_DOMAIN` | Variable | 推荐填写实际主机名 | 推荐填写实际主机名 | +| `PREVIEW_DOMAIN` | Variable | 仅填预览 hostname;留空自动生成 | 仅填预览 hostname;留空自动生成 | | `ADMIN_EMAIL` | Variable | 管理员邮箱 | 不需要 | | `GITHUB_CLIENT_ID` | Variable | 不需要 | OAuth App 的 Client ID | | `GITHUB_CLIENT_SECRET` | Secret | 不需要 | OAuth App 的 Client Secret | @@ -62,6 +63,19 @@ GitHub OAuth App 必须由用户在 GitHub 创建;普通 GitHub Token 没有 API Token 只存 GitHub Secret,不放普通变量、代码或命令行输入框。不要将 Token 填到 Run workflow 的邮箱字段。 +## 独立预览 Worker 与端口转发 + +远端网站可能已被入侵并返回恶意 JavaScript。同源代理会让它代发主站 SSH API;`HttpOnly` 只能保护 Cookie,挡不住同源脚本调用接口。独立 Worker 本身不代表不同 site:若仍在同一 site,主站 Cookie 仍有风险,因此必须使用跨 site 的预览域名。 + +- 部署先发布主 Worker,再发布只绑定 `SSH_SESSIONS` 的预览 Worker;预览不绑定 D1、ASSETS、加密密钥或主站接口。 +- `PREVIEW_DOMAIN` 是 Actions Variable,只填 hostname。未配置时默认 `-preview.<账户子域>.workers.dev`。 +- `ssh.example.com` + 默认 `workers.dev` 可行;`ssh.example.com` + `preview.example.com` 会拒绝;同一账户下两个 `workers.dev` 也会拒绝。主站只有 `workers.dev` 时,必须为预览配置独立的自定义域名。 +- 运行时 `PREVIEW_ORIGIN` 由部署脚本生成并写入两个 Worker,用户不需要手工修改主配置。 + +使用主机管理页选择主机和端口(例如 `127.0.0.1` HTTP),确认指纹后打开新预览窗口;管理页保留在原窗口。停止转发、离开预览页或 SSH 断线后,预览失效。数据流为:主站登录 -> SSH Durable Object -> 一次性 fragment -> 预览 HttpOnly capability -> 远端 HTTP。票据 60 秒内只能兑换一次;兑换后的授权最长 1 小时,二者不是同一时限。 + +预览支持 HTTP/SSE、相对资源、表单、目标 Cookie、重定向和 HTTP Basic 鉴权;仅支持 HTTP 上游 `127.0.0.1`。上传上限 16 MiB,不改变 SFTP 的 64 MiB 限制;最多 24 个并发通道;60 秒是请求通道闲置超时,不是整个 SSH 连接的 60 秒寿命。不支持 HTTPS 上游、WebSocket、Service Worker、JavaScript 写死 `localhost` 或 OAuth 固定 callback。同一预览 origin 内的网站不相互隔离,切换前关闭旧窗口,只用于一个网站且不要分享。主站跨 site 只能防止登录态被预览站点盗取,不代表预览网站本身安全。 + ## 自动执行顺序 1. 校验本地配置,运行类型检查、测试、前端构建和 Wrangler dry-run。 @@ -101,6 +115,7 @@ EdgeSSH-Auto-Update: true | `D1_DATABASE_ID` | Variable | 指定已有 D1 UUID,不填则按名称查找 | | `ACCESS_IDP_IDS` | Variable | 新应用采用的 IdP UUID,多个用逗号分隔 | | `GITHUB_ADMIN_ID` | Variable | 仅显式更换 GitHub 管理员时填写数字用户 ID | +| `PREVIEW_DOMAIN` | Variable | 预览 hostname;留空为 `-preview.<账户子域>.workers.dev` | | `ENCRYPTION_KEY` | Secret,仅恢复/迁移使用 | 仅 Worker 尚无密钥时使用;已有密钥不会覆盖 | `DB`、`SSH_SESSIONS`、`ASSETS` 是资源绑定,不是需要用户创建的变量。`CONNECT_TIMEOUT_MS` 已有默认值 `10000`。 diff --git a/README.md b/README.md index 05b3790..76c7304 100644 --- a/README.md +++ b/README.md @@ -114,6 +114,7 @@ SSH 握手、密钥交换、认证与通道逻辑在 Worker 内完成。浏览 | `AUTH_PROVIDER` | Variable | `cloudflare` | `github` | | `CLOUDFLARE_API_TOKEN` | Secret | 必填 | 必填,不需要 Access 权限 | | `CUSTOM_DOMAIN` | Variable | 推荐,如 `ssh.example.com` | 推荐,如 `ssh.example.com` | +| `PREVIEW_DOMAIN` | Variable | 预览入口 hostname;仅填域名 | 预览入口 hostname;仅填域名 | | `ADMIN_EMAIL` | Variable | 管理员邮箱,也可在 Run workflow 输入 | 不填 | | `GITHUB_CLIENT_ID` | Variable | 不填 | OAuth App 的 Client ID | | `GITHUB_CLIENT_SECRET` | Secret | 不填 | OAuth App 的 Client Secret | @@ -161,6 +162,7 @@ npm ci | `D1_DATABASE_ID` | `00000000-0000-4000-8000-000000000001` | 复用明确指定的数据库 | | `ACCESS_IDP_IDS` | `一个或多个 IdP UUID,以逗号分隔` | 创建新应用时使用已有 GitHub/其他 IdP,而非自动配置 OTP | | `GITHUB_ADMIN_ID` | `12345678` | 仅在明确更换 GitHub 管理员时填写 | +| `PREVIEW_DOMAIN` | `preview.example.com` | 预览入口 hostname;留空自动生成 `-preview.<账户 workers.dev 子域>` | `ENCRYPTION_KEY` 由部署流程管理并持久保存在 **Cloudflare Worker Secrets**;Cloudflare 模式另存 `ACCESS_TEAM_DOMAIN`、`ACCESS_AUD`,GitHub 模式同步 `GITHUB_CLIENT_SECRET`。不需要用户复制自动生成的值回 GitHub。加密密钥只在首次部署生成,后续保留,即使 GitHub 留有旧值也不会覆盖线上密钥。不要删除 Worker 或其加密密钥;Cloudflare 不提供密钥明文读回,丢失后无法解密已有资料。 @@ -215,6 +217,16 @@ EdgeSSH/ ## 安全与限制 +### 独立预览 Worker + +远端网站可能已被入侵并返回恶意 JavaScript。若代理与主站同源,脚本就能代发主站 SSH 管理 API;HttpOnly 只能保护 Cookie,不能阻止同源脚本调用接口。部署会先发布主 Worker,再发布只绑定 `SSH_SESSIONS` 的预览 Worker;预览不持有 D1、ASSETS 或任何 Secret。不同 Worker 不必然代表不同 site:普通域名比较末两段,`workers.dev` 比较末三段;同账户双 workers.dev 会明确拒绝,跨 site 隔离是保护主站会话边界的必要成本。 + +凭据只在 URL fragment 中短暂传递,随后立即换成 HttpOnly Cookie;票据一次性使用,断线或 1 小时后失效。支持常规资源、表单、目标 Cookie、Location 跳转和 HTTP Basic 鉴权。首版仅支持 HTTP-only 回环 `127.0.0.1`,上传上限 16 MiB、最多 24 个并发 SSH 通道、闲置 60 秒;不支持 HTTPS、WebSocket、Service Worker、硬编码 localhost 的 JavaScript 或 OAuth 固定回调。SFTP 文件上传仍是独立的 64 MiB 限制。同一预览 origin 内的网站不再相互隔离;切换前关闭旧预览,只用于一个网站,不能对外分享。 + +### 端口转发 + +在主机管理页选择主机和端口转发类型(例如 `127.0.0.1` 的 HTTP),确认 SSH 主机指纹后,系统会打开新的预览窗口。管理页会保留在原窗口;停止转发、离开预览页或 SSH 断线后,预览立即失效。预览票据只使用一次,不能分享给他人。 + ### 安全边界 - **不是端到端加密**:Worker 是实际的 SSH 客户端,会在会话内处理明文凭据。请仅部署到可信账户,并使用最小权限的 SSH 账号或密钥。 @@ -232,8 +244,9 @@ EdgeSSH/ | 私钥认证 | 未加密 OpenSSH Ed25519、RSA、ECDSA P-256/P-384/P-521 | | 主机管理 | 单一管理员工作区最多 200 台;不提供多租户或独立用户资料 | | 终端编码 | UTF-8、GB18030、Big5,取决于浏览器解码支持 | +| 端口转发 | HTTP/SSE、相对资源、表单、Cookie、重定向、HTTP Basic 鉴权 | -暂不支持加密私钥、PEM/PKCS#8 私钥、SSH Agent、多因素键盘交互认证、SCP、端口转发、ProxyJump、SSH 压缩与会话内 rekey;不支持出站 TCP 25 端口。 +暂不支持加密私钥、PEM/PKCS#8 私钥、SSH Agent、多因素键盘交互认证、SCP、ProxyJump、SSH 压缩与会话内 rekey;不支持出站 TCP 25 端口。 ### 独立文件管理 diff --git a/frontend/src/dashboard.ts b/frontend/src/dashboard.ts index b222755..f0af789 100644 --- a/frontend/src/dashboard.ts +++ b/frontend/src/dashboard.ts @@ -2,6 +2,7 @@ import { api, saveHost, removeHost, refreshHostLocation, type CloudHost, type Ho import type { HostGlobe } from './globe'; import type { FilePage } from './file-page'; import type { Snippets } from './snippets'; +import { ForwardPage } from './forward-page'; import { countryFlag } from './flags'; import { systemIcon } from './os-icons'; import './dashboard.css'; @@ -19,6 +20,7 @@ const icons = { home: '', server: '', terminal: '', + forward: '', snippets: '', folder: '', shield: '', @@ -40,6 +42,7 @@ export class Dashboard { private returnFocus?: HTMLElement; private readonly dialog: HTMLDialogElement; private readonly form: HTMLFormElement; + private readonly forwarding = new ForwardPage(); constructor(private readonly actions: DashboardActions) { this.root.id = 'dashboard'; @@ -56,6 +59,7 @@ export class Dashboard { + ${icon('shield')}身份
保护
@@ -110,6 +114,7 @@ export class Dashboard { document.body.prepend(this.root); this.get('.home-layout').append(this.actions.files.root); this.get('.home-layout').append(this.actions.snippets.page); + this.get('.home-layout').append(this.forwarding.root); this.dialog = this.get('.host-dialog'); this.form = this.get('#cloud-host-form'); this.get('#account-action').addEventListener('click', async (event) => { @@ -135,6 +140,7 @@ export class Dashboard { }); this.get('#rail-files').addEventListener('click', () => this.showFiles()); this.get('#rail-snippets').addEventListener('click', () => this.showSnippets()); + this.get('#rail-forward').addEventListener('click', () => this.showForwarding()); for (const id of ['#quick-connect', '#bottom-quick']) this.get(id).addEventListener('click', () => { if (this.busy || !this.actions.files.confirmLeave()) return; this.actions.leaveWorkspace(); @@ -164,6 +170,10 @@ export class Dashboard { files.className = 'icon-button home-back'; files.textContent = '文件管理'; files.type = 'button'; files.addEventListener('click', () => this.showFiles()); document.querySelector('#app .topbar-actions')!.prepend(files); + const forwarding = document.createElement('button'); + forwarding.className = 'icon-button home-back'; forwarding.textContent = '端口转发'; forwarding.type = 'button'; + forwarding.addEventListener('click', () => this.showForwarding()); + document.querySelector('#app .topbar-actions')!.append(forwarding); this.show(); } @@ -197,6 +207,7 @@ export class Dashboard { setHosts(hosts: CloudHost[]): void { this.hosts = hosts; this.actions.files.setHosts(hosts); + this.forwarding.setHosts(hosts); if (this.group && !hosts.some((host) => host.group === this.group)) this.group = ''; this.renderFilters(); this.renderList(); this.globe?.setHosts(hosts); @@ -213,6 +224,7 @@ export class Dashboard { } show(): void { + this.forwarding.hide(); this.actions.files.hide(); this.actions.snippets.hide(); this.isHome = true; this.root.hidden = false; @@ -224,6 +236,7 @@ export class Dashboard { } openWorkspace(): void { + this.forwarding.hide(); this.actions.files.hide(); this.actions.snippets.hide(); this.isHome = false; this.root.hidden = true; @@ -235,6 +248,7 @@ export class Dashboard { } showFiles(): void { + this.forwarding.hide(); this.actions.snippets.hide(); this.isHome = false; this.root.hidden = false; this.get('.home-content').hidden = true; @@ -247,6 +261,7 @@ export class Dashboard { showSnippets(): void { if (!this.actions.files.confirmLeave()) return; + this.forwarding.hide(); // 仅切换视图,不结束 SSH;在片段页编辑后可回到同一会话。 this.actions.files.hide(); this.isHome = false; this.root.hidden = false; @@ -258,6 +273,19 @@ export class Dashboard { this.actions.snippets.show(); } + showForwarding(): void { + if (!this.actions.files.confirmLeave()) return; + this.actions.files.hide(); + this.actions.snippets.hide(); + this.isHome = false; this.root.hidden = false; + this.get('.home-content').hidden = true; + document.getElementById('app')!.hidden = true; + document.body.dataset.view = 'forward'; + this.selectNavigation('rail-forward'); + this.globe?.setActive(false); + this.forwarding.show(); + } + private selectNavigation(id: string): void { this.root.querySelectorAll('.rail-item').forEach((button) => { button.classList.toggle('selected', button.id === id); diff --git a/frontend/src/forward-page.css b/frontend/src/forward-page.css new file mode 100644 index 0000000..247c114 --- /dev/null +++ b/frontend/src/forward-page.css @@ -0,0 +1,26 @@ +body[data-view='forward'] { overflow-y: auto; background: var(--home-bg); } +.forward-page { min-width: 0; width: 100%; max-width: 1240px; margin-inline: auto; padding: 36px 32px; } +.forward-card { padding: 24px; border: 1px solid var(--home-line); border-radius: 10px; background: var(--home-surface); } +.forward-badge { padding: 6px 10px; border-radius: 5px; background: var(--home-accent-soft); color: var(--home-accent-hover); font-size: 12px; white-space: nowrap; } +.forward-form { display: grid; grid-template-columns: minmax(180px, 1fr) 130px auto auto; align-items: end; gap: 16px; } +.forward-form label { display: grid; gap: 8px; min-width: 0; font-weight: 600; } +#dashboard .forward-form :is(input, select) { width: 100%; height: 44px; min-width: 0; border: 1px solid var(--home-line); border-radius: 6px; padding: 8px 10px; background: var(--home-bg); color: var(--home-ink); font-size: 16px; } +#dashboard .forward-page .home-button { min-height: 44px; } +.forward-hint { color: var(--home-muted); font-size: 13px; line-height: 1.8; } +.forward-status { display: flex; align-items: center; justify-content: space-between; gap: 16px; border-top: 1px solid var(--home-line); padding-top: 18px; margin-top: 18px; overflow-wrap: anywhere; } +.forward-explanation { max-width: 780px; margin-top: 32px; line-height: 1.9; } +.forward-explanation h2 { font-size: 20px; } +.forward-explanation ol { padding-left: 22px; } +.forward-explanation li { padding-block: 4px; } +.forward-page code { font-family: var(--home-mono); } +.forward-page [data-key-fingerprint] { white-space: pre-wrap; overflow-wrap: anywhere; font-family: var(--home-mono); } +.forward-page [data-preview-link] { display: inline-block; margin-top: 16px; overflow-wrap: anywhere; } +.forward-page [hidden] { display: none !important; } +@media (max-width: 1000px) { .forward-form { grid-template-columns: minmax(0, 1fr) 130px; } } +@media (max-width: 600px) { + .forward-page { padding: 24px 16px; } + .forward-page .home-section-heading { align-items: start; flex-direction: column; } + .forward-card { padding: 18px; } + .forward-form { grid-template-columns: minmax(0, 1fr); } + .forward-status { align-items: start; flex-direction: column; } +} diff --git a/frontend/src/forward-page.ts b/frontend/src/forward-page.ts new file mode 100644 index 0000000..74ed06c --- /dev/null +++ b/frontend/src/forward-page.ts @@ -0,0 +1,206 @@ +import { api, hostCredentials, type CloudHost } from './cloud-api'; +import './forward-page.css'; + +/** 转发持有独立 SSH 连接,不借用终端,防止预览页获得命令输入能力。 */ +export class ForwardPage { + readonly root = document.createElement('main'); + private hosts: CloudHost[] = []; + private socket?: WebSocket; + private sessionId?: string; + private generation = 0; + private busy = false; + private ready = false; + private popup: Window | null = null; + private deadline?: ReturnType; + private readonly select: HTMLSelectElement; + private readonly port: HTMLInputElement; + private readonly startButton: HTMLButtonElement; + private readonly stopButton: HTMLButtonElement; + private readonly openButton: HTMLButtonElement; + private readonly keyDialog: HTMLDialogElement; + private pendingKey?: { socket: WebSocket; fingerprint: string }; + + constructor() { + this.root.className = 'forward-page'; + this.root.hidden = true; + this.root.innerHTML = ` +

PRIVATE WEB PREVIEW

+

端口转发

通过 SSH,打开只监听在服务器本机的网站。

+ 独立站点隔离
+
+
+ + + + +
+

访问目标固定为所选服务器的 http://127.0.0.1:端口,不是你电脑的本机端口。

+
未连接 · 请选择主机和网站端口 +
+ +
+
+

让网站留在另一扇窗里

+

远端网站可能包含不可信脚本。预览在独立 Worker、独立站点打开,不共享 EdgeSSH 登录 Cookie,也不提供主机管理或终端接口。

+
  1. 选择云端主机,输入网站的 HTTP 端口。
  2. 核对 SSH 主机指纹,连接后自动打开预览。
  3. 用完点击停止;离开此页面、刷新或断线后,转发立即失效。
+

支持常见资源、表单、网站 Cookie、HTTP 登录和重定向。单次授权最长 1 小时,上传最多 16 MiB。首版不支持 HTTPS 上游、WebSocket、Service Worker,以及脚本内写死的 localhost 地址。同一预览域名一次只使用一个网站,请先关闭旧预览窗口再切换。

+
+ +

核对 SSH 主机指纹

+


+        

请通过可信渠道核对。接受只用于本次连接,不会自动覆盖已保存指纹。

+
+
+
`; + this.select = this.get('select'); + this.port = this.get('input'); + this.startButton = this.get('[type="submit"]'); + this.stopButton = this.get('[data-stop]'); + this.openButton = this.get('[data-open]'); + this.keyDialog = this.get('dialog'); + this.get('[data-key-accept]').addEventListener('click', () => { + const pending = this.pendingKey; + if (pending?.socket.readyState === WebSocket.OPEN) { + pending.socket.send(JSON.stringify({ type: 'host_key_decision', fingerprint: pending.fingerprint, accept: true })); + } + this.pendingKey = undefined; this.keyDialog.close(); + }); + this.get('[data-key-reject]').addEventListener('click', () => this.stop('已取消:未信任主机指纹。')); + this.keyDialog.addEventListener('cancel', (event) => { event.preventDefault(); this.stop('已取消:未信任主机指纹。'); }); + this.get('form').addEventListener('submit', (event) => { event.preventDefault(); void this.start(); }); + this.stopButton.addEventListener('click', () => this.stop()); + this.openButton.addEventListener('click', () => { + this.reservePopup(); + void this.launch(this.generation); + }); + window.addEventListener('pagehide', () => this.stop()); + window.addEventListener('auth-required', () => this.stop()); + } + + private get(selector: string): T { return this.root.querySelector(selector)!; } + private message(text: string): void { this.get('[data-status]').textContent = text; } + + setHosts(hosts: CloudHost[]): void { + this.hosts = hosts; + const selected = this.select.value; + this.select.replaceChildren(new Option(hosts.length ? '请选择主机' : '暂无主机,请先在总览添加', '')); + for (const host of hosts) this.select.add(new Option(`${host.name} · ${host.username}@${host.host}`, host.id)); + this.select.value = hosts.some((host) => host.id === selected) ? selected : ''; + this.render(); + } + + show(): void { this.root.hidden = false; this.get('h1').focus(); } + hide(): void { + if (this.root.hidden) return; + this.stop(); + this.root.hidden = true; + } + + private render(): void { + this.select.disabled = this.busy || this.ready; + this.port.disabled = this.busy || this.ready; + this.startButton.disabled = this.busy || this.ready || !this.hosts.length; + this.startButton.textContent = this.busy ? '正在连接…' : '连接并打开网站 ↗'; + this.stopButton.disabled = !this.busy && !this.ready; + this.openButton.hidden = !this.ready; + this.openButton.disabled = this.busy; + } + + private reservePopup(): void { + // 同步占用用户手势;一开始就去掉 opener,不让远端页面访问主站窗口。 + this.popup = window.open('about:blank', '_blank'); + if (this.popup) { + this.popup.opener = null; + this.popup.document.title = 'EdgeSSH · 正在连接'; + this.popup.document.body.textContent = '正在建立 SSH 转发。请回到 EdgeSSH 核对首次连接的主机指纹。'; + } + } + + private async start(): Promise { + if (this.busy || this.ready) return; + const host = this.hosts.find((item) => item.id === this.select.value); + if (!host) { this.message('请先选择一台已保存的主机。'); return; } + const port = Number(this.port.value); + if (!Number.isInteger(port) || port < 1 || port > 65535) return; + const generation = ++this.generation; + this.reservePopup(); + this.busy = true; this.render(); this.message('正在获取连接授权…'); + this.deadline = setTimeout(() => this.stop('连接超时,请检查主机后重试。'), 60_000); + try { + const credentials = await hostCredentials(host.id); + if (generation !== this.generation) return; + const ticket = await api<{ ticket: string; sessionId: string }>('/api/session', 'POST', {}); + if (generation !== this.generation) return; + this.sessionId = ticket.sessionId; + const url = new URL('/api/ssh', location.origin); + url.protocol = location.protocol === 'https:' ? 'wss:' : 'ws:'; + url.searchParams.set('ticket', ticket.ticket); url.searchParams.set('session', ticket.sessionId); + const socket = new WebSocket(url); + this.socket = socket; + socket.addEventListener('open', () => { + if (generation !== this.generation) return; + socket.send(JSON.stringify({ + type: 'connect', mode: 'forward', host: host.host, port: host.port, + username: host.username, authMethod: host.authMethod, ...credentials, + ...(host.fingerprint ? { expectedFingerprint: host.fingerprint } : {}), + })); + credentials.password = undefined; credentials.privateKey = undefined; + this.message('正在连接 SSH,首次连接请核对主机指纹…'); + }); + socket.addEventListener('message', (event) => { + if (generation !== this.generation || typeof event.data !== 'string') return; + const message = JSON.parse(event.data) as { type: string; trusted?: boolean; fingerprint?: string; expectedFingerprint?: string }; + if (message.type === 'host_key' && !message.trusted) { + if (!message.fingerprint || !/^SHA256:[A-Za-z0-9+/]{43}$/.test(message.fingerprint)) { + this.stop('收到无效的主机指纹,请重新连接。'); return; + } + // 新窗口可能已取得焦点,原生 confirm 会被浏览器抑制;站内对话框不会默默拒绝首次连接。 + this.pendingKey = { socket, fingerprint: message.fingerprint }; + this.get('[data-key-warning]').textContent = message.expectedFingerprint + ? `警告:主机指纹已变化!原指纹:${message.expectedFingerprint}` : '首次连接,请通过可信渠道核对主机指纹。'; + this.get('[data-key-target]').textContent = `${host.name} (${host.host})`; + this.get('[data-key-fingerprint]').textContent = message.fingerprint; + this.keyDialog.showModal(); + } else if (message.type === 'ready') { + clearTimeout(this.deadline); + void this.launch(generation); + } else if (message.type === 'error') this.stop('SSH 连接失败,请检查凭据、指纹和服务器状态。'); + }); + socket.addEventListener('close', () => { if (generation === this.generation) this.stop('SSH 已断开,预览授权已失效。'); }); + socket.addEventListener('error', () => { if (generation === this.generation) this.stop('SSH 连接失败,请重新连接。'); }); + } catch (error) { if (generation === this.generation) this.stop(error instanceof Error ? error.message : '连接失败。'); } + } + + private async launch(generation: number): Promise { + if (!this.sessionId) return; + this.busy = true; this.render(); + this.get('[data-preview-link]').hidden = true; + try { + const { url, expiresAt } = await api<{ url: string; expiresAt: number }>( + `/api/forwarding?session=${this.sessionId}`, 'POST', { port: Number(this.port.value) }); + if (generation !== this.generation) return; + this.ready = true; + const link = this.get('[data-preview-link]'); + link.href = url; + if (this.popup && !this.popup.closed) this.popup.location.replace(url); + else link.hidden = false; + this.popup = null; + this.message(`已转发 127.0.0.1:${this.port.value} · 授权至 ${new Date(expiresAt).toLocaleTimeString()} · 请保持本页打开`); + } catch (error) { + if (generation === this.generation) this.stop(error instanceof Error ? error.message : '创建转发失败。'); + } finally { if (generation === this.generation) { this.busy = false; this.render(); } } + } + + stop(message = '已停止转发,预览授权已失效。'): void { + this.generation++; + clearTimeout(this.deadline); + this.pendingKey = undefined; this.keyDialog.close(); + this.popup?.close(); this.popup = null; + this.socket?.close(); this.socket = undefined; + // 主 WebSocket 关闭就是撤销操作;无需依赖卸载页面时不可靠的异步 fetch。 + this.sessionId = undefined; this.busy = false; this.ready = false; + this.get('[data-preview-link]').hidden = true; + this.get('[data-preview-link]').removeAttribute('href'); + this.message(message); this.render(); + } +} diff --git a/scripts/cloudflare-access.ts b/scripts/cloudflare-access.ts index 3c1bcf4..62c57cb 100644 --- a/scripts/cloudflare-access.ts +++ b/scripts/cloudflare-access.ts @@ -21,6 +21,11 @@ interface AccessPolicy { } export async function resolveWorkerHostname(api: CloudflareApi, settings: DeploymentSettings): Promise { + const subdomain = await resolveWorkersDevSubdomain(api, settings); + return `${settings.workerName}.${subdomain}.workers.dev`; +} + +export async function resolveWorkersDevSubdomain(api: CloudflareApi, settings: DeploymentSettings): Promise { const path = `/accounts/${settings.accountId}/workers/subdomain`; let subdomain: string | null = null; try { @@ -34,7 +39,7 @@ export async function resolveWorkerHostname(api: CloudflareApi, settings: Deploy subdomain = `${settings.workerName}-${settings.accountId.slice(0, 8)}`; await api.request(path, 'PUT', { subdomain }); } - return `${settings.workerName}.${subdomain}.workers.dev`; + return subdomain; } export async function ensureAccess( diff --git a/scripts/deploy.ts b/scripts/deploy.ts index 37b3e50..95a92e6 100644 --- a/scripts/deploy.ts +++ b/scripts/deploy.ts @@ -3,20 +3,22 @@ import { appendFile, readFile, writeFile } from 'node:fs/promises'; import { fileURLToPath } from 'node:url'; import { parse, stringify } from 'smol-toml'; import { ensureDatabase } from './cloudflare-d1.ts'; -import { createDeploymentConfig, readDeploymentSettings } from './deployment-config.ts'; +import { createDeploymentConfig, createPreviewDeploymentConfig, readDeploymentSettings } from './deployment-config.ts'; import { CloudflareApi, resolveAccountId } from './cloudflare-api.ts'; -import { resolveWorkerHostname } from './cloudflare-access.ts'; +import { resolveWorkersDevSubdomain } from './cloudflare-access.ts'; +import { previewOrigin } from '../src/forwarding/security.ts'; import { maskSecrets, prepareEncryptionSecret, readWorkerSecretNames, readWorkerVariable } from './deployment-secrets.ts'; import { prepareAuthentication, requiredAuthSecrets } from './deployment-auth.ts'; import { readWorkspaceState, updateWorkspaceState } from './workspace-state.ts'; const root = fileURLToPath(new URL('../', import.meta.url)); const generatedConfig = '.wrangler.generated.toml'; +const previewGeneratedConfig = '.wrangler.preview.generated.toml'; const wrangler = fileURLToPath(new URL('../node_modules/wrangler/bin/wrangler.js', import.meta.url)); -async function runWrangler(args: string[], input?: string): Promise { +async function runWrangler(args: string[], input?: string, configPath = generatedConfig): Promise { await new Promise((resolve, reject) => { - const child = spawn(process.execPath, [wrangler, ...args, '--config', generatedConfig], { + const child = spawn(process.execPath, [wrangler, ...args, '--config', configPath], { cwd: root, env: { ...process.env, CI: 'true' }, stdio: [input === undefined ? 'ignore' : 'pipe', 'inherit', 'inherit'], @@ -42,7 +44,10 @@ async function main(): Promise { settings.accountId = await resolveAccountId(api, settings.accountId); process.env.CLOUDFLARE_ACCOUNT_ID = settings.accountId; const existingSecrets = await readWorkerSecretNames(api, settings); - const hostname = settings.customDomain || await resolveWorkerHostname(api, settings); + const workersSubdomain = await resolveWorkersDevSubdomain(api, settings); + const hostname = settings.customDomain || `${settings.workerName}.${workersSubdomain}.workers.dev`; + const previewHostname = settings.previewDomain || `${settings.workerName}-preview.${workersSubdomain}.workers.dev`; + const preview = previewOrigin(`https://${previewHostname}`, `https://${hostname}`); const database = await ensureDatabase(settings); const workspace = await readWorkspaceState(api, settings, database); const deployedProvider = workspace.authProvider @@ -63,7 +68,7 @@ async function main(): Promise { const secrets = { ...authentication.secrets, ...await prepareEncryptionSecret(api, settings, database, existingSecrets) }; maskSecrets(secrets); const config = createDeploymentConfig(template, settings, database, { - hostname, githubAdminId: authentication.githubAdminId, + hostname, previewOrigin: preview, githubAdminId: authentication.githubAdminId, }); // 临时配置放在仓库根目录,保持 assets、main、migrations_dir 的相对路径语义。 await writeFile(new URL(`../${generatedConfig}`, import.meta.url), stringify(config), 'utf8'); @@ -72,14 +77,17 @@ async function main(): Promise { // Secret 只通过标准输入发送,不写临时文件或命令行参数;首次部署由 Wrangler 创建草稿 Worker。 if (Object.keys(secrets).length) await runWrangler(['secret', 'bulk'], JSON.stringify(secrets)); await runWrangler(['deploy']); - // Worker 成功发布后再切换状态;失败的发布不会让旧版本提前失去当前认证方式。 await updateWorkspaceState(api, settings, database, workspace, authentication.githubAdminId); // Wrangler 默认保留既有 Secret;部署后再次核对名称,避免把缺少认证的版本当作成功。 const deployedSecrets = await readWorkerSecretNames(api, settings); for (const name of ['ENCRYPTION_KEY', ...requiredAuthSecrets(settings.authProvider)]) { if (!deployedSecrets.has(name)) throw new Error(`部署后缺少 Worker Secret:${name}。`); } - console.log(`部署完成:https://${hostname}`); + // 预览 Worker 只做代理入口和 DO 绑定,不执行迁移、不写入任何 Secret。 + const previewConfig = createPreviewDeploymentConfig(template, settings, preview); + await writeFile(new URL(`../${previewGeneratedConfig}`, import.meta.url), stringify(previewConfig), 'utf8'); + await runWrangler(['deploy'], undefined, previewGeneratedConfig); + console.log(`部署完成:https://${hostname},预览:https://${previewHostname}`); if (process.env.GITHUB_STEP_SUMMARY) { await appendFile(process.env.GITHUB_STEP_SUMMARY, `## EdgeSSH 部署完成\n\n入口:https://${hostname}\n\n登录方式:${settings.authProvider}\n\n` diff --git a/scripts/deployment-config.ts b/scripts/deployment-config.ts index a9f7d8a..7b87350 100644 --- a/scripts/deployment-config.ts +++ b/scripts/deployment-config.ts @@ -10,6 +10,7 @@ export interface DeploymentSettings { databaseName: string; databaseId?: string; customDomain?: string; + previewDomain?: string; authProvider: 'cloudflare' | 'github'; githubClientId?: string; githubAdmin?: string; @@ -38,6 +39,7 @@ export function readDeploymentSettings( if (accountId && !/^[a-f0-9]{32}$/i.test(accountId)) throw new Error('CLOUDFLARE_ACCOUNT_ID 必须是 32 位十六进制账户 ID。'); const workerName = env.WORKER_NAME?.trim() || String(template.name); if (!/^[a-z0-9][a-z0-9-]{0,62}$/.test(workerName)) throw new Error('WORKER_NAME 必须是 1–63 位小写字母、数字或连字符,且不能以连字符开头。'); + if (`${workerName}-preview`.length > 63) throw new Error('WORKER_NAME 加上 -preview 后不能超过 63 位。'); const databases = template.d1_databases as TomlTable[] | undefined; const database = databases?.find((binding) => binding.binding === 'DB'); @@ -57,6 +59,13 @@ export function readDeploymentSettings( if (customDomain?.toLowerCase().endsWith('.workers.dev')) { throw new Error('CUSTOM_DOMAIN 不能填写 workers.dev 地址;使用 Worker 自带域名时请删除或留空该配置。'); } + const previewDomain = env.PREVIEW_DOMAIN?.trim(); + if (previewDomain && !/^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/i.test(previewDomain)) { + throw new Error('PREVIEW_DOMAIN 只能填写完整域名,不能包含协议、路径或通配符。'); + } + if (previewDomain?.toLowerCase().endsWith('.workers.dev')) { + throw new Error('PREVIEW_DOMAIN 不能填写 workers.dev 地址;请使用自定义跨站域名,或留空自动生成预览地址。'); + } const adminEmail = authProvider === 'cloudflare' ? env.ADMIN_EMAIL?.trim().toLowerCase() : undefined; if (adminEmail && !/^[^\s@,]+@[^\s@,]+\.[^\s@,]+$/.test(adminEmail)) { @@ -98,7 +107,7 @@ export function readDeploymentSettings( return { accountId, apiToken: env.CLOUDFLARE_API_TOKEN!.trim(), workerName, - databaseName, databaseId, customDomain, authProvider, githubClientId, githubAdmin, githubAdminId, + databaseName, databaseId, customDomain, previewDomain, authProvider, githubClientId, githubAdmin, githubAdminId, adminEmail, identityProviderIds, secrets, }; } @@ -107,7 +116,7 @@ export function createDeploymentConfig( template: TomlTable, settings: DeploymentSettings, database: Database, - runtime?: { hostname: string; githubAdminId?: string }, + runtime?: { hostname: string; previewOrigin?: string; githubAdminId?: string }, ): TomlTable { return { ...template, @@ -120,6 +129,7 @@ export function createDeploymentConfig( ...template.vars as TomlTable, AUTH_PROVIDER: settings.authProvider, ...(runtime ? { APP_ORIGIN: `https://${runtime.hostname}` } : {}), + ...(runtime?.previewOrigin ? { PREVIEW_ORIGIN: runtime.previewOrigin } : {}), ...(settings.authProvider === 'github' ? { GITHUB_CLIENT_ID: settings.githubClientId!, ...(runtime?.githubAdminId ? { GITHUB_ADMIN_ID: runtime.githubAdminId } : {}), @@ -131,3 +141,17 @@ export function createDeploymentConfig( : binding), }; } + +export function createPreviewDeploymentConfig(template: TomlTable, settings: DeploymentSettings, previewOrigin: string): TomlTable { + const previewTemplate = { + name: `${settings.workerName}-preview`, + main: 'src/preview-worker.ts', + compatibility_date: template.compatibility_date, + workers_dev: !settings.previewDomain, + preview_urls: false, + vars: { PREVIEW_ORIGIN: previewOrigin }, + durable_objects: { bindings: [{ name: 'SSH_SESSIONS', class_name: 'SSHSessionDO', script_name: settings.workerName }] }, + ...(settings.previewDomain ? { routes: [{ pattern: settings.previewDomain, custom_domain: true }] } : {}), + }; + return previewTemplate as TomlTable; +} diff --git a/src/backend/durable-object.ts b/src/backend/durable-object.ts index aa74b7d..a5f3720 100644 --- a/src/backend/durable-object.ts +++ b/src/backend/durable-object.ts @@ -3,6 +3,8 @@ import { parseConnectMessage, type Env } from '../types'; import { assertPublicTarget, toSocketHostname } from './security'; import { createTicket, verifyTicket } from './security'; import { SSHSession } from './session'; +import { ForwardingState } from '../forwarding/state'; +import { previewError } from '../forwarding/security'; interface MainAttachment { role: 'main'; phase: 'waiting' | 'connecting' | 'connected' } interface SFTPAttachment { role: 'sftp'; phase: 'connected' } @@ -30,6 +32,7 @@ export class SSHSessionDO implements DurableObject { private readonly state: DurableObjectState; private readonly env: Env; private readonly sessions = new Map(); + private readonly forwarding = new ForwardingState(); private readonly pendingConnections = new Map(); private readonly deadlines = new Map>(); private readonly sftpAttachTokens = new Map(); @@ -60,6 +63,8 @@ export class SSHSessionDO implements DurableObject { async fetch(request: Request): Promise { const url = new URL(request.url); + // 此入口只由独立预览 Worker 的 DO binding 调用,不暴露在主站 HTTP 路由中。 + if (url.pathname === '/preview-launch' || url.pathname === '/preview-http') return this.forwarding.preview(request); const accountId = request.headers.get('x-account-id'); if (!accountId) return Response.json({ error: 'Authentication required' }, { status: 401 }); if (url.pathname === '/ticket' && request.method === 'POST') { @@ -81,6 +86,20 @@ export class SSHSessionDO implements DurableObject { if (await this.state.storage.get('account-id') !== accountId) { return Response.json({ error: 'Session owner mismatch' }, { status: 403 }); } + if (url.pathname === '/forward') { + if (request.method === 'DELETE') { + this.forwarding.stop(); + return Response.json({ ok: true }, { headers: { 'Cache-Control': 'no-store' } }); + } + if (request.method !== 'POST') return previewError('Method not allowed', 405); + const session = [...this.sessions.values()].find((item) => item.isForwardReady()); + if (!session) return previewError('请先建立端口转发 SSH 连接。', 409); + const { port } = await request.json<{ port: number }>(); + const origin = request.headers.get('x-preview-origin'); + if (!Number.isInteger(port) || port < 1 || port > 65535 || !origin) return previewError('Invalid forwarding configuration', 400); + try { return await this.forwarding.create(session, port, origin, this.state.id.toString()); } + catch { return previewError('远端端口不可达或 SSH 服务未允许 TCP 转发。', 502); } + } if (url.pathname === '/sftp') return this.attachSFTP(request); if (url.pathname === '/processes') return this.attachProcesses(request); if (url.pathname !== '/connect') return Response.json({ error: 'Not found' }, { status: 404 }); @@ -419,6 +438,7 @@ export class SSHSessionDO implements DurableObject { } private cleanup(ws: WebSocket): void { + this.forwarding.clear(); this.clearDeadline(ws); const token = this.sftpTokenByMainWebSocket.get(ws); if (token) this.deleteSFTPAttachToken(token); diff --git a/src/backend/session.ts b/src/backend/session.ts index fad248f..aa5f5b9 100644 --- a/src/backend/session.ts +++ b/src/backend/session.ts @@ -49,6 +49,7 @@ import { encodeString, readUint32, toBufferSource } from '../ssh/utils'; import { parseTopSnapshot } from './top-parser'; import { retainTrailingMarkerPrefix } from './process-framing'; import { parseSystemProbe, SYSTEM_PROBE_COMMAND } from './system-info'; +import { ForwardChannel } from '../forwarding/channel'; type Cipher = SSHAESGCMCipher | SSHAESCTRCipher; type Phase = 'version' | 'kex' | 'host-confirm' | 'auth' | 'pty' | 'shell' | 'ready' | 'closed'; @@ -136,6 +137,7 @@ export class SSHSession { private readonly parser = new SSHPacketParser(); private readonly shellChannel = new SSHChannel(); private readonly channels = new Map([[0, this.shellChannel]]); + private readonly forwards = new Map(); private readonly config: SSHConnectionConfig; private readonly ws: WebSocket; private readonly socket: Socket; @@ -208,9 +210,33 @@ export class SSHSession { this.readTask = this.readLoop(); } + isForwardReady(): boolean { return this.config.mode === 'forward' && this.phase === 'ready'; } + + async openForward(port: number): Promise { + if (!this.isForwardReady()) throw new Error('Forwarding SSH connection is not ready'); + if (this.forwards.size >= 24) throw new Error('Too many forwarding requests'); + const id = this.nextChannelID++; + const forward = new ForwardChannel({ + send: (payload) => this.sendEncrypted(payload), + data: (channel, chunk) => this.sendChannelData(channel, chunk), + remove: () => { this.forwards.delete(id); }, + }); + this.forwards.set(id, forward); + try { + // 只开放所选 SSH 主机的 IPv4 回环服务,不提供任意内网目标代理。 + await this.sendEncrypted(forward.channel.buildOpenDirectTCPIP(id, '127.0.0.1', port)); + await forward.opened; + return forward; + } catch (error) { + forward.abort(new Error('Unable to open forwarding channel')); + throw error; + } + } + async handleClientMessage(message: string | ArrayBuffer): Promise { if (this.phase === 'closed') return; if (message instanceof ArrayBuffer) { + if (this.config.mode === 'forward') throw new Error('Terminal input is disabled for forwarding sessions'); if (message.byteLength > 64 * 1024) throw new Error('Binary terminal input exceeds 64 KiB'); this.queueInput(new Uint8Array(message)); return; @@ -231,6 +257,7 @@ export class SSHSession { this.sendJson({ type: 'pong' }); return; } + if (this.config.mode === 'forward') throw new Error('Terminal input is disabled for forwarding sessions'); const resize = value.type === 'resize' ? [value.cols, value.rows] : Array.isArray(value.resize) ? value.resize : null; @@ -249,6 +276,8 @@ export class SSHSession { close(normal = false): void { if (this.phase === 'closed') return; this.phase = 'closed'; + for (const forward of this.forwards.values()) forward.abort(new Error('SSH session closed')); + this.forwards.clear(); if (this.keepaliveTimer) clearInterval(this.keepaliveTimer); if (this.shellTimer) clearTimeout(this.shellTimer); this.keepaliveTimer = null; @@ -633,6 +662,12 @@ export class SSHSession { if (!this.authRequestSent) throw new Error('SSH authentication completed before credentials were sent'); this.config.password = undefined; this.config.privateKey = undefined; + if (this.config.mode === 'forward') { + this.phase = 'ready'; + this.startKeepalive(); + this.sendJson({ type: 'ready' }); + return; + } this.status('auth_success', 'SSH authentication succeeded; opening terminal'); this.phase = 'pty'; this.startKeepalive(); @@ -658,6 +693,8 @@ export class SSHSession { private async handleChannel(type: number, payload: Uint8Array): Promise { if (payload.length < 5) throw new Error('Malformed SSH channel message'); const channelID = readUint32(payload, 1); + const forward = this.forwards.get(channelID); + if (forward) { await forward.handle(type, payload); return; } const channel = this.channels.get(channelID); if (!channel) throw new Error('SSH channel message has an unknown recipient'); const isShell = channel === this.shellChannel; diff --git a/src/forwarding/body.ts b/src/forwarding/body.ts new file mode 100644 index 0000000..2906aa5 --- /dev/null +++ b/src/forwarding/body.ts @@ -0,0 +1,20 @@ +/** 小型授权接口也可能收到流式大请求:边读边限制,而不是 text() 后才检查。 */ +export async function readBoundedText(request: Request, limit: number): Promise { + if (!request.body) return ''; + const reader = request.body.getReader(); + const decoder = new TextDecoder(); + let bytes = 0; + let text = ''; + try { + while (true) { + const { value, done } = await reader.read(); + if (done) return text + decoder.decode(); + bytes += value.byteLength; + if (bytes > limit) throw new Error('Request body is too large'); + text += decoder.decode(value, { stream: true }); + } + } finally { + await reader.cancel().catch(() => undefined); + reader.releaseLock(); + } +} diff --git a/src/forwarding/channel.ts b/src/forwarding/channel.ts new file mode 100644 index 0000000..ec2a95a --- /dev/null +++ b/src/forwarding/channel.ts @@ -0,0 +1,114 @@ +import { SSHChannel, type ChannelDataChunk } from '../ssh/channel'; +import { + SSH_MSG_CHANNEL_OPEN_CONFIRMATION, SSH_MSG_CHANNEL_OPEN_FAILURE, SSH_MSG_CHANNEL_DATA, + SSH_MSG_CHANNEL_WINDOW_ADJUST, SSH_MSG_CHANNEL_EOF, SSH_MSG_CHANNEL_CLOSE, +} from '../types'; + +interface Transport { + send(payload: Uint8Array): Promise; + data(channel: SSHChannel, chunk: ChannelDataChunk): Promise; + remove(): void; +} + +/** 每个 HTTP 请求单独使用 direct-tcpip,避免连接复用带来的响应串线。 */ +export class ForwardChannel { + readonly channel = new SSHChannel(); + readonly readable: ReadableStream; + readonly opened: Promise; + private controller!: ReadableStreamDefaultController; + private resolveOpen!: () => void; + private rejectOpen!: (error: Error) => void; + private wakeWriter?: () => void; + private failure?: Error; + private ended = false; + private timer: ReturnType; + + constructor(private readonly transport: Transport) { + this.opened = new Promise((resolve, reject) => { this.resolveOpen = resolve; this.rejectOpen = reject; }); + // 读者未就绪时最多保留 SSH 的接收窗口,不把慢客户端的数据无限堆在 DO 内。 + this.readable = new ReadableStream({ + start: (controller) => { this.controller = controller; }, + pull: () => this.adjustWindow(), + cancel: () => this.close(), + }, { highWaterMark: 2 * 1024 * 1024, size: (chunk) => chunk.byteLength }); + this.timer = setTimeout(() => this.abort(new Error('Forwarding channel timed out')), 15_000); + void this.opened.catch(() => undefined); + } + + private touch(): void { + clearTimeout(this.timer); + this.timer = setTimeout(() => this.abort(new Error('Forwarding channel timed out')), 60_000); + } + + async handle(type: number, payload: Uint8Array): Promise { + const channel = this.channel; + if (type === SSH_MSG_CHANNEL_OPEN_CONFIRMATION) { + channel.handleOpenConfirmation(payload); + if (this.failure) { await this.sendClose(); return; } + this.touch(); this.resolveOpen(); + } else if (type === SSH_MSG_CHANNEL_OPEN_FAILURE) { + channel.handleOpenFailure(payload); + this.abort(new Error('SSH server rejected port forwarding')); + this.transport.remove(); + } else if (type === SSH_MSG_CHANNEL_DATA) { + const data = channel.handleChannelData(payload); + if (!this.ended) { this.touch(); this.controller.enqueue(data.slice()); await this.adjustWindow(); } + } else if (type === SSH_MSG_CHANNEL_WINDOW_ADJUST) { + channel.handleWindowAdjust(payload); + this.wakeWriter?.(); this.wakeWriter = undefined; + } else if (type === SSH_MSG_CHANNEL_EOF) { + channel.handleEof(payload); + if (!this.ended) { this.ended = true; this.controller.close(); } + } else if (type === SSH_MSG_CHANNEL_CLOSE) { + channel.handleClose(payload); + if (!this.ended) { this.ended = true; this.controller.close(); } + this.failure ??= new Error('Forwarding channel closed'); + this.rejectOpen(this.failure); + this.wakeWriter?.(); this.wakeWriter = undefined; + clearTimeout(this.timer); + await this.sendClose(); + this.transport.remove(); + } + } + + async write(data: Uint8Array): Promise { + await this.opened; + let offset = 0; + while (offset < data.length) { + if (this.failure) throw this.failure; + const chunk = this.channel.takeChannelDataChunk(data, offset); + if (!chunk) { await new Promise((resolve) => { this.wakeWriter = resolve; }); continue; } + await this.transport.data(this.channel, chunk); + offset += chunk.bytesConsumed; + this.touch(); + } + } + + private async adjustWindow(): Promise { + if (this.ended || this.failure || !this.channel.isOpen() || (this.controller.desiredSize ?? 0) < 1024 * 1024) return; + const amount = this.channel.takeLocalWindowAdjustment(512 * 1024); + if (amount) await this.transport.send(this.channel.buildWindowAdjust(amount)); + } + + private async sendClose(): Promise { + if (this.channel.hasSentClose()) return; + try { this.channel.getRemoteChannelID(); } catch { return; } + await this.transport.send(this.channel.buildClose()); + } + + abort(error: Error): void { + if (this.failure) return; + this.failure = error; + clearTimeout(this.timer); + this.rejectOpen(error); + this.wakeWriter?.(); this.wakeWriter = undefined; + if (!this.ended) { this.ended = true; this.controller.error(error); } + // 保留通道记录以处理迟到的 OPEN_CONFIRMATION,由会话并发上限约束数量。 + void this.sendClose().catch(() => undefined); + } + + async close(): Promise { + this.abort(new Error('Forwarding request finished')); + await this.sendClose(); + } +} diff --git a/src/forwarding/http.ts b/src/forwarding/http.ts new file mode 100644 index 0000000..435a363 --- /dev/null +++ b/src/forwarding/http.ts @@ -0,0 +1,254 @@ +import type { ForwardChannel } from './channel.ts'; +import { PREVIEW_COOKIE, PREVIEW_PREFIX } from './security.ts'; + +const encoder = new TextEncoder(); +const decoder = new TextDecoder('latin1'); +const MAX_HEADER_BYTES = 64 * 1024; +const MAX_UPLOAD_BYTES = 16 * 1024 * 1024; +const HOP_HEADERS = ['connection', 'keep-alive', 'proxy-authenticate', 'proxy-authorization', 'te', 'trailer', 'transfer-encoding', 'upgrade']; + +export function stripHopHeaders(headers: Headers): void { + for (const name of (headers.get('Connection') ?? '').split(',')) if (name.trim()) headers.delete(name.trim()); + for (const name of HOP_HEADERS) headers.delete(name); +} + +function isInternalCookie(name: string): boolean { + return name.toLowerCase() === PREVIEW_COOKIE.toLowerCase() || /^cf_authorization$/i.test(name); +} + +export function upstreamHeaders(request: Request, port: number, origin: string): Headers { + const headers = new Headers(request.headers); + stripHopHeaders(headers); + const headerNames: string[] = []; + headers.forEach((_value, name) => headerNames.push(name)); + for (const name of headerNames) { + if (/^(cf-|x-preview-|x-forwarded-|sec-fetch-)/i.test(name) + || ['x-account-id', 'forwarded', 'x-real-ip', 'expect'].includes(name)) headers.delete(name); + } + headers.set('Host', `127.0.0.1:${port}`); + headers.set('Connection', 'close'); + // 不压缩便于 HTML 属性中的绝对 localhost 地址重写;二进制响应仍保持原始字节。 + headers.set('Accept-Encoding', 'identity'); + const cookies = (headers.get('Cookie') ?? '').split(';').filter((part) => { + const index = part.indexOf('='); + return index > 0 && !isInternalCookie(part.slice(0, index).trim()); + }).join(';').trim(); + if (cookies) headers.set('Cookie', cookies); else headers.delete('Cookie'); + if (headers.get('Origin') === origin) headers.set('Origin', `http://127.0.0.1:${port}`); + const referer = headers.get('Referer'); + if (referer?.startsWith(`${origin}/`)) headers.set('Referer', referer.replace(origin, `http://127.0.0.1:${port}`)); + else headers.delete('Referer'); + return headers; +} + +export function rewriteLocalURL(value: string, port: number, origin: string): string { + // 相对路径天然落到预览站根路径;只修正当前端口的回环绝对地址,不扩大代理范围。 + // 嵌套页面的相对链接和 #fragment 必须留给浏览器解析,不能错误地按 / 作为基准。 + if (!/^https?:\/\//i.test(value) && !value.startsWith('//')) return value; + try { + const parsed = new URL(value, `http://127.0.0.1:${port}`); + if (parsed.protocol === 'http:' && ['localhost', '127.0.0.1', '[::1]'].includes(parsed.hostname) + && Number(parsed.port || 80) === port) return `${origin}${parsed.pathname}${parsed.search}${parsed.hash}`; + } catch { /* 非 URL 属性原样交给网站 */ } + return value; +} + +export function previewHeaders(source: Headers, port: number, origin: string): Headers { + const cookies = source.getSetCookie(); + const headers = new Headers(source); + stripHopHeaders(headers); + headers.delete('Set-Cookie'); + for (const cookie of cookies) { + const parts = cookie.split(';'); + const name = parts[0].split('=')[0].trim(); + if (isInternalCookie(name)) continue; + const attrs = parts.slice(1).filter((part) => !/^\s*(domain|secure)\s*(=|$)/i.test(part)); + headers.append('Set-Cookie', [parts[0], ...attrs, ' Secure'].join(';')); + } + const location = headers.get('Location'); + if (location) headers.set('Location', rewriteLocalURL(location, port, origin)); + const refresh = headers.get('Refresh'); + if (refresh) headers.delete('Refresh'); // 不接受无法可靠解析的自动导航头,普通 Location 保留。 + for (const name of ['clear-site-data', 'service-worker-allowed', 'alt-svc', 'report-to', 'nel', + 'access-control-allow-origin', 'access-control-allow-credentials', 'content-length']) headers.delete(name); + headers.set('Cache-Control', 'no-store'); + headers.set('Referrer-Policy', 'no-referrer'); + headers.set('X-Content-Type-Options', 'nosniff'); + headers.set('Cross-Origin-Opener-Policy', 'same-origin'); + headers.set('Cross-Origin-Resource-Policy', 'same-origin'); + // 叠加而不是覆盖网站自己的 CSP;禁止持久化 Service Worker 污染后续预览。 + headers.append('Content-Security-Policy', "worker-src 'none'; frame-ancestors 'none'; object-src 'none'"); + return headers; +} + +/** 增量 HTTP/1.1 帧解析;响应体不整体缓存,下载和 SSE 能沿 SSH 窗口反压。 */ +class HTTPReader { + private buffer = new Uint8Array(0); + private readonly reader: ReadableStreamDefaultReader; + constructor(reader: ReadableStreamDefaultReader) { this.reader = reader; } + private async fill(): Promise { + const { value, done } = await this.reader.read(); + if (done) return false; + const merged = new Uint8Array(this.buffer.length + value.length); + merged.set(this.buffer); merged.set(value, this.buffer.length); this.buffer = merged; + return true; + } + async line(limit = MAX_HEADER_BYTES): Promise { + while (true) { + for (let index = 0; index + 1 < this.buffer.length; index++) { + if (this.buffer[index] === 13 && this.buffer[index + 1] === 10) { + if (index > limit) throw new Error('HTTP line too long'); + const line = decoder.decode(this.buffer.subarray(0, index)); + this.buffer = this.buffer.slice(index + 2); + return line; + } + } + if (this.buffer.length > limit + 1 || !await this.fill()) throw new Error('Invalid HTTP headers'); + } + } + async bytes(max: number): Promise { + if (!this.buffer.length && !await this.fill()) return null; + const chunk = this.buffer.subarray(0, max); + this.buffer = this.buffer.slice(chunk.length); + return chunk; + } + async headers(): Promise<{ status: number; headers: Headers }> { + let total = 0; + const statusLine = await this.line(); + const match = /^HTTP\/1\.[01] ([1-5][0-9]{2})(?: .*)?$/.exec(statusLine); + if (!match) throw new Error('Invalid HTTP status'); + const headers = new Headers(); + while (true) { + const line = await this.line(MAX_HEADER_BYTES - total); + total += line.length + 2; + if (total > MAX_HEADER_BYTES) throw new Error('HTTP headers too large'); + if (!line) break; + const colon = line.indexOf(':'); + if (colon < 1 || !/^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/.test(line.slice(0, colon))) throw new Error('Invalid HTTP header'); + headers.append(line.slice(0, colon), line.slice(colon + 1).trim()); + } + return { status: Number(match[1]), headers }; + } +} + +async function sendRequest(channel: ForwardChannel, request: Request, path: string, port: number, origin: string): Promise { + const headers = upstreamHeaders(request, port, origin); + const length = headers.get('Content-Length'); + if (length !== null && (!/^\d+$/.test(length) || Number(length) > MAX_UPLOAD_BYTES)) throw new Error('Upload is too large'); + const chunked = request.body !== null && length === null; + if (chunked) headers.set('Transfer-Encoding', 'chunked'); + let head = `${request.method} ${path} HTTP/1.1\r\n`; + headers.forEach((value, name) => { head += `${name}: ${value}\r\n`; }); + await channel.write(encoder.encode(`${head}\r\n`)); + if (!request.body) return; + const reader = request.body.getReader(); + let bytes = 0; + try { + while (true) { + const { value, done } = await reader.read(); + if (done) break; + bytes += value.length; + if (bytes > MAX_UPLOAD_BYTES || (length !== null && bytes > Number(length))) throw new Error('Upload is too large'); + if (!value.length) continue; + if (chunked) await channel.write(encoder.encode(`${value.length.toString(16)}\r\n`)); + await channel.write(value); + if (chunked) await channel.write(encoder.encode('\r\n')); + } + if (length !== null && bytes !== Number(length)) throw new Error('Invalid upload length'); + if (chunked) await channel.write(encoder.encode('0\r\n\r\n')); + } finally { await reader.cancel().catch(() => undefined); reader.releaseLock(); } +} + +export async function proxyHTTP(channel: ForwardChannel, request: Request, port: number, origin: string): Promise { + const path = request.headers.get('x-preview-path') ?? '/'; + if (!path.startsWith('/') || /[\s\0]/.test(path) || path.startsWith(PREVIEW_PREFIX)) { + await channel.close(); + throw new Error('Invalid request path'); + } + const reader = channel.readable.getReader(); + const parser = new HTTPReader(reader); + const abort = () => channel.abort(new Error('Preview request cancelled')); + request.signal.addEventListener('abort', abort, { once: true }); + let finished = false; + const finish = async () => { + if (finished) return; + finished = true; + request.signal.removeEventListener('abort', abort); + await reader.cancel().catch(() => undefined); + reader.releaseLock(); + await channel.close(); + }; + const sending = sendRequest(channel, request, path, port, origin); + void sending.catch(() => channel.abort(new Error('Unable to send HTTP request'))); + try { + let parsed = await parser.headers(); + for (let count = 0; parsed.status < 200 && parsed.status !== 101 && count < 4; count++) parsed = await parser.headers(); + if (parsed.status < 200) throw new Error('HTTP upgrades are not supported'); + const { status, headers: source } = parsed; + const headers = previewHeaders(source, port, origin); + if (request.method === 'HEAD' || [204, 205, 304].includes(status)) { + await finish(); + return new Response(null, { status, headers }); + } + const transfer = source.get('Transfer-Encoding'); + const length = source.get('Content-Length'); + if (transfer && (transfer.toLowerCase() !== 'chunked' || length !== null)) throw new Error('Unsupported HTTP framing'); + if (length !== null && (!/^\d+$/.test(length) || !Number.isSafeInteger(Number(length)))) throw new Error('Invalid HTTP content length'); + let remaining = length === null ? null : Number(length); + let chunkRemaining = 0; + let chunkCRLF = false; + const body = new ReadableStream({ + async pull(controller) { + try { + if (transfer) { + if (chunkRemaining === 0) { + if (chunkCRLF && await parser.line(0) !== '') throw new Error('Invalid chunk separator'); + const line = await parser.line(1024); + if (!/^[a-fA-F0-9]+(?:;[^\r\n]*)?$/.test(line)) throw new Error('Invalid HTTP chunk'); + chunkRemaining = Number.parseInt(line.split(';')[0], 16); + if (!Number.isSafeInteger(chunkRemaining)) throw new Error('Invalid HTTP chunk size'); + if (chunkRemaining === 0) { + let trailerBytes = 0; + while (true) { + const trailer = await parser.line(MAX_HEADER_BYTES - trailerBytes); + trailerBytes += trailer.length + 2; + if (!trailer) break; + if (trailerBytes > MAX_HEADER_BYTES) throw new Error('HTTP trailers too large'); + } + controller.close(); await finish(); return; + } + chunkCRLF = true; + } + const chunk = await parser.bytes(chunkRemaining); + if (!chunk) throw new Error('Truncated chunked response'); + chunkRemaining -= chunk.length; + controller.enqueue(chunk); + } else { + if (remaining === 0) { controller.close(); await finish(); return; } + const chunk = await parser.bytes(remaining ?? 64 * 1024); + if (!chunk) { + if (remaining !== null) throw new Error('Truncated HTTP response'); + controller.close(); await finish(); return; + } + if (remaining !== null) remaining -= chunk.length; + controller.enqueue(chunk); + } + } catch (error) { controller.error(error); await finish(); } + }, + cancel: finish, + }); + let response = new Response(body, { status, headers, encodeBody: 'manual' }); + if (source.get('Content-Type')?.toLowerCase().includes('text/html') && !source.has('Content-Encoding')) { + // 不注入脚本、不缓冲 HTML,常见绝对资源链接与表单 action 按属性逐一修正。 + response = new HTMLRewriter().on('*', { + element(element) { + for (const name of ['href', 'src', 'action', 'formaction', 'poster']) { + const value = element.getAttribute(name); + if (value) element.setAttribute(name, rewriteLocalURL(value, port, origin)); + } + }, + }).transform(response); + } + return response; + } catch (error) { await finish(); throw error; } +} diff --git a/src/forwarding/routes.ts b/src/forwarding/routes.ts new file mode 100644 index 0000000..156ebfe --- /dev/null +++ b/src/forwarding/routes.ts @@ -0,0 +1,37 @@ +import type { Env } from '../types'; +import { jsonError } from '../http-security'; +import { previewOrigin } from './security'; +import { readBoundedText } from './body'; + +/** 只有主站的认证 API 能创建或撤销转发;预览站没有 SSH 控制接口。 */ +export async function forwardingRoute(request: Request, env: Env, accountId: string): Promise { + if (request.method !== 'POST' && request.method !== 'DELETE') return jsonError('Method not allowed', 405); + const url = new URL(request.url); + const session = url.searchParams.get('session'); + if (!session || !/^[a-f0-9]{64}$/.test(session)) return jsonError('Invalid session identifier', 400); + let origin: string; + try { + origin = previewOrigin(env.PREVIEW_ORIGIN, url.origin); + if (env.APP_ORIGIN) previewOrigin(origin, env.APP_ORIGIN); + } + catch (error) { return jsonError((error as Error).message, 503); } + const headers = new Headers({ + 'x-account-id': accountId, 'x-preview-origin': origin, 'Content-Type': 'application/json', + }); + let body: string | undefined; + if (request.method === 'POST') { + if (!request.headers.get('Content-Type')?.startsWith('application/json')) return jsonError('Expected application/json', 415); + let text: string; + try { text = await readBoundedText(request, 1024); } + catch { return jsonError('Request body is too large', 413); } + let input: { port?: unknown }; + try { input = JSON.parse(text); } catch { return jsonError('Invalid JSON body', 400); } + if (!input || !Number.isInteger(input.port) || Number(input.port) < 1 || Number(input.port) > 65535) { + return jsonError('端口必须是 1–65535 的整数。', 400); + } + body = JSON.stringify({ port: input.port }); + } + return env.SSH_SESSIONS.get(env.SSH_SESSIONS.idFromString(session)).fetch(new Request('https://session.internal/forward', { + method: request.method, headers, body, + })); +} diff --git a/src/forwarding/security.ts b/src/forwarding/security.ts new file mode 100644 index 0000000..84683ff --- /dev/null +++ b/src/forwarding/security.ts @@ -0,0 +1,45 @@ +export const PREVIEW_COOKIE = '__Host-edgessh-preview'; +export const PREVIEW_PREFIX = '/__edgessh/'; +export const FORWARD_TTL_MS = 60 * 60 * 1000; +export const LAUNCH_TTL_MS = 60_000; + +// workers.dev 是公共后缀,但同账户的两个 Worker 仍共享 account.workers.dev。 +// 其他域名保守比较末两段:宁可拒绝同一 co.uk 下的配置,也不误放行共享 Cookie 的域名。 +function cookieSite(host: string): string { + return host.split('.').slice(host.endsWith('.workers.dev') ? -3 : -2).join('.'); +} + +export function previewOrigin(value: string | undefined, appOrigin: string): string { + if (!value) throw new Error('预览 Worker 尚未配置,请先完成双 Worker 部署。'); + const preview = new URL(value.includes('://') ? value : `https://${value}`); + const app = new URL(appOrigin); + if (preview.hostname.endsWith('.workers.dev') && app.hostname.endsWith('.workers.dev') + && preview.hostname.split('.').slice(-2).join('.') === app.hostname.split('.').slice(-2).join('.')) { + throw new Error('PREVIEW_DOMAIN 与主站共享同一账户 workers.dev 站点,无法提供跨站隔离;请填写独立自定义域名。'); + } + if (preview.protocol !== 'https:' || preview.username || preview.password || preview.port + || preview.pathname !== '/' || preview.search || preview.hash + || !preview.hostname.includes('.') || cookieSite(preview.hostname) === cookieSite(app.hostname)) { + throw new Error('预览域名必须是与主站不共享父域 Cookie 的独立 HTTPS 站点。'); + } + return preview.origin; +} + +export function previewError(message: string, status: number): Response { + return Response.json({ error: message }, { status, headers: { + 'Cache-Control': 'no-store', 'Referrer-Policy': 'no-referrer', + 'X-Content-Type-Options': 'nosniff', 'Cross-Origin-Opener-Policy': 'same-origin', + 'Content-Security-Policy': "default-src 'none'; frame-ancestors 'none'", + } }); +} + +export function readPreviewCookie(headers: Headers): string | null { + const values = (headers.get('Cookie') ?? '').split(';').map((part) => part.trim()) + .filter((part) => part.startsWith(`${PREVIEW_COOKIE}=`)); + return values.length === 1 ? values[0].slice(PREVIEW_COOKIE.length + 1) : null; +} + +export function parseCapability(value: string | null): { session: string; token: string } | null { + const match = /^([a-f0-9]{64})\.([a-f0-9-]{36})$/.exec(value ?? ''); + return match ? { session: match[1], token: match[2] } : null; +} diff --git a/src/forwarding/state.ts b/src/forwarding/state.ts new file mode 100644 index 0000000..11aa2d8 --- /dev/null +++ b/src/forwarding/state.ts @@ -0,0 +1,80 @@ +import type { SSHSession } from '../backend/session.ts'; +import { FORWARD_TTL_MS, LAUNCH_TTL_MS, PREVIEW_PREFIX, previewError } from './security.ts'; +import { proxyHTTP } from './http.ts'; + +interface Grant { + session: SSHSession; + port: number; + origin: string; + launchToken: string | null; + launchExpires: number; + token: string; + expires: number; +} + +/** 授权仅存在 SSH 会话内存里;DO 重启、断线或显式停止都会让预览 Cookie 失效。 */ +export class ForwardingState { + private grant?: Grant; + private timer?: ReturnType; + private revision = 0; + + clear(): void { + this.revision++; + clearTimeout(this.timer); + this.timer = undefined; + this.grant = undefined; + } + + stop(): void { + const session = this.grant?.session; + this.clear(); + session?.close(true); + } + + async create(session: SSHSession, port: number, origin: string, sessionId: string): Promise { + this.clear(); + const revision = this.revision; + // 创建时先探测 direct-tcpip 权限和监听端口,避免给用户一个必定失败的链接。 + const probe = await session.openForward(port); + await probe.close(); + if (revision !== this.revision || !session.isForwardReady()) return previewError('转发已取消。', 409); + const grant: Grant = { + session, port, origin, launchToken: crypto.randomUUID(), launchExpires: Date.now() + LAUNCH_TTL_MS, + token: crypto.randomUUID(), expires: Date.now() + FORWARD_TTL_MS, + }; + this.grant = grant; + this.timer = setTimeout(() => this.stop(), FORWARD_TTL_MS); + return Response.json({ + url: `${origin}${PREVIEW_PREFIX}start#${sessionId}.${grant.launchToken}`, + expiresAt: grant.expires, + }, { headers: { 'Cache-Control': 'no-store' } }); + } + + async preview(request: Request): Promise { + const grant = this.grant; + if (!grant || grant.expires <= Date.now() || !grant.session.isForwardReady() + || request.headers.get('x-preview-origin') !== grant.origin) return previewError('转发已断开或过期,请回到 EdgeSSH 重新连接。', 410); + const token = request.headers.get('x-preview-token'); + if (new URL(request.url).pathname === '/preview-launch') { + if (request.method !== 'POST' || !grant.launchToken || token !== grant.launchToken || grant.launchExpires <= Date.now()) { + return previewError('预览链接已使用或过期。', 401); + } + grant.launchToken = null; + return Response.json({ token: grant.token, maxAge: Math.floor((grant.expires - Date.now()) / 1000) }, + { headers: { 'Cache-Control': 'no-store' } }); + } + if (token !== grant.token) return previewError('预览授权无效。', 401); + if (!['GET', 'HEAD', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'].includes(request.method) + || request.headers.has('Upgrade')) return previewError('此预览只支持 HTTP 请求,不支持 WebSocket 或 CONNECT。', 405); + const origin = request.headers.get('Origin'); + if ((origin && origin !== grant.origin) || request.headers.get('Sec-Fetch-Site') === 'cross-site' + || (!['GET', 'HEAD', 'OPTIONS'].includes(request.method) && origin !== grant.origin)) { + return previewError('不允许跨站访问预览。', 403); + } + try { + const channel = await grant.session.openForward(grant.port); + if (this.grant !== grant) { await channel.close(); return previewError('转发已停止。', 410); } + return await proxyHTTP(channel, request, grant.port, grant.origin); + } catch { return previewError('无法访问远端 HTTP 服务,请检查端口、服务状态和 SSH 转发权限。', 502); } + } +} diff --git a/src/preview-worker.ts b/src/preview-worker.ts new file mode 100644 index 0000000..ce6ae5d --- /dev/null +++ b/src/preview-worker.ts @@ -0,0 +1,65 @@ +import { parseCapability, PREVIEW_COOKIE, PREVIEW_PREFIX, previewError, readPreviewCookie } from './forwarding/security'; +import { readBoundedText } from './forwarding/body'; + +interface PreviewEnv { + SSH_SESSIONS: DurableObjectNamespace; + PREVIEW_ORIGIN: string; +} + +export default { + async fetch(request: Request, env: PreviewEnv): Promise { + try { + const url = new URL(request.url); + if (url.protocol !== 'https:' || url.origin !== env.PREVIEW_ORIGIN) return previewError('Preview origin is not allowed', 403); + if (request.headers.has('Service-Worker')) return previewError('Service workers are disabled in previews', 403); + if (url.pathname === `${PREVIEW_PREFIX}start`) { + if (request.method !== 'GET') return previewError('Method not allowed', 405); + const nonce = crypto.randomUUID(); + // 凭证只在 fragment 中交接,不进入访问日志、Referer 或远端网站。 + return new Response(`EdgeSSH 隔离预览

正在打开隔离预览…

`, { headers: { + 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store', + 'Referrer-Policy': 'no-referrer', 'Cross-Origin-Opener-Policy': 'same-origin', + 'Clear-Site-Data': '"cache", "cookies", "storage"', + 'Content-Security-Policy': `default-src 'none'; script-src 'nonce-${nonce}'; connect-src 'self'; frame-ancestors 'none'; base-uri 'none'`, + } }); + } + if (url.pathname === `${PREVIEW_PREFIX}launch`) { + if (request.method !== 'POST' || request.headers.get('Origin') !== url.origin + || !request.headers.get('Content-Type')?.startsWith('application/json')) return previewError('Launch origin is not allowed', 403); + let text: string; + try { text = await readBoundedText(request, 256); } + catch { return previewError('Invalid launch ticket', 400); } + const cap = parseCapability((JSON.parse(text) as { key: string }).key); + if (!cap) return previewError('Invalid launch ticket', 401); + const response = await env.SSH_SESSIONS.get(env.SSH_SESSIONS.idFromString(cap.session)).fetch(new Request('https://session.internal/preview-launch', { + method: 'POST', headers: { 'x-preview-token': cap.token, 'x-preview-origin': url.origin }, + })); + if (!response.ok) return response; + const { token, maxAge } = await response.json<{ token: string; maxAge: number }>(); + return Response.json({ ok: true }, { headers: { + 'Cache-Control': 'no-store', + 'Set-Cookie': `${PREVIEW_COOKIE}=${cap.session}.${token}; Secure; HttpOnly; SameSite=Strict; Path=/; Max-Age=${maxAge}`, + } }); + } + if (url.pathname.startsWith(PREVIEW_PREFIX)) return previewError('Not found', 404); + const cap = parseCapability(readPreviewCookie(request.headers)); + if (!cap) return previewError('请从 EdgeSSH 端口转发页面打开预览。', 401); + // 不将浏览器提供的内部头当作授权;只使用 HttpOnly Cookie 中的有限能力。 + const headers = new Headers(request.headers); + const headerNames: string[] = []; + headers.forEach((_value, name) => headerNames.push(name)); + for (const name of headerNames) if (name.startsWith('x-preview-') || name === 'x-account-id') headers.delete(name); + headers.set('x-preview-token', cap.token); + headers.set('x-preview-origin', url.origin); + headers.set('x-preview-path', url.pathname + url.search); + return await env.SSH_SESSIONS.get(env.SSH_SESSIONS.idFromString(cap.session)).fetch(new Request('https://session.internal/preview-http', { + method: request.method, headers, body: request.body, redirect: 'manual', signal: request.signal, + })); + } catch { return previewError('预览请求失败,请重新连接。', 502); } + }, +}; diff --git a/src/ssh/channel.ts b/src/ssh/channel.ts index 7fe4f3a..1ac786d 100644 --- a/src/ssh/channel.ts +++ b/src/ssh/channel.ts @@ -80,6 +80,22 @@ export class SSHChannel { return payload; } + buildOpenDirectTCPIP(channelID: number, host: string, port: number): Uint8Array { + this.localChannelID = channelID; + const fields = [encodeString('direct-tcpip'), encodeString(host), encodeString('127.0.0.1')]; + const payload = new Uint8Array(1 + fields[0].length + 12 + fields[1].length + 4 + fields[2].length + 4); + payload[0] = SSH_MSG_CHANNEL_OPEN; + let offset = writeBytes(payload, 1, fields[0]); + for (const value of [channelID, DEFAULT_WINDOW_SIZE, DEFAULT_MAX_PACKET_SIZE]) { + writeUint32(payload, offset, value); offset += 4; + } + offset = writeBytes(payload, offset, fields[1]); + writeUint32(payload, offset, port); offset += 4; + offset = writeBytes(payload, offset, fields[2]); + writeUint32(payload, offset, 0); + return payload; + } + handleOpenConfirmation(payload: Uint8Array): void { if (payload.length !== 17) throw new Error('Malformed channel open confirmation'); if (readUint32(payload, 1) !== this.localChannelID) throw new Error('Channel confirmation has an unexpected recipient'); diff --git a/src/types.ts b/src/types.ts index e270ee1..301730e 100644 --- a/src/types.ts +++ b/src/types.ts @@ -39,6 +39,7 @@ export interface SSHConnectionConfig { rows: number; term: string; expectedFingerprint?: string; + mode?: 'forward'; } export interface TerminalSize { @@ -51,6 +52,7 @@ export interface Env { ENCRYPTION_KEY: string; AUTH_PROVIDER?: string; APP_ORIGIN?: string; + PREVIEW_ORIGIN?: string; ACCESS_TEAM_DOMAIN?: string; ACCESS_AUD?: string; GITHUB_CLIENT_ID?: string; @@ -126,8 +128,9 @@ export function parseConnectMessage(value: unknown): SSHConnectionConfig { if (typeof raw.port !== 'number' || !Number.isInteger(raw.port) || raw.port < 1 || raw.port > 65535) throw new Error('Invalid SSH port'); if (typeof raw.username !== 'string' || raw.username.length < 1 || raw.username.length > 128 || /[\0\r\n]/.test(raw.username)) throw new Error('Invalid SSH username'); if (raw.authMethod !== 'password' && raw.authMethod !== 'publickey') throw new Error('Invalid authentication method'); - const allowedFields = new Set(['type', 'host', 'port', 'username', 'authMethod', 'password', 'privateKey', 'cols', 'rows', 'term', 'expectedFingerprint']); + const allowedFields = new Set(['type', 'host', 'port', 'username', 'authMethod', 'password', 'privateKey', 'cols', 'rows', 'term', 'expectedFingerprint', 'mode']); if (Object.keys(raw).some((field) => !allowedFields.has(field))) throw new Error('Unsupported connection field'); + if (raw.mode !== undefined && raw.mode !== 'forward') throw new Error('Invalid connection mode'); const size = normalizeTerminalSize(raw.cols ?? 120, raw.rows ?? 40); if (!size) throw new Error('Invalid terminal size'); @@ -142,5 +145,6 @@ export function parseConnectMessage(value: unknown): SSHConnectionConfig { type: 'connect', host: raw.host.trim().replace(/^\[|\]$/g, ''), port: raw.port, username: raw.username, authMethod: raw.authMethod, password, privateKey, cols: size.cols, rows: size.rows, term, expectedFingerprint: raw.expectedFingerprint as string | undefined, + ...(raw.mode === 'forward' ? { mode: 'forward' as const } : {}), }; } diff --git a/src/worker.ts b/src/worker.ts index 58b88b6..4b66c04 100644 --- a/src/worker.ts +++ b/src/worker.ts @@ -8,6 +8,7 @@ import { hostsRoute } from './accounts/hosts'; import { snippetsRoute } from './accounts/snippets'; import { apiFailure, json } from './accounts/http'; import { locateHost } from './accounts/location'; +import { forwardingRoute } from './forwarding/routes'; export { SSHSessionDO }; @@ -155,6 +156,7 @@ export default { if (url.pathname === '/api/auth/me' && request.method === 'GET') return json({ account, provider: authProvider(env) }); if (url.pathname.startsWith('/api/hosts')) return await hostsRoute(request, env, account!.id, url.pathname); if (url.pathname.startsWith('/api/snippets')) return await snippetsRoute(request, env, account!.id, url.pathname); + if (url.pathname === '/api/forwarding') return await forwardingRoute(request, env, account!.id); if (url.pathname === '/api/session') { if (request.method !== 'POST') return corsResponse(jsonError('Method not allowed', 405)); return corsResponse(await sessionTicket(request, env, account!.id)); diff --git a/tests/browser/forwarding.spec.ts b/tests/browser/forwarding.spec.ts new file mode 100644 index 0000000..7b5bc9b --- /dev/null +++ b/tests/browser/forwarding.spec.ts @@ -0,0 +1,82 @@ +import { test, expect, type Page, type WebSocketRoute } from '@playwright/test'; + +const host = { + id: 'alpha', name: 'Tokyo production', host: '192.0.2.10', port: 22, username: 'root', + group: '测试主机', authMethod: 'password', initialCommand: '', termType: 'xterm-256color', + encoding: 'utf-8', fingerprint: `SHA256:${'A'.repeat(43)}`, location: null, system: null, + hasCredential: true, updatedAt: Date.now(), +}; + +async function forwardingFixture(page: Page) { + const calls: Array> = []; + const sockets: WebSocketRoute[] = []; + const closed: boolean[] = []; + await page.route('**/api/**', async (route) => { + const path = new URL(route.request().url()).pathname; + if (path === '/api/auth/me') return route.fulfill({ json: { account: { username: 'Administrator' }, provider: 'cloudflare' } }); + if (path === '/api/hosts') return route.fulfill({ json: { hosts: [host] } }); + if (path.endsWith('/credentials')) return route.fulfill({ json: { password: 'test-only-password' } }); + if (path === '/api/session') return route.fulfill({ json: { ticket: 'test-ticket', sessionId: 'session-forward' } }); + if (path === '/api/forwarding') { + calls.push({ type: 'forwarding', body: route.request().postDataJSON() }); + return route.fulfill({ json: { url: 'https://preview.example.net/__edgessh/start#xxx', expiresAt: Date.now() + 3600000 } }); + } + return route.fulfill({ json: {} }); + }); + await page.routeWebSocket('**/api/ssh?*', (ws) => { + sockets.push(ws); + const index = sockets.length - 1; + closed[index] = false; + ws.onClose(() => { closed[index] = true; void ws.close(); }); + ws.onMessage((raw) => { + const message = JSON.parse(String(raw)); + calls.push(message); + if (message.type === 'connect') ws.send(JSON.stringify({ type: 'ready' })); + }); + }); + await page.goto('/'); + await page.locator('#rail-forward').click(); + return { calls, sockets, closed }; +} + +async function startForward(page: Page) { + await page.locator('.forward-page select[name="host"]').selectOption('alpha'); + await page.locator('.forward-page input[name="port"]').fill('8080'); + await page.locator('.forward-page button[type="submit"]').click(); +} + +test('连接转发发送 forward 协议并提供弹窗拦截回退链接,停止后解锁主机', async ({ page }, testInfo) => { + const { calls } = await forwardingFixture(page); + await page.evaluate(() => { window.open = () => null; }); + await startForward(page); + await expect.poll(() => calls.find((call) => call.type === 'connect')?.mode).toBe('forward'); + await expect.poll(() => calls.find((call) => call.type === 'forwarding')?.body).toEqual({ port: 8080 }); + await expect.poll(() => page.locator('a[data-preview-link]').getAttribute('href')).toBe('https://preview.example.net/__edgessh/start#xxx'); + await expect(page.locator('a[data-preview-link]')).toBeVisible(); + await expect(page.locator('a[data-preview-link]')).toHaveAttribute('rel', 'noopener noreferrer'); + await expect(page.locator('a[data-preview-link]')).toHaveAttribute('target', '_blank'); + await page.locator('[data-stop]').click(); + await expect(page.locator('a[data-preview-link]')).toBeHidden(); + await expect(page.locator('.forward-page select[name="host"]')).toBeEnabled(); + await page.screenshot({ path: testInfo.outputPath('forwarding-desktop.png'), fullPage: true }); +}); + +test('离开总览会关闭独立转发 WebSocket,返回页面保持未连接', async ({ page }) => { + const { sockets, closed } = await forwardingFixture(page); + await startForward(page); + await expect.poll(() => sockets.length).toBe(1); + await page.locator('#rail-overview').click(); + await expect.poll(() => closed[0]).toBe(true); + await page.locator('#rail-forward').click(); + await expect(page.locator('[data-status]')).not.toContainText('已转发'); + await expect(page.locator('[data-stop]')).toBeDisabled(); +}); + +test('空主机禁用连接按钮且桌面与 375px 手机布局没有横向溢出', async ({ page }, testInfo) => { + await page.route('**/api/hosts', (route) => route.fulfill({ json: { hosts: [] } })); + await page.goto('/'); + await page.locator('#rail-forward').click(); + await expect(page.locator('.forward-page button[type="submit"]')).toBeDisabled(); + expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true); + await page.screenshot({ path: testInfo.outputPath('forwarding-mobile.png'), fullPage: true }); +}); diff --git a/tests/deployment.test.ts b/tests/deployment.test.ts index eadfaf1..30a8d40 100644 --- a/tests/deployment.test.ts +++ b/tests/deployment.test.ts @@ -3,7 +3,7 @@ import { readFile } from 'node:fs/promises'; import { test } from 'node:test'; import { parse, stringify } from 'smol-toml'; import { ensureDatabase } from '../scripts/cloudflare-d1.ts'; -import { createDeploymentConfig, readDeploymentSettings } from '../scripts/deployment-config.ts'; +import { createDeploymentConfig, createPreviewDeploymentConfig, readDeploymentSettings } from '../scripts/deployment-config.ts'; const templateText = await readFile(new URL('../wrangler.toml', import.meta.url), 'utf8'); const template = parse(templateText); @@ -50,6 +50,18 @@ test('generated config preserves application bindings and paths without persisti assert.deepEqual(template, before); }); +test('main and preview configs use the same preview origin and isolate preview bindings', () => { + const origin = 'https://edgessh-preview.example.com'; + const main = createDeploymentConfig(template, settings, database, { hostname: 'edgessh.example.com', previewOrigin: origin }); + const preview = createPreviewDeploymentConfig(template, settings, origin); + assert.equal((main.vars as Record).PREVIEW_ORIGIN, origin); + assert.equal((preview.vars as Record).PREVIEW_ORIGIN, origin); + assert.equal(preview.d1_databases, undefined); + assert.equal(preview.assets, undefined); + assert.equal(preview.vars && Object.keys(preview.vars as object).length, 1); + assert.deepEqual(preview.durable_objects, { bindings: [{ name: 'SSH_SESSIONS', class_name: 'SSHSessionDO', script_name: 'edgessh' }] }); +}); + test('worker, database and custom domain can be configured for another account', () => { const custom = readDeploymentSettings(template, { ...env, CLOUDFLARE_ACCOUNT_ID: 'b'.repeat(32), WORKER_NAME: 'my-ssh', CUSTOM_DOMAIN: 'ssh.example.com', @@ -107,6 +119,8 @@ test('invalid account, resource, domain and runtime configuration fail validatio () => readDeploymentSettings(template, { ...env, CUSTOM_DOMAIN: 'edgessh.example.workers.dev' }), /CUSTOM_DOMAIN.*workers\.dev/, ); + assert.throws(() => readDeploymentSettings(template, { ...env, PREVIEW_DOMAIN: 'edgessh-preview.workers.dev' }), /PREVIEW_DOMAIN.*workers\.dev/); + assert.throws(() => readDeploymentSettings(template, { ...env, WORKER_NAME: 'a'.repeat(63) }), /-preview/); assert.throws(() => readDeploymentSettings({ ...template, vars: { ENCRYPTION_KEY: env.ENCRYPTION_KEY }, }, env), /Secret/); diff --git a/tests/forwarding-auth.test.ts b/tests/forwarding-auth.test.ts new file mode 100644 index 0000000..9a5f114 --- /dev/null +++ b/tests/forwarding-auth.test.ts @@ -0,0 +1,110 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { ForwardingState } from '../src/forwarding/state.ts'; +import { parseCapability, previewOrigin } from '../src/forwarding/security.ts'; + +const origin = 'https://preview.other.test'; +const sessionId = 'a'.repeat(64); + +function fakeSession(initialReady = true) { + let ready = initialReady; + return { + isForwardReady: () => ready, + openForward: async () => ({ close: async () => undefined }), + close: () => { ready = false; }, + }; +} + +async function prepared() { + const session = fakeSession(); + const state = new ForwardingState(); + const response = await state.create(session as never, 8080, origin, sessionId); + const url = (await response.json() as { url: string }).url; + const capability = parseCapability(new URL(url).hash.slice(1)); + assert.ok(capability); + return { state, session, launchToken: capability.token }; +} + +test('launch token is single-use and returns preview token', async () => { + const { state, session, launchToken } = await prepared(); + try { + const request = () => new Request('https://internal/preview-launch', { + method: 'POST', headers: { 'x-preview-origin': origin, 'x-preview-token': launchToken }, + }); + const first = await state.preview(request()); + assert.equal(first.status, 200); + const body = await first.json() as { token: string }; + assert.match(body.token, /^[a-f0-9-]{36}$/); + assert.equal((await state.preview(request())).status, 401); + } finally { state.clear(); } + assert.equal(session.isForwardReady(), true); +}); + +test('cleared grant rejects the launch token with 410', async () => { + const { state, launchToken } = await prepared(); + try { + state.clear(); + const response = await state.preview(new Request('https://internal/preview-launch', { + method: 'POST', headers: { 'x-preview-origin': origin, 'x-preview-token': launchToken }, + })); + assert.equal(response.status, 410); + } finally { state.clear(); } +}); + +test('invalid preview token returns 401', async () => { + const { state } = await prepared(); + try { + const response = await state.preview(new Request('https://internal/preview', { + headers: { 'x-preview-origin': origin, 'x-preview-token': 'bad' }, + })); + assert.equal(response.status, 401); + } finally { state.clear(); } +}); + +test('cross-site and missing-origin writes are rejected with 403', async () => { + const { state, launchToken } = await prepared(); + try { + const launch = await state.preview(new Request('https://internal/preview-launch', { + method: 'POST', headers: { 'x-preview-origin': origin, 'x-preview-token': launchToken }, + })); + const token = (await launch.json() as { token: string }).token; + const crossSite = await state.preview(new Request('https://internal/preview', { + method: 'POST', headers: { 'x-preview-origin': origin, 'x-preview-token': token, Origin: 'https://evil.test' }, + })); + assert.equal(crossSite.status, 403); + const missingOrigin = await state.preview(new Request('https://internal/preview', { + method: 'POST', headers: { 'x-preview-origin': origin, 'x-preview-token': token }, + })); + assert.equal(missingOrigin.status, 403); + } finally { state.clear(); } +}); + +test('unready SSH session returns 410', async () => { + const { state, session, launchToken } = await prepared(); + try { + session.close(); + const response = await state.preview(new Request('https://internal/preview-launch', { + method: 'POST', headers: { 'x-preview-origin': origin, 'x-preview-token': launchToken }, + })); + assert.equal(response.status, 410); + } finally { state.clear(); } +}); + +test('stop closes the SSH session', async () => { + const { state, session } = await prepared(); + state.stop(); + assert.equal(session.isForwardReady(), false); + state.clear(); +}); + +test('previewOrigin rejects missing, same-parent, and same-account workers.dev domains', () => { + assert.throws(() => previewOrigin(undefined, 'https://ssh.example.com')); + assert.throws(() => previewOrigin('https://preview.ssh.example.com', 'https://ssh.example.com')); + assert.throws(() => previewOrigin('https://preview.account.workers.dev', 'https://ssh.account.workers.dev')); +}); + +test('previewOrigin allows independent custom and workers.dev domains', () => { + assert.equal(previewOrigin('preview.other.test', 'https://ssh.example.com'), origin); + assert.equal(previewOrigin('https://preview.account.workers.dev', 'https://ssh.example.com'), + 'https://preview.account.workers.dev'); +}); diff --git a/tests/forwarding-http.test.ts b/tests/forwarding-http.test.ts new file mode 100644 index 0000000..84fad13 --- /dev/null +++ b/tests/forwarding-http.test.ts @@ -0,0 +1,44 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { previewHeaders, rewriteLocalURL, stripHopHeaders, upstreamHeaders } from '../src/forwarding/http.ts'; + +test('forwarding strips hop-by-hop and internal request headers', () => { + const request = new Request('https://preview.example/app', { headers: { + Connection: 'keep-alive, X-Custom-Hop', 'X-Custom-Hop': 'drop', + Cookie: 'a=1; __Host-edgessh-preview=secret; b=2', 'X-Preview-Path': '/app', + Origin: 'https://preview.example', Referer: 'https://preview.example/app', + }}); + const headers = upstreamHeaders(request, 2222, 'https://preview.example'); + assert.equal(headers.has('connection'), true); + assert.equal(headers.get('host'), '127.0.0.1:2222'); + assert.equal(headers.get('cookie'), 'a=1; b=2'); + assert.equal(headers.has('x-preview-path'), false); + assert.equal(headers.get('origin'), 'http://127.0.0.1:2222'); +} +); + +test('preview response preserves safe cookies and rewrites location', () => { + const source = new Headers(); + source.append('Set-Cookie', 'sid=abc; Domain=internal.test; Secure; Path=/'); + source.append('Set-Cookie', '__Host-edgessh-preview=bad; Path=/'); + source.set('Location', 'http://127.0.0.1:2222/login'); + source.set('Content-Length', '10'); + const result = previewHeaders(source, 2222, 'https://preview.example'); + assert.equal(result.get('location'), 'https://preview.example/login'); + assert.equal(result.get('content-length'), null); + assert.deepEqual(result.getSetCookie(), ['sid=abc; Path=/; Secure']); +} +); + +test('URL rewriting leaves foreign origins unchanged', () => { + assert.equal(rewriteLocalURL('http://127.0.0.1:2222/a?x=1', 2222, 'https://p.test'), 'https://p.test/a?x=1'); + assert.equal(rewriteLocalURL('http://127.0.0.1:9999/a', 2222, 'https://p.test'), 'http://127.0.0.1:9999/a'); +}); + +test('stripHopHeaders removes declared connection tokens', () => { + const headers = new Headers({ Connection: 'X-Trace', 'X-Trace': 'drop', Upgrade: 'h2c', KeepAlive: 'x' }); + stripHopHeaders(headers); + assert.equal(headers.has('x-trace'), false); + assert.equal(headers.has('upgrade'), false); + assert.equal(headers.has('keepalive'), true); +}); diff --git a/tests/forwarding-protocol.test.ts b/tests/forwarding-protocol.test.ts new file mode 100644 index 0000000..ba79fd9 --- /dev/null +++ b/tests/forwarding-protocol.test.ts @@ -0,0 +1,62 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { proxyHTTP, rewriteLocalURL } from '../src/forwarding/http.ts'; + +class FakeChannel { + writes: Uint8Array[] = []; + closed = 0; + aborted = 0; + constructor(bytes: Uint8Array) { + this.readable = new ReadableStream({ start: c => { c.enqueue(bytes); c.close(); } }); + } + readable: ReadableStream; + async write(value: Uint8Array) { this.writes.push(value.slice()); } + async close() { this.closed++; } + abort() { this.aborted++; } +} +const bytes = (s: string) => new TextEncoder().encode(s); +const text = async (r: Response) => new TextDecoder().decode(await r.arrayBuffer()); +const run = async (wire: string, init: RequestInit = {}, path = '/x') => { + const channel = new FakeChannel(bytes(wire)); + const request = new Request('https://p.test', { ...init, headers: { ...(init.headers ?? {}), 'x-preview-path': path } }); + const response = await proxyHTTP(channel as never, request, 2222, 'https://p.test'); + return { channel, response }; +}; + +test('proxyHTTP reads content-length and EOF bodies', async () => { + for (const wire of ['HTTP/1.1 200 OK\r\nContent-Length: 5\r\n\r\nhello', 'HTTP/1.1 200 OK\r\n\r\nhello']) { + const { channel, response } = await run(wire); + assert.equal(await text(response), 'hello'); assert.equal(channel.closed, 1); + } +}); + +test('proxyHTTP parses byte-split chunk framing and trailers', async () => { + const { response } = await run('HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n1\r\na\r\n2\r\nbc\r\n0\r\nX-End: yes\r\n\r\n'); + assert.equal(await text(response), 'abc'); +}); + +test('POST preserves exact form body and authorization upstream', async () => { + const { channel } = await run('HTTP/1.1 201 Created\r\nContent-Length: 0\r\n\r\n', { method: 'POST', body: 'a=1&b=two', headers: { Authorization: 'Bearer x', 'Content-Type': 'application/x-www-form-urlencoded' } }); + const sent = channel.writes.map(value => new TextDecoder().decode(value)).join(''); + assert.match(sent, /authorization: Bearer x/i); assert.ok(sent.includes('a=1&b=two')); +}); + +test('ambiguous framing and truncated bodies fail and close', async () => { + await assert.rejects(() => run('HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\nContent-Length: 1\r\n\r\na')); + const c = new FakeChannel(bytes('HTTP/1.1 200 OK\r\nContent-Length: 5\r\n\r\nno')); + const response = await proxyHTTP(c as never, new Request('https://p.test', { headers: { 'x-preview-path': '/' } }), 2222, 'https://p.test'); + await assert.rejects(() => response.arrayBuffer()); + assert.equal(c.closed, 1); +}); + +test('HEAD and 204 responses have no body and cancellation closes channel', async () => { + const head = await run('HTTP/1.1 200 OK\r\nContent-Length: 4\r\n\r\nbody', { method: 'HEAD' }); + assert.equal(await head.response.text(), ''); assert.equal(head.channel.closed, 1); + const empty = await run('HTTP/1.1 204 No Content\r\n\r\n'); assert.equal(await empty.response.text(), ''); + const cancel = await run('HTTP/1.1 200 OK\r\nContent-Length: 5\r\n\r\nhello'); await cancel.response.body?.cancel(); assert.equal(cancel.channel.closed, 1); +}); + +test('URL rewrite preserves relative nested links and fragments', () => { + assert.equal(rewriteLocalURL('../next#part', 2222, 'https://p.test'), '../next#part'); + assert.equal(rewriteLocalURL('#part', 2222, 'https://p.test'), '#part'); +}); diff --git a/wrangler.preview.toml b/wrangler.preview.toml new file mode 100644 index 0000000..da2dfb6 --- /dev/null +++ b/wrangler.preview.toml @@ -0,0 +1,15 @@ +name = "edgessh-preview" +main = "src/preview-worker.ts" +compatibility_date = "2026-07-01" +workers_dev = true +preview_urls = false + +# 部署脚本填入实际预览域名,并将 script_name 指向主 Worker。 +# 预览 Worker 不绑定 D1、静态资源、ENCRYPTION_KEY 或主站认证 Secret。 +[vars] +PREVIEW_ORIGIN = "https://edgessh-preview.example.workers.dev" + +[[durable_objects.bindings]] +name = "SSH_SESSIONS" +class_name = "SSHSessionDO" +script_name = "edgessh"