package networkconfig import ( "context" "crypto/rand" "encoding/hex" "encoding/json" "errors" "fmt" "net" "net/netip" "os" "os/exec" "path/filepath" "reflect" "sort" "strconv" "strings" "sync" "time" "tera-clone/services/tera-api/internal/storage/atomicfile" ) const ( DefaultInterfacesPath = "/etc/network/interfaces" DefaultRoutingPath = "/etc/network/if-up.d/20-tera-active-active" DefaultConfigPath = "/var/lib/tera-network/config.json" DefaultPendingPath = "/var/lib/tera-network/pending.json" DefaultHostnamePath = "/etc/hostname" ) type Runner interface { Run(context.Context, string, ...string) ([]byte, error) } type execRunner struct{} func (execRunner) Run(ctx context.Context, name string, args ...string) ([]byte, error) { return exec.CommandContext(ctx, name, args...).CombinedOutput() } type OSOptions struct { InterfacesPath string RoutingPath string ConfigPath string PendingPath string HostnamePath string HostnameCommandPath string IfdownPath string IfupPath string IPPath string PingPath string ShellPath string SysClassRoot string Runner Runner Now func() time.Time } type fileSnapshot struct { Path string `json:"path"` Exists bool `json:"exists"` Mode os.FileMode `json:"mode"` Content []byte `json:"content,omitempty"` } type pendingTransaction struct { TransactionID string `json:"transaction_id"` Deadline time.Time `json:"deadline"` Before []fileSnapshot `json:"before"` Config Config `json:"config"` } type OSController struct { options OSOptions mu sync.Mutex pending *pendingTransaction timer *time.Timer state string current Config oob OOBRuntimeStatus oobFailures int } func NewOSController(options OSOptions) (*OSController, error) { options = normalizeOSOptions(options) for name, path := range map[string]string{ "interfaces": options.InterfacesPath, "routing": options.RoutingPath, "config": options.ConfigPath, "pending": options.PendingPath, "hostname": options.HostnamePath, "hostname_command": options.HostnameCommandPath, "ifdown": options.IfdownPath, "ifup": options.IfupPath, "ip": options.IPPath, "ping": options.PingPath, "shell": options.ShellPath, "sys_class_root": options.SysClassRoot, } { if path == "" || (!filepath.IsAbs(path) && !strings.HasPrefix(path, "/")) { return nil, fmt.Errorf("%s path must be absolute", name) } } for _, path := range []string{options.ConfigPath, options.PendingPath, options.InterfacesPath, options.RoutingPath, options.HostnamePath} { if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil { return nil, fmt.Errorf("prepare network configuration directory: %w", err) } } return &OSController{options: options, state: "idle"}, nil } func normalizeOSOptions(options OSOptions) OSOptions { if options.InterfacesPath == "" { options.InterfacesPath = DefaultInterfacesPath } if options.RoutingPath == "" { options.RoutingPath = DefaultRoutingPath } if options.ConfigPath == "" { options.ConfigPath = DefaultConfigPath } if options.PendingPath == "" { options.PendingPath = DefaultPendingPath } if options.HostnamePath == "" { options.HostnamePath = DefaultHostnamePath } if options.HostnameCommandPath == "" { options.HostnameCommandPath = "/bin/hostname" } if options.IfdownPath == "" { options.IfdownPath = "/sbin/ifdown" } if options.IfupPath == "" { options.IfupPath = "/sbin/ifup" } if options.IPPath == "" { options.IPPath = "/sbin/ip" } if options.PingPath == "" { options.PingPath = "/bin/ping" } if options.ShellPath == "" { options.ShellPath = "/bin/sh" } if options.SysClassRoot == "" { options.SysClassRoot = "/sys/class/net" } if options.Runner == nil { options.Runner = execRunner{} } if options.Now == nil { options.Now = time.Now } return options } // Recover restores the pre-change files when a daemon restart or device reboot // occurs before the management client confirms a pending transaction. func (c *OSController) Recover(ctx context.Context) error { c.mu.Lock() defer c.mu.Unlock() data, err := os.ReadFile(c.options.PendingPath) if os.IsNotExist(err) { return c.loadCurrentConfigLocked() } if err != nil { return fmt.Errorf("read pending network transaction: %w", err) } var pending pendingTransaction decoder := json.NewDecoder(strings.NewReader(string(data))) decoder.DisallowUnknownFields() if err := decoder.Decode(&pending); err != nil { return fmt.Errorf("decode pending network transaction: %w", err) } c.pending = &pending if err := c.rollbackLocked(ctx, "recovered_after_restart"); err != nil { return fmt.Errorf("recover pending network transaction: %w", err) } return c.loadCurrentConfigLocked() } func (c *OSController) Apply(ctx context.Context, config Config) (ApplyResult, error) { normalized, err := Normalize(config) if err != nil { return ApplyResult{}, err } c.mu.Lock() defer c.mu.Unlock() if err := ctx.Err(); err != nil { return ApplyResult{}, err } if c.pending != nil { return ApplyResult{}, errors.New("a network transaction is already pending confirmation") } before := make([]fileSnapshot, 0, 4) for _, path := range []string{c.options.InterfacesPath, c.options.RoutingPath, c.options.ConfigPath, c.options.HostnamePath} { snapshot, err := snapshotFile(path) if err != nil { return ApplyResult{}, err } before = append(before, snapshot) } transactionID, err := newTransactionID() if err != nil { return ApplyResult{}, err } deadline := c.options.Now().UTC().Add(time.Duration(normalized.RollbackTimeoutSecond) * time.Second) pending := &pendingTransaction{TransactionID: transactionID, Deadline: deadline, Before: before, Config: normalized} if err := c.persistPending(pending); err != nil { return ApplyResult{}, err } c.pending = pending changedDevices := changedNetworkDevices(c.current, normalized) if err := c.writeConfigFiles(normalized); err != nil { rollbackErr := c.rollbackLocked(context.Background(), "write_failed") return ApplyResult{}, combineApplyRollbackError("write network configuration", err, rollbackErr) } if err := c.reconfigure(ctx, normalized, changedDevices); err != nil { rollbackErr := c.rollbackLocked(context.Background(), "apply_failed") return ApplyResult{}, combineApplyRollbackError("apply network configuration", err, rollbackErr) } c.setCurrentConfigLocked(normalized) c.state = "pending_confirmation" c.timer = time.AfterFunc(time.Duration(normalized.RollbackTimeoutSecond)*time.Second, func() { c.rollbackExpired(transactionID) }) return ApplyResult{TransactionID: transactionID, State: c.state, Deadline: deadline}, nil } func (c *OSController) Confirm(ctx context.Context, transactionID string) (Status, error) { c.mu.Lock() defer c.mu.Unlock() if err := c.requirePending(ctx, transactionID); err != nil { return Status{}, err } if c.timer != nil { c.timer.Stop() c.timer = nil } if err := os.Remove(c.options.PendingPath); err != nil && !os.IsNotExist(err) { return Status{}, fmt.Errorf("commit network transaction: %w", err) } c.pending = nil c.state = "confirmed" return c.statusLocked(ctx), nil } func (c *OSController) Rollback(ctx context.Context, transactionID string) (Status, error) { c.mu.Lock() defer c.mu.Unlock() if err := c.requirePending(ctx, transactionID); err != nil { return Status{}, err } if err := c.rollbackLocked(ctx, "operator_requested"); err != nil { return Status{}, err } return c.statusLocked(ctx), nil } func (c *OSController) Status(ctx context.Context) (Status, error) { c.mu.Lock() defer c.mu.Unlock() if err := ctx.Err(); err != nil { return Status{}, err } return c.statusLocked(ctx), nil } func (c *OSController) requirePending(ctx context.Context, transactionID string) error { if err := ctx.Err(); err != nil { return err } if c.pending == nil || transactionID == "" || c.pending.TransactionID != transactionID { return errors.New("network transaction not found") } return nil } func (c *OSController) rollbackExpired(transactionID string) { c.mu.Lock() defer c.mu.Unlock() if c.pending == nil || c.pending.TransactionID != transactionID { return } _ = c.rollbackLocked(context.Background(), "confirmation_timeout") } func (c *OSController) rollbackLocked(ctx context.Context, reason string) error { if c.pending == nil { return errors.New("no network transaction is pending") } if c.timer != nil { c.timer.Stop() c.timer = nil } var restoreErrors []error for _, snapshot := range c.pending.Before { if err := restoreFile(snapshot); err != nil { restoreErrors = append(restoreErrors, err) } } if len(restoreErrors) == 0 { if err := c.reconfigureRestored(ctx); err != nil { restoreErrors = append(restoreErrors, err) } } if len(restoreErrors) > 0 { c.state = "rollback_failed" return errors.Join(restoreErrors...) } if err := os.Remove(c.options.PendingPath); err != nil && !os.IsNotExist(err) { c.state = "rollback_failed" return fmt.Errorf("remove pending network transaction: %w", err) } c.pending = nil c.state = "rolled_back_" + reason if err := c.loadCurrentConfigLocked(); err != nil { return err } return nil } func (c *OSController) loadCurrentConfigLocked() error { payload, err := os.ReadFile(c.options.ConfigPath) if os.IsNotExist(err) { c.current = Config{} c.oob = OOBRuntimeStatus{} c.oobFailures = 0 return nil } if err != nil { return fmt.Errorf("read active network configuration: %w", err) } decoder := json.NewDecoder(strings.NewReader(string(payload))) decoder.DisallowUnknownFields() var config Config if err := decoder.Decode(&config); err != nil { return fmt.Errorf("decode active network configuration: %w", err) } normalized, err := Normalize(config) if err != nil { return fmt.Errorf("validate active network configuration: %w", err) } c.setCurrentConfigLocked(normalized) return nil } func (c *OSController) setCurrentConfigLocked(config Config) { previousFallback := c.oob.FallbackActive previousAt := c.oob.LastFailoverAt c.current = config c.oobFailures = 0 c.oob = OOBRuntimeStatus{ PrimaryInterface: config.OOBPrimaryInterface, FallbackInterface: config.OOBFallbackInterface, PrimaryLink: "unknown", FallbackActive: previousFallback, FailoverReason: "none", LastFailoverAt: previousAt, } if config.OperatingMode != OperatingModeOOB { c.oob.FallbackActive = false c.oob.LastFailoverAt = nil } } // RunOOBMonitor continuously evaluates the configured primary path. It is // intentionally owned by tera-netd so route changes stay inside the narrow // privileged network boundary. func (c *OSController) RunOOBMonitor(ctx context.Context) { ticker := time.NewTicker(time.Second) defer ticker.Stop() var next time.Time for { select { case <-ctx.Done(): return case now := <-ticker.C: c.mu.Lock() interval := time.Duration(c.current.OOBHealthCheckSecond) * time.Second enabled := c.current.OperatingMode == OperatingModeOOB c.mu.Unlock() if !enabled || interval <= 0 || (!next.IsZero() && now.Before(next)) { continue } next = now.Add(interval) probeContext, cancel := context.WithTimeout(ctx, 5*time.Second) c.checkOOBHealth(probeContext) cancel() } } } func (c *OSController) checkOOBHealth(ctx context.Context) { c.mu.Lock() defer c.mu.Unlock() if c.current.OperatingMode != OperatingModeOOB { return } primary, ok := configuredInterface(c.current, c.current.OOBPrimaryInterface) if !ok { c.oob.PrimaryLink = "unknown" c.oob.FailoverReason = "primary_not_configured" return } c.oob.PrimaryInterface = c.current.OOBPrimaryInterface c.oob.FallbackInterface = c.current.OOBFallbackInterface c.oob.PrimaryLink = c.interfaceLink(primary.Device) fallback, fallbackConfigured := configuredInterface(c.current, c.current.OOBFallbackInterface) c.oob.FallbackAvailable = fallbackConfigured && c.interfaceLink(fallback.Device) == "up" healthy := c.oob.PrimaryLink == "up" if healthy { _, err := c.options.Runner.Run(ctx, c.options.PingPath, "-I", primary.Device, "-c", "1", "-W", "2", c.current.OOBHealthCheckTarget) healthy = err == nil } if healthy { c.oobFailures = 0 if c.oob.FallbackActive { if c.installPrimaryDefaultLocked(ctx, primary) == nil { c.oob.FallbackActive = false c.oob.FailoverReason = "primary_restored" now := c.options.Now().UTC() c.oob.LastFailoverAt = &now } } else { c.oob.FailoverReason = "none" } return } c.oobFailures++ c.oob.FailoverReason = "primary_health_check_failed" if c.oobFailures < c.current.OOBFailureThreshold || c.oob.FallbackActive || !c.oob.FallbackAvailable { return } args := []string{"-4", "route", "replace", "default"} if fallback.Gateway != "" { args = append(args, "via", fallback.Gateway) } args = append(args, "dev", fallback.Device) if _, err := c.options.Runner.Run(ctx, c.options.IPPath, args...); err != nil { c.oob.FailoverReason = "fallback_route_failed" return } c.oob.FallbackActive = true c.oob.FailoverReason = "primary_failure_threshold_reached" now := c.options.Now().UTC() c.oob.LastFailoverAt = &now } func configuredInterface(config Config, name string) (Interface, bool) { for _, item := range config.Interfaces { if item.Name == name || item.Device == name { return item, true } } return Interface{}, false } func (c *OSController) interfaceLink(device string) string { device = strings.TrimSpace(device) if device == "" || strings.ContainsAny(device, `/\\`) { return "unknown" } if carrier, err := os.ReadFile(filepath.Join(c.options.SysClassRoot, device, "carrier")); err == nil { if strings.TrimSpace(string(carrier)) == "1" { return "up" } return "down" } if state, err := os.ReadFile(filepath.Join(c.options.SysClassRoot, device, "operstate")); err == nil { if strings.TrimSpace(string(state)) == "up" { return "up" } return "down" } if iface, err := net.InterfaceByName(device); err == nil && iface.Flags&net.FlagUp != 0 { return "up" } return "unknown" } func (c *OSController) installPrimaryDefaultLocked(ctx context.Context, primary Interface) error { args := []string{"-4", "route", "replace", "default"} if primary.Gateway != "" { args = append(args, "via", primary.Gateway) } args = append(args, "dev", primary.Device) _, err := c.options.Runner.Run(ctx, c.options.IPPath, args...) return err } func (c *OSController) persistPending(pending *pendingTransaction) error { payload, err := json.MarshalIndent(pending, "", " ") if err != nil { return fmt.Errorf("encode pending network transaction: %w", err) } payload = append(payload, '\n') if err := atomicfile.Write(c.options.PendingPath, payload, 0o600); err != nil { return fmt.Errorf("persist pending network transaction: %w", err) } return nil } func (c *OSController) writeConfigFiles(config Config) error { interfaces, err := renderInterfaces(config, c.options.RoutingPath, c.options.IPPath) if err != nil { return err } routing, err := RenderRoutingScript(config, c.options.IPPath) if err != nil { return err } payload, err := json.MarshalIndent(config, "", " ") if err != nil { return err } if err := atomicfile.Write(c.options.InterfacesPath, interfaces, 0o644); err != nil { return err } if err := atomicfile.Write(c.options.RoutingPath, routing, 0o755); err != nil { return err } if err := atomicfile.Write(c.options.ConfigPath, append(payload, '\n'), 0o600); err != nil { return err } if err := atomicfile.Write(c.options.HostnamePath, []byte(config.HostName+"\n"), 0o644); err != nil { return err } return nil } func (c *OSController) reconfigure(ctx context.Context, config Config, changedDevices map[string]bool) error { if output, err := c.options.Runner.Run(ctx, c.options.HostnameCommandPath, config.HostName); err != nil { return fmt.Errorf("set hostname: %s: %w", strings.TrimSpace(string(output)), err) } networkChanged := false for _, changed := range changedDevices { networkChanged = networkChanged || changed } if !networkChanged { return nil } // Tear down aggregate devices explicitly. The interfaces file has already // been replaced, so ifdown alone cannot reliably find the previous mode. for _, device := range []string{BondDevice, BridgeDevice} { _, _ = c.options.Runner.Run(ctx, c.options.IfdownPath, device) _, _ = c.options.Runner.Run(ctx, c.options.IPPath, "link", "delete", "dev", device) } if config.Advanced.Mode != AdvancedModeSingle { for _, device := range []string{Interface1Device, Interface2Device} { _, _ = c.options.Runner.Run(ctx, c.options.IfdownPath, device) } device := BondDevice if config.Advanced.Mode == AdvancedModeBridge { device = BridgeDevice } output, err := c.options.Runner.Run(ctx, c.options.IfupPath, device) if err != nil { return fmt.Errorf("ifup %s: %s: %w", device, strings.TrimSpace(string(output)), err) } if info, err := os.Stat(c.options.RoutingPath); err == nil && info.Mode().IsRegular() { _, _ = c.options.Runner.Run(ctx, c.options.ShellPath, c.options.RoutingPath) } return nil } for _, item := range config.Interfaces { if !changedDevices[item.Device] { continue } _, _ = c.options.Runner.Run(ctx, c.options.IfdownPath, item.Device) output, err := c.options.Runner.Run(ctx, c.options.IfupPath, item.Device) if err != nil { if item.Device == "eth0" { return fmt.Errorf("ifup %s: %s: %w", item.Device, strings.TrimSpace(string(output)), err) } } } if info, err := os.Stat(c.options.RoutingPath); err == nil && info.Mode().IsRegular() { _, _ = c.options.Runner.Run(ctx, c.options.ShellPath, c.options.RoutingPath) } return nil } func changedNetworkDevices(current, next Config) map[string]bool { changed := make(map[string]bool, len(next.Interfaces)) currentByDevice := make(map[string]Interface, len(current.Interfaces)) for _, item := range current.Interfaces { currentByDevice[item.Device] = item } advancedChanged := !reflect.DeepEqual(current.Advanced, next.Advanced) for _, item := range next.Interfaces { previous, exists := currentByDevice[item.Device] changed[item.Device] = !exists || advancedChanged || !reflect.DeepEqual(previous, item) } return changed } func (c *OSController) reconfigureRestored(ctx context.Context) error { if payload, err := os.ReadFile(c.options.ConfigPath); err == nil { var restored Config decoder := json.NewDecoder(strings.NewReader(string(payload))) decoder.DisallowUnknownFields() if decodeErr := decoder.Decode(&restored); decodeErr == nil { if normalized, normalizeErr := Normalize(restored); normalizeErr == nil { return c.reconfigure(ctx, normalized, map[string]bool{Interface1Device: true, Interface2Device: true}) } } } if payload, err := os.ReadFile(c.options.HostnamePath); err == nil { hostname := strings.TrimSpace(string(payload)) if hostname != "" { _, _ = c.options.Runner.Run(ctx, c.options.HostnameCommandPath, hostname) } } for _, device := range []string{BondDevice, BridgeDevice} { _, _ = c.options.Runner.Run(ctx, c.options.IfdownPath, device) _, _ = c.options.Runner.Run(ctx, c.options.IPPath, "link", "delete", "dev", device) } for _, device := range []string{Interface1Device, Interface2Device} { _, _ = c.options.Runner.Run(ctx, c.options.IfdownPath, device) _, _ = c.options.Runner.Run(ctx, c.options.IfupPath, device) } if info, err := os.Stat(c.options.RoutingPath); err == nil && info.Mode().IsRegular() { _, _ = c.options.Runner.Run(ctx, c.options.ShellPath, c.options.RoutingPath) } return nil } func (c *OSController) statusLocked(ctx context.Context) Status { state := c.state if state == "" { state = "idle" } status := Status{State: state, RoutingMode: RoutingModeActive} if c.current.OperatingMode != "" { current := c.current current.Interfaces = append([]Interface(nil), c.current.Interfaces...) status.Current = ¤t status.RoutingMode = c.current.OperatingMode } status.OOB = c.oob if c.pending != nil { deadline := c.pending.Deadline if c.state == "rollback_failed" { status.State = c.state } else { status.State = "pending_confirmation" } status.TransactionID = c.pending.TransactionID status.Deadline = &deadline } statusDevices := []string{Interface1Device, Interface2Device} if c.current.Advanced.Mode == AdvancedModeBonding { statusDevices = append(statusDevices, BondDevice) } else if c.current.Advanced.Mode == AdvancedModeBridge { statusDevices = append(statusDevices, BridgeDevice) } for _, device := range statusDevices { actual := ActualInterface{Device: device, Link: "unknown", Addresses: []string{}} if iface, err := net.InterfaceByName(device); err == nil { if iface.Flags&net.FlagUp != 0 { actual.Link = "up" } else { actual.Link = "down" } if addresses, err := iface.Addrs(); err == nil { for _, address := range addresses { if prefix, err := netip.ParsePrefix(address.String()); err == nil { actual.Addresses = append(actual.Addresses, prefix.String()) } } sort.Strings(actual.Addresses) } } if carrier, err := os.ReadFile(filepath.Join(c.options.SysClassRoot, device, "carrier")); err == nil { switch strings.TrimSpace(string(carrier)) { case "1": actual.Link = "up" case "0": actual.Link = "down" } } if speed, err := os.ReadFile(filepath.Join(c.options.SysClassRoot, device, "speed")); err == nil { value := strings.TrimSpace(string(speed)) if parsed, err := strconv.Atoi(value); err == nil && parsed > 0 { actual.Speed = fmt.Sprintf("%d Mbps", parsed) } } status.Interfaces = append(status.Interfaces, actual) } if output, err := c.options.Runner.Run(ctx, c.options.IPPath, "-4", "route", "show", "default"); err == nil { status.DefaultRoute = strings.TrimSpace(string(output)) } return status } func snapshotFile(path string) (fileSnapshot, error) { info, err := os.Lstat(path) if os.IsNotExist(err) { return fileSnapshot{Path: path}, nil } if err != nil { return fileSnapshot{}, fmt.Errorf("inspect %s: %w", path, err) } if !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 { return fileSnapshot{}, fmt.Errorf("refusing to replace non-regular network path %s", path) } content, err := os.ReadFile(path) if err != nil { return fileSnapshot{}, fmt.Errorf("read %s: %w", path, err) } return fileSnapshot{Path: path, Exists: true, Mode: info.Mode().Perm(), Content: content}, nil } func restoreFile(snapshot fileSnapshot) error { if snapshot.Exists { mode := snapshot.Mode if mode == 0 { mode = 0o600 } return atomicfile.Write(snapshot.Path, snapshot.Content, mode) } if err := os.Remove(snapshot.Path); err != nil && !os.IsNotExist(err) { return fmt.Errorf("remove newly created network path %s: %w", snapshot.Path, err) } return nil } func newTransactionID() (string, error) { random := make([]byte, 12) if _, err := rand.Read(random); err != nil { return "", fmt.Errorf("create network transaction ID: %w", err) } return "network-" + hex.EncodeToString(random), nil } func combineApplyRollbackError(action string, applyErr, rollbackErr error) error { if rollbackErr != nil { return fmt.Errorf("%s: %v; automatic rollback failed: %w", action, applyErr, rollbackErr) } return fmt.Errorf("%s: %w; previous configuration restored", action, applyErr) } func RenderInterfaces(config Config, routingPath string) ([]byte, error) { return renderInterfaces(config, routingPath, "/sbin/ip") } func renderInterfaces(config Config, routingPath, ipPath string) ([]byte, error) { normalized, err := Normalize(config) if err != nil { return nil, err } if routingPath == "" || !isAbsoluteCommandPath(routingPath) || strings.ContainsAny(routingPath, "\r\n\t ") { return nil, errors.New("routing script path must be an absolute path without whitespace") } if ipPath == "" || !isAbsoluteCommandPath(ipPath) || strings.ContainsAny(ipPath, "\r\n\t ") { return nil, errors.New("ip command path must be an absolute path without whitespace") } var builder strings.Builder builder.WriteString("# Managed by tera-netd. Use the Web UI; manual edits are replaced.\n") builder.WriteString("auto lo\niface lo inet loopback\n\n") if normalized.Advanced.Mode == AdvancedModeSingle { for index, item := range normalized.Interfaces { writeLayer3Stanza(&builder, item, index == len(normalized.Interfaces)-1, routingPath, nil, nil) } return []byte(builder.String()), nil } builder.WriteString("# eth0 and eth1 are layer-2 members; Interface-1 owns the shared layer-3 configuration.\n") builder.WriteString("auto eth0\niface eth0 inet manual\n\nauto eth1\niface eth1 inet manual\n\n") effective := effectiveInterfaces(normalized) if len(effective) != 1 { return nil, errors.New("advanced network mode requires Interface-1") } item := effective[0] preUp := make([]string, 0, 8) postDown := make([]string, 0, 4) if normalized.Advanced.Mode == AdvancedModeBonding { preUp = append(preUp, fmt.Sprintf("%s link add name %s type bond 2>/dev/null || true", ipPath, BondDevice), fmt.Sprintf("%s link set dev %s type bond mode %s miimon %d", ipPath, BondDevice, normalized.Advanced.BondMode, normalized.Advanced.BondMii), ) } else { stp := 0 if normalized.Advanced.BridgeSTP { stp = 1 } preUp = append(preUp, fmt.Sprintf("%s link add name %s type bridge 2>/dev/null || true", ipPath, BridgeDevice), fmt.Sprintf("%s link set dev %s type bridge stp_state %d", ipPath, BridgeDevice, stp), ) } for _, device := range []string{Interface1Device, Interface2Device} { preUp = append(preUp, fmt.Sprintf("%s link set dev %s down", ipPath, device), fmt.Sprintf("%s address flush dev %s", ipPath, device), fmt.Sprintf("%s link set dev %s master %s", ipPath, device, item.Device), fmt.Sprintf("%s link set dev %s up", ipPath, device), ) postDown = append(postDown, fmt.Sprintf("%s link set dev %s nomaster 2>/dev/null || true", ipPath, device)) } postDown = append(postDown, fmt.Sprintf("%s link delete dev %s 2>/dev/null || true", ipPath, item.Device)) writeLayer3Stanza(&builder, item, true, routingPath, preUp, postDown) return []byte(builder.String()), nil } func writeLayer3Stanza(builder *strings.Builder, item Interface, runRouting bool, routingPath string, preUp, postDown []string) { fmt.Fprintf(builder, "# %s role=%s weight=%d\nauto %s\n", item.Name, item.Role, item.Weight, item.Device) if item.AddressMode == AddressModeDHCP { fmt.Fprintf(builder, "iface %s inet dhcp\n", item.Device) } else { prefix := netip.MustParsePrefix(item.Address) mask := net.IP(net.CIDRMask(prefix.Bits(), 32)).String() fmt.Fprintf(builder, "iface %s inet static\n address %s\n netmask %s\n", item.Device, prefix.Addr(), mask) } for _, command := range preUp { fmt.Fprintf(builder, " pre-up %s\n", command) } interfaceDNSServers := append([]string(nil), item.DNSServers...) // BusyBox ifupdown does not implement the Debian "inet6 auto" method. if item.IPv6Mode == IPv6ModeAuto { interfaceDNSServers = append(interfaceDNSServers, item.IPv6DNSServers...) } if len(interfaceDNSServers) > 0 { fmt.Fprintf(builder, " dns-nameservers %s\n", strings.Join(interfaceDNSServers, " ")) } if runRouting { fmt.Fprintf(builder, " post-up %s\n", routingPath) } for _, command := range postDown { fmt.Fprintf(builder, " post-down %s\n", command) } builder.WriteByte('\n') if item.IPv6Mode == IPv6ModeStatic { prefix := netip.MustParsePrefix(item.IPv6Address) fmt.Fprintf(builder, "iface %s inet6 static\n address %s\n netmask %d\n", item.Device, prefix.Addr(), prefix.Bits()) if item.IPv6Gateway != "" { fmt.Fprintf(builder, " gateway %s\n", item.IPv6Gateway) } if len(item.IPv6DNSServers) > 0 { fmt.Fprintf(builder, " dns-nameservers %s\n", strings.Join(item.IPv6DNSServers, " ")) } builder.WriteByte('\n') } } func RenderRoutingScript(config Config, ipPath string) ([]byte, error) { normalized, err := Normalize(config) if err != nil { return nil, err } if ipPath == "" || !isAbsoluteCommandPath(ipPath) || strings.ContainsAny(ipPath, "\r\n\t ") { return nil, errors.New("ip command path must be an absolute path without whitespace") } var builder strings.Builder interfaces := effectiveInterfaces(normalized) external := make([]Interface, 0, 2) dhcpExternal := false for _, item := range interfaces { if item.Role == RoleExternal && item.AddressMode == AddressModeDHCP { dhcpExternal = true } if item.Role == RoleExternal && item.AddressMode == AddressModeStatic { external = append(external, item) } } builder.WriteString("#!/bin/sh\nset -eu\nIP=") builder.WriteString(ipPath) builder.WriteString("\n\n# ifup invokes this script once per interface. Defer routes that depend on\n# an address which has not been installed yet.\nnetwork_ready() {\n $IP -4 addr show dev \"$1\" 2>/dev/null | grep -Fq \"inet $2/\"\n}\n\nif $IP -4 rule show >/dev/null 2>&1; then\n") for index, item := range interfaces { if item.AddressMode != AddressModeStatic { continue } table := 100 + index priority := 10000 + index prefix := netip.MustParsePrefix(item.Address) fmt.Fprintf(&builder, " while $IP -4 rule del priority %d 2>/dev/null; do :; done\n", priority) fmt.Fprintf(&builder, " $IP -4 route flush table %d 2>/dev/null || true\n", table) fmt.Fprintf(&builder, " if network_ready %s %s; then\n", item.Device, prefix.Addr()) fmt.Fprintf(&builder, " $IP -4 route add %s dev %s src %s table %d\n", prefix.Masked(), item.Device, prefix.Addr(), table) if item.Gateway != "" { fmt.Fprintf(&builder, " $IP -4 route add default via %s dev %s table %d\n", item.Gateway, item.Device, table) } fmt.Fprintf(&builder, " $IP -4 rule add priority %d from %s/32 lookup %d\n", priority, prefix.Addr(), table) fmt.Fprintf(&builder, " else\n echo 'deferring IPv4 policy route for %s until %s is configured' >&2\n fi\n", item.Device, prefix.Addr()) } builder.WriteString("else\n") if len(external) == 2 { builder.WriteString(" echo 'kernel lacks IPv4 policy routing required for dual-external active/active' >&2\n exit 1\n") } else { builder.WriteString(" echo 'IPv4 policy routing unavailable; using connected routes plus one main default' >&2\n") } builder.WriteString("fi\n\n") if normalized.OperatingMode == OperatingModeOOB { primary, _ := configuredInterface(normalized, normalized.OOBPrimaryInterface) if primary.AddressMode == AddressModeDHCP { builder.WriteString("# DHCP owns the OOB primary default route until health monitoring activates fallback.\n") } else { builder.WriteString("while $IP -4 route del default 2>/dev/null; do :; done\n") primaryAddress := netip.MustParsePrefix(primary.Address).Addr() fmt.Fprintf(&builder, "if network_ready %s %s; then\n $IP -4 route add default via %s dev %s\nelse\n echo 'deferring OOB default route until %s is configured' >&2\nfi\n", primary.Device, primaryAddress, primary.Gateway, primary.Device, primary.Device) } } else if !dhcpExternal { builder.WriteString("while $IP -4 route del default 2>/dev/null; do :; done\n") if len(external) == 1 { address := netip.MustParsePrefix(external[0].Address).Addr() fmt.Fprintf(&builder, "if network_ready %s %s; then\n $IP -4 route add default via %s dev %s\nelse\n echo 'deferring main default route until %s is configured' >&2\nfi\n", external[0].Device, address, external[0].Gateway, external[0].Device, external[0].Device) } else if len(external) == 2 { firstAddress := netip.MustParsePrefix(external[0].Address).Addr() secondAddress := netip.MustParsePrefix(external[1].Address).Addr() fmt.Fprintf(&builder, "if network_ready %s %s && network_ready %s %s; then\n $IP -4 route add default", external[0].Device, firstAddress, external[1].Device, secondAddress) for _, item := range external { fmt.Fprintf(&builder, " nexthop via %s dev %s weight %d", item.Gateway, item.Device, item.Weight) } fmt.Fprintf(&builder, "\nelif network_ready %s %s; then\n $IP -4 route add default via %s dev %s\nelif network_ready %s %s; then\n $IP -4 route add default via %s dev %s\nelse\n echo 'deferring main default route until an external interface is configured' >&2\nfi\n", external[0].Device, firstAddress, external[0].Gateway, external[0].Device, external[1].Device, secondAddress, external[1].Gateway, external[1].Device) } } else { builder.WriteString("# DHCP owns the dynamic default route; do not replace it here.\n") } return []byte(builder.String()), nil } func isAbsoluteCommandPath(path string) bool { return filepath.IsAbs(path) || strings.HasPrefix(path, "/") }