From 8a55ea35f296863400ebf9515f60205f44fbc9e7 Mon Sep 17 00:00:00 2001 From: Siyuan Miao Date: Tue, 15 Apr 2025 06:33:40 +0200 Subject: [PATCH] feat(network): allow users to update network settings from ui --- internal/confparser/confparser.go | 383 ++++++++++++++++++ internal/confparser/confparser_test.go | 100 +++++ internal/confparser/utils.go | 28 ++ internal/mdns/mdns.go | 64 ++- internal/network/config.go | 81 ++-- internal/network/netif.go | 58 +-- internal/network/netif_linux.go | 58 +++ internal/network/netif_notlinux.go | 21 + internal/network/rpc.go | 63 +-- internal/network/utils.go | 14 +- internal/timesync/timesync.go | 4 +- network.go | 25 +- ui/src/hooks/stores.ts | 4 +- .../routes/devices.$id.settings.network.tsx | 42 +- 14 files changed, 786 insertions(+), 159 deletions(-) create mode 100644 internal/confparser/confparser.go create mode 100644 internal/confparser/confparser_test.go create mode 100644 internal/confparser/utils.go create mode 100644 internal/network/netif_linux.go create mode 100644 internal/network/netif_notlinux.go diff --git a/internal/confparser/confparser.go b/internal/confparser/confparser.go new file mode 100644 index 0000000..aa8a991 --- /dev/null +++ b/internal/confparser/confparser.go @@ -0,0 +1,383 @@ +package confparser + +import ( + "fmt" + "log" + "net" + "reflect" + "slices" + "strconv" + "strings" + + "github.com/guregu/null/v6" + "golang.org/x/net/idna" +) + +type FieldConfig struct { + Name string + Required bool + RequiredIf map[string]interface{} + OneOf []string + ValidateTypes []string + Defaults interface{} + IsEmpty bool + CurrentValue interface{} + TypeString string + Delegated bool + shouldUpdateValue bool +} + +func SetDefaultsAndValidate(config interface{}) error { + return setDefaultsAndValidate(config, true) +} + +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") + } + + // now iterate over the lease struct and set the values + configType := reflect.TypeOf(config).Elem() + configValue := reflect.ValueOf(config).Elem() + + fields := make(map[string]FieldConfig) + + for i := 0; i < configType.NumField(); i++ { + field := configType.Field(i) + fieldValue := configValue.Field(i) + + defaultValue := field.Tag.Get("default") + + fieldType := field.Type.String() + + fieldConfig := FieldConfig{ + Name: field.Name, + OneOf: splitString(field.Tag.Get("one_of")), + ValidateTypes: splitString(field.Tag.Get("validate_type")), + RequiredIf: make(map[string]interface{}), + CurrentValue: fieldValue.Interface(), + IsEmpty: false, + TypeString: fieldType, + } + + // check if the field is required + required := field.Tag.Get("required") + if required != "" { + requiredBool, _ := strconv.ParseBool(required) + fieldConfig.Required = requiredBool + } + + var canUseOneOff = false + + // use switch to get the type + switch fieldValue.Interface().(type) { + case string, null.String: + if defaultValue != "" { + fieldConfig.Defaults = defaultValue + } + canUseOneOff = true + case []string: + if defaultValue != "" { + fieldConfig.Defaults = strings.Split(defaultValue, ",") + } + canUseOneOff = true + case int, null.Int: + if defaultValue != "" { + defaultValueInt, err := strconv.Atoi(defaultValue) + if err != nil { + return fmt.Errorf("invalid default value for field `%s`: %s", field.Name, defaultValue) + } + + fieldConfig.Defaults = defaultValueInt + } + case bool, null.Bool: + if defaultValue != "" { + defaultValueBool, err := strconv.ParseBool(defaultValue) + if err != nil { + return fmt.Errorf("invalid default value for field `%s`: %s", field.Name, defaultValue) + } + + fieldConfig.Defaults = defaultValueBool + } + default: + if defaultValue != "" { + return fmt.Errorf("field `%s` cannot use default value: unsupported type: %s", field.Name, fieldType) + } + + // check if it's a pointer + if fieldValue.Kind() == reflect.Ptr { + // check if the pointer is nil + if fieldValue.IsNil() { + fieldConfig.IsEmpty = true + } else { + fieldConfig.CurrentValue = fieldValue.Elem().Addr() + fieldConfig.Delegated = true + } + } else { + fieldConfig.Delegated = true + } + } + + // now check if the field is nullable interface + switch fieldValue.Interface().(type) { + case null.String: + if fieldValue.Interface().(null.String).IsZero() { + fieldConfig.IsEmpty = true + } + case null.Int: + if fieldValue.Interface().(null.Int).IsZero() { + fieldConfig.IsEmpty = true + } + case null.Bool: + if fieldValue.Interface().(null.Bool).IsZero() { + fieldConfig.IsEmpty = true + } + case []string: + if len(fieldValue.Interface().([]string)) == 0 { + fieldConfig.IsEmpty = true + } + } + + // now check if the field has required_if + requiredIf := field.Tag.Get("required_if") + if requiredIf != "" { + 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) + } + + fieldConfig.RequiredIf[partVal[0]] = partVal[1] + } + } + + // check if the field can use one_of + if !canUseOneOff && len(fieldConfig.OneOf) > 0 { + return fmt.Errorf("field `%s` cannot use one_of: unsupported type: %s", field.Name, fieldType) + } + + fields[field.Name] = fieldConfig + } + + if err := validateFields(config, fields); err != nil { + return err + } + + return nil +} + +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 { + return err + } + + fieldConfig.populate(config) + } + + return nil +} + +func (f *FieldConfig) validate(fields map[string]FieldConfig) error { + var required bool + var err error + + if required, err = f.validateRequired(fields); err != nil { + return err + } + + // check if the field needs to be updated and set defaults if needed + if err := f.checkIfFieldNeedsUpdate(); err != nil { + return err + } + + // then we can check if the field is one_of + if err := f.validateOneOf(); err != nil { + return err + } + + // and validate the type + if err := f.validateField(); err != nil { + return err + } + + // if the field is delegated, we need to validate the nested field + // but before that, let's check if the field is required + if required && f.Delegated { + if err := setDefaultsAndValidate(f.CurrentValue.(reflect.Value).Interface(), false); err != nil { + return err + } + } + + return nil +} + +func (f *FieldConfig) populate(config interface{}) { + // update the field if it's not empty + if !f.shouldUpdateValue { + return + } + + reflect.ValueOf(config).Elem().FieldByName(f.Name).Set(reflect.ValueOf(f.CurrentValue)) +} + +func (f *FieldConfig) checkIfFieldNeedsUpdate() error { + // populate the field if it's empty and has a default value + if f.IsEmpty && f.Defaults != nil { + switch f.CurrentValue.(type) { + case null.String: + f.CurrentValue = null.StringFrom(f.Defaults.(string)) + case null.Int: + f.CurrentValue = null.IntFrom(int64(f.Defaults.(int))) + case null.Bool: + f.CurrentValue = null.BoolFrom(f.Defaults.(bool)) + case string: + f.CurrentValue = f.Defaults.(string) + case int: + f.CurrentValue = f.Defaults.(int) + case bool: + f.CurrentValue = f.Defaults.(bool) + case []string: + f.CurrentValue = f.Defaults.([]string) + default: + return fmt.Errorf("field `%s` cannot use default value: unsupported type: %s", f.Name, f.TypeString) + } + + f.shouldUpdateValue = true + log.Printf("field `%s` updated to default value: %v", f.Name, f.CurrentValue) + } + + return nil +} + +func (f *FieldConfig) validateRequired(fields map[string]FieldConfig) (bool, error) { + var required = f.Required + + // if the field is not required, we need to check if it's required_if + if !required && len(f.RequiredIf) > 0 { + for key, value := range f.RequiredIf { + // check if the field's result matches the required_if + // right now we only support string and int + requiredField, ok := fields[key] + if !ok { + return required, fmt.Errorf("required_if field `%s` not found", key) + } + + switch requiredField.CurrentValue.(type) { + case string: + if requiredField.CurrentValue.(string) == value.(string) { + required = true + } + case int: + if requiredField.CurrentValue.(int) == value.(int) { + required = true + } + case null.String: + if !requiredField.CurrentValue.(null.String).IsZero() && + requiredField.CurrentValue.(null.String).String == value.(string) { + required = true + } + case null.Int: + if !requiredField.CurrentValue.(null.Int).IsZero() && + requiredField.CurrentValue.(null.Int).Int64 == value.(int64) { + required = true + } + } + + // if the field is required, we can break the loop + // because we only need one of the required_if fields to be true + if required { + break + } + } + } + + if required && f.IsEmpty { + return false, fmt.Errorf("field `%s` is required", f.Name) + } + + return required, nil +} + +func checkIfSliceContains(slice []string, one_of []string) bool { + for _, oneOf := range one_of { + if slices.Contains(slice, oneOf) { + return true + } + } + + return false +} + +func (f *FieldConfig) validateOneOf() error { + if len(f.OneOf) == 0 { + return nil + } + + var val []string + switch f.CurrentValue.(type) { + case string: + val = []string{f.CurrentValue.(string)} + case null.String: + val = []string{f.CurrentValue.(null.String).String} + case []string: + // let's validate the value here + val = f.CurrentValue.([]string) + default: + return fmt.Errorf("field `%s` cannot use one_of: unsupported type: %s", f.Name, f.TypeString) + } + + if !checkIfSliceContains(val, f.OneOf) { + return fmt.Errorf( + "field `%s` is not one of the allowed values: %s, current value: %s", + f.Name, + strings.Join(f.OneOf, ", "), + strings.Join(val, ", "), + ) + } + + return nil +} + +func (f *FieldConfig) validateField() error { + if len(f.ValidateTypes) == 0 || f.IsEmpty { + return nil + } + + val, err := toString(f.CurrentValue) + if err != nil { + return fmt.Errorf("field `%s` cannot use validate_type: %s", f.Name, err) + } + + if val == "" { + return nil + } + + for _, validateType := range f.ValidateTypes { + switch validateType { + case "ipv4": + if net.ParseIP(val).To4() == nil { + return fmt.Errorf("field `%s` is not a valid IPv4 address: %s", f.Name, val) + } + case "ipv6": + if net.ParseIP(val).To16() == nil { + return fmt.Errorf("field `%s` is not a valid IPv6 address: %s", f.Name, 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) + } + case "hostname": + if _, err := idna.Lookup.ToASCII(val); err != nil { + return fmt.Errorf("field `%s` is not a valid hostname: %s", f.Name, val) + } + default: + return fmt.Errorf("field `%s` cannot use validate_type: unsupported validator: %s", f.Name, validateType) + } + } + + return nil +} diff --git a/internal/confparser/confparser_test.go b/internal/confparser/confparser_test.go new file mode 100644 index 0000000..fec90b1 --- /dev/null +++ b/internal/confparser/confparser_test.go @@ -0,0 +1,100 @@ +package confparser + +import ( + "net" + "testing" + "time" + + "github.com/guregu/null/v6" +) + +type testIPv6Address struct { + Address net.IP `json:"address"` + Prefix net.IPNet `json:"prefix"` + ValidLifetime *time.Time `json:"valid_lifetime"` + PreferredLifetime *time.Time `json:"preferred_lifetime"` + Scope int `json:"scope"` +} + +type testIPv4StaticConfig struct { + Address null.String `json:"address" validate_type:"ipv4" required:"true"` + Netmask null.String `json:"netmask" validate_type:"ipv4" required:"true"` + Gateway null.String `json:"gateway" validate_type:"ipv4" required:"true"` + DNS []string `json:"dns" validate_type:"ipv4" required:"true"` +} + +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"` +} +type testNetworkConfig struct { + Hostname null.String `json:"hostname,omitempty"` + Domain null.String `json:"domain,omitempty"` + + IPv4Mode null.String `json:"ipv4_mode" one_of:"dhcp,static,disabled" default:"dhcp"` + IPv4Static *testIPv4StaticConfig `json:"ipv4_static,omitempty" required_if:"IPv4Mode=static"` + + 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,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"` + TimeSyncOrdering []string `json:"time_sync_ordering,omitempty" one_of:"http,ntp,ntp_dhcp,ntp_user_provided,ntp_fallback" default:"ntp,http"` + TimeSyncDisableFallback null.Bool `json:"time_sync_disable_fallback,omitempty" default:"false"` + TimeSyncParallel null.Int `json:"time_sync_parallel,omitempty" default:"4"` +} + +func TestValidateConfig(t *testing.T) { + config := &testNetworkConfig{} + + err := SetDefaultsAndValidate(config) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } +} + +func TestValidateIPv4StaticConfigRequired(t *testing.T) { + config := &testNetworkConfig{ + IPv4Static: &testIPv4StaticConfig{ + Address: null.StringFrom("192.168.1.1"), + Gateway: null.StringFrom("192.168.1.1"), + }, + } + + err := SetDefaultsAndValidate(config) + if err == nil { + t.Fatalf("expected error, got nil") + } +} + +func TestValidateIPv4StaticConfigRequiredIf(t *testing.T) { + config := &testNetworkConfig{ + IPv4Mode: null.StringFrom("static"), + } + + err := SetDefaultsAndValidate(config) + if err == nil { + t.Fatalf("expected error, got nil") + } +} + +func TestValidateIPv4StaticConfigValidateType(t *testing.T) { + config := &testNetworkConfig{ + IPv4Static: &testIPv4StaticConfig{ + Address: null.StringFrom("X"), + Netmask: null.StringFrom("255.255.255.0"), + Gateway: null.StringFrom("192.168.1.1"), + DNS: []string{"8.8.8.8", "8.8.4.4"}, + }, + IPv4Mode: null.StringFrom("static"), + } + + err := SetDefaultsAndValidate(config) + if err == nil { + t.Fatalf("expected error, got nil") + } +} diff --git a/internal/confparser/utils.go b/internal/confparser/utils.go new file mode 100644 index 0000000..287cfb9 --- /dev/null +++ b/internal/confparser/utils.go @@ -0,0 +1,28 @@ +package confparser + +import ( + "fmt" + "reflect" + "strings" + + "github.com/guregu/null/v6" +) + +func splitString(s string) []string { + if s == "" { + return []string{} + } + + return strings.Split(s, ",") +} + +func toString(v interface{}) (string, error) { + switch v.(type) { + case string: + return v.(string), nil + case null.String: + return v.(null.String).String, nil + } + + return "", fmt.Errorf("unsupported type: %s", reflect.TypeOf(v)) +} diff --git a/internal/mdns/mdns.go b/internal/mdns/mdns.go index 4899180..48b14c0 100644 --- a/internal/mdns/mdns.go +++ b/internal/mdns/mdns.go @@ -44,6 +44,13 @@ func NewMDNS(opts *MDNSOptions) (*MDNS, error) { opts.Logger = logging.GetDefaultLogger() } + if opts.ListenOptions == nil { + opts.ListenOptions = &MDNSListenOptions{ + IPv4: true, + IPv6: true, + } + } + return &MDNS{ l: opts.Logger, lock: sync.Mutex{}, @@ -64,27 +71,56 @@ func (m *MDNS) start(allowRestart bool) error { m.conn.Close() } - addr4, err := net.ResolveUDPAddr("udp4", DefaultAddressIPv4) - if err != nil { - return err + if m.listenOptions == nil { + return fmt.Errorf("listen options not set") } - addr6, err := net.ResolveUDPAddr("udp6", DefaultAddressIPv6) - if err != nil { - return err + if !m.listenOptions.IPv4 && !m.listenOptions.IPv6 { + m.l.Info().Msg("mDNS server disabled") + return nil } - l4, err := net.ListenUDP("udp4", addr4) - if err != nil { - return err + var ( + addr4, addr6 *net.UDPAddr + l4, l6 *net.UDPConn + p4 *ipv4.PacketConn + p6 *ipv6.PacketConn + err error + ) + + if m.listenOptions.IPv4 { + addr4, err = net.ResolveUDPAddr("udp4", DefaultAddressIPv4) + if err != nil { + return err + } + + l4, err = net.ListenUDP("udp4", addr4) + if err != nil { + return err + } + + p4 = ipv4.NewPacketConn(l4) } - l6, err := net.ListenUDP("udp6", addr6) - if err != nil { - return err + if m.listenOptions.IPv6 { + addr6, err = net.ResolveUDPAddr("udp6", DefaultAddressIPv6) + if err != nil { + return err + } + + l6, err = net.ListenUDP("udp6", addr6) + if err != nil { + return err + } + + p6 = ipv6.NewPacketConn(l6) } - scopeLogger := m.l.With().Interface("local_names", m.localNames).Logger() + scopeLogger := m.l.With(). + Interface("local_names", m.localNames). + Interface("ipv4", p4.LocalAddr()). + Interface("ipv6", p6.LocalAddr()). + Logger() newLocalNames := make([]string, len(m.localNames)) for i, name := range m.localNames { @@ -94,7 +130,7 @@ func (m *MDNS) start(allowRestart bool) error { } } - mDNSConn, err := pion_mdns.Server(ipv4.NewPacketConn(l4), ipv6.NewPacketConn(l6), &pion_mdns.Config{ + mDNSConn, err := pion_mdns.Server(p4, p6, &pion_mdns.Config{ LocalNames: newLocalNames, LoggerFactory: logging.GetPionDefaultLoggerFactory(), }) diff --git a/internal/network/config.go b/internal/network/config.go index 1629665..4f6a903 100644 --- a/internal/network/config.go +++ b/internal/network/config.go @@ -5,6 +5,8 @@ import ( "net" "time" + "github.com/guregu/null/v6" + "github.com/jetkvm/kvm/internal/mdns" "golang.org/x/net/idna" ) @@ -16,37 +18,58 @@ type IPv6Address struct { Scope int `json:"scope"` } -type NetworkConfig struct { - Hostname string `json:"hostname,omitempty"` - Domain string `json:"domain,omitempty"` - - IPv4Mode string `json:"ipv4_mode" one_of:"dhcp,static,disabled" default:"dhcp"` - IPv4Static struct { - Address string `json:"address" validate_type:"ipv4"` - Netmask string `json:"netmask" validate_type:"ipv4"` - Gateway string `json:"gateway" validate_type:"ipv4"` - DNS []string `json:"dns" validate_type:"ipv4"` - } `json:"ipv4_static,omitempty" required_if:"ipv4_mode,static"` - - IPv6Mode string `json:"ipv6_mode" one_of:"slaac,dhcpv6,slaac_and_dhcpv6,static,link_local,disabled" default:"slaac"` - IPv6Static struct { - Address string `json:"address" validate_type:"ipv6"` - Netmask string `json:"netmask" validate_type:"ipv6"` - Gateway string `json:"gateway" validate_type:"ipv6"` - DNS []string `json:"dns" validate_type:"ipv6"` - } `json:"ipv6_static,omitempty" required_if:"ipv6_mode,static"` - - LLDPMode string `json:"lldp_mode,omitempty" one_of:"disabled,basic,all" default:"basic"` - LLDPTxTLVs []string `json:"lldp_tx_tlvs,omitempty" one_of:"chassis,port,system,vlan" default:"chassis,port,system,vlan"` - MDNSMode string `json:"mdns_mode,omitempty" one_of:"disabled,auto,ipv4_only,ipv6_only" default:"auto"` - TimeSyncMode string `json:"time_sync_mode,omitempty" one_of:"ntp_only,ntp_and_http,http_only,custom" default:"ntp_and_http"` - TimeSyncOrdering []string `json:"time_sync_ordering,omitempty" one_of:"http,ntp,ntp_dhcp,ntp_user_provided,ntp_fallback" default:"ntp,http"` - TimeSyncDisableFallback bool `json:"time_sync_disable_fallback,omitempty" default:"false"` - TimeSyncParallel int `json:"time_sync_parallel,omitempty" default:"4"` +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"` + Gateway null.String `json:"gateway,omitempty" validate_type:"ipv4" required:"true"` + DNS []string `json:"dns,omitempty" validate_type:"ipv4" required:"true"` } +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"` + Gateway null.String `json:"gateway,omitempty" validate_type:"ipv6" required:"true"` + DNS []string `json:"dns,omitempty" validate_type:"ipv6" required:"true"` +} +type NetworkConfig struct { + Hostname null.String `json:"hostname,omitempty" validate_type:"hostname"` + Domain null.String `json:"domain,omitempty" validate_type:"hostname"` + + IPv4Mode null.String `json:"ipv4_mode,omitempty" one_of:"dhcp,static,disabled" default:"dhcp"` + IPv4Static *IPv4StaticConfig `json:"ipv4_static,omitempty" required_if:"IPv4Mode=static"` + + 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,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"` + TimeSyncOrdering []string `json:"time_sync_ordering,omitempty" one_of:"http,ntp,ntp_dhcp,ntp_user_provided,ntp_fallback" default:"ntp,http"` + TimeSyncDisableFallback null.Bool `json:"time_sync_disable_fallback,omitempty" default:"false"` + TimeSyncParallel null.Int `json:"time_sync_parallel,omitempty" default:"4"` +} + +func (c *NetworkConfig) GetMDNSMode() *mdns.MDNSListenOptions { + mode := c.MDNSMode.String + listenOptions := &mdns.MDNSListenOptions{ + IPv4: true, + IPv6: true, + } + + if mode == "ipv4_only" { + listenOptions.IPv6 = false + } else if mode == "ipv6_only" { + listenOptions.IPv4 = false + } else if mode == "disabled" { + listenOptions.IPv4 = false + listenOptions.IPv6 = false + } + + return listenOptions +} func (s *NetworkInterfaceState) GetHostname() string { - hostname := ToValidHostname(s.config.Hostname) + hostname := ToValidHostname(s.config.Hostname.String) if hostname == "" { return s.defaultHostname @@ -65,7 +88,7 @@ func ToValidDomain(domain string) string { } func (s *NetworkInterfaceState) GetDomain() string { - domain := ToValidDomain(s.config.Domain) + domain := ToValidDomain(s.config.Domain.String) if domain == "" { lease := s.dhcpClient.GetLease() diff --git a/internal/network/netif.go b/internal/network/netif.go index 11cb6bc..a8f75d6 100644 --- a/internal/network/netif.go +++ b/internal/network/netif.go @@ -4,14 +4,13 @@ import ( "fmt" "net" "sync" - "time" + "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" - "github.com/vishvananda/netlink/nl" ) type NetworkInterfaceState struct { @@ -36,6 +35,7 @@ type NetworkInterfaceState struct { onStateChange func(state *NetworkInterfaceState) onInitialCheck func(state *NetworkInterfaceState) + cbConfigChange func(config *NetworkConfig) checked bool } @@ -48,6 +48,7 @@ type NetworkInterfaceOptions struct { OnStateChange func(state *NetworkInterfaceState) OnInitialCheck func(state *NetworkInterfaceState) OnDhcpLeaseChange func(lease *udhcpc.Lease) + OnConfigChange func(config *NetworkConfig) NetworkConfig *NetworkConfig } @@ -60,6 +61,11 @@ func NewNetworkInterfaceState(opts *NetworkInterfaceOptions) (*NetworkInterfaceS opts.DefaultHostname = "jetkvm" } + err := confparser.SetDefaultsAndValidate(opts.NetworkConfig) + if err != nil { + return nil, err + } + l := opts.Logger s := &NetworkInterfaceState{ interfaceName: opts.InterfaceName, @@ -68,6 +74,7 @@ func NewNetworkInterfaceState(opts *NetworkInterfaceOptions) (*NetworkInterfaceS l: l, onStateChange: opts.OnStateChange, onInitialCheck: opts.OnInitialCheck, + cbConfigChange: opts.OnConfigChange, config: opts.NetworkConfig, } @@ -179,7 +186,7 @@ func (s *NetworkInterfaceState) update() (DhcpTargetState, error) { s.macAddr = &attrs.HardwareAddr // get the ip addresses - addrs, err := netlink.AddrList(iface, nl.FAMILY_ALL) + addrs, err := netlinkAddrs(iface) if err != nil { return dhcpTargetState, logging.ErrorfL(s.l, "failed to get ip addresses", err) } @@ -333,46 +340,7 @@ func (s *NetworkInterfaceState) CheckAndUpdateDhcp() error { return nil } -func (s *NetworkInterfaceState) HandleLinkUpdate(update netlink.LinkUpdate) { - if update.Link.Attrs().Name == s.interfaceName { - s.l.Info().Interface("update", update).Msg("interface link update received") - _ = s.CheckAndUpdateDhcp() - } -} - -func (s *NetworkInterfaceState) Run() error { - updates := make(chan netlink.LinkUpdate) - done := make(chan struct{}) - - if err := netlink.LinkSubscribe(updates, done); err != nil { - s.l.Warn().Err(err).Msg("failed to subscribe to link updates") - return err - } - - _ = 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 (s *NetworkInterfaceState) onConfigChange(config *NetworkConfig) { + s.setHostnameIfNotSame() + s.cbConfigChange(config) } diff --git a/internal/network/netif_linux.go b/internal/network/netif_linux.go new file mode 100644 index 0000000..ec057f1 --- /dev/null +++ b/internal/network/netif_linux.go @@ -0,0 +1,58 @@ +//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 new file mode 100644 index 0000000..d101630 --- /dev/null +++ b/internal/network/netif_notlinux.go @@ -0,0 +1,21 @@ +//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 0d6361a..230cfa2 100644 --- a/internal/network/rpc.go +++ b/internal/network/rpc.go @@ -4,7 +4,7 @@ import ( "fmt" "time" - "github.com/guregu/null/v6" + "github.com/jetkvm/kvm/internal/confparser" "github.com/jetkvm/kvm/internal/udhcpc" ) @@ -27,14 +27,7 @@ type RpcNetworkState struct { } type RpcNetworkSettings struct { - Hostname null.String `json:"hostname,omitempty"` - Domain null.String `json:"domain,omitempty"` - IPv4Mode null.String `json:"ipv4_mode,omitempty"` - IPv6Mode null.String `json:"ipv6_mode,omitempty"` - LLDPMode null.String `json:"lldp_mode,omitempty"` - LLDPTxTLVs []string `json:"lldp_tx_tlvs,omitempty"` - MDNSMode null.String `json:"mdns_mode,omitempty"` - TimeSyncMode null.String `json:"time_sync_mode,omitempty"` + NetworkConfig } func (s *NetworkInterfaceState) RpcGetNetworkState() RpcNetworkState { @@ -69,59 +62,25 @@ func (s *NetworkInterfaceState) RpcGetNetworkSettings() RpcNetworkSettings { } return RpcNetworkSettings{ - Hostname: null.StringFrom(s.config.Hostname), - Domain: null.StringFrom(s.config.Domain), - IPv4Mode: null.StringFrom(s.config.IPv4Mode), - IPv6Mode: null.StringFrom(s.config.IPv6Mode), - LLDPMode: null.StringFrom(s.config.LLDPMode), - LLDPTxTLVs: s.config.LLDPTxTLVs, - MDNSMode: null.StringFrom(s.config.MDNSMode), - TimeSyncMode: null.StringFrom(s.config.TimeSyncMode), + NetworkConfig: *s.config, } } func (s *NetworkInterfaceState) RpcSetNetworkSettings(settings RpcNetworkSettings) error { - changeset := make(map[string]string) currentSettings := s.config - if !settings.Hostname.IsZero() { - changeset["hostname"] = settings.Hostname.String - currentSettings.Hostname = settings.Hostname.String + err := confparser.SetDefaultsAndValidate(&settings.NetworkConfig) + if err != nil { + return err } - if !settings.Domain.IsZero() { - changeset["domain"] = settings.Domain.String - currentSettings.Domain = settings.Domain.String + if IsSame(currentSettings, settings.NetworkConfig) { + // no changes, do nothing + return nil } - if !settings.IPv4Mode.IsZero() { - changeset["ipv4_mode"] = settings.IPv4Mode.String - currentSettings.IPv4Mode = settings.IPv4Mode.String - } - - if !settings.IPv6Mode.IsZero() { - changeset["ipv6_mode"] = settings.IPv6Mode.String - currentSettings.IPv6Mode = settings.IPv6Mode.String - } - - if !settings.LLDPMode.IsZero() { - changeset["lldp_mode"] = settings.LLDPMode.String - currentSettings.LLDPMode = settings.LLDPMode.String - } - - if !settings.MDNSMode.IsZero() { - changeset["mdns_mode"] = settings.MDNSMode.String - currentSettings.MDNSMode = settings.MDNSMode.String - } - - if !settings.TimeSyncMode.IsZero() { - changeset["time_sync_mode"] = settings.TimeSyncMode.String - currentSettings.TimeSyncMode = settings.TimeSyncMode.String - } - - if len(changeset) > 0 { - s.config = currentSettings - } + s.config = &settings.NetworkConfig + s.onConfigChange(s.config) return nil } diff --git a/internal/network/utils.go b/internal/network/utils.go index 0d02e19..e32dad6 100644 --- a/internal/network/utils.go +++ b/internal/network/utils.go @@ -1,6 +1,9 @@ package network -import "time" +import ( + "encoding/json" + "time" +) func lifetimeToTime(lifetime int) *time.Time { if lifetime == 0 { @@ -9,3 +12,12 @@ func lifetimeToTime(lifetime int) *time.Time { t := time.Now().Add(time.Duration(lifetime) * time.Second) return &t } + +func IsSame(a, b interface{}) bool { + aJSON, err := json.Marshal(a) + if err != nil { + return false + } + bJSON, err := json.Marshal(b) + return string(aJSON) == string(bJSON) +} diff --git a/internal/timesync/timesync.go b/internal/timesync/timesync.go index 88d6e9d..e9ad069 100644 --- a/internal/timesync/timesync.go +++ b/internal/timesync/timesync.go @@ -90,8 +90,8 @@ func (t *TimeSync) getSyncMode() SyncMode { var syncModeString string if t.networkConfig != nil { - syncModeString = t.networkConfig.TimeSyncMode - if t.networkConfig.TimeSyncDisableFallback { + syncModeString = t.networkConfig.TimeSyncMode.String + if t.networkConfig.TimeSyncDisableFallback.Bool { syncMode.NtpUseFallback = false syncMode.HttpUseFallback = false } diff --git a/network.go b/network.go index 75947ce..2e8595b 100644 --- a/network.go +++ b/network.go @@ -51,6 +51,18 @@ func initNetwork() error { writeJSONRPCEvent("networkState", networkState.RpcGetNetworkState(), currentSession) }, + OnConfigChange: func(networkConfig *network.NetworkConfig) { + config.NetworkConfig = networkConfig + networkStateChanged() + + if mDNS != nil { + mDNS.SetListenOptions(networkConfig.GetMDNSMode()) + mDNS.SetLocalNames([]string{ + networkState.GetHostname(), + networkState.GetFQDN(), + }, true) + } + }, }) if state == nil { @@ -77,8 +89,17 @@ func rpcGetNetworkSettings() network.RpcNetworkSettings { return networkState.RpcGetNetworkSettings() } -func rpcSetNetworkSettings(settings network.RpcNetworkSettings) error { - return networkState.RpcSetNetworkSettings(settings) +func rpcSetNetworkSettings(settings network.RpcNetworkSettings) (*network.RpcNetworkSettings, error) { + s := networkState.RpcSetNetworkSettings(settings) + if s != nil { + return nil, s + } + + if err := SaveConfig(); err != nil { + return nil, err + } + + return &network.RpcNetworkSettings{NetworkConfig: *config.NetworkConfig}, nil } func rpcRenewDHCPLease() error { diff --git a/ui/src/hooks/stores.ts b/ui/src/hooks/stores.ts index f20eee2..db1fd04 100644 --- a/ui/src/hooks/stores.ts +++ b/ui/src/hooks/stores.ts @@ -727,6 +727,8 @@ export type mDNSMode = "disabled" | "auto" | "ipv4_only" | "ipv6_only" | "unknow export type TimeSyncMode = "ntp_only" | "ntp_and_http" | "http_only" | "custom" | "unknown"; export interface NetworkSettings { + hostname: string; + domain: string; ipv4_mode: IPv4Mode; ipv6_mode: IPv6Mode; lldp_mode: LLDPMode; @@ -745,7 +747,7 @@ export const useNetworkStateStore = create((set, get) => ({ return; } - lease.lease_expiry = expiry.toISOString(); + lease.lease_expiry = expiry; set({ dhcp_lease: lease }); } })); diff --git a/ui/src/routes/devices.$id.settings.network.tsx b/ui/src/routes/devices.$id.settings.network.tsx index c5f4fe4..59d52ef 100644 --- a/ui/src/routes/devices.$id.settings.network.tsx +++ b/ui/src/routes/devices.$id.settings.network.tsx @@ -17,6 +17,8 @@ import relativeTime from 'dayjs/plugin/relativeTime'; dayjs.extend(relativeTime); const defaultNetworkSettings: NetworkSettings = { + hostname: "", + domain: "", ipv4_mode: "unknown", ipv6_mode: "unknown", lldp_mode: "unknown", @@ -58,18 +60,30 @@ export default function SettingsNetworkRoute() { const [networkSettings, setNetworkSettings] = useState(defaultNetworkSettings); const [networkSettingsLoaded, setNetworkSettingsLoaded] = useState(false); - const [dhcpLeaseExpiry, setDhcpLeaseExpiry] = useState(null); - const [dhcpLeaseExpiryRemaining, setDhcpLeaseExpiryRemaining] = useState(null); - const getNetworkSettings = useCallback(() => { setNetworkSettingsLoaded(false); send("getNetworkSettings", {}, resp => { if ("error" in resp) return; + console.log(resp.result); setNetworkSettings(resp.result as NetworkSettings); setNetworkSettingsLoaded(true); }); }, [send]); + const setNetworkSettingsRemote = useCallback((settings: NetworkSettings) => { + setNetworkSettingsLoaded(false); + send("setNetworkSettings", { settings }, resp => { + if ("error" in resp) { + notifications.error("Failed to save network settings: " + (resp.error.data ? resp.error.data : resp.error.message)); + setNetworkSettingsLoaded(true); + return; + } + setNetworkSettings(resp.result as NetworkSettings); + setNetworkSettingsLoaded(true); + notifications.success("Network settings saved"); + }); + }, [send]); + const getNetworkState = useCallback(() => { send("getNetworkState", {}, resp => { if ("error" in resp) return; @@ -105,9 +119,9 @@ export default function SettingsNetworkRoute() { setNetworkSettings({ ...networkSettings, lldp_mode: value as LLDPMode }); }; - const handleLldpTxTlvsChange = (value: string[]) => { - setNetworkSettings({ ...networkSettings, lldp_tx_tlvs: value }); - }; + // const handleLldpTxTlvsChange = (value: string[]) => { + // setNetworkSettings({ ...networkSettings, lldp_tx_tlvs: value }); + // }; const handleMdnsModeChange = (value: mDNSMode | string) => { setNetworkSettings({ ...networkSettings, mdns_mode: value as mDNSMode }); @@ -154,11 +168,12 @@ export default function SettingsNetworkRoute() { { - console.log(e.target.value); + setNetworkSettings({ ...networkSettings, hostname: e.target.value }); }} + disabled={!networkSettingsLoaded} /> @@ -178,11 +193,12 @@ export default function SettingsNetworkRoute() { { - console.log(e.target.value); + setNetworkSettings({ ...networkSettings, domain: e.target.value }); }} + disabled={!networkSettingsLoaded} /> @@ -368,11 +384,11 @@ export default function SettingsNetworkRoute() { disabled={!networkSettingsLoaded} options={filterUnknown([ { value: "unknown", label: "..." }, - { value: "auto", label: "Auto" }, + // { value: "auto", label: "Auto" }, { value: "ntp_only", label: "NTP only" }, { value: "ntp_and_http", label: "NTP and HTTP" }, { value: "http_only", label: "HTTP only" }, - { value: "custom", label: "Custom" }, + // { value: "custom", label: "Custom" }, ])} /> @@ -380,7 +396,7 @@ export default function SettingsNetworkRoute() {