Compare commits

...

9 Commits

Author SHA1 Message Date
Aveline f16a4bc020
Merge 97844a8caf into b144d9926f 2025-10-08 20:01:01 +02:00
Siyuan 97844a8caf use reconcile instead of updating addresses and routes individually 2025-10-08 17:46:19 +00:00
Siyuan ad0b86c8a6 fix state change detection 2025-10-08 15:36:14 +00:00
Siyuan 6743db6e3d fix online state detection 2025-10-08 13:39:12 +00:00
Siyuan 05f2e5babe fix online state detection 2025-10-08 13:18:17 +00:00
Siyuan 8cc7ead032 do not sync time multiple times 2025-10-08 12:54:48 +00:00
Marc Brooks b144d9926f
Remove the temporary directory after extracting buildkit (#874) 2025-10-07 11:57:26 +02:00
Aylen e755a6e1b1
Update openSUSE image reference to Leap 16.0 (#865) 2025-10-07 11:57:10 +02:00
Marc Brooks 99a8c2711c
Add podman support (#875)
Reimplement #141 since we've changed everything since
2025-10-07 11:43:25 +02:00
22 changed files with 740 additions and 517 deletions

View File

@ -1,5 +1,5 @@
{ {
"name": "JetKVM", "name": "JetKVM docker devcontainer",
"image": "mcr.microsoft.com/devcontainers/go:1.25-trixie", "image": "mcr.microsoft.com/devcontainers/go:1.25-trixie",
"features": { "features": {
"ghcr.io/devcontainers/features/node:1": { "ghcr.io/devcontainers/features/node:1": {

View File

@ -32,4 +32,5 @@ wget https://github.com/jetkvm/rv1106-system/releases/download/${BUILDKIT_VERSIO
sudo mkdir -p /opt/jetkvm-native-buildkit && \ sudo mkdir -p /opt/jetkvm-native-buildkit && \
sudo tar --use-compress-program="unzstd --long=31" -xvf buildkit.tar.zst -C /opt/jetkvm-native-buildkit && \ sudo tar --use-compress-program="unzstd --long=31" -xvf buildkit.tar.zst -C /opt/jetkvm-native-buildkit && \
rm buildkit.tar.zst rm buildkit.tar.zst
popd popd
rm -rf "${BUILDKIT_TMPDIR}"

View File

@ -0,0 +1,19 @@
{
"name": "JetKVM podman devcontainer",
"image": "mcr.microsoft.com/devcontainers/go:1.25-trixie",
"features": {
"ghcr.io/devcontainers/features/node:1": {
// Should match what is defined in ui/package.json
"version": "22.19.0"
}
},
"runArgs": [
"--userns=keep-id",
"--security-opt=label=disable",
"--security-opt=label=nested"
],
"containerUser": "vscode",
"containerEnv": {
"HOME": "/home/vscode"
}
}

View File

@ -1,5 +1,10 @@
package types package types
import (
"net"
"time"
)
// DHCPClient is the interface for a DHCP client. // DHCPClient is the interface for a DHCP client.
type DHCPClient interface { type DHCPClient interface {
Domain() string Domain() string
@ -13,3 +18,75 @@ type DHCPClient interface {
Start() error Start() error
Stop() error Stop() error
} }
// DHCPLease is a network configuration obtained by DHCP.
type DHCPLease struct {
// from https://udhcp.busybox.net/README.udhcpc
IPAddress net.IP `env:"ip" json:"ip"` // The obtained IP
Netmask net.IP `env:"subnet" json:"netmask"` // The assigned subnet mask
Broadcast net.IP `env:"broadcast" json:"broadcast"` // The broadcast address for this network
TTL int `env:"ipttl" json:"ttl,omitempty"` // The TTL to use for this network
MTU int `env:"mtu" json:"mtu,omitempty"` // The MTU to use for this network
HostName string `env:"hostname" json:"hostname,omitempty"` // The assigned hostname
Domain string `env:"domain" json:"domain,omitempty"` // The domain name of the network
SearchList []string `env:"search" json:"search_list,omitempty"` // The search list for the network
BootPNextServer net.IP `env:"siaddr" json:"bootp_next_server,omitempty"` // The bootp next server option
BootPServerName string `env:"sname" json:"bootp_server_name,omitempty"` // The bootp server name option
BootPFile string `env:"boot_file" json:"bootp_file,omitempty"` // The bootp boot file option
Timezone string `env:"timezone" json:"timezone,omitempty"` // Offset in seconds from UTC
Routers []net.IP `env:"router" json:"routers,omitempty"` // A list of routers
DNS []net.IP `env:"dns" json:"dns_servers,omitempty"` // A list of DNS servers
NTPServers []net.IP `env:"ntpsrv" json:"ntp_servers,omitempty"` // A list of NTP servers
LPRServers []net.IP `env:"lprsvr" json:"lpr_servers,omitempty"` // A list of LPR servers
TimeServers []net.IP `env:"timesvr" json:"_time_servers,omitempty"` // A list of time servers (obsolete)
IEN116NameServers []net.IP `env:"namesvr" json:"_name_servers,omitempty"` // A list of IEN 116 name servers (obsolete)
LogServers []net.IP `env:"logsvr" json:"_log_servers,omitempty"` // A list of MIT-LCS UDP log servers (obsolete)
CookieServers []net.IP `env:"cookiesvr" json:"_cookie_servers,omitempty"` // A list of RFC 865 cookie servers (obsolete)
WINSServers []net.IP `env:"wins" json:"_wins_servers,omitempty"` // A list of WINS servers
SwapServer net.IP `env:"swapsvr" json:"_swap_server,omitempty"` // The IP address of the client's swap server
BootSize int `env:"bootsize" json:"bootsize,omitempty"` // The length in 512 octect blocks of the bootfile
RootPath string `env:"rootpath" json:"root_path,omitempty"` // The path name of the client's root disk
LeaseTime time.Duration `env:"lease" json:"lease,omitempty"` // The lease time, in seconds
RenewalTime time.Duration `env:"renewal" json:"renewal,omitempty"` // The renewal time, in seconds
RebindingTime time.Duration `env:"rebinding" json:"rebinding,omitempty"` // The rebinding time, in seconds
DHCPType string `env:"dhcptype" json:"dhcp_type,omitempty"` // DHCP message type (safely ignored)
ServerID string `env:"serverid" json:"server_id,omitempty"` // The IP of the server
Message string `env:"message" json:"reason,omitempty"` // Reason for a DHCPNAK
TFTPServerName string `env:"tftp" json:"tftp,omitempty"` // The TFTP server name
BootFileName string `env:"bootfile" json:"bootfile,omitempty"` // The boot file name
Uptime time.Duration `env:"uptime" json:"uptime,omitempty"` // The uptime of the device when the lease was obtained, in seconds
ClassIdentifier string `env:"classid" json:"class_identifier,omitempty"` // The class identifier
LeaseExpiry *time.Time `json:"lease_expiry,omitempty"` // The expiry time of the lease
InterfaceName string `json:"interface_name,omitempty"` // The name of the interface
DHCPClient string `json:"dhcp_client,omitempty"` // The DHCP client that obtained the lease
}
// IsIPv6 returns true if the DHCP lease is for an IPv6 address
func (d *DHCPLease) IsIPv6() bool {
return d.IPAddress.To4() == nil
}
// IPMask returns the IP mask for the DHCP lease
func (d *DHCPLease) IPMask() net.IPMask {
if d.IsIPv6() {
// TODO: not implemented
return nil
}
mask := net.ParseIP(d.Netmask.String())
return net.IPv4Mask(mask[12], mask[13], mask[14], mask[15])
}
// IPNet returns the IP net for the DHCP lease
func (d *DHCPLease) IPNet() *net.IPNet {
if d.IsIPv6() {
// TODO: not implemented
return nil
}
return &net.IPNet{
IP: d.IPAddress,
Mask: d.IPMask(),
}
}

View File

@ -0,0 +1,33 @@
package types
import "time"
type RpcIPv6Address struct {
Address string `json:"address"`
Prefix string `json:"prefix"`
ValidLifetime *time.Time `json:"valid_lifetime"`
PreferredLifetime *time.Time `json:"preferred_lifetime"`
Scope int `json:"scope"`
}
type RpcInterfaceState struct {
InterfaceState
IPv6Addresses []RpcIPv6Address `json:"ipv6_addresses"`
}
func (s *InterfaceState) ToRpcInterfaceState() *RpcInterfaceState {
addrs := make([]RpcIPv6Address, len(s.IPv6Addresses))
for i, addr := range s.IPv6Addresses {
addrs[i] = RpcIPv6Address{
Address: addr.Address.String(),
Prefix: addr.Prefix.String(),
ValidLifetime: addr.ValidLifetime,
PreferredLifetime: addr.PreferredLifetime,
Scope: addr.Scope,
}
}
return &RpcInterfaceState{
InterfaceState: *s,
IPv6Addresses: addrs,
}
}

View File

@ -4,9 +4,11 @@ import (
"net" "net"
"net/http" "net/http"
"net/url" "net/url"
"slices"
"time" "time"
"github.com/guregu/null/v6" "github.com/guregu/null/v6"
"github.com/vishvananda/netlink"
) )
// IPAddress represents a network interface address // IPAddress represents a network interface address
@ -19,6 +21,44 @@ type IPAddress struct {
Permanent bool Permanent bool
} }
func (a *IPAddress) String() string {
return a.Address.String()
}
func (a *IPAddress) Compare(n netlink.Addr) bool {
if !a.Address.IP.Equal(n.IP) {
return false
}
if slices.Compare(a.Address.Mask, n.IPNet.Mask) != 0 {
return false
}
return true
}
func (a *IPAddress) NetlinkAddr() netlink.Addr {
return netlink.Addr{
IPNet: &a.Address,
}
}
func (a *IPAddress) DefaultRoute(linkIndex int) netlink.Route {
return netlink.Route{
Dst: nil,
Gw: a.Gateway,
LinkIndex: linkIndex,
}
}
// ParsedIPConfig represents the parsed IP configuration
type ParsedIPConfig struct {
Addresses []IPAddress
Nameservers []net.IP
SearchList []string
Domain string
MTU int
Interface string
}
// IPv6Address represents an IPv6 address with lifetime information // IPv6Address represents an IPv6 address with lifetime information
type IPv6Address struct { type IPv6Address struct {
Address net.IP `json:"address"` Address net.IP `json:"address"`
@ -107,49 +147,6 @@ func (c *NetworkConfig) GetTransportProxyFunc() func(*http.Request) (*url.URL, e
} }
} }
// DHCPLease is a network configuration obtained by DHCP.
type DHCPLease struct {
// from https://udhcp.busybox.net/README.udhcpc
IPAddress net.IP `env:"ip" json:"ip"` // The obtained IP
Netmask net.IP `env:"subnet" json:"netmask"` // The assigned subnet mask
Broadcast net.IP `env:"broadcast" json:"broadcast"` // The broadcast address for this network
TTL int `env:"ipttl" json:"ttl,omitempty"` // The TTL to use for this network
MTU int `env:"mtu" json:"mtu,omitempty"` // The MTU to use for this network
HostName string `env:"hostname" json:"hostname,omitempty"` // The assigned hostname
Domain string `env:"domain" json:"domain,omitempty"` // The domain name of the network
SearchList []string `env:"search" json:"search_list,omitempty"` // The search list for the network
BootPNextServer net.IP `env:"siaddr" json:"bootp_next_server,omitempty"` // The bootp next server option
BootPServerName string `env:"sname" json:"bootp_server_name,omitempty"` // The bootp server name option
BootPFile string `env:"boot_file" json:"bootp_file,omitempty"` // The bootp boot file option
Timezone string `env:"timezone" json:"timezone,omitempty"` // Offset in seconds from UTC
Routers []net.IP `env:"router" json:"routers,omitempty"` // A list of routers
DNS []net.IP `env:"dns" json:"dns_servers,omitempty"` // A list of DNS servers
NTPServers []net.IP `env:"ntpsrv" json:"ntp_servers,omitempty"` // A list of NTP servers
LPRServers []net.IP `env:"lprsvr" json:"lpr_servers,omitempty"` // A list of LPR servers
TimeServers []net.IP `env:"timesvr" json:"_time_servers,omitempty"` // A list of time servers (obsolete)
IEN116NameServers []net.IP `env:"namesvr" json:"_name_servers,omitempty"` // A list of IEN 116 name servers (obsolete)
LogServers []net.IP `env:"logsvr" json:"_log_servers,omitempty"` // A list of MIT-LCS UDP log servers (obsolete)
CookieServers []net.IP `env:"cookiesvr" json:"_cookie_servers,omitempty"` // A list of RFC 865 cookie servers (obsolete)
WINSServers []net.IP `env:"wins" json:"_wins_servers,omitempty"` // A list of WINS servers
SwapServer net.IP `env:"swapsvr" json:"_swap_server,omitempty"` // The IP address of the client's swap server
BootSize int `env:"bootsize" json:"bootsize,omitempty"` // The length in 512 octect blocks of the bootfile
RootPath string `env:"rootpath" json:"root_path,omitempty"` // The path name of the client's root disk
LeaseTime time.Duration `env:"lease" json:"lease,omitempty"` // The lease time, in seconds
RenewalTime time.Duration `env:"renewal" json:"renewal,omitempty"` // The renewal time, in seconds
RebindingTime time.Duration `env:"rebinding" json:"rebinding,omitempty"` // The rebinding time, in seconds
DHCPType string `env:"dhcptype" json:"dhcp_type,omitempty"` // DHCP message type (safely ignored)
ServerID string `env:"serverid" json:"server_id,omitempty"` // The IP of the server
Message string `env:"message" json:"reason,omitempty"` // Reason for a DHCPNAK
TFTPServerName string `env:"tftp" json:"tftp,omitempty"` // The TFTP server name
BootFileName string `env:"bootfile" json:"bootfile,omitempty"` // The boot file name
Uptime time.Duration `env:"uptime" json:"uptime,omitempty"` // The uptime of the device when the lease was obtained, in seconds
ClassIdentifier string `env:"classid" json:"class_identifier,omitempty"` // The class identifier
LeaseExpiry *time.Time `json:"lease_expiry,omitempty"` // The expiry time of the lease
InterfaceName string `json:"interface_name,omitempty"` // The name of the interface
DHCPClient string `json:"dhcp_client,omitempty"` // The DHCP client that obtained the lease
}
// InterfaceState represents the current state of a network interface // InterfaceState represents the current state of a network interface
type InterfaceState struct { type InterfaceState struct {
InterfaceName string `json:"interface_name"` InterfaceName string `json:"interface_name"`
@ -161,6 +158,7 @@ type InterfaceState struct {
IPv4Address string `json:"ipv4_address,omitempty"` IPv4Address string `json:"ipv4_address,omitempty"`
IPv6Address string `json:"ipv6_address,omitempty"` IPv6Address string `json:"ipv6_address,omitempty"`
IPv6LinkLocal string `json:"ipv6_link_local,omitempty"` IPv6LinkLocal string `json:"ipv6_link_local,omitempty"`
IPv6Gateway string `json:"ipv6_gateway,omitempty"`
IPv4Addresses []string `json:"ipv4_addresses,omitempty"` IPv4Addresses []string `json:"ipv4_addresses,omitempty"`
IPv6Addresses []IPv6Address `json:"ipv6_addresses,omitempty"` IPv6Addresses []IPv6Address `json:"ipv6_addresses,omitempty"`
NTPServers []net.IP `json:"ntp_servers,omitempty"` NTPServers []net.IP `json:"ntp_servers,omitempty"`
@ -175,32 +173,3 @@ type NetworkConfigInterface interface {
IPv4Addresses() []IPAddress IPv4Addresses() []IPAddress
IPv6Addresses() []IPAddress IPv6Addresses() []IPAddress
} }
// IsIPv6 returns true if the DHCP lease is for an IPv6 address
func (d *DHCPLease) IsIPv6() bool {
return d.IPAddress.To4() == nil
}
// IPMask returns the IP mask for the DHCP lease
func (d *DHCPLease) IPMask() net.IPMask {
if d.IsIPv6() {
// TODO: not implemented
return nil
}
mask := net.ParseIP(d.Netmask.String())
return net.IPv4Mask(mask[12], mask[13], mask[14], mask[15])
}
// IPNet returns the IP net for the DHCP lease
func (d *DHCPLease) IPNet() *net.IPNet {
if d.IsIPv6() {
// TODO: not implemented
return nil
}
return &net.IPNet{
IP: d.IPAddress,
Mask: d.IPMask(),
}
}

View File

@ -38,6 +38,7 @@ type TimeSync struct {
rtcLock *sync.Mutex rtcLock *sync.Mutex
syncSuccess bool syncSuccess bool
timer *time.Timer
preCheckFunc PreCheckFunc preCheckFunc PreCheckFunc
preCheckIPv4 PreCheckFunc preCheckIPv4 PreCheckFunc
@ -78,6 +79,7 @@ func NewTimeSync(opts *TimeSyncOptions) *TimeSync {
preCheckIPv4: opts.PreCheckIPv4, preCheckIPv4: opts.PreCheckIPv4,
preCheckIPv6: opts.PreCheckIPv6, preCheckIPv6: opts.PreCheckIPv6,
networkConfig: opts.NetworkConfig, networkConfig: opts.NetworkConfig,
timer: time.NewTimer(timeSyncWaitNetUpInt),
} }
if t.rtcDevicePath != "" { if t.rtcDevicePath != "" {
@ -130,45 +132,51 @@ func (t *TimeSync) getSyncMode() SyncMode {
return syncMode return syncMode
} }
func (t *TimeSync) doTimeSync() { func (t *TimeSync) timeSyncLoop() {
metricTimeSyncStatus.Set(0) metricTimeSyncStatus.Set(0)
for {
// use a timer here instead of sleep
for range t.timer.C {
if ok, err := t.preCheckFunc(); !ok { if ok, err := t.preCheckFunc(); !ok {
if err != nil { if err != nil {
t.l.Error().Err(err).Msg("pre-check failed") t.l.Error().Err(err).Msg("pre-check failed")
} }
time.Sleep(timeSyncWaitNetChkInt) t.timer.Reset(timeSyncWaitNetChkInt)
continue continue
} }
t.l.Info().Msg("syncing system time") t.l.Info().Msg("syncing system time")
start := time.Now() start := time.Now()
err := t.Sync() err := t.sync()
if err != nil { if err != nil {
t.l.Error().Str("error", err.Error()).Msg("failed to sync system time") t.l.Error().Str("error", err.Error()).Msg("failed to sync system time")
// retry after a delay // retry after a delay
timeSyncRetryInterval += timeSyncRetryStep timeSyncRetryInterval += timeSyncRetryStep
time.Sleep(timeSyncRetryInterval) t.timer.Reset(timeSyncRetryInterval)
// reset the retry interval if it exceeds the max interval // reset the retry interval if it exceeds the max interval
if timeSyncRetryInterval > timeSyncRetryMaxInt { if timeSyncRetryInterval > timeSyncRetryMaxInt {
timeSyncRetryInterval = 0 timeSyncRetryInterval = 0
} }
continue continue
} }
isInitialSync := !t.syncSuccess
t.syncSuccess = true t.syncSuccess = true
t.l.Info().Str("now", time.Now().Format(time.RFC3339)). t.l.Info().Str("now", time.Now().Format(time.RFC3339)).
Str("time_taken", time.Since(start).String()). Str("time_taken", time.Since(start).String()).
Bool("is_initial_sync", isInitialSync).
Msg("time sync successful") Msg("time sync successful")
metricTimeSyncStatus.Set(1) metricTimeSyncStatus.Set(1)
time.Sleep(timeSyncInterval) // after the first sync is done t.timer.Reset(timeSyncInterval) // after the first sync is done
} }
} }
func (t *TimeSync) Sync() error { func (t *TimeSync) sync() error {
t.syncLock.Lock() t.syncLock.Lock()
defer t.syncLock.Unlock() defer t.syncLock.Unlock()
@ -256,12 +264,25 @@ Orders:
return nil return nil
} }
// Sync triggers a manual time sync
func (t *TimeSync) Sync() error {
if !t.syncLock.TryLock() {
t.l.Warn().Msg("sync already in progress, skipping")
return nil
}
t.syncLock.Unlock()
return t.sync()
}
// IsSyncSuccess returns true if the system time is synchronized
func (t *TimeSync) IsSyncSuccess() bool { func (t *TimeSync) IsSyncSuccess() bool {
return t.syncSuccess return t.syncSuccess
} }
// Start starts the time sync
func (t *TimeSync) Start() { func (t *TimeSync) Start() {
go t.doTimeSync() go t.timeSyncLoop()
} }
func (t *TimeSync) setSystemTime(now time.Time) error { func (t *TimeSync) setSystemTime(now time.Time) error {

View File

@ -47,31 +47,39 @@ func restartMdns() {
}, true) }, true)
} }
func networkStateChanged(iface string, state *types.InterfaceState) { func triggerTimeSyncOnNetworkStateChange() {
if timeSync == nil {
return
}
// set the NTP servers from the network manager
if networkManager != nil {
timeSync.SetDhcpNtpAddresses(networkManager.NTPServerStrings())
}
// sync time
if err := timeSync.Sync(); err != nil {
networkLogger.Error().Err(err).Msg("failed to sync time after network state change")
}
}
func networkStateChanged(_ string, state types.InterfaceState) {
// do not block the main thread // do not block the main thread
go waitCtrlAndRequestDisplayUpdate(true, "network_state_changed") go waitCtrlAndRequestDisplayUpdate(true, "network_state_changed")
if timeSync != nil { if currentSession != nil {
if networkManager != nil { writeJSONRPCEvent("networkState", state.ToRpcInterfaceState(), currentSession)
timeSync.SetDhcpNtpAddresses(networkManager.NTPServerStrings()) }
}
if err := timeSync.Sync(); err != nil { if state.Online {
networkLogger.Error().Err(err).Msg("failed to sync time after network state change") networkLogger.Info().Msg("network state changed to online, triggering time sync")
} triggerTimeSyncOnNetworkStateChange()
} }
// always restart mDNS when the network state changes // always restart mDNS when the network state changes
if mDNS != nil { if mDNS != nil {
restartMdns() restartMdns()
} }
// if the network is now online, trigger an NTP sync if still needed
if state.Up && timeSync != nil && (isTimeSyncNeeded() || !timeSync.IsSyncSuccess()) {
if err := timeSync.Sync(); err != nil {
logger.Warn().Str("error", err.Error()).Msg("unable to sync time on network state change")
}
}
} }
func validateNetworkConfig() { func validateNetworkConfig() {
@ -104,9 +112,9 @@ func initNetwork() error {
return nil return nil
} }
func rpcGetNetworkState() *types.InterfaceState { func rpcGetNetworkState() *types.RpcInterfaceState {
state, _ := networkManager.GetInterfaceState(NetIfName) state, _ := networkManager.GetInterfaceState(NetIfName)
return state return state.ToRpcInterfaceState()
} }
func rpcGetNetworkSettings() *RpcNetworkSettings { func rpcGetNetworkSettings() *RpcNetworkSettings {

View File

@ -36,7 +36,7 @@ type InterfaceManager struct {
hostname *HostnameManager hostname *HostnameManager
// Callbacks // Callbacks
onStateChange func(state *types.InterfaceState) onStateChange func(state types.InterfaceState)
onConfigChange func(config *types.NetworkConfig) onConfigChange func(config *types.NetworkConfig)
onDHCPLeaseChange func(lease *types.DHCPLease) onDHCPLeaseChange func(lease *types.DHCPLease)
@ -321,7 +321,7 @@ func (im *InterfaceManager) RenewDHCPLease() error {
} }
// SetOnStateChange sets the callback for state changes // SetOnStateChange sets the callback for state changes
func (im *InterfaceManager) SetOnStateChange(callback func(state *types.InterfaceState)) { func (im *InterfaceManager) SetOnStateChange(callback func(state types.InterfaceState)) {
im.onStateChange = callback im.onStateChange = callback
} }
@ -403,13 +403,23 @@ func (im *InterfaceManager) applyIPv4Static() error {
return fmt.Errorf("IPv4 static configuration is nil") return fmt.Errorf("IPv4 static configuration is nil")
} }
im.logger.Info().Msg("stopping DHCP")
// Disable DHCP // Disable DHCP
if im.dhcpClient != nil { if im.dhcpClient != nil {
im.dhcpClient.SetIPv4(false) im.dhcpClient.SetIPv4(false)
} }
// Apply static configuration im.logger.Info().Interface("config", im.config.IPv4Static).Msg("applying IPv4 static configuration")
return im.staticConfig.ApplyIPv4Static(im.config.IPv4Static)
config, err := im.staticConfig.ToIPv4Static(im.config.IPv4Static)
if err != nil {
return fmt.Errorf("failed to convert IPv4 static configuration: %w", err)
}
im.logger.Info().Interface("config", config).Msg("converted IPv4 static configuration")
return im.ReconcileLinkAddrs(config.Addresses, link.AfInet)
} }
// applyIPv4DHCP applies DHCP IPv4 configuration // applyIPv4DHCP applies DHCP IPv4 configuration
@ -440,13 +450,20 @@ func (im *InterfaceManager) applyIPv6Static() error {
return fmt.Errorf("IPv6 static configuration is nil") return fmt.Errorf("IPv6 static configuration is nil")
} }
im.logger.Info().Msg("stopping DHCPv6")
// Disable DHCPv6 // Disable DHCPv6
if im.dhcpClient != nil { if im.dhcpClient != nil {
im.dhcpClient.SetIPv6(false) im.dhcpClient.SetIPv6(false)
} }
// Apply static configuration // Apply static configuration
return im.staticConfig.ApplyIPv6Static(im.config.IPv6Static) config, err := im.staticConfig.ToIPv6Static(im.config.IPv6Static)
if err != nil {
return fmt.Errorf("failed to convert IPv6 static configuration: %w", err)
}
im.logger.Info().Interface("config", config).Msg("converted IPv6 static configuration")
return im.ReconcileLinkAddrs(config.Addresses, link.AfInet6)
} }
// applyIPv6DHCP applies DHCPv6 configuration // applyIPv6DHCP applies DHCPv6 configuration
@ -683,119 +700,6 @@ func (im *InterfaceManager) monitorInterfaceState() {
} }
// updateInterfaceState updates the current interface state
func (im *InterfaceManager) updateInterfaceState() error {
nl, err := im.link()
if err != nil {
return fmt.Errorf("failed to get interface: %w", err)
}
attrs := nl.Attrs()
isUp := attrs.OperState == netlink.OperUp
hasAddrs := false
addrs, err := nl.AddrList(link.AfUnspec)
if err != nil {
return fmt.Errorf("failed to get addresses: %w", err)
}
if len(addrs) > 0 {
hasAddrs = true
}
// Check if state changed
stateChanged := false
// We should release the lock before calling the callbacks
// to avoid deadlocks
im.stateMu.Lock()
if im.state.Up != isUp {
im.state.Up = isUp
stateChanged = true
}
if im.state.Online != hasAddrs {
im.state.Online = hasAddrs
stateChanged = true
}
if im.state.MACAddress != attrs.HardwareAddr.String() {
im.state.MACAddress = attrs.HardwareAddr.String()
stateChanged = true
}
// Update IP addresses
if err := im.updateIPAddresses(nl); err != nil {
im.logger.Error().Err(err).Msg("failed to update IP addresses")
}
im.state.LastUpdated = time.Now()
im.stateMu.Unlock()
// Notify callback if state changed
if stateChanged && im.onStateChange != nil {
state := *im.state
im.logger.Debug().Interface("state", state).Msg("notifying state change")
im.onStateChange(&state)
}
return nil
}
// updateIPAddresses updates the IP addresses in the state
func (im *InterfaceManager) updateIPAddresses(nl *link.Link) error {
if err := nl.Refresh(); err != nil {
return fmt.Errorf("failed to refresh link: %w", err)
}
addrs, err := nl.AddrList(link.AfUnspec)
if err != nil {
return fmt.Errorf("failed to get addresses: %w", err)
}
var (
ipv4Addresses []string
ipv6Addresses []types.IPv6Address
ipv4Addr, ipv6Addr string
ipv6LinkLocal string
ipv4Ready, ipv6Ready = false, false
)
for _, addr := range addrs {
im.logger.Debug().Str("address", addr.IP.String()).Msg("checking address")
if addr.IP.To4() != nil {
// IPv4 address
ipv4Addresses = append(ipv4Addresses, addr.IPNet.String())
if ipv4Addr == "" {
ipv4Addr = addr.IP.String()
ipv4Ready = true
}
} else if addr.IP.To16() != nil {
// IPv6 address
if addr.IP.IsLinkLocalUnicast() {
ipv6LinkLocal = addr.IP.String()
} else if addr.IP.IsGlobalUnicast() {
ipv6Addresses = append(ipv6Addresses, types.IPv6Address{
Address: addr.IP,
Prefix: *addr.IPNet,
Scope: addr.Scope,
})
if ipv6Addr == "" {
ipv6Addr = addr.IP.String()
ipv6Ready = true
}
}
}
}
im.state.IPv4Addresses = ipv4Addresses
im.state.IPv6Addresses = ipv6Addresses
im.state.IPv6LinkLocal = ipv6LinkLocal
im.state.IPv4Address = ipv4Addr
im.state.IPv6Address = ipv6Addr
im.state.IPv4Ready = ipv4Ready
im.state.IPv6Ready = ipv6Ready
return nil
}
// updateStateFromDHCPLease updates the state from a DHCP lease // updateStateFromDHCPLease updates the state from a DHCP lease
func (im *InterfaceManager) updateStateFromDHCPLease(lease *types.DHCPLease) { func (im *InterfaceManager) updateStateFromDHCPLease(lease *types.DHCPLease) {
im.stateMu.Lock() im.stateMu.Lock()
@ -808,7 +712,8 @@ func (im *InterfaceManager) updateStateFromDHCPLease(lease *types.DHCPLease) {
} }
} }
func (im *InterfaceManager) ReconcileLinkAddrs(addrs []*types.IPAddress) error { // ReconcileLinkAddrs reconciles the link addresses
func (im *InterfaceManager) ReconcileLinkAddrs(addrs []types.IPAddress, family int) error {
nl := getNetlinkManager() nl := getNetlinkManager()
link, err := im.link() link, err := im.link()
if err != nil { if err != nil {
@ -817,7 +722,7 @@ func (im *InterfaceManager) ReconcileLinkAddrs(addrs []*types.IPAddress) error {
if link == nil { if link == nil {
return fmt.Errorf("failed to get interface: %w", err) return fmt.Errorf("failed to get interface: %w", err)
} }
return nl.ReconcileLink(link, addrs) return nl.ReconcileLink(link, addrs, family)
} }
// applyDHCPLease applies DHCP lease configuration using ReconcileLinkAddrs // applyDHCPLease applies DHCP lease configuration using ReconcileLinkAddrs
@ -826,7 +731,7 @@ func (im *InterfaceManager) applyDHCPLease(lease *types.DHCPLease) error {
ipv4Config := im.convertDHCPLeaseToIPv4Config(lease) ipv4Config := im.convertDHCPLeaseToIPv4Config(lease)
// Apply the configuration using ReconcileLinkAddrs // Apply the configuration using ReconcileLinkAddrs
return im.ReconcileLinkAddrs([]*types.IPAddress{ipv4Config}) return im.ReconcileLinkAddrs([]types.IPAddress{*ipv4Config}, link.AfInet)
} }
// convertDHCPLeaseToIPv4Config converts a DHCP lease to IPv4Config // convertDHCPLeaseToIPv4Config converts a DHCP lease to IPv4Config

View File

@ -0,0 +1,162 @@
package nmlite
import (
"fmt"
"time"
"github.com/jetkvm/kvm/internal/network/types"
"github.com/jetkvm/kvm/pkg/nmlite/link"
"github.com/vishvananda/netlink"
)
// updateInterfaceState updates the current interface state
func (im *InterfaceManager) updateInterfaceState() error {
nl, err := im.link()
if err != nil {
return fmt.Errorf("failed to get interface: %w", err)
}
var stateChanged bool
attrs := nl.Attrs()
// We should release the lock before calling the callbacks
// to avoid deadlocks
im.stateMu.Lock()
// Check if the interface is up
isUp := attrs.OperState == netlink.OperUp
if im.state.Up != isUp {
im.state.Up = isUp
stateChanged = true
}
// Check if the interface is online
isOnline := isUp && nl.HasGlobalUnicastAddress()
if im.state.Online != isOnline {
im.state.Online = isOnline
stateChanged = true
}
// Check if the MAC address has changed
if im.state.MACAddress != attrs.HardwareAddr.String() {
im.state.MACAddress = attrs.HardwareAddr.String()
stateChanged = true
}
// Update IP addresses
if ipChanged, err := im.updateInterfaceStateAddresses(nl); err != nil {
im.logger.Error().Err(err).Msg("failed to update IP addresses")
} else if ipChanged {
stateChanged = true
}
im.state.LastUpdated = time.Now()
im.stateMu.Unlock()
// Notify callback if state changed
if stateChanged && im.onStateChange != nil {
im.logger.Debug().Interface("state", im.state).Msg("notifying state change")
im.onStateChange(*im.state)
}
return nil
}
// updateIPAddresses updates the IP addresses in the state
func (im *InterfaceManager) updateInterfaceStateAddresses(nl *link.Link) (bool, error) {
mgr := getNetlinkManager()
addrs, err := nl.AddrList(link.AfUnspec)
if err != nil {
return false, fmt.Errorf("failed to get addresses: %w", err)
}
var (
ipv4Addresses []string
ipv6Addresses []types.IPv6Address
ipv4Addr, ipv6Addr string
ipv6LinkLocal string
ipv6Gateway string
ipv4Ready, ipv6Ready = false, false
stateChanged = false
)
routes, _ := mgr.ListDefaultRoutes(link.AfInet6)
if len(routes) > 0 {
ipv6Gateway = routes[0].Gw.String()
}
for _, addr := range addrs {
if addr.IP.To4() != nil {
// IPv4 address
ipv4Addresses = append(ipv4Addresses, addr.IPNet.String())
if ipv4Addr == "" {
ipv4Addr = addr.IP.String()
ipv4Ready = true
}
continue
}
// IPv6 address (if it's not an IPv4 address, it must be an IPv6 address)
if addr.IP.IsLinkLocalUnicast() {
ipv6LinkLocal = addr.IP.String()
continue
} else if !addr.IP.IsGlobalUnicast() {
continue
}
ipv6Addresses = append(ipv6Addresses, types.IPv6Address{
Address: addr.IP,
Prefix: *addr.IPNet,
Scope: addr.Scope,
ValidLifetime: lifetimeToTime(addr.ValidLft),
PreferredLifetime: lifetimeToTime(addr.PreferedLft),
})
if ipv6Addr == "" {
ipv6Addr = addr.IP.String()
ipv6Ready = true
}
}
if !compareStringSlices(im.state.IPv4Addresses, ipv4Addresses) {
im.state.IPv4Addresses = ipv4Addresses
stateChanged = true
}
if !compareIPv6AddressSlices(im.state.IPv6Addresses, ipv6Addresses) {
im.state.IPv6Addresses = ipv6Addresses
stateChanged = true
}
if im.state.IPv4Address != ipv4Addr {
im.state.IPv4Address = ipv4Addr
stateChanged = true
}
if im.state.IPv6Address != ipv6Addr {
im.state.IPv6Address = ipv6Addr
stateChanged = true
}
if im.state.IPv6LinkLocal != ipv6LinkLocal {
im.state.IPv6LinkLocal = ipv6LinkLocal
stateChanged = true
}
if im.state.IPv6Gateway != ipv6Gateway {
im.state.IPv6Gateway = ipv6Gateway
stateChanged = true
}
if im.state.IPv4Ready != ipv4Ready {
im.state.IPv4Ready = ipv4Ready
stateChanged = true
}
if im.state.IPv6Ready != ipv6Ready {
im.state.IPv6Ready = ipv6Ready
stateChanged = true
}
return stateChanged, nil
}

View File

@ -279,7 +279,7 @@ func (c *Client) handleLeaseChange(lease *Lease) {
} }
// clear all current jobs with the same tags // clear all current jobs with the same tags
c.scheduler.RemoveByTags(version) // c.scheduler.RemoveByTags(version)
// add scheduler job to renew the lease // add scheduler job to renew the lease
if lease.RenewalTime > 0 { if lease.RenewalTime > 0 {
@ -357,6 +357,9 @@ func (c *Client) SetIPv4(ipv4 bool) {
} }
func (c *Client) SetIPv6(ipv6 bool) { func (c *Client) SetIPv6(ipv6 bool) {
c.cfgMu.Lock()
defer c.cfgMu.Unlock()
c.cfg.IPv6 = ipv6 c.cfg.IPv6 = ipv6
} }

View File

@ -302,26 +302,28 @@ func (nm *NetlinkManager) RouteReplace(route *netlink.Route) error {
return netlink.RouteReplace(route) return netlink.RouteReplace(route)
} }
// ListDefaultRoutes lists the default routes for the given family
func (nm *NetlinkManager) ListDefaultRoutes(family int) ([]netlink.Route, error) {
routes, err := netlink.RouteListFiltered(
family,
&netlink.Route{Dst: nil, Table: 254},
netlink.RT_FILTER_DST|netlink.RT_FILTER_TABLE,
)
if err != nil {
nm.logger.Error().Err(err).Int("family", family).Msg("failed to list default routes")
return nil, err
}
return routes, nil
}
// HasDefaultRoute checks if a default route exists for the given family // HasDefaultRoute checks if a default route exists for the given family
func (nm *NetlinkManager) HasDefaultRoute(family int) bool { func (nm *NetlinkManager) HasDefaultRoute(family int) bool {
routes, err := netlink.RouteList(nil, family) routes, err := nm.ListDefaultRoutes(family)
if err != nil { if err != nil {
return false return false
} }
return len(routes) > 0
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 // AddDefaultRoute adds a default route
@ -370,59 +372,152 @@ func (nm *NetlinkManager) RemoveDefaultRoute(family int) error {
return nil return nil
} }
// ReconcileLink reconciles the addresses and routes of a link func (nm *NetlinkManager) reconcileDefaultRoute(link *Link, expected map[string]net.IP, family int) error {
func (nm *NetlinkManager) ReconcileLink(link *Link, expected []*types.IPAddress) error { linkIndex := link.Attrs().Index
expectedAddrs := make(map[string]bool)
existingAddrs := make(map[string]bool)
for _, addr := range expected { added := 0
ipCidr := addr.Address.IP.String() + "/" + addr.Address.Mask.String() toRemove := make([]*netlink.Route, 0)
expectedAddrs[ipCidr] = true
defaultRoutes, err := nm.ListDefaultRoutes(family)
if err != nil {
return fmt.Errorf("failed to get default routes: %w", err)
} }
addrs, err := nm.AddrList(link, AfUnspec) // check existing default routes
for _, defaultRoute := range defaultRoutes {
// only check the default routes for the current link
// TODO: we should also check others later
if defaultRoute.LinkIndex != linkIndex {
continue
}
key := defaultRoute.Gw.String()
if _, ok := expected[key]; !ok {
toRemove = append(toRemove, &defaultRoute)
continue
}
nm.logger.Warn().Str("gateway", key).Msg("keeping default route")
delete(expected, key)
}
// remove remaining default routes
for _, defaultRoute := range toRemove {
nm.logger.Warn().Str("gateway", defaultRoute.Gw.String()).Msg("removing default route")
if err := nm.RouteDel(defaultRoute); err != nil {
nm.logger.Warn().Err(err).Msg("failed to remove default route")
}
}
// add remaining expected default routes
for _, gateway := range expected {
nm.logger.Warn().Str("gateway", gateway.String()).Msg("adding default route")
route := &netlink.Route{
Dst: &ipv4DefaultRoute,
Gw: gateway,
LinkIndex: linkIndex,
}
if family == AfInet6 {
route.Dst = &ipv6DefaultRoute
}
if err := nm.RouteAdd(route); err != nil {
nm.logger.Warn().Err(err).Interface("route", route).Msg("failed to add default route")
}
added++
}
nm.logger.Info().
Int("added", added).
Int("removed", len(toRemove)).
Msg("default routes reconciled")
return nil
}
// ReconcileLink reconciles the addresses and routes of a link
func (nm *NetlinkManager) ReconcileLink(link *Link, expected []types.IPAddress, family int) error {
toAdd := make([]*types.IPAddress, 0)
toRemove := make([]*netlink.Addr, 0)
toUpdate := make([]*types.IPAddress, 0)
expectedAddrs := make(map[string]*types.IPAddress)
expectedGateways := make(map[string]net.IP)
// add all expected addresses to the map
for _, addr := range expected {
expectedAddrs[addr.String()] = &addr
if addr.Gateway != nil {
expectedGateways[addr.String()] = addr.Gateway
}
}
addrs, err := nm.AddrList(link, family)
if err != nil { if err != nil {
return fmt.Errorf("failed to get addresses: %w", err) return fmt.Errorf("failed to get addresses: %w", err)
} }
// check existing addresses
for _, addr := range addrs { for _, addr := range addrs {
ipCidr := addr.IP.String() + "/" + addr.IPNet.Mask.String() // skip the link-local address
existingAddrs[ipCidr] = true if addr.IP.IsLinkLocalUnicast() {
continue
}
expectedAddr, ok := expectedAddrs[addr.IPNet.String()]
if !ok {
toRemove = append(toRemove, &addr)
continue
}
// if it's not fully equal, we need to update it
if !expectedAddr.Compare(addr) {
toUpdate = append(toUpdate, expectedAddr)
continue
}
// remove it from expected addresses
delete(expectedAddrs, addr.IPNet.String())
} }
for _, addr := range expected { // add remaining expected addresses
family := AfUnspec for _, addr := range expectedAddrs {
if addr.Address.IP.To4() != nil { toAdd = append(toAdd, addr)
family = AfInet }
} else if addr.Address.IP.To16() != nil {
family = AfInet6 for _, addr := range toUpdate {
netlinkAddr := addr.NetlinkAddr()
if err := nm.AddrDel(link, &netlinkAddr); err != nil {
nm.logger.Warn().Err(err).Str("address", addr.Address.String()).Msg("failed to update address")
} }
// we'll add it again later
toAdd = append(toAdd, addr)
}
ipCidr := addr.Address.IP.String() + "/" + addr.Address.Mask.String() for _, addr := range toAdd {
ipNet := &net.IPNet{ netlinkAddr := addr.NetlinkAddr()
IP: addr.Address.IP, if err := nm.AddrAdd(link, &netlinkAddr); err != nil {
Mask: addr.Address.Mask, nm.logger.Warn().Err(err).Str("address", addr.Address.String()).Msg("failed to add address")
} }
}
l := nm.logger.With().Str("address", ipNet.String()).Logger() for _, netlinkAddr := range toRemove {
if ok := existingAddrs[ipCidr]; !ok { if err := nm.AddrDel(link, netlinkAddr); err != nil {
l.Trace().Msg("adding address") nm.logger.Warn().Err(err).Str("address", netlinkAddr.IP.String()).Msg("failed to remove address")
if err := nm.AddrAdd(link, &netlink.Addr{IPNet: ipNet}); err != nil {
return fmt.Errorf("failed to add address %s: %w", ipCidr, err)
}
l.Info().Msg("address added")
} }
}
if addr.Gateway != nil { actualToAdd := len(toAdd) - len(toUpdate)
gl := l.With().Str("gateway", addr.Gateway.String()).Logger() if len(toAdd) > 0 || len(toUpdate) > 0 || len(toRemove) > 0 {
gl.Trace().Msg("adding default route") nm.logger.Info().
if err := nm.AddDefaultRoute(link, addr.Gateway, family); err != nil { Int("added", actualToAdd).
return fmt.Errorf("failed to add default route for address %s: %w", ipCidr, err) Int("updated", len(toUpdate)).
} Int("removed", len(toRemove)).
gl.Info().Msg("default route added") Msg("addresses reconciled")
} }
if err := nm.reconcileDefaultRoute(link, expectedGateways, family); err != nil {
nm.logger.Warn().Err(err).Msg("failed to reconcile default route")
} }
return nil return nil

View File

@ -118,6 +118,22 @@ func (l *Link) AddrList(family int) ([]netlink.Addr, error) {
return netlink.AddrList(l.Link, family) return netlink.AddrList(l.Link, family)
} }
// HasGlobalUnicastAddress returns true if the link has a global unicast address
func (l *Link) HasGlobalUnicastAddress() bool {
addrs, err := l.AddrList(AfUnspec)
if err != nil {
return false
}
for _, addr := range addrs {
if addr.IP.IsGlobalUnicast() {
return true
}
}
return false
}
// IsSame checks if the link is the same as another link
func (l *Link) IsSame(other *Link) bool { func (l *Link) IsSame(other *Link) bool {
if l == nil || other == nil { if l == nil || other == nil {
return false return false

View File

@ -11,13 +11,3 @@ type IPv4Address struct {
Secondary bool Secondary bool
Permanent 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
}

View File

@ -24,7 +24,7 @@ type NetworkManager struct {
cancel context.CancelFunc cancel context.CancelFunc
// Callback functions for state changes // Callback functions for state changes
onInterfaceStateChange func(iface string, state *types.InterfaceState) onInterfaceStateChange func(iface string, state types.InterfaceState)
onConfigChange func(iface string, config *types.NetworkConfig) onConfigChange func(iface string, config *types.NetworkConfig)
onDHCPLeaseChange func(iface string, lease *types.DHCPLease) onDHCPLeaseChange func(iface string, lease *types.DHCPLease)
} }
@ -63,7 +63,7 @@ func (nm *NetworkManager) AddInterface(iface string, config *types.NetworkConfig
} }
// Set up callbacks // Set up callbacks
im.SetOnStateChange(func(state *types.InterfaceState) { im.SetOnStateChange(func(state types.InterfaceState) {
if nm.onInterfaceStateChange != nil { if nm.onInterfaceStateChange != nil {
nm.onInterfaceStateChange(iface, state) nm.onInterfaceStateChange(iface, state)
} }
@ -179,7 +179,7 @@ func (nm *NetworkManager) RenewDHCPLease(iface string) error {
} }
// SetOnInterfaceStateChange sets the callback for interface state changes // SetOnInterfaceStateChange sets the callback for interface state changes
func (nm *NetworkManager) SetOnInterfaceStateChange(callback func(iface string, state *types.InterfaceState)) { func (nm *NetworkManager) SetOnInterfaceStateChange(callback func(iface string, state types.InterfaceState)) {
nm.onInterfaceStateChange = callback nm.onInterfaceStateChange = callback
} }

View File

@ -7,7 +7,6 @@ import (
"github.com/jetkvm/kvm/internal/network/types" "github.com/jetkvm/kvm/internal/network/types"
"github.com/jetkvm/kvm/pkg/nmlite/link" "github.com/jetkvm/kvm/pkg/nmlite/link"
"github.com/rs/zerolog" "github.com/rs/zerolog"
"github.com/vishvananda/netlink"
) )
// StaticConfigManager manages static network configuration // StaticConfigManager manages static network configuration
@ -32,81 +31,90 @@ func NewStaticConfigManager(ifaceName string, logger *zerolog.Logger) (*StaticCo
}, nil }, nil
} }
// ApplyIPv4Static applies static IPv4 configuration // ToIPv4Static applies static IPv4 configuration
func (scm *StaticConfigManager) ApplyIPv4Static(config *types.IPv4StaticConfig) error { func (scm *StaticConfigManager) ToIPv4Static(config *types.IPv4StaticConfig) (*types.ParsedIPConfig, error) {
scm.logger.Info().Msg("applying static IPv4 configuration") if config == nil {
return nil, fmt.Errorf("config is nil")
}
// Parse and validate configuration // Parse IP address and netmask
ipv4Config, err := scm.parseIPv4Config(config) ipNet, err := link.ParseIPv4Netmask(config.Address.String, config.Netmask.String)
if err != nil { if err != nil {
return fmt.Errorf("failed to parse IPv4 config: %w", err) return nil, err
}
scm.logger.Info().Str("ipNet", ipNet.String()).Interface("ipc", config).Msg("parsed IPv4 address and netmask")
// Parse gateway
gateway := net.ParseIP(config.Gateway.String)
if gateway == nil {
return nil, fmt.Errorf("invalid gateway: %s", config.Gateway.String)
} }
// Get interface // Parse DNS servers
netlinkMgr := getNetlinkManager() var dns []net.IP
link, err := netlinkMgr.GetLinkByName(scm.ifaceName) for _, dnsStr := range config.DNS {
if err != nil { if err := link.ValidateIPAddress(dnsStr, false); err != nil {
return fmt.Errorf("failed to get interface: %w", err) return nil, fmt.Errorf("invalid DNS server: %w", err)
}
dns = append(dns, net.ParseIP(dnsStr))
} }
// Ensure interface is up address := types.IPAddress{
if err := netlinkMgr.EnsureInterfaceUp(link); err != nil { Family: link.AfInet,
return fmt.Errorf("failed to bring interface up: %w", err) Address: *ipNet,
Gateway: gateway,
Secondary: false,
Permanent: true,
} }
// Apply IP address return &types.ParsedIPConfig{
if err := scm.applyIPv4Address(link, ipv4Config); err != nil { Addresses: []types.IPAddress{address},
return fmt.Errorf("failed to apply IPv4 address: %w", err) Nameservers: dns,
} Interface: scm.ifaceName,
}, nil
// 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 // ToIPv6Static applies static IPv6 configuration
func (scm *StaticConfigManager) ApplyIPv6Static(config *types.IPv6StaticConfig) error { func (scm *StaticConfigManager) ToIPv6Static(config *types.IPv6StaticConfig) (*types.ParsedIPConfig, error) {
scm.logger.Info().Msg("applying static IPv6 configuration") if config == nil {
return nil, fmt.Errorf("config is nil")
}
// Parse and validate configuration // Parse IP address and prefix
ipv6Config, err := scm.parseIPv6Config(config) ipNet, err := link.ParseIPv6Prefix(config.Prefix.String, 64) // Default to /64 if not specified
if err != nil { if err != nil {
return fmt.Errorf("failed to parse IPv6 config: %w", err) return nil, err
} }
// Get interface // Parse gateway
netlinkMgr := getNetlinkManager() gateway := net.ParseIP(config.Gateway.String)
link, err := netlinkMgr.GetLinkByName(scm.ifaceName) if gateway == nil {
if err != nil { return nil, fmt.Errorf("invalid gateway: %s", config.Gateway.String)
return fmt.Errorf("failed to get interface: %w", err)
} }
// Enable IPv6 // Parse DNS servers
if err := scm.enableIPv6(); err != nil { var dns []net.IP
return fmt.Errorf("failed to enable IPv6: %w", err) for _, dnsStr := range config.DNS {
dnsIP := net.ParseIP(dnsStr)
if dnsIP == nil {
return nil, fmt.Errorf("invalid DNS server: %s", dnsStr)
}
dns = append(dns, dnsIP)
} }
// Ensure interface is up address := types.IPAddress{
if err := netlinkMgr.EnsureInterfaceUp(link); err != nil { Family: link.AfInet6,
return fmt.Errorf("failed to bring interface up: %w", err) Address: *ipNet,
Gateway: gateway,
Secondary: false,
Permanent: true,
} }
// Apply IP address return &types.ParsedIPConfig{
if err := scm.applyIPv6Address(link, ipv6Config); err != nil { Addresses: []types.IPAddress{address},
return fmt.Errorf("failed to apply IPv6 address: %w", err) Nameservers: dns,
} Interface: scm.ifaceName,
}, nil
// 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 // DisableIPv4 disables IPv4 on the interface
@ -169,156 +177,6 @@ func (scm *StaticConfigManager) EnableIPv6LinkLocal() error {
return netlinkMgr.EnsureInterfaceUp(link) return netlinkMgr.EnsureInterfaceUp(link)
} }
// parseIPv4Config parses and validates IPv4 static configuration
func (scm *StaticConfigManager) parseIPv4Config(config *types.IPv4StaticConfig) (*parsedIPv4Config, error) {
if config == nil {
return nil, fmt.Errorf("config is nil")
}
// Parse IP address and netmask
ipNet, err := link.ParseIPv4Netmask(config.Address.String, config.Netmask.String)
if err != nil {
return nil, err
}
scm.logger.Info().Str("ipNet", ipNet.String()).Interface("ipc", config).Msg("parsed IPv4 address and netmask")
// Parse gateway
gateway := net.ParseIP(config.Gateway.String)
if gateway == nil {
return nil, fmt.Errorf("invalid gateway: %s", config.Gateway.String)
}
// Parse DNS servers
var dns []net.IP
for _, dnsStr := range config.DNS {
if err := link.ValidateIPAddress(dnsStr, false); err != nil {
return nil, fmt.Errorf("invalid DNS server: %w", err)
}
dns = append(dns, net.ParseIP(dnsStr))
}
return &parsedIPv4Config{
network: *ipNet,
gateway: gateway,
dns: dns,
}, nil
}
// parseIPv6Config parses and validates IPv6 static configuration
func (scm *StaticConfigManager) parseIPv6Config(config *types.IPv6StaticConfig) (*parsedIPv6Config, error) {
if config == nil {
return nil, fmt.Errorf("config is nil")
}
// Parse IP address and prefix
ipNet, err := link.ParseIPv6Prefix(config.Prefix.String, 64) // Default to /64 if not specified
if err != nil {
return nil, err
}
// Parse gateway
gateway := net.ParseIP(config.Gateway.String)
if gateway == nil {
return nil, fmt.Errorf("invalid gateway: %s", config.Gateway.String)
}
// Parse DNS servers
var dns []net.IP
for _, dnsStr := range config.DNS {
dnsIP := net.ParseIP(dnsStr)
if dnsIP == nil {
return nil, fmt.Errorf("invalid DNS server: %s", dnsStr)
}
dns = append(dns, dnsIP)
}
return &parsedIPv6Config{
prefix: *ipNet,
gateway: gateway,
dns: dns,
}, nil
}
// applyIPv4Address applies IPv4 address to interface
func (scm *StaticConfigManager) applyIPv4Address(iface *link.Link, config *parsedIPv4Config) error {
netlinkMgr := getNetlinkManager()
// Remove existing IPv4 addresses
if err := netlinkMgr.RemoveAllAddresses(iface, link.AfInet); err != nil {
return fmt.Errorf("failed to remove existing IPv4 addresses: %w", err)
}
// Add new address
addr := &netlink.Addr{
IPNet: &config.network,
}
if err := netlinkMgr.AddrAdd(iface, addr); err != nil {
return fmt.Errorf("failed to add IPv4 address: %w", err)
}
scm.logger.Info().Str("address", config.network.String()).Msg("IPv4 address applied")
return nil
}
// applyIPv6Address applies IPv6 address to interface
func (scm *StaticConfigManager) applyIPv6Address(iface *link.Link, config *parsedIPv6Config) error {
netlinkMgr := getNetlinkManager()
// Remove existing global IPv6 addresses
if err := netlinkMgr.RemoveNonLinkLocalIPv6Addresses(iface); err != nil {
return fmt.Errorf("failed to remove existing IPv6 addresses: %w", err)
}
// Add new address
addr := &netlink.Addr{
IPNet: &config.prefix,
}
if err := netlinkMgr.AddrAdd(iface, addr); err != nil {
return fmt.Errorf("failed to add IPv6 address: %w", err)
}
scm.logger.Info().Str("address", config.prefix.String()).Msg("IPv6 address applied")
return nil
}
// applyIPv4Route applies IPv4 default route
func (scm *StaticConfigManager) applyIPv4Route(iface *link.Link, config *parsedIPv4Config) error {
netlinkMgr := getNetlinkManager()
// Check if default route already exists
if netlinkMgr.HasDefaultRoute(link.AfInet) {
scm.logger.Info().Msg("IPv4 default route already exists")
return nil
}
// Add default route
if err := netlinkMgr.AddDefaultRoute(iface, config.gateway, link.AfInet); err != nil {
return fmt.Errorf("failed to add IPv4 default route: %w", err)
}
scm.logger.Info().Str("gateway", config.gateway.String()).Msg("IPv4 default route applied")
return nil
}
// applyIPv6Route applies IPv6 default route
func (scm *StaticConfigManager) applyIPv6Route(iface *link.Link, config *parsedIPv6Config) error {
netlinkMgr := getNetlinkManager()
// Check if default route already exists
if netlinkMgr.HasDefaultRoute(link.AfInet6) {
scm.logger.Info().Msg("IPv6 default route already exists")
return nil
}
// Add default route
if err := netlinkMgr.AddDefaultRoute(iface, config.gateway, link.AfInet6); err != nil {
return fmt.Errorf("failed to add IPv6 default route: %w", err)
}
scm.logger.Info().Str("gateway", config.gateway.String()).Msg("IPv6 default route applied")
return nil
}
// removeIPv4DefaultRoute removes IPv4 default route // removeIPv4DefaultRoute removes IPv4 default route
func (scm *StaticConfigManager) removeIPv4DefaultRoute() error { func (scm *StaticConfigManager) removeIPv4DefaultRoute() error {
netlinkMgr := getNetlinkManager() netlinkMgr := getNetlinkManager()
@ -330,17 +188,3 @@ func (scm *StaticConfigManager) enableIPv6() error {
netlinkMgr := getNetlinkManager() netlinkMgr := getNetlinkManager()
return netlinkMgr.EnableIPv6(scm.ifaceName) 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
}

70
pkg/nmlite/utils.go Normal file
View File

@ -0,0 +1,70 @@
package nmlite
import (
"sort"
"time"
"github.com/jetkvm/kvm/internal/network/types"
)
func lifetimeToTime(lifetime int) *time.Time {
if lifetime == 0 {
return nil
}
// Check for infinite lifetime (0xFFFFFFFF = 4294967295)
// This is used for static/permanent addresses
// Use uint32 to avoid int overflow on 32-bit systems
const infiniteLifetime uint32 = 0xFFFFFFFF
if uint32(lifetime) == infiniteLifetime || lifetime < 0 {
return nil // Infinite lifetime - no expiration
}
// For finite lifetimes (SLAAC addresses)
t := time.Now().Add(time.Duration(lifetime) * time.Second)
return &t
}
func compareStringSlices(a, b []string) bool {
if len(a) != len(b) {
return false
}
sort.Strings(a)
sort.Strings(b)
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}
func compareIPv6AddressSlices(a, b []types.IPv6Address) bool {
if len(a) != len(b) {
return false
}
sort.SliceStable(a, func(i, j int) bool {
return a[i].Address.String() < b[j].Address.String()
})
sort.SliceStable(b, func(i, j int) bool {
return b[i].Address.String() < a[j].Address.String()
})
for i := range a {
if a[i].Address.String() != b[i].Address.String() {
return false
}
if a[i].Prefix.String() != b[i].Prefix.String() {
return false
}
// we don't compare the lifetimes because they are not always same
if a[i].Scope != b[i].Scope {
return false
}
}
return true
}

View File

@ -25,6 +25,14 @@ export default function Ipv6NetworkCard({
{networkState?.ipv6_link_local} {networkState?.ipv6_link_local}
</span> </span>
</div> </div>
<div className="flex flex-col justify-between">
<span className="text-sm text-slate-600 dark:text-slate-400">
Gateway
</span>
<span className="text-sm font-medium">
{networkState?.ipv6_gateway}
</span>
</div>
</div> </div>
<div className="space-y-3 pt-2"> <div className="space-y-3 pt-2">

View File

@ -709,6 +709,7 @@ export interface NetworkState {
ipv6?: string; ipv6?: string;
ipv6_addresses?: IPv6Address[]; ipv6_addresses?: IPv6Address[];
ipv6_link_local?: string; ipv6_link_local?: string;
ipv6_gateway?: string;
dhcp_lease?: DhcpLease; dhcp_lease?: DhcpLease;
setNetworkState: (state: NetworkState) => void; setNetworkState: (state: NetworkState) => void;

View File

@ -374,8 +374,8 @@ function UrlView({
icon: FedoraIcon, icon: FedoraIcon,
}, },
{ {
name: "openSUSE Leap 15.6", name: "openSUSE Leap 16.0",
url: "https://download.opensuse.org/distribution/leap/15.6/iso/openSUSE-Leap-15.6-NET-x86_64-Media.iso", url: "https://download.opensuse.org/distribution/leap/16.0/offline/Leap-16.0-online-installer-x86_64.install.iso",
icon: OpenSUSEIcon, icon: OpenSUSEIcon,
}, },
{ {

View File

@ -8,7 +8,7 @@ import validator from "validator";
import { ConfirmDialog } from "@/components/ConfirmDialog"; import { ConfirmDialog } from "@/components/ConfirmDialog";
import { SelectMenuBasic } from "@/components/SelectMenuBasic"; import { SelectMenuBasic } from "@/components/SelectMenuBasic";
import { SettingsPageHeader } from "@/components/SettingsPageheader"; import { SettingsPageHeader } from "@/components/SettingsPageheader";
import { NetworkSettings, NetworkState, useRTCStore } from "@/hooks/stores"; import { NetworkSettings, NetworkState, useNetworkStateStore, useRTCStore } from "@/hooks/stores";
import notifications from "@/notifications"; import notifications from "@/notifications";
import { getNetworkSettings, getNetworkState } from "@/utils/jsonrpc"; import { getNetworkSettings, getNetworkState } from "@/utils/jsonrpc";
import { Button } from "@components/Button"; import { Button } from "@components/Button";
@ -75,7 +75,8 @@ export function LifeTimeLabel({ lifetime }: { lifetime: string }) {
export default function SettingsNetworkRoute() { export default function SettingsNetworkRoute() {
const { send } = useJsonRpc(); const { send } = useJsonRpc();
const [networkState, setNetworkState] = useState<NetworkState | null>(null); const networkState = useNetworkStateStore(state => state);
const setNetworkState = useNetworkStateStore(state => state.setNetworkState);
// Some input needs direct state management. Mostly options that open more details // Some input needs direct state management. Mostly options that open more details
const [customDomain, setCustomDomain] = useState<string>(""); const [customDomain, setCustomDomain] = useState<string>("");

View File

@ -123,9 +123,9 @@ export default function KvmIdRoute() {
const params = useParams() as { id: string }; const params = useParams() as { id: string };
const { sidebarView, setSidebarView, disableVideoFocusTrap } = useUiStore(); const { sidebarView, setSidebarView, disableVideoFocusTrap } = useUiStore();
const [ queryParams, setQueryParams ] = useSearchParams(); const [queryParams, setQueryParams] = useSearchParams();
const { const {
peerConnection, setPeerConnection, peerConnection, setPeerConnection,
peerConnectionState, setPeerConnectionState, peerConnectionState, setPeerConnectionState,
setMediaStream, setMediaStream,
@ -597,10 +597,10 @@ export default function KvmIdRoute() {
}); });
}, 10000); }, 10000);
const { setNetworkState} = useNetworkStateStore(); const { setNetworkState } = useNetworkStateStore();
const { setHdmiState } = useVideoStore(); const { setHdmiState } = useVideoStore();
const { const {
keyboardLedState, setKeyboardLedState, keyboardLedState, setKeyboardLedState,
keysDownState, setKeysDownState, setUsbState, keysDownState, setKeysDownState, setUsbState,
} = useHidStore(); } = useHidStore();
const setHidRpcDisabled = useRTCStore(state => state.setHidRpcDisabled); const setHidRpcDisabled = useRTCStore(state => state.setHidRpcDisabled);
@ -756,7 +756,7 @@ export default function KvmIdRoute() {
if (location.pathname !== "/other-session") navigateTo("/"); if (location.pathname !== "/other-session") navigateTo("/");
}, [navigateTo, location.pathname]); }, [navigateTo, location.pathname]);
const { appVersion, getLocalVersion} = useVersion(); const { appVersion, getLocalVersion } = useVersion();
useEffect(() => { useEffect(() => {
if (appVersion) return; if (appVersion) return;