package networking import ( "context" "encoding/json" "fmt" "net/netip" "os" "strconv" "strings" "nadir/internal/oscmd" "github.com/danielgtaylor/huma/v2" ) // Reads go through `ip -j` (JSON) and /etc/resolv.conf, which behave the same // regardless of which backend manages the interfaces - so reads need no backend // detection. var resolvConf = "/etc/resolv.conf" type Interface struct { Name string `json:"name" example:"eth0"` State string `json:"state" example:"up" doc:"operstate: up / down / unknown"` MAC string `json:"mac" example:"52:54:00:12:34:56"` MTU int `json:"mtu" example:"1500"` IPv4 []string `json:"ipv4" example:"[\"192.168.1.10/24\"]" doc:"IPv4 addresses in CIDR form"` IPv6 []string `json:"ipv6" example:"[\"fe80::1/64\"]" doc:"IPv6 addresses in CIDR form"` } type ListInterfacesOutput struct { Body struct { Interfaces []Interface `json:"interfaces"` } } type RouteEntry struct { Destination string `json:"destination" example:"default" doc:"Destination network, or \"default\""` Gateway string `json:"gateway,omitempty" example:"192.168.1.1"` Interface string `json:"interface" example:"eth0"` Source string `json:"source,omitempty" example:"192.168.1.10" doc:"Preferred source address"` Metric int `json:"metric,omitempty" example:"100"` } type ListRoutesOutput struct { Body struct { Routes []RouteEntry `json:"routes"` } } type DNSOutput struct { Body struct { Servers []string `json:"servers" example:"[\"1.1.1.1\"]" doc:"Nameservers from /etc/resolv.conf"` } } // GetInterfaceConfigInput carries the path param; matches the PUT endpoint's // IfacePathInput so the frontend can use the same path for both verbs. type GetInterfaceConfigInput struct { Name string `path:"name" example:"eth0" doc:"Interface name"` } // GetInterfaceConfigOutput returns the same IfaceConfig shape that PUT // accepts, so the form can be pre-filled directly from this response. type GetInterfaceConfigOutput struct { Body IfaceConfig } func registerReads(api huma.API, m *Module) { huma.Register(api, huma.Operation{ OperationID: "networking-get-interface", Method: "GET", Path: "/api/networking/interfaces/{name}", Summary: "Get an interface's current configuration", Description: "Returns the IfaceConfig the backend currently has for this " + "interface (method, address/prefix, gateway, DNS, IPv6). Same schema as " + "PUT /api/networking/interfaces/{name}, so the frontend can prefill an " + "edit form from this response directly. Returns 501 when no backend was " + "detected (nmcli / networkd / ifupdown).", Tags: []string{tagNetworking}, Metadata: op("read"), Errors: readErrors, }, func(ctx context.Context, in *GetInterfaceConfigInput) (*GetInterfaceConfigOutput, error) { if m.be == nil { return nil, huma.Error501NotImplemented("", errNoBackend) } if err := validateIface(in.Name); err != nil { return nil, err } cfg, err := m.be.Snapshot(ctx, in.Name) if err != nil { return nil, huma.Error500InternalServerError("snapshot failed", err) } augmentWithLiveState(ctx, in.Name, &cfg) return &GetInterfaceConfigOutput{Body: cfg}, nil }) huma.Register(api, huma.Operation{ OperationID: "networking-list-interfaces", Method: "GET", Path: "/api/networking/interfaces", Summary: "List network interfaces", Description: "Returns every interface with its state, MAC, MTU and addresses, via `ip -j addr`.", Tags: []string{tagNetworking}, Metadata: op("read"), Errors: readErrors, }, func(ctx context.Context, _ *struct{}) (*ListInterfacesOutput, error) { out, err := oscmd.RunContext(ctx, "ip", "-j", "addr") if err != nil { return nil, huma.Error500InternalServerError("ip addr failed", err) } ifaces, err := parseInterfaces(out) if err != nil { return nil, huma.Error500InternalServerError("parse ip addr failed", err) } res := &ListInterfacesOutput{} res.Body.Interfaces = ifaces return res, nil }) huma.Register(api, huma.Operation{ OperationID: "networking-list-routes", Method: "GET", Path: "/api/networking/routes", Summary: "List the IPv4 route table", Description: "Returns the kernel IPv4 route table via `ip -j route`.", Tags: []string{tagNetworking}, Metadata: op("read"), Errors: readErrors, }, func(ctx context.Context, _ *struct{}) (*ListRoutesOutput, error) { out, err := oscmd.RunContext(ctx, "ip", "-j", "route") if err != nil { return nil, huma.Error500InternalServerError("ip route failed", err) } routes, err := parseRoutes(out) if err != nil { return nil, huma.Error500InternalServerError("parse ip route failed", err) } res := &ListRoutesOutput{} res.Body.Routes = routes return res, nil }) huma.Register(api, huma.Operation{ OperationID: "networking-get-dns", Method: "GET", Path: "/api/networking/dns", Summary: "Get configured DNS servers", Description: "Returns the nameservers listed in /etc/resolv.conf. DNS is set " + "per-interface as part of the interface config (PUT /api/networking/interfaces/{name}), " + "so there is no standalone DNS write endpoint.", Tags: []string{tagNetworking}, Metadata: op("read"), Errors: readErrors, }, func(ctx context.Context, _ *struct{}) (*DNSOutput, error) { data, err := os.ReadFile(resolvConf) if err != nil { return nil, huma.Error500InternalServerError("read resolv.conf failed", err) } res := &DNSOutput{} res.Body.Servers = parseResolv(string(data)) return res, nil }) } // --- parsers (pure, tested) -------------------------------------------------- // ipAddr mirrors the fields we use from one `ip -j addr` element. type ipAddr struct { Name string `json:"ifname"` MAC string `json:"address"` MTU int `json:"mtu"` OperState string `json:"operstate"` AddrInfo []struct { Family string `json:"family"` // "inet" / "inet6" Local string `json:"local"` Prefix int `json:"prefixlen"` } `json:"addr_info"` } func parseInterfaces(jsonOut string) ([]Interface, error) { var raw []ipAddr if err := json.Unmarshal([]byte(jsonOut), &raw); err != nil { return nil, err } ifaces := make([]Interface, 0, len(raw)) for _, r := range raw { iface := Interface{ Name: r.Name, State: strings.ToLower(r.OperState), MAC: r.MAC, MTU: r.MTU, IPv4: []string{}, IPv6: []string{}, } for _, a := range r.AddrInfo { cidr := a.Local + "/" + strconv.Itoa(a.Prefix) if a.Family == "inet6" { iface.IPv6 = append(iface.IPv6, cidr) } else { iface.IPv4 = append(iface.IPv4, cidr) } } ifaces = append(ifaces, iface) } return ifaces, nil } // ipRoute mirrors the fields we use from one `ip -j route` element. type ipRoute struct { Dst string `json:"dst"` Gateway string `json:"gateway"` Dev string `json:"dev"` PrefSrc string `json:"prefsrc"` Metric int `json:"metric"` } func parseRoutes(jsonOut string) ([]RouteEntry, error) { var raw []ipRoute if err := json.Unmarshal([]byte(jsonOut), &raw); err != nil { return nil, err } routes := make([]RouteEntry, 0, len(raw)) for _, r := range raw { routes = append(routes, RouteEntry{ Destination: r.Dst, Gateway: r.Gateway, Interface: r.Dev, Source: r.PrefSrc, Metric: r.Metric, }) } return routes, nil } // parseResolv extracts "nameserver X" entries, ignoring comments and other // directives. func parseResolv(text string) []string { servers := []string{} for line := range strings.SplitSeq(text, "\n") { line = strings.TrimSpace(line) if strings.HasPrefix(line, "#") || strings.HasPrefix(line, ";") { continue } if rest, ok := strings.CutPrefix(line, "nameserver"); ok { if s := strings.TrimSpace(rest); s != "" { servers = append(servers, s) } } } 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) } } }