From afef04bf8d07b97212de60cdfcb292fe3037672c Mon Sep 17 00:00:00 2001 From: gdz66601 Date: Fri, 25 Sep 2026 09:09:22 +0800 Subject: [PATCH] =?UTF-8?q?feat(forwarding):=20retain=20sessions=20for=20e?= =?UTF-8?q?ight=20minutes=20/=20=E7=AB=AF=E5=8F=A3=E8=BD=AC=E5=8F=91?= =?UTF-8?q?=E7=A6=BB=E9=A1=B5=E4=BF=9D=E6=8C=81=E5=85=AB=E5=88=86=E9=92=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- DEPLOYMENT.md | 2 +- README.md | 2 +- frontend/src/forward-page.ts | 91 ++++++++++++++++++++++++++++++-- src/backend/durable-object.ts | 50 ++++++++++++++++-- src/forwarding/routes.ts | 9 +++- src/forwarding/security.ts | 1 + src/forwarding/state.ts | 36 +++++++++++-- tests/browser/forwarding.spec.ts | 38 +++++++++++-- tests/forwarding-routes.test.ts | 31 ++++++++++- 9 files changed, 237 insertions(+), 23 deletions(-) diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md index aafce36..a12c7ee 100644 --- a/DEPLOYMENT.md +++ b/DEPLOYMENT.md @@ -76,7 +76,7 @@ API Token 只存 GitHub Secret,不放普通变量、代码或命令行输入 主站与预览必须跨 site:自定义域名加默认 `workers.dev` 可行;自定义域名加同站自定义域名会拒绝;同账户双 `workers.dev` 也会拒绝,主站只有 `workers.dev` 时需独立自定义域名。专用 preview 同一 origin 内不同目标网站不相互隔离,切换前关闭旧预览窗口。 -操作步骤:在端口转发页面选择主机和端口转发类型(如 `127.0.0.1` HTTP)→ 选择 `trusted` 或 `isolated`(trusted 需勾选界面信任确认,isolated 不需要)→ 确认指纹并连接。连接期间禁用模式切换,需先点击停止再切换。停止主站连接、离开主站端口转发管理页、刷新页面或 SSH 断线后预览失效;仅关闭目标预览 tab 不保证停止。isolated 票据 60 秒内只能兑换一次,兑换后的授权最长 1 小时;trusted 链接依赖主站登录及账户绑定,不使用一次性 fragment。 +操作步骤:在端口转发页面选择主机和端口转发类型(如 `127.0.0.1` HTTP)→ 选择 `trusted` 或 `isolated`(trusted 需勾选界面信任确认,isolated 不需要)→ 确认指纹并连接。连接期间禁用模式切换,需先点击停止再切换。离开管理页或刷新后,Worker 保持 SSH 转发 8 分钟;期限内重新进入可恢复显示、重新打开预览或立即停止。8 分钟到期、用户点击停止、SSH 断线或 Durable Object 重启后预览失效;仅关闭目标预览 tab 不保证停止。isolated 票据 60 秒内只能兑换一次,兑换后的授权最长 1 小时;trusted 链接依赖主站登录及账户绑定,不使用一次性 fragment。 标准实现改写 HTML 属性、`srcset`、CSS URL、`Location`、Cookie 名称与 Path,并注入常见 `fetch`/XHR/EventSource/history/cookie 兼容脚本;不承诺任意网站透明代理。严格 CSP、动态 ES 模块、写死的 location、复杂 inline CSS/JS 框架仍可能需要 baseURL 配置,优先使用 isolated。仅支持 HTTP/SSE、相对资源、表单、目标 Cookie、重定向和 HTTP Basic 鉴权;HTTP 上游限 `127.0.0.1`,上传 16 MiB,CSS 重写 2 MiB,最多 24 个并发通道,通道闲置 60 秒。HTTPS 上游、WebSocket、Service Worker、写死 `localhost`、OAuth 固定 callback 不支持;SFTP 上传仍为 64 MiB。 diff --git a/README.md b/README.md index f873112..f01fef3 100644 --- a/README.md +++ b/README.md @@ -229,7 +229,7 @@ isolated 模式凭据只在 URL fragment 中短暂传递,随后立即换成 Ht ### 端口转发 -在端口转发页面选择主机和端口转发类型(例如 `127.0.0.1` 的 HTTP),确认 SSH 主机指纹后,系统会打开新的预览窗口。管理页会保留在原窗口;用户停止主站连接、离开主站端口转发管理页、刷新页面或 SSH 断线后,预览立即失效;仅关闭目标预览 tab 不保证停止。预览票据只使用一次,不能分享给他人。 +在端口转发页面选择主机和端口转发类型(例如 `127.0.0.1` 的 HTTP),确认 SSH 主机指纹后,系统会打开新的预览窗口。离开管理页或刷新后,Worker 会继续保持 SSH 转发 8 分钟;在期限内重新进入页面,可看到原端口、模式和截止时间,并可重新打开预览或立即停止。8 分钟到期、用户点击停止、SSH 断线或 Durable Object 重启后,预览失效;仅关闭目标预览 tab 不保证停止。预览票据只使用一次,不能分享给他人。 ### 安全边界 diff --git a/frontend/src/forward-page.ts b/frontend/src/forward-page.ts index 964e6cf..1060dbd 100644 --- a/frontend/src/forward-page.ts +++ b/frontend/src/forward-page.ts @@ -1,6 +1,16 @@ import { api, hostCredentials, type CloudHost } from './cloud-api'; import './forward-page.css'; +const FORWARD_SESSION_STORAGE_KEY = 'edgessh.forwarding-session'; +const FORWARD_RETENTION_MS = 8 * 60 * 1000; + +interface ForwardingStatus { + active: boolean; + port?: number; + mode?: 'trusted' | 'isolated'; + expiresAt?: number; +} + /** 转发持有独立 SSH 连接;标准模式与主站同源,不能当作恶意脚本的安全边界。 */ export class ForwardPage { readonly root = document.createElement('main'); @@ -8,10 +18,13 @@ export class ForwardPage { private socket?: WebSocket; private sessionId?: string; private generation = 0; + private restoreGeneration = 0; private busy = false; private ready = false; private popup: Window | null = null; private deadline?: ReturnType; + private retentionDeadline?: number; + private retentionTimer?: ReturnType; private readonly select: HTMLSelectElement; private readonly port: HTMLInputElement; private readonly startButton: HTMLButtonElement; @@ -56,7 +69,7 @@ export class ForwardPage {

