package networking import ( "context" "encoding/json" "errors" "net/http" "os" "path/filepath" "reflect" "strings" "testing" "nadir/internal/oscmd" "github.com/danielgtaylor/huma/v2" "github.com/danielgtaylor/huma/v2/adapters/humago" "github.com/danielgtaylor/huma/v2/humatest" ) type mockBackend struct { name string snapshotResult IfaceConfig applyCalledWith IfaceConfig applyErr error snapshotErr error setUpCalled bool setDownCalled bool } func (m *mockBackend) Name() string { return m.name } func (m *mockBackend) Snapshot(_ context.Context, iface string) (IfaceConfig, error) { return m.snapshotResult, m.snapshotErr } func (m *mockBackend) Apply(_ context.Context, iface string, cfg IfaceConfig) error { m.applyCalledWith = cfg return m.applyErr } func (m *mockBackend) SetLinkUp(_ context.Context, iface string) error { m.setUpCalled = true return nil } func (m *mockBackend) SetLinkDown(_ context.Context, iface string) error { m.setDownCalled = true return nil } func TestNetworkingHandlers(t *testing.T) { mux := http.NewServeMux() api := humatest.Wrap(t, humago.New(mux, huma.DefaultConfig("Test", "1.0.0"))) be := &mockBackend{ name: "mockbe", snapshotResult: IfaceConfig{ Method: "dhcp", Address: "192.168.1.10/24", }, } m := &Module{be: be} m.Register(api) tempResolv := filepath.Join(t.TempDir(), "resolv.conf") if err := os.WriteFile(tempResolv, []byte("nameserver 1.1.1.1\nnameserver 8.8.8.8\n"), 0644); err != nil { t.Fatal(err) } oldResolv := resolvConf resolvConf = tempResolv defer func() { resolvConf = oldResolv }() oscmd.SetMock("ip", func(args []string) oscmd.MockCommand { if reflect.DeepEqual(args, []string{"-j", "addr"}) { out := `[{"ifname": "eth0", "operstate": "UP", "address": "aa:bb:cc:dd:ee:ff", "mtu": 1500, "addr_info": [{"family": "inet", "local": "192.168.1.10", "prefixlen": 24}]}]` return oscmd.MockCommand{Stdout: out, ExitCode: 0} } if reflect.DeepEqual(args, []string{"-j", "route"}) { out := `[{"dst": "default", "gateway": "192.168.1.1", "dev": "eth0"}]` return oscmd.MockCommand{Stdout: out, ExitCode: 0} } return oscmd.MockCommand{ExitCode: 1} }) defer oscmd.ClearMocks() // 1. Test GET /api/networking/interfaces resp := api.Get("/api/networking/interfaces") if resp.Code != http.StatusOK { t.Errorf("list interfaces: got %d, want %d", resp.Code, http.StatusOK) } // 1b. Test GET /api/networking/interfaces/{name} (used by edit-form prefill). // Asserts the backend's Snapshot output is returned verbatim as the body, so // the same shape can feed straight into PUT. resp = api.Get("/api/networking/interfaces/eth0") if resp.Code != http.StatusOK { t.Errorf("get interface: got %d, want %d", resp.Code, http.StatusOK) } var ifaceRes GetInterfaceConfigOutput if err := json.Unmarshal(resp.Body.Bytes(), &ifaceRes.Body); err != nil { t.Fatal(err) } if ifaceRes.Body.Method != "dhcp" || ifaceRes.Body.Address != "192.168.1.10/24" { t.Errorf("get interface: got %+v, want snapshot result", ifaceRes.Body) } // Same endpoint with no backend should return 501. noBackend := &Module{} noBackendMux := http.NewServeMux() noBackendAPI := humatest.Wrap(t, humago.New(noBackendMux, huma.DefaultConfig("Test", "1.0.0"))) noBackend.Register(noBackendAPI) if got := noBackendAPI.Get("/api/networking/interfaces/eth0").Code; got != http.StatusNotImplemented { t.Errorf("get interface without backend: got %d, want 501", got) } // 2. Test GET /api/networking/routes resp = api.Get("/api/networking/routes") if resp.Code != http.StatusOK { t.Errorf("list routes: got %d, want %d", resp.Code, http.StatusOK) } // 3. Test GET /api/networking/dns resp = api.Get("/api/networking/dns") if resp.Code != http.StatusOK { t.Errorf("get dns: got %d, want %d", resp.Code, http.StatusOK) } var dnsRes DNSOutput if err := json.Unmarshal(resp.Body.Bytes(), &dnsRes.Body); err != nil { t.Fatal(err) } if len(dnsRes.Body.Servers) != 2 || dnsRes.Body.Servers[0] != "1.1.1.1" { t.Errorf("get dns output: %+v", dnsRes.Body) } // 4. Test PUT /api/networking/interfaces/{name} applyPayload := struct { Method string `json:"method"` Address string `json:"address,omitempty"` Prefix int `json:"prefix,omitempty"` Gateway string `json:"gateway,omitempty"` DNS []string `json:"dns,omitempty"` RollbackSeconds int `json:"rollback_seconds,omitempty"` }{ Method: "static", Address: "192.168.1.20", Prefix: 24, Gateway: "192.168.1.1", DNS: []string{"1.1.1.1"}, RollbackSeconds: 2, } resp = api.Put("/api/networking/interfaces/eth0", applyPayload) if resp.Code != http.StatusOK { t.Errorf("apply interface config: got %d, want %d, body=%s", resp.Code, http.StatusOK, resp.Body.String()) } resp = api.Get("/api/networking/pending") if resp.Code != http.StatusOK { t.Errorf("get pending: got %d, want %d", resp.Code, http.StatusOK) } // 5. Test POST /api/networking/interfaces/{name}/confirm resp = api.Post("/api/networking/interfaces/eth0/confirm", struct{}{}) if resp.Code != http.StatusOK { t.Errorf("confirm change: got %d, want %d", resp.Code, http.StatusOK) } resp = api.Get("/api/networking/pending") if resp.Code != http.StatusNotFound { t.Errorf("pending change should be cleared: got %d, want %d", resp.Code, http.StatusNotFound) } // 6. Test explicit rollback (same revert path as automatic rollback). applyPayload.RollbackSeconds = 1 resp = api.Put("/api/networking/interfaces/eth0", applyPayload) if resp.Code != http.StatusOK { t.Errorf("apply config again: got %d, want %d", resp.Code, http.StatusOK) } if m.pending == nil { t.Fatal("expected pending change after apply") } if err := m.rollbackNow("eth0"); err != nil { t.Fatal(err) } resp = api.Get("/api/networking/pending") if resp.Code != http.StatusNotFound { t.Errorf("pending change should be rolled back: got %d, want %d", resp.Code, http.StatusNotFound) } if be.applyCalledWith.Method != "dhcp" || be.applyCalledWith.Address != "192.168.1.10/24" { t.Errorf("rollback failed to restore prior config: %+v", be.applyCalledWith) } // 7. Test POST link up & down resp = api.Post("/api/networking/interfaces/eth0/up", struct{}{}) if resp.Code != http.StatusOK { t.Errorf("set link up: got %d, want %d", resp.Code, http.StatusOK) } if !be.setUpCalled { t.Errorf("expected SetLinkUp call on backend") } resp = api.Post("/api/networking/interfaces/eth0/down", struct{}{}) if resp.Code != http.StatusOK { t.Errorf("set link down: got %d, want %d", resp.Code, http.StatusOK) } if !be.setDownCalled { t.Errorf("expected SetLinkDown call on backend") } } // #3: a failed Apply must restore the prior snapshot (no half-applied config // left with no auto-revert) and arm no pending change. func TestApplyFailureRestoresPrior(t *testing.T) { be := &mockBackend{ name: "mock", snapshotResult: IfaceConfig{Method: "dhcp"}, applyErr: errors.New("con up failed"), } m := &Module{be: be} _, err := m.startRollback(t.Context(), "eth0", IfaceConfig{Method: "static", Address: "10.0.0.5", Prefix: 24}) if err == nil { t.Fatal("expected apply to fail") } // The last Apply call should be the restore-to-prior (dhcp), not the new config. if be.applyCalledWith.Method != "dhcp" { t.Errorf("expected restore to prior config, last Apply got %+v", be.applyCalledWith) } if m.pending != nil { t.Error("no pending change should be armed after a failed apply") } } // #1: link-down must go through the rollback safety net (arms a pending change), // and rolling it back brings the interface back up. #2: a change to another // interface while one is pending is rejected with errAlreadyPending. func TestLinkDownArmsRollback(t *testing.T) { be := &mockBackend{name: "mock"} m := &Module{be: be} secs, err := m.startLinkDown(t.Context(), "eth0") if err != nil { t.Fatal(err) } if secs != defaultRollbackSeconds { t.Errorf("seconds = %d, want default %d", secs, defaultRollbackSeconds) } if !be.setDownCalled { t.Error("expected SetLinkDown to be called") } if m.pending == nil { t.Fatal("expected a pending change to be armed") } // A concurrent change to a different interface is rejected (global lock). if _, err := m.startRollback(t.Context(), "eth1", IfaceConfig{Method: "dhcp"}); !errors.Is(err, errAlreadyPending) { t.Errorf("expected errAlreadyPending for eth1, got %v", err) } // Rolling back brings the link back up and clears the pending change. if err := m.rollbackNow("eth0"); err != nil { t.Fatal(err) } if !be.setUpCalled { t.Error("expected SetLinkUp on rollback") } if m.pending != nil { t.Error("pending change should be cleared after rollback") } } func TestBackendImplementations(t *testing.T) { tempDir := t.TempDir() // Mock ip command for fallback snapshots and link control oscmd.SetMock("ip", func(args []string) oscmd.MockCommand { argStr := strings.Join(args, " ") if strings.Contains(argStr, "addr show") { out := `[{"ifname": "eth0", "operstate": "UP", "address": "aa:bb:cc:dd:ee:ff", "mtu": 1500, "addr_info": [{"family": "inet", "local": "192.168.1.30", "prefixlen": 24}]}]` return oscmd.MockCommand{Stdout: out, ExitCode: 0} } if strings.Contains(argStr, "route show") { out := `[{"dst": "default", "gateway": "192.168.1.1", "dev": "eth0"}]` return oscmd.MockCommand{Stdout: out, ExitCode: 0} } if strings.Contains(argStr, "link set") { return oscmd.MockCommand{ExitCode: 0} } return oscmd.MockCommand{ExitCode: 1} }) defer oscmd.ClearMocks() // 1. Test nmcliBackend nm := &nmcliBackend{} oscmd.SetMock("nmcli", func(args []string) oscmd.MockCommand { argStr := strings.Join(args, " ") t.Logf("nmcli mock called with: %s", argStr) if strings.Contains(argStr, "con show --active") { return oscmd.MockCommand{Stdout: "myconn:eth0\n", ExitCode: 0} } if strings.Contains(argStr, "con show") && strings.Contains(argStr, "myconn") { showOut := "ipv4.method:manual\nipv4.addresses:192.168.1.10/24\nipv4.gateway:192.168.1.1\nipv4.dns:1.1.1.1\nipv4.routes:10.0.0.0/24 192.168.1.1\n" return oscmd.MockCommand{Stdout: showOut, ExitCode: 0} } if (strings.Contains(argStr, "con modify") || strings.Contains(argStr, "con up") || strings.Contains(argStr, "con down")) && strings.Contains(argStr, "myconn") { return oscmd.MockCommand{ExitCode: 0} } return oscmd.MockCommand{ExitCode: 1} }) cfg, err := nm.Snapshot(t.Context(), "eth0") if err != nil { t.Fatal(err) } if cfg.Address != "192.168.1.10" || cfg.Gateway != "192.168.1.1" { t.Errorf("nmcli snapshot failed: %+v", cfg) } err = nm.Apply(t.Context(), "eth0", IfaceConfig{ Method: "static", Address: "192.168.1.20", Prefix: 24, Gateway: "192.168.1.1", }) if err != nil { t.Fatal(err) } if err := nm.SetLinkUp(t.Context(), "eth0"); err != nil { t.Fatal(err) } if err := nm.SetLinkDown(t.Context(), "eth0"); err != nil { t.Fatal(err) } // 2. Test networkdBackend nd := &networkdBackend{} oldNdDir := networkdDir networkdDir = tempDir defer func() { networkdDir = oldNdDir }() oscmd.SetMock("networkctl", func(args []string) oscmd.MockCommand { return oscmd.MockCommand{ExitCode: 0} }) err = nd.Apply(t.Context(), "eth0", IfaceConfig{ Method: "static", Address: "192.168.1.30", Prefix: 24, Gateway: "192.168.1.1", DNS: []string{"8.8.8.8"}, }) if err != nil { t.Fatal(err) } cfg, err = nd.Snapshot(t.Context(), "eth0") if err != nil { t.Fatal(err) } if cfg.Address != "192.168.1.30" || cfg.Method != "static" { t.Errorf("networkd snapshot failed: %+v", cfg) } if err := nd.SetLinkUp(t.Context(), "eth0"); err != nil { t.Fatal(err) } if err := nd.SetLinkDown(t.Context(), "eth0"); err != nil { t.Fatal(err) } // 3. Test ifupdownBackend iu := &ifupdownBackend{} oldIfaceFile := interfacesFile oldInterfacesDDir := interfacesDDir interfacesFile = filepath.Join(tempDir, "interfaces") interfacesDDir = filepath.Join(tempDir, "interfaces.d") defer func() { interfacesFile = oldIfaceFile interfacesDDir = oldInterfacesDDir }() if err := os.MkdirAll(interfacesDDir, 0755); err != nil { t.Fatal(err) } if err := os.WriteFile(interfacesFile, []byte("auto lo\niface lo inet loopback\n"), 0644); err != nil { t.Fatal(err) } oscmd.SetMock("ifup", func(args []string) oscmd.MockCommand { return oscmd.MockCommand{ExitCode: 0} }) oscmd.SetMock("ifdown", func(args []string) oscmd.MockCommand { return oscmd.MockCommand{ExitCode: 0} }) err = iu.Apply(t.Context(), "eth0", IfaceConfig{ Method: "static", Address: "192.168.1.40", Prefix: 24, Gateway: "192.168.1.1", DNS: []string{"1.1.1.1"}, }) if err != nil { t.Fatal(err) } cfg, err = iu.Snapshot(t.Context(), "eth0") if err != nil { t.Fatal(err) } if cfg.Address != "192.168.1.40" || cfg.Method != "static" { t.Errorf("ifupdown snapshot failed: %+v", cfg) } if err := iu.SetLinkUp(t.Context(), "eth0"); err != nil { t.Fatal(err) } if err := iu.SetLinkDown(t.Context(), "eth0"); err != nil { t.Fatal(err) } } func TestGetInterfaceConfigAugment(t *testing.T) { mux := http.NewServeMux() api := humatest.Wrap(t, humago.New(mux, huma.DefaultConfig("Test", "1.0.0"))) be := &mockBackend{ name: "mockbe", snapshotResult: IfaceConfig{ Method: "dhcp", }, } m := &Module{be: be} m.Register(api) tempResolv := filepath.Join(t.TempDir(), "resolv.conf") if err := os.WriteFile(tempResolv, []byte("nameserver 1.1.1.1\nnameserver 8.8.8.8\n"), 0644); err != nil { t.Fatal(err) } oldResolv := resolvConf resolvConf = tempResolv defer func() { resolvConf = oldResolv }() oscmd.SetMock("ip", func(args []string) oscmd.MockCommand { argStr := strings.Join(args, " ") if strings.Contains(argStr, "addr show") { out := `[{"ifname": "eth0", "operstate": "UP", "address": "aa:bb:cc:dd:ee:ff", "mtu": 1500, "addr_info": [{"family": "inet", "local": "192.168.1.10", "prefixlen": 24}, {"family": "inet6", "local": "2001:db8::10", "prefixlen": 64}]}]` return oscmd.MockCommand{Stdout: out, ExitCode: 0} } if strings.Contains(argStr, "route show") && !strings.Contains(argStr, "-6") { out := `[{"dst": "default", "gateway": "192.168.1.1", "dev": "eth0"}]` return oscmd.MockCommand{Stdout: out, ExitCode: 0} } if strings.Contains(argStr, "-6") && strings.Contains(argStr, "route show") { out := `[{"dst": "default", "gateway": "2001:db8::1", "dev": "eth0"}]` return oscmd.MockCommand{Stdout: out, ExitCode: 0} } return oscmd.MockCommand{ExitCode: 1} }) defer oscmd.ClearMocks() resp := api.Get("/api/networking/interfaces/eth0") if resp.Code != http.StatusOK { t.Errorf("get interface: got %d, want %d", resp.Code, http.StatusOK) } var ifaceRes GetInterfaceConfigOutput if err := json.Unmarshal(resp.Body.Bytes(), &ifaceRes.Body); err != nil { t.Fatal(err) } if ifaceRes.Body.Method != "dhcp" { t.Errorf("expected Method to be dhcp, got %s", ifaceRes.Body.Method) } if ifaceRes.Body.Address != "192.168.1.10" || ifaceRes.Body.Prefix != 24 { t.Errorf("expected augmented Address 192.168.1.10/24, got %s/%d", ifaceRes.Body.Address, ifaceRes.Body.Prefix) } if ifaceRes.Body.Gateway != "192.168.1.1" { t.Errorf("expected augmented Gateway 192.168.1.1, got %s", ifaceRes.Body.Gateway) } if len(ifaceRes.Body.DNS) != 2 || ifaceRes.Body.DNS[0] != "1.1.1.1" || ifaceRes.Body.DNS[1] != "8.8.8.8" { t.Errorf("expected augmented DNS [1.1.1.1, 8.8.8.8], got %v", ifaceRes.Body.DNS) } if ifaceRes.Body.IPv6 == nil || ifaceRes.Body.IPv6.Method != "auto" || ifaceRes.Body.IPv6.Address != "2001:db8::10" || ifaceRes.Body.IPv6.Prefix != 64 || ifaceRes.Body.IPv6.Gateway != "2001:db8::1" { t.Errorf("expected augmented IPv6, got %+v", ifaceRes.Body.IPv6) } }