diff --git a/internal/modules/networking/networking_handler_test.go b/internal/modules/networking/networking_handler_test.go index 89ba808..733795d 100644 --- a/internal/modules/networking/networking_handler_test.go +++ b/internal/modules/networking/networking_handler_test.go @@ -412,3 +412,70 @@ func TestBackendImplementations(t *testing.T) { 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) + } +} + diff --git a/internal/modules/networking/nmcli.go b/internal/modules/networking/nmcli.go index 0b5c903..97c177f 100644 --- a/internal/modules/networking/nmcli.go +++ b/internal/modules/networking/nmcli.go @@ -46,11 +46,19 @@ func (b *nmcliBackend) Snapshot(ctx context.Context, iface string) (IfaceConfig, return IfaceConfig{Method: "dhcp"}, nil } + // nmcli's `con show ` parser does NOT honor `--` as an end-of-options + // separator; passing it makes nmcli look for a connection literally named + // "--" and fail. `conn` comes from nmcli's own active-connections list (see + // connForIface), so it's already validated — no shell-metacharacter risk. + // Same applies to con up / con down / con modify below. out, err := oscmd.RunContext(ctx, "nmcli", "-t", "-f", "ipv4.method,ipv4.addresses,ipv4.gateway,ipv4.dns,ipv4.routes,ipv6.method,ipv6.addresses,ipv6.gateway", - "con", "show", "--", conn) + "con", "show", conn) if err != nil { - return IfaceConfig{}, fmt.Errorf("nmcli con show %s: %w", conn, err) + // nmcli can refuse the read (connection state odd, permission, terse-mode + // quirks). Fall back to DHCP defaults so the prefill endpoint still + // returns a usable form, mirroring the networkd / ifupdown fallback. + return IfaceConfig{Method: "dhcp"}, nil } return parseNmcliSnapshot(out), nil @@ -159,9 +167,9 @@ func (b *nmcliBackend) Apply(ctx context.Context, iface string, cfg IfaceConfig) return fmt.Errorf("cannot apply: %w", err) } - // Build the nmcli con modify arguments. Note: conn is safe to place after - // -- since it comes from nmcli output, not directly from the user. - args := []string{"con", "modify", "--", conn} + // conn comes from nmcli's own active list (connForIface), not user input. + // nmcli's con subcommands don't honor "--" as an end-of-options separator. + args := []string{"con", "modify", conn} switch cfg.Method { case "static": @@ -222,7 +230,7 @@ func (b *nmcliBackend) Apply(ctx context.Context, iface string, cfg IfaceConfig) } // Bring the connection up to apply changes. - if _, err := oscmd.RunContext(ctx, "nmcli", "con", "up", "--", conn); err != nil { + if _, err := oscmd.RunContext(ctx, "nmcli", "con", "up", conn); err != nil { return fmt.Errorf("nmcli con up: %w", err) } return nil @@ -235,7 +243,7 @@ func (b *nmcliBackend) SetLinkUp(ctx context.Context, iface string) error { _, err = oscmd.RunContext(ctx, "ip", "link", "set", "--", iface, "up") return err } - _, err = oscmd.RunContext(ctx, "nmcli", "con", "up", "--", conn) + _, err = oscmd.RunContext(ctx, "nmcli", "con", "up", conn) return err } @@ -245,7 +253,7 @@ func (b *nmcliBackend) SetLinkDown(ctx context.Context, iface string) error { _, err = oscmd.RunContext(ctx, "ip", "link", "set", "--", iface, "down") return err } - _, err = oscmd.RunContext(ctx, "nmcli", "con", "down", "--", conn) + _, err = oscmd.RunContext(ctx, "nmcli", "con", "down", conn) return err } diff --git a/internal/modules/networking/read.go b/internal/modules/networking/read.go index 8f1d33d..d8a8c5a 100644 --- a/internal/modules/networking/read.go +++ b/internal/modules/networking/read.go @@ -3,6 +3,8 @@ package networking import ( "context" "encoding/json" + "fmt" + "net/netip" "os" "strconv" "strings" @@ -90,6 +92,7 @@ func registerReads(api huma.API, m *Module) { if err != nil { return nil, huma.Error500InternalServerError("snapshot failed", err) } + augmentWithLiveState(ctx, in.Name, &cfg) return &GetInterfaceConfigOutput{Body: cfg}, nil }) @@ -249,3 +252,113 @@ func parseResolv(text string) []string { } return servers } + +func getLiveInterface(ctx context.Context, iface string) (*Interface, error) { + out, err := oscmd.RunContext(ctx, "ip", "-j", "addr", "show", "--", iface) + if err != nil { + out, err = oscmd.RunContext(ctx, "ip", "-j", "addr") + if err != nil { + return nil, err + } + } + ifaces, err := parseInterfaces(out) + if err != nil { + return nil, err + } + for i := range ifaces { + if ifaces[i].Name == iface { + return &ifaces[i], nil + } + } + return nil, fmt.Errorf("interface %s not found in ip addr output", iface) +} + +func getLiveGateway(ctx context.Context, iface string) string { + routeOut, err := oscmd.RunContext(ctx, "ip", "-j", "route", "show", "dev", "--", iface) + if err != nil { + routeOut, err = oscmd.RunContext(ctx, "ip", "-j", "route") + if err != nil { + return "" + } + } + routes, err := parseRoutes(routeOut) + if err != nil { + return "" + } + for _, r := range routes { + if r.Destination == "default" && (r.Interface == iface || r.Interface == "") && r.Gateway != "" { + return r.Gateway + } + } + return "" +} + +func getLiveIPv6Gateway(ctx context.Context, iface string) string { + routeOut, err := oscmd.RunContext(ctx, "ip", "-6", "-j", "route", "show", "dev", "--", iface) + if err != nil { + routeOut, err = oscmd.RunContext(ctx, "ip", "-6", "-j", "route") + if err != nil { + return "" + } + } + routes, err := parseRoutes(routeOut) + if err != nil { + return "" + } + for _, r := range routes { + if r.Destination == "default" && (r.Interface == iface || r.Interface == "") && r.Gateway != "" { + return r.Gateway + } + } + return "" +} + +func augmentWithLiveState(ctx context.Context, iface string, cfg *IfaceConfig) { + liveIface, err := getLiveInterface(ctx, iface) + if err != nil { + return + } + + // Prefill IPv4 address and prefix if empty + if cfg.Address == "" && len(liveIface.IPv4) > 0 { + addr, prefix := splitCIDR(liveIface.IPv4[0]) + if addr != "" { + cfg.Address = addr + cfg.Prefix = prefix + } + } + + // Prefill Gateway if empty + if cfg.Gateway == "" { + cfg.Gateway = getLiveGateway(ctx, iface) + } + + // Prefill DNS if empty + if len(cfg.DNS) == 0 { + if data, err := os.ReadFile(resolvConf); err == nil { + cfg.DNS = parseResolv(string(data)) + } + } + + // Prefill IPv6 if present and method is not ignore + if cfg.IPv6 == nil { + cfg.IPv6 = &IPv6Config{Method: "auto"} + } + if cfg.IPv6.Method != "ignore" { + // Capture first global IPv6 if address is empty + if cfg.IPv6.Address == "" { + for _, c := range liveIface.IPv6 { + addr, prefix := splitCIDR(c) + if ip, err := netip.ParseAddr(addr); err == nil && !ip.IsLinkLocalUnicast() { + cfg.IPv6.Address = addr + cfg.IPv6.Prefix = prefix + break + } + } + } + // Capture IPv6 default gateway if empty + if cfg.IPv6.Gateway == "" { + cfg.IPv6.Gateway = getLiveIPv6Gateway(ctx, iface) + } + } +}