mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-06-06 21:24:10 +00:00
Merge branch 'main' into fix/farhadh/security-hardening
This commit is contained in:
commit
8f486cadd7
20 changed files with 893 additions and 444 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -1,6 +1,7 @@
|
|||
# Ignore editor and IDE settings
|
||||
.idea/
|
||||
.vscode/
|
||||
.claude/
|
||||
.cache/
|
||||
.sync*
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,61 @@
|
|||
#!/bin/sh
|
||||
|
||||
# Start fail2ban
|
||||
[ $XUI_ENABLE_FAIL2BAN == "true" ] && fail2ban-client -x start
|
||||
# Start fail2ban with the 3x-ipl jail
|
||||
if [ "$XUI_ENABLE_FAIL2BAN" = "true" ]; then
|
||||
LOG_FOLDER="${XUI_LOG_FOLDER:-/var/log/x-ui}"
|
||||
mkdir -p "$LOG_FOLDER"
|
||||
touch "$LOG_FOLDER/3xipl.log" "$LOG_FOLDER/3xipl-banned.log"
|
||||
|
||||
mkdir -p /etc/fail2ban/jail.d /etc/fail2ban/filter.d /etc/fail2ban/action.d
|
||||
|
||||
cat > /etc/fail2ban/jail.d/3x-ipl.conf << EOF
|
||||
[3x-ipl]
|
||||
enabled=true
|
||||
backend=auto
|
||||
filter=3x-ipl
|
||||
action=3x-ipl
|
||||
logpath=$LOG_FOLDER/3xipl.log
|
||||
maxretry=1
|
||||
findtime=32
|
||||
bantime=30m
|
||||
EOF
|
||||
|
||||
cat > /etc/fail2ban/filter.d/3x-ipl.conf << 'EOF'
|
||||
[Definition]
|
||||
datepattern = ^%Y/%m/%d %H:%M:%S
|
||||
failregex = \[LIMIT_IP\]\s*Email\s*=\s*<F-USER>.+</F-USER>\s*\|\|\s*Disconnecting OLD IP\s*=\s*<ADDR>\s*\|\|\s*Timestamp\s*=\s*\d+
|
||||
ignoreregex =
|
||||
EOF
|
||||
|
||||
cat > /etc/fail2ban/action.d/3x-ipl.conf << EOF
|
||||
[INCLUDES]
|
||||
before = iptables-allports.conf
|
||||
|
||||
[Definition]
|
||||
actionstart = <iptables> -N f2b-<name>
|
||||
<iptables> -A f2b-<name> -j <returntype>
|
||||
<iptables> -I <chain> -p <protocol> -j f2b-<name>
|
||||
|
||||
actionstop = <iptables> -D <chain> -p <protocol> -j f2b-<name>
|
||||
<actionflush>
|
||||
<iptables> -X f2b-<name>
|
||||
|
||||
actioncheck = <iptables> -n -L <chain> | grep -q 'f2b-<name>[ \t]'
|
||||
|
||||
actionban = <iptables> -I f2b-<name> 1 -s <ip> -j <blocktype>
|
||||
echo "\$(date +"%Y/%m/%d %H:%M:%S") BAN [Email] = <F-USER> [IP] = <ip> banned for <bantime> seconds." >> $LOG_FOLDER/3xipl-banned.log
|
||||
|
||||
actionunban = <iptables> -D f2b-<name> -s <ip> -j <blocktype>
|
||||
echo "\$(date +"%Y/%m/%d %H:%M:%S") UNBAN [Email] = <F-USER> [IP] = <ip> unbanned." >> $LOG_FOLDER/3xipl-banned.log
|
||||
|
||||
[Init]
|
||||
name = default
|
||||
protocol = tcp
|
||||
chain = INPUT
|
||||
EOF
|
||||
|
||||
fail2ban-client -x start
|
||||
fi
|
||||
|
||||
# Run x-ui
|
||||
exec /app/x-ui
|
||||
|
|
|
|||
|
|
@ -582,28 +582,18 @@ func applyShareNetworkParams(stream map[string]any, streamNetwork string, params
|
|||
applyPathAndHostParams(httpupgrade, params)
|
||||
case "xhttp":
|
||||
xhttp, _ := stream["xhttpSettings"].(map[string]any)
|
||||
applyPathAndHostParams(xhttp, params)
|
||||
params["mode"], _ = xhttp["mode"].(string)
|
||||
applyXhttpPaddingParams(xhttp, params)
|
||||
applyXhttpExtraParams(xhttp, params)
|
||||
}
|
||||
}
|
||||
|
||||
func applyXhttpPaddingObj(xhttp map[string]any, obj map[string]any) {
|
||||
// VMess base64 JSON supports arbitrary keys; copy the padding
|
||||
// settings through so clients can match the server's xhttp
|
||||
// xPaddingBytes range and, when the admin opted into obfs
|
||||
// mode, the custom key / header / placement / method.
|
||||
// applyXhttpExtraObj copies the bidirectional xhttp settings into the
|
||||
// VMess base64 JSON link object. VMess supports arbitrary keys, so we
|
||||
// flatten the SplitHTTPConfig "extra" fields directly onto obj.
|
||||
func applyXhttpExtraObj(xhttp map[string]any, obj map[string]any) {
|
||||
if xpb, ok := xhttp["xPaddingBytes"].(string); ok && len(xpb) > 0 {
|
||||
obj["x_padding_bytes"] = xpb
|
||||
}
|
||||
if obfs, ok := xhttp["xPaddingObfsMode"].(bool); ok && obfs {
|
||||
obj["xPaddingObfsMode"] = true
|
||||
for _, field := range []string{"xPaddingKey", "xPaddingHeader", "xPaddingPlacement", "xPaddingMethod"} {
|
||||
if v, ok := xhttp[field].(string); ok && len(v) > 0 {
|
||||
obj[field] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
maps.Copy(obj, buildXhttpExtra(xhttp))
|
||||
}
|
||||
|
||||
func applyVmessNetworkParams(stream map[string]any, network string, obj map[string]any) {
|
||||
|
|
@ -639,8 +629,10 @@ func applyVmessNetworkParams(stream map[string]any, network string, obj map[stri
|
|||
case "xhttp":
|
||||
xhttp, _ := stream["xhttpSettings"].(map[string]any)
|
||||
applyPathAndHostObj(xhttp, obj)
|
||||
obj["mode"], _ = xhttp["mode"].(string)
|
||||
applyXhttpPaddingObj(xhttp, obj)
|
||||
if mode, ok := xhttp["mode"].(string); ok {
|
||||
obj["mode"] = mode
|
||||
}
|
||||
applyXhttpExtraObj(xhttp, obj)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -928,45 +920,33 @@ func searchKey(data any, key string) (any, bool) {
|
|||
return nil, false
|
||||
}
|
||||
|
||||
// applyXhttpPaddingParams copies the xPadding* fields from an xhttpSettings
|
||||
// map into the URL query params of a vless:// / trojan:// / ss:// link.
|
||||
// buildXhttpExtra walks an xhttpSettings map and returns the JSON blob
|
||||
// that goes into the URL's `extra` param (or, for VMess, the link
|
||||
// object). Carries ONLY the bidirectional fields from xray-core's
|
||||
// SplitHTTPConfig — i.e. the ones the server enforces and the client
|
||||
// must match. Strictly one-sided fields are excluded:
|
||||
//
|
||||
// Before this helper existed, only path / host / mode were propagated,
|
||||
// so a server configured with a non-default xPaddingBytes (e.g. 80-600)
|
||||
// or with xPaddingObfsMode=true + custom xPaddingKey / xPaddingHeader
|
||||
// would silently diverge from the client: the client kept defaults,
|
||||
// hit the server, and was rejected by its padding validation
|
||||
// ("invalid padding" in the inbound log) — the client-visible symptom
|
||||
// was "xhttp doesn't connect" on OpenWRT / sing-box.
|
||||
// - 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.
|
||||
//
|
||||
// Two encodings are written so every popular client can read at least one:
|
||||
//
|
||||
// - x_padding_bytes=<range> — flat param, understood by sing-box and its
|
||||
// derivatives (Podkop, OpenWRT sing-box, Karing, NekoBox, …).
|
||||
// - extra=<url-encoded-json> — full xhttp settings blob, which is how
|
||||
// xray-core clients (v2rayNG, Happ, Furious, Exclave, …) pick up the
|
||||
// obfs-mode key / header / placement / method.
|
||||
//
|
||||
// Anything that doesn't map to a non-empty value is skipped, so simple
|
||||
// inbounds (no custom padding) produce exactly the same URL as before.
|
||||
func applyXhttpPaddingParams(xhttp map[string]any, params map[string]string) {
|
||||
// Truthy-only guards keep default inbounds emitting the same compact URL
|
||||
// they did before this helper grew.
|
||||
func buildXhttpExtra(xhttp map[string]any) map[string]any {
|
||||
if xhttp == nil {
|
||||
return
|
||||
return nil
|
||||
}
|
||||
|
||||
if xpb, ok := xhttp["xPaddingBytes"].(string); ok && len(xpb) > 0 {
|
||||
params["x_padding_bytes"] = xpb
|
||||
}
|
||||
|
||||
extra := map[string]any{}
|
||||
|
||||
if xpb, ok := xhttp["xPaddingBytes"].(string); ok && len(xpb) > 0 {
|
||||
extra["xPaddingBytes"] = xpb
|
||||
}
|
||||
if obfs, ok := xhttp["xPaddingObfsMode"].(bool); ok && obfs {
|
||||
extra["xPaddingObfsMode"] = true
|
||||
// The obfs-mode-only fields: only populate the ones the admin
|
||||
// actually set, so xray-core falls back to its own defaults for
|
||||
// the rest instead of seeing spurious empty strings.
|
||||
for _, field := range []string{"xPaddingKey", "xPaddingHeader", "xPaddingPlacement", "xPaddingMethod"} {
|
||||
if v, ok := xhttp[field].(string); ok && len(v) > 0 {
|
||||
extra[field] = v
|
||||
|
|
@ -974,7 +954,74 @@ func applyXhttpPaddingParams(xhttp map[string]any, params map[string]string) {
|
|||
}
|
||||
}
|
||||
|
||||
if len(extra) > 0 {
|
||||
stringFields := []string{
|
||||
"sessionPlacement", "sessionKey",
|
||||
"seqPlacement", "seqKey",
|
||||
"uplinkDataPlacement", "uplinkDataKey",
|
||||
"scMaxEachPostBytes",
|
||||
}
|
||||
for _, field := range stringFields {
|
||||
if v, ok := xhttp[field].(string); ok && len(v) > 0 {
|
||||
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 —
|
||||
// host already wins as a top-level URL param.
|
||||
if rawHeaders, ok := xhttp["headers"].(map[string]any); ok && len(rawHeaders) > 0 {
|
||||
out := map[string]any{}
|
||||
for k, v := range rawHeaders {
|
||||
if strings.EqualFold(k, "host") {
|
||||
continue
|
||||
}
|
||||
out[k] = v
|
||||
}
|
||||
if len(out) > 0 {
|
||||
extra["headers"] = out
|
||||
}
|
||||
}
|
||||
|
||||
if len(extra) == 0 {
|
||||
return nil
|
||||
}
|
||||
return extra
|
||||
}
|
||||
|
||||
// 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
|
||||
// everything else into a JSON `extra` param. Also writes the flat
|
||||
// `x_padding_bytes` param sing-box-family clients understand.
|
||||
//
|
||||
// Without this, the admin's custom xPaddingBytes / sessionKey / etc. never
|
||||
// reach the client and handshakes are silently rejected with
|
||||
// `invalid padding (...) length: 0` — the client-visible symptom is
|
||||
// "xhttp doesn't connect" on OpenWRT / sing-box.
|
||||
//
|
||||
// Two encodings are written so every popular client can read at least one:
|
||||
//
|
||||
// - x_padding_bytes=<range> — flat param, understood by sing-box and its
|
||||
// derivatives (Podkop, OpenWRT sing-box, Karing, NekoBox, …).
|
||||
// - extra=<url-encoded-json> — full xhttp settings blob, which is how
|
||||
// xray-core clients (v2rayNG, Happ, Furious, Exclave, …) pick up the
|
||||
// bidirectional fields beyond path/host/mode.
|
||||
func applyXhttpExtraParams(xhttp map[string]any, params map[string]string) {
|
||||
if xhttp == nil {
|
||||
return
|
||||
}
|
||||
applyPathAndHostParams(xhttp, params)
|
||||
if mode, ok := xhttp["mode"].(string); ok {
|
||||
params["mode"] = mode
|
||||
}
|
||||
|
||||
if xpb, ok := xhttp["xPaddingBytes"].(string); ok && len(xpb) > 0 {
|
||||
params["x_padding_bytes"] = xpb
|
||||
}
|
||||
|
||||
extra := buildXhttpExtra(xhttp)
|
||||
if extra != nil {
|
||||
if b, err := json.Marshal(extra); err == nil {
|
||||
params["extra"] = string(b)
|
||||
}
|
||||
|
|
|
|||
2
web/assets/css/custom.min.css
vendored
2
web/assets/css/custom.min.css
vendored
File diff suppressed because one or more lines are too long
|
|
@ -145,6 +145,19 @@ class XrayCommonClass {
|
|||
return this;
|
||||
}
|
||||
|
||||
// Build a clean Xray fallback entry. Per docs, name/alpn/path empty = "any",
|
||||
// and xver=0 means PROXY protocol off — omit them so the generated config
|
||||
// stays minimal and readable. dest is required and always emitted.
|
||||
static fallbackToJson(fb) {
|
||||
const out = { dest: fb.dest };
|
||||
if (fb.name) out.name = fb.name;
|
||||
if (fb.alpn) out.alpn = fb.alpn;
|
||||
if (fb.path) out.path = fb.path;
|
||||
const xver = Number(fb.xver);
|
||||
if (Number.isInteger(xver) && xver > 0) out.xver = xver;
|
||||
return out;
|
||||
}
|
||||
|
||||
toString(format = true) {
|
||||
return format ? JSON.stringify(this.toJson(), null, 2) : JSON.stringify(this.toJson());
|
||||
}
|
||||
|
|
@ -472,54 +485,67 @@ 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.
|
||||
//
|
||||
// `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.
|
||||
class xHTTPStreamSettings extends XrayCommonClass {
|
||||
constructor(
|
||||
// Bidirectional — must match between client and server
|
||||
path = '/',
|
||||
host = '',
|
||||
headers = [],
|
||||
scMaxBufferedPosts = 30,
|
||||
scMaxEachPostBytes = "1000000",
|
||||
scStreamUpServerSecs = "20-80",
|
||||
noSSEHeader = false,
|
||||
xPaddingBytes = "100-1000",
|
||||
mode = MODE_OPTION.AUTO,
|
||||
xPaddingBytes = "100-1000",
|
||||
xPaddingObfsMode = false,
|
||||
xPaddingKey = '',
|
||||
xPaddingHeader = '',
|
||||
xPaddingPlacement = '',
|
||||
xPaddingMethod = '',
|
||||
uplinkHTTPMethod = '',
|
||||
sessionPlacement = '',
|
||||
sessionKey = '',
|
||||
seqPlacement = '',
|
||||
seqKey = '',
|
||||
uplinkDataPlacement = '',
|
||||
uplinkDataKey = '',
|
||||
uplinkChunkSize = 0,
|
||||
scMaxEachPostBytes = "1000000",
|
||||
// Server-side only
|
||||
noSSEHeader = false,
|
||||
scMaxBufferedPosts = 30,
|
||||
scStreamUpServerSecs = "20-80",
|
||||
serverMaxHeaderBytes = 0,
|
||||
// URL-share only — embedded in the link's `extra` blob so clients
|
||||
// pick them up; xray's listener ignores them at runtime.
|
||||
headers = [],
|
||||
) {
|
||||
super();
|
||||
this.path = path;
|
||||
this.host = host;
|
||||
this.headers = headers;
|
||||
this.scMaxBufferedPosts = scMaxBufferedPosts;
|
||||
this.scMaxEachPostBytes = scMaxEachPostBytes;
|
||||
this.scStreamUpServerSecs = scStreamUpServerSecs;
|
||||
this.noSSEHeader = noSSEHeader;
|
||||
this.xPaddingBytes = xPaddingBytes;
|
||||
this.mode = mode;
|
||||
this.xPaddingBytes = xPaddingBytes;
|
||||
this.xPaddingObfsMode = xPaddingObfsMode;
|
||||
this.xPaddingKey = xPaddingKey;
|
||||
this.xPaddingHeader = xPaddingHeader;
|
||||
this.xPaddingPlacement = xPaddingPlacement;
|
||||
this.xPaddingMethod = xPaddingMethod;
|
||||
this.uplinkHTTPMethod = uplinkHTTPMethod;
|
||||
this.sessionPlacement = sessionPlacement;
|
||||
this.sessionKey = sessionKey;
|
||||
this.seqPlacement = seqPlacement;
|
||||
this.seqKey = seqKey;
|
||||
this.uplinkDataPlacement = uplinkDataPlacement;
|
||||
this.uplinkDataKey = uplinkDataKey;
|
||||
this.uplinkChunkSize = uplinkChunkSize;
|
||||
this.scMaxEachPostBytes = scMaxEachPostBytes;
|
||||
this.noSSEHeader = noSSEHeader;
|
||||
this.scMaxBufferedPosts = scMaxBufferedPosts;
|
||||
this.scStreamUpServerSecs = scStreamUpServerSecs;
|
||||
this.serverMaxHeaderBytes = serverMaxHeaderBytes;
|
||||
this.headers = headers;
|
||||
}
|
||||
|
||||
addHeader(name, value) {
|
||||
|
|
@ -534,26 +560,25 @@ class xHTTPStreamSettings extends XrayCommonClass {
|
|||
return new xHTTPStreamSettings(
|
||||
json.path,
|
||||
json.host,
|
||||
XrayCommonClass.toHeaders(json.headers),
|
||||
json.scMaxBufferedPosts,
|
||||
json.scMaxEachPostBytes,
|
||||
json.scStreamUpServerSecs,
|
||||
json.noSSEHeader,
|
||||
json.xPaddingBytes,
|
||||
json.mode,
|
||||
json.xPaddingBytes,
|
||||
json.xPaddingObfsMode,
|
||||
json.xPaddingKey,
|
||||
json.xPaddingHeader,
|
||||
json.xPaddingPlacement,
|
||||
json.xPaddingMethod,
|
||||
json.uplinkHTTPMethod,
|
||||
json.sessionPlacement,
|
||||
json.sessionKey,
|
||||
json.seqPlacement,
|
||||
json.seqKey,
|
||||
json.uplinkDataPlacement,
|
||||
json.uplinkDataKey,
|
||||
json.uplinkChunkSize,
|
||||
json.scMaxEachPostBytes,
|
||||
json.noSSEHeader,
|
||||
json.scMaxBufferedPosts,
|
||||
json.scStreamUpServerSecs,
|
||||
json.serverMaxHeaderBytes,
|
||||
XrayCommonClass.toHeaders(json.headers),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -561,26 +586,25 @@ class xHTTPStreamSettings extends XrayCommonClass {
|
|||
return {
|
||||
path: this.path,
|
||||
host: this.host,
|
||||
headers: XrayCommonClass.toV2Headers(this.headers, false),
|
||||
scMaxBufferedPosts: this.scMaxBufferedPosts,
|
||||
scMaxEachPostBytes: this.scMaxEachPostBytes,
|
||||
scStreamUpServerSecs: this.scStreamUpServerSecs,
|
||||
noSSEHeader: this.noSSEHeader,
|
||||
xPaddingBytes: this.xPaddingBytes,
|
||||
mode: this.mode,
|
||||
xPaddingBytes: this.xPaddingBytes,
|
||||
xPaddingObfsMode: this.xPaddingObfsMode,
|
||||
xPaddingKey: this.xPaddingKey,
|
||||
xPaddingHeader: this.xPaddingHeader,
|
||||
xPaddingPlacement: this.xPaddingPlacement,
|
||||
xPaddingMethod: this.xPaddingMethod,
|
||||
uplinkHTTPMethod: this.uplinkHTTPMethod,
|
||||
sessionPlacement: this.sessionPlacement,
|
||||
sessionKey: this.sessionKey,
|
||||
seqPlacement: this.seqPlacement,
|
||||
seqKey: this.seqKey,
|
||||
uplinkDataPlacement: this.uplinkDataPlacement,
|
||||
uplinkDataKey: this.uplinkDataKey,
|
||||
uplinkChunkSize: this.uplinkChunkSize,
|
||||
scMaxEachPostBytes: this.scMaxEachPostBytes,
|
||||
noSSEHeader: this.noSSEHeader,
|
||||
scMaxBufferedPosts: this.scMaxBufferedPosts,
|
||||
scStreamUpServerSecs: this.scStreamUpServerSecs,
|
||||
serverMaxHeaderBytes: this.serverMaxHeaderBytes,
|
||||
headers: XrayCommonClass.toV2Headers(this.headers, false),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -1523,26 +1547,39 @@ class Inbound extends XrayCommonClass {
|
|||
return this.clientStats;
|
||||
}
|
||||
|
||||
// Copy the xPadding* settings into the query-string of a vless/trojan/ss
|
||||
// link. Without this, the admin's custom xPaddingBytes range and (in
|
||||
// obfs mode) the custom xPaddingKey / xPaddingHeader / placement /
|
||||
// method never reach the client — the client keeps xray / sing-box's
|
||||
// internal defaults and the server rejects every handshake with
|
||||
// `invalid padding (...) length: 0`.
|
||||
//
|
||||
// Two encodings are emitted so each client family can pick at least
|
||||
// one up:
|
||||
// - x_padding_bytes=<range> flat, for sing-box-family clients
|
||||
// - extra=<url-encoded-json> full blob, for xray-core clients
|
||||
//
|
||||
// Fields are only included when they actually have a value, so a
|
||||
// default inbound yields the same URL it did before this helper.
|
||||
static applyXhttpPaddingToParams(xhttp, params) {
|
||||
if (!xhttp) return;
|
||||
if (typeof xhttp.xPaddingBytes === 'string' && xhttp.xPaddingBytes.length > 0) {
|
||||
params.set("x_padding_bytes", xhttp.xPaddingBytes);
|
||||
// Looks for a "host"-named entry in xhttp.headers and returns its value,
|
||||
// or '' if not found. Used as a fallback when xhttp.host is empty so the
|
||||
// share URL still carries a usable Host hint.
|
||||
static xhttpHostFallback(xhttp) {
|
||||
if (!xhttp || !Array.isArray(xhttp.headers)) return '';
|
||||
for (const h of xhttp.headers) {
|
||||
if (h && typeof h.name === 'string' && h.name.toLowerCase() === 'host') {
|
||||
return h.value || '';
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
// Build the JSON blob that goes into the URL's `extra` param (or, for
|
||||
// VMess, into the base64-encoded link object). Carries ONLY the
|
||||
// bidirectional fields from xray-core's SplitHTTPConfig — i.e. the
|
||||
// ones the server enforces and the client must match. Strictly
|
||||
// one-sided fields are excluded:
|
||||
//
|
||||
// - 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.
|
||||
//
|
||||
// Truthy-only guards keep default inbounds emitting the same compact
|
||||
// URL they did before this helper grew.
|
||||
static buildXhttpExtra(xhttp) {
|
||||
if (!xhttp) return null;
|
||||
const extra = {};
|
||||
|
||||
if (typeof xhttp.xPaddingBytes === 'string' && xhttp.xPaddingBytes.length > 0) {
|
||||
extra.xPaddingBytes = xhttp.xPaddingBytes;
|
||||
}
|
||||
|
|
@ -1554,26 +1591,73 @@ class Inbound extends XrayCommonClass {
|
|||
}
|
||||
});
|
||||
}
|
||||
if (Object.keys(extra).length > 0) {
|
||||
params.set("extra", JSON.stringify(extra));
|
||||
|
||||
const stringFields = [
|
||||
"sessionPlacement", "sessionKey",
|
||||
"seqPlacement", "seqKey",
|
||||
"uplinkDataPlacement", "uplinkDataKey",
|
||||
"scMaxEachPostBytes",
|
||||
];
|
||||
for (const k of stringFields) {
|
||||
const v = xhttp[k];
|
||||
if (typeof v === 'string' && 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.
|
||||
if (Array.isArray(xhttp.headers) && xhttp.headers.length > 0) {
|
||||
const headersMap = {};
|
||||
for (const h of xhttp.headers) {
|
||||
if (h && h.name && h.name.toLowerCase() !== 'host') {
|
||||
headersMap[h.name] = h.value || '';
|
||||
}
|
||||
}
|
||||
if (Object.keys(headersMap).length > 0) extra.headers = headersMap;
|
||||
}
|
||||
|
||||
return Object.keys(extra).length > 0 ? extra : null;
|
||||
}
|
||||
|
||||
// Inject the inbound-side xhttp config into URL query params for
|
||||
// vless/trojan/ss links. Sets path/host/mode at top level (xray's
|
||||
// Build() always lets these win over `extra`) and packs the
|
||||
// bidirectional fields into a JSON `extra` param. Also writes the
|
||||
// flat `x_padding_bytes` param sing-box-family clients understand.
|
||||
//
|
||||
// Without this, the admin's custom xPaddingBytes / sessionKey / etc.
|
||||
// never reach the client and handshakes are silently rejected with
|
||||
// `invalid padding (...) length: 0`.
|
||||
static applyXhttpExtraToParams(xhttp, params) {
|
||||
if (!xhttp) return;
|
||||
params.set("path", xhttp.path);
|
||||
const host = xhttp.host?.length > 0 ? xhttp.host : Inbound.xhttpHostFallback(xhttp);
|
||||
params.set("host", host);
|
||||
params.set("mode", xhttp.mode);
|
||||
|
||||
// Flat fallback for sing-box-family clients that don't read `extra`.
|
||||
if (typeof xhttp.xPaddingBytes === 'string' && xhttp.xPaddingBytes.length > 0) {
|
||||
params.set("x_padding_bytes", xhttp.xPaddingBytes);
|
||||
}
|
||||
|
||||
const extra = Inbound.buildXhttpExtra(xhttp);
|
||||
if (extra) params.set("extra", JSON.stringify(extra));
|
||||
}
|
||||
|
||||
// VMess variant: VMess links are a base64-encoded JSON object, so we
|
||||
// copy the padding fields directly into the JSON instead of building
|
||||
// a query string.
|
||||
static applyXhttpPaddingToObj(xhttp, obj) {
|
||||
// copy the same bidirectional fields directly into the JSON instead
|
||||
// of building a query string. (The base VMess link generator already
|
||||
// sets net/type/path/host, so we only contribute the SplitHTTPConfig
|
||||
// extra side here.)
|
||||
static applyXhttpExtraToObj(xhttp, obj) {
|
||||
if (!xhttp || !obj) return;
|
||||
if (typeof xhttp.xPaddingBytes === 'string' && xhttp.xPaddingBytes.length > 0) {
|
||||
obj.x_padding_bytes = xhttp.xPaddingBytes;
|
||||
}
|
||||
if (xhttp.xPaddingObfsMode === true) {
|
||||
obj.xPaddingObfsMode = true;
|
||||
["xPaddingKey", "xPaddingHeader", "xPaddingPlacement", "xPaddingMethod"].forEach(k => {
|
||||
if (typeof xhttp[k] === 'string' && xhttp[k].length > 0) {
|
||||
obj[k] = xhttp[k];
|
||||
}
|
||||
});
|
||||
const extra = Inbound.buildXhttpExtra(xhttp);
|
||||
if (!extra) return;
|
||||
for (const [k, v] of Object.entries(extra)) {
|
||||
obj[k] = v;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1763,12 +1847,13 @@ class Inbound extends XrayCommonClass {
|
|||
return false;
|
||||
}
|
||||
|
||||
// Vision seed applies only when vision flow is selected
|
||||
// Vision seed applies only when the XTLS Vision (TCP/TLS) flow is selected.
|
||||
// Excludes the UDP variant per spec.
|
||||
canEnableVisionSeed() {
|
||||
if (!this.canEnableTlsFlow()) return false;
|
||||
const clients = this.settings?.vlesses;
|
||||
if (!Array.isArray(clients)) return false;
|
||||
return clients.some(c => c?.flow === TLS_FLOW_CONTROL.VISION || c?.flow === TLS_FLOW_CONTROL.VISION_UDP443);
|
||||
return clients.some(c => c?.flow === TLS_FLOW_CONTROL.VISION);
|
||||
}
|
||||
|
||||
canEnableReality() {
|
||||
|
|
@ -1838,7 +1923,7 @@ class Inbound extends XrayCommonClass {
|
|||
obj.path = xhttp.path;
|
||||
obj.host = xhttp.host?.length > 0 ? xhttp.host : this.getHeader(xhttp, 'host');
|
||||
obj.type = xhttp.mode;
|
||||
Inbound.applyXhttpPaddingToObj(xhttp, obj);
|
||||
Inbound.applyXhttpExtraToObj(xhttp, obj);
|
||||
}
|
||||
|
||||
Inbound.applyFinalMaskToObj(this.stream.finalmask, obj);
|
||||
|
|
@ -1903,11 +1988,7 @@ class Inbound extends XrayCommonClass {
|
|||
params.set("host", httpupgrade.host?.length > 0 ? httpupgrade.host : this.getHeader(httpupgrade, 'host'));
|
||||
break;
|
||||
case "xhttp":
|
||||
const xhttp = this.stream.xhttp;
|
||||
params.set("path", xhttp.path);
|
||||
params.set("host", xhttp.host?.length > 0 ? xhttp.host : this.getHeader(xhttp, 'host'));
|
||||
params.set("mode", xhttp.mode);
|
||||
Inbound.applyXhttpPaddingToParams(xhttp, params);
|
||||
Inbound.applyXhttpExtraToParams(this.stream.xhttp, params);
|
||||
break;
|
||||
}
|
||||
|
||||
|
|
@ -2008,11 +2089,7 @@ class Inbound extends XrayCommonClass {
|
|||
params.set("host", httpupgrade.host?.length > 0 ? httpupgrade.host : this.getHeader(httpupgrade, 'host'));
|
||||
break;
|
||||
case "xhttp":
|
||||
const xhttp = this.stream.xhttp;
|
||||
params.set("path", xhttp.path);
|
||||
params.set("host", xhttp.host?.length > 0 ? xhttp.host : this.getHeader(xhttp, 'host'));
|
||||
params.set("mode", xhttp.mode);
|
||||
Inbound.applyXhttpPaddingToParams(xhttp, params);
|
||||
Inbound.applyXhttpExtraToParams(this.stream.xhttp, params);
|
||||
break;
|
||||
}
|
||||
|
||||
|
|
@ -2089,11 +2166,7 @@ class Inbound extends XrayCommonClass {
|
|||
params.set("host", httpupgrade.host?.length > 0 ? httpupgrade.host : this.getHeader(httpupgrade, 'host'));
|
||||
break;
|
||||
case "xhttp":
|
||||
const xhttp = this.stream.xhttp;
|
||||
params.set("path", xhttp.path);
|
||||
params.set("host", xhttp.host?.length > 0 ? xhttp.host : this.getHeader(xhttp, 'host'));
|
||||
params.set("mode", xhttp.mode);
|
||||
Inbound.applyXhttpPaddingToParams(xhttp, params);
|
||||
Inbound.applyXhttpExtraToParams(this.stream.xhttp, params);
|
||||
break;
|
||||
}
|
||||
|
||||
|
|
@ -2542,15 +2615,13 @@ Inbound.VLESSSettings = class extends Inbound.Settings {
|
|||
decryption = "none",
|
||||
encryption = "none",
|
||||
fallbacks = [],
|
||||
selectedAuth = undefined,
|
||||
testseed = [900, 500, 900, 256],
|
||||
testseed = [],
|
||||
) {
|
||||
super(protocol);
|
||||
this.vlesses = vlesses;
|
||||
this.decryption = decryption;
|
||||
this.encryption = encryption;
|
||||
this.fallbacks = fallbacks;
|
||||
this.selectedAuth = selectedAuth;
|
||||
this.testseed = testseed;
|
||||
}
|
||||
|
||||
|
|
@ -2562,12 +2633,23 @@ Inbound.VLESSSettings = class extends Inbound.Settings {
|
|||
this.fallbacks.splice(index, 1);
|
||||
}
|
||||
|
||||
// Empty array means "use server defaults" (won't be sent).
|
||||
// Anything else must be exactly 4 positive integers.
|
||||
static isValidTestseed(arr) {
|
||||
if (!Array.isArray(arr) || arr.length === 0) return true;
|
||||
if (arr.length !== 4) return false;
|
||||
return arr.every(v => Number.isInteger(v) && v > 0);
|
||||
}
|
||||
|
||||
static fromJson(json = {}) {
|
||||
// Ensure testseed is always initialized as an array
|
||||
let testseed = [900, 500, 900, 256];
|
||||
if (json.testseed && Array.isArray(json.testseed) && json.testseed.length >= 4) {
|
||||
testseed = json.testseed;
|
||||
}
|
||||
// Preserve a saved testseed only if it's a valid 4-positive-int array; otherwise leave empty
|
||||
// so toJson omits it and the form falls back to placeholder defaults.
|
||||
const saved = json.testseed;
|
||||
const testseed = (Array.isArray(saved)
|
||||
&& saved.length === 4
|
||||
&& saved.every(v => Number.isInteger(v) && v > 0))
|
||||
? saved
|
||||
: [];
|
||||
|
||||
const obj = new Inbound.VLESSSettings(
|
||||
Protocols.VLESS,
|
||||
|
|
@ -2575,8 +2657,7 @@ Inbound.VLESSSettings = class extends Inbound.Settings {
|
|||
json.decryption,
|
||||
json.encryption,
|
||||
Inbound.VLESSSettings.Fallback.fromJson(json.fallbacks || []),
|
||||
json.selectedAuth,
|
||||
testseed
|
||||
testseed,
|
||||
);
|
||||
return obj;
|
||||
}
|
||||
|
|
@ -2598,13 +2679,15 @@ Inbound.VLESSSettings = class extends Inbound.Settings {
|
|||
if (this.fallbacks && this.fallbacks.length > 0) {
|
||||
json.fallbacks = Inbound.VLESSSettings.toJsonArray(this.fallbacks);
|
||||
}
|
||||
if (this.selectedAuth) {
|
||||
json.selectedAuth = this.selectedAuth;
|
||||
}
|
||||
|
||||
// Only include testseed if at least one client has a flow set
|
||||
const hasFlow = this.vlesses && this.vlesses.some(vless => vless.flow && vless.flow !== '');
|
||||
if (hasFlow && this.testseed && this.testseed.length >= 4) {
|
||||
// testseed is only meaningful for the exact xtls-rprx-vision flow, and only when
|
||||
// the user supplied a complete 4-positive-int array. Otherwise omit and let the
|
||||
// backend fall back to its safe defaults.
|
||||
const hasVisionFlow = this.vlesses && this.vlesses.some(v => v.flow === TLS_FLOW_CONTROL.VISION);
|
||||
if (hasVisionFlow
|
||||
&& Array.isArray(this.testseed)
|
||||
&& this.testseed.length === 4
|
||||
&& this.testseed.every(v => Number.isInteger(v) && v > 0)) {
|
||||
json.testseed = this.testseed;
|
||||
}
|
||||
|
||||
|
|
@ -2663,31 +2746,13 @@ Inbound.VLESSSettings.Fallback = class extends XrayCommonClass {
|
|||
}
|
||||
|
||||
toJson() {
|
||||
let xver = this.xver;
|
||||
if (!Number.isInteger(xver)) {
|
||||
xver = 0;
|
||||
}
|
||||
return {
|
||||
name: this.name,
|
||||
alpn: this.alpn,
|
||||
path: this.path,
|
||||
dest: this.dest,
|
||||
xver: xver,
|
||||
}
|
||||
return XrayCommonClass.fallbackToJson(this);
|
||||
}
|
||||
|
||||
static fromJson(json = []) {
|
||||
const fallbacks = [];
|
||||
for (let fallback of json) {
|
||||
fallbacks.push(new Inbound.VLESSSettings.Fallback(
|
||||
fallback.name,
|
||||
fallback.alpn,
|
||||
fallback.path,
|
||||
fallback.dest,
|
||||
fallback.xver,
|
||||
))
|
||||
}
|
||||
return fallbacks;
|
||||
return (json || []).map(f => new Inbound.VLESSSettings.Fallback(
|
||||
f.name, f.alpn, f.path, f.dest, f.xver,
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -2716,10 +2781,13 @@ Inbound.TrojanSettings = class extends Inbound.Settings {
|
|||
}
|
||||
|
||||
toJson() {
|
||||
return {
|
||||
const json = {
|
||||
clients: Inbound.TrojanSettings.toJsonArray(this.trojans),
|
||||
fallbacks: Inbound.TrojanSettings.toJsonArray(this.fallbacks)
|
||||
};
|
||||
if (this.fallbacks && this.fallbacks.length > 0) {
|
||||
json.fallbacks = Inbound.TrojanSettings.toJsonArray(this.fallbacks);
|
||||
}
|
||||
return json;
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -2758,31 +2826,13 @@ Inbound.TrojanSettings.Fallback = class extends XrayCommonClass {
|
|||
}
|
||||
|
||||
toJson() {
|
||||
let xver = this.xver;
|
||||
if (!Number.isInteger(xver)) {
|
||||
xver = 0;
|
||||
}
|
||||
return {
|
||||
name: this.name,
|
||||
alpn: this.alpn,
|
||||
path: this.path,
|
||||
dest: this.dest,
|
||||
xver: xver,
|
||||
}
|
||||
return XrayCommonClass.fallbackToJson(this);
|
||||
}
|
||||
|
||||
static fromJson(json = []) {
|
||||
const fallbacks = [];
|
||||
for (let fallback of json) {
|
||||
fallbacks.push(new Inbound.TrojanSettings.Fallback(
|
||||
fallback.name,
|
||||
fallback.alpn,
|
||||
fallback.path,
|
||||
fallback.dest,
|
||||
fallback.xver,
|
||||
))
|
||||
}
|
||||
return fallbacks;
|
||||
return (json || []).map(f => new Inbound.TrojanSettings.Fallback(
|
||||
f.name, f.alpn, f.path, f.dest, f.xver,
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -3127,7 +3177,7 @@ Inbound.TunSettings = class extends Inbound.Settings {
|
|||
constructor(
|
||||
protocol,
|
||||
name = 'xray0',
|
||||
mtu = [1500, 1280],
|
||||
mtu = 1500,
|
||||
gateway = [],
|
||||
dns = [],
|
||||
userLevel = 0,
|
||||
|
|
@ -3136,7 +3186,7 @@ Inbound.TunSettings = class extends Inbound.Settings {
|
|||
) {
|
||||
super(protocol);
|
||||
this.name = name;
|
||||
this.mtu = this._normalizeMtu(mtu);
|
||||
this.mtu = Number(mtu) || 1500;
|
||||
this.gateway = Array.isArray(gateway) ? gateway : [];
|
||||
this.dns = Array.isArray(dns) ? dns : [];
|
||||
this.userLevel = userLevel;
|
||||
|
|
@ -3144,26 +3194,13 @@ Inbound.TunSettings = class extends Inbound.Settings {
|
|||
this.autoOutboundsInterface = autoOutboundsInterface;
|
||||
}
|
||||
|
||||
_normalizeMtu(mtu) {
|
||||
if (!Array.isArray(mtu)) {
|
||||
const single = Number(mtu) || 1500;
|
||||
return [single, single];
|
||||
}
|
||||
if (mtu.length === 0) {
|
||||
return [1500, 1280];
|
||||
}
|
||||
if (mtu.length === 1) {
|
||||
const single = Number(mtu[0]) || 1500;
|
||||
return [single, single];
|
||||
}
|
||||
return [Number(mtu[0]) || 1500, Number(mtu[1]) || 1280];
|
||||
}
|
||||
|
||||
static fromJson(json = {}) {
|
||||
const rawMtu = json.mtu ?? json.MTU;
|
||||
const mtu = Array.isArray(rawMtu) ? rawMtu[0] : rawMtu;
|
||||
return new Inbound.TunSettings(
|
||||
Protocols.TUN,
|
||||
json.name ?? 'xray0',
|
||||
json.mtu ?? json.MTU ?? [1500, 1280],
|
||||
mtu ?? 1500,
|
||||
json.gateway ?? json.Gateway ?? [],
|
||||
json.dns ?? json.DNS ?? [],
|
||||
json.userLevel ?? 0,
|
||||
|
|
@ -3175,7 +3212,7 @@ Inbound.TunSettings = class extends Inbound.Settings {
|
|||
toJson() {
|
||||
return {
|
||||
name: this.name || 'xray0',
|
||||
mtu: this._normalizeMtu(this.mtu),
|
||||
mtu: Number(this.mtu) || 1500,
|
||||
gateway: this.gateway,
|
||||
dns: this.dns,
|
||||
userLevel: this.userLevel || 0,
|
||||
|
|
|
|||
|
|
@ -402,11 +402,35 @@ class HttpUpgradeStreamSettings extends CommonClass {
|
|||
}
|
||||
}
|
||||
|
||||
// Mirrors the outbound (client-side) view of Xray-core's SplitHTTPConfig
|
||||
// (infra/conf/transport_internet.go). Only fields the client actually
|
||||
// reads at runtime, plus the bidirectional fields the client must match
|
||||
// against the server, live here. Server-only fields (noSSEHeader,
|
||||
// scMaxBufferedPosts, scStreamUpServerSecs, serverMaxHeaderBytes) belong
|
||||
// on the inbound class instead.
|
||||
class xHTTPStreamSettings extends CommonClass {
|
||||
constructor(
|
||||
// Bidirectional — must match the inbound side
|
||||
path = '/',
|
||||
host = '',
|
||||
mode = '',
|
||||
xPaddingBytes = "100-1000",
|
||||
xPaddingObfsMode = false,
|
||||
xPaddingKey = '',
|
||||
xPaddingHeader = '',
|
||||
xPaddingPlacement = '',
|
||||
xPaddingMethod = '',
|
||||
sessionPlacement = '',
|
||||
sessionKey = '',
|
||||
seqPlacement = '',
|
||||
seqKey = '',
|
||||
uplinkDataPlacement = '',
|
||||
uplinkDataKey = '',
|
||||
scMaxEachPostBytes = "1000000",
|
||||
// Client-side only
|
||||
headers = [],
|
||||
uplinkHTTPMethod = '',
|
||||
uplinkChunkSize = 0,
|
||||
noGRPCHeader = false,
|
||||
scMinPostsIntervalMs = "30",
|
||||
xmux = {
|
||||
|
|
@ -417,32 +441,112 @@ class xHTTPStreamSettings extends CommonClass {
|
|||
hMaxReusableSecs: "1800-3000",
|
||||
hKeepAlivePeriod: 0,
|
||||
},
|
||||
// UI-only toggle — controls whether the XMUX block is expanded in
|
||||
// the form (mirrors the QUIC Params switch in stream_finalmask).
|
||||
// Never serialized; toJson() only emits the xmux block itself.
|
||||
enableXmux = false,
|
||||
) {
|
||||
super();
|
||||
this.path = path;
|
||||
this.host = host;
|
||||
this.mode = mode;
|
||||
this.xPaddingBytes = xPaddingBytes;
|
||||
this.xPaddingObfsMode = xPaddingObfsMode;
|
||||
this.xPaddingKey = xPaddingKey;
|
||||
this.xPaddingHeader = xPaddingHeader;
|
||||
this.xPaddingPlacement = xPaddingPlacement;
|
||||
this.xPaddingMethod = xPaddingMethod;
|
||||
this.sessionPlacement = sessionPlacement;
|
||||
this.sessionKey = sessionKey;
|
||||
this.seqPlacement = seqPlacement;
|
||||
this.seqKey = seqKey;
|
||||
this.uplinkDataPlacement = uplinkDataPlacement;
|
||||
this.uplinkDataKey = uplinkDataKey;
|
||||
this.scMaxEachPostBytes = scMaxEachPostBytes;
|
||||
this.headers = headers;
|
||||
this.uplinkHTTPMethod = uplinkHTTPMethod;
|
||||
this.uplinkChunkSize = uplinkChunkSize;
|
||||
this.noGRPCHeader = noGRPCHeader;
|
||||
this.scMinPostsIntervalMs = scMinPostsIntervalMs;
|
||||
this.xmux = xmux;
|
||||
this.enableXmux = enableXmux;
|
||||
}
|
||||
|
||||
addHeader(name, value) {
|
||||
this.headers.push({ name: name, value: value });
|
||||
}
|
||||
|
||||
removeHeader(index) {
|
||||
this.headers.splice(index, 1);
|
||||
}
|
||||
|
||||
static fromJson(json = {}) {
|
||||
const headersInput = json.headers;
|
||||
let headers = [];
|
||||
if (Array.isArray(headersInput)) {
|
||||
headers = headersInput;
|
||||
} else if (headersInput && typeof headersInput === 'object') {
|
||||
// Upstream uses a {name: value} map; convert to the panel's [{name, value}] form.
|
||||
headers = Object.entries(headersInput).map(([name, value]) => ({ name, value }));
|
||||
}
|
||||
return new xHTTPStreamSettings(
|
||||
json.path,
|
||||
json.host,
|
||||
json.mode,
|
||||
json.xPaddingBytes,
|
||||
json.xPaddingObfsMode,
|
||||
json.xPaddingKey,
|
||||
json.xPaddingHeader,
|
||||
json.xPaddingPlacement,
|
||||
json.xPaddingMethod,
|
||||
json.sessionPlacement,
|
||||
json.sessionKey,
|
||||
json.seqPlacement,
|
||||
json.seqKey,
|
||||
json.uplinkDataPlacement,
|
||||
json.uplinkDataKey,
|
||||
json.scMaxEachPostBytes,
|
||||
headers,
|
||||
json.uplinkHTTPMethod,
|
||||
json.uplinkChunkSize,
|
||||
json.noGRPCHeader,
|
||||
json.scMinPostsIntervalMs,
|
||||
json.xmux
|
||||
json.xmux,
|
||||
// Auto-toggle the XMUX switch on when an existing outbound has
|
||||
// the xmux key saved, so users editing such configs see their
|
||||
// values immediately.
|
||||
json.xmux !== undefined,
|
||||
);
|
||||
}
|
||||
|
||||
toJson() {
|
||||
// Upstream expects headers as a {name: value} map, not a list of entries.
|
||||
const headersMap = {};
|
||||
if (Array.isArray(this.headers)) {
|
||||
for (const h of this.headers) {
|
||||
if (h && h.name) headersMap[h.name] = h.value || '';
|
||||
}
|
||||
}
|
||||
return {
|
||||
path: this.path,
|
||||
host: this.host,
|
||||
mode: this.mode,
|
||||
xPaddingBytes: this.xPaddingBytes,
|
||||
xPaddingObfsMode: this.xPaddingObfsMode,
|
||||
xPaddingKey: this.xPaddingKey,
|
||||
xPaddingHeader: this.xPaddingHeader,
|
||||
xPaddingPlacement: this.xPaddingPlacement,
|
||||
xPaddingMethod: this.xPaddingMethod,
|
||||
sessionPlacement: this.sessionPlacement,
|
||||
sessionKey: this.sessionKey,
|
||||
seqPlacement: this.seqPlacement,
|
||||
seqKey: this.seqKey,
|
||||
uplinkDataPlacement: this.uplinkDataPlacement,
|
||||
uplinkDataKey: this.uplinkDataKey,
|
||||
scMaxEachPostBytes: this.scMaxEachPostBytes,
|
||||
headers: headersMap,
|
||||
uplinkHTTPMethod: this.uplinkHTTPMethod,
|
||||
uplinkChunkSize: this.uplinkChunkSize,
|
||||
noGRPCHeader: this.noGRPCHeader,
|
||||
scMinPostsIntervalMs: this.scMinPostsIntervalMs,
|
||||
xmux: {
|
||||
|
|
@ -1139,11 +1243,11 @@ class Outbound extends CommonClass {
|
|||
return false;
|
||||
}
|
||||
|
||||
// Vision seed applies only when vision flow is selected
|
||||
// Vision seed applies only when the XTLS Vision (TCP/TLS) flow is selected.
|
||||
// Excludes the UDP variant per spec.
|
||||
canEnableVisionSeed() {
|
||||
if (!this.canEnableTlsFlow()) return false;
|
||||
const flow = this.settings?.flow;
|
||||
return flow === TLS_FLOW_CONTROL.VISION || flow === TLS_FLOW_CONTROL.VISION_UDP443;
|
||||
return this.settings?.flow === TLS_FLOW_CONTROL.VISION;
|
||||
}
|
||||
|
||||
canEnableReality() {
|
||||
|
|
@ -1799,7 +1903,7 @@ Outbound.VmessSettings = class extends CommonClass {
|
|||
}
|
||||
};
|
||||
Outbound.VLESSSettings = class extends CommonClass {
|
||||
constructor(address, port, id, flow, encryption, reverseTag = '', reverseSniffing = new ReverseSniffing(), testpre = 0, testseed = [900, 500, 900, 256]) {
|
||||
constructor(address, port, id, flow, encryption, reverseTag = '', reverseSniffing = new ReverseSniffing(), testpre = 0, testseed = []) {
|
||||
super();
|
||||
this.address = address;
|
||||
this.port = port;
|
||||
|
|
@ -1814,6 +1918,12 @@ Outbound.VLESSSettings = class extends CommonClass {
|
|||
|
||||
static fromJson(json = {}) {
|
||||
if (ObjectUtil.isEmpty(json.address) || ObjectUtil.isEmpty(json.port)) return new Outbound.VLESSSettings();
|
||||
const saved = json.testseed;
|
||||
const testseed = (Array.isArray(saved)
|
||||
&& saved.length === 4
|
||||
&& saved.every(v => Number.isInteger(v) && v > 0))
|
||||
? saved
|
||||
: [];
|
||||
return new Outbound.VLESSSettings(
|
||||
json.address,
|
||||
json.port,
|
||||
|
|
@ -1823,7 +1933,7 @@ Outbound.VLESSSettings = class extends CommonClass {
|
|||
json.reverse?.tag || '',
|
||||
ReverseSniffing.fromJson(json.reverse?.sniffing || {}),
|
||||
json.testpre || 0,
|
||||
json.testseed && json.testseed.length >= 4 ? json.testseed : [900, 500, 900, 256]
|
||||
testseed,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -1843,12 +1953,14 @@ Outbound.VLESSSettings = class extends CommonClass {
|
|||
sniffing: JSON.stringify(reverseSniffing) === JSON.stringify(defaultReverseSniffing) ? {} : reverseSniffing,
|
||||
};
|
||||
}
|
||||
// Only include Vision settings when flow is set
|
||||
if (this.flow && this.flow !== '') {
|
||||
// Vision-specific knobs are only meaningful for the exact xtls-rprx-vision flow.
|
||||
if (this.flow === TLS_FLOW_CONTROL.VISION) {
|
||||
if (this.testpre > 0) {
|
||||
result.testpre = this.testpre;
|
||||
}
|
||||
if (this.testseed && this.testseed.length >= 4) {
|
||||
if (Array.isArray(this.testseed)
|
||||
&& this.testseed.length === 4
|
||||
&& this.testseed.every(v => Number.isInteger(v) && v > 0)) {
|
||||
result.testseed = this.testseed;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -135,8 +135,9 @@
|
|||
return enabledOk && expiryOk && trafficOk;
|
||||
},
|
||||
shadowrocketUrl() {
|
||||
const rawUrl = this.app.subUrl + '?flag=shadowrocket';
|
||||
const base64Url = btoa(rawUrl);
|
||||
const separator = this.app.subUrl.includes('?') ? '&' : '?';
|
||||
const rawUrl = this.app.subUrl + separator + 'flag=shadowrocket';
|
||||
const base64Url = encodeURIComponent(btoa(rawUrl));
|
||||
const remark = encodeURIComponent(this.app.sId || 'Subscription');
|
||||
return `shadowrocket://add/sub/${base64Url}?remark=${remark}`;
|
||||
},
|
||||
|
|
|
|||
85
web/html/form/fallbacks.html
Normal file
85
web/html/form/fallbacks.html
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
{{define "form/fallbacks"}}
|
||||
<div :style="{ display: 'flex', alignItems: 'center', flexWrap: 'wrap', gap: '8px', margin: '8px 0' }">
|
||||
<span :style="{ fontWeight: 500 }">
|
||||
<a-tooltip title="Route incoming TLS traffic to a backend when it doesn't match a valid VLESS/Trojan handshake. Match by SNI, ALPN, and HTTP path; the most precise rule wins. Fallbacks require TCP+TLS transport.">
|
||||
Fallbacks ([[ inbound.settings.fallbacks.length ]])
|
||||
<a-icon type="question-circle"></a-icon>
|
||||
</a-tooltip>
|
||||
</span>
|
||||
<span :style="{ flex: 1 }"></span>
|
||||
<a-button icon="plus" type="primary" size="small" @click="inbound.settings.addFallback()">Add</a-button>
|
||||
</div>
|
||||
|
||||
<a-form v-for="(fallback, index) in inbound.settings.fallbacks" :colon="false"
|
||||
:label-col="{ md: {span:8} }" :wrapper-col="{ md: {span:14} }">
|
||||
<a-divider :style="{ margin: '0' }">
|
||||
Fallback [[ index + 1 ]]
|
||||
<a-icon type="delete" @click="() => inbound.settings.delFallback(index)"
|
||||
:style="{ color: 'rgb(255, 77, 79)', cursor: 'pointer', marginLeft: '6px' }"></a-icon>
|
||||
</a-divider>
|
||||
|
||||
<a-form-item>
|
||||
<template slot="label">
|
||||
<a-tooltip title="Match TLS SNI (server name). Leave empty to match any SNI.">
|
||||
SNI <a-icon type="question-circle"></a-icon>
|
||||
</a-tooltip>
|
||||
</template>
|
||||
<a-input v-model.trim="fallback.name" placeholder="any (leave empty)"></a-input>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item>
|
||||
<template slot="label">
|
||||
<a-tooltip title="Match TLS ALPN. 'any' = no ALPN constraint. Use h2/http/1.1 split when the inbound advertises both.">
|
||||
ALPN <a-icon type="question-circle"></a-icon>
|
||||
</a-tooltip>
|
||||
</template>
|
||||
<a-select v-model="fallback.alpn" :style="{ width: '100%' }">
|
||||
<a-select-option value="">any</a-select-option>
|
||||
<a-select-option value="h2">h2</a-select-option>
|
||||
<a-select-option value="http/1.1">http/1.1</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item
|
||||
:validate-status="fallback.path && !fallback.path.startsWith('/') ? 'error' : ''"
|
||||
:help="fallback.path && !fallback.path.startsWith('/') ? 'Path must start with /' : ''">
|
||||
<template slot="label">
|
||||
<a-tooltip title="Match the HTTP request path of the first packet. Must start with '/'. Leave empty to match any.">
|
||||
Path <a-icon type="question-circle"></a-icon>
|
||||
</a-tooltip>
|
||||
</template>
|
||||
<a-input v-model.trim="fallback.path" placeholder="any (leave empty) or /ws"></a-input>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item
|
||||
:validate-status="!fallback.dest ? 'error' : ''"
|
||||
:help="!fallback.dest ? 'Destination is required' : ''">
|
||||
<template slot="label">
|
||||
<a-tooltip>
|
||||
<template slot="title">
|
||||
<span>
|
||||
Where matching traffic is forwarded. Accepts a port number (<code>80</code>),
|
||||
an <code>addr:port</code> (<code>127.0.0.1:8080</code>), or a Unix socket path
|
||||
(<code>/dev/shm/x.sock</code> or <code>@abstract</code>).
|
||||
</span>
|
||||
</template>
|
||||
Destination <a-icon type="question-circle"></a-icon>
|
||||
</a-tooltip>
|
||||
</template>
|
||||
<a-input v-model.trim="fallback.dest" placeholder="80 | 127.0.0.1:8080 | /dev/shm/x.sock"></a-input>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item>
|
||||
<template slot="label">
|
||||
<a-tooltip title="PROXY protocol version sent to the destination. Off (0) for plain TCP; v1/v2 to preserve client IP if the backend supports it.">
|
||||
PROXY <a-icon type="question-circle"></a-icon>
|
||||
</a-tooltip>
|
||||
</template>
|
||||
<a-select v-model="fallback.xver" :style="{ width: '100%' }">
|
||||
<a-select-option :value="0">Off</a-select-option>
|
||||
<a-select-option :value="1">v1</a-select-option>
|
||||
<a-select-option :value="2">v2</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
{{end}}
|
||||
|
|
@ -566,36 +566,150 @@
|
|||
<a-form-item label='{{ i18n "path" }}'>
|
||||
<a-input v-model.trim="outbound.stream.xhttp.path"></a-input>
|
||||
</a-form-item>
|
||||
<a-form-item label='{{ i18n "pages.inbounds.stream.tcp.requestHeader" }}'>
|
||||
<a-button icon="plus" size="small" @click="outbound.stream.xhttp.addHeader('', '')"></a-button>
|
||||
</a-form-item>
|
||||
<a-form-item :wrapper-col="{span:24}">
|
||||
<a-input-group compact v-for="(header, index) in outbound.stream.xhttp.headers">
|
||||
<a-input :style="{ width: '50%' }" v-model.trim="header.name"
|
||||
placeholder='{{ i18n "pages.inbounds.stream.general.name"}}'>
|
||||
<template slot="addonBefore">[[ index+1 ]]</template>
|
||||
</a-input>
|
||||
<a-input :style="{ width: '50%' }" v-model.trim="header.value"
|
||||
placeholder='{{ i18n "pages.inbounds.stream.general.value" }}'>
|
||||
<a-button icon="minus" slot="addonAfter" size="small"
|
||||
@click="outbound.stream.xhttp.removeHeader(index)"></a-button>
|
||||
</a-input>
|
||||
</a-input-group>
|
||||
</a-form-item>
|
||||
<a-form-item label="Mode">
|
||||
<a-select v-model="outbound.stream.xhttp.mode" :dropdown-class-name="themeSwitcher.currentTheme">
|
||||
<a-select-option v-for="key in MODE_OPTION" :value="key">[[ key ]]</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label="No gRPC Header"
|
||||
v-if="outbound.stream.xhttp.mode === 'stream-up' || outbound.stream.xhttp.mode === 'stream-one'">
|
||||
<a-switch v-model="outbound.stream.xhttp.noGRPCHeader"></a-switch>
|
||||
<a-form-item label="Max Upload Size (Byte)" v-if="outbound.stream.xhttp.mode === 'packet-up'">
|
||||
<a-input v-model.trim="outbound.stream.xhttp.scMaxEachPostBytes"></a-input>
|
||||
</a-form-item>
|
||||
<a-form-item label="Min Upload Interval (Ms)" v-if="outbound.stream.xhttp.mode === 'packet-up'">
|
||||
<a-input v-model.trim="outbound.stream.xhttp.scMinPostsIntervalMs"></a-input>
|
||||
</a-form-item>
|
||||
<a-form-item label="Max Concurrency" v-if="!outbound.stream.xhttp.xmux.maxConnections">
|
||||
<a-input v-model="outbound.stream.xhttp.xmux.maxConcurrency"></a-input>
|
||||
<a-form-item label="Padding Bytes">
|
||||
<a-input v-model.trim="outbound.stream.xhttp.xPaddingBytes"></a-input>
|
||||
</a-form-item>
|
||||
<a-form-item label="Max Connections" v-if="!outbound.stream.xhttp.xmux.maxConcurrency">
|
||||
<a-input v-model="outbound.stream.xhttp.xmux.maxConnections"></a-input>
|
||||
<a-form-item label="Padding Obfs Mode">
|
||||
<a-switch v-model="outbound.stream.xhttp.xPaddingObfsMode"></a-switch>
|
||||
</a-form-item>
|
||||
<a-form-item label="Max Reuse Times">
|
||||
<a-input v-model="outbound.stream.xhttp.xmux.cMaxReuseTimes"></a-input>
|
||||
<template v-if="outbound.stream.xhttp.xPaddingObfsMode">
|
||||
<a-form-item label="Padding Key">
|
||||
<a-input v-model.trim="outbound.stream.xhttp.xPaddingKey" placeholder="x_padding"></a-input>
|
||||
</a-form-item>
|
||||
<a-form-item label="Padding Header">
|
||||
<a-input v-model.trim="outbound.stream.xhttp.xPaddingHeader" placeholder="X-Padding"></a-input>
|
||||
</a-form-item>
|
||||
<a-form-item label="Padding Placement">
|
||||
<a-select v-model="outbound.stream.xhttp.xPaddingPlacement"
|
||||
:dropdown-class-name="themeSwitcher.currentTheme">
|
||||
<a-select-option value>Default (queryInHeader)</a-select-option>
|
||||
<a-select-option value="queryInHeader">queryInHeader</a-select-option>
|
||||
<a-select-option value="header">header</a-select-option>
|
||||
<a-select-option value="cookie">cookie</a-select-option>
|
||||
<a-select-option value="query">query</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label="Padding Method">
|
||||
<a-select v-model="outbound.stream.xhttp.xPaddingMethod"
|
||||
:dropdown-class-name="themeSwitcher.currentTheme">
|
||||
<a-select-option value>Default (repeat-x)</a-select-option>
|
||||
<a-select-option value="repeat-x">repeat-x</a-select-option>
|
||||
<a-select-option value="tokenish">tokenish</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
</template>
|
||||
<a-form-item label="Uplink HTTP Method">
|
||||
<a-select v-model="outbound.stream.xhttp.uplinkHTTPMethod"
|
||||
:dropdown-class-name="themeSwitcher.currentTheme">
|
||||
<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="outbound.stream.xhttp.mode !== 'packet-up'">
|
||||
GET (packet-up only)
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label="Max Request Times">
|
||||
<a-input v-model="outbound.stream.xhttp.xmux.hMaxRequestTimes"></a-input>
|
||||
<a-form-item label="Session Placement">
|
||||
<a-select v-model="outbound.stream.xhttp.sessionPlacement"
|
||||
:dropdown-class-name="themeSwitcher.currentTheme">
|
||||
<a-select-option value>Default (path)</a-select-option>
|
||||
<a-select-option value="path">path</a-select-option>
|
||||
<a-select-option value="header">header</a-select-option>
|
||||
<a-select-option value="cookie">cookie</a-select-option>
|
||||
<a-select-option value="query">query</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label="Max Reusable Secs">
|
||||
<a-input v-model="outbound.stream.xhttp.xmux.hMaxReusableSecs"></a-input>
|
||||
<a-form-item label="Session Key"
|
||||
v-if="outbound.stream.xhttp.sessionPlacement && outbound.stream.xhttp.sessionPlacement !== 'path'">
|
||||
<a-input v-model.trim="outbound.stream.xhttp.sessionKey" placeholder="x_session"></a-input>
|
||||
</a-form-item>
|
||||
<a-form-item label="Keep Alive Period">
|
||||
<a-input-number v-model.number="outbound.stream.xhttp.xmux.hKeepAlivePeriod"></a-input-number>
|
||||
<a-form-item label="Sequence Placement">
|
||||
<a-select v-model="outbound.stream.xhttp.seqPlacement"
|
||||
:dropdown-class-name="themeSwitcher.currentTheme">
|
||||
<a-select-option value>Default (path)</a-select-option>
|
||||
<a-select-option value="path">path</a-select-option>
|
||||
<a-select-option value="header">header</a-select-option>
|
||||
<a-select-option value="cookie">cookie</a-select-option>
|
||||
<a-select-option value="query">query</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label="Sequence Key"
|
||||
v-if="outbound.stream.xhttp.seqPlacement && outbound.stream.xhttp.seqPlacement !== 'path'">
|
||||
<a-input v-model.trim="outbound.stream.xhttp.seqKey" placeholder="x_seq"></a-input>
|
||||
</a-form-item>
|
||||
<a-form-item label="Uplink Data Placement" v-if="outbound.stream.xhttp.mode === 'packet-up'">
|
||||
<a-select v-model="outbound.stream.xhttp.uplinkDataPlacement"
|
||||
:dropdown-class-name="themeSwitcher.currentTheme">
|
||||
<a-select-option value>Default (body)</a-select-option>
|
||||
<a-select-option value="body">body</a-select-option>
|
||||
<a-select-option value="header">header</a-select-option>
|
||||
<a-select-option value="cookie">cookie</a-select-option>
|
||||
<a-select-option value="query">query</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label="Uplink Data Key"
|
||||
v-if="outbound.stream.xhttp.mode === 'packet-up' && outbound.stream.xhttp.uplinkDataPlacement && outbound.stream.xhttp.uplinkDataPlacement !== 'body'">
|
||||
<a-input v-model.trim="outbound.stream.xhttp.uplinkDataKey" placeholder="x_data"></a-input>
|
||||
</a-form-item>
|
||||
<a-form-item label="Uplink Chunk Size"
|
||||
v-if="outbound.stream.xhttp.mode === 'packet-up' && outbound.stream.xhttp.uplinkDataPlacement && outbound.stream.xhttp.uplinkDataPlacement !== 'body'">
|
||||
<a-input-number v-model.number="outbound.stream.xhttp.uplinkChunkSize" :min="0"
|
||||
placeholder="0 (unlimited)"></a-input-number>
|
||||
</a-form-item>
|
||||
<a-form-item label="No gRPC Header"
|
||||
v-if="outbound.stream.xhttp.mode === 'stream-up' || outbound.stream.xhttp.mode === 'stream-one'">
|
||||
<a-switch v-model="outbound.stream.xhttp.noGRPCHeader"></a-switch>
|
||||
</a-form-item>
|
||||
<a-form-item label="XMUX">
|
||||
<a-switch v-model="outbound.stream.xhttp.enableXmux"></a-switch>
|
||||
</a-form-item>
|
||||
<template v-if="outbound.stream.xhttp.enableXmux">
|
||||
<a-form-item label="Max Concurrency" v-if="!outbound.stream.xhttp.xmux.maxConnections">
|
||||
<a-input v-model="outbound.stream.xhttp.xmux.maxConcurrency"></a-input>
|
||||
</a-form-item>
|
||||
<a-form-item label="Max Connections" v-if="!outbound.stream.xhttp.xmux.maxConcurrency">
|
||||
<a-input v-model="outbound.stream.xhttp.xmux.maxConnections"></a-input>
|
||||
</a-form-item>
|
||||
<a-form-item label="Max Reuse Times">
|
||||
<a-input v-model="outbound.stream.xhttp.xmux.cMaxReuseTimes"></a-input>
|
||||
</a-form-item>
|
||||
<a-form-item label="Max Request Times">
|
||||
<a-input v-model="outbound.stream.xhttp.xmux.hMaxRequestTimes"></a-input>
|
||||
</a-form-item>
|
||||
<a-form-item label="Max Reusable Secs">
|
||||
<a-input v-model="outbound.stream.xhttp.xmux.hMaxReusableSecs"></a-input>
|
||||
</a-form-item>
|
||||
<a-form-item label="Keep Alive Period">
|
||||
<a-input-number v-model.number="outbound.stream.xhttp.xmux.hKeepAlivePeriod"></a-input-number>
|
||||
</a-form-item>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<!-- hysteria -->
|
||||
|
|
|
|||
|
|
@ -19,35 +19,7 @@
|
|||
</a-collapse-panel>
|
||||
</a-collapse>
|
||||
<template v-if=" inbound.isTcp">
|
||||
<a-form :colon="false" :label-col="{ md: {span:8} }" :wrapper-col="{ md: {span:14} }">
|
||||
<a-form-item label="Fallbacks">
|
||||
<a-button icon="plus" type="primary" size="small" @click="inbound.settings.addFallback()"></a-button>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
|
||||
<!-- trojan fallbacks -->
|
||||
<a-form v-for="(fallback, index) in inbound.settings.fallbacks" :colon="false" :label-col="{ md: {span:8} }"
|
||||
:wrapper-col="{ md: {span:14} }">
|
||||
<a-divider :style="{ margin: '0' }"> Fallback [[ index + 1 ]] <a-icon type="delete"
|
||||
@click="() => inbound.settings.delFallback(index)"
|
||||
:style="{ color: 'rgb(255, 77, 79)', cursor: 'pointer' }"></a-icon>
|
||||
</a-divider>
|
||||
<a-form-item label='SNI'>
|
||||
<a-input v-model="fallback.name"></a-input>
|
||||
</a-form-item>
|
||||
<a-form-item label='ALPN'>
|
||||
<a-input v-model="fallback.alpn"></a-input>
|
||||
</a-form-item>
|
||||
<a-form-item label='Path'>
|
||||
<a-input v-model="fallback.path"></a-input>
|
||||
</a-form-item>
|
||||
<a-form-item label='Dest'>
|
||||
<a-input v-model="fallback.dest"></a-input>
|
||||
</a-form-item>
|
||||
<a-form-item label='xVer'>
|
||||
<a-input-number v-model.number="fallback.xver" :min="0" :max="2"></a-input-number>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
{{template "form/fallbacks" .}}
|
||||
<a-divider style="margin:5px 0;"></a-divider>
|
||||
</template>
|
||||
{{end}}
|
||||
|
|
@ -18,14 +18,11 @@
|
|||
<template slot="title">
|
||||
<span>{{ i18n "pages.xray.tun.mtuDesc" }}</span>
|
||||
</template>
|
||||
MTU IPv4
|
||||
MTU
|
||||
<a-icon type="question-circle"></a-icon>
|
||||
</a-tooltip>
|
||||
</template>
|
||||
<a-input-number v-model.number="inbound.settings.mtu[0]" :min="1" :max="9000" placeholder="1500"></a-input-number>
|
||||
</a-form-item>
|
||||
<a-form-item label="MTU IPv6">
|
||||
<a-input-number v-model.number="inbound.settings.mtu[1]" :min="1" :max="9000" placeholder="1280"></a-input-number>
|
||||
<a-input-number v-model.number="inbound.settings.mtu" :min="1" :max="9000" placeholder="1500"></a-input-number>
|
||||
</a-form-item>
|
||||
<a-form-item label="Gateway">
|
||||
<a-select mode="tags" v-model="inbound.settings.gateway" :style="{ width: '100%' }" :token-separators="[',']"
|
||||
|
|
|
|||
|
|
@ -21,16 +21,6 @@
|
|||
</a-collapse>
|
||||
<template v-if=" !inbound.stream.isTLS || !inbound.stream.isReality">
|
||||
<a-form :colon="false" :label-col="{ md: {span:8} }" :wrapper-col="{ md: {span:14} }">
|
||||
<a-form-item label="Authentication">
|
||||
<a-select v-model="inbound.settings.selectedAuth" @change="getNewVlessEnc"
|
||||
:dropdown-class-name="themeSwitcher.currentTheme">
|
||||
<a-select-option :value="undefined">None</a-select-option>
|
||||
<a-select-option value="X25519, not Post-Quantum">X25519 (not
|
||||
Post-Quantum)</a-select-option>
|
||||
<a-select-option value="ML-KEM-768, Post-Quantum">ML-KEM-768
|
||||
(Post-Quantum)</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label="decryption">
|
||||
<a-input v-model.trim="inbound.settings.decryption"></a-input>
|
||||
</a-form-item>
|
||||
|
|
@ -38,73 +28,57 @@
|
|||
<a-input v-model="inbound.settings.encryption"></a-input>
|
||||
</a-form-item>
|
||||
<a-form-item label=" ">
|
||||
<a-space>
|
||||
<a-button type="primary" icon="import" @click="getNewVlessEnc">Get New
|
||||
keys</a-button>
|
||||
<a-space :size="8" wrap>
|
||||
<a-button type="primary" icon="import" @click="getNewVlessEnc('X25519, not Post-Quantum')">
|
||||
X25519
|
||||
</a-button>
|
||||
<a-button type="primary" icon="import" @click="getNewVlessEnc('ML-KEM-768, Post-Quantum')">
|
||||
ML-KEM-768
|
||||
</a-button>
|
||||
<a-button danger @click="clearVlessEnc">Clear</a-button>
|
||||
</a-space>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
<a-divider :style="{ margin: '5px 0' }"></a-divider>
|
||||
</template>
|
||||
<template v-if="inbound.isTcp && !inbound.settings.selectedAuth">
|
||||
<a-form :colon="false" :label-col="{ md: {span:8} }" :wrapper-col="{ md: {span:14} }">
|
||||
<a-form-item label="Fallbacks">
|
||||
<a-button icon="plus" type="primary" size="small" @click="inbound.settings.addFallback()"></a-button>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
|
||||
<!-- vless fallbacks -->
|
||||
<a-form v-for="(fallback, index) in inbound.settings.fallbacks" :colon="false" :label-col="{ md: {span:8} }"
|
||||
:wrapper-col="{ md: {span:14} }">
|
||||
<a-divider :style="{ margin: '0' }"> Fallback [[ index + 1 ]] <a-icon type="delete"
|
||||
@click="() => inbound.settings.delFallback(index)"
|
||||
:style="{ color: 'rgb(255, 77, 79)', cursor: 'pointer' }"></a-icon>
|
||||
</a-divider>
|
||||
<a-form-item label='SNI'>
|
||||
<a-input v-model="fallback.name"></a-input>
|
||||
</a-form-item>
|
||||
<a-form-item label='ALPN'>
|
||||
<a-input v-model="fallback.alpn"></a-input>
|
||||
</a-form-item>
|
||||
<a-form-item label='Path'>
|
||||
<a-input v-model="fallback.path"></a-input>
|
||||
</a-form-item>
|
||||
<a-form-item label='Dest'>
|
||||
<a-input v-model="fallback.dest"></a-input>
|
||||
</a-form-item>
|
||||
<a-form-item label='xVer'>
|
||||
<a-input-number v-model.number="fallback.xver" :min="0" :max="2"></a-input-number>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
<template v-if="inbound.isTcp && (!inbound.settings.encryption || inbound.settings.encryption === 'none')">
|
||||
{{template "form/fallbacks" .}}
|
||||
<a-divider :style="{ margin: '5px 0' }"></a-divider>
|
||||
</template>
|
||||
<template v-if="inbound.canEnableVisionSeed()">
|
||||
<a-form :colon="false" :label-col="{ md: {span:8} }" :wrapper-col="{ md: {span:14} }">
|
||||
<a-form-item label="Vision Seed">
|
||||
<a-form-item
|
||||
:validate-status="testseedError() ? 'error' : ''"
|
||||
:help="testseedError() || ''">
|
||||
<template slot="label">
|
||||
Vision Seed
|
||||
<a-tooltip title="Optional. Controls XTLS Vision padding. Provide exactly 4 positive integers, or leave empty to use defaults: [900, 500, 900, 256].">
|
||||
<a-icon type="question-circle" :style="{ marginLeft: '4px' }"></a-icon>
|
||||
</a-tooltip>
|
||||
</template>
|
||||
<a-row :gutter="8">
|
||||
<a-col :span="6">
|
||||
<a-input-number
|
||||
:value="(inbound.settings.testseed && inbound.settings.testseed[0] !== undefined) ? inbound.settings.testseed[0] : 900"
|
||||
@change="(val) => updateTestseed(0, val)" :min="0" :max="9999" :style="{ width: '100%' }" placeholder="900"
|
||||
:value="(inbound.settings.testseed && inbound.settings.testseed[0] !== undefined) ? inbound.settings.testseed[0] : null"
|
||||
@change="(val) => updateTestseed(0, val)" :min="1" :max="9999" :style="{ width: '100%' }" placeholder="900"
|
||||
addon-before="[0]"></a-input-number>
|
||||
</a-col>
|
||||
<a-col :span="6">
|
||||
<a-input-number
|
||||
:value="(inbound.settings.testseed && inbound.settings.testseed[1] !== undefined) ? inbound.settings.testseed[1] : 500"
|
||||
@change="(val) => updateTestseed(1, val)" :min="0" :max="9999" :style="{ width: '100%' }" placeholder="500"
|
||||
:value="(inbound.settings.testseed && inbound.settings.testseed[1] !== undefined) ? inbound.settings.testseed[1] : null"
|
||||
@change="(val) => updateTestseed(1, val)" :min="1" :max="9999" :style="{ width: '100%' }" placeholder="500"
|
||||
addon-before="[1]"></a-input-number>
|
||||
</a-col>
|
||||
<a-col :span="6">
|
||||
<a-input-number
|
||||
:value="(inbound.settings.testseed && inbound.settings.testseed[2] !== undefined) ? inbound.settings.testseed[2] : 900"
|
||||
@change="(val) => updateTestseed(2, val)" :min="0" :max="9999" :style="{ width: '100%' }" placeholder="900"
|
||||
:value="(inbound.settings.testseed && inbound.settings.testseed[2] !== undefined) ? inbound.settings.testseed[2] : null"
|
||||
@change="(val) => updateTestseed(2, val)" :min="1" :max="9999" :style="{ width: '100%' }" placeholder="900"
|
||||
addon-before="[2]"></a-input-number>
|
||||
</a-col>
|
||||
<a-col :span="6">
|
||||
<a-input-number
|
||||
:value="(inbound.settings.testseed && inbound.settings.testseed[3] !== undefined) ? inbound.settings.testseed[3] : 256"
|
||||
@change="(val) => updateTestseed(3, val)" :min="0" :max="9999" :style="{ width: '100%' }" placeholder="256"
|
||||
:value="(inbound.settings.testseed && inbound.settings.testseed[3] !== undefined) ? inbound.settings.testseed[3] : null"
|
||||
@change="(val) => updateTestseed(3, val)" :min="1" :max="9999" :style="{ width: '100%' }" placeholder="256"
|
||||
addon-before="[3]"></a-input-number>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
|
@ -116,6 +90,10 @@
|
|||
Reset
|
||||
</a-button>
|
||||
</a-space>
|
||||
<div :style="{ marginTop: '6px', fontSize: '12px', color: 'inherit', opacity: 0.65, lineHeight: 1.4 }">
|
||||
Optional. Controls XTLS Vision padding behavior (used only for xtls-rprx-vision).
|
||||
Provide exactly four positive integers to customize padding; otherwise leave empty to use safe defaults.
|
||||
</div>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
<a-divider :style="{ margin: '5px 0' }"></a-divider>
|
||||
|
|
|
|||
|
|
@ -37,6 +37,10 @@
|
|||
<a-form-item label="Stream-Up Server" v-if="inbound.stream.xhttp.mode === 'stream-up'">
|
||||
<a-input v-model.trim="inbound.stream.xhttp.scStreamUpServerSecs"></a-input>
|
||||
</a-form-item>
|
||||
<a-form-item label="Server Max Header Bytes">
|
||||
<a-input-number v-model.number="inbound.stream.xhttp.serverMaxHeaderBytes" :min="0"
|
||||
placeholder="0 (default)"></a-input-number>
|
||||
</a-form-item>
|
||||
<a-form-item label="Padding Bytes">
|
||||
<a-input v-model.trim="inbound.stream.xhttp.xPaddingBytes"></a-input>
|
||||
</a-form-item>
|
||||
|
|
@ -67,14 +71,6 @@
|
|||
</a-select>
|
||||
</a-form-item>
|
||||
</template>
|
||||
<a-form-item label="Uplink HTTP Method">
|
||||
<a-select v-model="inbound.stream.xhttp.uplinkHTTPMethod" :dropdown-class-name="themeSwitcher.currentTheme">
|
||||
<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">GET (packet-up only)</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label="Session Placement">
|
||||
<a-select v-model="inbound.stream.xhttp.sessionPlacement" :dropdown-class-name="themeSwitcher.currentTheme">
|
||||
<a-select-option value>Default (path)</a-select-option>
|
||||
|
|
@ -114,13 +110,8 @@
|
|||
v-if="inbound.stream.xhttp.mode === 'packet-up' && inbound.stream.xhttp.uplinkDataPlacement && inbound.stream.xhttp.uplinkDataPlacement !== 'body'">
|
||||
<a-input v-model.trim="inbound.stream.xhttp.uplinkDataKey" placeholder="x_data"></a-input>
|
||||
</a-form-item>
|
||||
<a-form-item label="Uplink Chunk Size"
|
||||
v-if="inbound.stream.xhttp.mode === 'packet-up' && inbound.stream.xhttp.uplinkDataPlacement && inbound.stream.xhttp.uplinkDataPlacement !== 'body'">
|
||||
<a-input-number v-model.number="inbound.stream.xhttp.uplinkChunkSize" :min="0"
|
||||
placeholder="0 (unlimited)"></a-input-number>
|
||||
</a-form-item>
|
||||
<a-form-item label="No SSE Header">
|
||||
<a-switch v-model="inbound.stream.xhttp.noSSEHeader"></a-switch>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
{{end}}
|
||||
{{end}}
|
||||
|
|
|
|||
|
|
@ -1407,14 +1407,34 @@
|
|||
class: themeSwitcher.currentTheme,
|
||||
cancelText: '{{ i18n "cancel"}}',
|
||||
onOk: async () => {
|
||||
const targetVersion = panelUpdateModal.info.latestVersion || '';
|
||||
const baseTip = '{{ i18n "pages.index.dontRefresh"}}';
|
||||
const tipPrefix = targetVersion ? `${baseTip} (${targetVersion})` : baseTip;
|
||||
panelUpdateModal.hide();
|
||||
this.loading(true, '{{ i18n "pages.index.dontRefresh"}}');
|
||||
this.loading(true, tipPrefix);
|
||||
const msg = await HttpUtil.post('/panel/api/server/updatePanel');
|
||||
if (!msg.success) {
|
||||
this.loading(false);
|
||||
return;
|
||||
}
|
||||
await PromiseUtil.sleep(15000);
|
||||
|
||||
// Wait for the running process to exit, then poll the new panel
|
||||
// until it answers (up to ~90s). Reload as soon as it's back.
|
||||
await PromiseUtil.sleep(5000);
|
||||
this.loading(true, tipPrefix);
|
||||
const deadline = Date.now() + 90_000;
|
||||
let back = false;
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
const r = await axios.get('/panel/api/server/status', { timeout: 2000 });
|
||||
if (r && r.data && r.data.success) { back = true; break; }
|
||||
} catch (_) { /* still restarting */ }
|
||||
await PromiseUtil.sleep(2000);
|
||||
}
|
||||
if (back) {
|
||||
this.$message.success('{{ i18n "pages.index.panelUpdateStartedPopover" }}');
|
||||
await PromiseUtil.sleep(800);
|
||||
}
|
||||
window.location.reload();
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -90,14 +90,9 @@
|
|||
<a-tag :color="inbound.stream.security == 'none' ? 'red' : 'green'">[[
|
||||
inbound.stream.security ]]</a-tag>
|
||||
<br />
|
||||
<td>Authentication</td>
|
||||
<a-tag v-if="inbound.settings.selectedAuth" color="green">[[
|
||||
inbound.settings.selectedAuth ? inbound.settings.selectedAuth : ''
|
||||
]]</a-tag>
|
||||
<a-tag v-else color="red">{{ i18n "none" }}</a-tag>
|
||||
<br />
|
||||
{{ i18n "encryption" }}
|
||||
<a-tag class="info-large-tag" :color="inbound.settings.encryption ? 'green' : 'red'">[[
|
||||
<a-tag class="info-large-tag"
|
||||
:color="inbound.settings.encryption && inbound.settings.encryption !== 'none' ? 'green' : 'red'">[[
|
||||
inbound.settings.encryption ? inbound.settings.encryption : ''
|
||||
]]</a-tag>
|
||||
<a-tooltip title='{{ i18n "copy" }}'>
|
||||
|
|
|
|||
|
|
@ -16,6 +16,16 @@
|
|||
inbound: new Inbound(),
|
||||
dbInbound: new DBInbound(),
|
||||
ok() {
|
||||
// Block submit when Vision Seed is XRV-gated and partially/invalidly filled.
|
||||
const seedErr = inModal.testseedError();
|
||||
if (seedErr) {
|
||||
if (typeof Vue !== "undefined" && Vue.prototype && Vue.prototype.$message) {
|
||||
Vue.prototype.$message.error(seedErr);
|
||||
} else {
|
||||
alert(seedErr);
|
||||
}
|
||||
return;
|
||||
}
|
||||
ObjectUtil.execute(inModal.confirm, inModal.inbound, inModal.dbInbound);
|
||||
},
|
||||
show({
|
||||
|
|
@ -33,16 +43,12 @@
|
|||
} else {
|
||||
this.inbound = new Inbound();
|
||||
}
|
||||
// Always ensure testseed is initialized for VLESS protocol (even if vision flow is not set yet)
|
||||
// This ensures Vue reactivity works properly
|
||||
// Ensure VLESS settings has a testseed array reference for Vue reactivity,
|
||||
// but leave it empty so we don't auto-emit defaults — user must explicitly
|
||||
// fill all four fields, or leave blank to fall back to backend defaults.
|
||||
if (this.inbound.protocol === Protocols.VLESS && this.inbound.settings) {
|
||||
if (
|
||||
!this.inbound.settings.testseed ||
|
||||
!Array.isArray(this.inbound.settings.testseed) ||
|
||||
this.inbound.settings.testseed.length < 4
|
||||
) {
|
||||
// Create a new array to ensure Vue reactivity
|
||||
this.inbound.settings.testseed = [900, 500, 900, 256].slice();
|
||||
if (!Array.isArray(this.inbound.settings.testseed)) {
|
||||
this.inbound.settings.testseed = [];
|
||||
}
|
||||
}
|
||||
if (dbInbound) {
|
||||
|
|
@ -61,48 +67,50 @@
|
|||
loading(loading = true) {
|
||||
inModal.confirmLoading = loading;
|
||||
},
|
||||
// Vision Seed methods - always available regardless of Vue context
|
||||
// Returns an error string when the current testseed state would be rejected,
|
||||
// or "" when it's valid (empty == use defaults; full 4 positive ints == custom).
|
||||
testseedError() {
|
||||
if (!inModal.inbound || inModal.inbound.protocol !== Protocols.VLESS) return "";
|
||||
if (typeof inModal.inbound.canEnableVisionSeed === "function"
|
||||
&& !inModal.inbound.canEnableVisionSeed()) return "";
|
||||
const seed = inModal.inbound.settings && inModal.inbound.settings.testseed;
|
||||
if (!Array.isArray(seed) || seed.length === 0) return "";
|
||||
const filled = seed.filter(v => v !== null && v !== undefined && v !== "");
|
||||
if (filled.length === 0) return "";
|
||||
if (seed.length !== 4 || filled.length !== 4 ||
|
||||
!seed.every(v => Number.isInteger(v) && v > 0)) {
|
||||
return "Provide exactly 4 positive integers or leave empty to use defaults.";
|
||||
}
|
||||
return "";
|
||||
},
|
||||
// Vision Seed helpers — always available regardless of Vue context
|
||||
updateTestseed(index, value) {
|
||||
// Use inModal.inbound explicitly to ensure correct context
|
||||
if (!inModal.inbound || !inModal.inbound.settings) return;
|
||||
// Ensure testseed is initialized
|
||||
if (
|
||||
!inModal.inbound.settings.testseed ||
|
||||
!Array.isArray(inModal.inbound.settings.testseed)
|
||||
) {
|
||||
inModal.inbound.settings.testseed = [900, 500, 900, 256];
|
||||
if (!Array.isArray(inModal.inbound.settings.testseed)) {
|
||||
inModal.inbound.settings.testseed = [];
|
||||
}
|
||||
// Ensure array has enough elements
|
||||
while (inModal.inbound.settings.testseed.length <= index) {
|
||||
inModal.inbound.settings.testseed.push(0);
|
||||
const seed = inModal.inbound.settings.testseed;
|
||||
while (seed.length <= index) seed.push(null);
|
||||
seed[index] = value;
|
||||
// If user cleared every slot, collapse back to empty so we omit testseed entirely.
|
||||
if (seed.every(v => v === null || v === undefined || v === "")) {
|
||||
inModal.inbound.settings.testseed = [];
|
||||
}
|
||||
// Update value
|
||||
inModal.inbound.settings.testseed[index] = value;
|
||||
},
|
||||
setRandomTestseed() {
|
||||
// Use inModal.inbound explicitly to ensure correct context
|
||||
if (!inModal.inbound || !inModal.inbound.settings) return;
|
||||
// Ensure testseed is initialized
|
||||
if (
|
||||
!inModal.inbound.settings.testseed ||
|
||||
!Array.isArray(inModal.inbound.settings.testseed) ||
|
||||
inModal.inbound.settings.testseed.length < 4
|
||||
) {
|
||||
inModal.inbound.settings.testseed = [900, 500, 900, 256].slice();
|
||||
}
|
||||
// Create new array with random values
|
||||
// Positive integers only (>=1) so the array passes validation and gets emitted.
|
||||
inModal.inbound.settings.testseed = [
|
||||
Math.floor(Math.random() * 1000),
|
||||
Math.floor(Math.random() * 1000),
|
||||
Math.floor(Math.random() * 1000),
|
||||
Math.floor(Math.random() * 1000),
|
||||
Math.floor(Math.random() * 999) + 1,
|
||||
Math.floor(Math.random() * 999) + 1,
|
||||
Math.floor(Math.random() * 999) + 1,
|
||||
Math.floor(Math.random() * 999) + 1,
|
||||
];
|
||||
},
|
||||
resetTestseed() {
|
||||
// Use inModal.inbound explicitly to ensure correct context
|
||||
if (!inModal.inbound || !inModal.inbound.settings) return;
|
||||
// Reset testseed to default values
|
||||
inModal.inbound.settings.testseed = [900, 500, 900, 256].slice();
|
||||
// Empty == "use server defaults [900, 500, 900, 256]"; placeholders show in the form.
|
||||
inModal.inbound.settings.testseed = [];
|
||||
},
|
||||
});
|
||||
|
||||
|
|
@ -170,27 +178,17 @@
|
|||
});
|
||||
}
|
||||
},
|
||||
// Ensure testseed is always initialized when vision flow is enabled
|
||||
// Keep testseed as a valid array reference for Vue reactivity while the user
|
||||
// toggles flows — but do NOT auto-fill defaults. Empty means "use server defaults"
|
||||
// and is the only way the form omits testseed from the outbound JSON.
|
||||
"inModal.inbound.settings.vlesses": {
|
||||
handler() {
|
||||
if (
|
||||
inModal.inbound.protocol === Protocols.VLESS &&
|
||||
inModal.inbound.settings &&
|
||||
inModal.inbound.settings.vlesses
|
||||
!Array.isArray(inModal.inbound.settings.testseed)
|
||||
) {
|
||||
const hasVisionFlow = inModal.inbound.settings.vlesses.some(
|
||||
(c) =>
|
||||
c.flow === "xtls-rprx-vision" ||
|
||||
c.flow === "xtls-rprx-vision-udp443",
|
||||
);
|
||||
if (
|
||||
hasVisionFlow &&
|
||||
(!inModal.inbound.settings.testseed ||
|
||||
!Array.isArray(inModal.inbound.settings.testseed) ||
|
||||
inModal.inbound.settings.testseed.length < 4)
|
||||
) {
|
||||
inModal.inbound.settings.testseed = [900, 500, 900, 256];
|
||||
}
|
||||
inModal.inbound.settings.testseed = [];
|
||||
}
|
||||
},
|
||||
deep: true,
|
||||
|
|
@ -304,12 +302,11 @@
|
|||
this.inbound.stream.tls.echServerKeys = "";
|
||||
this.inbound.stream.tls.settings.echConfigList = "";
|
||||
},
|
||||
async getNewVlessEnc() {
|
||||
const selected = inModal.inbound.settings.selectedAuth;
|
||||
if (!selected) {
|
||||
this.clearVlessEnc();
|
||||
return;
|
||||
}
|
||||
// Pulls the requested auth block from `xray vlessenc` (which always returns
|
||||
// both X25519 and ML-KEM-768 variants) and applies it to the inbound's
|
||||
// decryption/encryption strings. The auth mode is implied by the resulting
|
||||
async getNewVlessEnc(authLabel) {
|
||||
if (!authLabel) return;
|
||||
|
||||
inModal.loading(true);
|
||||
const msg = await HttpUtil.get("/panel/api/server/getNewVlessEnc");
|
||||
|
|
@ -320,10 +317,10 @@
|
|||
}
|
||||
|
||||
const auths = msg.obj.auths || [];
|
||||
const block = auths.find((a) => a.label === selected);
|
||||
const block = auths.find((a) => a.label === authLabel);
|
||||
|
||||
if (!block) {
|
||||
console.error("No auth block for", selected);
|
||||
console.error("No auth block for", authLabel);
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -333,37 +330,37 @@
|
|||
clearVlessEnc() {
|
||||
this.inbound.settings.decryption = "none";
|
||||
this.inbound.settings.encryption = "none";
|
||||
this.inbound.settings.selectedAuth = undefined;
|
||||
},
|
||||
// Vision Seed methods - must be in Vue methods for proper binding
|
||||
// Vision Seed methods - must be in Vue methods for proper template binding.
|
||||
// Mirror the inModal helpers but use Vue.set so the form re-renders.
|
||||
updateTestseed(index, value) {
|
||||
// Ensure testseed is initialized
|
||||
if (
|
||||
!this.inbound.settings.testseed ||
|
||||
!Array.isArray(this.inbound.settings.testseed)
|
||||
) {
|
||||
this.$set(this.inbound.settings, "testseed", [900, 500, 900, 256]);
|
||||
if (!Array.isArray(this.inbound.settings.testseed)) {
|
||||
this.$set(this.inbound.settings, "testseed", []);
|
||||
}
|
||||
// Ensure array has enough elements
|
||||
while (this.inbound.settings.testseed.length <= index) {
|
||||
this.inbound.settings.testseed.push(0);
|
||||
const seed = this.inbound.settings.testseed;
|
||||
while (seed.length <= index) seed.push(null);
|
||||
this.$set(seed, index, value);
|
||||
// Collapse to empty when every slot is cleared so testseed is omitted from JSON.
|
||||
if (seed.every(v => v === null || v === undefined || v === "")) {
|
||||
this.$set(this.inbound.settings, "testseed", []);
|
||||
}
|
||||
// Update value using Vue.set for reactivity
|
||||
this.$set(this.inbound.settings.testseed, index, value);
|
||||
},
|
||||
setRandomTestseed() {
|
||||
// Create new array with random values and use Vue.set for reactivity
|
||||
// Positive integers only (>=1) so the resulting array passes validation.
|
||||
const newSeed = [
|
||||
Math.floor(Math.random() * 1000),
|
||||
Math.floor(Math.random() * 1000),
|
||||
Math.floor(Math.random() * 1000),
|
||||
Math.floor(Math.random() * 1000),
|
||||
Math.floor(Math.random() * 999) + 1,
|
||||
Math.floor(Math.random() * 999) + 1,
|
||||
Math.floor(Math.random() * 999) + 1,
|
||||
Math.floor(Math.random() * 999) + 1,
|
||||
];
|
||||
this.$set(this.inbound.settings, "testseed", newSeed);
|
||||
},
|
||||
resetTestseed() {
|
||||
// Reset testseed to default values using Vue.set for reactivity
|
||||
this.$set(this.inbound.settings, "testseed", [900, 500, 900, 256]);
|
||||
// Empty == "use server defaults [900, 500, 900, 256]". Placeholders will show in the form.
|
||||
this.$set(this.inbound.settings, "testseed", []);
|
||||
},
|
||||
testseedError() {
|
||||
return inModal.testseedError();
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -104,6 +104,17 @@
|
|||
return outModal.outbound;
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
// xray-core's SplitHTTPConfig.Build() rejects "GET" as
|
||||
// uplinkHTTPMethod outside packet-up mode. Clear the field
|
||||
// instead of carrying an invalid combination through.
|
||||
"outModal.outbound.stream.xhttp.mode"(newMode) {
|
||||
const xhttp = outModal.outbound.stream && outModal.outbound.stream.xhttp;
|
||||
if (xhttp && xhttp.uplinkHTTPMethod === "GET" && newMode !== "packet-up") {
|
||||
xhttp.uplinkHTTPMethod = "";
|
||||
}
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
streamNetworkChange() {
|
||||
if (this.outModal.outbound.protocol == Protocols.VLESS && !outModal.outbound
|
||||
|
|
|
|||
|
|
@ -403,16 +403,6 @@ func (j *CheckClientIpJob) updateInboundClientIps(inboundClientIps *model.Inboun
|
|||
shouldCleanLog := false
|
||||
j.disAllowedIps = []string{}
|
||||
|
||||
// Open log file
|
||||
logIpFile, err := os.OpenFile(xray.GetIPLimitLogPath(), os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644)
|
||||
if err != nil {
|
||||
logger.Errorf("failed to open IP limit log file: %s", err)
|
||||
return false
|
||||
}
|
||||
defer logIpFile.Close()
|
||||
log.SetOutput(logIpFile)
|
||||
log.SetFlags(log.LstdFlags)
|
||||
|
||||
// historical db-only ips are excluded from this count on purpose.
|
||||
var keptLive []IPWithTimestamp
|
||||
if len(liveIps) > limitIp {
|
||||
|
|
@ -422,13 +412,25 @@ func (j *CheckClientIpJob) updateInboundClientIps(inboundClientIps *model.Inboun
|
|||
keptLive = liveIps[:limitIp]
|
||||
bannedLive := liveIps[limitIp:]
|
||||
|
||||
// Open log file only when a ban entry needs to be written.
|
||||
// Use a local logger to avoid mutating the global log.* state,
|
||||
// which would redirect all standard-library logging to this file
|
||||
// and leave a dangling closed-file handle after the defer fires.
|
||||
logIpFile, err := os.OpenFile(xray.GetIPLimitLogPath(), os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644)
|
||||
if err != nil {
|
||||
logger.Errorf("failed to open IP limit log file: %s", err)
|
||||
return false
|
||||
}
|
||||
defer logIpFile.Close()
|
||||
ipLogger := log.New(logIpFile, "", log.LstdFlags)
|
||||
|
||||
// log format is load-bearing: x-ui.sh create_iplimit_jails builds
|
||||
// filter.d/3x-ipl.conf with
|
||||
// failregex = \[LIMIT_IP\]\s*Email\s*=\s*<F-USER>.+</F-USER>\s*\|\|\s*Disconnecting OLD IP\s*=\s*<ADDR>\s*\|\|\s*Timestamp\s*=\s*\d+
|
||||
// don't change the wording.
|
||||
for _, ipTime := range bannedLive {
|
||||
j.disAllowedIps = append(j.disAllowedIps, ipTime.IP)
|
||||
log.Printf("[LIMIT_IP] Email = %s || Disconnecting OLD IP = %s || Timestamp = %d", clientEmail, ipTime.IP, ipTime.Timestamp)
|
||||
ipLogger.Printf("[LIMIT_IP] Email = %s || Disconnecting OLD IP = %s || Timestamp = %d", clientEmail, ipTime.IP, ipTime.Timestamp)
|
||||
}
|
||||
|
||||
// force xray to drop existing connections from banned ips
|
||||
|
|
|
|||
|
|
@ -1213,6 +1213,28 @@ func (s *InboundService) UpdateInboundClient(data *model.Inbound, clientId strin
|
|||
settingsClients[clientIndex] = interfaceClients[0]
|
||||
oldSettings["clients"] = settingsClients
|
||||
|
||||
// testseed is only meaningful when at least one VLESS client uses the exact
|
||||
// xtls-rprx-vision flow. The client-edit path only rewrites a single client,
|
||||
// so re-check the flow set here and strip a stale testseed when nothing in the
|
||||
// inbound still warrants it. The full-inbound update path already handles this
|
||||
// on the JS side via VLESSSettings.toJson().
|
||||
if oldInbound.Protocol == model.VLESS {
|
||||
hasVisionFlow := false
|
||||
for _, c := range settingsClients {
|
||||
cm, ok := c.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if flow, _ := cm["flow"].(string); flow == "xtls-rprx-vision" {
|
||||
hasVisionFlow = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasVisionFlow {
|
||||
delete(oldSettings, "testseed")
|
||||
}
|
||||
}
|
||||
|
||||
newSettings, err := json.MarshalIndent(oldSettings, "", " ")
|
||||
if err != nil {
|
||||
return false, err
|
||||
|
|
@ -2885,6 +2907,7 @@ func (s *InboundService) MigrationRequirements() {
|
|||
if ok {
|
||||
// Fix Client configuration problems
|
||||
var newClients []any
|
||||
hasVisionFlow := false
|
||||
for client_index := range clients {
|
||||
c := clients[client_index].(map[string]any)
|
||||
|
||||
|
|
@ -2910,6 +2933,9 @@ func (s *InboundService) MigrationRequirements() {
|
|||
c["flow"] = ""
|
||||
}
|
||||
}
|
||||
if flow, _ := c["flow"].(string); flow == "xtls-rprx-vision" {
|
||||
hasVisionFlow = true
|
||||
}
|
||||
// Backfill created_at and updated_at
|
||||
if _, ok := c["created_at"]; !ok {
|
||||
c["created_at"] = time.Now().Unix() * 1000
|
||||
|
|
@ -2918,6 +2944,15 @@ func (s *InboundService) MigrationRequirements() {
|
|||
newClients = append(newClients, any(c))
|
||||
}
|
||||
settings["clients"] = newClients
|
||||
|
||||
// Drop orphaned testseed: VLESS-only field, only meaningful when at least
|
||||
// one client uses the exact xtls-rprx-vision flow. Older versions saved it
|
||||
// for any non-empty flow (including the UDP variant) or kept it after the
|
||||
// flow was cleared from the client modal — clean those up here.
|
||||
if inbounds[inbound_index].Protocol == model.VLESS && !hasVisionFlow {
|
||||
delete(settings, "testseed")
|
||||
}
|
||||
|
||||
modifiedSettings, err := json.MarshalIndent(settings, "", " ")
|
||||
if err != nil {
|
||||
return
|
||||
|
|
|
|||
8
x-ui.sh
8
x-ui.sh
|
|
@ -2034,14 +2034,14 @@ backend=auto
|
|||
filter=3x-ipl
|
||||
action=3x-ipl
|
||||
logpath=${iplimit_log_path}
|
||||
maxretry=2
|
||||
maxretry=1
|
||||
findtime=32
|
||||
bantime=${bantime}m
|
||||
EOF
|
||||
|
||||
cat << EOF > /etc/fail2ban/filter.d/3x-ipl.conf
|
||||
[Definition]
|
||||
datepattern = ^%%Y/%%m/%%d %%H:%%M:%%S
|
||||
datepattern = ^%Y/%m/%d %H:%M:%S
|
||||
failregex = \[LIMIT_IP\]\s*Email\s*=\s*<F-USER>.+</F-USER>\s*\|\|\s*Disconnecting OLD IP\s*=\s*<ADDR>\s*\|\|\s*Timestamp\s*=\s*\d+
|
||||
ignoreregex =
|
||||
EOF
|
||||
|
|
@ -2062,10 +2062,10 @@ actionstop = <iptables> -D <chain> -p <protocol> -j f2b-<name>
|
|||
actioncheck = <iptables> -n -L <chain> | grep -q 'f2b-<name>[ \t]'
|
||||
|
||||
actionban = <iptables> -I f2b-<name> 1 -s <ip> -j <blocktype>
|
||||
echo "\$(date +"%%Y/%%m/%%d %%H:%%M:%%S") BAN [Email] = <F-USER> [IP] = <ip> banned for <bantime> seconds." >> ${iplimit_banned_log_path}
|
||||
echo "\$(date +"%Y/%m/%d %H:%M:%S") BAN [Email] = <F-USER> [IP] = <ip> banned for <bantime> seconds." >> ${iplimit_banned_log_path}
|
||||
|
||||
actionunban = <iptables> -D f2b-<name> -s <ip> -j <blocktype>
|
||||
echo "\$(date +"%%Y/%%m/%%d %%H:%%M:%%S") UNBAN [Email] = <F-USER> [IP] = <ip> unbanned." >> ${iplimit_banned_log_path}
|
||||
echo "\$(date +"%Y/%m/%d %H:%M:%S") UNBAN [Email] = <F-USER> [IP] = <ip> unbanned." >> ${iplimit_banned_log_path}
|
||||
|
||||
[Init]
|
||||
name = default
|
||||
|
|
|
|||
Loading…
Reference in a new issue