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.
950 lines
33 KiB
950 lines
33 KiB
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, "/")
|
|
}
|
|
|
|
// FactoryReset replaces persistent network state without dropping the current
|
|
// management connection. Boot applies these files after the reset response.
|
|
func (c *OSController) FactoryReset(ctx context.Context, config Config) error {
|
|
normalized, err := Normalize(config)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
if err := ctx.Err(); err != nil {
|
|
return err
|
|
}
|
|
if c.timer != nil {
|
|
c.timer.Stop()
|
|
c.timer = nil
|
|
}
|
|
if err := c.writeConfigFiles(normalized); err != nil {
|
|
return err
|
|
}
|
|
if err := os.Remove(c.options.PendingPath); err != nil && !os.IsNotExist(err) {
|
|
return err
|
|
}
|
|
c.pending = nil
|
|
c.setCurrentConfigLocked(normalized)
|
|
c.state = "confirmed"
|
|
return nil
|
|
}
|
|
|