可信网站简单用,不可信网站单独隔离

标准转发使用当前 Worker 的独立路径,不增加部署资源。隔离预览使用可选的独立 Worker 和跨站域名,不共享主站登录 Cookie。

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

支持常见资源、表单、网站 Cookie、HTTP 登录和重定向。授权最长 1 小时,上传最多 16 MiB。不支持 HTTPS 上游、WebSocket、Service Worker。标准模式兼容常见 fetch / XHR,但不是任意网站的透明代理;复杂 SPA、动态模块或硬编码跳转可能需要网站配置 base URL。同一隔离预览域名一次只使用一个网站,切换前关闭旧窗口。

@@ -91,7 +104,7 @@ export class ForwardPage { this.reservePopup(); void this.launch(this.generation); }); - window.addEventListener('pagehide', () => this.stop()); + window.addEventListener('pagehide', () => this.suspend()); window.addEventListener('auth-required', () => this.stop()); } @@ -112,10 +125,12 @@ export class ForwardPage { void api<{ previewAvailable: boolean }>('/api/forwarding').then((config) => { this.previewAvailable = config.previewAvailable === true; this.render(); }).catch(() => { this.previewAvailable = false; this.render(); }); + void this.restore(++this.restoreGeneration); } hide(): void { if (this.root.hidden) return; - this.stop(); + this.restoreGeneration++; + this.suspend(); this.root.hidden = true; } @@ -218,28 +233,94 @@ export class ForwardPage { }); if (generation !== this.generation) return; this.ready = true; + this.rememberSession(); + if (!this.socket) this.scheduleRetention(expiresAt); const link = this.get('[data-preview-link]'); link.href = url; if (this.popup && !this.popup.closed) this.popup.location.replace(url); else link.hidden = false; this.popup = null; - this.message(`已转发 127.0.0.1:${this.port.value} · 授权至 ${new Date(expiresAt).toLocaleTimeString()} · 请保持本页打开`); + this.message(`已转发 127.0.0.1:${this.port.value} · 授权至 ${new Date(expiresAt).toLocaleTimeString()} · 离开页面后保持 8 分钟`); } 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 { + const sessionId = this.sessionId; this.generation++; + this.restoreGeneration++; clearTimeout(this.deadline); + clearTimeout(this.retentionTimer); + this.retentionTimer = undefined; this.retentionDeadline = undefined; 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; + localStorage.removeItem(FORWARD_SESSION_STORAGE_KEY); + if (sessionId) void api(`/api/forwarding?session=${sessionId}`, 'DELETE').catch(() => undefined); this.trust.checked = false; this.get('[data-preview-link]').hidden = true; this.get('[data-preview-link]').removeAttribute('href'); this.message(message); this.render(); } + + private suspend(): void { + if (!this.ready || !this.sessionId) { + if (this.busy) this.stop(); + return; + } + this.generation++; + clearTimeout(this.deadline); + this.pendingKey = undefined; this.keyDialog.close(); + this.popup?.close(); this.popup = null; + this.socket?.close(); this.socket = undefined; + this.busy = false; + this.rememberSession(); + this.scheduleRetention(Date.now() + FORWARD_RETENTION_MS); + this.message(`后台保持 127.0.0.1:${this.port.value} 至 ${new Date(this.retentionDeadline!).toLocaleTimeString()},重新进入可继续查看或停止。`); + this.render(); + } + + private rememberSession(): void { + // 这里只保存无权访问 SSH 的 DO 标识;账户校验和 8 分钟期限仍由 Worker 决定。 + if (this.sessionId) localStorage.setItem(FORWARD_SESSION_STORAGE_KEY, this.sessionId); + } + + private scheduleRetention(expiresAt: number): void { + clearTimeout(this.retentionTimer); + this.retentionDeadline = expiresAt; + this.retentionTimer = setTimeout(() => this.stop('后台保持 8 分钟已结束,转发已自动停止。'), + Math.max(0, expiresAt - Date.now())); + } + + private async restore(generation: number): Promise { + const sessionId = this.sessionId ?? localStorage.getItem(FORWARD_SESSION_STORAGE_KEY) ?? undefined; + if (!sessionId || this.socket) return; + this.busy = true; this.render(); + try { + const status = await api(`/api/forwarding?session=${sessionId}`); + if (generation !== this.restoreGeneration) return; + if (!status.active || !status.port || !status.mode || !status.expiresAt) { + localStorage.removeItem(FORWARD_SESSION_STORAGE_KEY); + if (this.sessionId === sessionId) this.stop('之前保持的转发已结束。'); + else this.message('之前保持的转发已结束。'); + return; + } + this.sessionId = sessionId; + this.port.value = String(status.port); + this.mode.value = status.mode; + this.trust.checked = status.mode === 'trusted'; + this.ready = true; + this.scheduleRetention(status.expiresAt); + this.message(`正在保持 127.0.0.1:${status.port} 至 ${new Date(status.expiresAt).toLocaleTimeString()},可重新打开预览或立即停止。`); + } catch { + if (generation !== this.restoreGeneration) return; + localStorage.removeItem(FORWARD_SESSION_STORAGE_KEY); + this.sessionId = undefined; this.ready = false; + this.message('之前保持的转发已结束。'); + } finally { + if (generation === this.restoreGeneration) { this.busy = false; this.render(); } + } + } } diff --git a/src/backend/durable-object.ts b/src/backend/durable-object.ts index 407e773..1f731df 100644 --- a/src/backend/durable-object.ts +++ b/src/backend/durable-object.ts @@ -4,7 +4,7 @@ import { assertPublicTarget, toSocketHostname } from './security'; import { createTicket, verifyTicket } from './security'; import { SSHSession } from './session'; import { ForwardingState } from '../forwarding/state'; -import { previewError } from '../forwarding/security'; +import { FORWARD_RETENTION_MS, previewError } from '../forwarding/security'; interface MainAttachment { role: 'main'; phase: 'waiting' | 'connecting' | 'connected' } interface SFTPAttachment { role: 'sftp'; phase: 'connected' } @@ -27,6 +27,7 @@ type AuxiliaryAttachToken = SFTPAttachToken; const TICKET_STORAGE_KEY = 'session-ticket'; const SFTP_ATTACH_TOKEN_TTL_MS = 10 * 60 * 1000; const SFTP_ATTACH_TOKEN_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; +interface RetainedForwarding { expiresAt: number; timeout: ReturnType } export class SSHSessionDO implements DurableObject { private readonly state: DurableObjectState; @@ -45,6 +46,7 @@ export class SSHSessionDO implements DurableObject { private readonly processSessions = new Map(); private readonly processOwners = new Map(); private readonly processWebSocketsByMain = new Map>(); + private readonly retainedForwarding = new Map(); constructor(state: DurableObjectState, env: Env) { this.state = state; @@ -88,8 +90,13 @@ export class SSHSessionDO implements DurableObject { } if (url.pathname === '/forward-http') return this.forwarding.trusted(request); if (url.pathname === '/forward') { + if (request.method === 'GET') { + return Response.json(this.forwarding.status(this.forwardingRetentionDeadline()), { + headers: { 'Cache-Control': 'no-store' }, + }); + } if (request.method === 'DELETE') { - this.forwarding.stop(); + this.stopForwarding(); return Response.json({ ok: true }, { headers: { 'Cache-Control': 'no-store' } }); } if (request.method !== 'POST') return previewError('Method not allowed', 405); @@ -99,7 +106,10 @@ export class SSHSessionDO implements DurableObject { const origin = request.headers.get('x-preview-origin'); if (!Number.isInteger(port) || port < 1 || port > 65535 || !origin || (mode !== 'trusted' && mode !== 'isolated')) return previewError('Invalid forwarding configuration', 400); - try { return await this.forwarding.create(session, port, origin, this.state.id.toString(), mode); } + try { + return await this.forwarding.create(session, port, origin, this.state.id.toString(), mode, + this.forwardingRetentionDeadline(session)); + } catch { return previewError('远端端口不可达或 SSH 服务未允许 TCP 转发。', 502); } } if (url.pathname === '/sftp') return this.attachSFTP(request); @@ -439,8 +449,21 @@ export class SSHSessionDO implements DurableObject { this.deadlines.delete(ws); } - private cleanup(ws: WebSocket): void { - this.forwarding.clear(); + private cleanup(ws: WebSocket, force = false): void { + const session = this.sessions.get(ws); + if (!force && session && this.forwarding.owns(session)) { + if (!this.retainedForwarding.has(ws)) { + // 管理页断开不等于用户停止;短暂保留底层 SSH,允许刷新或切页后回来主动关闭。 + const expiresAt = Date.now() + FORWARD_RETENTION_MS; + const timeout = setTimeout(() => this.cleanup(ws, true), FORWARD_RETENTION_MS); + this.retainedForwarding.set(ws, { expiresAt, timeout }); + } + return; + } + const retained = this.retainedForwarding.get(ws); + if (retained) clearTimeout(retained.timeout); + this.retainedForwarding.delete(ws); + if (!session || this.forwarding.currentSession() === session) this.forwarding.clear(); this.clearDeadline(ws); const token = this.sftpTokenByMainWebSocket.get(ws); if (token) this.deleteSFTPAttachToken(token); @@ -472,6 +495,23 @@ export class SSHSessionDO implements DurableObject { this.sessions.delete(ws); } + private forwardingRetentionDeadline(session = this.forwarding.currentSession()): number | undefined { + if (!session) return undefined; + for (const [ws, retained] of this.retainedForwarding) { + if (this.sessions.get(ws) === session) return retained.expiresAt; + } + return undefined; + } + + private stopForwarding(): void { + const session = this.forwarding.currentSession(); + this.forwarding.stop(); + if (!session) return; + for (const [ws, current] of this.sessions) { + if (current === session) this.cleanup(ws, true); + } + } + private cleanupSFTPWebSocket(ws: WebSocket): void { const session = this.sftpSessions.get(ws); if (!session) return; diff --git a/src/forwarding/routes.ts b/src/forwarding/routes.ts index f3361ec..3245ca3 100644 --- a/src/forwarding/routes.ts +++ b/src/forwarding/routes.ts @@ -6,7 +6,15 @@ import { TRUSTED_PREFIX } from './mount.ts'; /** 只有主站的认证 API 能创建或撤销转发;预览站没有 SSH 控制接口。 */ export async function forwardingRoute(request: Request, env: Env, accountId: string): Promise { + const url = new URL(request.url); if (request.method === 'GET') { + const session = url.searchParams.get('session'); + if (session) { + if (!/^[a-f0-9]{64}$/.test(session)) return jsonError('Invalid session identifier', 400); + return env.SSH_SESSIONS.get(env.SSH_SESSIONS.idFromString(session)).fetch(new Request('https://session.internal/forward', { + headers: { 'x-account-id': accountId }, + })); + } let previewAvailable = false; try { previewOrigin(env.PREVIEW_ORIGIN, new URL(request.url).origin); @@ -16,7 +24,6 @@ export async function forwardingRoute(request: Request, env: Env, accountId: str return Response.json({ previewAvailable }, { headers: { 'Cache-Control': 'no-store' } }); } if (request.method !== 'POST' && request.method !== 'DELETE') return jsonError('Method not allowed', 405); - const url = new URL(request.url); const session = url.searchParams.get('session'); if (!session || !/^[a-f0-9]{64}$/.test(session)) return jsonError('Invalid session identifier', 400); let origin = url.origin; diff --git a/src/forwarding/security.ts b/src/forwarding/security.ts index 84683ff..cca93a5 100644 --- a/src/forwarding/security.ts +++ b/src/forwarding/security.ts @@ -1,6 +1,7 @@ export const PREVIEW_COOKIE = '__Host-edgessh-preview'; export const PREVIEW_PREFIX = '/__edgessh/'; export const FORWARD_TTL_MS = 60 * 60 * 1000; +export const FORWARD_RETENTION_MS = 8 * 60 * 1000; export const LAUNCH_TTL_MS = 60_000; // workers.dev 是公共后缀,但同账户的两个 Worker 仍共享 account.workers.dev。 diff --git a/src/forwarding/state.ts b/src/forwarding/state.ts index 7905dd4..422527c 100644 --- a/src/forwarding/state.ts +++ b/src/forwarding/state.ts @@ -13,9 +13,17 @@ interface Grant { token: string; expires: number; base: string; + mode: 'trusted' | 'isolated'; } -/** 授权仅存在 SSH 会话内存里;DO 重启、断线或显式停止都会让预览 Cookie 失效。 */ +export interface ForwardingStatus { + active: boolean; + port?: number; + mode?: 'trusted' | 'isolated'; + expiresAt?: number; +} + +/** 授权仅存在 SSH 会话内存里;DO 重启、远端 SSH 断线或显式停止都会让预览 Cookie 失效。 */ export class ForwardingState { private grant?: Grant; private timer?: ReturnType; @@ -34,7 +42,27 @@ export class ForwardingState { session?.close(true); } - async create(session: SSHSession, port: number, origin: string, sessionId: string, mode: 'trusted' | 'isolated' = 'isolated'): Promise { + owns(session: SSHSession): boolean { + return this.grant?.session === session && session.isForwardReady(); + } + + currentSession(): SSHSession | undefined { + return this.grant?.session; + } + + status(expiresAtLimit?: number): ForwardingStatus { + const grant = this.grant; + if (!grant || !grant.session.isForwardReady() || grant.expires <= Date.now()) return { active: false }; + return { + active: true, + port: grant.port, + mode: grant.mode, + expiresAt: Math.min(grant.expires, expiresAtLimit ?? grant.expires), + }; + } + + async create(session: SSHSession, port: number, origin: string, sessionId: string, + mode: 'trusted' | 'isolated' = 'isolated', expiresAtLimit?: number): Promise { this.clear(); const revision = this.revision; // 创建时先探测 direct-tcpip 权限和监听端口,避免给用户一个必定失败的链接。 @@ -43,8 +71,8 @@ export class ForwardingState { 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, - base: mode === 'trusted' ? trustedBase(sessionId) : '', + token: crypto.randomUUID(), expires: Math.min(Date.now() + FORWARD_TTL_MS, expiresAtLimit ?? Number.MAX_SAFE_INTEGER), + base: mode === 'trusted' ? trustedBase(sessionId) : '', mode, }; this.grant = grant; this.timer = setTimeout(() => this.stop(), FORWARD_TTL_MS); diff --git a/tests/browser/forwarding.spec.ts b/tests/browser/forwarding.spec.ts index db1b330..0d28f51 100644 --- a/tests/browser/forwarding.spec.ts +++ b/tests/browser/forwarding.spec.ts @@ -14,27 +14,40 @@ async function forwardingFixture(page: Page, previewAvailable = true, noHosts = const calls: Array> = []; const sockets: WebSocketRoute[] = []; const closed: boolean[] = []; + let active = false; + let retainedExpiresAt = 0; await page.route('**/api/**', async (route) => { - const path = new URL(route.request().url()).pathname; + const requestURL = new URL(route.request().url()); + const path = requestURL.pathname; if (path === '/api/auth/me') return route.fulfill({ json: { account: { username: 'Administrator' }, provider: 'cloudflare' } }); if (path === '/api/hosts') return route.fulfill({ json: { hosts: noHosts ? [] : [host] } }); if (path.endsWith('/credentials')) return route.fulfill({ json: { password: 'test-only-password' } }); if (path === '/api/session') return route.fulfill({ json: { ticket: 'test-ticket', sessionId } }); if (path === '/api/forwarding' && route.request().method() === 'GET') { + if (requestURL.searchParams.has('session')) { + return route.fulfill({ json: active + ? { active: true, port: 8080, mode: 'trusted', expiresAt: retainedExpiresAt || Date.now() + 480000 } + : { active: false } }); + } return route.fulfill({ json: { previewAvailable } }); } if (path === '/api/forwarding' && route.request().method() === 'POST') { calls.push({ type: 'forwarding', body: route.request().postDataJSON() }); + active = true; const mode = route.request().postDataJSON().mode; return route.fulfill({ json: { url: mode === 'isolated' ? 'https://isolated.example.net/start#xxx' : trustedURL, expiresAt: Date.now() + 3600000 } }); } + if (path === '/api/forwarding' && route.request().method() === 'DELETE') { + active = false; calls.push({ type: 'delete' }); + return route.fulfill({ json: { ok: true } }); + } 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.onClose(() => { closed[index] = true; retainedExpiresAt = Date.now() + 480000; void ws.close(); }); ws.onMessage((raw) => { const message = JSON.parse(String(raw)); calls.push(message); @@ -120,15 +133,30 @@ test('连接转发发送 forward 协议并提供弹窗拦截回退链接,停 await page.screenshot({ path: testInfo.outputPath('forwarding-desktop.png'), fullPage: true }); }); -test('离开总览会关闭独立转发 WebSocket,返回页面保持未连接', async ({ page }) => { - const { sockets, closed } = await forwardingFixture(page); +test('离开转发页后保持 8 分钟,返回页面可查看并手动停止', async ({ page }) => { + const { calls, 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-status]')).toContainText('正在保持 127.0.0.1:8080'); + await expect(page.locator('[data-stop]')).toBeEnabled(); + await page.locator('[data-stop]').click(); await expect(page.locator('[data-stop]')).toBeDisabled(); + await expect.poll(() => calls.some((call) => call.type === 'delete')).toBe(true); +}); + +test('刷新后可从短期会话句柄恢复正在保持的转发', async ({ page }) => { + const { sockets } = await forwardingFixture(page); + await startForward(page); + await expect.poll(() => sockets.length).toBe(1); + await page.reload(); + await page.locator('#rail-forward').click(); + await expect(page.locator('[data-status]')).toContainText('正在保持 127.0.0.1:8080'); + await expect(page.locator('.forward-page input[name="port"]')).toHaveValue('8080'); + await expect(page.locator('.forward-page select[name="mode"]')).toHaveValue('trusted'); + await page.locator('[data-stop]').click(); }); test('空主机禁用连接按钮且桌面与 375px 手机布局没有横向溢出', async ({ page }, testInfo) => { diff --git a/tests/forwarding-routes.test.ts b/tests/forwarding-routes.test.ts index 5847c32..f1276a2 100644 --- a/tests/forwarding-routes.test.ts +++ b/tests/forwarding-routes.test.ts @@ -25,6 +25,17 @@ test('GET reports preview availability only when configured', async () => { assert.deepEqual(await (await forwardingRoute(new Request('https://main.test/api/forward'), makeEnv('https://preview.test').env, 'acct')).json(), { previewAvailable: true }); }); +test('GET with a session queries the owned Durable Object', async () => { + const state = makeEnv(); + const response = await forwardingRoute(new Request(`https://main.test/api/forward?session=${sessionId}`), state.env, 'acct'); + assert.equal(response.status, 200); + assert.equal(state.calls.length, 1); + assert.equal(state.calls[0].id, sessionId); + assert.equal(state.calls[0].request.method, 'GET'); + assert.equal(state.calls[0].request.headers.get('x-account-id'), 'acct'); + assert.equal((await forwardingRoute(new Request('https://main.test/api/forward?session=bad'), state.env, 'acct')).status, 400); +}); + test('trusted POST requires explicit confirmation and forwards trusted mode', async () => { const missing = makeEnv(); const rejected = await forwardingRoute(new Request(`https://main.test/api/forward?session=${sessionId}`, { @@ -77,7 +88,12 @@ test('trusted forwarding validates session, strips token, derives path, and pres }); function fakeSession() { - return { isForwardReady: () => true, openForward: async () => ({ close: async () => {} }) } as never; + let ready = true; + return { + isForwardReady: () => ready, + openForward: async () => ({ close: async () => {} }), + close: () => { ready = false; }, + } as never; } test('ForwardingState rejects cross-mode grants and clear invalidates both paths', async () => { @@ -91,3 +107,16 @@ test('ForwardingState rejects cross-mode grants and clear invalidates both paths assert.equal((await state.trusted(new Request('https://main.test/'))).status, 410); assert.equal((await state.preview(new Request('https://main.test/'))).status, 410); }); + +test('ForwardingState reports restorable metadata and applies the retention deadline', async () => { + const state = new ForwardingState(); + const session = fakeSession(); + const retainedUntil = Date.now() + 8 * 60_000; + await state.create(session, 8080, 'https://main.test', sessionId, 'trusted', retainedUntil); + assert.equal(state.owns(session), true); + assert.deepEqual(state.status(retainedUntil), { + active: true, port: 8080, mode: 'trusted', expiresAt: retainedUntil, + }); + state.stop(); + assert.deepEqual(state.status(), { active: false }); +});