WIP: rebase for 2026-05-18 #7

Draft
codinget wants to merge 234 commits from rebase/2026-05-18 into webnet
4 changed files with 431 additions and 1 deletions
Showing only changes of commit 4b8e0ede6d - Show all commits
+12
View File
@@ -313,6 +313,13 @@ func main() {
io.Copy(w, resp.Body)
})
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) {
logBuf.mu.Lock()
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
// 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
// intended to capture early logs from the process, even if
// gokrazy's syslog streaming isn't working or yet working.
+155
View File
@@ -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))
}
+112 -1
View File
@@ -17,26 +17,33 @@ package vmtest
import (
"context"
"encoding/base64"
"flag"
"fmt"
"io"
"net"
"net/http"
"net/netip"
"net/url"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"sync"
"testing"
"time"
"github.com/google/gopacket/layers"
"go4.org/mem"
"golang.org/x/sync/errgroup"
"tailscale.com/client/local"
"tailscale.com/ipn"
"tailscale.com/ipn/ipnstate"
"tailscale.com/tailcfg"
"tailscale.com/tstest/integration/testcontrol"
"tailscale.com/tstest/natlab/vnet"
"tailscale.com/types/key"
"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
// --accept-routes flag), controlling whether it installs subnet routes
// advertised by peers.
@@ -849,7 +960,7 @@ func (e *Env) ping(from, to *Node) {
// is running (which is all of them — DontJoinTailnet only skips
// `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) {
e.t.Helper()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+152
View File
@@ -5,9 +5,12 @@ package vmtest_test
import (
"fmt"
"net/netip"
"strings"
"testing"
"tailscale.com/tailcfg"
"tailscale.com/tstest/integration/testcontrol"
"tailscale.com/tstest/natlab/vmtest"
"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)
}