mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-06-05 20:54:14 +00:00
✨ Introduce extended XHTTP and external proxy settings
This commit is contained in:
parent
237b7c898d
commit
326d027f6c
7 changed files with 462 additions and 60 deletions
|
|
@ -499,14 +499,13 @@ export class HTTPUpgradeStreamSettings extends XrayCommonClass {
|
|||
// Mirrors the inbound (server-side) view of Xray-core's SplitHTTPConfig
|
||||
// (infra/conf/transport_internet.go). Only fields the server actually
|
||||
// reads at runtime, plus the bidirectional fields the server enforces,
|
||||
// live here. Client-only fields (uplinkHTTPMethod, uplinkChunkSize,
|
||||
// noGRPCHeader, scMinPostsIntervalMs, xmux, downloadSettings) belong on
|
||||
// the outbound class instead.
|
||||
// live here. Most client-only fields (uplinkChunkSize, noGRPCHeader,
|
||||
// scMinPostsIntervalMs, xmux, downloadSettings) belong on the outbound
|
||||
// class instead.
|
||||
//
|
||||
// `headers` is technically client-only at runtime (xray's listener
|
||||
// doesn't read it) but we keep it here so the admin can set request
|
||||
// headers that get embedded into the share link's `extra` blob — the
|
||||
// client picks them up from there.
|
||||
// `headers` and `uplinkHTTPMethod` are client-only at runtime (xray's
|
||||
// listener doesn't read them) but we keep them here so the admin can set
|
||||
// values that get embedded into the share link's `extra` blob.
|
||||
export class xHTTPStreamSettings extends XrayCommonClass {
|
||||
constructor(
|
||||
// Bidirectional — must match between client and server
|
||||
|
|
@ -533,6 +532,7 @@ export class xHTTPStreamSettings extends XrayCommonClass {
|
|||
serverMaxHeaderBytes = 0,
|
||||
// URL-share only — embedded in the link's `extra` blob so clients
|
||||
// pick them up; xray's listener ignores them at runtime.
|
||||
uplinkHTTPMethod = '',
|
||||
headers = [],
|
||||
) {
|
||||
super();
|
||||
|
|
@ -556,6 +556,7 @@ export class xHTTPStreamSettings extends XrayCommonClass {
|
|||
this.scMaxBufferedPosts = scMaxBufferedPosts;
|
||||
this.scStreamUpServerSecs = scStreamUpServerSecs;
|
||||
this.serverMaxHeaderBytes = serverMaxHeaderBytes;
|
||||
this.uplinkHTTPMethod = uplinkHTTPMethod;
|
||||
this.headers = headers;
|
||||
}
|
||||
|
||||
|
|
@ -589,6 +590,7 @@ export class xHTTPStreamSettings extends XrayCommonClass {
|
|||
json.scMaxBufferedPosts,
|
||||
json.scStreamUpServerSecs,
|
||||
json.serverMaxHeaderBytes,
|
||||
json.uplinkHTTPMethod,
|
||||
XrayCommonClass.toHeaders(json.headers),
|
||||
);
|
||||
}
|
||||
|
|
@ -615,6 +617,7 @@ export class xHTTPStreamSettings extends XrayCommonClass {
|
|||
scMaxBufferedPosts: this.scMaxBufferedPosts,
|
||||
scStreamUpServerSecs: this.scStreamUpServerSecs,
|
||||
serverMaxHeaderBytes: this.serverMaxHeaderBytes,
|
||||
uplinkHTTPMethod: this.uplinkHTTPMethod,
|
||||
headers: XrayCommonClass.toV2Headers(this.headers, false),
|
||||
};
|
||||
}
|
||||
|
|
@ -1584,10 +1587,9 @@ export class Inbound extends XrayCommonClass {
|
|||
// - server-only (noSSEHeader, scMaxBufferedPosts,
|
||||
// scStreamUpServerSecs, serverMaxHeaderBytes) — client wouldn't
|
||||
// read them, so emitting them just bloats the URL.
|
||||
// - client-only (headers, uplinkHTTPMethod, uplinkChunkSize,
|
||||
// noGRPCHeader, scMinPostsIntervalMs, xmux, downloadSettings) —
|
||||
// not on the inbound class at all; the client configures them
|
||||
// locally.
|
||||
// - client-only values are included only when present on the inbound
|
||||
// object. Imported/API-created configs can carry them there, and
|
||||
// the share link is the only place clients can receive them.
|
||||
//
|
||||
// Truthy-only guards keep default inbounds emitting the same compact
|
||||
// URL they did before this helper grew.
|
||||
|
|
@ -1607,21 +1609,35 @@ export class Inbound extends XrayCommonClass {
|
|||
});
|
||||
}
|
||||
|
||||
if (typeof xhttp.mode === 'string' && xhttp.mode.length > 0) {
|
||||
extra.mode = xhttp.mode;
|
||||
}
|
||||
|
||||
const stringFields = [
|
||||
"uplinkHTTPMethod",
|
||||
"sessionPlacement", "sessionKey",
|
||||
"seqPlacement", "seqKey",
|
||||
"uplinkDataPlacement", "uplinkDataKey",
|
||||
"scMaxEachPostBytes",
|
||||
"scMaxEachPostBytes", "scMinPostsIntervalMs",
|
||||
];
|
||||
for (const k of stringFields) {
|
||||
const v = xhttp[k];
|
||||
if (typeof v === 'string' && v.length > 0) extra[k] = v;
|
||||
}
|
||||
|
||||
const uplinkChunkSize = xhttp.uplinkChunkSize;
|
||||
if ((typeof uplinkChunkSize === 'number' && uplinkChunkSize !== 0) ||
|
||||
(typeof uplinkChunkSize === 'string' && uplinkChunkSize.length > 0)) {
|
||||
extra.uplinkChunkSize = uplinkChunkSize;
|
||||
}
|
||||
|
||||
if (xhttp.noGRPCHeader === true) {
|
||||
extra.noGRPCHeader = true;
|
||||
}
|
||||
|
||||
for (const k of ["xmux", "downloadSettings"]) {
|
||||
const v = xhttp[k];
|
||||
if (v && typeof v === 'object' && Object.keys(v).length > 0) {
|
||||
extra[k] = v;
|
||||
}
|
||||
}
|
||||
|
||||
// Headers — emitted as the {name: value} map upstream's struct
|
||||
// expects. The server runtime ignores this field, but the client
|
||||
// (consuming the share link) honors it.
|
||||
|
|
@ -1680,6 +1696,27 @@ export class Inbound extends XrayCommonClass {
|
|||
}
|
||||
}
|
||||
|
||||
static externalProxyAlpn(value) {
|
||||
if (Array.isArray(value)) return value.filter(Boolean).join(',');
|
||||
return typeof value === 'string' ? value : '';
|
||||
}
|
||||
|
||||
static applyExternalProxyTLSParams(externalProxy, params, security) {
|
||||
if (!externalProxy || security !== 'tls') return;
|
||||
if (externalProxy.dest?.length > 0) params.set("sni", externalProxy.dest);
|
||||
if (externalProxy.fingerprint?.length > 0) params.set("fp", externalProxy.fingerprint);
|
||||
const alpn = Inbound.externalProxyAlpn(externalProxy.alpn);
|
||||
if (alpn.length > 0) params.set("alpn", alpn);
|
||||
}
|
||||
|
||||
static applyExternalProxyTLSObj(externalProxy, obj, security) {
|
||||
if (!externalProxy || !obj || security !== 'tls') return;
|
||||
if (externalProxy.dest?.length > 0) obj.sni = externalProxy.dest;
|
||||
if (externalProxy.fingerprint?.length > 0) obj.fp = externalProxy.fingerprint;
|
||||
const alpn = Inbound.externalProxyAlpn(externalProxy.alpn);
|
||||
if (alpn.length > 0) obj.alpn = alpn;
|
||||
}
|
||||
|
||||
static hasShareableFinalMaskValue(value) {
|
||||
if (value == null) {
|
||||
return false;
|
||||
|
|
@ -1894,7 +1931,7 @@ export class Inbound extends XrayCommonClass {
|
|||
this.sniffing = new Sniffing();
|
||||
}
|
||||
|
||||
genVmessLink(address = '', port = this.port, forceTls, remark = '', clientId, security) {
|
||||
genVmessLink(address = '', port = this.port, forceTls, remark = '', clientId, security, externalProxy = null) {
|
||||
if (this.protocol !== Protocols.VMESS) {
|
||||
return '';
|
||||
}
|
||||
|
|
@ -1958,11 +1995,12 @@ export class Inbound extends XrayCommonClass {
|
|||
obj.alpn = this.stream.tls.alpn.join(',');
|
||||
}
|
||||
}
|
||||
Inbound.applyExternalProxyTLSObj(externalProxy, obj, tls);
|
||||
|
||||
return 'vmess://' + Base64.encode(JSON.stringify(obj, null, 2));
|
||||
}
|
||||
|
||||
genVLESSLink(address = '', port = this.port, forceTls, remark = '', clientId, flow) {
|
||||
genVLESSLink(address = '', port = this.port, forceTls, remark = '', clientId, flow, externalProxy = null) {
|
||||
const uuid = clientId;
|
||||
const type = this.stream.network;
|
||||
const security = forceTls == 'same' ? this.stream.security : forceTls;
|
||||
|
|
@ -2028,6 +2066,7 @@ export class Inbound extends XrayCommonClass {
|
|||
params.set("flow", flow);
|
||||
}
|
||||
}
|
||||
Inbound.applyExternalProxyTLSParams(externalProxy, params, security);
|
||||
}
|
||||
|
||||
else if (security === 'reality') {
|
||||
|
|
@ -2064,7 +2103,7 @@ export class Inbound extends XrayCommonClass {
|
|||
return url.toString();
|
||||
}
|
||||
|
||||
genSSLink(address = '', port = this.port, forceTls, remark = '', clientPassword) {
|
||||
genSSLink(address = '', port = this.port, forceTls, remark = '', clientPassword, externalProxy = null) {
|
||||
let settings = this.settings;
|
||||
const type = this.stream.network;
|
||||
const security = forceTls == 'same' ? this.stream.security : forceTls;
|
||||
|
|
@ -2126,6 +2165,7 @@ export class Inbound extends XrayCommonClass {
|
|||
params.set("sni", this.stream.tls.sni);
|
||||
}
|
||||
}
|
||||
Inbound.applyExternalProxyTLSParams(externalProxy, params, security);
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -2142,7 +2182,7 @@ export class Inbound extends XrayCommonClass {
|
|||
return url.toString();
|
||||
}
|
||||
|
||||
genTrojanLink(address = '', port = this.port, forceTls, remark = '', clientPassword) {
|
||||
genTrojanLink(address = '', port = this.port, forceTls, remark = '', clientPassword, externalProxy = null) {
|
||||
const security = forceTls == 'same' ? this.stream.security : forceTls;
|
||||
const type = this.stream.network;
|
||||
const params = new Map();
|
||||
|
|
@ -2203,6 +2243,7 @@ export class Inbound extends XrayCommonClass {
|
|||
params.set("sni", this.stream.tls.sni);
|
||||
}
|
||||
}
|
||||
Inbound.applyExternalProxyTLSParams(externalProxy, params, security);
|
||||
}
|
||||
|
||||
else if (security === 'reality') {
|
||||
|
|
@ -2344,16 +2385,16 @@ export class Inbound extends XrayCommonClass {
|
|||
return links.join('\r\n');
|
||||
}
|
||||
|
||||
genLink(address = '', port = this.port, forceTls = 'same', remark = '', client) {
|
||||
genLink(address = '', port = this.port, forceTls = 'same', remark = '', client, externalProxy = null) {
|
||||
switch (this.protocol) {
|
||||
case Protocols.VMESS:
|
||||
return this.genVmessLink(address, port, forceTls, remark, client.id, client.security);
|
||||
return this.genVmessLink(address, port, forceTls, remark, client.id, client.security, externalProxy);
|
||||
case Protocols.VLESS:
|
||||
return this.genVLESSLink(address, port, forceTls, remark, client.id, client.flow);
|
||||
return this.genVLESSLink(address, port, forceTls, remark, client.id, client.flow, externalProxy);
|
||||
case Protocols.SHADOWSOCKS:
|
||||
return this.genSSLink(address, port, forceTls, remark, this.isSSMultiUser ? client.password : '');
|
||||
return this.genSSLink(address, port, forceTls, remark, this.isSSMultiUser ? client.password : '', externalProxy);
|
||||
case Protocols.TROJAN:
|
||||
return this.genTrojanLink(address, port, forceTls, remark, client.password);
|
||||
return this.genTrojanLink(address, port, forceTls, remark, client.password, externalProxy);
|
||||
case Protocols.HYSTERIA:
|
||||
return this.genHysteriaLink(address, port, remark, client.auth.length > 0 ? client.auth : this.stream.hysteria.auth);
|
||||
default: return '';
|
||||
|
|
@ -2384,7 +2425,7 @@ export class Inbound extends XrayCommonClass {
|
|||
let r = orderChars.split('').map(char => orders[char]).filter(x => x.length > 0).join(separationChar);
|
||||
result.push({
|
||||
remark: r,
|
||||
link: this.genLink(ep.dest, ep.port, ep.forceTls, r, client)
|
||||
link: this.genLink(ep.dest, ep.port, ep.forceTls, r, client, ep)
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1407,10 +1407,24 @@ export class Outbound extends CommonClass {
|
|||
});
|
||||
}
|
||||
// Bidirectional string fields carried in the extra block
|
||||
const xFields = ["sessionPlacement", "sessionKey", "seqPlacement", "seqKey", "uplinkDataPlacement", "uplinkDataKey", "scMaxEachPostBytes"];
|
||||
const xFields = [
|
||||
"uplinkHTTPMethod",
|
||||
"sessionPlacement", "sessionKey",
|
||||
"seqPlacement", "seqKey",
|
||||
"uplinkDataPlacement", "uplinkDataKey",
|
||||
"scMaxEachPostBytes", "scMinPostsIntervalMs",
|
||||
];
|
||||
xFields.forEach(k => {
|
||||
if (typeof json[k] === 'string' && json[k]) xh[k] = json[k];
|
||||
});
|
||||
if (typeof json.uplinkChunkSize === 'number' && json.uplinkChunkSize !== 0) xh.uplinkChunkSize = json.uplinkChunkSize;
|
||||
if (typeof json.uplinkChunkSize === 'string' && json.uplinkChunkSize) xh.uplinkChunkSize = json.uplinkChunkSize;
|
||||
if (json.noGRPCHeader === true) xh.noGRPCHeader = true;
|
||||
if (json.xmux && typeof json.xmux === 'object') {
|
||||
xh.xmux = json.xmux;
|
||||
xh.enableXmux = true;
|
||||
}
|
||||
if (json.downloadSettings && typeof json.downloadSettings === 'object') xh.downloadSettings = json.downloadSettings;
|
||||
// Headers — VMess extra emits them as a {name: value} map
|
||||
if (json.headers && typeof json.headers === 'object' && !Array.isArray(json.headers)) {
|
||||
xh.headers = Object.entries(json.headers).map(([name, value]) => ({ name, value }));
|
||||
|
|
@ -1487,10 +1501,24 @@ export class Outbound extends CommonClass {
|
|||
});
|
||||
if (!xh.mode && typeof extra.mode === 'string' && extra.mode) xh.mode = extra.mode;
|
||||
// Bidirectional string fields carried inside the extra block
|
||||
const xFields = ["sessionPlacement", "sessionKey", "seqPlacement", "seqKey", "uplinkDataPlacement", "uplinkDataKey", "scMaxEachPostBytes"];
|
||||
const xFields = [
|
||||
"uplinkHTTPMethod",
|
||||
"sessionPlacement", "sessionKey",
|
||||
"seqPlacement", "seqKey",
|
||||
"uplinkDataPlacement", "uplinkDataKey",
|
||||
"scMaxEachPostBytes", "scMinPostsIntervalMs",
|
||||
];
|
||||
xFields.forEach(k => {
|
||||
if (typeof extra[k] === 'string' && extra[k]) xh[k] = extra[k];
|
||||
});
|
||||
if (typeof extra.uplinkChunkSize === 'number' && extra.uplinkChunkSize !== 0) xh.uplinkChunkSize = extra.uplinkChunkSize;
|
||||
if (typeof extra.uplinkChunkSize === 'string' && extra.uplinkChunkSize) xh.uplinkChunkSize = extra.uplinkChunkSize;
|
||||
if (extra.noGRPCHeader === true) xh.noGRPCHeader = true;
|
||||
if (extra.xmux && typeof extra.xmux === 'object') {
|
||||
xh.xmux = extra.xmux;
|
||||
xh.enableXmux = true;
|
||||
}
|
||||
if (extra.downloadSettings && typeof extra.downloadSettings === 'object') xh.downloadSettings = extra.downloadSettings;
|
||||
// Headers — extra emits them as a {name: value} map
|
||||
if (extra.headers && typeof extra.headers === 'object' && !Array.isArray(extra.headers)) {
|
||||
xh.headers = Object.entries(extra.headers).map(([name, value]) => ({ name, value }));
|
||||
|
|
|
|||
|
|
@ -101,6 +101,8 @@ const externalProxy = computed({
|
|||
dest: window.location.hostname,
|
||||
port: inbound.value.port,
|
||||
remark: '',
|
||||
fingerprint: '',
|
||||
alpn: [],
|
||||
}];
|
||||
} else {
|
||||
inbound.value.stream.externalProxy = [];
|
||||
|
|
@ -1597,6 +1599,15 @@ watch(() => inbound.value?.protocol, () => stampAdvancedTextFor('stream'));
|
|||
<a-form-item label="Padding Bytes">
|
||||
<a-input v-model:value="inbound.stream.xhttp.xPaddingBytes" />
|
||||
</a-form-item>
|
||||
<a-form-item label="Uplink HTTP Method">
|
||||
<a-select v-model:value="inbound.stream.xhttp.uplinkHTTPMethod">
|
||||
<a-select-option value="">Default (POST)</a-select-option>
|
||||
<a-select-option value="POST">POST</a-select-option>
|
||||
<a-select-option value="PUT">PUT</a-select-option>
|
||||
<a-select-option value="GET" :disabled="inbound.stream.xhttp.mode !== 'packet-up'">GET (packet-up
|
||||
only)</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label="Padding Obfs Mode">
|
||||
<a-switch v-model:checked="inbound.stream.xhttp.xPaddingObfsMode" />
|
||||
</a-form-item>
|
||||
|
|
@ -1674,32 +1685,42 @@ watch(() => inbound.value?.protocol, () => stampAdvancedTextFor('stream'));
|
|||
<a-form-item label="External Proxy">
|
||||
<a-switch v-model:checked="externalProxy" />
|
||||
<a-button v-if="externalProxy" size="small" type="primary" :style="{ marginLeft: '10px' }"
|
||||
@click="inbound.stream.externalProxy.push({ forceTls: 'same', dest: '', port: 443, remark: '' })">
|
||||
@click="inbound.stream.externalProxy.push({ forceTls: 'same', dest: '', port: 443, remark: '', fingerprint: '', alpn: [] })">
|
||||
<template #icon>
|
||||
<PlusOutlined />
|
||||
</template>
|
||||
</a-button>
|
||||
</a-form-item>
|
||||
<a-form-item v-if="externalProxy" :wrapper-col="{ span: 24 }">
|
||||
<a-input-group v-for="(row, idx) in inbound.stream.externalProxy" :key="`ep-${idx}`" compact
|
||||
:style="{ margin: '8px 0' }">
|
||||
<a-tooltip title="Force TLS">
|
||||
<a-select v-model:value="row.forceTls" :style="{ width: '20%' }">
|
||||
<a-select-option value="same">{{ t('pages.inbounds.same') }}</a-select-option>
|
||||
<a-select-option value="none">{{ t('none') }}</a-select-option>
|
||||
<a-select-option value="tls">TLS</a-select-option>
|
||||
<div v-for="(row, idx) in inbound.stream.externalProxy" :key="`ep-${idx}`" :style="{ margin: '8px 0' }">
|
||||
<a-input-group compact>
|
||||
<a-tooltip title="Force TLS">
|
||||
<a-select v-model:value="row.forceTls" :style="{ width: '20%' }">
|
||||
<a-select-option value="same">{{ t('pages.inbounds.same') }}</a-select-option>
|
||||
<a-select-option value="none">{{ t('none') }}</a-select-option>
|
||||
<a-select-option value="tls">TLS</a-select-option>
|
||||
</a-select>
|
||||
</a-tooltip>
|
||||
<a-input v-model:value="row.dest" :style="{ width: '30%' }" :placeholder="t('host')" />
|
||||
<a-tooltip :title="t('pages.inbounds.port')">
|
||||
<a-input-number v-model:value="row.port" :style="{ width: '15%' }" :min="1" :max="65535" />
|
||||
</a-tooltip>
|
||||
<a-input v-model:value="row.remark" :style="{ width: '35%' }" :placeholder="t('pages.inbounds.remark')">
|
||||
<template #addonAfter>
|
||||
<MinusOutlined @click="inbound.stream.externalProxy.splice(idx, 1)" />
|
||||
</template>
|
||||
</a-input>
|
||||
</a-input-group>
|
||||
<a-input-group v-if="row.forceTls === 'tls'" compact :style="{ marginTop: '6px' }">
|
||||
<a-select v-model:value="row.fingerprint" :style="{ width: '35%' }" placeholder="Fingerprint">
|
||||
<a-select-option value="">Default</a-select-option>
|
||||
<a-select-option v-for="fp in FINGERPRINTS" :key="fp" :value="fp">{{ fp }}</a-select-option>
|
||||
</a-select>
|
||||
</a-tooltip>
|
||||
<a-input v-model:value="row.dest" :style="{ width: '30%' }" :placeholder="t('host')" />
|
||||
<a-tooltip :title="t('pages.inbounds.port')">
|
||||
<a-input-number v-model:value="row.port" :style="{ width: '15%' }" :min="1" :max="65535" />
|
||||
</a-tooltip>
|
||||
<a-input v-model:value="row.remark" :style="{ width: '35%' }" :placeholder="t('pages.inbounds.remark')">
|
||||
<template #addonAfter>
|
||||
<MinusOutlined @click="inbound.stream.externalProxy.splice(idx, 1)" />
|
||||
</template>
|
||||
</a-input>
|
||||
</a-input-group>
|
||||
<a-select v-model:value="row.alpn" mode="multiple" :style="{ width: '65%' }" placeholder="ALPN">
|
||||
<a-select-option v-for="alpn in ALPNS" :key="alpn" :value="alpn">{{ alpn }}</a-select-option>
|
||||
</a-select>
|
||||
</a-input-group>
|
||||
</div>
|
||||
</a-form-item>
|
||||
|
||||
<!-- ====== Sockopt ====== -->
|
||||
|
|
|
|||
|
|
@ -122,7 +122,8 @@ func (s *SubClashService) getProxies(inbound *model.Inbound, client model.Client
|
|||
defaultDest = host
|
||||
}
|
||||
externalProxies, ok := stream["externalProxy"].([]any)
|
||||
if !ok || len(externalProxies) == 0 {
|
||||
hasExternalProxy := ok && len(externalProxies) > 0
|
||||
if !hasExternalProxy {
|
||||
externalProxies = []any{map[string]any{
|
||||
"forceTls": "same",
|
||||
"dest": defaultDest,
|
||||
|
|
@ -153,6 +154,10 @@ func (s *SubClashService) getProxies(inbound *model.Inbound, client model.Client
|
|||
delete(workingStream, "realitySettings")
|
||||
}
|
||||
}
|
||||
security, _ := workingStream["security"].(string)
|
||||
if hasExternalProxy {
|
||||
applyExternalProxyTLSToStream(extPrxy, workingStream, security)
|
||||
}
|
||||
|
||||
proxy := s.buildProxy(&workingInbound, client, workingStream, extPrxy["remark"].(string))
|
||||
if len(proxy) > 0 {
|
||||
|
|
@ -383,6 +388,17 @@ func (s *SubClashService) applySecurity(proxy map[string]any, security string, s
|
|||
if fingerprint, ok := tlsSettings["fingerprint"].(string); ok && fingerprint != "" {
|
||||
proxy["client-fingerprint"] = fingerprint
|
||||
}
|
||||
if alpn, ok := externalProxyALPNList(tlsSettings["alpn"]); ok {
|
||||
out := make([]string, 0, len(alpn))
|
||||
for _, item := range alpn {
|
||||
if s, ok := item.(string); ok && s != "" {
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
if len(out) > 0 {
|
||||
proxy["alpn"] = out
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
case "reality":
|
||||
|
|
|
|||
|
|
@ -174,7 +174,8 @@ func (s *SubJsonService) getConfig(inbound *model.Inbound, client model.Client,
|
|||
}
|
||||
|
||||
externalProxies, ok := stream["externalProxy"].([]any)
|
||||
if !ok || len(externalProxies) == 0 {
|
||||
hasExternalProxy := ok && len(externalProxies) > 0
|
||||
if !hasExternalProxy {
|
||||
externalProxies = []any{
|
||||
map[string]any{
|
||||
"forceTls": "same",
|
||||
|
|
@ -204,6 +205,10 @@ func (s *SubJsonService) getConfig(inbound *model.Inbound, client model.Client,
|
|||
delete(newStream, "tlsSettings")
|
||||
}
|
||||
}
|
||||
security, _ := newStream["security"].(string)
|
||||
if hasExternalProxy {
|
||||
applyExternalProxyTLSToStream(extPrxy, newStream, security)
|
||||
}
|
||||
streamSettings, _ := json.MarshalIndent(newStream, "", " ")
|
||||
|
||||
var newOutbounds []json_util.RawMessage
|
||||
|
|
|
|||
|
|
@ -863,11 +863,131 @@ func cloneVmessShareObj(baseObj map[string]any, newSecurity string) map[string]a
|
|||
return newObj
|
||||
}
|
||||
|
||||
func applyExternalProxyTLSObj(ep map[string]any, obj map[string]any, security string) {
|
||||
if security != "tls" {
|
||||
return
|
||||
}
|
||||
if dest, ok := ep["dest"].(string); ok && dest != "" {
|
||||
obj["sni"] = dest
|
||||
}
|
||||
if fp, ok := ep["fingerprint"].(string); ok && fp != "" {
|
||||
obj["fp"] = fp
|
||||
}
|
||||
if alpn, ok := externalProxyALPN(ep["alpn"]); ok {
|
||||
obj["alpn"] = alpn
|
||||
}
|
||||
}
|
||||
|
||||
func applyExternalProxyTLSParams(ep map[string]any, params map[string]string, security string) {
|
||||
if security != "tls" {
|
||||
return
|
||||
}
|
||||
if dest, ok := ep["dest"].(string); ok && dest != "" {
|
||||
params["sni"] = dest
|
||||
}
|
||||
if fp, ok := ep["fingerprint"].(string); ok && fp != "" {
|
||||
params["fp"] = fp
|
||||
}
|
||||
if alpn, ok := externalProxyALPN(ep["alpn"]); ok {
|
||||
params["alpn"] = alpn
|
||||
}
|
||||
}
|
||||
|
||||
func applyExternalProxyTLSToStream(ep map[string]any, stream map[string]any, security string) {
|
||||
if security != "tls" {
|
||||
return
|
||||
}
|
||||
tlsSettings, _ := stream["tlsSettings"].(map[string]any)
|
||||
if tlsSettings == nil {
|
||||
tlsSettings = map[string]any{}
|
||||
stream["tlsSettings"] = tlsSettings
|
||||
}
|
||||
if dest, ok := ep["dest"].(string); ok && dest != "" {
|
||||
tlsSettings["serverName"] = dest
|
||||
}
|
||||
if fp, ok := ep["fingerprint"].(string); ok && fp != "" {
|
||||
tlsSettings["fingerprint"] = fp
|
||||
settings, _ := tlsSettings["settings"].(map[string]any)
|
||||
if settings == nil {
|
||||
settings = map[string]any{}
|
||||
tlsSettings["settings"] = settings
|
||||
}
|
||||
settings["fingerprint"] = fp
|
||||
}
|
||||
if alpn, ok := externalProxyALPNList(ep["alpn"]); ok {
|
||||
tlsSettings["alpn"] = alpn
|
||||
}
|
||||
}
|
||||
|
||||
func externalProxyALPN(value any) (string, bool) {
|
||||
switch v := value.(type) {
|
||||
case string:
|
||||
return v, v != ""
|
||||
case []string:
|
||||
if len(v) == 0 {
|
||||
return "", false
|
||||
}
|
||||
return strings.Join(v, ","), true
|
||||
case []any:
|
||||
alpn := make([]string, 0, len(v))
|
||||
for _, item := range v {
|
||||
if s, ok := item.(string); ok && s != "" {
|
||||
alpn = append(alpn, s)
|
||||
}
|
||||
}
|
||||
if len(alpn) == 0 {
|
||||
return "", false
|
||||
}
|
||||
return strings.Join(alpn, ","), true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
func externalProxyALPNList(value any) ([]any, bool) {
|
||||
switch v := value.(type) {
|
||||
case string:
|
||||
if v == "" {
|
||||
return nil, false
|
||||
}
|
||||
parts := strings.Split(v, ",")
|
||||
out := make([]any, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
if part = strings.TrimSpace(part); part != "" {
|
||||
out = append(out, part)
|
||||
}
|
||||
}
|
||||
return out, len(out) > 0
|
||||
case []string:
|
||||
out := make([]any, 0, len(v))
|
||||
for _, item := range v {
|
||||
if item != "" {
|
||||
out = append(out, item)
|
||||
}
|
||||
}
|
||||
return out, len(out) > 0
|
||||
case []any:
|
||||
out := make([]any, 0, len(v))
|
||||
for _, item := range v {
|
||||
if s, ok := item.(string); ok && s != "" {
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
return out, len(out) > 0
|
||||
default:
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
|
||||
func (s *SubService) buildVmessExternalProxyLinks(externalProxies []any, baseObj map[string]any, inbound *model.Inbound, email string) string {
|
||||
var links strings.Builder
|
||||
for index, externalProxy := range externalProxies {
|
||||
ep, _ := externalProxy.(map[string]any)
|
||||
newSecurity, _ := ep["forceTls"].(string)
|
||||
securityToApply := baseObj["tls"].(string)
|
||||
if newSecurity != "same" {
|
||||
securityToApply = newSecurity
|
||||
}
|
||||
newObj := cloneVmessShareObj(baseObj, newSecurity)
|
||||
newObj["ps"] = s.genRemark(inbound, email, ep["remark"].(string))
|
||||
newObj["add"] = ep["dest"].(string)
|
||||
|
|
@ -876,6 +996,7 @@ func (s *SubService) buildVmessExternalProxyLinks(externalProxies []any, baseObj
|
|||
if newSecurity != "same" {
|
||||
newObj["tls"] = newSecurity
|
||||
}
|
||||
applyExternalProxyTLSObj(ep, newObj, securityToApply)
|
||||
if index > 0 {
|
||||
links.WriteString("\n")
|
||||
}
|
||||
|
|
@ -931,11 +1052,14 @@ func (s *SubService) buildExternalProxyURLLinks(
|
|||
securityToApply = newSecurity
|
||||
}
|
||||
|
||||
nextParams := cloneStringMap(params)
|
||||
applyExternalProxyTLSParams(ep, nextParams, securityToApply)
|
||||
|
||||
links = append(
|
||||
links,
|
||||
buildLinkWithParamsAndSecurity(
|
||||
makeLink(dest, port),
|
||||
params,
|
||||
nextParams,
|
||||
makeRemark(ep),
|
||||
securityToApply,
|
||||
newSecurity == "none",
|
||||
|
|
@ -1066,10 +1190,9 @@ func searchKey(data any, key string) (any, bool) {
|
|||
// - server-only (noSSEHeader, scMaxBufferedPosts, scStreamUpServerSecs,
|
||||
// serverMaxHeaderBytes) — client wouldn't read them, so emitting
|
||||
// them just bloats the URL.
|
||||
// - client-only (headers, uplinkHTTPMethod, uplinkChunkSize,
|
||||
// noGRPCHeader, scMinPostsIntervalMs, xmux, downloadSettings) — the
|
||||
// inbound config doesn't have them; the client configures them
|
||||
// locally.
|
||||
// - client-only values are included only when present in the inbound
|
||||
// JSON. Some deployments/imported configs carry them there, and the
|
||||
// subscription link is the only place clients can receive them.
|
||||
//
|
||||
// Truthy-only guards keep default inbounds emitting the same compact URL
|
||||
// they did before this helper grew.
|
||||
|
|
@ -1091,15 +1214,12 @@ func buildXhttpExtra(xhttp map[string]any) map[string]any {
|
|||
}
|
||||
}
|
||||
|
||||
if mode, ok := xhttp["mode"].(string); ok && len(mode) > 0 {
|
||||
extra["mode"] = mode
|
||||
}
|
||||
|
||||
stringFields := []string{
|
||||
"uplinkHTTPMethod",
|
||||
"sessionPlacement", "sessionKey",
|
||||
"seqPlacement", "seqKey",
|
||||
"uplinkDataPlacement", "uplinkDataKey",
|
||||
"scMaxEachPostBytes",
|
||||
"scMaxEachPostBytes", "scMinPostsIntervalMs",
|
||||
}
|
||||
for _, field := range stringFields {
|
||||
if v, ok := xhttp[field].(string); ok && len(v) > 0 {
|
||||
|
|
@ -1107,6 +1227,24 @@ func buildXhttpExtra(xhttp map[string]any) map[string]any {
|
|||
}
|
||||
}
|
||||
|
||||
for _, field := range []string{"uplinkChunkSize"} {
|
||||
if v, ok := nonZeroShareValue(xhttp[field]); ok {
|
||||
extra[field] = v
|
||||
}
|
||||
}
|
||||
|
||||
for _, field := range []string{"noGRPCHeader"} {
|
||||
if v, ok := xhttp[field].(bool); ok && v {
|
||||
extra[field] = v
|
||||
}
|
||||
}
|
||||
|
||||
for _, field := range []string{"xmux", "downloadSettings"} {
|
||||
if v, ok := nonEmptyShareObject(xhttp[field]); ok {
|
||||
extra[field] = v
|
||||
}
|
||||
}
|
||||
|
||||
// Headers — emitted as the {name: value} map upstream's struct
|
||||
// expects. The server runtime ignores this field, but the client
|
||||
// (consuming the share link) honors it. Drop any "host" entry —
|
||||
|
|
@ -1130,6 +1268,38 @@ func buildXhttpExtra(xhttp map[string]any) map[string]any {
|
|||
return extra
|
||||
}
|
||||
|
||||
func nonZeroShareValue(v any) (any, bool) {
|
||||
switch value := v.(type) {
|
||||
case string:
|
||||
return value, value != ""
|
||||
case int:
|
||||
return value, value != 0
|
||||
case int32:
|
||||
return value, value != 0
|
||||
case int64:
|
||||
return value, value != 0
|
||||
case float32:
|
||||
return value, value != 0
|
||||
case float64:
|
||||
return value, value != 0
|
||||
default:
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
|
||||
func nonEmptyShareObject(v any) (any, bool) {
|
||||
switch value := v.(type) {
|
||||
case map[string]any:
|
||||
return value, len(value) > 0
|
||||
case map[string]string:
|
||||
return value, len(value) > 0
|
||||
case []any:
|
||||
return value, len(value) > 0
|
||||
default:
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
|
||||
// applyXhttpExtraParams emits the full xhttp config into the URL query
|
||||
// params of a vless:// / trojan:// / ss:// link. Sets path/host/mode at
|
||||
// top level (xray's Build() always lets these win over `extra`) and packs
|
||||
|
|
|
|||
|
|
@ -151,6 +151,77 @@ func TestSearchKey_OnScalar(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestBuildXhttpExtra_IncludesClientSideFieldsWhenPresent(t *testing.T) {
|
||||
extra := buildXhttpExtra(map[string]any{
|
||||
"path": "/xhttp",
|
||||
"host": "example.com",
|
||||
"mode": "packet-up",
|
||||
"xPaddingBytes": "100-1000",
|
||||
"uplinkHTTPMethod": "GET",
|
||||
"uplinkChunkSize": float64(4096),
|
||||
"noGRPCHeader": true,
|
||||
"scMinPostsIntervalMs": "20-40",
|
||||
"xmux": map[string]any{
|
||||
"maxConcurrency": "16-32",
|
||||
"hMaxRequestTimes": "600-900",
|
||||
"hMaxReusableSecs": "1800-3000",
|
||||
"hKeepAlivePeriod": float64(15),
|
||||
},
|
||||
"downloadSettings": map[string]any{
|
||||
"network": "xhttp",
|
||||
},
|
||||
"headers": map[string]any{
|
||||
"Host": "ignored.example.com",
|
||||
"X-Forwarded": "1",
|
||||
"X-Test-Empty": "",
|
||||
},
|
||||
})
|
||||
|
||||
if extra["path"] != nil || extra["host"] != nil {
|
||||
t.Fatalf("path/host should stay top-level, got extra %#v", extra)
|
||||
}
|
||||
for _, key := range []string{
|
||||
"xPaddingBytes",
|
||||
"uplinkHTTPMethod",
|
||||
"uplinkChunkSize",
|
||||
"noGRPCHeader",
|
||||
"scMinPostsIntervalMs",
|
||||
"xmux",
|
||||
"downloadSettings",
|
||||
} {
|
||||
if _, ok := extra[key]; !ok {
|
||||
t.Fatalf("extra missing %q: %#v", key, extra)
|
||||
}
|
||||
}
|
||||
if _, ok := extra["mode"]; ok {
|
||||
t.Fatalf("mode should stay as a top-level query parameter, got extra %#v", extra)
|
||||
}
|
||||
|
||||
headers, ok := extra["headers"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("headers = %#v, want map", extra["headers"])
|
||||
}
|
||||
if _, ok := headers["Host"]; ok {
|
||||
t.Fatalf("headers should not include Host: %#v", headers)
|
||||
}
|
||||
if headers["X-Forwarded"] != "1" {
|
||||
t.Fatalf("headers[X-Forwarded] = %#v, want 1", headers["X-Forwarded"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildXhttpExtra_LeavesDefaultClientSideFieldsOut(t *testing.T) {
|
||||
extra := buildXhttpExtra(map[string]any{
|
||||
"uplinkHTTPMethod": "",
|
||||
"uplinkChunkSize": float64(0),
|
||||
"noGRPCHeader": false,
|
||||
"xmux": map[string]any{},
|
||||
"downloadSettings": map[string]any{},
|
||||
})
|
||||
if extra != nil {
|
||||
t.Fatalf("default-only xhttp extra = %#v, want nil", extra)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCloneStringMap(t *testing.T) {
|
||||
src := map[string]string{"a": "1", "b": "2"}
|
||||
dst := cloneStringMap(src)
|
||||
|
|
@ -369,6 +440,56 @@ func TestCloneVmessShareObj_NoneStripsTLSOnlyKeys(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestApplyExternalProxyTLSParams_UsesProxyDomainAndOverrides(t *testing.T) {
|
||||
params := map[string]string{
|
||||
"security": "tls",
|
||||
"sni": "origin.example.com",
|
||||
"fp": "firefox",
|
||||
"alpn": "h2",
|
||||
}
|
||||
ep := map[string]any{
|
||||
"dest": "proxy.example.com",
|
||||
"fingerprint": "chrome",
|
||||
"alpn": []any{"h3", "h2"},
|
||||
}
|
||||
|
||||
applyExternalProxyTLSParams(ep, params, "tls")
|
||||
|
||||
if params["sni"] != "proxy.example.com" {
|
||||
t.Fatalf("sni = %q, want proxy.example.com", params["sni"])
|
||||
}
|
||||
if params["fp"] != "chrome" {
|
||||
t.Fatalf("fp = %q, want chrome", params["fp"])
|
||||
}
|
||||
if params["alpn"] != "h3,h2" {
|
||||
t.Fatalf("alpn = %q, want h3,h2", params["alpn"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyExternalProxyTLSParams_DoesNotApplyForNone(t *testing.T) {
|
||||
params := map[string]string{
|
||||
"security": "none",
|
||||
"sni": "origin.example.com",
|
||||
}
|
||||
ep := map[string]any{
|
||||
"dest": "proxy.example.com",
|
||||
"fingerprint": "chrome",
|
||||
"alpn": []any{"h3"},
|
||||
}
|
||||
|
||||
applyExternalProxyTLSParams(ep, params, "none")
|
||||
|
||||
if params["sni"] != "origin.example.com" {
|
||||
t.Fatalf("sni should not change for security=none, got %q", params["sni"])
|
||||
}
|
||||
if _, ok := params["fp"]; ok {
|
||||
t.Fatalf("fp should not be set for security=none, got %v", params)
|
||||
}
|
||||
if _, ok := params["alpn"]; ok {
|
||||
t.Fatalf("alpn should not be set for security=none, got %v", params)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractKcpShareFields_Defaults(t *testing.T) {
|
||||
stream := map[string]any{}
|
||||
got := extractKcpShareFields(stream)
|
||||
|
|
|
|||
Loading…
Reference in a new issue