From c775979ccbf5a4784d78d689f4583ab171118f87 Mon Sep 17 00:00:00 2001 From: Aveline <352441+ym@users.noreply.github.com> Date: Wed, 15 Oct 2025 18:32:58 +0200 Subject: [PATCH] feat: refactoring network stack (#878) Co-authored-by: Adam Shiervani --- .vscode/settings.json | 9 +- Makefile | 6 + cloud.go | 2 +- cmd/main.go | 111 ++- config.go | 142 +-- display.go | 44 +- go.mod | 8 + go.sum | 22 + internal/confparser/confparser.go | 84 +- internal/confparser/confparser_test.go | 10 +- internal/confparser/utils.go | 2 +- internal/mdns/mdns.go | 44 +- internal/native/eez/jetkvm.eez-project | 862 ++++++++++++++++- internal/native/eez/src/ui/actions.c | 24 + internal/native/eez/src/ui/actions.h | 2 + internal/native/eez/src/ui/screens.c | 237 +++++ internal/native/eez/src/ui/screens.h | 14 + internal/network/dhcp.go | 11 - internal/network/hostname.go | 137 --- internal/network/netif.go | 403 -------- internal/network/netif_linux.go | 58 -- internal/network/netif_notlinux.go | 21 - internal/network/rpc.go | 126 --- internal/network/{ => types}/config.go | 96 +- .../parser.go => network/types/dhcp.go} | 186 +--- internal/network/types/interface.go | 62 ++ internal/network/types/ip.go | 85 ++ internal/network/types/resolvconf.go | 22 + internal/network/utils.go | 26 - internal/sync/log.go | 149 +++ internal/sync/mutex.go | 69 ++ internal/sync/once.go | 18 + internal/sync/release.go | 92 ++ internal/sync/waitgroup.go | 30 + internal/timesync/ntp.go | 46 +- internal/timesync/timesync.go | 72 +- jsonrpc.go | 7 +- main.go | 4 +- mdns.go | 16 +- native.go | 2 + network.go | 325 +++++-- ota.go | 9 + pkg/nmlite/dhcp.go | 219 +++++ pkg/nmlite/hostname.go | 261 ++++++ pkg/nmlite/interface.go | 853 +++++++++++++++++ pkg/nmlite/interface_state.go | 163 ++++ pkg/nmlite/jetdhcpc/client.go | 407 ++++++++ pkg/nmlite/jetdhcpc/dhcp4.go | 85 ++ pkg/nmlite/jetdhcpc/dhcp6.go | 135 +++ pkg/nmlite/jetdhcpc/lease.go | 313 +++++++ pkg/nmlite/jetdhcpc/legacy.go | 102 ++ pkg/nmlite/jetdhcpc/logging.go | 64 ++ pkg/nmlite/jetdhcpc/state.go | 247 +++++ pkg/nmlite/jetdhcpc/utils.go | 48 + pkg/nmlite/link/consts.go | 13 + pkg/nmlite/link/manager.go | 544 +++++++++++ pkg/nmlite/link/netlink.go | 164 ++++ pkg/nmlite/link/sysctl.go | 52 ++ pkg/nmlite/link/types.go | 13 + pkg/nmlite/link/utils.go | 87 ++ pkg/nmlite/manager.go | 260 ++++++ pkg/nmlite/netlink.go | 7 + pkg/nmlite/resolvconf.go | 209 +++++ pkg/nmlite/state.go | 106 +++ pkg/nmlite/static.go | 184 ++++ {internal => pkg/nmlite}/udhcpc/options.go | 0 pkg/nmlite/udhcpc/parser.go | 171 ++++ .../nmlite}/udhcpc/parser_test.go | 0 {internal => pkg/nmlite}/udhcpc/proc.go | 0 {internal => pkg/nmlite}/udhcpc/udhcpc.go | 62 +- pkg/nmlite/utils.go | 76 ++ scripts/dev_deploy.sh | 17 +- timesync.go | 14 +- ui/package-lock.json | 17 + ui/package.json | 1 + ui/src/components/ConfirmDialog.tsx | 103 +- ui/src/components/DhcpLeaseCard.tsx | 83 +- ui/src/components/Ipv6NetworkCard.tsx | 140 +-- ui/src/components/SettingsPageheader.tsx | 13 +- ui/src/components/StaticIpv4Card.tsx | 137 +++ ui/src/components/StaticIpv6Card.tsx | 117 +++ ui/src/components/VideoOverlay.tsx | 188 +++- ui/src/components/WebRTCVideo.tsx | 7 +- ui/src/components/useCopyToClipBoard.tsx | 49 + ui/src/hooks/stores.ts | 55 +- ui/src/index.css | 9 + .../devices.$id.settings.general.reboot.tsx | 6 +- .../routes/devices.$id.settings.network.tsx | 882 ++++++++++-------- ui/src/routes/devices.$id.tsx | 67 +- ui/src/utils/ip.ts | 10 + ui/src/utils/jsonrpc.ts | 103 ++ web.go | 12 + 92 files changed, 8763 insertions(+), 1807 deletions(-) delete mode 100644 internal/network/dhcp.go delete mode 100644 internal/network/hostname.go delete mode 100644 internal/network/netif.go delete mode 100644 internal/network/netif_linux.go delete mode 100644 internal/network/netif_notlinux.go delete mode 100644 internal/network/rpc.go rename internal/network/{ => types}/config.go (62%) rename internal/{udhcpc/parser.go => network/types/dhcp.go} (55%) create mode 100644 internal/network/types/interface.go create mode 100644 internal/network/types/ip.go create mode 100644 internal/network/types/resolvconf.go delete mode 100644 internal/network/utils.go create mode 100644 internal/sync/log.go create mode 100644 internal/sync/mutex.go create mode 100644 internal/sync/once.go create mode 100644 internal/sync/release.go create mode 100644 internal/sync/waitgroup.go create mode 100644 pkg/nmlite/dhcp.go create mode 100644 pkg/nmlite/hostname.go create mode 100644 pkg/nmlite/interface.go create mode 100644 pkg/nmlite/interface_state.go create mode 100644 pkg/nmlite/jetdhcpc/client.go create mode 100644 pkg/nmlite/jetdhcpc/dhcp4.go create mode 100644 pkg/nmlite/jetdhcpc/dhcp6.go create mode 100644 pkg/nmlite/jetdhcpc/lease.go create mode 100644 pkg/nmlite/jetdhcpc/legacy.go create mode 100644 pkg/nmlite/jetdhcpc/logging.go create mode 100644 pkg/nmlite/jetdhcpc/state.go create mode 100644 pkg/nmlite/jetdhcpc/utils.go create mode 100644 pkg/nmlite/link/consts.go create mode 100644 pkg/nmlite/link/manager.go create mode 100644 pkg/nmlite/link/netlink.go create mode 100644 pkg/nmlite/link/sysctl.go create mode 100644 pkg/nmlite/link/types.go create mode 100644 pkg/nmlite/link/utils.go create mode 100644 pkg/nmlite/manager.go create mode 100644 pkg/nmlite/netlink.go create mode 100644 pkg/nmlite/resolvconf.go create mode 100644 pkg/nmlite/state.go create mode 100644 pkg/nmlite/static.go rename {internal => pkg/nmlite}/udhcpc/options.go (100%) create mode 100644 pkg/nmlite/udhcpc/parser.go rename {internal => pkg/nmlite}/udhcpc/parser_test.go (100%) rename {internal => pkg/nmlite}/udhcpc/proc.go (100%) rename {internal => pkg/nmlite}/udhcpc/udhcpc.go (77%) create mode 100644 pkg/nmlite/utils.go create mode 100644 ui/src/components/StaticIpv4Card.tsx create mode 100644 ui/src/components/StaticIpv6Card.tsx create mode 100644 ui/src/components/useCopyToClipBoard.tsx create mode 100644 ui/src/utils/ip.ts create mode 100644 ui/src/utils/jsonrpc.ts diff --git a/.vscode/settings.json b/.vscode/settings.json index a86e6b63..ba3550bf 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -3,5 +3,12 @@ "cva", "cx" ], - "git.ignoreLimitWarning": true + "gopls": { + "build.buildFlags": [ + "-tags", + "synctrace" + ] + }, + "git.ignoreLimitWarning": true, + "cmake.sourceDirectory": "/workspaces/kvm-static-ip/internal/native/cgo" } \ No newline at end of file diff --git a/Makefile b/Makefile index c3554879..e519a75a 100644 --- a/Makefile +++ b/Makefile @@ -12,7 +12,13 @@ BUILDKIT_FLAVOR := arm-rockchip830-linux-uclibcgnueabihf BUILDKIT_PATH ?= /opt/jetkvm-native-buildkit SKIP_NATIVE_IF_EXISTS ?= 0 SKIP_UI_BUILD ?= 0 +ENABLE_SYNC_TRACE ?= 0 + GO_BUILD_ARGS := -tags netgo,timetzdata,nomsgpack +ifeq ($(ENABLE_SYNC_TRACE), 1) + GO_BUILD_ARGS := $(GO_BUILD_ARGS),synctrace +endif + GO_RELEASE_BUILD_ARGS := -trimpath $(GO_BUILD_ARGS) GO_LDFLAGS := \ -s -w \ diff --git a/cloud.go b/cloud.go index a851d51f..dbbd3bbc 100644 --- a/cloud.go +++ b/cloud.go @@ -494,7 +494,7 @@ func RunWebsocketClient() { } // If the network is not up, well, we can't connect to the cloud. - if !networkState.IsOnline() { + if !networkManager.IsOnline() { cloudLogger.Warn().Msg("waiting for network to be online, will retry in 3 seconds") time.Sleep(3 * time.Second) continue diff --git a/cmd/main.go b/cmd/main.go index 59033c47..d9636088 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -16,10 +16,10 @@ import ( ) const ( - envChildID = "JETKVM_CHILD_ID" - errorDumpDir = "/userdata/jetkvm/" - errorDumpStateFile = ".has_error_dump" - errorDumpTemplate = "jetkvm-%s.log" + envChildID = "JETKVM_CHILD_ID" + errorDumpDir = "/userdata/jetkvm/crashdump" + errorDumpLastFile = "last-crash.log" + errorDumpTemplate = "jetkvm-%s.log" ) func program() { @@ -117,30 +117,47 @@ func supervise() error { return nil } -func createErrorDump(logFile *os.File) { - logFile.Close() - - // touch the error dump state file - if err := os.WriteFile(filepath.Join(errorDumpDir, errorDumpStateFile), []byte{}, 0644); err != nil { - return - } - - fileName := fmt.Sprintf(errorDumpTemplate, time.Now().Format("20060102150405")) - filePath := filepath.Join(errorDumpDir, fileName) - if err := os.Rename(logFile.Name(), filePath); err == nil { - fmt.Printf("error dump created: %s\n", filePath) - return - } - - fnSrc, err := os.Open(logFile.Name()) +func isSymlinkTo(oldName, newName string) bool { + file, err := os.Stat(newName) if err != nil { - return + return false + } + if file.Mode()&os.ModeSymlink != os.ModeSymlink { + return false + } + target, err := os.Readlink(newName) + if err != nil { + return false + } + return target == oldName +} + +func ensureSymlink(oldName, newName string) error { + if isSymlinkTo(oldName, newName) { + return nil + } + _ = os.Remove(newName) + return os.Symlink(oldName, newName) +} + +func renameFile(f *os.File, newName string) error { + _ = f.Close() + + // try to rename the file first + if err := os.Rename(f.Name(), newName); err == nil { + return nil + } + + // copy the log file to the error dump directory + fnSrc, err := os.Open(f.Name()) + if err != nil { + return fmt.Errorf("failed to open file: %w", err) } defer fnSrc.Close() - fnDst, err := os.Create(filePath) + fnDst, err := os.Create(newName) if err != nil { - return + return fmt.Errorf("failed to create file: %w", err) } defer fnDst.Close() @@ -148,18 +165,60 @@ func createErrorDump(logFile *os.File) { for { n, err := fnSrc.Read(buf) if err != nil && err != io.EOF { - return + return fmt.Errorf("failed to read file: %w", err) } if n == 0 { break } if _, err := fnDst.Write(buf[:n]); err != nil { - return + return fmt.Errorf("failed to write file: %w", err) } } - fmt.Printf("error dump created: %s\n", filePath) + return nil +} + +func ensureErrorDumpDir() error { + // TODO: check if the directory is writable + f, err := os.Stat(errorDumpDir) + if err == nil && f.IsDir() { + return nil + } + if err := os.MkdirAll(errorDumpDir, 0755); err != nil { + return fmt.Errorf("failed to create error dump directory: %w", err) + } + return nil +} + +func createErrorDump(logFile *os.File) { + fmt.Println() + + fileName := fmt.Sprintf( + errorDumpTemplate, + time.Now().Format("20060102-150405"), + ) + + // check if the directory exists + if err := ensureErrorDumpDir(); err != nil { + fmt.Printf("failed to ensure error dump directory: %v\n", err) + return + } + + filePath := filepath.Join(errorDumpDir, fileName) + if err := renameFile(logFile, filePath); err != nil { + fmt.Printf("failed to rename file: %v\n", err) + return + } + + fmt.Printf("error dump copied: %s\n", filePath) + + lastFilePath := filepath.Join(errorDumpDir, errorDumpLastFile) + + if err := ensureSymlink(filePath, lastFilePath); err != nil { + fmt.Printf("failed to create symlink: %v\n", err) + return + } } func doSupervise() { diff --git a/config.go b/config.go index 0b008912..36df92da 100644 --- a/config.go +++ b/config.go @@ -7,8 +7,9 @@ import ( "strconv" "sync" + "github.com/jetkvm/kvm/internal/confparser" "github.com/jetkvm/kvm/internal/logging" - "github.com/jetkvm/kvm/internal/network" + "github.com/jetkvm/kvm/internal/network/types" "github.com/jetkvm/kvm/internal/usbgadget" "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus/promauto" @@ -78,33 +79,33 @@ 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"` - JigglerConfig *JigglerConfig `json:"jiggler_config"` - 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 - LocalLoopbackOnly bool `json:"local_loopback_only"` - WakeOnLanDevices []WakeOnLanDevice `json:"wake_on_lan_devices"` - KeyboardMacros []KeyboardMacro `json:"keyboard_macros"` - KeyboardLayout string `json:"keyboard_layout"` - EdidString string `json:"hdmi_edid_string"` - ActiveExtension string `json:"active_extension"` - DisplayRotation string `json:"display_rotation"` - 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"` - VideoSleepAfterSec int `json:"video_sleep_after_sec"` + 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"` + JigglerConfig *JigglerConfig `json:"jiggler_config"` + 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 + LocalLoopbackOnly bool `json:"local_loopback_only"` + WakeOnLanDevices []WakeOnLanDevice `json:"wake_on_lan_devices"` + KeyboardMacros []KeyboardMacro `json:"keyboard_macros"` + KeyboardLayout string `json:"keyboard_layout"` + EdidString string `json:"hdmi_edid_string"` + ActiveExtension string `json:"active_extension"` + DisplayRotation string `json:"display_rotation"` + 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 *types.NetworkConfig `json:"network_config"` + DefaultLogLevel string `json:"default_log_level"` + VideoSleepAfterSec int `json:"video_sleep_after_sec"` } func (c *Config) GetDisplayRotation() uint16 { @@ -128,41 +129,55 @@ func (c *Config) SetDisplayRotation(rotation string) error { const configPath = "/userdata/kvm_config.json" -var defaultConfig = &Config{ - CloudURL: "https://api.jetkvm.com", - CloudAppURL: "https://app.jetkvm.com", - AutoUpdateEnabled: true, // Set a default value - ActiveExtension: "", - KeyboardMacros: []KeyboardMacro{}, - DisplayRotation: "270", - KeyboardLayout: "en-US", - DisplayMaxBrightness: 64, - DisplayDimAfterSec: 120, // 2 minutes - DisplayOffAfterSec: 1800, // 30 minutes - JigglerEnabled: false, - // This is the "Standard" jiggler option in the UI - JigglerConfig: &JigglerConfig{ +// it's a temporary solution to avoid sharing the same pointer +// we should migrate to a proper config solution in the future +var ( + defaultJigglerConfig = JigglerConfig{ InactivityLimitSeconds: 60, JitterPercentage: 25, ScheduleCronTab: "0 * * * * *", Timezone: "UTC", - }, - TLSMode: "", - UsbConfig: &usbgadget.Config{ + } + defaultUsbConfig = usbgadget.Config{ VendorId: "0x1d6b", //The Linux Foundation ProductId: "0x0104", //Multifunction Composite Gadget SerialNumber: "", Manufacturer: "JetKVM", Product: "USB Emulation Device", - }, - UsbDevices: &usbgadget.Devices{ + } + defaultUsbDevices = usbgadget.Devices{ AbsoluteMouse: true, RelativeMouse: true, Keyboard: true, MassStorage: true, - }, - NetworkConfig: &network.NetworkConfig{}, - DefaultLogLevel: "INFO", + } +) + +func getDefaultConfig() Config { + return Config{ + CloudURL: "https://api.jetkvm.com", + CloudAppURL: "https://app.jetkvm.com", + AutoUpdateEnabled: true, // Set a default value + ActiveExtension: "", + KeyboardMacros: []KeyboardMacro{}, + DisplayRotation: "270", + KeyboardLayout: "en-US", + DisplayMaxBrightness: 64, + DisplayDimAfterSec: 120, // 2 minutes + DisplayOffAfterSec: 1800, // 30 minutes + JigglerEnabled: false, + // This is the "Standard" jiggler option in the UI + JigglerConfig: func() *JigglerConfig { c := defaultJigglerConfig; return &c }(), + TLSMode: "", + UsbConfig: func() *usbgadget.Config { c := defaultUsbConfig; return &c }(), + UsbDevices: func() *usbgadget.Devices { c := defaultUsbDevices; return &c }(), + NetworkConfig: func() *types.NetworkConfig { + c := &types.NetworkConfig{} + _ = confparser.SetDefaultsAndValidate(c) + return c + }(), + DefaultLogLevel: "INFO", + } } var ( @@ -195,7 +210,8 @@ func LoadConfig() { } // load the default config - config = defaultConfig + defaultConfig := getDefaultConfig() + config = &defaultConfig file, err := os.Open(configPath) if err != nil { @@ -207,7 +223,7 @@ func LoadConfig() { defer file.Close() // load and merge the default config with the user config - loadedConfig := *defaultConfig + loadedConfig := defaultConfig if err := json.NewDecoder(file).Decode(&loadedConfig); err != nil { logger.Warn().Err(err).Msg("config file JSON parsing failed") configSuccess.Set(0.0) @@ -216,19 +232,19 @@ func LoadConfig() { // merge the user config with the default config if loadedConfig.UsbConfig == nil { - loadedConfig.UsbConfig = defaultConfig.UsbConfig + loadedConfig.UsbConfig = getDefaultConfig().UsbConfig } if loadedConfig.UsbDevices == nil { - loadedConfig.UsbDevices = defaultConfig.UsbDevices + loadedConfig.UsbDevices = getDefaultConfig().UsbDevices } if loadedConfig.NetworkConfig == nil { - loadedConfig.NetworkConfig = defaultConfig.NetworkConfig + loadedConfig.NetworkConfig = getDefaultConfig().NetworkConfig } if loadedConfig.JigglerConfig == nil { - loadedConfig.JigglerConfig = defaultConfig.JigglerConfig + loadedConfig.JigglerConfig = getDefaultConfig().JigglerConfig } // fixup old keyboard layout value @@ -247,17 +263,25 @@ func LoadConfig() { } func SaveConfig() error { + return saveConfig(configPath) +} + +func SaveBackupConfig() error { + return saveConfig(configPath + ".bak") +} + +func saveConfig(path string) error { configLock.Lock() defer configLock.Unlock() - logger.Trace().Str("path", configPath).Msg("Saving config") + logger.Trace().Str("path", path).Msg("Saving config") // fixup old keyboard layout value if config.KeyboardLayout == "en_US" { config.KeyboardLayout = "en-US" } - file, err := os.Create(configPath) + file, err := os.Create(path) if err != nil { return fmt.Errorf("failed to create config file: %w", err) } @@ -273,7 +297,7 @@ func SaveConfig() error { return fmt.Errorf("failed to wite config: %w", err) } - logger.Info().Str("path", configPath).Msg("config saved") + logger.Info().Str("path", path).Msg("config saved") return nil } diff --git a/display.go b/display.go index b414a353..042bf122 100644 --- a/display.go +++ b/display.go @@ -27,7 +27,12 @@ const ( ) func switchToMainScreen() { - if networkState.IsUp() { + if networkManager == nil { + nativeInstance.SwitchToScreenIfDifferent("no_network_screen") + return + } + + if networkManager.IsUp() { nativeInstance.SwitchToScreenIfDifferent("home_screen") } else { nativeInstance.SwitchToScreenIfDifferent("no_network_screen") @@ -35,13 +40,21 @@ func switchToMainScreen() { } func updateDisplay() { - nativeInstance.UpdateLabelIfChanged("home_info_ipv4_addr", networkState.IPv4String()) - nativeInstance.UpdateLabelAndChangeVisibility("home_info_ipv6_addr", networkState.IPv6String()) + if networkManager != nil { + nativeInstance.UpdateLabelIfChanged("home_info_ipv4_addr", networkManager.IPv4String()) + nativeInstance.UpdateLabelAndChangeVisibility("home_info_ipv6_addr", networkManager.IPv6String()) + nativeInstance.UpdateLabelIfChanged("home_info_mac_addr", networkManager.MACString()) + } _, _ = nativeInstance.UIObjHide("menu_btn_network") _, _ = nativeInstance.UIObjHide("menu_btn_access") - nativeInstance.UpdateLabelIfChanged("home_info_mac_addr", networkState.MACString()) + switch config.NetworkConfig.DHCPClient.String { + case "jetdhcpc": + nativeInstance.UpdateLabelIfChanged("dhcp_client_change_label", "Change to udhcpc") + case "udhcpc": + nativeInstance.UpdateLabelIfChanged("dhcp_client_change_label", "Change to JetKVM") + } if usbState == "configured" { nativeInstance.UpdateLabelIfChanged("usb_status_label", "Connected") @@ -59,7 +72,7 @@ func updateDisplay() { } nativeInstance.UpdateLabelIfChanged("cloud_status_label", fmt.Sprintf("%d active", actionSessions)) - if networkState.IsUp() { + if networkManager != nil && networkManager.IsUp() { nativeInstance.UISetVar("main_screen", "home_screen") nativeInstance.SwitchToScreenIf("home_screen", []string{"no_network_screen", "boot_screen"}) } else { @@ -175,7 +188,7 @@ func requestDisplayUpdate(shouldWakeDisplay bool, reason string) { wakeDisplay(false, reason) } displayLogger.Debug().Msg("display updating") - //TODO: only run once regardless how many pending updates + // TODO: only run once regardless how many pending updates updateDisplay() }() } @@ -184,13 +197,14 @@ func waitCtrlAndRequestDisplayUpdate(shouldWakeDisplay bool, reason string) { waitDisplayUpdate.Lock() defer waitDisplayUpdate.Unlock() - // nativeInstance.WaitCtrlClientConnected() requestDisplayUpdate(shouldWakeDisplay, reason) } func updateStaticContents() { //contents that never change - nativeInstance.UpdateLabelIfChanged("home_info_mac_addr", networkState.MACString()) + if networkManager != nil { + nativeInstance.UpdateLabelIfChanged("home_info_mac_addr", networkManager.MACString()) + } // get cpu info if cpuInfo, err := os.ReadFile("/proc/cpuinfo"); err == nil { @@ -326,11 +340,8 @@ func startBacklightTickers() { dimTicker = time.NewTicker(time.Duration(config.DisplayDimAfterSec) * time.Second) go func() { - for { //nolint:staticcheck - select { - case <-dimTicker.C: - tick_displayDim() - } + for range dimTicker.C { + tick_displayDim() } }() } @@ -340,11 +351,8 @@ func startBacklightTickers() { offTicker = time.NewTicker(time.Duration(config.DisplayOffAfterSec) * time.Second) go func() { - for { //nolint:staticcheck - select { - case <-offTicker.C: - tick_displayOff() - } + for range offTicker.C { + tick_displayOff() } }() } diff --git a/go.mod b/go.mod index 8605693e..e4ada2c0 100644 --- a/go.mod +++ b/go.mod @@ -16,6 +16,7 @@ require ( github.com/google/uuid v1.6.0 github.com/guregu/null/v6 v6.0.0 github.com/gwatts/rootcerts v0.0.0-20250901182336-dc5ae18bd79f + github.com/insomniacslk/dhcp v0.0.0-20250919081422-f80a1952f48e github.com/pion/logging v0.2.4 github.com/pion/mdns/v2 v2.0.7 github.com/pion/webrtc/v4 v4.1.4 @@ -54,15 +55,20 @@ require ( github.com/go-playground/validator/v10 v10.27.0 // indirect github.com/goccy/go-json v0.10.5 // indirect github.com/jonboulle/clockwork v0.5.0 // indirect + github.com/josharian/native v1.1.0 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mdlayher/ndp v1.1.0 // indirect + github.com/mdlayher/packet v1.1.2 // indirect + github.com/mdlayher/socket v0.4.1 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/pierrec/lz4/v4 v4.1.14 // indirect github.com/pilebones/go-udev v0.9.1 // indirect github.com/pion/datachannel v1.5.10 // indirect github.com/pion/dtls/v3 v3.0.7 // indirect @@ -82,12 +88,14 @@ require ( github.com/robfig/cron/v3 v3.0.1 // indirect github.com/rogpeppe/go-internal v1.14.1 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/u-root/uio v0.0.0-20230220225925-ffce2a382923 // indirect github.com/ugorji/go/codec v1.3.0 // indirect github.com/vishvananda/netns v0.0.5 // indirect github.com/wlynxg/anet v0.0.5 // indirect go.yaml.in/yaml/v2 v2.4.2 // indirect golang.org/x/arch v0.20.0 // indirect golang.org/x/oauth2 v0.30.0 // indirect + golang.org/x/sync v0.17.0 // indirect golang.org/x/text v0.29.0 // indirect google.golang.org/protobuf v1.36.9 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect diff --git a/go.sum b/go.sum index d5b03602..9df5e759 100644 --- a/go.sum +++ b/go.sum @@ -66,8 +66,15 @@ 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-20250901182336-dc5ae18bd79f h1:08t2PbrkDgW2+mwCQ3jhKUBrCM9Bc9SeH5j2Dst3B+0= github.com/gwatts/rootcerts v0.0.0-20250901182336-dc5ae18bd79f/go.mod h1:5Kt9XkWvkGi2OHOq0QsGxebHmhCcqJ8KCbNg/a6+n+g= +github.com/hugelgupf/socketpair v0.0.0-20190730060125-05d35a94e714 h1:/jC7qQFrv8CrSJVmaolDVOxTfS9kc36uB6H40kdbQq8= +github.com/hugelgupf/socketpair v0.0.0-20190730060125-05d35a94e714/go.mod h1:2Goc3h8EklBH5mspfHFxBnEoURQCGzQQH1ga9Myjvis= +github.com/insomniacslk/dhcp v0.0.0-20250919081422-f80a1952f48e h1:nu5z6Kg+gMNW6tdqnVjg/QEJ8Nw71IJQqOtWj00XHEU= +github.com/insomniacslk/dhcp v0.0.0-20250919081422-f80a1952f48e/go.mod h1:qfvBmyDNp+/liLEYWRvqny/PEz9hGe2Dz833eXILSmo= github.com/jonboulle/clockwork v0.5.0 h1:Hyh9A8u51kptdkR+cqRpT1EebBwTn1oK9YfGYbdFz6I= github.com/jonboulle/clockwork v0.5.0/go.mod h1:3mZlmanh0g2NDKO5TWZVJAfofYk64M7XN3SzBPjZF60= +github.com/josharian/native v1.0.1-0.20221213033349-c1e37c09b531/go.mod h1:7X/raswPFr05uY3HiLlYeyQntB6OO7E/d2Cu7qoaN2w= +github.com/josharian/native v1.1.0 h1:uuaP0hAbW7Y4l0ZRQ6C9zfb7Mg1mbFKry/xzDAfmtLA= +github.com/josharian/native v1.1.0/go.mod h1:7X/raswPFr05uY3HiLlYeyQntB6OO7E/d2Cu7qoaN2w= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= @@ -92,6 +99,12 @@ github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/ github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mdlayher/ndp v1.1.0 h1:QylGKGVtH60sKZUE88+IW5ila1Z/M9/OXhWdsVKuscs= +github.com/mdlayher/ndp v1.1.0/go.mod h1:FmgESgemgjl38vuOIyAHWUUL6vQKA/pQNkvXdWsdQFM= +github.com/mdlayher/packet v1.1.2 h1:3Up1NG6LZrsgDVn6X4L9Ge/iyRyxFEFD9o6Pr3Q1nQY= +github.com/mdlayher/packet v1.1.2/go.mod h1:GEu1+n9sG5VtiRE4SydOmX5GTwyyYlteZiFU+x0kew4= +github.com/mdlayher/socket v0.4.1 h1:eM9y2/jlbs1M615oshPQOHZzj6R6wMT7bX5NPiQvn2U= +github.com/mdlayher/socket v0.4.1/go.mod h1:cAqeGjoufqdxWkD7DkpyS+wcefOtmu5OQ8KuoJGIReA= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= @@ -101,6 +114,8 @@ github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pierrec/lz4/v4 v4.1.14 h1:+fL8AQEZtz/ijeNnpduH0bROTu0O3NZAlPjQxGn8LwE= +github.com/pierrec/lz4/v4 v4.1.14/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= github.com/pilebones/go-udev v0.9.1 h1:uN72M1C1fgzhsVmBGEM8w9RD1JY4iVsPZpr+Z6rb3O8= github.com/pilebones/go-udev v0.9.1/go.mod h1:Bgcl07crebF3JSeS4+nuaRvhWFdCeFoBhXXeAp93XNo= github.com/pion/datachannel v1.5.10 h1:ly0Q26K1i6ZkGf42W7D4hQYR90pZwzFOjTq5AuCKk4o= @@ -161,6 +176,8 @@ github.com/sourcegraph/tf-dag v0.2.2-0.20250131204052-3e8ff1477b4f/go.mod h1:pzr github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= @@ -169,6 +186,8 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/u-root/uio v0.0.0-20230220225925-ffce2a382923 h1:tHNk7XK9GkmKUR6Gh8gVBKXc2MVSZ4G/NnWLtzw4gNA= +github.com/u-root/uio v0.0.0-20230220225925-ffce2a382923/go.mod h1:eLL9Nub3yfAho7qB0MzZizFhTU2QkLeoVsWdHtDW264= github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA= github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= github.com/vearutop/statigz v1.5.0 h1:FuWwZiT82yBw4xbWdWIawiP2XFTyEPhIo8upRxiKLqk= @@ -193,6 +212,9 @@ golang.org/x/net v0.44.0 h1:evd8IRDyfNBMBTTY5XRF1vaZlD+EmWx6x8PkhR04H/I= golang.org/x/net v0.44.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY= golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= +golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= +golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.0.0-20220622161953-175b2fd9d664/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= diff --git a/internal/confparser/confparser.go b/internal/confparser/confparser.go index aaa39686..c1488f4e 100644 --- a/internal/confparser/confparser.go +++ b/internal/confparser/confparser.go @@ -16,22 +16,22 @@ import ( type FieldConfig struct { Name string Required bool - RequiredIf map[string]any + RequiredIf map[string]interface{} OneOf []string ValidateTypes []string - Defaults any + Defaults interface{} IsEmpty bool - CurrentValue any + CurrentValue interface{} TypeString string Delegated bool shouldUpdateValue bool } -func SetDefaultsAndValidate(config any) error { +func SetDefaultsAndValidate(config interface{}) error { return setDefaultsAndValidate(config, true) } -func setDefaultsAndValidate(config any, isRoot bool) error { +func setDefaultsAndValidate(config interface{}, isRoot bool) error { // first we need to check if the config is a pointer if reflect.TypeOf(config).Kind() != reflect.Ptr { return fmt.Errorf("config is not a pointer") @@ -55,7 +55,7 @@ func setDefaultsAndValidate(config any, isRoot bool) error { Name: field.Name, OneOf: splitString(field.Tag.Get("one_of")), ValidateTypes: splitString(field.Tag.Get("validate_type")), - RequiredIf: make(map[string]any), + RequiredIf: make(map[string]interface{}), CurrentValue: fieldValue.Interface(), IsEmpty: false, TypeString: fieldType, @@ -142,8 +142,8 @@ func setDefaultsAndValidate(config any, isRoot bool) error { // now check if the field has required_if requiredIf := field.Tag.Get("required_if") if requiredIf != "" { - requiredIfParts := strings.SplitSeq(requiredIf, ",") - for part := range requiredIfParts { + requiredIfParts := strings.Split(requiredIf, ",") + for _, part := range requiredIfParts { partVal := strings.SplitN(part, "=", 2) if len(partVal) != 2 { return fmt.Errorf("invalid required_if for field `%s`: %s", field.Name, requiredIf) @@ -168,7 +168,7 @@ func setDefaultsAndValidate(config any, isRoot bool) error { return nil } -func validateFields(config any, fields map[string]FieldConfig) error { +func validateFields(config interface{}, fields map[string]FieldConfig) error { // now we can start to validate the fields for _, fieldConfig := range fields { if err := fieldConfig.validate(fields); err != nil { @@ -215,7 +215,7 @@ func (f *FieldConfig) validate(fields map[string]FieldConfig) error { return nil } -func (f *FieldConfig) populate(config any) { +func (f *FieldConfig) populate(config interface{}) { // update the field if it's not empty if !f.shouldUpdateValue { return @@ -346,6 +346,17 @@ func (f *FieldConfig) validateField() error { return nil } + // Handle []string types, like dns servers, time sync ntp servers, etc. + if slice, ok := f.CurrentValue.([]string); ok { + for i, item := range slice { + if err := f.validateSingleValue(item, i); err != nil { + return err + } + } + return nil + } + + // Handle single string types val, err := toString(f.CurrentValue) if err != nil { return fmt.Errorf("field `%s` cannot use validate_type: %s", f.Name, err) @@ -355,30 +366,71 @@ func (f *FieldConfig) validateField() error { return nil } + return f.validateSingleValue(val, -1) +} + +func (f *FieldConfig) validateSingleValue(val string, index int) error { for _, validateType := range f.ValidateTypes { + var fieldRef string + if index >= 0 { + fieldRef = fmt.Sprintf("field `%s[%d]`", f.Name, index) + } else { + fieldRef = fmt.Sprintf("field `%s`", f.Name) + } + switch validateType { + case "int": + if _, err := strconv.Atoi(val); err != nil { + return fmt.Errorf("field `%s` is not a valid integer: %s", fieldRef, val) + } + case "ipv6_prefix_length": + valInt, err := strconv.Atoi(val) + if err != nil { + return fmt.Errorf("field `%s` is not a valid IPv6 prefix length: %s", fieldRef, val) + } + if valInt < 0 || valInt > 128 { + return fmt.Errorf("field `%s` is not a valid IPv6 prefix length: %s", fieldRef, val) + } case "ipv4": if net.ParseIP(val).To4() == nil { - return fmt.Errorf("field `%s` is not a valid IPv4 address: %s", f.Name, val) + return fmt.Errorf("field `%s` is not a valid IPv4 address: %s", fieldRef, val) } case "ipv6": if net.ParseIP(val).To16() == nil { - return fmt.Errorf("field `%s` is not a valid IPv6 address: %s", f.Name, val) + return fmt.Errorf("field `%s` is not a valid IPv6 address: %s", fieldRef, val) + } + case "ipv6_prefix": + if i, _, err := net.ParseCIDR(val); err != nil { + if i.To16() == nil { + return fmt.Errorf("field `%s` is not a valid IPv6 prefix: %s", fieldRef, val) + } + } + case "ipv4_or_ipv6": + if net.ParseIP(val) == nil { + return fmt.Errorf("%s is not a valid IPv4 or IPv6 address: %s", fieldRef, val) } case "hwaddr": if _, err := net.ParseMAC(val); err != nil { - return fmt.Errorf("field `%s` is not a valid MAC address: %s", f.Name, val) + return fmt.Errorf("%s is not a valid MAC address: %s", fieldRef, val) } case "hostname": if _, err := idna.Lookup.ToASCII(val); err != nil { - return fmt.Errorf("field `%s` is not a valid hostname: %s", f.Name, val) + return fmt.Errorf("%s is not a valid hostname: %s", fieldRef, val) } case "proxy": if url, err := url.Parse(val); err != nil || (url.Scheme != "http" && url.Scheme != "https") || url.Host == "" { - return fmt.Errorf("field `%s` is not a valid HTTP proxy URL: %s", f.Name, val) + return fmt.Errorf("%s is not a valid HTTP proxy URL: %s", fieldRef, val) + } + case "url": + if _, err := url.Parse(val); err != nil { + return fmt.Errorf("%s is not a valid URL: %s", fieldRef, val) + } + case "cidr": + if _, _, err := net.ParseCIDR(val); err != nil { + return fmt.Errorf("%s is not a valid CIDR notation: %s", fieldRef, val) } default: - return fmt.Errorf("field `%s` cannot use validate_type: unsupported validator: %s", f.Name, validateType) + return fmt.Errorf("field `%s` cannot use validate_type: unsupported validator: %s", fieldRef, validateType) } } diff --git a/internal/confparser/confparser_test.go b/internal/confparser/confparser_test.go index ebf051d1..7e43f262 100644 --- a/internal/confparser/confparser_test.go +++ b/internal/confparser/confparser_test.go @@ -24,10 +24,10 @@ type testIPv4StaticConfig struct { } type testIPv6StaticConfig struct { - Address null.String `json:"address" validate_type:"ipv6" required:"true"` - Prefix null.String `json:"prefix" validate_type:"ipv6" required:"true"` - Gateway null.String `json:"gateway" validate_type:"ipv6" required:"true"` - DNS []string `json:"dns" validate_type:"ipv6" required:"true"` + Address null.String `json:"address" validate_type:"ipv6" required:"true"` + PrefixLength null.Int `json:"prefix_length" validate_type:"ipv6_prefix_length" required:"true"` + Gateway null.String `json:"gateway" validate_type:"ipv6" required:"true"` + DNS []string `json:"dns" validate_type:"ipv6" required:"true"` } type testNetworkConfig struct { Hostname null.String `json:"hostname,omitempty"` @@ -39,7 +39,7 @@ type testNetworkConfig struct { IPv6Mode null.String `json:"ipv6_mode" one_of:"slaac,dhcpv6,slaac_and_dhcpv6,static,link_local,disabled" default:"slaac"` IPv6Static *testIPv6StaticConfig `json:"ipv6_static,omitempty" required_if:"IPv6Mode=static"` - LLDPMode null.String `json:"lldp_mode,omitempty" one_of:"disabled,rx_only,tx_only,enabled" default:"enabled"` + LLDPMode null.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 null.String `json:"mdns_mode,omitempty" one_of:"disabled,auto,ipv4_only,ipv6_only" default:"auto"` TimeSyncMode null.String `json:"time_sync_mode,omitempty" one_of:"ntp_only,ntp_and_http,http_only,custom" default:"ntp_and_http"` diff --git a/internal/confparser/utils.go b/internal/confparser/utils.go index 36ee28b1..a46871e9 100644 --- a/internal/confparser/utils.go +++ b/internal/confparser/utils.go @@ -16,7 +16,7 @@ func splitString(s string) []string { return strings.Split(s, ",") } -func toString(v any) (string, error) { +func toString(v interface{}) (string, error) { switch v := v.(type) { case string: return v, nil diff --git a/internal/mdns/mdns.go b/internal/mdns/mdns.go index b882b93a..2b954d45 100644 --- a/internal/mdns/mdns.go +++ b/internal/mdns/mdns.go @@ -146,14 +146,17 @@ func (m *MDNS) start(allowRestart bool) error { return nil } +// Start starts the mDNS server func (m *MDNS) Start() error { return m.start(false) } +// Restart restarts the mDNS server func (m *MDNS) Restart() error { return m.start(true) } +// Stop stops the mDNS server func (m *MDNS) Stop() error { m.lock.Lock() defer m.lock.Unlock() @@ -165,26 +168,45 @@ func (m *MDNS) Stop() error { return m.conn.Close() } -func (m *MDNS) SetLocalNames(localNames []string, always bool) error { - if reflect.DeepEqual(m.localNames, localNames) && !always { - return nil +func (m *MDNS) setLocalNames(localNames []string) { + m.lock.Lock() + defer m.lock.Unlock() + + if reflect.DeepEqual(m.localNames, localNames) { + return } m.localNames = localNames - _ = m.Restart() - - return nil } -func (m *MDNS) SetListenOptions(listenOptions *MDNSListenOptions) error { +func (m *MDNS) setListenOptions(listenOptions *MDNSListenOptions) { + m.lock.Lock() + defer m.lock.Unlock() + if m.listenOptions != nil && m.listenOptions.IPv4 == listenOptions.IPv4 && m.listenOptions.IPv6 == listenOptions.IPv6 { - return nil + return } m.listenOptions = listenOptions - _ = m.Restart() - - return nil +} + +// SetLocalNames sets the local names and restarts the mDNS server +func (m *MDNS) SetLocalNames(localNames []string) error { + m.setLocalNames(localNames) + return m.Restart() +} + +// SetListenOptions sets the listen options and restarts the mDNS server +func (m *MDNS) SetListenOptions(listenOptions *MDNSListenOptions) error { + m.setListenOptions(listenOptions) + return m.Restart() +} + +// SetOptions sets the local names and listen options and restarts the mDNS server +func (m *MDNS) SetOptions(options *MDNSOptions) error { + m.setLocalNames(options.LocalNames) + m.setListenOptions(options.ListenOptions) + return m.Restart() } diff --git a/internal/native/eez/jetkvm.eez-project b/internal/native/eez/jetkvm.eez-project index 0ad49e76..42901345 100644 --- a/internal/native/eez/jetkvm.eez-project +++ b/internal/native/eez/jetkvm.eez-project @@ -48,7 +48,7 @@ { "objID": "58af3ebb-96b3-494c-f4e3-9c23852e3e42", "fileName": "actions.c", - "template": "#include \"actions.h\"\n#include \"screens.h\"\n#include \n#include \n#include \"ui.h\"\n#include \"vars.h\"\n\nint handle_gesture_screen_switch(lv_event_t *e, lv_dir_t direction, int screenId) {\n lv_event_code_t event_code = lv_event_get_code(e);\n if (event_code != LV_EVENT_GESTURE) {\n return 0;\n }\n\n if (lv_indev_get_gesture_dir(lv_indev_get_act()) != direction) {\n return 0;\n }\n lv_indev_wait_release(lv_indev_get_act());\n loadScreen(screenId);\n return 1;\n}\n\nvoid handle_gesture_main_screen_switch(lv_event_t *e, lv_dir_t direction) {\n const char *main_screen = get_var_main_screen();\n if (strcmp(main_screen, \"home_screen\") == 0) { \n loadScreen(SCREEN_ID_HOME_SCREEN);\n } else if (strcmp(main_screen, \"no_network_screen\") == 0) {\n loadScreen(SCREEN_ID_NO_NETWORK_SCREEN);\n }\n}\n\nvoid action_switch_to_menu(lv_event_t *e) {\n loadScreen(SCREEN_ID_MENU_SCREEN);\n}\n\nvoid action_switch_to_advanced_menu(lv_event_t *e) {\n loadScreen(SCREEN_ID_MENU_ADVANCED_SCREEN);\n}\n\nvoid action_switch_to_status(lv_event_t *e) {\n loadScreen(SCREEN_ID_STATUS_SCREEN);\n}\n\nvoid action_switch_to_about(lv_event_t *e) {\n loadScreen(SCREEN_ID_ABOUT_SCREEN);\n}\n\nvoid action_switch_to_reset_config(lv_event_t *e) {\n loadScreen(SCREEN_ID_RESET_CONFIG_SCREEN);\n}\n\nvoid action_switch_to_reboot(lv_event_t *e) {\n loadScreen(SCREEN_ID_REBOOT_SCREEN);\n}\n\nvoid action_menu_screen_gesture(lv_event_t * e) {\n handle_gesture_main_screen_switch(e, LV_DIR_RIGHT);\n}\n\nvoid action_menu_advanced_screen_gesture(lv_event_t * e) {\n handle_gesture_screen_switch(e, LV_DIR_RIGHT, SCREEN_ID_MENU_SCREEN);\n}\n\nvoid action_reset_config_screen_gesture(lv_event_t * e) {\n handle_gesture_screen_switch(e, LV_DIR_RIGHT, SCREEN_ID_MENU_SCREEN);\n}\n\nvoid action_home_screen_gesture(lv_event_t * e) {\n handle_gesture_screen_switch(e, LV_DIR_LEFT, SCREEN_ID_MENU_SCREEN);\n}\n\nvoid action_about_screen_gesture(lv_event_t * e) {\n handle_gesture_screen_switch(e, LV_DIR_RIGHT, SCREEN_ID_MENU_SCREEN);\n}\n\n// user_data doesn't seem to be working, so we use a global variable here\nstatic uint32_t t_reset_config;\nstatic uint32_t t_reboot;\n\nstatic bool b_reboot = false;\nstatic bool b_reset_config = false;\n\nstatic bool b_reboot_lock = false;\nstatic bool b_reset_config_lock = false;\n\nconst int RESET_CONFIG_HOLD_TIME = 10;\nconst int REBOOT_HOLD_TIME = 5;\n\ntypedef struct {\n uint32_t *start_time;\n bool *completed;\n bool *lock;\n int hold_time_seconds;\n const char *rpc_method;\n lv_obj_t *button_obj;\n lv_obj_t *spinner_obj;\n lv_obj_t *label_obj;\n const char *default_text;\n} hold_action_config_t;\n\nstatic void handle_hold_action(lv_event_t *e, hold_action_config_t *config) {\n lv_event_code_t event_code = lv_event_get_code(e);\n \n if (event_code == LV_EVENT_PRESSED) {\n *(config->start_time) = lv_tick_get();\n }\n else if (event_code == LV_EVENT_PRESSING) {\n int remaining_time = config->hold_time_seconds * 1000 - lv_tick_elaps(*(config->start_time));\n if (remaining_time <= 0) {\n if (*(config->lock)) {\n return;\n }\n if (config->button_obj && config->spinner_obj) {\n lv_obj_add_flag(config->button_obj, LV_OBJ_FLAG_HIDDEN);\n lv_obj_clear_flag(config->spinner_obj, LV_OBJ_FLAG_HIDDEN);\n }\n ui_call_rpc_handler(config->rpc_method, NULL);\n *(config->lock) = true;\n *(config->completed) = true;\n } else {\n *(config->completed) = false;\n char buf[100];\n int remaining_time_seconds = remaining_time / 1000;\n if (remaining_time_seconds <= 1) {\n remaining_time_seconds = 1;\n }\n sprintf(buf, \"Press and hold for\\n%d seconds\", remaining_time_seconds);\n lv_label_set_text(config->label_obj, buf);\n }\n } else if (event_code == LV_EVENT_RELEASED) {\n if (*(config->lock)) {\n *(config->lock) = false;\n }\n\n if (!*(config->completed)) {\n lv_label_set_text(config->label_obj, config->default_text);\n }\n }\n}\n\nvoid action_reset_config(lv_event_t * e) {\n hold_action_config_t config = {\n .start_time = &t_reset_config,\n .completed = &b_reset_config,\n .lock = &b_reset_config_lock,\n .hold_time_seconds = RESET_CONFIG_HOLD_TIME,\n .rpc_method = \"resetConfig\",\n .button_obj = objects.reset_config_button,\n .spinner_obj = objects.reset_config_spinner,\n .label_obj = objects.reset_config_label,\n .default_text = \"Press and hold for\\n10 seconds\"\n };\n \n handle_hold_action(e, &config);\n}\n\nvoid action_reboot(lv_event_t * e) {\n hold_action_config_t config = {\n .start_time = &t_reboot,\n .completed = &b_reboot,\n .lock = &b_reboot_lock,\n .hold_time_seconds = REBOOT_HOLD_TIME,\n .rpc_method = \"reboot\",\n .button_obj = NULL, // No button/spinner for reboot\n .spinner_obj = NULL,\n .label_obj = objects.reboot_label,\n .default_text = \"Press and hold for\\n5 seconds\"\n };\n \n handle_hold_action(e, &config);\n}" + "template": "#include \"actions.h\"\n#include \"screens.h\"\n#include \n#include \n#include \"ui.h\"\n#include \"vars.h\"\n\nint handle_gesture_screen_switch(lv_event_t *e, lv_dir_t direction, int screenId) {\n lv_event_code_t event_code = lv_event_get_code(e);\n if (event_code != LV_EVENT_GESTURE) {\n return 0;\n }\n\n if (lv_indev_get_gesture_dir(lv_indev_get_act()) != direction) {\n return 0;\n }\n lv_indev_wait_release(lv_indev_get_act());\n loadScreen(screenId);\n return 1;\n}\n\nvoid handle_gesture_main_screen_switch(lv_event_t *e, lv_dir_t direction) {\n const char *main_screen = get_var_main_screen();\n if (strcmp(main_screen, \"home_screen\") == 0) { \n loadScreen(SCREEN_ID_HOME_SCREEN);\n } else if (strcmp(main_screen, \"no_network_screen\") == 0) {\n loadScreen(SCREEN_ID_NO_NETWORK_SCREEN);\n }\n}\n\nvoid action_switch_to_menu(lv_event_t *e) {\n loadScreen(SCREEN_ID_MENU_SCREEN);\n}\n\nvoid action_switch_to_advanced_menu(lv_event_t *e) {\n loadScreen(SCREEN_ID_MENU_ADVANCED_SCREEN);\n}\n\nvoid action_switch_to_status(lv_event_t *e) {\n loadScreen(SCREEN_ID_STATUS_SCREEN);\n}\n\nvoid action_switch_to_about(lv_event_t *e) {\n loadScreen(SCREEN_ID_ABOUT_SCREEN);\n}\n\nvoid action_switch_to_reset_config(lv_event_t *e) {\n loadScreen(SCREEN_ID_RESET_CONFIG_SCREEN);\n}\n\nvoid action_switch_to_dhcpc(lv_event_t *e) {\n loadScreen(SCREEN_ID_SWITCH_DHCP_CLIENT_SCREEN);\n}\n\nvoid action_switch_to_reboot(lv_event_t *e) {\n loadScreen(SCREEN_ID_REBOOT_SCREEN);\n}\n\nvoid action_menu_screen_gesture(lv_event_t * e) {\n handle_gesture_main_screen_switch(e, LV_DIR_RIGHT);\n}\n\nvoid action_menu_advanced_screen_gesture(lv_event_t * e) {\n handle_gesture_screen_switch(e, LV_DIR_RIGHT, SCREEN_ID_MENU_SCREEN);\n}\n\nvoid action_reset_config_screen_gesture(lv_event_t * e) {\n handle_gesture_screen_switch(e, LV_DIR_RIGHT, SCREEN_ID_MENU_SCREEN);\n}\n\nvoid action_home_screen_gesture(lv_event_t * e) {\n handle_gesture_screen_switch(e, LV_DIR_LEFT, SCREEN_ID_MENU_SCREEN);\n}\n\nvoid action_about_screen_gesture(lv_event_t * e) {\n handle_gesture_screen_switch(e, LV_DIR_RIGHT, SCREEN_ID_MENU_SCREEN);\n}\n\n// user_data doesn't seem to be working, so we use a global variable here\nstatic uint32_t t_reset_config;\nstatic uint32_t t_reboot;\nstatic uint32_t t_dhcpc;\n\nstatic bool b_reboot = false;\nstatic bool b_reset_config = false;\nstatic bool b_dhcpc = false;\n\nstatic bool b_reboot_lock = false;\nstatic bool b_reset_config_lock = false;\nstatic bool b_dhcpc_lock = false;\n\nconst int RESET_CONFIG_HOLD_TIME = 10;\nconst int REBOOT_HOLD_TIME = 5;\nconst int DHCPC_HOLD_TIME = 5;\n\ntypedef struct {\n uint32_t *start_time;\n bool *completed;\n bool *lock;\n int hold_time_seconds;\n const char *rpc_method;\n lv_obj_t *button_obj;\n lv_obj_t *spinner_obj;\n lv_obj_t *label_obj;\n const char *default_text;\n} hold_action_config_t;\n\nstatic void handle_hold_action(lv_event_t *e, hold_action_config_t *config) {\n lv_event_code_t event_code = lv_event_get_code(e);\n \n if (event_code == LV_EVENT_PRESSED) {\n *(config->start_time) = lv_tick_get();\n }\n else if (event_code == LV_EVENT_PRESSING) {\n int remaining_time = config->hold_time_seconds * 1000 - lv_tick_elaps(*(config->start_time));\n if (remaining_time <= 0) {\n if (*(config->lock)) {\n return;\n }\n if (config->button_obj && config->spinner_obj) {\n lv_obj_add_flag(config->button_obj, LV_OBJ_FLAG_HIDDEN);\n lv_obj_clear_flag(config->spinner_obj, LV_OBJ_FLAG_HIDDEN);\n }\n ui_call_rpc_handler(config->rpc_method, NULL);\n *(config->lock) = true;\n *(config->completed) = true;\n } else {\n *(config->completed) = false;\n char buf[100];\n int remaining_time_seconds = remaining_time / 1000;\n if (remaining_time_seconds <= 1) {\n remaining_time_seconds = 1;\n }\n sprintf(buf, \"Press and hold for\\n%d seconds\", remaining_time_seconds);\n lv_label_set_text(config->label_obj, buf);\n }\n } else if (event_code == LV_EVENT_RELEASED) {\n if (*(config->lock)) {\n *(config->lock) = false;\n }\n\n if (!*(config->completed)) {\n lv_label_set_text(config->label_obj, config->default_text);\n }\n }\n}\n\nvoid action_reset_config(lv_event_t * e) {\n hold_action_config_t config = {\n .start_time = &t_reset_config,\n .completed = &b_reset_config,\n .lock = &b_reset_config_lock,\n .hold_time_seconds = RESET_CONFIG_HOLD_TIME,\n .rpc_method = \"resetConfig\",\n .button_obj = objects.reset_config_button,\n .spinner_obj = objects.reset_config_spinner,\n .label_obj = objects.reset_config_label,\n .default_text = \"Press and hold for\\n10 seconds\"\n };\n \n handle_hold_action(e, &config);\n}\n\nvoid action_dhcpc(lv_event_t * e) {\n hold_action_config_t config = {\n .start_time = &t_dhcpc,\n .completed = &b_dhcpc,\n .lock = &b_dhcpc_lock,\n .hold_time_seconds = DHCPC_HOLD_TIME,\n .rpc_method = \"toggleDHCPClient\",\n .button_obj = NULL, // No button/spinner for reboot\n .spinner_obj = NULL,\n .label_obj = objects.dhcpc_label,\n .default_text = \"Press and hold for\\n5 seconds\"\n };\n \n handle_hold_action(e, &config);\n}\n\nvoid action_reboot(lv_event_t * e) {\n hold_action_config_t config = {\n .start_time = &t_reboot,\n .completed = &b_reboot,\n .lock = &b_reboot_lock,\n .hold_time_seconds = REBOOT_HOLD_TIME,\n .rpc_method = \"reboot\",\n .button_obj = NULL, // No button/spinner for reboot\n .spinner_obj = NULL,\n .label_obj = objects.reboot_label,\n .default_text = \"Press and hold for\\n5 seconds\"\n };\n \n handle_hold_action(e, &config);\n}" }, { "objID": "1dbd1b7e-7270-47f0-ee02-e80bdae287cf", @@ -273,6 +273,22 @@ "localVariables": [], "userProperties": [], "name": "SwitchToReboot" + }, + { + "objID": "3efa8142-0c2d-4b63-e75e-8ff57d132f5e", + "components": [], + "connectionLines": [], + "localVariables": [], + "userProperties": [], + "name": "DHCPC" + }, + { + "objID": "1fbdb292-2b01-4dfe-a4ab-da889959259e", + "components": [], + "connectionLines": [], + "localVariables": [], + "userProperties": [], + "name": "SwitchToDHCPC" } ], "userPages": [ @@ -3038,7 +3054,7 @@ "left": 0, "top": 0, "width": 100, - "height": 108, + "height": 166, "customInputs": [], "customOutputs": [], "style": { @@ -3336,6 +3352,104 @@ "group": "", "groupIndex": 0 }, + { + "objID": "3a5c0243-2877-4e9f-f23f-8022eeb772cd", + "type": "LVGLButtonWidget", + "left": 0, + "top": 0, + "width": 100, + "height": 50, + "customInputs": [], + "customOutputs": [], + "style": { + "objID": "31a3a5fe-d9e6-42ba-dffe-916f7d247168", + "useStyle": "default", + "conditionalStyles": [], + "childStyles": [] + }, + "locked": false, + "hiddenInEditor": false, + "timeline": [], + "eventHandlers": [ + { + "objID": "6a730d9a-c11d-4800-9833-f4d7b5a8a40d", + "eventName": "PRESSED", + "handlerType": "action", + "action": "SwitchToDHCPC", + "userData": 0 + } + ], + "identifier": "MenuBtnDHCPClient", + "leftUnit": "px", + "topUnit": "px", + "widthUnit": "%", + "heightUnit": "px", + "children": [ + { + "objID": "929d0861-7f4d-43f0-ce71-07fafb9ac719", + "type": "LVGLLabelWidget", + "left": 0, + "top": 0, + "width": 115, + "height": 20, + "customInputs": [], + "customOutputs": [], + "style": { + "objID": "704ef898-9f7c-43a8-ab11-c48b4af1e864", + "useStyle": "default", + "conditionalStyles": [], + "childStyles": [] + }, + "hiddenInEditor": false, + "timeline": [], + "eventHandlers": [], + "identifier": "", + "leftUnit": "px", + "topUnit": "px", + "widthUnit": "content", + "heightUnit": "content", + "children": [], + "widgetFlags": "CLICK_FOCUSABLE|GESTURE_BUBBLE|PRESS_LOCK|SCROLLABLE|SCROLL_CHAIN_HOR|SCROLL_CHAIN_VER|SCROLL_ELASTIC|SCROLL_MOMENTUM|SCROLL_WITH_ARROW|SNAPPABLE", + "hiddenFlagType": "literal", + "clickableFlagType": "literal", + "flagScrollbarMode": "", + "flagScrollDirection": "", + "scrollSnapX": "", + "scrollSnapY": "", + "checkedStateType": "literal", + "disabledStateType": "literal", + "states": "", + "useStyle": "MenuButtonLabel", + "localStyles": { + "objID": "5144f730-9924-47ca-bfb9-c02449de88be" + }, + "group": "", + "groupIndex": 0, + "text": "DHCP Client", + "textType": "literal", + "longMode": "WRAP", + "recolor": false + } + ], + "widgetFlags": "CLICK_FOCUSABLE|GESTURE_BUBBLE|PRESS_LOCK|SCROLL_CHAIN_HOR|SCROLL_CHAIN_VER|SCROLL_ELASTIC|SCROLL_MOMENTUM|SCROLL_ON_FOCUS|SCROLL_WITH_ARROW", + "hiddenFlag": false, + "hiddenFlagType": "literal", + "clickableFlag": true, + "clickableFlagType": "literal", + "flagScrollbarMode": "", + "flagScrollDirection": "", + "scrollSnapX": "", + "scrollSnapY": "", + "checkedStateType": "literal", + "disabledStateType": "literal", + "states": "", + "useStyle": "MenuButton", + "localStyles": { + "objID": "2d17602a-58eb-4a21-9aff-c1f1a7d09bfe" + }, + "group": "", + "groupIndex": 0 + }, { "objID": "1077686a-8cc3-44f5-f09e-000d5e0ef066", "type": "LVGLButtonWidget", @@ -8094,6 +8208,750 @@ "isUsedAsUserWidget": false, "createAtStart": true, "deleteOnScreenUnload": false + }, + { + "objID": "606eb261-e593-41e5-d86b-6487f467eea9", + "components": [ + { + "objID": "61b8bea9-a21d-4498-c0ed-14a4bd2a4858", + "type": "LVGLScreenWidget", + "left": 0, + "top": 0, + "width": 300, + "height": 240, + "customInputs": [], + "customOutputs": [], + "style": { + "objID": "938e0598-29de-4807-b736-8bcda0e9d417", + "useStyle": "default", + "conditionalStyles": [], + "childStyles": [] + }, + "timeline": [], + "eventHandlers": [ + { + "objID": "eeb49a6a-3cfe-4372-8706-b6734bbbceec", + "eventName": "GESTURE", + "handlerType": "action", + "action": "AboutScreenGesture", + "userData": 0 + } + ], + "leftUnit": "px", + "topUnit": "px", + "widthUnit": "px", + "heightUnit": "px", + "children": [ + { + "objID": "0f3e9d37-63ca-45b0-bd92-13fbf1ecf63f", + "type": "LVGLContainerWidget", + "left": 0, + "top": 0, + "width": 100, + "height": 100, + "customInputs": [], + "customOutputs": [], + "style": { + "objID": "e2636ce4-0cbd-4646-a676-26efc3cd3bed", + "useStyle": "default", + "conditionalStyles": [], + "childStyles": [] + }, + "timeline": [], + "eventHandlers": [], + "identifier": "", + "leftUnit": "px", + "topUnit": "px", + "widthUnit": "%", + "heightUnit": "%", + "children": [ + { + "objID": "fd6de3f1-da9d-4bf9-c789-ba4b4b7e6f98", + "type": "LVGLContainerWidget", + "left": 0, + "top": 0, + "width": 100, + "height": 32, + "customInputs": [], + "customOutputs": [], + "style": { + "objID": "6138f675-e136-435f-890d-5841a1faa447", + "useStyle": "default", + "conditionalStyles": [], + "childStyles": [] + }, + "timeline": [], + "eventHandlers": [], + "identifier": "DHCPClientHeader", + "leftUnit": "px", + "topUnit": "px", + "widthUnit": "%", + "heightUnit": "content", + "children": [ + { + "objID": "edd2bee0-2bfa-4db7-930c-2445899a3d7f", + "type": "LVGLButtonWidget", + "left": 0, + "top": 0, + "width": 32, + "height": 32, + "customInputs": [], + "customOutputs": [], + "style": { + "objID": "34a0ab43-9134-405d-d30b-a60c487a18a0", + "useStyle": "default", + "conditionalStyles": [], + "childStyles": [] + }, + "timeline": [], + "eventHandlers": [ + { + "objID": "cfbd687f-29e2-4c6a-de27-f8d10dceeeb8", + "eventName": "CLICKED", + "handlerType": "action", + "action": "SwitchToMenu", + "userData": 0 + } + ], + "leftUnit": "px", + "topUnit": "px", + "widthUnit": "px", + "heightUnit": "px", + "children": [ + { + "objID": "9d5fdeca-711b-4c7d-e9ce-35f506095573", + "type": "LVGLImageWidget", + "left": -1, + "top": 2, + "width": 8, + "height": 12, + "customInputs": [], + "customOutputs": [], + "style": { + "objID": "3dbbc13f-48bc-47d3-cfa1-865309068b4b", + "useStyle": "default", + "conditionalStyles": [], + "childStyles": [] + }, + "timeline": [], + "eventHandlers": [], + "leftUnit": "px", + "topUnit": "px", + "widthUnit": "content", + "heightUnit": "content", + "children": [], + "widgetFlags": "ADV_HITTEST|CLICK_FOCUSABLE|GESTURE_BUBBLE|PRESS_LOCK|SCROLLABLE|SCROLL_CHAIN_HOR|SCROLL_CHAIN_VER|SCROLL_ELASTIC|SCROLL_MOMENTUM|SCROLL_WITH_ARROW|SNAPPABLE", + "hiddenFlagType": "literal", + "clickableFlagType": "literal", + "flagScrollbarMode": "", + "flagScrollDirection": "", + "scrollSnapX": "", + "scrollSnapY": "", + "checkedStateType": "literal", + "disabledStateType": "literal", + "states": "", + "localStyles": { + "objID": "3864abb7-e6ea-4188-cc23-99583e940dde" + }, + "group": "", + "groupIndex": 0, + "image": "back-caret", + "setPivot": false, + "pivotX": 0, + "pivotY": 0, + "zoom": 256, + "angle": 0, + "innerAlign": "CENTER" + } + ], + "widgetFlags": "CLICK_FOCUSABLE|GESTURE_BUBBLE|PRESS_LOCK|SCROLL_CHAIN_HOR|SCROLL_CHAIN_VER|SCROLL_ELASTIC|SCROLL_MOMENTUM|SCROLL_ON_FOCUS|SCROLL_WITH_ARROW|SNAPPABLE", + "hiddenFlagType": "literal", + "clickableFlag": true, + "clickableFlagType": "literal", + "flagScrollbarMode": "", + "flagScrollDirection": "", + "scrollSnapX": "", + "scrollSnapY": "", + "checkedStateType": "literal", + "disabledStateType": "literal", + "states": "", + "useStyle": "BackButton", + "localStyles": { + "objID": "04d66c1f-9518-4905-bd8f-25f99bb40708" + }, + "group": "", + "groupIndex": 0 + }, + { + "objID": "bde5dd77-e61f-4398-f76c-b32d204a1532", + "type": "LVGLLabelWidget", + "left": 0, + "top": 0, + "width": 115, + "height": 20, + "customInputs": [], + "customOutputs": [], + "style": { + "objID": "6cacf93f-877f-46b2-cc3a-85d25ed6725c", + "useStyle": "default", + "conditionalStyles": [], + "childStyles": [] + }, + "timeline": [], + "eventHandlers": [], + "identifier": "", + "leftUnit": "%", + "topUnit": "%", + "widthUnit": "content", + "heightUnit": "content", + "children": [], + "widgetFlags": "CLICK_FOCUSABLE|GESTURE_BUBBLE|PRESS_LOCK|SCROLLABLE|SCROLL_CHAIN_HOR|SCROLL_CHAIN_VER|SCROLL_ELASTIC|SCROLL_MOMENTUM|SCROLL_WITH_ARROW|SNAPPABLE", + "hiddenFlagType": "literal", + "clickableFlagType": "literal", + "checkedStateType": "literal", + "disabledStateType": "literal", + "states": "", + "useStyle": "HeaderLink", + "localStyles": { + "objID": "0ac01432-b282-41c4-b119-316fe81ad79b" + }, + "groupIndex": 0, + "text": "DHCP Client", + "textType": "literal", + "longMode": "WRAP", + "recolor": false + } + ], + "widgetFlags": "CLICK_FOCUSABLE|GESTURE_BUBBLE|PRESS_LOCK|SCROLLABLE|SCROLL_CHAIN_HOR|SCROLL_CHAIN_VER|SCROLL_ELASTIC|SCROLL_MOMENTUM|SCROLL_WITH_ARROW|SNAPPABLE", + "hiddenFlagType": "literal", + "clickableFlag": true, + "clickableFlagType": "literal", + "flagScrollbarMode": "", + "flagScrollDirection": "", + "scrollSnapX": "", + "scrollSnapY": "", + "checkedStateType": "literal", + "disabledStateType": "literal", + "states": "", + "useStyle": "FlowRowSpaceBetween", + "localStyles": { + "objID": "318d6e6f-47fc-43b2-d552-78044e89013b", + "definition": { + "MAIN": { + "DEFAULT": { + "pad_right": 4 + } + } + } + }, + "group": "", + "groupIndex": 0 + }, + { + "objID": "be2e4477-a5c6-4fbf-b6ae-41781ba8beb3", + "type": "LVGLContainerWidget", + "left": 0, + "top": 0, + "width": 100, + "height": 80, + "customInputs": [], + "customOutputs": [], + "style": { + "objID": "32002f2f-ef5d-4cb2-861b-64e6d5a7d579", + "useStyle": "default", + "conditionalStyles": [], + "childStyles": [] + }, + "timeline": [], + "eventHandlers": [], + "identifier": "DHCPClientContainer", + "leftUnit": "px", + "topUnit": "px", + "widthUnit": "%", + "heightUnit": "%", + "children": [ + { + "objID": "b54678c4-4a2c-4cb3-f69f-1f5c2af56871", + "type": "LVGLContainerWidget", + "left": 0, + "top": 0, + "width": 100, + "height": 118, + "customInputs": [], + "customOutputs": [], + "style": { + "objID": "e2990e43-732a-4333-fe6b-2ebce8595dbe", + "useStyle": "default", + "conditionalStyles": [], + "childStyles": [] + }, + "timeline": [], + "eventHandlers": [], + "leftUnit": "px", + "topUnit": "px", + "widthUnit": "%", + "heightUnit": "content", + "children": [ + { + "objID": "1eac7e05-d4ef-4f1d-fb76-6d5ed568080c", + "type": "LVGLContainerWidget", + "left": 0, + "top": 0, + "width": 100, + "height": 60, + "customInputs": [], + "customOutputs": [], + "style": { + "objID": "bfe31228-f83e-4e4d-cc0b-a54b11ae977d", + "useStyle": "default", + "conditionalStyles": [], + "childStyles": [] + }, + "timeline": [], + "eventHandlers": [], + "identifier": "DHCPClientLabelContainer", + "leftUnit": "px", + "topUnit": "px", + "widthUnit": "%", + "heightUnit": "content", + "children": [ + { + "objID": "6bc2314a-818b-4dda-bd0d-14fc9e95e335", + "type": "LVGLLabelWidget", + "left": 0, + "top": 0, + "width": 100, + "height": 40, + "customInputs": [], + "customOutputs": [], + "style": { + "objID": "cddebe4d-3ea2-48c6-cd6e-6e9fb0b9b542", + "useStyle": "default", + "conditionalStyles": [], + "childStyles": [] + }, + "timeline": [], + "eventHandlers": [], + "identifier": "DHCPC_Label", + "leftUnit": "px", + "topUnit": "px", + "widthUnit": "%", + "heightUnit": "content", + "children": [], + "widgetFlags": "CLICK_FOCUSABLE|GESTURE_BUBBLE|PRESS_LOCK|SCROLLABLE|SCROLL_CHAIN_HOR|SCROLL_CHAIN_VER|SCROLL_ELASTIC|SCROLL_MOMENTUM|SCROLL_WITH_ARROW|SNAPPABLE", + "hiddenFlagType": "literal", + "clickableFlagType": "literal", + "flagScrollbarMode": "", + "flagScrollDirection": "", + "scrollSnapX": "", + "scrollSnapY": "", + "checkedStateType": "literal", + "disabledStateType": "literal", + "states": "", + "useStyle": "InfoContentLabel", + "localStyles": { + "objID": "120b38c1-a71d-4d16-9716-75f3bb8b6e1e", + "definition": { + "MAIN": { + "DEFAULT": { + "text_font": "FontBook20" + } + } + } + }, + "group": "", + "groupIndex": 0, + "text": "Press and hold for\n5 seconds", + "textType": "literal", + "longMode": "WRAP", + "recolor": false, + "previewValue": "0.0.1" + } + ], + "widgetFlags": "CLICK_FOCUSABLE|GESTURE_BUBBLE|PRESS_LOCK|SCROLL_CHAIN_HOR|SCROLL_CHAIN_VER|SCROLL_ELASTIC|SCROLL_MOMENTUM|SCROLL_WITH_ARROW|SNAPPABLE", + "hiddenFlagType": "literal", + "clickableFlag": true, + "clickableFlagType": "literal", + "flagScrollbarMode": "", + "flagScrollDirection": "", + "scrollSnapX": "", + "scrollSnapY": "", + "checkedStateType": "literal", + "disabledStateType": "literal", + "states": "", + "useStyle": "FlexColumnStart", + "localStyles": { + "objID": "49877fd7-7428-4aa9-de03-7dcfcd6e9c3e", + "definition": { + "MAIN": { + "DEFAULT": { + "pad_right": 10, + "pad_left": 10, + "pad_top": 10, + "pad_bottom": 10 + } + } + } + }, + "group": "", + "groupIndex": 0 + }, + { + "objID": "080e4506-6708-4853-fe18-6fa783466f89", + "type": "LVGLContainerWidget", + "left": 0, + "top": 0, + "width": 100, + "height": 80, + "customInputs": [], + "customOutputs": [], + "style": { + "objID": "433da4ef-fa5a-4d0e-9646-7e0cfb5ca8a1", + "useStyle": "default", + "conditionalStyles": [], + "childStyles": [] + }, + "hiddenInEditor": false, + "timeline": [], + "eventHandlers": [], + "identifier": "DHCPClientSpinner", + "leftUnit": "px", + "topUnit": "px", + "widthUnit": "%", + "heightUnit": "content", + "children": [ + { + "objID": "9ac3d644-13f9-4d1e-c5df-d30f77e060c9", + "type": "LVGLSpinnerWidget", + "left": 0, + "top": 0, + "width": 80, + "height": 80, + "customInputs": [], + "customOutputs": [], + "style": { + "objID": "eecd876c-0d54-40b5-80c9-35b5fed54d49", + "useStyle": "default", + "conditionalStyles": [], + "childStyles": [] + }, + "timeline": [], + "eventHandlers": [], + "leftUnit": "px", + "topUnit": "px", + "widthUnit": "px", + "heightUnit": "px", + "children": [], + "widgetFlags": "CLICK_FOCUSABLE|GESTURE_BUBBLE|PRESS_LOCK|SCROLL_ELASTIC|SCROLL_MOMENTUM|SCROLL_WITH_ARROW|SNAPPABLE", + "hiddenFlagType": "literal", + "clickableFlagType": "literal", + "flagScrollbarMode": "", + "flagScrollDirection": "", + "scrollSnapX": "", + "scrollSnapY": "", + "checkedStateType": "literal", + "disabledStateType": "literal", + "states": "", + "localStyles": { + "objID": "3b02891a-1c25-4bb9-c452-992e29e1b522" + }, + "group": "", + "groupIndex": 0 + } + ], + "widgetFlags": "CLICK_FOCUSABLE|GESTURE_BUBBLE|PRESS_LOCK|SCROLL_CHAIN_HOR|SCROLL_CHAIN_VER|SCROLL_ELASTIC|SCROLL_MOMENTUM|SCROLL_WITH_ARROW|SNAPPABLE", + "hiddenFlag": true, + "hiddenFlagType": "literal", + "clickableFlag": false, + "clickableFlagType": "literal", + "flagScrollbarMode": "", + "flagScrollDirection": "", + "scrollSnapX": "", + "scrollSnapY": "", + "checkedStateType": "literal", + "disabledStateType": "literal", + "states": "", + "useStyle": "FlexColumnStart", + "localStyles": { + "objID": "0261577f-97e3-4b4b-b761-cbb127842798", + "definition": { + "MAIN": { + "DEFAULT": { + "flex_main_place": "CENTER", + "flex_cross_place": "CENTER", + "flex_track_place": "CENTER" + } + } + } + }, + "group": "", + "groupIndex": 0 + }, + { + "objID": "3cd2081b-7f47-44da-ad46-0fec4d6c13af", + "type": "LVGLContainerWidget", + "left": 0, + "top": 0, + "width": 100, + "height": 50, + "customInputs": [], + "customOutputs": [], + "style": { + "objID": "96ae878d-571b-4be0-e408-ba24a005a7c7", + "useStyle": "default", + "conditionalStyles": [], + "childStyles": [] + }, + "hiddenInEditor": false, + "timeline": [], + "eventHandlers": [], + "identifier": "DHCPClientButton", + "leftUnit": "px", + "topUnit": "px", + "widthUnit": "%", + "heightUnit": "content", + "children": [ + { + "objID": "ca25a750-c474-4994-eb13-b2ea9abeb1f1", + "type": "LVGLButtonWidget", + "left": 0, + "top": 0, + "width": 100, + "height": 50, + "customInputs": [], + "customOutputs": [], + "style": { + "objID": "22a3e3ca-a02c-4e43-9b78-7de7d03a7879", + "useStyle": "default", + "conditionalStyles": [], + "childStyles": [] + }, + "hiddenInEditor": false, + "timeline": [], + "eventHandlers": [ + { + "objID": "71ed231c-9bc3-4f0f-81f0-bf83c082974c", + "eventName": "PRESSED", + "handlerType": "action", + "action": "DHCPC", + "userData": 0 + }, + { + "objID": "677825fd-50cd-4d46-ef8e-4b296fef7c76", + "eventName": "PRESSING", + "handlerType": "action", + "action": "DHCPC", + "userData": 0 + }, + { + "objID": "f25e6fa7-45de-4d97-d892-3411d07f2df6", + "eventName": "RELEASED", + "handlerType": "action", + "action": "DHCPC", + "userData": 0 + } + ], + "leftUnit": "px", + "topUnit": "px", + "widthUnit": "%", + "heightUnit": "px", + "children": [ + { + "objID": "9370968e-29f3-411e-8e6e-28874fb3cdd8", + "type": "LVGLLabelWidget", + "left": 0, + "top": 0, + "width": 129, + "height": 16, + "customInputs": [], + "customOutputs": [], + "style": { + "objID": "e81bebce-db31-4a4e-e0d1-2c48865b9e5c", + "useStyle": "default", + "conditionalStyles": [], + "childStyles": [] + }, + "hiddenInEditor": false, + "timeline": [], + "eventHandlers": [], + "identifier": "DHCPClientChangeLabel", + "leftUnit": "px", + "topUnit": "px", + "widthUnit": "content", + "heightUnit": "content", + "children": [], + "widgetFlags": "CLICK_FOCUSABLE|GESTURE_BUBBLE|PRESS_LOCK|SCROLLABLE|SCROLL_CHAIN_HOR|SCROLL_CHAIN_VER|SCROLL_ELASTIC|SCROLL_MOMENTUM|SCROLL_WITH_ARROW|SNAPPABLE", + "hiddenFlagType": "literal", + "clickableFlagType": "literal", + "flagScrollbarMode": "", + "flagScrollDirection": "", + "scrollSnapX": "", + "scrollSnapY": "", + "checkedStateType": "literal", + "disabledStateType": "literal", + "states": "", + "localStyles": { + "objID": "c7acc23d-631e-40ca-e8af-d73735a6b513", + "definition": { + "MAIN": { + "DEFAULT": { + "align": "CENTER", + "text_align": "LEFT" + } + } + } + }, + "group": "", + "groupIndex": 0, + "text": "Switch to udhcpc", + "textType": "literal", + "longMode": "WRAP", + "recolor": false + } + ], + "widgetFlags": "CLICK_FOCUSABLE|GESTURE_BUBBLE|PRESS_LOCK|SCROLL_CHAIN_HOR|SCROLL_CHAIN_VER|SCROLL_ELASTIC|SCROLL_MOMENTUM|SCROLL_ON_FOCUS|SCROLL_WITH_ARROW|SNAPPABLE", + "hiddenFlagType": "literal", + "clickableFlag": true, + "clickableFlagType": "literal", + "flagScrollbarMode": "", + "flagScrollDirection": "", + "scrollSnapX": "", + "scrollSnapY": "", + "checkedStateType": "literal", + "disabledStateType": "literal", + "states": "", + "useStyle": "", + "localStyles": { + "objID": "244a5188-9237-429c-ea0f-0892dffba39d", + "definition": { + "MAIN": { + "DEFAULT": { + "bg_color": "DC2626", + "text_align": "LEFT", + "pad_right": 13 + } + } + } + }, + "group": "", + "groupIndex": 0 + } + ], + "widgetFlags": "CLICK_FOCUSABLE|GESTURE_BUBBLE|PRESS_LOCK|SCROLL_CHAIN_HOR|SCROLL_CHAIN_VER|SCROLL_ELASTIC|SCROLL_MOMENTUM|SCROLL_WITH_ARROW|SNAPPABLE", + "hiddenFlagType": "literal", + "clickableFlag": true, + "clickableFlagType": "literal", + "flagScrollbarMode": "", + "flagScrollDirection": "", + "scrollSnapX": "", + "scrollSnapY": "", + "checkedStateType": "literal", + "disabledStateType": "literal", + "states": "", + "useStyle": "FlexColumnStart", + "localStyles": { + "objID": "98d255ea-498c-4bce-cbd9-5c58f4148905" + }, + "group": "", + "groupIndex": 0 + } + ], + "widgetFlags": "CLICK_FOCUSABLE|GESTURE_BUBBLE|PRESS_LOCK|SCROLL_CHAIN_HOR|SCROLL_CHAIN_VER|SCROLL_ELASTIC|SCROLL_MOMENTUM|SCROLL_WITH_ARROW|SNAPPABLE", + "hiddenFlagType": "literal", + "clickableFlag": true, + "clickableFlagType": "literal", + "flagScrollbarMode": "", + "flagScrollDirection": "", + "scrollSnapX": "", + "scrollSnapY": "", + "checkedStateType": "literal", + "disabledStateType": "literal", + "states": "", + "useStyle": "FlexColumnStart", + "localStyles": { + "objID": "ac0d8cb4-7113-4ad6-f9b2-6d24553c6558", + "definition": { + "MAIN": { + "DEFAULT": { + "pad_right": 10 + } + } + } + }, + "group": "", + "groupIndex": 0 + } + ], + "widgetFlags": "CLICK_FOCUSABLE|GESTURE_BUBBLE|PRESS_LOCK|SCROLL_CHAIN_VER|SNAPPABLE|SCROLL_ELASTIC|SCROLL_WITH_ARROW|SCROLL_MOMENTUM|SCROLL_CHAIN_HOR|SCROLLABLE", + "hiddenFlagType": "literal", + "clickableFlag": true, + "clickableFlagType": "literal", + "flagScrollbarMode": "auto", + "flagScrollDirection": "ver", + "scrollSnapX": "start", + "scrollSnapY": "", + "checkedStateType": "literal", + "disabledStateType": "literal", + "states": "", + "useStyle": "FlexColumnStart", + "localStyles": { + "objID": "b4f4b1a5-fe3d-4114-bb43-68d4a39ff3c2", + "definition": { + "MAIN": { + "DEFAULT": { + "pad_right": 4 + } + } + } + }, + "group": "", + "groupIndex": 0 + } + ], + "widgetFlags": "CLICK_FOCUSABLE|GESTURE_BUBBLE|PRESS_LOCK|SCROLL_CHAIN_HOR|SCROLL_CHAIN_VER|SCROLL_MOMENTUM|SCROLL_WITH_ARROW|SNAPPABLE|SCROLL_ELASTIC", + "hiddenFlagType": "literal", + "clickableFlag": true, + "clickableFlagType": "literal", + "flagScrollbarMode": "", + "flagScrollDirection": "", + "scrollSnapX": "", + "scrollSnapY": "", + "checkedStateType": "literal", + "disabledStateType": "literal", + "states": "", + "useStyle": "FlexStart", + "localStyles": { + "objID": "34c01609-fb79-4d5a-943a-a28e67b73adf" + }, + "group": "", + "groupIndex": 0 + } + ], + "widgetFlags": "CLICKABLE|PRESS_LOCK|CLICK_FOCUSABLE|GESTURE_BUBBLE|SNAPPABLE|SCROLLABLE|SCROLL_ELASTIC|SCROLL_MOMENTUM|SCROLL_CHAIN_HOR|SCROLL_CHAIN_VER", + "hiddenFlagType": "literal", + "clickableFlag": true, + "clickableFlagType": "literal", + "checkedStateType": "literal", + "disabledStateType": "literal", + "states": "", + "useStyle": "FlexScreenMenu", + "localStyles": { + "objID": "cd8e005a-54e3-4ed2-edfb-719d97a7ded8" + }, + "groupIndex": 0 + } + ], + "connectionLines": [], + "localVariables": [], + "userProperties": [], + "name": "SwitchDHCPClientScreen", + "left": 0, + "top": 0, + "width": 300, + "height": 240, + "isUsedAsUserWidget": false, + "createAtStart": true, + "deleteOnScreenUnload": false } ], "userWidgets": [], diff --git a/internal/native/eez/src/ui/actions.c b/internal/native/eez/src/ui/actions.c index 801b03da..fd13c142 100644 --- a/internal/native/eez/src/ui/actions.c +++ b/internal/native/eez/src/ui/actions.c @@ -48,6 +48,10 @@ void action_switch_to_reset_config(lv_event_t *e) { loadScreen(SCREEN_ID_RESET_CONFIG_SCREEN); } +void action_switch_to_dhcpc(lv_event_t *e) { + loadScreen(SCREEN_ID_SWITCH_DHCP_CLIENT_SCREEN); +} + void action_switch_to_reboot(lv_event_t *e) { loadScreen(SCREEN_ID_REBOOT_SCREEN); } @@ -75,15 +79,19 @@ void action_about_screen_gesture(lv_event_t * e) { // user_data doesn't seem to be working, so we use a global variable here static uint32_t t_reset_config; static uint32_t t_reboot; +static uint32_t t_dhcpc; static bool b_reboot = false; static bool b_reset_config = false; +static bool b_dhcpc = false; static bool b_reboot_lock = false; static bool b_reset_config_lock = false; +static bool b_dhcpc_lock = false; const int RESET_CONFIG_HOLD_TIME = 10; const int REBOOT_HOLD_TIME = 5; +const int DHCPC_HOLD_TIME = 5; typedef struct { uint32_t *start_time; @@ -153,6 +161,22 @@ void action_reset_config(lv_event_t * e) { handle_hold_action(e, &config); } +void action_dhcpc(lv_event_t * e) { + hold_action_config_t config = { + .start_time = &t_dhcpc, + .completed = &b_dhcpc, + .lock = &b_dhcpc_lock, + .hold_time_seconds = DHCPC_HOLD_TIME, + .rpc_method = "toggleDHCPClient", + .button_obj = NULL, // No button/spinner for reboot + .spinner_obj = NULL, + .label_obj = objects.dhcpc_label, + .default_text = "Press and hold for\n5 seconds" + }; + + handle_hold_action(e, &config); +} + void action_reboot(lv_event_t * e) { hold_action_config_t config = { .start_time = &t_reboot, diff --git a/internal/native/eez/src/ui/actions.h b/internal/native/eez/src/ui/actions.h index f4a24e44..a4179b3c 100644 --- a/internal/native/eez/src/ui/actions.h +++ b/internal/native/eez/src/ui/actions.h @@ -24,6 +24,8 @@ extern void action_handle_common_press_event(lv_event_t * e); extern void action_reset_config(lv_event_t * e); extern void action_reboot(lv_event_t * e); extern void action_switch_to_reboot(lv_event_t * e); +extern void action_dhcpc(lv_event_t * e); +extern void action_switch_to_dhcpc(lv_event_t * e); #ifdef __cplusplus diff --git a/internal/native/eez/src/ui/screens.c b/internal/native/eez/src/ui/screens.c index dd5ad98c..48abea41 100644 --- a/internal/native/eez/src/ui/screens.c +++ b/internal/native/eez/src/ui/screens.c @@ -887,6 +887,26 @@ void create_screen_menu_advanced_screen() { } } } + { + // MenuBtnDHCPClient + lv_obj_t *obj = lv_button_create(parent_obj); + objects.menu_btn_dhcp_client = obj; + lv_obj_set_pos(obj, 0, 0); + lv_obj_set_size(obj, LV_PCT(100), 50); + lv_obj_add_event_cb(obj, action_switch_to_dhcpc, LV_EVENT_PRESSED, (void *)0); + lv_obj_clear_flag(obj, LV_OBJ_FLAG_SNAPPABLE); + add_style_menu_button(obj); + { + lv_obj_t *parent_obj = obj; + { + lv_obj_t *obj = lv_label_create(parent_obj); + lv_obj_set_pos(obj, 0, 0); + lv_obj_set_size(obj, LV_SIZE_CONTENT, LV_SIZE_CONTENT); + add_style_menu_button_label(obj); + lv_label_set_text(obj, "DHCP Client"); + } + } + } { // MenuBtnAdvancedResetConfig lv_obj_t *obj = lv_button_create(parent_obj); @@ -2197,6 +2217,221 @@ void create_screen_rebooting_screen() { void tick_screen_rebooting_screen() { } +void create_screen_switch_dhcp_client_screen() { + lv_obj_t *obj = lv_obj_create(0); + objects.switch_dhcp_client_screen = obj; + lv_obj_set_pos(obj, 0, 0); + lv_obj_set_size(obj, 300, 240); + lv_obj_add_event_cb(obj, action_about_screen_gesture, LV_EVENT_GESTURE, (void *)0); + add_style_flex_screen_menu(obj); + { + lv_obj_t *parent_obj = obj; + { + lv_obj_t *obj = lv_obj_create(parent_obj); + lv_obj_set_pos(obj, 0, 0); + lv_obj_set_size(obj, LV_PCT(100), LV_PCT(100)); + lv_obj_set_style_pad_left(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_pad_top(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_pad_right(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_pad_bottom(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_bg_opa(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_border_width(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_radius(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_clear_flag(obj, LV_OBJ_FLAG_SCROLLABLE); + add_style_flex_start(obj); + { + lv_obj_t *parent_obj = obj; + { + // DHCPClientHeader + lv_obj_t *obj = lv_obj_create(parent_obj); + objects.dhcp_client_header = obj; + lv_obj_set_pos(obj, 0, 0); + lv_obj_set_size(obj, LV_PCT(100), LV_SIZE_CONTENT); + lv_obj_set_style_pad_left(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_pad_top(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_pad_bottom(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_bg_opa(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_border_width(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_radius(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT); + add_style_flow_row_space_between(obj); + lv_obj_set_style_pad_right(obj, 4, LV_PART_MAIN | LV_STATE_DEFAULT); + { + lv_obj_t *parent_obj = obj; + { + lv_obj_t *obj = lv_button_create(parent_obj); + lv_obj_set_pos(obj, 0, 0); + lv_obj_set_size(obj, 32, 32); + lv_obj_add_event_cb(obj, action_switch_to_menu, LV_EVENT_CLICKED, (void *)0); + add_style_back_button(obj); + { + lv_obj_t *parent_obj = obj; + { + lv_obj_t *obj = lv_image_create(parent_obj); + lv_obj_set_pos(obj, -1, 2); + lv_obj_set_size(obj, LV_SIZE_CONTENT, LV_SIZE_CONTENT); + lv_image_set_src(obj, &img_back_caret); + } + } + } + { + lv_obj_t *obj = lv_label_create(parent_obj); + lv_obj_set_pos(obj, LV_PCT(0), LV_PCT(0)); + lv_obj_set_size(obj, LV_SIZE_CONTENT, LV_SIZE_CONTENT); + add_style_header_link(obj); + lv_label_set_text(obj, "DHCP Client"); + } + } + } + { + // DHCPClientContainer + lv_obj_t *obj = lv_obj_create(parent_obj); + objects.dhcp_client_container = obj; + lv_obj_set_pos(obj, 0, 0); + lv_obj_set_size(obj, LV_PCT(100), LV_PCT(80)); + lv_obj_set_style_pad_left(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_pad_top(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_pad_bottom(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_bg_opa(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_border_width(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_radius(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_scrollbar_mode(obj, LV_SCROLLBAR_MODE_AUTO); + lv_obj_set_scroll_dir(obj, LV_DIR_VER); + lv_obj_set_scroll_snap_x(obj, LV_SCROLL_SNAP_START); + add_style_flex_column_start(obj); + lv_obj_set_style_pad_right(obj, 4, LV_PART_MAIN | LV_STATE_DEFAULT); + { + lv_obj_t *parent_obj = obj; + { + lv_obj_t *obj = lv_obj_create(parent_obj); + lv_obj_set_pos(obj, 0, 0); + lv_obj_set_size(obj, LV_PCT(100), LV_SIZE_CONTENT); + lv_obj_set_style_pad_left(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_pad_top(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_pad_bottom(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_bg_opa(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_border_width(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_radius(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_clear_flag(obj, LV_OBJ_FLAG_SCROLLABLE); + add_style_flex_column_start(obj); + lv_obj_set_style_pad_right(obj, 10, LV_PART_MAIN | LV_STATE_DEFAULT); + { + lv_obj_t *parent_obj = obj; + { + // DHCPClientLabelContainer + lv_obj_t *obj = lv_obj_create(parent_obj); + objects.dhcp_client_label_container = obj; + lv_obj_set_pos(obj, 0, 0); + lv_obj_set_size(obj, LV_PCT(100), LV_SIZE_CONTENT); + lv_obj_set_style_bg_opa(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_border_width(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_radius(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_clear_flag(obj, LV_OBJ_FLAG_SCROLLABLE); + add_style_flex_column_start(obj); + lv_obj_set_style_pad_right(obj, 10, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_pad_left(obj, 10, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_pad_top(obj, 10, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_pad_bottom(obj, 10, LV_PART_MAIN | LV_STATE_DEFAULT); + { + lv_obj_t *parent_obj = obj; + { + // DHCPC_Label + lv_obj_t *obj = lv_label_create(parent_obj); + objects.dhcpc_label = obj; + lv_obj_set_pos(obj, 0, 0); + lv_obj_set_size(obj, LV_PCT(100), LV_SIZE_CONTENT); + add_style_info_content_label(obj); + lv_obj_set_style_text_font(obj, &ui_font_font_book20, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_label_set_text(obj, "Press and hold for\n5 seconds"); + } + } + } + { + // DHCPClientSpinner + lv_obj_t *obj = lv_obj_create(parent_obj); + objects.dhcp_client_spinner = obj; + lv_obj_set_pos(obj, 0, 0); + lv_obj_set_size(obj, LV_PCT(100), LV_SIZE_CONTENT); + lv_obj_set_style_pad_left(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_pad_top(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_pad_right(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_pad_bottom(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_bg_opa(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_border_width(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_radius(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_add_flag(obj, LV_OBJ_FLAG_HIDDEN); + lv_obj_clear_flag(obj, LV_OBJ_FLAG_CLICKABLE|LV_OBJ_FLAG_SCROLLABLE); + add_style_flex_column_start(obj); + lv_obj_set_style_flex_main_place(obj, LV_FLEX_ALIGN_CENTER, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_flex_cross_place(obj, LV_FLEX_ALIGN_CENTER, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_flex_track_place(obj, LV_FLEX_ALIGN_CENTER, LV_PART_MAIN | LV_STATE_DEFAULT); + { + lv_obj_t *parent_obj = obj; + { + lv_obj_t *obj = lv_spinner_create(parent_obj); + lv_obj_set_pos(obj, 0, 0); + lv_obj_set_size(obj, 80, 80); + lv_spinner_set_anim_params(obj, 1000, 60); + } + } + } + { + // DHCPClientButton + lv_obj_t *obj = lv_obj_create(parent_obj); + objects.dhcp_client_button = obj; + lv_obj_set_pos(obj, 0, 0); + lv_obj_set_size(obj, LV_PCT(100), LV_SIZE_CONTENT); + lv_obj_set_style_pad_left(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_pad_top(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_pad_right(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_pad_bottom(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_bg_opa(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_border_width(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_radius(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_clear_flag(obj, LV_OBJ_FLAG_SCROLLABLE); + add_style_flex_column_start(obj); + { + lv_obj_t *parent_obj = obj; + { + lv_obj_t *obj = lv_button_create(parent_obj); + objects.obj2 = obj; + lv_obj_set_pos(obj, 0, 0); + lv_obj_set_size(obj, LV_PCT(100), 50); + lv_obj_add_event_cb(obj, action_dhcpc, LV_EVENT_PRESSED, (void *)0); + lv_obj_add_event_cb(obj, action_dhcpc, LV_EVENT_PRESSING, (void *)0); + lv_obj_add_event_cb(obj, action_dhcpc, LV_EVENT_RELEASED, (void *)0); + lv_obj_set_style_bg_color(obj, lv_color_hex(0xffdc2626), LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_text_align(obj, LV_TEXT_ALIGN_LEFT, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_pad_right(obj, 13, LV_PART_MAIN | LV_STATE_DEFAULT); + { + lv_obj_t *parent_obj = obj; + { + // DHCPClientChangeLabel + lv_obj_t *obj = lv_label_create(parent_obj); + objects.dhcp_client_change_label = obj; + lv_obj_set_pos(obj, 0, 0); + lv_obj_set_size(obj, LV_SIZE_CONTENT, LV_SIZE_CONTENT); + lv_obj_set_style_align(obj, LV_ALIGN_CENTER, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_text_align(obj, LV_TEXT_ALIGN_LEFT, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_label_set_text(obj, "Switch to udhcpc"); + } + } + } + } + } + } + } + } + } + } + } + } + + tick_screen_switch_dhcp_client_screen(); +} + +void tick_screen_switch_dhcp_client_screen() { +} + typedef void (*tick_screen_func_t)(); @@ -2212,6 +2447,7 @@ tick_screen_func_t tick_screen_funcs[] = { tick_screen_reset_config_screen, tick_screen_reboot_screen, tick_screen_rebooting_screen, + tick_screen_switch_dhcp_client_screen, }; void tick_screen(int screen_index) { tick_screen_funcs[screen_index](); @@ -2236,4 +2472,5 @@ void create_screens() { create_screen_reset_config_screen(); create_screen_reboot_screen(); create_screen_rebooting_screen(); + create_screen_switch_dhcp_client_screen(); } diff --git a/internal/native/eez/src/ui/screens.h b/internal/native/eez/src/ui/screens.h index f2d2fc92..d17f6abd 100644 --- a/internal/native/eez/src/ui/screens.h +++ b/internal/native/eez/src/ui/screens.h @@ -19,6 +19,7 @@ typedef struct _objects_t { lv_obj_t *reset_config_screen; lv_obj_t *reboot_screen; lv_obj_t *rebooting_screen; + lv_obj_t *switch_dhcp_client_screen; lv_obj_t *boot_logo; lv_obj_t *boot_screen_version; lv_obj_t *no_network_header_container; @@ -54,6 +55,7 @@ typedef struct _objects_t { lv_obj_t *menu_btn_advanced_developer_mode; lv_obj_t *menu_btn_advanced_usb_emulation; lv_obj_t *menu_btn_advanced_reboot; + lv_obj_t *menu_btn_dhcp_client; lv_obj_t *menu_btn_advanced_reset_config; lv_obj_t *menu_header_container_2; lv_obj_t *menu_items_container_2; @@ -101,6 +103,14 @@ typedef struct _objects_t { lv_obj_t *obj1; lv_obj_t *reboot_in_progress_logo; lv_obj_t *reboot_in_progress_label; + lv_obj_t *dhcp_client_header; + lv_obj_t *dhcp_client_container; + lv_obj_t *dhcp_client_label_container; + lv_obj_t *dhcpc_label; + lv_obj_t *dhcp_client_spinner; + lv_obj_t *dhcp_client_button; + lv_obj_t *obj2; + lv_obj_t *dhcp_client_change_label; } objects_t; extern objects_t objects; @@ -117,6 +127,7 @@ enum ScreensEnum { SCREEN_ID_RESET_CONFIG_SCREEN = 9, SCREEN_ID_REBOOT_SCREEN = 10, SCREEN_ID_REBOOTING_SCREEN = 11, + SCREEN_ID_SWITCH_DHCP_CLIENT_SCREEN = 12, }; void create_screen_boot_screen(); @@ -151,6 +162,9 @@ void tick_screen_reboot_screen(); void create_screen_rebooting_screen(); void tick_screen_rebooting_screen(); + +void create_screen_switch_dhcp_client_screen(); +void tick_screen_switch_dhcp_client_screen(); void tick_screen_by_id(enum ScreensEnum screenId); void tick_screen(int screen_index); diff --git a/internal/network/dhcp.go b/internal/network/dhcp.go deleted file mode 100644 index 9e173cc7..00000000 --- a/internal/network/dhcp.go +++ /dev/null @@ -1,11 +0,0 @@ -package network - -type DhcpTargetState int - -const ( - DhcpTargetStateDoNothing DhcpTargetState = iota - DhcpTargetStateStart - DhcpTargetStateStop - DhcpTargetStateRenew - DhcpTargetStateRelease -) diff --git a/internal/network/hostname.go b/internal/network/hostname.go deleted file mode 100644 index 09d39969..00000000 --- a/internal/network/hostname.go +++ /dev/null @@ -1,137 +0,0 @@ -package network - -import ( - "fmt" - "io" - "os" - "os/exec" - "strings" - "sync" - - "golang.org/x/net/idna" -) - -const ( - hostnamePath = "/etc/hostname" - hostsPath = "/etc/hosts" -) - -var ( - hostnameLock sync.Mutex = sync.Mutex{} -) - -func updateEtcHosts(hostname string, fqdn string) error { - // update /etc/hosts - hostsFile, err := os.OpenFile(hostsPath, os.O_RDWR|os.O_SYNC, os.ModeExclusive) - if err != nil { - return fmt.Errorf("failed to open %s: %w", hostsPath, err) - } - defer hostsFile.Close() - - // read all lines - if _, err := hostsFile.Seek(0, io.SeekStart); err != nil { - return fmt.Errorf("failed to seek %s: %w", hostsPath, err) - } - - lines, err := io.ReadAll(hostsFile) - if err != nil { - return fmt.Errorf("failed to read %s: %w", hostsPath, err) - } - - newLines := []string{} - hostLine := fmt.Sprintf("127.0.1.1\t%s %s", hostname, fqdn) - hostLineExists := false - - for line := range strings.SplitSeq(string(lines), "\n") { - if strings.HasPrefix(line, "127.0.1.1") { - hostLineExists = true - line = hostLine - } - newLines = append(newLines, line) - } - - if !hostLineExists { - newLines = append(newLines, hostLine) - } - - if err := hostsFile.Truncate(0); err != nil { - return fmt.Errorf("failed to truncate %s: %w", hostsPath, err) - } - - if _, err := hostsFile.Seek(0, io.SeekStart); err != nil { - return fmt.Errorf("failed to seek %s: %w", hostsPath, err) - } - - if _, err := hostsFile.Write([]byte(strings.Join(newLines, "\n"))); err != nil { - return fmt.Errorf("failed to write %s: %w", hostsPath, err) - } - - return nil -} - -func ToValidHostname(hostname string) string { - ascii, err := idna.Lookup.ToASCII(hostname) - if err != nil { - return "" - } - return ascii -} - -func SetHostname(hostname string, fqdn string) error { - hostnameLock.Lock() - defer hostnameLock.Unlock() - - hostname = ToValidHostname(strings.TrimSpace(hostname)) - fqdn = ToValidHostname(strings.TrimSpace(fqdn)) - - if hostname == "" { - return fmt.Errorf("invalid hostname: %s", hostname) - } - - if fqdn == "" { - fqdn = hostname - } - - // update /etc/hostname - if err := os.WriteFile(hostnamePath, []byte(hostname), 0644); err != nil { - return fmt.Errorf("failed to write %s: %w", hostnamePath, err) - } - - // update /etc/hosts - if err := updateEtcHosts(hostname, fqdn); err != nil { - return fmt.Errorf("failed to update /etc/hosts: %w", err) - } - - // run hostname - if err := exec.Command("hostname", "-F", hostnamePath).Run(); err != nil { - return fmt.Errorf("failed to run hostname: %w", err) - } - - return nil -} - -func (s *NetworkInterfaceState) setHostnameIfNotSame() error { - hostname := s.GetHostname() - currentHostname, _ := os.Hostname() - - fqdn := fmt.Sprintf("%s.%s", hostname, s.GetDomain()) - - if currentHostname == hostname && s.currentFqdn == fqdn && s.currentHostname == hostname { - return nil - } - - scopedLogger := s.l.With().Str("hostname", hostname).Str("fqdn", fqdn).Logger() - - err := SetHostname(hostname, fqdn) - if err != nil { - scopedLogger.Error().Err(err).Msg("failed to set hostname") - return err - } - - s.currentHostname = hostname - s.currentFqdn = fqdn - - scopedLogger.Info().Msg("hostname set") - - return nil -} diff --git a/internal/network/netif.go b/internal/network/netif.go deleted file mode 100644 index bd01aba8..00000000 --- a/internal/network/netif.go +++ /dev/null @@ -1,403 +0,0 @@ -package network - -import ( - "fmt" - "net" - "sync" - - "github.com/jetkvm/kvm/internal/confparser" - "github.com/jetkvm/kvm/internal/logging" - "github.com/jetkvm/kvm/internal/udhcpc" - "github.com/rs/zerolog" - - "github.com/vishvananda/netlink" -) - -type NetworkInterfaceState struct { - interfaceName string - interfaceUp bool - ipv4Addr *net.IP - ipv4Addresses []string - ipv6Addr *net.IP - ipv6Addresses []IPv6Address - ipv6LinkLocal *net.IP - ntpAddresses []*net.IP - macAddr *net.HardwareAddr - - l *zerolog.Logger - stateLock sync.Mutex - - config *NetworkConfig - dhcpClient *udhcpc.DHCPClient - - defaultHostname string - currentHostname string - currentFqdn string - - onStateChange func(state *NetworkInterfaceState) - onInitialCheck func(state *NetworkInterfaceState) - cbConfigChange func(config *NetworkConfig) - - checked bool -} - -type NetworkInterfaceOptions struct { - InterfaceName string - DhcpPidFile string - Logger *zerolog.Logger - DefaultHostname string - OnStateChange func(state *NetworkInterfaceState) - OnInitialCheck func(state *NetworkInterfaceState) - OnDhcpLeaseChange func(lease *udhcpc.Lease, state *NetworkInterfaceState) - OnConfigChange func(config *NetworkConfig) - NetworkConfig *NetworkConfig -} - -func NewNetworkInterfaceState(opts *NetworkInterfaceOptions) (*NetworkInterfaceState, error) { - if opts.NetworkConfig == nil { - return nil, fmt.Errorf("NetworkConfig can not be nil") - } - - if opts.DefaultHostname == "" { - opts.DefaultHostname = "jetkvm" - } - - err := confparser.SetDefaultsAndValidate(opts.NetworkConfig) - if err != nil { - return nil, err - } - - l := opts.Logger - s := &NetworkInterfaceState{ - interfaceName: opts.InterfaceName, - defaultHostname: opts.DefaultHostname, - stateLock: sync.Mutex{}, - l: l, - onStateChange: opts.OnStateChange, - onInitialCheck: opts.OnInitialCheck, - cbConfigChange: opts.OnConfigChange, - config: opts.NetworkConfig, - ntpAddresses: make([]*net.IP, 0), - } - - // create the dhcp client - dhcpClient := udhcpc.NewDHCPClient(&udhcpc.DHCPClientOptions{ - InterfaceName: opts.InterfaceName, - PidFile: opts.DhcpPidFile, - Logger: l, - OnLeaseChange: func(lease *udhcpc.Lease) { - _, err := s.update() - if err != nil { - opts.Logger.Error().Err(err).Msg("failed to update network state") - return - } - _ = s.updateNtpServersFromLease(lease) - _ = s.setHostnameIfNotSame() - - opts.OnDhcpLeaseChange(lease, s) - }, - }) - - s.dhcpClient = dhcpClient - return s, nil -} - -func (s *NetworkInterfaceState) IsUp() bool { - return s.interfaceUp -} - -func (s *NetworkInterfaceState) HasIPAssigned() bool { - return s.ipv4Addr != nil || s.ipv6Addr != nil -} - -func (s *NetworkInterfaceState) IsOnline() bool { - return s.IsUp() && s.HasIPAssigned() -} - -func (s *NetworkInterfaceState) IPv4() *net.IP { - return s.ipv4Addr -} - -func (s *NetworkInterfaceState) IPv4String() string { - if s.ipv4Addr == nil { - return "..." - } - return s.ipv4Addr.String() -} - -func (s *NetworkInterfaceState) IPv6() *net.IP { - return s.ipv6Addr -} - -func (s *NetworkInterfaceState) IPv6String() string { - if s.ipv6Addr == nil { - return "..." - } - return s.ipv6Addr.String() -} - -func (s *NetworkInterfaceState) NtpAddresses() []*net.IP { - return s.ntpAddresses -} - -func (s *NetworkInterfaceState) NtpAddressesString() []string { - ntpServers := []string{} - - if s != nil { - s.l.Debug().Any("s", s).Msg("getting NTP address strings") - - if len(s.ntpAddresses) > 0 { - for _, server := range s.ntpAddresses { - s.l.Debug().IPAddr("server", *server).Msg("converting NTP address") - ntpServers = append(ntpServers, server.String()) - } - } - } - - return ntpServers -} - -func (s *NetworkInterfaceState) MAC() *net.HardwareAddr { - return s.macAddr -} - -func (s *NetworkInterfaceState) MACString() string { - if s.macAddr == nil { - return "" - } - return s.macAddr.String() -} - -func (s *NetworkInterfaceState) update() (DhcpTargetState, error) { - s.stateLock.Lock() - defer s.stateLock.Unlock() - - dhcpTargetState := DhcpTargetStateDoNothing - - iface, err := netlink.LinkByName(s.interfaceName) - if err != nil { - s.l.Error().Err(err).Msg("failed to get interface") - return dhcpTargetState, err - } - - // detect if the interface status changed - var changed bool - attrs := iface.Attrs() - state := attrs.OperState - newInterfaceUp := state == netlink.OperUp - - // check if the interface is coming up - interfaceGoingUp := !s.interfaceUp && newInterfaceUp - interfaceGoingDown := s.interfaceUp && !newInterfaceUp - - if s.interfaceUp != newInterfaceUp { - s.interfaceUp = newInterfaceUp - changed = true - } - - if changed { - if interfaceGoingUp { - s.l.Info().Msg("interface state transitioned to up") - dhcpTargetState = DhcpTargetStateRenew - } else if interfaceGoingDown { - s.l.Info().Msg("interface state transitioned to down") - } - } - - // set the mac address - s.macAddr = &attrs.HardwareAddr - - // get the ip addresses - addrs, err := netlinkAddrs(iface) - if err != nil { - return dhcpTargetState, logging.ErrorfL(s.l, "failed to get ip addresses", err) - } - - var ( - ipv4Addresses = make([]net.IP, 0) - ipv4AddressesString = make([]string, 0) - ipv6Addresses = make([]IPv6Address, 0) - // ipv6AddressesString = make([]string, 0) - ipv6LinkLocal *net.IP - ) - - for _, addr := range addrs { - if addr.IP.To4() != nil { - scopedLogger := s.l.With().Str("ipv4", addr.IP.String()).Logger() - if interfaceGoingDown { - // remove all IPv4 addresses from the interface. - scopedLogger.Info().Msg("state transitioned to down, removing IPv4 address") - err := netlink.AddrDel(iface, &addr) - if err != nil { - scopedLogger.Warn().Err(err).Msg("failed to delete address") - } - // notify the DHCP client to release the lease - dhcpTargetState = DhcpTargetStateRelease - continue - } - ipv4Addresses = append(ipv4Addresses, addr.IP) - ipv4AddressesString = append(ipv4AddressesString, addr.IPNet.String()) - } else if addr.IP.To16() != nil { - if s.config.IPv6Mode.String == "disabled" { - continue - } - - 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 s.config.IPv6Mode.String != "disabled" { - if ipv6LinkLocal != nil { - if s.ipv6LinkLocal == nil || s.ipv6LinkLocal.String() != ipv6LinkLocal.String() { - scopedLogger := s.l.With().Str("ipv6", ipv6LinkLocal.String()).Logger() - if s.ipv6LinkLocal != nil { - scopedLogger.Info(). - Str("old_ipv6", s.ipv6LinkLocal.String()). - Msg("IPv6 link local address changed") - } else { - scopedLogger.Info().Msg("IPv6 link local address found") - } - s.ipv6LinkLocal = ipv6LinkLocal - changed = true - } - } - s.ipv6Addresses = ipv6Addresses - - if len(ipv6Addresses) > 0 { - // compare the addresses to see if there's a change - if s.ipv6Addr == nil || s.ipv6Addr.String() != ipv6Addresses[0].Address.String() { - scopedLogger := s.l.With().Str("ipv6", ipv6Addresses[0].Address.String()).Logger() - if s.ipv6Addr != nil { - scopedLogger.Info(). - Str("old_ipv6", s.ipv6Addr.String()). - Msg("IPv6 address changed") - } else { - scopedLogger.Info().Msg("IPv6 address found") - } - s.ipv6Addr = &ipv6Addresses[0].Address - changed = true - } - } - } - - // if it's the initial check, we'll set changed to false - initialCheck := !s.checked - if initialCheck { - s.checked = true - changed = false - if dhcpTargetState == DhcpTargetStateRenew { - // it's the initial check, we'll start the DHCP client - // dhcpTargetState = DhcpTargetStateStart - // TODO: manage DHCP client start/stop - dhcpTargetState = DhcpTargetStateDoNothing - } - } - - if initialCheck { - s.handleInitialCheck() - } else if changed { - s.handleStateChange() - } - - return dhcpTargetState, nil -} - -func (s *NetworkInterfaceState) updateNtpServersFromLease(lease *udhcpc.Lease) error { - if lease != nil && len(lease.NTPServers) > 0 { - s.l.Info().Msg("lease found, updating DHCP NTP addresses") - s.ntpAddresses = make([]*net.IP, 0, len(lease.NTPServers)) - - for _, ntpServer := range lease.NTPServers { - if ntpServer != nil { - s.l.Info().IPAddr("ntp_server", ntpServer).Msg("NTP server found in lease") - s.ntpAddresses = append(s.ntpAddresses, &ntpServer) - } - } - } else { - s.l.Info().Msg("no NTP servers found in lease") - s.ntpAddresses = make([]*net.IP, 0, len(s.config.TimeSyncNTPServers)) - } - - return nil -} - -func (s *NetworkInterfaceState) handleInitialCheck() { - // if s.IsUp() {} - s.onInitialCheck(s) -} - -func (s *NetworkInterfaceState) handleStateChange() { - // if s.IsUp() {} else {} - s.onStateChange(s) -} - -func (s *NetworkInterfaceState) CheckAndUpdateDhcp() error { - dhcpTargetState, err := s.update() - if err != nil { - return logging.ErrorfL(s.l, "failed to update network state", err) - } - - switch dhcpTargetState { - case DhcpTargetStateRenew: - s.l.Info().Msg("renewing DHCP lease") - _ = s.dhcpClient.Renew() - case DhcpTargetStateRelease: - s.l.Info().Msg("releasing DHCP lease") - _ = s.dhcpClient.Release() - case DhcpTargetStateStart: - s.l.Warn().Msg("dhcpTargetStateStart not implemented") - case DhcpTargetStateStop: - s.l.Warn().Msg("dhcpTargetStateStop not implemented") - } - - return nil -} - -func (s *NetworkInterfaceState) onConfigChange(config *NetworkConfig) { - _ = s.setHostnameIfNotSame() - s.cbConfigChange(config) -} diff --git a/internal/network/netif_linux.go b/internal/network/netif_linux.go deleted file mode 100644 index ec057f1d..00000000 --- a/internal/network/netif_linux.go +++ /dev/null @@ -1,58 +0,0 @@ -//go:build linux - -package network - -import ( - "time" - - "github.com/vishvananda/netlink" - "github.com/vishvananda/netlink/nl" -) - -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 - } - - _ = s.setHostnameIfNotSame() - - // 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 -} - -func netlinkAddrs(iface netlink.Link) ([]netlink.Addr, error) { - return netlink.AddrList(iface, nl.FAMILY_ALL) -} diff --git a/internal/network/netif_notlinux.go b/internal/network/netif_notlinux.go deleted file mode 100644 index d1016301..00000000 --- a/internal/network/netif_notlinux.go +++ /dev/null @@ -1,21 +0,0 @@ -//go:build !linux - -package network - -import ( - "fmt" - - "github.com/vishvananda/netlink" -) - -func (s *NetworkInterfaceState) HandleLinkUpdate() error { - return fmt.Errorf("not implemented") -} - -func (s *NetworkInterfaceState) Run() error { - return fmt.Errorf("not implemented") -} - -func netlinkAddrs(iface netlink.Link) ([]netlink.Addr, error) { - return nil, fmt.Errorf("not implemented") -} diff --git a/internal/network/rpc.go b/internal/network/rpc.go deleted file mode 100644 index 62f21be8..00000000 --- a/internal/network/rpc.go +++ /dev/null @@ -1,126 +0,0 @@ -package network - -import ( - "fmt" - "time" - - "github.com/jetkvm/kvm/internal/confparser" - "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 -} - -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() -} diff --git a/internal/network/config.go b/internal/network/types/config.go similarity index 62% rename from internal/network/config.go rename to internal/network/types/config.go index e8a8c058..364f8609 100644 --- a/internal/network/config.go +++ b/internal/network/types/config.go @@ -1,25 +1,13 @@ -package network +package types import ( - "fmt" - "net" "net/http" "net/url" - "time" "github.com/guregu/null/v6" - "github.com/jetkvm/kvm/internal/mdns" - "golang.org/x/net/idna" ) -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"` -} - +// IPv4StaticConfig represents static IPv4 configuration type IPv4StaticConfig struct { Address null.String `json:"address,omitempty" validate_type:"ipv4" required:"true"` Netmask null.String `json:"netmask,omitempty" validate_type:"ipv4" required:"true"` @@ -27,13 +15,23 @@ type IPv4StaticConfig struct { DNS []string `json:"dns,omitempty" validate_type:"ipv4" required:"true"` } +// IPv6StaticConfig represents static IPv6 configuration type IPv6StaticConfig struct { - Address null.String `json:"address,omitempty" validate_type:"ipv6" required:"true"` - Prefix null.String `json:"prefix,omitempty" validate_type:"ipv6" required:"true"` + Prefix null.String `json:"prefix,omitempty" validate_type:"ipv6_prefix" required:"true"` Gateway null.String `json:"gateway,omitempty" validate_type:"ipv6" required:"true"` 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"` + Hostname null.String `json:"hostname,omitempty" validate_type:"hostname"` HTTPProxy null.String `json:"http_proxy,omitempty" validate_type:"proxy"` Domain null.String `json:"domain,omitempty" validate_type:"hostname"` @@ -44,7 +42,7 @@ type NetworkConfig struct { IPv6Mode null.String `json:"ipv6_mode,omitempty" one_of:"slaac,dhcpv6,slaac_and_dhcpv6,static,link_local,disabled" default:"slaac"` IPv6Static *IPv6StaticConfig `json:"ipv6_static,omitempty" required_if:"IPv6Mode=static"` - LLDPMode null.String `json:"lldp_mode,omitempty" one_of:"disabled,rx_only,tx_only,basic,all,enabled" default:"enabled"` + LLDPMode null.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 null.String `json:"mdns_mode,omitempty" one_of:"disabled,auto,ipv4_only,ipv6_only" default:"auto"` TimeSyncMode null.String `json:"time_sync_mode,omitempty" one_of:"ntp_only,ntp_and_http,http_only,custom" default:"ntp_and_http"` @@ -55,13 +53,15 @@ type NetworkConfig struct { TimeSyncHTTPUrls []string `json:"time_sync_http_urls,omitempty" validate_type:"url" required_if:"TimeSyncOrdering=http_user_provided"` } -func (c *NetworkConfig) GetMDNSMode() *mdns.MDNSListenOptions { - listenOptions := &mdns.MDNSListenOptions{ - IPv4: c.IPv4Mode.String != "disabled", - IPv6: c.IPv6Mode.String != "disabled", +// GetMDNSMode returns the MDNS mode configuration +func (c *NetworkConfig) GetMDNSMode() *MDNSListenOptions { + mode := c.MDNSMode.String + listenOptions := &MDNSListenOptions{ + IPv4: true, + IPv6: true, } - switch c.MDNSMode.String { + switch mode { case "ipv4_only": listenOptions.IPv6 = false case "ipv6_only": @@ -74,53 +74,21 @@ func (c *NetworkConfig) GetMDNSMode() *mdns.MDNSListenOptions { return listenOptions } -func (s *NetworkConfig) GetTransportProxyFunc() func(*http.Request) (*url.URL, error) { +// 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 s.HTTPProxy.String == "" { + if c.HTTPProxy.String == "" { return nil, nil } else { - proxyUrl, _ := url.Parse(s.HTTPProxy.String) - return proxyUrl, nil + proxyURL, _ := url.Parse(c.HTTPProxy.String) + return proxyURL, nil } } } -func (s *NetworkInterfaceState) GetHostname() string { - hostname := ToValidHostname(s.config.Hostname.String) - - if hostname == "" { - return s.defaultHostname - } - - return hostname -} - -func ToValidDomain(domain string) string { - ascii, err := idna.Lookup.ToASCII(domain) - if err != nil { - return "" - } - - return ascii -} - -func (s *NetworkInterfaceState) GetDomain() string { - domain := ToValidDomain(s.config.Domain.String) - - if domain == "" { - lease := s.dhcpClient.GetLease() - if lease != nil && lease.Domain != "" { - domain = ToValidDomain(lease.Domain) - } - } - - if domain == "" { - return "local" - } - - return domain -} - -func (s *NetworkInterfaceState) GetFQDN() string { - return fmt.Sprintf("%s.%s", s.GetHostname(), s.GetDomain()) +// NetworkConfig interface for backward compatibility +type NetworkConfigInterface interface { + InterfaceName() string + IPv4Addresses() []IPAddress + IPv6Addresses() []IPAddress } diff --git a/internal/udhcpc/parser.go b/internal/network/types/dhcp.go similarity index 55% rename from internal/udhcpc/parser.go rename to internal/network/types/dhcp.go index d75857c9..ff34e2f2 100644 --- a/internal/udhcpc/parser.go +++ b/internal/network/types/dhcp.go @@ -1,18 +1,26 @@ -package udhcpc +package types import ( - "bufio" - "encoding/json" - "fmt" "net" - "os" - "reflect" - "strconv" - "strings" "time" ) -type Lease struct { +// DHCPClient is the interface for a DHCP client. +type DHCPClient interface { + Domain() string + Lease4() *DHCPLease + Lease6() *DHCPLease + Renew() error + Release() error + SetIPv4(enabled bool) + SetIPv6(enabled bool) + SetOnLeaseChange(callback func(lease *DHCPLease)) + Start() error + Stop() error +} + +// DHCPLease is a network configuration obtained by DHCP. +type DHCPLease 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 @@ -21,6 +29,7 @@ type Lease struct { 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 + SearchList []string `env:"search" json:"search_list,omitempty"` // The search list for 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 @@ -38,149 +47,46 @@ type Lease struct { 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 + RenewalTime time.Duration `env:"renewal" json:"renewal,omitempty"` // The renewal time, in seconds + RebindingTime time.Duration `env:"rebinding" json:"rebinding,omitempty"` // The rebinding 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 + ClassIdentifier string `env:"classid" json:"class_identifier,omitempty"` // The class identifier LeaseExpiry *time.Time `json:"lease_expiry,omitempty"` // The expiry time of the lease - isEmpty map[string]bool + + InterfaceName string `json:"interface_name,omitempty"` // The name of the interface + DHCPClient string `json:"dhcp_client,omitempty"` // The DHCP client that obtained the lease } -func (l *Lease) setIsEmpty(m map[string]bool) { - l.isEmpty = m +// IsIPv6 returns true if the DHCP lease is for an IPv6 address +func (d *DHCPLease) IsIPv6() bool { + return d.IPAddress.To4() == nil } -func (l *Lease) IsEmpty(key string) bool { - return l.isEmpty[key] +// IPMask returns the IP mask for the DHCP lease +func (d *DHCPLease) IPMask() net.IPMask { + if d.IsIPv6() { + // TODO: not implemented + return nil + } + + mask := net.ParseIP(d.Netmask.String()) + return net.IPv4Mask(mask[12], mask[13], mask[14], mask[15]) } -func (l *Lease) ToJSON() string { - json, err := json.Marshal(l) - if err != nil { - return "" +// IPNet returns the IP net for the DHCP lease +func (d *DHCPLease) IPNet() *net.IPNet { + if d.IsIPv6() { + // TODO: not implemented + return nil + } + + return &net.IPNet{ + IP: d.IPAddress, + Mask: d.IPMask(), } - 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 } diff --git a/internal/network/types/interface.go b/internal/network/types/interface.go new file mode 100644 index 00000000..cea9620b --- /dev/null +++ b/internal/network/types/interface.go @@ -0,0 +1,62 @@ +package types + +import ( + "net" + "time" + + "golang.org/x/sys/unix" +) + +// InterfaceState represents the current state of a network interface +type InterfaceState struct { + InterfaceName string `json:"interface_name"` + Hostname string `json:"hostname"` + 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 +type RpcInterfaceState struct { + InterfaceState + IPv6Addresses []RpcIPv6Address `json:"ipv6_addresses"` +} + +// ToRpcInterfaceState converts an InterfaceState to a RpcInterfaceState +func (s *InterfaceState) ToRpcInterfaceState() *RpcInterfaceState { + addrs := make([]RpcIPv6Address, len(s.IPv6Addresses)) + for i, addr := range s.IPv6Addresses { + addrs[i] = RpcIPv6Address{ + Address: addr.Address.String(), + Prefix: addr.Prefix.String(), + ValidLifetime: addr.ValidLifetime, + PreferredLifetime: addr.PreferredLifetime, + Scope: addr.Scope, + Flags: addr.Flags, + FlagSecondary: addr.Flags&unix.IFA_F_SECONDARY != 0, + FlagPermanent: addr.Flags&unix.IFA_F_PERMANENT != 0, + FlagTemporary: addr.Flags&unix.IFA_F_TEMPORARY != 0, + FlagStablePrivacy: addr.Flags&unix.IFA_F_STABLE_PRIVACY != 0, + FlagDeprecated: addr.Flags&unix.IFA_F_DEPRECATED != 0, + FlagOptimistic: addr.Flags&unix.IFA_F_OPTIMISTIC != 0, + FlagDADFailed: addr.Flags&unix.IFA_F_DADFAILED != 0, + FlagTentative: addr.Flags&unix.IFA_F_TENTATIVE != 0, + } + } + return &RpcInterfaceState{ + InterfaceState: *s, + IPv6Addresses: addrs, + } +} diff --git a/internal/network/types/ip.go b/internal/network/types/ip.go new file mode 100644 index 00000000..b0a07bb3 --- /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.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/internal/network/utils.go b/internal/network/utils.go deleted file mode 100644 index 797fd72f..00000000 --- a/internal/network/utils.go +++ /dev/null @@ -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) -} diff --git a/internal/sync/log.go b/internal/sync/log.go new file mode 100644 index 00000000..36d0b29c --- /dev/null +++ b/internal/sync/log.go @@ -0,0 +1,149 @@ +//go:build synctrace + +package sync + +import ( + "fmt" + "reflect" + "runtime" + "sync" + "time" + + "github.com/jetkvm/kvm/internal/logging" + "github.com/rs/zerolog" +) + +var defaultLogger = logging.GetSubsystemLogger("synctrace") + +func logTrace(msg string) { + if defaultLogger.GetLevel() > zerolog.TraceLevel { + return + } + + logTrack(3).Trace().Msg(msg) +} + +func logTrack(callerSkip int) *zerolog.Logger { + l := *defaultLogger + if l.GetLevel() > zerolog.TraceLevel { + return &l + } + + pc, file, no, ok := runtime.Caller(callerSkip) + if ok { + l = l.With(). + Str("file", file). + Int("line", no). + Logger() + + details := runtime.FuncForPC(pc) + if details != nil { + l = l.With(). + Str("func", details.Name()). + Logger() + } + } + + return &l +} + +func logLockTrack(i string) *zerolog.Logger { + l := logTrack(4). + With(). + Str("index", i). + Logger() + return &l +} + +var ( + indexMu sync.Mutex + + lockCount map[string]int = make(map[string]int) + unlockCount map[string]int = make(map[string]int) + lastLock map[string]time.Time = make(map[string]time.Time) +) + +type trackable interface { + sync.Locker +} + +func getIndex(t trackable) string { + ptr := reflect.ValueOf(t).Pointer() + return fmt.Sprintf("%x", ptr) +} + +func increaseLockCount(i string) { + indexMu.Lock() + defer indexMu.Unlock() + + if _, ok := lockCount[i]; !ok { + lockCount[i] = 0 + } + lockCount[i]++ + + if _, ok := lastLock[i]; !ok { + lastLock[i] = time.Now() + } +} + +func increaseUnlockCount(i string) { + indexMu.Lock() + defer indexMu.Unlock() + + if _, ok := unlockCount[i]; !ok { + unlockCount[i] = 0 + } + unlockCount[i]++ +} + +func logLock(t trackable) { + i := getIndex(t) + increaseLockCount(i) + logLockTrack(i).Trace().Msg("locking mutex") +} + +func logUnlock(t trackable) { + i := getIndex(t) + increaseUnlockCount(i) + logLockTrack(i).Trace().Msg("unlocking mutex") +} + +func logTryLock(t trackable) { + i := getIndex(t) + logLockTrack(i).Trace().Msg("trying to lock mutex") +} + +func logTryLockResult(t trackable, l bool) { + if !l { + return + } + i := getIndex(t) + increaseLockCount(i) + logLockTrack(i).Trace().Msg("locked mutex") +} + +func logRLock(t trackable) { + i := getIndex(t) + increaseLockCount(i) + logLockTrack(i).Trace().Msg("locking mutex for reading") +} + +func logRUnlock(t trackable) { + i := getIndex(t) + increaseUnlockCount(i) + logLockTrack(i).Trace().Msg("unlocking mutex for reading") +} + +func logTryRLock(t trackable) { + i := getIndex(t) + logLockTrack(i).Trace().Msg("trying to lock mutex for reading") +} + +func logTryRLockResult(t trackable, l bool) { + if !l { + return + } + i := getIndex(t) + increaseLockCount(i) + logLockTrack(i).Trace().Msg("locked mutex for reading") +} diff --git a/internal/sync/mutex.go b/internal/sync/mutex.go new file mode 100644 index 00000000..a269bdaf --- /dev/null +++ b/internal/sync/mutex.go @@ -0,0 +1,69 @@ +//go:build synctrace + +package sync + +import ( + gosync "sync" +) + +// Mutex is a wrapper around the sync.Mutex +type Mutex struct { + mu gosync.Mutex +} + +// Lock locks the mutex +func (m *Mutex) Lock() { + logLock(m) + m.mu.Lock() +} + +// Unlock unlocks the mutex +func (m *Mutex) Unlock() { + logUnlock(m) + m.mu.Unlock() +} + +// TryLock tries to lock the mutex +func (m *Mutex) TryLock() bool { + logTryLock(m) + l := m.mu.TryLock() + logTryLockResult(m, l) + return l +} + +// RWMutex is a wrapper around the sync.RWMutex +type RWMutex struct { + mu gosync.RWMutex +} + +// Lock locks the mutex +func (m *RWMutex) Lock() { + logLock(m) + m.mu.Lock() +} + +// Unlock unlocks the mutex +func (m *RWMutex) Unlock() { + logUnlock(m) + m.mu.Unlock() +} + +// RLock locks the mutex for reading +func (m *RWMutex) RLock() { + logRLock(m) + m.mu.RLock() +} + +// RUnlock unlocks the mutex for reading +func (m *RWMutex) RUnlock() { + logRUnlock(m) + m.mu.RUnlock() +} + +// TryRLock tries to lock the mutex for reading +func (m *RWMutex) TryRLock() bool { + logTryRLock(m) + l := m.mu.TryRLock() + logTryRLockResult(m, l) + return l +} diff --git a/internal/sync/once.go b/internal/sync/once.go new file mode 100644 index 00000000..e2d90aff --- /dev/null +++ b/internal/sync/once.go @@ -0,0 +1,18 @@ +//go:build synctrace + +package sync + +import ( + gosync "sync" +) + +// Once is a wrapper around the sync.Once +type Once struct { + mu gosync.Once +} + +// Do calls the function f if and only if Do has not been called before for this instance of Once. +func (o *Once) Do(f func()) { + logTrace("Doing once") + o.mu.Do(f) +} diff --git a/internal/sync/release.go b/internal/sync/release.go new file mode 100644 index 00000000..daec330e --- /dev/null +++ b/internal/sync/release.go @@ -0,0 +1,92 @@ +//go:build !synctrace + +package sync + +import ( + gosync "sync" +) + +// Mutex is a wrapper around the sync.Mutex +type Mutex struct { + mu gosync.Mutex +} + +// Lock locks the mutex +func (m *Mutex) Lock() { + m.mu.Lock() +} + +// Unlock unlocks the mutex +func (m *Mutex) Unlock() { + m.mu.Unlock() +} + +// TryLock tries to lock the mutex +func (m *Mutex) TryLock() bool { + return m.mu.TryLock() +} + +// RWMutex is a wrapper around the sync.RWMutex +type RWMutex struct { + mu gosync.RWMutex +} + +// Lock locks the mutex +func (m *RWMutex) Lock() { + m.mu.Lock() +} + +// Unlock unlocks the mutex +func (m *RWMutex) Unlock() { + m.mu.Unlock() +} + +// RLock locks the mutex for reading +func (m *RWMutex) RLock() { + m.mu.RLock() +} + +// RUnlock unlocks the mutex for reading +func (m *RWMutex) RUnlock() { + m.mu.RUnlock() +} + +// TryRLock tries to lock the mutex for reading +func (m *RWMutex) TryRLock() bool { + return m.mu.TryRLock() +} + +// TryLock tries to lock the mutex +func (m *RWMutex) TryLock() bool { + return m.mu.TryLock() +} + +// WaitGroup is a wrapper around the sync.WaitGroup +type WaitGroup struct { + wg gosync.WaitGroup +} + +// Add adds a function to the wait group +func (w *WaitGroup) Add(delta int) { + w.wg.Add(delta) +} + +// Done decrements the wait group counter +func (w *WaitGroup) Done() { + w.wg.Done() +} + +// Wait waits for the wait group to finish +func (w *WaitGroup) Wait() { + w.wg.Wait() +} + +// Once is a wrapper around the sync.Once +type Once struct { + mu gosync.Once +} + +// Do calls the function f if and only if Do has not been called before for this instance of Once. +func (o *Once) Do(f func()) { + o.mu.Do(f) +} diff --git a/internal/sync/waitgroup.go b/internal/sync/waitgroup.go new file mode 100644 index 00000000..8d022e29 --- /dev/null +++ b/internal/sync/waitgroup.go @@ -0,0 +1,30 @@ +//go:build synctrace + +package sync + +import ( + gosync "sync" +) + +// WaitGroup is a wrapper around the sync.WaitGroup +type WaitGroup struct { + wg gosync.WaitGroup +} + +// Add adds a function to the wait group +func (w *WaitGroup) Add(delta int) { + logTrace("Adding to wait group") + w.wg.Add(delta) +} + +// Done decrements the wait group counter +func (w *WaitGroup) Done() { + logTrace("Done with wait group") + w.wg.Done() +} + +// Wait waits for the wait group to finish +func (w *WaitGroup) Wait() { + logTrace("Waiting for wait group") + w.wg.Wait() +} diff --git a/internal/timesync/ntp.go b/internal/timesync/ntp.go index b9ffa249..7ff410b0 100644 --- a/internal/timesync/ntp.go +++ b/internal/timesync/ntp.go @@ -3,13 +3,14 @@ package timesync import ( "context" "math/rand/v2" + "net" "strconv" "time" "github.com/beevik/ntp" ) -var defaultNTPServerIPs = []string{ +var DefaultNTPServerIPs = []string{ // These servers are known by static IP and as such don't need DNS lookups // These are from Google and Cloudflare since if they're down, the internet // is broken anyway @@ -27,7 +28,7 @@ var defaultNTPServerIPs = []string{ "2001:4860:4806:c::", // time.google.com IPv6 } -var defaultNTPServerHostnames = []string{ +var DefaultNTPServerHostnames = []string{ // should use something from https://github.com/jauderho/public-ntp-servers "time.apple.com", "time.aws.com", @@ -37,7 +38,48 @@ var defaultNTPServerHostnames = []string{ "pool.ntp.org", } +func (t *TimeSync) filterNTPServers(ntpServers []string) ([]string, error) { + if len(ntpServers) == 0 { + return nil, nil + } + + hasIPv4, err := t.preCheckIPv4() + if err != nil { + t.l.Error().Err(err).Msg("failed to check IPv4") + return nil, err + } + + hasIPv6, err := t.preCheckIPv6() + if err != nil { + t.l.Error().Err(err).Msg("failed to check IPv6") + return nil, err + } + + filteredServers := []string{} + for _, server := range ntpServers { + ip := net.ParseIP(server) + t.l.Trace().Str("server", server).Interface("ip", ip).Msg("checking NTP server") + if ip == nil { + continue + } + + if hasIPv4 && ip.To4() != nil { + filteredServers = append(filteredServers, server) + } + if hasIPv6 && ip.To16() != nil { + filteredServers = append(filteredServers, server) + } + } + return filteredServers, nil +} + func (t *TimeSync) queryNetworkTime(ntpServers []string) (now *time.Time, offset *time.Duration) { + ntpServers, err := t.filterNTPServers(ntpServers) + if err != nil { + t.l.Error().Err(err).Msg("failed to filter NTP servers") + return nil, nil + } + chunkSize := int(t.networkConfig.TimeSyncParallel.ValueOr(4)) t.l.Info().Strs("servers", ntpServers).Int("chunkSize", chunkSize).Msg("querying NTP servers") diff --git a/internal/timesync/timesync.go b/internal/timesync/timesync.go index b29a61ab..97cee97d 100644 --- a/internal/timesync/timesync.go +++ b/internal/timesync/timesync.go @@ -7,7 +7,7 @@ import ( "sync" "time" - "github.com/jetkvm/kvm/internal/network" + "github.com/jetkvm/kvm/internal/network/types" "github.com/rs/zerolog" ) @@ -24,11 +24,13 @@ var ( timeSyncRetryInterval = 0 * time.Second ) +type PreCheckFunc func() (bool, error) + type TimeSync struct { syncLock *sync.Mutex l *zerolog.Logger - networkConfig *network.NetworkConfig + networkConfig *types.NetworkConfig dhcpNtpAddresses []string rtcDevicePath string @@ -36,14 +38,19 @@ type TimeSync struct { rtcLock *sync.Mutex syncSuccess bool + timer *time.Timer - preCheckFunc func() (bool, error) + preCheckFunc PreCheckFunc + preCheckIPv4 PreCheckFunc + preCheckIPv6 PreCheckFunc } type TimeSyncOptions struct { - PreCheckFunc func() (bool, error) + PreCheckFunc PreCheckFunc + PreCheckIPv4 PreCheckFunc + PreCheckIPv6 PreCheckFunc Logger *zerolog.Logger - NetworkConfig *network.NetworkConfig + NetworkConfig *types.NetworkConfig } type SyncMode struct { @@ -69,7 +76,10 @@ func NewTimeSync(opts *TimeSyncOptions) *TimeSync { rtcDevicePath: rtcDevice, rtcLock: &sync.Mutex{}, preCheckFunc: opts.PreCheckFunc, + preCheckIPv4: opts.PreCheckIPv4, + preCheckIPv6: opts.PreCheckIPv6, networkConfig: opts.NetworkConfig, + timer: time.NewTimer(timeSyncWaitNetUpInt), } if t.rtcDevicePath != "" { @@ -112,49 +122,64 @@ func (t *TimeSync) getSyncMode() SyncMode { } } - t.l.Debug().Strs("Ordering", syncMode.Ordering).Bool("Ntp", syncMode.Ntp).Bool("Http", syncMode.Http).Bool("NtpUseFallback", syncMode.NtpUseFallback).Bool("HttpUseFallback", syncMode.HttpUseFallback).Msg("sync mode") + t.l.Debug(). + Strs("Ordering", syncMode.Ordering). + Bool("Ntp", syncMode.Ntp). + Bool("Http", syncMode.Http). + Bool("NtpUseFallback", syncMode.NtpUseFallback). + Bool("HttpUseFallback", syncMode.HttpUseFallback). + Msg("sync mode") return syncMode } -func (t *TimeSync) doTimeSync() { +func (t *TimeSync) timeSyncLoop() { metricTimeSyncStatus.Set(0) - for { + + // use a timer here instead of sleep + + for range t.timer.C { if ok, err := t.preCheckFunc(); !ok { if err != nil { t.l.Error().Err(err).Msg("pre-check failed") } - time.Sleep(timeSyncWaitNetChkInt) + t.timer.Reset(timeSyncWaitNetChkInt) continue } t.l.Info().Msg("syncing system time") start := time.Now() - err := t.Sync() + err := t.sync() if err != nil { t.l.Error().Str("error", err.Error()).Msg("failed to sync system time") // retry after a delay timeSyncRetryInterval += timeSyncRetryStep - time.Sleep(timeSyncRetryInterval) + t.timer.Reset(timeSyncRetryInterval) // reset the retry interval if it exceeds the max interval if timeSyncRetryInterval > timeSyncRetryMaxInt { timeSyncRetryInterval = 0 } - continue } + + isInitialSync := !t.syncSuccess t.syncSuccess = true + t.l.Info().Str("now", time.Now().Format(time.RFC3339)). Str("time_taken", time.Since(start).String()). + Bool("is_initial_sync", isInitialSync). Msg("time sync successful") metricTimeSyncStatus.Set(1) - time.Sleep(timeSyncInterval) // after the first sync is done + t.timer.Reset(timeSyncInterval) // after the first sync is done } } -func (t *TimeSync) Sync() error { +func (t *TimeSync) sync() error { + t.syncLock.Lock() + defer t.syncLock.Unlock() + var ( now *time.Time offset *time.Duration @@ -188,10 +213,10 @@ Orders: case "ntp": if syncMode.Ntp && syncMode.NtpUseFallback { log.Info().Msg("using NTP fallback IPs") - now, offset = t.queryNetworkTime(defaultNTPServerIPs) + now, offset = t.queryNetworkTime(DefaultNTPServerIPs) if now == nil { log.Info().Msg("using NTP fallback hostnames") - now, offset = t.queryNetworkTime(defaultNTPServerHostnames) + now, offset = t.queryNetworkTime(DefaultNTPServerHostnames) } if now != nil { break Orders @@ -239,12 +264,25 @@ Orders: return nil } +// Sync triggers a manual time sync +func (t *TimeSync) Sync() error { + if !t.syncLock.TryLock() { + t.l.Warn().Msg("sync already in progress, skipping") + return nil + } + t.syncLock.Unlock() + + return t.sync() +} + +// IsSyncSuccess returns true if the system time is synchronized func (t *TimeSync) IsSyncSuccess() bool { return t.syncSuccess } +// Start starts the time sync func (t *TimeSync) Start() { - go t.doTimeSync() + go t.timeSyncLoop() } func (t *TimeSync) setSystemTime(now time.Time) error { diff --git a/jsonrpc.go b/jsonrpc.go index 6b321c6d..d2d3f401 100644 --- a/jsonrpc.go +++ b/jsonrpc.go @@ -175,6 +175,10 @@ func rpcGetDeviceID() (string, error) { func rpcReboot(force bool) error { logger.Info().Msg("Got reboot request from JSONRPC, rebooting...") + writeJSONRPCEvent("willReboot", nil, currentSession) + + // Wait for the JSONRPCEvent to be sent + time.Sleep(1 * time.Second) nativeInstance.SwitchToScreenIfDifferent("rebooting_screen") args := []string{} @@ -720,7 +724,8 @@ func rpcSetWakeOnLanDevices(params SetWakeOnLanDevicesParams) error { } func rpcResetConfig() error { - config = defaultConfig + defaultConfig := getDefaultConfig() + config = &defaultConfig if err := SaveConfig(); err != nil { return fmt.Errorf("failed to reset config: %w", err) } diff --git a/main.go b/main.go index 81c85431..2648b68d 100644 --- a/main.go +++ b/main.go @@ -33,6 +33,7 @@ func Main() { go runWatchdog() go confirmCurrentSystem() + initDisplay() initNative(systemVersionLocal, appVersionLocal) http.DefaultClient.Timeout = 1 * time.Minute @@ -74,9 +75,6 @@ func Main() { } initJiggler() - // initialize display - initDisplay() - // start video sleep mode timer startVideoSleepModeTicker() diff --git a/mdns.go b/mdns.go index 4f9b49b1..c197d16f 100644 --- a/mdns.go +++ b/mdns.go @@ -1,19 +1,23 @@ package kvm import ( + "fmt" + "github.com/jetkvm/kvm/internal/mdns" ) var mDNS *mdns.MDNS func initMdns() error { + options := getMdnsOptions() + if options == nil { + return fmt.Errorf("failed to get mDNS options") + } + m, err := mdns.NewMDNS(&mdns.MDNSOptions{ - Logger: logger, - LocalNames: []string{ - networkState.GetHostname(), - networkState.GetFQDN(), - }, - ListenOptions: config.NetworkConfig.GetMDNSMode(), + Logger: logger, + LocalNames: options.LocalNames, + ListenOptions: options.ListenOptions, }) if err != nil { return err diff --git a/native.go b/native.go index e8eea745..4268bf2c 100644 --- a/native.go +++ b/native.go @@ -43,6 +43,8 @@ func initNative(systemVersion *semver.Version, appVersion *semver.Version) { _ = rpcReboot(true) case "reboot": _ = rpcReboot(true) + case "toggleDHCPClient": + _ = rpcToggleDHCPClient() default: nativeLogger.Warn().Str("event", event).Msg("unknown rpc event received") } diff --git a/network.go b/network.go index b808d6fe..ff071460 100644 --- a/network.go +++ b/network.go @@ -1,10 +1,14 @@ package kvm import ( + "context" "fmt" + "reflect" - "github.com/jetkvm/kvm/internal/network" - "github.com/jetkvm/kvm/internal/udhcpc" + "github.com/jetkvm/kvm/internal/confparser" + "github.com/jetkvm/kvm/internal/mdns" + "github.com/jetkvm/kvm/internal/network/types" + "github.com/jetkvm/kvm/pkg/nmlite" ) const ( @@ -12,114 +16,297 @@ const ( ) var ( - networkState *network.NetworkInterfaceState + networkManager *nmlite.NetworkManager ) -func networkStateChanged(isOnline bool) { - // do not block the main thread - go waitCtrlAndRequestDisplayUpdate(true, "network_state_changed") +type RpcNetworkSettings struct { + types.NetworkConfig +} - if timeSync != nil { - if networkState != nil { - timeSync.SetDhcpNtpAddresses(networkState.NtpAddressesString()) +func (s *RpcNetworkSettings) ToNetworkConfig() *types.NetworkConfig { + return &s.NetworkConfig +} + +type PostRebootAction struct { + HealthCheck string `json:"healthCheck"` + RedirectUrl string `json:"redirectUrl"` +} + +func toRpcNetworkSettings(config *types.NetworkConfig) *RpcNetworkSettings { + return &RpcNetworkSettings{ + NetworkConfig: *config, + } +} + +func getMdnsOptions() *mdns.MDNSOptions { + if networkManager == nil { + return nil + } + + var ipv4, ipv6 bool + switch config.NetworkConfig.MDNSMode.String { + case "auto": + ipv4 = true + ipv6 = true + case "ipv4_only": + ipv4 = true + case "ipv6_only": + ipv6 = true + } + + return &mdns.MDNSOptions{ + LocalNames: []string{ + networkManager.Hostname(), + networkManager.FQDN(), + }, + ListenOptions: &mdns.MDNSListenOptions{ + IPv4: ipv4, + IPv6: ipv6, + }, + } +} + +func restartMdns() { + if mDNS == nil { + return + } + + options := getMdnsOptions() + if options == nil { + return + } + + if err := mDNS.SetOptions(options); err != nil { + networkLogger.Error().Err(err).Msg("failed to restart mDNS") + } +} + +func triggerTimeSyncOnNetworkStateChange() { + if timeSync == nil { + return + } + + // set the NTP servers from the network manager + if networkManager != nil { + 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 + go func() { if err := timeSync.Sync(); err != nil { networkLogger.Error().Err(err).Msg("failed to sync time after network state change") } + }() +} + +func networkStateChanged(_ string, state types.InterfaceState) { + // do not block the main thread + go waitCtrlAndRequestDisplayUpdate(true, "network_state_changed") + + if currentSession != nil { + writeJSONRPCEvent("networkState", state.ToRpcInterfaceState(), currentSession) + } + + if state.Online { + networkLogger.Info().Msg("network state changed to online, triggering time sync") + triggerTimeSyncOnNetworkStateChange() } // always restart mDNS when the network state changes if mDNS != nil { - _ = mDNS.SetListenOptions(config.NetworkConfig.GetMDNSMode()) - _ = mDNS.SetLocalNames([]string{ - networkState.GetHostname(), - networkState.GetFQDN(), - }, true) + restartMdns() + } +} + +func validateNetworkConfig() { + err := confparser.SetDefaultsAndValidate(config.NetworkConfig) + if err == nil { + return } - // if the network is now online, trigger an NTP sync if still needed - if isOnline && timeSync != nil && (isTimeSyncNeeded() || !timeSync.IsSyncSuccess()) { - if err := timeSync.Sync(); err != nil { - logger.Warn().Str("error", err.Error()).Msg("unable to sync time on network state change") - } + networkLogger.Error().Err(err).Msg("failed to validate config, reverting to default config") + if err := SaveBackupConfig(); err != nil { + networkLogger.Error().Err(err).Msg("failed to save backup config") + } + + // do not use a pointer to the default config + // it has been already changed during LoadConfig + config.NetworkConfig = &(types.NetworkConfig{}) + if err := SaveConfig(); err != nil { + networkLogger.Error().Err(err).Msg("failed to save config") } } func initNetwork() error { ensureConfigLoaded() - state, err := network.NewNetworkInterfaceState(&network.NetworkInterfaceOptions{ - DefaultHostname: GetDefaultHostname(), - InterfaceName: NetIfName, - NetworkConfig: config.NetworkConfig, - Logger: networkLogger, - OnStateChange: func(state *network.NetworkInterfaceState) { - networkStateChanged(state.IsOnline()) - }, - OnInitialCheck: func(state *network.NetworkInterfaceState) { - networkStateChanged(state.IsOnline()) - }, - OnDhcpLeaseChange: func(lease *udhcpc.Lease, state *network.NetworkInterfaceState) { - networkStateChanged(state.IsOnline()) + // validate the config, if it's invalid, revert to the default config and save the backup + validateNetworkConfig() - if currentSession == nil { - return - } + nc := config.NetworkConfig - writeJSONRPCEvent("networkState", networkState.RpcGetNetworkState(), currentSession) - }, - OnConfigChange: func(networkConfig *network.NetworkConfig) { - config.NetworkConfig = networkConfig - networkStateChanged(false) - - if mDNS != nil { - _ = mDNS.SetListenOptions(networkConfig.GetMDNSMode()) - _ = mDNS.SetLocalNames([]string{ - networkState.GetHostname(), - networkState.GetFQDN(), - }, true) - } - }, - }) - - if state == nil { - if err == nil { - return fmt.Errorf("failed to create NetworkInterfaceState") - } - return err + nm := nmlite.NewNetworkManager(context.Background(), networkLogger) + networkLogger.Info().Interface("networkConfig", nc).Str("hostname", nc.Hostname.String).Str("domain", nc.Domain.String).Msg("initializing network manager") + _ = 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) } + _ = nm.CleanUpLegacyDHCPClients() - if err := state.Run(); err != nil { - return err - } - - networkState = state + networkManager = nm return nil } -func rpcGetNetworkState() network.RpcNetworkState { - return networkState.RpcGetNetworkState() +func setHostname(nm *nmlite.NetworkManager, hostname, domain string) error { + if nm == nil { + return nil + } + + if hostname == "" { + hostname = GetDefaultHostname() + } + + return nm.SetHostname(hostname, domain) } -func rpcGetNetworkSettings() network.RpcNetworkSettings { - return networkState.RpcGetNetworkSettings() +func shouldRebootForNetworkChange(oldConfig, newConfig *types.NetworkConfig) (rebootRequired bool, postRebootAction *PostRebootAction) { + oldDhcpClient := oldConfig.DHCPClient.String + + l := networkLogger.With(). + Interface("old", oldConfig). + Interface("new", newConfig). + Logger() + + // DHCP client change always requires reboot + if newConfig.DHCPClient.String != oldDhcpClient { + rebootRequired = true + l.Info().Msg("DHCP client changed, reboot required") + return rebootRequired, postRebootAction + } + + oldIPv4Mode := oldConfig.IPv4Mode.String + newIPv4Mode := newConfig.IPv4Mode.String + // IPv4 mode change requires reboot + if newIPv4Mode != oldIPv4Mode { + rebootRequired = true + l.Info().Msg("IPv4 mode changed with udhcpc, reboot required") + + if newIPv4Mode == "static" && oldIPv4Mode != "static" { + postRebootAction = &PostRebootAction{ + HealthCheck: fmt.Sprintf("//%s/device/status", newConfig.IPv4Static.Address.String), + RedirectUrl: fmt.Sprintf("//%s", newConfig.IPv4Static.Address.String), + } + l.Info().Interface("postRebootAction", postRebootAction).Msg("IPv4 mode changed to static, reboot required") + } + + return rebootRequired, postRebootAction + } + + // IPv4 static config changes require reboot + if !reflect.DeepEqual(oldConfig.IPv4Static, newConfig.IPv4Static) { + rebootRequired = true + + // Handle IP change for redirect (only if both are not nil and IP changed) + if newConfig.IPv4Static != nil && oldConfig.IPv4Static != nil && + newConfig.IPv4Static.Address.String != oldConfig.IPv4Static.Address.String { + postRebootAction = &PostRebootAction{ + HealthCheck: fmt.Sprintf("//%s/device/status", newConfig.IPv4Static.Address.String), + RedirectUrl: fmt.Sprintf("//%s", newConfig.IPv4Static.Address.String), + } + + l.Info().Interface("postRebootAction", postRebootAction).Msg("IPv4 static config changed, reboot required") + } + + return rebootRequired, postRebootAction + } + + // IPv6 mode change requires reboot when using udhcpc + if newConfig.IPv6Mode.String != oldConfig.IPv6Mode.String && oldDhcpClient == "udhcpc" { + rebootRequired = true + l.Info().Msg("IPv6 mode changed with udhcpc, reboot required") + } + + return rebootRequired, postRebootAction } -func rpcSetNetworkSettings(settings network.RpcNetworkSettings) (*network.RpcNetworkSettings, error) { - s := networkState.RpcSetNetworkSettings(settings) +func rpcGetNetworkState() *types.RpcInterfaceState { + state, _ := networkManager.GetInterfaceState(NetIfName) + return state.ToRpcInterfaceState() +} + +func rpcGetNetworkSettings() *RpcNetworkSettings { + return toRpcNetworkSettings(config.NetworkConfig) +} + +func rpcSetNetworkSettings(settings RpcNetworkSettings) (*RpcNetworkSettings, error) { + netConfig := settings.ToNetworkConfig() + + l := networkLogger.With(). + Str("interface", NetIfName). + Interface("newConfig", netConfig). + Logger() + + l.Debug().Msg("setting new config") + + // Check if reboot is needed + rebootRequired, postRebootAction := shouldRebootForNetworkChange(config.NetworkConfig, netConfig) + + // If reboot required, send willReboot event before applying network config + if rebootRequired { + l.Info().Msg("Sending willReboot event before applying network config") + writeJSONRPCEvent("willReboot", postRebootAction, currentSession) + } + + _ = setHostname(networkManager, netConfig.Hostname.String, netConfig.Domain.String) + + s := networkManager.SetInterfaceConfig(NetIfName, netConfig) if s != nil { return nil, s } + l.Debug().Msg("new config applied") + newConfig, err := networkManager.GetInterfaceConfig(NetIfName) + if err != nil { + return nil, err + } + config.NetworkConfig = newConfig + + l.Debug().Msg("saving new config") if err := SaveConfig(); err != nil { return nil, err } - return &network.RpcNetworkSettings{NetworkConfig: *config.NetworkConfig}, nil + if rebootRequired { + if err := rpcReboot(false); err != nil { + return nil, err + } + } + + return toRpcNetworkSettings(newConfig), nil } func rpcRenewDHCPLease() error { - return networkState.RpcRenewDHCPLease() + return networkManager.RenewDHCPLease(NetIfName) +} + +func rpcToggleDHCPClient() error { + switch config.NetworkConfig.DHCPClient.String { + case "jetdhcpc": + config.NetworkConfig.DHCPClient.String = "udhcpc" + case "udhcpc": + config.NetworkConfig.DHCPClient.String = "jetdhcpc" + } + + if err := SaveConfig(); err != nil { + return err + } + + return rpcReboot(true) } diff --git a/ota.go b/ota.go index bf0828dc..7063c7ff 100644 --- a/ota.go +++ b/ota.go @@ -488,6 +488,15 @@ func TryUpdate(ctx context.Context, deviceId string, includePreRelease bool) err if rebootNeeded { scopedLogger.Info().Msg("System Rebooting in 10s") + + // TODO: Future enhancement - send postRebootAction to redirect to release notes + // Example: + // postRebootAction := &PostRebootAction{ + // HealthCheck: "[..]/device/status", + // RedirectUrl: "[..]/settings/general/update?version=X.Y.Z", + // } + // writeJSONRPCEvent("willReboot", postRebootAction, currentSession) + time.Sleep(10 * time.Second) cmd := exec.Command("reboot") err := cmd.Start() diff --git a/pkg/nmlite/dhcp.go b/pkg/nmlite/dhcp.go new file mode 100644 index 00000000..2a0c47b5 --- /dev/null +++ b/pkg/nmlite/dhcp.go @@ -0,0 +1,219 @@ +// Package nmlite provides DHCP client functionality for the network manager. +package nmlite + +import ( + "context" + "fmt" + + "github.com/jetkvm/kvm/internal/network/types" + "github.com/jetkvm/kvm/pkg/nmlite/jetdhcpc" + "github.com/jetkvm/kvm/pkg/nmlite/udhcpc" + "github.com/rs/zerolog" +) + +// DHCPClient wraps the dhclient package for use in the network manager +type DHCPClient struct { + ctx context.Context + ifaceName string + logger *zerolog.Logger + client types.DHCPClient + clientType string + + // Configuration + ipv4Enabled bool + ipv6Enabled bool + + // Callbacks + onLeaseChange func(lease *types.DHCPLease) +} + +// NewDHCPClient creates a new DHCP client +func NewDHCPClient(ctx context.Context, ifaceName string, logger *zerolog.Logger, clientType string) (*DHCPClient, error) { + if ifaceName == "" { + return nil, fmt.Errorf("interface name cannot be empty") + } + + if logger == nil { + return nil, fmt.Errorf("logger cannot be nil") + } + + return &DHCPClient{ + ctx: ctx, + ifaceName: ifaceName, + logger: logger, + clientType: clientType, + }, nil +} + +// SetIPv4 enables or disables IPv4 DHCP +func (dc *DHCPClient) SetIPv4(enabled bool) { + dc.ipv4Enabled = enabled + if dc.client != nil { + dc.client.SetIPv4(enabled) + } +} + +// SetIPv6 enables or disables IPv6 DHCP +func (dc *DHCPClient) SetIPv6(enabled bool) { + dc.ipv6Enabled = enabled + if dc.client != nil { + dc.client.SetIPv6(enabled) + } +} + +// SetOnLeaseChange sets the callback for lease changes +func (dc *DHCPClient) SetOnLeaseChange(callback func(lease *types.DHCPLease)) { + dc.onLeaseChange = callback +} + +func (dc *DHCPClient) initClient() (types.DHCPClient, error) { + switch dc.clientType { + case "jetdhcpc": + return dc.initJetDHCPC() + case "udhcpc": + return dc.initUDHCPC() + default: + return nil, fmt.Errorf("invalid client type: %s", dc.clientType) + } +} + +func (dc *DHCPClient) initJetDHCPC() (types.DHCPClient, error) { + return jetdhcpc.NewClient(dc.ctx, []string{dc.ifaceName}, &jetdhcpc.Config{ + IPv4: dc.ipv4Enabled, + IPv6: dc.ipv6Enabled, + V4ClientIdentifier: true, + OnLease4Change: func(lease *types.DHCPLease) { + dc.handleLeaseChange(lease, false) + }, + OnLease6Change: func(lease *types.DHCPLease) { + dc.handleLeaseChange(lease, true) + }, + UpdateResolvConf: func(nameservers []string) error { + // This will be handled by the resolv.conf manager + dc.logger.Debug(). + Interface("nameservers", nameservers). + Msg("DHCP client requested resolv.conf update") + return nil + }, + }, dc.logger) +} + +func (dc *DHCPClient) initUDHCPC() (types.DHCPClient, error) { + c := udhcpc.NewDHCPClient(&udhcpc.DHCPClientOptions{ + InterfaceName: dc.ifaceName, + PidFile: "", + Logger: dc.logger, + OnLeaseChange: func(lease *types.DHCPLease) { + dc.handleLeaseChange(lease, false) + }, + }) + return c, nil +} + +// Start starts the DHCP client +func (dc *DHCPClient) Start() error { + if dc.client != nil { + dc.logger.Warn().Msg("DHCP client already started") + return nil + } + + dc.logger.Info().Msg("starting DHCP client") + + // Create the underlying DHCP client + client, err := dc.initClient() + + if err != nil { + return fmt.Errorf("failed to create DHCP client: %w", err) + } + + dc.client = client + + // Start the client + if err := dc.client.Start(); err != nil { + dc.client = nil + return fmt.Errorf("failed to start DHCP client: %w", err) + } + + dc.logger.Info().Msg("DHCP client started") + 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 +func (dc *DHCPClient) Stop() error { + if dc.client == nil { + return nil + } + + dc.logger.Info().Msg("stopping DHCP client") + + dc.client = nil + dc.logger.Info().Msg("DHCP client stopped") + return nil +} + +// Renew renews the DHCP lease +func (dc *DHCPClient) Renew() error { + if dc.client == nil { + return fmt.Errorf("DHCP client not started") + } + + dc.logger.Info().Msg("renewing DHCP lease") + if err := dc.client.Renew(); err != nil { + return fmt.Errorf("failed to renew DHCP lease: %w", err) + } + return nil +} + +// Release releases the DHCP lease +func (dc *DHCPClient) Release() error { + if dc.client == nil { + return fmt.Errorf("DHCP client not started") + } + + dc.logger.Info().Msg("releasing DHCP lease") + if err := dc.client.Release(); err != nil { + return fmt.Errorf("failed to release DHCP lease: %w", err) + } + return nil +} + +// handleLeaseChange handles lease changes from the underlying DHCP client +func (dc *DHCPClient) handleLeaseChange(lease *types.DHCPLease, isIPv6 bool) { + if lease == nil { + return + } + + dc.logger.Info(). + Bool("ipv6", isIPv6). + Str("ip", lease.IPAddress.String()). + Msg("DHCP lease changed") + + // copy the lease to avoid race conditions + leaseCopy := *lease + + // Notify callback + if dc.onLeaseChange != nil { + dc.onLeaseChange(&leaseCopy) + } +} diff --git a/pkg/nmlite/hostname.go b/pkg/nmlite/hostname.go new file mode 100644 index 00000000..146aad0b --- /dev/null +++ b/pkg/nmlite/hostname.go @@ -0,0 +1,261 @@ +package nmlite + +import ( + "fmt" + "io" + "os" + "os/exec" + "strings" + + "golang.org/x/net/idna" +) + +const ( + hostnamePath = "/etc/hostname" + hostsPath = "/etc/hosts" +) + +// SetHostname sets the system hostname and updates /etc/hosts +func (hm *ResolvConfManager) SetHostname(hostname, domain string) error { + hostname = ToValidHostname(strings.TrimSpace(hostname)) + domain = ToValidHostname(strings.TrimSpace(domain)) + + if hostname == "" { + return fmt.Errorf("invalid hostname: %s", 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 "local" +} + +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(). + Str("hostname", hostname). + Str("fqdn", fqdn). + Msg("setting hostname") + + // Update /etc/hostname + if err := hm.updateEtcHostname(hostname); err != nil { + return fmt.Errorf("failed to update /etc/hostname: %w", err) + } + + // Update /etc/hosts + if err := hm.updateEtcHosts(hostname, fqdn); err != nil { + return fmt.Errorf("failed to update /etc/hosts: %w", err) + } + + // Set the hostname using hostname command + if err := hm.setSystemHostname(hostname); err != nil { + return fmt.Errorf("failed to set system hostname: %w", err) + } + + hm.logger.Info(). + Str("hostname", hostname). + Str("fqdn", fqdn). + Msg("hostname set successfully") + + return nil +} + +// GetCurrentHostname returns the current system hostname +func (hm *ResolvConfManager) GetCurrentHostname() (string, error) { + return os.Hostname() +} + +// GetCurrentFQDN returns the current FQDN +func (hm *ResolvConfManager) GetCurrentFQDN() (string, error) { + hostname, err := hm.GetCurrentHostname() + if err != nil { + return "", err + } + + // Try to get the FQDN from /etc/hosts + return hm.getFQDNFromHosts(hostname) +} + +// updateEtcHostname updates the /etc/hostname file +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) + } + + hm.logger.Debug().Str("file", hostnamePath).Str("hostname", hostname).Msg("updated /etc/hostname") + return nil +} + +// updateEtcHosts updates the /etc/hosts file +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 { + return fmt.Errorf("failed to open %s: %w", hostsPath, err) + } + defer hostsFile.Close() + + // Read all lines + if _, err := hostsFile.Seek(0, io.SeekStart); err != nil { + return fmt.Errorf("failed to seek %s: %w", hostsPath, err) + } + + lines, err := io.ReadAll(hostsFile) + if err != nil { + return fmt.Errorf("failed to read %s: %w", hostsPath, err) + } + + // Process lines + newLines := []string{} + hostLine := fmt.Sprintf("127.0.1.1\t%s %s", hostname, fqdn) + hostLineExists := false + + for _, line := range strings.Split(string(lines), "\n") { + if strings.HasPrefix(line, "127.0.1.1") { + hostLineExists = true + line = hostLine + } + newLines = append(newLines, line) + } + + // Add host line if it doesn't exist + if !hostLineExists { + newLines = append(newLines, hostLine) + } + + // Write back to file + if err := hostsFile.Truncate(0); err != nil { + return fmt.Errorf("failed to truncate %s: %w", hostsPath, err) + } + + if _, err := hostsFile.Seek(0, io.SeekStart); err != nil { + return fmt.Errorf("failed to seek %s: %w", hostsPath, err) + } + + if _, err := hostsFile.Write([]byte(strings.Join(newLines, "\n"))); err != nil { + return fmt.Errorf("failed to write %s: %w", hostsPath, err) + } + + hm.logger.Debug(). + Str("file", hostsPath). + Str("hostname", hostname). + Str("fqdn", fqdn). + Msg("updated /etc/hosts") + + return nil +} + +// setSystemHostname sets the system hostname using the hostname command +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) + } + + hm.logger.Debug().Str("hostname", hostname).Msg("set system hostname") + return nil +} + +// getFQDNFromHosts tries to get the FQDN from /etc/hosts +func (hm *ResolvConfManager) getFQDNFromHosts(hostname string) (string, error) { + content, err := os.ReadFile(hostsPath) + if err != nil { + return hostname, nil // Return hostname as fallback + } + + lines := strings.Split(string(content), "\n") + for _, line := range lines { + if strings.HasPrefix(line, "127.0.1.1") { + parts := strings.Fields(line) + if len(parts) >= 2 { + // The second part should be the FQDN + return parts[1], nil + } + } + } + + return hostname, nil // Return hostname as fallback +} + +// ToValidHostname converts a hostname to a valid format +func ToValidHostname(hostname string) string { + ascii, err := idna.Lookup.ToASCII(hostname) + if err != nil { + return "" + } + return ascii +} + +// ValidateHostname validates a hostname +func ValidateHostname(hostname string) error { + _, err := idna.Lookup.ToASCII(hostname) + return err +} diff --git a/pkg/nmlite/interface.go b/pkg/nmlite/interface.go new file mode 100644 index 00000000..58bd7353 --- /dev/null +++ b/pkg/nmlite/interface.go @@ -0,0 +1,853 @@ +package nmlite + +import ( + "context" + "fmt" + "net" + "net/netip" + + "time" + + "github.com/jetkvm/kvm/internal/sync" + + "github.com/jetkvm/kvm/internal/confparser" + "github.com/jetkvm/kvm/internal/logging" + "github.com/jetkvm/kvm/internal/network/types" + "github.com/jetkvm/kvm/pkg/nmlite/link" + "github.com/mdlayher/ndp" + "github.com/rs/zerolog" + "github.com/vishvananda/netlink" +) + +type ResolvConfChangeCallback func(family int, resolvConf *types.InterfaceResolvConf) error + +// InterfaceManager manages a single network interface +type InterfaceManager struct { + ctx context.Context + ifaceName string + config *types.NetworkConfig + logger *zerolog.Logger + state *types.InterfaceState + linkState *link.Link + stateMu sync.RWMutex + + // Network components + staticConfig *StaticConfigManager + dhcpClient *DHCPClient + + // Callbacks + onStateChange func(state types.InterfaceState) + onConfigChange func(config *types.NetworkConfig) + onDHCPLeaseChange func(lease *types.DHCPLease) + onResolvConfChange ResolvConfChangeCallback + + // Control + stopCh chan struct{} + wg sync.WaitGroup +} + +// NewInterfaceManager creates a new interface manager +func NewInterfaceManager(ctx context.Context, ifaceName string, config *types.NetworkConfig, logger *zerolog.Logger) (*InterfaceManager, error) { + if config == nil { + return nil, fmt.Errorf("config cannot be nil") + } + + if logger == nil { + logger = logging.GetSubsystemLogger("interface") + } + + scopedLogger := logger.With().Str("interface", ifaceName).Logger() + + // Validate and set defaults + if err := confparser.SetDefaultsAndValidate(config); err != nil { + return nil, fmt.Errorf("invalid config: %w", err) + } + + im := &InterfaceManager{ + ctx: ctx, + ifaceName: ifaceName, + config: config, + logger: &scopedLogger, + state: &types.InterfaceState{ + InterfaceName: ifaceName, + // LastUpdated: time.Now(), + }, + stopCh: make(chan struct{}), + } + + // Initialize components + var err error + im.staticConfig, err = NewStaticConfigManager(ifaceName, &scopedLogger) + if err != nil { + return nil, fmt.Errorf("failed to create static config manager: %w", err) + } + + // create the dhcp client + im.dhcpClient, err = NewDHCPClient(ctx, ifaceName, &scopedLogger, config.DHCPClient.String) + if err != nil { + return nil, fmt.Errorf("failed to create DHCP client: %w", err) + } + + // Set up DHCP client callbacks + im.dhcpClient.SetOnLeaseChange(func(lease *types.DHCPLease) { + if im.config.IPv4Mode.String != "dhcp" { + im.logger.Warn().Str("mode", im.config.IPv4Mode.String).Msg("ignoring DHCP lease, current mode is not DHCP") + return + } + + if err := im.applyDHCPLease(lease); err != nil { + im.logger.Error().Err(err).Msg("failed to apply DHCP lease") + } + im.updateStateFromDHCPLease(lease) + if im.onDHCPLeaseChange != nil { + im.onDHCPLeaseChange(lease) + } + }) + + return im, nil +} + +// Start starts managing the interface +func (im *InterfaceManager) Start() error { + im.stateMu.Lock() + defer im.stateMu.Unlock() + + im.logger.Info().Msg("starting interface manager") + + // Start monitoring interface state + im.wg.Add(1) + go im.monitorInterfaceState() + + nl := getNetlinkManager() + + // Set the link state + linkState, err := nl.GetLinkByName(im.ifaceName) + if err != nil { + return fmt.Errorf("failed to get interface: %w", err) + } + im.linkState = linkState + + // Bring the interface up + _, linkUpErr := nl.EnsureInterfaceUpWithTimeout( + im.ctx, + im.linkState, + 30*time.Second, + ) + + // Set callback after the interface is up + nl.AddStateChangeCallback(im.ifaceName, link.StateChangeCallback{ + Async: true, + Func: func(link *link.Link) { + im.handleLinkStateChange(link) + }, + }) + + if linkUpErr != nil { + im.logger.Error().Err(linkUpErr).Msg("failed to bring interface up, continuing anyway") + } else { + // Apply initial configuration + if err := im.applyConfiguration(); err != nil { + im.logger.Error().Err(err).Msg("failed to apply initial configuration") + return err + } + } + + im.logger.Info().Msg("interface manager started") + return nil +} + +// Stop stops managing the interface +func (im *InterfaceManager) Stop() error { + im.logger.Info().Msg("stopping interface manager") + + close(im.stopCh) + im.wg.Wait() + + // Stop DHCP client + if im.dhcpClient != nil { + if err := im.dhcpClient.Stop(); err != nil { + return fmt.Errorf("failed to stop DHCP client: %w", err) + } + } + + im.logger.Info().Msg("interface manager stopped") + return nil +} + +func (im *InterfaceManager) link() (*link.Link, error) { + nl := getNetlinkManager() + if nl == nil { + return nil, fmt.Errorf("netlink manager not initialized") + } + return nl.GetLinkByName(im.ifaceName) +} + +// IsUp returns true if the interface is up +func (im *InterfaceManager) IsUp() bool { + im.stateMu.RLock() + defer im.stateMu.RUnlock() + + if im.state == nil { + return false + } + + return im.state.Up +} + +// IsOnline returns true if the interface is online +func (im *InterfaceManager) IsOnline() bool { + im.stateMu.RLock() + defer im.stateMu.RUnlock() + + if im.state == nil { + return false + } + + return im.state.Online +} + +// IPv4Ready returns true if the interface has an IPv4 address +func (im *InterfaceManager) IPv4Ready() bool { + im.stateMu.RLock() + defer im.stateMu.RUnlock() + + if im.state == nil { + return false + } + + return im.state.IPv4Ready +} + +// IPv6Ready returns true if the interface has an IPv6 address +func (im *InterfaceManager) IPv6Ready() bool { + im.stateMu.RLock() + defer im.stateMu.RUnlock() + + if im.state == nil { + return false + } + + return im.state.IPv6Ready +} + +// GetIPv4Addresses returns the IPv4 addresses of the interface +func (im *InterfaceManager) GetIPv4Addresses() []string { + im.stateMu.RLock() + defer im.stateMu.RUnlock() + + if im.state == nil { + return []string{} + } + + return im.state.IPv4Addresses +} + +// GetIPv4Address returns the IPv4 address of the interface +func (im *InterfaceManager) GetIPv4Address() string { + im.stateMu.RLock() + defer im.stateMu.RUnlock() + + if im.state == nil { + return "" + } + + return im.state.IPv4Address +} + +// GetIPv6Address returns the IPv6 address of the interface +func (im *InterfaceManager) GetIPv6Address() string { + im.stateMu.RLock() + defer im.stateMu.RUnlock() + + if im.state == nil { + return "" + } + + return im.state.IPv6Address +} + +// GetIPv6Addresses returns the IPv6 addresses of the interface +func (im *InterfaceManager) GetIPv6Addresses() []string { + im.stateMu.RLock() + defer im.stateMu.RUnlock() + + addresses := []string{} + + if im.state == nil { + return addresses + } + + for _, addr := range im.state.IPv6Addresses { + addresses = append(addresses, addr.Address.String()) + } + + return addresses +} + +// GetMACAddress returns the MAC address of the interface +func (im *InterfaceManager) GetMACAddress() string { + im.stateMu.RLock() + defer im.stateMu.RUnlock() + + if im.state == nil { + return "" + } + + return im.state.MACAddress +} + +// GetState returns the current interface state +func (im *InterfaceManager) GetState() *types.InterfaceState { + im.stateMu.RLock() + defer im.stateMu.RUnlock() + + // Return a copy to avoid race conditions + im.logger.Debug().Interface("state", im.state).Msg("getting interface state") + + state := *im.state + return &state +} + +// NTPServers returns the NTP servers of the interface +func (im *InterfaceManager) NTPServers() []net.IP { + im.stateMu.RLock() + defer im.stateMu.RUnlock() + + if im.state == nil { + return []net.IP{} + } + + return im.state.NTPServers +} + +func (im *InterfaceManager) Domain() string { + im.stateMu.RLock() + defer im.stateMu.RUnlock() + + if im.state == nil { + return "" + } + + if im.state.DHCPLease4 != nil { + return im.state.DHCPLease4.Domain + } + + if im.state.DHCPLease6 != nil { + return im.state.DHCPLease6.Domain + } + + return "" +} + +// GetConfig returns the current interface configuration +func (im *InterfaceManager) GetConfig() *types.NetworkConfig { + // Return a copy to avoid race conditions + config := *im.config + return &config +} + +// ApplyConfiguration applies the current configuration to the interface +func (im *InterfaceManager) ApplyConfiguration() error { + return im.applyConfiguration() +} + +// SetConfig updates the interface configuration +func (im *InterfaceManager) SetConfig(config *types.NetworkConfig) error { + if config == nil { + return fmt.Errorf("config cannot be nil") + } + + // Validate and set defaults + if err := confparser.SetDefaultsAndValidate(config); err != nil { + return fmt.Errorf("invalid config: %w", err) + } + + im.config = config + + // Apply the new configuration + if err := im.applyConfiguration(); err != nil { + im.logger.Error().Err(err).Msg("failed to apply new configuration") + return err + } + + // Notify callback + if im.onConfigChange != nil { + im.onConfigChange(config) + } + + im.logger.Info().Msg("configuration updated") + return nil +} + +// RenewDHCPLease renews the DHCP lease +func (im *InterfaceManager) RenewDHCPLease() error { + if im.dhcpClient == nil { + return fmt.Errorf("DHCP client not available") + } + + return im.dhcpClient.Renew() +} + +// SetOnStateChange sets the callback for state changes +func (im *InterfaceManager) SetOnStateChange(callback func(state types.InterfaceState)) { + im.onStateChange = callback +} + +// SetOnConfigChange sets the callback for configuration changes +func (im *InterfaceManager) SetOnConfigChange(callback func(config *types.NetworkConfig)) { + im.onConfigChange = callback +} + +// SetOnDHCPLeaseChange sets the callback for DHCP lease changes +func (im *InterfaceManager) SetOnDHCPLeaseChange(callback func(lease *types.DHCPLease)) { + im.onDHCPLeaseChange = callback +} + +// SetOnResolvConfChange sets the callback for resolv.conf changes +func (im *InterfaceManager) SetOnResolvConfChange(callback ResolvConfChangeCallback) { + im.onResolvConfChange = callback +} + +// applyConfiguration applies the current configuration to the interface +func (im *InterfaceManager) applyConfiguration() error { + im.logger.Info().Msg("applying configuration") + + // Apply IPv4 configuration + if err := im.applyIPv4Config(); err != nil { + return fmt.Errorf("failed to apply IPv4 config: %w", err) + } + + // Apply IPv6 configuration + if err := im.applyIPv6Config(); err != nil { + return fmt.Errorf("failed to apply IPv6 config: %w", err) + } + + return nil +} + +// applyIPv4Config applies IPv4 configuration +func (im *InterfaceManager) applyIPv4Config() error { + mode := im.config.IPv4Mode.String + im.logger.Info().Str("mode", mode).Msg("applying IPv4 configuration") + + switch mode { + case "static": + return im.applyIPv4Static() + case "dhcp": + return im.applyIPv4DHCP() + case "disabled": + return im.disableIPv4() + default: + return fmt.Errorf("invalid IPv4 mode: %s", mode) + } +} + +// applyIPv6Config applies IPv6 configuration +func (im *InterfaceManager) applyIPv6Config() error { + mode := im.config.IPv6Mode.String + im.logger.Info().Str("mode", mode).Msg("applying IPv6 configuration") + + switch mode { + case "static": + return im.applyIPv6Static() + case "dhcpv6": + return im.applyIPv6DHCP() + case "slaac": + return im.applyIPv6SLAAC() + case "slaac_and_dhcpv6": + return im.applyIPv6SLAACAndDHCP() + case "link_local": + return im.applyIPv6LinkLocal() + case "disabled": + return im.disableIPv6() + default: + return fmt.Errorf("invalid IPv6 mode: %s", mode) + } +} + +// applyIPv4Static applies static IPv4 configuration +func (im *InterfaceManager) applyIPv4Static() error { + if im.config.IPv4Static == nil { + return fmt.Errorf("IPv4 static configuration is nil") + } + + im.logger.Info().Msg("stopping DHCP") + + // Disable DHCP + if im.dhcpClient != nil { + im.dhcpClient.SetIPv4(false) + } + + im.logger.Info().Interface("config", im.config.IPv4Static).Msg("applying IPv4 static configuration") + + config, err := im.staticConfig.ToIPv4Static(im.config.IPv4Static) + if err != nil { + return fmt.Errorf("failed to convert IPv4 static configuration: %w", err) + } + + im.logger.Info().Interface("config", config).Msg("converted IPv4 static configuration") + + if err := im.onResolvConfChange(link.AfInet, &types.InterfaceResolvConf{ + NameServers: config.Nameservers, + Source: "static", + }); err != nil { + im.logger.Warn().Err(err).Msg("failed to update resolv.conf") + } + + return im.ReconcileLinkAddrs(config.Addresses, link.AfInet) +} + +// applyIPv4DHCP applies DHCP IPv4 configuration +func (im *InterfaceManager) applyIPv4DHCP() error { + if im.dhcpClient == nil { + return fmt.Errorf("DHCP client not available") + } + + // Enable DHCP + im.dhcpClient.SetIPv4(true) + return im.dhcpClient.Start() +} + +// disableIPv4 disables IPv4 +func (im *InterfaceManager) disableIPv4() error { + // Disable DHCP + if im.dhcpClient != nil { + im.dhcpClient.SetIPv4(false) + } + + // Remove all IPv4 addresses + return im.staticConfig.DisableIPv4() +} + +// applyIPv6Static applies static IPv6 configuration +func (im *InterfaceManager) applyIPv6Static() error { + if im.config.IPv6Static == nil { + return fmt.Errorf("IPv6 static configuration is nil") + } + + im.logger.Info().Msg("stopping DHCPv6") + // Disable DHCPv6 + if im.dhcpClient != nil { + im.dhcpClient.SetIPv6(false) + } + + // Apply static configuration + config, err := im.staticConfig.ToIPv6Static(im.config.IPv6Static) + if err != nil { + return fmt.Errorf("failed to convert IPv6 static configuration: %w", err) + } + im.logger.Info().Interface("config", config).Msg("converted IPv6 static configuration") + + if err := im.onResolvConfChange(link.AfInet6, &types.InterfaceResolvConf{ + NameServers: config.Nameservers, + Source: "static", + }); err != nil { + im.logger.Warn().Err(err).Msg("failed to update resolv.conf") + } + + return im.ReconcileLinkAddrs(config.Addresses, link.AfInet6) +} + +// applyIPv6DHCP applies DHCPv6 configuration +func (im *InterfaceManager) applyIPv6DHCP() error { + if im.dhcpClient == nil { + return fmt.Errorf("DHCP client not available") + } + + // Enable DHCPv6 + im.dhcpClient.SetIPv6(true) + return im.dhcpClient.Start() +} + +// applyIPv6SLAAC applies SLAAC configuration +func (im *InterfaceManager) applyIPv6SLAAC() error { + // Disable DHCPv6 + if im.dhcpClient != nil { + im.dhcpClient.SetIPv6(false) + } + + // Remove static IPv6 configuration + l, err := im.link() + if err != nil { + return fmt.Errorf("failed to get interface: %w", err) + } + + netlinkMgr := getNetlinkManager() + + // Ensure interface is up + if err := netlinkMgr.EnsureInterfaceUp(l); err != nil { + return fmt.Errorf("failed to bring interface up: %w", err) + } + + if err := netlinkMgr.RemoveNonLinkLocalIPv6Addresses(l); err != nil { + return fmt.Errorf("failed to remove non-link-local IPv6 addresses: %w", err) + } + + if err := im.SendRouterSolicitation(); err != nil { + im.logger.Error().Err(err).Msg("failed to send router solicitation, continuing anyway") + } + + // Enable SLAAC + return im.staticConfig.EnableIPv6SLAAC() +} + +// applyIPv6SLAACAndDHCP applies SLAAC + DHCPv6 configuration +func (im *InterfaceManager) applyIPv6SLAACAndDHCP() error { + // Enable both SLAAC and DHCPv6 + if im.dhcpClient != nil { + im.dhcpClient.SetIPv6(true) + if err := im.dhcpClient.Start(); err != nil { + return fmt.Errorf("failed to start DHCP client: %w", err) + } + } + + return im.staticConfig.EnableIPv6SLAAC() +} + +// applyIPv6LinkLocal applies link-local only IPv6 configuration +func (im *InterfaceManager) applyIPv6LinkLocal() error { + // Disable DHCPv6 + if im.dhcpClient != nil { + im.dhcpClient.SetIPv6(false) + } + + // Enable link-local only + return im.staticConfig.EnableIPv6LinkLocal() +} + +// disableIPv6 disables IPv6 +func (im *InterfaceManager) disableIPv6() error { + // Disable DHCPv6 + if im.dhcpClient != nil { + im.dhcpClient.SetIPv6(false) + } + + // Disable IPv6 + return im.staticConfig.DisableIPv6() +} + +func (im *InterfaceManager) handleLinkStateChange(link *link.Link) { + { + im.stateMu.Lock() + defer im.stateMu.Unlock() + + if link.IsSame(im.linkState) { + return + } + + im.linkState = link + } + + im.logger.Info().Interface("link", link).Msg("link state changed") + + operState := link.Attrs().OperState + if operState == netlink.OperUp { + im.handleLinkUp() + } else { + im.handleLinkDown() + } +} + +// SendRouterSolicitation sends a router solicitation +func (im *InterfaceManager) SendRouterSolicitation() error { + im.logger.Info().Msg("sending router solicitation") + m := &ndp.RouterSolicitation{} + + l, err := im.link() + if err != nil { + return fmt.Errorf("failed to get interface: %w", err) + } + + if l.Attrs().OperState != netlink.OperUp { + return fmt.Errorf("interface %s is not up", im.ifaceName) + } + + iface := l.Interface() + if iface == nil { + return fmt.Errorf("failed to get net.Interface for %s", im.ifaceName) + } + + hwAddr := l.HardwareAddr() + if hwAddr == nil { + return fmt.Errorf("failed to get hardware address for %s", im.ifaceName) + } + + c, _, err := ndp.Listen(iface, ndp.LinkLocal) + if err != nil { + return fmt.Errorf("failed to create NDP listener on %s: %w", im.ifaceName, err) + } + + m.Options = append(m.Options, &ndp.LinkLayerAddress{ + Addr: hwAddr, + Direction: ndp.Source, + }) + + targetAddr := netip.MustParseAddr("ff02::2") + + if err := c.WriteTo(m, nil, targetAddr); err != nil { + c.Close() + return fmt.Errorf("failed to write to %s: %w", targetAddr.String(), err) + } + + im.logger.Info().Msg("router solicitation sent") + c.Close() + + return nil +} + +func (im *InterfaceManager) handleLinkUp() { + im.logger.Info().Msg("link up") + + if err := im.applyConfiguration(); err != nil { + im.logger.Error().Err(err).Msg("failed to apply configuration") + } + + if im.config.IPv4Mode.String == "dhcp" { + if err := im.dhcpClient.Renew(); err != nil { + im.logger.Error().Err(err).Msg("failed to renew DHCP lease") + } + } + + if im.config.IPv6Mode.String == "slaac" { + if err := im.staticConfig.EnableIPv6SLAAC(); err != nil { + im.logger.Error().Err(err).Msg("failed to enable IPv6 SLAAC") + } + if err := im.SendRouterSolicitation(); err != nil { + im.logger.Error().Err(err).Msg("failed to send router solicitation") + } + } +} + +func (im *InterfaceManager) handleLinkDown() { + im.logger.Info().Msg("link down") + + if im.config.IPv4Mode.String == "dhcp" { + if err := im.dhcpClient.Stop(); err != nil { + im.logger.Error().Err(err).Msg("failed to stop DHCP client") + } + } + + netlinkMgr := getNetlinkManager() + if err := netlinkMgr.RemoveAllAddresses(im.linkState, link.AfInet); err != nil { + im.logger.Error().Err(err).Msg("failed to remove all IPv4 addresses") + } + + if err := netlinkMgr.RemoveNonLinkLocalIPv6Addresses(im.linkState); err != nil { + im.logger.Error().Err(err).Msg("failed to remove non-link-local IPv6 addresses") + } +} + +// monitorInterfaceState monitors the interface state and updates accordingly +func (im *InterfaceManager) monitorInterfaceState() { + defer im.wg.Done() + + im.logger.Debug().Msg("monitoring interface state") + // TODO: use netlink subscription instead of polling + ticker := time.NewTicker(5 * time.Second) + defer ticker.Stop() + + for { + select { + case <-im.ctx.Done(): + return + case <-im.stopCh: + return + case <-ticker.C: + if err := im.updateInterfaceState(); err != nil { + im.logger.Error().Err(err).Msg("failed to update interface state") + } + } + } +} + +// updateStateFromDHCPLease updates the state from a DHCP lease +func (im *InterfaceManager) updateStateFromDHCPLease(lease *types.DHCPLease) { + family := link.AfInet + + im.stateMu.Lock() + if lease.IsIPv6() { + im.state.DHCPLease6 = lease + family = link.AfInet6 + } else { + im.state.DHCPLease4 = lease + } + im.stateMu.Unlock() + + // Update resolv.conf with DNS information + if im.onResolvConfChange == nil { + return + } + + if im.ifaceName == "" { + im.logger.Warn().Msg("interface name is empty, skipping resolv.conf update") + return + } + + if err := im.onResolvConfChange(family, &types.InterfaceResolvConf{ + NameServers: lease.DNS, + SearchList: lease.SearchList, + Source: "dhcp", + }); err != nil { + im.logger.Warn().Err(err).Msg("failed to update resolv.conf") + } +} + +// ReconcileLinkAddrs reconciles the link addresses +func (im *InterfaceManager) ReconcileLinkAddrs(addrs []types.IPAddress, family int) error { + nl := getNetlinkManager() + link, err := im.link() + if err != nil { + return fmt.Errorf("failed to get interface: %w", err) + } + if link == nil { + return fmt.Errorf("failed to get interface: %w", err) + } + return nl.ReconcileLink(link, addrs, family) +} + +// applyDHCPLease applies DHCP lease configuration using ReconcileLinkAddrs +func (im *InterfaceManager) applyDHCPLease(lease *types.DHCPLease) error { + if lease == nil { + return fmt.Errorf("DHCP lease is nil") + } + + if lease.DHCPClient != "jetdhcpc" { + im.logger.Warn().Str("dhcp_client", lease.DHCPClient).Msg("ignoring DHCP lease, not implemented yet") + return nil + } + + if lease.IsIPv6() { + im.logger.Warn().Msg("ignoring IPv6 DHCP lease, not implemented yet") + return nil + } + + // Convert DHCP lease to IPv4Config + ipv4Config := im.convertDHCPLeaseToIPv4Config(lease) + + // Apply the configuration using ReconcileLinkAddrs + return im.ReconcileLinkAddrs([]types.IPAddress{*ipv4Config}, link.AfInet) +} + +// convertDHCPLeaseToIPv4Config converts a DHCP lease to IPv4Config +func (im *InterfaceManager) convertDHCPLeaseToIPv4Config(lease *types.DHCPLease) *types.IPAddress { + ipNet := lease.IPNet() + if ipNet == nil { + return nil + } + + // Create IPv4Address + ipv4Addr := types.IPAddress{ + Address: *ipNet, + Gateway: lease.Routers[0], + Secondary: false, + Permanent: false, + } + + im.logger.Trace(). + Interface("ipv4Addr", ipv4Addr). + Interface("lease", lease). + Msg("converted DHCP lease to IPv4Config") + + // Create IPv4Config + return &ipv4Addr +} diff --git a/pkg/nmlite/interface_state.go b/pkg/nmlite/interface_state.go new file mode 100644 index 00000000..087cf010 --- /dev/null +++ b/pkg/nmlite/interface_state.go @@ -0,0 +1,163 @@ +package nmlite + +import ( + "fmt" + "time" + + "github.com/jetkvm/kvm/internal/network/types" + "github.com/jetkvm/kvm/pkg/nmlite/link" + "github.com/vishvananda/netlink" +) + +// updateInterfaceState updates the current interface state +func (im *InterfaceManager) updateInterfaceState() error { + nl, err := im.link() + if err != nil { + return fmt.Errorf("failed to get interface: %w", err) + } + + var stateChanged bool + + attrs := nl.Attrs() + + // We should release the lock before calling the callbacks + // to avoid deadlocks + im.stateMu.Lock() + + // Check if the interface is up + isUp := attrs.OperState == netlink.OperUp + if im.state.Up != isUp { + im.state.Up = isUp + stateChanged = true + } + + // Check if the interface is online + isOnline := isUp && nl.HasGlobalUnicastAddress() + if im.state.Online != isOnline { + im.state.Online = isOnline + stateChanged = true + } + + // Check if the MAC address has changed + if im.state.MACAddress != attrs.HardwareAddr.String() { + im.state.MACAddress = attrs.HardwareAddr.String() + stateChanged = true + } + + // Update IP addresses + if ipChanged, err := im.updateInterfaceStateAddresses(nl); err != nil { + im.logger.Error().Err(err).Msg("failed to update IP addresses") + } else if ipChanged { + stateChanged = true + } + + im.state.LastUpdated = time.Now() + im.stateMu.Unlock() + + // Notify callback if state changed + if stateChanged && im.onStateChange != nil { + im.logger.Debug().Interface("state", im.state).Msg("notifying state change") + im.onStateChange(*im.state) + } + + return nil +} + +// updateIPAddresses updates the IP addresses in the state +func (im *InterfaceManager) updateInterfaceStateAddresses(nl *link.Link) (bool, error) { + mgr := getNetlinkManager() + + addrs, err := nl.AddrList(link.AfUnspec) + if err != nil { + return false, fmt.Errorf("failed to get addresses: %w", err) + } + + var ( + ipv4Addresses []string + ipv6Addresses []types.IPv6Address + ipv4Addr, ipv6Addr string + ipv6LinkLocal string + ipv6Gateway string + ipv4Ready, ipv6Ready = false, false + stateChanged = false + ) + + routes, _ := mgr.ListDefaultRoutes(link.AfInet6) + if len(routes) > 0 { + ipv6Gateway = routes[0].Gw.String() + } + + for _, addr := range addrs { + if addr.IP.To4() != nil { + // IPv4 address + ipv4Addresses = append(ipv4Addresses, addr.IPNet.String()) + if ipv4Addr == "" { + ipv4Addr = addr.IP.String() + ipv4Ready = true + } + continue + } + + // IPv6 address (if it's not an IPv4 address, it must be an IPv6 address) + if addr.IP.IsLinkLocalUnicast() { + ipv6LinkLocal = addr.IP.String() + continue + } else if !addr.IP.IsGlobalUnicast() { + continue + } + + ipv6Addresses = append(ipv6Addresses, types.IPv6Address{ + Address: addr.IP, + Prefix: *addr.IPNet, + Scope: addr.Scope, + Flags: addr.Flags, + ValidLifetime: lifetimeToTime(addr.ValidLft), + PreferredLifetime: lifetimeToTime(addr.PreferedLft), + }) + if ipv6Addr == "" { + ipv6Addr = addr.IP.String() + ipv6Ready = true + } + } + + if !sortAndCompareStringSlices(im.state.IPv4Addresses, ipv4Addresses) { + im.state.IPv4Addresses = ipv4Addresses + stateChanged = true + } + + if !sortAndCompareIPv6AddressSlices(im.state.IPv6Addresses, ipv6Addresses) { + im.state.IPv6Addresses = ipv6Addresses + stateChanged = true + } + + if im.state.IPv4Address != ipv4Addr { + im.state.IPv4Address = ipv4Addr + stateChanged = true + } + + if im.state.IPv6Address != ipv6Addr { + im.state.IPv6Address = ipv6Addr + stateChanged = true + } + if im.state.IPv6LinkLocal != ipv6LinkLocal { + im.state.IPv6LinkLocal = ipv6LinkLocal + stateChanged = true + } + + if im.state.IPv6Gateway != ipv6Gateway { + im.state.IPv6Gateway = ipv6Gateway + stateChanged = true + } + + if im.state.IPv4Ready != ipv4Ready { + im.state.IPv4Ready = ipv4Ready + stateChanged = true + } + + if im.state.IPv6Ready != ipv6Ready { + im.state.IPv6Ready = ipv6Ready + stateChanged = true + } + + return stateChanged, nil +} diff --git a/pkg/nmlite/jetdhcpc/client.go b/pkg/nmlite/jetdhcpc/client.go new file mode 100644 index 00000000..155ea249 --- /dev/null +++ b/pkg/nmlite/jetdhcpc/client.go @@ -0,0 +1,407 @@ +package jetdhcpc + +import ( + "context" + "errors" + "net" + "slices" + + "time" + + "github.com/jetkvm/kvm/internal/sync" + "github.com/jetkvm/kvm/pkg/nmlite/link" + + "github.com/insomniacslk/dhcp/dhcpv4" + "github.com/insomniacslk/dhcp/dhcpv6" + "github.com/jetkvm/kvm/internal/network/types" + "github.com/rs/zerolog" +) + +const ( + VendorIdentifier = "jetkvm" +) + +var ( + ErrIPv6LinkTimeout = errors.New("timeout after waiting for a non-tentative IPv6 address") + ErrIPv6RouteTimeout = errors.New("timeout after waiting for an IPv6 route") + ErrInterfaceUpTimeout = errors.New("timeout after waiting for an interface to come up") + ErrInterfaceUpCanceled = errors.New("context canceled while waiting for an interface to come up") +) + +type LeaseChangeHandler func(lease *types.DHCPLease) + +// Config is a DHCP client configuration. +type Config struct { + LinkUpTimeout time.Duration + + // Timeout is the timeout for one DHCP request attempt. + Timeout time.Duration + + // Retries is how many times to retry DHCP attempts. + Retries int + + // IPv4 is whether to request an IPv4 lease. + IPv4 bool + + // IPv6 is whether to request an IPv6 lease. + IPv6 bool + + // Modifiers4 allows modifications to the IPv4 DHCP request. + Modifiers4 []dhcpv4.Modifier + + // Modifiers6 allows modifications to the IPv6 DHCP request. + Modifiers6 []dhcpv6.Modifier + + // V6ServerAddr can be a unicast or broadcast destination for DHCPv6 + // messages. + // + // If not set, it will default to nclient6's default (all servers & + // relay agents). + V6ServerAddr *net.UDPAddr + + // V6ClientPort is the port that is used to send and receive DHCPv6 + // messages. + // + // If not set, it will default to dhcpv6's default (546). + V6ClientPort *int + + // V4ServerAddr can be a unicast or broadcast destination for IPv4 DHCP + // messages. + // + // If not set, it will default to nclient4's default (DHCP broadcast + // address). + V4ServerAddr *net.UDPAddr + + // If true, add Client Identifier (61) option to the IPv4 request. + V4ClientIdentifier bool + + Hostname string + + OnLease4Change LeaseChangeHandler + OnLease6Change LeaseChangeHandler + + UpdateResolvConf func([]string) error +} + +// Client is a DHCP client. +type Client struct { + types.DHCPClient + + ifaces []string + cfg Config + l *zerolog.Logger + + ctx context.Context + + // TODO: support multiple interfaces + currentLease4 *Lease + currentLease6 *Lease + + mu sync.Mutex + cfgMu sync.Mutex + + lease4Mu sync.Mutex + lease6Mu sync.Mutex + + timer4 *time.Timer + timer6 *time.Timer + stateDir string +} + +var ( + defaultTimerDuration = 1 * time.Second + defaultLinkUpTimeout = 30 * time.Second + maxRenewalAttemptDuration = 2 * time.Hour +) + +// NewClient creates a new DHCP client for the given interface. +func NewClient(ctx context.Context, ifaces []string, c *Config, l *zerolog.Logger) (*Client, error) { + timer4 := time.NewTimer(defaultTimerDuration) + timer6 := time.NewTimer(defaultTimerDuration) + + cfg := *c + if cfg.LinkUpTimeout == 0 { + cfg.LinkUpTimeout = defaultLinkUpTimeout + } + + if cfg.Timeout == 0 { + cfg.Timeout = defaultLinkUpTimeout + } + + if cfg.Retries == 0 { + cfg.Retries = 3 + } + + return &Client{ + ctx: ctx, + ifaces: ifaces, + cfg: cfg, + l: l, + stateDir: "/run/jetkvm-dhcp", + + currentLease4: nil, + currentLease6: nil, + + lease4Mu: sync.Mutex{}, + lease6Mu: sync.Mutex{}, + + mu: sync.Mutex{}, + cfgMu: sync.Mutex{}, + + timer4: timer4, + timer6: timer6, + }, nil +} + +func resetTimer(t *time.Timer, l *zerolog.Logger) { + l.Debug().Dur("delay", defaultTimerDuration).Msg("will retry later") + t.Reset(defaultTimerDuration) +} + +func getRenewalTime(lease *Lease) time.Duration { + if lease.RenewalTime <= 0 || lease.LeaseTime > maxRenewalAttemptDuration/2 { + return maxRenewalAttemptDuration + } + + return lease.RenewalTime +} + +func (c *Client) requestLoop(t *time.Timer, family int, ifname string) { + l := c.l.With().Str("interface", ifname).Int("family", family).Logger() + for range t.C { + l.Info().Msg("requesting lease") + + if _, err := c.ensureInterfaceUp(ifname); err != nil { + l.Error().Err(err).Msg("failed to ensure interface up") + resetTimer(t, c.l) + continue + } + + var ( + lease *Lease + err error + ) + switch family { + case link.AfInet: + lease, err = c.requestLease4(ifname) + case link.AfInet6: + lease, err = c.requestLease6(ifname) + } + if err != nil { + l.Error().Err(err).Msg("failed to request lease") + resetTimer(t, c.l) + continue + } + + c.handleLeaseChange(lease) + + nextRenewal := getRenewalTime(lease) + + l.Info(). + Dur("nextRenewal", nextRenewal). + Dur("leaseTime", lease.LeaseTime). + Dur("rebindingTime", lease.RebindingTime). + Msg("sleeping until next renewal") + + t.Reset(nextRenewal) + } +} + +func (c *Client) ensureInterfaceUp(ifname string) (*link.Link, error) { + nlm := link.GetNetlinkManager() + iface, err := nlm.GetLinkByName(ifname) + if err != nil { + return nil, err + } + return nlm.EnsureInterfaceUpWithTimeout(c.ctx, iface, c.cfg.LinkUpTimeout) +} + +// Lease4 returns the current IPv4 lease +func (c *Client) Lease4() *types.DHCPLease { + c.lease4Mu.Lock() + defer c.lease4Mu.Unlock() + + if c.currentLease4 == nil { + return nil + } + + return c.currentLease4.ToDHCPLease() +} + +// Lease6 returns the current IPv6 lease +func (c *Client) Lease6() *types.DHCPLease { + c.lease6Mu.Lock() + defer c.lease6Mu.Unlock() + + if c.currentLease6 == nil { + return nil + } + + return c.currentLease6.ToDHCPLease() +} + +// Domain returns the current domain +func (c *Client) Domain() string { + c.lease4Mu.Lock() + defer c.lease4Mu.Unlock() + + if c.currentLease4 != nil { + return c.currentLease4.Domain + } + + c.lease6Mu.Lock() + defer c.lease6Mu.Unlock() + + if c.currentLease6 != nil { + return c.currentLease6.Domain + } + + return "" +} + +// handleLeaseChange handles lease changes +func (c *Client) handleLeaseChange(lease *Lease) { + // do not use defer here, because we need to unlock the mutex before returning + ipv4 := lease.p4 != nil + + if ipv4 { + c.lease4Mu.Lock() + c.currentLease4 = lease + c.lease4Mu.Unlock() + } else { + c.lease6Mu.Lock() + c.currentLease6 = lease + c.lease6Mu.Unlock() + } + + c.apply() + + // TODO: handle lease expiration + if c.cfg.OnLease4Change != nil && ipv4 { + c.cfg.OnLease4Change(lease.ToDHCPLease()) + } + + if c.cfg.OnLease6Change != nil && !ipv4 { + c.cfg.OnLease6Change(lease.ToDHCPLease()) + } +} + +func (c *Client) Renew() error { + c.timer4.Reset(defaultTimerDuration) + c.timer6.Reset(defaultTimerDuration) + return nil +} + +func (c *Client) Release() error { + // TODO: implement + return nil +} + +func (c *Client) SetIPv4(ipv4 bool) { + c.cfgMu.Lock() + defer c.cfgMu.Unlock() + + currentIPv4 := c.cfg.IPv4 + c.cfg.IPv4 = ipv4 + + if currentIPv4 == ipv4 { + return + } + + if !ipv4 { + c.lease4Mu.Lock() + c.currentLease4 = nil + c.lease4Mu.Unlock() + + c.timer4.Stop() + } + + c.timer4.Reset(defaultTimerDuration) +} + +func (c *Client) SetIPv6(ipv6 bool) { + c.cfgMu.Lock() + defer c.cfgMu.Unlock() + + currentIPv6 := c.cfg.IPv6 + c.cfg.IPv6 = ipv6 + + if currentIPv6 == ipv6 { + return + } + + if !ipv6 { + c.lease6Mu.Lock() + c.currentLease6 = nil + c.lease6Mu.Unlock() + + c.timer6.Stop() + } + + c.timer6.Reset(defaultTimerDuration) +} + +func (c *Client) Start() error { + if err := c.killUdhcpc(); err != nil { + c.l.Warn().Err(err).Msg("failed to kill udhcpc processes, continuing anyway") + } + + for _, iface := range c.ifaces { + if c.cfg.IPv4 { + go c.requestLoop(c.timer4, link.AfInet, iface) + } + if c.cfg.IPv6 { + go c.requestLoop(c.timer6, link.AfInet6, iface) + } + } + + return nil +} + +func (c *Client) apply() { + var ( + iface string + nameservers []net.IP + searchList []string + domain string + ) + + if c.currentLease4 != nil { + iface = c.currentLease4.InterfaceName + nameservers = c.currentLease4.DNS + searchList = c.currentLease4.SearchList + domain = c.currentLease4.Domain + } + + if c.currentLease6 != nil { + iface = c.currentLease6.InterfaceName + nameservers = append(nameservers, c.currentLease6.DNS...) + searchList = append(searchList, c.currentLease6.SearchList...) + domain = c.currentLease6.Domain + } + + // deduplicate searchList + searchList = slices.Compact(searchList) + + if c.cfg.UpdateResolvConf == nil { + c.l.Warn().Msg("no UpdateResolvConf function set, skipping resolv.conf update") + return + } + + c.l.Info(). + Str("interface", iface). + Interface("nameservers", nameservers). + Interface("searchList", searchList). + Str("domain", domain). + Msg("updating resolv.conf") + + // Convert net.IP to string slice + var nameserverStrings []string + for _, ns := range nameservers { + nameserverStrings = append(nameserverStrings, ns.String()) + } + + if err := c.cfg.UpdateResolvConf(nameserverStrings); err != nil { + c.l.Error().Err(err).Msg("failed to update resolv.conf") + } +} diff --git a/pkg/nmlite/jetdhcpc/dhcp4.go b/pkg/nmlite/jetdhcpc/dhcp4.go new file mode 100644 index 00000000..5de74245 --- /dev/null +++ b/pkg/nmlite/jetdhcpc/dhcp4.go @@ -0,0 +1,85 @@ +package jetdhcpc + +import ( + "fmt" + + "github.com/insomniacslk/dhcp/dhcpv4" + "github.com/insomniacslk/dhcp/dhcpv4/nclient4" + "github.com/vishvananda/netlink" +) + +func (c *Client) requestLease4(ifname string) (*Lease, error) { + iface, err := netlink.LinkByName(ifname) + if err != nil { + return nil, err + } + + l := c.l.With().Str("interface", ifname).Logger() + + mods := []nclient4.ClientOpt{ + nclient4.WithTimeout(c.cfg.Timeout), + nclient4.WithRetry(c.cfg.Retries), + } + mods = append(mods, c.getDHCP4Logger(ifname)) + if c.cfg.V4ServerAddr != nil { + mods = append(mods, nclient4.WithServerAddr(c.cfg.V4ServerAddr)) + } + + client, err := nclient4.New(ifname, mods...) + if err != nil { + return nil, err + } + defer client.Close() + + // Prepend modifiers with default options, so they can be overridden. + reqmods := append( + []dhcpv4.Modifier{ + dhcpv4.WithOption(dhcpv4.OptClassIdentifier(VendorIdentifier)), + dhcpv4.WithRequestedOptions( + dhcpv4.OptionSubnetMask, + dhcpv4.OptionInterfaceMTU, + dhcpv4.OptionNTPServers, + dhcpv4.OptionDomainName, + dhcpv4.OptionDomainNameServer, + dhcpv4.OptionDNSDomainSearchList, + ), + }, + c.cfg.Modifiers4...) + + if c.cfg.V4ClientIdentifier { + // Client Id is hardware type + mac per RFC 2132 9.14. + ident := []byte{0x01} // Type ethernet + ident = append(ident, iface.Attrs().HardwareAddr...) + 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 + reqErr error + ) + if c.currentLease4 != nil { + l.Info().Msg("current lease is not nil, renewing") + lease, reqErr = client.Renew(c.ctx, c.currentLease4.p4, reqmods...) + } else { + l.Info().Msg("current lease is nil, requesting new lease") + lease, reqErr = client.Request(c.ctx, reqmods...) + } + + if reqErr != nil { + return nil, reqErr + } + + if lease == nil || lease.ACK == nil { + return nil, fmt.Errorf("failed to acquire DHCPv4 lease") + } + + 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/dhcp6.go b/pkg/nmlite/jetdhcpc/dhcp6.go new file mode 100644 index 00000000..49faf6f0 --- /dev/null +++ b/pkg/nmlite/jetdhcpc/dhcp6.go @@ -0,0 +1,135 @@ +package jetdhcpc + +import ( + "net" + "time" + + "github.com/insomniacslk/dhcp/dhcpv6" + "github.com/insomniacslk/dhcp/dhcpv6/nclient6" + "github.com/rs/zerolog" + "github.com/vishvananda/netlink" +) + +// isIPv6LinkReady returns true if the interface has a link-local address +// which is not tentative. +func isIPv6LinkReady(l netlink.Link, logger *zerolog.Logger) (bool, error) { + addrs, err := netlink.AddrList(l, 10) // AF_INET6 + if err != nil { + return false, err + } + for _, addr := range addrs { + if addr.IP.IsLinkLocalUnicast() && (addr.Flags&0x40 == 0) { // IFA_F_TENTATIVE + if addr.Flags&0x80 != 0 { // IFA_F_DADFAILED + logger.Warn().Str("address", addr.IP.String()).Msg("DADFAILED for address, continuing anyhow") + } + return true, nil + } + } + return false, nil +} + +// isIPv6RouteReady returns true if serverAddr is reachable. +func isIPv6RouteReady(serverAddr net.IP) waitForCondition { + return func(l netlink.Link, logger *zerolog.Logger) (bool, error) { + if serverAddr.IsMulticast() { + return true, nil + } + + routes, err := netlink.RouteList(l, 10) // AF_INET6 + if err != nil { + return false, err + } + for _, route := range routes { + if route.LinkIndex != l.Attrs().Index { + continue + } + // Default route. + if route.Dst == nil { + return true, nil + } + if route.Dst.Contains(serverAddr) { + return true, nil + } + } + return false, nil + } +} + +func (c *Client) requestLease6(ifname string) (*Lease, error) { + l := c.l.With().Str("interface", ifname).Logger() + + iface, err := netlink.LinkByName(ifname) + if err != nil { + return nil, err + } + + clientPort := dhcpv6.DefaultClientPort + if c.cfg.V6ClientPort != nil { + clientPort = *c.cfg.V6ClientPort + } + + // For ipv6, we cannot bind to the port until Duplicate Address + // Detection (DAD) is complete which is indicated by the link being no + // longer marked as "tentative". This usually takes about a second. + + // If the link is never going to be ready, don't wait forever. + // (The user may not have configured a ctx with a timeout.) + + linkUpTimeout := time.After(c.cfg.LinkUpTimeout) + if err := c.waitFor( + iface, + linkUpTimeout, + isIPv6LinkReady, + ErrIPv6LinkTimeout, + ); err != nil { + return nil, err + } + + // If user specified a non-multicast address, make sure it's routable before we start. + if c.cfg.V6ServerAddr != nil { + if err := c.waitFor( + iface, + linkUpTimeout, + isIPv6RouteReady(c.cfg.V6ServerAddr.IP), + ErrIPv6RouteTimeout, + ); err != nil { + return nil, err + } + } + + mods := []nclient6.ClientOpt{ + nclient6.WithTimeout(c.cfg.Timeout), + nclient6.WithRetry(c.cfg.Retries), + c.getDHCP6Logger(), + } + if c.cfg.V6ServerAddr != nil { + mods = append(mods, nclient6.WithBroadcastAddr(c.cfg.V6ServerAddr)) + } + + conn, err := nclient6.NewIPv6UDPConn(iface.Attrs().Name, clientPort) + if err != nil { + return nil, err + } + + client, err := nclient6.NewWithConn(conn, iface.Attrs().HardwareAddr, mods...) + if err != nil { + return nil, err + } + defer client.Close() + + // Prepend modifiers with default options, so they can be overridden. + reqmods := append( + []dhcpv6.Modifier{ + dhcpv6.WithNetboot, + }, + c.cfg.Modifiers6...) + + l.Info().Msg("attempting to get DHCPv6 lease") + p, err := client.RapidSolicit(c.ctx, reqmods...) + if err != nil { + return nil, err + } + + l.Info().Msgf("DHCPv6 lease acquired: %s", p.Summary()) + return fromNclient6Lease(p, ifname), nil +} diff --git a/pkg/nmlite/jetdhcpc/lease.go b/pkg/nmlite/jetdhcpc/lease.go new file mode 100644 index 00000000..ea09e40f --- /dev/null +++ b/pkg/nmlite/jetdhcpc/lease.go @@ -0,0 +1,313 @@ +package jetdhcpc + +import ( + "bufio" + "encoding/binary" + "encoding/json" + "fmt" + "net" + "os" + "reflect" + "strconv" + "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" +) + +var ( + defaultLeaseTime = time.Duration(30 * time.Minute) + defaultRenewalTime = time.Duration(15 * time.Minute) +) + +// Lease is a network configuration obtained by DHCP. +type Lease struct { + types.DHCPLease + + p4 *nclient4.Lease + p6 *dhcpv6.Message + + isEmpty map[string]bool +} + +// ToDHCPLease converts a lease to a DHCP lease. +func (l *Lease) ToDHCPLease() *types.DHCPLease { + lease := &l.DHCPLease + lease.DHCPClient = "jetdhcpc" + return lease +} + +// fromNclient4Lease creates a lease from a nclient4.Lease. +func fromNclient4Lease(l *nclient4.Lease, iface string) *Lease { + lease := &Lease{} + + lease.p4 = l + + // only the fields that we need are set + lease.Routers = l.ACK.Router() + lease.IPAddress = l.ACK.YourIPAddr + + lease.Netmask = net.IP(l.ACK.SubnetMask()) + lease.Broadcast = l.ACK.BroadcastAddress() + + lease.NTPServers = l.ACK.NTPServers() + + lease.HostName = l.ACK.HostName() + lease.Domain = l.ACK.DomainName() + + searchList := l.ACK.DomainSearch() + if searchList != nil { + lease.SearchList = searchList.Labels + } + + lease.DNS = l.ACK.DNS() + + 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) + + lease.InterfaceName = iface + + return lease +} + +// fromNclient6Lease creates a lease from a nclient6.Message. +func fromNclient6Lease(l *dhcpv6.Message, iface string) *Lease { + lease := &Lease{} + + lease.p6 = l + + iana := l.Options.OneIANA() + if iana == nil { + return nil + } + + address := iana.Options.OneAddress() + if address == nil { + return nil + } + + lease.IPAddress = address.IPv6Addr + lease.Netmask = net.IP(net.CIDRMask(128, 128)) + lease.DNS = l.Options.DNS() + // lease.LeaseTime = iana.Options.OnePreferredLifetime() + // lease.RenewalTime = iana.Options.OneValidLifetime() + // lease.RebindingTime = iana.Options.OneRebindingTime() + + lease.InterfaceName = iface + + return lease +} + +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 +} + +func (l *Lease) Apply() error { + if l.p4 != nil { + return l.applyIPv4() + } + + if l.p6 != nil { + return l.applyIPv6() + } + + return nil +} + +func (l *Lease) applyIPv4() error { + return nil +} + +func (l *Lease) applyIPv6() error { + return 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.Split(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.Fields(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 +} + +// MarshalDHCPCLease marshals a lease to a string. +func MarshalDHCPCLease(lease *Lease) (string, error) { + leaseType := reflect.TypeOf(lease).Elem() + leaseValue := reflect.ValueOf(lease).Elem() + + leaseFile := "" + + for i := 0; i < leaseType.NumField(); i++ { + field := leaseValue.Field(i) + key := leaseType.Field(i).Tag.Get("env") + if key == "" { + continue + } + + outValue := "" + + switch field.Interface().(type) { + case string: + outValue = field.String() + case int: + outValue = strconv.Itoa(int(field.Int())) + case time.Duration: + outValue = strconv.Itoa(int(field.Int())) + case net.IP: + outValue = field.String() + case []net.IP: + ips := field.Interface().([]net.IP) + ipStrings := make([]string, len(ips)) + for i, ip := range ips { + ipStrings[i] = ip.String() + } + outValue = strings.Join(ipStrings, " ") + default: + return "", fmt.Errorf("unsupported field `%s` type: %s", key, field.Type().String()) + } + + leaseFile += fmt.Sprintf("%s=%s\n", key, outValue) + } + + return leaseFile, nil +} diff --git a/pkg/nmlite/jetdhcpc/legacy.go b/pkg/nmlite/jetdhcpc/legacy.go new file mode 100644 index 00000000..6fa2ddf9 --- /dev/null +++ b/pkg/nmlite/jetdhcpc/legacy.go @@ -0,0 +1,102 @@ +package jetdhcpc + +import ( + "bytes" + "io" + "os" + "path/filepath" + "strconv" + "strings" + "syscall" + + "github.com/rs/zerolog" +) + +func readFileNoStat(filename string) ([]byte, error) { + const maxBufferSize = 1024 * 1024 + + f, err := os.Open(filename) + if err != nil { + return nil, err + } + defer f.Close() + + reader := io.LimitReader(f, maxBufferSize) + return io.ReadAll(reader) +} + +func toCmdline(path string) ([]string, error) { + data, err := readFileNoStat(path) + if err != nil { + return nil, err + } + + if len(data) < 1 { + return []string{}, nil + } + + return strings.Split(string(bytes.TrimRight(data, "\x00")), "\x00"), nil +} + +// KillUdhcpC kills all udhcpc processes +func KillUdhcpC(l *zerolog.Logger) error { + // read procfs for udhcpc processes + // we do not use procfs.AllProcs() because we want to avoid the overhead of reading the entire procfs + processes, err := os.ReadDir("/proc") + if err != nil { + return err + } + + matchedPids := make([]int, 0) + + // iterate over the processes + for _, d := range processes { + // check if file is numeric + pid, err := strconv.Atoi(d.Name()) + if err != nil { + continue + } + + // check if it's a directory + if !d.IsDir() { + continue + } + + cmdline, err := toCmdline(filepath.Join("/proc", d.Name(), "cmdline")) + if err != nil { + continue + } + + if len(cmdline) < 1 { + continue + } + + if cmdline[0] != "udhcpc" { + continue + } + + matchedPids = append(matchedPids, pid) + } + + if len(matchedPids) == 0 { + l.Info().Msg("no udhcpc processes found") + return nil + } + + l.Info().Ints("pids", matchedPids).Msg("found udhcpc processes, terminating") + + for _, pid := range matchedPids { + err := syscall.Kill(pid, syscall.SIGTERM) + if err != nil { + return err + } + + l.Info().Int("pid", pid).Msg("terminated udhcpc process") + } + + return nil +} + +func (c *Client) killUdhcpc() error { + return KillUdhcpC(c.l) +} diff --git a/pkg/nmlite/jetdhcpc/logging.go b/pkg/nmlite/jetdhcpc/logging.go new file mode 100644 index 00000000..da31c2e8 --- /dev/null +++ b/pkg/nmlite/jetdhcpc/logging.go @@ -0,0 +1,64 @@ +package jetdhcpc + +import ( + "github.com/insomniacslk/dhcp/dhcpv4" + "github.com/insomniacslk/dhcp/dhcpv4/nclient4" + "github.com/insomniacslk/dhcp/dhcpv6/nclient6" + "github.com/rs/zerolog" +) + +type dhcpLogger struct { + // Printfer is used for actual output of the logger + nclient4.Printfer + + l *zerolog.Logger +} + +// Printf prints a log message as-is via predefined Printfer +func (s dhcpLogger) Printf(format string, v ...interface{}) { + s.l.Info().Msgf(format, v...) +} + +// PrintMessage prints a DHCP message in the short format via predefined Printfer +func (s dhcpLogger) PrintMessage(prefix string, message *dhcpv4.DHCPv4) { + s.l.Info().Msgf("%s: %s", prefix, message.String()) +} + +func summaryStructured(d *dhcpv4.DHCPv4, l *zerolog.Logger) *zerolog.Logger { + logger := l.With(). + Str("opCode", d.OpCode.String()). + Str("hwType", d.HWType.String()). + Int("hopCount", int(d.HopCount)). + Str("transactionID", d.TransactionID.String()). + Int("numSeconds", int(d.NumSeconds)). + Str("flagsString", d.FlagsToString()). + Int("flags", int(d.Flags)). + Str("clientIP", d.ClientIPAddr.String()). + Str("yourIP", d.YourIPAddr.String()). + Str("serverIP", d.ServerIPAddr.String()). + Str("gatewayIP", d.GatewayIPAddr.String()). + Str("clientMAC", d.ClientHWAddr.String()). + Str("serverHostname", d.ServerHostName). + Str("bootFileName", d.BootFileName). + Str("options", d.Options.Summary(nil)). + Logger() + return &logger +} + +func (c *Client) getDHCP4Logger(ifname string) nclient4.ClientOpt { + logger := c.l.With(). + Str("interface", ifname). + Str("source", "dhcp4"). + Logger() + + return nclient4.WithLogger(dhcpLogger{ + l: &logger, + }) +} + +// TODO: nclient6 doesn't implement the WithLogger option, +// we might need to open a PR to add it + +func (c *Client) getDHCP6Logger() nclient6.ClientOpt { + return nclient6.WithSummaryLogger() +} diff --git a/pkg/nmlite/jetdhcpc/state.go b/pkg/nmlite/jetdhcpc/state.go new file mode 100644 index 00000000..312211e8 --- /dev/null +++ b/pkg/nmlite/jetdhcpc/state.go @@ -0,0 +1,247 @@ +package jetdhcpc + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "time" + + "github.com/jetkvm/kvm/internal/network/types" +) + +const ( + // DefaultStateDir is the default state directory + DefaultStateDir = "/var/run/" + // DHCPStateFile is the name of the DHCP state file + DHCPStateFile = "jetkvm_dhcp_state.json" +) + +// DHCPState represents the persistent state of DHCP clients +type DHCPState struct { + InterfaceStates map[string]*InterfaceDHCPState `json:"interface_states"` + LastUpdated time.Time `json:"last_updated"` + Version string `json:"version"` +} + +// InterfaceDHCPState represents the DHCP state for a specific interface +type InterfaceDHCPState struct { + InterfaceName string `json:"interface_name"` + IPv4Enabled bool `json:"ipv4_enabled"` + IPv6Enabled bool `json:"ipv6_enabled"` + IPv4Lease *Lease `json:"ipv4_lease,omitempty"` + IPv6Lease *Lease `json:"ipv6_lease,omitempty"` + LastRenewal time.Time `json:"last_renewal"` + Config *types.NetworkConfig `json:"config,omitempty"` +} + +// SaveState saves the current DHCP state to disk +func (c *Client) SaveState(state *DHCPState) error { + if state == nil { + return fmt.Errorf("state cannot be nil") + } + + // Return error if state directory doesn't exist + if _, err := os.Stat(c.stateDir); os.IsNotExist(err) { + return fmt.Errorf("state directory does not exist: %w", err) + } + + // Update timestamp + state.LastUpdated = time.Now() + state.Version = "1.0" + + // Serialize state + data, err := json.MarshalIndent(state, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal state: %w", err) + } + + // Write to temporary file first, then rename to ensure atomic operation + tmpFile, err := os.CreateTemp(c.stateDir, DHCPStateFile) + if err != nil { + return fmt.Errorf("failed to create temporary file: %w", err) + } + defer tmpFile.Close() + + if err := os.WriteFile(tmpFile.Name(), data, 0644); err != nil { + return fmt.Errorf("failed to write state file: %w", err) + } + + stateFile := filepath.Join(c.stateDir, DHCPStateFile) + if err := os.Rename(tmpFile.Name(), stateFile); err != nil { + os.Remove(tmpFile.Name()) + return fmt.Errorf("failed to rename state file: %w", err) + } + + c.l.Debug().Str("file", stateFile).Msg("DHCP state saved") + return nil +} + +// LoadState loads the DHCP state from disk +func (c *Client) LoadState() (*DHCPState, error) { + stateFile := filepath.Join(c.stateDir, DHCPStateFile) + + // Check if state file exists + if _, err := os.Stat(stateFile); os.IsNotExist(err) { + c.l.Debug().Msg("No existing DHCP state file found") + return &DHCPState{ + InterfaceStates: make(map[string]*InterfaceDHCPState), + LastUpdated: time.Now(), + Version: "1.0", + }, nil + } + + // Read state file + data, err := os.ReadFile(stateFile) + if err != nil { + return nil, fmt.Errorf("failed to read state file: %w", err) + } + + // Deserialize state + var state DHCPState + if err := json.Unmarshal(data, &state); err != nil { + return nil, fmt.Errorf("failed to unmarshal state: %w", err) + } + + // Initialize interface states map if nil + if state.InterfaceStates == nil { + state.InterfaceStates = make(map[string]*InterfaceDHCPState) + } + + c.l.Debug().Str("file", stateFile).Msg("DHCP state loaded") + return &state, nil +} + +// UpdateInterfaceState updates the state for a specific interface +func (c *Client) UpdateInterfaceState(ifaceName string, state *InterfaceDHCPState) error { + // Load current state + currentState, err := c.LoadState() + if err != nil { + return fmt.Errorf("failed to load current state: %w", err) + } + + // Update interface state + currentState.InterfaceStates[ifaceName] = state + + // Save updated state + return c.SaveState(currentState) +} + +// GetInterfaceState gets the state for a specific interface +func (c *Client) GetInterfaceState(ifaceName string) (*InterfaceDHCPState, error) { + state, err := c.LoadState() + if err != nil { + return nil, fmt.Errorf("failed to load state: %w", err) + } + + return state.InterfaceStates[ifaceName], nil +} + +// RemoveInterfaceState removes the state for a specific interface +func (c *Client) RemoveInterfaceState(ifaceName string) error { + // Load current state + currentState, err := c.LoadState() + if err != nil { + return fmt.Errorf("failed to load current state: %w", err) + } + + // Remove interface state + delete(currentState.InterfaceStates, ifaceName) + + // Save updated state + return c.SaveState(currentState) +} + +// IsLeaseValid checks if a DHCP lease is still valid +func (c *Client) IsLeaseValid(lease *Lease) bool { + if lease == nil { + return false + } + + // Check if lease has expired + if lease.LeaseExpiry == nil { + return false + } + + return time.Now().Before(*lease.LeaseExpiry) +} + +// ShouldRenewLease checks if a lease should be renewed +func (c *Client) ShouldRenewLease(lease *Lease) bool { + if !c.IsLeaseValid(lease) { + return false + } + + expiry := *lease.LeaseExpiry + leaseTime := time.Now().Add(time.Duration(lease.LeaseTime) * time.Second) + + // Renew if lease expires within 50% of its lifetime + leaseDuration := expiry.Sub(leaseTime) + renewalTime := leaseTime.Add(leaseDuration / 2) + + return time.Now().After(renewalTime) +} + +// CleanupExpiredStates removes expired states from the state file +func (c *Client) CleanupExpiredStates() error { + state, err := c.LoadState() + if err != nil { + return fmt.Errorf("failed to load state: %w", err) + } + + cleaned := false + for ifaceName, ifaceState := range state.InterfaceStates { + // Remove interface state if both leases are expired + ipv4Valid := c.IsLeaseValid(ifaceState.IPv4Lease) + ipv6Valid := c.IsLeaseValid(ifaceState.IPv6Lease) + + if !ipv4Valid && !ipv6Valid { + delete(state.InterfaceStates, ifaceName) + cleaned = true + c.l.Debug().Str("interface", ifaceName).Msg("Removed expired DHCP state") + } + } + + if cleaned { + return c.SaveState(state) + } + + return nil +} + +// GetStateSummary returns a summary of the current state +func (c *Client) GetStateSummary() (map[string]interface{}, error) { + state, err := c.LoadState() + if err != nil { + return nil, fmt.Errorf("failed to load state: %w", err) + } + + summary := map[string]interface{}{ + "last_updated": state.LastUpdated, + "version": state.Version, + "interface_count": len(state.InterfaceStates), + "interfaces": make(map[string]interface{}), + } + + interfaces := summary["interfaces"].(map[string]interface{}) + for ifaceName, ifaceState := range state.InterfaceStates { + interfaceInfo := map[string]interface{}{ + "ipv4_enabled": ifaceState.IPv4Enabled, + "ipv6_enabled": ifaceState.IPv6Enabled, + "last_renewal": ifaceState.LastRenewal, + // "ipv4_lease_valid": c.IsLeaseValid(ifaceState.IPv4Lease.(*Lease)), + // "ipv6_lease_valid": c.IsLeaseValid(ifaceState.IPv6Lease), + } + + if ifaceState.IPv4Lease != nil { + interfaceInfo["ipv4_lease_expiry"] = ifaceState.IPv4Lease.LeaseExpiry + } + if ifaceState.IPv6Lease != nil { + interfaceInfo["ipv6_lease_expiry"] = ifaceState.IPv6Lease.LeaseExpiry + } + + interfaces[ifaceName] = interfaceInfo + } + + return summary, nil +} diff --git a/pkg/nmlite/jetdhcpc/utils.go b/pkg/nmlite/jetdhcpc/utils.go new file mode 100644 index 00000000..684a9302 --- /dev/null +++ b/pkg/nmlite/jetdhcpc/utils.go @@ -0,0 +1,48 @@ +package jetdhcpc + +import ( + "context" + "time" + + "github.com/rs/zerolog" + "github.com/vishvananda/netlink" +) + +type waitForCondition func(l netlink.Link, logger *zerolog.Logger) (ready bool, err error) + +func (c *Client) waitFor( + link netlink.Link, + timeout <-chan time.Time, + condition waitForCondition, + timeoutError error, +) error { + return waitFor(c.ctx, link, c.l, timeout, condition, timeoutError) +} + +func waitFor( + ctx context.Context, + link netlink.Link, + logger *zerolog.Logger, + timeout <-chan time.Time, + condition waitForCondition, + timeoutError error, +) error { + for { + if ready, err := condition(link, logger); err != nil { + return err + } else if ready { + break + } + + select { + case <-time.After(100 * time.Millisecond): + continue + case <-timeout: + return timeoutError + case <-ctx.Done(): + return timeoutError + } + } + + return nil +} diff --git a/pkg/nmlite/link/consts.go b/pkg/nmlite/link/consts.go new file mode 100644 index 00000000..c226583b --- /dev/null +++ b/pkg/nmlite/link/consts.go @@ -0,0 +1,13 @@ +package link + +const ( + // AfUnspec is the unspecified address family constant + AfUnspec = 0 + // AfInet is the IPv4 address family constant + AfInet = 2 + // AfInet6 is the IPv6 address family constant + AfInet6 = 10 + + sysctlBase = "/proc/sys" + sysctlFileMode = 0640 +) diff --git a/pkg/nmlite/link/manager.go b/pkg/nmlite/link/manager.go new file mode 100644 index 00000000..c9b9410c --- /dev/null +++ b/pkg/nmlite/link/manager.go @@ -0,0 +1,544 @@ +package link + +import ( + "context" + "fmt" + "net" + "time" + + "github.com/jetkvm/kvm/internal/sync" + + "github.com/jetkvm/kvm/internal/network/types" + "github.com/rs/zerolog" + "github.com/vishvananda/netlink" +) + +// StateChangeHandler is the function type for link state callbacks +type StateChangeHandler func(link *Link) + +// StateChangeCallback is the struct for link state callbacks +type StateChangeCallback struct { + Async bool + Func StateChangeHandler +} + +// NetlinkManager provides centralized netlink operations +type NetlinkManager struct { + logger *zerolog.Logger + mu sync.RWMutex + stateChangeCallbacks map[string][]StateChangeCallback +} + +func newNetlinkManager(logger *zerolog.Logger) *NetlinkManager { + if logger == nil { + logger = &zerolog.Logger{} // Default no-op logger + } + n := &NetlinkManager{ + logger: logger, + stateChangeCallbacks: make(map[string][]StateChangeCallback), + } + n.monitorStateChange() + return n +} + +// GetNetlinkManager returns the singleton NetlinkManager instance +func GetNetlinkManager() *NetlinkManager { + netlinkManagerOnce.Do(func() { + netlinkManagerInstance = newNetlinkManager(nil) + }) + return netlinkManagerInstance +} + +// InitializeNetlinkManager initializes the singleton NetlinkManager with a logger +func InitializeNetlinkManager(logger *zerolog.Logger) *NetlinkManager { + netlinkManagerOnce.Do(func() { + netlinkManagerInstance = newNetlinkManager(logger) + }) + return netlinkManagerInstance +} + +// AddStateChangeCallback adds a callback for link state changes +func (nm *NetlinkManager) AddStateChangeCallback(ifname string, callback StateChangeCallback) { + nm.mu.Lock() + defer nm.mu.Unlock() + + if _, ok := nm.stateChangeCallbacks[ifname]; !ok { + nm.stateChangeCallbacks[ifname] = make([]StateChangeCallback, 0) + } + + nm.stateChangeCallbacks[ifname] = append(nm.stateChangeCallbacks[ifname], callback) +} + +// Interface operations +func (nm *NetlinkManager) monitorStateChange() { + 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 + if err := netlink.LinkSubscribe(updateCh, stopCh); err != nil { + nm.logger.Error().Err(err).Msg("failed to subscribe to link state changes") + } + + nm.logger.Info().Msg("state change monitoring started") + + go func() { + for update := range updateCh { + nm.runCallbacks(update) + } + }() +} + +func (nm *NetlinkManager) runCallbacks(update netlink.LinkUpdate) { + nm.mu.RLock() + defer nm.mu.RUnlock() + + ifname := update.Link.Attrs().Name + callbacks, ok := nm.stateChangeCallbacks[ifname] + + l := nm.logger.With().Str("interface", ifname).Logger() + if !ok { + l.Trace().Msg("no state change callbacks for interface") + return + } + + for _, callback := range callbacks { + l.Trace(). + Interface("callback", callback). + Bool("async", callback.Async). + Msg("calling callback") + + if callback.Async { + go callback.Func(&Link{Link: update.Link}) + } else { + callback.Func(&Link{Link: update.Link}) + } + } +} + +// GetLinkByName gets a network link by name +func (nm *NetlinkManager) GetLinkByName(name string) (*Link, error) { + nm.mu.RLock() + defer nm.mu.RUnlock() + link, err := netlink.LinkByName(name) + if err != nil { + return nil, err + } + return &Link{Link: link}, nil +} + +// LinkSetUp brings a network interface up +func (nm *NetlinkManager) LinkSetUp(link *Link) error { + nm.mu.RLock() + defer nm.mu.RUnlock() + return netlink.LinkSetUp(link) +} + +// LinkSetDown brings a network interface down +func (nm *NetlinkManager) LinkSetDown(link *Link) error { + nm.mu.RLock() + defer nm.mu.RUnlock() + return netlink.LinkSetDown(link) +} + +// EnsureInterfaceUp ensures the interface is up +func (nm *NetlinkManager) EnsureInterfaceUp(link *Link) error { + if link.Attrs().OperState == netlink.OperUp { + return nil + } + return nm.LinkSetUp(link) +} + +// EnsureInterfaceUpWithTimeout ensures the interface is up with timeout and retry logic +func (nm *NetlinkManager) EnsureInterfaceUpWithTimeout(ctx context.Context, iface *Link, timeout time.Duration) (*Link, error) { + ifname := iface.Attrs().Name + + l := nm.logger.With().Str("interface", ifname).Logger() + + linkUpTimeout := time.After(timeout) + + var attempt int + start := time.Now() + + for { + link, err := nm.GetLinkByName(ifname) + if err != nil { + return nil, err + } + + state := link.Attrs().OperState + + l = l.With(). + Int("attempt", attempt). + Dur("duration", time.Since(start)). + Str("state", state.String()). + Logger() + if state == netlink.OperUp || state == netlink.OperUnknown { + if attempt > 0 { + l.Info().Int("attempt", attempt-1).Msg("interface is up") + } + return link, nil + } + + l.Info().Msg("bringing up interface") + + // bring up the interface + if err = nm.LinkSetUp(link); err != nil { + l.Error().Err(err).Msg("interface can't make it up") + } + + // refresh the link attributes + if err = link.Refresh(); err != nil { + l.Error().Err(err).Msg("failed to refresh link attributes") + } + + // check the state again + state = link.Attrs().OperState + l = l.With().Str("new_state", state.String()).Logger() + if state == netlink.OperUp { + l.Info().Msg("interface is up") + return link, nil + } + l.Warn().Msg("interface is still down, retrying") + + select { + case <-time.After(500 * time.Millisecond): + attempt++ + continue + case <-ctx.Done(): + if err != nil { + return nil, err + } + return nil, ErrInterfaceUpCanceled + case <-linkUpTimeout: + attempt++ + l.Error(). + Int("attempt", attempt).Msg("interface is still down after timeout") + if err != nil { + return nil, err + } + return nil, ErrInterfaceUpTimeout + } + } +} + +// Address operations + +// AddrList gets all addresses for a link +func (nm *NetlinkManager) AddrList(link *Link, family int) ([]netlink.Addr, error) { + nm.mu.RLock() + defer nm.mu.RUnlock() + return netlink.AddrList(link, family) +} + +// AddrAdd adds an address to a link +func (nm *NetlinkManager) AddrAdd(link *Link, addr *netlink.Addr) error { + nm.mu.RLock() + defer nm.mu.RUnlock() + return netlink.AddrAdd(link, addr) +} + +// AddrDel removes an address from a link +func (nm *NetlinkManager) AddrDel(link *Link, addr *netlink.Addr) error { + nm.mu.RLock() + defer nm.mu.RUnlock() + return netlink.AddrDel(link, addr) +} + +// RemoveAllAddresses removes all addresses of a specific family from a link +func (nm *NetlinkManager) RemoveAllAddresses(link *Link, family int) error { + addrs, err := nm.AddrList(link, family) + if err != nil { + return fmt.Errorf("failed to get addresses: %w", err) + } + + for _, addr := range addrs { + if err := nm.AddrDel(link, &addr); err != nil { + nm.logger.Warn().Err(err).Str("address", addr.IP.String()).Msg("failed to remove address") + } + } + + return nil +} + +// RemoveNonLinkLocalIPv6Addresses removes all non-link-local IPv6 addresses +func (nm *NetlinkManager) RemoveNonLinkLocalIPv6Addresses(link *Link) error { + addrs, err := nm.AddrList(link, AfInet6) + if err != nil { + return fmt.Errorf("failed to get IPv6 addresses: %w", err) + } + + for _, addr := range addrs { + if !addr.IP.IsLinkLocalUnicast() { + if err := nm.AddrDel(link, &addr); err != nil { + nm.logger.Warn().Err(err).Str("address", addr.IP.String()).Msg("failed to remove IPv6 address") + } + } + } + + return nil +} + +// RouteList gets all routes +func (nm *NetlinkManager) RouteList(link *Link, family int) ([]netlink.Route, error) { + nm.mu.RLock() + defer nm.mu.RUnlock() + return netlink.RouteList(link, family) +} + +// RouteAdd adds a route +func (nm *NetlinkManager) RouteAdd(route *netlink.Route) error { + nm.mu.RLock() + defer nm.mu.RUnlock() + return netlink.RouteAdd(route) +} + +// RouteDel removes a route +func (nm *NetlinkManager) RouteDel(route *netlink.Route) error { + nm.mu.RLock() + defer nm.mu.RUnlock() + return netlink.RouteDel(route) +} + +// RouteReplace replaces a route +func (nm *NetlinkManager) RouteReplace(route *netlink.Route) error { + nm.mu.RLock() + defer nm.mu.RUnlock() + return netlink.RouteReplace(route) +} + +// ListDefaultRoutes lists the default routes for the given family +func (nm *NetlinkManager) ListDefaultRoutes(family int) ([]netlink.Route, error) { + routes, err := netlink.RouteListFiltered( + family, + &netlink.Route{Dst: nil, Table: 254}, + netlink.RT_FILTER_DST|netlink.RT_FILTER_TABLE, + ) + if err != nil { + nm.logger.Error().Err(err).Int("family", family).Msg("failed to list default routes") + return nil, err + } + + return routes, nil +} + +// HasDefaultRoute checks if a default route exists for the given family +func (nm *NetlinkManager) HasDefaultRoute(family int) bool { + routes, err := nm.ListDefaultRoutes(family) + if err != nil { + return false + } + return len(routes) > 0 +} + +// AddDefaultRoute adds a default route +func (nm *NetlinkManager) AddDefaultRoute(link *Link, gateway net.IP, family int) error { + var dst *net.IPNet + switch family { + case AfInet: + dst = &ipv4DefaultRoute + case AfInet6: + dst = &ipv6DefaultRoute + default: + return fmt.Errorf("unsupported address family: %d", family) + } + + route := &netlink.Route{ + Dst: dst, + Gw: gateway, + LinkIndex: link.Attrs().Index, + } + + return nm.RouteReplace(route) +} + +// RemoveDefaultRoute removes the default route for the given family +func (nm *NetlinkManager) RemoveDefaultRoute(family int) error { + routes, err := nm.RouteList(nil, family) + if err != nil { + return fmt.Errorf("failed to get routes: %w", err) + } + + for _, route := range routes { + if route.Dst != nil { + if family == AfInet && route.Dst.IP.Equal(net.IPv4zero) && route.Dst.Mask.String() == "0.0.0.0/0" { + if err := nm.RouteDel(&route); err != nil { + nm.logger.Warn().Err(err).Msg("failed to remove IPv4 default route") + } + } + if family == AfInet6 && route.Dst.IP.Equal(net.IPv6zero) && route.Dst.Mask.String() == "::/0" { + if err := nm.RouteDel(&route); err != nil { + nm.logger.Warn().Err(err).Msg("failed to remove IPv6 default route") + } + } + } + } + + return nil +} + +func (nm *NetlinkManager) reconcileDefaultRoute(link *Link, expected map[string]net.IP, family int) error { + linkIndex := link.Attrs().Index + + added := 0 + toRemove := make([]*netlink.Route, 0) + + defaultRoutes, err := nm.ListDefaultRoutes(family) + if err != nil { + return fmt.Errorf("failed to get default routes: %w", err) + } + + // check existing default routes + for _, defaultRoute := range defaultRoutes { + // only check the default routes for the current link + // TODO: we should also check others later + if defaultRoute.LinkIndex != linkIndex { + continue + } + + key := defaultRoute.Gw.String() + if _, ok := expected[key]; !ok { + toRemove = append(toRemove, &defaultRoute) + continue + } + + nm.logger.Warn().Str("gateway", key).Msg("keeping default route") + delete(expected, key) + } + + // remove remaining default routes + for _, defaultRoute := range toRemove { + nm.logger.Warn().Str("gateway", defaultRoute.Gw.String()).Msg("removing default route") + if err := nm.RouteDel(defaultRoute); err != nil { + nm.logger.Warn().Err(err).Msg("failed to remove default route") + } + } + + // add remaining expected default routes + for _, gateway := range expected { + nm.logger.Warn().Str("gateway", gateway.String()).Msg("adding default route") + + route := &netlink.Route{ + Dst: &ipv4DefaultRoute, + Gw: gateway, + LinkIndex: linkIndex, + } + if family == AfInet6 { + route.Dst = &ipv6DefaultRoute + } + if err := nm.RouteAdd(route); err != nil { + nm.logger.Warn().Err(err).Interface("route", route).Msg("failed to add default route") + } + added++ + } + + nm.logger.Info(). + Int("added", added). + Int("removed", len(toRemove)). + Msg("default routes reconciled") + + return nil +} + +// ReconcileLink reconciles the addresses and routes of a link +func (nm *NetlinkManager) ReconcileLink(link *Link, expected []types.IPAddress, family int) error { + toAdd := make([]*types.IPAddress, 0) + toRemove := make([]*netlink.Addr, 0) + toUpdate := make([]*types.IPAddress, 0) + expectedAddrs := make(map[string]*types.IPAddress) + + expectedGateways := make(map[string]net.IP) + + mtu := link.Attrs().MTU + expectedMTU := mtu + // add all expected addresses to the map + for _, addr := range expected { + expectedAddrs[addr.String()] = &addr + if addr.Gateway != nil { + expectedGateways[addr.String()] = addr.Gateway + } + if addr.MTU != 0 { + mtu = addr.MTU + } + } + if expectedMTU != mtu { + if err := link.SetMTU(expectedMTU); err != nil { + nm.logger.Warn().Err(err).Int("expected_mtu", expectedMTU).Int("mtu", mtu).Msg("failed to set MTU") + } + } + + addrs, err := nm.AddrList(link, family) + if err != nil { + return fmt.Errorf("failed to get addresses: %w", err) + } + + // check existing addresses + for _, addr := range addrs { + // skip the link-local address + if addr.IP.IsLinkLocalUnicast() { + continue + } + + expectedAddr, ok := expectedAddrs[addr.IPNet.String()] + if !ok { + toRemove = append(toRemove, &addr) + continue + } + + // if it's not fully equal, we need to update it + if !expectedAddr.Compare(addr) { + toUpdate = append(toUpdate, expectedAddr) + continue + } + + // remove it from expected addresses + delete(expectedAddrs, addr.IPNet.String()) + } + + // add remaining expected addresses + for _, addr := range expectedAddrs { + toAdd = append(toAdd, addr) + } + + for _, addr := range toUpdate { + netlinkAddr := addr.NetlinkAddr() + if err := nm.AddrDel(link, &netlinkAddr); err != nil { + nm.logger.Warn().Err(err).Str("address", addr.Address.String()).Msg("failed to update address") + } + // we'll add it again later + toAdd = append(toAdd, addr) + } + + for _, addr := range toAdd { + netlinkAddr := addr.NetlinkAddr() + if err := nm.AddrAdd(link, &netlinkAddr); err != nil { + nm.logger.Warn().Err(err).Str("address", addr.Address.String()).Msg("failed to add address") + } + } + + for _, netlinkAddr := range toRemove { + if err := nm.AddrDel(link, netlinkAddr); err != nil { + nm.logger.Warn().Err(err).Str("address", netlinkAddr.IP.String()).Msg("failed to remove address") + } + } + + for _, addr := range toAdd { + netlinkAddr := addr.NetlinkAddr() + if err := nm.AddrAdd(link, &netlinkAddr); err != nil { + nm.logger.Warn().Err(err).Str("address", addr.Address.String()).Msg("failed to add address") + } + } + + actualToAdd := len(toAdd) - len(toUpdate) + if len(toAdd) > 0 || len(toUpdate) > 0 || len(toRemove) > 0 { + nm.logger.Info(). + Int("added", actualToAdd). + Int("updated", len(toUpdate)). + Int("removed", len(toRemove)). + Msg("addresses reconciled") + } + + if err := nm.reconcileDefaultRoute(link, expectedGateways, family); err != nil { + nm.logger.Warn().Err(err).Msg("failed to reconcile default route") + } + + return nil +} diff --git a/pkg/nmlite/link/netlink.go b/pkg/nmlite/link/netlink.go new file mode 100644 index 00000000..12358d32 --- /dev/null +++ b/pkg/nmlite/link/netlink.go @@ -0,0 +1,164 @@ +// Package link provides a wrapper around netlink.Link and provides a singleton netlink manager. +package link + +import ( + "errors" + "fmt" + "net" + + "github.com/jetkvm/kvm/internal/sync" + + "github.com/vishvananda/netlink" +) + +var ( + ipv4DefaultRoute = net.IPNet{ + IP: net.IPv4zero, + Mask: net.CIDRMask(0, 0), + } + + ipv6DefaultRoute = net.IPNet{ + IP: net.IPv6zero, + Mask: net.CIDRMask(0, 0), + } + + // Singleton instance + netlinkManagerInstance *NetlinkManager + netlinkManagerOnce sync.Once + + // ErrInterfaceUpTimeout is the error returned when the interface does not come up within the timeout + ErrInterfaceUpTimeout = errors.New("timeout after waiting for an interface to come up") + // ErrInterfaceUpCanceled is the error returned when the interface does not come up due to context cancellation + ErrInterfaceUpCanceled = errors.New("context canceled while waiting for an interface to come up") +) + +// Link is a wrapper around netlink.Link +type Link struct { + netlink.Link + mu sync.Mutex +} + +// All lock actions should be done in external functions +// and the internal functions should not be called directly + +func (l *Link) refresh() error { + linkName := l.ifName() + link, err := netlink.LinkByName(linkName) + if err != nil { + return err + } + if link == nil { + return fmt.Errorf("link not found: %s", linkName) + } + l.Link = link + return nil +} + +func (l *Link) attrs() *netlink.LinkAttrs { + return l.Link.Attrs() +} + +func (l *Link) ifName() string { + attrs := l.attrs() + if attrs.Name == "" { + return "" + } + return attrs.Name +} + +// Refresh refreshes the link +func (l *Link) Refresh() error { + l.mu.Lock() + defer l.mu.Unlock() + + return l.refresh() +} + +// Attrs returns the attributes of the link +func (l *Link) Attrs() *netlink.LinkAttrs { + l.mu.Lock() + defer l.mu.Unlock() + + return l.attrs() +} + +// Interface returns the interface of the link +func (l *Link) Interface() *net.Interface { + l.mu.Lock() + defer l.mu.Unlock() + + ifname := l.ifName() + if ifname == "" { + return nil + } + iface, err := net.InterfaceByName(ifname) + if err != nil { + return nil + } + return iface +} + +// HardwareAddr returns the hardware address of the link +func (l *Link) HardwareAddr() net.HardwareAddr { + l.mu.Lock() + defer l.mu.Unlock() + + attrs := l.attrs() + if attrs.HardwareAddr == nil { + return nil + } + return attrs.HardwareAddr +} + +// AddrList returns the addresses of the link +func (l *Link) AddrList(family int) ([]netlink.Addr, error) { + l.mu.Lock() + defer l.mu.Unlock() + + return netlink.AddrList(l.Link, family) +} + +func (l *Link) SetMTU(mtu int) error { + l.mu.Lock() + defer l.mu.Unlock() + + return netlink.LinkSetMTU(l.Link, mtu) +} + +// HasGlobalUnicastAddress returns true if the link has a global unicast address +func (l *Link) HasGlobalUnicastAddress() bool { + addrs, err := l.AddrList(AfUnspec) + if err != nil { + return false + } + + for _, addr := range addrs { + if addr.IP.IsGlobalUnicast() { + return true + } + } + return false +} + +// IsSame checks if the link is the same as another link +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 +} diff --git a/pkg/nmlite/link/sysctl.go b/pkg/nmlite/link/sysctl.go new file mode 100644 index 00000000..33a0a62a --- /dev/null +++ b/pkg/nmlite/link/sysctl.go @@ -0,0 +1,52 @@ +package link + +import ( + "fmt" + "os" + "path" + "strconv" + "strings" +) + +func (nm *NetlinkManager) setSysctlValues(ifaceName string, values map[string]int) error { + for name, value := range values { + name = fmt.Sprintf(name, ifaceName) + name = strings.ReplaceAll(name, ".", "/") + + if err := os.WriteFile(path.Join(sysctlBase, name), []byte(strconv.Itoa(value)), sysctlFileMode); err != nil { + return fmt.Errorf("failed to set sysctl %s=%d: %w", name, value, err) + } + } + return nil +} + +// EnableIPv6 enables IPv6 on the interface +func (nm *NetlinkManager) EnableIPv6(ifaceName string) error { + return nm.setSysctlValues(ifaceName, map[string]int{ + "net.ipv6.conf.%s.disable_ipv6": 0, + "net.ipv6.conf.%s.accept_ra": 2, + }) +} + +// DisableIPv6 disables IPv6 on the interface +func (nm *NetlinkManager) DisableIPv6(ifaceName string) error { + return nm.setSysctlValues(ifaceName, map[string]int{ + "net.ipv6.conf.%s.disable_ipv6": 1, + }) +} + +// EnableIPv6SLAAC enables IPv6 SLAAC on the interface +func (nm *NetlinkManager) EnableIPv6SLAAC(ifaceName string) error { + return nm.setSysctlValues(ifaceName, map[string]int{ + "net.ipv6.conf.%s.disable_ipv6": 0, + "net.ipv6.conf.%s.accept_ra": 2, + }) +} + +// EnableIPv6LinkLocal enables IPv6 link-local only on the interface +func (nm *NetlinkManager) EnableIPv6LinkLocal(ifaceName string) error { + return nm.setSysctlValues(ifaceName, map[string]int{ + "net.ipv6.conf.%s.disable_ipv6": 0, + "net.ipv6.conf.%s.accept_ra": 0, + }) +} diff --git a/pkg/nmlite/link/types.go b/pkg/nmlite/link/types.go new file mode 100644 index 00000000..06f941a0 --- /dev/null +++ b/pkg/nmlite/link/types.go @@ -0,0 +1,13 @@ +package link + +import ( + "net" +) + +// IPv4Address represents an IPv4 address and its gateway +type IPv4Address struct { + Address net.IPNet + Gateway net.IP + Secondary bool + Permanent bool +} diff --git a/pkg/nmlite/link/utils.go b/pkg/nmlite/link/utils.go new file mode 100644 index 00000000..ba911b86 --- /dev/null +++ b/pkg/nmlite/link/utils.go @@ -0,0 +1,87 @@ +package link + +import ( + "fmt" + "net" + "strings" +) + +// ParseIPv4Netmask parses an IPv4 netmask string and returns the IPNet +func ParseIPv4Netmask(address, netmask string) (*net.IPNet, error) { + if strings.Contains(address, "/") { + _, ipNet, err := net.ParseCIDR(address) + if err != nil { + return nil, fmt.Errorf("invalid IPv4 address: %s", address) + } + return ipNet, nil + } + + ip := net.ParseIP(address) + if ip == nil { + return nil, fmt.Errorf("invalid IPv4 address: %s", address) + } + if ip.To4() == nil { + return nil, fmt.Errorf("not an IPv4 address: %s", address) + } + + mask := net.ParseIP(netmask) + if mask == nil { + return nil, fmt.Errorf("invalid IPv4 netmask: %s", netmask) + } + if mask.To4() == nil { + return nil, fmt.Errorf("not an IPv4 netmask: %s", netmask) + } + + return &net.IPNet{ + IP: ip, + Mask: net.IPv4Mask(mask[12], mask[13], mask[14], mask[15]), + }, nil +} + +// ParseIPv6Prefix parses an IPv6 address and prefix length +func ParseIPv6Prefix(address string, prefixLength int) (*net.IPNet, error) { + if strings.Contains(address, "/") { + _, ipNet, err := net.ParseCIDR(address) + if err != nil { + return nil, fmt.Errorf("invalid IPv6 address: %s", address) + } + return ipNet, nil + } + + ip := net.ParseIP(address) + if ip == nil { + return nil, fmt.Errorf("invalid IPv6 address: %s", address) + } + if ip.To16() == nil || ip.To4() != nil { + return nil, fmt.Errorf("not an IPv6 address: %s", address) + } + + if prefixLength < 0 || prefixLength > 128 { + return nil, fmt.Errorf("invalid IPv6 prefix length: %d (must be 0-128)", prefixLength) + } + + return &net.IPNet{ + IP: ip, + Mask: net.CIDRMask(prefixLength, 128), + }, nil +} + +// ValidateIPAddress validates an IP address +func ValidateIPAddress(address string, isIPv6 bool) error { + ip := net.ParseIP(address) + if ip == nil { + return fmt.Errorf("invalid IP address: %s", address) + } + + if isIPv6 { + if ip.To16() == nil || ip.To4() != nil { + return fmt.Errorf("not an IPv6 address: %s", address) + } + } else { + if ip.To4() == nil { + return fmt.Errorf("not an IPv4 address: %s", address) + } + } + + return nil +} diff --git a/pkg/nmlite/manager.go b/pkg/nmlite/manager.go new file mode 100644 index 00000000..03496d9e --- /dev/null +++ b/pkg/nmlite/manager.go @@ -0,0 +1,260 @@ +// Package nmlite provides a lightweight network management system. +// It supports multiple network interfaces with static and DHCP configuration, +// IPv4/IPv6 support, and proper separation of concerns. +package nmlite + +import ( + "context" + "fmt" + + "github.com/jetkvm/kvm/internal/sync" + + "github.com/jetkvm/kvm/internal/logging" + "github.com/jetkvm/kvm/internal/network/types" + "github.com/jetkvm/kvm/pkg/nmlite/jetdhcpc" + "github.com/jetkvm/kvm/pkg/nmlite/link" + "github.com/rs/zerolog" +) + +// NetworkManager manages multiple network interfaces +type NetworkManager struct { + interfaces map[string]*InterfaceManager + mu sync.RWMutex + logger *zerolog.Logger + 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) + onDHCPLeaseChange func(iface string, lease *types.DHCPLease) +} + +// NewNetworkManager creates a new network manager +func NewNetworkManager(ctx context.Context, logger *zerolog.Logger) *NetworkManager { + if logger == nil { + logger = logging.GetSubsystemLogger("nm") + } + + // Initialize the NetlinkManager singleton + link.InitializeNetlinkManager(logger) + + ctx, cancel := context.WithCancel(ctx) + + return &NetworkManager{ + interfaces: make(map[string]*InterfaceManager), + 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() + defer nm.mu.Unlock() + + if _, exists := nm.interfaces[iface]; exists { + return fmt.Errorf("interface %s already managed", iface) + } + + im, err := NewInterfaceManager(nm.ctx, iface, config, nm.logger) + if err != nil { + return fmt.Errorf("failed to create interface manager for %s: %w", iface, err) + } + + // Set up callbacks + im.SetOnStateChange(func(state types.InterfaceState) { + if nm.onInterfaceStateChange != nil { + state.Hostname = nm.Hostname() + nm.onInterfaceStateChange(iface, state) + } + }) + + im.SetOnConfigChange(func(config *types.NetworkConfig) { + if nm.onConfigChange != nil { + nm.onConfigChange(iface, config) + } + }) + + im.SetOnDHCPLeaseChange(func(lease *types.DHCPLease) { + if nm.onDHCPLeaseChange != nil { + nm.onDHCPLeaseChange(iface, lease) + } + }) + + im.SetOnResolvConfChange(func(family int, resolvConf *types.InterfaceResolvConf) error { + return nm.resolvConf.SetInterfaceConfig(iface, family, *resolvConf) + }) + + nm.interfaces[iface] = im + + // Start monitoring the interface + if err := im.Start(); err != nil { + delete(nm.interfaces, iface) + return fmt.Errorf("failed to start interface manager for %s: %w", iface, err) + } + + nm.logger.Info().Str("interface", iface).Msg("added interface to network manager") + return nil +} + +// RemoveInterface removes a network interface from management +func (nm *NetworkManager) RemoveInterface(iface string) error { + nm.mu.Lock() + defer nm.mu.Unlock() + + im, exists := nm.interfaces[iface] + if !exists { + return fmt.Errorf("interface %s not managed", iface) + } + + if err := im.Stop(); err != nil { + nm.logger.Error().Err(err).Str("interface", iface).Msg("failed to stop interface manager") + } + + delete(nm.interfaces, iface) + nm.logger.Info().Str("interface", iface).Msg("removed interface from network manager") + return nil +} + +// GetInterface returns the interface manager for a specific interface +func (nm *NetworkManager) GetInterface(iface string) (*InterfaceManager, error) { + nm.mu.RLock() + defer nm.mu.RUnlock() + + im, exists := nm.interfaces[iface] + if !exists { + return nil, fmt.Errorf("interface %s not managed", iface) + } + + return im, nil +} + +// ListInterfaces returns a list of all managed interfaces +func (nm *NetworkManager) ListInterfaces() []string { + nm.mu.RLock() + defer nm.mu.RUnlock() + + interfaces := make([]string, 0, len(nm.interfaces)) + for iface := range nm.interfaces { + interfaces = append(interfaces, iface) + } + + return interfaces +} + +// GetInterfaceState returns the current state of a specific interface +func (nm *NetworkManager) GetInterfaceState(iface string) (*types.InterfaceState, error) { + im, err := nm.GetInterface(iface) + if err != nil { + return nil, err + } + + state := im.GetState() + state.Hostname = nm.Hostname() + + return state, nil +} + +// GetInterfaceConfig returns the current configuration of a specific interface +func (nm *NetworkManager) GetInterfaceConfig(iface string) (*types.NetworkConfig, error) { + im, err := nm.GetInterface(iface) + if err != nil { + return nil, err + } + + return im.GetConfig(), nil +} + +// SetInterfaceConfig updates the configuration of a specific interface +func (nm *NetworkManager) SetInterfaceConfig(iface string, config *types.NetworkConfig) error { + im, err := nm.GetInterface(iface) + if err != nil { + return err + } + + return im.SetConfig(config) +} + +// RenewDHCPLease renews the DHCP lease for a specific interface +func (nm *NetworkManager) RenewDHCPLease(iface string) error { + im, err := nm.GetInterface(iface) + if err != nil { + return err + } + + return im.RenewDHCPLease() +} + +// SetOnInterfaceStateChange sets the callback for interface state changes +func (nm *NetworkManager) SetOnInterfaceStateChange(callback func(iface string, state types.InterfaceState)) { + nm.onInterfaceStateChange = callback +} + +// SetOnConfigChange sets the callback for configuration changes +func (nm *NetworkManager) SetOnConfigChange(callback func(iface string, config *types.NetworkConfig)) { + nm.onConfigChange = callback +} + +// SetOnDHCPLeaseChange sets the callback for DHCP lease changes +func (nm *NetworkManager) SetOnDHCPLeaseChange(callback func(iface string, lease *types.DHCPLease)) { + nm.onDHCPLeaseChange = callback +} + +func (nm *NetworkManager) shouldKillLegacyDHCPClients() bool { + nm.mu.RLock() + defer nm.mu.RUnlock() + + // TODO: remove it when we need to support multiple interfaces + for _, im := range nm.interfaces { + if im.dhcpClient.clientType != "udhcpc" { + return true + } + + if im.config.IPv4Mode.String != "dhcp" { + return true + } + } + return false +} + +// CleanUpLegacyDHCPClients cleans up legacy DHCP clients +func (nm *NetworkManager) CleanUpLegacyDHCPClients() error { + shouldKill := nm.shouldKillLegacyDHCPClients() + if shouldKill { + return jetdhcpc.KillUdhcpC(nm.logger) + } + return nil +} + +// Stop stops the network manager and all managed interfaces +func (nm *NetworkManager) Stop() error { + nm.mu.Lock() + defer nm.mu.Unlock() + + var lastErr error + for iface, im := range nm.interfaces { + if err := im.Stop(); err != nil { + nm.logger.Error().Err(err).Str("interface", iface).Msg("failed to stop interface manager") + lastErr = err + } + } + + nm.cancel() + nm.logger.Info().Msg("network manager stopped") + return lastErr +} diff --git a/pkg/nmlite/netlink.go b/pkg/nmlite/netlink.go new file mode 100644 index 00000000..cca2fc09 --- /dev/null +++ b/pkg/nmlite/netlink.go @@ -0,0 +1,7 @@ +package nmlite + +import "github.com/jetkvm/kvm/pkg/nmlite/link" + +func getNetlinkManager() *link.NetlinkManager { + return link.GetNetlinkManager() +} diff --git a/pkg/nmlite/resolvconf.go b/pkg/nmlite/resolvconf.go new file mode 100644 index 00000000..1bacee7a --- /dev/null +++ b/pkg/nmlite/resolvconf.go @@ -0,0 +1,209 @@ +package nmlite + +import ( + "bytes" + "fmt" + "html/template" + "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 JetKVM +# DO NOT EDIT THIS FILE BY HAND -- YOUR CHANGES WILL BE OVERWRITTEN + +{{ if .searchList }} +search {{ join .searchList " " }} +{{- end -}} +{{ if .domain }} +domain {{ .domain }} +{{- end -}} +{{ range $ns, $comment := .nameservers }} +nameserver {{ printf "%s" $ns }} # {{ join $comment ", " }} +{{- end }} +` +) + +var ( + tplFuncMap = template.FuncMap{ + "join": strings.Join, + } +) + +// 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 +func NewResolvConfManager(logger *zerolog.Logger) *ResolvConfManager { + if logger == nil { + // Create a no-op logger if none provided + logger = &zerolog.Logger{} + } + + return &ResolvConfManager{ + logger: logger, + mu: sync.Mutex{}, + conf: &types.ResolvConf{ + ConfigIPv4: make(map[string]types.InterfaceResolvConf), + ConfigIPv6: make(map[string]types.InterfaceResolvConf), + }, + } +} + +// 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() + + if err := rcm.reconcileHostname(); err != nil { + return fmt.Errorf("failed to reconcile hostname: %w", err) + } + + return rcm.update() +} + +// SetConfig sets the resolv.conf configuration +func (rcm *ResolvConfManager) SetConfig(resolvConf *types.ResolvConf) error { + if resolvConf == nil { + return fmt.Errorf("resolvConf cannot be nil") + } + + rcm.mu.Lock() + rcm.conf = resolvConf + defer rcm.mu.Unlock() + + return rcm.update() +} + +// Reconcile reconciles the resolv.conf configuration +func (rcm *ResolvConfManager) Reconcile() error { + if err := rcm.reconcileHostname(); err != nil { + return fmt.Errorf("failed to reconcile hostname: %w", err) + } + return rcm.update() +} + +// Update updates the resolv.conf file +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(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(). + 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(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{ + "nameservers": nameservers, + "searchList": flattenedSearchList, + }); err != nil { + return nil, fmt.Errorf("failed to execute template: %w", err) + } + + return buf.Bytes(), nil +} diff --git a/pkg/nmlite/state.go b/pkg/nmlite/state.go new file mode 100644 index 00000000..bc6accae --- /dev/null +++ b/pkg/nmlite/state.go @@ -0,0 +1,106 @@ +package nmlite + +import "net" + +func (nm *NetworkManager) IsOnline() bool { + for _, iface := range nm.interfaces { + if iface.IsOnline() { + return true + } + } + return false +} + +func (nm *NetworkManager) IsUp() bool { + for _, iface := range nm.interfaces { + if iface.IsUp() { + return true + } + } + return false +} + +func (nm *NetworkManager) Hostname() string { + return nm.resolvConf.Hostname() +} + +func (nm *NetworkManager) FQDN() string { + return nm.resolvConf.FQDN() +} + +func (nm *NetworkManager) NTPServers() []net.IP { + servers := []net.IP{} + for _, iface := range nm.interfaces { + servers = append(servers, iface.NTPServers()...) + } + return servers +} + +func (nm *NetworkManager) NTPServerStrings() []string { + servers := []string{} + for _, server := range nm.NTPServers() { + servers = append(servers, server.String()) + } + return servers +} + +func (nm *NetworkManager) GetIPv4Addresses() []string { + for _, iface := range nm.interfaces { + return iface.GetIPv4Addresses() + } + return []string{} +} + +func (nm *NetworkManager) GetIPv4Address() string { + for _, iface := range nm.interfaces { + return iface.GetIPv4Address() + } + return "" +} + +func (nm *NetworkManager) GetIPv6Address() string { + for _, iface := range nm.interfaces { + return iface.GetIPv6Address() + } + return "" +} + +func (nm *NetworkManager) GetIPv6Addresses() []string { + for _, iface := range nm.interfaces { + return iface.GetIPv6Addresses() + } + return []string{} +} + +func (nm *NetworkManager) GetMACAddress() string { + for _, iface := range nm.interfaces { + return iface.GetMACAddress() + } + return "" +} + +func (nm *NetworkManager) IPv4Ready() bool { + for _, iface := range nm.interfaces { + return iface.IPv4Ready() + } + return false +} + +func (nm *NetworkManager) IPv6Ready() bool { + for _, iface := range nm.interfaces { + return iface.IPv6Ready() + } + return false +} + +func (nm *NetworkManager) IPv4String() string { + return nm.GetIPv4Address() +} + +func (nm *NetworkManager) IPv6String() string { + return nm.GetIPv6Address() +} + +func (nm *NetworkManager) MACString() string { + return nm.GetMACAddress() +} diff --git a/pkg/nmlite/static.go b/pkg/nmlite/static.go new file mode 100644 index 00000000..9500556b --- /dev/null +++ b/pkg/nmlite/static.go @@ -0,0 +1,184 @@ +package nmlite + +import ( + "fmt" + "net" + + "github.com/jetkvm/kvm/internal/network/types" + "github.com/jetkvm/kvm/pkg/nmlite/link" + "github.com/rs/zerolog" +) + +// StaticConfigManager manages static network configuration +type StaticConfigManager struct { + ifaceName string + logger *zerolog.Logger +} + +// NewStaticConfigManager creates a new static configuration manager +func NewStaticConfigManager(ifaceName string, logger *zerolog.Logger) (*StaticConfigManager, error) { + if ifaceName == "" { + return nil, fmt.Errorf("interface name cannot be empty") + } + + if logger == nil { + return nil, fmt.Errorf("logger cannot be nil") + } + + return &StaticConfigManager{ + ifaceName: ifaceName, + logger: logger, + }, nil +} + +// ToIPv4Static applies static IPv4 configuration +func (scm *StaticConfigManager) ToIPv4Static(config *types.IPv4StaticConfig) (*types.ParsedIPConfig, error) { + if config == nil { + return nil, fmt.Errorf("config is nil") + } + + // Parse IP address and netmask + ipNet, err := link.ParseIPv4Netmask(config.Address.String, config.Netmask.String) + if err != nil { + return nil, err + } + scm.logger.Info().Str("ipNet", ipNet.String()).Interface("ipc", config).Msg("parsed IPv4 address and netmask") + + // Parse gateway + gateway := net.ParseIP(config.Gateway.String) + if gateway == nil { + return nil, fmt.Errorf("invalid gateway: %s", config.Gateway.String) + } + + // Parse DNS servers + var dns []net.IP + for _, dnsStr := range config.DNS { + if err := link.ValidateIPAddress(dnsStr, false); err != nil { + return nil, fmt.Errorf("invalid DNS server: %w", err) + } + dns = append(dns, net.ParseIP(dnsStr)) + } + + address := types.IPAddress{ + Family: link.AfInet, + Address: *ipNet, + Gateway: gateway, + Secondary: false, + Permanent: true, + } + + return &types.ParsedIPConfig{ + Addresses: []types.IPAddress{address}, + Nameservers: dns, + Interface: scm.ifaceName, + }, nil +} + +// ToIPv6Static applies static IPv6 configuration +func (scm *StaticConfigManager) ToIPv6Static(config *types.IPv6StaticConfig) (*types.ParsedIPConfig, error) { + if config == nil { + return nil, fmt.Errorf("config is nil") + } + + // Parse IP address and prefix + ipNet, err := link.ParseIPv6Prefix(config.Prefix.String, 64) // Default to /64 if not specified + if err != nil { + return nil, err + } + + // Parse gateway + gateway := net.ParseIP(config.Gateway.String) + if gateway == nil { + return nil, fmt.Errorf("invalid gateway: %s", config.Gateway.String) + } + + // Parse DNS servers + var dns []net.IP + for _, dnsStr := range config.DNS { + dnsIP := net.ParseIP(dnsStr) + if dnsIP == nil { + return nil, fmt.Errorf("invalid DNS server: %s", dnsStr) + } + dns = append(dns, dnsIP) + } + + address := types.IPAddress{ + Family: link.AfInet6, + Address: *ipNet, + Gateway: gateway, + Secondary: false, + Permanent: true, + } + + return &types.ParsedIPConfig{ + Addresses: []types.IPAddress{address}, + Nameservers: dns, + Interface: scm.ifaceName, + }, nil +} + +// DisableIPv4 disables IPv4 on the interface +func (scm *StaticConfigManager) DisableIPv4() error { + scm.logger.Info().Msg("disabling IPv4") + + netlinkMgr := getNetlinkManager() + iface, err := netlinkMgr.GetLinkByName(scm.ifaceName) + if err != nil { + return fmt.Errorf("failed to get interface: %w", err) + } + + // Remove all IPv4 addresses + if err := netlinkMgr.RemoveAllAddresses(iface, link.AfInet); err != nil { + return fmt.Errorf("failed to remove IPv4 addresses: %w", err) + } + + // Remove default route + if err := scm.removeIPv4DefaultRoute(); err != nil { + scm.logger.Warn().Err(err).Msg("failed to remove IPv4 default route") + } + + scm.logger.Info().Msg("IPv4 disabled") + return nil +} + +// DisableIPv6 disables IPv6 on the interface +func (scm *StaticConfigManager) DisableIPv6() error { + scm.logger.Info().Msg("disabling IPv6") + netlinkMgr := getNetlinkManager() + return netlinkMgr.DisableIPv6(scm.ifaceName) +} + +// EnableIPv6SLAAC enables IPv6 SLAAC +func (scm *StaticConfigManager) EnableIPv6SLAAC() error { + scm.logger.Info().Msg("enabling IPv6 SLAAC") + netlinkMgr := getNetlinkManager() + return netlinkMgr.EnableIPv6SLAAC(scm.ifaceName) +} + +// EnableIPv6LinkLocal enables IPv6 link-local only +func (scm *StaticConfigManager) EnableIPv6LinkLocal() error { + scm.logger.Info().Msg("enabling IPv6 link-local only") + + netlinkMgr := getNetlinkManager() + if err := netlinkMgr.EnableIPv6LinkLocal(scm.ifaceName); err != nil { + return err + } + + // Remove all non-link-local IPv6 addresses + link, err := netlinkMgr.GetLinkByName(scm.ifaceName) + if err != nil { + return fmt.Errorf("failed to get interface: %w", err) + } + + if err := netlinkMgr.RemoveNonLinkLocalIPv6Addresses(link); err != nil { + return fmt.Errorf("failed to remove non-link-local IPv6 addresses: %w", err) + } + + return netlinkMgr.EnsureInterfaceUp(link) +} + +// removeIPv4DefaultRoute removes IPv4 default route +func (scm *StaticConfigManager) removeIPv4DefaultRoute() error { + netlinkMgr := getNetlinkManager() + return netlinkMgr.RemoveDefaultRoute(link.AfInet) +} diff --git a/internal/udhcpc/options.go b/pkg/nmlite/udhcpc/options.go similarity index 100% rename from internal/udhcpc/options.go rename to pkg/nmlite/udhcpc/options.go diff --git a/pkg/nmlite/udhcpc/parser.go b/pkg/nmlite/udhcpc/parser.go new file mode 100644 index 00000000..115cdc17 --- /dev/null +++ b/pkg/nmlite/udhcpc/parser.go @@ -0,0 +1,171 @@ +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) +} + +// ToDHCPLease converts a lease to a DHCP lease. +func (l *Lease) ToDHCPLease() *types.DHCPLease { + lease := &l.DHCPLease + lease.DHCPClient = "udhcpc" + return lease +} + +// 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(obj *Lease, str string) error { + lease := &obj.DHCPLease + + // 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 + } + + obj.setIsEmpty(valuesParsed) + + return nil +} diff --git a/internal/udhcpc/parser_test.go b/pkg/nmlite/udhcpc/parser_test.go similarity index 100% rename from internal/udhcpc/parser_test.go rename to pkg/nmlite/udhcpc/parser_test.go diff --git a/internal/udhcpc/proc.go b/pkg/nmlite/udhcpc/proc.go similarity index 100% rename from internal/udhcpc/proc.go rename to pkg/nmlite/udhcpc/proc.go diff --git a/internal/udhcpc/udhcpc.go b/pkg/nmlite/udhcpc/udhcpc.go similarity index 77% rename from internal/udhcpc/udhcpc.go rename to pkg/nmlite/udhcpc/udhcpc.go index 7b4d6e4d..19ce2cb5 100644 --- a/internal/udhcpc/udhcpc.go +++ b/pkg/nmlite/udhcpc/udhcpc.go @@ -6,9 +6,13 @@ import ( "os" "path/filepath" "reflect" + "time" + "github.com/jetkvm/kvm/internal/sync" + "github.com/fsnotify/fsnotify" + "github.com/jetkvm/kvm/internal/network/types" "github.com/rs/zerolog" ) @@ -18,20 +22,22 @@ const ( ) type DHCPClient struct { + types.DHCPClient InterfaceName string leaseFile string pidFile string lease *Lease logger *zerolog.Logger process *os.Process - onLeaseChange func(lease *Lease) + runOnce sync.Once + onLeaseChange func(lease *types.DHCPLease) } type DHCPClientOptions struct { InterfaceName string PidFile string Logger *zerolog.Logger - OnLeaseChange func(lease *Lease) + OnLeaseChange func(lease *types.DHCPLease) } var defaultLogger = zerolog.New(os.Stdout).Level(zerolog.InfoLevel) @@ -67,8 +73,8 @@ func (c *DHCPClient) getWatchPaths() []string { } // Run starts the DHCP client and watches the lease file for changes. -// this isn't a blocking call, and the lease file is reloaded when a change is detected. -func (c *DHCPClient) Run() error { +// this is a blocking call. +func (c *DHCPClient) run() error { err := c.loadLeaseFile() if err != nil && !errors.Is(err, os.ErrNotExist) { return err @@ -125,7 +131,7 @@ func (c *DHCPClient) Run() error { // c.logger.Error().Msg("udhcpc process not found") // } - // block the goroutine until the lease file is updated + // block the goroutine <-make(chan struct{}) return nil @@ -182,7 +188,7 @@ func (c *DHCPClient) loadLeaseFile() error { Msg("current dhcp lease expiry time calculated") } - c.onLeaseChange(lease) + c.onLeaseChange(lease.ToDHCPLease()) c.logger.Info(). Str("ip", lease.IPAddress.String()). @@ -196,3 +202,47 @@ func (c *DHCPClient) loadLeaseFile() error { func (c *DHCPClient) GetLease() *Lease { return c.lease } + +func (c *DHCPClient) Domain() string { + return c.lease.Domain +} + +func (c *DHCPClient) Lease4() *types.DHCPLease { + if c.lease == nil { + return nil + } + return c.lease.ToDHCPLease() +} + +func (c *DHCPClient) Lease6() *types.DHCPLease { + // TODO: implement + return nil +} + +func (c *DHCPClient) SetIPv4(enabled bool) { + // TODO: implement +} + +func (c *DHCPClient) SetIPv6(enabled bool) { + // TODO: implement +} + +func (c *DHCPClient) SetOnLeaseChange(callback func(lease *types.DHCPLease)) { + c.onLeaseChange = callback +} + +func (c *DHCPClient) Start() error { + c.runOnce.Do(func() { + go func() { + err := c.run() + if err != nil { + c.logger.Error().Err(err).Msg("failed to run udhcpc") + } + }() + }) + return nil +} + +func (c *DHCPClient) Stop() error { + return c.KillProcess() // udhcpc already has KillProcess() +} diff --git a/pkg/nmlite/utils.go b/pkg/nmlite/utils.go new file mode 100644 index 00000000..49ed0078 --- /dev/null +++ b/pkg/nmlite/utils.go @@ -0,0 +1,76 @@ +package nmlite + +import ( + "sort" + "time" + + "github.com/jetkvm/kvm/internal/network/types" +) + +func lifetimeToTime(lifetime int) *time.Time { + if lifetime == 0 { + return nil + } + + // Check for infinite lifetime (0xFFFFFFFF = 4294967295) + // This is used for static/permanent addresses + // Use uint32 to avoid int overflow on 32-bit systems + const infiniteLifetime uint32 = 0xFFFFFFFF + if uint32(lifetime) == infiniteLifetime || lifetime < 0 { + return nil // Infinite lifetime - no expiration + } + + // For finite lifetimes (SLAAC addresses) + t := time.Now().Add(time.Duration(lifetime) * time.Second) + return &t +} + +func sortAndCompareStringSlices(a, b []string) bool { + if len(a) != len(b) { + return false + } + + sort.Strings(a) + sort.Strings(b) + + for i := range a { + if a[i] != b[i] { + return false + } + } + + return true +} + +func sortAndCompareIPv6AddressSlices(a, b []types.IPv6Address) bool { + if len(a) != len(b) { + return false + } + + sort.SliceStable(a, func(i, j int) bool { + return a[i].Address.String() < b[j].Address.String() + }) + sort.SliceStable(b, func(i, j int) bool { + return b[i].Address.String() < a[j].Address.String() + }) + + for i := range a { + if a[i].Address.String() != b[i].Address.String() { + return false + } + + if a[i].Prefix.String() != b[i].Prefix.String() { + return false + } + + if a[i].Flags != b[i].Flags { + return false + } + + // we don't compare the lifetimes because they are not always same + if a[i].Scope != b[i].Scope { + return false + } + } + return true +} diff --git a/scripts/dev_deploy.sh b/scripts/dev_deploy.sh index 1ff9296b..d99f6974 100755 --- a/scripts/dev_deploy.sh +++ b/scripts/dev_deploy.sh @@ -17,6 +17,7 @@ show_help() { echo " --skip-ui-build Skip frontend/UI build" echo " --skip-native-build Skip native build" echo " --disable-docker Disable docker build" + echo " --enable-sync-trace Enable sync trace (do not use in release builds)" echo " -i, --install Build for release and install the app" echo " --help Display this help message" echo @@ -32,6 +33,7 @@ REMOTE_PATH="/userdata/jetkvm/bin" SKIP_UI_BUILD=false SKIP_UI_BUILD_RELEASE=0 SKIP_NATIVE_BUILD=0 +ENABLE_SYNC_TRACE=0 RESET_USB_HID_DEVICE=false LOG_TRACE_SCOPES="${LOG_TRACE_SCOPES:-jetkvm,cloud,websocket,native,jsonrpc}" RUN_GO_TESTS=false @@ -64,6 +66,11 @@ while [[ $# -gt 0 ]]; do RESET_USB_HID_DEVICE=true shift ;; + --enable-sync-trace) + ENABLE_SYNC_TRACE=1 + LOG_TRACE_SCOPES="${LOG_TRACE_SCOPES},synctrace" + shift + ;; --disable-docker) BUILD_IN_DOCKER=false shift @@ -180,7 +187,10 @@ fi if [ "$INSTALL_APP" = true ] then msg_info "▶ Building release binary" - do_make build_release SKIP_NATIVE_IF_EXISTS=${SKIP_NATIVE_BUILD} SKIP_UI_BUILD=${SKIP_UI_BUILD_RELEASE} + do_make build_release \ + SKIP_NATIVE_IF_EXISTS=${SKIP_NATIVE_BUILD} \ + SKIP_UI_BUILD=${SKIP_UI_BUILD_RELEASE} \ + ENABLE_SYNC_TRACE=${ENABLE_SYNC_TRACE} # Copy the binary to the remote host as if we were the OTA updater. ssh "${REMOTE_USER}@${REMOTE_HOST}" "cat > /userdata/jetkvm/jetkvm_app.update" < bin/jetkvm_app @@ -189,7 +199,10 @@ then ssh "${REMOTE_USER}@${REMOTE_HOST}" "reboot" else msg_info "▶ Building development binary" - do_make build_dev SKIP_NATIVE_IF_EXISTS=${SKIP_NATIVE_BUILD} SKIP_UI_BUILD=${SKIP_UI_BUILD_RELEASE} + do_make build_dev \ + SKIP_NATIVE_IF_EXISTS=${SKIP_NATIVE_BUILD} \ + SKIP_UI_BUILD=${SKIP_UI_BUILD_RELEASE} \ + ENABLE_SYNC_TRACE=${ENABLE_SYNC_TRACE} # Kill any existing instances of the application ssh "${REMOTE_USER}@${REMOTE_HOST}" "killall jetkvm_app_debug || true" diff --git a/timesync.go b/timesync.go index 7b25fe26..956011b3 100644 --- a/timesync.go +++ b/timesync.go @@ -43,8 +43,20 @@ func initTimeSync() { timeSync = timesync.NewTimeSync(×ync.TimeSyncOptions{ Logger: timesyncLogger, NetworkConfig: config.NetworkConfig, + PreCheckIPv4: func() (bool, error) { + if !networkManager.IPv4Ready() { + return false, nil + } + return true, nil + }, + PreCheckIPv6: func() (bool, error) { + if !networkManager.IPv6Ready() { + return false, nil + } + return true, nil + }, PreCheckFunc: func() (bool, error) { - if !networkState.IsOnline() { + if !networkManager.IsOnline() { return false, nil } return true, nil diff --git a/ui/package-lock.json b/ui/package-lock.json index 7d4faf36..7fb5b1c8 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -28,6 +28,7 @@ "react": "^19.1.1", "react-animate-height": "^3.2.3", "react-dom": "^19.1.1", + "react-hook-form": "^7.65.0", "react-hot-toast": "^2.6.0", "react-icons": "^5.5.0", "react-router": "^7.9.3", @@ -5856,6 +5857,22 @@ "react": "^19.1.1" } }, + "node_modules/react-hook-form": { + "version": "7.65.0", + "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.65.0.tgz", + "integrity": "sha512-xtOzDz063WcXvGWaHgLNrNzlsdFgtUWcb32E6WFaGTd7kPZG3EeDusjdZfUsPwKCKVXy1ZlntifaHZ4l8pAsmw==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/react-hook-form" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17 || ^18 || ^19" + } + }, "node_modules/react-hot-toast": { "version": "2.6.0", "resolved": "https://registry.npmjs.org/react-hot-toast/-/react-hot-toast-2.6.0.tgz", diff --git a/ui/package.json b/ui/package.json index 412b407c..d7feef6d 100644 --- a/ui/package.json +++ b/ui/package.json @@ -40,6 +40,7 @@ "react-animate-height": "^3.2.3", "react-dom": "^19.1.1", "react-hot-toast": "^2.6.0", + "react-hook-form": "^7.65.0", "react-icons": "^5.5.0", "react-router": "^7.9.3", "react-simple-keyboard": "^3.8.125", diff --git a/ui/src/components/ConfirmDialog.tsx b/ui/src/components/ConfirmDialog.tsx index f6a39231..82907601 100644 --- a/ui/src/components/ConfirmDialog.tsx +++ b/ui/src/components/ConfirmDialog.tsx @@ -1,8 +1,5 @@ -import { - CheckCircleIcon, - ExclamationTriangleIcon, - InformationCircleIcon, -} from "@heroicons/react/24/outline"; +import { CloseButton } from "@headlessui/react"; +import { LuCircleAlert, LuInfo, LuTriangleAlert } from "react-icons/lu"; import { Button } from "@/components/Button"; import Modal from "@/components/Modal"; @@ -24,27 +21,23 @@ interface ConfirmDialogProps { const variantConfig = { danger: { - icon: ExclamationTriangleIcon, - iconClass: "text-red-600", - iconBgClass: "bg-red-100", + icon: LuCircleAlert, + iconClass: "text-red-600 dark:text-red-400", buttonTheme: "danger", }, success: { - icon: CheckCircleIcon, - iconClass: "text-green-600", - iconBgClass: "bg-green-100", + icon: LuCircleAlert, + iconClass: "text-emerald-600 dark:text-emerald-400", buttonTheme: "primary", }, warning: { - icon: ExclamationTriangleIcon, - iconClass: "text-yellow-600", - iconBgClass: "bg-yellow-100", - buttonTheme: "lightDanger", + icon: LuTriangleAlert, + iconClass: "text-amber-600 dark:text-amber-400", + buttonTheme: "primary", }, info: { - icon: InformationCircleIcon, - iconClass: "text-blue-600", - iconBgClass: "bg-blue-100", + icon: LuInfo, + iconClass: "text-slate-700 dark:text-slate-300", buttonTheme: "primary", }, } as Record< @@ -52,7 +45,6 @@ const variantConfig = { { icon: React.ElementType; iconClass: string; - iconBgClass: string; buttonTheme: "danger" | "primary" | "blank" | "light" | "lightDanger"; } >; @@ -68,47 +60,50 @@ export function ConfirmDialog({ onConfirm, isConfirming = false, }: ConfirmDialogProps) { - const { icon: Icon, iconClass, iconBgClass, buttonTheme } = variantConfig[variant]; + const { icon: Icon, iconClass, buttonTheme } = variantConfig[variant]; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Escape") { + e.stopPropagation(); + onClose(); + } + }; return ( - -
-
-
-
-
-
-
-

- {title} -

-
- {description} +
+ +
+
+
+
+
-
-
- {cancelText && ( -
-
- + +
); -} \ No newline at end of file +} diff --git a/ui/src/components/DhcpLeaseCard.tsx b/ui/src/components/DhcpLeaseCard.tsx index 8a6e59c1..f0826c7a 100644 --- a/ui/src/components/DhcpLeaseCard.tsx +++ b/ui/src/components/DhcpLeaseCard.tsx @@ -5,20 +5,47 @@ import { GridCard } from "@/components/Card"; import { LifeTimeLabel } from "@/routes/devices.$id.settings.network"; import { NetworkState } from "@/hooks/stores"; +import EmptyCard from "./EmptyCard"; + export default function DhcpLeaseCard({ networkState, setShowRenewLeaseConfirm, }: { - networkState: NetworkState; + networkState: NetworkState | null; setShowRenewLeaseConfirm: (show: boolean) => void; }) { + const isDhcpLeaseEmpty = Object.keys(networkState?.dhcp_lease || {}).length === 0; + + if (isDhcpLeaseEmpty) { + return ( + + ); + } + return ( -
+
-

- DHCP Lease Information -

+
+

+ DHCP Lease Information +

+ +
+
+
@@ -44,24 +71,15 @@ export default function DhcpLeaseCard({
)} - {networkState?.dhcp_lease?.dns && ( + {networkState?.dhcp_lease?.dns_servers && (
DNS Servers - {networkState?.dhcp_lease?.dns.map(dns =>
{dns}
)} -
-
- )} - - {networkState?.dhcp_lease?.broadcast && ( -
- - Broadcast - - - {networkState?.dhcp_lease?.broadcast} + {networkState?.dhcp_lease?.dns_servers.map(dns => ( +
{dns}
+ ))}
)} @@ -142,6 +160,17 @@ export default function DhcpLeaseCard({
)} + {networkState?.dhcp_lease?.broadcast && ( +
+ + Broadcast + + + {networkState?.dhcp_lease?.broadcast} + +
+ )} + {networkState?.dhcp_lease?.mtu && (
MTU @@ -192,18 +221,14 @@ export default function DhcpLeaseCard({
)} -
-
-
-
diff --git a/ui/src/components/Ipv6NetworkCard.tsx b/ui/src/components/Ipv6NetworkCard.tsx index 0cfacc6d..a2a3d4da 100644 --- a/ui/src/components/Ipv6NetworkCard.tsx +++ b/ui/src/components/Ipv6NetworkCard.tsx @@ -1,12 +1,26 @@ +import { cx } from "@/cva.config"; + import { NetworkState } from "../hooks/stores"; import { LifeTimeLabel } from "../routes/devices.$id.settings.network"; import { GridCard } from "./Card"; +export function FlagLabel({ flag, className }: { flag: string, className?: string }) { + const classes = cx( + "ml-2 rounded-sm bg-red-500 px-2 py-1 text-[10px] font-medium leading-none text-white dark:border", + "bg-red-500 text-white dark:border-red-700 dark:bg-red-800 dark:text-red-50", + className, + ); + + return + {flag} + +} + export default function Ipv6NetworkCard({ networkState, }: { - networkState: NetworkState; + networkState: NetworkState | undefined; }) { return ( @@ -17,72 +31,82 @@ export default function Ipv6NetworkCard({
- {networkState?.ipv6_link_local && ( -
- - Link-local - - - {networkState?.ipv6_link_local} - -
- )} +
+ + Link-local + + + {networkState?.ipv6_link_local} + +
+
+ + Gateway + + + {networkState?.ipv6_gateway} + +
{networkState?.ipv6_addresses && networkState?.ipv6_addresses.length > 0 && (

IPv6 Addresses

- {networkState.ipv6_addresses.map( - addr => ( -
-
-
- - Address + {networkState.ipv6_addresses.map(addr => ( +
+
+
+ + Address + + + {addr.address} + + {addr.flag_deprecated ? : null} + {addr.flag_dad_failed ? : null} - {addr.address} -
- - {addr.valid_lifetime && ( -
- - Valid Lifetime - - - {addr.valid_lifetime === "" ? ( - - N/A - - ) : ( - - )} - -
- )} - {addr.preferred_lifetime && ( -
- - Preferred Lifetime - - - {addr.preferred_lifetime === "" ? ( - - N/A - - ) : ( - - )} - -
- )} +
+ + {addr.valid_lifetime && ( +
+ + Valid Lifetime + + + {addr.valid_lifetime === "" ? ( + + N/A + + ) : ( + + )} + +
+ )} + {addr.preferred_lifetime && ( +
+ + Preferred Lifetime + + + {addr.preferred_lifetime === "" ? ( + + N/A + + ) : ( + + )} + +
+ )}
- ), - )} +
+ ))}
)}
diff --git a/ui/src/components/SettingsPageheader.tsx b/ui/src/components/SettingsPageheader.tsx index a7e26211..c63344c3 100644 --- a/ui/src/components/SettingsPageheader.tsx +++ b/ui/src/components/SettingsPageheader.tsx @@ -3,14 +3,19 @@ import { ReactNode } from "react"; export function SettingsPageHeader({ title, description, + action, }: { title: string | ReactNode; description: string | ReactNode; + action?: ReactNode; }) { return ( -
-

{title}

-
{description}
+
+
+

{title}

+
{description}
+
+ {action &&
{action}
}
); -} +} \ No newline at end of file diff --git a/ui/src/components/StaticIpv4Card.tsx b/ui/src/components/StaticIpv4Card.tsx new file mode 100644 index 00000000..c8863ace --- /dev/null +++ b/ui/src/components/StaticIpv4Card.tsx @@ -0,0 +1,137 @@ +import { LuPlus, LuX } from "react-icons/lu"; +import { useFieldArray, useFormContext } from "react-hook-form"; +import { useEffect } from "react"; +import validator from "validator"; +import { cx } from "cva"; + +import { GridCard } from "@/components/Card"; +import { Button } from "@/components/Button"; +import { InputFieldWithLabel } from "@/components/InputField"; +import { NetworkSettings } from "@/hooks/stores"; +import { netMaskFromCidr4 } from "@/utils/ip"; + +export default function StaticIpv4Card() { + const formMethods = useFormContext(); + const { register, formState, watch, setValue } = formMethods; + + const { fields, append, remove } = useFieldArray({ name: "ipv4_static.dns" }); + + useEffect(() => { + if (fields.length === 0) append(""); + }, [append, fields.length]); + + const dns = watch("ipv4_static.dns"); + + const ipv4StaticAddress = watch("ipv4_static.address"); + const hideSubnetMask = ipv4StaticAddress?.includes("/"); + useEffect(() => { + const parts = ipv4StaticAddress?.split("/", 2); + if (parts?.length !== 2) return; + + const cidrNotation = parseInt(parts?.[1] ?? ""); + if (isNaN(cidrNotation) || cidrNotation < 0 || cidrNotation > 32) return; + + const mask = netMaskFromCidr4(cidrNotation); + setValue("ipv4_static.netmask", mask); + }, [ipv4StaticAddress, setValue]); + + const validate = (value: string) => { + if (!validator.isIP(value)) return "Invalid IP address"; + return true; + }; + + const validateIsIPOrCIDR4 = (value: string) => { + if (!validator.isIP(value, 4) && !validator.isIPRange(value, 4)) return "Invalid IP address or CIDR notation"; + return true; + }; + + return ( + +
+
+

+ Static IPv4 Configuration +

+ +
+ validateIsIPOrCIDR4(value ?? "") + })} + error={formState.errors.ipv4_static?.address?.message} + /> + + {!hideSubnetMask && validate(value ?? "") })} + error={formState.errors.ipv4_static?.netmask?.message} + />} +
+ + validate(value ?? "") })} + error={formState.errors.ipv4_static?.gateway?.message} + /> + + {/* DNS server fields */} +
+ {fields.map((dns, index) => { + return ( +
+
+
+ validate(value ?? "") } + )} + error={formState.errors.ipv4_static?.dns?.[index]?.message} + /> +
+ {index > 0 && ( +
+
+ )} +
+
+ ); + })} +
+ +
+
+
+ ); +} diff --git a/ui/src/components/StaticIpv6Card.tsx b/ui/src/components/StaticIpv6Card.tsx new file mode 100644 index 00000000..b9194e8c --- /dev/null +++ b/ui/src/components/StaticIpv6Card.tsx @@ -0,0 +1,117 @@ +import { LuPlus, LuX } from "react-icons/lu"; +import { useFieldArray, useFormContext } from "react-hook-form"; +import validator from "validator"; +import { useEffect } from "react"; + +import { GridCard } from "@/components/Card"; +import { Button } from "@/components/Button"; +import { InputFieldWithLabel } from "@/components/InputField"; +import { NetworkSettings } from "@/hooks/stores"; + +export default function StaticIpv6Card() { + const formMethods = useFormContext(); + const { register, formState, watch } = formMethods; + + const { fields, append, remove } = useFieldArray({ name: "ipv6_static.dns" }); + + useEffect(() => { + if (fields.length === 0) append(""); + }, [append, fields.length]); + + const dns = watch("ipv6_static.dns"); + + const cidrValidation = (value: string) => { + if (value === "") return true; + + // Check if it's a valid IPv6 address with CIDR notation + const parts = value.split("/"); + if (parts.length !== 2) return "Please use CIDR notation (e.g., 2001:db8::1/64)"; + + const [address, prefix] = parts; + if (!validator.isIP(address, 6)) return "Invalid IPv6 address"; + const prefixNum = parseInt(prefix); + if (isNaN(prefixNum) || prefixNum < 0 || prefixNum > 128) { + return "Prefix must be between 0 and 128"; + } + + return true; + }; + + const ipv6Validation = (value: string) => { + if (!validator.isIP(value, 6)) return "Invalid IPv6 address"; + return true; + }; + + return ( + +
+
+

+ Static IPv6 Configuration +

+ + cidrValidation(value ?? "") })} + error={formState.errors.ipv6_static?.prefix?.message} + /> + + ipv6Validation(value ?? "") })} + error={formState.errors.ipv6_static?.gateway?.message} + /> + + {/* DNS server fields */} +
+ {fields.map((dns, index) => { + return ( +
+
+
+ ipv6Validation(value ?? "") })} + error={formState.errors.ipv6_static?.dns?.[index]?.message} + /> +
+ {index > 0 && ( +
+
+ )} +
+
+ ); + })} +
+ +
+
+
+ ); +} diff --git a/ui/src/components/VideoOverlay.tsx b/ui/src/components/VideoOverlay.tsx index 1c59e788..4493d633 100644 --- a/ui/src/components/VideoOverlay.tsx +++ b/ui/src/components/VideoOverlay.tsx @@ -1,4 +1,4 @@ -import React from "react"; +import React, { useEffect, useState, useRef } from "react"; import { ExclamationTriangleIcon } from "@heroicons/react/24/solid"; import { ArrowPathIcon, ArrowRightIcon } from "@heroicons/react/16/solid"; import { motion, AnimatePresence } from "framer-motion"; @@ -8,6 +8,11 @@ import { BsMouseFill } from "react-icons/bs"; import { Button, LinkButton } from "@components/Button"; import LoadingSpinner from "@components/LoadingSpinner"; import Card, { GridCard } from "@components/Card"; +import { useRTCStore, PostRebootAction } from "@/hooks/stores"; +import LogoBlue from "@/assets/logo-blue.svg"; +import LogoWhite from "@/assets/logo-white.svg"; +import { isOnDevice } from "@/main"; + interface OverlayContentProps { readonly children: React.ReactNode; @@ -392,3 +397,184 @@ export function PointerLockBar({ show }: PointerLockBarProps) { ); } + +interface RebootingOverlayProps { + readonly show: boolean; + readonly postRebootAction: PostRebootAction; +} + +export function RebootingOverlay({ show, postRebootAction }: RebootingOverlayProps) { + const { peerConnectionState } = useRTCStore(); + + // Check if we've already seen the connection drop (confirms reboot actually started) + const [hasSeenDisconnect, setHasSeenDisconnect] = useState( + ['disconnected', 'closed', 'failed'].includes(peerConnectionState ?? '') + ); + + // Track if we've timed out + const [hasTimedOut, setHasTimedOut] = useState(false); + + // Monitor for disconnect after reboot is initiated + useEffect(() => { + if (!show) return; + if (hasSeenDisconnect) return; + + if (['disconnected', 'closed', 'failed'].includes(peerConnectionState ?? '')) { + console.log('hasSeenDisconnect', hasSeenDisconnect); + setHasSeenDisconnect(true); + } + }, [show, peerConnectionState, hasSeenDisconnect]); + + // Set timeout after 30 seconds + useEffect(() => { + if (!show) { + setHasTimedOut(false); + return; + } + + const timeoutId = setTimeout(() => { + setHasTimedOut(true); + }, 30 * 1000); + + return () => { + clearTimeout(timeoutId); + }; + }, [show]); + + + // Poll suggested IP in device mode to detect when it's available + const abortControllerRef = useRef(null); + const isFetchingRef = useRef(false); + + useEffect(() => { + // Only run in device mode with a postRebootAction + if (!isOnDevice || !postRebootAction || !show || !hasSeenDisconnect) { + return; + } + + const checkPostRebootHealth = async () => { + // Don't start a new fetch if one is already in progress + if (isFetchingRef.current) { + return; + } + + // Cancel any pending fetch + if (abortControllerRef.current) { + abortControllerRef.current.abort(); + } + + // Create new abort controller for this fetch + const abortController = new AbortController(); + abortControllerRef.current = abortController; + isFetchingRef.current = true; + + console.log('Checking post-reboot health endpoint:', postRebootAction.healthCheck); + const timeoutId = window.setTimeout(() => abortController.abort(), 2000); + try { + const response = await fetch( + postRebootAction.healthCheck, + { signal: abortController.signal, } + ); + + if (response.ok) { + // Device is available, redirect to the specified URL + console.log('Device is available, redirecting to:', postRebootAction.redirectUrl); + window.location.href = postRebootAction.redirectUrl; + } + } catch (err) { + // Ignore errors - they're expected while device is rebooting + // Only log if it's not an abort error + if (err instanceof Error && err.name !== 'AbortError') { + console.debug('Error checking post-reboot health:', err); + } + } finally { + clearTimeout(timeoutId); + isFetchingRef.current = false; + } + }; + + // Start interval (check every 2 seconds) + const intervalId = setInterval(checkPostRebootHealth, 2000); + + // Also check immediately + checkPostRebootHealth(); + + // Cleanup on unmount or when dependencies change + return () => { + clearInterval(intervalId); + if (abortControllerRef.current) { + abortControllerRef.current.abort(); + } + isFetchingRef.current = false; + }; + }, [show, postRebootAction, hasTimedOut, hasSeenDisconnect]); + + return ( + + {show && ( + + + +
+
+ + +
+
+
+
+

{hasTimedOut ? "Unable to Reconnect" : "Device is Rebooting"}

+

+ {hasTimedOut ? ( + <> + The device may have restarted with a different IP address. Check the JetKVM's physical display to find the current IP address and reconnect. + + ) : ( + <> + Please wait while the device restarts. This usually takes 20-30 seconds. + + + )} +

+
+
+ +
+ {!hasTimedOut ? ( + <> + +

+ Waiting for device to restart... +

+ + ) : ( +
+
+ +

+ Automatic Reconnection Timed Out +

+
+
+ )} +
+
+
+
+
+
+
+
+ )} +
+ ); +} diff --git a/ui/src/components/WebRTCVideo.tsx b/ui/src/components/WebRTCVideo.tsx index 64452bf8..1ce25fe2 100644 --- a/ui/src/components/WebRTCVideo.tsx +++ b/ui/src/components/WebRTCVideo.tsx @@ -23,7 +23,7 @@ import { PointerLockBar, } from "./VideoOverlay"; -export default function WebRTCVideo() { +export default function WebRTCVideo({ hasConnectionIssues }: { hasConnectionIssues: boolean }) { // Video and stream related refs and states const videoElm = useRef(null); const { mediaStream, peerConnectionState } = useRTCStore(); @@ -527,9 +527,10 @@ export default function WebRTCVideo() { "max-h-full min-h-[384px] max-w-full min-w-[512px] bg-black/50 object-contain transition-all duration-1000", { "cursor-none": settings.isCursorHidden, - "opacity-0": + "!opacity-0": isVideoLoading || hdmiError || + hasConnectionIssues || peerConnectionState !== "connected", "opacity-60!": showPointerLockBar, "animate-slideUpFade border border-slate-800/30 shadow-xs dark:border-slate-300/20": @@ -537,7 +538,7 @@ export default function WebRTCVideo() { }, )} /> - {peerConnection?.connectionState == "connected" && ( + {peerConnection?.connectionState == "connected" && !hasConnectionIssues && (
{ + if (!text) return false; + + let success = false; + + if (navigator.clipboard && window.isSecureContext) { + try { + await navigator.clipboard.writeText(text); + success = true; + } catch (err) { + console.warn("Clipboard API failed:", err); + } + } + + // Fallback for insecure contexts + if (!success) { + const textarea = document.createElement("textarea"); + textarea.value = text; + textarea.style.position = "fixed"; + textarea.style.opacity = "0"; + document.body.appendChild(textarea); + textarea.focus(); + textarea.select(); + + try { + success = document.execCommand("copy"); + } catch (err) { + console.error("Fallback copy failed:", err); + success = false; + } finally { + document.body.removeChild(textarea); + } + } + + setIsCopied(success); + if (success && resetInterval > 0) { + setTimeout(() => setIsCopied(false), resetInterval); + } + + return success; + }, [resetInterval]); + + return { copy, isCopied }; +} diff --git a/ui/src/hooks/stores.ts b/ui/src/hooks/stores.ts index bfbbb26e..488bca5e 100644 --- a/ui/src/hooks/stores.ts +++ b/ui/src/hooks/stores.ts @@ -19,6 +19,11 @@ interface JsonRpcResponse { id: number | string | null; } +export type PostRebootAction = { + healthCheck: string; + redirectUrl: string; +} | null; + // Utility function to append stats to a Map const appendStatToMap = ( stat: T, @@ -69,11 +74,16 @@ export interface UIState { terminalType: AvailableTerminalTypes; setTerminalType: (type: UIState["terminalType"]) => void; + + rebootState: { isRebooting: boolean; postRebootAction: PostRebootAction } | null; + setRebootState: ( + state: { isRebooting: boolean; postRebootAction: PostRebootAction } | null, + ) => void; } export const useUiStore = create(set => ({ terminalType: "none", - setTerminalType: (type: UIState["terminalType"]) => set({ terminalType: type }), + setTerminalType: (type: UIState["terminalType"]) => set({ terminalType: type }), sidebarView: null, setSidebarView: (view: AvailableSidebarViews | null) => set({ sidebarView: view }), @@ -82,7 +92,8 @@ export const useUiStore = create(set => ({ setDisableVideoFocusTrap: (enabled: boolean) => set({ disableVideoFocusTrap: enabled }), isWakeOnLanModalVisible: false, - setWakeOnLanModalVisibility: (enabled: boolean) => set({ isWakeOnLanModalVisible: enabled }), + setWakeOnLanModalVisibility: (enabled: boolean) => + set({ isWakeOnLanModalVisible: enabled }), toggleSidebarView: view => set(state => { @@ -96,6 +107,9 @@ export const useUiStore = create(set => ({ isAttachedVirtualKeyboardVisible: true, setAttachedVirtualKeyboardVisibility: (enabled: boolean) => set({ isAttachedVirtualKeyboardVisible: enabled }), + + rebootState: null, + setRebootState: state => set({ rebootState: state }), })); export interface RTCState { @@ -465,7 +479,7 @@ export interface KeysDownState { keys: number[]; } -export type USBStates = +export type USBStates = | "configured" | "attached" | "not attached" @@ -672,6 +686,7 @@ export interface DhcpLease { timezone?: string; routers?: string[]; dns?: string[]; + dns_servers?: string[]; ntp_servers?: string[]; lpr_servers?: string[]; _time_servers?: string[]; @@ -689,6 +704,7 @@ export interface DhcpLease { message?: string; tftp?: string; bootfile?: string; + dhcp_client?: string; } export interface IPv6Address { @@ -697,6 +713,15 @@ export interface IPv6Address { valid_lifetime: string; preferred_lifetime: string; scope: string; + flags: number; + flag_secondary?: boolean; + flag_permanent?: boolean; + flag_temporary?: boolean; + flag_stable_privacy?: boolean; + flag_deprecated?: boolean; + flag_optimistic?: boolean; + flag_dad_failed?: boolean; + flag_tentative?: boolean; } export interface NetworkState { @@ -707,7 +732,9 @@ export interface NetworkState { ipv6?: string; ipv6_addresses?: IPv6Address[]; ipv6_link_local?: string; + ipv6_gateway?: string; dhcp_lease?: DhcpLease; + hostname?: string; setNetworkState: (state: NetworkState) => void; setDhcpLease: (lease: NetworkState["dhcp_lease"]) => void; @@ -732,12 +759,28 @@ export type TimeSyncMode = | "custom" | "unknown"; +export interface IPv4StaticConfig { + address: string; + netmask: string; + gateway: string; + dns: string[]; +} + +export interface IPv6StaticConfig { + prefix: string; + gateway: string; + dns: string[]; +} + export interface NetworkSettings { - hostname: string; - domain: string; - http_proxy: string; + dhcp_client: string; + hostname: string | null; + domain: string | null; + http_proxy: string | null; ipv4_mode: IPv4Mode; + ipv4_static?: IPv4StaticConfig; ipv6_mode: IPv6Mode; + ipv6_static?: IPv6StaticConfig; lldp_mode: LLDPMode; lldp_tx_tlvs: string[]; mdns_mode: mDNSMode; diff --git a/ui/src/index.css b/ui/src/index.css index b13fc3a1..0e837875 100644 --- a/ui/src/index.css +++ b/ui/src/index.css @@ -109,6 +109,15 @@ transform: translateY(0); } } + + @keyframes fadeInStill { + 0% { + opacity: 0; + } + 100% { + opacity: 1; + } + } @keyframes slideUpFade { 0% { diff --git a/ui/src/routes/devices.$id.settings.general.reboot.tsx b/ui/src/routes/devices.$id.settings.general.reboot.tsx index db0e0530..c3347f14 100644 --- a/ui/src/routes/devices.$id.settings.general.reboot.tsx +++ b/ui/src/routes/devices.$id.settings.general.reboot.tsx @@ -9,13 +9,9 @@ export default function SettingsGeneralRebootRoute() { const { send } = useJsonRpc(); const onConfirmUpdate = useCallback(() => { - // This is where we send the RPC to the golang binary - send("reboot", {force: true}); + send("reboot", { force: true}); }, [send]); - { - /* TODO: Migrate to using URLs instead of the global state. To simplify the refactoring, we'll keep the global state for now. */ - } return navigate("..")} onConfirmUpdate={onConfirmUpdate} />; } diff --git a/ui/src/routes/devices.$id.settings.network.tsx b/ui/src/routes/devices.$id.settings.network.tsx index 5f4dc90f..8fb4fa78 100644 --- a/ui/src/routes/devices.$id.settings.network.tsx +++ b/ui/src/routes/devices.$id.settings.network.tsx @@ -1,46 +1,50 @@ -import { useCallback, useEffect, useRef, useState } from "react"; import dayjs from "dayjs"; import relativeTime from "dayjs/plugin/relativeTime"; -import { LuEthernetPort } from "react-icons/lu"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { FieldValues, FormProvider, useForm } from "react-hook-form"; +import { LuCopy, LuEthernetPort } from "react-icons/lu"; +import validator from "validator"; -import { - IPv4Mode, - IPv6Mode, - LLDPMode, - mDNSMode, - NetworkSettings, - NetworkState, - TimeSyncMode, - useNetworkStateStore, -} from "@/hooks/stores"; -import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc"; +import { ConfirmDialog } from "@/components/ConfirmDialog"; +import { SelectMenuBasic } from "@/components/SelectMenuBasic"; +import { SettingsPageHeader } from "@/components/SettingsPageheader"; +import { NetworkSettings, NetworkState, useNetworkStateStore, useRTCStore } from "@/hooks/stores"; +import notifications from "@/notifications"; +import { getNetworkSettings, getNetworkState } from "@/utils/jsonrpc"; import { Button } from "@components/Button"; import { GridCard } from "@components/Card"; import InputField, { InputFieldWithLabel } from "@components/InputField"; -import { SelectMenuBasic } from "@/components/SelectMenuBasic"; -import { SettingsPageHeader } from "@/components/SettingsPageheader"; -import Fieldset from "@/components/Fieldset"; -import { ConfirmDialog } from "@/components/ConfirmDialog"; -import { SettingsItem } from "@components/SettingsItem"; -import notifications from "@/notifications"; +import { netMaskFromCidr4 } from "@/utils/ip"; -import Ipv6NetworkCard from "../components/Ipv6NetworkCard"; -import EmptyCard from "../components/EmptyCard"; import AutoHeight from "../components/AutoHeight"; import DhcpLeaseCard from "../components/DhcpLeaseCard"; +import EmptyCard from "../components/EmptyCard"; +import Ipv6NetworkCard from "../components/Ipv6NetworkCard"; +import StaticIpv4Card from "../components/StaticIpv4Card"; +import StaticIpv6Card from "../components/StaticIpv6Card"; +import { useJsonRpc } from "../hooks/useJsonRpc"; +import { SettingsItem } from "../components/SettingsItem"; +import { useCopyToClipboard } from "../components/useCopyToClipBoard"; dayjs.extend(relativeTime); -const defaultNetworkSettings: NetworkSettings = { - hostname: "", - http_proxy: "", - domain: "", - ipv4_mode: "unknown", - ipv6_mode: "unknown", - lldp_mode: "unknown", - lldp_tx_tlvs: [], - mdns_mode: "unknown", - time_sync_mode: "unknown", +const resolveOnRtcReady = () => { + return new Promise(resolve => { + // Check if RTC is already connected + const currentState = useRTCStore.getState(); + if (currentState.rpcDataChannel?.readyState === "open") { + // Already connected, fetch data immediately + return resolve(void 0); + } + + // Not connected yet, subscribe to state changes + const unsubscribe = useRTCStore.subscribe(state => { + if (state.rpcDataChannel?.readyState === "open") { + unsubscribe(); // Clean up subscription + return resolve(void 0); + } + }); + }); }; export function LifeTimeLabel({ lifetime }: { lifetime: string }) { @@ -72,418 +76,520 @@ export function LifeTimeLabel({ lifetime }: { lifetime: string }) { export default function SettingsNetworkRoute() { const { send } = useJsonRpc(); - const [networkState, setNetworkState] = useNetworkStateStore(state => [ - state, - state.setNetworkState, - ]); - const [networkSettings, setNetworkSettings] = - useState(defaultNetworkSettings); - - // We use this to determine whether the settings have changed - const firstNetworkSettings = useRef(undefined); - - const [networkSettingsLoaded, setNetworkSettingsLoaded] = useState(false); + const networkState = useNetworkStateStore(state => state); + const setNetworkState = useNetworkStateStore(state => state.setNetworkState); + // Some input needs direct state management. Mostly options that open more details const [customDomain, setCustomDomain] = useState(""); - const [selectedDomainOption, setSelectedDomainOption] = useState("dhcp"); - useEffect(() => { - if (networkSettings.domain && networkSettingsLoaded) { - // Check if the domain is one of the predefined options - const predefinedOptions = ["dhcp", "local"]; - if (predefinedOptions.includes(networkSettings.domain)) { - setSelectedDomainOption(networkSettings.domain); - } else { - setSelectedDomainOption("custom"); - setCustomDomain(networkSettings.domain); - } + // Confirm dialog + const [showRenewLeaseConfirm, setShowRenewLeaseConfirm] = useState(false); + const initialSettingsRef = useRef(null); + + const [showCriticalSettingsConfirm, setShowCriticalSettingsConfirm] = useState(false); + const [stagedSettings, setStagedSettings] = useState(null); + const [criticalChanges, setCriticalChanges] = useState< + { label: string; from: string; to: string }[] + >([]); + + const fetchNetworkData = useCallback(async () => { + try { + console.log("Fetching network data..."); + + const [settings, state] = (await Promise.all([ + getNetworkSettings(), + getNetworkState(), + ])) as [NetworkSettings, NetworkState]; + + setNetworkState(state as NetworkState); + + const settingsWithDefaults = { + ...settings, + + domain: settings.domain || "local", // TODO: null means local domain TRUE????? + mdns_mode: settings.mdns_mode || "disabled", + time_sync_mode: settings.time_sync_mode || "ntp_only", + ipv4_static: { + address: settings.ipv4_static?.address || state.dhcp_lease?.ip || "", + netmask: settings.ipv4_static?.netmask || state.dhcp_lease?.netmask || "", + gateway: settings.ipv4_static?.gateway || state.dhcp_lease?.routers?.[0] || "", + dns: settings.ipv4_static?.dns || state.dhcp_lease?.dns_servers || [], + }, + ipv6_static: { + prefix: settings.ipv6_static?.prefix || state.ipv6_addresses?.[0]?.prefix || "", + gateway: settings.ipv6_static?.gateway || "", + dns: settings.ipv6_static?.dns || [], + }, + }; + + initialSettingsRef.current = settingsWithDefaults; + return { settings: settingsWithDefaults, state }; + } catch (err) { + notifications.error(err instanceof Error ? err.message : "Unknown error"); + throw err; } - }, [networkSettings.domain, networkSettingsLoaded]); + }, [setNetworkState]); - const getNetworkSettings = useCallback(() => { - setNetworkSettingsLoaded(false); - send("getNetworkSettings", {}, (resp: JsonRpcResponse) => { - if ("error" in resp) return; - const networkSettings = resp.result as NetworkSettings; - console.debug("Network settings: ", networkSettings); - setNetworkSettings(networkSettings); + const formMethods = useForm({ + mode: "onBlur", - if (!firstNetworkSettings.current) { - firstNetworkSettings.current = networkSettings; - } - setNetworkSettingsLoaded(true); - }); - }, [send]); + defaultValues: async () => { + console.log("Preparing form default values..."); - const getNetworkState = useCallback(() => { - send("getNetworkState", {}, (resp: JsonRpcResponse) => { - if ("error" in resp) return; - const networkState = resp.result as NetworkState; - console.debug("Network state:", networkState); - setNetworkState(networkState); - }); - }, [send, setNetworkState]); + // Ensure data channel is ready, before fetching network data from the device + await resolveOnRtcReady(); - const setNetworkSettingsRemote = useCallback( - (settings: NetworkSettings) => { - setNetworkSettingsLoaded(false); - send("setNetworkSettings", { settings }, (resp: JsonRpcResponse) => { - if ("error" in resp) { - notifications.error( - "Failed to save network settings: " + - (resp.error.data ? resp.error.data : resp.error.message), - ); - setNetworkSettingsLoaded(true); - return; - } - const networkSettings = resp.result as NetworkSettings; - // We need to update the firstNetworkSettings ref to the new settings so we can use it to determine if the settings have changed - firstNetworkSettings.current = networkSettings; - setNetworkSettings(networkSettings); - getNetworkState(); - setNetworkSettingsLoaded(true); - notifications.success("Network settings saved"); - }); + const { settings } = await fetchNetworkData(); + return settings; }, - [getNetworkState, send], - ); + }); - const handleRenewLease = useCallback(() => { - send("renewDHCPLease", {}, (resp: JsonRpcResponse) => { + const prepareSettings = useCallback((data: FieldValues) => { + return { + ...data, + + // If custom domain option is selected, use the custom domain as value + domain: data.domain === "custom" ? customDomain : data.domain, + } as NetworkSettings; + }, [customDomain]); + + const { register, handleSubmit, watch, formState, reset } = formMethods; + + const onSubmit = useCallback(async (settings: NetworkSettings) => { + if (settings.ipv4_static?.address?.includes("/")) { + const parts = settings.ipv4_static.address.split("/"); + const cidrNotation = parseInt(parts[1]); + if (isNaN(cidrNotation) || cidrNotation < 0 || cidrNotation > 32) { + return notifications.error("Invalid CIDR notation for IPv4 address"); + } + settings.ipv4_static.netmask = netMaskFromCidr4(cidrNotation); + settings.ipv4_static.address = parts[0]; + } + + send("setNetworkSettings", { settings }, async (resp) => { + if ("error" in resp) { + return notifications.error( + resp.error.data ? resp.error.data : resp.error.message, + ); + } else { + // If the settings are saved successfully, fetch the latest network data and reset the form + // We do this so we get all the form state values, for stuff like is the form dirty, etc... + + try { + const networkData = await fetchNetworkData(); + if (!networkData) return + + reset(networkData.settings); + notifications.success("Network settings saved"); + + } catch (error) { + console.error("Failed to fetch network data:", error); + } + } + }); + }, [fetchNetworkData, reset, send]); + + const onSubmitGate = useCallback(async (data: FieldValues) => { + const settings = prepareSettings(data); + const dirty = formState.dirtyFields; + + // Build list of critical changes for display + const changes: { label: string; from: string; to: string }[] = []; + + if (dirty.dhcp_client) { + changes.push({ + label: "DHCP client", + from: initialSettingsRef.current?.dhcp_client as string, + to: data.dhcp_client as string, + }); + } + + if (dirty.ipv4_mode) { + changes.push({ + label: "IPv4 mode", + from: initialSettingsRef.current?.ipv4_mode as string, + to: data.ipv4_mode as string, + }); + } + + if (dirty.ipv4_static?.address) { + changes.push({ + label: "IPv4 address", + from: initialSettingsRef.current?.ipv4_static?.address as string, + to: data.ipv4_static?.address as string, + }); + } + + if (dirty.ipv4_static?.netmask) { + changes.push({ + label: "IPv4 netmask", + from: initialSettingsRef.current?.ipv4_static?.netmask as string, + to: data.ipv4_static?.netmask as string, + }); + } + + if (dirty.ipv4_static?.gateway) { + changes.push({ + label: "IPv4 gateway", + from: initialSettingsRef.current?.ipv4_static?.gateway as string, + to: data.ipv4_static?.gateway as string, + }); + } + + if (dirty.ipv4_static?.dns) { + changes.push({ + label: "IPv4 DNS", + from: initialSettingsRef.current?.ipv4_static?.dns.join(", ").toString() ?? "", + to: data.ipv4_static?.dns.join(", ").toString() ?? "", + }); + } + + if (dirty.ipv6_mode) { + changes.push({ + label: "IPv6 mode", + from: initialSettingsRef.current?.ipv6_mode as string, + to: data.ipv6_mode as string, + }); + } + + // If no critical fields are changed, save immediately + if (changes.length === 0) return onSubmit(settings); + + // Show confirmation dialog for critical changes + setStagedSettings(settings); + setCriticalChanges(changes); + setShowCriticalSettingsConfirm(true); + }, [prepareSettings, formState.dirtyFields, onSubmit]); + + const ipv4mode = watch("ipv4_mode"); + const ipv6mode = watch("ipv6_mode"); + + const onDhcpLeaseRenew = () => { + send("renewDHCPLease", {}, (resp) => { if ("error" in resp) { notifications.error("Failed to renew lease: " + resp.error.message); } else { notifications.success("DHCP lease renewed"); } }); - }, [send]); - - useEffect(() => { - getNetworkState(); - getNetworkSettings(); - }, [getNetworkState, getNetworkSettings]); - - const handleIpv4ModeChange = (value: IPv4Mode | string) => { - setNetworkSettingsRemote({ ...networkSettings, ipv4_mode: value as IPv4Mode }); }; - const handleIpv6ModeChange = (value: IPv6Mode | string) => { - setNetworkSettingsRemote({ ...networkSettings, ipv6_mode: value as IPv6Mode }); - }; - - const handleLldpModeChange = (value: LLDPMode | string) => { - setNetworkSettings({ ...networkSettings, lldp_mode: value as LLDPMode }); - }; - - const handleMdnsModeChange = (value: mDNSMode | string) => { - setNetworkSettings({ ...networkSettings, mdns_mode: value as mDNSMode }); - }; - - const handleTimeSyncModeChange = (value: TimeSyncMode | string) => { - setNetworkSettings({ ...networkSettings, time_sync_mode: value as TimeSyncMode }); - }; - - const handleHostnameChange = (value: string) => { - setNetworkSettings({ ...networkSettings, hostname: value }); - }; - - const handleProxyChange = (value: string) => { - setNetworkSettings({ ...networkSettings, http_proxy: value }); - }; - - const handleDomainChange = (value: string) => { - setNetworkSettings({ ...networkSettings, domain: value }); - }; - - const handleDomainOptionChange = (value: string) => { - setSelectedDomainOption(value); - if (value !== "custom") { - handleDomainChange(value); - } - }; - - const handleCustomDomainChange = (value: string) => { - setCustomDomain(value); - handleDomainChange(value); - }; - - const filterUnknown = useCallback( - (options: { value: string; label: string }[]) => { - if (!networkSettingsLoaded) return options; - return options.filter(option => option.value !== "unknown"); - }, - [networkSettingsLoaded], - ); - - const [showRenewLeaseConfirm, setShowRenewLeaseConfirm] = useState(false); + const { copy } = useCopyToClipboard(); return ( <> -
- -
- - - -
-
- -
-
- { - handleHostnameChange(e.target.value); - }} - /> -
-
-
-
-
- -
-
- { - handleProxyChange(e.target.value); - }} - /> -
-
-
-
- -
-
- -
- handleDomainOptionChange(e.target.value)} - options={[ - { value: "dhcp", label: "DHCP provided" }, - { value: "local", label: ".local" }, - { value: "custom", label: "Custom" }, - ]} - /> -
-
- {selectedDomainOption === "custom" && ( -
- { - setCustomDomain(e.target.value); - handleCustomDomainChange(e.target.value); - }} - /> -
- )} -
+ +
+ +
+
+ + } + />
- +
+ +
+ +
+ {networkState?.mac_address} {" "} +
+
+
+
+ + + + + { + if (value === "" || value === null) return true; + if (!validator.isURL(value || "", { protocols: ["http", "https"] })) { + return "Invalid HTTP proxy URL"; + } + return true; + }, + })} + error={formState.errors.http_proxy?.message} + /> + +
+ +
+ +
+
+ {watch("domain") === "custom" && ( +
+ { + setCustomDomain(e.target.value); + }} + /> +
+ )} +
+ + handleMdnsModeChange(e.target.value)} - options={filterUnknown([ + options={[ { value: "disabled", label: "Disabled" }, { value: "auto", label: "Auto" }, { value: "ipv4_only", label: "IPv4 only" }, { value: "ipv6_only", label: "IPv6 only" }, - ])} + ]} + {...register("mdns_mode")} /> -
- -
{ - handleTimeSyncModeChange(e.target.value); - }} - options={filterUnknown([ - { value: "unknown", label: "..." }, - // { value: "auto", label: "Auto" }, + options={[ { value: "ntp_only", label: "NTP only" }, { value: "ntp_and_http", label: "NTP and HTTP" }, { value: "http_only", label: "HTTP only" }, - // { value: "custom", label: "Custom" }, - ])} + ]} + {...register("time_sync_mode")} /> + + + + + + + + +
+ + {formState.isLoading ? ( + +
+
+
+
+
+
+
+
+
+
+
+
+ + ) : ipv4mode === "static" ? ( + + ) : ipv4mode === "dhcp" && !!formState.dirtyFields.ipv4_mode ? ( + + ) : ipv4mode === "dhcp" ? ( + + ) : ( + + )} + +
+ + + + +
+ + {!networkState ? ( + +
+
+

+ IPv6 Network Information +

+
+
+
+
+
+
+
+ + ) : ipv6mode === "static" ? ( + + ) : ( + + )} + +
+ <> +
+
+
+ + -
+ {/* Critical change confirm */} + { + setShowCriticalSettingsConfirm(false); + if (stagedSettings) onSubmit(stagedSettings); -
+ // Wait for the close animation to finish before resetting the staged settings + setTimeout(() => { + setStagedSettings(null); + setCriticalChanges([]); + }, 500); + }} + onClose={() => { + setShowCriticalSettingsConfirm(false); + }} + isConfirming={formState.isSubmitting} + description={ +
+
+

+ The following network settings will be applied. These changes may require a reboot and cause brief disconnection. +

+
-
- - handleIpv4ModeChange(e.target.value)} - options={filterUnknown([ - { value: "dhcp", label: "DHCP" }, - // { value: "static", label: "Static" }, - ])} - /> - - - {!networkSettingsLoaded && !networkState?.dhcp_lease ? ( - -
-
-

- DHCP Lease Information -

-
-
-
-
+
+
+ Configuration changes +
+
+ {criticalChanges.map((c, idx) => ( +
+ {c.label} +
+ + {c.from || "—"} + + + + + + {c.to} +
-
- - ) : networkState?.dhcp_lease && networkState.dhcp_lease.ip ? ( - - ) : ( - - )} - -
-
- - handleIpv6ModeChange(e.target.value)} - options={filterUnknown([ - { value: "disabled", label: "Disabled" }, - { value: "slaac", label: "SLAAC" }, - // { value: "dhcpv6", label: "DHCPv6" }, - // { value: "slaac_and_dhcpv6", label: "SLAAC and DHCPv6" }, - // { value: "static", label: "Static" }, - // { value: "link_local", label: "Link-local only" }, - ])} - /> - - - {!networkSettingsLoaded && - !(networkState?.ipv6_addresses && networkState.ipv6_addresses.length > 0) ? ( - -
-
-

- IPv6 Information -

-
-
-
-
-
-
-
- - ) : networkState?.ipv6_addresses && networkState.ipv6_addresses.length > 0 ? ( - - ) : ( - - )} - -
-
- - handleLldpModeChange(e.target.value)} - options={filterUnknown([ - { value: "disabled", label: "Disabled" }, - { value: "basic", label: "Basic" }, - { value: "all", label: "All" }, - ])} - /> - -
-
+ ))} +
+
+ +
+ } + /> setShowRenewLeaseConfirm(false)} title="Renew DHCP Lease" - description="This will request a new IP address from your DHCP server. Your device may temporarily lose network connectivity during this process." - variant="danger" + variant="warning" confirmText="Renew Lease" + description={ +

+ This will request a new IP address from your router. The device may briefly + disconnect during the renewal process. +
+
+ If you receive a new IP address,{" "} + you may need to reconnect using the new address. +

+ } onConfirm={() => { - handleRenewLease(); setShowRenewLeaseConfirm(false); + onDhcpLeaseRenew(); }} + onClose={() => setShowRenewLeaseConfirm(false)} /> ); diff --git a/ui/src/routes/devices.$id.tsx b/ui/src/routes/devices.$id.tsx index a1ace077..fcc4e8a8 100644 --- a/ui/src/routes/devices.$id.tsx +++ b/ui/src/routes/devices.$id.tsx @@ -24,6 +24,7 @@ import { KeysDownState, NetworkState, OtaState, + PostRebootAction, USBStates, useHidStore, useNetworkStateStore, @@ -45,6 +46,7 @@ import { ConnectionFailedOverlay, LoadingConnectionOverlay, PeerConnectionDisconnectedOverlay, + RebootingOverlay, } from "@/components/VideoOverlay"; import { useDeviceUiNavigation } from "@/hooks/useAppNavigation"; import { FeatureFlagProvider } from "@/providers/FeatureFlagProvider"; @@ -122,10 +124,10 @@ export default function KvmIdRoute() { const authMode = "authMode" in loaderResp ? loaderResp.authMode : null; const params = useParams() as { id: string }; - const { sidebarView, setSidebarView, disableVideoFocusTrap } = useUiStore(); - const [ queryParams, setQueryParams ] = useSearchParams(); + const { sidebarView, setSidebarView, disableVideoFocusTrap, rebootState, setRebootState } = useUiStore(); + const [queryParams, setQueryParams] = useSearchParams(); - const { + const { peerConnection, setPeerConnection, peerConnectionState, setPeerConnectionState, setMediaStream, @@ -241,7 +243,7 @@ export default function KvmIdRoute() { { heartbeat: true, retryOnError: true, - reconnectAttempts: 15, + reconnectAttempts: 2000, reconnectInterval: 1000, onReconnectStop: () => { console.debug("Reconnect stopped"); @@ -250,8 +252,7 @@ export default function KvmIdRoute() { shouldReconnect(event) { console.debug("[Websocket] shouldReconnect", event); - // TODO: Why true? - return true; + return !isLegacySignalingEnabled.current; }, onClose(event) { @@ -265,6 +266,16 @@ export default function KvmIdRoute() { }, onOpen() { console.debug("[Websocket] onOpen"); + // We want to clear the reboot state when the websocket connection is opened + // Currently the flow is: + // 1. User clicks reboot + // 2. Device sends event 'willReboot' + // 3. We set the reboot state + // 4. Reboot modal is shown + // 5. WS tries to reconnect + // 6. WS reconnects + // 7. This function is called and now we clear the reboot state + setRebootState({ isRebooting: false, postRebootAction: null }); }, onMessage: message => { @@ -340,10 +351,7 @@ export default function KvmIdRoute() { peerConnection.addIceCandidate(candidate); } }, - }, - - // Don't even retry once we declare failure - !connectionFailed && isLegacySignalingEnabled.current === false, + } ); const sendWebRTCSignal = useCallback( @@ -594,13 +602,15 @@ export default function KvmIdRoute() { api.POST(`${CLOUD_API}/webrtc/turn_activity`, { bytesReceived: bytesReceivedDelta, bytesSent: bytesSentDelta, + }).catch(() => { + // we don't care about errors here, but we don't want unhandled promise rejections }); }, 10000); - const { setNetworkState} = useNetworkStateStore(); + const { setNetworkState } = useNetworkStateStore(); const { setHdmiState } = useVideoStore(); - const { - keyboardLedState, setKeyboardLedState, + const { + keyboardLedState, setKeyboardLedState, keysDownState, setKeysDownState, setUsbState, } = useHidStore(); const setHidRpcDisabled = useRTCStore(state => state.setHidRpcDisabled); @@ -666,6 +676,13 @@ export default function KvmIdRoute() { window.location.href = currentUrl.toString(); } } + + if (resp.method === "willReboot") { + const postRebootAction = resp.params as unknown as PostRebootAction; + console.debug("Setting reboot state", postRebootAction); + setRebootState({ isRebooting: true, postRebootAction }); + navigateTo("/"); + } } const { send } = useJsonRpc(onJsonRpcRequest); @@ -756,7 +773,7 @@ export default function KvmIdRoute() { if (location.pathname !== "/other-session") navigateTo("/"); }, [navigateTo, location.pathname]); - const { appVersion, getLocalVersion} = useVersion(); + const { appVersion, getLocalVersion } = useVersion(); useEffect(() => { if (appVersion) return; @@ -765,6 +782,14 @@ export default function KvmIdRoute() { }, [appVersion, getLocalVersion]); const ConnectionStatusElement = useMemo(() => { + const isOtherSession = location.pathname.includes("other-session"); + if (isOtherSession) return null; + + // Rebooting takes priority over connection status + if (rebootState?.isRebooting) { + return ; + } + const hasConnectionFailed = connectionFailed || ["failed", "closed"].includes(peerConnectionState ?? ""); @@ -774,9 +799,6 @@ export default function KvmIdRoute() { const isDisconnected = peerConnectionState === "disconnected"; - const isOtherSession = location.pathname.includes("other-session"); - - if (isOtherSession) return null; if (peerConnectionState === "connected") return null; if (isDisconnected) { return ; @@ -792,14 +814,7 @@ export default function KvmIdRoute() { } return null; - }, [ - connectionFailed, - loadingMessage, - location.pathname, - peerConnection, - peerConnectionState, - setupPeerConnection, - ]); + }, [location.pathname, rebootState?.isRebooting, rebootState?.postRebootAction, connectionFailed, peerConnectionState, peerConnection, setupPeerConnection, loadingMessage]); return ( @@ -841,7 +856,7 @@ export default function KvmIdRoute() { />
- +
{ + const mask = []; + let bitCount = cidr; + for(let i=0; i<4; i++) { + const n = Math.min(bitCount, 8); + mask.push(256 - Math.pow(2, 8-n)); + bitCount -= n; + } + return mask.join('.'); +}; \ No newline at end of file diff --git a/ui/src/utils/jsonrpc.ts b/ui/src/utils/jsonrpc.ts new file mode 100644 index 00000000..ecfa1c4b --- /dev/null +++ b/ui/src/utils/jsonrpc.ts @@ -0,0 +1,103 @@ +import { useRTCStore } from "@/hooks/stores"; + +// JSON-RPC utility for use outside of React components +export interface JsonRpcCallOptions { + method: string; + params?: unknown; + timeout?: number; +} + +export interface JsonRpcCallResponse { + jsonrpc: string; + result?: unknown; + error?: { + code: number; + message: string; + data?: unknown; + }; + id: number | string | null; +} + +let rpcCallCounter = 0; + +export function callJsonRpc(options: JsonRpcCallOptions): Promise { + return new Promise((resolve, reject) => { + // Access the RTC store directly outside of React context + const rpcDataChannel = useRTCStore.getState().rpcDataChannel; + + if (!rpcDataChannel || rpcDataChannel.readyState !== "open") { + reject(new Error("RPC data channel not available")); + return; + } + + rpcCallCounter++; + const requestId = `rpc_${Date.now()}_${rpcCallCounter}`; + + const request = { + jsonrpc: "2.0", + method: options.method, + params: options.params || {}, + id: requestId, + }; + + const timeout = options.timeout || 5000; + let timeoutId: number | undefined; // eslint-disable-line prefer-const + + const messageHandler = (event: MessageEvent) => { + try { + const response = JSON.parse(event.data) as JsonRpcCallResponse; + if (response.id === requestId) { + clearTimeout(timeoutId); + rpcDataChannel.removeEventListener("message", messageHandler); + resolve(response); + } + } catch (error) { + // Ignore parse errors from other messages + } + }; + + timeoutId = setTimeout(() => { + rpcDataChannel.removeEventListener("message", messageHandler); + reject(new Error(`JSON-RPC call timed out after ${timeout}ms`)); + }, timeout); + + rpcDataChannel.addEventListener("message", messageHandler); + rpcDataChannel.send(JSON.stringify(request)); + }); +} + +// Specific network settings API calls +export async function getNetworkSettings() { + const response = await callJsonRpc({ method: "getNetworkSettings" }); + if (response.error) { + throw new Error(response.error.message); + } + return response.result; +} + +export async function setNetworkSettings(settings: unknown) { + const response = await callJsonRpc({ + method: "setNetworkSettings", + params: { settings }, + }); + if (response.error) { + throw new Error(response.error.message); + } + return response.result; +} + +export async function getNetworkState() { + const response = await callJsonRpc({ method: "getNetworkState" }); + if (response.error) { + throw new Error(response.error.message); + } + return response.result; +} + +export async function renewDHCPLease() { + const response = await callJsonRpc({ method: "renewDHCPLease" }); + if (response.error) { + throw new Error(response.error.message); + } + return response.result; +} diff --git a/web.go b/web.go index 45253579..0fd968b8 100644 --- a/web.go +++ b/web.go @@ -725,6 +725,18 @@ func handleDeletePassword(c *gin.Context) { } func handleDeviceStatus(c *gin.Context) { + // Add CORS headers to allow cross-origin requests + // This is safe because device/status is a public endpoint + c.Header("Access-Control-Allow-Origin", "*") + c.Header("Access-Control-Allow-Methods", "GET, OPTIONS") + c.Header("Access-Control-Allow-Headers", "Content-Type") + + // Handle preflight requests + if c.Request.Method == "OPTIONS" { + c.AbortWithStatus(http.StatusNoContent) + return + } + response := DeviceStatus{ IsSetup: config.LocalAuthMode != "", }