mirror of
https://github.com/tailscale/tailscale.git
synced 2026-05-06 04:36:15 +02:00
Add a vmtest that brings up a Tailscale client, an Ubuntu VM acting as a Mullvad-style plain-WireGuard exit node, and a non-Tailscale webserver, each on its own NAT'd vnet network with a distinct WAN IP. The test exercises Tailscale's IsWireGuardOnly peer code path: the way the control plane wires Mullvad exit nodes into a client's netmap, including the per-client SelfNodeV4MasqAddrForThisPeer source-IP rewrite that lets a Tailscale CGNAT IP egress through a plain-WireGuard tunnel that has no idea what Tailscale is. The mullvad VM doesn't run wireguard-tools or kernel WireGuard; instead, a new TTA endpoint /wg-server-up creates a real Linux TUN named wg0, drives it with wireguard-go (already vendored), and configures the kernel side (ip addr/up, ip_forward, iptables NAT MASQUERADE) so decrypted traffic from the peer egresses with the mullvad VM's WAN IP. 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 — and this lets the test avoid downloading and installing .deb packages inside the VM. Adds Env.BringUpMullvadWGServer (calls /wg-server-up, returns the generated WG public key as a key.NodePublic), Env.SetExitNodeIP (EditPrefs ExitNodeIP directly, for exit nodes whose IPs aren't discoverable via TTA), Env.ControlServer (exposes the underlying testcontrol.Server so tests can UpdateNode / SetMasqueradeAddresses to inject custom peers), and Env.Status (fetches a node's tailscale status, used to read the client's pubkey so we can pin it as the WG server's only allowed peer). The test verifies that the webserver's echoed source IP is the client's WAN with no exit node selected, the mullvad VM's WAN with the WG-only peer selected as exit, and the client's WAN again after clearing. Updates #13038 Change-Id: I5bac4e0d832f05929f12cb77fa9946d7f5fb5ef1 Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
156 lines
4.6 KiB
Go
156 lines
4.6 KiB
Go
// 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))
|
|
}
|