- 🛡️ 安全(Security): - 彻底修复 IPv6 及保留地址的 SSRF 绕过漏洞。 - 新增基于 IP 的连接频率限制 (10次/分钟) 防范暴力破解。 - 在 localStorage 中通过 Web Crypto (AES-GCM) 对记住的凭证进行加密存储。 - 连接时静默计算并输出服务器 Host Key 指纹 (SHA-256),兼顾安全与易用性。 - ✨ 功能(Feature): - 支持基于 Ed25519 的 SSH 纯文本私钥直接登录。 - 前端终端集成 zmodem.js,支持在终端中运行 rz/sz 实现浏览器与服务器文件的直接拖拽与无缝传输。 - 支持 WebSocket 会话漫游 (Roaming),网络短暂断开可借助 Durable Object 在 15 秒内静默重连恢复会话,免去重新输入密码的繁琐。 - 🎨 体验与界面(UX/UI): - 前端渲染引擎升级为 WebGL (@xterm/addon-webgl),大幅提升海量文本滚动的帧率与流畅度。 - 新增内置极客主题下拉框 (Cyberpunk / Glacier / Gruvbox) 及右键复制粘贴支持。
188 lines
6.3 KiB
TypeScript
188 lines
6.3 KiB
TypeScript
import { Env, SSHConnectionConfig } from '../types';
|
|
import { SSHSession } from './ssh-session';
|
|
|
|
/**
|
|
* SSRF 防护:检测目标主机是否为内网、保留或特殊地址。
|
|
* 覆盖 IPv4 私有段、IPv6 回环/链路本地/私有段、IPv4-mapped IPv6 等。
|
|
*/
|
|
function isBlockedHost(host: string): boolean {
|
|
const h = host.toLowerCase().trim();
|
|
|
|
// 特殊主机名
|
|
if (h === 'localhost' || h === 'ip6-localhost' || h === 'ip6-loopback') return true;
|
|
|
|
// IPv4 私有 / 保留地址
|
|
if (/^(127\.|10\.|0\.|192\.168\.|169\.254\.)/.test(h)) return true;
|
|
if (/^172\.(1[6-9]|2[0-9]|3[0-1])\./.test(h)) return true;
|
|
|
|
// 移除 IPv6 方括号 (e.g. [::1])
|
|
const v6 = h.replace(/^\[|\]$/g, '');
|
|
|
|
// IPv6 回环
|
|
if (v6 === '::1' || v6 === '0:0:0:0:0:0:0:1') return true;
|
|
// IPv6 未指定地址
|
|
if (v6 === '::' || v6 === '0:0:0:0:0:0:0:0') return true;
|
|
// IPv6 链路本地 (fe80::/10)
|
|
if (/^fe[89ab]/i.test(v6)) return true;
|
|
// IPv6 唯一本地 (fc00::/7)
|
|
if (/^f[cd]/i.test(v6)) return true;
|
|
// IPv4-mapped IPv6 (::ffff:127.0.0.1 等)
|
|
const v4mapped = v6.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/i);
|
|
if (v4mapped) return isBlockedHost(v4mapped[1]);
|
|
|
|
return false;
|
|
}
|
|
|
|
export class SSHSessionDO {
|
|
private state: DurableObjectState;
|
|
private env: Env;
|
|
private sessions: Map<WebSocket, SSHSession> = new Map();
|
|
private activeSessionsByRoamId: Map<string, SSHSession> = new Map();
|
|
private roamTimeouts: Map<string, ReturnType<typeof setTimeout>> = new Map();
|
|
private _pendingTimeouts: Map<WebSocket, ReturnType<typeof setTimeout>> = new Map();
|
|
|
|
constructor(state: DurableObjectState, env: Env) {
|
|
this.state = state;
|
|
this.env = env;
|
|
}
|
|
|
|
async fetch(request: Request): Promise<Response> {
|
|
const upgradeHeader = request.headers.get('Upgrade');
|
|
if (upgradeHeader !== 'websocket') {
|
|
return new Response('Expected WebSocket', { status: 400 });
|
|
}
|
|
|
|
const pair = new WebSocketPair();
|
|
const [client, server] = [pair[0], pair[1]];
|
|
|
|
// Use Hibernation API for long-lived WebSocket connections
|
|
this.state.acceptWebSocket(server);
|
|
|
|
// Set a timeout for receiving credentials
|
|
const timeout = setTimeout(() => {
|
|
try {
|
|
server.send(JSON.stringify({ type: 'error', message: 'Connection timeout' }));
|
|
server.close(1011, 'Timeout');
|
|
} catch {}
|
|
}, 10000);
|
|
|
|
// Store timeout ID so we can clear it when credentials arrive
|
|
server.serializeAttachment({ state: 'waiting', timeout: null });
|
|
// Note: we can't serialize setTimeout, so we store it in a map
|
|
this._pendingTimeouts.set(server, timeout);
|
|
|
|
return new Response(null, {
|
|
status: 101,
|
|
webSocket: client,
|
|
} as any);
|
|
}
|
|
|
|
// Hibernation API: called when a WebSocket receives a message
|
|
async webSocketMessage(ws: WebSocket, message: string | ArrayBuffer): Promise<void> {
|
|
const session = this.sessions.get(ws);
|
|
if (session) {
|
|
await session.handleWebSocketMessage(message);
|
|
return;
|
|
}
|
|
|
|
// This is the first message (credentials or roam reconnect)
|
|
const timeout = this._pendingTimeouts.get(ws);
|
|
if (timeout) {
|
|
clearTimeout(timeout);
|
|
this._pendingTimeouts.delete(ws);
|
|
}
|
|
|
|
try {
|
|
const config = JSON.parse(message as string);
|
|
|
|
if (config.roamId) {
|
|
// Attempt to resume session
|
|
const existingSession = this.activeSessionsByRoamId.get(config.roamId);
|
|
if (existingSession) {
|
|
const roamTimeout = this.roamTimeouts.get(config.roamId);
|
|
if (roamTimeout) {
|
|
clearTimeout(roamTimeout);
|
|
this.roamTimeouts.delete(config.roamId);
|
|
}
|
|
this.sessions.set(ws, existingSession);
|
|
existingSession.updateWebSocket(ws);
|
|
return;
|
|
}
|
|
// If roam session not found, fall back to new connection if config provided
|
|
}
|
|
|
|
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 as SSHConnectionConfig);
|
|
} catch (e) {
|
|
ws.send(JSON.stringify({ type: 'error', message: 'Invalid credentials format' }));
|
|
ws.close(1011, 'Invalid format');
|
|
}
|
|
}
|
|
|
|
// Hibernation API: called when a WebSocket is closed
|
|
async webSocketClose(ws: WebSocket, code: number, reason: string, wasClean: boolean): Promise<void> {
|
|
const session = this.sessions.get(ws);
|
|
if (session) {
|
|
this.sessions.delete(ws);
|
|
// Wait 15s for roam reconnect before closing session
|
|
const roamId = session.roamId;
|
|
const timeout = setTimeout(() => {
|
|
session.close();
|
|
this.activeSessionsByRoamId.delete(roamId);
|
|
this.roamTimeouts.delete(roamId);
|
|
}, 15000);
|
|
this.roamTimeouts.set(roamId, timeout);
|
|
}
|
|
const timeout = this._pendingTimeouts.get(ws);
|
|
if (timeout) {
|
|
clearTimeout(timeout);
|
|
this._pendingTimeouts.delete(ws);
|
|
}
|
|
}
|
|
|
|
// Hibernation API: called when a WebSocket error occurs
|
|
async webSocketError(ws: WebSocket, error: unknown): Promise<void> {
|
|
await this.webSocketClose(ws, 1011, 'Error', false);
|
|
}
|
|
|
|
private async initSSHSession(
|
|
ws: WebSocket,
|
|
config: SSHConnectionConfig
|
|
): Promise<void> {
|
|
try {
|
|
// --- SSRF Protection ---
|
|
if (isBlockedHost(config.host)) {
|
|
throw new Error('禁止连接内网或保留地址 (SSRF 防护)');
|
|
}
|
|
const BLOCKED_PORTS = [80, 443, 25, 465, 587, 3306, 6379, 27017, 11211];
|
|
if (BLOCKED_PORTS.includes(config.port)) {
|
|
throw new Error(`端口 ${config.port} 存在安全风险,已被禁止连接`);
|
|
}
|
|
|
|
const { connect } = await import('cloudflare:sockets');
|
|
const socket = connect({ hostname: config.host, port: config.port });
|
|
|
|
await socket.opened;
|
|
|
|
const session = new SSHSession(ws, socket, config);
|
|
this.sessions.set(ws, session);
|
|
this.activeSessionsByRoamId.set(session.roamId, session);
|
|
|
|
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 {}
|
|
}
|
|
}
|
|
}
|