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 }