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>
This commit is contained in:
Brad Fitzpatrick
2026-04-08 17:24:19 +00:00
committed by Brad Fitzpatrick
parent ccef06b968
commit 814161303f
4 changed files with 314 additions and 27 deletions
+87 -5
View File
@@ -72,6 +72,13 @@ func nodeMac(n int) MAC {
return MAC{0x52, 0xcc, 0xcc, 0xcc, 0xcc, byte(n)}
}
// nodeNICMac returns the MAC for the nicIdx-th secondary NIC (1-indexed) of node n.
// Primary NICs (index 0) use nodeMac. Secondary NICs use a different scheme:
// 52:cc:cc:cc:KK:NN where KK is the NIC index and NN is the node number.
func nodeNICMac(nodeNum, nicIdx int) MAC {
return MAC{0x52, 0xcc, 0xcc, 0xcc, byte(nicIdx), byte(nodeNum)}
}
func routerMac(n int) MAC {
// 52=TS then 0xee for 'etwork
return MAC{0x52, 0xee, 0xee, 0xee, 0xee, byte(n)}
@@ -236,11 +243,31 @@ func (n *Node) String() string {
return fmt.Sprintf("node%d", n.num)
}
// MAC returns the MAC address of the node.
// MAC returns the MAC address of the node's primary NIC.
func (n *Node) MAC() MAC {
return n.mac
}
// NumNICs returns the number of network interfaces on the node
// (one per network the node is on).
func (n *Node) NumNICs() int {
return len(n.nets)
}
// NICMac returns the MAC address for the i-th NIC (0-indexed).
// NIC 0 is the primary NIC (same as MAC()). NIC 1+ are extra NICs.
func (n *Node) NICMac(i int) MAC {
if i == 0 {
return n.mac
}
return nodeNICMac(n.num, i)
}
// Networks returns the list of networks this node is on.
func (n *Node) Networks() []*Network {
return n.nets
}
func (n *Node) Env() []TailscaledEnv {
return n.env
}
@@ -306,6 +333,26 @@ func (n *Node) IsV6Only() bool {
return false
}
// LanIP returns the node's LAN IPv4 address on the given network.
// It requires the [Server] to have been initialized (i.e., [New] was called).
// Returns an invalid addr if the node has no IP on that network.
func (n *Node) LanIP(net *Network) netip.Addr {
if n.n == nil {
return netip.Addr{}
}
for i, nn := range n.nets {
if nn == net {
if i == 0 {
return n.n.lanIP
}
if i-1 < len(n.n.extraNICs) {
return n.n.extraNICs[i-1].lanIP
}
}
}
return netip.Addr{}
}
// Network returns the first network this node is connected to,
// or nil if none.
func (n *Node) Network() *Network {
@@ -486,10 +533,11 @@ func (s *Server) initFromConfig(c *Config) error {
if conf.err != nil {
return conf.err
}
primaryNet := netOfConf[conf.Network()]
n := &node{
num: conf.num,
mac: conf.mac,
net: netOfConf[conf.Network()],
net: primaryNet,
verboseSyslog: conf.VerboseSyslog(),
}
n.interfaceID = must.Get(s.pcapWriter.AddInterface(pcapgo.NgInterface{
@@ -503,16 +551,50 @@ func (s *Server) initFromConfig(c *Config) error {
s.nodes = append(s.nodes, n)
s.nodeByMAC[n.mac] = n
if n.net.v4 {
if n.net != nil && n.net.v4 {
// Allocate a lanIP for the node. Use the network's CIDR and use final
// octet 101 (for first node), 102, etc. The node number comes from the
// last octent of the MAC address (0-based)
// last octet of the MAC address (0-based)
ip4 := n.net.lanIP4.Addr().As4()
ip4[3] = 100 + n.mac[5]
n.lanIP = netip.AddrFrom4(ip4)
n.net.nodesByIP4[n.lanIP] = n
}
n.net.nodesByMAC[n.mac] = n
if n.net != nil {
n.net.nodesByMAC[n.mac] = n
}
// Set up extra NICs for multi-homed nodes (nodes on more than one network).
for nicIdx, confNet := range conf.nets[1:] {
extraNet := netOfConf[confNet]
if extraNet == nil {
continue
}
mac := nodeNICMac(conf.num, nicIdx+1)
nic := nodeNIC{
mac: mac,
net: extraNet,
}
nic.interfaceID = must.Get(s.pcapWriter.AddInterface(pcapgo.NgInterface{
Name: fmt.Sprintf("%s-nic%d", n.String(), nicIdx+1),
LinkType: layers.LinkTypeEthernet,
}))
// Allocate a lanIP for the node. Use the network's CIDR and use final
// octet 101 (for first node), 102, etc. The node number comes from the
// last octet of the MAC address (0-based)
if extraNet.v4 {
ip4 := extraNet.lanIP4.Addr().As4()
ip4[3] = 100 + mac[5]
nic.lanIP = netip.AddrFrom4(ip4)
extraNet.nodesByIP4[nic.lanIP] = n
}
extraNet.nodesByMAC[mac] = n
if _, ok := s.nodeByMAC[mac]; ok {
return fmt.Errorf("two nodes have the same MAC %v", mac)
}
s.nodeByMAC[mac] = n
n.extraNICs = append(n.extraNICs, nic)
}
}
// Now that nodes are populated, set up NAT: