feat: add isolated HTTP port forwarding
新增独立预览 Worker 的 HTTP 端口转发,隔离主站登录态。
This commit is contained in:
1 parent
33b161cfba
commit
fc7a0caf81
28 files changed
+1362
-14
No files matched your search
@@ -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
|
||||
|
||||
@@ -12,5 +12,6 @@ deliverables/
|
||||
frontend/dist-*
|
||||
scripts-tmp/
|
||||
.wrangler.generated.toml
|
||||
.wrangler.preview.generated.toml
|
||||
test-results/
|
||||
playwright-report/
|
||||
@@ -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。未配置时默认 `<WORKER_NAME>-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;留空为 `<WORKER_NAME>-preview.<账户子域>.workers.dev` |
|
||||
| `ENCRYPTION_KEY` | Secret,仅恢复/迁移使用 | 仅 Worker 尚无密钥时使用;已有密钥不会覆盖 |
|
||||
|
||||
`DB`、`SSH_SESSIONS`、`ASSETS` 是资源绑定,不是需要用户创建的变量。`CONNECT_TIMEOUT_MS` 已有默认值 `10000`。
|
||||
|
||||
@@ -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;留空自动生成 `<Worker 名>-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 端口。
|
||||
|
||||
### 独立文件管理
|
||||
|
||||
|
||||
@@ -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: '<path d="m3 10 9-7 9 7v10H3Z"/><path d="M9 20v-7h6v7"/>',
|
||||
server: '<rect x="4" y="3" width="16" height="7" rx="2"/><rect x="4" y="14" width="16" height="7" rx="2"/><path d="M8 6.5h.01M8 17.5h.01M15 6.5h2M15 17.5h2"/>',
|
||||
terminal: '<path d="m5 6 6 6-6 6m8 0h6"/>',
|
||||
forward: '<rect x="3" y="4" width="18" height="16" rx="2"/><path d="M3 8h18M7 14h10m-3-3 3 3-3 3"/>',
|
||||
snippets: '<path d="M9 3H7a2 2 0 0 0-2 2v4l-2 3 2 3v4a2 2 0 0 0 2 2h2m6-18h2a2 2 0 0 1 2 2v4l2 3-2 3v4a2 2 0 0 1-2 2h-2"/>',
|
||||
folder: '<path d="M3 7V5a2 2 0 0 1 2-2h5l2 3h7a2 2 0 0 1 2 2v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2Z"/><path d="M3 9h18"/>',
|
||||
shield: '<path d="m12 3 8 3v6c0 5-8 9-8 9s-8-4-8-9V6Z"/><path d="m8 12 3 3 5-6"/>',
|
||||
@@ -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 {
|
||||
<button class="rail-item" id="rail-hosts">${icon('server')}<span>主机</span></button>
|
||||
<button class="rail-item" id="rail-files">${icon('folder')}<span>文件管理</span></button>
|
||||
<button class="rail-item" id="rail-snippets">${icon('snippets')}<span>代码片段</span></button>
|
||||
<button class="rail-item" id="rail-forward">${icon('forward')}<span>端口转发</span></button>
|
||||
<button class="rail-item" id="quick-connect">${icon('terminal')}<span>快速连接</span></button>
|
||||
<span class="rail-security" title="管理员身份认证">${icon('shield')}<span id="auth-provider-label">身份<br>保护</span></span>
|
||||
</nav>
|
||||
@@ -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<HTMLDialogElement>('.host-dialog');
|
||||
this.form = this.get<HTMLFormElement>('#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<HTMLElement>('.rail-item').forEach((button) => {
|
||||
button.classList.toggle('selected', button.id === id);
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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<typeof setTimeout>;
|
||||
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 = `
|
||||
<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>
|
||||
<section class="forward-card" aria-label="端口转发设置">
|
||||
<form class="forward-form">
|
||||
<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>
|
||||
</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>
|
||||
</section>
|
||||
<section class="forward-explanation" aria-label="隔离与使用说明">
|
||||
<h2>让网站留在另一扇窗里</h2>
|
||||
<p>远端网站可能包含不可信脚本。预览在独立 Worker、独立站点打开,不共享 EdgeSSH 登录 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>
|
||||
</section>
|
||||
<dialog class="host-dialog" aria-labelledby="forward-key-heading">
|
||||
<h2 id="forward-key-heading">核对 SSH 主机指纹</h2>
|
||||
<p data-key-warning></p><p data-key-target></p><pre data-key-fingerprint></pre>
|
||||
<p class="forward-hint">请通过可信渠道核对。接受只用于本次连接,不会自动覆盖已保存指纹。</p>
|
||||
<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.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<T extends HTMLElement>(selector: string): T { return this.root.querySelector<T>(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<void> {
|
||||
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<void> {
|
||||
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<HTMLAnchorElement>('[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<HTMLAnchorElement>('[data-preview-link]').removeAttribute('href');
|
||||
this.message(message); this.render();
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,11 @@ interface AccessPolicy {
|
||||
}
|
||||
|
||||
export async function resolveWorkerHostname(api: CloudflareApi, settings: DeploymentSettings): Promise<string> {
|
||||
const subdomain = await resolveWorkersDevSubdomain(api, settings);
|
||||
return `${settings.workerName}.${subdomain}.workers.dev`;
|
||||
}
|
||||
|
||||
export async function resolveWorkersDevSubdomain(api: CloudflareApi, settings: DeploymentSettings): Promise<string> {
|
||||
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(
|
||||
|
||||
+16
-8
@@ -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<void> {
|
||||
async function runWrangler(args: string[], input?: string, configPath = generatedConfig): Promise<void> {
|
||||
await new Promise<void>((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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
// 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`
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<WebSocket, SSHSession>();
|
||||
private readonly forwarding = new ForwardingState();
|
||||
private readonly pendingConnections = new Map<WebSocket, PendingConnection>();
|
||||
private readonly deadlines = new Map<WebSocket, ReturnType<typeof setTimeout>>();
|
||||
private readonly sftpAttachTokens = new Map<string, SFTPAttachToken>();
|
||||
@@ -60,6 +63,8 @@ export class SSHSessionDO implements DurableObject {
|
||||
|
||||
async fetch(request: Request): Promise<Response> {
|
||||
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);
|
||||
|
||||
@@ -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<number, SSHChannel>([[0, this.shellChannel]]);
|
||||
private readonly forwards = new Map<number, ForwardChannel>();
|
||||
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<ForwardChannel> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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;
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
/** 小型授权接口也可能收到流式大请求:边读边限制,而不是 text() 后才检查。 */
|
||||
export async function readBoundedText(request: Request, limit: number): Promise<string> {
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -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<void>;
|
||||
data(channel: SSHChannel, chunk: ChannelDataChunk): Promise<void>;
|
||||
remove(): void;
|
||||
}
|
||||
|
||||
/** 每个 HTTP 请求单独使用 direct-tcpip,避免连接复用带来的响应串线。 */
|
||||
export class ForwardChannel {
|
||||
readonly channel = new SSHChannel();
|
||||
readonly readable: ReadableStream<Uint8Array>;
|
||||
readonly opened: Promise<void>;
|
||||
private controller!: ReadableStreamDefaultController<Uint8Array>;
|
||||
private resolveOpen!: () => void;
|
||||
private rejectOpen!: (error: Error) => void;
|
||||
private wakeWriter?: () => void;
|
||||
private failure?: Error;
|
||||
private ended = false;
|
||||
private timer: ReturnType<typeof setTimeout>;
|
||||
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void>((resolve) => { this.wakeWriter = resolve; }); continue; }
|
||||
await this.transport.data(this.channel, chunk);
|
||||
offset += chunk.bytesConsumed;
|
||||
this.touch();
|
||||
}
|
||||
}
|
||||
|
||||
private async adjustWindow(): Promise<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
this.abort(new Error('Forwarding request finished'));
|
||||
await this.sendClose();
|
||||
}
|
||||
}
|
||||
@@ -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<Uint8Array>;
|
||||
constructor(reader: ReadableStreamDefaultReader<Uint8Array>) { this.reader = reader; }
|
||||
private async fill(): Promise<boolean> {
|
||||
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<string> {
|
||||
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<Uint8Array | null> {
|
||||
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<void> {
|
||||
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<Response> {
|
||||
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<Uint8Array>({
|
||||
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; }
|
||||
}
|
||||
@@ -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<Response> {
|
||||
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,
|
||||
}));
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<typeof setTimeout>;
|
||||
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<Response> {
|
||||
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<Response> {
|
||||
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); }
|
||||
}
|
||||
}
|
||||
@@ -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<Response> {
|
||||
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(`<!doctype html><html lang="zh-CN"><meta charset="utf-8"><meta name="viewport" content="width=device-width"><title>EdgeSSH 隔离预览</title><p id="status">正在打开隔离预览…</p><script nonce="${nonce}">
|
||||
const key=location.hash.slice(1);history.replaceState(null,'',location.pathname);
|
||||
fetch('${PREVIEW_PREFIX}launch',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({key})})
|
||||
.then(async r=>{if(!r.ok)throw new Error('预览链接已失效,请回到 EdgeSSH 重新打开。');location.replace('/');})
|
||||
.catch(e=>document.getElementById('status').textContent=e.message);
|
||||
</script></html>`, { 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); }
|
||||
},
|
||||
};
|
||||
@@ -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');
|
||||
|
||||
+5
-1
@@ -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 } : {}),
|
||||
};
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
@@ -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<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.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 });
|
||||
});
|
||||
@@ -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<string, string>).PREVIEW_ORIGIN, origin);
|
||||
assert.equal((preview.vars as Record<string, string>).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/);
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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<Uint8Array>({ start: c => { c.enqueue(bytes); c.close(); } });
|
||||
}
|
||||
readable: ReadableStream<Uint8Array>;
|
||||
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');
|
||||
});
|
||||
@@ -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"
|
||||
Reference in new issue
Block a user