mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-09-05 18:07:14 +00:00
feat(amneziawg): auto-wire the SOCKS5 relay into every embedded Device
Manager.ensureLocked now attaches AttachTCPForwarder/AttachUDPHandler to every Device it builds, relaying into that instance's own loopback SOCKS5 inbound automatically -- no caller needs to know relay.go exists at all. Peer identity is re-looked-up via Manager.Lookup on every connection rather than captured once at attach time, so a reconfigure-in-place (peers added/ removed without a full rebuild) doesn't leave the forwarder working off a stale peer index. Added TestManagerEnsureAutomaticallyWiresRelay: drives this through the real Manager.Ensure entry point (not manual wiring like the existing relay_e2e_test.go) against a real xray-core process, confirming the automatic attachment and the port/password Manager derives internally actually agree with what a real SOCKS5 inbound expects. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -2,9 +2,12 @@ package amneziawgnet
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net/netip"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
|
"gvisor.dev/gvisor/pkg/tcpip/adapters/gonet"
|
||||||
|
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/amneziawg"
|
"github.com/mhsanaei/3x-ui/v3/internal/amneziawg"
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/logger"
|
"github.com/mhsanaei/3x-ui/v3/internal/logger"
|
||||||
)
|
)
|
||||||
@@ -18,12 +21,13 @@ type Desired struct {
|
|||||||
Options DeviceOptions
|
Options DeviceOptions
|
||||||
}
|
}
|
||||||
|
|
||||||
// managed is one running embedded interface: the live Device, the peer
|
// managed is one running embedded interface: the live Device, its UDP relay
|
||||||
// lookup index built from its current peer list, and enough of its own
|
// sessions, the peer lookup index built from its current peer list, and
|
||||||
// configuration to decide whether a later Ensure call can reconfigure it in
|
// enough of its own configuration to decide whether a later Ensure call can
|
||||||
// place or needs to rebuild it from scratch.
|
// reconfigure it in place or needs to rebuild it from scratch.
|
||||||
type managed struct {
|
type managed struct {
|
||||||
dev *Device
|
dev *Device
|
||||||
|
udpRelay *UDPRelay
|
||||||
peers *PeerIndex
|
peers *PeerIndex
|
||||||
inst amneziawg.Instance
|
inst amneziawg.Instance
|
||||||
structFP string
|
structFP string
|
||||||
@@ -33,12 +37,11 @@ type managed struct {
|
|||||||
// inbound id -- the same shape as internal/amneziawg.Manager (GetManager()
|
// inbound id -- the same shape as internal/amneziawg.Manager (GetManager()
|
||||||
// + sync.Once, mu-guarded map, Ensure/Reconcile/StopAll/HasRunning), so a
|
// + sync.Once, mu-guarded map, Ensure/Reconcile/StopAll/HasRunning), so a
|
||||||
// caller already familiar with that Manager needs to learn nothing new here.
|
// caller already familiar with that Manager needs to learn nothing new here.
|
||||||
// Unlike that Manager, this one doesn't attach any traffic handling by
|
// Every Device this Manager builds gets its TCP forwarder and UDP handler
|
||||||
// itself: Ensure/Reconcile only bring each Instance's Device up to date.
|
// attached automatically (see ensureLocked), relaying into that instance's
|
||||||
// Attaching a forwarder/UDP handler (see forwarder.go / udp.go) using the
|
// own loopback SOCKS5 inbound (SOCKSPortForInbound/SocksPassword) -- a
|
||||||
// Device and PeerIndex returned by Lookup is left to the caller -- today a
|
// caller only needs to keep calling Ensure/Reconcile with fresh Instance
|
||||||
// test harness, later the Phase 2 SOCKS5 relay wiring -- since this package
|
// data; it doesn't need to know relay.go exists at all.
|
||||||
// doesn't yet know what that handler should do with a recovered connection.
|
|
||||||
type Manager struct {
|
type Manager struct {
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
ifaces map[int]*managed
|
ifaces map[int]*managed
|
||||||
@@ -97,6 +100,7 @@ func (m *Manager) ensureLocked(d Desired) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if exists {
|
if exists {
|
||||||
|
cur.udpRelay.Close()
|
||||||
cur.dev.Close()
|
cur.dev.Close()
|
||||||
delete(m.ifaces, inst.Id)
|
delete(m.ifaces, inst.Id)
|
||||||
}
|
}
|
||||||
@@ -104,8 +108,47 @@ func (m *Manager) ensureLocked(d Desired) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
relay := socksRelayForInstance(inst)
|
||||||
|
udpRelay := NewUDPRelay(relay, dev.Stack)
|
||||||
|
inboundID := inst.Id // captured for the closures below, which outlive this call
|
||||||
|
AttachTCPForwarder(dev.Stack, func(conn *gonet.TCPConn, dest netip.AddrPort) {
|
||||||
|
srcAddrPort, err := netip.ParseAddrPort(conn.RemoteAddr().String())
|
||||||
|
if err != nil {
|
||||||
|
conn.Close()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Re-fetched on every connection, not captured once at attach time:
|
||||||
|
// a reconfigure-in-place (peers added/removed, no rebuild) replaces
|
||||||
|
// cur.peers without ever re-attaching the forwarder, so a stale
|
||||||
|
// captured index would silently miss newly-added peers.
|
||||||
|
_, peers, ok := m.Lookup(inboundID)
|
||||||
|
if !ok {
|
||||||
|
conn.Close()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
peer, ok := peers.Lookup(srcAddrPort.Addr().Unmap())
|
||||||
|
if !ok {
|
||||||
|
conn.Close()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
relay.RelayTCP(conn, peer.Email, dest)
|
||||||
|
})
|
||||||
|
AttachUDPHandler(dev.Stack, func(src, dst netip.AddrPort, payload []byte) {
|
||||||
|
_, peers, ok := m.Lookup(inboundID)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
peer, ok := peers.Lookup(src.Addr())
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
udpRelay.Handle(src, dst, peer.Email, payload)
|
||||||
|
})
|
||||||
|
|
||||||
m.ifaces[inst.Id] = &managed{
|
m.ifaces[inst.Id] = &managed{
|
||||||
dev: dev,
|
dev: dev,
|
||||||
|
udpRelay: udpRelay,
|
||||||
peers: NewPeerIndex(inst.Peers),
|
peers: NewPeerIndex(inst.Peers),
|
||||||
inst: inst,
|
inst: inst,
|
||||||
structFP: structFP,
|
structFP: structFP,
|
||||||
@@ -114,6 +157,17 @@ func (m *Manager) ensureLocked(d Desired) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// socksRelayForInstance derives the loopback SOCKS5 relay address/password
|
||||||
|
// for inst -- both fully determined by its id and the process-wide
|
||||||
|
// password (SOCKSPortForInbound/SocksPassword), so no per-instance state
|
||||||
|
// needs threading through Desired/DeviceOptions for this.
|
||||||
|
func socksRelayForInstance(inst amneziawg.Instance) SocksRelay {
|
||||||
|
return SocksRelay{
|
||||||
|
Addr: fmt.Sprintf("127.0.0.1:%d", SOCKSPortForInbound(inst.Id)),
|
||||||
|
Password: SocksPassword(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// addressFingerprint captures the two Instance fields that can't be changed
|
// addressFingerprint captures the two Instance fields that can't be changed
|
||||||
// on a running Device via IpcSet alone (they're fixed when the gVisor
|
// on a running Device via IpcSet alone (they're fixed when the gVisor
|
||||||
// netstack is built) -- everything else (keys, listen port, obfuscation,
|
// netstack is built) -- everything else (keys, listen port, obfuscation,
|
||||||
@@ -137,6 +191,7 @@ func (m *Manager) Reconcile(desired []Desired) {
|
|||||||
if _, ok := want[id]; ok {
|
if _, ok := want[id]; ok {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
cur.udpRelay.Close()
|
||||||
cur.dev.Close()
|
cur.dev.Close()
|
||||||
delete(m.ifaces, id)
|
delete(m.ifaces, id)
|
||||||
logger.Infof("amneziawgnet: stopped embedded interface for removed inbound %d", id)
|
logger.Infof("amneziawgnet: stopped embedded interface for removed inbound %d", id)
|
||||||
@@ -153,6 +208,7 @@ func (m *Manager) StopAll() {
|
|||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
defer m.mu.Unlock()
|
defer m.mu.Unlock()
|
||||||
for id, cur := range m.ifaces {
|
for id, cur := range m.ifaces {
|
||||||
|
cur.udpRelay.Close()
|
||||||
cur.dev.Close()
|
cur.dev.Close()
|
||||||
delete(m.ifaces, id)
|
delete(m.ifaces, id)
|
||||||
}
|
}
|
||||||
@@ -166,9 +222,10 @@ func (m *Manager) HasRunning() bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Lookup returns the running Device and PeerIndex for inbound id, if any --
|
// Lookup returns the running Device and PeerIndex for inbound id, if any --
|
||||||
// for a caller that wants to attach its own forwarder/handler (a test
|
// the forwarder/UDP-handler closures ensureLocked attaches use this to
|
||||||
// harness today, the Phase 2 SOCKS5 relay wiring later) once the interface
|
// re-fetch the current peer index on every connection (see ensureLocked's
|
||||||
// is up.
|
// comment on why), and it's equally available to a test harness or any
|
||||||
|
// other caller that wants read access to a managed interface's state.
|
||||||
func (m *Manager) Lookup(id int) (dev *Device, peers *PeerIndex, ok bool) {
|
func (m *Manager) Lookup(id int) (dev *Device, peers *PeerIndex, ok bool) {
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
defer m.mu.Unlock()
|
defer m.mu.Unlock()
|
||||||
|
|||||||
@@ -284,6 +284,193 @@ func TestSocksRelayAgainstRealXray(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestManagerEnsureAutomaticallyWiresRelay is Phase 3's own real proof: unlike
|
||||||
|
// TestSocksRelayAgainstRealXray above (which builds a Device and attaches
|
||||||
|
// RelayTCP/UDPRelay by hand), this drives everything through the public
|
||||||
|
// Manager.Ensure entry point the real app actually calls -- confirming
|
||||||
|
// ensureLocked's own forwarder/UDP-handler attachment (added this phase)
|
||||||
|
// really does relay a fresh Device's traffic into Xray with zero manual
|
||||||
|
// wiring from the caller. Uses the exact port/password
|
||||||
|
// (SOCKSPortForInbound/SocksPassword) the Manager computes internally, so
|
||||||
|
// this only passes if that internal derivation and the externally-visible
|
||||||
|
// contract genuinely agree.
|
||||||
|
func TestManagerEnsureAutomaticallyWiresRelay(t *testing.T) {
|
||||||
|
bin := os.Getenv("XRAY_E2E_BINARY")
|
||||||
|
if bin == "" {
|
||||||
|
t.Skip("set XRAY_E2E_BINARY to an xray binary to run this test")
|
||||||
|
}
|
||||||
|
localIP, ok := firstNonLoopbackIPv4()
|
||||||
|
if !ok {
|
||||||
|
t.Skip("no non-loopback IPv4 address available on this host")
|
||||||
|
}
|
||||||
|
|
||||||
|
const wantEmail = "manager-e2e-peer@example.com"
|
||||||
|
const listenPort = 58716
|
||||||
|
const inboundID = 5
|
||||||
|
|
||||||
|
tcpEcho, tcpEchoAddr := startTCPEcho(t, localIP)
|
||||||
|
defer tcpEcho.Close()
|
||||||
|
|
||||||
|
serverPriv, serverPub, err := wireguard.GenerateWireguardKeypair()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("generate server keypair: %v", err)
|
||||||
|
}
|
||||||
|
clientPriv, clientPub, err := wireguard.GenerateWireguardKeypair()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("generate client keypair: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
inst := amneziawg.Instance{
|
||||||
|
Id: inboundID,
|
||||||
|
InterfaceName: "awgtest5",
|
||||||
|
ListenPort: listenPort,
|
||||||
|
PrivateKey: serverPriv,
|
||||||
|
PublicKey: serverPub,
|
||||||
|
Address: []string{"10.205.0.1/24"},
|
||||||
|
MTU: 1420,
|
||||||
|
Obfuscation: amneziawg.Obfuscation20{
|
||||||
|
Jc: 4, Jmin: 40, Jmax: 70,
|
||||||
|
S1: 20, S2: 30, S3: 20, S4: 20,
|
||||||
|
},
|
||||||
|
Peers: []amneziawg.Peer{{
|
||||||
|
Email: wantEmail,
|
||||||
|
PublicKey: clientPub,
|
||||||
|
AllowedIPs: []string{"10.205.0.2/32"},
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
|
||||||
|
// A real xray-core process with a SOCKS5 inbound at exactly the port and
|
||||||
|
// password ensureLocked will derive on its own for this instance --
|
||||||
|
// SocksPassword() is cached (sync.Once), so calling it here first and
|
||||||
|
// again inside Manager.Ensure below returns the identical value.
|
||||||
|
socksPort := SOCKSPortForInbound(inboundID)
|
||||||
|
password := SocksPassword()
|
||||||
|
settingsJSON, err := SocksInboundSettings([]string{wantEmail}, password)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SocksInboundSettings: %v", err)
|
||||||
|
}
|
||||||
|
var rawSettings any
|
||||||
|
if err := json.Unmarshal(settingsJSON, &rawSettings); err != nil {
|
||||||
|
t.Fatalf("unmarshal generated SOCKS5 settings: %v", err)
|
||||||
|
}
|
||||||
|
xrayCfg := map[string]any{
|
||||||
|
"log": map[string]any{"loglevel": "debug"},
|
||||||
|
"inbounds": []any{
|
||||||
|
map[string]any{
|
||||||
|
"listen": "127.0.0.1",
|
||||||
|
"port": socksPort,
|
||||||
|
"protocol": "socks",
|
||||||
|
"settings": rawSettings,
|
||||||
|
"tag": "awg-e2e-manager",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"outbounds": []any{
|
||||||
|
map[string]any{"protocol": "freedom", "settings": map[string]any{}, "tag": "direct"},
|
||||||
|
},
|
||||||
|
"policy": map[string]any{
|
||||||
|
"levels": map[string]any{
|
||||||
|
"0": map[string]any{"statsUserUplink": true, "statsUserDownlink": true},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"stats": map[string]any{},
|
||||||
|
}
|
||||||
|
cfgBytes, err := json.MarshalIndent(xrayCfg, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal xray config: %v", err)
|
||||||
|
}
|
||||||
|
cfgPath := filepath.Join(t.TempDir(), "config.json")
|
||||||
|
if err := os.WriteFile(cfgPath, cfgBytes, 0o644); err != nil {
|
||||||
|
t.Fatalf("write xray config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var xrayLog syncBuffer
|
||||||
|
cmd := exec.Command(bin, "-c", cfgPath)
|
||||||
|
cmd.Stdout = &xrayLog
|
||||||
|
cmd.Stderr = &xrayLog
|
||||||
|
if err := cmd.Start(); err != nil {
|
||||||
|
t.Fatalf("start xray: %v", err)
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
_ = cmd.Process.Kill()
|
||||||
|
_, _ = cmd.Process.Wait()
|
||||||
|
}()
|
||||||
|
waitForPort(t, socksPort)
|
||||||
|
|
||||||
|
// A throwaway Manager, not the process-wide singleton, so this test
|
||||||
|
// doesn't interact with any other test's state.
|
||||||
|
m := &Manager{ifaces: map[int]*managed{}}
|
||||||
|
defer m.StopAll()
|
||||||
|
if err := m.Ensure(Desired{Instance: inst}); err != nil {
|
||||||
|
t.Fatalf("Manager.Ensure: %v", err)
|
||||||
|
}
|
||||||
|
dev, _, ok := m.Lookup(inboundID)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("Lookup after Ensure: not found")
|
||||||
|
}
|
||||||
|
defer dev.Close() // StopAll would also do this; explicit for clarity
|
||||||
|
|
||||||
|
clientTun, clientNet, err := netstack.CreateNetTUN(
|
||||||
|
[]netip.Addr{netip.MustParseAddr("10.205.0.2")},
|
||||||
|
[]netip.Addr{netip.MustParseAddr("1.1.1.1")}, 1420)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("client CreateNetTUN: %v", err)
|
||||||
|
}
|
||||||
|
clientDev := device.NewDevice(clientTun, awgconn.NewDefaultBind(), device.NewLogger(device.LogLevelSilent, ""))
|
||||||
|
defer clientDev.Close()
|
||||||
|
|
||||||
|
clientPrivHex, err := wireguard.KeyToHex(clientPriv)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("client key to hex: %v", err)
|
||||||
|
}
|
||||||
|
serverPubHex, err := wireguard.KeyToHex(serverPub)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("server key to hex: %v", err)
|
||||||
|
}
|
||||||
|
clientConf := fmt.Sprintf(
|
||||||
|
"private_key=%s\njc=4\njmin=40\njmax=70\ns1=20\ns2=30\ns3=20\ns4=20\npublic_key=%s\nendpoint=127.0.0.1:%d\nallowed_ip=0.0.0.0/0\n",
|
||||||
|
clientPrivHex, serverPubHex, listenPort)
|
||||||
|
if err := clientDev.IpcSet(clientConf); err != nil {
|
||||||
|
t.Fatalf("client IpcSet: %v", err)
|
||||||
|
}
|
||||||
|
if err := clientDev.Up(); err != nil {
|
||||||
|
t.Fatalf("client Up: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
const tcpMsg = "hello via Manager.Ensure's automatic relay wiring"
|
||||||
|
dialDeadline := time.Now().Add(10 * time.Second)
|
||||||
|
var conn net.Conn
|
||||||
|
for {
|
||||||
|
c, dialErr := clientNet.DialContext(t.Context(), "tcp", tcpEchoAddr.String())
|
||||||
|
if dialErr == nil {
|
||||||
|
conn = c
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if time.Now().After(dialDeadline) {
|
||||||
|
t.Fatalf("client TCP dial via tunnel never succeeded: %v", dialErr)
|
||||||
|
}
|
||||||
|
time.Sleep(150 * time.Millisecond)
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
if _, err := conn.Write([]byte(tcpMsg)); err != nil {
|
||||||
|
t.Fatalf("client TCP write: %v", err)
|
||||||
|
}
|
||||||
|
buf := make([]byte, len(tcpMsg))
|
||||||
|
if _, err := readFull(conn, buf, 10*time.Second); err != nil {
|
||||||
|
t.Fatalf("client TCP read: %v", err)
|
||||||
|
}
|
||||||
|
if string(buf) != tcpMsg {
|
||||||
|
t.Errorf("TCP echo = %q, want %q", buf, tcpMsg)
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = cmd.Process.Kill()
|
||||||
|
_, _ = cmd.Process.Wait()
|
||||||
|
log := xrayLog.String()
|
||||||
|
wantUp := fmt.Sprintf("user>>>%s>>>traffic>>>uplink", wantEmail)
|
||||||
|
if !strings.Contains(log, wantUp) {
|
||||||
|
t.Errorf("xray log missing uplink stats counter %q (Manager.Ensure's automatic relay wiring may not be attributing traffic correctly)\nfull log:\n%s", wantUp, log)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// firstNonLoopbackIPv4 finds a real, locally-bound IPv4 address suitable as
|
// firstNonLoopbackIPv4 finds a real, locally-bound IPv4 address suitable as
|
||||||
// a relay-reachable test destination.
|
// a relay-reachable test destination.
|
||||||
func firstNonLoopbackIPv4() (netip.Addr, bool) {
|
func firstNonLoopbackIPv4() (netip.Addr, bool) {
|
||||||
|
|||||||
Reference in New Issue
Block a user