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.
646 lines
22 KiB
646 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 = ¤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)
|
|
}
|
|
|
|
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
|
|
}
|
|
|