| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550 |
- package amneziawgnet
- import (
- "context"
- "fmt"
- "io"
- "net"
- "net/netip"
- "strings"
- "testing"
- "time"
- awgconn "github.com/amnezia-vpn/amneziawg-go/v3/conn"
- "github.com/amnezia-vpn/amneziawg-go/v3/device"
- "github.com/amnezia-vpn/amneziawg-go/v3/tun/netstack"
- "gvisor.dev/gvisor/pkg/tcpip/adapters/gonet"
- "github.com/mhsanaei/3x-ui/v3/internal/amneziawg"
- "github.com/mhsanaei/3x-ui/v3/internal/util/wireguard"
- )
- // TestNewDeviceHandshakeForwarderAndIdentity is Phase 1's real end-to-end
- // proof, not just a compile check: a genuine amneziawg-go client (via that
- // project's own tun/netstack.CreateNetTUN -- the client side doesn't need a
- // forwarder or peer-identity resolution, only this package's server side
- // does) completes a real 3-way handshake against a Device built by
- // NewDevice, dials a destination that was never configured anywhere on the
- // server, and the test verifies AttachTCPForwarder recovers that exact
- // destination *and* PeerIndex.Lookup resolves the connection's source back
- // to the right peer's Email -- Phase 1a/1b/1c working together, the same
- // mechanism Phase 0's throwaway spike validated, now as a real, repo-owned,
- // repeatable test instead of scratch code.
- func TestNewDeviceHandshakeForwarderAndIdentity(t *testing.T) {
- 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)
- }
- const listenPort = 58712 // fixed loopback test port, matches the validated Phase 0 spike approach
- const wantEmail = "[email protected]"
- inst := amneziawg.Instance{
- Id: 1,
- InterfaceName: "awgtest1",
- ListenPort: listenPort,
- PrivateKey: serverPriv,
- PublicKey: serverPub,
- Address: []string{"10.201.0.1/24"},
- MTU: 1420,
- Obfuscation: amneziawg.Obfuscation31{
- Jc: 4, Jmin: 40, Jmax: 70,
- S1: 20, S2: 30, S3: 20, S4: 20,
- },
- Peers: []amneziawg.Peer{{
- Email: wantEmail,
- PublicKey: clientPub,
- AllowedIPs: []string{"10.201.0.2/32"},
- }},
- }
- dev, err := newUnconfiguredDevice(inst, DeviceOptions{})
- if err != nil {
- t.Fatalf("newUnconfiguredDevice: %v", err)
- }
- defer dev.Close()
- idx := NewPeerIndex(inst.Peers)
- type recovered struct {
- email string
- ok bool
- dest netip.AddrPort
- }
- got := make(chan recovered, 1)
- // Never configured anywhere server-side: the forwarder must recover it
- // purely from the decapsulated packet, not from any routing table.
- wantDest := netip.MustParseAddrPort("10.201.9.9:9999")
- AttachTCPForwarder(dev.Stack, func(conn *gonet.TCPConn, dest netip.AddrPort) {
- defer conn.Close()
- srcAddrPort, parseErr := netip.ParseAddrPort(conn.RemoteAddr().String())
- var peer amneziawg.Peer
- var ok bool
- if parseErr == nil {
- peer, ok = idx.Lookup(srcAddrPort.Addr().Unmap())
- }
- got <- recovered{email: peer.Email, ok: ok, dest: dest}
- io.Copy(io.Discard, conn)
- })
- // Configure (IpcSet) must come after AttachTCPForwarder -- see
- // newUnconfiguredDevice's doc comment: IpcSet is what starts the peer's
- // receive goroutine, which must never be able to run before the
- // forwarder is registered on the stack.
- if err := dev.Configure(inst, DeviceOptions{}); err != nil {
- t.Fatalf("Configure: %v", err)
- }
- clientTun, clientNet, err := netstack.CreateNetTUN(
- []netip.Addr{netip.MustParseAddr("10.201.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)
- }
- // allowed_ip=0.0.0.0/0 on the client matches a real VPN client's own
- // config (route everything through the tunnel) -- it's also what makes
- // dialing an arbitrary, never-configured destination like wantDest
- // actually get routed to the server peer at all: a narrower AllowedIPs
- // here would make the client's own Device drop the packet as
- // non-matching before it ever reached the wire.
- 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)
- }
- // Retry the dial rather than guessing a fixed handshake delay: the
- // first attempts may race the handshake, later ones should succeed
- // once it completes.
- dialCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
- defer cancel()
- var lastErr error
- for {
- conn, dialErr := clientNet.DialContext(dialCtx, "tcp", wantDest.String())
- if dialErr == nil {
- conn.Close()
- break
- }
- lastErr = dialErr
- select {
- case <-dialCtx.Done():
- t.Fatalf("client dial never succeeded: %v", lastErr)
- case <-time.After(100 * time.Millisecond):
- }
- }
- select {
- case r := <-got:
- if !r.ok {
- t.Fatal("forwarder: peer identity lookup failed for the recovered connection")
- }
- if r.email != wantEmail {
- t.Errorf("resolved peer email = %q, want %q", r.email, wantEmail)
- }
- if r.dest != wantDest {
- t.Errorf("recovered destination = %v, want %v", r.dest, wantDest)
- }
- case <-time.After(5 * time.Second):
- t.Fatal("timed out waiting for the forwarder to hand back the recovered connection")
- }
- }
- // TestBuildUAPIConfigHeaderProtectionAndContentPaddingLines is a cheap,
- // network-free companion to the real round-trip test below: confirms the 2
- // AWG 3.0 UAPI lines only appear when set, and that a malformed
- // HeaderProtectionKey surfaces a clear, wrapped error instead of silently
- // producing a UAPI string amneziawg-go's own IpcSet would reject uselessly.
- func TestBuildUAPIConfigHeaderProtectionAndContentPaddingLines(t *testing.T) {
- priv, _, err := wireguard.GenerateWireguardKeypair()
- if err != nil {
- t.Fatalf("generate keypair: %v", err)
- }
- inst := amneziawg.Instance{
- PrivateKey: priv,
- Obfuscation: amneziawg.Obfuscation31{
- S1: 20, S2: 20, S3: 20, S4: 20,
- },
- }
- conf, err := buildUAPIConfig(inst, DeviceOptions{})
- if err != nil {
- t.Fatalf("buildUAPIConfig with empty options: %v", err)
- }
- if strings.Contains(conf, "header_protection_key=") || strings.Contains(conf, "content_padding_addition=") {
- t.Fatalf("empty DeviceOptions must not emit AWG 3.0 lines, got:\n%s", conf)
- }
- key, err := wireguard.GenerateWireguardPSK()
- if err != nil {
- t.Fatalf("generate header protection key: %v", err)
- }
- conf, err = buildUAPIConfig(inst, DeviceOptions{HeaderProtectionKey: key, ContentPaddingAddition: "20-40"})
- if err != nil {
- t.Fatalf("buildUAPIConfig with AWG 3.0 options: %v", err)
- }
- if !strings.Contains(conf, "header_protection_key=") {
- t.Errorf("expected a header_protection_key= line, got:\n%s", conf)
- }
- if !strings.Contains(conf, "content_padding_addition=20-40\n") {
- t.Errorf("expected a content_padding_addition=20-40 line, got:\n%s", conf)
- }
- if _, err := buildUAPIConfig(inst, DeviceOptions{HeaderProtectionKey: "not-a-valid-base64-key"}); err == nil {
- t.Fatal("a malformed HeaderProtectionKey must be rejected, not silently passed through")
- }
- }
- // TestNewDeviceHeaderProtectionAndContentPaddingRoundTrip is the real proof
- // behind AmneziaWG 3.0's admin-facing HeaderProtectionKey/
- // ContentPaddingAddition fields: a genuine amneziawg-go client, configured
- // with matching header_protection_key/content_padding_addition UAPI lines
- // (S1-S4 all >= 12, the hard requirement amneziawg-go's own IpcSet enforces
- // for header protection), completes a real handshake against a Device built
- // via NewDevice/DeviceOptions and exchanges real application data both
- // directions through it. This is more than a handshake-completed check --
- // it also confirms actual payload bytes survive content padding on both the
- // send and receive sides, the specific area a third-party AmneziaWG
- // installer project's docs flagged a past interop concern for (see the
- // migration plan's own risk note); it is not a substitute for real-VPS
- // verification against the official client, but it is the cheapest
- // available local check against a regression in either engine's own padding
- // handling.
- func TestNewDeviceHeaderProtectionAndContentPaddingRoundTrip(t *testing.T) {
- 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)
- }
- headerProtectionKey, err := wireguard.GenerateWireguardPSK()
- if err != nil {
- t.Fatalf("generate header protection key: %v", err)
- }
- const listenPort = 58713 // fixed loopback test port, distinct from the handshake test above
- const contentPaddingAddition = "20-40"
- inst := amneziawg.Instance{
- Id: 2,
- InterfaceName: "awgtest2",
- ListenPort: listenPort,
- PrivateKey: serverPriv,
- PublicKey: serverPub,
- Address: []string{"10.202.0.1/24"},
- MTU: 1420,
- Obfuscation: amneziawg.Obfuscation31{
- Jc: 4, Jmin: 40, Jmax: 70,
- S1: 20, S2: 30, S3: 20, S4: 20, // all >= 12, required for header protection
- },
- Peers: []amneziawg.Peer{{
- Email: "[email protected]",
- PublicKey: clientPub,
- AllowedIPs: []string{"10.202.0.2/32"},
- }},
- }
- opts := DeviceOptions{
- HeaderProtectionKey: headerProtectionKey,
- ContentPaddingAddition: contentPaddingAddition,
- }
- dev, err := newUnconfiguredDevice(inst, opts)
- if err != nil {
- t.Fatalf("newUnconfiguredDevice: %v", err)
- }
- defer dev.Close()
- const wantRequest = "hello from client"
- const wantReply = "hello from server"
- serverDone := make(chan error, 1)
- AttachTCPForwarder(dev.Stack, func(conn *gonet.TCPConn, dest netip.AddrPort) {
- defer conn.Close()
- buf := make([]byte, len(wantRequest))
- if _, err := io.ReadFull(conn, buf); err != nil {
- serverDone <- fmt.Errorf("server read: %w", err)
- return
- }
- if string(buf) != wantRequest {
- serverDone <- fmt.Errorf("server got %q, want %q", buf, wantRequest)
- return
- }
- if _, err := conn.Write([]byte(wantReply)); err != nil {
- serverDone <- fmt.Errorf("server write: %w", err)
- return
- }
- serverDone <- nil
- })
- // Configure (IpcSet) must come after AttachTCPForwarder -- see
- // newUnconfiguredDevice's doc comment.
- if err := dev.Configure(inst, opts); err != nil {
- t.Fatalf("Configure: %v", err)
- }
- clientTun, clientNet, err := netstack.CreateNetTUN(
- []netip.Addr{netip.MustParseAddr("10.202.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)
- }
- headerProtectionKeyHex, err := wireguard.KeyToHex(headerProtectionKey)
- if err != nil {
- t.Fatalf("header protection 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\nheader_protection_key=%s\ncontent_padding_addition=%s\npublic_key=%s\nendpoint=127.0.0.1:%d\nallowed_ip=0.0.0.0/0\n",
- clientPrivHex, headerProtectionKeyHex, contentPaddingAddition, 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)
- }
- dialCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
- defer cancel()
- var conn net.Conn
- for {
- c, dialErr := clientNet.DialContext(dialCtx, "tcp", "10.202.9.9:9999")
- if dialErr == nil {
- conn = c
- break
- }
- select {
- case <-dialCtx.Done():
- t.Fatalf("client dial never succeeded: %v", dialErr)
- case <-time.After(100 * time.Millisecond):
- }
- }
- defer conn.Close()
- if _, err := conn.Write([]byte(wantRequest)); err != nil {
- t.Fatalf("client write: %v", err)
- }
- if err := conn.SetReadDeadline(time.Now().Add(5 * time.Second)); err != nil {
- t.Fatalf("SetReadDeadline: %v", err)
- }
- reply := make([]byte, len(wantReply))
- if _, err := io.ReadFull(conn, reply); err != nil {
- t.Fatalf("client read reply: %v", err)
- }
- if string(reply) != wantReply {
- t.Fatalf("client got reply %q, want %q", reply, wantReply)
- }
- select {
- case err := <-serverDone:
- if err != nil {
- t.Fatalf("server side: %v", err)
- }
- case <-time.After(5 * time.Second):
- t.Fatal("timed out waiting for the server side to finish")
- }
- }
- func TestBuildUAPIConfigRandomTrailersAndDisableCookiesLines(t *testing.T) {
- priv, _, err := wireguard.GenerateWireguardKeypair()
- if err != nil {
- t.Fatalf("generate keypair: %v", err)
- }
- inst := amneziawg.Instance{PrivateKey: priv}
- // Unlike HeaderProtectionKey/ContentPaddingAddition, these two lines
- // must always be present -- see DeviceOptions.RandomTrailers's own doc
- // comment on why an absent line (instead of an explicit "false") would
- // break the reconfigure-in-place diff for a true->false edit.
- conf, err := buildUAPIConfig(inst, DeviceOptions{})
- if err != nil {
- t.Fatalf("buildUAPIConfig with empty options: %v", err)
- }
- if !strings.Contains(conf, "random_trailers=false\n") {
- t.Errorf("expected an explicit random_trailers=false line even when unset, got:\n%s", conf)
- }
- if !strings.Contains(conf, "disable_cookies=false\n") {
- t.Errorf("expected an explicit disable_cookies=false line even when unset, got:\n%s", conf)
- }
- conf, err = buildUAPIConfig(inst, DeviceOptions{RandomTrailers: true, DisableCookies: true})
- if err != nil {
- t.Fatalf("buildUAPIConfig with both enabled: %v", err)
- }
- if !strings.Contains(conf, "random_trailers=true\n") {
- t.Errorf("expected a random_trailers=true line, got:\n%s", conf)
- }
- if !strings.Contains(conf, "disable_cookies=true\n") {
- t.Errorf("expected a disable_cookies=true line, got:\n%s", conf)
- }
- }
- // TestNewDeviceRandomTrailersAndDisableCookiesRoundTrip is the real proof
- // behind AmneziaWG 3.1's two new device-wide toggles: a genuine amneziawg-go
- // client with matching random_trailers=true/disable_cookies=true UAPI lines
- // completes a real handshake against a Device built via NewDevice/
- // DeviceOptions and exchanges real application data both directions through
- // it. This specifically exercises amneziawg-go's receive.go size-matching
- // path for RandomTrailers (device_test.go's HeaderProtection test doesn't
- // enable it), which only accepts a message when
- // `size == expectedSize || randomTrailers && size > expectedSize` -- proof
- // that setting it on both ends really does interoperate, not just that
- // IpcSet accepts the value.
- func TestNewDeviceRandomTrailersAndDisableCookiesRoundTrip(t *testing.T) {
- 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)
- }
- const listenPort = 58721 // fixed loopback test port, distinct from every other test in this package
- inst := amneziawg.Instance{
- Id: 3,
- InterfaceName: "awgtest3",
- ListenPort: listenPort,
- PrivateKey: serverPriv,
- PublicKey: serverPub,
- Address: []string{"10.203.0.1/24"},
- MTU: 1420,
- Peers: []amneziawg.Peer{{
- Email: "[email protected]",
- PublicKey: clientPub,
- AllowedIPs: []string{"10.203.0.2/32"},
- }},
- }
- opts := DeviceOptions{RandomTrailers: true, DisableCookies: true}
- dev, err := newUnconfiguredDevice(inst, opts)
- if err != nil {
- t.Fatalf("newUnconfiguredDevice: %v", err)
- }
- defer dev.Close()
- const wantRequest = "hello from client, with a trailer"
- const wantReply = "hello from server, with a trailer"
- serverDone := make(chan error, 1)
- AttachTCPForwarder(dev.Stack, func(conn *gonet.TCPConn, dest netip.AddrPort) {
- defer conn.Close()
- buf := make([]byte, len(wantRequest))
- if _, err := io.ReadFull(conn, buf); err != nil {
- serverDone <- fmt.Errorf("server read: %w", err)
- return
- }
- if string(buf) != wantRequest {
- serverDone <- fmt.Errorf("server got %q, want %q", buf, wantRequest)
- return
- }
- if _, err := conn.Write([]byte(wantReply)); err != nil {
- serverDone <- fmt.Errorf("server write: %w", err)
- return
- }
- serverDone <- nil
- })
- // Configure (IpcSet) must come after AttachTCPForwarder -- see
- // newUnconfiguredDevice's doc comment.
- if err := dev.Configure(inst, opts); err != nil {
- t.Fatalf("Configure: %v", err)
- }
- clientTun, clientNet, err := netstack.CreateNetTUN(
- []netip.Addr{netip.MustParseAddr("10.203.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\nrandom_trailers=true\ndisable_cookies=true\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)
- }
- dialCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
- defer cancel()
- var conn net.Conn
- for {
- c, dialErr := clientNet.DialContext(dialCtx, "tcp", "10.203.9.9:9999")
- if dialErr == nil {
- conn = c
- break
- }
- select {
- case <-dialCtx.Done():
- t.Fatalf("client dial never succeeded: %v", dialErr)
- case <-time.After(100 * time.Millisecond):
- }
- }
- defer conn.Close()
- if _, err := conn.Write([]byte(wantRequest)); err != nil {
- t.Fatalf("client write: %v", err)
- }
- if err := conn.SetReadDeadline(time.Now().Add(5 * time.Second)); err != nil {
- t.Fatalf("SetReadDeadline: %v", err)
- }
- reply := make([]byte, len(wantReply))
- if _, err := io.ReadFull(conn, reply); err != nil {
- t.Fatalf("client read reply: %v", err)
- }
- if string(reply) != wantReply {
- t.Fatalf("client got reply %q, want %q", reply, wantReply)
- }
- select {
- case err := <-serverDone:
- if err != nil {
- t.Fatalf("server side: %v", err)
- }
- case <-time.After(5 * time.Second):
- t.Fatal("timed out waiting for the server side to finish")
- }
- }
|