// 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 = ¤t 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) }