Skip to content

Commit 4e10db9

Browse files
committed
Merge branch 'dev'
2 parents 77a2fb0 + aaeab56 commit 4e10db9

14 files changed

Lines changed: 325 additions & 45 deletions

File tree

src/locales/en.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -461,6 +461,8 @@
461461
"xrayExtraInbound": "Extra inbound",
462462
"xrayInboundLabel": "Label",
463463
"xrayInboundLabelPlaceholder": "e.g. Mobile, CDN, Backup",
464+
"xrayInboundUniqueName": "Unique name",
465+
"xrayInboundUniqueNameHint": "When enabled, the subscription uses just the label instead of \"Node name (label)\"",
464466
"xrayInboundTag": "Inbound tag",
465467
"xrayInboundPortConflict": "This port is already used by another inbound or the API",
466468
"xrayInboundTagConflict": "This tag is already used by another inbound",
@@ -1096,7 +1098,9 @@
10961098
"events": "Events",
10971099
"eventsHint": "empty = all events",
10981100
"test": "Test",
1099-
"urlRequired": "Enter a URL first"
1101+
"urlRequired": "Enter a URL first",
1102+
"testEventDefault": "Generic test",
1103+
"testEventHint": "Send a sample payload for this event"
11001104
},
11011105
"network": {
11021106
"title": "Network Map",

src/locales/ru.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -461,6 +461,8 @@
461461
"xrayExtraInbound": "Дополнительный inbound",
462462
"xrayInboundLabel": "Метка",
463463
"xrayInboundLabelPlaceholder": "Например: Mobile, CDN, Backup",
464+
"xrayInboundUniqueName": "Уникальное имя",
465+
"xrayInboundUniqueNameHint": "Если включено, в подписке используется только метка вместо «Имя ноды (метка)»",
464466
"xrayInboundTag": "Тег inbound'а",
465467
"xrayInboundPortConflict": "Этот порт уже занят другим inbound'ом или API",
466468
"xrayInboundTagConflict": "Этот тег уже используется другим inbound'ом",
@@ -1096,7 +1098,9 @@
10961098
"events": "События",
10971099
"eventsHint": "пусто = все события",
10981100
"test": "Тест",
1099-
"urlRequired": "Сначала введите URL"
1101+
"urlRequired": "Сначала введите URL",
1102+
"testEventDefault": "Обычный тест",
1103+
"testEventHint": "Отправить пример пейлоада для выбранного события"
11001104
},
11011105
"network": {
11021106
"title": "Карта сети",

src/models/hyNodeModel.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,9 @@ const xrayExtraInboundSchema = new mongoose.Schema({
113113
// Stable client-generated id (uuid) used to track edits across form submits
114114
id: { type: String, required: true },
115115
label: { type: String, default: '' },
116+
// When true, the subscription server name is just the label (issue #74)
117+
// instead of "<node name> (<label>)".
118+
uniqueName: { type: Boolean, default: false },
116119
port: { type: Number, required: true },
117120
inboundTag: { type: String, required: true },
118121

src/routes/panel/helpers.js

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,9 @@ function parseExtraInbounds(body) {
112112
};
113113
const ports = arr('xray_extra_port');
114114
const labels = arr('xray_extra_label');
115+
// Unchecked checkboxes are not submitted, so we identify "uniqueName" rows
116+
// by the inbound id used as the checkbox value (kept stable on the form).
117+
const uniqueNameIds = new Set(arr('xray_extra_uniqueName').map(v => String(v || '')));
115118
const tags = arr('xray_extra_inboundTag');
116119
const transports = arr('xray_extra_transport');
117120
const securities = arr('xray_extra_security');
@@ -145,9 +148,11 @@ function parseExtraInbounds(body) {
145148
const transport = _pickEnum(transports[i], XRAY_TRANSPORT_VALUES, 'tcp');
146149
const security = _pickEnum(securities[i], XRAY_SECURITY_VALUES, 'reality');
147150

151+
const id = String(idArr[i] || '').trim() || `extra-${i + 1}`;
148152
const inbound = {
149-
id: String(idArr[i] || '').trim() || `extra-${i + 1}`,
153+
id,
150154
label: String(labels[i] || '').trim().slice(0, 64),
155+
uniqueName: uniqueNameIds.has(id),
151156
port,
152157
inboundTag: String(tags[i] || '').trim() || `vless-extra-${i + 1}`,
153158
transport,

src/routes/panel/settings.js

Lines changed: 89 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -604,6 +604,88 @@ router.get('/settings/backups-s3', async (req, res) => {
604604
}
605605
});
606606

607+
// GET /settings/backups/download - Download a local backup file
608+
// Query: ?name=hysteria-backup-YYYY-MM-DDTHH-mm-ss.tar.gz
609+
router.get('/settings/backups/download', async (req, res) => {
610+
try {
611+
const backupService = require('../../services/backupService');
612+
const fsSync = require('fs');
613+
614+
const name = String(req.query.name || '');
615+
const localPath = backupService.getLocalBackupPath(name);
616+
617+
let stats;
618+
try {
619+
stats = await require('fs').promises.stat(localPath);
620+
} catch {
621+
return res.status(404).json({ error: 'Backup file not found' });
622+
}
623+
624+
const safeName = require('path').basename(localPath);
625+
res.setHeader('Content-Type', 'application/gzip');
626+
res.setHeader('Content-Length', stats.size);
627+
res.setHeader('Content-Disposition', `attachment; filename="${safeName}"`);
628+
res.setHeader('Cache-Control', 'no-store');
629+
630+
const stream = fsSync.createReadStream(localPath);
631+
stream.on('error', (err) => {
632+
logger.error(`[Backup] Local download stream error: ${err.message}`);
633+
if (!res.headersSent) res.status(500).end();
634+
else res.destroy(err);
635+
});
636+
stream.pipe(res);
637+
638+
logger.info(`[Panel] Backup download (local): ${safeName} by ${req.session.adminUsername}`);
639+
} catch (error) {
640+
logger.error(`[Panel] Backup download error: ${error.message}`);
641+
if (!res.headersSent) res.status(400).json({ error: error.message });
642+
}
643+
});
644+
645+
// GET /settings/backups-s3/download - Download an S3 backup file
646+
// Query: ?key=<full S3 object key>
647+
router.get('/settings/backups-s3/download', async (req, res) => {
648+
try {
649+
const backupService = require('../../services/backupService');
650+
const path = require('path');
651+
652+
const key = String(req.query.key || '').trim();
653+
if (!key) return res.status(400).json({ error: 'Key is required' });
654+
655+
const settings = await Settings.get();
656+
if (!settings?.backup?.s3?.enabled) {
657+
return res.status(400).json({ error: 'S3 not configured' });
658+
}
659+
660+
// Sanity check: key must live in the configured prefix to prevent
661+
// arbitrary object reads from the bucket via this endpoint.
662+
const prefix = (settings.backup.s3.prefix || 'backups').replace(/\/+$/, '');
663+
if (!key.startsWith(`${prefix}/hysteria-backup-`) || !key.endsWith('.tar.gz')) {
664+
return res.status(400).json({ error: 'Invalid backup key' });
665+
}
666+
667+
const { stream, contentLength, contentType } = await backupService.getS3BackupStream(settings, key);
668+
669+
const safeName = path.basename(key);
670+
res.setHeader('Content-Type', contentType || 'application/gzip');
671+
if (contentLength) res.setHeader('Content-Length', contentLength);
672+
res.setHeader('Content-Disposition', `attachment; filename="${safeName}"`);
673+
res.setHeader('Cache-Control', 'no-store');
674+
675+
stream.on('error', (err) => {
676+
logger.error(`[Backup] S3 download stream error: ${err.message}`);
677+
if (!res.headersSent) res.status(500).end();
678+
else res.destroy(err);
679+
});
680+
stream.pipe(res);
681+
682+
logger.info(`[Panel] Backup download (S3): ${safeName} by ${req.session.adminUsername}`);
683+
} catch (error) {
684+
logger.error(`[Panel] S3 backup download error: ${error.message}`);
685+
if (!res.headersSent) res.status(500).json({ error: error.message });
686+
}
687+
});
688+
607689
// POST /settings/restore-backup - Restore from backup (local or S3)
608690
router.post('/settings/restore-backup', async (req, res) => {
609691
try {
@@ -714,16 +796,20 @@ router.post('/api-keys/:id/delete', async (req, res) => {
714796
// POST /settings/test-webhook - Send test webhook
715797
router.post('/settings/test-webhook', async (req, res) => {
716798
try {
717-
const { url, secret } = req.body;
799+
const { url, secret, event } = req.body;
718800

719801
if (!url || !url.trim()) {
720802
return res.status(400).json({ error: 'URL is required' });
721803
}
722804

723-
const result = await webhookService.test(url.trim(), secret || '');
805+
// Whitelist event against EVENTS to prevent spoofing arbitrary headers.
806+
const knownEvents = Object.values(webhookService.EVENTS);
807+
const safeEvent = event && knownEvents.includes(event) ? event : undefined;
808+
809+
const result = await webhookService.test(url.trim(), secret || '', safeEvent);
724810

725811
if (result.success) {
726-
logger.info(`[Panel] Webhook test OK: ${url} (HTTP ${result.status})`);
812+
logger.info(`[Panel] Webhook test OK: ${url} (HTTP ${result.status})${safeEvent ? ` event=${safeEvent}` : ''}`);
727813
res.json({ success: true, status: result.status });
728814
} else {
729815
res.status(400).json({ success: false, error: result.error, status: result.status });

src/routes/panel/users.js

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -232,7 +232,13 @@ router.post('/users', async (req, res) => {
232232
logger.error(`[Panel] Xray addUser error for ${userId}: ${err.message}`);
233233
});
234234
}
235-
235+
236+
webhookService.emit(webhookService.EVENTS.USER_CREATED, {
237+
userId,
238+
username: username || '',
239+
groups,
240+
});
241+
236242
res.redirect(`/panel/users/${userId}`);
237243
} catch (error) {
238244
res.status(500).send(`${res.locals.t?.('common.error') || 'Error'}: ${error.message}`);

src/routes/subscription.js

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -290,6 +290,9 @@ function getXrayPublishedInbounds(node) {
290290
nameSuffix: i.label && String(i.label).trim()
291291
? String(i.label).trim()
292292
: `${i.transport || 'tcp'}:${i.port}`,
293+
// When uniqueName is set the label fully replaces the node name
294+
// in the published server name (issue #74).
295+
uniqueName: !!i.uniqueName,
293296
transport: i.transport,
294297
security: i.security,
295298
flow: i.flow,
@@ -312,10 +315,15 @@ function getXrayPublishedInbounds(node) {
312315

313316
/**
314317
* Build a server display name for a single inbound. Main inbound uses the
315-
* node label as-is; extras append the suffix in parentheses for clarity.
318+
* node label as-is; extras append the suffix in parentheses unless the inbound
319+
* is marked as `uniqueName`, in which case the label replaces the node name.
316320
*/
317321
function _xrayInboundName(node, inbound) {
318-
const base = `${node.flag || ''} ${node.name}`.trim();
322+
const flag = node.flag || '';
323+
if (inbound.uniqueName && inbound.nameSuffix) {
324+
return `${flag} ${inbound.nameSuffix}`.trim();
325+
}
326+
const base = `${flag} ${node.name}`.trim();
319327
return inbound.nameSuffix ? `${base} (${inbound.nameSuffix})` : base;
320328
}
321329

@@ -1240,10 +1248,16 @@ async function generateHTML(user, nodes, token, baseUrl, settings) {
12401248
inbounds.forEach(inbound => {
12411249
const uri = generateVlessURIForInbound(user, node, inbound);
12421250
if (uri) {
1251+
let location;
1252+
if (inbound.uniqueName && inbound.nameSuffix) {
1253+
location = inbound.nameSuffix;
1254+
} else if (inbound.nameSuffix) {
1255+
location = `${node.name} (${inbound.nameSuffix})`;
1256+
} else {
1257+
location = node.name;
1258+
}
12431259
allConfigs.push({
1244-
location: inbound.nameSuffix
1245-
? `${node.name} (${inbound.nameSuffix})`
1246-
: node.name,
1260+
location,
12471261
flag: node.flag || '🌐',
12481262
name: 'VLESS',
12491263
uri,

src/services/backupService.js

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -371,6 +371,46 @@ async function listS3Backups(settings) {
371371
}
372372
}
373373

374+
/**
375+
* Get a readable stream for an S3 backup object (for HTTP download).
376+
* Returns { stream, contentLength, contentType }.
377+
*/
378+
async function getS3BackupStream(settings, key) {
379+
const client = getS3Client(settings);
380+
if (!client) {
381+
throw new Error('S3 client not available');
382+
}
383+
384+
const { GetObjectCommand } = require('@aws-sdk/client-s3');
385+
const bucket = settings.backup.s3.bucket;
386+
387+
const response = await client.send(new GetObjectCommand({
388+
Bucket: bucket,
389+
Key: key,
390+
}));
391+
392+
return {
393+
stream: response.Body,
394+
contentLength: response.ContentLength,
395+
contentType: response.ContentType || 'application/gzip',
396+
};
397+
}
398+
399+
/**
400+
* Resolve absolute path of a local backup file by name (with safety checks).
401+
*/
402+
function getLocalBackupPath(name) {
403+
const backupDir = path.join(__dirname, '../../backups');
404+
const safeName = path.basename(name || '');
405+
if (!safeName || safeName === '.' || safeName === '..') {
406+
throw new Error('Invalid backup name');
407+
}
408+
if (!safeName.startsWith('hysteria-backup-') || !safeName.endsWith('.tar.gz')) {
409+
throw new Error('Invalid backup name');
410+
}
411+
return path.join(backupDir, safeName);
412+
}
413+
374414
/**
375415
* Download backup from S3 for restore
376416
*/
@@ -492,6 +532,8 @@ module.exports = {
492532
listBackups,
493533
listS3Backups,
494534
downloadFromS3,
535+
getS3BackupStream,
536+
getLocalBackupPath,
495537
restoreBackup,
496538
shouldRunBackup,
497539
scheduledBackup,

0 commit comments

Comments
 (0)