252 lines
7.7 KiB
Go
252 lines
7.7 KiB
Go
package networking
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"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)
|
|
}
|
|
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
|
|
}
|