feat: add selectable forwarding modes and optional preview deployment

默认使用可信同源路径转发,支持界面切换隔离预览并按需部署。
This commit is contained in:
gdz66601 committed 2026-09-24 20:41:44 +08:00
1 parent fc7a0caf81
commit 5d65b34907
24 files changed
+680 -102

No files matched your search

+14
View File
@@ -0,0 +1,14 @@
name: 部署预览 Worker
on:
workflow_dispatch:
permissions:
contents: read
jobs:
deploy:
uses: ./.github/workflows/deploy.yml
with:
deploy_preview: true
secrets: inherit
+6
View File
@@ -20,6 +20,11 @@ on:
description: 需要检出并部署的提交 SHA
required: false
type: string
deploy_preview:
description: 是否同时部署预览 Worker
required: false
default: false
type: boolean
concurrency:
group: production-deploy
@@ -49,6 +54,7 @@ jobs:
# 域名本身不是敏感信息,推荐使用 Variable;同时兼容已将它保存为 Secret 的仓库。
CUSTOM_DOMAIN: ${{ secrets.CUSTOM_DOMAIN || vars.CUSTOM_DOMAIN }}
PREVIEW_DOMAIN: ${{ vars.PREVIEW_DOMAIN }}
DEPLOY_PREVIEW_WORKER: ${{ inputs.deploy_preview && 'true' || 'false' }}
steps:
- name: Check out repository
uses: actions/checkout@v4
+14 -10
View File
@@ -11,7 +11,7 @@
| `AUTH_PROVIDER` | Variable | `cloudflare`(默认) | `github` |
| `CLOUDFLARE_API_TOKEN` | Secret | 必填 | 必填 |
| `CUSTOM_DOMAIN` | Variable | 推荐填写实际主机名 | 推荐填写实际主机名 |
| `PREVIEW_DOMAIN` | Variable | 仅填预览 hostname;留空自动生成 | 仅填预览 hostname;留空自动生成 |
| `PREVIEW_DOMAIN` | Variable | 可选;仅部署独立预览 Worker 时使用 | 可选;仅部署独立预览 Worker 时使用 |
| `ADMIN_EMAIL` | Variable | 管理员邮箱 | 不需要 |
| `GITHUB_CLIENT_ID` | Variable | 不需要 | OAuth App 的 Client ID |
| `GITHUB_CLIENT_SECRET` | Secret | 不需要 | OAuth App 的 Client Secret |
@@ -65,16 +65,20 @@ API Token 只存 GitHub Secret,不放普通变量、代码或命令行输入
## 独立预览 Worker 与端口转发
远端网站可能已被入侵并返回恶意 JavaScript。同源代理会让它代发主站 SSH API;`HttpOnly` 只能保护 Cookie,挡不住同源脚本调用接口。独立 Worker 本身不代表不同 site:若仍在同一 site,主站 Cookie 仍有风险,因此必须使用跨 site 的预览域名。
| 模式 | 默认入口 | 适用场景 | 部署要求 |
| --- | --- | --- | --- |
| `trusted` | 主 Worker `/_forward/<session>/` | 你信任的目标网站 | 无需第二 Worker;默认模式 |
| `isolated` | 独立预览 Worker | 不可信或可能被入侵的目标网站 | Actions 运行 `部署预览 Worker`,预览域名必须跨 site |
- 部署先发布主 Worker,再发布只绑定 `SSH_SESSIONS` 的预览 Worker;预览不绑定 D1、ASSETS、加密密钥或主站接口。
- `PREVIEW_DOMAIN` 是 Actions Variable,只填 hostname。未配置时默认 `<WORKER_NAME>-preview.<账户子域>.workers.dev`。
- `ssh.example.com` + 默认 `workers.dev` 可行;`ssh.example.com` + `preview.example.com` 会拒绝;同一账户下两个 `workers.dev` 也会拒绝。主站只有 `workers.dev` 时,必须为预览配置独立的自定义域名。
- 运行时 `PREVIEW_ORIGIN` 由部署脚本生成并写入两个 Worker,用户不需要手工修改主配置。
同源路径、HttpOnly Cookie 和新窗口不是沙箱;恶意脚本仍可代发主站 SSH API。主站 Cookie 不会转发给远端,但不防同源 JavaScript 调用接口。界面会显示风险警告并要求信任确认;连接期间禁用切换,用户需先点击停止再切换。未部署预览 Worker 时选择 isolated 不会回退到标准转发,而是明确拒绝连接。
使用主机管理页选择主机和端口(例如 `127.0.0.1` HTTP),确认指纹后打开新预览窗口;管理页保留在原窗口。停止转发、离开预览页或 SSH 断线后,预览失效。数据流为:主站登录 -> SSH Durable Object -> 一次性 fragment -> 预览 HttpOnly capability -> 远端 HTTP。票据 60 秒内只能兑换一次;兑换后的授权最长 1 小时,二者不是同一时限。
普通 push 或 `Deploy` 只发布主 Worker,不新建 preview。需要 isolated 时,运行 Actions **部署预览 Worker**,该 workflow 先更新主 Worker 配置,再调用原可复用 Deploy 并传入 `deploy_preview=true`,仅该流程设置 `DEPLOY_PREVIEW_WORKER`;可选填写 `PREVIEW_DOMAIN`,留空默认 `<WORKER_NAME>-preview.<账户子域>.workers.dev`。它发布只绑定 `SSH_SESSIONS` 的预览 Worker,不绑定 D1、ASSETS、加密密钥或主站接口,并设置主站 `PREVIEW_ORIGIN`。已有 `PREVIEW_ORIGIN` 在常规发布中保留,不自动删除;普通发布不会更新预览代码,修改预览实现时须重新运行该 workflow,现有预览 Worker 无需重新部署即可在界面切换。
预览支持 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 只能防止登录态被预览站点盗取,不代表预览网站本身安全。
主站与预览必须跨 site:自定义域名加默认 `workers.dev` 可行;自定义域名加同站自定义域名会拒绝;同账户双 `workers.dev` 也会拒绝,主站只有 `workers.dev` 时需独立自定义域名。专用 preview 同一 origin 内不同目标网站不相互隔离,切换前关闭旧预览窗口。
操作步骤:在端口转发页面选择主机和端口转发类型(如 `127.0.0.1` HTTP)→ 选择 `trusted` 或 `isolated`(trusted 需勾选界面信任确认,isolated 不需要)→ 确认指纹并连接。连接期间禁用模式切换,需先点击停止再切换。停止主站连接、离开主站端口转发管理页、刷新页面或 SSH 断线后预览失效;仅关闭目标预览 tab 不保证停止。isolated 票据 60 秒内只能兑换一次,兑换后的授权最长 1 小时;trusted 链接依赖主站登录及账户绑定,不使用一次性 fragment。
标准实现改写 HTML 属性、`srcset`、CSS URL、`Location`、Cookie 名称与 Path,并注入常见 `fetch`/XHR/EventSource/history/cookie 兼容脚本;不承诺任意网站透明代理。严格 CSP、动态 ES 模块、写死的 location、复杂 inline CSS/JS 框架仍可能需要 baseURL 配置,优先使用 isolated。仅支持 HTTP/SSE、相对资源、表单、目标 Cookie、重定向和 HTTP Basic 鉴权;HTTP 上游限 `127.0.0.1`,上传 16 MiB,CSS 重写 2 MiB,最多 24 个并发通道,通道闲置 60 秒。HTTPS 上游、WebSocket、Service Worker、写死 `localhost`、OAuth 固定 callback 不支持;SFTP 上传仍为 64 MiB。
## 自动执行顺序
@@ -90,7 +94,7 @@ API Token 只存 GitHub Secret,不放普通变量、代码或命令行输入
生产任务通过 concurrency 串行运行,失败可修正原因后重跑,已创建的资源会复用。请勿用多个仓库同时管理同一个 Worker。
设置自定义域名时关闭备用 `workers.dev` 入口,所有部署关闭 preview URL。Cloudflare 模式校验 Access JWT;GitHub 模式使用 state、PKCE 和签名 HttpOnly Cookie。缺少认证不降级为匿名 SSH。
设置自定义域名时关闭备用 `workers.dev` 入口;两个 Worker 都关闭 Cloudflare 版本预览 URL;独立 workflow 发布专用 preview Worker 的正式地址。Cloudflare 模式校验 Access JWT;GitHub 模式使用 state、PKCE 和签名 HttpOnly Cookie。缺少认证不降级为匿名 SSH。
## 官方强制更新
@@ -115,7 +119,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;留空为 `<WORKER_NAME>-preview.<账户子域>.workers.dev` |
| `PREVIEW_DOMAIN` | Variable | 仅 `部署预览 Worker` 使用;留空为 `<WORKER_NAME>-preview.<账户子域>.workers.dev` |
| `ENCRYPTION_KEY` | Secret,仅恢复/迁移使用 | 仅 Worker 尚无密钥时使用;已有密钥不会覆盖 |
`DB`、`SSH_SESSIONS`、`ASSETS` 是资源绑定,不是需要用户创建的变量。`CONNECT_TIMEOUT_MS` 已有默认值 `10000`。
+10 -6
View File
@@ -114,7 +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;仅填域名 |
| `PREVIEW_DOMAIN` | Variable | 可选;仅在部署独立预览 Worker 时填写 hostname | 可选;仅在部署独立预览 Worker 时填写 hostname |
| `ADMIN_EMAIL` | Variable | 管理员邮箱,也可在 Run workflow 输入 | 不填 |
| `GITHUB_CLIENT_ID` | Variable | 不填 | OAuth App 的 Client ID |
| `GITHUB_CLIENT_SECRET` | Secret | 不填 | OAuth App 的 Client Secret |
@@ -162,11 +162,11 @@ 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;留空自动生成 `<Worker 名>-preview.<账户 workers.dev 子域>` |
| `PREVIEW_DOMAIN` | `preview.example.com` | 可选;仅供 `部署预览 Worker` 使用,留空自动生成 `<Worker 名>-preview.<账户 workers.dev 子域>.workers.dev` |
`ENCRYPTION_KEY` 由部署流程管理并持久保存在 **Cloudflare Worker Secrets**;Cloudflare 模式另存 `ACCESS_TEAM_DOMAIN`、`ACCESS_AUD`,GitHub 模式同步 `GITHUB_CLIENT_SECRET`。不需要用户复制自动生成的值回 GitHub。加密密钥只在首次部署生成,后续保留,即使 GitHub 留有旧值也不会覆盖线上密钥。不要删除 Worker 或其加密密钥;Cloudflare 不提供密钥明文读回,丢失后无法解密已有资料。
推送 `main` 或手动运行 `Deploy` 都会执行检查和部署。只在首次 Run workflow 输入邮箱也可以:后续未提供邮箱时保留已有认证配置。需要更换域名或重新自动配置 Access 时,请再次提供邮箱。
推送 `main` 或手动运行 `Deploy` 都会执行检查并只部署主 Worker,不会自动新建预览 Worker。需要代理不可信网站时,再从 Actions 手动运行 **部署预览 Worker**;它调用同一套可复用部署逻辑并设置 `deploy_preview=true`,只在该工作流临时设置 `DEPLOY_PREVIEW_WORKER`。只在首次 Run workflow 输入邮箱也可以:后续未提供邮箱时保留已有认证配置。需要更换域名或重新自动配置 Access 时,请再次提供邮箱。
Fork 启用 Actions 后,`Force Update` 每小时检查一次官方 `aozorae/EdgeSSH` 的 `main`。只有当前版本与官方最新版本之间出现带有独立 Git trailer `EdgeSSH-Auto-Update: true` 的提交时,工作流才会将 Fork 的 `main` 精确同步到最新一个标记提交,并在同一次运行中直接部署该 SHA;普通提交不会触发同步。也可从 Actions 页面手动运行检查。该流程会覆盖 Fork 在 `main` 上的自定义提交,自定义开发请保留在其他分支。
@@ -219,13 +219,17 @@ EdgeSSH/
### 独立预览 Worker
远端网站可能已被入侵并返回恶意 JavaScript。若代理与主站同源,脚本就能代发主站 SSH 管理 API;HttpOnly 只能保护 Cookie,不能阻止同源脚本调用接口。部署会先发布主 Worker,再发布只绑定 `SSH_SESSIONS` 的预览 Worker;预览不持有 D1、ASSETS 或任何 Secret。不同 Worker 不必然代表不同 site:普通域名比较末两段,`workers.dev` 比较末三段;同账户双 workers.dev 会明确拒绝,跨 site 隔离是保护主站会话边界的必要成本。
默认端口转发路径为主 Worker 的 `/_forward/<session>/`,界面默认 **trusted**,只应连接你信任的网站。页面会显示醒目风险警告,并要求勾选信任确认;连接期间禁用 trusted/isolated 切换,用户需先点击停止再切换。路径同源、HttpOnly Cookie 和新窗口都不是沙箱:恶意脚本仍可能代发主站 SSH API;主站 Cookie 不会转发给远端,但这不能防止同源 JavaScript 调用主站接口。
凭据只在 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 内的网站不再相互隔离;切换前关闭旧预览,只用于一个网站,不能对外分享。
需要不可信网站时,从 Actions 运行 **部署预览 Worker**(可选填写 `PREVIEW_DOMAIN`,默认 `<Worker 名>-preview.<账户 workers.dev 子域>.workers.dev`)。该工作流先更新主 Worker 配置,再部署只绑定 `SSH_SESSIONS` 的独立 Worker,并设置主站 `PREVIEW_ORIGIN`;普通 push/`Deploy` 只发布主 Worker,已有 `PREVIEW_ORIGIN` 会保留,不会自动删除既有预览,普通发布也不会更新预览代码。现有预览 Worker 保留即可在界面切换;修改预览实现时须重新运行该工作流。运行后在界面选择 **isolated**;选择隔离模式但未部署预览 Worker 时不会回退到标准转发,而是明确拒绝连接。预览域名必须与主站跨 site:同账户双 `workers.dev` 会拒绝,主站仅用 `workers.dev` 时需独立自定义域名。专用预览同一 origin 内的不同目标网站不相互隔离,切换前关闭旧预览窗口。
isolated 模式凭据只在 URL fragment 中短暂传递,随后立即换成 HttpOnly Cookie;fragment 票据每 60 秒只能一次性兑换,断线或 1 小时后失效。trusted 链接依赖主站登录及账户绑定,不使用一次性 fragment。标准实现会改写 HTML 属性、`srcset`、CSS URL、`Location`、Cookie 名称与 Path,并注入常见 `fetch`/XHR/EventSource/history/cookie 兼容脚本;不承诺任意网站透明代理。严格 CSP、动态 ES 模块、写死的 location、复杂 inline CSS/JS 框架仍可能需要 baseURL 配置,优先使用独立隔离模式。
支持 HTTP/SSE、相对资源、表单、目标 Cookie、重定向和 HTTP Basic 鉴权;仅支持 HTTP 上游 `127.0.0.1`。上传上限 16 MiB,CSS 重写上限 2 MiB,最多 24 个并发 SSH 通道;授权最长 1 小时,单通道闲置 60 秒。不支持 HTTPS 上游、WebSocket、Service Worker、JavaScript 写死 `localhost` 或 OAuth 固定 callback。SFTP 文件上传仍是独立的 64 MiB 限制。
### 端口转发
在主机管理页选择主机和端口转发类型(例如 `127.0.0.1` 的 HTTP),确认 SSH 主机指纹后,系统会打开新的预览窗口。管理页会保留在原窗口;停止转发、离开预览页或 SSH 断线后,预览立即失效。预览票据只使用一次,不能分享给他人。
在端口转发页面选择主机和端口转发类型(例如 `127.0.0.1` 的 HTTP),确认 SSH 主机指纹后,系统会打开新的预览窗口。管理页会保留在原窗口;用户停止主站连接、离开主站端口转发管理页、刷新页面或 SSH 断线后,预览立即失效;仅关闭目标预览 tab 不保证停止。预览票据只使用一次,不能分享给他人。
### 安全边界
+6
View File
@@ -1,6 +1,12 @@
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-warning { border: 1px solid var(--home-accent); border-left-width: 4px; border-radius: 8px; padding: 18px 22px; margin-bottom: 20px; background: var(--home-accent-soft); }
.forward-warning p { margin: 8px 0 0; line-height: 1.8; }
.forward-mode-field, .forward-wide { grid-column: 1 / -1; }
#dashboard .forward-form .forward-trust { display: flex; align-items: flex-start; gap: 10px; font-size: 14px; line-height: 1.7; font-weight: 400; }
#dashboard .forward-form .forward-trust input { width: 18px; height: 18px; flex: 0 0 18px; margin-top: 3px; accent-color: var(--home-accent); }
.forward-setup { margin-top: 20px; border-top: 1px solid var(--home-line); padding-top: 18px; line-height: 1.8; }
.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; }
+50 -11
View File
@@ -1,7 +1,7 @@
import { api, hostCredentials, type CloudHost } from './cloud-api';
import './forward-page.css';
/** 转发持有独立 SSH 连接,不借用终端,防止预览页获得命令输入能力。 */
/** 转发持有独立 SSH 连接;标准模式与主站同源,不能当作恶意脚本的安全边界。 */
export class ForwardPage {
readonly root = document.createElement('main');
private hosts: CloudHost[] = [];
@@ -18,6 +18,9 @@ export class ForwardPage {
private readonly stopButton: HTMLButtonElement;
private readonly openButton: HTMLButtonElement;
private readonly keyDialog: HTMLDialogElement;
private readonly mode: HTMLSelectElement;
private readonly trust: HTMLInputElement;
private previewAvailable = false;
private pendingKey?: { socket: WebSocket; fingerprint: string };
constructor() {
@@ -26,24 +29,35 @@ export class ForwardPage {
this.root.innerHTML = `
<header class="home-section-heading"><div><p class="home-eyebrow">PRIVATE WEB PREVIEW</p>
<h1 tabindex="-1">端口转发</h1><p>通过 SSH,打开只监听在服务器本机的网站。</p></div>
<span class="forward-badge">独立站点隔离</span></header>
<span class="forward-badge" data-mode-badge>标准路径转发</span></header>
<aside class="forward-warning" data-trust-warning role="note"><strong>标准转发仅用于可信网站</strong>
<p>网站与 EdgeSSH 主站同源。恶意脚本可能借用你的登录态调用主机管理或 SSH 接口;不同路径、新窗口和 HttpOnly 都不能隔离它。不要用此模式打开不可信或疑似被入侵的网站。</p></aside>
<section class="forward-card" aria-label="端口转发设置">
<form class="forward-form">
<label class="forward-mode-field">转发方式<select name="mode" aria-describedby="forward-mode-help">
<option value="trusted">标准转发 · 本 Worker 路径(仅可信网站)</option>
<option value="isolated">隔离预览 · 独立 Worker(不可信网站)</option>
</select></label>
<p id="forward-mode-help" class="forward-hint forward-wide">默认无需部署第二个 Worker。选择隔离预览可查看启用方法。</p>
<label>目标主机<select name="host" required aria-describedby="forward-host-hint"><option value="">请选择主机</option></select></label>
<label>网站端口<input name="port" type="number" min="1" max="65535" value="8080" required inputmode="numeric"></label>
<button class="home-button primary" type="submit">连接并打开网站 ↗</button>
<button class="home-button" type="button" data-stop disabled>停止转发</button>
<label class="forward-trust forward-wide"><input name="trusted" type="checkbox" required>我确认该网站及其脚本可信,接受与 SSH 主站同源的风险。</label>
</form>
<p id="forward-host-hint" class="forward-hint">访问目标固定为所选服务器的 <code>http://127.0.0.1:端口</code>,不是你电脑的本机端口。</p>
<div class="forward-status"><span data-status role="status" aria-live="polite">未连接 · 请选择主机和网站端口</span>
<button class="home-button" type="button" data-open hidden>重新打开预览 ↗</button></div>
<a data-preview-link target="_blank" rel="noopener noreferrer" hidden>弹窗被拦截?点击打开隔离预览 ↗</a>
<a data-preview-link target="_blank" rel="noopener noreferrer" hidden>弹窗被拦截?点击打开网站 ↗</a>
<div class="forward-setup" data-preview-setup hidden><strong>尚未启用隔离预览</strong>
<p>在 GitHub 仓库的 Actions 中运行「部署预览 Worker」工作流。可在 Settings → Secrets and variables → Actions 设置 PREVIEW_DOMAIN;不填则使用默认 workers.dev 地址。预览域名必须与主站跨站。</p>
<p>部署完成后重新进入此页,再选择「隔离预览」。此模式不会回退到标准转发。</p></div>
</section>
<section class="forward-explanation" aria-label="隔离与使用说明">
<h2>让网站留在另一扇窗里</h2>
<p>远端网站可能包含不可信脚本。预览在独立 Worker、独立站点打开,不共享 EdgeSSH 登录 Cookie,也不提供主机管理或终端接口。</p>
<h2>可信网站简单用,不可信网站单独隔离</h2>
<p>标准转发使用当前 Worker 的独立路径,不增加部署资源。隔离预览使用可选的独立 Worker 和跨站域名,不共享主站登录 Cookie。</p>
<ol><li>选择云端主机,输入网站的 HTTP 端口。</li><li>核对 SSH 主机指纹,连接后自动打开预览。</li><li>用完点击停止;离开此页面、刷新或断线后,转发立即失效。</li></ol>
<p class="forward-hint">支持常见资源、表单、网站 Cookie、HTTP 登录和重定向。单次授权最长 1 小时,上传最多 16 MiB。首版不支持 HTTPS 上游、WebSocket、Service Worker,以及脚本内写死的 localhost 地址。同一预览域名一次只使用一个网站,请先关闭旧预览窗口再切换。</p>
<p class="forward-hint">支持常见资源、表单、网站 Cookie、HTTP 登录和重定向。授权最长 1 小时,上传最多 16 MiB。不支持 HTTPS 上游、WebSocket、Service Worker。标准模式兼容常见 fetch / XHR,但不是任意网站的透明代理;复杂 SPA、动态模块或硬编码跳转可能需要网站配置 base URL。同一隔离预览域名一次只使用一个网站,切换前关闭旧窗口。</p>
</section>
<dialog class="host-dialog" aria-labelledby="forward-key-heading">
<h2 id="forward-key-heading">核对 SSH 主机指纹</h2>
@@ -52,12 +66,16 @@ export class ForwardPage {
<div class="dialog-actions"><button class="home-button" type="button" data-key-reject>取消连接</button>
<button class="home-button primary" type="button" data-key-accept>信任并连接</button></div>
</dialog>`;
this.select = this.get('select');
this.port = this.get('input');
this.select = this.get('[name="host"]');
this.port = this.get('[name="port"]');
this.mode = this.get('[name="mode"]');
this.trust = this.get('[name="trusted"]');
this.startButton = this.get('[type="submit"]');
this.stopButton = this.get('[data-stop]');
this.openButton = this.get('[data-open]');
this.keyDialog = this.get('dialog');
this.mode.addEventListener('change', () => { this.trust.checked = false; this.render(); });
this.trust.addEventListener('change', () => this.render());
this.get('[data-key-accept]').addEventListener('click', () => {
const pending = this.pendingKey;
if (pending?.socket.readyState === WebSocket.OPEN) {
@@ -89,7 +107,12 @@ export class ForwardPage {
this.render();
}
show(): void { this.root.hidden = false; this.get('h1').focus(); }
show(): void {
this.root.hidden = false; this.get('h1').focus();
void api<{ previewAvailable: boolean }>('/api/forwarding').then((config) => {
this.previewAvailable = config.previewAvailable === true; this.render();
}).catch(() => { this.previewAvailable = false; this.render(); });
}
hide(): void {
if (this.root.hidden) return;
this.stop();
@@ -97,9 +120,20 @@ export class ForwardPage {
}
private render(): void {
const isolated = this.mode.value === 'isolated';
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.mode.disabled = this.busy || this.ready;
this.trust.disabled = this.busy || this.ready || isolated;
this.trust.required = !isolated;
this.get('.forward-trust').hidden = isolated;
this.get('[data-trust-warning]').hidden = isolated;
this.get('[data-preview-setup]').hidden = !isolated || this.previewAvailable;
this.get('[data-mode-badge]').textContent = isolated ? '跨站隔离预览' : '标准路径转发';
this.get('#forward-mode-help').textContent = isolated
? (this.previewAvailable ? '独立预览 Worker 已启用。网站内容将在另一个站点打开。' : '需要先运行「部署预览 Worker」工作流。未启用时不会降级转发。')
: '无需第二个 Worker;与主站同源,仅可访问可信网站。连接后请先停止再切换方式。';
this.startButton.disabled = this.busy || this.ready || !this.hosts.length || (isolated ? !this.previewAvailable : !this.trust.checked);
this.startButton.textContent = this.busy ? '正在连接…' : '连接并打开网站 ↗';
this.stopButton.disabled = !this.busy && !this.ready;
this.openButton.hidden = !this.ready;
@@ -118,6 +152,7 @@ export class ForwardPage {
private async start(): Promise<void> {
if (this.busy || this.ready) return;
if (this.mode.value === 'trusted' ? !this.trust.checked : !this.previewAvailable) return;
const host = this.hosts.find((item) => item.id === this.select.value);
if (!host) { this.message('请先选择一台已保存的主机。'); return; }
const port = Number(this.port.value);
@@ -177,7 +212,10 @@ export class ForwardPage {
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) });
`/api/forwarding?session=${this.sessionId}`, 'POST', {
port: Number(this.port.value), mode: this.mode.value,
...(this.mode.value === 'trusted' ? { trusted: this.trust.checked } : {}),
});
if (generation !== this.generation) return;
this.ready = true;
const link = this.get<HTMLAnchorElement>('[data-preview-link]');
@@ -199,6 +237,7 @@ export class ForwardPage {
this.socket?.close(); this.socket = undefined;
// 主 WebSocket 关闭就是撤销操作;无需依赖卸载页面时不可靠的异步 fetch。
this.sessionId = undefined; this.busy = false; this.ready = false;
this.trust.checked = false;
this.get('[data-preview-link]').hidden = true;
this.get<HTMLAnchorElement>('[data-preview-link]').removeAttribute('href');
this.message(message); this.render();
+16 -11
View File
@@ -5,7 +5,7 @@ import { parse, stringify } from 'smol-toml';
import { ensureDatabase } from './cloudflare-d1.ts';
import { createDeploymentConfig, createPreviewDeploymentConfig, readDeploymentSettings } from './deployment-config.ts';
import { CloudflareApi, resolveAccountId } from './cloudflare-api.ts';
import { resolveWorkersDevSubdomain } from './cloudflare-access.ts';
import { resolveWorkerHostname, 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';
@@ -44,10 +44,13 @@ async function main(): Promise<void> {
settings.accountId = await resolveAccountId(api, settings.accountId);
process.env.CLOUDFLARE_ACCOUNT_ID = settings.accountId;
const existingSecrets = await readWorkerSecretNames(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 hostname = settings.customDomain || await resolveWorkerHostname(api, settings);
let preview = await readWorkerVariable(api, settings, 'PREVIEW_ORIGIN');
let previewHostname: string | undefined;
if (settings.deployPreview) {
previewHostname = settings.previewDomain || `${settings.workerName}-preview.${await resolveWorkersDevSubdomain(api, settings)}.workers.dev`;
preview = previewOrigin(`https://${previewHostname}`, `https://${hostname}`);
}
const database = await ensureDatabase(settings);
const workspace = await readWorkspaceState(api, settings, database);
const deployedProvider = workspace.authProvider
@@ -68,7 +71,7 @@ async function main(): Promise<void> {
const secrets = { ...authentication.secrets, ...await prepareEncryptionSecret(api, settings, database, existingSecrets) };
maskSecrets(secrets);
const config = createDeploymentConfig(template, settings, database, {
hostname, previewOrigin: preview, githubAdminId: authentication.githubAdminId,
hostname, ...(preview ? { previewOrigin: preview } : {}), githubAdminId: authentication.githubAdminId,
});
// 临时配置放在仓库根目录,保持 assets、main、migrations_dir 的相对路径语义。
await writeFile(new URL(`../${generatedConfig}`, import.meta.url), stringify(config), 'utf8');
@@ -84,13 +87,15 @@ async function main(): Promise<void> {
if (!deployedSecrets.has(name)) throw new Error(`部署后缺少 Worker Secret:${name}。`);
}
// 预览 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 (settings.deployPreview) {
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}${settings.deployPreview ? `,预览:https://${previewHostname}` : '(仅主 Worker)'}`);
if (process.env.GITHUB_STEP_SUMMARY) {
await appendFile(process.env.GITHUB_STEP_SUMMARY,
`## EdgeSSH 部署完成\n\n入口:https://${hostname}\n\n登录方式:${settings.authProvider}\n\n`
`## EdgeSSH 部署完成\n\n入口:https://${hostname}\n\n${settings.deployPreview ? `预览:https://${previewHostname}\n\n` : ''}登录方式:${settings.authProvider}\n\n`
+ (settings.authProvider === 'github' ? `GitHub OAuth 回调地址:https://${hostname}/auth/callback\n\n` : '')
+ 'D1 已迁移,运行时 Secret 已保存在 Cloudflare。后续部署保留原加密密钥与管理员资料。\n');
}
+9 -5
View File
@@ -11,6 +11,7 @@ export interface DeploymentSettings {
databaseId?: string;
customDomain?: string;
previewDomain?: string;
deployPreview?: boolean;
authProvider: 'cloudflare' | 'github';
githubClientId?: string;
githubAdmin?: string;
@@ -39,7 +40,10 @@ 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 deployPreviewValue = env.DEPLOY_PREVIEW_WORKER?.trim().toLowerCase() || 'false';
if (deployPreviewValue !== 'true' && deployPreviewValue !== 'false') throw new Error('DEPLOY_PREVIEW_WORKER 只能是 true 或 false。');
const deployPreview = deployPreviewValue === 'true';
if (deployPreview && `${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');
@@ -59,11 +63,11 @@ 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)) {
const previewDomain = deployPreview ? env.PREVIEW_DOMAIN?.trim() : undefined;
if (deployPreview && 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')) {
if (deployPreview && previewDomain?.toLowerCase().endsWith('.workers.dev')) {
throw new Error('PREVIEW_DOMAIN 不能填写 workers.dev 地址;请使用自定义跨站域名,或留空自动生成预览地址。');
}
@@ -107,7 +111,7 @@ export function readDeploymentSettings(
return {
accountId, apiToken: env.CLOUDFLARE_API_TOKEN!.trim(), workerName,
databaseName, databaseId, customDomain, previewDomain, authProvider, githubClientId, githubAdmin, githubAdminId,
databaseName, databaseId, customDomain, previewDomain, deployPreview, authProvider, githubClientId, githubAdmin, githubAdminId,
adminEmail, identityProviderIds, secrets,
};
}
+5 -3
View File
@@ -86,6 +86,7 @@ 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-http') return this.forwarding.trusted(request);
if (url.pathname === '/forward') {
if (request.method === 'DELETE') {
this.forwarding.stop();
@@ -94,10 +95,11 @@ export class SSHSessionDO implements DurableObject {
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 { port, mode } = await request.json<{ port: number; mode: 'trusted' | 'isolated' }>();
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()); }
if (!Number.isInteger(port) || port < 1 || port > 65535 || !origin
|| (mode !== 'trusted' && mode !== 'isolated')) return previewError('Invalid forwarding configuration', 400);
try { return await this.forwarding.create(session, port, origin, this.state.id.toString(), mode); }
catch { return previewError('远端端口不可达或 SSH 服务未允许 TCP 转发。', 502); }
}
if (url.pathname === '/sftp') return this.attachSFTP(request);
+1 -1
View File
@@ -1,5 +1,5 @@
/** 小型授权接口也可能收到流式大请求:边读边限制,而不是 text() 后才检查。 */
export async function readBoundedText(request: Request, limit: number): Promise<string> {
export async function readBoundedText(request: Pick<Request, 'body'>, limit: number): Promise<string> {
if (!request.body) return '';
const reader = request.body.getReader();
const decoder = new TextDecoder();
+48 -24
View File
@@ -1,5 +1,7 @@
import type { ForwardChannel } from './channel.ts';
import { PREVIEW_COOKIE, PREVIEW_PREFIX } from './security.ts';
import { mountURL, mountCookie, mountCSS, mountSrcset, upstreamCookies, RUNTIME_PATH } from './mount.ts';
import { readBoundedText } from './body.ts';
const encoder = new TextEncoder();
const decoder = new TextDecoder('latin1');
@@ -13,10 +15,11 @@ export function stripHopHeaders(headers: Headers): void {
}
function isInternalCookie(name: string): boolean {
return name.toLowerCase() === PREVIEW_COOKIE.toLowerCase() || /^cf_authorization$/i.test(name);
return name.toLowerCase() === PREVIEW_COOKIE.toLowerCase() || /^cf_authorization$/i.test(name)
|| /^__host-edgessh-session$/i.test(name);
}
export function upstreamHeaders(request: Request, port: number, origin: string): Headers {
export function upstreamHeaders(request: Request, port: number, origin: string, base = ''): Headers {
const headers = new Headers(request.headers);
stripHopHeaders(headers);
const headerNames: string[] = [];
@@ -29,36 +32,40 @@ export function upstreamHeaders(request: Request, port: number, origin: string):
headers.set('Connection', 'close');
// 不压缩便于 HTML 属性中的绝对 localhost 地址重写;二进制响应仍保持原始字节。
headers.set('Accept-Encoding', 'identity');
const cookies = (headers.get('Cookie') ?? '').split(';').filter((part) => {
const cookies = base ? upstreamCookies(headers.get('Cookie') ?? '', base) : (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}`));
if (referer?.startsWith(`${origin}${base}/`)) headers.set('Referer', referer.replace(`${origin}${base}`, `http://127.0.0.1:${port}`));
else headers.delete('Referer');
if (base) {
headers.set('X-Forwarded-Prefix', base);
headers.set('X-Forwarded-Host', new URL(origin).host);
headers.set('X-Forwarded-Proto', 'https');
}
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;
return mountURL(value, port, origin);
}
export function previewHeaders(source: Headers, port: number, origin: string): Headers {
export function previewHeaders(source: Headers, port: number, origin: string, base = '', requestPath = '/'): Headers {
const cookies = source.getSetCookie();
const headers = new Headers(source);
stripHopHeaders(headers);
headers.delete('Set-Cookie');
for (const cookie of cookies) {
if (base) {
const name = cookie.slice(0, cookie.indexOf('=')).trim();
if (isInternalCookie(name)) continue;
const scoped = mountCookie(cookie, base, requestPath);
if (scoped) headers.append('Set-Cookie', scoped);
continue;
}
const parts = cookie.split(';');
const name = parts[0].split('=')[0].trim();
if (isInternalCookie(name)) continue;
@@ -66,7 +73,11 @@ export function previewHeaders(source: Headers, port: number, origin: string): H
headers.append('Set-Cookie', [parts[0], ...attrs, ' Secure'].join(';'));
}
const location = headers.get('Location');
if (location) headers.set('Location', rewriteLocalURL(location, port, origin));
if (location) {
// Location 可用 ../ 逃出挂载路径,先以远端请求路径解析,再映射回当前转发。
const target = base ? new URL(location, `http://127.0.0.1:${port}${requestPath}`).href : location;
headers.set('Location', mountURL(target, port, origin, base));
}
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',
@@ -131,8 +142,8 @@ class HTTPReader {
}
}
async function sendRequest(channel: ForwardChannel, request: Request, path: string, port: number, origin: string): Promise<void> {
const headers = upstreamHeaders(request, port, origin);
async function sendRequest(channel: ForwardChannel, request: Request, path: string, port: number, origin: string, base: string): Promise<void> {
const headers = upstreamHeaders(request, port, origin, base);
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;
@@ -159,7 +170,7 @@ async function sendRequest(channel: ForwardChannel, request: Request, path: stri
} finally { await reader.cancel().catch(() => undefined); reader.releaseLock(); }
}
export async function proxyHTTP(channel: ForwardChannel, request: Request, port: number, origin: string): Promise<Response> {
export async function proxyHTTP(channel: ForwardChannel, request: Request, port: number, origin: string, base = ''): Promise<Response> {
const path = request.headers.get('x-preview-path') ?? '/';
if (!path.startsWith('/') || /[\s\0]/.test(path) || path.startsWith(PREVIEW_PREFIX)) {
await channel.close();
@@ -178,14 +189,14 @@ export async function proxyHTTP(channel: ForwardChannel, request: Request, port:
reader.releaseLock();
await channel.close();
};
const sending = sendRequest(channel, request, path, port, origin);
const sending = sendRequest(channel, request, path, port, origin, base);
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);
const headers = previewHeaders(source, port, origin, base, path);
if (request.method === 'HEAD' || [204, 205, 304].includes(status)) {
await finish();
return new Response(null, { status, headers });
@@ -239,15 +250,28 @@ export async function proxyHTTP(channel: ForwardChannel, request: Request, port:
});
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('*', {
const rewriter = 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));
if (value) element.setAttribute(name, mountURL(value, port, origin, base));
}
if (base) {
const style = element.getAttribute('style');
if (style) element.setAttribute('style', mountCSS(style, port, origin, base));
const srcset = element.getAttribute('srcset');
if (srcset) element.setAttribute('srcset', mountSrcset(srcset, port, origin, base));
}
},
}).transform(response);
});
// 只有可信同源模式注入兼容脚本;隔离模式保持原站页面,不扩大其权限。
if (base) rewriter.on('head', { element: (element) => {
element.prepend(`<script src="${base}${RUNTIME_PATH}"></script>`, { html: true });
} });
response = rewriter.transform(response);
} else if (base && source.get('Content-Type')?.toLowerCase().includes('text/css') && !source.has('Content-Encoding')) {
const css = await readBoundedText(response, 2 * 1024 * 1024);
response = new Response(mountCSS(css, port, origin, base), { status, headers });
}
return response;
} catch (error) { await finish(); throw error; }
+86
View File
@@ -0,0 +1,86 @@
export const TRUSTED_PREFIX = '/_forward/';
export const RUNTIME_PATH = '/__edgessh/runtime.js';
export function trustedBase(session: string): string { return `${TRUSTED_PREFIX}${session}`; }
export function cookiePrefix(base: string): string { return `ef_${base.split('/').pop()}_`; }
/** 路径映射是兼容处理,不是安全沙箱;主站与可信网站仍然完全同源。 */
export function mountURL(value: string, port: number, origin: string, base = ''): string {
if (base && value.startsWith('/') && !value.startsWith('//')) {
const parsed = new URL(value, origin);
const path = parsed.pathname;
return `${path === base || path.startsWith(`${base}/`) ? path : `${base}${path}`}${parsed.search}${parsed.hash}`;
}
if (!/^https?:\/\//i.test(value) && !value.startsWith('//')) return value;
try {
const url = new URL(value, `http://127.0.0.1:${port}`);
const local = url.protocol === 'http:' && ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname)
&& Number(url.port || 80) === port;
if (local || (base && url.origin === origin)) {
const path = base && (url.pathname === base || url.pathname.startsWith(`${base}/`)) ? url.pathname : `${base}${url.pathname}`;
return `${origin}${path}${url.search}${url.hash}`;
}
} catch { /* 非 URL 属性原样保留 */ }
return value;
}
export function mountCSS(css: string, port: number, origin: string, base: string): string {
return css.replace(/(url\(\s*|@import\s+)(["']?)([^"'()\s;]+)\2/gi,
(_match, start: string, quote: string, value: string) => `${start}${quote}${mountURL(value, port, origin, base)}${quote}`);
}
export function mountSrcset(value: string, port: number, origin: string, base: string): string {
let result = '';
let offset = 0;
// srcset 的 data URL 自带逗号,不能直接 split(','),否则会破坏内嵌图片。
while (offset < value.length) {
const start = offset;
while (offset < value.length && /[\s,]/.test(value[offset])) offset++;
result += value.slice(start, offset);
const urlStart = offset;
while (offset < value.length && !/\s/.test(value[offset])) offset++;
const token = value.slice(urlStart, offset);
const url = token.replace(/,+$/, '');
result += mountURL(url, port, origin, base) + token.slice(url.length);
if (url.length !== token.length) continue;
const descriptorStart = offset;
while (offset < value.length && value[offset] !== ',') offset++;
if (offset < value.length) offset++;
result += value.slice(descriptorStart, offset);
}
return result;
}
export function upstreamCookies(value: string, base: string): string {
const prefix = cookiePrefix(base);
return value.split(';').map((part) => part.trim()).filter((part) => part.startsWith(prefix) && part.includes('='))
.map((part) => part.slice(prefix.length)).join('; ');
}
export function mountCookie(value: string, base: string, requestPath = '/'): string | null {
const parts = value.split(';');
const equals = parts[0].indexOf('=');
if (equals < 1) return null;
const name = parts[0].slice(0, equals).trim();
const pathname = new URL(requestPath, 'http://localhost').pathname;
let path = pathname.slice(0, pathname.lastIndexOf('/')) || '/';
let maxAge: number | undefined;
let expiresAt: number | undefined;
const attrs: string[] = [];
for (const part of parts.slice(1)) {
const trimmed = part.trim();
if (/^path=/i.test(trimmed)) path = trimmed.slice(5);
else if (/^max-age=/i.test(trimmed)) {
const seconds = Number(trimmed.slice(8));
if (Number.isFinite(seconds)) maxAge = Math.floor(seconds);
} else if (/^expires=/i.test(trimmed)) {
const expires = Date.parse(trimmed.slice(8));
if (Number.isFinite(expires)) expiresAt = expires;
} else if (!/^(domain=|secure$)/i.test(trimmed)) attrs.push(trimmed);
}
if (!path.startsWith('/')) path = '/';
// 按 Cookie 语义让 Max-Age 优先于 Expires,再限制残留 Cookie 最长保留一小时。
const lifetime = Math.max(0, Math.min(3600, maxAge ?? (expiresAt === undefined ? 3600 : Math.floor((expiresAt - Date.now()) / 1000))));
// 只转发会话自己的 Cookie;不能让目标网站 Set-Cookie 覆盖主站登录 Cookie。
return `${cookiePrefix(base)}${name}${parts[0].slice(equals)}; ${attrs.concat(`Path=${base}${path}`, 'Secure', `Max-Age=${lifetime}`).join('; ')}`;
}
+45 -13
View File
@@ -1,22 +1,27 @@
import type { Env } from '../types';
import { jsonError } from '../http-security';
import { previewOrigin } from './security';
import { readBoundedText } from './body';
import type { Env } from '../types.ts';
import { jsonError } from '../http-security.ts';
import { previewOrigin } from './security.ts';
import { readBoundedText } from './body.ts';
import { TRUSTED_PREFIX } from './mount.ts';
/** 只有主站的认证 API 能创建或撤销转发;预览站没有 SSH 控制接口。 */
export async function forwardingRoute(request: Request, env: Env, accountId: string): Promise<Response> {
if (request.method === 'GET') {
let previewAvailable = false;
try {
previewOrigin(env.PREVIEW_ORIGIN, new URL(request.url).origin);
if (env.APP_ORIGIN) previewOrigin(env.PREVIEW_ORIGIN, env.APP_ORIGIN);
previewAvailable = true;
} catch { /* 未启用隔离预览不影响默认标准转发 */ }
return Response.json({ previewAvailable }, { headers: { 'Cache-Control': 'no-store' } });
}
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); }
let origin = url.origin;
const headers = new Headers({
'x-account-id': accountId, 'x-preview-origin': origin, 'Content-Type': 'application/json',
'x-account-id': accountId, 'Content-Type': 'application/json',
});
let body: string | undefined;
if (request.method === 'POST') {
@@ -24,14 +29,41 @@ export async function forwardingRoute(request: Request, env: Env, accountId: str
let text: string;
try { text = await readBoundedText(request, 1024); }
catch { return jsonError('Request body is too large', 413); }
let input: { port?: unknown };
let input: { port?: unknown; mode?: unknown; trusted?: 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 });
const mode = input.mode ?? 'trusted';
if (mode !== 'trusted' && mode !== 'isolated') return jsonError('Invalid forwarding mode', 400);
if (mode === 'trusted' && input.trusted !== true) return jsonError('标准转发仅适用于可信网站,请先确认风险。', 400);
if (mode === 'isolated') {
try {
origin = previewOrigin(env.PREVIEW_ORIGIN, url.origin);
if (env.APP_ORIGIN) previewOrigin(origin, env.APP_ORIGIN);
} catch { return jsonError('隔离预览尚未启用,请先运行“部署预览 Worker”工作流。', 503); }
}
body = JSON.stringify({ port: input.port, mode });
}
headers.set('x-preview-origin', origin);
return env.SSH_SESSIONS.get(env.SSH_SESSIONS.idFromString(session)).fetch(new Request('https://session.internal/forward', {
method: request.method, headers, body,
}));
}
export async function trustedForwardRoute(request: Request, env: Env, accountId: string): Promise<Response> {
const url = new URL(request.url);
const match = /^\/_forward\/([a-f0-9]{64})(\/.*)?$/.exec(url.pathname);
if (!match) return jsonError('Not found', 404);
if (request.headers.has('Service-Worker')) return jsonError('Service workers are disabled in forwarding pages', 403);
if (!match[2]) return new Response(null, { status: 307, headers: { Location: `${TRUSTED_PREFIX}${match[1]}/${url.search}`, 'Cache-Control': 'no-store' } });
const headers = new Headers(request.headers);
// 覆盖所有内部路由信息;不能相信网页发送的目标、模式、账户或 preview token。
headers.set('x-account-id', accountId);
headers.set('x-preview-origin', url.origin);
headers.set('x-preview-path', match[2] + url.search);
headers.delete('x-preview-token');
return env.SSH_SESSIONS.get(env.SSH_SESSIONS.idFromString(match[1])).fetch(new Request('https://session.internal/forward-http', {
method: request.method, headers, body: request.body, signal: request.signal,
}));
}
+33
View File
@@ -0,0 +1,33 @@
import { cookiePrefix } from './mount.ts';
/** 只为可信路径模式补常用浏览器 API;不伪装成能运行任意网站的透明反向代理。 */
export function pathRuntime(base: string, port: number): string {
return `(() => {
const base=${JSON.stringify(base)}, prefix=${JSON.stringify(cookiePrefix(base))}, port=${port};
const map=value=>{
const url=new URL(String(value),document.baseURI);
const local=url.protocol==='http:'&&['localhost','127.0.0.1','[::1]'].includes(url.hostname)&&Number(url.port||80)===port;
if(!local&&url.origin!==location.origin)return String(value);
if(url.pathname===base||url.pathname.startsWith(base+'/'))return url.href;
return location.origin+base+url.pathname+url.search+url.hash;
};
const originalFetch=window.fetch;
window.fetch=function(input,init){return originalFetch.call(this,input instanceof Request?new Request(map(input.url),input):map(input),init);};
const open=XMLHttpRequest.prototype.open;
XMLHttpRequest.prototype.open=function(method,url,...rest){return open.call(this,method,map(url),...rest);};
if(window.EventSource){const Native=window.EventSource;window.EventSource=class extends Native{constructor(url,options){super(map(url),options);}};}
for(const name of ['pushState','replaceState']){const original=history[name];history[name]=function(data,unused,url){return original.call(this,data,unused,url==null?url:map(url));};}
const cookie=Object.getOwnPropertyDescriptor(Document.prototype,'cookie');
if(cookie&&cookie.get&&cookie.set)Object.defineProperty(document,'cookie',{
get(){return cookie.get.call(document).split(';').map(v=>v.trim()).filter(v=>v.startsWith(prefix)).map(v=>v.slice(prefix.length)).join('; ');},
set(value){
const parts=String(value).split(';'), first=parts.shift(), split=first.indexOf('=');
if(split<1)return;
let path=location.pathname.slice(base.length).replace(/[^/]*$/,'')||'/';
const attrs=parts.filter(part=>{const v=part.trim();if(/^path=/i.test(v)){path=v.slice(5);return false;}return !/^(domain=|secure$)/i.test(v);});
if(!path.startsWith('/'))path='/';
cookie.set.call(document,prefix+first.trim()+'; '+attrs.join('; ')+'; Path='+base+path+'; Secure');
}
});
})();`;
}
+26 -4
View File
@@ -1,6 +1,8 @@
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';
import { trustedBase, RUNTIME_PATH } from './mount.ts';
import { pathRuntime } from './runtime.ts';
interface Grant {
session: SSHSession;
@@ -10,6 +12,7 @@ interface Grant {
launchExpires: number;
token: string;
expires: number;
base: string;
}
/** 授权仅存在 SSH 会话内存里;DO 重启、断线或显式停止都会让预览 Cookie 失效。 */
@@ -31,7 +34,7 @@ export class ForwardingState {
session?.close(true);
}
async create(session: SSHSession, port: number, origin: string, sessionId: string): Promise<Response> {
async create(session: SSHSession, port: number, origin: string, sessionId: string, mode: 'trusted' | 'isolated' = 'isolated'): Promise<Response> {
this.clear();
const revision = this.revision;
// 创建时先探测 direct-tcpip 权限和监听端口,避免给用户一个必定失败的链接。
@@ -41,18 +44,19 @@ export class ForwardingState {
const grant: Grant = {
session, port, origin, launchToken: crypto.randomUUID(), launchExpires: Date.now() + LAUNCH_TTL_MS,
token: crypto.randomUUID(), expires: Date.now() + FORWARD_TTL_MS,
base: mode === 'trusted' ? trustedBase(sessionId) : '',
};
this.grant = grant;
this.timer = setTimeout(() => this.stop(), FORWARD_TTL_MS);
return Response.json({
url: `${origin}${PREVIEW_PREFIX}start#${sessionId}.${grant.launchToken}`,
url: grant.base ? `${origin}${grant.base}/` : `${origin}${PREVIEW_PREFIX}start#${sessionId}.${grant.launchToken}`,
expiresAt: grant.expires,
}, { headers: { 'Cache-Control': 'no-store' } });
}
async preview(request: Request): Promise<Response> {
const grant = this.grant;
if (!grant || grant.expires <= Date.now() || !grant.session.isForwardReady()
if (!grant || grant.base || 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') {
@@ -64,6 +68,24 @@ export class ForwardingState {
{ headers: { 'Cache-Control': 'no-store' } });
}
if (token !== grant.token) return previewError('预览授权无效。', 401);
return this.serve(request, grant);
}
/** 主站已完成登录校验,DO 已核对 account-id;不能借此入口绕过隔离模式票据。 */
async trusted(request: Request): Promise<Response> {
const grant = this.grant;
if (!grant?.base || grant.expires <= Date.now() || !grant.session.isForwardReady()
|| request.headers.get('x-preview-origin') !== grant.origin) return previewError('转发已断开或模式不匹配。', 410);
if (request.headers.get('x-preview-path') === RUNTIME_PATH && request.method === 'GET') {
return new Response(pathRuntime(grant.base, grant.port), { headers: {
'Content-Type': 'application/javascript; charset=utf-8', 'Cache-Control': 'no-store',
'X-Content-Type-Options': 'nosniff', 'Content-Security-Policy': "worker-src 'none'",
} });
}
return this.serve(request, grant);
}
private async serve(request: Request, grant: Grant): Promise<Response> {
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');
@@ -74,7 +96,7 @@ export class ForwardingState {
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);
return await proxyHTTP(channel, request, grant.port, grant.origin, grant.base);
} catch { return previewError('无法访问远端 HTTP 服务,请检查端口、服务状态和 SSH 转发权限。', 502); }
}
}
+7 -4
View File
@@ -8,7 +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';
import { forwardingRoute, trustedForwardRoute } from './forwarding/routes';
export { SSHSessionDO };
@@ -127,6 +127,7 @@ export default {
async fetch(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url);
const isApiRequest = url.pathname.startsWith('/api/');
const isForwardRequest = url.pathname.startsWith('/_forward/');
try {
if (isProductionHttp(request)) {
if (isApiRequest) return corsResponse(jsonError('HTTPS is required', 403));
@@ -135,7 +136,7 @@ export default {
}
// Cookie 认证只接受同源请求;写操作必须有 Origin,阻断跨站表单与 CSRF。
const origin = request.headers.get('Origin');
if (isApiRequest && ((origin && origin !== url.origin)
if ((isApiRequest || isForwardRequest) && ((origin && origin !== url.origin)
|| (!['GET', 'HEAD', 'OPTIONS'].includes(request.method) && origin !== url.origin)
|| request.headers.get('Sec-Fetch-Site') === 'cross-site')) return jsonError('不允许跨站请求。', 403);
if (isApiRequest && request.method === 'OPTIONS') {
@@ -151,8 +152,10 @@ export default {
if (!location) return corsResponse(jsonError('位置服务暂时不可用。', 503));
return corsResponse(json({ status: 'ok', sample: '8.8.8.8', location }));
}
const account = isApiRequest ? await currentAccount(request, env) : null;
if (isApiRequest && !account) return jsonError('请先登录。', 401);
const account = isApiRequest || isForwardRequest ? await currentAccount(request, env) : null;
if ((isApiRequest || isForwardRequest) && !account) return jsonError('请先登录。', 401);
// 转发返回目标网站自己的 CSP,不套主应用 CSP;此路径仍必须先通过管理员认证。
if (isForwardRequest) return await trustedForwardRoute(request, env, account!.id);
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);
+35
View File
@@ -0,0 +1,35 @@
import { test, expect } from '@playwright/test';
import { pathRuntime } from '../../src/forwarding/runtime.ts';
test('pathRuntime rewrites localhost fetch/XHR and isolates prefixed cookies', async ({ page }) => {
await page.route('**/_forward/**', async (route) => {
await route.fulfill({ status: 200, contentType: 'text/html', body: '<!doctype html><html><head></head><body></body></html>' });
});
await page.goto('/_forward/' + 'a'.repeat(64) + '/foo/');
await page.context().addCookies([
{ name: 'main', value: 'hidden', url: 'http://127.0.0.1:5173/' },
{ name: 'ef_' + 'a'.repeat(64) + '_target', value: 'visible', url: 'http://127.0.0.1:5173/' },
]);
const requests: string[] = [];
await page.route('**/*', async (route) => {
requests.push(new URL(route.request().url()).pathname + new URL(route.request().url()).search);
await route.fulfill({ status: 200, body: '{}' });
});
await page.addScriptTag({ content: pathRuntime('/_forward/' + 'a'.repeat(64), 8080) });
const result = await page.evaluate(async () => {
await fetch('http://127.0.0.1:8080/api/data');
await new Promise<void>((resolve) => {
const xhr = new XMLHttpRequest();
xhr.addEventListener('loadend', () => resolve());
xhr.open('GET', 'http://127.0.0.1:8080/api/xhr');
xhr.send();
});
return document.cookie;
});
await expect.poll(() => requests).toEqual(expect.arrayContaining([
'/_forward/' + 'a'.repeat(64) + '/api/data',
'/_forward/' + 'a'.repeat(64) + '/api/xhr',
]));
expect(result).toContain('target=visible');
expect(result).not.toContain('main=hidden');
});
+72 -10
View File
@@ -7,19 +7,26 @@ const host = {
hasCredential: true, updatedAt: Date.now(),
};
async function forwardingFixture(page: Page) {
const sessionId = 'a'.repeat(64);
const trustedURL = `http://127.0.0.1:5173/_forward/${sessionId}/`;
async function forwardingFixture(page: Page, previewAvailable = true, noHosts = false) {
const calls: Array<Record<string, unknown>> = [];
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 === '/api/hosts') return route.fulfill({ json: { hosts: noHosts ? [] : [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') {
if (path === '/api/session') return route.fulfill({ json: { ticket: 'test-ticket', sessionId } });
if (path === '/api/forwarding' && route.request().method() === 'GET') {
return route.fulfill({ json: { previewAvailable } });
}
if (path === '/api/forwarding' && route.request().method() === 'POST') {
calls.push({ type: 'forwarding', body: route.request().postDataJSON() });
return route.fulfill({ json: { url: 'https://preview.example.net/__edgessh/start#xxx', expiresAt: Date.now() + 3600000 } });
const mode = route.request().postDataJSON().mode;
return route.fulfill({ json: { url: mode === 'isolated' ? 'https://isolated.example.net/start#xxx' : trustedURL, expiresAt: Date.now() + 3600000 } });
}
return route.fulfill({ json: {} });
});
@@ -42,22 +49,74 @@ async function forwardingFixture(page: Page) {
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 input[name="trusted"]').check();
await page.locator('.forward-page button[type="submit"]').click();
}
async function chooseForwardMode(page: Page, mode: 'trusted' | 'isolated') {
await page.locator('.forward-page select[name="mode"]').selectOption(mode);
}
test('默认标准模式显示风险警告,隔离模式未配置时仍禁用连接', async ({ page }, testInfo) => {
await forwardingFixture(page, false);
await expect(page.locator('[data-trust-warning]')).toBeVisible();
await expect(page.locator('.forward-page button[type="submit"]')).toBeDisabled();
await page.evaluate(() => { window.scrollTo(0, 0); document.body.scrollTo(0, 0); });
await page.screenshot({ path: testInfo.outputPath('trusted-mode.png'), fullPage: true });
await page.locator('.forward-page select[name="host"]').selectOption('alpha');
await expect(page.locator('.forward-page button[type="submit"]')).toBeDisabled();
await page.locator('.forward-page input[name="trusted"]').check();
await expect(page.locator('.forward-page button[type="submit"]')).toBeEnabled();
await chooseForwardMode(page, 'isolated');
await expect(page.locator('[data-preview-setup]')).toBeVisible();
await expect(page.locator('.forward-page button[type="submit"]')).toBeDisabled();
await page.evaluate(() => { window.scrollTo(0, 0); document.body.scrollTo(0, 0); });
await page.screenshot({ path: testInfo.outputPath('isolated-setup.png'), fullPage: true });
await chooseForwardMode(page, 'trusted');
await expect(page.locator('.forward-page input[name="trusted"]')).not.toBeChecked();
await expect(page.locator('.forward-page button[type="submit"]')).toBeDisabled();
});
test('已启用隔离预览无需信任勾选,提交隔离请求并在停止后解锁模式', async ({ page }) => {
const { calls } = await forwardingFixture(page, true);
await page.locator('.forward-page select[name="host"]').selectOption('alpha');
await page.locator('.forward-page input[name="port"]').fill('8080');
await chooseForwardMode(page, 'isolated');
await expect(page.locator('.forward-page input[name="trusted"]')).toBeHidden();
await expect(page.locator('.forward-page button[type="submit"]')).toBeEnabled();
await page.locator('.forward-page button[type="submit"]').click();
await expect.poll(() => calls.find((call) => call.type === 'forwarding')?.body).toEqual({ port: 8080, mode: 'isolated' });
await expect(page.locator('[data-preview-link]')).toHaveAttribute('href', 'https://isolated.example.net/start#xxx');
await expect(page.locator('.forward-page select[name="mode"]')).toBeDisabled();
await page.locator('[data-stop]').click();
await expect(page.locator('.forward-page select[name="mode"]')).toBeEnabled();
});
test('切换转发方式会清空之前的信任确认', async ({ page }) => {
await forwardingFixture(page, true);
await page.locator('.forward-page select[name="host"]').selectOption('alpha');
await page.locator('.forward-page input[name="trusted"]').check();
await expect(page.locator('.forward-page input[name="trusted"]')).toBeChecked();
await chooseForwardMode(page, 'isolated');
await chooseForwardMode(page, 'trusted');
await expect(page.locator('.forward-page input[name="trusted"]')).not.toBeChecked();
await expect(page.locator('.forward-page button[type="submit"]')).toBeDisabled();
});
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.poll(() => calls.find((call) => call.type === 'forwarding')?.body).toEqual({ port: 8080, mode: 'trusted', trusted: true });
await expect.poll(() => page.locator('a[data-preview-link]').getAttribute('href')).toBe(trustedURL);
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.evaluate(() => { window.scrollTo(0, 0); document.body.scrollTo(0, 0); });
await page.screenshot({ path: testInfo.outputPath('forwarding-desktop.png'), fullPage: true });
});
@@ -73,10 +132,13 @@ test('离开总览会关闭独立转发 WebSocket,返回页面保持未连接'
});
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 forwardingFixture(page, false, true);
await expect(page.locator('.forward-page')).toBeVisible();
await expect(page.locator('.forward-page h1')).toHaveText('端口转发');
await expect(page.locator('.forward-page select[name="mode"]')).toHaveValue('trusted');
await expect(page.locator('[data-trust-warning]')).toBeVisible();
await expect(page.locator('.forward-page button[type="submit"]')).toBeDisabled();
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true);
await page.evaluate(() => { window.scrollTo(0, 0); document.body.scrollTo(0, 0); });
await page.screenshot({ path: testInfo.outputPath('forwarding-mobile.png'), fullPage: true });
});
+12
View File
@@ -13,6 +13,7 @@ const env = {
ENCRYPTION_KEY: Buffer.alloc(32, 1).toString('base64'),
ACCESS_TEAM_DOMAIN: 'example.cloudflareaccess.com',
ACCESS_AUD: 'test-audience',
DEPLOY_PREVIEW_WORKER: 'true',
};
const settings = readDeploymentSettings(template, env);
const database = { uuid: '00000000-0000-4000-8000-000000000001', name: 'edgessh-accounts' };
@@ -126,6 +127,17 @@ test('invalid account, resource, domain and runtime configuration fail validatio
}, env), /Secret/);
});
test('preview validation and main-only config follow DEPLOY_PREVIEW_WORKER', () => {
const disabled = readDeploymentSettings(template, { ...env, DEPLOY_PREVIEW_WORKER: 'false', PREVIEW_DOMAIN: 'not a domain' });
assert.equal(disabled.deployPreview, false);
assert.equal(disabled.previewDomain, undefined);
const config = createDeploymentConfig(template, disabled, database, { hostname: 'edgessh.example.com' });
assert.equal((config.vars as Record<string, string>).PREVIEW_ORIGIN, undefined);
const existing = createDeploymentConfig(template, disabled, database, { hostname: 'edgessh.example.com', previewOrigin: 'https://existing.example.com' });
assert.equal((existing.vars as Record<string, string>).PREVIEW_ORIGIN, 'https://existing.example.com');
assert.throws(() => readDeploymentSettings(template, { ...env, DEPLOY_PREVIEW_WORKER: 'true', PREVIEW_DOMAIN: 'not a domain' }), /PREVIEW_DOMAIN/);
});
test('database lookup reuses an existing database without any writes', async () => {
const calls: string[] = [];
const fetcher: typeof fetch = async (input, init) => {
+10
View File
@@ -25,6 +25,16 @@ async function prepared() {
return { state, session, launchToken: capability.token };
}
test('trusted grants use the main origin path and do not accept isolated preview tokens', async () => {
const session = fakeSession();
const state = new ForwardingState();
const response = await state.create(session as never, 8080, 'https://main.test', sessionId, 'trusted');
const body = await response.json() as { url: string };
assert.equal(body.url, `https://main.test/_forward/${sessionId}/`);
assert.equal((await state.preview(new Request('https://internal/preview'))).status, 410);
state.clear();
});
test('launch token is single-use and returns preview token', async () => {
const { state, session, launchToken } = await prepared();
try {
+13
View File
@@ -42,3 +42,16 @@ test('stripHopHeaders removes declared connection tokens', () => {
assert.equal(headers.has('upgrade'), false);
assert.equal(headers.has('keepalive'), true);
});
test('trusted cookies are namespaced and never forward host login cookies', () => {
const request = new Request('https://main.test/_forward/abc/', { headers: {
Cookie: 'ef_abc_sid=ok; __Host-edgessh-session=main; CF_Authorization=access; ef_other_no=bad',
}});
const headers = upstreamHeaders(request, 8080, 'https://main.test', '/_forward/abc');
assert.equal(headers.get('cookie'), 'sid=ok');
const result = previewHeaders(new Headers([
['Set-Cookie', 'sid=abc; Path=/; Domain=internal.test'],
['Set-Cookie', '__Host-edgessh-session=bad; Path=/'],
]), 8080, 'https://main.test', '/_forward/abc');
assert.deepEqual(result.getSetCookie(), ['ef_abc_sid=abc; Path=/_forward/abc/; Secure; Max-Age=3600']);
});
+66
View File
@@ -0,0 +1,66 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdtemp, writeFile, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { build } from 'esbuild';
import { Miniflare } from 'miniflare';
import { mountCookie, mountCSS } from '../src/forwarding/mount.ts';
const root = 'https://preview.example';
const base = '/_forward/64a';
test('mounted proxy rewrites HTML/CSS through real HTMLRewriter and scopes headers', async () => {
const dir = await mkdtemp(join(tmpdir(), 'edgessh-mounted-'));
const entry = join(dir, 'worker.ts');
const output = join(dir, 'worker.js');
await writeFile(entry, `
import { proxyHTTP } from ${JSON.stringify(join(process.cwd(), 'src/forwarding/http.ts'))};
const encoder = new TextEncoder();
const html = '<!doctype html><html><head><title>x</title></head><body style="background:url(/bg.png)"><a href="/next">a</a><img src="http://127.0.0.1:8080/i.png"><form action="/go" formaction="/alt"><img srcset="/a.png 1x, data:image/png;base64,AA 2x"></form><style>@import url("/theme.css"); .x{background:url(/x.png)}</style></body></html>';
class Channel {
readable = new ReadableStream({ start(c) { c.enqueue(encoder.encode('HTTP/1.1 302 Found\\r\\nContent-Type: text/html\\r\\nContent-Length: '+new TextEncoder().encode(html).length+'\\r\\nSet-Cookie: sid=abc; Path=/; Max-Age=10; Expires=Wed, 21 Oct 2030 07:28:00 GMT\\r\\nSet-Cookie: __Host-edgessh-session=bad; Path=/\\r\\nLocation: ../login\\r\\n\\r\\n'+html)); c.close(); } });
async write() {} async close() {} abort() {}
}
export default { async fetch(request) { return proxyHTTP(new Channel(), request, 8080, '${root}', '${base}'); } };
`);
let mf: Miniflare | undefined;
try {
await build({ entryPoints: [entry], outfile: output, bundle: true, format: 'esm', platform: 'neutral', sourcemap: false });
const bundleJS = await (await import('node:fs/promises')).readFile(output, 'utf8');
mf = new Miniflare({ workers: [{ config: {
type: 'worker',
name: 'test',
compatibilityDate: '2026-07-01',
manifest: {
mainModule: 'worker.js',
modulesRoot: dir,
modules: { 'worker.js': { type: 'esm', contents: bundleJS } },
},
} }] });
const response = await mf.dispatchFetch(`${root}${base}/dir/page`, {
redirect: 'manual',
headers: { 'x-preview-path': '/dir/page' },
});
const text = await response.text();
assert.equal(response.status, 302);
assert.match(text, /href="\/_forward\/64a\/next"/);
assert.match(text, /src="https:\/\/preview\.example\/_forward\/64a\/i\.png"/);
assert.match(text, /action="\/_forward\/64a\/go"/);
assert.match(text, /formaction="\/_forward\/64a\/alt"/);
assert.match(text, /srcset="\/_forward\/64a\/a\.png 1x, data:image\/png;base64,AA 2x"/);
assert.match(text, /style="background:url\(\/_forward\/64a\/bg\.png\)"/);
assert.equal((text.match(/__edgessh\/runtime\.js/g) ?? []).length, 1);
assert.equal(response.headers.get('location'), `${root}${base}/login`);
assert.deepEqual(response.headers.getSetCookie(), [`ef_64a_sid=abc; Path=${base}/; Secure; Max-Age=10`]);
} finally {
await mf?.dispose();
await rm(dir, { recursive: true, force: true });
}
});
test('mounted CSS and cookie helpers preserve URL semantics', () => {
assert.equal(mountCSS('@import "/a.css";x{background:url(https://x.test/a)}', 8080, root, base), `@import "${base}/a.css";x{background:url(https://x.test/a)}`);
assert.match(mountCookie('sid=x; Expires=Wed, 21 Oct 2030 07:28:00 GMT', base, '/dir/page')!, new RegExp(`Path=${base}/dir;`));
assert.match(mountCookie('sid=x; Max-Age=10; Expires=Wed, 21 Oct 2030 07:28:00 GMT', base)!, /Max-Age=10/);
});
+93
View File
@@ -0,0 +1,93 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { forwardingRoute, trustedForwardRoute } from '../src/forwarding/routes.ts';
import { ForwardingState } from '../src/forwarding/state.ts';
const sessionId = 'a'.repeat(64);
function makeEnv(previewOrigin?: string, appOrigin?: string) {
const calls: Array<{ request: Request; id: string }> = [];
const env = {
PREVIEW_ORIGIN: previewOrigin,
APP_ORIGIN: appOrigin,
SSH_SESSIONS: {
idFromString(id: string) { return id; },
get(id: string) {
return { fetch(request: Request) { calls.push({ request, id }); return Promise.resolve(new Response('ok')); } };
},
},
} as never;
return { env, calls };
}
test('GET reports preview availability only when configured', async () => {
assert.deepEqual(await (await forwardingRoute(new Request('https://main.test/api/forward'), makeEnv().env, 'acct')).json(), { previewAvailable: false });
assert.deepEqual(await (await forwardingRoute(new Request('https://main.test/api/forward'), makeEnv('https://preview.test').env, 'acct')).json(), { previewAvailable: true });
});
test('trusted POST requires explicit confirmation and forwards trusted mode', async () => {
const missing = makeEnv();
const rejected = await forwardingRoute(new Request(`https://main.test/api/forward?session=${sessionId}`, {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ port: 80 }),
}), missing.env, 'acct');
assert.equal(rejected.status, 400); assert.equal(missing.calls.length, 0);
const configured = makeEnv();
const accepted = await forwardingRoute(new Request(`https://main.test/api/forward?session=${sessionId}`, {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ port: 80, trusted: true }),
}), configured.env, 'acct');
assert.equal(accepted.status, 200); assert.equal(configured.calls.length, 1);
assert.equal(await configured.calls[0].request.text(), JSON.stringify({ port: 80, mode: 'trusted' }));
assert.equal(configured.calls[0].request.headers.get('x-preview-origin'), 'https://main.test');
});
test('isolated mode without preview configuration returns 503 and invalid mode returns 400', async () => {
const isolated = makeEnv();
const result = await forwardingRoute(new Request(`https://main.test/api/forward?session=${sessionId}`, {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ port: 80, mode: 'isolated' }),
}), isolated.env, 'acct');
assert.equal(result.status, 503); assert.equal(isolated.calls.length, 0);
const invalid = await forwardingRoute(new Request(`https://main.test/api/forward?session=${sessionId}`, {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ port: 80, mode: 'other', trusted: true }),
}), isolated.env, 'acct');
assert.equal(invalid.status, 400);
});
test('DELETE works without preview configuration', async () => {
const state = makeEnv();
const result = await forwardingRoute(new Request(`https://main.test/api/forward?session=${sessionId}`, { method: 'DELETE' }), state.env, 'acct');
assert.equal(result.status, 200); assert.equal(state.calls.length, 1);
});
test('trusted forwarding validates session, strips token, derives path, and preserves body', async () => {
const state = makeEnv();
// 传入可复用的字符串 body,避免 Node 对转发 ReadableStream 强制要求 duplex: half。
const request = {
method: 'POST', url: `https://main.test/_forward/${sessionId}/form?a=1`,
headers: new Headers({ 'x-account-id': 'forged', 'x-preview-token': 'secret' }), body: 'payload',
} as unknown as Request;
assert.equal((await trustedForwardRoute(request, state.env, 'real-account')).status, 200);
const forwarded = state.calls[0].request;
assert.equal(forwarded.headers.get('x-account-id'), 'real-account');
assert.equal(forwarded.headers.get('x-preview-token'), null);
assert.equal(forwarded.headers.get('x-preview-path'), '/form?a=1');
assert.equal(await forwarded.text(), 'payload');
assert.equal((await trustedForwardRoute(new Request('https://main.test/_forward/not-hex/form'), state.env, 'acct')).status, 404);
assert.equal((await trustedForwardRoute(new Request(`https://main.test/_forward/${sessionId}/form`, { headers: { 'Service-Worker': 'script' } }), state.env, 'acct')).status, 403);
});
function fakeSession() {
return { isForwardReady: () => true, openForward: async () => ({ close: async () => {} }) } as never;
}
test('ForwardingState rejects cross-mode grants and clear invalidates both paths', async () => {
const state = new ForwardingState();
await state.create(fakeSession(), 80, 'https://main.test', sessionId, 'isolated');
assert.equal((await state.trusted(new Request('https://main.test/'))).status, 410);
state.clear();
await state.create(fakeSession(), 80, 'https://main.test', sessionId, 'trusted');
assert.equal((await state.preview(new Request('https://main.test/'))).status, 410);
state.clear();
assert.equal((await state.trusted(new Request('https://main.test/'))).status, 410);
assert.equal((await state.preview(new Request('https://main.test/'))).status, 410);
});
+3
View File
@@ -6,6 +6,9 @@ export default defineConfig({
server: {
port: 5173,
proxy: {
'/_forward': {
target: 'http://127.0.0.1:8787',
},
'/auth': {
target: 'http://127.0.0.1:8787',
},