feat: 纯 Cloudflare 架构 Web SSH 工具
- Cloudflare Workers + Durable Objects - 自实现 SSH 协议引擎(ECDH-P256 + AES-256-GCM) - 前端:Vite + TypeScript + xterm.js - 支持密码认证、终端渲染、窗口自适应
This commit is contained in:
1 parent
8dc0d2b1cc
commit
b95635bdd5
27 files changed
+5991
No files matched your search
@@ -0,0 +1,6 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.wrangler/
|
||||
.dev.vars
|
||||
*.log
|
||||
.DS_Store
|
||||
@@ -0,0 +1,29 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>CloudSSH</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;600&family=Inter:wght@400;500;600&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="/src/style.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<div id="auth-section">
|
||||
<div class="auth-header">
|
||||
<h1>CloudSSH</h1>
|
||||
<p>纯 Cloudflare 驱动的 Web SSH 终端</p>
|
||||
</div>
|
||||
<div id="connection-form"></div>
|
||||
</div>
|
||||
|
||||
<div id="toolbar" style="display: none;">
|
||||
<span id="connection-info"></span>
|
||||
<button id="disconnect-btn">断开连接</button>
|
||||
</div>
|
||||
<div id="terminal-container" style="display: none;"></div>
|
||||
</div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+1187
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "cloudssh-frontend",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@xterm/xterm": "^5.5.0",
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
"@xterm/addon-web-links": "^0.11.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.7.0",
|
||||
"vite": "^6.0.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { SSHTerminal, SSHConnectionConfig } from './terminal';
|
||||
|
||||
export class ConnectionForm {
|
||||
private terminal: SSHTerminal;
|
||||
|
||||
constructor(terminal: SSHTerminal) {
|
||||
this.terminal = terminal;
|
||||
this.render();
|
||||
}
|
||||
|
||||
private render(): void {
|
||||
const form = document.getElementById('connection-form')!;
|
||||
|
||||
form.innerHTML = `
|
||||
<div class="form-group">
|
||||
<label for="host">主机地址</label>
|
||||
<input type="text" id="host" placeholder="192.168.1.100 或 example.com" required />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="port">端口</label>
|
||||
<input type="number" id="port" value="22" min="1" max="65535" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="username">用户名</label>
|
||||
<input type="text" id="username" placeholder="root" required />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="password">密码</label>
|
||||
<input type="password" id="password" required />
|
||||
</div>
|
||||
<button type="button" id="connect-btn" class="btn-connect">
|
||||
连接
|
||||
</button>
|
||||
`;
|
||||
|
||||
document.getElementById('connect-btn')!.addEventListener('click', () => {
|
||||
this.handleConnect();
|
||||
});
|
||||
|
||||
form.addEventListener('keypress', (e) => {
|
||||
if (e.key === 'Enter') this.handleConnect();
|
||||
});
|
||||
}
|
||||
|
||||
private async handleConnect(): Promise<void> {
|
||||
const host = (document.getElementById('host') as HTMLInputElement).value;
|
||||
const port = parseInt(
|
||||
(document.getElementById('port') as HTMLInputElement).value || '22'
|
||||
);
|
||||
const username = (document.getElementById('username') as HTMLInputElement).value;
|
||||
const password = (document.getElementById('password') as HTMLInputElement).value;
|
||||
|
||||
if (!host || !username || !password) {
|
||||
alert('请填写所有必填字段');
|
||||
return;
|
||||
}
|
||||
|
||||
document.getElementById('connection-form')!.style.display = 'none';
|
||||
document.getElementById('terminal-container')!.style.display = 'block';
|
||||
document.getElementById('toolbar')!.style.display = 'flex';
|
||||
|
||||
document.getElementById('connection-info')!.textContent =
|
||||
`${username}@${host}:${port}`;
|
||||
|
||||
this.terminal.mount();
|
||||
|
||||
try {
|
||||
await this.terminal.connect({ host, port, username, password });
|
||||
} catch (error) {
|
||||
document.getElementById('connection-form')!.style.display = 'block';
|
||||
document.getElementById('terminal-container')!.style.display = 'none';
|
||||
document.getElementById('toolbar')!.style.display = 'none';
|
||||
alert('连接失败,请检查连接信息');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { SSHTerminal } from './terminal';
|
||||
import { ConnectionForm } from './auth-form';
|
||||
|
||||
const terminal = new SSHTerminal('terminal-container');
|
||||
const form = new ConnectionForm(terminal);
|
||||
|
||||
document.getElementById('disconnect-btn')?.addEventListener('click', () => {
|
||||
terminal.disconnect();
|
||||
document.getElementById('connection-form')!.style.display = 'block';
|
||||
document.getElementById('terminal-container')!.style.display = 'none';
|
||||
document.getElementById('toolbar')!.style.display = 'none';
|
||||
});
|
||||
@@ -0,0 +1,175 @@
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
|
||||
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
|
||||
color: #e0e0e0;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
#app {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
#auth-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 80vh;
|
||||
}
|
||||
|
||||
.auth-header {
|
||||
text-align: center;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.auth-header h1 {
|
||||
font-size: 3rem;
|
||||
font-weight: 600;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.auth-header p {
|
||||
color: #8892b0;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
#connection-form {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-radius: 12px;
|
||||
padding: 2rem;
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
backdrop-filter: blur(10px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
color: #a8b2d1;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.form-group input {
|
||||
width: 100%;
|
||||
padding: 0.75rem 1rem;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 8px;
|
||||
color: #e0e0e0;
|
||||
font-size: 1rem;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.form-group input:focus {
|
||||
outline: none;
|
||||
border-color: #667eea;
|
||||
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.3);
|
||||
}
|
||||
|
||||
.form-group input::placeholder {
|
||||
color: #5a6a8a;
|
||||
}
|
||||
|
||||
.btn-connect {
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.btn-connect:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 20px rgba(102, 126, 234, 0.4);
|
||||
}
|
||||
|
||||
.btn-connect:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
#toolbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.75rem 1rem;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
border-radius: 8px 8px 0 0;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
#connection-info {
|
||||
color: #a8b2d1;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
#disconnect-btn {
|
||||
padding: 0.5rem 1rem;
|
||||
background: rgba(234, 108, 115, 0.2);
|
||||
color: #ea6c73;
|
||||
border: 1px solid rgba(234, 108, 115, 0.3);
|
||||
border-radius: 6px;
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
#disconnect-btn:hover {
|
||||
background: rgba(234, 108, 115, 0.3);
|
||||
border-color: rgba(234, 108, 115, 0.5);
|
||||
}
|
||||
|
||||
#terminal-container {
|
||||
background: #0a0e14;
|
||||
border-radius: 0 0 8px 8px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-top: none;
|
||||
padding: 0.5rem;
|
||||
min-height: 500px;
|
||||
}
|
||||
|
||||
#terminal-container .xterm {
|
||||
height: 500px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
#app {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.auth-header h1 {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
#connection-form {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
#terminal-container .xterm {
|
||||
height: 400px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
import { Terminal } from '@xterm/xterm';
|
||||
import { FitAddon } from '@xterm/addon-fit';
|
||||
import { WebLinksAddon } from '@xterm/addon-web-links';
|
||||
import '@xterm/xterm/css/xterm.css';
|
||||
|
||||
export interface SSHConnectionConfig {
|
||||
host: string;
|
||||
port: number;
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export class SSHTerminal {
|
||||
private terminal: Terminal;
|
||||
private fitAddon: FitAddon;
|
||||
private ws: WebSocket | null = null;
|
||||
private container: HTMLElement;
|
||||
|
||||
constructor(containerId: string) {
|
||||
this.container = document.getElementById(containerId)!;
|
||||
|
||||
this.terminal = new Terminal({
|
||||
cursorBlink: true,
|
||||
cursorStyle: 'block',
|
||||
fontSize: 14,
|
||||
fontFamily: '"JetBrains Mono", "Fira Code", "Cascadia Code", monospace',
|
||||
theme: {
|
||||
background: '#0a0e14',
|
||||
foreground: '#b3b1ad',
|
||||
cursor: '#e6b450',
|
||||
cursorAccent: '#0a0e14',
|
||||
selectionBackground: '#273747',
|
||||
black: '#01060e',
|
||||
red: '#ea6c73',
|
||||
green: '#91b362',
|
||||
yellow: '#f9af4f',
|
||||
blue: '#53bdfa',
|
||||
magenta: '#fae994',
|
||||
cyan: '#90e1c6',
|
||||
white: '#c7c7c7',
|
||||
brightBlack: '#686868',
|
||||
brightRed: '#f07178',
|
||||
brightGreen: '#c2d94c',
|
||||
brightYellow: '#ffb454',
|
||||
brightBlue: '#59c2ff',
|
||||
brightMagenta: '#ffee99',
|
||||
brightCyan: '#95e6cb',
|
||||
brightWhite: '#ffffff',
|
||||
},
|
||||
allowProposedApi: true,
|
||||
scrollback: 10000,
|
||||
});
|
||||
|
||||
this.fitAddon = new FitAddon();
|
||||
this.terminal.loadAddon(this.fitAddon);
|
||||
this.terminal.loadAddon(new WebLinksAddon());
|
||||
|
||||
window.addEventListener('resize', () => this.fit());
|
||||
}
|
||||
|
||||
mount(): void {
|
||||
this.terminal.open(this.container);
|
||||
this.fit();
|
||||
|
||||
this.terminal.writeln('\x1b[1;33m╔══════════════════════════════════╗\x1b[0m');
|
||||
this.terminal.writeln('\x1b[1;33m║ CloudSSH Web Terminal ║\x1b[0m');
|
||||
this.terminal.writeln('\x1b[1;33m║ Powered by Cloudflare ║\x1b[0m');
|
||||
this.terminal.writeln('\x1b[1;33m╚══════════════════════════════════╝\x1b[0m');
|
||||
this.terminal.writeln('');
|
||||
}
|
||||
|
||||
async connect(config: SSHConnectionConfig): Promise<void> {
|
||||
this.terminal.writeln(
|
||||
`\x1b[36m正在连接 ${config.username}@${config.host}:${config.port}...\x1b[0m`
|
||||
);
|
||||
|
||||
const wsUrl = `wss://${window.location.host}/api/ssh?` +
|
||||
`host=${encodeURIComponent(config.host)}` +
|
||||
`&port=${config.port}` +
|
||||
`&user=${encodeURIComponent(config.username)}` +
|
||||
`&pass=${encodeURIComponent(config.password)}`;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
this.ws = new WebSocket(wsUrl);
|
||||
|
||||
this.ws.onopen = () => {
|
||||
this.terminal.writeln('\x1b[32mWebSocket 连接已建立\x1b[0m');
|
||||
resolve();
|
||||
};
|
||||
|
||||
this.ws.onmessage = (event) => {
|
||||
if (typeof event.data === 'string') {
|
||||
try {
|
||||
const msg = JSON.parse(event.data);
|
||||
switch (msg.type) {
|
||||
case 'status':
|
||||
this.terminal.writeln(`\x1b[32m[系统] ${msg.message}\x1b[0m`);
|
||||
break;
|
||||
case 'error':
|
||||
this.terminal.writeln(`\x1b[31m[错误] ${msg.message}\x1b[0m`);
|
||||
break;
|
||||
}
|
||||
} catch {
|
||||
this.terminal.write(event.data);
|
||||
}
|
||||
} else {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
this.terminal.write(new Uint8Array(reader.result as ArrayBuffer));
|
||||
};
|
||||
reader.readAsArrayBuffer(event.data);
|
||||
}
|
||||
};
|
||||
|
||||
this.ws.onclose = (event) => {
|
||||
this.terminal.writeln(
|
||||
`\x1b[33m[连接关闭] code=${event.code}\x1b[0m`
|
||||
);
|
||||
};
|
||||
|
||||
this.ws.onerror = () => {
|
||||
this.terminal.writeln('\x1b[31m[连接错误]\x1b[0m');
|
||||
reject(new Error('WebSocket connection failed'));
|
||||
};
|
||||
|
||||
this.terminal.onData((data) => {
|
||||
if (this.ws?.readyState === WebSocket.OPEN) {
|
||||
this.ws.send(data);
|
||||
}
|
||||
});
|
||||
|
||||
this.terminal.onResize(({ cols, rows }) => {
|
||||
if (this.ws?.readyState === WebSocket.OPEN) {
|
||||
this.ws.send(JSON.stringify({
|
||||
type: 'resize',
|
||||
cols,
|
||||
rows,
|
||||
}));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
fit(): void {
|
||||
this.fitAddon.fit();
|
||||
}
|
||||
|
||||
disconnect(): void {
|
||||
this.ws?.close();
|
||||
this.ws = null;
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.disconnect();
|
||||
this.terminal.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ES2022",
|
||||
"moduleResolution": "bundler",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
export default defineConfig({
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8787',
|
||||
ws: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
emptyOutDir: true,
|
||||
},
|
||||
});
|
||||
Generated
+2999
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "cloudssh",
|
||||
"version": "1.0.0",
|
||||
"description": "纯 Cloudflare 架构 Web SSH 工具",
|
||||
"main": "src/worker/index.ts",
|
||||
"scripts": {
|
||||
"dev": "wrangler dev",
|
||||
"deploy": "wrangler deploy",
|
||||
"build:frontend": "cd frontend && npm run build",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"keywords": ["ssh", "cloudflare", "web-terminal"],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"devDependencies": {
|
||||
"@cloudflare/workers-types": "^4.20241230.0",
|
||||
"typescript": "^5.7.0",
|
||||
"wrangler": "^3.99.0",
|
||||
"vitest": "^3.0.0"
|
||||
},
|
||||
"dependencies": {}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { SSH_MSG_USERAUTH_REQUEST, SSH_MSG_USERAUTH_SUCCESS, SSH_MSG_USERAUTH_FAILURE, AuthResult } from '../types';
|
||||
import { encodeString, concat, readUint32 } from './utils';
|
||||
|
||||
export class SSHAuth {
|
||||
static buildPasswordAuthRequest(
|
||||
username: string,
|
||||
password: string
|
||||
): Uint8Array {
|
||||
const parts: Uint8Array[] = [
|
||||
new Uint8Array([SSH_MSG_USERAUTH_REQUEST]),
|
||||
encodeString(username),
|
||||
encodeString('ssh-connection'),
|
||||
encodeString('password'),
|
||||
new Uint8Array([0x00]),
|
||||
encodeString(password),
|
||||
];
|
||||
|
||||
return concat(...parts);
|
||||
}
|
||||
|
||||
static handleResponse(payload: Uint8Array): AuthResult {
|
||||
const msgType = payload[0];
|
||||
|
||||
switch (msgType) {
|
||||
case SSH_MSG_USERAUTH_SUCCESS:
|
||||
return { success: true };
|
||||
|
||||
case SSH_MSG_USERAUTH_FAILURE: {
|
||||
const len = readUint32(payload, 1);
|
||||
const methods = new TextDecoder().decode(
|
||||
payload.slice(5, 5 + len)
|
||||
);
|
||||
return {
|
||||
success: false,
|
||||
allowedMethods: methods.split(','),
|
||||
};
|
||||
}
|
||||
|
||||
default:
|
||||
throw new Error(`Unexpected auth message type: ${msgType}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import {
|
||||
SSH_MSG_CHANNEL_OPEN,
|
||||
SSH_MSG_CHANNEL_OPEN_CONFIRMATION,
|
||||
SSH_MSG_CHANNEL_WINDOW_ADJUST,
|
||||
SSH_MSG_CHANNEL_DATA,
|
||||
SSH_MSG_CHANNEL_EOF,
|
||||
SSH_MSG_CHANNEL_CLOSE,
|
||||
SSH_MSG_CHANNEL_REQUEST,
|
||||
SSH_MSG_CHANNEL_SUCCESS,
|
||||
SSH_MSG_CHANNEL_FAILURE
|
||||
} from '../types';
|
||||
import { encodeString, encodeUint32, readUint32, concat } from './utils';
|
||||
|
||||
export class SSHChannel {
|
||||
private localChannelID: number = 0;
|
||||
private remoteChannelID: number = 0;
|
||||
private localWindowSize: number = 2097152;
|
||||
private remoteWindowSize: number = 0;
|
||||
private maxPacketSize: number = 32768;
|
||||
|
||||
buildOpenSession(): Uint8Array {
|
||||
this.localChannelID = 0;
|
||||
|
||||
return concat(
|
||||
new Uint8Array([SSH_MSG_CHANNEL_OPEN]),
|
||||
encodeString('session'),
|
||||
encodeUint32(this.localChannelID),
|
||||
encodeUint32(this.localWindowSize),
|
||||
encodeUint32(this.maxPacketSize)
|
||||
);
|
||||
}
|
||||
|
||||
handleOpenConfirmation(payload: Uint8Array): void {
|
||||
let offset = 1;
|
||||
offset += 4;
|
||||
this.remoteChannelID = readUint32(payload, offset);
|
||||
offset += 4;
|
||||
this.remoteWindowSize = readUint32(payload, offset);
|
||||
offset += 4;
|
||||
const serverMaxPacket = readUint32(payload, offset);
|
||||
this.maxPacketSize = Math.min(this.maxPacketSize, serverMaxPacket);
|
||||
}
|
||||
|
||||
buildPTYRequest(cols: number, rows: number): Uint8Array {
|
||||
const modes = new Uint8Array([0]);
|
||||
|
||||
return concat(
|
||||
new Uint8Array([SSH_MSG_CHANNEL_REQUEST]),
|
||||
encodeUint32(this.remoteChannelID),
|
||||
encodeString('pty-req'),
|
||||
new Uint8Array([0x01]),
|
||||
encodeString('xterm-256color'),
|
||||
encodeUint32(cols),
|
||||
encodeUint32(rows),
|
||||
encodeUint32(0),
|
||||
encodeUint32(0),
|
||||
encodeString(modes)
|
||||
);
|
||||
}
|
||||
|
||||
buildShellRequest(): Uint8Array {
|
||||
return concat(
|
||||
new Uint8Array([SSH_MSG_CHANNEL_REQUEST]),
|
||||
encodeUint32(this.remoteChannelID),
|
||||
encodeString('shell'),
|
||||
new Uint8Array([0x01])
|
||||
);
|
||||
}
|
||||
|
||||
buildChannelData(data: Uint8Array): Uint8Array {
|
||||
return concat(
|
||||
new Uint8Array([SSH_MSG_CHANNEL_DATA]),
|
||||
encodeUint32(this.remoteChannelID),
|
||||
encodeUint32(data.length),
|
||||
data
|
||||
);
|
||||
}
|
||||
|
||||
handleChannelData(payload: Uint8Array): Uint8Array {
|
||||
let offset = 1;
|
||||
offset += 4;
|
||||
const dataLen = readUint32(payload, offset);
|
||||
offset += 4;
|
||||
return payload.slice(offset, offset + dataLen);
|
||||
}
|
||||
|
||||
buildWindowChange(cols: number, rows: number): Uint8Array {
|
||||
return concat(
|
||||
new Uint8Array([SSH_MSG_CHANNEL_REQUEST]),
|
||||
encodeUint32(this.remoteChannelID),
|
||||
encodeString('window-change'),
|
||||
new Uint8Array([0x00]),
|
||||
encodeUint32(cols),
|
||||
encodeUint32(rows),
|
||||
encodeUint32(0),
|
||||
encodeUint32(0)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
export class SSHAESGCMCipher {
|
||||
private key: CryptoKey | null = null;
|
||||
private baseIV: Uint8Array;
|
||||
private seqNum: number = 0;
|
||||
private rawKey: Uint8Array;
|
||||
|
||||
constructor(rawKey: Uint8Array, iv: Uint8Array) {
|
||||
this.baseIV = iv;
|
||||
this.rawKey = rawKey;
|
||||
}
|
||||
|
||||
async init(): Promise<void> {
|
||||
this.key = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
this.rawKey,
|
||||
{ name: 'AES-GCM', length: 256 },
|
||||
false,
|
||||
['encrypt', 'decrypt']
|
||||
);
|
||||
}
|
||||
|
||||
private buildNonce(seqNum: number): Uint8Array {
|
||||
const nonce = new Uint8Array(12);
|
||||
nonce.set(this.baseIV.slice(0, 4), 0);
|
||||
const view = new DataView(nonce.buffer);
|
||||
view.setUint32(4, 0, false);
|
||||
view.setUint32(8, seqNum, false);
|
||||
return nonce;
|
||||
}
|
||||
|
||||
async encrypt(plaintext: Uint8Array, seqNum?: number): Promise<Uint8Array> {
|
||||
if (!this.key) throw new Error('Cipher not initialized');
|
||||
const seq = seqNum ?? this.seqNum++;
|
||||
const nonce = this.buildNonce(seq);
|
||||
|
||||
const encrypted = new Uint8Array(
|
||||
await crypto.subtle.encrypt(
|
||||
{ name: 'AES-GCM', iv: nonce, tagLength: 128 },
|
||||
this.key,
|
||||
plaintext
|
||||
)
|
||||
);
|
||||
|
||||
return encrypted;
|
||||
}
|
||||
|
||||
async decrypt(ciphertext: Uint8Array, seqNum?: number): Promise<Uint8Array | null> {
|
||||
if (!this.key) throw new Error('Cipher not initialized');
|
||||
const seq = seqNum ?? this.seqNum++;
|
||||
const nonce = this.buildNonce(seq);
|
||||
|
||||
try {
|
||||
const decrypted = new Uint8Array(
|
||||
await crypto.subtle.decrypt(
|
||||
{ name: 'AES-GCM', iv: nonce, tagLength: 128 },
|
||||
this.key,
|
||||
ciphertext
|
||||
)
|
||||
);
|
||||
return decrypted;
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const REKEY_THRESHOLD = 1 << 30;
|
||||
|
||||
export function shouldRekey(seqNum: number): boolean {
|
||||
return seqNum >= REKEY_THRESHOLD;
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import { SSH_MSG_KEX_ECDH_INIT } from '../types';
|
||||
import { concat, readUint32, encodeUint32, encodeString, toSSHMPInt, extractRawECDHPoint } from './utils';
|
||||
|
||||
export class ECDHKeyExchange {
|
||||
static async generateKeyPair(): Promise<CryptoKeyPair> {
|
||||
return crypto.subtle.generateKey(
|
||||
{ name: 'ECDH', namedCurve: 'P-256' },
|
||||
true,
|
||||
['deriveBits']
|
||||
);
|
||||
}
|
||||
|
||||
static async exportPublicKeyForSSH(keyPair: CryptoKeyPair): Promise<Uint8Array> {
|
||||
const rawKey = new Uint8Array(
|
||||
await crypto.subtle.exportKey('raw', keyPair.publicKey)
|
||||
);
|
||||
|
||||
const parts: Uint8Array[] = [];
|
||||
|
||||
const keyType = new TextEncoder().encode('ecdsa-sha2-nistp256');
|
||||
const keyTypeLen = new Uint8Array(4);
|
||||
new DataView(keyTypeLen.buffer).setUint32(0, keyType.length, false);
|
||||
|
||||
const curveName = new TextEncoder().encode('nistp256');
|
||||
const curveNameLen = new Uint8Array(4);
|
||||
new DataView(curveNameLen.buffer).setUint32(0, curveName.length, false);
|
||||
|
||||
const pointLen = new Uint8Array(4);
|
||||
new DataView(pointLen.buffer).setUint32(0, rawKey.length, false);
|
||||
|
||||
const totalLen = keyTypeLen.length + keyType.length +
|
||||
curveNameLen.length + curveName.length +
|
||||
pointLen.length + rawKey.length;
|
||||
const totalLenBytes = new Uint8Array(4);
|
||||
new DataView(totalLenBytes.buffer).setUint32(0, totalLen, false);
|
||||
|
||||
return concat(totalLenBytes, keyTypeLen, keyType,
|
||||
curveNameLen, curveName, pointLen, rawKey);
|
||||
}
|
||||
|
||||
static buildInit(clientPublicKeySSH: Uint8Array): Uint8Array {
|
||||
return concat(
|
||||
new Uint8Array([SSH_MSG_KEX_ECDH_INIT]),
|
||||
clientPublicKeySSH
|
||||
);
|
||||
}
|
||||
|
||||
static parseReply(data: Uint8Array): {
|
||||
hostKey: Uint8Array;
|
||||
serverPublicKey: Uint8Array;
|
||||
signature: Uint8Array;
|
||||
} {
|
||||
let offset = 1;
|
||||
|
||||
const hostKeyLen = readUint32(data, offset);
|
||||
offset += 4;
|
||||
const hostKey = data.slice(offset, offset + hostKeyLen);
|
||||
offset += hostKeyLen;
|
||||
|
||||
const qSLen = readUint32(data, offset);
|
||||
offset += 4;
|
||||
const serverPublicKey = data.slice(offset, offset + qSLen);
|
||||
offset += qSLen;
|
||||
|
||||
const sigLen = readUint32(data, offset);
|
||||
offset += 4;
|
||||
const signature = data.slice(offset, offset + sigLen);
|
||||
|
||||
return { hostKey, serverPublicKey, signature };
|
||||
}
|
||||
|
||||
static async computeSharedSecret(
|
||||
privateKey: CryptoKey,
|
||||
serverPublicKeyRaw: Uint8Array
|
||||
): Promise<Uint8Array> {
|
||||
const rawPoint = extractRawECDHPoint(serverPublicKeyRaw);
|
||||
|
||||
const serverKey = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
rawPoint,
|
||||
{ name: 'ECDH', namedCurve: 'P-256' },
|
||||
false,
|
||||
[]
|
||||
);
|
||||
|
||||
const sharedBits = await crypto.subtle.deriveBits(
|
||||
{ name: 'ECDH', public: serverKey },
|
||||
privateKey,
|
||||
256
|
||||
);
|
||||
|
||||
return toSSHMPInt(new Uint8Array(sharedBits));
|
||||
}
|
||||
|
||||
static async computeExchangeHash(
|
||||
clientVersion: string,
|
||||
serverVersion: string,
|
||||
clientKEXInit: Uint8Array,
|
||||
serverKEXInit: Uint8Array,
|
||||
hostKey: Uint8Array,
|
||||
clientPublicKeySSH: Uint8Array,
|
||||
serverPublicKeySSH: Uint8Array,
|
||||
sharedSecret: Uint8Array
|
||||
): Promise<Uint8Array> {
|
||||
const data = concat(
|
||||
encodeString(clientVersion),
|
||||
encodeString(serverVersion),
|
||||
encodeUint32(clientKEXInit.length),
|
||||
clientKEXInit,
|
||||
encodeUint32(serverKEXInit.length),
|
||||
serverKEXInit,
|
||||
hostKey,
|
||||
clientPublicKeySSH,
|
||||
serverPublicKeySSH,
|
||||
sharedSecret
|
||||
);
|
||||
|
||||
const hashBuffer = await crypto.subtle.digest('SHA-256', data);
|
||||
return new Uint8Array(hashBuffer);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { SSH_MSG_KEXINIT, KEXInitMessage } from '../types';
|
||||
import { encodeUint32, concat } from './utils';
|
||||
|
||||
export class KEXInitBuilder {
|
||||
static build(): Uint8Array {
|
||||
const parts: Uint8Array[] = [];
|
||||
|
||||
parts.push(new Uint8Array([SSH_MSG_KEXINIT]));
|
||||
|
||||
const cookie = new Uint8Array(16);
|
||||
crypto.getRandomValues(cookie);
|
||||
parts.push(cookie);
|
||||
|
||||
const algorithmLists = [
|
||||
'ecdh-sha2-nistp256',
|
||||
'rsa-sha2-256,ssh-rsa',
|
||||
'aes256-gcm@openssh.com',
|
||||
'aes256-gcm@openssh.com',
|
||||
'hmac-sha2-256-etm@openssh.com,hmac-sha2-256',
|
||||
'hmac-sha2-256-etm@openssh.com,hmac-sha2-256',
|
||||
'none',
|
||||
'none',
|
||||
'',
|
||||
'',
|
||||
];
|
||||
|
||||
for (const name of algorithmLists) {
|
||||
const encoded = new TextEncoder().encode(name);
|
||||
const len = new Uint8Array(4);
|
||||
new DataView(len.buffer).setUint32(0, encoded.length, false);
|
||||
parts.push(len);
|
||||
parts.push(encoded);
|
||||
}
|
||||
|
||||
parts.push(new Uint8Array([0]));
|
||||
|
||||
const reserved = new Uint8Array(4);
|
||||
parts.push(reserved);
|
||||
|
||||
return concat(...parts);
|
||||
}
|
||||
}
|
||||
|
||||
export function parseKEXInit(data: Uint8Array): KEXInitMessage {
|
||||
let offset = 1;
|
||||
|
||||
offset += 16;
|
||||
|
||||
const lists: string[] = [];
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const len = (data[offset] << 24) | (data[offset+1] << 16) |
|
||||
(data[offset+2] << 8) | data[offset+3];
|
||||
offset += 4;
|
||||
const name = new TextDecoder().decode(data.slice(offset, offset + len));
|
||||
lists.push(name);
|
||||
offset += len;
|
||||
}
|
||||
|
||||
return {
|
||||
kexAlgorithms: lists[0].split(','),
|
||||
hostKeyAlgorithms: lists[1].split(','),
|
||||
encryptionC2S: lists[2].split(','),
|
||||
encryptionS2C: lists[3].split(','),
|
||||
macC2S: lists[4].split(','),
|
||||
macS2C: lists[5].split(','),
|
||||
compressionC2S: lists[6].split(','),
|
||||
compressionS2C: lists[7].split(','),
|
||||
};
|
||||
}
|
||||
|
||||
export function negotiate(clientList: string[], serverList: string[]): string {
|
||||
for (const algo of clientList) {
|
||||
if (serverList.includes(algo)) return algo;
|
||||
}
|
||||
throw new Error(`No common algorithm: ${clientList} vs ${serverList}`);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { SessionKeys } from '../types';
|
||||
import { concat } from './utils';
|
||||
|
||||
export class KeyDerivation {
|
||||
static async deriveKeys(
|
||||
sharedSecret: Uint8Array,
|
||||
exchangeHash: Uint8Array,
|
||||
sessionID: Uint8Array
|
||||
): Promise<SessionKeys> {
|
||||
const ivC2S = await this.expandKey(sharedSecret, exchangeHash, 'A', sessionID, 12);
|
||||
const ivS2C = await this.expandKey(sharedSecret, exchangeHash, 'B', sessionID, 12);
|
||||
const keyC2S = await this.expandKey(sharedSecret, exchangeHash, 'C', sessionID, 32);
|
||||
const keyS2C = await this.expandKey(sharedSecret, exchangeHash, 'D', sessionID, 32);
|
||||
const intKeyC2S = await this.expandKey(sharedSecret, exchangeHash, 'E', sessionID, 32);
|
||||
const intKeyS2C = await this.expandKey(sharedSecret, exchangeHash, 'F', sessionID, 32);
|
||||
|
||||
return {
|
||||
ivClientToServer: ivC2S,
|
||||
ivServerToClient: ivS2C,
|
||||
encKeyClientToServer: keyC2S,
|
||||
encKeyServerToClient: keyS2C,
|
||||
integrityKeyC2S: intKeyC2S,
|
||||
integrityKeyS2C: intKeyS2C,
|
||||
sessionID,
|
||||
};
|
||||
}
|
||||
|
||||
private static async expandKey(
|
||||
K: Uint8Array,
|
||||
H: Uint8Array,
|
||||
X: string,
|
||||
sessionId: Uint8Array,
|
||||
needed: number
|
||||
): Promise<Uint8Array> {
|
||||
const hashLen = 32;
|
||||
const rounds = Math.ceil(needed / hashLen);
|
||||
const result = new Uint8Array(needed);
|
||||
let offset = 0;
|
||||
|
||||
let key = new Uint8Array(
|
||||
await crypto.subtle.digest('SHA-256',
|
||||
concat(K, H, new TextEncoder().encode(X), sessionId)
|
||||
)
|
||||
);
|
||||
result.set(key.slice(0, Math.min(hashLen, needed)), 0);
|
||||
offset += hashLen;
|
||||
|
||||
for (let i = 1; i < rounds; i++) {
|
||||
key = new Uint8Array(
|
||||
await crypto.subtle.digest('SHA-256',
|
||||
concat(K, H, key, sessionId)
|
||||
)
|
||||
);
|
||||
const remaining = needed - offset;
|
||||
result.set(key.slice(0, Math.min(hashLen, remaining)), offset);
|
||||
offset += hashLen;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { SSHPacket } from '../types';
|
||||
import { readUint32 } from './utils';
|
||||
|
||||
export class SSHPacketParser {
|
||||
private buffer: Uint8Array = new Uint8Array(0);
|
||||
private seqNum: number = 0;
|
||||
|
||||
feed(data: Uint8Array): void {
|
||||
const merged = new Uint8Array(this.buffer.length + data.length);
|
||||
merged.set(this.buffer);
|
||||
merged.set(data, this.buffer.length);
|
||||
this.buffer = merged;
|
||||
}
|
||||
|
||||
nextPacket(blockSize: number, decrypt: (
|
||||
data: Uint8Array, seq: number
|
||||
) => Uint8Array | null): SSHPacket | null {
|
||||
if (this.buffer.length < blockSize) return null;
|
||||
|
||||
const header = decrypt(
|
||||
this.buffer.slice(0, blockSize), this.seqNum
|
||||
);
|
||||
if (!header) return null;
|
||||
|
||||
const packetLength = (header[0] << 24) | (header[1] << 16) |
|
||||
(header[2] << 8) | header[3];
|
||||
|
||||
const totalBlocks = Math.ceil((4 + packetLength) / blockSize);
|
||||
const totalSize = totalBlocks * blockSize;
|
||||
|
||||
const expectedSize = totalSize + 16;
|
||||
|
||||
if (this.buffer.length < expectedSize) return null;
|
||||
|
||||
const encryptedPacket = this.buffer.slice(0, expectedSize);
|
||||
this.buffer = this.buffer.slice(expectedSize);
|
||||
|
||||
const decrypted = decrypt(encryptedPacket, this.seqNum);
|
||||
if (!decrypted) return null;
|
||||
|
||||
const paddingLength = decrypted[4];
|
||||
const payload = decrypted.slice(5, 5 + packetLength - 1 - paddingLength);
|
||||
|
||||
this.seqNum++;
|
||||
|
||||
return {
|
||||
length: packetLength,
|
||||
paddingLength,
|
||||
payload,
|
||||
mac: encryptedPacket.slice(totalSize),
|
||||
};
|
||||
}
|
||||
|
||||
getSeqNum(): number {
|
||||
return this.seqNum;
|
||||
}
|
||||
}
|
||||
|
||||
export class SSHPacketBuilder {
|
||||
static build(
|
||||
payload: Uint8Array,
|
||||
blockSize: number,
|
||||
encrypt: ((data: Uint8Array, seq: number) => Uint8Array) | null,
|
||||
seqNum: number
|
||||
): Uint8Array {
|
||||
const packetLength = 1 + payload.length;
|
||||
const paddingNeeded = blockSize - ((4 + packetLength) % blockSize);
|
||||
const paddingLength = paddingNeeded < 4
|
||||
? paddingNeeded + blockSize
|
||||
: paddingNeeded;
|
||||
|
||||
const totalLength = 4 + 1 + payload.length + paddingLength;
|
||||
const packet = new Uint8Array(totalLength);
|
||||
|
||||
const pl = 1 + payload.length + paddingLength;
|
||||
packet[0] = (pl >> 24) & 0xff;
|
||||
packet[1] = (pl >> 16) & 0xff;
|
||||
packet[2] = (pl >> 8) & 0xff;
|
||||
packet[3] = pl & 0xff;
|
||||
|
||||
packet[4] = paddingLength;
|
||||
|
||||
packet.set(payload, 5);
|
||||
|
||||
const crypto = globalThis.crypto;
|
||||
const randomPadding = new Uint8Array(paddingLength);
|
||||
crypto.getRandomValues(randomPadding);
|
||||
packet.set(randomPadding, 5 + payload.length);
|
||||
|
||||
if (encrypt) {
|
||||
return encrypt(packet, seqNum);
|
||||
}
|
||||
|
||||
return packet;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
const SSH_VERSION = 'SSH-2.0-CloudSSH_1.0';
|
||||
|
||||
export class SSHTransport {
|
||||
private remoteVersion: string = '';
|
||||
private localVersion: string = SSH_VERSION;
|
||||
private versionBuffer: string = '';
|
||||
|
||||
handleVersionExchange(data: string): boolean {
|
||||
this.versionBuffer += data;
|
||||
|
||||
const lines = this.versionBuffer.split('\r\n');
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('SSH-')) {
|
||||
this.remoteVersion = line;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
sendVersion(socket: { write: (data: Uint8Array) => void }): void {
|
||||
const encoder = new TextEncoder();
|
||||
socket.write(encoder.encode(SSH_VERSION + '\r\n'));
|
||||
}
|
||||
|
||||
getRemoteVersion(): string {
|
||||
return this.remoteVersion;
|
||||
}
|
||||
|
||||
getLocalVersion(): string {
|
||||
return this.localVersion;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
export function concat(...arrays: Uint8Array[]): Uint8Array {
|
||||
const totalLength = arrays.reduce((sum, arr) => sum + arr.length, 0);
|
||||
const result = new Uint8Array(totalLength);
|
||||
let offset = 0;
|
||||
for (const arr of arrays) {
|
||||
result.set(arr, offset);
|
||||
offset += arr.length;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function readUint32(data: Uint8Array, offset: number): number {
|
||||
return (data[offset] << 24) | (data[offset + 1] << 16) |
|
||||
(data[offset + 2] << 8) | data[offset + 3];
|
||||
}
|
||||
|
||||
export function encodeUint32(value: number): Uint8Array {
|
||||
const buf = new Uint8Array(4);
|
||||
new DataView(buf.buffer).setUint32(0, value, false);
|
||||
return buf;
|
||||
}
|
||||
|
||||
export function encodeString(input: string | Uint8Array): Uint8Array {
|
||||
const encoded = typeof input === 'string'
|
||||
? new TextEncoder().encode(input)
|
||||
: input;
|
||||
const len = encodeUint32(encoded.length);
|
||||
return concat(len, encoded);
|
||||
}
|
||||
|
||||
export function toSSHMPInt(bytes: Uint8Array): Uint8Array {
|
||||
let start = 0;
|
||||
while (start < bytes.length - 1 && bytes[start] === 0) {
|
||||
start++;
|
||||
}
|
||||
let significant = bytes.slice(start);
|
||||
|
||||
if (significant[0] & 0x80) {
|
||||
significant = concat(new Uint8Array([0]), significant);
|
||||
}
|
||||
|
||||
return encodeString(significant);
|
||||
}
|
||||
|
||||
export function extractRawECDHPoint(blob: Uint8Array): Uint8Array {
|
||||
let offset = 0;
|
||||
|
||||
const keyTypeLen = readUint32(blob, offset);
|
||||
offset += 4 + keyTypeLen;
|
||||
|
||||
const curveLen = readUint32(blob, offset);
|
||||
offset += 4 + curveLen;
|
||||
|
||||
const pointLen = readUint32(blob, offset);
|
||||
offset += 4;
|
||||
|
||||
return blob.slice(offset, offset + pointLen);
|
||||
}
|
||||
|
||||
export function encodePrefixedString(str: string): Uint8Array {
|
||||
const encoded = new TextEncoder().encode(str);
|
||||
return concat(encodeUint32(encoded.length), encoded);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
export interface SSHPacket {
|
||||
length: number;
|
||||
paddingLength: number;
|
||||
payload: Uint8Array;
|
||||
mac?: Uint8Array;
|
||||
}
|
||||
|
||||
export interface KEXInitMessage {
|
||||
kexAlgorithms: string[];
|
||||
hostKeyAlgorithms: string[];
|
||||
encryptionC2S: string[];
|
||||
encryptionS2C: string[];
|
||||
macC2S: string[];
|
||||
macS2C: string[];
|
||||
compressionC2S: string[];
|
||||
compressionS2C: string[];
|
||||
}
|
||||
|
||||
export interface SessionKeys {
|
||||
ivClientToServer: Uint8Array;
|
||||
ivServerToClient: Uint8Array;
|
||||
encKeyClientToServer: Uint8Array;
|
||||
encKeyServerToClient: Uint8Array;
|
||||
integrityKeyC2S: Uint8Array;
|
||||
integrityKeyS2C: Uint8Array;
|
||||
sessionID: Uint8Array;
|
||||
}
|
||||
|
||||
export interface ECDHResult {
|
||||
sharedSecret: Uint8Array;
|
||||
exchangeHash: Uint8Array;
|
||||
sessionID: Uint8Array;
|
||||
hostKey: Uint8Array;
|
||||
signature: Uint8Array;
|
||||
}
|
||||
|
||||
export interface AuthResult {
|
||||
success: boolean;
|
||||
allowedMethods?: string[];
|
||||
}
|
||||
|
||||
export interface SSHConnectionConfig {
|
||||
host: string;
|
||||
port: number;
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface Env {
|
||||
SSH_SESSION: DurableObjectNamespace;
|
||||
ASSETS: Fetcher;
|
||||
MAX_CONNECTIONS?: string;
|
||||
IDLE_TIMEOUT?: string;
|
||||
}
|
||||
|
||||
export const SSH_MSG_DISCONNECT = 1;
|
||||
export const SSH_MSG_IGNORE = 2;
|
||||
export const SSH_MSG_UNIMPLEMENTED = 3;
|
||||
export const SSH_MSG_DEBUG = 4;
|
||||
export const SSH_MSG_SERVICE_REQUEST = 5;
|
||||
export const SSH_MSG_SERVICE_ACCEPT = 6;
|
||||
export const SSH_MSG_KEXINIT = 20;
|
||||
export const SSH_MSG_NEWKEYS = 21;
|
||||
export const SSH_MSG_KEX_ECDH_INIT = 30;
|
||||
export const SSH_MSG_KEX_ECDH_REPLY = 31;
|
||||
export const SSH_MSG_USERAUTH_REQUEST = 50;
|
||||
export const SSH_MSG_USERAUTH_FAILURE = 51;
|
||||
export const SSH_MSG_USERAUTH_SUCCESS = 52;
|
||||
export const SSH_MSG_CHANNEL_OPEN = 90;
|
||||
export const SSH_MSG_CHANNEL_OPEN_CONFIRMATION = 91;
|
||||
export const SSH_MSG_CHANNEL_WINDOW_ADJUST = 93;
|
||||
export const SSH_MSG_CHANNEL_DATA = 94;
|
||||
export const SSH_MSG_CHANNEL_EOF = 96;
|
||||
export const SSH_MSG_CHANNEL_CLOSE = 97;
|
||||
export const SSH_MSG_CHANNEL_REQUEST = 98;
|
||||
export const SSH_MSG_CHANNEL_SUCCESS = 99;
|
||||
export const SSH_MSG_CHANNEL_FAILURE = 100;
|
||||
@@ -0,0 +1,95 @@
|
||||
import { Env, SSHConnectionConfig } from '../types';
|
||||
import { SSHSession } from './ssh-session';
|
||||
|
||||
export class SSHSessionDO {
|
||||
private state: DurableObjectState;
|
||||
private env: Env;
|
||||
private sessions: Map<WebSocket, SSHSession> = new Map();
|
||||
|
||||
constructor(state: DurableObjectState, env: Env) {
|
||||
this.state = state;
|
||||
this.env = env;
|
||||
}
|
||||
|
||||
async fetch(request: Request): Promise<Response> {
|
||||
const url = new URL(request.url);
|
||||
|
||||
if (url.pathname === '/websocket') {
|
||||
return this.handleWebSocketUpgrade(request);
|
||||
}
|
||||
|
||||
return new Response('SSH Session DO', { status: 200 });
|
||||
}
|
||||
|
||||
private async handleWebSocketUpgrade(request: Request): Promise<Response> {
|
||||
const sshConfig = this.extractSSHConfig(request);
|
||||
if (!sshConfig) {
|
||||
return new Response('Missing SSH config', { status: 400 });
|
||||
}
|
||||
|
||||
const pair = new WebSocketPair();
|
||||
const [client, server] = [pair[0], pair[1]];
|
||||
|
||||
(server as any).accept();
|
||||
|
||||
await this.initSSHSession(server, sshConfig);
|
||||
|
||||
return new Response(null, {
|
||||
status: 101,
|
||||
webSocket: client,
|
||||
} as any);
|
||||
}
|
||||
|
||||
private extractSSHConfig(request: Request): SSHConnectionConfig | null {
|
||||
const url = new URL(request.url);
|
||||
const host = url.searchParams.get('host');
|
||||
const port = parseInt(url.searchParams.get('port') || '22');
|
||||
const username = url.searchParams.get('user');
|
||||
const password = url.searchParams.get('pass');
|
||||
|
||||
if (!host || !username || !password) return null;
|
||||
|
||||
const hostRegex = /^[\w\.\-]+$/;
|
||||
if (!hostRegex.test(host)) return null;
|
||||
|
||||
if (isNaN(port) || port < 1 || port > 65535) return null;
|
||||
|
||||
const userRegex = /^[\w\.\-@]+$/;
|
||||
if (!userRegex.test(username)) return null;
|
||||
|
||||
return { host, port, username, password };
|
||||
}
|
||||
|
||||
private async initSSHSession(
|
||||
ws: WebSocket,
|
||||
config: SSHConnectionConfig
|
||||
): Promise<void> {
|
||||
try {
|
||||
const socket = await (globalThis as any).connect(config.host, config.port);
|
||||
|
||||
const session = new SSHSession(ws, socket, config);
|
||||
this.sessions.set(ws, session);
|
||||
|
||||
ws.addEventListener('message', (event) => {
|
||||
session.handleWebSocketMessage(event.data);
|
||||
});
|
||||
|
||||
ws.addEventListener('close', () => {
|
||||
session.close();
|
||||
this.sessions.delete(ws);
|
||||
});
|
||||
|
||||
ws.addEventListener('error', () => {
|
||||
session.close();
|
||||
this.sessions.delete(ws);
|
||||
});
|
||||
|
||||
await session.startHandshake();
|
||||
|
||||
} catch (error) {
|
||||
const errMsg = error instanceof Error ? error.message : 'Unknown error';
|
||||
ws.send(JSON.stringify({ type: 'error', message: `连接失败: ${errMsg}` }));
|
||||
ws.close(1011, 'SSH connection failed');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { Env } from '../types';
|
||||
|
||||
export { SSHSessionDO } from './durable-object';
|
||||
|
||||
export default {
|
||||
async fetch(request: Request, env: Env): Promise<Response> {
|
||||
const url = new URL(request.url);
|
||||
|
||||
if (url.pathname === '/api/ssh') {
|
||||
return handleSSHConnection(request, env);
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/health') {
|
||||
return Response.json({ status: 'ok', timestamp: Date.now() });
|
||||
}
|
||||
|
||||
return env.ASSETS.fetch(request);
|
||||
},
|
||||
};
|
||||
|
||||
async function handleSSHConnection(request: Request, env: Env): Promise<Response> {
|
||||
const upgradeHeader = request.headers.get('Upgrade');
|
||||
if (upgradeHeader !== 'websocket') {
|
||||
return Response.json(
|
||||
{ error: 'Expected WebSocket upgrade' },
|
||||
{ status: 426 }
|
||||
);
|
||||
}
|
||||
|
||||
const url = new URL(request.url);
|
||||
const host = url.searchParams.get('host');
|
||||
const port = parseInt(url.searchParams.get('port') || '22');
|
||||
const username = url.searchParams.get('user');
|
||||
const password = url.searchParams.get('pass');
|
||||
|
||||
if (!host || !username || !password) {
|
||||
return Response.json(
|
||||
{ error: 'Missing required parameters: host, user, pass' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const doId = env.SSH_SESSION.idFromName(`ssh:${host}:${port}:${username}`);
|
||||
const stub = env.SSH_SESSION.get(doId);
|
||||
|
||||
return stub.fetch(request);
|
||||
}
|
||||
@@ -0,0 +1,355 @@
|
||||
import { SSHConnectionConfig } from '../types';
|
||||
import {
|
||||
SSH_MSG_KEXINIT,
|
||||
SSH_MSG_NEWKEYS,
|
||||
SSH_MSG_KEX_ECDH_REPLY,
|
||||
SSH_MSG_USERAUTH_SUCCESS,
|
||||
SSH_MSG_USERAUTH_FAILURE,
|
||||
SSH_MSG_CHANNEL_OPEN_CONFIRMATION,
|
||||
SSH_MSG_CHANNEL_SUCCESS,
|
||||
SSH_MSG_CHANNEL_DATA,
|
||||
SSH_MSG_CHANNEL_WINDOW_ADJUST,
|
||||
SSH_MSG_CHANNEL_EOF,
|
||||
SSH_MSG_CHANNEL_CLOSE,
|
||||
SSH_MSG_DISCONNECT,
|
||||
SSH_MSG_IGNORE,
|
||||
SSH_MSG_DEBUG
|
||||
} from '../types';
|
||||
import { SSHTransport } from '../ssh/transport';
|
||||
import { SSHPacketParser, SSHPacketBuilder } from '../ssh/packet';
|
||||
import { KEXInitBuilder } from '../ssh/kex';
|
||||
import { ECDHKeyExchange } from '../ssh/kex-ecdh';
|
||||
import { KeyDerivation } from '../ssh/keys';
|
||||
import { SSHAESGCMCipher } from '../ssh/crypto';
|
||||
import { SSHAuth } from '../ssh/auth';
|
||||
import { SSHChannel } from '../ssh/channel';
|
||||
|
||||
export class SSHSession {
|
||||
private ws: WebSocket;
|
||||
private socket: any;
|
||||
private config: SSHConnectionConfig;
|
||||
|
||||
private transport: SSHTransport;
|
||||
private packetParser: SSHPacketParser;
|
||||
private channel: SSHChannel;
|
||||
private encryptCipher: SSHAESGCMCipher | null = null;
|
||||
private decryptCipher: SSHAESGCMCipher | null = null;
|
||||
|
||||
private seqNumSend: number = 0;
|
||||
private seqNumRecv: number = 0;
|
||||
private sessionID: Uint8Array | null = null;
|
||||
|
||||
private kexInitLocal: Uint8Array | null = null;
|
||||
private kexInitRemote: Uint8Array | null = null;
|
||||
|
||||
private ecdhKeyPair!: CryptoKeyPair;
|
||||
private ecdhPublicKeySSH!: Uint8Array;
|
||||
|
||||
private state: 'connecting' | 'version' | 'kex' | 'auth' | 'shell' | 'ready'
|
||||
= 'connecting';
|
||||
|
||||
constructor(ws: WebSocket, socket: any, config: SSHConnectionConfig) {
|
||||
this.ws = ws;
|
||||
this.socket = socket;
|
||||
this.config = config;
|
||||
this.transport = new SSHTransport();
|
||||
this.packetParser = new SSHPacketParser();
|
||||
this.channel = new SSHChannel();
|
||||
}
|
||||
|
||||
async startHandshake(): Promise<void> {
|
||||
this.state = 'version';
|
||||
this.transport.sendVersion(this.socket);
|
||||
this.startReading();
|
||||
}
|
||||
|
||||
private async startReading(): Promise<void> {
|
||||
const reader = this.socket.readable.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
if (this.state === 'version') {
|
||||
const versionStr = decoder.decode(value);
|
||||
if (this.transport.handleVersionExchange(versionStr)) {
|
||||
this.state = 'kex';
|
||||
await this.startKEX();
|
||||
}
|
||||
} else {
|
||||
this.packetParser.feed(value);
|
||||
await this.processPackets();
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
this.ws.send(JSON.stringify({
|
||||
type: 'error',
|
||||
message: 'SSH 连接断开'
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
private async startKEX(): Promise<void> {
|
||||
this.kexInitLocal = KEXInitBuilder.build();
|
||||
const packet = SSHPacketBuilder.build(
|
||||
this.kexInitLocal, 8, null, this.seqNumSend++
|
||||
);
|
||||
this.socket.write(packet);
|
||||
|
||||
this.ecdhKeyPair = await ECDHKeyExchange.generateKeyPair();
|
||||
this.ecdhPublicKeySSH = await ECDHKeyExchange.exportPublicKeyForSSH(
|
||||
this.ecdhKeyPair
|
||||
);
|
||||
|
||||
const ecdhInit = ECDHKeyExchange.buildInit(this.ecdhPublicKeySSH);
|
||||
const ecdhPacket = SSHPacketBuilder.build(
|
||||
ecdhInit, 8, null, this.seqNumSend++
|
||||
);
|
||||
this.socket.write(ecdhPacket);
|
||||
}
|
||||
|
||||
private async processPackets(): Promise<void> {
|
||||
const blockSize = this.decryptCipher ? 16 : 8;
|
||||
|
||||
while (true) {
|
||||
const packet = this.packetParser.nextPacket(
|
||||
blockSize,
|
||||
this.decryptCipher
|
||||
? (data, seq) => this.decryptCipher!.decrypt(data, seq) as any
|
||||
: (data) => data
|
||||
);
|
||||
|
||||
if (!packet) break;
|
||||
|
||||
await this.handlePacket(packet);
|
||||
}
|
||||
}
|
||||
|
||||
private async handlePacket(packet: any): Promise<void> {
|
||||
const msgType = packet.payload[0];
|
||||
|
||||
switch (this.state) {
|
||||
case 'kex':
|
||||
await this.handleKEXPacket(msgType, packet.payload);
|
||||
break;
|
||||
|
||||
case 'auth':
|
||||
await this.handleAuthPacket(msgType, packet.payload);
|
||||
break;
|
||||
|
||||
case 'ready':
|
||||
await this.handleSessionPacket(msgType, packet.payload);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private async handleKEXPacket(msgType: number, payload: Uint8Array): Promise<void> {
|
||||
switch (msgType) {
|
||||
case SSH_MSG_KEXINIT:
|
||||
this.kexInitRemote = payload;
|
||||
break;
|
||||
|
||||
case SSH_MSG_KEX_ECDH_REPLY:
|
||||
await this.handleECDHReply(payload);
|
||||
break;
|
||||
|
||||
case SSH_MSG_NEWKEYS:
|
||||
await this.enableEncryption();
|
||||
|
||||
const newKeys = new Uint8Array([SSH_MSG_NEWKEYS]);
|
||||
const packet = SSHPacketBuilder.build(
|
||||
newKeys, 16,
|
||||
(data, seq) => this.encryptCipher!.encrypt(data, seq) as any,
|
||||
this.seqNumSend++
|
||||
);
|
||||
this.socket.write(packet);
|
||||
|
||||
this.state = 'auth';
|
||||
await this.authenticate();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private async handleECDHReply(payload: Uint8Array): Promise<void> {
|
||||
const { hostKey, serverPublicKey, signature } =
|
||||
ECDHKeyExchange.parseReply(payload);
|
||||
|
||||
const sharedSecret = await ECDHKeyExchange.computeSharedSecret(
|
||||
this.ecdhKeyPair.privateKey,
|
||||
serverPublicKey
|
||||
);
|
||||
|
||||
const H = await ECDHKeyExchange.computeExchangeHash(
|
||||
this.transport.getLocalVersion(),
|
||||
this.transport.getRemoteVersion(),
|
||||
this.kexInitLocal!,
|
||||
this.kexInitRemote!,
|
||||
hostKey,
|
||||
this.ecdhPublicKeySSH,
|
||||
serverPublicKey,
|
||||
sharedSecret
|
||||
);
|
||||
|
||||
if (!this.sessionID) {
|
||||
this.sessionID = H;
|
||||
}
|
||||
|
||||
const keys = await KeyDerivation.deriveKeys(sharedSecret, H, this.sessionID!);
|
||||
|
||||
this.encryptCipher = new SSHAESGCMCipher(
|
||||
keys.encKeyClientToServer,
|
||||
keys.ivClientToServer
|
||||
);
|
||||
await this.encryptCipher.init();
|
||||
|
||||
this.decryptCipher = new SSHAESGCMCipher(
|
||||
keys.encKeyServerToClient,
|
||||
keys.ivServerToClient
|
||||
);
|
||||
await this.decryptCipher.init();
|
||||
}
|
||||
|
||||
private async enableEncryption(): Promise<void> {
|
||||
// Encryption is now active via this.decryptCipher
|
||||
}
|
||||
|
||||
private async authenticate(): Promise<void> {
|
||||
const authRequest = SSHAuth.buildPasswordAuthRequest(
|
||||
this.config.username,
|
||||
this.config.password
|
||||
);
|
||||
|
||||
const packet = SSHPacketBuilder.build(
|
||||
authRequest, 16,
|
||||
(data, seq) => this.encryptCipher!.encrypt(data, seq) as any,
|
||||
this.seqNumSend++
|
||||
);
|
||||
this.socket.write(packet);
|
||||
}
|
||||
|
||||
private async handleAuthPacket(msgType: number, payload: Uint8Array): Promise<void> {
|
||||
switch (msgType) {
|
||||
case SSH_MSG_USERAUTH_SUCCESS:
|
||||
this.ws.send(JSON.stringify({
|
||||
type: 'status',
|
||||
message: '认证成功'
|
||||
}));
|
||||
this.state = 'shell';
|
||||
await this.openShell();
|
||||
break;
|
||||
|
||||
case SSH_MSG_USERAUTH_FAILURE:
|
||||
this.ws.send(JSON.stringify({
|
||||
type: 'error',
|
||||
message: '认证失败:用户名或密码错误'
|
||||
}));
|
||||
this.close();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private async openShell(): Promise<void> {
|
||||
const openMsg = this.channel.buildOpenSession();
|
||||
await this.sendEncrypted(openMsg);
|
||||
}
|
||||
|
||||
private async handleSessionPacket(msgType: number, payload: Uint8Array): Promise<void> {
|
||||
switch (msgType) {
|
||||
case SSH_MSG_CHANNEL_OPEN_CONFIRMATION:
|
||||
this.channel.handleOpenConfirmation(payload);
|
||||
const ptyReq = this.channel.buildPTYRequest(120, 40);
|
||||
await this.sendEncrypted(ptyReq);
|
||||
break;
|
||||
|
||||
case SSH_MSG_CHANNEL_SUCCESS:
|
||||
if (this.state === 'shell') {
|
||||
const shellReq = this.channel.buildShellRequest();
|
||||
await this.sendEncrypted(shellReq);
|
||||
this.state = 'ready';
|
||||
|
||||
this.ws.send(JSON.stringify({
|
||||
type: 'status',
|
||||
message: 'Shell 已就绪'
|
||||
}));
|
||||
}
|
||||
break;
|
||||
|
||||
case SSH_MSG_CHANNEL_DATA:
|
||||
const outputData = this.channel.handleChannelData(payload);
|
||||
this.ws.send(outputData.buffer);
|
||||
break;
|
||||
|
||||
case SSH_MSG_CHANNEL_WINDOW_ADJUST:
|
||||
break;
|
||||
|
||||
case SSH_MSG_CHANNEL_EOF:
|
||||
case SSH_MSG_CHANNEL_CLOSE:
|
||||
this.ws.send(JSON.stringify({
|
||||
type: 'status',
|
||||
message: '会话已结束'
|
||||
}));
|
||||
this.close();
|
||||
break;
|
||||
|
||||
case SSH_MSG_DISCONNECT:
|
||||
this.ws.send(JSON.stringify({
|
||||
type: 'status',
|
||||
message: '服务器断开连接'
|
||||
}));
|
||||
this.close();
|
||||
break;
|
||||
|
||||
case SSH_MSG_IGNORE:
|
||||
case SSH_MSG_DEBUG:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
handleWebSocketMessage(data: string | ArrayBuffer): void {
|
||||
if (this.state !== 'ready') return;
|
||||
|
||||
if (typeof data === 'string') {
|
||||
try {
|
||||
const msg = JSON.parse(data);
|
||||
if (msg.type === 'resize') {
|
||||
this.handleResize(msg.cols, msg.rows);
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
const encoded = new TextEncoder().encode(data);
|
||||
const channelData = this.channel.buildChannelData(encoded);
|
||||
this.sendEncrypted(channelData);
|
||||
}
|
||||
} else {
|
||||
const channelData = this.channel.buildChannelData(
|
||||
new Uint8Array(data)
|
||||
);
|
||||
this.sendEncrypted(channelData);
|
||||
}
|
||||
}
|
||||
|
||||
private async handleResize(cols: number, rows: number): Promise<void> {
|
||||
const resizeMsg = this.channel.buildWindowChange(cols, rows);
|
||||
await this.sendEncrypted(resizeMsg);
|
||||
}
|
||||
|
||||
private async sendEncrypted(payload: Uint8Array): Promise<void> {
|
||||
if (!this.encryptCipher) {
|
||||
throw new Error('Encryption not initialized');
|
||||
}
|
||||
|
||||
const encrypted = SSHPacketBuilder.build(
|
||||
payload, 16,
|
||||
(data, seq) => this.encryptCipher!.encrypt(data, seq) as any,
|
||||
this.seqNumSend++
|
||||
);
|
||||
this.socket.write(encrypted);
|
||||
}
|
||||
|
||||
close(): void {
|
||||
try { this.socket.close(); } catch {}
|
||||
try { this.ws.close(); } catch {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ES2022",
|
||||
"moduleResolution": "bundler",
|
||||
"lib": ["ES2022"],
|
||||
"types": ["@cloudflare/workers-types"],
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["node_modules", "dist", "frontend"]
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
name = "cloudssh"
|
||||
main = "src/worker/index.ts"
|
||||
compatibility_date = "2024-12-01"
|
||||
compatibility_flags = ["nodejs_compat"]
|
||||
|
||||
[[durable_objects.bindings]]
|
||||
name = "SSH_SESSION"
|
||||
class_name = "SSHSessionDO"
|
||||
|
||||
[[migrations]]
|
||||
tag = "v1"
|
||||
new_classes = ["SSHSessionDO"]
|
||||
|
||||
[site]
|
||||
bucket = "./frontend/dist"
|
||||
|
||||
[vars]
|
||||
MAX_CONNECTIONS = "100"
|
||||
IDLE_TIMEOUT = "1800"
|
||||
Reference in new issue
Block a user