feat(network): allow users to update network settings from ui

This commit is contained in:
Siyuan Miao 2025-04-15 06:33:40 +02:00
parent 3d84008217
commit 8a55ea35f2
14 changed files with 786 additions and 159 deletions

View File

@ -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
}

View File

@ -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")
}
}

View File

@ -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))
}

View File

@ -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(),
})

View File

@ -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()

View File

@ -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)
}

View File

@ -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)
}

View File

@ -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")
}

View File

@ -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
}

View File

@ -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)
}

View File

@ -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
}

View File

@ -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 {

View File

@ -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<NetworkState>((set, get) => ({
return;
}
lease.lease_expiry = expiry.toISOString();
lease.lease_expiry = expiry;
set({ dhcp_lease: lease });
}
}));

View File

@ -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<NetworkSettings>(defaultNetworkSettings);
const [networkSettingsLoaded, setNetworkSettingsLoaded] = useState(false);
const [dhcpLeaseExpiry, setDhcpLeaseExpiry] = useState<Date | null>(null);
const [dhcpLeaseExpiryRemaining, setDhcpLeaseExpiryRemaining] = useState<string | null>(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() {
<InputField
type="text"
placeholder="jetkvm"
value={""}
value={networkSettings.hostname}
error={""}
onChange={e => {
console.log(e.target.value);
setNetworkSettings({ ...networkSettings, hostname: e.target.value });
}}
disabled={!networkSettingsLoaded}
/>
</SettingsItem>
</div>
@ -178,11 +193,12 @@ export default function SettingsNetworkRoute() {
<InputField
type="text"
placeholder="local"
value={""}
value={networkSettings.domain}
error={""}
onChange={e => {
console.log(e.target.value);
setNetworkSettings({ ...networkSettings, domain: e.target.value });
}}
disabled={!networkSettingsLoaded}
/>
</SettingsItem>
</div>
@ -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" },
])}
/>
</SettingsItem>
@ -380,7 +396,7 @@ export default function SettingsNetworkRoute() {
<div className="flex items-end gap-x-2">
<Button
onClick={() => {
console.log("save settings");
setNetworkSettingsRemote(networkSettings);
}}
size="SM"
theme="light"