Skip to content

Commit 8c8e111

Browse files
committed
Merge dev into main
2 parents 4b1b5ee + b0f63a4 commit 8c8e111

6 files changed

Lines changed: 292 additions & 101 deletions

File tree

‎docker.env.example‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,4 +43,10 @@ LOG_LEVEL=info
4343

4444
# Интерактивная документация API (Scalar UI) по адресу /api/docs
4545
# По умолчанию: выключено
46-
# API_DOCS_ENABLED=true
46+
# API_DOCS_ENABLED=true
47+
48+
# Лимит параллельных прямых SSH-подключений (когда пул выключен).
49+
# Защита от перегрузки CPU при сетевой нестабильности (issue #70).
50+
# По умолчанию: 3. Увеличивать на свой риск — на 1 vCPU больше 3 хэндшейков
51+
# одновременно гарантированно сажают панель.
52+
# SSH_DIRECT_MAX_CONCURRENT=3

‎src/locales/en.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -634,6 +634,7 @@
634634
"sshPoolKeepaliveHint": "Send keepalive packets to prevent NAT timeout",
635635
"sshPoolRetries": "Max retries",
636636
"sshPoolRetriesHint": "Reconnection attempts on failure",
637+
"sshPoolDisableWarning": "Disabling is not recommended. Without the pool, network instability can saturate CPU with direct SSH handshakes. Direct mode is now capped at 3 parallel connections, but the pool is more reliable.",
637638
"nodeAuth": "Node Auth API",
638639
"nodeAuthInsecure": "Allow self-signed certificates",
639640
"nodeAuthInsecureHint": "Enable if panel uses HTTP or self-signed SSL. Nodes will accept any certificate when connecting to auth API. Disable for production with valid SSL.",

‎src/locales/ru.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -634,6 +634,7 @@
634634
"sshPoolKeepaliveHint": "Отправка пакетов keepalive для предотвращения NAT timeout",
635635
"sshPoolRetries": "Макс. попыток",
636636
"sshPoolRetriesHint": "Количество попыток переподключения при ошибке",
637+
"sshPoolDisableWarning": "Не рекомендуется отключать. Без пула при сетевых сбоях возможна перегрузка CPU из-за потока прямых SSH-подключений. Прямой режим теперь ограничен 3 параллельными подключениями, но пул надёжнее.",
637638
"nodeAuth": "Node Auth API",
638639
"nodeAuthInsecure": "Разрешить self-signed сертификаты",
639640
"nodeAuthInsecureHint": "Включите, если панель использует HTTP или self-signed SSL. Ноды будут принимать любой сертификат при подключении к auth API. Отключите для production с валидным SSL.",

‎src/services/nodeSSH.js‎

Lines changed: 130 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,55 +1,132 @@
11
/**
22
* SSH service for Hysteria node management
3-
*
3+
*
44
* Uses SSHPool for connection reuse.
55
* Falls back to direct connection if pool unavailable.
6+
*
7+
* Direct mode is hardened against avalanches (issue #70):
8+
* - Global concurrency cap: at most MAX_CONCURRENT_DIRECT handshakes at once,
9+
* bounding CPU usage from DH key-exchange on weak hardware.
10+
* - Bounded retries with exponential backoff (mirrors sshPoolService).
11+
*
12+
* Configuration:
13+
* - readyTimeout / maxRetries are inherited from sshPool settings, so the
14+
* admin tunes both pool and direct paths from one place in the UI.
15+
* - MAX_CONCURRENT_DIRECT is an expert-only CPU safety cap, exposed as the
16+
* SSH_DIRECT_MAX_CONCURRENT env var (default 3). Not surfaced in the UI to
17+
* avoid foot-guns: setting it too high re-introduces the avalanche risk.
18+
*
19+
* Note: We intentionally do NOT share clients across NodeSSH instances via
20+
* in-flight dedup. Each instance owns its directClient and ends it on
21+
* disconnect(); sharing would let one instance close the socket out from
22+
* under another. Same-node parallel SSH is rare in this codebase (cascade
23+
* health-check, sync, etc. iterate distinct nodes), and the semaphore alone
24+
* is sufficient to prevent the avalanche described in issue #70.
625
*/
726

827
const { Client } = require('ssh2');
928
const sshPool = require('./sshPoolService');
1029
const logger = require('../utils/logger');
1130
const cryptoService = require('./cryptoService');
1231

