From 7ebf7ba9fbe1ed8226c4686524d2800fa4d259c6 Mon Sep 17 00:00:00 2001 From: Siyuan Miao Date: Fri, 11 Apr 2025 19:16:08 +0200 Subject: [PATCH 01/26] chore(network): improve connectivity check --- cloud.go | 4 +-- network.go | 12 +++++++++ ntp.go | 77 +++++++++++++++++++++++++++++++++--------------------- 3 files changed, 61 insertions(+), 32 deletions(-) diff --git a/cloud.go b/cloud.go index fd96c41d6..9fbf00b91 100644 --- a/cloud.go +++ b/cloud.go @@ -450,8 +450,8 @@ func RunWebsocketClient() { } // If the network is not up, well, we can't connect to the cloud. - if !networkState.Up { - cloudLogger.Warn().Msg("waiting for network to be up, will retry in 3 seconds") + if !networkState.IsOnline() { + cloudLogger.Warn().Msg("waiting for network to be online, will retry in 3 seconds") time.Sleep(3 * time.Second) continue } diff --git a/network.go b/network.go index 6948d9a0d..e524e7210 100644 --- a/network.go +++ b/network.go @@ -32,6 +32,18 @@ type NetworkState struct { checked bool } +func (s *NetworkState) IsUp() bool { + return s.Up && s.IPv4 != "" && s.IPv6 != "" +} + +func (s *NetworkState) HasIPAssigned() bool { + return s.IPv4 != "" || s.IPv6 != "" +} + +func (s *NetworkState) IsOnline() bool { + return s.Up && s.HasIPAssigned() +} + type LocalIpInfo struct { IPv4 string IPv6 string diff --git a/ntp.go b/ntp.go index a104c5682..481c1410d 100644 --- a/ntp.go +++ b/ntp.go @@ -31,13 +31,13 @@ var ( func isTimeSyncNeeded() bool { if builtTimestamp == "" { - ntpLogger.Warn().Msg("Built timestamp is not set, time sync is needed") + ntpLogger.Warn().Msg("built timestamp is not set, time sync is needed") return true } ts, err := strconv.Atoi(builtTimestamp) if err != nil { - ntpLogger.Warn().Str("error", err.Error()).Msg("Failed to parse built timestamp") + ntpLogger.Warn().Str("error", err.Error()).Msg("failed to parse built timestamp") return true } @@ -45,10 +45,11 @@ func isTimeSyncNeeded() bool { builtTime := time.Unix(int64(ts), 0) now := time.Now() - ntpLogger.Debug().Str("built_time", builtTime.Format(time.RFC3339)).Str("now", now.Format(time.RFC3339)).Msg("Built time and now") - if now.Sub(builtTime) < 0 { - ntpLogger.Warn().Msg("System time is behind the built time, time sync is needed") + ntpLogger.Warn(). + Str("built_time", builtTime.Format(time.RFC3339)). + Str("now", now.Format(time.RFC3339)). + Msg("system time is behind the built time, time sync is needed") return true } @@ -62,8 +63,8 @@ func TimeSyncLoop() { continue } - if !networkState.Up { - ntpLogger.Info().Msg("Waiting for network to come up") + if !networkState.IsOnline() { + ntpLogger.Info().Msg("waiting for network to be online") time.Sleep(timeSyncWaitNetUpInt) continue } @@ -71,11 +72,11 @@ func TimeSyncLoop() { // check if time sync is needed, but do nothing for now isTimeSyncNeeded() - ntpLogger.Info().Msg("Syncing system time") + ntpLogger.Info().Msg("syncing system time") start := time.Now() err := SyncSystemTime() if err != nil { - ntpLogger.Error().Str("error", err.Error()).Msg("Failed to sync system time") + ntpLogger.Error().Str("error", err.Error()).Msg("failed to sync system time") // retry after a delay timeSyncRetryInterval += timeSyncRetryStep @@ -90,7 +91,7 @@ func TimeSyncLoop() { timeSyncSuccess = true ntpLogger.Info().Str("now", time.Now().Format(time.RFC3339)). Str("time_taken", time.Since(start).String()). - Msg("Time sync successful") + Msg("time sync successful") time.Sleep(timeSyncInterval) // after the first sync is done } } @@ -117,24 +118,31 @@ func queryNetworkTime() (*time.Time, error) { ntpServers = defaultNTPServers ntpLogger.Info(). Interface("ntp_servers", ntpServers). - Msg("Using default NTP servers") + Msg("using default NTP servers") } else { ntpLogger.Info(). Interface("ntp_servers", ntpServers). - Msg("Using NTP servers from DHCP") + Msg("using NTP servers from DHCP") } for _, server := range ntpServers { - now, err := queryNtpServer(server, timeSyncTimeout) + now, err, response := queryNtpServer(server, timeSyncTimeout) + + scopedLogger := ntpLogger.With(). + Str("server", server). + Logger() + if err == nil { - ntpLogger.Info(). - Str("ntp_server", server). + scopedLogger.Info(). Str("time", now.Format(time.RFC3339)). + Str("reference", response.ReferenceString()). + Str("rtt", response.RTT.String()). + Str("clockOffset", response.ClockOffset.String()). + Uint8("stratum", response.Stratum). Msg("NTP server returned time") return now, nil } else { - ntpLogger.Error(). - Str("ntp_server", server). + scopedLogger.Error(). Str("error", err.Error()). Msg("failed to query NTP server") } @@ -145,16 +153,25 @@ func queryNetworkTime() (*time.Time, error) { "http://cloudflare.com", } for _, url := range httpUrls { - now, err := queryHttpTime(url, timeSyncTimeout) + now, err, response := queryHttpTime(url, timeSyncTimeout) + + var status string + if response != nil { + status = response.Status + } + + scopedLogger := ntpLogger.With(). + Str("http_url", url). + Str("status", status). + Logger() + if err == nil { - ntpLogger.Info(). - Str("http_url", url). + scopedLogger.Info(). Str("time", now.Format(time.RFC3339)). Msg("HTTP server returned time") return now, nil } else { - ntpLogger.Error(). - Str("http_url", url). + scopedLogger.Error(). Str("error", err.Error()). Msg("failed to query HTTP server") } @@ -163,28 +180,28 @@ func queryNetworkTime() (*time.Time, error) { return nil, ErrorfL(ntpLogger, "failed to query network time, all NTP servers and HTTP servers failed", nil) } -func queryNtpServer(server string, timeout time.Duration) (now *time.Time, err error) { +func queryNtpServer(server string, timeout time.Duration) (now *time.Time, err error, response *ntp.Response) { resp, err := ntp.QueryWithOptions(server, ntp.QueryOptions{Timeout: timeout}) if err != nil { - return nil, err + return nil, err, nil } - return &resp.Time, nil + return &resp.Time, nil, resp } -func queryHttpTime(url string, timeout time.Duration) (*time.Time, error) { +func queryHttpTime(url string, timeout time.Duration) (now *time.Time, err error, response *http.Response) { client := http.Client{ Timeout: timeout, } resp, err := client.Head(url) if err != nil { - return nil, err + return nil, err, nil } dateStr := resp.Header.Get("Date") - now, err := time.Parse(time.RFC1123, dateStr) + parsedTime, err := time.Parse(time.RFC1123, dateStr) if err != nil { - return nil, err + return nil, err, resp } - return &now, nil + return &parsedTime, nil, resp } func setSystemTime(now time.Time) error { From f712cb1719cd20f81b51757f07bfb505d49256d3 Mon Sep 17 00:00:00 2001 From: Siyuan Miao Date: Sat, 12 Apr 2025 17:32:15 +0200 Subject: [PATCH 02/26] refactor(network): rewrite network and timesync component --- cloud.go | 14 +- config.go | 4 +- display.go | 6 +- go.mod | 1 + go.sum | 2 + internal/timesync/http.go | 54 +++++ internal/timesync/ntp.go | 42 ++++ internal/timesync/rtc.go | 26 ++ internal/timesync/rtc_linux.go | 103 ++++++++ internal/timesync/rtc_notlinux.go | 16 ++ internal/timesync/timesync.go | 151 ++++++++++++ internal/udhcpc/options.go | 12 + internal/udhcpc/parser.go | 150 ++++++++++++ internal/udhcpc/parser_test.go | 74 ++++++ internal/udhcpc/proc.go | 212 ++++++++++++++++ internal/udhcpc/udhcpc.go | 145 +++++++++++ log.go | 3 +- main.go | 22 +- mdns.go | 60 +++++ network.go | 386 +++++++++++++++++++----------- ntp.go | 214 ----------------- timesync.go | 69 ++++++ web_tls.go | 2 +- 23 files changed, 1394 insertions(+), 374 deletions(-) create mode 100644 internal/timesync/http.go create mode 100644 internal/timesync/ntp.go create mode 100644 internal/timesync/rtc.go create mode 100644 internal/timesync/rtc_linux.go create mode 100644 internal/timesync/rtc_notlinux.go create mode 100644 internal/timesync/timesync.go create mode 100644 internal/udhcpc/options.go create mode 100644 internal/udhcpc/parser.go create mode 100644 internal/udhcpc/parser_test.go create mode 100644 internal/udhcpc/proc.go create mode 100644 internal/udhcpc/udhcpc.go create mode 100644 mdns.go delete mode 100644 ntp.go create mode 100644 timesync.go diff --git a/cloud.go b/cloud.go index 9fbf00b91..f7bdb6e3b 100644 --- a/cloud.go +++ b/cloud.go @@ -311,11 +311,15 @@ func runWebsocketClient() error { }, }) - // get the request id from the response header - connectionId := resp.Header.Get("X-Request-ID") - if connectionId == "" { - connectionId = resp.Header.Get("Cf-Ray") + var connectionId string + if resp != nil { + // get the request id from the response header + connectionId = resp.Header.Get("X-Request-ID") + if connectionId == "" { + connectionId = resp.Header.Get("Cf-Ray") + } } + if connectionId == "" { connectionId = uuid.New().String() scopedLogger.Warn(). @@ -457,7 +461,7 @@ func RunWebsocketClient() { } // If the system time is not synchronized, the API request will fail anyway because the TLS handshake will fail. - if isTimeSyncNeeded() && !timeSyncSuccess { + if isTimeSyncNeeded() && !timeSync.IsSyncSuccess() { cloudLogger.Warn().Msg("system time is not synced, will retry in 3 seconds") time.Sleep(3 * time.Second) continue diff --git a/config.go b/config.go index cf096a73e..ed7477ed9 100644 --- a/config.go +++ b/config.go @@ -134,7 +134,7 @@ func LoadConfig() { defer configLock.Unlock() if config != nil { - logger.Info().Msg("config already loaded, skipping") + logger.Debug().Msg("config already loaded, skipping") return } @@ -167,6 +167,8 @@ func LoadConfig() { config = &loadedConfig rootLogger.UpdateLogLevel() + + logger.Info().Str("path", configPath).Msg("config loaded") } func SaveConfig() error { diff --git a/display.go b/display.go index 38e12b1da..7320cce8b 100644 --- a/display.go +++ b/display.go @@ -48,7 +48,7 @@ func switchToScreenIfDifferent(screenName string) { } func updateDisplay() { - updateLabelIfChanged("ui_Home_Content_Ip", networkState.IPv4) + updateLabelIfChanged("ui_Home_Content_Ip", networkState.IPv4String()) if usbState == "configured" { updateLabelIfChanged("ui_Home_Footer_Usb_Status_Label", "Connected") _, _ = CallCtrlAction("lv_obj_set_state", map[string]interface{}{"obj": "ui_Home_Footer_Usb_Status_Label", "state": "LV_STATE_DEFAULT"}) @@ -64,7 +64,7 @@ func updateDisplay() { _, _ = CallCtrlAction("lv_obj_set_state", map[string]interface{}{"obj": "ui_Home_Footer_Hdmi_Status_Label", "state": "LV_STATE_USER_2"}) } updateLabelIfChanged("ui_Home_Header_Cloud_Status_Label", fmt.Sprintf("%d active", actionSessions)) - if networkState.Up { + if networkState.IsUp() { switchToScreenIfDifferent("ui_Home_Screen") } else { switchToScreenIfDifferent("ui_No_Network_Screen") @@ -94,7 +94,7 @@ func requestDisplayUpdate() { func updateStaticContents() { //contents that never change - updateLabelIfChanged("ui_Home_Content_Mac", networkState.MAC) + updateLabelIfChanged("ui_Home_Content_Mac", networkState.MACString()) systemVersion, appVersion, err := GetLocalVersion() if err == nil { updateLabelIfChanged("ui_About_Content_Operating_System_Version_ContentLabel", systemVersion.String()) diff --git a/go.mod b/go.mod index 1311a333d..bc231f23b 100644 --- a/go.mod +++ b/go.mod @@ -8,6 +8,7 @@ require ( github.com/coder/websocket v1.8.13 github.com/coreos/go-oidc/v3 v3.11.0 github.com/creack/pty v1.1.23 + github.com/fsnotify/fsnotify v1.9.0 github.com/gin-contrib/logger v1.2.5 github.com/gin-gonic/gin v1.10.0 github.com/google/uuid v1.6.0 diff --git a/go.sum b/go.sum index 565c0ccb1..018d3a893 100644 --- a/go.sum +++ b/go.sum @@ -28,6 +28,8 @@ github.com/creack/pty v1.1.23/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfv github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM= github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8= github.com/gin-contrib/logger v1.2.5 h1:qVQI4omayQecuN4zX9ZZnsOq7w9J/ZLds3J/FMn8ypM= diff --git a/internal/timesync/http.go b/internal/timesync/http.go new file mode 100644 index 000000000..a6be68cbf --- /dev/null +++ b/internal/timesync/http.go @@ -0,0 +1,54 @@ +package timesync + +import ( + "net/http" + "time" +) + +func queryHttpTime( + url string, + timeout time.Duration, +) (now *time.Time, err error, response *http.Response) { + client := http.Client{ + Timeout: timeout, + } + resp, err := client.Head(url) + if err != nil { + return nil, err, nil + } + dateStr := resp.Header.Get("Date") + parsedTime, err := time.Parse(time.RFC1123, dateStr) + if err != nil { + return nil, err, resp + } + return &parsedTime, nil, resp +} + +func (t *TimeSync) queryAllHttpTime() (now *time.Time) { + for _, url := range t.httpUrls { + now, err, response := queryHttpTime(url, timeSyncTimeout) + + var status string + if response != nil { + status = response.Status + } + + scopedLogger := t.l.With(). + Str("http_url", url). + Str("status", status). + Logger() + + if err == nil { + scopedLogger.Info(). + Str("time", now.Format(time.RFC3339)). + Msg("HTTP server returned time") + return now + } else { + scopedLogger.Error(). + Str("error", err.Error()). + Msg("failed to query HTTP server") + } + } + + return nil +} diff --git a/internal/timesync/ntp.go b/internal/timesync/ntp.go new file mode 100644 index 000000000..9bc9812ff --- /dev/null +++ b/internal/timesync/ntp.go @@ -0,0 +1,42 @@ +package timesync + +import ( + "time" + + "github.com/beevik/ntp" +) + +func (t *TimeSync) queryNetworkTime() (now *time.Time) { + for _, server := range t.ntpServers { + now, err, response := queryNtpServer(server, timeSyncTimeout) + + scopedLogger := t.l.With(). + Str("server", server). + Logger() + + if err == nil { + scopedLogger.Info(). + Str("time", now.Format(time.RFC3339)). + Str("reference", response.ReferenceString()). + Str("rtt", response.RTT.String()). + Str("clockOffset", response.ClockOffset.String()). + Uint8("stratum", response.Stratum). + Msg("NTP server returned time") + return now + } else { + scopedLogger.Error(). + Str("error", err.Error()). + Msg("failed to query NTP server") + } + } + + return nil +} + +func queryNtpServer(server string, timeout time.Duration) (now *time.Time, err error, response *ntp.Response) { + resp, err := ntp.QueryWithOptions(server, ntp.QueryOptions{Timeout: timeout}) + if err != nil { + return nil, err, nil + } + return &resp.Time, nil, resp +} diff --git a/internal/timesync/rtc.go b/internal/timesync/rtc.go new file mode 100644 index 000000000..92ee485da --- /dev/null +++ b/internal/timesync/rtc.go @@ -0,0 +1,26 @@ +package timesync + +import ( + "fmt" + "os" +) + +var ( + rtcDeviceSearchPaths = []string{ + "/dev/rtc", + "/dev/rtc0", + "/dev/rtc1", + "/dev/misc/rtc", + "/dev/misc/rtc0", + "/dev/misc/rtc1", + } +) + +func getRtcDevicePath() (string, error) { + for _, path := range rtcDeviceSearchPaths { + if _, err := os.Stat(path); err == nil { + return path, nil + } + } + return "", fmt.Errorf("rtc device not found") +} diff --git a/internal/timesync/rtc_linux.go b/internal/timesync/rtc_linux.go new file mode 100644 index 000000000..dccfab2cd --- /dev/null +++ b/internal/timesync/rtc_linux.go @@ -0,0 +1,103 @@ +//go:build linux + +package timesync + +import ( + "fmt" + "os" + "time" + + "golang.org/x/sys/unix" +) + +func TimetoRtcTime(t time.Time) unix.RTCTime { + return unix.RTCTime{ + Sec: int32(t.Second()), + Min: int32(t.Minute()), + Hour: int32(t.Hour()), + Mday: int32(t.Day()), + Mon: int32(t.Month() - 1), + Year: int32(t.Year() - 1900), + Wday: int32(0), + Yday: int32(0), + Isdst: int32(0), + } +} + +func RtcTimetoTime(t unix.RTCTime) time.Time { + return time.Date( + int(t.Year)+1900, + time.Month(t.Mon+1), + int(t.Mday), + int(t.Hour), + int(t.Min), + int(t.Sec), + 0, + time.UTC, + ) +} + +func (t *TimeSync) getRtcDevice() (*os.File, error) { + if t.rtcDevice == nil { + file, err := os.OpenFile(t.rtcDevicePath, os.O_RDWR, 0666) + if err != nil { + return nil, err + } + t.rtcDevice = file + } + return t.rtcDevice, nil +} + +func (t *TimeSync) getRtcDeviceFd() (int, error) { + device, err := t.getRtcDevice() + if err != nil { + return 0, err + } + return int(device.Fd()), nil +} + +// Read implements Read for the Linux RTC +func (t *TimeSync) readRtcTime() (time.Time, error) { + fd, err := t.getRtcDeviceFd() + if err != nil { + return time.Time{}, fmt.Errorf("failed to get RTC device fd: %w", err) + } + + rtcTime, err := unix.IoctlGetRTCTime(fd) + if err != nil { + return time.Time{}, fmt.Errorf("failed to get RTC time: %w", err) + } + + date := RtcTimetoTime(*rtcTime) + + return date, nil +} + +// Set implements Set for the Linux RTC +// ... +// It might be not accurate as the time consumed by the system call is not taken into account +// but it's good enough for our purposes +func (t *TimeSync) setRtcTime(tu time.Time) error { + rt := TimetoRtcTime(tu) + + fd, err := t.getRtcDeviceFd() + if err != nil { + return fmt.Errorf("failed to get RTC device fd: %w", err) + } + + currentRtcTime, err := t.readRtcTime() + if err != nil { + return fmt.Errorf("failed to read RTC time: %w", err) + } + + t.l.Info(). + Interface("rtc_time", tu). + Str("offset", tu.Sub(currentRtcTime).String()). + Msg("set rtc time") + + if err := unix.IoctlSetRTCTime(fd, &rt); err != nil { + return fmt.Errorf("failed to set RTC time: %w", err) + } + + return nil +} diff --git a/internal/timesync/rtc_notlinux.go b/internal/timesync/rtc_notlinux.go new file mode 100644 index 000000000..e3c1b20ce --- /dev/null +++ b/internal/timesync/rtc_notlinux.go @@ -0,0 +1,16 @@ +//go:build !linux + +package timesync + +import ( + "errors" + "time" +) + +func (t *TimeSync) readRtcTime() (time.Time, error) { + return time.Now(), nil +} + +func (t *TimeSync) setRtcTime(tu time.Time) error { + return errors.New("not supported") +} diff --git a/internal/timesync/timesync.go b/internal/timesync/timesync.go new file mode 100644 index 000000000..eac474941 --- /dev/null +++ b/internal/timesync/timesync.go @@ -0,0 +1,151 @@ +package timesync + +import ( + "fmt" + "os" + "os/exec" + "sync" + "time" + + "github.com/rs/zerolog" +) + +const ( + timeSyncRetryStep = 5 * time.Second + timeSyncRetryMaxInt = 1 * time.Minute + timeSyncWaitNetChkInt = 100 * time.Millisecond + timeSyncWaitNetUpInt = 3 * time.Second + timeSyncInterval = 1 * time.Hour + timeSyncTimeout = 2 * time.Second +) + +var ( + timeSyncRetryInterval = 0 * time.Second + defaultNTPServers = []string{ + "time.cloudflare.com", + "time.apple.com", + } +) + +type TimeSync struct { + syncLock *sync.Mutex + l *zerolog.Logger + + ntpServers []string + httpUrls []string + + rtcDevicePath string + rtcDevice *os.File + rtcLock *sync.Mutex + + syncSuccess bool + + preCheckFunc func() (bool, error) +} + +func NewTimeSync( + precheckFunc func() (bool, error), + ntpServers []string, + httpUrls []string, + logger *zerolog.Logger, +) *TimeSync { + rtcDevice, err := getRtcDevicePath() + if err != nil { + logger.Error().Err(err).Msg("failed to get RTC device path") + } else { + logger.Info().Str("path", rtcDevice).Msg("RTC device found") + } + + t := &TimeSync{ + syncLock: &sync.Mutex{}, + l: logger, + rtcDevicePath: rtcDevice, + rtcLock: &sync.Mutex{}, + preCheckFunc: precheckFunc, + ntpServers: ntpServers, + httpUrls: httpUrls, + } + + if t.rtcDevicePath != "" { + rtcTime, _ := t.readRtcTime() + t.l.Info().Interface("rtc_time", rtcTime).Msg("read RTC time") + } + + return t +} + +func (t *TimeSync) doTimeSync() { + for { + if ok, err := t.preCheckFunc(); !ok { + if err != nil { + t.l.Error().Err(err).Msg("pre-check failed") + } + time.Sleep(timeSyncWaitNetChkInt) + continue + } + + t.l.Info().Msg("syncing system time") + start := time.Now() + err := t.Sync() + if err != nil { + t.l.Error().Str("error", err.Error()).Msg("failed to sync system time") + + // retry after a delay + timeSyncRetryInterval += timeSyncRetryStep + time.Sleep(timeSyncRetryInterval) + // reset the retry interval if it exceeds the max interval + if timeSyncRetryInterval > timeSyncRetryMaxInt { + timeSyncRetryInterval = 0 + } + + continue + } + t.syncSuccess = true + t.l.Info().Str("now", time.Now().Format(time.RFC3339)). + Str("time_taken", time.Since(start).String()). + Msg("time sync successful") + + time.Sleep(timeSyncInterval) // after the first sync is done + } +} + +func (t *TimeSync) Sync() error { + var now *time.Time + now = t.queryNetworkTime() + if now == nil { + now = t.queryAllHttpTime() + } + + if now == nil { + return fmt.Errorf("failed to get time from any source") + } + + err := t.setSystemTime(*now) + if err != nil { + return fmt.Errorf("failed to set system time: %w", err) + } + + return nil +} + +func (t *TimeSync) IsSyncSuccess() bool { + return t.syncSuccess +} + +func (t *TimeSync) Start() { + go t.doTimeSync() +} + +func (t *TimeSync) setSystemTime(now time.Time) error { + nowStr := now.Format("2006-01-02 15:04:05") + output, err := exec.Command("date", "-s", nowStr).CombinedOutput() + if err != nil { + return fmt.Errorf("failed to run date -s: %w, %s", err, string(output)) + } + + if t.rtcDevicePath != "" { + return t.setRtcTime(now) + } + + return nil +} diff --git a/internal/udhcpc/options.go b/internal/udhcpc/options.go new file mode 100644 index 000000000..10c9f75b8 --- /dev/null +++ b/internal/udhcpc/options.go @@ -0,0 +1,12 @@ +package udhcpc + +func (u *DHCPClient) GetNtpServers() []string { + if u.lease == nil { + return nil + } + servers := make([]string, len(u.lease.NTPServers)) + for i, server := range u.lease.NTPServers { + servers[i] = server.String() + } + return servers +} diff --git a/internal/udhcpc/parser.go b/internal/udhcpc/parser.go new file mode 100644 index 000000000..ee529aa19 --- /dev/null +++ b/internal/udhcpc/parser.go @@ -0,0 +1,150 @@ +package udhcpc + +import ( + "encoding/json" + "fmt" + "log" + "net" + "reflect" + "strconv" + "strings" + "time" +) + +type Lease struct { + // from https://udhcp.busybox.net/README.udhcpc + IPAddress net.IP `env:"ip" json:"ip"` // The obtained IP + Netmask net.IP `env:"subnet" json:"netmask"` // The assigned subnet mask + Broadcast net.IP `env:"broadcast" json:"broadcast"` // The broadcast address for this network + TTL int `env:"ipttl" json:"ttl,omitempty"` // The TTL to use for this network + MTU int `env:"mtu" json:"mtu,omitempty"` // The MTU to use for this network + HostName string `env:"hostname" json:"hostname,omitempty"` // The assigned hostname + Domain string `env:"domain" json:"domain,omitempty"` // The domain name of the network + BootPNextServer net.IP `env:"siaddr" json:"bootp_next_server,omitempty"` // The bootp next server option + BootPServerName string `env:"sname" json:"bootp_server_name,omitempty"` // The bootp server name option + BootPFile string `env:"boot_file" json:"bootp_file,omitempty"` // The bootp boot file option + Timezone string `env:"timezone" json:"timezone,omitempty"` // Offset in seconds from UTC + Routers []net.IP `env:"router" json:"routers,omitempty"` // A list of routers + DNS []net.IP `env:"dns" json:"dns_servers,omitempty"` // A list of DNS servers + NTPServers []net.IP `env:"ntpsrv" json:"ntp_servers,omitempty"` // A list of NTP servers + LPRServers []net.IP `env:"lprsvr" json:"lpr_servers,omitempty"` // A list of LPR servers + TimeServers []net.IP `env:"timesvr" json:"_time_servers,omitempty"` // A list of time servers (obsolete) + IEN116NameServers []net.IP `env:"namesvr" json:"_name_servers,omitempty"` // A list of IEN 116 name servers (obsolete) + LogServers []net.IP `env:"logsvr" json:"_log_servers,omitempty"` // A list of MIT-LCS UDP log servers (obsolete) + CookieServers []net.IP `env:"cookiesvr" json:"_cookie_servers,omitempty"` // A list of RFC 865 cookie servers (obsolete) + WINSServers []net.IP `env:"wins" json:"_wins_servers,omitempty"` // A list of WINS servers + SwapServer net.IP `env:"swapsvr" json:"_swap_server,omitempty"` // The IP address of the client's swap server + BootSize int `env:"bootsize" json:"bootsize,omitempty"` // The length in 512 octect blocks of the bootfile + RootPath string `env:"rootpath" json:"root_path,omitempty"` // The path name of the client's root disk + LeaseTime time.Duration `env:"lease" json:"lease,omitempty"` // The lease time, in seconds + DHCPType string `env:"dhcptype" json:"dhcp_type,omitempty"` // DHCP message type (safely ignored) + ServerID string `env:"serverid" json:"server_id,omitempty"` // The IP of the server + Message string `env:"message" json:"reason,omitempty"` // Reason for a DHCPNAK + TFTPServerName string `env:"tftp" json:"tftp,omitempty"` // The TFTP server name + BootFileName string `env:"bootfile" json:"bootfile,omitempty"` // The boot file name + isEmpty map[string]bool +} + +func (l *Lease) setIsEmpty(m map[string]bool) { + l.isEmpty = m +} + +func (l *Lease) IsEmpty(key string) bool { + return l.isEmpty[key] +} + +func (l *Lease) ToJSON() string { + json, err := json.Marshal(l) + if err != nil { + return "" + } + return string(json) +} + +func UnmarshalDHCPCLease(lease *Lease, str string) error { + // parse the lease file as a map + data := make(map[string]string) + for _, line := range strings.Split(str, "\n") { + line = strings.TrimSpace(line) + // skip empty lines and comments + if line == "" || strings.HasPrefix(line, "#") { + continue + } + + parts := strings.SplitN(line, "=", 2) + if len(parts) != 2 { + log.Printf("invalid line: %s", line) + continue + } + + key := strings.TrimSpace(parts[0]) + value := strings.TrimSpace(parts[1]) + + data[key] = value + } + + // now iterate over the lease struct and set the values + leaseType := reflect.TypeOf(lease).Elem() + leaseValue := reflect.ValueOf(lease).Elem() + + valuesParsed := make(map[string]bool) + + for i := 0; i < leaseType.NumField(); i++ { + field := leaseValue.Field(i) + + // get the env tag + key := leaseType.Field(i).Tag.Get("env") + if key == "" { + continue + } + + valuesParsed[key] = false + + // get the value from the data map + value, ok := data[key] + if !ok || value == "" { + continue + } + + switch field.Interface().(type) { + case string: + field.SetString(value) + case int: + val, err := strconv.Atoi(value) + if err != nil { + continue + } + field.SetInt(int64(val)) + case time.Duration: + val, err := time.ParseDuration(value + "s") + if err != nil { + continue + } + field.Set(reflect.ValueOf(val)) + case net.IP: + ip := net.ParseIP(value) + if ip == nil { + continue + } + field.Set(reflect.ValueOf(ip)) + case []net.IP: + val := make([]net.IP, 0) + for _, ipStr := range strings.Fields(value) { + ip := net.ParseIP(ipStr) + if ip == nil { + continue + } + val = append(val, ip) + } + field.Set(reflect.ValueOf(val)) + default: + return fmt.Errorf("unsupported field `%s` type: %s", key, field.Type().String()) + } + + valuesParsed[key] = true + } + + lease.setIsEmpty(valuesParsed) + + return nil +} diff --git a/internal/udhcpc/parser_test.go b/internal/udhcpc/parser_test.go new file mode 100644 index 000000000..423ab5323 --- /dev/null +++ b/internal/udhcpc/parser_test.go @@ -0,0 +1,74 @@ +package udhcpc + +import ( + "testing" + "time" +) + +func TestUnmarshalDHCPCLease(t *testing.T) { + lease := &Lease{} + err := UnmarshalDHCPCLease(lease, ` +# generated @ Mon Jan 4 19:31:53 UTC 2021 +# 19:31:53 up 0 min, 0 users, load average: 0.72, 0.14, 0.04 +# the date might be inaccurate if the clock is not set +ip=192.168.0.240 +siaddr=192.168.0.1 +sname= +boot_file= +subnet=255.255.255.0 +timezone= +router=192.168.0.1 +timesvr= +namesvr= +dns=172.19.53.2 +logsvr= +cookiesvr= +lprsvr= +hostname= +bootsize= +domain= +swapsvr= +rootpath= +ipttl= +mtu= +broadcast= +ntpsrv=162.159.200.123 +wins= +lease=172800 +dhcptype= +serverid=192.168.0.1 +message= +tftp= +bootfile= + `) + if lease.IPAddress.String() != "192.168.0.240" { + t.Fatalf("expected ip to be 192.168.0.240, got %s", lease.IPAddress.String()) + } + if lease.Netmask.String() != "255.255.255.0" { + t.Fatalf("expected netmask to be 255.255.255.0, got %s", lease.Netmask.String()) + } + if len(lease.Routers) != 1 { + t.Fatalf("expected 1 router, got %d", len(lease.Routers)) + } + if lease.Routers[0].String() != "192.168.0.1" { + t.Fatalf("expected router to be 192.168.0.1, got %s", lease.Routers[0].String()) + } + if len(lease.NTPServers) != 1 { + t.Fatalf("expected 1 timeserver, got %d", len(lease.NTPServers)) + } + if lease.NTPServers[0].String() != "162.159.200.123" { + t.Fatalf("expected timeserver to be 162.159.200.123, got %s", lease.NTPServers[0].String()) + } + if len(lease.DNS) != 1 { + t.Fatalf("expected 1 dns, got %d", len(lease.DNS)) + } + if lease.DNS[0].String() != "172.19.53.2" { + t.Fatalf("expected dns to be 172.19.53.2, got %s", lease.DNS[0].String()) + } + if lease.LeaseTime != 172800*time.Second { + t.Fatalf("expected lease time to be 172800 seconds, got %d", lease.LeaseTime) + } + if err != nil { + t.Fatal(err) + } +} diff --git a/internal/udhcpc/proc.go b/internal/udhcpc/proc.go new file mode 100644 index 000000000..69c2ab99e --- /dev/null +++ b/internal/udhcpc/proc.go @@ -0,0 +1,212 @@ +package udhcpc + +import ( + "bytes" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "strconv" + "strings" + "syscall" +) + +func readFileNoStat(filename string) ([]byte, error) { + const maxBufferSize = 1024 * 1024 + + f, err := os.Open(filename) + if err != nil { + return nil, err + } + defer f.Close() + + reader := io.LimitReader(f, maxBufferSize) + return io.ReadAll(reader) +} + +func toCmdline(path string) ([]string, error) { + data, err := readFileNoStat(path) + if err != nil { + return nil, err + } + + if len(data) < 1 { + return []string{}, nil + } + + return strings.Split(string(bytes.TrimRight(data, "\x00")), "\x00"), nil +} + +func (p *DHCPClient) findUdhcpcProcess() (int, error) { + // read procfs for udhcpc processes + // we do not use procfs.AllProcs() because we want to avoid the overhead of reading the entire procfs + processes, err := os.ReadDir("/proc") + if err != nil { + return 0, err + } + + // iterate over the processes + for _, d := range processes { + // check if file is numeric + pid, err := strconv.Atoi(d.Name()) + if err != nil { + continue + } + + // check if it's a directory + if !d.IsDir() { + continue + } + + cmdline, err := toCmdline(filepath.Join("/proc", d.Name(), "cmdline")) + if err != nil { + continue + } + + if len(cmdline) < 1 { + continue + } + + if cmdline[0] != "udhcpc" { + continue + } + + cmdlineText := strings.Join(cmdline, " ") + + // check if it's a udhcpc process + if strings.Contains(cmdlineText, fmt.Sprintf("-i %s", p.InterfaceName)) { + p.logger.Debug(). + Str("pid", d.Name()). + Interface("cmdline", cmdline). + Msg("found udhcpc process") + return pid, nil + } + } + + return 0, errors.New("udhcpc process not found") +} + +func (c *DHCPClient) getProcessPid() (int, error) { + var pid int + if c.pidFile != "" { + // try to read the pid file + pidHandle, err := os.ReadFile(c.pidFile) + if err != nil { + c.logger.Warn().Err(err). + Str("pidFile", c.pidFile).Msg("failed to read udhcpc pid file") + } + + // if it exists, try to read the pid + if pidHandle != nil { + pidFromFile, err := strconv.Atoi(string(pidHandle)) + if err != nil { + c.logger.Warn().Err(err). + Str("pidFile", c.pidFile).Msg("failed to convert pid file to int") + } + pid = pidFromFile + } + } + + // if the pid is 0, try to find the pid using procfs + if pid == 0 { + newPid, err := c.findUdhcpcProcess() + if err != nil { + return 0, err + } + pid = newPid + } + + return pid, nil +} + +func (c *DHCPClient) getProcess() *os.Process { + pid, err := c.getProcessPid() + if err != nil { + return nil + } + + process, err := os.FindProcess(pid) + if err != nil { + c.logger.Warn().Err(err). + Int("pid", pid).Msg("failed to find process") + return nil + } + + return process +} + +func (c *DHCPClient) GetProcess() *os.Process { + if c.process == nil { + process := c.getProcess() + if process == nil { + return nil + } + c.process = process + } + + err := c.process.Signal(syscall.Signal(0)) + if err != nil && errors.Is(err, os.ErrProcessDone) { + oldPid := c.process.Pid + + c.process = nil + c.process = c.getProcess() + if c.process == nil { + c.logger.Error().Msg("failed to find new udhcpc process") + return nil + } + c.logger.Warn(). + Int("oldPid", oldPid). + Int("newPid", c.process.Pid). + Msg("udhcpc process pid changed") + } else if err != nil { + c.logger.Warn().Err(err). + Int("pid", c.process.Pid).Msg("udhcpc process is not running") + } + + return c.process +} + +func (c *DHCPClient) KillProcess() error { + process := c.GetProcess() + if process == nil { + return nil + } + + return process.Kill() +} + +func (c *DHCPClient) ReleaseProcess() error { + process := c.GetProcess() + if process == nil { + return nil + } + + return process.Release() +} + +func (c *DHCPClient) signalProcess(sig syscall.Signal) error { + process := c.GetProcess() + if process == nil { + return nil + } + + s := process.Signal(sig) + if s != nil { + c.logger.Warn().Err(s). + Int("pid", process.Pid). + Str("signal", sig.String()). + Msg("failed to signal udhcpc process") + return s + } + + return nil +} + +func (c *DHCPClient) Renew() error { + return c.signalProcess(syscall.SIGUSR1) +} + +func (c *DHCPClient) Release() error { + return c.signalProcess(syscall.SIGUSR2) +} diff --git a/internal/udhcpc/udhcpc.go b/internal/udhcpc/udhcpc.go new file mode 100644 index 000000000..1459b4091 --- /dev/null +++ b/internal/udhcpc/udhcpc.go @@ -0,0 +1,145 @@ +package udhcpc + +import ( + "errors" + "fmt" + "os" + + "github.com/fsnotify/fsnotify" + "github.com/rs/zerolog" +) + +const ( + DHCPLeaseFile = "/run/udhcpc.%s.info" + DHCPPidFile = "/run/udhcpc.%s.pid" +) + +type DHCPClient struct { + InterfaceName string + leaseFile string + pidFile string + lease *Lease + logger *zerolog.Logger + process *os.Process + onLeaseChange func(lease *Lease) +} + +type DHCPClientOptions struct { + InterfaceName string + PidFile string + Logger *zerolog.Logger + OnLeaseChange func(lease *Lease) +} + +var defaultLogger = zerolog.New(os.Stdout).Level(zerolog.InfoLevel) + +func NewDHCPClient(options *DHCPClientOptions) *DHCPClient { + if options.Logger == nil { + options.Logger = &defaultLogger + } + + l := options.Logger.With().Str("interface", options.InterfaceName).Logger() + return &DHCPClient{ + InterfaceName: options.InterfaceName, + logger: &l, + leaseFile: fmt.Sprintf(DHCPLeaseFile, options.InterfaceName), + pidFile: options.PidFile, + onLeaseChange: options.OnLeaseChange, + } +} + +// Run starts the DHCP client and watches the lease file for changes. +// this isn't a blocking call, and the lease file is reloaded when a change is detected. +func (c *DHCPClient) Run() error { + err := c.loadLeaseFile() + if err != nil && !errors.Is(err, os.ErrNotExist) { + return err + } + + watcher, err := fsnotify.NewWatcher() + if err != nil { + return err + } + defer watcher.Close() + + go func() { + for { + select { + case event, ok := <-watcher.Events: + if !ok { + return + } + if event.Has(fsnotify.Write) || event.Has(fsnotify.Create) { + c.logger.Debug(). + Str("event", event.Name). + Msg("udhcpc lease file updated, reloading lease") + c.loadLeaseFile() + } + case err, ok := <-watcher.Errors: + if !ok { + return + } + c.logger.Error().Err(err).Msg("error watching lease file") + } + } + }() + + watcher.Add(c.leaseFile) + + // TODO: update udhcpc pid file + // we'll comment this out for now because the pid might change + // process := c.GetProcess() + // if process == nil { + // c.logger.Error().Msg("udhcpc process not found") + // } + + // block the goroutine until the lease file is updated + <-make(chan struct{}) + + return nil +} + +func (c *DHCPClient) loadLeaseFile() error { + file, err := os.ReadFile(c.leaseFile) + if err != nil { + return err + } + + data := string(file) + if data == "" { + c.logger.Debug().Msg("udhcpc lease file is empty") + return nil + } + + lease := &Lease{} + err = UnmarshalDHCPCLease(lease, string(file)) + if err != nil { + return err + } + + isFirstLoad := c.lease == nil + c.lease = lease + + if lease.IPAddress == nil { + c.logger.Info(). + Interface("lease", lease). + Str("data", string(file)). + Msg("udhcpc lease cleared") + return nil + } + + msg := "udhcpc lease updated" + if isFirstLoad { + msg = "udhcpc lease loaded" + } + + c.onLeaseChange(lease) + + c.logger.Info(). + Str("ip", lease.IPAddress.String()). + Str("leaseTime", lease.LeaseTime.String()). + Interface("data", lease). + Msg(msg) + + return nil +} diff --git a/log.go b/log.go index ed4685299..8bc24d9c7 100644 --- a/log.go +++ b/log.go @@ -218,12 +218,13 @@ func ErrorfL(l *zerolog.Logger, format string, err error, args ...interface{}) e var ( logger = rootLogger.getLogger("jetkvm") + networkLogger = rootLogger.getLogger("network") cloudLogger = rootLogger.getLogger("cloud") websocketLogger = rootLogger.getLogger("websocket") webrtcLogger = rootLogger.getLogger("webrtc") nativeLogger = rootLogger.getLogger("native") nbdLogger = rootLogger.getLogger("nbd") - ntpLogger = rootLogger.getLogger("ntp") + timesyncLogger = rootLogger.getLogger("timesync") jsonRpcLogger = rootLogger.getLogger("jsonrpc") watchdogLogger = rootLogger.getLogger("watchdog") websecureLogger = rootLogger.getLogger("websecure") diff --git a/main.go b/main.go index 9eab708ab..73a4702d7 100644 --- a/main.go +++ b/main.go @@ -15,26 +15,38 @@ var appCtx context.Context func Main() { LoadConfig() - logger.Debug().Msg("config loaded") var cancel context.CancelFunc appCtx, cancel = context.WithCancel(context.Background()) defer cancel() - logger.Info().Msg("starting JetKvm") + + systemVersionLocal, appVersionLocal, err := GetLocalVersion() + if err != nil { + logger.Warn().Err(err).Msg("failed to get local version") + } + + logger.Info(). + Interface("system_version", systemVersionLocal). + Interface("app_version", appVersionLocal). + Msg("starting JetKVM") go runWatchdog() go confirmCurrentSystem() http.DefaultClient.Timeout = 1 * time.Minute - err := rootcerts.UpdateDefaultTransport() + err = rootcerts.UpdateDefaultTransport() if err != nil { - logger.Warn().Err(err).Msg("failed to load CA certs") + logger.Warn().Err(err).Msg("failed to load Root CA certificates") } + logger.Info(). + Int("ca_certs_loaded", len(rootcerts.Certs())). + Msg("loaded Root CA certificates") initNetwork() + initTimeSync() - go TimeSyncLoop() + timeSync.Start() StartNativeCtrlSocketServer() StartNativeVideoSocketServer() diff --git a/mdns.go b/mdns.go new file mode 100644 index 000000000..309709e24 --- /dev/null +++ b/mdns.go @@ -0,0 +1,60 @@ +package kvm + +import ( + "net" + + "github.com/pion/mdns/v2" + "golang.org/x/net/ipv4" + "golang.org/x/net/ipv6" +) + +var mDNSConn *mdns.Conn + +func startMDNS() error { + // If server was previously running, stop it + if mDNSConn != nil { + logger.Info().Msg("stopping mDNS server") + err := mDNSConn.Close() + if err != nil { + logger.Warn().Err(err).Msg("failed to stop mDNS server") + } + } + + // Start a new server + hostname := "jetkvm.local" + + scopedLogger := logger.With().Str("hostname", hostname).Logger() + scopedLogger.Info().Msg("starting mDNS server") + + addr4, err := net.ResolveUDPAddr("udp4", mdns.DefaultAddressIPv4) + if err != nil { + return err + } + + addr6, err := net.ResolveUDPAddr("udp6", mdns.DefaultAddressIPv6) + if err != nil { + return err + } + + l4, err := net.ListenUDP("udp4", addr4) + if err != nil { + return err + } + + l6, err := net.ListenUDP("udp6", addr6) + if err != nil { + return err + } + + mDNSConn, err = mdns.Server(ipv4.NewPacketConn(l4), ipv6.NewPacketConn(l6), &mdns.Config{ + LocalNames: []string{hostname}, //TODO: make it configurable + LoggerFactory: defaultLoggerFactory, + }) + if err != nil { + scopedLogger.Warn().Err(err).Msg("failed to start mDNS server") + mDNSConn = nil + return err + } + //defer server.Close() + return nil +} diff --git a/network.go b/network.go index e524e7210..14ffe7d73 100644 --- a/network.go +++ b/network.go @@ -1,214 +1,308 @@ package kvm import ( - "bytes" "fmt" "net" "os" - "strings" + "sync" "time" - "os/exec" - - "github.com/hashicorp/go-envparse" - "github.com/pion/mdns/v2" - "golang.org/x/net/ipv4" - "golang.org/x/net/ipv6" + "github.com/Masterminds/semver/v3" + "github.com/jetkvm/kvm/internal/udhcpc" + "github.com/rs/zerolog" "github.com/vishvananda/netlink" "github.com/vishvananda/netlink/nl" ) -var mDNSConn *mdns.Conn +var ( + networkState *NetworkInterfaceState +) + +type DhcpTargetState int + +const ( + DhcpTargetStateDoNothing DhcpTargetState = iota + DhcpTargetStateStart + DhcpTargetStateStop + DhcpTargetStateRenew + DhcpTargetStateRelease +) + +type NetworkInterfaceState struct { + interfaceName string + interfaceUp bool + ipv4Addr *net.IP + ipv6Addr *net.IP + macAddr *net.HardwareAddr + + l *zerolog.Logger + stateLock sync.Mutex -var networkState NetworkState + dhcpClient *udhcpc.DHCPClient -type NetworkState struct { - Up bool - IPv4 string - IPv6 string - MAC string + onStateChange func(state *NetworkInterfaceState) + onInitialCheck func(state *NetworkInterfaceState) checked bool } -func (s *NetworkState) IsUp() bool { - return s.Up && s.IPv4 != "" && s.IPv6 != "" +func (s *NetworkInterfaceState) IsUp() bool { + return s.interfaceUp } -func (s *NetworkState) HasIPAssigned() bool { - return s.IPv4 != "" || s.IPv6 != "" +func (s *NetworkInterfaceState) HasIPAssigned() bool { + return s.ipv4Addr != nil || s.ipv6Addr != nil } -func (s *NetworkState) IsOnline() bool { - return s.Up && s.HasIPAssigned() +func (s *NetworkInterfaceState) IsOnline() bool { + return s.IsUp() && s.HasIPAssigned() } -type LocalIpInfo struct { - IPv4 string - IPv6 string - MAC string +func (s *NetworkInterfaceState) IPv4() *net.IP { + return s.ipv4Addr +} + +func (s *NetworkInterfaceState) IPv4String() string { + if s.ipv4Addr == nil { + return "..." + } + return s.ipv4Addr.String() +} + +func (s *NetworkInterfaceState) IPv6() *net.IP { + return s.ipv6Addr +} + +func (s *NetworkInterfaceState) IPv6String() string { + if s.ipv6Addr == nil { + return "..." + } + return s.ipv6Addr.String() +} + +func (s *NetworkInterfaceState) MAC() *net.HardwareAddr { + return s.macAddr +} + +func (s *NetworkInterfaceState) MACString() string { + if s.macAddr == nil { + return "" + } + return s.macAddr.String() } const ( - NetIfName = "eth0" - DHCPLeaseFile = "/run/udhcpc.%s.info" + // TODO: add support for multiple interfaces + NetIfName = "eth0" ) -// setDhcpClientState sends signals to udhcpc to change it's current mode -// of operation. Setting active to true will force udhcpc to renew the DHCP lease. -// Setting active to false will put udhcpc into idle mode. -func setDhcpClientState(active bool) { - var signal string - if active { - signal = "-SIGUSR1" - } else { - signal = "-SIGUSR2" +func NewNetworkInterfaceState(ifname string) *NetworkInterfaceState { + logger := networkLogger.With().Str("interface", ifname).Logger() + + s := &NetworkInterfaceState{ + interfaceName: ifname, + stateLock: sync.Mutex{}, + l: &logger, + onStateChange: func(state *NetworkInterfaceState) { + go func() { + waitCtrlClientConnected() + requestDisplayUpdate() + }() + }, + onInitialCheck: func(state *NetworkInterfaceState) { + go func() { + waitCtrlClientConnected() + requestDisplayUpdate() + }() + }, } - cmd := exec.Command("/usr/bin/killall", signal, "udhcpc") - if err := cmd.Run(); err != nil { - logger.Warn().Err(err).Msg("network: setDhcpClientState: failed to change udhcpc state") + // use a pid file for udhcpc if the system version is 0.2.4 or higher + dhcpPidFile := "" + systemVersionLocal, _, _ := GetLocalVersion() + if systemVersionLocal != nil && + systemVersionLocal.Compare(semver.MustParse("0.2.4")) >= 0 { + dhcpPidFile = fmt.Sprintf("/run/udhcpc.%s.pid", ifname) } + + // create the dhcp client + dhcpClient := udhcpc.NewDHCPClient(&udhcpc.DHCPClientOptions{ + InterfaceName: ifname, + PidFile: dhcpPidFile, + Logger: &logger, + OnLeaseChange: func(lease *udhcpc.Lease) { + s.update() + }, + }) + + s.dhcpClient = dhcpClient + + return s } -func checkNetworkState() { - iface, err := netlink.LinkByName(NetIfName) +func (s *NetworkInterfaceState) update() (DhcpTargetState, error) { + s.stateLock.Lock() + defer s.stateLock.Unlock() + + dhcpTargetState := DhcpTargetStateDoNothing + + iface, err := netlink.LinkByName(s.interfaceName) if err != nil { - logger.Warn().Err(err).Str("interface", NetIfName).Msg("failed to get interface") - return + s.l.Error().Err(err).Msg("failed to get interface") + return dhcpTargetState, err } - newState := NetworkState{ - Up: iface.Attrs().OperState == netlink.OperUp, - MAC: iface.Attrs().HardwareAddr.String(), + // detect if the interface status changed + var changed bool + attrs := iface.Attrs() + state := attrs.OperState + newInterfaceUp := state == netlink.OperUp - checked: true, + // check if the interface is coming up + interfaceGoingUp := s.interfaceUp == false && newInterfaceUp == true + interfaceGoingDown := s.interfaceUp == true && newInterfaceUp == false + + if s.interfaceUp != newInterfaceUp { + s.interfaceUp = newInterfaceUp + changed = true } + if changed { + if interfaceGoingUp { + s.l.Info().Msg("interface state transitioned to up") + dhcpTargetState = DhcpTargetStateRenew + } else if interfaceGoingDown { + s.l.Info().Msg("interface state transitioned to down") + } + } + + // set the mac address + s.macAddr = &attrs.HardwareAddr + + // get the ip addresses addrs, err := netlink.AddrList(iface, nl.FAMILY_ALL) if err != nil { - logger.Warn().Err(err).Str("interface", NetIfName).Msg("failed to get addresses") + s.l.Error().Err(err).Msg("failed to get ip addresses") + return dhcpTargetState, err } - // If the link is going down, put udhcpc into idle mode. - // If the link is coming back up, activate udhcpc and force it to renew the lease. - if newState.Up != networkState.Up { - setDhcpClientState(newState.Up) - } + var ( + ipv4Addresses = make([]net.IP, 0) + ipv6Addresses = make([]net.IP, 0) + ) for _, addr := range addrs { if addr.IP.To4() != nil { - if !newState.Up && networkState.Up { - // If the network is going down, remove all IPv4 addresses from the interface. - logger.Info().Str("address", addr.IP.String()).Msg("network: state transitioned to down, removing IPv4 address") + scopedLogger := s.l.With().Str("ipv4", addr.IP.String()).Logger() + if interfaceGoingDown { + // remove all IPv4 addresses from the interface. + scopedLogger.Info().Msg("state transitioned to down, removing IPv4 address") err := netlink.AddrDel(iface, &addr) if err != nil { - logger.Warn().Err(err).Str("address", addr.IP.String()).Msg("network: failed to delete address") + scopedLogger.Warn().Err(err).Msg("failed to delete address") } + // notify the DHCP client to release the lease + dhcpTargetState = DhcpTargetStateRelease + continue + } + ipv4Addresses = append(ipv4Addresses, addr.IP) + } else if addr.IP.To16() != nil { + scopedLogger := s.l.With().Str("ipv6", addr.IP.String()).Logger() + // check if it's a link local address + if !addr.IP.IsGlobalUnicast() { + scopedLogger.Trace().Msg("not a global unicast address, skipping") + continue + } - newState.IPv4 = "..." - } else { - newState.IPv4 = addr.IP.String() + if interfaceGoingDown { + scopedLogger.Info().Msg("state transitioned to down, removing IPv6 address") + err := netlink.AddrDel(iface, &addr) + if err != nil { + scopedLogger.Warn().Err(err).Msg("failed to delete address") + } + continue } - } else if addr.IP.To16() != nil && newState.IPv6 == "" { - newState.IPv6 = addr.IP.String() + ipv6Addresses = append(ipv6Addresses, addr.IP) } } - if newState != networkState { - logger.Info(). - Interface("newState", newState). - Interface("oldState", networkState). - Msg("network state changed") - - // restart MDNS - _ = startMDNS() - networkState = newState - requestDisplayUpdate() - } -} - -func startMDNS() error { - // If server was previously running, stop it - if mDNSConn != nil { - logger.Info().Msg("stopping mDNS server") - err := mDNSConn.Close() - if err != nil { - logger.Warn().Err(err).Msg("failed to stop mDNS server") + if len(ipv4Addresses) > 0 { + // compare the addresses to see if there's a change + if s.ipv4Addr == nil || s.ipv4Addr.String() != ipv4Addresses[0].String() { + scopedLogger := s.l.With().Str("ipv4", ipv4Addresses[0].String()).Logger() + if s.ipv4Addr != nil { + scopedLogger.Info(). + Str("old_ipv4", s.ipv4Addr.String()). + Msg("IPv4 address changed") + changed = true + } else { + scopedLogger.Info().Msg("IPv4 address found") + } + s.ipv4Addr = &ipv4Addresses[0] + changed = true } } - // Start a new server - hostname := "jetkvm.local" - - scopedLogger := logger.With().Str("hostname", hostname).Logger() - scopedLogger.Info().Msg("starting mDNS server") - - addr4, err := net.ResolveUDPAddr("udp4", mdns.DefaultAddressIPv4) - if err != nil { - return err - } - - addr6, err := net.ResolveUDPAddr("udp6", mdns.DefaultAddressIPv6) - if err != nil { - return err + if len(ipv6Addresses) > 0 { + // compare the addresses to see if there's a change + if s.ipv6Addr == nil || s.ipv6Addr.String() != ipv6Addresses[0].String() { + scopedLogger := s.l.With().Str("ipv6", ipv6Addresses[0].String()).Logger() + if s.ipv6Addr != nil { + scopedLogger.Info(). + Str("old_ipv6", s.ipv6Addr.String()). + Msg("IPv6 address changed") + } else { + scopedLogger.Info().Msg("IPv6 address found") + } + s.ipv6Addr = &ipv6Addresses[0] + changed = true + } } - l4, err := net.ListenUDP("udp4", addr4) - if err != nil { - return err + // if it's the initial check, we'll set changed to false + initialCheck := !s.checked + if initialCheck { + s.checked = true + changed = false } - l6, err := net.ListenUDP("udp6", addr6) - if err != nil { - return err + if initialCheck { + s.onInitialCheck(s) + } else if changed { + s.onStateChange(s) } - mDNSConn, err = mdns.Server(ipv4.NewPacketConn(l4), ipv6.NewPacketConn(l6), &mdns.Config{ - LocalNames: []string{hostname}, //TODO: make it configurable - LoggerFactory: defaultLoggerFactory, - }) - if err != nil { - scopedLogger.Warn().Err(err).Msg("failed to start mDNS server") - mDNSConn = nil - return err - } - //defer server.Close() - return nil + return dhcpTargetState, nil } -func getNTPServersFromDHCPInfo() ([]string, error) { - buf, err := os.ReadFile(fmt.Sprintf(DHCPLeaseFile, NetIfName)) - if err != nil { - // do not return error if file does not exist - if os.IsNotExist(err) { - return nil, nil - } - return nil, fmt.Errorf("failed to load udhcpc info: %w", err) - } - - // parse udhcpc info - env, err := envparse.Parse(bytes.NewReader(buf)) +func (s *NetworkInterfaceState) CheckAndUpdateDhcp() error { + dhcpTargetState, err := s.update() if err != nil { - return nil, fmt.Errorf("failed to parse udhcpc info: %w", err) + return ErrorfL(s.l, "failed to update network state", err) } - val, ok := env["ntpsrv"] - if !ok { - return nil, nil + switch dhcpTargetState { + case DhcpTargetStateRenew: + s.l.Info().Msg("renewing DHCP lease") + s.dhcpClient.Renew() + case DhcpTargetStateRelease: + s.l.Info().Msg("releasing DHCP lease") + s.dhcpClient.Release() + case DhcpTargetStateStart: + s.l.Warn().Msg("dhcpTargetStateStart not implemented") + case DhcpTargetStateStop: + s.l.Warn().Msg("dhcpTargetStateStop not implemented") } - var servers []string + return nil +} - for _, server := range strings.Fields(val) { - if net.ParseIP(server) == nil { - logger.Info().Str("server", server).Msg("invalid NTP server IP, ignoring") - } - servers = append(servers, server) +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() } - - return servers, nil } func initNetwork() { @@ -218,25 +312,29 @@ func initNetwork() { done := make(chan struct{}) if err := netlink.LinkSubscribe(updates, done); err != nil { - logger.Warn().Err(err).Msg("failed to subscribe to link updates") + networkLogger.Warn().Err(err).Msg("failed to subscribe to link updates") return } + // TODO: support multiple interfaces + networkState = NewNetworkInterfaceState(NetIfName) + go networkState.dhcpClient.Run() + + if err := networkState.CheckAndUpdateDhcp(); err != nil { + os.Exit(1) + } + go func() { waitCtrlClientConnected() - checkNetworkState() ticker := time.NewTicker(1 * time.Second) defer ticker.Stop() for { select { case update := <-updates: - if update.Link.Attrs().Name == NetIfName { - logger.Info().Interface("update", update).Msg("link update") - checkNetworkState() - } + networkState.HandleLinkUpdate(update) case <-ticker.C: - checkNetworkState() + _ = networkState.CheckAndUpdateDhcp() case <-done: return } diff --git a/ntp.go b/ntp.go deleted file mode 100644 index 481c1410d..000000000 --- a/ntp.go +++ /dev/null @@ -1,214 +0,0 @@ -package kvm - -import ( - "fmt" - "net/http" - "os/exec" - "strconv" - "time" - - "github.com/beevik/ntp" -) - -const ( - timeSyncRetryStep = 5 * time.Second - timeSyncRetryMaxInt = 1 * time.Minute - timeSyncWaitNetChkInt = 100 * time.Millisecond - timeSyncWaitNetUpInt = 3 * time.Second - timeSyncInterval = 1 * time.Hour - timeSyncTimeout = 2 * time.Second -) - -var ( - builtTimestamp string - timeSyncRetryInterval = 0 * time.Second - timeSyncSuccess = false - defaultNTPServers = []string{ - "time.cloudflare.com", - "time.apple.com", - } -) - -func isTimeSyncNeeded() bool { - if builtTimestamp == "" { - ntpLogger.Warn().Msg("built timestamp is not set, time sync is needed") - return true - } - - ts, err := strconv.Atoi(builtTimestamp) - if err != nil { - ntpLogger.Warn().Str("error", err.Error()).Msg("failed to parse built timestamp") - return true - } - - // builtTimestamp is UNIX timestamp in seconds - builtTime := time.Unix(int64(ts), 0) - now := time.Now() - - if now.Sub(builtTime) < 0 { - ntpLogger.Warn(). - Str("built_time", builtTime.Format(time.RFC3339)). - Str("now", now.Format(time.RFC3339)). - Msg("system time is behind the built time, time sync is needed") - return true - } - - return false -} - -func TimeSyncLoop() { - for { - if !networkState.checked { - time.Sleep(timeSyncWaitNetChkInt) - continue - } - - if !networkState.IsOnline() { - ntpLogger.Info().Msg("waiting for network to be online") - time.Sleep(timeSyncWaitNetUpInt) - continue - } - - // check if time sync is needed, but do nothing for now - isTimeSyncNeeded() - - ntpLogger.Info().Msg("syncing system time") - start := time.Now() - err := SyncSystemTime() - if err != nil { - ntpLogger.Error().Str("error", err.Error()).Msg("failed to sync system time") - - // retry after a delay - timeSyncRetryInterval += timeSyncRetryStep - time.Sleep(timeSyncRetryInterval) - // reset the retry interval if it exceeds the max interval - if timeSyncRetryInterval > timeSyncRetryMaxInt { - timeSyncRetryInterval = 0 - } - - continue - } - timeSyncSuccess = true - ntpLogger.Info().Str("now", time.Now().Format(time.RFC3339)). - Str("time_taken", time.Since(start).String()). - Msg("time sync successful") - time.Sleep(timeSyncInterval) // after the first sync is done - } -} - -func SyncSystemTime() (err error) { - now, err := queryNetworkTime() - if err != nil { - return fmt.Errorf("failed to query network time: %w", err) - } - err = setSystemTime(*now) - if err != nil { - return fmt.Errorf("failed to set system time: %w", err) - } - return nil -} - -func queryNetworkTime() (*time.Time, error) { - ntpServers, err := getNTPServersFromDHCPInfo() - if err != nil { - ntpLogger.Info().Err(err).Msg("failed to get NTP servers from DHCP info") - } - - if ntpServers == nil { - ntpServers = defaultNTPServers - ntpLogger.Info(). - Interface("ntp_servers", ntpServers). - Msg("using default NTP servers") - } else { - ntpLogger.Info(). - Interface("ntp_servers", ntpServers). - Msg("using NTP servers from DHCP") - } - - for _, server := range ntpServers { - now, err, response := queryNtpServer(server, timeSyncTimeout) - - scopedLogger := ntpLogger.With(). - Str("server", server). - Logger() - - if err == nil { - scopedLogger.Info(). - Str("time", now.Format(time.RFC3339)). - Str("reference", response.ReferenceString()). - Str("rtt", response.RTT.String()). - Str("clockOffset", response.ClockOffset.String()). - Uint8("stratum", response.Stratum). - Msg("NTP server returned time") - return now, nil - } else { - scopedLogger.Error(). - Str("error", err.Error()). - Msg("failed to query NTP server") - } - } - - httpUrls := []string{ - "http://apple.com", - "http://cloudflare.com", - } - for _, url := range httpUrls { - now, err, response := queryHttpTime(url, timeSyncTimeout) - - var status string - if response != nil { - status = response.Status - } - - scopedLogger := ntpLogger.With(). - Str("http_url", url). - Str("status", status). - Logger() - - if err == nil { - scopedLogger.Info(). - Str("time", now.Format(time.RFC3339)). - Msg("HTTP server returned time") - return now, nil - } else { - scopedLogger.Error(). - Str("error", err.Error()). - Msg("failed to query HTTP server") - } - } - - return nil, ErrorfL(ntpLogger, "failed to query network time, all NTP servers and HTTP servers failed", nil) -} - -func queryNtpServer(server string, timeout time.Duration) (now *time.Time, err error, response *ntp.Response) { - resp, err := ntp.QueryWithOptions(server, ntp.QueryOptions{Timeout: timeout}) - if err != nil { - return nil, err, nil - } - return &resp.Time, nil, resp -} - -func queryHttpTime(url string, timeout time.Duration) (now *time.Time, err error, response *http.Response) { - client := http.Client{ - Timeout: timeout, - } - resp, err := client.Head(url) - if err != nil { - return nil, err, nil - } - dateStr := resp.Header.Get("Date") - parsedTime, err := time.Parse(time.RFC1123, dateStr) - if err != nil { - return nil, err, resp - } - return &parsedTime, nil, resp -} - -func setSystemTime(now time.Time) error { - nowStr := now.Format("2006-01-02 15:04:05") - output, err := exec.Command("date", "-s", nowStr).CombinedOutput() - if err != nil { - return fmt.Errorf("failed to run date -s: %w, %s", err, string(output)) - } - return nil -} diff --git a/timesync.go b/timesync.go new file mode 100644 index 000000000..20306ec25 --- /dev/null +++ b/timesync.go @@ -0,0 +1,69 @@ +package kvm + +import ( + "strconv" + "time" + + "github.com/jetkvm/kvm/internal/timesync" +) + +const ( + timeSyncRetryStep = 5 * time.Second + timeSyncRetryMaxInt = 1 * time.Minute + timeSyncWaitNetChkInt = 100 * time.Millisecond + timeSyncWaitNetUpInt = 3 * time.Second +) + +var ( + timeSync *timesync.TimeSync + defaultNTPServers = []string{ + "time.cloudflare.com", + "time.apple.com", + } + defaultHTTPUrls = []string{ + "http://apple.com", + "http://cloudflare.com", + } + builtTimestamp string +) + +func isTimeSyncNeeded() bool { + if builtTimestamp == "" { + timesyncLogger.Warn().Msg("built timestamp is not set, time sync is needed") + return true + } + + ts, err := strconv.Atoi(builtTimestamp) + if err != nil { + timesyncLogger.Warn().Str("error", err.Error()).Msg("failed to parse built timestamp") + return true + } + + // builtTimestamp is UNIX timestamp in seconds + builtTime := time.Unix(int64(ts), 0) + now := time.Now() + + if now.Sub(builtTime) < 0 { + timesyncLogger.Warn(). + Str("built_time", builtTime.Format(time.RFC3339)). + Str("now", now.Format(time.RFC3339)). + Msg("system time is behind the built time, time sync is needed") + return true + } + + return false +} + +func initTimeSync() { + timeSync = timesync.NewTimeSync( + func() (bool, error) { + if !networkState.IsOnline() { + return false, nil + } + return true, nil + }, + defaultNTPServers, + defaultHTTPUrls, + timesyncLogger, + ) +} diff --git a/web_tls.go b/web_tls.go index 2989957ac..46ba60f7e 100644 --- a/web_tls.go +++ b/web_tls.go @@ -53,7 +53,7 @@ func initCertStore() { func getCertificate(info *tls.ClientHelloInfo) (*tls.Certificate, error) { if config.TLSMode == "self-signed" { - if isTimeSyncNeeded() || !timeSyncSuccess { + if isTimeSyncNeeded() || !timeSync.IsSyncSuccess() { return nil, fmt.Errorf("time is not synced") } return certSigner.GetCertificate(info) From edba5c94f58aa3d1006edc87632852f9e892baa6 Mon Sep 17 00:00:00 2001 From: Siyuan Miao Date: Sat, 12 Apr 2025 22:46:48 +0200 Subject: [PATCH 03/26] feat(display): show cloud connection status --- cloud.go | 16 +++++++++ display.go | 60 +++++++++++++++++++++++++++++++--- network.go | 5 --- resource/jetkvm_native | Bin 1545740 -> 1545772 bytes resource/jetkvm_native.sha256 | 2 +- web.go | 7 +++- 6 files changed, 78 insertions(+), 12 deletions(-) diff --git a/cloud.go b/cloud.go index f7bdb6e3b..3f46a42e4 100644 --- a/cloud.go +++ b/cloud.go @@ -140,10 +140,22 @@ var ( ) var ( + cloudConnectionAlive bool + cloudConnectionAliveLock = &sync.Mutex{} + cloudDisconnectChan chan error cloudDisconnectLock = &sync.Mutex{} ) +func setCloudConnectionAlive(alive bool) { + cloudConnectionAliveLock.Lock() + defer cloudConnectionAliveLock.Unlock() + + cloudConnectionAlive = alive + + go waitCtrlAndRequestDisplayUpdate() +} + func wsResetMetrics(established bool, sourceType string, source string) { metricConnectionLastPingTimestamp.WithLabelValues(sourceType, source).Set(-1) metricConnectionLastPingDuration.WithLabelValues(sourceType, source).Set(-1) @@ -307,6 +319,8 @@ func runWebsocketClient() error { metricConnectionTotalPingReceivedCount.WithLabelValues("cloud", wsURL.Host).Inc() metricConnectionLastPingReceivedTimestamp.WithLabelValues("cloud", wsURL.Host).SetToCurrentTime() + setCloudConnectionAlive(true) + return true }, }) @@ -336,6 +350,8 @@ func runWebsocketClient() error { if err != nil { if errors.Is(err, context.Canceled) { cloudLogger.Info().Msg("websocket connection canceled") + setCloudConnectionAlive(false) + return nil } return err diff --git a/display.go b/display.go index 7320cce8b..c64e75122 100644 --- a/display.go +++ b/display.go @@ -33,9 +33,37 @@ func switchToScreen(screen string) { var displayedTexts = make(map[string]string) +func lvObjSetState(objName string, state string) (*CtrlResponse, error) { + return CallCtrlAction("lv_obj_set_state", map[string]interface{}{"obj": objName, "state": state}) +} + +func lvObjAddFlag(objName string, flag string) (*CtrlResponse, error) { + return CallCtrlAction("lv_obj_add_flag", map[string]interface{}{"obj": objName, "flag": flag}) +} + +func lvObjClearFlag(objName string, flag string) (*CtrlResponse, error) { + return CallCtrlAction("lv_obj_clear_flag", map[string]interface{}{"obj": objName, "flag": flag}) +} + +func lvObjHide(objName string) (*CtrlResponse, error) { + return lvObjAddFlag(objName, "LV_OBJ_FLAG_HIDDEN") +} + +func lvObjShow(objName string) (*CtrlResponse, error) { + return lvObjClearFlag(objName, "LV_OBJ_FLAG_HIDDEN") +} + +func lvLabelSetText(objName string, text string) (*CtrlResponse, error) { + return CallCtrlAction("lv_label_set_text", map[string]interface{}{"obj": objName, "text": text}) +} + +func lvImgSetSrc(objName string, src string) (*CtrlResponse, error) { + return CallCtrlAction("lv_img_set_src", map[string]interface{}{"obj": objName, "src": src}) +} + func updateLabelIfChanged(objName string, newText string) { if newText != "" && newText != displayedTexts[objName] { - _, _ = CallCtrlAction("lv_label_set_text", map[string]interface{}{"obj": objName, "text": newText}) + _, _ = lvLabelSetText(objName, newText) displayedTexts[objName] = newText } } @@ -51,29 +79,43 @@ func updateDisplay() { updateLabelIfChanged("ui_Home_Content_Ip", networkState.IPv4String()) if usbState == "configured" { updateLabelIfChanged("ui_Home_Footer_Usb_Status_Label", "Connected") - _, _ = CallCtrlAction("lv_obj_set_state", map[string]interface{}{"obj": "ui_Home_Footer_Usb_Status_Label", "state": "LV_STATE_DEFAULT"}) + _, _ = lvObjSetState("ui_Home_Footer_Usb_Status_Label", "LV_STATE_DEFAULT") } else { updateLabelIfChanged("ui_Home_Footer_Usb_Status_Label", "Disconnected") - _, _ = CallCtrlAction("lv_obj_set_state", map[string]interface{}{"obj": "ui_Home_Footer_Usb_Status_Label", "state": "LV_STATE_USER_2"}) + _, _ = lvObjSetState("ui_Home_Footer_Usb_Status_Label", "LV_STATE_USER_2") } if lastVideoState.Ready { updateLabelIfChanged("ui_Home_Footer_Hdmi_Status_Label", "Connected") - _, _ = CallCtrlAction("lv_obj_set_state", map[string]interface{}{"obj": "ui_Home_Footer_Hdmi_Status_Label", "state": "LV_STATE_DEFAULT"}) + _, _ = lvObjSetState("ui_Home_Footer_Hdmi_Status_Label", "LV_STATE_DEFAULT") } else { updateLabelIfChanged("ui_Home_Footer_Hdmi_Status_Label", "Disconnected") - _, _ = CallCtrlAction("lv_obj_set_state", map[string]interface{}{"obj": "ui_Home_Footer_Hdmi_Status_Label", "state": "LV_STATE_USER_2"}) + _, _ = lvObjSetState("ui_Home_Footer_Hdmi_Status_Label", "LV_STATE_USER_2") } updateLabelIfChanged("ui_Home_Header_Cloud_Status_Label", fmt.Sprintf("%d active", actionSessions)) + if networkState.IsUp() { switchToScreenIfDifferent("ui_Home_Screen") } else { switchToScreenIfDifferent("ui_No_Network_Screen") } + + if config.CloudToken == "" || config.CloudURL == "" { + lvObjHide("ui_Home_Header_Cloud_Status_Icon") + } else { + lvObjShow("ui_Home_Header_Cloud_Status_Icon") + // TODO: blink the icon if establishing connection + if cloudConnectionAlive { + _, _ = lvImgSetSrc("ui_Home_Header_Cloud_Status_Icon", "cloud.png") + } else { + _, _ = lvImgSetSrc("ui_Home_Header_Cloud_Status_Icon", "cloud_disconnected.png") + } + } } var ( displayInited = false displayUpdateLock = sync.Mutex{} + waitDisplayUpdate = sync.Mutex{} ) func requestDisplayUpdate() { @@ -92,6 +134,14 @@ func requestDisplayUpdate() { }() } +func waitCtrlAndRequestDisplayUpdate() { + waitDisplayUpdate.Lock() + defer waitDisplayUpdate.Unlock() + + waitCtrlClientConnected() + requestDisplayUpdate() +} + func updateStaticContents() { //contents that never change updateLabelIfChanged("ui_Home_Content_Mac", networkState.MACString()) diff --git a/network.go b/network.go index 14ffe7d73..b2588c9c5 100644 --- a/network.go +++ b/network.go @@ -325,7 +325,6 @@ func initNetwork() { } go func() { - waitCtrlClientConnected() ticker := time.NewTicker(1 * time.Second) defer ticker.Stop() @@ -340,8 +339,4 @@ func initNetwork() { } } }() - err := startMDNS() - if err != nil { - logger.Warn().Err(err).Msg("failed to run mDNS") - } } diff --git a/resource/jetkvm_native b/resource/jetkvm_native index 0d0719c796e876a7b4277cacc1fbac0772cf10e7..5b169d826f2dada81457fef9455d2861f30d65de 100644 GIT binary patch delta 216642 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

