WIP: rebase for 2026-05-18 #7
@@ -313,6 +313,13 @@ func main() {
|
|||||||
io.Copy(w, resp.Body)
|
io.Copy(w, resp.Body)
|
||||||
})
|
})
|
||||||
ttaMux.HandleFunc("/fw", addFirewallHandler)
|
ttaMux.HandleFunc("/fw", addFirewallHandler)
|
||||||
|
ttaMux.HandleFunc("/wg-server-up", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if wgServerUp == nil {
|
||||||
|
http.Error(w, "wg-server-up not supported on this platform", http.StatusNotImplemented)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
wgServerUp(w, r)
|
||||||
|
})
|
||||||
ttaMux.HandleFunc("/logs", func(w http.ResponseWriter, r *http.Request) {
|
ttaMux.HandleFunc("/logs", func(w http.ResponseWriter, r *http.Request) {
|
||||||
logBuf.mu.Lock()
|
logBuf.mu.Lock()
|
||||||
defer logBuf.mu.Unlock()
|
defer logBuf.mu.Unlock()
|
||||||
@@ -477,6 +484,11 @@ func addFirewallHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
var addFirewall func() error // set by fw_linux.go
|
var addFirewall func() error // set by fw_linux.go
|
||||||
|
|
||||||
|
// wgServerUp brings up a userspace WireGuard "Mullvad-style" exit-node
|
||||||
|
// server on this VM. It is set by wgserver_linux.go and is nil on
|
||||||
|
// non-Linux.
|
||||||
|
var wgServerUp func(w http.ResponseWriter, r *http.Request)
|
||||||
|
|
||||||
// logBuffer is a bytes.Buffer that is safe for concurrent use
|
// logBuffer is a bytes.Buffer that is safe for concurrent use
|
||||||
// intended to capture early logs from the process, even if
|
// intended to capture early logs from the process, even if
|
||||||
// gokrazy's syslog streaming isn't working or yet working.
|
// gokrazy's syslog streaming isn't working or yet working.
|
||||||
|
|||||||
@@ -0,0 +1,155 @@
|
|||||||
|
// Copyright (c) Tailscale Inc & contributors
|
||||||
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"cmp"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/tailscale/wireguard-go/conn"
|
||||||
|
"github.com/tailscale/wireguard-go/device"
|
||||||
|
"github.com/tailscale/wireguard-go/tun"
|
||||||
|
"golang.org/x/crypto/curve25519"
|
||||||
|
"tailscale.com/wgengine/wgcfg"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
wgServerUp = wgServerUpLinux
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
wgServerMu sync.Mutex
|
||||||
|
wgServerDev *device.Device // retained so the goroutines stay alive
|
||||||
|
)
|
||||||
|
|
||||||
|
// wgServerUpLinux brings up a userspace WireGuard interface on the local VM
|
||||||
|
// configured as a single-peer "Mullvad-style" exit node, then sets up the
|
||||||
|
// kernel-side IP/forwarding/MASQUERADE so that decrypted traffic from the
|
||||||
|
// peer egresses to the test internet.
|
||||||
|
//
|
||||||
|
// Required URL query parameters:
|
||||||
|
// - addr: CIDR for the WG interface (e.g. "10.64.0.1/24")
|
||||||
|
// - listen-port: WG listen port
|
||||||
|
// - peer-pub-b64: base64-encoded 32-byte WG public key of the only peer
|
||||||
|
// - peer-allowed-ip: prefix the peer is allowed to source from
|
||||||
|
// (e.g. "10.64.0.2/32")
|
||||||
|
// - masq-src: prefix to MASQUERADE on egress (e.g. "10.64.0.0/24")
|
||||||
|
//
|
||||||
|
// Optional:
|
||||||
|
// - name: TUN device name (default "wg0")
|
||||||
|
//
|
||||||
|
// On success, it writes "PUBKEY=<base64>\n" — the freshly generated public
|
||||||
|
// key the caller must pin as the peer's WG public key.
|
||||||
|
func wgServerUpLinux(w http.ResponseWriter, r *http.Request) {
|
||||||
|
wgServerMu.Lock()
|
||||||
|
defer wgServerMu.Unlock()
|
||||||
|
if wgServerDev != nil {
|
||||||
|
http.Error(w, "wg server already up", http.StatusConflict)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
q := r.URL.Query()
|
||||||
|
name := cmp.Or(q.Get("name"), "wg0")
|
||||||
|
addr := q.Get("addr")
|
||||||
|
listenPort := q.Get("listen-port")
|
||||||
|
peerPubB64 := q.Get("peer-pub-b64")
|
||||||
|
peerAllowedIP := q.Get("peer-allowed-ip")
|
||||||
|
masqSrc := q.Get("masq-src")
|
||||||
|
for _, kv := range []struct{ k, v string }{
|
||||||
|
{"addr", addr},
|
||||||
|
{"listen-port", listenPort},
|
||||||
|
{"peer-pub-b64", peerPubB64},
|
||||||
|
{"peer-allowed-ip", peerAllowedIP},
|
||||||
|
{"masq-src", masqSrc},
|
||||||
|
} {
|
||||||
|
if kv.v == "" {
|
||||||
|
http.Error(w, "missing "+kv.k, http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
peerPub, err := base64.StdEncoding.DecodeString(peerPubB64)
|
||||||
|
if err != nil || len(peerPub) != 32 {
|
||||||
|
http.Error(w, fmt.Sprintf("bad peer-pub-b64: %v (len=%d)", err, len(peerPub)), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var priv [32]byte
|
||||||
|
if _, err := rand.Read(priv[:]); err != nil {
|
||||||
|
http.Error(w, "rand: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// X25519 key clamping.
|
||||||
|
priv[0] &= 248
|
||||||
|
priv[31] = (priv[31] & 127) | 64
|
||||||
|
|
||||||
|
pub, err := curve25519.X25519(priv[:], curve25519.Basepoint)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "deriving pubkey: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
tdev, err := tun.CreateTUN(name, device.DefaultMTU)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "tun.CreateTUN: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
wglog := &device.Logger{
|
||||||
|
Verbosef: func(string, ...any) {},
|
||||||
|
Errorf: func(f string, a ...any) { log.Printf("wg-server: "+f, a...) },
|
||||||
|
}
|
||||||
|
dev := wgcfg.NewDevice(tdev, conn.NewDefaultBind(), wglog)
|
||||||
|
|
||||||
|
uapi := fmt.Sprintf("private_key=%s\nlisten_port=%s\npublic_key=%s\nallowed_ip=%s\n",
|
||||||
|
hex.EncodeToString(priv[:]), listenPort,
|
||||||
|
hex.EncodeToString(peerPub), peerAllowedIP)
|
||||||
|
if err := dev.IpcSet(uapi); err != nil {
|
||||||
|
dev.Close()
|
||||||
|
http.Error(w, "IpcSet: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := dev.Up(); err != nil {
|
||||||
|
dev.Close()
|
||||||
|
http.Error(w, "dev.Up: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
steps := []struct {
|
||||||
|
why string
|
||||||
|
exec []string
|
||||||
|
file struct{ path, data string }
|
||||||
|
}{
|
||||||
|
{why: "ip addr add", exec: []string{"ip", "addr", "add", addr, "dev", name}},
|
||||||
|
{why: "ip link up", exec: []string{"ip", "link", "set", name, "up"}},
|
||||||
|
{why: "enable forwarding", file: struct{ path, data string }{"/proc/sys/net/ipv4/ip_forward", "1\n"}},
|
||||||
|
{why: "FORWARD policy", exec: []string{"iptables", "-P", "FORWARD", "ACCEPT"}},
|
||||||
|
{why: "MASQUERADE", exec: []string{"iptables", "-t", "nat", "-A", "POSTROUTING", "-s", masqSrc, "-j", "MASQUERADE"}},
|
||||||
|
}
|
||||||
|
for _, s := range steps {
|
||||||
|
if s.file.path != "" {
|
||||||
|
if err := os.WriteFile(s.file.path, []byte(s.file.data), 0644); err != nil {
|
||||||
|
dev.Close()
|
||||||
|
http.Error(w, fmt.Sprintf("%s: %v", s.why, err), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if out, err := exec.Command(s.exec[0], s.exec[1:]...).CombinedOutput(); err != nil {
|
||||||
|
dev.Close()
|
||||||
|
http.Error(w, fmt.Sprintf("%s: %v: %s", s.why, err, out), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
wgServerDev = dev
|
||||||
|
fmt.Fprintf(w, "PUBKEY=%s\n", base64.StdEncoding.EncodeToString(pub))
|
||||||
|
}
|
||||||
@@ -17,26 +17,33 @@ package vmtest
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/google/gopacket/layers"
|
"github.com/google/gopacket/layers"
|
||||||
|
"go4.org/mem"
|
||||||
"golang.org/x/sync/errgroup"
|
"golang.org/x/sync/errgroup"
|
||||||
"tailscale.com/client/local"
|
"tailscale.com/client/local"
|
||||||
"tailscale.com/ipn"
|
"tailscale.com/ipn"
|
||||||
|
"tailscale.com/ipn/ipnstate"
|
||||||
"tailscale.com/tailcfg"
|
"tailscale.com/tailcfg"
|
||||||
|
"tailscale.com/tstest/integration/testcontrol"
|
||||||
"tailscale.com/tstest/natlab/vnet"
|
"tailscale.com/tstest/natlab/vnet"
|
||||||
|
"tailscale.com/types/key"
|
||||||
"tailscale.com/util/set"
|
"tailscale.com/util/set"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -733,6 +740,110 @@ func (e *Env) SetExitNode(client, exitNode *Node) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetExitNodeIP sets the client's ExitNodeIP preference directly, by IP.
|
||||||
|
// This is the right helper for plain-WireGuard exit nodes (Mullvad-style)
|
||||||
|
// that aren't on the tailnet — pass an invalid netip.Addr{} to clear.
|
||||||
|
// For tailnet exit nodes whose Tailscale IP is discoverable via TTA, use
|
||||||
|
// [Env.SetExitNode] instead.
|
||||||
|
func (e *Env) SetExitNodeIP(client *Node, ip netip.Addr) {
|
||||||
|
e.t.Helper()
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
if _, err := client.agent.EditPrefs(ctx, &ipn.MaskedPrefs{
|
||||||
|
Prefs: ipn.Prefs{
|
||||||
|
ExitNodeID: "",
|
||||||
|
ExitNodeIP: ip,
|
||||||
|
},
|
||||||
|
ExitNodeIDSet: true,
|
||||||
|
ExitNodeIPSet: true,
|
||||||
|
}); err != nil {
|
||||||
|
e.t.Fatalf("SetExitNodeIP(%s, %v): %v", client.name, ip, err)
|
||||||
|
}
|
||||||
|
if !ip.IsValid() {
|
||||||
|
e.t.Logf("[%s] cleared exit node", client.name)
|
||||||
|
} else {
|
||||||
|
e.t.Logf("[%s] using exit-node IP %v", client.name, ip)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ControlServer returns the underlying test control server, for tests that
|
||||||
|
// need to inject custom peers, masquerade pairs, etc. The returned server's
|
||||||
|
// Node store is shared with the running tailnet, so changes take effect on
|
||||||
|
// the next netmap update sent to peers.
|
||||||
|
func (e *Env) ControlServer() *testcontrol.Server {
|
||||||
|
return e.server.ControlServer()
|
||||||
|
}
|
||||||
|
|
||||||
|
// BringUpMullvadWGServer brings up a userspace WireGuard server on n,
|
||||||
|
// configured as a single-peer "Mullvad-style" exit-node target. The
|
||||||
|
// server runs inside n's TTA process on a Linux TUN named "wg0".
|
||||||
|
//
|
||||||
|
// gw is the WG interface address (e.g. 10.64.0.1/24). The server listens
|
||||||
|
// on listenPort, accepts only the single peer whose public key is peerPub
|
||||||
|
// at peerAllowedIP, and MASQUERADEs egress traffic from masqSrc so that
|
||||||
|
// decrypted packets from the peer egress with n's WAN IP.
|
||||||
|
//
|
||||||
|
// It returns the freshly generated public key of the WG server, which
|
||||||
|
// the caller must pin as the peer key on the [tailcfg.Node] it injects
|
||||||
|
// into the netmap to advertise this server as a plain-WireGuard exit
|
||||||
|
// node. It fatals the test on error.
|
||||||
|
func (e *Env) BringUpMullvadWGServer(n *Node, gw netip.Prefix, listenPort uint16, peerPub key.NodePublic, peerAllowedIP, masqSrc netip.Prefix) key.NodePublic {
|
||||||
|
e.t.Helper()
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
peerPubRaw := peerPub.Raw32()
|
||||||
|
v := url.Values{
|
||||||
|
"addr": {gw.String()},
|
||||||
|
"listen-port": {strconv.Itoa(int(listenPort))},
|
||||||
|
"peer-pub-b64": {base64.StdEncoding.EncodeToString(peerPubRaw[:])},
|
||||||
|
"peer-allowed-ip": {peerAllowedIP.String()},
|
||||||
|
"masq-src": {masqSrc.String()},
|
||||||
|
}
|
||||||
|
reqURL := "http://unused/wg-server-up?" + v.Encode()
|
||||||
|
req, err := http.NewRequestWithContext(ctx, "GET", reqURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
e.t.Fatalf("BringUpMullvadWGServer: %v", err)
|
||||||
|
}
|
||||||
|
res, err := n.agent.HTTPClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
e.t.Fatalf("BringUpMullvadWGServer(%s): %v", n.name, err)
|
||||||
|
}
|
||||||
|
defer res.Body.Close()
|
||||||
|
body, _ := io.ReadAll(res.Body)
|
||||||
|
if res.StatusCode != 200 {
|
||||||
|
e.t.Fatalf("BringUpMullvadWGServer(%s): %s: %s", n.name, res.Status, body)
|
||||||
|
}
|
||||||
|
var pubB64 string
|
||||||
|
for _, line := range strings.Split(string(body), "\n") {
|
||||||
|
if s, ok := strings.CutPrefix(strings.TrimSpace(line), "PUBKEY="); ok {
|
||||||
|
pubB64 = s
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if pubB64 == "" {
|
||||||
|
e.t.Fatalf("BringUpMullvadWGServer(%s): no PUBKEY in response: %q", n.name, body)
|
||||||
|
}
|
||||||
|
pubRaw, err := base64.StdEncoding.DecodeString(pubB64)
|
||||||
|
if err != nil || len(pubRaw) != 32 {
|
||||||
|
e.t.Fatalf("BringUpMullvadWGServer(%s): bad PUBKEY %q: %v", n.name, pubB64, err)
|
||||||
|
}
|
||||||
|
return key.NodePublicFromRaw32(mem.B(pubRaw))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Status returns the tailscale status of the given node, fetched from its
|
||||||
|
// TTA agent. It fatals the test on error.
|
||||||
|
func (e *Env) Status(n *Node) *ipnstate.Status {
|
||||||
|
e.t.Helper()
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
st, err := n.agent.Status(ctx)
|
||||||
|
if err != nil {
|
||||||
|
e.t.Fatalf("Status(%s): %v", n.name, err)
|
||||||
|
}
|
||||||
|
return st
|
||||||
|
}
|
||||||
|
|
||||||
// SetAcceptRoutes toggles the node's RouteAll preference (the
|
// SetAcceptRoutes toggles the node's RouteAll preference (the
|
||||||
// --accept-routes flag), controlling whether it installs subnet routes
|
// --accept-routes flag), controlling whether it installs subnet routes
|
||||||
// advertised by peers.
|
// advertised by peers.
|
||||||
@@ -849,7 +960,7 @@ func (e *Env) ping(from, to *Node) {
|
|||||||
// is running (which is all of them — DontJoinTailnet only skips
|
// is running (which is all of them — DontJoinTailnet only skips
|
||||||
// `tailscale up`; the agent runs regardless). Currently Linux-only in TTA.
|
// `tailscale up`; the agent runs regardless). Currently Linux-only in TTA.
|
||||||
//
|
//
|
||||||
// Fatals on error.
|
// It fatals the test on error.
|
||||||
func (e *Env) AddRoute(n *Node, prefix, via string) {
|
func (e *Env) AddRoute(n *Node, prefix, via string) {
|
||||||
e.t.Helper()
|
e.t.Helper()
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
|||||||
@@ -5,9 +5,12 @@ package vmtest_test
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net/netip"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"tailscale.com/tailcfg"
|
||||||
|
"tailscale.com/tstest/integration/testcontrol"
|
||||||
"tailscale.com/tstest/natlab/vmtest"
|
"tailscale.com/tstest/natlab/vmtest"
|
||||||
"tailscale.com/tstest/natlab/vnet"
|
"tailscale.com/tstest/natlab/vnet"
|
||||||
)
|
)
|
||||||
@@ -441,3 +444,152 @@ func TestExitNode(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestMullvadExitNode verifies that a Tailscale client whose netmap contains
|
||||||
|
// a plain-WireGuard exit node (the way Mullvad exit nodes are wired up by
|
||||||
|
// the control plane) can route internet traffic through it, with the source
|
||||||
|
// IP rewritten to the per-client Mullvad-assigned address.
|
||||||
|
//
|
||||||
|
// Topology:
|
||||||
|
//
|
||||||
|
// client (Tailscale, gokrazy) — clientNet (EasyNAT) WAN 1.0.0.1
|
||||||
|
// mullvad (Ubuntu, userspace WG) — mullvadNet (One2OneNAT) WAN 2.0.0.1
|
||||||
|
// webserver (no Tailscale, gokrazy) — webNet (One2OneNAT) WAN 5.0.0.1
|
||||||
|
//
|
||||||
|
// The mullvad VM impersonates a Mullvad WireGuard server. After boot, the
|
||||||
|
// test asks its TTA agent to bring up a userspace WireGuard interface (a
|
||||||
|
// real Linux TUN driven by wireguard-go) that pins the client's Tailscale
|
||||||
|
// node public key as its only allowed peer, sets up IP-forwarding + a
|
||||||
|
// MASQUERADE rule, and reports the WG server's freshly generated public
|
||||||
|
// key back. Userspace vs kernel WireGuard makes no difference on the wire
|
||||||
|
// — what's being tested is Tailscale's plain-WireGuard exit-node code
|
||||||
|
// path, not the kernel module.
|
||||||
|
//
|
||||||
|
// The test then injects a netmap peer with IsWireGuardOnly=true,
|
||||||
|
// AllowedIPs=[gw/32, 0.0.0.0/0, ::/0], the WG endpoint, and a per-client
|
||||||
|
// SelfNodeV4MasqAddrForThisPeer (the mock equivalent of the per-client IP
|
||||||
|
// Mullvad's API hands out at registration time).
|
||||||
|
//
|
||||||
|
// The webserver echoes the source IP it sees:
|
||||||
|
// - exit-node off: source is client's WAN (direct egress)
|
||||||
|
// - exit-node on: source is mullvad's WAN (egress via WG + MASQUERADE)
|
||||||
|
func TestMullvadExitNode(t *testing.T) {
|
||||||
|
env := vmtest.New(t)
|
||||||
|
|
||||||
|
const (
|
||||||
|
clientWAN = "1.0.0.1"
|
||||||
|
mullvadWAN = "2.0.0.1"
|
||||||
|
webWAN = "5.0.0.1"
|
||||||
|
)
|
||||||
|
// Mullvad-side WG network. The client appears as clientMasqIP to
|
||||||
|
// mullvad's wg0; mullvad terminates the tunnel at gw.
|
||||||
|
var (
|
||||||
|
mullvadWGNet = netip.MustParsePrefix("10.64.0.0/24")
|
||||||
|
gw = netip.MustParsePrefix("10.64.0.1/24")
|
||||||
|
clientMasq = netip.MustParsePrefix("10.64.0.2/32")
|
||||||
|
)
|
||||||
|
const wgListenPort uint16 = 51820
|
||||||
|
|
||||||
|
clientNet := env.AddNetwork(clientWAN, "192.168.1.1/24", vnet.EasyNAT)
|
||||||
|
mullvadNet := env.AddNetwork(mullvadWAN, "192.168.2.1/24", vnet.One2OneNAT)
|
||||||
|
webNet := env.AddNetwork(webWAN, "192.168.5.1/24", vnet.One2OneNAT)
|
||||||
|
|
||||||
|
client := env.AddNode("client", clientNet, vmtest.OS(vmtest.Gokrazy))
|
||||||
|
mullvad := env.AddNode("mullvad", mullvadNet,
|
||||||
|
vmtest.OS(vmtest.Ubuntu2404),
|
||||||
|
vmtest.DontJoinTailnet())
|
||||||
|
env.AddNode("webserver", webNet,
|
||||||
|
vmtest.OS(vmtest.Gokrazy),
|
||||||
|
vmtest.DontJoinTailnet(),
|
||||||
|
vmtest.WebServer(8080))
|
||||||
|
|
||||||
|
// Declare test-specific steps for the web UI.
|
||||||
|
wgUpStep := env.AddStep("Bring up Mullvad WG server")
|
||||||
|
injectStep := env.AddStep("Inject Mullvad netmap peer")
|
||||||
|
checkOff1Step := env.AddStep("HTTP GET (exit off)")
|
||||||
|
checkMullvadStep := env.AddStep("HTTP GET (exit=mullvad)")
|
||||||
|
checkOff2Step := env.AddStep("HTTP GET (exit off, again)")
|
||||||
|
|
||||||
|
env.Start()
|
||||||
|
|
||||||
|
// Bring up the WG server inside mullvad's TTA, pinning the client's
|
||||||
|
// Tailscale node public key as the sole allowed peer.
|
||||||
|
wgUpStep.Begin()
|
||||||
|
clientStatus := env.Status(client)
|
||||||
|
mullvadPub := env.BringUpMullvadWGServer(mullvad,
|
||||||
|
gw, wgListenPort,
|
||||||
|
clientStatus.Self.PublicKey, clientMasq, mullvadWGNet)
|
||||||
|
wgUpStep.End(nil)
|
||||||
|
|
||||||
|
// Inject the mullvad node into the netmap as a plain-WireGuard exit
|
||||||
|
// node. This mirrors how the control plane describes Mullvad exit
|
||||||
|
// nodes to clients (see control/cmullvad in the closed repo): a
|
||||||
|
// peer with IsWireGuardOnly=true, an Endpoints entry pointing at
|
||||||
|
// the public WG host:port, and AllowedIPs covering both the gateway
|
||||||
|
// /32 and the 0.0.0.0/0+::/0 exit-node routes.
|
||||||
|
injectStep.Begin()
|
||||||
|
mullvadEndpoint := netip.AddrPortFrom(netip.MustParseAddr(mullvadWAN), wgListenPort)
|
||||||
|
gwHost := netip.PrefixFrom(gw.Addr(), gw.Addr().BitLen())
|
||||||
|
mullvadNode := &tailcfg.Node{
|
||||||
|
ID: 999_001,
|
||||||
|
StableID: "mullvad-test",
|
||||||
|
Name: "mullvad-test.fake-control.example.net.",
|
||||||
|
Key: mullvadPub,
|
||||||
|
MachineAuthorized: true,
|
||||||
|
IsWireGuardOnly: true,
|
||||||
|
Endpoints: []netip.AddrPort{mullvadEndpoint},
|
||||||
|
Addresses: []netip.Prefix{gwHost},
|
||||||
|
AllowedIPs: []netip.Prefix{
|
||||||
|
gwHost,
|
||||||
|
netip.MustParsePrefix("0.0.0.0/0"),
|
||||||
|
netip.MustParsePrefix("::/0"),
|
||||||
|
},
|
||||||
|
Hostinfo: (&tailcfg.Hostinfo{
|
||||||
|
Hostname: "mullvad-test",
|
||||||
|
}).View(),
|
||||||
|
}
|
||||||
|
cs := env.ControlServer()
|
||||||
|
cs.UpdateNode(mullvadNode)
|
||||||
|
|
||||||
|
// Set the per-peer source-IP masquerade. The control plane normally
|
||||||
|
// derives this from the Mullvad API's per-client registration; here
|
||||||
|
// we just pin it to the address mullvad's wg0 was told to accept.
|
||||||
|
cs.SetMasqueradeAddresses([]testcontrol.MasqueradePair{{
|
||||||
|
Node: clientStatus.Self.PublicKey,
|
||||||
|
Peer: mullvadPub,
|
||||||
|
NodeMasqueradesAs: clientMasq.Addr(),
|
||||||
|
}})
|
||||||
|
injectStep.End(nil)
|
||||||
|
|
||||||
|
webURL := fmt.Sprintf("http://%s:8080/", webWAN)
|
||||||
|
check := func(step *vmtest.Step, label, wantSrc string) {
|
||||||
|
t.Helper()
|
||||||
|
step.Begin()
|
||||||
|
body := env.HTTPGet(client, webURL)
|
||||||
|
t.Logf("[%s] response: %s", label, body)
|
||||||
|
if !strings.Contains(body, "Hello world I am webserver") {
|
||||||
|
step.End(fmt.Errorf("[%s] unexpected webserver response: %q", label, body))
|
||||||
|
t.Fatalf("[%s] unexpected webserver response: %q", label, body)
|
||||||
|
}
|
||||||
|
if !strings.Contains(body, "from "+wantSrc) {
|
||||||
|
step.End(fmt.Errorf("[%s] expected source %q in response, got %q", label, wantSrc, body))
|
||||||
|
t.Fatalf("[%s] expected source %q in response, got %q", label, wantSrc, body)
|
||||||
|
}
|
||||||
|
step.End(nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exit-node off: client routes 0.0.0.0/0 directly via its host stack,
|
||||||
|
// so the webserver sees client's WAN IP.
|
||||||
|
check(checkOff1Step, "exit-off", clientWAN)
|
||||||
|
|
||||||
|
// Switch to the Mullvad WG-only peer as exit node. The client should
|
||||||
|
// now route 0.0.0.0/0 through the WG tunnel; mullvad MASQUERADEs to
|
||||||
|
// its WAN; the webserver sees the mullvad VM's WAN IP.
|
||||||
|
env.SetExitNodeIP(client, gw.Addr())
|
||||||
|
check(checkMullvadStep, "exit-mullvad", mullvadWAN)
|
||||||
|
|
||||||
|
// And back off again, to make sure the transition works in both
|
||||||
|
// directions.
|
||||||
|
env.SetExitNodeIP(client, netip.Addr{})
|
||||||
|
check(checkOff2Step, "exit-off (again)", clientWAN)
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user