Skip to content

Commit 0d15b3b

Browse files
committed
fix: auto-copy panel TLS certs for same-VPS nodes (#22)
- Add isSameVpsAsPanel() detection (domain, localhost, PANEL_IP env) - Add certificate check/upload methods in NodeSSH - Auto-copy panel certs during sync if missing on node
1 parent dc0eeb9 commit 0d15b3b

3 files changed

Lines changed: 174 additions & 20 deletions

File tree

src/services/nodeSSH.js

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -428,6 +428,107 @@ echo "Port hopping: ${startPort}-${endPort} -> ${mainPort}"
428428
}
429429
}
430430

431+
/**
432+
* Check if TLS certificate files exist and are valid on the node
433+
* @returns {Object} { exists: boolean, valid: boolean, error?: string }
434+
*/
435+
async checkCertificates() {
436+
try {
437+
const certPath = this.node.paths?.cert || '/etc/hysteria/cert.pem';
438+
const keyPath = this.node.paths?.key || '/etc/hysteria/key.pem';
439+
440+
const result = await this.exec(`
441+
if [ -f "${certPath}" ] && [ -s "${certPath}" ] && [ -f "${keyPath}" ] && [ -s "${keyPath}" ]; then
442+
if openssl x509 -in "${certPath}" -noout 2>/dev/null; then
443+
echo "VALID"
444+
openssl x509 -in "${certPath}" -noout -enddate 2>/dev/null | grep notAfter
445+
else
446+
echo "INVALID"
447+
fi
448+
else
449+
echo "MISSING"
450+
fi
451+
`);
452+
453+
const output = result.stdout.trim();
454+
455+
if (output.includes('VALID')) {
456+
return { exists: true, valid: true };
457+
} else if (output.includes('INVALID')) {
458+
return { exists: true, valid: false, error: 'Certificate is invalid or corrupted' };
459+
} else {
460+
return { exists: false, valid: false, error: 'Certificate files missing' };
461+
}
462+
} catch (error) {
463+
logger.error(`[SSH] Certificate check error on ${this.node.name}: ${error.message}`);
464+
return { exists: false, valid: false, error: error.message };
465+
}
466+
}
467+
468+
/**
469+
* Upload TLS certificates to the node
470+
* @param {string} cert - Certificate content (PEM)
471+
* @param {string} key - Private key content (PEM)
472+
* @returns {boolean} Success status
473+
*/
474+
async uploadCertificates(cert, key) {
475+
try {
476+
const certPath = this.node.paths?.cert || '/etc/hysteria/cert.pem';
477+
const keyPath = this.node.paths?.key || '/etc/hysteria/key.pem';
478+
479+
await this.exec('mkdir -p /etc/hysteria');
480+
481+
await this.writeFile(certPath, cert);
482+
await this.writeFile(keyPath, key);
483+
484+
await this.exec(`
485+
chmod 644 ${certPath}
486+
chmod 600 ${keyPath}
487+
if id "hysteria" &>/dev/null; then
488+
chown hysteria:hysteria ${certPath} ${keyPath}
489+
fi
490+
`);
491+
492+
logger.info(`[SSH] Certificates uploaded to ${this.node.name}`);
493+
return true;
494+
} catch (error) {
495+
logger.error(`[SSH] Certificate upload error on ${this.node.name}: ${error.message}`);
496+
return false;
497+
}
498+
}
499+
500+
/**
501+
* Ensure valid certificates exist on the node
502+
* If useTlsFiles is true and certs are missing/invalid, tries to copy from panel
503+
* @param {Object} panelCerts - { cert, key } from panel, or null
504+
* @returns {Object} { success: boolean, action: string, error?: string }
505+
*/
506+
async ensureCertificates(panelCerts) {
507+
const certStatus = await this.checkCertificates();
508+
509+
if (certStatus.exists && certStatus.valid) {
510+
return { success: true, action: 'existing' };
511+
}
512+
513+
if (!panelCerts || !panelCerts.cert || !panelCerts.key) {
514+
return {
515+
success: false,
516+
action: 'failed',
517+
error: 'Certificates missing on node and panel certs not available'
518+
};
519+
}
520+
521+
logger.info(`[SSH] ${this.node.name}: certificates ${certStatus.exists ? 'invalid' : 'missing'}, uploading from panel`);
522+
523+
const uploaded = await this.uploadCertificates(panelCerts.cert, panelCerts.key);
524+
525+
if (uploaded) {
526+
return { success: true, action: 'uploaded' };
527+
} else {
528+
return { success: false, action: 'failed', error: 'Failed to upload certificates' };
529+
}
530+
}
531+
431532
/**
432533
* Get system stats from node
433534
*/

