mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-10-26 07:44:59 +03:00
56ac8d0fc7
* feat(amneziawg): add opt-in Linux kernel datapath Add the kernel backend as an explicit per-inbound option while keeping the embedded backend as the default. Include host preflight, UI/API support, the bundled installer CLI, lifecycle and accounting integration, regression tests, and upstream-facing documentation. Kernel mode is NAT-only and rejects unsupported routing, IPv6, forwarding, and pinned-listen configurations. Local saves check the local host; remote nodes check their own host during apply. Preserve adopted interface accounting across panel restarts, retain manager state after failed teardown so the job can retry, prevent embedded startup while a kernel interface remains live, and prune collector state for removed peers. Tested the restart accounting path on Debian 13 with XanMod 7.1.11 on x86_64; Docker and cross-kernel coverage are not claimed. * fix(amneziawg): align readiness and endpoint reporting Expire endpoint sightings using the latest handshake and sweep stale addresses for the polled interface. Keep the readiness API schema, generated client types, and ready-host empty blocker list consistent; watch the nested form field and defer local readiness checks for remote targets. Document safe rollback before using a build without kernel support. Regression evidence: the idle endpoint test failed before the collector change because the old address remained visible after 31 minutes; endpoint tests pass after the fix. The form component suite passes 3 tests; restoring the invalid array watch made its off-mode severity test fail, while the corrected dot-path watch passes. Targeted Go regressions across amneziawgkern, job, and service pass. * style(amneziawg): format readiness test * test: reproduce kernel AWG client validation gaps * test(service): cover kernel AWG shared client fanout * fix(amneziawg): validate kernel client fanout and config values * test(amneziawgkern): cover owned interface lifecycle recovery * fix(amneziawgkern): recover owned interfaces on restart * test(amneziawgkern): retain visibility after recovered cleanup failure * fix(amneziawgkern): report recovered interfaces while cleanup retries * style(amneziawgkern): satisfy static analysis * test(amneziawgkern): cover long rendered peer comments * fix(amneziawgkern): accept long generated config comments * test(amneziawgkern): refuse foreign occupied interfaces Capture the current Ensure ordering failure with real temporary config paths and manager side-effect boundaries. * test(amneziawgkern): cover stale interface collisions Extend the Ensure regression across absent links, mismatched live interfaces, cached manager state, and Remove teardown. * fix(amneziawgkern): verify interface ownership before mutation Refuse to replace or tear down links unless the canonical config is a valid panel-owned file and its key matches the live interface. Preserve normal adoption and key rotation, and document migration of legacy unmarked configs. Validation: go test ./internal/amneziawgkern -count=1; go test -race ./internal/amneziawgkern -count=1; golangci-lint run ./internal/amneziawgkern/...; git diff --check. * build: update Go toolchain to 1.27.3 * test(amneziawg): reproduce unsafe key rotation recovery Keep the previous owned config authoritative until teardown succeeds, and require fresh managers to rebuild structural key changes. These regressions expose the stuck live-key/disk-key mismatch after teardown failure. * fix(amneziawg): recover interrupted key rotations Keep the old owned config on disk until a structural rebuild has confirmed the previous interface is down. Fresh managers only adopt when the live config and desired structural identity match, so retries and removals retain the key needed to prove ownership. * fix(scanner): align Go image with module version * fix(docs): pin pnpm release with bootstrap fix pnpm 12.9.1 restores a Node launcher before its postinstall replaces the target with ELF; pnpm 11 then runs that binary as JavaScript. Pin 12.10.0, which includes the shim fix, so the docs CI bootstrap can switch versions safely. * fix(xray): build core from pinned module The configured Xray release asset tag is unpublished, and the latest published binary lacks GetUsersStats used by the panel. Build the packaged core from the exact Xray module version already selected in go.mod for Docker and release targets. * fix(tests): stabilize local network fixtures Keep the TUIC stream fragmentation check on a working UDP loopback path, allocate the AmneziaWG fixture ports dynamically, and wait for a real Xray stats response before starting API mutation assertions. Recreate failed readiness channels so an early connection cannot stall the probe. Verified host and offline parallel Go suites, repeated occupied-port and UDP tests, race/shuffle runs, and golangci-lint. Production behavior and dependency versions are unchanged. * fix(i18n): add the kernel-mode AmneziaWG keys to sk-SK main gained the sk-SK locale after this branch added its kernel-mode keys to the other 13, so i18n-dead-keys.test.ts failed on the merged tree. * refactor(amneziawgkern): apply kernel interfaces with direct commands, no awg-quick awg-quick evaluates PostUp/PostDown through a root shell, so every value the panel rendered into its config was one validation slip away from root command execution. The interface is now built with direct ip/awg/nft execs from a setconf-only file: link add, awg setconf, address, mtu, an atomically replaced nftables table per inbound, and the IPv4 forwarding sysctl written directly. - Panel-owned names and paths: xui-awg<id> interfaces, xui_awg<id> NAT tables and configs under the panel's data dir, so nothing collides with an operator's own awg0/awg1 awg-quick tunnels in /etc/amnezia/amneziawg. - FORWARD accepts (comment 3x-ui:xui-awg<id>) when iptables is present: Docker and ufw set the FORWARD policy to DROP, which silently black-holed the tunnel. - A full panel stop records the final counters and tears the interfaces down, like the embedded datapath; a stopped panel enforces no quota or expiry. Crash survivors are still adopted without a rebuild. - The job reads counters before reconciling, and from every managed interface, so a client disabled for quota, or a removed inbound, is charged for its last interval instead of losing it with the dropped peer. - An unchanged reconcile tick forks no process (it probed the live key with `awg show` every 10s); readiness no longer loads the module from a GET. Verified on the real module (WSL2 6.6 kernel, v3.1.20260906): create, peer and egress changes in place, SIGKILL adoption with the same ifindex, SIGTERM teardown leaving no link, table, FORWARD rule or config behind. * fix(amneziawg): gate kernel host readiness only when a save starts the datapath Every save of a kernel-mode inbound re-ran the host preflight, so a host that lost its module (a kernel upgrade without a DKMS rebuild) refused even disabling the inbound or switching it back to embedded mode. Host blockers now apply only when the save starts the kernel datapath: a new inbound, a switch from embedded, or enabling a disabled one. Feature blockers (IPv6, forwarded ports, pinned listen) still apply to every save. * feat(nodes): refuse kernel-mode AmneziaWG pushes to nodes that predate it A node built before kernel mode drops the unknown kernelMode field and serves the inbound on the embedded datapath while the master's form shows kernel mode on. Nodes now advertise awg-kernel in X-3x-Node-Caps, and Remote refuses to push a kernel-mode inbound to a node that has not advertised it, learning the caps with an inbound list first when this Remote has not heard from it. * fix(amneziawg): pin the kernel module installer to verified commits The installer ran as root and built whatever upstream tag sorted newest at install time. It now clones fixed tags (module v3.1.20260906, tools v3.1.20260812) and refuses unless each resolves to its recorded commit. The udp_tunnel perl patch is gone: those releases carry the kernel 7.1.5 compat upstream, so the patch never matched. * fix(i18n): translate the kernel-mode AmneziaWG strings All thirteen non-English locales carried the English text verbatim. * fix(amneziawg): skip the kernel traffic flush when no kernel interface runs Shutdown listed the desired AmneziaWG inbounds from the database even on a panel with no kernel interface, an unneeded query on every stop. A server stopped before its database was opened panicked on it, which turned TestServerStopWhenXrayNotRunningSucceeds red whenever it ran first. --------- Co-authored-by: MHSanaei <ho3ein.sanaei@gmail.com>
1018 lines
35 KiB
Go
1018 lines
35 KiB
Go
package service
|
|
|
|
import (
|
|
"net"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
|
|
"github.com/op/go-logging"
|
|
|
|
"github.com/mhsanaei/3x-ui/v3/internal/amneziawgnet"
|
|
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
|
"github.com/mhsanaei/3x-ui/v3/internal/database/dbtest"
|
|
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
|
xuilogger "github.com/mhsanaei/3x-ui/v3/internal/logger"
|
|
)
|
|
|
|
// the panel logger is a process-wide singleton. init it once per test
|
|
// binary so a stray warning from gorm doesn't blow up on a nil logger.
|
|
var portConflictLoggerOnce sync.Once
|
|
|
|
// setupConflictDB wires a temp sqlite db so checkPortConflict can read
|
|
// real candidates. closes the db before t.TempDir cleans up so windows
|
|
// doesn't refuse to remove the file.
|
|
func setupConflictDB(t *testing.T) {
|
|
t.Helper()
|
|
portConflictLoggerOnce.Do(func() { xuilogger.InitLogger(logging.ERROR) })
|
|
|
|
dbDir := t.TempDir()
|
|
t.Setenv("XUI_DB_FOLDER", dbDir)
|
|
dbtest.InitDB(t, filepath.Join(dbDir, "x-ui.db"))
|
|
}
|
|
|
|
func seedInboundConflict(t *testing.T, tag, listen string, port int, protocol model.Protocol, streamSettings, settings string) {
|
|
t.Helper()
|
|
seedInboundConflictNode(t, tag, listen, port, protocol, streamSettings, settings, nil)
|
|
}
|
|
|
|
func seedInboundConflictNode(t *testing.T, tag, listen string, port int, protocol model.Protocol, streamSettings, settings string, nodeID *int) {
|
|
t.Helper()
|
|
in := &model.Inbound{
|
|
Tag: tag,
|
|
Enable: true,
|
|
Listen: listen,
|
|
Port: port,
|
|
Protocol: protocol,
|
|
StreamSettings: streamSettings,
|
|
Settings: settings,
|
|
NodeID: nodeID,
|
|
}
|
|
if err := database.GetDB().Create(in).Error; err != nil {
|
|
t.Fatalf("seed inbound %s: %v", tag, err)
|
|
}
|
|
}
|
|
|
|
func TestInboundTransports(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
protocol model.Protocol
|
|
streamSettings string
|
|
settings string
|
|
want transportBits
|
|
}{
|
|
{"vless default tcp", model.VLESS, `{"network":"tcp"}`, ``, transportTCP},
|
|
{"vless ws (still tcp)", model.VLESS, `{"network":"ws"}`, ``, transportTCP},
|
|
{"vless kcp is udp", model.VLESS, `{"network":"kcp"}`, ``, transportUDP},
|
|
{"vless empty stream defaults to tcp", model.VLESS, ``, ``, transportTCP},
|
|
{"vless garbage stream stays tcp", model.VLESS, `not json`, ``, transportTCP},
|
|
|
|
{"vmess default tcp", model.VMESS, `{"network":"tcp"}`, ``, transportTCP},
|
|
{"trojan grpc is tcp", model.Trojan, `{"network":"grpc"}`, ``, transportTCP},
|
|
|
|
{"hysteria forced udp", model.Hysteria, `{"network":"tcp"}`, ``, transportUDP},
|
|
{"wireguard forced udp", model.WireGuard, ``, ``, transportUDP},
|
|
|
|
// MASQUE serves HTTP/3 on UDP unless the TLS ALPN offers only h2, and adds an
|
|
// HTTP/2 listener on TCP whenever h2 is offered.
|
|
{"masque without alpn is udp", model.MASQUE, `{"network":"masque","tlsSettings":{}}`, ``, transportUDP},
|
|
{"masque h3 is udp", model.MASQUE, `{"network":"masque","tlsSettings":{"alpn":["h3"]}}`, ``, transportUDP},
|
|
{"masque h2 is tcp", model.MASQUE, `{"network":"masque","tlsSettings":{"alpn":["h2"]}}`, ``, transportTCP},
|
|
{"masque h3+h2 is both", model.MASQUE, `{"network":"masque","tlsSettings":{"alpn":["h3","h2"]}}`, ``, transportTCP | transportUDP},
|
|
|
|
{"shadowsocks tcp,udp", model.Shadowsocks, ``, `{"network":"tcp,udp"}`, transportTCP | transportUDP},
|
|
{"shadowsocks udp only", model.Shadowsocks, ``, `{"network":"udp"}`, transportUDP},
|
|
{"shadowsocks tcp only", model.Shadowsocks, ``, `{"network":"tcp"}`, transportTCP},
|
|
{"shadowsocks empty network falls back to streamSettings", model.Shadowsocks, `{"network":"tcp"}`, `{}`, transportTCP},
|
|
|
|
{"mixed udp on", model.Mixed, `{"network":"tcp"}`, `{"udp":true}`, transportTCP | transportUDP},
|
|
{"mixed udp off", model.Mixed, `{"network":"tcp"}`, `{"udp":false}`, transportTCP},
|
|
{"mixed udp missing", model.Mixed, `{"network":"tcp"}`, `{}`, transportTCP},
|
|
}
|
|
|
|
for _, c := range cases {
|
|
t.Run(c.name, func(t *testing.T) {
|
|
got := inboundTransports(c.protocol, c.streamSettings, c.settings)
|
|
if got != c.want {
|
|
t.Fatalf("got bits %#b, want %#b", got, c.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestListenOverlaps(t *testing.T) {
|
|
cases := []struct {
|
|
a, b string
|
|
want bool
|
|
}{
|
|
{"", "", true},
|
|
{"0.0.0.0", "", true},
|
|
{"0.0.0.0", "1.2.3.4", true},
|
|
{"::", "1.2.3.4", true},
|
|
{"::0", "fe80::1", true},
|
|
{"1.2.3.4", "1.2.3.4", true},
|
|
{"1.2.3.4", "5.6.7.8", false},
|
|
{"1.2.3.4", "::1", false},
|
|
}
|
|
for _, c := range cases {
|
|
if got := listenOverlaps(bindAddr{listen: c.a}, bindAddr{listen: c.b}); got != c.want {
|
|
t.Errorf("listenOverlaps(%q, %q) = %v, want %v", c.a, c.b, got, c.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// the actual case from #4103: tcp/443 vless reality and udp/443
|
|
// hysteria must be allowed to coexist on the same port.
|
|
func TestCheckPortConflict_TCPandUDPCoexistOnSamePort(t *testing.T) {
|
|
setupConflictDB(t)
|
|
seedInboundConflict(t, "vless-443-tcp", "0.0.0.0", 443, model.VLESS, `{"network":"tcp"}`, `{}`)
|
|
|
|
svc := &InboundService{}
|
|
hyst2 := &model.Inbound{
|
|
Tag: "hyst2-443-udp",
|
|
Listen: "0.0.0.0",
|
|
Port: 443,
|
|
Protocol: model.Hysteria,
|
|
}
|
|
exist, err := svc.checkPortConflict(hyst2, 0)
|
|
if err != nil {
|
|
t.Fatalf("checkPortConflict: %v", err)
|
|
}
|
|
if exist != nil {
|
|
t.Fatalf("vless/tcp and hysteria2/udp on the same port must be allowed to coexist")
|
|
}
|
|
}
|
|
|
|
// two tcp inbounds on the same port still conflict.
|
|
func TestCheckPortConflict_TCPCollidesWithTCP(t *testing.T) {
|
|
setupConflictDB(t)
|
|
seedInboundConflict(t, "vless-443-a", "0.0.0.0", 443, model.VLESS, `{"network":"tcp"}`, `{}`)
|
|
|
|
svc := &InboundService{}
|
|
other := &model.Inbound{
|
|
Tag: "vless-443-b",
|
|
Listen: "0.0.0.0",
|
|
Port: 443,
|
|
Protocol: model.Trojan,
|
|
StreamSettings: `{"network":"ws"}`,
|
|
}
|
|
exist, err := svc.checkPortConflict(other, 0)
|
|
if err != nil {
|
|
t.Fatalf("checkPortConflict: %v", err)
|
|
}
|
|
if exist == nil {
|
|
t.Fatalf("two tcp inbounds on the same port must still conflict")
|
|
}
|
|
}
|
|
|
|
// two udp inbounds (e.g. hysteria2 vs wireguard) on the same port also
|
|
// conflict, since they fight for the same socket.
|
|
func TestCheckPortConflict_UDPCollidesWithUDP(t *testing.T) {
|
|
setupConflictDB(t)
|
|
seedInboundConflict(t, "hyst2-443", "0.0.0.0", 443, model.Hysteria, ``, ``)
|
|
|
|
svc := &InboundService{}
|
|
wg := &model.Inbound{
|
|
Tag: "wg-443",
|
|
Listen: "0.0.0.0",
|
|
Port: 443,
|
|
Protocol: model.WireGuard,
|
|
}
|
|
exist, err := svc.checkPortConflict(wg, 0)
|
|
if err != nil {
|
|
t.Fatalf("checkPortConflict: %v", err)
|
|
}
|
|
if exist == nil {
|
|
t.Fatalf("two udp inbounds on the same port must conflict")
|
|
}
|
|
}
|
|
|
|
// shadowsocks listening on tcp+udp eats the whole port for both
|
|
// transports, so neither a tcp nor a udp neighbour is allowed.
|
|
func TestCheckPortConflict_ShadowsocksDualListenBlocksBoth(t *testing.T) {
|
|
setupConflictDB(t)
|
|
seedInboundConflict(t, "ss-443-dual", "0.0.0.0", 443, model.Shadowsocks, ``, `{"network":"tcp,udp"}`)
|
|
|
|
svc := &InboundService{}
|
|
|
|
tcpClash := &model.Inbound{
|
|
Tag: "vless-443",
|
|
Listen: "0.0.0.0",
|
|
Port: 443,
|
|
Protocol: model.VLESS,
|
|
StreamSettings: `{"network":"tcp"}`,
|
|
}
|
|
if exist, err := svc.checkPortConflict(tcpClash, 0); err != nil || exist == nil {
|
|
t.Fatalf("tcp inbound should clash with shadowsocks tcp,udp; exist=%v err=%v", exist, err)
|
|
}
|
|
|
|
udpClash := &model.Inbound{
|
|
Tag: "hyst2-443",
|
|
Listen: "0.0.0.0",
|
|
Port: 443,
|
|
Protocol: model.Hysteria,
|
|
}
|
|
if exist, err := svc.checkPortConflict(udpClash, 0); err != nil || exist == nil {
|
|
t.Fatalf("udp inbound should clash with shadowsocks tcp,udp; exist=%v err=%v", exist, err)
|
|
}
|
|
}
|
|
|
|
// different ports never conflict regardless of transport.
|
|
func TestCheckPortConflict_DifferentPortNeverConflicts(t *testing.T) {
|
|
setupConflictDB(t)
|
|
seedInboundConflict(t, "vless-443", "0.0.0.0", 443, model.VLESS, `{"network":"tcp"}`, `{}`)
|
|
|
|
svc := &InboundService{}
|
|
other := &model.Inbound{
|
|
Tag: "vless-444",
|
|
Listen: "0.0.0.0",
|
|
Port: 444,
|
|
Protocol: model.VLESS,
|
|
StreamSettings: `{"network":"tcp"}`,
|
|
}
|
|
if exist, err := svc.checkPortConflict(other, 0); err != nil || exist != nil {
|
|
t.Fatalf("different port must not conflict; exist=%v err=%v", exist, err)
|
|
}
|
|
}
|
|
|
|
// specific listen addresses on the same port don't clash with each other,
|
|
// but do clash with any-address on the same port (preserved from the old
|
|
// check).
|
|
func TestCheckPortConflict_ListenOverlapPreserved(t *testing.T) {
|
|
setupConflictDB(t)
|
|
seedInboundConflict(t, "vless-1.2.3.4", "1.2.3.4", 443, model.VLESS, `{"network":"tcp"}`, `{}`)
|
|
|
|
svc := &InboundService{}
|
|
|
|
// different specific address, same port + transport: no conflict.
|
|
other := &model.Inbound{
|
|
Tag: "vless-5.6.7.8",
|
|
Listen: "5.6.7.8",
|
|
Port: 443,
|
|
Protocol: model.VLESS,
|
|
StreamSettings: `{"network":"tcp"}`,
|
|
}
|
|
if exist, err := svc.checkPortConflict(other, 0); err != nil || exist != nil {
|
|
t.Fatalf("different specific listen must not conflict; exist=%v err=%v", exist, err)
|
|
}
|
|
|
|
// any-address vs specific on same transport: conflict (any-addr wins).
|
|
anyAddr := &model.Inbound{
|
|
Tag: "vless-any",
|
|
Listen: "0.0.0.0",
|
|
Port: 443,
|
|
Protocol: model.VLESS,
|
|
StreamSettings: `{"network":"tcp"}`,
|
|
}
|
|
if exist, err := svc.checkPortConflict(anyAddr, 0); err != nil || exist == nil {
|
|
t.Fatalf("any-addr on same port+transport must conflict with specific; exist=%v err=%v", exist, err)
|
|
}
|
|
}
|
|
|
|
// even with a stale legacy tag owning "in-443", a new UDP-side
|
|
// inbound gets a fully qualified canonical tag and does not collide.
|
|
func TestGenerateInboundTag_DisambiguatesByTransportOnSamePort(t *testing.T) {
|
|
setupConflictDB(t)
|
|
seedInboundConflict(t, "in-443", "0.0.0.0", 443, model.VLESS, `{"network":"tcp"}`, `{}`)
|
|
|
|
svc := &InboundService{}
|
|
udp := &model.Inbound{
|
|
Listen: "0.0.0.0",
|
|
Port: 443,
|
|
Protocol: model.Hysteria,
|
|
}
|
|
got, err := svc.generateInboundTag(udp, 0)
|
|
if err != nil {
|
|
t.Fatalf("generateInboundTag: %v", err)
|
|
}
|
|
if got != "in-443-udp" {
|
|
t.Fatalf("expected in-443-udp, got %q", got)
|
|
}
|
|
}
|
|
|
|
// when the port is free, the canonical tag carries the transport so
|
|
// tcp/8443 and udp/8443 get distinct tags out of the box.
|
|
func TestGenerateInboundTag_KeepsBaseTagWhenFree(t *testing.T) {
|
|
setupConflictDB(t)
|
|
|
|
svc := &InboundService{}
|
|
in := &model.Inbound{
|
|
Listen: "0.0.0.0",
|
|
Port: 8443,
|
|
Protocol: model.VLESS,
|
|
}
|
|
got, err := svc.generateInboundTag(in, 0)
|
|
if err != nil {
|
|
t.Fatalf("generateInboundTag: %v", err)
|
|
}
|
|
if got != "in-8443-tcp" {
|
|
t.Fatalf("expected in-8443-tcp, got %q", got)
|
|
}
|
|
}
|
|
|
|
// updating an inbound on its own port must not flag its own tag as taken;
|
|
// that's what ignoreId is for.
|
|
func TestGenerateInboundTag_IgnoresSelfOnUpdate(t *testing.T) {
|
|
setupConflictDB(t)
|
|
seedInboundConflict(t, "in-443-tcp", "0.0.0.0", 443, model.VLESS, `{"network":"tcp"}`, `{}`)
|
|
|
|
var existing model.Inbound
|
|
if err := database.GetDB().Where("tag = ?", "in-443-tcp").First(&existing).Error; err != nil {
|
|
t.Fatalf("read seeded row: %v", err)
|
|
}
|
|
|
|
svc := &InboundService{}
|
|
got, err := svc.generateInboundTag(&existing, existing.Id)
|
|
if err != nil {
|
|
t.Fatalf("generateInboundTag: %v", err)
|
|
}
|
|
if got != "in-443-tcp" {
|
|
t.Fatalf("self-update must keep base tag, got %q", got)
|
|
}
|
|
}
|
|
|
|
// the listen address never appears in the tag; the transport suffix still
|
|
// keeps a udp inbound distinct from a tcp one on the same port.
|
|
func TestGenerateInboundTag_ListenIgnoredTransportDisambiguates(t *testing.T) {
|
|
setupConflictDB(t)
|
|
seedInboundConflict(t, "in-443-tcp", "1.2.3.4", 443, model.VLESS, `{"network":"tcp"}`, `{}`)
|
|
|
|
svc := &InboundService{}
|
|
udp := &model.Inbound{
|
|
Listen: "1.2.3.4",
|
|
Port: 443,
|
|
Protocol: model.Hysteria,
|
|
}
|
|
got, err := svc.generateInboundTag(udp, 0)
|
|
if err != nil {
|
|
t.Fatalf("generateInboundTag: %v", err)
|
|
}
|
|
if got != "in-443-udp" {
|
|
t.Fatalf("expected in-443-udp, got %q", got)
|
|
}
|
|
}
|
|
|
|
// inbounds bound to different nodes run on different physical machines,
|
|
// so the same port + transport must be allowed across nodes. covers
|
|
// local-vs-remote, remote-A-vs-remote-B, and the still-clashing
|
|
// same-node case.
|
|
func TestCheckPortConflict_NodeScope(t *testing.T) {
|
|
setupConflictDB(t)
|
|
seedInboundConflictNode(t, "local-443-tcp", "0.0.0.0", 443, model.VLESS, `{"network":"tcp"}`, `{}`, nil)
|
|
seedInboundConflictNode(t, "node1-443-tcp", "0.0.0.0", 443, model.VLESS, `{"network":"tcp"}`, `{}`, new(1))
|
|
|
|
svc := &InboundService{}
|
|
|
|
cases := []struct {
|
|
name string
|
|
nodeID *int
|
|
want bool
|
|
}{
|
|
{"new local same port + tcp clashes with local", nil, true},
|
|
{"new remote on different node from local is fine", new(2), false},
|
|
{"new remote on existing node 1 clashes", new(1), true},
|
|
}
|
|
for _, c := range cases {
|
|
t.Run(c.name, func(t *testing.T) {
|
|
candidate := &model.Inbound{
|
|
Listen: "0.0.0.0",
|
|
Port: 443,
|
|
Protocol: model.VLESS,
|
|
StreamSettings: `{"network":"tcp"}`,
|
|
NodeID: c.nodeID,
|
|
}
|
|
got, err := svc.checkPortConflict(candidate, 0)
|
|
if err != nil {
|
|
t.Fatalf("checkPortConflict: %v", err)
|
|
}
|
|
if (got != nil) != c.want {
|
|
t.Fatalf("got conflict=%v, want %v", got != nil, c.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// when the caller passes an explicit non-empty Tag that doesn't collide,
|
|
// resolveInboundTag returns it verbatim. this is the cross-panel path:
|
|
// the central panel picks a tag, pushes the inbound to a node, and the
|
|
// node must keep that exact tag so the eventual traffic sync-back can
|
|
// match the row by tag. previously the node regenerated and the two
|
|
// panels diverged, causing a UNIQUE constraint failure on sync.
|
|
func TestResolveInboundTag_RespectsCallerTagWhenFree(t *testing.T) {
|
|
setupConflictDB(t)
|
|
seedInboundConflictNode(t, "in-5000-tcp", "0.0.0.0", 5000, model.VLESS, `{"network":"tcp"}`, `{}`, nil)
|
|
seedInboundConflictNode(t, "in-5000-udp", "0.0.0.0", 5000, model.Hysteria, ``, ``, nil)
|
|
|
|
svc := &InboundService{}
|
|
pushed := &model.Inbound{
|
|
Tag: "custom-pushed-tag",
|
|
Listen: "0.0.0.0",
|
|
Port: 5000,
|
|
Protocol: model.VLESS,
|
|
StreamSettings: `{"network":"tcp"}`,
|
|
NodeID: new(1),
|
|
}
|
|
got, err := svc.resolveInboundTag(pushed, 0)
|
|
if err != nil {
|
|
t.Fatalf("resolveInboundTag: %v", err)
|
|
}
|
|
if got != "custom-pushed-tag" {
|
|
t.Fatalf("caller tag must be preserved when free, got %q", got)
|
|
}
|
|
}
|
|
|
|
// when the caller leaves Tag empty (the local UI path) resolveInboundTag
|
|
// falls back to generateInboundTag, which emits the canonical
|
|
// "in-<port>-<transport>" shape.
|
|
func TestResolveInboundTag_GeneratesWhenTagEmpty(t *testing.T) {
|
|
setupConflictDB(t)
|
|
|
|
svc := &InboundService{}
|
|
in := &model.Inbound{
|
|
Listen: "0.0.0.0",
|
|
Port: 8443,
|
|
Protocol: model.VLESS,
|
|
}
|
|
got, err := svc.resolveInboundTag(in, 0)
|
|
if err != nil {
|
|
t.Fatalf("resolveInboundTag: %v", err)
|
|
}
|
|
if got != "in-8443-tcp" {
|
|
t.Fatalf("expected generated in-8443-tcp, got %q", got)
|
|
}
|
|
}
|
|
|
|
// when the caller's Tag collides (e.g. a node that was used standalone
|
|
// happens to already own the tag the central panel picked),
|
|
// resolveInboundTag falls back to generateInboundTag rather than
|
|
// failing — the inbound still lands, just under a slightly different
|
|
// tag that the central will pick up via the AddInbound response.
|
|
func TestResolveInboundTag_RegeneratesOnCollision(t *testing.T) {
|
|
setupConflictDB(t)
|
|
seedInboundConflictNode(t, "in-5000-tcp", "0.0.0.0", 5000, model.VLESS, `{"network":"tcp"}`, `{}`, nil)
|
|
|
|
svc := &InboundService{}
|
|
pushed := &model.Inbound{
|
|
Tag: "in-5000-tcp",
|
|
Listen: "0.0.0.0",
|
|
Port: 5000,
|
|
Protocol: model.Hysteria,
|
|
StreamSettings: ``,
|
|
Settings: ``,
|
|
}
|
|
got, err := svc.resolveInboundTag(pushed, 0)
|
|
if err != nil {
|
|
t.Fatalf("resolveInboundTag: %v", err)
|
|
}
|
|
if got == "in-5000-tcp" {
|
|
t.Fatalf("colliding caller tag must be replaced, but resolver kept %q", got)
|
|
}
|
|
}
|
|
|
|
// inbounds bound to a remote node get the canonical tag prefixed with
|
|
// "n<id>-" so the same listen+port+transport can live on the central
|
|
// panel and on the node simultaneously without bumping the global
|
|
// UNIQUE(inbounds.tag) constraint.
|
|
func TestGenerateInboundTag_NodePrefix(t *testing.T) {
|
|
setupConflictDB(t)
|
|
|
|
svc := &InboundService{}
|
|
in := &model.Inbound{
|
|
Listen: "0.0.0.0",
|
|
Port: 443,
|
|
Protocol: model.VLESS,
|
|
NodeID: new(1),
|
|
}
|
|
got, err := svc.generateInboundTag(in, 0)
|
|
if err != nil {
|
|
t.Fatalf("generateInboundTag: %v", err)
|
|
}
|
|
if got != "n1-in-443-tcp" {
|
|
t.Fatalf("expected n1-in-443-tcp, got %q", got)
|
|
}
|
|
}
|
|
|
|
// a node-prefixed inbound shouldn't collide with a same-port local one:
|
|
// the prefix scopes the tag to that specific node.
|
|
func TestGenerateInboundTag_NodePrefixedDoesNotCollideWithLocal(t *testing.T) {
|
|
setupConflictDB(t)
|
|
seedInboundConflict(t, "in-443-tcp", "0.0.0.0", 443, model.VLESS, `{"network":"tcp"}`, `{}`)
|
|
|
|
svc := &InboundService{}
|
|
in := &model.Inbound{
|
|
Listen: "0.0.0.0",
|
|
Port: 443,
|
|
Protocol: model.VLESS,
|
|
NodeID: new(1),
|
|
}
|
|
got, err := svc.generateInboundTag(in, 0)
|
|
if err != nil {
|
|
t.Fatalf("generateInboundTag: %v", err)
|
|
}
|
|
if got != "n1-in-443-tcp" {
|
|
t.Fatalf("expected n1-in-443-tcp, got %q", got)
|
|
}
|
|
}
|
|
|
|
// updating an inbound must not see itself as a conflict, that's what
|
|
// ignoreId is for.
|
|
func TestCheckPortConflict_IgnoreSelfOnUpdate(t *testing.T) {
|
|
setupConflictDB(t)
|
|
seedInboundConflict(t, "vless-443", "0.0.0.0", 443, model.VLESS, `{"network":"tcp"}`, `{}`)
|
|
|
|
var existing model.Inbound
|
|
if err := database.GetDB().Where("tag = ?", "vless-443").First(&existing).Error; err != nil {
|
|
t.Fatalf("read seeded row: %v", err)
|
|
}
|
|
|
|
svc := &InboundService{}
|
|
if exist, err := svc.checkPortConflict(&existing, existing.Id); err != nil || exist != nil {
|
|
t.Fatalf("self-update must not be flagged as conflict; exist=%v err=%v", exist, err)
|
|
}
|
|
}
|
|
|
|
// streamSettings.network=quic rides on UDP at L4, so a QUIC inbound must
|
|
// conflict with a UDP-only neighbour (hysteria) on the same port but not
|
|
// with a TCP-only one. covers the gap left by the original kcp-only check.
|
|
func TestCheckPortConflict_QUICTreatedAsUDP(t *testing.T) {
|
|
quic := &model.Inbound{
|
|
Tag: "vless-quic-443",
|
|
Listen: "0.0.0.0",
|
|
Port: 443,
|
|
Protocol: model.VLESS,
|
|
StreamSettings: `{"network":"quic"}`,
|
|
}
|
|
|
|
t.Run("conflicts with hysteria/udp", func(t *testing.T) {
|
|
setupConflictDB(t)
|
|
seedInboundConflict(t, "hyst-443", "0.0.0.0", 443, model.Hysteria, ``, ``)
|
|
svc := &InboundService{}
|
|
if exist, err := svc.checkPortConflict(quic, 0); err != nil || exist == nil {
|
|
t.Fatalf("quic on same port as hysteria must conflict; exist=%v err=%v", exist, err)
|
|
}
|
|
})
|
|
|
|
t.Run("coexists with vless/tcp", func(t *testing.T) {
|
|
setupConflictDB(t)
|
|
seedInboundConflict(t, "vless-tcp-443", "0.0.0.0", 443, model.VLESS, `{"network":"tcp"}`, `{}`)
|
|
svc := &InboundService{}
|
|
if exist, err := svc.checkPortConflict(quic, 0); err != nil || exist != nil {
|
|
t.Fatalf("quic and tcp on same port must coexist; exist=%v err=%v", exist, err)
|
|
}
|
|
})
|
|
}
|
|
|
|
// tunnel (dokodemo-door) carries its L4 transport list in
|
|
// settings.allowedNetwork, not settings.network. verify the predicate
|
|
// picks the right field for each protocol.
|
|
func TestCheckPortConflict_TunnelAllowedNetwork(t *testing.T) {
|
|
setupConflictDB(t)
|
|
seedInboundConflict(t, "tunnel-udp-443", "0.0.0.0", 443, model.Tunnel, ``, `{"allowedNetwork":"udp"}`)
|
|
|
|
svc := &InboundService{}
|
|
|
|
// tcp inbound on same port should coexist with udp-only tunnel.
|
|
tcpNeighbour := &model.Inbound{
|
|
Tag: "vless-443",
|
|
Listen: "0.0.0.0",
|
|
Port: 443,
|
|
Protocol: model.VLESS,
|
|
StreamSettings: `{"network":"tcp"}`,
|
|
}
|
|
if exist, err := svc.checkPortConflict(tcpNeighbour, 0); err != nil || exist != nil {
|
|
t.Fatalf("tunnel/udp and vless/tcp on same port must coexist; exist=%v err=%v", exist, err)
|
|
}
|
|
|
|
// udp neighbour (hysteria) on same port must conflict.
|
|
udpNeighbour := &model.Inbound{
|
|
Tag: "hyst-443",
|
|
Listen: "0.0.0.0",
|
|
Port: 443,
|
|
Protocol: model.Hysteria,
|
|
}
|
|
if exist, err := svc.checkPortConflict(udpNeighbour, 0); err != nil || exist == nil {
|
|
t.Fatalf("tunnel/udp and hysteria on same port must conflict; exist=%v err=%v", exist, err)
|
|
}
|
|
}
|
|
|
|
// the rich conflict detail surfaced to the user must name the offending
|
|
// inbound (by remark when available) and the shared L4 transport(s).
|
|
func TestCheckPortConflict_DetailMessage(t *testing.T) {
|
|
setupConflictDB(t)
|
|
seeded := &model.Inbound{
|
|
Tag: "vless-443",
|
|
Remark: "my-vless",
|
|
Enable: true,
|
|
Listen: "0.0.0.0",
|
|
Port: 443,
|
|
Protocol: model.VLESS,
|
|
StreamSettings: `{"network":"tcp"}`,
|
|
Settings: `{}`,
|
|
}
|
|
if err := database.GetDB().Create(seeded).Error; err != nil {
|
|
t.Fatalf("seed inbound: %v", err)
|
|
}
|
|
|
|
svc := &InboundService{}
|
|
candidate := &model.Inbound{
|
|
Tag: "trojan-443",
|
|
Listen: "0.0.0.0",
|
|
Port: 443,
|
|
Protocol: model.Trojan,
|
|
StreamSettings: `{"network":"ws"}`,
|
|
}
|
|
got, err := svc.checkPortConflict(candidate, 0)
|
|
if err != nil || got == nil {
|
|
t.Fatalf("expected conflict, got=%v err=%v", got, err)
|
|
}
|
|
msg := got.String()
|
|
if !strings.Contains(msg, "my-vless") {
|
|
t.Fatalf("message should mention the conflicting inbound's remark; got %q", msg)
|
|
}
|
|
if !strings.Contains(msg, "tcp") {
|
|
t.Fatalf("message should mention the shared L4 transport; got %q", msg)
|
|
}
|
|
if !strings.Contains(msg, "443") {
|
|
t.Fatalf("message should mention the port; got %q", msg)
|
|
}
|
|
}
|
|
|
|
// isAutoGeneratedTag must recognise the tags generateInboundTag emits (so an
|
|
// edit that changes port/transport re-derives them) while leaving user-typed
|
|
// or cross-panel tags untouched.
|
|
func TestIsAutoGeneratedTag(t *testing.T) {
|
|
tcp := transportTCP
|
|
cases := []struct {
|
|
name string
|
|
tag string
|
|
port int
|
|
nodeID *int
|
|
bits transportBits
|
|
want bool
|
|
}{
|
|
{"canonical", "in-443-tcp", 443, nil, tcp, true},
|
|
{"canonical udp", "in-443-udp", 443, nil, transportUDP, true},
|
|
{"dedup suffix", "in-443-tcp-2", 443, nil, tcp, true},
|
|
{"node prefixed", "n1-in-443-tcp", 443, new(1), tcp, true},
|
|
{"legacy listen-scoped is now custom", "in-127.0.0.1:443-tcp", 443, nil, tcp, false},
|
|
{"custom tag", "my-cool-tag", 443, nil, tcp, false},
|
|
{"stale port", "in-443-tcp", 8443, nil, tcp, false},
|
|
{"stale transport", "in-443-tcp", 443, nil, transportUDP, false},
|
|
{"non-numeric suffix", "in-443-tcp-x", 443, nil, tcp, false},
|
|
{"empty suffix", "in-443-tcp-", 443, nil, tcp, false},
|
|
}
|
|
for _, c := range cases {
|
|
t.Run(c.name, func(t *testing.T) {
|
|
if got := isAutoGeneratedTag(c.tag, c.port, c.nodeID, c.bits); got != c.want {
|
|
t.Fatalf("isAutoGeneratedTag(%q) = %v, want %v", c.tag, got, c.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// the internal Xray API inbound (tag "api", loopback TCP) isn't a DB row, so
|
|
// checkPortConflict must still reject a local user inbound that reuses its
|
|
// reserved port — otherwise Xray binds the port twice (#5304).
|
|
func TestCheckPortConflict_ReservedAPIPortBlockedLocal(t *testing.T) {
|
|
setupConflictDB(t)
|
|
|
|
svc := &InboundService{}
|
|
candidate := &model.Inbound{
|
|
Tag: "user-62789",
|
|
Listen: "0.0.0.0",
|
|
Port: defaultXrayAPIPort,
|
|
Protocol: model.VLESS,
|
|
StreamSettings: `{"network":"tcp"}`,
|
|
}
|
|
got, err := svc.checkPortConflict(candidate, 0)
|
|
if err != nil {
|
|
t.Fatalf("checkPortConflict: %v", err)
|
|
}
|
|
if got == nil {
|
|
t.Fatalf("local inbound on the reserved API port %d must conflict", defaultXrayAPIPort)
|
|
}
|
|
if msg := got.String(); !strings.Contains(msg, "api") {
|
|
t.Fatalf("conflict message should name the api inbound; got %q", msg)
|
|
}
|
|
}
|
|
|
|
// nodes run their own Xray with their own API port, so a node inbound on the
|
|
// central panel's reserved API port must be allowed.
|
|
func TestCheckPortConflict_ReservedAPIPortAllowedOnNode(t *testing.T) {
|
|
setupConflictDB(t)
|
|
|
|
svc := &InboundService{}
|
|
candidate := &model.Inbound{
|
|
Tag: "node-62789",
|
|
Listen: "0.0.0.0",
|
|
Port: defaultXrayAPIPort,
|
|
Protocol: model.VLESS,
|
|
StreamSettings: `{"network":"tcp"}`,
|
|
NodeID: new(1),
|
|
}
|
|
if got, err := svc.checkPortConflict(candidate, 0); err != nil || got != nil {
|
|
t.Fatalf("node inbound on the reserved API port must be allowed; got=%v err=%v", got, err)
|
|
}
|
|
}
|
|
|
|
// the API inbound is TCP-only, so a UDP-only inbound (e.g. hysteria) may share
|
|
// its port — same tcp/udp coexistence the rest of the checks allow.
|
|
func TestCheckPortConflict_ReservedAPIPortUDPCoexists(t *testing.T) {
|
|
setupConflictDB(t)
|
|
|
|
svc := &InboundService{}
|
|
candidate := &model.Inbound{
|
|
Tag: "hyst-62789",
|
|
Listen: "0.0.0.0",
|
|
Port: defaultXrayAPIPort,
|
|
Protocol: model.Hysteria,
|
|
}
|
|
if got, err := svc.checkPortConflict(candidate, 0); err != nil || got != nil {
|
|
t.Fatalf("udp-only inbound must coexist with the tcp API inbound; got=%v err=%v", got, err)
|
|
}
|
|
}
|
|
|
|
// amneziawgRoutedSettings builds a minimal but complete AmneziaWG settings
|
|
// blob with one qualifying, enabled peer -- the shape that makes
|
|
// injectAmneziawgnetSocks (and therefore checkAmneziawgnetSocksConflict)
|
|
// create a relay inbound at all. The routeThroughXray field is kept in the
|
|
// JSON (a stale value from a pre-cutover install) specifically to prove
|
|
// it's now ignored -- see the "RouteThroughXrayOff" test below.
|
|
const amneziawgRoutedSettings = `{"server":{"privateKey":"priv","publicKey":"pub","subnetIp":"10.8.1.0","subnetCidr":24,"routeThroughXray":true},"clients":[{"email":"a@x","enable":true,"publicKey":"pub-a","allowedIPs":["10.8.1.2/32"]}]}`
|
|
|
|
// A local TCP inbound on EgressBasePort must conflict with the AmneziaWG
|
|
// egress SOCKS server (which is not in the database).
|
|
func TestCheckPortConflict_EgressPortBlockedLocal(t *testing.T) {
|
|
setupConflictDB(t)
|
|
|
|
svc := &InboundService{}
|
|
candidate := &model.Inbound{
|
|
Tag: "vless-bridge",
|
|
Listen: "0.0.0.0",
|
|
Port: int(amneziawgnet.EgressBasePort),
|
|
Protocol: model.VLESS,
|
|
}
|
|
got, err := svc.checkPortConflict(candidate, 0)
|
|
if err != nil {
|
|
t.Fatalf("checkPortConflict: %v", err)
|
|
}
|
|
if got == nil {
|
|
t.Fatalf("a local inbound on the egress port %d must conflict", amneziawgnet.EgressBasePort)
|
|
}
|
|
}
|
|
|
|
// Where EgressBasePort is taken the egress listens on another port, and that
|
|
// is the port an inbound must not collide with.
|
|
func TestCheckPortConflict_EgressPortFollowsTheListener(t *testing.T) {
|
|
setupConflictDB(t)
|
|
if ln, err := net.Listen("tcp", net.JoinHostPort("127.0.0.1", strconv.Itoa(amneziawgnet.EgressBasePort))); err == nil {
|
|
t.Cleanup(func() { ln.Close() })
|
|
}
|
|
egress := amneziawgnet.GetEgressServer()
|
|
if err := egress.Listen(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(egress.Close)
|
|
|
|
svc := &InboundService{}
|
|
candidate := &model.Inbound{Tag: "vless-bridge", Listen: "0.0.0.0", Port: egress.Port(), Protocol: model.VLESS}
|
|
got, err := svc.checkPortConflict(candidate, 0)
|
|
if err != nil {
|
|
t.Fatalf("checkPortConflict: %v", err)
|
|
}
|
|
if got == nil || got.Tag != "amneziawg-egress" {
|
|
t.Fatalf("an inbound on the egress's port %d must conflict with amneziawg-egress, got %+v", egress.Port(), got)
|
|
}
|
|
}
|
|
|
|
func TestCheckPortConflict_AmneziawgnetSocksRelayBlockedLocal(t *testing.T) {
|
|
setupConflictDB(t)
|
|
seedInboundConflict(t, "awg-1", "0.0.0.0", 51820, model.AmneziaWG, ``, amneziawgRoutedSettings)
|
|
|
|
var awgInbound model.Inbound
|
|
if err := database.GetDB().Where("tag = ?", "awg-1").First(&awgInbound).Error; err != nil {
|
|
t.Fatalf("read seeded row: %v", err)
|
|
}
|
|
relayPort := amneziawgnet.SOCKSPortForInbound(awgInbound.Id)
|
|
|
|
svc := &InboundService{}
|
|
candidate := &model.Inbound{
|
|
Tag: "vless-bridge",
|
|
Listen: "0.0.0.0",
|
|
Port: relayPort,
|
|
Protocol: model.VLESS,
|
|
}
|
|
got, err := svc.checkPortConflict(candidate, 0)
|
|
if err != nil {
|
|
t.Fatalf("checkPortConflict: %v", err)
|
|
}
|
|
if got == nil {
|
|
t.Fatalf("a local inbound on the AmneziaWG relay port %d must conflict", relayPort)
|
|
}
|
|
if msg := got.String(); !strings.Contains(msg, "awg-1") {
|
|
t.Fatalf("conflict message should name the owning AmneziaWG inbound; got %q", msg)
|
|
}
|
|
}
|
|
|
|
func TestCheckPortConflict_KernelAmneziaWGDoesNotReserveEmbeddedRelay(t *testing.T) {
|
|
setupConflictDB(t)
|
|
seedInboundConflict(t, "awg-kernel", "0.0.0.0", 51820, model.AmneziaWG, ``,
|
|
`{"server":{"privateKey":"priv","publicKey":"pub","subnetIp":"10.8.1.0","subnetCidr":24,"kernelMode":true},"clients":[]}`)
|
|
|
|
var awgInbound model.Inbound
|
|
if err := database.GetDB().Where("tag = ?", "awg-kernel").First(&awgInbound).Error; err != nil {
|
|
t.Fatalf("read seeded row: %v", err)
|
|
}
|
|
candidate := &model.Inbound{
|
|
Tag: "vless-kernel-relay-port",
|
|
Listen: "0.0.0.0",
|
|
Port: amneziawgnet.SOCKSPortForInbound(awgInbound.Id),
|
|
Protocol: model.VLESS,
|
|
}
|
|
if got, err := (&InboundService{}).checkPortConflict(candidate, 0); err != nil || got != nil {
|
|
t.Fatalf("kernel-mode AmneziaWG must not reserve an embedded relay port, including with no peers; got=%v err=%v", got, err)
|
|
}
|
|
}
|
|
|
|
func TestCheckPortConflict_KernelAmneziaWGAllowsItsUDPPortToMatchRelayRange(t *testing.T) {
|
|
setupConflictDB(t)
|
|
seedInboundConflict(t, "awg-kernel", "0.0.0.0", 51820, model.AmneziaWG, ``,
|
|
`{"server":{"privateKey":"priv","publicKey":"pub","subnetIp":"10.8.1.0","subnetCidr":24,"kernelMode":true},"clients":[]}`)
|
|
|
|
var awgInbound model.Inbound
|
|
if err := database.GetDB().Where("tag = ?", "awg-kernel").First(&awgInbound).Error; err != nil {
|
|
t.Fatalf("read seeded row: %v", err)
|
|
}
|
|
awgInbound.Port = amneziawgnet.SOCKSPortForInbound(awgInbound.Id)
|
|
if got, err := (&InboundService{}).checkPortConflict(&awgInbound, awgInbound.Id); err != nil || got != nil {
|
|
t.Fatalf("kernel-mode AmneziaWG UDP port may use a port in the embedded TCP relay range; got=%v err=%v", got, err)
|
|
}
|
|
}
|
|
|
|
// Nodes run their own Xray, so a node inbound landing on the central panel's
|
|
// AmneziaWG relay port must be allowed -- the relay inbound only ever binds
|
|
// 127.0.0.1 on the local panel's own Xray.
|
|
func TestCheckPortConflict_AmneziawgnetSocksRelayAllowedOnNode(t *testing.T) {
|
|
setupConflictDB(t)
|
|
seedInboundConflict(t, "awg-1", "0.0.0.0", 51820, model.AmneziaWG, ``, amneziawgRoutedSettings)
|
|
|
|
var awgInbound model.Inbound
|
|
if err := database.GetDB().Where("tag = ?", "awg-1").First(&awgInbound).Error; err != nil {
|
|
t.Fatalf("read seeded row: %v", err)
|
|
}
|
|
relayPort := amneziawgnet.SOCKSPortForInbound(awgInbound.Id)
|
|
|
|
svc := &InboundService{}
|
|
candidate := &model.Inbound{
|
|
Tag: "node-bridge",
|
|
Listen: "0.0.0.0",
|
|
Port: relayPort,
|
|
Protocol: model.VLESS,
|
|
NodeID: new(1),
|
|
}
|
|
if got, err := svc.checkPortConflict(candidate, 0); err != nil || got != nil {
|
|
t.Fatalf("a node inbound on the local AmneziaWG relay port must be allowed; got=%v err=%v", got, err)
|
|
}
|
|
}
|
|
|
|
// Unlike the retired kernel-module bridge, the embedded relay has no
|
|
// RouteThroughXray-style opt-in -- every qualifying AmneziaWG inbound
|
|
// reserves its relay port regardless of that (now-vestigial) field's value,
|
|
// including a stale routeThroughXray:true left over from a pre-cutover
|
|
// install (amneziawgRoutedSettings).
|
|
func TestCheckPortConflict_AmneziawgnetSocksRelayReservedRegardlessOfLegacyRouteThroughXrayField(t *testing.T) {
|
|
setupConflictDB(t)
|
|
seedInboundConflict(t, "awg-1", "0.0.0.0", 51820, model.AmneziaWG, ``, `{"server":{"privateKey":"priv","publicKey":"pub","subnetIp":"10.8.1.0","subnetCidr":24},"clients":[{"email":"a@x","enable":true,"publicKey":"pub-a","allowedIPs":["10.8.1.2/32"]}]}`)
|
|
|
|
var awgInbound model.Inbound
|
|
if err := database.GetDB().Where("tag = ?", "awg-1").First(&awgInbound).Error; err != nil {
|
|
t.Fatalf("read seeded row: %v", err)
|
|
}
|
|
relayPort := amneziawgnet.SOCKSPortForInbound(awgInbound.Id)
|
|
|
|
svc := &InboundService{}
|
|
candidate := &model.Inbound{
|
|
Tag: "vless-bridge",
|
|
Listen: "0.0.0.0",
|
|
Port: relayPort,
|
|
Protocol: model.VLESS,
|
|
}
|
|
got, err := svc.checkPortConflict(candidate, 0)
|
|
if err != nil {
|
|
t.Fatalf("checkPortConflict: %v", err)
|
|
}
|
|
if got == nil {
|
|
t.Fatalf("an enabled, qualifying AmneziaWG inbound must reserve its relay port even with RouteThroughXray left at its default")
|
|
}
|
|
}
|
|
|
|
// A local AmneziaWG inbound owns its relay port from the row, not from its first
|
|
// peer: the relay appears when a client is added, and that path runs no port check.
|
|
func TestCheckPortConflict_AmneziawgnetSocksRelayReservedBeforeTheFirstPeer(t *testing.T) {
|
|
setupConflictDB(t)
|
|
// The shape normalizeAmneziaWGSettings writes for a fresh AmneziaWG inbound.
|
|
seedInboundConflict(t, "awg-1", "0.0.0.0", 51820, model.AmneziaWG, ``,
|
|
`{"server":{"privateKey":"priv","publicKey":"pub","subnetIp":"10.8.1.0","subnetCidr":24},"clients":[]}`)
|
|
|
|
var awgInbound model.Inbound
|
|
if err := database.GetDB().Where("tag = ?", "awg-1").First(&awgInbound).Error; err != nil {
|
|
t.Fatalf("read seeded row: %v", err)
|
|
}
|
|
relayPort := amneziawgnet.SOCKSPortForInbound(awgInbound.Id)
|
|
|
|
svc := &InboundService{}
|
|
candidate := &model.Inbound{
|
|
Tag: "vless-bridge",
|
|
Listen: "0.0.0.0",
|
|
Port: relayPort,
|
|
Protocol: model.VLESS,
|
|
}
|
|
got, err := svc.checkPortConflict(candidate, 0)
|
|
if err != nil {
|
|
t.Fatalf("checkPortConflict: %v", err)
|
|
}
|
|
if got == nil {
|
|
t.Fatalf("an AmneziaWG inbound with no peer yet still owns relay port %d; the save must be refused", relayPort)
|
|
}
|
|
if !strings.Contains(got.String(), "awg-1") {
|
|
t.Fatalf("the conflict must name the inbound owning the port, got %q", got.String())
|
|
}
|
|
}
|
|
|
|
// An unrelated port never conflicts with the relay inbound.
|
|
func TestCheckPortConflict_AmneziawgnetSocksRelayDifferentPortAllowed(t *testing.T) {
|
|
setupConflictDB(t)
|
|
seedInboundConflict(t, "awg-1", "0.0.0.0", 51820, model.AmneziaWG, ``, amneziawgRoutedSettings)
|
|
|
|
svc := &InboundService{}
|
|
candidate := &model.Inbound{
|
|
Tag: "vless-elsewhere",
|
|
Listen: "0.0.0.0",
|
|
Port: 9999,
|
|
Protocol: model.VLESS,
|
|
}
|
|
if got, err := svc.checkPortConflict(candidate, 0); err != nil || got != nil {
|
|
t.Fatalf("an unrelated port must not conflict with the AmneziaWG relay inbound; got=%v err=%v", got, err)
|
|
}
|
|
}
|
|
|
|
// The reverse direction: saving an AmneziaWG inbound whose own derived relay
|
|
// port happens to equal another inbound's real port must also be rejected,
|
|
// not just the already-covered "someone else picks my relay port" case.
|
|
func TestCheckPortConflict_AmneziawgnetSocksRelayReverseDirectionBlockedOnUpdate(t *testing.T) {
|
|
setupConflictDB(t)
|
|
seedInboundConflict(t, "awg-1", "0.0.0.0", 51820, model.AmneziaWG, ``, amneziawgRoutedSettings)
|
|
|
|
var awgInbound model.Inbound
|
|
if err := database.GetDB().Where("tag = ?", "awg-1").First(&awgInbound).Error; err != nil {
|
|
t.Fatalf("read seeded row: %v", err)
|
|
}
|
|
relayPort := amneziawgnet.SOCKSPortForInbound(awgInbound.Id)
|
|
seedInboundConflict(t, "vless-1", "0.0.0.0", relayPort, model.VLESS, ``, `{}`)
|
|
|
|
svc := &InboundService{}
|
|
candidate := &model.Inbound{
|
|
Id: awgInbound.Id,
|
|
Tag: "awg-1",
|
|
Listen: "0.0.0.0",
|
|
Port: 51820,
|
|
Protocol: model.AmneziaWG,
|
|
Settings: amneziawgRoutedSettings,
|
|
}
|
|
got, err := svc.checkPortConflict(candidate, awgInbound.Id)
|
|
if err != nil {
|
|
t.Fatalf("checkPortConflict: %v", err)
|
|
}
|
|
if got == nil {
|
|
t.Fatalf("awg-1's own derived relay port %d collides with vless-1's real port; must be rejected", relayPort)
|
|
}
|
|
}
|
|
|
|
// xray binds "::" dual-stack unless sockopt.v6only is set, so only then may an
|
|
// IPv4 address share its port; the flag is read from the saved streamSettings.
|
|
func TestCheckPortConflict_V6OnlyWildcardLeavesIPv4AddressFree(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
name string
|
|
stream string
|
|
want bool
|
|
}{
|
|
{"dual-stack", `{"network":"tcp"}`, true},
|
|
{"v6only", `{"network":"tcp","sockopt":{"v6only":true}}`, false},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
setupConflictDB(t)
|
|
seedInboundConflict(t, "vless-v6", "::", 443, model.VLESS, tc.stream, `{}`)
|
|
v4 := &model.Inbound{Tag: "vless-v4", Listen: "10.5.0.200", Port: 443, Protocol: model.VLESS, StreamSettings: `{"network":"tcp"}`}
|
|
exist, err := (&InboundService{}).checkPortConflict(v4, 0)
|
|
if err != nil {
|
|
t.Fatalf("checkPortConflict: %v", err)
|
|
}
|
|
if got := exist != nil; got != tc.want {
|
|
t.Fatalf("conflict = %v, want %v", got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|