mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-09-16 23:27:14 +00:00
baef3cdd07
* fix(xray): refuse a config the running core cannot bind RestartXray stopped a working core before handing it a config whose listens collide, so the failed bind exited the whole process (main/run.go:94) and the one-second watchdog retried it in a loop: every protocol down, cause only in the logs. The save-time port guards cannot cover this -- SetInboundEnable, the AmneziaWG relay created on the first peer, template and bridge edits all reach a colliding config with no guard on that path. Probe the generated config at the single restart funnel instead. Collisions the running core already serves are excused, so an established setup is never refused by a static read being wrong about it, and the port-bucketed pass costs nothing on a clean config. * fix(xray): surface a refused config and re-key the bind excuse set Round-1 findings on this PR. Refusing the swap left the running core on its previous config with nothing but a log line to show for it, so the status response now carries the reason while the core runs and the overview marks it; the node list picks the same field up through that response. The excuse set is keyed on the two listens, the port and the shared transports instead of the tag pair, so a pair whose listen moves onto the other's address is refused again, while the same two sockets stay excused however the generator orders them. TestBindConflicts/excused_pair_whose_listen_changed_into_a_real_collision fails without the key change -- watched red first.
238 lines
7.9 KiB
Go
238 lines
7.9 KiB
Go
package service
|
|
|
|
import (
|
|
"encoding/json"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/mhsanaei/3x-ui/v3/internal/amneziawgnet"
|
|
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
|
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
|
"github.com/mhsanaei/3x-ui/v3/internal/xray"
|
|
)
|
|
|
|
// configFromInbounds builds the config the way the panel does, from raw JSON:
|
|
// the probe is only worth anything if it parses what is really written.
|
|
func configFromInbounds(t *testing.T, inbounds string) *xray.Config {
|
|
t.Helper()
|
|
var cfg xray.Config
|
|
if err := json.Unmarshal([]byte(`{"inbounds":[`+inbounds+`]}`), &cfg); err != nil {
|
|
t.Fatalf("build config: %v", err)
|
|
}
|
|
return &cfg
|
|
}
|
|
|
|
func TestBindConflicts(t *testing.T) {
|
|
const (
|
|
relay = `{"listen":"127.0.0.1","port":65101,"protocol":"socks","tag":"relay","settings":{"auth":"password","udp":true,"accounts":[]}}`
|
|
user = `{"listen":"0.0.0.0","port":65101,"protocol":"vless","tag":"user","streamSettings":{"network":"tcp"}}`
|
|
)
|
|
|
|
cases := []struct {
|
|
name string
|
|
inbounds string
|
|
running string
|
|
want int
|
|
}{
|
|
{
|
|
"same listen, port and tcp",
|
|
`{"listen":"0.0.0.0","port":443,"protocol":"vless","tag":"a"},
|
|
{"listen":"0.0.0.0","port":443,"protocol":"vmess","tag":"b"}`,
|
|
``, 1,
|
|
},
|
|
{
|
|
"tcp and udp on one port are legal",
|
|
`{"listen":"0.0.0.0","port":443,"protocol":"vless","tag":"a"},
|
|
{"listen":"0.0.0.0","port":443,"protocol":"hysteria","tag":"b"}`,
|
|
``, 0,
|
|
},
|
|
{
|
|
"kcp moves vless to udp and frees the port",
|
|
`{"listen":"0.0.0.0","port":443,"protocol":"vless","tag":"a","streamSettings":{"network":"kcp"}},
|
|
{"listen":"0.0.0.0","port":443,"protocol":"vless","tag":"b","streamSettings":{"network":"tcp"}}`,
|
|
``, 0,
|
|
},
|
|
{
|
|
"wildcard listen overlaps a loopback one",
|
|
`{"listen":"0.0.0.0","port":8443,"protocol":"vless","tag":"a"},
|
|
{"listen":"127.0.0.1","port":8443,"protocol":"trojan","tag":"b"}`,
|
|
``, 1,
|
|
},
|
|
{
|
|
"absent listen means wildcard",
|
|
`{"port":8443,"protocol":"vless","tag":"a"},
|
|
{"listen":"127.0.0.1","port":8443,"protocol":"trojan","tag":"b"}`,
|
|
``, 1,
|
|
},
|
|
{
|
|
"distinct loopback addresses do not overlap",
|
|
`{"listen":"127.0.0.1","port":8443,"protocol":"vless","tag":"a"},
|
|
{"listen":"127.0.0.2","port":8443,"protocol":"vless","tag":"b"}`,
|
|
``, 0,
|
|
},
|
|
{
|
|
"port zero is not a bind",
|
|
`{"listen":"0.0.0.0","port":0,"protocol":"tunnel","tag":"a"},
|
|
{"listen":"0.0.0.0","port":0,"protocol":"vless","tag":"b"}`,
|
|
``, 0,
|
|
},
|
|
{
|
|
"clean config",
|
|
`{"listen":"0.0.0.0","port":443,"protocol":"vless","tag":"a"},
|
|
{"listen":"127.0.0.1","port":62789,"protocol":"tunnel","tag":"api"},
|
|
` + relay,
|
|
``, 0,
|
|
},
|
|
{
|
|
// The relay the AmneziaWG family lives on: loopback tcp+udp, on the
|
|
// same port as a user inbound's tcp.
|
|
"amneziawg relay against a tcp inbound",
|
|
relay + "," + user, ``, 1,
|
|
},
|
|
{
|
|
"amneziawg relay against a udp inbound",
|
|
relay + `,{"listen":"0.0.0.0","port":65101,"protocol":"hysteria","tag":"user"}`,
|
|
``, 1,
|
|
},
|
|
{
|
|
// Proves the relay's transports come from settings.udp and not from a
|
|
// blanket "loopback owns everything" rule.
|
|
"socks bridge without udp coexists with a udp inbound",
|
|
`{"listen":"127.0.0.1","port":65101,"protocol":"socks","tag":"bridge","settings":{"auth":"noauth"}},
|
|
{"listen":"0.0.0.0","port":65101,"protocol":"hysteria","tag":"user"}`,
|
|
``, 0,
|
|
},
|
|
{
|
|
"reserved api inbound against a user inbound",
|
|
`{"listen":"127.0.0.1","port":62789,"protocol":"tunnel","tag":"api","settings":{"rewriteAddress":"127.0.0.1"}},
|
|
{"listen":"0.0.0.0","port":62789,"protocol":"vless","tag":"user"}`,
|
|
``, 1,
|
|
},
|
|
{
|
|
// The core is running this pair right now, so whatever a static read
|
|
// says about it, it binds: an established setup is never refused.
|
|
"collision the running config already serves",
|
|
relay + "," + user, relay + "," + user, 0,
|
|
},
|
|
{
|
|
"collision the running config does not have",
|
|
relay + "," + user, user, 1,
|
|
},
|
|
{
|
|
// `::` and `0.0.0.0` on one port is what bindv6only=1 makes legal, so the
|
|
// running config excuses it -- but moving one onto the other is not it.
|
|
"excused pair whose listen changed into a real collision",
|
|
`{"listen":"0.0.0.0","port":443,"protocol":"vless","tag":"a"},
|
|
{"listen":"0.0.0.0","port":443,"protocol":"vless","tag":"b"}`,
|
|
`{"listen":"::","port":443,"protocol":"vless","tag":"a"},
|
|
{"listen":"0.0.0.0","port":443,"protocol":"vless","tag":"b"}`,
|
|
1,
|
|
},
|
|
{
|
|
// The same pair of sockets is the same evidence, whichever order the
|
|
// generator happened to emit them in.
|
|
"excused pair with its listens swapped stays excused",
|
|
`{"listen":"0.0.0.0","port":443,"protocol":"vless","tag":"a"},
|
|
{"listen":"::","port":443,"protocol":"vless","tag":"b"}`,
|
|
`{"listen":"::","port":443,"protocol":"vless","tag":"a"},
|
|
{"listen":"0.0.0.0","port":443,"protocol":"vless","tag":"b"}`,
|
|
0,
|
|
},
|
|
{
|
|
"same pair on another port is still new",
|
|
`{"listen":"127.0.0.1","port":65102,"protocol":"socks","tag":"relay","settings":{"auth":"password","udp":true,"accounts":[]}},
|
|
{"listen":"0.0.0.0","port":65102,"protocol":"vless","tag":"user","streamSettings":{"network":"tcp"}}`,
|
|
relay + "," + user, 1,
|
|
},
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
var running *xray.Config
|
|
if tc.running != "" {
|
|
running = configFromInbounds(t, tc.running)
|
|
}
|
|
got := bindConflicts(configFromInbounds(t, tc.inbounds), running)
|
|
if len(got) != tc.want {
|
|
t.Fatalf("bindConflicts = %v, want %d conflict(s)", got, tc.want)
|
|
}
|
|
for _, c := range got {
|
|
if c.tagA == "" || c.tagB == "" || c.tagA == c.tagB {
|
|
t.Fatalf("a conflict must name both tags, got %+v", c)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestBindConflicts_MessageNamesBothSides(t *testing.T) {
|
|
conflicts := bindConflicts(configFromInbounds(t, `
|
|
{"listen":"127.0.0.1","port":65101,"protocol":"socks","tag":"relay","settings":{"auth":"password","udp":true,"accounts":[]}},
|
|
{"listen":"0.0.0.0","port":65101,"protocol":"vless","tag":"user","streamSettings":{"network":"tcp"}}`), nil)
|
|
if len(conflicts) != 1 {
|
|
t.Fatalf("want exactly one conflict, got %v", conflicts)
|
|
}
|
|
msg := conflicts[0].String()
|
|
for _, want := range []string{`"relay"`, `"user"`, "127.0.0.1", "65101"} {
|
|
if !strings.Contains(msg, want) {
|
|
t.Fatalf("conflict message %q must contain %q", msg, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// The probe must read what the panel really emits: the AmneziaWG relay is a
|
|
// loopback "socks" inbound whose udp flag lives in settings, not streamSettings.
|
|
func TestBindConflicts_GeneratedConfig(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
portOff int
|
|
running bool
|
|
want int
|
|
}{
|
|
{"user inbound on the relay port", 0, false, 1},
|
|
{"already running config", 0, true, 0},
|
|
{"user inbound on a free port", 1, false, 0},
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
setupSettingTestDB(t)
|
|
seedInboundConflict(t, "awg-1", "0.0.0.0", 51820, model.AmneziaWG, ``, amneziawgRoutedSettings)
|
|
|
|
var awg model.Inbound
|
|
if err := database.GetDB().Where("tag = ?", "awg-1").First(&awg).Error; err != nil {
|
|
t.Fatalf("read seeded row: %v", err)
|
|
}
|
|
relayPort := amneziawgnet.SOCKSPortForInbound(awg.Id)
|
|
seedInboundConflict(t, "user", "0.0.0.0", relayPort+tc.portOff, model.VLESS, `{"network":"tcp"}`, `{}`)
|
|
|
|
svc := &XrayService{}
|
|
cfg, err := svc.GetXrayConfig()
|
|
if err != nil {
|
|
t.Fatalf("GetXrayConfig: %v", err)
|
|
}
|
|
running := (*xray.Config)(nil)
|
|
if tc.running {
|
|
if running, err = svc.GetXrayConfig(); err != nil {
|
|
t.Fatalf("second GetXrayConfig: %v", err)
|
|
}
|
|
}
|
|
|
|
got := bindConflicts(cfg, running)
|
|
if len(got) != tc.want {
|
|
t.Fatalf("bindConflicts = %v, want %d", got, tc.want)
|
|
}
|
|
if tc.want == 0 {
|
|
return
|
|
}
|
|
msg := got[0].String()
|
|
for _, want := range []string{`"awg-1"`, `"user"`, strconv.Itoa(relayPort)} {
|
|
if !strings.Contains(msg, want) {
|
|
t.Fatalf("conflict message %q must contain %q", msg, want)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|