mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-09-08 11:17:13 +00:00
3b5273b1d6
ValidateObfuscation exists, by its own doc comment, so that a bad manual entry cannot break the embedded device's IpcSet. It was not covering enough to do that. Auditing the panel against amneziawg-go v3.1.20260828's full UAPI surface turned up two holes, both confirmed by driving the values through a real IpcSet: S1 = 70000 upstream parses s1-s4 as uint16 S2 = 70000 (device/uapi.go) Jc = -1 jc/jmin/jmax are uint32, so no negatives Jmin/Jmax = -5/-1 Jc = 5000000000 and nothing wider than uint32 I1 = <rand 100> newObfChain hard-fails on an unknown tag I1 = <r 100 ... and on a missing '>' I1 = <> ... and on an empty one All eight passed validation and were then rejected by the device. Only S3 and S4 were bounded, which is why the asymmetry went unnoticed. The inbound saves, the reconcile fails on every tick, and the interface never comes up with a single log line to say so. Bound the five numeric fields to the widths upstream actually parses, and check the I1-I5 chain's <tag value> structure against a tag set mirroring upstream's own obfBuilders map. Each tag's value grammar stays amneziawg-go's to enforce -- that is eight builders across several files, and duplicating them here would drift. So <r abc> still reaches IpcSet, now as the only remaining class rather than one of four. Mirror the same bounds in the Zod schema, next to the max() that s3 and s4 already carried, so the form rejects the value instead of the save doing it. TestValidatedObfuscationAlwaysApplies pins the contract itself: whatever ValidateObfuscation accepts, a real amneziawg-go device must accept too. It covers the specs the new grammar check deliberately allows, not just the ones it rejects, so the allowlist cannot quietly become stricter than upstream. The rest of the audit found no gaps: all 17 settable device keys reach buildUAPIConfig, ServerSettings, the Zod schema and all three .conf emitters. fwmark and persistent_keepalive_interval remain unemitted, both deliberately -- the panel models no fwmark anywhere, and keepAlive is carried client-side where WireGuard puts it.
627 lines
22 KiB
Go
627 lines
22 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)
|
|
}
|
|
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: "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)
|
|
}
|
|
})
|
|
}
|
|
}
|