You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

647 lines
22 KiB

// Package networkconfig owns validation and the narrow privileged boundary
// used to apply the product's dual-Ethernet configuration.
package networkconfig
import (
"context"
"errors"
"fmt"
"net/netip"
"sort"
"strings"
"sync"
"time"
)
const (
RoleInternal = "internal"
RoleExternal = "external"
OperatingModeSingle = "single"
OperatingModeOOB = "oob"
AdvancedModeSingle = "single"
AdvancedModeBonding = "bonding"
AdvancedModeBridge = "bridge"
BondDevice = "bond0"
BridgeDevice = "br0"
AddressModeStatic = "static"
AddressModeDHCP = "dhcp"
IPv6ModeDisabled = "disabled"
IPv6ModeAuto = "auto"
IPv6ModeStatic = "static"
RoutingModeActive = OperatingModeSingle
DefaultRollbackSecond = 120
Interface1Name = "Interface-1"
Interface1Device = "eth0"
Interface2Name = "Interface-2"
Interface2Device = "eth1"
)
type Interface struct {
Name string `json:"name"`
Device string `json:"device"`
Role string `json:"role"`
AddressMode string `json:"address_mode"`
Address string `json:"address,omitempty"`
Gateway string `json:"gateway,omitempty"`
DNSServers []string `json:"dns_servers,omitempty"`
IPv6Mode string `json:"ipv6_mode"`
IPv6Address string `json:"ipv6_address,omitempty"`
IPv6Gateway string `json:"ipv6_gateway,omitempty"`
IPv6DNSServers []string `json:"ipv6_dns_servers,omitempty"`
Weight int `json:"weight"`
}
type AdvancedConfig struct {
Mode string `json:"mode"`
BondMode string `json:"bond_mode"`
BondMii int `json:"bond_mii"`
BridgeSTP bool `json:"bridge_stp"`
}
type DDNSConfig struct {
Enabled bool `json:"enabled"`
Provider string `json:"provider"`
IPv4Enabled bool `json:"ipv4_enabled"`
IPv6Enabled bool `json:"ipv6_enabled"`
Username string `json:"username"`
Password string `json:"password,omitempty"`
IPv4Domain string `json:"ipv4_domain"`
IPv6Domain string `json:"ipv6_domain"`
CheckIPURL string `json:"check_ip_url"`
CustomProvider string `json:"custom_provider"`
CustomPath string `json:"custom_path"`
}
type Config struct {
HostName string `json:"host_name"`
OperatingMode string `json:"operating_mode"`
Advanced AdvancedConfig `json:"advanced"`
DDNS DDNSConfig `json:"ddns"`
OOBPrimaryInterface string `json:"oob_primary_interface,omitempty"`
OOBFallbackInterface string `json:"oob_fallback_interface,omitempty"`
OOBHealthCheckTarget string `json:"oob_health_check_target,omitempty"`
OOBHealthCheckSecond int `json:"oob_health_check_interval_seconds,omitempty"`
OOBFailureThreshold int `json:"oob_failure_threshold,omitempty"`
RoutingMode string `json:"routing_mode"`
RollbackTimeoutSecond int `json:"rollback_timeout_seconds"`
Interfaces []Interface `json:"interfaces"`
}
type ApplyResult struct {
TransactionID string `json:"transaction_id"`
State string `json:"state"`
Deadline time.Time `json:"deadline"`
}
type ActualInterface struct {
Device string `json:"device"`
Link string `json:"link"`
Speed string `json:"speed,omitempty"`
Addresses []string `json:"addresses"`
}
type Status struct {
State string `json:"state"`
TransactionID string `json:"transaction_id,omitempty"`
Deadline *time.Time `json:"deadline,omitempty"`
Current *Config `json:"current,omitempty"`
RoutingMode string `json:"routing_mode"`
DefaultRoute string `json:"default_route,omitempty"`
OOB OOBRuntimeStatus `json:"oob"`
Interfaces []ActualInterface `json:"interfaces"`
}
type OOBRuntimeStatus struct {
PrimaryInterface string `json:"primary_interface"`
FallbackInterface string `json:"fallback_interface"`
PrimaryLink string `json:"primary_link"`
FallbackAvailable bool `json:"fallback_available"`
FallbackActive bool `json:"fallback_active"`
FailoverReason string `json:"failover_reason"`
LastFailoverAt *time.Time `json:"last_failover_at,omitempty"`
}
type Controller interface {
Apply(context.Context, Config) (ApplyResult, error)
Confirm(context.Context, string) (Status, error)
Rollback(context.Context, string) (Status, error)
Status(context.Context) (Status, error)
}
func Normalize(config Config) (Config, error) {
config.HostName = strings.ToLower(strings.TrimSpace(config.HostName))
if config.HostName == "" {
config.HostName = "iconsole-server"
}
if !validHostName(config.HostName) {
return Config{}, errors.New("host_name must be a valid host label")
}
config.OperatingMode = strings.ToLower(strings.TrimSpace(config.OperatingMode))
if config.OperatingMode == "" {
config.OperatingMode = OperatingModeSingle
}
if config.OperatingMode != OperatingModeSingle && config.OperatingMode != OperatingModeOOB {
return Config{}, errors.New("operating_mode must be single or oob")
}
config.RoutingMode = config.OperatingMode
advMode := strings.ToLower(strings.TrimSpace(config.Advanced.Mode))
if advMode == "" || advMode == "single" {
advMode = "single"
} else if advMode == "bond" || advMode == "bonding" {
advMode = "bonding"
} else if advMode == "bridge" {
advMode = "bridge"
} else {
return Config{}, errors.New("advance mode must be single, bonding, or bridge")
}
bondMode := strings.ToLower(strings.TrimSpace(config.Advanced.BondMode))
if bondMode == "" || bondMode == "backup" || bondMode == "active-backup" {
bondMode = "active-backup"
} else if bondMode == "balance" || bondMode == "balance-rr" {
bondMode = "balance-rr"
} else {
return Config{}, errors.New("bond mode must be balance-rr or active-backup")
}
bondMii := config.Advanced.BondMii
if bondMii <= 0 {
bondMii = 50
}
config.Advanced = AdvancedConfig{
Mode: advMode,
BondMode: bondMode,
BondMii: bondMii,
BridgeSTP: config.Advanced.BridgeSTP,
}
if config.OperatingMode == OperatingModeOOB && config.Advanced.Mode != AdvancedModeSingle {
return Config{}, errors.New("oob operating mode requires single advanced network mode")
}
ddnsProvider := strings.ToLower(strings.TrimSpace(config.DDNS.Provider))
if ddnsProvider == "" {
ddnsProvider = "dnspod"
}
config.DDNS = DDNSConfig{
Enabled: config.DDNS.Enabled,
Provider: ddnsProvider,
IPv4Enabled: config.DDNS.IPv4Enabled,
IPv6Enabled: config.DDNS.IPv6Enabled,
Username: strings.TrimSpace(config.DDNS.Username),
Password: strings.TrimSpace(config.DDNS.Password),
IPv4Domain: strings.TrimSpace(config.DDNS.IPv4Domain),
IPv6Domain: strings.TrimSpace(config.DDNS.IPv6Domain),
CheckIPURL: strings.TrimSpace(config.DDNS.CheckIPURL),
CustomProvider: strings.TrimSpace(config.DDNS.CustomProvider),
CustomPath: strings.TrimSpace(config.DDNS.CustomPath),
}
// The product has two fixed management uplinks. OOB always prefers
// Interface-1 (eth0) and fails over to Interface-2 (eth1); accepting an
// arbitrary Linux device here previously left the factory default pointing
// at the non-existent wwan0 interface.
config.OOBPrimaryInterface = Interface1Name
config.OOBFallbackInterface = Interface2Name
config.OOBHealthCheckTarget = strings.TrimSpace(config.OOBHealthCheckTarget)
if config.OOBHealthCheckTarget == "" {
config.OOBHealthCheckTarget = "1.1.1.1"
}
if config.OOBHealthCheckSecond == 0 {
config.OOBHealthCheckSecond = 10
}
if config.OOBFailureThreshold == 0 {
config.OOBFailureThreshold = 3
}
if config.OOBHealthCheckSecond < 5 || config.OOBHealthCheckSecond > 300 {
return Config{}, errors.New("oob_health_check_interval_seconds must be between 5 and 300")
}
if config.OOBFailureThreshold < 1 || config.OOBFailureThreshold > 10 {
return Config{}, errors.New("oob_failure_threshold must be between 1 and 10")
}
if target, err := netip.ParseAddr(config.OOBHealthCheckTarget); err != nil || !target.IsValid() || target.IsUnspecified() || target.IsMulticast() {
return Config{}, errors.New("oob_health_check_target must be a usable IP address")
}
if config.RollbackTimeoutSecond == 0 {
config.RollbackTimeoutSecond = DefaultRollbackSecond
}
if config.RollbackTimeoutSecond < 30 || config.RollbackTimeoutSecond > 600 {
return Config{}, errors.New("rollback_timeout_seconds must be between 30 and 600")
}
if len(config.Interfaces) != 2 {
return Config{}, errors.New("network configuration must contain eth0 and eth1")
}
normalized := Config{
HostName: config.HostName,
OperatingMode: config.OperatingMode,
Advanced: config.Advanced,
DDNS: config.DDNS,
OOBPrimaryInterface: config.OOBPrimaryInterface,
OOBFallbackInterface: config.OOBFallbackInterface,
OOBHealthCheckTarget: config.OOBHealthCheckTarget,
OOBHealthCheckSecond: config.OOBHealthCheckSecond,
OOBFailureThreshold: config.OOBFailureThreshold,
RoutingMode: config.OperatingMode,
RollbackTimeoutSecond: config.RollbackTimeoutSecond,
Interfaces: make([]Interface, 0, 2),
}
seenDevices := make(map[string]bool, 2)
seenNames := make(map[string]bool, 2)
prefixes := make([]netip.Prefix, 0, 2)
addresses := make(map[netip.Addr]bool, 2)
for _, item := range config.Interfaces {
item.Name = strings.TrimSpace(item.Name)
item.Device = strings.ToLower(strings.TrimSpace(item.Device))
item.Role = strings.ToLower(strings.TrimSpace(item.Role))
item.AddressMode = strings.ToLower(strings.TrimSpace(item.AddressMode))
item.IPv6Mode = strings.ToLower(strings.TrimSpace(item.IPv6Mode))
item.Address = strings.TrimSpace(item.Address)
item.Gateway = strings.TrimSpace(item.Gateway)
item.IPv6Address = strings.TrimSpace(item.IPv6Address)
item.IPv6Gateway = strings.TrimSpace(item.IPv6Gateway)
if config.OperatingMode == OperatingModeOOB {
item.Role = RoleExternal
}
if item.Name == "" || seenNames[item.Name] {
return Config{}, errors.New("interface names must be present and unique")
}
expectedDevice := ""
switch item.Name {
case Interface1Name:
expectedDevice = Interface1Device
case Interface2Name:
expectedDevice = Interface2Device
default:
return Config{}, fmt.Errorf("interface name must be %s or %s", Interface1Name, Interface2Name)
}
if item.Device != expectedDevice {
return Config{}, fmt.Errorf("%s must use %s", item.Name, expectedDevice)
}
if item.Device != "eth0" && item.Device != "eth1" {
return Config{}, errors.New("device must be eth0 or eth1")
}
if seenDevices[item.Device] {
return Config{}, fmt.Errorf("device %s is configured more than once", item.Device)
}
if item.Role != RoleInternal && item.Role != RoleExternal {
return Config{}, fmt.Errorf("%s role must be internal or external", item.Name)
}
if item.AddressMode == "" {
item.AddressMode = AddressModeStatic
}
if item.AddressMode != AddressModeStatic && item.AddressMode != AddressModeDHCP {
return Config{}, fmt.Errorf("%s address_mode must be static or dhcp", item.Name)
}
if item.AddressMode == AddressModeStatic {
prefix, err := netip.ParsePrefix(item.Address)
if err != nil || !prefix.Addr().Is4() || !prefix.Addr().IsValid() || prefix.Addr().IsUnspecified() || prefix.Addr().IsMulticast() {
return Config{}, fmt.Errorf("%s address must be a usable IPv4 CIDR", item.Name)
}
hostAddress := prefix.Addr()
networkPrefix := prefix.Masked()
if addresses[hostAddress] {
return Config{}, errors.New("interface addresses must be unique")
}
for _, existing := range prefixes {
if existing.Contains(hostAddress) || networkPrefix.Contains(existing.Addr()) {
return Config{}, errors.New("interface subnets must not overlap in routed mode")
}
}
if item.Role == RoleExternal && item.Gateway == "" {
return Config{}, fmt.Errorf("%s external role requires a gateway", item.Name)
}
if item.Gateway != "" {
gateway, err := netip.ParseAddr(item.Gateway)
if err != nil || !gateway.Is4() || !gateway.IsValid() || !networkPrefix.Contains(gateway) || gateway == hostAddress {
return Config{}, fmt.Errorf("%s gateway must be a usable address inside its interface subnet", item.Name)
}
}
item.Address = hostAddress.String() + fmt.Sprintf("/%d", prefix.Bits())
addresses[hostAddress] = true
prefixes = append(prefixes, networkPrefix)
} else {
item.Address = ""
item.Gateway = ""
}
var err error
if item.DNSServers, err = normalizeDNSServers(item.DNSServers, true); err != nil {
return Config{}, fmt.Errorf("%s dns_servers: %w", item.Name, err)
}
if item.IPv6Mode == "" {
item.IPv6Mode = IPv6ModeAuto
}
if item.IPv6Mode != IPv6ModeDisabled && item.IPv6Mode != IPv6ModeAuto && item.IPv6Mode != IPv6ModeStatic {
return Config{}, fmt.Errorf("%s ipv6_mode must be disabled, auto, or static", item.Name)
}
if item.IPv6Mode == IPv6ModeStatic {
prefix, err := netip.ParsePrefix(item.IPv6Address)
if err != nil || !prefix.Addr().Is6() || prefix.Addr().IsUnspecified() || prefix.Addr().IsMulticast() {
return Config{}, fmt.Errorf("%s ipv6_address must be a usable IPv6 CIDR", item.Name)
}
item.IPv6Address = prefix.Addr().String() + fmt.Sprintf("/%d", prefix.Bits())
if item.IPv6Gateway != "" {
gateway, err := netip.ParseAddr(item.IPv6Gateway)
if err != nil || !gateway.Is6() || gateway.IsUnspecified() || gateway.IsMulticast() {
return Config{}, fmt.Errorf("%s ipv6_gateway must be a usable IPv6 address", item.Name)
}
}
} else if item.IPv6Mode == IPv6ModeDisabled {
item.IPv6Address = ""
item.IPv6Gateway = ""
}
if item.IPv6DNSServers, err = normalizeDNSServers(item.IPv6DNSServers, false); err != nil {
return Config{}, fmt.Errorf("%s ipv6_dns_servers: %w", item.Name, err)
}
if item.Weight == 0 {
item.Weight = 1
}
if item.Weight < 1 || item.Weight > 16 {
return Config{}, fmt.Errorf("%s weight must be between 1 and 16", item.Name)
}
seenNames[item.Name] = true
seenDevices[item.Device] = true
normalized.Interfaces = append(normalized.Interfaces, item)
}
if !seenDevices["eth0"] || !seenDevices["eth1"] {
return Config{}, errors.New("network configuration must contain eth0 and eth1")
}
primary, ok := configuredInterface(normalized, normalized.OOBPrimaryInterface)
if !ok {
return Config{}, errors.New("oob_primary_interface must name a configured interface")
}
fallback, ok := configuredInterface(normalized, normalized.OOBFallbackInterface)
if !ok {
return Config{}, errors.New("oob_fallback_interface must name a configured interface")
}
if normalized.OperatingMode == OperatingModeOOB {
if primary.Device != Interface1Device || fallback.Device != Interface2Device {
return Config{}, errors.New("oob mode requires eth0 as primary and eth1 as fallback")
}
if primary.Role != RoleExternal || fallback.Role != RoleExternal {
return Config{}, errors.New("oob mode requires eth0 and eth1 to use the external role")
}
if primary.AddressMode != AddressModeStatic || fallback.AddressMode != AddressModeStatic {
return Config{}, errors.New("oob mode requires static IPv4 configuration on eth0 and eth1")
}
if primary.Gateway == "" || fallback.Gateway == "" {
return Config{}, errors.New("oob mode requires gateways on eth0 and eth1")
}
}
externalCount := 0
dhcpExternal := false
for _, item := range normalized.Interfaces {
if item.Role == RoleExternal {
externalCount++
dhcpExternal = dhcpExternal || item.AddressMode == AddressModeDHCP
}
}
if normalized.OperatingMode == OperatingModeSingle && externalCount > 1 && dhcpExternal {
return Config{}, errors.New("multiple external interfaces require static IPv4 gateways")
}
sort.Slice(normalized.Interfaces, func(i, j int) bool {
return normalized.Interfaces[i].Device < normalized.Interfaces[j].Device
})
return normalized, nil
}
func validLinuxInterfaceName(value string) bool {
if len(value) < 1 || len(value) > 15 || value == "." || value == ".." {
return false
}
for _, char := range value {
if (char >= 'a' && char <= 'z') || (char >= 'A' && char <= 'Z') || (char >= '0' && char <= '9') || char == '_' || char == '-' || char == '.' {
continue
}
return false
}
return true
}
func validHostName(value string) bool {
if len(value) < 1 || len(value) > 63 || value[0] == '-' || value[len(value)-1] == '-' {
return false
}
for _, char := range value {
if (char >= 'a' && char <= 'z') || (char >= '0' && char <= '9') || char == '-' {
continue
}
return false
}
return true
}
func normalizeDNSServers(values []string, ipv4 bool) ([]string, error) {
if len(values) > 2 {
return nil, errors.New("must contain at most two addresses")
}
result := make([]string, 0, len(values))
seen := make(map[string]bool, len(values))
for _, value := range values {
value = strings.TrimSpace(value)
if value == "" {
continue
}
address, err := netip.ParseAddr(value)
if err != nil || address.Is4() != ipv4 || address.IsUnspecified() || address.IsMulticast() {
return nil, errors.New("must contain usable addresses of the requested family")
}
if !seen[address.String()] {
seen[address.String()] = true
result = append(result, address.String())
}
}
return result, nil
}
// RouteFor reports the deterministic egress selected by connected-network
// precedence before the external ECMP default route is considered.
func RouteFor(config Config, destination netip.Addr) ([]string, error) {
normalized, err := Normalize(config)
if err != nil {
return nil, err
}
if !destination.Is4() {
return nil, errors.New("destination must be IPv4")
}
interfaces := effectiveInterfaces(normalized)
for _, item := range interfaces {
if item.AddressMode != AddressModeStatic {
continue
}
prefix, _ := netip.ParsePrefix(item.Address)
if prefix.Contains(destination) {
return []string{item.Device}, nil
}
}
devices := make([]string, 0, 2)
for _, item := range interfaces {
if item.Role == RoleExternal {
devices = append(devices, item.Device)
}
}
return devices, nil
}
// effectiveInterfaces returns the Linux interfaces that own layer-3
// addressing. In bonding and bridge modes the manual specifies that
// Interface-1 supplies the shared IP configuration while eth0 and eth1 act as
// layer-2 member ports.
func effectiveInterfaces(config Config) []Interface {
if config.Advanced.Mode != AdvancedModeBonding && config.Advanced.Mode != AdvancedModeBridge {
return append([]Interface(nil), config.Interfaces...)
}
for _, item := range config.Interfaces {
if item.Name != Interface1Name {
continue
}
item.Device = BondDevice
if config.Advanced.Mode == AdvancedModeBridge {
item.Device = BridgeDevice
}
return []Interface{item}
}
return nil
}
type MockController struct {
mu sync.Mutex
Current Config
Pending *ApplyResult
previous *Config
}
func (c *MockController) Apply(_ context.Context, config Config) (ApplyResult, error) {
c.mu.Lock()
defer c.mu.Unlock()
normalized, err := Normalize(config)
if err != nil {
return ApplyResult{}, err
}
if c.Pending != nil {
return ApplyResult{}, errors.New("a network transaction is already pending")
}
deadline := time.Now().UTC().Add(time.Duration(normalized.RollbackTimeoutSecond) * time.Second)
result := ApplyResult{TransactionID: "network-mock-transaction", State: "pending_confirmation", Deadline: deadline}
previous := c.Current
previous.Interfaces = append([]Interface(nil), c.Current.Interfaces...)
c.previous = &previous
c.Current = normalized
c.Pending = &result
return result, nil
}
func (c *MockController) Confirm(ctx context.Context, transactionID string) (Status, error) {
c.mu.Lock()
defer c.mu.Unlock()
if err := c.checkPending(ctx, transactionID); err != nil {
return Status{}, err
}
c.Pending = nil
c.previous = nil
return c.statusLocked(), nil
}
func (c *MockController) Rollback(ctx context.Context, transactionID string) (Status, error) {
c.mu.Lock()
defer c.mu.Unlock()
if err := c.checkPending(ctx, transactionID); err != nil {
return Status{}, err
}
c.Pending = nil
if c.previous != nil {
c.Current = *c.previous
}
c.previous = nil
status := c.statusLocked()
status.State = "rolled_back"
return status, nil
}
func (c *MockController) checkPending(ctx context.Context, transactionID string) error {
if err := ctx.Err(); err != nil {
return err
}
if c == nil || c.Pending == nil || transactionID == "" || c.Pending.TransactionID != transactionID {
return errors.New("network transaction not found")
}
return nil
}
func (c *MockController) Status(context.Context) (Status, error) {
c.mu.Lock()
defer c.mu.Unlock()
return c.statusLocked(), nil
}
func (c *MockController) statusLocked() Status {
status := Status{State: "idle", RoutingMode: OperatingModeSingle}
if c != nil && c.Current.OperatingMode != "" {
current := c.Current
current.Interfaces = append([]Interface(nil), c.Current.Interfaces...)
status.Current = &current
status.RoutingMode = c.Current.OperatingMode
status.OOB = OOBRuntimeStatus{
PrimaryInterface: c.Current.OOBPrimaryInterface, FallbackInterface: c.Current.OOBFallbackInterface,
PrimaryLink: "up", FailoverReason: "none",
}
}
if c != nil && c.Pending != nil {
deadline := c.Pending.Deadline
status.State = c.Pending.State
status.TransactionID = c.Pending.TransactionID
status.Deadline = &deadline
}
return status
}
type UnavailableController struct{ Reason string }
func (c UnavailableController) unavailable() error {
reason := strings.TrimSpace(c.Reason)
if reason == "" {
reason = "tera-netd is not configured"
}
return &UnavailableError{Reason: reason}
}
func (c UnavailableController) Apply(context.Context, Config) (ApplyResult, error) {
return ApplyResult{}, c.unavailable()
}
func (c UnavailableController) Confirm(context.Context, string) (Status, error) {
return Status{}, c.unavailable()
}
func (c UnavailableController) Rollback(context.Context, string) (Status, error) {
return Status{}, c.unavailable()
}
func (c UnavailableController) Status(context.Context) (Status, error) {
return Status{}, c.unavailable()
}
type UnavailableError struct{ Reason string }
func (e *UnavailableError) Error() string { return e.Reason }
func IsUnavailable(err error) bool {
var unavailable *UnavailableError
return errors.As(err, &unavailable)
}
type FactoryResetter interface {
FactoryReset(context.Context, Config) error
}
func (c *MockController) FactoryReset(ctx context.Context, config Config) error {
if err := ctx.Err(); err != nil {
return err
}
normalized, err := Normalize(config)
if err != nil {
return err
}
c.mu.Lock()
defer c.mu.Unlock()
c.Current = normalized
c.Pending = nil
c.previous = nil
return nil
}