Skip to content

Commit b524d67

Browse files
committed
feat: add Salamander obfuscation support for Hysteria 2
- Add obfs.type and obfs.password fields to node model - Generate obfs block in server config when enabled - Add obfs params to hysteria2:// URI (?obfs=salamander&obfs-password=xxx) - Add obfs block to sing-box JSON outbounds - Add obfs/obfs-password to Clash YAML proxies - Add UI section for obfs configuration in node form
1 parent 8bc6a7a commit b524d67

File tree

5 files changed

+83
-5
lines changed

5 files changed

+83
-5
lines changed

src/models/hyNodeModel.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,10 @@ const hyNodeSchema = new mongoose.Schema({
7676
port: { type: Number, default: 443 },
7777
portRange: { type: String, default: '20000-50000' },
7878
portConfigs: { type: [portConfigSchema], default: [] },
79+
obfs: {
80+
type: { type: String, enum: ['', 'salamander'], default: '' },
81+
password: { type: String, default: '' },
82+
},
7983
statsPort: { type: Number, default: 9999 },
8084
statsSecret: { type: String, default: '' },
8185

src/routes/panel.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -463,6 +463,10 @@ router.post('/nodes', requireAuth, async (req, res) => {
463463
active: req.body.active === 'on',
464464
useCustomConfig: req.body.useCustomConfig === 'on',
465465
customConfig: req.body.customConfig || '',
466+
obfs: {
467+
type: req.body['obfs.type'] || '',
468+
password: req.body['obfs.password'] || '',
469+
},
466470
ssh: {
467471
port: parseInt(req.body['ssh.port']) || 22,
468472
username: req.body['ssh.username'] || 'root',
@@ -531,6 +535,8 @@ router.post('/nodes/:id', requireAuth, async (req, res) => {
531535
active: req.body.active === 'on',
532536
useCustomConfig: req.body.useCustomConfig === 'on',
533537
customConfig: req.body.customConfig || '',
538+
'obfs.type': req.body['obfs.type'] || '',
539+
'obfs.password': req.body['obfs.password'] || '',
534540
flag: req.body.flag || '',
535541
'ssh.port': parseInt(req.body['ssh.port']) || 22,
536542
'ssh.username': req.body['ssh.username'] || 'root',

src/routes/subscription.js

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ async function getUserByToken(token) {
4646
{ userId: token }
4747
]
4848
})
49-
.populate('nodes', 'active name type status onlineUsers maxOnlineUsers rankingCoefficient domain sni ip port portRange portConfigs flag xray')
49+
.populate('nodes', 'active name type status onlineUsers maxOnlineUsers rankingCoefficient domain sni ip port portRange portConfigs obfs flag xray')
5050
.populate('groups', '_id name subscriptionTitle');
5151

5252
return user;
@@ -80,9 +80,9 @@ async function getActiveNodesWithCache() {
8080
const cached = await cache.getActiveNodes();
8181
if (cached) return cached;
8282

83-
// Include type and xray fields needed for VLESS URI generation
83+
// Include type, xray, and obfs fields needed for URI generation
8484
const nodes = await HyNode.find({ active: true })
85-
.select('name type flag ip domain sni port portRange portConfigs active status onlineUsers maxOnlineUsers rankingCoefficient groups xray')
85+
.select('name type flag ip domain sni port portRange portConfigs obfs active status onlineUsers maxOnlineUsers rankingCoefficient groups xray')
8686
.lean();
8787
await cache.setActiveNodes(nodes);
8888
return nodes;
@@ -179,6 +179,9 @@ function getNodeConfigs(node) {
179179
// hasCert: true if domain is set (ACME = valid cert)
180180
const hasCert = !!node.domain;
181181

182+
const obfs = node.obfs?.type || '';
183+
const obfsPassword = node.obfs?.password || '';
184+
182185
if (node.portConfigs && node.portConfigs.length > 0) {
183186
node.portConfigs.filter(c => c.enabled).forEach(cfg => {
184187
configs.push({
@@ -188,13 +191,15 @@ function getNodeConfigs(node) {
188191
portRange: cfg.portRange || '',
189192
sni,
190193
hasCert,
194+
obfs,
195+
obfsPassword,
191196
});
192197
});
193198
} else {
194-
configs.push({ name: 'TLS', host, port: node.port || 443, portRange: '', sni, hasCert });
199+
configs.push({ name: 'TLS', host, port: node.port || 443, portRange: '', sni, hasCert, obfs, obfsPassword });
195200
// Порт 80 убран (используется для ACME)
196201
if (node.portRange) {
197-
configs.push({ name: 'Hopping', host, port: node.port || 443, portRange: node.portRange, sni, hasCert });
202+
configs.push({ name: 'Hopping', host, port: node.port || 443, portRange: node.portRange, sni, hasCert, obfs, obfsPassword });
198203
}
199204
}
200205

@@ -214,6 +219,10 @@ function generateURI(user, node, config) {
214219
// insecure=1 only if no valid certificate (self-signed without domain)
215220
params.push(`insecure=${config.hasCert ? '0' : '1'}`);
216221
if (config.portRange) params.push(`mport=${config.portRange}`);
222+
if (config.obfs === 'salamander' && config.obfsPassword) {
223+
params.push('obfs=salamander');
224+
params.push(`obfs-password=${encodeURIComponent(config.obfsPassword)}`);
225+
}
217226

218227
const name = `${node.flag || ''} ${node.name} ${config.name}`.trim();
219228
const uri = `hysteria2://${auth}@${config.host}:${config.port}?${params.join('&')}#${encodeURIComponent(name)}`;
@@ -390,6 +399,9 @@ function generateClashYAML(user, nodes) {
390399
- h3`;
391400

392401
if (cfg.portRange) proxy += `\n ports: ${cfg.portRange}`;
402+
if (cfg.obfs === 'salamander' && cfg.obfsPassword) {
403+
proxy += `\n obfs: salamander\n obfs-password: "${cfg.obfsPassword}"`;
404+
}
393405
proxies.push(proxy);
394406
});
395407
}
@@ -499,6 +511,10 @@ function generateSingboxJSON(user, nodes) {
499511
outbound.server_port = cfg.port;
500512
}
501513

514+
if (cfg.obfs === 'salamander' && cfg.obfsPassword) {
515+
outbound.obfs = { type: 'salamander', password: cfg.obfsPassword };
516+
}
517+
502518
proxyOutbounds.push(outbound);
503519
});
504520
}

src/services/configGenerator.js

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,13 @@ function generateNodeConfig(node, authUrl, options = {}) {
8282
}
8383
}
8484

85+
if (node.obfs?.password) {
86+
config.obfs = {
87+
type: 'salamander',
88+
salamander: { password: node.obfs.password },
89+
};
90+
}
91+
8592
if (node.statsPort && node.statsSecret) {
8693
config.trafficStats = {
8794
listen: `:${node.statsPort}`,
@@ -207,6 +214,13 @@ function generateNodeConfigACME(node, authUrl, domain, email, options = {}) {
207214
},
208215
};
209216

217+
if (node.obfs?.password) {
218+
config.obfs = {
219+
type: 'salamander',
220+
salamander: { password: node.obfs.password },
221+
};
222+
}
223+
210224
if (node.statsPort && node.statsSecret) {
211225
config.trafficStats = {
212226
listen: `:${node.statsPort}`,

views/node-form.ejs

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -320,6 +320,30 @@
320320
</div>
321321
</div>
322322
323+
<div class="form-section">
324+
<h3>Obfuscation (obfs)</h3>
325+
<small class="section-hint">Salamander скрывает QUIC-трафик от DPI. Включайте только если оператор блокирует Hysteria без обфускации — на мобильных сетях может работать хуже.</small>
326+
<div class="form-row" style="margin-top:12px;">
327+
<div class="form-group">
328+
<label for="obfsType">Тип обфускации</label>
329+
<select id="obfsType" name="obfs.type" onchange="toggleObfsPassword()">
330+
<option value="" <%= !node?.obfs?.type ? 'selected' : '' %>>Отключено</option>
331+
<option value="salamander" <%= node?.obfs?.type === 'salamander' ? 'selected' : '' %>>Salamander</option>
332+
</select>
333+
</div>
334+
<div class="form-group" id="obfsPasswordGroup" style="<%= node?.obfs?.type === 'salamander' ? '' : 'display:none' %>">
335+
<label for="obfsPassword">Пароль obfs</label>
336+
<div class="input-with-button">
337+
<input type="text" id="obfsPassword" name="obfs.password"
338+
value="<%= node?.obfs?.password || '' %>"
339+
placeholder="cry_me_a_r1ver">
340+
<button type="button" class="btn btn-sm" onclick="generateObfsPassword()" title="Сгенерировать пароль"><i class="ti ti-dice"></i></button>
341+
</div>
342+
<small>Одинаковый пароль должен быть на сервере и в клиенте</small>
343+
</div>
344+
</div>
345+
</div>
346+
323347
<div class="form-section">
324348
<h3><%= t('nodes.hysteriaConfig') %></h3>
325349
<div class="form-group">
@@ -819,6 +843,20 @@ function generateSecret() {
819843
document.getElementById('statsSecret').value = secret;
820844
}
821845
846+
function toggleObfsPassword() {
847+
const type = document.getElementById('obfsType').value;
848+
document.getElementById('obfsPasswordGroup').style.display = type === 'salamander' ? '' : 'none';
849+
}
850+
851+
function generateObfsPassword() {
852+
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*';
853+
let pwd = '';
854+
for (let i = 0; i < 24; i++) {
855+
pwd += chars.charAt(Math.floor(Math.random() * chars.length));
856+
}
857+
document.getElementById('obfsPassword').value = pwd;
858+
}
859+
822860
function toggleCustomConfig() {
823861
const checked = document.getElementById('useCustomConfig').checked;
824862
document.getElementById('customConfigSection').style.display = checked ? 'block' : 'none';

0 commit comments

Comments
 (0)