package routing import ( "encoding/json" "os" "os/exec" "path/filepath" "strings" "testing" ) func useTempRouteStores(t *testing.T) { t.Helper() oldStatic, oldPolicy, oldTables := StaticRoutesStorePath, PolicyRulesStorePath, CustomTablesStorePath dir := t.TempDir() StaticRoutesStorePath = filepath.Join(dir, "static.json") PolicyRulesStorePath = filepath.Join(dir, "rules.json") CustomTablesStorePath = filepath.Join(dir, "tables.json") t.Cleanup(func() { StaticRoutesStorePath, PolicyRulesStorePath, CustomTablesStorePath = oldStatic, oldPolicy, oldTables }) } func TestRouteEnableFailureRestoresStoredAndMemoryState(t *testing.T) { useTempRouteStores(t) bin := t.TempDir() t.Setenv("PATH", bin) script := "#!/bin/sh\nif [ \"$2\" = del ]; then echo 'RTNETLINK answers: No such process' >&2; exit 2; fi\necho 'permission denied' >&2; exit 2\n" if err := os.WriteFile(filepath.Join(bin, "ip"), []byte(script), 0755); err != nil { t.Fatal(err) } old := RouteItem{ID: "r1", DstAddress: "198.51.100.0/24", Gateway: "192.0.2.1", Interface: "eth0", RoutingTable: "main", Distance: 10, Disabled: true, Active: false} m := &RoutingManager{staticRoutes: []RouteItem{old}} if err := m.save(); err != nil { t.Fatal(err) } err := m.SetRouteState("r1", false) if err == nil || !strings.Contains(err.Error(), "applying route change") { t.Fatalf("expected apply error: %v", err) } if !m.staticRoutes[0].Disabled || m.staticRoutes[0].Active { t.Fatalf("memory not restored: %+v", m.staticRoutes[0]) } data, err := os.ReadFile(StaticRoutesStorePath) if err != nil { t.Fatal(err) } var persisted []RouteItem if err = json.Unmarshal(data, &persisted); err != nil { t.Fatal(err) } if len(persisted) != 1 || !persisted[0].Disabled { t.Fatalf("storage not restored: %+v", persisted) } } func TestRouteDeletionNeverUsesUnqualifiedFallback(t *testing.T) { useTempRouteStores(t) bin := t.TempDir() t.Setenv("PATH", bin) logPath := filepath.Join(t.TempDir(), "commands") t.Setenv("DEBTIK_TEST_IP_LOG", logPath) script := "#!/bin/sh\nprintf '%s\\n' \"$*\" >> \"$DEBTIK_TEST_IP_LOG\"\necho 'RTNETLINK answers: No such process' >&2\nexit 2\n" if err := os.WriteFile(filepath.Join(bin, "ip"), []byte(script), 0755); err != nil { t.Fatal(err) } r := RouteItem{ID: "r1", DstAddress: "198.51.100.0/24", Gateway: "192.0.2.1", Interface: "eth0", RoutingTable: "100", Distance: 10, Active: true} m := &RoutingManager{staticRoutes: []RouteItem{r}} if err := m.save(); err != nil { t.Fatal(err) } if err := m.DeleteRoute("r1"); err != nil { t.Fatal(err) } data, err := os.ReadFile(logPath) if err != nil { t.Fatal(err) } lines := strings.Split(strings.TrimSpace(string(data)), "\n") if len(lines) != 2 { t.Fatalf("unexpected commands: %s", data) } for _, line := range lines { if !strings.Contains(line, "metric 10 table 100 proto static") { t.Fatalf("unsafe deletion: %s", line) } } } func TestIsolatedRouteGroupDeletionKeepsOtherMetric(t *testing.T) { if os.Getenv("DEBTIK_TEST_ISOLATED_NETNS") != "1" { t.Skip("requires a dedicated network namespace") } ip := func(args ...string) { t.Helper() if out, err := exec.Command("ip", args...).CombinedOutput(); err != nil { t.Fatalf("ip %v: %s: %v", args, out, err) } } ip("link", "set", "lo", "up") ip("route", "add", "198.51.100.0/24", "dev", "lo", "metric", "10", "proto", "static") ip("route", "add", "198.51.100.0/24", "dev", "lo", "metric", "20", "proto", "static") m := &RoutingManager{} if err := m.syncRouteGroupToKernel("198.51.100.0/24", "main", 10); err != nil { t.Fatal(err) } out, err := exec.Command("ip", "route", "show", "198.51.100.0/24").CombinedOutput() if err != nil { t.Fatal(err) } if strings.Contains(string(out), "metric 10") || !strings.Contains(string(out), "metric 20") { t.Fatalf("unexpected routes: %s", out) } } func TestRouteEditSameGroupDoesNotDeleteReplacement(t *testing.T) { useTempRouteStores(t) bin := t.TempDir() t.Setenv("PATH", bin) logPath := filepath.Join(t.TempDir(), "commands") t.Setenv("DEBTIK_TEST_IP_LOG", logPath) script := "#!/bin/sh\nprintf '%s\\n' \"$*\" >> \"$DEBTIK_TEST_IP_LOG\"\nexit 0\n" if err := os.WriteFile(filepath.Join(bin, "ip"), []byte(script), 0755); err != nil { t.Fatal(err) } old := RouteItem{ID: "r1", DstAddress: "198.51.100.0/24", Gateway: "192.0.2.1", Interface: "eth0", RoutingTable: "main", Distance: 10, Active: true} m := &RoutingManager{staticRoutes: []RouteItem{old}} if err := m.save(); err != nil { t.Fatal(err) } next := old next.Gateway = "192.0.2.2" if err := m.SaveRoute(&next); err != nil { t.Fatal(err) } data, err := os.ReadFile(logPath) if err != nil { t.Fatal(err) } commands := strings.Split(strings.TrimSpace(string(data)), "\n") if len(commands) != 1 || !strings.Contains(commands[0], "route replace 198.51.100.0/24 via 192.0.2.2") { t.Fatalf("replacement was deleted or repeated: %s", data) } } func TestRouteEditCleanupFailureRollsBack(t *testing.T) { useTempRouteStores(t) bin := t.TempDir() t.Setenv("PATH", bin) logPath := filepath.Join(t.TempDir(), "commands") t.Setenv("DEBTIK_TEST_IP_LOG", logPath) script := "#!/bin/sh\nprintf '%s\\n' \"$*\" >> \"$DEBTIK_TEST_IP_LOG\"\nif [ \"$2\" = del ] && [ \"$3\" = 198.51.100.0/24 ]; then echo 'permission denied' >&2; exit 2; fi\nif [ \"$2\" = del ]; then echo 'RTNETLINK answers: No such process' >&2; exit 2; fi\nexit 0\n" if err := os.WriteFile(filepath.Join(bin, "ip"), []byte(script), 0755); err != nil { t.Fatal(err) } old := RouteItem{ID: "r1", DstAddress: "198.51.100.0/24", Gateway: "192.0.2.1", Interface: "eth0", RoutingTable: "main", Distance: 10, Active: true} m := &RoutingManager{staticRoutes: []RouteItem{old}} if err := m.save(); err != nil { t.Fatal(err) } next := old next.DstAddress = "203.0.113.0/24" err := m.SaveRoute(&next) if err == nil || !strings.Contains(err.Error(), "cleaning previous route group") { t.Fatalf("cleanup failure ignored: %v", err) } if len(m.staticRoutes) != 1 || m.staticRoutes[0].DstAddress != old.DstAddress { t.Fatalf("memory not restored: %+v", m.staticRoutes) } data, err := os.ReadFile(StaticRoutesStorePath) if err != nil { t.Fatal(err) } var persisted []RouteItem if err = json.Unmarshal(data, &persisted); err != nil { t.Fatal(err) } if len(persisted) != 1 || persisted[0].DstAddress != old.DstAddress { t.Fatalf("storage not restored: %+v", persisted) } cmds, err := os.ReadFile(logPath) if err != nil { t.Fatal(err) } if !strings.Contains(string(cmds), "route replace 198.51.100.0/24") || !strings.Contains(string(cmds), "route del 203.0.113.0/24") { t.Fatalf("rollback omitted: %s", cmds) } } func TestIsolatedRouteEditKeepsReplacementAndRollsBackFailure(t *testing.T) { if os.Getenv("DEBTIK_TEST_ISOLATED_NETNS") != "1" { t.Skip("requires a dedicated network namespace") } useTempRouteStores(t) ip := func(args ...string) string { t.Helper() out, err := exec.Command("ip", args...).CombinedOutput() if err != nil { t.Fatalf("ip %v: %s: %v", args, out, err) } return string(out) } ip("link", "set", "lo", "up") ip("link", "add", "route-test0", "type", "dummy") ip("link", "set", "route-test0", "up") old := RouteItem{ID: "r1", DstAddress: "198.51.101.0/24", Gateway: "192.0.2.1", Interface: "route-test0", RoutingTable: "main", Distance: 10, Active: true} ip("route", "add", old.DstAddress, "via", old.Gateway, "dev", old.Interface, "onlink", "metric", "10", "proto", "static") m := &RoutingManager{staticRoutes: []RouteItem{old}} if err := m.save(); err != nil { t.Fatal(err) } next := old next.Gateway = "192.0.2.2" if err := m.SaveRoute(&next); err != nil { t.Fatal(err) } output := ip("route", "show", old.DstAddress) if !strings.Contains(output, "via 192.0.2.2") || strings.Contains(output, "via 192.0.2.1") { t.Fatalf("replacement missing: %s", output) } invalid := next invalid.DstAddress = "203.0.114.0/24" invalid.Interface = "does-not-exist" if err := m.SaveRoute(&invalid); err == nil { t.Fatal("expected failure applying invalid device") } output = ip("route", "show", old.DstAddress) if !strings.Contains(output, "via 192.0.2.2") { t.Fatalf("old route lost after failed edit: %s", output) } if output = ip("route", "show", invalid.DstAddress); strings.TrimSpace(output) != "" { t.Fatalf("invalid route left in kernel: %s", output) } } func TestIsolatedRouteIDsAndMetricsRemainDistinct(t *testing.T) { if os.Getenv("DEBTIK_TEST_ISOLATED_NETNS") != "1" { t.Skip("requires a dedicated network namespace") } useTempRouteStores(t) ip := func(args ...string) { t.Helper() if out, err := exec.Command("ip", args...).CombinedOutput(); err != nil { t.Fatalf("ip %v: %s %v", args, out, err) } } ip("link", "set", "lo", "up") ip("link", "add", "route-test1", "type", "dummy") ip("link", "set", "route-test1", "up") ip("route", "add", "198.51.102.0/24", "dev", "route-test1", "metric", "10", "proto", "static") ip("route", "add", "198.51.102.0/24", "dev", "route-test1", "metric", "20", "proto", "static") m := &RoutingManager{} var kernelIDs []string for _, r := range m.listLiveRoutesLocked() { if r.DstAddress == "198.51.102.0/24" { kernelIDs = append(kernelIDs, r.ID) } } if len(kernelIDs) != 2 || kernelIDs[0] == kernelIDs[1] { t.Fatalf("ambiguous kernel IDs: %v", kernelIDs) } m.staticRoutes = []RouteItem{{ID: "r20", DstAddress: "198.51.102.0/24", Interface: "route-test1", RoutingTable: "main", Distance: 20, Active: true}, {ID: "r10", DstAddress: "198.51.102.0/24", Interface: "route-test1", RoutingTable: "main", Distance: 10, Active: true}} seen := map[int]string{} for _, r := range m.ListRoutes() { if r.DstAddress == "198.51.102.0/24" && r.Active { seen[r.Distance] = r.ID } } if seen[10] != "r10" || seen[20] != "r20" { t.Fatalf("wrong route mapping: %v", seen) } m.staticRoutes[1].Disabled = true for _, r := range m.ListRoutes() { if r.ID == "r10" && r.Active { t.Fatal("disabled managed route displayed as active") } } }