tstest/natlab: add TestSubnetRouterFreeBSD with FreeBSD cloud image support

As a warm-up to making natlab support multiple operating systems,
start with an easy one (in that it's also Unixy and open source like
Linux) and add FreeBSD 15.0 as a VM OS option for the vmtest
integration test framework, and add TestSubnetRouterFreeBSD which
tests subnet routing through a FreeBSD VM (Gokrazy → FreeBSD →
Gokrazy).

Key changes:
- Add FreeBSD150 OSImage using the official FreeBSD 15.0
  BASIC-CLOUDINIT cloud image (xz-compressed qcow2)
- Add GOOS()/IsFreeBSD() methods to OSImage for cross-compilation
  and OS-specific behavior
- Handle xz-compressed image downloads in ensureImage
- Refactor compileBinaries into compileBinariesForOS to support
  multiple GOOS targets (linux, freebsd), with binaries registered
  at <goos>/<name> paths on the file server VIP
- Add FreeBSD-specific cloud-init (nuageinit) user-data generation:
  string-form runcmd (nuageinit doesn't support YAML arrays),
  fetch(1) instead of curl, FreeBSD sysctl names for IP forwarding,
  mkdir /usr/local/bin, PATH setup for tta
- Skip network-config in cidata ISO for FreeBSD (DHCP via rc.conf)

Updates tailscale/tailscale#13038

Change-Id: Ibeb4f7d02659d5cd8e3a7c3a66ee7b1a92a0110d
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
This commit is contained in:
Brad Fitzpatrick
2026-04-09 01:37:43 +00:00
committed by Brad Fitzpatrick
parent 85d6ba9473
commit dca1d8eea1
6 changed files with 163 additions and 39 deletions
+80 -14
View File
@@ -13,18 +13,22 @@ import (
)
// createCloudInitISO creates a cidata seed ISO for the given cloud VM node.
// The ISO contains meta-data, user-data, and network-config files.
// Cloud-init reads these during init-local (pre-network), which is critical
// for network-config to take effect before systemd-networkd-wait-online runs.
// For Linux VMs, the ISO contains meta-data, user-data, and network-config.
// For FreeBSD VMs, the ISO contains meta-data and user-data only (nuageinit
// doesn't use netplan-style network-config; DHCP is enabled in rc.conf).
func (e *Env) createCloudInitISO(n *Node) (string, error) {
metaData := fmt.Sprintf("instance-id: %s\nlocal-hostname: %s\n", n.name, n.name)
userData := e.generateUserData(n)
// Network config: DHCP all ethernet interfaces.
// The "optional: true" prevents systemd-networkd-wait-online from blocking.
// The first vnet NIC gets the default route (metric 100).
// Other interfaces get higher metrics to avoid routing conflicts.
networkConfig := `version: 2
files := map[string]string{
"meta-data": metaData,
"user-data": userData,
}
// Linux cloud-init needs network-config to configure interfaces before
// systemd-networkd-wait-online blocks boot.
if n.os.GOOS() == "linux" {
files["network-config"] = `version: 2
ethernets:
primary:
match:
@@ -41,6 +45,7 @@ ethernets:
route-metric: 200
optional: true
`
}
iw, err := iso9660.NewWriter()
if err != nil {
@@ -48,11 +53,7 @@ ethernets:
}
defer iw.Cleanup()
for name, content := range map[string]string{
"meta-data": metaData,
"user-data": userData,
"network-config": networkConfig,
} {
for name, content := range files {
if err := iw.AddFile(strings.NewReader(content), name); err != nil {
return "", fmt.Errorf("adding %s to ISO: %w", name, err)
}
@@ -72,6 +73,18 @@ ethernets:
// generateUserData creates the cloud-init user-data (#cloud-config) for a node.
func (e *Env) generateUserData(n *Node) string {
switch n.os.GOOS() {
case "linux":
return e.generateLinuxUserData(n)
case "freebsd":
return e.generateFreeBSDUserData(n)
default:
panic(fmt.Sprintf("unsupported GOOS %q for cloud-init user-data", n.os.GOOS()))
}
}
// generateLinuxUserData creates Linux cloud-init user-data (#cloud-config) for a node.
func (e *Env) generateLinuxUserData(n *Node) string {
var ud strings.Builder
ud.WriteString("#cloud-config\n")
@@ -95,8 +108,9 @@ func (e *Env) generateUserData(n *Node) string {
// Download binaries from the files.tailscale VIP (52.52.0.6).
// Use the IP directly to avoid DNS resolution issues during early boot.
binDir := n.os.GOOS() + "_" + n.os.GOARCH()
for _, bin := range []string{"tailscaled", "tailscale", "tta"} {
fmt.Fprintf(&ud, " - [\"/bin/sh\", \"-c\", \"curl -v --retry 10 --retry-delay 2 --retry-all-errors -o /usr/local/bin/%s http://52.52.0.6/%s 2>&1\"]\n", bin, bin)
fmt.Fprintf(&ud, " - [\"/bin/sh\", \"-c\", \"curl -v --retry 10 --retry-delay 2 --retry-all-errors -o /usr/local/bin/%s http://52.52.0.6/%s/%s 2>&1\"]\n", bin, binDir, bin)
}
ud.WriteString(" - [\"chmod\", \"+x\", \"/usr/local/bin/tailscaled\", \"/usr/local/bin/tailscale\", \"/usr/local/bin/tta\"]\n")
@@ -115,3 +129,55 @@ func (e *Env) generateUserData(n *Node) string {
return ud.String()
}
// generateFreeBSDUserData creates FreeBSD nuageinit user-data (#cloud-config)
// for a node. FreeBSD's nuageinit supports a subset of cloud-init directives
// including runcmd, which runs after networking is up.
//
// IMPORTANT: nuageinit's runcmd only supports string entries, not the YAML
// array form that Linux cloud-init supports. Each entry must be a plain string
// that gets passed to /bin/sh -c.
func (e *Env) generateFreeBSDUserData(n *Node) string {
var ud strings.Builder
ud.WriteString("#cloud-config\n")
ud.WriteString("ssh_pwauth: true\n")
ud.WriteString("runcmd:\n")
// /usr/local/bin may not exist on a fresh FreeBSD cloud image (it's
// created when the first package is installed).
ud.WriteString(" - \"mkdir -p /usr/local/bin\"\n")
// Remove the default route via the debug NIC's SLIRP gateway so that
// traffic goes through the vnet NICs. The debug NIC is only for SSH.
ud.WriteString(" - \"route delete default 10.0.2.2 2>/dev/null || true\"\n")
// Download binaries from the files.tailscale VIP (52.52.0.6).
// FreeBSD's fetch(1) is part of the base system (no curl needed).
// Retry in a loop since the file server may not be ready immediately.
binDir := n.os.GOOS() + "_" + n.os.GOARCH()
for _, bin := range []string{"tailscaled", "tailscale", "tta"} {
fmt.Fprintf(&ud, " - \"n=0; while [ $n -lt 10 ]; do fetch -o /usr/local/bin/%s http://52.52.0.6/%s/%s && break; n=$((n+1)); sleep 2; done\"\n", bin, binDir, bin)
}
ud.WriteString(" - \"chmod +x /usr/local/bin/tailscaled /usr/local/bin/tailscale /usr/local/bin/tta\"\n")
// Enable IP forwarding for subnet routers.
// This is currently a noop as of 2026-04-08 because FreeBSD uses
// gvisor netstack for subnet routing until
// https://github.com/tailscale/tailscale/issues/5573 etc are fixed.
if n.advertiseRoutes != "" {
ud.WriteString(" - \"sysctl net.inet.ip.forwarding=1\"\n")
ud.WriteString(" - \"sysctl net.inet6.ip6.forwarding=1\"\n")
}
// Start tailscaled and tta in the background.
// Set PATH to include /usr/local/bin so that tta can find "tailscale"
// (TTA uses exec.Command("tailscale", ...) without a full path).
ud.WriteString(" - \"export PATH=/usr/local/bin:$PATH && /usr/local/bin/tailscaled --state=mem: &\"\n")
ud.WriteString(" - \"sleep 2\"\n")
// Start tta (Tailscale Test Agent).
ud.WriteString(" - \"export PATH=/usr/local/bin:$PATH && /usr/local/bin/tta &\"\n")
return ud.String()
}