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 # Ignore editor and IDE settings
.idea/ .idea/
.vscode/ .vscode/
.claude/
.cache/ .cache/
.sync* .sync*

View file

@ -1,7 +1,61 @@
#!/bin/sh #!/bin/sh
# Start fail2ban # Start fail2ban with the 3x-ipl jail
[ $XUI_ENABLE_FAIL2BAN == "true" ] && fail2ban-client -x start 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 # Run x-ui
exec /app/x-ui exec /app/x-ui

View file

@ -582,28 +582,18 @@ func applyShareNetworkParams(stream map[string]any, streamNetwork string, params
applyPathAndHostParams(httpupgrade, params) applyPathAndHostParams(httpupgrade, params)
case "xhttp": case "xhttp":
xhttp, _ := stream["xhttpSettings"].(map[string]any) xhttp, _ := stream["xhttpSettings"].(map[string]any)
applyPathAndHostParams(xhttp, params) applyXhttpExtraParams(xhttp, params)
params["mode"], _ = xhttp["mode"].(string)
applyXhttpPaddingParams(xhttp, params)
} }
} }
func applyXhttpPaddingObj(xhttp map[string]any, obj map[string]any) { // applyXhttpExtraObj copies the bidirectional xhttp settings into the
// VMess base64 JSON supports arbitrary keys; copy the padding // VMess base64 JSON link object. VMess supports arbitrary keys, so we
// settings through so clients can match the server's xhttp // flatten the SplitHTTPConfig "extra" fields directly onto obj.
// xPaddingBytes range and, when the admin opted into obfs func applyXhttpExtraObj(xhttp map[string]any, obj map[string]any) {
// mode, the custom key / header / placement / method.
if xpb, ok := xhttp["xPaddingBytes"].(string); ok && len(xpb) > 0 { if xpb, ok := xhttp["xPaddingBytes"].(string); ok && len(xpb) > 0 {
obj["x_padding_bytes"] = xpb obj["x_padding_bytes"] = xpb
} }
if obfs, ok := xhttp["xPaddingObfsMode"].(bool); ok && obfs { maps.Copy(obj, buildXhttpExtra(xhttp))
obj["xPaddingObfsMode"] = true
for _, field := range []string{"xPaddingKey", "xPaddingHeader", "xPaddingPlacement", "xPaddingMethod"} {
if v, ok := xhttp[field].(string); ok && len(v) > 0 {
obj[field] = v
}
}
}
} }
func applyVmessNetworkParams(stream map[string]any, network string, obj map[string]any) { 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": case "xhttp":
xhttp, _ := stream["xhttpSettings"].(map[string]any) xhttp, _ := stream["xhttpSettings"].(map[string]any)
applyPathAndHostObj(xhttp, obj) applyPathAndHostObj(xhttp, obj)
obj["mode"], _ = xhttp["mode"].(string) if mode, ok := xhttp["mode"].(string); ok {
applyXhttpPaddingObj(xhttp, obj) obj["mode"] = mode
}
applyXhttpExtraObj(xhttp, obj)
} }
} }
@ -928,45 +920,33 @@ func searchKey(data any, key string) (any, bool) {
return nil, false return nil, false
} }
// applyXhttpPaddingParams copies the xPadding* fields from an xhttpSettings // buildXhttpExtra walks an xhttpSettings map and returns the JSON blob
// map into the URL query params of a vless:// / trojan:// / ss:// link. // 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, // - server-only (noSSEHeader, scMaxBufferedPosts, scStreamUpServerSecs,
// so a server configured with a non-default xPaddingBytes (e.g. 80-600) // serverMaxHeaderBytes) — client wouldn't read them, so emitting
// or with xPaddingObfsMode=true + custom xPaddingKey / xPaddingHeader // them just bloats the URL.
// would silently diverge from the client: the client kept defaults, // - client-only (headers, uplinkHTTPMethod, uplinkChunkSize,
// hit the server, and was rejected by its padding validation // noGRPCHeader, scMinPostsIntervalMs, xmux, downloadSettings) — the
// ("invalid padding" in the inbound log) — the client-visible symptom // inbound config doesn't have them; the client configures them
// was "xhttp doesn't connect" on OpenWRT / sing-box. // locally.
// //
// Two encodings are written so every popular client can read at least one: // Truthy-only guards keep default inbounds emitting the same compact URL
// // they did before this helper grew.
// - x_padding_bytes=<range> — flat param, understood by sing-box and its func buildXhttpExtra(xhttp map[string]any) map[string]any {
// 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) {
if xhttp == nil { 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{} extra := map[string]any{}
if xpb, ok := xhttp["xPaddingBytes"].(string); ok && len(xpb) > 0 { if xpb, ok := xhttp["xPaddingBytes"].(string); ok && len(xpb) > 0 {
extra["xPaddingBytes"] = xpb extra["xPaddingBytes"] = xpb
} }
if obfs, ok := xhttp["xPaddingObfsMode"].(bool); ok && obfs { if obfs, ok := xhttp["xPaddingObfsMode"].(bool); ok && obfs {
extra["xPaddingObfsMode"] = true 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"} { for _, field := range []string{"xPaddingKey", "xPaddingHeader", "xPaddingPlacement", "xPaddingMethod"} {
if v, ok := xhttp[field].(string); ok && len(v) > 0 { if v, ok := xhttp[field].(string); ok && len(v) > 0 {
extra[field] = v 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 { if b, err := json.Marshal(extra); err == nil {
params["extra"] = string(b) 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; 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) { toString(format = true) {
return format ? JSON.stringify(this.toJson(), null, 2) : JSON.stringify(this.toJson()); 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 { class xHTTPStreamSettings extends XrayCommonClass {
constructor( constructor(
// Bidirectional — must match between client and server
path = '/', path = '/',
host = '', host = '',
headers = [],
scMaxBufferedPosts = 30,
scMaxEachPostBytes = "1000000",
scStreamUpServerSecs = "20-80",
noSSEHeader = false,
xPaddingBytes = "100-1000",
mode = MODE_OPTION.AUTO, mode = MODE_OPTION.AUTO,
xPaddingBytes = "100-1000",
xPaddingObfsMode = false, xPaddingObfsMode = false,
xPaddingKey = '', xPaddingKey = '',
xPaddingHeader = '', xPaddingHeader = '',
xPaddingPlacement = '', xPaddingPlacement = '',
xPaddingMethod = '', xPaddingMethod = '',
uplinkHTTPMethod = '',
sessionPlacement = '', sessionPlacement = '',
sessionKey = '', sessionKey = '',
seqPlacement = '', seqPlacement = '',
seqKey = '', seqKey = '',
uplinkDataPlacement = '', uplinkDataPlacement = '',
uplinkDataKey = '', 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(); super();
this.path = path; this.path = path;
this.host = host; 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.mode = mode;
this.xPaddingBytes = xPaddingBytes;
this.xPaddingObfsMode = xPaddingObfsMode; this.xPaddingObfsMode = xPaddingObfsMode;
this.xPaddingKey = xPaddingKey; this.xPaddingKey = xPaddingKey;
this.xPaddingHeader = xPaddingHeader; this.xPaddingHeader = xPaddingHeader;
this.xPaddingPlacement = xPaddingPlacement; this.xPaddingPlacement = xPaddingPlacement;
this.xPaddingMethod = xPaddingMethod; this.xPaddingMethod = xPaddingMethod;
this.uplinkHTTPMethod = uplinkHTTPMethod;
this.sessionPlacement = sessionPlacement; this.sessionPlacement = sessionPlacement;
this.sessionKey = sessionKey; this.sessionKey = sessionKey;
this.seqPlacement = seqPlacement; this.seqPlacement = seqPlacement;
this.seqKey = seqKey; this.seqKey = seqKey;
this.uplinkDataPlacement = uplinkDataPlacement; this.uplinkDataPlacement = uplinkDataPlacement;
this.uplinkDataKey = uplinkDataKey; 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) { addHeader(name, value) {
@ -534,26 +560,25 @@ class xHTTPStreamSettings extends XrayCommonClass {
return new xHTTPStreamSettings( return new xHTTPStreamSettings(
json.path, json.path,
json.host, json.host,
XrayCommonClass.toHeaders(json.headers),
json.scMaxBufferedPosts,
json.scMaxEachPostBytes,
json.scStreamUpServerSecs,
json.noSSEHeader,
json.xPaddingBytes,
json.mode, json.mode,
json.xPaddingBytes,
json.xPaddingObfsMode, json.xPaddingObfsMode,
json.xPaddingKey, json.xPaddingKey,
json.xPaddingHeader, json.xPaddingHeader,
json.xPaddingPlacement, json.xPaddingPlacement,
json.xPaddingMethod, json.xPaddingMethod,
json.uplinkHTTPMethod,
json.sessionPlacement, json.sessionPlacement,
json.sessionKey, json.sessionKey,
json.seqPlacement, json.seqPlacement,
json.seqKey, json.seqKey,
json.uplinkDataPlacement, json.uplinkDataPlacement,
json.uplinkDataKey, 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 { return {
path: this.path, path: this.path,
host: this.host, 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, mode: this.mode,
xPaddingBytes: this.xPaddingBytes,
xPaddingObfsMode: this.xPaddingObfsMode, xPaddingObfsMode: this.xPaddingObfsMode,
xPaddingKey: this.xPaddingKey, xPaddingKey: this.xPaddingKey,
xPaddingHeader: this.xPaddingHeader, xPaddingHeader: this.xPaddingHeader,
xPaddingPlacement: this.xPaddingPlacement, xPaddingPlacement: this.xPaddingPlacement,
xPaddingMethod: this.xPaddingMethod, xPaddingMethod: this.xPaddingMethod,
uplinkHTTPMethod: this.uplinkHTTPMethod,
sessionPlacement: this.sessionPlacement, sessionPlacement: this.sessionPlacement,
sessionKey: this.sessionKey, sessionKey: this.sessionKey,
seqPlacement: this.seqPlacement, seqPlacement: this.seqPlacement,
seqKey: this.seqKey, seqKey: this.seqKey,
uplinkDataPlacement: this.uplinkDataPlacement, uplinkDataPlacement: this.uplinkDataPlacement,
uplinkDataKey: this.uplinkDataKey, 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; return this.clientStats;
} }
// Copy the xPadding* settings into the query-string of a vless/trojan/ss // Looks for a "host"-named entry in xhttp.headers and returns its value,
// link. Without this, the admin's custom xPaddingBytes range and (in // or '' if not found. Used as a fallback when xhttp.host is empty so the
// obfs mode) the custom xPaddingKey / xPaddingHeader / placement / // share URL still carries a usable Host hint.
// method never reach the client — the client keeps xray / sing-box's static xhttpHostFallback(xhttp) {
// internal defaults and the server rejects every handshake with if (!xhttp || !Array.isArray(xhttp.headers)) return '';
// `invalid padding (...) length: 0`. for (const h of xhttp.headers) {
// if (h && typeof h.name === 'string' && h.name.toLowerCase() === 'host') {
// Two encodings are emitted so each client family can pick at least return h.value || '';
// 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);
} }
}
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 = {}; const extra = {};
if (typeof xhttp.xPaddingBytes === 'string' && xhttp.xPaddingBytes.length > 0) { if (typeof xhttp.xPaddingBytes === 'string' && xhttp.xPaddingBytes.length > 0) {
extra.xPaddingBytes = xhttp.xPaddingBytes; 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 // VMess variant: VMess links are a base64-encoded JSON object, so we
// copy the padding fields directly into the JSON instead of building // copy the same bidirectional fields directly into the JSON instead
// a query string. // of building a query string. (The base VMess link generator already
static applyXhttpPaddingToObj(xhttp, obj) { // sets net/type/path/host, so we only contribute the SplitHTTPConfig
// extra side here.)
static applyXhttpExtraToObj(xhttp, obj) {
if (!xhttp || !obj) return; if (!xhttp || !obj) return;
if (typeof xhttp.xPaddingBytes === 'string' && xhttp.xPaddingBytes.length > 0) { if (typeof xhttp.xPaddingBytes === 'string' && xhttp.xPaddingBytes.length > 0) {
obj.x_padding_bytes = xhttp.xPaddingBytes; obj.x_padding_bytes = xhttp.xPaddingBytes;
} }
if (xhttp.xPaddingObfsMode === true) { const extra = Inbound.buildXhttpExtra(xhttp);
obj.xPaddingObfsMode = true; if (!extra) return;
["xPaddingKey", "xPaddingHeader", "xPaddingPlacement", "xPaddingMethod"].forEach(k => { for (const [k, v] of Object.entries(extra)) {
if (typeof xhttp[k] === 'string' && xhttp[k].length > 0) { obj[k] = v;
obj[k] = xhttp[k];
}
});
} }
} }
@ -1763,12 +1847,13 @@ class Inbound extends XrayCommonClass {
return false; 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() { canEnableVisionSeed() {
if (!this.canEnableTlsFlow()) return false; if (!this.canEnableTlsFlow()) return false;
const clients = this.settings?.vlesses; const clients = this.settings?.vlesses;
if (!Array.isArray(clients)) return false; 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() { canEnableReality() {
@ -1838,7 +1923,7 @@ class Inbound extends XrayCommonClass {
obj.path = xhttp.path; obj.path = xhttp.path;
obj.host = xhttp.host?.length > 0 ? xhttp.host : this.getHeader(xhttp, 'host'); obj.host = xhttp.host?.length > 0 ? xhttp.host : this.getHeader(xhttp, 'host');
obj.type = xhttp.mode; obj.type = xhttp.mode;
Inbound.applyXhttpPaddingToObj(xhttp, obj); Inbound.applyXhttpExtraToObj(xhttp, obj);
} }
Inbound.applyFinalMaskToObj(this.stream.finalmask, 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')); params.set("host", httpupgrade.host?.length > 0 ? httpupgrade.host : this.getHeader(httpupgrade, 'host'));
break; break;
case "xhttp": case "xhttp":
const xhttp = this.stream.xhttp; Inbound.applyXhttpExtraToParams(this.stream.xhttp, params);
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);
break; break;
} }
@ -2008,11 +2089,7 @@ class Inbound extends XrayCommonClass {
params.set("host", httpupgrade.host?.length > 0 ? httpupgrade.host : this.getHeader(httpupgrade, 'host')); params.set("host", httpupgrade.host?.length > 0 ? httpupgrade.host : this.getHeader(httpupgrade, 'host'));
break; break;
case "xhttp": case "xhttp":
const xhttp = this.stream.xhttp; Inbound.applyXhttpExtraToParams(this.stream.xhttp, params);
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);
break; break;
} }
@ -2089,11 +2166,7 @@ class Inbound extends XrayCommonClass {
params.set("host", httpupgrade.host?.length > 0 ? httpupgrade.host : this.getHeader(httpupgrade, 'host')); params.set("host", httpupgrade.host?.length > 0 ? httpupgrade.host : this.getHeader(httpupgrade, 'host'));
break; break;
case "xhttp": case "xhttp":
const xhttp = this.stream.xhttp; Inbound.applyXhttpExtraToParams(this.stream.xhttp, params);
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);
break; break;
} }
@ -2542,15 +2615,13 @@ Inbound.VLESSSettings = class extends Inbound.Settings {
decryption = "none", decryption = "none",
encryption = "none", encryption = "none",
fallbacks = [], fallbacks = [],
selectedAuth = undefined, testseed = [],
testseed = [900, 500, 900, 256],
) { ) {
super(protocol); super(protocol);
this.vlesses = vlesses; this.vlesses = vlesses;
this.decryption = decryption; this.decryption = decryption;
this.encryption = encryption; this.encryption = encryption;
this.fallbacks = fallbacks; this.fallbacks = fallbacks;
this.selectedAuth = selectedAuth;
this.testseed = testseed; this.testseed = testseed;
} }
@ -2562,21 +2633,31 @@ Inbound.VLESSSettings = class extends Inbound.Settings {
this.fallbacks.splice(index, 1); this.fallbacks.splice(index, 1);
} }
static fromJson(json = {}) { // Empty array means "use server defaults" (won't be sent).
// Ensure testseed is always initialized as an array // Anything else must be exactly 4 positive integers.
let testseed = [900, 500, 900, 256]; static isValidTestseed(arr) {
if (json.testseed && Array.isArray(json.testseed) && json.testseed.length >= 4) { if (!Array.isArray(arr) || arr.length === 0) return true;
testseed = json.testseed; if (arr.length !== 4) return false;
return arr.every(v => Number.isInteger(v) && v > 0);
} }
static fromJson(json = {}) {
// 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( const obj = new Inbound.VLESSSettings(
Protocols.VLESS, Protocols.VLESS,
(json.clients || []).map(client => Inbound.VLESSSettings.VLESS.fromJson(client)), (json.clients || []).map(client => Inbound.VLESSSettings.VLESS.fromJson(client)),
json.decryption, json.decryption,
json.encryption, json.encryption,
Inbound.VLESSSettings.Fallback.fromJson(json.fallbacks || []), Inbound.VLESSSettings.Fallback.fromJson(json.fallbacks || []),
json.selectedAuth, testseed,
testseed
); );
return obj; return obj;
} }
@ -2598,13 +2679,15 @@ Inbound.VLESSSettings = class extends Inbound.Settings {
if (this.fallbacks && this.fallbacks.length > 0) { if (this.fallbacks && this.fallbacks.length > 0) {
json.fallbacks = Inbound.VLESSSettings.toJsonArray(this.fallbacks); 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 // testseed is only meaningful for the exact xtls-rprx-vision flow, and only when
const hasFlow = this.vlesses && this.vlesses.some(vless => vless.flow && vless.flow !== ''); // the user supplied a complete 4-positive-int array. Otherwise omit and let the
if (hasFlow && this.testseed && this.testseed.length >= 4) { // 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; json.testseed = this.testseed;
} }
@ -2663,31 +2746,13 @@ Inbound.VLESSSettings.Fallback = class extends XrayCommonClass {
} }
toJson() { toJson() {
let xver = this.xver; return XrayCommonClass.fallbackToJson(this);
if (!Number.isInteger(xver)) {
xver = 0;
}
return {
name: this.name,
alpn: this.alpn,
path: this.path,
dest: this.dest,
xver: xver,
}
} }
static fromJson(json = []) { static fromJson(json = []) {
const fallbacks = []; return (json || []).map(f => new Inbound.VLESSSettings.Fallback(
for (let fallback of json) { f.name, f.alpn, f.path, f.dest, f.xver,
fallbacks.push(new Inbound.VLESSSettings.Fallback( ));
fallback.name,
fallback.alpn,
fallback.path,
fallback.dest,
fallback.xver,
))
}
return fallbacks;
} }
}; };
@ -2716,10 +2781,13 @@ Inbound.TrojanSettings = class extends Inbound.Settings {
} }
toJson() { toJson() {
return { const json = {
clients: Inbound.TrojanSettings.toJsonArray(this.trojans), 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() { toJson() {
let xver = this.xver; return XrayCommonClass.fallbackToJson(this);
if (!Number.isInteger(xver)) {
xver = 0;
}
return {
name: this.name,
alpn: this.alpn,
path: this.path,
dest: this.dest,
xver: xver,
}
} }
static fromJson(json = []) { static fromJson(json = []) {
const fallbacks = []; return (json || []).map(f => new Inbound.TrojanSettings.Fallback(
for (let fallback of json) { f.name, f.alpn, f.path, f.dest, f.xver,
fallbacks.push(new Inbound.TrojanSettings.Fallback( ));
fallback.name,
fallback.alpn,
fallback.path,
fallback.dest,
fallback.xver,
))
}
return fallbacks;
} }
}; };
@ -3127,7 +3177,7 @@ Inbound.TunSettings = class extends Inbound.Settings {
constructor( constructor(
protocol, protocol,
name = 'xray0', name = 'xray0',
mtu = [1500, 1280], mtu = 1500,
gateway = [], gateway = [],
dns = [], dns = [],
userLevel = 0, userLevel = 0,
@ -3136,7 +3186,7 @@ Inbound.TunSettings = class extends Inbound.Settings {
) { ) {
super(protocol); super(protocol);
this.name = name; this.name = name;
this.mtu = this._normalizeMtu(mtu); this.mtu = Number(mtu) || 1500;
this.gateway = Array.isArray(gateway) ? gateway : []; this.gateway = Array.isArray(gateway) ? gateway : [];
this.dns = Array.isArray(dns) ? dns : []; this.dns = Array.isArray(dns) ? dns : [];
this.userLevel = userLevel; this.userLevel = userLevel;
@ -3144,26 +3194,13 @@ Inbound.TunSettings = class extends Inbound.Settings {
this.autoOutboundsInterface = autoOutboundsInterface; 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 = {}) { static fromJson(json = {}) {
const rawMtu = json.mtu ?? json.MTU;
const mtu = Array.isArray(rawMtu) ? rawMtu[0] : rawMtu;
return new Inbound.TunSettings( return new Inbound.TunSettings(
Protocols.TUN, Protocols.TUN,
json.name ?? 'xray0', json.name ?? 'xray0',
json.mtu ?? json.MTU ?? [1500, 1280], mtu ?? 1500,
json.gateway ?? json.Gateway ?? [], json.gateway ?? json.Gateway ?? [],
json.dns ?? json.DNS ?? [], json.dns ?? json.DNS ?? [],
json.userLevel ?? 0, json.userLevel ?? 0,
@ -3175,7 +3212,7 @@ Inbound.TunSettings = class extends Inbound.Settings {
toJson() { toJson() {
return { return {
name: this.name || 'xray0', name: this.name || 'xray0',
mtu: this._normalizeMtu(this.mtu), mtu: Number(this.mtu) || 1500,
gateway: this.gateway, gateway: this.gateway,
dns: this.dns, dns: this.dns,
userLevel: this.userLevel || 0, 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 { class xHTTPStreamSettings extends CommonClass {
constructor( constructor(
// Bidirectional — must match the inbound side
path = '/', path = '/',
host = '', host = '',
mode = '', 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, noGRPCHeader = false,
scMinPostsIntervalMs = "30", scMinPostsIntervalMs = "30",
xmux = { xmux = {
@ -417,32 +441,112 @@ class xHTTPStreamSettings extends CommonClass {
hMaxReusableSecs: "1800-3000", hMaxReusableSecs: "1800-3000",
hKeepAlivePeriod: 0, 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(); super();
this.path = path; this.path = path;
this.host = host; this.host = host;
this.mode = mode; 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.noGRPCHeader = noGRPCHeader;
this.scMinPostsIntervalMs = scMinPostsIntervalMs; this.scMinPostsIntervalMs = scMinPostsIntervalMs;
this.xmux = xmux; 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 = {}) { 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( return new xHTTPStreamSettings(
json.path, json.path,
json.host, json.host,
json.mode, 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.noGRPCHeader,
json.scMinPostsIntervalMs, 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() { 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 { return {
path: this.path, path: this.path,
host: this.host, host: this.host,
mode: this.mode, 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, noGRPCHeader: this.noGRPCHeader,
scMinPostsIntervalMs: this.scMinPostsIntervalMs, scMinPostsIntervalMs: this.scMinPostsIntervalMs,
xmux: { xmux: {
@ -1139,11 +1243,11 @@ class Outbound extends CommonClass {
return false; 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() { canEnableVisionSeed() {
if (!this.canEnableTlsFlow()) return false; if (!this.canEnableTlsFlow()) return false;
const flow = this.settings?.flow; return this.settings?.flow === TLS_FLOW_CONTROL.VISION;
return flow === TLS_FLOW_CONTROL.VISION || flow === TLS_FLOW_CONTROL.VISION_UDP443;
} }
canEnableReality() { canEnableReality() {
@ -1799,7 +1903,7 @@ Outbound.VmessSettings = class extends CommonClass {
} }
}; };
Outbound.VLESSSettings = 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(); super();
this.address = address; this.address = address;
this.port = port; this.port = port;
@ -1814,6 +1918,12 @@ Outbound.VLESSSettings = class extends CommonClass {
static fromJson(json = {}) { static fromJson(json = {}) {
if (ObjectUtil.isEmpty(json.address) || ObjectUtil.isEmpty(json.port)) return new Outbound.VLESSSettings(); 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( return new Outbound.VLESSSettings(
json.address, json.address,
json.port, json.port,
@ -1823,7 +1933,7 @@ Outbound.VLESSSettings = class extends CommonClass {
json.reverse?.tag || '', json.reverse?.tag || '',
ReverseSniffing.fromJson(json.reverse?.sniffing || {}), ReverseSniffing.fromJson(json.reverse?.sniffing || {}),
json.testpre || 0, 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, sniffing: JSON.stringify(reverseSniffing) === JSON.stringify(defaultReverseSniffing) ? {} : reverseSniffing,
}; };
} }
// Only include Vision settings when flow is set // Vision-specific knobs are only meaningful for the exact xtls-rprx-vision flow.
if (this.flow && this.flow !== '') { if (this.flow === TLS_FLOW_CONTROL.VISION) {
if (this.testpre > 0) { if (this.testpre > 0) {
result.testpre = this.testpre; 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; result.testseed = this.testseed;
} }
} }

View file

@ -135,8 +135,9 @@
return enabledOk && expiryOk && trafficOk; return enabledOk && expiryOk && trafficOk;
}, },
shadowrocketUrl() { shadowrocketUrl() {
const rawUrl = this.app.subUrl + '?flag=shadowrocket'; const separator = this.app.subUrl.includes('?') ? '&' : '?';
const base64Url = btoa(rawUrl); const rawUrl = this.app.subUrl + separator + 'flag=shadowrocket';
const base64Url = encodeURIComponent(btoa(rawUrl));
const remark = encodeURIComponent(this.app.sId || 'Subscription'); const remark = encodeURIComponent(this.app.sId || 'Subscription');
return `shadowrocket://add/sub/${base64Url}?remark=${remark}`; 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,18 +566,131 @@
<a-form-item label='{{ i18n "path" }}'> <a-form-item label='{{ i18n "path" }}'>
<a-input v-model.trim="outbound.stream.xhttp.path"></a-input> <a-input v-model.trim="outbound.stream.xhttp.path"></a-input>
</a-form-item> </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-form-item label="Mode">
<a-select v-model="outbound.stream.xhttp.mode" :dropdown-class-name="themeSwitcher.currentTheme"> <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-option v-for="key in MODE_OPTION" :value="key">[[ key ]]</a-select-option>
</a-select> </a-select>
</a-form-item> </a-form-item>
<a-form-item label="No gRPC Header" <a-form-item label="Max Upload Size (Byte)" v-if="outbound.stream.xhttp.mode === 'packet-up'">
v-if="outbound.stream.xhttp.mode === 'stream-up' || outbound.stream.xhttp.mode === 'stream-one'"> <a-input v-model.trim="outbound.stream.xhttp.scMaxEachPostBytes"></a-input>
<a-switch v-model="outbound.stream.xhttp.noGRPCHeader"></a-switch>
</a-form-item> </a-form-item>
<a-form-item label="Min Upload Interval (Ms)" v-if="outbound.stream.xhttp.mode === 'packet-up'"> <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-input v-model.trim="outbound.stream.xhttp.scMinPostsIntervalMs"></a-input>
</a-form-item> </a-form-item>
<a-form-item label="Padding Bytes">
<a-input v-model.trim="outbound.stream.xhttp.xPaddingBytes"></a-input>
</a-form-item>
<a-form-item label="Padding Obfs Mode">
<a-switch v-model="outbound.stream.xhttp.xPaddingObfsMode"></a-switch>
</a-form-item>
<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="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="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="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-form-item label="Max Concurrency" v-if="!outbound.stream.xhttp.xmux.maxConnections">
<a-input v-model="outbound.stream.xhttp.xmux.maxConcurrency"></a-input> <a-input v-model="outbound.stream.xhttp.xmux.maxConcurrency"></a-input>
</a-form-item> </a-form-item>
@ -597,6 +710,7 @@
<a-input-number v-model.number="outbound.stream.xhttp.xmux.hKeepAlivePeriod"></a-input-number> <a-input-number v-model.number="outbound.stream.xhttp.xmux.hKeepAlivePeriod"></a-input-number>
</a-form-item> </a-form-item>
</template> </template>
</template>
<!-- hysteria --> <!-- hysteria -->
<template v-if="outbound.stream.network === 'hysteria'"> <template v-if="outbound.stream.network === 'hysteria'">

View file

@ -19,35 +19,7 @@
</a-collapse-panel> </a-collapse-panel>
</a-collapse> </a-collapse>
<template v-if=" inbound.isTcp"> <template v-if=" inbound.isTcp">
<a-form :colon="false" :label-col="{ md: {span:8} }" :wrapper-col="{ md: {span:14} }"> {{template "form/fallbacks" .}}
<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>
<a-divider style="margin:5px 0;"></a-divider> <a-divider style="margin:5px 0;"></a-divider>
</template> </template>
{{end}} {{end}}

View file

@ -18,14 +18,11 @@
<template slot="title"> <template slot="title">
<span>{{ i18n "pages.xray.tun.mtuDesc" }}</span> <span>{{ i18n "pages.xray.tun.mtuDesc" }}</span>
</template> </template>
MTU IPv4 MTU
<a-icon type="question-circle"></a-icon> <a-icon type="question-circle"></a-icon>
</a-tooltip> </a-tooltip>
</template> </template>
<a-input-number v-model.number="inbound.settings.mtu[0]" :min="1" :max="9000" placeholder="1500"></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="MTU IPv6">
<a-input-number v-model.number="inbound.settings.mtu[1]" :min="1" :max="9000" placeholder="1280"></a-input-number>
</a-form-item> </a-form-item>
<a-form-item label="Gateway"> <a-form-item label="Gateway">
<a-select mode="tags" v-model="inbound.settings.gateway" :style="{ width: '100%' }" :token-separators="[',']" <a-select mode="tags" v-model="inbound.settings.gateway" :style="{ width: '100%' }" :token-separators="[',']"

View file

@ -21,16 +21,6 @@
</a-collapse> </a-collapse>
<template v-if=" !inbound.stream.isTLS || !inbound.stream.isReality"> <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 :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-form-item label="decryption">
<a-input v-model.trim="inbound.settings.decryption"></a-input> <a-input v-model.trim="inbound.settings.decryption"></a-input>
</a-form-item> </a-form-item>
@ -38,73 +28,57 @@
<a-input v-model="inbound.settings.encryption"></a-input> <a-input v-model="inbound.settings.encryption"></a-input>
</a-form-item> </a-form-item>
<a-form-item label=" "> <a-form-item label=" ">
<a-space> <a-space :size="8" wrap>
<a-button type="primary" icon="import" @click="getNewVlessEnc">Get New <a-button type="primary" icon="import" @click="getNewVlessEnc('X25519, not Post-Quantum')">
keys</a-button> 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-button danger @click="clearVlessEnc">Clear</a-button>
</a-space> </a-space>
</a-form-item> </a-form-item>
</a-form> </a-form>
<a-divider :style="{ margin: '5px 0' }"></a-divider> <a-divider :style="{ margin: '5px 0' }"></a-divider>
</template> </template>
<template v-if="inbound.isTcp && !inbound.settings.selectedAuth"> <template v-if="inbound.isTcp && (!inbound.settings.encryption || inbound.settings.encryption === 'none')">
<a-form :colon="false" :label-col="{ md: {span:8} }" :wrapper-col="{ md: {span:14} }"> {{template "form/fallbacks" .}}
<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>
<a-divider :style="{ margin: '5px 0' }"></a-divider> <a-divider :style="{ margin: '5px 0' }"></a-divider>
</template> </template>
<template v-if="inbound.canEnableVisionSeed()"> <template v-if="inbound.canEnableVisionSeed()">
<a-form :colon="false" :label-col="{ md: {span:8} }" :wrapper-col="{ md: {span:14} }"> <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-row :gutter="8">
<a-col :span="6"> <a-col :span="6">
<a-input-number <a-input-number
:value="(inbound.settings.testseed && inbound.settings.testseed[0] !== undefined) ? inbound.settings.testseed[0] : 900" :value="(inbound.settings.testseed && inbound.settings.testseed[0] !== undefined) ? inbound.settings.testseed[0] : null"
@change="(val) => updateTestseed(0, val)" :min="0" :max="9999" :style="{ width: '100%' }" placeholder="900" @change="(val) => updateTestseed(0, val)" :min="1" :max="9999" :style="{ width: '100%' }" placeholder="900"
addon-before="[0]"></a-input-number> addon-before="[0]"></a-input-number>
</a-col> </a-col>
<a-col :span="6"> <a-col :span="6">
<a-input-number <a-input-number
:value="(inbound.settings.testseed && inbound.settings.testseed[1] !== undefined) ? inbound.settings.testseed[1] : 500" :value="(inbound.settings.testseed && inbound.settings.testseed[1] !== undefined) ? inbound.settings.testseed[1] : null"
@change="(val) => updateTestseed(1, val)" :min="0" :max="9999" :style="{ width: '100%' }" placeholder="500" @change="(val) => updateTestseed(1, val)" :min="1" :max="9999" :style="{ width: '100%' }" placeholder="500"
addon-before="[1]"></a-input-number> addon-before="[1]"></a-input-number>
</a-col> </a-col>
<a-col :span="6"> <a-col :span="6">
<a-input-number <a-input-number
:value="(inbound.settings.testseed && inbound.settings.testseed[2] !== undefined) ? inbound.settings.testseed[2] : 900" :value="(inbound.settings.testseed && inbound.settings.testseed[2] !== undefined) ? inbound.settings.testseed[2] : null"
@change="(val) => updateTestseed(2, val)" :min="0" :max="9999" :style="{ width: '100%' }" placeholder="900" @change="(val) => updateTestseed(2, val)" :min="1" :max="9999" :style="{ width: '100%' }" placeholder="900"
addon-before="[2]"></a-input-number> addon-before="[2]"></a-input-number>
</a-col> </a-col>
<a-col :span="6"> <a-col :span="6">
<a-input-number <a-input-number
:value="(inbound.settings.testseed && inbound.settings.testseed[3] !== undefined) ? inbound.settings.testseed[3] : 256" :value="(inbound.settings.testseed && inbound.settings.testseed[3] !== undefined) ? inbound.settings.testseed[3] : null"
@change="(val) => updateTestseed(3, val)" :min="0" :max="9999" :style="{ width: '100%' }" placeholder="256" @change="(val) => updateTestseed(3, val)" :min="1" :max="9999" :style="{ width: '100%' }" placeholder="256"
addon-before="[3]"></a-input-number> addon-before="[3]"></a-input-number>
</a-col> </a-col>
</a-row> </a-row>
@ -116,6 +90,10 @@
Reset Reset
</a-button> </a-button>
</a-space> </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-item>
</a-form> </a-form>
<a-divider :style="{ margin: '5px 0' }"></a-divider> <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-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-input v-model.trim="inbound.stream.xhttp.scStreamUpServerSecs"></a-input>
</a-form-item> </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-form-item label="Padding Bytes">
<a-input v-model.trim="inbound.stream.xhttp.xPaddingBytes"></a-input> <a-input v-model.trim="inbound.stream.xhttp.xPaddingBytes"></a-input>
</a-form-item> </a-form-item>
@ -67,14 +71,6 @@
</a-select> </a-select>
</a-form-item> </a-form-item>
</template> </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-form-item label="Session Placement">
<a-select v-model="inbound.stream.xhttp.sessionPlacement" :dropdown-class-name="themeSwitcher.currentTheme"> <a-select v-model="inbound.stream.xhttp.sessionPlacement" :dropdown-class-name="themeSwitcher.currentTheme">
<a-select-option value>Default (path)</a-select-option> <a-select-option value>Default (path)</a-select-option>
@ -114,11 +110,6 @@
v-if="inbound.stream.xhttp.mode === 'packet-up' && inbound.stream.xhttp.uplinkDataPlacement && inbound.stream.xhttp.uplinkDataPlacement !== 'body'"> 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-input v-model.trim="inbound.stream.xhttp.uplinkDataKey" placeholder="x_data"></a-input>
</a-form-item> </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-form-item label="No SSE Header">
<a-switch v-model="inbound.stream.xhttp.noSSEHeader"></a-switch> <a-switch v-model="inbound.stream.xhttp.noSSEHeader"></a-switch>
</a-form-item> </a-form-item>

View file

@ -1407,14 +1407,34 @@
class: themeSwitcher.currentTheme, class: themeSwitcher.currentTheme,
cancelText: '{{ i18n "cancel"}}', cancelText: '{{ i18n "cancel"}}',
onOk: async () => { onOk: async () => {
const targetVersion = panelUpdateModal.info.latestVersion || '';
const baseTip = '{{ i18n "pages.index.dontRefresh"}}';
const tipPrefix = targetVersion ? `${baseTip} (${targetVersion})` : baseTip;
panelUpdateModal.hide(); panelUpdateModal.hide();
this.loading(true, '{{ i18n "pages.index.dontRefresh"}}'); this.loading(true, tipPrefix);
const msg = await HttpUtil.post('/panel/api/server/updatePanel'); const msg = await HttpUtil.post('/panel/api/server/updatePanel');
if (!msg.success) { if (!msg.success) {
this.loading(false); this.loading(false);
return; 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(); window.location.reload();
}, },
}); });

View file

@ -90,14 +90,9 @@
<a-tag :color="inbound.stream.security == 'none' ? 'red' : 'green'">[[ <a-tag :color="inbound.stream.security == 'none' ? 'red' : 'green'">[[
inbound.stream.security ]]</a-tag> inbound.stream.security ]]</a-tag>
<br /> <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" }} {{ 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 : '' inbound.settings.encryption ? inbound.settings.encryption : ''
]]</a-tag> ]]</a-tag>
<a-tooltip title='{{ i18n "copy" }}'> <a-tooltip title='{{ i18n "copy" }}'>

View file

@ -16,6 +16,16 @@
inbound: new Inbound(), inbound: new Inbound(),
dbInbound: new DBInbound(), dbInbound: new DBInbound(),
ok() { 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); ObjectUtil.execute(inModal.confirm, inModal.inbound, inModal.dbInbound);
}, },
show({ show({
@ -33,16 +43,12 @@
} else { } else {
this.inbound = new Inbound(); this.inbound = new Inbound();
} }
// Always ensure testseed is initialized for VLESS protocol (even if vision flow is not set yet) // Ensure VLESS settings has a testseed array reference for Vue reactivity,
// This ensures Vue reactivity works properly // 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.protocol === Protocols.VLESS && this.inbound.settings) {
if ( if (!Array.isArray(this.inbound.settings.testseed)) {
!this.inbound.settings.testseed || 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 (dbInbound) { if (dbInbound) {
@ -61,48 +67,50 @@
loading(loading = true) { loading(loading = true) {
inModal.confirmLoading = loading; 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) { updateTestseed(index, value) {
// Use inModal.inbound explicitly to ensure correct context
if (!inModal.inbound || !inModal.inbound.settings) return; if (!inModal.inbound || !inModal.inbound.settings) return;
// Ensure testseed is initialized if (!Array.isArray(inModal.inbound.settings.testseed)) {
if ( inModal.inbound.settings.testseed = [];
!inModal.inbound.settings.testseed ||
!Array.isArray(inModal.inbound.settings.testseed)
) {
inModal.inbound.settings.testseed = [900, 500, 900, 256];
} }
// Ensure array has enough elements const seed = inModal.inbound.settings.testseed;
while (inModal.inbound.settings.testseed.length <= index) { while (seed.length <= index) seed.push(null);
inModal.inbound.settings.testseed.push(0); 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() { setRandomTestseed() {
// Use inModal.inbound explicitly to ensure correct context
if (!inModal.inbound || !inModal.inbound.settings) return; if (!inModal.inbound || !inModal.inbound.settings) return;
// Ensure testseed is initialized // Positive integers only (>=1) so the array passes validation and gets emitted.
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
inModal.inbound.settings.testseed = [ inModal.inbound.settings.testseed = [
Math.floor(Math.random() * 1000), Math.floor(Math.random() * 999) + 1,
Math.floor(Math.random() * 1000), Math.floor(Math.random() * 999) + 1,
Math.floor(Math.random() * 1000), Math.floor(Math.random() * 999) + 1,
Math.floor(Math.random() * 1000), Math.floor(Math.random() * 999) + 1,
]; ];
}, },
resetTestseed() { resetTestseed() {
// Use inModal.inbound explicitly to ensure correct context
if (!inModal.inbound || !inModal.inbound.settings) return; if (!inModal.inbound || !inModal.inbound.settings) return;
// Reset testseed to default values // Empty == "use server defaults [900, 500, 900, 256]"; placeholders show in the form.
inModal.inbound.settings.testseed = [900, 500, 900, 256].slice(); 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": { "inModal.inbound.settings.vlesses": {
handler() { handler() {
if ( if (
inModal.inbound.protocol === Protocols.VLESS && inModal.inbound.protocol === Protocols.VLESS &&
inModal.inbound.settings && inModal.inbound.settings &&
inModal.inbound.settings.vlesses !Array.isArray(inModal.inbound.settings.testseed)
) { ) {
const hasVisionFlow = inModal.inbound.settings.vlesses.some( inModal.inbound.settings.testseed = [];
(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];
}
} }
}, },
deep: true, deep: true,
@ -304,12 +302,11 @@
this.inbound.stream.tls.echServerKeys = ""; this.inbound.stream.tls.echServerKeys = "";
this.inbound.stream.tls.settings.echConfigList = ""; this.inbound.stream.tls.settings.echConfigList = "";
}, },
async getNewVlessEnc() { // Pulls the requested auth block from `xray vlessenc` (which always returns
const selected = inModal.inbound.settings.selectedAuth; // both X25519 and ML-KEM-768 variants) and applies it to the inbound's
if (!selected) { // decryption/encryption strings. The auth mode is implied by the resulting
this.clearVlessEnc(); async getNewVlessEnc(authLabel) {
return; if (!authLabel) return;
}
inModal.loading(true); inModal.loading(true);
const msg = await HttpUtil.get("/panel/api/server/getNewVlessEnc"); const msg = await HttpUtil.get("/panel/api/server/getNewVlessEnc");
@ -320,10 +317,10 @@
} }
const auths = msg.obj.auths || []; const auths = msg.obj.auths || [];
const block = auths.find((a) => a.label === selected); const block = auths.find((a) => a.label === authLabel);
if (!block) { if (!block) {
console.error("No auth block for", selected); console.error("No auth block for", authLabel);
return; return;
} }
@ -333,37 +330,37 @@
clearVlessEnc() { clearVlessEnc() {
this.inbound.settings.decryption = "none"; this.inbound.settings.decryption = "none";
this.inbound.settings.encryption = "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) { updateTestseed(index, value) {
// Ensure testseed is initialized if (!Array.isArray(this.inbound.settings.testseed)) {
if ( this.$set(this.inbound.settings, "testseed", []);
!this.inbound.settings.testseed ||
!Array.isArray(this.inbound.settings.testseed)
) {
this.$set(this.inbound.settings, "testseed", [900, 500, 900, 256]);
} }
// Ensure array has enough elements const seed = this.inbound.settings.testseed;
while (this.inbound.settings.testseed.length <= index) { while (seed.length <= index) seed.push(null);
this.inbound.settings.testseed.push(0); 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() { 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 = [ const newSeed = [
Math.floor(Math.random() * 1000), Math.floor(Math.random() * 999) + 1,
Math.floor(Math.random() * 1000), Math.floor(Math.random() * 999) + 1,
Math.floor(Math.random() * 1000), Math.floor(Math.random() * 999) + 1,
Math.floor(Math.random() * 1000), Math.floor(Math.random() * 999) + 1,
]; ];
this.$set(this.inbound.settings, "testseed", newSeed); this.$set(this.inbound.settings, "testseed", newSeed);
}, },
resetTestseed() { resetTestseed() {
// Reset testseed to default values using Vue.set for reactivity // Empty == "use server defaults [900, 500, 900, 256]". Placeholders will show in the form.
this.$set(this.inbound.settings, "testseed", [900, 500, 900, 256]); this.$set(this.inbound.settings, "testseed", []);
},
testseedError() {
return inModal.testseedError();
}, },
}, },
}); });

View file

@ -104,6 +104,17 @@
return outModal.outbound; 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: { methods: {
streamNetworkChange() { streamNetworkChange() {
if (this.outModal.outbound.protocol == Protocols.VLESS && !outModal.outbound if (this.outModal.outbound.protocol == Protocols.VLESS && !outModal.outbound

View file

@ -403,16 +403,6 @@ func (j *CheckClientIpJob) updateInboundClientIps(inboundClientIps *model.Inboun
shouldCleanLog := false shouldCleanLog := false
j.disAllowedIps = []string{} 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. // historical db-only ips are excluded from this count on purpose.
var keptLive []IPWithTimestamp var keptLive []IPWithTimestamp
if len(liveIps) > limitIp { if len(liveIps) > limitIp {
@ -422,13 +412,25 @@ func (j *CheckClientIpJob) updateInboundClientIps(inboundClientIps *model.Inboun
keptLive = liveIps[:limitIp] keptLive = liveIps[:limitIp]
bannedLive := 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 // log format is load-bearing: x-ui.sh create_iplimit_jails builds
// filter.d/3x-ipl.conf with // 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+ // 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. // don't change the wording.
for _, ipTime := range bannedLive { for _, ipTime := range bannedLive {
j.disAllowedIps = append(j.disAllowedIps, ipTime.IP) 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 // 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] settingsClients[clientIndex] = interfaceClients[0]
oldSettings["clients"] = settingsClients 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, "", " ") newSettings, err := json.MarshalIndent(oldSettings, "", " ")
if err != nil { if err != nil {
return false, err return false, err
@ -2885,6 +2907,7 @@ func (s *InboundService) MigrationRequirements() {
if ok { if ok {
// Fix Client configuration problems // Fix Client configuration problems
var newClients []any var newClients []any
hasVisionFlow := false
for client_index := range clients { for client_index := range clients {
c := clients[client_index].(map[string]any) c := clients[client_index].(map[string]any)
@ -2910,6 +2933,9 @@ func (s *InboundService) MigrationRequirements() {
c["flow"] = "" c["flow"] = ""
} }
} }
if flow, _ := c["flow"].(string); flow == "xtls-rprx-vision" {
hasVisionFlow = true
}
// Backfill created_at and updated_at // Backfill created_at and updated_at
if _, ok := c["created_at"]; !ok { if _, ok := c["created_at"]; !ok {
c["created_at"] = time.Now().Unix() * 1000 c["created_at"] = time.Now().Unix() * 1000
@ -2918,6 +2944,15 @@ func (s *InboundService) MigrationRequirements() {
newClients = append(newClients, any(c)) newClients = append(newClients, any(c))
} }
settings["clients"] = newClients 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, "", " ") modifiedSettings, err := json.MarshalIndent(settings, "", " ")
if err != nil { if err != nil {
return return

View file

@ -2034,14 +2034,14 @@ backend=auto
filter=3x-ipl filter=3x-ipl
action=3x-ipl action=3x-ipl
logpath=${iplimit_log_path} logpath=${iplimit_log_path}
maxretry=2 maxretry=1
findtime=32 findtime=32
bantime=${bantime}m bantime=${bantime}m
EOF EOF
cat << EOF > /etc/fail2ban/filter.d/3x-ipl.conf cat << EOF > /etc/fail2ban/filter.d/3x-ipl.conf
[Definition] [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+ 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 = ignoreregex =
EOF EOF
@ -2062,10 +2062,10 @@ actionstop = <iptables> -D <chain> -p <protocol> -j f2b-<name>
actioncheck = <iptables> -n -L <chain> | grep -q 'f2b-<name>[ \t]' actioncheck = <iptables> -n -L <chain> | grep -q 'f2b-<name>[ \t]'
actionban = <iptables> -I f2b-<name> 1 -s <ip> -j <blocktype> 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> 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] [Init]
name = default name = default