YjU)Hp>p-0?5odehpu$;EOQdu2#>rpz%FQ8V#F< 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 Date: Sat, 12 Apr 2025 22:54:45 +0200 Subject: [PATCH 04/26] chore: change logging verbosity --- cloud.go | 36 ++++++++++---- dev_deploy.sh | 2 +- display.go | 87 ++++++++++++++++++++++++++++++---- native.go | 5 +- resource/jetkvm_native | Bin 1545772 -> 1545928 bytes resource/jetkvm_native.sha256 | 2 +- web.go | 4 +- 7 files changed, 114 insertions(+), 22 deletions(-) diff --git a/cloud.go b/cloud.go index 3f46a42e4..57d4366ee 100644 --- a/cloud.go +++ b/cloud.go @@ -139,19 +139,33 @@ var ( ) ) +type CloudConnectionState uint8 + +const ( + CloudConnectionStateNotConfigured CloudConnectionState = iota + CloudConnectionStateDisconnected + CloudConnectionStateConnecting + CloudConnectionStateConnected +) + var ( - cloudConnectionAlive bool - cloudConnectionAliveLock = &sync.Mutex{} + cloudConnectionState CloudConnectionState = CloudConnectionStateNotConfigured + cloudConnectionStateLock = &sync.Mutex{} cloudDisconnectChan chan error cloudDisconnectLock = &sync.Mutex{} ) -func setCloudConnectionAlive(alive bool) { - cloudConnectionAliveLock.Lock() - defer cloudConnectionAliveLock.Unlock() +func setCloudConnectionState(state CloudConnectionState) { + cloudConnectionStateLock.Lock() + defer cloudConnectionStateLock.Unlock() - cloudConnectionAlive = alive + if cloudConnectionState == CloudConnectionStateDisconnected && + (config.CloudToken == "" || config.CloudURL == "") { + state = CloudConnectionStateNotConfigured + } + + cloudConnectionState = state go waitCtrlAndRequestDisplayUpdate() } @@ -297,6 +311,8 @@ func runWebsocketClient() error { wsURL.Scheme = "wss" } + setCloudConnectionState(CloudConnectionStateConnecting) + header := http.Header{} header.Set("X-Device-ID", GetDeviceID()) header.Set("X-App-Version", builtAppVersion) @@ -314,12 +330,12 @@ func runWebsocketClient() error { c, resp, err := websocket.Dial(dialCtx, wsURL.String(), &websocket.DialOptions{ HTTPHeader: header, OnPingReceived: func(ctx context.Context, payload []byte) bool { - scopedLogger.Info().Bytes("payload", payload).Int("length", len(payload)).Msg("ping frame received") + scopedLogger.Debug().Bytes("payload", payload).Int("length", len(payload)).Msg("ping frame received") metricConnectionTotalPingReceivedCount.WithLabelValues("cloud", wsURL.Host).Inc() metricConnectionLastPingReceivedTimestamp.WithLabelValues("cloud", wsURL.Host).SetToCurrentTime() - setCloudConnectionAlive(true) + setCloudConnectionState(CloudConnectionStateConnected) return true }, @@ -350,7 +366,7 @@ func runWebsocketClient() error { if err != nil { if errors.Is(err, context.Canceled) { cloudLogger.Info().Msg("websocket connection canceled") - setCloudConnectionAlive(false) + setCloudConnectionState(CloudConnectionStateDisconnected) return nil } @@ -540,6 +556,8 @@ func rpcDeregisterDevice() error { cloudLogger.Info().Msg("device deregistered, disconnecting from cloud") disconnectCloud(fmt.Errorf("device deregistered")) + setCloudConnectionState(CloudConnectionStateNotConfigured) + return nil } diff --git a/dev_deploy.sh b/dev_deploy.sh index 02bbb2460..ca627cd80 100755 --- a/dev_deploy.sh +++ b/dev_deploy.sh @@ -91,7 +91,7 @@ cd "${REMOTE_PATH}" chmod +x jetkvm_app_debug # Run the application in the background -PION_LOG_TRACE=jetkvm,cloud,websocket ./jetkvm_app_debug +PION_LOG_TRACE=jetkvm,cloud,websocket,native ./jetkvm_app_debug EOF echo "Deployment complete." diff --git a/display.go b/display.go index c64e75122..dff435575 100644 --- a/display.go +++ b/display.go @@ -1,6 +1,7 @@ package kvm import ( + "context" "errors" "fmt" "os" @@ -53,6 +54,18 @@ func lvObjShow(objName string) (*CtrlResponse, error) { return lvObjClearFlag(objName, "LV_OBJ_FLAG_HIDDEN") } +func lvObjSetOpacity(objName string, opacity int) (*CtrlResponse, error) { + return CallCtrlAction("lv_obj_set_style_opa_layered", map[string]interface{}{"obj": objName, "opa": opacity}) +} + +func lvObjFadeIn(objName string, duration uint32) (*CtrlResponse, error) { + return CallCtrlAction("lv_obj_fade_in", map[string]interface{}{"obj": objName, "time": duration}) +} + +func lvObjFadeOut(objName string, duration uint32) (*CtrlResponse, error) { + return CallCtrlAction("lv_obj_fade_out", map[string]interface{}{"obj": objName, "time": duration}) +} + func lvLabelSetText(objName string, text string) (*CtrlResponse, error) { return CallCtrlAction("lv_label_set_text", map[string]interface{}{"obj": objName, "text": text}) } @@ -69,13 +82,20 @@ func updateLabelIfChanged(objName string, newText string) { } func switchToScreenIfDifferent(screenName string) { - displayLogger.Info().Str("from", currentScreen).Str("to", screenName).Msg("switching screen") if currentScreen != screenName { + displayLogger.Info().Str("from", currentScreen).Str("to", screenName).Msg("switching screen") switchToScreen(screenName) } } +var ( + cloudBlinkCtx context.Context + cloudBlinkCancel context.CancelFunc + cloudBlinkTicker *time.Ticker +) + func updateDisplay() { + updateLabelIfChanged("ui_Home_Content_Ip", networkState.IPv4String()) if usbState == "configured" { updateLabelIfChanged("ui_Home_Footer_Usb_Status_Label", "Connected") @@ -99,16 +119,67 @@ func updateDisplay() { switchToScreenIfDifferent("ui_No_Network_Screen") } - if config.CloudToken == "" || config.CloudURL == "" { + if cloudConnectionState == CloudConnectionStateNotConfigured { lvObjHide("ui_Home_Header_Cloud_Status_Icon") } else { lvObjShow("ui_Home_Header_Cloud_Status_Icon") - // TODO: blink the icon if establishing connection - if cloudConnectionAlive { - _, _ = lvImgSetSrc("ui_Home_Header_Cloud_Status_Icon", "cloud.png") - } else { - _, _ = lvImgSetSrc("ui_Home_Header_Cloud_Status_Icon", "cloud_disconnected.png") + } + + switch cloudConnectionState { + case CloudConnectionStateDisconnected: + lvImgSetSrc("ui_Home_Header_Cloud_Status_Icon", "cloud_disconnected.png") + stopCloudBlink() + case CloudConnectionStateConnecting: + lvImgSetSrc("ui_Home_Header_Cloud_Status_Icon", "cloud.png") + startCloudBlink() + case CloudConnectionStateConnected: + lvImgSetSrc("ui_Home_Header_Cloud_Status_Icon", "cloud.png") + stopCloudBlink() + } +} + +func startCloudBlink() { + if cloudBlinkTicker == nil { + cloudBlinkTicker = time.NewTicker(2 * time.Second) + } + + if cloudBlinkCtx != nil { + cloudBlinkCancel() + } + + cloudBlinkCtx, cloudBlinkCancel = context.WithCancel(appCtx) + cloudBlinkTicker.Reset(2 * time.Second) + + go func() { + defer cloudBlinkTicker.Stop() + for { + select { + case <-cloudBlinkTicker.C: + if cloudConnectionState != CloudConnectionStateConnecting { + return + } + _, _ = lvObjFadeIn("ui_Home_Header_Cloud_Status_Icon", 1000) + time.Sleep(1000 * time.Millisecond) + _, _ = lvObjFadeOut("ui_Home_Header_Cloud_Status_Icon", 1000) + time.Sleep(1000 * time.Millisecond) + case <-cloudBlinkCtx.Done(): + time.Sleep(1000 * time.Millisecond) + _, _ = lvObjFadeIn("ui_Home_Header_Cloud_Status_Icon", 1000) + time.Sleep(1000 * time.Millisecond) + _, _ = lvObjSetOpacity("ui_Home_Header_Cloud_Status_Icon", 255) + return + } } + }() +} + +func stopCloudBlink() { + if cloudBlinkTicker != nil { + cloudBlinkTicker.Stop() + } + + if cloudBlinkCtx != nil { + cloudBlinkCancel() } } @@ -128,7 +199,7 @@ func requestDisplayUpdate() { } go func() { wakeDisplay(false) - displayLogger.Info().Msg("display updating") + displayLogger.Debug().Msg("display updating") //TODO: only run once regardless how many pending updates updateDisplay() }() diff --git a/native.go b/native.go index b61598cca..c339569d2 100644 --- a/native.go +++ b/native.go @@ -335,7 +335,10 @@ func ensureBinaryUpdated(destPath string) error { _, err = os.Stat(destPath) if shouldOverwrite(destPath, srcHash) || err != nil { - nativeLogger.Info().Msg("writing jetkvm_native") + nativeLogger.Info(). + Interface("hash", srcHash). + Msg("writing jetkvm_native") + _ = os.Remove(destPath) destFile, err := os.OpenFile(destPath, os.O_CREATE|os.O_RDWR, 0755) if err != nil { diff --git a/resource/jetkvm_native b/resource/jetkvm_native index 5b169d826f2dada81457fef9455d2861f30d65de..084ce14970c8ef559f9c2e2bcdfd05d5a8993952 100644 GIT binary patch delta 65977 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 delta 64981 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 Date: Sun, 13 Apr 2025 03:11:17 +0200 Subject: [PATCH 05/26] chore(websecure): update log message --- internal/websecure/store.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/internal/websecure/store.go b/internal/websecure/store.go index 7da2deea0..b1d5aac82 100644 --- a/internal/websecure/store.go +++ b/internal/websecure/store.go @@ -96,7 +96,11 @@ func (s *CertStore) loadCertificate(hostname string) { s.certificates[hostname] = &cert - s.log.Info().Str("hostname", hostname).Msg("Loaded certificate") + if hostname == selfSignerCAMagicName { + s.log.Info().Msg("loaded CA certificate") + } else { + s.log.Info().Str("hostname", hostname).Msg("loaded certificate") + } } // GetCertificate returns the certificate for the given hostname From d9eae340bfca35483b912c21d86fad9d9a9c960e Mon Sep 17 00:00:00 2001 From: Siyuan Miao Date: Sun, 13 Apr 2025 03:13:35 +0200 Subject: [PATCH 06/26] fix(ota): validate root certificate when downloading update --- ota.go | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/ota.go b/ota.go index a5da7726a..0559978d4 100644 --- a/ota.go +++ b/ota.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "crypto/sha256" + "crypto/tls" "encoding/hex" "encoding/json" "fmt" @@ -16,6 +17,7 @@ import ( "time" "github.com/Masterminds/semver/v3" + "github.com/gwatts/rootcerts" "github.com/rs/zerolog" ) @@ -127,10 +129,14 @@ func downloadFile(ctx context.Context, path string, url string, downloadProgress return fmt.Errorf("error creating request: %w", err) } - // TODO: set a separate timeout for the download but keep the TLS handshake short - // use Transport here will cause CA certificate validation failure so we temporarily removed it client := http.Client{ Timeout: 10 * time.Minute, + Transport: &http.Transport{ + TLSHandshakeTimeout: 30 * time.Second, + TLSClientConfig: &tls.Config{ + RootCAs: rootcerts.ServerCertPool(), + }, + }, } resp, err := client.Do(req) From fd3a8cb5534692c8030ab56e3952e7ed8029906d Mon Sep 17 00:00:00 2001 From: Siyuan Miao Date: Sun, 13 Apr 2025 03:53:20 +0200 Subject: [PATCH 07/26] feat(ui): add network settings tab --- internal/udhcpc/udhcpc.go | 4 + jsonrpc.go | 2 + network.go | 29 ++++ ui/src/main.tsx | 5 + .../routes/devices.$id.settings.network.tsx | 135 ++++++++++++++++++ ui/src/routes/devices.$id.settings.tsx | 12 ++ 6 files changed, 187 insertions(+) create mode 100644 ui/src/routes/devices.$id.settings.network.tsx diff --git a/internal/udhcpc/udhcpc.go b/internal/udhcpc/udhcpc.go index 1459b4091..298ac2657 100644 --- a/internal/udhcpc/udhcpc.go +++ b/internal/udhcpc/udhcpc.go @@ -143,3 +143,7 @@ func (c *DHCPClient) loadLeaseFile() error { return nil } + +func (c *DHCPClient) GetLease() *Lease { + return c.lease +} diff --git a/jsonrpc.go b/jsonrpc.go index 0c7d7fd96..3e2805456 100644 --- a/jsonrpc.go +++ b/jsonrpc.go @@ -960,6 +960,8 @@ var rpcHandlers = map[string]RPCHandler{ "getDeviceID": {Func: rpcGetDeviceID}, "deregisterDevice": {Func: rpcDeregisterDevice}, "getCloudState": {Func: rpcGetCloudState}, + "getNetworkState": {Func: rpcGetNetworkState}, + "renewDHCPLease": {Func: rpcRenewDHCPLease}, "keyboardReport": {Func: rpcKeyboardReport, Params: []string{"modifier", "keys"}}, "absMouseReport": {Func: rpcAbsMouseReport, Params: []string{"x", "y", "buttons"}}, "relMouseReport": {Func: rpcRelMouseReport, Params: []string{"dx", "dy", "buttons"}}, diff --git a/network.go b/network.go index b2588c9c5..fd62dd83c 100644 --- a/network.go +++ b/network.go @@ -47,6 +47,14 @@ type NetworkInterfaceState struct { checked bool } +type RpcNetworkState struct { + InterfaceName string `json:"interface_name"` + MacAddress string `json:"mac_address"` + IPv4 string `json:"ipv4,omitempty"` + IPv6 string `json:"ipv6,omitempty"` + DHCPLease *udhcpc.Lease `json:"dhcp_lease,omitempty"` +} + func (s *NetworkInterfaceState) IsUp() bool { return s.interfaceUp } @@ -305,6 +313,27 @@ func (s *NetworkInterfaceState) HandleLinkUpdate(update netlink.LinkUpdate) { } } +func rpcGetNetworkState() RpcNetworkState { + return RpcNetworkState{ + InterfaceName: networkState.interfaceName, + MacAddress: networkState.macAddr.String(), + IPv4: networkState.ipv4Addr.String(), + IPv6: networkState.ipv6Addr.String(), + DHCPLease: networkState.dhcpClient.GetLease(), + } +} + +func rpcRenewDHCPLease() error { + if networkState == nil { + return fmt.Errorf("network state not initialized") + } + if networkState.dhcpClient == nil { + return fmt.Errorf("dhcp client not initialized") + } + + return networkState.dhcpClient.Renew() +} + func initNetwork() { ensureConfigLoaded() diff --git a/ui/src/main.tsx b/ui/src/main.tsx index e09a2a92e..f4bdd3481 100644 --- a/ui/src/main.tsx +++ b/ui/src/main.tsx @@ -42,6 +42,7 @@ import SettingsVideoRoute from "./routes/devices.$id.settings.video"; import SettingsAppearanceRoute from "./routes/devices.$id.settings.appearance"; import * as SettingsGeneralIndexRoute from "./routes/devices.$id.settings.general._index"; import SettingsGeneralUpdateRoute from "./routes/devices.$id.settings.general.update"; +import SettingsNetworkRoute from "./routes/devices.$id.settings.network"; import SecurityAccessLocalAuthRoute from "./routes/devices.$id.settings.access.local-auth"; import SettingsMacrosRoute from "./routes/devices.$id.settings.macros"; import SettingsMacrosAddRoute from "./routes/devices.$id.settings.macros.add"; @@ -156,6 +157,10 @@ if (isOnDevice) { path: "hardware", element: , }, + { + path: "network", + element: , + }, { path: "access", children: [ diff --git a/ui/src/routes/devices.$id.settings.network.tsx b/ui/src/routes/devices.$id.settings.network.tsx new file mode 100644 index 000000000..c7ade5fcf --- /dev/null +++ b/ui/src/routes/devices.$id.settings.network.tsx @@ -0,0 +1,135 @@ +import { useCallback, useEffect, useState } from "react"; + +import { SettingsPageHeader } from "../components/SettingsPageheader"; +import { SelectMenuBasic } from "../components/SelectMenuBasic"; + +import { SettingsItem } from "./devices.$id.settings"; +import { useJsonRpc } from "@/hooks/useJsonRpc"; +import { Button } from "@components/Button"; +import notifications from "@/notifications"; + +interface DhcpLease { + ip?: string; + netmask?: string; + broadcast?: string; + ttl?: string; + mtu?: string; + hostname?: string; + domain?: string; + bootp_next_server?: string; + bootp_server_name?: string; + bootp_file?: string; + timezone?: string; + routers?: string[]; + dns?: string[]; + ntp_servers?: string[]; + lpr_servers?: string[]; + _time_servers?: string[]; + _name_servers?: string[]; + _log_servers?: string[]; + _cookie_servers?: string[]; + _wins_servers?: string[]; + _swap_server?: string; + boot_size?: string; + root_path?: string; + lease?: string; + dhcp_type?: string; + server_id?: string; + message?: string; + tftp?: string; + bootfile?: string; +} + + +interface NetworkState { + interface_name?: string; + mac_address?: string; + ipv4?: string; + ipv6?: string; + dhcp_lease?: DhcpLease; +} + +export default function SettingsNetworkRoute() { + const [send] = useJsonRpc(); + const [networkState, setNetworkState] = useState(null); + + + const getNetworkState = useCallback(() => { + send("getNetworkState", {}, resp => { + if ("error" in resp) return; + setNetworkState(resp.result as NetworkState); + }); + }, [send]); + + const handleRenewLease = useCallback(() => { + send("renewDHCPLease", {}, resp => { + if ("error" in resp) { + notifications.error("Failed to renew lease: " + resp.error.message); + } else { + notifications.success("DHCP lease renewed"); + getNetworkState(); + } + }); + }, [send, getNetworkState]); + + useEffect(() => { + getNetworkState(); + }, [getNetworkState]); + + return ( +
+ +
+ {networkState?.ipv4} + } + /> +
+
+ {networkState?.ipv6}} + /> +
+
+ {networkState?.mac_address}} + /> +
+
+ +
    + {networkState?.dhcp_lease?.ip &&
  • IP: {networkState?.dhcp_lease?.ip}
  • } + {networkState?.dhcp_lease?.netmask &&
  • Subnet: {networkState?.dhcp_lease?.netmask}
  • } + {networkState?.dhcp_lease?.broadcast &&
  • Broadcast: {networkState?.dhcp_lease?.broadcast}
  • } + {networkState?.dhcp_lease?.ttl &&
  • TTL: {networkState?.dhcp_lease?.ttl}
  • } + {networkState?.dhcp_lease?.mtu &&
  • MTU: {networkState?.dhcp_lease?.mtu}
  • } + {networkState?.dhcp_lease?.hostname &&
  • Hostname: {networkState?.dhcp_lease?.hostname}
  • } + {networkState?.dhcp_lease?.domain &&
  • Domain: {networkState?.dhcp_lease?.domain}
  • } + {networkState?.dhcp_lease?.routers &&
  • Gateway: {networkState?.dhcp_lease?.routers.join(", ")}
  • } + {networkState?.dhcp_lease?.dns &&
  • DNS: {networkState?.dhcp_lease?.dns.join(", ")}
  • } + {networkState?.dhcp_lease?.ntp_servers &&
  • NTP Servers: {networkState?.dhcp_lease?.ntp_servers.join(", ")}
  • } +
