134 lines
5.5 KiB
Go
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
|
|
}
|