From ef0bdc0f655ddd641f0606b996834cfe6f8928d7 Mon Sep 17 00:00:00 2001 From: Siyuan Date: Tue, 7 Oct 2025 10:57:35 +0000 Subject: [PATCH 01/85] rewrite network manager --- cloud.go | 2 +- config.go | 56 +- display.go | 12 +- go.mod | 7 + go.sum | 20 + internal/confparser/confparser.go | 82 +- internal/confparser/confparser_test.go | 10 +- internal/confparser/utils.go | 2 +- internal/netif/network.go | 19 + internal/network/dhcp.go | 11 - internal/network/hostname.go | 137 --- internal/network/netif.go | 403 --------- internal/network/netif_linux.go | 58 -- internal/network/netif_notlinux.go | 21 - internal/network/rpc.go | 201 ++--- internal/network/{config.go => types/type.go} | 120 ++- internal/timesync/ntp.go | 4 +- internal/timesync/timesync.go | 10 +- mdns.go | 10 +- network.go | 126 ++- pkg/nmlite/dhclient/client.go | 404 +++++++++ pkg/nmlite/dhclient/dhcp4.go | 52 ++ pkg/nmlite/dhclient/dhcp6.go | 131 +++ pkg/nmlite/dhclient/lease.go | 335 ++++++++ pkg/nmlite/dhclient/legacy.go | 95 ++ pkg/nmlite/dhclient/logging.go | 40 + pkg/nmlite/dhclient/state.go | 248 ++++++ pkg/nmlite/dhclient/utils.go | 46 + pkg/nmlite/dhcp.go | 232 +++++ pkg/nmlite/hostname.go | 247 ++++++ pkg/nmlite/interface.go | 624 ++++++++++++++ pkg/nmlite/link/netlink.go | 465 ++++++++++ pkg/nmlite/link/types.go | 23 + pkg/nmlite/manager.go | 211 +++++ pkg/nmlite/netlink.go | 7 + pkg/nmlite/resolvconf.go | 197 +++++ pkg/nmlite/state.go | 81 ++ pkg/nmlite/static.go | 348 ++++++++ timesync.go | 2 +- ui/package-lock.json | 19 + ui/package.json | 1 + ui/src/components/ConfirmDialog.tsx | 32 +- ui/src/components/DhcpLeaseCard.tsx | 76 +- ui/src/components/Ipv6NetworkCard.tsx | 98 ++- ui/src/components/SettingsPageheader.tsx | 13 +- ui/src/components/StaticIpv4Card.tsx | 113 +++ ui/src/components/StaticIpv6Card.tsx | 119 +++ ui/src/hooks/stores.ts | 24 +- ui/src/index.css | 9 + .../routes/devices.$id.settings.network.tsx | 813 +++++++++--------- ui/src/utils/jsonrpc.ts | 103 +++ 51 files changed, 5120 insertions(+), 1399 deletions(-) create mode 100644 internal/netif/network.go delete mode 100644 internal/network/dhcp.go delete mode 100644 internal/network/hostname.go delete mode 100644 internal/network/netif.go delete mode 100644 internal/network/netif_linux.go delete mode 100644 internal/network/netif_notlinux.go rename internal/network/{config.go => types/type.go} (52%) create mode 100644 pkg/nmlite/dhclient/client.go create mode 100644 pkg/nmlite/dhclient/dhcp4.go create mode 100644 pkg/nmlite/dhclient/dhcp6.go create mode 100644 pkg/nmlite/dhclient/lease.go create mode 100644 pkg/nmlite/dhclient/legacy.go create mode 100644 pkg/nmlite/dhclient/logging.go create mode 100644 pkg/nmlite/dhclient/state.go create mode 100644 pkg/nmlite/dhclient/utils.go create mode 100644 pkg/nmlite/dhcp.go create mode 100644 pkg/nmlite/hostname.go create mode 100644 pkg/nmlite/interface.go create mode 100644 pkg/nmlite/link/netlink.go create mode 100644 pkg/nmlite/link/types.go create mode 100644 pkg/nmlite/manager.go create mode 100644 pkg/nmlite/netlink.go create mode 100644 pkg/nmlite/resolvconf.go create mode 100644 pkg/nmlite/state.go create mode 100644 pkg/nmlite/static.go create mode 100644 ui/src/components/StaticIpv4Card.tsx create mode 100644 ui/src/components/StaticIpv6Card.tsx create mode 100644 ui/src/utils/jsonrpc.ts 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 c83ccfc7..1694244f 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,32 +78,32 @@ 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"` + 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"` } func (c *Config) GetDisplayRotation() uint16 { @@ -160,7 +160,7 @@ var defaultConfig = &Config{ Keyboard: true, MassStorage: true, }, - NetworkConfig: &network.NetworkConfig{}, + NetworkConfig: &types.NetworkConfig{}, DefaultLogLevel: "INFO", } diff --git a/display.go b/display.go index b414a353..36a560f9 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 { @@ -190,7 +190,7 @@ func waitCtrlAndRequestDisplayUpdate(shouldWakeDisplay bool, reason string) { 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 { diff --git a/go.mod b/go.mod index 8605693e..695a9f91 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,19 @@ 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/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 +87,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..4a3397b8 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,10 @@ 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/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 +112,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 +174,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 +184,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 +210,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/netif/network.go b/internal/netif/network.go new file mode 100644 index 00000000..a135851f --- /dev/null +++ b/internal/netif/network.go @@ -0,0 +1,19 @@ +package netif + +import ( + "fmt" + + "github.com/vishvananda/netlink" +) + +func ensureInterfaceIsUp(iface *netlink.Link) error { + if (*iface).Attrs().OperState == netlink.OperUp { + return nil + } + + if err := netlink.LinkSetUp(*iface); err != nil { + return fmt.Errorf("failed to set interface up: %w", err) + } + + return 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 index 62f21be8..d18b9ae9 100644 --- a/internal/network/rpc.go +++ b/internal/network/rpc.go @@ -1,126 +1,127 @@ package network -import ( - "fmt" - "time" +// import ( +// "fmt" +// "time" - "github.com/jetkvm/kvm/internal/confparser" - "github.com/jetkvm/kvm/internal/udhcpc" -) +// "github.com/jetkvm/kvm/internal/confparser" +// "github.com/jetkvm/kvm/internal/network/types" +// "github.com/jetkvm/kvm/internal/udhcpc" +// ) -type RpcIPv6Address struct { - Address string `json:"address"` - ValidLifetime *time.Time `json:"valid_lifetime,omitempty"` - PreferredLifetime *time.Time `json:"preferred_lifetime,omitempty"` - Scope int `json:"scope"` -} +// type 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 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 -} +// type RpcNetworkSettings struct { +// NetworkConfig types.NetworkConfig +// } -func (s *NetworkInterfaceState) MacAddress() string { - if s.macAddr == nil { - return "" - } +// // func (s *NetworkInterfaceState) MacAddress() string { +// // if s.macAddr == nil { +// // return "" +// // } - return s.macAddr.String() -} +// // return s.macAddr.String() +// // } -func (s *NetworkInterfaceState) IPv4Address() string { - if s.ipv4Addr == nil { - return "" - } +// // func (s *NetworkInterfaceState) IPv4Address() string { +// // if s.ipv4Addr == nil { +// // return "" +// // } - return s.ipv4Addr.String() -} +// // return s.ipv4Addr.String() +// // } -func (s *NetworkInterfaceState) IPv6Address() string { - if s.ipv6Addr == nil { - return "" - } +// // func (s *NetworkInterfaceState) IPv6Address() string { +// // if s.ipv6Addr == nil { +// // return "" +// // } - return s.ipv6Addr.String() -} +// // return s.ipv6Addr.String() +// // } -func (s *NetworkInterfaceState) IPv6LinkLocalAddress() string { - if s.ipv6LinkLocal == nil { - return "" - } +// // func (s *NetworkInterfaceState) IPv6LinkLocalAddress() string { +// // if s.ipv6LinkLocal == nil { +// // return "" +// // } - return s.ipv6LinkLocal.String() -} +// // return s.ipv6LinkLocal.String() +// // } -func (s *NetworkInterfaceState) RpcGetNetworkState() RpcNetworkState { - ipv6Addresses := make([]RpcIPv6Address, 0) +// 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, - }) - } - } +// 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(), - } -} +// 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{} - } +// func (s *NetworkInterfaceState) RpcGetNetworkSettings() RpcNetworkSettings { +// if s.config == nil { +// return RpcNetworkSettings{} +// } - return RpcNetworkSettings{ - NetworkConfig: *s.config, - } -} +// return RpcNetworkSettings{ +// NetworkConfig: *s.config, +// } +// } -func (s *NetworkInterfaceState) RpcSetNetworkSettings(settings RpcNetworkSettings) error { - currentSettings := s.config +// func (s *NetworkInterfaceState) RpcSetNetworkSettings(settings RpcNetworkSettings) error { +// currentSettings := s.config - err := confparser.SetDefaultsAndValidate(&settings.NetworkConfig) - if err != nil { - return err - } +// err := confparser.SetDefaultsAndValidate(&settings.NetworkConfig) +// if err != nil { +// return err +// } - if IsSame(currentSettings, settings.NetworkConfig) { - // no changes, do nothing - return nil - } +// if IsSame(currentSettings, settings.NetworkConfig) { +// // no changes, do nothing +// return nil +// } - s.config = &settings.NetworkConfig - s.onConfigChange(s.config) +// s.config = &settings.NetworkConfig +// s.onConfigChange(s.config) - return nil -} +// return nil +// } -func (s *NetworkInterfaceState) RpcRenewDHCPLease() error { - if s.dhcpClient == nil { - return fmt.Errorf("dhcp client not initialized") - } +// func (s *NetworkInterfaceState) RpcRenewDHCPLease() error { +// if s.dhcpClient == nil { +// return fmt.Errorf("dhcp client not initialized") +// } - return s.dhcpClient.Renew() -} +// return s.dhcpClient.Renew() +// } diff --git a/internal/network/config.go b/internal/network/types/type.go similarity index 52% rename from internal/network/config.go rename to internal/network/types/type.go index e8a8c058..cac4b2c4 100644 --- a/internal/network/config.go +++ b/internal/network/types/type.go @@ -1,17 +1,25 @@ -package network +package types import ( - "fmt" "net" "net/http" "net/url" "time" "github.com/guregu/null/v6" - "github.com/jetkvm/kvm/internal/mdns" - "golang.org/x/net/idna" ) +// IPAddress represents a network interface address +type IPAddress struct { + Family int + Address net.IPNet + Gateway net.IP + MTU int + Secondary bool + Permanent bool +} + +// IPv6Address represents an IPv6 address with lifetime information type IPv6Address struct { Address net.IP `json:"address"` Prefix net.IPNet `json:"prefix"` @@ -20,6 +28,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,12 +36,14 @@ 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 { Hostname null.String `json:"hostname,omitempty" validate_type:"hostname"` HTTPProxy null.String `json:"http_proxy,omitempty" validate_type:"proxy"` @@ -44,7 +55,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 +66,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 +87,66 @@ 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 +// DHCPLease represents a DHCP lease +type DHCPLease struct { + InterfaceName string `json:"interface_name"` + IPAddress net.IP `json:"ip_address"` + Netmask net.IP `json:"netmask"` + Gateway net.IP `json:"gateway"` + DNS []net.IP `json:"dns"` + SearchList []string `json:"search_list"` + Domain string `json:"domain"` + NTPServers []net.IP `json:"ntp_servers"` + LeaseTime time.Time `json:"lease_time"` + RenewalTime time.Time `json:"renewal_time"` + RebindingTime time.Time `json:"rebinding_time"` + ExpiryTime time.Time `json:"expiry_time"` } -func ToValidDomain(domain string) string { - ascii, err := idna.Lookup.ToASCII(domain) - if err != nil { - return "" - } - - return ascii +// 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"` + 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 (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 +// NetworkConfig interface for backward compatibility +type NetworkConfigInterface interface { + InterfaceName() string + IPv4Addresses() []IPAddress + IPv6Addresses() []IPAddress } -func (s *NetworkInterfaceState) GetFQDN() string { - return fmt.Sprintf("%s.%s", s.GetHostname(), s.GetDomain()) +func (d *DHCPLease) IsIPv6() bool { + return d.IPAddress.To4() == nil } diff --git a/internal/timesync/ntp.go b/internal/timesync/ntp.go index b9ffa249..dcadaa96 100644 --- a/internal/timesync/ntp.go +++ b/internal/timesync/ntp.go @@ -9,7 +9,7 @@ import ( "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 +27,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", diff --git a/internal/timesync/timesync.go b/internal/timesync/timesync.go index b29a61ab..ad5f6f49 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" ) @@ -28,7 +28,7 @@ type TimeSync struct { syncLock *sync.Mutex l *zerolog.Logger - networkConfig *network.NetworkConfig + networkConfig *types.NetworkConfig dhcpNtpAddresses []string rtcDevicePath string @@ -43,7 +43,7 @@ type TimeSync struct { type TimeSyncOptions struct { PreCheckFunc func() (bool, error) Logger *zerolog.Logger - NetworkConfig *network.NetworkConfig + NetworkConfig *types.NetworkConfig } type SyncMode struct { @@ -188,10 +188,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 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..03618891 100644 --- a/network.go +++ b/network.go @@ -1,10 +1,11 @@ package kvm import ( - "fmt" + "context" - "github.com/jetkvm/kvm/internal/network" - "github.com/jetkvm/kvm/internal/udhcpc" + "github.com/jetkvm/kvm/internal/mdns" + "github.com/jetkvm/kvm/internal/network/types" + "github.com/jetkvm/kvm/pkg/nmlite" ) const ( @@ -12,16 +13,45 @@ const ( ) var ( - networkState *network.NetworkInterfaceState + networkManager *nmlite.NetworkManager ) +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 networkStateChanged(isOnline bool) { // do not block the main thread go waitCtrlAndRequestDisplayUpdate(true, "network_state_changed") if timeSync != nil { - if networkState != nil { - timeSync.SetDhcpNtpAddresses(networkState.NtpAddressesString()) + if networkManager != nil { + timeSync.SetDhcpNtpAddresses(networkManager.NTPServerStrings()) } if err := timeSync.Sync(); err != nil { @@ -31,11 +61,7 @@ func networkStateChanged(isOnline bool) { // 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() } // if the network is now online, trigger an NTP sync if still needed @@ -49,77 +75,47 @@ func networkStateChanged(isOnline bool) { 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()) - - 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 - } - - if err := state.Run(); err != nil { - return err - } - - networkState = state + networkManager = nmlite.NewNetworkManager(context.Background(), networkLogger) + networkManager.AddInterface(NetIfName, config.NetworkConfig) return nil } -func rpcGetNetworkState() network.RpcNetworkState { - return networkState.RpcGetNetworkState() +func rpcGetNetworkState() *types.InterfaceState { + state, _ := networkManager.GetInterfaceState(NetIfName) + return state } -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() + + networkLogger.Debug().Interface("newConfig", netConfig).Interface("config", settings).Msg("setting new config") + + s := networkManager.SetInterfaceConfig(NetIfName, netConfig) if s != nil { return nil, s } + networkLogger.Debug().Interface("newConfig", netConfig).Interface("config", settings).Msg("new config") + + newConfig, err := networkManager.GetInterfaceConfig(NetIfName) + if err != nil { + return nil, err + } + config.NetworkConfig = newConfig + + networkLogger.Debug().Interface("newConfig", newConfig).Interface("config", settings).Msg("saving 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/dhclient/client.go b/pkg/nmlite/dhclient/client.go new file mode 100644 index 00000000..af6dc9ea --- /dev/null +++ b/pkg/nmlite/dhclient/client.go @@ -0,0 +1,404 @@ +package dhclient + +import ( + "context" + "errors" + "fmt" + "net" + "slices" + "sync" + "time" + + "github.com/go-co-op/gocron/v2" + "github.com/insomniacslk/dhcp/dhcpv4" + "github.com/insomniacslk/dhcp/dhcpv6" + "github.com/jetkvm/kvm/pkg/nmlite/link" + "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 *Lease) + +// 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 +} + +type Client struct { + 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 + + scheduler gocron.Scheduler + stateDir string +} + +// NewClient creates a new DHCP client for the given interface. +func NewClient(ctx context.Context, ifaces []string, c *Config, l *zerolog.Logger) (*Client, error) { + scheduler, err := gocron.NewScheduler() + if err != nil { + return nil, fmt.Errorf("failed to create scheduler: %w", err) + } + + cfg := *c + if cfg.LinkUpTimeout == 0 { + cfg.LinkUpTimeout = 30 * time.Second + } + + if cfg.Timeout == 0 { + cfg.Timeout = 30 * time.Second + } + + if cfg.Retries == 0 { + cfg.Retries = 3 + } + + return &Client{ + ctx: ctx, + ifaces: ifaces, + cfg: cfg, + l: l, + scheduler: scheduler, + stateDir: "/run/jetkvm-dhcp", + + currentLease4: nil, + currentLease6: nil, + + lease4Mu: sync.Mutex{}, + lease6Mu: sync.Mutex{}, + + mu: sync.Mutex{}, + cfgMu: sync.Mutex{}, + }, nil +} + +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) +} + +func (c *Client) sendInitialRequests() chan interface{} { + return c.sendRequests(c.cfg.IPv4, c.cfg.IPv6) +} + +func (c *Client) sendRequests(ipv4, ipv6 bool) chan interface{} { + c.mu.Lock() + defer c.mu.Unlock() + + // Yeah, this is a hack, until we can cancel all leases in progress. + r := make(chan interface{}, 3*len(c.ifaces)) + + var wg sync.WaitGroup + for _, iface := range c.ifaces { + wg.Add(1) + go func(ifname string) { + defer wg.Done() + + l := c.l.With().Str("interface", ifname).Logger() + + iface, err := c.ensureInterfaceUp(ifname) + if err != nil { + l.Error().Err(err).Msg("Could not bring up interface") + return + } + + if ipv4 { + wg.Add(1) + go func(iface *link.Link) { + defer wg.Done() + lease, err := c.requestLease4(iface) + if err != nil { + l.Error().Err(err).Msg("Could not get IPv4 lease") + return + } + r <- lease + }(iface) + } + + if ipv6 { + return // TODO: implement DHCP6 + wg.Add(1) + go func(iface *link.Link) { + defer wg.Done() + lease, err := c.requestLease6(iface) + if err != nil { + l.Error().Err(err).Msg("Could not get IPv6 lease") + return + } + r <- lease + }(iface) + } + }(iface) + } + + go func() { + wg.Wait() + close(r) + }() + return r +} + +func (c *Client) Lease4() *Lease { + c.lease4Mu.Lock() + defer c.lease4Mu.Unlock() + + return c.currentLease4 +} + +func (c *Client) Lease6() *Lease { + c.lease6Mu.Lock() + defer c.lease6Mu.Unlock() + + return c.currentLease6 +} + +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 "" +} + +func (c *Client) handleLeaseChange(lease *Lease) { + // do not use defer here, because we need to unlock the mutex before returning + + ipv4 := lease.p4 != nil + version := "ipv4" + + if ipv4 { + c.lease4Mu.Lock() + c.currentLease4 = lease + } else { + version = "ipv6" + c.lease6Mu.Lock() + c.currentLease6 = lease + } + + // clear all current jobs with the same tags + c.scheduler.RemoveByTags(version) + + // add scheduler job to renew the lease + if lease.RenewalTime > 0 { + c.scheduler.NewJob( + gocron.DurationJob(time.Duration(lease.RenewalTime)*time.Second), + gocron.NewTask(func() { + c.l.Info().Msg("renewing lease") + for lease := range c.sendRequests(ipv4, !ipv4) { + if lease, ok := lease.(*Lease); ok { + c.handleLeaseChange(lease) + } + } + }), + gocron.WithName(fmt.Sprintf("renew-%s", version)), + gocron.WithSingletonMode(gocron.LimitModeWait), + gocron.WithTags(version), + ) + } + + c.apply() + + if ipv4 { + c.lease4Mu.Unlock() + } else { + c.lease6Mu.Unlock() + } + + // TODO: handle lease expiration + if c.cfg.OnLease4Change != nil && ipv4 { + c.cfg.OnLease4Change(lease) + } + + if c.cfg.OnLease6Change != nil && !ipv4 { + c.cfg.OnLease6Change(lease) + } +} + +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() { + go c.renew() +} + +func (c *Client) Release() { + // TODO: implement +} + +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.scheduler.RemoveByTags("ipv4") + } + + c.sendRequests(ipv4, c.cfg.IPv6) +} + +func (c *Client) SetIPv6(ipv6 bool) { + c.cfg.IPv6 = ipv6 +} + +func (c *Client) Start() error { + if err := c.killUdhcpc(); err != nil { + c.l.Warn().Err(err).Msg("failed to kill udhcpc processes, continuing anyway") + } + + c.scheduler.Start() + + go func() { + for lease := range c.sendInitialRequests() { + if lease, ok := lease.(*Lease); ok { + c.handleLeaseChange(lease) + } + } + }() + + 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/dhclient/dhcp4.go b/pkg/nmlite/dhclient/dhcp4.go new file mode 100644 index 00000000..9518bc87 --- /dev/null +++ b/pkg/nmlite/dhclient/dhcp4.go @@ -0,0 +1,52 @@ +package dhclient + +import ( + "github.com/insomniacslk/dhcp/dhcpv4" + "github.com/insomniacslk/dhcp/dhcpv4/nclient4" + "github.com/vishvananda/netlink" +) + +func (c *Client) requestLease4(iface netlink.Link) (*Lease, error) { + ifname := iface.Attrs().Name + 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 + } + + l.Info().Msgf("DHCPv4 lease acquired: %s", lease.ACK.Summary()) + + return fromNclient4Lease(lease, ifname), nil +} diff --git a/pkg/nmlite/dhclient/dhcp6.go b/pkg/nmlite/dhclient/dhcp6.go new file mode 100644 index 00000000..6c393ecf --- /dev/null +++ b/pkg/nmlite/dhclient/dhcp6.go @@ -0,0 +1,131 @@ +package dhclient + +import ( + "log" + "net" + "time" + + "github.com/insomniacslk/dhcp/dhcpv6" + "github.com/insomniacslk/dhcp/dhcpv6/nclient6" + "github.com/vishvananda/netlink" +) + +// isIPv6LinkReady returns true if the interface has a link-local address +// which is not tentative. +func isIPv6LinkReady(l netlink.Link) (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 + log.Printf("DADFAILED for %v, continuing anyhow", addr.IP) + } + return true, nil + } + } + return false, nil +} + +// isIPv6RouteReady returns true if serverAddr is reachable. +func isIPv6RouteReady(serverAddr net.IP) waitForCondition { + return func(l netlink.Link) (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(iface netlink.Link) (*Lease, error) { + ifname := iface.Attrs().Name + l := c.l.With().Str("interface", ifname).Logger() + + 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/dhclient/lease.go b/pkg/nmlite/dhclient/lease.go new file mode 100644 index 00000000..d9f10218 --- /dev/null +++ b/pkg/nmlite/dhclient/lease.go @@ -0,0 +1,335 @@ +package dhclient + +import ( + "bufio" + "encoding/json" + "fmt" + "net" + "os" + "reflect" + "strconv" + "strings" + "time" + + "github.com/insomniacslk/dhcp/dhcpv4/nclient4" + "github.com/insomniacslk/dhcp/dhcpv6" +) + +var ( + defaultLeaseTime = time.Duration(30 * time.Minute) + defaultRenewalTime = time.Duration(15 * time.Minute) +) + +// Lease is a network configuration obtained by DHCP. +type Lease struct { + // from https://udhcp.busybox.net/README.udhcpc + IPAddress net.IP `env:"ip" json:"ip"` // The obtained IP + Netmask net.IP `env:"subnet" json:"netmask"` // The assigned subnet mask + Broadcast net.IP `env:"broadcast" json:"broadcast"` // The broadcast address for this network + TTL int `env:"ipttl" json:"ttl,omitempty"` // The TTL to use for this network + MTU int `env:"mtu" json:"mtu,omitempty"` // The MTU to use for this network + HostName string `env:"hostname" json:"hostname,omitempty"` // The assigned hostname + Domain string `env:"domain" json:"domain,omitempty"` // The domain name of the network + 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 + Timezone string `env:"timezone" json:"timezone,omitempty"` // Offset in seconds from UTC + Routers []net.IP `env:"router" json:"routers,omitempty"` // A list of routers + DNS []net.IP `env:"dns" json:"dns_servers,omitempty"` // A list of DNS servers + NTPServers []net.IP `env:"ntpsrv" json:"ntp_servers,omitempty"` // A list of NTP servers + LPRServers []net.IP `env:"lprsvr" json:"lpr_servers,omitempty"` // A list of LPR servers + TimeServers []net.IP `env:"timesvr" json:"_time_servers,omitempty"` // A list of time servers (obsolete) + IEN116NameServers []net.IP `env:"namesvr" json:"_name_servers,omitempty"` // A list of IEN 116 name servers (obsolete) + LogServers []net.IP `env:"logsvr" json:"_log_servers,omitempty"` // A list of MIT-LCS UDP log servers (obsolete) + CookieServers []net.IP `env:"cookiesvr" json:"_cookie_servers,omitempty"` // A list of RFC 865 cookie servers (obsolete) + WINSServers []net.IP `env:"wins" json:"_wins_servers,omitempty"` // A list of WINS servers + SwapServer net.IP `env:"swapsvr" json:"_swap_server,omitempty"` // The IP address of the client's swap server + BootSize int `env:"bootsize" json:"bootsize,omitempty"` // The length in 512 octect blocks of the bootfile + RootPath string `env:"rootpath" json:"root_path,omitempty"` // The path name of the client's root disk + LeaseTime time.Duration `env:"lease" json:"lease,omitempty"` // The lease time, in seconds + 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 + + InterfaceName string `json:"interface_name,omitempty"` // The name of the interface + + p4 *nclient4.Lease + p6 *dhcpv6.Message + + isEmpty map[string]bool +} + +// 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.MTU = int(resp.Options.Get(dhcpv4.OptionInterfaceMTU)) + + 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/dhclient/legacy.go b/pkg/nmlite/dhclient/legacy.go new file mode 100644 index 00000000..2a47a70e --- /dev/null +++ b/pkg/nmlite/dhclient/legacy.go @@ -0,0 +1,95 @@ +package dhclient + +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/dhclient/logging.go b/pkg/nmlite/dhclient/logging.go new file mode 100644 index 00000000..3d6cb8ce --- /dev/null +++ b/pkg/nmlite/dhclient/logging.go @@ -0,0 +1,40 @@ +package dhclient + +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().Str("prefix", prefix).Str("message", message.String()).Msg("DHCP message") +} + +func (c *Client) getDHCP4Logger(ifname string) nclient4.ClientOpt { + logger := c.l.With().Str("interface", ifname).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/dhclient/state.go b/pkg/nmlite/dhclient/state.go new file mode 100644 index 00000000..d9afc396 --- /dev/null +++ b/pkg/nmlite/dhclient/state.go @@ -0,0 +1,248 @@ +// Package nmlite provides DHCP state persistence for the network manager. +package dhclient + +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/dhclient/utils.go b/pkg/nmlite/dhclient/utils.go new file mode 100644 index 00000000..4825cc4f --- /dev/null +++ b/pkg/nmlite/dhclient/utils.go @@ -0,0 +1,46 @@ +package dhclient + +import ( + "context" + "time" + + "github.com/vishvananda/netlink" +) + +type waitForCondition func(l netlink.Link) (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, timeout, condition, timeoutError) +} + +func waitFor( + ctx context.Context, + link netlink.Link, + timeout <-chan time.Time, + condition waitForCondition, + timeoutError error, +) error { + for { + if ready, err := condition(link); 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/dhcp.go b/pkg/nmlite/dhcp.go new file mode 100644 index 00000000..b67857f1 --- /dev/null +++ b/pkg/nmlite/dhcp.go @@ -0,0 +1,232 @@ +// 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/dhclient" + "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 *dhclient.Client + link netlink.Link + + // Configuration + ipv4Enabled bool + ipv6Enabled bool + + // State management + // stateManager *DHCPStateManager + + // Callbacks + onLeaseChange func(lease *types.DHCPLease) +} + +// NewDHCPClient creates a new DHCP client +func NewDHCPClient(ctx context.Context, ifaceName string, logger *zerolog.Logger) (*DHCPClient, error) { + if ifaceName == "" { + return nil, fmt.Errorf("interface name cannot be empty") + } + + if logger == nil { + return nil, fmt.Errorf("logger cannot be nil") + } + + // Create state manager + // stateManager := NewDHCPStateManager("", logger) + + return &DHCPClient{ + ctx: ctx, + ifaceName: ifaceName, + logger: logger, + }, 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 +} + +// 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 := dhclient.NewClient(dc.ctx, []string{dc.ifaceName}, &dhclient.Config{ + IPv4: dc.ipv4Enabled, + IPv6: dc.ipv6Enabled, + OnLease4Change: func(lease *dhclient.Lease) { + dc.handleLeaseChange(lease, false) + }, + OnLease6Change: func(lease *dhclient.Lease) { + 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) + + 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 +} + +// 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 +} + +// GetLease4 returns the current IPv4 lease +func (dc *DHCPClient) GetLease4() *types.DHCPLease { + if dc.client == nil { + return nil + } + + lease := dc.client.Lease4() + if lease == nil { + return nil + } + + return dc.convertLease(lease, false) +} + +// GetLease6 returns the current IPv6 lease +func (dc *DHCPClient) GetLease6() *types.DHCPLease { + if dc.client == nil { + return nil + } + + lease := dc.client.Lease6() + if lease == nil { + return nil + } + + return dc.convertLease(lease, true) +} + +// handleLeaseChange handles lease changes from the underlying DHCP client +func (dc *DHCPClient) handleLeaseChange(lease *dhclient.Lease, isIPv6 bool) { + if lease == nil { + return + } + + convertedLease := dc.convertLease(lease, isIPv6) + if convertedLease == nil { + dc.logger.Error().Msg("failed to convert lease") + return + } + + dc.logger.Info(). + Bool("ipv6", isIPv6). + Str("ip", convertedLease.IPAddress.String()). + Msg("DHCP lease changed") + + // Notify callback + if dc.onLeaseChange != nil { + dc.onLeaseChange(convertedLease) + } +} + +// convertLease converts a dhclient.Lease to types.DHCPLease +func (dc *DHCPClient) convertLease(lease *dhclient.Lease, isIPv6 bool) *types.DHCPLease { + if lease == nil { + return nil + } + + // Convert the lease + convertedLease := &types.DHCPLease{ + InterfaceName: dc.ifaceName, + Domain: lease.Domain, + SearchList: lease.SearchList, + NTPServers: lease.NTPServers, + } + + // Set IP address and related information + convertedLease.IPAddress = lease.IPAddress + convertedLease.Netmask = lease.Netmask + if len(lease.Routers) > 0 { + convertedLease.Gateway = lease.Routers[0] + } + + // Set DNS servers + convertedLease.DNS = lease.DNS + // convertedLease.LeaseTime = lease.LeaseTime + // convertedLease.RenewalTime = lease.RenewalTime + // convertedLease.RebindingTime = lease.RebindingTime + + return convertedLease +} diff --git a/pkg/nmlite/hostname.go b/pkg/nmlite/hostname.go new file mode 100644 index 00000000..48090cac --- /dev/null +++ b/pkg/nmlite/hostname.go @@ -0,0 +1,247 @@ +package nmlite + +import ( + "fmt" + "io" + "os" + "os/exec" + "strings" + "sync" + + "github.com/rs/zerolog" + "golang.org/x/net/idna" +) + +const ( + hostnamePath = "/etc/hostname" + hostsPath = "/etc/hosts" +) + +var ( + hostnameLock sync.Mutex +) + +// HostnameManager manages system hostname and /etc/hosts +type HostnameManager struct { + logger *zerolog.Logger +} + +// 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 { + 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 + } + + 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..f4968c98 --- /dev/null +++ b/pkg/nmlite/interface.go @@ -0,0 +1,624 @@ +package nmlite + +import ( + "context" + "fmt" + "net" + "sync" + "time" + + "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/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 + 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) + 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) { + im.updateStateFromDHCPLease(lease) + if im.onDHCPLeaseChange != nil { + im.onDHCPLeaseChange(lease) + } + }) + + return im, nil +} + +// Start starts managing the interface +func (im *InterfaceManager) Start() error { + im.logger.Info().Msg("starting interface manager") + + // Start monitoring interface state + im.wg.Add(1) + go im.monitorInterfaceState() + + // 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 { + return im.state.Up +} + +func (im *InterfaceManager) IsOnline() bool { + return im.IsUp() +} + +func (im *InterfaceManager) GetIPv4Addresses() []string { + return im.state.IPv4Addresses +} + +func (im *InterfaceManager) GetIPv6Addresses() []string { + addresses := []string{} + for _, addr := range im.state.IPv6Addresses { + addresses = append(addresses, addr.Address.String()) + } + return []string{} +} + +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 + state := *im.state + return &state +} + +func (im *InterfaceManager) NTPServers() []net.IP { + 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 +} + +// 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") + } + + // Disable DHCP + if im.dhcpClient != nil { + im.dhcpClient.SetIPv4(false) + } + + // Apply static configuration + return im.staticConfig.ApplyIPv4Static(im.config.IPv4Static) +} + +// 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") + } + + // Disable DHCPv6 + if im.dhcpClient != nil { + im.dhcpClient.SetIPv6(false) + } + + // Apply static configuration + return im.staticConfig.ApplyIPv6Static(im.config.IPv6Static) +} + +// 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() + if err := netlinkMgr.RemoveNonLinkLocalIPv6Addresses(l); err != nil { + return fmt.Errorf("failed to remove non-link-local IPv6 addresses: %w", err) + } + + // 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.GetLease4(); lease != nil && lease.Domain != "" { + return lease.Domain + } + } + + return "local" +} + +// monitorInterfaceState monitors the interface state and updates accordingly +func (im *InterfaceManager) monitorInterfaceState() { + defer im.wg.Done() + + // 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") + } + } + } +} + +// 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) + } + + attrs := nl.Attrs() + isUp := attrs.OperState == netlink.OperUp + + hasAddrs := false + addrs, err := nl.AddrList(link.AfInet) + if err != nil { + return fmt.Errorf("failed to get addresses: %w", err) + } + if len(addrs) > 0 { + hasAddrs = true + } + + im.stateMu.Lock() + defer im.stateMu.Unlock() + + // Check if state changed + stateChanged := false + if im.state.Up != isUp { + im.state.Up = isUp + stateChanged = true + } + if im.state.Online != hasAddrs { + im.state.Online = hasAddrs + stateChanged = true + } + + // if im.state.MACAddr != attrs.HardwareAddr { + // im.state.MACAddr = attrs.HardwareAddr + // stateChanged = true + // } + + // Update IP addresses + if err := im.updateIPAddresses(nl); err != nil { + im.logger.Error().Err(err).Msg("failed to update IP addresses") + } + + // im.state.LastUpdated = time.Now() // TODO: remove this + + // Notify callback if state changed + if stateChanged && im.onStateChange != nil { + state := *im.state + im.onStateChange(&state) + } + + return nil +} + +// updateIPAddresses updates the IP addresses in the state +func (im *InterfaceManager) updateIPAddresses(nl *link.Link) error { + addrs, err := nl.AddrList(link.AfUnspec) + if err != nil { + return fmt.Errorf("failed to get addresses: %w", err) + } + + var ipv4Addresses []string + var ipv6Addresses []types.IPv6Address + var ipv4Addr, ipv6Addr string + var ipv6LinkLocal string + + for _, addr := range addrs { + if addr.IP.To4() != nil { + // IPv4 address + ipv4Addresses = append(ipv4Addresses, addr.IPNet.String()) + if ipv4Addr == "" { + ipv4Addr = addr.IP.String() + } + } else if addr.IP.To16() != nil { + // IPv6 address + if addr.IP.IsLinkLocalUnicast() { + ipv6LinkLocal = addr.IP.String() + } else if addr.IP.IsGlobalUnicast() { + ipv6Addresses = append(ipv6Addresses, types.IPv6Address{ + Address: addr.IP, + Prefix: *addr.IPNet, + Scope: addr.Scope, + }) + if ipv6Addr == "" { + ipv6Addr = addr.IP.String() + } + } + } + } + + im.state.IPv4Addresses = ipv4Addresses + im.state.IPv6Addresses = ipv6Addresses + im.state.IPv6LinkLocal = ipv6LinkLocal + + return nil +} + +// updateStateFromDHCPLease updates the state from a DHCP lease +func (im *InterfaceManager) updateStateFromDHCPLease(lease *types.DHCPLease) { + im.stateMu.Lock() + defer im.stateMu.Unlock() + + im.state.DHCPLease4 = lease + + // Update resolv.conf with DNS information + if im.resolvConf != nil { + im.resolvConf.UpdateFromLease(lease) + } +} + +func (im *InterfaceManager) ReconcileLinkAddrs(ipv4Config *types.IPv4StaticConfig) error { + // nl := getNetlinkManager() + // return nl.ReconcileLinkAddrs(ipv4Config) + return nil +} + +// applyDHCPLease applies DHCP lease configuration using ReconcileLinkAddrs +func (im *InterfaceManager) applyDHCPLease(lease *types.DHCPLease) error { + // Convert DHCP lease to IPv4Config + // ipv4Config := im.convertDHCPLeaseToIPv4Config(lease) + + // Apply the configuration using ReconcileLinkAddrs + // return im.ReconcileLinkAddrs(ipv4Config) + return nil +} + +// convertDHCPLeaseToIPv4Config converts a DHCP lease to IPv4Config +// func (im *InterfaceManager) convertDHCPLeaseToIPv4Config(lease *types.DHCPLease) *netif.IPv4Config { +// // Create IPNet from IP and netmask +// ipNet := &net.IPNet{ +// IP: lease.IPAddress, +// Mask: net.IPMask(lease.Netmask), +// } + +// // Create IPv4Address +// ipv4Addr := netif.IPv4Address{ +// Address: *ipNet, +// Gateway: lease.Gateway, +// Secondary: false, +// Permanent: false, +// } + +// // Create IPv4Config +// return &netif.IPv4Config{ +// Addresses: []netif.IPv4Address{ipv4Addr}, +// Nameservers: lease.DNS, +// SearchList: lease.SearchList, +// Domain: lease.Domain, +// Interface: im.ifaceName, +// } +// } diff --git a/pkg/nmlite/link/netlink.go b/pkg/nmlite/link/netlink.go new file mode 100644 index 00000000..7c252920 --- /dev/null +++ b/pkg/nmlite/link/netlink.go @@ -0,0 +1,465 @@ +// Package link provides a wrapper around netlink.Link and provides a singleton netlink manager. +package link + +import ( + "context" + "errors" + "fmt" + "net" + "os" + "path" + "strconv" + "strings" + "sync" + "time" + + "github.com/rs/zerolog" + "github.com/vishvananda/netlink" +) + +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 +) + +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 + + // Error definitions + 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") +) + +// NetlinkManager provides centralized netlink operations +type NetlinkManager struct { + logger *zerolog.Logger + mu sync.RWMutex +} + +// Link is a wrapper around netlink.Link +type Link struct { + netlink.Link +} + +// Attrs returns the attributes of the link +func (l *Link) Attrs() *netlink.LinkAttrs { + return l.Link.Attrs() +} + +func (l *Link) AddrList(family int) ([]netlink.Addr, error) { + return netlink.AddrList(l, family) +} + +// GetNetlinkManager returns the singleton NetlinkManager instance +func GetNetlinkManager() *NetlinkManager { + netlinkManagerOnce.Do(func() { + netlinkManagerInstance = &NetlinkManager{ + logger: &zerolog.Logger{}, // Default no-op logger + } + }) + return netlinkManagerInstance +} + +// InitializeNetlinkManager initializes the singleton NetlinkManager with a logger +func InitializeNetlinkManager(logger *zerolog.Logger) *NetlinkManager { + netlinkManagerOnce.Do(func() { + if logger == nil { + // Create a no-op logger if none provided + logger = &zerolog.Logger{} + } + netlinkManagerInstance = &NetlinkManager{ + logger: logger, + } + }) + return netlinkManagerInstance +} + +// Interface operations + +// 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 + if state == netlink.OperUp || state == netlink.OperUnknown { + return link, nil + } + + l.Info().Str("state", state.String()).Msg("bringing up interface") + + if err = nm.LinkSetUp(link); err != nil { + l.Error().Err(err).Msg("interface can't make it up") + } + + l = l.With().Int("attempt", attempt).Dur("duration", time.Since(start)).Logger() + + if attempt > 0 { + l.Info().Msg("interface up") + } + + 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 +} + +// Route operations + +// 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) +} + +// HasDefaultRoute checks if a default route exists for the given family +func (nm *NetlinkManager) HasDefaultRoute(family int) bool { + routes, err := netlink.RouteList(nil, family) + if err != nil { + return false + } + + for _, route := range routes { + if route.Dst == nil { + return true + } + if family == AfInet && route.Dst.IP.Equal(net.IPv4zero) && route.Dst.Mask.String() == "0.0.0.0/0" { + return true + } + if family == AfInet6 && route.Dst.IP.Equal(net.IPv6zero) && route.Dst.Mask.String() == "::/0" { + return true + } + } + + return false +} + +// AddDefaultRoute adds a default route +func (nm *NetlinkManager) AddDefaultRoute(link *Link, gateway net.IP, family int) error { + var dst *net.IPNet + if family == AfInet { + dst = &ipv4DefaultRoute + } else if family == AfInet6 { + dst = &ipv6DefaultRoute + } else { + 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 +} + +// Sysctl operations + +// SetSysctlValues sets sysctl values for the interface +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, + }) +} + +// Utility functions + +// ParseIPv4Netmask parses an IPv4 netmask string and returns the IPNet +func (nm *NetlinkManager) 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 (nm *NetlinkManager) 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 (nm *NetlinkManager) 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/link/types.go b/pkg/nmlite/link/types.go new file mode 100644 index 00000000..abcf69e3 --- /dev/null +++ b/pkg/nmlite/link/types.go @@ -0,0 +1,23 @@ +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 +} + +// IPv4Config represents the configuration for an IPv4 interface +type IPv4Config struct { + Addresses []IPv4Address + Nameservers []net.IP + SearchList []string + Domain string + MTU int + Interface string +} diff --git a/pkg/nmlite/manager.go b/pkg/nmlite/manager.go new file mode 100644 index 00000000..8fe6819f --- /dev/null +++ b/pkg/nmlite/manager.go @@ -0,0 +1,211 @@ +// 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" + "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("networkmgr") + } + + // 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..873fd5ee --- /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]interface{}{ + "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/state.go b/pkg/nmlite/state.go new file mode 100644 index 00000000..f7bdf617 --- /dev/null +++ b/pkg/nmlite/state.go @@ -0,0 +1,81 @@ +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 { + return nm.IsOnline() +} + +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) 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) IPv4String() string { + l := nm.GetIPv4Addresses() + if len(l) == 0 { + return "" + } + return l[0] +} + +func (nm *NetworkManager) IPv6String() string { + l := nm.GetIPv6Addresses() + if len(l) == 0 { + return "" + } + return l[0] +} + +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..1bf24c47 --- /dev/null +++ b/pkg/nmlite/static.go @@ -0,0 +1,348 @@ +package nmlite + +import ( + "fmt" + "net" + + "github.com/jetkvm/kvm/internal/network/types" + "github.com/jetkvm/kvm/pkg/nmlite/link" + "github.com/rs/zerolog" + "github.com/vishvananda/netlink" +) + +// 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 +} + +// ApplyIPv4Static applies static IPv4 configuration +func (scm *StaticConfigManager) ApplyIPv4Static(config *types.IPv4StaticConfig) error { + scm.logger.Info().Msg("applying static IPv4 configuration") + + // Parse and validate configuration + ipv4Config, err := scm.parseIPv4Config(config) + if err != nil { + return fmt.Errorf("failed to parse IPv4 config: %w", err) + } + + // Get interface + netlinkMgr := getNetlinkManager() + link, err := netlinkMgr.GetLinkByName(scm.ifaceName) + if err != nil { + return fmt.Errorf("failed to get interface: %w", err) + } + + // Ensure interface is up + if err := netlinkMgr.EnsureInterfaceUp(link); err != nil { + return fmt.Errorf("failed to bring interface up: %w", err) + } + + // Apply IP address + if err := scm.applyIPv4Address(link, ipv4Config); err != nil { + return fmt.Errorf("failed to apply IPv4 address: %w", err) + } + + // Apply default route + if err := scm.applyIPv4Route(link, ipv4Config); err != nil { + return fmt.Errorf("failed to apply IPv4 route: %w", err) + } + + scm.logger.Info().Msg("static IPv4 configuration applied successfully") + return nil +} + +// ApplyIPv6Static applies static IPv6 configuration +func (scm *StaticConfigManager) ApplyIPv6Static(config *types.IPv6StaticConfig) error { + scm.logger.Info().Msg("applying static IPv6 configuration") + + // Parse and validate configuration + ipv6Config, err := scm.parseIPv6Config(config) + if err != nil { + return fmt.Errorf("failed to parse IPv6 config: %w", err) + } + + // Get interface + netlinkMgr := getNetlinkManager() + link, err := netlinkMgr.GetLinkByName(scm.ifaceName) + if err != nil { + return fmt.Errorf("failed to get interface: %w", err) + } + + // Enable IPv6 + if err := scm.enableIPv6(); err != nil { + return fmt.Errorf("failed to enable IPv6: %w", err) + } + + // Ensure interface is up + if err := netlinkMgr.EnsureInterfaceUp(link); err != nil { + return fmt.Errorf("failed to bring interface up: %w", err) + } + + // Apply IP address + if err := scm.applyIPv6Address(link, ipv6Config); err != nil { + return fmt.Errorf("failed to apply IPv6 address: %w", err) + } + + // Apply default route + if err := scm.applyIPv6Route(link, ipv6Config); err != nil { + return fmt.Errorf("failed to apply IPv6 route: %w", err) + } + + scm.logger.Info().Msg("static IPv6 configuration applied successfully") + return 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) +} + +// parseIPv4Config parses and validates IPv4 static configuration +func (scm *StaticConfigManager) parseIPv4Config(config *types.IPv4StaticConfig) (*parsedIPv4Config, error) { + if config == nil { + return nil, fmt.Errorf("config is nil") + } + + // Parse IP address and netmask + netlinkMgr := getNetlinkManager() + ipNet, err := netlinkMgr.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 := netlinkMgr.ValidateIPAddress(dnsStr, false); err != nil { + return nil, fmt.Errorf("invalid DNS server: %w", err) + } + dns = append(dns, net.ParseIP(dnsStr)) + } + + return &parsedIPv4Config{ + network: *ipNet, + gateway: gateway, + dns: dns, + }, nil +} + +// parseIPv6Config parses and validates IPv6 static configuration +func (scm *StaticConfigManager) parseIPv6Config(config *types.IPv6StaticConfig) (*parsedIPv6Config, error) { + if config == nil { + return nil, fmt.Errorf("config is nil") + } + + // Parse IP address and prefix + netlinkMgr := getNetlinkManager() + ipNet, err := netlinkMgr.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) + } + + return &parsedIPv6Config{ + prefix: *ipNet, + gateway: gateway, + dns: dns, + }, nil +} + +// applyIPv4Address applies IPv4 address to interface +func (scm *StaticConfigManager) applyIPv4Address(iface *link.Link, config *parsedIPv4Config) error { + netlinkMgr := getNetlinkManager() + + // Remove existing IPv4 addresses + if err := netlinkMgr.RemoveAllAddresses(iface, link.AfInet); err != nil { + return fmt.Errorf("failed to remove existing IPv4 addresses: %w", err) + } + + // Add new address + addr := &netlink.Addr{ + IPNet: &config.network, + } + if err := netlinkMgr.AddrAdd(iface, addr); err != nil { + return fmt.Errorf("failed to add IPv4 address: %w", err) + } + + scm.logger.Info().Str("address", config.network.String()).Msg("IPv4 address applied") + return nil +} + +// applyIPv6Address applies IPv6 address to interface +func (scm *StaticConfigManager) applyIPv6Address(iface *link.Link, config *parsedIPv6Config) error { + netlinkMgr := getNetlinkManager() + + // Remove existing global IPv6 addresses + if err := netlinkMgr.RemoveNonLinkLocalIPv6Addresses(iface); err != nil { + return fmt.Errorf("failed to remove existing IPv6 addresses: %w", err) + } + + // Add new address + addr := &netlink.Addr{ + IPNet: &config.prefix, + } + if err := netlinkMgr.AddrAdd(iface, addr); err != nil { + return fmt.Errorf("failed to add IPv6 address: %w", err) + } + + scm.logger.Info().Str("address", config.prefix.String()).Msg("IPv6 address applied") + return nil +} + +// applyIPv4Route applies IPv4 default route +func (scm *StaticConfigManager) applyIPv4Route(iface *link.Link, config *parsedIPv4Config) error { + netlinkMgr := getNetlinkManager() + + // Check if default route already exists + if netlinkMgr.HasDefaultRoute(link.AfInet) { + scm.logger.Info().Msg("IPv4 default route already exists") + return nil + } + + // Add default route + if err := netlinkMgr.AddDefaultRoute(iface, config.gateway, link.AfInet); err != nil { + return fmt.Errorf("failed to add IPv4 default route: %w", err) + } + + scm.logger.Info().Str("gateway", config.gateway.String()).Msg("IPv4 default route applied") + return nil +} + +// applyIPv6Route applies IPv6 default route +func (scm *StaticConfigManager) applyIPv6Route(iface *link.Link, config *parsedIPv6Config) error { + netlinkMgr := getNetlinkManager() + + // Check if default route already exists + if netlinkMgr.HasDefaultRoute(link.AfInet6) { + scm.logger.Info().Msg("IPv6 default route already exists") + return nil + } + + // Add default route + if err := netlinkMgr.AddDefaultRoute(iface, config.gateway, link.AfInet6); err != nil { + return fmt.Errorf("failed to add IPv6 default route: %w", err) + } + + scm.logger.Info().Str("gateway", config.gateway.String()).Msg("IPv6 default route applied") + return nil +} + +// 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) +} + +// parsedIPv4Config represents parsed IPv4 configuration +type parsedIPv4Config struct { + network net.IPNet + gateway net.IP + dns []net.IP +} + +// parsedIPv6Config represents parsed IPv6 configuration +type parsedIPv6Config struct { + prefix net.IPNet + gateway net.IP + dns []net.IP +} diff --git a/timesync.go b/timesync.go index 7b25fe26..abb427e0 100644 --- a/timesync.go +++ b/timesync.go @@ -44,7 +44,7 @@ func initTimeSync() { Logger: timesyncLogger, NetworkConfig: config.NetworkConfig, 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({ -
+
{cancelText && ( -
+
@@ -44,24 +71,15 @@ export default function DhcpLeaseCard({
)} - {networkState?.dhcp_lease?.dns && ( + {networkState?.dhcp_lease?.dns_servers && (
DNS Servers - {networkState?.dhcp_lease?.dns.map(dns =>
{dns}
)} -
-
- )} - - {networkState?.dhcp_lease?.broadcast && ( -
- - Broadcast - - - {networkState?.dhcp_lease?.broadcast} + {networkState?.dhcp_lease?.dns_servers.map(dns => ( +
{dns}
+ ))}
)} @@ -142,6 +160,17 @@ export default function DhcpLeaseCard({
)} + {networkState?.dhcp_lease?.broadcast && ( +
+ + Broadcast + + + {networkState?.dhcp_lease?.broadcast} + +
+ )} + {networkState?.dhcp_lease?.mtu && (
MTU @@ -194,17 +223,6 @@ export default function DhcpLeaseCard({ )}
- -
-
diff --git a/ui/src/components/Ipv6NetworkCard.tsx b/ui/src/components/Ipv6NetworkCard.tsx index 0cfacc6d..ac9d20fb 100644 --- a/ui/src/components/Ipv6NetworkCard.tsx +++ b/ui/src/components/Ipv6NetworkCard.tsx @@ -6,7 +6,7 @@ import { GridCard } from "./Card"; export default function Ipv6NetworkCard({ networkState, }: { - networkState: NetworkState; + networkState: NetworkState | undefined; }) { return ( @@ -17,7 +17,7 @@ export default function Ipv6NetworkCard({
- {networkState?.ipv6_link_local && ( + {networkState?.dhcp_lease?.ip && (
Link-local @@ -33,56 +33,54 @@ export default function Ipv6NetworkCard({ {networkState?.ipv6_addresses && networkState?.ipv6_addresses.length > 0 && (

IPv6 Addresses

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

{title}

-
{description}
+
+
+

{title}

+
{description}
+
+ {action &&
{action}
}
); -} +} \ No newline at end of file diff --git a/ui/src/components/StaticIpv4Card.tsx b/ui/src/components/StaticIpv4Card.tsx new file mode 100644 index 00000000..e2eaf2c5 --- /dev/null +++ b/ui/src/components/StaticIpv4Card.tsx @@ -0,0 +1,113 @@ +import { LuPlus, LuX } from "react-icons/lu"; +import { useFieldArray, useFormContext } from "react-hook-form"; +import { useEffect } from "react"; +import validator from "validator"; + +import { GridCard } from "@/components/Card"; +import { Button } from "@/components/Button"; +import { InputFieldWithLabel } from "@/components/InputField"; +import { NetworkSettings } from "@/hooks/stores"; + +export default function StaticIpv4Card() { + const formMethods = useFormContext(); + const { register, formState, watch } = formMethods; + + const { fields, append, remove } = useFieldArray({ name: "ipv4_static.dns" }); + + // TODO: set subnet mask if IP address is in CIDR notation + + useEffect(() => { + if (fields.length === 0) append(""); + }, [append, fields.length]); + + const dns = watch("ipv4_static.dns"); + + const validate = (value: string) => { + if (!validator.isIP(value)) return "Invalid IP address"; + return true; + }; + + return ( + +
+
+

+ Static IPv4 Configuration +

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

+ Static IPv6 Configuration +

+ + + + + + {/* DNS server fields */} +
+ {fields.map((dns, index) => { + return ( +
+
+
+ +
+ {index > 0 && ( +
+
+ )} +
+
+ ); + })} +
+ +
+
+
+ ); +} diff --git a/ui/src/hooks/stores.ts b/ui/src/hooks/stores.ts index bfbbb26e..e539f916 100644 --- a/ui/src/hooks/stores.ts +++ b/ui/src/hooks/stores.ts @@ -672,6 +672,7 @@ export interface DhcpLease { timezone?: string; routers?: string[]; dns?: string[]; + dns_servers?: string[]; ntp_servers?: string[]; lpr_servers?: string[]; _time_servers?: string[]; @@ -732,12 +733,27 @@ export type TimeSyncMode = | "custom" | "unknown"; +export interface IPv4StaticConfig { + address: string; + netmask: string; + gateway: string; + dns: string[]; +} + +export interface IPv6StaticConfig { + prefix: string; + gateway: string; + dns: string[]; +} + export interface NetworkSettings { - hostname: string; - domain: string; - http_proxy: string; + hostname: string | null; + domain: string | null; + http_proxy: string | null; ipv4_mode: IPv4Mode; + ipv4_static?: IPv4StaticConfig; ipv6_mode: IPv6Mode; + ipv6_static?: IPv6StaticConfig; lldp_mode: LLDPMode; lldp_tx_tlvs: string[]; mdns_mode: mDNSMode; @@ -925,4 +941,4 @@ export const useMacrosStore = create((set, get) => ({ set({ loading: false }); } } -})); +})); \ No newline at end of file diff --git a/ui/src/index.css b/ui/src/index.css index b13fc3a1..0e837875 100644 --- a/ui/src/index.css +++ b/ui/src/index.css @@ -109,6 +109,15 @@ transform: translateY(0); } } + + @keyframes fadeInStill { + 0% { + opacity: 0; + } + 100% { + opacity: 1; + } + } @keyframes slideUpFade { 0% { diff --git a/ui/src/routes/devices.$id.settings.network.tsx b/ui/src/routes/devices.$id.settings.network.tsx index 5f4dc90f..11897600 100644 --- a/ui/src/routes/devices.$id.settings.network.tsx +++ b/ui/src/routes/devices.$id.settings.network.tsx @@ -1,46 +1,48 @@ -import { useCallback, useEffect, useRef, useState } from "react"; import dayjs from "dayjs"; import relativeTime from "dayjs/plugin/relativeTime"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { FieldValues, FormProvider, useForm } from "react-hook-form"; import { LuEthernetPort } from "react-icons/lu"; +import validator from "validator"; -import { - IPv4Mode, - IPv6Mode, - LLDPMode, - mDNSMode, - NetworkSettings, - NetworkState, - TimeSyncMode, - useNetworkStateStore, -} from "@/hooks/stores"; -import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc"; +import { ConfirmDialog } from "@/components/ConfirmDialog"; +import { SelectMenuBasic } from "@/components/SelectMenuBasic"; +import { SettingsPageHeader } from "@/components/SettingsPageheader"; +import { NetworkSettings, NetworkState, useRTCStore } from "@/hooks/stores"; +import notifications from "@/notifications"; +import { getNetworkSettings, getNetworkState } from "@/utils/jsonrpc"; import { Button } from "@components/Button"; import { GridCard } from "@components/Card"; import InputField, { InputFieldWithLabel } from "@components/InputField"; -import { SelectMenuBasic } from "@/components/SelectMenuBasic"; -import { SettingsPageHeader } from "@/components/SettingsPageheader"; -import Fieldset from "@/components/Fieldset"; -import { ConfirmDialog } from "@/components/ConfirmDialog"; -import { SettingsItem } from "@components/SettingsItem"; -import notifications from "@/notifications"; -import Ipv6NetworkCard from "../components/Ipv6NetworkCard"; -import EmptyCard from "../components/EmptyCard"; import AutoHeight from "../components/AutoHeight"; import DhcpLeaseCard from "../components/DhcpLeaseCard"; +import EmptyCard from "../components/EmptyCard"; +import Ipv6NetworkCard from "../components/Ipv6NetworkCard"; +import StaticIpv4Card from "../components/StaticIpv4Card"; +import StaticIpv6Card from "../components/StaticIpv6Card"; +import { useJsonRpc } from "../hooks/useJsonRpc"; +import { SettingsItem } from "../components/SettingsItem"; dayjs.extend(relativeTime); -const defaultNetworkSettings: NetworkSettings = { - hostname: "", - http_proxy: "", - domain: "", - ipv4_mode: "unknown", - ipv6_mode: "unknown", - lldp_mode: "unknown", - lldp_tx_tlvs: [], - mdns_mode: "unknown", - time_sync_mode: "unknown", +const resolveOnRtcReady = () => { + return new Promise(resolve => { + // Check if RTC is already connected + const currentState = useRTCStore.getState(); + if (currentState.rpcDataChannel?.readyState === "open") { + // Already connected, fetch data immediately + return resolve(void 0); + } + + // Not connected yet, subscribe to state changes + const unsubscribe = useRTCStore.subscribe(state => { + if (state.rpcDataChannel?.readyState === "open") { + unsubscribe(); // Clean up subscription + return resolve(void 0); + } + }); + }); }; export function LifeTimeLabel({ lifetime }: { lifetime: string }) { @@ -72,418 +74,457 @@ export function LifeTimeLabel({ lifetime }: { lifetime: string }) { export default function SettingsNetworkRoute() { const { send } = useJsonRpc(); - const [networkState, setNetworkState] = useNetworkStateStore(state => [ - state, - state.setNetworkState, - ]); - const [networkSettings, setNetworkSettings] = - useState(defaultNetworkSettings); - - // We use this to determine whether the settings have changed - const firstNetworkSettings = useRef(undefined); - - const [networkSettingsLoaded, setNetworkSettingsLoaded] = useState(false); + const [networkState, setNetworkState] = useState(null); + // Some input needs direct state management. Mostly options that open more details const [customDomain, setCustomDomain] = useState(""); - const [selectedDomainOption, setSelectedDomainOption] = useState("dhcp"); - useEffect(() => { - if (networkSettings.domain && networkSettingsLoaded) { - // Check if the domain is one of the predefined options - const predefinedOptions = ["dhcp", "local"]; - if (predefinedOptions.includes(networkSettings.domain)) { - setSelectedDomainOption(networkSettings.domain); - } else { - setSelectedDomainOption("custom"); - setCustomDomain(networkSettings.domain); - } + // Confirm dialog + const [showRenewLeaseConfirm, setShowRenewLeaseConfirm] = useState(false); + const initialSettingsRef = useRef(null); + + const [showCriticalSettingsConfirm, setShowCriticalSettingsConfirm] = useState(false); + const [stagedSettings, setStagedSettings] = useState(null); + const [criticalChanges, setCriticalChanges] = useState< + { label: string; from: string; to: string }[] + >([]); + + const fetchNetworkData = useCallback(async () => { + try { + console.log("Fetching network data..."); + + const [settings, state] = (await Promise.all([ + getNetworkSettings(), + getNetworkState(), + ])) as [NetworkSettings, NetworkState]; + + setNetworkState(state as NetworkState); + + const settingsWithDefaults = { + ...settings, + + domain: settings.domain || "local", // TODO: null means local domain TRUE????? + mdns_mode: settings.mdns_mode || "disabled", + time_sync_mode: settings.time_sync_mode || "ntp_only", + ipv4_static: { + address: settings.ipv4_static?.address || state.dhcp_lease?.ip || "", + netmask: settings.ipv4_static?.netmask || state.dhcp_lease?.netmask || "", + gateway: settings.ipv4_static?.gateway || state.dhcp_lease?.routers?.[0] || "", + dns: settings.ipv4_static?.dns || state.dhcp_lease?.dns_servers || [], + }, + ipv6_static: { + prefix: settings.ipv6_static?.prefix || state.ipv6_addresses?.[0]?.prefix || "", + gateway: settings.ipv6_static?.gateway || "", + dns: settings.ipv6_static?.dns || [], + }, + }; + + initialSettingsRef.current = settingsWithDefaults; + return { settings: settingsWithDefaults, state }; + } catch (err) { + notifications.error(err instanceof Error ? err.message : "Unknown error"); + throw err; } - }, [networkSettings.domain, networkSettingsLoaded]); + }, []); - const getNetworkSettings = useCallback(() => { - setNetworkSettingsLoaded(false); - send("getNetworkSettings", {}, (resp: JsonRpcResponse) => { - if ("error" in resp) return; - const networkSettings = resp.result as NetworkSettings; - console.debug("Network settings: ", networkSettings); - setNetworkSettings(networkSettings); + const formMethods = useForm({ + mode: "onBlur", - if (!firstNetworkSettings.current) { - firstNetworkSettings.current = networkSettings; - } - setNetworkSettingsLoaded(true); - }); - }, [send]); + defaultValues: async () => { + console.log("Preparing form default values..."); - const getNetworkState = useCallback(() => { - send("getNetworkState", {}, (resp: JsonRpcResponse) => { - if ("error" in resp) return; - const networkState = resp.result as NetworkState; - console.debug("Network state:", networkState); - setNetworkState(networkState); - }); - }, [send, setNetworkState]); + // Ensure data channel is ready, before fetching network data from the device + await resolveOnRtcReady(); - const setNetworkSettingsRemote = useCallback( - (settings: NetworkSettings) => { - setNetworkSettingsLoaded(false); - send("setNetworkSettings", { settings }, (resp: JsonRpcResponse) => { - if ("error" in resp) { - notifications.error( - "Failed to save network settings: " + - (resp.error.data ? resp.error.data : resp.error.message), - ); - setNetworkSettingsLoaded(true); - return; - } - const networkSettings = resp.result as NetworkSettings; - // We need to update the firstNetworkSettings ref to the new settings so we can use it to determine if the settings have changed - firstNetworkSettings.current = networkSettings; - setNetworkSettings(networkSettings); - getNetworkState(); - setNetworkSettingsLoaded(true); - notifications.success("Network settings saved"); - }); + const { settings } = await fetchNetworkData(); + return settings; }, - [getNetworkState, send], - ); + }); - const handleRenewLease = useCallback(() => { - send("renewDHCPLease", {}, (resp: JsonRpcResponse) => { + const prepareSettings = (data: FieldValues) => { + return { + ...data, + + // If custom domain option is selected, use the custom domain as value + domain: data.domain === "custom" ? customDomain : data.domain, + } as NetworkSettings; + }; + + const { register, handleSubmit, watch, formState, reset } = formMethods; + + const onSubmit = async (settings: NetworkSettings) => { + send("setNetworkSettings", { settings }, async (resp: any) => { + if ("error" in resp) { + return notifications.error( + resp.error.data ? resp.error.data : resp.error.message, + ); + } else { + // If the settings are saved successfully, fetch the latest network data and reset the form + // We do this so we get all the form state values, for stuff like is the form dirty, etc... + const networkData = await fetchNetworkData(); + reset(networkData.settings); + notifications.success("Network settings saved"); + } + }); + }; + + const onSubmitGate = async (data: FieldValues) => { + const settings = prepareSettings(data); + const dirty = formState.dirtyFields; + + // These fields will prompt a confirm dialog, all else save immediately + const criticalFields = [ + // Label is for the UI, key is the internal key of the field + { label: "IPv4 mode", key: "ipv4_mode" }, + { label: "IPv6 mode", key: "ipv6_mode" }, + ] as { label: string; key: keyof NetworkSettings }[]; + + const criticalChanged = criticalFields.some(field => dirty[field.key]); + + // If no critical fields are changed, save immediately + if (!criticalChanged) return onSubmit(settings); + + const changes = new Set<{ label: string; from: string; to: string }>(); + criticalFields.forEach(field => { + const { key, label } = field; + if (dirty[key]) { + const from = initialSettingsRef?.current?.[key] as string; + const to = data[key] as string; + changes.add({ label, from, to }); + } + }); + + setStagedSettings(settings); + setCriticalChanges(Array.from(changes)); + setShowCriticalSettingsConfirm(true); + }; + + const ipv4mode = watch("ipv4_mode"); + const ipv6mode = watch("ipv6_mode"); + + const onDhcpLeaseRenew = () => { + send("renewDHCPLease", {}, (resp: any) => { if ("error" in resp) { notifications.error("Failed to renew lease: " + resp.error.message); } else { notifications.success("DHCP lease renewed"); } }); - }, [send]); - - useEffect(() => { - getNetworkState(); - getNetworkSettings(); - }, [getNetworkState, getNetworkSettings]); - - const handleIpv4ModeChange = (value: IPv4Mode | string) => { - setNetworkSettingsRemote({ ...networkSettings, ipv4_mode: value as IPv4Mode }); }; - const handleIpv6ModeChange = (value: IPv6Mode | string) => { - setNetworkSettingsRemote({ ...networkSettings, ipv6_mode: value as IPv6Mode }); - }; - - const handleLldpModeChange = (value: LLDPMode | string) => { - setNetworkSettings({ ...networkSettings, lldp_mode: value as LLDPMode }); - }; - - const handleMdnsModeChange = (value: mDNSMode | string) => { - setNetworkSettings({ ...networkSettings, mdns_mode: value as mDNSMode }); - }; - - const handleTimeSyncModeChange = (value: TimeSyncMode | string) => { - setNetworkSettings({ ...networkSettings, time_sync_mode: value as TimeSyncMode }); - }; - - const handleHostnameChange = (value: string) => { - setNetworkSettings({ ...networkSettings, hostname: value }); - }; - - const handleProxyChange = (value: string) => { - setNetworkSettings({ ...networkSettings, http_proxy: value }); - }; - - const handleDomainChange = (value: string) => { - setNetworkSettings({ ...networkSettings, domain: value }); - }; - - const handleDomainOptionChange = (value: string) => { - setSelectedDomainOption(value); - if (value !== "custom") { - handleDomainChange(value); - } - }; - - const handleCustomDomainChange = (value: string) => { - setCustomDomain(value); - handleDomainChange(value); - }; - - const filterUnknown = useCallback( - (options: { value: string; label: string }[]) => { - if (!networkSettingsLoaded) return options; - return options.filter(option => option.value !== "unknown"); - }, - [networkSettingsLoaded], - ); - - const [showRenewLeaseConfirm, setShowRenewLeaseConfirm] = useState(false); - return ( <> -
- -
- - - -
-
- -
-
- { - handleHostnameChange(e.target.value); - }} - /> -
-
-
-
-
- -
-
- { - handleProxyChange(e.target.value); - }} - /> -
-
-
-
- -
-
- -
- handleDomainOptionChange(e.target.value)} - options={[ - { value: "dhcp", label: "DHCP provided" }, - { value: "local", label: ".local" }, - { value: "custom", label: "Custom" }, - ]} - /> -
-
- {selectedDomainOption === "custom" && ( -
- { - setCustomDomain(e.target.value); - handleCustomDomainChange(e.target.value); - }} - /> -
- )} -
+ +
+ + + {(formState.isDirty || formState.isSubmitting) && ( + //
+
+
+ )} + + } + />
+ + + + + + + { + if (value === "" || value === null) return true; + if (!validator.isURL(value || "", { protocols: ["http", "https"] })) { + return "Invalid HTTP proxy URL"; + } + return true; + }, + })} + error={formState.errors.http_proxy?.message} + /> + +
+ +
+ +
+
+ {watch("domain") === "custom" && ( +
+ { + setCustomDomain(e.target.value); + }} + /> +
+ )} +
+ + handleMdnsModeChange(e.target.value)} - options={filterUnknown([ + options={[ { value: "disabled", label: "Disabled" }, { value: "auto", label: "Auto" }, { value: "ipv4_only", label: "IPv4 only" }, { value: "ipv6_only", label: "IPv6 only" }, - ])} + ]} + {...register("mdns_mode")} /> -
- -
{ - handleTimeSyncModeChange(e.target.value); - }} - options={filterUnknown([ - { value: "unknown", label: "..." }, - // { value: "auto", label: "Auto" }, + options={[ { value: "ntp_only", label: "NTP only" }, { value: "ntp_and_http", label: "NTP and HTTP" }, { value: "http_only", label: "HTTP only" }, - // { value: "custom", label: "Custom" }, - ])} + ]} + {...register("time_sync_mode")} /> + + + + +
+ + {formState.isLoading ? ( + +
+
+
+
+
+
+
+
+
+
+
+
+ + ) : ipv4mode === "static" ? ( + + ) : ipv4mode === "dhcp" && !!formState.dirtyFields.ipv4_mode ? ( + + ) : ipv4mode === "dhcp" ? ( + + ) : ( + + )} + +
+ + + + +
+ + {!networkState ? ( + +
+
+

+ IPv6 Network Information +

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

+ This will update the device's network configuration and may briefly + disconnect your session. +

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

- DHCP Lease Information -

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

- IPv6 Information -

-
-
-
-
-
-
-
- - ) : networkState?.ipv6_addresses && networkState.ipv6_addresses.length > 0 ? ( - - ) : ( - - )} - -
-
- - handleLldpModeChange(e.target.value)} - options={filterUnknown([ - { value: "disabled", label: "Disabled" }, - { value: "basic", label: "Basic" }, - { value: "all", label: "All" }, - ])} - /> - -
-
+ ))} + +
+ +

+ If the network settings are invalid,{" "} + the device may become unreachable and require a factory + reset to restore connectivity. +

+
+ } + /> setShowRenewLeaseConfirm(false)} title="Renew DHCP Lease" - description="This will request a new IP address from your DHCP server. Your device may temporarily lose network connectivity during this process." - variant="danger" + variant="warning" confirmText="Renew Lease" + description={ +

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

+ } onConfirm={() => { - handleRenewLease(); setShowRenewLeaseConfirm(false); + onDhcpLeaseRenew(); }} + onClose={() => setShowRenewLeaseConfirm(false)} /> ); diff --git a/ui/src/utils/jsonrpc.ts b/ui/src/utils/jsonrpc.ts new file mode 100644 index 00000000..43ab7ec2 --- /dev/null +++ b/ui/src/utils/jsonrpc.ts @@ -0,0 +1,103 @@ +import { useRTCStore } from "@/hooks/stores"; + +// JSON-RPC utility for use outside of React components +export interface JsonRpcCallOptions { + method: string; + params?: unknown; + timeout?: number; +} + +export interface JsonRpcCallResponse { + jsonrpc: string; + result?: unknown; + error?: { + code: number; + message: string; + data?: unknown; + }; + id: number | string | null; +} + +let rpcCallCounter = 0; + +export function callJsonRpc(options: JsonRpcCallOptions): Promise { + return new Promise((resolve, reject) => { + // Access the RTC store directly outside of React context + const rpcDataChannel = useRTCStore.getState().rpcDataChannel; + + if (!rpcDataChannel || rpcDataChannel.readyState !== "open") { + reject(new Error("RPC data channel not available")); + return; + } + + rpcCallCounter++; + const requestId = `rpc_${Date.now()}_${rpcCallCounter}`; + + const request = { + jsonrpc: "2.0", + method: options.method, + params: options.params || {}, + id: requestId, + }; + + const timeout = options.timeout || 5000; + let timeoutId: number | undefined; + + const messageHandler = (event: MessageEvent) => { + try { + const response = JSON.parse(event.data) as JsonRpcCallResponse; + if (response.id === requestId) { + clearTimeout(timeoutId); + rpcDataChannel.removeEventListener("message", messageHandler); + resolve(response); + } + } catch (error) { + // Ignore parse errors from other messages + } + }; + + timeoutId = setTimeout(() => { + rpcDataChannel.removeEventListener("message", messageHandler); + reject(new Error(`JSON-RPC call timed out after ${timeout}ms`)); + }, timeout); + + rpcDataChannel.addEventListener("message", messageHandler); + rpcDataChannel.send(JSON.stringify(request)); + }); +} + +// Specific network settings API calls +export async function getNetworkSettings() { + const response = await callJsonRpc({ method: "getNetworkSettings" }); + if (response.error) { + throw new Error(response.error.message); + } + return response.result; +} + +export async function setNetworkSettings(settings: unknown) { + const response = await callJsonRpc({ + method: "setNetworkSettings", + params: { settings }, + }); + if (response.error) { + throw new Error(response.error.message); + } + return response.result; +} + +export async function getNetworkState() { + const response = await callJsonRpc({ method: "getNetworkState" }); + if (response.error) { + throw new Error(response.error.message); + } + return response.result; +} + +export async function renewDHCPLease() { + const response = await callJsonRpc({ method: "renewDHCPLease" }); + if (response.error) { + throw new Error(response.error.message); + } + return response.result; +} From 2f33c08c9863a495f281bb03b1aa4c409de9b9bc Mon Sep 17 00:00:00 2001 From: Siyuan Date: Tue, 7 Oct 2025 12:28:27 +0000 Subject: [PATCH 02/85] fix compatibility with new network types --- internal/network/types/type.go | 52 ++++++++++++++++++++------- network.go | 5 +-- pkg/nmlite/dhclient/lease.go | 45 ++++------------------- pkg/nmlite/dhcp.go | 23 +----------- pkg/nmlite/interface.go | 17 ++++++--- pkg/nmlite/manager.go | 2 +- ui/src/components/Ipv6NetworkCard.tsx | 18 +++++----- 7 files changed, 71 insertions(+), 91 deletions(-) diff --git a/internal/network/types/type.go b/internal/network/types/type.go index cac4b2c4..3544fb35 100644 --- a/internal/network/types/type.go +++ b/internal/network/types/type.go @@ -105,20 +105,46 @@ func (c *NetworkConfig) GetTransportProxyFunc() func(*http.Request) (*url.URL, e } } -// DHCPLease represents a DHCP lease +// DHCPLease is a network configuration obtained by DHCP. type DHCPLease struct { - InterfaceName string `json:"interface_name"` - IPAddress net.IP `json:"ip_address"` - Netmask net.IP `json:"netmask"` - Gateway net.IP `json:"gateway"` - DNS []net.IP `json:"dns"` - SearchList []string `json:"search_list"` - Domain string `json:"domain"` - NTPServers []net.IP `json:"ntp_servers"` - LeaseTime time.Time `json:"lease_time"` - RenewalTime time.Time `json:"renewal_time"` - RebindingTime time.Time `json:"rebinding_time"` - ExpiryTime time.Time `json:"expiry_time"` + // from https://udhcp.busybox.net/README.udhcpc + IPAddress net.IP `env:"ip" json:"ip"` // The obtained IP + Netmask net.IP `env:"subnet" json:"netmask"` // The assigned subnet mask + Broadcast net.IP `env:"broadcast" json:"broadcast"` // The broadcast address for this network + TTL int `env:"ipttl" json:"ttl,omitempty"` // The TTL to use for this network + MTU int `env:"mtu" json:"mtu,omitempty"` // The MTU to use for this network + HostName string `env:"hostname" json:"hostname,omitempty"` // The assigned hostname + Domain string `env:"domain" json:"domain,omitempty"` // The domain name of the network + 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 + Timezone string `env:"timezone" json:"timezone,omitempty"` // Offset in seconds from UTC + Routers []net.IP `env:"router" json:"routers,omitempty"` // A list of routers + DNS []net.IP `env:"dns" json:"dns_servers,omitempty"` // A list of DNS servers + NTPServers []net.IP `env:"ntpsrv" json:"ntp_servers,omitempty"` // A list of NTP servers + LPRServers []net.IP `env:"lprsvr" json:"lpr_servers,omitempty"` // A list of LPR servers + TimeServers []net.IP `env:"timesvr" json:"_time_servers,omitempty"` // A list of time servers (obsolete) + IEN116NameServers []net.IP `env:"namesvr" json:"_name_servers,omitempty"` // A list of IEN 116 name servers (obsolete) + LogServers []net.IP `env:"logsvr" json:"_log_servers,omitempty"` // A list of MIT-LCS UDP log servers (obsolete) + CookieServers []net.IP `env:"cookiesvr" json:"_cookie_servers,omitempty"` // A list of RFC 865 cookie servers (obsolete) + WINSServers []net.IP `env:"wins" json:"_wins_servers,omitempty"` // A list of WINS servers + SwapServer net.IP `env:"swapsvr" json:"_swap_server,omitempty"` // The IP address of the client's swap server + BootSize int `env:"bootsize" json:"bootsize,omitempty"` // The length in 512 octect blocks of the bootfile + RootPath string `env:"rootpath" json:"root_path,omitempty"` // The path name of the client's root disk + LeaseTime time.Duration `env:"lease" json:"lease,omitempty"` // The lease time, in seconds + 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 + + InterfaceName string `json:"interface_name,omitempty"` // The name of the interface } // InterfaceState represents the current state of a network interface diff --git a/network.go b/network.go index 03618891..de2ff956 100644 --- a/network.go +++ b/network.go @@ -45,7 +45,7 @@ func restartMdns() { }, true) } -func networkStateChanged(isOnline bool) { +func networkStateChanged(iface string, state *types.InterfaceState) { // do not block the main thread go waitCtrlAndRequestDisplayUpdate(true, "network_state_changed") @@ -65,7 +65,7 @@ func networkStateChanged(isOnline bool) { } // if the network is now online, trigger an NTP sync if still needed - if isOnline && timeSync != nil && (isTimeSyncNeeded() || !timeSync.IsSyncSuccess()) { + if state.Up && 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") } @@ -76,6 +76,7 @@ func initNetwork() error { ensureConfigLoaded() networkManager = nmlite.NewNetworkManager(context.Background(), networkLogger) + networkManager.SetOnInterfaceStateChange(networkStateChanged) networkManager.AddInterface(NetIfName, config.NetworkConfig) return nil diff --git a/pkg/nmlite/dhclient/lease.go b/pkg/nmlite/dhclient/lease.go index d9f10218..b63600bf 100644 --- a/pkg/nmlite/dhclient/lease.go +++ b/pkg/nmlite/dhclient/lease.go @@ -13,6 +13,7 @@ import ( "github.com/insomniacslk/dhcp/dhcpv4/nclient4" "github.com/insomniacslk/dhcp/dhcpv6" + "github.com/jetkvm/kvm/internal/network/types" ) var ( @@ -22,44 +23,7 @@ var ( // Lease is a network configuration obtained by DHCP. type Lease struct { - // from https://udhcp.busybox.net/README.udhcpc - IPAddress net.IP `env:"ip" json:"ip"` // The obtained IP - Netmask net.IP `env:"subnet" json:"netmask"` // The assigned subnet mask - Broadcast net.IP `env:"broadcast" json:"broadcast"` // The broadcast address for this network - TTL int `env:"ipttl" json:"ttl,omitempty"` // The TTL to use for this network - MTU int `env:"mtu" json:"mtu,omitempty"` // The MTU to use for this network - HostName string `env:"hostname" json:"hostname,omitempty"` // The assigned hostname - Domain string `env:"domain" json:"domain,omitempty"` // The domain name of the network - 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 - Timezone string `env:"timezone" json:"timezone,omitempty"` // Offset in seconds from UTC - Routers []net.IP `env:"router" json:"routers,omitempty"` // A list of routers - DNS []net.IP `env:"dns" json:"dns_servers,omitempty"` // A list of DNS servers - NTPServers []net.IP `env:"ntpsrv" json:"ntp_servers,omitempty"` // A list of NTP servers - LPRServers []net.IP `env:"lprsvr" json:"lpr_servers,omitempty"` // A list of LPR servers - TimeServers []net.IP `env:"timesvr" json:"_time_servers,omitempty"` // A list of time servers (obsolete) - IEN116NameServers []net.IP `env:"namesvr" json:"_name_servers,omitempty"` // A list of IEN 116 name servers (obsolete) - LogServers []net.IP `env:"logsvr" json:"_log_servers,omitempty"` // A list of MIT-LCS UDP log servers (obsolete) - CookieServers []net.IP `env:"cookiesvr" json:"_cookie_servers,omitempty"` // A list of RFC 865 cookie servers (obsolete) - WINSServers []net.IP `env:"wins" json:"_wins_servers,omitempty"` // A list of WINS servers - SwapServer net.IP `env:"swapsvr" json:"_swap_server,omitempty"` // The IP address of the client's swap server - BootSize int `env:"bootsize" json:"bootsize,omitempty"` // The length in 512 octect blocks of the bootfile - RootPath string `env:"rootpath" json:"root_path,omitempty"` // The path name of the client's root disk - LeaseTime time.Duration `env:"lease" json:"lease,omitempty"` // The lease time, in seconds - 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 - - InterfaceName string `json:"interface_name,omitempty"` // The name of the interface + types.DHCPLease p4 *nclient4.Lease p6 *dhcpv6.Message @@ -67,6 +31,11 @@ type Lease struct { isEmpty map[string]bool } +// ToDHCPLease converts a lease to a DHCP lease. +func (l *Lease) ToDHCPLease() *types.DHCPLease { + return &l.DHCPLease +} + // fromNclient4Lease creates a lease from a nclient4.Lease. func fromNclient4Lease(l *nclient4.Lease, iface string) *Lease { lease := &Lease{} diff --git a/pkg/nmlite/dhcp.go b/pkg/nmlite/dhcp.go index b67857f1..fa1312e6 100644 --- a/pkg/nmlite/dhcp.go +++ b/pkg/nmlite/dhcp.go @@ -207,26 +207,5 @@ func (dc *DHCPClient) convertLease(lease *dhclient.Lease, isIPv6 bool) *types.DH return nil } - // Convert the lease - convertedLease := &types.DHCPLease{ - InterfaceName: dc.ifaceName, - Domain: lease.Domain, - SearchList: lease.SearchList, - NTPServers: lease.NTPServers, - } - - // Set IP address and related information - convertedLease.IPAddress = lease.IPAddress - convertedLease.Netmask = lease.Netmask - if len(lease.Routers) > 0 { - convertedLease.Gateway = lease.Routers[0] - } - - // Set DNS servers - convertedLease.DNS = lease.DNS - // convertedLease.LeaseTime = lease.LeaseTime - // convertedLease.RenewalTime = lease.RenewalTime - // convertedLease.RebindingTime = lease.RebindingTime - - return convertedLease + return lease.ToDHCPLease() } diff --git a/pkg/nmlite/interface.go b/pkg/nmlite/interface.go index f4968c98..90c2e5df 100644 --- a/pkg/nmlite/interface.go +++ b/pkg/nmlite/interface.go @@ -169,6 +169,8 @@ func (im *InterfaceManager) GetState() *types.InterfaceState { 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 } @@ -453,6 +455,8 @@ func (im *InterfaceManager) getDomain() string { 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() @@ -464,6 +468,7 @@ func (im *InterfaceManager) monitorInterfaceState() { case <-im.stopCh: return case <-ticker.C: + im.logger.Debug().Msg("checking interface state") if err := im.updateInterfaceState(); err != nil { im.logger.Error().Err(err).Msg("failed to update interface state") } @@ -482,7 +487,7 @@ func (im *InterfaceManager) updateInterfaceState() error { isUp := attrs.OperState == netlink.OperUp hasAddrs := false - addrs, err := nl.AddrList(link.AfInet) + addrs, err := nl.AddrList(link.AfUnspec) if err != nil { return fmt.Errorf("failed to get addresses: %w", err) } @@ -504,10 +509,10 @@ func (im *InterfaceManager) updateInterfaceState() error { stateChanged = true } - // if im.state.MACAddr != attrs.HardwareAddr { - // im.state.MACAddr = attrs.HardwareAddr - // stateChanged = true - // } + if im.state.MACAddress != attrs.HardwareAddr.String() { + im.state.MACAddress = attrs.HardwareAddr.String() + stateChanged = true + } // Update IP addresses if err := im.updateIPAddresses(nl); err != nil { @@ -519,6 +524,7 @@ func (im *InterfaceManager) updateInterfaceState() error { // Notify callback if state changed if stateChanged && im.onStateChange != nil { state := *im.state + im.logger.Debug().Interface("state", state).Msg("notifying state change") im.onStateChange(&state) } @@ -538,6 +544,7 @@ func (im *InterfaceManager) updateIPAddresses(nl *link.Link) error { var ipv6LinkLocal string for _, addr := range addrs { + im.logger.Debug().Str("address", addr.IP.String()).Msg("checking address") if addr.IP.To4() != nil { // IPv4 address ipv4Addresses = append(ipv4Addresses, addr.IPNet.String()) diff --git a/pkg/nmlite/manager.go b/pkg/nmlite/manager.go index 8fe6819f..ba7d3a8f 100644 --- a/pkg/nmlite/manager.go +++ b/pkg/nmlite/manager.go @@ -31,7 +31,7 @@ type NetworkManager struct { // NewNetworkManager creates a new network manager func NewNetworkManager(ctx context.Context, logger *zerolog.Logger) *NetworkManager { if logger == nil { - logger = logging.GetSubsystemLogger("networkmgr") + logger = logging.GetSubsystemLogger("nm") } // Initialize the NetlinkManager singleton diff --git a/ui/src/components/Ipv6NetworkCard.tsx b/ui/src/components/Ipv6NetworkCard.tsx index ac9d20fb..ca6c3079 100644 --- a/ui/src/components/Ipv6NetworkCard.tsx +++ b/ui/src/components/Ipv6NetworkCard.tsx @@ -17,16 +17,14 @@ export default function Ipv6NetworkCard({
- {networkState?.dhcp_lease?.ip && ( -
- - Link-local - - - {networkState?.ipv6_link_local} - -
- )} +
+ + Link-local + + + {networkState?.ipv6_link_local} + +
From 49e28f218e34a3ead39ac7618f51f0912655e447 Mon Sep 17 00:00:00 2001 From: Siyuan Date: Tue, 7 Oct 2025 12:44:55 +0000 Subject: [PATCH 03/85] fix: dhcp not working --- pkg/nmlite/interface.go | 60 ++++++++++++++++++++------------------ pkg/nmlite/link/netlink.go | 39 +++++++++++++++++++++++-- 2 files changed, 68 insertions(+), 31 deletions(-) diff --git a/pkg/nmlite/interface.go b/pkg/nmlite/interface.go index 90c2e5df..a12b57cb 100644 --- a/pkg/nmlite/interface.go +++ b/pkg/nmlite/interface.go @@ -87,6 +87,9 @@ func NewInterfaceManager(ctx context.Context, ifaceName string, config *types.Ne // 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) @@ -588,44 +591,43 @@ func (im *InterfaceManager) updateStateFromDHCPLease(lease *types.DHCPLease) { } } -func (im *InterfaceManager) ReconcileLinkAddrs(ipv4Config *types.IPv4StaticConfig) error { - // nl := getNetlinkManager() - // return nl.ReconcileLinkAddrs(ipv4Config) - return nil +func (im *InterfaceManager) ReconcileLinkAddrs(addrs []*types.IPAddress) 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.ReconcileLinkAddrs(link, addrs) } // applyDHCPLease applies DHCP lease configuration using ReconcileLinkAddrs func (im *InterfaceManager) applyDHCPLease(lease *types.DHCPLease) error { // Convert DHCP lease to IPv4Config - // ipv4Config := im.convertDHCPLeaseToIPv4Config(lease) + ipv4Config := im.convertDHCPLeaseToIPv4Config(lease) // Apply the configuration using ReconcileLinkAddrs - // return im.ReconcileLinkAddrs(ipv4Config) - return nil + return im.ReconcileLinkAddrs([]*types.IPAddress{ipv4Config}) } // convertDHCPLeaseToIPv4Config converts a DHCP lease to IPv4Config -// func (im *InterfaceManager) convertDHCPLeaseToIPv4Config(lease *types.DHCPLease) *netif.IPv4Config { -// // Create IPNet from IP and netmask -// ipNet := &net.IPNet{ -// IP: lease.IPAddress, -// Mask: net.IPMask(lease.Netmask), -// } +func (im *InterfaceManager) convertDHCPLeaseToIPv4Config(lease *types.DHCPLease) *types.IPAddress { + // Create IPNet from IP and netmask + ipNet := &net.IPNet{ + IP: lease.IPAddress, + Mask: net.IPMask(lease.Netmask), + } -// // Create IPv4Address -// ipv4Addr := netif.IPv4Address{ -// Address: *ipNet, -// Gateway: lease.Gateway, -// Secondary: false, -// Permanent: false, -// } + // Create IPv4Address + ipv4Addr := types.IPAddress{ + Address: *ipNet, + Gateway: lease.Routers[0], + Secondary: false, + Permanent: false, + } -// // Create IPv4Config -// return &netif.IPv4Config{ -// Addresses: []netif.IPv4Address{ipv4Addr}, -// Nameservers: lease.DNS, -// SearchList: lease.SearchList, -// Domain: lease.Domain, -// Interface: im.ifaceName, -// } -// } + // Create IPv4Config + return &ipv4Addr +} diff --git a/pkg/nmlite/link/netlink.go b/pkg/nmlite/link/netlink.go index 7c252920..01adbe86 100644 --- a/pkg/nmlite/link/netlink.go +++ b/pkg/nmlite/link/netlink.go @@ -13,6 +13,7 @@ import ( "sync" "time" + "github.com/jetkvm/kvm/internal/network/types" "github.com/rs/zerolog" "github.com/vishvananda/netlink" ) @@ -239,8 +240,6 @@ func (nm *NetlinkManager) RemoveNonLinkLocalIPv6Addresses(link *Link) error { return nil } -// Route operations - // RouteList gets all routes func (nm *NetlinkManager) RouteList(link *Link, family int) ([]netlink.Route, error) { nm.mu.RLock() @@ -336,6 +335,42 @@ func (nm *NetlinkManager) RemoveDefaultRoute(family int) error { return nil } +func (nm *NetlinkManager) ReconcileLinkAddrs(link *Link, expected []*types.IPAddress) error { + existingAddr := make(map[string]bool) + + addrs, err := nm.AddrList(link, AfUnspec) + if err != nil { + return fmt.Errorf("failed to get addresses: %w", err) + } + + for _, addr := range addrs { + ipCidr := addr.IP.String() + "/" + addr.IPNet.Mask.String() + existingAddr[ipCidr] = true + } + + for _, addr := range expected { + ipCidr := addr.Address.IP.String() + "/" + addr.Address.Mask.String() + if ok := existingAddr[ipCidr]; ok { + continue + } + + ipNet := &net.IPNet{ + IP: addr.Address.IP, + Mask: addr.Address.Mask, + } + + addr := &netlink.Addr{ + IPNet: ipNet, + } + + if err := nm.AddrAdd(link, addr); err != nil { + return fmt.Errorf("failed to add address %s: %w", ipCidr, err) + } + } + + return nil +} + // Sysctl operations // SetSysctlValues sets sysctl values for the interface From df0f5efff306673ab757eb1d39ae68cb8e061ffa Mon Sep 17 00:00:00 2001 From: Siyuan Date: Tue, 7 Oct 2025 12:58:47 +0000 Subject: [PATCH 04/85] fix: default route not set --- pkg/nmlite/interface.go | 2 ++ pkg/nmlite/link/netlink.go | 46 +++++++++++++++++++++++++------------- 2 files changed, 33 insertions(+), 15 deletions(-) diff --git a/pkg/nmlite/interface.go b/pkg/nmlite/interface.go index a12b57cb..19912652 100644 --- a/pkg/nmlite/interface.go +++ b/pkg/nmlite/interface.go @@ -628,6 +628,8 @@ func (im *InterfaceManager) convertDHCPLeaseToIPv4Config(lease *types.DHCPLease) Permanent: false, } + im.logger.Trace().Interface("ipv4Addr", ipv4Addr).Msg("converted DHCP lease to IPv4Config") + // Create IPv4Config return &ipv4Addr } diff --git a/pkg/nmlite/link/netlink.go b/pkg/nmlite/link/netlink.go index 01adbe86..d8b5e7cd 100644 --- a/pkg/nmlite/link/netlink.go +++ b/pkg/nmlite/link/netlink.go @@ -336,7 +336,13 @@ func (nm *NetlinkManager) RemoveDefaultRoute(family int) error { } func (nm *NetlinkManager) ReconcileLinkAddrs(link *Link, expected []*types.IPAddress) error { - existingAddr := make(map[string]bool) + expectedAddrs := make(map[string]bool) + existingAddrs := make(map[string]bool) + + for _, addr := range expected { + ipCidr := addr.Address.IP.String() + "/" + addr.Address.Mask.String() + expectedAddrs[ipCidr] = true + } addrs, err := nm.AddrList(link, AfUnspec) if err != nil { @@ -345,26 +351,36 @@ func (nm *NetlinkManager) ReconcileLinkAddrs(link *Link, expected []*types.IPAdd for _, addr := range addrs { ipCidr := addr.IP.String() + "/" + addr.IPNet.Mask.String() - existingAddr[ipCidr] = true + existingAddrs[ipCidr] = true } for _, addr := range expected { + family := AfUnspec + if addr.Address.IP.To4() != nil { + family = AfInet + } else if addr.Address.IP.To16() != nil { + family = AfInet6 + } + ipCidr := addr.Address.IP.String() + "/" + addr.Address.Mask.String() - if ok := existingAddr[ipCidr]; ok { - continue + if ok := existingAddrs[ipCidr]; !ok { + ipNet := &net.IPNet{ + IP: addr.Address.IP, + Mask: addr.Address.Mask, + } + + if err := nm.AddrAdd(link, &netlink.Addr{IPNet: ipNet}); err != nil { + return fmt.Errorf("failed to add address %s: %w", ipCidr, err) + } + + nm.logger.Info().Str("address", ipCidr).Msg("added address") } - ipNet := &net.IPNet{ - IP: addr.Address.IP, - Mask: addr.Address.Mask, - } - - addr := &netlink.Addr{ - IPNet: ipNet, - } - - if err := nm.AddrAdd(link, addr); err != nil { - return fmt.Errorf("failed to add address %s: %w", ipCidr, err) + if addr.Gateway != nil { + nm.logger.Trace().Str("address", ipCidr).Str("gateway", addr.Gateway.String()).Msg("adding default route for address") + if err := nm.AddDefaultRoute(link, addr.Gateway, family); err != nil { + return fmt.Errorf("failed to add default route for address %s: %w", ipCidr, err) + } } } From 456ee66fc29ea4f08e931e67bb8b61d68c930941 Mon Sep 17 00:00:00 2001 From: Siyuan Date: Tue, 7 Oct 2025 17:18:20 +0000 Subject: [PATCH 05/85] monitor link state using netlink --- internal/network/rpc.go | 127 ------------ internal/network/utils.go | 26 --- internal/udhcpc/parser.go | 186 ------------------ pkg/nmlite/dhcp.go | 83 +++----- pkg/nmlite/interface.go | 61 +++++- pkg/nmlite/{dhclient => jetdhcpc}/client.go | 83 +++++--- pkg/nmlite/{dhclient => jetdhcpc}/dhcp4.go | 2 +- pkg/nmlite/{dhclient => jetdhcpc}/dhcp6.go | 2 +- pkg/nmlite/{dhclient => jetdhcpc}/lease.go | 2 +- pkg/nmlite/{dhclient => jetdhcpc}/legacy.go | 2 +- pkg/nmlite/{dhclient => jetdhcpc}/logging.go | 2 +- pkg/nmlite/{dhclient => jetdhcpc}/state.go | 3 +- pkg/nmlite/{dhclient => jetdhcpc}/utils.go | 2 +- pkg/nmlite/link/netlink.go | 102 ++++++++-- pkg/nmlite/resolvconf_test.go | 35 ++++ {internal => pkg/nmlite}/udhcpc/options.go | 0 pkg/nmlite/udhcpc/parser.go | 162 +++++++++++++++ .../nmlite}/udhcpc/parser_test.go | 0 {internal => pkg/nmlite}/udhcpc/proc.go | 0 {internal => pkg/nmlite}/udhcpc/udhcpc.go | 34 ++++ 20 files changed, 469 insertions(+), 445 deletions(-) delete mode 100644 internal/network/rpc.go delete mode 100644 internal/network/utils.go delete mode 100644 internal/udhcpc/parser.go rename pkg/nmlite/{dhclient => jetdhcpc}/client.go (86%) rename pkg/nmlite/{dhclient => jetdhcpc}/dhcp4.go (98%) rename pkg/nmlite/{dhclient => jetdhcpc}/dhcp6.go (99%) rename pkg/nmlite/{dhclient => jetdhcpc}/lease.go (99%) rename pkg/nmlite/{dhclient => jetdhcpc}/legacy.go (99%) rename pkg/nmlite/{dhclient => jetdhcpc}/logging.go (98%) rename pkg/nmlite/{dhclient => jetdhcpc}/state.go (98%) rename pkg/nmlite/{dhclient => jetdhcpc}/utils.go (97%) create mode 100644 pkg/nmlite/resolvconf_test.go rename {internal => pkg/nmlite}/udhcpc/options.go (100%) create mode 100644 pkg/nmlite/udhcpc/parser.go rename {internal => pkg/nmlite}/udhcpc/parser_test.go (100%) rename {internal => pkg/nmlite}/udhcpc/proc.go (100%) rename {internal => pkg/nmlite}/udhcpc/udhcpc.go (86%) diff --git a/internal/network/rpc.go b/internal/network/rpc.go deleted file mode 100644 index d18b9ae9..00000000 --- a/internal/network/rpc.go +++ /dev/null @@ -1,127 +0,0 @@ -package network - -// import ( -// "fmt" -// "time" - -// "github.com/jetkvm/kvm/internal/confparser" -// "github.com/jetkvm/kvm/internal/network/types" -// "github.com/jetkvm/kvm/internal/udhcpc" -// ) - -// type RpcIPv6Address struct { -// Address string `json:"address"` -// ValidLifetime *time.Time `json:"valid_lifetime,omitempty"` -// PreferredLifetime *time.Time `json:"preferred_lifetime,omitempty"` -// Scope int `json:"scope"` -// } - -// type RpcNetworkState struct { -// InterfaceName string `json:"interface_name"` -// MacAddress string `json:"mac_address"` -// IPv4 string `json:"ipv4,omitempty"` -// IPv6 string `json:"ipv6,omitempty"` -// IPv6LinkLocal string `json:"ipv6_link_local,omitempty"` -// IPv4Addresses []string `json:"ipv4_addresses,omitempty"` -// IPv6Addresses []RpcIPv6Address `json:"ipv6_addresses,omitempty"` -// DHCPLease *udhcpc.Lease `json:"dhcp_lease,omitempty"` -// } - -// type RpcNetworkSettings struct { -// NetworkConfig types.NetworkConfig -// } - -// // func (s *NetworkInterfaceState) MacAddress() string { -// // if s.macAddr == nil { -// // return "" -// // } - -// // return s.macAddr.String() -// // } - -// // func (s *NetworkInterfaceState) IPv4Address() string { -// // if s.ipv4Addr == nil { -// // return "" -// // } - -// // return s.ipv4Addr.String() -// // } - -// // func (s *NetworkInterfaceState) IPv6Address() string { -// // if s.ipv6Addr == nil { -// // return "" -// // } - -// // return s.ipv6Addr.String() -// // } - -// // func (s *NetworkInterfaceState) IPv6LinkLocalAddress() string { -// // if s.ipv6LinkLocal == nil { -// // return "" -// // } - -// // return s.ipv6LinkLocal.String() -// // } - -// func (s *NetworkInterfaceState) RpcGetNetworkState() RpcNetworkState { -// ipv6Addresses := make([]RpcIPv6Address, 0) - -// if s.ipv6Addresses != nil && s.config.IPv6Mode.String != "disabled" { -// for _, addr := range s.ipv6Addresses { -// ipv6Addresses = append(ipv6Addresses, RpcIPv6Address{ -// Address: addr.Prefix.String(), -// ValidLifetime: addr.ValidLifetime, -// PreferredLifetime: addr.PreferredLifetime, -// Scope: addr.Scope, -// }) -// } -// } - -// return RpcNetworkState{ -// InterfaceName: s.interfaceName, -// MacAddress: s.MacAddress(), -// IPv4: s.IPv4Address(), -// IPv6: s.IPv6Address(), -// IPv6LinkLocal: s.IPv6LinkLocalAddress(), -// IPv4Addresses: s.ipv4Addresses, -// IPv6Addresses: ipv6Addresses, -// DHCPLease: s.dhcpClient.GetLease(), -// } -// } - -// func (s *NetworkInterfaceState) RpcGetNetworkSettings() RpcNetworkSettings { -// if s.config == nil { -// return RpcNetworkSettings{} -// } - -// return RpcNetworkSettings{ -// NetworkConfig: *s.config, -// } -// } - -// func (s *NetworkInterfaceState) RpcSetNetworkSettings(settings RpcNetworkSettings) error { -// currentSettings := s.config - -// err := confparser.SetDefaultsAndValidate(&settings.NetworkConfig) -// if err != nil { -// return err -// } - -// if IsSame(currentSettings, settings.NetworkConfig) { -// // no changes, do nothing -// return nil -// } - -// s.config = &settings.NetworkConfig -// s.onConfigChange(s.config) - -// return nil -// } - -// func (s *NetworkInterfaceState) RpcRenewDHCPLease() error { -// if s.dhcpClient == nil { -// return fmt.Errorf("dhcp client not initialized") -// } - -// return s.dhcpClient.Renew() -// } 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/udhcpc/parser.go b/internal/udhcpc/parser.go deleted file mode 100644 index d75857c9..00000000 --- a/internal/udhcpc/parser.go +++ /dev/null @@ -1,186 +0,0 @@ -package udhcpc - -import ( - "bufio" - "encoding/json" - "fmt" - "net" - "os" - "reflect" - "strconv" - "strings" - "time" -) - -type Lease struct { - // from https://udhcp.busybox.net/README.udhcpc - IPAddress net.IP `env:"ip" json:"ip"` // The obtained IP - Netmask net.IP `env:"subnet" json:"netmask"` // The assigned subnet mask - Broadcast net.IP `env:"broadcast" json:"broadcast"` // The broadcast address for this network - TTL int `env:"ipttl" json:"ttl,omitempty"` // The TTL to use for this network - MTU int `env:"mtu" json:"mtu,omitempty"` // The MTU to use for this network - HostName string `env:"hostname" json:"hostname,omitempty"` // The assigned hostname - Domain string `env:"domain" json:"domain,omitempty"` // The domain name of the network - BootPNextServer net.IP `env:"siaddr" json:"bootp_next_server,omitempty"` // The bootp next server option - BootPServerName string `env:"sname" json:"bootp_server_name,omitempty"` // The bootp server name option - BootPFile string `env:"boot_file" json:"bootp_file,omitempty"` // The bootp boot file option - Timezone string `env:"timezone" json:"timezone,omitempty"` // Offset in seconds from UTC - Routers []net.IP `env:"router" json:"routers,omitempty"` // A list of routers - DNS []net.IP `env:"dns" json:"dns_servers,omitempty"` // A list of DNS servers - NTPServers []net.IP `env:"ntpsrv" json:"ntp_servers,omitempty"` // A list of NTP servers - LPRServers []net.IP `env:"lprsvr" json:"lpr_servers,omitempty"` // A list of LPR servers - TimeServers []net.IP `env:"timesvr" json:"_time_servers,omitempty"` // A list of time servers (obsolete) - IEN116NameServers []net.IP `env:"namesvr" json:"_name_servers,omitempty"` // A list of IEN 116 name servers (obsolete) - LogServers []net.IP `env:"logsvr" json:"_log_servers,omitempty"` // A list of MIT-LCS UDP log servers (obsolete) - CookieServers []net.IP `env:"cookiesvr" json:"_cookie_servers,omitempty"` // A list of RFC 865 cookie servers (obsolete) - WINSServers []net.IP `env:"wins" json:"_wins_servers,omitempty"` // A list of WINS servers - SwapServer net.IP `env:"swapsvr" json:"_swap_server,omitempty"` // The IP address of the client's swap server - BootSize int `env:"bootsize" json:"bootsize,omitempty"` // The length in 512 octect blocks of the bootfile - RootPath string `env:"rootpath" json:"root_path,omitempty"` // The path name of the client's root disk - LeaseTime time.Duration `env:"lease" json:"lease,omitempty"` // The lease time, in seconds - DHCPType string `env:"dhcptype" json:"dhcp_type,omitempty"` // DHCP message type (safely ignored) - ServerID string `env:"serverid" json:"server_id,omitempty"` // The IP of the server - Message string `env:"message" json:"reason,omitempty"` // Reason for a DHCPNAK - TFTPServerName string `env:"tftp" json:"tftp,omitempty"` // The TFTP server name - BootFileName string `env:"bootfile" json:"bootfile,omitempty"` // The boot file name - Uptime time.Duration `env:"uptime" json:"uptime,omitempty"` // The uptime of the device when the lease was obtained, in seconds - LeaseExpiry *time.Time `json:"lease_expiry,omitempty"` // The expiry time of the lease - isEmpty map[string]bool -} - -func (l *Lease) setIsEmpty(m map[string]bool) { - l.isEmpty = m -} - -func (l *Lease) IsEmpty(key string) bool { - return l.isEmpty[key] -} - -func (l *Lease) ToJSON() string { - json, err := json.Marshal(l) - if err != nil { - return "" - } - return string(json) -} - -func (l *Lease) SetLeaseExpiry() (time.Time, error) { - if l.Uptime == 0 || l.LeaseTime == 0 { - return time.Time{}, fmt.Errorf("uptime or lease time isn't set") - } - - // get the uptime of the device - - file, err := os.Open("/proc/uptime") - if err != nil { - return time.Time{}, fmt.Errorf("failed to open uptime file: %w", err) - } - defer file.Close() - - var uptime time.Duration - - scanner := bufio.NewScanner(file) - for scanner.Scan() { - text := scanner.Text() - parts := strings.Split(text, " ") - uptime, err = time.ParseDuration(parts[0] + "s") - - if err != nil { - return time.Time{}, fmt.Errorf("failed to parse uptime: %w", err) - } - } - - relativeLeaseRemaining := (l.Uptime + l.LeaseTime) - uptime - leaseExpiry := time.Now().Add(relativeLeaseRemaining) - - l.LeaseExpiry = &leaseExpiry - - return leaseExpiry, nil -} - -func UnmarshalDHCPCLease(lease *Lease, str string) error { - // parse the lease file as a map - data := make(map[string]string) - for line := range strings.SplitSeq(str, "\n") { - line = strings.TrimSpace(line) - // skip empty lines and comments - if line == "" || strings.HasPrefix(line, "#") { - continue - } - - parts := strings.SplitN(line, "=", 2) - if len(parts) != 2 { - continue - } - - key := strings.TrimSpace(parts[0]) - value := strings.TrimSpace(parts[1]) - - data[key] = value - } - - // now iterate over the lease struct and set the values - leaseType := reflect.TypeOf(lease).Elem() - leaseValue := reflect.ValueOf(lease).Elem() - - valuesParsed := make(map[string]bool) - - for i := 0; i < leaseType.NumField(); i++ { - field := leaseValue.Field(i) - - // get the env tag - key := leaseType.Field(i).Tag.Get("env") - if key == "" { - continue - } - - valuesParsed[key] = false - - // get the value from the data map - value, ok := data[key] - if !ok || value == "" { - continue - } - - switch field.Interface().(type) { - case string: - field.SetString(value) - case int: - val, err := strconv.Atoi(value) - if err != nil { - continue - } - field.SetInt(int64(val)) - case time.Duration: - val, err := time.ParseDuration(value + "s") - if err != nil { - continue - } - field.Set(reflect.ValueOf(val)) - case net.IP: - ip := net.ParseIP(value) - if ip == nil { - continue - } - field.Set(reflect.ValueOf(ip)) - case []net.IP: - val := make([]net.IP, 0) - for ipStr := range strings.FieldsSeq(value) { - ip := net.ParseIP(ipStr) - if ip == nil { - continue - } - val = append(val, ip) - } - field.Set(reflect.ValueOf(val)) - default: - return fmt.Errorf("unsupported field `%s` type: %s", key, field.Type().String()) - } - - valuesParsed[key] = true - } - - lease.setIsEmpty(valuesParsed) - - return nil -} diff --git a/pkg/nmlite/dhcp.go b/pkg/nmlite/dhcp.go index fa1312e6..f64d4b80 100644 --- a/pkg/nmlite/dhcp.go +++ b/pkg/nmlite/dhcp.go @@ -6,7 +6,7 @@ import ( "fmt" "github.com/jetkvm/kvm/internal/network/types" - "github.com/jetkvm/kvm/pkg/nmlite/dhclient" + "github.com/jetkvm/kvm/pkg/nmlite/jetdhcpc" "github.com/rs/zerolog" "github.com/vishvananda/netlink" ) @@ -16,7 +16,7 @@ type DHCPClient struct { ctx context.Context ifaceName string logger *zerolog.Logger - client *dhclient.Client + client types.DHCPClient link netlink.Link // Configuration @@ -40,9 +40,6 @@ func NewDHCPClient(ctx context.Context, ifaceName string, logger *zerolog.Logger return nil, fmt.Errorf("logger cannot be nil") } - // Create state manager - // stateManager := NewDHCPStateManager("", logger) - return &DHCPClient{ ctx: ctx, ifaceName: ifaceName, @@ -81,13 +78,13 @@ func (dc *DHCPClient) Start() error { dc.logger.Info().Msg("starting DHCP client") // Create the underlying DHCP client - client, err := dhclient.NewClient(dc.ctx, []string{dc.ifaceName}, &dhclient.Config{ + client, err := jetdhcpc.NewClient(dc.ctx, []string{dc.ifaceName}, &jetdhcpc.Config{ IPv4: dc.ipv4Enabled, IPv6: dc.ipv6Enabled, - OnLease4Change: func(lease *dhclient.Lease) { + OnLease4Change: func(lease *types.DHCPLease) { dc.handleLeaseChange(lease, false) }, - OnLease6Change: func(lease *dhclient.Lease) { + OnLease6Change: func(lease *types.DHCPLease) { dc.handleLeaseChange(lease, true) }, UpdateResolvConf: func(nameservers []string) error { @@ -115,6 +112,27 @@ func (dc *DHCPClient) Start() error { 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 { @@ -150,62 +168,19 @@ func (dc *DHCPClient) Release() error { return nil } -// GetLease4 returns the current IPv4 lease -func (dc *DHCPClient) GetLease4() *types.DHCPLease { - if dc.client == nil { - return nil - } - - lease := dc.client.Lease4() - if lease == nil { - return nil - } - - return dc.convertLease(lease, false) -} - -// GetLease6 returns the current IPv6 lease -func (dc *DHCPClient) GetLease6() *types.DHCPLease { - if dc.client == nil { - return nil - } - - lease := dc.client.Lease6() - if lease == nil { - return nil - } - - return dc.convertLease(lease, true) -} - // handleLeaseChange handles lease changes from the underlying DHCP client -func (dc *DHCPClient) handleLeaseChange(lease *dhclient.Lease, isIPv6 bool) { +func (dc *DHCPClient) handleLeaseChange(lease *types.DHCPLease, isIPv6 bool) { if lease == nil { return } - convertedLease := dc.convertLease(lease, isIPv6) - if convertedLease == nil { - dc.logger.Error().Msg("failed to convert lease") - return - } - dc.logger.Info(). Bool("ipv6", isIPv6). - Str("ip", convertedLease.IPAddress.String()). + Str("ip", lease.IPAddress.String()). Msg("DHCP lease changed") // Notify callback if dc.onLeaseChange != nil { - dc.onLeaseChange(convertedLease) + dc.onLeaseChange(lease) } } - -// convertLease converts a dhclient.Lease to types.DHCPLease -func (dc *DHCPClient) convertLease(lease *dhclient.Lease, isIPv6 bool) *types.DHCPLease { - if lease == nil { - return nil - } - - return lease.ToDHCPLease() -} diff --git a/pkg/nmlite/interface.go b/pkg/nmlite/interface.go index 19912652..8928ce14 100644 --- a/pkg/nmlite/interface.go +++ b/pkg/nmlite/interface.go @@ -22,6 +22,7 @@ type InterfaceManager struct { config *types.NetworkConfig logger *zerolog.Logger state *types.InterfaceState + linkState *link.Link stateMu sync.RWMutex // Network components @@ -107,6 +108,14 @@ func (im *InterfaceManager) Start() error { im.wg.Add(1) go im.monitorInterfaceState() + nl := getNetlinkManager() + nl.AddLinkStateCallback(im.ifaceName, link.LinkStateCallback{ + Async: true, + Func: func(link *link.Link) { + im.handleLinkStateChange(link) + }, + }) + // Apply initial configuration if err := im.applyConfiguration(); err != nil { im.logger.Error().Err(err).Msg("failed to apply initial configuration") @@ -189,6 +198,10 @@ func (im *InterfaceManager) GetConfig() *types.NetworkConfig { return &config } +func (im *InterfaceManager) ApplyConfiguration() error { + return im.applyConfiguration() +} + // SetConfig updates the interface configuration func (im *InterfaceManager) SetConfig(config *types.NetworkConfig) error { if config == nil { @@ -446,7 +459,7 @@ func (im *InterfaceManager) getDomain() string { // Try to get domain from DHCP lease if im.dhcpClient != nil { - if lease := im.dhcpClient.GetLease4(); lease != nil && lease.Domain != "" { + if lease := im.dhcpClient.Lease4(); lease != nil && lease.Domain != "" { return lease.Domain } } @@ -454,6 +467,51 @@ func (im *InterfaceManager) getDomain() string { return "local" } +func (im *InterfaceManager) handleLinkStateChange(link *link.Link) { + { + im.stateMu.Lock() + defer im.stateMu.Unlock() + + if link.IsSame(im.linkState) { + return + } + + im.linkState = link + } + + im.logger.Info().Interface("link", link).Msg("link state changed") + + operState := link.Attrs().OperState + if operState == netlink.OperUp { + im.handleLinkUp() + } else { + im.handleLinkDown() + } +} + +func (im *InterfaceManager) handleLinkUp() { + im.logger.Info().Msg("link up") + + im.applyConfiguration() +} + +func (im *InterfaceManager) handleLinkDown() { + im.logger.Info().Msg("link down") + + if im.config.IPv4Mode.String == "dhcp" { + im.dhcpClient.Stop() + } + + netlinkMgr := getNetlinkManager() + if err := netlinkMgr.RemoveAllAddresses(im.linkState, link.AfInet); err != nil { + im.logger.Error().Err(err).Msg("failed to remove all IPv4 addresses") + } + + if err := netlinkMgr.RemoveNonLinkLocalIPv6Addresses(im.linkState); err != nil { + im.logger.Error().Err(err).Msg("failed to remove non-link-local IPv6 addresses") + } +} + // monitorInterfaceState monitors the interface state and updates accordingly func (im *InterfaceManager) monitorInterfaceState() { defer im.wg.Done() @@ -477,6 +535,7 @@ func (im *InterfaceManager) monitorInterfaceState() { } } } + } // updateInterfaceState updates the current interface state diff --git a/pkg/nmlite/dhclient/client.go b/pkg/nmlite/jetdhcpc/client.go similarity index 86% rename from pkg/nmlite/dhclient/client.go rename to pkg/nmlite/jetdhcpc/client.go index af6dc9ea..0f076ebb 100644 --- a/pkg/nmlite/dhclient/client.go +++ b/pkg/nmlite/jetdhcpc/client.go @@ -1,4 +1,4 @@ -package dhclient +package jetdhcpc import ( "context" @@ -12,6 +12,7 @@ import ( "github.com/go-co-op/gocron/v2" "github.com/insomniacslk/dhcp/dhcpv4" "github.com/insomniacslk/dhcp/dhcpv6" + "github.com/jetkvm/kvm/internal/network/types" "github.com/jetkvm/kvm/pkg/nmlite/link" "github.com/rs/zerolog" ) @@ -27,7 +28,7 @@ var ( ErrInterfaceUpCanceled = errors.New("context canceled while waiting for an interface to come up") ) -type LeaseChangeHandler func(lease *Lease) +type LeaseChangeHandler func(lease *types.DHCPLease) // Config is a DHCP client configuration. type Config struct { @@ -81,6 +82,7 @@ type Config struct { } type Client struct { + types.DHCPClient ifaces []string cfg Config l *zerolog.Logger @@ -153,6 +155,34 @@ func (c *Client) sendInitialRequests() chan interface{} { return c.sendRequests(c.cfg.IPv4, c.cfg.IPv6) } +func (c *Client) sendRequestsFamily( + family int, + wg *sync.WaitGroup, + r *chan interface{}, + l *zerolog.Logger, + iface *link.Link, +) { + wg.Add(1) + go func(iface *link.Link) { + defer wg.Done() + var ( + lease *Lease + err error + ) + switch family { + case link.AfInet: + lease, err = c.requestLease4(iface) + case link.AfInet6: + lease, err = c.requestLease6(iface) + } + if err != nil { + l.Error().Err(err).Msg("Could not get lease") + return + } + (*r) <- lease + }(iface) +} + func (c *Client) sendRequests(ipv4, ipv6 bool) chan interface{} { c.mu.Lock() defer c.mu.Unlock() @@ -175,30 +205,11 @@ func (c *Client) sendRequests(ipv4, ipv6 bool) chan interface{} { } if ipv4 { - wg.Add(1) - go func(iface *link.Link) { - defer wg.Done() - lease, err := c.requestLease4(iface) - if err != nil { - l.Error().Err(err).Msg("Could not get IPv4 lease") - return - } - r <- lease - }(iface) + c.sendRequestsFamily(link.AfInet, &wg, &r, &l, iface) } if ipv6 { - return // TODO: implement DHCP6 - wg.Add(1) - go func(iface *link.Link) { - defer wg.Done() - lease, err := c.requestLease6(iface) - if err != nil { - l.Error().Err(err).Msg("Could not get IPv6 lease") - return - } - r <- lease - }(iface) + c.sendRequestsFamily(link.AfInet6, &wg, &r, &l, iface) } }(iface) } @@ -210,18 +221,26 @@ func (c *Client) sendRequests(ipv4, ipv6 bool) chan interface{} { return r } -func (c *Client) Lease4() *Lease { +func (c *Client) Lease4() *types.DHCPLease { c.lease4Mu.Lock() defer c.lease4Mu.Unlock() - return c.currentLease4 + if c.currentLease4 == nil { + return nil + } + + return c.currentLease4.ToDHCPLease() } -func (c *Client) Lease6() *Lease { +func (c *Client) Lease6() *types.DHCPLease { c.lease6Mu.Lock() defer c.lease6Mu.Unlock() - return c.currentLease6 + if c.currentLease6 == nil { + return nil + } + + return c.currentLease6.ToDHCPLease() } func (c *Client) Domain() string { @@ -288,11 +307,11 @@ func (c *Client) handleLeaseChange(lease *Lease) { // TODO: handle lease expiration if c.cfg.OnLease4Change != nil && ipv4 { - c.cfg.OnLease4Change(lease) + c.cfg.OnLease4Change(lease.ToDHCPLease()) } if c.cfg.OnLease6Change != nil && !ipv4 { - c.cfg.OnLease6Change(lease) + c.cfg.OnLease6Change(lease.ToDHCPLease()) } } @@ -304,12 +323,14 @@ func (c *Client) renew() { } } -func (c *Client) Renew() { +func (c *Client) Renew() error { go c.renew() + return nil } -func (c *Client) Release() { +func (c *Client) Release() error { // TODO: implement + return nil } func (c *Client) SetIPv4(ipv4 bool) { diff --git a/pkg/nmlite/dhclient/dhcp4.go b/pkg/nmlite/jetdhcpc/dhcp4.go similarity index 98% rename from pkg/nmlite/dhclient/dhcp4.go rename to pkg/nmlite/jetdhcpc/dhcp4.go index 9518bc87..e77db862 100644 --- a/pkg/nmlite/dhclient/dhcp4.go +++ b/pkg/nmlite/jetdhcpc/dhcp4.go @@ -1,4 +1,4 @@ -package dhclient +package jetdhcpc import ( "github.com/insomniacslk/dhcp/dhcpv4" diff --git a/pkg/nmlite/dhclient/dhcp6.go b/pkg/nmlite/jetdhcpc/dhcp6.go similarity index 99% rename from pkg/nmlite/dhclient/dhcp6.go rename to pkg/nmlite/jetdhcpc/dhcp6.go index 6c393ecf..f71f744c 100644 --- a/pkg/nmlite/dhclient/dhcp6.go +++ b/pkg/nmlite/jetdhcpc/dhcp6.go @@ -1,4 +1,4 @@ -package dhclient +package jetdhcpc import ( "log" diff --git a/pkg/nmlite/dhclient/lease.go b/pkg/nmlite/jetdhcpc/lease.go similarity index 99% rename from pkg/nmlite/dhclient/lease.go rename to pkg/nmlite/jetdhcpc/lease.go index b63600bf..dbd4fef9 100644 --- a/pkg/nmlite/dhclient/lease.go +++ b/pkg/nmlite/jetdhcpc/lease.go @@ -1,4 +1,4 @@ -package dhclient +package jetdhcpc import ( "bufio" diff --git a/pkg/nmlite/dhclient/legacy.go b/pkg/nmlite/jetdhcpc/legacy.go similarity index 99% rename from pkg/nmlite/dhclient/legacy.go rename to pkg/nmlite/jetdhcpc/legacy.go index 2a47a70e..b8ee4c0b 100644 --- a/pkg/nmlite/dhclient/legacy.go +++ b/pkg/nmlite/jetdhcpc/legacy.go @@ -1,4 +1,4 @@ -package dhclient +package jetdhcpc import ( "bytes" diff --git a/pkg/nmlite/dhclient/logging.go b/pkg/nmlite/jetdhcpc/logging.go similarity index 98% rename from pkg/nmlite/dhclient/logging.go rename to pkg/nmlite/jetdhcpc/logging.go index 3d6cb8ce..3ee696e7 100644 --- a/pkg/nmlite/dhclient/logging.go +++ b/pkg/nmlite/jetdhcpc/logging.go @@ -1,4 +1,4 @@ -package dhclient +package jetdhcpc import ( "github.com/insomniacslk/dhcp/dhcpv4" diff --git a/pkg/nmlite/dhclient/state.go b/pkg/nmlite/jetdhcpc/state.go similarity index 98% rename from pkg/nmlite/dhclient/state.go rename to pkg/nmlite/jetdhcpc/state.go index d9afc396..312211e8 100644 --- a/pkg/nmlite/dhclient/state.go +++ b/pkg/nmlite/jetdhcpc/state.go @@ -1,5 +1,4 @@ -// Package nmlite provides DHCP state persistence for the network manager. -package dhclient +package jetdhcpc import ( "encoding/json" diff --git a/pkg/nmlite/dhclient/utils.go b/pkg/nmlite/jetdhcpc/utils.go similarity index 97% rename from pkg/nmlite/dhclient/utils.go rename to pkg/nmlite/jetdhcpc/utils.go index 4825cc4f..86b5d5d8 100644 --- a/pkg/nmlite/dhclient/utils.go +++ b/pkg/nmlite/jetdhcpc/utils.go @@ -1,4 +1,4 @@ -package dhclient +package jetdhcpc import ( "context" diff --git a/pkg/nmlite/link/netlink.go b/pkg/nmlite/link/netlink.go index d8b5e7cd..c8c4e53a 100644 --- a/pkg/nmlite/link/netlink.go +++ b/pkg/nmlite/link/netlink.go @@ -50,10 +50,18 @@ var ( ErrInterfaceUpCanceled = errors.New("context canceled while waiting for an interface to come up") ) +type LinkStateCallbackFunction func(link *Link) +type LinkStateCallback struct { + Async bool + Func LinkStateCallbackFunction +} + // NetlinkManager provides centralized netlink operations type NetlinkManager struct { - logger *zerolog.Logger - mu sync.RWMutex + logger *zerolog.Logger + linkStateCh chan netlink.LinkUpdate + mu sync.RWMutex + linkStateCallbacks map[string][]LinkStateCallback } // Link is a wrapper around netlink.Link @@ -70,12 +78,44 @@ func (l *Link) AddrList(family int) ([]netlink.Addr, error) { return netlink.AddrList(l, family) } +func (l *Link) IsSame(other *Link) bool { + if l == nil || other == nil { + return false + } + + a := l.Attrs() + b := other.Attrs() + if a.OperState != b.OperState { + return false + } + if a.Index != b.Index { + return false + } + if a.MTU != b.MTU { + return false + } + if a.HardwareAddr.String() != b.HardwareAddr.String() { + return false + } + return true +} + +func newNetlinkManager(logger *zerolog.Logger) *NetlinkManager { + if logger == nil { + logger = &zerolog.Logger{} // Default no-op logger + } + n := &NetlinkManager{ + logger: logger, + linkStateCallbacks: make(map[string][]LinkStateCallback), + } + n.monitorLinkState() + return n +} + // GetNetlinkManager returns the singleton NetlinkManager instance func GetNetlinkManager() *NetlinkManager { netlinkManagerOnce.Do(func() { - netlinkManagerInstance = &NetlinkManager{ - logger: &zerolog.Logger{}, // Default no-op logger - } + netlinkManagerInstance = newNetlinkManager(nil) }) return netlinkManagerInstance } @@ -83,18 +123,56 @@ func GetNetlinkManager() *NetlinkManager { // InitializeNetlinkManager initializes the singleton NetlinkManager with a logger func InitializeNetlinkManager(logger *zerolog.Logger) *NetlinkManager { netlinkManagerOnce.Do(func() { - if logger == nil { - // Create a no-op logger if none provided - logger = &zerolog.Logger{} - } - netlinkManagerInstance = &NetlinkManager{ - logger: logger, - } + netlinkManagerInstance = newNetlinkManager(logger) }) return netlinkManagerInstance } +func (nm *NetlinkManager) runCallbacks(update netlink.LinkUpdate) { + nm.mu.RLock() + defer nm.mu.RUnlock() + + ifname := update.Link.Attrs().Name + callbacks, ok := nm.linkStateCallbacks[ifname] + + l := nm.logger.With().Str("interface", ifname).Logger() + if !ok { + l.Trace().Msg("no callbacks for interface") + return + } + for _, callback := range callbacks { + l.Trace().Interface("callback", callback).Msg("calling callback") + + if callback.Async { + go callback.Func(&Link{Link: update.Link}) + } else { + callback.Func(&Link{Link: update.Link}) + } + } +} + +// AddLinkStateCallback adds a callback for link state changes +func (nm *NetlinkManager) AddLinkStateCallback(ifname string, callback LinkStateCallback) { + nm.mu.Lock() + defer nm.mu.Unlock() + nm.linkStateCallbacks[ifname] = append(nm.linkStateCallbacks[ifname], callback) +} + // Interface operations +func (nm *NetlinkManager) monitorLinkState() { + updateCh := make(chan netlink.LinkUpdate) + // we don't need to stop the subscription, as it will be closed when the program exits + stopCh := make(chan struct{}) //nolint:unused + netlink.LinkSubscribe(updateCh, stopCh) + + nm.logger.Info().Msg("link state monitoring started") + + go func() { + for update := range updateCh { + nm.runCallbacks(update) + } + }() +} // GetLinkByName gets a network link by name func (nm *NetlinkManager) GetLinkByName(name string) (*Link, error) { 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/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..0c15b031 --- /dev/null +++ b/pkg/nmlite/udhcpc/parser.go @@ -0,0 +1,162 @@ +package udhcpc + +import ( + "bufio" + "encoding/json" + "fmt" + "net" + "os" + "reflect" + "strconv" + "strings" + "time" + + "github.com/jetkvm/kvm/internal/network/types" +) + +type Lease struct { + types.DHCPLease + // from https://udhcp.busybox.net/README.udhcpc + isEmpty map[string]bool +} + +func (l *Lease) setIsEmpty(m map[string]bool) { + l.isEmpty = m +} + +// IsEmpty returns true if the lease is empty for the given key. +func (l *Lease) IsEmpty(key string) bool { + return l.isEmpty[key] +} + +// ToJSON returns the lease as a JSON string. +func (l *Lease) ToJSON() string { + json, err := json.Marshal(l) + if err != nil { + return "" + } + return string(json) +} + +// SetLeaseExpiry sets the lease expiry time. +func (l *Lease) SetLeaseExpiry() (time.Time, error) { + if l.Uptime == 0 || l.LeaseTime == 0 { + return time.Time{}, fmt.Errorf("uptime or lease time isn't set") + } + + // get the uptime of the device + + file, err := os.Open("/proc/uptime") + if err != nil { + return time.Time{}, fmt.Errorf("failed to open uptime file: %w", err) + } + defer file.Close() + + var uptime time.Duration + + scanner := bufio.NewScanner(file) + for scanner.Scan() { + text := scanner.Text() + parts := strings.Split(text, " ") + uptime, err = time.ParseDuration(parts[0] + "s") + + if err != nil { + return time.Time{}, fmt.Errorf("failed to parse uptime: %w", err) + } + } + + relativeLeaseRemaining := (l.Uptime + l.LeaseTime) - uptime + leaseExpiry := time.Now().Add(relativeLeaseRemaining) + + l.LeaseExpiry = &leaseExpiry + + return leaseExpiry, nil +} + +// UnmarshalDHCPCLease unmarshals a lease from a string. +func UnmarshalDHCPCLease(lease *Lease, str string) error { + // parse the lease file as a map + data := make(map[string]string) + for line := range strings.SplitSeq(str, "\n") { + line = strings.TrimSpace(line) + // skip empty lines and comments + if line == "" || strings.HasPrefix(line, "#") { + continue + } + + parts := strings.SplitN(line, "=", 2) + if len(parts) != 2 { + continue + } + + key := strings.TrimSpace(parts[0]) + value := strings.TrimSpace(parts[1]) + + data[key] = value + } + + // now iterate over the lease struct and set the values + leaseType := reflect.TypeOf(lease).Elem() + leaseValue := reflect.ValueOf(lease).Elem() + + valuesParsed := make(map[string]bool) + + for i := 0; i < leaseType.NumField(); i++ { + field := leaseValue.Field(i) + + // get the env tag + key := leaseType.Field(i).Tag.Get("env") + if key == "" { + continue + } + + valuesParsed[key] = false + + // get the value from the data map + value, ok := data[key] + if !ok || value == "" { + continue + } + + switch field.Interface().(type) { + case string: + field.SetString(value) + case int: + val, err := strconv.Atoi(value) + if err != nil { + continue + } + field.SetInt(int64(val)) + case time.Duration: + val, err := time.ParseDuration(value + "s") + if err != nil { + continue + } + field.Set(reflect.ValueOf(val)) + case net.IP: + ip := net.ParseIP(value) + if ip == nil { + continue + } + field.Set(reflect.ValueOf(ip)) + case []net.IP: + val := make([]net.IP, 0) + for ipStr := range strings.FieldsSeq(value) { + ip := net.ParseIP(ipStr) + if ip == nil { + continue + } + val = append(val, ip) + } + field.Set(reflect.ValueOf(val)) + default: + return fmt.Errorf("unsupported field `%s` type: %s", key, field.Type().String()) + } + + valuesParsed[key] = true + } + + lease.setIsEmpty(valuesParsed) + + return nil +} 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 86% rename from internal/udhcpc/udhcpc.go rename to pkg/nmlite/udhcpc/udhcpc.go index 7b4d6e4d..1c6664b5 100644 --- a/internal/udhcpc/udhcpc.go +++ b/pkg/nmlite/udhcpc/udhcpc.go @@ -9,6 +9,7 @@ import ( "time" "github.com/fsnotify/fsnotify" + "github.com/jetkvm/kvm/internal/network/types" "github.com/rs/zerolog" ) @@ -18,6 +19,7 @@ const ( ) type DHCPClient struct { + types.DHCPClient InterfaceName string leaseFile string pidFile string @@ -196,3 +198,35 @@ 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() *Lease { + return c.lease +} + +func (c *DHCPClient) Lease6() *Lease { + return c.lease +} + +func (c *DHCPClient) SetIPv4(enabled bool) { + // TODO: implement +} + +func (c *DHCPClient) SetIPv6(enabled bool) { + // TODO: implement +} + +func (c *DHCPClient) SetOnLeaseChange(callback func(lease *Lease)) { + c.onLeaseChange = callback +} + +func (c *DHCPClient) Start() error { + return c.Run() // udhcpc already has Run() +} + +func (c *DHCPClient) Stop() error { + return c.KillProcess() // udhcpc already has KillProcess() +} From 50469c1fb69f152d434d74b2ba6b0c2fba6d1820 Mon Sep 17 00:00:00 2001 From: Siyuan Date: Tue, 7 Oct 2025 17:45:44 +0000 Subject: [PATCH 06/85] renew dhcp lease on link up --- pkg/nmlite/dhcp.go | 43 +++++++++++++++++++++++++++++-------- pkg/nmlite/interface.go | 4 ++++ pkg/nmlite/udhcpc/parser.go | 5 +++++ pkg/nmlite/udhcpc/udhcpc.go | 38 +++++++++++++++++++++----------- 4 files changed, 69 insertions(+), 21 deletions(-) diff --git a/pkg/nmlite/dhcp.go b/pkg/nmlite/dhcp.go index f64d4b80..74e8df28 100644 --- a/pkg/nmlite/dhcp.go +++ b/pkg/nmlite/dhcp.go @@ -7,6 +7,7 @@ import ( "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" ) @@ -68,17 +69,16 @@ func (dc *DHCPClient) SetOnLeaseChange(callback func(lease *types.DHCPLease)) { dc.onLeaseChange = callback } -// Start starts the DHCP client -func (dc *DHCPClient) Start() error { - if dc.client != nil { - dc.logger.Warn().Msg("DHCP client already started") - return nil +func (dc *DHCPClient) initClient() (types.DHCPClient, error) { + if false { + return dc.initJetDHCPC() + } else { + return dc.initUDHCPC() } +} - dc.logger.Info().Msg("starting DHCP client") - - // Create the underlying DHCP client - client, err := jetdhcpc.NewClient(dc.ctx, []string{dc.ifaceName}, &jetdhcpc.Config{ +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) { @@ -95,6 +95,31 @@ func (dc *DHCPClient) Start() error { 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) diff --git a/pkg/nmlite/interface.go b/pkg/nmlite/interface.go index 8928ce14..91e1b6f9 100644 --- a/pkg/nmlite/interface.go +++ b/pkg/nmlite/interface.go @@ -493,6 +493,10 @@ func (im *InterfaceManager) handleLinkUp() { im.logger.Info().Msg("link up") im.applyConfiguration() + + if im.config.IPv4Mode.String == "dhcp" { + im.dhcpClient.Renew() + } } func (im *InterfaceManager) handleLinkDown() { diff --git a/pkg/nmlite/udhcpc/parser.go b/pkg/nmlite/udhcpc/parser.go index 0c15b031..0c08ad15 100644 --- a/pkg/nmlite/udhcpc/parser.go +++ b/pkg/nmlite/udhcpc/parser.go @@ -38,6 +38,11 @@ func (l *Lease) ToJSON() string { return string(json) } +// ToDHCPLease converts a lease to a DHCP lease. +func (l *Lease) ToDHCPLease() *types.DHCPLease { + return &l.DHCPLease +} + // SetLeaseExpiry sets the lease expiry time. func (l *Lease) SetLeaseExpiry() (time.Time, error) { if l.Uptime == 0 || l.LeaseTime == 0 { diff --git a/pkg/nmlite/udhcpc/udhcpc.go b/pkg/nmlite/udhcpc/udhcpc.go index 1c6664b5..12e1374d 100644 --- a/pkg/nmlite/udhcpc/udhcpc.go +++ b/pkg/nmlite/udhcpc/udhcpc.go @@ -6,6 +6,7 @@ import ( "os" "path/filepath" "reflect" + "sync" "time" "github.com/fsnotify/fsnotify" @@ -26,14 +27,15 @@ type DHCPClient struct { 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) @@ -69,8 +71,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 @@ -127,7 +129,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 @@ -184,7 +186,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()). @@ -203,12 +205,16 @@ func (c *DHCPClient) Domain() string { return c.lease.Domain } -func (c *DHCPClient) Lease4() *Lease { - return c.lease +func (c *DHCPClient) Lease4() *types.DHCPLease { + if c.lease == nil { + return nil + } + return c.lease.ToDHCPLease() } -func (c *DHCPClient) Lease6() *Lease { - return c.lease +func (c *DHCPClient) Lease6() *types.DHCPLease { + // TODO: implement + return nil } func (c *DHCPClient) SetIPv4(enabled bool) { @@ -219,12 +225,20 @@ func (c *DHCPClient) SetIPv6(enabled bool) { // TODO: implement } -func (c *DHCPClient) SetOnLeaseChange(callback func(lease *Lease)) { +func (c *DHCPClient) SetOnLeaseChange(callback func(lease *types.DHCPLease)) { c.onLeaseChange = callback } func (c *DHCPClient) Start() error { - return c.Run() // udhcpc already has Run() + 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 { From 3c83bcfe69e24ab37eb2a92285d7030d6163855a Mon Sep 17 00:00:00 2001 From: Siyuan Date: Tue, 7 Oct 2025 17:50:29 +0000 Subject: [PATCH 07/85] add missing dhcp client methods --- internal/network/types/dhcp.go | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 internal/network/types/dhcp.go diff --git a/internal/network/types/dhcp.go b/internal/network/types/dhcp.go new file mode 100644 index 00000000..5607849b --- /dev/null +++ b/internal/network/types/dhcp.go @@ -0,0 +1,15 @@ +package types + +// 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 +} From 78f0479b6bca61b8771a9544447269dbf811afbd Mon Sep 17 00:00:00 2001 From: Siyuan Date: Tue, 7 Oct 2025 18:27:47 +0000 Subject: [PATCH 08/85] fix netmask calculation --- pkg/nmlite/interface.go | 36 +++++++++++++++++++++++++++++------- pkg/nmlite/link/netlink.go | 1 + pkg/nmlite/state.go | 26 ++++++++++++++++---------- pkg/nmlite/udhcpc/parser.go | 6 ++++-- 4 files changed, 50 insertions(+), 19 deletions(-) diff --git a/pkg/nmlite/interface.go b/pkg/nmlite/interface.go index 91e1b6f9..102dc864 100644 --- a/pkg/nmlite/interface.go +++ b/pkg/nmlite/interface.go @@ -163,6 +163,14 @@ func (im *InterfaceManager) GetIPv4Addresses() []string { return im.state.IPv4Addresses } +func (im *InterfaceManager) GetIPv4Address() string { + return im.state.IPv4Address +} + +func (im *InterfaceManager) GetIPv6Address() string { + return im.state.IPv6Address +} + func (im *InterfaceManager) GetIPv6Addresses() []string { addresses := []string{} for _, addr := range im.state.IPv6Addresses { @@ -585,7 +593,7 @@ func (im *InterfaceManager) updateInterfaceState() error { im.logger.Error().Err(err).Msg("failed to update IP addresses") } - // im.state.LastUpdated = time.Now() // TODO: remove this + im.state.LastUpdated = time.Now() // Notify callback if state changed if stateChanged && im.onStateChange != nil { @@ -604,10 +612,13 @@ func (im *InterfaceManager) updateIPAddresses(nl *link.Link) error { return fmt.Errorf("failed to get addresses: %w", err) } - var ipv4Addresses []string - var ipv6Addresses []types.IPv6Address - var ipv4Addr, ipv6Addr string - var ipv6LinkLocal string + var ( + ipv4Addresses []string + ipv6Addresses []types.IPv6Address + ipv4Addr, ipv6Addr string + ipv6LinkLocal string + ipv4Ready, ipv6Ready = false, false + ) for _, addr := range addrs { im.logger.Debug().Str("address", addr.IP.String()).Msg("checking address") @@ -616,6 +627,7 @@ func (im *InterfaceManager) updateIPAddresses(nl *link.Link) error { ipv4Addresses = append(ipv4Addresses, addr.IPNet.String()) if ipv4Addr == "" { ipv4Addr = addr.IP.String() + ipv4Ready = true } } else if addr.IP.To16() != nil { // IPv6 address @@ -629,6 +641,7 @@ func (im *InterfaceManager) updateIPAddresses(nl *link.Link) error { }) if ipv6Addr == "" { ipv6Addr = addr.IP.String() + ipv6Ready = true } } } @@ -637,6 +650,10 @@ func (im *InterfaceManager) updateIPAddresses(nl *link.Link) error { im.state.IPv4Addresses = ipv4Addresses im.state.IPv6Addresses = ipv6Addresses im.state.IPv6LinkLocal = ipv6LinkLocal + im.state.IPv4Address = ipv4Addr + im.state.IPv6Address = ipv6Addr + im.state.IPv4Ready = ipv4Ready + im.state.IPv6Ready = ipv6Ready return nil } @@ -677,10 +694,12 @@ func (im *InterfaceManager) applyDHCPLease(lease *types.DHCPLease) error { // convertDHCPLeaseToIPv4Config converts a DHCP lease to IPv4Config func (im *InterfaceManager) convertDHCPLeaseToIPv4Config(lease *types.DHCPLease) *types.IPAddress { + mask := lease.Netmask + // Create IPNet from IP and netmask ipNet := &net.IPNet{ IP: lease.IPAddress, - Mask: net.IPMask(lease.Netmask), + Mask: net.IPv4Mask(mask[12], mask[13], mask[14], mask[15]), } // Create IPv4Address @@ -691,7 +710,10 @@ func (im *InterfaceManager) convertDHCPLeaseToIPv4Config(lease *types.DHCPLease) Permanent: false, } - im.logger.Trace().Interface("ipv4Addr", ipv4Addr).Msg("converted DHCP lease to IPv4Config") + im.logger.Trace(). + Interface("ipv4Addr", ipv4Addr). + Interface("lease", lease). + Msg("converted DHCP lease to IPv4Config") // Create IPv4Config return &ipv4Addr diff --git a/pkg/nmlite/link/netlink.go b/pkg/nmlite/link/netlink.go index c8c4e53a..ef56a212 100644 --- a/pkg/nmlite/link/netlink.go +++ b/pkg/nmlite/link/netlink.go @@ -419,6 +419,7 @@ func (nm *NetlinkManager) ReconcileLinkAddrs(link *Link, expected []*types.IPAdd for _, addr := range expected { ipCidr := addr.Address.IP.String() + "/" + addr.Address.Mask.String() + nm.logger.Trace().Str("address", ipCidr).Msg("adding expected address") expectedAddrs[ipCidr] = true } diff --git a/pkg/nmlite/state.go b/pkg/nmlite/state.go index f7bdf617..48c080bd 100644 --- a/pkg/nmlite/state.go +++ b/pkg/nmlite/state.go @@ -46,6 +46,20 @@ func (nm *NetworkManager) GetIPv4Addresses() []string { 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() @@ -61,19 +75,11 @@ func (nm *NetworkManager) GetMACAddress() string { } func (nm *NetworkManager) IPv4String() string { - l := nm.GetIPv4Addresses() - if len(l) == 0 { - return "" - } - return l[0] + return nm.GetIPv4Address() } func (nm *NetworkManager) IPv6String() string { - l := nm.GetIPv6Addresses() - if len(l) == 0 { - return "" - } - return l[0] + return nm.GetIPv6Address() } func (nm *NetworkManager) MACString() string { diff --git a/pkg/nmlite/udhcpc/parser.go b/pkg/nmlite/udhcpc/parser.go index 0c08ad15..e76dceca 100644 --- a/pkg/nmlite/udhcpc/parser.go +++ b/pkg/nmlite/udhcpc/parser.go @@ -79,7 +79,9 @@ func (l *Lease) SetLeaseExpiry() (time.Time, error) { } // UnmarshalDHCPCLease unmarshals a lease from a string. -func UnmarshalDHCPCLease(lease *Lease, str string) error { +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") { @@ -161,7 +163,7 @@ func UnmarshalDHCPCLease(lease *Lease, str string) error { valuesParsed[key] = true } - lease.setIsEmpty(valuesParsed) + obj.setIsEmpty(valuesParsed) return nil } From 45b55fe89fd20c0f10738d7c05078cda9122c7e6 Mon Sep 17 00:00:00 2001 From: Siyuan Date: Tue, 7 Oct 2025 18:31:44 +0000 Subject: [PATCH 09/85] fix dhcp6 logger --- pkg/nmlite/jetdhcpc/dhcp6.go | 8 ++++---- pkg/nmlite/jetdhcpc/utils.go | 8 +++++--- pkg/nmlite/link/netlink.go | 1 - 3 files changed, 9 insertions(+), 8 deletions(-) diff --git a/pkg/nmlite/jetdhcpc/dhcp6.go b/pkg/nmlite/jetdhcpc/dhcp6.go index f71f744c..9a501ce9 100644 --- a/pkg/nmlite/jetdhcpc/dhcp6.go +++ b/pkg/nmlite/jetdhcpc/dhcp6.go @@ -1,18 +1,18 @@ package jetdhcpc import ( - "log" "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) (bool, error) { +func isIPv6LinkReady(l netlink.Link, logger *zerolog.Logger) (bool, error) { addrs, err := netlink.AddrList(l, 10) // AF_INET6 if err != nil { return false, err @@ -20,7 +20,7 @@ func isIPv6LinkReady(l netlink.Link) (bool, error) { for _, addr := range addrs { if addr.IP.IsLinkLocalUnicast() && (addr.Flags&0x40 == 0) { // IFA_F_TENTATIVE if addr.Flags&0x80 != 0 { // IFA_F_DADFAILED - log.Printf("DADFAILED for %v, continuing anyhow", addr.IP) + logger.Warn().Str("address", addr.IP.String()).Msg("DADFAILED for address, continuing anyhow") } return true, nil } @@ -30,7 +30,7 @@ func isIPv6LinkReady(l netlink.Link) (bool, error) { // isIPv6RouteReady returns true if serverAddr is reachable. func isIPv6RouteReady(serverAddr net.IP) waitForCondition { - return func(l netlink.Link) (bool, error) { + return func(l netlink.Link, logger *zerolog.Logger) (bool, error) { if serverAddr.IsMulticast() { return true, nil } diff --git a/pkg/nmlite/jetdhcpc/utils.go b/pkg/nmlite/jetdhcpc/utils.go index 86b5d5d8..684a9302 100644 --- a/pkg/nmlite/jetdhcpc/utils.go +++ b/pkg/nmlite/jetdhcpc/utils.go @@ -4,10 +4,11 @@ import ( "context" "time" + "github.com/rs/zerolog" "github.com/vishvananda/netlink" ) -type waitForCondition func(l netlink.Link) (ready bool, err error) +type waitForCondition func(l netlink.Link, logger *zerolog.Logger) (ready bool, err error) func (c *Client) waitFor( link netlink.Link, @@ -15,18 +16,19 @@ func (c *Client) waitFor( condition waitForCondition, timeoutError error, ) error { - return waitFor(c.ctx, link, timeout, condition, timeoutError) + 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); err != nil { + if ready, err := condition(link, logger); err != nil { return err } else if ready { break diff --git a/pkg/nmlite/link/netlink.go b/pkg/nmlite/link/netlink.go index ef56a212..c8c4e53a 100644 --- a/pkg/nmlite/link/netlink.go +++ b/pkg/nmlite/link/netlink.go @@ -419,7 +419,6 @@ func (nm *NetlinkManager) ReconcileLinkAddrs(link *Link, expected []*types.IPAdd for _, addr := range expected { ipCidr := addr.Address.IP.String() + "/" + addr.Address.Mask.String() - nm.logger.Trace().Str("address", ipCidr).Msg("adding expected address") expectedAddrs[ipCidr] = true } From 656df6c9103c82253c89280e1fdb0f646f7b2c40 Mon Sep 17 00:00:00 2001 From: Siyuan Date: Tue, 7 Oct 2025 18:59:19 +0000 Subject: [PATCH 10/85] fix deadlocks --- internal/timesync/ntp.go | 41 +++++++++++++++++++++++++++++++ internal/timesync/timesync.go | 20 +++++++++++++--- pkg/nmlite/interface.go | 45 ++++++++++++++++++++++++++++++----- pkg/nmlite/state.go | 21 +++++++++++++++- timesync.go | 12 ++++++++++ 5 files changed, 129 insertions(+), 10 deletions(-) diff --git a/internal/timesync/ntp.go b/internal/timesync/ntp.go index dcadaa96..e5a18865 100644 --- a/internal/timesync/ntp.go +++ b/internal/timesync/ntp.go @@ -3,6 +3,7 @@ package timesync import ( "context" "math/rand/v2" + "net" "strconv" "time" @@ -37,7 +38,47 @@ 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 ad5f6f49..d93401e0 100644 --- a/internal/timesync/timesync.go +++ b/internal/timesync/timesync.go @@ -24,6 +24,8 @@ var ( timeSyncRetryInterval = 0 * time.Second ) +type PreCheckFunc func() (bool, error) + type TimeSync struct { syncLock *sync.Mutex l *zerolog.Logger @@ -37,11 +39,15 @@ type TimeSync struct { syncSuccess bool - 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 *types.NetworkConfig } @@ -69,6 +75,8 @@ func NewTimeSync(opts *TimeSyncOptions) *TimeSync { rtcDevicePath: rtcDevice, rtcLock: &sync.Mutex{}, preCheckFunc: opts.PreCheckFunc, + preCheckIPv4: opts.PreCheckIPv4, + preCheckIPv6: opts.PreCheckIPv6, networkConfig: opts.NetworkConfig, } @@ -112,7 +120,13 @@ 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 } diff --git a/pkg/nmlite/interface.go b/pkg/nmlite/interface.go index 102dc864..324a0bf0 100644 --- a/pkg/nmlite/interface.go +++ b/pkg/nmlite/interface.go @@ -152,30 +152,63 @@ func (im *InterfaceManager) link() (*link.Link, error) { // IsUp returns true if the interface is up func (im *InterfaceManager) IsUp() bool { + im.stateMu.RLock() + defer im.stateMu.RUnlock() + return im.state.Up } func (im *InterfaceManager) IsOnline() bool { - return im.IsUp() + im.stateMu.RLock() + defer im.stateMu.RUnlock() + + return im.state.Online +} + +func (im *InterfaceManager) IPv4Ready() bool { + im.stateMu.RLock() + defer im.stateMu.RUnlock() + + return im.state.IPv4Ready +} + +func (im *InterfaceManager) IPv6Ready() bool { + im.stateMu.RLock() + defer im.stateMu.RUnlock() + + return im.state.IPv6Ready } func (im *InterfaceManager) GetIPv4Addresses() []string { + im.stateMu.RLock() + defer im.stateMu.RUnlock() + return im.state.IPv4Addresses } func (im *InterfaceManager) GetIPv4Address() string { + im.stateMu.RLock() + defer im.stateMu.RUnlock() + return im.state.IPv4Address } func (im *InterfaceManager) GetIPv6Address() string { + im.stateMu.RLock() + defer im.stateMu.RUnlock() + return im.state.IPv6Address } 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{} } @@ -569,11 +602,11 @@ func (im *InterfaceManager) updateInterfaceState() error { hasAddrs = true } - im.stateMu.Lock() - defer im.stateMu.Unlock() - // Check if state changed stateChanged := false + // We should release the lock before calling the callbacks + // to avoid deadlocks + im.stateMu.Lock() if im.state.Up != isUp { im.state.Up = isUp stateChanged = true @@ -594,6 +627,7 @@ func (im *InterfaceManager) updateInterfaceState() error { } im.state.LastUpdated = time.Now() + im.stateMu.Unlock() // Notify callback if state changed if stateChanged && im.onStateChange != nil { @@ -661,9 +695,8 @@ func (im *InterfaceManager) updateIPAddresses(nl *link.Link) error { // updateStateFromDHCPLease updates the state from a DHCP lease func (im *InterfaceManager) updateStateFromDHCPLease(lease *types.DHCPLease) { im.stateMu.Lock() - defer im.stateMu.Unlock() - im.state.DHCPLease4 = lease + im.stateMu.Unlock() // Update resolv.conf with DNS information if im.resolvConf != nil { diff --git a/pkg/nmlite/state.go b/pkg/nmlite/state.go index 48c080bd..517a44ac 100644 --- a/pkg/nmlite/state.go +++ b/pkg/nmlite/state.go @@ -12,7 +12,12 @@ func (nm *NetworkManager) IsOnline() bool { } func (nm *NetworkManager) IsUp() bool { - return nm.IsOnline() + for _, iface := range nm.interfaces { + if iface.IsUp() { + return true + } + } + return false } func (nm *NetworkManager) GetHostname() string { @@ -74,6 +79,20 @@ func (nm *NetworkManager) GetMACAddress() string { 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() } diff --git a/timesync.go b/timesync.go index abb427e0..956011b3 100644 --- a/timesync.go +++ b/timesync.go @@ -43,6 +43,18 @@ 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 !networkManager.IsOnline() { return false, nil From b04b148a4bf7b612b794efb6913a2963799c37d2 Mon Sep 17 00:00:00 2001 From: Siyuan Date: Tue, 7 Oct 2025 23:11:53 +0000 Subject: [PATCH 11/85] feat: add sync trace --- Makefile | 6 ++ internal/sync/log.go | 149 +++++++++++++++++++++++++++++++++++++ internal/sync/mutex.go | 69 +++++++++++++++++ internal/sync/once.go | 18 +++++ internal/sync/release.go | 92 +++++++++++++++++++++++ internal/sync/waitgroup.go | 30 ++++++++ scripts/dev_deploy.sh | 17 ++++- 7 files changed, 379 insertions(+), 2 deletions(-) create mode 100644 internal/sync/log.go create mode 100644 internal/sync/mutex.go create mode 100644 internal/sync/once.go create mode 100644 internal/sync/release.go create mode 100644 internal/sync/waitgroup.go 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/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/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" From aef26459d314f25938fe10a078f136b082e8a9e9 Mon Sep 17 00:00:00 2001 From: Siyuan Date: Tue, 7 Oct 2025 23:12:20 +0000 Subject: [PATCH 12/85] use sync trace to track mutexes to make deadlock analysis easier --- .vscode/settings.json | 6 +++++ internal/network/types/type.go | 28 +++++++++++++++++++++++ internal/timesync/ntp.go | 1 + internal/timesync/timesync.go | 3 +++ pkg/nmlite/dhcp.go | 35 ++++++++++++++++------------- pkg/nmlite/hostname.go | 3 ++- pkg/nmlite/interface.go | 17 ++++++-------- pkg/nmlite/jetdhcpc/client.go | 4 +++- pkg/nmlite/jetdhcpc/lease.go | 6 +++-- pkg/nmlite/link/netlink.go | 3 ++- pkg/nmlite/manager.go | 3 ++- pkg/nmlite/udhcpc/parser.go | 4 +++- pkg/nmlite/udhcpc/udhcpc.go | 4 +++- ui/src/components/DhcpLeaseCard.tsx | 7 ++++++ ui/src/hooks/stores.ts | 1 + 15 files changed, 92 insertions(+), 33 deletions(-) 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/internal/network/types/type.go b/internal/network/types/type.go index 3544fb35..83f64521 100644 --- a/internal/network/types/type.go +++ b/internal/network/types/type.go @@ -45,6 +45,8 @@ type IPv6StaticConfig struct { // 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"` @@ -145,6 +147,7 @@ type DHCPLease struct { LeaseExpiry *time.Time `json:"lease_expiry,omitempty"` // The expiry time of the lease InterfaceName string `json:"interface_name,omitempty"` // The name of the interface + DHCPClient string `json:"dhcp_client,omitempty"` // The DHCP client that obtained the lease } // InterfaceState represents the current state of a network interface @@ -173,6 +176,31 @@ type NetworkConfigInterface interface { IPv6Addresses() []IPAddress } +// IsIPv6 returns true if the DHCP lease is for an IPv6 address func (d *DHCPLease) IsIPv6() bool { return d.IPAddress.To4() == nil } + +// 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]) +} + +// 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(), + } +} diff --git a/internal/timesync/ntp.go b/internal/timesync/ntp.go index e5a18865..7ff410b0 100644 --- a/internal/timesync/ntp.go +++ b/internal/timesync/ntp.go @@ -62,6 +62,7 @@ func (t *TimeSync) filterNTPServers(ntpServers []string) ([]string, error) { if ip == nil { continue } + if hasIPv4 && ip.To4() != nil { filteredServers = append(filteredServers, server) } diff --git a/internal/timesync/timesync.go b/internal/timesync/timesync.go index d93401e0..f9f4d4f4 100644 --- a/internal/timesync/timesync.go +++ b/internal/timesync/timesync.go @@ -169,6 +169,9 @@ func (t *TimeSync) doTimeSync() { } func (t *TimeSync) Sync() error { + t.syncLock.Lock() + defer t.syncLock.Unlock() + var ( now *time.Time offset *time.Duration diff --git a/pkg/nmlite/dhcp.go b/pkg/nmlite/dhcp.go index 74e8df28..000633f0 100644 --- a/pkg/nmlite/dhcp.go +++ b/pkg/nmlite/dhcp.go @@ -14,25 +14,23 @@ import ( // 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 - link netlink.Link + ctx context.Context + ifaceName string + logger *zerolog.Logger + client types.DHCPClient + clientType string + link netlink.Link // Configuration ipv4Enabled bool ipv6Enabled bool - // State management - // stateManager *DHCPStateManager - // Callbacks onLeaseChange func(lease *types.DHCPLease) } // NewDHCPClient creates a new DHCP client -func NewDHCPClient(ctx context.Context, ifaceName string, logger *zerolog.Logger) (*DHCPClient, error) { +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") } @@ -42,9 +40,10 @@ func NewDHCPClient(ctx context.Context, ifaceName string, logger *zerolog.Logger } return &DHCPClient{ - ctx: ctx, - ifaceName: ifaceName, - logger: logger, + ctx: ctx, + ifaceName: ifaceName, + logger: logger, + clientType: clientType, }, nil } @@ -70,10 +69,13 @@ func (dc *DHCPClient) SetOnLeaseChange(callback func(lease *types.DHCPLease)) { } func (dc *DHCPClient) initClient() (types.DHCPClient, error) { - if false { + switch dc.clientType { + case "jetdhcpc": return dc.initJetDHCPC() - } else { + case "udhcpc": return dc.initUDHCPC() + default: + return nil, fmt.Errorf("invalid client type: %s", dc.clientType) } } @@ -204,8 +206,11 @@ func (dc *DHCPClient) handleLeaseChange(lease *types.DHCPLease, isIPv6 bool) { 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(lease) + dc.onLeaseChange(&leaseCopy) } } diff --git a/pkg/nmlite/hostname.go b/pkg/nmlite/hostname.go index 48090cac..6bf06991 100644 --- a/pkg/nmlite/hostname.go +++ b/pkg/nmlite/hostname.go @@ -6,7 +6,8 @@ import ( "os" "os/exec" "strings" - "sync" + + "github.com/jetkvm/kvm/internal/sync" "github.com/rs/zerolog" "golang.org/x/net/idna" diff --git a/pkg/nmlite/interface.go b/pkg/nmlite/interface.go index 324a0bf0..1bae6599 100644 --- a/pkg/nmlite/interface.go +++ b/pkg/nmlite/interface.go @@ -4,9 +4,11 @@ import ( "context" "fmt" "net" - "sync" + "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" @@ -78,7 +80,7 @@ func NewInterfaceManager(ctx context.Context, ifaceName string, config *types.Ne } // create the dhcp client - im.dhcpClient, err = NewDHCPClient(ctx, ifaceName, &scopedLogger) + im.dhcpClient, err = NewDHCPClient(ctx, ifaceName, &scopedLogger, config.DHCPClient.String) if err != nil { return nil, fmt.Errorf("failed to create DHCP client: %w", err) } @@ -562,7 +564,6 @@ 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() @@ -574,7 +575,6 @@ func (im *InterfaceManager) monitorInterfaceState() { case <-im.stopCh: return case <-ticker.C: - im.logger.Debug().Msg("checking interface state") if err := im.updateInterfaceState(); err != nil { im.logger.Error().Err(err).Msg("failed to update interface state") } @@ -727,12 +727,9 @@ func (im *InterfaceManager) applyDHCPLease(lease *types.DHCPLease) error { // convertDHCPLeaseToIPv4Config converts a DHCP lease to IPv4Config func (im *InterfaceManager) convertDHCPLeaseToIPv4Config(lease *types.DHCPLease) *types.IPAddress { - mask := lease.Netmask - - // Create IPNet from IP and netmask - ipNet := &net.IPNet{ - IP: lease.IPAddress, - Mask: net.IPv4Mask(mask[12], mask[13], mask[14], mask[15]), + ipNet := lease.IPNet() + if ipNet == nil { + return nil } // Create IPv4Address diff --git a/pkg/nmlite/jetdhcpc/client.go b/pkg/nmlite/jetdhcpc/client.go index 0f076ebb..2b9c1099 100644 --- a/pkg/nmlite/jetdhcpc/client.go +++ b/pkg/nmlite/jetdhcpc/client.go @@ -6,9 +6,11 @@ import ( "fmt" "net" "slices" - "sync" + "time" + "github.com/jetkvm/kvm/internal/sync" + "github.com/go-co-op/gocron/v2" "github.com/insomniacslk/dhcp/dhcpv4" "github.com/insomniacslk/dhcp/dhcpv6" diff --git a/pkg/nmlite/jetdhcpc/lease.go b/pkg/nmlite/jetdhcpc/lease.go index dbd4fef9..0c06f8fa 100644 --- a/pkg/nmlite/jetdhcpc/lease.go +++ b/pkg/nmlite/jetdhcpc/lease.go @@ -33,7 +33,9 @@ type Lease struct { // ToDHCPLease converts a lease to a DHCP lease. func (l *Lease) ToDHCPLease() *types.DHCPLease { - return &l.DHCPLease + lease := &l.DHCPLease + lease.DHCPClient = "jetdhcpc" + return lease } // fromNclient4Lease creates a lease from a nclient4.Lease. @@ -45,9 +47,9 @@ func fromNclient4Lease(l *nclient4.Lease, iface string) *Lease { // 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.MTU = int(resp.Options.Get(dhcpv4.OptionInterfaceMTU)) lease.NTPServers = l.ACK.NTPServers() diff --git a/pkg/nmlite/link/netlink.go b/pkg/nmlite/link/netlink.go index c8c4e53a..8e342f6d 100644 --- a/pkg/nmlite/link/netlink.go +++ b/pkg/nmlite/link/netlink.go @@ -10,9 +10,10 @@ import ( "path" "strconv" "strings" - "sync" "time" + "github.com/jetkvm/kvm/internal/sync" + "github.com/jetkvm/kvm/internal/network/types" "github.com/rs/zerolog" "github.com/vishvananda/netlink" diff --git a/pkg/nmlite/manager.go b/pkg/nmlite/manager.go index ba7d3a8f..b7ef6a64 100644 --- a/pkg/nmlite/manager.go +++ b/pkg/nmlite/manager.go @@ -6,7 +6,8 @@ package nmlite import ( "context" "fmt" - "sync" + + "github.com/jetkvm/kvm/internal/sync" "github.com/jetkvm/kvm/internal/logging" "github.com/jetkvm/kvm/internal/network/types" diff --git a/pkg/nmlite/udhcpc/parser.go b/pkg/nmlite/udhcpc/parser.go index e76dceca..115cdc17 100644 --- a/pkg/nmlite/udhcpc/parser.go +++ b/pkg/nmlite/udhcpc/parser.go @@ -40,7 +40,9 @@ func (l *Lease) ToJSON() string { // ToDHCPLease converts a lease to a DHCP lease. func (l *Lease) ToDHCPLease() *types.DHCPLease { - return &l.DHCPLease + lease := &l.DHCPLease + lease.DHCPClient = "udhcpc" + return lease } // SetLeaseExpiry sets the lease expiry time. diff --git a/pkg/nmlite/udhcpc/udhcpc.go b/pkg/nmlite/udhcpc/udhcpc.go index 12e1374d..19ce2cb5 100644 --- a/pkg/nmlite/udhcpc/udhcpc.go +++ b/pkg/nmlite/udhcpc/udhcpc.go @@ -6,9 +6,11 @@ import ( "os" "path/filepath" "reflect" - "sync" + "time" + "github.com/jetkvm/kvm/internal/sync" + "github.com/fsnotify/fsnotify" "github.com/jetkvm/kvm/internal/network/types" "github.com/rs/zerolog" diff --git a/ui/src/components/DhcpLeaseCard.tsx b/ui/src/components/DhcpLeaseCard.tsx index bf20f64d..f0826c7a 100644 --- a/ui/src/components/DhcpLeaseCard.tsx +++ b/ui/src/components/DhcpLeaseCard.tsx @@ -221,6 +221,13 @@ export default function DhcpLeaseCard({
)} + + {networkState?.dhcp_lease?.dhcp_client && ( +
+ DHCP Client + {networkState?.dhcp_lease?.dhcp_client} +
+ )}
diff --git a/ui/src/hooks/stores.ts b/ui/src/hooks/stores.ts index e539f916..e9ef15d5 100644 --- a/ui/src/hooks/stores.ts +++ b/ui/src/hooks/stores.ts @@ -690,6 +690,7 @@ export interface DhcpLease { message?: string; tftp?: string; bootfile?: string; + dhcp_client?: string; } export interface IPv6Address { From 17a15619ff0586c3dcf980d203e7bcadfd75447d Mon Sep 17 00:00:00 2001 From: Siyuan Date: Wed, 8 Oct 2025 09:20:31 +0000 Subject: [PATCH 13/85] use any to replace interface{} --- pkg/nmlite/jetdhcpc/client.go | 8 ++++---- pkg/nmlite/resolvconf.go | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/pkg/nmlite/jetdhcpc/client.go b/pkg/nmlite/jetdhcpc/client.go index 2b9c1099..7bb68795 100644 --- a/pkg/nmlite/jetdhcpc/client.go +++ b/pkg/nmlite/jetdhcpc/client.go @@ -153,14 +153,14 @@ func (c *Client) ensureInterfaceUp(ifname string) (*link.Link, error) { return nlm.EnsureInterfaceUpWithTimeout(c.ctx, iface, c.cfg.LinkUpTimeout) } -func (c *Client) sendInitialRequests() chan interface{} { +func (c *Client) sendInitialRequests() chan any { return c.sendRequests(c.cfg.IPv4, c.cfg.IPv6) } func (c *Client) sendRequestsFamily( family int, wg *sync.WaitGroup, - r *chan interface{}, + r *chan any, l *zerolog.Logger, iface *link.Link, ) { @@ -185,12 +185,12 @@ func (c *Client) sendRequestsFamily( }(iface) } -func (c *Client) sendRequests(ipv4, ipv6 bool) chan interface{} { +func (c *Client) sendRequests(ipv4, ipv6 bool) chan any { c.mu.Lock() defer c.mu.Unlock() // Yeah, this is a hack, until we can cancel all leases in progress. - r := make(chan interface{}, 3*len(c.ifaces)) + r := make(chan any, 3*len(c.ifaces)) var wg sync.WaitGroup for _, iface := range c.ifaces { diff --git a/pkg/nmlite/resolvconf.go b/pkg/nmlite/resolvconf.go index 873fd5ee..0502bda7 100644 --- a/pkg/nmlite/resolvconf.go +++ b/pkg/nmlite/resolvconf.go @@ -133,7 +133,7 @@ func (rcm *ResolvConfManager) generateResolvConf(iface string, nameservers []net } var buf bytes.Buffer - if err := tmpl.Execute(&buf, map[string]interface{}{ + if err := tmpl.Execute(&buf, map[string]any{ "iface": iface, "nameservers": nameservers, "searchList": searchList, From d6ebbf4d0925afe33c1f2cb7b9db35de4f626c42 Mon Sep 17 00:00:00 2001 From: Siyuan Date: Wed, 8 Oct 2025 09:21:20 +0000 Subject: [PATCH 14/85] fix lint error --- internal/netif/network.go | 19 ------------------- network.go | 5 ++++- 2 files changed, 4 insertions(+), 20 deletions(-) delete mode 100644 internal/netif/network.go diff --git a/internal/netif/network.go b/internal/netif/network.go deleted file mode 100644 index a135851f..00000000 --- a/internal/netif/network.go +++ /dev/null @@ -1,19 +0,0 @@ -package netif - -import ( - "fmt" - - "github.com/vishvananda/netlink" -) - -func ensureInterfaceIsUp(iface *netlink.Link) error { - if (*iface).Attrs().OperState == netlink.OperUp { - return nil - } - - if err := netlink.LinkSetUp(*iface); err != nil { - return fmt.Errorf("failed to set interface up: %w", err) - } - - return nil -} diff --git a/network.go b/network.go index de2ff956..a2518adb 100644 --- a/network.go +++ b/network.go @@ -2,6 +2,7 @@ package kvm import ( "context" + "fmt" "github.com/jetkvm/kvm/internal/mdns" "github.com/jetkvm/kvm/internal/network/types" @@ -77,7 +78,9 @@ func initNetwork() error { networkManager = nmlite.NewNetworkManager(context.Background(), networkLogger) networkManager.SetOnInterfaceStateChange(networkStateChanged) - networkManager.AddInterface(NetIfName, config.NetworkConfig) + if err := networkManager.AddInterface(NetIfName, config.NetworkConfig); err != nil { + return fmt.Errorf("failed to add interface: %w", err) + } return nil } From f452e6b4c4eb7146f2746aeb13fbf5b35108b41c Mon Sep 17 00:00:00 2001 From: Siyuan Date: Wed, 8 Oct 2025 09:47:15 +0000 Subject: [PATCH 15/85] fix: link addr not updated --- pkg/nmlite/hostname.go | 9 +- pkg/nmlite/interface.go | 21 +- pkg/nmlite/link/consts.go | 13 + pkg/nmlite/link/manager.go | 406 +++++++++++++++++++++++++++ pkg/nmlite/link/netlink.go | 545 ++----------------------------------- pkg/nmlite/link/sysctl.go | 52 ++++ pkg/nmlite/link/utils.go | 87 ++++++ pkg/nmlite/static.go | 8 +- 8 files changed, 599 insertions(+), 542 deletions(-) create mode 100644 pkg/nmlite/link/consts.go create mode 100644 pkg/nmlite/link/manager.go create mode 100644 pkg/nmlite/link/sysctl.go create mode 100644 pkg/nmlite/link/utils.go diff --git a/pkg/nmlite/hostname.go b/pkg/nmlite/hostname.go index 6bf06991..812fa533 100644 --- a/pkg/nmlite/hostname.go +++ b/pkg/nmlite/hostname.go @@ -18,13 +18,10 @@ const ( hostsPath = "/etc/hosts" ) -var ( - hostnameLock sync.Mutex -) - // HostnameManager manages system hostname and /etc/hosts type HostnameManager struct { logger *zerolog.Logger + mu sync.Mutex } // NewHostnameManager creates a new hostname manager @@ -41,8 +38,8 @@ func NewHostnameManager(logger *zerolog.Logger) *HostnameManager { // SetHostname sets the system hostname and updates /etc/hosts func (hm *HostnameManager) SetHostname(hostname, fqdn string) error { - hostnameLock.Lock() - defer hostnameLock.Unlock() + hm.mu.Lock() + defer hm.mu.Unlock() hostname = ToValidHostname(strings.TrimSpace(hostname)) fqdn = ToValidHostname(strings.TrimSpace(fqdn)) diff --git a/pkg/nmlite/interface.go b/pkg/nmlite/interface.go index 1bae6599..fb3b5304 100644 --- a/pkg/nmlite/interface.go +++ b/pkg/nmlite/interface.go @@ -111,7 +111,7 @@ func (im *InterfaceManager) Start() error { go im.monitorInterfaceState() nl := getNetlinkManager() - nl.AddLinkStateCallback(im.ifaceName, link.LinkStateCallback{ + nl.AddStateChangeCallback(im.ifaceName, link.StateChangeCallback{ Async: true, Func: func(link *link.Link) { im.handleLinkStateChange(link) @@ -160,6 +160,7 @@ func (im *InterfaceManager) IsUp() bool { return im.state.Up } +// IsOnline returns true if the interface is online func (im *InterfaceManager) IsOnline() bool { im.stateMu.RLock() defer im.stateMu.RUnlock() @@ -167,6 +168,7 @@ func (im *InterfaceManager) IsOnline() bool { 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() @@ -174,6 +176,7 @@ func (im *InterfaceManager) IPv4Ready() bool { 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() @@ -181,6 +184,7 @@ func (im *InterfaceManager) IPv6Ready() bool { return im.state.IPv6Ready } +// GetIPv4Addresses returns the IPv4 addresses of the interface func (im *InterfaceManager) GetIPv4Addresses() []string { im.stateMu.RLock() defer im.stateMu.RUnlock() @@ -188,6 +192,7 @@ func (im *InterfaceManager) GetIPv4Addresses() []string { return im.state.IPv4Addresses } +// GetIPv4Address returns the IPv4 address of the interface func (im *InterfaceManager) GetIPv4Address() string { im.stateMu.RLock() defer im.stateMu.RUnlock() @@ -195,6 +200,7 @@ func (im *InterfaceManager) GetIPv4Address() string { return im.state.IPv4Address } +// GetIPv6Address returns the IPv6 address of the interface func (im *InterfaceManager) GetIPv6Address() string { im.stateMu.RLock() defer im.stateMu.RUnlock() @@ -202,6 +208,7 @@ func (im *InterfaceManager) GetIPv6Address() string { return im.state.IPv6Address } +// GetIPv6Addresses returns the IPv6 addresses of the interface func (im *InterfaceManager) GetIPv6Addresses() []string { im.stateMu.RLock() defer im.stateMu.RUnlock() @@ -214,6 +221,7 @@ func (im *InterfaceManager) GetIPv6Addresses() []string { return []string{} } +// GetMACAddress returns the MAC address of the interface func (im *InterfaceManager) GetMACAddress() string { return im.state.MACAddress } @@ -230,7 +238,11 @@ func (im *InterfaceManager) GetState() *types.InterfaceState { 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 } @@ -241,6 +253,7 @@ func (im *InterfaceManager) GetConfig() *types.NetworkConfig { return &config } +// ApplyConfiguration applies the current configuration to the interface func (im *InterfaceManager) ApplyConfiguration() error { return im.applyConfiguration() } @@ -641,6 +654,10 @@ func (im *InterfaceManager) updateInterfaceState() error { // updateIPAddresses updates the IP addresses in the state func (im *InterfaceManager) updateIPAddresses(nl *link.Link) error { + if err := nl.Refresh(); err != nil { + return fmt.Errorf("failed to refresh link: %w", err) + } + addrs, err := nl.AddrList(link.AfUnspec) if err != nil { return fmt.Errorf("failed to get addresses: %w", err) @@ -713,7 +730,7 @@ func (im *InterfaceManager) ReconcileLinkAddrs(addrs []*types.IPAddress) error { if link == nil { return fmt.Errorf("failed to get interface: %w", err) } - return nl.ReconcileLinkAddrs(link, addrs) + return nl.ReconcileLink(link, addrs) } // applyDHCPLease applies DHCP lease configuration using ReconcileLinkAddrs 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..a3333999 --- /dev/null +++ b/pkg/nmlite/link/manager.go @@ -0,0 +1,406 @@ +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 + if state == netlink.OperUp || state == netlink.OperUnknown { + return link, nil + } + + l.Info().Str("state", state.String()).Msg("bringing up interface") + + if err = nm.LinkSetUp(link); err != nil { + l.Error().Err(err).Msg("interface can't make it up") + } + + l = l.With().Int("attempt", attempt).Dur("duration", time.Since(start)).Logger() + + if attempt > 0 { + l.Info().Msg("interface up") + } + + 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) +} + +// HasDefaultRoute checks if a default route exists for the given family +func (nm *NetlinkManager) HasDefaultRoute(family int) bool { + routes, err := netlink.RouteList(nil, family) + if err != nil { + return false + } + + for _, route := range routes { + if route.Dst == nil { + return true + } + if family == AfInet && route.Dst.IP.Equal(net.IPv4zero) && route.Dst.Mask.String() == "0.0.0.0/0" { + return true + } + if family == AfInet6 && route.Dst.IP.Equal(net.IPv6zero) && route.Dst.Mask.String() == "::/0" { + return true + } + } + + return false +} + +// 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 +} + +// ReconcileLink reconciles the addresses and routes of a link +func (nm *NetlinkManager) ReconcileLink(link *Link, expected []*types.IPAddress) error { + expectedAddrs := make(map[string]bool) + existingAddrs := make(map[string]bool) + + for _, addr := range expected { + ipCidr := addr.Address.IP.String() + "/" + addr.Address.Mask.String() + expectedAddrs[ipCidr] = true + } + + addrs, err := nm.AddrList(link, AfUnspec) + if err != nil { + return fmt.Errorf("failed to get addresses: %w", err) + } + + for _, addr := range addrs { + ipCidr := addr.IP.String() + "/" + addr.IPNet.Mask.String() + existingAddrs[ipCidr] = true + } + + for _, addr := range expected { + family := AfUnspec + if addr.Address.IP.To4() != nil { + family = AfInet + } else if addr.Address.IP.To16() != nil { + family = AfInet6 + } + + ipCidr := addr.Address.IP.String() + "/" + addr.Address.Mask.String() + if ok := existingAddrs[ipCidr]; !ok { + ipNet := &net.IPNet{ + IP: addr.Address.IP, + Mask: addr.Address.Mask, + } + + if err := nm.AddrAdd(link, &netlink.Addr{IPNet: ipNet}); err != nil { + return fmt.Errorf("failed to add address %s: %w", ipCidr, err) + } + + nm.logger.Info().Str("address", ipCidr).Msg("added address") + } + + if addr.Gateway != nil { + nm.logger.Trace().Str("address", ipCidr).Str("gateway", addr.Gateway.String()).Msg("adding default route for address") + if err := nm.AddDefaultRoute(link, addr.Gateway, family); err != nil { + return fmt.Errorf("failed to add default route for address %s: %w", ipCidr, err) + } + } + } + + return nil +} diff --git a/pkg/nmlite/link/netlink.go b/pkg/nmlite/link/netlink.go index 8e342f6d..994935d7 100644 --- a/pkg/nmlite/link/netlink.go +++ b/pkg/nmlite/link/netlink.go @@ -2,35 +2,15 @@ package link import ( - "context" "errors" "fmt" "net" - "os" - "path" - "strconv" - "strings" - "time" "github.com/jetkvm/kvm/internal/sync" - "github.com/jetkvm/kvm/internal/network/types" - "github.com/rs/zerolog" "github.com/vishvananda/netlink" ) -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 -) - var ( ipv4DefaultRoute = net.IPNet{ IP: net.IPv4zero, @@ -46,30 +26,30 @@ var ( netlinkManagerInstance *NetlinkManager netlinkManagerOnce sync.Once - // Error definitions - ErrInterfaceUpTimeout = errors.New("timeout after waiting for an interface to come up") + // 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") ) -type LinkStateCallbackFunction func(link *Link) -type LinkStateCallback struct { - Async bool - Func LinkStateCallbackFunction -} - -// NetlinkManager provides centralized netlink operations -type NetlinkManager struct { - logger *zerolog.Logger - linkStateCh chan netlink.LinkUpdate - mu sync.RWMutex - linkStateCallbacks map[string][]LinkStateCallback -} - // Link is a wrapper around netlink.Link type Link struct { netlink.Link } +func (l *Link) Refresh() error { + linkName := l.Link.Attrs().Name + 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 +} + // Attrs returns the attributes of the link func (l *Link) Attrs() *netlink.LinkAttrs { return l.Link.Attrs() @@ -100,496 +80,3 @@ func (l *Link) IsSame(other *Link) bool { } return true } - -func newNetlinkManager(logger *zerolog.Logger) *NetlinkManager { - if logger == nil { - logger = &zerolog.Logger{} // Default no-op logger - } - n := &NetlinkManager{ - logger: logger, - linkStateCallbacks: make(map[string][]LinkStateCallback), - } - n.monitorLinkState() - return n -} - -// GetNetlinkManager returns the singleton NetlinkManager instance -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 -} - -func (nm *NetlinkManager) runCallbacks(update netlink.LinkUpdate) { - nm.mu.RLock() - defer nm.mu.RUnlock() - - ifname := update.Link.Attrs().Name - callbacks, ok := nm.linkStateCallbacks[ifname] - - l := nm.logger.With().Str("interface", ifname).Logger() - if !ok { - l.Trace().Msg("no callbacks for interface") - return - } - for _, callback := range callbacks { - l.Trace().Interface("callback", callback).Msg("calling callback") - - if callback.Async { - go callback.Func(&Link{Link: update.Link}) - } else { - callback.Func(&Link{Link: update.Link}) - } - } -} - -// AddLinkStateCallback adds a callback for link state changes -func (nm *NetlinkManager) AddLinkStateCallback(ifname string, callback LinkStateCallback) { - nm.mu.Lock() - defer nm.mu.Unlock() - nm.linkStateCallbacks[ifname] = append(nm.linkStateCallbacks[ifname], callback) -} - -// Interface operations -func (nm *NetlinkManager) monitorLinkState() { - updateCh := make(chan netlink.LinkUpdate) - // we don't need to stop the subscription, as it will be closed when the program exits - stopCh := make(chan struct{}) //nolint:unused - netlink.LinkSubscribe(updateCh, stopCh) - - nm.logger.Info().Msg("link state monitoring started") - - go func() { - for update := range updateCh { - nm.runCallbacks(update) - } - }() -} - -// GetLinkByName gets a network link by name -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 - if state == netlink.OperUp || state == netlink.OperUnknown { - return link, nil - } - - l.Info().Str("state", state.String()).Msg("bringing up interface") - - if err = nm.LinkSetUp(link); err != nil { - l.Error().Err(err).Msg("interface can't make it up") - } - - l = l.With().Int("attempt", attempt).Dur("duration", time.Since(start)).Logger() - - if attempt > 0 { - l.Info().Msg("interface up") - } - - 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) -} - -// HasDefaultRoute checks if a default route exists for the given family -func (nm *NetlinkManager) HasDefaultRoute(family int) bool { - routes, err := netlink.RouteList(nil, family) - if err != nil { - return false - } - - for _, route := range routes { - if route.Dst == nil { - return true - } - if family == AfInet && route.Dst.IP.Equal(net.IPv4zero) && route.Dst.Mask.String() == "0.0.0.0/0" { - return true - } - if family == AfInet6 && route.Dst.IP.Equal(net.IPv6zero) && route.Dst.Mask.String() == "::/0" { - return true - } - } - - return false -} - -// AddDefaultRoute adds a default route -func (nm *NetlinkManager) AddDefaultRoute(link *Link, gateway net.IP, family int) error { - var dst *net.IPNet - if family == AfInet { - dst = &ipv4DefaultRoute - } else if family == AfInet6 { - dst = &ipv6DefaultRoute - } else { - 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) ReconcileLinkAddrs(link *Link, expected []*types.IPAddress) error { - expectedAddrs := make(map[string]bool) - existingAddrs := make(map[string]bool) - - for _, addr := range expected { - ipCidr := addr.Address.IP.String() + "/" + addr.Address.Mask.String() - expectedAddrs[ipCidr] = true - } - - addrs, err := nm.AddrList(link, AfUnspec) - if err != nil { - return fmt.Errorf("failed to get addresses: %w", err) - } - - for _, addr := range addrs { - ipCidr := addr.IP.String() + "/" + addr.IPNet.Mask.String() - existingAddrs[ipCidr] = true - } - - for _, addr := range expected { - family := AfUnspec - if addr.Address.IP.To4() != nil { - family = AfInet - } else if addr.Address.IP.To16() != nil { - family = AfInet6 - } - - ipCidr := addr.Address.IP.String() + "/" + addr.Address.Mask.String() - if ok := existingAddrs[ipCidr]; !ok { - ipNet := &net.IPNet{ - IP: addr.Address.IP, - Mask: addr.Address.Mask, - } - - if err := nm.AddrAdd(link, &netlink.Addr{IPNet: ipNet}); err != nil { - return fmt.Errorf("failed to add address %s: %w", ipCidr, err) - } - - nm.logger.Info().Str("address", ipCidr).Msg("added address") - } - - if addr.Gateway != nil { - nm.logger.Trace().Str("address", ipCidr).Str("gateway", addr.Gateway.String()).Msg("adding default route for address") - if err := nm.AddDefaultRoute(link, addr.Gateway, family); err != nil { - return fmt.Errorf("failed to add default route for address %s: %w", ipCidr, err) - } - } - } - - return nil -} - -// Sysctl operations - -// SetSysctlValues sets sysctl values for the interface -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, - }) -} - -// Utility functions - -// ParseIPv4Netmask parses an IPv4 netmask string and returns the IPNet -func (nm *NetlinkManager) 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 (nm *NetlinkManager) 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 (nm *NetlinkManager) 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/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/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/static.go b/pkg/nmlite/static.go index 1bf24c47..292c8b59 100644 --- a/pkg/nmlite/static.go +++ b/pkg/nmlite/static.go @@ -176,8 +176,7 @@ func (scm *StaticConfigManager) parseIPv4Config(config *types.IPv4StaticConfig) } // Parse IP address and netmask - netlinkMgr := getNetlinkManager() - ipNet, err := netlinkMgr.ParseIPv4Netmask(config.Address.String, config.Netmask.String) + ipNet, err := link.ParseIPv4Netmask(config.Address.String, config.Netmask.String) if err != nil { return nil, err } @@ -192,7 +191,7 @@ func (scm *StaticConfigManager) parseIPv4Config(config *types.IPv4StaticConfig) // Parse DNS servers var dns []net.IP for _, dnsStr := range config.DNS { - if err := netlinkMgr.ValidateIPAddress(dnsStr, false); err != nil { + if err := link.ValidateIPAddress(dnsStr, false); err != nil { return nil, fmt.Errorf("invalid DNS server: %w", err) } dns = append(dns, net.ParseIP(dnsStr)) @@ -212,8 +211,7 @@ func (scm *StaticConfigManager) parseIPv6Config(config *types.IPv6StaticConfig) } // Parse IP address and prefix - netlinkMgr := getNetlinkManager() - ipNet, err := netlinkMgr.ParseIPv6Prefix(config.Prefix.String, 64) // Default to /64 if not specified + ipNet, err := link.ParseIPv6Prefix(config.Prefix.String, 64) // Default to /64 if not specified if err != nil { return nil, err } From abb8c4f0b5b50a60cf8a365caebd50277e12a906 Mon Sep 17 00:00:00 2001 From: Siyuan Date: Wed, 8 Oct 2025 10:04:58 +0000 Subject: [PATCH 16/85] revert to default config if config is invalid --- config.go | 14 +++++++++++--- network.go | 31 +++++++++++++++++++++++++++---- 2 files changed, 38 insertions(+), 7 deletions(-) diff --git a/config.go b/config.go index 1694244f..593b635c 100644 --- a/config.go +++ b/config.go @@ -246,17 +246,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) } @@ -272,7 +280,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/network.go b/network.go index a2518adb..70936a89 100644 --- a/network.go +++ b/network.go @@ -4,6 +4,7 @@ import ( "context" "fmt" + "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" @@ -73,9 +74,27 @@ func networkStateChanged(iface string, state *types.InterfaceState) { } } +func validateNetworkConfig() { + err := confparser.SetDefaultsAndValidate(config.NetworkConfig) + if err == nil { + return + } + + 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() + // validate the config, if it's invalid, revert to the default config and save the backup + validateNetworkConfig() + networkManager = nmlite.NewNetworkManager(context.Background(), networkLogger) networkManager.SetOnInterfaceStateChange(networkStateChanged) if err := networkManager.AddInterface(NetIfName, config.NetworkConfig); err != nil { @@ -97,13 +116,18 @@ func rpcGetNetworkSettings() *RpcNetworkSettings { func rpcSetNetworkSettings(settings RpcNetworkSettings) (*RpcNetworkSettings, error) { netConfig := settings.ToNetworkConfig() - networkLogger.Debug().Interface("newConfig", netConfig).Interface("config", settings).Msg("setting new config") + 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 } - networkLogger.Debug().Interface("newConfig", netConfig).Interface("config", settings).Msg("new config") + l.Debug().Msg("new config applied") newConfig, err := networkManager.GetInterfaceConfig(NetIfName) if err != nil { @@ -111,8 +135,7 @@ func rpcSetNetworkSettings(settings RpcNetworkSettings) (*RpcNetworkSettings, er } config.NetworkConfig = newConfig - networkLogger.Debug().Interface("newConfig", newConfig).Interface("config", settings).Msg("saving config") - + l.Debug().Msg("saving new config") if err := SaveConfig(); err != nil { return nil, err } From db64c649d4500616f501f5a03d5f6b0cfebaab6b Mon Sep 17 00:00:00 2001 From: Siyuan Date: Wed, 8 Oct 2025 10:28:32 +0000 Subject: [PATCH 17/85] init display before network initialization --- display.go | 3 +-- main.go | 6 +++--- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/display.go b/display.go index 36a560f9..b90f40e0 100644 --- a/display.go +++ b/display.go @@ -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,7 +184,6 @@ func waitCtrlAndRequestDisplayUpdate(shouldWakeDisplay bool, reason string) { waitDisplayUpdate.Lock() defer waitDisplayUpdate.Unlock() - // nativeInstance.WaitCtrlClientConnected() requestDisplayUpdate(shouldWakeDisplay, reason) } diff --git a/main.go b/main.go index e9931d46..8074cca4 100644 --- a/main.go +++ b/main.go @@ -35,6 +35,9 @@ func Main() { initNative(systemVersionLocal, appVersionLocal) + // initialize display + initDisplay() + http.DefaultClient.Timeout = 1 * time.Minute err = rootcerts.UpdateDefaultTransport() @@ -74,9 +77,6 @@ func Main() { } initJiggler() - // initialize display - initDisplay() - go func() { time.Sleep(15 * time.Minute) for { From 844991134365981164827ece0bea88fb94ed1932 Mon Sep 17 00:00:00 2001 From: Adam Shiervani Date: Wed, 8 Oct 2025 12:05:30 +0200 Subject: [PATCH 18/85] Add placeholder to hostname --- ui/src/routes/devices.$id.settings.network.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ui/src/routes/devices.$id.settings.network.tsx b/ui/src/routes/devices.$id.settings.network.tsx index 11897600..4ea4b8eb 100644 --- a/ui/src/routes/devices.$id.settings.network.tsx +++ b/ui/src/routes/devices.$id.settings.network.tsx @@ -222,9 +222,8 @@ export default function SettingsNetworkRoute() { description="Configure the network settings for the device" action={ <> - + {(formState.isDirty || formState.isSubmitting) && ( - //
- )} +
+
} /> @@ -420,21 +417,18 @@ export default function SettingsNetworkRoute() { )}
- {(formState.isDirty || formState.isSubmitting) && ( - <> -
-
-
- - )} + <> +
+
+
From 8310077af621fa6e3b85b758bf70d81565efb02b Mon Sep 17 00:00:00 2001 From: Siyuan Date: Wed, 8 Oct 2025 10:57:31 +0000 Subject: [PATCH 20/85] send router solicitation --- display.go | 14 +++------- go.mod | 1 + go.sum | 2 ++ pkg/nmlite/interface.go | 53 ++++++++++++++++++++++++++++++++++++++ pkg/nmlite/link/netlink.go | 23 +++++++++++++++++ 5 files changed, 83 insertions(+), 10 deletions(-) diff --git a/display.go b/display.go index b90f40e0..cab87ff3 100644 --- a/display.go +++ b/display.go @@ -325,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() } }() } @@ -339,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 695a9f91..e4ada2c0 100644 --- a/go.mod +++ b/go.mod @@ -61,6 +61,7 @@ require ( 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 diff --git a/go.sum b/go.sum index 4a3397b8..9df5e759 100644 --- a/go.sum +++ b/go.sum @@ -99,6 +99,8 @@ 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= diff --git a/pkg/nmlite/interface.go b/pkg/nmlite/interface.go index fb3b5304..759b0e02 100644 --- a/pkg/nmlite/interface.go +++ b/pkg/nmlite/interface.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "net" + "net/netip" "time" @@ -13,6 +14,7 @@ import ( "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" ) @@ -453,6 +455,10 @@ func (im *InterfaceManager) applyIPv6SLAAC() error { return fmt.Errorf("failed to remove non-link-local IPv6 addresses: %w", err) } + if err := im.SendRouterSolicitation(); err != nil { + return fmt.Errorf("failed to send router solicitation: %w", err) + } + // Enable SLAAC return im.staticConfig.EnableIPv6SLAAC() } @@ -545,6 +551,48 @@ func (im *InterfaceManager) handleLinkStateChange(link *link.Link) { } } +// 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) + } + + 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) + defer c.Close() + 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 { + return fmt.Errorf("failed to write to %s: %w", targetAddr.String(), err) + } + + im.logger.Info().Msg("router solicitation sent") + + return nil +} + func (im *InterfaceManager) handleLinkUp() { im.logger.Info().Msg("link up") @@ -553,6 +601,11 @@ func (im *InterfaceManager) handleLinkUp() { if im.config.IPv4Mode.String == "dhcp" { im.dhcpClient.Renew() } + + if im.config.IPv6Mode.String == "slaac" { + im.staticConfig.EnableIPv6SLAAC() + im.SendRouterSolicitation() + } } func (im *InterfaceManager) handleLinkDown() { diff --git a/pkg/nmlite/link/netlink.go b/pkg/nmlite/link/netlink.go index 994935d7..0fc460b2 100644 --- a/pkg/nmlite/link/netlink.go +++ b/pkg/nmlite/link/netlink.go @@ -37,6 +37,7 @@ type Link struct { netlink.Link } +// Refresh refreshes the link func (l *Link) Refresh() error { linkName := l.Link.Attrs().Name link, err := netlink.LinkByName(linkName) @@ -50,6 +51,28 @@ func (l *Link) Refresh() error { return nil } +// Interface returns the interface of the link +func (l *Link) Interface() *net.Interface { + attrs := l.Attrs() + if attrs.Name == "" { + return nil + } + iface, err := net.InterfaceByName(attrs.Name) + if err != nil { + return nil + } + return iface +} + +// HardwareAddr returns the hardware address of the link +func (l *Link) HardwareAddr() net.HardwareAddr { + attrs := l.Attrs() + if attrs.HardwareAddr == nil { + return nil + } + return attrs.HardwareAddr +} + // Attrs returns the attributes of the link func (l *Link) Attrs() *netlink.LinkAttrs { return l.Link.Attrs() From f1283431871fe5b0aac0864a82b9a1fbe2f2e9f8 Mon Sep 17 00:00:00 2001 From: Siyuan Date: Wed, 8 Oct 2025 12:01:55 +0000 Subject: [PATCH 21/85] fix race condition in link manager --- main.go | 4 +-- pkg/nmlite/interface.go | 46 +++++++++++++++++++++---- pkg/nmlite/jetdhcpc/dhcp4.go | 8 ++++- pkg/nmlite/jetdhcpc/logging.go | 28 +++++++++++++-- pkg/nmlite/link/manager.go | 45 ++++++++++++++++++------ pkg/nmlite/link/netlink.go | 62 +++++++++++++++++++++++++++------- 6 files changed, 157 insertions(+), 36 deletions(-) diff --git a/main.go b/main.go index 8074cca4..961f0c58 100644 --- a/main.go +++ b/main.go @@ -33,10 +33,8 @@ func Main() { go runWatchdog() go confirmCurrentSystem() - initNative(systemVersionLocal, appVersionLocal) - - // initialize display initDisplay() + initNative(systemVersionLocal, appVersionLocal) http.DefaultClient.Timeout = 1 * time.Minute diff --git a/pkg/nmlite/interface.go b/pkg/nmlite/interface.go index 759b0e02..42ede504 100644 --- a/pkg/nmlite/interface.go +++ b/pkg/nmlite/interface.go @@ -106,6 +106,9 @@ func NewInterfaceManager(ctx context.Context, ifaceName string, config *types.Ne // 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 @@ -113,6 +116,22 @@ func (im *InterfaceManager) Start() error { 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) { @@ -120,10 +139,14 @@ func (im *InterfaceManager) Start() error { }, }) - // Apply initial configuration - if err := im.applyConfiguration(); err != nil { - im.logger.Error().Err(err).Msg("failed to apply initial configuration") - return err + 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") @@ -451,12 +474,18 @@ func (im *InterfaceManager) applyIPv6SLAAC() error { } 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 { - return fmt.Errorf("failed to send router solicitation: %w", err) + im.logger.Error().Err(err).Msg("failed to send router solicitation, continuing anyway") } // Enable SLAAC @@ -561,6 +590,10 @@ func (im *InterfaceManager) SendRouterSolicitation() error { 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) @@ -572,7 +605,6 @@ func (im *InterfaceManager) SendRouterSolicitation() error { } c, _, err := ndp.Listen(iface, ndp.LinkLocal) - defer c.Close() if err != nil { return fmt.Errorf("failed to create NDP listener on %s: %w", im.ifaceName, err) } @@ -585,10 +617,12 @@ func (im *InterfaceManager) SendRouterSolicitation() error { 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 } diff --git a/pkg/nmlite/jetdhcpc/dhcp4.go b/pkg/nmlite/jetdhcpc/dhcp4.go index e77db862..4f0cdbba 100644 --- a/pkg/nmlite/jetdhcpc/dhcp4.go +++ b/pkg/nmlite/jetdhcpc/dhcp4.go @@ -1,6 +1,8 @@ package jetdhcpc import ( + "fmt" + "github.com/insomniacslk/dhcp/dhcpv4" "github.com/insomniacslk/dhcp/dhcpv4/nclient4" "github.com/vishvananda/netlink" @@ -46,7 +48,11 @@ func (c *Client) requestLease4(iface netlink.Link) (*Lease, error) { return nil, err } - l.Info().Msgf("DHCPv4 lease acquired: %s", lease.ACK.Summary()) + 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/logging.go b/pkg/nmlite/jetdhcpc/logging.go index 3ee696e7..da31c2e8 100644 --- a/pkg/nmlite/jetdhcpc/logging.go +++ b/pkg/nmlite/jetdhcpc/logging.go @@ -21,11 +21,35 @@ func (s dhcpLogger) Printf(format string, v ...interface{}) { // PrintMessage prints a DHCP message in the short format via predefined Printfer func (s dhcpLogger) PrintMessage(prefix string, message *dhcpv4.DHCPv4) { - s.l.Info().Str("prefix", prefix).Str("message", message.String()).Msg("DHCP message") + 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).Logger() + logger := c.l.With(). + Str("interface", ifname). + Str("source", "dhcp4"). + Logger() return nclient4.WithLogger(dhcpLogger{ l: &logger, diff --git a/pkg/nmlite/link/manager.go b/pkg/nmlite/link/manager.go index a3333999..96813b4f 100644 --- a/pkg/nmlite/link/manager.go +++ b/pkg/nmlite/link/manager.go @@ -163,22 +163,40 @@ func (nm *NetlinkManager) EnsureInterfaceUpWithTimeout(ctx context.Context, ifac } 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().Str("state", state.String()).Msg("bringing up interface") + 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") } - l = l.With().Int("attempt", attempt).Dur("duration", time.Since(start)).Logger() - - if attempt > 0 { - l.Info().Msg("interface 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++ @@ -381,24 +399,29 @@ func (nm *NetlinkManager) ReconcileLink(link *Link, expected []*types.IPAddress) } ipCidr := addr.Address.IP.String() + "/" + addr.Address.Mask.String() + ipNet := &net.IPNet{ + IP: addr.Address.IP, + Mask: addr.Address.Mask, + } + + l := nm.logger.With().Str("address", ipNet.String()).Logger() if ok := existingAddrs[ipCidr]; !ok { - ipNet := &net.IPNet{ - IP: addr.Address.IP, - Mask: addr.Address.Mask, - } + l.Trace().Msg("adding address") if err := nm.AddrAdd(link, &netlink.Addr{IPNet: ipNet}); err != nil { return fmt.Errorf("failed to add address %s: %w", ipCidr, err) } - nm.logger.Info().Str("address", ipCidr).Msg("added address") + l.Info().Msg("address added") } if addr.Gateway != nil { - nm.logger.Trace().Str("address", ipCidr).Str("gateway", addr.Gateway.String()).Msg("adding default route for address") + gl := l.With().Str("gateway", addr.Gateway.String()).Logger() + gl.Trace().Msg("adding default route") if err := nm.AddDefaultRoute(link, addr.Gateway, family); err != nil { return fmt.Errorf("failed to add default route for address %s: %w", ipCidr, err) } + gl.Info().Msg("default route added") } } diff --git a/pkg/nmlite/link/netlink.go b/pkg/nmlite/link/netlink.go index 0fc460b2..7db382e1 100644 --- a/pkg/nmlite/link/netlink.go +++ b/pkg/nmlite/link/netlink.go @@ -35,11 +35,14 @@ var ( // Link is a wrapper around netlink.Link type Link struct { netlink.Link + mu sync.Mutex } -// Refresh refreshes the link -func (l *Link) Refresh() error { - linkName := l.Link.Attrs().Name +// 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 @@ -51,13 +54,44 @@ func (l *Link) Refresh() error { 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 { - attrs := l.Attrs() - if attrs.Name == "" { + l.mu.Lock() + defer l.mu.Unlock() + + ifname := l.ifName() + if ifname == "" { return nil } - iface, err := net.InterfaceByName(attrs.Name) + iface, err := net.InterfaceByName(ifname) if err != nil { return nil } @@ -66,20 +100,22 @@ func (l *Link) Interface() *net.Interface { // HardwareAddr returns the hardware address of the link func (l *Link) HardwareAddr() net.HardwareAddr { - attrs := l.Attrs() + l.mu.Lock() + defer l.mu.Unlock() + + attrs := l.attrs() if attrs.HardwareAddr == nil { return nil } return attrs.HardwareAddr } -// Attrs returns the attributes of the link -func (l *Link) Attrs() *netlink.LinkAttrs { - return l.Link.Attrs() -} - +// AddrList returns the addresses of the link func (l *Link) AddrList(family int) ([]netlink.Addr, error) { - return netlink.AddrList(l, family) + l.mu.Lock() + defer l.mu.Unlock() + + return netlink.AddrList(l.Link, family) } func (l *Link) IsSame(other *Link) bool { From 8cc7ead032f8b3f7fa9152c44d5b45d08fcfc0c6 Mon Sep 17 00:00:00 2001 From: Siyuan Date: Wed, 8 Oct 2025 12:54:48 +0000 Subject: [PATCH 22/85] do not sync time multiple times --- internal/timesync/timesync.go | 39 +++++++++++++++++++++++++++-------- pkg/nmlite/link/netlink.go | 1 + 2 files changed, 31 insertions(+), 9 deletions(-) diff --git a/internal/timesync/timesync.go b/internal/timesync/timesync.go index f9f4d4f4..97cee97d 100644 --- a/internal/timesync/timesync.go +++ b/internal/timesync/timesync.go @@ -38,6 +38,7 @@ type TimeSync struct { rtcLock *sync.Mutex syncSuccess bool + timer *time.Timer preCheckFunc PreCheckFunc preCheckIPv4 PreCheckFunc @@ -78,6 +79,7 @@ func NewTimeSync(opts *TimeSyncOptions) *TimeSync { preCheckIPv4: opts.PreCheckIPv4, preCheckIPv6: opts.PreCheckIPv6, networkConfig: opts.NetworkConfig, + timer: time.NewTimer(timeSyncWaitNetUpInt), } if t.rtcDevicePath != "" { @@ -130,45 +132,51 @@ func (t *TimeSync) getSyncMode() SyncMode { 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() @@ -256,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/pkg/nmlite/link/netlink.go b/pkg/nmlite/link/netlink.go index 7db382e1..93aa9e1d 100644 --- a/pkg/nmlite/link/netlink.go +++ b/pkg/nmlite/link/netlink.go @@ -118,6 +118,7 @@ func (l *Link) AddrList(family int) ([]netlink.Addr, error) { return netlink.AddrList(l.Link, family) } +// 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 From 05f2e5babef35d3fa175b434fdb8a078dd377242 Mon Sep 17 00:00:00 2001 From: Siyuan Date: Wed, 8 Oct 2025 13:18:17 +0000 Subject: [PATCH 23/85] fix online state detection --- network.go | 36 ++++++++++++++++++++---------------- pkg/nmlite/interface.go | 17 ++++++++++------- pkg/nmlite/manager.go | 6 +++--- 3 files changed, 33 insertions(+), 26 deletions(-) diff --git a/network.go b/network.go index 70936a89..5e3496bb 100644 --- a/network.go +++ b/network.go @@ -47,31 +47,35 @@ func restartMdns() { }, true) } -func networkStateChanged(iface string, state *types.InterfaceState) { +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 networkManager != nil { - timeSync.SetDhcpNtpAddresses(networkManager.NTPServerStrings()) - } - - 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 { restartMdns() } - - // if the network is now online, trigger an NTP sync if still needed - if state.Up && 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") - } - } } func validateNetworkConfig() { diff --git a/pkg/nmlite/interface.go b/pkg/nmlite/interface.go index 42ede504..eea1cd59 100644 --- a/pkg/nmlite/interface.go +++ b/pkg/nmlite/interface.go @@ -36,7 +36,7 @@ type InterfaceManager struct { hostname *HostnameManager // Callbacks - onStateChange func(state *types.InterfaceState) + onStateChange func(state types.InterfaceState) onConfigChange func(config *types.NetworkConfig) onDHCPLeaseChange func(lease *types.DHCPLease) @@ -321,7 +321,7 @@ func (im *InterfaceManager) RenewDHCPLease() error { } // SetOnStateChange sets the callback for state changes -func (im *InterfaceManager) SetOnStateChange(callback func(state *types.InterfaceState)) { +func (im *InterfaceManager) SetOnStateChange(callback func(state types.InterfaceState)) { im.onStateChange = callback } @@ -693,13 +693,17 @@ func (im *InterfaceManager) updateInterfaceState() error { attrs := nl.Attrs() isUp := attrs.OperState == netlink.OperUp + // check if the interface has unicast addresses hasAddrs := false addrs, err := nl.AddrList(link.AfUnspec) if err != nil { return fmt.Errorf("failed to get addresses: %w", err) } - if len(addrs) > 0 { - hasAddrs = true + for _, addr := range addrs { + if addr.IP.IsGlobalUnicast() { + hasAddrs = true + break + } } // Check if state changed @@ -731,9 +735,8 @@ func (im *InterfaceManager) updateInterfaceState() error { // Notify callback if state changed if stateChanged && im.onStateChange != nil { - state := *im.state - im.logger.Debug().Interface("state", state).Msg("notifying state change") - im.onStateChange(&state) + im.logger.Debug().Interface("state", im.state).Msg("notifying state change") + im.onStateChange(*im.state) } return nil diff --git a/pkg/nmlite/manager.go b/pkg/nmlite/manager.go index b7ef6a64..dc04190f 100644 --- a/pkg/nmlite/manager.go +++ b/pkg/nmlite/manager.go @@ -24,7 +24,7 @@ type NetworkManager struct { cancel context.CancelFunc // Callback functions for state changes - onInterfaceStateChange func(iface string, state *types.InterfaceState) + onInterfaceStateChange func(iface string, state types.InterfaceState) onConfigChange func(iface string, config *types.NetworkConfig) onDHCPLeaseChange func(iface string, lease *types.DHCPLease) } @@ -63,7 +63,7 @@ func (nm *NetworkManager) AddInterface(iface string, config *types.NetworkConfig } // Set up callbacks - im.SetOnStateChange(func(state *types.InterfaceState) { + im.SetOnStateChange(func(state types.InterfaceState) { if nm.onInterfaceStateChange != nil { nm.onInterfaceStateChange(iface, state) } @@ -179,7 +179,7 @@ func (nm *NetworkManager) RenewDHCPLease(iface string) error { } // SetOnInterfaceStateChange sets the callback for interface state changes -func (nm *NetworkManager) SetOnInterfaceStateChange(callback func(iface string, state *types.InterfaceState)) { +func (nm *NetworkManager) SetOnInterfaceStateChange(callback func(iface string, state types.InterfaceState)) { nm.onInterfaceStateChange = callback } From 6743db6e3dd2a6674425f1665645711807fa94f3 Mon Sep 17 00:00:00 2001 From: Siyuan Date: Wed, 8 Oct 2025 13:39:12 +0000 Subject: [PATCH 24/85] fix online state detection --- pkg/nmlite/interface.go | 27 +++++++++++---------------- pkg/nmlite/link/netlink.go | 15 +++++++++++++++ 2 files changed, 26 insertions(+), 16 deletions(-) diff --git a/pkg/nmlite/interface.go b/pkg/nmlite/interface.go index eea1cd59..b935fc9a 100644 --- a/pkg/nmlite/interface.go +++ b/pkg/nmlite/interface.go @@ -690,24 +690,15 @@ func (im *InterfaceManager) updateInterfaceState() error { return fmt.Errorf("failed to get interface: %w", err) } + // Check if state changed + stateChanged := false + attrs := nl.Attrs() isUp := attrs.OperState == netlink.OperUp // check if the interface has unicast addresses - hasAddrs := false - addrs, err := nl.AddrList(link.AfUnspec) - if err != nil { - return fmt.Errorf("failed to get addresses: %w", err) - } - for _, addr := range addrs { - if addr.IP.IsGlobalUnicast() { - hasAddrs = true - break - } - } + isOnline := isUp && nl.HasGlobalUnicastAddress() - // Check if state changed - stateChanged := false // We should release the lock before calling the callbacks // to avoid deadlocks im.stateMu.Lock() @@ -715,8 +706,9 @@ func (im *InterfaceManager) updateInterfaceState() error { im.state.Up = isUp stateChanged = true } - if im.state.Online != hasAddrs { - im.state.Online = hasAddrs + + if im.state.Online != isOnline { + im.state.Online = isOnline stateChanged = true } @@ -762,7 +754,9 @@ func (im *InterfaceManager) updateIPAddresses(nl *link.Link) error { ) for _, addr := range addrs { - im.logger.Debug().Str("address", addr.IP.String()).Msg("checking address") + im.logger.Debug(). + IPAddr("address", addr.IP). + Msg("checking address") if addr.IP.To4() != nil { // IPv4 address ipv4Addresses = append(ipv4Addresses, addr.IPNet.String()) @@ -811,6 +805,7 @@ func (im *InterfaceManager) updateStateFromDHCPLease(lease *types.DHCPLease) { } } +// ReconcileLinkAddrs reconciles the link addresses func (im *InterfaceManager) ReconcileLinkAddrs(addrs []*types.IPAddress) error { nl := getNetlinkManager() link, err := im.link() diff --git a/pkg/nmlite/link/netlink.go b/pkg/nmlite/link/netlink.go index 93aa9e1d..d726e4b9 100644 --- a/pkg/nmlite/link/netlink.go +++ b/pkg/nmlite/link/netlink.go @@ -118,6 +118,21 @@ func (l *Link) AddrList(family int) ([]netlink.Addr, error) { 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 { From ad0b86c8a6a4caf3113a38f7497e2800d7244c81 Mon Sep 17 00:00:00 2001 From: Siyuan Date: Wed, 8 Oct 2025 15:36:02 +0000 Subject: [PATCH 25/85] fix state change detection --- internal/network/types/dhcp.go | 77 +++++++++ internal/network/types/rpc.go | 33 ++++ internal/network/types/type.go | 74 +-------- network.go | 8 +- pkg/nmlite/interface.go | 110 ------------- pkg/nmlite/interface_state.go | 152 ++++++++++++++++++ pkg/nmlite/utils.go | 70 ++++++++ .../routes/devices.$id.settings.network.tsx | 5 +- ui/src/routes/devices.$id.tsx | 12 +- 9 files changed, 349 insertions(+), 192 deletions(-) create mode 100644 internal/network/types/rpc.go create mode 100644 pkg/nmlite/interface_state.go create mode 100644 pkg/nmlite/utils.go diff --git a/internal/network/types/dhcp.go b/internal/network/types/dhcp.go index 5607849b..ff34e2f2 100644 --- a/internal/network/types/dhcp.go +++ b/internal/network/types/dhcp.go @@ -1,5 +1,10 @@ package types +import ( + "net" + "time" +) + // DHCPClient is the interface for a DHCP client. type DHCPClient interface { Domain() string @@ -13,3 +18,75 @@ type DHCPClient interface { 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 + Broadcast net.IP `env:"broadcast" json:"broadcast"` // The broadcast address for this network + TTL int `env:"ipttl" json:"ttl,omitempty"` // The TTL to use for this network + MTU int `env:"mtu" json:"mtu,omitempty"` // The MTU to use for this network + HostName string `env:"hostname" json:"hostname,omitempty"` // The assigned hostname + Domain string `env:"domain" json:"domain,omitempty"` // The domain name of the network + 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 + Timezone string `env:"timezone" json:"timezone,omitempty"` // Offset in seconds from UTC + Routers []net.IP `env:"router" json:"routers,omitempty"` // A list of routers + DNS []net.IP `env:"dns" json:"dns_servers,omitempty"` // A list of DNS servers + NTPServers []net.IP `env:"ntpsrv" json:"ntp_servers,omitempty"` // A list of NTP servers + LPRServers []net.IP `env:"lprsvr" json:"lpr_servers,omitempty"` // A list of LPR servers + TimeServers []net.IP `env:"timesvr" json:"_time_servers,omitempty"` // A list of time servers (obsolete) + IEN116NameServers []net.IP `env:"namesvr" json:"_name_servers,omitempty"` // A list of IEN 116 name servers (obsolete) + LogServers []net.IP `env:"logsvr" json:"_log_servers,omitempty"` // A list of MIT-LCS UDP log servers (obsolete) + CookieServers []net.IP `env:"cookiesvr" json:"_cookie_servers,omitempty"` // A list of RFC 865 cookie servers (obsolete) + WINSServers []net.IP `env:"wins" json:"_wins_servers,omitempty"` // A list of WINS servers + SwapServer net.IP `env:"swapsvr" json:"_swap_server,omitempty"` // The IP address of the client's swap server + BootSize int `env:"bootsize" json:"bootsize,omitempty"` // The length in 512 octect blocks of the bootfile + RootPath string `env:"rootpath" json:"root_path,omitempty"` // The path name of the client's root disk + LeaseTime time.Duration `env:"lease" json:"lease,omitempty"` // The lease time, in seconds + 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 + + InterfaceName string `json:"interface_name,omitempty"` // The name of the interface + DHCPClient string `json:"dhcp_client,omitempty"` // The DHCP client that obtained the lease +} + +// IsIPv6 returns true if the DHCP lease is for an IPv6 address +func (d *DHCPLease) IsIPv6() bool { + return d.IPAddress.To4() == nil +} + +// 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]) +} + +// 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(), + } +} 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/types/type.go b/internal/network/types/type.go index 83f64521..e664be6f 100644 --- a/internal/network/types/type.go +++ b/internal/network/types/type.go @@ -25,6 +25,8 @@ type IPv6Address struct { Prefix net.IPNet `json:"prefix"` ValidLifetime *time.Time `json:"valid_lifetime"` PreferredLifetime *time.Time `json:"preferred_lifetime"` + valid_lft int `json:"valid_lft"` + prefered_lft int `json:"prefered_lft"` Scope int `json:"scope"` } @@ -107,49 +109,6 @@ func (c *NetworkConfig) GetTransportProxyFunc() func(*http.Request) (*url.URL, e } } -// 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 - Broadcast net.IP `env:"broadcast" json:"broadcast"` // The broadcast address for this network - TTL int `env:"ipttl" json:"ttl,omitempty"` // The TTL to use for this network - MTU int `env:"mtu" json:"mtu,omitempty"` // The MTU to use for this network - HostName string `env:"hostname" json:"hostname,omitempty"` // The assigned hostname - Domain string `env:"domain" json:"domain,omitempty"` // The domain name of the network - 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 - Timezone string `env:"timezone" json:"timezone,omitempty"` // Offset in seconds from UTC - Routers []net.IP `env:"router" json:"routers,omitempty"` // A list of routers - DNS []net.IP `env:"dns" json:"dns_servers,omitempty"` // A list of DNS servers - NTPServers []net.IP `env:"ntpsrv" json:"ntp_servers,omitempty"` // A list of NTP servers - LPRServers []net.IP `env:"lprsvr" json:"lpr_servers,omitempty"` // A list of LPR servers - TimeServers []net.IP `env:"timesvr" json:"_time_servers,omitempty"` // A list of time servers (obsolete) - IEN116NameServers []net.IP `env:"namesvr" json:"_name_servers,omitempty"` // A list of IEN 116 name servers (obsolete) - LogServers []net.IP `env:"logsvr" json:"_log_servers,omitempty"` // A list of MIT-LCS UDP log servers (obsolete) - CookieServers []net.IP `env:"cookiesvr" json:"_cookie_servers,omitempty"` // A list of RFC 865 cookie servers (obsolete) - WINSServers []net.IP `env:"wins" json:"_wins_servers,omitempty"` // A list of WINS servers - SwapServer net.IP `env:"swapsvr" json:"_swap_server,omitempty"` // The IP address of the client's swap server - BootSize int `env:"bootsize" json:"bootsize,omitempty"` // The length in 512 octect blocks of the bootfile - RootPath string `env:"rootpath" json:"root_path,omitempty"` // The path name of the client's root disk - LeaseTime time.Duration `env:"lease" json:"lease,omitempty"` // The lease time, in seconds - 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 - - InterfaceName string `json:"interface_name,omitempty"` // The name of the interface - DHCPClient string `json:"dhcp_client,omitempty"` // The DHCP client that obtained the lease -} - // InterfaceState represents the current state of a network interface type InterfaceState struct { InterfaceName string `json:"interface_name"` @@ -175,32 +134,3 @@ type NetworkConfigInterface interface { IPv4Addresses() []IPAddress IPv6Addresses() []IPAddress } - -// IsIPv6 returns true if the DHCP lease is for an IPv6 address -func (d *DHCPLease) IsIPv6() bool { - return d.IPAddress.To4() == nil -} - -// 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]) -} - -// 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(), - } -} diff --git a/network.go b/network.go index 5e3496bb..ce8f60f8 100644 --- a/network.go +++ b/network.go @@ -67,6 +67,10 @@ func networkStateChanged(_ string, state types.InterfaceState) { // do not block the main thread go waitCtrlAndRequestDisplayUpdate(true, "network_state_changed") + if currentSession != nil { + writeJSONRPCEvent("networkState", state.ToRpcInterfaceState(), currentSession) + } + if state.Online { networkLogger.Info().Msg("network state changed to online, triggering time sync") triggerTimeSyncOnNetworkStateChange() @@ -108,9 +112,9 @@ func initNetwork() error { return nil } -func rpcGetNetworkState() *types.InterfaceState { +func rpcGetNetworkState() *types.RpcInterfaceState { state, _ := networkManager.GetInterfaceState(NetIfName) - return state + return state.ToRpcInterfaceState() } func rpcGetNetworkSettings() *RpcNetworkSettings { diff --git a/pkg/nmlite/interface.go b/pkg/nmlite/interface.go index b935fc9a..93d7f840 100644 --- a/pkg/nmlite/interface.go +++ b/pkg/nmlite/interface.go @@ -683,116 +683,6 @@ func (im *InterfaceManager) monitorInterfaceState() { } -// 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) - } - - // Check if state changed - stateChanged := false - - attrs := nl.Attrs() - isUp := attrs.OperState == netlink.OperUp - - // check if the interface has unicast addresses - isOnline := isUp && nl.HasGlobalUnicastAddress() - - // We should release the lock before calling the callbacks - // to avoid deadlocks - im.stateMu.Lock() - if im.state.Up != isUp { - im.state.Up = isUp - stateChanged = true - } - - if im.state.Online != isOnline { - im.state.Online = isOnline - stateChanged = true - } - - if im.state.MACAddress != attrs.HardwareAddr.String() { - im.state.MACAddress = attrs.HardwareAddr.String() - stateChanged = true - } - - // Update IP addresses - if err := im.updateIPAddresses(nl); err != nil { - im.logger.Error().Err(err).Msg("failed to update IP addresses") - } - - 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) updateIPAddresses(nl *link.Link) error { - if err := nl.Refresh(); err != nil { - return fmt.Errorf("failed to refresh link: %w", err) - } - - addrs, err := nl.AddrList(link.AfUnspec) - if err != nil { - return fmt.Errorf("failed to get addresses: %w", err) - } - - var ( - ipv4Addresses []string - ipv6Addresses []types.IPv6Address - ipv4Addr, ipv6Addr string - ipv6LinkLocal string - ipv4Ready, ipv6Ready = false, false - ) - - for _, addr := range addrs { - im.logger.Debug(). - IPAddr("address", addr.IP). - Msg("checking address") - if addr.IP.To4() != nil { - // IPv4 address - ipv4Addresses = append(ipv4Addresses, addr.IPNet.String()) - if ipv4Addr == "" { - ipv4Addr = addr.IP.String() - ipv4Ready = true - } - } else if addr.IP.To16() != nil { - // IPv6 address - if addr.IP.IsLinkLocalUnicast() { - ipv6LinkLocal = addr.IP.String() - } else if addr.IP.IsGlobalUnicast() { - ipv6Addresses = append(ipv6Addresses, types.IPv6Address{ - Address: addr.IP, - Prefix: *addr.IPNet, - Scope: addr.Scope, - }) - if ipv6Addr == "" { - ipv6Addr = addr.IP.String() - ipv6Ready = true - } - } - } - } - - im.state.IPv4Addresses = ipv4Addresses - im.state.IPv6Addresses = ipv6Addresses - im.state.IPv6LinkLocal = ipv6LinkLocal - im.state.IPv4Address = ipv4Addr - im.state.IPv6Address = ipv6Addr - im.state.IPv4Ready = ipv4Ready - im.state.IPv6Ready = ipv6Ready - - return nil -} - // updateStateFromDHCPLease updates the state from a DHCP lease func (im *InterfaceManager) updateStateFromDHCPLease(lease *types.DHCPLease) { im.stateMu.Lock() diff --git a/pkg/nmlite/interface_state.go b/pkg/nmlite/interface_state.go new file mode 100644 index 00000000..96fbdb61 --- /dev/null +++ b/pkg/nmlite/interface_state.go @@ -0,0 +1,152 @@ +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) { + 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 + ipv4Ready, ipv6Ready = false, false + stateChanged = false + ) + + for _, addr := range addrs { + im.logger.Debug(). + IPAddr("address", addr.IP). + Msg("checking address") + 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.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/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/ui/src/routes/devices.$id.settings.network.tsx b/ui/src/routes/devices.$id.settings.network.tsx index 073234f4..621ff59c 100644 --- a/ui/src/routes/devices.$id.settings.network.tsx +++ b/ui/src/routes/devices.$id.settings.network.tsx @@ -8,7 +8,7 @@ import validator from "validator"; import { ConfirmDialog } from "@/components/ConfirmDialog"; import { SelectMenuBasic } from "@/components/SelectMenuBasic"; import { SettingsPageHeader } from "@/components/SettingsPageheader"; -import { NetworkSettings, NetworkState, useRTCStore } from "@/hooks/stores"; +import { NetworkSettings, NetworkState, useNetworkStateStore, useRTCStore } from "@/hooks/stores"; import notifications from "@/notifications"; import { getNetworkSettings, getNetworkState } from "@/utils/jsonrpc"; import { Button } from "@components/Button"; @@ -75,7 +75,8 @@ export function LifeTimeLabel({ lifetime }: { lifetime: string }) { export default function SettingsNetworkRoute() { const { send } = useJsonRpc(); - const [networkState, setNetworkState] = useState(null); + const networkState = useNetworkStateStore(state => state); + const setNetworkState = useNetworkStateStore(state => state.setNetworkState); // Some input needs direct state management. Mostly options that open more details const [customDomain, setCustomDomain] = useState(""); diff --git a/ui/src/routes/devices.$id.tsx b/ui/src/routes/devices.$id.tsx index a1ace077..05bf8e0a 100644 --- a/ui/src/routes/devices.$id.tsx +++ b/ui/src/routes/devices.$id.tsx @@ -123,9 +123,9 @@ export default function KvmIdRoute() { const params = useParams() as { id: string }; const { sidebarView, setSidebarView, disableVideoFocusTrap } = useUiStore(); - const [ queryParams, setQueryParams ] = useSearchParams(); + const [queryParams, setQueryParams] = useSearchParams(); - const { + const { peerConnection, setPeerConnection, peerConnectionState, setPeerConnectionState, setMediaStream, @@ -597,10 +597,10 @@ export default function KvmIdRoute() { }); }, 10000); - const { setNetworkState} = useNetworkStateStore(); + const { setNetworkState } = useNetworkStateStore(); const { setHdmiState } = useVideoStore(); - const { - keyboardLedState, setKeyboardLedState, + const { + keyboardLedState, setKeyboardLedState, keysDownState, setKeysDownState, setUsbState, } = useHidStore(); const setHidRpcDisabled = useRTCStore(state => state.setHidRpcDisabled); @@ -756,7 +756,7 @@ export default function KvmIdRoute() { if (location.pathname !== "/other-session") navigateTo("/"); }, [navigateTo, location.pathname]); - const { appVersion, getLocalVersion} = useVersion(); + const { appVersion, getLocalVersion } = useVersion(); useEffect(() => { if (appVersion) return; From 97844a8cafd5f6d3c2b81c2c88c38ce820c15354 Mon Sep 17 00:00:00 2001 From: Siyuan Date: Wed, 8 Oct 2025 17:46:19 +0000 Subject: [PATCH 26/85] use reconcile instead of updating addresses and routes individually --- internal/network/types/type.go | 43 +++- pkg/nmlite/interface.go | 29 ++- pkg/nmlite/interface_state.go | 16 +- pkg/nmlite/jetdhcpc/client.go | 5 +- pkg/nmlite/link/manager.go | 199 +++++++++++++----- pkg/nmlite/link/types.go | 10 - pkg/nmlite/static.go | 284 ++++++-------------------- ui/src/components/Ipv6NetworkCard.tsx | 8 + ui/src/hooks/stores.ts | 1 + 9 files changed, 301 insertions(+), 294 deletions(-) diff --git a/internal/network/types/type.go b/internal/network/types/type.go index e664be6f..2bee48ac 100644 --- a/internal/network/types/type.go +++ b/internal/network/types/type.go @@ -4,9 +4,11 @@ import ( "net" "net/http" "net/url" + "slices" "time" "github.com/guregu/null/v6" + "github.com/vishvananda/netlink" ) // IPAddress represents a network interface address @@ -19,14 +21,50 @@ type IPAddress struct { Permanent bool } +func (a *IPAddress) String() string { + return a.Address.String() +} + +func (a *IPAddress) Compare(n netlink.Addr) bool { + if !a.Address.IP.Equal(n.IP) { + return false + } + if slices.Compare(a.Address.Mask, n.IPNet.Mask) != 0 { + return false + } + return true +} + +func (a *IPAddress) NetlinkAddr() netlink.Addr { + return netlink.Addr{ + IPNet: &a.Address, + } +} + +func (a *IPAddress) DefaultRoute(linkIndex int) netlink.Route { + return netlink.Route{ + Dst: nil, + Gw: a.Gateway, + LinkIndex: linkIndex, + } +} + +// ParsedIPConfig represents the parsed IP configuration +type ParsedIPConfig struct { + Addresses []IPAddress + Nameservers []net.IP + SearchList []string + Domain string + MTU int + Interface string +} + // IPv6Address represents an IPv6 address with lifetime information type IPv6Address struct { Address net.IP `json:"address"` Prefix net.IPNet `json:"prefix"` ValidLifetime *time.Time `json:"valid_lifetime"` PreferredLifetime *time.Time `json:"preferred_lifetime"` - valid_lft int `json:"valid_lft"` - prefered_lft int `json:"prefered_lft"` Scope int `json:"scope"` } @@ -120,6 +158,7 @@ type InterfaceState struct { 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"` diff --git a/pkg/nmlite/interface.go b/pkg/nmlite/interface.go index 93d7f840..28dc11d6 100644 --- a/pkg/nmlite/interface.go +++ b/pkg/nmlite/interface.go @@ -403,13 +403,23 @@ func (im *InterfaceManager) applyIPv4Static() error { return fmt.Errorf("IPv4 static configuration is nil") } + im.logger.Info().Msg("stopping DHCP") + // Disable DHCP if im.dhcpClient != nil { im.dhcpClient.SetIPv4(false) } - // Apply static configuration - return im.staticConfig.ApplyIPv4Static(im.config.IPv4Static) + 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 @@ -440,13 +450,20 @@ func (im *InterfaceManager) applyIPv6Static() error { 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 - return im.staticConfig.ApplyIPv6Static(im.config.IPv6Static) + 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 @@ -696,7 +713,7 @@ func (im *InterfaceManager) updateStateFromDHCPLease(lease *types.DHCPLease) { } // ReconcileLinkAddrs reconciles the link addresses -func (im *InterfaceManager) ReconcileLinkAddrs(addrs []*types.IPAddress) error { +func (im *InterfaceManager) ReconcileLinkAddrs(addrs []types.IPAddress, family int) error { nl := getNetlinkManager() link, err := im.link() if err != nil { @@ -705,7 +722,7 @@ func (im *InterfaceManager) ReconcileLinkAddrs(addrs []*types.IPAddress) error { if link == nil { return fmt.Errorf("failed to get interface: %w", err) } - return nl.ReconcileLink(link, addrs) + return nl.ReconcileLink(link, addrs, family) } // applyDHCPLease applies DHCP lease configuration using ReconcileLinkAddrs @@ -714,7 +731,7 @@ func (im *InterfaceManager) applyDHCPLease(lease *types.DHCPLease) error { ipv4Config := im.convertDHCPLeaseToIPv4Config(lease) // Apply the configuration using ReconcileLinkAddrs - return im.ReconcileLinkAddrs([]*types.IPAddress{ipv4Config}) + return im.ReconcileLinkAddrs([]types.IPAddress{*ipv4Config}, link.AfInet) } // convertDHCPLeaseToIPv4Config converts a DHCP lease to IPv4Config diff --git a/pkg/nmlite/interface_state.go b/pkg/nmlite/interface_state.go index 96fbdb61..f29c8f22 100644 --- a/pkg/nmlite/interface_state.go +++ b/pkg/nmlite/interface_state.go @@ -65,6 +65,8 @@ func (im *InterfaceManager) updateInterfaceState() error { // 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) @@ -75,14 +77,17 @@ func (im *InterfaceManager) updateInterfaceStateAddresses(nl *link.Link) (bool, 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 { - im.logger.Debug(). - IPAddr("address", addr.IP). - Msg("checking address") if addr.IP.To4() != nil { // IPv4 address ipv4Addresses = append(ipv4Addresses, addr.IPNet.String()) @@ -138,6 +143,11 @@ func (im *InterfaceManager) updateInterfaceStateAddresses(nl *link.Link) (bool, stateChanged = true } + if im.state.IPv6Gateway != ipv6Gateway { + im.state.IPv6Gateway = ipv6Gateway + stateChanged = true + } + if im.state.IPv4Ready != ipv4Ready { im.state.IPv4Ready = ipv4Ready stateChanged = true diff --git a/pkg/nmlite/jetdhcpc/client.go b/pkg/nmlite/jetdhcpc/client.go index 7bb68795..68706638 100644 --- a/pkg/nmlite/jetdhcpc/client.go +++ b/pkg/nmlite/jetdhcpc/client.go @@ -279,7 +279,7 @@ func (c *Client) handleLeaseChange(lease *Lease) { } // clear all current jobs with the same tags - c.scheduler.RemoveByTags(version) + // c.scheduler.RemoveByTags(version) // add scheduler job to renew the lease if lease.RenewalTime > 0 { @@ -357,6 +357,9 @@ func (c *Client) SetIPv4(ipv4 bool) { } func (c *Client) SetIPv6(ipv6 bool) { + c.cfgMu.Lock() + defer c.cfgMu.Unlock() + c.cfg.IPv6 = ipv6 } diff --git a/pkg/nmlite/link/manager.go b/pkg/nmlite/link/manager.go index 96813b4f..fec9b075 100644 --- a/pkg/nmlite/link/manager.go +++ b/pkg/nmlite/link/manager.go @@ -302,26 +302,28 @@ func (nm *NetlinkManager) RouteReplace(route *netlink.Route) error { 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 := netlink.RouteList(nil, family) + routes, err := nm.ListDefaultRoutes(family) if err != nil { return false } - - for _, route := range routes { - if route.Dst == nil { - return true - } - if family == AfInet && route.Dst.IP.Equal(net.IPv4zero) && route.Dst.Mask.String() == "0.0.0.0/0" { - return true - } - if family == AfInet6 && route.Dst.IP.Equal(net.IPv6zero) && route.Dst.Mask.String() == "::/0" { - return true - } - } - - return false + return len(routes) > 0 } // AddDefaultRoute adds a default route @@ -370,59 +372,152 @@ func (nm *NetlinkManager) RemoveDefaultRoute(family int) error { return nil } -// ReconcileLink reconciles the addresses and routes of a link -func (nm *NetlinkManager) ReconcileLink(link *Link, expected []*types.IPAddress) error { - expectedAddrs := make(map[string]bool) - existingAddrs := make(map[string]bool) +func (nm *NetlinkManager) reconcileDefaultRoute(link *Link, expected map[string]net.IP, family int) error { + linkIndex := link.Attrs().Index - for _, addr := range expected { - ipCidr := addr.Address.IP.String() + "/" + addr.Address.Mask.String() - expectedAddrs[ipCidr] = true + 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) } - addrs, err := nm.AddrList(link, AfUnspec) + // 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 { - ipCidr := addr.IP.String() + "/" + addr.IPNet.Mask.String() - existingAddrs[ipCidr] = true + // 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()) } - for _, addr := range expected { - family := AfUnspec - if addr.Address.IP.To4() != nil { - family = AfInet - } else if addr.Address.IP.To16() != nil { - family = AfInet6 + // 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) + } - ipCidr := addr.Address.IP.String() + "/" + addr.Address.Mask.String() - ipNet := &net.IPNet{ - IP: addr.Address.IP, - Mask: addr.Address.Mask, + 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") } + } - l := nm.logger.With().Str("address", ipNet.String()).Logger() - if ok := existingAddrs[ipCidr]; !ok { - l.Trace().Msg("adding address") - - if err := nm.AddrAdd(link, &netlink.Addr{IPNet: ipNet}); err != nil { - return fmt.Errorf("failed to add address %s: %w", ipCidr, err) - } - - l.Info().Msg("address added") + 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") } + } - if addr.Gateway != nil { - gl := l.With().Str("gateway", addr.Gateway.String()).Logger() - gl.Trace().Msg("adding default route") - if err := nm.AddDefaultRoute(link, addr.Gateway, family); err != nil { - return fmt.Errorf("failed to add default route for address %s: %w", ipCidr, err) - } - gl.Info().Msg("default route added") - } + 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/types.go b/pkg/nmlite/link/types.go index abcf69e3..06f941a0 100644 --- a/pkg/nmlite/link/types.go +++ b/pkg/nmlite/link/types.go @@ -11,13 +11,3 @@ type IPv4Address struct { Secondary bool Permanent bool } - -// IPv4Config represents the configuration for an IPv4 interface -type IPv4Config struct { - Addresses []IPv4Address - Nameservers []net.IP - SearchList []string - Domain string - MTU int - Interface string -} diff --git a/pkg/nmlite/static.go b/pkg/nmlite/static.go index 292c8b59..e23424b1 100644 --- a/pkg/nmlite/static.go +++ b/pkg/nmlite/static.go @@ -7,7 +7,6 @@ import ( "github.com/jetkvm/kvm/internal/network/types" "github.com/jetkvm/kvm/pkg/nmlite/link" "github.com/rs/zerolog" - "github.com/vishvananda/netlink" ) // StaticConfigManager manages static network configuration @@ -32,81 +31,90 @@ func NewStaticConfigManager(ifaceName string, logger *zerolog.Logger) (*StaticCo }, nil } -// ApplyIPv4Static applies static IPv4 configuration -func (scm *StaticConfigManager) ApplyIPv4Static(config *types.IPv4StaticConfig) error { - scm.logger.Info().Msg("applying static IPv4 configuration") +// 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 and validate configuration - ipv4Config, err := scm.parseIPv4Config(config) + // Parse IP address and netmask + ipNet, err := link.ParseIPv4Netmask(config.Address.String, config.Netmask.String) if err != nil { - return fmt.Errorf("failed to parse IPv4 config: %w", err) + 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) } - // Get interface - netlinkMgr := getNetlinkManager() - link, err := netlinkMgr.GetLinkByName(scm.ifaceName) - if err != nil { - return fmt.Errorf("failed to get interface: %w", err) + // 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)) } - // Ensure interface is up - if err := netlinkMgr.EnsureInterfaceUp(link); err != nil { - return fmt.Errorf("failed to bring interface up: %w", err) + address := types.IPAddress{ + Family: link.AfInet, + Address: *ipNet, + Gateway: gateway, + Secondary: false, + Permanent: true, } - // Apply IP address - if err := scm.applyIPv4Address(link, ipv4Config); err != nil { - return fmt.Errorf("failed to apply IPv4 address: %w", err) - } - - // Apply default route - if err := scm.applyIPv4Route(link, ipv4Config); err != nil { - return fmt.Errorf("failed to apply IPv4 route: %w", err) - } - - scm.logger.Info().Msg("static IPv4 configuration applied successfully") - return nil + return &types.ParsedIPConfig{ + Addresses: []types.IPAddress{address}, + Nameservers: dns, + Interface: scm.ifaceName, + }, nil } -// ApplyIPv6Static applies static IPv6 configuration -func (scm *StaticConfigManager) ApplyIPv6Static(config *types.IPv6StaticConfig) error { - scm.logger.Info().Msg("applying static IPv6 configuration") +// 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 and validate configuration - ipv6Config, err := scm.parseIPv6Config(config) + // Parse IP address and prefix + ipNet, err := link.ParseIPv6Prefix(config.Prefix.String, 64) // Default to /64 if not specified if err != nil { - return fmt.Errorf("failed to parse IPv6 config: %w", err) + return nil, err } - // Get interface - netlinkMgr := getNetlinkManager() - link, err := netlinkMgr.GetLinkByName(scm.ifaceName) - if err != nil { - return fmt.Errorf("failed to get interface: %w", err) + // Parse gateway + gateway := net.ParseIP(config.Gateway.String) + if gateway == nil { + return nil, fmt.Errorf("invalid gateway: %s", config.Gateway.String) } - // Enable IPv6 - if err := scm.enableIPv6(); err != nil { - return fmt.Errorf("failed to enable IPv6: %w", err) + // 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) } - // Ensure interface is up - if err := netlinkMgr.EnsureInterfaceUp(link); err != nil { - return fmt.Errorf("failed to bring interface up: %w", err) + address := types.IPAddress{ + Family: link.AfInet6, + Address: *ipNet, + Gateway: gateway, + Secondary: false, + Permanent: true, } - // Apply IP address - if err := scm.applyIPv6Address(link, ipv6Config); err != nil { - return fmt.Errorf("failed to apply IPv6 address: %w", err) - } - - // Apply default route - if err := scm.applyIPv6Route(link, ipv6Config); err != nil { - return fmt.Errorf("failed to apply IPv6 route: %w", err) - } - - scm.logger.Info().Msg("static IPv6 configuration applied successfully") - return nil + return &types.ParsedIPConfig{ + Addresses: []types.IPAddress{address}, + Nameservers: dns, + Interface: scm.ifaceName, + }, nil } // DisableIPv4 disables IPv4 on the interface @@ -169,156 +177,6 @@ func (scm *StaticConfigManager) EnableIPv6LinkLocal() error { return netlinkMgr.EnsureInterfaceUp(link) } -// parseIPv4Config parses and validates IPv4 static configuration -func (scm *StaticConfigManager) parseIPv4Config(config *types.IPv4StaticConfig) (*parsedIPv4Config, 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)) - } - - return &parsedIPv4Config{ - network: *ipNet, - gateway: gateway, - dns: dns, - }, nil -} - -// parseIPv6Config parses and validates IPv6 static configuration -func (scm *StaticConfigManager) parseIPv6Config(config *types.IPv6StaticConfig) (*parsedIPv6Config, 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) - } - - return &parsedIPv6Config{ - prefix: *ipNet, - gateway: gateway, - dns: dns, - }, nil -} - -// applyIPv4Address applies IPv4 address to interface -func (scm *StaticConfigManager) applyIPv4Address(iface *link.Link, config *parsedIPv4Config) error { - netlinkMgr := getNetlinkManager() - - // Remove existing IPv4 addresses - if err := netlinkMgr.RemoveAllAddresses(iface, link.AfInet); err != nil { - return fmt.Errorf("failed to remove existing IPv4 addresses: %w", err) - } - - // Add new address - addr := &netlink.Addr{ - IPNet: &config.network, - } - if err := netlinkMgr.AddrAdd(iface, addr); err != nil { - return fmt.Errorf("failed to add IPv4 address: %w", err) - } - - scm.logger.Info().Str("address", config.network.String()).Msg("IPv4 address applied") - return nil -} - -// applyIPv6Address applies IPv6 address to interface -func (scm *StaticConfigManager) applyIPv6Address(iface *link.Link, config *parsedIPv6Config) error { - netlinkMgr := getNetlinkManager() - - // Remove existing global IPv6 addresses - if err := netlinkMgr.RemoveNonLinkLocalIPv6Addresses(iface); err != nil { - return fmt.Errorf("failed to remove existing IPv6 addresses: %w", err) - } - - // Add new address - addr := &netlink.Addr{ - IPNet: &config.prefix, - } - if err := netlinkMgr.AddrAdd(iface, addr); err != nil { - return fmt.Errorf("failed to add IPv6 address: %w", err) - } - - scm.logger.Info().Str("address", config.prefix.String()).Msg("IPv6 address applied") - return nil -} - -// applyIPv4Route applies IPv4 default route -func (scm *StaticConfigManager) applyIPv4Route(iface *link.Link, config *parsedIPv4Config) error { - netlinkMgr := getNetlinkManager() - - // Check if default route already exists - if netlinkMgr.HasDefaultRoute(link.AfInet) { - scm.logger.Info().Msg("IPv4 default route already exists") - return nil - } - - // Add default route - if err := netlinkMgr.AddDefaultRoute(iface, config.gateway, link.AfInet); err != nil { - return fmt.Errorf("failed to add IPv4 default route: %w", err) - } - - scm.logger.Info().Str("gateway", config.gateway.String()).Msg("IPv4 default route applied") - return nil -} - -// applyIPv6Route applies IPv6 default route -func (scm *StaticConfigManager) applyIPv6Route(iface *link.Link, config *parsedIPv6Config) error { - netlinkMgr := getNetlinkManager() - - // Check if default route already exists - if netlinkMgr.HasDefaultRoute(link.AfInet6) { - scm.logger.Info().Msg("IPv6 default route already exists") - return nil - } - - // Add default route - if err := netlinkMgr.AddDefaultRoute(iface, config.gateway, link.AfInet6); err != nil { - return fmt.Errorf("failed to add IPv6 default route: %w", err) - } - - scm.logger.Info().Str("gateway", config.gateway.String()).Msg("IPv6 default route applied") - return nil -} - // removeIPv4DefaultRoute removes IPv4 default route func (scm *StaticConfigManager) removeIPv4DefaultRoute() error { netlinkMgr := getNetlinkManager() @@ -330,17 +188,3 @@ func (scm *StaticConfigManager) enableIPv6() error { netlinkMgr := getNetlinkManager() return netlinkMgr.EnableIPv6(scm.ifaceName) } - -// parsedIPv4Config represents parsed IPv4 configuration -type parsedIPv4Config struct { - network net.IPNet - gateway net.IP - dns []net.IP -} - -// parsedIPv6Config represents parsed IPv6 configuration -type parsedIPv6Config struct { - prefix net.IPNet - gateway net.IP - dns []net.IP -} diff --git a/ui/src/components/Ipv6NetworkCard.tsx b/ui/src/components/Ipv6NetworkCard.tsx index ca6c3079..5ed27ca1 100644 --- a/ui/src/components/Ipv6NetworkCard.tsx +++ b/ui/src/components/Ipv6NetworkCard.tsx @@ -25,6 +25,14 @@ export default function Ipv6NetworkCard({ {networkState?.ipv6_link_local} +
+ + Gateway + + + {networkState?.ipv6_gateway} + +
diff --git a/ui/src/hooks/stores.ts b/ui/src/hooks/stores.ts index e9ef15d5..ca8ff46f 100644 --- a/ui/src/hooks/stores.ts +++ b/ui/src/hooks/stores.ts @@ -709,6 +709,7 @@ export interface NetworkState { ipv6?: string; ipv6_addresses?: IPv6Address[]; ipv6_link_local?: string; + ipv6_gateway?: string; dhcp_lease?: DhcpLease; setNetworkState: (state: NetworkState) => void; From 52ddc9ebe5bc54d94267a1c6ade8a2886d40caf6 Mon Sep 17 00:00:00 2001 From: Siyuan Date: Thu, 9 Oct 2025 21:03:12 +0000 Subject: [PATCH 27/85] fix: do not apply IPv6 DHCP lease if it's from udhcpc --- pkg/nmlite/interface.go | 14 ++ pkg/nmlite/jetdhcpc/client.go | 279 +++++++++++++++++++--------------- pkg/nmlite/jetdhcpc/dhcp4.go | 8 +- pkg/nmlite/jetdhcpc/dhcp6.go | 8 +- 4 files changed, 183 insertions(+), 126 deletions(-) diff --git a/pkg/nmlite/interface.go b/pkg/nmlite/interface.go index 28dc11d6..58aa48a0 100644 --- a/pkg/nmlite/interface.go +++ b/pkg/nmlite/interface.go @@ -727,6 +727,20 @@ func (im *InterfaceManager) ReconcileLinkAddrs(addrs []types.IPAddress, family i // 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) diff --git a/pkg/nmlite/jetdhcpc/client.go b/pkg/nmlite/jetdhcpc/client.go index 68706638..ac200503 100644 --- a/pkg/nmlite/jetdhcpc/client.go +++ b/pkg/nmlite/jetdhcpc/client.go @@ -3,19 +3,17 @@ package jetdhcpc import ( "context" "errors" - "fmt" "net" "slices" "time" "github.com/jetkvm/kvm/internal/sync" + "github.com/jetkvm/kvm/pkg/nmlite/link" - "github.com/go-co-op/gocron/v2" "github.com/insomniacslk/dhcp/dhcpv4" "github.com/insomniacslk/dhcp/dhcpv6" "github.com/jetkvm/kvm/internal/network/types" - "github.com/jetkvm/kvm/pkg/nmlite/link" "github.com/rs/zerolog" ) @@ -83,8 +81,10 @@ type Config struct { UpdateResolvConf func([]string) error } +// Client is a DHCP client. type Client struct { types.DHCPClient + ifaces []string cfg Config l *zerolog.Logger @@ -101,24 +101,28 @@ type Client struct { lease4Mu sync.Mutex lease6Mu sync.Mutex - scheduler gocron.Scheduler - stateDir string + 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) { - scheduler, err := gocron.NewScheduler() - if err != nil { - return nil, fmt.Errorf("failed to create scheduler: %w", err) - } + timer4 := time.NewTimer(defaultTimerDuration) + timer6 := time.NewTimer(defaultTimerDuration) cfg := *c if cfg.LinkUpTimeout == 0 { - cfg.LinkUpTimeout = 30 * time.Second + cfg.LinkUpTimeout = defaultLinkUpTimeout } if cfg.Timeout == 0 { - cfg.Timeout = 30 * time.Second + cfg.Timeout = defaultLinkUpTimeout } if cfg.Retries == 0 { @@ -126,12 +130,11 @@ func NewClient(ctx context.Context, ifaces []string, c *Config, l *zerolog.Logge } return &Client{ - ctx: ctx, - ifaces: ifaces, - cfg: cfg, - l: l, - scheduler: scheduler, - stateDir: "/run/jetkvm-dhcp", + ctx: ctx, + ifaces: ifaces, + cfg: cfg, + l: l, + stateDir: "/run/jetkvm-dhcp", currentLease4: nil, currentLease6: nil, @@ -141,9 +144,45 @@ func NewClient(ctx context.Context, ifaces []string, c *Config, l *zerolog.Logge 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) @@ -153,76 +192,77 @@ func (c *Client) ensureInterfaceUp(ifname string) (*link.Link, error) { return nlm.EnsureInterfaceUpWithTimeout(c.ctx, iface, c.cfg.LinkUpTimeout) } -func (c *Client) sendInitialRequests() chan any { - return c.sendRequests(c.cfg.IPv4, c.cfg.IPv6) -} +// func (c *Client) sendInitialRequests() chan any { +// return c.sendRequests(c.cfg.IPv4, c.cfg.IPv6) +// } -func (c *Client) sendRequestsFamily( - family int, - wg *sync.WaitGroup, - r *chan any, - l *zerolog.Logger, - iface *link.Link, -) { - wg.Add(1) - go func(iface *link.Link) { - defer wg.Done() - var ( - lease *Lease - err error - ) - switch family { - case link.AfInet: - lease, err = c.requestLease4(iface) - case link.AfInet6: - lease, err = c.requestLease6(iface) - } - if err != nil { - l.Error().Err(err).Msg("Could not get lease") - return - } - (*r) <- lease - }(iface) -} +// func (c *Client) sendRequestsFamily( +// family int, +// wg *sync.WaitGroup, +// r *chan any, +// l *zerolog.Logger, +// iface *link.Link, +// ) { +// wg.Add(1) +// go func(iface *link.Link) { +// defer wg.Done() +// var ( +// lease *Lease +// err error +// ) +// switch family { +// case link.AfInet: +// lease, err = c.requestLease4(iface) +// case link.AfInet6: +// lease, err = c.requestLease6(iface) +// } +// if err != nil { +// l.Error().Err(err).Msg("Could not get lease") +// return +// } +// (*r) <- lease +// }(iface) +// } -func (c *Client) sendRequests(ipv4, ipv6 bool) chan any { - c.mu.Lock() - defer c.mu.Unlock() +// func (c *Client) sendRequests(ipv4, ipv6 bool) chan any { +// c.mu.Lock() +// defer c.mu.Unlock() - // Yeah, this is a hack, until we can cancel all leases in progress. - r := make(chan any, 3*len(c.ifaces)) +// // Yeah, this is a hack, until we can cancel all leases in progress. +// r := make(chan any, 3*len(c.ifaces)) - var wg sync.WaitGroup - for _, iface := range c.ifaces { - wg.Add(1) - go func(ifname string) { - defer wg.Done() +// var wg sync.WaitGroup +// for _, iface := range c.ifaces { +// wg.Add(1) +// go func(ifname string) { +// defer wg.Done() - l := c.l.With().Str("interface", ifname).Logger() +// l := c.l.With().Str("interface", ifname).Logger() - iface, err := c.ensureInterfaceUp(ifname) - if err != nil { - l.Error().Err(err).Msg("Could not bring up interface") - return - } +// iface, err := c.ensureInterfaceUp(ifname) +// if err != nil { +// l.Error().Err(err).Msg("Could not bring up interface") +// return +// } - if ipv4 { - c.sendRequestsFamily(link.AfInet, &wg, &r, &l, iface) - } +// if ipv4 { +// c.sendRequestsFamily(link.AfInet, &wg, &r, &l, iface) +// } - if ipv6 { - c.sendRequestsFamily(link.AfInet6, &wg, &r, &l, iface) - } - }(iface) - } +// if ipv6 { +// c.sendRequestsFamily(link.AfInet6, &wg, &r, &l, iface) +// } +// }(iface) +// } - go func() { - wg.Wait() - close(r) - }() - return r -} +// go func() { +// wg.Wait() +// close(r) +// }() +// return r +// } +// Lease4 returns the current IPv4 lease func (c *Client) Lease4() *types.DHCPLease { c.lease4Mu.Lock() defer c.lease4Mu.Unlock() @@ -234,6 +274,7 @@ func (c *Client) Lease4() *types.DHCPLease { return c.currentLease4.ToDHCPLease() } +// Lease6 returns the current IPv6 lease func (c *Client) Lease6() *types.DHCPLease { c.lease6Mu.Lock() defer c.lease6Mu.Unlock() @@ -245,6 +286,7 @@ func (c *Client) Lease6() *types.DHCPLease { return c.currentLease6.ToDHCPLease() } +// Domain returns the current domain func (c *Client) Domain() string { c.lease4Mu.Lock() defer c.lease4Mu.Unlock() @@ -263,50 +305,23 @@ func (c *Client) Domain() string { 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 - version := "ipv4" if ipv4 { c.lease4Mu.Lock() c.currentLease4 = lease + c.lease4Mu.Unlock() } else { - version = "ipv6" c.lease6Mu.Lock() c.currentLease6 = lease - } - - // clear all current jobs with the same tags - // c.scheduler.RemoveByTags(version) - - // add scheduler job to renew the lease - if lease.RenewalTime > 0 { - c.scheduler.NewJob( - gocron.DurationJob(time.Duration(lease.RenewalTime)*time.Second), - gocron.NewTask(func() { - c.l.Info().Msg("renewing lease") - for lease := range c.sendRequests(ipv4, !ipv4) { - if lease, ok := lease.(*Lease); ok { - c.handleLeaseChange(lease) - } - } - }), - gocron.WithName(fmt.Sprintf("renew-%s", version)), - gocron.WithSingletonMode(gocron.LimitModeWait), - gocron.WithTags(version), - ) + c.lease6Mu.Unlock() } c.apply() - if ipv4 { - c.lease4Mu.Unlock() - } else { - c.lease6Mu.Unlock() - } - // TODO: handle lease expiration if c.cfg.OnLease4Change != nil && ipv4 { c.cfg.OnLease4Change(lease.ToDHCPLease()) @@ -317,14 +332,23 @@ func (c *Client) handleLeaseChange(lease *Lease) { } } -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) 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 @@ -350,17 +374,29 @@ func (c *Client) SetIPv4(ipv4 bool) { c.lease4Mu.Lock() c.currentLease4 = nil c.lease4Mu.Unlock() - c.scheduler.RemoveByTags("ipv4") } - c.sendRequests(ipv4, c.cfg.IPv6) + 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 { @@ -368,15 +404,14 @@ func (c *Client) Start() error { c.l.Warn().Err(err).Msg("failed to kill udhcpc processes, continuing anyway") } - c.scheduler.Start() - - go func() { - for lease := range c.sendInitialRequests() { - if lease, ok := lease.(*Lease); ok { - c.handleLeaseChange(lease) - } + 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 } diff --git a/pkg/nmlite/jetdhcpc/dhcp4.go b/pkg/nmlite/jetdhcpc/dhcp4.go index 4f0cdbba..dda0350e 100644 --- a/pkg/nmlite/jetdhcpc/dhcp4.go +++ b/pkg/nmlite/jetdhcpc/dhcp4.go @@ -8,8 +8,12 @@ import ( "github.com/vishvananda/netlink" ) -func (c *Client) requestLease4(iface netlink.Link) (*Lease, error) { - ifname := iface.Attrs().Name +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{ diff --git a/pkg/nmlite/jetdhcpc/dhcp6.go b/pkg/nmlite/jetdhcpc/dhcp6.go index 9a501ce9..6eddde25 100644 --- a/pkg/nmlite/jetdhcpc/dhcp6.go +++ b/pkg/nmlite/jetdhcpc/dhcp6.go @@ -55,10 +55,14 @@ func isIPv6RouteReady(serverAddr net.IP) waitForCondition { } } -func (c *Client) requestLease6(iface netlink.Link) (*Lease, error) { - ifname := iface.Attrs().Name +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 From a9cd36c5fbb96c218f50955613fe85db8b8990a8 Mon Sep 17 00:00:00 2001 From: Siyuan Date: Thu, 9 Oct 2025 21:05:31 +0000 Subject: [PATCH 28/85] fix: update NetworkConfig type in config.go --- config.go | 54 +++++++++++++-------------- pkg/nmlite/jetdhcpc/client.go | 70 ----------------------------------- 2 files changed, 27 insertions(+), 97 deletions(-) diff --git a/config.go b/config.go index 2b79d50c..0fff2fcb 100644 --- a/config.go +++ b/config.go @@ -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 { diff --git a/pkg/nmlite/jetdhcpc/client.go b/pkg/nmlite/jetdhcpc/client.go index ac200503..b3460621 100644 --- a/pkg/nmlite/jetdhcpc/client.go +++ b/pkg/nmlite/jetdhcpc/client.go @@ -192,76 +192,6 @@ func (c *Client) ensureInterfaceUp(ifname string) (*link.Link, error) { return nlm.EnsureInterfaceUpWithTimeout(c.ctx, iface, c.cfg.LinkUpTimeout) } -// func (c *Client) sendInitialRequests() chan any { -// return c.sendRequests(c.cfg.IPv4, c.cfg.IPv6) -// } - -// func (c *Client) sendRequestsFamily( -// family int, -// wg *sync.WaitGroup, -// r *chan any, -// l *zerolog.Logger, -// iface *link.Link, -// ) { -// wg.Add(1) -// go func(iface *link.Link) { -// defer wg.Done() -// var ( -// lease *Lease -// err error -// ) -// switch family { -// case link.AfInet: -// lease, err = c.requestLease4(iface) -// case link.AfInet6: -// lease, err = c.requestLease6(iface) -// } -// if err != nil { -// l.Error().Err(err).Msg("Could not get lease") -// return -// } -// (*r) <- lease -// }(iface) -// } - -// func (c *Client) sendRequests(ipv4, ipv6 bool) chan any { -// c.mu.Lock() -// defer c.mu.Unlock() - -// // Yeah, this is a hack, until we can cancel all leases in progress. -// r := make(chan any, 3*len(c.ifaces)) - -// var wg sync.WaitGroup -// for _, iface := range c.ifaces { -// wg.Add(1) -// go func(ifname string) { -// defer wg.Done() - -// l := c.l.With().Str("interface", ifname).Logger() - -// iface, err := c.ensureInterfaceUp(ifname) -// if err != nil { -// l.Error().Err(err).Msg("Could not bring up interface") -// return -// } - -// if ipv4 { -// c.sendRequestsFamily(link.AfInet, &wg, &r, &l, iface) -// } - -// if ipv6 { -// c.sendRequestsFamily(link.AfInet6, &wg, &r, &l, iface) -// } -// }(iface) -// } - -// go func() { -// wg.Wait() -// close(r) -// }() -// return r -// } - // Lease4 returns the current IPv4 lease func (c *Client) Lease4() *types.DHCPLease { c.lease4Mu.Lock() From 6ff4f37a364e0f2f09c2e06778302a418f3341fa Mon Sep 17 00:00:00 2001 From: Siyuan Date: Fri, 10 Oct 2025 10:12:41 +0000 Subject: [PATCH 29/85] show flags on ipv6 network card --- .vscode/settings.json | 3 +- internal/network/types/rpc.go | 27 +++++++++++- internal/network/types/type.go | 1 + pkg/nmlite/interface_state.go | 1 + pkg/nmlite/jetdhcpc/client.go | 60 ++++++++++++++++----------- pkg/nmlite/jetdhcpc/dhcp4.go | 17 ++++++-- pkg/nmlite/utils.go | 6 +++ ui/src/components/Ipv6NetworkCard.tsx | 21 +++++++++- ui/src/hooks/stores.ts | 9 ++++ 9 files changed, 114 insertions(+), 31 deletions(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index b0e6df67..ba3550bf 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -9,5 +9,6 @@ "synctrace" ] }, - "git.ignoreLimitWarning": true + "git.ignoreLimitWarning": true, + "cmake.sourceDirectory": "/workspaces/kvm-static-ip/internal/native/cgo" } \ No newline at end of file diff --git a/internal/network/types/rpc.go b/internal/network/types/rpc.go index 748d778e..431c5100 100644 --- a/internal/network/types/rpc.go +++ b/internal/network/types/rpc.go @@ -1,20 +1,36 @@ package types -import "time" +import ( + "time" + "golang.org/x/sys/unix" +) + +// RpcIPv6Address is the RPC representation of an IPv6 address type RpcIPv6Address struct { Address string `json:"address"` Prefix string `json:"prefix"` ValidLifetime *time.Time `json:"valid_lifetime"` PreferredLifetime *time.Time `json:"preferred_lifetime"` Scope int `json:"scope"` + Flags int `json:"flags"` + FlagSecondary bool `json:"flag_secondary"` + FlagPermanent bool `json:"flag_permanent"` + FlagTemporary bool `json:"flag_temporary"` + FlagStablePrivacy bool `json:"flag_stable_privacy"` + FlagDeprecated bool `json:"flag_deprecated"` + FlagOptimistic bool `json:"flag_optimistic"` + FlagDADFailed bool `json:"flag_dad_failed"` + FlagTentative bool `json:"flag_tentative"` } +// RpcInterfaceState is the RPC representation of an interface state type RpcInterfaceState struct { InterfaceState IPv6Addresses []RpcIPv6Address `json:"ipv6_addresses"` } +// ToRpcInterfaceState converts an InterfaceState to a RpcInterfaceState func (s *InterfaceState) ToRpcInterfaceState() *RpcInterfaceState { addrs := make([]RpcIPv6Address, len(s.IPv6Addresses)) for i, addr := range s.IPv6Addresses { @@ -24,6 +40,15 @@ func (s *InterfaceState) ToRpcInterfaceState() *RpcInterfaceState { ValidLifetime: addr.ValidLifetime, PreferredLifetime: addr.PreferredLifetime, Scope: addr.Scope, + Flags: addr.Flags, + FlagSecondary: addr.Flags&unix.IFA_F_SECONDARY != 0, + FlagPermanent: addr.Flags&unix.IFA_F_PERMANENT != 0, + FlagTemporary: addr.Flags&unix.IFA_F_TEMPORARY != 0, + FlagStablePrivacy: addr.Flags&unix.IFA_F_STABLE_PRIVACY != 0, + FlagDeprecated: addr.Flags&unix.IFA_F_DEPRECATED != 0, + FlagOptimistic: addr.Flags&unix.IFA_F_OPTIMISTIC != 0, + FlagDADFailed: addr.Flags&unix.IFA_F_DADFAILED != 0, + FlagTentative: addr.Flags&unix.IFA_F_TENTATIVE != 0, } } return &RpcInterfaceState{ diff --git a/internal/network/types/type.go b/internal/network/types/type.go index 2bee48ac..b770fcd5 100644 --- a/internal/network/types/type.go +++ b/internal/network/types/type.go @@ -65,6 +65,7 @@ type IPv6Address struct { Prefix net.IPNet `json:"prefix"` ValidLifetime *time.Time `json:"valid_lifetime"` PreferredLifetime *time.Time `json:"preferred_lifetime"` + Flags int `json:"flags"` Scope int `json:"scope"` } diff --git a/pkg/nmlite/interface_state.go b/pkg/nmlite/interface_state.go index f29c8f22..8a60784b 100644 --- a/pkg/nmlite/interface_state.go +++ b/pkg/nmlite/interface_state.go @@ -110,6 +110,7 @@ func (im *InterfaceManager) updateInterfaceStateAddresses(nl *link.Link) (bool, Address: addr.IP, Prefix: *addr.IPNet, Scope: addr.Scope, + Flags: addr.Flags, ValidLifetime: lifetimeToTime(addr.ValidLft), PreferredLifetime: lifetimeToTime(addr.PreferedLft), }) diff --git a/pkg/nmlite/jetdhcpc/client.go b/pkg/nmlite/jetdhcpc/client.go index b3460621..bcab787d 100644 --- a/pkg/nmlite/jetdhcpc/client.go +++ b/pkg/nmlite/jetdhcpc/client.go @@ -107,8 +107,9 @@ type Client struct { } var ( - defaultTimerDuration = 1 * time.Second - defaultLinkUpTimeout = 30 * time.Second + defaultTimerDuration = 1 * time.Second + defaultLinkUpTimeout = 30 * time.Second + maxRenewalAttemptDuration = 2 * time.Hour ) // NewClient creates a new DHCP client for the given interface. @@ -155,10 +156,21 @@ func resetTimer(t *time.Timer, l *zerolog.Logger) { t.Reset(defaultTimerDuration) } +func getRenewalTime(lease *Lease) time.Duration { + if lease.RenewalTime <= 0 || lease.LeaseTime > maxRenewalAttemptDuration/2 { + return maxRenewalAttemptDuration + } + + return lease.RenewalTime +} + func (c *Client) requestLoop(t *time.Timer, family int, ifname string) { + l := c.l.With().Str("interface", ifname).Int("family", family).Logger() for range t.C { + l.Info().Msg("requesting lease") + if _, err := c.ensureInterfaceUp(ifname); err != nil { - c.l.Error().Err(err).Msg("failed to ensure interface up") + l.Error().Err(err).Msg("failed to ensure interface up") resetTimer(t, c.l) continue } @@ -174,12 +186,22 @@ func (c *Client) requestLoop(t *time.Timer, family int, ifname string) { lease, err = c.requestLease6(ifname) } if err != nil { - c.l.Error().Err(err).Msg("failed to request lease") + l.Error().Err(err).Msg("failed to request lease") resetTimer(t, c.l) continue } c.handleLeaseChange(lease) + + nextRenewal := getRenewalTime(lease) + + l.Info(). + Dur("nextRenewal", nextRenewal). + Dur("leaseTime", lease.LeaseTime). + Dur("rebindingTime", lease.RebindingTime). + Msg("sleeping until next renewal") + + t.Reset(nextRenewal) } } @@ -262,25 +284,9 @@ func (c *Client) handleLeaseChange(lease *Lease) { } } -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() + c.timer4.Reset(defaultTimerDuration) + c.timer6.Reset(defaultTimerDuration) return nil } @@ -304,9 +310,11 @@ func (c *Client) SetIPv4(ipv4 bool) { c.lease4Mu.Lock() c.currentLease4 = nil c.lease4Mu.Unlock() + + c.timer4.Stop() } - c.timer4.Stop() + c.timer4.Reset(defaultTimerDuration) } func (c *Client) SetIPv6(ipv6 bool) { @@ -323,10 +331,12 @@ func (c *Client) SetIPv6(ipv6 bool) { if !ipv6 { c.lease6Mu.Lock() c.currentLease6 = nil - c.lease4Mu.Unlock() + c.lease6Mu.Unlock() + + c.timer6.Stop() } - c.timer6.Stop() + c.timer6.Reset(defaultTimerDuration) } func (c *Client) Start() error { diff --git a/pkg/nmlite/jetdhcpc/dhcp4.go b/pkg/nmlite/jetdhcpc/dhcp4.go index dda0350e..4eb0ee14 100644 --- a/pkg/nmlite/jetdhcpc/dhcp4.go +++ b/pkg/nmlite/jetdhcpc/dhcp4.go @@ -47,9 +47,20 @@ func (c *Client) requestLease4(ifname string) (*Lease, error) { } l.Info().Msg("attempting to get DHCPv4 lease") - lease, err := client.Request(c.ctx, reqmods...) - if err != nil { - return nil, err + var ( + lease *nclient4.Lease + reqErr error + ) + if c.currentLease4 != nil { + l.Info().Msg("current lease is not nil, renewing") + lease, reqErr = client.Renew(c.ctx, c.currentLease4.p4, reqmods...) + } else { + l.Info().Msg("current lease is nil, requesting new lease") + lease, reqErr = client.Request(c.ctx, reqmods...) + } + + if reqErr != nil { + return nil, reqErr } if lease == nil || lease.ACK == nil { diff --git a/pkg/nmlite/utils.go b/pkg/nmlite/utils.go index 15d5624c..5a6eb0b8 100644 --- a/pkg/nmlite/utils.go +++ b/pkg/nmlite/utils.go @@ -58,9 +58,15 @@ func compareIPv6AddressSlices(a, b []types.IPv6Address) bool { if a[i].Address.String() != b[i].Address.String() { return false } + if a[i].Prefix.String() != b[i].Prefix.String() { return false } + + if a[i].Flags != b[i].Flags { + return false + } + // we don't compare the lifetimes because they are not always same if a[i].Scope != b[i].Scope { return false diff --git a/ui/src/components/Ipv6NetworkCard.tsx b/ui/src/components/Ipv6NetworkCard.tsx index 5ed27ca1..d34da739 100644 --- a/ui/src/components/Ipv6NetworkCard.tsx +++ b/ui/src/components/Ipv6NetworkCard.tsx @@ -1,8 +1,21 @@ +import { cx } from "@/cva.config"; import { NetworkState } from "../hooks/stores"; import { LifeTimeLabel } from "../routes/devices.$id.settings.network"; import { GridCard } from "./Card"; +export function FlagLabel({ flag, className }: { flag: string, className?: string }) { + const classes = cx( + "ml-2 rounded-sm bg-red-500 px-2 py-1 text-[10px] font-medium leading-none text-white dark:border", + "bg-red-500 text-white dark:border-red-700 dark:bg-red-800 dark:text-red-50", + className, + ); + + return + {flag} + +} + export default function Ipv6NetworkCard({ networkState, }: { @@ -49,7 +62,13 @@ export default function Ipv6NetworkCard({ Address - {addr.address} + + {addr.address} + + {addr.flag_deprecated ? : null} + {addr.flag_dad_failed ? : null} + +
{addr.valid_lifetime && ( diff --git a/ui/src/hooks/stores.ts b/ui/src/hooks/stores.ts index ca8ff46f..1b8dae1b 100644 --- a/ui/src/hooks/stores.ts +++ b/ui/src/hooks/stores.ts @@ -699,6 +699,15 @@ export interface IPv6Address { valid_lifetime: string; preferred_lifetime: string; scope: string; + flags: number; + flag_secondary?: boolean; + flag_permanent?: boolean; + flag_temporary?: boolean; + flag_stable_privacy?: boolean; + flag_deprecated?: boolean; + flag_optimistic?: boolean; + flag_dad_failed?: boolean; + flag_tentative?: boolean; } export interface NetworkState { From 579345e5b43c12cccfa9e7e3e0648a20c5c44a4d Mon Sep 17 00:00:00 2001 From: Siyuan Date: Fri, 10 Oct 2025 13:33:43 +0000 Subject: [PATCH 30/85] refactor & fix hostname --- internal/network/types/{type.go => config.go} | 98 +------- .../network/types/{rpc.go => interface.go} | 35 +-- internal/network/types/ip.go | 85 +++++++ internal/network/types/resolvconf.go | 22 ++ network.go | 36 ++- pkg/nmlite/dhcp.go | 5 +- pkg/nmlite/hostname.go | 116 +++++++--- pkg/nmlite/interface.go | 116 ++++++---- pkg/nmlite/jetdhcpc/client.go | 2 + pkg/nmlite/jetdhcpc/dhcp4.go | 14 +- pkg/nmlite/jetdhcpc/lease.go | 7 + pkg/nmlite/manager.go | 18 ++ pkg/nmlite/resolvconf.go | 216 +++++++++--------- pkg/nmlite/resolvconf_test.go | 35 --- pkg/nmlite/state.go | 8 +- 15 files changed, 472 insertions(+), 341 deletions(-) rename internal/network/types/{type.go => config.go} (61%) rename internal/network/types/{rpc.go => interface.go} (57%) create mode 100644 internal/network/types/ip.go create mode 100644 internal/network/types/resolvconf.go delete mode 100644 pkg/nmlite/resolvconf_test.go diff --git a/internal/network/types/type.go b/internal/network/types/config.go similarity index 61% rename from internal/network/types/type.go rename to internal/network/types/config.go index b770fcd5..364f8609 100644 --- a/internal/network/types/type.go +++ b/internal/network/types/config.go @@ -1,74 +1,12 @@ package types import ( - "net" "net/http" "net/url" - "slices" - "time" "github.com/guregu/null/v6" - "github.com/vishvananda/netlink" ) -// IPAddress represents a network interface address -type IPAddress struct { - Family int - Address net.IPNet - Gateway net.IP - MTU int - Secondary bool - Permanent bool -} - -func (a *IPAddress) String() string { - return a.Address.String() -} - -func (a *IPAddress) Compare(n netlink.Addr) bool { - if !a.Address.IP.Equal(n.IP) { - return false - } - if slices.Compare(a.Address.Mask, n.IPNet.Mask) != 0 { - return false - } - return true -} - -func (a *IPAddress) NetlinkAddr() netlink.Addr { - return netlink.Addr{ - IPNet: &a.Address, - } -} - -func (a *IPAddress) DefaultRoute(linkIndex int) netlink.Route { - return netlink.Route{ - Dst: nil, - Gw: a.Gateway, - LinkIndex: linkIndex, - } -} - -// ParsedIPConfig represents the parsed IP configuration -type ParsedIPConfig struct { - Addresses []IPAddress - Nameservers []net.IP - SearchList []string - Domain string - MTU int - Interface string -} - -// IPv6Address represents an IPv6 address with lifetime information -type IPv6Address struct { - Address net.IP `json:"address"` - Prefix net.IPNet `json:"prefix"` - ValidLifetime *time.Time `json:"valid_lifetime"` - PreferredLifetime *time.Time `json:"preferred_lifetime"` - Flags int `json:"flags"` - Scope int `json:"scope"` -} - // IPv4StaticConfig represents static IPv4 configuration type IPv4StaticConfig struct { Address null.String `json:"address,omitempty" validate_type:"ipv4" required:"true"` @@ -84,6 +22,12 @@ type IPv6StaticConfig struct { DNS []string `json:"dns,omitempty" validate_type:"ipv6" required:"true"` } +// MDNSListenOptions represents MDNS listening options +type MDNSListenOptions struct { + IPv4 bool + IPv6 bool +} + // NetworkConfig represents the complete network configuration for an interface type NetworkConfig struct { DHCPClient null.String `json:"dhcp_client,omitempty" one_of:"jetdhcpc,udhcpc" default:"jetdhcpc"` @@ -130,44 +74,18 @@ func (c *NetworkConfig) GetMDNSMode() *MDNSListenOptions { return listenOptions } -// MDNSListenOptions represents MDNS listening options -type MDNSListenOptions struct { - IPv4 bool - IPv6 bool -} - // GetTransportProxyFunc returns a function for HTTP proxy configuration func (c *NetworkConfig) GetTransportProxyFunc() func(*http.Request) (*url.URL, error) { return func(*http.Request) (*url.URL, error) { if c.HTTPProxy.String == "" { return nil, nil } else { - proxyUrl, _ := url.Parse(c.HTTPProxy.String) - return proxyUrl, nil + proxyURL, _ := url.Parse(c.HTTPProxy.String) + return proxyURL, nil } } } -// InterfaceState represents the current state of a network interface -type InterfaceState struct { - InterfaceName string `json:"interface_name"` - MACAddress string `json:"mac_address"` - Up bool `json:"up"` - Online bool `json:"online"` - IPv4Ready bool `json:"ipv4_ready"` - IPv6Ready bool `json:"ipv6_ready"` - IPv4Address string `json:"ipv4_address,omitempty"` - IPv6Address string `json:"ipv6_address,omitempty"` - IPv6LinkLocal string `json:"ipv6_link_local,omitempty"` - IPv6Gateway string `json:"ipv6_gateway,omitempty"` - IPv4Addresses []string `json:"ipv4_addresses,omitempty"` - IPv6Addresses []IPv6Address `json:"ipv6_addresses,omitempty"` - NTPServers []net.IP `json:"ntp_servers,omitempty"` - DHCPLease4 *DHCPLease `json:"dhcp_lease,omitempty"` - DHCPLease6 *DHCPLease `json:"dhcp_lease6,omitempty"` - LastUpdated time.Time `json:"last_updated"` -} - // NetworkConfig interface for backward compatibility type NetworkConfigInterface interface { InterfaceName() string diff --git a/internal/network/types/rpc.go b/internal/network/types/interface.go similarity index 57% rename from internal/network/types/rpc.go rename to internal/network/types/interface.go index 431c5100..07ae12d5 100644 --- a/internal/network/types/rpc.go +++ b/internal/network/types/interface.go @@ -1,27 +1,30 @@ package types import ( + "net" "time" "golang.org/x/sys/unix" ) -// RpcIPv6Address is the RPC representation of an IPv6 address -type RpcIPv6Address struct { - Address string `json:"address"` - Prefix string `json:"prefix"` - ValidLifetime *time.Time `json:"valid_lifetime"` - PreferredLifetime *time.Time `json:"preferred_lifetime"` - Scope int `json:"scope"` - Flags int `json:"flags"` - FlagSecondary bool `json:"flag_secondary"` - FlagPermanent bool `json:"flag_permanent"` - FlagTemporary bool `json:"flag_temporary"` - FlagStablePrivacy bool `json:"flag_stable_privacy"` - FlagDeprecated bool `json:"flag_deprecated"` - FlagOptimistic bool `json:"flag_optimistic"` - FlagDADFailed bool `json:"flag_dad_failed"` - FlagTentative bool `json:"flag_tentative"` +// InterfaceState represents the current state of a network interface +type InterfaceState struct { + InterfaceName string `json:"interface_name"` + MACAddress string `json:"mac_address"` + Up bool `json:"up"` + Online bool `json:"online"` + IPv4Ready bool `json:"ipv4_ready"` + IPv6Ready bool `json:"ipv6_ready"` + IPv4Address string `json:"ipv4_address,omitempty"` + IPv6Address string `json:"ipv6_address,omitempty"` + IPv6LinkLocal string `json:"ipv6_link_local,omitempty"` + IPv6Gateway string `json:"ipv6_gateway,omitempty"` + IPv4Addresses []string `json:"ipv4_addresses,omitempty"` + IPv6Addresses []IPv6Address `json:"ipv6_addresses,omitempty"` + NTPServers []net.IP `json:"ntp_servers,omitempty"` + DHCPLease4 *DHCPLease `json:"dhcp_lease,omitempty"` + DHCPLease6 *DHCPLease `json:"dhcp_lease6,omitempty"` + LastUpdated time.Time `json:"last_updated"` } // RpcInterfaceState is the RPC representation of an interface state diff --git a/internal/network/types/ip.go b/internal/network/types/ip.go new file mode 100644 index 00000000..293e7e3f --- /dev/null +++ b/internal/network/types/ip.go @@ -0,0 +1,85 @@ +package types + +import ( + "net" + "slices" + "time" + + "github.com/vishvananda/netlink" +) + +// IPAddress represents a network interface address +type IPAddress struct { + Family int + Address net.IPNet + Gateway net.IP + MTU int + Secondary bool + Permanent bool +} + +func (a *IPAddress) String() string { + return a.Address.String() +} + +func (a *IPAddress) Compare(n netlink.Addr) bool { + if !a.Address.IP.Equal(n.IP) { + return false + } + if slices.Compare(a.Address.Mask, n.IPNet.Mask) != 0 { + return false + } + return true +} + +func (a *IPAddress) NetlinkAddr() netlink.Addr { + return netlink.Addr{ + IPNet: &a.Address, + } +} + +func (a *IPAddress) DefaultRoute(linkIndex int) netlink.Route { + return netlink.Route{ + Dst: nil, + Gw: a.Gateway, + LinkIndex: linkIndex, + } +} + +// ParsedIPConfig represents the parsed IP configuration +type ParsedIPConfig struct { + Addresses []IPAddress + Nameservers []net.IP + SearchList []string + Domain string + MTU int + Interface string +} + +// IPv6Address represents an IPv6 address with lifetime information +type IPv6Address struct { + Address net.IP `json:"address"` + Prefix net.IPNet `json:"prefix"` + ValidLifetime *time.Time `json:"valid_lifetime"` + PreferredLifetime *time.Time `json:"preferred_lifetime"` + Flags int `json:"flags"` + Scope int `json:"scope"` +} + +// RpcIPv6Address is the RPC representation of an IPv6 address +type RpcIPv6Address struct { + Address string `json:"address"` + Prefix string `json:"prefix"` + ValidLifetime *time.Time `json:"valid_lifetime"` + PreferredLifetime *time.Time `json:"preferred_lifetime"` + Scope int `json:"scope"` + Flags int `json:"flags"` + FlagSecondary bool `json:"flag_secondary"` + FlagPermanent bool `json:"flag_permanent"` + FlagTemporary bool `json:"flag_temporary"` + FlagStablePrivacy bool `json:"flag_stable_privacy"` + FlagDeprecated bool `json:"flag_deprecated"` + FlagOptimistic bool `json:"flag_optimistic"` + FlagDADFailed bool `json:"flag_dad_failed"` + FlagTentative bool `json:"flag_tentative"` +} diff --git a/internal/network/types/resolvconf.go b/internal/network/types/resolvconf.go new file mode 100644 index 00000000..c15b96fa --- /dev/null +++ b/internal/network/types/resolvconf.go @@ -0,0 +1,22 @@ +package types + +import "net" + +// InterfaceResolvConf represents the DNS configuration for a network interface +type InterfaceResolvConf struct { + NameServers []net.IP `json:"nameservers"` + SearchList []string `json:"search_list"` + Domain string `json:"domain,omitempty"` // TODO: remove this once we have a better way to handle the domain + Source string `json:"source,omitempty"` +} + +// InterfaceResolvConfMap .. +type InterfaceResolvConfMap map[string]InterfaceResolvConf + +// ResolvConf represents the DNS configuration for the system +type ResolvConf struct { + ConfigIPv4 InterfaceResolvConfMap `json:"config_ipv4"` + ConfigIPv6 InterfaceResolvConfMap `json:"config_ipv6"` + Domain string `json:"domain"` + HostName string `json:"host_name"` +} diff --git a/network.go b/network.go index ce8f60f8..bd155f81 100644 --- a/network.go +++ b/network.go @@ -42,8 +42,8 @@ func restartMdns() { IPv6: config.NetworkConfig.MDNSMode.String != "disabled", }) _ = mDNS.SetLocalNames([]string{ - networkManager.GetHostname(), - networkManager.GetFQDN(), + networkManager.Hostname(), + networkManager.FQDN(), }, true) } @@ -54,7 +54,12 @@ func triggerTimeSyncOnNetworkStateChange() { // set the NTP servers from the network manager if networkManager != nil { - timeSync.SetDhcpNtpAddresses(networkManager.NTPServerStrings()) + ntpServers := make([]string, len(networkManager.NTPServers())) + for i, server := range networkManager.NTPServers() { + ntpServers[i] = server.String() + } + networkLogger.Info().Strs("ntpServers", ntpServers).Msg("setting NTP servers from network manager") + timeSync.SetDhcpNtpAddresses(ntpServers) } // sync time @@ -103,15 +108,32 @@ func initNetwork() error { // validate the config, if it's invalid, revert to the default config and save the backup validateNetworkConfig() - networkManager = nmlite.NewNetworkManager(context.Background(), networkLogger) - networkManager.SetOnInterfaceStateChange(networkStateChanged) - if err := networkManager.AddInterface(NetIfName, config.NetworkConfig); err != nil { + nc := config.NetworkConfig + + nm := nmlite.NewNetworkManager(context.Background(), networkLogger) + _ = setHostname(nm, nc.Hostname.String, nc.Domain.String) + nm.SetOnInterfaceStateChange(networkStateChanged) + if err := nm.AddInterface(NetIfName, nc); err != nil { return fmt.Errorf("failed to add interface: %w", err) } + networkManager = nm + return nil } +func setHostname(nm *nmlite.NetworkManager, hostname, domain string) error { + if nm == nil { + return nil + } + + if hostname == "" { + hostname = GetDefaultHostname() + } + + return nm.SetHostname(hostname, domain) +} + func rpcGetNetworkState() *types.RpcInterfaceState { state, _ := networkManager.GetInterfaceState(NetIfName) return state.ToRpcInterfaceState() @@ -131,6 +153,8 @@ func rpcSetNetworkSettings(settings RpcNetworkSettings) (*RpcNetworkSettings, er l.Debug().Msg("setting new config") + _ = setHostname(networkManager, netConfig.Hostname.String, netConfig.Domain.String) + s := networkManager.SetInterfaceConfig(NetIfName, netConfig) if s != nil { return nil, s diff --git a/pkg/nmlite/dhcp.go b/pkg/nmlite/dhcp.go index 000633f0..fb50d986 100644 --- a/pkg/nmlite/dhcp.go +++ b/pkg/nmlite/dhcp.go @@ -81,8 +81,9 @@ func (dc *DHCPClient) initClient() (types.DHCPClient, error) { func (dc *DHCPClient) initJetDHCPC() (types.DHCPClient, error) { return jetdhcpc.NewClient(dc.ctx, []string{dc.ifaceName}, &jetdhcpc.Config{ - IPv4: dc.ipv4Enabled, - IPv6: dc.ipv6Enabled, + IPv4: dc.ipv4Enabled, + IPv6: dc.ipv6Enabled, + V4ClientIdentifier: true, OnLease4Change: func(lease *types.DHCPLease) { dc.handleLeaseChange(lease, false) }, diff --git a/pkg/nmlite/hostname.go b/pkg/nmlite/hostname.go index 812fa533..5d220176 100644 --- a/pkg/nmlite/hostname.go +++ b/pkg/nmlite/hostname.go @@ -7,9 +7,6 @@ import ( "os/exec" "strings" - "github.com/jetkvm/kvm/internal/sync" - - "github.com/rs/zerolog" "golang.org/x/net/idna" ) @@ -18,38 +15,89 @@ const ( hostsPath = "/etc/hosts" ) -// HostnameManager manages system hostname and /etc/hosts -type HostnameManager struct { - logger *zerolog.Logger - mu sync.Mutex -} - -// NewHostnameManager creates a new hostname manager -func NewHostnameManager(logger *zerolog.Logger) *HostnameManager { - if logger == nil { - // Create a no-op logger if none provided - logger = &zerolog.Logger{} - } - - return &HostnameManager{ - logger: logger, - } -} - // SetHostname sets the system hostname and updates /etc/hosts -func (hm *HostnameManager) SetHostname(hostname, fqdn string) error { - hm.mu.Lock() - defer hm.mu.Unlock() - +func (hm *ResolvConfManager) SetHostname(hostname, domain string) error { hostname = ToValidHostname(strings.TrimSpace(hostname)) - fqdn = ToValidHostname(strings.TrimSpace(fqdn)) + domain = ToValidHostname(strings.TrimSpace(domain)) if hostname == "" { return fmt.Errorf("invalid hostname: %s", hostname) } - if fqdn == "" { - fqdn = hostname + hm.hostname = hostname + hm.domain = domain + + return hm.reconcileHostname() +} + +func (hm *ResolvConfManager) Domain() string { + hm.mu.Lock() + defer hm.mu.Unlock() + return hm.getDomain() +} + +func (hm *ResolvConfManager) Hostname() string { + hm.mu.Lock() + defer hm.mu.Unlock() + return hm.getHostname() +} + +func (hm *ResolvConfManager) FQDN() string { + hm.mu.Lock() + defer hm.mu.Unlock() + return hm.getFQDN() +} + +func (hm *ResolvConfManager) getFQDN() string { + hostname := hm.getHostname() + domain := hm.getDomain() + + if domain == "" { + return hostname + } + + return fmt.Sprintf("%s.%s", hostname, domain) +} + +func (hm *ResolvConfManager) getHostname() string { + if hm.hostname != "" { + return hm.hostname + } + return "jetkvm" +} + +func (hm *ResolvConfManager) getDomain() string { + if hm.domain != "" { + return hm.domain + } + + for _, iface := range hm.conf.ConfigIPv4 { + if iface.Domain != "" { + return iface.Domain + } + } + + for _, iface := range hm.conf.ConfigIPv6 { + if iface.Domain != "" { + return iface.Domain + } + } + + return "" +} + +func (hm *ResolvConfManager) reconcileHostname() error { + hm.mu.Lock() + domain := hm.getDomain() + hostname := hm.hostname + if hostname == "" { + hostname = "jetkvm" + } + hm.mu.Unlock() + + fqdn := hostname + if fqdn != "" { + fqdn = fmt.Sprintf("%s.%s", hostname, domain) } hm.logger.Info(). @@ -81,12 +129,12 @@ func (hm *HostnameManager) SetHostname(hostname, fqdn string) error { } // GetCurrentHostname returns the current system hostname -func (hm *HostnameManager) GetCurrentHostname() (string, error) { +func (hm *ResolvConfManager) GetCurrentHostname() (string, error) { return os.Hostname() } // GetCurrentFQDN returns the current FQDN -func (hm *HostnameManager) GetCurrentFQDN() (string, error) { +func (hm *ResolvConfManager) GetCurrentFQDN() (string, error) { hostname, err := hm.GetCurrentHostname() if err != nil { return "", err @@ -97,7 +145,7 @@ func (hm *HostnameManager) GetCurrentFQDN() (string, error) { } // updateEtcHostname updates the /etc/hostname file -func (hm *HostnameManager) updateEtcHostname(hostname string) error { +func (hm *ResolvConfManager) updateEtcHostname(hostname string) error { if err := os.WriteFile(hostnamePath, []byte(hostname), 0644); err != nil { return fmt.Errorf("failed to write %s: %w", hostnamePath, err) } @@ -107,7 +155,7 @@ func (hm *HostnameManager) updateEtcHostname(hostname string) error { } // updateEtcHosts updates the /etc/hosts file -func (hm *HostnameManager) updateEtcHosts(hostname, fqdn string) error { +func (hm *ResolvConfManager) updateEtcHosts(hostname, fqdn string) error { // Open /etc/hosts for reading and writing hostsFile, err := os.OpenFile(hostsPath, os.O_RDWR|os.O_SYNC, os.ModeExclusive) if err != nil { @@ -166,7 +214,7 @@ func (hm *HostnameManager) updateEtcHosts(hostname, fqdn string) error { } // setSystemHostname sets the system hostname using the hostname command -func (hm *HostnameManager) setSystemHostname(hostname string) error { +func (hm *ResolvConfManager) setSystemHostname(hostname string) error { cmd := exec.Command("hostname", "-F", hostnamePath) if err := cmd.Run(); err != nil { return fmt.Errorf("failed to run hostname command: %w", err) @@ -177,7 +225,7 @@ func (hm *HostnameManager) setSystemHostname(hostname string) error { } // getFQDNFromHosts tries to get the FQDN from /etc/hosts -func (hm *HostnameManager) getFQDNFromHosts(hostname string) (string, error) { +func (hm *ResolvConfManager) getFQDNFromHosts(hostname string) (string, error) { content, err := os.ReadFile(hostsPath) if err != nil { return hostname, nil // Return hostname as fallback diff --git a/pkg/nmlite/interface.go b/pkg/nmlite/interface.go index 58aa48a0..4dca6d8d 100644 --- a/pkg/nmlite/interface.go +++ b/pkg/nmlite/interface.go @@ -19,6 +19,8 @@ import ( "github.com/vishvananda/netlink" ) +type ResolvConfChangeCallback func(family int, resolvConf *types.InterfaceResolvConf) error + // InterfaceManager manages a single network interface type InterfaceManager struct { ctx context.Context @@ -32,13 +34,12 @@ type InterfaceManager struct { // Network components staticConfig *StaticConfigManager dhcpClient *DHCPClient - resolvConf *ResolvConfManager - hostname *HostnameManager // Callbacks - onStateChange func(state types.InterfaceState) - onConfigChange func(config *types.NetworkConfig) - onDHCPLeaseChange func(lease *types.DHCPLease) + onStateChange func(state types.InterfaceState) + onConfigChange func(config *types.NetworkConfig) + onDHCPLeaseChange func(lease *types.DHCPLease) + onResolvConfChange ResolvConfChangeCallback // Control stopCh chan struct{} @@ -87,9 +88,6 @@ func NewInterfaceManager(ctx context.Context, ifaceName string, config *types.Ne return nil, fmt.Errorf("failed to create DHCP client: %w", err) } - im.resolvConf = NewResolvConfManager(&scopedLogger) - im.hostname = NewHostnameManager(&scopedLogger) - // Set up DHCP client callbacks im.dhcpClient.SetOnLeaseChange(func(lease *types.DHCPLease) { if err := im.applyDHCPLease(lease); err != nil { @@ -248,6 +246,9 @@ func (im *InterfaceManager) GetIPv6Addresses() []string { // GetMACAddress returns the MAC address of the interface func (im *InterfaceManager) GetMACAddress() string { + im.stateMu.RLock() + defer im.stateMu.RUnlock() + return im.state.MACAddress } @@ -271,6 +272,21 @@ func (im *InterfaceManager) NTPServers() []net.IP { return im.state.NTPServers } +func (im *InterfaceManager) Domain() string { + im.stateMu.RLock() + defer im.stateMu.RUnlock() + + if im.state.DHCPLease4 != nil { + return im.state.DHCPLease4.Domain + } + + if im.state.DHCPLease6 != nil { + return im.state.DHCPLease6.Domain + } + + return "" +} + // GetConfig returns the current interface configuration func (im *InterfaceManager) GetConfig() *types.NetworkConfig { // Return a copy to avoid race conditions @@ -335,6 +351,11 @@ func (im *InterfaceManager) SetOnDHCPLeaseChange(callback func(lease *types.DHCP im.onDHCPLeaseChange = callback } +// SetOnResolvConfChange sets the callback for resolv.conf changes +func (im *InterfaceManager) SetOnResolvConfChange(callback ResolvConfChangeCallback) { + im.onResolvConfChange = callback +} + // applyConfiguration applies the current configuration to the interface func (im *InterfaceManager) applyConfiguration() error { im.logger.Info().Msg("applying configuration") @@ -349,11 +370,6 @@ func (im *InterfaceManager) applyConfiguration() error { return fmt.Errorf("failed to apply IPv6 config: %w", err) } - // Update hostname - if err := im.updateHostname(); err != nil { - im.logger.Warn().Err(err).Msg("failed to update hostname") - } - return nil } @@ -419,6 +435,13 @@ func (im *InterfaceManager) applyIPv4Static() error { im.logger.Info().Interface("config", config).Msg("converted IPv4 static configuration") + if err := im.onResolvConfChange(link.AfInet, &types.InterfaceResolvConf{ + NameServers: config.Nameservers, + Source: "static", + }); err != nil { + im.logger.Warn().Err(err).Msg("failed to update resolv.conf") + } + return im.ReconcileLinkAddrs(config.Addresses, link.AfInet) } @@ -463,6 +486,13 @@ func (im *InterfaceManager) applyIPv6Static() error { } im.logger.Info().Interface("config", config).Msg("converted IPv6 static configuration") + if err := im.onResolvConfChange(link.AfInet6, &types.InterfaceResolvConf{ + NameServers: config.Nameservers, + Source: "static", + }); err != nil { + im.logger.Warn().Err(err).Msg("failed to update resolv.conf") + } + return im.ReconcileLinkAddrs(config.Addresses, link.AfInet6) } @@ -542,39 +572,6 @@ func (im *InterfaceManager) disableIPv6() error { return im.staticConfig.DisableIPv6() } -// updateHostname updates the system hostname -func (im *InterfaceManager) updateHostname() error { - hostname := im.getHostname() - domain := im.getDomain() - fqdn := fmt.Sprintf("%s.%s", hostname, domain) - - return im.hostname.SetHostname(hostname, fqdn) -} - -// getHostname returns the configured hostname or default -func (im *InterfaceManager) getHostname() string { - if im.config.Hostname.String != "" { - return im.config.Hostname.String - } - return "jetkvm" -} - -// getDomain returns the configured domain or default -func (im *InterfaceManager) getDomain() string { - if im.config.Domain.String != "" { - return im.config.Domain.String - } - - // Try to get domain from DHCP lease - if im.dhcpClient != nil { - if lease := im.dhcpClient.Lease4(); lease != nil && lease.Domain != "" { - return lease.Domain - } - } - - return "local" -} - func (im *InterfaceManager) handleLinkStateChange(link *link.Link) { { im.stateMu.Lock() @@ -702,13 +699,34 @@ func (im *InterfaceManager) monitorInterfaceState() { // updateStateFromDHCPLease updates the state from a DHCP lease func (im *InterfaceManager) updateStateFromDHCPLease(lease *types.DHCPLease) { + family := link.AfInet + im.stateMu.Lock() - im.state.DHCPLease4 = lease + if lease.IsIPv6() { + im.state.DHCPLease6 = lease + family = link.AfInet6 + } else { + im.state.DHCPLease4 = lease + family = link.AfInet + } im.stateMu.Unlock() // Update resolv.conf with DNS information - if im.resolvConf != nil { - im.resolvConf.UpdateFromLease(lease) + if im.onResolvConfChange == nil { + return + } + + if im.ifaceName == "" { + im.logger.Warn().Msg("interface name is empty, skipping resolv.conf update") + return + } + + if err := im.onResolvConfChange(family, &types.InterfaceResolvConf{ + NameServers: lease.DNS, + SearchList: lease.SearchList, + Source: "dhcp", + }); err != nil { + im.logger.Warn().Err(err).Msg("failed to update resolv.conf") } } diff --git a/pkg/nmlite/jetdhcpc/client.go b/pkg/nmlite/jetdhcpc/client.go index bcab787d..155ea249 100644 --- a/pkg/nmlite/jetdhcpc/client.go +++ b/pkg/nmlite/jetdhcpc/client.go @@ -75,6 +75,8 @@ type Config struct { // If true, add Client Identifier (61) option to the IPv4 request. V4ClientIdentifier bool + Hostname string + OnLease4Change LeaseChangeHandler OnLease6Change LeaseChangeHandler diff --git a/pkg/nmlite/jetdhcpc/dhcp4.go b/pkg/nmlite/jetdhcpc/dhcp4.go index 4eb0ee14..afa10a4a 100644 --- a/pkg/nmlite/jetdhcpc/dhcp4.go +++ b/pkg/nmlite/jetdhcpc/dhcp4.go @@ -35,7 +35,14 @@ func (c *Client) requestLease4(ifname string) (*Lease, error) { reqmods := append( []dhcpv4.Modifier{ dhcpv4.WithOption(dhcpv4.OptClassIdentifier(VendorIdentifier)), - dhcpv4.WithRequestedOptions(dhcpv4.OptionSubnetMask), + dhcpv4.WithRequestedOptions( + dhcpv4.OptionSubnetMask, + dhcpv4.OptionInterfaceMTU, + dhcpv4.OptionNTPServers, + dhcpv4.OptionDomainName, + dhcpv4.OptionDomainNameServer, + dhcpv4.OptionDNSDomainSearchList, + ), }, c.cfg.Modifiers4...) @@ -46,6 +53,10 @@ func (c *Client) requestLease4(ifname string) (*Lease, error) { reqmods = append(reqmods, dhcpv4.WithOption(dhcpv4.OptClientIdentifier(ident))) } + if c.cfg.Hostname != "" { + reqmods = append(reqmods, dhcpv4.WithOption(dhcpv4.OptHostName(c.cfg.Hostname))) + } + l.Info().Msg("attempting to get DHCPv4 lease") var ( lease *nclient4.Lease @@ -68,6 +79,7 @@ func (c *Client) requestLease4(ifname string) (*Lease, error) { } summaryStructured(lease.ACK, &l).Info().Msgf("DHCPv4 lease acquired: %s", lease.ACK.String()) + l.Trace().Interface("options", lease.ACK.Options.String()).Msg("DHCPv4 lease options") return fromNclient4Lease(lease, ifname), nil } diff --git a/pkg/nmlite/jetdhcpc/lease.go b/pkg/nmlite/jetdhcpc/lease.go index 0c06f8fa..ea09e40f 100644 --- a/pkg/nmlite/jetdhcpc/lease.go +++ b/pkg/nmlite/jetdhcpc/lease.go @@ -2,6 +2,7 @@ package jetdhcpc import ( "bufio" + "encoding/binary" "encoding/json" "fmt" "net" @@ -11,6 +12,7 @@ import ( "strings" "time" + "github.com/insomniacslk/dhcp/dhcpv4" "github.com/insomniacslk/dhcp/dhcpv4/nclient4" "github.com/insomniacslk/dhcp/dhcpv6" "github.com/jetkvm/kvm/internal/network/types" @@ -66,6 +68,11 @@ func fromNclient4Lease(l *nclient4.Lease, iface string) *Lease { lease.ClassIdentifier = l.ACK.ClassIdentifier() lease.ServerID = l.ACK.ServerIdentifier().String() + mtu := l.ACK.Options.Get(dhcpv4.OptionInterfaceMTU) + if mtu != nil { + lease.MTU = int(binary.BigEndian.Uint16(mtu)) + } + lease.Message = l.ACK.Message() lease.LeaseTime = l.ACK.IPAddressLeaseTime(defaultLeaseTime) lease.RenewalTime = l.ACK.IPAddressRenewalTime(defaultRenewalTime) diff --git a/pkg/nmlite/manager.go b/pkg/nmlite/manager.go index dc04190f..2c69534a 100644 --- a/pkg/nmlite/manager.go +++ b/pkg/nmlite/manager.go @@ -23,6 +23,8 @@ type NetworkManager struct { ctx context.Context cancel context.CancelFunc + resolvConf *ResolvConfManager + // Callback functions for state changes onInterfaceStateChange func(iface string, state types.InterfaceState) onConfigChange func(iface string, config *types.NetworkConfig) @@ -45,9 +47,20 @@ func NewNetworkManager(ctx context.Context, logger *zerolog.Logger) *NetworkMana logger: logger, ctx: ctx, cancel: cancel, + resolvConf: NewResolvConfManager(logger), } } +// SetHostname sets the hostname and domain for the network manager +func (nm *NetworkManager) SetHostname(hostname string, domain string) error { + return nm.resolvConf.SetHostname(hostname, domain) +} + +// Domain returns the effective domain for the network manager +func (nm *NetworkManager) Domain() string { + return nm.resolvConf.Domain() +} + // AddInterface adds a new network interface to be managed func (nm *NetworkManager) AddInterface(iface string, config *types.NetworkConfig) error { nm.mu.Lock() @@ -81,6 +94,11 @@ func (nm *NetworkManager) AddInterface(iface string, config *types.NetworkConfig } }) + im.SetOnResolvConfChange(func(family int, resolvConf *types.InterfaceResolvConf) error { + nm.resolvConf.SetInterfaceConfig(iface, family, *resolvConf) + return nil + }) + nm.interfaces[iface] = im // Start monitoring the interface diff --git a/pkg/nmlite/resolvconf.go b/pkg/nmlite/resolvconf.go index 0502bda7..05ccc517 100644 --- a/pkg/nmlite/resolvconf.go +++ b/pkg/nmlite/resolvconf.go @@ -4,28 +4,29 @@ import ( "bytes" "fmt" "html/template" - "net" "os" "strings" "github.com/jetkvm/kvm/internal/network/types" + "github.com/jetkvm/kvm/internal/sync" + "github.com/jetkvm/kvm/pkg/nmlite/link" "github.com/rs/zerolog" ) const ( resolvConfPath = "/etc/resolv.conf" resolvConfFileMode = 0644 - resolvConfTemplate = `# the resolv.conf file is managed by the jetkvm network manager + resolvConfTemplate = `# the resolv.conf file is managed by JetKVM # DO NOT EDIT THIS FILE BY HAND -- YOUR CHANGES WILL BE OVERWRITTEN {{ if .searchList }} -search {{ join .searchList " " }} # {{ .iface }} +search {{ join .searchList " " }} {{- end -}} {{ if .domain }} -domain {{ .domain }} # {{ .iface }} +domain {{ .domain }} {{- end -}} -{{ range .nameservers }} -nameserver {{ printf "%s" . }} # {{ $.iface }} +{{ range $ns, $comment := .nameservers }} +nameserver {{ printf "%s" $ns }} # {{ join $comment ", " }} {{- end }} ` ) @@ -39,6 +40,11 @@ var ( // ResolvConfManager manages the resolv.conf file type ResolvConfManager struct { logger *zerolog.Logger + mu sync.Mutex + conf *types.ResolvConf + + hostname string + domain string } // NewResolvConfManager creates a new resolv.conf manager @@ -50,148 +56,150 @@ func NewResolvConfManager(logger *zerolog.Logger) *ResolvConfManager { return &ResolvConfManager{ logger: logger, + mu: sync.Mutex{}, + conf: &types.ResolvConf{ + ConfigIPv4: make(map[string]types.InterfaceResolvConf), + ConfigIPv6: make(map[string]types.InterfaceResolvConf), + }, } } -// UpdateFromLease updates resolv.conf from a DHCP lease -func (rcm *ResolvConfManager) UpdateFromLease(lease *types.DHCPLease) error { - if lease == nil { - return fmt.Errorf("lease cannot be nil") +// SetInterfaceConfig sets the resolv.conf configuration for a specific interface +func (rcm *ResolvConfManager) SetInterfaceConfig(iface string, family int, config types.InterfaceResolvConf) error { + // DO NOT USE defer HERE, rcm.update() also locks the mutex + rcm.mu.Lock() + switch family { + case link.AfInet: + rcm.conf.ConfigIPv4[iface] = config + case link.AfInet6: + rcm.conf.ConfigIPv6[iface] = config + default: + rcm.mu.Unlock() + return fmt.Errorf("invalid family: %d", family) } + rcm.mu.Unlock() - rcm.logger.Info(). - Str("interface", lease.InterfaceName). - Msg("updating resolv.conf from DHCP lease") + rcm.reconcileHostname() - return rcm.Update(lease.InterfaceName, lease.DNS, lease.SearchList, lease.Domain) + return rcm.update() } -// UpdateFromStaticConfig updates resolv.conf from static configuration -func (rcm *ResolvConfManager) UpdateFromStaticConfig(iface string, dns []string) error { - if len(dns) == 0 { - rcm.logger.Debug().Str("interface", iface).Msg("no DNS servers in static config") - return nil +// SetConfig sets the resolv.conf configuration +func (rcm *ResolvConfManager) SetConfig(resolvConf *types.ResolvConf) error { + if resolvConf == nil { + return fmt.Errorf("resolvConf cannot be nil") } - // Parse DNS servers - var dnsIPs []net.IP - for _, dnsStr := range dns { - dnsIP := net.ParseIP(dnsStr) - if dnsIP == nil { - rcm.logger.Warn().Str("dns", dnsStr).Msg("invalid DNS server, skipping") - continue - } - dnsIPs = append(dnsIPs, dnsIP) - } + rcm.mu.Lock() + rcm.conf = resolvConf + defer rcm.mu.Unlock() - if len(dnsIPs) == 0 { - rcm.logger.Debug().Str("interface", iface).Msg("no valid DNS servers in static config") - return nil - } + return rcm.update() +} - rcm.logger.Info(). - Str("interface", iface). - Interface("dns", dnsIPs). - Msg("updating resolv.conf from static config") - - return rcm.Update(iface, dnsIPs, nil, "") +// Reconcile reconciles the resolv.conf configuration +func (rcm *ResolvConfManager) Reconcile() error { + rcm.reconcileHostname() + return rcm.update() } // Update updates the resolv.conf file -func (rcm *ResolvConfManager) Update(iface string, nameservers []net.IP, searchList []string, domain string) error { - rcm.logger.Debug(). - Str("interface", iface). - Interface("nameservers", nameservers). - Interface("searchList", searchList). - Str("domain", domain). - Msg("updating resolv.conf") +func (rcm *ResolvConfManager) update() error { + rcm.mu.Lock() + defer rcm.mu.Unlock() + + rcm.logger.Debug().Msg("updating resolv.conf") // Generate resolv.conf content - content, err := rcm.generateResolvConf(iface, nameservers, searchList, domain) + content, err := rcm.generateResolvConf(rcm.conf) if err != nil { return fmt.Errorf("failed to generate resolv.conf: %w", err) } + // Check if the file is the same + if _, err := os.Stat(resolvConfPath); err == nil { + existingContent, err := os.ReadFile(resolvConfPath) + if err != nil { + rcm.logger.Warn().Err(err).Msg("failed to read existing resolv.conf") + } + + if bytes.Equal(existingContent, content) { + rcm.logger.Debug().Msg("resolv.conf is the same, skipping write") + return nil + } + } + // Write to file if err := os.WriteFile(resolvConfPath, content, resolvConfFileMode); err != nil { return fmt.Errorf("failed to write resolv.conf: %w", err) } rcm.logger.Info(). - Str("interface", iface). - Int("nameservers", len(nameservers)). + Interface("config", rcm.conf). Msg("resolv.conf updated successfully") return nil } +type configMap map[string][]string + +func mergeConfig(nameservers *configMap, searchList *configMap, config *types.InterfaceResolvConfMap) { + localNameservers := *nameservers + localSearchList := *searchList + + for ifname, iface := range *config { + comment := ifname + if iface.Source != "" { + comment += fmt.Sprintf(" (%s)", iface.Source) + } + + for _, ip := range iface.NameServers { + ns := ip.String() + if _, ok := localNameservers[ns]; !ok { + localNameservers[ns] = []string{} + } + localNameservers[ns] = append(localNameservers[ns], comment) + } + + for _, search := range iface.SearchList { + search = strings.Trim(search, ".") + if _, ok := localSearchList[search]; !ok { + localSearchList[search] = []string{} + } + localSearchList[search] = append(localSearchList[search], comment) + } + } + + *nameservers = localNameservers + *searchList = localSearchList +} + // generateResolvConf generates resolv.conf content -func (rcm *ResolvConfManager) generateResolvConf(iface string, nameservers []net.IP, searchList []string, domain string) ([]byte, error) { +func (rcm *ResolvConfManager) generateResolvConf(conf *types.ResolvConf) ([]byte, error) { tmpl, err := template.New("resolv.conf").Funcs(tplFuncMap).Parse(resolvConfTemplate) if err != nil { return nil, fmt.Errorf("failed to parse template: %w", err) } + // merge the nameservers and searchList + nameservers := configMap{} + searchList := configMap{} + + mergeConfig(&nameservers, &searchList, &conf.ConfigIPv4) + mergeConfig(&nameservers, &searchList, &conf.ConfigIPv6) + + flattenedSearchList := []string{} + for search := range searchList { + flattenedSearchList = append(flattenedSearchList, search) + } + var buf bytes.Buffer if err := tmpl.Execute(&buf, map[string]any{ - "iface": iface, "nameservers": nameservers, - "searchList": searchList, - "domain": domain, + "searchList": flattenedSearchList, }); err != nil { return nil, fmt.Errorf("failed to execute template: %w", err) } return buf.Bytes(), nil } - -// Clear clears the resolv.conf file (removes all entries) -func (rcm *ResolvConfManager) Clear() error { - rcm.logger.Info().Msg("clearing resolv.conf") - - content := []byte("# the resolv.conf file is managed by the jetkvm network manager\n# DO NOT EDIT THIS FILE BY HAND -- YOUR CHANGES WILL BE OVERWRITTEN\n") - - if err := os.WriteFile(resolvConfPath, content, resolvConfFileMode); err != nil { - return fmt.Errorf("failed to clear resolv.conf: %w", err) - } - - rcm.logger.Info().Msg("resolv.conf cleared") - return nil -} - -// GetCurrentContent returns the current content of resolv.conf -func (rcm *ResolvConfManager) GetCurrentContent() ([]byte, error) { - return os.ReadFile(resolvConfPath) -} - -// Backup creates a backup of the current resolv.conf -func (rcm *ResolvConfManager) Backup() error { - content, err := rcm.GetCurrentContent() - if err != nil { - return fmt.Errorf("failed to read current resolv.conf: %w", err) - } - - backupPath := resolvConfPath + ".backup" - if err := os.WriteFile(backupPath, content, resolvConfFileMode); err != nil { - return fmt.Errorf("failed to create backup: %w", err) - } - - rcm.logger.Info().Str("backup", backupPath).Msg("resolv.conf backed up") - return nil -} - -// Restore restores resolv.conf from backup -func (rcm *ResolvConfManager) Restore() error { - backupPath := resolvConfPath + ".backup" - content, err := os.ReadFile(backupPath) - if err != nil { - return fmt.Errorf("failed to read backup: %w", err) - } - - if err := os.WriteFile(resolvConfPath, content, resolvConfFileMode); err != nil { - return fmt.Errorf("failed to restore resolv.conf: %w", err) - } - - rcm.logger.Info().Str("backup", backupPath).Msg("resolv.conf restored from backup") - return nil -} diff --git a/pkg/nmlite/resolvconf_test.go b/pkg/nmlite/resolvconf_test.go deleted file mode 100644 index ddb91854..00000000 --- a/pkg/nmlite/resolvconf_test.go +++ /dev/null @@ -1,35 +0,0 @@ -package nmlite - -import ( - "net" - "testing" - - "github.com/stretchr/testify/assert" -) - -func TestToResolvConf(t *testing.T) { - rc, err := ResolvConfManager{}.generateResolvConf( - "eth0", - []net.IP{ - net.ParseIP("198.51.100.53"), - net.ParseIP("203.0.113.53"), - }, - []string{"example.com"}, - "example.com", - ) - if err != nil { - t.Fatal(err) - } - - want := `# the resolv.conf file is managed by the jetkvm network manager -# DO NOT EDIT THIS FILE BY HAND -- YOUR CHANGES WILL BE OVERWRITTEN - - -search example.com # eth0 -domain example.com # eth0 -nameserver 198.51.100.53 # eth0 -nameserver 203.0.113.53 # eth0 -` - - assert.Equal(t, want, rc.String()) -} diff --git a/pkg/nmlite/state.go b/pkg/nmlite/state.go index 517a44ac..bc6accae 100644 --- a/pkg/nmlite/state.go +++ b/pkg/nmlite/state.go @@ -20,12 +20,12 @@ func (nm *NetworkManager) IsUp() bool { return false } -func (nm *NetworkManager) GetHostname() string { - return "jetkvm" +func (nm *NetworkManager) Hostname() string { + return nm.resolvConf.Hostname() } -func (nm *NetworkManager) GetFQDN() string { - return "jetkvm.local" +func (nm *NetworkManager) FQDN() string { + return nm.resolvConf.FQDN() } func (nm *NetworkManager) NTPServers() []net.IP { From fe074b22530b491bd6ff237f5451f46afa105442 Mon Sep 17 00:00:00 2001 From: Siyuan Date: Fri, 10 Oct 2025 13:39:33 +0000 Subject: [PATCH 31/85] fix hostname --- internal/network/types/interface.go | 1 + pkg/nmlite/manager.go | 1 + ui/src/hooks/stores.ts | 1 + ui/src/routes/devices.$id.settings.network.tsx | 2 +- 4 files changed, 4 insertions(+), 1 deletion(-) diff --git a/internal/network/types/interface.go b/internal/network/types/interface.go index 07ae12d5..cea9620b 100644 --- a/internal/network/types/interface.go +++ b/internal/network/types/interface.go @@ -10,6 +10,7 @@ import ( // InterfaceState represents the current state of a network interface type InterfaceState struct { InterfaceName string `json:"interface_name"` + Hostname string `json:"hostname"` MACAddress string `json:"mac_address"` Up bool `json:"up"` Online bool `json:"online"` diff --git a/pkg/nmlite/manager.go b/pkg/nmlite/manager.go index 2c69534a..35b8224d 100644 --- a/pkg/nmlite/manager.go +++ b/pkg/nmlite/manager.go @@ -78,6 +78,7 @@ func (nm *NetworkManager) AddInterface(iface string, config *types.NetworkConfig // Set up callbacks im.SetOnStateChange(func(state types.InterfaceState) { if nm.onInterfaceStateChange != nil { + state.Hostname = nm.Hostname() nm.onInterfaceStateChange(iface, state) } }) diff --git a/ui/src/hooks/stores.ts b/ui/src/hooks/stores.ts index 1b8dae1b..5dcfb8e5 100644 --- a/ui/src/hooks/stores.ts +++ b/ui/src/hooks/stores.ts @@ -720,6 +720,7 @@ export interface NetworkState { ipv6_link_local?: string; ipv6_gateway?: string; dhcp_lease?: DhcpLease; + hostname?: string; setNetworkState: (state: NetworkState) => void; setDhcpLease: (lease: NetworkState["dhcp_lease"]) => void; diff --git a/ui/src/routes/devices.$id.settings.network.tsx b/ui/src/routes/devices.$id.settings.network.tsx index 621ff59c..b0e4487b 100644 --- a/ui/src/routes/devices.$id.settings.network.tsx +++ b/ui/src/routes/devices.$id.settings.network.tsx @@ -253,7 +253,7 @@ export default function SettingsNetworkRoute() { From b3ce961b79a46ad514e4f75c17fc192fa15a01e5 Mon Sep 17 00:00:00 2001 From: Siyuan Date: Fri, 10 Oct 2025 13:40:26 +0000 Subject: [PATCH 32/85] fix errcheck --- network.go | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/network.go b/network.go index bd155f81..02ccaee6 100644 --- a/network.go +++ b/network.go @@ -94,12 +94,16 @@ func validateNetworkConfig() { } networkLogger.Error().Err(err).Msg("failed to validate config, reverting to default config") - SaveBackupConfig() + if err := SaveBackupConfig(); err != nil { + networkLogger.Error().Err(err).Msg("failed to save backup config") + } // do not use a pointer to the default config // it has been already changed during LoadConfig config.NetworkConfig = &(types.NetworkConfig{}) - SaveConfig() + if err := SaveConfig(); err != nil { + networkLogger.Error().Err(err).Msg("failed to save config") + } } func initNetwork() error { From e47442d701c8dfa963b09be8283b7b19a85c59bd Mon Sep 17 00:00:00 2001 From: Siyuan Date: Fri, 10 Oct 2025 13:48:54 +0000 Subject: [PATCH 33/85] fix mtu --- pkg/nmlite/link/manager.go | 10 ++++++++++ pkg/nmlite/link/netlink.go | 7 +++++++ 2 files changed, 17 insertions(+) diff --git a/pkg/nmlite/link/manager.go b/pkg/nmlite/link/manager.go index fec9b075..5996bc66 100644 --- a/pkg/nmlite/link/manager.go +++ b/pkg/nmlite/link/manager.go @@ -444,12 +444,22 @@ func (nm *NetlinkManager) ReconcileLink(link *Link, expected []types.IPAddress, expectedGateways := make(map[string]net.IP) + mtu := link.Attrs().MTU + expectedMTU := mtu // add all expected addresses to the map for _, addr := range expected { expectedAddrs[addr.String()] = &addr if addr.Gateway != nil { expectedGateways[addr.String()] = addr.Gateway } + if addr.MTU != 0 { + mtu = addr.MTU + } + } + if expectedMTU != mtu { + if err := link.SetMTU(expectedMTU); err != nil { + nm.logger.Warn().Err(err).Int("expected_mtu", expectedMTU).Int("mtu", mtu).Msg("failed to set MTU") + } } addrs, err := nm.AddrList(link, family) diff --git a/pkg/nmlite/link/netlink.go b/pkg/nmlite/link/netlink.go index d726e4b9..12358d32 100644 --- a/pkg/nmlite/link/netlink.go +++ b/pkg/nmlite/link/netlink.go @@ -118,6 +118,13 @@ func (l *Link) AddrList(family int) ([]netlink.Addr, error) { return netlink.AddrList(l.Link, family) } +func (l *Link) SetMTU(mtu int) error { + l.mu.Lock() + defer l.mu.Unlock() + + return netlink.LinkSetMTU(l.Link, mtu) +} + // HasGlobalUnicastAddress returns true if the link has a global unicast address func (l *Link) HasGlobalUnicastAddress() bool { addrs, err := l.AddrList(AfUnspec) From 59b7141d844bad60407a3b1adf7415b9c33a00c2 Mon Sep 17 00:00:00 2001 From: Siyuan Date: Fri, 10 Oct 2025 13:56:07 +0000 Subject: [PATCH 34/85] fix lint errors --- pkg/nmlite/dhcp.go | 10 ++++++---- pkg/nmlite/hostname.go | 38 +++----------------------------------- pkg/nmlite/interface.go | 32 ++++++++++++++++++++++---------- pkg/nmlite/manager.go | 3 +-- pkg/nmlite/resolvconf.go | 8 ++++++-- pkg/nmlite/static.go | 6 ------ 6 files changed, 38 insertions(+), 59 deletions(-) diff --git a/pkg/nmlite/dhcp.go b/pkg/nmlite/dhcp.go index fb50d986..2a0c47b5 100644 --- a/pkg/nmlite/dhcp.go +++ b/pkg/nmlite/dhcp.go @@ -9,7 +9,6 @@ import ( "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 @@ -19,7 +18,6 @@ type DHCPClient struct { logger *zerolog.Logger client types.DHCPClient clientType string - link netlink.Link // Configuration ipv4Enabled bool @@ -181,7 +179,9 @@ func (dc *DHCPClient) Renew() error { } dc.logger.Info().Msg("renewing DHCP lease") - dc.client.Renew() + if err := dc.client.Renew(); err != nil { + return fmt.Errorf("failed to renew DHCP lease: %w", err) + } return nil } @@ -192,7 +192,9 @@ func (dc *DHCPClient) Release() error { } dc.logger.Info().Msg("releasing DHCP lease") - dc.client.Release() + if err := dc.client.Release(); err != nil { + return fmt.Errorf("failed to release DHCP lease: %w", err) + } return nil } diff --git a/pkg/nmlite/hostname.go b/pkg/nmlite/hostname.go index 5d220176..9083e088 100644 --- a/pkg/nmlite/hostname.go +++ b/pkg/nmlite/hostname.go @@ -256,38 +256,6 @@ func ToValidHostname(hostname string) string { // 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 -} + _, err := idna.Lookup.ToASCII(hostname) + return err +} \ No newline at end of file diff --git a/pkg/nmlite/interface.go b/pkg/nmlite/interface.go index 4dca6d8d..15ebb1d1 100644 --- a/pkg/nmlite/interface.go +++ b/pkg/nmlite/interface.go @@ -160,7 +160,9 @@ func (im *InterfaceManager) Stop() error { // Stop DHCP client if im.dhcpClient != nil { - im.dhcpClient.Stop() + if err := im.dhcpClient.Stop(); err != nil { + return fmt.Errorf("failed to stop DHCP client: %w", err) + } } im.logger.Info().Msg("interface manager stopped") @@ -241,7 +243,7 @@ func (im *InterfaceManager) GetIPv6Addresses() []string { addresses = append(addresses, addr.Address.String()) } - return []string{} + return addresses } // GetMACAddress returns the MAC address of the interface @@ -544,7 +546,9 @@ func (im *InterfaceManager) applyIPv6SLAACAndDHCP() error { // Enable both SLAAC and DHCPv6 if im.dhcpClient != nil { im.dhcpClient.SetIPv6(true) - im.dhcpClient.Start() + if err := im.dhcpClient.Start(); err != nil { + return fmt.Errorf("failed to start DHCP client: %w", err) + } } return im.staticConfig.EnableIPv6SLAAC() @@ -644,15 +648,23 @@ func (im *InterfaceManager) SendRouterSolicitation() error { func (im *InterfaceManager) handleLinkUp() { im.logger.Info().Msg("link up") - im.applyConfiguration() + if err := im.applyConfiguration(); err != nil { + im.logger.Error().Err(err).Msg("failed to apply configuration") + } if im.config.IPv4Mode.String == "dhcp" { - im.dhcpClient.Renew() + if err := im.dhcpClient.Renew(); err != nil { + im.logger.Error().Err(err).Msg("failed to renew DHCP lease") + } } if im.config.IPv6Mode.String == "slaac" { - im.staticConfig.EnableIPv6SLAAC() - im.SendRouterSolicitation() + if err := im.staticConfig.EnableIPv6SLAAC(); err != nil { + im.logger.Error().Err(err).Msg("failed to enable IPv6 SLAAC") + } + if err := im.SendRouterSolicitation(); err != nil { + im.logger.Error().Err(err).Msg("failed to send router solicitation") + } } } @@ -660,7 +672,9 @@ func (im *InterfaceManager) handleLinkDown() { im.logger.Info().Msg("link down") if im.config.IPv4Mode.String == "dhcp" { - im.dhcpClient.Stop() + if err := im.dhcpClient.Stop(); err != nil { + im.logger.Error().Err(err).Msg("failed to stop DHCP client") + } } netlinkMgr := getNetlinkManager() @@ -694,7 +708,6 @@ func (im *InterfaceManager) monitorInterfaceState() { } } } - } // updateStateFromDHCPLease updates the state from a DHCP lease @@ -707,7 +720,6 @@ func (im *InterfaceManager) updateStateFromDHCPLease(lease *types.DHCPLease) { family = link.AfInet6 } else { im.state.DHCPLease4 = lease - family = link.AfInet } im.stateMu.Unlock() diff --git a/pkg/nmlite/manager.go b/pkg/nmlite/manager.go index 35b8224d..92847176 100644 --- a/pkg/nmlite/manager.go +++ b/pkg/nmlite/manager.go @@ -96,8 +96,7 @@ func (nm *NetworkManager) AddInterface(iface string, config *types.NetworkConfig }) im.SetOnResolvConfChange(func(family int, resolvConf *types.InterfaceResolvConf) error { - nm.resolvConf.SetInterfaceConfig(iface, family, *resolvConf) - return nil + return nm.resolvConf.SetInterfaceConfig(iface, family, *resolvConf) }) nm.interfaces[iface] = im diff --git a/pkg/nmlite/resolvconf.go b/pkg/nmlite/resolvconf.go index 05ccc517..1bacee7a 100644 --- a/pkg/nmlite/resolvconf.go +++ b/pkg/nmlite/resolvconf.go @@ -79,7 +79,9 @@ func (rcm *ResolvConfManager) SetInterfaceConfig(iface string, family int, confi } rcm.mu.Unlock() - rcm.reconcileHostname() + if err := rcm.reconcileHostname(); err != nil { + return fmt.Errorf("failed to reconcile hostname: %w", err) + } return rcm.update() } @@ -99,7 +101,9 @@ func (rcm *ResolvConfManager) SetConfig(resolvConf *types.ResolvConf) error { // Reconcile reconciles the resolv.conf configuration func (rcm *ResolvConfManager) Reconcile() error { - rcm.reconcileHostname() + if err := rcm.reconcileHostname(); err != nil { + return fmt.Errorf("failed to reconcile hostname: %w", err) + } return rcm.update() } diff --git a/pkg/nmlite/static.go b/pkg/nmlite/static.go index e23424b1..9500556b 100644 --- a/pkg/nmlite/static.go +++ b/pkg/nmlite/static.go @@ -182,9 +182,3 @@ 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) -} From feec19ab1358c73622a5c0553931e263fbf30f90 Mon Sep 17 00:00:00 2001 From: Siyuan Date: Fri, 10 Oct 2025 13:59:11 +0000 Subject: [PATCH 35/85] fix ui lint errors --- ui/src/components/Ipv6NetworkCard.tsx | 1 + ui/src/routes/devices.$id.settings.network.tsx | 6 +++--- ui/src/utils/jsonrpc.ts | 2 +- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/ui/src/components/Ipv6NetworkCard.tsx b/ui/src/components/Ipv6NetworkCard.tsx index d34da739..a2a3d4da 100644 --- a/ui/src/components/Ipv6NetworkCard.tsx +++ b/ui/src/components/Ipv6NetworkCard.tsx @@ -1,4 +1,5 @@ import { cx } from "@/cva.config"; + import { NetworkState } from "../hooks/stores"; import { LifeTimeLabel } from "../routes/devices.$id.settings.network"; diff --git a/ui/src/routes/devices.$id.settings.network.tsx b/ui/src/routes/devices.$id.settings.network.tsx index b0e4487b..7ba65957 100644 --- a/ui/src/routes/devices.$id.settings.network.tsx +++ b/ui/src/routes/devices.$id.settings.network.tsx @@ -127,7 +127,7 @@ export default function SettingsNetworkRoute() { notifications.error(err instanceof Error ? err.message : "Unknown error"); throw err; } - }, []); + }, [setNetworkState]); const formMethods = useForm({ mode: "onBlur", @@ -155,7 +155,7 @@ export default function SettingsNetworkRoute() { const { register, handleSubmit, watch, formState, reset } = formMethods; const onSubmit = async (settings: NetworkSettings) => { - send("setNetworkSettings", { settings }, async (resp: any) => { + send("setNetworkSettings", { settings }, async (resp) => { if ("error" in resp) { return notifications.error( resp.error.data ? resp.error.data : resp.error.message, @@ -205,7 +205,7 @@ export default function SettingsNetworkRoute() { const ipv6mode = watch("ipv6_mode"); const onDhcpLeaseRenew = () => { - send("renewDHCPLease", {}, (resp: any) => { + send("renewDHCPLease", {}, (resp) => { if ("error" in resp) { notifications.error("Failed to renew lease: " + resp.error.message); } else { diff --git a/ui/src/utils/jsonrpc.ts b/ui/src/utils/jsonrpc.ts index 43ab7ec2..ecfa1c4b 100644 --- a/ui/src/utils/jsonrpc.ts +++ b/ui/src/utils/jsonrpc.ts @@ -41,7 +41,7 @@ export function callJsonRpc(options: JsonRpcCallOptions): Promise { try { From ed90e423244ebcf28a2b18b521a68d30f4467939 Mon Sep 17 00:00:00 2001 From: Siyuan Date: Fri, 10 Oct 2025 14:09:33 +0000 Subject: [PATCH 36/85] feat: change dhcp client --- network.go | 9 +++++++++ ui/src/hooks/stores.ts | 1 + ui/src/routes/devices.$id.settings.network.tsx | 11 +++++++++++ 3 files changed, 21 insertions(+) diff --git a/network.go b/network.go index 02ccaee6..cf6db9ba 100644 --- a/network.go +++ b/network.go @@ -157,6 +157,11 @@ func rpcSetNetworkSettings(settings RpcNetworkSettings) (*RpcNetworkSettings, er l.Debug().Msg("setting new config") + rebootRequired := false + if netConfig.DHCPClient.String != config.NetworkConfig.DHCPClient.String { + rebootRequired = true + } + _ = setHostname(networkManager, netConfig.Hostname.String, netConfig.Domain.String) s := networkManager.SetInterfaceConfig(NetIfName, netConfig) @@ -176,6 +181,10 @@ func rpcSetNetworkSettings(settings RpcNetworkSettings) (*RpcNetworkSettings, er return nil, err } + if rebootRequired { + rpcReboot(false) + } + return toRpcNetworkSettings(newConfig), nil } diff --git a/ui/src/hooks/stores.ts b/ui/src/hooks/stores.ts index 5dcfb8e5..796682be 100644 --- a/ui/src/hooks/stores.ts +++ b/ui/src/hooks/stores.ts @@ -759,6 +759,7 @@ export interface IPv6StaticConfig { } export interface NetworkSettings { + dhcp_client: string; hostname: string | null; domain: string | null; http_proxy: string | null; diff --git a/ui/src/routes/devices.$id.settings.network.tsx b/ui/src/routes/devices.$id.settings.network.tsx index 7ba65957..fde49ee3 100644 --- a/ui/src/routes/devices.$id.settings.network.tsx +++ b/ui/src/routes/devices.$id.settings.network.tsx @@ -334,6 +334,17 @@ export default function SettingsNetworkRoute() { /> + + + + Date: Fri, 10 Oct 2025 14:12:09 +0000 Subject: [PATCH 37/85] fix lint errors --- internal/network/types/ip.go | 2 +- pkg/nmlite/hostname.go | 2 +- pkg/nmlite/jetdhcpc/dhcp4.go | 2 +- pkg/nmlite/jetdhcpc/dhcp6.go | 2 +- pkg/nmlite/link/manager.go | 6 ++++-- 5 files changed, 8 insertions(+), 6 deletions(-) diff --git a/internal/network/types/ip.go b/internal/network/types/ip.go index 293e7e3f..b0a07bb3 100644 --- a/internal/network/types/ip.go +++ b/internal/network/types/ip.go @@ -26,7 +26,7 @@ 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 { + if slices.Compare(a.Address.Mask, n.Mask) != 0 { return false } return true diff --git a/pkg/nmlite/hostname.go b/pkg/nmlite/hostname.go index 9083e088..a75aaf4f 100644 --- a/pkg/nmlite/hostname.go +++ b/pkg/nmlite/hostname.go @@ -258,4 +258,4 @@ func ToValidHostname(hostname string) string { func ValidateHostname(hostname string) error { _, err := idna.Lookup.ToASCII(hostname) return err -} \ No newline at end of file +} diff --git a/pkg/nmlite/jetdhcpc/dhcp4.go b/pkg/nmlite/jetdhcpc/dhcp4.go index afa10a4a..5de74245 100644 --- a/pkg/nmlite/jetdhcpc/dhcp4.go +++ b/pkg/nmlite/jetdhcpc/dhcp4.go @@ -31,7 +31,7 @@ func (c *Client) requestLease4(ifname string) (*Lease, error) { } defer client.Close() - // Prepend modifiers with default options, so they can be overriden. + // Prepend modifiers with default options, so they can be overridden. reqmods := append( []dhcpv4.Modifier{ dhcpv4.WithOption(dhcpv4.OptClassIdentifier(VendorIdentifier)), diff --git a/pkg/nmlite/jetdhcpc/dhcp6.go b/pkg/nmlite/jetdhcpc/dhcp6.go index 6eddde25..49faf6f0 100644 --- a/pkg/nmlite/jetdhcpc/dhcp6.go +++ b/pkg/nmlite/jetdhcpc/dhcp6.go @@ -117,7 +117,7 @@ func (c *Client) requestLease6(ifname string) (*Lease, error) { } defer client.Close() - // Prepend modifiers with default options, so they can be overriden. + // Prepend modifiers with default options, so they can be overridden. reqmods := append( []dhcpv6.Modifier{ dhcpv6.WithNetboot, diff --git a/pkg/nmlite/link/manager.go b/pkg/nmlite/link/manager.go index 5996bc66..4cf79046 100644 --- a/pkg/nmlite/link/manager.go +++ b/pkg/nmlite/link/manager.go @@ -74,7 +74,9 @@ 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) + if err := netlink.LinkSubscribe(updateCh, stopCh); err != nil { + nm.logger.Error().Err(err).Msg("failed to subscribe to link state changes") + } nm.logger.Info().Msg("state change monitoring started") @@ -153,7 +155,7 @@ func (nm *NetlinkManager) EnsureInterfaceUpWithTimeout(ctx context.Context, ifac linkUpTimeout := time.After(timeout) - attempt := 0 + var attempt int start := time.Now() for { From bb45be1d6df2eab44dca4a61a907cd579361c615 Mon Sep 17 00:00:00 2001 From: Siyuan Date: Fri, 10 Oct 2025 14:19:36 +0000 Subject: [PATCH 38/85] fix golang lint errors again --- network.go | 6 ++++-- pkg/nmlite/link/manager.go | 4 +++- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/network.go b/network.go index cf6db9ba..61350bd5 100644 --- a/network.go +++ b/network.go @@ -157,7 +157,7 @@ func rpcSetNetworkSettings(settings RpcNetworkSettings) (*RpcNetworkSettings, er l.Debug().Msg("setting new config") - rebootRequired := false + var rebootRequired bool if netConfig.DHCPClient.String != config.NetworkConfig.DHCPClient.String { rebootRequired = true } @@ -182,7 +182,9 @@ func rpcSetNetworkSettings(settings RpcNetworkSettings) (*RpcNetworkSettings, er } if rebootRequired { - rpcReboot(false) + if err := rpcReboot(false); err != nil { + return nil, err + } } return toRpcNetworkSettings(newConfig), nil diff --git a/pkg/nmlite/link/manager.go b/pkg/nmlite/link/manager.go index 4cf79046..2f1bc8b6 100644 --- a/pkg/nmlite/link/manager.go +++ b/pkg/nmlite/link/manager.go @@ -210,7 +210,9 @@ func (nm *NetlinkManager) EnsureInterfaceUpWithTimeout(ctx context.Context, ifac return nil, ErrInterfaceUpCanceled case <-linkUpTimeout: attempt++ - l.Error().Msg("interface is still down after timeout") + l.Error(). + Int("attempt", attempt). + Msg("interface is still down after timeout") if err != nil { return nil, err } From 1ad44ed461aafb61ac046353c021e1926fd2398b Mon Sep 17 00:00:00 2001 From: Siyuan Date: Fri, 10 Oct 2025 14:42:23 +0000 Subject: [PATCH 39/85] allow to toggle dhcp client via touchscreen --- display.go | 7 + internal/native/eez/jetkvm.eez-project | 854 ++++++++++++++++++++++++- internal/native/eez/src/ui/actions.c | 20 + internal/native/eez/src/ui/actions.h | 1 + internal/native/eez/src/ui/screens.c | 237 +++++++ internal/native/eez/src/ui/screens.h | 14 + native.go | 2 + network.go | 11 + 8 files changed, 1144 insertions(+), 2 deletions(-) diff --git a/display.go b/display.go index cab87ff3..220c8055 100644 --- a/display.go +++ b/display.go @@ -43,6 +43,13 @@ func updateDisplay() { nativeInstance.UpdateLabelIfChanged("home_info_mac_addr", networkManager.MACString()) + switch config.NetworkConfig.DHCPClient.String { + case "jetdhcpc": + nativeInstance.UpdateLabelIfChanged("dhcp_client_change_label", "Change to udhcpc") + case "udhcpc": + nativeInstance.UpdateLabelIfChanged("dhcp_client_change_label", "Change to JetKVM") + } + if usbState == "configured" { nativeInstance.UpdateLabelIfChanged("usb_status_label", "Connected") _, _ = nativeInstance.UIObjAddState("usb_status_label", "LV_STATE_CHECKED") diff --git a/internal/native/eez/jetkvm.eez-project b/internal/native/eez/jetkvm.eez-project index 0ad49e76..5d09cb25 100644 --- a/internal/native/eez/jetkvm.eez-project +++ b/internal/native/eez/jetkvm.eez-project @@ -48,7 +48,7 @@ { "objID": "58af3ebb-96b3-494c-f4e3-9c23852e3e42", "fileName": "actions.c", - "template": "#include \"actions.h\"\n#include \"screens.h\"\n#include \n#include \n#include \"ui.h\"\n#include \"vars.h\"\n\nint handle_gesture_screen_switch(lv_event_t *e, lv_dir_t direction, int screenId) {\n lv_event_code_t event_code = lv_event_get_code(e);\n if (event_code != LV_EVENT_GESTURE) {\n return 0;\n }\n\n if (lv_indev_get_gesture_dir(lv_indev_get_act()) != direction) {\n return 0;\n }\n lv_indev_wait_release(lv_indev_get_act());\n loadScreen(screenId);\n return 1;\n}\n\nvoid handle_gesture_main_screen_switch(lv_event_t *e, lv_dir_t direction) {\n const char *main_screen = get_var_main_screen();\n if (strcmp(main_screen, \"home_screen\") == 0) { \n loadScreen(SCREEN_ID_HOME_SCREEN);\n } else if (strcmp(main_screen, \"no_network_screen\") == 0) {\n loadScreen(SCREEN_ID_NO_NETWORK_SCREEN);\n }\n}\n\nvoid action_switch_to_menu(lv_event_t *e) {\n loadScreen(SCREEN_ID_MENU_SCREEN);\n}\n\nvoid action_switch_to_advanced_menu(lv_event_t *e) {\n loadScreen(SCREEN_ID_MENU_ADVANCED_SCREEN);\n}\n\nvoid action_switch_to_status(lv_event_t *e) {\n loadScreen(SCREEN_ID_STATUS_SCREEN);\n}\n\nvoid action_switch_to_about(lv_event_t *e) {\n loadScreen(SCREEN_ID_ABOUT_SCREEN);\n}\n\nvoid action_switch_to_reset_config(lv_event_t *e) {\n loadScreen(SCREEN_ID_RESET_CONFIG_SCREEN);\n}\n\nvoid action_switch_to_reboot(lv_event_t *e) {\n loadScreen(SCREEN_ID_REBOOT_SCREEN);\n}\n\nvoid action_menu_screen_gesture(lv_event_t * e) {\n handle_gesture_main_screen_switch(e, LV_DIR_RIGHT);\n}\n\nvoid action_menu_advanced_screen_gesture(lv_event_t * e) {\n handle_gesture_screen_switch(e, LV_DIR_RIGHT, SCREEN_ID_MENU_SCREEN);\n}\n\nvoid action_reset_config_screen_gesture(lv_event_t * e) {\n handle_gesture_screen_switch(e, LV_DIR_RIGHT, SCREEN_ID_MENU_SCREEN);\n}\n\nvoid action_home_screen_gesture(lv_event_t * e) {\n handle_gesture_screen_switch(e, LV_DIR_LEFT, SCREEN_ID_MENU_SCREEN);\n}\n\nvoid action_about_screen_gesture(lv_event_t * e) {\n handle_gesture_screen_switch(e, LV_DIR_RIGHT, SCREEN_ID_MENU_SCREEN);\n}\n\n// user_data doesn't seem to be working, so we use a global variable here\nstatic uint32_t t_reset_config;\nstatic uint32_t t_reboot;\n\nstatic bool b_reboot = false;\nstatic bool b_reset_config = false;\n\nstatic bool b_reboot_lock = false;\nstatic bool b_reset_config_lock = false;\n\nconst int RESET_CONFIG_HOLD_TIME = 10;\nconst int REBOOT_HOLD_TIME = 5;\n\ntypedef struct {\n uint32_t *start_time;\n bool *completed;\n bool *lock;\n int hold_time_seconds;\n const char *rpc_method;\n lv_obj_t *button_obj;\n lv_obj_t *spinner_obj;\n lv_obj_t *label_obj;\n const char *default_text;\n} hold_action_config_t;\n\nstatic void handle_hold_action(lv_event_t *e, hold_action_config_t *config) {\n lv_event_code_t event_code = lv_event_get_code(e);\n \n if (event_code == LV_EVENT_PRESSED) {\n *(config->start_time) = lv_tick_get();\n }\n else if (event_code == LV_EVENT_PRESSING) {\n int remaining_time = config->hold_time_seconds * 1000 - lv_tick_elaps(*(config->start_time));\n if (remaining_time <= 0) {\n if (*(config->lock)) {\n return;\n }\n if (config->button_obj && config->spinner_obj) {\n lv_obj_add_flag(config->button_obj, LV_OBJ_FLAG_HIDDEN);\n lv_obj_clear_flag(config->spinner_obj, LV_OBJ_FLAG_HIDDEN);\n }\n ui_call_rpc_handler(config->rpc_method, NULL);\n *(config->lock) = true;\n *(config->completed) = true;\n } else {\n *(config->completed) = false;\n char buf[100];\n int remaining_time_seconds = remaining_time / 1000;\n if (remaining_time_seconds <= 1) {\n remaining_time_seconds = 1;\n }\n sprintf(buf, \"Press and hold for\\n%d seconds\", remaining_time_seconds);\n lv_label_set_text(config->label_obj, buf);\n }\n } else if (event_code == LV_EVENT_RELEASED) {\n if (*(config->lock)) {\n *(config->lock) = false;\n }\n\n if (!*(config->completed)) {\n lv_label_set_text(config->label_obj, config->default_text);\n }\n }\n}\n\nvoid action_reset_config(lv_event_t * e) {\n hold_action_config_t config = {\n .start_time = &t_reset_config,\n .completed = &b_reset_config,\n .lock = &b_reset_config_lock,\n .hold_time_seconds = RESET_CONFIG_HOLD_TIME,\n .rpc_method = \"resetConfig\",\n .button_obj = objects.reset_config_button,\n .spinner_obj = objects.reset_config_spinner,\n .label_obj = objects.reset_config_label,\n .default_text = \"Press and hold for\\n10 seconds\"\n };\n \n handle_hold_action(e, &config);\n}\n\nvoid action_reboot(lv_event_t * e) {\n hold_action_config_t config = {\n .start_time = &t_reboot,\n .completed = &b_reboot,\n .lock = &b_reboot_lock,\n .hold_time_seconds = REBOOT_HOLD_TIME,\n .rpc_method = \"reboot\",\n .button_obj = NULL, // No button/spinner for reboot\n .spinner_obj = NULL,\n .label_obj = objects.reboot_label,\n .default_text = \"Press and hold for\\n5 seconds\"\n };\n \n handle_hold_action(e, &config);\n}" + "template": "#include \"actions.h\"\n#include \"screens.h\"\n#include \n#include \n#include \"ui.h\"\n#include \"vars.h\"\n\nint handle_gesture_screen_switch(lv_event_t *e, lv_dir_t direction, int screenId) {\n lv_event_code_t event_code = lv_event_get_code(e);\n if (event_code != LV_EVENT_GESTURE) {\n return 0;\n }\n\n if (lv_indev_get_gesture_dir(lv_indev_get_act()) != direction) {\n return 0;\n }\n lv_indev_wait_release(lv_indev_get_act());\n loadScreen(screenId);\n return 1;\n}\n\nvoid handle_gesture_main_screen_switch(lv_event_t *e, lv_dir_t direction) {\n const char *main_screen = get_var_main_screen();\n if (strcmp(main_screen, \"home_screen\") == 0) { \n loadScreen(SCREEN_ID_HOME_SCREEN);\n } else if (strcmp(main_screen, \"no_network_screen\") == 0) {\n loadScreen(SCREEN_ID_NO_NETWORK_SCREEN);\n }\n}\n\nvoid action_switch_to_menu(lv_event_t *e) {\n loadScreen(SCREEN_ID_MENU_SCREEN);\n}\n\nvoid action_switch_to_advanced_menu(lv_event_t *e) {\n loadScreen(SCREEN_ID_MENU_ADVANCED_SCREEN);\n}\n\nvoid action_switch_to_status(lv_event_t *e) {\n loadScreen(SCREEN_ID_STATUS_SCREEN);\n}\n\nvoid action_switch_to_about(lv_event_t *e) {\n loadScreen(SCREEN_ID_ABOUT_SCREEN);\n}\n\nvoid action_switch_to_reset_config(lv_event_t *e) {\n loadScreen(SCREEN_ID_RESET_CONFIG_SCREEN);\n}\n\nvoid action_switch_to_reboot(lv_event_t *e) {\n loadScreen(SCREEN_ID_REBOOT_SCREEN);\n}\n\nvoid action_menu_screen_gesture(lv_event_t * e) {\n handle_gesture_main_screen_switch(e, LV_DIR_RIGHT);\n}\n\nvoid action_menu_advanced_screen_gesture(lv_event_t * e) {\n handle_gesture_screen_switch(e, LV_DIR_RIGHT, SCREEN_ID_MENU_SCREEN);\n}\n\nvoid action_reset_config_screen_gesture(lv_event_t * e) {\n handle_gesture_screen_switch(e, LV_DIR_RIGHT, SCREEN_ID_MENU_SCREEN);\n}\n\nvoid action_home_screen_gesture(lv_event_t * e) {\n handle_gesture_screen_switch(e, LV_DIR_LEFT, SCREEN_ID_MENU_SCREEN);\n}\n\nvoid action_about_screen_gesture(lv_event_t * e) {\n handle_gesture_screen_switch(e, LV_DIR_RIGHT, SCREEN_ID_MENU_SCREEN);\n}\n\n// user_data doesn't seem to be working, so we use a global variable here\nstatic uint32_t t_reset_config;\nstatic uint32_t t_reboot;\nstatic uint32_t t_dhcpc;\n\nstatic bool b_reboot = false;\nstatic bool b_reset_config = false;\nstatic bool b_dhcpc = false;\n\nstatic bool b_reboot_lock = false;\nstatic bool b_reset_config_lock = false;\nstatic bool b_dhcpc_lock = false;\n\nconst int RESET_CONFIG_HOLD_TIME = 10;\nconst int REBOOT_HOLD_TIME = 5;\nconst int DHCPC_HOLD_TIME = 5;\n\ntypedef struct {\n uint32_t *start_time;\n bool *completed;\n bool *lock;\n int hold_time_seconds;\n const char *rpc_method;\n lv_obj_t *button_obj;\n lv_obj_t *spinner_obj;\n lv_obj_t *label_obj;\n const char *default_text;\n} hold_action_config_t;\n\nstatic void handle_hold_action(lv_event_t *e, hold_action_config_t *config) {\n lv_event_code_t event_code = lv_event_get_code(e);\n \n if (event_code == LV_EVENT_PRESSED) {\n *(config->start_time) = lv_tick_get();\n }\n else if (event_code == LV_EVENT_PRESSING) {\n int remaining_time = config->hold_time_seconds * 1000 - lv_tick_elaps(*(config->start_time));\n if (remaining_time <= 0) {\n if (*(config->lock)) {\n return;\n }\n if (config->button_obj && config->spinner_obj) {\n lv_obj_add_flag(config->button_obj, LV_OBJ_FLAG_HIDDEN);\n lv_obj_clear_flag(config->spinner_obj, LV_OBJ_FLAG_HIDDEN);\n }\n ui_call_rpc_handler(config->rpc_method, NULL);\n *(config->lock) = true;\n *(config->completed) = true;\n } else {\n *(config->completed) = false;\n char buf[100];\n int remaining_time_seconds = remaining_time / 1000;\n if (remaining_time_seconds <= 1) {\n remaining_time_seconds = 1;\n }\n sprintf(buf, \"Press and hold for\\n%d seconds\", remaining_time_seconds);\n lv_label_set_text(config->label_obj, buf);\n }\n } else if (event_code == LV_EVENT_RELEASED) {\n if (*(config->lock)) {\n *(config->lock) = false;\n }\n\n if (!*(config->completed)) {\n lv_label_set_text(config->label_obj, config->default_text);\n }\n }\n}\n\nvoid action_reset_config(lv_event_t * e) {\n hold_action_config_t config = {\n .start_time = &t_reset_config,\n .completed = &b_reset_config,\n .lock = &b_reset_config_lock,\n .hold_time_seconds = RESET_CONFIG_HOLD_TIME,\n .rpc_method = \"resetConfig\",\n .button_obj = objects.reset_config_button,\n .spinner_obj = objects.reset_config_spinner,\n .label_obj = objects.reset_config_label,\n .default_text = \"Press and hold for\\n10 seconds\"\n };\n \n handle_hold_action(e, &config);\n}\n\nvoid action_dhcpc(lv_event_t * e) {\n hold_action_config_t config = {\n .start_time = &t_dhcpc,\n .completed = &b_dhcpc,\n .lock = &b_dhcpc_lock,\n .hold_time_seconds = DHCPC_HOLD_TIME,\n .rpc_method = \"toggleDHCPClient\",\n .button_obj = NULL, // No button/spinner for reboot\n .spinner_obj = NULL,\n .label_obj = objects.dhcpc_label,\n .default_text = \"Press and hold for\\n5 seconds\"\n };\n \n handle_hold_action(e, &config);\n}\n\nvoid action_reboot(lv_event_t * e) {\n hold_action_config_t config = {\n .start_time = &t_reboot,\n .completed = &b_reboot,\n .lock = &b_reboot_lock,\n .hold_time_seconds = REBOOT_HOLD_TIME,\n .rpc_method = \"reboot\",\n .button_obj = NULL, // No button/spinner for reboot\n .spinner_obj = NULL,\n .label_obj = objects.reboot_label,\n .default_text = \"Press and hold for\\n5 seconds\"\n };\n \n handle_hold_action(e, &config);\n}" }, { "objID": "1dbd1b7e-7270-47f0-ee02-e80bdae287cf", @@ -273,6 +273,14 @@ "localVariables": [], "userProperties": [], "name": "SwitchToReboot" + }, + { + "objID": "3efa8142-0c2d-4b63-e75e-8ff57d132f5e", + "components": [], + "connectionLines": [], + "localVariables": [], + "userProperties": [], + "name": "DHCPC" } ], "userPages": [ @@ -3038,7 +3046,7 @@ "left": 0, "top": 0, "width": 100, - "height": 108, + "height": 166, "customInputs": [], "customOutputs": [], "style": { @@ -3336,6 +3344,104 @@ "group": "", "groupIndex": 0 }, + { + "objID": "3a5c0243-2877-4e9f-f23f-8022eeb772cd", + "type": "LVGLButtonWidget", + "left": 0, + "top": 0, + "width": 100, + "height": 50, + "customInputs": [], + "customOutputs": [], + "style": { + "objID": "31a3a5fe-d9e6-42ba-dffe-916f7d247168", + "useStyle": "default", + "conditionalStyles": [], + "childStyles": [] + }, + "locked": false, + "hiddenInEditor": false, + "timeline": [], + "eventHandlers": [ + { + "objID": "6a730d9a-c11d-4800-9833-f4d7b5a8a40d", + "eventName": "PRESSED", + "handlerType": "action", + "action": "SwitchToReboot", + "userData": 0 + } + ], + "identifier": "MenuBtnDHCPClient", + "leftUnit": "px", + "topUnit": "px", + "widthUnit": "%", + "heightUnit": "px", + "children": [ + { + "objID": "929d0861-7f4d-43f0-ce71-07fafb9ac719", + "type": "LVGLLabelWidget", + "left": 0, + "top": 0, + "width": 115, + "height": 20, + "customInputs": [], + "customOutputs": [], + "style": { + "objID": "704ef898-9f7c-43a8-ab11-c48b4af1e864", + "useStyle": "default", + "conditionalStyles": [], + "childStyles": [] + }, + "hiddenInEditor": false, + "timeline": [], + "eventHandlers": [], + "identifier": "", + "leftUnit": "px", + "topUnit": "px", + "widthUnit": "content", + "heightUnit": "content", + "children": [], + "widgetFlags": "CLICK_FOCUSABLE|GESTURE_BUBBLE|PRESS_LOCK|SCROLLABLE|SCROLL_CHAIN_HOR|SCROLL_CHAIN_VER|SCROLL_ELASTIC|SCROLL_MOMENTUM|SCROLL_WITH_ARROW|SNAPPABLE", + "hiddenFlagType": "literal", + "clickableFlagType": "literal", + "flagScrollbarMode": "", + "flagScrollDirection": "", + "scrollSnapX": "", + "scrollSnapY": "", + "checkedStateType": "literal", + "disabledStateType": "literal", + "states": "", + "useStyle": "MenuButtonLabel", + "localStyles": { + "objID": "5144f730-9924-47ca-bfb9-c02449de88be" + }, + "group": "", + "groupIndex": 0, + "text": "DHCP Client", + "textType": "literal", + "longMode": "WRAP", + "recolor": false + } + ], + "widgetFlags": "CLICK_FOCUSABLE|GESTURE_BUBBLE|PRESS_LOCK|SCROLL_CHAIN_HOR|SCROLL_CHAIN_VER|SCROLL_ELASTIC|SCROLL_MOMENTUM|SCROLL_ON_FOCUS|SCROLL_WITH_ARROW", + "hiddenFlag": false, + "hiddenFlagType": "literal", + "clickableFlag": true, + "clickableFlagType": "literal", + "flagScrollbarMode": "", + "flagScrollDirection": "", + "scrollSnapX": "", + "scrollSnapY": "", + "checkedStateType": "literal", + "disabledStateType": "literal", + "states": "", + "useStyle": "MenuButton", + "localStyles": { + "objID": "2d17602a-58eb-4a21-9aff-c1f1a7d09bfe" + }, + "group": "", + "groupIndex": 0 + }, { "objID": "1077686a-8cc3-44f5-f09e-000d5e0ef066", "type": "LVGLButtonWidget", @@ -8094,6 +8200,750 @@ "isUsedAsUserWidget": false, "createAtStart": true, "deleteOnScreenUnload": false + }, + { + "objID": "606eb261-e593-41e5-d86b-6487f467eea9", + "components": [ + { + "objID": "61b8bea9-a21d-4498-c0ed-14a4bd2a4858", + "type": "LVGLScreenWidget", + "left": 0, + "top": 0, + "width": 300, + "height": 240, + "customInputs": [], + "customOutputs": [], + "style": { + "objID": "938e0598-29de-4807-b736-8bcda0e9d417", + "useStyle": "default", + "conditionalStyles": [], + "childStyles": [] + }, + "timeline": [], + "eventHandlers": [ + { + "objID": "eeb49a6a-3cfe-4372-8706-b6734bbbceec", + "eventName": "GESTURE", + "handlerType": "action", + "action": "AboutScreenGesture", + "userData": 0 + } + ], + "leftUnit": "px", + "topUnit": "px", + "widthUnit": "px", + "heightUnit": "px", + "children": [ + { + "objID": "0f3e9d37-63ca-45b0-bd92-13fbf1ecf63f", + "type": "LVGLContainerWidget", + "left": 0, + "top": 0, + "width": 100, + "height": 100, + "customInputs": [], + "customOutputs": [], + "style": { + "objID": "e2636ce4-0cbd-4646-a676-26efc3cd3bed", + "useStyle": "default", + "conditionalStyles": [], + "childStyles": [] + }, + "timeline": [], + "eventHandlers": [], + "identifier": "", + "leftUnit": "px", + "topUnit": "px", + "widthUnit": "%", + "heightUnit": "%", + "children": [ + { + "objID": "fd6de3f1-da9d-4bf9-c789-ba4b4b7e6f98", + "type": "LVGLContainerWidget", + "left": 0, + "top": 0, + "width": 100, + "height": 32, + "customInputs": [], + "customOutputs": [], + "style": { + "objID": "6138f675-e136-435f-890d-5841a1faa447", + "useStyle": "default", + "conditionalStyles": [], + "childStyles": [] + }, + "timeline": [], + "eventHandlers": [], + "identifier": "DHCPClientHeader", + "leftUnit": "px", + "topUnit": "px", + "widthUnit": "%", + "heightUnit": "content", + "children": [ + { + "objID": "edd2bee0-2bfa-4db7-930c-2445899a3d7f", + "type": "LVGLButtonWidget", + "left": 0, + "top": 0, + "width": 32, + "height": 32, + "customInputs": [], + "customOutputs": [], + "style": { + "objID": "34a0ab43-9134-405d-d30b-a60c487a18a0", + "useStyle": "default", + "conditionalStyles": [], + "childStyles": [] + }, + "timeline": [], + "eventHandlers": [ + { + "objID": "cfbd687f-29e2-4c6a-de27-f8d10dceeeb8", + "eventName": "CLICKED", + "handlerType": "action", + "action": "SwitchToMenu", + "userData": 0 + } + ], + "leftUnit": "px", + "topUnit": "px", + "widthUnit": "px", + "heightUnit": "px", + "children": [ + { + "objID": "9d5fdeca-711b-4c7d-e9ce-35f506095573", + "type": "LVGLImageWidget", + "left": -1, + "top": 2, + "width": 8, + "height": 12, + "customInputs": [], + "customOutputs": [], + "style": { + "objID": "3dbbc13f-48bc-47d3-cfa1-865309068b4b", + "useStyle": "default", + "conditionalStyles": [], + "childStyles": [] + }, + "timeline": [], + "eventHandlers": [], + "leftUnit": "px", + "topUnit": "px", + "widthUnit": "content", + "heightUnit": "content", + "children": [], + "widgetFlags": "ADV_HITTEST|CLICK_FOCUSABLE|GESTURE_BUBBLE|PRESS_LOCK|SCROLLABLE|SCROLL_CHAIN_HOR|SCROLL_CHAIN_VER|SCROLL_ELASTIC|SCROLL_MOMENTUM|SCROLL_WITH_ARROW|SNAPPABLE", + "hiddenFlagType": "literal", + "clickableFlagType": "literal", + "flagScrollbarMode": "", + "flagScrollDirection": "", + "scrollSnapX": "", + "scrollSnapY": "", + "checkedStateType": "literal", + "disabledStateType": "literal", + "states": "", + "localStyles": { + "objID": "3864abb7-e6ea-4188-cc23-99583e940dde" + }, + "group": "", + "groupIndex": 0, + "image": "back-caret", + "setPivot": false, + "pivotX": 0, + "pivotY": 0, + "zoom": 256, + "angle": 0, + "innerAlign": "CENTER" + } + ], + "widgetFlags": "CLICK_FOCUSABLE|GESTURE_BUBBLE|PRESS_LOCK|SCROLL_CHAIN_HOR|SCROLL_CHAIN_VER|SCROLL_ELASTIC|SCROLL_MOMENTUM|SCROLL_ON_FOCUS|SCROLL_WITH_ARROW|SNAPPABLE", + "hiddenFlagType": "literal", + "clickableFlag": true, + "clickableFlagType": "literal", + "flagScrollbarMode": "", + "flagScrollDirection": "", + "scrollSnapX": "", + "scrollSnapY": "", + "checkedStateType": "literal", + "disabledStateType": "literal", + "states": "", + "useStyle": "BackButton", + "localStyles": { + "objID": "04d66c1f-9518-4905-bd8f-25f99bb40708" + }, + "group": "", + "groupIndex": 0 + }, + { + "objID": "bde5dd77-e61f-4398-f76c-b32d204a1532", + "type": "LVGLLabelWidget", + "left": 0, + "top": 0, + "width": 116, + "height": 20, + "customInputs": [], + "customOutputs": [], + "style": { + "objID": "6cacf93f-877f-46b2-cc3a-85d25ed6725c", + "useStyle": "default", + "conditionalStyles": [], + "childStyles": [] + }, + "timeline": [], + "eventHandlers": [], + "identifier": "", + "leftUnit": "%", + "topUnit": "%", + "widthUnit": "content", + "heightUnit": "content", + "children": [], + "widgetFlags": "CLICK_FOCUSABLE|GESTURE_BUBBLE|PRESS_LOCK|SCROLLABLE|SCROLL_CHAIN_HOR|SCROLL_CHAIN_VER|SCROLL_ELASTIC|SCROLL_MOMENTUM|SCROLL_WITH_ARROW|SNAPPABLE", + "hiddenFlagType": "literal", + "clickableFlagType": "literal", + "checkedStateType": "literal", + "disabledStateType": "literal", + "states": "", + "useStyle": "HeaderLink", + "localStyles": { + "objID": "0ac01432-b282-41c4-b119-316fe81ad79b" + }, + "groupIndex": 0, + "text": "Reset Config", + "textType": "literal", + "longMode": "WRAP", + "recolor": false + } + ], + "widgetFlags": "CLICK_FOCUSABLE|GESTURE_BUBBLE|PRESS_LOCK|SCROLLABLE|SCROLL_CHAIN_HOR|SCROLL_CHAIN_VER|SCROLL_ELASTIC|SCROLL_MOMENTUM|SCROLL_WITH_ARROW|SNAPPABLE", + "hiddenFlagType": "literal", + "clickableFlag": true, + "clickableFlagType": "literal", + "flagScrollbarMode": "", + "flagScrollDirection": "", + "scrollSnapX": "", + "scrollSnapY": "", + "checkedStateType": "literal", + "disabledStateType": "literal", + "states": "", + "useStyle": "FlowRowSpaceBetween", + "localStyles": { + "objID": "318d6e6f-47fc-43b2-d552-78044e89013b", + "definition": { + "MAIN": { + "DEFAULT": { + "pad_right": 4 + } + } + } + }, + "group": "", + "groupIndex": 0 + }, + { + "objID": "be2e4477-a5c6-4fbf-b6ae-41781ba8beb3", + "type": "LVGLContainerWidget", + "left": 0, + "top": 0, + "width": 100, + "height": 80, + "customInputs": [], + "customOutputs": [], + "style": { + "objID": "32002f2f-ef5d-4cb2-861b-64e6d5a7d579", + "useStyle": "default", + "conditionalStyles": [], + "childStyles": [] + }, + "timeline": [], + "eventHandlers": [], + "identifier": "DHCPClientContainer", + "leftUnit": "px", + "topUnit": "px", + "widthUnit": "%", + "heightUnit": "%", + "children": [ + { + "objID": "b54678c4-4a2c-4cb3-f69f-1f5c2af56871", + "type": "LVGLContainerWidget", + "left": 0, + "top": 0, + "width": 100, + "height": 118, + "customInputs": [], + "customOutputs": [], + "style": { + "objID": "e2990e43-732a-4333-fe6b-2ebce8595dbe", + "useStyle": "default", + "conditionalStyles": [], + "childStyles": [] + }, + "timeline": [], + "eventHandlers": [], + "leftUnit": "px", + "topUnit": "px", + "widthUnit": "%", + "heightUnit": "content", + "children": [ + { + "objID": "1eac7e05-d4ef-4f1d-fb76-6d5ed568080c", + "type": "LVGLContainerWidget", + "left": 0, + "top": 0, + "width": 100, + "height": 60, + "customInputs": [], + "customOutputs": [], + "style": { + "objID": "bfe31228-f83e-4e4d-cc0b-a54b11ae977d", + "useStyle": "default", + "conditionalStyles": [], + "childStyles": [] + }, + "timeline": [], + "eventHandlers": [], + "identifier": "DHCPClientLabelContainer", + "leftUnit": "px", + "topUnit": "px", + "widthUnit": "%", + "heightUnit": "content", + "children": [ + { + "objID": "6bc2314a-818b-4dda-bd0d-14fc9e95e335", + "type": "LVGLLabelWidget", + "left": 0, + "top": 0, + "width": 100, + "height": 40, + "customInputs": [], + "customOutputs": [], + "style": { + "objID": "cddebe4d-3ea2-48c6-cd6e-6e9fb0b9b542", + "useStyle": "default", + "conditionalStyles": [], + "childStyles": [] + }, + "timeline": [], + "eventHandlers": [], + "identifier": "DHCPC_Label", + "leftUnit": "px", + "topUnit": "px", + "widthUnit": "%", + "heightUnit": "content", + "children": [], + "widgetFlags": "CLICK_FOCUSABLE|GESTURE_BUBBLE|PRESS_LOCK|SCROLLABLE|SCROLL_CHAIN_HOR|SCROLL_CHAIN_VER|SCROLL_ELASTIC|SCROLL_MOMENTUM|SCROLL_WITH_ARROW|SNAPPABLE", + "hiddenFlagType": "literal", + "clickableFlagType": "literal", + "flagScrollbarMode": "", + "flagScrollDirection": "", + "scrollSnapX": "", + "scrollSnapY": "", + "checkedStateType": "literal", + "disabledStateType": "literal", + "states": "", + "useStyle": "InfoContentLabel", + "localStyles": { + "objID": "120b38c1-a71d-4d16-9716-75f3bb8b6e1e", + "definition": { + "MAIN": { + "DEFAULT": { + "text_font": "FontBook20" + } + } + } + }, + "group": "", + "groupIndex": 0, + "text": "Press and hold for\n10 seconds", + "textType": "literal", + "longMode": "WRAP", + "recolor": false, + "previewValue": "0.0.1" + } + ], + "widgetFlags": "CLICK_FOCUSABLE|GESTURE_BUBBLE|PRESS_LOCK|SCROLL_CHAIN_HOR|SCROLL_CHAIN_VER|SCROLL_ELASTIC|SCROLL_MOMENTUM|SCROLL_WITH_ARROW|SNAPPABLE", + "hiddenFlagType": "literal", + "clickableFlag": true, + "clickableFlagType": "literal", + "flagScrollbarMode": "", + "flagScrollDirection": "", + "scrollSnapX": "", + "scrollSnapY": "", + "checkedStateType": "literal", + "disabledStateType": "literal", + "states": "", + "useStyle": "FlexColumnStart", + "localStyles": { + "objID": "49877fd7-7428-4aa9-de03-7dcfcd6e9c3e", + "definition": { + "MAIN": { + "DEFAULT": { + "pad_right": 10, + "pad_left": 10, + "pad_top": 10, + "pad_bottom": 10 + } + } + } + }, + "group": "", + "groupIndex": 0 + }, + { + "objID": "080e4506-6708-4853-fe18-6fa783466f89", + "type": "LVGLContainerWidget", + "left": 0, + "top": 0, + "width": 100, + "height": 80, + "customInputs": [], + "customOutputs": [], + "style": { + "objID": "433da4ef-fa5a-4d0e-9646-7e0cfb5ca8a1", + "useStyle": "default", + "conditionalStyles": [], + "childStyles": [] + }, + "hiddenInEditor": false, + "timeline": [], + "eventHandlers": [], + "identifier": "DHCPClientSpinner", + "leftUnit": "px", + "topUnit": "px", + "widthUnit": "%", + "heightUnit": "content", + "children": [ + { + "objID": "9ac3d644-13f9-4d1e-c5df-d30f77e060c9", + "type": "LVGLSpinnerWidget", + "left": 0, + "top": 0, + "width": 80, + "height": 80, + "customInputs": [], + "customOutputs": [], + "style": { + "objID": "eecd876c-0d54-40b5-80c9-35b5fed54d49", + "useStyle": "default", + "conditionalStyles": [], + "childStyles": [] + }, + "timeline": [], + "eventHandlers": [], + "leftUnit": "px", + "topUnit": "px", + "widthUnit": "px", + "heightUnit": "px", + "children": [], + "widgetFlags": "CLICK_FOCUSABLE|GESTURE_BUBBLE|PRESS_LOCK|SCROLL_ELASTIC|SCROLL_MOMENTUM|SCROLL_WITH_ARROW|SNAPPABLE", + "hiddenFlagType": "literal", + "clickableFlagType": "literal", + "flagScrollbarMode": "", + "flagScrollDirection": "", + "scrollSnapX": "", + "scrollSnapY": "", + "checkedStateType": "literal", + "disabledStateType": "literal", + "states": "", + "localStyles": { + "objID": "3b02891a-1c25-4bb9-c452-992e29e1b522" + }, + "group": "", + "groupIndex": 0 + } + ], + "widgetFlags": "CLICK_FOCUSABLE|GESTURE_BUBBLE|PRESS_LOCK|SCROLL_CHAIN_HOR|SCROLL_CHAIN_VER|SCROLL_ELASTIC|SCROLL_MOMENTUM|SCROLL_WITH_ARROW|SNAPPABLE", + "hiddenFlag": true, + "hiddenFlagType": "literal", + "clickableFlag": false, + "clickableFlagType": "literal", + "flagScrollbarMode": "", + "flagScrollDirection": "", + "scrollSnapX": "", + "scrollSnapY": "", + "checkedStateType": "literal", + "disabledStateType": "literal", + "states": "", + "useStyle": "FlexColumnStart", + "localStyles": { + "objID": "0261577f-97e3-4b4b-b761-cbb127842798", + "definition": { + "MAIN": { + "DEFAULT": { + "flex_main_place": "CENTER", + "flex_cross_place": "CENTER", + "flex_track_place": "CENTER" + } + } + } + }, + "group": "", + "groupIndex": 0 + }, + { + "objID": "3cd2081b-7f47-44da-ad46-0fec4d6c13af", + "type": "LVGLContainerWidget", + "left": 0, + "top": 0, + "width": 100, + "height": 50, + "customInputs": [], + "customOutputs": [], + "style": { + "objID": "96ae878d-571b-4be0-e408-ba24a005a7c7", + "useStyle": "default", + "conditionalStyles": [], + "childStyles": [] + }, + "hiddenInEditor": false, + "timeline": [], + "eventHandlers": [], + "identifier": "DHCPClientButton", + "leftUnit": "px", + "topUnit": "px", + "widthUnit": "%", + "heightUnit": "content", + "children": [ + { + "objID": "ca25a750-c474-4994-eb13-b2ea9abeb1f1", + "type": "LVGLButtonWidget", + "left": 0, + "top": 0, + "width": 100, + "height": 50, + "customInputs": [], + "customOutputs": [], + "style": { + "objID": "22a3e3ca-a02c-4e43-9b78-7de7d03a7879", + "useStyle": "default", + "conditionalStyles": [], + "childStyles": [] + }, + "hiddenInEditor": false, + "timeline": [], + "eventHandlers": [ + { + "objID": "71ed231c-9bc3-4f0f-81f0-bf83c082974c", + "eventName": "PRESSED", + "handlerType": "action", + "action": "DHCPC", + "userData": 0 + }, + { + "objID": "677825fd-50cd-4d46-ef8e-4b296fef7c76", + "eventName": "PRESSING", + "handlerType": "action", + "action": "DHCPC", + "userData": 0 + }, + { + "objID": "f25e6fa7-45de-4d97-d892-3411d07f2df6", + "eventName": "RELEASED", + "handlerType": "action", + "action": "DHCPC", + "userData": 0 + } + ], + "leftUnit": "px", + "topUnit": "px", + "widthUnit": "%", + "heightUnit": "px", + "children": [ + { + "objID": "9370968e-29f3-411e-8e6e-28874fb3cdd8", + "type": "LVGLLabelWidget", + "left": 0, + "top": 0, + "width": 129, + "height": 16, + "customInputs": [], + "customOutputs": [], + "style": { + "objID": "e81bebce-db31-4a4e-e0d1-2c48865b9e5c", + "useStyle": "default", + "conditionalStyles": [], + "childStyles": [] + }, + "hiddenInEditor": false, + "timeline": [], + "eventHandlers": [], + "identifier": "DHCPClientChangeLabel", + "leftUnit": "px", + "topUnit": "px", + "widthUnit": "content", + "heightUnit": "content", + "children": [], + "widgetFlags": "CLICK_FOCUSABLE|GESTURE_BUBBLE|PRESS_LOCK|SCROLLABLE|SCROLL_CHAIN_HOR|SCROLL_CHAIN_VER|SCROLL_ELASTIC|SCROLL_MOMENTUM|SCROLL_WITH_ARROW|SNAPPABLE", + "hiddenFlagType": "literal", + "clickableFlagType": "literal", + "flagScrollbarMode": "", + "flagScrollDirection": "", + "scrollSnapX": "", + "scrollSnapY": "", + "checkedStateType": "literal", + "disabledStateType": "literal", + "states": "", + "localStyles": { + "objID": "c7acc23d-631e-40ca-e8af-d73735a6b513", + "definition": { + "MAIN": { + "DEFAULT": { + "align": "CENTER", + "text_align": "LEFT" + } + } + } + }, + "group": "", + "groupIndex": 0, + "text": "Switch to udhcpc", + "textType": "literal", + "longMode": "WRAP", + "recolor": false + } + ], + "widgetFlags": "CLICK_FOCUSABLE|GESTURE_BUBBLE|PRESS_LOCK|SCROLL_CHAIN_HOR|SCROLL_CHAIN_VER|SCROLL_ELASTIC|SCROLL_MOMENTUM|SCROLL_ON_FOCUS|SCROLL_WITH_ARROW|SNAPPABLE", + "hiddenFlagType": "literal", + "clickableFlag": true, + "clickableFlagType": "literal", + "flagScrollbarMode": "", + "flagScrollDirection": "", + "scrollSnapX": "", + "scrollSnapY": "", + "checkedStateType": "literal", + "disabledStateType": "literal", + "states": "", + "useStyle": "", + "localStyles": { + "objID": "244a5188-9237-429c-ea0f-0892dffba39d", + "definition": { + "MAIN": { + "DEFAULT": { + "bg_color": "DC2626", + "text_align": "LEFT", + "pad_right": 13 + } + } + } + }, + "group": "", + "groupIndex": 0 + } + ], + "widgetFlags": "CLICK_FOCUSABLE|GESTURE_BUBBLE|PRESS_LOCK|SCROLL_CHAIN_HOR|SCROLL_CHAIN_VER|SCROLL_ELASTIC|SCROLL_MOMENTUM|SCROLL_WITH_ARROW|SNAPPABLE", + "hiddenFlagType": "literal", + "clickableFlag": true, + "clickableFlagType": "literal", + "flagScrollbarMode": "", + "flagScrollDirection": "", + "scrollSnapX": "", + "scrollSnapY": "", + "checkedStateType": "literal", + "disabledStateType": "literal", + "states": "", + "useStyle": "FlexColumnStart", + "localStyles": { + "objID": "98d255ea-498c-4bce-cbd9-5c58f4148905" + }, + "group": "", + "groupIndex": 0 + } + ], + "widgetFlags": "CLICK_FOCUSABLE|GESTURE_BUBBLE|PRESS_LOCK|SCROLL_CHAIN_HOR|SCROLL_CHAIN_VER|SCROLL_ELASTIC|SCROLL_MOMENTUM|SCROLL_WITH_ARROW|SNAPPABLE", + "hiddenFlagType": "literal", + "clickableFlag": true, + "clickableFlagType": "literal", + "flagScrollbarMode": "", + "flagScrollDirection": "", + "scrollSnapX": "", + "scrollSnapY": "", + "checkedStateType": "literal", + "disabledStateType": "literal", + "states": "", + "useStyle": "FlexColumnStart", + "localStyles": { + "objID": "ac0d8cb4-7113-4ad6-f9b2-6d24553c6558", + "definition": { + "MAIN": { + "DEFAULT": { + "pad_right": 10 + } + } + } + }, + "group": "", + "groupIndex": 0 + } + ], + "widgetFlags": "CLICK_FOCUSABLE|GESTURE_BUBBLE|PRESS_LOCK|SCROLL_CHAIN_VER|SNAPPABLE|SCROLL_ELASTIC|SCROLL_WITH_ARROW|SCROLL_MOMENTUM|SCROLL_CHAIN_HOR|SCROLLABLE", + "hiddenFlagType": "literal", + "clickableFlag": true, + "clickableFlagType": "literal", + "flagScrollbarMode": "auto", + "flagScrollDirection": "ver", + "scrollSnapX": "start", + "scrollSnapY": "", + "checkedStateType": "literal", + "disabledStateType": "literal", + "states": "", + "useStyle": "FlexColumnStart", + "localStyles": { + "objID": "b4f4b1a5-fe3d-4114-bb43-68d4a39ff3c2", + "definition": { + "MAIN": { + "DEFAULT": { + "pad_right": 4 + } + } + } + }, + "group": "", + "groupIndex": 0 + } + ], + "widgetFlags": "CLICK_FOCUSABLE|GESTURE_BUBBLE|PRESS_LOCK|SCROLL_CHAIN_HOR|SCROLL_CHAIN_VER|SCROLL_MOMENTUM|SCROLL_WITH_ARROW|SNAPPABLE|SCROLL_ELASTIC", + "hiddenFlagType": "literal", + "clickableFlag": true, + "clickableFlagType": "literal", + "flagScrollbarMode": "", + "flagScrollDirection": "", + "scrollSnapX": "", + "scrollSnapY": "", + "checkedStateType": "literal", + "disabledStateType": "literal", + "states": "", + "useStyle": "FlexStart", + "localStyles": { + "objID": "34c01609-fb79-4d5a-943a-a28e67b73adf" + }, + "group": "", + "groupIndex": 0 + } + ], + "widgetFlags": "CLICKABLE|PRESS_LOCK|CLICK_FOCUSABLE|GESTURE_BUBBLE|SNAPPABLE|SCROLLABLE|SCROLL_ELASTIC|SCROLL_MOMENTUM|SCROLL_CHAIN_HOR|SCROLL_CHAIN_VER", + "hiddenFlagType": "literal", + "clickableFlag": true, + "clickableFlagType": "literal", + "checkedStateType": "literal", + "disabledStateType": "literal", + "states": "", + "useStyle": "FlexScreenMenu", + "localStyles": { + "objID": "cd8e005a-54e3-4ed2-edfb-719d97a7ded8" + }, + "groupIndex": 0 + } + ], + "connectionLines": [], + "localVariables": [], + "userProperties": [], + "name": "SwitchDHCPClientScreen", + "left": 0, + "top": 0, + "width": 300, + "height": 240, + "isUsedAsUserWidget": false, + "createAtStart": true, + "deleteOnScreenUnload": false } ], "userWidgets": [], diff --git a/internal/native/eez/src/ui/actions.c b/internal/native/eez/src/ui/actions.c index 801b03da..135d8938 100644 --- a/internal/native/eez/src/ui/actions.c +++ b/internal/native/eez/src/ui/actions.c @@ -75,15 +75,19 @@ void action_about_screen_gesture(lv_event_t * e) { // user_data doesn't seem to be working, so we use a global variable here static uint32_t t_reset_config; static uint32_t t_reboot; +static uint32_t t_dhcpc; static bool b_reboot = false; static bool b_reset_config = false; +static bool b_dhcpc = false; static bool b_reboot_lock = false; static bool b_reset_config_lock = false; +static bool b_dhcpc_lock = false; const int RESET_CONFIG_HOLD_TIME = 10; const int REBOOT_HOLD_TIME = 5; +const int DHCPC_HOLD_TIME = 5; typedef struct { uint32_t *start_time; @@ -153,6 +157,22 @@ void action_reset_config(lv_event_t * e) { handle_hold_action(e, &config); } +void action_dhcpc(lv_event_t * e) { + hold_action_config_t config = { + .start_time = &t_dhcpc, + .completed = &b_dhcpc, + .lock = &b_dhcpc_lock, + .hold_time_seconds = DHCPC_HOLD_TIME, + .rpc_method = "toggleDHCPClient", + .button_obj = NULL, // No button/spinner for reboot + .spinner_obj = NULL, + .label_obj = objects.dhcpc_label, + .default_text = "Press and hold for\n5 seconds" + }; + + handle_hold_action(e, &config); +} + void action_reboot(lv_event_t * e) { hold_action_config_t config = { .start_time = &t_reboot, diff --git a/internal/native/eez/src/ui/actions.h b/internal/native/eez/src/ui/actions.h index f4a24e44..4ec4a807 100644 --- a/internal/native/eez/src/ui/actions.h +++ b/internal/native/eez/src/ui/actions.h @@ -24,6 +24,7 @@ extern void action_handle_common_press_event(lv_event_t * e); extern void action_reset_config(lv_event_t * e); extern void action_reboot(lv_event_t * e); extern void action_switch_to_reboot(lv_event_t * e); +extern void action_dhcpc(lv_event_t * e); #ifdef __cplusplus diff --git a/internal/native/eez/src/ui/screens.c b/internal/native/eez/src/ui/screens.c index dd5ad98c..566006f1 100644 --- a/internal/native/eez/src/ui/screens.c +++ b/internal/native/eez/src/ui/screens.c @@ -887,6 +887,26 @@ void create_screen_menu_advanced_screen() { } } } + { + // MenuBtnDHCPClient + lv_obj_t *obj = lv_button_create(parent_obj); + objects.menu_btn_dhcp_client = obj; + lv_obj_set_pos(obj, 0, 0); + lv_obj_set_size(obj, LV_PCT(100), 50); + lv_obj_add_event_cb(obj, action_switch_to_reboot, LV_EVENT_PRESSED, (void *)0); + lv_obj_clear_flag(obj, LV_OBJ_FLAG_SNAPPABLE); + add_style_menu_button(obj); + { + lv_obj_t *parent_obj = obj; + { + lv_obj_t *obj = lv_label_create(parent_obj); + lv_obj_set_pos(obj, 0, 0); + lv_obj_set_size(obj, LV_SIZE_CONTENT, LV_SIZE_CONTENT); + add_style_menu_button_label(obj); + lv_label_set_text(obj, "DHCP Client"); + } + } + } { // MenuBtnAdvancedResetConfig lv_obj_t *obj = lv_button_create(parent_obj); @@ -2197,6 +2217,221 @@ void create_screen_rebooting_screen() { void tick_screen_rebooting_screen() { } +void create_screen_switch_dhcp_client_screen() { + lv_obj_t *obj = lv_obj_create(0); + objects.switch_dhcp_client_screen = obj; + lv_obj_set_pos(obj, 0, 0); + lv_obj_set_size(obj, 300, 240); + lv_obj_add_event_cb(obj, action_about_screen_gesture, LV_EVENT_GESTURE, (void *)0); + add_style_flex_screen_menu(obj); + { + lv_obj_t *parent_obj = obj; + { + lv_obj_t *obj = lv_obj_create(parent_obj); + lv_obj_set_pos(obj, 0, 0); + lv_obj_set_size(obj, LV_PCT(100), LV_PCT(100)); + lv_obj_set_style_pad_left(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_pad_top(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_pad_right(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_pad_bottom(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_bg_opa(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_border_width(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_radius(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_clear_flag(obj, LV_OBJ_FLAG_SCROLLABLE); + add_style_flex_start(obj); + { + lv_obj_t *parent_obj = obj; + { + // DHCPClientHeader + lv_obj_t *obj = lv_obj_create(parent_obj); + objects.dhcp_client_header = obj; + lv_obj_set_pos(obj, 0, 0); + lv_obj_set_size(obj, LV_PCT(100), LV_SIZE_CONTENT); + lv_obj_set_style_pad_left(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_pad_top(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_pad_bottom(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_bg_opa(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_border_width(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_radius(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT); + add_style_flow_row_space_between(obj); + lv_obj_set_style_pad_right(obj, 4, LV_PART_MAIN | LV_STATE_DEFAULT); + { + lv_obj_t *parent_obj = obj; + { + lv_obj_t *obj = lv_button_create(parent_obj); + lv_obj_set_pos(obj, 0, 0); + lv_obj_set_size(obj, 32, 32); + lv_obj_add_event_cb(obj, action_switch_to_menu, LV_EVENT_CLICKED, (void *)0); + add_style_back_button(obj); + { + lv_obj_t *parent_obj = obj; + { + lv_obj_t *obj = lv_image_create(parent_obj); + lv_obj_set_pos(obj, -1, 2); + lv_obj_set_size(obj, LV_SIZE_CONTENT, LV_SIZE_CONTENT); + lv_image_set_src(obj, &img_back_caret); + } + } + } + { + lv_obj_t *obj = lv_label_create(parent_obj); + lv_obj_set_pos(obj, LV_PCT(0), LV_PCT(0)); + lv_obj_set_size(obj, LV_SIZE_CONTENT, LV_SIZE_CONTENT); + add_style_header_link(obj); + lv_label_set_text(obj, "Reset Config"); + } + } + } + { + // DHCPClientContainer + lv_obj_t *obj = lv_obj_create(parent_obj); + objects.dhcp_client_container = obj; + lv_obj_set_pos(obj, 0, 0); + lv_obj_set_size(obj, LV_PCT(100), LV_PCT(80)); + lv_obj_set_style_pad_left(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_pad_top(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_pad_bottom(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_bg_opa(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_border_width(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_radius(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_scrollbar_mode(obj, LV_SCROLLBAR_MODE_AUTO); + lv_obj_set_scroll_dir(obj, LV_DIR_VER); + lv_obj_set_scroll_snap_x(obj, LV_SCROLL_SNAP_START); + add_style_flex_column_start(obj); + lv_obj_set_style_pad_right(obj, 4, LV_PART_MAIN | LV_STATE_DEFAULT); + { + lv_obj_t *parent_obj = obj; + { + lv_obj_t *obj = lv_obj_create(parent_obj); + lv_obj_set_pos(obj, 0, 0); + lv_obj_set_size(obj, LV_PCT(100), LV_SIZE_CONTENT); + lv_obj_set_style_pad_left(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_pad_top(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_pad_bottom(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_bg_opa(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_border_width(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_radius(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_clear_flag(obj, LV_OBJ_FLAG_SCROLLABLE); + add_style_flex_column_start(obj); + lv_obj_set_style_pad_right(obj, 10, LV_PART_MAIN | LV_STATE_DEFAULT); + { + lv_obj_t *parent_obj = obj; + { + // DHCPClientLabelContainer + lv_obj_t *obj = lv_obj_create(parent_obj); + objects.dhcp_client_label_container = obj; + lv_obj_set_pos(obj, 0, 0); + lv_obj_set_size(obj, LV_PCT(100), LV_SIZE_CONTENT); + lv_obj_set_style_bg_opa(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_border_width(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_radius(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_clear_flag(obj, LV_OBJ_FLAG_SCROLLABLE); + add_style_flex_column_start(obj); + lv_obj_set_style_pad_right(obj, 10, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_pad_left(obj, 10, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_pad_top(obj, 10, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_pad_bottom(obj, 10, LV_PART_MAIN | LV_STATE_DEFAULT); + { + lv_obj_t *parent_obj = obj; + { + // DHCPC_Label + lv_obj_t *obj = lv_label_create(parent_obj); + objects.dhcpc_label = obj; + lv_obj_set_pos(obj, 0, 0); + lv_obj_set_size(obj, LV_PCT(100), LV_SIZE_CONTENT); + add_style_info_content_label(obj); + lv_obj_set_style_text_font(obj, &ui_font_font_book20, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_label_set_text(obj, "Press and hold for\n10 seconds"); + } + } + } + { + // DHCPClientSpinner + lv_obj_t *obj = lv_obj_create(parent_obj); + objects.dhcp_client_spinner = obj; + lv_obj_set_pos(obj, 0, 0); + lv_obj_set_size(obj, LV_PCT(100), LV_SIZE_CONTENT); + lv_obj_set_style_pad_left(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_pad_top(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_pad_right(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_pad_bottom(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_bg_opa(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_border_width(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_radius(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_add_flag(obj, LV_OBJ_FLAG_HIDDEN); + lv_obj_clear_flag(obj, LV_OBJ_FLAG_CLICKABLE|LV_OBJ_FLAG_SCROLLABLE); + add_style_flex_column_start(obj); + lv_obj_set_style_flex_main_place(obj, LV_FLEX_ALIGN_CENTER, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_flex_cross_place(obj, LV_FLEX_ALIGN_CENTER, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_flex_track_place(obj, LV_FLEX_ALIGN_CENTER, LV_PART_MAIN | LV_STATE_DEFAULT); + { + lv_obj_t *parent_obj = obj; + { + lv_obj_t *obj = lv_spinner_create(parent_obj); + lv_obj_set_pos(obj, 0, 0); + lv_obj_set_size(obj, 80, 80); + lv_spinner_set_anim_params(obj, 1000, 60); + } + } + } + { + // DHCPClientButton + lv_obj_t *obj = lv_obj_create(parent_obj); + objects.dhcp_client_button = obj; + lv_obj_set_pos(obj, 0, 0); + lv_obj_set_size(obj, LV_PCT(100), LV_SIZE_CONTENT); + lv_obj_set_style_pad_left(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_pad_top(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_pad_right(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_pad_bottom(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_bg_opa(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_border_width(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_radius(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_clear_flag(obj, LV_OBJ_FLAG_SCROLLABLE); + add_style_flex_column_start(obj); + { + lv_obj_t *parent_obj = obj; + { + lv_obj_t *obj = lv_button_create(parent_obj); + objects.obj2 = obj; + lv_obj_set_pos(obj, 0, 0); + lv_obj_set_size(obj, LV_PCT(100), 50); + lv_obj_add_event_cb(obj, action_dhcpc, LV_EVENT_PRESSED, (void *)0); + lv_obj_add_event_cb(obj, action_dhcpc, LV_EVENT_PRESSING, (void *)0); + lv_obj_add_event_cb(obj, action_dhcpc, LV_EVENT_RELEASED, (void *)0); + lv_obj_set_style_bg_color(obj, lv_color_hex(0xffdc2626), LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_text_align(obj, LV_TEXT_ALIGN_LEFT, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_pad_right(obj, 13, LV_PART_MAIN | LV_STATE_DEFAULT); + { + lv_obj_t *parent_obj = obj; + { + // DHCPClientChangeLabel + lv_obj_t *obj = lv_label_create(parent_obj); + objects.dhcp_client_change_label = obj; + lv_obj_set_pos(obj, 0, 0); + lv_obj_set_size(obj, LV_SIZE_CONTENT, LV_SIZE_CONTENT); + lv_obj_set_style_align(obj, LV_ALIGN_CENTER, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_obj_set_style_text_align(obj, LV_TEXT_ALIGN_LEFT, LV_PART_MAIN | LV_STATE_DEFAULT); + lv_label_set_text(obj, "Switch to udhcpc"); + } + } + } + } + } + } + } + } + } + } + } + } + + tick_screen_switch_dhcp_client_screen(); +} + +void tick_screen_switch_dhcp_client_screen() { +} + typedef void (*tick_screen_func_t)(); @@ -2212,6 +2447,7 @@ tick_screen_func_t tick_screen_funcs[] = { tick_screen_reset_config_screen, tick_screen_reboot_screen, tick_screen_rebooting_screen, + tick_screen_switch_dhcp_client_screen, }; void tick_screen(int screen_index) { tick_screen_funcs[screen_index](); @@ -2236,4 +2472,5 @@ void create_screens() { create_screen_reset_config_screen(); create_screen_reboot_screen(); create_screen_rebooting_screen(); + create_screen_switch_dhcp_client_screen(); } diff --git a/internal/native/eez/src/ui/screens.h b/internal/native/eez/src/ui/screens.h index f2d2fc92..d17f6abd 100644 --- a/internal/native/eez/src/ui/screens.h +++ b/internal/native/eez/src/ui/screens.h @@ -19,6 +19,7 @@ typedef struct _objects_t { lv_obj_t *reset_config_screen; lv_obj_t *reboot_screen; lv_obj_t *rebooting_screen; + lv_obj_t *switch_dhcp_client_screen; lv_obj_t *boot_logo; lv_obj_t *boot_screen_version; lv_obj_t *no_network_header_container; @@ -54,6 +55,7 @@ typedef struct _objects_t { lv_obj_t *menu_btn_advanced_developer_mode; lv_obj_t *menu_btn_advanced_usb_emulation; lv_obj_t *menu_btn_advanced_reboot; + lv_obj_t *menu_btn_dhcp_client; lv_obj_t *menu_btn_advanced_reset_config; lv_obj_t *menu_header_container_2; lv_obj_t *menu_items_container_2; @@ -101,6 +103,14 @@ typedef struct _objects_t { lv_obj_t *obj1; lv_obj_t *reboot_in_progress_logo; lv_obj_t *reboot_in_progress_label; + lv_obj_t *dhcp_client_header; + lv_obj_t *dhcp_client_container; + lv_obj_t *dhcp_client_label_container; + lv_obj_t *dhcpc_label; + lv_obj_t *dhcp_client_spinner; + lv_obj_t *dhcp_client_button; + lv_obj_t *obj2; + lv_obj_t *dhcp_client_change_label; } objects_t; extern objects_t objects; @@ -117,6 +127,7 @@ enum ScreensEnum { SCREEN_ID_RESET_CONFIG_SCREEN = 9, SCREEN_ID_REBOOT_SCREEN = 10, SCREEN_ID_REBOOTING_SCREEN = 11, + SCREEN_ID_SWITCH_DHCP_CLIENT_SCREEN = 12, }; void create_screen_boot_screen(); @@ -151,6 +162,9 @@ void tick_screen_reboot_screen(); void create_screen_rebooting_screen(); void tick_screen_rebooting_screen(); + +void create_screen_switch_dhcp_client_screen(); +void tick_screen_switch_dhcp_client_screen(); void tick_screen_by_id(enum ScreensEnum screenId); void tick_screen(int screen_index); diff --git a/native.go b/native.go index e8eea745..4268bf2c 100644 --- a/native.go +++ b/native.go @@ -43,6 +43,8 @@ func initNative(systemVersion *semver.Version, appVersion *semver.Version) { _ = rpcReboot(true) case "reboot": _ = rpcReboot(true) + case "toggleDHCPClient": + _ = rpcToggleDHCPClient() default: nativeLogger.Warn().Str("event", event).Msg("unknown rpc event received") } diff --git a/network.go b/network.go index 61350bd5..b4d3ad37 100644 --- a/network.go +++ b/network.go @@ -193,3 +193,14 @@ func rpcSetNetworkSettings(settings RpcNetworkSettings) (*RpcNetworkSettings, er func rpcRenewDHCPLease() error { return networkManager.RenewDHCPLease(NetIfName) } + +func rpcToggleDHCPClient() error { + switch config.NetworkConfig.DHCPClient.String { + case "jetdhcpc": + config.NetworkConfig.DHCPClient.String = "udhcpc" + case "udhcpc": + config.NetworkConfig.DHCPClient.String = "jetdhcpc" + } + + return rpcReboot(false) +} From 9cd29a3b1b03f029f04aca34d820d1380027ae03 Mon Sep 17 00:00:00 2001 From: Siyuan Date: Fri, 10 Oct 2025 14:56:31 +0000 Subject: [PATCH 40/85] delete then add addresses again --- pkg/nmlite/link/manager.go | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/pkg/nmlite/link/manager.go b/pkg/nmlite/link/manager.go index 2f1bc8b6..c9b9410c 100644 --- a/pkg/nmlite/link/manager.go +++ b/pkg/nmlite/link/manager.go @@ -211,8 +211,7 @@ func (nm *NetlinkManager) EnsureInterfaceUpWithTimeout(ctx context.Context, ifac case <-linkUpTimeout: attempt++ l.Error(). - Int("attempt", attempt). - Msg("interface is still down after timeout") + Int("attempt", attempt).Msg("interface is still down after timeout") if err != nil { return nil, err } @@ -521,6 +520,13 @@ func (nm *NetlinkManager) ReconcileLink(link *Link, expected []types.IPAddress, } } + for _, addr := range toAdd { + netlinkAddr := addr.NetlinkAddr() + if err := nm.AddrAdd(link, &netlinkAddr); err != nil { + nm.logger.Warn().Err(err).Str("address", addr.Address.String()).Msg("failed to add address") + } + } + actualToAdd := len(toAdd) - len(toUpdate) if len(toAdd) > 0 || len(toUpdate) > 0 || len(toRemove) > 0 { nm.logger.Info(). From 22f5ed2a8b40d3e4d04fbc68624ccf514f1074d0 Mon Sep 17 00:00:00 2001 From: Siyuan Date: Fri, 10 Oct 2025 15:03:23 +0000 Subject: [PATCH 41/85] fix possible nil dereference --- pkg/nmlite/interface.go | 45 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/pkg/nmlite/interface.go b/pkg/nmlite/interface.go index 15ebb1d1..ba22b6ee 100644 --- a/pkg/nmlite/interface.go +++ b/pkg/nmlite/interface.go @@ -182,6 +182,10 @@ func (im *InterfaceManager) IsUp() bool { im.stateMu.RLock() defer im.stateMu.RUnlock() + if im.state == nil { + return false + } + return im.state.Up } @@ -190,6 +194,10 @@ func (im *InterfaceManager) IsOnline() bool { im.stateMu.RLock() defer im.stateMu.RUnlock() + if im.state == nil { + return false + } + return im.state.Online } @@ -198,6 +206,10 @@ func (im *InterfaceManager) IPv4Ready() bool { im.stateMu.RLock() defer im.stateMu.RUnlock() + if im.state == nil { + return false + } + return im.state.IPv4Ready } @@ -206,6 +218,10 @@ func (im *InterfaceManager) IPv6Ready() bool { im.stateMu.RLock() defer im.stateMu.RUnlock() + if im.state == nil { + return false + } + return im.state.IPv6Ready } @@ -214,6 +230,10 @@ func (im *InterfaceManager) GetIPv4Addresses() []string { im.stateMu.RLock() defer im.stateMu.RUnlock() + if im.state == nil { + return []string{} + } + return im.state.IPv4Addresses } @@ -222,6 +242,10 @@ func (im *InterfaceManager) GetIPv4Address() string { im.stateMu.RLock() defer im.stateMu.RUnlock() + if im.state == nil { + return "" + } + return im.state.IPv4Address } @@ -230,6 +254,10 @@ func (im *InterfaceManager) GetIPv6Address() string { im.stateMu.RLock() defer im.stateMu.RUnlock() + if im.state == nil { + return "" + } + return im.state.IPv6Address } @@ -239,6 +267,11 @@ func (im *InterfaceManager) GetIPv6Addresses() []string { defer im.stateMu.RUnlock() addresses := []string{} + + if im.state == nil { + return addresses + } + for _, addr := range im.state.IPv6Addresses { addresses = append(addresses, addr.Address.String()) } @@ -251,6 +284,10 @@ func (im *InterfaceManager) GetMACAddress() string { im.stateMu.RLock() defer im.stateMu.RUnlock() + if im.state == nil { + return "" + } + return im.state.MACAddress } @@ -271,6 +308,10 @@ func (im *InterfaceManager) NTPServers() []net.IP { im.stateMu.RLock() defer im.stateMu.RUnlock() + if im.state == nil { + return []net.IP{} + } + return im.state.NTPServers } @@ -278,6 +319,10 @@ func (im *InterfaceManager) Domain() string { im.stateMu.RLock() defer im.stateMu.RUnlock() + if im.state == nil { + return "" + } + if im.state.DHCPLease4 != nil { return im.state.DHCPLease4.Domain } From ece467eba8fdc7fcdf09ca2426ac5eff6e93a245 Mon Sep 17 00:00:00 2001 From: Siyuan Date: Fri, 10 Oct 2025 15:11:20 +0000 Subject: [PATCH 42/85] fix: mac address not showing on home screen --- display.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/display.go b/display.go index 220c8055..99ad5548 100644 --- a/display.go +++ b/display.go @@ -196,7 +196,9 @@ func waitCtrlAndRequestDisplayUpdate(shouldWakeDisplay bool, reason string) { func updateStaticContents() { //contents that never change - nativeInstance.UpdateLabelIfChanged("home_info_mac_addr", networkManager.MACString()) + if networkManager != nil { + nativeInstance.UpdateLabelIfChanged("home_info_mac_addr", networkManager.MACString()) + } // get cpu info if cpuInfo, err := os.ReadFile("/proc/cpuinfo"); err == nil { From 403c1f8fa17f5bfe6487e9df13aa627f3afc6387 Mon Sep 17 00:00:00 2001 From: Siyuan Date: Fri, 10 Oct 2025 15:16:21 +0000 Subject: [PATCH 43/85] fix: ensure symlink to last-crash.log --- cmd/main.go | 25 +++++++++++++++++++++++++ display.go | 9 +++++---- 2 files changed, 30 insertions(+), 4 deletions(-) diff --git a/cmd/main.go b/cmd/main.go index 59033c47..4494e280 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -117,6 +117,29 @@ func supervise() error { return nil } +func isSymlinkTo(dst, src string) bool { + file, err := os.Stat(dst) + if err != nil { + return false + } + if file.Mode()&os.ModeSymlink != os.ModeSymlink { + return false + } + target, err := os.Readlink(dst) + if err != nil { + return false + } + return target == src +} + +func ensureSymlink(dst, src string) error { + if isSymlinkTo(dst, src) { + return nil + } + _ = os.Remove(dst) + return os.Symlink(src, dst) +} + func createErrorDump(logFile *os.File) { logFile.Close() @@ -160,6 +183,8 @@ func createErrorDump(logFile *os.File) { } fmt.Printf("error dump created: %s\n", filePath) + + ensureSymlink(filePath, filepath.Join(errorDumpDir, "last-crash.log")) } func doSupervise() { diff --git a/display.go b/display.go index 99ad5548..9ec4296f 100644 --- a/display.go +++ b/display.go @@ -35,14 +35,15 @@ func switchToMainScreen() { } func updateDisplay() { - nativeInstance.UpdateLabelIfChanged("home_info_ipv4_addr", networkManager.IPv4String()) - nativeInstance.UpdateLabelAndChangeVisibility("home_info_ipv6_addr", networkManager.IPv6String()) + if networkManager != nil { + nativeInstance.UpdateLabelIfChanged("home_info_ipv4_addr", networkManager.IPv4String()) + nativeInstance.UpdateLabelAndChangeVisibility("home_info_ipv6_addr", networkManager.IPv6String()) + nativeInstance.UpdateLabelIfChanged("home_info_mac_addr", networkManager.MACString()) + } _, _ = nativeInstance.UIObjHide("menu_btn_network") _, _ = nativeInstance.UIObjHide("menu_btn_access") - nativeInstance.UpdateLabelIfChanged("home_info_mac_addr", networkManager.MACString()) - switch config.NetworkConfig.DHCPClient.String { case "jetdhcpc": nativeInstance.UpdateLabelIfChanged("dhcp_client_change_label", "Change to udhcpc") From 110790a664eb6114befb4bac31e1d6364f2dbc54 Mon Sep 17 00:00:00 2001 From: Siyuan Date: Fri, 10 Oct 2025 15:22:09 +0000 Subject: [PATCH 44/85] fix: switch to no_network_screen if network manager is nil --- cmd/main.go | 2 +- display.go | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/cmd/main.go b/cmd/main.go index 4494e280..b1889e2e 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -184,7 +184,7 @@ func createErrorDump(logFile *os.File) { fmt.Printf("error dump created: %s\n", filePath) - ensureSymlink(filePath, filepath.Join(errorDumpDir, "last-crash.log")) + _ = ensureSymlink(filePath, filepath.Join(errorDumpDir, "last-crash.log")) } func doSupervise() { diff --git a/display.go b/display.go index 9ec4296f..bcee2256 100644 --- a/display.go +++ b/display.go @@ -27,6 +27,11 @@ const ( ) func switchToMainScreen() { + if networkManager == nil { + nativeInstance.SwitchToScreenIfDifferent("no_network_screen") + return + } + if networkManager.IsUp() { nativeInstance.SwitchToScreenIfDifferent("home_screen") } else { From 459dc5c9fae2cb5f6eb3f92622d8df5b0d6192e9 Mon Sep 17 00:00:00 2001 From: Siyuan Date: Fri, 10 Oct 2025 15:24:17 +0000 Subject: [PATCH 45/85] fix: check if network manager is nil --- display.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/display.go b/display.go index bcee2256..042bf122 100644 --- a/display.go +++ b/display.go @@ -72,7 +72,7 @@ func updateDisplay() { } nativeInstance.UpdateLabelIfChanged("cloud_status_label", fmt.Sprintf("%d active", actionSessions)) - if networkManager.IsUp() { + if networkManager != nil && networkManager.IsUp() { nativeInstance.UISetVar("main_screen", "home_screen") nativeInstance.SwitchToScreenIf("home_screen", []string{"no_network_screen", "boot_screen"}) } else { From 775b0f10494e9a702465078b3267e083c41864dc Mon Sep 17 00:00:00 2001 From: Siyuan Date: Fri, 10 Oct 2025 21:29:21 +0000 Subject: [PATCH 46/85] fix: reset config --- config.go | 72 +++++++++++++++++++++++++++++++++--------------------- jsonrpc.go | 3 ++- 2 files changed, 46 insertions(+), 29 deletions(-) diff --git a/config.go b/config.go index 0fff2fcb..36df92da 100644 --- a/config.go +++ b/config.go @@ -7,6 +7,7 @@ import ( "strconv" "sync" + "github.com/jetkvm/kvm/internal/confparser" "github.com/jetkvm/kvm/internal/logging" "github.com/jetkvm/kvm/internal/network/types" "github.com/jetkvm/kvm/internal/usbgadget" @@ -128,41 +129,55 @@ func (c *Config) SetDisplayRotation(rotation string) error { const configPath = "/userdata/kvm_config.json" -var defaultConfig = &Config{ - CloudURL: "https://api.jetkvm.com", - CloudAppURL: "https://app.jetkvm.com", - AutoUpdateEnabled: true, // Set a default value - ActiveExtension: "", - KeyboardMacros: []KeyboardMacro{}, - DisplayRotation: "270", - KeyboardLayout: "en-US", - DisplayMaxBrightness: 64, - DisplayDimAfterSec: 120, // 2 minutes - DisplayOffAfterSec: 1800, // 30 minutes - JigglerEnabled: false, - // This is the "Standard" jiggler option in the UI - JigglerConfig: &JigglerConfig{ +// it's a temporary solution to avoid sharing the same pointer +// we should migrate to a proper config solution in the future +var ( + defaultJigglerConfig = JigglerConfig{ InactivityLimitSeconds: 60, JitterPercentage: 25, ScheduleCronTab: "0 * * * * *", Timezone: "UTC", - }, - TLSMode: "", - UsbConfig: &usbgadget.Config{ + } + defaultUsbConfig = usbgadget.Config{ VendorId: "0x1d6b", //The Linux Foundation ProductId: "0x0104", //Multifunction Composite Gadget SerialNumber: "", Manufacturer: "JetKVM", Product: "USB Emulation Device", - }, - UsbDevices: &usbgadget.Devices{ + } + defaultUsbDevices = usbgadget.Devices{ AbsoluteMouse: true, RelativeMouse: true, Keyboard: true, MassStorage: true, - }, - NetworkConfig: &types.NetworkConfig{}, - DefaultLogLevel: "INFO", + } +) + +func getDefaultConfig() Config { + return Config{ + CloudURL: "https://api.jetkvm.com", + CloudAppURL: "https://app.jetkvm.com", + AutoUpdateEnabled: true, // Set a default value + ActiveExtension: "", + KeyboardMacros: []KeyboardMacro{}, + DisplayRotation: "270", + KeyboardLayout: "en-US", + DisplayMaxBrightness: 64, + DisplayDimAfterSec: 120, // 2 minutes + DisplayOffAfterSec: 1800, // 30 minutes + JigglerEnabled: false, + // This is the "Standard" jiggler option in the UI + JigglerConfig: func() *JigglerConfig { c := defaultJigglerConfig; return &c }(), + TLSMode: "", + UsbConfig: func() *usbgadget.Config { c := defaultUsbConfig; return &c }(), + UsbDevices: func() *usbgadget.Devices { c := defaultUsbDevices; return &c }(), + NetworkConfig: func() *types.NetworkConfig { + c := &types.NetworkConfig{} + _ = confparser.SetDefaultsAndValidate(c) + return c + }(), + DefaultLogLevel: "INFO", + } } var ( @@ -195,7 +210,8 @@ func LoadConfig() { } // load the default config - config = defaultConfig + defaultConfig := getDefaultConfig() + config = &defaultConfig file, err := os.Open(configPath) if err != nil { @@ -207,7 +223,7 @@ func LoadConfig() { defer file.Close() // load and merge the default config with the user config - loadedConfig := *defaultConfig + loadedConfig := defaultConfig if err := json.NewDecoder(file).Decode(&loadedConfig); err != nil { logger.Warn().Err(err).Msg("config file JSON parsing failed") configSuccess.Set(0.0) @@ -216,19 +232,19 @@ func LoadConfig() { // merge the user config with the default config if loadedConfig.UsbConfig == nil { - loadedConfig.UsbConfig = defaultConfig.UsbConfig + loadedConfig.UsbConfig = getDefaultConfig().UsbConfig } if loadedConfig.UsbDevices == nil { - loadedConfig.UsbDevices = defaultConfig.UsbDevices + loadedConfig.UsbDevices = getDefaultConfig().UsbDevices } if loadedConfig.NetworkConfig == nil { - loadedConfig.NetworkConfig = defaultConfig.NetworkConfig + loadedConfig.NetworkConfig = getDefaultConfig().NetworkConfig } if loadedConfig.JigglerConfig == nil { - loadedConfig.JigglerConfig = defaultConfig.JigglerConfig + loadedConfig.JigglerConfig = getDefaultConfig().JigglerConfig } // fixup old keyboard layout value diff --git a/jsonrpc.go b/jsonrpc.go index 6b321c6d..e3ab0aca 100644 --- a/jsonrpc.go +++ b/jsonrpc.go @@ -720,7 +720,8 @@ func rpcSetWakeOnLanDevices(params SetWakeOnLanDevicesParams) error { } func rpcResetConfig() error { - config = defaultConfig + defaultConfig := getDefaultConfig() + config = &defaultConfig if err := SaveConfig(); err != nil { return fmt.Errorf("failed to reset config: %w", err) } From 02382e4632dcf08a9bd77b7d63be49582a3febad Mon Sep 17 00:00:00 2001 From: Siyuan Date: Fri, 10 Oct 2025 22:37:09 +0000 Subject: [PATCH 47/85] fix: mDNS options --- internal/mdns/mdns.go | 45 +++++++++++++++++++++++++++++++----------- mdns.go | 20 +++++++++---------- network.go | 45 ++++++++++++++++++++++++++++++++++-------- pkg/nmlite/hostname.go | 2 +- pkg/nmlite/manager.go | 5 ++++- 5 files changed, 86 insertions(+), 31 deletions(-) diff --git a/internal/mdns/mdns.go b/internal/mdns/mdns.go index b882b93a..dbbe43ee 100644 --- a/internal/mdns/mdns.go +++ b/internal/mdns/mdns.go @@ -146,14 +146,17 @@ func (m *MDNS) start(allowRestart bool) error { return nil } +// Start starts the mDNS server func (m *MDNS) Start() error { return m.start(false) } +// Restart restarts the mDNS server func (m *MDNS) Restart() error { return m.start(true) } +// Stop stops the mDNS server func (m *MDNS) Stop() error { m.lock.Lock() defer m.lock.Unlock() @@ -165,26 +168,46 @@ func (m *MDNS) Stop() error { return m.conn.Close() } -func (m *MDNS) SetLocalNames(localNames []string, always bool) error { - if reflect.DeepEqual(m.localNames, localNames) && !always { - return nil +func (m *MDNS) setLocalNames(localNames []string) { + m.lock.Lock() + defer m.lock.Unlock() + + if reflect.DeepEqual(m.localNames, localNames) { + return } m.localNames = localNames - _ = m.Restart() - - return nil + return } -func (m *MDNS) SetListenOptions(listenOptions *MDNSListenOptions) error { +func (m *MDNS) setListenOptions(listenOptions *MDNSListenOptions) { + m.lock.Lock() + defer m.lock.Unlock() + if m.listenOptions != nil && m.listenOptions.IPv4 == listenOptions.IPv4 && m.listenOptions.IPv6 == listenOptions.IPv6 { - return nil + return } m.listenOptions = listenOptions - _ = m.Restart() - - return nil +} + +// SetLocalNames sets the local names and restarts the mDNS server +func (m *MDNS) SetLocalNames(localNames []string) error { + m.setLocalNames(localNames) + return m.Restart() +} + +// SetListenOptions sets the listen options and restarts the mDNS server +func (m *MDNS) SetListenOptions(listenOptions *MDNSListenOptions) error { + m.setListenOptions(listenOptions) + return m.Restart() +} + +// SetOptions sets the local names and listen options and restarts the mDNS server +func (m *MDNS) SetOptions(options *MDNSOptions) error { + m.setLocalNames(options.LocalNames) + m.setListenOptions(options.ListenOptions) + return m.Restart() } diff --git a/mdns.go b/mdns.go index 8e251e1b..c197d16f 100644 --- a/mdns.go +++ b/mdns.go @@ -1,23 +1,23 @@ package kvm import ( + "fmt" + "github.com/jetkvm/kvm/internal/mdns" ) var mDNS *mdns.MDNS func initMdns() error { + options := getMdnsOptions() + if options == nil { + return fmt.Errorf("failed to get mDNS options") + } + m, err := mdns.NewMDNS(&mdns.MDNSOptions{ - Logger: logger, - LocalNames: []string{ - "jetkvm", "jetkvm.local", - // networkManager.GetHostname(), - // networkManager.GetFQDN(), - }, - ListenOptions: &mdns.MDNSListenOptions{ - IPv4: config.NetworkConfig.MDNSMode.String != "disabled", - IPv6: config.NetworkConfig.MDNSMode.String != "disabled", - }, + Logger: logger, + LocalNames: options.LocalNames, + ListenOptions: options.ListenOptions, }) if err != nil { return err diff --git a/network.go b/network.go index b4d3ad37..b957df8c 100644 --- a/network.go +++ b/network.go @@ -32,19 +32,47 @@ func toRpcNetworkSettings(config *types.NetworkConfig) *RpcNetworkSettings { } } +func getMdnsOptions() *mdns.MDNSOptions { + if networkManager == nil { + return nil + } + + var ipv4, ipv6 bool + switch config.NetworkConfig.MDNSMode.String { + case "auto": + ipv4 = true + ipv6 = true + case "ipv4_only": + ipv4 = true + case "ipv6_only": + ipv6 = true + } + + return &mdns.MDNSOptions{ + LocalNames: []string{ + networkManager.Hostname(), + networkManager.FQDN(), + }, + ListenOptions: &mdns.MDNSListenOptions{ + IPv4: ipv4, + IPv6: ipv6, + }, + } +} + func restartMdns() { if mDNS == nil { return } - _ = mDNS.SetListenOptions(&mdns.MDNSListenOptions{ - IPv4: config.NetworkConfig.MDNSMode.String != "disabled", - IPv6: config.NetworkConfig.MDNSMode.String != "disabled", - }) - _ = mDNS.SetLocalNames([]string{ - networkManager.Hostname(), - networkManager.FQDN(), - }, true) + options := getMdnsOptions() + if options == nil { + return + } + + if err := mDNS.SetOptions(options); err != nil { + networkLogger.Error().Err(err).Msg("failed to restart mDNS") + } } func triggerTimeSyncOnNetworkStateChange() { @@ -115,6 +143,7 @@ func initNetwork() error { nc := config.NetworkConfig nm := nmlite.NewNetworkManager(context.Background(), networkLogger) + networkLogger.Info().Interface("networkConfig", nc).Str("hostname", nc.Hostname.String).Str("domain", nc.Domain.String).Msg("initializing network manager") _ = setHostname(nm, nc.Hostname.String, nc.Domain.String) nm.SetOnInterfaceStateChange(networkStateChanged) if err := nm.AddInterface(NetIfName, nc); err != nil { diff --git a/pkg/nmlite/hostname.go b/pkg/nmlite/hostname.go index a75aaf4f..146aad0b 100644 --- a/pkg/nmlite/hostname.go +++ b/pkg/nmlite/hostname.go @@ -83,7 +83,7 @@ func (hm *ResolvConfManager) getDomain() string { } } - return "" + return "local" } func (hm *ResolvConfManager) reconcileHostname() error { diff --git a/pkg/nmlite/manager.go b/pkg/nmlite/manager.go index 92847176..d8a0ddd8 100644 --- a/pkg/nmlite/manager.go +++ b/pkg/nmlite/manager.go @@ -163,7 +163,10 @@ func (nm *NetworkManager) GetInterfaceState(iface string) (*types.InterfaceState return nil, err } - return im.GetState(), nil + state := im.GetState() + state.Hostname = nm.Hostname() + + return state, nil } // GetInterfaceConfig returns the current configuration of a specific interface From d02ae062e441128eba0f15724bf7ce0fe2bb9337 Mon Sep 17 00:00:00 2001 From: Siyuan Date: Fri, 10 Oct 2025 22:44:57 +0000 Subject: [PATCH 48/85] fix: make timesync non-blocking --- network.go | 8 +++++--- pkg/nmlite/interface_state.go | 4 ++-- pkg/nmlite/utils.go | 4 ++-- 3 files changed, 9 insertions(+), 7 deletions(-) diff --git a/network.go b/network.go index b957df8c..bafd8bb1 100644 --- a/network.go +++ b/network.go @@ -91,9 +91,11 @@ func triggerTimeSyncOnNetworkStateChange() { } // sync time - if err := timeSync.Sync(); err != nil { - networkLogger.Error().Err(err).Msg("failed to sync time after network state change") - } + go func() { + if err := timeSync.Sync(); err != nil { + networkLogger.Error().Err(err).Msg("failed to sync time after network state change") + } + }() } func networkStateChanged(_ string, state types.InterfaceState) { diff --git a/pkg/nmlite/interface_state.go b/pkg/nmlite/interface_state.go index 8a60784b..087cf010 100644 --- a/pkg/nmlite/interface_state.go +++ b/pkg/nmlite/interface_state.go @@ -120,12 +120,12 @@ func (im *InterfaceManager) updateInterfaceStateAddresses(nl *link.Link) (bool, } } - if !compareStringSlices(im.state.IPv4Addresses, ipv4Addresses) { + if !sortAndCompareStringSlices(im.state.IPv4Addresses, ipv4Addresses) { im.state.IPv4Addresses = ipv4Addresses stateChanged = true } - if !compareIPv6AddressSlices(im.state.IPv6Addresses, ipv6Addresses) { + if !sortAndCompareIPv6AddressSlices(im.state.IPv6Addresses, ipv6Addresses) { im.state.IPv6Addresses = ipv6Addresses stateChanged = true } diff --git a/pkg/nmlite/utils.go b/pkg/nmlite/utils.go index 5a6eb0b8..49ed0078 100644 --- a/pkg/nmlite/utils.go +++ b/pkg/nmlite/utils.go @@ -25,7 +25,7 @@ func lifetimeToTime(lifetime int) *time.Time { return &t } -func compareStringSlices(a, b []string) bool { +func sortAndCompareStringSlices(a, b []string) bool { if len(a) != len(b) { return false } @@ -42,7 +42,7 @@ func compareStringSlices(a, b []string) bool { return true } -func compareIPv6AddressSlices(a, b []types.IPv6Address) bool { +func sortAndCompareIPv6AddressSlices(a, b []types.IPv6Address) bool { if len(a) != len(b) { return false } From ff81768b8856edb67176aba76847a1ecc471d7d5 Mon Sep 17 00:00:00 2001 From: Siyuan Date: Fri, 10 Oct 2025 22:47:14 +0000 Subject: [PATCH 49/85] fix: fix field reference in confparser --- internal/confparser/confparser.go | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/internal/confparser/confparser.go b/internal/confparser/confparser.go index 73eaa7bd..c1488f4e 100644 --- a/internal/confparser/confparser.go +++ b/internal/confparser/confparser.go @@ -381,28 +381,28 @@ func (f *FieldConfig) validateSingleValue(val string, index int) error { 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) + return fmt.Errorf("field `%s` is not a valid integer: %s", fieldRef, val) } case "ipv6_prefix_length": valInt, err := strconv.Atoi(val) if err != nil { - return fmt.Errorf("field `%s` is not a valid IPv6 prefix length: %s", f.Name, val) + return fmt.Errorf("field `%s` is not a valid IPv6 prefix length: %s", fieldRef, val) } if valInt < 0 || valInt > 128 { - return fmt.Errorf("field `%s` is not a valid IPv6 prefix length: %s", f.Name, val) + return fmt.Errorf("field `%s` is not a valid IPv6 prefix length: %s", fieldRef, val) } case "ipv4": if net.ParseIP(val).To4() == nil { - return fmt.Errorf("%s is not a valid IPv4 address: %s", fieldRef, val) + return fmt.Errorf("field `%s` is not a valid IPv4 address: %s", fieldRef, val) } case "ipv6": if net.ParseIP(val).To16() == nil { - return fmt.Errorf("%s is not a valid IPv6 address: %s", fieldRef, val) + return fmt.Errorf("field `%s` is not a valid IPv6 address: %s", fieldRef, val) } case "ipv6_prefix": if i, _, err := net.ParseCIDR(val); err != nil { if i.To16() == nil { - return fmt.Errorf("%s is not a valid IPv6 prefix: %s", fieldRef, val) + return fmt.Errorf("field `%s` is not a valid IPv6 prefix: %s", fieldRef, val) } } case "ipv4_or_ipv6": @@ -430,7 +430,7 @@ func (f *FieldConfig) validateSingleValue(val string, index int) error { return fmt.Errorf("%s is not a valid CIDR notation: %s", fieldRef, val) } default: - return fmt.Errorf("field `%s` cannot use validate_type: unsupported validator: %s", f.Name, validateType) + return fmt.Errorf("field `%s` cannot use validate_type: unsupported validator: %s", fieldRef, validateType) } } From a3f7b5e93731465d48afbf8c13a061ddd1fb84ae Mon Sep 17 00:00:00 2001 From: Siyuan Date: Fri, 10 Oct 2025 22:54:35 +0000 Subject: [PATCH 50/85] refactor: rename error dump file --- cmd/main.go | 60 ++++++++++++++++++++++++++++++++--------------------- 1 file changed, 36 insertions(+), 24 deletions(-) diff --git a/cmd/main.go b/cmd/main.go index b1889e2e..a04b8b93 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -16,10 +16,10 @@ import ( ) const ( - envChildID = "JETKVM_CHILD_ID" - errorDumpDir = "/userdata/jetkvm/" - errorDumpStateFile = ".has_error_dump" - errorDumpTemplate = "jetkvm-%s.log" + envChildID = "JETKVM_CHILD_ID" + errorDumpDir = "/userdata/jetkvm/" + errorDumpLastFile = "last-crash.log" + errorDumpTemplate = "jetkvm-%s.log" ) func program() { @@ -140,30 +140,24 @@ func ensureSymlink(dst, src string) error { return os.Symlink(src, dst) } -func createErrorDump(logFile *os.File) { - logFile.Close() +func renameFile(f *os.File, newName string) error { + f.Close() - // touch the error dump state file - if err := os.WriteFile(filepath.Join(errorDumpDir, errorDumpStateFile), []byte{}, 0644); err != nil { - return + // try to rename the file first + if err := os.Rename(f.Name(), newName); err == nil { + return nil } - fileName := fmt.Sprintf(errorDumpTemplate, time.Now().Format("20060102150405")) - filePath := filepath.Join(errorDumpDir, fileName) - if err := os.Rename(logFile.Name(), filePath); err == nil { - fmt.Printf("error dump created: %s\n", filePath) - return - } - - fnSrc, err := os.Open(logFile.Name()) + // copy the log file to the error dump directory + fnSrc, err := os.Open(f.Name()) if err != nil { - return + return fmt.Errorf("failed to open file: %w", err) } defer fnSrc.Close() - fnDst, err := os.Create(filePath) + fnDst, err := os.Create(newName) if err != nil { - return + return fmt.Errorf("failed to create file: %w", err) } defer fnDst.Close() @@ -171,20 +165,38 @@ func createErrorDump(logFile *os.File) { for { n, err := fnSrc.Read(buf) if err != nil && err != io.EOF { - return + return fmt.Errorf("failed to read file: %w", err) } if n == 0 { break } if _, err := fnDst.Write(buf[:n]); err != nil { - return + return fmt.Errorf("failed to write file: %w", err) } } - fmt.Printf("error dump created: %s\n", filePath) + return nil +} - _ = ensureSymlink(filePath, filepath.Join(errorDumpDir, "last-crash.log")) +func createErrorDump(logFile *os.File) { + fileName := fmt.Sprintf( + errorDumpTemplate, + time.Now().Format("20060102-150405"), + ) + + filePath := filepath.Join(errorDumpDir, fileName) + if err := renameFile(logFile, filePath); err != nil { + fmt.Printf("failed to rename file: %v\n", err) + return + } + + fmt.Printf("error dump copied: %s\n", fileName) + + if err := ensureSymlink(filePath, filepath.Join(errorDumpDir, errorDumpLastFile)); err != nil { + fmt.Printf("failed to create symlink: %v\n", err) + return + } } func doSupervise() { From aa9d78998fd9d723ec8505f3c90fa4d0936280cd Mon Sep 17 00:00:00 2001 From: Siyuan Date: Fri, 10 Oct 2025 22:56:27 +0000 Subject: [PATCH 51/85] fix: dhcpc button doesnt work --- internal/native/eez/jetkvm.eez-project | 16 ++++++++++++---- internal/native/eez/src/ui/actions.c | 4 ++++ internal/native/eez/src/ui/actions.h | 1 + internal/native/eez/src/ui/screens.c | 4 ++-- 4 files changed, 19 insertions(+), 6 deletions(-) diff --git a/internal/native/eez/jetkvm.eez-project b/internal/native/eez/jetkvm.eez-project index 5d09cb25..d174f159 100644 --- a/internal/native/eez/jetkvm.eez-project +++ b/internal/native/eez/jetkvm.eez-project @@ -48,7 +48,7 @@ { "objID": "58af3ebb-96b3-494c-f4e3-9c23852e3e42", "fileName": "actions.c", - "template": "#include \"actions.h\"\n#include \"screens.h\"\n#include \n#include \n#include \"ui.h\"\n#include \"vars.h\"\n\nint handle_gesture_screen_switch(lv_event_t *e, lv_dir_t direction, int screenId) {\n lv_event_code_t event_code = lv_event_get_code(e);\n if (event_code != LV_EVENT_GESTURE) {\n return 0;\n }\n\n if (lv_indev_get_gesture_dir(lv_indev_get_act()) != direction) {\n return 0;\n }\n lv_indev_wait_release(lv_indev_get_act());\n loadScreen(screenId);\n return 1;\n}\n\nvoid handle_gesture_main_screen_switch(lv_event_t *e, lv_dir_t direction) {\n const char *main_screen = get_var_main_screen();\n if (strcmp(main_screen, \"home_screen\") == 0) { \n loadScreen(SCREEN_ID_HOME_SCREEN);\n } else if (strcmp(main_screen, \"no_network_screen\") == 0) {\n loadScreen(SCREEN_ID_NO_NETWORK_SCREEN);\n }\n}\n\nvoid action_switch_to_menu(lv_event_t *e) {\n loadScreen(SCREEN_ID_MENU_SCREEN);\n}\n\nvoid action_switch_to_advanced_menu(lv_event_t *e) {\n loadScreen(SCREEN_ID_MENU_ADVANCED_SCREEN);\n}\n\nvoid action_switch_to_status(lv_event_t *e) {\n loadScreen(SCREEN_ID_STATUS_SCREEN);\n}\n\nvoid action_switch_to_about(lv_event_t *e) {\n loadScreen(SCREEN_ID_ABOUT_SCREEN);\n}\n\nvoid action_switch_to_reset_config(lv_event_t *e) {\n loadScreen(SCREEN_ID_RESET_CONFIG_SCREEN);\n}\n\nvoid action_switch_to_reboot(lv_event_t *e) {\n loadScreen(SCREEN_ID_REBOOT_SCREEN);\n}\n\nvoid action_menu_screen_gesture(lv_event_t * e) {\n handle_gesture_main_screen_switch(e, LV_DIR_RIGHT);\n}\n\nvoid action_menu_advanced_screen_gesture(lv_event_t * e) {\n handle_gesture_screen_switch(e, LV_DIR_RIGHT, SCREEN_ID_MENU_SCREEN);\n}\n\nvoid action_reset_config_screen_gesture(lv_event_t * e) {\n handle_gesture_screen_switch(e, LV_DIR_RIGHT, SCREEN_ID_MENU_SCREEN);\n}\n\nvoid action_home_screen_gesture(lv_event_t * e) {\n handle_gesture_screen_switch(e, LV_DIR_LEFT, SCREEN_ID_MENU_SCREEN);\n}\n\nvoid action_about_screen_gesture(lv_event_t * e) {\n handle_gesture_screen_switch(e, LV_DIR_RIGHT, SCREEN_ID_MENU_SCREEN);\n}\n\n// user_data doesn't seem to be working, so we use a global variable here\nstatic uint32_t t_reset_config;\nstatic uint32_t t_reboot;\nstatic uint32_t t_dhcpc;\n\nstatic bool b_reboot = false;\nstatic bool b_reset_config = false;\nstatic bool b_dhcpc = false;\n\nstatic bool b_reboot_lock = false;\nstatic bool b_reset_config_lock = false;\nstatic bool b_dhcpc_lock = false;\n\nconst int RESET_CONFIG_HOLD_TIME = 10;\nconst int REBOOT_HOLD_TIME = 5;\nconst int DHCPC_HOLD_TIME = 5;\n\ntypedef struct {\n uint32_t *start_time;\n bool *completed;\n bool *lock;\n int hold_time_seconds;\n const char *rpc_method;\n lv_obj_t *button_obj;\n lv_obj_t *spinner_obj;\n lv_obj_t *label_obj;\n const char *default_text;\n} hold_action_config_t;\n\nstatic void handle_hold_action(lv_event_t *e, hold_action_config_t *config) {\n lv_event_code_t event_code = lv_event_get_code(e);\n \n if (event_code == LV_EVENT_PRESSED) {\n *(config->start_time) = lv_tick_get();\n }\n else if (event_code == LV_EVENT_PRESSING) {\n int remaining_time = config->hold_time_seconds * 1000 - lv_tick_elaps(*(config->start_time));\n if (remaining_time <= 0) {\n if (*(config->lock)) {\n return;\n }\n if (config->button_obj && config->spinner_obj) {\n lv_obj_add_flag(config->button_obj, LV_OBJ_FLAG_HIDDEN);\n lv_obj_clear_flag(config->spinner_obj, LV_OBJ_FLAG_HIDDEN);\n }\n ui_call_rpc_handler(config->rpc_method, NULL);\n *(config->lock) = true;\n *(config->completed) = true;\n } else {\n *(config->completed) = false;\n char buf[100];\n int remaining_time_seconds = remaining_time / 1000;\n if (remaining_time_seconds <= 1) {\n remaining_time_seconds = 1;\n }\n sprintf(buf, \"Press and hold for\\n%d seconds\", remaining_time_seconds);\n lv_label_set_text(config->label_obj, buf);\n }\n } else if (event_code == LV_EVENT_RELEASED) {\n if (*(config->lock)) {\n *(config->lock) = false;\n }\n\n if (!*(config->completed)) {\n lv_label_set_text(config->label_obj, config->default_text);\n }\n }\n}\n\nvoid action_reset_config(lv_event_t * e) {\n hold_action_config_t config = {\n .start_time = &t_reset_config,\n .completed = &b_reset_config,\n .lock = &b_reset_config_lock,\n .hold_time_seconds = RESET_CONFIG_HOLD_TIME,\n .rpc_method = \"resetConfig\",\n .button_obj = objects.reset_config_button,\n .spinner_obj = objects.reset_config_spinner,\n .label_obj = objects.reset_config_label,\n .default_text = \"Press and hold for\\n10 seconds\"\n };\n \n handle_hold_action(e, &config);\n}\n\nvoid action_dhcpc(lv_event_t * e) {\n hold_action_config_t config = {\n .start_time = &t_dhcpc,\n .completed = &b_dhcpc,\n .lock = &b_dhcpc_lock,\n .hold_time_seconds = DHCPC_HOLD_TIME,\n .rpc_method = \"toggleDHCPClient\",\n .button_obj = NULL, // No button/spinner for reboot\n .spinner_obj = NULL,\n .label_obj = objects.dhcpc_label,\n .default_text = \"Press and hold for\\n5 seconds\"\n };\n \n handle_hold_action(e, &config);\n}\n\nvoid action_reboot(lv_event_t * e) {\n hold_action_config_t config = {\n .start_time = &t_reboot,\n .completed = &b_reboot,\n .lock = &b_reboot_lock,\n .hold_time_seconds = REBOOT_HOLD_TIME,\n .rpc_method = \"reboot\",\n .button_obj = NULL, // No button/spinner for reboot\n .spinner_obj = NULL,\n .label_obj = objects.reboot_label,\n .default_text = \"Press and hold for\\n5 seconds\"\n };\n \n handle_hold_action(e, &config);\n}" + "template": "#include \"actions.h\"\n#include \"screens.h\"\n#include \n#include \n#include \"ui.h\"\n#include \"vars.h\"\n\nint handle_gesture_screen_switch(lv_event_t *e, lv_dir_t direction, int screenId) {\n lv_event_code_t event_code = lv_event_get_code(e);\n if (event_code != LV_EVENT_GESTURE) {\n return 0;\n }\n\n if (lv_indev_get_gesture_dir(lv_indev_get_act()) != direction) {\n return 0;\n }\n lv_indev_wait_release(lv_indev_get_act());\n loadScreen(screenId);\n return 1;\n}\n\nvoid handle_gesture_main_screen_switch(lv_event_t *e, lv_dir_t direction) {\n const char *main_screen = get_var_main_screen();\n if (strcmp(main_screen, \"home_screen\") == 0) { \n loadScreen(SCREEN_ID_HOME_SCREEN);\n } else if (strcmp(main_screen, \"no_network_screen\") == 0) {\n loadScreen(SCREEN_ID_NO_NETWORK_SCREEN);\n }\n}\n\nvoid action_switch_to_menu(lv_event_t *e) {\n loadScreen(SCREEN_ID_MENU_SCREEN);\n}\n\nvoid action_switch_to_advanced_menu(lv_event_t *e) {\n loadScreen(SCREEN_ID_MENU_ADVANCED_SCREEN);\n}\n\nvoid action_switch_to_status(lv_event_t *e) {\n loadScreen(SCREEN_ID_STATUS_SCREEN);\n}\n\nvoid action_switch_to_about(lv_event_t *e) {\n loadScreen(SCREEN_ID_ABOUT_SCREEN);\n}\n\nvoid action_switch_to_reset_config(lv_event_t *e) {\n loadScreen(SCREEN_ID_RESET_CONFIG_SCREEN);\n}\n\nvoid action_switch_to_dhcpc(lv_event_t *e) {\n loadScreen(SCREEN_ID_SWITCH_DHCP_CLIENT_SCREEN);\n}\n\nvoid action_switch_to_reboot(lv_event_t *e) {\n loadScreen(SCREEN_ID_REBOOT_SCREEN);\n}\n\nvoid action_menu_screen_gesture(lv_event_t * e) {\n handle_gesture_main_screen_switch(e, LV_DIR_RIGHT);\n}\n\nvoid action_menu_advanced_screen_gesture(lv_event_t * e) {\n handle_gesture_screen_switch(e, LV_DIR_RIGHT, SCREEN_ID_MENU_SCREEN);\n}\n\nvoid action_reset_config_screen_gesture(lv_event_t * e) {\n handle_gesture_screen_switch(e, LV_DIR_RIGHT, SCREEN_ID_MENU_SCREEN);\n}\n\nvoid action_home_screen_gesture(lv_event_t * e) {\n handle_gesture_screen_switch(e, LV_DIR_LEFT, SCREEN_ID_MENU_SCREEN);\n}\n\nvoid action_about_screen_gesture(lv_event_t * e) {\n handle_gesture_screen_switch(e, LV_DIR_RIGHT, SCREEN_ID_MENU_SCREEN);\n}\n\n// user_data doesn't seem to be working, so we use a global variable here\nstatic uint32_t t_reset_config;\nstatic uint32_t t_reboot;\nstatic uint32_t t_dhcpc;\n\nstatic bool b_reboot = false;\nstatic bool b_reset_config = false;\nstatic bool b_dhcpc = false;\n\nstatic bool b_reboot_lock = false;\nstatic bool b_reset_config_lock = false;\nstatic bool b_dhcpc_lock = false;\n\nconst int RESET_CONFIG_HOLD_TIME = 10;\nconst int REBOOT_HOLD_TIME = 5;\nconst int DHCPC_HOLD_TIME = 5;\n\ntypedef struct {\n uint32_t *start_time;\n bool *completed;\n bool *lock;\n int hold_time_seconds;\n const char *rpc_method;\n lv_obj_t *button_obj;\n lv_obj_t *spinner_obj;\n lv_obj_t *label_obj;\n const char *default_text;\n} hold_action_config_t;\n\nstatic void handle_hold_action(lv_event_t *e, hold_action_config_t *config) {\n lv_event_code_t event_code = lv_event_get_code(e);\n \n if (event_code == LV_EVENT_PRESSED) {\n *(config->start_time) = lv_tick_get();\n }\n else if (event_code == LV_EVENT_PRESSING) {\n int remaining_time = config->hold_time_seconds * 1000 - lv_tick_elaps(*(config->start_time));\n if (remaining_time <= 0) {\n if (*(config->lock)) {\n return;\n }\n if (config->button_obj && config->spinner_obj) {\n lv_obj_add_flag(config->button_obj, LV_OBJ_FLAG_HIDDEN);\n lv_obj_clear_flag(config->spinner_obj, LV_OBJ_FLAG_HIDDEN);\n }\n ui_call_rpc_handler(config->rpc_method, NULL);\n *(config->lock) = true;\n *(config->completed) = true;\n } else {\n *(config->completed) = false;\n char buf[100];\n int remaining_time_seconds = remaining_time / 1000;\n if (remaining_time_seconds <= 1) {\n remaining_time_seconds = 1;\n }\n sprintf(buf, \"Press and hold for\\n%d seconds\", remaining_time_seconds);\n lv_label_set_text(config->label_obj, buf);\n }\n } else if (event_code == LV_EVENT_RELEASED) {\n if (*(config->lock)) {\n *(config->lock) = false;\n }\n\n if (!*(config->completed)) {\n lv_label_set_text(config->label_obj, config->default_text);\n }\n }\n}\n\nvoid action_reset_config(lv_event_t * e) {\n hold_action_config_t config = {\n .start_time = &t_reset_config,\n .completed = &b_reset_config,\n .lock = &b_reset_config_lock,\n .hold_time_seconds = RESET_CONFIG_HOLD_TIME,\n .rpc_method = \"resetConfig\",\n .button_obj = objects.reset_config_button,\n .spinner_obj = objects.reset_config_spinner,\n .label_obj = objects.reset_config_label,\n .default_text = \"Press and hold for\\n10 seconds\"\n };\n \n handle_hold_action(e, &config);\n}\n\nvoid action_dhcpc(lv_event_t * e) {\n hold_action_config_t config = {\n .start_time = &t_dhcpc,\n .completed = &b_dhcpc,\n .lock = &b_dhcpc_lock,\n .hold_time_seconds = DHCPC_HOLD_TIME,\n .rpc_method = \"toggleDHCPClient\",\n .button_obj = NULL, // No button/spinner for reboot\n .spinner_obj = NULL,\n .label_obj = objects.dhcpc_label,\n .default_text = \"Press and hold for\\n5 seconds\"\n };\n \n handle_hold_action(e, &config);\n}\n\nvoid action_reboot(lv_event_t * e) {\n hold_action_config_t config = {\n .start_time = &t_reboot,\n .completed = &b_reboot,\n .lock = &b_reboot_lock,\n .hold_time_seconds = REBOOT_HOLD_TIME,\n .rpc_method = \"reboot\",\n .button_obj = NULL, // No button/spinner for reboot\n .spinner_obj = NULL,\n .label_obj = objects.reboot_label,\n .default_text = \"Press and hold for\\n5 seconds\"\n };\n \n handle_hold_action(e, &config);\n}" }, { "objID": "1dbd1b7e-7270-47f0-ee02-e80bdae287cf", @@ -281,6 +281,14 @@ "localVariables": [], "userProperties": [], "name": "DHCPC" + }, + { + "objID": "1fbdb292-2b01-4dfe-a4ab-da889959259e", + "components": [], + "connectionLines": [], + "localVariables": [], + "userProperties": [], + "name": "SwitchToDHCPC" } ], "userPages": [ @@ -3367,7 +3375,7 @@ "objID": "6a730d9a-c11d-4800-9833-f4d7b5a8a40d", "eventName": "PRESSED", "handlerType": "action", - "action": "SwitchToReboot", + "action": "SwitchToDHCPC", "userData": 0 } ], @@ -8379,7 +8387,7 @@ "type": "LVGLLabelWidget", "left": 0, "top": 0, - "width": 116, + "width": 115, "height": 20, "customInputs": [], "customOutputs": [], @@ -8408,7 +8416,7 @@ "objID": "0ac01432-b282-41c4-b119-316fe81ad79b" }, "groupIndex": 0, - "text": "Reset Config", + "text": "DHCP Client", "textType": "literal", "longMode": "WRAP", "recolor": false diff --git a/internal/native/eez/src/ui/actions.c b/internal/native/eez/src/ui/actions.c index 135d8938..fd13c142 100644 --- a/internal/native/eez/src/ui/actions.c +++ b/internal/native/eez/src/ui/actions.c @@ -48,6 +48,10 @@ void action_switch_to_reset_config(lv_event_t *e) { loadScreen(SCREEN_ID_RESET_CONFIG_SCREEN); } +void action_switch_to_dhcpc(lv_event_t *e) { + loadScreen(SCREEN_ID_SWITCH_DHCP_CLIENT_SCREEN); +} + void action_switch_to_reboot(lv_event_t *e) { loadScreen(SCREEN_ID_REBOOT_SCREEN); } diff --git a/internal/native/eez/src/ui/actions.h b/internal/native/eez/src/ui/actions.h index 4ec4a807..a4179b3c 100644 --- a/internal/native/eez/src/ui/actions.h +++ b/internal/native/eez/src/ui/actions.h @@ -25,6 +25,7 @@ extern void action_reset_config(lv_event_t * e); extern void action_reboot(lv_event_t * e); extern void action_switch_to_reboot(lv_event_t * e); extern void action_dhcpc(lv_event_t * e); +extern void action_switch_to_dhcpc(lv_event_t * e); #ifdef __cplusplus diff --git a/internal/native/eez/src/ui/screens.c b/internal/native/eez/src/ui/screens.c index 566006f1..d2824a57 100644 --- a/internal/native/eez/src/ui/screens.c +++ b/internal/native/eez/src/ui/screens.c @@ -893,7 +893,7 @@ void create_screen_menu_advanced_screen() { objects.menu_btn_dhcp_client = obj; lv_obj_set_pos(obj, 0, 0); lv_obj_set_size(obj, LV_PCT(100), 50); - lv_obj_add_event_cb(obj, action_switch_to_reboot, LV_EVENT_PRESSED, (void *)0); + lv_obj_add_event_cb(obj, action_switch_to_dhcpc, LV_EVENT_PRESSED, (void *)0); lv_obj_clear_flag(obj, LV_OBJ_FLAG_SNAPPABLE); add_style_menu_button(obj); { @@ -2278,7 +2278,7 @@ void create_screen_switch_dhcp_client_screen() { lv_obj_set_pos(obj, LV_PCT(0), LV_PCT(0)); lv_obj_set_size(obj, LV_SIZE_CONTENT, LV_SIZE_CONTENT); add_style_header_link(obj); - lv_label_set_text(obj, "Reset Config"); + lv_label_set_text(obj, "DHCP Client"); } } } From ae77887ab28d977419e591ccabdd5311f2420cdb Mon Sep 17 00:00:00 2001 From: Siyuan Date: Fri, 10 Oct 2025 22:58:09 +0000 Subject: [PATCH 52/85] fix: symlink direction --- cmd/main.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/main.go b/cmd/main.go index a04b8b93..3c5443a4 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -137,7 +137,7 @@ func ensureSymlink(dst, src string) error { return nil } _ = os.Remove(dst) - return os.Symlink(src, dst) + return os.Symlink(dst, src) } func renameFile(f *os.File, newName string) error { From aee1a01cee4b5b639d245531930c9c778fa840be Mon Sep 17 00:00:00 2001 From: Siyuan Date: Fri, 10 Oct 2025 23:08:27 +0000 Subject: [PATCH 53/85] fix: symlink handling --- cmd/main.go | 26 +++++++++++++++----------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/cmd/main.go b/cmd/main.go index 3c5443a4..eb6ac796 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -117,31 +117,31 @@ func supervise() error { return nil } -func isSymlinkTo(dst, src string) bool { - file, err := os.Stat(dst) +func isSymlinkTo(oldName, newName string) bool { + file, err := os.Stat(newName) if err != nil { return false } if file.Mode()&os.ModeSymlink != os.ModeSymlink { return false } - target, err := os.Readlink(dst) + target, err := os.Readlink(newName) if err != nil { return false } - return target == src + return target == oldName } -func ensureSymlink(dst, src string) error { - if isSymlinkTo(dst, src) { +func ensureSymlink(oldName, newName string) error { + if isSymlinkTo(oldName, newName) { return nil } - _ = os.Remove(dst) - return os.Symlink(dst, src) + _ = os.Remove(newName) + return os.Symlink(oldName, newName) } func renameFile(f *os.File, newName string) error { - f.Close() + _ = f.Close() // try to rename the file first if err := os.Rename(f.Name(), newName); err == nil { @@ -180,6 +180,8 @@ func renameFile(f *os.File, newName string) error { } func createErrorDump(logFile *os.File) { + fmt.Println() + fileName := fmt.Sprintf( errorDumpTemplate, time.Now().Format("20060102-150405"), @@ -191,9 +193,11 @@ func createErrorDump(logFile *os.File) { return } - fmt.Printf("error dump copied: %s\n", fileName) + fmt.Printf("error dump copied: %s\n", filePath) - if err := ensureSymlink(filePath, filepath.Join(errorDumpDir, errorDumpLastFile)); err != nil { + lastFilePath := filepath.Join(errorDumpDir, errorDumpLastFile) + + if err := ensureSymlink(filePath, lastFilePath); err != nil { fmt.Printf("failed to create symlink: %v\n", err) return } From 8810ed4827d5056dc013d2861b801cc89216e52f Mon Sep 17 00:00:00 2001 From: Siyuan Date: Sat, 11 Oct 2025 15:48:53 +0000 Subject: [PATCH 54/85] fix: error dump directory --- cmd/main.go | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/cmd/main.go b/cmd/main.go index eb6ac796..d9636088 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -17,7 +17,7 @@ import ( const ( envChildID = "JETKVM_CHILD_ID" - errorDumpDir = "/userdata/jetkvm/" + errorDumpDir = "/userdata/jetkvm/crashdump" errorDumpLastFile = "last-crash.log" errorDumpTemplate = "jetkvm-%s.log" ) @@ -179,6 +179,18 @@ func renameFile(f *os.File, newName string) error { return nil } +func ensureErrorDumpDir() error { + // TODO: check if the directory is writable + f, err := os.Stat(errorDumpDir) + if err == nil && f.IsDir() { + return nil + } + if err := os.MkdirAll(errorDumpDir, 0755); err != nil { + return fmt.Errorf("failed to create error dump directory: %w", err) + } + return nil +} + func createErrorDump(logFile *os.File) { fmt.Println() @@ -187,6 +199,12 @@ func createErrorDump(logFile *os.File) { time.Now().Format("20060102-150405"), ) + // check if the directory exists + if err := ensureErrorDumpDir(); err != nil { + fmt.Printf("failed to ensure error dump directory: %v\n", err) + return + } + filePath := filepath.Join(errorDumpDir, fileName) if err := renameFile(logFile, filePath); err != nil { fmt.Printf("failed to rename file: %v\n", err) From bc4c2d92db70534674368384536d24e79c9b3a5b Mon Sep 17 00:00:00 2001 From: Siyuan Date: Sat, 11 Oct 2025 15:52:51 +0000 Subject: [PATCH 55/85] fix: golangci-lint warnings --- internal/mdns/mdns.go | 1 - 1 file changed, 1 deletion(-) diff --git a/internal/mdns/mdns.go b/internal/mdns/mdns.go index dbbe43ee..2b954d45 100644 --- a/internal/mdns/mdns.go +++ b/internal/mdns/mdns.go @@ -177,7 +177,6 @@ func (m *MDNS) setLocalNames(localNames []string) { } m.localNames = localNames - return } func (m *MDNS) setListenOptions(listenOptions *MDNSListenOptions) { From 76d256b69aa9e589e3dcf2f86ece9eb5ffacbea2 Mon Sep 17 00:00:00 2001 From: Siyuan Date: Sat, 11 Oct 2025 16:15:35 +0000 Subject: [PATCH 56/85] feat: add CIDR notation support for IPv4 address --- ui/src/components/StaticIpv4Card.tsx | 33 +++++++++++++++---- .../routes/devices.$id.settings.network.tsx | 11 +++++++ ui/src/utils/ip.ts | 10 ++++++ 3 files changed, 47 insertions(+), 7 deletions(-) create mode 100644 ui/src/utils/ip.ts diff --git a/ui/src/components/StaticIpv4Card.tsx b/ui/src/components/StaticIpv4Card.tsx index e2eaf2c5..c0c5d19c 100644 --- a/ui/src/components/StaticIpv4Card.tsx +++ b/ui/src/components/StaticIpv4Card.tsx @@ -2,31 +2,49 @@ import { LuPlus, LuX } from "react-icons/lu"; import { useFieldArray, useFormContext } from "react-hook-form"; import { useEffect } from "react"; import validator from "validator"; +import { cx } from "cva"; import { GridCard } from "@/components/Card"; import { Button } from "@/components/Button"; import { InputFieldWithLabel } from "@/components/InputField"; import { NetworkSettings } from "@/hooks/stores"; +import { netMaskFromCidr4 } from "@/utils/ip"; export default function StaticIpv4Card() { const formMethods = useFormContext(); - const { register, formState, watch } = formMethods; + const { register, formState, watch, setValue } = formMethods; const { fields, append, remove } = useFieldArray({ name: "ipv4_static.dns" }); - // TODO: set subnet mask if IP address is in CIDR notation - useEffect(() => { if (fields.length === 0) append(""); }, [append, fields.length]); const dns = watch("ipv4_static.dns"); + const ipv4StaticAddress = watch("ipv4_static.address"); + const hideSubnetMask = ipv4StaticAddress?.includes("/"); + useEffect(() => { + const parts = ipv4StaticAddress?.split("/", 2); + if (parts.length !== 2) return; + + const cidrNotation = parseInt(parts[1]); + if (isNaN(cidrNotation) || cidrNotation < 0 || cidrNotation > 32) return; + + const mask = netMaskFromCidr4(cidrNotation); + setValue("ipv4_static.netmask", mask); + }, [ipv4StaticAddress, setValue]); + const validate = (value: string) => { if (!validator.isIP(value)) return "Invalid IP address"; return true; }; + const validateIsIPOrCIDR4 = (value: string) => { + if (!validator.isIP(value, 4) && !validator.isIPRange(value, 4)) return "Invalid IP address or CIDR notation"; + return true; + }; + return (
@@ -35,24 +53,25 @@ export default function StaticIpv4Card() { Static IPv4 Configuration -
+
- + />}
{ + if (settings.ipv4_static?.address?.includes("/")) { + const parts = settings.ipv4_static.address.split("/"); + const cidrNotation = parseInt(parts[1]); + if (isNaN(cidrNotation) || cidrNotation < 0 || cidrNotation > 32) { + return notifications.error("Invalid CIDR notation for IPv4 address"); + } + settings.ipv4_static.netmask = netMaskFromCidr4(cidrNotation); + settings.ipv4_static.address = parts[0]; + } + send("setNetworkSettings", { settings }, async (resp) => { if ("error" in resp) { return notifications.error( diff --git a/ui/src/utils/ip.ts b/ui/src/utils/ip.ts new file mode 100644 index 00000000..d9ad2389 --- /dev/null +++ b/ui/src/utils/ip.ts @@ -0,0 +1,10 @@ +export const netMaskFromCidr4 = (cidr: number) => { + const mask = []; + let bitCount = cidr; + for(let i=0; i<4; i++) { + const n = Math.min(bitCount, 8); + mask.push(256 - Math.pow(2, 8-n)); + bitCount -= n; + } + return mask.join('.'); +}; \ No newline at end of file From 5e06625966e61312ff416a909c2d003cb416bfd4 Mon Sep 17 00:00:00 2001 From: Adam Shiervani Date: Mon, 13 Oct 2025 17:00:43 +0200 Subject: [PATCH 57/85] feat: add copy to clipboard functionality for MAC address in network settings --- ui/package-lock.json | 4 +- ui/src/components/useCopyToClipBoard.tsx | 49 +++++++++++++++++++ .../routes/devices.$id.settings.network.tsx | 36 +++++++++----- 3 files changed, 73 insertions(+), 16 deletions(-) create mode 100644 ui/src/components/useCopyToClipBoard.tsx diff --git a/ui/package-lock.json b/ui/package-lock.json index 9f948f2c..fb6903d6 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -28,6 +28,7 @@ "react": "^19.1.1", "react-animate-height": "^3.2.3", "react-dom": "^19.1.1", + "react-hook-form": "^7.62.0", "react-hot-toast": "^2.6.0", "react-icons": "^5.5.0", "react-router": "^7.9.3", @@ -5856,8 +5857,6 @@ "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", @@ -5874,7 +5873,6 @@ "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/src/components/useCopyToClipBoard.tsx b/ui/src/components/useCopyToClipBoard.tsx new file mode 100644 index 00000000..39041bb9 --- /dev/null +++ b/ui/src/components/useCopyToClipBoard.tsx @@ -0,0 +1,49 @@ +import { useCallback, useState } from "react"; + +export function useCopyToClipboard(resetInterval = 2000) { + const [isCopied, setIsCopied] = useState(false); + + const copy = useCallback(async (text: string) => { + if (!text) return false; + + let success = false; + + if (navigator.clipboard && window.isSecureContext) { + try { + await navigator.clipboard.writeText(text); + success = true; + } catch (err) { + console.warn("Clipboard API failed:", err); + } + } + + // Fallback for insecure contexts + if (!success) { + const textarea = document.createElement("textarea"); + textarea.value = text; + textarea.style.position = "fixed"; + textarea.style.opacity = "0"; + document.body.appendChild(textarea); + textarea.focus(); + textarea.select(); + + try { + success = document.execCommand("copy"); + } catch (err) { + console.error("Fallback copy failed:", err); + success = false; + } finally { + document.body.removeChild(textarea); + } + } + + setIsCopied(success); + if (success && resetInterval > 0) { + setTimeout(() => setIsCopied(false), resetInterval); + } + + return success; + }, [resetInterval]); + + return { copy, isCopied }; +} diff --git a/ui/src/routes/devices.$id.settings.network.tsx b/ui/src/routes/devices.$id.settings.network.tsx index 17fe79ac..d36bd9dd 100644 --- a/ui/src/routes/devices.$id.settings.network.tsx +++ b/ui/src/routes/devices.$id.settings.network.tsx @@ -2,7 +2,7 @@ import dayjs from "dayjs"; import relativeTime from "dayjs/plugin/relativeTime"; import { useCallback, useEffect, useRef, useState } from "react"; import { FieldValues, FormProvider, useForm } from "react-hook-form"; -import { LuEthernetPort } from "react-icons/lu"; +import { LuCopy, LuEthernetPort } from "react-icons/lu"; import validator from "validator"; import { ConfirmDialog } from "@/components/ConfirmDialog"; @@ -24,6 +24,7 @@ import StaticIpv4Card from "../components/StaticIpv4Card"; import StaticIpv6Card from "../components/StaticIpv6Card"; import { useJsonRpc } from "../hooks/useJsonRpc"; import { SettingsItem } from "../components/SettingsItem"; +import { useCopyToClipboard } from "../components/useCopyToClipBoard"; dayjs.extend(relativeTime); @@ -225,6 +226,8 @@ export default function SettingsNetworkRoute() { }); }; + const { copy } = useCopyToClipboard(); + return ( <> @@ -248,19 +251,26 @@ export default function SettingsNetworkRoute() { } />
- - + - +
+ +
+ {networkState?.mac_address} {" "} +
+
+
+
Date: Mon, 13 Oct 2025 17:09:10 +0200 Subject: [PATCH 58/85] Close Modals on Escape --- ui/src/components/ConfirmDialog.tsx | 77 ++++++++++++++++------------- 1 file changed, 43 insertions(+), 34 deletions(-) diff --git a/ui/src/components/ConfirmDialog.tsx b/ui/src/components/ConfirmDialog.tsx index d302ead6..9cd9c9ba 100644 --- a/ui/src/components/ConfirmDialog.tsx +++ b/ui/src/components/ConfirmDialog.tsx @@ -67,46 +67,55 @@ export function ConfirmDialog({ }: ConfirmDialogProps) { const { icon: Icon, iconClass, iconBgClass, buttonTheme } = variantConfig[variant]; + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Escape") { + e.stopPropagation(); + onClose(); + } + }; + return ( - -
-
-
-
-
-
-
-

- {title} -

-
- {description} +
+ +
+
+
+
+
+
+
+

+ {title} +

+
+ {description} +
-
-
- {cancelText && ( - - )} -
-
- + +
); } From aa6f5b496d9f70fffc38be7138464ecd983865c1 Mon Sep 17 00:00:00 2001 From: Adam Shiervani Date: Mon, 13 Oct 2025 17:17:51 +0200 Subject: [PATCH 59/85] feat: add DHCP client as a critical field --- ui/src/routes/devices.$id.settings.network.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/ui/src/routes/devices.$id.settings.network.tsx b/ui/src/routes/devices.$id.settings.network.tsx index d36bd9dd..7be6ef51 100644 --- a/ui/src/routes/devices.$id.settings.network.tsx +++ b/ui/src/routes/devices.$id.settings.network.tsx @@ -191,6 +191,7 @@ export default function SettingsNetworkRoute() { // Label is for the UI, key is the internal key of the field { label: "IPv4 mode", key: "ipv4_mode" }, { label: "IPv6 mode", key: "ipv6_mode" }, + { label: "DHCP client", key: "dhcp_client" }, ] as { label: string; key: keyof NetworkSettings }[]; const criticalChanged = criticalFields.some(field => dirty[field.key]); From b6a1eecc1fd15e73084501a10fcdb26fe9e23356 Mon Sep 17 00:00:00 2001 From: Siyuan Date: Mon, 13 Oct 2025 15:57:36 +0000 Subject: [PATCH 60/85] fix: touchscreen dhcp client button --- internal/native/eez/jetkvm.eez-project | 2 +- internal/native/eez/src/ui/screens.c | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/native/eez/jetkvm.eez-project b/internal/native/eez/jetkvm.eez-project index d174f159..42901345 100644 --- a/internal/native/eez/jetkvm.eez-project +++ b/internal/native/eez/jetkvm.eez-project @@ -8561,7 +8561,7 @@ }, "group": "", "groupIndex": 0, - "text": "Press and hold for\n10 seconds", + "text": "Press and hold for\n5 seconds", "textType": "literal", "longMode": "WRAP", "recolor": false, diff --git a/internal/native/eez/src/ui/screens.c b/internal/native/eez/src/ui/screens.c index d2824a57..48abea41 100644 --- a/internal/native/eez/src/ui/screens.c +++ b/internal/native/eez/src/ui/screens.c @@ -2341,7 +2341,7 @@ void create_screen_switch_dhcp_client_screen() { lv_obj_set_size(obj, LV_PCT(100), LV_SIZE_CONTENT); add_style_info_content_label(obj); lv_obj_set_style_text_font(obj, &ui_font_font_book20, LV_PART_MAIN | LV_STATE_DEFAULT); - lv_label_set_text(obj, "Press and hold for\n10 seconds"); + lv_label_set_text(obj, "Press and hold for\n5 seconds"); } } } From 667877ff507b936e56be7dcc61cc971e01db4e26 Mon Sep 17 00:00:00 2001 From: Siyuan Date: Mon, 13 Oct 2025 16:00:10 +0000 Subject: [PATCH 61/85] fix: save config after toggling dhcp client --- network.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/network.go b/network.go index bafd8bb1..719fdd0c 100644 --- a/network.go +++ b/network.go @@ -233,5 +233,9 @@ func rpcToggleDHCPClient() error { config.NetworkConfig.DHCPClient.String = "jetdhcpc" } + if err := SaveConfig(); err != nil { + return err + } + return rpcReboot(false) } From 9b46209f1bf2ea30d91a1d20ccf9a908a5be5632 Mon Sep 17 00:00:00 2001 From: Siyuan Date: Mon, 13 Oct 2025 16:17:35 +0000 Subject: [PATCH 62/85] fix: clean up udhcpc processes --- network.go | 1 + pkg/nmlite/jetdhcpc/legacy.go | 15 +++++++++++---- pkg/nmlite/manager.go | 27 +++++++++++++++++++++++++++ 3 files changed, 39 insertions(+), 4 deletions(-) diff --git a/network.go b/network.go index 719fdd0c..8a7c2761 100644 --- a/network.go +++ b/network.go @@ -151,6 +151,7 @@ func initNetwork() error { if err := nm.AddInterface(NetIfName, nc); err != nil { return fmt.Errorf("failed to add interface: %w", err) } + _ = nm.CleanUpLegacyDHCPClients() networkManager = nm diff --git a/pkg/nmlite/jetdhcpc/legacy.go b/pkg/nmlite/jetdhcpc/legacy.go index b8ee4c0b..6fa2ddf9 100644 --- a/pkg/nmlite/jetdhcpc/legacy.go +++ b/pkg/nmlite/jetdhcpc/legacy.go @@ -8,6 +8,8 @@ import ( "strconv" "strings" "syscall" + + "github.com/rs/zerolog" ) func readFileNoStat(filename string) ([]byte, error) { @@ -36,7 +38,8 @@ func toCmdline(path string) ([]string, error) { return strings.Split(string(bytes.TrimRight(data, "\x00")), "\x00"), nil } -func (c *Client) killUdhcpc() error { +// KillUdhcpC kills all udhcpc processes +func KillUdhcpC(l *zerolog.Logger) error { // read procfs for udhcpc processes // we do not use procfs.AllProcs() because we want to avoid the overhead of reading the entire procfs processes, err := os.ReadDir("/proc") @@ -76,11 +79,11 @@ func (c *Client) killUdhcpc() error { } if len(matchedPids) == 0 { - c.l.Info().Msg("no udhcpc processes found") + l.Info().Msg("no udhcpc processes found") return nil } - c.l.Info().Ints("pids", matchedPids).Msg("found udhcpc processes, terminating") + l.Info().Ints("pids", matchedPids).Msg("found udhcpc processes, terminating") for _, pid := range matchedPids { err := syscall.Kill(pid, syscall.SIGTERM) @@ -88,8 +91,12 @@ func (c *Client) killUdhcpc() error { return err } - c.l.Info().Int("pid", pid).Msg("terminated udhcpc process") + l.Info().Int("pid", pid).Msg("terminated udhcpc process") } return nil } + +func (c *Client) killUdhcpc() error { + return KillUdhcpC(c.l) +} diff --git a/pkg/nmlite/manager.go b/pkg/nmlite/manager.go index d8a0ddd8..03496d9e 100644 --- a/pkg/nmlite/manager.go +++ b/pkg/nmlite/manager.go @@ -11,6 +11,7 @@ import ( "github.com/jetkvm/kvm/internal/logging" "github.com/jetkvm/kvm/internal/network/types" + "github.com/jetkvm/kvm/pkg/nmlite/jetdhcpc" "github.com/jetkvm/kvm/pkg/nmlite/link" "github.com/rs/zerolog" ) @@ -214,6 +215,32 @@ func (nm *NetworkManager) SetOnDHCPLeaseChange(callback func(iface string, lease nm.onDHCPLeaseChange = callback } +func (nm *NetworkManager) shouldKillLegacyDHCPClients() bool { + nm.mu.RLock() + defer nm.mu.RUnlock() + + // TODO: remove it when we need to support multiple interfaces + for _, im := range nm.interfaces { + if im.dhcpClient.clientType != "udhcpc" { + return true + } + + if im.config.IPv4Mode.String != "dhcp" { + return true + } + } + return false +} + +// CleanUpLegacyDHCPClients cleans up legacy DHCP clients +func (nm *NetworkManager) CleanUpLegacyDHCPClients() error { + shouldKill := nm.shouldKillLegacyDHCPClients() + if shouldKill { + return jetdhcpc.KillUdhcpC(nm.logger) + } + return nil +} + // Stop stops the network manager and all managed interfaces func (nm *NetworkManager) Stop() error { nm.mu.Lock() From 710f082b1537689fadd5824f6536356200e44a5f Mon Sep 17 00:00:00 2001 From: Adam Shiervani Date: Tue, 14 Oct 2025 15:50:34 +0200 Subject: [PATCH 63/85] refactor: update ConfirmDialog component styles and icons --- ui/src/components/ConfirmDialog.tsx | 48 +++++++++++------------------ 1 file changed, 18 insertions(+), 30 deletions(-) diff --git a/ui/src/components/ConfirmDialog.tsx b/ui/src/components/ConfirmDialog.tsx index 9cd9c9ba..82907601 100644 --- a/ui/src/components/ConfirmDialog.tsx +++ b/ui/src/components/ConfirmDialog.tsx @@ -1,10 +1,10 @@ -import { CheckCircleIcon } from "@heroicons/react/24/outline"; import { CloseButton } from "@headlessui/react"; -import { LuInfo, LuOctagonAlert, LuTriangleAlert } from "react-icons/lu"; +import { LuCircleAlert, LuInfo, 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 { @@ -21,27 +21,23 @@ interface ConfirmDialogProps { const variantConfig = { danger: { - icon: LuOctagonAlert, - iconClass: "text-red-600", - iconBgClass: "bg-red-100 border border-red-500/90", + icon: LuCircleAlert, + iconClass: "text-red-600 dark:text-red-400", buttonTheme: "danger", }, success: { - icon: CheckCircleIcon, - iconClass: "text-green-600", - iconBgClass: "bg-green-100 border border-green-500/90", + icon: LuCircleAlert, + iconClass: "text-emerald-600 dark:text-emerald-400", buttonTheme: "primary", }, warning: { icon: LuTriangleAlert, - iconClass: "text-yellow-600", - iconBgClass: "bg-yellow-100 border border-yellow-500/90", + iconClass: "text-amber-600 dark:text-amber-400", buttonTheme: "primary", }, info: { icon: LuInfo, - iconClass: "text-blue-600", - iconBgClass: "bg-blue-100 border border-blue-500/90", + iconClass: "text-slate-700 dark:text-slate-300", buttonTheme: "primary", }, } as Record< @@ -49,7 +45,6 @@ const variantConfig = { { icon: React.ElementType; iconClass: string; - iconBgClass: string; buttonTheme: "danger" | "primary" | "blank" | "light" | "lightDanger"; } >; @@ -65,7 +60,7 @@ export function ConfirmDialog({ onConfirm, isConfirming = false, }: ConfirmDialogProps) { - const { icon: Icon, iconClass, iconBgClass, buttonTheme } = variantConfig[variant]; + const { icon: Icon, iconClass, buttonTheme } = variantConfig[variant]; const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === "Escape") { @@ -77,29 +72,22 @@ export function ConfirmDialog({ return (
-
-
-
-
-
-
-
-

+
+
+
+
+