feat: 纯 Cloudflare 架构 Web SSH 工具

- Cloudflare Workers + Durable Objects
- 自实现 SSH 协议引擎(ECDH-P256 + AES-256-GCM)
- 前端:Vite + TypeScript + xterm.js
- 支持密码认证、终端渲染、窗口自适应
This commit is contained in:
IT committed 2026-06-12 15:09:29 +08:00
1 parent 8dc0d2b1cc
commit b95635bdd5
27 files changed
+5991

No files matched your search

+6
View File
@@ -0,0 +1,6 @@
node_modules/
dist/
.wrangler/
.dev.vars
*.log
.DS_Store
+29
View File
@@ -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>
+1187
View File
File diff suppressed because it is too large. Load diff
+19
View File
@@ -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"
}
}
+76
View File
@@ -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('连接失败,请检查连接信息');
}
}
}
+12
View File
@@ -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';
});
+175
View File
@@ -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;
}
}
+157
View File
@@ -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();
}
}
+17
View File
@@ -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"]
}
+16
View File
@@ -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,
},
});
+2999
View File
File diff suppressed because it is too large. Load diff
+22
View File
@@ -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": {}
}
+43
View File
@@ -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}`);
}
}
}
+99
View File
@@ -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)
);
}
}
+71
View File
@@ -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;
}
+121
View File
@@ -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);
}
}
+76
View File
@@ -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}`);
}
+61
View File
@@ -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;
}
}
+96
View File
@@ -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;
}
}
+34
View File
@@ -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;
}
}
+63
View File
@@ -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);
}
+77
View File
@@ -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;
+95
View File
@@ -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');
}
}
}
+47
View File
@@ -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);
}
+355
View File
@@ -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 {}
}
}
+19
View File
@@ -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"]
}
+19
View File
@@ -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"