+ } + > +
+
+ ); +} diff --git a/ui/src/routes/devices.$id.settings.tsx b/ui/src/routes/devices.$id.settings.tsx index c0b41817d..f8e5262aa 100644 --- a/ui/src/routes/devices.$id.settings.tsx +++ b/ui/src/routes/devices.$id.settings.tsx @@ -9,6 +9,7 @@ import { LuArrowLeft, LuPalette, LuCommand, + LuNetwork, } from "react-icons/lu"; import React, { useEffect, useRef, useState } from "react"; @@ -207,6 +208,17 @@ export default function SettingsRoute() { +
+ (isActive ? "active" : "")} + > +
+ +

Network

+
+
+
Date: Sun, 13 Apr 2025 15:54:24 +0200 Subject: [PATCH 08/26] fix(display): cloud connecting animation --- display.go | 36 +++++++++++++++--------------------- 1 file changed, 15 insertions(+), 21 deletions(-) diff --git a/display.go b/display.go index dff435575..24028362d 100644 --- a/display.go +++ b/display.go @@ -1,7 +1,6 @@ package kvm import ( - "context" "errors" "fmt" "os" @@ -89,9 +88,9 @@ func switchToScreenIfDifferent(screenName string) { } var ( - cloudBlinkCtx context.Context - cloudBlinkCancel context.CancelFunc - cloudBlinkTicker *time.Ticker + cloudBlinkLock sync.Mutex = sync.Mutex{} + cloudBlinkStopped bool + cloudBlinkTicker *time.Ticker ) func updateDisplay() { @@ -141,33 +140,28 @@ func updateDisplay() { func startCloudBlink() { if cloudBlinkTicker == nil { cloudBlinkTicker = time.NewTicker(2 * time.Second) - } + } else { + // do nothing if the blink isn't stopped + if cloudBlinkStopped { + cloudBlinkLock.Lock() + defer cloudBlinkLock.Unlock() - if cloudBlinkCtx != nil { - cloudBlinkCancel() + cloudBlinkStopped = false + cloudBlinkTicker.Reset(2 * time.Second) + } } - cloudBlinkCtx, cloudBlinkCancel = context.WithCancel(appCtx) - cloudBlinkTicker.Reset(2 * time.Second) - go func() { - defer cloudBlinkTicker.Stop() for { select { case <-cloudBlinkTicker.C: if cloudConnectionState != CloudConnectionStateConnecting { - return + continue } - _, _ = lvObjFadeIn("ui_Home_Header_Cloud_Status_Icon", 1000) - time.Sleep(1000 * time.Millisecond) _, _ = lvObjFadeOut("ui_Home_Header_Cloud_Status_Icon", 1000) time.Sleep(1000 * time.Millisecond) - case <-cloudBlinkCtx.Done(): - time.Sleep(1000 * time.Millisecond) _, _ = lvObjFadeIn("ui_Home_Header_Cloud_Status_Icon", 1000) time.Sleep(1000 * time.Millisecond) - _, _ = lvObjSetOpacity("ui_Home_Header_Cloud_Status_Icon", 255) - return } } }() @@ -178,9 +172,9 @@ func stopCloudBlink() { cloudBlinkTicker.Stop() } - if cloudBlinkCtx != nil { - cloudBlinkCancel() - } + cloudBlinkLock.Lock() + defer cloudBlinkLock.Unlock() + cloudBlinkStopped = true } var ( From eb8bac4013661cd60cf1f8f16950ff7a92e1a8bf Mon Sep 17 00:00:00 2001 From: Siyuan Miao Date: Sun, 13 Apr 2025 15:58:59 +0200 Subject: [PATCH 09/26] fix: golintci issues --- display.go | 30 +++++++++++++----------------- internal/timesync/timesync.go | 4 ---- internal/udhcpc/parser.go | 2 -- internal/udhcpc/udhcpc.go | 11 +++++++++-- network.go | 15 +++++++-------- timesync.go | 7 ------- 6 files changed, 29 insertions(+), 40 deletions(-) diff --git a/display.go b/display.go index 24028362d..b60f9f88b 100644 --- a/display.go +++ b/display.go @@ -53,7 +53,7 @@ func lvObjShow(objName string) (*CtrlResponse, error) { return lvObjClearFlag(objName, "LV_OBJ_FLAG_HIDDEN") } -func lvObjSetOpacity(objName string, opacity int) (*CtrlResponse, error) { +func lvObjSetOpacity(objName string, opacity int) (*CtrlResponse, error) { // nolint:unused return CallCtrlAction("lv_obj_set_style_opa_layered", map[string]interface{}{"obj": objName, "opa": opacity}) } @@ -94,7 +94,6 @@ var ( ) func updateDisplay() { - updateLabelIfChanged("ui_Home_Content_Ip", networkState.IPv4String()) if usbState == "configured" { updateLabelIfChanged("ui_Home_Footer_Usb_Status_Label", "Connected") @@ -119,20 +118,20 @@ func updateDisplay() { } if cloudConnectionState == CloudConnectionStateNotConfigured { - lvObjHide("ui_Home_Header_Cloud_Status_Icon") + _, _ = lvObjHide("ui_Home_Header_Cloud_Status_Icon") } else { - lvObjShow("ui_Home_Header_Cloud_Status_Icon") + _, _ = lvObjShow("ui_Home_Header_Cloud_Status_Icon") } switch cloudConnectionState { case CloudConnectionStateDisconnected: - lvImgSetSrc("ui_Home_Header_Cloud_Status_Icon", "cloud_disconnected.png") + _, _ = lvImgSetSrc("ui_Home_Header_Cloud_Status_Icon", "cloud_disconnected.png") stopCloudBlink() case CloudConnectionStateConnecting: - lvImgSetSrc("ui_Home_Header_Cloud_Status_Icon", "cloud.png") + _, _ = lvImgSetSrc("ui_Home_Header_Cloud_Status_Icon", "cloud.png") startCloudBlink() case CloudConnectionStateConnected: - lvImgSetSrc("ui_Home_Header_Cloud_Status_Icon", "cloud.png") + _, _ = lvImgSetSrc("ui_Home_Header_Cloud_Status_Icon", "cloud.png") stopCloudBlink() } } @@ -152,17 +151,14 @@ func startCloudBlink() { } go func() { - for { - select { - case <-cloudBlinkTicker.C: - if cloudConnectionState != CloudConnectionStateConnecting { - continue - } - _, _ = lvObjFadeOut("ui_Home_Header_Cloud_Status_Icon", 1000) - time.Sleep(1000 * time.Millisecond) - _, _ = lvObjFadeIn("ui_Home_Header_Cloud_Status_Icon", 1000) - time.Sleep(1000 * time.Millisecond) + for range cloudBlinkTicker.C { + if cloudConnectionState != CloudConnectionStateConnecting { + continue } + _, _ = lvObjFadeOut("ui_Home_Header_Cloud_Status_Icon", 1000) + time.Sleep(1000 * time.Millisecond) + _, _ = lvObjFadeIn("ui_Home_Header_Cloud_Status_Icon", 1000) + time.Sleep(1000 * time.Millisecond) } }() } diff --git a/internal/timesync/timesync.go b/internal/timesync/timesync.go index eac474941..144720184 100644 --- a/internal/timesync/timesync.go +++ b/internal/timesync/timesync.go @@ -21,10 +21,6 @@ const ( var ( timeSyncRetryInterval = 0 * time.Second - defaultNTPServers = []string{ - "time.cloudflare.com", - "time.apple.com", - } ) type TimeSync struct { diff --git a/internal/udhcpc/parser.go b/internal/udhcpc/parser.go index ee529aa19..08204092c 100644 --- a/internal/udhcpc/parser.go +++ b/internal/udhcpc/parser.go @@ -3,7 +3,6 @@ package udhcpc import ( "encoding/json" "fmt" - "log" "net" "reflect" "strconv" @@ -73,7 +72,6 @@ func UnmarshalDHCPCLease(lease *Lease, str string) error { parts := strings.SplitN(line, "=", 2) if len(parts) != 2 { - log.Printf("invalid line: %s", line) continue } diff --git a/internal/udhcpc/udhcpc.go b/internal/udhcpc/udhcpc.go index 298ac2657..4ff75ab04 100644 --- a/internal/udhcpc/udhcpc.go +++ b/internal/udhcpc/udhcpc.go @@ -73,7 +73,7 @@ func (c *DHCPClient) Run() error { c.logger.Debug(). Str("event", event.Name). Msg("udhcpc lease file updated, reloading lease") - c.loadLeaseFile() + _ = c.loadLeaseFile() } case err, ok := <-watcher.Errors: if !ok { @@ -84,7 +84,14 @@ func (c *DHCPClient) Run() error { } }() - watcher.Add(c.leaseFile) + err = watcher.Add(c.leaseFile) + if err != nil { + c.logger.Error(). + Err(err). + Str("path", c.leaseFile). + Msg("failed to watch lease file") + return err + } // TODO: update udhcpc pid file // we'll comment this out for now because the pid might change diff --git a/network.go b/network.go index fd62dd83c..e298d521d 100644 --- a/network.go +++ b/network.go @@ -140,7 +140,7 @@ func NewNetworkInterfaceState(ifname string) *NetworkInterfaceState { PidFile: dhcpPidFile, Logger: &logger, OnLeaseChange: func(lease *udhcpc.Lease) { - s.update() + _, _ = s.update() }, }) @@ -168,8 +168,8 @@ func (s *NetworkInterfaceState) update() (DhcpTargetState, error) { newInterfaceUp := state == netlink.OperUp // check if the interface is coming up - interfaceGoingUp := s.interfaceUp == false && newInterfaceUp == true - interfaceGoingDown := s.interfaceUp == true && newInterfaceUp == false + interfaceGoingUp := !s.interfaceUp && newInterfaceUp + interfaceGoingDown := s.interfaceUp && !newInterfaceUp if s.interfaceUp != newInterfaceUp { s.interfaceUp = newInterfaceUp @@ -243,7 +243,6 @@ func (s *NetworkInterfaceState) update() (DhcpTargetState, error) { scopedLogger.Info(). Str("old_ipv4", s.ipv4Addr.String()). Msg("IPv4 address changed") - changed = true } else { scopedLogger.Info().Msg("IPv4 address found") } @@ -293,10 +292,10 @@ func (s *NetworkInterfaceState) CheckAndUpdateDhcp() error { switch dhcpTargetState { case DhcpTargetStateRenew: s.l.Info().Msg("renewing DHCP lease") - s.dhcpClient.Renew() + _ = s.dhcpClient.Renew() case DhcpTargetStateRelease: s.l.Info().Msg("releasing DHCP lease") - s.dhcpClient.Release() + _ = s.dhcpClient.Release() case DhcpTargetStateStart: s.l.Warn().Msg("dhcpTargetStateStart not implemented") case DhcpTargetStateStop: @@ -309,7 +308,7 @@ func (s *NetworkInterfaceState) CheckAndUpdateDhcp() error { 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() + _ = s.CheckAndUpdateDhcp() } } @@ -347,7 +346,7 @@ func initNetwork() { // TODO: support multiple interfaces networkState = NewNetworkInterfaceState(NetIfName) - go networkState.dhcpClient.Run() + go networkState.dhcpClient.Run() // nolint:errcheck if err := networkState.CheckAndUpdateDhcp(); err != nil { os.Exit(1) diff --git a/timesync.go b/timesync.go index 20306ec25..e31f9080f 100644 --- a/timesync.go +++ b/timesync.go @@ -7,13 +7,6 @@ import ( "github.com/jetkvm/kvm/internal/timesync" ) -const ( - timeSyncRetryStep = 5 * time.Second - timeSyncRetryMaxInt = 1 * time.Minute - timeSyncWaitNetChkInt = 100 * time.Millisecond - timeSyncWaitNetUpInt = 3 * time.Second -) - var ( timeSync *timesync.TimeSync defaultNTPServers = []string{ From 73b83557aff533d065ac8aff88d02aab87e056af Mon Sep 17 00:00:00 2001 From: Siyuan Miao Date: Mon, 14 Apr 2025 06:00:00 +0200 Subject: [PATCH 10/26] feat: add network settings tab --- cloud.go | 5 +- display.go | 12 +- internal/udhcpc/parser.go | 38 ++ internal/udhcpc/udhcpc.go | 12 + jsonrpc.go | 1 + network.go | 159 ++++++- ui/dev_device.sh | 10 + ui/package-lock.json | 6 + ui/package.json | 1 + ui/src/hooks/stores.ts | 91 +++- .../routes/devices.$id.settings.network.tsx | 402 ++++++++++++++---- ui/src/routes/devices.$id.tsx | 9 + usb.go | 2 +- video.go | 2 +- webrtc.go | 2 +- 15 files changed, 655 insertions(+), 97 deletions(-) diff --git a/cloud.go b/cloud.go index 57d4366ee..fb1998ae7 100644 --- a/cloud.go +++ b/cloud.go @@ -165,9 +165,12 @@ func setCloudConnectionState(state CloudConnectionState) { state = CloudConnectionStateNotConfigured } + previousState := cloudConnectionState cloudConnectionState = state - go waitCtrlAndRequestDisplayUpdate() + go waitCtrlAndRequestDisplayUpdate( + previousState != state, + ) } func wsResetMetrics(established bool, sourceType string, source string) { diff --git a/display.go b/display.go index b60f9f88b..b983efca8 100644 --- a/display.go +++ b/display.go @@ -179,7 +179,7 @@ var ( waitDisplayUpdate = sync.Mutex{} ) -func requestDisplayUpdate() { +func requestDisplayUpdate(shouldWakeDisplay bool) { displayUpdateLock.Lock() defer displayUpdateLock.Unlock() @@ -188,19 +188,21 @@ func requestDisplayUpdate() { return } go func() { - wakeDisplay(false) + if shouldWakeDisplay { + wakeDisplay(false) + } displayLogger.Debug().Msg("display updating") //TODO: only run once regardless how many pending updates updateDisplay() }() } -func waitCtrlAndRequestDisplayUpdate() { +func waitCtrlAndRequestDisplayUpdate(shouldWakeDisplay bool) { waitDisplayUpdate.Lock() defer waitDisplayUpdate.Unlock() waitCtrlClientConnected() - requestDisplayUpdate() + requestDisplayUpdate(shouldWakeDisplay) } func updateStaticContents() { @@ -376,7 +378,7 @@ func init() { displayLogger.Info().Msg("display inited") startBacklightTickers() wakeDisplay(true) - requestDisplayUpdate() + requestDisplayUpdate(true) }() go watchTsEvents() diff --git a/internal/udhcpc/parser.go b/internal/udhcpc/parser.go index 08204092c..66c3ba2a4 100644 --- a/internal/udhcpc/parser.go +++ b/internal/udhcpc/parser.go @@ -1,9 +1,11 @@ package udhcpc import ( + "bufio" "encoding/json" "fmt" "net" + "os" "reflect" "strconv" "strings" @@ -41,6 +43,8 @@ type Lease struct { Message string `env:"message" json:"reason,omitempty"` // Reason for a DHCPNAK TFTPServerName string `env:"tftp" json:"tftp,omitempty"` // The TFTP server name BootFileName string `env:"bootfile" json:"bootfile,omitempty"` // The boot file name + Uptime time.Duration `env:"uptime" json:"uptime,omitempty"` // The uptime of the device when the lease was obtained, in seconds + LeaseExpiry *time.Time `json:"lease_expiry,omitempty"` // The expiry time of the lease isEmpty map[string]bool } @@ -60,6 +64,40 @@ func (l *Lease) ToJSON() string { return string(json) } +func (l *Lease) SetLeaseExpiry() (time.Time, error) { + if l.Uptime == 0 || l.LeaseTime == 0 { + return time.Time{}, fmt.Errorf("uptime or lease time isn't set") + } + + // get the uptime of the device + + file, err := os.Open("/proc/uptime") + if err != nil { + return time.Time{}, fmt.Errorf("failed to open uptime file: %w", err) + } + defer file.Close() + + var uptime time.Duration + + scanner := bufio.NewScanner(file) + for scanner.Scan() { + text := scanner.Text() + parts := strings.Split(text, " ") + uptime, err = time.ParseDuration(parts[0] + "s") + + if err != nil { + return time.Time{}, fmt.Errorf("failed to parse uptime: %w", err) + } + } + + relativeLeaseRemaining := (l.Uptime + l.LeaseTime) - uptime + leaseExpiry := time.Now().Add(relativeLeaseRemaining) + + l.LeaseExpiry = &leaseExpiry + + return leaseExpiry, nil +} + func UnmarshalDHCPCLease(lease *Lease, str string) error { // parse the lease file as a map data := make(map[string]string) diff --git a/internal/udhcpc/udhcpc.go b/internal/udhcpc/udhcpc.go index 4ff75ab04..927d551b6 100644 --- a/internal/udhcpc/udhcpc.go +++ b/internal/udhcpc/udhcpc.go @@ -4,6 +4,7 @@ import ( "errors" "fmt" "os" + "time" "github.com/fsnotify/fsnotify" "github.com/rs/zerolog" @@ -140,6 +141,17 @@ func (c *DHCPClient) loadLeaseFile() error { msg = "udhcpc lease loaded" } + leaseExpiry, err := lease.SetLeaseExpiry() + if err != nil { + c.logger.Error().Err(err).Msg("failed to get dhcp lease expiry") + } else { + expiresIn := leaseExpiry.Sub(time.Now()) + c.logger.Info(). + Interface("expiry", leaseExpiry). + Str("expiresIn", expiresIn.String()). + Msg("current dhcp lease expiry time calculated") + } + c.onLeaseChange(lease) c.logger.Info(). diff --git a/jsonrpc.go b/jsonrpc.go index 3e2805456..d39fdb1dc 100644 --- a/jsonrpc.go +++ b/jsonrpc.go @@ -961,6 +961,7 @@ var rpcHandlers = map[string]RPCHandler{ "deregisterDevice": {Func: rpcDeregisterDevice}, "getCloudState": {Func: rpcGetCloudState}, "getNetworkState": {Func: rpcGetNetworkState}, + "getNetworkSettings": {Func: rpcGetNetworkSettings}, "renewDHCPLease": {Func: rpcRenewDHCPLease}, "keyboardReport": {Func: rpcKeyboardReport, Params: []string{"modifier", "keys"}}, "absMouseReport": {Func: rpcAbsMouseReport, Params: []string{"x", "y", "buttons"}}, diff --git a/network.go b/network.go index e298d521d..5ce9861da 100644 --- a/network.go +++ b/network.go @@ -29,11 +29,22 @@ const ( DhcpTargetStateRelease ) +type IPv6Address struct { + Address net.IP `json:"address"` + Prefix net.IPNet `json:"prefix"` + ValidLifetime *time.Time `json:"valid_lifetime"` + PreferredLifetime *time.Time `json:"preferred_lifetime"` + Scope int `json:"scope"` +} + type NetworkInterfaceState struct { interfaceName string interfaceUp bool ipv4Addr *net.IP + ipv4Addresses []string ipv6Addr *net.IP + ipv6Addresses []IPv6Address + ipv6LinkLocal *net.IP macAddr *net.HardwareAddr l *zerolog.Logger @@ -47,12 +58,65 @@ type NetworkInterfaceState struct { checked bool } +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"` +} + +type RpcIPv6Address struct { + Address string `json:"address"` + ValidLifetime *time.Time `json:"valid_lifetime,omitempty"` + PreferredLifetime *time.Time `json:"preferred_lifetime,omitempty"` + Scope int `json:"scope"` +} + type RpcNetworkState struct { - InterfaceName string `json:"interface_name"` - MacAddress string `json:"mac_address"` - IPv4 string `json:"ipv4,omitempty"` - IPv6 string `json:"ipv6,omitempty"` - DHCPLease *udhcpc.Lease `json:"dhcp_lease,omitempty"` + InterfaceName string `json:"interface_name"` + MacAddress string `json:"mac_address"` + IPv4 string `json:"ipv4,omitempty"` + IPv6 string `json:"ipv6,omitempty"` + IPv6LinkLocal string `json:"ipv6_link_local,omitempty"` + IPv4Addresses []string `json:"ipv4_addresses,omitempty"` + IPv6Addresses []RpcIPv6Address `json:"ipv6_addresses,omitempty"` + DHCPLease *udhcpc.Lease `json:"dhcp_lease,omitempty"` +} + +type RpcNetworkSettings struct { + IPv4Mode string `json:"ipv4_mode,omitempty"` + IPv6Mode string `json:"ipv6_mode,omitempty"` + LLDPMode string `json:"lldp_mode,omitempty"` + LLDPTxTLVs []string `json:"lldp_tx_tlvs,omitempty"` + MDNSMode string `json:"mdns_mode,omitempty"` + TimeSyncMode string `json:"time_sync_mode,omitempty"` +} + +func lifetimeToTime(lifetime int) *time.Time { + if lifetime == 0 { + return nil + } + t := time.Now().Add(time.Duration(lifetime) * time.Second) + return &t } func (s *NetworkInterfaceState) IsUp() bool { @@ -115,13 +179,13 @@ func NewNetworkInterfaceState(ifname string) *NetworkInterfaceState { onStateChange: func(state *NetworkInterfaceState) { go func() { waitCtrlClientConnected() - requestDisplayUpdate() + requestDisplayUpdate(true) }() }, onInitialCheck: func(state *NetworkInterfaceState) { go func() { waitCtrlClientConnected() - requestDisplayUpdate() + requestDisplayUpdate(true) }() }, } @@ -140,7 +204,18 @@ func NewNetworkInterfaceState(ifname string) *NetworkInterfaceState { PidFile: dhcpPidFile, Logger: &logger, OnLeaseChange: func(lease *udhcpc.Lease) { - _, _ = s.update() + _, err := s.update() + if err != nil { + logger.Error().Err(err).Msg("failed to update network state") + return + } + + if currentSession == nil { + logger.Info().Msg("No active RPC session, skipping network state update") + return + } + + writeJSONRPCEvent("networkState", rpcGetNetworkState(), currentSession) }, }) @@ -196,8 +271,11 @@ func (s *NetworkInterfaceState) update() (DhcpTargetState, error) { } var ( - ipv4Addresses = make([]net.IP, 0) - ipv6Addresses = make([]net.IP, 0) + ipv4Addresses = make([]net.IP, 0) + ipv4AddressesString = make([]string, 0) + ipv6Addresses = make([]IPv6Address, 0) + ipv6AddressesString = make([]string, 0) + ipv6LinkLocal *net.IP ) for _, addr := range addrs { @@ -215,9 +293,15 @@ func (s *NetworkInterfaceState) update() (DhcpTargetState, error) { continue } ipv4Addresses = append(ipv4Addresses, addr.IP) + ipv4AddressesString = append(ipv4AddressesString, addr.IPNet.String()) } else if addr.IP.To16() != nil { scopedLogger := s.l.With().Str("ipv6", addr.IP.String()).Logger() // check if it's a link local address + if addr.IP.IsLinkLocalUnicast() { + ipv6LinkLocal = &addr.IP + continue + } + if !addr.IP.IsGlobalUnicast() { scopedLogger.Trace().Msg("not a global unicast address, skipping") continue @@ -231,7 +315,14 @@ func (s *NetworkInterfaceState) update() (DhcpTargetState, error) { } continue } - ipv6Addresses = append(ipv6Addresses, addr.IP) + ipv6Addresses = append(ipv6Addresses, IPv6Address{ + Address: addr.IP, + Prefix: *addr.IPNet, + ValidLifetime: lifetimeToTime(addr.ValidLft), + PreferredLifetime: lifetimeToTime(addr.PreferedLft), + Scope: addr.Scope, + }) + ipv6AddressesString = append(ipv6AddressesString, addr.IPNet.String()) } } @@ -250,11 +341,28 @@ func (s *NetworkInterfaceState) update() (DhcpTargetState, error) { changed = true } } + s.ipv4Addresses = ipv4AddressesString + + if ipv6LinkLocal != nil { + if s.ipv6LinkLocal == nil || s.ipv6LinkLocal.String() != ipv6LinkLocal.String() { + scopedLogger := s.l.With().Str("ipv6", ipv6LinkLocal.String()).Logger() + if s.ipv6LinkLocal != nil { + scopedLogger.Info(). + Str("old_ipv6", s.ipv6LinkLocal.String()). + Msg("IPv6 link local address changed") + } else { + scopedLogger.Info().Msg("IPv6 link local address found") + } + s.ipv6LinkLocal = ipv6LinkLocal + changed = true + } + } + s.ipv6Addresses = ipv6Addresses if len(ipv6Addresses) > 0 { // compare the addresses to see if there's a change - if s.ipv6Addr == nil || s.ipv6Addr.String() != ipv6Addresses[0].String() { - scopedLogger := s.l.With().Str("ipv6", ipv6Addresses[0].String()).Logger() + if s.ipv6Addr == nil || s.ipv6Addr.String() != ipv6Addresses[0].Address.String() { + scopedLogger := s.l.With().Str("ipv6", ipv6Addresses[0].Address.String()).Logger() if s.ipv6Addr != nil { scopedLogger.Info(). Str("old_ipv6", s.ipv6Addr.String()). @@ -262,7 +370,7 @@ func (s *NetworkInterfaceState) update() (DhcpTargetState, error) { } else { scopedLogger.Info().Msg("IPv6 address found") } - s.ipv6Addr = &ipv6Addresses[0] + s.ipv6Addr = &ipv6Addresses[0].Address changed = true } } @@ -313,15 +421,38 @@ func (s *NetworkInterfaceState) HandleLinkUpdate(update netlink.LinkUpdate) { } func rpcGetNetworkState() RpcNetworkState { + ipv6Addresses := make([]RpcIPv6Address, 0) + for _, addr := range networkState.ipv6Addresses { + ipv6Addresses = append(ipv6Addresses, RpcIPv6Address{ + Address: addr.Prefix.String(), + ValidLifetime: addr.ValidLifetime, + PreferredLifetime: addr.PreferredLifetime, + Scope: addr.Scope, + }) + } return RpcNetworkState{ InterfaceName: networkState.interfaceName, MacAddress: networkState.macAddr.String(), IPv4: networkState.ipv4Addr.String(), IPv6: networkState.ipv6Addr.String(), + IPv6LinkLocal: networkState.ipv6LinkLocal.String(), + IPv4Addresses: networkState.ipv4Addresses, + IPv6Addresses: ipv6Addresses, DHCPLease: networkState.dhcpClient.GetLease(), } } +func rpcGetNetworkSettings() RpcNetworkSettings { + return RpcNetworkSettings{ + IPv4Mode: "dhcp", + IPv6Mode: "slaac", + LLDPMode: "basic", + LLDPTxTLVs: []string{"chassis", "port", "system", "vlan"}, + MDNSMode: "auto", + TimeSyncMode: "ntp_and_http", + } +} + func rpcRenewDHCPLease() error { if networkState == nil { return fmt.Errorf("network state not initialized") diff --git a/ui/dev_device.sh b/ui/dev_device.sh index 650cadd19..2c7b497f0 100755 --- a/ui/dev_device.sh +++ b/ui/dev_device.sh @@ -15,5 +15,15 @@ echo "└─────────────────────── # Set the environment variable and run Vite echo "Starting development server with JetKVM device at: $ip_address" + +# Check if pwd is the current directory of the script +if [ "$(pwd)" != "$(dirname "$0")" ]; then + pushd "$(dirname "$0")" > /dev/null + echo "Changed directory to: $(pwd)" +fi + sleep 1 + JETKVM_PROXY_URL="ws://$ip_address" npx vite dev --mode=device + +popd > /dev/null diff --git a/ui/package-lock.json b/ui/package-lock.json index ebce14885..6e58f39d6 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -18,6 +18,7 @@ "@xterm/addon-webgl": "^0.18.0", "@xterm/xterm": "^5.5.0", "cva": "^1.0.0-beta.1", + "dayjs": "^1.11.13", "eslint-import-resolver-alias": "^1.1.2", "focus-trap-react": "^10.2.3", "framer-motion": "^11.15.0", @@ -2460,6 +2461,11 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/dayjs": { + "version": "1.11.13", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.13.tgz", + "integrity": "sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==" + }, "node_modules/debug": { "version": "4.3.4", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", diff --git a/ui/package.json b/ui/package.json index a248616a6..1683ee832 100644 --- a/ui/package.json +++ b/ui/package.json @@ -28,6 +28,7 @@ "@xterm/addon-webgl": "^0.18.0", "@xterm/xterm": "^5.5.0", "cva": "^1.0.0-beta.1", + "dayjs": "^1.11.13", "eslint-import-resolver-alias": "^1.1.2", "focus-trap-react": "^10.2.3", "framer-motion": "^11.15.0", diff --git a/ui/src/hooks/stores.ts b/ui/src/hooks/stores.ts index 0fa4121bf..f20eee256 100644 --- a/ui/src/hooks/stores.ts +++ b/ui/src/hooks/stores.ts @@ -663,6 +663,93 @@ export const useDeviceStore = create(set => ({ setSystemVersion: version => set({ systemVersion: version }), })); +export interface DhcpLease { + ip?: string; + netmask?: string; + broadcast?: string; + ttl?: string; + mtu?: string; + hostname?: string; + domain?: string; + bootp_next_server?: string; + bootp_server_name?: string; + bootp_file?: string; + timezone?: string; + routers?: string[]; + dns?: string[]; + ntp_servers?: string[]; + lpr_servers?: string[]; + _time_servers?: string[]; + _name_servers?: string[]; + _log_servers?: string[]; + _cookie_servers?: string[]; + _wins_servers?: string[]; + _swap_server?: string; + boot_size?: string; + root_path?: string; + lease?: string; + lease_expiry?: Date; + dhcp_type?: string; + server_id?: string; + message?: string; + tftp?: string; + bootfile?: string; +} + +export interface IPv6Address { + address: string; + prefix: string; + valid_lifetime: string; + preferred_lifetime: string; + scope: string; +} + +export interface NetworkState { + interface_name?: string; + mac_address?: string; + ipv4?: string; + ipv4_addresses?: string[]; + ipv6?: string; + ipv6_addresses?: IPv6Address[]; + ipv6_link_local?: string; + dhcp_lease?: DhcpLease; + + setNetworkState: (state: NetworkState) => void; + setDhcpLease: (lease: NetworkState["dhcp_lease"]) => void; + setDhcpLeaseExpiry: (expiry: Date) => void; +} + + +export type IPv6Mode = "disabled" | "slaac" | "dhcpv6" | "slaac_and_dhcpv6" | "static" | "link_local" | "unknown"; +export type IPv4Mode = "disabled" | "static" | "dhcp" | "unknown"; +export type LLDPMode = "disabled" | "basic" | "all" | "unknown"; +export type mDNSMode = "disabled" | "auto" | "ipv4_only" | "ipv6_only" | "unknown"; +export type TimeSyncMode = "ntp_only" | "ntp_and_http" | "http_only" | "custom" | "unknown"; + +export interface NetworkSettings { + ipv4_mode: IPv4Mode; + ipv6_mode: IPv6Mode; + lldp_mode: LLDPMode; + lldp_tx_tlvs: string[]; + mdns_mode: mDNSMode; + time_sync_mode: TimeSyncMode; +} + +export const useNetworkStateStore = create((set, get) => ({ + setNetworkState: (state: NetworkState) => set(state), + setDhcpLease: (lease: NetworkState["dhcp_lease"]) => set({ dhcp_lease: lease }), + setDhcpLeaseExpiry: (expiry: Date) => { + const lease = get().dhcp_lease; + if (!lease) { + console.warn("No lease found"); + return; + } + + lease.lease_expiry = expiry.toISOString(); + set({ dhcp_lease: lease }); + } +})); + export interface KeySequenceStep { keys: string[]; modifiers: string[]; @@ -767,8 +854,8 @@ export const useMacrosStore = create((set, get) => ({ for (let i = 0; i < macro.steps.length; i++) { const step = macro.steps[i]; if (step.keys && step.keys.length > MAX_KEYS_PER_STEP) { - console.error(`Cannot save: macro "${macro.name}" step ${i+1} exceeds maximum of ${MAX_KEYS_PER_STEP} keys`); - throw new Error(`Cannot save: macro "${macro.name}" step ${i+1} exceeds maximum of ${MAX_KEYS_PER_STEP} keys`); + console.error(`Cannot save: macro "${macro.name}" step ${i + 1} exceeds maximum of ${MAX_KEYS_PER_STEP} keys`); + throw new Error(`Cannot save: macro "${macro.name}" step ${i + 1} exceeds maximum of ${MAX_KEYS_PER_STEP} keys`); } } } diff --git a/ui/src/routes/devices.$id.settings.network.tsx b/ui/src/routes/devices.$id.settings.network.tsx index c7ade5fcf..c1e8468b7 100644 --- a/ui/src/routes/devices.$id.settings.network.tsx +++ b/ui/src/routes/devices.$id.settings.network.tsx @@ -1,62 +1,79 @@ import { useCallback, useEffect, useState } from "react"; -import { SettingsPageHeader } from "../components/SettingsPageheader"; import { SelectMenuBasic } from "../components/SelectMenuBasic"; +import { SettingsPageHeader } from "../components/SettingsPageheader"; -import { SettingsItem } from "./devices.$id.settings"; +import { IPv4Mode, IPv6Mode, LLDPMode, mDNSMode, NetworkSettings, NetworkState, TimeSyncMode, useNetworkStateStore } from "@/hooks/stores"; import { useJsonRpc } from "@/hooks/useJsonRpc"; -import { Button } from "@components/Button"; import notifications from "@/notifications"; +import { Button } from "@components/Button"; +import { GridCard } from "@components/Card"; +import InputField from "@components/InputField"; +import { SettingsItem } from "./devices.$id.settings"; + +import dayjs from 'dayjs'; +import relativeTime from 'dayjs/plugin/relativeTime'; -interface DhcpLease { - ip?: string; - netmask?: string; - broadcast?: string; - ttl?: string; - mtu?: string; - hostname?: string; - domain?: string; - bootp_next_server?: string; - bootp_server_name?: string; - bootp_file?: string; - timezone?: string; - routers?: string[]; - dns?: string[]; - ntp_servers?: string[]; - lpr_servers?: string[]; - _time_servers?: string[]; - _name_servers?: string[]; - _log_servers?: string[]; - _cookie_servers?: string[]; - _wins_servers?: string[]; - _swap_server?: string; - boot_size?: string; - root_path?: string; - lease?: string; - dhcp_type?: string; - server_id?: string; - message?: string; - tftp?: string; - bootfile?: string; +dayjs.extend(relativeTime); + +const defaultNetworkSettings: NetworkSettings = { + ipv4_mode: "unknown", + ipv6_mode: "unknown", + lldp_mode: "unknown", + lldp_tx_tlvs: [], + mdns_mode: "unknown", + time_sync_mode: "unknown", } +export function LifeTimeLabel({ lifetime }: { lifetime: string }) { + if (lifetime == "") { + return N/A; + } + + const [remaining, setRemaining] = useState(null); -interface NetworkState { - interface_name?: string; - mac_address?: string; - ipv4?: string; - ipv6?: string; - dhcp_lease?: DhcpLease; + useEffect(() => { + setRemaining(dayjs(lifetime).fromNow()); + + const interval = setInterval(() => { + setRemaining(dayjs(lifetime).fromNow()); + }, 1000 * 30); + return () => clearInterval(interval); + }, [lifetime]); + + return <> + {dayjs(lifetime).format()} + {remaining && <> + {" "} + ({remaining}) + + } + } export default function SettingsNetworkRoute() { const [send] = useJsonRpc(); - const [networkState, setNetworkState] = useState(null); + const [networkState, setNetworkState] = useNetworkStateStore(state => [state, state.setNetworkState]); + + 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; + setNetworkSettings(resp.result as NetworkSettings); + setNetworkSettingsLoaded(true); + }); + }, [send]); const getNetworkState = useCallback(() => { send("getNetworkState", {}, resp => { if ("error" in resp) return; + console.log(resp.result); setNetworkState(resp.result as NetworkState); }); }, [send]); @@ -74,7 +91,37 @@ export default function SettingsNetworkRoute() { useEffect(() => { getNetworkState(); - }, [getNetworkState]); + getNetworkSettings(); + }, [getNetworkState, getNetworkSettings]); + + const handleIpv4ModeChange = (value: IPv4Mode | string) => { + setNetworkSettings({ ...networkSettings, ipv4_mode: value as IPv4Mode }); + }; + + const handleIpv6ModeChange = (value: IPv6Mode | string) => { + setNetworkSettings({ ...networkSettings, ipv6_mode: value as IPv6Mode }); + }; + + const handleLldpModeChange = (value: LLDPMode | string) => { + setNetworkSettings({ ...networkSettings, lldp_mode: value as LLDPMode }); + }; + + const handleLldpTxTlvsChange = (value: string[]) => { + setNetworkSettings({ ...networkSettings, lldp_tx_tlvs: value }); + }; + + const handleMdnsModeChange = (value: mDNSMode | string) => { + setNetworkSettings({ ...networkSettings, mdns_mode: value as mDNSMode }); + }; + + const handleTimeSyncModeChange = (value: TimeSyncMode | string) => { + setNetworkSettings({ ...networkSettings, time_sync_mode: value as TimeSyncMode }); + }; + + const filterUnknown = useCallback((options: { value: string; label: string; }[]) => { + if (!networkSettingsLoaded) return options; + return options.filter(option => option.value !== "unknown"); + }, [networkSettingsLoaded]); return (
@@ -84,52 +131,263 @@ export default function SettingsNetworkRoute() { />
} + > + + {networkState?.mac_address} + + +
+
+ {networkState?.ipv4} + <> + Hostname for the device +
+ + Leave blank for default + + } - /> + > + { + console.log(e.target.value); + }} + /> +
{networkState?.ipv6}} - /> + title="Domain" + description={ + <> + Domain for the device +
+ + Leave blank to use DHCP provided domain, if there is no domain, use local + + + } + > + { + console.log(e.target.value); + }} + /> +
{networkState?.mac_address}} - /> + title="IPv4 Mode" + description="Configure the IPv4 mode" + > + handleIpv4ModeChange(e.target.value)} + disabled={!networkSettingsLoaded} + options={filterUnknown([ + { value: "dhcp", label: "DHCP" }, + // { value: "static", label: "Static" }, + ])} + /> + + {networkState?.dhcp_lease && ( + +
+
+
+

+ Current DHCP Lease +

+
+
    + {networkState?.dhcp_lease?.ip &&
  • IP: {networkState?.dhcp_lease?.ip}
  • } + {networkState?.dhcp_lease?.netmask &&
  • Subnet: {networkState?.dhcp_lease?.netmask}
  • } + {networkState?.dhcp_lease?.broadcast &&
  • Broadcast: {networkState?.dhcp_lease?.broadcast}
  • } + {networkState?.dhcp_lease?.ttl &&
  • TTL: {networkState?.dhcp_lease?.ttl}
  • } + {networkState?.dhcp_lease?.mtu &&
  • MTU: {networkState?.dhcp_lease?.mtu}
  • } + {networkState?.dhcp_lease?.hostname &&
  • Hostname: {networkState?.dhcp_lease?.hostname}
  • } + {networkState?.dhcp_lease?.domain &&
  • Domain: {networkState?.dhcp_lease?.domain}
  • } + {networkState?.dhcp_lease?.routers &&
  • Gateway: {networkState?.dhcp_lease?.routers.join(", ")}
  • } + {networkState?.dhcp_lease?.dns &&
  • DNS: {networkState?.dhcp_lease?.dns.join(", ")}
  • } + {networkState?.dhcp_lease?.ntp_servers &&
  • NTP Servers: {networkState?.dhcp_lease?.ntp_servers.join(", ")}
  • } + {networkState?.dhcp_lease?.server_id &&
  • Server ID: {networkState?.dhcp_lease?.server_id}
  • } + {networkState?.dhcp_lease?.bootp_next_server &&
  • BootP Next Server: {networkState?.dhcp_lease?.bootp_next_server}
  • } + {networkState?.dhcp_lease?.bootp_server_name &&
  • BootP Server Name: {networkState?.dhcp_lease?.bootp_server_name}
  • } + {networkState?.dhcp_lease?.bootp_file &&
  • Boot File: {networkState?.dhcp_lease?.bootp_file}
  • } + {networkState?.dhcp_lease?.lease_expiry &&
  • + Lease Expiry: +
  • } + {/* {JSON.stringify(networkState?.dhcp_lease)} */} +
+
+
+
+
+
+
+
+
+ )} +
+
+ + handleIpv6ModeChange(e.target.value)} + disabled={!networkSettingsLoaded} + options={filterUnknown([ + // { value: "disabled", label: "Disabled" }, + { value: "slaac", label: "SLAAC" }, + // { value: "dhcpv6", label: "DHCPv6" }, + // { value: "slaac_and_dhcpv6", label: "SLAAC and DHCPv6" }, + // { value: "static", label: "Static" }, + // { value: "link_local", label: "Link-local only" }, + ])} + /> + + {networkState?.ipv6_addresses && ( + +
+
+
+

+ IPv6 Information +

+
+
+

+ IPv6 Link-local +

+

+ {networkState?.ipv6_link_local} +

+
+
+

+ IPv6 Addresses +

+
    + {networkState?.ipv6_addresses && networkState?.ipv6_addresses.map(addr => ( +
  • + {addr.address} + {addr.valid_lifetime && <> +
    + - valid_lft: {" "} + + + + } + {addr.preferred_lifetime && <> +
    + - pref_lft: {" "} + + + + } +
  • + ))} +
+
+
+
+
+
+
+ )} +
+
+ + handleLldpModeChange(e.target.value)} + disabled={!networkSettingsLoaded} + options={filterUnknown([ + { value: "disabled", label: "Disabled" }, + { value: "basic", label: "Basic" }, + { value: "all", label: "All" }, + ])} + /> +
-
    - {networkState?.dhcp_lease?.ip &&
  • IP: {networkState?.dhcp_lease?.ip}
  • } - {networkState?.dhcp_lease?.netmask &&
  • Subnet: {networkState?.dhcp_lease?.netmask}
  • } - {networkState?.dhcp_lease?.broadcast &&
  • Broadcast: {networkState?.dhcp_lease?.broadcast}
  • } - {networkState?.dhcp_lease?.ttl &&
  • TTL: {networkState?.dhcp_lease?.ttl}
  • } - {networkState?.dhcp_lease?.mtu &&
  • MTU: {networkState?.dhcp_lease?.mtu}
  • } - {networkState?.dhcp_lease?.hostname &&
  • Hostname: {networkState?.dhcp_lease?.hostname}
  • } - {networkState?.dhcp_lease?.domain &&
  • Domain: {networkState?.dhcp_lease?.domain}
  • } - {networkState?.dhcp_lease?.routers &&
  • Gateway: {networkState?.dhcp_lease?.routers.join(", ")}
  • } - {networkState?.dhcp_lease?.dns &&
  • DNS: {networkState?.dhcp_lease?.dns.join(", ")}
  • } - {networkState?.dhcp_lease?.ntp_servers &&
  • NTP Servers: {networkState?.dhcp_lease?.ntp_servers.join(", ")}
  • } -
