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; +}