diff --git a/internal/network/types/type.go b/internal/network/types/config.go similarity index 61% rename from internal/network/types/type.go rename to internal/network/types/config.go index b770fcd5..364f8609 100644 --- a/internal/network/types/type.go +++ b/internal/network/types/config.go @@ -1,74 +1,12 @@ package types import ( - "net" "net/http" "net/url" - "slices" - "time" "github.com/guregu/null/v6" - "github.com/vishvananda/netlink" ) -// IPAddress represents a network interface address -type IPAddress struct { - Family int - Address net.IPNet - Gateway net.IP - MTU int - Secondary bool - Permanent bool -} - -func (a *IPAddress) String() string { - return a.Address.String() -} - -func (a *IPAddress) Compare(n netlink.Addr) bool { - if !a.Address.IP.Equal(n.IP) { - return false - } - if slices.Compare(a.Address.Mask, n.IPNet.Mask) != 0 { - return false - } - return true -} - -func (a *IPAddress) NetlinkAddr() netlink.Addr { - return netlink.Addr{ - IPNet: &a.Address, - } -} - -func (a *IPAddress) DefaultRoute(linkIndex int) netlink.Route { - return netlink.Route{ - Dst: nil, - Gw: a.Gateway, - LinkIndex: linkIndex, - } -} - -// ParsedIPConfig represents the parsed IP configuration -type ParsedIPConfig struct { - Addresses []IPAddress - Nameservers []net.IP - SearchList []string - Domain string - MTU int - Interface string -} - -// IPv6Address represents an IPv6 address with lifetime information -type IPv6Address struct { - Address net.IP `json:"address"` - Prefix net.IPNet `json:"prefix"` - ValidLifetime *time.Time `json:"valid_lifetime"` - PreferredLifetime *time.Time `json:"preferred_lifetime"` - Flags int `json:"flags"` - Scope int `json:"scope"` -} - // IPv4StaticConfig represents static IPv4 configuration type IPv4StaticConfig struct { Address null.String `json:"address,omitempty" validate_type:"ipv4" required:"true"` @@ -84,6 +22,12 @@ type IPv6StaticConfig struct { DNS []string `json:"dns,omitempty" validate_type:"ipv6" required:"true"` } +// MDNSListenOptions represents MDNS listening options +type MDNSListenOptions struct { + IPv4 bool + IPv6 bool +} + // NetworkConfig represents the complete network configuration for an interface type NetworkConfig struct { DHCPClient null.String `json:"dhcp_client,omitempty" one_of:"jetdhcpc,udhcpc" default:"jetdhcpc"` @@ -130,44 +74,18 @@ func (c *NetworkConfig) GetMDNSMode() *MDNSListenOptions { return listenOptions } -// MDNSListenOptions represents MDNS listening options -type MDNSListenOptions struct { - IPv4 bool - IPv6 bool -} - // GetTransportProxyFunc returns a function for HTTP proxy configuration func (c *NetworkConfig) GetTransportProxyFunc() func(*http.Request) (*url.URL, error) { return func(*http.Request) (*url.URL, error) { if c.HTTPProxy.String == "" { return nil, nil } else { - proxyUrl, _ := url.Parse(c.HTTPProxy.String) - return proxyUrl, nil + proxyURL, _ := url.Parse(c.HTTPProxy.String) + return proxyURL, nil } } } -// InterfaceState represents the current state of a network interface -type InterfaceState struct { - InterfaceName string `json:"interface_name"` - MACAddress string `json:"mac_address"` - Up bool `json:"up"` - Online bool `json:"online"` - IPv4Ready bool `json:"ipv4_ready"` - IPv6Ready bool `json:"ipv6_ready"` - IPv4Address string `json:"ipv4_address,omitempty"` - IPv6Address string `json:"ipv6_address,omitempty"` - IPv6LinkLocal string `json:"ipv6_link_local,omitempty"` - IPv6Gateway string `json:"ipv6_gateway,omitempty"` - IPv4Addresses []string `json:"ipv4_addresses,omitempty"` - IPv6Addresses []IPv6Address `json:"ipv6_addresses,omitempty"` - NTPServers []net.IP `json:"ntp_servers,omitempty"` - DHCPLease4 *DHCPLease `json:"dhcp_lease,omitempty"` - DHCPLease6 *DHCPLease `json:"dhcp_lease6,omitempty"` - LastUpdated time.Time `json:"last_updated"` -} - // NetworkConfig interface for backward compatibility type NetworkConfigInterface interface { InterfaceName() string diff --git a/internal/network/types/rpc.go b/internal/network/types/interface.go similarity index 57% rename from internal/network/types/rpc.go rename to internal/network/types/interface.go index 431c5100..07ae12d5 100644 --- a/internal/network/types/rpc.go +++ b/internal/network/types/interface.go @@ -1,27 +1,30 @@ package types import ( + "net" "time" "golang.org/x/sys/unix" ) -// RpcIPv6Address is the RPC representation of an IPv6 address -type RpcIPv6Address struct { - Address string `json:"address"` - Prefix string `json:"prefix"` - ValidLifetime *time.Time `json:"valid_lifetime"` - PreferredLifetime *time.Time `json:"preferred_lifetime"` - Scope int `json:"scope"` - Flags int `json:"flags"` - FlagSecondary bool `json:"flag_secondary"` - FlagPermanent bool `json:"flag_permanent"` - FlagTemporary bool `json:"flag_temporary"` - FlagStablePrivacy bool `json:"flag_stable_privacy"` - FlagDeprecated bool `json:"flag_deprecated"` - FlagOptimistic bool `json:"flag_optimistic"` - FlagDADFailed bool `json:"flag_dad_failed"` - FlagTentative bool `json:"flag_tentative"` +// InterfaceState represents the current state of a network interface +type InterfaceState struct { + InterfaceName string `json:"interface_name"` + MACAddress string `json:"mac_address"` + Up bool `json:"up"` + Online bool `json:"online"` + IPv4Ready bool `json:"ipv4_ready"` + IPv6Ready bool `json:"ipv6_ready"` + IPv4Address string `json:"ipv4_address,omitempty"` + IPv6Address string `json:"ipv6_address,omitempty"` + IPv6LinkLocal string `json:"ipv6_link_local,omitempty"` + IPv6Gateway string `json:"ipv6_gateway,omitempty"` + IPv4Addresses []string `json:"ipv4_addresses,omitempty"` + IPv6Addresses []IPv6Address `json:"ipv6_addresses,omitempty"` + NTPServers []net.IP `json:"ntp_servers,omitempty"` + DHCPLease4 *DHCPLease `json:"dhcp_lease,omitempty"` + DHCPLease6 *DHCPLease `json:"dhcp_lease6,omitempty"` + LastUpdated time.Time `json:"last_updated"` } // RpcInterfaceState is the RPC representation of an interface state diff --git a/internal/network/types/ip.go b/internal/network/types/ip.go new file mode 100644 index 00000000..293e7e3f --- /dev/null +++ b/internal/network/types/ip.go @@ -0,0 +1,85 @@ +package types + +import ( + "net" + "slices" + "time" + + "github.com/vishvananda/netlink" +) + +// IPAddress represents a network interface address +type IPAddress struct { + Family int + Address net.IPNet + Gateway net.IP + MTU int + Secondary bool + Permanent bool +} + +func (a *IPAddress) String() string { + return a.Address.String() +} + +func (a *IPAddress) Compare(n netlink.Addr) bool { + if !a.Address.IP.Equal(n.IP) { + return false + } + if slices.Compare(a.Address.Mask, n.IPNet.Mask) != 0 { + return false + } + return true +} + +func (a *IPAddress) NetlinkAddr() netlink.Addr { + return netlink.Addr{ + IPNet: &a.Address, + } +} + +func (a *IPAddress) DefaultRoute(linkIndex int) netlink.Route { + return netlink.Route{ + Dst: nil, + Gw: a.Gateway, + LinkIndex: linkIndex, + } +} + +// ParsedIPConfig represents the parsed IP configuration +type ParsedIPConfig struct { + Addresses []IPAddress + Nameservers []net.IP + SearchList []string + Domain string + MTU int + Interface string +} + +// IPv6Address represents an IPv6 address with lifetime information +type IPv6Address struct { + Address net.IP `json:"address"` + Prefix net.IPNet `json:"prefix"` + ValidLifetime *time.Time `json:"valid_lifetime"` + PreferredLifetime *time.Time `json:"preferred_lifetime"` + Flags int `json:"flags"` + Scope int `json:"scope"` +} + +// RpcIPv6Address is the RPC representation of an IPv6 address +type RpcIPv6Address struct { + Address string `json:"address"` + Prefix string `json:"prefix"` + ValidLifetime *time.Time `json:"valid_lifetime"` + PreferredLifetime *time.Time `json:"preferred_lifetime"` + Scope int `json:"scope"` + Flags int `json:"flags"` + FlagSecondary bool `json:"flag_secondary"` + FlagPermanent bool `json:"flag_permanent"` + FlagTemporary bool `json:"flag_temporary"` + FlagStablePrivacy bool `json:"flag_stable_privacy"` + FlagDeprecated bool `json:"flag_deprecated"` + FlagOptimistic bool `json:"flag_optimistic"` + FlagDADFailed bool `json:"flag_dad_failed"` + FlagTentative bool `json:"flag_tentative"` +} diff --git a/internal/network/types/resolvconf.go b/internal/network/types/resolvconf.go new file mode 100644 index 00000000..c15b96fa --- /dev/null +++ b/internal/network/types/resolvconf.go @@ -0,0 +1,22 @@ +package types + +import "net" + +// InterfaceResolvConf represents the DNS configuration for a network interface +type InterfaceResolvConf struct { + NameServers []net.IP `json:"nameservers"` + SearchList []string `json:"search_list"` + Domain string `json:"domain,omitempty"` // TODO: remove this once we have a better way to handle the domain + Source string `json:"source,omitempty"` +} + +// InterfaceResolvConfMap .. +type InterfaceResolvConfMap map[string]InterfaceResolvConf + +// ResolvConf represents the DNS configuration for the system +type ResolvConf struct { + ConfigIPv4 InterfaceResolvConfMap `json:"config_ipv4"` + ConfigIPv6 InterfaceResolvConfMap `json:"config_ipv6"` + Domain string `json:"domain"` + HostName string `json:"host_name"` +} diff --git a/network.go b/network.go index ce8f60f8..bd155f81 100644 --- a/network.go +++ b/network.go @@ -42,8 +42,8 @@ func restartMdns() { IPv6: config.NetworkConfig.MDNSMode.String != "disabled", }) _ = mDNS.SetLocalNames([]string{ - networkManager.GetHostname(), - networkManager.GetFQDN(), + networkManager.Hostname(), + networkManager.FQDN(), }, true) } @@ -54,7 +54,12 @@ func triggerTimeSyncOnNetworkStateChange() { // set the NTP servers from the network manager if networkManager != nil { - timeSync.SetDhcpNtpAddresses(networkManager.NTPServerStrings()) + ntpServers := make([]string, len(networkManager.NTPServers())) + for i, server := range networkManager.NTPServers() { + ntpServers[i] = server.String() + } + networkLogger.Info().Strs("ntpServers", ntpServers).Msg("setting NTP servers from network manager") + timeSync.SetDhcpNtpAddresses(ntpServers) } // sync time @@ -103,15 +108,32 @@ func initNetwork() error { // validate the config, if it's invalid, revert to the default config and save the backup validateNetworkConfig() - networkManager = nmlite.NewNetworkManager(context.Background(), networkLogger) - networkManager.SetOnInterfaceStateChange(networkStateChanged) - if err := networkManager.AddInterface(NetIfName, config.NetworkConfig); err != nil { + nc := config.NetworkConfig + + nm := nmlite.NewNetworkManager(context.Background(), networkLogger) + _ = setHostname(nm, nc.Hostname.String, nc.Domain.String) + nm.SetOnInterfaceStateChange(networkStateChanged) + if err := nm.AddInterface(NetIfName, nc); err != nil { return fmt.Errorf("failed to add interface: %w", err) } + networkManager = nm + return nil } +func setHostname(nm *nmlite.NetworkManager, hostname, domain string) error { + if nm == nil { + return nil + } + + if hostname == "" { + hostname = GetDefaultHostname() + } + + return nm.SetHostname(hostname, domain) +} + func rpcGetNetworkState() *types.RpcInterfaceState { state, _ := networkManager.GetInterfaceState(NetIfName) return state.ToRpcInterfaceState() @@ -131,6 +153,8 @@ func rpcSetNetworkSettings(settings RpcNetworkSettings) (*RpcNetworkSettings, er l.Debug().Msg("setting new config") + _ = setHostname(networkManager, netConfig.Hostname.String, netConfig.Domain.String) + s := networkManager.SetInterfaceConfig(NetIfName, netConfig) if s != nil { return nil, s diff --git a/pkg/nmlite/dhcp.go b/pkg/nmlite/dhcp.go index 000633f0..fb50d986 100644 --- a/pkg/nmlite/dhcp.go +++ b/pkg/nmlite/dhcp.go @@ -81,8 +81,9 @@ func (dc *DHCPClient) initClient() (types.DHCPClient, error) { func (dc *DHCPClient) initJetDHCPC() (types.DHCPClient, error) { return jetdhcpc.NewClient(dc.ctx, []string{dc.ifaceName}, &jetdhcpc.Config{ - IPv4: dc.ipv4Enabled, - IPv6: dc.ipv6Enabled, + IPv4: dc.ipv4Enabled, + IPv6: dc.ipv6Enabled, + V4ClientIdentifier: true, OnLease4Change: func(lease *types.DHCPLease) { dc.handleLeaseChange(lease, false) }, diff --git a/pkg/nmlite/hostname.go b/pkg/nmlite/hostname.go index 812fa533..5d220176 100644 --- a/pkg/nmlite/hostname.go +++ b/pkg/nmlite/hostname.go @@ -7,9 +7,6 @@ import ( "os/exec" "strings" - "github.com/jetkvm/kvm/internal/sync" - - "github.com/rs/zerolog" "golang.org/x/net/idna" ) @@ -18,38 +15,89 @@ const ( hostsPath = "/etc/hosts" ) -// HostnameManager manages system hostname and /etc/hosts -type HostnameManager struct { - logger *zerolog.Logger - mu sync.Mutex -} - -// NewHostnameManager creates a new hostname manager -func NewHostnameManager(logger *zerolog.Logger) *HostnameManager { - if logger == nil { - // Create a no-op logger if none provided - logger = &zerolog.Logger{} - } - - return &HostnameManager{ - logger: logger, - } -} - // SetHostname sets the system hostname and updates /etc/hosts -func (hm *HostnameManager) SetHostname(hostname, fqdn string) error { - hm.mu.Lock() - defer hm.mu.Unlock() - +func (hm *ResolvConfManager) SetHostname(hostname, domain string) error { hostname = ToValidHostname(strings.TrimSpace(hostname)) - fqdn = ToValidHostname(strings.TrimSpace(fqdn)) + domain = ToValidHostname(strings.TrimSpace(domain)) if hostname == "" { return fmt.Errorf("invalid hostname: %s", hostname) } - if fqdn == "" { - fqdn = hostname + hm.hostname = hostname + hm.domain = domain + + return hm.reconcileHostname() +} + +func (hm *ResolvConfManager) Domain() string { + hm.mu.Lock() + defer hm.mu.Unlock() + return hm.getDomain() +} + +func (hm *ResolvConfManager) Hostname() string { + hm.mu.Lock() + defer hm.mu.Unlock() + return hm.getHostname() +} + +func (hm *ResolvConfManager) FQDN() string { + hm.mu.Lock() + defer hm.mu.Unlock() + return hm.getFQDN() +} + +func (hm *ResolvConfManager) getFQDN() string { + hostname := hm.getHostname() + domain := hm.getDomain() + + if domain == "" { + return hostname + } + + return fmt.Sprintf("%s.%s", hostname, domain) +} + +func (hm *ResolvConfManager) getHostname() string { + if hm.hostname != "" { + return hm.hostname + } + return "jetkvm" +} + +func (hm *ResolvConfManager) getDomain() string { + if hm.domain != "" { + return hm.domain + } + + for _, iface := range hm.conf.ConfigIPv4 { + if iface.Domain != "" { + return iface.Domain + } + } + + for _, iface := range hm.conf.ConfigIPv6 { + if iface.Domain != "" { + return iface.Domain + } + } + + return "" +} + +func (hm *ResolvConfManager) reconcileHostname() error { + hm.mu.Lock() + domain := hm.getDomain() + hostname := hm.hostname + if hostname == "" { + hostname = "jetkvm" + } + hm.mu.Unlock() + + fqdn := hostname + if fqdn != "" { + fqdn = fmt.Sprintf("%s.%s", hostname, domain) } hm.logger.Info(). @@ -81,12 +129,12 @@ func (hm *HostnameManager) SetHostname(hostname, fqdn string) error { } // GetCurrentHostname returns the current system hostname -func (hm *HostnameManager) GetCurrentHostname() (string, error) { +func (hm *ResolvConfManager) GetCurrentHostname() (string, error) { return os.Hostname() } // GetCurrentFQDN returns the current FQDN -func (hm *HostnameManager) GetCurrentFQDN() (string, error) { +func (hm *ResolvConfManager) GetCurrentFQDN() (string, error) { hostname, err := hm.GetCurrentHostname() if err != nil { return "", err @@ -97,7 +145,7 @@ func (hm *HostnameManager) GetCurrentFQDN() (string, error) { } // updateEtcHostname updates the /etc/hostname file -func (hm *HostnameManager) updateEtcHostname(hostname string) error { +func (hm *ResolvConfManager) updateEtcHostname(hostname string) error { if err := os.WriteFile(hostnamePath, []byte(hostname), 0644); err != nil { return fmt.Errorf("failed to write %s: %w", hostnamePath, err) } @@ -107,7 +155,7 @@ func (hm *HostnameManager) updateEtcHostname(hostname string) error { } // updateEtcHosts updates the /etc/hosts file -func (hm *HostnameManager) updateEtcHosts(hostname, fqdn string) error { +func (hm *ResolvConfManager) updateEtcHosts(hostname, fqdn string) error { // Open /etc/hosts for reading and writing hostsFile, err := os.OpenFile(hostsPath, os.O_RDWR|os.O_SYNC, os.ModeExclusive) if err != nil { @@ -166,7 +214,7 @@ func (hm *HostnameManager) updateEtcHosts(hostname, fqdn string) error { } // setSystemHostname sets the system hostname using the hostname command -func (hm *HostnameManager) setSystemHostname(hostname string) error { +func (hm *ResolvConfManager) setSystemHostname(hostname string) error { cmd := exec.Command("hostname", "-F", hostnamePath) if err := cmd.Run(); err != nil { return fmt.Errorf("failed to run hostname command: %w", err) @@ -177,7 +225,7 @@ func (hm *HostnameManager) setSystemHostname(hostname string) error { } // getFQDNFromHosts tries to get the FQDN from /etc/hosts -func (hm *HostnameManager) getFQDNFromHosts(hostname string) (string, error) { +func (hm *ResolvConfManager) getFQDNFromHosts(hostname string) (string, error) { content, err := os.ReadFile(hostsPath) if err != nil { return hostname, nil // Return hostname as fallback diff --git a/pkg/nmlite/interface.go b/pkg/nmlite/interface.go index 58aa48a0..4dca6d8d 100644 --- a/pkg/nmlite/interface.go +++ b/pkg/nmlite/interface.go @@ -19,6 +19,8 @@ import ( "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 @@ -32,13 +34,12 @@ type InterfaceManager struct { // Network components staticConfig *StaticConfigManager dhcpClient *DHCPClient - resolvConf *ResolvConfManager - hostname *HostnameManager // Callbacks - onStateChange func(state types.InterfaceState) - onConfigChange func(config *types.NetworkConfig) - onDHCPLeaseChange func(lease *types.DHCPLease) + onStateChange func(state types.InterfaceState) + onConfigChange func(config *types.NetworkConfig) + onDHCPLeaseChange func(lease *types.DHCPLease) + onResolvConfChange ResolvConfChangeCallback // Control stopCh chan struct{} @@ -87,9 +88,6 @@ func NewInterfaceManager(ctx context.Context, ifaceName string, config *types.Ne return nil, fmt.Errorf("failed to create DHCP client: %w", err) } - im.resolvConf = NewResolvConfManager(&scopedLogger) - im.hostname = NewHostnameManager(&scopedLogger) - // Set up DHCP client callbacks im.dhcpClient.SetOnLeaseChange(func(lease *types.DHCPLease) { if err := im.applyDHCPLease(lease); err != nil { @@ -248,6 +246,9 @@ func (im *InterfaceManager) GetIPv6Addresses() []string { // GetMACAddress returns the MAC address of the interface func (im *InterfaceManager) GetMACAddress() string { + im.stateMu.RLock() + defer im.stateMu.RUnlock() + return im.state.MACAddress } @@ -271,6 +272,21 @@ func (im *InterfaceManager) NTPServers() []net.IP { return im.state.NTPServers } +func (im *InterfaceManager) Domain() string { + im.stateMu.RLock() + defer im.stateMu.RUnlock() + + 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 @@ -335,6 +351,11 @@ func (im *InterfaceManager) SetOnDHCPLeaseChange(callback func(lease *types.DHCP 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") @@ -349,11 +370,6 @@ func (im *InterfaceManager) applyConfiguration() error { return fmt.Errorf("failed to apply IPv6 config: %w", err) } - // Update hostname - if err := im.updateHostname(); err != nil { - im.logger.Warn().Err(err).Msg("failed to update hostname") - } - return nil } @@ -419,6 +435,13 @@ func (im *InterfaceManager) applyIPv4Static() error { 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) } @@ -463,6 +486,13 @@ func (im *InterfaceManager) applyIPv6Static() error { } 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) } @@ -542,39 +572,6 @@ func (im *InterfaceManager) disableIPv6() error { return im.staticConfig.DisableIPv6() } -// updateHostname updates the system hostname -func (im *InterfaceManager) updateHostname() error { - hostname := im.getHostname() - domain := im.getDomain() - fqdn := fmt.Sprintf("%s.%s", hostname, domain) - - return im.hostname.SetHostname(hostname, fqdn) -} - -// getHostname returns the configured hostname or default -func (im *InterfaceManager) getHostname() string { - if im.config.Hostname.String != "" { - return im.config.Hostname.String - } - return "jetkvm" -} - -// getDomain returns the configured domain or default -func (im *InterfaceManager) getDomain() string { - if im.config.Domain.String != "" { - return im.config.Domain.String - } - - // Try to get domain from DHCP lease - if im.dhcpClient != nil { - if lease := im.dhcpClient.Lease4(); lease != nil && lease.Domain != "" { - return lease.Domain - } - } - - return "local" -} - func (im *InterfaceManager) handleLinkStateChange(link *link.Link) { { im.stateMu.Lock() @@ -702,13 +699,34 @@ func (im *InterfaceManager) monitorInterfaceState() { // updateStateFromDHCPLease updates the state from a DHCP lease func (im *InterfaceManager) updateStateFromDHCPLease(lease *types.DHCPLease) { + family := link.AfInet + im.stateMu.Lock() - im.state.DHCPLease4 = lease + if lease.IsIPv6() { + im.state.DHCPLease6 = lease + family = link.AfInet6 + } else { + im.state.DHCPLease4 = lease + family = link.AfInet + } im.stateMu.Unlock() // Update resolv.conf with DNS information - if im.resolvConf != nil { - im.resolvConf.UpdateFromLease(lease) + 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") } } diff --git a/pkg/nmlite/jetdhcpc/client.go b/pkg/nmlite/jetdhcpc/client.go index bcab787d..155ea249 100644 --- a/pkg/nmlite/jetdhcpc/client.go +++ b/pkg/nmlite/jetdhcpc/client.go @@ -75,6 +75,8 @@ type Config struct { // If true, add Client Identifier (61) option to the IPv4 request. V4ClientIdentifier bool + Hostname string + OnLease4Change LeaseChangeHandler OnLease6Change LeaseChangeHandler diff --git a/pkg/nmlite/jetdhcpc/dhcp4.go b/pkg/nmlite/jetdhcpc/dhcp4.go index 4eb0ee14..afa10a4a 100644 --- a/pkg/nmlite/jetdhcpc/dhcp4.go +++ b/pkg/nmlite/jetdhcpc/dhcp4.go @@ -35,7 +35,14 @@ func (c *Client) requestLease4(ifname string) (*Lease, error) { reqmods := append( []dhcpv4.Modifier{ dhcpv4.WithOption(dhcpv4.OptClassIdentifier(VendorIdentifier)), - dhcpv4.WithRequestedOptions(dhcpv4.OptionSubnetMask), + dhcpv4.WithRequestedOptions( + dhcpv4.OptionSubnetMask, + dhcpv4.OptionInterfaceMTU, + dhcpv4.OptionNTPServers, + dhcpv4.OptionDomainName, + dhcpv4.OptionDomainNameServer, + dhcpv4.OptionDNSDomainSearchList, + ), }, c.cfg.Modifiers4...) @@ -46,6 +53,10 @@ func (c *Client) requestLease4(ifname string) (*Lease, error) { reqmods = append(reqmods, dhcpv4.WithOption(dhcpv4.OptClientIdentifier(ident))) } + if c.cfg.Hostname != "" { + reqmods = append(reqmods, dhcpv4.WithOption(dhcpv4.OptHostName(c.cfg.Hostname))) + } + l.Info().Msg("attempting to get DHCPv4 lease") var ( lease *nclient4.Lease @@ -68,6 +79,7 @@ func (c *Client) requestLease4(ifname string) (*Lease, error) { } summaryStructured(lease.ACK, &l).Info().Msgf("DHCPv4 lease acquired: %s", lease.ACK.String()) + l.Trace().Interface("options", lease.ACK.Options.String()).Msg("DHCPv4 lease options") return fromNclient4Lease(lease, ifname), nil } diff --git a/pkg/nmlite/jetdhcpc/lease.go b/pkg/nmlite/jetdhcpc/lease.go index 0c06f8fa..ea09e40f 100644 --- a/pkg/nmlite/jetdhcpc/lease.go +++ b/pkg/nmlite/jetdhcpc/lease.go @@ -2,6 +2,7 @@ package jetdhcpc import ( "bufio" + "encoding/binary" "encoding/json" "fmt" "net" @@ -11,6 +12,7 @@ import ( "strings" "time" + "github.com/insomniacslk/dhcp/dhcpv4" "github.com/insomniacslk/dhcp/dhcpv4/nclient4" "github.com/insomniacslk/dhcp/dhcpv6" "github.com/jetkvm/kvm/internal/network/types" @@ -66,6 +68,11 @@ func fromNclient4Lease(l *nclient4.Lease, iface string) *Lease { lease.ClassIdentifier = l.ACK.ClassIdentifier() lease.ServerID = l.ACK.ServerIdentifier().String() + mtu := l.ACK.Options.Get(dhcpv4.OptionInterfaceMTU) + if mtu != nil { + lease.MTU = int(binary.BigEndian.Uint16(mtu)) + } + lease.Message = l.ACK.Message() lease.LeaseTime = l.ACK.IPAddressLeaseTime(defaultLeaseTime) lease.RenewalTime = l.ACK.IPAddressRenewalTime(defaultRenewalTime) diff --git a/pkg/nmlite/manager.go b/pkg/nmlite/manager.go index dc04190f..2c69534a 100644 --- a/pkg/nmlite/manager.go +++ b/pkg/nmlite/manager.go @@ -23,6 +23,8 @@ type NetworkManager struct { ctx context.Context cancel context.CancelFunc + resolvConf *ResolvConfManager + // Callback functions for state changes onInterfaceStateChange func(iface string, state types.InterfaceState) onConfigChange func(iface string, config *types.NetworkConfig) @@ -45,9 +47,20 @@ func NewNetworkManager(ctx context.Context, logger *zerolog.Logger) *NetworkMana logger: logger, ctx: ctx, cancel: cancel, + resolvConf: NewResolvConfManager(logger), } } +// SetHostname sets the hostname and domain for the network manager +func (nm *NetworkManager) SetHostname(hostname string, domain string) error { + return nm.resolvConf.SetHostname(hostname, domain) +} + +// Domain returns the effective domain for the network manager +func (nm *NetworkManager) Domain() string { + return nm.resolvConf.Domain() +} + // AddInterface adds a new network interface to be managed func (nm *NetworkManager) AddInterface(iface string, config *types.NetworkConfig) error { nm.mu.Lock() @@ -81,6 +94,11 @@ func (nm *NetworkManager) AddInterface(iface string, config *types.NetworkConfig } }) + im.SetOnResolvConfChange(func(family int, resolvConf *types.InterfaceResolvConf) error { + nm.resolvConf.SetInterfaceConfig(iface, family, *resolvConf) + return nil + }) + nm.interfaces[iface] = im // Start monitoring the interface diff --git a/pkg/nmlite/resolvconf.go b/pkg/nmlite/resolvconf.go index 0502bda7..05ccc517 100644 --- a/pkg/nmlite/resolvconf.go +++ b/pkg/nmlite/resolvconf.go @@ -4,28 +4,29 @@ import ( "bytes" "fmt" "html/template" - "net" "os" "strings" "github.com/jetkvm/kvm/internal/network/types" + "github.com/jetkvm/kvm/internal/sync" + "github.com/jetkvm/kvm/pkg/nmlite/link" "github.com/rs/zerolog" ) const ( resolvConfPath = "/etc/resolv.conf" resolvConfFileMode = 0644 - resolvConfTemplate = `# the resolv.conf file is managed by the jetkvm network manager + resolvConfTemplate = `# the resolv.conf file is managed by JetKVM # DO NOT EDIT THIS FILE BY HAND -- YOUR CHANGES WILL BE OVERWRITTEN {{ if .searchList }} -search {{ join .searchList " " }} # {{ .iface }} +search {{ join .searchList " " }} {{- end -}} {{ if .domain }} -domain {{ .domain }} # {{ .iface }} +domain {{ .domain }} {{- end -}} -{{ range .nameservers }} -nameserver {{ printf "%s" . }} # {{ $.iface }} +{{ range $ns, $comment := .nameservers }} +nameserver {{ printf "%s" $ns }} # {{ join $comment ", " }} {{- end }} ` ) @@ -39,6 +40,11 @@ var ( // ResolvConfManager manages the resolv.conf file type ResolvConfManager struct { logger *zerolog.Logger + mu sync.Mutex + conf *types.ResolvConf + + hostname string + domain string } // NewResolvConfManager creates a new resolv.conf manager @@ -50,148 +56,150 @@ func NewResolvConfManager(logger *zerolog.Logger) *ResolvConfManager { return &ResolvConfManager{ logger: logger, + mu: sync.Mutex{}, + conf: &types.ResolvConf{ + ConfigIPv4: make(map[string]types.InterfaceResolvConf), + ConfigIPv6: make(map[string]types.InterfaceResolvConf), + }, } } -// UpdateFromLease updates resolv.conf from a DHCP lease -func (rcm *ResolvConfManager) UpdateFromLease(lease *types.DHCPLease) error { - if lease == nil { - return fmt.Errorf("lease cannot be nil") +// SetInterfaceConfig sets the resolv.conf configuration for a specific interface +func (rcm *ResolvConfManager) SetInterfaceConfig(iface string, family int, config types.InterfaceResolvConf) error { + // DO NOT USE defer HERE, rcm.update() also locks the mutex + rcm.mu.Lock() + switch family { + case link.AfInet: + rcm.conf.ConfigIPv4[iface] = config + case link.AfInet6: + rcm.conf.ConfigIPv6[iface] = config + default: + rcm.mu.Unlock() + return fmt.Errorf("invalid family: %d", family) } + rcm.mu.Unlock() - rcm.logger.Info(). - Str("interface", lease.InterfaceName). - Msg("updating resolv.conf from DHCP lease") + rcm.reconcileHostname() - return rcm.Update(lease.InterfaceName, lease.DNS, lease.SearchList, lease.Domain) + return rcm.update() } -// UpdateFromStaticConfig updates resolv.conf from static configuration -func (rcm *ResolvConfManager) UpdateFromStaticConfig(iface string, dns []string) error { - if len(dns) == 0 { - rcm.logger.Debug().Str("interface", iface).Msg("no DNS servers in static config") - return nil +// SetConfig sets the resolv.conf configuration +func (rcm *ResolvConfManager) SetConfig(resolvConf *types.ResolvConf) error { + if resolvConf == nil { + return fmt.Errorf("resolvConf cannot be nil") } - // Parse DNS servers - var dnsIPs []net.IP - for _, dnsStr := range dns { - dnsIP := net.ParseIP(dnsStr) - if dnsIP == nil { - rcm.logger.Warn().Str("dns", dnsStr).Msg("invalid DNS server, skipping") - continue - } - dnsIPs = append(dnsIPs, dnsIP) - } + rcm.mu.Lock() + rcm.conf = resolvConf + defer rcm.mu.Unlock() - if len(dnsIPs) == 0 { - rcm.logger.Debug().Str("interface", iface).Msg("no valid DNS servers in static config") - return nil - } + return rcm.update() +} - rcm.logger.Info(). - Str("interface", iface). - Interface("dns", dnsIPs). - Msg("updating resolv.conf from static config") - - return rcm.Update(iface, dnsIPs, nil, "") +// Reconcile reconciles the resolv.conf configuration +func (rcm *ResolvConfManager) Reconcile() error { + rcm.reconcileHostname() + return rcm.update() } // Update updates the resolv.conf file -func (rcm *ResolvConfManager) Update(iface string, nameservers []net.IP, searchList []string, domain string) error { - rcm.logger.Debug(). - Str("interface", iface). - Interface("nameservers", nameservers). - Interface("searchList", searchList). - Str("domain", domain). - Msg("updating resolv.conf") +func (rcm *ResolvConfManager) update() error { + rcm.mu.Lock() + defer rcm.mu.Unlock() + + rcm.logger.Debug().Msg("updating resolv.conf") // Generate resolv.conf content - content, err := rcm.generateResolvConf(iface, nameservers, searchList, domain) + content, err := rcm.generateResolvConf(rcm.conf) if err != nil { return fmt.Errorf("failed to generate resolv.conf: %w", err) } + // Check if the file is the same + if _, err := os.Stat(resolvConfPath); err == nil { + existingContent, err := os.ReadFile(resolvConfPath) + if err != nil { + rcm.logger.Warn().Err(err).Msg("failed to read existing resolv.conf") + } + + if bytes.Equal(existingContent, content) { + rcm.logger.Debug().Msg("resolv.conf is the same, skipping write") + return nil + } + } + // Write to file if err := os.WriteFile(resolvConfPath, content, resolvConfFileMode); err != nil { return fmt.Errorf("failed to write resolv.conf: %w", err) } rcm.logger.Info(). - Str("interface", iface). - Int("nameservers", len(nameservers)). + Interface("config", rcm.conf). Msg("resolv.conf updated successfully") return nil } +type configMap map[string][]string + +func mergeConfig(nameservers *configMap, searchList *configMap, config *types.InterfaceResolvConfMap) { + localNameservers := *nameservers + localSearchList := *searchList + + for ifname, iface := range *config { + comment := ifname + if iface.Source != "" { + comment += fmt.Sprintf(" (%s)", iface.Source) + } + + for _, ip := range iface.NameServers { + ns := ip.String() + if _, ok := localNameservers[ns]; !ok { + localNameservers[ns] = []string{} + } + localNameservers[ns] = append(localNameservers[ns], comment) + } + + for _, search := range iface.SearchList { + search = strings.Trim(search, ".") + if _, ok := localSearchList[search]; !ok { + localSearchList[search] = []string{} + } + localSearchList[search] = append(localSearchList[search], comment) + } + } + + *nameservers = localNameservers + *searchList = localSearchList +} + // generateResolvConf generates resolv.conf content -func (rcm *ResolvConfManager) generateResolvConf(iface string, nameservers []net.IP, searchList []string, domain string) ([]byte, error) { +func (rcm *ResolvConfManager) generateResolvConf(conf *types.ResolvConf) ([]byte, error) { tmpl, err := template.New("resolv.conf").Funcs(tplFuncMap).Parse(resolvConfTemplate) if err != nil { return nil, fmt.Errorf("failed to parse template: %w", err) } + // merge the nameservers and searchList + nameservers := configMap{} + searchList := configMap{} + + mergeConfig(&nameservers, &searchList, &conf.ConfigIPv4) + mergeConfig(&nameservers, &searchList, &conf.ConfigIPv6) + + flattenedSearchList := []string{} + for search := range searchList { + flattenedSearchList = append(flattenedSearchList, search) + } + var buf bytes.Buffer if err := tmpl.Execute(&buf, map[string]any{ - "iface": iface, "nameservers": nameservers, - "searchList": searchList, - "domain": domain, + "searchList": flattenedSearchList, }); err != nil { return nil, fmt.Errorf("failed to execute template: %w", err) } return buf.Bytes(), nil } - -// Clear clears the resolv.conf file (removes all entries) -func (rcm *ResolvConfManager) Clear() error { - rcm.logger.Info().Msg("clearing resolv.conf") - - content := []byte("# the resolv.conf file is managed by the jetkvm network manager\n# DO NOT EDIT THIS FILE BY HAND -- YOUR CHANGES WILL BE OVERWRITTEN\n") - - if err := os.WriteFile(resolvConfPath, content, resolvConfFileMode); err != nil { - return fmt.Errorf("failed to clear resolv.conf: %w", err) - } - - rcm.logger.Info().Msg("resolv.conf cleared") - return nil -} - -// GetCurrentContent returns the current content of resolv.conf -func (rcm *ResolvConfManager) GetCurrentContent() ([]byte, error) { - return os.ReadFile(resolvConfPath) -} - -// Backup creates a backup of the current resolv.conf -func (rcm *ResolvConfManager) Backup() error { - content, err := rcm.GetCurrentContent() - if err != nil { - return fmt.Errorf("failed to read current resolv.conf: %w", err) - } - - backupPath := resolvConfPath + ".backup" - if err := os.WriteFile(backupPath, content, resolvConfFileMode); err != nil { - return fmt.Errorf("failed to create backup: %w", err) - } - - rcm.logger.Info().Str("backup", backupPath).Msg("resolv.conf backed up") - return nil -} - -// Restore restores resolv.conf from backup -func (rcm *ResolvConfManager) Restore() error { - backupPath := resolvConfPath + ".backup" - content, err := os.ReadFile(backupPath) - if err != nil { - return fmt.Errorf("failed to read backup: %w", err) - } - - if err := os.WriteFile(resolvConfPath, content, resolvConfFileMode); err != nil { - return fmt.Errorf("failed to restore resolv.conf: %w", err) - } - - rcm.logger.Info().Str("backup", backupPath).Msg("resolv.conf restored from backup") - return nil -} diff --git a/pkg/nmlite/resolvconf_test.go b/pkg/nmlite/resolvconf_test.go deleted file mode 100644 index ddb91854..00000000 --- a/pkg/nmlite/resolvconf_test.go +++ /dev/null @@ -1,35 +0,0 @@ -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()) -} diff --git a/pkg/nmlite/state.go b/pkg/nmlite/state.go index 517a44ac..bc6accae 100644 --- a/pkg/nmlite/state.go +++ b/pkg/nmlite/state.go @@ -20,12 +20,12 @@ func (nm *NetworkManager) IsUp() bool { return false } -func (nm *NetworkManager) GetHostname() string { - return "jetkvm" +func (nm *NetworkManager) Hostname() string { + return nm.resolvConf.Hostname() } -func (nm *NetworkManager) GetFQDN() string { - return "jetkvm.local" +func (nm *NetworkManager) FQDN() string { + return nm.resolvConf.FQDN() } func (nm *NetworkManager) NTPServers() []net.IP {