- } + title="mDNS" + description="Control mDNS (multicast DNS) operational mode" > -
+
+ + handleTimeSyncModeChange(e.target.value)} + disabled={!networkSettingsLoaded} + options={filterUnknown([ + { value: "unknown", label: "..." }, + { 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" }, + ])} + /> + +
+
+
); } diff --git a/ui/src/routes/devices.$id.tsx b/ui/src/routes/devices.$id.tsx index 82bb542ad..161f494af 100644 --- a/ui/src/routes/devices.$id.tsx +++ b/ui/src/routes/devices.$id.tsx @@ -20,11 +20,13 @@ import { cx } from "@/cva.config"; import { DeviceSettingsState, HidState, + NetworkState, UpdateState, useDeviceSettingsStore, useDeviceStore, useHidStore, useMountMediaStore, + useNetworkStateStore, User, useRTCStore, useUiStore, @@ -581,6 +583,8 @@ export default function KvmIdRoute() { }); }, 10000); + const setNetworkState = useNetworkStateStore(state => state.setNetworkState); + const setUsbState = useHidStore(state => state.setUsbState); const setHdmiState = useVideoStore(state => state.setHdmiState); @@ -600,6 +604,11 @@ export default function KvmIdRoute() { setHdmiState(resp.params as Parameters[0]); } + if (resp.method === "networkState") { + console.log("Setting network state", resp.params); + setNetworkState(resp.params as NetworkState); + } + if (resp.method === "otaState") { const otaState = resp.params as UpdateState["otaState"]; setOtaState(otaState); diff --git a/usb.go b/usb.go index 3395db46e..91674c99a 100644 --- a/usb.go +++ b/usb.go @@ -66,6 +66,6 @@ func checkUSBState() { usbState = newState usbLogger.Info().Str("from", usbState).Str("to", newState).Msg("USB state changed") - requestDisplayUpdate() + requestDisplayUpdate(true) triggerUSBStateUpdate() } diff --git a/video.go b/video.go index d74add8a7..6fa77b94a 100644 --- a/video.go +++ b/video.go @@ -43,7 +43,7 @@ func HandleVideoStateMessage(event CtrlResponse) { } lastVideoState = videoState triggerVideoStateUpdate() - requestDisplayUpdate() + requestDisplayUpdate(true) } func rpcGetVideoState() (VideoInputState, error) { diff --git a/webrtc.go b/webrtc.go index 1e093e2c5..5324b23eb 100644 --- a/webrtc.go +++ b/webrtc.go @@ -205,7 +205,7 @@ func newSession(config SessionConfig) (*Session, error) { var actionSessions = 0 func onActiveSessionsChanged() { - requestDisplayUpdate() + requestDisplayUpdate(true) } func onFirstSessionConnected() { From e470371d23e021e7a3a4c83edc70b00dd3081911 Mon Sep 17 00:00:00 2001 From: Siyuan Miao Date: Mon, 14 Apr 2025 06:33:14 +0200 Subject: [PATCH 11/26] feat(timesync): query servers in parallel --- .golangci.yml | 55 +++++---- dev_deploy.sh | 2 +- internal/timesync/http.go | 111 +++++++++++++----- internal/timesync/ntp.go | 63 ++++++---- timesync.go | 18 ++- .../routes/devices.$id.settings.network.tsx | 5 +- 6 files changed, 177 insertions(+), 77 deletions(-) diff --git a/.golangci.yml b/.golangci.yml index 95a1cb840..ccd3c1afd 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -1,22 +1,37 @@ ---- +version: "2" linters: enable: - - forbidigo - - goimports - - misspell - # - revive - - whitespace - -issues: - exclude-rules: - - path: _test.go - linters: - - errcheck - -linters-settings: - forbidigo: - forbid: - - p: ^fmt\.Print.*$ - msg: Do not commit print statements. Use logger package. - - p: ^log\.(Fatal|Panic|Print)(f|ln)?.*$ - msg: Do not commit log statements. Use logger package. + - forbidigo + - misspell + - whitespace + settings: + forbidigo: + forbid: + - pattern: ^fmt\.Print.*$ + msg: Do not commit print statements. Use logger package. + - pattern: ^log\.(Fatal|Panic|Print)(f|ln)?.*$ + msg: Do not commit log statements. Use logger package. + exclusions: + generated: lax + presets: + - comments + - common-false-positives + - legacy + - std-error-handling + rules: + - linters: + - errcheck + path: _test.go + paths: + - third_party$ + - builtin$ + - examples$ +formatters: + enable: + - goimports + exclusions: + generated: lax + paths: + - third_party$ + - builtin$ + - examples$ diff --git a/dev_deploy.sh b/dev_deploy.sh index ca627cd80..30fb3c72f 100755 --- a/dev_deploy.sh +++ b/dev_deploy.sh @@ -91,7 +91,7 @@ cd "${REMOTE_PATH}" chmod +x jetkvm_app_debug # Run the application in the background -PION_LOG_TRACE=jetkvm,cloud,websocket,native ./jetkvm_app_debug +PION_LOG_TRACE=jetkvm,cloud,websocket,native,jsonrpc ./jetkvm_app_debug EOF echo "Deployment complete." diff --git a/internal/timesync/http.go b/internal/timesync/http.go index a6be68cbf..14b4633f3 100644 --- a/internal/timesync/http.go +++ b/internal/timesync/http.go @@ -1,18 +1,98 @@ package timesync import ( + "context" + "errors" + "math/rand" "net/http" "time" ) +func (t *TimeSync) queryAllHttpTime() (now *time.Time) { + chunkSize := 4 + httpUrls := t.httpUrls + + // shuffle the http urls to avoid always querying the same servers + rand.Shuffle(len(httpUrls), func(i, j int) { httpUrls[i], httpUrls[j] = httpUrls[j], httpUrls[i] }) + + for i := 0; i < len(httpUrls); i += chunkSize { + chunk := httpUrls[i:min(i+chunkSize, len(httpUrls))] + results := t.queryMultipleHttp(chunk, timeSyncTimeout) + if results != nil { + return results + } + } + + return nil +} + +func (t *TimeSync) queryMultipleHttp(urls []string, timeout time.Duration) (now *time.Time) { + results := make(chan *time.Time, len(urls)) + + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + + for _, url := range urls { + go func(url string) { + scopedLogger := t.l.With(). + Str("http_url", url). + Logger() + + startTime := time.Now() + now, err, response := queryHttpTime( + ctx, + url, + timeout, + ) + duration := time.Since(startTime) + + var status int + if response != nil { + status = response.StatusCode + } + + if err == nil { + requestId := response.Header.Get("X-Request-Id") + if requestId != "" { + requestId = response.Header.Get("X-Msedge-Ref") + } + if requestId == "" { + requestId = response.Header.Get("Cf-Ray") + } + scopedLogger.Info(). + Str("time", now.Format(time.RFC3339)). + Int("status", status). + Str("request_id", requestId). + Str("time_taken", duration.String()). + Msg("HTTP server returned time") + + cancel() + results <- now + } else if !errors.Is(err, context.Canceled) { + scopedLogger.Warn(). + Str("error", err.Error()). + Int("status", status). + Msg("failed to query HTTP server") + } + }(url) + } + + return <-results +} + func queryHttpTime( + ctx context.Context, url string, timeout time.Duration, ) (now *time.Time, err error, response *http.Response) { client := http.Client{ Timeout: timeout, } - resp, err := client.Head(url) + req, err := http.NewRequestWithContext(ctx, "GET", url, nil) + if err != nil { + return nil, err, nil + } + resp, err := client.Do(req) if err != nil { return nil, err, nil } @@ -23,32 +103,3 @@ func queryHttpTime( } return &parsedTime, nil, resp } - -func (t *TimeSync) queryAllHttpTime() (now *time.Time) { - for _, url := range t.httpUrls { - now, err, response := queryHttpTime(url, timeSyncTimeout) - - var status string - if response != nil { - status = response.Status - } - - scopedLogger := t.l.With(). - Str("http_url", url). - Str("status", status). - Logger() - - if err == nil { - scopedLogger.Info(). - Str("time", now.Format(time.RFC3339)). - Msg("HTTP server returned time") - return now - } else { - scopedLogger.Error(). - Str("error", err.Error()). - Msg("failed to query HTTP server") - } - } - - return nil -} diff --git a/internal/timesync/ntp.go b/internal/timesync/ntp.go index 9bc9812ff..eb15ff9d6 100644 --- a/internal/timesync/ntp.go +++ b/internal/timesync/ntp.go @@ -1,38 +1,61 @@ package timesync import ( + "math/rand/v2" "time" "github.com/beevik/ntp" ) func (t *TimeSync) queryNetworkTime() (now *time.Time) { - for _, server := range t.ntpServers { - now, err, response := queryNtpServer(server, timeSyncTimeout) - - scopedLogger := t.l.With(). - Str("server", server). - Logger() - - if err == nil { - scopedLogger.Info(). - Str("time", now.Format(time.RFC3339)). - Str("reference", response.ReferenceString()). - Str("rtt", response.RTT.String()). - Str("clockOffset", response.ClockOffset.String()). - Uint8("stratum", response.Stratum). - Msg("NTP server returned time") - return now - } else { - scopedLogger.Error(). - Str("error", err.Error()). - Msg("failed to query NTP server") + chunkSize := 4 + ntpServers := t.ntpServers + + // shuffle the ntp servers to avoid always querying the same servers + rand.Shuffle(len(ntpServers), func(i, j int) { ntpServers[i], ntpServers[j] = ntpServers[j], ntpServers[i] }) + + for i := 0; i < len(ntpServers); i += chunkSize { + chunk := ntpServers[i:min(i+chunkSize, len(ntpServers))] + results := t.queryMultipleNTP(chunk, timeSyncTimeout) + if results != nil { + return results } } return nil } +func (t *TimeSync) queryMultipleNTP(servers []string, timeout time.Duration) (now *time.Time) { + results := make(chan *time.Time, len(servers)) + + for _, server := range servers { + go func(server string) { + scopedLogger := t.l.With(). + Str("server", server). + Logger() + + now, err, response := queryNtpServer(server, timeout) + + if err == nil { + scopedLogger.Info(). + Str("time", now.Format(time.RFC3339)). + Str("reference", response.ReferenceString()). + Str("rtt", response.RTT.String()). + Str("clockOffset", response.ClockOffset.String()). + Uint8("stratum", response.Stratum). + Msg("NTP server returned time") + results <- now + } else { + scopedLogger.Warn(). + Str("error", err.Error()). + Msg("failed to query NTP server") + } + }(server) + } + + return <-results +} + func queryNtpServer(server string, timeout time.Duration) (now *time.Time, err error, response *ntp.Response) { resp, err := ntp.QueryWithOptions(server, ntp.QueryOptions{Timeout: timeout}) if err != nil { diff --git a/timesync.go b/timesync.go index e31f9080f..ed95ffda7 100644 --- a/timesync.go +++ b/timesync.go @@ -10,12 +10,24 @@ import ( var ( timeSync *timesync.TimeSync defaultNTPServers = []string{ - "time.cloudflare.com", "time.apple.com", + "time.aws.com", + "time.windows.com", + "time.google.com", + "162.159.200.123", // time.cloudflare.com + "0.pool.ntp.org", + "1.pool.ntp.org", + "2.pool.ntp.org", + "3.pool.ntp.org", } defaultHTTPUrls = []string{ - "http://apple.com", - "http://cloudflare.com", + "http://www.gstatic.com/generate_204", + "http://cp.cloudflare.com/", + "http://edge-http.microsoft.com/captiveportal/generate_204", + // Firefox, Apple, and Microsoft have inconsistent results, so we don't use it + // "http://detectportal.firefox.com/", + // "http://www.apple.com/library/test/success.html", + // "http://www.msftconnecttest.com/connecttest.txt", } builtTimestamp string ) diff --git a/ui/src/routes/devices.$id.settings.network.tsx b/ui/src/routes/devices.$id.settings.network.tsx index c1e8468b7..c5f4fe4a7 100644 --- a/ui/src/routes/devices.$id.settings.network.tsx +++ b/ui/src/routes/devices.$id.settings.network.tsx @@ -84,10 +84,9 @@ export default function SettingsNetworkRoute() { notifications.error("Failed to renew lease: " + resp.error.message); } else { notifications.success("DHCP lease renewed"); - getNetworkState(); } }); - }, [send, getNetworkState]); + }, [send]); useEffect(() => { getNetworkState(); @@ -320,7 +319,7 @@ export default function SettingsNetworkRoute() { )}
-
+
Date: Mon, 14 Apr 2025 18:03:23 +0200 Subject: [PATCH 12/26] refactor(network): move to internal/network package --- config.go | 48 ++-- go.mod | 1 + go.sum | 2 + internal/logging/utils.go | 28 +++ internal/network/config.go | 40 +++ internal/network/dhcp.go | 11 + internal/network/netif.go | 353 ++++++++++++++++++++++++++ internal/network/rpc.go | 127 ++++++++++ internal/network/utils.go | 11 + jsonrpc.go | 7 +- network.go | 496 ++----------------------------------- 11 files changed, 622 insertions(+), 502 deletions(-) create mode 100644 internal/logging/utils.go create mode 100644 internal/network/config.go create mode 100644 internal/network/dhcp.go create mode 100644 internal/network/netif.go create mode 100644 internal/network/rpc.go create mode 100644 internal/network/utils.go diff --git a/config.go b/config.go index ed7477ed9..6cc7a299c 100644 --- a/config.go +++ b/config.go @@ -6,6 +6,7 @@ import ( "os" "sync" + "github.com/jetkvm/kvm/internal/network" "github.com/jetkvm/kvm/internal/usbgadget" ) @@ -73,27 +74,28 @@ func (m *KeyboardMacro) Validate() error { } type Config struct { - CloudURL string `json:"cloud_url"` - CloudAppURL string `json:"cloud_app_url"` - CloudToken string `json:"cloud_token"` - GoogleIdentity string `json:"google_identity"` - JigglerEnabled bool `json:"jiggler_enabled"` - AutoUpdateEnabled bool `json:"auto_update_enabled"` - IncludePreRelease bool `json:"include_pre_release"` - HashedPassword string `json:"hashed_password"` - LocalAuthToken string `json:"local_auth_token"` - LocalAuthMode string `json:"localAuthMode"` //TODO: fix it with migration - WakeOnLanDevices []WakeOnLanDevice `json:"wake_on_lan_devices"` - KeyboardMacros []KeyboardMacro `json:"keyboard_macros"` - EdidString string `json:"hdmi_edid_string"` - ActiveExtension string `json:"active_extension"` - DisplayMaxBrightness int `json:"display_max_brightness"` - DisplayDimAfterSec int `json:"display_dim_after_sec"` - DisplayOffAfterSec int `json:"display_off_after_sec"` - TLSMode string `json:"tls_mode"` // options: "self-signed", "user-defined", "" - UsbConfig *usbgadget.Config `json:"usb_config"` - UsbDevices *usbgadget.Devices `json:"usb_devices"` - DefaultLogLevel string `json:"default_log_level"` + CloudURL string `json:"cloud_url"` + CloudAppURL string `json:"cloud_app_url"` + CloudToken string `json:"cloud_token"` + GoogleIdentity string `json:"google_identity"` + JigglerEnabled bool `json:"jiggler_enabled"` + AutoUpdateEnabled bool `json:"auto_update_enabled"` + IncludePreRelease bool `json:"include_pre_release"` + HashedPassword string `json:"hashed_password"` + LocalAuthToken string `json:"local_auth_token"` + LocalAuthMode string `json:"localAuthMode"` //TODO: fix it with migration + WakeOnLanDevices []WakeOnLanDevice `json:"wake_on_lan_devices"` + KeyboardMacros []KeyboardMacro `json:"keyboard_macros"` + EdidString string `json:"hdmi_edid_string"` + ActiveExtension string `json:"active_extension"` + DisplayMaxBrightness int `json:"display_max_brightness"` + DisplayDimAfterSec int `json:"display_dim_after_sec"` + DisplayOffAfterSec int `json:"display_off_after_sec"` + TLSMode string `json:"tls_mode"` // options: "self-signed", "user-defined", "" + UsbConfig *usbgadget.Config `json:"usb_config"` + UsbDevices *usbgadget.Devices `json:"usb_devices"` + NetworkConfig *network.NetworkConfig `json:"network_config"` + DefaultLogLevel string `json:"default_log_level"` } const configPath = "/userdata/kvm_config.json" @@ -164,6 +166,10 @@ func LoadConfig() { loadedConfig.UsbDevices = defaultConfig.UsbDevices } + if loadedConfig.NetworkConfig == nil { + loadedConfig.NetworkConfig = defaultConfig.NetworkConfig + } + config = &loadedConfig rootLogger.UpdateLogLevel() diff --git a/go.mod b/go.mod index bc231f23b..6784a596d 100644 --- a/go.mod +++ b/go.mod @@ -45,6 +45,7 @@ require ( github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/validator/v10 v10.26.0 // indirect github.com/goccy/go-json v0.10.5 // indirect + github.com/guregu/null/v6 v6.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/compress v1.17.11 // indirect github.com/klauspost/cpuid/v2 v2.2.10 // indirect diff --git a/go.sum b/go.sum index 018d3a893..3ad832ad6 100644 --- a/go.sum +++ b/go.sum @@ -56,6 +56,8 @@ github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeN github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/guregu/null/v6 v6.0.0 h1:N14VRS+4di81i1PXRiprbQJ9EM9gqBa0+KVMeS/QSjQ= +github.com/guregu/null/v6 v6.0.0/go.mod h1:hrMIhIfrOZeLPZhROSn149tpw2gHkidAqxoXNyeX3iQ= github.com/gwatts/rootcerts v0.0.0-20240401182218-3ab9db955caf h1:JO6ISZIvEUitto5zjQ3/VEnDM5rPbqIFuOhS0U0ByeA= github.com/gwatts/rootcerts v0.0.0-20240401182218-3ab9db955caf/go.mod h1:5Kt9XkWvkGi2OHOq0QsGxebHmhCcqJ8KCbNg/a6+n+g= github.com/hanwen/go-fuse/v2 v2.5.1 h1:OQBE8zVemSocRxA4OaFJbjJ5hlpCmIWbGr7r0M4uoQQ= diff --git a/internal/logging/utils.go b/internal/logging/utils.go new file mode 100644 index 000000000..6d54bc5ce --- /dev/null +++ b/internal/logging/utils.go @@ -0,0 +1,28 @@ +package logging + +import ( + "fmt" + "os" + + "github.com/rs/zerolog" +) + +var defaultLogger = zerolog.New(os.Stdout).Level(zerolog.InfoLevel) + +func ErrorfL(l *zerolog.Logger, format string, err error, args ...interface{}) error { + // TODO: move rootLogger to logging package + if l == nil { + l = &defaultLogger + } + + l.Error().Err(err).Msgf(format, args...) + + if err == nil { + return fmt.Errorf(format, args...) + } + + err_msg := err.Error() + ": %v" + err_args := append(args, err) + + return fmt.Errorf(err_msg, err_args...) +} diff --git a/internal/network/config.go b/internal/network/config.go new file mode 100644 index 000000000..1cfe9bb4b --- /dev/null +++ b/internal/network/config.go @@ -0,0 +1,40 @@ +package network + +import ( + "net" + "time" +) + +type IPv6Address struct { + Address net.IP `json:"address"` + Prefix net.IPNet `json:"prefix"` + ValidLifetime *time.Time `json:"valid_lifetime"` + PreferredLifetime *time.Time `json:"preferred_lifetime"` + 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"` +} diff --git a/internal/network/dhcp.go b/internal/network/dhcp.go new file mode 100644 index 000000000..9e173cc7b --- /dev/null +++ b/internal/network/dhcp.go @@ -0,0 +1,11 @@ +package network + +type DhcpTargetState int + +const ( + DhcpTargetStateDoNothing DhcpTargetState = iota + DhcpTargetStateStart + DhcpTargetStateStop + DhcpTargetStateRenew + DhcpTargetStateRelease +) diff --git a/internal/network/netif.go b/internal/network/netif.go new file mode 100644 index 000000000..8e3370a09 --- /dev/null +++ b/internal/network/netif.go @@ -0,0 +1,353 @@ +package network + +import ( + "net" + "sync" + "time" + + "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 { + interfaceName string + interfaceUp bool + ipv4Addr *net.IP + ipv4Addresses []string + ipv6Addr *net.IP + ipv6Addresses []IPv6Address + ipv6LinkLocal *net.IP + macAddr *net.HardwareAddr + + l *zerolog.Logger + stateLock sync.Mutex + + config *NetworkConfig + dhcpClient *udhcpc.DHCPClient + + onStateChange func(state *NetworkInterfaceState) + onInitialCheck func(state *NetworkInterfaceState) + + checked bool +} + +type NetworkInterfaceOptions struct { + InterfaceName string + DhcpPidFile string + Logger *zerolog.Logger + OnStateChange func(state *NetworkInterfaceState) + OnInitialCheck func(state *NetworkInterfaceState) + OnDhcpLeaseChange func(lease *udhcpc.Lease) + NetworkConfig *NetworkConfig +} + +func NewNetworkInterfaceState(opts *NetworkInterfaceOptions) *NetworkInterfaceState { + l := opts.Logger + s := &NetworkInterfaceState{ + interfaceName: opts.InterfaceName, + stateLock: sync.Mutex{}, + l: l, + onStateChange: opts.OnStateChange, + onInitialCheck: opts.OnInitialCheck, + config: opts.NetworkConfig, + } + + // create the dhcp client + dhcpClient := udhcpc.NewDHCPClient(&udhcpc.DHCPClientOptions{ + InterfaceName: opts.InterfaceName, + PidFile: opts.DhcpPidFile, + Logger: l, + OnLeaseChange: func(lease *udhcpc.Lease) { + _, err := s.update() + if err != nil { + opts.Logger.Error().Err(err).Msg("failed to update network state") + return + } + + opts.OnDhcpLeaseChange(lease) + }, + }) + + s.dhcpClient = dhcpClient + + return s +} + +func (s *NetworkInterfaceState) IsUp() bool { + return s.interfaceUp +} + +func (s *NetworkInterfaceState) HasIPAssigned() bool { + return s.ipv4Addr != nil || s.ipv6Addr != nil +} + +func (s *NetworkInterfaceState) IsOnline() bool { + return s.IsUp() && s.HasIPAssigned() +} + +func (s *NetworkInterfaceState) IPv4() *net.IP { + return s.ipv4Addr +} + +func (s *NetworkInterfaceState) IPv4String() string { + if s.ipv4Addr == nil { + return "..." + } + return s.ipv4Addr.String() +} + +func (s *NetworkInterfaceState) IPv6() *net.IP { + return s.ipv6Addr +} + +func (s *NetworkInterfaceState) IPv6String() string { + if s.ipv6Addr == nil { + return "..." + } + return s.ipv6Addr.String() +} + +func (s *NetworkInterfaceState) MAC() *net.HardwareAddr { + return s.macAddr +} + +func (s *NetworkInterfaceState) MACString() string { + if s.macAddr == nil { + return "" + } + return s.macAddr.String() +} + +func (s *NetworkInterfaceState) update() (DhcpTargetState, error) { + s.stateLock.Lock() + defer s.stateLock.Unlock() + + dhcpTargetState := DhcpTargetStateDoNothing + + iface, err := netlink.LinkByName(s.interfaceName) + if err != nil { + s.l.Error().Err(err).Msg("failed to get interface") + return dhcpTargetState, err + } + + // detect if the interface status changed + var changed bool + attrs := iface.Attrs() + state := attrs.OperState + newInterfaceUp := state == netlink.OperUp + + // check if the interface is coming up + interfaceGoingUp := !s.interfaceUp && newInterfaceUp + interfaceGoingDown := s.interfaceUp && !newInterfaceUp + + if s.interfaceUp != newInterfaceUp { + s.interfaceUp = newInterfaceUp + changed = true + } + + if changed { + if interfaceGoingUp { + s.l.Info().Msg("interface state transitioned to up") + dhcpTargetState = DhcpTargetStateRenew + } else if interfaceGoingDown { + s.l.Info().Msg("interface state transitioned to down") + } + } + + // set the mac address + s.macAddr = &attrs.HardwareAddr + + // get the ip addresses + addrs, err := netlink.AddrList(iface, nl.FAMILY_ALL) + if err != nil { + return dhcpTargetState, logging.ErrorfL(s.l, "failed to get ip addresses", err) + } + + var ( + ipv4Addresses = make([]net.IP, 0) + ipv4AddressesString = make([]string, 0) + ipv6Addresses = make([]IPv6Address, 0) + ipv6AddressesString = make([]string, 0) + ipv6LinkLocal *net.IP + ) + + for _, addr := range addrs { + if addr.IP.To4() != nil { + scopedLogger := s.l.With().Str("ipv4", addr.IP.String()).Logger() + if interfaceGoingDown { + // remove all IPv4 addresses from the interface. + scopedLogger.Info().Msg("state transitioned to down, removing IPv4 address") + err := netlink.AddrDel(iface, &addr) + if err != nil { + scopedLogger.Warn().Err(err).Msg("failed to delete address") + } + // notify the DHCP client to release the lease + dhcpTargetState = DhcpTargetStateRelease + continue + } + ipv4Addresses = append(ipv4Addresses, addr.IP) + ipv4AddressesString = append(ipv4AddressesString, addr.IPNet.String()) + } else if addr.IP.To16() != nil { + scopedLogger := s.l.With().Str("ipv6", addr.IP.String()).Logger() + // check if it's a link local address + if addr.IP.IsLinkLocalUnicast() { + ipv6LinkLocal = &addr.IP + continue + } + + if !addr.IP.IsGlobalUnicast() { + scopedLogger.Trace().Msg("not a global unicast address, skipping") + continue + } + + if interfaceGoingDown { + scopedLogger.Info().Msg("state transitioned to down, removing IPv6 address") + err := netlink.AddrDel(iface, &addr) + if err != nil { + scopedLogger.Warn().Err(err).Msg("failed to delete address") + } + continue + } + ipv6Addresses = append(ipv6Addresses, IPv6Address{ + Address: addr.IP, + Prefix: *addr.IPNet, + ValidLifetime: lifetimeToTime(addr.ValidLft), + PreferredLifetime: lifetimeToTime(addr.PreferedLft), + Scope: addr.Scope, + }) + ipv6AddressesString = append(ipv6AddressesString, addr.IPNet.String()) + } + } + + if len(ipv4Addresses) > 0 { + // compare the addresses to see if there's a change + if s.ipv4Addr == nil || s.ipv4Addr.String() != ipv4Addresses[0].String() { + scopedLogger := s.l.With().Str("ipv4", ipv4Addresses[0].String()).Logger() + if s.ipv4Addr != nil { + scopedLogger.Info(). + Str("old_ipv4", s.ipv4Addr.String()). + Msg("IPv4 address changed") + } else { + scopedLogger.Info().Msg("IPv4 address found") + } + s.ipv4Addr = &ipv4Addresses[0] + changed = true + } + } + s.ipv4Addresses = ipv4AddressesString + + if ipv6LinkLocal != nil { + if s.ipv6LinkLocal == nil || s.ipv6LinkLocal.String() != ipv6LinkLocal.String() { + scopedLogger := s.l.With().Str("ipv6", ipv6LinkLocal.String()).Logger() + if s.ipv6LinkLocal != nil { + scopedLogger.Info(). + Str("old_ipv6", s.ipv6LinkLocal.String()). + Msg("IPv6 link local address changed") + } else { + scopedLogger.Info().Msg("IPv6 link local address found") + } + s.ipv6LinkLocal = ipv6LinkLocal + changed = true + } + } + s.ipv6Addresses = ipv6Addresses + + if len(ipv6Addresses) > 0 { + // compare the addresses to see if there's a change + if s.ipv6Addr == nil || s.ipv6Addr.String() != ipv6Addresses[0].Address.String() { + scopedLogger := s.l.With().Str("ipv6", ipv6Addresses[0].Address.String()).Logger() + if s.ipv6Addr != nil { + scopedLogger.Info(). + Str("old_ipv6", s.ipv6Addr.String()). + Msg("IPv6 address changed") + } else { + scopedLogger.Info().Msg("IPv6 address found") + } + s.ipv6Addr = &ipv6Addresses[0].Address + changed = true + } + } + + // if it's the initial check, we'll set changed to false + initialCheck := !s.checked + if initialCheck { + s.checked = true + changed = false + } + + if initialCheck { + s.onInitialCheck(s) + } else if changed { + s.onStateChange(s) + } + + return dhcpTargetState, nil +} + +func (s *NetworkInterfaceState) CheckAndUpdateDhcp() error { + dhcpTargetState, err := s.update() + if err != nil { + return logging.ErrorfL(s.l, "failed to update network state", err) + } + + switch dhcpTargetState { + case DhcpTargetStateRenew: + s.l.Info().Msg("renewing DHCP lease") + _ = s.dhcpClient.Renew() + case DhcpTargetStateRelease: + s.l.Info().Msg("releasing DHCP lease") + _ = s.dhcpClient.Release() + case DhcpTargetStateStart: + s.l.Warn().Msg("dhcpTargetStateStart not implemented") + case DhcpTargetStateStop: + s.l.Warn().Msg("dhcpTargetStateStop not implemented") + } + + return nil +} + +func (s *NetworkInterfaceState) 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 + } + + // 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 +} diff --git a/internal/network/rpc.go b/internal/network/rpc.go new file mode 100644 index 000000000..afdcbc0da --- /dev/null +++ b/internal/network/rpc.go @@ -0,0 +1,127 @@ +package network + +import ( + "fmt" + "time" + + "github.com/guregu/null/v6" + "github.com/jetkvm/kvm/internal/udhcpc" +) + +type RpcIPv6Address struct { + Address string `json:"address"` + ValidLifetime *time.Time `json:"valid_lifetime,omitempty"` + PreferredLifetime *time.Time `json:"preferred_lifetime,omitempty"` + Scope int `json:"scope"` +} + +type RpcNetworkState struct { + InterfaceName string `json:"interface_name"` + MacAddress string `json:"mac_address"` + IPv4 string `json:"ipv4,omitempty"` + IPv6 string `json:"ipv6,omitempty"` + IPv6LinkLocal string `json:"ipv6_link_local,omitempty"` + IPv4Addresses []string `json:"ipv4_addresses,omitempty"` + IPv6Addresses []RpcIPv6Address `json:"ipv6_addresses,omitempty"` + DHCPLease *udhcpc.Lease `json:"dhcp_lease,omitempty"` +} + +type RpcNetworkSettings struct { + 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"` +} + +func (s *NetworkInterfaceState) RpcGetNetworkState() RpcNetworkState { + ipv6Addresses := make([]RpcIPv6Address, 0) + for _, addr := range s.ipv6Addresses { + ipv6Addresses = append(ipv6Addresses, RpcIPv6Address{ + Address: addr.Prefix.String(), + ValidLifetime: addr.ValidLifetime, + PreferredLifetime: addr.PreferredLifetime, + Scope: addr.Scope, + }) + } + return RpcNetworkState{ + InterfaceName: s.interfaceName, + MacAddress: s.macAddr.String(), + IPv4: s.ipv4Addr.String(), + IPv6: s.ipv6Addr.String(), + IPv6LinkLocal: s.ipv6LinkLocal.String(), + IPv4Addresses: s.ipv4Addresses, + IPv6Addresses: ipv6Addresses, + DHCPLease: s.dhcpClient.GetLease(), + } +} + +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), + } +} + +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 + } + + if !settings.Domain.IsZero() { + changeset["domain"] = settings.Domain.String + currentSettings.Domain = settings.Domain.String + } + + 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 + } + + return nil +} + +func (s *NetworkInterfaceState) RpcRenewDHCPLease() error { + if s.dhcpClient == nil { + return fmt.Errorf("dhcp client not initialized") + } + + return s.dhcpClient.Renew() +} diff --git a/internal/network/utils.go b/internal/network/utils.go new file mode 100644 index 000000000..0d02e19eb --- /dev/null +++ b/internal/network/utils.go @@ -0,0 +1,11 @@ +package network + +import "time" + +func lifetimeToTime(lifetime int) *time.Time { + if lifetime == 0 { + return nil + } + t := time.Now().Add(time.Duration(lifetime) * time.Second) + return &t +} diff --git a/jsonrpc.go b/jsonrpc.go index d39fdb1dc..9dd365ff3 100644 --- a/jsonrpc.go +++ b/jsonrpc.go @@ -960,9 +960,10 @@ var rpcHandlers = map[string]RPCHandler{ "getDeviceID": {Func: rpcGetDeviceID}, "deregisterDevice": {Func: rpcDeregisterDevice}, "getCloudState": {Func: rpcGetCloudState}, - "getNetworkState": {Func: rpcGetNetworkState}, - "getNetworkSettings": {Func: rpcGetNetworkSettings}, - "renewDHCPLease": {Func: rpcRenewDHCPLease}, + "getNetworkState": {Func: networkState.RpcGetNetworkState}, + "getNetworkSettings": {Func: networkState.RpcGetNetworkSettings}, + "setNetworkSettings": {Func: networkState.RpcSetNetworkSettings, Params: []string{"settings"}}, + "renewDHCPLease": {Func: networkState.RpcRenewDHCPLease}, "keyboardReport": {Func: rpcKeyboardReport, Params: []string{"modifier", "keys"}}, "absMouseReport": {Func: rpcAbsMouseReport, Params: []string{"x", "y", "buttons"}}, "relMouseReport": {Func: rpcRelMouseReport, Params: []string{"dx", "dy", "buttons"}}, diff --git a/network.go b/network.go index 5ce9861da..4e1d42b43 100644 --- a/network.go +++ b/network.go @@ -1,501 +1,41 @@ package kvm import ( - "fmt" - "net" "os" - "sync" - "time" - "github.com/Masterminds/semver/v3" + "github.com/jetkvm/kvm/internal/network" "github.com/jetkvm/kvm/internal/udhcpc" - "github.com/rs/zerolog" - - "github.com/vishvananda/netlink" - "github.com/vishvananda/netlink/nl" -) - -var ( - networkState *NetworkInterfaceState ) -type DhcpTargetState int - const ( - DhcpTargetStateDoNothing DhcpTargetState = iota - DhcpTargetStateStart - DhcpTargetStateStop - DhcpTargetStateRenew - DhcpTargetStateRelease + NetIfName = "eth0" ) -type IPv6Address struct { - Address net.IP `json:"address"` - Prefix net.IPNet `json:"prefix"` - ValidLifetime *time.Time `json:"valid_lifetime"` - PreferredLifetime *time.Time `json:"preferred_lifetime"` - Scope int `json:"scope"` -} - -type NetworkInterfaceState struct { - interfaceName string - interfaceUp bool - ipv4Addr *net.IP - ipv4Addresses []string - ipv6Addr *net.IP - ipv6Addresses []IPv6Address - ipv6LinkLocal *net.IP - macAddr *net.HardwareAddr - - l *zerolog.Logger - stateLock sync.Mutex - - dhcpClient *udhcpc.DHCPClient - - onStateChange func(state *NetworkInterfaceState) - onInitialCheck func(state *NetworkInterfaceState) - - checked bool -} - -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"` -} - -type RpcIPv6Address struct { - Address string `json:"address"` - ValidLifetime *time.Time `json:"valid_lifetime,omitempty"` - PreferredLifetime *time.Time `json:"preferred_lifetime,omitempty"` - Scope int `json:"scope"` -} - -type RpcNetworkState struct { - InterfaceName string `json:"interface_name"` - MacAddress string `json:"mac_address"` - IPv4 string `json:"ipv4,omitempty"` - IPv6 string `json:"ipv6,omitempty"` - IPv6LinkLocal string `json:"ipv6_link_local,omitempty"` - IPv4Addresses []string `json:"ipv4_addresses,omitempty"` - IPv6Addresses []RpcIPv6Address `json:"ipv6_addresses,omitempty"` - DHCPLease *udhcpc.Lease `json:"dhcp_lease,omitempty"` -} - -type RpcNetworkSettings struct { - IPv4Mode string `json:"ipv4_mode,omitempty"` - IPv6Mode string `json:"ipv6_mode,omitempty"` - LLDPMode string `json:"lldp_mode,omitempty"` - LLDPTxTLVs []string `json:"lldp_tx_tlvs,omitempty"` - MDNSMode string `json:"mdns_mode,omitempty"` - TimeSyncMode string `json:"time_sync_mode,omitempty"` -} - -func lifetimeToTime(lifetime int) *time.Time { - if lifetime == 0 { - return nil - } - t := time.Now().Add(time.Duration(lifetime) * time.Second) - return &t -} - -func (s *NetworkInterfaceState) IsUp() bool { - return s.interfaceUp -} - -func (s *NetworkInterfaceState) HasIPAssigned() bool { - return s.ipv4Addr != nil || s.ipv6Addr != nil -} - -func (s *NetworkInterfaceState) IsOnline() bool { - return s.IsUp() && s.HasIPAssigned() -} - -func (s *NetworkInterfaceState) IPv4() *net.IP { - return s.ipv4Addr -} - -func (s *NetworkInterfaceState) IPv4String() string { - if s.ipv4Addr == nil { - return "..." - } - return s.ipv4Addr.String() -} - -func (s *NetworkInterfaceState) IPv6() *net.IP { - return s.ipv6Addr -} - -func (s *NetworkInterfaceState) IPv6String() string { - if s.ipv6Addr == nil { - return "..." - } - return s.ipv6Addr.String() -} - -func (s *NetworkInterfaceState) MAC() *net.HardwareAddr { - return s.macAddr -} - -func (s *NetworkInterfaceState) MACString() string { - if s.macAddr == nil { - return "" - } - return s.macAddr.String() -} - -const ( - // TODO: add support for multiple interfaces - NetIfName = "eth0" +var ( + networkState *network.NetworkInterfaceState ) -func NewNetworkInterfaceState(ifname string) *NetworkInterfaceState { - logger := networkLogger.With().Str("interface", ifname).Logger() +func initNetwork() { + ensureConfigLoaded() - s := &NetworkInterfaceState{ - interfaceName: ifname, - stateLock: sync.Mutex{}, - l: &logger, - onStateChange: func(state *NetworkInterfaceState) { - go func() { - waitCtrlClientConnected() - requestDisplayUpdate(true) - }() + networkState = network.NewNetworkInterfaceState(&network.NetworkInterfaceOptions{ + InterfaceName: NetIfName, + NetworkConfig: config.NetworkConfig, + Logger: networkLogger, + OnStateChange: func(state *network.NetworkInterfaceState) { + waitCtrlAndRequestDisplayUpdate(true) }, - onInitialCheck: func(state *NetworkInterfaceState) { - go func() { - waitCtrlClientConnected() - requestDisplayUpdate(true) - }() + OnInitialCheck: func(state *network.NetworkInterfaceState) { + waitCtrlAndRequestDisplayUpdate(true) }, - } - - // use a pid file for udhcpc if the system version is 0.2.4 or higher - dhcpPidFile := "" - systemVersionLocal, _, _ := GetLocalVersion() - if systemVersionLocal != nil && - systemVersionLocal.Compare(semver.MustParse("0.2.4")) >= 0 { - dhcpPidFile = fmt.Sprintf("/run/udhcpc.%s.pid", ifname) - } - - // create the dhcp client - dhcpClient := udhcpc.NewDHCPClient(&udhcpc.DHCPClientOptions{ - InterfaceName: ifname, - PidFile: dhcpPidFile, - Logger: &logger, - OnLeaseChange: func(lease *udhcpc.Lease) { - _, err := s.update() - if err != nil { - logger.Error().Err(err).Msg("failed to update network state") - return - } - - if currentSession == nil { - logger.Info().Msg("No active RPC session, skipping network state update") - return - } - - writeJSONRPCEvent("networkState", rpcGetNetworkState(), currentSession) + OnDhcpLeaseChange: func(lease *udhcpc.Lease) { + waitCtrlAndRequestDisplayUpdate(true) }, }) - s.dhcpClient = dhcpClient - - return s -} - -func (s *NetworkInterfaceState) update() (DhcpTargetState, error) { - s.stateLock.Lock() - defer s.stateLock.Unlock() - - dhcpTargetState := DhcpTargetStateDoNothing - - iface, err := netlink.LinkByName(s.interfaceName) - if err != nil { - s.l.Error().Err(err).Msg("failed to get interface") - return dhcpTargetState, err - } - - // detect if the interface status changed - var changed bool - attrs := iface.Attrs() - state := attrs.OperState - newInterfaceUp := state == netlink.OperUp - - // check if the interface is coming up - interfaceGoingUp := !s.interfaceUp && newInterfaceUp - interfaceGoingDown := s.interfaceUp && !newInterfaceUp - - if s.interfaceUp != newInterfaceUp { - s.interfaceUp = newInterfaceUp - changed = true - } - - if changed { - if interfaceGoingUp { - s.l.Info().Msg("interface state transitioned to up") - dhcpTargetState = DhcpTargetStateRenew - } else if interfaceGoingDown { - s.l.Info().Msg("interface state transitioned to down") - } - } - - // set the mac address - s.macAddr = &attrs.HardwareAddr - - // get the ip addresses - addrs, err := netlink.AddrList(iface, nl.FAMILY_ALL) - if err != nil { - s.l.Error().Err(err).Msg("failed to get ip addresses") - return dhcpTargetState, err - } - - var ( - ipv4Addresses = make([]net.IP, 0) - ipv4AddressesString = make([]string, 0) - ipv6Addresses = make([]IPv6Address, 0) - ipv6AddressesString = make([]string, 0) - ipv6LinkLocal *net.IP - ) - - for _, addr := range addrs { - if addr.IP.To4() != nil { - scopedLogger := s.l.With().Str("ipv4", addr.IP.String()).Logger() - if interfaceGoingDown { - // remove all IPv4 addresses from the interface. - scopedLogger.Info().Msg("state transitioned to down, removing IPv4 address") - err := netlink.AddrDel(iface, &addr) - if err != nil { - scopedLogger.Warn().Err(err).Msg("failed to delete address") - } - // notify the DHCP client to release the lease - dhcpTargetState = DhcpTargetStateRelease - continue - } - ipv4Addresses = append(ipv4Addresses, addr.IP) - ipv4AddressesString = append(ipv4AddressesString, addr.IPNet.String()) - } else if addr.IP.To16() != nil { - scopedLogger := s.l.With().Str("ipv6", addr.IP.String()).Logger() - // check if it's a link local address - if addr.IP.IsLinkLocalUnicast() { - ipv6LinkLocal = &addr.IP - continue - } - - if !addr.IP.IsGlobalUnicast() { - scopedLogger.Trace().Msg("not a global unicast address, skipping") - continue - } - - if interfaceGoingDown { - scopedLogger.Info().Msg("state transitioned to down, removing IPv6 address") - err := netlink.AddrDel(iface, &addr) - if err != nil { - scopedLogger.Warn().Err(err).Msg("failed to delete address") - } - continue - } - ipv6Addresses = append(ipv6Addresses, IPv6Address{ - Address: addr.IP, - Prefix: *addr.IPNet, - ValidLifetime: lifetimeToTime(addr.ValidLft), - PreferredLifetime: lifetimeToTime(addr.PreferedLft), - Scope: addr.Scope, - }) - ipv6AddressesString = append(ipv6AddressesString, addr.IPNet.String()) - } - } - - if len(ipv4Addresses) > 0 { - // compare the addresses to see if there's a change - if s.ipv4Addr == nil || s.ipv4Addr.String() != ipv4Addresses[0].String() { - scopedLogger := s.l.With().Str("ipv4", ipv4Addresses[0].String()).Logger() - if s.ipv4Addr != nil { - scopedLogger.Info(). - Str("old_ipv4", s.ipv4Addr.String()). - Msg("IPv4 address changed") - } else { - scopedLogger.Info().Msg("IPv4 address found") - } - s.ipv4Addr = &ipv4Addresses[0] - changed = true - } - } - s.ipv4Addresses = ipv4AddressesString - - if ipv6LinkLocal != nil { - if s.ipv6LinkLocal == nil || s.ipv6LinkLocal.String() != ipv6LinkLocal.String() { - scopedLogger := s.l.With().Str("ipv6", ipv6LinkLocal.String()).Logger() - if s.ipv6LinkLocal != nil { - scopedLogger.Info(). - Str("old_ipv6", s.ipv6LinkLocal.String()). - Msg("IPv6 link local address changed") - } else { - scopedLogger.Info().Msg("IPv6 link local address found") - } - s.ipv6LinkLocal = ipv6LinkLocal - changed = true - } - } - s.ipv6Addresses = ipv6Addresses - - if len(ipv6Addresses) > 0 { - // compare the addresses to see if there's a change - if s.ipv6Addr == nil || s.ipv6Addr.String() != ipv6Addresses[0].Address.String() { - scopedLogger := s.l.With().Str("ipv6", ipv6Addresses[0].Address.String()).Logger() - if s.ipv6Addr != nil { - scopedLogger.Info(). - Str("old_ipv6", s.ipv6Addr.String()). - Msg("IPv6 address changed") - } else { - scopedLogger.Info().Msg("IPv6 address found") - } - s.ipv6Addr = &ipv6Addresses[0].Address - changed = true - } - } - - // if it's the initial check, we'll set changed to false - initialCheck := !s.checked - if initialCheck { - s.checked = true - changed = false - } - - if initialCheck { - s.onInitialCheck(s) - } else if changed { - s.onStateChange(s) - } - - return dhcpTargetState, nil -} - -func (s *NetworkInterfaceState) CheckAndUpdateDhcp() error { - dhcpTargetState, err := s.update() + err := networkState.Run() if err != nil { - return ErrorfL(s.l, "failed to update network state", err) - } - - switch dhcpTargetState { - case DhcpTargetStateRenew: - s.l.Info().Msg("renewing DHCP lease") - _ = s.dhcpClient.Renew() - case DhcpTargetStateRelease: - s.l.Info().Msg("releasing DHCP lease") - _ = s.dhcpClient.Release() - case DhcpTargetStateStart: - s.l.Warn().Msg("dhcpTargetStateStart not implemented") - case DhcpTargetStateStop: - s.l.Warn().Msg("dhcpTargetStateStop not implemented") - } - - return nil -} - -func (s *NetworkInterfaceState) 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 rpcGetNetworkState() RpcNetworkState { - ipv6Addresses := make([]RpcIPv6Address, 0) - for _, addr := range networkState.ipv6Addresses { - ipv6Addresses = append(ipv6Addresses, RpcIPv6Address{ - Address: addr.Prefix.String(), - ValidLifetime: addr.ValidLifetime, - PreferredLifetime: addr.PreferredLifetime, - Scope: addr.Scope, - }) - } - return RpcNetworkState{ - InterfaceName: networkState.interfaceName, - MacAddress: networkState.macAddr.String(), - IPv4: networkState.ipv4Addr.String(), - IPv6: networkState.ipv6Addr.String(), - IPv6LinkLocal: networkState.ipv6LinkLocal.String(), - IPv4Addresses: networkState.ipv4Addresses, - IPv6Addresses: ipv6Addresses, - DHCPLease: networkState.dhcpClient.GetLease(), - } -} - -func rpcGetNetworkSettings() RpcNetworkSettings { - return RpcNetworkSettings{ - IPv4Mode: "dhcp", - IPv6Mode: "slaac", - LLDPMode: "basic", - LLDPTxTLVs: []string{"chassis", "port", "system", "vlan"}, - MDNSMode: "auto", - TimeSyncMode: "ntp_and_http", - } -} - -func rpcRenewDHCPLease() error { - if networkState == nil { - return fmt.Errorf("network state not initialized") - } - if networkState.dhcpClient == nil { - return fmt.Errorf("dhcp client not initialized") - } - - return networkState.dhcpClient.Renew() -} - -func initNetwork() { - ensureConfigLoaded() - - updates := make(chan netlink.LinkUpdate) - done := make(chan struct{}) - - if err := netlink.LinkSubscribe(updates, done); err != nil { - networkLogger.Warn().Err(err).Msg("failed to subscribe to link updates") - return - } - - // TODO: support multiple interfaces - networkState = NewNetworkInterfaceState(NetIfName) - go networkState.dhcpClient.Run() // nolint:errcheck - - if err := networkState.CheckAndUpdateDhcp(); err != nil { + networkLogger.Error().Err(err).Msg("failed to run network state") os.Exit(1) } - - go func() { - ticker := time.NewTicker(1 * time.Second) - defer ticker.Stop() - - for { - select { - case update := <-updates: - networkState.HandleLinkUpdate(update) - case <-ticker.C: - _ = networkState.CheckAndUpdateDhcp() - case <-done: - return - } - } - }() } From 5614b26a38af5cfa96c47249bccf5b22f9fe5a55 Mon Sep 17 00:00:00 2001 From: Siyuan Miao Date: Tue, 15 Apr 2025 00:46:01 +0200 Subject: [PATCH 13/26] feat(timesync): add metrics --- internal/timesync/http.go | 31 ++++++- internal/timesync/metrics.go | 147 +++++++++++++++++++++++++++++++++ internal/timesync/ntp.go | 66 +++++++++++++-- internal/timesync/rtc_linux.go | 2 + internal/timesync/timesync.go | 95 +++++++++++++++++---- timesync.go | 33 ++------ 6 files changed, 319 insertions(+), 55 deletions(-) create mode 100644 internal/timesync/metrics.go diff --git a/internal/timesync/http.go b/internal/timesync/http.go index 14b4633f3..908fd188c 100644 --- a/internal/timesync/http.go +++ b/internal/timesync/http.go @@ -5,9 +5,20 @@ import ( "errors" "math/rand" "net/http" + "strconv" "time" ) +var defaultHTTPUrls = []string{ + "http://www.gstatic.com/generate_204", + "http://cp.cloudflare.com/", + "http://edge-http.microsoft.com/captiveportal/generate_204", + // Firefox, Apple, and Microsoft have inconsistent results, so we don't use it + // "http://detectportal.firefox.com/", + // "http://www.apple.com/library/test/success.html", + // "http://www.msftconnecttest.com/connecttest.txt", +} + func (t *TimeSync) queryAllHttpTime() (now *time.Time) { chunkSize := 4 httpUrls := t.httpUrls @@ -38,6 +49,9 @@ func (t *TimeSync) queryMultipleHttp(urls []string, timeout time.Duration) (now Str("http_url", url). Logger() + metricHttpRequestCount.WithLabelValues(url).Inc() + metricHttpTotalRequestCount.Inc() + startTime := time.Now() now, err, response := queryHttpTime( ctx, @@ -46,12 +60,22 @@ func (t *TimeSync) queryMultipleHttp(urls []string, timeout time.Duration) (now ) duration := time.Since(startTime) - var status int + metricHttpServerLastRTT.WithLabelValues(url).Set(float64(duration.Milliseconds())) + metricHttpServerRttHistogram.WithLabelValues(url).Observe(float64(duration.Milliseconds())) + + status := 0 if response != nil { status = response.StatusCode } + metricHttpServerInfo.WithLabelValues( + url, + strconv.Itoa(status), + ).Set(1) if err == nil { + metricHttpTotalSuccessCount.Inc() + metricHttpSuccessCount.WithLabelValues(url).Inc() + requestId := response.Header.Get("X-Request-Id") if requestId != "" { requestId = response.Header.Get("X-Msedge-Ref") @@ -68,7 +92,10 @@ func (t *TimeSync) queryMultipleHttp(urls []string, timeout time.Duration) (now cancel() results <- now - } else if !errors.Is(err, context.Canceled) { + } else if errors.Is(err, context.Canceled) { + metricHttpCancelCount.WithLabelValues(url).Inc() + metricHttpTotalCancelCount.Inc() + } else { scopedLogger.Warn(). Str("error", err.Error()). Int("status", status). diff --git a/internal/timesync/metrics.go b/internal/timesync/metrics.go new file mode 100644 index 000000000..0401f3ad1 --- /dev/null +++ b/internal/timesync/metrics.go @@ -0,0 +1,147 @@ +package timesync + +import ( + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/promauto" +) + +var ( + metricTimeSyncStatus = promauto.NewGauge( + prometheus.GaugeOpts{ + Name: "jetkvm_timesync_status", + Help: "The status of the timesync, 1 if successful, 0 if not", + }, + ) + metricTimeSyncCount = promauto.NewCounter( + prometheus.CounterOpts{ + Name: "jetkvm_timesync_count", + Help: "The number of times the timesync has been run", + }, + ) + metricTimeSyncSuccessCount = promauto.NewCounter( + prometheus.CounterOpts{ + Name: "jetkvm_timesync_success_count", + Help: "The number of times the timesync has been successful", + }, + ) + metricRTCUpdateCount = promauto.NewCounter( + prometheus.CounterOpts{ + Name: "jetkvm_timesync_rtc_update_count", + Help: "The number of times the RTC has been updated", + }, + ) + metricNtpTotalSuccessCount = promauto.NewCounter( + prometheus.CounterOpts{ + Name: "jetkvm_timesync_ntp_total_success_count", + Help: "The total number of successful NTP requests", + }, + ) + metricNtpTotalRequestCount = promauto.NewCounter( + prometheus.CounterOpts{ + Name: "jetkvm_timesync_ntp_total_request_count", + Help: "The total number of NTP requests sent", + }, + ) + metricNtpSuccessCount = promauto.NewCounterVec( + prometheus.CounterOpts{ + Name: "jetkvm_timesync_ntp_success_count", + Help: "The number of successful NTP requests", + }, + []string{"url"}, + ) + metricNtpRequestCount = promauto.NewCounterVec( + prometheus.CounterOpts{ + Name: "jetkvm_timesync_ntp_request_count", + Help: "The number of NTP requests sent to the server", + }, + []string{"url"}, + ) + metricNtpServerLastRTT = promauto.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "jetkvm_timesync_ntp_server_last_rtt", + Help: "The last RTT of the NTP server in milliseconds", + }, + []string{"url"}, + ) + metricNtpServerRttHistogram = promauto.NewHistogramVec( + prometheus.HistogramOpts{ + Name: "jetkvm_timesync_ntp_server_rtt", + Help: "The histogram of the RTT of the NTP server in milliseconds", + Buckets: []float64{ + 10, 25, 50, 100, 200, 300, 500, 1000, + }, + }, + []string{"url"}, + ) + metricNtpServerInfo = promauto.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "jetkvm_timesync_ntp_server_info", + Help: "The info of the NTP server", + }, + []string{"url", "reference", "stratum", "precision"}, + ) + + metricHttpTotalSuccessCount = promauto.NewCounter( + prometheus.CounterOpts{ + Name: "jetkvm_timesync_http_total_success_count", + Help: "The total number of successful HTTP requests", + }, + ) + metricHttpTotalRequestCount = promauto.NewCounter( + prometheus.CounterOpts{ + Name: "jetkvm_timesync_http_total_request_count", + Help: "The total number of HTTP requests sent", + }, + ) + metricHttpTotalCancelCount = promauto.NewCounter( + prometheus.CounterOpts{ + Name: "jetkvm_timesync_http_total_cancel_count", + Help: "The total number of HTTP requests cancelled", + }, + ) + metricHttpSuccessCount = promauto.NewCounterVec( + prometheus.CounterOpts{ + Name: "jetkvm_timesync_http_success_count", + Help: "The number of successful HTTP requests", + }, + []string{"url"}, + ) + metricHttpRequestCount = promauto.NewCounterVec( + prometheus.CounterOpts{ + Name: "jetkvm_timesync_http_request_count", + Help: "The number of HTTP requests sent to the server", + }, + []string{"url"}, + ) + metricHttpCancelCount = promauto.NewCounterVec( + prometheus.CounterOpts{ + Name: "jetkvm_timesync_http_cancel_count", + Help: "The number of HTTP requests cancelled", + }, + []string{"url"}, + ) + metricHttpServerLastRTT = promauto.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "jetkvm_timesync_http_server_last_rtt", + Help: "The last RTT of the HTTP server in milliseconds", + }, + []string{"url"}, + ) + metricHttpServerRttHistogram = promauto.NewHistogramVec( + prometheus.HistogramOpts{ + Name: "jetkvm_timesync_http_server_rtt", + Help: "The histogram of the RTT of the HTTP server in milliseconds", + Buckets: []float64{ + 10, 25, 50, 100, 200, 300, 500, 1000, + }, + }, + []string{"url"}, + ) + metricHttpServerInfo = promauto.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "jetkvm_timesync_http_server_info", + Help: "The info of the HTTP server", + }, + []string{"url", "http_code"}, + ) +) diff --git a/internal/timesync/ntp.go b/internal/timesync/ntp.go index eb15ff9d6..34e87c9be 100644 --- a/internal/timesync/ntp.go +++ b/internal/timesync/ntp.go @@ -2,12 +2,25 @@ package timesync import ( "math/rand/v2" + "strconv" "time" "github.com/beevik/ntp" ) -func (t *TimeSync) queryNetworkTime() (now *time.Time) { +var defaultNTPServers = []string{ + "time.apple.com", + "time.aws.com", + "time.windows.com", + "time.google.com", + "162.159.200.123", // time.cloudflare.com + "0.pool.ntp.org", + "1.pool.ntp.org", + "2.pool.ntp.org", + "3.pool.ntp.org", +} + +func (t *TimeSync) queryNetworkTime() (now *time.Time, offset *time.Duration) { chunkSize := 4 ntpServers := t.ntpServers @@ -16,27 +29,58 @@ func (t *TimeSync) queryNetworkTime() (now *time.Time) { for i := 0; i < len(ntpServers); i += chunkSize { chunk := ntpServers[i:min(i+chunkSize, len(ntpServers))] - results := t.queryMultipleNTP(chunk, timeSyncTimeout) - if results != nil { - return results + now, offset := t.queryMultipleNTP(chunk, timeSyncTimeout) + if now != nil { + return now, offset } } - return nil + return nil, nil } -func (t *TimeSync) queryMultipleNTP(servers []string, timeout time.Duration) (now *time.Time) { - results := make(chan *time.Time, len(servers)) +type ntpResult struct { + now *time.Time + offset *time.Duration +} +func (t *TimeSync) queryMultipleNTP(servers []string, timeout time.Duration) (now *time.Time, offset *time.Duration) { + results := make(chan *ntpResult, len(servers)) for _, server := range servers { go func(server string) { scopedLogger := t.l.With(). Str("server", server). Logger() + // increase request count + metricNtpTotalRequestCount.Inc() + metricNtpRequestCount.WithLabelValues(server).Inc() + + // query the server now, err, response := queryNtpServer(server, timeout) + // set the last RTT + metricNtpServerLastRTT.WithLabelValues( + server, + ).Set(float64(response.RTT.Milliseconds())) + + // set the RTT histogram + metricNtpServerRttHistogram.WithLabelValues( + server, + ).Observe(float64(response.RTT.Milliseconds())) + + // set the server info + metricNtpServerInfo.WithLabelValues( + server, + response.ReferenceString(), + strconv.Itoa(int(response.Stratum)), + strconv.Itoa(int(response.Precision)), + ).Set(1) + if err == nil { + // increase success count + metricNtpTotalSuccessCount.Inc() + metricNtpSuccessCount.WithLabelValues(server).Inc() + scopedLogger.Info(). Str("time", now.Format(time.RFC3339)). Str("reference", response.ReferenceString()). @@ -44,7 +88,10 @@ func (t *TimeSync) queryMultipleNTP(servers []string, timeout time.Duration) (no Str("clockOffset", response.ClockOffset.String()). Uint8("stratum", response.Stratum). Msg("NTP server returned time") - results <- now + results <- &ntpResult{ + now: now, + offset: &response.ClockOffset, + } } else { scopedLogger.Warn(). Str("error", err.Error()). @@ -53,7 +100,8 @@ func (t *TimeSync) queryMultipleNTP(servers []string, timeout time.Duration) (no }(server) } - return <-results + result := <-results + return result.now, result.offset } func queryNtpServer(server string, timeout time.Duration) (now *time.Time, err error, response *ntp.Response) { diff --git a/internal/timesync/rtc_linux.go b/internal/timesync/rtc_linux.go index dccfab2cd..27e4ec79c 100644 --- a/internal/timesync/rtc_linux.go +++ b/internal/timesync/rtc_linux.go @@ -99,5 +99,7 @@ func (t *TimeSync) setRtcTime(tu time.Time) error { return fmt.Errorf("failed to set RTC time: %w", err) } + metricRTCUpdateCount.Inc() + return nil } diff --git a/internal/timesync/timesync.go b/internal/timesync/timesync.go index 144720184..88d6e9d54 100644 --- a/internal/timesync/timesync.go +++ b/internal/timesync/timesync.go @@ -7,6 +7,7 @@ import ( "sync" "time" + "github.com/jetkvm/kvm/internal/network" "github.com/rs/zerolog" ) @@ -27,8 +28,9 @@ type TimeSync struct { syncLock *sync.Mutex l *zerolog.Logger - ntpServers []string - httpUrls []string + ntpServers []string + httpUrls []string + networkConfig *network.NetworkConfig rtcDevicePath string rtcDevice *os.File @@ -39,27 +41,37 @@ type TimeSync struct { preCheckFunc func() (bool, error) } -func NewTimeSync( - precheckFunc func() (bool, error), - ntpServers []string, - httpUrls []string, - logger *zerolog.Logger, -) *TimeSync { +type TimeSyncOptions struct { + PreCheckFunc func() (bool, error) + Logger *zerolog.Logger + NetworkConfig *network.NetworkConfig +} + +type SyncMode struct { + Ntp bool + Http bool + Ordering []string + NtpUseFallback bool + HttpUseFallback bool +} + +func NewTimeSync(opts *TimeSyncOptions) *TimeSync { rtcDevice, err := getRtcDevicePath() if err != nil { - logger.Error().Err(err).Msg("failed to get RTC device path") + opts.Logger.Error().Err(err).Msg("failed to get RTC device path") } else { - logger.Info().Str("path", rtcDevice).Msg("RTC device found") + opts.Logger.Info().Str("path", rtcDevice).Msg("RTC device found") } t := &TimeSync{ syncLock: &sync.Mutex{}, - l: logger, + l: opts.Logger, rtcDevicePath: rtcDevice, rtcLock: &sync.Mutex{}, - preCheckFunc: precheckFunc, - ntpServers: ntpServers, - httpUrls: httpUrls, + preCheckFunc: opts.PreCheckFunc, + ntpServers: defaultNTPServers, + httpUrls: defaultHTTPUrls, + networkConfig: opts.NetworkConfig, } if t.rtcDevicePath != "" { @@ -70,7 +82,36 @@ func NewTimeSync( return t } +func (t *TimeSync) getSyncMode() SyncMode { + syncMode := SyncMode{ + NtpUseFallback: true, + HttpUseFallback: true, + } + var syncModeString string + + if t.networkConfig != nil { + syncModeString = t.networkConfig.TimeSyncMode + if t.networkConfig.TimeSyncDisableFallback { + syncMode.NtpUseFallback = false + syncMode.HttpUseFallback = false + } + } + + switch syncModeString { + case "ntp_only": + syncMode.Ntp = true + case "http_only": + syncMode.Http = true + default: + syncMode.Ntp = true + syncMode.Http = true + } + + return syncMode +} + func (t *TimeSync) doTimeSync() { + metricTimeSyncStatus.Set(0) for { if ok, err := t.preCheckFunc(); !ok { if err != nil { @@ -101,14 +142,27 @@ func (t *TimeSync) doTimeSync() { Str("time_taken", time.Since(start).String()). Msg("time sync successful") + metricTimeSyncStatus.Set(1) + time.Sleep(timeSyncInterval) // after the first sync is done } } func (t *TimeSync) Sync() error { - var now *time.Time - now = t.queryNetworkTime() - if now == nil { + var ( + now *time.Time + offset *time.Duration + ) + + syncMode := t.getSyncMode() + + metricTimeSyncCount.Inc() + + if syncMode.Ntp { + now, offset = t.queryNetworkTime() + } + + if syncMode.Http && now == nil { now = t.queryAllHttpTime() } @@ -116,11 +170,18 @@ func (t *TimeSync) Sync() error { return fmt.Errorf("failed to get time from any source") } + if offset != nil { + newNow := time.Now().Add(*offset) + now = &newNow + } + err := t.setSystemTime(*now) if err != nil { return fmt.Errorf("failed to set system time: %w", err) } + metricTimeSyncSuccessCount.Inc() + return nil } diff --git a/timesync.go b/timesync.go index ed95ffda7..7b25fe26c 100644 --- a/timesync.go +++ b/timesync.go @@ -8,27 +8,7 @@ import ( ) var ( - timeSync *timesync.TimeSync - defaultNTPServers = []string{ - "time.apple.com", - "time.aws.com", - "time.windows.com", - "time.google.com", - "162.159.200.123", // time.cloudflare.com - "0.pool.ntp.org", - "1.pool.ntp.org", - "2.pool.ntp.org", - "3.pool.ntp.org", - } - defaultHTTPUrls = []string{ - "http://www.gstatic.com/generate_204", - "http://cp.cloudflare.com/", - "http://edge-http.microsoft.com/captiveportal/generate_204", - // Firefox, Apple, and Microsoft have inconsistent results, so we don't use it - // "http://detectportal.firefox.com/", - // "http://www.apple.com/library/test/success.html", - // "http://www.msftconnecttest.com/connecttest.txt", - } + timeSync *timesync.TimeSync builtTimestamp string ) @@ -60,15 +40,14 @@ func isTimeSyncNeeded() bool { } func initTimeSync() { - timeSync = timesync.NewTimeSync( - func() (bool, error) { + timeSync = timesync.NewTimeSync(×ync.TimeSyncOptions{ + Logger: timesyncLogger, + NetworkConfig: config.NetworkConfig, + PreCheckFunc: func() (bool, error) { if !networkState.IsOnline() { return false, nil } return true, nil }, - defaultNTPServers, - defaultHTTPUrls, - timesyncLogger, - ) + }) } From 08021f912e847c040c98c375bac968d2c6b6c83b Mon Sep 17 00:00:00 2001 From: Siyuan Miao Date: Tue, 15 Apr 2025 00:46:16 +0200 Subject: [PATCH 14/26] refactor(log): move log to internal/logging package --- internal/logging/logger.go | 192 ++++++++++++++++++++++++ internal/logging/pion.go | 63 ++++++++ internal/logging/root.go | 20 +++ internal/logging/utils.go | 4 + log.go | 297 +++---------------------------------- 5 files changed, 300 insertions(+), 276 deletions(-) create mode 100644 internal/logging/logger.go create mode 100644 internal/logging/pion.go create mode 100644 internal/logging/root.go diff --git a/internal/logging/logger.go b/internal/logging/logger.go new file mode 100644 index 000000000..f37c2dc93 --- /dev/null +++ b/internal/logging/logger.go @@ -0,0 +1,192 @@ +package logging + +import ( + "fmt" + "io" + "os" + "strings" + "sync" + "time" + + "github.com/rs/zerolog" +) + +type Logger struct { + l *zerolog.Logger + scopeLoggers map[string]*zerolog.Logger + scopeLevels map[string]zerolog.Level + scopeLevelMutex sync.Mutex + + defaultLogLevelFromEnv zerolog.Level + defaultLogLevelFromConfig zerolog.Level + defaultLogLevel zerolog.Level +} + +const ( + defaultLogLevel = zerolog.ErrorLevel +) + +type logOutput struct { + mu *sync.Mutex +} + +func (w *logOutput) Write(p []byte) (n int, err error) { + w.mu.Lock() + defer w.mu.Unlock() + + // TODO: write to file or syslog + + return len(p), nil +} + +var ( + consoleLogOutput io.Writer = zerolog.ConsoleWriter{ + Out: os.Stdout, + TimeFormat: time.RFC3339, + PartsOrder: []string{"time", "level", "scope", "component", "message"}, + FieldsExclude: []string{"scope", "component"}, + FormatPartValueByName: func(value interface{}, name string) string { + val := fmt.Sprintf("%s", value) + if name == "component" { + if value == nil { + return "-" + } + } + return val + }, + } + fileLogOutput io.Writer = &logOutput{mu: &sync.Mutex{}} + defaultLogOutput = zerolog.MultiLevelWriter(consoleLogOutput, fileLogOutput) + + zerologLevels = map[string]zerolog.Level{ + "DISABLE": zerolog.Disabled, + "NOLEVEL": zerolog.NoLevel, + "PANIC": zerolog.PanicLevel, + "FATAL": zerolog.FatalLevel, + "ERROR": zerolog.ErrorLevel, + "WARN": zerolog.WarnLevel, + "INFO": zerolog.InfoLevel, + "DEBUG": zerolog.DebugLevel, + "TRACE": zerolog.TraceLevel, + } +) + +func NewLogger(zerologLogger zerolog.Logger) *Logger { + return &Logger{ + l: &zerologLogger, + scopeLoggers: make(map[string]*zerolog.Logger), + scopeLevels: make(map[string]zerolog.Level), + scopeLevelMutex: sync.Mutex{}, + defaultLogLevelFromEnv: -2, + defaultLogLevelFromConfig: -2, + defaultLogLevel: defaultLogLevel, + } +} + +func (l *Logger) updateLogLevel() { + l.scopeLevelMutex.Lock() + defer l.scopeLevelMutex.Unlock() + + l.scopeLevels = make(map[string]zerolog.Level) + + finalDefaultLogLevel := l.defaultLogLevel + + for name, level := range zerologLevels { + env := os.Getenv(fmt.Sprintf("JETKVM_LOG_%s", name)) + + if env == "" { + env = os.Getenv(fmt.Sprintf("PION_LOG_%s", name)) + } + + if env == "" { + env = os.Getenv(fmt.Sprintf("PIONS_LOG_%s", name)) + } + + if env == "" { + continue + } + + if strings.ToLower(env) == "all" { + l.defaultLogLevelFromEnv = level + + if finalDefaultLogLevel > level { + finalDefaultLogLevel = level + } + + continue + } + + scopes := strings.Split(strings.ToLower(env), ",") + for _, scope := range scopes { + l.scopeLevels[scope] = level + } + } + + l.defaultLogLevel = finalDefaultLogLevel +} + +func (l *Logger) getScopeLoggerLevel(scope string) zerolog.Level { + if l.scopeLevels == nil { + l.updateLogLevel() + } + + var scopeLevel zerolog.Level + if l.defaultLogLevelFromConfig != -2 { + scopeLevel = l.defaultLogLevelFromConfig + } + if l.defaultLogLevelFromEnv != -2 { + scopeLevel = l.defaultLogLevelFromEnv + } + + // if the scope is not in the map, use the default level from the root logger + if level, ok := l.scopeLevels[scope]; ok { + scopeLevel = level + } + + return scopeLevel +} + +func (l *Logger) newScopeLogger(scope string) zerolog.Logger { + scopeLevel := l.getScopeLoggerLevel(scope) + logger := l.l.Level(scopeLevel).With().Str("component", scope).Logger() + + return logger +} + +func (l *Logger) getLogger(scope string) *zerolog.Logger { + logger, ok := l.scopeLoggers[scope] + if !ok || logger == nil { + scopeLogger := l.newScopeLogger(scope) + l.scopeLoggers[scope] = &scopeLogger + } + + return l.scopeLoggers[scope] +} + +func (l *Logger) UpdateLogLevel(configDefaultLogLevel string) { + needUpdate := false + + if configDefaultLogLevel != "" { + if logLevel, ok := zerologLevels[configDefaultLogLevel]; ok { + l.defaultLogLevelFromConfig = logLevel + } else { + l.l.Warn().Str("logLevel", configDefaultLogLevel).Msg("invalid defaultLogLevel from config, using ERROR") + } + + if l.defaultLogLevelFromConfig != l.defaultLogLevel { + needUpdate = true + } + } + + l.updateLogLevel() + + if needUpdate { + for scope, logger := range l.scopeLoggers { + currentLevel := logger.GetLevel() + targetLevel := l.getScopeLoggerLevel(scope) + if currentLevel != targetLevel { + *logger = l.newScopeLogger(scope) + } + } + } +} diff --git a/internal/logging/pion.go b/internal/logging/pion.go new file mode 100644 index 000000000..453b8bc95 --- /dev/null +++ b/internal/logging/pion.go @@ -0,0 +1,63 @@ +package logging + +import ( + "github.com/pion/logging" + "github.com/rs/zerolog" +) + +type pionLogger struct { + logger *zerolog.Logger +} + +// Print all messages except trace. +func (c pionLogger) Trace(msg string) { + c.logger.Trace().Msg(msg) +} +func (c pionLogger) Tracef(format string, args ...interface{}) { + c.logger.Trace().Msgf(format, args...) +} + +func (c pionLogger) Debug(msg string) { + c.logger.Debug().Msg(msg) +} +func (c pionLogger) Debugf(format string, args ...interface{}) { + c.logger.Debug().Msgf(format, args...) +} +func (c pionLogger) Info(msg string) { + c.logger.Info().Msg(msg) +} +func (c pionLogger) Infof(format string, args ...interface{}) { + c.logger.Info().Msgf(format, args...) +} +func (c pionLogger) Warn(msg string) { + c.logger.Warn().Msg(msg) +} +func (c pionLogger) Warnf(format string, args ...interface{}) { + c.logger.Warn().Msgf(format, args...) +} +func (c pionLogger) Error(msg string) { + c.logger.Error().Msg(msg) +} +func (c pionLogger) Errorf(format string, args ...interface{}) { + c.logger.Error().Msgf(format, args...) +} + +// customLoggerFactory satisfies the interface logging.LoggerFactory +// This allows us to create different loggers per subsystem. So we can +// add custom behavior. +type pionLoggerFactory struct{} + +func (c pionLoggerFactory) NewLogger(subsystem string) logging.LeveledLogger { + logger := rootLogger.getLogger(subsystem).With(). + Str("scope", "pion"). + Str("component", subsystem). + Logger() + + return pionLogger{logger: &logger} +} + +var defaultLoggerFactory = &pionLoggerFactory{} + +func GetPionDefaultLoggerFactory() logging.LoggerFactory { + return defaultLoggerFactory +} diff --git a/internal/logging/root.go b/internal/logging/root.go new file mode 100644 index 000000000..397ca6488 --- /dev/null +++ b/internal/logging/root.go @@ -0,0 +1,20 @@ +package logging + +import "github.com/rs/zerolog" + +var ( + rootZerologLogger = zerolog.New(defaultLogOutput).With(). + Str("scope", "jetkvm"). + Timestamp(). + Stack(). + Logger() + rootLogger = NewLogger(rootZerologLogger) +) + +func GetRootLogger() *Logger { + return rootLogger +} + +func GetSubsystemLogger(subsystem string) *zerolog.Logger { + return rootLogger.getLogger(subsystem) +} diff --git a/internal/logging/utils.go b/internal/logging/utils.go index 6d54bc5ce..e622d9648 100644 --- a/internal/logging/utils.go +++ b/internal/logging/utils.go @@ -9,6 +9,10 @@ import ( var defaultLogger = zerolog.New(os.Stdout).Level(zerolog.InfoLevel) +func GetDefaultLogger() *zerolog.Logger { + return &defaultLogger +} + func ErrorfL(l *zerolog.Logger, format string, err error, args ...interface{}) error { // TODO: move rootLogger to logging package if l == nil { diff --git a/log.go b/log.go index 8bc24d9c7..af880afb6 100644 --- a/log.go +++ b/log.go @@ -1,292 +1,37 @@ package kvm import ( - "fmt" - "io" - "os" - "strings" - "sync" - "time" - - "github.com/pion/logging" + "github.com/jetkvm/kvm/internal/logging" "github.com/rs/zerolog" ) -type Logger struct { - l *zerolog.Logger - scopeLoggers map[string]*zerolog.Logger - scopeLevels map[string]zerolog.Level - scopeLevelMutex sync.Mutex - - defaultLogLevelFromEnv zerolog.Level - defaultLogLevelFromConfig zerolog.Level - defaultLogLevel zerolog.Level -} - const ( defaultLogLevel = zerolog.ErrorLevel ) -type logOutput struct { - mu *sync.Mutex -} - -func (w *logOutput) Write(p []byte) (n int, err error) { - w.mu.Lock() - defer w.mu.Unlock() - - // TODO: write to file or syslog - - return len(p), nil -} - -var ( - consoleLogOutput io.Writer = zerolog.ConsoleWriter{ - Out: os.Stdout, - TimeFormat: time.RFC3339, - PartsOrder: []string{"time", "level", "scope", "component", "message"}, - FieldsExclude: []string{"scope", "component"}, - FormatPartValueByName: func(value interface{}, name string) string { - val := fmt.Sprintf("%s", value) - if name == "component" { - if value == nil { - return "-" - } - } - return val - }, - } - fileLogOutput io.Writer = &logOutput{mu: &sync.Mutex{}} - defaultLogOutput = zerolog.MultiLevelWriter(consoleLogOutput, fileLogOutput) - - zerologLevels = map[string]zerolog.Level{ - "DISABLE": zerolog.Disabled, - "NOLEVEL": zerolog.NoLevel, - "PANIC": zerolog.PanicLevel, - "FATAL": zerolog.FatalLevel, - "ERROR": zerolog.ErrorLevel, - "WARN": zerolog.WarnLevel, - "INFO": zerolog.InfoLevel, - "DEBUG": zerolog.DebugLevel, - "TRACE": zerolog.TraceLevel, - } - - rootZerologLogger = zerolog.New(defaultLogOutput).With(). - Str("scope", "jetkvm"). - Timestamp(). - Stack(). - Logger() - rootLogger = NewLogger(rootZerologLogger) -) - -func NewLogger(zerologLogger zerolog.Logger) *Logger { - return &Logger{ - l: &zerologLogger, - scopeLoggers: make(map[string]*zerolog.Logger), - scopeLevels: make(map[string]zerolog.Level), - scopeLevelMutex: sync.Mutex{}, - defaultLogLevelFromEnv: -2, - defaultLogLevelFromConfig: -2, - defaultLogLevel: defaultLogLevel, - } -} - -func (l *Logger) updateLogLevel() { - l.scopeLevelMutex.Lock() - defer l.scopeLevelMutex.Unlock() - - l.scopeLevels = make(map[string]zerolog.Level) - - finalDefaultLogLevel := l.defaultLogLevel - - for name, level := range zerologLevels { - env := os.Getenv(fmt.Sprintf("JETKVM_LOG_%s", name)) - - if env == "" { - env = os.Getenv(fmt.Sprintf("PION_LOG_%s", name)) - } - - if env == "" { - env = os.Getenv(fmt.Sprintf("PIONS_LOG_%s", name)) - } - - if env == "" { - continue - } - - if strings.ToLower(env) == "all" { - l.defaultLogLevelFromEnv = level - - if finalDefaultLogLevel > level { - finalDefaultLogLevel = level - } - - continue - } - - scopes := strings.Split(strings.ToLower(env), ",") - for _, scope := range scopes { - l.scopeLevels[scope] = level - } - } - - l.defaultLogLevel = finalDefaultLogLevel -} - -func (l *Logger) getScopeLoggerLevel(scope string) zerolog.Level { - if l.scopeLevels == nil { - l.updateLogLevel() - } - - var scopeLevel zerolog.Level - if l.defaultLogLevelFromConfig != -2 { - scopeLevel = l.defaultLogLevelFromConfig - } - if l.defaultLogLevelFromEnv != -2 { - scopeLevel = l.defaultLogLevelFromEnv - } - - // if the scope is not in the map, use the default level from the root logger - if level, ok := l.scopeLevels[scope]; ok { - scopeLevel = level - } - - return scopeLevel -} - -func (l *Logger) newScopeLogger(scope string) zerolog.Logger { - scopeLevel := l.getScopeLoggerLevel(scope) - logger := l.l.Level(scopeLevel).With().Str("component", scope).Logger() - - return logger -} - -func (l *Logger) getLogger(scope string) *zerolog.Logger { - logger, ok := l.scopeLoggers[scope] - if !ok || logger == nil { - scopeLogger := l.newScopeLogger(scope) - l.scopeLoggers[scope] = &scopeLogger - } - - return l.scopeLoggers[scope] -} - -func (l *Logger) UpdateLogLevel() { - needUpdate := false - - if config != nil && config.DefaultLogLevel != "" { - if logLevel, ok := zerologLevels[config.DefaultLogLevel]; ok { - l.defaultLogLevelFromConfig = logLevel - } else { - l.l.Warn().Str("logLevel", config.DefaultLogLevel).Msg("invalid defaultLogLevel from config, using ERROR") - } - - if l.defaultLogLevelFromConfig != l.defaultLogLevel { - needUpdate = true - } - } - - l.updateLogLevel() - - if needUpdate { - for scope, logger := range l.scopeLoggers { - currentLevel := logger.GetLevel() - targetLevel := l.getScopeLoggerLevel(scope) - if currentLevel != targetLevel { - *logger = l.newScopeLogger(scope) - } - } - } -} - func ErrorfL(l *zerolog.Logger, format string, err error, args ...interface{}) error { - if l == nil { - l = rootLogger.getLogger("jetkvm") - } - - l.Error().Err(err).Msgf(format, args...) - - if err == nil { - return fmt.Errorf(format, args...) - } - - err_msg := err.Error() + ": %v" - err_args := append(args, err) - - return fmt.Errorf(err_msg, err_args...) + return logging.ErrorfL(l, format, err, args...) } var ( - logger = rootLogger.getLogger("jetkvm") - networkLogger = rootLogger.getLogger("network") - cloudLogger = rootLogger.getLogger("cloud") - websocketLogger = rootLogger.getLogger("websocket") - webrtcLogger = rootLogger.getLogger("webrtc") - nativeLogger = rootLogger.getLogger("native") - nbdLogger = rootLogger.getLogger("nbd") - timesyncLogger = rootLogger.getLogger("timesync") - jsonRpcLogger = rootLogger.getLogger("jsonrpc") - watchdogLogger = rootLogger.getLogger("watchdog") - websecureLogger = rootLogger.getLogger("websecure") - otaLogger = rootLogger.getLogger("ota") - serialLogger = rootLogger.getLogger("serial") - terminalLogger = rootLogger.getLogger("terminal") - displayLogger = rootLogger.getLogger("display") - wolLogger = rootLogger.getLogger("wol") - usbLogger = rootLogger.getLogger("usb") + rootLogger = logging.GetRootLogger() + logger = logging.GetSubsystemLogger("jetkvm") + networkLogger = logging.GetSubsystemLogger("network") + cloudLogger = logging.GetSubsystemLogger("cloud") + websocketLogger = logging.GetSubsystemLogger("websocket") + webrtcLogger = logging.GetSubsystemLogger("webrtc") + nativeLogger = logging.GetSubsystemLogger("native") + nbdLogger = logging.GetSubsystemLogger("nbd") + timesyncLogger = logging.GetSubsystemLogger("timesync") + jsonRpcLogger = logging.GetSubsystemLogger("jsonrpc") + watchdogLogger = logging.GetSubsystemLogger("watchdog") + websecureLogger = logging.GetSubsystemLogger("websecure") + otaLogger = logging.GetSubsystemLogger("ota") + serialLogger = logging.GetSubsystemLogger("serial") + terminalLogger = logging.GetSubsystemLogger("terminal") + displayLogger = logging.GetSubsystemLogger("display") + wolLogger = logging.GetSubsystemLogger("wol") + usbLogger = logging.GetSubsystemLogger("usb") // external components - ginLogger = rootLogger.getLogger("gin") + ginLogger = logging.GetSubsystemLogger("gin") ) - -type pionLogger struct { - logger *zerolog.Logger -} - -// Print all messages except trace. -func (c pionLogger) Trace(msg string) { - c.logger.Trace().Msg(msg) -} -func (c pionLogger) Tracef(format string, args ...interface{}) { - c.logger.Trace().Msgf(format, args...) -} - -func (c pionLogger) Debug(msg string) { - c.logger.Debug().Msg(msg) -} -func (c pionLogger) Debugf(format string, args ...interface{}) { - c.logger.Debug().Msgf(format, args...) -} -func (c pionLogger) Info(msg string) { - c.logger.Info().Msg(msg) -} -func (c pionLogger) Infof(format string, args ...interface{}) { - c.logger.Info().Msgf(format, args...) -} -func (c pionLogger) Warn(msg string) { - c.logger.Warn().Msg(msg) -} -func (c pionLogger) Warnf(format string, args ...interface{}) { - c.logger.Warn().Msgf(format, args...) -} -func (c pionLogger) Error(msg string) { - c.logger.Error().Msg(msg) -} -func (c pionLogger) Errorf(format string, args ...interface{}) { - c.logger.Error().Msgf(format, args...) -} - -// customLoggerFactory satisfies the interface logging.LoggerFactory -// This allows us to create different loggers per subsystem. So we can -// add custom behavior. -type pionLoggerFactory struct{} - -func (c pionLoggerFactory) NewLogger(subsystem string) logging.LeveledLogger { - logger := rootLogger.getLogger(subsystem).With(). - Str("scope", "pion"). - Str("component", subsystem). - Logger() - - return pionLogger{logger: &logger} -} - -var defaultLoggerFactory = &pionLoggerFactory{} From b24191d14eb433849366de6fabea7ddfb8971eb9 Mon Sep 17 00:00:00 2001 From: Siyuan Miao Date: Tue, 15 Apr 2025 00:46:46 +0200 Subject: [PATCH 15/26] refactor(mdms): move mdns to internal/mdns package --- config.go | 4 +- hw.go | 10 +++ internal/mdns/mdns.go | 154 +++++++++++++++++++++++++++++++++++ internal/mdns/utils.go | 1 + internal/network/config.go | 54 +++++++++++- internal/network/hostname.go | 124 ++++++++++++++++++++++++++++ internal/network/netif.go | 41 ++++++++-- internal/network/rpc.go | 22 +++-- jsonrpc.go | 8 +- main.go | 18 +++- mdns.go | 61 ++++---------- network.go | 71 +++++++++++++--- webrtc.go | 3 +- 13 files changed, 487 insertions(+), 84 deletions(-) create mode 100644 internal/mdns/mdns.go create mode 100644 internal/mdns/utils.go create mode 100644 internal/network/hostname.go diff --git a/config.go b/config.go index 6cc7a299c..23d4c8415 100644 --- a/config.go +++ b/config.go @@ -6,6 +6,7 @@ import ( "os" "sync" + "github.com/jetkvm/kvm/internal/logging" "github.com/jetkvm/kvm/internal/network" "github.com/jetkvm/kvm/internal/usbgadget" ) @@ -123,6 +124,7 @@ var defaultConfig = &Config{ Keyboard: true, MassStorage: true, }, + NetworkConfig: &network.NetworkConfig{}, DefaultLogLevel: "INFO", } @@ -172,7 +174,7 @@ func LoadConfig() { config = &loadedConfig - rootLogger.UpdateLogLevel() + logging.GetRootLogger().UpdateLogLevel(config.DefaultLogLevel) logger.Info().Str("path", configPath).Msg("config loaded") } diff --git a/hw.go b/hw.go index 21bffad8a..20d88ebfc 100644 --- a/hw.go +++ b/hw.go @@ -4,6 +4,7 @@ import ( "fmt" "os" "regexp" + "strings" "sync" "time" ) @@ -51,6 +52,15 @@ func GetDeviceID() string { return deviceID } +func GetDefaultHostname() string { + deviceId := GetDeviceID() + if deviceId == "unknown_device_id" { + return "jetkvm" + } + + return fmt.Sprintf("jetkvm-%s", strings.ToLower(deviceId)) +} + func runWatchdog() { file, err := os.OpenFile("/dev/watchdog", os.O_WRONLY, 0) if err != nil { diff --git a/internal/mdns/mdns.go b/internal/mdns/mdns.go new file mode 100644 index 000000000..4899180c7 --- /dev/null +++ b/internal/mdns/mdns.go @@ -0,0 +1,154 @@ +package mdns + +import ( + "fmt" + "net" + "reflect" + "strings" + "sync" + + "github.com/jetkvm/kvm/internal/logging" + pion_mdns "github.com/pion/mdns/v2" + "github.com/rs/zerolog" + "golang.org/x/net/ipv4" + "golang.org/x/net/ipv6" +) + +type MDNS struct { + conn *pion_mdns.Conn + lock sync.Mutex + l *zerolog.Logger + + localNames []string + listenOptions *MDNSListenOptions +} + +type MDNSListenOptions struct { + IPv4 bool + IPv6 bool +} + +type MDNSOptions struct { + Logger *zerolog.Logger + LocalNames []string + ListenOptions *MDNSListenOptions +} + +const ( + DefaultAddressIPv4 = pion_mdns.DefaultAddressIPv4 + DefaultAddressIPv6 = pion_mdns.DefaultAddressIPv6 +) + +func NewMDNS(opts *MDNSOptions) (*MDNS, error) { + if opts.Logger == nil { + opts.Logger = logging.GetDefaultLogger() + } + + return &MDNS{ + l: opts.Logger, + lock: sync.Mutex{}, + localNames: opts.LocalNames, + listenOptions: opts.ListenOptions, + }, nil +} + +func (m *MDNS) start(allowRestart bool) error { + m.lock.Lock() + defer m.lock.Unlock() + + if m.conn != nil { + if !allowRestart { + return fmt.Errorf("mDNS server already running") + } + + m.conn.Close() + } + + addr4, err := net.ResolveUDPAddr("udp4", DefaultAddressIPv4) + if err != nil { + return err + } + + addr6, err := net.ResolveUDPAddr("udp6", DefaultAddressIPv6) + if err != nil { + return err + } + + l4, err := net.ListenUDP("udp4", addr4) + if err != nil { + return err + } + + l6, err := net.ListenUDP("udp6", addr6) + if err != nil { + return err + } + + scopeLogger := m.l.With().Interface("local_names", m.localNames).Logger() + + newLocalNames := make([]string, len(m.localNames)) + for i, name := range m.localNames { + newLocalNames[i] = strings.TrimRight(strings.ToLower(name), ".") + if !strings.HasSuffix(newLocalNames[i], ".local") { + newLocalNames[i] = newLocalNames[i] + ".local" + } + } + + mDNSConn, err := pion_mdns.Server(ipv4.NewPacketConn(l4), ipv6.NewPacketConn(l6), &pion_mdns.Config{ + LocalNames: newLocalNames, + LoggerFactory: logging.GetPionDefaultLoggerFactory(), + }) + + if err != nil { + scopeLogger.Warn().Err(err).Msg("failed to start mDNS server") + return err + } + + m.conn = mDNSConn + scopeLogger.Info().Msg("mDNS server started") + + return nil +} + +func (m *MDNS) Start() error { + return m.start(false) +} + +func (m *MDNS) Restart() error { + return m.start(true) +} + +func (m *MDNS) Stop() error { + m.lock.Lock() + defer m.lock.Unlock() + + if m.conn == nil { + return nil + } + + return m.conn.Close() +} + +func (m *MDNS) SetLocalNames(localNames []string, always bool) error { + if reflect.DeepEqual(m.localNames, localNames) && !always { + return nil + } + + m.localNames = localNames + m.Restart() + + return nil +} + +func (m *MDNS) SetListenOptions(listenOptions *MDNSListenOptions) error { + if m.listenOptions != nil && + m.listenOptions.IPv4 == listenOptions.IPv4 && + m.listenOptions.IPv6 == listenOptions.IPv6 { + return nil + } + + m.listenOptions = listenOptions + m.Restart() + + return nil +} diff --git a/internal/mdns/utils.go b/internal/mdns/utils.go new file mode 100644 index 000000000..7565eee2c --- /dev/null +++ b/internal/mdns/utils.go @@ -0,0 +1 @@ +package mdns diff --git a/internal/network/config.go b/internal/network/config.go index 1cfe9bb4b..16296652b 100644 --- a/internal/network/config.go +++ b/internal/network/config.go @@ -1,8 +1,11 @@ package network import ( + "fmt" "net" "time" + + "golang.org/x/net/idna" ) type IPv6Address struct { @@ -33,8 +36,51 @@ type NetworkConfig struct { 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"` + 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"` +} + +func (s *NetworkInterfaceState) GetHostname() string { + hostname := ToValidHostname(s.config.Hostname) + + if hostname == "" { + return s.defaultHostname + } + + return hostname +} + +func ToValidDomain(domain string) string { + ascii, err := idna.Lookup.ToASCII(domain) + if err != nil { + return "" + } + + return ascii +} + +func (s *NetworkInterfaceState) GetDomain() string { + domain := ToValidDomain(s.config.Domain) + + if domain == "" { + lease := s.dhcpClient.GetLease() + if lease != nil && lease.Domain != "" { + domain = ToValidDomain(lease.Domain) + } + } + + if domain == "" { + return "local" + } + + return domain +} + +func (s *NetworkInterfaceState) GetFQDN() string { + return fmt.Sprintf("%s.%s", s.GetHostname(), s.GetDomain()) } diff --git a/internal/network/hostname.go b/internal/network/hostname.go new file mode 100644 index 000000000..8d9286ffb --- /dev/null +++ b/internal/network/hostname.go @@ -0,0 +1,124 @@ +package network + +import ( + "fmt" + "io" + "os" + "os/exec" + "strings" + "sync" + + "golang.org/x/net/idna" +) + +const ( + hostnamePath = "/etc/hostname" + hostsPath = "/etc/hosts" +) + +var ( + hostnameLock sync.Mutex = sync.Mutex{} +) + +func updateEtcHosts(hostname string, fqdn string) error { + // update /etc/hosts + hostsFile, err := os.OpenFile(hostsPath, os.O_RDWR|os.O_SYNC, os.ModeExclusive) + if err != nil { + return fmt.Errorf("failed to open %s: %w", hostsPath, err) + } + defer hostsFile.Close() + + // read all lines + hostsFile.Seek(0, io.SeekStart) + lines, err := io.ReadAll(hostsFile) + if err != nil { + return fmt.Errorf("failed to read %s: %w", hostsPath, err) + } + + newLines := []string{} + hostLine := fmt.Sprintf("127.0.1.1\t%s %s", hostname, fqdn) + hostLineExists := false + + for _, line := range strings.Split(string(lines), "\n") { + if strings.HasPrefix(line, "127.0.1.1") { + hostLineExists = true + line = hostLine + } + newLines = append(newLines, line) + } + + if !hostLineExists { + newLines = append(newLines, hostLine) + } + + hostsFile.Truncate(0) + hostsFile.Seek(0, io.SeekStart) + hostsFile.Write([]byte(strings.Join(newLines, "\n"))) + + return nil +} + +func ToValidHostname(hostname string) string { + ascii, err := idna.Lookup.ToASCII(hostname) + if err != nil { + return "" + } + return ascii +} + +func SetHostname(hostname string, fqdn string) error { + hostnameLock.Lock() + defer hostnameLock.Unlock() + + hostname = ToValidHostname(strings.TrimSpace(hostname)) + fqdn = ToValidHostname(strings.TrimSpace(fqdn)) + + if hostname == "" { + return fmt.Errorf("invalid hostname: %s", hostname) + } + + if fqdn == "" { + fqdn = hostname + } + + // update /etc/hostname + os.WriteFile(hostnamePath, []byte(hostname), 0644) + + // update /etc/hosts + if err := updateEtcHosts(hostname, fqdn); err != nil { + return fmt.Errorf("failed to update /etc/hosts: %w", err) + } + + // run hostname + if err := exec.Command("hostname", "-F", hostnamePath).Run(); err != nil { + return fmt.Errorf("failed to run hostname: %w", err) + } + + return nil +} + +func (s *NetworkInterfaceState) setHostnameIfNotSame() error { + hostname := s.GetHostname() + currentHostname, _ := os.Hostname() + + fqdn := fmt.Sprintf("%s.%s", hostname, s.GetDomain()) + + if currentHostname == hostname && s.currentFqdn == fqdn && s.currentHostname == hostname { + return nil + } + + scopedLogger := s.l.With().Str("hostname", hostname).Str("fqdn", fqdn).Logger() + + err := SetHostname(hostname, fqdn) + if err != nil { + scopedLogger.Error().Err(err).Msg("failed to set hostname") + return err + } + + s.currentHostname = hostname + s.currentFqdn = fqdn + + scopedLogger.Info().Msg("hostname set") + + return nil +} diff --git a/internal/network/netif.go b/internal/network/netif.go index 8e3370a09..11cb6bcce 100644 --- a/internal/network/netif.go +++ b/internal/network/netif.go @@ -1,6 +1,7 @@ package network import ( + "fmt" "net" "sync" "time" @@ -29,6 +30,10 @@ type NetworkInterfaceState struct { config *NetworkConfig dhcpClient *udhcpc.DHCPClient + defaultHostname string + currentHostname string + currentFqdn string + onStateChange func(state *NetworkInterfaceState) onInitialCheck func(state *NetworkInterfaceState) @@ -39,21 +44,31 @@ type NetworkInterfaceOptions struct { InterfaceName string DhcpPidFile string Logger *zerolog.Logger + DefaultHostname string OnStateChange func(state *NetworkInterfaceState) OnInitialCheck func(state *NetworkInterfaceState) OnDhcpLeaseChange func(lease *udhcpc.Lease) NetworkConfig *NetworkConfig } -func NewNetworkInterfaceState(opts *NetworkInterfaceOptions) *NetworkInterfaceState { +func NewNetworkInterfaceState(opts *NetworkInterfaceOptions) (*NetworkInterfaceState, error) { + if opts.NetworkConfig == nil { + return nil, fmt.Errorf("NetworkConfig can not be nil") + } + + if opts.DefaultHostname == "" { + opts.DefaultHostname = "jetkvm" + } + l := opts.Logger s := &NetworkInterfaceState{ - interfaceName: opts.InterfaceName, - stateLock: sync.Mutex{}, - l: l, - onStateChange: opts.OnStateChange, - onInitialCheck: opts.OnInitialCheck, - config: opts.NetworkConfig, + interfaceName: opts.InterfaceName, + defaultHostname: opts.DefaultHostname, + stateLock: sync.Mutex{}, + l: l, + onStateChange: opts.OnStateChange, + onInitialCheck: opts.OnInitialCheck, + config: opts.NetworkConfig, } // create the dhcp client @@ -68,13 +83,15 @@ func NewNetworkInterfaceState(opts *NetworkInterfaceOptions) *NetworkInterfaceSt return } + s.setHostnameIfNotSame() + opts.OnDhcpLeaseChange(lease) }, }) s.dhcpClient = dhcpClient - return s + return s, nil } func (s *NetworkInterfaceState) IsUp() bool { @@ -277,6 +294,12 @@ func (s *NetworkInterfaceState) update() (DhcpTargetState, error) { if initialCheck { s.checked = true changed = false + if dhcpTargetState == DhcpTargetStateRenew { + // it's the initial check, we'll start the DHCP client + // dhcpTargetState = DhcpTargetStateStart + // TODO: manage DHCP client start/stop + dhcpTargetState = DhcpTargetStateDoNothing + } } if initialCheck { @@ -326,6 +349,8 @@ func (s *NetworkInterfaceState) Run() error { return err } + _ = s.setHostnameIfNotSame() + // run the dhcp client go s.dhcpClient.Run() // nolint:errcheck diff --git a/internal/network/rpc.go b/internal/network/rpc.go index afdcbc0da..0d6361a7e 100644 --- a/internal/network/rpc.go +++ b/internal/network/rpc.go @@ -39,14 +39,18 @@ type RpcNetworkSettings struct { func (s *NetworkInterfaceState) RpcGetNetworkState() RpcNetworkState { ipv6Addresses := make([]RpcIPv6Address, 0) - for _, addr := range s.ipv6Addresses { - ipv6Addresses = append(ipv6Addresses, RpcIPv6Address{ - Address: addr.Prefix.String(), - ValidLifetime: addr.ValidLifetime, - PreferredLifetime: addr.PreferredLifetime, - Scope: addr.Scope, - }) + + if s.ipv6Addresses != nil { + for _, addr := range s.ipv6Addresses { + ipv6Addresses = append(ipv6Addresses, RpcIPv6Address{ + Address: addr.Prefix.String(), + ValidLifetime: addr.ValidLifetime, + PreferredLifetime: addr.PreferredLifetime, + Scope: addr.Scope, + }) + } } + return RpcNetworkState{ InterfaceName: s.interfaceName, MacAddress: s.macAddr.String(), @@ -60,6 +64,10 @@ func (s *NetworkInterfaceState) RpcGetNetworkState() RpcNetworkState { } func (s *NetworkInterfaceState) RpcGetNetworkSettings() RpcNetworkSettings { + if s.config == nil { + return RpcNetworkSettings{} + } + return RpcNetworkSettings{ Hostname: null.StringFrom(s.config.Hostname), Domain: null.StringFrom(s.config.Domain), diff --git a/jsonrpc.go b/jsonrpc.go index 9dd365ff3..00df3cdfc 100644 --- a/jsonrpc.go +++ b/jsonrpc.go @@ -960,10 +960,10 @@ var rpcHandlers = map[string]RPCHandler{ "getDeviceID": {Func: rpcGetDeviceID}, "deregisterDevice": {Func: rpcDeregisterDevice}, "getCloudState": {Func: rpcGetCloudState}, - "getNetworkState": {Func: networkState.RpcGetNetworkState}, - "getNetworkSettings": {Func: networkState.RpcGetNetworkSettings}, - "setNetworkSettings": {Func: networkState.RpcSetNetworkSettings, Params: []string{"settings"}}, - "renewDHCPLease": {Func: networkState.RpcRenewDHCPLease}, + "getNetworkState": {Func: rpcGetNetworkState}, + "getNetworkSettings": {Func: rpcGetNetworkSettings}, + "setNetworkSettings": {Func: rpcSetNetworkSettings, Params: []string{"settings"}}, + "renewDHCPLease": {Func: rpcRenewDHCPLease}, "keyboardReport": {Func: rpcKeyboardReport, Params: []string{"modifier", "keys"}}, "absMouseReport": {Func: rpcAbsMouseReport, Params: []string{"x", "y", "buttons"}}, "relMouseReport": {Func: rpcRelMouseReport, Params: []string{"dx", "dy", "buttons"}}, diff --git a/main.go b/main.go index 73a4702d7..25fbb3aa7 100644 --- a/main.go +++ b/main.go @@ -43,12 +43,26 @@ func Main() { Int("ca_certs_loaded", len(rootcerts.Certs())). Msg("loaded Root CA certificates") - initNetwork() - initTimeSync() + // Initialize network + if err := initNetwork(); err != nil { + logger.Error().Err(err).Msg("failed to initialize network") + os.Exit(1) + } + // Initialize time sync + initTimeSync() timeSync.Start() + // Initialize mDNS + if err := initMdns(); err != nil { + logger.Error().Err(err).Msg("failed to initialize mDNS") + os.Exit(1) + } + + // Initialize native ctrl socket server StartNativeCtrlSocketServer() + + // Initialize native video socket server StartNativeVideoSocketServer() initPrometheus() diff --git a/mdns.go b/mdns.go index 309709e24..dd9eaf802 100644 --- a/mdns.go +++ b/mdns.go @@ -1,60 +1,33 @@ package kvm import ( - "net" - - "github.com/pion/mdns/v2" - "golang.org/x/net/ipv4" - "golang.org/x/net/ipv6" + "github.com/jetkvm/kvm/internal/mdns" ) -var mDNSConn *mdns.Conn - -func startMDNS() error { - // If server was previously running, stop it - if mDNSConn != nil { - logger.Info().Msg("stopping mDNS server") - err := mDNSConn.Close() - if err != nil { - logger.Warn().Err(err).Msg("failed to stop mDNS server") - } - } - - // Start a new server - hostname := "jetkvm.local" - - scopedLogger := logger.With().Str("hostname", hostname).Logger() - scopedLogger.Info().Msg("starting mDNS server") - - addr4, err := net.ResolveUDPAddr("udp4", mdns.DefaultAddressIPv4) - if err != nil { - return err - } - - addr6, err := net.ResolveUDPAddr("udp6", mdns.DefaultAddressIPv6) +var mDNS *mdns.MDNS + +func initMdns() error { + m, err := mdns.NewMDNS(&mdns.MDNSOptions{ + Logger: logger, + LocalNames: []string{ + networkState.GetHostname(), + networkState.GetFQDN(), + }, + ListenOptions: &mdns.MDNSListenOptions{ + IPv4: true, + IPv6: true, + }, + }) if err != nil { return err } - l4, err := net.ListenUDP("udp4", addr4) + err = m.Start() if err != nil { return err } - l6, err := net.ListenUDP("udp6", addr6) - if err != nil { - return err - } + mDNS = m - mDNSConn, err = mdns.Server(ipv4.NewPacketConn(l4), ipv6.NewPacketConn(l6), &mdns.Config{ - LocalNames: []string{hostname}, //TODO: make it configurable - LoggerFactory: defaultLoggerFactory, - }) - if err != nil { - scopedLogger.Warn().Err(err).Msg("failed to start mDNS server") - mDNSConn = nil - return err - } - //defer server.Close() return nil } diff --git a/network.go b/network.go index 4e1d42b43..75947ce30 100644 --- a/network.go +++ b/network.go @@ -1,7 +1,7 @@ package kvm import ( - "os" + "fmt" "github.com/jetkvm/kvm/internal/network" "github.com/jetkvm/kvm/internal/udhcpc" @@ -15,27 +15,72 @@ var ( networkState *network.NetworkInterfaceState ) -func initNetwork() { +func networkStateChanged() { + // do not block the main thread + go waitCtrlAndRequestDisplayUpdate(true) + + // always restart mDNS when the network state changes + if mDNS != nil { + mDNS.SetLocalNames([]string{ + networkState.GetHostname(), + networkState.GetFQDN(), + }, true) + } +} + +func initNetwork() error { ensureConfigLoaded() - networkState = network.NewNetworkInterfaceState(&network.NetworkInterfaceOptions{ - InterfaceName: NetIfName, - NetworkConfig: config.NetworkConfig, - Logger: networkLogger, + state, err := network.NewNetworkInterfaceState(&network.NetworkInterfaceOptions{ + DefaultHostname: GetDefaultHostname(), + InterfaceName: NetIfName, + NetworkConfig: config.NetworkConfig, + Logger: networkLogger, OnStateChange: func(state *network.NetworkInterfaceState) { - waitCtrlAndRequestDisplayUpdate(true) + networkStateChanged() }, OnInitialCheck: func(state *network.NetworkInterfaceState) { - waitCtrlAndRequestDisplayUpdate(true) + networkStateChanged() }, OnDhcpLeaseChange: func(lease *udhcpc.Lease) { - waitCtrlAndRequestDisplayUpdate(true) + networkStateChanged() + + if currentSession == nil { + return + } + + writeJSONRPCEvent("networkState", networkState.RpcGetNetworkState(), currentSession) }, }) - err := networkState.Run() - if err != nil { - networkLogger.Error().Err(err).Msg("failed to run network state") - os.Exit(1) + if state == nil { + if err == nil { + return fmt.Errorf("failed to create NetworkInterfaceState") + } + return err + } + + if err := state.Run(); err != nil { + return err } + + networkState = state + + return nil +} + +func rpcGetNetworkState() network.RpcNetworkState { + return networkState.RpcGetNetworkState() +} + +func rpcGetNetworkSettings() network.RpcNetworkSettings { + return networkState.RpcGetNetworkSettings() +} + +func rpcSetNetworkSettings(settings network.RpcNetworkSettings) error { + return networkState.RpcSetNetworkSettings(settings) +} + +func rpcRenewDHCPLease() error { + return networkState.RpcRenewDHCPLease() } diff --git a/webrtc.go b/webrtc.go index 5324b23eb..f6c852935 100644 --- a/webrtc.go +++ b/webrtc.go @@ -10,6 +10,7 @@ import ( "github.com/coder/websocket" "github.com/coder/websocket/wsjson" "github.com/gin-gonic/gin" + "github.com/jetkvm/kvm/internal/logging" "github.com/pion/webrtc/v4" "github.com/rs/zerolog" ) @@ -68,7 +69,7 @@ func (s *Session) ExchangeOffer(offerStr string) (string, error) { func newSession(config SessionConfig) (*Session, error) { webrtcSettingEngine := webrtc.SettingEngine{ - LoggerFactory: defaultLoggerFactory, + LoggerFactory: logging.GetPionDefaultLoggerFactory(), } iceServer := webrtc.ICEServer{} From 58605718d012e0a9a7bf373dd367f60620ab5802 Mon Sep 17 00:00:00 2001 From: Siyuan Miao Date: Tue, 15 Apr 2025 01:05:30 +0200 Subject: [PATCH 16/26] feat(developer): add pprof endpoint --- web.go | 66 +++++++++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 63 insertions(+), 3 deletions(-) diff --git a/web.go b/web.go index e74d551c6..31d3133a6 100644 --- a/web.go +++ b/web.go @@ -9,6 +9,7 @@ import ( "fmt" "io/fs" "net/http" + "net/http/pprof" "path/filepath" "strings" "time" @@ -103,6 +104,25 @@ func setupRouter() *gin.Engine { // A Prometheus metrics endpoint. r.GET("/metrics", gin.WrapH(promhttp.Handler())) + // Developer mode protected routes + developerModeRouter := r.Group("/developer/") + developerModeRouter.Use(basicAuthProtectedMiddleware(true)) + { + // pprof + developerModeRouter.GET("/pprof/", gin.WrapF(pprof.Index)) + developerModeRouter.GET("/pprof/cmdline", gin.WrapF(pprof.Cmdline)) + developerModeRouter.GET("/pprof/profile", gin.WrapF(pprof.Profile)) + developerModeRouter.POST("/pprof/symbol", gin.WrapF(pprof.Symbol)) + developerModeRouter.GET("/pprof/symbol", gin.WrapF(pprof.Symbol)) + developerModeRouter.GET("/pprof/trace", gin.WrapF(pprof.Trace)) + developerModeRouter.GET("/pprof/allocs", gin.WrapH(pprof.Handler("allocs"))) + developerModeRouter.GET("/pprof/block", gin.WrapH(pprof.Handler("block"))) + developerModeRouter.GET("/pprof/goroutine", gin.WrapH(pprof.Handler("goroutine"))) + developerModeRouter.GET("/pprof/heap", gin.WrapH(pprof.Handler("heap"))) + developerModeRouter.GET("/pprof/mutex", gin.WrapH(pprof.Handler("mutex"))) + developerModeRouter.GET("/pprof/threadcreate", gin.WrapH(pprof.Handler("threadcreate"))) + } + // Protected routes (allows both password and noPassword modes) protected := r.Group("/") protected.Use(protectedMiddleware()) @@ -464,11 +484,51 @@ func protectedMiddleware() gin.HandlerFunc { } } +func sendErrorJsonThenAbort(c *gin.Context, status int, message string) { + c.JSON(status, gin.H{"error": message}) + c.Abort() +} + +func basicAuthProtectedMiddleware(requireDeveloperMode bool) gin.HandlerFunc { + return func(c *gin.Context) { + if requireDeveloperMode { + devModeState, err := rpcGetDevModeState() + if err != nil { + sendErrorJsonThenAbort(c, http.StatusInternalServerError, "Failed to get developer mode state") + return + } + + if !devModeState.Enabled { + sendErrorJsonThenAbort(c, http.StatusUnauthorized, "Developer mode is not enabled") + return + } + } + + if config.LocalAuthMode == "noPassword" { + sendErrorJsonThenAbort(c, http.StatusForbidden, "The resource is not available in noPassword mode") + return + } + + // calculate basic auth credentials + _, password, ok := c.Request.BasicAuth() + if !ok { + c.Header("WWW-Authenticate", "Basic realm=\"JetKVM\"") + sendErrorJsonThenAbort(c, http.StatusUnauthorized, "Basic auth is required") + return + } + + err := bcrypt.CompareHashAndPassword([]byte(config.HashedPassword), []byte(password)) + if err != nil { + sendErrorJsonThenAbort(c, http.StatusUnauthorized, "Invalid password") + return + } + + c.Next() + } +} + func RunWebServer() { r := setupRouter() - //if strings.Contains(builtAppVersion, "-dev") { - // pprof.Register(r) - //} err := r.Run(":80") if err != nil { panic(err) From d5950f1485d417ea8f0e934fc4c7b15edd441475 Mon Sep 17 00:00:00 2001 From: Siyuan Miao Date: Tue, 15 Apr 2025 02:24:59 +0200 Subject: [PATCH 17/26] feat(logging): add a simple logging streaming endpoint --- internal/logging/logger.go | 7 +- internal/logging/sse.go | 138 ++++++++++++++++ internal/logging/sse.html | 319 +++++++++++++++++++++++++++++++++++++ ui/public/sse.html | 1 + ui/vite.config.ts | 1 + web.go | 3 + 6 files changed, 468 insertions(+), 1 deletion(-) create mode 100644 internal/logging/sse.go create mode 100644 internal/logging/sse.html create mode 120000 ui/public/sse.html diff --git a/internal/logging/logger.go b/internal/logging/logger.go index f37c2dc93..39156eccf 100644 --- a/internal/logging/logger.go +++ b/internal/logging/logger.go @@ -35,7 +35,12 @@ func (w *logOutput) Write(p []byte) (n int, err error) { defer w.mu.Unlock() // TODO: write to file or syslog - + if sseServer != nil { + // use a goroutine to avoid blocking the Write method + go func() { + sseServer.Message <- string(p) + }() + } return len(p), nil } diff --git a/internal/logging/sse.go b/internal/logging/sse.go new file mode 100644 index 000000000..a466432a0 --- /dev/null +++ b/internal/logging/sse.go @@ -0,0 +1,138 @@ +package logging + +import ( + "embed" + "io" + "net/http" + + "github.com/gin-gonic/gin" + "github.com/rs/zerolog" +) + +//go:embed sse.html +var sseHTML embed.FS + +type sseEvent struct { + Message chan string + NewClients chan chan string + ClosedClients chan chan string + TotalClients map[chan string]bool +} + +// New event messages are broadcast to all registered client connection channels +type sseClientChan chan string + +var ( + sseServer *sseEvent + sseLogger *zerolog.Logger +) + +func init() { + sseServer = newSseServer() + sseLogger = GetSubsystemLogger("sse") +} + +// Initialize event and Start procnteessing requests +func newSseServer() (event *sseEvent) { + event = &sseEvent{ + Message: make(chan string), + NewClients: make(chan chan string), + ClosedClients: make(chan chan string), + TotalClients: make(map[chan string]bool), + } + + go event.listen() + + return +} + +// It Listens all incoming requests from clients. +// Handles addition and removal of clients and broadcast messages to clients. +func (stream *sseEvent) listen() { + for { + select { + // Add new available client + case client := <-stream.NewClients: + stream.TotalClients[client] = true + sseLogger.Info(). + Int("total_clients", len(stream.TotalClients)). + Msg("new client connected") + + // Remove closed client + case client := <-stream.ClosedClients: + delete(stream.TotalClients, client) + close(client) + sseLogger.Info().Int("total_clients", len(stream.TotalClients)).Msg("client disconnected") + + // Broadcast message to client + case eventMsg := <-stream.Message: + for clientMessageChan := range stream.TotalClients { + select { + case clientMessageChan <- eventMsg: + // Message sent successfully + default: + // Failed to send, dropping message + } + } + } + } +} + +func (stream *sseEvent) serveHTTP() gin.HandlerFunc { + return func(c *gin.Context) { + clientChan := make(sseClientChan) + stream.NewClients <- clientChan + + go func() { + <-c.Writer.CloseNotify() + + for range clientChan { + } + + stream.ClosedClients <- clientChan + }() + + c.Set("clientChan", clientChan) + c.Next() + } +} + +func sseHeadersMiddleware() gin.HandlerFunc { + return func(c *gin.Context) { + if c.Request.Method == "GET" && c.NegotiateFormat(gin.MIMEHTML) == gin.MIMEHTML { + c.FileFromFS("/sse.html", http.FS(sseHTML)) + c.Status(http.StatusOK) + c.Abort() + return + } + + c.Writer.Header().Set("Content-Type", "text/event-stream") + c.Writer.Header().Set("Cache-Control", "no-cache") + c.Writer.Header().Set("Connection", "keep-alive") + c.Writer.Header().Set("Transfer-Encoding", "chunked") + c.Writer.Header().Set("Access-Control-Allow-Origin", "*") + c.Next() + } +} + +func AttachSSEHandler(router *gin.RouterGroup) { + + router.StaticFS("/log-stream", http.FS(sseHTML)) + router.GET("/log-stream", sseHeadersMiddleware(), sseServer.serveHTTP(), func(c *gin.Context) { + v, ok := c.Get("clientChan") + if !ok { + return + } + clientChan, ok := v.(sseClientChan) + if !ok { + return + } + c.Stream(func(w io.Writer) bool { + if msg, ok := <-clientChan; ok { + c.SSEvent("message", msg) + return true + } + return false + }) + }) +} diff --git a/internal/logging/sse.html b/internal/logging/sse.html new file mode 100644 index 000000000..192b4648d --- /dev/null +++ b/internal/logging/sse.html @@ -0,0 +1,319 @@ + + + + + + Server Sent Event + + + + +
+ +
+ +
+
+ + + + + \ No newline at end of file diff --git a/ui/public/sse.html b/ui/public/sse.html new file mode 120000 index 000000000..0a8b4f368 --- /dev/null +++ b/ui/public/sse.html @@ -0,0 +1 @@ +../../internal/logging/sse.html \ No newline at end of file diff --git a/ui/vite.config.ts b/ui/vite.config.ts index f6aae5093..2c153e74b 100644 --- a/ui/vite.config.ts +++ b/ui/vite.config.ts @@ -26,6 +26,7 @@ export default defineConfig(({ mode, command }) => { "/auth": JETKVM_PROXY_URL, "/storage": JETKVM_PROXY_URL, "/cloud": JETKVM_PROXY_URL, + "/developer": JETKVM_PROXY_URL, } : undefined, }, diff --git a/web.go b/web.go index 31d3133a6..766eaf510 100644 --- a/web.go +++ b/web.go @@ -19,6 +19,7 @@ import ( gin_logger "github.com/gin-contrib/logger" "github.com/gin-gonic/gin" "github.com/google/uuid" + "github.com/jetkvm/kvm/internal/logging" "github.com/pion/webrtc/v4" "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus/promhttp" @@ -121,6 +122,8 @@ func setupRouter() *gin.Engine { developerModeRouter.GET("/pprof/heap", gin.WrapH(pprof.Handler("heap"))) developerModeRouter.GET("/pprof/mutex", gin.WrapH(pprof.Handler("mutex"))) developerModeRouter.GET("/pprof/threadcreate", gin.WrapH(pprof.Handler("threadcreate"))) + + logging.AttachSSEHandler(developerModeRouter) } // Protected routes (allows both password and noPassword modes) From 3d84008217a156aec415c72e307aaac3bd29bd9e Mon Sep 17 00:00:00 2001 From: Siyuan Miao Date: Tue, 15 Apr 2025 02:41:21 +0200 Subject: [PATCH 18/26] fix(mdns): do not start mdns until network is up --- mdns.go | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/mdns.go b/mdns.go index dd9eaf802..d7a3b5530 100644 --- a/mdns.go +++ b/mdns.go @@ -22,11 +22,7 @@ func initMdns() error { return err } - err = m.Start() - if err != nil { - return err - } - + // do not start the server yet, as we need to wait for the network state to be set mDNS = m return nil From 8a55ea35f296863400ebf9515f60205f44fbc9e7 Mon Sep 17 00:00:00 2001 From: Siyuan Miao Date: Tue, 15 Apr 2025 06:33:40 +0200 Subject: [PATCH 19/26] 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 | 79 ++-- 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, 785 insertions(+), 158 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 000000000..aa8a991f4 --- /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 000000000..fec90b1da --- /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 000000000..287cfb998 --- /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 4899180c7..48b14c094 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 16296652b..4f6a903f2 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 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 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"` + 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 11cb6bcce..a8f75d60f 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 000000000..ec057f1df --- /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 000000000..d10163018 --- /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 0d6361a7e..230cfa2a8 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 0d02e19eb..e32dad6e5 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 88d6e9d54..e9ad0695d 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 75947ce30..2e8595ba2 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 f20eee256..db1fd045c 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 c5f4fe4a7..59d52efc1 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,15 +60,27 @@ 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]); @@ -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() {