mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-09-29 18:52:08 +03:00
8979072bd9
* fix(amneziawg): bound S1-S3 by the receive buffer, reject overlapping H The native AmneziaWG validator, both Zod schemas, both forms and the docs now follow the rules amneziawg-go actually enforces. S1-S3. A padded handshake message is 148+S1, 92+S2 or 64+S3 bytes (device/send.go). The peer reads each datagram into a [MaxMessageSize]byte buffer, where MaxMessageSize = MaxSegmentSize (device/pools.go, constants.go). MaxSegmentSize is 65535 on Linux/Android, 2016 on Windows and 1700 on iOS (device/queueconstants_*.go). The limits are therefore S1 <= 1552, S2 <= 1608 and S3 <= 1636. Before, S1/S2 allowed 65535, which iOS peers silently drop, and S3 was capped at 64, a number inherited from the coinman-dev/3ax-ui port in #6105 with no stated reason. That cap blocked real configs such as Amnezia Premium's S3=1045. RandomTrailers only tops a packet up to 500 bytes (DefaultUdpWindow), so it never pushes a message past these limits. H1-H4. amneziawg-go refuses the whole device when the header ranges overlap ("headers must not overlap", device/uapi.go mergeWithDevice), and so does the kernel module (src/netlink.c). The panel did not check this, so an inbound with overlapping ranges saved and then failed to apply. A blank H is never sent, so the engine keeps its default, WireGuard's own type 1-4; the check treats blank fields that way. The docs said 1-4 "must not be used". They are valid and are the engine default, only unobfuscated without a HeaderProtectionKey. The docs also said amneziawg-go rejects S1+56 == S2. It does not (IpcSet accepts it). The panel keeps that rule as a fingerprint guard, and the docs now say so. Tests: the new params_test cases and the Zod bounds fail on the old code. TestValidatedObfuscationAlwaysApplies runs every accepted set through a real amneziawg-go IpcSet and now covers overlap, blank-H defaults, H=1-4, the exact S bounds and the full Amnezia Premium set. Before this fix it failed with "headers must not overlap". Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(amneziawg): bound only inbound padding by the iOS receive buffer The 1700-byte iOS buffer limits what an inbound's clients can receive, but ValidateObfuscation also runs for outbounds, and the Xray template save re-validates every AmneziaWG outbound. An outbound whose remote server uses S1 above 1552 would have blocked every Xray settings save, though its values come from that server and are received on Linux. ValidateObfuscation keeps amneziawg-go's uint16 UAPI width for S1-S3; ValidateServerObfuscation adds the receive-buffer bounds and is what inbounds call. The outbound schema and form follow the same split. --------- Co-authored-by: Kirill Rudenko <rudenko@npp-energy.ru> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com>
683 lines
24 KiB
Go
683 lines
24 KiB
Go
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 = "test-peer@example.com"
|
|
|
|
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)
|
|
}
|
|
// header_protection_key is the exception: an omitted line reads as
|
|
// "unchanged", so clearing the key has to be sent as the all-zero one.
|
|
if !strings.Contains(conf, "header_protection_key="+strings.Repeat("0", 64)+"\n") {
|
|
t.Fatalf("an unset key must be emitted as the all-zero key, got:\n%s", conf)
|
|
}
|
|
if 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: "hp-peer@example.com",
|
|
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: "trailer-peer@example.com",
|
|
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")
|
|
}
|
|
}
|
|
|
|
// TestValidatedObfuscationAlwaysApplies pins the contract ValidateObfuscation
|
|
// exists for: whatever it accepts, amneziawg-go's own IpcSet must accept too.
|
|
func TestValidatedObfuscationAlwaysApplies(t *testing.T) {
|
|
priv, pub, err := wireguard.GenerateWireguardKeypair()
|
|
if err != nil {
|
|
t.Fatalf("server keypair: %v", err)
|
|
}
|
|
_, peerPub, err := wireguard.GenerateWireguardKeypair()
|
|
if err != nil {
|
|
t.Fatalf("peer keypair: %v", err)
|
|
}
|
|
base := amneziawg.Obfuscation31{Jc: 4, Jmin: 40, Jmax: 70, S1: 20, S2: 30, S3: 20, S4: 20}
|
|
|
|
cases := []struct {
|
|
name string
|
|
mut func(*amneziawg.Obfuscation31)
|
|
}{
|
|
{"generated defaults", func(o *amneziawg.Obfuscation31) { *o = amneziawg.GenerateObfuscation31() }},
|
|
{"S1 over uint16", func(o *amneziawg.Obfuscation31) { o.S1 = 70000 }},
|
|
{"S2 over uint16", func(o *amneziawg.Obfuscation31) { o.S2 = 70000 }},
|
|
{"negative Jc", func(o *amneziawg.Obfuscation31) { o.Jc = -1 }},
|
|
{"negative Jmin and Jmax", func(o *amneziawg.Obfuscation31) { o.Jmin, o.Jmax = -5, -1 }},
|
|
{"Jc over uint32", func(o *amneziawg.Obfuscation31) { o.Jc = 5000000000 }},
|
|
{"I1 unknown tag", func(o *amneziawg.Obfuscation31) { o.I1 = "<rand 100>" }},
|
|
{"I1 missing close", func(o *amneziawg.Obfuscation31) { o.I1 = "<r 100" }},
|
|
{"I1 empty tag", func(o *amneziawg.Obfuscation31) { o.I1 = "<>" }},
|
|
// The specs validateObfChain deliberately accepts must really apply.
|
|
{"I1 chained tags", func(o *amneziawg.Obfuscation31) { o.I1 = "<b ff00><r 10>" }},
|
|
{"I1 valueless tag", func(o *amneziawg.Obfuscation31) { o.I1 = "<t><rc 5>" }},
|
|
{"I1 no tags at all", func(o *amneziawg.Obfuscation31) { o.I1 = "plain text" }},
|
|
{"H ranges overlap", func(o *amneziawg.Obfuscation31) { o.H1, o.H2 = "100-200", "150-300" }},
|
|
{"H1 equals the blank H3 default", func(o *amneziawg.Obfuscation31) { o.H1 = "3" }},
|
|
{"H1-H4 = WireGuard's 1-4", func(o *amneziawg.Obfuscation31) { o.H1, o.H2, o.H3, o.H4 = "1", "2", "3", "4" }},
|
|
// Separate cases: 1552+56 == 1608, so both maxima together trip the S1/S2 size rule.
|
|
{"S1 and S3 at the 1700-byte bound", func(o *amneziawg.Obfuscation31) { o.S1, o.S3 = 1552, 1636 }},
|
|
{"S2 at the 1700-byte bound", func(o *amneziawg.Obfuscation31) { o.S2 = 1608 }},
|
|
{"Amnezia Premium set", func(o *amneziawg.Obfuscation31) {
|
|
o.S1, o.S2, o.S3, o.S4 = 284, 659, 1045, 12
|
|
o.H1, o.H2, o.H3, o.H4 = "1", "2", "3", "4"
|
|
o.HeaderProtectionKey = "A2lG0Jm3m8u1WJt0qg3d7V6Qx8cFvH9pL1nR4sT6yZ0="
|
|
}},
|
|
}
|
|
|
|
for i, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
o := base
|
|
tc.mut(&o)
|
|
if err := amneziawg.ValidateObfuscation(o); err != nil {
|
|
return // rejected before saving, which is the whole point
|
|
}
|
|
inst := amneziawg.Instance{
|
|
Id: 88, InterfaceName: "awgcontract", ListenPort: 58900 + i,
|
|
PrivateKey: priv, PublicKey: pub,
|
|
Address: []string{"10.198.0.1/24"}, MTU: 1420,
|
|
Obfuscation: o,
|
|
Peers: []amneziawg.Peer{{
|
|
Email: "contract@example.com", PublicKey: peerPub,
|
|
AllowedIPs: []string{"10.198.0.2/32"},
|
|
}},
|
|
}
|
|
opts := DeviceOptions{
|
|
HeaderProtectionKey: o.HeaderProtectionKey,
|
|
ContentPaddingAddition: o.ContentPaddingAddition,
|
|
RekeyAfterTime: o.RekeyAfterTime,
|
|
RekeyTimeout: o.RekeyTimeout,
|
|
RejectAfterTime: o.RejectAfterTime,
|
|
KeepaliveTimeout: o.KeepaliveTimeout,
|
|
MaxHandshakeAttempts: o.MaxHandshakeAttempts,
|
|
RandomTrailers: o.RandomTrailers,
|
|
DisableCookies: o.DisableCookies,
|
|
}
|
|
dev, err := newUnconfiguredDevice(inst, opts)
|
|
if err != nil {
|
|
t.Fatalf("newUnconfiguredDevice: %v", err)
|
|
}
|
|
defer dev.Close()
|
|
conf, err := buildUAPIConfig(inst, opts)
|
|
if err != nil {
|
|
t.Fatalf("buildUAPIConfig: %v", err)
|
|
}
|
|
if err := dev.IpcSet(conf); err != nil {
|
|
t.Fatalf("ValidateObfuscation accepted this config but amneziawg-go rejected it: %v", err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// Clearing HeaderProtectionKey on a running inbound must actually reach the
|
|
// device: amneziawg-go treats an absent UAPI line as "keep the current value",
|
|
// so an omitted key leaves header protection permanently on. Worse, the stale
|
|
// key keeps the S1-S4 minimum alive, so lowering them then fails IpcSet with
|
|
// -22 on every reconcile after the peers were already replaced.
|
|
func TestBuildUAPIConfigClearedHeaderProtectionKeyIsSentAsZero(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},
|
|
}
|
|
|
|
key, err := wireguard.GenerateWireguardPSK()
|
|
if err != nil {
|
|
t.Fatalf("generate header protection key: %v", err)
|
|
}
|
|
withKey, err := buildUAPIConfig(inst, DeviceOptions{HeaderProtectionKey: key})
|
|
if err != nil {
|
|
t.Fatalf("buildUAPIConfig with a key: %v", err)
|
|
}
|
|
cleared, err := buildUAPIConfig(inst, DeviceOptions{})
|
|
if err != nil {
|
|
t.Fatalf("buildUAPIConfig with the key cleared: %v", err)
|
|
}
|
|
if withKey == cleared {
|
|
t.Fatal("clearing the key produced an identical UAPI config, so the device would never see the change")
|
|
}
|
|
|
|
zero := "header_protection_key=" + strings.Repeat("0", 64) + "\n"
|
|
if !strings.Contains(cleared, zero) {
|
|
t.Fatalf("cleared config must carry the all-zero key, got:\n%s", cleared)
|
|
}
|
|
if strings.Contains(withKey, zero) {
|
|
t.Fatalf("a configured key must not be emitted as zero, got:\n%s", withKey)
|
|
}
|
|
}
|