package network import ( "fmt" "net" "os" "strconv" "strings" "sync" "kvm/internal/confparser" "kvm/internal/logging" "kvm/internal/udhcpc" "github.com/rs/zerolog" "github.com/vishvananda/netlink" ) type NetworkInterfaceState struct { interfaceName string interfaceUp bool ipv4Addr *net.IP ipv4Addresses []string ipv6Addr *net.IP ipv6Addresses []IPv6Address ipv6LinkLocal *net.IP macAddr *net.HardwareAddr l *zerolog.Logger stateLock sync.Mutex config *NetworkConfig dhcpClient *udhcpc.DHCPClient defaultHostname string currentHostname string currentFqdn string onStateChange func(state *NetworkInterfaceState) onInitialCheck func(state *NetworkInterfaceState) cbConfigChange func(config *NetworkConfig) checked bool } type NetworkInterfaceOptions struct { InterfaceName string DhcpPidFile string Logger *zerolog.Logger DefaultHostname string OnStateChange func(state *NetworkInterfaceState) OnInitialCheck func(state *NetworkInterfaceState) OnDhcpLeaseChange func(lease *udhcpc.Lease) OnConfigChange func(config *NetworkConfig) NetworkConfig *NetworkConfig } func NewNetworkInterfaceState(opts *NetworkInterfaceOptions) (*NetworkInterfaceState, error) { if opts.NetworkConfig == nil { return nil, fmt.Errorf("NetworkConfig can not be nil") } if opts.DefaultHostname == "" { opts.DefaultHostname = "picokvm" } err := confparser.SetDefaultsAndValidate(opts.NetworkConfig) if err != nil { return nil, err } l := opts.Logger s := &NetworkInterfaceState{ interfaceName: opts.InterfaceName, defaultHostname: opts.DefaultHostname, stateLock: sync.Mutex{}, l: l, onStateChange: opts.OnStateChange, onInitialCheck: opts.OnInitialCheck, cbConfigChange: opts.OnConfigChange, config: opts.NetworkConfig, } // create the dhcp client dhcpClient := udhcpc.NewDHCPClient(&udhcpc.DHCPClientOptions{ InterfaceName: opts.InterfaceName, PidFile: opts.DhcpPidFile, Logger: l, OnLeaseChange: func(lease *udhcpc.Lease) { _, err := s.update() if err != nil { opts.Logger.Error().Err(err).Msg("failed to update network state") return } _ = s.setHostnameIfNotSame() opts.OnDhcpLeaseChange(lease) }, RequestAddress: s.config.IPv4RequestAddress.String, }) s.dhcpClient = dhcpClient mode := strings.TrimSpace(s.config.IPv4Mode.String) switch mode { case "static": if s.dhcpClient != nil { s.dhcpClient.SetEnabled(false) } if err := s.applyIPv4Static(); err != nil { l.Error().Err(err).Msg("failed to apply static IPv4 on init") } case "dhcp": if s.dhcpClient != nil { s.dhcpClient.SetEnabled(true) } case "disabled": if s.dhcpClient != nil { s.dhcpClient.SetEnabled(false) } if err := s.clearIPv4Addresses(); err != nil { l.Warn().Err(err).Msg("failed to clear IPv4 addresses on init") } if err := s.clearDefaultIPv4Route(); err != nil { l.Debug().Err(err).Msg("failed to clear default route on init") } } return s, nil } func (s *NetworkInterfaceState) IsUp() bool { return s.interfaceUp } func (s *NetworkInterfaceState) HasIPAssigned() bool { return s.ipv4Addr != nil || s.ipv6Addr != nil } func (s *NetworkInterfaceState) IsOnline() bool { return s.IsUp() && s.HasIPAssigned() } func (s *NetworkInterfaceState) IPv4() *net.IP { return s.ipv4Addr } func (s *NetworkInterfaceState) IPv4String() string { if s.ipv4Addr == nil { return "..." } return s.ipv4Addr.String() } func (s *NetworkInterfaceState) IPv6() *net.IP { return s.ipv6Addr } func (s *NetworkInterfaceState) IPv6String() string { if s.ipv6Addr == nil { return "..." } return s.ipv6Addr.String() } func (s *NetworkInterfaceState) MAC() *net.HardwareAddr { return s.macAddr } func (s *NetworkInterfaceState) MACString() string { if s.macAddr == nil { return "" } return s.macAddr.String() } func (s *NetworkInterfaceState) update() (DhcpTargetState, error) { s.stateLock.Lock() defer s.stateLock.Unlock() dhcpTargetState := DhcpTargetStateDoNothing iface, err := netlink.LinkByName(s.interfaceName) if err != nil { s.l.Error().Err(err).Msg("failed to get interface") return dhcpTargetState, err } // detect if the interface status changed var changed bool attrs := iface.Attrs() state := attrs.OperState newInterfaceUp := state == netlink.OperUp // check if the interface is coming up interfaceGoingUp := !s.interfaceUp && newInterfaceUp interfaceGoingDown := s.interfaceUp && !newInterfaceUp if s.interfaceUp != newInterfaceUp { s.interfaceUp = newInterfaceUp changed = true } if changed { if interfaceGoingUp { s.l.Info().Msg("interface state transitioned to up") dhcpTargetState = DhcpTargetStateRenew } else if interfaceGoingDown { s.l.Info().Msg("interface state transitioned to down") } } // set the mac address s.macAddr = &attrs.HardwareAddr // get the ip addresses addrs, err := netlinkAddrs(iface) if err != nil { return dhcpTargetState, logging.ErrorfL(s.l, "failed to get ip addresses", err) } var ( ipv4Addresses = make([]net.IP, 0) ipv4AddressesString = make([]string, 0) ipv6Addresses = make([]IPv6Address, 0) // ipv6AddressesString = make([]string, 0) ipv6LinkLocal *net.IP ) for _, addr := range addrs { if addr.IP.To4() != nil { scopedLogger := s.l.With().Str("ipv4", addr.IP.String()).Logger() if interfaceGoingDown { // remove all IPv4 addresses from the interface. scopedLogger.Info().Msg("state transitioned to down, removing IPv4 address") err := netlink.AddrDel(iface, &addr) if err != nil { scopedLogger.Warn().Err(err).Msg("failed to delete address") } // notify the DHCP client to release the lease dhcpTargetState = DhcpTargetStateRelease continue } ipv4Addresses = append(ipv4Addresses, addr.IP) ipv4AddressesString = append(ipv4AddressesString, addr.IPNet.String()) } else if addr.IP.To16() != nil { scopedLogger := s.l.With().Str("ipv6", addr.IP.String()).Logger() // check if it's a link local address if addr.IP.IsLinkLocalUnicast() { ipv6LinkLocal = &addr.IP continue } if !addr.IP.IsGlobalUnicast() { scopedLogger.Trace().Msg("not a global unicast address, skipping") continue } if interfaceGoingDown { scopedLogger.Info().Msg("state transitioned to down, removing IPv6 address") err := netlink.AddrDel(iface, &addr) if err != nil { scopedLogger.Warn().Err(err).Msg("failed to delete address") } continue } ipv6Addresses = append(ipv6Addresses, IPv6Address{ Address: addr.IP, Prefix: *addr.IPNet, ValidLifetime: lifetimeToTime(addr.ValidLft), PreferredLifetime: lifetimeToTime(addr.PreferedLft), Scope: addr.Scope, }) // ipv6AddressesString = append(ipv6AddressesString, addr.IPNet.String()) } } if len(ipv4Addresses) > 0 { // compare the addresses to see if there's a change if s.ipv4Addr == nil || s.ipv4Addr.String() != ipv4Addresses[0].String() { scopedLogger := s.l.With().Str("ipv4", ipv4Addresses[0].String()).Logger() if s.ipv4Addr != nil { scopedLogger.Info(). Str("old_ipv4", s.ipv4Addr.String()). Msg("IPv4 address changed") } else { scopedLogger.Info().Msg("IPv4 address found") } s.ipv4Addr = &ipv4Addresses[0] changed = true } } s.ipv4Addresses = ipv4AddressesString if ipv6LinkLocal != nil { if s.ipv6LinkLocal == nil || s.ipv6LinkLocal.String() != ipv6LinkLocal.String() { scopedLogger := s.l.With().Str("ipv6", ipv6LinkLocal.String()).Logger() if s.ipv6LinkLocal != nil { scopedLogger.Info(). Str("old_ipv6", s.ipv6LinkLocal.String()). Msg("IPv6 link local address changed") } else { scopedLogger.Info().Msg("IPv6 link local address found") } s.ipv6LinkLocal = ipv6LinkLocal changed = true } } s.ipv6Addresses = ipv6Addresses if len(ipv6Addresses) > 0 { // compare the addresses to see if there's a change if s.ipv6Addr == nil || s.ipv6Addr.String() != ipv6Addresses[0].Address.String() { scopedLogger := s.l.With().Str("ipv6", ipv6Addresses[0].Address.String()).Logger() if s.ipv6Addr != nil { scopedLogger.Info(). Str("old_ipv6", s.ipv6Addr.String()). Msg("IPv6 address changed") } else { scopedLogger.Info().Msg("IPv6 address found") } s.ipv6Addr = &ipv6Addresses[0].Address changed = true } } // if it's the initial check, we'll set changed to false initialCheck := !s.checked if initialCheck { s.checked = true changed = false if dhcpTargetState == DhcpTargetStateRenew { // it's the initial check, we'll start the DHCP client // dhcpTargetState = DhcpTargetStateStart // TODO: manage DHCP client start/stop dhcpTargetState = DhcpTargetStateDoNothing } } if initialCheck { s.onInitialCheck(s) } else if changed { s.onStateChange(s) } return dhcpTargetState, nil } func (s *NetworkInterfaceState) CheckAndUpdateDhcp() error { dhcpTargetState, err := s.update() if err != nil { return logging.ErrorfL(s.l, "failed to update network state", err) } switch dhcpTargetState { case DhcpTargetStateRenew: s.l.Info().Msg("renewing DHCP lease") _ = s.dhcpClient.Renew() case DhcpTargetStateRelease: s.l.Info().Msg("releasing DHCP lease") _ = s.dhcpClient.Release() case DhcpTargetStateStart: s.l.Warn().Msg("dhcpTargetStateStart not implemented") case DhcpTargetStateStop: s.l.Warn().Msg("dhcpTargetStateStop not implemented") } return nil } func (s *NetworkInterfaceState) onConfigChange(config *NetworkConfig) { _ = s.setHostnameIfNotSame() s.cbConfigChange(config) } // clearIPv4Addresses removes all IPv4 addresses from the interface. func (s *NetworkInterfaceState) clearIPv4Addresses() error { iface, err := netlink.LinkByName(s.interfaceName) if err != nil { return err } addrs, err := netlinkAddrs(iface) if err != nil { return err } for _, addr := range addrs { if addr.IP.To4() != nil { if err := netlink.AddrDel(iface, &addr); err != nil { s.l.Warn().Err(err).Str("addr", addr.IPNet.String()).Msg("failed to delete IPv4 address") } } } return nil } // clearDefaultIPv4Route removes existing default IPv4 route on this interface. func (s *NetworkInterfaceState) clearDefaultIPv4Route() error { iface, err := netlink.LinkByName(s.interfaceName) if err != nil { return err } routes, err := netlink.RouteList(iface, netlink.FAMILY_V4) if err != nil { return err } for _, r := range routes { if r.Dst == nil { // default route if err := netlink.RouteDel(&r); err != nil { s.l.Warn().Err(err).Msg("failed to delete default route") } } } return nil } // parseIPv4Mask converts dotted decimal netmask to net.IPMask. func parseIPv4Mask(mask string) (net.IPMask, error) { parts := strings.Split(strings.TrimSpace(mask), ".") if len(parts) != 4 { return nil, fmt.Errorf("invalid netmask: %s", mask) } bytes := make([]byte, 4) for i := 0; i < 4; i++ { v, err := strconv.Atoi(parts[i]) if err != nil || v < 0 || v > 255 { return nil, fmt.Errorf("invalid netmask octet: %s", parts[i]) } bytes[i] = byte(v) } return net.IPv4Mask(bytes[0], bytes[1], bytes[2], bytes[3]), nil } // writeResolvConf writes DNS servers and optional domain into /etc/resolv.conf. func (s *NetworkInterfaceState) writeResolvConf(dns []string, domain string) error { var b strings.Builder if domain != "" { b.WriteString("search ") b.WriteString(domain) b.WriteString("\n") } for _, d := range dns { d = strings.TrimSpace(d) if d == "" { continue } b.WriteString("nameserver ") b.WriteString(d) b.WriteString("\n") } content := b.String() if content == "" { return nil } if err := os.WriteFile("/etc/resolv.conf", []byte(content), 0644); err != nil { return err } s.l.Info().Msg("updated /etc/resolv.conf for static IPv4") return nil } // applyIPv4Static sets a static IPv4 address, default route, and DNS. func (s *NetworkInterfaceState) applyIPv4Static() error { if s.config == nil || s.config.IPv4Static == nil { return fmt.Errorf("IPv4Static config not provided") } ipStr := strings.TrimSpace(s.config.IPv4Static.Address.String) maskStr := strings.TrimSpace(s.config.IPv4Static.Netmask.String) gwStr := strings.TrimSpace(s.config.IPv4Static.Gateway.String) dns := s.config.IPv4Static.DNS ip := net.ParseIP(ipStr) if ip == nil || ip.To4() == nil { return fmt.Errorf("invalid IPv4 address: %s", ipStr) } mask, err := parseIPv4Mask(maskStr) if err != nil { return err } iface, err := netlink.LinkByName(s.interfaceName) if err != nil { return err } // Clear existing IPv4 addresses and default route if err := s.clearIPv4Addresses(); err != nil { s.l.Warn().Err(err).Msg("failed clearing IPv4 addresses prior to static apply") } if err := s.clearDefaultIPv4Route(); err != nil { s.l.Warn().Err(err).Msg("failed clearing default route prior to static apply") } ipNet := &net.IPNet{IP: ip, Mask: mask} addr := &netlink.Addr{IPNet: ipNet} if err := netlink.AddrAdd(iface, addr); err != nil { return logging.ErrorfL(s.l, "failed to add static IPv4 address", err) } s.l.Info().Str("ipv4", ipNet.String()).Msg("static IPv4 address applied") // Default route if gwStr != "" { gw := net.ParseIP(gwStr) if gw == nil || gw.To4() == nil { s.l.Warn().Str("gateway", gwStr).Msg("invalid IPv4 gateway; skipping route") } else { route := netlink.Route{LinkIndex: iface.Attrs().Index, Gw: gw} // remove any existing default routes already attempted above, then add if err := netlink.RouteAdd(&route); err != nil { // try replace if add failed if replaceErr := netlink.RouteReplace(&route); replaceErr != nil { s.l.Warn().Err(err).Msg("failed to add default route") } } s.l.Info().Str("gateway", gwStr).Msg("default route applied") } } // DNS if len(dns) > 0 { if err := s.writeResolvConf(dns, s.GetDomain()); err != nil { s.l.Warn().Err(err).Msg("failed to write resolv.conf") } } // Refresh internal state if _, err := s.update(); err != nil { s.l.Warn().Err(err).Msg("failed to refresh state after static apply") } return nil }