Files
nadir-agent/internal/modules/networking/command.go
T
2026-06-22 16:06:57 +02:00

134 lines
5.5 KiB
Go

package networking
import (
"net/netip"
"regexp"
"github.com/danielgtaylor/huma/v2"
)
// tagNetworking groups every networking-module operation under one OpenAPI tag,
// keeping tags 1:1 with modules.
const tagNetworking = "Networking"
var (
readErrors = []int{401, 403, 500}
writeErrors = []int{400, 401, 403, 409, 500, 501}
)
// ifaceNameRe matches a Linux interface name. Linux caps names at 15 bytes and
// forbids '/' and whitespace; we additionally reject a leading dash so a name
// can never be read as a flag by `ip`/`nmcli`/`ifup`. Every command line also
// passes user-supplied names after a "--" separator.
var ifaceNameRe = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._@-]{0,14}$`)
func validateIface(name string) error {
if !ifaceNameRe.MatchString(name) {
return huma.Error400BadRequest("invalid interface name: " + name)
}
return nil
}
// IfaceConfig is the declarative desired state for one interface. A PUT replaces
// the interface's IPv4 configuration with this (and IPv6 too, when the IPv6 block
// is included), across whichever backend is in use. Routes are part of the config
// (declarative add/remove): the client sends the full set it wants.
type IfaceConfig struct {
Method string `json:"method" enum:"static,dhcp" example:"static" doc:"\"static\" for a fixed address, \"dhcp\" for automatic"`
Address string `json:"address,omitempty" example:"192.168.1.10" doc:"IPv4 address (static only)"`
Prefix int `json:"prefix,omitempty" minimum:"0" maximum:"32" example:"24" doc:"Network prefix length (static only)"`
Gateway string `json:"gateway,omitempty" example:"192.168.1.1" doc:"Default gateway (static only, optional)"`
IPv6 *IPv6Config `json:"ipv6,omitempty" doc:"Optional IPv6 settings. Omit to leave IPv6 untouched; include to manage it."`
DNS []string `json:"dns,omitempty" example:"[\"1.1.1.1\",\"8.8.8.8\"]" doc:"DNS servers for this interface (IPv4 or IPv6)"`
Routes []Route `json:"routes,omitempty" doc:"Static routes to install for this interface"`
RollbackSeconds int `json:"rollback_seconds,omitempty" minimum:"0" maximum:"3600" example:"60" doc:"Auto-revert after this many seconds unless confirmed. 0 uses the default (60s)."`
}
// IPv6Config is the optional IPv6 settings for an interface. Method "auto" uses
// SLAAC/router advertisements (the usual default), "static" pins an address, and
// "ignore" disables IPv6 on the interface. DHCPv6 is not modeled.
type IPv6Config struct {
Method string `json:"method" enum:"auto,static,ignore" example:"static" doc:"\"auto\" (SLAAC), \"static\", or \"ignore\" (disable IPv6)"`
Address string `json:"address,omitempty" example:"2001:db8::10" doc:"IPv6 address (static only)"`
Prefix int `json:"prefix,omitempty" minimum:"0" maximum:"128" example:"64" doc:"Prefix length (static only)"`
Gateway string `json:"gateway,omitempty" example:"2001:db8::1" doc:"IPv6 default gateway (static only, optional)"`
}
// Route is a single static route. Destination is a CIDR (or \"default\").
type Route struct {
Destination string `json:"destination" example:"10.0.0.0/24" doc:"Destination network in CIDR notation, or \"default\""`
Gateway string `json:"gateway" example:"192.168.1.1" doc:"Next-hop gateway"`
}
// validate checks the desired config independently of the backend, so a bad
// request is a 400 before we touch the system. IP/CIDR parsing uses net/netip.
func (c IfaceConfig) validate() error {
switch c.Method {
case "static":
if c.Address == "" {
return huma.Error400BadRequest("static method requires an address")
}
if _, err := netip.ParseAddr(c.Address); err != nil {
return huma.Error400BadRequest("invalid address: " + c.Address)
}
if c.Prefix < 1 || c.Prefix > 32 {
return huma.Error400BadRequest("prefix must be 1-32")
}
if c.Gateway != "" {
if _, err := netip.ParseAddr(c.Gateway); err != nil {
return huma.Error400BadRequest("invalid gateway: " + c.Gateway)
}
}
case "dhcp":
// address/gateway/prefix are ignored; nothing to validate.
default:
return huma.Error400BadRequest("method must be \"static\" or \"dhcp\"")
}
for _, s := range c.DNS {
if _, err := netip.ParseAddr(s); err != nil {
return huma.Error400BadRequest("invalid DNS server: " + s)
}
}
for _, r := range c.Routes {
if r.Destination != "default" {
if _, err := netip.ParsePrefix(r.Destination); err != nil {
return huma.Error400BadRequest("invalid route destination: " + r.Destination)
}
}
if _, err := netip.ParseAddr(r.Gateway); err != nil {
return huma.Error400BadRequest("invalid route gateway: " + r.Gateway)
}
}
if c.IPv6 != nil {
if err := c.IPv6.validate(); err != nil {
return err
}
}
return nil
}
// validate checks an IPv6 block. Static addresses/gateways must parse as IPv6
// (an IPv4 literal here is a client mistake).
func (c IPv6Config) validate() error {
switch c.Method {
case "auto", "ignore":
// no address fields to validate
case "static":
addr, err := netip.ParseAddr(c.Address)
if err != nil || addr.Is4() {
return huma.Error400BadRequest("ipv6 static requires a valid IPv6 address, got: " + c.Address)
}
if c.Prefix < 1 || c.Prefix > 128 {
return huma.Error400BadRequest("ipv6 prefix must be 1-128")
}
if c.Gateway != "" {
if gw, err := netip.ParseAddr(c.Gateway); err != nil || gw.Is4() {
return huma.Error400BadRequest("invalid ipv6 gateway: " + c.Gateway)
}
}
default:
return huma.Error400BadRequest("ipv6 method must be \"auto\", \"static\", or \"ignore\"")
}
return nil
}