monitor link state using netlink

This commit is contained in:
Siyuan 2025-10-07 17:18:20 +00:00
parent df0f5efff3
commit 456ee66fc2
20 changed files with 469 additions and 445 deletions

View File

@ -1,127 +0,0 @@
package network
// import (
// "fmt"
// "time"
// "github.com/jetkvm/kvm/internal/confparser"
// "github.com/jetkvm/kvm/internal/network/types"
// "github.com/jetkvm/kvm/internal/udhcpc"
// )
// type RpcIPv6Address struct {
// Address string `json:"address"`
// ValidLifetime *time.Time `json:"valid_lifetime,omitempty"`
// PreferredLifetime *time.Time `json:"preferred_lifetime,omitempty"`
// Scope int `json:"scope"`
// }
// type RpcNetworkState struct {
// InterfaceName string `json:"interface_name"`
// MacAddress string `json:"mac_address"`
// IPv4 string `json:"ipv4,omitempty"`
// IPv6 string `json:"ipv6,omitempty"`
// IPv6LinkLocal string `json:"ipv6_link_local,omitempty"`
// IPv4Addresses []string `json:"ipv4_addresses,omitempty"`
// IPv6Addresses []RpcIPv6Address `json:"ipv6_addresses,omitempty"`
// DHCPLease *udhcpc.Lease `json:"dhcp_lease,omitempty"`
// }
// type RpcNetworkSettings struct {
// NetworkConfig types.NetworkConfig
// }
// // func (s *NetworkInterfaceState) MacAddress() string {
// // if s.macAddr == nil {
// // return ""
// // }
// // return s.macAddr.String()
// // }
// // func (s *NetworkInterfaceState) IPv4Address() string {
// // if s.ipv4Addr == nil {
// // return ""
// // }
// // return s.ipv4Addr.String()
// // }
// // func (s *NetworkInterfaceState) IPv6Address() string {
// // if s.ipv6Addr == nil {
// // return ""
// // }
// // return s.ipv6Addr.String()
// // }
// // func (s *NetworkInterfaceState) IPv6LinkLocalAddress() string {
// // if s.ipv6LinkLocal == nil {
// // return ""
// // }
// // return s.ipv6LinkLocal.String()
// // }
// func (s *NetworkInterfaceState) RpcGetNetworkState() RpcNetworkState {
// ipv6Addresses := make([]RpcIPv6Address, 0)
// if s.ipv6Addresses != nil && s.config.IPv6Mode.String != "disabled" {
// for _, addr := range s.ipv6Addresses {
// ipv6Addresses = append(ipv6Addresses, RpcIPv6Address{
// Address: addr.Prefix.String(),
// ValidLifetime: addr.ValidLifetime,
// PreferredLifetime: addr.PreferredLifetime,
// Scope: addr.Scope,
// })
// }
// }
// return RpcNetworkState{
// InterfaceName: s.interfaceName,
// MacAddress: s.MacAddress(),
// IPv4: s.IPv4Address(),
// IPv6: s.IPv6Address(),
// IPv6LinkLocal: s.IPv6LinkLocalAddress(),
// IPv4Addresses: s.ipv4Addresses,
// IPv6Addresses: ipv6Addresses,
// DHCPLease: s.dhcpClient.GetLease(),
// }
// }
// func (s *NetworkInterfaceState) RpcGetNetworkSettings() RpcNetworkSettings {
// if s.config == nil {
// return RpcNetworkSettings{}
// }
// return RpcNetworkSettings{
// NetworkConfig: *s.config,
// }
// }
// func (s *NetworkInterfaceState) RpcSetNetworkSettings(settings RpcNetworkSettings) error {
// currentSettings := s.config
// err := confparser.SetDefaultsAndValidate(&settings.NetworkConfig)
// if err != nil {
// return err
// }
// if IsSame(currentSettings, settings.NetworkConfig) {
// // no changes, do nothing
// return nil
// }
// s.config = &settings.NetworkConfig
// s.onConfigChange(s.config)
// return nil
// }
// func (s *NetworkInterfaceState) RpcRenewDHCPLease() error {
// if s.dhcpClient == nil {
// return fmt.Errorf("dhcp client not initialized")
// }
// return s.dhcpClient.Renew()
// }

View File

@ -1,26 +0,0 @@
package network
import (
"encoding/json"
"time"
)
func lifetimeToTime(lifetime int) *time.Time {
if lifetime == 0 {
return nil
}
t := time.Now().Add(time.Duration(lifetime) * time.Second)
return &t
}
func IsSame(a, b any) bool {
aJSON, err := json.Marshal(a)
if err != nil {
return false
}
bJSON, err := json.Marshal(b)
if err != nil {
return false
}
return string(aJSON) == string(bJSON)
}

View File

@ -1,186 +0,0 @@
package udhcpc
import (
"bufio"
"encoding/json"
"fmt"
"net"
"os"
"reflect"
"strconv"
"strings"
"time"
)
type Lease struct {
// from https://udhcp.busybox.net/README.udhcpc
IPAddress net.IP `env:"ip" json:"ip"` // The obtained IP
Netmask net.IP `env:"subnet" json:"netmask"` // The assigned subnet mask
Broadcast net.IP `env:"broadcast" json:"broadcast"` // The broadcast address for this network
TTL int `env:"ipttl" json:"ttl,omitempty"` // The TTL to use for this network
MTU int `env:"mtu" json:"mtu,omitempty"` // The MTU to use for this network
HostName string `env:"hostname" json:"hostname,omitempty"` // The assigned hostname
Domain string `env:"domain" json:"domain,omitempty"` // The domain name of the network
BootPNextServer net.IP `env:"siaddr" json:"bootp_next_server,omitempty"` // The bootp next server option
BootPServerName string `env:"sname" json:"bootp_server_name,omitempty"` // The bootp server name option
BootPFile string `env:"boot_file" json:"bootp_file,omitempty"` // The bootp boot file option
Timezone string `env:"timezone" json:"timezone,omitempty"` // Offset in seconds from UTC
Routers []net.IP `env:"router" json:"routers,omitempty"` // A list of routers
DNS []net.IP `env:"dns" json:"dns_servers,omitempty"` // A list of DNS servers
NTPServers []net.IP `env:"ntpsrv" json:"ntp_servers,omitempty"` // A list of NTP servers
LPRServers []net.IP `env:"lprsvr" json:"lpr_servers,omitempty"` // A list of LPR servers
TimeServers []net.IP `env:"timesvr" json:"_time_servers,omitempty"` // A list of time servers (obsolete)
IEN116NameServers []net.IP `env:"namesvr" json:"_name_servers,omitempty"` // A list of IEN 116 name servers (obsolete)
LogServers []net.IP `env:"logsvr" json:"_log_servers,omitempty"` // A list of MIT-LCS UDP log servers (obsolete)
CookieServers []net.IP `env:"cookiesvr" json:"_cookie_servers,omitempty"` // A list of RFC 865 cookie servers (obsolete)
WINSServers []net.IP `env:"wins" json:"_wins_servers,omitempty"` // A list of WINS servers
SwapServer net.IP `env:"swapsvr" json:"_swap_server,omitempty"` // The IP address of the client's swap server
BootSize int `env:"bootsize" json:"bootsize,omitempty"` // The length in 512 octect blocks of the bootfile
RootPath string `env:"rootpath" json:"root_path,omitempty"` // The path name of the client's root disk
LeaseTime time.Duration `env:"lease" json:"lease,omitempty"` // The lease time, in seconds
DHCPType string `env:"dhcptype" json:"dhcp_type,omitempty"` // DHCP message type (safely ignored)
ServerID string `env:"serverid" json:"server_id,omitempty"` // The IP of the server
Message string `env:"message" json:"reason,omitempty"` // Reason for a DHCPNAK
TFTPServerName string `env:"tftp" json:"tftp,omitempty"` // The TFTP server name
BootFileName string `env:"bootfile" json:"bootfile,omitempty"` // The boot file name
Uptime time.Duration `env:"uptime" json:"uptime,omitempty"` // The uptime of the device when the lease was obtained, in seconds
LeaseExpiry *time.Time `json:"lease_expiry,omitempty"` // The expiry time of the lease
isEmpty map[string]bool
}
func (l *Lease) setIsEmpty(m map[string]bool) {
l.isEmpty = m
}
func (l *Lease) IsEmpty(key string) bool {
return l.isEmpty[key]
}
func (l *Lease) ToJSON() string {
json, err := json.Marshal(l)
if err != nil {
return ""
}
return string(json)
}
func (l *Lease) SetLeaseExpiry() (time.Time, error) {
if l.Uptime == 0 || l.LeaseTime == 0 {
return time.Time{}, fmt.Errorf("uptime or lease time isn't set")
}
// get the uptime of the device
file, err := os.Open("/proc/uptime")
if err != nil {
return time.Time{}, fmt.Errorf("failed to open uptime file: %w", err)
}
defer file.Close()
var uptime time.Duration
scanner := bufio.NewScanner(file)
for scanner.Scan() {
text := scanner.Text()
parts := strings.Split(text, " ")
uptime, err = time.ParseDuration(parts[0] + "s")
if err != nil {
return time.Time{}, fmt.Errorf("failed to parse uptime: %w", err)
}
}
relativeLeaseRemaining := (l.Uptime + l.LeaseTime) - uptime
leaseExpiry := time.Now().Add(relativeLeaseRemaining)
l.LeaseExpiry = &leaseExpiry
return leaseExpiry, nil
}
func UnmarshalDHCPCLease(lease *Lease, str string) error {
// parse the lease file as a map
data := make(map[string]string)
for line := range strings.SplitSeq(str, "\n") {
line = strings.TrimSpace(line)
// skip empty lines and comments
if line == "" || strings.HasPrefix(line, "#") {
continue
}
parts := strings.SplitN(line, "=", 2)
if len(parts) != 2 {
continue
}
key := strings.TrimSpace(parts[0])
value := strings.TrimSpace(parts[1])
data[key] = value
}
// now iterate over the lease struct and set the values
leaseType := reflect.TypeOf(lease).Elem()
leaseValue := reflect.ValueOf(lease).Elem()
valuesParsed := make(map[string]bool)
for i := 0; i < leaseType.NumField(); i++ {
field := leaseValue.Field(i)
// get the env tag
key := leaseType.Field(i).Tag.Get("env")
if key == "" {
continue
}
valuesParsed[key] = false
// get the value from the data map
value, ok := data[key]
if !ok || value == "" {
continue
}
switch field.Interface().(type) {
case string:
field.SetString(value)
case int:
val, err := strconv.Atoi(value)
if err != nil {
continue
}
field.SetInt(int64(val))
case time.Duration:
val, err := time.ParseDuration(value + "s")
if err != nil {
continue
}
field.Set(reflect.ValueOf(val))
case net.IP:
ip := net.ParseIP(value)
if ip == nil {
continue
}
field.Set(reflect.ValueOf(ip))
case []net.IP:
val := make([]net.IP, 0)
for ipStr := range strings.FieldsSeq(value) {
ip := net.ParseIP(ipStr)
if ip == nil {
continue
}
val = append(val, ip)
}
field.Set(reflect.ValueOf(val))
default:
return fmt.Errorf("unsupported field `%s` type: %s", key, field.Type().String())
}
valuesParsed[key] = true
}
lease.setIsEmpty(valuesParsed)
return nil
}

