Files
3x-ui/internal/amneziawgnet/outbound_manager_test.go
T
MHSanaei 75f3702dd3 fix(amneziawg): fall back to a free egress port when 64900 is refused
The panel's SOCKS5 egress for AmneziaWG outbounds bound the fixed
127.0.0.1:64900, and every generated socks bridge dialed that constant.
64900 sits inside Windows' dynamic port range, where the OS can reserve
whole blocks (this host excludes 64885-64984), so on the Windows builds
release.yml ships the listener could stay down and every AmneziaWG
outbound with it. Listen now tries 64900 first and falls back to any
free loopback port; bridges, the outbound probe and the port-conflict
check use EgressPort(), the port actually held. Bridges are generated
apart from the listener, so BuildSocksBridge records the port it wrote
and the AmneziaWG job requests an Xray restart while the listener holds
a different one. Where 64900 is free nothing changes.

The job's restart request is two lines of wiring no test reaches; the
staleness it acts on is pinned by
TestBridgesStaleUntilRegeneratedForTheBoundPort.
2026-09-27 16:09:54 +02:00

148 lines
3.7 KiB
Go

package amneziawgnet
import (
"net"
"sync"
"testing"
"time"
"github.com/mhsanaei/3x-ui/v3/internal/amneziawg"
"github.com/mhsanaei/3x-ui/v3/internal/util/wireguard"
)
// egressPortBound reports whether the egress server's Port accepts TCP.
func egressPortBound(t *testing.T) bool {
t.Helper()
conn, err := net.DialTimeout("tcp", net.JoinHostPort("127.0.0.1", itoa(GetEgressServer().Port())), 500*time.Millisecond)
if err != nil {
return false
}
conn.Close()
return true
}
func itoa(n int) string {
if n == 0 {
return "0"
}
var b [8]byte
i := len(b)
for n > 0 {
i--
b[i] = byte('0' + n%10)
n /= 10
}
return string(b[i:])
}
// newTestOutboundDesired builds one runnable outbound desired state.
func newTestOutboundDesired(t *testing.T, tag string) OutboundDesired {
t.Helper()
priv, _, err := wireguard.GenerateWireguardKeypair()
if err != nil {
t.Fatalf("generate keypair: %v", err)
}
return OutboundDesired{
Instance: amneziawg.OutboundInstance{
Tag: tag,
Address: []string{"10.204.0.1/24"},
MTU: 1420,
PrivateKey: priv,
ListenPort: 0,
},
}
}
// TestOutboundManagerReconcileEmptyDesiredClosesEgress verifies that an empty
// desired set tears down interfaces and releases the egress port.
func TestOutboundManagerReconcileEmptyDesiredClosesEgress(t *testing.T) {
m := &OutboundManager{iface: map[string]*managedOutbound{}}
defer m.Reconcile(nil)
// Other tests in this package may leave the process-wide egress
// singleton bound; converge to a known-free state before pinning.
GetEgressServer().Close()
if egressPortBound(t) {
t.Fatal("egress port still bound after Close; Close() failed to release it")
}
// Non-empty: listener must come up.
d := newTestOutboundDesired(t, "t1")
m.Reconcile([]OutboundDesired{d})
if !egressPortBound(t) {
t.Fatal("egress port not bound after Reconcile with a desired outbound")
}
held := GetEgressServer().Port()
// Empty: listener must be released so other listeners can take the port.
m.Reconcile(nil)
if egressPortBound(t) {
t.Fatal("egress port still bound after Reconcile(nil)")
}
ln, err := net.Listen("tcp", net.JoinHostPort("127.0.0.1", itoa(held)))
if err != nil {
t.Fatalf("egress port must be free after Reconcile(nil): %v", err)
}
ln.Close()
// Back to non-empty and empty again: Close/Listen must be repeatable.
m.Reconcile([]OutboundDesired{d})
if !egressPortBound(t) {
t.Fatal("egress port not re-bound after a second non-empty Reconcile")
}
m.Reconcile(nil)
if egressPortBound(t) {
t.Fatal("egress port still bound after a second Reconcile(nil)")
}
}
// TestEgressServerCloseDuringConcurrentAccepts ensures Close during
// concurrent accepts shuts down cleanly without hanging wg.Wait().
func TestEgressServerCloseDuringConcurrentAccepts(t *testing.T) {
srv := GetEgressServer()
if err := srv.Listen(); err != nil {
t.Fatal(err)
}
addr := net.JoinHostPort("127.0.0.1", itoa(srv.Port()))
stop := make(chan struct{})
done := make(chan struct{})
var clientWg sync.WaitGroup
go func() {
defer close(done)
for {
select {
case <-stop:
return
default:
c, err := net.DialTimeout("tcp", addr, 50*time.Millisecond)
if err == nil {
clientWg.Add(1)
go func(conn net.Conn) {
defer clientWg.Done()
time.Sleep(20 * time.Millisecond)
conn.Close()
}(c)
}
time.Sleep(2 * time.Millisecond)
}
}
}()
time.Sleep(20 * time.Millisecond)
closeChan := make(chan struct{})
go func() {
srv.Close()
close(closeChan)
}()
select {
case <-closeChan:
case <-time.After(3 * time.Second):
t.Fatal("srv.Close() hung waiting for connection handlers to exit")
}
close(stop)
<-done
clientWg.Wait()
}