package nmlite import ( "context" "fmt" "net" "net/netip" "time" "github.com/jetkvm/kvm/internal/sync" "github.com/jetkvm/kvm/internal/confparser" "github.com/jetkvm/kvm/internal/logging" "github.com/jetkvm/kvm/internal/network/types" "github.com/jetkvm/kvm/pkg/nmlite/link" "github.com/mdlayher/ndp" "github.com/rs/zerolog" "github.com/vishvananda/netlink" ) type ResolvConfChangeCallback func(family int, resolvConf *types.InterfaceResolvConf) error // InterfaceManager manages a single network interface type InterfaceManager struct { ctx context.Context ifaceName string config *types.NetworkConfig logger *zerolog.Logger state *types.InterfaceState linkState *link.Link stateMu sync.RWMutex // Network components staticConfig *StaticConfigManager dhcpClient *DHCPClient // Callbacks onStateChange func(state types.InterfaceState) onConfigChange func(config *types.NetworkConfig) onDHCPLeaseChange func(lease *types.DHCPLease) onResolvConfChange ResolvConfChangeCallback // Control stopCh chan struct{} wg sync.WaitGroup } // NewInterfaceManager creates a new interface manager func NewInterfaceManager(ctx context.Context, ifaceName string, config *types.NetworkConfig, logger *zerolog.Logger) (*InterfaceManager, error) { if config == nil { return nil, fmt.Errorf("config cannot be nil") } if logger == nil { logger = logging.GetSubsystemLogger("interface") } scopedLogger := logger.With().Str("interface", ifaceName).Logger() // Validate and set defaults if err := confparser.SetDefaultsAndValidate(config); err != nil { return nil, fmt.Errorf("invalid config: %w", err) } im := &InterfaceManager{ ctx: ctx, ifaceName: ifaceName, config: config, logger: &scopedLogger, state: &types.InterfaceState{ InterfaceName: ifaceName, // LastUpdated: time.Now(), }, stopCh: make(chan struct{}), } // Initialize components var err error im.staticConfig, err = NewStaticConfigManager(ifaceName, &scopedLogger) if err != nil { return nil, fmt.Errorf("failed to create static config manager: %w", err) } // create the dhcp client im.dhcpClient, err = NewDHCPClient(ctx, ifaceName, &scopedLogger, config.DHCPClient.String) if err != nil { return nil, fmt.Errorf("failed to create DHCP client: %w", err) } // Set up DHCP client callbacks im.dhcpClient.SetOnLeaseChange(func(lease *types.DHCPLease) { if im.config.IPv4Mode.String != "dhcp" { im.logger.Warn().Str("mode", im.config.IPv4Mode.String).Msg("ignoring DHCP lease, current mode is not DHCP") return } if err := im.applyDHCPLease(lease); err != nil { im.logger.Error().Err(err).Msg("failed to apply DHCP lease") } im.updateStateFromDHCPLease(lease) if im.onDHCPLeaseChange != nil { im.onDHCPLeaseChange(lease) } }) return im, nil } // Start starts managing the interface func (im *InterfaceManager) Start() error { im.stateMu.Lock() defer im.stateMu.Unlock() im.logger.Info().Msg("starting interface manager") // Start monitoring interface state im.wg.Add(1) go im.monitorInterfaceState() nl := getNetlinkManager() // Set the link state linkState, err := nl.GetLinkByName(im.ifaceName) if err != nil { return fmt.Errorf("failed to get interface: %w", err) } im.linkState = linkState // Bring the interface up _, linkUpErr := nl.EnsureInterfaceUpWithTimeout( im.ctx, im.linkState, 30*time.Second, ) // Set callback after the interface is up nl.AddStateChangeCallback(im.ifaceName, link.StateChangeCallback{ Async: true, Func: func(link *link.Link) { im.handleLinkStateChange(link) }, }) if linkUpErr != nil { im.logger.Error().Err(linkUpErr).Msg("failed to bring interface up, continuing anyway") } else { // Apply initial configuration if err := im.applyConfiguration(); err != nil { im.logger.Error().Err(err).Msg("failed to apply initial configuration") return err } } im.logger.Info().Msg("interface manager started") return nil } // Stop stops managing the interface func (im *InterfaceManager) Stop() error { im.logger.Info().Msg("stopping interface manager") close(im.stopCh) im.wg.Wait() // Stop DHCP client if im.dhcpClient != nil { if err := im.dhcpClient.Stop(); err != nil { return fmt.Errorf("failed to stop DHCP client: %w", err) } } im.logger.Info().Msg("interface manager stopped") return nil } func (im *InterfaceManager) link() (*link.Link, error) { nl := getNetlinkManager() if nl == nil { return nil, fmt.Errorf("netlink manager not initialized") } return nl.GetLinkByName(im.ifaceName) } // IsUp returns true if the interface is up func (im *InterfaceManager) IsUp() bool { im.stateMu.RLock() defer im.stateMu.RUnlock() if im.state == nil { return false } return im.state.Up } // IsOnline returns true if the interface is online func (im *InterfaceManager) IsOnline() bool { im.stateMu.RLock() defer im.stateMu.RUnlock() if im.state == nil { return false } return im.state.Online } // IPv4Ready returns true if the interface has an IPv4 address func (im *InterfaceManager) IPv4Ready() bool { im.stateMu.RLock() defer im.stateMu.RUnlock() if im.state == nil { return false } return im.state.IPv4Ready } // IPv6Ready returns true if the interface has an IPv6 address func (im *InterfaceManager) IPv6Ready() bool { im.stateMu.RLock() defer im.stateMu.RUnlock() if im.state == nil { return false } return im.state.IPv6Ready } // GetIPv4Addresses returns the IPv4 addresses of the interface func (im *InterfaceManager) GetIPv4Addresses() []string { im.stateMu.RLock() defer im.stateMu.RUnlock() if im.state == nil { return []string{} } return im.state.IPv4Addresses } // GetIPv4Address returns the IPv4 address of the interface func (im *InterfaceManager) GetIPv4Address() string { im.stateMu.RLock() defer im.stateMu.RUnlock() if im.state == nil { return "" } return im.state.IPv4Address } // GetIPv6Address returns the IPv6 address of the interface func (im *InterfaceManager) GetIPv6Address() string { im.stateMu.RLock() defer im.stateMu.RUnlock() if im.state == nil { return "" } return im.state.IPv6Address } // GetIPv6Addresses returns the IPv6 addresses of the interface func (im *InterfaceManager) GetIPv6Addresses() []string { im.stateMu.RLock() defer im.stateMu.RUnlock() addresses := []string{} if im.state == nil { return addresses } for _, addr := range im.state.IPv6Addresses { addresses = append(addresses, addr.Address.String()) } return addresses } // GetMACAddress returns the MAC address of the interface func (im *InterfaceManager) GetMACAddress() string { im.stateMu.RLock() defer im.stateMu.RUnlock() if im.state == nil { return "" } return im.state.MACAddress } // GetState returns the current interface state func (im *InterfaceManager) GetState() *types.InterfaceState { im.stateMu.RLock() defer im.stateMu.RUnlock() // Return a copy to avoid race conditions im.logger.Debug().Interface("state", im.state).Msg("getting interface state") state := *im.state return &state } // NTPServers returns the NTP servers of the interface func (im *InterfaceManager) NTPServers() []net.IP { im.stateMu.RLock() defer im.stateMu.RUnlock() if im.state == nil { return []net.IP{} } return im.state.NTPServers } func (im *InterfaceManager) Domain() string { im.stateMu.RLock() defer im.stateMu.RUnlock() if im.state == nil { return "" } if im.state.DHCPLease4 != nil { return im.state.DHCPLease4.Domain } if im.state.DHCPLease6 != nil { return im.state.DHCPLease6.Domain } return "" } // GetConfig returns the current interface configuration func (im *InterfaceManager) GetConfig() *types.NetworkConfig { // Return a copy to avoid race conditions config := *im.config return &config } // ApplyConfiguration applies the current configuration to the interface func (im *InterfaceManager) ApplyConfiguration() error { return im.applyConfiguration() } // SetConfig updates the interface configuration func (im *InterfaceManager) SetConfig(config *types.NetworkConfig) error { if config == nil { return fmt.Errorf("config cannot be nil") } // Validate and set defaults if err := confparser.SetDefaultsAndValidate(config); err != nil { return fmt.Errorf("invalid config: %w", err) } im.config = config // Apply the new configuration if err := im.applyConfiguration(); err != nil { im.logger.Error().Err(err).Msg("failed to apply new configuration") return err } // Notify callback if im.onConfigChange != nil { im.onConfigChange(config) } im.logger.Info().Msg("configuration updated") return nil } // RenewDHCPLease renews the DHCP lease func (im *InterfaceManager) RenewDHCPLease() error { if im.dhcpClient == nil { return fmt.Errorf("DHCP client not available") } return im.dhcpClient.Renew() } // SetOnStateChange sets the callback for state changes func (im *InterfaceManager) SetOnStateChange(callback func(state types.InterfaceState)) { im.onStateChange = callback } // SetOnConfigChange sets the callback for configuration changes func (im *InterfaceManager) SetOnConfigChange(callback func(config *types.NetworkConfig)) { im.onConfigChange = callback } // SetOnDHCPLeaseChange sets the callback for DHCP lease changes func (im *InterfaceManager) SetOnDHCPLeaseChange(callback func(lease *types.DHCPLease)) { im.onDHCPLeaseChange = callback } // SetOnResolvConfChange sets the callback for resolv.conf changes func (im *InterfaceManager) SetOnResolvConfChange(callback ResolvConfChangeCallback) { im.onResolvConfChange = callback } // applyConfiguration applies the current configuration to the interface func (im *InterfaceManager) applyConfiguration() error { im.logger.Info().Msg("applying configuration") // Apply IPv4 configuration if err := im.applyIPv4Config(); err != nil { return fmt.Errorf("failed to apply IPv4 config: %w", err) } // Apply IPv6 configuration if err := im.applyIPv6Config(); err != nil { return fmt.Errorf("failed to apply IPv6 config: %w", err) } return nil } // applyIPv4Config applies IPv4 configuration func (im *InterfaceManager) applyIPv4Config() error { mode := im.config.IPv4Mode.String im.logger.Info().Str("mode", mode).Msg("applying IPv4 configuration") switch mode { case "static": return im.applyIPv4Static() case "dhcp": return im.applyIPv4DHCP() case "disabled": return im.disableIPv4() default: return fmt.Errorf("invalid IPv4 mode: %s", mode) } } // applyIPv6Config applies IPv6 configuration func (im *InterfaceManager) applyIPv6Config() error { mode := im.config.IPv6Mode.String im.logger.Info().Str("mode", mode).Msg("applying IPv6 configuration") switch mode { case "static": return im.applyIPv6Static() case "dhcpv6": return im.applyIPv6DHCP() case "slaac": return im.applyIPv6SLAAC() case "slaac_and_dhcpv6": return im.applyIPv6SLAACAndDHCP() case "link_local": return im.applyIPv6LinkLocal() case "disabled": return im.disableIPv6() default: return fmt.Errorf("invalid IPv6 mode: %s", mode) } } // applyIPv4Static applies static IPv4 configuration func (im *InterfaceManager) applyIPv4Static() error { if im.config.IPv4Static == nil { return fmt.Errorf("IPv4 static configuration is nil") } im.logger.Info().Msg("stopping DHCP") // Disable DHCP if im.dhcpClient != nil { im.dhcpClient.SetIPv4(false) } im.logger.Info().Interface("config", im.config.IPv4Static).Msg("applying IPv4 static configuration") config, err := im.staticConfig.ToIPv4Static(im.config.IPv4Static) if err != nil { return fmt.Errorf("failed to convert IPv4 static configuration: %w", err) } im.logger.Info().Interface("config", config).Msg("converted IPv4 static configuration") if err := im.onResolvConfChange(link.AfInet, &types.InterfaceResolvConf{ NameServers: config.Nameservers, Source: "static", }); err != nil { im.logger.Warn().Err(err).Msg("failed to update resolv.conf") } return im.ReconcileLinkAddrs(config.Addresses, link.AfInet) } // applyIPv4DHCP applies DHCP IPv4 configuration func (im *InterfaceManager) applyIPv4DHCP() error { if im.dhcpClient == nil { return fmt.Errorf("DHCP client not available") } // Enable DHCP im.dhcpClient.SetIPv4(true) return im.dhcpClient.Start() } // disableIPv4 disables IPv4 func (im *InterfaceManager) disableIPv4() error { // Disable DHCP if im.dhcpClient != nil { im.dhcpClient.SetIPv4(false) } // Remove all IPv4 addresses return im.staticConfig.DisableIPv4() } // applyIPv6Static applies static IPv6 configuration func (im *InterfaceManager) applyIPv6Static() error { if im.config.IPv6Static == nil { return fmt.Errorf("IPv6 static configuration is nil") } im.logger.Info().Msg("stopping DHCPv6") // Disable DHCPv6 if im.dhcpClient != nil { im.dhcpClient.SetIPv6(false) } // Apply static configuration config, err := im.staticConfig.ToIPv6Static(im.config.IPv6Static) if err != nil { return fmt.Errorf("failed to convert IPv6 static configuration: %w", err) } im.logger.Info().Interface("config", config).Msg("converted IPv6 static configuration") if err := im.onResolvConfChange(link.AfInet6, &types.InterfaceResolvConf{ NameServers: config.Nameservers, Source: "static", }); err != nil { im.logger.Warn().Err(err).Msg("failed to update resolv.conf") } return im.ReconcileLinkAddrs(config.Addresses, link.AfInet6) } // applyIPv6DHCP applies DHCPv6 configuration func (im *InterfaceManager) applyIPv6DHCP() error { if im.dhcpClient == nil { return fmt.Errorf("DHCP client not available") } // Enable DHCPv6 im.dhcpClient.SetIPv6(true) return im.dhcpClient.Start() } // applyIPv6SLAAC applies SLAAC configuration func (im *InterfaceManager) applyIPv6SLAAC() error { // Disable DHCPv6 if im.dhcpClient != nil { im.dhcpClient.SetIPv6(false) } // Remove static IPv6 configuration l, err := im.link() if err != nil { return fmt.Errorf("failed to get interface: %w", err) } netlinkMgr := getNetlinkManager() // Ensure interface is up if err := netlinkMgr.EnsureInterfaceUp(l); err != nil { return fmt.Errorf("failed to bring interface up: %w", err) } if err := netlinkMgr.RemoveNonLinkLocalIPv6Addresses(l); err != nil { return fmt.Errorf("failed to remove non-link-local IPv6 addresses: %w", err) } if err := im.SendRouterSolicitation(); err != nil { im.logger.Error().Err(err).Msg("failed to send router solicitation, continuing anyway") } // Enable SLAAC return im.staticConfig.EnableIPv6SLAAC() } // applyIPv6SLAACAndDHCP applies SLAAC + DHCPv6 configuration func (im *InterfaceManager) applyIPv6SLAACAndDHCP() error { // Enable both SLAAC and DHCPv6 if im.dhcpClient != nil { im.dhcpClient.SetIPv6(true) if err := im.dhcpClient.Start(); err != nil { return fmt.Errorf("failed to start DHCP client: %w", err) } } return im.staticConfig.EnableIPv6SLAAC() } // applyIPv6LinkLocal applies link-local only IPv6 configuration func (im *InterfaceManager) applyIPv6LinkLocal() error { // Disable DHCPv6 if im.dhcpClient != nil { im.dhcpClient.SetIPv6(false) } // Enable link-local only return im.staticConfig.EnableIPv6LinkLocal() } // disableIPv6 disables IPv6 func (im *InterfaceManager) disableIPv6() error { // Disable DHCPv6 if im.dhcpClient != nil { im.dhcpClient.SetIPv6(false) } // Disable IPv6 return im.staticConfig.DisableIPv6() } func (im *InterfaceManager) handleLinkStateChange(link *link.Link) { { im.stateMu.Lock() defer im.stateMu.Unlock() if link.IsSame(im.linkState) { return } im.linkState = link } im.logger.Info().Interface("link", link).Msg("link state changed") operState := link.Attrs().OperState if operState == netlink.OperUp { im.handleLinkUp() } else { im.handleLinkDown() } } // SendRouterSolicitation sends a router solicitation func (im *InterfaceManager) SendRouterSolicitation() error { im.logger.Info().Msg("sending router solicitation") m := &ndp.RouterSolicitation{} l, err := im.link() if err != nil { return fmt.Errorf("failed to get interface: %w", err) } if l.Attrs().OperState != netlink.OperUp { return fmt.Errorf("interface %s is not up", im.ifaceName) } iface := l.Interface() if iface == nil { return fmt.Errorf("failed to get net.Interface for %s", im.ifaceName) } hwAddr := l.HardwareAddr() if hwAddr == nil { return fmt.Errorf("failed to get hardware address for %s", im.ifaceName) } c, _, err := ndp.Listen(iface, ndp.LinkLocal) if err != nil { return fmt.Errorf("failed to create NDP listener on %s: %w", im.ifaceName, err) } m.Options = append(m.Options, &ndp.LinkLayerAddress{ Addr: hwAddr, Direction: ndp.Source, }) targetAddr := netip.MustParseAddr("ff02::2") if err := c.WriteTo(m, nil, targetAddr); err != nil { c.Close() return fmt.Errorf("failed to write to %s: %w", targetAddr.String(), err) } im.logger.Info().Msg("router solicitation sent") c.Close() return nil } func (im *InterfaceManager) handleLinkUp() { im.logger.Info().Msg("link up") if err := im.applyConfiguration(); err != nil { im.logger.Error().Err(err).Msg("failed to apply configuration") } if im.config.IPv4Mode.String == "dhcp" { if err := im.dhcpClient.Renew(); err != nil { im.logger.Error().Err(err).Msg("failed to renew DHCP lease") } } if im.config.IPv6Mode.String == "slaac" { if err := im.staticConfig.EnableIPv6SLAAC(); err != nil { im.logger.Error().Err(err).Msg("failed to enable IPv6 SLAAC") } if err := im.SendRouterSolicitation(); err != nil { im.logger.Error().Err(err).Msg("failed to send router solicitation") } } } func (im *InterfaceManager) handleLinkDown() { im.logger.Info().Msg("link down") if im.config.IPv4Mode.String == "dhcp" { if err := im.dhcpClient.Stop(); err != nil { im.logger.Error().Err(err).Msg("failed to stop DHCP client") } } netlinkMgr := getNetlinkManager() if err := netlinkMgr.RemoveAllAddresses(im.linkState, link.AfInet); err != nil { im.logger.Error().Err(err).Msg("failed to remove all IPv4 addresses") } if err := netlinkMgr.RemoveNonLinkLocalIPv6Addresses(im.linkState); err != nil { im.logger.Error().Err(err).Msg("failed to remove non-link-local IPv6 addresses") } } // monitorInterfaceState monitors the interface state and updates accordingly func (im *InterfaceManager) monitorInterfaceState() { defer im.wg.Done() im.logger.Debug().Msg("monitoring interface state") // TODO: use netlink subscription instead of polling ticker := time.NewTicker(5 * time.Second) defer ticker.Stop() for { select { case <-im.ctx.Done(): return case <-im.stopCh: return case <-ticker.C: if err := im.updateInterfaceState(); err != nil { im.logger.Error().Err(err).Msg("failed to update interface state") } } } } // updateStateFromDHCPLease updates the state from a DHCP lease func (im *InterfaceManager) updateStateFromDHCPLease(lease *types.DHCPLease) { family := link.AfInet im.stateMu.Lock() if lease.IsIPv6() { im.state.DHCPLease6 = lease family = link.AfInet6 } else { im.state.DHCPLease4 = lease } im.stateMu.Unlock() // Update resolv.conf with DNS information if im.onResolvConfChange == nil { return } if im.ifaceName == "" { im.logger.Warn().Msg("interface name is empty, skipping resolv.conf update") return } if err := im.onResolvConfChange(family, &types.InterfaceResolvConf{ NameServers: lease.DNS, SearchList: lease.SearchList, Source: "dhcp", }); err != nil { im.logger.Warn().Err(err).Msg("failed to update resolv.conf") } } // ReconcileLinkAddrs reconciles the link addresses func (im *InterfaceManager) ReconcileLinkAddrs(addrs []types.IPAddress, family int) error { nl := getNetlinkManager() link, err := im.link() if err != nil { return fmt.Errorf("failed to get interface: %w", err) } if link == nil { return fmt.Errorf("failed to get interface: %w", err) } return nl.ReconcileLink(link, addrs, family) } // applyDHCPLease applies DHCP lease configuration using ReconcileLinkAddrs func (im *InterfaceManager) applyDHCPLease(lease *types.DHCPLease) error { if lease == nil { return fmt.Errorf("DHCP lease is nil") } if lease.DHCPClient != "jetdhcpc" { im.logger.Warn().Str("dhcp_client", lease.DHCPClient).Msg("ignoring DHCP lease, not implemented yet") return nil } if lease.IsIPv6() { im.logger.Warn().Msg("ignoring IPv6 DHCP lease, not implemented yet") return nil } // Convert DHCP lease to IPv4Config ipv4Config := im.convertDHCPLeaseToIPv4Config(lease) // Apply the configuration using ReconcileLinkAddrs return im.ReconcileLinkAddrs([]types.IPAddress{*ipv4Config}, link.AfInet) } // convertDHCPLeaseToIPv4Config converts a DHCP lease to IPv4Config func (im *InterfaceManager) convertDHCPLeaseToIPv4Config(lease *types.DHCPLease) *types.IPAddress { ipNet := lease.IPNet() if ipNet == nil { return nil } // Create IPv4Address ipv4Addr := types.IPAddress{ Address: *ipNet, Gateway: lease.Routers[0], Secondary: false, Permanent: false, } im.logger.Trace(). Interface("ipv4Addr", ipv4Addr). Interface("lease", lease). Msg("converted DHCP lease to IPv4Config") // Create IPv4Config return &ipv4Addr }