Compare commits

...

25 Commits

Author SHA1 Message Date
MHSanaei
0b5c239f98 v2.9.3 2026-04-27 15:31:32 +02:00
MHSanaei
03393c9f52 Minor changes 2026-04-27 15:02:43 +02:00
MHSanaei
b56db67759 fix: handle Init error in GetXrayTraffic to prevent nil pointer panic
#3969
2026-04-27 14:11:28 +02:00
MHSanaei
6d05702d00 TCP Masks 2026-04-27 03:06:41 +02:00
MHSanaei
9791b05a4e kcp: noise, header-custom, sudoku 2026-04-27 01:28:06 +02:00
MHSanaei
0aca2d3b3d sub: kcp finalmask 2026-04-26 23:04:47 +02:00
MHSanaei
8529f4f0cf kcp: mtu and tti
Add KCP-specific fields mtu and tti to inbound stream handling in web/assets/js/model/inbound.js. The changes add obj.mtu/obj.tti when serializing the kcp stream and set params for mtu and tti in the various KCP parameter-building branches so these values are preserved and transmitted where KCP is used.
2026-04-26 21:32:50 +02:00
MHSanaei
abc5cf3439 Increase KCP maxSendingWindow to 2MiB 2026-04-26 20:49:02 +02:00
MHSanaei
a7e7788e29 Bump Xray release to v26.4.25 2026-04-26 20:45:00 +02:00
MHSanaei
8620344925 Replace with-block with explicit settings 2026-04-26 20:37:03 +02:00
MHSanaei
47e229e323 Default to dark theme when unset 2026-04-26 20:16:27 +02:00
MHSanaei
4521beab7c wireguard: link 2026-04-26 20:06:24 +02:00
MHSanaei
a62c637632 DNS outbound: Add rules 2026-04-26 17:34:31 +02:00
dependabot[bot]
35609b7b13 Bump github.com/Azure/go-ntlmssp (#4094)
Bumps the go_modules group with 1 update in the / directory: [github.com/Azure/go-ntlmssp](https://github.com/Azure/go-ntlmssp).


Updates `github.com/Azure/go-ntlmssp` from 0.1.0 to 0.1.1
- [Release notes](https://github.com/Azure/go-ntlmssp/releases)
- [Commits](https://github.com/Azure/go-ntlmssp/compare/v0.1.0...v0.1.1)

---
updated-dependencies:
- dependency-name: github.com/Azure/go-ntlmssp
  dependency-version: 0.1.1
  dependency-type: indirect
  dependency-group: go_modules
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-24 10:41:11 +02:00
pwnnex
a4b1b3d06d Merge pull request #4092 from pwnnex/fix/iplimit-live-only-slot-count
iplimit: dont count idle db-only ips toward the per-client limit (#4091)
2026-04-23 21:36:37 +03:00
pwnnex
5f7c7c5f3d iplimit: dont count idle db-only ips toward the per-client limit
after #4083 the staleness window is 30 minutes, which still lets an ip
that stopped connecting a few minutes ago sit in the db blob and keep
the protected slot on the ascending sort. the ip that is actually
connecting right now gets classified as excess and sent to fail2ban,
and never lands in inbound_client_ips.ips so the panel doesnt show it
until you clear the log by hand.

only count ips observed in the current scan toward the limit. db-only
entries stay in the blob for display but dont participate in the ban
decision. live subset still uses the "protect oldest, ban newcomer"
rule.

closes #4091. followup to #4077.
2026-04-23 21:11:45 +03:00
Rs.Nest
6bcaf61c44 Feature: Copy clients between inbounds (#4087)
* feat: copy clients between inbounds

* fix: copy clients modal not opening

* fix: copy clients modal not opening

* fix: copy clients modal not opening

* fix: copy clients modal not opening

* fix: copy clients modal not opening

* fix: copy clients modal not opening

* fix: copy clients modal not opening

* fix: copy clients modal not opening

* fix: copy clients modal not opening

* revert: undo install.sh/deploy.sh changes; i18n: add copy-clients translations for all languages

---------

Co-authored-by: Нестеров Руслан <r.nesterov@comagic.dev>
2026-04-23 15:19:07 +02:00
MHSanaei
ff25072690 SS: remove unsupported cipher method 2026-04-22 21:44:39 +02:00
pwnnex
530c1597b8 Merge pull request #4086 from pwnnex/fix/hysteria2-protocol-aliases
hysteria: accept "hysteria2" as a protocol string (#4081)
2026-04-22 16:02:05 +00:00
pwnnex
c8e16d8c41 Merge pull request #4085 from pwnnex/fix/iplimit-install-nftables
x-ui.sh: bundle nftables when installing fail2ban
2026-04-22 15:58:00 +00:00
pwnnex
17f67ef3a5 sub: dont panic on bad externalProxy entry in genHysteriaLink
The externalProxy fanout from #4073 did `int(ep["port"].(float64))`
with no ok-check. If any entry is missing port or has the wrong
type it panics, and since this runs in the /sub/<id> handler the
whole subscription returns 500. Skip malformed entries instead.
2026-04-22 18:55:27 +03:00
pwnnex
eb4791a1cd hysteria: also accept "hysteria2" protocol string
UI stores v1 and v2 both as "hysteria" with settings.version, but
inbounds that came in from imports / manual SQL can carry the
literal "hysteria2" string and get silently dropped everywhere we
switch on protocol.

Add Hysteria2 constant + IsHysteria helper, use it in the places
that gate on protocol (sub SQL, getLink, genHysteriaLink, clash
buildProxy, json gen, inbound.go validation, xray AddUser).

Existing "hysteria" inbounds are untouched.

closes #4081
2026-04-22 18:55:09 +03:00
pwnnex
71ac920436 x-ui.sh: install nftables alongside fail2ban in install_iplimit
On fresh Debian 12+, Ubuntu 24+ and recent RHEL-family minimal images
the fail2ban package ships with `banaction = nftables-multiport` as
the default in /etc/fail2ban/jail.conf but does not pull in the
`nftables` package as a dependency. The first SSH brute-force attempt
hits the default sshd jail and fail2ban logs

    stderr: /bin/sh: 1: nft: not found
    returned 127 -- HINT on 127: "Command not found"

repeatedly, which users mistake for a 3x-ui regression (see the
discussion on #4083). The 3x-ipl jail itself is unaffected — it uses
an iptables-based action configured in create_iplimit_jails — so this
is only stray noise, but noisy enough to look like a real failure on
first install.

Add `nftables` to the package list in every branch of install_iplimit
so new installs end up with a working default sshd jail out of the
box. Existing installs where `nftables` is already present are a
no-op.
2026-04-22 18:50:42 +03:00
pwnnex
e6d0c33937 Merge pull request #4083 from pwnnex/fix/iplimit-stale-db-evict
Fix IP Limit continuous ban loop after a legitimate ban expires (#4077)
2026-04-22 14:09:55 +00:00
pwnnex
eef2d311f4 Fix IP Limit continuous ban loop from stale DB entries (#4077)
After 60abeaa flipped the excess-IP selector to "oldest wins,
newest loses" (to protect the original/current connections), the
per-client IP table in `inbound_client_ips.ips` never evicted IPs
that stopped connecting. Their stored timestamp stayed ancient, so
on every subsequent run they counted as the "oldest protected"
slot(s) and whichever IP was actually using the config now was
classified as "new excess" and re-banned via fail2ban.

This is exactly the #4077 scenario: two IPs connect once and get
recorded, the ban lifts after the configured duration, the lone
legitimate IP that reconnects gets banned again, and again, and
again — a permanent 3xipl.log loop with no real abuser anywhere.

Fix: when merging the persisted `old` list with the freshly
observed `new` log lines, drop entries whose last-seen timestamp
is older than `ipStaleAfterSeconds` (30 minutes). A client that's
actually still active refreshes its timestamp any time xray emits
a new `accepted` line for a fresh TCP, so the cutoff is far above
even idle streaming sessions; a client that's genuinely gone falls
out of the table in bounded time and frees its slot.

Extracted the merge into `mergeClientIps` so it can be exercised
by unit tests without spinning up the full DB-backed job.

Tests cover:
- stale old entry is dropped (the #4077 regression)
- fresh old entries are still carried forward (access-log rotation
  is still backed by the persisted table)
- newer timestamp wins when the same IP appears in both lists
- a clock-skewed old `new` entry can't resurrect a stale IP
- a zero cutoff never over-evicts

Closes #4077
2026-04-22 16:53:32 +03:00
43 changed files with 3478 additions and 1041 deletions

View File

@@ -126,7 +126,7 @@ jobs:
cd x-ui/bin
# Download dependencies
Xray_URL="https://github.com/XTLS/Xray-core/releases/download/v26.4.17/"
Xray_URL="https://github.com/XTLS/Xray-core/releases/download/v26.4.25/"
if [ "${{ matrix.platform }}" == "amd64" ]; then
wget -q ${Xray_URL}Xray-linux-64.zip
unzip Xray-linux-64.zip
@@ -244,7 +244,7 @@ jobs:
cd x-ui\bin
# Download Xray for Windows
$Xray_URL = "https://github.com/XTLS/Xray-core/releases/download/v26.4.17/"
$Xray_URL = "https://github.com/XTLS/Xray-core/releases/download/v26.4.25/"
Invoke-WebRequest -Uri "${Xray_URL}Xray-windows-64.zip" -OutFile "Xray-windows-64.zip"
Expand-Archive -Path "Xray-windows-64.zip" -DestinationPath .
Remove-Item "Xray-windows-64.zip"

View File

@@ -27,7 +27,7 @@ case $1 in
esac
mkdir -p build/bin
cd build/bin
curl -sfLRO "https://github.com/XTLS/Xray-core/releases/download/v26.4.17/Xray-linux-${ARCH}.zip"
curl -sfLRO "https://github.com/XTLS/Xray-core/releases/download/v26.4.25/Xray-linux-${ARCH}.zip"
unzip "Xray-linux-${ARCH}.zip"
rm -f "Xray-linux-${ARCH}.zip" geoip.dat geosite.dat
mv xray "xray-linux-${FNAME}"

View File

@@ -1 +1 @@
2.9.2
2.9.3

View File

@@ -21,9 +21,21 @@ const (
Shadowsocks Protocol = "shadowsocks"
Mixed Protocol = "mixed"
WireGuard Protocol = "wireguard"
Hysteria Protocol = "hysteria"
// UI stores Hysteria v1 and v2 both as "hysteria" and uses
// settings.version to discriminate. Imports from outside the panel
// can carry the literal "hysteria2" string, so IsHysteria below
// accepts both.
Hysteria Protocol = "hysteria"
Hysteria2 Protocol = "hysteria2"
)
// IsHysteria returns true for both "hysteria" and "hysteria2".
// Use instead of a bare ==model.Hysteria check: a v2 inbound stored
// with the literal v2 string would otherwise fall through (#4081).
func IsHysteria(p Protocol) bool {
return p == Hysteria || p == Hysteria2
}
// User represents a user account in the 3x-ui panel.
type User struct {
Id int `json:"id" gorm:"primaryKey;autoIncrement"`

View File

@@ -0,0 +1,22 @@
package model
import "testing"
func TestIsHysteria(t *testing.T) {
cases := []struct {
in Protocol
want bool
}{
{Hysteria, true},
{Hysteria2, true},
{VLESS, false},
{Shadowsocks, false},
{Protocol(""), false},
{Protocol("hysteria3"), false},
}
for _, c := range cases {
if got := IsHysteria(c.in); got != c.want {
t.Errorf("IsHysteria(%q) = %v, want %v", c.in, got, c.want)
}
}
}

6
go.mod
View File

@@ -32,7 +32,7 @@ require (
)
require (
github.com/Azure/go-ntlmssp v0.1.0 // indirect
github.com/Azure/go-ntlmssp v0.1.1 // indirect
github.com/andybalholm/brotli v1.2.1 // indirect
github.com/apernet/quic-go v0.59.1-0.20260217092621-db4786c77a22 // indirect
github.com/bytedance/gopkg v0.1.4 // indirect
@@ -72,7 +72,7 @@ require (
github.com/quic-go/quic-go v0.59.0 // indirect
github.com/refraction-networking/utls v1.8.3-0.20260301010127-aa6edf4b11af // indirect
github.com/rogpeppe/go-internal v1.14.1 // indirect
github.com/sagernet/sing v0.8.8 // indirect
github.com/sagernet/sing v0.8.9 // indirect
github.com/sagernet/sing-shadowsocks v0.2.9 // indirect
github.com/tklauser/go-sysconf v0.3.16 // indirect
github.com/tklauser/numcpus v0.11.0 // indirect
@@ -95,7 +95,7 @@ require (
golang.org/x/tools v0.44.0 // indirect
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect
golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260420184626-e10c466a9529 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gvisor.dev/gvisor v0.0.0-20260122175437-89a5d21be8f0 // indirect
lukechampine.com/blake3 v1.4.1 // indirect

12
go.sum
View File

@@ -1,5 +1,5 @@
github.com/Azure/go-ntlmssp v0.1.0 h1:DjFo6YtWzNqNvQdrwEyr/e4nhU3vRiwenz5QX7sFz+A=
github.com/Azure/go-ntlmssp v0.1.0/go.mod h1:NYqdhxd/8aAct/s4qSYZEerdPuH1liG2/X9DiVTbhpk=
github.com/Azure/go-ntlmssp v0.1.1 h1:l+FM/EEMb0U9QZE7mKNEDw5Mu3mFiaa2GKOoTSsNDPw=
github.com/Azure/go-ntlmssp v0.1.1/go.mod h1:NYqdhxd/8aAct/s4qSYZEerdPuH1liG2/X9DiVTbhpk=
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e h1:4dAU9FXIyQktpoUAgOJK3OTFc/xug0PCXYCqU0FgDKI=
@@ -156,8 +156,8 @@ github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/sagernet/sing v0.8.8 h1:1dRlGJ3wm4d2nwjKI1R/dr/7GKDKgUvXyD4OAWlQyt8=
github.com/sagernet/sing v0.8.8/go.mod h1:ARkL0gM13/Iv5VCZmci/NuoOlePoIsW0m7BWfln/Hak=
github.com/sagernet/sing v0.8.9 h1:iX8FyMrWNl/divVgTe7cLT9n36v6bfzfnCYlcM1cLaU=
github.com/sagernet/sing v0.8.9/go.mod h1:ARkL0gM13/Iv5VCZmci/NuoOlePoIsW0m7BWfln/Hak=
github.com/sagernet/sing-shadowsocks v0.2.9 h1:Paep5zCszRKsEn8587O0MnhFWKJwDW1Y4zOYYlIxMkM=
github.com/sagernet/sing-shadowsocks v0.2.9/go.mod h1:TE/Z6401Pi8tgr0nBZcM/xawAI6u3F6TTbz4nH/qw+8=
github.com/shirou/gopsutil/v4 v4.26.3 h1:2ESdQt90yU3oXF/CdOlRCJxrP+Am1aBYubTMTfxJ1qc=
@@ -256,8 +256,8 @@ golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb h1:whnFRlWMcXI9d+Z
golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb/go.mod h1:rpwXGsirqLqN2L0JDJQlwOboGHmptD5ZD6T2VmcqhTw=
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 h1:m8qni9SQFH0tJc1X0vmnpw/0t+AImlSvp30sEupozUg=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260420184626-e10c466a9529 h1:XF8+t6QQiS0o9ArVan/HW8Q7cycNPGsJf6GA2nXxYAg=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260420184626-e10c466a9529/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM=
google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=

20
main.go
View File

@@ -130,20 +130,22 @@ func runWebServer() {
}
// resetSetting resets all panel settings to their default values.
func resetSetting() {
func resetSetting() error {
err := database.InitDB(config.GetDBPath())
if err != nil {
fmt.Println("Failed to initialize database:", err)
return
return err
}
settingService := service.SettingService{}
err = settingService.ResetSettings()
if err != nil {
fmt.Println("Failed to reset settings:", err)
return err
} else {
fmt.Println("Settings successfully reset.")
}
return nil
}
// showSetting displays the current panel settings if show is true.
@@ -255,11 +257,11 @@ func updateTgbotSetting(tgBotToken string, tgBotChatid string, tgBotRuntime stri
}
// updateSetting updates various panel settings including port, credentials, base path, listen IP, and two-factor authentication.
func updateSetting(port int, username string, password string, webBasePath string, listenIP string, resetTwoFactor bool) {
func updateSetting(port int, username string, password string, webBasePath string, listenIP string, resetTwoFactor bool) error {
err := database.InitDB(config.GetDBPath())
if err != nil {
fmt.Println("Database initialization failed:", err)
return
return err
}
settingService := service.SettingService{}
@@ -311,6 +313,8 @@ func updateSetting(port int, username string, password string, webBasePath strin
fmt.Printf("listen %v set successfully", listenIP)
}
}
return nil
}
// updateCert updates the SSL certificate files for the panel.
@@ -481,9 +485,13 @@ func main() {
return
}
if reset {
resetSetting()
if err = resetSetting(); err != nil {
return
}
} else {
updateSetting(port, username, password, webBasePath, listenIP, resetTwoFactor)
if err = updateSetting(port, username, password, webBasePath, listenIP, resetTwoFactor); err != nil {
return
}
}
if show {
showSetting(show)

View File

@@ -159,13 +159,10 @@ func (s *SubClashService) getProxies(inbound *model.Inbound, client model.Client
}
func (s *SubClashService) buildProxy(inbound *model.Inbound, client model.Client, stream map[string]any, extraRemark string) map[string]any {
// Hysteria (v1 / v2) doesn't ride an xray `streamSettings.network`
// transport and the TLS story is handled inside hysteria itself, so
// applyTransport / applySecurity below don't model it. Build the
// proxy directly. Without this, hysteria inbounds fell into the
// `default: return nil` branch and silently vanished from the
// generated Clash config.
if inbound.Protocol == model.Hysteria {
// Hysteria has its own transport + TLS model, applyTransport /
// applySecurity don't fit. IsHysteria also covers the literal
// "hysteria2" protocol string (#4081).
if model.IsHysteria(inbound.Protocol) {
return s.buildHysteriaProxy(inbound, client, extraRemark)
}

View File

@@ -209,7 +209,7 @@ func (s *SubJsonService) getConfig(inbound *model.Inbound, client model.Client,
newOutbounds = append(newOutbounds, s.genVless(inbound, streamSettings, client))
case "trojan", "shadowsocks":
newOutbounds = append(newOutbounds, s.genServer(inbound, streamSettings, client))
case "hysteria":
case "hysteria", "hysteria2":
newOutbounds = append(newOutbounds, s.genHy(inbound, newStream, client))
}

File diff suppressed because it is too large Load Diff

View File

@@ -3,16 +3,15 @@ const Protocols = {
VLESS: 'vless',
TROJAN: 'trojan',
SHADOWSOCKS: 'shadowsocks',
TUNNEL: 'tunnel',
WIREGUARD: 'wireguard',
HYSTERIA: 'hysteria',
MIXED: 'mixed',
HTTP: 'http',
WIREGUARD: 'wireguard',
TUNNEL: 'tunnel',
TUN: 'tun',
HYSTERIA: 'hysteria',
};
const SSMethods = {
AES_256_GCM: 'aes-256-gcm',
CHACHA20_POLY1305: 'chacha20-poly1305',
CHACHA20_IETF_POLY1305: 'chacha20-ietf-poly1305',
XCHACHA20_IETF_POLY1305: 'xchacha20-ietf-poly1305',
@@ -324,7 +323,7 @@ class KcpStreamSettings extends XrayCommonClass {
uplinkCapacity = 5,
downlinkCapacity = 20,
cwndMultiplier = 1,
maxSendingWindow = 1350,
maxSendingWindow = 2097152,
) {
super();
this.mtu = mtu;
@@ -1086,11 +1085,15 @@ class UdpMask extends XrayCommonClass {
case 'header-wireguard':
return {};
case 'header-custom':
return { client: [], server: [] };
return {
client: Array.isArray(settings.client) ? settings.client : [],
server: Array.isArray(settings.server) ? settings.server : [],
};
case 'noise':
return { reset: 0, noise: [] };
case 'sudoku':
return { ascii: '', customTable: '', customTables: [], paddingMin: 0, paddingMax: 0 };
return {
reset: settings.reset ?? 0,
noise: Array.isArray(settings.noise) ? settings.noise : [],
};
default:
return settings;
}
@@ -1104,27 +1107,219 @@ class UdpMask extends XrayCommonClass {
}
toJson() {
const cleanItem = item => {
const out = { ...item };
if (out.type === 'array') {
delete out.packet;
} else {
delete out.rand;
delete out.randRange;
}
return out;
};
let settings = this.settings;
if (this.type === 'noise' && settings && Array.isArray(settings.noise)) {
settings = { ...settings, noise: settings.noise.map(cleanItem) };
} else if (this.type === 'header-custom' && settings) {
settings = {
...settings,
client: Array.isArray(settings.client) ? settings.client.map(cleanItem) : settings.client,
server: Array.isArray(settings.server) ? settings.server.map(cleanItem) : settings.server,
};
}
return {
type: this.type,
settings: (this.settings && Object.keys(this.settings).length > 0) ? this.settings : undefined
settings: (settings && Object.keys(settings).length > 0) ? settings : undefined
};
}
}
class FinalMaskStreamSettings extends XrayCommonClass {
constructor(udp = []) {
class TcpMask extends XrayCommonClass {
constructor(type = 'fragment', settings = {}) {
super();
this.udp = Array.isArray(udp) ? udp.map(u => new UdpMask(u.type, u.settings)) : [new UdpMask(udp.type, udp.settings)];
this.type = type;
this.settings = this._getDefaultSettings(type, settings);
}
_getDefaultSettings(type, settings = {}) {
switch (type) {
case 'fragment':
return {
packets: settings.packets ?? 'tlshello',
length: settings.length ?? '',
delay: settings.delay ?? '',
maxSplit: settings.maxSplit ?? '',
};
case 'sudoku':
return {
password: settings.password ?? '',
ascii: settings.ascii ?? '',
customTable: settings.customTable ?? '',
customTables: Array.isArray(settings.customTables) ? settings.customTables : [],
paddingMin: settings.paddingMin ?? 0,
paddingMax: settings.paddingMax ?? 0,
};
case 'header-custom':
return {
clients: Array.isArray(settings.clients) ? settings.clients : [],
servers: Array.isArray(settings.servers) ? settings.servers : [],
};
default:
return settings;
}
}
static fromJson(json = {}) {
return new FinalMaskStreamSettings(json.udp || []);
return new TcpMask(
json.type || 'fragment',
json.settings || {}
);
}
toJson() {
return {
udp: this.udp.map(udp => udp.toJson())
const cleanItem = item => {
const out = { ...item };
if (out.type === 'array') {
delete out.packet;
} else {
delete out.rand;
delete out.randRange;
}
return out;
};
let settings = this.settings;
if (this.type === 'header-custom' && settings) {
const cleanGroup = group => Array.isArray(group) ? group.map(cleanItem) : group;
settings = {
...settings,
clients: Array.isArray(settings.clients) ? settings.clients.map(cleanGroup) : settings.clients,
servers: Array.isArray(settings.servers) ? settings.servers.map(cleanGroup) : settings.servers,
};
}
return {
type: this.type,
settings: (settings && Object.keys(settings).length > 0) ? settings : undefined
};
}
}
class QuicParams extends XrayCommonClass {
constructor(
congestion = 'bbr',
debug = false,
brutalUp = '',
brutalDown = '',
udpHop = undefined,
initStreamReceiveWindow = 8388608,
maxStreamReceiveWindow = 8388608,
initConnectionReceiveWindow = 20971520,
maxConnectionReceiveWindow = 20971520,
maxIdleTimeout = 30,
keepAlivePeriod = 0,
disablePathMTUDiscovery = false,
maxIncomingStreams = 1024,
) {
super();
this.congestion = congestion;
this.debug = debug;
this.brutalUp = brutalUp;
this.brutalDown = brutalDown;
this.udpHop = udpHop;
this.initStreamReceiveWindow = initStreamReceiveWindow;
this.maxStreamReceiveWindow = maxStreamReceiveWindow;
this.initConnectionReceiveWindow = initConnectionReceiveWindow;
this.maxConnectionReceiveWindow = maxConnectionReceiveWindow;
this.maxIdleTimeout = maxIdleTimeout;
this.keepAlivePeriod = keepAlivePeriod;
this.disablePathMTUDiscovery = disablePathMTUDiscovery;
this.maxIncomingStreams = maxIncomingStreams;
}
get hasUdpHop() {
return this.udpHop != null;
}
set hasUdpHop(value) {
this.udpHop = value ? (this.udpHop || { ports: '20000-50000', interval: '5-10' }) : undefined;
}
static fromJson(json = {}) {
if (!json || Object.keys(json).length === 0) return undefined;
return new QuicParams(
json.congestion,
json.debug,
json.brutalUp,
json.brutalDown,
json.udpHop ? { ports: json.udpHop.ports, interval: json.udpHop.interval } : undefined,
json.initStreamReceiveWindow,
json.maxStreamReceiveWindow,
json.initConnectionReceiveWindow,
json.maxConnectionReceiveWindow,
json.maxIdleTimeout,
json.keepAlivePeriod,
json.disablePathMTUDiscovery,
json.maxIncomingStreams,
);
}
toJson() {
const result = { congestion: this.congestion };
if (this.debug) result.debug = this.debug;
if (this.brutalUp) result.brutalUp = this.brutalUp;
if (this.brutalDown) result.brutalDown = this.brutalDown;
if (this.udpHop) result.udpHop = { ports: this.udpHop.ports, interval: this.udpHop.interval };
if (this.initStreamReceiveWindow > 0) result.initStreamReceiveWindow = this.initStreamReceiveWindow;
if (this.maxStreamReceiveWindow > 0) result.maxStreamReceiveWindow = this.maxStreamReceiveWindow;
if (this.initConnectionReceiveWindow > 0) result.initConnectionReceiveWindow = this.initConnectionReceiveWindow;
if (this.maxConnectionReceiveWindow > 0) result.maxConnectionReceiveWindow = this.maxConnectionReceiveWindow;
if (this.maxIdleTimeout !== 30 && this.maxIdleTimeout > 0) result.maxIdleTimeout = this.maxIdleTimeout;
if (this.keepAlivePeriod > 0) result.keepAlivePeriod = this.keepAlivePeriod;
if (this.disablePathMTUDiscovery) result.disablePathMTUDiscovery = this.disablePathMTUDiscovery;
if (this.maxIncomingStreams > 0) result.maxIncomingStreams = this.maxIncomingStreams;
return result;
}
}
class FinalMaskStreamSettings extends XrayCommonClass {
constructor(tcp = [], udp = [], quicParams = undefined) {
super();
this.tcp = Array.isArray(tcp) ? tcp.map(t => t instanceof TcpMask ? t : new TcpMask(t.type, t.settings)) : [];
this.udp = Array.isArray(udp) ? udp.map(u => new UdpMask(u.type, u.settings)) : [new UdpMask(udp.type, udp.settings)];
this.quicParams = quicParams instanceof QuicParams ? quicParams : (quicParams ? QuicParams.fromJson(quicParams) : undefined);
}
get enableQuicParams() {
return this.quicParams != null;
}
set enableQuicParams(value) {
this.quicParams = value ? (this.quicParams || new QuicParams()) : undefined;
}
static fromJson(json = {}) {
return new FinalMaskStreamSettings(
json.tcp || [],
json.udp || [],
json.quicParams ? QuicParams.fromJson(json.quicParams) : undefined,
);
}
toJson() {
const result = {};
if (this.tcp && this.tcp.length > 0) {
result.tcp = this.tcp.map(t => t.toJson());
}
if (this.udp && this.udp.length > 0) {
result.udp = this.udp.map(udp => udp.toJson());
}
if (this.quicParams) {
result.quicParams = this.quicParams.toJson();
}
return result;
}
}
@@ -1161,6 +1356,16 @@ class StreamSettings extends XrayCommonClass {
this.sockopt = sockopt;
}
addTcpMask(type = 'fragment') {
this.finalmask.tcp.push(new TcpMask(type));
}
delTcpMask(index) {
if (this.finalmask.tcp) {
this.finalmask.tcp.splice(index, 1);
}
}
addUdpMask(type = 'salamander') {
this.finalmask.udp.push(new UdpMask(type));
}
@@ -1172,7 +1377,10 @@ class StreamSettings extends XrayCommonClass {
}
get hasFinalMask() {
return this.finalmask.udp && this.finalmask.udp.length > 0;
const hasTcp = this.finalmask.tcp && this.finalmask.tcp.length > 0;
const hasUdp = this.finalmask.udp && this.finalmask.udp.length > 0;
const hasQuicParams = this.finalmask.quicParams != null;
return hasTcp || hasUdp || hasQuicParams;
}
get isTls() {
@@ -1371,6 +1579,50 @@ class Inbound extends XrayCommonClass {
}
}
static hasShareableFinalMaskValue(value) {
if (value == null) {
return false;
}
if (Array.isArray(value)) {
return value.some(item => Inbound.hasShareableFinalMaskValue(item));
}
if (typeof value === 'object') {
return Object.values(value).some(item => Inbound.hasShareableFinalMaskValue(item));
}
if (typeof value === 'string') {
return value.length > 0;
}
return true;
}
static serializeFinalMask(finalmask) {
if (!finalmask) {
return '';
}
const value = typeof finalmask.toJson === 'function' ? finalmask.toJson() : finalmask;
return Inbound.hasShareableFinalMaskValue(value) ? JSON.stringify(value) : '';
}
// Export finalmask with the same compact JSON payload shape that
// v2rayN-compatible share links use: fm=<json>.
static applyFinalMaskToParams(finalmask, params) {
if (!params) return;
const payload = Inbound.serializeFinalMask(finalmask);
if (payload.length > 0) {
params.set("fm", payload);
}
}
// VMess links are a base64 JSON object, so keep the same fm payload
// under a flat property instead of a URL query string.
static applyFinalMaskToObj(finalmask, obj) {
if (!obj) return;
const payload = Inbound.serializeFinalMask(finalmask);
if (payload.length > 0) {
obj.fm = payload;
}
}
get clients() {
switch (this.protocol) {
case Protocols.VMESS: return this.settings.vmesses;
@@ -1567,6 +1819,8 @@ class Inbound extends XrayCommonClass {
}
} else if (network === 'kcp') {
const kcp = this.stream.kcp;
obj.mtu = kcp.mtu;
obj.tti = kcp.tti;
} else if (network === 'ws') {
const ws = this.stream.ws;
obj.path = ws.path;
@@ -1589,6 +1843,8 @@ class Inbound extends XrayCommonClass {
Inbound.applyXhttpPaddingToObj(xhttp, obj);
}
Inbound.applyFinalMaskToObj(this.stream.finalmask, obj);
if (tls === 'tls') {
if (!ObjectUtil.isEmpty(this.stream.tls.sni)) {
obj.sni = this.stream.tls.sni;
@@ -1627,6 +1883,8 @@ class Inbound extends XrayCommonClass {
break;
case "kcp":
const kcp = this.stream.kcp;
params.set("mtu", kcp.mtu);
params.set("tti", kcp.tti);
break;
case "ws":
const ws = this.stream.ws;
@@ -1655,6 +1913,8 @@ class Inbound extends XrayCommonClass {
break;
}
Inbound.applyFinalMaskToParams(this.stream.finalmask, params);
if (security === 'tls') {
params.set("security", "tls");
if (this.stream.isTls) {
@@ -1728,6 +1988,8 @@ class Inbound extends XrayCommonClass {
break;
case "kcp":
const kcp = this.stream.kcp;
params.set("mtu", kcp.mtu);
params.set("tti", kcp.tti);
break;
case "ws":
const ws = this.stream.ws;
@@ -1756,6 +2018,8 @@ class Inbound extends XrayCommonClass {
break;
}
Inbound.applyFinalMaskToParams(this.stream.finalmask, params);
if (security === 'tls') {
params.set("security", "tls");
if (this.stream.isTls) {
@@ -1805,6 +2069,8 @@ class Inbound extends XrayCommonClass {
break;
case "kcp":
const kcp = this.stream.kcp;
params.set("mtu", kcp.mtu);
params.set("tti", kcp.tti);
break;
case "ws":
const ws = this.stream.ws;
@@ -1833,6 +2099,8 @@ class Inbound extends XrayCommonClass {
break;
}
Inbound.applyFinalMaskToParams(this.stream.finalmask, params);
if (security === 'tls') {
params.set("security", "tls");
if (this.stream.isTls) {
@@ -1900,6 +2168,8 @@ class Inbound extends XrayCommonClass {
}
}
Inbound.applyFinalMaskToParams(this.stream.finalmask, params);
const url = new URL(link);
for (const [key, value] of params) {
url.searchParams.set(key, value);
@@ -1908,7 +2178,7 @@ class Inbound extends XrayCommonClass {
return url.toString();
}
getWireguardLink(address, port, remark, peerId) {
getWireguardTxt(address, port, remark, peerId) {
let txt = `[Interface]\n`
txt += `PrivateKey = ${this.settings.peers[peerId].privateKey}\n`
txt += `Address = ${this.settings.peers[peerId].allowedIPs[0]}\n`
@@ -1930,6 +2200,48 @@ class Inbound extends XrayCommonClass {
return txt;
}
getWireguardLink(address, port, remark, peerId) {
const peer = this.settings?.peers?.[peerId];
if (!peer) return '';
const link = `wireguard://${address}:${port}`;
const url = new URL(link);
url.username = peer.privateKey || '';
if (this.settings?.pubKey) {
url.searchParams.set("publickey", this.settings.pubKey);
}
if (Array.isArray(peer.allowedIPs) && peer.allowedIPs.length > 0 && peer.allowedIPs[0]) {
url.searchParams.set("address", peer.allowedIPs[0]);
}
if (this.settings?.mtu) {
url.searchParams.set("mtu", this.settings.mtu);
}
url.hash = encodeURIComponent(remark);
return url.toString();
}
genWireguardLinks(remark = '', remarkModel = '-ieo') {
const addr = !ObjectUtil.isEmpty(this.listen) && this.listen !== "0.0.0.0" ? this.listen : location.hostname;
const separationChar = remarkModel.charAt(0);
let links = [];
this.settings.peers.forEach((p, index) => {
links.push(this.getWireguardLink(addr, this.port, remark + separationChar + (index + 1), index));
});
return links.join('\r\n');
}
genWireguardConfigs(remark = '', remarkModel = '-ieo') {
const addr = !ObjectUtil.isEmpty(this.listen) && this.listen !== "0.0.0.0" ? this.listen : location.hostname;
const separationChar = remarkModel.charAt(0);
let links = [];
this.settings.peers.forEach((p, index) => {
links.push(this.getWireguardTxt(addr, this.port, remark + separationChar + (index + 1), index));
});
return links.join('\r\n');
}
genLink(address = '', port = this.port, forceTls = 'same', remark = '', client) {
switch (this.protocol) {
case Protocols.VMESS:
@@ -1990,11 +2302,7 @@ class Inbound extends XrayCommonClass {
} else {
if (this.protocol == Protocols.SHADOWSOCKS && !this.isSSMultiUser) return this.genSSLink(addr, this.port, 'same', remark);
if (this.protocol == Protocols.WIREGUARD) {
let links = [];
this.settings.peers.forEach((p, index) => {
links.push(this.getWireguardLink(addr, this.port, remark + remarkModel.charAt(0) + (index + 1), index));
});
return links.join('\r\n');
return this.genWireguardConfigs(remark, remarkModel);
}
return '';
}

View File

@@ -97,6 +97,74 @@ const Address_Port_Strategy = {
TxtPortAndAddress: "txtportandaddress"
};
const DNSRuleActions = ['direct', 'drop', 'reject', 'hijack'];
function normalizeDNSRuleField(value) {
if (value === null || value === undefined) {
return '';
}
if (Array.isArray(value)) {
return value.map(item => item.toString().trim()).filter(item => item.length > 0).join(',');
}
return value.toString().trim();
}
function normalizeDNSRuleAction(action) {
action = ObjectUtil.isEmpty(action) ? 'direct' : action.toString().toLowerCase().trim();
return DNSRuleActions.includes(action) ? action : 'direct';
}
function parseLegacyDNSBlockTypes(blockTypes) {
if (blockTypes === null || blockTypes === undefined || blockTypes === '') {
return [];
}
if (Array.isArray(blockTypes)) {
return blockTypes
.map(item => Number(item))
.filter(item => Number.isInteger(item) && item >= 0 && item <= 65535);
}
if (typeof blockTypes === 'number') {
return Number.isInteger(blockTypes) && blockTypes >= 0 && blockTypes <= 65535 ? [blockTypes] : [];
}
return blockTypes
.toString()
.split(',')
.map(item => item.trim())
.filter(item => /^\d+$/.test(item))
.map(item => Number(item))
.filter(item => item >= 0 && item <= 65535);
}
function buildLegacyDNSRules(nonIPQuery, blockTypes) {
const mode = ['reject', 'drop', 'skip'].includes(nonIPQuery) ? nonIPQuery : 'reject';
const rules = [];
const parsedBlockTypes = parseLegacyDNSBlockTypes(blockTypes);
if (parsedBlockTypes.length > 0) {
rules.push(new Outbound.DNSRule(mode === 'reject' ? 'reject' : 'drop', parsedBlockTypes.join(',')));
}
rules.push(new Outbound.DNSRule('hijack', '1,28'));
rules.push(new Outbound.DNSRule(mode === 'skip' ? 'direct' : mode));
return rules;
}
function getDNSRulesFromJson(json = {}) {
if (Array.isArray(json.rules) && json.rules.length > 0) {
return json.rules.map(rule => Outbound.DNSRule.fromJson(rule));
}
if (json.nonIPQuery !== undefined || json.blockTypes !== undefined) {
return buildLegacyDNSRules(json.nonIPQuery, json.blockTypes);
}
return [];
}
Object.freeze(Protocols);
Object.freeze(SSMethods);
Object.freeze(TLS_FLOW_CONTROL);
@@ -107,6 +175,7 @@ Object.freeze(WireguardDomainStrategy);
Object.freeze(USERS_SECURITY);
Object.freeze(MODE_OPTION);
Object.freeze(Address_Port_Strategy);
Object.freeze(DNSRuleActions);
class CommonClass {
@@ -586,11 +655,23 @@ class UdpMask extends CommonClass {
case 'header-wireguard':
return {}; // No settings needed
case 'header-custom':
return { client: [], server: [] };
return {
client: Array.isArray(settings.client) ? settings.client : [],
server: Array.isArray(settings.server) ? settings.server : [],
};
case 'noise':
return { reset: 0, noise: [] };
return {
reset: settings.reset ?? 0,
noise: Array.isArray(settings.noise) ? settings.noise : [],
};
case 'sudoku':
return { ascii: '', customTable: '', customTables: [], paddingMin: 0, paddingMax: 0 };
return {
ascii: settings.ascii || '',
customTable: settings.customTable || '',
customTables: Array.isArray(settings.customTables) ? settings.customTables : [],
paddingMin: settings.paddingMin ?? 0,
paddingMax: settings.paddingMax ?? 0
};
default:
return settings;
}
@@ -604,28 +685,187 @@ class UdpMask extends CommonClass {
}
toJson() {
const cleanItem = item => {
const out = { ...item };
if (out.type === 'array') {
delete out.packet;
} else {
delete out.rand;
delete out.randRange;
}
return out;
};
let settings = this.settings;
if (this.type === 'noise' && settings && Array.isArray(settings.noise)) {
settings = { ...settings, noise: settings.noise.map(cleanItem) };
} else if (this.type === 'header-custom' && settings) {
settings = {
...settings,
client: Array.isArray(settings.client) ? settings.client.map(cleanItem) : settings.client,
server: Array.isArray(settings.server) ? settings.server.map(cleanItem) : settings.server,
};
}
return {
type: this.type,
settings: (this.settings && Object.keys(this.settings).length > 0) ? this.settings : undefined
settings: (settings && Object.keys(settings).length > 0) ? settings : undefined
};
}
}
class FinalMaskStreamSettings extends CommonClass {
constructor(udp = []) {
class TcpMask extends CommonClass {
constructor(type = 'fragment', settings = {}) {
super();
this.udp = Array.isArray(udp) ? udp.map(u => new UdpMask(u.type, u.settings)) : [new UdpMask(udp.type, udp.settings)];
this.type = type;
this.settings = this._getDefaultSettings(type, settings);
}
_getDefaultSettings(type, settings = {}) {
switch (type) {
case 'fragment':
return {
packets: settings.packets ?? 'tlshello',
length: settings.length ?? '',
delay: settings.delay ?? '',
maxSplit: settings.maxSplit ?? '',
};
case 'sudoku':
return {
password: settings.password ?? '',
ascii: settings.ascii ?? '',
customTable: settings.customTable ?? '',
customTables: Array.isArray(settings.customTables) ? settings.customTables : [],
paddingMin: settings.paddingMin ?? 0,
paddingMax: settings.paddingMax ?? 0,
};
case 'header-custom':
return {
clients: Array.isArray(settings.clients) ? settings.clients : [],
servers: Array.isArray(settings.servers) ? settings.servers : [],
};
default:
return settings;
}
}
static fromJson(json = {}) {
return new FinalMaskStreamSettings(json.udp || []);
return new TcpMask(
json.type || 'fragment',
json.settings || {}
);
}
toJson() {
return {
udp: this.udp.map(udp => udp.toJson())
const cleanItem = item => {
const out = { ...item };
if (out.type === 'array') {
delete out.packet;
} else {
delete out.rand;
delete out.randRange;
}
return out;
};
let settings = this.settings;
if (this.type === 'header-custom' && settings) {
const cleanGroup = group => Array.isArray(group) ? group.map(cleanItem) : group;
settings = {
...settings,
clients: Array.isArray(settings.clients) ? settings.clients.map(cleanGroup) : settings.clients,
servers: Array.isArray(settings.servers) ? settings.servers.map(cleanGroup) : settings.servers,
};
}
return {
type: this.type,
settings: (settings && Object.keys(settings).length > 0) ? settings : undefined
};
}
}
class QuicParams extends CommonClass {
constructor(
congestion = 'bbr',
debug = false,
brutalUp = '',
brutalDown = '',
udpHop = undefined,
) {
super();
this.congestion = congestion;
this.debug = debug;
this.brutalUp = brutalUp;
this.brutalDown = brutalDown;
this.udpHop = udpHop;
}
get hasUdpHop() {
return this.udpHop != null;
}
set hasUdpHop(value) {
this.udpHop = value ? (this.udpHop || { ports: '20000-50000', interval: '5-10' }) : undefined;
}
static fromJson(json = {}) {
if (!json || Object.keys(json).length === 0) return undefined;
return new QuicParams(
json.congestion,
json.debug,
json.brutalUp,
json.brutalDown,
json.udpHop ? { ports: json.udpHop.ports, interval: json.udpHop.interval } : undefined,
);
}
toJson() {
const result = { congestion: this.congestion };
if (this.debug) result.debug = this.debug;
if (this.brutalUp) result.brutalUp = this.brutalUp;
if (this.brutalDown) result.brutalDown = this.brutalDown;
if (this.udpHop) result.udpHop = { ports: this.udpHop.ports, interval: this.udpHop.interval };
return result;
}
}
class FinalMaskStreamSettings extends CommonClass {
constructor(tcp = [], udp = [], quicParams = undefined) {
super();
this.tcp = Array.isArray(tcp) ? tcp.map(t => t instanceof TcpMask ? t : new TcpMask(t.type, t.settings)) : [];
this.udp = Array.isArray(udp) ? udp.map(u => new UdpMask(u.type, u.settings)) : [new UdpMask(udp.type, udp.settings)];
this.quicParams = quicParams instanceof QuicParams ? quicParams : (quicParams ? QuicParams.fromJson(quicParams) : undefined);
}
get enableQuicParams() {
return this.quicParams != null;
}
set enableQuicParams(value) {
this.quicParams = value ? (this.quicParams || new QuicParams()) : undefined;
}
static fromJson(json = {}) {
return new FinalMaskStreamSettings(
json.tcp || [],
json.udp || [],
json.quicParams ? QuicParams.fromJson(json.quicParams) : undefined,
);
}
toJson() {
const result = {};
if (this.tcp && this.tcp.length > 0) {
result.tcp = this.tcp.map(t => t.toJson());
}
if (this.udp && this.udp.length > 0) {
result.udp = this.udp.map(udp => udp.toJson());
}
if (this.quicParams) {
result.quicParams = this.quicParams.toJson();
}
return result;
}
}
@@ -661,6 +901,16 @@ class StreamSettings extends CommonClass {
this.sockopt = sockopt;
}
addTcpMask(type = 'fragment') {
this.finalmask.tcp.push(new TcpMask(type));
}
delTcpMask(index) {
if (this.finalmask.tcp) {
this.finalmask.tcp.splice(index, 1);
}
}
addUdpMask(type = 'salamander') {
this.finalmask.udp.push(new UdpMask(type));
}
@@ -672,7 +922,10 @@ class StreamSettings extends CommonClass {
}
get hasFinalMask() {
return this.finalmask.udp && this.finalmask.udp.length > 0;
const hasTcp = this.finalmask.tcp && this.finalmask.tcp.length > 0;
const hasUdp = this.finalmask.udp && this.finalmask.udp.length > 0;
const hasQuicParams = this.finalmask.quicParams != null;
return hasTcp || hasUdp || hasQuicParams;
}
get isTls() {
@@ -923,6 +1176,10 @@ class Outbound extends CommonClass {
stream.kcp = new KcpStreamSettings();
stream.type = json.type;
stream.seed = json.path;
const mtu = Number(json.mtu);
if (Number.isFinite(mtu) && mtu > 0) stream.kcp.mtu = mtu;
const tti = Number(json.tti);
if (Number.isFinite(tti) && tti > 0) stream.kcp.tti = tti;
} else if (network === 'ws') {
stream.ws = new WsStreamSettings(json.path, json.host);
} else if (network === 'grpc') {
@@ -960,6 +1217,7 @@ class Outbound extends CommonClass {
let headerType = url.searchParams.get('headerType') ?? undefined;
let host = url.searchParams.get('host') ?? undefined;
let path = url.searchParams.get('path') ?? undefined;
let seed = url.searchParams.get('seed') ?? path ?? undefined;
let mode = url.searchParams.get('mode') ?? undefined;
if (type === 'tcp' || type === 'none') {
@@ -967,7 +1225,11 @@ class Outbound extends CommonClass {
} else if (type === 'kcp') {
stream.kcp = new KcpStreamSettings();
stream.kcp.type = headerType ?? 'none';
stream.kcp.seed = path;
stream.kcp.seed = seed;
const mtu = Number(url.searchParams.get('mtu'));
if (Number.isFinite(mtu) && mtu > 0) stream.kcp.mtu = mtu;
const tti = Number(url.searchParams.get('tti'));
if (Number.isFinite(tti) && tti > 0) stream.kcp.tti = tti;
} else if (type === 'ws') {
stream.ws = new WsStreamSettings(path, host);
} else if (type === 'grpc') {
@@ -1277,20 +1539,69 @@ Outbound.BlackholeSettings = class extends CommonClass {
};
}
};
Outbound.DNSRule = class extends CommonClass {
constructor(action = 'direct', qtype = '', domain = '') {
super();
this.action = action;
this.qtype = qtype;
this.domain = domain;
}
static fromJson(json = {}) {
return new Outbound.DNSRule(
json.action,
normalizeDNSRuleField(json.qtype),
normalizeDNSRuleField(json.domain),
);
}
toJson() {
const rule = {
action: normalizeDNSRuleAction(this.action),
};
const qtype = normalizeDNSRuleField(this.qtype);
if (!ObjectUtil.isEmpty(qtype)) {
if (/^\d+$/.test(qtype)) {
rule.qtype = Number(qtype);
} else {
rule.qtype = qtype;
}
}
const domains = normalizeDNSRuleField(this.domain)
.split(',')
.map(d => d.trim())
.filter(d => d.length > 0);
if (domains.length > 0) {
rule.domain = domains;
}
return rule;
}
};
Outbound.DNSSettings = class extends CommonClass {
constructor(
network = 'udp',
address = '',
port = 53,
nonIPQuery = 'reject',
blockTypes = []
rules = []
) {
super();
this.network = network;
this.address = address;
this.port = port;
this.nonIPQuery = nonIPQuery;
this.blockTypes = blockTypes;
this.rules = Array.isArray(rules) ? rules.map(rule => rule instanceof Outbound.DNSRule ? rule : Outbound.DNSRule.fromJson(rule)) : [];
}
addRule(action = 'direct') {
this.rules.push(new Outbound.DNSRule(action));
}
delRule(index) {
this.rules.splice(index, 1);
}
static fromJson(json = {}) {
@@ -1298,10 +1609,23 @@ Outbound.DNSSettings = class extends CommonClass {
json.network,
json.address,
json.port,
json.nonIPQuery,
json.blockTypes,
getDNSRulesFromJson(json),
);
}
toJson() {
const json = {
network: this.network,
address: this.address,
port: this.port,
};
if (this.rules.length > 0) {
json.rules = Outbound.DNSRule.toJsonArray(this.rules);
}
return json;
}
};
Outbound.VmessSettings = class extends CommonClass {
constructor(address, port, id, security) {

View File

@@ -152,6 +152,12 @@ class RandomUtil {
return Base64.alternativeEncode(String.fromCharCode(...array));
}
static randomBase64(length = 16) {
const array = new Uint8Array(length);
window.crypto.getRandomValues(array);
return Base64.alternativeEncode(String.fromCharCode(...array));
}
static randomBase32String(length = 16) {
const array = new Uint8Array(length);

View File

@@ -41,6 +41,7 @@ func (a *InboundController) initRouter(g *gin.RouterGroup) {
g.POST("/clientIps/:email", a.getClientIps)
g.POST("/clearClientIps/:email", a.clearClientIps)
g.POST("/addClient", a.addInboundClient)
g.POST("/:id/copyClients", a.copyInboundClients)
g.POST("/:id/delClient/:clientId", a.delInboundClient)
g.POST("/updateClient/:clientId", a.updateInboundClient)
g.POST("/:id/resetClientTraffic/:email", a.resetClientTraffic)
@@ -54,6 +55,12 @@ func (a *InboundController) initRouter(g *gin.RouterGroup) {
g.POST("/:id/delClientByEmail/:email", a.delInboundClientByEmail)
}
type CopyInboundClientsRequest struct {
SourceInboundID int `form:"sourceInboundId" json:"sourceInboundId"`
ClientEmails []string `form:"clientEmails" json:"clientEmails"`
Flow string `form:"flow" json:"flow"`
}
// getInbounds retrieves the list of inbounds for the logged-in user.
func (a *InboundController) getInbounds(c *gin.Context) {
user := session.GetLoginUser(c)
@@ -260,6 +267,36 @@ func (a *InboundController) addInboundClient(c *gin.Context) {
}
}
// copyInboundClients copies clients from source inbound to target inbound.
func (a *InboundController) copyInboundClients(c *gin.Context) {
targetID, err := strconv.Atoi(c.Param("id"))
if err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
req := &CopyInboundClientsRequest{}
err = c.ShouldBind(req)
if err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
if req.SourceInboundID <= 0 {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), fmt.Errorf("invalid source inbound id"))
return
}
result, needRestart, err := a.inboundService.CopyInboundClients(targetID, req.SourceInboundID, req.ClientEmails, req.Flow)
if err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
jsonObj(c, result, nil)
if needRestart {
a.xrayService.SetToNeedRestart()
}
}
// delInboundClient deletes a client from an inbound by inbound ID and client ID.
func (a *InboundController) delInboundClient(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))

View File

@@ -40,7 +40,8 @@
{{define "component/aThemeSwitch"}}
<script>
function createThemeSwitcher() {
const isDarkTheme = localStorage.getItem('dark-mode') === 'true';
const darkModePreference = localStorage.getItem('dark-mode');
const isDarkTheme = darkModePreference === null ? true : darkModePreference === 'true';
const isUltra = localStorage.getItem('isUltraDarkThemeEnabled') === 'true';
if (isUltra) {
document.documentElement.setAttribute('data-theme', 'ultra-dark');

View File

@@ -190,22 +190,73 @@
>
</a-select>
</a-form-item>
<a-form-item label="non-IP queries">
<a-select
v-model="outbound.settings.nonIPQuery"
:dropdown-class-name="themeSwitcher.currentTheme"
>
<a-select-option v-for="s in ['reject','drop','skip']" :value="s"
>[[ s ]]</a-select-option
>
</a-select>
<a-form-item label="Rules">
<a-button
icon="plus"
type="primary"
size="small"
@click="outbound.settings.addRule()"
></a-button>
</a-form-item>
<a-form-item
v-if="outbound.settings.nonIPQuery === 'skip'"
label="Block Types"
<a-form
v-for="(rule, index) in outbound.settings.rules"
:colon="false"
:label-col="{ md: {span:8} }"
:wrapper-col="{ md: {span:14} }"
>
<a-input v-model.number="outbound.settings.blockTypes"></a-input>
</a-form-item>
<a-divider :style="{ margin: '0' }">
Rule [[ index + 1 ]]
<a-icon
type="delete"
@click="() => outbound.settings.delRule(index)"
:style="{ color: 'rgb(255, 77, 79)', cursor: 'pointer' }"
></a-icon>
</a-divider>
<a-form-item label="Action">
<a-select
v-model="rule.action"
:dropdown-class-name="themeSwitcher.currentTheme"
>
<a-select-option v-for="action in DNSRuleActions" :value="action"
>[[ action ]]</a-select-option
>
</a-select>
</a-form-item>
<a-form-item>
<template slot="label">
<a-tooltip>
<template slot="title">
<span>Single qtype (e.g. 28) or list/range (e.g. 1,3,23-24)</span>
</template>
QType
<a-icon type="question-circle"></a-icon>
</a-tooltip>
</template>
<a-input
v-model.trim="rule.qtype"
placeholder="1,3,23-24"
></a-input>
</a-form-item>
<a-form-item>
<template slot="label">
<a-tooltip>
<template slot="title">
<span>Comma-separated domain rules, e.g. domain:example.com,full:example.com</span>
</template>
Domain
<a-icon type="question-circle"></a-icon>
</a-tooltip>
</template>
<a-input
v-model.trim="rule.domain"
placeholder="domain:example.com,full:example.com"
></a-input>
</a-form-item>
</a-form>
</template>
<!-- wireguard settings -->
@@ -791,7 +842,7 @@
:max="60"
></a-input-number>
</a-form-item>
<a-form-item label="Disable Path MTU">
<a-form-item label="Disable Path MTU Dis">
<a-switch
v-model="outbound.stream.hysteria.disablePathMTUDiscovery"
></a-switch>
@@ -801,6 +852,184 @@
<!-- finalmask settings -->
<template v-if="outbound.canEnableStream()">
<!-- TCP Masks for raw/tcp/httpupgrade/ws/grpc/xhttp -->
<a-form-item
label="TCP Masks"
v-if="['raw', 'tcp', 'httpupgrade', 'ws', 'grpc', 'xhttp'].includes(outbound.stream.network)"
>
<a-button
icon="plus"
type="primary"
size="small"
@click="outbound.stream.addTcpMask('fragment')"
></a-button>
</a-form-item>
<template v-if="outbound.stream.finalmask.tcp && outbound.stream.finalmask.tcp.length > 0">
<a-form
v-for="(mask, index) in outbound.stream.finalmask.tcp"
:key="index"
:colon="false"
:label-col="{ md: {span:8} }"
:wrapper-col="{ md: {span:14} }"
>
<a-divider :style="{ margin: '0' }">
TCP Mask [[ index + 1 ]]
<a-icon
type="delete"
@click="() => outbound.stream.delTcpMask(index)"
:style="{ color: 'rgb(255, 77, 79)', cursor: 'pointer' }"
></a-icon>
</a-divider>
<a-form-item label="Type">
<a-select
v-model="mask.type"
@change="(type) => { mask.settings = mask._getDefaultSettings(type, {}); }"
:dropdown-class-name="themeSwitcher.currentTheme"
>
<a-select-option value="fragment">Fragment</a-select-option>
<a-select-option value="header-custom">Header Custom</a-select-option>
<a-select-option value="sudoku">Sudoku</a-select-option>
</a-select>
</a-form-item>
<!-- Fragment -->
<template v-if="mask.type === 'fragment'">
<a-form-item label="Packets">
<a-select v-model="mask.settings.packets" :dropdown-class-name="themeSwitcher.currentTheme">
<a-select-option value="tlshello">tlshello</a-select-option>
<a-select-option value="1-3">1-3</a-select-option>
<a-select-option value="1-5">1-5</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="Length">
<a-input v-model.trim="mask.settings.length" placeholder="e.g. 100-200" />
</a-form-item>
<a-form-item label="Delay">
<a-input v-model.trim="mask.settings.delay" placeholder="e.g. 10-20" />
</a-form-item>
<a-form-item label="Max Split">
<a-input v-model.trim="mask.settings.maxSplit" placeholder="e.g. 3-6" />
</a-form-item>
</template>
<!-- Sudoku (TCP) -->
<template v-if="mask.type === 'sudoku'">
<a-form-item label="Password">
<a-input v-model.trim="mask.settings.password" placeholder="Obfuscation password" />
</a-form-item>
<a-form-item label="ASCII">
<a-input v-model.trim="mask.settings.ascii" placeholder="ASCII" />
</a-form-item>
<a-form-item label="Custom Table">
<a-input v-model.trim="mask.settings.customTable" placeholder="Custom Table" />
</a-form-item>
<a-form-item label="Custom Tables">
<a-input v-model.trim="mask.settings.customTables" placeholder="Custom Tables" />
</a-form-item>
<a-form-item label="Padding Min">
<a-input-number v-model.number="mask.settings.paddingMin" :min="0" />
</a-form-item>
<a-form-item label="Padding Max">
<a-input-number v-model.number="mask.settings.paddingMax" :min="0" />
</a-form-item>
</template>
<!-- Header Custom (TCP) clients/servers are 2D arrays of groups -->
<template v-if="mask.type === 'header-custom'">
<!-- Clients -->
<a-form-item label="Clients">
<a-button
icon="plus"
type="primary"
size="small"
@click="mask.settings.clients.push([{delay: 0, rand: 0, randRange: '0-255', type: 'array', packet: []}])"
></a-button>
</a-form-item>
<template v-for="(group, gi) in mask.settings.clients" :key="'cg'+gi">
<a-divider :style="{ margin: '0' }">
Clients Group [[ gi + 1 ]]
<a-icon
type="delete"
@click="mask.settings.clients.splice(gi, 1)"
:style="{ color: 'rgb(255, 77, 79)', cursor: 'pointer', marginLeft: '8px' }"
></a-icon>
</a-divider>
<template v-for="(item, ii) in group" :key="'ci'+ii">
<a-form-item label='{{ i18n "pages.xray.outbound.type" }}'>
<a-select v-model="item.type" :dropdown-class-name="themeSwitcher.currentTheme"
@change="t => { if(t === 'array') { item.rand = 0; item.packet = []; } else { item.packet = ''; } }">
<a-select-option value="array">Array</a-select-option>
<a-select-option value="str">String</a-select-option>
<a-select-option value="hex">Hex</a-select-option>
<a-select-option value="base64">Base64</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="Delay (ms)">
<a-input-number v-model.number="item.delay" :min="0" />
</a-form-item>
<template v-if="item.type === 'array'">
<a-form-item label="Rand">
<a-input-number v-model.number="item.rand" :min="0" />
</a-form-item>
<a-form-item label="Rand Range">
<a-input v-model.trim="item.randRange" placeholder="0-255" />
</a-form-item>
</template>
<a-form-item v-else label="Packet">
<a-input v-model.trim="item.packet" placeholder="binary data" />
</a-form-item>
</template>
</template>
<!-- Servers -->
<a-form-item label="Servers">
<a-button
icon="plus"
type="primary"
size="small"
@click="mask.settings.servers.push([{delay: 0, rand: 0, randRange: '0-255', type: 'array', packet: []}])"
></a-button>
</a-form-item>
<template v-for="(group, gi) in mask.settings.servers" :key="'sg'+gi">
<a-divider :style="{ margin: '0' }">
Servers Group [[ gi + 1 ]]
<a-icon
type="delete"
@click="mask.settings.servers.splice(gi, 1)"
:style="{ color: 'rgb(255, 77, 79)', cursor: 'pointer', marginLeft: '8px' }"
></a-icon>
</a-divider>
<template v-for="(item, ii) in group" :key="'si'+ii">
<a-form-item label='{{ i18n "pages.xray.outbound.type" }}'>
<a-select v-model="item.type" :dropdown-class-name="themeSwitcher.currentTheme"
@change="t => { if(t === 'array') { item.rand = 0; item.packet = []; } else { item.packet = ''; } }">
<a-select-option value="array">Array</a-select-option>
<a-select-option value="str">String</a-select-option>
<a-select-option value="hex">Hex</a-select-option>
<a-select-option value="base64">Base64</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="Delay (ms)">
<a-input-number v-model.number="item.delay" :min="0" />
</a-form-item>
<template v-if="item.type === 'array'">
<a-form-item label="Rand">
<a-input-number v-model.number="item.rand" :min="0" />
</a-form-item>
<a-form-item label="Rand Range">
<a-input v-model.trim="item.randRange" placeholder="0-255" />
</a-form-item>
</template>
<a-form-item label="Packet" v-else>
<a-input v-model.trim="item.packet" placeholder="binary data" />
</a-form-item>
</template>
</template>
</template>
</a-form>
</template>
<a-form-item
label="UDP Masks"
v-if="outbound.stream.network === 'kcp' || outbound.protocol === Protocols.Hysteria"
@@ -869,17 +1098,14 @@
>
<a-select-option value="xdns">xDNS</a-select-option>
<a-select-option value="xicmp">xICMP</a-select-option>
<a-select-option value="header-custom"
>Header Custom</a-select-option
>
<a-select-option value="header-custom">Header Custom</a-select-option>
<a-select-option value="noise">Noise</a-select-option>
<a-select-option value="sudoku">Sudoku</a-select-option>
</template>
</a-select>
</a-form-item>
<a-form-item
label="Password"
v-if="['salamander', 'mkcp-aes128gcm', 'sudoku'].includes(mask.type)"
v-if="['salamander', 'mkcp-aes128gcm'].includes(mask.type)"
>
<a-input
v-model.trim="mask.settings.password"
@@ -903,134 +1129,17 @@
></a-select>
</a-form-item>
</template>
<template v-if="mask.type === 'header-custom'">
<a-form-item label="Client">
<a-icon
type="plus"
type="primary"
size="small"
@click="mask.settings.client.push({rand: 0, randRange: '0-255', type: 'array', packet: []})"
/>
</a-form-item>
<template v-for="(c, index) in mask.settings.client" :key="index">
<a-divider :style="{ margin: '0' }">
Client [[ index + 1 ]]
<a-icon
type="delete"
@click="() => mask.settings.client.splice(index, 1)"
:style="{ color: 'rgb(255, 77, 79)', cursor: 'pointer' }"
></a-icon>
</a-divider>
<a-form-item label="Rand">
<a-input-number
v-model.number="c.rand"
:min="0"
></a-input-number>
</a-form-item>
<a-form-item label="Rand Range">
<a-input
v-model.trim="c.randRange"
placeholder="0-255"
></a-input>
</a-form-item>
<a-form-item label="Type">
<a-select
v-model="c.type"
:dropdown-class-name="themeSwitcher.currentTheme"
>
<a-select-option value="array">Array</a-select-option>
<a-select-option value="str">String</a-select-option>
<a-select-option value="hex">Hex</a-select-option>
<a-select-option value="base64">Base64</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="Packet">
<a-input v-model.trim="c.packet" placeholder="binary data" />
</a-form-item>
</template>
<a-divider :style="{ margin: '0' }"></a-divider>
<a-form-item label="Server">
<a-icon
type="plus"
type="primary"
size="small"
@click="mask.settings.server.push({rand: 0, randRange: '0-255', type: 'array', packet: []})"
/>
</a-form-item>
<template v-for="(s, index) in mask.settings.server" :key="index">
<a-divider :style="{ margin: '0' }">
Server [[ index + 1 ]]
<a-icon
type="delete"
@click="() => mask.settings.server.splice(index, 1)"
:style="{ color: 'rgb(255, 77, 79)', cursor: 'pointer' }"
></a-icon>
</a-divider>
<a-form-item label="Rand">
<a-input-number
v-model.number="s.rand"
:min="0"
></a-input-number>
</a-form-item>
<a-form-item label="Rand Range">
<a-input
v-model.trim="s.randRange"
placeholder="0-255"
></a-input>
</a-form-item>
<a-form-item label="Type">
<a-select
v-model="s.type"
:dropdown-class-name="themeSwitcher.currentTheme"
>
<a-select-option value="array">Array</a-select-option>
<a-select-option value="str">String</a-select-option>
<a-select-option value="hex">Hex</a-select-option>
<a-select-option value="base64">Base64</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="Packet">
<a-input v-model.trim="s.packet" placeholder="binary data" />
</a-form-item>
</template>
</template>
<template v-if="mask.type === 'sudoku'">
<a-form-item label="ASCII">
<a-input
v-model.trim="mask.settings.ascii"
placeholder="ASCII"
></a-input>
</a-form-item>
<a-form-item label="Custom Table">
<a-input
v-model.trim="mask.settings.customTable"
placeholder="Custom Table"
></a-input>
</a-form-item>
<a-form-item label="Custom Tables">
<a-input
v-model.trim="mask.settings.customTables"
placeholder="Custom Tables"
></a-input>
</a-form-item>
<a-form-item label="Padding Min">
<a-input-number
v-model.number="mask.settings.paddingMin"
:min="0"
></a-input-number>
</a-form-item>
</template>
<template v-if="mask.type === 'noise'">
<a-form-item label="Reset">
<a-input-number v-model.number="mask.settings.reset" :min="0" />
</a-form-item>
<a-form-item label="Noise">
<a-icon
type="plus"
<a-button
icon="plus"
type="primary"
size="small"
@click="mask.settings.noise.push({rand: '1-8192', randRange: '0-255', type: 'array', packet: '', delay: ''})"
/>
@click="mask.settings.noise.push({rand: 0, randRange: '0-255', type: 'array', packet: [], delay: ''})"
></a-button>
</a-form-item>
<template v-for="(n, index) in mask.settings.noise" :key="index">
<a-divider :style="{ margin: '0' }">
@@ -1041,22 +1150,11 @@
:style="{ color: 'rgb(255, 77, 79)', cursor: 'pointer' }"
></a-icon>
</a-divider>
<a-form-item label="Rand">
<a-input-number
v-model.number="n.rand"
:min="0"
></a-input-number>
</a-form-item>
<a-form-item label="Rand Range">
<a-input
v-model.trim="n.randRange"
placeholder="0-255"
></a-input>
</a-form-item>
<a-form-item label="Type">
<a-select
v-model="n.type"
:dropdown-class-name="themeSwitcher.currentTheme"
@change="t => { if(t === 'array') n.packet = []; else n.packet = ''; }"
>
<a-select-option value="array">Array</a-select-option>
<a-select-option value="str">String</a-select-option>
@@ -1064,7 +1162,18 @@
<a-select-option value="base64">Base64</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="Packet">
<template v-if="n.type === 'array'">
<a-form-item label="Rand">
<a-input v-model.trim="n.rand" placeholder="0 or 1-8192" />
</a-form-item>
<a-form-item label="Rand Range">
<a-input
v-model.trim="n.randRange"
placeholder="0-255"
></a-input>
</a-form-item>
</template>
<a-form-item label="Packet" v-else>
<a-input v-model.trim="n.packet" placeholder="binary data" />
</a-form-item>
<a-form-item label="Delay">
@@ -1072,6 +1181,91 @@
</a-form-item>
</template>
</template>
<template v-if="mask.type === 'header-custom'">
<a-form-item label="Client">
<a-button
icon="plus"
type="primary"
size="small"
@click="mask.settings.client.push({rand: 0, randRange: '0-255', type: 'array', packet: []})"
></a-button>
</a-form-item>
<template v-for="(c, index) in mask.settings.client" :key="index">
<a-divider :style="{ margin: '0' }">
Client [[ index + 1 ]]
<a-icon
type="delete"
@click="mask.settings.client.splice(index, 1)"
:style="{ color: 'rgb(255, 77, 79)', cursor: 'pointer' }"
></a-icon>
</a-divider>
<a-form-item label="Type">
<a-select
v-model="c.type"
:dropdown-class-name="themeSwitcher.currentTheme"
@change="t => { if(t === 'array') c.packet = []; else c.packet = ''; }"
>
<a-select-option value="array">Array</a-select-option>
<a-select-option value="str">String</a-select-option>
<a-select-option value="hex">Hex</a-select-option>
<a-select-option value="base64">Base64</a-select-option>
</a-select>
</a-form-item>
<template v-if="c.type === 'array'">
<a-form-item label="Rand">
<a-input-number v-model.number="c.rand" :min="0"></a-input-number>
</a-form-item>
<a-form-item label="Rand Range">
<a-input v-model.trim="c.randRange" placeholder="0-255"></a-input>
</a-form-item>
</template>
<a-form-item label="Packet" v-else>
<a-input v-model.trim="c.packet" placeholder="binary data" />
</a-form-item>
</template>
<a-divider :style="{ margin: '0' }"></a-divider>
<a-form-item label="Server">
<a-button
icon="plus"
type="primary"
size="small"
@click="mask.settings.server.push({rand: 0, randRange: '0-255', type: 'array', packet: []})"
></a-button>
</a-form-item>
<template v-for="(s, index) in mask.settings.server" :key="index">
<a-divider :style="{ margin: '0' }">
Server [[ index + 1 ]]
<a-icon
type="delete"
@click="mask.settings.server.splice(index, 1)"
:style="{ color: 'rgb(255, 77, 79)', cursor: 'pointer' }"
></a-icon>
</a-divider>
<a-form-item label="Type">
<a-select
v-model="s.type"
:dropdown-class-name="themeSwitcher.currentTheme"
@change="t => { if(t === 'array') s.packet = []; else s.packet = ''; }"
>
<a-select-option value="array">Array</a-select-option>
<a-select-option value="str">String</a-select-option>
<a-select-option value="hex">Hex</a-select-option>
<a-select-option value="base64">Base64</a-select-option>
</a-select>
</a-form-item>
<template v-if="s.type === 'array'">
<a-form-item label="Rand">
<a-input-number v-model.number="s.rand" :min="0"></a-input-number>
</a-form-item>
<a-form-item label="Rand Range">
<a-input v-model.trim="s.randRange" placeholder="0-255"></a-input>
</a-form-item>
</template>
<a-form-item label="Packet" v-else>
<a-input v-model.trim="s.packet" placeholder="binary data" />
</a-form-item>
</template>
</template>
<template v-if="mask.type === 'xicmp'">
<a-form-item label="IP">
<a-input
@@ -1088,6 +1282,72 @@
</template>
</a-form>
</template>
<!-- quicParams only for xhttp H3 and hysteria -->
<template v-if="outbound.stream.network === 'xhttp' || outbound.protocol === Protocols.Hysteria">
<a-form-item label="QUIC Params">
<a-switch v-model="outbound.stream.finalmask.enableQuicParams"></a-switch>
</a-form-item>
<template v-if="outbound.stream.finalmask.enableQuicParams">
<a-form-item label="Congestion">
<a-select
v-model="outbound.stream.finalmask.quicParams.congestion"
:dropdown-class-name="themeSwitcher.currentTheme"
>
<a-select-option value="reno">Reno</a-select-option>
<a-select-option value="bbr">BBR</a-select-option>
<a-select-option value="brutal">Brutal</a-select-option>
<a-select-option value="force-brutal">Force Brutal</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="Debug">
<a-switch v-model="outbound.stream.finalmask.quicParams.debug"></a-switch>
</a-form-item>
<template v-if="['brutal','force-brutal'].includes(outbound.stream.finalmask.quicParams.congestion)">
<a-form-item label="Brutal Up">
<a-input v-model.trim="outbound.stream.finalmask.quicParams.brutalUp" placeholder="e.g. 60 mbps" />
</a-form-item>
<a-form-item label="Brutal Down">
<a-input v-model.trim="outbound.stream.finalmask.quicParams.brutalDown" placeholder="e.g. 60 mbps" />
</a-form-item>
</template>
<a-form-item label="UDP Hop">
<a-switch v-model="outbound.stream.finalmask.quicParams.hasUdpHop"></a-switch>
</a-form-item>
<template v-if="outbound.stream.finalmask.quicParams.hasUdpHop">
<a-form-item label="Hop Ports">
<a-input v-model.trim="outbound.stream.finalmask.quicParams.udpHop.ports" placeholder="e.g. 20000-50000" />
</a-form-item>
<a-form-item label="Hop Interval (s)">
<a-input-number v-model.number="outbound.stream.finalmask.quicParams.udpHop.interval" :min="5" />
</a-form-item>
</template>
<a-form-item label="Max Idle Timeout (s)">
<a-input-number v-model.number="outbound.stream.finalmask.quicParams.maxIdleTimeout" :min="4" :max="120" />
</a-form-item>
<a-form-item label="Keep Alive Period (s)">
<a-input-number v-model.number="outbound.stream.finalmask.quicParams.keepAlivePeriod" :min="0" :max="60" />
</a-form-item>
<a-form-item label="Disable Path MTU Dis">
<a-switch v-model="outbound.stream.finalmask.quicParams.disablePathMTUDiscovery"></a-switch>
</a-form-item>
<a-form-item label="Max Incoming Streams">
<a-input-number v-model.number="outbound.stream.finalmask.quicParams.maxIncomingStreams" :min="0" placeholder="0 = default" />
</a-form-item>
<a-form-item label="Init Stream Window">
<a-input-number v-model.number="outbound.stream.finalmask.quicParams.initStreamReceiveWindow" :min="0" placeholder="0 = default" />
</a-form-item>
<a-form-item label="Max Stream Window">
<a-input-number v-model.number="outbound.stream.finalmask.quicParams.maxStreamReceiveWindow" :min="0" placeholder="0 = default" />
</a-form-item>
<a-form-item label="Init Conn Window">
<a-input-number v-model.number="outbound.stream.finalmask.quicParams.initConnectionReceiveWindow" :min="0" placeholder="0 = default" />
</a-form-item>
<a-form-item label="Max Conn Window">
<a-input-number v-model.number="outbound.stream.finalmask.quicParams.maxConnectionReceiveWindow" :min="0" placeholder="0 = default" />
</a-form-item>
</template>
</template>
</template>
<!-- tls settings -->

View File

@@ -3,9 +3,199 @@
:colon="false"
:label-col="{ md: {span:8} }"
:wrapper-col="{ md: {span:14} }"
v-if="inbound.protocol == Protocols.HYSTERIA || inbound.stream.network == 'kcp'"
v-if="inbound.protocol == Protocols.HYSTERIA || ['kcp', 'xhttp', 'raw', 'tcp', 'httpupgrade', 'ws', 'grpc'].includes(inbound.stream.network)"
>
<a-divider :style="{ margin: '5px 0 0' }"></a-divider>
<!-- TCP Masks for raw/tcp/httpupgrade/ws/grpc/xhttp -->
<template v-if="['raw', 'tcp', 'httpupgrade', 'ws', 'grpc', 'xhttp'].includes(inbound.stream.network)">
<a-form-item label="TCP Masks">
<a-button
icon="plus"
type="primary"
size="small"
@click="inbound.stream.addTcpMask('fragment')"
></a-button>
</a-form-item>
<template v-if="inbound.stream.finalmask.tcp && inbound.stream.finalmask.tcp.length > 0">
<a-form
v-for="(mask, index) in inbound.stream.finalmask.tcp"
:key="index"
:colon="false"
:label-col="{ md: {span:8} }"
:wrapper-col="{ md: {span:14} }"
>
<a-divider :style="{ margin: '0' }">
TCP Mask [[ index + 1 ]]
<a-icon
type="delete"
@click="() => inbound.stream.delTcpMask(index)"
:style="{ color: 'rgb(255, 77, 79)', cursor: 'pointer' }"
></a-icon>
</a-divider>
<a-form-item label='{{ i18n "pages.xray.outbound.type" }}'>
<a-select
v-model="mask.type"
@change="(type) => { mask.settings = mask._getDefaultSettings(type, {}); }"
:dropdown-class-name="themeSwitcher.currentTheme"
>
<a-select-option value="fragment">Fragment</a-select-option>
<a-select-option value="header-custom">Header Custom</a-select-option>
<a-select-option value="sudoku">Sudoku</a-select-option>
</a-select>
</a-form-item>
<!-- Fragment settings -->
<template v-if="mask.type === 'fragment'">
<a-form-item label="Packets">
<a-select
v-model="mask.settings.packets"
:dropdown-class-name="themeSwitcher.currentTheme"
>
<a-select-option value="tlshello">tlshello</a-select-option>
<a-select-option value="1-3">1-3</a-select-option>
<a-select-option value="1-5">1-5</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="Length">
<a-input v-model.trim="mask.settings.length" placeholder="e.g. 100-200" />
</a-form-item>
<a-form-item label="Delay">
<a-input v-model.trim="mask.settings.delay" placeholder="e.g. 10-20" />
</a-form-item>
<a-form-item label="Max Split">
<a-input v-model.trim="mask.settings.maxSplit" placeholder="e.g. 3-6" />
</a-form-item>
</template>
<!-- Sudoku settings (TCP) -->
<template v-if="mask.type === 'sudoku'">
<a-form-item label="Password">
<a-input v-model.trim="mask.settings.password" placeholder="Obfuscation password" />
</a-form-item>
<a-form-item label="ASCII">
<a-input v-model.trim="mask.settings.ascii" placeholder="ASCII" />
</a-form-item>
<a-form-item label="Custom Table">
<a-input v-model.trim="mask.settings.customTable" placeholder="Custom Table" />
</a-form-item>
<a-form-item label="Custom Tables">
<a-input v-model.trim="mask.settings.customTables" placeholder="Custom Tables" />
</a-form-item>
<a-form-item label="Padding Min">
<a-input-number v-model.number="mask.settings.paddingMin" :min="0" />
</a-form-item>
<a-form-item label="Padding Max">
<a-input-number v-model.number="mask.settings.paddingMax" :min="0" />
</a-form-item>
</template>
<!-- Header Custom (TCP) clients/servers/errors are 2D arrays of groups -->
<template v-if="mask.type === 'header-custom'">
<!-- Clients -->
<a-form-item label="Clients">
<a-button
icon="plus"
type="primary"
size="small"
@click="mask.settings.clients.push([{delay: 0, rand: 0, randRange: '0-255', type: 'array', packet: []}])"
></a-button>
</a-form-item>
<template v-for="(group, gi) in mask.settings.clients" :key="'cg'+gi">
<a-divider :style="{ margin: '0' }">
Clients Group [[ gi + 1 ]]
<a-icon
type="delete"
@click="mask.settings.clients.splice(gi, 1)"
:style="{ color: 'rgb(255, 77, 79)', cursor: 'pointer', marginLeft: '8px' }"
></a-icon>
</a-divider>
<template v-for="(item, ii) in group" :key="'ci'+ii">
<a-form-item label='{{ i18n "pages.xray.outbound.type" }}'>
<a-select v-model="item.type" :dropdown-class-name="themeSwitcher.currentTheme"
@change="t => { if(t === 'base64') item.packet = RandomUtil.randomBase64(); else if(t === 'array') { item.rand = 0; item.packet = []; } else { item.packet = ''; } }">
<a-select-option value="array">Array</a-select-option>
<a-select-option value="str">String</a-select-option>
<a-select-option value="hex">Hex</a-select-option>
<a-select-option value="base64">Base64</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="Delay (ms)">
<a-input-number v-model.number="item.delay" :min="0" />
</a-form-item>
<template v-if="item.type === 'array'">
<a-form-item label="Rand">
<a-input-number v-model.number="item.rand" :min="0" />
</a-form-item>
<a-form-item label="Rand Range">
<a-input v-model.trim="item.randRange" placeholder="0-255" />
</a-form-item>
</template>
<a-form-item v-else label="Packet">
<a-input-group compact v-if="item.type === 'base64'">
<a-input v-model.trim="item.packet" placeholder="binary data" :style="{ width: 'calc(100% - 32px)' }" />
<a-button icon="reload" @click="item.packet = RandomUtil.randomBase64()" />
</a-input-group>
<a-input v-else v-model.trim="item.packet" placeholder="binary data" />
</a-form-item>
</template>
</template>
<!-- Servers -->
<a-form-item label="Servers">
<a-button
icon="plus"
type="primary"
size="small"
@click="mask.settings.servers.push([{delay: 0, rand: 0, randRange: '0-255', type: 'array', packet: []}])"
></a-button>
</a-form-item>
<template v-for="(group, gi) in mask.settings.servers" :key="'sg'+gi">
<a-divider :style="{ margin: '0' }">
Servers Group [[ gi + 1 ]]
<a-icon
type="delete"
@click="mask.settings.servers.splice(gi, 1)"
:style="{ color: 'rgb(255, 77, 79)', cursor: 'pointer', marginLeft: '8px' }"
></a-icon>
</a-divider>
<template v-for="(item, ii) in group" :key="'si'+ii">
<a-form-item label='{{ i18n "pages.xray.outbound.type" }}'>
<a-select v-model="item.type" :dropdown-class-name="themeSwitcher.currentTheme"
@change="t => { if(t === 'base64') item.packet = RandomUtil.randomBase64(); else if(t === 'array') { item.rand = 0; item.packet = []; } else { item.packet = ''; } }">
<a-select-option value="array">Array</a-select-option>
<a-select-option value="str">String</a-select-option>
<a-select-option value="hex">Hex</a-select-option>
<a-select-option value="base64">Base64</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="Delay (ms)">
<a-input-number v-model.number="item.delay" :min="0" />
</a-form-item>
<template v-if="item.type === 'array'">
<a-form-item label="Rand">
<a-input-number v-model.number="item.rand" :min="0" />
</a-form-item>
<a-form-item label="Rand Range">
<a-input v-model.trim="item.randRange" placeholder="0-255" />
</a-form-item>
</template>
<a-form-item v-else label="Packet">
<a-input-group compact v-if="item.type === 'base64'">
<a-input v-model.trim="item.packet" placeholder="binary data" :style="{ width: 'calc(100% - 32px)' }" />
<a-button icon="reload" @click="item.packet = RandomUtil.randomBase64()" />
</a-input-group>
<a-input v-else v-model.trim="item.packet" placeholder="binary data" />
</a-form-item>
</template>
</template>
</template>
</a-form>
</template>
</template>
<template v-if="inbound.protocol == Protocols.HYSTERIA || inbound.stream.network == 'kcp'">
<a-form-item label="UDP Masks">
<a-button
icon="plus"
@@ -64,17 +254,14 @@
>
<a-select-option value="xdns">xDNS</a-select-option>
<a-select-option value="xicmp">xICMP</a-select-option>
<a-select-option value="header-custom"
>Header Custom</a-select-option
>
<a-select-option value="header-custom">Header Custom</a-select-option>
<a-select-option value="noise">Noise</a-select-option>
<a-select-option value="sudoku">Sudoku</a-select-option>
</template>
</a-select>
</a-form-item>
<a-form-item
label="Password"
v-if="['mkcp-aes128gcm', 'salamander', 'sudoku'].includes(mask.type)"
v-if="['mkcp-aes128gcm', 'salamander'].includes(mask.type)"
>
<a-input
v-model.trim="mask.settings.password"
@@ -98,96 +285,17 @@
></a-select>
</a-form-item>
</template>
<template v-if="mask.type === 'header-custom'">
<a-form-item label="Client">
<a-icon
type="plus"
type="primary"
size="small"
@click="mask.settings.client.push({rand: 0, randRange: '0-255', type: 'array', packet: []})"
/>
</a-form-item>
<template v-for="(c, index) in mask.settings.client" :key="index">
<a-divider :style="{ margin: '0' }">
Client [[ index + 1 ]]
<a-icon
type="delete"
@click="() => mask.settings.client.splice(index, 1)"
:style="{ color: 'rgb(255, 77, 79)', cursor: 'pointer' }"
></a-icon>
</a-divider>
<a-form-item label="Rand">
<a-input-number v-model.number="c.rand" />
</a-form-item>
<a-form-item label="Rand Range">
<a-input v-model.trim="c.randRange" placeholder="0-255" />
</a-form-item>
<a-form-item label='{{ i18n "pages.xray.outbound.type" }}'>
<a-select
v-model="c.type"
:dropdown-class-name="themeSwitcher.currentTheme"
>
<a-select-option value="array">Array</a-select-option>
<a-select-option value="str">String</a-select-option>
<a-select-option value="hex">Hex</a-select-option>
<a-select-option value="base64">Base64</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="Packet">
<a-input v-model.trim="c.packet" placeholder="binary data" />
</a-form-item>
</template>
<a-divider :style="{ margin: '0' }"></a-divider>
<a-form-item label="Server">
<a-icon
type="plus"
type="primary"
size="small"
@click="mask.settings.server.push({rand: 0, randRange: '0-255', type: 'array', packet: []})"
/>
</a-form-item>
<template v-for="(s, index) in mask.settings.server" :key="index">
<a-divider :style="{ margin: '0' }">
Server [[ index + 1 ]]
<a-icon
type="delete"
@click="() => mask.settings.server.splice(index, 1)"
:style="{ color: 'rgb(255, 77, 79)', cursor: 'pointer' }"
></a-icon>
</a-divider>
<a-form-item label="Rand">
<a-input-number v-model.number="s.rand" />
</a-form-item>
<a-form-item label="Rand Range">
<a-input v-model.trim="s.randRange" placeholder="0-255" />
</a-form-item>
<a-form-item label='{{ i18n "pages.xray.outbound.type" }}'>
<a-select
v-model="s.type"
:dropdown-class-name="themeSwitcher.currentTheme"
>
<a-select-option value="array">Array</a-select-option>
<a-select-option value="str">String</a-select-option>
<a-select-option value="hex">Hex</a-select-option>
<a-select-option value="base64">Base64</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="Packet">
<a-input v-model.trim="s.packet" placeholder="binary data" />
</a-form-item>
</template>
</template>
<template v-if="mask.type === 'noise'">
<a-form-item label="Reset">
<a-input-number v-model.number="mask.settings.reset" :min="0" />
</a-form-item>
<a-form-item label="Noise">
<a-icon
type="plus"
<a-button
icon="plus"
type="primary"
size="small"
@click="mask.settings.noise.push({rand: '1-8192', randRange: '0-255', type: 'array', packet: '', delay: ''})"
/>
@click="mask.settings.noise.push({rand: '1-8192', randRange: '0-255', type: 'array', packet: [], delay: '10-20'})"
></a-button>
</a-form-item>
<template v-for="(n, index) in mask.settings.noise" :key="index">
<a-divider :style="{ margin: '0' }">
@@ -198,16 +306,11 @@
:style="{ color: 'rgb(255, 77, 79)', cursor: 'pointer' }"
></a-icon>
</a-divider>
<a-form-item label="Rand">
<a-input v-model.trim="n.rand" placeholder="1-8192" />
</a-form-item>
<a-form-item label="Rand Range">
<a-input v-model.trim="n.randRange" placeholder="0-255" />
</a-form-item>
<a-form-item label='{{ i18n "pages.xray.outbound.type" }}'>
<a-select
v-model="n.type"
:dropdown-class-name="themeSwitcher.currentTheme"
@change="t => { if(t === 'base64') n.packet = RandomUtil.randomBase64(); else if(t === 'array') n.packet = []; else n.packet = ''; }"
>
<a-select-option value="array">Array</a-select-option>
<a-select-option value="str">String</a-select-option>
@@ -215,36 +318,118 @@
<a-select-option value="base64">Base64</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="Packet">
<a-input v-model.trim="n.packet" placeholder="binary data" />
<template v-if="n.type === 'array'">
<a-form-item label="Rand">
<a-input v-model.trim="n.rand" placeholder="0 or 1-8192" />
</a-form-item>
<a-form-item label="Rand Range">
<a-input v-model.trim="n.randRange" placeholder="0-255" />
</a-form-item>
</template>
<a-form-item v-else label="Packet">
<a-input-group compact v-if="n.type === 'base64'">
<a-input v-model.trim="n.packet" placeholder="binary data" :style="{ width: 'calc(100% - 32px)' }" />
<a-button icon="reload" @click="n.packet = RandomUtil.randomBase64()" />
</a-input-group>
<a-input v-else v-model.trim="n.packet" placeholder="binary data" />
</a-form-item>
<a-form-item label="Delay">
<a-input v-model.trim="n.delay" placeholder="10-20" />
</a-form-item>
</template>
</template>
<template v-if="mask.type === 'sudoku'">
<a-form-item label="ASCII">
<a-input v-model.trim="mask.settings.ascii" placeholder="ASCII" />
<template v-if="mask.type === 'header-custom'">
<a-form-item label="Client">
<a-button
icon="plus"
type="primary"
size="small"
@click="mask.settings.client.push({rand: 0, randRange: '0-255', type: 'array', packet: []})"
></a-button>
</a-form-item>
<a-form-item label="Custom Table">
<a-input
v-model.trim="mask.settings.customTable"
placeholder="Custom Table"
/>
</a-form-item>
<a-form-item label="Custom Tables">
<a-input
v-model.trim="mask.settings.customTables"
placeholder="Custom Tables"
/>
</a-form-item>
<a-form-item label="Padding Min">
<a-input-number v-model.number="mask.settings.paddingMin" :min="0" />
</a-form-item>
<a-form-item label="Padding Max">
<a-input-number v-model.number="mask.settings.paddingMax" :min="0" />
<template v-for="(c, index) in mask.settings.client" :key="index">
<a-divider :style="{ margin: '0' }">
Client [[ index + 1 ]]
<a-icon
type="delete"
@click="mask.settings.client.splice(index, 1)"
:style="{ color: 'rgb(255, 77, 79)', cursor: 'pointer' }"
></a-icon>
</a-divider>
<a-form-item label='{{ i18n "pages.xray.outbound.type" }}'>
<a-select
v-model="c.type"
:dropdown-class-name="themeSwitcher.currentTheme"
@change="t => { if(t === 'base64') c.packet = RandomUtil.randomBase64(); else if(t === 'array') c.packet = []; else c.packet = ''; }"
>
<a-select-option value="array">Array</a-select-option>
<a-select-option value="str">String</a-select-option>
<a-select-option value="hex">Hex</a-select-option>
<a-select-option value="base64">Base64</a-select-option>
</a-select>
</a-form-item>
<template v-if="c.type === 'array'">
<a-form-item label="Rand">
<a-input-number v-model.number="c.rand" />
</a-form-item>
<a-form-item label="Rand Range">
<a-input v-model.trim="c.randRange" placeholder="0-255" />
</a-form-item>
</template>
<a-form-item v-else label="Packet">
<a-input-group compact v-if="c.type === 'base64'">
<a-input v-model.trim="c.packet" placeholder="binary data" :style="{ width: 'calc(100% - 32px)' }" />
<a-button icon="reload" @click="c.packet = RandomUtil.randomBase64()" />
</a-input-group>
<a-input v-else v-model.trim="c.packet" placeholder="binary data" />
</a-form-item>
</template>
<a-divider :style="{ margin: '0' }"></a-divider>
<a-form-item label="Server">
<a-button
icon="plus"
type="primary"
size="small"
@click="mask.settings.server.push({rand: 0, randRange: '0-255', type: 'array', packet: []})"
></a-button>
</a-form-item>
<template v-for="(s, index) in mask.settings.server" :key="index">
<a-divider :style="{ margin: '0' }">
Server [[ index + 1 ]]
<a-icon
type="delete"
@click="mask.settings.server.splice(index, 1)"
:style="{ color: 'rgb(255, 77, 79)', cursor: 'pointer' }"
></a-icon>
</a-divider>
<a-form-item label='{{ i18n "pages.xray.outbound.type" }}'>
<a-select
v-model="s.type"
:dropdown-class-name="themeSwitcher.currentTheme"
@change="t => { if(t === 'base64') s.packet = RandomUtil.randomBase64(); else if(t === 'array') s.packet = []; else s.packet = ''; }"
>
<a-select-option value="array">Array</a-select-option>
<a-select-option value="str">String</a-select-option>
<a-select-option value="hex">Hex</a-select-option>
<a-select-option value="base64">Base64</a-select-option>
</a-select>
</a-form-item>
<template v-if="s.type === 'array'">
<a-form-item label="Rand">
<a-input-number v-model.number="s.rand" />
</a-form-item>
<a-form-item label="Rand Range">
<a-input v-model.trim="s.randRange" placeholder="0-255" />
</a-form-item>
</template>
<a-form-item v-else label="Packet">
<a-input-group compact v-if="s.type === 'base64'">
<a-input v-model.trim="s.packet" placeholder="binary data" :style="{ width: 'calc(100% - 32px)' }" />
<a-button icon="reload" @click="s.packet = RandomUtil.randomBase64()" />
</a-input-group>
<a-input v-else v-model.trim="s.packet" placeholder="binary data" />
</a-form-item>
</template>
</template>
<template v-if="mask.type === 'xicmp'">
<a-form-item label="IP">
@@ -256,5 +441,72 @@
</template>
</a-form>
</template>
</template>
<!-- quicParams only for xhttp H3 and hysteria -->
<template v-if="inbound.protocol == Protocols.HYSTERIA || inbound.stream.network == 'xhttp'">
<a-form-item label="QUIC Params">
<a-switch v-model="inbound.stream.finalmask.enableQuicParams"></a-switch>
</a-form-item>
<template v-if="inbound.stream.finalmask.enableQuicParams">
<a-form-item label="Congestion">
<a-select
v-model="inbound.stream.finalmask.quicParams.congestion"
:dropdown-class-name="themeSwitcher.currentTheme"
>
<a-select-option value="reno">Reno</a-select-option>
<a-select-option value="bbr">BBR</a-select-option>
<a-select-option value="brutal">Brutal</a-select-option>
<a-select-option value="force-brutal">Force Brutal</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="Debug">
<a-switch v-model="inbound.stream.finalmask.quicParams.debug"></a-switch>
</a-form-item>
<template v-if="['brutal','force-brutal'].includes(inbound.stream.finalmask.quicParams.congestion)">
<a-form-item label="Brutal Up">
<a-input v-model.trim="inbound.stream.finalmask.quicParams.brutalUp" placeholder="e.g. 60 mbps" />
</a-form-item>
<a-form-item label="Brutal Down">
<a-input v-model.trim="inbound.stream.finalmask.quicParams.brutalDown" placeholder="e.g. 60 mbps" />
</a-form-item>
</template>
<a-form-item label="UDP Hop">
<a-switch v-model="inbound.stream.finalmask.quicParams.hasUdpHop"></a-switch>
</a-form-item>
<template v-if="inbound.stream.finalmask.quicParams.hasUdpHop">
<a-form-item label="Hop Ports">
<a-input v-model.trim="inbound.stream.finalmask.quicParams.udpHop.ports" placeholder="e.g. 20000-50000" />
</a-form-item>
<a-form-item label="Hop Interval (s)">
<a-input-number v-model.number="inbound.stream.finalmask.quicParams.udpHop.interval" :min="5" />
</a-form-item>
</template>
<a-form-item label="Max Idle Timeout (s)">
<a-input-number v-model.number="inbound.stream.finalmask.quicParams.maxIdleTimeout" :min="4" :max="120" />
</a-form-item>
<a-form-item label="Keep Alive Period (s)">
<a-input-number v-model.number="inbound.stream.finalmask.quicParams.keepAlivePeriod" :min="0" :max="60" />
</a-form-item>
<a-form-item label="Disable Path MTU Dis">
<a-switch v-model="inbound.stream.finalmask.quicParams.disablePathMTUDiscovery"></a-switch>
</a-form-item>
<a-form-item label="Max Incoming Streams">
<a-input-number v-model.number="inbound.stream.finalmask.quicParams.maxIncomingStreams" :min="0" placeholder="0 = default" />
</a-form-item>
<a-form-item label="Init Stream Window">
<a-input-number v-model.number="inbound.stream.finalmask.quicParams.initStreamReceiveWindow" :min="0" placeholder="0 = default" />
</a-form-item>
<a-form-item label="Max Stream Window">
<a-input-number v-model.number="inbound.stream.finalmask.quicParams.maxStreamReceiveWindow" :min="0" placeholder="0 = default" />
</a-form-item>
<a-form-item label="Init Conn Window">
<a-input-number v-model.number="inbound.stream.finalmask.quicParams.initConnectionReceiveWindow" :min="0" placeholder="0 = default" />
</a-form-item>
<a-form-item label="Max Conn Window">
<a-input-number v-model.number="inbound.stream.finalmask.quicParams.maxConnectionReceiveWindow" :min="0" placeholder="0 = default" />
</a-form-item>
</template>
</template>
</a-form>
{{end}}

View File

@@ -262,6 +262,10 @@
<a-icon type="usergroup-add"></a-icon>
{{ i18n "pages.client.bulk"}}
</a-menu-item>
<a-menu-item key="copyClients">
<a-icon type="copy"></a-icon>
{{ i18n "pages.client.copyFromInbound"}}
</a-menu-item>
<a-menu-item key="resetClients">
<a-icon type="file-done"></a-icon>
{{ i18n
@@ -777,6 +781,218 @@
{{template "modals/inboundInfoModal"}}
{{template "modals/clientsModal"}}
{{template "modals/clientsBulkModal"}}
<a-modal id="copy-clients-modal"
:title="copyClientsModal.title"
:visible="copyClientsModal.visible"
:confirm-loading="copyClientsModal.confirmLoading"
ok-text='{{ i18n "pages.client.copySelected" }}'
cancel-text='{{ i18n "close" }}'
:class="themeSwitcher.currentTheme"
:closable="true"
:mask-closable="false"
@ok="() => copyClientsModal.ok()"
@cancel="() => copyClientsModal.close()"
width="900px">
<a-space direction="vertical" style="width: 100%;">
<div>
<div style="margin-bottom: 6px;">{{ i18n "pages.client.copySource" }}</div>
<a-select v-model="copyClientsModal.sourceInboundId"
style="width: 100%;"
:dropdown-class-name="themeSwitcher.currentTheme"
@change="id => copyClientsModal.onSourceChange(id)">
<a-select-option v-for="item in copyClientsModal.sources"
:key="item.id"
:value="item.id">
[[ item.label ]]
</a-select-option>
</a-select>
</div>
<div v-if="copyClientsModal.sourceInboundId">
<a-space style="margin-bottom: 10px;">
<a-button size="small" @click="() => copyClientsModal.selectAll()">{{ i18n "pages.client.selectAll" }}</a-button>
<a-button size="small" @click="() => copyClientsModal.clearAll()">{{ i18n "pages.client.clearAll" }}</a-button>
</a-space>
<a-table :columns="copyClientsColumns"
:data-source="copyClientsModal.sourceClients"
:pagination="false"
size="small"
:row-key="item => item.email"
:scroll="{ y: 280 }">
<template slot="emailCheckbox" slot-scope="text, record">
<a-checkbox :checked="copyClientsModal.selectedEmails.includes(record.email)"
@change="event => copyClientsModal.toggleEmail(record.email, event.target.checked)">
[[ record.email ]]
</a-checkbox>
</template>
</a-table>
</div>
<div v-if="copyClientsModal.showFlow">
<div style="margin-bottom: 6px;">{{ i18n "pages.client.copyFlowLabel" }}</div>
<a-select v-model="copyClientsModal.flow"
style="width: 100%;"
:dropdown-class-name="themeSwitcher.currentTheme"
allow-clear>
<a-select-option value="">{{ i18n "none" }}</a-select-option>
<a-select-option value="xtls-rprx-vision">xtls-rprx-vision</a-select-option>
<a-select-option value="xtls-rprx-vision-udp443">xtls-rprx-vision-udp443</a-select-option>
</a-select>
<div style="margin-top: 4px; font-size: 12px; opacity: 0.7;">
{{ i18n "pages.client.copyFlowHint" }}
</div>
</div>
<div v-if="copyClientsModal.selectedEmails.length > 0">
<div style="margin-bottom: 4px;">{{ i18n "pages.client.copyEmailPreview" }}</div>
<div style="max-height: 120px; overflow-y: auto;">
<a-tag v-for="preview in previewEmails" :key="preview" style="margin-bottom: 4px;">
[[ preview ]]
</a-tag>
</div>
</div>
</a-space>
</a-modal>
<script>
const copyClientsColumns = [
{ title: '{{ i18n "pages.inbounds.email" }}', width: 300, scopedSlots: { customRender: 'emailCheckbox' } },
{ title: '{{ i18n "pages.inbounds.traffic" }}', width: 160, dataIndex: 'trafficLabel' },
{ title: '{{ i18n "pages.inbounds.expireDate" }}', width: 180, dataIndex: 'expiryLabel' },
];
const copyClientsModal = {
visible: false,
confirmLoading: false,
title: '',
targetInboundId: 0,
targetInboundRemark: '',
targetProtocol: '',
showFlow: false,
flow: '',
sourceInboundId: undefined,
sources: [],
sourceClients: [],
selectedEmails: [],
show(targetDbInbound) {
if (!targetDbInbound) return;
const sources = app.dbInbounds
.filter(row => row.id !== targetDbInbound.id && typeof row.isMultiUser === 'function' && row.isMultiUser())
.map(row => {
const clients = app.getInboundClients(row) || [];
return { id: row.id, label: `${row.remark} (${row.protocol}, ${clients.length})` };
});
let showFlow = false;
try {
const targetInbound = targetDbInbound.toInbound();
showFlow = !!(targetInbound && typeof targetInbound.canEnableTlsFlow === 'function' && targetInbound.canEnableTlsFlow());
} catch (e) {
showFlow = false;
}
copyClientsModal.targetInboundId = targetDbInbound.id;
copyClientsModal.targetInboundRemark = targetDbInbound.remark;
copyClientsModal.targetProtocol = targetDbInbound.protocol;
copyClientsModal.showFlow = showFlow;
copyClientsModal.flow = '';
copyClientsModal.title = `{{ i18n "pages.client.copyToInbound" }} ${targetDbInbound.remark}`;
copyClientsModal.sources = sources;
copyClientsModal.sourceInboundId = undefined;
copyClientsModal.sourceClients = [];
copyClientsModal.selectedEmails = [];
copyClientsModal.confirmLoading = false;
copyClientsModal.visible = true;
},
close() {
copyClientsModal.visible = false;
copyClientsModal.confirmLoading = false;
},
onSourceChange(sourceInboundId) {
copyClientsModal.selectedEmails = [];
const sourceInbound = app.dbInbounds.find(row => row.id === Number(sourceInboundId));
if (!sourceInbound) {
copyClientsModal.sourceClients = [];
return;
}
const sourceClients = app.getInboundClients(sourceInbound) || [];
copyClientsModal.sourceClients = sourceClients.map(client => {
const stats = app.getClientStats(sourceInbound, client.email);
const used = stats ? ((stats.up || 0) + (stats.down || 0)) : 0;
let expiryLabel = '{{ i18n "unlimited" }}';
if (client.expiryTime > 0) {
expiryLabel = IntlUtil.formatDate(client.expiryTime);
} else if (client.expiryTime < 0) {
expiryLabel = `${-client.expiryTime / 86400000}d`;
}
return {
email: client.email,
trafficLabel: SizeFormatter.sizeFormat(used),
expiryLabel,
};
});
},
toggleEmail(email, checked) {
const selected = copyClientsModal.selectedEmails.slice();
if (checked) {
if (!selected.includes(email)) selected.push(email);
} else {
const idx = selected.indexOf(email);
if (idx >= 0) selected.splice(idx, 1);
}
copyClientsModal.selectedEmails = selected;
},
selectAll() {
copyClientsModal.selectedEmails = copyClientsModal.sourceClients.map(item => item.email);
},
clearAll() {
copyClientsModal.selectedEmails = [];
},
async ok() {
if (!copyClientsModal.sourceInboundId) {
app.$message.error('{{ i18n "pages.client.copySelectSourceFirst" }}');
return;
}
copyClientsModal.confirmLoading = true;
const payload = {
sourceInboundId: copyClientsModal.sourceInboundId,
clientEmails: copyClientsModal.selectedEmails,
};
if (copyClientsModal.showFlow && copyClientsModal.flow) {
payload.flow = copyClientsModal.flow;
}
try {
const msg = await HttpUtil.post(`/panel/api/inbounds/${copyClientsModal.targetInboundId}/copyClients`, payload);
if (!msg || !msg.success) return;
const obj = msg.obj || {};
const addedCount = (obj.added || []).length;
const errorList = obj.errors || [];
if (addedCount > 0) {
app.$message.success(`{{ i18n "pages.client.copyResultSuccess" }}: ${addedCount}`);
} else {
app.$message.warning('{{ i18n "pages.client.copyResultNone" }}');
}
if (errorList.length > 0) {
app.$message.error(`{{ i18n "pages.client.copyResultErrors" }}: ${errorList.join('; ')}`);
}
copyClientsModal.close();
await app.getDBInbounds();
} finally {
copyClientsModal.confirmLoading = false;
}
},
};
const copyClientsModalApp = new Vue({
delimiters: ['[[', ']]'],
el: '#copy-clients-modal',
data: {
copyClientsModal,
copyClientsColumns,
themeSwitcher,
},
computed: {
previewEmails() {
if (!this.copyClientsModal.targetInboundId) return [];
return this.copyClientsModal.selectedEmails.map(email => `${email}_${this.copyClientsModal.targetInboundId}`);
},
},
});
</script>
<script>
const columns = [{
title: "ID",
@@ -948,24 +1164,23 @@
if (!msg.success) {
return;
}
with (msg.obj) {
this.expireDiff = expireDiff * 86400000;
this.trafficDiff = trafficDiff * 1073741824;
this.defaultCert = defaultCert;
this.defaultKey = defaultKey;
this.tgBotEnable = tgBotEnable;
this.subSettings = {
enable: subEnable,
subTitle: subTitle,
subURI: subURI,
subJsonURI: subJsonURI,
subJsonEnable: subJsonEnable,
};
this.pageSize = pageSize;
this.remarkModel = remarkModel;
this.datepicker = datepicker;
this.ipLimitEnable = ipLimitEnable;
}
const settings = msg.obj || {};
this.expireDiff = settings.expireDiff * 86400000;
this.trafficDiff = settings.trafficDiff * 1073741824;
this.defaultCert = settings.defaultCert;
this.defaultKey = settings.defaultKey;
this.tgBotEnable = settings.tgBotEnable;
this.subSettings = {
enable: settings.subEnable,
subTitle: settings.subTitle,
subURI: settings.subURI,
subJsonURI: settings.subJsonURI,
subJsonEnable: settings.subJsonEnable,
};
this.pageSize = settings.pageSize;
this.remarkModel = settings.remarkModel;
this.datepicker = settings.datepicker;
this.ipLimitEnable = settings.ipLimitEnable;
},
setInbounds(dbInbounds) {
this.inbounds.splice(0);
@@ -1135,6 +1350,9 @@
case "addBulkClient":
this.openAddBulkClient(dbInbound.id)
break;
case "copyClients":
copyClientsModal.show(dbInbound);
break;
case "export":
this.inboundLinks(dbInbound.id);
break;

View File

@@ -513,9 +513,19 @@
<div v-html="infoModal.links[index].replaceAll(`\n`,`<br />`)"
:style="{ borderRadius: '1rem', padding: '0.5rem' }" class="client-table-odd-row">
</div>
<a-divider orientation="center">Link</a-divider>
<tr-info-title class="tr-info-title">
<a-tag color="green">Link</a-tag>
<a-tooltip title='{{ i18n "copy" }}'>
<a-button :style="{ minWidth: '24px' }" size="small" icon="snippets"
@click="copy(infoModal.wireguardLinks[index])"></a-button>
</a-tooltip>
</tr-info-title>
<code :style="{ display: 'block', whiteSpace: 'normal', wordBreak: 'break-all' }">[[ infoModal.wireguardLinks[index] ]]</code>
</tr-info-row>
</td>
</tr>
</template>
</table>
</template>
</template>
@@ -603,6 +613,7 @@
upStats: 0,
downStats: 0,
links: [],
wireguardLinks: [],
index: null,
isExpired: false,
subLink: '',
@@ -633,9 +644,11 @@
}
}
if (this.inbound.protocol == Protocols.WIREGUARD) {
this.links = this.inbound.genInboundLinks(dbInbound.remark).split('\r\n')
this.links = this.inbound.genWireguardConfigs(dbInbound.remark).split('\r\n')
this.wireguardLinks = this.inbound.genWireguardLinks(dbInbound.remark).split('\r\n')
} else {
this.links = this.inbound.genAllLinks(this.dbInbound.remark, app.remarkModel, this.clientSettings);
this.wireguardLinks = [];
}
if (this.clientSettings) {
if (this.clientSettings.subId) {

View File

@@ -6,6 +6,23 @@
<script src="{{ .base_path }}assets/js/util/index.js?{{ .cur_ver }}"></script>
<script src="{{ .base_path }}assets/qrcode/qrious2.min.js?{{ .cur_ver }}"></script>
<style>
.subscription-page tr-qr-box.qr-box {
display: inline-flex;
flex-direction: column;
align-items: center;
width: 220px;
}
.subscription-page tr-qr-box.qr-box .qr-tag {
width: 100%;
justify-content: center;
}
.subscription-page tr-qr-box.qr-box .qr-bg,
.subscription-page tr-qr-box.qr-box .qr-bg-sub {
margin-inline: auto;
}
.subscription-page .subscription-link-box {
cursor: pointer;
border-radius: 12px;
@@ -193,8 +210,6 @@
</div>
</div>
</div>
</div>
<br />
<a-form layout="vertical">

View File

@@ -1077,7 +1077,8 @@
return;
}
if (!msg.obj) return;
const { geoip = [], geosite = [] } = msg.obj;
const geoip = msg.obj.geoip ?? [];
const geosite = msg.obj.geosite ?? [];
const geoSuffix = this.customGeoAliasLabelSuffix || '';
geoip.forEach((x) => {
this.settingsData.IPsOptions.push({

View File

@@ -35,6 +35,25 @@ var job *CheckClientIpJob
const defaultXrayAPIPort = 62789
// ipStaleAfterSeconds controls how long a client IP kept in the
// per-client tracking table (model.InboundClientIps.Ips) is considered
// still "active" before it's evicted during the next scan.
//
// Without this eviction, an IP that connected once and then went away
// keeps sitting in the table with its old timestamp. Because the
// excess-IP selector sorts ascending ("oldest wins, newest loses") to
// protect the original/current connections, that stale entry keeps
// occupying a slot and the IP that is *actually* currently using the
// config gets classified as "new excess" and banned by fail2ban on
// every single run — producing the continuous ban loop from #4077.
//
// 30 minutes is chosen so an actively-streaming client (where xray
// emits a fresh `accepted` log line whenever it opens a new TCP) will
// always refresh its timestamp well within the window, but a client
// that has really stopped using the config will drop out of the table
// in a bounded time and free its slot.
const ipStaleAfterSeconds = int64(30 * 60)
// NewCheckClientIpJob creates a new client IP monitoring job instance.
func NewCheckClientIpJob() *CheckClientIpJob {
job = new(CheckClientIpJob)
@@ -202,6 +221,62 @@ func (j *CheckClientIpJob) processLogFile() bool {
return shouldCleanLog
}
// mergeClientIps combines the persisted (old) and freshly observed (new)
// IP-with-timestamp lists for a single client into a map. An entry is
// dropped if its last-seen timestamp is older than staleCutoff.
//
// Extracted as a helper so updateInboundClientIps can stay DB-oriented
// and the merge policy can be exercised by a unit test.
func mergeClientIps(old, new []IPWithTimestamp, staleCutoff int64) map[string]int64 {
ipMap := make(map[string]int64, len(old)+len(new))
for _, ipTime := range old {
if ipTime.Timestamp < staleCutoff {
continue
}
ipMap[ipTime.IP] = ipTime.Timestamp
}
for _, ipTime := range new {
if ipTime.Timestamp < staleCutoff {
continue
}
if existingTime, ok := ipMap[ipTime.IP]; !ok || ipTime.Timestamp > existingTime {
ipMap[ipTime.IP] = ipTime.Timestamp
}
}
return ipMap
}
// partitionLiveIps splits the merged ip map into live (seen in the
// current scan) and historical (only in the db blob, still inside the
// staleness window).
//
// only live ips count toward the per-client limit. historical ones stay
// in the db so the panel keeps showing them, but they must not take a
// protected slot. the 30min cutoff alone isn't tight enough: an ip that
// stopped connecting a few minutes ago still looks fresh to
// mergeClientIps, and since the over-limit picker sorts ascending and
// keeps the oldest, those idle entries used to win the slot while the
// ip actually connecting got classified as excess and sent to fail2ban
// every tick. see #4077 / #4091.
//
// live is sorted ascending so the "protect original, ban newcomer"
// rule still holds when several ips are really connecting at once.
func partitionLiveIps(ipMap map[string]int64, observedThisScan map[string]bool) (live, historical []IPWithTimestamp) {
live = make([]IPWithTimestamp, 0, len(observedThisScan))
historical = make([]IPWithTimestamp, 0, len(ipMap))
for ip, ts := range ipMap {
entry := IPWithTimestamp{IP: ip, Timestamp: ts}
if observedThisScan[ip] {
live = append(live, entry)
} else {
historical = append(historical, entry)
}
}
sort.Slice(live, func(i, j int) bool { return live[i].Timestamp < live[j].Timestamp })
sort.Slice(historical, func(i, j int) bool { return historical[i].Timestamp < historical[j].Timestamp })
return live, historical
}
func (j *CheckClientIpJob) checkFail2BanInstalled() bool {
cmd := "fail2ban-client"
args := []string{"-h"}
@@ -310,26 +385,17 @@ func (j *CheckClientIpJob) updateInboundClientIps(inboundClientIps *model.Inboun
json.Unmarshal([]byte(inboundClientIps.Ips), &oldIpsWithTime)
}
// Merge old and new IPs, keeping the latest timestamp for each IP
ipMap := make(map[string]int64)
for _, ipTime := range oldIpsWithTime {
ipMap[ipTime.IP] = ipTime.Timestamp
}
for _, ipTime := range newIpsWithTime {
if existingTime, ok := ipMap[ipTime.IP]; !ok || ipTime.Timestamp > existingTime {
ipMap[ipTime.IP] = ipTime.Timestamp
}
}
// Merge old and new IPs, evicting entries that haven't been
// re-observed in a while. See mergeClientIps / #4077 for why.
ipMap := mergeClientIps(oldIpsWithTime, newIpsWithTime, time.Now().Unix()-ipStaleAfterSeconds)
// Convert back to slice and sort by timestamp (oldest first)
// This ensures we always protect the original/current connections and ban new excess ones.
allIps := make([]IPWithTimestamp, 0, len(ipMap))
for ip, timestamp := range ipMap {
allIps = append(allIps, IPWithTimestamp{IP: ip, Timestamp: timestamp})
// only ips seen in this scan count toward the limit. see
// partitionLiveIps.
observedThisScan := make(map[string]bool, len(newIpsWithTime))
for _, ipTime := range newIpsWithTime {
observedThisScan[ipTime.IP] = true
}
sort.Slice(allIps, func(i, j int) bool {
return allIps[i].Timestamp < allIps[j].Timestamp // Ascending order (oldest first)
})
liveIps, historicalIps := partitionLiveIps(ipMap, observedThisScan)
shouldCleanLog := false
j.disAllowedIps = []string{}
@@ -344,35 +410,39 @@ func (j *CheckClientIpJob) updateInboundClientIps(inboundClientIps *model.Inboun
log.SetOutput(logIpFile)
log.SetFlags(log.LstdFlags)
// Check if we exceed the limit
if len(allIps) > limitIp {
// historical db-only ips are excluded from this count on purpose.
var keptLive []IPWithTimestamp
if len(liveIps) > limitIp {
shouldCleanLog = true
// Keep the oldest IPs (currently active connections) and ban the new excess ones.
keptIps := allIps[:limitIp]
bannedIps := allIps[limitIp:]
// protect the oldest live ip, ban newcomers.
keptLive = liveIps[:limitIp]
bannedLive := liveIps[limitIp:]
// Log banned IPs in the format fail2ban filters expect: [LIMIT_IP] Email = X || Disconnecting OLD IP = Y || Timestamp = Z
for _, ipTime := range bannedIps {
// 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)
}
// Actually disconnect banned IPs by temporarily removing and re-adding user
// This forces Xray to drop existing connections from banned IPs
if len(bannedIps) > 0 {
j.disconnectClientTemporarily(inbound, clientEmail, clients)
}
// Update database with only the currently active (kept) IPs
jsonIps, _ := json.Marshal(keptIps)
inboundClientIps.Ips = string(jsonIps)
// force xray to drop existing connections from banned ips
j.disconnectClientTemporarily(inbound, clientEmail, clients)
} else {
// Under limit, save all IPs
jsonIps, _ := json.Marshal(allIps)
inboundClientIps.Ips = string(jsonIps)
keptLive = liveIps
}
// keep kept-live + historical in the blob so the panel keeps showing
// recently seen ips. banned live ips are already in the fail2ban log
// and will reappear in the next scan if they reconnect.
dbIps := make([]IPWithTimestamp, 0, len(keptLive)+len(historicalIps))
dbIps = append(dbIps, keptLive...)
dbIps = append(dbIps, historicalIps...)
jsonIps, _ := json.Marshal(dbIps)
inboundClientIps.Ips = string(jsonIps)
db := database.GetDB()
err = db.Save(inboundClientIps).Error
if err != nil {
@@ -381,7 +451,7 @@ func (j *CheckClientIpJob) updateInboundClientIps(inboundClientIps *model.Inboun
}
if len(j.disAllowedIps) > 0 {
logger.Infof("[LIMIT_IP] Client %s: Kept %d current IPs, queued %d new IPs for fail2ban", clientEmail, limitIp, len(j.disAllowedIps))
logger.Infof("[LIMIT_IP] Client %s: Kept %d live IPs, queued %d new IPs for fail2ban", clientEmail, len(keptLive), len(j.disAllowedIps))
}
return shouldCleanLog

View File

@@ -0,0 +1,250 @@
package job
import (
"encoding/json"
"log"
"os"
"path/filepath"
"sync"
"testing"
"time"
"github.com/mhsanaei/3x-ui/v2/database"
"github.com/mhsanaei/3x-ui/v2/database/model"
xuilogger "github.com/mhsanaei/3x-ui/v2/logger"
"github.com/op/go-logging"
)
// 3x-ui logger must be initialised once before any code path that can
// log a warning. otherwise log.Warningf panics on a nil logger.
var loggerInitOnce sync.Once
// setupIntegrationDB wires a temp sqlite db and log folder so
// updateInboundClientIps can run end to end. closes the db before
// TempDir cleanup so windows doesn't complain about the file being in
// use.
func setupIntegrationDB(t *testing.T) {
t.Helper()
loggerInitOnce.Do(func() {
xuilogger.InitLogger(logging.ERROR)
})
dbDir := t.TempDir()
logDir := t.TempDir()
t.Setenv("XUI_DB_FOLDER", dbDir)
t.Setenv("XUI_LOG_FOLDER", logDir)
// updateInboundClientIps calls log.SetOutput on the package global,
// which would leak to other tests in the same binary.
origLogWriter := log.Writer()
origLogFlags := log.Flags()
t.Cleanup(func() {
log.SetOutput(origLogWriter)
log.SetFlags(origLogFlags)
})
if err := database.InitDB(filepath.Join(dbDir, "3x-ui.db")); err != nil {
t.Fatalf("database.InitDB failed: %v", err)
}
// LIFO cleanup order: this runs before t.TempDir's own cleanup.
t.Cleanup(func() {
if err := database.CloseDB(); err != nil {
t.Logf("database.CloseDB warning: %v", err)
}
})
}
// seed an inbound whose settings json has a single client with the
// given email and ip limit.
func seedInboundWithClient(t *testing.T, tag, email string, limitIp int) {
t.Helper()
settings := map[string]any{
"clients": []map[string]any{
{
"email": email,
"limitIp": limitIp,
"enable": true,
},
},
}
settingsJSON, err := json.Marshal(settings)
if err != nil {
t.Fatalf("marshal settings: %v", err)
}
inbound := &model.Inbound{
Tag: tag,
Enable: true,
Protocol: model.VLESS,
Port: 4321,
Settings: string(settingsJSON),
}
if err := database.GetDB().Create(inbound).Error; err != nil {
t.Fatalf("seed inbound: %v", err)
}
}
// seed an InboundClientIps row with the given blob.
func seedClientIps(t *testing.T, email string, ips []IPWithTimestamp) *model.InboundClientIps {
t.Helper()
blob, err := json.Marshal(ips)
if err != nil {
t.Fatalf("marshal ips: %v", err)
}
row := &model.InboundClientIps{
ClientEmail: email,
Ips: string(blob),
}
if err := database.GetDB().Create(row).Error; err != nil {
t.Fatalf("seed InboundClientIps: %v", err)
}
return row
}
// read the persisted blob and parse it back.
func readClientIps(t *testing.T, email string) []IPWithTimestamp {
t.Helper()
row := &model.InboundClientIps{}
if err := database.GetDB().Where("client_email = ?", email).First(row).Error; err != nil {
t.Fatalf("read InboundClientIps for %s: %v", email, err)
}
if row.Ips == "" {
return nil
}
var out []IPWithTimestamp
if err := json.Unmarshal([]byte(row.Ips), &out); err != nil {
t.Fatalf("unmarshal Ips blob %q: %v", row.Ips, err)
}
return out
}
// make a lookup map so asserts don't depend on slice order.
func ipSet(entries []IPWithTimestamp) map[string]int64 {
out := make(map[string]int64, len(entries))
for _, e := range entries {
out[e.IP] = e.Timestamp
}
return out
}
// #4091 repro: client has limit=3, db still holds 3 idle ips from a
// few minutes ago, only one live ip is actually connecting. pre-fix:
// live ip got banned every tick and never appeared in the panel.
// post-fix: no ban, live ip persisted, historical ips still visible.
func TestUpdateInboundClientIps_LiveIpNotBannedByStillFreshHistoricals(t *testing.T) {
setupIntegrationDB(t)
const email = "pr4091-repro"
seedInboundWithClient(t, "inbound-pr4091", email, 3)
now := time.Now().Unix()
// idle but still within the 30min staleness window.
row := seedClientIps(t, email, []IPWithTimestamp{
{IP: "10.0.0.1", Timestamp: now - 20*60},
{IP: "10.0.0.2", Timestamp: now - 15*60},
{IP: "10.0.0.3", Timestamp: now - 10*60},
})
j := NewCheckClientIpJob()
// the one that's actually connecting (user's 128.71.x.x).
live := []IPWithTimestamp{
{IP: "128.71.1.1", Timestamp: now},
}
shouldCleanLog := j.updateInboundClientIps(row, email, live)
if shouldCleanLog {
t.Fatalf("shouldCleanLog must be false, nothing should have been banned with 1 live ip under limit 3")
}
if len(j.disAllowedIps) != 0 {
t.Fatalf("disAllowedIps must be empty, got %v", j.disAllowedIps)
}
persisted := ipSet(readClientIps(t, email))
for _, want := range []string{"128.71.1.1", "10.0.0.1", "10.0.0.2", "10.0.0.3"} {
if _, ok := persisted[want]; !ok {
t.Errorf("expected %s to be persisted in inbound_client_ips.ips; got %v", want, persisted)
}
}
if got := persisted["128.71.1.1"]; got != now {
t.Errorf("live ip timestamp should match the scan timestamp %d, got %d", now, got)
}
// 3xipl.log must not contain a ban line.
if info, err := os.Stat(readIpLimitLogPath()); err == nil && info.Size() > 0 {
body, _ := os.ReadFile(readIpLimitLogPath())
t.Fatalf("3xipl.log should be empty when no ips are banned, got:\n%s", body)
}
}
// opposite invariant: when several ips are actually live and exceed
// the limit, the newcomer still gets banned.
func TestUpdateInboundClientIps_ExcessLiveIpIsStillBanned(t *testing.T) {
setupIntegrationDB(t)
const email = "pr4091-abuse"
seedInboundWithClient(t, "inbound-pr4091-abuse", email, 1)
now := time.Now().Unix()
row := seedClientIps(t, email, []IPWithTimestamp{
{IP: "10.1.0.1", Timestamp: now - 60}, // original connection
})
j := NewCheckClientIpJob()
// both live, limit=1. use distinct timestamps so sort-by-timestamp
// is deterministic: 10.1.0.1 is the original (older), 192.0.2.9
// joined later and must get banned.
live := []IPWithTimestamp{
{IP: "10.1.0.1", Timestamp: now - 5},
{IP: "192.0.2.9", Timestamp: now},
}
shouldCleanLog := j.updateInboundClientIps(row, email, live)
if !shouldCleanLog {
t.Fatalf("shouldCleanLog must be true when the live set exceeds the limit")
}
if len(j.disAllowedIps) != 1 || j.disAllowedIps[0] != "192.0.2.9" {
t.Fatalf("expected 192.0.2.9 to be banned; disAllowedIps = %v", j.disAllowedIps)
}
persisted := ipSet(readClientIps(t, email))
if _, ok := persisted["10.1.0.1"]; !ok {
t.Errorf("original IP 10.1.0.1 must still be persisted; got %v", persisted)
}
if _, ok := persisted["192.0.2.9"]; ok {
t.Errorf("banned IP 192.0.2.9 must NOT be persisted; got %v", persisted)
}
// 3xipl.log must contain the ban line in the exact fail2ban format.
body, err := os.ReadFile(readIpLimitLogPath())
if err != nil {
t.Fatalf("read 3xipl.log: %v", err)
}
wantSubstr := "[LIMIT_IP] Email = pr4091-abuse || Disconnecting OLD IP = 192.0.2.9"
if !contains(string(body), wantSubstr) {
t.Fatalf("3xipl.log missing expected ban line %q\nfull log:\n%s", wantSubstr, body)
}
}
// readIpLimitLogPath reads the 3xipl.log path the same way the job
// does via xray.GetIPLimitLogPath but without importing xray here
// just for the path helper (which would pull a lot more deps into the
// test binary). The env-derived log folder is deterministic.
func readIpLimitLogPath() string {
folder := os.Getenv("XUI_LOG_FOLDER")
if folder == "" {
folder = filepath.Join(".", "log")
}
return filepath.Join(folder, "3xipl.log")
}
func contains(haystack, needle string) bool {
for i := 0; i+len(needle) <= len(haystack); i++ {
if haystack[i:i+len(needle)] == needle {
return true
}
}
return false
}

View File

@@ -0,0 +1,146 @@
package job
import (
"reflect"
"testing"
)
func TestMergeClientIps_EvictsStaleOldEntries(t *testing.T) {
// #4077: after a ban expires, a single IP that reconnects used to get
// banned again immediately because a long-disconnected IP stayed in the
// DB with an ancient timestamp and kept "protecting" itself against
// eviction. Guard against that regression here.
old := []IPWithTimestamp{
{IP: "1.1.1.1", Timestamp: 100}, // stale — client disconnected long ago
{IP: "2.2.2.2", Timestamp: 1900}, // fresh — still connecting
}
new := []IPWithTimestamp{
{IP: "2.2.2.2", Timestamp: 2000}, // same IP, newer log line
}
got := mergeClientIps(old, new, 1000)
want := map[string]int64{"2.2.2.2": 2000}
if !reflect.DeepEqual(got, want) {
t.Fatalf("stale 1.1.1.1 should have been dropped\ngot: %v\nwant: %v", got, want)
}
}
func TestMergeClientIps_KeepsFreshOldEntriesUnchanged(t *testing.T) {
// Backwards-compat: entries that aren't stale are still carried forward,
// so enforcement survives access-log rotation.
old := []IPWithTimestamp{
{IP: "1.1.1.1", Timestamp: 1500},
}
got := mergeClientIps(old, nil, 1000)
want := map[string]int64{"1.1.1.1": 1500}
if !reflect.DeepEqual(got, want) {
t.Fatalf("fresh old IP should have been retained\ngot: %v\nwant: %v", got, want)
}
}
func TestMergeClientIps_PrefersLaterTimestampForSameIp(t *testing.T) {
old := []IPWithTimestamp{{IP: "1.1.1.1", Timestamp: 1500}}
new := []IPWithTimestamp{{IP: "1.1.1.1", Timestamp: 1700}}
got := mergeClientIps(old, new, 1000)
if got["1.1.1.1"] != 1700 {
t.Fatalf("expected latest timestamp 1700, got %d", got["1.1.1.1"])
}
}
func TestMergeClientIps_DropsStaleNewEntries(t *testing.T) {
// A log line with a clock-skewed old timestamp must not resurrect a
// stale IP past the cutoff.
new := []IPWithTimestamp{{IP: "1.1.1.1", Timestamp: 500}}
got := mergeClientIps(nil, new, 1000)
if len(got) != 0 {
t.Fatalf("stale new IP should have been dropped, got %v", got)
}
}
func TestMergeClientIps_NoStaleCutoffStillWorks(t *testing.T) {
// Defensive: a zero cutoff (e.g. during very first run on a fresh
// install) must not over-evict.
old := []IPWithTimestamp{{IP: "1.1.1.1", Timestamp: 100}}
new := []IPWithTimestamp{{IP: "2.2.2.2", Timestamp: 200}}
got := mergeClientIps(old, new, 0)
want := map[string]int64{"1.1.1.1": 100, "2.2.2.2": 200}
if !reflect.DeepEqual(got, want) {
t.Fatalf("zero cutoff should keep everything\ngot: %v\nwant: %v", got, want)
}
}
func collectIps(entries []IPWithTimestamp) []string {
out := make([]string, 0, len(entries))
for _, e := range entries {
out = append(out, e.IP)
}
return out
}
func TestPartitionLiveIps_SingleLiveNotStarvedByStillFreshHistoricals(t *testing.T) {
// #4091: db holds A, B, C from minutes ago (still in the 30min
// window) but they're not connecting anymore. only D is. old code
// merged all four, sorted ascending, kept [A,B,C] and banned D
// every tick. pin the new rule: only live ips count toward the limit.
ipMap := map[string]int64{
"A": 1000,
"B": 1100,
"C": 1200,
"D": 2000,
}
observed := map[string]bool{"D": true}
live, historical := partitionLiveIps(ipMap, observed)
if got := collectIps(live); !reflect.DeepEqual(got, []string{"D"}) {
t.Fatalf("live set should only contain the ip observed this scan\ngot: %v\nwant: [D]", got)
}
if got := collectIps(historical); !reflect.DeepEqual(got, []string{"A", "B", "C"}) {
t.Fatalf("historical set should contain db-only ips in ascending order\ngot: %v\nwant: [A B C]", got)
}
}
func TestPartitionLiveIps_ConcurrentLiveIpsStillBanNewcomers(t *testing.T) {
// keep the "protect original, ban newcomer" policy when several ips
// are really live. with limit=1, A must stay and B must be banned.
ipMap := map[string]int64{
"A": 5000,
"B": 5500,
}
observed := map[string]bool{"A": true, "B": true}
live, historical := partitionLiveIps(ipMap, observed)
if got := collectIps(live); !reflect.DeepEqual(got, []string{"A", "B"}) {
t.Fatalf("both live ips should be in the live set, ascending\ngot: %v\nwant: [A B]", got)
}
if len(historical) != 0 {
t.Fatalf("no historical ips expected, got %v", historical)
}
}
func TestPartitionLiveIps_EmptyScanLeavesDbIntact(t *testing.T) {
// quiet tick: nothing observed => nothing live. everything merged
// is historical. keeps the panel from wiping recent-but-idle ips.
ipMap := map[string]int64{
"A": 1000,
"B": 1100,
}
observed := map[string]bool{}
live, historical := partitionLiveIps(ipMap, observed)
if len(live) != 0 {
t.Fatalf("no live ips expected, got %v", live)
}
if got := collectIps(historical); !reflect.DeepEqual(got, []string{"A", "B"}) {
t.Fatalf("all merged entries should flow to historical\ngot: %v\nwant: [A B]", got)
}
}

View File

@@ -10,6 +10,7 @@ import (
"strings"
"time"
"github.com/google/uuid"
"github.com/mhsanaei/3x-ui/v2/database"
"github.com/mhsanaei/3x-ui/v2/database/model"
"github.com/mhsanaei/3x-ui/v2/logger"
@@ -26,6 +27,12 @@ type InboundService struct {
xrayApi xray.XrayAPI
}
type CopyClientsResult struct {
Added []string `json:"added"`
Skipped []string `json:"skipped"`
Errors []string `json:"errors"`
}
// GetInbounds retrieves all inbounds for a specific user.
// Returns a slice of inbound models with their associated client statistics.
func (s *InboundService) GetInbounds(userId int) ([]*model.Inbound, error) {
@@ -270,7 +277,7 @@ func (s *InboundService) AddInbound(inbound *model.Inbound) (*model.Inbound, boo
if client.Email == "" {
return inbound, false, common.NewError("empty client ID")
}
case "hysteria":
case "hysteria", "hysteria2":
if client.Auth == "" {
return inbound, false, common.NewError("empty client ID")
}
@@ -675,7 +682,7 @@ func (s *InboundService) AddInboundClient(data *model.Inbound) (bool, error) {
if client.Email == "" {
return false, common.NewError("empty client ID")
}
case "hysteria":
case "hysteria", "hysteria2":
if client.Auth == "" {
return false, common.NewError("empty client ID")
}
@@ -750,6 +757,202 @@ func (s *InboundService) AddInboundClient(data *model.Inbound) (bool, error) {
return needRestart, tx.Save(oldInbound).Error
}
func (s *InboundService) getClientPrimaryKey(protocol model.Protocol, client model.Client) string {
switch protocol {
case model.Trojan:
return client.Password
case model.Shadowsocks:
return client.Email
case model.Hysteria:
return client.Auth
default:
return client.ID
}
}
func (s *InboundService) writeBackClientSubID(sourceInboundID int, sourceProtocol model.Protocol, client model.Client, subID string) (bool, error) {
client.SubID = subID
client.UpdatedAt = time.Now().UnixMilli()
clientID := s.getClientPrimaryKey(sourceProtocol, client)
if clientID == "" {
return false, common.NewError("empty client ID")
}
settingsBytes, err := json.Marshal(map[string][]model.Client{
"clients": {client},
})
if err != nil {
return false, err
}
updatePayload := &model.Inbound{
Id: sourceInboundID,
Settings: string(settingsBytes),
}
return s.UpdateInboundClient(updatePayload, clientID)
}
func (s *InboundService) generateRandomCredential(targetProtocol model.Protocol) string {
switch targetProtocol {
case model.VMESS, model.VLESS:
return uuid.NewString()
default:
return strings.ReplaceAll(uuid.NewString(), "-", "")
}
}
func (s *InboundService) buildTargetClientFromSource(source model.Client, targetProtocol model.Protocol, email string, flow string) (model.Client, error) {
nowTs := time.Now().UnixMilli()
target := source
target.Email = email
target.CreatedAt = nowTs
target.UpdatedAt = nowTs
target.ID = ""
target.Password = ""
target.Auth = ""
target.Flow = ""
switch targetProtocol {
case model.VMESS:
target.ID = s.generateRandomCredential(targetProtocol)
case model.VLESS:
target.ID = s.generateRandomCredential(targetProtocol)
if flow == "xtls-rprx-vision" || flow == "xtls-rprx-vision-udp443" {
target.Flow = flow
}
case model.Trojan, model.Shadowsocks:
target.Password = s.generateRandomCredential(targetProtocol)
case model.Hysteria:
target.Auth = s.generateRandomCredential(targetProtocol)
default:
target.ID = s.generateRandomCredential(targetProtocol)
}
return target, nil
}
func (s *InboundService) nextAvailableCopiedEmail(originalEmail string, targetID int, occupied map[string]struct{}) string {
base := fmt.Sprintf("%s_%d", originalEmail, targetID)
candidate := base
suffix := 0
for {
if _, exists := occupied[strings.ToLower(candidate)]; !exists {
occupied[strings.ToLower(candidate)] = struct{}{}
return candidate
}
suffix++
candidate = fmt.Sprintf("%s_%d", base, suffix)
}
}
func (s *InboundService) CopyInboundClients(targetInboundID int, sourceInboundID int, clientEmails []string, flow string) (*CopyClientsResult, bool, error) {
result := &CopyClientsResult{
Added: []string{},
Skipped: []string{},
Errors: []string{},
}
if targetInboundID == sourceInboundID {
return result, false, common.NewError("source and target inbounds must be different")
}
targetInbound, err := s.GetInbound(targetInboundID)
if err != nil {
return result, false, err
}
sourceInbound, err := s.GetInbound(sourceInboundID)
if err != nil {
return result, false, err
}
sourceClients, err := s.GetClients(sourceInbound)
if err != nil {
return result, false, err
}
if len(sourceClients) == 0 {
return result, false, nil
}
allowedEmails := map[string]struct{}{}
if len(clientEmails) > 0 {
for _, email := range clientEmails {
allowedEmails[strings.ToLower(strings.TrimSpace(email))] = struct{}{}
}
}
occupiedEmails := map[string]struct{}{}
allEmails, err := s.getAllEmails()
if err != nil {
return result, false, err
}
for _, email := range allEmails {
clean := strings.Trim(email, "\"")
if clean != "" {
occupiedEmails[strings.ToLower(clean)] = struct{}{}
}
}
newClients := make([]model.Client, 0)
needRestart := false
for _, sourceClient := range sourceClients {
originalEmail := strings.TrimSpace(sourceClient.Email)
if originalEmail == "" {
continue
}
if len(allowedEmails) > 0 {
if _, ok := allowedEmails[strings.ToLower(originalEmail)]; !ok {
continue
}
}
if sourceClient.SubID == "" {
newSubID := uuid.NewString()
subNeedRestart, subErr := s.writeBackClientSubID(sourceInbound.Id, sourceInbound.Protocol, sourceClient, newSubID)
if subErr != nil {
result.Errors = append(result.Errors, fmt.Sprintf("%s: failed to write source subId: %v", originalEmail, subErr))
continue
}
if subNeedRestart {
needRestart = true
}
sourceClient.SubID = newSubID
}
targetEmail := s.nextAvailableCopiedEmail(originalEmail, targetInboundID, occupiedEmails)
targetClient, buildErr := s.buildTargetClientFromSource(sourceClient, targetInbound.Protocol, targetEmail, flow)
if buildErr != nil {
result.Errors = append(result.Errors, fmt.Sprintf("%s: %v", originalEmail, buildErr))
continue
}
newClients = append(newClients, targetClient)
result.Added = append(result.Added, targetEmail)
}
if len(newClients) == 0 {
return result, needRestart, nil
}
settingsPayload, err := json.Marshal(map[string][]model.Client{
"clients": newClients,
})
if err != nil {
return result, needRestart, err
}
addNeedRestart, err := s.AddInboundClient(&model.Inbound{
Id: targetInboundID,
Settings: string(settingsPayload),
})
if err != nil {
return result, needRestart, err
}
if addNeedRestart {
needRestart = true
}
return result, needRestart, nil
}
func (s *InboundService) DelInboundClient(inboundId int, clientId string) (bool, error) {
oldInbound, err := s.GetInbound(inboundId)
if err != nil {
@@ -769,7 +972,7 @@ func (s *InboundService) DelInboundClient(inboundId int, clientId string) (bool,
client_key = "password"
case "shadowsocks":
client_key = "email"
case "hysteria":
case "hysteria", "hysteria2":
client_key = "auth"
}
@@ -877,7 +1080,7 @@ func (s *InboundService) UpdateInboundClient(data *model.Inbound, clientId strin
case "shadowsocks":
oldClientId = oldClient.Email
newClientId = clients[0].Email
case "hysteria":
case "hysteria", "hysteria2":
oldClientId = oldClient.Auth
newClientId = clients[0].Auth
default:

View File

@@ -206,7 +206,10 @@ func (s *XrayService) GetXrayTraffic() ([]*xray.Traffic, []*xray.ClientTraffic,
return nil, nil, err
}
apiPort := p.GetAPIPort()
s.xrayAPI.Init(apiPort)
if err := s.xrayAPI.Init(apiPort); err != nil {
logger.Debug("Failed to initialize Xray API:", err)
return nil, nil, err
}
defer s.xrayAPI.Close()
traffic, clientTraffic, err := s.xrayAPI.GetTraffic(true)

View File

@@ -298,6 +298,20 @@
"submitEdit" = "احفظ التعديلات"
"clientCount" = "عدد العملاء"
"bulk" = "إضافة بالجملة"
"copyFromInbound" = "نسخ العملاء من الـ Inbound"
"copyToInbound" = "نسخ العملاء إلى"
"copySelected" = "نسخ المحدد"
"copySource" = "المصدر"
"copyEmailPreview" = "معاينة البريد الإلكتروني الناتج"
"copySelectSourceFirst" = "الرجاء اختيار الـ Inbound المصدر أولاً."
"copyResult" = "نتيجة النسخ"
"copyResultSuccess" = "تم النسخ بنجاح"
"copyResultNone" = "لا يوجد شيء للنسخ: لم يتم اختيار أي عميل أو أن المصدر فارغ"
"copyResultErrors" = "أخطاء النسخ"
"copyFlowLabel" = "Flow للعملاء الجدد (VLESS)"
"copyFlowHint" = "يُطبَّق على جميع العملاء المنسوخين. اتركه فارغاً لتخطيه."
"selectAll" = "تحديد الكل"
"clearAll" = "مسح الكل"
"method" = "طريقة"
"first" = "أول واحد"
"last" = "آخر واحد"

View File

@@ -298,6 +298,20 @@
"submitEdit" = "Save Changes"
"clientCount" = "Number of Clients"
"bulk" = "Add Bulk"
"copyFromInbound" = "Copy Clients from Inbound"
"copyToInbound" = "Copy clients to"
"copySelected" = "Copy Selected"
"copySource" = "Source"
"copyEmailPreview" = "Resulting email preview"
"copySelectSourceFirst" = "Please select a source inbound first."
"copyResult" = "Copy result"
"copyResultSuccess" = "Copied successfully"
"copyResultNone" = "Nothing to copy: no clients selected or source is empty"
"copyResultErrors" = "Copy errors"
"copyFlowLabel" = "Flow for new clients (VLESS)"
"copyFlowHint" = "Applied to all copied clients. Leave empty to skip."
"selectAll" = "Select all"
"clearAll" = "Clear all"
"method" = "Method"
"first" = "First"
"last" = "Last"

View File

@@ -298,6 +298,20 @@
"submitEdit" = "Guardar Cambios"
"clientCount" = "Número de Clientes"
"bulk" = "Agregar en Lote"
"copyFromInbound" = "Copiar clientes desde entrada"
"copyToInbound" = "Copiar clientes a"
"copySelected" = "Copiar seleccionados"
"copySource" = "Origen"
"copyEmailPreview" = "Vista previa del email resultante"
"copySelectSourceFirst" = "Seleccione primero una entrada de origen."
"copyResult" = "Resultado de la copia"
"copyResultSuccess" = "Copiado correctamente"
"copyResultNone" = "Nada que copiar: ningún cliente seleccionado o el origen está vacío"
"copyResultErrors" = "Errores al copiar"
"copyFlowLabel" = "Flow para nuevos clientes (VLESS)"
"copyFlowHint" = "Se aplica a todos los clientes copiados. Déjelo vacío para omitir."
"selectAll" = "Seleccionar todo"
"clearAll" = "Limpiar todo"
"method" = "Método"
"first" = "Primero"
"last" = "Último"

View File

@@ -298,6 +298,20 @@
"submitEdit" = "ذخیره تغییرات"
"clientCount" = "تعداد کاربران"
"bulk" = "انبوه‌سازی"
"copyFromInbound" = "کپی کاربران از اینباند"
"copyToInbound" = "کپی کاربران به"
"copySelected" = "کپی انتخاب‌شده‌ها"
"copySource" = "منبع"
"copyEmailPreview" = "پیش‌نمایش ایمیل نهایی"
"copySelectSourceFirst" = "ابتدا یک اینباند منبع انتخاب کنید."
"copyResult" = "نتیجه کپی"
"copyResultSuccess" = "با موفقیت کپی شد"
"copyResultNone" = "چیزی برای کپی نیست: هیچ کاربری انتخاب نشده یا منبع خالی است"
"copyResultErrors" = "خطاهای کپی"
"copyFlowLabel" = "Flow برای کاربران جدید (VLESS)"
"copyFlowHint" = "برای همه کاربران کپی‌شده اعمال می‌شود. برای نادیده گرفتن، خالی بگذارید."
"selectAll" = "انتخاب همه"
"clearAll" = "پاک کردن همه"
"method" = "روش"
"first" = "از"
"last" = "تا"

View File

@@ -298,6 +298,20 @@
"submitEdit" = "Simpan Perubahan"
"clientCount" = "Jumlah Klien"
"bulk" = "Tambahkan Massal"
"copyFromInbound" = "Salin klien dari inbound"
"copyToInbound" = "Salin klien ke"
"copySelected" = "Salin yang dipilih"
"copySource" = "Sumber"
"copyEmailPreview" = "Pratinjau email hasil"
"copySelectSourceFirst" = "Silakan pilih inbound sumber terlebih dahulu."
"copyResult" = "Hasil penyalinan"
"copyResultSuccess" = "Berhasil disalin"
"copyResultNone" = "Tidak ada yang disalin: tidak ada klien yang dipilih atau sumber kosong"
"copyResultErrors" = "Kesalahan penyalinan"
"copyFlowLabel" = "Flow untuk klien baru (VLESS)"
"copyFlowHint" = "Diterapkan ke semua klien yang disalin. Biarkan kosong untuk melewati."
"selectAll" = "Pilih semua"
"clearAll" = "Hapus semua"
"method" = "Metode"
"first" = "Pertama"
"last" = "Terakhir"

View File

@@ -298,6 +298,20 @@
"submitEdit" = "変更を保存"
"clientCount" = "クライアント数"
"bulk" = "一括作成"
"copyFromInbound" = "インバウンドからクライアントをコピー"
"copyToInbound" = "クライアントのコピー先"
"copySelected" = "選択項目をコピー"
"copySource" = "ソース"
"copyEmailPreview" = "結果メールのプレビュー"
"copySelectSourceFirst" = "先にソースインバウンドを選択してください。"
"copyResult" = "コピー結果"
"copyResultSuccess" = "正常にコピーされました"
"copyResultNone" = "コピーする項目がありません: クライアントが選択されていないかソースが空です"
"copyResultErrors" = "コピーエラー"
"copyFlowLabel" = "新規クライアントの Flow (VLESS)"
"copyFlowHint" = "すべてのコピー対象クライアントに適用されます。空のままにするとスキップします。"
"selectAll" = "すべて選択"
"clearAll" = "すべて解除"
"method" = "方法"
"first" = "最初"
"last" = "最後"

View File

@@ -298,6 +298,20 @@
"submitEdit" = "Salvar Alterações"
"clientCount" = "Número de Clientes"
"bulk" = "Adicionar Vários"
"copyFromInbound" = "Copiar clientes da entrada"
"copyToInbound" = "Copiar clientes para"
"copySelected" = "Copiar selecionados"
"copySource" = "Origem"
"copyEmailPreview" = "Prévia do email resultante"
"copySelectSourceFirst" = "Selecione primeiro uma entrada de origem."
"copyResult" = "Resultado da cópia"
"copyResultSuccess" = "Copiado com sucesso"
"copyResultNone" = "Nada a copiar: nenhum cliente selecionado ou origem vazia"
"copyResultErrors" = "Erros ao copiar"
"copyFlowLabel" = "Flow para novos clientes (VLESS)"
"copyFlowHint" = "Aplicado a todos os clientes copiados. Deixe em branco para ignorar."
"selectAll" = "Selecionar tudo"
"clearAll" = "Limpar tudo"
"method" = "Método"
"first" = "Primeiro"
"last" = "Último"

View File

@@ -298,6 +298,20 @@
"submitEdit" = "Сохранить изменения"
"clientCount" = "Количество клиентов"
"bulk" = "Добавить несколько"
"copyFromInbound" = "Скопировать клиентов из инбаунда"
"copyToInbound" = "Скопировать клиентов в"
"copySelected" = "Скопировать выбранных"
"copySource" = "Источник"
"copyEmailPreview" = "Предпросмотр итоговых email"
"copySelectSourceFirst" = "Сначала выберите источник."
"copyResult" = "Результат копирования"
"copyResultSuccess" = "Успешно скопировано"
"copyResultNone" = "Нечего копировать: ни одного клиента не выбрано или список источника пуст"
"copyResultErrors" = "Ошибки при копировании"
"copyFlowLabel" = "Flow для новых клиентов (VLESS)"
"copyFlowHint" = "Применится ко всем копируемым клиентам. Оставьте пустым, чтобы не задавать."
"selectAll" = "Выбрать всех"
"clearAll" = "Снять всё"
"method" = "Метод"
"first" = "Первый"
"last" = "Последний"

View File

@@ -298,6 +298,20 @@
"submitEdit" = "Değişiklikleri Kaydet"
"clientCount" = "Müşteri Sayısı"
"bulk" = "Toplu Ekle"
"copyFromInbound" = "Gelen bağlantıdan istemcileri kopyala"
"copyToInbound" = "İstemcileri şuraya kopyala"
"copySelected" = "Seçilenleri kopyala"
"copySource" = "Kaynak"
"copyEmailPreview" = "Sonuç e-posta önizlemesi"
"copySelectSourceFirst" = "Önce bir kaynak gelen bağlantı seçin."
"copyResult" = "Kopyalama sonucu"
"copyResultSuccess" = "Başarıyla kopyalandı"
"copyResultNone" = "Kopyalanacak bir şey yok: istemci seçilmedi veya kaynak boş"
"copyResultErrors" = "Kopyalama hataları"
"copyFlowLabel" = "Yeni istemciler için Flow (VLESS)"
"copyFlowHint" = "Kopyalanan tüm istemcilere uygulanır. Boş bırakırsanız atlanır."
"selectAll" = "Tümünü seç"
"clearAll" = "Tümünü temizle"
"method" = "Yöntem"
"first" = "İlk"
"last" = "Son"

View File

@@ -298,6 +298,20 @@
"submitEdit" = "Зберегти зміни"
"clientCount" = "Кількість клієнтів"
"bulk" = "Додати групу"
"copyFromInbound" = "Скопіювати клієнтів з інбаунда"
"copyToInbound" = "Скопіювати клієнтів у"
"copySelected" = "Скопіювати вибраних"
"copySource" = "Джерело"
"copyEmailPreview" = "Попередній перегляд підсумкових email"
"copySelectSourceFirst" = "Спочатку виберіть джерело."
"copyResult" = "Результат копіювання"
"copyResultSuccess" = "Успішно скопійовано"
"copyResultNone" = "Нічого копіювати: жодного клієнта не вибрано або список джерела порожній"
"copyResultErrors" = "Помилки під час копіювання"
"copyFlowLabel" = "Flow для нових клієнтів (VLESS)"
"copyFlowHint" = "Застосується до всіх скопійованих клієнтів. Залиште порожнім, щоб не задавати."
"selectAll" = "Вибрати всіх"
"clearAll" = "Зняти все"
"method" = "Метод"
"first" = "Перший"
"last" = "Останній"

View File

@@ -298,6 +298,20 @@
"submitEdit" = "Lưu thay đổi"
"clientCount" = "Số lượng người dùng"
"bulk" = "Thêm hàng loạt"
"copyFromInbound" = "Sao chép người dùng từ Inbound"
"copyToInbound" = "Sao chép người dùng đến"
"copySelected" = "Sao chép đã chọn"
"copySource" = "Nguồn"
"copyEmailPreview" = "Xem trước email kết quả"
"copySelectSourceFirst" = "Vui lòng chọn Inbound nguồn trước."
"copyResult" = "Kết quả sao chép"
"copyResultSuccess" = "Đã sao chép thành công"
"copyResultNone" = "Không có gì để sao chép: chưa chọn người dùng hoặc nguồn trống"
"copyResultErrors" = "Lỗi sao chép"
"copyFlowLabel" = "Flow cho người dùng mới (VLESS)"
"copyFlowHint" = "Áp dụng cho tất cả người dùng được sao chép. Để trống để bỏ qua."
"selectAll" = "Chọn tất cả"
"clearAll" = "Bỏ chọn tất cả"
"method" = "Phương pháp"
"first" = "Đầu tiên"
"last" = "Cuối cùng"

View File

@@ -298,6 +298,20 @@
"submitEdit" = "保存修改"
"clientCount" = "客户端数量"
"bulk" = "批量创建"
"copyFromInbound" = "从入站复制客户端"
"copyToInbound" = "复制客户端到"
"copySelected" = "复制所选"
"copySource" = "来源"
"copyEmailPreview" = "最终邮箱预览"
"copySelectSourceFirst" = "请先选择来源入站。"
"copyResult" = "复制结果"
"copyResultSuccess" = "复制成功"
"copyResultNone" = "没有可复制的内容:未选择客户端或来源为空"
"copyResultErrors" = "复制错误"
"copyFlowLabel" = "新客户端的 Flow (VLESS)"
"copyFlowHint" = "应用于所有复制的客户端。留空则跳过。"
"selectAll" = "全选"
"clearAll" = "全不选"
"method" = "方法"
"first" = "置顶"
"last" = "置底"

View File

@@ -298,6 +298,20 @@
"submitEdit" = "儲存修改"
"clientCount" = "客戶端數量"
"bulk" = "批量建立"
"copyFromInbound" = "從入站複製用戶端"
"copyToInbound" = "複製用戶端到"
"copySelected" = "複製所選"
"copySource" = "來源"
"copyEmailPreview" = "最終郵箱預覽"
"copySelectSourceFirst" = "請先選擇來源入站。"
"copyResult" = "複製結果"
"copyResultSuccess" = "複製成功"
"copyResultNone" = "沒有可複製的內容:未選擇用戶端或來源為空"
"copyResultErrors" = "複製錯誤"
"copyFlowLabel" = "新用戶端的 Flow (VLESS)"
"copyFlowHint" = "套用於所有複製的用戶端。留空則略過。"
"selectAll" = "全選"
"clearAll" = "全不選"
"method" = "方法"
"first" = "置頂"
"last" = "置底"

View File

@@ -353,14 +353,17 @@ func (s *Server) startTask() {
isTgbotenabled, err := s.settingService.GetTgbotEnabled()
if (err == nil) && (isTgbotenabled) {
runtime, err := s.settingService.GetTgbotRuntime()
if err != nil || runtime == "" {
logger.Errorf("Add NewStatsNotifyJob error[%s], Runtime[%s] invalid, will run default", err, runtime)
if err != nil {
logger.Warningf("Add NewStatsNotifyJob: failed to load runtime: %v; using default @daily", err)
runtime = "@daily"
} else if strings.TrimSpace(runtime) == "" {
logger.Warning("Add NewStatsNotifyJob runtime is empty, using default @daily")
runtime = "@daily"
}
logger.Infof("Tg notify enabled,run at %s", runtime)
_, err = s.cron.AddJob(runtime, job.NewStatsNotifyJob())
if err != nil {
logger.Warning("Add NewStatsNotifyJob error", err)
logger.Warningf("Add NewStatsNotifyJob: failed to schedule runtime %q: %v", runtime, err)
return
}

25
x-ui.sh
View File

@@ -1802,7 +1802,14 @@ install_iplimit() {
if ! command -v fail2ban-client &>/dev/null; then
echo -e "${green}Fail2ban is not installed. Installing now...!${plain}\n"
# Check the OS and install necessary packages
# Install fail2ban together with nftables. Recent fail2ban packages
# default to `banaction = nftables-multiport` in /etc/fail2ban/jail.conf,
# but the `nftables` package isn't pulled in as a dependency on most
# minimal server images (Debian 12+, Ubuntu 24+, fresh RHEL-family).
# Without `nft` in PATH the default sshd jail fails to ban with
# stderr: '/bin/sh: 1: nft: not found'
# even though our own 3x-ipl jail uses iptables. Bundling the binary
# at install time prevents that confusing log spam for new installs.
case "${release}" in
ubuntu)
apt-get update
@@ -1810,34 +1817,34 @@ install_iplimit() {
apt-get install python3-pip -y
python3 -m pip install pyasynchat --break-system-packages
fi
apt-get install fail2ban -y
apt-get install fail2ban nftables -y
;;
debian)
apt-get update
if [ "$os_version" -ge 12 ]; then
apt-get install -y python3-systemd
fi
apt-get install -y fail2ban
apt-get install -y fail2ban nftables
;;
armbian)
apt-get update && apt-get install fail2ban -y
apt-get update && apt-get install fail2ban nftables -y
;;
fedora | amzn | virtuozzo | rhel | almalinux | rocky | ol)
dnf -y update && dnf -y install fail2ban
dnf -y update && dnf -y install fail2ban nftables
;;
centos)
if [[ "${VERSION_ID}" =~ ^7 ]]; then
yum update -y && yum install epel-release -y
yum -y install fail2ban
yum -y install fail2ban nftables
else
dnf -y update && dnf -y install fail2ban
dnf -y update && dnf -y install fail2ban nftables
fi
;;
arch | manjaro | parch)
pacman -Syu --noconfirm fail2ban
pacman -Syu --noconfirm fail2ban nftables
;;
alpine)
apk add fail2ban
apk add fail2ban nftables
;;
*)
echo -e "${red}Unsupported operating system. Please check the script and install the necessary packages manually.${plain}\n"

View File

@@ -208,10 +208,6 @@ func (x *XrayAPI) AddUser(Protocol string, inboundTag string, user map[string]an
var ssCipherType shadowsocks.CipherType
switch cipher {
case "aes-128-gcm":
ssCipherType = shadowsocks.CipherType_AES_128_GCM
case "aes-256-gcm":
ssCipherType = shadowsocks.CipherType_AES_256_GCM
case "chacha20-poly1305", "chacha20-ietf-poly1305":
ssCipherType = shadowsocks.CipherType_CHACHA20_POLY1305
case "xchacha20-poly1305", "xchacha20-ietf-poly1305":
@@ -231,7 +227,7 @@ func (x *XrayAPI) AddUser(Protocol string, inboundTag string, user map[string]an
Email: userEmail,
})
}
case "hysteria":
case "hysteria", "hysteria2":
auth, err := getRequiredUserString(user, "auth")
if err != nil {
return err