Add saved port forwarding rules and mode switch
This commit is contained in:
1 parent
c9f69b2c08
commit
ae9e7f94b7
8 files changed
+466
-77
No files matched your search
@@ -37,5 +37,6 @@ export function demoApiResult(path: string, method: string): unknown | undefined
|
||||
if (method !== 'GET') return undefined;
|
||||
if (path === '/api/auth/me') return { account: { username: 'Demo Admin' } };
|
||||
if (path === '/api/hosts') return { hosts: DEMO_HOSTS };
|
||||
if (path === '/api/forward-rules') return { rules: [] };
|
||||
return undefined;
|
||||
}
|
||||
@@ -1,20 +1,49 @@
|
||||
body[data-view='forward'] { overflow-y: auto; background: var(--home-bg); }
|
||||
.forward-page { min-width: 0; width: 100%; max-width: 1240px; margin-inline: auto; padding: 36px 32px; }
|
||||
.forward-card { padding: 24px; border: 1px solid var(--home-line); border-radius: 10px; background: var(--home-surface); }
|
||||
.forward-warning { border: 1px solid var(--home-accent); border-left-width: 4px; border-radius: 8px; padding: 18px 22px; margin-bottom: 20px; background: var(--home-accent-soft); }
|
||||
.forward-warning p { margin: 8px 0 0; line-height: 1.8; }
|
||||
.forward-mode-field, .forward-wide { grid-column: 1 / -1; }
|
||||
.forward-page .home-section-heading { align-items: center; }
|
||||
#dashboard .forward-new { min-height: 42px; }
|
||||
.forward-toolbar { display: flex; align-items: center; gap: 20px; margin: 4px 0 28px; }
|
||||
.forward-toolbar .forward-hint { margin: 0; }
|
||||
.forward-switch { position: relative; display: grid; grid-template-columns: repeat(2, minmax(118px, 1fr)); flex: 0 0 auto; padding: 4px; border: 1px solid var(--home-line); border-radius: 10px; background: #e9edf2; isolation: isolate; }
|
||||
.forward-switch::before { content: ''; position: absolute; z-index: -1; top: 4px; bottom: 4px; left: 4px; width: calc((100% - 8px) / 2); border-radius: 7px; background: var(--home-surface); box-shadow: 0 1px 5px #17203520; transition: transform 180ms ease; }
|
||||
.forward-switch[data-active-mode='isolated']::before { transform: translateX(100%); }
|
||||
#dashboard .forward-switch button { min-height: 36px; padding: 5px 15px; border-radius: 7px; background: transparent; color: var(--home-muted); font-weight: 600; white-space: nowrap; }
|
||||
#dashboard .forward-switch button[aria-pressed='true'] { color: var(--home-ink); }
|
||||
.forward-list-heading { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; margin-bottom: 14px; }
|
||||
.forward-list-heading h2 { margin: 0; font-size: 17px; }
|
||||
.forward-list-heading span { color: var(--home-muted); font-size: 12px; }
|
||||
.forward-library [data-rule-message]:empty { display: none; }
|
||||
.forward-rules { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; }
|
||||
.forward-rule { display: flex; align-items: center; min-width: 0; min-height: 92px; border: 1px solid var(--home-line); border-radius: 16px; background: var(--home-surface); transition: border-color 140ms ease, box-shadow 140ms ease; }
|
||||
.forward-rule:hover, .forward-rule.selected { border-color: var(--home-accent); box-shadow: 0 4px 16px #1720350d; }
|
||||
#dashboard .forward-rule-select { display: flex; align-items: center; flex: 1; min-width: 0; gap: 14px; padding: 17px; background: transparent; text-align: left; }
|
||||
.forward-rule-symbol { display: grid; place-items: center; flex: 0 0 48px; height: 48px; border-radius: 13px; color: var(--home-surface); background: #596078; font-size: 23px; font-weight: 600; }
|
||||
.forward-rule-copy { display: flex; flex-direction: column; min-width: 0; gap: 4px; }
|
||||
.forward-rule-copy strong, .forward-rule-copy small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.forward-rule-copy strong { font-size: 15px; }
|
||||
.forward-rule-copy small { color: var(--home-muted); font-size: 12px; }
|
||||
.forward-rule-actions { align-self: stretch; display: flex; flex-direction: column; justify-content: center; gap: 3px; padding-right: 12px; }
|
||||
#dashboard .forward-rule-actions button { padding: 4px 8px; border-radius: 5px; background: transparent; color: var(--home-muted); font-size: 12px; }
|
||||
#dashboard .forward-rule-actions button:hover { background: var(--home-accent-soft); color: var(--home-accent-hover); }
|
||||
.forward-empty { grid-column: 1 / -1; padding: 42px 20px; border: 1px dashed var(--home-line); border-radius: 16px; background: var(--home-surface); text-align: center; }
|
||||
.forward-empty strong { font-size: 15px; }
|
||||
.forward-empty p { color: var(--home-muted); margin: 8px 0 0; }
|
||||
.forward-card { padding: 24px; margin-top: 24px; border: 1px solid var(--home-line); border-radius: 12px; background: var(--home-surface); }
|
||||
.forward-connection-heading { display: flex; align-items: start; justify-content: space-between; gap: 12px; margin-bottom: 18px; }
|
||||
.forward-connection-heading h2 { margin: 0 0 4px; font-size: 18px; }
|
||||
.forward-connection-heading p { margin: 0; color: var(--home-muted); overflow-wrap: anywhere; }
|
||||
.forward-warning { border: 1px solid var(--home-accent); border-left-width: 4px; border-radius: 8px; padding: 14px 18px; margin-bottom: 18px; background: var(--home-accent-soft); }
|
||||
.forward-warning p { margin: 6px 0 0; line-height: 1.7; }
|
||||
.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: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 16px; }
|
||||
#dashboard .forward-form .forward-trust { display: flex; align-items: flex-start; gap: 10px; font-size: 14px; line-height: 1.7; font-weight: 400; }
|
||||
#dashboard .forward-form .forward-trust input { width: 18px; height: 18px; flex: 0 0 18px; margin-top: 3px; accent-color: var(--home-accent); }
|
||||
.forward-setup { margin-top: 20px; border-top: 1px solid var(--home-line); padding-top: 18px; line-height: 1.8; }
|
||||
.forward-badge { padding: 6px 10px; border-radius: 5px; background: var(--home-accent-soft); color: var(--home-accent-hover); font-size: 12px; white-space: nowrap; }
|
||||
.forward-form { display: grid; grid-template-columns: minmax(180px, 1fr) 130px auto auto; align-items: end; gap: 16px; }
|
||||
.forward-form label { display: grid; gap: 8px; min-width: 0; font-weight: 600; }
|
||||
#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; }
|
||||
.forward-actions { display: flex; flex-wrap: wrap; gap: 10px; }
|
||||
#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-status [data-status].is-retained { flex: 1; border: 1px solid var(--home-accent); border-left-width: 4px; border-radius: 6px; padding: 12px 14px; background: var(--home-accent-soft); color: var(--home-ink); font-weight: 700; line-height: 1.6; }
|
||||
.forward-setup { margin-top: 20px; padding: 18px 20px; border: 1px solid var(--home-line); border-radius: 10px; background: var(--home-surface); line-height: 1.8; }
|
||||
.forward-explanation { max-width: 780px; margin-top: 32px; line-height: 1.9; }
|
||||
.forward-explanation h2 { font-size: 20px; }
|
||||
.forward-explanation ol { padding-left: 22px; }
|
||||
@@ -22,12 +51,19 @@ body[data-view='forward'] { overflow-y: auto; background: var(--home-bg); }
|
||||
.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-rule-dialog { width: min(480px, calc(100vw - 32px)); }
|
||||
.forward-rule-fields { display: grid; gap: 15px; margin: 20px 0; }
|
||||
.forward-rule-fields label { display: grid; gap: 7px; font-weight: 600; }
|
||||
#dashboard .forward-rule-fields :is(input, select) { width: 100%; min-width: 0; height: 44px; padding: 8px 10px; border: 1px solid var(--home-line); border-radius: 6px; background: var(--home-bg); color: var(--home-ink); font-size: 15px; }
|
||||
.forward-page [hidden] { display: none !important; }
|
||||
@media (max-width: 1000px) { .forward-form { grid-template-columns: minmax(0, 1fr) 130px; } }
|
||||
@media (max-width: 850px) { .forward-rules { grid-template-columns: minmax(0, 1fr); } .forward-toolbar { align-items: start; flex-direction: column; } }
|
||||
@media (max-width: 600px) {
|
||||
.forward-page { padding: 24px 16px; }
|
||||
.forward-page .home-section-heading { align-items: start; flex-direction: column; }
|
||||
.forward-switch { width: 100%; }
|
||||
.forward-card { padding: 18px; }
|
||||
.forward-form { grid-template-columns: minmax(0, 1fr); }
|
||||
.forward-status { align-items: start; flex-direction: column; }
|
||||
.forward-connection-heading, .forward-status { align-items: start; flex-direction: column; }
|
||||
.forward-form { align-items: stretch; flex-direction: column; }
|
||||
.forward-actions { width: 100%; }
|
||||
.forward-actions button { flex: 1; }
|
||||
}
|
||||
+208
-41
@@ -2,8 +2,18 @@ import { api, hostCredentials, type CloudHost } from './cloud-api';
|
||||
import './forward-page.css';
|
||||
|
||||
const FORWARD_SESSION_STORAGE_KEY = 'edgessh.forwarding-session';
|
||||
const FORWARD_RULE_SESSION_KEY = 'edgessh.forwarding-rule';
|
||||
const FORWARD_RETENTION_MS = 8 * 60 * 1000;
|
||||
|
||||
interface ForwardRule {
|
||||
id: string;
|
||||
name: string;
|
||||
hostId: string;
|
||||
port: number;
|
||||
mode: 'trusted' | 'isolated';
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
interface ForwardingStatus {
|
||||
active: boolean;
|
||||
port?: number;
|
||||
@@ -31,8 +41,17 @@ export class ForwardPage {
|
||||
private readonly stopButton: HTMLButtonElement;
|
||||
private readonly openButton: HTMLButtonElement;
|
||||
private readonly keyDialog: HTMLDialogElement;
|
||||
private readonly mode: HTMLSelectElement;
|
||||
private readonly ruleDialog: HTMLDialogElement;
|
||||
private readonly ruleForm: HTMLFormElement;
|
||||
private readonly trust: HTMLInputElement;
|
||||
private rules: ForwardRule[] = [];
|
||||
private rulesLoading = false;
|
||||
private rulesError = '';
|
||||
private ruleGeneration = 0;
|
||||
private activeMode: 'trusted' | 'isolated' = 'trusted';
|
||||
private selectedRuleId?: string;
|
||||
private editingRuleId?: string;
|
||||
private savingRule = false;
|
||||
private previewAvailable = false;
|
||||
private pendingKey?: { socket: WebSocket; fingerprint: string };
|
||||
|
||||
@@ -41,37 +60,50 @@ export class ForwardPage {
|
||||
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" data-mode-badge>标准路径转发</span></header>
|
||||
<aside class="forward-warning" data-trust-warning role="note"><strong>标准转发仅用于可信网站</strong>
|
||||
<p>网站与 EdgeSSH 主站同源。恶意脚本可能借用你的登录态调用主机管理或 SSH 接口;不同路径、新窗口和 HttpOnly 都不能隔离它。不要用此模式打开不可信或疑似被入侵的网站。</p></aside>
|
||||
<section class="forward-card" aria-label="端口转发设置">
|
||||
<form class="forward-form">
|
||||
<label class="forward-mode-field">转发方式<select name="mode" aria-describedby="forward-mode-help">
|
||||
<option value="trusted">标准转发 · 本 Worker 路径(仅可信网站)</option>
|
||||
<option value="isolated">隔离预览 · 独立 Worker(不可信网站)</option>
|
||||
</select></label>
|
||||
<p id="forward-mode-help" class="forward-hint forward-wide">默认无需部署第二个 Worker。选择隔离预览可查看启用方法。</p>
|
||||
<label>目标主机<select name="host" required aria-describedby="forward-host-hint"><option value="">请选择主机</option></select></label>
|
||||
<label>网站端口<input name="port" type="number" min="1" max="65535" value="8080" required inputmode="numeric"></label>
|
||||
<button class="home-button primary" type="submit">连接并打开网站 ↗</button>
|
||||
<button class="home-button" type="button" data-stop disabled>停止转发</button>
|
||||
<label class="forward-trust forward-wide"><input name="trusted" type="checkbox" required>我确认该网站及其脚本可信,接受与 SSH 主站同源的风险。</label>
|
||||
</form>
|
||||
<p id="forward-host-hint" class="forward-hint">访问目标固定为所选服务器的 <code>http://127.0.0.1:端口</code>,不是你电脑的本机端口。</p>
|
||||
<h1 tabindex="-1">端口转发</h1><p>保存常用规则,通过 SSH 打开服务器本机的网站。</p></div>
|
||||
<button class="home-button primary forward-new" type="button" data-new-rule>+ 新建规则</button></header>
|
||||
<div class="forward-toolbar"><div class="forward-switch" role="group" aria-label="转发方式" data-active-mode="trusted">
|
||||
<button type="button" data-mode="trusted" aria-pressed="true">标准转发</button>
|
||||
<button type="button" data-mode="isolated" aria-pressed="false">隔离预览</button></div>
|
||||
<p id="forward-mode-help" class="forward-hint"></p></div>
|
||||
<section class="forward-library" aria-label="已保存的转发规则">
|
||||
<div class="forward-list-heading"><h2>转发规则</h2><span data-rule-count></span></div>
|
||||
<p class="forward-hint" data-rule-message role="status"></p>
|
||||
<div class="forward-rules" data-rules></div>
|
||||
</section>
|
||||
<div class="forward-setup" data-preview-setup hidden><strong>尚未启用隔离预览</strong>
|
||||
<p>在 GitHub 仓库的 Actions 中运行「部署预览 Worker」工作流。可在 Settings → Secrets and variables → Actions 设置 PREVIEW_DOMAIN;不填则使用默认 workers.dev 地址。预览域名必须与主站跨站。</p>
|
||||
<p>部署完成后重新进入此页,再选择「隔离预览」。此模式不会回退到标准转发。</p></div>
|
||||
<section class="forward-card" aria-label="转发连接" data-connection hidden>
|
||||
<div class="forward-connection-heading"><div><span class="home-eyebrow">SELECTED RULE</span><h2 data-selected-name></h2><p data-selected-target></p></div>
|
||||
<span class="forward-badge" data-mode-badge></span></div>
|
||||
<aside class="forward-warning" data-trust-warning role="note"><strong>标准转发仅用于可信网站</strong>
|
||||
<p>网站与 EdgeSSH 主站同源。恶意脚本可能借用你的登录态调用主机管理或 SSH 接口;不同路径、新窗口和 HttpOnly 都不能隔离它。不要用此模式打开不可信或疑似被入侵的网站。</p></aside>
|
||||
<form class="forward-form"><label class="forward-trust"><input name="trusted" type="checkbox" required>我确认该网站及其脚本可信,接受与 SSH 主站同源的风险。</label>
|
||||
<div class="forward-actions"><button class="home-button primary" type="submit">连接并打开网站 ↗</button>
|
||||
<button class="home-button" type="button" data-stop disabled>停止转发</button></div></form>
|
||||
<p 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>
|
||||
<div class="forward-setup" data-preview-setup hidden><strong>尚未启用隔离预览</strong>
|
||||
<p>在 GitHub 仓库的 Actions 中运行「部署预览 Worker」工作流。可在 Settings → Secrets and variables → Actions 设置 PREVIEW_DOMAIN;不填则使用默认 workers.dev 地址。预览域名必须与主站跨站。</p>
|
||||
<p>部署完成后重新进入此页,再选择「隔离预览」。此模式不会回退到标准转发。</p></div>
|
||||
</section>
|
||||
<section class="forward-explanation" aria-label="隔离与使用说明">
|
||||
<h2>可信网站简单用,不可信网站单独隔离</h2>
|
||||
<p>标准转发使用当前 Worker 的独立路径,不增加部署资源。隔离预览使用可选的独立 Worker 和跨站域名,不共享主站登录 Cookie。</p>
|
||||
<ol><li>选择云端主机,输入网站的 HTTP 端口。</li><li>核对 SSH 主机指纹,连接后自动打开预览。</li><li>离开此页面后转发保持 8 分钟;重新进入可查看或立即停止。</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 forward-rule-dialog" aria-labelledby="forward-rule-title">
|
||||
<form autocomplete="off"><div class="dialog-heading"><div><p class="home-eyebrow">SAVED FORWARD</p><h2 id="forward-rule-title">新建转发规则</h2></div></div>
|
||||
<p class="dialog-intro">规则会加密保存在云端。网站仅在你点击连接后打开。</p>
|
||||
<div class="forward-rule-fields"><label>规则名称<input name="name" maxlength="80" required placeholder="例如:开发服务"></label>
|
||||
<label>目标主机<select name="host" required><option value="">请选择主机</option></select></label>
|
||||
<label>网站端口<input name="port" type="number" min="1" max="65535" value="8080" required inputmode="numeric"></label></div>
|
||||
<p class="forward-hint">将转发所选主机的 <code>127.0.0.1:端口</code>。连接方式由顶部滑块决定。</p>
|
||||
<p class="host-form-error" data-rule-error role="alert" hidden></p>
|
||||
<div class="dialog-actions"><button class="home-button" type="button" data-cancel-rule>取消</button><button class="home-button primary" type="submit">保存规则</button></div>
|
||||
</form>
|
||||
</dialog>
|
||||
<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>
|
||||
@@ -81,13 +113,22 @@ export class ForwardPage {
|
||||
</dialog>`;
|
||||
this.select = this.get('[name="host"]');
|
||||
this.port = this.get('[name="port"]');
|
||||
this.mode = this.get('[name="mode"]');
|
||||
this.trust = this.get('[name="trusted"]');
|
||||
this.ruleDialog = this.get('.forward-rule-dialog');
|
||||
this.ruleForm = this.ruleDialog.querySelector('form')!;
|
||||
this.startButton = this.get('[type="submit"]');
|
||||
this.stopButton = this.get('[data-stop]');
|
||||
this.openButton = this.get('[data-open]');
|
||||
this.keyDialog = this.get('dialog');
|
||||
this.mode.addEventListener('change', () => { this.trust.checked = false; this.render(); });
|
||||
this.keyDialog = this.get('[aria-labelledby="forward-key-heading"]');
|
||||
this.root.querySelectorAll<HTMLButtonElement>('[data-mode]').forEach((button) => button.addEventListener('click', () => {
|
||||
if (this.busy || this.ready) return;
|
||||
this.activeMode = button.dataset.mode as 'trusted' | 'isolated';
|
||||
this.selectedRuleId = undefined; this.trust.checked = false; this.render();
|
||||
}));
|
||||
this.get('[data-new-rule]').addEventListener('click', () => this.openRuleEditor());
|
||||
this.get('[data-cancel-rule]').addEventListener('click', () => this.ruleDialog.close());
|
||||
this.ruleDialog.addEventListener('cancel', (event) => { if (this.savingRule) event.preventDefault(); });
|
||||
this.ruleForm.addEventListener('submit', (event) => { event.preventDefault(); void this.saveRule(); });
|
||||
this.trust.addEventListener('change', () => this.render());
|
||||
this.get('[data-key-accept]').addEventListener('click', () => {
|
||||
const pending = this.pendingKey;
|
||||
@@ -105,7 +146,10 @@ export class ForwardPage {
|
||||
void this.launch(this.generation);
|
||||
});
|
||||
window.addEventListener('pagehide', () => this.suspend());
|
||||
window.addEventListener('auth-required', () => this.stop());
|
||||
window.addEventListener('auth-required', () => {
|
||||
this.ruleGeneration++; this.rules = []; this.rulesError = ''; this.selectedRuleId = undefined;
|
||||
this.ruleDialog.close(); this.stop();
|
||||
});
|
||||
}
|
||||
|
||||
private get<T extends HTMLElement>(selector: string): T { return this.root.querySelector<T>(selector)!; }
|
||||
@@ -126,6 +170,7 @@ export class ForwardPage {
|
||||
|
||||
show(): void {
|
||||
this.root.hidden = false; this.get('h1').focus();
|
||||
void this.loadRules();
|
||||
void api<{ previewAvailable: boolean }>('/api/forwarding').then((config) => {
|
||||
this.previewAvailable = config.previewAvailable === true; this.render();
|
||||
}).catch(() => { this.previewAvailable = false; this.render(); });
|
||||
@@ -139,10 +184,22 @@ export class ForwardPage {
|
||||
}
|
||||
|
||||
private render(): void {
|
||||
const isolated = this.mode.value === 'isolated';
|
||||
this.select.disabled = this.busy || this.ready;
|
||||
this.port.disabled = this.busy || this.ready;
|
||||
this.mode.disabled = this.busy || this.ready;
|
||||
const isolated = this.activeMode === 'isolated';
|
||||
const selected = this.rules.find((rule) => rule.id === this.selectedRuleId);
|
||||
const host = this.hosts.find((item) => item.id === selected?.hostId);
|
||||
const connection = this.get('[data-connection]');
|
||||
connection.hidden = !selected && !this.ready && !this.busy;
|
||||
this.get('[data-selected-name]').textContent = selected?.name ?? '当前转发';
|
||||
this.get('[data-selected-target]').textContent = selected
|
||||
? `${host?.name ?? '主机已删除'} · ${host?.host ?? '无法连接'} → 127.0.0.1:${selected.port}`
|
||||
: `127.0.0.1:${this.port.value}`;
|
||||
this.get('[data-new-rule]').toggleAttribute('disabled', this.busy || this.ready || this.rulesLoading);
|
||||
const switcher = this.get<HTMLElement>('.forward-switch');
|
||||
switcher.dataset.activeMode = this.activeMode;
|
||||
this.root.querySelectorAll<HTMLButtonElement>('[data-mode]').forEach((button) => {
|
||||
button.setAttribute('aria-pressed', String(button.dataset.mode === this.activeMode));
|
||||
button.disabled = this.busy || this.ready;
|
||||
});
|
||||
this.trust.disabled = this.busy || this.ready || isolated;
|
||||
this.trust.required = !isolated;
|
||||
this.get('.forward-trust').hidden = isolated;
|
||||
@@ -150,13 +207,115 @@ export class ForwardPage {
|
||||
this.get('[data-preview-setup]').hidden = !isolated || this.previewAvailable;
|
||||
this.get('[data-mode-badge]').textContent = isolated ? '跨站隔离预览' : '标准路径转发';
|
||||
this.get('#forward-mode-help').textContent = isolated
|
||||
? (this.previewAvailable ? '独立预览 Worker 已启用。网站内容将在另一个站点打开。' : '需要先运行「部署预览 Worker」工作流。未启用时不会降级转发。')
|
||||
: '无需第二个 Worker;与主站同源,仅可访问可信网站。连接后请先停止再切换方式。';
|
||||
this.startButton.disabled = this.busy || this.ready || !this.hosts.length || (isolated ? !this.previewAvailable : !this.trust.checked);
|
||||
? (this.previewAvailable ? '独立 Worker 已启用,网站在另一个站点打开。' : '需要先部署独立预览 Worker;未启用时无法连接。')
|
||||
: '通过当前 Worker 打开可信网站;与 EdgeSSH 主站同源。';
|
||||
this.startButton.disabled = this.busy || this.ready || !host || (isolated ? !this.previewAvailable : !this.trust.checked);
|
||||
this.startButton.textContent = this.busy ? '正在连接…' : '连接并打开网站 ↗';
|
||||
this.stopButton.disabled = !this.busy && !this.ready;
|
||||
this.openButton.hidden = !this.ready;
|
||||
this.openButton.disabled = this.busy;
|
||||
this.renderRules();
|
||||
}
|
||||
|
||||
private renderRules(): void {
|
||||
const list = this.get('[data-rules]');
|
||||
list.replaceChildren();
|
||||
const rules = this.rules.filter((rule) => rule.mode === this.activeMode);
|
||||
this.get('[data-rule-count]').textContent = `${rules.length} 条规则 · 云端加密保存`;
|
||||
this.get('[data-rule-message]').textContent = this.rulesLoading ? '正在加载规则…' : this.rulesError;
|
||||
if (this.rulesLoading || this.rulesError) return;
|
||||
if (!rules.length) {
|
||||
const empty = document.createElement('div'); empty.className = 'forward-empty';
|
||||
empty.innerHTML = '<strong>这里还没有转发规则</strong><p>点击右上角新建,选择主机并保存网站端口。</p>';
|
||||
list.append(empty); return;
|
||||
}
|
||||
for (const rule of rules) {
|
||||
const host = this.hosts.find((item) => item.id === rule.hostId);
|
||||
const card = document.createElement('article');
|
||||
card.className = `forward-rule${this.selectedRuleId === rule.id ? ' selected' : ''}`;
|
||||
const choose = document.createElement('button'); choose.type = 'button'; choose.className = 'forward-rule-select';
|
||||
choose.setAttribute('aria-label', `选择规则 ${rule.name}`);
|
||||
choose.setAttribute('aria-pressed', String(this.selectedRuleId === rule.id));
|
||||
choose.disabled = this.busy || this.ready || !host;
|
||||
const symbol = document.createElement('span'); symbol.className = 'forward-rule-symbol'; symbol.textContent = rule.name.slice(0, 1).toUpperCase();
|
||||
const copy = document.createElement('span'); copy.className = 'forward-rule-copy';
|
||||
const name = document.createElement('strong'); name.textContent = rule.name;
|
||||
const target = document.createElement('small'); target.textContent = host
|
||||
? `${host.name} · ${host.host} → 127.0.0.1:${rule.port}` : '主机已删除 · 编辑规则以重新选择主机';
|
||||
copy.append(name, target); choose.append(symbol, copy);
|
||||
choose.addEventListener('click', () => {
|
||||
this.selectedRuleId = rule.id; this.select.value = rule.hostId; this.port.value = String(rule.port);
|
||||
this.trust.checked = false; this.message('未连接 · 核对规则后点击连接'); this.render();
|
||||
this.get('[data-connection]').scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||
});
|
||||
const actions = document.createElement('div'); actions.className = 'forward-rule-actions';
|
||||
const edit = document.createElement('button'); edit.type = 'button'; edit.textContent = '编辑'; edit.setAttribute('aria-label', `编辑规则 ${rule.name}`);
|
||||
edit.disabled = this.busy || this.ready; edit.addEventListener('click', () => this.openRuleEditor(rule));
|
||||
const remove = document.createElement('button'); remove.type = 'button'; remove.textContent = '删除'; remove.setAttribute('aria-label', `删除规则 ${rule.name}`);
|
||||
remove.disabled = this.busy || this.ready; remove.addEventListener('click', () => void this.removeRule(rule));
|
||||
actions.append(edit, remove); card.append(choose, actions); list.append(card);
|
||||
}
|
||||
}
|
||||
|
||||
private async loadRules(): Promise<void> {
|
||||
const generation = ++this.ruleGeneration;
|
||||
this.rulesLoading = true; this.rulesError = ''; this.render();
|
||||
try {
|
||||
const { rules } = await api<{ rules: ForwardRule[] }>('/api/forward-rules');
|
||||
if (generation !== this.ruleGeneration) return;
|
||||
this.rules = rules;
|
||||
if (!this.selectedRuleId && this.sessionId) this.selectedRuleId = localStorage.getItem(FORWARD_RULE_SESSION_KEY) ?? undefined;
|
||||
} catch (error) {
|
||||
if (generation === this.ruleGeneration) this.rulesError = error instanceof Error ? error.message : '规则加载失败,请重试。';
|
||||
} finally {
|
||||
if (generation === this.ruleGeneration) { this.rulesLoading = false; this.render(); }
|
||||
}
|
||||
}
|
||||
|
||||
private openRuleEditor(rule?: ForwardRule): void {
|
||||
if (this.busy || this.ready || this.rulesLoading) return;
|
||||
this.editingRuleId = rule?.id;
|
||||
this.ruleForm.reset();
|
||||
this.ruleForm.querySelector<HTMLInputElement>('[name="name"]')!.value = rule?.name ?? '';
|
||||
this.select.value = rule?.hostId ?? '';
|
||||
this.port.value = String(rule?.port ?? 8080);
|
||||
this.get('#forward-rule-title').textContent = rule ? '编辑转发规则' : '新建转发规则';
|
||||
this.get('[data-rule-error]').hidden = true;
|
||||
this.ruleDialog.showModal();
|
||||
this.ruleForm.querySelector<HTMLInputElement>('[name="name"]')!.focus();
|
||||
}
|
||||
|
||||
private async saveRule(): Promise<void> {
|
||||
if (this.savingRule || !this.ruleForm.reportValidity()) return;
|
||||
this.savingRule = true;
|
||||
const save = this.ruleForm.querySelector<HTMLButtonElement>('[type="submit"]')!;
|
||||
save.disabled = true;
|
||||
const error = this.get('[data-rule-error]'); error.hidden = true;
|
||||
try {
|
||||
const { rule } = await api<{ rule: ForwardRule }>(this.editingRuleId ? `/api/forward-rules/${this.editingRuleId}` : '/api/forward-rules',
|
||||
this.editingRuleId ? 'PUT' : 'POST', {
|
||||
name: this.ruleForm.querySelector<HTMLInputElement>('[name="name"]')!.value.trim(),
|
||||
hostId: this.select.value, port: Number(this.port.value), mode: this.activeMode,
|
||||
});
|
||||
this.rules = [rule, ...this.rules.filter((item) => item.id !== rule.id)];
|
||||
this.selectedRuleId = rule.id;
|
||||
this.message('规则已保存 · 核对后点击连接');
|
||||
this.ruleDialog.close(); this.render();
|
||||
} catch (cause) {
|
||||
error.textContent = cause instanceof Error ? cause.message : '规则保存失败,请重试。'; error.hidden = false;
|
||||
} finally { this.savingRule = false; save.disabled = false; }
|
||||
}
|
||||
|
||||
private async removeRule(rule: ForwardRule): Promise<void> {
|
||||
if (!confirm(`删除转发规则「${rule.name}」?`)) return;
|
||||
try {
|
||||
await api(`/api/forward-rules/${rule.id}`, 'DELETE');
|
||||
this.rules = this.rules.filter((item) => item.id !== rule.id);
|
||||
if (this.selectedRuleId === rule.id) this.selectedRuleId = undefined;
|
||||
this.render();
|
||||
} catch (cause) {
|
||||
this.rulesError = cause instanceof Error ? cause.message : '删除失败,请重试。'; this.render();
|
||||
}
|
||||
}
|
||||
|
||||
private reservePopup(): void {
|
||||
@@ -171,10 +330,13 @@ export class ForwardPage {
|
||||
|
||||
private async start(): Promise<void> {
|
||||
if (this.busy || this.ready) return;
|
||||
if (this.mode.value === 'trusted' ? !this.trust.checked : !this.previewAvailable) return;
|
||||
const host = this.hosts.find((item) => item.id === this.select.value);
|
||||
if (this.activeMode === 'trusted' ? !this.trust.checked : !this.previewAvailable) return;
|
||||
const rule = this.rules.find((item) => item.id === this.selectedRuleId && item.mode === this.activeMode);
|
||||
if (!rule) return;
|
||||
const host = this.hosts.find((item) => item.id === rule.hostId);
|
||||
if (!host) { this.message('请先选择一台已保存的主机。'); return; }
|
||||
const port = Number(this.port.value);
|
||||
this.select.value = host.id; this.port.value = String(rule.port);
|
||||
const port = rule.port;
|
||||
if (!Number.isInteger(port) || port < 1 || port > 65535) return;
|
||||
const generation = ++this.generation;
|
||||
this.reservePopup();
|
||||
@@ -232,8 +394,8 @@ export class ForwardPage {
|
||||
try {
|
||||
const { url, expiresAt } = await api<{ url: string; expiresAt: number }>(
|
||||
`/api/forwarding?session=${this.sessionId}`, 'POST', {
|
||||
port: Number(this.port.value), mode: this.mode.value,
|
||||
...(this.mode.value === 'trusted' ? { trusted: this.trust.checked } : {}),
|
||||
port: Number(this.port.value), mode: this.activeMode,
|
||||
...(this.activeMode === 'trusted' ? { trusted: this.trust.checked } : {}),
|
||||
});
|
||||
if (generation !== this.generation) return;
|
||||
this.ready = true;
|
||||
@@ -262,6 +424,7 @@ export class ForwardPage {
|
||||
this.socket?.close(); this.socket = undefined;
|
||||
this.sessionId = undefined; this.busy = false; this.ready = false;
|
||||
localStorage.removeItem(FORWARD_SESSION_STORAGE_KEY);
|
||||
localStorage.removeItem(FORWARD_RULE_SESSION_KEY);
|
||||
if (sessionId) void api(`/api/forwarding?session=${sessionId}`, 'DELETE').catch(() => undefined);
|
||||
this.trust.checked = false;
|
||||
this.get('[data-preview-link]').hidden = true;
|
||||
@@ -288,7 +451,10 @@ export class ForwardPage {
|
||||
|
||||
private rememberSession(): void {
|
||||
// 这里只保存无权访问 SSH 的 DO 标识;账户校验和 8 分钟期限仍由 Worker 决定。
|
||||
if (this.sessionId) localStorage.setItem(FORWARD_SESSION_STORAGE_KEY, this.sessionId);
|
||||
if (this.sessionId) {
|
||||
localStorage.setItem(FORWARD_SESSION_STORAGE_KEY, this.sessionId);
|
||||
if (this.selectedRuleId) localStorage.setItem(FORWARD_RULE_SESSION_KEY, this.selectedRuleId);
|
||||
}
|
||||
}
|
||||
|
||||
private scheduleRetention(expiresAt: number): void {
|
||||
@@ -313,7 +479,8 @@ export class ForwardPage {
|
||||
}
|
||||
this.sessionId = sessionId;
|
||||
this.port.value = String(status.port);
|
||||
this.mode.value = status.mode;
|
||||
this.activeMode = status.mode;
|
||||
this.selectedRuleId = localStorage.getItem(FORWARD_RULE_SESSION_KEY) ?? undefined;
|
||||
this.trust.checked = status.mode === 'trusted';
|
||||
this.ready = true;
|
||||
this.scheduleRetention(status.expiresAt);
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
CREATE TABLE IF NOT EXISTS forward_rules (
|
||||
id TEXT PRIMARY KEY,
|
||||
account_id TEXT NOT NULL,
|
||||
encrypted_payload TEXT NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS forward_rules_account_updated ON forward_rules(account_id, updated_at DESC);
|
||||
@@ -0,0 +1,61 @@
|
||||
import type { Env } from '../types.ts';
|
||||
import { decryptHost, encryptHost } from './crypto.ts';
|
||||
import { APIError, json, readJSON } from './http.ts';
|
||||
|
||||
export interface ForwardRuleInput {
|
||||
name: string;
|
||||
hostId: string;
|
||||
port: number;
|
||||
mode: 'trusted' | 'isolated';
|
||||
}
|
||||
|
||||
interface RuleRow { id: string; encrypted_payload: string; updated_at: number }
|
||||
|
||||
export function validateForwardRule(body: Record<string, unknown>): ForwardRuleInput {
|
||||
const name = body.name;
|
||||
if (typeof name !== 'string' || !name.trim() || name.length > 80 || /[\x00-\x1f\x7f]/.test(name)) {
|
||||
throw new APIError('规则名称不能为空、超过 80 个字符或包含控制字符。');
|
||||
}
|
||||
if (typeof body.hostId !== 'string' || !/^[a-f0-9-]{36}$/.test(body.hostId)) throw new APIError('请选择已保存的主机。');
|
||||
if (typeof body.port !== 'number' || !Number.isInteger(body.port) || body.port < 1 || body.port > 65535) {
|
||||
throw new APIError('网站端口必须为 1 到 65535 的整数。');
|
||||
}
|
||||
if (body.mode !== 'trusted' && body.mode !== 'isolated') throw new APIError('转发方式无效。');
|
||||
return { name: name.trim(), hostId: body.hostId, port: body.port, mode: body.mode };
|
||||
}
|
||||
|
||||
export async function forwardRulesRoute(request: Request, env: Env, accountId: string, pathname: string): Promise<Response> {
|
||||
const match = /^\/api\/forward-rules(?:\/([a-f0-9-]{36}))?$/.exec(pathname);
|
||||
if (!match) throw new APIError('接口不存在。', 404);
|
||||
const id = match[1];
|
||||
if (!id && request.method === 'GET') {
|
||||
const rows = await env.DB.prepare('SELECT id, encrypted_payload, updated_at FROM forward_rules WHERE account_id = ? ORDER BY updated_at DESC, id')
|
||||
.bind(accountId).all<RuleRow>();
|
||||
const rules = await Promise.all(rows.results.map(async (row) => ({
|
||||
...await decryptHost<ForwardRuleInput>(row.encrypted_payload, env.ENCRYPTION_KEY, accountId, `forward-rule:${row.id}`),
|
||||
id: row.id, updatedAt: row.updated_at,
|
||||
})));
|
||||
return json({ rules });
|
||||
}
|
||||
if (id && request.method === 'DELETE') {
|
||||
const result = await env.DB.prepare('DELETE FROM forward_rules WHERE id = ? AND account_id = ?').bind(id, accountId).run();
|
||||
if (!result.meta.changes) throw new APIError('规则不存在,请刷新列表。', 404);
|
||||
return json({ ok: true });
|
||||
}
|
||||
if (id ? request.method !== 'PUT' : request.method !== 'POST') throw new APIError('不支持此请求方法。', 405);
|
||||
const payload = validateForwardRule(await readJSON(request));
|
||||
// 引用必须属于当前账户;删除主机后,规则保留并在界面标明不可连接。
|
||||
const host = await env.DB.prepare('SELECT id FROM hosts WHERE id = ? AND account_id = ?').bind(payload.hostId, accountId).first();
|
||||
if (!host) throw new APIError('所选主机不存在,请刷新主机列表。', 404);
|
||||
const ruleId = id ?? crypto.randomUUID();
|
||||
const encrypted = await encryptHost(payload, env.ENCRYPTION_KEY, accountId, `forward-rule:${ruleId}`);
|
||||
const now = Date.now();
|
||||
const result = id
|
||||
? await env.DB.prepare('UPDATE forward_rules SET encrypted_payload = ?, updated_at = ? WHERE id = ? AND account_id = ?')
|
||||
.bind(encrypted, now, id, accountId).run()
|
||||
: await env.DB.prepare(`INSERT INTO forward_rules(id, account_id, encrypted_payload, updated_at)
|
||||
SELECT ?, ?, ?, ? WHERE (SELECT COUNT(*) FROM forward_rules WHERE account_id = ?) < 200`)
|
||||
.bind(ruleId, accountId, encrypted, now, accountId).run();
|
||||
if (!result.meta.changes) throw new APIError(id ? '规则不存在,请刷新列表。' : '最多保存 200 条转发规则。', id ? 404 : 409);
|
||||
return json({ rule: { ...payload, id: ruleId, updatedAt: now } }, id ? 200 : 201);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { authRoute } from './accounts/auth-routes';
|
||||
import { authProvider } from './accounts/auth-provider';
|
||||
import { hostsRoute } from './accounts/hosts';
|
||||
import { snippetsRoute } from './accounts/snippets';
|
||||
import { forwardRulesRoute } from './accounts/forward-rules';
|
||||
import { apiFailure, json } from './accounts/http';
|
||||
import { locateHost } from './accounts/location';
|
||||
import { forwardingRoute, trustedForwardRoute } from './forwarding/routes';
|
||||
@@ -159,6 +160,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.startsWith('/api/forward-rules')) return await forwardRulesRoute(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));
|
||||
|
||||
@@ -9,6 +9,8 @@ const host = {
|
||||
|
||||
const sessionId = 'a'.repeat(64);
|
||||
const trustedURL = `http://127.0.0.1:5173/_forward/${sessionId}/`;
|
||||
const rule = { id: 'rule-trusted', name: '开发服务', hostId: 'alpha', port: 8080, mode: 'trusted', updatedAt: Date.now() };
|
||||
const isolatedRule = { ...rule, id: 'rule-isolated', name: '隔离服务', mode: 'isolated' };
|
||||
|
||||
async function forwardingFixture(page: Page, previewAvailable = true, noHosts = false) {
|
||||
const calls: Array<Record<string, unknown>> = [];
|
||||
@@ -16,11 +18,25 @@ async function forwardingFixture(page: Page, previewAvailable = true, noHosts =
|
||||
const closed: boolean[] = [];
|
||||
let active = false;
|
||||
let retainedExpiresAt = 0;
|
||||
let rules = [rule, isolatedRule];
|
||||
await page.route('**/api/**', async (route) => {
|
||||
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 === '/api/forward-rules' && route.request().method() === 'GET') return route.fulfill({ json: { rules } });
|
||||
if (path === '/api/forward-rules' && route.request().method() === 'POST') {
|
||||
const saved = { ...route.request().postDataJSON(), id: 'created-rule', updatedAt: Date.now() };
|
||||
rules = [saved, ...rules]; return route.fulfill({ status: 201, json: { rule: saved } });
|
||||
}
|
||||
if (path.startsWith('/api/forward-rules/')) {
|
||||
const id = path.slice('/api/forward-rules/'.length);
|
||||
if (route.request().method() === 'DELETE') {
|
||||
rules = rules.filter((item) => item.id !== id); return route.fulfill({ json: { ok: true } });
|
||||
}
|
||||
const saved = { ...route.request().postDataJSON(), id, updatedAt: Date.now() };
|
||||
rules = [saved, ...rules.filter((item) => item.id !== id)]; return route.fulfill({ json: { rule: saved } });
|
||||
}
|
||||
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') {
|
||||
@@ -60,60 +76,84 @@ async function forwardingFixture(page: Page, previewAvailable = true, noHosts =
|
||||
}
|
||||
|
||||
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.getByRole('button', { name: '选择规则 开发服务' }).click();
|
||||
await page.locator('.forward-page input[name="trusted"]').check();
|
||||
await page.locator('.forward-page button[type="submit"]').click();
|
||||
await page.locator('.forward-card button[type="submit"]').click();
|
||||
}
|
||||
|
||||
async function chooseForwardMode(page: Page, mode: 'trusted' | 'isolated') {
|
||||
await page.locator('.forward-page select[name="mode"]').selectOption(mode);
|
||||
await page.locator(`.forward-switch [data-mode="${mode}"]`).click();
|
||||
}
|
||||
|
||||
test('默认标准模式显示风险警告,隔离模式未配置时仍禁用连接', async ({ page }, testInfo) => {
|
||||
await forwardingFixture(page, false);
|
||||
await expect(page.locator('.forward-switch [data-mode="trusted"]')).toHaveAttribute('aria-pressed', 'true');
|
||||
await page.getByRole('button', { name: '选择规则 开发服务' }).click();
|
||||
await expect(page.locator('[data-trust-warning]')).toBeVisible();
|
||||
await expect(page.locator('.forward-page button[type="submit"]')).toBeDisabled();
|
||||
await expect(page.locator('.forward-card button[type="submit"]')).toBeDisabled();
|
||||
await page.evaluate(() => { window.scrollTo(0, 0); document.body.scrollTo(0, 0); });
|
||||
await page.screenshot({ path: testInfo.outputPath('trusted-mode.png'), fullPage: true });
|
||||
await page.locator('.forward-page select[name="host"]').selectOption('alpha');
|
||||
await expect(page.locator('.forward-page button[type="submit"]')).toBeDisabled();
|
||||
await page.locator('.forward-page input[name="trusted"]').check();
|
||||
await expect(page.locator('.forward-page button[type="submit"]')).toBeEnabled();
|
||||
await expect(page.locator('.forward-card button[type="submit"]')).toBeEnabled();
|
||||
await chooseForwardMode(page, 'isolated');
|
||||
await page.getByRole('button', { name: '选择规则 隔离服务' }).click();
|
||||
await expect(page.locator('[data-preview-setup]')).toBeVisible();
|
||||
await expect(page.locator('.forward-page button[type="submit"]')).toBeDisabled();
|
||||
await expect(page.locator('.forward-card button[type="submit"]')).toBeDisabled();
|
||||
await page.evaluate(() => { window.scrollTo(0, 0); document.body.scrollTo(0, 0); });
|
||||
await page.screenshot({ path: testInfo.outputPath('isolated-setup.png'), fullPage: true });
|
||||
await chooseForwardMode(page, 'trusted');
|
||||
await page.getByRole('button', { name: '选择规则 开发服务' }).click();
|
||||
await expect(page.locator('.forward-page input[name="trusted"]')).not.toBeChecked();
|
||||
await expect(page.locator('.forward-page button[type="submit"]')).toBeDisabled();
|
||||
await expect(page.locator('.forward-card button[type="submit"]')).toBeDisabled();
|
||||
});
|
||||
|
||||
test('已启用隔离预览无需信任勾选,提交隔离请求并在停止后解锁模式', async ({ page }) => {
|
||||
const { calls } = await forwardingFixture(page, true);
|
||||
await page.locator('.forward-page select[name="host"]').selectOption('alpha');
|
||||
await page.locator('.forward-page input[name="port"]').fill('8080');
|
||||
await chooseForwardMode(page, 'isolated');
|
||||
await page.getByRole('button', { name: '选择规则 隔离服务' }).click();
|
||||
await expect(page.locator('.forward-page input[name="trusted"]')).toBeHidden();
|
||||
await expect(page.locator('.forward-page button[type="submit"]')).toBeEnabled();
|
||||
await page.locator('.forward-page button[type="submit"]').click();
|
||||
await expect(page.locator('.forward-card button[type="submit"]')).toBeEnabled();
|
||||
await page.locator('.forward-card button[type="submit"]').click();
|
||||
await expect.poll(() => calls.find((call) => call.type === 'forwarding')?.body).toEqual({ port: 8080, mode: 'isolated' });
|
||||
await expect(page.locator('[data-preview-link]')).toHaveAttribute('href', 'https://isolated.example.net/start#xxx');
|
||||
await expect(page.locator('.forward-page select[name="mode"]')).toBeDisabled();
|
||||
await expect(page.locator('.forward-switch [data-mode="trusted"]')).toBeDisabled();
|
||||
await page.locator('[data-stop]').click();
|
||||
await expect(page.locator('.forward-page select[name="mode"]')).toBeEnabled();
|
||||
await expect(page.locator('.forward-switch [data-mode="trusted"]')).toBeEnabled();
|
||||
});
|
||||
|
||||
test('切换转发方式会清空之前的信任确认', async ({ page }) => {
|
||||
await forwardingFixture(page, true);
|
||||
await page.locator('.forward-page select[name="host"]').selectOption('alpha');
|
||||
await page.getByRole('button', { name: '选择规则 开发服务' }).click();
|
||||
await page.locator('.forward-page input[name="trusted"]').check();
|
||||
await expect(page.locator('.forward-page input[name="trusted"]')).toBeChecked();
|
||||
await chooseForwardMode(page, 'isolated');
|
||||
await chooseForwardMode(page, 'trusted');
|
||||
await expect(page.locator('.forward-page input[name="trusted"]')).not.toBeChecked();
|
||||
await expect(page.locator('.forward-page button[type="submit"]')).toBeDisabled();
|
||||
await page.getByRole('button', { name: '选择规则 开发服务' }).click();
|
||||
await expect(page.locator('.forward-card button[type="submit"]')).toBeDisabled();
|
||||
});
|
||||
|
||||
test('新建、编辑、删除云端转发规则,连接使用所选主机与端口', async ({ page }) => {
|
||||
const { calls } = await forwardingFixture(page);
|
||||
await page.getByRole('button', { name: '+ 新建规则' }).click();
|
||||
const editor = page.locator('.forward-rule-dialog');
|
||||
await editor.locator('[name="name"]').fill('测试网站');
|
||||
await editor.locator('[name="host"]').selectOption('alpha');
|
||||
await editor.locator('[name="port"]').fill('3000');
|
||||
await editor.locator('[type="submit"]').click();
|
||||
await expect(page.getByRole('button', { name: '选择规则 测试网站' })).toBeVisible();
|
||||
await page.getByRole('button', { name: '编辑规则 测试网站' }).click();
|
||||
await editor.locator('[name="name"]').fill('内部网站');
|
||||
await editor.locator('[type="submit"]').click();
|
||||
await expect(page.getByRole('button', { name: '选择规则 内部网站' })).toBeVisible();
|
||||
await page.getByRole('button', { name: '选择规则 内部网站' }).click();
|
||||
await page.locator('[name="trusted"]').check();
|
||||
await page.locator('.forward-card [type="submit"]').click();
|
||||
await expect.poll(() => calls.find((call) => call.type === 'forwarding')?.body).toEqual({ port: 3000, mode: 'trusted', trusted: true });
|
||||
await page.locator('[data-stop]').click();
|
||||
page.once('dialog', (dialog) => void dialog.accept());
|
||||
await page.getByRole('button', { name: '删除规则 内部网站' }).click();
|
||||
await expect(page.getByRole('button', { name: '选择规则 内部网站' })).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('连接转发发送 forward 协议并提供弹窗拦截回退链接,停止后解锁主机', async ({ page }, testInfo) => {
|
||||
@@ -128,7 +168,7 @@ test('连接转发发送 forward 协议并提供弹窗拦截回退链接,停
|
||||
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 expect(page.getByRole('button', { name: '选择规则 开发服务' })).toBeEnabled();
|
||||
await page.evaluate(() => { window.scrollTo(0, 0); document.body.scrollTo(0, 0); });
|
||||
await page.screenshot({ path: testInfo.outputPath('forwarding-desktop.png'), fullPage: true });
|
||||
});
|
||||
@@ -137,6 +177,7 @@ test('离开转发页后保持 8 分钟,返回页面可查看并手动停止',
|
||||
const { calls, sockets, closed } = await forwardingFixture(page);
|
||||
await startForward(page);
|
||||
await expect.poll(() => sockets.length).toBe(1);
|
||||
await expect(page.locator('[data-open]')).toBeVisible();
|
||||
await page.locator('#rail-overview').click();
|
||||
await expect.poll(() => closed[0]).toBe(true);
|
||||
await page.locator('#rail-forward').click();
|
||||
@@ -155,11 +196,12 @@ test('刷新后可从短期会话句柄恢复正在保持的转发', async ({ pa
|
||||
const { sockets } = await forwardingFixture(page);
|
||||
await startForward(page);
|
||||
await expect.poll(() => sockets.length).toBe(1);
|
||||
await expect(page.locator('[data-open]')).toBeVisible();
|
||||
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 expect(page.locator('.forward-page [data-selected-target]')).toContainText('127.0.0.1:8080');
|
||||
await expect(page.locator('.forward-switch [data-mode="trusted"]')).toHaveAttribute('aria-pressed', 'true');
|
||||
await page.locator('[data-stop]').click();
|
||||
});
|
||||
|
||||
@@ -167,9 +209,8 @@ test('空主机禁用连接按钮且桌面与 375px 手机布局没有横向溢
|
||||
await forwardingFixture(page, false, true);
|
||||
await expect(page.locator('.forward-page')).toBeVisible();
|
||||
await expect(page.locator('.forward-page h1')).toHaveText('端口转发');
|
||||
await expect(page.locator('.forward-page select[name="mode"]')).toHaveValue('trusted');
|
||||
await expect(page.locator('[data-trust-warning]')).toBeVisible();
|
||||
await expect(page.locator('.forward-page button[type="submit"]')).toBeDisabled();
|
||||
await expect(page.locator('.forward-switch [data-mode="trusted"]')).toHaveAttribute('aria-pressed', 'true');
|
||||
await expect(page.getByRole('button', { name: '选择规则 开发服务' })).toBeDisabled();
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true);
|
||||
await page.evaluate(() => { window.scrollTo(0, 0); document.body.scrollTo(0, 0); });
|
||||
await page.screenshot({ path: testInfo.outputPath('forwarding-mobile.png'), fullPage: true });
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { DatabaseSync } from 'node:sqlite';
|
||||
import { test } from 'node:test';
|
||||
import type { Env } from '../src/types.ts';
|
||||
import { decryptHost } from '../src/accounts/crypto.ts';
|
||||
import { forwardRulesRoute, validateForwardRule } from '../src/accounts/forward-rules.ts';
|
||||
|
||||
const hostId = '00000000-0000-4000-8000-000000000001';
|
||||
const input = { name: ' 服务 A ', hostId, port: 8080, mode: 'trusted' };
|
||||
|
||||
function fixture() {
|
||||
const sqlite = new DatabaseSync(':memory:');
|
||||
sqlite.exec('CREATE TABLE hosts (id TEXT PRIMARY KEY, account_id TEXT NOT NULL)');
|
||||
sqlite.exec(readFileSync(new URL('../migrations/0004_forward_rules.sql', import.meta.url), 'utf8'));
|
||||
sqlite.prepare('INSERT INTO hosts(id, account_id) VALUES (?, ?)').run(hostId, 'owner');
|
||||
const prepare = (sql: string) => {
|
||||
let params: (string | number)[] = [];
|
||||
const query = sqlite.prepare(sql);
|
||||
const statement = {
|
||||
bind(...values: (string | number)[]) { params = values; return statement; },
|
||||
async first() { return query.get(...params) ?? null; },
|
||||
async all() { return { results: query.all(...params) }; },
|
||||
async run() { return { meta: { changes: Number(query.run(...params).changes) } }; },
|
||||
};
|
||||
return statement;
|
||||
};
|
||||
const env = { ENCRYPTION_KEY: Buffer.alloc(32, 3).toString('base64'), DB: { prepare } } as unknown as Env;
|
||||
const request = async (method = 'GET', id = '', body?: unknown, owner = 'owner') => {
|
||||
const path = `/api/forward-rules${id ? `/${id}` : ''}`;
|
||||
return forwardRulesRoute(new Request(`https://example.com${path}`, {
|
||||
method, headers: { 'Content-Type': 'application/json' }, body: body === undefined ? undefined : JSON.stringify(body),
|
||||
}), env, owner, path);
|
||||
};
|
||||
return { sqlite, env, request };
|
||||
}
|
||||
|
||||
test('规则只接受有效名称、所属主机、端口与连接方式', () => {
|
||||
assert.deepEqual(validateForwardRule(input), { ...input, name: '服务 A' });
|
||||
for (const invalid of [
|
||||
{ ...input, name: ' ' }, { ...input, name: 'A'.repeat(81) }, { ...input, name: 'x\n' },
|
||||
{ ...input, hostId: 'other' }, { ...input, port: 0 }, { ...input, port: 65536 },
|
||||
{ ...input, port: '8080' }, { ...input, port: 22.5 }, { ...input, mode: 'unknown' },
|
||||
]) assert.throws(() => validateForwardRule(invalid), /规则名称|主机|端口|转发方式/);
|
||||
});
|
||||
|
||||
test('规则云端加密、按账户隔离并允许增改删', async (context) => {
|
||||
const f = fixture(); context.after(() => f.sqlite.close());
|
||||
const created = await f.request('POST', '', input);
|
||||
assert.equal(created.status, 201);
|
||||
const { rule } = await created.json() as { rule: { id: string; name: string; port: number } };
|
||||
const row = f.sqlite.prepare('SELECT encrypted_payload FROM forward_rules WHERE id = ?').get(rule.id)!;
|
||||
assert.equal(String(row.encrypted_payload).includes('服务 A'), false);
|
||||
await assert.rejects(decryptHost(String(row.encrypted_payload), f.env.ENCRYPTION_KEY, 'owner', rule.id));
|
||||
assert.equal((await (await f.request()).json() as { rules: unknown[] }).rules.length, 1);
|
||||
assert.deepEqual((await (await f.request('GET', '', undefined, 'other')).json() as { rules: unknown[] }).rules, []);
|
||||
await assert.rejects(f.request('POST', '', input, 'other'), /主机不存在/);
|
||||
await assert.rejects(f.request('PUT', rule.id, input, 'other'), /主机不存在/);
|
||||
await assert.rejects(f.request('DELETE', rule.id, undefined, 'other'), /规则不存在/);
|
||||
await f.request('PUT', rule.id, { ...input, name: '更新', port: 3000, mode: 'isolated' });
|
||||
const listed = (await (await f.request()).json() as { rules: Array<{ name: string; port: number; mode: string }> }).rules;
|
||||
assert.deepEqual([listed[0].name, listed[0].port, listed[0].mode], ['更新', 3000, 'isolated']);
|
||||
await f.request('DELETE', rule.id);
|
||||
assert.equal((await (await f.request()).json() as { rules: unknown[] }).rules.length, 0);
|
||||
});
|
||||
|
||||
test('主机删除后旧规则仍可列出,但无法保存新的无效引用', async (context) => {
|
||||
const f = fixture(); context.after(() => f.sqlite.close());
|
||||
await f.request('POST', '', input);
|
||||
f.sqlite.prepare('DELETE FROM hosts WHERE id = ?').run(hostId);
|
||||
assert.equal((await (await f.request()).json() as { rules: unknown[] }).rules.length, 1);
|
||||
await assert.rejects(f.request('POST', '', input), /主机不存在/);
|
||||
});
|
||||
Reference in new issue
Block a user