diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index f8144f0..683fd18 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -2,7 +2,7 @@ name: Deploy to Cloudflare Workers on: push: - branches: [test] + branches: [test, main] jobs: deploy: @@ -23,7 +23,15 @@ jobs: pnpm install cd frontend && pnpm install - - name: Build frontend and deploy + - name: Build frontend and deploy (test) + if: github.ref == 'refs/heads/test' + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + run: pnpm run deploy:test + + - name: Build frontend and deploy (production) + if: github.ref == 'refs/heads/main' env: CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} diff --git a/README.md b/README.md index 262cd1b..05ca117 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,13 @@ - [核心特性](#features) - [架构说明](#architecture) - [快速部署](#quick-start) + - [GitHub 绑定自动部署](#方式一通过-github-绑定自动部署推荐) + - [本地命令行部署](#方式二本地命令行部署) + - [配置 Turnstile](#可选配置-turnstile-人机验证) + - [配置 GitHub OAuth](#可选配置-github-oauth-登录与服务器管理) - [开发说明](#development) + - [本地开发](#本地开发) + - [技术栈](#技术栈) - [开源协议](#license) @@ -152,10 +158,12 @@ flowchart TB #### 方式一:通过 GitHub 绑定自动部署(推荐) 1. **Fork 本仓库** 到你的 GitHub 账号。 -2. **一键部署**:登录 Cloudflare,进入 Workers & Pages 绑定你的 GitHub 账号,选择刚才 Fork 的仓库进行应用创建。 -3. **填写构建命令**:在部署设置中,请务必将"构建命令"(Build command)填写为 `pnpm run build:frontend`,然后点击保存并部署(无需填写构建输出目录)。 +2. **创建 Worker 应用**:登录 Cloudflare,进入 Workers & Pages,点击创建应用,绑定你的 GitHub 账号,选择 Fork 的仓库。 +3. **填写构建命令**:在部署设置中,将"构建命令"(Build command)填写为 `pnpm run build:frontend`,点击保存并部署。 4. **访问应用**:部署成功后,可通过默认域名 `https://cloudssh.<你的子域>.workers.dev` 访问。 -5. **绑定自定义域名**(可选):在 Cloudflare Dashboard 的 Workers 设置中,进入 "Triggers" → "Custom Domains" 添加你的域名。 +5. **绑定自定义域名**(可选):进入 Worker 的 Settings → Domains & Routes → Add,输入你的域名并确认。 + +> **说明**:如需部署 test 环境,可 Fork 后在 `test` 分支上重复上述步骤,创建独立的 Worker(如 `cloudssh-test`),两个 Worker 共享相同的 DO 类名即可实现数据互通。 #### 方式二:本地命令行部署 @@ -169,6 +177,7 @@ flowchart TB ```bash npm install -g pnpm pnpm install + cd frontend && pnpm install ``` 3. **登录 Cloudflare** @@ -176,14 +185,22 @@ flowchart TB npx wrangler login ``` -4. **一键部署** +4. **部署生产环境** ```bash pnpm run deploy ``` -部署完成后,Wrangler 会输出你的 Worker URL。打开浏览器访问该 URL,即可开始使用你的 Web SSH 终端。 +5. **部署测试环境**(可选) + ```bash + pnpm run deploy:test + ``` -5. **绑定自定义域名**(可选):在 Cloudflare Dashboard 的 Workers 设置中,进入 "Triggers" → "Custom Domains" 添加你的域名。 +| 环境 | 命令 | 默认域名 | 说明 | +|------|------|---------|------| +| Production | `pnpm run deploy` | `cloudssh.<子域>.workers.dev` | main 分支代码 | +| Test | `pnpm run deploy:test` | `cloudssh-test.<子域>.workers.dev` | test 分支代码,与生产环境共享 DO 数据 | + +> **说明**:两个环境的 Durable Objects 绑定相同的 class_name,数据完全共享。部署完成后可在 Cloudflare Dashboard 中分别绑定不同的自定义域名(Settings → Domains & Routes)。 #### 可选:配置 Turnstile 人机验证 @@ -242,21 +259,54 @@ CloudSSH/ ### 本地开发 -1. **安装依赖** +#### 环境准备 + +1. **Fork 并克隆仓库** + ```bash + git clone https://github.com/<你的用户名>/CloudSSH.git + cd CloudSSH + ``` + +2. **安装依赖**(需分别安装根目录和前端依赖) ```bash pnpm install + cd frontend && pnpm install ``` -2. **启动开发服务器** +3. **登录 Cloudflare**(首次需要,后续会缓存凭据) ```bash - pnpm run dev + npx wrangler login ``` - 此命令将构建前端并启动 Wrangler 本地开发环境。 + > **说明**:本地开发使用 Wrangler Dev 时,会连接到你的 Cloudflare 账号以使用 Durable Objects 和 TCP Sockets。SSH 连接的真实 TCP 流量会通过 Cloudflare 的基础设施转发。 -3. **仅构建前端** - ```bash - pnpm run build:frontend - ``` +#### 启动开发服务器 + +```bash +pnpm run dev +``` + +此命令将构建前端并启动 Wrangler 本地开发环境,支持: +- 前端代码变更自动重新构建 +- Worker 代码变更自动重新加载 +- 完整的 Durable Objects 和 TCP Sockets 功能 + +开发服务器启动后,访问终端输出的本地地址(通常为 `http://localhost:8787`)即可进行调试。 + +#### 常用开发命令 + +| 命令 | 说明 | +|------|------| +| `pnpm run dev` | 构建前端 + 启动 Wrangler 开发服务器 | +| `pnpm run build:frontend` | 仅构建前端(输出到 `frontend/dist/`) | +| `pnpm test` | 运行测试 | + +#### 提交 PR 的流程 + +1. 基于 `main` 分支创建你的特性分支:`git checkout -b feat/your-feature` +2. 进行开发并本地测试 +3. 提交 PR 到 `main` 分支 + +> **说明**:`test` 分支为预发布分支,用于部署测试环境,普通开发请基于 `main` 分支工作。 ### 技术栈 diff --git a/README_en.md b/README_en.md index d8558d1..1c4b7e7 100644 --- a/README_en.md +++ b/README_en.md @@ -39,7 +39,13 @@ - [Features](#features) - [Architecture](#architecture) - [Quick Deployment](#quick-start) + - [GitHub Integration](#method-1-deploy-via-github-integration-recommended) + - [Local CLI Deployment](#method-2-local-cli-deployment) + - [Configure Turnstile](#optional-configure-turnstile-human-verification) + - [Configure GitHub OAuth](#optional-configure-github-oauth-login--server-management) - [Development](#development) + - [Local Development](#local-development) + - [Tech Stack](#tech-stack) - [License](#license) @@ -152,10 +158,12 @@ This project implements a complete SSH-2.0 protocol stack: #### Method 1: Deploy via GitHub Integration (Recommended) 1. **Fork this repository** to your GitHub account. -2. **Setup Deployment**: Log in to the Cloudflare dashboard, go to Workers & Pages to connect your GitHub account, and select the forked repository. -3. **Build Command**: During the deployment configuration, make sure to enter `pnpm run build:frontend` as the Build command, then deploy with one click (the build output directory can be left blank). -4. **Access the App**: After successful deployment, you can access it via the default domain `https://cloudssh..workers.dev`. -5. **Bind Custom Domain** (Optional): In the Cloudflare Dashboard Workers settings, go to "Triggers" → "Custom Domains" to add your domain. +2. **Create Worker App**: Log in to Cloudflare, go to Workers & Pages, click Create Application, connect your GitHub account, and select the forked repository. +3. **Build Command**: During deployment settings, enter `pnpm run build:frontend` as the Build command, then save and deploy. +4. **Access the App**: After successful deployment, access via the default domain `https://cloudssh..workers.dev`. +5. **Bind Custom Domain** (Optional): Go to Worker Settings → Domains & Routes → Add, enter your domain and confirm. + +> **Note**: To deploy a test environment, repeat the above steps on the `test` branch to create a separate Worker (e.g., `cloudssh-test`). Both Workers share the same DO class names for data synchronization. #### Method 2: Local CLI Deployment @@ -169,6 +177,7 @@ This project implements a complete SSH-2.0 protocol stack: ```bash npm install -g pnpm pnpm install + cd frontend && pnpm install ``` 3. **Login to Cloudflare** @@ -176,14 +185,22 @@ This project implements a complete SSH-2.0 protocol stack: npx wrangler login ``` -4. **One-Click Deploy** +4. **Deploy Production** ```bash pnpm run deploy ``` -Once deployed, Wrangler will output your Worker URL. Open that URL in your browser to start using your Web SSH terminal. +5. **Deploy Test Environment** (Optional) + ```bash + pnpm run deploy:test + ``` -5. **Bind Custom Domain** (Optional): In the Cloudflare Dashboard Workers settings, go to "Triggers" → "Custom Domains" to add your domain. +| Environment | Command | Default Domain | Description | +|-------------|---------|---------------|-------------| +| Production | `pnpm run deploy` | `cloudssh..workers.dev` | main branch code | +| Test | `pnpm run deploy:test` | `cloudssh-test..workers.dev` | test branch code, shares DO data with production | + +> **Note**: Both environments bind to Durable Objects with the same `class_name`, sharing data completely. After deployment, you can bind different custom domains for each environment in the Cloudflare Dashboard (Settings → Domains & Routes). #### Optional: Configure Turnstile Human Verification @@ -242,21 +259,54 @@ CloudSSH/ ### Local Development -1. **Install Dependencies** +#### Environment Setup + +1. **Fork and Clone the Repository** + ```bash + git clone https://github.com//CloudSSH.git + cd CloudSSH + ``` + +2. **Install Dependencies** (root and frontend separately) ```bash pnpm install + cd frontend && pnpm install ``` -2. **Start Development Server** +3. **Login to Cloudflare** (required on first run, credentials are cached afterward) ```bash - pnpm run dev + npx wrangler login ``` - This command builds the frontend and starts the Wrangler local development environment. + > **Note**: When using Wrangler Dev for local development, it connects to your Cloudflare account to access Durable Objects and TCP Sockets. Real SSH TCP traffic is forwarded through Cloudflare's infrastructure. -3. **Build Frontend Only** - ```bash - pnpm run build:frontend - ``` +#### Start Development Server + +```bash +pnpm run dev +``` + +This command builds the frontend and starts the Wrangler local development environment, supporting: +- Automatic rebuild on frontend code changes +- Automatic reload on Worker code changes +- Full Durable Objects and TCP Sockets functionality + +After the dev server starts, visit the local address shown in the terminal (usually `http://localhost:8787`) to start debugging. + +#### Common Development Commands + +| Command | Description | +|---------|-------------| +| `pnpm run dev` | Build frontend + start Wrangler dev server | +| `pnpm run build:frontend` | Build frontend only (output to `frontend/dist/`) | +| `pnpm test` | Run tests | + +#### Submitting a PR + +1. Create your feature branch from `main`: `git checkout -b feat/your-feature` +2. Develop and test locally +3. Submit a PR to the `main` branch + +> **Note**: The `test` branch is a pre-release branch for deploying the test environment. For regular development, work based on the `main` branch. ### Tech Stack diff --git a/frontend/src/main.ts b/frontend/src/main.ts index fd3f628..ec81a74 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -9,6 +9,10 @@ let connectionForm: ConnectionForm | null = null; let serverList: ServerList | null = null; let isLoggedIn = false; +terminal.setSessionClosedHandler(() => { + showOfflineUI(); +}); + // ==================== 独立终端标签页模式 ==================== function isTerminalTab(): boolean { @@ -38,12 +42,6 @@ function initTerminalTab(): void { const ws = new WebSocket(wsUrl); ws.binaryType = 'arraybuffer'; terminal.connectWithWebSocket(ws); - - // 断开连接时关闭标签页 - document.getElementById('disconnect-btn')?.addEventListener('click', () => { - terminal.disconnect(); - window.close(); - }); } // ==================== 页面切换 ==================== @@ -81,6 +79,28 @@ function showUserSpace(user: { id: number; github_id: number; username: string; ); } +function showOfflineUI(): void { + if (isTerminalTab()) { + window.close(); + return; + } + + const termSection = document.getElementById('terminal-section'); + if (termSection) { + termSection.classList.add('hidden'); + termSection.classList.remove('flex'); + } + + if (isLoggedIn) { + document.getElementById('user-space-section')?.classList.remove('hidden'); + document.getElementById('user-space-section')?.classList.add('flex'); + } else { + showAuthSection(); + } + + document.getElementById('status-text')!.innerHTML = ' STATUS: OFFLINE'; +} + function showTerminalFromServer(wsUrl: string, serverName: string): void { document.getElementById('auth-section')!.classList.add('hidden'); document.getElementById('user-space-section')!.classList.add('hidden'); @@ -105,20 +125,7 @@ function showTerminalFromServer(wsUrl: string, serverName: string): void { document.getElementById('disconnect-btn')?.addEventListener('click', () => { terminal.disconnect(); - const termSection = document.getElementById('terminal-section')!; - termSection.classList.add('hidden'); - termSection.classList.remove('flex'); - - if (isLoggedIn) { - // 已登录用户回到用户空间 - document.getElementById('user-space-section')!.classList.remove('hidden'); - document.getElementById('user-space-section')!.classList.add('flex'); - } else { - // 匿名用户回到连接表单 - document.getElementById('auth-section')!.classList.remove('hidden'); - } - - document.getElementById('status-text')!.innerHTML = ' STATUS: OFFLINE'; + showOfflineUI(); }); // ==================== 主题切换 ==================== diff --git a/frontend/src/terminal.ts b/frontend/src/terminal.ts index 949cd4b..dd97fa2 100644 --- a/frontend/src/terminal.ts +++ b/frontend/src/terminal.ts @@ -51,6 +51,7 @@ export class SSHTerminal { private maxReconnectAttempts: number = 5; private reconnectTimeout: ReturnType | null = null; private lastConfig: SSHConnectionConfig | null = null; + private onSessionClosed?: (event: CloseEvent) => void; constructor(containerId: string) { this.container = document.getElementById(containerId)!; @@ -104,6 +105,10 @@ export class SSHTerminal { this.terminal.options.theme = THEMES[themeName]; } + setSessionClosedHandler(handler: (event: CloseEvent) => void): void { + this.onSessionClosed = handler; + } + mount(): void { this.terminal.open(this.container); @@ -152,6 +157,7 @@ export class SSHTerminal { password: config.password, authMethod: config.authMethod, privateKey: config.privateKey, + ...this.getTerminalSize(), })); this.startHeartbeat(); @@ -188,14 +194,20 @@ export class SSHTerminal { ws.onopen = () => { this.terminal.writeln('\x1b[32m[+] WebSocket connected, authenticating...\x1b[0m'); + this.sendResize(); this.startHeartbeat(); }; + if (ws.readyState === WebSocket.OPEN) { + this.sendResize(); + } + this.setupWebSocketHandlers(); } private setupWebSocketHandlers(rejectFn?: (reason?: any) => void): void { if (!this.ws) return; + const socket = this.ws; // Trzsz file transfer support this.trzszFilter = new TrzszFilter({ @@ -234,6 +246,9 @@ export class SSHTerminal { case 'error': this.terminal.writeln(`\x1b[31m[!] ${msg.message}\x1b[0m`); break; + case 'debug': + this.terminal.writeln(`\x1b[90m[DEBUG] ${msg.message}\x1b[0m`); + break; case 'pong': break; } @@ -248,6 +263,8 @@ export class SSHTerminal { }; this.ws.onclose = (event) => { + if (socket !== this.ws) return; + this.stopHeartbeat(); this.terminal.writeln( `\x1b[33m[*] Connection closed (code=${event.code})\x1b[0m` @@ -257,7 +274,12 @@ export class SSHTerminal { const statusText = document.getElementById('status-text'); if (statusText) statusText.innerHTML = ' STATUS: OFFLINE'; - if (event.code !== 1000 && this.lastConfig && this.reconnectAttempts < this.maxReconnectAttempts) { + if (event.code === 1000) { + this.onSessionClosed?.(event); + return; + } + + if (this.lastConfig && this.reconnectAttempts < this.maxReconnectAttempts) { this.scheduleReconnect(); } }; @@ -284,13 +306,7 @@ export class SSHTerminal { // Terminal resize: send to server + update trzsz column count this.disposables.push( this.terminal.onResize(({ cols, rows }) => { - if (this.ws?.readyState === WebSocket.OPEN) { - this.ws.send(JSON.stringify({ - type: 'resize', - cols, - rows, - })); - } + this.sendResize({ cols, rows }); this.trzszFilter?.setTerminalColumns(cols); }) ); @@ -309,6 +325,22 @@ export class SSHTerminal { }, 30000); } + private getTerminalSize(): { cols: number; rows: number } { + return { + cols: this.terminal.cols, + rows: this.terminal.rows, + }; + } + + private sendResize(size = this.getTerminalSize()): void { + if (this.ws?.readyState === WebSocket.OPEN) { + this.ws.send(JSON.stringify({ + type: 'resize', + ...size, + })); + } + } + private stopHeartbeat(): void { if (this.heartbeatInterval) { clearInterval(this.heartbeatInterval); @@ -333,11 +365,13 @@ export class SSHTerminal { this.clearReconnectTimeout(); this.disposeConnectionDisposables(); - if (this.ws && this.ws.readyState !== WebSocket.CLOSED && this.ws.readyState !== WebSocket.CLOSING) { - this.ws.close(1000); - } + const socket = this.ws; this.ws = null; this.trzszFilter = null; + + if (socket && socket.readyState !== WebSocket.CLOSED && socket.readyState !== WebSocket.CLOSING) { + socket.close(1000); + } } private scheduleReconnect(): void { diff --git a/package.json b/package.json index 4f3c407..e4dc2e6 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,7 @@ "scripts": { "dev": "node scripts/build-html.js && wrangler dev", "deploy": "node scripts/build-html.js && wrangler deploy", + "deploy:test": "node scripts/build-html.js && wrangler deploy --env test", "build:frontend": "node scripts/build-html.js", "test": "vitest run" }, diff --git a/src/types.ts b/src/types.ts index 2d4ac6f..a1a7ecc 100644 --- a/src/types.ts +++ b/src/types.ts @@ -46,6 +46,13 @@ export interface SSHConnectionConfig { password: string; authMethod?: 'password' | 'publickey'; privateKey?: string; + cols?: number; + rows?: number; +} + +export interface TerminalSize { + cols: number; + rows: number; } export interface Env { @@ -62,6 +69,8 @@ export interface Env { BASE_URL?: string; // 主机密钥验证严格模式(默认 true,设为 false 可跳过签名验证失败) STRICT_HOST_KEY_VERIFY?: string; + // 调试模式(设为 true 启用调试日志输出到前端) + DEBUG_MODE?: string; } export interface UserInfo { diff --git a/src/worker/durable-object.ts b/src/worker/durable-object.ts index dcecb9b..fe262a0 100644 --- a/src/worker/durable-object.ts +++ b/src/worker/durable-object.ts @@ -1,4 +1,4 @@ -import { Env, SSHConnectionConfig } from '../types'; +import { Env, SSHConnectionConfig, TerminalSize } from '../types'; import { SSHSession } from './ssh-session'; /** @@ -38,6 +38,7 @@ export class SSHSessionDO { private env: Env; private sessions: Map = new Map(); private _pendingTimeouts: Map> = new Map(); + private pendingTerminalSizes: Map = new Map(); constructor(state: DurableObjectState, env: Env) { this.state = state; @@ -105,26 +106,43 @@ export class SSHSessionDO { return; } + if (typeof message !== 'string') { + return; + } + + let msg: any; + try { + msg = JSON.parse(message); + } catch { + ws.send(JSON.stringify({ type: 'error', message: 'Invalid credentials format' })); + ws.close(1011, 'Invalid format'); + return; + } + + if (msg.type === 'resize') { + this.rememberTerminalSize(ws, msg.cols, msg.rows); + return; + } + if (msg.type === 'ping') { + ws.send(JSON.stringify({ type: 'pong' })); + return; + } + const timeout = this._pendingTimeouts.get(ws); if (timeout) { clearTimeout(timeout); this._pendingTimeouts.delete(ws); } - try { - const config = JSON.parse(message as string) as SSHConnectionConfig; + const config = msg as SSHConnectionConfig; - if (!config.host || !config.username || (!config.password && !config.privateKey)) { - ws.send(JSON.stringify({ type: 'error', message: 'Missing credentials' })); - ws.close(1011, 'Invalid credentials'); - return; - } - - await this.initSSHSession(ws, config); - } catch (e) { - ws.send(JSON.stringify({ type: 'error', message: 'Invalid credentials format' })); - ws.close(1011, 'Invalid format'); + if (!config.host || !config.username || (!config.password && !config.privateKey)) { + ws.send(JSON.stringify({ type: 'error', message: 'Missing credentials' })); + ws.close(1011, 'Invalid credentials'); + return; } + + await this.initSSHSession(ws, config); } async webSocketClose(ws: WebSocket, code: number, reason: string, wasClean: boolean): Promise { @@ -138,6 +156,7 @@ export class SSHSessionDO { clearTimeout(timeout); this._pendingTimeouts.delete(ws); } + this.pendingTerminalSizes.delete(ws); } async webSocketError(ws: WebSocket, error: unknown): Promise { @@ -165,18 +184,51 @@ export class SSHSessionDO { await socket.opened; const strictVerify = this.env.STRICT_HOST_KEY_VERIFY !== 'false'; - const session = new SSHSession(ws, socket, config, strictVerify); + const debugMode = this.env.DEBUG_MODE === 'true'; + const pendingSize = this.pendingTerminalSizes.get(ws); + if (pendingSize) { + config.cols = pendingSize.cols; + config.rows = pendingSize.rows; + } + const session = new SSHSession(ws, socket, config, strictVerify, debugMode); this.sessions.set(ws, session); + this.pendingTerminalSizes.delete(ws); await session.startHandshake(); } catch (error) { const errMsg = error instanceof Error ? error.message : String(error); - console.error('[SSH] Session error:', errMsg); try { ws.send(JSON.stringify({ type: 'error', message: `连接失败: ${errMsg}` })); ws.close(1011, 'SSH connection failed'); } catch {} } } + + private rememberTerminalSize(ws: WebSocket, cols: unknown, rows: unknown): void { + const size = this.normalizeTerminalSize(cols, rows); + if (size) this.pendingTerminalSizes.set(ws, size); + } + + private normalizeTerminalSize(cols: unknown, rows: unknown): TerminalSize | null { + if ( + typeof cols !== 'number' || + typeof rows !== 'number' || + !Number.isFinite(cols) || + !Number.isFinite(rows) + ) { + return null; + } + + const size = { + cols: Math.floor(cols), + rows: Math.floor(rows), + }; + + if (size.cols < 10 || size.cols > 1000 || size.rows < 5 || size.rows > 1000) { + return null; + } + + return size; + } } diff --git a/src/worker/html.ts b/src/worker/html.ts index 8a15870..54cc15f 100644 --- a/src/worker/html.ts +++ b/src/worker/html.ts @@ -41,15 +41,15 @@ export const HTML = ` } -