32+
// Hard cap to keep CPU usage bounded on weak hardware (1 vCPU).
33+
// Each SSH handshake involves DH key-exchange which is CPU-heavy on Node.js.
34+
const MAX_CONCURRENT_DIRECT = (() => {
35+
const raw = parseInt(process.env.SSH_DIRECT_MAX_CONCURRENT, 10);
36+
return (Number.isFinite(raw) && raw > 0) ? raw : 3;
37+
})();
38+
39+
// Fallback defaults used only until sshPool.config is populated.
40+
const FALLBACK_READY_TIMEOUT_MS = 15000;
41+
const FALLBACK_MAX_RETRIES = 2;
42+
43+
function getDirectReadyTimeoutMs() {
44+
// sshPool.config.connectTimeout is stored in milliseconds (defaults 15000).
45+
const t = sshPool?.config?.connectTimeout;
46+
return (Number.isFinite(t) && t > 0) ? t : FALLBACK_READY_TIMEOUT_MS;
47+
}
48+
49+
function getDirectMaxRetries() {
50+
const r = sshPool?.config?.maxRetries;
51+
return (Number.isFinite(r) && r >= 0) ? r : FALLBACK_MAX_RETRIES;
52+
}
53+
1354
class NodeSSH {
1455
constructor(node) {
1556
this.node = node;
16-
this.usePool = true; // Use pool by default
17-
this.directClient = null; // For legacy mode
57+
this.usePool = true;
58+
this.directClient = null;
1859
}
1960

2061
/**
2162
* Connect to node via SSH (via pool or direct)
2263
*/
2364
async connect() {
2465
if (this.usePool) {
25-
// Pool manages connections - just verify we can connect
2666
try {
2767
await sshPool.getConnection(this.node);
2868
return;
2969
} catch (error) {
30-
logger.warn(`[SSH] Pool failed for ${this.node.name}, falling back to direct`);
70+
if (error && error.message === 'SSH Pool disabled') {
71+
logger.debug(`[SSH] Pool disabled, using direct for ${this.node.name}`);
72+
} else {
73+
logger.warn(`[SSH] Pool error for ${this.node.name} (${error?.message || 'unknown'}), falling back to direct`);
74+
}
3175
this.usePool = false;
3276
}
3377
}
34-
35-
// Fallback: direct connection
78+
3679
return this.connectDirect();
3780
}
38-
81+
3982
/**
40-
* Direct connection (legacy, for special cases)
83+
* Direct connection (used when pool is disabled or temporarily failing).
84+
* A global semaphore caps simultaneous handshakes so a burst of callers
85+
* cannot saturate CPU during network instability.
4186
*/
4287
async connectDirect() {
88+
await NodeSSH._acquireDirectSlot();
89+
try {
90+
this.directClient = await this._connectDirectWithRetry();
91+
} finally {
92+
NodeSSH._releaseDirectSlot();
93+
}
94+
}
95+
96+
/**
97+
* Open a single SSH client with bounded retries and exponential backoff.
98+
* Mirrors the retry pattern in sshPoolService.createConnection.
99+
*/
100+
async _connectDirectWithRetry(attempt = 0) {
101+
const maxRetries = getDirectMaxRetries();
102+
try {
103+
return await this._openDirectClient();
104+
} catch (err) {
105+
if (attempt < maxRetries) {
106+
const delay = Math.pow(2, attempt) * 500;
107+
logger.warn(`[SSH] ${this.node.name}: direct retry ${attempt + 1}/${maxRetries} in ${delay}ms (${err.message})`);
108+
await new Promise(r => setTimeout(r, delay));
109+
return this._connectDirectWithRetry(attempt + 1);
110+
}
111+
logger.error(`[SSH] Direct connection failed for ${this.node.name}: ${err.message}`);
112+
throw err;
113+
}
114+
}
115+
116+
/**
117+
* One handshake attempt. Resolves with a ready Client or rejects.
118+
*/
119+
_openDirectClient() {
43120
return new Promise((resolve, reject) => {
44-
this.directClient = new Client();
45-
121+
const client = new Client();
122+
46123
const config = {
47124
host: this.node.ip,
48125
port: this.node.ssh?.port || 22,
49126
username: this.node.ssh?.username || 'root',
50-
readyTimeout: 30000,
127+
readyTimeout: getDirectReadyTimeoutMs(),
51128
};
52-
129+
53130
if (this.node.ssh?.privateKey) {
54131
config.privateKey = cryptoService.decryptPrivateKey(this.node.ssh.privateKey);
55132
} else if (this.node.ssh?.password) {
@@ -58,20 +135,50 @@ class NodeSSH {
58135
reject(new Error('SSH: no key or password provided'));
59136
return;
60137
}
61-
62-
this.directClient
138+
139+
let settled = false;
140+
client
63141
.on('ready', () => {
142+
if (settled) return;
143+
settled = true;
64144
logger.info(`[SSH] Connected (direct) to ${this.node.name} (${this.node.ip})`);
65-
resolve();
145+
resolve(client);
66146
})
67147
.on('error', (err) => {
68-
logger.error(`[SSH] Connection error to ${this.node.name}: ${err.message}`);
148+
if (settled) return;
149+
settled = true;
150+
try { client.end(); } catch (_) {}
69151
reject(err);
70152
})
71153
.connect(config);
72154
});
73155
}
74156

157+
/**
158+
* Acquire a slot from the global direct-connection semaphore.
159+
* Blocks (queues) when MAX_CONCURRENT_DIRECT handshakes are already in flight.
160+
*/
161+
static _acquireDirectSlot() {
162+
if (NodeSSH._activeDirect < MAX_CONCURRENT_DIRECT) {
163+
NodeSSH._activeDirect++;
164+
return Promise.resolve();
165+
}
166+
return new Promise(resolve => NodeSSH._waitQueue.push(resolve));
167+
}
168+
169+
/**
170+
* Release a slot and wake the next waiter, if any.
171+
*/
172+
static _releaseDirectSlot() {
173+
const next = NodeSSH._waitQueue.shift();
174+
if (next) {
175+
// Slot count stays the same: we hand it to the next waiter.
176+
next();
177+
} else {
178+
NodeSSH._activeDirect = Math.max(0, NodeSSH._activeDirect - 1);
179+
}
180+
}
181+
75182
/**
76183
* Close connection
77184
*/
@@ -662,4 +769,10 @@ cat /proc/uptime | cut -d' ' -f1
662769

663770
NodeSSH._cpuSamples = new Map();
664771

772+
// Direct-connection semaphore (issue #70 hardening).
773+
// _activeDirect: number of handshakes currently in progress.
774+
// _waitQueue: resolvers blocked by the semaphore, FIFO.
775+
NodeSSH._activeDirect = 0;
776+
NodeSSH._waitQueue = [];
777+
665778
module.exports = NodeSSH;

0 commit comments

Comments
 (0)