diff --git a/.vscode/settings.json b/.vscode/settings.json index a86e6b63..b0e6df67 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -3,5 +3,11 @@ "cva", "cx" ], + "gopls": { + "build.buildFlags": [ + "-tags", + "synctrace" + ] + }, "git.ignoreLimitWarning": true } \ 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/config.go b/config.go index 0b008912..0fff2fcb 100644 --- a/config.go +++ b/config.go @@ -8,7 +8,7 @@ import ( "sync" "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 +78,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 { @@ -161,7 +161,7 @@ var defaultConfig = &Config{ Keyboard: true, MassStorage: true, }, - NetworkConfig: &network.NetworkConfig{}, + NetworkConfig: &types.NetworkConfig{}, DefaultLogLevel: "INFO", } @@ -247,17 +247,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 +281,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..cab87ff3 100644 --- a/display.go +++ b/display.go @@ -27,7 +27,7 @@ const ( ) func switchToMainScreen() { - if networkState.IsUp() { + if networkManager.IsUp() { nativeInstance.SwitchToScreenIfDifferent("home_screen") } else { nativeInstance.SwitchToScreenIfDifferent("no_network_screen") @@ -35,13 +35,13 @@ func switchToMainScreen() { } func updateDisplay() { - nativeInstance.UpdateLabelIfChanged("home_info_ipv4_addr", networkState.IPv4String()) - nativeInstance.UpdateLabelAndChangeVisibility("home_info_ipv6_addr", networkState.IPv6String()) + nativeInstance.UpdateLabelIfChanged("home_info_ipv4_addr", networkManager.IPv4String()) + nativeInstance.UpdateLabelAndChangeVisibility("home_info_ipv6_addr", networkManager.IPv6String()) _, _ = nativeInstance.UIObjHide("menu_btn_network") _, _ = nativeInstance.UIObjHide("menu_btn_access") - nativeInstance.UpdateLabelIfChanged("home_info_mac_addr", networkState.MACString()) + nativeInstance.UpdateLabelIfChanged("home_info_mac_addr", networkManager.MACString()) if usbState == "configured" { nativeInstance.UpdateLabelIfChanged("usb_status_label", "Connected") @@ -59,7 +59,7 @@ func updateDisplay() { } nativeInstance.UpdateLabelIfChanged("cloud_status_label", fmt.Sprintf("%d active", actionSessions)) - if networkState.IsUp() { + if networkManager.IsUp() { nativeInstance.UISetVar("main_screen", "home_screen") nativeInstance.SwitchToScreenIf("home_screen", []string{"no_network_screen", "boot_screen"}) } else { @@ -175,7 +175,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 +184,12 @@ 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()) + nativeInstance.UpdateLabelIfChanged("home_info_mac_addr", networkManager.MACString()) // get cpu info if cpuInfo, err := os.ReadFile("/proc/cpuinfo"); err == nil { @@ -326,11 +325,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 +336,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..73eaa7bd 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,27 +366,68 @@ 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", f.Name, 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", f.Name, val) + } + if valInt < 0 || valInt > 128 { + return fmt.Errorf("field `%s` is not a valid IPv6 prefix length: %s", f.Name, 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("%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("%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("%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) 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/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/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/rpc.go b/internal/network/types/rpc.go new file mode 100644 index 00000000..748d778e --- /dev/null +++ b/internal/network/types/rpc.go @@ -0,0 +1,33 @@ +package types + +import "time" + +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"` +} + +type RpcInterfaceState struct { + InterfaceState + IPv6Addresses []RpcIPv6Address `json:"ipv6_addresses"` +} + +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, + } + } + return &RpcInterfaceState{ + InterfaceState: *s, + IPv6Addresses: addrs, + } +} diff --git a/internal/network/config.go b/internal/network/types/type.go similarity index 50% rename from internal/network/config.go rename to internal/network/types/type.go index e8a8c058..2bee48ac 100644 --- a/internal/network/config.go +++ b/internal/network/types/type.go @@ -1,17 +1,65 @@ -package network +package types import ( - "fmt" "net" "net/http" "net/url" + "slices" "time" "github.com/guregu/null/v6" - "github.com/jetkvm/kvm/internal/mdns" - "golang.org/x/net/idna" + "github.com/vishvananda/netlink" ) +// IPAddress represents a network interface address +type IPAddress struct { + Family int + Address net.IPNet + Gateway net.IP + MTU int + Secondary bool + Permanent bool +} + +func (a *IPAddress) String() string { + return a.Address.String() +} + +func (a *IPAddress) Compare(n netlink.Addr) bool { + if !a.Address.IP.Equal(n.IP) { + return false + } + if slices.Compare(a.Address.Mask, n.IPNet.Mask) != 0 { + return false + } + return true +} + +func (a *IPAddress) NetlinkAddr() netlink.Addr { + return netlink.Addr{ + IPNet: &a.Address, + } +} + +func (a *IPAddress) DefaultRoute(linkIndex int) netlink.Route { + return netlink.Route{ + Dst: nil, + Gw: a.Gateway, + LinkIndex: linkIndex, + } +} + +// ParsedIPConfig represents the parsed IP configuration +type ParsedIPConfig struct { + Addresses []IPAddress + Nameservers []net.IP + SearchList []string + Domain string + MTU int + Interface string +} + +// IPv6Address represents an IPv6 address with lifetime information type IPv6Address struct { Address net.IP `json:"address"` Prefix net.IPNet `json:"prefix"` @@ -20,6 +68,7 @@ type IPv6Address struct { 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 +76,17 @@ 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"` } + +// 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 +97,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 +108,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 +129,47 @@ func (c *NetworkConfig) GetMDNSMode() *mdns.MDNSListenOptions { return listenOptions } -func (s *NetworkConfig) GetTransportProxyFunc() func(*http.Request) (*url.URL, error) { +// MDNSListenOptions represents MDNS listening options +type MDNSListenOptions struct { + IPv4 bool + IPv6 bool +} + +// GetTransportProxyFunc returns a function for HTTP proxy configuration +func (c *NetworkConfig) GetTransportProxyFunc() func(*http.Request) (*url.URL, error) { return func(*http.Request) (*url.URL, error) { - if s.HTTPProxy.String == "" { + if c.HTTPProxy.String == "" { return nil, nil } else { - proxyUrl, _ := url.Parse(s.HTTPProxy.String) + 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 +// InterfaceState represents the current state of a network interface +type InterfaceState struct { + InterfaceName string `json:"interface_name"` + MACAddress string `json:"mac_address"` + Up bool `json:"up"` + Online bool `json:"online"` + IPv4Ready bool `json:"ipv4_ready"` + IPv6Ready bool `json:"ipv6_ready"` + IPv4Address string `json:"ipv4_address,omitempty"` + IPv6Address string `json:"ipv6_address,omitempty"` + IPv6LinkLocal string `json:"ipv6_link_local,omitempty"` + IPv6Gateway string `json:"ipv6_gateway,omitempty"` + IPv4Addresses []string `json:"ipv4_addresses,omitempty"` + IPv6Addresses []IPv6Address `json:"ipv6_addresses,omitempty"` + NTPServers []net.IP `json:"ntp_servers,omitempty"` + DHCPLease4 *DHCPLease `json:"dhcp_lease,omitempty"` + DHCPLease6 *DHCPLease `json:"dhcp_lease6,omitempty"` + LastUpdated time.Time `json:"last_updated"` } -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/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/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..8e251e1b 100644 --- a/mdns.go +++ b/mdns.go @@ -10,10 +10,14 @@ func initMdns() error { m, err := mdns.NewMDNS(&mdns.MDNSOptions{ Logger: logger, LocalNames: []string{ - networkState.GetHostname(), - networkState.GetFQDN(), + "jetkvm", "jetkvm.local", + // networkManager.GetHostname(), + // networkManager.GetFQDN(), + }, + ListenOptions: &mdns.MDNSListenOptions{ + IPv4: config.NetworkConfig.MDNSMode.String != "disabled", + IPv6: config.NetworkConfig.MDNSMode.String != "disabled", }, - ListenOptions: config.NetworkConfig.GetMDNSMode(), }) if err != nil { return err diff --git a/network.go b/network.go index b808d6fe..ce8f60f8 100644 --- a/network.go +++ b/network.go @@ -1,10 +1,13 @@ package kvm import ( + "context" "fmt" - "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 +15,142 @@ const ( ) var ( - networkState *network.NetworkInterfaceState + networkManager *nmlite.NetworkManager ) -func networkStateChanged(isOnline bool) { +type RpcNetworkSettings struct { + types.NetworkConfig +} + +func (s *RpcNetworkSettings) ToNetworkConfig() *types.NetworkConfig { + return &s.NetworkConfig +} + +func toRpcNetworkSettings(config *types.NetworkConfig) *RpcNetworkSettings { + return &RpcNetworkSettings{ + NetworkConfig: *config, + } +} + +func restartMdns() { + if mDNS == nil { + return + } + + _ = mDNS.SetListenOptions(&mdns.MDNSListenOptions{ + IPv4: config.NetworkConfig.MDNSMode.String != "disabled", + IPv6: config.NetworkConfig.MDNSMode.String != "disabled", + }) + _ = mDNS.SetLocalNames([]string{ + networkManager.GetHostname(), + networkManager.GetFQDN(), + }, true) +} + +func triggerTimeSyncOnNetworkStateChange() { + if timeSync == nil { + return + } + + // set the NTP servers from the network manager + if networkManager != nil { + timeSync.SetDhcpNtpAddresses(networkManager.NTPServerStrings()) + } + + // sync time + 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 timeSync != nil { - if networkState != nil { - timeSync.SetDhcpNtpAddresses(networkState.NtpAddressesString()) - } + if currentSession != nil { + writeJSONRPCEvent("networkState", state.ToRpcInterfaceState(), currentSession) + } - if err := timeSync.Sync(); err != nil { - networkLogger.Error().Err(err).Msg("failed to sync time after network state change") - } + 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") + SaveBackupConfig() + + // do not use a pointer to the default config + // it has been already changed during LoadConfig + config.NetworkConfig = &(types.NetworkConfig{}) + SaveConfig() } 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 - } - - 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 + networkManager = nmlite.NewNetworkManager(context.Background(), networkLogger) + networkManager.SetOnInterfaceStateChange(networkStateChanged) + if err := networkManager.AddInterface(NetIfName, config.NetworkConfig); err != nil { + return fmt.Errorf("failed to add interface: %w", err) } - if err := state.Run(); err != nil { - return err - } - - networkState = state - return nil } -func rpcGetNetworkState() network.RpcNetworkState { - return networkState.RpcGetNetworkState() +func rpcGetNetworkState() *types.RpcInterfaceState { + state, _ := networkManager.GetInterfaceState(NetIfName) + return state.ToRpcInterfaceState() } -func rpcGetNetworkSettings() network.RpcNetworkSettings { - return networkState.RpcGetNetworkSettings() +func rpcGetNetworkSettings() *RpcNetworkSettings { + return toRpcNetworkSettings(config.NetworkConfig) } -func rpcSetNetworkSettings(settings network.RpcNetworkSettings) (*network.RpcNetworkSettings, error) { - s := networkState.RpcSetNetworkSettings(settings) +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") + + 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 + return toRpcNetworkSettings(newConfig), nil } func rpcRenewDHCPLease() error { - return networkState.RpcRenewDHCPLease() + return networkManager.RenewDHCPLease(NetIfName) } diff --git a/pkg/nmlite/dhcp.go b/pkg/nmlite/dhcp.go new file mode 100644 index 00000000..000633f0 --- /dev/null +++ b/pkg/nmlite/dhcp.go @@ -0,0 +1,216 @@ +// 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" + "github.com/vishvananda/netlink" +) + +// 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 + link netlink.Link + + // 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, + 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") + dc.client.Renew() + 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") + dc.client.Release() + 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..812fa533 --- /dev/null +++ b/pkg/nmlite/hostname.go @@ -0,0 +1,245 @@ +package nmlite + +import ( + "fmt" + "io" + "os" + "os/exec" + "strings" + + "github.com/jetkvm/kvm/internal/sync" + + "github.com/rs/zerolog" + "golang.org/x/net/idna" +) + +const ( + hostnamePath = "/etc/hostname" + hostsPath = "/etc/hosts" +) + +// HostnameManager manages system hostname and /etc/hosts +type HostnameManager struct { + logger *zerolog.Logger + mu sync.Mutex +} + +// NewHostnameManager creates a new hostname manager +func NewHostnameManager(logger *zerolog.Logger) *HostnameManager { + if logger == nil { + // Create a no-op logger if none provided + logger = &zerolog.Logger{} + } + + return &HostnameManager{ + logger: logger, + } +} + +// SetHostname sets the system hostname and updates /etc/hosts +func (hm *HostnameManager) SetHostname(hostname, fqdn string) error { + hm.mu.Lock() + defer hm.mu.Unlock() + + hostname = ToValidHostname(strings.TrimSpace(hostname)) + fqdn = ToValidHostname(strings.TrimSpace(fqdn)) + + if hostname == "" { + return fmt.Errorf("invalid hostname: %s", hostname) + } + + if fqdn == "" { + fqdn = hostname + } + + 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 *HostnameManager) GetCurrentHostname() (string, error) { + return os.Hostname() +} + +// GetCurrentFQDN returns the current FQDN +func (hm *HostnameManager) 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 *HostnameManager) 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 *HostnameManager) 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 *HostnameManager) 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 *HostnameManager) 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 { + if hostname == "" { + return fmt.Errorf("hostname cannot be empty") + } + + validHostname := ToValidHostname(hostname) + if validHostname != hostname { + return fmt.Errorf("hostname contains invalid characters: %s", hostname) + } + + if len(hostname) > 253 { + return fmt.Errorf("hostname too long: %d characters (max 253)", len(hostname)) + } + + // Check for valid characters (alphanumeric, hyphens, dots) + for _, char := range hostname { + if !((char >= 'a' && char <= 'z') || + (char >= 'A' && char <= 'Z') || + (char >= '0' && char <= '9') || + char == '-' || char == '.') { + return fmt.Errorf("hostname contains invalid character: %c", char) + } + } + + // Check that it doesn't start or end with hyphen + if strings.HasPrefix(hostname, "-") || strings.HasSuffix(hostname, "-") { + return fmt.Errorf("hostname cannot start or end with hyphen") + } + + // Check that it doesn't start or end with dot + if strings.HasPrefix(hostname, ".") || strings.HasSuffix(hostname, ".") { + return fmt.Errorf("hostname cannot start or end with dot") + } + + return nil +} diff --git a/pkg/nmlite/interface.go b/pkg/nmlite/interface.go new file mode 100644 index 00000000..58aa48a0 --- /dev/null +++ b/pkg/nmlite/interface.go @@ -0,0 +1,773 @@ +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" +) + +// 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 + resolvConf *ResolvConfManager + hostname *HostnameManager + + // Callbacks + onStateChange func(state types.InterfaceState) + onConfigChange func(config *types.NetworkConfig) + onDHCPLeaseChange func(lease *types.DHCPLease) + + // 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) + } + + im.resolvConf = NewResolvConfManager(&scopedLogger) + im.hostname = NewHostnameManager(&scopedLogger) + + // Set up DHCP client callbacks + im.dhcpClient.SetOnLeaseChange(func(lease *types.DHCPLease) { + if err := im.applyDHCPLease(lease); err != nil { + 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 { + im.dhcpClient.Stop() + } + + 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() + + return im.state.Up +} + +// IsOnline returns true if the interface is online +func (im *InterfaceManager) IsOnline() bool { + im.stateMu.RLock() + defer im.stateMu.RUnlock() + + 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() + + 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() + + return im.state.IPv6Ready +} + +// GetIPv4Addresses returns the IPv4 addresses of the interface +func (im *InterfaceManager) GetIPv4Addresses() []string { + im.stateMu.RLock() + defer im.stateMu.RUnlock() + + return im.state.IPv4Addresses +} + +// GetIPv4Address returns the IPv4 address of the interface +func (im *InterfaceManager) GetIPv4Address() string { + im.stateMu.RLock() + defer im.stateMu.RUnlock() + + return im.state.IPv4Address +} + +// GetIPv6Address returns the IPv6 address of the interface +func (im *InterfaceManager) GetIPv6Address() string { + im.stateMu.RLock() + defer im.stateMu.RUnlock() + + 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{} + for _, addr := range im.state.IPv6Addresses { + addresses = append(addresses, addr.Address.String()) + } + + return []string{} +} + +// GetMACAddress returns the MAC address of the interface +func (im *InterfaceManager) GetMACAddress() string { + 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() + + return im.state.NTPServers +} + +// 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 +} + +// 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) + } + + // Update hostname + if err := im.updateHostname(); err != nil { + im.logger.Warn().Err(err).Msg("failed to update hostname") + } + + 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") + + 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") + + 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) + im.dhcpClient.Start() + } + + 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() +} + +// updateHostname updates the system hostname +func (im *InterfaceManager) updateHostname() error { + hostname := im.getHostname() + domain := im.getDomain() + fqdn := fmt.Sprintf("%s.%s", hostname, domain) + + return im.hostname.SetHostname(hostname, fqdn) +} + +// getHostname returns the configured hostname or default +func (im *InterfaceManager) getHostname() string { + if im.config.Hostname.String != "" { + return im.config.Hostname.String + } + return "jetkvm" +} + +// getDomain returns the configured domain or default +func (im *InterfaceManager) getDomain() string { + if im.config.Domain.String != "" { + return im.config.Domain.String + } + + // Try to get domain from DHCP lease + if im.dhcpClient != nil { + if lease := im.dhcpClient.Lease4(); lease != nil && lease.Domain != "" { + return lease.Domain + } + } + + return "local" +} + +func (im *InterfaceManager) handleLinkStateChange(link *link.Link) { + { + im.stateMu.Lock() + 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") + + im.applyConfiguration() + + if im.config.IPv4Mode.String == "dhcp" { + im.dhcpClient.Renew() + } + + if im.config.IPv6Mode.String == "slaac" { + im.staticConfig.EnableIPv6SLAAC() + im.SendRouterSolicitation() + } +} + +func (im *InterfaceManager) handleLinkDown() { + im.logger.Info().Msg("link down") + + if im.config.IPv4Mode.String == "dhcp" { + im.dhcpClient.Stop() + } + + netlinkMgr := getNetlinkManager() + if err := netlinkMgr.RemoveAllAddresses(im.linkState, link.AfInet); err != nil { + im.logger.Error().Err(err).Msg("failed to remove all IPv4 addresses") + } + + if err := netlinkMgr.RemoveNonLinkLocalIPv6Addresses(im.linkState); err != nil { + im.logger.Error().Err(err).Msg("failed to remove non-link-local IPv6 addresses") + } +} + +// monitorInterfaceState monitors the interface state and updates accordingly +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) { + im.stateMu.Lock() + im.state.DHCPLease4 = lease + im.stateMu.Unlock() + + // Update resolv.conf with DNS information + if im.resolvConf != nil { + im.resolvConf.UpdateFromLease(lease) + } +} + +// 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..f29c8f22 --- /dev/null +++ b/pkg/nmlite/interface_state.go @@ -0,0 +1,162 @@ +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, + ValidLifetime: lifetimeToTime(addr.ValidLft), + PreferredLifetime: lifetimeToTime(addr.PreferedLft), + }) + if ipv6Addr == "" { + ipv6Addr = addr.IP.String() + ipv6Ready = true + } + } + + if !compareStringSlices(im.state.IPv4Addresses, ipv4Addresses) { + im.state.IPv4Addresses = ipv4Addresses + stateChanged = true + } + + if !compareIPv6AddressSlices(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..b3460621 --- /dev/null +++ b/pkg/nmlite/jetdhcpc/client.go @@ -0,0 +1,395 @@ +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 + + 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 +) + +// 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 (c *Client) requestLoop(t *time.Timer, family int, ifname string) { + for range t.C { + if _, err := c.ensureInterfaceUp(ifname); err != nil { + c.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 { + c.l.Error().Err(err).Msg("failed to request lease") + resetTimer(t, c.l) + continue + } + + c.handleLeaseChange(lease) + } +} + +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) doRenewLoop() { + timer := time.NewTimer(time.Duration(c.currentLease4.RenewalTime) * time.Second) + defer timer.Stop() + + for range timer.C { + c.renew() + } +} + +func (c *Client) renew() { + // for lease := range c.sendRequests(c.cfg.IPv4, c.cfg.IPv6) { + // if lease, ok := lease.(*Lease); ok { + // c.handleLeaseChange(lease) + // } + // } +} + +func (c *Client) Renew() error { + go c.renew() + 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() +} + +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.lease4Mu.Unlock() + } + + c.timer6.Stop() +} + +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..dda0350e --- /dev/null +++ b/pkg/nmlite/jetdhcpc/dhcp4.go @@ -0,0 +1,62 @@ +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 overriden. + reqmods := append( + []dhcpv4.Modifier{ + dhcpv4.WithOption(dhcpv4.OptClassIdentifier(VendorIdentifier)), + dhcpv4.WithRequestedOptions(dhcpv4.OptionSubnetMask), + }, + 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))) + } + + l.Info().Msg("attempting to get DHCPv4 lease") + lease, err := client.Request(c.ctx, reqmods...) + if err != nil { + return nil, err + } + + 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()) + + 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..6eddde25 --- /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 overriden. + 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..0c06f8fa --- /dev/null +++ b/pkg/nmlite/jetdhcpc/lease.go @@ -0,0 +1,306 @@ +package jetdhcpc + +import ( + "bufio" + "encoding/json" + "fmt" + "net" + "os" + "reflect" + "strconv" + "strings" + "time" + + "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() + + 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..b8ee4c0b --- /dev/null +++ b/pkg/nmlite/jetdhcpc/legacy.go @@ -0,0 +1,95 @@ +package jetdhcpc + +import ( + "bytes" + "io" + "os" + "path/filepath" + "strconv" + "strings" + "syscall" +) + +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 +} + +func (c *Client) killUdhcpc() 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 { + c.l.Info().Msg("no udhcpc processes found") + return nil + } + + c.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 + } + + c.l.Info().Int("pid", pid).Msg("terminated udhcpc process") + } + + return nil +} 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..fec9b075 --- /dev/null +++ b/pkg/nmlite/link/manager.go @@ -0,0 +1,524 @@ +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 + netlink.LinkSubscribe(updateCh, stopCh) + + 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) + + attempt := 0 + 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().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) + + // add all expected addresses to the map + for _, addr := range expected { + expectedAddrs[addr.String()] = &addr + if addr.Gateway != nil { + expectedGateways[addr.String()] = addr.Gateway + } + } + + 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") + } + } + + 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..d726e4b9 --- /dev/null +++ b/pkg/nmlite/link/netlink.go @@ -0,0 +1,157 @@ +// 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) +} + +// 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..dc04190f --- /dev/null +++ b/pkg/nmlite/manager.go @@ -0,0 +1,212 @@ +// 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/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 + + // 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, + } +} + +// 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 { + 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) + } + }) + + 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 + } + + return im.GetState(), 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 +} + +// 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..0502bda7 --- /dev/null +++ b/pkg/nmlite/resolvconf.go @@ -0,0 +1,197 @@ +package nmlite + +import ( + "bytes" + "fmt" + "html/template" + "net" + "os" + "strings" + + "github.com/jetkvm/kvm/internal/network/types" + "github.com/rs/zerolog" +) + +const ( + resolvConfPath = "/etc/resolv.conf" + resolvConfFileMode = 0644 + resolvConfTemplate = `# the resolv.conf file is managed by the jetkvm network manager +# DO NOT EDIT THIS FILE BY HAND -- YOUR CHANGES WILL BE OVERWRITTEN + +{{ if .searchList }} +search {{ join .searchList " " }} # {{ .iface }} +{{- end -}} +{{ if .domain }} +domain {{ .domain }} # {{ .iface }} +{{- end -}} +{{ range .nameservers }} +nameserver {{ printf "%s" . }} # {{ $.iface }} +{{- end }} +` +) + +var ( + tplFuncMap = template.FuncMap{ + "join": strings.Join, + } +) + +// ResolvConfManager manages the resolv.conf file +type ResolvConfManager struct { + logger *zerolog.Logger +} + +// 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, + } +} + +// UpdateFromLease updates resolv.conf from a DHCP lease +func (rcm *ResolvConfManager) UpdateFromLease(lease *types.DHCPLease) error { + if lease == nil { + return fmt.Errorf("lease cannot be nil") + } + + rcm.logger.Info(). + Str("interface", lease.InterfaceName). + Msg("updating resolv.conf from DHCP lease") + + return rcm.Update(lease.InterfaceName, lease.DNS, lease.SearchList, lease.Domain) +} + +// UpdateFromStaticConfig updates resolv.conf from static configuration +func (rcm *ResolvConfManager) UpdateFromStaticConfig(iface string, dns []string) error { + if len(dns) == 0 { + rcm.logger.Debug().Str("interface", iface).Msg("no DNS servers in static config") + return nil + } + + // Parse DNS servers + var dnsIPs []net.IP + for _, dnsStr := range dns { + dnsIP := net.ParseIP(dnsStr) + if dnsIP == nil { + rcm.logger.Warn().Str("dns", dnsStr).Msg("invalid DNS server, skipping") + continue + } + dnsIPs = append(dnsIPs, dnsIP) + } + + if len(dnsIPs) == 0 { + rcm.logger.Debug().Str("interface", iface).Msg("no valid DNS servers in static config") + return nil + } + + rcm.logger.Info(). + Str("interface", iface). + Interface("dns", dnsIPs). + Msg("updating resolv.conf from static config") + + return rcm.Update(iface, dnsIPs, nil, "") +} + +// Update updates the resolv.conf file +func (rcm *ResolvConfManager) Update(iface string, nameservers []net.IP, searchList []string, domain string) error { + rcm.logger.Debug(). + Str("interface", iface). + Interface("nameservers", nameservers). + Interface("searchList", searchList). + Str("domain", domain). + Msg("updating resolv.conf") + + // Generate resolv.conf content + content, err := rcm.generateResolvConf(iface, nameservers, searchList, domain) + if err != nil { + return fmt.Errorf("failed to generate resolv.conf: %w", err) + } + + // Write to file + if err := os.WriteFile(resolvConfPath, content, resolvConfFileMode); err != nil { + return fmt.Errorf("failed to write resolv.conf: %w", err) + } + + rcm.logger.Info(). + Str("interface", iface). + Int("nameservers", len(nameservers)). + Msg("resolv.conf updated successfully") + + return nil +} + +// generateResolvConf generates resolv.conf content +func (rcm *ResolvConfManager) generateResolvConf(iface string, nameservers []net.IP, searchList []string, domain string) ([]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) + } + + var buf bytes.Buffer + if err := tmpl.Execute(&buf, map[string]any{ + "iface": iface, + "nameservers": nameservers, + "searchList": searchList, + "domain": domain, + }); err != nil { + return nil, fmt.Errorf("failed to execute template: %w", err) + } + + return buf.Bytes(), nil +} + +// Clear clears the resolv.conf file (removes all entries) +func (rcm *ResolvConfManager) Clear() error { + rcm.logger.Info().Msg("clearing resolv.conf") + + content := []byte("# the resolv.conf file is managed by the jetkvm network manager\n# DO NOT EDIT THIS FILE BY HAND -- YOUR CHANGES WILL BE OVERWRITTEN\n") + + if err := os.WriteFile(resolvConfPath, content, resolvConfFileMode); err != nil { + return fmt.Errorf("failed to clear resolv.conf: %w", err) + } + + rcm.logger.Info().Msg("resolv.conf cleared") + return nil +} + +// GetCurrentContent returns the current content of resolv.conf +func (rcm *ResolvConfManager) GetCurrentContent() ([]byte, error) { + return os.ReadFile(resolvConfPath) +} + +// Backup creates a backup of the current resolv.conf +func (rcm *ResolvConfManager) Backup() error { + content, err := rcm.GetCurrentContent() + if err != nil { + return fmt.Errorf("failed to read current resolv.conf: %w", err) + } + + backupPath := resolvConfPath + ".backup" + if err := os.WriteFile(backupPath, content, resolvConfFileMode); err != nil { + return fmt.Errorf("failed to create backup: %w", err) + } + + rcm.logger.Info().Str("backup", backupPath).Msg("resolv.conf backed up") + return nil +} + +// Restore restores resolv.conf from backup +func (rcm *ResolvConfManager) Restore() error { + backupPath := resolvConfPath + ".backup" + content, err := os.ReadFile(backupPath) + if err != nil { + return fmt.Errorf("failed to read backup: %w", err) + } + + if err := os.WriteFile(resolvConfPath, content, resolvConfFileMode); err != nil { + return fmt.Errorf("failed to restore resolv.conf: %w", err) + } + + rcm.logger.Info().Str("backup", backupPath).Msg("resolv.conf restored from backup") + return nil +} diff --git a/pkg/nmlite/resolvconf_test.go b/pkg/nmlite/resolvconf_test.go new file mode 100644 index 00000000..ddb91854 --- /dev/null +++ b/pkg/nmlite/resolvconf_test.go @@ -0,0 +1,35 @@ +package nmlite + +import ( + "net" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestToResolvConf(t *testing.T) { + rc, err := ResolvConfManager{}.generateResolvConf( + "eth0", + []net.IP{ + net.ParseIP("198.51.100.53"), + net.ParseIP("203.0.113.53"), + }, + []string{"example.com"}, + "example.com", + ) + if err != nil { + t.Fatal(err) + } + + want := `# the resolv.conf file is managed by the jetkvm network manager +# DO NOT EDIT THIS FILE BY HAND -- YOUR CHANGES WILL BE OVERWRITTEN + + +search example.com # eth0 +domain example.com # eth0 +nameserver 198.51.100.53 # eth0 +nameserver 203.0.113.53 # eth0 +` + + assert.Equal(t, want, rc.String()) +} diff --git a/pkg/nmlite/state.go b/pkg/nmlite/state.go new file mode 100644 index 00000000..517a44ac --- /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) GetHostname() string { + return "jetkvm" +} + +func (nm *NetworkManager) GetFQDN() string { + return "jetkvm.local" +} + +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..e23424b1 --- /dev/null +++ b/pkg/nmlite/static.go @@ -0,0 +1,190 @@ +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) +} + +// enableIPv6 enables IPv6 on the interface +func (scm *StaticConfigManager) enableIPv6() error { + netlinkMgr := getNetlinkManager() + return netlinkMgr.EnableIPv6(scm.ifaceName) +} 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..15d5624c --- /dev/null +++ b/pkg/nmlite/utils.go @@ -0,0 +1,70 @@ +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 compareStringSlices(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 compareIPv6AddressSlices(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 + } + // 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..9f948f2c 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -5856,6 +5856,25 @@ "react": "^19.1.1" } }, +<<<<<<< Updated upstream +======= + "node_modules/react-hook-form": { + "version": "7.62.0", + "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.62.0.tgz", + "integrity": "sha512-7KWFejc98xqG/F4bAxpL41NB3o1nnvQO1RWZT3TqRZYL8RryQETGfEdVnJN2fy1crCiBLLjkRBVK05j24FxJGA==", + "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" + } + }, +>>>>>>> Stashed changes "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..26e860c6 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.62.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..d302ead6 100644 --- a/ui/src/components/ConfirmDialog.tsx +++ b/ui/src/components/ConfirmDialog.tsx @@ -1,13 +1,10 @@ -import { - CheckCircleIcon, - ExclamationTriangleIcon, - InformationCircleIcon, -} from "@heroicons/react/24/outline"; +import { CheckCircleIcon } from "@heroicons/react/24/outline"; +import { CloseButton } from "@headlessui/react"; +import { LuInfo, LuOctagonAlert, LuTriangleAlert } from "react-icons/lu"; import { Button } from "@/components/Button"; import Modal from "@/components/Modal"; import { cx } from "@/cva.config"; - type Variant = "danger" | "success" | "warning" | "info"; interface ConfirmDialogProps { @@ -24,27 +21,27 @@ interface ConfirmDialogProps { const variantConfig = { danger: { - icon: ExclamationTriangleIcon, + icon: LuOctagonAlert, iconClass: "text-red-600", - iconBgClass: "bg-red-100", + iconBgClass: "bg-red-100 border border-red-500/90", buttonTheme: "danger", }, success: { icon: CheckCircleIcon, iconClass: "text-green-600", - iconBgClass: "bg-green-100", + iconBgClass: "bg-green-100 border border-green-500/90", buttonTheme: "primary", }, warning: { - icon: ExclamationTriangleIcon, + icon: LuTriangleAlert, iconClass: "text-yellow-600", - iconBgClass: "bg-yellow-100", - buttonTheme: "lightDanger", + iconBgClass: "bg-yellow-100 border border-yellow-500/90", + buttonTheme: "primary", }, info: { - icon: InformationCircleIcon, + icon: LuInfo, iconClass: "text-blue-600", - iconBgClass: "bg-blue-100", + iconBgClass: "bg-blue-100 border border-blue-500/90", buttonTheme: "primary", }, } as Record< @@ -94,12 +91,13 @@ export function ConfirmDialog({ -