Merge branch 'main' into fix/farhadh/security-hardening

This commit is contained in:
Sanaei 2026-05-07 21:06:52 +02:00 committed by GitHub
commit 8f486cadd7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 893 additions and 444 deletions

1
.gitignore vendored
View file

@ -1,6 +1,7 @@
# Ignore editor and IDE settings
.idea/
.vscode/
.claude/
.cache/
.sync*

View file

@ -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

View file

@ -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)
}

File diff suppressed because one or more lines are too long

View file

@ -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,

View file

@ -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;
}
}

View file

@ -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}`;
},

View 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}}

View file

@ -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 -->

View file

@ -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}}

View file

@ -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="[',']"

View file

@ -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>

View file

@ -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}}

View file

@ -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();
},
});

View file

@ -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" }}'>

View file

@ -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();
},
},
});

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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