feat(forwarding): retain sessions for eight minutes / 端口转发离页保持八分钟
This commit is contained in:
1 parent
5d65b34907
commit
afef04bf8d
9 files changed
+237
-23
No files matched your search
+1
-1
@@ -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。
|
||||
|
||||
|
||||
@@ -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 不保证停止。预览票据只使用一次,不能分享给他人。
|
||||
|
||||
### 安全边界
|
||||
|
||||
|
||||
@@ -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<typeof setTimeout>;
|
||||
private retentionDeadline?: number;
|
||||
private retentionTimer?: ReturnType<typeof setTimeout>;
|
||||
private readonly select: HTMLSelectElement;
|
||||
private readonly port: HTMLInputElement;
|
||||
private readonly startButton: HTMLButtonElement;
|
||||
@@ -56,7 +69,7 @@ export class ForwardPage {
|
||||
<section class="forward-explanation" aria-label="隔离与使用说明">
|
||||
<h2>可信网站简单用,不可信网站单独隔离</h2>
|
||||
<p>标准转发使用当前 Worker 的独立路径,不增加部署资源。隔离预览使用可选的独立 Worker 和跨站域名,不共享主站登录 Cookie。</p>
|
||||
<ol><li>选择云端主机,输入网站的 HTTP 端口。</li><li>核对 SSH 主机指纹,连接后自动打开预览。</li><li>用完点击停止;离开此页面、刷新或断线后,转发立即失效。</li></ol>
|
||||
<ol><li>选择云端主机,输入网站的 HTTP 端口。</li><li>核对 SSH 主机指纹,连接后自动打开预览。</li><li>离开此页面后转发保持 8 分钟;重新进入可查看或立即停止。</li></ol>
|
||||
<p class="forward-hint">支持常见资源、表单、网站 Cookie、HTTP 登录和重定向。授权最长 1 小时,上传最多 16 MiB。不支持 HTTPS 上游、WebSocket、Service Worker。标准模式兼容常见 fetch / XHR,但不是任意网站的透明代理;复杂 SPA、动态模块或硬编码跳转可能需要网站配置 base URL。同一隔离预览域名一次只使用一个网站,切换前关闭旧窗口。</p>
|
||||
</section>
|
||||
<dialog class="host-dialog" aria-labelledby="forward-key-heading">
|
||||
@@ -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<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()} · 请保持本页打开`);
|
||||
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<HTMLAnchorElement>('[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<void> {
|
||||
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<ForwardingStatus>(`/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(); }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<typeof setTimeout> }
|
||||
|
||||
export class SSHSessionDO implements DurableObject {
|
||||
private readonly state: DurableObjectState;
|
||||
@@ -45,6 +46,7 @@ export class SSHSessionDO implements DurableObject {
|
||||
private readonly processSessions = new Map<WebSocket, SSHSession>();
|
||||
private readonly processOwners = new Map<WebSocket, WebSocket>();
|
||||
private readonly processWebSocketsByMain = new Map<WebSocket, Set<WebSocket>>();
|
||||
private readonly retainedForwarding = new Map<WebSocket, RetainedForwarding>();
|
||||
|
||||
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;
|
||||
|
||||
@@ -6,7 +6,15 @@ import { TRUSTED_PREFIX } from './mount.ts';
|
||||
|
||||
/** 只有主站的认证 API 能创建或撤销转发;预览站没有 SSH 控制接口。 */
|
||||
export async function forwardingRoute(request: Request, env: Env, accountId: string): Promise<Response> {
|
||||
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;
|
||||
|
||||
@@ -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。
|
||||
|
||||
+32
-4
@@ -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<typeof setTimeout>;
|
||||
@@ -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<Response> {
|
||||
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<Response> {
|
||||
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);
|
||||
|
||||
@@ -14,27 +14,40 @@ async function forwardingFixture(page: Page, previewAvailable = true, noHosts =
|
||||
const calls: Array<Record<string, unknown>> = [];
|
||||
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) => {
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
Reference in new issue
Block a user