Brad Fitzpatrick 814161303f tstest/natlab/vnet: add multi-NIC node support, DHCP fixes, and VIPs
Multi-NIC support:
  - Add nodeNIC type and node.extraNICs for secondary network interfaces
  - Add netForMAC/macForNet to route packets to the correct network by MAC
  - Update initFromConfig to allocate a MAC + LAN IP per network
  - Fix handleEthernetFrameFromVM, ServeUnixConn to use netForMAC
  - Fix MACOfIP, writeEth, WriteUDPPacketNoNAT, gVisor write path, and
    createARPResponse to use macForNet (return the MAC actually on that
    network, not the node's primary MAC)
  - Fix createDHCPResponse for multi-NIC (correct client IP and subnet)
  - Add nodeNICMac for secondary NIC MAC generation
  - Add Node accessors: NumNICs, NICMac, Networks, LanIP

DHCP fixes:
  - Include LeaseTime, SubnetMask, Router, DNS in DHCP Offer (not just
    Ack). systemd-networkd requires these to accept an Offer.
  - Fix DHCP response source IP: use gateway IP instead of echoing
    the request's destination (which was 255.255.255.255 for discovers)

New VIPs:
  - cloud-init.tailscale: serves per-node cloud-init meta-data, user-data,
    and network-config for VMs booting with nocloud datasource
  - files.tailscale: serves binary files (tta, tailscale, tailscaled)
    registered via RegisterFile for cloud VM provisioning
  - Add ControlServer() accessor for test control server

This is necessary for a three-VM natlab subnet router
integration test, coming later.

Updates #13038

Change-Id: I59f9f356bae9b5509c117265237983972dfdd5af
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2026-04-08 11:04:26 -07:00

102 lines
2.9 KiB
Go

// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package vnet
import (
"fmt"
"net/netip"
)
var vips = map[string]virtualIP{} // DNS name => details
var (
fakeDNS = newVIP("dns", "4.11.4.11", "2411::411")
fakeProxyControlplane = newVIP("controlplane.tailscale.com", 1)
fakeTestAgent = newVIP("test-driver.tailscale", 2)
fakeControl = newVIP("control.tailscale", 3)
fakeDERP1 = newVIP("derp1.tailscale", "33.4.0.1") // 3340=DERP; 1=derp 1
fakeDERP2 = newVIP("derp2.tailscale", "33.4.0.2") // 3340=DERP; 2=derp 2
fakeLogCatcher = newVIP("log.tailscale.com", 4)
fakeSyslog = newVIP("syslog.tailscale", 9)
fakeCloudInit = newVIP("cloud-init.tailscale", 5) // serves cloud-init metadata/userdata per node
fakeFiles = newVIP("files.tailscale", 6) // serves binary files (tta, tailscale, tailscaled) to VMs
)
type virtualIP struct {
name string // for DNS
v4 netip.Addr
v6 netip.Addr
}
func (v virtualIP) Match(a netip.Addr) bool {
return v.v4 == a.Unmap() || v.v6 == a
}
// FakeDNSIPv4 returns the fake DNS IPv4 address.
func FakeDNSIPv4() netip.Addr { return fakeDNS.v4 }
// FakeDNSIPv6 returns the fake DNS IPv6 address.
func FakeDNSIPv6() netip.Addr { return fakeDNS.v6 }
// FakeSyslogIPv4 returns the fake syslog IPv4 address.
func FakeSyslogIPv4() netip.Addr { return fakeSyslog.v4 }
// FakeSyslogIPv6 returns the fake syslog IPv6 address.
func FakeSyslogIPv6() netip.Addr { return fakeSyslog.v6 }
// newVIP returns a new virtual IP.
//
// opts may be an IPv4 an IPv6 (in string form) or an int (bounded by uint8) to
// use IPv4 of 52.52.0.x.
//
// If the IPv6 is omitted, one is derived from the IPv4.
//
// If an opt is invalid or the DNS name is already used, it panics.
func newVIP(name string, opts ...any) (v virtualIP) {
if _, ok := vips[name]; ok {
panic(fmt.Sprintf("duplicate VIP %q", name))
}
v.name = name
for _, o := range opts {
switch o := o.(type) {
case string:
if ip, err := netip.ParseAddr(o); err == nil {
if ip.Is4() {
v.v4 = ip
} else if ip.Is6() {
v.v6 = ip
}
} else {
panic(fmt.Sprintf("unsupported string option %q", o))
}
case int:
if o <= 0 || o > 255 {
panic(fmt.Sprintf("bad octet %d", o))
}
v.v4 = netip.AddrFrom4([4]byte{52, 52, 0, byte(o)})
default:
panic(fmt.Sprintf("unknown option type %T", o))
}
}
if !v.v6.IsValid() && v.v4.IsValid() {
// Map 1.2.3.4 to 2052::0102:0304
// But make 52.52.0.x map to 2052::x
a := [16]byte{0: 0x20, 1: 0x52} // 2052::
v4 := v.v4.As4()
if v4[0] == 52 && v4[1] == 52 && v4[2] == 0 {
a[15] = v4[3]
} else {
copy(a[12:], v.v4.AsSlice())
}
v.v6 = netip.AddrFrom16(a)
}
for _, b := range vips {
if b.Match(v.v4) || b.Match(v.v6) {
panic(fmt.Sprintf("VIP %q collides with %q", name, v.name))
}
}
vips[name] = v
return v
}