|
| 1 | +// Copyright 2026 Google LLC. |
| 2 | +// |
| 3 | +// Licensed under the Apache License, Version 2.0 (the "License"); |
| 4 | +// you may not use this file except in compliance with the License. |
| 5 | +// You may obtain a copy of the License at |
| 6 | +// |
| 7 | +// http://www.apache.org/licenses/LICENSE-2.0 |
| 8 | +// |
| 9 | +// Unless required by applicable law or agreed to in writing, software |
| 10 | +// distributed under the License is distributed on an "AS IS" BASIS, |
| 11 | +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| 12 | +// See the License for the specific language governing permissions and |
| 13 | +// limitations under the License. |
| 14 | + |
| 15 | +//go:build linux |
| 16 | + |
| 17 | +package nicsetup |
| 18 | + |
| 19 | +import ( |
| 20 | + "fmt" |
| 21 | + "os" |
| 22 | + "os/exec" |
| 23 | + "strings" |
| 24 | + "testing" |
| 25 | + "time" |
| 26 | +) |
| 27 | + |
| 28 | +// TestMetadataHostsCompliance validates that /etc/hosts is correctly configured |
| 29 | +// based on the stack types detected on the primary interface (nic0). |
| 30 | +func TestMetadataHostsCompliance(t *testing.T) { |
| 31 | + t.Logf("%s: Verifying /etc/hosts metadata compliance", getCurrentTime()) |
| 32 | + |
| 33 | + hasGlobalV4, hasGlobalV6 := getStackType(t) |
| 34 | + t.Logf("Detected stack type: Global IPv4: %v, Global IPv6: %v", hasGlobalV4, hasGlobalV6) |
| 35 | + |
| 36 | + hostsBytes, err := os.ReadFile("/etc/hosts") |
| 37 | + if err != nil { |
| 38 | + t.Fatalf("failed to read /etc/hosts: %v", err) |
| 39 | + } |
| 40 | + hostsContent := string(hostsBytes) |
| 41 | + t.Logf("/etc/hosts content:\n%s", hostsContent) |
| 42 | + |
| 43 | + hasV4MDS := strings.Contains(hostsContent, "169.254.169.254 metadata.google.internal") |
| 44 | + hasV6MDS := strings.Contains(hostsContent, "fd20:ce::254 metadata.google.internal") |
| 45 | + |
| 46 | + dualStack := hasGlobalV4 && hasGlobalV6 |
| 47 | + ipv4Only := hasGlobalV4 && !hasGlobalV6 |
| 48 | + ipv6Only := !hasGlobalV4 && hasGlobalV6 |
| 49 | + stackName := "Unknown" |
| 50 | + |
| 51 | + if dualStack { |
| 52 | + stackName = "DualStack" |
| 53 | + if !hasV4MDS { |
| 54 | + t.Errorf("%s: MUST contain 169.254.169.254 metadata.google.internal", stackName) |
| 55 | + } |
| 56 | + if hasV6MDS { |
| 57 | + t.Errorf("%s: MUST NOT contain fd20:ce::254 metadata.google.internal (IPv6 MDS disabled on dual-stack)", stackName) |
| 58 | + } |
| 59 | + } else if ipv6Only { |
| 60 | + stackName = "IPv6Only" |
| 61 | + if !hasV6MDS { |
| 62 | + t.Errorf("%s: MUST contain fd20:ce::254 metadata.google.internal", stackName) |
| 63 | + } |
| 64 | + } else if ipv4Only { |
| 65 | + stackName = "IPv4Only" |
| 66 | + if !hasV4MDS { |
| 67 | + t.Errorf("%s: MUST contain 169.254.169.254 metadata.google.internal", stackName) |
| 68 | + } |
| 69 | + if hasV6MDS { |
| 70 | + t.Errorf("%s: MUST NOT contain fd20:ce::254 metadata.google.internal", stackName) |
| 71 | + } |
| 72 | + } else { |
| 73 | + stackName = "NoGlobalIP" |
| 74 | + if hasV4MDS || hasV6MDS { |
| 75 | + t.Errorf("%s: unexpected MDS entries found", stackName) |
| 76 | + } |
| 77 | + } |
| 78 | + t.Logf("Detected stack type: %s", stackName) |
| 79 | +} |
| 80 | + |
| 81 | +// TestMetadataResolutionAndReachability verifies MDS reachability & content specifically on nic0. |
| 82 | +func TestMetadataResolutionAndReachability(t *testing.T) { |
| 83 | + t.Logf("%s: Verifying MDS reachability & content via hostname", getCurrentTime()) |
| 84 | + |
| 85 | + hasGlobalV4, hasGlobalV6 := getStackType(t) |
| 86 | + if !hasGlobalV4 && !hasGlobalV6 { |
| 87 | + t.Skip("Skipping reachability tests: No global IP addresses detected on primary interface.") |
| 88 | + return |
| 89 | + } |
| 90 | + |
| 91 | + curlCmd := exec.Command("curl", "-sSf", "-m", "5", "-H", "Metadata-Flavor: Google", "http://metadata.google.internal/computeMetadata/v1/instance/hostname") |
| 92 | + output, err := curlCmd.CombinedOutput() |
| 93 | + |
| 94 | + if err != nil { |
| 95 | + t.Fatalf("MDS reachability via HOSTNAME failed (Stack: v4:%v, v6:%v): %v\nOutput: %s", hasGlobalV4, hasGlobalV6, err, string(output)) |
| 96 | + } else { |
| 97 | + t.Logf("MDS hostname resolution and reachability successful: %s, detected stack: v4:%v, v6:%v", strings.TrimSpace(string(output)), hasGlobalV4, hasGlobalV6) |
| 98 | + } |
| 99 | +} |
| 100 | + |
| 101 | +// TestMDSHostsCorrection executes a negative test on Dual-Stack VMs by manually |
| 102 | +// injecting a forbidden IPv6 MDS entry, triggering the network dispatcher, and |
| 103 | +// verifying that the core worker successfully scrubs the file. |
| 104 | +func TestMDSHostsCorrection(t *testing.T) { |
| 105 | + t.Logf("%s: Verifying /etc/hosts correction on dual stack (Negative Test)", getCurrentTime()) |
| 106 | + |
| 107 | + hasGlobalV4, hasGlobalV6 := getStackType(t) |
| 108 | + if !(hasGlobalV4 && hasGlobalV6) { |
| 109 | + t.Skip("Skipping negative test: Not a dual-stack VM.") |
| 110 | + return |
| 111 | + } |
| 112 | + |
| 113 | + badEntry := "fd20:ce::254 metadata.google.internal" |
| 114 | + hostsFile := "/etc/hosts" |
| 115 | + |
| 116 | + // Append the forbidden IPv6 entry to /etc/hosts |
| 117 | + hostsBytes, _ := os.ReadFile(hostsFile) |
| 118 | + originalContent := string(hostsBytes) |
| 119 | + if !strings.Contains(originalContent, badEntry) { |
| 120 | + f, err := os.OpenFile(hostsFile, os.O_APPEND|os.O_WRONLY, 0644) |
| 121 | + if err != nil { |
| 122 | + t.Fatalf("Failed to open /etc/hosts for append: %v", err) |
| 123 | + } |
| 124 | + if _, err := f.WriteString("\n" + badEntry + "\n"); err != nil { |
| 125 | + t.Fatalf("Failed to write bad entry to /etc/hosts: %v", err) |
| 126 | + } |
| 127 | + f.Close() |
| 128 | + t.Logf("Appended forbidden IPv6 entry to /etc/hosts") |
| 129 | + } |
| 130 | + |
| 131 | + // Restart the network service (Triggers dispatcher hooks) |
| 132 | + refreshNetworkState(t) |
| 133 | + |
| 134 | + // Wait for the asynchronous script execution to acquire lock and complete |
| 135 | + time.Sleep(5 * time.Second) |
| 136 | + |
| 137 | + // Verify the forbidden line was removed |
| 138 | + hostsBytes, _ = os.ReadFile(hostsFile) |
| 139 | + currentContent := string(hostsBytes) |
| 140 | + |
| 141 | + if strings.Contains(currentContent, badEntry) { |
| 142 | + t.Errorf("Negative Test Failed: Dispatcher script failed to remove the forbidden IPv6 MDS entry.") |
| 143 | + } else { |
| 144 | + t.Logf("Negative Test Passed: Forbidden entry successfully scrubbed.") |
| 145 | + } |
| 146 | + |
| 147 | + // Ensure the required IPv4 entry survived |
| 148 | + if !strings.Contains(currentContent, "169.254.169.254 metadata.google.internal") { |
| 149 | + t.Errorf("Negative Test Failed: Dispatcher script accidentally removed the required IPv4 MDS entry during cleanup.") |
| 150 | + } |
| 151 | +} |
| 152 | + |
| 153 | +// refreshNetworkState detects the active network service and forces a |
| 154 | +// re-run of dispatcher scripts to verify configuration enforcement. |
| 155 | +func refreshNetworkState(t *testing.T) { |
| 156 | + t.Helper() |
| 157 | + |
| 158 | + // Detect primary interface dynamically |
| 159 | + outIface, _ := exec.Command("bash", "-c", "ip route show default | head -n 1 | awk '{print $5}'").Output() |
| 160 | + primaryIface := strings.TrimSpace(string(outIface)) |
| 161 | + if primaryIface == "" { |
| 162 | + outIface6, _ := exec.Command("bash", "-c", "ip -6 route show default | head -n 1 | awk '{print $5}'").Output() |
| 163 | + primaryIface = strings.TrimSpace(string(outIface6)) |
| 164 | + } |
| 165 | + if primaryIface == "" { |
| 166 | + t.Errorf("Failed to detect primary interface") |
| 167 | + return |
| 168 | + } |
| 169 | + |
| 170 | + // Check for systemd-networkd (Primary for Debian 12+/Ubuntu) |
| 171 | + _, errNetworkd := exec.LookPath("networkd-dispatcher") |
| 172 | + isNetworkdActive := exec.Command("systemctl", "is-active", "--quiet", "systemd-networkd").Run() == nil |
| 173 | + |
| 174 | + if errNetworkd == nil && isNetworkdActive { |
| 175 | + t.Log("Detected systemd-networkd; triggering via networkd-dispatcher") |
| 176 | + cmd := exec.Command("sudo", "networkd-dispatcher", "--run-startup-triggers") |
| 177 | + if err := cmd.Start(); err != nil { |
| 178 | + t.Errorf("networkd-dispatcher trigger failed to start: %v", err) |
| 179 | + } |
| 180 | + return |
| 181 | + } |
| 182 | + |
| 183 | + // Check for NetworkManager (Primary for RHEL/CentOS/Fedora) |
| 184 | + _, errNM := exec.LookPath("nmcli") |
| 185 | + isNMActive := exec.Command("systemctl", "is-active", "--quiet", "NetworkManager").Run() == nil |
| 186 | + |
| 187 | + if errNM == nil && isNMActive { |
| 188 | + t.Logf("Detected NetworkManager; simulating dispatcher event for %s", primaryIface) |
| 189 | + |
| 190 | + hookPath := "/etc/NetworkManager/dispatcher.d/google_hostname.sh" |
| 191 | + |
| 192 | + // Verify the hook exists before calling it |
| 193 | + if _, err := os.Stat(hookPath); err == nil { |
| 194 | + // Call the hook exactly as NM does: script <interface> <action> |
| 195 | + cmd := exec.Command("sudo", hookPath, primaryIface, "up") |
| 196 | + if err := cmd.Start(); err != nil { |
| 197 | + t.Errorf("Simulated NetworkManager hook failed to start: %v", err) |
| 198 | + } |
| 199 | + return |
| 200 | + } |
| 201 | + } |
| 202 | + |
| 203 | + // Check for dhclient |
| 204 | + isDhclientActive := exec.Command("pgrep", "dhclient").Run() == nil |
| 205 | + |
| 206 | + if isDhclientActive { |
| 207 | + t.Logf("Detected active dhclient; forcing release and restart on %s", primaryIface) |
| 208 | + |
| 209 | + // Release the lease properly |
| 210 | + _ = exec.Command("sudo", "dhclient", "-r", primaryIface).Run() |
| 211 | + |
| 212 | + // Kill any rogue/zombie dhclient processes left behind |
| 213 | + // This is needed to ensure that the next dhclient process is the only one running. |
| 214 | + _ = exec.Command("sudo", "pkill", "-9", "dhclient").Run() |
| 215 | + |
| 216 | + // Bring the interface back up cleanly using the system's native network manager |
| 217 | + // rather than launching a raw dhclient process. |
| 218 | + restartCmd := exec.Command("sudo", "ifup", "--force", primaryIface) |
| 219 | + if err := restartCmd.Start(); err != nil { |
| 220 | + t.Logf("Warning: ifup failed, falling back to raw dhclient start: %v", err) |
| 221 | + _ = exec.Command("sudo", "dhclient", primaryIface).Start() |
| 222 | + } |
| 223 | + return |
| 224 | + } |
| 225 | + // Check for wicked (Primary for SUSE/SLES) |
| 226 | + _, errWicked := exec.LookPath("wicked") |
| 227 | + isWickedActive := exec.Command("systemctl", "is-active", "--quiet", "wicked").Run() == nil |
| 228 | + |
| 229 | + if errWicked == nil && isWickedActive { |
| 230 | + t.Logf("Detected wicked; simulating post-up hook for %s", primaryIface) |
| 231 | + |
| 232 | + hookPath := "/etc/sysconfig/network/scripts/google_up.sh" |
| 233 | + |
| 234 | + if _, err := os.Stat(hookPath); err == nil { |
| 235 | + cmd := exec.Command("sudo", hookPath, primaryIface) |
| 236 | + if err := cmd.Start(); err != nil { |
| 237 | + t.Errorf("Simulated wicked hook failed to start: %v", err) |
| 238 | + } |
| 239 | + return |
| 240 | + } |
| 241 | + t.Errorf("wicked is active, but hook script is missing at %s", hookPath) |
| 242 | + return |
| 243 | + } |
| 244 | + |
| 245 | + // Update the fatal string to include dhclient |
| 246 | + t.Fatalf("No active network manager (systemd-networkd, NetworkManager, or dhclient) found to trigger dispatcher scripts.") |
| 247 | +} |
| 248 | + |
| 249 | +// getStackType determines the IP stack capabilities of the PRIMARY interface only, |
| 250 | +// as Google Cloud Metadata Server (MDS) routing is strictly bound to nic0. |
| 251 | +func getStackType(t *testing.T) (v4 bool, v6 bool) { |
| 252 | + t.Helper() |
| 253 | + t.Logf("%s: Determining stack type for primary interface", getCurrentTime()) |
| 254 | + |
| 255 | + // Identify the primary interface (checking both IPv4 and IPv6 routes) |
| 256 | + outIface, _ := exec.Command("bash", "-c", "ip route show default | head -n 1 | awk '{print $5}'").Output() |
| 257 | + primaryIface := strings.TrimSpace(string(outIface)) |
| 258 | + if primaryIface == "" { |
| 259 | + outIface6, _ := exec.Command("bash", "-c", "ip -6 route show default | head -n 1 | awk '{print $5}'").Output() |
| 260 | + primaryIface = strings.TrimSpace(string(outIface6)) |
| 261 | + } |
| 262 | + if primaryIface == "" { |
| 263 | + primaryIface = "eth0" // Fallback |
| 264 | + } |
| 265 | + |
| 266 | + t.Logf("Evaluating stack type specifically for primary interface: %s", primaryIface) |
| 267 | + |
| 268 | + // Check for global IPv4 exclusively on the primary interface |
| 269 | + cmdV4 := fmt.Sprintf("ip -4 addr show dev %s scope global | grep -v '169.254' | grep -q 'inet '", primaryIface) |
| 270 | + if err := exec.Command("bash", "-c", cmdV4).Run(); err == nil { |
| 271 | + v4 = true |
| 272 | + } |
| 273 | + |
| 274 | + // Check for global IPv6 exclusively on the primary interface |
| 275 | + cmdV6 := fmt.Sprintf("ip -6 addr show dev %s scope global | grep -q 'inet6'", primaryIface) |
| 276 | + if err := exec.Command("bash", "-c", cmdV6).Run(); err == nil { |
| 277 | + v6 = true |
| 278 | + } |
| 279 | + |
| 280 | + return v4, v6 |
| 281 | +} |
0 commit comments