diff --git a/config.go b/config.go index ed7477e..6cc7a29 100644 --- a/config.go +++ b/config.go @@ -6,6 +6,7 @@ import ( "os" "sync" + "github.com/jetkvm/kvm/internal/network" "github.com/jetkvm/kvm/internal/usbgadget" ) @@ -73,27 +74,28 @@ func (m *KeyboardMacro) Validate() error { } type Config struct { - CloudURL string `json:"cloud_url"` - CloudAppURL string `json:"cloud_app_url"` - CloudToken string `json:"cloud_token"` - GoogleIdentity string `json:"google_identity"` - JigglerEnabled bool `json:"jiggler_enabled"` - AutoUpdateEnabled bool `json:"auto_update_enabled"` - IncludePreRelease bool `json:"include_pre_release"` - HashedPassword string `json:"hashed_password"` - LocalAuthToken string `json:"local_auth_token"` - LocalAuthMode string `json:"localAuthMode"` //TODO: fix it with migration - WakeOnLanDevices []WakeOnLanDevice `json:"wake_on_lan_devices"` - KeyboardMacros []KeyboardMacro `json:"keyboard_macros"` - EdidString string `json:"hdmi_edid_string"` - ActiveExtension string `json:"active_extension"` - DisplayMaxBrightness int `json:"display_max_brightness"` - DisplayDimAfterSec int `json:"display_dim_after_sec"` - DisplayOffAfterSec int `json:"display_off_after_sec"` - TLSMode string `json:"tls_mode"` // options: "self-signed", "user-defined", "" - UsbConfig *usbgadget.Config `json:"usb_config"` - UsbDevices *usbgadget.Devices `json:"usb_devices"` - DefaultLogLevel string `json:"default_log_level"` + CloudURL string `json:"cloud_url"` + CloudAppURL string `json:"cloud_app_url"` + CloudToken string `json:"cloud_token"` + GoogleIdentity string `json:"google_identity"` + JigglerEnabled bool `json:"jiggler_enabled"` + AutoUpdateEnabled bool `json:"auto_update_enabled"` + IncludePreRelease bool `json:"include_pre_release"` + HashedPassword string `json:"hashed_password"` + LocalAuthToken string `json:"local_auth_token"` + LocalAuthMode string `json:"localAuthMode"` //TODO: fix it with migration + WakeOnLanDevices []WakeOnLanDevice `json:"wake_on_lan_devices"` + KeyboardMacros []KeyboardMacro `json:"keyboard_macros"` + EdidString string `json:"hdmi_edid_string"` + ActiveExtension string `json:"active_extension"` + DisplayMaxBrightness int `json:"display_max_brightness"` + DisplayDimAfterSec int `json:"display_dim_after_sec"` + DisplayOffAfterSec int `json:"display_off_after_sec"` + TLSMode string `json:"tls_mode"` // options: "self-signed", "user-defined", "" + UsbConfig *usbgadget.Config `json:"usb_config"` + UsbDevices *usbgadget.Devices `json:"usb_devices"` + NetworkConfig *network.NetworkConfig `json:"network_config"` + DefaultLogLevel string `json:"default_log_level"` } const configPath = "/userdata/kvm_config.json" @@ -164,6 +166,10 @@ func LoadConfig() { loadedConfig.UsbDevices = defaultConfig.UsbDevices } + if loadedConfig.NetworkConfig == nil { + loadedConfig.NetworkConfig = defaultConfig.NetworkConfig + } + config = &loadedConfig rootLogger.UpdateLogLevel() diff --git a/go.mod b/go.mod index bc231f2..6784a59 100644 --- a/go.mod +++ b/go.mod @@ -45,6 +45,7 @@ require ( github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/validator/v10 v10.26.0 // indirect github.com/goccy/go-json v0.10.5 // indirect + github.com/guregu/null/v6 v6.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/compress v1.17.11 // indirect github.com/klauspost/cpuid/v2 v2.2.10 // indirect diff --git a/go.sum b/go.sum index 018d3a8..3ad832a 100644 --- a/go.sum +++ b/go.sum @@ -56,6 +56,8 @@ github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeN github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/guregu/null/v6 v6.0.0 h1:N14VRS+4di81i1PXRiprbQJ9EM9gqBa0+KVMeS/QSjQ= +github.com/guregu/null/v6 v6.0.0/go.mod h1:hrMIhIfrOZeLPZhROSn149tpw2gHkidAqxoXNyeX3iQ= github.com/gwatts/rootcerts v0.0.0-20240401182218-3ab9db955caf h1:JO6ISZIvEUitto5zjQ3/VEnDM5rPbqIFuOhS0U0ByeA= github.com/gwatts/rootcerts v0.0.0-20240401182218-3ab9db955caf/go.mod h1:5Kt9XkWvkGi2OHOq0QsGxebHmhCcqJ8KCbNg/a6+n+g= github.com/hanwen/go-fuse/v2 v2.5.1 h1:OQBE8zVemSocRxA4OaFJbjJ5hlpCmIWbGr7r0M4uoQQ= diff --git a/internal/logging/utils.go b/internal/logging/utils.go new file mode 100644 index 0000000..6d54bc5 --- /dev/null +++ b/internal/logging/utils.go @@ -0,0 +1,28 @@ +package logging + +import ( + "fmt" + "os" + + "github.com/rs/zerolog" +) + +var defaultLogger = zerolog.New(os.Stdout).Level(zerolog.InfoLevel) + +func ErrorfL(l *zerolog.Logger, format string, err error, args ...interface{}) error { + // TODO: move rootLogger to logging package + if l == nil { + l = &defaultLogger + } + + l.Error().Err(err).Msgf(format, args...) + + if err == nil { + return fmt.Errorf(format, args...) + } + + err_msg := err.Error() + ": %v" + err_args := append(args, err) + + return fmt.Errorf(err_msg, err_args...) +} diff --git a/internal/network/config.go b/internal/network/config.go new file mode 100644 index 0000000..1cfe9bb --- /dev/null +++ b/internal/network/config.go @@ -0,0 +1,40 @@ +package network + +import ( + "net" + "time" +) + +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"` + Scope int `json:"scope"` +} + +type NetworkConfig struct { + Hostname string `json:"hostname,omitempty"` + Domain string `json:"domain,omitempty"` + + IPv4Mode string `json:"ipv4_mode" one_of:"dhcp,static,disabled" default:"dhcp"` + IPv4Static struct { + Address string `json:"address" validate_type:"ipv4"` + Netmask string `json:"netmask" validate_type:"ipv4"` + Gateway string `json:"gateway" validate_type:"ipv4"` + DNS []string `json:"dns" validate_type:"ipv4"` + } `json:"ipv4_static,omitempty" required_if:"ipv4_mode,static"` + + IPv6Mode string `json:"ipv6_mode" one_of:"slaac,dhcpv6,slaac_and_dhcpv6,static,link_local,disabled" default:"slaac"` + IPv6Static struct { + Address string `json:"address" validate_type:"ipv6"` + Netmask string `json:"netmask" validate_type:"ipv6"` + Gateway string `json:"gateway" validate_type:"ipv6"` + DNS []string `json:"dns" validate_type:"ipv6"` + } `json:"ipv6_static,omitempty" required_if:"ipv6_mode,static"` + + LLDPMode string `json:"lldp_mode,omitempty" one_of:"disabled,basic,all" default:"basic"` + LLDPTxTLVs []string `json:"lldp_tx_tlvs,omitempty" one_of:"chassis,port,system,vlan" default:"chassis,port,system,vlan"` + MDNSMode string `json:"mdns_mode,omitempty" one_of:"disabled,auto,ipv4_only,ipv6_only" default:"auto"` + TimeSyncMode string `json:"time_sync_mode,omitempty" one_of:"ntp_only,ntp_and_http,http_only,custom" default:"ntp_and_http"` +} diff --git a/internal/network/dhcp.go b/internal/network/dhcp.go new file mode 100644 index 0000000..9e173cc --- /dev/null +++ b/internal/network/dhcp.go @@ -0,0 +1,11 @@ +package network + +type DhcpTargetState int + +const ( + DhcpTargetStateDoNothing DhcpTargetState = iota + DhcpTargetStateStart + DhcpTargetStateStop + DhcpTargetStateRenew + DhcpTargetStateRelease +) diff --git a/internal/network/netif.go b/internal/network/netif.go new file mode 100644 index 0000000..8e3370a --- /dev/null +++ b/internal/network/netif.go @@ -0,0 +1,353 @@ +package network + +import ( + "net" + "sync" + "time" + + "github.com/jetkvm/kvm/internal/logging" + "github.com/jetkvm/kvm/internal/udhcpc" + "github.com/rs/zerolog" + + "github.com/vishvananda/netlink" + "github.com/vishvananda/netlink/nl" +) + +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 + + onStateChange func(state *NetworkInterfaceState) + onInitialCheck func(state *NetworkInterfaceState) + + checked bool +} + +type NetworkInterfaceOptions struct { + InterfaceName string + DhcpPidFile string + Logger *zerolog.Logger + OnStateChange func(state *NetworkInterfaceState) + OnInitialCheck func(state *NetworkInterfaceState) + OnDhcpLeaseChange func(lease *udhcpc.Lease) + NetworkConfig *NetworkConfig +} + +func NewNetworkInterfaceState(opts *NetworkInterfaceOptions) *NetworkInterfaceState { + l := opts.Logger + s := &NetworkInterfaceState{ + interfaceName: opts.InterfaceName, + stateLock: sync.Mutex{}, + l: l, + onStateChange: opts.OnStateChange, + onInitialCheck: opts.OnInitialCheck, + 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 + } + + opts.OnDhcpLeaseChange(lease) + }, + }) + + s.dhcpClient = dhcpClient + + return s +} + +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 := netlink.AddrList(iface, nl.FAMILY_ALL) + 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 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) HandleLinkUpdate(update netlink.LinkUpdate) { + if update.Link.Attrs().Name == s.interfaceName { + s.l.Info().Interface("update", update).Msg("interface link update received") + _ = s.CheckAndUpdateDhcp() + } +} + +func (s *NetworkInterfaceState) Run() error { + updates := make(chan netlink.LinkUpdate) + done := make(chan struct{}) + + if err := netlink.LinkSubscribe(updates, done); err != nil { + s.l.Warn().Err(err).Msg("failed to subscribe to link updates") + return err + } + + // run the dhcp client + go s.dhcpClient.Run() // nolint:errcheck + + if err := s.CheckAndUpdateDhcp(); err != nil { + return err + } + + go func() { + ticker := time.NewTicker(1 * time.Second) + defer ticker.Stop() + + for { + select { + case update := <-updates: + s.HandleLinkUpdate(update) + case <-ticker.C: + _ = s.CheckAndUpdateDhcp() + case <-done: + return + } + } + }() + + return nil +} diff --git a/internal/network/rpc.go b/internal/network/rpc.go new file mode 100644 index 0000000..afdcbc0 --- /dev/null +++ b/internal/network/rpc.go @@ -0,0 +1,127 @@ +package network + +import ( + "fmt" + "time" + + "github.com/guregu/null/v6" + "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 { + Hostname null.String `json:"hostname,omitempty"` + Domain null.String `json:"domain,omitempty"` + IPv4Mode null.String `json:"ipv4_mode,omitempty"` + IPv6Mode null.String `json:"ipv6_mode,omitempty"` + LLDPMode null.String `json:"lldp_mode,omitempty"` + LLDPTxTLVs []string `json:"lldp_tx_tlvs,omitempty"` + MDNSMode null.String `json:"mdns_mode,omitempty"` + TimeSyncMode null.String `json:"time_sync_mode,omitempty"` +} + +func (s *NetworkInterfaceState) RpcGetNetworkState() RpcNetworkState { + ipv6Addresses := make([]RpcIPv6Address, 0) + 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.macAddr.String(), + IPv4: s.ipv4Addr.String(), + IPv6: s.ipv6Addr.String(), + IPv6LinkLocal: s.ipv6LinkLocal.String(), + IPv4Addresses: s.ipv4Addresses, + IPv6Addresses: ipv6Addresses, + DHCPLease: s.dhcpClient.GetLease(), + } +} + +func (s *NetworkInterfaceState) RpcGetNetworkSettings() RpcNetworkSettings { + return RpcNetworkSettings{ + Hostname: null.StringFrom(s.config.Hostname), + Domain: null.StringFrom(s.config.Domain), + IPv4Mode: null.StringFrom(s.config.IPv4Mode), + IPv6Mode: null.StringFrom(s.config.IPv6Mode), + LLDPMode: null.StringFrom(s.config.LLDPMode), + LLDPTxTLVs: s.config.LLDPTxTLVs, + MDNSMode: null.StringFrom(s.config.MDNSMode), + TimeSyncMode: null.StringFrom(s.config.TimeSyncMode), + } +} + +func (s *NetworkInterfaceState) RpcSetNetworkSettings(settings RpcNetworkSettings) error { + changeset := make(map[string]string) + currentSettings := s.config + + if !settings.Hostname.IsZero() { + changeset["hostname"] = settings.Hostname.String + currentSettings.Hostname = settings.Hostname.String + } + + if !settings.Domain.IsZero() { + changeset["domain"] = settings.Domain.String + currentSettings.Domain = settings.Domain.String + } + + if !settings.IPv4Mode.IsZero() { + changeset["ipv4_mode"] = settings.IPv4Mode.String + currentSettings.IPv4Mode = settings.IPv4Mode.String + } + + if !settings.IPv6Mode.IsZero() { + changeset["ipv6_mode"] = settings.IPv6Mode.String + currentSettings.IPv6Mode = settings.IPv6Mode.String + } + + if !settings.LLDPMode.IsZero() { + changeset["lldp_mode"] = settings.LLDPMode.String + currentSettings.LLDPMode = settings.LLDPMode.String + } + + if !settings.MDNSMode.IsZero() { + changeset["mdns_mode"] = settings.MDNSMode.String + currentSettings.MDNSMode = settings.MDNSMode.String + } + + if !settings.TimeSyncMode.IsZero() { + changeset["time_sync_mode"] = settings.TimeSyncMode.String + currentSettings.TimeSyncMode = settings.TimeSyncMode.String + } + + if len(changeset) > 0 { + s.config = currentSettings + } + + return nil +} + +func (s *NetworkInterfaceState) RpcRenewDHCPLease() error { + if s.dhcpClient == nil { + return fmt.Errorf("dhcp client not initialized") + } + + return s.dhcpClient.Renew() +} diff --git a/internal/network/utils.go b/internal/network/utils.go new file mode 100644 index 0000000..0d02e19 --- /dev/null +++ b/internal/network/utils.go @@ -0,0 +1,11 @@ +package network + +import "time" + +func lifetimeToTime(lifetime int) *time.Time { + if lifetime == 0 { + return nil + } + t := time.Now().Add(time.Duration(lifetime) * time.Second) + return &t +} diff --git a/jsonrpc.go b/jsonrpc.go index d39fdb1..9dd365f 100644 --- a/jsonrpc.go +++ b/jsonrpc.go @@ -960,9 +960,10 @@ var rpcHandlers = map[string]RPCHandler{ "getDeviceID": {Func: rpcGetDeviceID}, "deregisterDevice": {Func: rpcDeregisterDevice}, "getCloudState": {Func: rpcGetCloudState}, - "getNetworkState": {Func: rpcGetNetworkState}, - "getNetworkSettings": {Func: rpcGetNetworkSettings}, - "renewDHCPLease": {Func: rpcRenewDHCPLease}, + "getNetworkState": {Func: networkState.RpcGetNetworkState}, + "getNetworkSettings": {Func: networkState.RpcGetNetworkSettings}, + "setNetworkSettings": {Func: networkState.RpcSetNetworkSettings, Params: []string{"settings"}}, + "renewDHCPLease": {Func: networkState.RpcRenewDHCPLease}, "keyboardReport": {Func: rpcKeyboardReport, Params: []string{"modifier", "keys"}}, "absMouseReport": {Func: rpcAbsMouseReport, Params: []string{"x", "y", "buttons"}}, "relMouseReport": {Func: rpcRelMouseReport, Params: []string{"dx", "dy", "buttons"}}, diff --git a/network.go b/network.go index 5ce9861..4e1d42b 100644 --- a/network.go +++ b/network.go @@ -1,501 +1,41 @@ package kvm import ( - "fmt" - "net" "os" - "sync" - "time" - "github.com/Masterminds/semver/v3" + "github.com/jetkvm/kvm/internal/network" "github.com/jetkvm/kvm/internal/udhcpc" - "github.com/rs/zerolog" - - "github.com/vishvananda/netlink" - "github.com/vishvananda/netlink/nl" ) -var ( - networkState *NetworkInterfaceState -) - -type DhcpTargetState int - const ( - DhcpTargetStateDoNothing DhcpTargetState = iota - DhcpTargetStateStart - DhcpTargetStateStop - DhcpTargetStateRenew - DhcpTargetStateRelease -) - -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"` - Scope int `json:"scope"` -} - -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 - - dhcpClient *udhcpc.DHCPClient - - onStateChange func(state *NetworkInterfaceState) - onInitialCheck func(state *NetworkInterfaceState) - - checked bool -} - -type NetworkConfig struct { - Hostname string `json:"hostname,omitempty"` - Domain string `json:"domain,omitempty"` - - IPv4Mode string `json:"ipv4_mode" one_of:"dhcp,static,disabled" default:"dhcp"` - IPv4Static struct { - Address string `json:"address" validate_type:"ipv4"` - Netmask string `json:"netmask" validate_type:"ipv4"` - Gateway string `json:"gateway" validate_type:"ipv4"` - DNS []string `json:"dns" validate_type:"ipv4"` - } `json:"ipv4_static,omitempty" required_if:"ipv4_mode,static"` - - IPv6Mode string `json:"ipv6_mode" one_of:"slaac,dhcpv6,slaac_and_dhcpv6,static,link_local,disabled" default:"slaac"` - IPv6Static struct { - Address string `json:"address" validate_type:"ipv6"` - Netmask string `json:"netmask" validate_type:"ipv6"` - Gateway string `json:"gateway" validate_type:"ipv6"` - DNS []string `json:"dns" validate_type:"ipv6"` - } `json:"ipv6_static,omitempty" required_if:"ipv6_mode,static"` - - LLDPMode string `json:"lldp_mode,omitempty" one_of:"disabled,basic,all" default:"basic"` - LLDPTxTLVs []string `json:"lldp_tx_tlvs,omitempty" one_of:"chassis,port,system,vlan" default:"chassis,port,system,vlan"` - MDNSMode string `json:"mdns_mode,omitempty" one_of:"disabled,auto,ipv4_only,ipv6_only" default:"auto"` - TimeSyncMode string `json:"time_sync_mode,omitempty" one_of:"ntp_only,ntp_and_http,http_only,custom" default:"ntp_and_http"` -} - -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 { - IPv4Mode string `json:"ipv4_mode,omitempty"` - IPv6Mode string `json:"ipv6_mode,omitempty"` - LLDPMode string `json:"lldp_mode,omitempty"` - LLDPTxTLVs []string `json:"lldp_tx_tlvs,omitempty"` - MDNSMode string `json:"mdns_mode,omitempty"` - TimeSyncMode string `json:"time_sync_mode,omitempty"` -} - -func lifetimeToTime(lifetime int) *time.Time { - if lifetime == 0 { - return nil - } - t := time.Now().Add(time.Duration(lifetime) * time.Second) - return &t -} - -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() -} - -const ( - // TODO: add support for multiple interfaces NetIfName = "eth0" ) -func NewNetworkInterfaceState(ifname string) *NetworkInterfaceState { - logger := networkLogger.With().Str("interface", ifname).Logger() - - s := &NetworkInterfaceState{ - interfaceName: ifname, - stateLock: sync.Mutex{}, - l: &logger, - onStateChange: func(state *NetworkInterfaceState) { - go func() { - waitCtrlClientConnected() - requestDisplayUpdate(true) - }() - }, - onInitialCheck: func(state *NetworkInterfaceState) { - go func() { - waitCtrlClientConnected() - requestDisplayUpdate(true) - }() - }, - } - - // use a pid file for udhcpc if the system version is 0.2.4 or higher - dhcpPidFile := "" - systemVersionLocal, _, _ := GetLocalVersion() - if systemVersionLocal != nil && - systemVersionLocal.Compare(semver.MustParse("0.2.4")) >= 0 { - dhcpPidFile = fmt.Sprintf("/run/udhcpc.%s.pid", ifname) - } - - // create the dhcp client - dhcpClient := udhcpc.NewDHCPClient(&udhcpc.DHCPClientOptions{ - InterfaceName: ifname, - PidFile: dhcpPidFile, - Logger: &logger, - OnLeaseChange: func(lease *udhcpc.Lease) { - _, err := s.update() - if err != nil { - logger.Error().Err(err).Msg("failed to update network state") - return - } - - if currentSession == nil { - logger.Info().Msg("No active RPC session, skipping network state update") - return - } - - writeJSONRPCEvent("networkState", rpcGetNetworkState(), currentSession) - }, - }) - - s.dhcpClient = dhcpClient - - return s -} - -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 := netlink.AddrList(iface, nl.FAMILY_ALL) - if err != nil { - s.l.Error().Err(err).Msg("failed to get ip addresses") - return dhcpTargetState, 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 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 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) HandleLinkUpdate(update netlink.LinkUpdate) { - if update.Link.Attrs().Name == s.interfaceName { - s.l.Info().Interface("update", update).Msg("interface link update received") - _ = s.CheckAndUpdateDhcp() - } -} - -func rpcGetNetworkState() RpcNetworkState { - ipv6Addresses := make([]RpcIPv6Address, 0) - for _, addr := range networkState.ipv6Addresses { - ipv6Addresses = append(ipv6Addresses, RpcIPv6Address{ - Address: addr.Prefix.String(), - ValidLifetime: addr.ValidLifetime, - PreferredLifetime: addr.PreferredLifetime, - Scope: addr.Scope, - }) - } - return RpcNetworkState{ - InterfaceName: networkState.interfaceName, - MacAddress: networkState.macAddr.String(), - IPv4: networkState.ipv4Addr.String(), - IPv6: networkState.ipv6Addr.String(), - IPv6LinkLocal: networkState.ipv6LinkLocal.String(), - IPv4Addresses: networkState.ipv4Addresses, - IPv6Addresses: ipv6Addresses, - DHCPLease: networkState.dhcpClient.GetLease(), - } -} - -func rpcGetNetworkSettings() RpcNetworkSettings { - return RpcNetworkSettings{ - IPv4Mode: "dhcp", - IPv6Mode: "slaac", - LLDPMode: "basic", - LLDPTxTLVs: []string{"chassis", "port", "system", "vlan"}, - MDNSMode: "auto", - TimeSyncMode: "ntp_and_http", - } -} - -func rpcRenewDHCPLease() error { - if networkState == nil { - return fmt.Errorf("network state not initialized") - } - if networkState.dhcpClient == nil { - return fmt.Errorf("dhcp client not initialized") - } - - return networkState.dhcpClient.Renew() -} +var ( + networkState *network.NetworkInterfaceState +) func initNetwork() { ensureConfigLoaded() - updates := make(chan netlink.LinkUpdate) - done := make(chan struct{}) + networkState = network.NewNetworkInterfaceState(&network.NetworkInterfaceOptions{ + InterfaceName: NetIfName, + NetworkConfig: config.NetworkConfig, + Logger: networkLogger, + OnStateChange: func(state *network.NetworkInterfaceState) { + waitCtrlAndRequestDisplayUpdate(true) + }, + OnInitialCheck: func(state *network.NetworkInterfaceState) { + waitCtrlAndRequestDisplayUpdate(true) + }, + OnDhcpLeaseChange: func(lease *udhcpc.Lease) { + waitCtrlAndRequestDisplayUpdate(true) + }, + }) - if err := netlink.LinkSubscribe(updates, done); err != nil { - networkLogger.Warn().Err(err).Msg("failed to subscribe to link updates") - return - } - - // TODO: support multiple interfaces - networkState = NewNetworkInterfaceState(NetIfName) - go networkState.dhcpClient.Run() // nolint:errcheck - - if err := networkState.CheckAndUpdateDhcp(); err != nil { + err := networkState.Run() + if err != nil { + networkLogger.Error().Err(err).Msg("failed to run network state") os.Exit(1) } - - go func() { - ticker := time.NewTicker(1 * time.Second) - defer ticker.Stop() - - for { - select { - case update := <-updates: - networkState.HandleLinkUpdate(update) - case <-ticker.C: - _ = networkState.CheckAndUpdateDhcp() - case <-done: - return - } - } - }() }