mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-09-10 12:17:14 +00:00
cfd596a489
amneziawg-go reads an absent UAPI line as "keep the current value", and addressFingerprint keys only on the addresses and MTU, so an obfuscation-only edit reconfigures in place rather than rebuilding. Clearing headerProtectionKey therefore never took effect: the device kept protecting headers with the old key. The stale key also keeps the S1-S4 minimum in force, so lowering S3/S4 in the same edit made every later IpcSet fail with -22 — after replace_peers had already dropped the peers. Send the all-zero key when the field is empty, which is how the UAPI expresses "disabled"; an empty value would be rejected, since it decodes to zero bytes.
672 lines
24 KiB
Go
672 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" }},
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|