View File

@ -6,7 +6,7 @@ import (
"fmt" "fmt"
"github.com/jetkvm/kvm/internal/network/types" "github.com/jetkvm/kvm/internal/network/types"
"github.com/jetkvm/kvm/pkg/nmlite/dhclient" "github.com/jetkvm/kvm/pkg/nmlite/jetdhcpc"
"github.com/rs/zerolog" "github.com/rs/zerolog"
"github.com/vishvananda/netlink" "github.com/vishvananda/netlink"
) )
@ -16,7 +16,7 @@ type DHCPClient struct {
ctx context.Context ctx context.Context
ifaceName string ifaceName string
logger *zerolog.Logger logger *zerolog.Logger
client *dhclient.Client client types.DHCPClient
link netlink.Link link netlink.Link
// Configuration // Configuration
@ -40,9 +40,6 @@ func NewDHCPClient(ctx context.Context, ifaceName string, logger *zerolog.Logger
return nil, fmt.Errorf("logger cannot be nil") return nil, fmt.Errorf("logger cannot be nil")
} }
// Create state manager
// stateManager := NewDHCPStateManager("", logger)
return &DHCPClient{ return &DHCPClient{
ctx: ctx, ctx: ctx,
ifaceName: ifaceName, ifaceName: ifaceName,
@ -81,13 +78,13 @@ func (dc *DHCPClient) Start() error {
dc.logger.Info().Msg("starting DHCP client") dc.logger.Info().Msg("starting DHCP client")
// Create the underlying DHCP client // Create the underlying DHCP client
client, err := dhclient.NewClient(dc.ctx, []string{dc.ifaceName}, &dhclient.Config{ client, err := jetdhcpc.NewClient(dc.ctx, []string{dc.ifaceName}, &jetdhcpc.Config{
IPv4: dc.ipv4Enabled, IPv4: dc.ipv4Enabled,
IPv6: dc.ipv6Enabled, IPv6: dc.ipv6Enabled,
OnLease4Change: func(lease *dhclient.Lease) { OnLease4Change: func(lease *types.DHCPLease) {
dc.handleLeaseChange(lease, false) dc.handleLeaseChange(lease, false)
}, },
OnLease6Change: func(lease *dhclient.Lease) { OnLease6Change: func(lease *types.DHCPLease) {
dc.handleLeaseChange(lease, true) dc.handleLeaseChange(lease, true)
}, },
UpdateResolvConf: func(nameservers []string) error { UpdateResolvConf: func(nameservers []string) error {
@ -115,6 +112,27 @@ func (dc *DHCPClient) Start() error {
return nil return nil
} }
func (dc *DHCPClient) Domain() string {
if dc.client == nil {
return ""
}
return dc.client.Domain()
}
func (dc *DHCPClient) Lease4() *types.DHCPLease {
if dc.client == nil {
return nil
}
return dc.client.Lease4()
}
func (dc *DHCPClient) Lease6() *types.DHCPLease {
if dc.client == nil {
return nil
}
return dc.client.Lease6()
}
// Stop stops the DHCP client // Stop stops the DHCP client
func (dc *DHCPClient) Stop() error { func (dc *DHCPClient) Stop() error {
if dc.client == nil { if dc.client == nil {
@ -150,62 +168,19 @@ func (dc *DHCPClient) Release() error {
return nil return nil
} }
// GetLease4 returns the current IPv4 lease
func (dc *DHCPClient) GetLease4() *types.DHCPLease {
if dc.client == nil {
return nil
}
lease := dc.client.Lease4()
if lease == nil {
return nil
}
return dc.convertLease(lease, false)
}
// GetLease6 returns the current IPv6 lease
func (dc *DHCPClient) GetLease6() *types.DHCPLease {
if dc.client == nil {
return nil
}
lease := dc.client.Lease6()
if lease == nil {
return nil
}
return dc.convertLease(lease, true)
}
// handleLeaseChange handles lease changes from the underlying DHCP client // handleLeaseChange handles lease changes from the underlying DHCP client
func (dc *DHCPClient) handleLeaseChange(lease *dhclient.Lease, isIPv6 bool) { func (dc *DHCPClient) handleLeaseChange(lease *types.DHCPLease, isIPv6 bool) {
if lease == nil { if lease == nil {
return return
} }
convertedLease := dc.convertLease(lease, isIPv6)
if convertedLease == nil {
dc.logger.Error().Msg("failed to convert lease")
return
}
dc.logger.Info(). dc.logger.Info().
Bool("ipv6", isIPv6). Bool("ipv6", isIPv6).
Str("ip", convertedLease.IPAddress.String()). Str("ip", lease.IPAddress.String()).
Msg("DHCP lease changed") Msg("DHCP lease changed")
// Notify callback // Notify callback
if dc.onLeaseChange != nil { if dc.onLeaseChange != nil {
dc.onLeaseChange(convertedLease) dc.onLeaseChange(lease)
} }
} }
// convertLease converts a dhclient.Lease to types.DHCPLease
func (dc *DHCPClient) convertLease(lease *dhclient.Lease, isIPv6 bool) *types.DHCPLease {
if lease == nil {
return nil
}
return lease.ToDHCPLease()
}

View File

@ -22,6 +22,7 @@ type InterfaceManager struct {
config *types.NetworkConfig config *types.NetworkConfig
logger *zerolog.Logger logger *zerolog.Logger
state *types.InterfaceState state *types.InterfaceState
linkState *link.Link
stateMu sync.RWMutex stateMu sync.RWMutex
// Network components // Network components
@ -107,6 +108,14 @@ func (im *InterfaceManager) Start() error {
im.wg.Add(1) im.wg.Add(1)
go im.monitorInterfaceState() go im.monitorInterfaceState()
nl := getNetlinkManager()
nl.AddLinkStateCallback(im.ifaceName, link.LinkStateCallback{
Async: true,
Func: func(link *link.Link) {
im.handleLinkStateChange(link)
},
})
// Apply initial configuration // Apply initial configuration
if err := im.applyConfiguration(); err != nil { if err := im.applyConfiguration(); err != nil {
im.logger.Error().Err(err).Msg("failed to apply initial configuration") im.logger.Error().Err(err).Msg("failed to apply initial configuration")
@ -189,6 +198,10 @@ func (im *InterfaceManager) GetConfig() *types.NetworkConfig {
return &config return &config
} }
func (im *InterfaceManager) ApplyConfiguration() error {
return im.applyConfiguration()
}
// SetConfig updates the interface configuration // SetConfig updates the interface configuration
func (im *InterfaceManager) SetConfig(config *types.NetworkConfig) error { func (im *InterfaceManager) SetConfig(config *types.NetworkConfig) error {
if config == nil { if config == nil {
@ -446,7 +459,7 @@ func (im *InterfaceManager) getDomain() string {
// Try to get domain from DHCP lease // Try to get domain from DHCP lease
if im.dhcpClient != nil { if im.dhcpClient != nil {
if lease := im.dhcpClient.GetLease4(); lease != nil && lease.Domain != "" { if lease := im.dhcpClient.Lease4(); lease != nil && lease.Domain != "" {
return lease.Domain return lease.Domain
} }
} }
@ -454,6 +467,51 @@ func (im *InterfaceManager) getDomain() string {
return "local" return "local"
} }
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()
}
}
func (im *InterfaceManager) handleLinkUp() {
im.logger.Info().Msg("link up")
im.applyConfiguration()
}
func (im *InterfaceManager) handleLinkDown() {
im.logger.Info().Msg("link down")
if im.config.IPv4Mode.String == "dhcp" {
im.dhcpClient.Stop()
}
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 // monitorInterfaceState monitors the interface state and updates accordingly
func (im *InterfaceManager) monitorInterfaceState() { func (im *InterfaceManager) monitorInterfaceState() {
defer im.wg.Done() defer im.wg.Done()
@ -477,6 +535,7 @@ func (im *InterfaceManager) monitorInterfaceState() {
} }
} }
} }
} }
// updateInterfaceState updates the current interface state // updateInterfaceState updates the current interface state

View File

@ -1,4 +1,4 @@
package dhclient package jetdhcpc
import ( import (
"context" "context"
@ -12,6 +12,7 @@ import (
"github.com/go-co-op/gocron/v2" "github.com/go-co-op/gocron/v2"
"github.com/insomniacslk/dhcp/dhcpv4" "github.com/insomniacslk/dhcp/dhcpv4"
"github.com/insomniacslk/dhcp/dhcpv6" "github.com/insomniacslk/dhcp/dhcpv6"
"github.com/jetkvm/kvm/internal/network/types"
"github.com/jetkvm/kvm/pkg/nmlite/link" "github.com/jetkvm/kvm/pkg/nmlite/link"
"github.com/rs/zerolog" "github.com/rs/zerolog"
) )
@ -27,7 +28,7 @@ var (
ErrInterfaceUpCanceled = errors.New("context canceled while waiting for an interface to come up") ErrInterfaceUpCanceled = errors.New("context canceled while waiting for an interface to come up")
) )
type LeaseChangeHandler func(lease *Lease) type LeaseChangeHandler func(lease *types.DHCPLease)
// Config is a DHCP client configuration. // Config is a DHCP client configuration.
type Config struct { type Config struct {
@ -81,6 +82,7 @@ type Config struct {
} }
type Client struct { type Client struct {
types.DHCPClient
ifaces []string ifaces []string
cfg Config cfg Config
l *zerolog.Logger l *zerolog.Logger
@ -153,6 +155,34 @@ func (c *Client) sendInitialRequests() chan interface{} {
return c.sendRequests(c.cfg.IPv4, c.cfg.IPv6) return c.sendRequests(c.cfg.IPv4, c.cfg.IPv6)
} }
func (c *Client) sendRequestsFamily(
family int,
wg *sync.WaitGroup,
r *chan interface{},
l *zerolog.Logger,
iface *link.Link,
) {
wg.Add(1)
go func(iface *link.Link) {
defer wg.Done()
var (
lease *Lease
err error
)
switch family {
case link.AfInet:
lease, err = c.requestLease4(iface)
case link.AfInet6:
lease, err = c.requestLease6(iface)
}
if err != nil {
l.Error().Err(err).Msg("Could not get lease")
return
}
(*r) <- lease
}(iface)
}
func (c *Client) sendRequests(ipv4, ipv6 bool) chan interface{} { func (c *Client) sendRequests(ipv4, ipv6 bool) chan interface{} {
c.mu.Lock() c.mu.Lock()
defer c.mu.Unlock() defer c.mu.Unlock()
@ -175,30 +205,11 @@ func (c *Client) sendRequests(ipv4, ipv6 bool) chan interface{} {
} }
if ipv4 { if ipv4 {
wg.Add(1) c.sendRequestsFamily(link.AfInet, &wg, &r, &l, iface)
go func(iface *link.Link) {
defer wg.Done()
lease, err := c.requestLease4(iface)
if err != nil {
l.Error().Err(err).Msg("Could not get IPv4 lease")
return
}
r <- lease
}(iface)
} }
if ipv6 { if ipv6 {
return // TODO: implement DHCP6 c.sendRequestsFamily(link.AfInet6, &wg, &r, &l, iface)
wg.Add(1)
go func(iface *link.Link) {
defer wg.Done()
lease, err := c.requestLease6(iface)
if err != nil {
l.Error().Err(err).Msg("Could not get IPv6 lease")
return
}
r <- lease
}(iface)
} }
}(iface) }(iface)
} }
@ -210,18 +221,26 @@ func (c *Client) sendRequests(ipv4, ipv6 bool) chan interface{} {
return r return r
} }
func (c *Client) Lease4() *Lease { func (c *Client) Lease4() *types.DHCPLease {
c.lease4Mu.Lock() c.lease4Mu.Lock()
defer c.lease4Mu.Unlock() defer c.lease4Mu.Unlock()
return c.currentLease4 if c.currentLease4 == nil {
return nil
}
return c.currentLease4.ToDHCPLease()
} }
func (c *Client) Lease6() *Lease { func (c *Client) Lease6() *types.DHCPLease {
c.lease6Mu.Lock() c.lease6Mu.Lock()
defer c.lease6Mu.Unlock() defer c.lease6Mu.Unlock()
return c.currentLease6 if c.currentLease6 == nil {
return nil
}
return c.currentLease6.ToDHCPLease()
} }
func (c *Client) Domain() string { func (c *Client) Domain() string {
@ -288,11 +307,11 @@ func (c *Client) handleLeaseChange(lease *Lease) {
// TODO: handle lease expiration // TODO: handle lease expiration
if c.cfg.OnLease4Change != nil && ipv4 { if c.cfg.OnLease4Change != nil && ipv4 {
c.cfg.OnLease4Change(lease) c.cfg.OnLease4Change(lease.ToDHCPLease())
} }
if c.cfg.OnLease6Change != nil && !ipv4 { if c.cfg.OnLease6Change != nil && !ipv4 {
c.cfg.OnLease6Change(lease) c.cfg.OnLease6Change(lease.ToDHCPLease())
} }
} }
@ -304,12 +323,14 @@ func (c *Client) renew() {
} }
} }
func (c *Client) Renew() { func (c *Client) Renew() error {
go c.renew() go c.renew()
return nil
} }
func (c *Client) Release() { func (c *Client) Release() error {
// TODO: implement // TODO: implement
return nil
} }
func (c *Client) SetIPv4(ipv4 bool) { func (c *Client) SetIPv4(ipv4 bool) {

View File

@ -1,4 +1,4 @@
package dhclient package jetdhcpc
import ( import (
"github.com/insomniacslk/dhcp/dhcpv4" "github.com/insomniacslk/dhcp/dhcpv4"

View File

@ -1,4 +1,4 @@
package dhclient package jetdhcpc
import ( import (
"log" "log"

View File

@ -1,4 +1,4 @@
package dhclient package jetdhcpc
import ( import (
"bufio" "bufio"

View File

@ -1,4 +1,4 @@
package dhclient package jetdhcpc
import ( import (
"bytes" "bytes"

View File

@ -1,4 +1,4 @@
package dhclient package jetdhcpc
import ( import (
"github.com/insomniacslk/dhcp/dhcpv4" "github.com/insomniacslk/dhcp/dhcpv4"

View File

@ -1,5 +1,4 @@
// Package nmlite provides DHCP state persistence for the network manager. package jetdhcpc
package dhclient
import ( import (
"encoding/json" "encoding/json"

View File

@ -1,4 +1,4 @@
package dhclient package jetdhcpc
import ( import (
"context" "context"

View File

@ -50,10 +50,18 @@ var (
ErrInterfaceUpCanceled = errors.New("context canceled while waiting for an interface to come up") ErrInterfaceUpCanceled = errors.New("context canceled while waiting for an interface to come up")
) )
type LinkStateCallbackFunction func(link *Link)
type LinkStateCallback struct {
Async bool
Func LinkStateCallbackFunction
}
// NetlinkManager provides centralized netlink operations // NetlinkManager provides centralized netlink operations
type NetlinkManager struct { type NetlinkManager struct {
logger *zerolog.Logger logger *zerolog.Logger
mu sync.RWMutex linkStateCh chan netlink.LinkUpdate
mu sync.RWMutex
linkStateCallbacks map[string][]LinkStateCallback
} }
// Link is a wrapper around netlink.Link // Link is a wrapper around netlink.Link
@ -70,12 +78,44 @@ func (l *Link) AddrList(family int) ([]netlink.Addr, error) {
return netlink.AddrList(l, family) return netlink.AddrList(l, family)
} }
func (l *Link) IsSame(other *Link) bool {
if l == nil || other == nil {
return false
}
a := l.Attrs()
b := other.Attrs()
if a.OperState != b.OperState {
return false
}
if a.Index != b.Index {
return false
}
if a.MTU != b.MTU {
return false
}
if a.HardwareAddr.String() != b.HardwareAddr.String() {
return false
}
return true
}
func newNetlinkManager(logger *zerolog.Logger) *NetlinkManager {
if logger == nil {
logger = &zerolog.Logger{} // Default no-op logger
}
n := &NetlinkManager{
logger: logger,
linkStateCallbacks: make(map[string][]LinkStateCallback),
}
n.monitorLinkState()
return n
}
// GetNetlinkManager returns the singleton NetlinkManager instance // GetNetlinkManager returns the singleton NetlinkManager instance
func GetNetlinkManager() *NetlinkManager { func GetNetlinkManager() *NetlinkManager {
netlinkManagerOnce.Do(func() { netlinkManagerOnce.Do(func() {
netlinkManagerInstance = &NetlinkManager{ netlinkManagerInstance = newNetlinkManager(nil)
logger: &zerolog.Logger{}, // Default no-op logger
}
}) })
return netlinkManagerInstance return netlinkManagerInstance
} }
@ -83,18 +123,56 @@ func GetNetlinkManager() *NetlinkManager {
// InitializeNetlinkManager initializes the singleton NetlinkManager with a logger // InitializeNetlinkManager initializes the singleton NetlinkManager with a logger
func InitializeNetlinkManager(logger *zerolog.Logger) *NetlinkManager { func InitializeNetlinkManager(logger *zerolog.Logger) *NetlinkManager {
netlinkManagerOnce.Do(func() { netlinkManagerOnce.Do(func() {
if logger == nil { netlinkManagerInstance = newNetlinkManager(logger)
// Create a no-op logger if none provided
logger = &zerolog.Logger{}
}
netlinkManagerInstance = &NetlinkManager{
logger: logger,
}
}) })
return netlinkManagerInstance return netlinkManagerInstance
} }
func (nm *NetlinkManager) runCallbacks(update netlink.LinkUpdate) {
nm.mu.RLock()
defer nm.mu.RUnlock()
ifname := update.Link.Attrs().Name
callbacks, ok := nm.linkStateCallbacks[ifname]
l := nm.logger.With().Str("interface", ifname).Logger()
if !ok {
l.Trace().Msg("no callbacks for interface")
return
}
for _, callback := range callbacks {
l.Trace().Interface("callback", callback).Msg("calling callback")
if callback.Async {
go callback.Func(&Link{Link: update.Link})
} else {
callback.Func(&Link{Link: update.Link})
}
}
}
// AddLinkStateCallback adds a callback for link state changes
func (nm *NetlinkManager) AddLinkStateCallback(ifname string, callback LinkStateCallback) {
nm.mu.Lock()
defer nm.mu.Unlock()
nm.linkStateCallbacks[ifname] = append(nm.linkStateCallbacks[ifname], callback)
}
// Interface operations // Interface operations
func (nm *NetlinkManager) monitorLinkState() {
updateCh := make(chan netlink.LinkUpdate)
// we don't need to stop the subscription, as it will be closed when the program exits
stopCh := make(chan struct{}) //nolint:unused
netlink.LinkSubscribe(updateCh, stopCh)
nm.logger.Info().Msg("link state monitoring started")
go func() {
for update := range updateCh {
nm.runCallbacks(update)
}
}()
}
// GetLinkByName gets a network link by name // GetLinkByName gets a network link by name
func (nm *NetlinkManager) GetLinkByName(name string) (*Link, error) { func (nm *NetlinkManager) GetLinkByName(name string) (*Link, error) {

View File

@ -0,0 +1,35 @@
package nmlite
import (
"net"
"testing"
"github.com/stretchr/testify/assert"
)
func TestToResolvConf(t *testing.T) {
rc, err := ResolvConfManager{}.generateResolvConf(
"eth0",
[]net.IP{
net.ParseIP("198.51.100.53"),
net.ParseIP("203.0.113.53"),
},
[]string{"example.com"},
"example.com",
)
if err != nil {
t.Fatal(err)
}
want := `# the resolv.conf file is managed by the jetkvm network manager
# DO NOT EDIT THIS FILE BY HAND -- YOUR CHANGES WILL BE OVERWRITTEN
search example.com # eth0
domain example.com # eth0
nameserver 198.51.100.53 # eth0
nameserver 203.0.113.53 # eth0
`
assert.Equal(t, want, rc.String())
}

162
pkg/nmlite/udhcpc/parser.go Normal file
View File

@ -0,0 +1,162 @@
package udhcpc
import (
"bufio"
"encoding/json"
"fmt"
"net"
"os"
"reflect"
"strconv"
"strings"
"time"
"github.com/jetkvm/kvm/internal/network/types"
)
type Lease struct {
types.DHCPLease
// from https://udhcp.busybox.net/README.udhcpc
isEmpty map[string]bool
}
func (l *Lease) setIsEmpty(m map[string]bool) {
l.isEmpty = m
}
// IsEmpty returns true if the lease is empty for the given key.
func (l *Lease) IsEmpty(key string) bool {
return l.isEmpty[key]
}
// ToJSON returns the lease as a JSON string.
func (l *Lease) ToJSON() string {
json, err := json.Marshal(l)
if err != nil {
return ""
}
return string(json)
}
// SetLeaseExpiry sets the lease expiry time.
func (l *Lease) SetLeaseExpiry() (time.Time, error) {
if l.Uptime == 0 || l.LeaseTime == 0 {
return time.Time{}, fmt.Errorf("uptime or lease time isn't set")
}
// get the uptime of the device
file, err := os.Open("/proc/uptime")
if err != nil {
return time.Time{}, fmt.Errorf("failed to open uptime file: %w", err)
}
defer file.Close()
var uptime time.Duration
scanner := bufio.NewScanner(file)
for scanner.Scan() {
text := scanner.Text()
parts := strings.Split(text, " ")
uptime, err = time.ParseDuration(parts[0] + "s")
if err != nil {
return time.Time{}, fmt.Errorf("failed to parse uptime: %w", err)
}
}
relativeLeaseRemaining := (l.Uptime + l.LeaseTime) - uptime
leaseExpiry := time.Now().Add(relativeLeaseRemaining)
l.LeaseExpiry = &leaseExpiry
return leaseExpiry, nil
}
// UnmarshalDHCPCLease unmarshals a lease from a string.
func UnmarshalDHCPCLease(lease *Lease, str string) error {
// parse the lease file as a map
data := make(map[string]string)
for line := range strings.SplitSeq(str, "\n") {
line = strings.TrimSpace(line)
// skip empty lines and comments
if line == "" || strings.HasPrefix(line, "#") {
continue
}
parts := strings.SplitN(line, "=", 2)
if len(parts) != 2 {
continue
}
key := strings.TrimSpace(parts[0])
value := strings.TrimSpace(parts[1])
data[key] = value
}
// now iterate over the lease struct and set the values
leaseType := reflect.TypeOf(lease).Elem()
leaseValue := reflect.ValueOf(lease).Elem()
valuesParsed := make(map[string]bool)
for i := 0; i < leaseType.NumField(); i++ {
field := leaseValue.Field(i)
// get the env tag
key := leaseType.Field(i).Tag.Get("env")
if key == "" {
continue
}
valuesParsed[key] = false
// get the value from the data map
value, ok := data[key]
if !ok || value == "" {
continue
}
switch field.Interface().(type) {
case string:
field.SetString(value)
case int:
val, err := strconv.Atoi(value)
if err != nil {
continue
}
field.SetInt(int64(val))
case time.Duration:
val, err := time.ParseDuration(value + "s")
if err != nil {
continue
}
field.Set(reflect.ValueOf(val))
case net.IP:
ip := net.ParseIP(value)
if ip == nil {
continue
}
field.Set(reflect.ValueOf(ip))
case []net.IP:
val := make([]net.IP, 0)
for ipStr := range strings.FieldsSeq(value) {
ip := net.ParseIP(ipStr)
if ip == nil {
continue
}
val = append(val, ip)
}
field.Set(reflect.ValueOf(val))
default:
return fmt.Errorf("unsupported field `%s` type: %s", key, field.Type().String())
}
valuesParsed[key] = true
}
lease.setIsEmpty(valuesParsed)
return nil
}

View File

@ -9,6 +9,7 @@ import (
"time" "time"
"github.com/fsnotify/fsnotify" "github.com/fsnotify/fsnotify"
"github.com/jetkvm/kvm/internal/network/types"
"github.com/rs/zerolog" "github.com/rs/zerolog"
) )
@ -18,6 +19,7 @@ const (
) )
type DHCPClient struct { type DHCPClient struct {
types.DHCPClient
InterfaceName string InterfaceName string
leaseFile string leaseFile string
pidFile string pidFile string
@ -196,3 +198,35 @@ func (c *DHCPClient) loadLeaseFile() error {
func (c *DHCPClient) GetLease() *Lease { func (c *DHCPClient) GetLease() *Lease {
return c.lease return c.lease
} }
func (c *DHCPClient) Domain() string {
return c.lease.Domain
}
func (c *DHCPClient) Lease4() *Lease {
return c.lease
}
func (c *DHCPClient) Lease6() *Lease {
return c.lease
}
func (c *DHCPClient) SetIPv4(enabled bool) {
// TODO: implement
}
func (c *DHCPClient) SetIPv6(enabled bool) {
// TODO: implement
}
func (c *DHCPClient) SetOnLeaseChange(callback func(lease *Lease)) {
c.onLeaseChange = callback
}
func (c *DHCPClient) Start() error {
return c.Run() // udhcpc already has Run()
}
func (c *DHCPClient) Stop() error {
return c.KillProcess() // udhcpc already has KillProcess()
}