diff --git a/frontend/src/demo-hosts.ts b/frontend/src/demo-hosts.ts index 5b793b8..738af3c 100644 --- a/frontend/src/demo-hosts.ts +++ b/frontend/src/demo-hosts.ts @@ -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; } diff --git a/frontend/src/forward-page.css b/frontend/src/forward-page.css index 06f15df..2c1da69 100644 --- a/frontend/src/forward-page.css +++ b/frontend/src/forward-page.css @@ -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; } } diff --git a/frontend/src/forward-page.ts b/frontend/src/forward-page.ts index 3d65b13..d1f7459 100644 --- a/frontend/src/forward-page.ts +++ b/frontend/src/forward-page.ts @@ -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 = `

PRIVATE WEB PREVIEW

-

端口转发

通过 SSH,打开只监听在服务器本机的网站。

- 标准路径转发
- -
-
- -

默认无需部署第二个 Worker。选择隔离预览可查看启用方法。

- - - - - -
-

访问目标固定为所选服务器的 http://127.0.0.1:端口,不是你电脑的本机端口。

+

端口转发

保存常用规则,通过 SSH 打开服务器本机的网站。

+ +
+ +
+

+
+

转发规则

+

+
+
+ +

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

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

-
  1. 选择云端主机,输入网站的 HTTP 端口。
  2. 核对 SSH 主机指纹,连接后自动打开预览。
  3. 离开此页面后转发保持 8 分钟;重新进入可查看或立即停止。
+
  1. 新建规则,选择主机并填写网站的 HTTP 端口。
  2. 选择规则并核对 SSH 主机指纹,连接后自动打开预览。
  3. 离开此页面后转发保持 8 分钟;重新进入可查看或立即停止。

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

+ +

SAVED FORWARD

新建转发规则

+

规则会加密保存在云端。网站仅在你点击连接后打开。

+
+ +
+

将转发所选主机的 127.0.0.1:端口。连接方式由顶部滑块决定。

+ +
+
+

核对 SSH 主机指纹


@@ -81,13 +113,22 @@ export class ForwardPage {
       
`; 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('[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(selector: string): T { return this.root.querySelector(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('.forward-switch'); + switcher.dataset.activeMode = this.activeMode; + this.root.querySelectorAll('[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 = '这里还没有转发规则

点击右上角新建,选择主机并保存网站端口。

'; + 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 { + 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('[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('[name="name"]')!.focus(); + } + + private async saveRule(): Promise { + if (this.savingRule || !this.ruleForm.reportValidity()) return; + this.savingRule = true; + const save = this.ruleForm.querySelector('[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('[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 { + 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 { 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); diff --git a/migrations/0004_forward_rules.sql b/migrations/0004_forward_rules.sql new file mode 100644 index 0000000..ba7420d --- /dev/null +++ b/migrations/0004_forward_rules.sql @@ -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); diff --git a/src/accounts/forward-rules.ts b/src/accounts/forward-rules.ts new file mode 100644 index 0000000..1f9c1f0 --- /dev/null +++ b/src/accounts/forward-rules.ts @@ -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): 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 { + 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(); + const rules = await Promise.all(rows.results.map(async (row) => ({ + ...await decryptHost(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); +} diff --git a/src/worker.ts b/src/worker.ts index c2057e3..d77a7c9 100644 --- a/src/worker.ts +++ b/src/worker.ts @@ -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)); diff --git a/tests/browser/forwarding.spec.ts b/tests/browser/forwarding.spec.ts index 7f188c8..aac38fd 100644 --- a/tests/browser/forwarding.spec.ts +++ b/tests/browser/forwarding.spec.ts @@ -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> = []; @@ -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 }); diff --git a/tests/forward-rules.test.ts b/tests/forward-rules.test.ts new file mode 100644 index 0000000..28eb43a --- /dev/null +++ b/tests/forward-rules.test.ts @@ -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), /主机不存在/); +});