src/services/nodeSetup.js

Lines changed: 54 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,39 @@ const cryptoService = require('./cryptoService');
1111
const Settings = require('../models/settingsModel');
1212
const configGenerator = require('./configGenerator');
1313

14+
/**
15+
* Check if a node is on the same VPS as the panel
16+
* Uses multiple heuristics: domain match, IP match via DNS, localhost detection
17+
* @param {Object} node - Node object with ip and domain fields
18+
* @returns {boolean} true if node appears to be on the same server as the panel
19+
*/
20+
function isSameVpsAsPanel(node) {
21+
const panelDomain = config.PANEL_DOMAIN;
22+
23+
// 1. Domain match - most reliable indicator
24+
if (node.domain && node.domain === panelDomain) {
25+
logger.debug(`[NodeSetup] Same VPS detected: domain match (${node.domain})`);
26+
return true;
27+
}
28+
29+
// 2. Localhost / loopback detection
30+
const nodeIp = (node.ip || '').toLowerCase().trim();
31+
if (nodeIp === 'localhost' || nodeIp === '127.0.0.1' || nodeIp === '::1') {
32+
logger.debug(`[NodeSetup] Same VPS detected: localhost IP (${nodeIp})`);
33+
return true;
34+
}
35+
36+
// 3. Try to resolve panel domain and compare with node IP
37+
// This is a sync check using cached DNS or env variable
38+
const panelIpFromEnv = process.env.PANEL_IP || '';
39+
if (panelIpFromEnv && panelIpFromEnv === nodeIp) {
40+
logger.debug(`[NodeSetup] Same VPS detected: IP match via PANEL_IP env (${nodeIp})`);
41+
return true;
42+
}
43+
44+
return false;
45+
}
46+
1447
/**
1548
* Read panel's SSL certificates from Greenlock or Caddy directory
1649
* @param {string} domain - Panel domain
@@ -313,25 +346,14 @@ async function setupNode(node, options = {}) {
313346
}
314347

315348
// Determine TLS mode: same-VPS (copy panel certs), ACME, or self-signed
316-
const isSameVpsSetup = node.domain && node.domain === config.PANEL_DOMAIN;
349+
// Use improved detection: checks domain match, localhost, and PANEL_IP env
350+
const isSameVpsSetup = isSameVpsAsPanel(node);
317351
let useTlsFiles = false;
318352

319-
if (!node.domain) {
320-
// No domain - use self-signed certificate
321-
log('No domain specified, generating self-signed certificate...');
322-
const certResult = await execSSH(conn, SELF_SIGNED_CERT_SCRIPT);
323-
logs.push(certResult.output);
324-
325-
if (!certResult.success) {
326-
throw new Error(`Certificate generation failed: ${certResult.error}`);
327-
}
328-
log('Certificate ready (self-signed)');
329-
useTlsFiles = true;
330-
331-
} else if (isSameVpsSetup) {
332-
// Same domain as panel - copy panel's certificates to node
333-
log(`Same-VPS setup detected (domain: ${node.domain})`);
334-
log('Copying panel certificates to node...');
353+
if (isSameVpsSetup) {
354+
// Same server as panel - try to copy panel's certificates
355+
log(`Same-VPS setup detected (node IP: ${node.ip}, panel domain: ${config.PANEL_DOMAIN})`);
356+
log('Attempting to copy panel certificates to node...');
335357

336358
const panelCerts = getPanelCertificates(config.PANEL_DOMAIN);
337359

@@ -360,8 +382,20 @@ ls -la /etc/hysteria/*.pem
360382
useTlsFiles = true;
361383
}
362384

385+
} else if (!node.domain) {
386+
// No domain and not same VPS - use self-signed certificate
387+
log('No domain specified, generating self-signed certificate...');
388+
const certResult = await execSSH(conn, SELF_SIGNED_CERT_SCRIPT);
389+
logs.push(certResult.output);
390+
391+
if (!certResult.success) {
392+
throw new Error(`Certificate generation failed: ${certResult.error}`);
393+
}
394+
log('Certificate ready (self-signed)');
395+
useTlsFiles = true;
396+
363397
} else {
364-
// Different domain - use ACME (but warn about potential port 80 conflict)
398+
// Different domain on different VPS - use ACME
365399
log(`Domain detected (${node.domain}), ACME will be used`);
366400
log('⚠️ WARNING: If this node is on the same VPS as the panel, ACME may fail!');
367401
log('⚠️ Port 80 is used by the panel for its own ACME challenges.');
@@ -996,4 +1030,6 @@ module.exports = {
9961030
generateX25519Keys,
9971031
checkXrayNodeStatus,
9981032
getXrayNodeLogs,
1033+
getPanelCertificates,
1034+
isSameVpsAsPanel,
9991035
};

src/services/syncService.js

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ const axios = require('axios');
2323
const https = require('https');
2424
const config = require('../../config');
2525
const webhook = require('./webhookService');
26+
const { getPanelCertificates, isSameVpsAsPanel } = require('./nodeSetup');
2627

2728
// HTTPS agent that ignores self-signed certs (agent uses self-signed cert by default)
2829
const selfSignedAgent = new https.Agent({ rejectUnauthorized: false });
@@ -445,11 +446,28 @@ class SyncService {
445446
try {
446447
await ssh.connect();
447448

449+
// Determine if this node uses TLS files (not ACME)
450+
const useTlsFiles = node.useTlsFiles || !node.domain;
451+
452+
// For same-VPS nodes or nodes using TLS files, ensure certificates exist
453+
if (useTlsFiles || isSameVpsAsPanel(node)) {
454+
const panelCerts = getPanelCertificates(config.PANEL_DOMAIN);
455+
const certResult = await ssh.ensureCertificates(panelCerts);
456+
457+
if (certResult.success) {
458+
if (certResult.action === 'uploaded') {
459+
logger.info(`[Sync] ${node.name}: certificates uploaded from panel`);
460+
}
461+
} else {
462+
logger.warn(`[Sync] ${node.name}: certificate issue - ${certResult.error}`);
463+
logger.warn(`[Sync] ${node.name}: Hysteria may fail to start without valid certificates`);
464+
}
465+
}
466+
448467
// Use custom config or generate automatically
449468
let configContent;
450469
const customConfig = (node.customConfig || '').trim();
451470
if (node.useCustomConfig && customConfig && customConfig.length > 50) {
452-
// Basic validation: must contain listen and auth/tls/acme
453471
if (!customConfig.includes('listen:')) {
454472
throw new Error('Custom config invalid: missing listen:');
455473
}
@@ -465,7 +483,6 @@ class SyncService {
465483
const authUrl = this.getAuthUrl();
466484
const settings = await Settings.get();
467485
const authInsecure = settings?.nodeAuth?.insecure ?? true;
468-
const useTlsFiles = node.useTlsFiles || false;
469486
configContent = configGenerator.generateNodeConfig(node, authUrl, { authInsecure, useTlsFiles });
470487
}
471488

0 commit comments

Comments
 (0)