From 189b84380b6e9b54166c897f2d5e2592a01dca1e Mon Sep 17 00:00:00 2001
From: Aveline <352441+ym@users.noreply.github.com>
Date: Wed, 16 Apr 2025 01:39:23 +0200
Subject: [PATCH] network enhanecment / refactor (#361)
* chore(network): improve connectivity check
* refactor(network): rewrite network and timesync component
* feat(display): show cloud connection status
* chore: change logging verbosity
* chore(websecure): update log message
* fix(ota): validate root certificate when downloading update
* feat(ui): add network settings tab
* fix(display): cloud connecting animation
* fix: golintci issues
* feat: add network settings tab
* feat(timesync): query servers in parallel
* refactor(network): move to internal/network package
* feat(timesync): add metrics
* refactor(log): move log to internal/logging package
* refactor(mdms): move mdns to internal/mdns package
* feat(developer): add pprof endpoint
* feat(logging): add a simple logging streaming endpoint
* fix(mdns): do not start mdns until network is up
* feat(network): allow users to update network settings from ui
* fix(network): handle errors when net.IPAddr is nil
* fix(mdns): scopedLogger SIGSEGV
* fix(dhcp): watch directory instead of file to catch fsnotify.Create event
* refactor(nbd): move platform-specific code to different files
* refactor(native): move platform-specific code to different files
* chore: fix linter issues
* chore(dev_deploy): allow to override PION_LOG_TRACE
---
block_device.go | 28 --
block_device_linux.go | 34 ++
block_device_notlinux.go | 17 +
cloud.go | 57 ++-
config.go | 56 ++-
dev_deploy.sh | 3 +-
display.go | 139 +++++-
go.mod | 2 +
go.sum | 4 +
hw.go | 10 +
internal/confparser/confparser.go | 381 ++++++++++++++++
internal/confparser/confparser_test.go | 100 +++++
internal/confparser/utils.go | 28 ++
internal/logging/logger.go | 197 +++++++++
internal/logging/pion.go | 63 +++
internal/logging/root.go | 20 +
internal/logging/sse.go | 137 ++++++
internal/logging/sse.html | 319 ++++++++++++++
internal/logging/utils.go | 32 ++
internal/mdns/mdns.go | 190 ++++++++
internal/mdns/utils.go | 1 +
internal/network/config.go | 110 +++++
internal/network/dhcp.go | 11 +
internal/network/hostname.go | 137 ++++++
internal/network/netif.go | 346 +++++++++++++++
internal/network/netif_linux.go | 58 +++
internal/network/netif_notlinux.go | 21 +
internal/network/rpc.go | 126 ++++++
internal/network/utils.go | 26 ++
internal/timesync/http.go | 132 ++++++
internal/timesync/metrics.go | 147 +++++++
internal/timesync/ntp.go | 113 +++++
internal/timesync/rtc.go | 26 ++
internal/timesync/rtc_linux.go | 105 +++++
internal/timesync/rtc_notlinux.go | 16 +
internal/timesync/timesync.go | 208 +++++++++
internal/udhcpc/options.go | 12 +
internal/udhcpc/parser.go | 186 ++++++++
internal/udhcpc/parser_test.go | 74 ++++
internal/udhcpc/proc.go | 212 +++++++++
internal/udhcpc/udhcpc.go | 191 ++++++++
internal/websecure/store.go | 8 +-
jsonrpc.go | 4 +
log.go | 299 +------------
main.go | 38 +-
mdns.go | 29 ++
native.go | 47 +-
native_linux.go | 57 +++
native_notlinux.go | 12 +
network.go | 288 ++++---------
ntp.go | 197 ---------
ota.go | 10 +-
resource/jetkvm_native | Bin 1545740 -> 1545928 bytes
resource/jetkvm_native.sha256 | 2 +-
timesync.go | 53 +++
ui/dev_device.sh | 10 +
ui/package-lock.json | 6 +
ui/package.json | 1 +
ui/public/sse.html | 1 +
ui/src/hooks/stores.ts | 93 +++-
ui/src/main.tsx | 5 +
.../routes/devices.$id.settings.network.tsx | 408 ++++++++++++++++++
ui/src/routes/devices.$id.settings.tsx | 12 +
ui/src/routes/devices.$id.tsx | 9 +
ui/vite.config.ts | 1 +
usb.go | 2 +-
usb_mass_storage.go | 7 +-
video.go | 2 +-
web.go | 78 +++-
web_tls.go | 4 +-
webrtc.go | 5 +-
71 files changed, 4938 insertions(+), 825 deletions(-)
create mode 100644 block_device_linux.go
create mode 100644 block_device_notlinux.go
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/logging/logger.go
create mode 100644 internal/logging/pion.go
create mode 100644 internal/logging/root.go
create mode 100644 internal/logging/sse.go
create mode 100644 internal/logging/sse.html
create mode 100644 internal/logging/utils.go
create mode 100644 internal/mdns/mdns.go
create mode 100644 internal/mdns/utils.go
create mode 100644 internal/network/config.go
create mode 100644 internal/network/dhcp.go
create mode 100644 internal/network/hostname.go
create mode 100644 internal/network/netif.go
create mode 100644 internal/network/netif_linux.go
create mode 100644 internal/network/netif_notlinux.go
create mode 100644 internal/network/rpc.go
create mode 100644 internal/network/utils.go
create mode 100644 internal/timesync/http.go
create mode 100644 internal/timesync/metrics.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
create mode 100644 native_linux.go
create mode 100644 native_notlinux.go
delete mode 100644 ntp.go
create mode 100644 timesync.go
create mode 120000 ui/public/sse.html
create mode 100644 ui/src/routes/devices.$id.settings.network.tsx
diff --git a/block_device.go b/block_device.go
index e4eab80..2274098 100644
--- a/block_device.go
+++ b/block_device.go
@@ -7,7 +7,6 @@ import (
"os"
"time"
- "github.com/pojntfx/go-nbd/pkg/client"
"github.com/pojntfx/go-nbd/pkg/server"
"github.com/rs/zerolog"
)
@@ -149,30 +148,3 @@ func (d *NBDDevice) runServerConn() {
d.l.Info().Err(err).Msg("nbd server exited")
}
-
-func (d *NBDDevice) runClientConn() {
- err := client.Connect(d.clientConn, d.dev, &client.Options{
- ExportName: "jetkvm",
- BlockSize: uint32(4 * 1024),
- })
- d.l.Info().Err(err).Msg("nbd client exited")
-}
-
-func (d *NBDDevice) Close() {
- if d.dev != nil {
- err := client.Disconnect(d.dev)
- if err != nil {
- d.l.Warn().Err(err).Msg("error disconnecting nbd client")
- }
- _ = d.dev.Close()
- }
- if d.listener != nil {
- _ = d.listener.Close()
- }
- if d.clientConn != nil {
- _ = d.clientConn.Close()
- }
- if d.serverConn != nil {
- _ = d.serverConn.Close()
- }
-}
diff --git a/block_device_linux.go b/block_device_linux.go
new file mode 100644
index 0000000..8ca9372
--- /dev/null
+++ b/block_device_linux.go
@@ -0,0 +1,34 @@
+//go:build linux
+
+package kvm
+
+import (
+ "github.com/pojntfx/go-nbd/pkg/client"
+)
+
+func (d *NBDDevice) runClientConn() {
+ err := client.Connect(d.clientConn, d.dev, &client.Options{
+ ExportName: "jetkvm",
+ BlockSize: uint32(4 * 1024),
+ })
+ d.l.Info().Err(err).Msg("nbd client exited")
+}
+
+func (d *NBDDevice) Close() {
+ if d.dev != nil {
+ err := client.Disconnect(d.dev)
+ if err != nil {
+ d.l.Warn().Err(err).Msg("error disconnecting nbd client")
+ }
+ _ = d.dev.Close()
+ }
+ if d.listener != nil {
+ _ = d.listener.Close()
+ }
+ if d.clientConn != nil {
+ _ = d.clientConn.Close()
+ }
+ if d.serverConn != nil {
+ _ = d.serverConn.Close()
+ }
+}
diff --git a/block_device_notlinux.go b/block_device_notlinux.go
new file mode 100644
index 0000000..b6a9aba
--- /dev/null
+++ b/block_device_notlinux.go
@@ -0,0 +1,17 @@
+//go:build !linux
+
+package kvm
+
+import (
+ "os"
+)
+
+func (d *NBDDevice) runClientConn() {
+ d.l.Error().Msg("platform not supported")
+ os.Exit(1)
+}
+
+func (d *NBDDevice) Close() {
+ d.l.Error().Msg("platform not supported")
+ os.Exit(1)
+}
diff --git a/cloud.go b/cloud.go
index fd96c41..fb1998a 100644
--- a/cloud.go
+++ b/cloud.go
@@ -139,11 +139,40 @@ var (
)
)
+type CloudConnectionState uint8
+
+const (
+ CloudConnectionStateNotConfigured CloudConnectionState = iota
+ CloudConnectionStateDisconnected
+ CloudConnectionStateConnecting
+ CloudConnectionStateConnected
+)
+
var (
+ cloudConnectionState CloudConnectionState = CloudConnectionStateNotConfigured
+ cloudConnectionStateLock = &sync.Mutex{}
+
cloudDisconnectChan chan error
cloudDisconnectLock = &sync.Mutex{}
)
+func setCloudConnectionState(state CloudConnectionState) {
+ cloudConnectionStateLock.Lock()
+ defer cloudConnectionStateLock.Unlock()
+
+ if cloudConnectionState == CloudConnectionStateDisconnected &&
+ (config.CloudToken == "" || config.CloudURL == "") {
+ state = CloudConnectionStateNotConfigured
+ }
+
+ previousState := cloudConnectionState
+ cloudConnectionState = state
+
+ go waitCtrlAndRequestDisplayUpdate(
+ previousState != state,
+ )
+}
+
func wsResetMetrics(established bool, sourceType string, source string) {
metricConnectionLastPingTimestamp.WithLabelValues(sourceType, source).Set(-1)
metricConnectionLastPingDuration.WithLabelValues(sourceType, source).Set(-1)
@@ -285,6 +314,8 @@ func runWebsocketClient() error {
wsURL.Scheme = "wss"
}
+ setCloudConnectionState(CloudConnectionStateConnecting)
+
header := http.Header{}
header.Set("X-Device-ID", GetDeviceID())
header.Set("X-App-Version", builtAppVersion)
@@ -302,20 +333,26 @@ 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()
+ setCloudConnectionState(CloudConnectionStateConnected)
+
return true
},
})
- // 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().
@@ -332,6 +369,8 @@ func runWebsocketClient() error {
if err != nil {
if errors.Is(err, context.Canceled) {
cloudLogger.Info().Msg("websocket connection canceled")
+ setCloudConnectionState(CloudConnectionStateDisconnected)
+
return nil
}
return err
@@ -450,14 +489,14 @@ 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
}
// 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
@@ -520,6 +559,8 @@ func rpcDeregisterDevice() error {
cloudLogger.Info().Msg("device deregistered, disconnecting from cloud")
disconnectCloud(fmt.Errorf("device deregistered"))
+ setCloudConnectionState(CloudConnectionStateNotConfigured)
+
return nil
}
diff --git a/config.go b/config.go
index cf096a7..23d4c84 100644
--- a/config.go
+++ b/config.go
@@ -6,6 +6,8 @@ import (
"os"
"sync"
+ "github.com/jetkvm/kvm/internal/logging"
+ "github.com/jetkvm/kvm/internal/network"
"github.com/jetkvm/kvm/internal/usbgadget"
)
@@ -73,27 +75,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"
@@ -121,6 +124,7 @@ var defaultConfig = &Config{
Keyboard: true,
MassStorage: true,
},
+ NetworkConfig: &network.NetworkConfig{},
DefaultLogLevel: "INFO",
}
@@ -134,7 +138,7 @@ func LoadConfig() {
defer configLock.Unlock()
if config != nil {
- logger.Info().Msg("config already loaded, skipping")
+ logger.Debug().Msg("config already loaded, skipping")
return
}
@@ -164,9 +168,15 @@ func LoadConfig() {
loadedConfig.UsbDevices = defaultConfig.UsbDevices
}
+ if loadedConfig.NetworkConfig == nil {
+ loadedConfig.NetworkConfig = defaultConfig.NetworkConfig
+ }
+
config = &loadedConfig
- rootLogger.UpdateLogLevel()
+ logging.GetRootLogger().UpdateLogLevel(config.DefaultLogLevel)
+
+ logger.Info().Str("path", configPath).Msg("config loaded")
}
func SaveConfig() error {
diff --git a/dev_deploy.sh b/dev_deploy.sh
index 02bbb24..d0ccaf2 100755
--- a/dev_deploy.sh
+++ b/dev_deploy.sh
@@ -24,6 +24,7 @@ show_help() {
REMOTE_USER="root"
REMOTE_PATH="/userdata/jetkvm/bin"
SKIP_UI_BUILD=false
+LOG_TRACE_SCOPES="${LOG_TRACE_SCOPES:-jetkvm,cloud,websocket,native,jsonrpc}"
# Parse command line arguments
while [[ $# -gt 0 ]]; do
@@ -91,7 +92,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=${LOG_TRACE_SCOPES} ./jetkvm_app_debug
EOF
echo "Deployment complete."
diff --git a/display.go b/display.go
index cbe9ddd..e2e82e1 100644
--- a/display.go
+++ b/display.go
@@ -33,50 +33,153 @@ 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 lvObjSetOpacity(objName string, opacity int) (*CtrlResponse, error) { // nolint:unused
+ 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})
+}
+
+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
}
}
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 (
+ cloudBlinkLock sync.Mutex = sync.Mutex{}
+ cloudBlinkStopped bool
+ cloudBlinkTicker *time.Ticker
+)
+
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"})
+ _, _ = 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.Up {
+
+ if networkState.IsUp() {
switchToScreenIfDifferent("ui_Home_Screen")
} else {
switchToScreenIfDifferent("ui_No_Network_Screen")
}
+
+ if cloudConnectionState == CloudConnectionStateNotConfigured {
+ _, _ = lvObjHide("ui_Home_Header_Cloud_Status_Icon")
+ } else {
+ _, _ = lvObjShow("ui_Home_Header_Cloud_Status_Icon")
+ }
+
+ 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)
+ } else {
+ // do nothing if the blink isn't stopped
+ if cloudBlinkStopped {
+ cloudBlinkLock.Lock()
+ defer cloudBlinkLock.Unlock()
+
+ cloudBlinkStopped = false
+ cloudBlinkTicker.Reset(2 * time.Second)
+ }
+ }
+
+ go func() {
+ 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)
+ }
+ }()
+}
+
+func stopCloudBlink() {
+ if cloudBlinkTicker != nil {
+ cloudBlinkTicker.Stop()
+ }
+
+ cloudBlinkLock.Lock()
+ defer cloudBlinkLock.Unlock()
+ cloudBlinkStopped = true
}
var (
displayInited = false
displayUpdateLock = sync.Mutex{}
+ waitDisplayUpdate = sync.Mutex{}
)
-func requestDisplayUpdate() {
+func requestDisplayUpdate(shouldWakeDisplay bool) {
displayUpdateLock.Lock()
defer displayUpdateLock.Unlock()
@@ -85,16 +188,26 @@ func requestDisplayUpdate() {
return
}
go func() {
- wakeDisplay(false)
- displayLogger.Info().Msg("display updating")
+ if shouldWakeDisplay {
+ wakeDisplay(false)
+ }
+ displayLogger.Debug().Msg("display updating")
//TODO: only run once regardless how many pending updates
updateDisplay()
}()
}
+func waitCtrlAndRequestDisplayUpdate(shouldWakeDisplay bool) {
+ waitDisplayUpdate.Lock()
+ defer waitDisplayUpdate.Unlock()
+
+ waitCtrlClientConnected()
+ requestDisplayUpdate(shouldWakeDisplay)
+}
+
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())
@@ -265,7 +378,7 @@ func init() {
displayLogger.Info().Msg("display inited")
startBacklightTickers()
wakeDisplay(true)
- requestDisplayUpdate()
+ requestDisplayUpdate(true)
}()
go watchTsEvents()
diff --git a/go.mod b/go.mod
index 1311a33..6784a59 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
@@ -44,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 565c0cc..3ad832a 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=
@@ -54,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/hw.go b/hw.go
index 21bffad..20d88eb 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/confparser/confparser.go b/internal/confparser/confparser.go
new file mode 100644
index 0000000..76102a3
--- /dev/null
+++ b/internal/confparser/confparser.go
@@ -0,0 +1,381 @@
+package confparser
+
+import (
+ "fmt"
+ "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
+ }
+
+ return nil
+}
+
+func (f *FieldConfig) validateRequired(fields map[string]FieldConfig) (bool, error) {
+ var required = f.Required
+
+ // if the field is not required, we need to check if it's required_if
+ if !required && len(f.RequiredIf) > 0 {
+ for key, value := range f.RequiredIf {
+ // check if the field's result matches the required_if
+ // right now we only support string and int
+ requiredField, ok := fields[key]
+ if !ok {
+ return required, fmt.Errorf("required_if field `%s` not found", key)
+ }
+
+ switch requiredField.CurrentValue.(type) {
+ case string:
+ if requiredField.CurrentValue.(string) == value.(string) {
+ required = true
+ }
+ case int:
+ if requiredField.CurrentValue.(int) == value.(int) {
+ required = true
+ }
+ case null.String:
+ if !requiredField.CurrentValue.(null.String).IsZero() &&
+ requiredField.CurrentValue.(null.String).String == value.(string) {
+ required = true
+ }
+ case null.Int:
+ if !requiredField.CurrentValue.(null.Int).IsZero() &&
+ requiredField.CurrentValue.(null.Int).Int64 == value.(int64) {
+ required = true
+ }
+ }
+
+ // if the field is required, we can break the loop
+ // because we only need one of the required_if fields to be true
+ if required {
+ break
+ }
+ }
+ }
+
+ if required && f.IsEmpty {
+ return false, fmt.Errorf("field `%s` is required", f.Name)
+ }
+
+ return required, nil
+}
+
+func checkIfSliceContains(slice []string, one_of []string) bool {
+ for _, oneOf := range one_of {
+ if slices.Contains(slice, oneOf) {
+ return true
+ }
+ }
+
+ return false
+}
+
+func (f *FieldConfig) validateOneOf() error {
+ if len(f.OneOf) == 0 {
+ return nil
+ }
+
+ var val []string
+ switch f.CurrentValue.(type) {
+ case string:
+ val = []string{f.CurrentValue.(string)}
+ case null.String:
+ val = []string{f.CurrentValue.(null.String).String}
+ case []string:
+ // let's validate the value here
+ val = f.CurrentValue.([]string)
+ default:
+ return fmt.Errorf("field `%s` cannot use one_of: unsupported type: %s", f.Name, f.TypeString)
+ }
+
+ if !checkIfSliceContains(val, f.OneOf) {
+ return fmt.Errorf(
+ "field `%s` is not one of the allowed values: %s, current value: %s",
+ f.Name,
+ strings.Join(f.OneOf, ", "),
+ strings.Join(val, ", "),
+ )
+ }
+
+ return nil
+}
+
+func (f *FieldConfig) validateField() error {
+ if len(f.ValidateTypes) == 0 || f.IsEmpty {
+ return nil
+ }
+
+ val, err := toString(f.CurrentValue)
+ if err != nil {
+ return fmt.Errorf("field `%s` cannot use validate_type: %s", f.Name, err)
+ }
+
+ if val == "" {
+ return nil
+ }
+
+ for _, validateType := range f.ValidateTypes {
+ switch validateType {
+ case "ipv4":
+ if net.ParseIP(val).To4() == nil {
+ return fmt.Errorf("field `%s` is not a valid IPv4 address: %s", f.Name, val)
+ }
+ case "ipv6":
+ if net.ParseIP(val).To16() == nil {
+ return fmt.Errorf("field `%s` is not a valid IPv6 address: %s", f.Name, val)
+ }
+ case "hwaddr":
+ if _, err := net.ParseMAC(val); err != nil {
+ return fmt.Errorf("field `%s` is not a valid MAC address: %s", f.Name, val)
+ }
+ case "hostname":
+ if _, err := idna.Lookup.ToASCII(val); err != nil {
+ return fmt.Errorf("field `%s` is not a valid hostname: %s", f.Name, val)
+ }
+ default:
+ return fmt.Errorf("field `%s` cannot use validate_type: unsupported validator: %s", f.Name, validateType)
+ }
+ }
+
+ return nil
+}
diff --git a/internal/confparser/confparser_test.go b/internal/confparser/confparser_test.go
new file mode 100644
index 0000000..dd5e00a
--- /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 { //nolint:unused
+ Address net.IP `json:"address"`
+ Prefix net.IPNet `json:"prefix"`
+ ValidLifetime *time.Time `json:"valid_lifetime"`
+ PreferredLifetime *time.Time `json:"preferred_lifetime"`
+ Scope int `json:"scope"`
+}
+
+type testIPv4StaticConfig struct {
+ Address null.String `json:"address" validate_type:"ipv4" required:"true"`
+ Netmask null.String `json:"netmask" validate_type:"ipv4" required:"true"`
+ Gateway null.String `json:"gateway" validate_type:"ipv4" required:"true"`
+ DNS []string `json:"dns" validate_type:"ipv4" required:"true"`
+}
+
+type testIPv6StaticConfig struct {
+ Address null.String `json:"address" validate_type:"ipv6" required:"true"`
+ Prefix null.String `json:"prefix" validate_type:"ipv6" required:"true"`
+ Gateway null.String `json:"gateway" validate_type:"ipv6" required:"true"`
+ DNS []string `json:"dns" validate_type:"ipv6" required:"true"`
+}
+type testNetworkConfig struct {
+ Hostname null.String `json:"hostname,omitempty"`
+ Domain null.String `json:"domain,omitempty"`
+
+ IPv4Mode null.String `json:"ipv4_mode" one_of:"dhcp,static,disabled" default:"dhcp"`
+ IPv4Static *testIPv4StaticConfig `json:"ipv4_static,omitempty" required_if:"IPv4Mode=static"`
+
+ IPv6Mode null.String `json:"ipv6_mode" one_of:"slaac,dhcpv6,slaac_and_dhcpv6,static,link_local,disabled" default:"slaac"`
+ IPv6Static *testIPv6StaticConfig `json:"ipv6_static,omitempty" required_if:"IPv6Mode=static"`
+
+ LLDPMode null.String `json:"lldp_mode,omitempty" one_of:"disabled,basic,all" default:"basic"`
+ LLDPTxTLVs []string `json:"lldp_tx_tlvs,omitempty" one_of:"chassis,port,system,vlan" default:"chassis,port,system,vlan"`
+ MDNSMode null.String `json:"mdns_mode,omitempty" one_of:"disabled,auto,ipv4_only,ipv6_only" default:"auto"`
+ TimeSyncMode null.String `json:"time_sync_mode,omitempty" one_of:"ntp_only,ntp_and_http,http_only,custom" default:"ntp_and_http"`
+ TimeSyncOrdering []string `json:"time_sync_ordering,omitempty" one_of:"http,ntp,ntp_dhcp,ntp_user_provided,ntp_fallback" default:"ntp,http"`
+ TimeSyncDisableFallback null.Bool `json:"time_sync_disable_fallback,omitempty" default:"false"`
+ TimeSyncParallel null.Int `json:"time_sync_parallel,omitempty" default:"4"`
+}
+
+func TestValidateConfig(t *testing.T) {
+ config := &testNetworkConfig{}
+
+ err := SetDefaultsAndValidate(config)
+ if err != nil {
+ t.Fatalf("expected no error, got %v", err)
+ }
+}
+
+func TestValidateIPv4StaticConfigRequired(t *testing.T) {
+ config := &testNetworkConfig{
+ IPv4Static: &testIPv4StaticConfig{
+ Address: null.StringFrom("192.168.1.1"),
+ Gateway: null.StringFrom("192.168.1.1"),
+ },
+ }
+
+ err := SetDefaultsAndValidate(config)
+ if err == nil {
+ t.Fatalf("expected error, got nil")
+ }
+}
+
+func TestValidateIPv4StaticConfigRequiredIf(t *testing.T) {
+ config := &testNetworkConfig{
+ IPv4Mode: null.StringFrom("static"),
+ }
+
+ err := SetDefaultsAndValidate(config)
+ if err == nil {
+ t.Fatalf("expected error, got nil")
+ }
+}
+
+func TestValidateIPv4StaticConfigValidateType(t *testing.T) {
+ config := &testNetworkConfig{
+ IPv4Static: &testIPv4StaticConfig{
+ Address: null.StringFrom("X"),
+ Netmask: null.StringFrom("255.255.255.0"),
+ Gateway: null.StringFrom("192.168.1.1"),
+ DNS: []string{"8.8.8.8", "8.8.4.4"},
+ },
+ IPv4Mode: null.StringFrom("static"),
+ }
+
+ err := SetDefaultsAndValidate(config)
+ if err == nil {
+ t.Fatalf("expected error, got nil")
+ }
+}
diff --git a/internal/confparser/utils.go b/internal/confparser/utils.go
new file mode 100644
index 0000000..a46871e
--- /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 := v.(type) {
+ case string:
+ return v, nil
+ case null.String:
+ return v.String, nil
+ }
+
+ return "", fmt.Errorf("unsupported type: %s", reflect.TypeOf(v))
+}
diff --git a/internal/logging/logger.go b/internal/logging/logger.go
new file mode 100644
index 0000000..39156ec
--- /dev/null
+++ b/internal/logging/logger.go
@@ -0,0 +1,197 @@
+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
+ if sseServer != nil {
+ // use a goroutine to avoid blocking the Write method
+ go func() {
+ sseServer.Message <- string(p)
+ }()
+ }
+ 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 0000000..453b8bc
--- /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 0000000..397ca64
--- /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/sse.go b/internal/logging/sse.go
new file mode 100644
index 0000000..05e6e9e
--- /dev/null
+++ b/internal/logging/sse.go
@@ -0,0 +1,137 @@
+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 0000000..192b464
--- /dev/null
+++ b/internal/logging/sse.html
@@ -0,0 +1,319 @@
+
+
+
+
+
+ Server Sent Event
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/internal/logging/utils.go b/internal/logging/utils.go
new file mode 100644
index 0000000..e622d96
--- /dev/null
+++ b/internal/logging/utils.go
@@ -0,0 +1,32 @@
+package logging
+
+import (
+ "fmt"
+ "os"
+
+ "github.com/rs/zerolog"
+)
+
+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 {
+ 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/mdns/mdns.go b/internal/mdns/mdns.go
new file mode 100644
index 0000000..b882b93
--- /dev/null
+++ b/internal/mdns/mdns.go
@@ -0,0 +1,190 @@
+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()
+ }
+
+ if opts.ListenOptions == nil {
+ opts.ListenOptions = &MDNSListenOptions{
+ IPv4: true,
+ IPv6: true,
+ }
+ }
+
+ 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()
+ }
+
+ if m.listenOptions == nil {
+ return fmt.Errorf("listen options not set")
+ }
+
+ if !m.listenOptions.IPv4 && !m.listenOptions.IPv6 {
+ m.l.Info().Msg("mDNS server disabled")
+ return nil
+ }
+
+ 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)
+ }
+
+ 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).
+ Bool("ipv4", m.listenOptions.IPv4).
+ Bool("ipv6", m.listenOptions.IPv6).
+ 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(p4, p6, &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 0000000..7565eee
--- /dev/null
+++ b/internal/mdns/utils.go
@@ -0,0 +1 @@
+package mdns
diff --git a/internal/network/config.go b/internal/network/config.go
new file mode 100644
index 0000000..74ddf19
--- /dev/null
+++ b/internal/network/config.go
@@ -0,0 +1,110 @@
+package network
+
+import (
+ "fmt"
+ "net"
+ "time"
+
+ "github.com/guregu/null/v6"
+ "github.com/jetkvm/kvm/internal/mdns"
+ "golang.org/x/net/idna"
+)
+
+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 IPv4StaticConfig struct {
+ Address null.String `json:"address,omitempty" validate_type:"ipv4" required:"true"`
+ Netmask null.String `json:"netmask,omitempty" validate_type:"ipv4" required:"true"`
+ Gateway null.String `json:"gateway,omitempty" validate_type:"ipv4" required:"true"`
+ DNS []string `json:"dns,omitempty" validate_type:"ipv4" required:"true"`
+}
+
+type IPv6StaticConfig struct {
+ Address null.String `json:"address,omitempty" validate_type:"ipv6" required:"true"`
+ Prefix null.String `json:"prefix,omitempty" validate_type:"ipv6" required:"true"`
+ Gateway null.String `json:"gateway,omitempty" validate_type:"ipv6" required:"true"`
+ DNS []string `json:"dns,omitempty" validate_type:"ipv6" required:"true"`
+}
+type NetworkConfig struct {
+ Hostname null.String `json:"hostname,omitempty" validate_type:"hostname"`
+ Domain null.String `json:"domain,omitempty" validate_type:"hostname"`
+
+ IPv4Mode null.String `json:"ipv4_mode,omitempty" one_of:"dhcp,static,disabled" default:"dhcp"`
+ IPv4Static *IPv4StaticConfig `json:"ipv4_static,omitempty" required_if:"IPv4Mode=static"`
+
+ IPv6Mode null.String `json:"ipv6_mode,omitempty" one_of:"slaac,dhcpv6,slaac_and_dhcpv6,static,link_local,disabled" default:"slaac"`
+ IPv6Static *IPv6StaticConfig `json:"ipv6_static,omitempty" required_if:"IPv6Mode=static"`
+
+ LLDPMode null.String `json:"lldp_mode,omitempty" one_of:"disabled,basic,all" default:"basic"`
+ LLDPTxTLVs []string `json:"lldp_tx_tlvs,omitempty" one_of:"chassis,port,system,vlan" default:"chassis,port,system,vlan"`
+ MDNSMode null.String `json:"mdns_mode,omitempty" one_of:"disabled,auto,ipv4_only,ipv6_only" default:"auto"`
+ TimeSyncMode null.String `json:"time_sync_mode,omitempty" one_of:"ntp_only,ntp_and_http,http_only,custom" default:"ntp_and_http"`
+ TimeSyncOrdering []string `json:"time_sync_ordering,omitempty" one_of:"http,ntp,ntp_dhcp,ntp_user_provided,ntp_fallback" default:"ntp,http"`
+ TimeSyncDisableFallback null.Bool `json:"time_sync_disable_fallback,omitempty" default:"false"`
+ TimeSyncParallel null.Int `json:"time_sync_parallel,omitempty" default:"4"`
+}
+
+func (c *NetworkConfig) GetMDNSMode() *mdns.MDNSListenOptions {
+ mode := c.MDNSMode.String
+ listenOptions := &mdns.MDNSListenOptions{
+ IPv4: true,
+ IPv6: true,
+ }
+
+ switch mode {
+ case "ipv4_only":
+ listenOptions.IPv6 = false
+ case "ipv6_only":
+ listenOptions.IPv4 = false
+ case "disabled":
+ listenOptions.IPv4 = false
+ listenOptions.IPv6 = false
+ }
+
+ return listenOptions
+}
+func (s *NetworkInterfaceState) GetHostname() string {
+ hostname := ToValidHostname(s.config.Hostname.String)
+
+ 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.String)
+
+ 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/dhcp.go b/internal/network/dhcp.go
new file mode 100644
index 0000000..9e173cc
--- /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/hostname.go b/internal/network/hostname.go
new file mode 100644
index 0000000..d75255c
--- /dev/null
+++ b/internal/network/hostname.go
@@ -0,0 +1,137 @@
+package network
+
+import (
+ "fmt"
+ "io"
+ "os"
+ "os/exec"
+ "strings"
+ "sync"
+
+ "golang.org/x/net/idna"
+)
+
+const (
+ hostnamePath = "/etc/hostname"
+ hostsPath = "/etc/hosts"
+)
+
+var (
+ hostnameLock sync.Mutex = sync.Mutex{}
+)
+
+func updateEtcHosts(hostname string, fqdn string) error {
+ // update /etc/hosts
+ hostsFile, err := os.OpenFile(hostsPath, os.O_RDWR|os.O_SYNC, os.ModeExclusive)
+ if err != nil {
+ return fmt.Errorf("failed to open %s: %w", hostsPath, err)
+ }
+ defer hostsFile.Close()
+
+ // read all lines
+ if _, err := hostsFile.Seek(0, io.SeekStart); err != nil {
+ return fmt.Errorf("failed to seek %s: %w", hostsPath, err)
+ }
+
+ lines, err := io.ReadAll(hostsFile)
+ if err != nil {
+ return fmt.Errorf("failed to read %s: %w", hostsPath, err)
+ }
+
+ newLines := []string{}
+ hostLine := fmt.Sprintf("127.0.1.1\t%s %s", hostname, fqdn)
+ hostLineExists := false
+
+ for _, line := range strings.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)
+ }
+
+ if err := hostsFile.Truncate(0); err != nil {
+ return fmt.Errorf("failed to truncate %s: %w", hostsPath, err)
+ }
+
+ if _, err := hostsFile.Seek(0, io.SeekStart); err != nil {
+ return fmt.Errorf("failed to seek %s: %w", hostsPath, err)
+ }
+
+ if _, err := hostsFile.Write([]byte(strings.Join(newLines, "\n"))); err != nil {
+ return fmt.Errorf("failed to write %s: %w", hostsPath, err)
+ }
+
+ return nil
+}
+
+func ToValidHostname(hostname string) string {
+ ascii, err := idna.Lookup.ToASCII(hostname)
+ if err != nil {
+ return ""
+ }
+ return ascii
+}
+
+func SetHostname(hostname string, fqdn string) error {
+ hostnameLock.Lock()
+ defer hostnameLock.Unlock()
+
+ hostname = ToValidHostname(strings.TrimSpace(hostname))
+ fqdn = ToValidHostname(strings.TrimSpace(fqdn))
+
+ if hostname == "" {
+ return fmt.Errorf("invalid hostname: %s", hostname)
+ }
+
+ if fqdn == "" {
+ fqdn = hostname
+ }
+
+ // update /etc/hostname
+ if err := os.WriteFile(hostnamePath, []byte(hostname), 0644); err != nil {
+ return fmt.Errorf("failed to write %s: %w", hostnamePath, err)
+ }
+
+ // update /etc/hosts
+ if err := updateEtcHosts(hostname, fqdn); err != nil {
+ return fmt.Errorf("failed to update /etc/hosts: %w", err)
+ }
+
+ // run hostname
+ if err := exec.Command("hostname", "-F", hostnamePath).Run(); err != nil {
+ return fmt.Errorf("failed to run hostname: %w", err)
+ }
+
+ return nil
+}
+
+func (s *NetworkInterfaceState) setHostnameIfNotSame() error {
+ hostname := s.GetHostname()
+ currentHostname, _ := os.Hostname()
+
+ fqdn := fmt.Sprintf("%s.%s", hostname, s.GetDomain())
+
+ if currentHostname == hostname && s.currentFqdn == fqdn && s.currentHostname == hostname {
+ return nil
+ }
+
+ scopedLogger := s.l.With().Str("hostname", hostname).Str("fqdn", fqdn).Logger()
+
+ err := SetHostname(hostname, fqdn)
+ if err != nil {
+ scopedLogger.Error().Err(err).Msg("failed to set hostname")
+ return err
+ }
+
+ s.currentHostname = hostname
+ s.currentFqdn = fqdn
+
+ scopedLogger.Info().Msg("hostname set")
+
+ return nil
+}
diff --git a/internal/network/netif.go b/internal/network/netif.go
new file mode 100644
index 0000000..c5db806
--- /dev/null
+++ b/internal/network/netif.go
@@ -0,0 +1,346 @@
+package network
+
+import (
+ "fmt"
+ "net"
+ "sync"
+
+ "github.com/jetkvm/kvm/internal/confparser"
+ "github.com/jetkvm/kvm/internal/logging"
+ "github.com/jetkvm/kvm/internal/udhcpc"
+ "github.com/rs/zerolog"
+
+ "github.com/vishvananda/netlink"
+)
+
+type NetworkInterfaceState struct {
+ interfaceName string
+ interfaceUp bool
+ ipv4Addr *net.IP
+ ipv4Addresses []string
+ ipv6Addr *net.IP
+ ipv6Addresses []IPv6Address
+ ipv6LinkLocal *net.IP
+ macAddr *net.HardwareAddr
+
+ l *zerolog.Logger
+ stateLock sync.Mutex
+
+ config *NetworkConfig
+ dhcpClient *udhcpc.DHCPClient
+
+ defaultHostname string
+ currentHostname string
+ currentFqdn string
+
+ onStateChange func(state *NetworkInterfaceState)
+ onInitialCheck func(state *NetworkInterfaceState)
+ cbConfigChange func(config *NetworkConfig)
+
+ checked bool
+}
+
+type NetworkInterfaceOptions struct {
+ InterfaceName string
+ DhcpPidFile string
+ Logger *zerolog.Logger
+ DefaultHostname string
+ OnStateChange func(state *NetworkInterfaceState)
+ OnInitialCheck func(state *NetworkInterfaceState)
+ OnDhcpLeaseChange func(lease *udhcpc.Lease)
+ OnConfigChange func(config *NetworkConfig)
+ NetworkConfig *NetworkConfig
+}
+
+func NewNetworkInterfaceState(opts *NetworkInterfaceOptions) (*NetworkInterfaceState, error) {
+ if opts.NetworkConfig == nil {
+ return nil, fmt.Errorf("NetworkConfig can not be nil")
+ }
+
+ if opts.DefaultHostname == "" {
+ opts.DefaultHostname = "jetkvm"
+ }
+
+ err := confparser.SetDefaultsAndValidate(opts.NetworkConfig)
+ if err != nil {
+ return nil, err
+ }
+
+ l := opts.Logger
+ s := &NetworkInterfaceState{
+ interfaceName: opts.InterfaceName,
+ defaultHostname: opts.DefaultHostname,
+ stateLock: sync.Mutex{},
+ l: l,
+ onStateChange: opts.OnStateChange,
+ onInitialCheck: opts.OnInitialCheck,
+ cbConfigChange: opts.OnConfigChange,
+ config: opts.NetworkConfig,
+ }
+
+ // create the dhcp client
+ dhcpClient := udhcpc.NewDHCPClient(&udhcpc.DHCPClientOptions{
+ InterfaceName: opts.InterfaceName,
+ PidFile: opts.DhcpPidFile,
+ Logger: l,
+ OnLeaseChange: func(lease *udhcpc.Lease) {
+ _, err := s.update()
+ if err != nil {
+ opts.Logger.Error().Err(err).Msg("failed to update network state")
+ return
+ }
+
+ _ = s.setHostnameIfNotSame()
+
+ opts.OnDhcpLeaseChange(lease)
+ },
+ })
+
+ s.dhcpClient = dhcpClient
+
+ return s, nil
+}
+
+func (s *NetworkInterfaceState) IsUp() bool {
+ return s.interfaceUp
+}
+
+func (s *NetworkInterfaceState) HasIPAssigned() bool {
+ return s.ipv4Addr != nil || s.ipv6Addr != nil
+}
+
+func (s *NetworkInterfaceState) IsOnline() bool {
+ return s.IsUp() && s.HasIPAssigned()
+}
+
+func (s *NetworkInterfaceState) IPv4() *net.IP {
+ return s.ipv4Addr
+}
+
+func (s *NetworkInterfaceState) IPv4String() string {
+ if s.ipv4Addr == nil {
+ return "..."
+ }
+ return s.ipv4Addr.String()
+}
+
+func (s *NetworkInterfaceState) IPv6() *net.IP {
+ return s.ipv6Addr
+}
+
+func (s *NetworkInterfaceState) IPv6String() string {
+ if s.ipv6Addr == nil {
+ return "..."
+ }
+ return s.ipv6Addr.String()
+}
+
+func (s *NetworkInterfaceState) MAC() *net.HardwareAddr {
+ return s.macAddr
+}
+
+func (s *NetworkInterfaceState) MACString() string {
+ if s.macAddr == nil {
+ return ""
+ }
+ return s.macAddr.String()
+}
+
+func (s *NetworkInterfaceState) update() (DhcpTargetState, error) {
+ s.stateLock.Lock()
+ defer s.stateLock.Unlock()
+
+ dhcpTargetState := DhcpTargetStateDoNothing
+
+ iface, err := netlink.LinkByName(s.interfaceName)
+ if err != nil {
+ s.l.Error().Err(err).Msg("failed to get interface")
+ return dhcpTargetState, err
+ }
+
+ // detect if the interface status changed
+ var changed bool
+ attrs := iface.Attrs()
+ state := attrs.OperState
+ newInterfaceUp := state == netlink.OperUp
+
+ // check if the interface is coming up
+ interfaceGoingUp := !s.interfaceUp && newInterfaceUp
+ interfaceGoingDown := s.interfaceUp && !newInterfaceUp
+
+ if s.interfaceUp != newInterfaceUp {
+ s.interfaceUp = newInterfaceUp
+ changed = true
+ }
+
+ if changed {
+ if interfaceGoingUp {
+ s.l.Info().Msg("interface state transitioned to up")
+ dhcpTargetState = DhcpTargetStateRenew
+ } else if interfaceGoingDown {
+ s.l.Info().Msg("interface state transitioned to down")
+ }
+ }
+
+ // set the mac address
+ s.macAddr = &attrs.HardwareAddr
+
+ // get the ip addresses
+ addrs, err := netlinkAddrs(iface)
+ if err != nil {
+ return dhcpTargetState, logging.ErrorfL(s.l, "failed to get ip addresses", err)
+ }
+
+ var (
+ ipv4Addresses = make([]net.IP, 0)
+ ipv4AddressesString = make([]string, 0)
+ ipv6Addresses = make([]IPv6Address, 0)
+ // ipv6AddressesString = make([]string, 0)
+ ipv6LinkLocal *net.IP
+ )
+
+ for _, addr := range addrs {
+ if addr.IP.To4() != nil {
+ scopedLogger := s.l.With().Str("ipv4", addr.IP.String()).Logger()
+ if interfaceGoingDown {
+ // remove all IPv4 addresses from the interface.
+ scopedLogger.Info().Msg("state transitioned to down, removing IPv4 address")
+ err := netlink.AddrDel(iface, &addr)
+ if err != nil {
+ scopedLogger.Warn().Err(err).Msg("failed to delete address")
+ }
+ // notify the DHCP client to release the lease
+ dhcpTargetState = DhcpTargetStateRelease
+ continue
+ }
+ ipv4Addresses = append(ipv4Addresses, addr.IP)
+ ipv4AddressesString = append(ipv4AddressesString, addr.IPNet.String())
+ } else if addr.IP.To16() != nil {
+ 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 dhcpTargetState == DhcpTargetStateRenew {
+ // it's the initial check, we'll start the DHCP client
+ // dhcpTargetState = DhcpTargetStateStart
+ // TODO: manage DHCP client start/stop
+ dhcpTargetState = DhcpTargetStateDoNothing
+ }
+ }
+
+ if initialCheck {
+ s.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) onConfigChange(config *NetworkConfig) {
+ _ = s.setHostnameIfNotSame()
+ s.cbConfigChange(config)
+}
diff --git a/internal/network/netif_linux.go b/internal/network/netif_linux.go
new file mode 100644
index 0000000..ec057f1
--- /dev/null
+++ b/internal/network/netif_linux.go
@@ -0,0 +1,58 @@
+//go:build linux
+
+package network
+
+import (
+ "time"
+
+ "github.com/vishvananda/netlink"
+ "github.com/vishvananda/netlink/nl"
+)
+
+func (s *NetworkInterfaceState) HandleLinkUpdate(update netlink.LinkUpdate) {
+ if update.Link.Attrs().Name == s.interfaceName {
+ s.l.Info().Interface("update", update).Msg("interface link update received")
+ _ = s.CheckAndUpdateDhcp()
+ }
+}
+
+func (s *NetworkInterfaceState) Run() error {
+ updates := make(chan netlink.LinkUpdate)
+ done := make(chan struct{})
+
+ if err := netlink.LinkSubscribe(updates, done); err != nil {
+ s.l.Warn().Err(err).Msg("failed to subscribe to link updates")
+ return err
+ }
+
+ _ = s.setHostnameIfNotSame()
+
+ // run the dhcp client
+ go s.dhcpClient.Run() // nolint:errcheck
+
+ if err := s.CheckAndUpdateDhcp(); err != nil {
+ return err
+ }
+
+ go func() {
+ ticker := time.NewTicker(1 * time.Second)
+ defer ticker.Stop()
+
+ for {
+ select {
+ case update := <-updates:
+ s.HandleLinkUpdate(update)
+ case <-ticker.C:
+ _ = s.CheckAndUpdateDhcp()
+ case <-done:
+ return
+ }
+ }
+ }()
+
+ return nil
+}
+
+func netlinkAddrs(iface netlink.Link) ([]netlink.Addr, error) {
+ return netlink.AddrList(iface, nl.FAMILY_ALL)
+}
diff --git a/internal/network/netif_notlinux.go b/internal/network/netif_notlinux.go
new file mode 100644
index 0000000..d101630
--- /dev/null
+++ b/internal/network/netif_notlinux.go
@@ -0,0 +1,21 @@
+//go:build !linux
+
+package network
+
+import (
+ "fmt"
+
+ "github.com/vishvananda/netlink"
+)
+
+func (s *NetworkInterfaceState) HandleLinkUpdate() error {
+ return fmt.Errorf("not implemented")
+}
+
+func (s *NetworkInterfaceState) Run() error {
+ return fmt.Errorf("not implemented")
+}
+
+func netlinkAddrs(iface netlink.Link) ([]netlink.Addr, error) {
+ return nil, fmt.Errorf("not implemented")
+}
diff --git a/internal/network/rpc.go b/internal/network/rpc.go
new file mode 100644
index 0000000..32f34f5
--- /dev/null
+++ b/internal/network/rpc.go
@@ -0,0 +1,126 @@
+package network
+
+import (
+ "fmt"
+ "time"
+
+ "github.com/jetkvm/kvm/internal/confparser"
+ "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 {
+ NetworkConfig
+}
+
+func (s *NetworkInterfaceState) MacAddress() string {
+ if s.macAddr == nil {
+ return ""
+ }
+
+ return s.macAddr.String()
+}
+
+func (s *NetworkInterfaceState) IPv4Address() string {
+ if s.ipv4Addr == nil {
+ return ""
+ }
+
+ return s.ipv4Addr.String()
+}
+
+func (s *NetworkInterfaceState) IPv6Address() string {
+ if s.ipv6Addr == nil {
+ return ""
+ }
+
+ return s.ipv6Addr.String()
+}
+
+func (s *NetworkInterfaceState) IPv6LinkLocalAddress() string {
+ if s.ipv6LinkLocal == nil {
+ return ""
+ }
+
+ return s.ipv6LinkLocal.String()
+}
+
+func (s *NetworkInterfaceState) RpcGetNetworkState() RpcNetworkState {
+ ipv6Addresses := make([]RpcIPv6Address, 0)
+
+ 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.MacAddress(),
+ IPv4: s.IPv4Address(),
+ IPv6: s.IPv6Address(),
+ IPv6LinkLocal: s.IPv6LinkLocalAddress(),
+ IPv4Addresses: s.ipv4Addresses,
+ IPv6Addresses: ipv6Addresses,
+ DHCPLease: s.dhcpClient.GetLease(),
+ }
+}
+
+func (s *NetworkInterfaceState) RpcGetNetworkSettings() RpcNetworkSettings {
+ if s.config == nil {
+ return RpcNetworkSettings{}
+ }
+
+ return RpcNetworkSettings{
+ NetworkConfig: *s.config,
+ }
+}
+
+func (s *NetworkInterfaceState) RpcSetNetworkSettings(settings RpcNetworkSettings) error {
+ currentSettings := s.config
+
+ err := confparser.SetDefaultsAndValidate(&settings.NetworkConfig)
+ if err != nil {
+ return err
+ }
+
+ if IsSame(currentSettings, settings.NetworkConfig) {
+ // no changes, do nothing
+ return nil
+ }
+
+ s.config = &settings.NetworkConfig
+ s.onConfigChange(s.config)
+
+ 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 0000000..6d64332
--- /dev/null
+++ b/internal/network/utils.go
@@ -0,0 +1,26 @@
+package network
+
+import (
+ "encoding/json"
+ "time"
+)
+
+func lifetimeToTime(lifetime int) *time.Time {
+ if lifetime == 0 {
+ return nil
+ }
+ 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)
+ if err != nil {
+ return false
+ }
+ return string(aJSON) == string(bJSON)
+}
diff --git a/internal/timesync/http.go b/internal/timesync/http.go
new file mode 100644
index 0000000..3a51463
--- /dev/null
+++ b/internal/timesync/http.go
@@ -0,0 +1,132 @@
+package timesync
+
+import (
+ "context"
+ "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
+
+ // 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()
+
+ metricHttpRequestCount.WithLabelValues(url).Inc()
+ metricHttpTotalRequestCount.Inc()
+
+ startTime := time.Now()
+ now, response, err := queryHttpTime(
+ ctx,
+ url,
+ timeout,
+ )
+ duration := time.Since(startTime)
+
+ 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")
+ }
+ 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) {
+ metricHttpCancelCount.WithLabelValues(url).Inc()
+ metricHttpTotalCancelCount.Inc()
+ } else {
+ 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, response *http.Response, err error) {
+ client := http.Client{
+ Timeout: timeout,
+ }
+ req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
+ if err != nil {
+ return nil, nil, err
+ }
+ resp, err := client.Do(req)
+ if err != nil {
+ return nil, nil, err
+ }
+ dateStr := resp.Header.Get("Date")
+ parsedTime, err := time.Parse(time.RFC1123, dateStr)
+ if err != nil {
+ return nil, nil, err
+ }
+ return &parsedTime, resp, nil
+}
diff --git a/internal/timesync/metrics.go b/internal/timesync/metrics.go
new file mode 100644
index 0000000..0e28acb
--- /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( //nolint:unused
+ 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
new file mode 100644
index 0000000..41656b7
--- /dev/null
+++ b/internal/timesync/ntp.go
@@ -0,0 +1,113 @@
+package timesync
+
+import (
+ "math/rand/v2"
+ "strconv"
+ "time"
+
+ "github.com/beevik/ntp"
+)
+
+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
+
+ // 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))]
+ now, offset := t.queryMultipleNTP(chunk, timeSyncTimeout)
+ if now != nil {
+ return now, offset
+ }
+ }
+
+ return nil, nil
+}
+
+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, response, err := 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()).
+ Str("rtt", response.RTT.String()).
+ Str("clockOffset", response.ClockOffset.String()).
+ Uint8("stratum", response.Stratum).
+ Msg("NTP server returned time")
+ results <- &ntpResult{
+ now: now,
+ offset: &response.ClockOffset,
+ }
+ } else {
+ scopedLogger.Warn().
+ Str("error", err.Error()).
+ Msg("failed to query NTP server")
+ }
+ }(server)
+ }
+
+ result := <-results
+ return result.now, result.offset
+}
+
+func queryNtpServer(server string, timeout time.Duration) (now *time.Time, response *ntp.Response, err error) {
+ resp, err := ntp.QueryWithOptions(server, ntp.QueryOptions{Timeout: timeout})
+ if err != nil {
+ return nil, nil, err
+ }
+ return &resp.Time, resp, nil
+}
diff --git a/internal/timesync/rtc.go b/internal/timesync/rtc.go
new file mode 100644
index 0000000..92ee485
--- /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 0000000..27e4ec7
--- /dev/null
+++ b/internal/timesync/rtc_linux.go
@@ -0,0 +1,105 @@
+//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)
+ }
+
+ metricRTCUpdateCount.Inc()
+
+ return nil
+}
diff --git a/internal/timesync/rtc_notlinux.go b/internal/timesync/rtc_notlinux.go
new file mode 100644
index 0000000..e3c1b20
--- /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 0000000..e956cf9
--- /dev/null
+++ b/internal/timesync/timesync.go
@@ -0,0 +1,208 @@
+package timesync
+
+import (
+ "fmt"
+ "os"
+ "os/exec"
+ "sync"
+ "time"
+
+ "github.com/jetkvm/kvm/internal/network"
+ "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
+)
+
+type TimeSync struct {
+ syncLock *sync.Mutex
+ l *zerolog.Logger
+
+ ntpServers []string
+ httpUrls []string
+ networkConfig *network.NetworkConfig
+
+ rtcDevicePath string
+ rtcDevice *os.File //nolint:unused
+ rtcLock *sync.Mutex
+
+ syncSuccess bool
+
+ preCheckFunc func() (bool, error)
+}
+
+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 {
+ opts.Logger.Error().Err(err).Msg("failed to get RTC device path")
+ } else {
+ opts.Logger.Info().Str("path", rtcDevice).Msg("RTC device found")
+ }
+
+ t := &TimeSync{
+ syncLock: &sync.Mutex{},
+ l: opts.Logger,
+ rtcDevicePath: rtcDevice,
+ rtcLock: &sync.Mutex{},
+ preCheckFunc: opts.PreCheckFunc,
+ ntpServers: defaultNTPServers,
+ httpUrls: defaultHTTPUrls,
+ networkConfig: opts.NetworkConfig,
+ }
+
+ if t.rtcDevicePath != "" {
+ rtcTime, _ := t.readRtcTime()
+ t.l.Info().Interface("rtc_time", rtcTime).Msg("read RTC time")
+ }
+
+ return t
+}
+
+func (t *TimeSync) getSyncMode() SyncMode {
+ syncMode := SyncMode{
+ NtpUseFallback: true,
+ HttpUseFallback: true,
+ }
+ var syncModeString string
+
+ if t.networkConfig != nil {
+ syncModeString = t.networkConfig.TimeSyncMode.String
+ if t.networkConfig.TimeSyncDisableFallback.Bool {
+ 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 {
+ 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")
+
+ metricTimeSyncStatus.Set(1)
+
+ time.Sleep(timeSyncInterval) // after the first sync is done
+ }
+}
+
+func (t *TimeSync) Sync() error {
+ 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()
+ }
+
+ if now == nil {
+ 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
+}
+
+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 0000000..10c9f75
--- /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 0000000..66c3ba2
--- /dev/null
+++ b/internal/udhcpc/parser.go
@@ -0,0 +1,186 @@
+package udhcpc
+
+import (
+ "bufio"
+ "encoding/json"
+ "fmt"
+ "net"
+ "os"
+ "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
+ 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
+}
+
+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 (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)
+ for _, line := range strings.Split(str, "\n") {
+ line = strings.TrimSpace(line)
+ // skip empty lines and comments
+ if line == "" || strings.HasPrefix(line, "#") {
+ continue
+ }
+
+ parts := strings.SplitN(line, "=", 2)
+ if len(parts) != 2 {
+ continue
+ }
+
+ key := strings.TrimSpace(parts[0])
+ value := strings.TrimSpace(parts[1])
+
+ data[key] = value
+ }
+
+ // now iterate over the lease struct and set the values
+ leaseType := reflect.TypeOf(lease).Elem()
+ leaseValue := reflect.ValueOf(lease).Elem()
+
+ valuesParsed := make(map[string]bool)
+
+ for i := 0; i < leaseType.NumField(); i++ {
+ field := leaseValue.Field(i)
+
+ // get the env tag
+ key := leaseType.Field(i).Tag.Get("env")
+ if key == "" {
+ continue
+ }
+
+ valuesParsed[key] = false
+
+ // get the value from the data map
+ value, ok := data[key]
+ if !ok || value == "" {
+ continue
+ }
+
+ switch field.Interface().(type) {
+ case string:
+ field.SetString(value)
+ case int:
+ val, err := strconv.Atoi(value)
+ if err != nil {
+ continue
+ }
+ field.SetInt(int64(val))
+ case time.Duration:
+ val, err := time.ParseDuration(value + "s")
+ if err != nil {
+ continue
+ }
+ field.Set(reflect.ValueOf(val))
+ case net.IP:
+ ip := net.ParseIP(value)
+ if ip == nil {
+ continue
+ }
+ field.Set(reflect.ValueOf(ip))
+ case []net.IP:
+ val := make([]net.IP, 0)
+ for _, ipStr := range strings.Fields(value) {
+ ip := net.ParseIP(ipStr)
+ if ip == nil {
+ continue
+ }
+ val = append(val, ip)
+ }
+ field.Set(reflect.ValueOf(val))
+ default:
+ return fmt.Errorf("unsupported field `%s` type: %s", key, field.Type().String())
+ }
+
+ valuesParsed[key] = true
+ }
+
+ lease.setIsEmpty(valuesParsed)
+
+ return nil
+}
diff --git a/internal/udhcpc/parser_test.go b/internal/udhcpc/parser_test.go
new file mode 100644
index 0000000..423ab53
--- /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 0000000..69c2ab9
--- /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 0000000..70ac1b8
--- /dev/null
+++ b/internal/udhcpc/udhcpc.go
@@ -0,0 +1,191 @@
+package udhcpc
+
+import (
+ "errors"
+ "fmt"
+ "os"
+ "path/filepath"
+ "time"
+
+ "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,
+ }
+}
+
+func (c *DHCPClient) getWatchPaths() []string {
+ watchPaths := make(map[string]interface{})
+ watchPaths[filepath.Dir(c.leaseFile)] = nil
+
+ if c.pidFile != "" {
+ watchPaths[filepath.Dir(c.pidFile)] = nil
+ }
+
+ paths := make([]string, 0)
+ for path := range watchPaths {
+ paths = append(paths, path)
+ }
+ return paths
+}
+
+// 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 {
+ continue
+ }
+ if !event.Has(fsnotify.Write) && !event.Has(fsnotify.Create) {
+ continue
+ }
+
+ if event.Name == c.leaseFile {
+ c.logger.Debug().
+ Str("event", event.Op.String()).
+ Str("path", 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")
+ }
+ }
+ }()
+
+ for _, path := range c.getWatchPaths() {
+ err = watcher.Add(path)
+ if err != nil {
+ c.logger.Error().
+ Err(err).
+ Str("path", path).
+ Msg("failed to watch directory")
+ return err
+ }
+ }
+
+ // 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"
+ }
+
+ leaseExpiry, err := lease.SetLeaseExpiry()
+ if err != nil {
+ c.logger.Error().Err(err).Msg("failed to get dhcp lease expiry")
+ } else {
+ expiresIn := time.Until(leaseExpiry)
+ c.logger.Info().
+ Interface("expiry", leaseExpiry).
+ Str("expiresIn", expiresIn.String()).
+ Msg("current dhcp lease expiry time calculated")
+ }
+
+ c.onLeaseChange(lease)
+
+ c.logger.Info().
+ Str("ip", lease.IPAddress.String()).
+ Str("leaseTime", lease.LeaseTime.String()).
+ Interface("data", lease).
+ Msg(msg)
+
+ return nil
+}
+
+func (c *DHCPClient) GetLease() *Lease {
+ return c.lease
+}
diff --git a/internal/websecure/store.go b/internal/websecure/store.go
index 69ae3ef..ea7911c 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
@@ -131,7 +135,7 @@ func (s *CertStore) ValidateAndSaveCertificate(hostname string, cert string, key
if !ignoreWarning {
return nil, fmt.Errorf("certificate does not match hostname: %w", err)
}
- s.log.Warn().Err(err).Msg("Certificate does not match hostname")
+ s.log.Warn().Err(err).Msg("certificate does not match hostname")
}
}
diff --git a/jsonrpc.go b/jsonrpc.go
index 248390e..d35f635 100644
--- a/jsonrpc.go
+++ b/jsonrpc.go
@@ -962,6 +962,10 @@ var rpcHandlers = map[string]RPCHandler{
"getDeviceID": {Func: rpcGetDeviceID},
"deregisterDevice": {Func: rpcDeregisterDevice},
"getCloudState": {Func: rpcGetCloudState},
+ "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/log.go b/log.go
index ed46852..b353a2c 100644
--- a/log.go
+++ b/log.go
@@ -1,291 +1,32 @@
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")
- cloudLogger = rootLogger.getLogger("cloud")
- websocketLogger = rootLogger.getLogger("websocket")
- webrtcLogger = rootLogger.getLogger("webrtc")
- nativeLogger = rootLogger.getLogger("native")
- nbdLogger = rootLogger.getLogger("nbd")
- ntpLogger = rootLogger.getLogger("ntp")
- 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")
+ 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{}
diff --git a/main.go b/main.go
index 9eab708..25fbb3a 100644
--- a/main.go
+++ b/main.go
@@ -15,28 +15,54 @@ 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")
+
+ // Initialize network
+ if err := initNetwork(); err != nil {
+ logger.Error().Err(err).Msg("failed to initialize network")
+ os.Exit(1)
}
- initNetwork()
+ // Initialize time sync
+ initTimeSync()
+ timeSync.Start()
- go TimeSyncLoop()
+ // 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
new file mode 100644
index 0000000..d7a3b55
--- /dev/null
+++ b/mdns.go
@@ -0,0 +1,29 @@
+package kvm
+
+import (
+ "github.com/jetkvm/kvm/internal/mdns"
+)
+
+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
+ }
+
+ // do not start the server yet, as we need to wait for the network state to be set
+ mDNS = m
+
+ return nil
+}
diff --git a/native.go b/native.go
index b61598c..496f580 100644
--- a/native.go
+++ b/native.go
@@ -8,13 +8,10 @@ import (
"io"
"net"
"os"
- "os/exec"
"sync"
- "syscall"
"time"
"github.com/jetkvm/kvm/resource"
- "github.com/rs/zerolog"
"github.com/pion/webrtc/v4/pkg/media"
)
@@ -36,19 +33,6 @@ type CtrlResponse struct {
Data json.RawMessage `json:"data,omitempty"`
}
-type nativeOutput struct {
- mu *sync.Mutex
- logger *zerolog.Event
-}
-
-func (w *nativeOutput) Write(p []byte) (n int, err error) {
- w.mu.Lock()
- defer w.mu.Unlock()
-
- w.logger.Msg(string(p))
- return len(p), nil
-}
-
type EventHandler func(event CtrlResponse)
var seq int32 = 1
@@ -262,30 +246,8 @@ func ExtractAndRunNativeBin() error {
return fmt.Errorf("failed to make binary executable: %w", err)
}
// Run the binary in the background
- cmd := exec.Command(binaryPath)
-
- nativeOutputLock := sync.Mutex{}
- nativeStdout := &nativeOutput{
- mu: &nativeOutputLock,
- logger: nativeLogger.Info().Str("pipe", "stdout"),
- }
- nativeStderr := &nativeOutput{
- mu: &nativeOutputLock,
- logger: nativeLogger.Info().Str("pipe", "stderr"),
- }
-
- // Redirect stdout and stderr to the current process
- cmd.Stdout = nativeStdout
- cmd.Stderr = nativeStderr
-
- // Set the process group ID so we can kill the process and its children when this process exits
- cmd.SysProcAttr = &syscall.SysProcAttr{
- Setpgid: true,
- Pdeathsig: syscall.SIGKILL,
- }
-
- // Start the command
- if err := cmd.Start(); err != nil {
+ cmd, err := startNativeBinary(binaryPath)
+ if err != nil {
return fmt.Errorf("failed to start binary: %w", err)
}
@@ -335,7 +297,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/native_linux.go b/native_linux.go
new file mode 100644
index 0000000..54d2150
--- /dev/null
+++ b/native_linux.go
@@ -0,0 +1,57 @@
+//go:build linux
+
+package kvm
+
+import (
+ "fmt"
+ "os/exec"
+ "sync"
+ "syscall"
+
+ "github.com/rs/zerolog"
+)
+
+type nativeOutput struct {
+ mu *sync.Mutex
+ logger *zerolog.Event
+}
+
+func (w *nativeOutput) Write(p []byte) (n int, err error) {
+ w.mu.Lock()
+ defer w.mu.Unlock()
+
+ w.logger.Msg(string(p))
+ return len(p), nil
+}
+
+func startNativeBinary(binaryPath string) (*exec.Cmd, error) {
+ // Run the binary in the background
+ cmd := exec.Command(binaryPath)
+
+ nativeOutputLock := sync.Mutex{}
+ nativeStdout := &nativeOutput{
+ mu: &nativeOutputLock,
+ logger: nativeLogger.Info().Str("pipe", "stdout"),
+ }
+ nativeStderr := &nativeOutput{
+ mu: &nativeOutputLock,
+ logger: nativeLogger.Info().Str("pipe", "stderr"),
+ }
+
+ // Redirect stdout and stderr to the current process
+ cmd.Stdout = nativeStdout
+ cmd.Stderr = nativeStderr
+
+ // Set the process group ID so we can kill the process and its children when this process exits
+ cmd.SysProcAttr = &syscall.SysProcAttr{
+ Setpgid: true,
+ Pdeathsig: syscall.SIGKILL,
+ }
+
+ // Start the command
+ if err := cmd.Start(); err != nil {
+ return nil, fmt.Errorf("failed to start binary: %w", err)
+ }
+
+ return cmd, nil
+}
diff --git a/native_notlinux.go b/native_notlinux.go
new file mode 100644
index 0000000..df6df74
--- /dev/null
+++ b/native_notlinux.go
@@ -0,0 +1,12 @@
+//go:build !linux
+
+package kvm
+
+import (
+ "fmt"
+ "os/exec"
+)
+
+func startNativeBinary(binaryPath string) (*exec.Cmd, error) {
+ return nil, fmt.Errorf("not supported")
+}
diff --git a/network.go b/network.go
index 6948d9a..8d9261b 100644
--- a/network.go
+++ b/network.go
@@ -1,237 +1,107 @@
package kvm
import (
- "bytes"
"fmt"
- "net"
- "os"
- "strings"
- "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/vishvananda/netlink"
- "github.com/vishvananda/netlink/nl"
+ "github.com/jetkvm/kvm/internal/network"
+ "github.com/jetkvm/kvm/internal/udhcpc"
)
-var mDNSConn *mdns.Conn
-
-var networkState NetworkState
-
-type NetworkState struct {
- Up bool
- IPv4 string
- IPv6 string
- MAC string
-
- checked bool
-}
-
-type LocalIpInfo struct {
- IPv4 string
- IPv6 string
- MAC string
-}
-
const (
- NetIfName = "eth0"
- DHCPLeaseFile = "/run/udhcpc.%s.info"
+ 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"
- }
+var (
+ networkState *network.NetworkInterfaceState
+)
- 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")
+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 checkNetworkState() {
- iface, err := netlink.LinkByName(NetIfName)
- if err != nil {
- logger.Warn().Err(err).Str("interface", NetIfName).Msg("failed to get interface")
- return
- }
+func initNetwork() error {
+ ensureConfigLoaded()
- newState := NetworkState{
- Up: iface.Attrs().OperState == netlink.OperUp,
- MAC: iface.Attrs().HardwareAddr.String(),
+ state, err := network.NewNetworkInterfaceState(&network.NetworkInterfaceOptions{
+ DefaultHostname: GetDefaultHostname(),
+ InterfaceName: NetIfName,
+ NetworkConfig: config.NetworkConfig,
+ Logger: networkLogger,
+ OnStateChange: func(state *network.NetworkInterfaceState) {
+ networkStateChanged()
+ },
+ OnInitialCheck: func(state *network.NetworkInterfaceState) {
+ networkStateChanged()
+ },
+ OnDhcpLeaseChange: func(lease *udhcpc.Lease) {
+ networkStateChanged()
- checked: true,
- }
-
- addrs, err := netlink.AddrList(iface, nl.FAMILY_ALL)
- if err != nil {
- logger.Warn().Err(err).Str("interface", NetIfName).Msg("failed to get addresses")
- }
-
- // 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)
- }
-
- 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")
- err := netlink.AddrDel(iface, &addr)
- if err != nil {
- logger.Warn().Err(err).Str("address", addr.IP.String()).Msg("network: failed to delete address")
- }
-
- newState.IPv4 = "..."
- } else {
- newState.IPv4 = addr.IP.String()
+ if currentSession == nil {
+ return
}
- } else if addr.IP.To16() != nil && newState.IPv6 == "" {
- newState.IPv6 = addr.IP.String()
- }
- }
- if newState != networkState {
- logger.Info().
- Interface("newState", newState).
- Interface("oldState", networkState).
- Msg("network state changed")
+ writeJSONRPCEvent("networkState", networkState.RpcGetNetworkState(), currentSession)
+ },
+ OnConfigChange: func(networkConfig *network.NetworkConfig) {
+ config.NetworkConfig = networkConfig
+ networkStateChanged()
- // 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")
- }
- }
-
- // 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 mDNS != nil {
+ _ = mDNS.SetListenOptions(networkConfig.GetMDNSMode())
+ _ = mDNS.SetLocalNames([]string{
+ networkState.GetHostname(),
+ networkState.GetFQDN(),
+ }, true)
+ }
+ },
})
- if err != nil {
- scopedLogger.Warn().Err(err).Msg("failed to start mDNS server")
- mDNSConn = nil
+
+ if state == nil {
+ if err == nil {
+ return fmt.Errorf("failed to create NetworkInterfaceState")
+ }
return err
}
- //defer server.Close()
+
+ if err := state.Run(); err != nil {
+ return err
+ }
+
+ networkState = state
+
return 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))
- if err != nil {
- return nil, fmt.Errorf("failed to parse udhcpc info: %w", err)
- }
-
- val, ok := env["ntpsrv"]
- if !ok {
- return nil, nil
- }
-
- var servers []string
-
- 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)
- }
-
- return servers, nil
+func rpcGetNetworkState() network.RpcNetworkState {
+ return networkState.RpcGetNetworkState()
}
-func initNetwork() {
- ensureConfigLoaded()
-
- updates := make(chan netlink.LinkUpdate)
- done := make(chan struct{})
-
- if err := netlink.LinkSubscribe(updates, done); err != nil {
- logger.Warn().Err(err).Msg("failed to subscribe to link updates")
- return
- }
-
- 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()
- }
- case <-ticker.C:
- checkNetworkState()
- case <-done:
- return
- }
- }
- }()
- err := startMDNS()
- if err != nil {
- logger.Warn().Err(err).Msg("failed to run mDNS")
- }
+func rpcGetNetworkSettings() network.RpcNetworkSettings {
+ return networkState.RpcGetNetworkSettings()
+}
+
+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 {
+ return networkState.RpcRenewDHCPLease()
}
diff --git a/ntp.go b/ntp.go
deleted file mode 100644
index a104c56..0000000
--- a/ntp.go
+++ /dev/null
@@ -1,197 +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()
-
- 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")
- return true
- }
-
- return false
-}
-
-func TimeSyncLoop() {
- for {
- if !networkState.checked {
- time.Sleep(timeSyncWaitNetChkInt)
- continue
- }
-
- if !networkState.Up {
- ntpLogger.Info().Msg("Waiting for network to come up")
- 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 := queryNtpServer(server, timeSyncTimeout)
- if err == nil {
- ntpLogger.Info().
- Str("ntp_server", server).
- Str("time", now.Format(time.RFC3339)).
- Msg("NTP server returned time")
- return now, nil
- } else {
- ntpLogger.Error().
- Str("ntp_server", server).
- Str("error", err.Error()).
- Msg("failed to query NTP server")
- }
- }
-
- httpUrls := []string{
- "http://apple.com",
- "http://cloudflare.com",
- }
- for _, url := range httpUrls {
- now, err := queryHttpTime(url, timeSyncTimeout)
- if err == nil {
- ntpLogger.Info().
- Str("http_url", url).
- Str("time", now.Format(time.RFC3339)).
- Msg("HTTP server returned time")
- return now, nil
- } else {
- ntpLogger.Error().
- Str("http_url", url).
- 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) {
- resp, err := ntp.QueryWithOptions(server, ntp.QueryOptions{Timeout: timeout})
- if err != nil {
- return nil, err
- }
- return &resp.Time, nil
-}
-
-func queryHttpTime(url string, timeout time.Duration) (*time.Time, error) {
- client := http.Client{
- Timeout: timeout,
- }
- resp, err := client.Head(url)
- if err != nil {
- return nil, err
- }
- dateStr := resp.Header.Get("Date")
- now, err := time.Parse(time.RFC1123, dateStr)
- if err != nil {
- return nil, err
- }
- return &now, nil
-}
-
-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/ota.go b/ota.go
index a5da772..0559978 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)
diff --git a/resource/jetkvm_native b/resource/jetkvm_native
index 0d0719c796e876a7b4277cacc1fbac0772cf10e7..084ce14970c8ef559f9c2e2bcdfd05d5a8993952 100644
GIT binary patch
delta 217848
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