Skip to content

Commit 4c63d65

Browse files
authored
Merge pull request #29 from ClickDevTech/merge/ssh-key-download
feat: add SSH private key download action for nodes
2 parents 600211f + dc40503 commit 4c63d65

File tree

5 files changed

+68
-4
lines changed

5 files changed

+68
-4
lines changed

public/css/style.css

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1760,6 +1760,11 @@ html {
17601760
cursor: not-allowed;
17611761
}
17621762

1763+
.action-btn.is-disabled {
1764+
opacity: 0.5;
1765+
cursor: not-allowed;
1766+
}
1767+
17631768
.action-btn .action-icon {
17641769
font-size: 16px;
17651770
}
@@ -2361,4 +2366,4 @@ html {
23612366
.nav-menu li, .topbar, .page-header, .quick-action {
23622367
animation: none;
23632368
}
2364-
}
2369+
}

src/locales/en.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,8 @@
159159
"sshKeyGenerating": "Generating and installing key...",
160160
"sshKeyInstalled": "SSH key generated and installed!",
161161
"sshKeyError": "Key generation error",
162+
"downloadSshKey": "Download SSH Key",
163+
"downloadSshKeyMissing": "SSH private key is not configured for this node",
162164
"maxOnlineUsers": "Online Users Limit",
163165
"maxOnlineHint": "0 = unlimited. If exceeded, node is hidden from subscription (if enabled).",
164166
"rankingCoefficient": "Priority (ranking)",
@@ -676,4 +678,4 @@
676678
"test": "Test",
677679
"urlRequired": "Enter a URL first"
678680
}
679-
}
681+
}

src/locales/ru.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,8 @@
159159
"sshKeyGenerating": "Генерация и установка ключа...",
160160
"sshKeyInstalled": "SSH ключ сгенерирован и установлен!",
161161
"sshKeyError": "Ошибка генерации ключа",
162+
"downloadSshKey": "Скачать SSH ключ",
163+
"downloadSshKeyMissing": "Для этой ноды не настроен SSH приватный ключ",
162164
"maxOnlineUsers": "Лимит онлайн пользователей",
163165
"maxOnlineHint": "0 = без лимита. При превышении нода скрывается из подписки (если включено).",
164166
"rankingCoefficient": "Приоритет (ranking)",
@@ -676,4 +678,4 @@
676678
"test": "Тест",
677679
"urlRequired": "Сначала введите URL"
678680
}
679-
}
681+
}

src/routes/panel.js

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,19 @@ function decryptSshPrivateKey(key) {
6565
return key;
6666
}
6767

68+
function buildSshKeyFilename(node) {
69+
const safe = (value, fallback) => {
70+
const normalized = String(value || '')
71+
.trim()
72+
.replace(/[^a-zA-Z0-9._-]+/g, '-')
73+
.replace(/-+/g, '-')
74+
.replace(/^-|-$/g, '');
75+
return normalized || fallback;
76+
};
77+
78+
return `${safe(node.name, 'node')}-${safe(node.ip, 'unknown')}.key`;
79+
}
80+
6881
/**
6982
* Open a direct SSH connection to a node using its stored credentials.
7083
* @returns {Promise<Client>} connected ssh2 Client
@@ -741,6 +754,37 @@ router.post('/nodes/:id/generate-ssh-key', requireAuth, generateSshKeyLimiter, a
741754
}
742755
});
743756

757+
// GET /panel/nodes/:id/download-ssh-key - Download stored SSH private key
758+
router.get('/nodes/:id/download-ssh-key', requireAuth, async (req, res) => {
759+
try {
760+
const node = await HyNode.findById(req.params.id).select('name ip ssh.privateKey');
761+
762+
if (!node) {
763+
return res.status(404).type('text/plain; charset=utf-8').send('Node not found');
764+
}
765+
766+
if (!node.ssh?.privateKey) {
767+
return res.status(404).type('text/plain; charset=utf-8').send('SSH private key not configured');
768+
}
769+
770+
const privateKey = decryptSshPrivateKey(node.ssh.privateKey);
771+
const filename = buildSshKeyFilename(node);
772+
773+
logger.info(`[Panel] SSH private key downloaded for node ${node.name}`);
774+
775+
res.set({
776+
'Content-Type': 'application/x-pem-file; charset=utf-8',
777+
'Content-Disposition': `attachment; filename="${filename}"`,
778+
'Cache-Control': 'no-store',
779+
'X-Content-Type-Options': 'nosniff',
780+
});
781+
return res.send(privateKey);
782+
} catch (error) {
783+
logger.error(`[Panel] SSH key download error: ${error.message}`);
784+
return res.status(500).type('text/plain; charset=utf-8').send('Failed to download SSH private key');
785+
}
786+
});
787+
744788
// GET /panel/nodes/:id/stats - Получение системной статистики ноды
745789
router.get('/nodes/:id/stats', requireAuth, async (req, res) => {
746790
try {
@@ -2252,4 +2296,4 @@ router.post('/settings/test-webhook', requireAuth, async (req, res) => {
22522296
}
22532297
});
22542298

2255-
module.exports = router;
2299+
module.exports = router;

views/node-form.ejs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -517,6 +517,17 @@
517517
<span class="badge" style="font-size:10px;background:var(--accent);color:#fff;border-radius:10px;padding:1px 6px;margin-left:4px;"><%= node.outbounds.length %></span>
518518
<% } %>
519519
</a>
520+
<% if (node.ssh?.privateKey) { %>
521+
<a href="/panel/nodes/<%= node._id %>/download-ssh-key" class="action-btn" style="text-decoration:none;text-align:center;">
522+
<span class="action-icon"><i class="ti ti-download"></i></span>
523+
<span><%= t('nodes.downloadSshKey') %></span>
524+
</a>
525+
<% } else { %>
526+
<span class="action-btn is-disabled" style="text-decoration:none;text-align:center;" title="<%= t('nodes.downloadSshKeyMissing') %>" aria-disabled="true">
527+
<span class="action-icon"><i class="ti ti-download"></i></span>
528+
<span><%= t('nodes.downloadSshKey') %></span>
529+
</span>
530+
<% } %>
520531
<button class="action-btn" onclick="openTerminal()">
521532
<span class="action-icon"><i class="ti ti-terminal-2"></i></span>
522533
<span><%= t('nodes.terminal') %></span>

0 commit comments

Comments
 (0)