Files
2026-06-24 17:29:45 +02:00

365 lines
10 KiB
Go

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("DNS config lookup 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)
}
}
}