mirror of https://github.com/jetkvm/kvm.git
Compare commits
2 Commits
83d544f0f7
...
beb7bde5c0
| Author | SHA1 | Date |
|---|---|---|
|
|
beb7bde5c0 | |
|
|
c775979ccb |
|
|
@ -4,12 +4,11 @@
|
|||
"features": {
|
||||
"ghcr.io/devcontainers/features/node:1": {
|
||||
// Should match what is defined in ui/package.json
|
||||
"version": "22.19.0"
|
||||
"version": "22.20.0"
|
||||
}
|
||||
},
|
||||
"mounts": [
|
||||
"source=${localEnv:HOME}/.ssh,target=/home/vscode/.ssh,type=bind,consistency=cached",
|
||||
"source=/var/run/docker.sock,target=/var/run/docker.sock,type=bind"
|
||||
"source=${localEnv:HOME}/.ssh,target=/home/vscode/.ssh,type=bind,consistency=cached"
|
||||
],
|
||||
"onCreateCommand": ".devcontainer/install-deps.sh",
|
||||
"customizations": {
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
"features": {
|
||||
"ghcr.io/devcontainers/features/node:1": {
|
||||
// Should match what is defined in ui/package.json
|
||||
"version": "22.19.0"
|
||||
"version": "22.20.0"
|
||||
}
|
||||
},
|
||||
"runArgs": [
|
||||
|
|
|
|||
|
|
@ -3,5 +3,12 @@
|
|||
"cva",
|
||||
"cx"
|
||||
],
|
||||
"git.ignoreLimitWarning": true
|
||||
"gopls": {
|
||||
"build.buildFlags": [
|
||||
"-tags",
|
||||
"synctrace"
|
||||
]
|
||||
},
|
||||
"git.ignoreLimitWarning": true,
|
||||
"cmake.sourceDirectory": "/workspaces/kvm-static-ip/internal/native/cgo"
|
||||
}
|
||||
6
Makefile
6
Makefile
|
|
@ -12,7 +12,13 @@ BUILDKIT_FLAVOR := arm-rockchip830-linux-uclibcgnueabihf
|
|||
BUILDKIT_PATH ?= /opt/jetkvm-native-buildkit
|
||||
SKIP_NATIVE_IF_EXISTS ?= 0
|
||||
SKIP_UI_BUILD ?= 0
|
||||
ENABLE_SYNC_TRACE ?= 0
|
||||
|
||||
GO_BUILD_ARGS := -tags netgo,timetzdata,nomsgpack
|
||||
ifeq ($(ENABLE_SYNC_TRACE), 1)
|
||||
GO_BUILD_ARGS := $(GO_BUILD_ARGS),synctrace
|
||||
endif
|
||||
|
||||
GO_RELEASE_BUILD_ARGS := -trimpath $(GO_BUILD_ARGS)
|
||||
GO_LDFLAGS := \
|
||||
-s -w \
|
||||
|
|
|
|||
2
cloud.go
2
cloud.go
|
|
@ -494,7 +494,7 @@ func RunWebsocketClient() {
|
|||
}
|
||||
|
||||
// If the network is not up, well, we can't connect to the cloud.
|
||||
if !networkState.IsOnline() {
|
||||
if !networkManager.IsOnline() {
|
||||
cloudLogger.Warn().Msg("waiting for network to be online, will retry in 3 seconds")
|
||||
time.Sleep(3 * time.Second)
|
||||
continue
|
||||
|
|
|
|||
111
cmd/main.go
111
cmd/main.go
|
|
@ -16,10 +16,10 @@ import (
|
|||
)
|
||||
|
||||
const (
|
||||
envChildID = "JETKVM_CHILD_ID"
|
||||
errorDumpDir = "/userdata/jetkvm/"
|
||||
errorDumpStateFile = ".has_error_dump"
|
||||
errorDumpTemplate = "jetkvm-%s.log"
|
||||
envChildID = "JETKVM_CHILD_ID"
|
||||
errorDumpDir = "/userdata/jetkvm/crashdump"
|
||||
errorDumpLastFile = "last-crash.log"
|
||||
errorDumpTemplate = "jetkvm-%s.log"
|
||||
)
|
||||
|
||||
func program() {
|
||||
|
|
@ -117,30 +117,47 @@ func supervise() error {
|
|||
return nil
|
||||
}
|
||||
|
||||
func createErrorDump(logFile *os.File) {
|
||||
logFile.Close()
|
||||
|
||||
// touch the error dump state file
|
||||
if err := os.WriteFile(filepath.Join(errorDumpDir, errorDumpStateFile), []byte{}, 0644); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
fileName := fmt.Sprintf(errorDumpTemplate, time.Now().Format("20060102150405"))
|
||||
filePath := filepath.Join(errorDumpDir, fileName)
|
||||
if err := os.Rename(logFile.Name(), filePath); err == nil {
|
||||
fmt.Printf("error dump created: %s\n", filePath)
|
||||
return
|
||||
}
|
||||
|
||||
fnSrc, err := os.Open(logFile.Name())
|
||||
func isSymlinkTo(oldName, newName string) bool {
|
||||
file, err := os.Stat(newName)
|
||||
if err != nil {
|
||||
return
|
||||
return false
|
||||
}
|
||||
if file.Mode()&os.ModeSymlink != os.ModeSymlink {
|
||||
return false
|
||||
}
|
||||
target, err := os.Readlink(newName)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return target == oldName
|
||||
}
|
||||
|
||||
func ensureSymlink(oldName, newName string) error {
|
||||
if isSymlinkTo(oldName, newName) {
|
||||
return nil
|
||||
}
|
||||
_ = os.Remove(newName)
|
||||
return os.Symlink(oldName, newName)
|
||||
}
|
||||
|
||||
func renameFile(f *os.File, newName string) error {
|
||||
_ = f.Close()
|
||||
|
||||
// try to rename the file first
|
||||
if err := os.Rename(f.Name(), newName); err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// copy the log file to the error dump directory
|
||||
fnSrc, err := os.Open(f.Name())
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to open file: %w", err)
|
||||
}
|
||||
defer fnSrc.Close()
|
||||
|
||||
fnDst, err := os.Create(filePath)
|
||||
fnDst, err := os.Create(newName)
|
||||
if err != nil {
|
||||
return
|
||||
return fmt.Errorf("failed to create file: %w", err)
|
||||
}
|
||||
defer fnDst.Close()
|
||||
|
||||
|
|
@ -148,18 +165,60 @@ func createErrorDump(logFile *os.File) {
|
|||
for {
|
||||
n, err := fnSrc.Read(buf)
|
||||
if err != nil && err != io.EOF {
|
||||
return
|
||||
return fmt.Errorf("failed to read file: %w", err)
|
||||
}
|
||||
if n == 0 {
|
||||
break
|
||||
}
|
||||
|
||||
if _, err := fnDst.Write(buf[:n]); err != nil {
|
||||
return
|
||||
return fmt.Errorf("failed to write file: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Printf("error dump created: %s\n", filePath)
|
||||
return nil
|
||||
}
|
||||
|
||||
func ensureErrorDumpDir() error {
|
||||
// TODO: check if the directory is writable
|
||||
f, err := os.Stat(errorDumpDir)
|
||||
if err == nil && f.IsDir() {
|
||||
return nil
|
||||
}
|
||||
if err := os.MkdirAll(errorDumpDir, 0755); err != nil {
|
||||
return fmt.Errorf("failed to create error dump directory: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func createErrorDump(logFile *os.File) {
|
||||
fmt.Println()
|
||||
|
||||
fileName := fmt.Sprintf(
|
||||
errorDumpTemplate,
|
||||
time.Now().Format("20060102-150405"),
|
||||
)
|
||||
|
||||
// check if the directory exists
|
||||
if err := ensureErrorDumpDir(); err != nil {
|
||||
fmt.Printf("failed to ensure error dump directory: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
filePath := filepath.Join(errorDumpDir, fileName)
|
||||
if err := renameFile(logFile, filePath); err != nil {
|
||||
fmt.Printf("failed to rename file: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Printf("error dump copied: %s\n", filePath)
|
||||
|
||||
lastFilePath := filepath.Join(errorDumpDir, errorDumpLastFile)
|
||||
|
||||
if err := ensureSymlink(filePath, lastFilePath); err != nil {
|
||||
fmt.Printf("failed to create symlink: %v\n", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func doSupervise() {
|
||||
|
|
|
|||
142
config.go
142
config.go
|
|
@ -7,8 +7,9 @@ import (
|
|||
"strconv"
|
||||
"sync"
|
||||
|
||||
"github.com/jetkvm/kvm/internal/confparser"
|
||||
"github.com/jetkvm/kvm/internal/logging"
|
||||
"github.com/jetkvm/kvm/internal/network"
|
||||
"github.com/jetkvm/kvm/internal/network/types"
|
||||
"github.com/jetkvm/kvm/internal/usbgadget"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/prometheus/client_golang/prometheus/promauto"
|
||||
|
|
@ -78,33 +79,33 @@ 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"`
|
||||
JigglerConfig *JigglerConfig `json:"jiggler_config"`
|
||||
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
|
||||
LocalLoopbackOnly bool `json:"local_loopback_only"`
|
||||
WakeOnLanDevices []WakeOnLanDevice `json:"wake_on_lan_devices"`
|
||||
KeyboardMacros []KeyboardMacro `json:"keyboard_macros"`
|
||||
KeyboardLayout string `json:"keyboard_layout"`
|
||||
EdidString string `json:"hdmi_edid_string"`
|
||||
ActiveExtension string `json:"active_extension"`
|
||||
DisplayRotation string `json:"display_rotation"`
|
||||
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"`
|
||||
VideoSleepAfterSec int `json:"video_sleep_after_sec"`
|
||||
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"`
|
||||
JigglerConfig *JigglerConfig `json:"jiggler_config"`
|
||||
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
|
||||
LocalLoopbackOnly bool `json:"local_loopback_only"`
|
||||
WakeOnLanDevices []WakeOnLanDevice `json:"wake_on_lan_devices"`
|
||||
KeyboardMacros []KeyboardMacro `json:"keyboard_macros"`
|
||||
KeyboardLayout string `json:"keyboard_layout"`
|
||||
EdidString string `json:"hdmi_edid_string"`
|
||||
ActiveExtension string `json:"active_extension"`
|
||||
DisplayRotation string `json:"display_rotation"`
|
||||
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 *types.NetworkConfig `json:"network_config"`
|
||||
DefaultLogLevel string `json:"default_log_level"`
|
||||
VideoSleepAfterSec int `json:"video_sleep_after_sec"`
|
||||
}
|
||||
|
||||
func (c *Config) GetDisplayRotation() uint16 {
|
||||
|
|
@ -128,41 +129,55 @@ func (c *Config) SetDisplayRotation(rotation string) error {
|
|||
|
||||
const configPath = "/userdata/kvm_config.json"
|
||||
|
||||
var defaultConfig = &Config{
|
||||
CloudURL: "https://api.jetkvm.com",
|
||||
CloudAppURL: "https://app.jetkvm.com",
|
||||
AutoUpdateEnabled: true, // Set a default value
|
||||
ActiveExtension: "",
|
||||
KeyboardMacros: []KeyboardMacro{},
|
||||
DisplayRotation: "270",
|
||||
KeyboardLayout: "en-US",
|
||||
DisplayMaxBrightness: 64,
|
||||
DisplayDimAfterSec: 120, // 2 minutes
|
||||
DisplayOffAfterSec: 1800, // 30 minutes
|
||||
JigglerEnabled: false,
|
||||
// This is the "Standard" jiggler option in the UI
|
||||
JigglerConfig: &JigglerConfig{
|
||||
// it's a temporary solution to avoid sharing the same pointer
|
||||
// we should migrate to a proper config solution in the future
|
||||
var (
|
||||
defaultJigglerConfig = JigglerConfig{
|
||||
InactivityLimitSeconds: 60,
|
||||
JitterPercentage: 25,
|
||||
ScheduleCronTab: "0 * * * * *",
|
||||
Timezone: "UTC",
|
||||
},
|
||||
TLSMode: "",
|
||||
UsbConfig: &usbgadget.Config{
|
||||
}
|
||||
defaultUsbConfig = usbgadget.Config{
|
||||
VendorId: "0x1d6b", //The Linux Foundation
|
||||
ProductId: "0x0104", //Multifunction Composite Gadget
|
||||
SerialNumber: "",
|
||||
Manufacturer: "JetKVM",
|
||||
Product: "USB Emulation Device",
|
||||
},
|
||||
UsbDevices: &usbgadget.Devices{
|
||||
}
|
||||
defaultUsbDevices = usbgadget.Devices{
|
||||
AbsoluteMouse: true,
|
||||
RelativeMouse: true,
|
||||
Keyboard: true,
|
||||
MassStorage: true,
|
||||
},
|
||||
NetworkConfig: &network.NetworkConfig{},
|
||||
DefaultLogLevel: "INFO",
|
||||
}
|
||||
)
|
||||
|
||||
func getDefaultConfig() Config {
|
||||
return Config{
|
||||
CloudURL: "https://api.jetkvm.com",
|
||||
CloudAppURL: "https://app.jetkvm.com",
|
||||
AutoUpdateEnabled: true, // Set a default value
|
||||
ActiveExtension: "",
|
||||
KeyboardMacros: []KeyboardMacro{},
|
||||
DisplayRotation: "270",
|
||||
KeyboardLayout: "en-US",
|
||||
DisplayMaxBrightness: 64,
|
||||
DisplayDimAfterSec: 120, // 2 minutes
|
||||
DisplayOffAfterSec: 1800, // 30 minutes
|
||||
JigglerEnabled: false,
|
||||
// This is the "Standard" jiggler option in the UI
|
||||
JigglerConfig: func() *JigglerConfig { c := defaultJigglerConfig; return &c }(),
|
||||
TLSMode: "",
|
||||
UsbConfig: func() *usbgadget.Config { c := defaultUsbConfig; return &c }(),
|
||||
UsbDevices: func() *usbgadget.Devices { c := defaultUsbDevices; return &c }(),
|
||||
NetworkConfig: func() *types.NetworkConfig {
|
||||
c := &types.NetworkConfig{}
|
||||
_ = confparser.SetDefaultsAndValidate(c)
|
||||
return c
|
||||
}(),
|
||||
DefaultLogLevel: "INFO",
|
||||
}
|
||||
}
|
||||
|
||||
var (
|
||||
|
|
@ -195,7 +210,8 @@ func LoadConfig() {
|
|||
}
|
||||
|
||||
// load the default config
|
||||
config = defaultConfig
|
||||
defaultConfig := getDefaultConfig()
|
||||
config = &defaultConfig
|
||||
|
||||
file, err := os.Open(configPath)
|
||||
if err != nil {
|
||||
|
|
@ -207,7 +223,7 @@ func LoadConfig() {
|
|||
defer file.Close()
|
||||
|
||||
// load and merge the default config with the user config
|
||||
loadedConfig := *defaultConfig
|
||||
loadedConfig := defaultConfig
|
||||
if err := json.NewDecoder(file).Decode(&loadedConfig); err != nil {
|
||||
logger.Warn().Err(err).Msg("config file JSON parsing failed")
|
||||
configSuccess.Set(0.0)
|
||||
|
|
@ -216,19 +232,19 @@ func LoadConfig() {
|
|||
|
||||
// merge the user config with the default config
|
||||
if loadedConfig.UsbConfig == nil {
|
||||
loadedConfig.UsbConfig = defaultConfig.UsbConfig
|
||||
loadedConfig.UsbConfig = getDefaultConfig().UsbConfig
|
||||
}
|
||||
|
||||
if loadedConfig.UsbDevices == nil {
|
||||
loadedConfig.UsbDevices = defaultConfig.UsbDevices
|
||||
loadedConfig.UsbDevices = getDefaultConfig().UsbDevices
|
||||
}
|
||||
|
||||
if loadedConfig.NetworkConfig == nil {
|
||||
loadedConfig.NetworkConfig = defaultConfig.NetworkConfig
|
||||
loadedConfig.NetworkConfig = getDefaultConfig().NetworkConfig
|
||||
}
|
||||
|
||||
if loadedConfig.JigglerConfig == nil {
|
||||
loadedConfig.JigglerConfig = defaultConfig.JigglerConfig
|
||||
loadedConfig.JigglerConfig = getDefaultConfig().JigglerConfig
|
||||
}
|
||||
|
||||
// fixup old keyboard layout value
|
||||
|
|
@ -247,17 +263,25 @@ func LoadConfig() {
|
|||
}
|
||||
|
||||
func SaveConfig() error {
|
||||
return saveConfig(configPath)
|
||||
}
|
||||
|
||||
func SaveBackupConfig() error {
|
||||
return saveConfig(configPath + ".bak")
|
||||
}
|
||||
|
||||
func saveConfig(path string) error {
|
||||
configLock.Lock()
|
||||
defer configLock.Unlock()
|
||||
|
||||
logger.Trace().Str("path", configPath).Msg("Saving config")
|
||||
logger.Trace().Str("path", path).Msg("Saving config")
|
||||
|
||||
// fixup old keyboard layout value
|
||||
if config.KeyboardLayout == "en_US" {
|
||||
config.KeyboardLayout = "en-US"
|
||||
}
|
||||
|
||||
file, err := os.Create(configPath)
|
||||
file, err := os.Create(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create config file: %w", err)
|
||||
}
|
||||
|
|
@ -273,7 +297,7 @@ func SaveConfig() error {
|
|||
return fmt.Errorf("failed to wite config: %w", err)
|
||||
}
|
||||
|
||||
logger.Info().Str("path", configPath).Msg("config saved")
|
||||
logger.Info().Str("path", path).Msg("config saved")
|
||||
return nil
|
||||
}
|
||||
|
||||
|
|
|
|||
44
display.go
44
display.go
|
|
@ -27,7 +27,12 @@ const (
|
|||
)
|
||||
|
||||
func switchToMainScreen() {
|
||||
if networkState.IsUp() {
|
||||
if networkManager == nil {
|
||||
nativeInstance.SwitchToScreenIfDifferent("no_network_screen")
|
||||
return
|
||||
}
|
||||
|
||||
if networkManager.IsUp() {
|
||||
nativeInstance.SwitchToScreenIfDifferent("home_screen")
|
||||
} else {
|
||||
nativeInstance.SwitchToScreenIfDifferent("no_network_screen")
|
||||
|
|
@ -35,13 +40,21 @@ func switchToMainScreen() {
|
|||
}
|
||||
|
||||
func updateDisplay() {
|
||||
nativeInstance.UpdateLabelIfChanged("home_info_ipv4_addr", networkState.IPv4String())
|
||||
nativeInstance.UpdateLabelAndChangeVisibility("home_info_ipv6_addr", networkState.IPv6String())
|
||||
if networkManager != nil {
|
||||
nativeInstance.UpdateLabelIfChanged("home_info_ipv4_addr", networkManager.IPv4String())
|
||||
nativeInstance.UpdateLabelAndChangeVisibility("home_info_ipv6_addr", networkManager.IPv6String())
|
||||
nativeInstance.UpdateLabelIfChanged("home_info_mac_addr", networkManager.MACString())
|
||||
}
|
||||
|
||||
_, _ = nativeInstance.UIObjHide("menu_btn_network")
|
||||
_, _ = nativeInstance.UIObjHide("menu_btn_access")
|
||||
|
||||
nativeInstance.UpdateLabelIfChanged("home_info_mac_addr", networkState.MACString())
|
||||
switch config.NetworkConfig.DHCPClient.String {
|
||||
case "jetdhcpc":
|
||||
nativeInstance.UpdateLabelIfChanged("dhcp_client_change_label", "Change to udhcpc")
|
||||
case "udhcpc":
|
||||
nativeInstance.UpdateLabelIfChanged("dhcp_client_change_label", "Change to JetKVM")
|
||||
}
|
||||
|
||||
if usbState == "configured" {
|
||||
nativeInstance.UpdateLabelIfChanged("usb_status_label", "Connected")
|
||||
|
|
@ -59,7 +72,7 @@ func updateDisplay() {
|
|||
}
|
||||
nativeInstance.UpdateLabelIfChanged("cloud_status_label", fmt.Sprintf("%d active", actionSessions))
|
||||
|
||||
if networkState.IsUp() {
|
||||
if networkManager != nil && networkManager.IsUp() {
|
||||
nativeInstance.UISetVar("main_screen", "home_screen")
|
||||
nativeInstance.SwitchToScreenIf("home_screen", []string{"no_network_screen", "boot_screen"})
|
||||
} else {
|
||||
|
|
@ -175,7 +188,7 @@ func requestDisplayUpdate(shouldWakeDisplay bool, reason string) {
|
|||
wakeDisplay(false, reason)
|
||||
}
|
||||
displayLogger.Debug().Msg("display updating")
|
||||
//TODO: only run once regardless how many pending updates
|
||||
// TODO: only run once regardless how many pending updates
|
||||
updateDisplay()
|
||||
}()
|
||||
}
|
||||
|
|
@ -184,13 +197,14 @@ func waitCtrlAndRequestDisplayUpdate(shouldWakeDisplay bool, reason string) {
|
|||
waitDisplayUpdate.Lock()
|
||||
defer waitDisplayUpdate.Unlock()
|
||||
|
||||
// nativeInstance.WaitCtrlClientConnected()
|
||||
requestDisplayUpdate(shouldWakeDisplay, reason)
|
||||
}
|
||||
|
||||
func updateStaticContents() {
|
||||
//contents that never change
|
||||
nativeInstance.UpdateLabelIfChanged("home_info_mac_addr", networkState.MACString())
|
||||
if networkManager != nil {
|
||||
nativeInstance.UpdateLabelIfChanged("home_info_mac_addr", networkManager.MACString())
|
||||
}
|
||||
|
||||
// get cpu info
|
||||
if cpuInfo, err := os.ReadFile("/proc/cpuinfo"); err == nil {
|
||||
|
|
@ -326,11 +340,8 @@ func startBacklightTickers() {
|
|||
dimTicker = time.NewTicker(time.Duration(config.DisplayDimAfterSec) * time.Second)
|
||||
|
||||
go func() {
|
||||
for { //nolint:staticcheck
|
||||
select {
|
||||
case <-dimTicker.C:
|
||||
tick_displayDim()
|
||||
}
|
||||
for range dimTicker.C {
|
||||
tick_displayDim()
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
|
@ -340,11 +351,8 @@ func startBacklightTickers() {
|
|||
offTicker = time.NewTicker(time.Duration(config.DisplayOffAfterSec) * time.Second)
|
||||
|
||||
go func() {
|
||||
for { //nolint:staticcheck
|
||||
select {
|
||||
case <-offTicker.C:
|
||||
tick_displayOff()
|
||||
}
|
||||
for range offTicker.C {
|
||||
tick_displayOff()
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
|
|
|||
8
go.mod
8
go.mod
|
|
@ -16,6 +16,7 @@ require (
|
|||
github.com/google/uuid v1.6.0
|
||||
github.com/guregu/null/v6 v6.0.0
|
||||
github.com/gwatts/rootcerts v0.0.0-20250901182336-dc5ae18bd79f
|
||||
github.com/insomniacslk/dhcp v0.0.0-20250919081422-f80a1952f48e
|
||||
github.com/pion/logging v0.2.4
|
||||
github.com/pion/mdns/v2 v2.0.7
|
||||
github.com/pion/webrtc/v4 v4.1.4
|
||||
|
|
@ -54,15 +55,20 @@ require (
|
|||
github.com/go-playground/validator/v10 v10.27.0 // indirect
|
||||
github.com/goccy/go-json v0.10.5 // indirect
|
||||
github.com/jonboulle/clockwork v0.5.0 // indirect
|
||||
github.com/josharian/native v1.1.0 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mdlayher/ndp v1.1.0 // indirect
|
||||
github.com/mdlayher/packet v1.1.2 // indirect
|
||||
github.com/mdlayher/socket v0.4.1 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||
github.com/pierrec/lz4/v4 v4.1.14 // indirect
|
||||
github.com/pilebones/go-udev v0.9.1 // indirect
|
||||
github.com/pion/datachannel v1.5.10 // indirect
|
||||
github.com/pion/dtls/v3 v3.0.7 // indirect
|
||||
|
|
@ -82,12 +88,14 @@ require (
|
|||
github.com/robfig/cron/v3 v3.0.1 // indirect
|
||||
github.com/rogpeppe/go-internal v1.14.1 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/u-root/uio v0.0.0-20230220225925-ffce2a382923 // indirect
|
||||
github.com/ugorji/go/codec v1.3.0 // indirect
|
||||
github.com/vishvananda/netns v0.0.5 // indirect
|
||||
github.com/wlynxg/anet v0.0.5 // indirect
|
||||
go.yaml.in/yaml/v2 v2.4.2 // indirect
|
||||
golang.org/x/arch v0.20.0 // indirect
|
||||
golang.org/x/oauth2 v0.30.0 // indirect
|
||||
golang.org/x/sync v0.17.0 // indirect
|
||||
golang.org/x/text v0.29.0 // indirect
|
||||
google.golang.org/protobuf v1.36.9 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
|
|
|
|||
22
go.sum
22
go.sum
|
|
@ -66,8 +66,15 @@ 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-20250901182336-dc5ae18bd79f h1:08t2PbrkDgW2+mwCQ3jhKUBrCM9Bc9SeH5j2Dst3B+0=
|
||||
github.com/gwatts/rootcerts v0.0.0-20250901182336-dc5ae18bd79f/go.mod h1:5Kt9XkWvkGi2OHOq0QsGxebHmhCcqJ8KCbNg/a6+n+g=
|
||||
github.com/hugelgupf/socketpair v0.0.0-20190730060125-05d35a94e714 h1:/jC7qQFrv8CrSJVmaolDVOxTfS9kc36uB6H40kdbQq8=
|
||||
github.com/hugelgupf/socketpair v0.0.0-20190730060125-05d35a94e714/go.mod h1:2Goc3h8EklBH5mspfHFxBnEoURQCGzQQH1ga9Myjvis=
|
||||
github.com/insomniacslk/dhcp v0.0.0-20250919081422-f80a1952f48e h1:nu5z6Kg+gMNW6tdqnVjg/QEJ8Nw71IJQqOtWj00XHEU=
|
||||
github.com/insomniacslk/dhcp v0.0.0-20250919081422-f80a1952f48e/go.mod h1:qfvBmyDNp+/liLEYWRvqny/PEz9hGe2Dz833eXILSmo=
|
||||
github.com/jonboulle/clockwork v0.5.0 h1:Hyh9A8u51kptdkR+cqRpT1EebBwTn1oK9YfGYbdFz6I=
|
||||
github.com/jonboulle/clockwork v0.5.0/go.mod h1:3mZlmanh0g2NDKO5TWZVJAfofYk64M7XN3SzBPjZF60=
|
||||
github.com/josharian/native v1.0.1-0.20221213033349-c1e37c09b531/go.mod h1:7X/raswPFr05uY3HiLlYeyQntB6OO7E/d2Cu7qoaN2w=
|
||||
github.com/josharian/native v1.1.0 h1:uuaP0hAbW7Y4l0ZRQ6C9zfb7Mg1mbFKry/xzDAfmtLA=
|
||||
github.com/josharian/native v1.1.0/go.mod h1:7X/raswPFr05uY3HiLlYeyQntB6OO7E/d2Cu7qoaN2w=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
|
||||
|
|
@ -92,6 +99,12 @@ github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/
|
|||
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mdlayher/ndp v1.1.0 h1:QylGKGVtH60sKZUE88+IW5ila1Z/M9/OXhWdsVKuscs=
|
||||
github.com/mdlayher/ndp v1.1.0/go.mod h1:FmgESgemgjl38vuOIyAHWUUL6vQKA/pQNkvXdWsdQFM=
|
||||
github.com/mdlayher/packet v1.1.2 h1:3Up1NG6LZrsgDVn6X4L9Ge/iyRyxFEFD9o6Pr3Q1nQY=
|
||||
github.com/mdlayher/packet v1.1.2/go.mod h1:GEu1+n9sG5VtiRE4SydOmX5GTwyyYlteZiFU+x0kew4=
|
||||
github.com/mdlayher/socket v0.4.1 h1:eM9y2/jlbs1M615oshPQOHZzj6R6wMT7bX5NPiQvn2U=
|
||||
github.com/mdlayher/socket v0.4.1/go.mod h1:cAqeGjoufqdxWkD7DkpyS+wcefOtmu5OQ8KuoJGIReA=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
|
|
@ -101,6 +114,8 @@ github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq
|
|||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/pierrec/lz4/v4 v4.1.14 h1:+fL8AQEZtz/ijeNnpduH0bROTu0O3NZAlPjQxGn8LwE=
|
||||
github.com/pierrec/lz4/v4 v4.1.14/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
|
||||
github.com/pilebones/go-udev v0.9.1 h1:uN72M1C1fgzhsVmBGEM8w9RD1JY4iVsPZpr+Z6rb3O8=
|
||||
github.com/pilebones/go-udev v0.9.1/go.mod h1:Bgcl07crebF3JSeS4+nuaRvhWFdCeFoBhXXeAp93XNo=
|
||||
github.com/pion/datachannel v1.5.10 h1:ly0Q26K1i6ZkGf42W7D4hQYR90pZwzFOjTq5AuCKk4o=
|
||||
|
|
@ -161,6 +176,8 @@ github.com/sourcegraph/tf-dag v0.2.2-0.20250131204052-3e8ff1477b4f/go.mod h1:pzr
|
|||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
|
|
@ -169,6 +186,8 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu
|
|||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/u-root/uio v0.0.0-20230220225925-ffce2a382923 h1:tHNk7XK9GkmKUR6Gh8gVBKXc2MVSZ4G/NnWLtzw4gNA=
|
||||
github.com/u-root/uio v0.0.0-20230220225925-ffce2a382923/go.mod h1:eLL9Nub3yfAho7qB0MzZizFhTU2QkLeoVsWdHtDW264=
|
||||
github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA=
|
||||
github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
||||
github.com/vearutop/statigz v1.5.0 h1:FuWwZiT82yBw4xbWdWIawiP2XFTyEPhIo8upRxiKLqk=
|
||||
|
|
@ -193,6 +212,9 @@ golang.org/x/net v0.44.0 h1:evd8IRDyfNBMBTTY5XRF1vaZlD+EmWx6x8PkhR04H/I=
|
|||
golang.org/x/net v0.44.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY=
|
||||
golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
|
||||
golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
|
||||
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
|
||||
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.0.0-20220622161953-175b2fd9d664/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
|
|
|
|||
|
|
@ -16,22 +16,22 @@ import (
|
|||
type FieldConfig struct {
|
||||
Name string
|
||||
Required bool
|
||||
RequiredIf map[string]any
|
||||
RequiredIf map[string]interface{}
|
||||
OneOf []string
|
||||
ValidateTypes []string
|
||||
Defaults any
|
||||
Defaults interface{}
|
||||
IsEmpty bool
|
||||
CurrentValue any
|
||||
CurrentValue interface{}
|
||||
TypeString string
|
||||
Delegated bool
|
||||
shouldUpdateValue bool
|
||||
}
|
||||
|
||||
func SetDefaultsAndValidate(config any) error {
|
||||
func SetDefaultsAndValidate(config interface{}) error {
|
||||
return setDefaultsAndValidate(config, true)
|
||||
}
|
||||
|
||||
func setDefaultsAndValidate(config any, isRoot bool) error {
|
||||
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")
|
||||
|
|
@ -55,7 +55,7 @@ func setDefaultsAndValidate(config any, isRoot bool) error {
|
|||
Name: field.Name,
|
||||
OneOf: splitString(field.Tag.Get("one_of")),
|
||||
ValidateTypes: splitString(field.Tag.Get("validate_type")),
|
||||
RequiredIf: make(map[string]any),
|
||||
RequiredIf: make(map[string]interface{}),
|
||||
CurrentValue: fieldValue.Interface(),
|
||||
IsEmpty: false,
|
||||
TypeString: fieldType,
|
||||
|
|
@ -142,8 +142,8 @@ func setDefaultsAndValidate(config any, isRoot bool) error {
|
|||
// now check if the field has required_if
|
||||
requiredIf := field.Tag.Get("required_if")
|
||||
if requiredIf != "" {
|
||||
requiredIfParts := strings.SplitSeq(requiredIf, ",")
|
||||
for part := range requiredIfParts {
|
||||
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)
|
||||
|
|
@ -168,7 +168,7 @@ func setDefaultsAndValidate(config any, isRoot bool) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
func validateFields(config any, fields map[string]FieldConfig) error {
|
||||
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 {
|
||||
|
|
@ -215,7 +215,7 @@ func (f *FieldConfig) validate(fields map[string]FieldConfig) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
func (f *FieldConfig) populate(config any) {
|
||||
func (f *FieldConfig) populate(config interface{}) {
|
||||
// update the field if it's not empty
|
||||
if !f.shouldUpdateValue {
|
||||
return
|
||||
|
|
@ -346,6 +346,17 @@ func (f *FieldConfig) validateField() error {
|
|||
return nil
|
||||
}
|
||||
|
||||
// Handle []string types, like dns servers, time sync ntp servers, etc.
|
||||
if slice, ok := f.CurrentValue.([]string); ok {
|
||||
for i, item := range slice {
|
||||
if err := f.validateSingleValue(item, i); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Handle single string types
|
||||
val, err := toString(f.CurrentValue)
|
||||
if err != nil {
|
||||
return fmt.Errorf("field `%s` cannot use validate_type: %s", f.Name, err)
|
||||
|
|
@ -355,30 +366,71 @@ func (f *FieldConfig) validateField() error {
|
|||
return nil
|
||||
}
|
||||
|
||||
return f.validateSingleValue(val, -1)
|
||||
}
|
||||
|
||||
func (f *FieldConfig) validateSingleValue(val string, index int) error {
|
||||
for _, validateType := range f.ValidateTypes {
|
||||
var fieldRef string
|
||||
if index >= 0 {
|
||||
fieldRef = fmt.Sprintf("field `%s[%d]`", f.Name, index)
|
||||
} else {
|
||||
fieldRef = fmt.Sprintf("field `%s`", f.Name)
|
||||
}
|
||||
|
||||
switch validateType {
|
||||
case "int":
|
||||
if _, err := strconv.Atoi(val); err != nil {
|
||||
return fmt.Errorf("field `%s` is not a valid integer: %s", fieldRef, val)
|
||||
}
|
||||
case "ipv6_prefix_length":
|
||||
valInt, err := strconv.Atoi(val)
|
||||
if err != nil {
|
||||
return fmt.Errorf("field `%s` is not a valid IPv6 prefix length: %s", fieldRef, val)
|
||||
}
|
||||
if valInt < 0 || valInt > 128 {
|
||||
return fmt.Errorf("field `%s` is not a valid IPv6 prefix length: %s", fieldRef, val)
|
||||
}
|
||||
case "ipv4":
|
||||
if net.ParseIP(val).To4() == nil {
|
||||
return fmt.Errorf("field `%s` is not a valid IPv4 address: %s", f.Name, val)
|
||||
return fmt.Errorf("field `%s` is not a valid IPv4 address: %s", fieldRef, val)
|
||||
}
|
||||
case "ipv6":
|
||||
if net.ParseIP(val).To16() == nil {
|
||||
return fmt.Errorf("field `%s` is not a valid IPv6 address: %s", f.Name, val)
|
||||
return fmt.Errorf("field `%s` is not a valid IPv6 address: %s", fieldRef, val)
|
||||
}
|
||||
case "ipv6_prefix":
|
||||
if i, _, err := net.ParseCIDR(val); err != nil {
|
||||
if i.To16() == nil {
|
||||
return fmt.Errorf("field `%s` is not a valid IPv6 prefix: %s", fieldRef, val)
|
||||
}
|
||||
}
|
||||
case "ipv4_or_ipv6":
|
||||
if net.ParseIP(val) == nil {
|
||||
return fmt.Errorf("%s is not a valid IPv4 or IPv6 address: %s", fieldRef, 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)
|
||||
return fmt.Errorf("%s is not a valid MAC address: %s", fieldRef, 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)
|
||||
return fmt.Errorf("%s is not a valid hostname: %s", fieldRef, val)
|
||||
}
|
||||
case "proxy":
|
||||
if url, err := url.Parse(val); err != nil || (url.Scheme != "http" && url.Scheme != "https") || url.Host == "" {
|
||||
return fmt.Errorf("field `%s` is not a valid HTTP proxy URL: %s", f.Name, val)
|
||||
return fmt.Errorf("%s is not a valid HTTP proxy URL: %s", fieldRef, val)
|
||||
}
|
||||
case "url":
|
||||
if _, err := url.Parse(val); err != nil {
|
||||
return fmt.Errorf("%s is not a valid URL: %s", fieldRef, val)
|
||||
}
|
||||
case "cidr":
|
||||
if _, _, err := net.ParseCIDR(val); err != nil {
|
||||
return fmt.Errorf("%s is not a valid CIDR notation: %s", fieldRef, val)
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("field `%s` cannot use validate_type: unsupported validator: %s", f.Name, validateType)
|
||||
return fmt.Errorf("field `%s` cannot use validate_type: unsupported validator: %s", fieldRef, validateType)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -24,10 +24,10 @@ type testIPv4StaticConfig struct {
|
|||
}
|
||||
|
||||
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"`
|
||||
Address null.String `json:"address" validate_type:"ipv6" required:"true"`
|
||||
PrefixLength null.Int `json:"prefix_length" validate_type:"ipv6_prefix_length" 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"`
|
||||
|
|
@ -39,7 +39,7 @@ type testNetworkConfig struct {
|
|||
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,rx_only,tx_only,enabled" default:"enabled"`
|
||||
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"`
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ func splitString(s string) []string {
|
|||
return strings.Split(s, ",")
|
||||
}
|
||||
|
||||
func toString(v any) (string, error) {
|
||||
func toString(v interface{}) (string, error) {
|
||||
switch v := v.(type) {
|
||||
case string:
|
||||
return v, nil
|
||||
|
|
|
|||
|
|
@ -146,14 +146,17 @@ func (m *MDNS) start(allowRestart bool) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
// Start starts the mDNS server
|
||||
func (m *MDNS) Start() error {
|
||||
return m.start(false)
|
||||
}
|
||||
|
||||
// Restart restarts the mDNS server
|
||||
func (m *MDNS) Restart() error {
|
||||
return m.start(true)
|
||||
}
|
||||
|
||||
// Stop stops the mDNS server
|
||||
func (m *MDNS) Stop() error {
|
||||
m.lock.Lock()
|
||||
defer m.lock.Unlock()
|
||||
|
|
@ -165,26 +168,45 @@ func (m *MDNS) Stop() error {
|
|||
return m.conn.Close()
|
||||
}
|
||||
|
||||
func (m *MDNS) SetLocalNames(localNames []string, always bool) error {
|
||||
if reflect.DeepEqual(m.localNames, localNames) && !always {
|
||||
return nil
|
||||
func (m *MDNS) setLocalNames(localNames []string) {
|
||||
m.lock.Lock()
|
||||
defer m.lock.Unlock()
|
||||
|
||||
if reflect.DeepEqual(m.localNames, localNames) {
|
||||
return
|
||||
}
|
||||
|
||||
m.localNames = localNames
|
||||
_ = m.Restart()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *MDNS) SetListenOptions(listenOptions *MDNSListenOptions) error {
|
||||
func (m *MDNS) setListenOptions(listenOptions *MDNSListenOptions) {
|
||||
m.lock.Lock()
|
||||
defer m.lock.Unlock()
|
||||
|
||||
if m.listenOptions != nil &&
|
||||
m.listenOptions.IPv4 == listenOptions.IPv4 &&
|
||||
m.listenOptions.IPv6 == listenOptions.IPv6 {
|
||||
return nil
|
||||
return
|
||||
}
|
||||
|
||||
m.listenOptions = listenOptions
|
||||
_ = m.Restart()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetLocalNames sets the local names and restarts the mDNS server
|
||||
func (m *MDNS) SetLocalNames(localNames []string) error {
|
||||
m.setLocalNames(localNames)
|
||||
return m.Restart()
|
||||
}
|
||||
|
||||
// SetListenOptions sets the listen options and restarts the mDNS server
|
||||
func (m *MDNS) SetListenOptions(listenOptions *MDNSListenOptions) error {
|
||||
m.setListenOptions(listenOptions)
|
||||
return m.Restart()
|
||||
}
|
||||
|
||||
// SetOptions sets the local names and listen options and restarts the mDNS server
|
||||
func (m *MDNS) SetOptions(options *MDNSOptions) error {
|
||||
m.setLocalNames(options.LocalNames)
|
||||
m.setListenOptions(options.ListenOptions)
|
||||
return m.Restart()
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -48,6 +48,10 @@ void action_switch_to_reset_config(lv_event_t *e) {
|
|||
loadScreen(SCREEN_ID_RESET_CONFIG_SCREEN);
|
||||
}
|
||||
|
||||
void action_switch_to_dhcpc(lv_event_t *e) {
|
||||
loadScreen(SCREEN_ID_SWITCH_DHCP_CLIENT_SCREEN);
|
||||
}
|
||||
|
||||
void action_switch_to_reboot(lv_event_t *e) {
|
||||
loadScreen(SCREEN_ID_REBOOT_SCREEN);
|
||||
}
|
||||
|
|
@ -75,15 +79,19 @@ void action_about_screen_gesture(lv_event_t * e) {
|
|||
// user_data doesn't seem to be working, so we use a global variable here
|
||||
static uint32_t t_reset_config;
|
||||
static uint32_t t_reboot;
|
||||
static uint32_t t_dhcpc;
|
||||
|
||||
static bool b_reboot = false;
|
||||
static bool b_reset_config = false;
|
||||
static bool b_dhcpc = false;
|
||||
|
||||
static bool b_reboot_lock = false;
|
||||
static bool b_reset_config_lock = false;
|
||||
static bool b_dhcpc_lock = false;
|
||||
|
||||
const int RESET_CONFIG_HOLD_TIME = 10;
|
||||
const int REBOOT_HOLD_TIME = 5;
|
||||
const int DHCPC_HOLD_TIME = 5;
|
||||
|
||||
typedef struct {
|
||||
uint32_t *start_time;
|
||||
|
|
@ -153,6 +161,22 @@ void action_reset_config(lv_event_t * e) {
|
|||
handle_hold_action(e, &config);
|
||||
}
|
||||
|
||||
void action_dhcpc(lv_event_t * e) {
|
||||
hold_action_config_t config = {
|
||||
.start_time = &t_dhcpc,
|
||||
.completed = &b_dhcpc,
|
||||
.lock = &b_dhcpc_lock,
|
||||
.hold_time_seconds = DHCPC_HOLD_TIME,
|
||||
.rpc_method = "toggleDHCPClient",
|
||||
.button_obj = NULL, // No button/spinner for reboot
|
||||
.spinner_obj = NULL,
|
||||
.label_obj = objects.dhcpc_label,
|
||||
.default_text = "Press and hold for\n5 seconds"
|
||||
};
|
||||
|
||||
handle_hold_action(e, &config);
|
||||
}
|
||||
|
||||
void action_reboot(lv_event_t * e) {
|
||||
hold_action_config_t config = {
|
||||
.start_time = &t_reboot,
|
||||
|
|
|
|||
|
|
@ -24,6 +24,8 @@ extern void action_handle_common_press_event(lv_event_t * e);
|
|||
extern void action_reset_config(lv_event_t * e);
|
||||
extern void action_reboot(lv_event_t * e);
|
||||
extern void action_switch_to_reboot(lv_event_t * e);
|
||||
extern void action_dhcpc(lv_event_t * e);
|
||||
extern void action_switch_to_dhcpc(lv_event_t * e);
|
||||
|
||||
|
||||
#ifdef __cplusplus
|
||||
|
|
|
|||
|
|
@ -887,6 +887,26 @@ void create_screen_menu_advanced_screen() {
|
|||
}
|
||||
}
|
||||
}
|
||||
{
|
||||
// MenuBtnDHCPClient
|
||||
lv_obj_t *obj = lv_button_create(parent_obj);
|
||||
objects.menu_btn_dhcp_client = obj;
|
||||
lv_obj_set_pos(obj, 0, 0);
|
||||
lv_obj_set_size(obj, LV_PCT(100), 50);
|
||||
lv_obj_add_event_cb(obj, action_switch_to_dhcpc, LV_EVENT_PRESSED, (void *)0);
|
||||
lv_obj_clear_flag(obj, LV_OBJ_FLAG_SNAPPABLE);
|
||||
add_style_menu_button(obj);
|
||||
{
|
||||
lv_obj_t *parent_obj = obj;
|
||||
{
|
||||
lv_obj_t *obj = lv_label_create(parent_obj);
|
||||
lv_obj_set_pos(obj, 0, 0);
|
||||
lv_obj_set_size(obj, LV_SIZE_CONTENT, LV_SIZE_CONTENT);
|
||||
add_style_menu_button_label(obj);
|
||||
lv_label_set_text(obj, "DHCP Client");
|
||||
}
|
||||
}
|
||||
}
|
||||
{
|
||||
// MenuBtnAdvancedResetConfig
|
||||
lv_obj_t *obj = lv_button_create(parent_obj);
|
||||
|
|
@ -2197,6 +2217,221 @@ void create_screen_rebooting_screen() {
|
|||
void tick_screen_rebooting_screen() {
|
||||
}
|
||||
|
||||
void create_screen_switch_dhcp_client_screen() {
|
||||
lv_obj_t *obj = lv_obj_create(0);
|
||||
objects.switch_dhcp_client_screen = obj;
|
||||
lv_obj_set_pos(obj, 0, 0);
|
||||
lv_obj_set_size(obj, 300, 240);
|
||||
lv_obj_add_event_cb(obj, action_about_screen_gesture, LV_EVENT_GESTURE, (void *)0);
|
||||
add_style_flex_screen_menu(obj);
|
||||
{
|
||||
lv_obj_t *parent_obj = obj;
|
||||
{
|
||||
lv_obj_t *obj = lv_obj_create(parent_obj);
|
||||
lv_obj_set_pos(obj, 0, 0);
|
||||
lv_obj_set_size(obj, LV_PCT(100), LV_PCT(100));
|
||||
lv_obj_set_style_pad_left(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT);
|
||||
lv_obj_set_style_pad_top(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT);
|
||||
lv_obj_set_style_pad_right(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT);
|
||||
lv_obj_set_style_pad_bottom(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT);
|
||||
lv_obj_set_style_bg_opa(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT);
|
||||
lv_obj_set_style_border_width(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT);
|
||||
lv_obj_set_style_radius(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT);
|
||||
lv_obj_clear_flag(obj, LV_OBJ_FLAG_SCROLLABLE);
|
||||
add_style_flex_start(obj);
|
||||
{
|
||||
lv_obj_t *parent_obj = obj;
|
||||
{
|
||||
// DHCPClientHeader
|
||||
lv_obj_t *obj = lv_obj_create(parent_obj);
|
||||
objects.dhcp_client_header = obj;
|
||||
lv_obj_set_pos(obj, 0, 0);
|
||||
lv_obj_set_size(obj, LV_PCT(100), LV_SIZE_CONTENT);
|
||||
lv_obj_set_style_pad_left(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT);
|
||||
lv_obj_set_style_pad_top(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT);
|
||||
lv_obj_set_style_pad_bottom(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT);
|
||||
lv_obj_set_style_bg_opa(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT);
|
||||
lv_obj_set_style_border_width(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT);
|
||||
lv_obj_set_style_radius(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT);
|
||||
add_style_flow_row_space_between(obj);
|
||||
lv_obj_set_style_pad_right(obj, 4, LV_PART_MAIN | LV_STATE_DEFAULT);
|
||||
{
|
||||
lv_obj_t *parent_obj = obj;
|
||||
{
|
||||
lv_obj_t *obj = lv_button_create(parent_obj);
|
||||
lv_obj_set_pos(obj, 0, 0);
|
||||
lv_obj_set_size(obj, 32, 32);
|
||||
lv_obj_add_event_cb(obj, action_switch_to_menu, LV_EVENT_CLICKED, (void *)0);
|
||||
add_style_back_button(obj);
|
||||
{
|
||||
lv_obj_t *parent_obj = obj;
|
||||
{
|
||||
lv_obj_t *obj = lv_image_create(parent_obj);
|
||||
lv_obj_set_pos(obj, -1, 2);
|
||||
lv_obj_set_size(obj, LV_SIZE_CONTENT, LV_SIZE_CONTENT);
|
||||
lv_image_set_src(obj, &img_back_caret);
|
||||
}
|
||||
}
|
||||
}
|
||||
{
|
||||
lv_obj_t *obj = lv_label_create(parent_obj);
|
||||
lv_obj_set_pos(obj, LV_PCT(0), LV_PCT(0));
|
||||
lv_obj_set_size(obj, LV_SIZE_CONTENT, LV_SIZE_CONTENT);
|
||||
add_style_header_link(obj);
|
||||
lv_label_set_text(obj, "DHCP Client");
|
||||
}
|
||||
}
|
||||
}
|
||||
{
|
||||
// DHCPClientContainer
|
||||
lv_obj_t *obj = lv_obj_create(parent_obj);
|
||||
objects.dhcp_client_container = obj;
|
||||
lv_obj_set_pos(obj, 0, 0);
|
||||
lv_obj_set_size(obj, LV_PCT(100), LV_PCT(80));
|
||||
lv_obj_set_style_pad_left(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT);
|
||||
lv_obj_set_style_pad_top(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT);
|
||||
lv_obj_set_style_pad_bottom(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT);
|
||||
lv_obj_set_style_bg_opa(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT);
|
||||
lv_obj_set_style_border_width(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT);
|
||||
lv_obj_set_style_radius(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT);
|
||||
lv_obj_set_scrollbar_mode(obj, LV_SCROLLBAR_MODE_AUTO);
|
||||
lv_obj_set_scroll_dir(obj, LV_DIR_VER);
|
||||
lv_obj_set_scroll_snap_x(obj, LV_SCROLL_SNAP_START);
|
||||
add_style_flex_column_start(obj);
|
||||
lv_obj_set_style_pad_right(obj, 4, LV_PART_MAIN | LV_STATE_DEFAULT);
|
||||
{
|
||||
lv_obj_t *parent_obj = obj;
|
||||
{
|
||||
lv_obj_t *obj = lv_obj_create(parent_obj);
|
||||
lv_obj_set_pos(obj, 0, 0);
|
||||
lv_obj_set_size(obj, LV_PCT(100), LV_SIZE_CONTENT);
|
||||
lv_obj_set_style_pad_left(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT);
|
||||
lv_obj_set_style_pad_top(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT);
|
||||
lv_obj_set_style_pad_bottom(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT);
|
||||
lv_obj_set_style_bg_opa(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT);
|
||||
lv_obj_set_style_border_width(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT);
|
||||
lv_obj_set_style_radius(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT);
|
||||
lv_obj_clear_flag(obj, LV_OBJ_FLAG_SCROLLABLE);
|
||||
add_style_flex_column_start(obj);
|
||||
lv_obj_set_style_pad_right(obj, 10, LV_PART_MAIN | LV_STATE_DEFAULT);
|
||||
{
|
||||
lv_obj_t *parent_obj = obj;
|
||||
{
|
||||
// DHCPClientLabelContainer
|
||||
lv_obj_t *obj = lv_obj_create(parent_obj);
|
||||
objects.dhcp_client_label_container = obj;
|
||||
lv_obj_set_pos(obj, 0, 0);
|
||||
lv_obj_set_size(obj, LV_PCT(100), LV_SIZE_CONTENT);
|
||||
lv_obj_set_style_bg_opa(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT);
|
||||
lv_obj_set_style_border_width(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT);
|
||||
lv_obj_set_style_radius(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT);
|
||||
lv_obj_clear_flag(obj, LV_OBJ_FLAG_SCROLLABLE);
|
||||
add_style_flex_column_start(obj);
|
||||
lv_obj_set_style_pad_right(obj, 10, LV_PART_MAIN | LV_STATE_DEFAULT);
|
||||
lv_obj_set_style_pad_left(obj, 10, LV_PART_MAIN | LV_STATE_DEFAULT);
|
||||
lv_obj_set_style_pad_top(obj, 10, LV_PART_MAIN | LV_STATE_DEFAULT);
|
||||
lv_obj_set_style_pad_bottom(obj, 10, LV_PART_MAIN | LV_STATE_DEFAULT);
|
||||
{
|
||||
lv_obj_t *parent_obj = obj;
|
||||
{
|
||||
// DHCPC_Label
|
||||
lv_obj_t *obj = lv_label_create(parent_obj);
|
||||
objects.dhcpc_label = obj;
|
||||
lv_obj_set_pos(obj, 0, 0);
|
||||
lv_obj_set_size(obj, LV_PCT(100), LV_SIZE_CONTENT);
|
||||
add_style_info_content_label(obj);
|
||||
lv_obj_set_style_text_font(obj, &ui_font_font_book20, LV_PART_MAIN | LV_STATE_DEFAULT);
|
||||
lv_label_set_text(obj, "Press and hold for\n5 seconds");
|
||||
}
|
||||
}
|
||||
}
|
||||
{
|
||||
// DHCPClientSpinner
|
||||
lv_obj_t *obj = lv_obj_create(parent_obj);
|
||||
objects.dhcp_client_spinner = obj;
|
||||
lv_obj_set_pos(obj, 0, 0);
|
||||
lv_obj_set_size(obj, LV_PCT(100), LV_SIZE_CONTENT);
|
||||
lv_obj_set_style_pad_left(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT);
|
||||
lv_obj_set_style_pad_top(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT);
|
||||
lv_obj_set_style_pad_right(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT);
|
||||
lv_obj_set_style_pad_bottom(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT);
|
||||
lv_obj_set_style_bg_opa(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT);
|
||||
lv_obj_set_style_border_width(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT);
|
||||
lv_obj_set_style_radius(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT);
|
||||
lv_obj_add_flag(obj, LV_OBJ_FLAG_HIDDEN);
|
||||
lv_obj_clear_flag(obj, LV_OBJ_FLAG_CLICKABLE|LV_OBJ_FLAG_SCROLLABLE);
|
||||
add_style_flex_column_start(obj);
|
||||
lv_obj_set_style_flex_main_place(obj, LV_FLEX_ALIGN_CENTER, LV_PART_MAIN | LV_STATE_DEFAULT);
|
||||
lv_obj_set_style_flex_cross_place(obj, LV_FLEX_ALIGN_CENTER, LV_PART_MAIN | LV_STATE_DEFAULT);
|
||||
lv_obj_set_style_flex_track_place(obj, LV_FLEX_ALIGN_CENTER, LV_PART_MAIN | LV_STATE_DEFAULT);
|
||||
{
|
||||
lv_obj_t *parent_obj = obj;
|
||||
{
|
||||
lv_obj_t *obj = lv_spinner_create(parent_obj);
|
||||
lv_obj_set_pos(obj, 0, 0);
|
||||
lv_obj_set_size(obj, 80, 80);
|
||||
lv_spinner_set_anim_params(obj, 1000, 60);
|
||||
}
|
||||
}
|
||||
}
|
||||
{
|
||||
// DHCPClientButton
|
||||
lv_obj_t *obj = lv_obj_create(parent_obj);
|
||||
objects.dhcp_client_button = obj;
|
||||
lv_obj_set_pos(obj, 0, 0);
|
||||
lv_obj_set_size(obj, LV_PCT(100), LV_SIZE_CONTENT);
|
||||
lv_obj_set_style_pad_left(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT);
|
||||
lv_obj_set_style_pad_top(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT);
|
||||
lv_obj_set_style_pad_right(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT);
|
||||
lv_obj_set_style_pad_bottom(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT);
|
||||
lv_obj_set_style_bg_opa(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT);
|
||||
lv_obj_set_style_border_width(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT);
|
||||
lv_obj_set_style_radius(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT);
|
||||
lv_obj_clear_flag(obj, LV_OBJ_FLAG_SCROLLABLE);
|
||||
add_style_flex_column_start(obj);
|
||||
{
|
||||
lv_obj_t *parent_obj = obj;
|
||||
{
|
||||
lv_obj_t *obj = lv_button_create(parent_obj);
|
||||
objects.obj2 = obj;
|
||||
lv_obj_set_pos(obj, 0, 0);
|
||||
lv_obj_set_size(obj, LV_PCT(100), 50);
|
||||
lv_obj_add_event_cb(obj, action_dhcpc, LV_EVENT_PRESSED, (void *)0);
|
||||
lv_obj_add_event_cb(obj, action_dhcpc, LV_EVENT_PRESSING, (void *)0);
|
||||
lv_obj_add_event_cb(obj, action_dhcpc, LV_EVENT_RELEASED, (void *)0);
|
||||
lv_obj_set_style_bg_color(obj, lv_color_hex(0xffdc2626), LV_PART_MAIN | LV_STATE_DEFAULT);
|
||||
lv_obj_set_style_text_align(obj, LV_TEXT_ALIGN_LEFT, LV_PART_MAIN | LV_STATE_DEFAULT);
|
||||
lv_obj_set_style_pad_right(obj, 13, LV_PART_MAIN | LV_STATE_DEFAULT);
|
||||
{
|
||||
lv_obj_t *parent_obj = obj;
|
||||
{
|
||||
// DHCPClientChangeLabel
|
||||
lv_obj_t *obj = lv_label_create(parent_obj);
|
||||
objects.dhcp_client_change_label = obj;
|
||||
lv_obj_set_pos(obj, 0, 0);
|
||||
lv_obj_set_size(obj, LV_SIZE_CONTENT, LV_SIZE_CONTENT);
|
||||
lv_obj_set_style_align(obj, LV_ALIGN_CENTER, LV_PART_MAIN | LV_STATE_DEFAULT);
|
||||
lv_obj_set_style_text_align(obj, LV_TEXT_ALIGN_LEFT, LV_PART_MAIN | LV_STATE_DEFAULT);
|
||||
lv_label_set_text(obj, "Switch to udhcpc");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tick_screen_switch_dhcp_client_screen();
|
||||
}
|
||||
|
||||
void tick_screen_switch_dhcp_client_screen() {
|
||||
}
|
||||
|
||||
|
||||
|
||||
typedef void (*tick_screen_func_t)();
|
||||
|
|
@ -2212,6 +2447,7 @@ tick_screen_func_t tick_screen_funcs[] = {
|
|||
tick_screen_reset_config_screen,
|
||||
tick_screen_reboot_screen,
|
||||
tick_screen_rebooting_screen,
|
||||
tick_screen_switch_dhcp_client_screen,
|
||||
};
|
||||
void tick_screen(int screen_index) {
|
||||
tick_screen_funcs[screen_index]();
|
||||
|
|
@ -2236,4 +2472,5 @@ void create_screens() {
|
|||
create_screen_reset_config_screen();
|
||||
create_screen_reboot_screen();
|
||||
create_screen_rebooting_screen();
|
||||
create_screen_switch_dhcp_client_screen();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ typedef struct _objects_t {
|
|||
lv_obj_t *reset_config_screen;
|
||||
lv_obj_t *reboot_screen;
|
||||
lv_obj_t *rebooting_screen;
|
||||
lv_obj_t *switch_dhcp_client_screen;
|
||||
lv_obj_t *boot_logo;
|
||||
lv_obj_t *boot_screen_version;
|
||||
lv_obj_t *no_network_header_container;
|
||||
|
|
@ -54,6 +55,7 @@ typedef struct _objects_t {
|
|||
lv_obj_t *menu_btn_advanced_developer_mode;
|
||||
lv_obj_t *menu_btn_advanced_usb_emulation;
|
||||
lv_obj_t *menu_btn_advanced_reboot;
|
||||
lv_obj_t *menu_btn_dhcp_client;
|
||||
lv_obj_t *menu_btn_advanced_reset_config;
|
||||
lv_obj_t *menu_header_container_2;
|
||||
lv_obj_t *menu_items_container_2;
|
||||
|
|
@ -101,6 +103,14 @@ typedef struct _objects_t {
|
|||
lv_obj_t *obj1;
|
||||
lv_obj_t *reboot_in_progress_logo;
|
||||
lv_obj_t *reboot_in_progress_label;
|
||||
lv_obj_t *dhcp_client_header;
|
||||
lv_obj_t *dhcp_client_container;
|
||||
lv_obj_t *dhcp_client_label_container;
|
||||
lv_obj_t *dhcpc_label;
|
||||
lv_obj_t *dhcp_client_spinner;
|
||||
lv_obj_t *dhcp_client_button;
|
||||
lv_obj_t *obj2;
|
||||
lv_obj_t *dhcp_client_change_label;
|
||||
} objects_t;
|
||||
|
||||
extern objects_t objects;
|
||||
|
|
@ -117,6 +127,7 @@ enum ScreensEnum {
|
|||
SCREEN_ID_RESET_CONFIG_SCREEN = 9,
|
||||
SCREEN_ID_REBOOT_SCREEN = 10,
|
||||
SCREEN_ID_REBOOTING_SCREEN = 11,
|
||||
SCREEN_ID_SWITCH_DHCP_CLIENT_SCREEN = 12,
|
||||
};
|
||||
|
||||
void create_screen_boot_screen();
|
||||
|
|
@ -151,6 +162,9 @@ void tick_screen_reboot_screen();
|
|||
|
||||
void create_screen_rebooting_screen();
|
||||
void tick_screen_rebooting_screen();
|
||||
|
||||
void create_screen_switch_dhcp_client_screen();
|
||||
void tick_screen_switch_dhcp_client_screen();
|
||||
|
||||
void tick_screen_by_id(enum ScreensEnum screenId);
|
||||
void tick_screen(int screen_index);
|
||||
|
|
|
|||
|
|
@ -1,11 +0,0 @@
|
|||
package network
|
||||
|
||||
type DhcpTargetState int
|
||||
|
||||
const (
|
||||
DhcpTargetStateDoNothing DhcpTargetState = iota
|
||||
DhcpTargetStateStart
|
||||
DhcpTargetStateStop
|
||||
DhcpTargetStateRenew
|
||||
DhcpTargetStateRelease
|
||||
)
|
||||
|
|
@ -1,137 +0,0 @@
|
|||
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.SplitSeq(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
|
||||
}
|
||||
|
|
@ -1,403 +0,0 @@
|
|||
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
|
||||
ntpAddresses []*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, state *NetworkInterfaceState)
|
||||
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,
|
||||
ntpAddresses: make([]*net.IP, 0),
|
||||
}
|
||||
|
||||
// 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.updateNtpServersFromLease(lease)
|
||||
_ = s.setHostnameIfNotSame()
|
||||
|
||||
opts.OnDhcpLeaseChange(lease, s)
|
||||
},
|
||||
})
|
||||
|
||||
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) NtpAddresses() []*net.IP {
|
||||
return s.ntpAddresses
|
||||
}
|
||||
|
||||
func (s *NetworkInterfaceState) NtpAddressesString() []string {
|
||||
ntpServers := []string{}
|
||||
|
||||
if s != nil {
|
||||
s.l.Debug().Any("s", s).Msg("getting NTP address strings")
|
||||
|
||||
if len(s.ntpAddresses) > 0 {
|
||||
for _, server := range s.ntpAddresses {
|
||||
s.l.Debug().IPAddr("server", *server).Msg("converting NTP address")
|
||||
ntpServers = append(ntpServers, server.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ntpServers
|
||||
}
|
||||
|
||||
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 {
|
||||
if s.config.IPv6Mode.String == "disabled" {
|
||||
continue
|
||||
}
|
||||
|
||||
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 s.config.IPv6Mode.String != "disabled" {
|
||||
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.handleInitialCheck()
|
||||
} else if changed {
|
||||
s.handleStateChange()
|
||||
}
|
||||
|
||||
return dhcpTargetState, nil
|
||||
}
|
||||
|
||||
func (s *NetworkInterfaceState) updateNtpServersFromLease(lease *udhcpc.Lease) error {
|
||||
if lease != nil && len(lease.NTPServers) > 0 {
|
||||
s.l.Info().Msg("lease found, updating DHCP NTP addresses")
|
||||
s.ntpAddresses = make([]*net.IP, 0, len(lease.NTPServers))
|
||||
|
||||
for _, ntpServer := range lease.NTPServers {
|
||||
if ntpServer != nil {
|
||||
s.l.Info().IPAddr("ntp_server", ntpServer).Msg("NTP server found in lease")
|
||||
s.ntpAddresses = append(s.ntpAddresses, &ntpServer)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
s.l.Info().Msg("no NTP servers found in lease")
|
||||
s.ntpAddresses = make([]*net.IP, 0, len(s.config.TimeSyncNTPServers))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *NetworkInterfaceState) handleInitialCheck() {
|
||||
// if s.IsUp() {}
|
||||
s.onInitialCheck(s)
|
||||
}
|
||||
|
||||
func (s *NetworkInterfaceState) handleStateChange() {
|
||||
// if s.IsUp() {} else {}
|
||||
s.onStateChange(s)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
|
@ -1,58 +0,0 @@
|
|||
//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)
|
||||
}
|
||||
|
|
@ -1,21 +0,0 @@
|
|||
//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")
|
||||
}
|
||||
|
|
@ -1,126 +0,0 @@
|
|||
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 && s.config.IPv6Mode.String != "disabled" {
|
||||
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()
|
||||
}
|
||||
|
|
@ -1,25 +1,13 @@
|
|||
package network
|
||||
package types
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"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"`
|
||||
}
|
||||
|
||||
// IPv4StaticConfig represents static IPv4 configuration
|
||||
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"`
|
||||
|
|
@ -27,13 +15,23 @@ type IPv4StaticConfig struct {
|
|||
DNS []string `json:"dns,omitempty" validate_type:"ipv4" required:"true"`
|
||||
}
|
||||
|
||||
// IPv6StaticConfig represents static IPv6 configuration
|
||||
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"`
|
||||
Prefix null.String `json:"prefix,omitempty" validate_type:"ipv6_prefix" required:"true"`
|
||||
Gateway null.String `json:"gateway,omitempty" validate_type:"ipv6" required:"true"`
|
||||
DNS []string `json:"dns,omitempty" validate_type:"ipv6" required:"true"`
|
||||
}
|
||||
|
||||
// MDNSListenOptions represents MDNS listening options
|
||||
type MDNSListenOptions struct {
|
||||
IPv4 bool
|
||||
IPv6 bool
|
||||
}
|
||||
|
||||
// NetworkConfig represents the complete network configuration for an interface
|
||||
type NetworkConfig struct {
|
||||
DHCPClient null.String `json:"dhcp_client,omitempty" one_of:"jetdhcpc,udhcpc" default:"jetdhcpc"`
|
||||
|
||||
Hostname null.String `json:"hostname,omitempty" validate_type:"hostname"`
|
||||
HTTPProxy null.String `json:"http_proxy,omitempty" validate_type:"proxy"`
|
||||
Domain null.String `json:"domain,omitempty" validate_type:"hostname"`
|
||||
|
|
@ -44,7 +42,7 @@ type NetworkConfig struct {
|
|||
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,rx_only,tx_only,basic,all,enabled" default:"enabled"`
|
||||
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"`
|
||||
|
|
@ -55,13 +53,15 @@ type NetworkConfig struct {
|
|||
TimeSyncHTTPUrls []string `json:"time_sync_http_urls,omitempty" validate_type:"url" required_if:"TimeSyncOrdering=http_user_provided"`
|
||||
}
|
||||
|
||||
func (c *NetworkConfig) GetMDNSMode() *mdns.MDNSListenOptions {
|
||||
listenOptions := &mdns.MDNSListenOptions{
|
||||
IPv4: c.IPv4Mode.String != "disabled",
|
||||
IPv6: c.IPv6Mode.String != "disabled",
|
||||
// GetMDNSMode returns the MDNS mode configuration
|
||||
func (c *NetworkConfig) GetMDNSMode() *MDNSListenOptions {
|
||||
mode := c.MDNSMode.String
|
||||
listenOptions := &MDNSListenOptions{
|
||||
IPv4: true,
|
||||
IPv6: true,
|
||||
}
|
||||
|
||||
switch c.MDNSMode.String {
|
||||
switch mode {
|
||||
case "ipv4_only":
|
||||
listenOptions.IPv6 = false
|
||||
case "ipv6_only":
|
||||
|
|
@ -74,53 +74,21 @@ func (c *NetworkConfig) GetMDNSMode() *mdns.MDNSListenOptions {
|
|||
return listenOptions
|
||||
}
|
||||
|
||||
func (s *NetworkConfig) GetTransportProxyFunc() func(*http.Request) (*url.URL, error) {
|
||||
// GetTransportProxyFunc returns a function for HTTP proxy configuration
|
||||
func (c *NetworkConfig) GetTransportProxyFunc() func(*http.Request) (*url.URL, error) {
|
||||
return func(*http.Request) (*url.URL, error) {
|
||||
if s.HTTPProxy.String == "" {
|
||||
if c.HTTPProxy.String == "" {
|
||||
return nil, nil
|
||||
} else {
|
||||
proxyUrl, _ := url.Parse(s.HTTPProxy.String)
|
||||
return proxyUrl, nil
|
||||
proxyURL, _ := url.Parse(c.HTTPProxy.String)
|
||||
return proxyURL, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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())
|
||||
// NetworkConfig interface for backward compatibility
|
||||
type NetworkConfigInterface interface {
|
||||
InterfaceName() string
|
||||
IPv4Addresses() []IPAddress
|
||||
IPv6Addresses() []IPAddress
|
||||
}
|
||||
|
|
@ -1,18 +1,26 @@
|
|||
package udhcpc
|
||||
package types
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Lease struct {
|
||||
// DHCPClient is the interface for a DHCP client.
|
||||
type DHCPClient interface {
|
||||
Domain() string
|
||||
Lease4() *DHCPLease
|
||||
Lease6() *DHCPLease
|
||||
Renew() error
|
||||
Release() error
|
||||
SetIPv4(enabled bool)
|
||||
SetIPv6(enabled bool)
|
||||
SetOnLeaseChange(callback func(lease *DHCPLease))
|
||||
Start() error
|
||||
Stop() error
|
||||
}
|
||||
|
||||
// DHCPLease is a network configuration obtained by DHCP.
|
||||
type DHCPLease struct {
|
||||
// from https://udhcp.busybox.net/README.udhcpc
|
||||
IPAddress net.IP `env:"ip" json:"ip"` // The obtained IP
|
||||
Netmask net.IP `env:"subnet" json:"netmask"` // The assigned subnet mask
|
||||
|
|
@ -21,6 +29,7 @@ type Lease struct {
|
|||
MTU int `env:"mtu" json:"mtu,omitempty"` // The MTU to use for this network
|
||||
HostName string `env:"hostname" json:"hostname,omitempty"` // The assigned hostname
|
||||
Domain string `env:"domain" json:"domain,omitempty"` // The domain name of the network
|
||||
SearchList []string `env:"search" json:"search_list,omitempty"` // The search list for the network
|
||||
BootPNextServer net.IP `env:"siaddr" json:"bootp_next_server,omitempty"` // The bootp next server option
|
||||
BootPServerName string `env:"sname" json:"bootp_server_name,omitempty"` // The bootp server name option
|
||||
BootPFile string `env:"boot_file" json:"bootp_file,omitempty"` // The bootp boot file option
|
||||
|
|
@ -38,149 +47,46 @@ type Lease struct {
|
|||
BootSize int `env:"bootsize" json:"bootsize,omitempty"` // The length in 512 octect blocks of the bootfile
|
||||
RootPath string `env:"rootpath" json:"root_path,omitempty"` // The path name of the client's root disk
|
||||
LeaseTime time.Duration `env:"lease" json:"lease,omitempty"` // The lease time, in seconds
|
||||
RenewalTime time.Duration `env:"renewal" json:"renewal,omitempty"` // The renewal time, in seconds
|
||||
RebindingTime time.Duration `env:"rebinding" json:"rebinding,omitempty"` // The rebinding time, in seconds
|
||||
DHCPType string `env:"dhcptype" json:"dhcp_type,omitempty"` // DHCP message type (safely ignored)
|
||||
ServerID string `env:"serverid" json:"server_id,omitempty"` // The IP of the server
|
||||
Message string `env:"message" json:"reason,omitempty"` // Reason for a DHCPNAK
|
||||
TFTPServerName string `env:"tftp" json:"tftp,omitempty"` // The TFTP server name
|
||||
BootFileName string `env:"bootfile" json:"bootfile,omitempty"` // The boot file name
|
||||
Uptime time.Duration `env:"uptime" json:"uptime,omitempty"` // The uptime of the device when the lease was obtained, in seconds
|
||||
ClassIdentifier string `env:"classid" json:"class_identifier,omitempty"` // The class identifier
|
||||
LeaseExpiry *time.Time `json:"lease_expiry,omitempty"` // The expiry time of the lease
|
||||
isEmpty map[string]bool
|
||||
|
||||
InterfaceName string `json:"interface_name,omitempty"` // The name of the interface
|
||||
DHCPClient string `json:"dhcp_client,omitempty"` // The DHCP client that obtained the lease
|
||||
}
|
||||
|
||||
func (l *Lease) setIsEmpty(m map[string]bool) {
|
||||
l.isEmpty = m
|
||||
// IsIPv6 returns true if the DHCP lease is for an IPv6 address
|
||||
func (d *DHCPLease) IsIPv6() bool {
|
||||
return d.IPAddress.To4() == nil
|
||||
}
|
||||
|
||||
func (l *Lease) IsEmpty(key string) bool {
|
||||
return l.isEmpty[key]
|
||||
// IPMask returns the IP mask for the DHCP lease
|
||||
func (d *DHCPLease) IPMask() net.IPMask {
|
||||
if d.IsIPv6() {
|
||||
// TODO: not implemented
|
||||
return nil
|
||||
}
|
||||
|
||||
mask := net.ParseIP(d.Netmask.String())
|
||||
return net.IPv4Mask(mask[12], mask[13], mask[14], mask[15])
|
||||
}
|
||||
|
||||
func (l *Lease) ToJSON() string {
|
||||
json, err := json.Marshal(l)
|
||||
if err != nil {
|
||||
return ""
|
||||
// IPNet returns the IP net for the DHCP lease
|
||||
func (d *DHCPLease) IPNet() *net.IPNet {
|
||||
if d.IsIPv6() {
|
||||
// TODO: not implemented
|
||||
return nil
|
||||
}
|
||||
|
||||
return &net.IPNet{
|
||||
IP: d.IPAddress,
|
||||
Mask: d.IPMask(),
|
||||
}
|
||||
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.SplitSeq(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.FieldsSeq(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
|
||||
}
|
||||
|
|
@ -0,0 +1,62 @@
|
|||
package types
|
||||
|
||||
import (
|
||||
"net"
|
||||
"time"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
// InterfaceState represents the current state of a network interface
|
||||
type InterfaceState struct {
|
||||
InterfaceName string `json:"interface_name"`
|
||||
Hostname string `json:"hostname"`
|
||||
MACAddress string `json:"mac_address"`
|
||||
Up bool `json:"up"`
|
||||
Online bool `json:"online"`
|
||||
IPv4Ready bool `json:"ipv4_ready"`
|
||||
IPv6Ready bool `json:"ipv6_ready"`
|
||||
IPv4Address string `json:"ipv4_address,omitempty"`
|
||||
IPv6Address string `json:"ipv6_address,omitempty"`
|
||||
IPv6LinkLocal string `json:"ipv6_link_local,omitempty"`
|
||||
IPv6Gateway string `json:"ipv6_gateway,omitempty"`
|
||||
IPv4Addresses []string `json:"ipv4_addresses,omitempty"`
|
||||
IPv6Addresses []IPv6Address `json:"ipv6_addresses,omitempty"`
|
||||
NTPServers []net.IP `json:"ntp_servers,omitempty"`
|
||||
DHCPLease4 *DHCPLease `json:"dhcp_lease,omitempty"`
|
||||
DHCPLease6 *DHCPLease `json:"dhcp_lease6,omitempty"`
|
||||
LastUpdated time.Time `json:"last_updated"`
|
||||
}
|
||||
|
||||
// RpcInterfaceState is the RPC representation of an interface state
|
||||
type RpcInterfaceState struct {
|
||||
InterfaceState
|
||||
IPv6Addresses []RpcIPv6Address `json:"ipv6_addresses"`
|
||||
}
|
||||
|
||||
// ToRpcInterfaceState converts an InterfaceState to a RpcInterfaceState
|
||||
func (s *InterfaceState) ToRpcInterfaceState() *RpcInterfaceState {
|
||||
addrs := make([]RpcIPv6Address, len(s.IPv6Addresses))
|
||||
for i, addr := range s.IPv6Addresses {
|
||||
addrs[i] = RpcIPv6Address{
|
||||
Address: addr.Address.String(),
|
||||
Prefix: addr.Prefix.String(),
|
||||
ValidLifetime: addr.ValidLifetime,
|
||||
PreferredLifetime: addr.PreferredLifetime,
|
||||
Scope: addr.Scope,
|
||||
Flags: addr.Flags,
|
||||
FlagSecondary: addr.Flags&unix.IFA_F_SECONDARY != 0,
|
||||
FlagPermanent: addr.Flags&unix.IFA_F_PERMANENT != 0,
|
||||
FlagTemporary: addr.Flags&unix.IFA_F_TEMPORARY != 0,
|
||||
FlagStablePrivacy: addr.Flags&unix.IFA_F_STABLE_PRIVACY != 0,
|
||||
FlagDeprecated: addr.Flags&unix.IFA_F_DEPRECATED != 0,
|
||||
FlagOptimistic: addr.Flags&unix.IFA_F_OPTIMISTIC != 0,
|
||||
FlagDADFailed: addr.Flags&unix.IFA_F_DADFAILED != 0,
|
||||
FlagTentative: addr.Flags&unix.IFA_F_TENTATIVE != 0,
|
||||
}
|
||||
}
|
||||
return &RpcInterfaceState{
|
||||
InterfaceState: *s,
|
||||
IPv6Addresses: addrs,
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,85 @@
|
|||
package types
|
||||
|
||||
import (
|
||||
"net"
|
||||
"slices"
|
||||
"time"
|
||||
|
||||
"github.com/vishvananda/netlink"
|
||||
)
|
||||
|
||||
// IPAddress represents a network interface address
|
||||
type IPAddress struct {
|
||||
Family int
|
||||
Address net.IPNet
|
||||
Gateway net.IP
|
||||
MTU int
|
||||
Secondary bool
|
||||
Permanent bool
|
||||
}
|
||||
|
||||
func (a *IPAddress) String() string {
|
||||
return a.Address.String()
|
||||
}
|
||||
|
||||
func (a *IPAddress) Compare(n netlink.Addr) bool {
|
||||
if !a.Address.IP.Equal(n.IP) {
|
||||
return false
|
||||
}
|
||||
if slices.Compare(a.Address.Mask, n.Mask) != 0 {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (a *IPAddress) NetlinkAddr() netlink.Addr {
|
||||
return netlink.Addr{
|
||||
IPNet: &a.Address,
|
||||
}
|
||||
}
|
||||
|
||||
func (a *IPAddress) DefaultRoute(linkIndex int) netlink.Route {
|
||||
return netlink.Route{
|
||||
Dst: nil,
|
||||
Gw: a.Gateway,
|
||||
LinkIndex: linkIndex,
|
||||
}
|
||||
}
|
||||
|
||||
// ParsedIPConfig represents the parsed IP configuration
|
||||
type ParsedIPConfig struct {
|
||||
Addresses []IPAddress
|
||||
Nameservers []net.IP
|
||||
SearchList []string
|
||||
Domain string
|
||||
MTU int
|
||||
Interface string
|
||||
}
|
||||
|
||||
// IPv6Address represents an IPv6 address with lifetime information
|
||||
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"`
|
||||
Flags int `json:"flags"`
|
||||
Scope int `json:"scope"`
|
||||
}
|
||||
|
||||
// RpcIPv6Address is the RPC representation of an IPv6 address
|
||||
type RpcIPv6Address struct {
|
||||
Address string `json:"address"`
|
||||
Prefix string `json:"prefix"`
|
||||
ValidLifetime *time.Time `json:"valid_lifetime"`
|
||||
PreferredLifetime *time.Time `json:"preferred_lifetime"`
|
||||
Scope int `json:"scope"`
|
||||
Flags int `json:"flags"`
|
||||
FlagSecondary bool `json:"flag_secondary"`
|
||||
FlagPermanent bool `json:"flag_permanent"`
|
||||
FlagTemporary bool `json:"flag_temporary"`
|
||||
FlagStablePrivacy bool `json:"flag_stable_privacy"`
|
||||
FlagDeprecated bool `json:"flag_deprecated"`
|
||||
FlagOptimistic bool `json:"flag_optimistic"`
|
||||
FlagDADFailed bool `json:"flag_dad_failed"`
|
||||
FlagTentative bool `json:"flag_tentative"`
|
||||
}
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
package types
|
||||
|
||||
import "net"
|
||||
|
||||
// InterfaceResolvConf represents the DNS configuration for a network interface
|
||||
type InterfaceResolvConf struct {
|
||||
NameServers []net.IP `json:"nameservers"`
|
||||
SearchList []string `json:"search_list"`
|
||||
Domain string `json:"domain,omitempty"` // TODO: remove this once we have a better way to handle the domain
|
||||
Source string `json:"source,omitempty"`
|
||||
}
|
||||
|
||||
// InterfaceResolvConfMap ..
|
||||
type InterfaceResolvConfMap map[string]InterfaceResolvConf
|
||||
|
||||
// ResolvConf represents the DNS configuration for the system
|
||||
type ResolvConf struct {
|
||||
ConfigIPv4 InterfaceResolvConfMap `json:"config_ipv4"`
|
||||
ConfigIPv6 InterfaceResolvConfMap `json:"config_ipv6"`
|
||||
Domain string `json:"domain"`
|
||||
HostName string `json:"host_name"`
|
||||
}
|
||||
|
|
@ -1,26 +0,0 @@
|
|||
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 any) 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)
|
||||
}
|
||||
|
|
@ -0,0 +1,149 @@
|
|||
//go:build synctrace
|
||||
|
||||
package sync
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"reflect"
|
||||
"runtime"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/jetkvm/kvm/internal/logging"
|
||||
"github.com/rs/zerolog"
|
||||
)
|
||||
|
||||
var defaultLogger = logging.GetSubsystemLogger("synctrace")
|
||||
|
||||
func logTrace(msg string) {
|
||||
if defaultLogger.GetLevel() > zerolog.TraceLevel {
|
||||
return
|
||||
}
|
||||
|
||||
logTrack(3).Trace().Msg(msg)
|
||||
}
|
||||
|
||||
func logTrack(callerSkip int) *zerolog.Logger {
|
||||
l := *defaultLogger
|
||||
if l.GetLevel() > zerolog.TraceLevel {
|
||||
return &l
|
||||
}
|
||||
|
||||
pc, file, no, ok := runtime.Caller(callerSkip)
|
||||
if ok {
|
||||
l = l.With().
|
||||
Str("file", file).
|
||||
Int("line", no).
|
||||
Logger()
|
||||
|
||||
details := runtime.FuncForPC(pc)
|
||||
if details != nil {
|
||||
l = l.With().
|
||||
Str("func", details.Name()).
|
||||
Logger()
|
||||
}
|
||||
}
|
||||
|
||||
return &l
|
||||
}
|
||||
|
||||
func logLockTrack(i string) *zerolog.Logger {
|
||||
l := logTrack(4).
|
||||
With().
|
||||
Str("index", i).
|
||||
Logger()
|
||||
return &l
|
||||
}
|
||||
|
||||
var (
|
||||
indexMu sync.Mutex
|
||||
|
||||
lockCount map[string]int = make(map[string]int)
|
||||
unlockCount map[string]int = make(map[string]int)
|
||||
lastLock map[string]time.Time = make(map[string]time.Time)
|
||||
)
|
||||
|
||||
type trackable interface {
|
||||
sync.Locker
|
||||
}
|
||||
|
||||
func getIndex(t trackable) string {
|
||||
ptr := reflect.ValueOf(t).Pointer()
|
||||
return fmt.Sprintf("%x", ptr)
|
||||
}
|
||||
|
||||
func increaseLockCount(i string) {
|
||||
indexMu.Lock()
|
||||
defer indexMu.Unlock()
|
||||
|
||||
if _, ok := lockCount[i]; !ok {
|
||||
lockCount[i] = 0
|
||||
}
|
||||
lockCount[i]++
|
||||
|
||||
if _, ok := lastLock[i]; !ok {
|
||||
lastLock[i] = time.Now()
|
||||
}
|
||||
}
|
||||
|
||||
func increaseUnlockCount(i string) {
|
||||
indexMu.Lock()
|
||||
defer indexMu.Unlock()
|
||||
|
||||
if _, ok := unlockCount[i]; !ok {
|
||||
unlockCount[i] = 0
|
||||
}
|
||||
unlockCount[i]++
|
||||
}
|
||||
|
||||
func logLock(t trackable) {
|
||||
i := getIndex(t)
|
||||
increaseLockCount(i)
|
||||
logLockTrack(i).Trace().Msg("locking mutex")
|
||||
}
|
||||
|
||||
func logUnlock(t trackable) {
|
||||
i := getIndex(t)
|
||||
increaseUnlockCount(i)
|
||||
logLockTrack(i).Trace().Msg("unlocking mutex")
|
||||
}
|
||||
|
||||
func logTryLock(t trackable) {
|
||||
i := getIndex(t)
|
||||
logLockTrack(i).Trace().Msg("trying to lock mutex")
|
||||
}
|
||||
|
||||
func logTryLockResult(t trackable, l bool) {
|
||||
if !l {
|
||||
return
|
||||
}
|
||||
i := getIndex(t)
|
||||
increaseLockCount(i)
|
||||
logLockTrack(i).Trace().Msg("locked mutex")
|
||||
}
|
||||
|
||||
func logRLock(t trackable) {
|
||||
i := getIndex(t)
|
||||
increaseLockCount(i)
|
||||
logLockTrack(i).Trace().Msg("locking mutex for reading")
|
||||
}
|
||||
|
||||
func logRUnlock(t trackable) {
|
||||
i := getIndex(t)
|
||||
increaseUnlockCount(i)
|
||||
logLockTrack(i).Trace().Msg("unlocking mutex for reading")
|
||||
}
|
||||
|
||||
func logTryRLock(t trackable) {
|
||||
i := getIndex(t)
|
||||
logLockTrack(i).Trace().Msg("trying to lock mutex for reading")
|
||||
}
|
||||
|
||||
func logTryRLockResult(t trackable, l bool) {
|
||||
if !l {
|
||||
return
|
||||
}
|
||||
i := getIndex(t)
|
||||
increaseLockCount(i)
|
||||
logLockTrack(i).Trace().Msg("locked mutex for reading")
|
||||
}
|
||||
|
|
@ -0,0 +1,69 @@
|
|||
//go:build synctrace
|
||||
|
||||
package sync
|
||||
|
||||
import (
|
||||
gosync "sync"
|
||||
)
|
||||
|
||||
// Mutex is a wrapper around the sync.Mutex
|
||||
type Mutex struct {
|
||||
mu gosync.Mutex
|
||||
}
|
||||
|
||||
// Lock locks the mutex
|
||||
func (m *Mutex) Lock() {
|
||||
logLock(m)
|
||||
m.mu.Lock()
|
||||
}
|
||||
|
||||
// Unlock unlocks the mutex
|
||||
func (m *Mutex) Unlock() {
|
||||
logUnlock(m)
|
||||
m.mu.Unlock()
|
||||
}
|
||||
|
||||
// TryLock tries to lock the mutex
|
||||
func (m *Mutex) TryLock() bool {
|
||||
logTryLock(m)
|
||||
l := m.mu.TryLock()
|
||||
logTryLockResult(m, l)
|
||||
return l
|
||||
}
|
||||
|
||||
// RWMutex is a wrapper around the sync.RWMutex
|
||||
type RWMutex struct {
|
||||
mu gosync.RWMutex
|
||||
}
|
||||
|
||||
// Lock locks the mutex
|
||||
func (m *RWMutex) Lock() {
|
||||
logLock(m)
|
||||
m.mu.Lock()
|
||||
}
|
||||
|
||||
// Unlock unlocks the mutex
|
||||
func (m *RWMutex) Unlock() {
|
||||
logUnlock(m)
|
||||
m.mu.Unlock()
|
||||
}
|
||||
|
||||
// RLock locks the mutex for reading
|
||||
func (m *RWMutex) RLock() {
|
||||
logRLock(m)
|
||||
m.mu.RLock()
|
||||
}
|
||||
|
||||
// RUnlock unlocks the mutex for reading
|
||||
func (m *RWMutex) RUnlock() {
|
||||
logRUnlock(m)
|
||||
m.mu.RUnlock()
|
||||
}
|
||||
|
||||
// TryRLock tries to lock the mutex for reading
|
||||
func (m *RWMutex) TryRLock() bool {
|
||||
logTryRLock(m)
|
||||
l := m.mu.TryRLock()
|
||||
logTryRLockResult(m, l)
|
||||
return l
|
||||
}
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
//go:build synctrace
|
||||
|
||||
package sync
|
||||
|
||||
import (
|
||||
gosync "sync"
|
||||
)
|
||||
|
||||
// Once is a wrapper around the sync.Once
|
||||
type Once struct {
|
||||
mu gosync.Once
|
||||
}
|
||||
|
||||
// Do calls the function f if and only if Do has not been called before for this instance of Once.
|
||||
func (o *Once) Do(f func()) {
|
||||
logTrace("Doing once")
|
||||
o.mu.Do(f)
|
||||
}
|
||||
|
|
@ -0,0 +1,92 @@
|
|||
//go:build !synctrace
|
||||
|
||||
package sync
|
||||
|
||||
import (
|
||||
gosync "sync"
|
||||
)
|
||||
|
||||
// Mutex is a wrapper around the sync.Mutex
|
||||
type Mutex struct {
|
||||
mu gosync.Mutex
|
||||
}
|
||||
|
||||
// Lock locks the mutex
|
||||
func (m *Mutex) Lock() {
|
||||
m.mu.Lock()
|
||||
}
|
||||
|
||||
// Unlock unlocks the mutex
|
||||
func (m *Mutex) Unlock() {
|
||||
m.mu.Unlock()
|
||||
}
|
||||
|
||||
// TryLock tries to lock the mutex
|
||||
func (m *Mutex) TryLock() bool {
|
||||
return m.mu.TryLock()
|
||||
}
|
||||
|
||||
// RWMutex is a wrapper around the sync.RWMutex
|
||||
type RWMutex struct {
|
||||
mu gosync.RWMutex
|
||||
}
|
||||
|
||||
// Lock locks the mutex
|
||||
func (m *RWMutex) Lock() {
|
||||
m.mu.Lock()
|
||||
}
|
||||
|
||||
// Unlock unlocks the mutex
|
||||
func (m *RWMutex) Unlock() {
|
||||
m.mu.Unlock()
|
||||
}
|
||||
|
||||
// RLock locks the mutex for reading
|
||||
func (m *RWMutex) RLock() {
|
||||
m.mu.RLock()
|
||||
}
|
||||
|
||||
// RUnlock unlocks the mutex for reading
|
||||
func (m *RWMutex) RUnlock() {
|
||||
m.mu.RUnlock()
|
||||
}
|
||||
|
||||
// TryRLock tries to lock the mutex for reading
|
||||
func (m *RWMutex) TryRLock() bool {
|
||||
return m.mu.TryRLock()
|
||||
}
|
||||
|
||||
// TryLock tries to lock the mutex
|
||||
func (m *RWMutex) TryLock() bool {
|
||||
return m.mu.TryLock()
|
||||
}
|
||||
|
||||
// WaitGroup is a wrapper around the sync.WaitGroup
|
||||
type WaitGroup struct {
|
||||
wg gosync.WaitGroup
|
||||
}
|
||||
|
||||
// Add adds a function to the wait group
|
||||
func (w *WaitGroup) Add(delta int) {
|
||||
w.wg.Add(delta)
|
||||
}
|
||||
|
||||
// Done decrements the wait group counter
|
||||
func (w *WaitGroup) Done() {
|
||||
w.wg.Done()
|
||||
}
|
||||
|
||||
// Wait waits for the wait group to finish
|
||||
func (w *WaitGroup) Wait() {
|
||||
w.wg.Wait()
|
||||
}
|
||||
|
||||
// Once is a wrapper around the sync.Once
|
||||
type Once struct {
|
||||
mu gosync.Once
|
||||
}
|
||||
|
||||
// Do calls the function f if and only if Do has not been called before for this instance of Once.
|
||||
func (o *Once) Do(f func()) {
|
||||
o.mu.Do(f)
|
||||
}
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
//go:build synctrace
|
||||
|
||||
package sync
|
||||
|
||||
import (
|
||||
gosync "sync"
|
||||
)
|
||||
|
||||
// WaitGroup is a wrapper around the sync.WaitGroup
|
||||
type WaitGroup struct {
|
||||
wg gosync.WaitGroup
|
||||
}
|
||||
|
||||
// Add adds a function to the wait group
|
||||
func (w *WaitGroup) Add(delta int) {
|
||||
logTrace("Adding to wait group")
|
||||
w.wg.Add(delta)
|
||||
}
|
||||
|
||||
// Done decrements the wait group counter
|
||||
func (w *WaitGroup) Done() {
|
||||
logTrace("Done with wait group")
|
||||
w.wg.Done()
|
||||
}
|
||||
|
||||
// Wait waits for the wait group to finish
|
||||
func (w *WaitGroup) Wait() {
|
||||
logTrace("Waiting for wait group")
|
||||
w.wg.Wait()
|
||||
}
|
||||
|
|
@ -3,13 +3,14 @@ package timesync
|
|||
import (
|
||||
"context"
|
||||
"math/rand/v2"
|
||||
"net"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/beevik/ntp"
|
||||
)
|
||||
|
||||
var defaultNTPServerIPs = []string{
|
||||
var DefaultNTPServerIPs = []string{
|
||||
// These servers are known by static IP and as such don't need DNS lookups
|
||||
// These are from Google and Cloudflare since if they're down, the internet
|
||||
// is broken anyway
|
||||
|
|
@ -27,7 +28,7 @@ var defaultNTPServerIPs = []string{
|
|||
"2001:4860:4806:c::", // time.google.com IPv6
|
||||
}
|
||||
|
||||
var defaultNTPServerHostnames = []string{
|
||||
var DefaultNTPServerHostnames = []string{
|
||||
// should use something from https://github.com/jauderho/public-ntp-servers
|
||||
"time.apple.com",
|
||||
"time.aws.com",
|
||||
|
|
@ -37,7 +38,48 @@ var defaultNTPServerHostnames = []string{
|
|||
"pool.ntp.org",
|
||||
}
|
||||
|
||||
func (t *TimeSync) filterNTPServers(ntpServers []string) ([]string, error) {
|
||||
if len(ntpServers) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
hasIPv4, err := t.preCheckIPv4()
|
||||
if err != nil {
|
||||
t.l.Error().Err(err).Msg("failed to check IPv4")
|
||||
return nil, err
|
||||
}
|
||||
|
||||
hasIPv6, err := t.preCheckIPv6()
|
||||
if err != nil {
|
||||
t.l.Error().Err(err).Msg("failed to check IPv6")
|
||||
return nil, err
|
||||
}
|
||||
|
||||
filteredServers := []string{}
|
||||
for _, server := range ntpServers {
|
||||
ip := net.ParseIP(server)
|
||||
t.l.Trace().Str("server", server).Interface("ip", ip).Msg("checking NTP server")
|
||||
if ip == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if hasIPv4 && ip.To4() != nil {
|
||||
filteredServers = append(filteredServers, server)
|
||||
}
|
||||
if hasIPv6 && ip.To16() != nil {
|
||||
filteredServers = append(filteredServers, server)
|
||||
}
|
||||
}
|
||||
return filteredServers, nil
|
||||
}
|
||||
|
||||
func (t *TimeSync) queryNetworkTime(ntpServers []string) (now *time.Time, offset *time.Duration) {
|
||||
ntpServers, err := t.filterNTPServers(ntpServers)
|
||||
if err != nil {
|
||||
t.l.Error().Err(err).Msg("failed to filter NTP servers")
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
chunkSize := int(t.networkConfig.TimeSyncParallel.ValueOr(4))
|
||||
t.l.Info().Strs("servers", ntpServers).Int("chunkSize", chunkSize).Msg("querying NTP servers")
|
||||
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import (
|
|||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/jetkvm/kvm/internal/network"
|
||||
"github.com/jetkvm/kvm/internal/network/types"
|
||||
"github.com/rs/zerolog"
|
||||
)
|
||||
|
||||
|
|
@ -24,11 +24,13 @@ var (
|
|||
timeSyncRetryInterval = 0 * time.Second
|
||||
)
|
||||
|
||||
type PreCheckFunc func() (bool, error)
|
||||
|
||||
type TimeSync struct {
|
||||
syncLock *sync.Mutex
|
||||
l *zerolog.Logger
|
||||
|
||||
networkConfig *network.NetworkConfig
|
||||
networkConfig *types.NetworkConfig
|
||||
dhcpNtpAddresses []string
|
||||
|
||||
rtcDevicePath string
|
||||
|
|
@ -36,14 +38,19 @@ type TimeSync struct {
|
|||
rtcLock *sync.Mutex
|
||||
|
||||
syncSuccess bool
|
||||
timer *time.Timer
|
||||
|
||||
preCheckFunc func() (bool, error)
|
||||
preCheckFunc PreCheckFunc
|
||||
preCheckIPv4 PreCheckFunc
|
||||
preCheckIPv6 PreCheckFunc
|
||||
}
|
||||
|
||||
type TimeSyncOptions struct {
|
||||
PreCheckFunc func() (bool, error)
|
||||
PreCheckFunc PreCheckFunc
|
||||
PreCheckIPv4 PreCheckFunc
|
||||
PreCheckIPv6 PreCheckFunc
|
||||
Logger *zerolog.Logger
|
||||
NetworkConfig *network.NetworkConfig
|
||||
NetworkConfig *types.NetworkConfig
|
||||
}
|
||||
|
||||
type SyncMode struct {
|
||||
|
|
@ -69,7 +76,10 @@ func NewTimeSync(opts *TimeSyncOptions) *TimeSync {
|
|||
rtcDevicePath: rtcDevice,
|
||||
rtcLock: &sync.Mutex{},
|
||||
preCheckFunc: opts.PreCheckFunc,
|
||||
preCheckIPv4: opts.PreCheckIPv4,
|
||||
preCheckIPv6: opts.PreCheckIPv6,
|
||||
networkConfig: opts.NetworkConfig,
|
||||
timer: time.NewTimer(timeSyncWaitNetUpInt),
|
||||
}
|
||||
|
||||
if t.rtcDevicePath != "" {
|
||||
|
|
@ -112,49 +122,64 @@ func (t *TimeSync) getSyncMode() SyncMode {
|
|||
}
|
||||
}
|
||||
|
||||
t.l.Debug().Strs("Ordering", syncMode.Ordering).Bool("Ntp", syncMode.Ntp).Bool("Http", syncMode.Http).Bool("NtpUseFallback", syncMode.NtpUseFallback).Bool("HttpUseFallback", syncMode.HttpUseFallback).Msg("sync mode")
|
||||
t.l.Debug().
|
||||
Strs("Ordering", syncMode.Ordering).
|
||||
Bool("Ntp", syncMode.Ntp).
|
||||
Bool("Http", syncMode.Http).
|
||||
Bool("NtpUseFallback", syncMode.NtpUseFallback).
|
||||
Bool("HttpUseFallback", syncMode.HttpUseFallback).
|
||||
Msg("sync mode")
|
||||
|
||||
return syncMode
|
||||
}
|
||||
func (t *TimeSync) doTimeSync() {
|
||||
func (t *TimeSync) timeSyncLoop() {
|
||||
metricTimeSyncStatus.Set(0)
|
||||
for {
|
||||
|
||||
// use a timer here instead of sleep
|
||||
|
||||
for range t.timer.C {
|
||||
if ok, err := t.preCheckFunc(); !ok {
|
||||
if err != nil {
|
||||
t.l.Error().Err(err).Msg("pre-check failed")
|
||||
}
|
||||
time.Sleep(timeSyncWaitNetChkInt)
|
||||
t.timer.Reset(timeSyncWaitNetChkInt)
|
||||
continue
|
||||
}
|
||||
|
||||
t.l.Info().Msg("syncing system time")
|
||||
start := time.Now()
|
||||
err := t.Sync()
|
||||
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)
|
||||
t.timer.Reset(timeSyncRetryInterval)
|
||||
// reset the retry interval if it exceeds the max interval
|
||||
if timeSyncRetryInterval > timeSyncRetryMaxInt {
|
||||
timeSyncRetryInterval = 0
|
||||
}
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
isInitialSync := !t.syncSuccess
|
||||
t.syncSuccess = true
|
||||
|
||||
t.l.Info().Str("now", time.Now().Format(time.RFC3339)).
|
||||
Str("time_taken", time.Since(start).String()).
|
||||
Bool("is_initial_sync", isInitialSync).
|
||||
Msg("time sync successful")
|
||||
|
||||
metricTimeSyncStatus.Set(1)
|
||||
|
||||
time.Sleep(timeSyncInterval) // after the first sync is done
|
||||
t.timer.Reset(timeSyncInterval) // after the first sync is done
|
||||
}
|
||||
}
|
||||
|
||||
func (t *TimeSync) Sync() error {
|
||||
func (t *TimeSync) sync() error {
|
||||
t.syncLock.Lock()
|
||||
defer t.syncLock.Unlock()
|
||||
|
||||
var (
|
||||
now *time.Time
|
||||
offset *time.Duration
|
||||
|
|
@ -188,10 +213,10 @@ Orders:
|
|||
case "ntp":
|
||||
if syncMode.Ntp && syncMode.NtpUseFallback {
|
||||
log.Info().Msg("using NTP fallback IPs")
|
||||
now, offset = t.queryNetworkTime(defaultNTPServerIPs)
|
||||
now, offset = t.queryNetworkTime(DefaultNTPServerIPs)
|
||||
if now == nil {
|
||||
log.Info().Msg("using NTP fallback hostnames")
|
||||
now, offset = t.queryNetworkTime(defaultNTPServerHostnames)
|
||||
now, offset = t.queryNetworkTime(DefaultNTPServerHostnames)
|
||||
}
|
||||
if now != nil {
|
||||
break Orders
|
||||
|
|
@ -239,12 +264,25 @@ Orders:
|
|||
return nil
|
||||
}
|
||||
|
||||
// Sync triggers a manual time sync
|
||||
func (t *TimeSync) Sync() error {
|
||||
if !t.syncLock.TryLock() {
|
||||
t.l.Warn().Msg("sync already in progress, skipping")
|
||||
return nil
|
||||
}
|
||||
t.syncLock.Unlock()
|
||||
|
||||
return t.sync()
|
||||
}
|
||||
|
||||
// IsSyncSuccess returns true if the system time is synchronized
|
||||
func (t *TimeSync) IsSyncSuccess() bool {
|
||||
return t.syncSuccess
|
||||
}
|
||||
|
||||
// Start starts the time sync
|
||||
func (t *TimeSync) Start() {
|
||||
go t.doTimeSync()
|
||||
go t.timeSyncLoop()
|
||||
}
|
||||
|
||||
func (t *TimeSync) setSystemTime(now time.Time) error {
|
||||
|
|
|
|||
|
|
@ -175,6 +175,10 @@ func rpcGetDeviceID() (string, error) {
|
|||
func rpcReboot(force bool) error {
|
||||
logger.Info().Msg("Got reboot request from JSONRPC, rebooting...")
|
||||
|
||||
writeJSONRPCEvent("willReboot", nil, currentSession)
|
||||
|
||||
// Wait for the JSONRPCEvent to be sent
|
||||
time.Sleep(1 * time.Second)
|
||||
nativeInstance.SwitchToScreenIfDifferent("rebooting_screen")
|
||||
|
||||
args := []string{}
|
||||
|
|
@ -720,7 +724,8 @@ func rpcSetWakeOnLanDevices(params SetWakeOnLanDevicesParams) error {
|
|||
}
|
||||
|
||||
func rpcResetConfig() error {
|
||||
config = defaultConfig
|
||||
defaultConfig := getDefaultConfig()
|
||||
config = &defaultConfig
|
||||
if err := SaveConfig(); err != nil {
|
||||
return fmt.Errorf("failed to reset config: %w", err)
|
||||
}
|
||||
|
|
|
|||
4
main.go
4
main.go
|
|
@ -33,6 +33,7 @@ func Main() {
|
|||
go runWatchdog()
|
||||
go confirmCurrentSystem()
|
||||
|
||||
initDisplay()
|
||||
initNative(systemVersionLocal, appVersionLocal)
|
||||
|
||||
http.DefaultClient.Timeout = 1 * time.Minute
|
||||
|
|
@ -74,9 +75,6 @@ func Main() {
|
|||
}
|
||||
initJiggler()
|
||||
|
||||
// initialize display
|
||||
initDisplay()
|
||||
|
||||
// start video sleep mode timer
|
||||
startVideoSleepModeTicker()
|
||||
|
||||
|
|
|
|||
16
mdns.go
16
mdns.go
|
|
@ -1,19 +1,23 @@
|
|||
package kvm
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/jetkvm/kvm/internal/mdns"
|
||||
)
|
||||
|
||||
var mDNS *mdns.MDNS
|
||||
|
||||
func initMdns() error {
|
||||
options := getMdnsOptions()
|
||||
if options == nil {
|
||||
return fmt.Errorf("failed to get mDNS options")
|
||||
}
|
||||
|
||||
m, err := mdns.NewMDNS(&mdns.MDNSOptions{
|
||||
Logger: logger,
|
||||
LocalNames: []string{
|
||||
networkState.GetHostname(),
|
||||
networkState.GetFQDN(),
|
||||
},
|
||||
ListenOptions: config.NetworkConfig.GetMDNSMode(),
|
||||
Logger: logger,
|
||||
LocalNames: options.LocalNames,
|
||||
ListenOptions: options.ListenOptions,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
|
|
|
|||
|
|
@ -43,6 +43,8 @@ func initNative(systemVersion *semver.Version, appVersion *semver.Version) {
|
|||
_ = rpcReboot(true)
|
||||
case "reboot":
|
||||
_ = rpcReboot(true)
|
||||
case "toggleDHCPClient":
|
||||
_ = rpcToggleDHCPClient()
|
||||
default:
|
||||
nativeLogger.Warn().Str("event", event).Msg("unknown rpc event received")
|
||||
}
|
||||
|
|
|
|||
325
network.go
325
network.go
|
|
@ -1,10 +1,14 @@
|
|||
package kvm
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"reflect"
|
||||
|
||||
"github.com/jetkvm/kvm/internal/network"
|
||||
"github.com/jetkvm/kvm/internal/udhcpc"
|
||||
"github.com/jetkvm/kvm/internal/confparser"
|
||||
"github.com/jetkvm/kvm/internal/mdns"
|
||||
"github.com/jetkvm/kvm/internal/network/types"
|
||||
"github.com/jetkvm/kvm/pkg/nmlite"
|
||||
)
|
||||
|
||||
const (
|
||||
|
|
@ -12,114 +16,297 @@ const (
|
|||
)
|
||||
|
||||
var (
|
||||
networkState *network.NetworkInterfaceState
|
||||
networkManager *nmlite.NetworkManager
|
||||
)
|
||||
|
||||
func networkStateChanged(isOnline bool) {
|
||||
// do not block the main thread
|
||||
go waitCtrlAndRequestDisplayUpdate(true, "network_state_changed")
|
||||
type RpcNetworkSettings struct {
|
||||
types.NetworkConfig
|
||||
}
|
||||
|
||||
if timeSync != nil {
|
||||
if networkState != nil {
|
||||
timeSync.SetDhcpNtpAddresses(networkState.NtpAddressesString())
|
||||
func (s *RpcNetworkSettings) ToNetworkConfig() *types.NetworkConfig {
|
||||
return &s.NetworkConfig
|
||||
}
|
||||
|
||||
type PostRebootAction struct {
|
||||
HealthCheck string `json:"healthCheck"`
|
||||
RedirectUrl string `json:"redirectUrl"`
|
||||
}
|
||||
|
||||
func toRpcNetworkSettings(config *types.NetworkConfig) *RpcNetworkSettings {
|
||||
return &RpcNetworkSettings{
|
||||
NetworkConfig: *config,
|
||||
}
|
||||
}
|
||||
|
||||
func getMdnsOptions() *mdns.MDNSOptions {
|
||||
if networkManager == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var ipv4, ipv6 bool
|
||||
switch config.NetworkConfig.MDNSMode.String {
|
||||
case "auto":
|
||||
ipv4 = true
|
||||
ipv6 = true
|
||||
case "ipv4_only":
|
||||
ipv4 = true
|
||||
case "ipv6_only":
|
||||
ipv6 = true
|
||||
}
|
||||
|
||||
return &mdns.MDNSOptions{
|
||||
LocalNames: []string{
|
||||
networkManager.Hostname(),
|
||||
networkManager.FQDN(),
|
||||
},
|
||||
ListenOptions: &mdns.MDNSListenOptions{
|
||||
IPv4: ipv4,
|
||||
IPv6: ipv6,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func restartMdns() {
|
||||
if mDNS == nil {
|
||||
return
|
||||
}
|
||||
|
||||
options := getMdnsOptions()
|
||||
if options == nil {
|
||||
return
|
||||
}
|
||||
|
||||
if err := mDNS.SetOptions(options); err != nil {
|
||||
networkLogger.Error().Err(err).Msg("failed to restart mDNS")
|
||||
}
|
||||
}
|
||||
|
||||
func triggerTimeSyncOnNetworkStateChange() {
|
||||
if timeSync == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// set the NTP servers from the network manager
|
||||
if networkManager != nil {
|
||||
ntpServers := make([]string, len(networkManager.NTPServers()))
|
||||
for i, server := range networkManager.NTPServers() {
|
||||
ntpServers[i] = server.String()
|
||||
}
|
||||
networkLogger.Info().Strs("ntpServers", ntpServers).Msg("setting NTP servers from network manager")
|
||||
timeSync.SetDhcpNtpAddresses(ntpServers)
|
||||
}
|
||||
|
||||
// sync time
|
||||
go func() {
|
||||
if err := timeSync.Sync(); err != nil {
|
||||
networkLogger.Error().Err(err).Msg("failed to sync time after network state change")
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func networkStateChanged(_ string, state types.InterfaceState) {
|
||||
// do not block the main thread
|
||||
go waitCtrlAndRequestDisplayUpdate(true, "network_state_changed")
|
||||
|
||||
if currentSession != nil {
|
||||
writeJSONRPCEvent("networkState", state.ToRpcInterfaceState(), currentSession)
|
||||
}
|
||||
|
||||
if state.Online {
|
||||
networkLogger.Info().Msg("network state changed to online, triggering time sync")
|
||||
triggerTimeSyncOnNetworkStateChange()
|
||||
}
|
||||
|
||||
// always restart mDNS when the network state changes
|
||||
if mDNS != nil {
|
||||
_ = mDNS.SetListenOptions(config.NetworkConfig.GetMDNSMode())
|
||||
_ = mDNS.SetLocalNames([]string{
|
||||
networkState.GetHostname(),
|
||||
networkState.GetFQDN(),
|
||||
}, true)
|
||||
restartMdns()
|
||||
}
|
||||
}
|
||||
|
||||
func validateNetworkConfig() {
|
||||
err := confparser.SetDefaultsAndValidate(config.NetworkConfig)
|
||||
if err == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// if the network is now online, trigger an NTP sync if still needed
|
||||
if isOnline && timeSync != nil && (isTimeSyncNeeded() || !timeSync.IsSyncSuccess()) {
|
||||
if err := timeSync.Sync(); err != nil {
|
||||
logger.Warn().Str("error", err.Error()).Msg("unable to sync time on network state change")
|
||||
}
|
||||
networkLogger.Error().Err(err).Msg("failed to validate config, reverting to default config")
|
||||
if err := SaveBackupConfig(); err != nil {
|
||||
networkLogger.Error().Err(err).Msg("failed to save backup config")
|
||||
}
|
||||
|
||||
// do not use a pointer to the default config
|
||||
// it has been already changed during LoadConfig
|
||||
config.NetworkConfig = &(types.NetworkConfig{})
|
||||
if err := SaveConfig(); err != nil {
|
||||
networkLogger.Error().Err(err).Msg("failed to save config")
|
||||
}
|
||||
}
|
||||
|
||||
func initNetwork() error {
|
||||
ensureConfigLoaded()
|
||||
|
||||
state, err := network.NewNetworkInterfaceState(&network.NetworkInterfaceOptions{
|
||||
DefaultHostname: GetDefaultHostname(),
|
||||
InterfaceName: NetIfName,
|
||||
NetworkConfig: config.NetworkConfig,
|
||||
Logger: networkLogger,
|
||||
OnStateChange: func(state *network.NetworkInterfaceState) {
|
||||
networkStateChanged(state.IsOnline())
|
||||
},
|
||||
OnInitialCheck: func(state *network.NetworkInterfaceState) {
|
||||
networkStateChanged(state.IsOnline())
|
||||
},
|
||||
OnDhcpLeaseChange: func(lease *udhcpc.Lease, state *network.NetworkInterfaceState) {
|
||||
networkStateChanged(state.IsOnline())
|
||||
// validate the config, if it's invalid, revert to the default config and save the backup
|
||||
validateNetworkConfig()
|
||||
|
||||
if currentSession == nil {
|
||||
return
|
||||
}
|
||||
nc := config.NetworkConfig
|
||||
|
||||
writeJSONRPCEvent("networkState", networkState.RpcGetNetworkState(), currentSession)
|
||||
},
|
||||
OnConfigChange: func(networkConfig *network.NetworkConfig) {
|
||||
config.NetworkConfig = networkConfig
|
||||
networkStateChanged(false)
|
||||
|
||||
if mDNS != nil {
|
||||
_ = mDNS.SetListenOptions(networkConfig.GetMDNSMode())
|
||||
_ = mDNS.SetLocalNames([]string{
|
||||
networkState.GetHostname(),
|
||||
networkState.GetFQDN(),
|
||||
}, true)
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
if state == nil {
|
||||
if err == nil {
|
||||
return fmt.Errorf("failed to create NetworkInterfaceState")
|
||||
}
|
||||
return err
|
||||
nm := nmlite.NewNetworkManager(context.Background(), networkLogger)
|
||||
networkLogger.Info().Interface("networkConfig", nc).Str("hostname", nc.Hostname.String).Str("domain", nc.Domain.String).Msg("initializing network manager")
|
||||
_ = setHostname(nm, nc.Hostname.String, nc.Domain.String)
|
||||
nm.SetOnInterfaceStateChange(networkStateChanged)
|
||||
if err := nm.AddInterface(NetIfName, nc); err != nil {
|
||||
return fmt.Errorf("failed to add interface: %w", err)
|
||||
}
|
||||
_ = nm.CleanUpLegacyDHCPClients()
|
||||
|
||||
if err := state.Run(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
networkState = state
|
||||
networkManager = nm
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func rpcGetNetworkState() network.RpcNetworkState {
|
||||
return networkState.RpcGetNetworkState()
|
||||
func setHostname(nm *nmlite.NetworkManager, hostname, domain string) error {
|
||||
if nm == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if hostname == "" {
|
||||
hostname = GetDefaultHostname()
|
||||
}
|
||||
|
||||
return nm.SetHostname(hostname, domain)
|
||||
}
|
||||
|
||||
func rpcGetNetworkSettings() network.RpcNetworkSettings {
|
||||
return networkState.RpcGetNetworkSettings()
|
||||
func shouldRebootForNetworkChange(oldConfig, newConfig *types.NetworkConfig) (rebootRequired bool, postRebootAction *PostRebootAction) {
|
||||
oldDhcpClient := oldConfig.DHCPClient.String
|
||||
|
||||
l := networkLogger.With().
|
||||
Interface("old", oldConfig).
|
||||
Interface("new", newConfig).
|
||||
Logger()
|
||||
|
||||
// DHCP client change always requires reboot
|
||||
if newConfig.DHCPClient.String != oldDhcpClient {
|
||||
rebootRequired = true
|
||||
l.Info().Msg("DHCP client changed, reboot required")
|
||||
return rebootRequired, postRebootAction
|
||||
}
|
||||
|
||||
oldIPv4Mode := oldConfig.IPv4Mode.String
|
||||
newIPv4Mode := newConfig.IPv4Mode.String
|
||||
// IPv4 mode change requires reboot
|
||||
if newIPv4Mode != oldIPv4Mode {
|
||||
rebootRequired = true
|
||||
l.Info().Msg("IPv4 mode changed with udhcpc, reboot required")
|
||||
|
||||
if newIPv4Mode == "static" && oldIPv4Mode != "static" {
|
||||
postRebootAction = &PostRebootAction{
|
||||
HealthCheck: fmt.Sprintf("//%s/device/status", newConfig.IPv4Static.Address.String),
|
||||
RedirectUrl: fmt.Sprintf("//%s", newConfig.IPv4Static.Address.String),
|
||||
}
|
||||
l.Info().Interface("postRebootAction", postRebootAction).Msg("IPv4 mode changed to static, reboot required")
|
||||
}
|
||||
|
||||
return rebootRequired, postRebootAction
|
||||
}
|
||||
|
||||
// IPv4 static config changes require reboot
|
||||
if !reflect.DeepEqual(oldConfig.IPv4Static, newConfig.IPv4Static) {
|
||||
rebootRequired = true
|
||||
|
||||
// Handle IP change for redirect (only if both are not nil and IP changed)
|
||||
if newConfig.IPv4Static != nil && oldConfig.IPv4Static != nil &&
|
||||
newConfig.IPv4Static.Address.String != oldConfig.IPv4Static.Address.String {
|
||||
postRebootAction = &PostRebootAction{
|
||||
HealthCheck: fmt.Sprintf("//%s/device/status", newConfig.IPv4Static.Address.String),
|
||||
RedirectUrl: fmt.Sprintf("//%s", newConfig.IPv4Static.Address.String),
|
||||
}
|
||||
|
||||
l.Info().Interface("postRebootAction", postRebootAction).Msg("IPv4 static config changed, reboot required")
|
||||
}
|
||||
|
||||
return rebootRequired, postRebootAction
|
||||
}
|
||||
|
||||
// IPv6 mode change requires reboot when using udhcpc
|
||||
if newConfig.IPv6Mode.String != oldConfig.IPv6Mode.String && oldDhcpClient == "udhcpc" {
|
||||
rebootRequired = true
|
||||
l.Info().Msg("IPv6 mode changed with udhcpc, reboot required")
|
||||
}
|
||||
|
||||
return rebootRequired, postRebootAction
|
||||
}
|
||||
|
||||
func rpcSetNetworkSettings(settings network.RpcNetworkSettings) (*network.RpcNetworkSettings, error) {
|
||||
s := networkState.RpcSetNetworkSettings(settings)
|
||||
func rpcGetNetworkState() *types.RpcInterfaceState {
|
||||
state, _ := networkManager.GetInterfaceState(NetIfName)
|
||||
return state.ToRpcInterfaceState()
|
||||
}
|
||||
|
||||
func rpcGetNetworkSettings() *RpcNetworkSettings {
|
||||
return toRpcNetworkSettings(config.NetworkConfig)
|
||||
}
|
||||
|
||||
func rpcSetNetworkSettings(settings RpcNetworkSettings) (*RpcNetworkSettings, error) {
|
||||
netConfig := settings.ToNetworkConfig()
|
||||
|
||||
l := networkLogger.With().
|
||||
Str("interface", NetIfName).
|
||||
Interface("newConfig", netConfig).
|
||||
Logger()
|
||||
|
||||
l.Debug().Msg("setting new config")
|
||||
|
||||
// Check if reboot is needed
|
||||
rebootRequired, postRebootAction := shouldRebootForNetworkChange(config.NetworkConfig, netConfig)
|
||||
|
||||
// If reboot required, send willReboot event before applying network config
|
||||
if rebootRequired {
|
||||
l.Info().Msg("Sending willReboot event before applying network config")
|
||||
writeJSONRPCEvent("willReboot", postRebootAction, currentSession)
|
||||
}
|
||||
|
||||
_ = setHostname(networkManager, netConfig.Hostname.String, netConfig.Domain.String)
|
||||
|
||||
s := networkManager.SetInterfaceConfig(NetIfName, netConfig)
|
||||
if s != nil {
|
||||
return nil, s
|
||||
}
|
||||
l.Debug().Msg("new config applied")
|
||||
|
||||
newConfig, err := networkManager.GetInterfaceConfig(NetIfName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
config.NetworkConfig = newConfig
|
||||
|
||||
l.Debug().Msg("saving new config")
|
||||
if err := SaveConfig(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &network.RpcNetworkSettings{NetworkConfig: *config.NetworkConfig}, nil
|
||||
if rebootRequired {
|
||||
if err := rpcReboot(false); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return toRpcNetworkSettings(newConfig), nil
|
||||
}
|
||||
|
||||
func rpcRenewDHCPLease() error {
|
||||
return networkState.RpcRenewDHCPLease()
|
||||
return networkManager.RenewDHCPLease(NetIfName)
|
||||
}
|
||||
|
||||
func rpcToggleDHCPClient() error {
|
||||
switch config.NetworkConfig.DHCPClient.String {
|
||||
case "jetdhcpc":
|
||||
config.NetworkConfig.DHCPClient.String = "udhcpc"
|
||||
case "udhcpc":
|
||||
config.NetworkConfig.DHCPClient.String = "jetdhcpc"
|
||||
}
|
||||
|
||||
if err := SaveConfig(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return rpcReboot(true)
|
||||
}
|
||||
|
|
|
|||
9
ota.go
9
ota.go
|
|
@ -488,6 +488,15 @@ func TryUpdate(ctx context.Context, deviceId string, includePreRelease bool) err
|
|||
|
||||
if rebootNeeded {
|
||||
scopedLogger.Info().Msg("System Rebooting in 10s")
|
||||
|
||||
// TODO: Future enhancement - send postRebootAction to redirect to release notes
|
||||
// Example:
|
||||
// postRebootAction := &PostRebootAction{
|
||||
// HealthCheck: "[..]/device/status",
|
||||
// RedirectUrl: "[..]/settings/general/update?version=X.Y.Z",
|
||||
// }
|
||||
// writeJSONRPCEvent("willReboot", postRebootAction, currentSession)
|
||||
|
||||
time.Sleep(10 * time.Second)
|
||||
cmd := exec.Command("reboot")
|
||||
err := cmd.Start()
|
||||
|
|
|
|||
|
|
@ -0,0 +1,219 @@
|
|||
// Package nmlite provides DHCP client functionality for the network manager.
|
||||
package nmlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/jetkvm/kvm/internal/network/types"
|
||||
"github.com/jetkvm/kvm/pkg/nmlite/jetdhcpc"
|
||||
"github.com/jetkvm/kvm/pkg/nmlite/udhcpc"
|
||||
"github.com/rs/zerolog"
|
||||
)
|
||||
|
||||
// DHCPClient wraps the dhclient package for use in the network manager
|
||||
type DHCPClient struct {
|
||||
ctx context.Context
|
||||
ifaceName string
|
||||
logger *zerolog.Logger
|
||||
client types.DHCPClient
|
||||
clientType string
|
||||
|
||||
// Configuration
|
||||
ipv4Enabled bool
|
||||
ipv6Enabled bool
|
||||
|
||||
// Callbacks
|
||||
onLeaseChange func(lease *types.DHCPLease)
|
||||
}
|
||||
|
||||
// NewDHCPClient creates a new DHCP client
|
||||
func NewDHCPClient(ctx context.Context, ifaceName string, logger *zerolog.Logger, clientType string) (*DHCPClient, error) {
|
||||
if ifaceName == "" {
|
||||
return nil, fmt.Errorf("interface name cannot be empty")
|
||||
}
|
||||
|
||||
if logger == nil {
|
||||
return nil, fmt.Errorf("logger cannot be nil")
|
||||
}
|
||||
|
||||
return &DHCPClient{
|
||||
ctx: ctx,
|
||||
ifaceName: ifaceName,
|
||||
logger: logger,
|
||||
clientType: clientType,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// SetIPv4 enables or disables IPv4 DHCP
|
||||
func (dc *DHCPClient) SetIPv4(enabled bool) {
|
||||
dc.ipv4Enabled = enabled
|
||||
if dc.client != nil {
|
||||
dc.client.SetIPv4(enabled)
|
||||
}
|
||||
}
|
||||
|
||||
// SetIPv6 enables or disables IPv6 DHCP
|
||||
func (dc *DHCPClient) SetIPv6(enabled bool) {
|
||||
dc.ipv6Enabled = enabled
|
||||
if dc.client != nil {
|
||||
dc.client.SetIPv6(enabled)
|
||||
}
|
||||
}
|
||||
|
||||
// SetOnLeaseChange sets the callback for lease changes
|
||||
func (dc *DHCPClient) SetOnLeaseChange(callback func(lease *types.DHCPLease)) {
|
||||
dc.onLeaseChange = callback
|
||||
}
|
||||
|
||||
func (dc *DHCPClient) initClient() (types.DHCPClient, error) {
|
||||
switch dc.clientType {
|
||||
case "jetdhcpc":
|
||||
return dc.initJetDHCPC()
|
||||
case "udhcpc":
|
||||
return dc.initUDHCPC()
|
||||
default:
|
||||
return nil, fmt.Errorf("invalid client type: %s", dc.clientType)
|
||||
}
|
||||
}
|
||||
|
||||
func (dc *DHCPClient) initJetDHCPC() (types.DHCPClient, error) {
|
||||
return jetdhcpc.NewClient(dc.ctx, []string{dc.ifaceName}, &jetdhcpc.Config{
|
||||
IPv4: dc.ipv4Enabled,
|
||||
IPv6: dc.ipv6Enabled,
|
||||
V4ClientIdentifier: true,
|
||||
OnLease4Change: func(lease *types.DHCPLease) {
|
||||
dc.handleLeaseChange(lease, false)
|
||||
},
|
||||
OnLease6Change: func(lease *types.DHCPLease) {
|
||||
dc.handleLeaseChange(lease, true)
|
||||
},
|
||||
UpdateResolvConf: func(nameservers []string) error {
|
||||
// This will be handled by the resolv.conf manager
|
||||
dc.logger.Debug().
|
||||
Interface("nameservers", nameservers).
|
||||
Msg("DHCP client requested resolv.conf update")
|
||||
return nil
|
||||
},
|
||||
}, dc.logger)
|
||||
}
|
||||
|
||||
func (dc *DHCPClient) initUDHCPC() (types.DHCPClient, error) {
|
||||
c := udhcpc.NewDHCPClient(&udhcpc.DHCPClientOptions{
|
||||
InterfaceName: dc.ifaceName,
|
||||
PidFile: "",
|
||||
Logger: dc.logger,
|
||||
OnLeaseChange: func(lease *types.DHCPLease) {
|
||||
dc.handleLeaseChange(lease, false)
|
||||
},
|
||||
})
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// Start starts the DHCP client
|
||||
func (dc *DHCPClient) Start() error {
|
||||
if dc.client != nil {
|
||||
dc.logger.Warn().Msg("DHCP client already started")
|
||||
return nil
|
||||
}
|
||||
|
||||
dc.logger.Info().Msg("starting DHCP client")
|
||||
|
||||
// Create the underlying DHCP client
|
||||
client, err := dc.initClient()
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create DHCP client: %w", err)
|
||||
}
|
||||
|
||||
dc.client = client
|
||||
|
||||
// Start the client
|
||||
if err := dc.client.Start(); err != nil {
|
||||
dc.client = nil
|
||||
return fmt.Errorf("failed to start DHCP client: %w", err)
|
||||
}
|
||||
|
||||
dc.logger.Info().Msg("DHCP client started")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (dc *DHCPClient) Domain() string {
|
||||
if dc.client == nil {
|
||||
return ""
|
||||
}
|
||||
return dc.client.Domain()
|
||||
}
|
||||
|
||||
func (dc *DHCPClient) Lease4() *types.DHCPLease {
|
||||
if dc.client == nil {
|
||||
return nil
|
||||
}
|
||||
return dc.client.Lease4()
|
||||
}
|
||||
|
||||
func (dc *DHCPClient) Lease6() *types.DHCPLease {
|
||||
if dc.client == nil {
|
||||
return nil
|
||||
}
|
||||
return dc.client.Lease6()
|
||||
}
|
||||
|
||||
// Stop stops the DHCP client
|
||||
func (dc *DHCPClient) Stop() error {
|
||||
if dc.client == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
dc.logger.Info().Msg("stopping DHCP client")
|
||||
|
||||
dc.client = nil
|
||||
dc.logger.Info().Msg("DHCP client stopped")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Renew renews the DHCP lease
|
||||
func (dc *DHCPClient) Renew() error {
|
||||
if dc.client == nil {
|
||||
return fmt.Errorf("DHCP client not started")
|
||||
}
|
||||
|
||||
dc.logger.Info().Msg("renewing DHCP lease")
|
||||
if err := dc.client.Renew(); err != nil {
|
||||
return fmt.Errorf("failed to renew DHCP lease: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Release releases the DHCP lease
|
||||
func (dc *DHCPClient) Release() error {
|
||||
if dc.client == nil {
|
||||
return fmt.Errorf("DHCP client not started")
|
||||
}
|
||||
|
||||
dc.logger.Info().Msg("releasing DHCP lease")
|
||||
if err := dc.client.Release(); err != nil {
|
||||
return fmt.Errorf("failed to release DHCP lease: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleLeaseChange handles lease changes from the underlying DHCP client
|
||||
func (dc *DHCPClient) handleLeaseChange(lease *types.DHCPLease, isIPv6 bool) {
|
||||
if lease == nil {
|
||||
return
|
||||
}
|
||||
|
||||
dc.logger.Info().
|
||||
Bool("ipv6", isIPv6).
|
||||
Str("ip", lease.IPAddress.String()).
|
||||
Msg("DHCP lease changed")
|
||||
|
||||
// copy the lease to avoid race conditions
|
||||
leaseCopy := *lease
|
||||
|
||||
// Notify callback
|
||||
if dc.onLeaseChange != nil {
|
||||
dc.onLeaseChange(&leaseCopy)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,261 @@
|
|||
package nmlite
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/net/idna"
|
||||
)
|
||||
|
||||
const (
|
||||
hostnamePath = "/etc/hostname"
|
||||
hostsPath = "/etc/hosts"
|
||||
)
|
||||
|
||||
// SetHostname sets the system hostname and updates /etc/hosts
|
||||
func (hm *ResolvConfManager) SetHostname(hostname, domain string) error {
|
||||
hostname = ToValidHostname(strings.TrimSpace(hostname))
|
||||
domain = ToValidHostname(strings.TrimSpace(domain))
|
||||
|
||||
if hostname == "" {
|
||||
return fmt.Errorf("invalid hostname: %s", hostname)
|
||||
}
|
||||
|
||||
hm.hostname = hostname
|
||||
hm.domain = domain
|
||||
|
||||
return hm.reconcileHostname()
|
||||
}
|
||||
|
||||
func (hm *ResolvConfManager) Domain() string {
|
||||
hm.mu.Lock()
|
||||
defer hm.mu.Unlock()
|
||||
return hm.getDomain()
|
||||
}
|
||||
|
||||
func (hm *ResolvConfManager) Hostname() string {
|
||||
hm.mu.Lock()
|
||||
defer hm.mu.Unlock()
|
||||
return hm.getHostname()
|
||||
}
|
||||
|
||||
func (hm *ResolvConfManager) FQDN() string {
|
||||
hm.mu.Lock()
|
||||
defer hm.mu.Unlock()
|
||||
return hm.getFQDN()
|
||||
}
|
||||
|
||||
func (hm *ResolvConfManager) getFQDN() string {
|
||||
hostname := hm.getHostname()
|
||||
domain := hm.getDomain()
|
||||
|
||||
if domain == "" {
|
||||
return hostname
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%s.%s", hostname, domain)
|
||||
}
|
||||
|
||||
func (hm *ResolvConfManager) getHostname() string {
|
||||
if hm.hostname != "" {
|
||||
return hm.hostname
|
||||
}
|
||||
return "jetkvm"
|
||||
}
|
||||
|
||||
func (hm *ResolvConfManager) getDomain() string {
|
||||
if hm.domain != "" {
|
||||
return hm.domain
|
||||
}
|
||||
|
||||
for _, iface := range hm.conf.ConfigIPv4 {
|
||||
if iface.Domain != "" {
|
||||
return iface.Domain
|
||||
}
|
||||
}
|
||||
|
||||
for _, iface := range hm.conf.ConfigIPv6 {
|
||||
if iface.Domain != "" {
|
||||
return iface.Domain
|
||||
}
|
||||
}
|
||||
|
||||
return "local"
|
||||
}
|
||||
|
||||
func (hm *ResolvConfManager) reconcileHostname() error {
|
||||
hm.mu.Lock()
|
||||
domain := hm.getDomain()
|
||||
hostname := hm.hostname
|
||||
if hostname == "" {
|
||||
hostname = "jetkvm"
|
||||
}
|
||||
hm.mu.Unlock()
|
||||
|
||||
fqdn := hostname
|
||||
if fqdn != "" {
|
||||
fqdn = fmt.Sprintf("%s.%s", hostname, domain)
|
||||
}
|
||||
|
||||
hm.logger.Info().
|
||||
Str("hostname", hostname).
|
||||
Str("fqdn", fqdn).
|
||||
Msg("setting hostname")
|
||||
|
||||
// Update /etc/hostname
|
||||
if err := hm.updateEtcHostname(hostname); err != nil {
|
||||
return fmt.Errorf("failed to update /etc/hostname: %w", err)
|
||||
}
|
||||
|
||||
// Update /etc/hosts
|
||||
if err := hm.updateEtcHosts(hostname, fqdn); err != nil {
|
||||
return fmt.Errorf("failed to update /etc/hosts: %w", err)
|
||||
}
|
||||
|
||||
// Set the hostname using hostname command
|
||||
if err := hm.setSystemHostname(hostname); err != nil {
|
||||
return fmt.Errorf("failed to set system hostname: %w", err)
|
||||
}
|
||||
|
||||
hm.logger.Info().
|
||||
Str("hostname", hostname).
|
||||
Str("fqdn", fqdn).
|
||||
Msg("hostname set successfully")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetCurrentHostname returns the current system hostname
|
||||
func (hm *ResolvConfManager) GetCurrentHostname() (string, error) {
|
||||
return os.Hostname()
|
||||
}
|
||||
|
||||
// GetCurrentFQDN returns the current FQDN
|
||||
func (hm *ResolvConfManager) GetCurrentFQDN() (string, error) {
|
||||
hostname, err := hm.GetCurrentHostname()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Try to get the FQDN from /etc/hosts
|
||||
return hm.getFQDNFromHosts(hostname)
|
||||
}
|
||||
|
||||
// updateEtcHostname updates the /etc/hostname file
|
||||
func (hm *ResolvConfManager) updateEtcHostname(hostname string) error {
|
||||
if err := os.WriteFile(hostnamePath, []byte(hostname), 0644); err != nil {
|
||||
return fmt.Errorf("failed to write %s: %w", hostnamePath, err)
|
||||
}
|
||||
|
||||
hm.logger.Debug().Str("file", hostnamePath).Str("hostname", hostname).Msg("updated /etc/hostname")
|
||||
return nil
|
||||
}
|
||||
|
||||
// updateEtcHosts updates the /etc/hosts file
|
||||
func (hm *ResolvConfManager) updateEtcHosts(hostname, fqdn string) error {
|
||||
// Open /etc/hosts for reading and writing
|
||||
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)
|
||||
}
|
||||
|
||||
// Process lines
|
||||
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)
|
||||
}
|
||||
|
||||
// Add host line if it doesn't exist
|
||||
if !hostLineExists {
|
||||
newLines = append(newLines, hostLine)
|
||||
}
|
||||
|
||||
// Write back to file
|
||||
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)
|
||||
}
|
||||
|
||||
hm.logger.Debug().
|
||||
Str("file", hostsPath).
|
||||
Str("hostname", hostname).
|
||||
Str("fqdn", fqdn).
|
||||
Msg("updated /etc/hosts")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// setSystemHostname sets the system hostname using the hostname command
|
||||
func (hm *ResolvConfManager) setSystemHostname(hostname string) error {
|
||||
cmd := exec.Command("hostname", "-F", hostnamePath)
|
||||
if err := cmd.Run(); err != nil {
|
||||
return fmt.Errorf("failed to run hostname command: %w", err)
|
||||
}
|
||||
|
||||
hm.logger.Debug().Str("hostname", hostname).Msg("set system hostname")
|
||||
return nil
|
||||
}
|
||||
|
||||
// getFQDNFromHosts tries to get the FQDN from /etc/hosts
|
||||
func (hm *ResolvConfManager) getFQDNFromHosts(hostname string) (string, error) {
|
||||
content, err := os.ReadFile(hostsPath)
|
||||
if err != nil {
|
||||
return hostname, nil // Return hostname as fallback
|
||||
}
|
||||
|
||||
lines := strings.Split(string(content), "\n")
|
||||
for _, line := range lines {
|
||||
if strings.HasPrefix(line, "127.0.1.1") {
|
||||
parts := strings.Fields(line)
|
||||
if len(parts) >= 2 {
|
||||
// The second part should be the FQDN
|
||||
return parts[1], nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return hostname, nil // Return hostname as fallback
|
||||
}
|
||||
|
||||
// ToValidHostname converts a hostname to a valid format
|
||||
func ToValidHostname(hostname string) string {
|
||||
ascii, err := idna.Lookup.ToASCII(hostname)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return ascii
|
||||
}
|
||||
|
||||
// ValidateHostname validates a hostname
|
||||
func ValidateHostname(hostname string) error {
|
||||
_, err := idna.Lookup.ToASCII(hostname)
|
||||
return err
|
||||
}
|
||||
|
|
@ -0,0 +1,853 @@
|
|||
package nmlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/netip"
|
||||
|
||||
"time"
|
||||
|
||||
"github.com/jetkvm/kvm/internal/sync"
|
||||
|
||||
"github.com/jetkvm/kvm/internal/confparser"
|
||||
"github.com/jetkvm/kvm/internal/logging"
|
||||
"github.com/jetkvm/kvm/internal/network/types"
|
||||
"github.com/jetkvm/kvm/pkg/nmlite/link"
|
||||
"github.com/mdlayher/ndp"
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/vishvananda/netlink"
|
||||
)
|
||||
|
||||
type ResolvConfChangeCallback func(family int, resolvConf *types.InterfaceResolvConf) error
|
||||
|
||||
// InterfaceManager manages a single network interface
|
||||
type InterfaceManager struct {
|
||||
ctx context.Context
|
||||
ifaceName string
|
||||
config *types.NetworkConfig
|
||||
logger *zerolog.Logger
|
||||
state *types.InterfaceState
|
||||
linkState *link.Link
|
||||
stateMu sync.RWMutex
|
||||
|
||||
// Network components
|
||||
staticConfig *StaticConfigManager
|
||||
dhcpClient *DHCPClient
|
||||
|
||||
// Callbacks
|
||||
onStateChange func(state types.InterfaceState)
|
||||
onConfigChange func(config *types.NetworkConfig)
|
||||
onDHCPLeaseChange func(lease *types.DHCPLease)
|
||||
onResolvConfChange ResolvConfChangeCallback
|
||||
|
||||
// Control
|
||||
stopCh chan struct{}
|
||||
wg sync.WaitGroup
|
||||
}
|
||||
|
||||
// NewInterfaceManager creates a new interface manager
|
||||
func NewInterfaceManager(ctx context.Context, ifaceName string, config *types.NetworkConfig, logger *zerolog.Logger) (*InterfaceManager, error) {
|
||||
if config == nil {
|
||||
return nil, fmt.Errorf("config cannot be nil")
|
||||
}
|
||||
|
||||
if logger == nil {
|
||||
logger = logging.GetSubsystemLogger("interface")
|
||||
}
|
||||
|
||||
scopedLogger := logger.With().Str("interface", ifaceName).Logger()
|
||||
|
||||
// Validate and set defaults
|
||||
if err := confparser.SetDefaultsAndValidate(config); err != nil {
|
||||
return nil, fmt.Errorf("invalid config: %w", err)
|
||||
}
|
||||
|
||||
im := &InterfaceManager{
|
||||
ctx: ctx,
|
||||
ifaceName: ifaceName,
|
||||
config: config,
|
||||
logger: &scopedLogger,
|
||||
state: &types.InterfaceState{
|
||||
InterfaceName: ifaceName,
|
||||
// LastUpdated: time.Now(),
|
||||
},
|
||||
stopCh: make(chan struct{}),
|
||||
}
|
||||
|
||||
// Initialize components
|
||||
var err error
|
||||
im.staticConfig, err = NewStaticConfigManager(ifaceName, &scopedLogger)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create static config manager: %w", err)
|
||||
}
|
||||
|
||||
// create the dhcp client
|
||||
im.dhcpClient, err = NewDHCPClient(ctx, ifaceName, &scopedLogger, config.DHCPClient.String)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create DHCP client: %w", err)
|
||||
}
|
||||
|
||||
// Set up DHCP client callbacks
|
||||
im.dhcpClient.SetOnLeaseChange(func(lease *types.DHCPLease) {
|
||||
if im.config.IPv4Mode.String != "dhcp" {
|
||||
im.logger.Warn().Str("mode", im.config.IPv4Mode.String).Msg("ignoring DHCP lease, current mode is not DHCP")
|
||||
return
|
||||
}
|
||||
|
||||
if err := im.applyDHCPLease(lease); err != nil {
|
||||
im.logger.Error().Err(err).Msg("failed to apply DHCP lease")
|
||||
}
|
||||
im.updateStateFromDHCPLease(lease)
|
||||
if im.onDHCPLeaseChange != nil {
|
||||
im.onDHCPLeaseChange(lease)
|
||||
}
|
||||
})
|
||||
|
||||
return im, nil
|
||||
}
|
||||
|
||||
// Start starts managing the interface
|
||||
func (im *InterfaceManager) Start() error {
|
||||
im.stateMu.Lock()
|
||||
defer im.stateMu.Unlock()
|
||||
|
||||
im.logger.Info().Msg("starting interface manager")
|
||||
|
||||
// Start monitoring interface state
|
||||
im.wg.Add(1)
|
||||
go im.monitorInterfaceState()
|
||||
|
||||
nl := getNetlinkManager()
|
||||
|
||||
// Set the link state
|
||||
linkState, err := nl.GetLinkByName(im.ifaceName)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get interface: %w", err)
|
||||
}
|
||||
im.linkState = linkState
|
||||
|
||||
// Bring the interface up
|
||||
_, linkUpErr := nl.EnsureInterfaceUpWithTimeout(
|
||||
im.ctx,
|
||||
im.linkState,
|
||||
30*time.Second,
|
||||
)
|
||||
|
||||
// Set callback after the interface is up
|
||||
nl.AddStateChangeCallback(im.ifaceName, link.StateChangeCallback{
|
||||
Async: true,
|
||||
Func: func(link *link.Link) {
|
||||
im.handleLinkStateChange(link)
|
||||
},
|
||||
})
|
||||
|
||||
if linkUpErr != nil {
|
||||
im.logger.Error().Err(linkUpErr).Msg("failed to bring interface up, continuing anyway")
|
||||
} else {
|
||||
// Apply initial configuration
|
||||
if err := im.applyConfiguration(); err != nil {
|
||||
im.logger.Error().Err(err).Msg("failed to apply initial configuration")
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
im.logger.Info().Msg("interface manager started")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Stop stops managing the interface
|
||||
func (im *InterfaceManager) Stop() error {
|
||||
im.logger.Info().Msg("stopping interface manager")
|
||||
|
||||
close(im.stopCh)
|
||||
im.wg.Wait()
|
||||
|
||||
// Stop DHCP client
|
||||
if im.dhcpClient != nil {
|
||||
if err := im.dhcpClient.Stop(); err != nil {
|
||||
return fmt.Errorf("failed to stop DHCP client: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
im.logger.Info().Msg("interface manager stopped")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (im *InterfaceManager) link() (*link.Link, error) {
|
||||
nl := getNetlinkManager()
|
||||
if nl == nil {
|
||||
return nil, fmt.Errorf("netlink manager not initialized")
|
||||
}
|
||||
return nl.GetLinkByName(im.ifaceName)
|
||||
}
|
||||
|
||||
// IsUp returns true if the interface is up
|
||||
func (im *InterfaceManager) IsUp() bool {
|
||||
im.stateMu.RLock()
|
||||
defer im.stateMu.RUnlock()
|
||||
|
||||
if im.state == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
return im.state.Up
|
||||
}
|
||||
|
||||
// IsOnline returns true if the interface is online
|
||||
func (im *InterfaceManager) IsOnline() bool {
|
||||
im.stateMu.RLock()
|
||||
defer im.stateMu.RUnlock()
|
||||
|
||||
if im.state == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
return im.state.Online
|
||||
}
|
||||
|
||||
// IPv4Ready returns true if the interface has an IPv4 address
|
||||
func (im *InterfaceManager) IPv4Ready() bool {
|
||||
im.stateMu.RLock()
|
||||
defer im.stateMu.RUnlock()
|
||||
|
||||
if im.state == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
return im.state.IPv4Ready
|
||||
}
|
||||
|
||||
// IPv6Ready returns true if the interface has an IPv6 address
|
||||
func (im *InterfaceManager) IPv6Ready() bool {
|
||||
im.stateMu.RLock()
|
||||
defer im.stateMu.RUnlock()
|
||||
|
||||
if im.state == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
return im.state.IPv6Ready
|
||||
}
|
||||
|
||||
// GetIPv4Addresses returns the IPv4 addresses of the interface
|
||||
func (im *InterfaceManager) GetIPv4Addresses() []string {
|
||||
im.stateMu.RLock()
|
||||
defer im.stateMu.RUnlock()
|
||||
|
||||
if im.state == nil {
|
||||
return []string{}
|
||||
}
|
||||
|
||||
return im.state.IPv4Addresses
|
||||
}
|
||||
|
||||
// GetIPv4Address returns the IPv4 address of the interface
|
||||
func (im *InterfaceManager) GetIPv4Address() string {
|
||||
im.stateMu.RLock()
|
||||
defer im.stateMu.RUnlock()
|
||||
|
||||
if im.state == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
return im.state.IPv4Address
|
||||
}
|
||||
|
||||
// GetIPv6Address returns the IPv6 address of the interface
|
||||
func (im *InterfaceManager) GetIPv6Address() string {
|
||||
im.stateMu.RLock()
|
||||
defer im.stateMu.RUnlock()
|
||||
|
||||
if im.state == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
return im.state.IPv6Address
|
||||
}
|
||||
|
||||
// GetIPv6Addresses returns the IPv6 addresses of the interface
|
||||
func (im *InterfaceManager) GetIPv6Addresses() []string {
|
||||
im.stateMu.RLock()
|
||||
defer im.stateMu.RUnlock()
|
||||
|
||||
addresses := []string{}
|
||||
|
||||
if im.state == nil {
|
||||
return addresses
|
||||
}
|
||||
|
||||
for _, addr := range im.state.IPv6Addresses {
|
||||
addresses = append(addresses, addr.Address.String())
|
||||
}
|
||||
|
||||
return addresses
|
||||
}
|
||||
|
||||
// GetMACAddress returns the MAC address of the interface
|
||||
func (im *InterfaceManager) GetMACAddress() string {
|
||||
im.stateMu.RLock()
|
||||
defer im.stateMu.RUnlock()
|
||||
|
||||
if im.state == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
return im.state.MACAddress
|
||||
}
|
||||
|
||||
// GetState returns the current interface state
|
||||
func (im *InterfaceManager) GetState() *types.InterfaceState {
|
||||
im.stateMu.RLock()
|
||||
defer im.stateMu.RUnlock()
|
||||
|
||||
// Return a copy to avoid race conditions
|
||||
im.logger.Debug().Interface("state", im.state).Msg("getting interface state")
|
||||
|
||||
state := *im.state
|
||||
return &state
|
||||
}
|
||||
|
||||
// NTPServers returns the NTP servers of the interface
|
||||
func (im *InterfaceManager) NTPServers() []net.IP {
|
||||
im.stateMu.RLock()
|
||||
defer im.stateMu.RUnlock()
|
||||
|
||||
if im.state == nil {
|
||||
return []net.IP{}
|
||||
}
|
||||
|
||||
return im.state.NTPServers
|
||||
}
|
||||
|
||||
func (im *InterfaceManager) Domain() string {
|
||||
im.stateMu.RLock()
|
||||
defer im.stateMu.RUnlock()
|
||||
|
||||
if im.state == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
if im.state.DHCPLease4 != nil {
|
||||
return im.state.DHCPLease4.Domain
|
||||
}
|
||||
|
||||
if im.state.DHCPLease6 != nil {
|
||||
return im.state.DHCPLease6.Domain
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// GetConfig returns the current interface configuration
|
||||
func (im *InterfaceManager) GetConfig() *types.NetworkConfig {
|
||||
// Return a copy to avoid race conditions
|
||||
config := *im.config
|
||||
return &config
|
||||
}
|
||||
|
||||
// ApplyConfiguration applies the current configuration to the interface
|
||||
func (im *InterfaceManager) ApplyConfiguration() error {
|
||||
return im.applyConfiguration()
|
||||
}
|
||||
|
||||
// SetConfig updates the interface configuration
|
||||
func (im *InterfaceManager) SetConfig(config *types.NetworkConfig) error {
|
||||
if config == nil {
|
||||
return fmt.Errorf("config cannot be nil")
|
||||
}
|
||||
|
||||
// Validate and set defaults
|
||||
if err := confparser.SetDefaultsAndValidate(config); err != nil {
|
||||
return fmt.Errorf("invalid config: %w", err)
|
||||
}
|
||||
|
||||
im.config = config
|
||||
|
||||
// Apply the new configuration
|
||||
if err := im.applyConfiguration(); err != nil {
|
||||
im.logger.Error().Err(err).Msg("failed to apply new configuration")
|
||||
return err
|
||||
}
|
||||
|
||||
// Notify callback
|
||||
if im.onConfigChange != nil {
|
||||
im.onConfigChange(config)
|
||||
}
|
||||
|
||||
im.logger.Info().Msg("configuration updated")
|
||||
return nil
|
||||
}
|
||||
|
||||
// RenewDHCPLease renews the DHCP lease
|
||||
func (im *InterfaceManager) RenewDHCPLease() error {
|
||||
if im.dhcpClient == nil {
|
||||
return fmt.Errorf("DHCP client not available")
|
||||
}
|
||||
|
||||
return im.dhcpClient.Renew()
|
||||
}
|
||||
|
||||
// SetOnStateChange sets the callback for state changes
|
||||
func (im *InterfaceManager) SetOnStateChange(callback func(state types.InterfaceState)) {
|
||||
im.onStateChange = callback
|
||||
}
|
||||
|
||||
// SetOnConfigChange sets the callback for configuration changes
|
||||
func (im *InterfaceManager) SetOnConfigChange(callback func(config *types.NetworkConfig)) {
|
||||
im.onConfigChange = callback
|
||||
}
|
||||
|
||||
// SetOnDHCPLeaseChange sets the callback for DHCP lease changes
|
||||
func (im *InterfaceManager) SetOnDHCPLeaseChange(callback func(lease *types.DHCPLease)) {
|
||||
im.onDHCPLeaseChange = callback
|
||||
}
|
||||
|
||||
// SetOnResolvConfChange sets the callback for resolv.conf changes
|
||||
func (im *InterfaceManager) SetOnResolvConfChange(callback ResolvConfChangeCallback) {
|
||||
im.onResolvConfChange = callback
|
||||
}
|
||||
|
||||
// applyConfiguration applies the current configuration to the interface
|
||||
func (im *InterfaceManager) applyConfiguration() error {
|
||||
im.logger.Info().Msg("applying configuration")
|
||||
|
||||
// Apply IPv4 configuration
|
||||
if err := im.applyIPv4Config(); err != nil {
|
||||
return fmt.Errorf("failed to apply IPv4 config: %w", err)
|
||||
}
|
||||
|
||||
// Apply IPv6 configuration
|
||||
if err := im.applyIPv6Config(); err != nil {
|
||||
return fmt.Errorf("failed to apply IPv6 config: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// applyIPv4Config applies IPv4 configuration
|
||||
func (im *InterfaceManager) applyIPv4Config() error {
|
||||
mode := im.config.IPv4Mode.String
|
||||
im.logger.Info().Str("mode", mode).Msg("applying IPv4 configuration")
|
||||
|
||||
switch mode {
|
||||
case "static":
|
||||
return im.applyIPv4Static()
|
||||
case "dhcp":
|
||||
return im.applyIPv4DHCP()
|
||||
case "disabled":
|
||||
return im.disableIPv4()
|
||||
default:
|
||||
return fmt.Errorf("invalid IPv4 mode: %s", mode)
|
||||
}
|
||||
}
|
||||
|
||||
// applyIPv6Config applies IPv6 configuration
|
||||
func (im *InterfaceManager) applyIPv6Config() error {
|
||||
mode := im.config.IPv6Mode.String
|
||||
im.logger.Info().Str("mode", mode).Msg("applying IPv6 configuration")
|
||||
|
||||
switch mode {
|
||||
case "static":
|
||||
return im.applyIPv6Static()
|
||||
case "dhcpv6":
|
||||
return im.applyIPv6DHCP()
|
||||
case "slaac":
|
||||
return im.applyIPv6SLAAC()
|
||||
case "slaac_and_dhcpv6":
|
||||
return im.applyIPv6SLAACAndDHCP()
|
||||
case "link_local":
|
||||
return im.applyIPv6LinkLocal()
|
||||
case "disabled":
|
||||
return im.disableIPv6()
|
||||
default:
|
||||
return fmt.Errorf("invalid IPv6 mode: %s", mode)
|
||||
}
|
||||
}
|
||||
|
||||
// applyIPv4Static applies static IPv4 configuration
|
||||
func (im *InterfaceManager) applyIPv4Static() error {
|
||||
if im.config.IPv4Static == nil {
|
||||
return fmt.Errorf("IPv4 static configuration is nil")
|
||||
}
|
||||
|
||||
im.logger.Info().Msg("stopping DHCP")
|
||||
|
||||
// Disable DHCP
|
||||
if im.dhcpClient != nil {
|
||||
im.dhcpClient.SetIPv4(false)
|
||||
}
|
||||
|
||||
im.logger.Info().Interface("config", im.config.IPv4Static).Msg("applying IPv4 static configuration")
|
||||
|
||||
config, err := im.staticConfig.ToIPv4Static(im.config.IPv4Static)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to convert IPv4 static configuration: %w", err)
|
||||
}
|
||||
|
||||
im.logger.Info().Interface("config", config).Msg("converted IPv4 static configuration")
|
||||
|
||||
if err := im.onResolvConfChange(link.AfInet, &types.InterfaceResolvConf{
|
||||
NameServers: config.Nameservers,
|
||||
Source: "static",
|
||||
}); err != nil {
|
||||
im.logger.Warn().Err(err).Msg("failed to update resolv.conf")
|
||||
}
|
||||
|
||||
return im.ReconcileLinkAddrs(config.Addresses, link.AfInet)
|
||||
}
|
||||
|
||||
// applyIPv4DHCP applies DHCP IPv4 configuration
|
||||
func (im *InterfaceManager) applyIPv4DHCP() error {
|
||||
if im.dhcpClient == nil {
|
||||
return fmt.Errorf("DHCP client not available")
|
||||
}
|
||||
|
||||
// Enable DHCP
|
||||
im.dhcpClient.SetIPv4(true)
|
||||
return im.dhcpClient.Start()
|
||||
}
|
||||
|
||||
// disableIPv4 disables IPv4
|
||||
func (im *InterfaceManager) disableIPv4() error {
|
||||
// Disable DHCP
|
||||
if im.dhcpClient != nil {
|
||||
im.dhcpClient.SetIPv4(false)
|
||||
}
|
||||
|
||||
// Remove all IPv4 addresses
|
||||
return im.staticConfig.DisableIPv4()
|
||||
}
|
||||
|
||||
// applyIPv6Static applies static IPv6 configuration
|
||||
func (im *InterfaceManager) applyIPv6Static() error {
|
||||
if im.config.IPv6Static == nil {
|
||||
return fmt.Errorf("IPv6 static configuration is nil")
|
||||
}
|
||||
|
||||
im.logger.Info().Msg("stopping DHCPv6")
|
||||
// Disable DHCPv6
|
||||
if im.dhcpClient != nil {
|
||||
im.dhcpClient.SetIPv6(false)
|
||||
}
|
||||
|
||||
// Apply static configuration
|
||||
config, err := im.staticConfig.ToIPv6Static(im.config.IPv6Static)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to convert IPv6 static configuration: %w", err)
|
||||
}
|
||||
im.logger.Info().Interface("config", config).Msg("converted IPv6 static configuration")
|
||||
|
||||
if err := im.onResolvConfChange(link.AfInet6, &types.InterfaceResolvConf{
|
||||
NameServers: config.Nameservers,
|
||||
Source: "static",
|
||||
}); err != nil {
|
||||
im.logger.Warn().Err(err).Msg("failed to update resolv.conf")
|
||||
}
|
||||
|
||||
return im.ReconcileLinkAddrs(config.Addresses, link.AfInet6)
|
||||
}
|
||||
|
||||
// applyIPv6DHCP applies DHCPv6 configuration
|
||||
func (im *InterfaceManager) applyIPv6DHCP() error {
|
||||
if im.dhcpClient == nil {
|
||||
return fmt.Errorf("DHCP client not available")
|
||||
}
|
||||
|
||||
// Enable DHCPv6
|
||||
im.dhcpClient.SetIPv6(true)
|
||||
return im.dhcpClient.Start()
|
||||
}
|
||||
|
||||
// applyIPv6SLAAC applies SLAAC configuration
|
||||
func (im *InterfaceManager) applyIPv6SLAAC() error {
|
||||
// Disable DHCPv6
|
||||
if im.dhcpClient != nil {
|
||||
im.dhcpClient.SetIPv6(false)
|
||||
}
|
||||
|
||||
// Remove static IPv6 configuration
|
||||
l, err := im.link()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get interface: %w", err)
|
||||
}
|
||||
|
||||
netlinkMgr := getNetlinkManager()
|
||||
|
||||
// Ensure interface is up
|
||||
if err := netlinkMgr.EnsureInterfaceUp(l); err != nil {
|
||||
return fmt.Errorf("failed to bring interface up: %w", err)
|
||||
}
|
||||
|
||||
if err := netlinkMgr.RemoveNonLinkLocalIPv6Addresses(l); err != nil {
|
||||
return fmt.Errorf("failed to remove non-link-local IPv6 addresses: %w", err)
|
||||
}
|
||||
|
||||
if err := im.SendRouterSolicitation(); err != nil {
|
||||
im.logger.Error().Err(err).Msg("failed to send router solicitation, continuing anyway")
|
||||
}
|
||||
|
||||
// Enable SLAAC
|
||||
return im.staticConfig.EnableIPv6SLAAC()
|
||||
}
|
||||
|
||||
// applyIPv6SLAACAndDHCP applies SLAAC + DHCPv6 configuration
|
||||
func (im *InterfaceManager) applyIPv6SLAACAndDHCP() error {
|
||||
// Enable both SLAAC and DHCPv6
|
||||
if im.dhcpClient != nil {
|
||||
im.dhcpClient.SetIPv6(true)
|
||||
if err := im.dhcpClient.Start(); err != nil {
|
||||
return fmt.Errorf("failed to start DHCP client: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return im.staticConfig.EnableIPv6SLAAC()
|
||||
}
|
||||
|
||||
// applyIPv6LinkLocal applies link-local only IPv6 configuration
|
||||
func (im *InterfaceManager) applyIPv6LinkLocal() error {
|
||||
// Disable DHCPv6
|
||||
if im.dhcpClient != nil {
|
||||
im.dhcpClient.SetIPv6(false)
|
||||
}
|
||||
|
||||
// Enable link-local only
|
||||
return im.staticConfig.EnableIPv6LinkLocal()
|
||||
}
|
||||
|
||||
// disableIPv6 disables IPv6
|
||||
func (im *InterfaceManager) disableIPv6() error {
|
||||
// Disable DHCPv6
|
||||
if im.dhcpClient != nil {
|
||||
im.dhcpClient.SetIPv6(false)
|
||||
}
|
||||
|
||||
// Disable IPv6
|
||||
return im.staticConfig.DisableIPv6()
|
||||
}
|
||||
|
||||
func (im *InterfaceManager) handleLinkStateChange(link *link.Link) {
|
||||
{
|
||||
im.stateMu.Lock()
|
||||
defer im.stateMu.Unlock()
|
||||
|
||||
if link.IsSame(im.linkState) {
|
||||
return
|
||||
}
|
||||
|
||||
im.linkState = link
|
||||
}
|
||||
|
||||
im.logger.Info().Interface("link", link).Msg("link state changed")
|
||||
|
||||
operState := link.Attrs().OperState
|
||||
if operState == netlink.OperUp {
|
||||
im.handleLinkUp()
|
||||
} else {
|
||||
im.handleLinkDown()
|
||||
}
|
||||
}
|
||||
|
||||
// SendRouterSolicitation sends a router solicitation
|
||||
func (im *InterfaceManager) SendRouterSolicitation() error {
|
||||
im.logger.Info().Msg("sending router solicitation")
|
||||
m := &ndp.RouterSolicitation{}
|
||||
|
||||
l, err := im.link()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get interface: %w", err)
|
||||
}
|
||||
|
||||
if l.Attrs().OperState != netlink.OperUp {
|
||||
return fmt.Errorf("interface %s is not up", im.ifaceName)
|
||||
}
|
||||
|
||||
iface := l.Interface()
|
||||
if iface == nil {
|
||||
return fmt.Errorf("failed to get net.Interface for %s", im.ifaceName)
|
||||
}
|
||||
|
||||
hwAddr := l.HardwareAddr()
|
||||
if hwAddr == nil {
|
||||
return fmt.Errorf("failed to get hardware address for %s", im.ifaceName)
|
||||
}
|
||||
|
||||
c, _, err := ndp.Listen(iface, ndp.LinkLocal)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create NDP listener on %s: %w", im.ifaceName, err)
|
||||
}
|
||||
|
||||
m.Options = append(m.Options, &ndp.LinkLayerAddress{
|
||||
Addr: hwAddr,
|
||||
Direction: ndp.Source,
|
||||
})
|
||||
|
||||
targetAddr := netip.MustParseAddr("ff02::2")
|
||||
|
||||
if err := c.WriteTo(m, nil, targetAddr); err != nil {
|
||||
c.Close()
|
||||
return fmt.Errorf("failed to write to %s: %w", targetAddr.String(), err)
|
||||
}
|
||||
|
||||
im.logger.Info().Msg("router solicitation sent")
|
||||
c.Close()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (im *InterfaceManager) handleLinkUp() {
|
||||
im.logger.Info().Msg("link up")
|
||||
|
||||
if err := im.applyConfiguration(); err != nil {
|
||||
im.logger.Error().Err(err).Msg("failed to apply configuration")
|
||||
}
|
||||
|
||||
if im.config.IPv4Mode.String == "dhcp" {
|
||||
if err := im.dhcpClient.Renew(); err != nil {
|
||||
im.logger.Error().Err(err).Msg("failed to renew DHCP lease")
|
||||
}
|
||||
}
|
||||
|
||||
if im.config.IPv6Mode.String == "slaac" {
|
||||
if err := im.staticConfig.EnableIPv6SLAAC(); err != nil {
|
||||
im.logger.Error().Err(err).Msg("failed to enable IPv6 SLAAC")
|
||||
}
|
||||
if err := im.SendRouterSolicitation(); err != nil {
|
||||
im.logger.Error().Err(err).Msg("failed to send router solicitation")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (im *InterfaceManager) handleLinkDown() {
|
||||
im.logger.Info().Msg("link down")
|
||||
|
||||
if im.config.IPv4Mode.String == "dhcp" {
|
||||
if err := im.dhcpClient.Stop(); err != nil {
|
||||
im.logger.Error().Err(err).Msg("failed to stop DHCP client")
|
||||
}
|
||||
}
|
||||
|
||||
netlinkMgr := getNetlinkManager()
|
||||
if err := netlinkMgr.RemoveAllAddresses(im.linkState, link.AfInet); err != nil {
|
||||
im.logger.Error().Err(err).Msg("failed to remove all IPv4 addresses")
|
||||
}
|
||||
|
||||
if err := netlinkMgr.RemoveNonLinkLocalIPv6Addresses(im.linkState); err != nil {
|
||||
im.logger.Error().Err(err).Msg("failed to remove non-link-local IPv6 addresses")
|
||||
}
|
||||
}
|
||||
|
||||
// monitorInterfaceState monitors the interface state and updates accordingly
|
||||
func (im *InterfaceManager) monitorInterfaceState() {
|
||||
defer im.wg.Done()
|
||||
|
||||
im.logger.Debug().Msg("monitoring interface state")
|
||||
// TODO: use netlink subscription instead of polling
|
||||
ticker := time.NewTicker(5 * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-im.ctx.Done():
|
||||
return
|
||||
case <-im.stopCh:
|
||||
return
|
||||
case <-ticker.C:
|
||||
if err := im.updateInterfaceState(); err != nil {
|
||||
im.logger.Error().Err(err).Msg("failed to update interface state")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// updateStateFromDHCPLease updates the state from a DHCP lease
|
||||
func (im *InterfaceManager) updateStateFromDHCPLease(lease *types.DHCPLease) {
|
||||
family := link.AfInet
|
||||
|
||||
im.stateMu.Lock()
|
||||
if lease.IsIPv6() {
|
||||
im.state.DHCPLease6 = lease
|
||||
family = link.AfInet6
|
||||
} else {
|
||||
im.state.DHCPLease4 = lease
|
||||
}
|
||||
im.stateMu.Unlock()
|
||||
|
||||
// Update resolv.conf with DNS information
|
||||
if im.onResolvConfChange == nil {
|
||||
return
|
||||
}
|
||||
|
||||
if im.ifaceName == "" {
|
||||
im.logger.Warn().Msg("interface name is empty, skipping resolv.conf update")
|
||||
return
|
||||
}
|
||||
|
||||
if err := im.onResolvConfChange(family, &types.InterfaceResolvConf{
|
||||
NameServers: lease.DNS,
|
||||
SearchList: lease.SearchList,
|
||||
Source: "dhcp",
|
||||
}); err != nil {
|
||||
im.logger.Warn().Err(err).Msg("failed to update resolv.conf")
|
||||
}
|
||||
}
|
||||
|
||||
// ReconcileLinkAddrs reconciles the link addresses
|
||||
func (im *InterfaceManager) ReconcileLinkAddrs(addrs []types.IPAddress, family int) error {
|
||||
nl := getNetlinkManager()
|
||||
link, err := im.link()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get interface: %w", err)
|
||||
}
|
||||
if link == nil {
|
||||
return fmt.Errorf("failed to get interface: %w", err)
|
||||
}
|
||||
return nl.ReconcileLink(link, addrs, family)
|
||||
}
|
||||
|
||||
// applyDHCPLease applies DHCP lease configuration using ReconcileLinkAddrs
|
||||
func (im *InterfaceManager) applyDHCPLease(lease *types.DHCPLease) error {
|
||||
if lease == nil {
|
||||
return fmt.Errorf("DHCP lease is nil")
|
||||
}
|
||||
|
||||
if lease.DHCPClient != "jetdhcpc" {
|
||||
im.logger.Warn().Str("dhcp_client", lease.DHCPClient).Msg("ignoring DHCP lease, not implemented yet")
|
||||
return nil
|
||||
}
|
||||
|
||||
if lease.IsIPv6() {
|
||||
im.logger.Warn().Msg("ignoring IPv6 DHCP lease, not implemented yet")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Convert DHCP lease to IPv4Config
|
||||
ipv4Config := im.convertDHCPLeaseToIPv4Config(lease)
|
||||
|
||||
// Apply the configuration using ReconcileLinkAddrs
|
||||
return im.ReconcileLinkAddrs([]types.IPAddress{*ipv4Config}, link.AfInet)
|
||||
}
|
||||
|
||||
// convertDHCPLeaseToIPv4Config converts a DHCP lease to IPv4Config
|
||||
func (im *InterfaceManager) convertDHCPLeaseToIPv4Config(lease *types.DHCPLease) *types.IPAddress {
|
||||
ipNet := lease.IPNet()
|
||||
if ipNet == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Create IPv4Address
|
||||
ipv4Addr := types.IPAddress{
|
||||
Address: *ipNet,
|
||||
Gateway: lease.Routers[0],
|
||||
Secondary: false,
|
||||
Permanent: false,
|
||||
}
|
||||
|
||||
im.logger.Trace().
|
||||
Interface("ipv4Addr", ipv4Addr).
|
||||
Interface("lease", lease).
|
||||
Msg("converted DHCP lease to IPv4Config")
|
||||
|
||||
// Create IPv4Config
|
||||
return &ipv4Addr
|
||||
}
|
||||
|
|
@ -0,0 +1,163 @@
|
|||
package nmlite
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/jetkvm/kvm/internal/network/types"
|
||||
"github.com/jetkvm/kvm/pkg/nmlite/link"
|
||||
"github.com/vishvananda/netlink"
|
||||
)
|
||||
|
||||
// updateInterfaceState updates the current interface state
|
||||
func (im *InterfaceManager) updateInterfaceState() error {
|
||||
nl, err := im.link()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get interface: %w", err)
|
||||
}
|
||||
|
||||
var stateChanged bool
|
||||
|
||||
attrs := nl.Attrs()
|
||||
|
||||
// We should release the lock before calling the callbacks
|
||||
// to avoid deadlocks
|
||||
im.stateMu.Lock()
|
||||
|
||||
// Check if the interface is up
|
||||
isUp := attrs.OperState == netlink.OperUp
|
||||
if im.state.Up != isUp {
|
||||
im.state.Up = isUp
|
||||
stateChanged = true
|
||||
}
|
||||
|
||||
// Check if the interface is online
|
||||
isOnline := isUp && nl.HasGlobalUnicastAddress()
|
||||
if im.state.Online != isOnline {
|
||||
im.state.Online = isOnline
|
||||
stateChanged = true
|
||||
}
|
||||
|
||||
// Check if the MAC address has changed
|
||||
if im.state.MACAddress != attrs.HardwareAddr.String() {
|
||||
im.state.MACAddress = attrs.HardwareAddr.String()
|
||||
stateChanged = true
|
||||
}
|
||||
|
||||
// Update IP addresses
|
||||
if ipChanged, err := im.updateInterfaceStateAddresses(nl); err != nil {
|
||||
im.logger.Error().Err(err).Msg("failed to update IP addresses")
|
||||
} else if ipChanged {
|
||||
stateChanged = true
|
||||
}
|
||||
|
||||
im.state.LastUpdated = time.Now()
|
||||
im.stateMu.Unlock()
|
||||
|
||||
// Notify callback if state changed
|
||||
if stateChanged && im.onStateChange != nil {
|
||||
im.logger.Debug().Interface("state", im.state).Msg("notifying state change")
|
||||
im.onStateChange(*im.state)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// updateIPAddresses updates the IP addresses in the state
|
||||
func (im *InterfaceManager) updateInterfaceStateAddresses(nl *link.Link) (bool, error) {
|
||||
mgr := getNetlinkManager()
|
||||
|
||||
addrs, err := nl.AddrList(link.AfUnspec)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("failed to get addresses: %w", err)
|
||||
}
|
||||
|
||||
var (
|
||||
ipv4Addresses []string
|
||||
ipv6Addresses []types.IPv6Address
|
||||
ipv4Addr, ipv6Addr string
|
||||
ipv6LinkLocal string
|
||||
ipv6Gateway string
|
||||
ipv4Ready, ipv6Ready = false, false
|
||||
stateChanged = false
|
||||
)
|
||||
|
||||
routes, _ := mgr.ListDefaultRoutes(link.AfInet6)
|
||||
if len(routes) > 0 {
|
||||
ipv6Gateway = routes[0].Gw.String()
|
||||
}
|
||||
|
||||
for _, addr := range addrs {
|
||||
if addr.IP.To4() != nil {
|
||||
// IPv4 address
|
||||
ipv4Addresses = append(ipv4Addresses, addr.IPNet.String())
|
||||
if ipv4Addr == "" {
|
||||
ipv4Addr = addr.IP.String()
|
||||
ipv4Ready = true
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// IPv6 address (if it's not an IPv4 address, it must be an IPv6 address)
|
||||
if addr.IP.IsLinkLocalUnicast() {
|
||||
ipv6LinkLocal = addr.IP.String()
|
||||
continue
|
||||
} else if !addr.IP.IsGlobalUnicast() {
|
||||
continue
|
||||
}
|
||||
|
||||
ipv6Addresses = append(ipv6Addresses, types.IPv6Address{
|
||||
Address: addr.IP,
|
||||
Prefix: *addr.IPNet,
|
||||
Scope: addr.Scope,
|
||||
Flags: addr.Flags,
|
||||
ValidLifetime: lifetimeToTime(addr.ValidLft),
|
||||
PreferredLifetime: lifetimeToTime(addr.PreferedLft),
|
||||
})
|
||||
if ipv6Addr == "" {
|
||||
ipv6Addr = addr.IP.String()
|
||||
ipv6Ready = true
|
||||
}
|
||||
}
|
||||
|
||||
if !sortAndCompareStringSlices(im.state.IPv4Addresses, ipv4Addresses) {
|
||||
im.state.IPv4Addresses = ipv4Addresses
|
||||
stateChanged = true
|
||||
}
|
||||
|
||||
if !sortAndCompareIPv6AddressSlices(im.state.IPv6Addresses, ipv6Addresses) {
|
||||
im.state.IPv6Addresses = ipv6Addresses
|
||||
stateChanged = true
|
||||
}
|
||||
|
||||
if im.state.IPv4Address != ipv4Addr {
|
||||
im.state.IPv4Address = ipv4Addr
|
||||
stateChanged = true
|
||||
}
|
||||
|
||||
if im.state.IPv6Address != ipv6Addr {
|
||||
im.state.IPv6Address = ipv6Addr
|
||||
stateChanged = true
|
||||
}
|
||||
if im.state.IPv6LinkLocal != ipv6LinkLocal {
|
||||
im.state.IPv6LinkLocal = ipv6LinkLocal
|
||||
stateChanged = true
|
||||
}
|
||||
|
||||
if im.state.IPv6Gateway != ipv6Gateway {
|
||||
im.state.IPv6Gateway = ipv6Gateway
|
||||
stateChanged = true
|
||||
}
|
||||
|
||||
if im.state.IPv4Ready != ipv4Ready {
|
||||
im.state.IPv4Ready = ipv4Ready
|
||||
stateChanged = true
|
||||
}
|
||||
|
||||
if im.state.IPv6Ready != ipv6Ready {
|
||||
im.state.IPv6Ready = ipv6Ready
|
||||
stateChanged = true
|
||||
}
|
||||
|
||||
return stateChanged, nil
|
||||
}
|
||||
|
|
@ -0,0 +1,407 @@
|
|||
package jetdhcpc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net"
|
||||
"slices"
|
||||
|
||||
"time"
|
||||
|
||||
"github.com/jetkvm/kvm/internal/sync"
|
||||
"github.com/jetkvm/kvm/pkg/nmlite/link"
|
||||
|
||||
"github.com/insomniacslk/dhcp/dhcpv4"
|
||||
"github.com/insomniacslk/dhcp/dhcpv6"
|
||||
"github.com/jetkvm/kvm/internal/network/types"
|
||||
"github.com/rs/zerolog"
|
||||
)
|
||||
|
||||
const (
|
||||
VendorIdentifier = "jetkvm"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrIPv6LinkTimeout = errors.New("timeout after waiting for a non-tentative IPv6 address")
|
||||
ErrIPv6RouteTimeout = errors.New("timeout after waiting for an IPv6 route")
|
||||
ErrInterfaceUpTimeout = errors.New("timeout after waiting for an interface to come up")
|
||||
ErrInterfaceUpCanceled = errors.New("context canceled while waiting for an interface to come up")
|
||||
)
|
||||
|
||||
type LeaseChangeHandler func(lease *types.DHCPLease)
|
||||
|
||||
// Config is a DHCP client configuration.
|
||||
type Config struct {
|
||||
LinkUpTimeout time.Duration
|
||||
|
||||
// Timeout is the timeout for one DHCP request attempt.
|
||||
Timeout time.Duration
|
||||
|
||||
// Retries is how many times to retry DHCP attempts.
|
||||
Retries int
|
||||
|
||||
// IPv4 is whether to request an IPv4 lease.
|
||||
IPv4 bool
|
||||
|
||||
// IPv6 is whether to request an IPv6 lease.
|
||||
IPv6 bool
|
||||
|
||||
// Modifiers4 allows modifications to the IPv4 DHCP request.
|
||||
Modifiers4 []dhcpv4.Modifier
|
||||
|
||||
// Modifiers6 allows modifications to the IPv6 DHCP request.
|
||||
Modifiers6 []dhcpv6.Modifier
|
||||
|
||||
// V6ServerAddr can be a unicast or broadcast destination for DHCPv6
|
||||
// messages.
|
||||
//
|
||||
// If not set, it will default to nclient6's default (all servers &
|
||||
// relay agents).
|
||||
V6ServerAddr *net.UDPAddr
|
||||
|
||||
// V6ClientPort is the port that is used to send and receive DHCPv6
|
||||
// messages.
|
||||
//
|
||||
// If not set, it will default to dhcpv6's default (546).
|
||||
V6ClientPort *int
|
||||
|
||||
// V4ServerAddr can be a unicast or broadcast destination for IPv4 DHCP
|
||||
// messages.
|
||||
//
|
||||
// If not set, it will default to nclient4's default (DHCP broadcast
|
||||
// address).
|
||||
V4ServerAddr *net.UDPAddr
|
||||
|
||||
// If true, add Client Identifier (61) option to the IPv4 request.
|
||||
V4ClientIdentifier bool
|
||||
|
||||
Hostname string
|
||||
|
||||
OnLease4Change LeaseChangeHandler
|
||||
OnLease6Change LeaseChangeHandler
|
||||
|
||||
UpdateResolvConf func([]string) error
|
||||
}
|
||||
|
||||
// Client is a DHCP client.
|
||||
type Client struct {
|
||||
types.DHCPClient
|
||||
|
||||
ifaces []string
|
||||
cfg Config
|
||||
l *zerolog.Logger
|
||||
|
||||
ctx context.Context
|
||||
|
||||
// TODO: support multiple interfaces
|
||||
currentLease4 *Lease
|
||||
currentLease6 *Lease
|
||||
|
||||
mu sync.Mutex
|
||||
cfgMu sync.Mutex
|
||||
|
||||
lease4Mu sync.Mutex
|
||||
lease6Mu sync.Mutex
|
||||
|
||||
timer4 *time.Timer
|
||||
timer6 *time.Timer
|
||||
stateDir string
|
||||
}
|
||||
|
||||
var (
|
||||
defaultTimerDuration = 1 * time.Second
|
||||
defaultLinkUpTimeout = 30 * time.Second
|
||||
maxRenewalAttemptDuration = 2 * time.Hour
|
||||
)
|
||||
|
||||
// NewClient creates a new DHCP client for the given interface.
|
||||
func NewClient(ctx context.Context, ifaces []string, c *Config, l *zerolog.Logger) (*Client, error) {
|
||||
timer4 := time.NewTimer(defaultTimerDuration)
|
||||
timer6 := time.NewTimer(defaultTimerDuration)
|
||||
|
||||
cfg := *c
|
||||
if cfg.LinkUpTimeout == 0 {
|
||||
cfg.LinkUpTimeout = defaultLinkUpTimeout
|
||||
}
|
||||
|
||||
if cfg.Timeout == 0 {
|
||||
cfg.Timeout = defaultLinkUpTimeout
|
||||
}
|
||||
|
||||
if cfg.Retries == 0 {
|
||||
cfg.Retries = 3
|
||||
}
|
||||
|
||||
return &Client{
|
||||
ctx: ctx,
|
||||
ifaces: ifaces,
|
||||
cfg: cfg,
|
||||
l: l,
|
||||
stateDir: "/run/jetkvm-dhcp",
|
||||
|
||||
currentLease4: nil,
|
||||
currentLease6: nil,
|
||||
|
||||
lease4Mu: sync.Mutex{},
|
||||
lease6Mu: sync.Mutex{},
|
||||
|
||||
mu: sync.Mutex{},
|
||||
cfgMu: sync.Mutex{},
|
||||
|
||||
timer4: timer4,
|
||||
timer6: timer6,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func resetTimer(t *time.Timer, l *zerolog.Logger) {
|
||||
l.Debug().Dur("delay", defaultTimerDuration).Msg("will retry later")
|
||||
t.Reset(defaultTimerDuration)
|
||||
}
|
||||
|
||||
func getRenewalTime(lease *Lease) time.Duration {
|
||||
if lease.RenewalTime <= 0 || lease.LeaseTime > maxRenewalAttemptDuration/2 {
|
||||
return maxRenewalAttemptDuration
|
||||
}
|
||||
|
||||
return lease.RenewalTime
|
||||
}
|
||||
|
||||
func (c *Client) requestLoop(t *time.Timer, family int, ifname string) {
|
||||
l := c.l.With().Str("interface", ifname).Int("family", family).Logger()
|
||||
for range t.C {
|
||||
l.Info().Msg("requesting lease")
|
||||
|
||||
if _, err := c.ensureInterfaceUp(ifname); err != nil {
|
||||
l.Error().Err(err).Msg("failed to ensure interface up")
|
||||
resetTimer(t, c.l)
|
||||
continue
|
||||
}
|
||||
|
||||
var (
|
||||
lease *Lease
|
||||
err error
|
||||
)
|
||||
switch family {
|
||||
case link.AfInet:
|
||||
lease, err = c.requestLease4(ifname)
|
||||
case link.AfInet6:
|
||||
lease, err = c.requestLease6(ifname)
|
||||
}
|
||||
if err != nil {
|
||||
l.Error().Err(err).Msg("failed to request lease")
|
||||
resetTimer(t, c.l)
|
||||
continue
|
||||
}
|
||||
|
||||
c.handleLeaseChange(lease)
|
||||
|
||||
nextRenewal := getRenewalTime(lease)
|
||||
|
||||
l.Info().
|
||||
Dur("nextRenewal", nextRenewal).
|
||||
Dur("leaseTime", lease.LeaseTime).
|
||||
Dur("rebindingTime", lease.RebindingTime).
|
||||
Msg("sleeping until next renewal")
|
||||
|
||||
t.Reset(nextRenewal)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) ensureInterfaceUp(ifname string) (*link.Link, error) {
|
||||
nlm := link.GetNetlinkManager()
|
||||
iface, err := nlm.GetLinkByName(ifname)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return nlm.EnsureInterfaceUpWithTimeout(c.ctx, iface, c.cfg.LinkUpTimeout)
|
||||
}
|
||||
|
||||
// Lease4 returns the current IPv4 lease
|
||||
func (c *Client) Lease4() *types.DHCPLease {
|
||||
c.lease4Mu.Lock()
|
||||
defer c.lease4Mu.Unlock()
|
||||
|
||||
if c.currentLease4 == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return c.currentLease4.ToDHCPLease()
|
||||
}
|
||||
|
||||
// Lease6 returns the current IPv6 lease
|
||||
func (c *Client) Lease6() *types.DHCPLease {
|
||||
c.lease6Mu.Lock()
|
||||
defer c.lease6Mu.Unlock()
|
||||
|
||||
if c.currentLease6 == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return c.currentLease6.ToDHCPLease()
|
||||
}
|
||||
|
||||
// Domain returns the current domain
|
||||
func (c *Client) Domain() string {
|
||||
c.lease4Mu.Lock()
|
||||
defer c.lease4Mu.Unlock()
|
||||
|
||||
if c.currentLease4 != nil {
|
||||
return c.currentLease4.Domain
|
||||
}
|
||||
|
||||
c.lease6Mu.Lock()
|
||||
defer c.lease6Mu.Unlock()
|
||||
|
||||
if c.currentLease6 != nil {
|
||||
return c.currentLease6.Domain
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// handleLeaseChange handles lease changes
|
||||
func (c *Client) handleLeaseChange(lease *Lease) {
|
||||
// do not use defer here, because we need to unlock the mutex before returning
|
||||
ipv4 := lease.p4 != nil
|
||||
|
||||
if ipv4 {
|
||||
c.lease4Mu.Lock()
|
||||
c.currentLease4 = lease
|
||||
c.lease4Mu.Unlock()
|
||||
} else {
|
||||
c.lease6Mu.Lock()
|
||||
c.currentLease6 = lease
|
||||
c.lease6Mu.Unlock()
|
||||
}
|
||||
|
||||
c.apply()
|
||||
|
||||
// TODO: handle lease expiration
|
||||
if c.cfg.OnLease4Change != nil && ipv4 {
|
||||
c.cfg.OnLease4Change(lease.ToDHCPLease())
|
||||
}
|
||||
|
||||
if c.cfg.OnLease6Change != nil && !ipv4 {
|
||||
c.cfg.OnLease6Change(lease.ToDHCPLease())
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) Renew() error {
|
||||
c.timer4.Reset(defaultTimerDuration)
|
||||
c.timer6.Reset(defaultTimerDuration)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) Release() error {
|
||||
// TODO: implement
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) SetIPv4(ipv4 bool) {
|
||||
c.cfgMu.Lock()
|
||||
defer c.cfgMu.Unlock()
|
||||
|
||||
currentIPv4 := c.cfg.IPv4
|
||||
c.cfg.IPv4 = ipv4
|
||||
|
||||
if currentIPv4 == ipv4 {
|
||||
return
|
||||
}
|
||||
|
||||
if !ipv4 {
|
||||
c.lease4Mu.Lock()
|
||||
c.currentLease4 = nil
|
||||
c.lease4Mu.Unlock()
|
||||
|
||||
c.timer4.Stop()
|
||||
}
|
||||
|
||||
c.timer4.Reset(defaultTimerDuration)
|
||||
}
|
||||
|
||||
func (c *Client) SetIPv6(ipv6 bool) {
|
||||
c.cfgMu.Lock()
|
||||
defer c.cfgMu.Unlock()
|
||||
|
||||
currentIPv6 := c.cfg.IPv6
|
||||
c.cfg.IPv6 = ipv6
|
||||
|
||||
if currentIPv6 == ipv6 {
|
||||
return
|
||||
}
|
||||
|
||||
if !ipv6 {
|
||||
c.lease6Mu.Lock()
|
||||
c.currentLease6 = nil
|
||||
c.lease6Mu.Unlock()
|
||||
|
||||
c.timer6.Stop()
|
||||
}
|
||||
|
||||
c.timer6.Reset(defaultTimerDuration)
|
||||
}
|
||||
|
||||
func (c *Client) Start() error {
|
||||
if err := c.killUdhcpc(); err != nil {
|
||||
c.l.Warn().Err(err).Msg("failed to kill udhcpc processes, continuing anyway")
|
||||
}
|
||||
|
||||
for _, iface := range c.ifaces {
|
||||
if c.cfg.IPv4 {
|
||||
go c.requestLoop(c.timer4, link.AfInet, iface)
|
||||
}
|
||||
if c.cfg.IPv6 {
|
||||
go c.requestLoop(c.timer6, link.AfInet6, iface)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) apply() {
|
||||
var (
|
||||
iface string
|
||||
nameservers []net.IP
|
||||
searchList []string
|
||||
domain string
|
||||
)
|
||||
|
||||
if c.currentLease4 != nil {
|
||||
iface = c.currentLease4.InterfaceName
|
||||
nameservers = c.currentLease4.DNS
|
||||
searchList = c.currentLease4.SearchList
|
||||
domain = c.currentLease4.Domain
|
||||
}
|
||||
|
||||
if c.currentLease6 != nil {
|
||||
iface = c.currentLease6.InterfaceName
|
||||
nameservers = append(nameservers, c.currentLease6.DNS...)
|
||||
searchList = append(searchList, c.currentLease6.SearchList...)
|
||||
domain = c.currentLease6.Domain
|
||||
}
|
||||
|
||||
// deduplicate searchList
|
||||
searchList = slices.Compact(searchList)
|
||||
|
||||
if c.cfg.UpdateResolvConf == nil {
|
||||
c.l.Warn().Msg("no UpdateResolvConf function set, skipping resolv.conf update")
|
||||
return
|
||||
}
|
||||
|
||||
c.l.Info().
|
||||
Str("interface", iface).
|
||||
Interface("nameservers", nameservers).
|
||||
Interface("searchList", searchList).
|
||||
Str("domain", domain).
|
||||
Msg("updating resolv.conf")
|
||||
|
||||
// Convert net.IP to string slice
|
||||
var nameserverStrings []string
|
||||
for _, ns := range nameservers {
|
||||
nameserverStrings = append(nameserverStrings, ns.String())
|
||||
}
|
||||
|
||||
if err := c.cfg.UpdateResolvConf(nameserverStrings); err != nil {
|
||||
c.l.Error().Err(err).Msg("failed to update resolv.conf")
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,85 @@
|
|||
package jetdhcpc
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/insomniacslk/dhcp/dhcpv4"
|
||||
"github.com/insomniacslk/dhcp/dhcpv4/nclient4"
|
||||
"github.com/vishvananda/netlink"
|
||||
)
|
||||
|
||||
func (c *Client) requestLease4(ifname string) (*Lease, error) {
|
||||
iface, err := netlink.LinkByName(ifname)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
l := c.l.With().Str("interface", ifname).Logger()
|
||||
|
||||
mods := []nclient4.ClientOpt{
|
||||
nclient4.WithTimeout(c.cfg.Timeout),
|
||||
nclient4.WithRetry(c.cfg.Retries),
|
||||
}
|
||||
mods = append(mods, c.getDHCP4Logger(ifname))
|
||||
if c.cfg.V4ServerAddr != nil {
|
||||
mods = append(mods, nclient4.WithServerAddr(c.cfg.V4ServerAddr))
|
||||
}
|
||||
|
||||
client, err := nclient4.New(ifname, mods...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
// Prepend modifiers with default options, so they can be overridden.
|
||||
reqmods := append(
|
||||
[]dhcpv4.Modifier{
|
||||
dhcpv4.WithOption(dhcpv4.OptClassIdentifier(VendorIdentifier)),
|
||||
dhcpv4.WithRequestedOptions(
|
||||
dhcpv4.OptionSubnetMask,
|
||||
dhcpv4.OptionInterfaceMTU,
|
||||
dhcpv4.OptionNTPServers,
|
||||
dhcpv4.OptionDomainName,
|
||||
dhcpv4.OptionDomainNameServer,
|
||||
dhcpv4.OptionDNSDomainSearchList,
|
||||
),
|
||||
},
|
||||
c.cfg.Modifiers4...)
|
||||
|
||||
if c.cfg.V4ClientIdentifier {
|
||||
// Client Id is hardware type + mac per RFC 2132 9.14.
|
||||
ident := []byte{0x01} // Type ethernet
|
||||
ident = append(ident, iface.Attrs().HardwareAddr...)
|
||||
reqmods = append(reqmods, dhcpv4.WithOption(dhcpv4.OptClientIdentifier(ident)))
|
||||
}
|
||||
|
||||
if c.cfg.Hostname != "" {
|
||||
reqmods = append(reqmods, dhcpv4.WithOption(dhcpv4.OptHostName(c.cfg.Hostname)))
|
||||
}
|
||||
|
||||
l.Info().Msg("attempting to get DHCPv4 lease")
|
||||
var (
|
||||
lease *nclient4.Lease
|
||||
reqErr error
|
||||
)
|
||||
if c.currentLease4 != nil {
|
||||
l.Info().Msg("current lease is not nil, renewing")
|
||||
lease, reqErr = client.Renew(c.ctx, c.currentLease4.p4, reqmods...)
|
||||
} else {
|
||||
l.Info().Msg("current lease is nil, requesting new lease")
|
||||
lease, reqErr = client.Request(c.ctx, reqmods...)
|
||||
}
|
||||
|
||||
if reqErr != nil {
|
||||
return nil, reqErr
|
||||
}
|
||||
|
||||
if lease == nil || lease.ACK == nil {
|
||||
return nil, fmt.Errorf("failed to acquire DHCPv4 lease")
|
||||
}
|
||||
|
||||
summaryStructured(lease.ACK, &l).Info().Msgf("DHCPv4 lease acquired: %s", lease.ACK.String())
|
||||
l.Trace().Interface("options", lease.ACK.Options.String()).Msg("DHCPv4 lease options")
|
||||
|
||||
return fromNclient4Lease(lease, ifname), nil
|
||||
}
|
||||
|
|
@ -0,0 +1,135 @@
|
|||
package jetdhcpc
|
||||
|
||||
import (
|
||||
"net"
|
||||
"time"
|
||||
|
||||
"github.com/insomniacslk/dhcp/dhcpv6"
|
||||
"github.com/insomniacslk/dhcp/dhcpv6/nclient6"
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/vishvananda/netlink"
|
||||
)
|
||||
|
||||
// isIPv6LinkReady returns true if the interface has a link-local address
|
||||
// which is not tentative.
|
||||
func isIPv6LinkReady(l netlink.Link, logger *zerolog.Logger) (bool, error) {
|
||||
addrs, err := netlink.AddrList(l, 10) // AF_INET6
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
for _, addr := range addrs {
|
||||
if addr.IP.IsLinkLocalUnicast() && (addr.Flags&0x40 == 0) { // IFA_F_TENTATIVE
|
||||
if addr.Flags&0x80 != 0 { // IFA_F_DADFAILED
|
||||
logger.Warn().Str("address", addr.IP.String()).Msg("DADFAILED for address, continuing anyhow")
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// isIPv6RouteReady returns true if serverAddr is reachable.
|
||||
func isIPv6RouteReady(serverAddr net.IP) waitForCondition {
|
||||
return func(l netlink.Link, logger *zerolog.Logger) (bool, error) {
|
||||
if serverAddr.IsMulticast() {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
routes, err := netlink.RouteList(l, 10) // AF_INET6
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
for _, route := range routes {
|
||||
if route.LinkIndex != l.Attrs().Index {
|
||||
continue
|
||||
}
|
||||
// Default route.
|
||||
if route.Dst == nil {
|
||||
return true, nil
|
||||
}
|
||||
if route.Dst.Contains(serverAddr) {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) requestLease6(ifname string) (*Lease, error) {
|
||||
l := c.l.With().Str("interface", ifname).Logger()
|
||||
|
||||
iface, err := netlink.LinkByName(ifname)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
clientPort := dhcpv6.DefaultClientPort
|
||||
if c.cfg.V6ClientPort != nil {
|
||||
clientPort = *c.cfg.V6ClientPort
|
||||
}
|
||||
|
||||
// For ipv6, we cannot bind to the port until Duplicate Address
|
||||
// Detection (DAD) is complete which is indicated by the link being no
|
||||
// longer marked as "tentative". This usually takes about a second.
|
||||
|
||||
// If the link is never going to be ready, don't wait forever.
|
||||
// (The user may not have configured a ctx with a timeout.)
|
||||
|
||||
linkUpTimeout := time.After(c.cfg.LinkUpTimeout)
|
||||
if err := c.waitFor(
|
||||
iface,
|
||||
linkUpTimeout,
|
||||
isIPv6LinkReady,
|
||||
ErrIPv6LinkTimeout,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// If user specified a non-multicast address, make sure it's routable before we start.
|
||||
if c.cfg.V6ServerAddr != nil {
|
||||
if err := c.waitFor(
|
||||
iface,
|
||||
linkUpTimeout,
|
||||
isIPv6RouteReady(c.cfg.V6ServerAddr.IP),
|
||||
ErrIPv6RouteTimeout,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
mods := []nclient6.ClientOpt{
|
||||
nclient6.WithTimeout(c.cfg.Timeout),
|
||||
nclient6.WithRetry(c.cfg.Retries),
|
||||
c.getDHCP6Logger(),
|
||||
}
|
||||
if c.cfg.V6ServerAddr != nil {
|
||||
mods = append(mods, nclient6.WithBroadcastAddr(c.cfg.V6ServerAddr))
|
||||
}
|
||||
|
||||
conn, err := nclient6.NewIPv6UDPConn(iface.Attrs().Name, clientPort)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
client, err := nclient6.NewWithConn(conn, iface.Attrs().HardwareAddr, mods...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
// Prepend modifiers with default options, so they can be overridden.
|
||||
reqmods := append(
|
||||
[]dhcpv6.Modifier{
|
||||
dhcpv6.WithNetboot,
|
||||
},
|
||||
c.cfg.Modifiers6...)
|
||||
|
||||
l.Info().Msg("attempting to get DHCPv6 lease")
|
||||
p, err := client.RapidSolicit(c.ctx, reqmods...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
l.Info().Msgf("DHCPv6 lease acquired: %s", p.Summary())
|
||||
return fromNclient6Lease(p, ifname), nil
|
||||
}
|
||||
|
|
@ -0,0 +1,313 @@
|
|||
package jetdhcpc
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/insomniacslk/dhcp/dhcpv4"
|
||||
"github.com/insomniacslk/dhcp/dhcpv4/nclient4"
|
||||
"github.com/insomniacslk/dhcp/dhcpv6"
|
||||
"github.com/jetkvm/kvm/internal/network/types"
|
||||
)
|
||||
|
||||
var (
|
||||
defaultLeaseTime = time.Duration(30 * time.Minute)
|
||||
defaultRenewalTime = time.Duration(15 * time.Minute)
|
||||
)
|
||||
|
||||
// Lease is a network configuration obtained by DHCP.
|
||||
type Lease struct {
|
||||
types.DHCPLease
|
||||
|
||||
p4 *nclient4.Lease
|
||||
p6 *dhcpv6.Message
|
||||
|
||||
isEmpty map[string]bool
|
||||
}
|
||||
|
||||
// ToDHCPLease converts a lease to a DHCP lease.
|
||||
func (l *Lease) ToDHCPLease() *types.DHCPLease {
|
||||
lease := &l.DHCPLease
|
||||
lease.DHCPClient = "jetdhcpc"
|
||||
return lease
|
||||
}
|
||||
|
||||
// fromNclient4Lease creates a lease from a nclient4.Lease.
|
||||
func fromNclient4Lease(l *nclient4.Lease, iface string) *Lease {
|
||||
lease := &Lease{}
|
||||
|
||||
lease.p4 = l
|
||||
|
||||
// only the fields that we need are set
|
||||
lease.Routers = l.ACK.Router()
|
||||
lease.IPAddress = l.ACK.YourIPAddr
|
||||
|
||||
lease.Netmask = net.IP(l.ACK.SubnetMask())
|
||||
lease.Broadcast = l.ACK.BroadcastAddress()
|
||||
|
||||
lease.NTPServers = l.ACK.NTPServers()
|
||||
|
||||
lease.HostName = l.ACK.HostName()
|
||||
lease.Domain = l.ACK.DomainName()
|
||||
|
||||
searchList := l.ACK.DomainSearch()
|
||||
if searchList != nil {
|
||||
lease.SearchList = searchList.Labels
|
||||
}
|
||||
|
||||
lease.DNS = l.ACK.DNS()
|
||||
|
||||
lease.ClassIdentifier = l.ACK.ClassIdentifier()
|
||||
lease.ServerID = l.ACK.ServerIdentifier().String()
|
||||
|
||||
mtu := l.ACK.Options.Get(dhcpv4.OptionInterfaceMTU)
|
||||
if mtu != nil {
|
||||
lease.MTU = int(binary.BigEndian.Uint16(mtu))
|
||||
}
|
||||
|
||||
lease.Message = l.ACK.Message()
|
||||
lease.LeaseTime = l.ACK.IPAddressLeaseTime(defaultLeaseTime)
|
||||
lease.RenewalTime = l.ACK.IPAddressRenewalTime(defaultRenewalTime)
|
||||
|
||||
lease.InterfaceName = iface
|
||||
|
||||
return lease
|
||||
}
|
||||
|
||||
// fromNclient6Lease creates a lease from a nclient6.Message.
|
||||
func fromNclient6Lease(l *dhcpv6.Message, iface string) *Lease {
|
||||
lease := &Lease{}
|
||||
|
||||
lease.p6 = l
|
||||
|
||||
iana := l.Options.OneIANA()
|
||||
if iana == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
address := iana.Options.OneAddress()
|
||||
if address == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
lease.IPAddress = address.IPv6Addr
|
||||
lease.Netmask = net.IP(net.CIDRMask(128, 128))
|
||||
lease.DNS = l.Options.DNS()
|
||||
// lease.LeaseTime = iana.Options.OnePreferredLifetime()
|
||||
// lease.RenewalTime = iana.Options.OneValidLifetime()
|
||||
// lease.RebindingTime = iana.Options.OneRebindingTime()
|
||||
|
||||
lease.InterfaceName = iface
|
||||
|
||||
return lease
|
||||
}
|
||||
|
||||
func (l *Lease) setIsEmpty(m map[string]bool) {
|
||||
l.isEmpty = m
|
||||
}
|
||||
|
||||
// IsEmpty returns true if the lease is empty for the given key.
|
||||
func (l *Lease) IsEmpty(key string) bool {
|
||||
return l.isEmpty[key]
|
||||
}
|
||||
|
||||
// ToJSON returns the lease as a JSON string.
|
||||
func (l *Lease) ToJSON() string {
|
||||
json, err := json.Marshal(l)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return string(json)
|
||||
}
|
||||
|
||||
// SetLeaseExpiry sets the lease expiry time.
|
||||
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 (l *Lease) Apply() error {
|
||||
if l.p4 != nil {
|
||||
return l.applyIPv4()
|
||||
}
|
||||
|
||||
if l.p6 != nil {
|
||||
return l.applyIPv6()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (l *Lease) applyIPv4() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (l *Lease) applyIPv6() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// UnmarshalDHCPCLease unmarshals a lease from a string.
|
||||
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
|
||||
}
|
||||
|
||||
// MarshalDHCPCLease marshals a lease to a string.
|
||||
func MarshalDHCPCLease(lease *Lease) (string, error) {
|
||||
leaseType := reflect.TypeOf(lease).Elem()
|
||||
leaseValue := reflect.ValueOf(lease).Elem()
|
||||
|
||||
leaseFile := ""
|
||||
|
||||
for i := 0; i < leaseType.NumField(); i++ {
|
||||
field := leaseValue.Field(i)
|
||||
key := leaseType.Field(i).Tag.Get("env")
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
outValue := ""
|
||||
|
||||
switch field.Interface().(type) {
|
||||
case string:
|
||||
outValue = field.String()
|
||||
case int:
|
||||
outValue = strconv.Itoa(int(field.Int()))
|
||||
case time.Duration:
|
||||
outValue = strconv.Itoa(int(field.Int()))
|
||||
case net.IP:
|
||||
outValue = field.String()
|
||||
case []net.IP:
|
||||
ips := field.Interface().([]net.IP)
|
||||
ipStrings := make([]string, len(ips))
|
||||
for i, ip := range ips {
|
||||
ipStrings[i] = ip.String()
|
||||
}
|
||||
outValue = strings.Join(ipStrings, " ")
|
||||
default:
|
||||
return "", fmt.Errorf("unsupported field `%s` type: %s", key, field.Type().String())
|
||||
}
|
||||
|
||||
leaseFile += fmt.Sprintf("%s=%s\n", key, outValue)
|
||||
}
|
||||
|
||||
return leaseFile, nil
|
||||
}
|
||||
|
|
@ -0,0 +1,102 @@
|
|||
package jetdhcpc
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
|
||||
"github.com/rs/zerolog"
|
||||
)
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// KillUdhcpC kills all udhcpc processes
|
||||
func KillUdhcpC(l *zerolog.Logger) 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 err
|
||||
}
|
||||
|
||||
matchedPids := make([]int, 0)
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
matchedPids = append(matchedPids, pid)
|
||||
}
|
||||
|
||||
if len(matchedPids) == 0 {
|
||||
l.Info().Msg("no udhcpc processes found")
|
||||
return nil
|
||||
}
|
||||
|
||||
l.Info().Ints("pids", matchedPids).Msg("found udhcpc processes, terminating")
|
||||
|
||||
for _, pid := range matchedPids {
|
||||
err := syscall.Kill(pid, syscall.SIGTERM)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
l.Info().Int("pid", pid).Msg("terminated udhcpc process")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) killUdhcpc() error {
|
||||
return KillUdhcpC(c.l)
|
||||
}
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
package jetdhcpc
|
||||
|
||||
import (
|
||||
"github.com/insomniacslk/dhcp/dhcpv4"
|
||||
"github.com/insomniacslk/dhcp/dhcpv4/nclient4"
|
||||
"github.com/insomniacslk/dhcp/dhcpv6/nclient6"
|
||||
"github.com/rs/zerolog"
|
||||
)
|
||||
|
||||
type dhcpLogger struct {
|
||||
// Printfer is used for actual output of the logger
|
||||
nclient4.Printfer
|
||||
|
||||
l *zerolog.Logger
|
||||
}
|
||||
|
||||
// Printf prints a log message as-is via predefined Printfer
|
||||
func (s dhcpLogger) Printf(format string, v ...interface{}) {
|
||||
s.l.Info().Msgf(format, v...)
|
||||
}
|
||||
|
||||
// PrintMessage prints a DHCP message in the short format via predefined Printfer
|
||||
func (s dhcpLogger) PrintMessage(prefix string, message *dhcpv4.DHCPv4) {
|
||||
s.l.Info().Msgf("%s: %s", prefix, message.String())
|
||||
}
|
||||
|
||||
func summaryStructured(d *dhcpv4.DHCPv4, l *zerolog.Logger) *zerolog.Logger {
|
||||
logger := l.With().
|
||||
Str("opCode", d.OpCode.String()).
|
||||
Str("hwType", d.HWType.String()).
|
||||
Int("hopCount", int(d.HopCount)).
|
||||
Str("transactionID", d.TransactionID.String()).
|
||||
Int("numSeconds", int(d.NumSeconds)).
|
||||
Str("flagsString", d.FlagsToString()).
|
||||
Int("flags", int(d.Flags)).
|
||||
Str("clientIP", d.ClientIPAddr.String()).
|
||||
Str("yourIP", d.YourIPAddr.String()).
|
||||
Str("serverIP", d.ServerIPAddr.String()).
|
||||
Str("gatewayIP", d.GatewayIPAddr.String()).
|
||||
Str("clientMAC", d.ClientHWAddr.String()).
|
||||
Str("serverHostname", d.ServerHostName).
|
||||
Str("bootFileName", d.BootFileName).
|
||||
Str("options", d.Options.Summary(nil)).
|
||||
Logger()
|
||||
return &logger
|
||||
}
|
||||
|
||||
func (c *Client) getDHCP4Logger(ifname string) nclient4.ClientOpt {
|
||||
logger := c.l.With().
|
||||
Str("interface", ifname).
|
||||
Str("source", "dhcp4").
|
||||
Logger()
|
||||
|
||||
return nclient4.WithLogger(dhcpLogger{
|
||||
l: &logger,
|
||||
})
|
||||
}
|
||||
|
||||
// TODO: nclient6 doesn't implement the WithLogger option,
|
||||
// we might need to open a PR to add it
|
||||
|
||||
func (c *Client) getDHCP6Logger() nclient6.ClientOpt {
|
||||
return nclient6.WithSummaryLogger()
|
||||
}
|
||||
|
|
@ -0,0 +1,247 @@
|
|||
package jetdhcpc
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/jetkvm/kvm/internal/network/types"
|
||||
)
|
||||
|
||||
const (
|
||||
// DefaultStateDir is the default state directory
|
||||
DefaultStateDir = "/var/run/"
|
||||
// DHCPStateFile is the name of the DHCP state file
|
||||
DHCPStateFile = "jetkvm_dhcp_state.json"
|
||||
)
|
||||
|
||||
// DHCPState represents the persistent state of DHCP clients
|
||||
type DHCPState struct {
|
||||
InterfaceStates map[string]*InterfaceDHCPState `json:"interface_states"`
|
||||
LastUpdated time.Time `json:"last_updated"`
|
||||
Version string `json:"version"`
|
||||
}
|
||||
|
||||
// InterfaceDHCPState represents the DHCP state for a specific interface
|
||||
type InterfaceDHCPState struct {
|
||||
InterfaceName string `json:"interface_name"`
|
||||
IPv4Enabled bool `json:"ipv4_enabled"`
|
||||
IPv6Enabled bool `json:"ipv6_enabled"`
|
||||
IPv4Lease *Lease `json:"ipv4_lease,omitempty"`
|
||||
IPv6Lease *Lease `json:"ipv6_lease,omitempty"`
|
||||
LastRenewal time.Time `json:"last_renewal"`
|
||||
Config *types.NetworkConfig `json:"config,omitempty"`
|
||||
}
|
||||
|
||||
// SaveState saves the current DHCP state to disk
|
||||
func (c *Client) SaveState(state *DHCPState) error {
|
||||
if state == nil {
|
||||
return fmt.Errorf("state cannot be nil")
|
||||
}
|
||||
|
||||
// Return error if state directory doesn't exist
|
||||
if _, err := os.Stat(c.stateDir); os.IsNotExist(err) {
|
||||
return fmt.Errorf("state directory does not exist: %w", err)
|
||||
}
|
||||
|
||||
// Update timestamp
|
||||
state.LastUpdated = time.Now()
|
||||
state.Version = "1.0"
|
||||
|
||||
// Serialize state
|
||||
data, err := json.MarshalIndent(state, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal state: %w", err)
|
||||
}
|
||||
|
||||
// Write to temporary file first, then rename to ensure atomic operation
|
||||
tmpFile, err := os.CreateTemp(c.stateDir, DHCPStateFile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create temporary file: %w", err)
|
||||
}
|
||||
defer tmpFile.Close()
|
||||
|
||||
if err := os.WriteFile(tmpFile.Name(), data, 0644); err != nil {
|
||||
return fmt.Errorf("failed to write state file: %w", err)
|
||||
}
|
||||
|
||||
stateFile := filepath.Join(c.stateDir, DHCPStateFile)
|
||||
if err := os.Rename(tmpFile.Name(), stateFile); err != nil {
|
||||
os.Remove(tmpFile.Name())
|
||||
return fmt.Errorf("failed to rename state file: %w", err)
|
||||
}
|
||||
|
||||
c.l.Debug().Str("file", stateFile).Msg("DHCP state saved")
|
||||
return nil
|
||||
}
|
||||
|
||||
// LoadState loads the DHCP state from disk
|
||||
func (c *Client) LoadState() (*DHCPState, error) {
|
||||
stateFile := filepath.Join(c.stateDir, DHCPStateFile)
|
||||
|
||||
// Check if state file exists
|
||||
if _, err := os.Stat(stateFile); os.IsNotExist(err) {
|
||||
c.l.Debug().Msg("No existing DHCP state file found")
|
||||
return &DHCPState{
|
||||
InterfaceStates: make(map[string]*InterfaceDHCPState),
|
||||
LastUpdated: time.Now(),
|
||||
Version: "1.0",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Read state file
|
||||
data, err := os.ReadFile(stateFile)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read state file: %w", err)
|
||||
}
|
||||
|
||||
// Deserialize state
|
||||
var state DHCPState
|
||||
if err := json.Unmarshal(data, &state); err != nil {
|
||||
return nil, fmt.Errorf("failed to unmarshal state: %w", err)
|
||||
}
|
||||
|
||||
// Initialize interface states map if nil
|
||||
if state.InterfaceStates == nil {
|
||||
state.InterfaceStates = make(map[string]*InterfaceDHCPState)
|
||||
}
|
||||
|
||||
c.l.Debug().Str("file", stateFile).Msg("DHCP state loaded")
|
||||
return &state, nil
|
||||
}
|
||||
|
||||
// UpdateInterfaceState updates the state for a specific interface
|
||||
func (c *Client) UpdateInterfaceState(ifaceName string, state *InterfaceDHCPState) error {
|
||||
// Load current state
|
||||
currentState, err := c.LoadState()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to load current state: %w", err)
|
||||
}
|
||||
|
||||
// Update interface state
|
||||
currentState.InterfaceStates[ifaceName] = state
|
||||
|
||||
// Save updated state
|
||||
return c.SaveState(currentState)
|
||||
}
|
||||
|
||||
// GetInterfaceState gets the state for a specific interface
|
||||
func (c *Client) GetInterfaceState(ifaceName string) (*InterfaceDHCPState, error) {
|
||||
state, err := c.LoadState()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to load state: %w", err)
|
||||
}
|
||||
|
||||
return state.InterfaceStates[ifaceName], nil
|
||||
}
|
||||
|
||||
// RemoveInterfaceState removes the state for a specific interface
|
||||
func (c *Client) RemoveInterfaceState(ifaceName string) error {
|
||||
// Load current state
|
||||
currentState, err := c.LoadState()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to load current state: %w", err)
|
||||
}
|
||||
|
||||
// Remove interface state
|
||||
delete(currentState.InterfaceStates, ifaceName)
|
||||
|
||||
// Save updated state
|
||||
return c.SaveState(currentState)
|
||||
}
|
||||
|
||||
// IsLeaseValid checks if a DHCP lease is still valid
|
||||
func (c *Client) IsLeaseValid(lease *Lease) bool {
|
||||
if lease == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check if lease has expired
|
||||
if lease.LeaseExpiry == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
return time.Now().Before(*lease.LeaseExpiry)
|
||||
}
|
||||
|
||||
// ShouldRenewLease checks if a lease should be renewed
|
||||
func (c *Client) ShouldRenewLease(lease *Lease) bool {
|
||||
if !c.IsLeaseValid(lease) {
|
||||
return false
|
||||
}
|
||||
|
||||
expiry := *lease.LeaseExpiry
|
||||
leaseTime := time.Now().Add(time.Duration(lease.LeaseTime) * time.Second)
|
||||
|
||||
// Renew if lease expires within 50% of its lifetime
|
||||
leaseDuration := expiry.Sub(leaseTime)
|
||||
renewalTime := leaseTime.Add(leaseDuration / 2)
|
||||
|
||||
return time.Now().After(renewalTime)
|
||||
}
|
||||
|
||||
// CleanupExpiredStates removes expired states from the state file
|
||||
func (c *Client) CleanupExpiredStates() error {
|
||||
state, err := c.LoadState()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to load state: %w", err)
|
||||
}
|
||||
|
||||
cleaned := false
|
||||
for ifaceName, ifaceState := range state.InterfaceStates {
|
||||
// Remove interface state if both leases are expired
|
||||
ipv4Valid := c.IsLeaseValid(ifaceState.IPv4Lease)
|
||||
ipv6Valid := c.IsLeaseValid(ifaceState.IPv6Lease)
|
||||
|
||||
if !ipv4Valid && !ipv6Valid {
|
||||
delete(state.InterfaceStates, ifaceName)
|
||||
cleaned = true
|
||||
c.l.Debug().Str("interface", ifaceName).Msg("Removed expired DHCP state")
|
||||
}
|
||||
}
|
||||
|
||||
if cleaned {
|
||||
return c.SaveState(state)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetStateSummary returns a summary of the current state
|
||||
func (c *Client) GetStateSummary() (map[string]interface{}, error) {
|
||||
state, err := c.LoadState()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to load state: %w", err)
|
||||
}
|
||||
|
||||
summary := map[string]interface{}{
|
||||
"last_updated": state.LastUpdated,
|
||||
"version": state.Version,
|
||||
"interface_count": len(state.InterfaceStates),
|
||||
"interfaces": make(map[string]interface{}),
|
||||
}
|
||||
|
||||
interfaces := summary["interfaces"].(map[string]interface{})
|
||||
for ifaceName, ifaceState := range state.InterfaceStates {
|
||||
interfaceInfo := map[string]interface{}{
|
||||
"ipv4_enabled": ifaceState.IPv4Enabled,
|
||||
"ipv6_enabled": ifaceState.IPv6Enabled,
|
||||
"last_renewal": ifaceState.LastRenewal,
|
||||
// "ipv4_lease_valid": c.IsLeaseValid(ifaceState.IPv4Lease.(*Lease)),
|
||||
// "ipv6_lease_valid": c.IsLeaseValid(ifaceState.IPv6Lease),
|
||||
}
|
||||
|
||||
if ifaceState.IPv4Lease != nil {
|
||||
interfaceInfo["ipv4_lease_expiry"] = ifaceState.IPv4Lease.LeaseExpiry
|
||||
}
|
||||
if ifaceState.IPv6Lease != nil {
|
||||
interfaceInfo["ipv6_lease_expiry"] = ifaceState.IPv6Lease.LeaseExpiry
|
||||
}
|
||||
|
||||
interfaces[ifaceName] = interfaceInfo
|
||||
}
|
||||
|
||||
return summary, nil
|
||||
}
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
package jetdhcpc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/vishvananda/netlink"
|
||||
)
|
||||
|
||||
type waitForCondition func(l netlink.Link, logger *zerolog.Logger) (ready bool, err error)
|
||||
|
||||
func (c *Client) waitFor(
|
||||
link netlink.Link,
|
||||
timeout <-chan time.Time,
|
||||
condition waitForCondition,
|
||||
timeoutError error,
|
||||
) error {
|
||||
return waitFor(c.ctx, link, c.l, timeout, condition, timeoutError)
|
||||
}
|
||||
|
||||
func waitFor(
|
||||
ctx context.Context,
|
||||
link netlink.Link,
|
||||
logger *zerolog.Logger,
|
||||
timeout <-chan time.Time,
|
||||
condition waitForCondition,
|
||||
timeoutError error,
|
||||
) error {
|
||||
for {
|
||||
if ready, err := condition(link, logger); err != nil {
|
||||
return err
|
||||
} else if ready {
|
||||
break
|
||||
}
|
||||
|
||||
select {
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
continue
|
||||
case <-timeout:
|
||||
return timeoutError
|
||||
case <-ctx.Done():
|
||||
return timeoutError
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
package link
|
||||
|
||||
const (
|
||||
// AfUnspec is the unspecified address family constant
|
||||
AfUnspec = 0
|
||||
// AfInet is the IPv4 address family constant
|
||||
AfInet = 2
|
||||
// AfInet6 is the IPv6 address family constant
|
||||
AfInet6 = 10
|
||||
|
||||
sysctlBase = "/proc/sys"
|
||||
sysctlFileMode = 0640
|
||||
)
|
||||
|
|
@ -0,0 +1,544 @@
|
|||
package link
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"time"
|
||||
|
||||
"github.com/jetkvm/kvm/internal/sync"
|
||||
|
||||
"github.com/jetkvm/kvm/internal/network/types"
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/vishvananda/netlink"
|
||||
)
|
||||
|
||||
// StateChangeHandler is the function type for link state callbacks
|
||||
type StateChangeHandler func(link *Link)
|
||||
|
||||
// StateChangeCallback is the struct for link state callbacks
|
||||
type StateChangeCallback struct {
|
||||
Async bool
|
||||
Func StateChangeHandler
|
||||
}
|
||||
|
||||
// NetlinkManager provides centralized netlink operations
|
||||
type NetlinkManager struct {
|
||||
logger *zerolog.Logger
|
||||
mu sync.RWMutex
|
||||
stateChangeCallbacks map[string][]StateChangeCallback
|
||||
}
|
||||
|
||||
func newNetlinkManager(logger *zerolog.Logger) *NetlinkManager {
|
||||
if logger == nil {
|
||||
logger = &zerolog.Logger{} // Default no-op logger
|
||||
}
|
||||
n := &NetlinkManager{
|
||||
logger: logger,
|
||||
stateChangeCallbacks: make(map[string][]StateChangeCallback),
|
||||
}
|
||||
n.monitorStateChange()
|
||||
return n
|
||||
}
|
||||
|
||||
// GetNetlinkManager returns the singleton NetlinkManager instance
|
||||
func GetNetlinkManager() *NetlinkManager {
|
||||
netlinkManagerOnce.Do(func() {
|
||||
netlinkManagerInstance = newNetlinkManager(nil)
|
||||
})
|
||||
return netlinkManagerInstance
|
||||
}
|
||||
|
||||
// InitializeNetlinkManager initializes the singleton NetlinkManager with a logger
|
||||
func InitializeNetlinkManager(logger *zerolog.Logger) *NetlinkManager {
|
||||
netlinkManagerOnce.Do(func() {
|
||||
netlinkManagerInstance = newNetlinkManager(logger)
|
||||
})
|
||||
return netlinkManagerInstance
|
||||
}
|
||||
|
||||
// AddStateChangeCallback adds a callback for link state changes
|
||||
func (nm *NetlinkManager) AddStateChangeCallback(ifname string, callback StateChangeCallback) {
|
||||
nm.mu.Lock()
|
||||
defer nm.mu.Unlock()
|
||||
|
||||
if _, ok := nm.stateChangeCallbacks[ifname]; !ok {
|
||||
nm.stateChangeCallbacks[ifname] = make([]StateChangeCallback, 0)
|
||||
}
|
||||
|
||||
nm.stateChangeCallbacks[ifname] = append(nm.stateChangeCallbacks[ifname], callback)
|
||||
}
|
||||
|
||||
// Interface operations
|
||||
func (nm *NetlinkManager) monitorStateChange() {
|
||||
updateCh := make(chan netlink.LinkUpdate)
|
||||
// we don't need to stop the subscription, as it will be closed when the program exits
|
||||
stopCh := make(chan struct{}) //nolint:unused
|
||||
if err := netlink.LinkSubscribe(updateCh, stopCh); err != nil {
|
||||
nm.logger.Error().Err(err).Msg("failed to subscribe to link state changes")
|
||||
}
|
||||
|
||||
nm.logger.Info().Msg("state change monitoring started")
|
||||
|
||||
go func() {
|
||||
for update := range updateCh {
|
||||
nm.runCallbacks(update)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (nm *NetlinkManager) runCallbacks(update netlink.LinkUpdate) {
|
||||
nm.mu.RLock()
|
||||
defer nm.mu.RUnlock()
|
||||
|
||||
ifname := update.Link.Attrs().Name
|
||||
callbacks, ok := nm.stateChangeCallbacks[ifname]
|
||||
|
||||
l := nm.logger.With().Str("interface", ifname).Logger()
|
||||
if !ok {
|
||||
l.Trace().Msg("no state change callbacks for interface")
|
||||
return
|
||||
}
|
||||
|
||||
for _, callback := range callbacks {
|
||||
l.Trace().
|
||||
Interface("callback", callback).
|
||||
Bool("async", callback.Async).
|
||||
Msg("calling callback")
|
||||
|
||||
if callback.Async {
|
||||
go callback.Func(&Link{Link: update.Link})
|
||||
} else {
|
||||
callback.Func(&Link{Link: update.Link})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// GetLinkByName gets a network link by name
|
||||
func (nm *NetlinkManager) GetLinkByName(name string) (*Link, error) {
|
||||
nm.mu.RLock()
|
||||
defer nm.mu.RUnlock()
|
||||
link, err := netlink.LinkByName(name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Link{Link: link}, nil
|
||||
}
|
||||
|
||||
// LinkSetUp brings a network interface up
|
||||
func (nm *NetlinkManager) LinkSetUp(link *Link) error {
|
||||
nm.mu.RLock()
|
||||
defer nm.mu.RUnlock()
|
||||
return netlink.LinkSetUp(link)
|
||||
}
|
||||
|
||||
// LinkSetDown brings a network interface down
|
||||
func (nm *NetlinkManager) LinkSetDown(link *Link) error {
|
||||
nm.mu.RLock()
|
||||
defer nm.mu.RUnlock()
|
||||
return netlink.LinkSetDown(link)
|
||||
}
|
||||
|
||||
// EnsureInterfaceUp ensures the interface is up
|
||||
func (nm *NetlinkManager) EnsureInterfaceUp(link *Link) error {
|
||||
if link.Attrs().OperState == netlink.OperUp {
|
||||
return nil
|
||||
}
|
||||
return nm.LinkSetUp(link)
|
||||
}
|
||||
|
||||
// EnsureInterfaceUpWithTimeout ensures the interface is up with timeout and retry logic
|
||||
func (nm *NetlinkManager) EnsureInterfaceUpWithTimeout(ctx context.Context, iface *Link, timeout time.Duration) (*Link, error) {
|
||||
ifname := iface.Attrs().Name
|
||||
|
||||
l := nm.logger.With().Str("interface", ifname).Logger()
|
||||
|
||||
linkUpTimeout := time.After(timeout)
|
||||
|
||||
var attempt int
|
||||
start := time.Now()
|
||||
|
||||
for {
|
||||
link, err := nm.GetLinkByName(ifname)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
state := link.Attrs().OperState
|
||||
|
||||
l = l.With().
|
||||
Int("attempt", attempt).
|
||||
Dur("duration", time.Since(start)).
|
||||
Str("state", state.String()).
|
||||
Logger()
|
||||
if state == netlink.OperUp || state == netlink.OperUnknown {
|
||||
if attempt > 0 {
|
||||
l.Info().Int("attempt", attempt-1).Msg("interface is up")
|
||||
}
|
||||
return link, nil
|
||||
}
|
||||
|
||||
l.Info().Msg("bringing up interface")
|
||||
|
||||
// bring up the interface
|
||||
if err = nm.LinkSetUp(link); err != nil {
|
||||
l.Error().Err(err).Msg("interface can't make it up")
|
||||
}
|
||||
|
||||
// refresh the link attributes
|
||||
if err = link.Refresh(); err != nil {
|
||||
l.Error().Err(err).Msg("failed to refresh link attributes")
|
||||
}
|
||||
|
||||
// check the state again
|
||||
state = link.Attrs().OperState
|
||||
l = l.With().Str("new_state", state.String()).Logger()
|
||||
if state == netlink.OperUp {
|
||||
l.Info().Msg("interface is up")
|
||||
return link, nil
|
||||
}
|
||||
l.Warn().Msg("interface is still down, retrying")
|
||||
|
||||
select {
|
||||
case <-time.After(500 * time.Millisecond):
|
||||
attempt++
|
||||
continue
|
||||
case <-ctx.Done():
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return nil, ErrInterfaceUpCanceled
|
||||
case <-linkUpTimeout:
|
||||
attempt++
|
||||
l.Error().
|
||||
Int("attempt", attempt).Msg("interface is still down after timeout")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return nil, ErrInterfaceUpTimeout
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Address operations
|
||||
|
||||
// AddrList gets all addresses for a link
|
||||
func (nm *NetlinkManager) AddrList(link *Link, family int) ([]netlink.Addr, error) {
|
||||
nm.mu.RLock()
|
||||
defer nm.mu.RUnlock()
|
||||
return netlink.AddrList(link, family)
|
||||
}
|
||||
|
||||
// AddrAdd adds an address to a link
|
||||
func (nm *NetlinkManager) AddrAdd(link *Link, addr *netlink.Addr) error {
|
||||
nm.mu.RLock()
|
||||
defer nm.mu.RUnlock()
|
||||
return netlink.AddrAdd(link, addr)
|
||||
}
|
||||
|
||||
// AddrDel removes an address from a link
|
||||
func (nm *NetlinkManager) AddrDel(link *Link, addr *netlink.Addr) error {
|
||||
nm.mu.RLock()
|
||||
defer nm.mu.RUnlock()
|
||||
return netlink.AddrDel(link, addr)
|
||||
}
|
||||
|
||||
// RemoveAllAddresses removes all addresses of a specific family from a link
|
||||
func (nm *NetlinkManager) RemoveAllAddresses(link *Link, family int) error {
|
||||
addrs, err := nm.AddrList(link, family)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get addresses: %w", err)
|
||||
}
|
||||
|
||||
for _, addr := range addrs {
|
||||
if err := nm.AddrDel(link, &addr); err != nil {
|
||||
nm.logger.Warn().Err(err).Str("address", addr.IP.String()).Msg("failed to remove address")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// RemoveNonLinkLocalIPv6Addresses removes all non-link-local IPv6 addresses
|
||||
func (nm *NetlinkManager) RemoveNonLinkLocalIPv6Addresses(link *Link) error {
|
||||
addrs, err := nm.AddrList(link, AfInet6)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get IPv6 addresses: %w", err)
|
||||
}
|
||||
|
||||
for _, addr := range addrs {
|
||||
if !addr.IP.IsLinkLocalUnicast() {
|
||||
if err := nm.AddrDel(link, &addr); err != nil {
|
||||
nm.logger.Warn().Err(err).Str("address", addr.IP.String()).Msg("failed to remove IPv6 address")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// RouteList gets all routes
|
||||
func (nm *NetlinkManager) RouteList(link *Link, family int) ([]netlink.Route, error) {
|
||||
nm.mu.RLock()
|
||||
defer nm.mu.RUnlock()
|
||||
return netlink.RouteList(link, family)
|
||||
}
|
||||
|
||||
// RouteAdd adds a route
|
||||
func (nm *NetlinkManager) RouteAdd(route *netlink.Route) error {
|
||||
nm.mu.RLock()
|
||||
defer nm.mu.RUnlock()
|
||||
return netlink.RouteAdd(route)
|
||||
}
|
||||
|
||||
// RouteDel removes a route
|
||||
func (nm *NetlinkManager) RouteDel(route *netlink.Route) error {
|
||||
nm.mu.RLock()
|
||||
defer nm.mu.RUnlock()
|
||||
return netlink.RouteDel(route)
|
||||
}
|
||||
|
||||
// RouteReplace replaces a route
|
||||
func (nm *NetlinkManager) RouteReplace(route *netlink.Route) error {
|
||||
nm.mu.RLock()
|
||||
defer nm.mu.RUnlock()
|
||||
return netlink.RouteReplace(route)
|
||||
}
|
||||
|
||||
// ListDefaultRoutes lists the default routes for the given family
|
||||
func (nm *NetlinkManager) ListDefaultRoutes(family int) ([]netlink.Route, error) {
|
||||
routes, err := netlink.RouteListFiltered(
|
||||
family,
|
||||
&netlink.Route{Dst: nil, Table: 254},
|
||||
netlink.RT_FILTER_DST|netlink.RT_FILTER_TABLE,
|
||||
)
|
||||
if err != nil {
|
||||
nm.logger.Error().Err(err).Int("family", family).Msg("failed to list default routes")
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return routes, nil
|
||||
}
|
||||
|
||||
// HasDefaultRoute checks if a default route exists for the given family
|
||||
func (nm *NetlinkManager) HasDefaultRoute(family int) bool {
|
||||
routes, err := nm.ListDefaultRoutes(family)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return len(routes) > 0
|
||||
}
|
||||
|
||||
// AddDefaultRoute adds a default route
|
||||
func (nm *NetlinkManager) AddDefaultRoute(link *Link, gateway net.IP, family int) error {
|
||||
var dst *net.IPNet
|
||||
switch family {
|
||||
case AfInet:
|
||||
dst = &ipv4DefaultRoute
|
||||
case AfInet6:
|
||||
dst = &ipv6DefaultRoute
|
||||
default:
|
||||
return fmt.Errorf("unsupported address family: %d", family)
|
||||
}
|
||||
|
||||
route := &netlink.Route{
|
||||
Dst: dst,
|
||||
Gw: gateway,
|
||||
LinkIndex: link.Attrs().Index,
|
||||
}
|
||||
|
||||
return nm.RouteReplace(route)
|
||||
}
|
||||
|
||||
// RemoveDefaultRoute removes the default route for the given family
|
||||
func (nm *NetlinkManager) RemoveDefaultRoute(family int) error {
|
||||
routes, err := nm.RouteList(nil, family)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get routes: %w", err)
|
||||
}
|
||||
|
||||
for _, route := range routes {
|
||||
if route.Dst != nil {
|
||||
if family == AfInet && route.Dst.IP.Equal(net.IPv4zero) && route.Dst.Mask.String() == "0.0.0.0/0" {
|
||||
if err := nm.RouteDel(&route); err != nil {
|
||||
nm.logger.Warn().Err(err).Msg("failed to remove IPv4 default route")
|
||||
}
|
||||
}
|
||||
if family == AfInet6 && route.Dst.IP.Equal(net.IPv6zero) && route.Dst.Mask.String() == "::/0" {
|
||||
if err := nm.RouteDel(&route); err != nil {
|
||||
nm.logger.Warn().Err(err).Msg("failed to remove IPv6 default route")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (nm *NetlinkManager) reconcileDefaultRoute(link *Link, expected map[string]net.IP, family int) error {
|
||||
linkIndex := link.Attrs().Index
|
||||
|
||||
added := 0
|
||||
toRemove := make([]*netlink.Route, 0)
|
||||
|
||||
defaultRoutes, err := nm.ListDefaultRoutes(family)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get default routes: %w", err)
|
||||
}
|
||||
|
||||
// check existing default routes
|
||||
for _, defaultRoute := range defaultRoutes {
|
||||
// only check the default routes for the current link
|
||||
// TODO: we should also check others later
|
||||
if defaultRoute.LinkIndex != linkIndex {
|
||||
continue
|
||||
}
|
||||
|
||||
key := defaultRoute.Gw.String()
|
||||
if _, ok := expected[key]; !ok {
|
||||
toRemove = append(toRemove, &defaultRoute)
|
||||
continue
|
||||
}
|
||||
|
||||
nm.logger.Warn().Str("gateway", key).Msg("keeping default route")
|
||||
delete(expected, key)
|
||||
}
|
||||
|
||||
// remove remaining default routes
|
||||
for _, defaultRoute := range toRemove {
|
||||
nm.logger.Warn().Str("gateway", defaultRoute.Gw.String()).Msg("removing default route")
|
||||
if err := nm.RouteDel(defaultRoute); err != nil {
|
||||
nm.logger.Warn().Err(err).Msg("failed to remove default route")
|
||||
}
|
||||
}
|
||||
|
||||
// add remaining expected default routes
|
||||
for _, gateway := range expected {
|
||||
nm.logger.Warn().Str("gateway", gateway.String()).Msg("adding default route")
|
||||
|
||||
route := &netlink.Route{
|
||||
Dst: &ipv4DefaultRoute,
|
||||
Gw: gateway,
|
||||
LinkIndex: linkIndex,
|
||||
}
|
||||
if family == AfInet6 {
|
||||
route.Dst = &ipv6DefaultRoute
|
||||
}
|
||||
if err := nm.RouteAdd(route); err != nil {
|
||||
nm.logger.Warn().Err(err).Interface("route", route).Msg("failed to add default route")
|
||||
}
|
||||
added++
|
||||
}
|
||||
|
||||
nm.logger.Info().
|
||||
Int("added", added).
|
||||
Int("removed", len(toRemove)).
|
||||
Msg("default routes reconciled")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ReconcileLink reconciles the addresses and routes of a link
|
||||
func (nm *NetlinkManager) ReconcileLink(link *Link, expected []types.IPAddress, family int) error {
|
||||
toAdd := make([]*types.IPAddress, 0)
|
||||
toRemove := make([]*netlink.Addr, 0)
|
||||
toUpdate := make([]*types.IPAddress, 0)
|
||||
expectedAddrs := make(map[string]*types.IPAddress)
|
||||
|
||||
expectedGateways := make(map[string]net.IP)
|
||||
|
||||
mtu := link.Attrs().MTU
|
||||
expectedMTU := mtu
|
||||
// add all expected addresses to the map
|
||||
for _, addr := range expected {
|
||||
expectedAddrs[addr.String()] = &addr
|
||||
if addr.Gateway != nil {
|
||||
expectedGateways[addr.String()] = addr.Gateway
|
||||
}
|
||||
if addr.MTU != 0 {
|
||||
mtu = addr.MTU
|
||||
}
|
||||
}
|
||||
if expectedMTU != mtu {
|
||||
if err := link.SetMTU(expectedMTU); err != nil {
|
||||
nm.logger.Warn().Err(err).Int("expected_mtu", expectedMTU).Int("mtu", mtu).Msg("failed to set MTU")
|
||||
}
|
||||
}
|
||||
|
||||
addrs, err := nm.AddrList(link, family)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get addresses: %w", err)
|
||||
}
|
||||
|
||||
// check existing addresses
|
||||
for _, addr := range addrs {
|
||||
// skip the link-local address
|
||||
if addr.IP.IsLinkLocalUnicast() {
|
||||
continue
|
||||
}
|
||||
|
||||
expectedAddr, ok := expectedAddrs[addr.IPNet.String()]
|
||||
if !ok {
|
||||
toRemove = append(toRemove, &addr)
|
||||
continue
|
||||
}
|
||||
|
||||
// if it's not fully equal, we need to update it
|
||||
if !expectedAddr.Compare(addr) {
|
||||
toUpdate = append(toUpdate, expectedAddr)
|
||||
continue
|
||||
}
|
||||
|
||||
// remove it from expected addresses
|
||||
delete(expectedAddrs, addr.IPNet.String())
|
||||
}
|
||||
|
||||
// add remaining expected addresses
|
||||
for _, addr := range expectedAddrs {
|
||||
toAdd = append(toAdd, addr)
|
||||
}
|
||||
|
||||
for _, addr := range toUpdate {
|
||||
netlinkAddr := addr.NetlinkAddr()
|
||||
if err := nm.AddrDel(link, &netlinkAddr); err != nil {
|
||||
nm.logger.Warn().Err(err).Str("address", addr.Address.String()).Msg("failed to update address")
|
||||
}
|
||||
// we'll add it again later
|
||||
toAdd = append(toAdd, addr)
|
||||
}
|
||||
|
||||
for _, addr := range toAdd {
|
||||
netlinkAddr := addr.NetlinkAddr()
|
||||
if err := nm.AddrAdd(link, &netlinkAddr); err != nil {
|
||||
nm.logger.Warn().Err(err).Str("address", addr.Address.String()).Msg("failed to add address")
|
||||
}
|
||||
}
|
||||
|
||||
for _, netlinkAddr := range toRemove {
|
||||
if err := nm.AddrDel(link, netlinkAddr); err != nil {
|
||||
nm.logger.Warn().Err(err).Str("address", netlinkAddr.IP.String()).Msg("failed to remove address")
|
||||
}
|
||||
}
|
||||
|
||||
for _, addr := range toAdd {
|
||||
netlinkAddr := addr.NetlinkAddr()
|
||||
if err := nm.AddrAdd(link, &netlinkAddr); err != nil {
|
||||
nm.logger.Warn().Err(err).Str("address", addr.Address.String()).Msg("failed to add address")
|
||||
}
|
||||
}
|
||||
|
||||
actualToAdd := len(toAdd) - len(toUpdate)
|
||||
if len(toAdd) > 0 || len(toUpdate) > 0 || len(toRemove) > 0 {
|
||||
nm.logger.Info().
|
||||
Int("added", actualToAdd).
|
||||
Int("updated", len(toUpdate)).
|
||||
Int("removed", len(toRemove)).
|
||||
Msg("addresses reconciled")
|
||||
}
|
||||
|
||||
if err := nm.reconcileDefaultRoute(link, expectedGateways, family); err != nil {
|
||||
nm.logger.Warn().Err(err).Msg("failed to reconcile default route")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
@ -0,0 +1,164 @@
|
|||
// Package link provides a wrapper around netlink.Link and provides a singleton netlink manager.
|
||||
package link
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
|
||||
"github.com/jetkvm/kvm/internal/sync"
|
||||
|
||||
"github.com/vishvananda/netlink"
|
||||
)
|
||||
|
||||
var (
|
||||
ipv4DefaultRoute = net.IPNet{
|
||||
IP: net.IPv4zero,
|
||||
Mask: net.CIDRMask(0, 0),
|
||||
}
|
||||
|
||||
ipv6DefaultRoute = net.IPNet{
|
||||
IP: net.IPv6zero,
|
||||
Mask: net.CIDRMask(0, 0),
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
netlinkManagerInstance *NetlinkManager
|
||||
netlinkManagerOnce sync.Once
|
||||
|
||||
// ErrInterfaceUpTimeout is the error returned when the interface does not come up within the timeout
|
||||
ErrInterfaceUpTimeout = errors.New("timeout after waiting for an interface to come up")
|
||||
// ErrInterfaceUpCanceled is the error returned when the interface does not come up due to context cancellation
|
||||
ErrInterfaceUpCanceled = errors.New("context canceled while waiting for an interface to come up")
|
||||
)
|
||||
|
||||
// Link is a wrapper around netlink.Link
|
||||
type Link struct {
|
||||
netlink.Link
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
// All lock actions should be done in external functions
|
||||
// and the internal functions should not be called directly
|
||||
|
||||
func (l *Link) refresh() error {
|
||||
linkName := l.ifName()
|
||||
link, err := netlink.LinkByName(linkName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if link == nil {
|
||||
return fmt.Errorf("link not found: %s", linkName)
|
||||
}
|
||||
l.Link = link
|
||||
return nil
|
||||
}
|
||||
|
||||
func (l *Link) attrs() *netlink.LinkAttrs {
|
||||
return l.Link.Attrs()
|
||||
}
|
||||
|
||||
func (l *Link) ifName() string {
|
||||
attrs := l.attrs()
|
||||
if attrs.Name == "" {
|
||||
return ""
|
||||
}
|
||||
return attrs.Name
|
||||
}
|
||||
|
||||
// Refresh refreshes the link
|
||||
func (l *Link) Refresh() error {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
|
||||
return l.refresh()
|
||||
}
|
||||
|
||||
// Attrs returns the attributes of the link
|
||||
func (l *Link) Attrs() *netlink.LinkAttrs {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
|
||||
return l.attrs()
|
||||
}
|
||||
|
||||
// Interface returns the interface of the link
|
||||
func (l *Link) Interface() *net.Interface {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
|
||||
ifname := l.ifName()
|
||||
if ifname == "" {
|
||||
return nil
|
||||
}
|
||||
iface, err := net.InterfaceByName(ifname)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return iface
|
||||
}
|
||||
|
||||
// HardwareAddr returns the hardware address of the link
|
||||
func (l *Link) HardwareAddr() net.HardwareAddr {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
|
||||
attrs := l.attrs()
|
||||
if attrs.HardwareAddr == nil {
|
||||
return nil
|
||||
}
|
||||
return attrs.HardwareAddr
|
||||
}
|
||||
|
||||
// AddrList returns the addresses of the link
|
||||
func (l *Link) AddrList(family int) ([]netlink.Addr, error) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
|
||||
return netlink.AddrList(l.Link, family)
|
||||
}
|
||||
|
||||
func (l *Link) SetMTU(mtu int) error {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
|
||||
return netlink.LinkSetMTU(l.Link, mtu)
|
||||
}
|
||||
|
||||
// HasGlobalUnicastAddress returns true if the link has a global unicast address
|
||||
func (l *Link) HasGlobalUnicastAddress() bool {
|
||||
addrs, err := l.AddrList(AfUnspec)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
for _, addr := range addrs {
|
||||
if addr.IP.IsGlobalUnicast() {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// IsSame checks if the link is the same as another link
|
||||
func (l *Link) IsSame(other *Link) bool {
|
||||
if l == nil || other == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
a := l.Attrs()
|
||||
b := other.Attrs()
|
||||
if a.OperState != b.OperState {
|
||||
return false
|
||||
}
|
||||
if a.Index != b.Index {
|
||||
return false
|
||||
}
|
||||
if a.MTU != b.MTU {
|
||||
return false
|
||||
}
|
||||
if a.HardwareAddr.String() != b.HardwareAddr.String() {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
package link
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func (nm *NetlinkManager) setSysctlValues(ifaceName string, values map[string]int) error {
|
||||
for name, value := range values {
|
||||
name = fmt.Sprintf(name, ifaceName)
|
||||
name = strings.ReplaceAll(name, ".", "/")
|
||||
|
||||
if err := os.WriteFile(path.Join(sysctlBase, name), []byte(strconv.Itoa(value)), sysctlFileMode); err != nil {
|
||||
return fmt.Errorf("failed to set sysctl %s=%d: %w", name, value, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// EnableIPv6 enables IPv6 on the interface
|
||||
func (nm *NetlinkManager) EnableIPv6(ifaceName string) error {
|
||||
return nm.setSysctlValues(ifaceName, map[string]int{
|
||||
"net.ipv6.conf.%s.disable_ipv6": 0,
|
||||
"net.ipv6.conf.%s.accept_ra": 2,
|
||||
})
|
||||
}
|
||||
|
||||
// DisableIPv6 disables IPv6 on the interface
|
||||
func (nm *NetlinkManager) DisableIPv6(ifaceName string) error {
|
||||
return nm.setSysctlValues(ifaceName, map[string]int{
|
||||
"net.ipv6.conf.%s.disable_ipv6": 1,
|
||||
})
|
||||
}
|
||||
|
||||
// EnableIPv6SLAAC enables IPv6 SLAAC on the interface
|
||||
func (nm *NetlinkManager) EnableIPv6SLAAC(ifaceName string) error {
|
||||
return nm.setSysctlValues(ifaceName, map[string]int{
|
||||
"net.ipv6.conf.%s.disable_ipv6": 0,
|
||||
"net.ipv6.conf.%s.accept_ra": 2,
|
||||
})
|
||||
}
|
||||
|
||||
// EnableIPv6LinkLocal enables IPv6 link-local only on the interface
|
||||
func (nm *NetlinkManager) EnableIPv6LinkLocal(ifaceName string) error {
|
||||
return nm.setSysctlValues(ifaceName, map[string]int{
|
||||
"net.ipv6.conf.%s.disable_ipv6": 0,
|
||||
"net.ipv6.conf.%s.accept_ra": 0,
|
||||
})
|
||||
}
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
package link
|
||||
|
||||
import (
|
||||
"net"
|
||||
)
|
||||
|
||||
// IPv4Address represents an IPv4 address and its gateway
|
||||
type IPv4Address struct {
|
||||
Address net.IPNet
|
||||
Gateway net.IP
|
||||
Secondary bool
|
||||
Permanent bool
|
||||
}
|
||||
|
|
@ -0,0 +1,87 @@
|
|||
package link
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ParseIPv4Netmask parses an IPv4 netmask string and returns the IPNet
|
||||
func ParseIPv4Netmask(address, netmask string) (*net.IPNet, error) {
|
||||
if strings.Contains(address, "/") {
|
||||
_, ipNet, err := net.ParseCIDR(address)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid IPv4 address: %s", address)
|
||||
}
|
||||
return ipNet, nil
|
||||
}
|
||||
|
||||
ip := net.ParseIP(address)
|
||||
if ip == nil {
|
||||
return nil, fmt.Errorf("invalid IPv4 address: %s", address)
|
||||
}
|
||||
if ip.To4() == nil {
|
||||
return nil, fmt.Errorf("not an IPv4 address: %s", address)
|
||||
}
|
||||
|
||||
mask := net.ParseIP(netmask)
|
||||
if mask == nil {
|
||||
return nil, fmt.Errorf("invalid IPv4 netmask: %s", netmask)
|
||||
}
|
||||
if mask.To4() == nil {
|
||||
return nil, fmt.Errorf("not an IPv4 netmask: %s", netmask)
|
||||
}
|
||||
|
||||
return &net.IPNet{
|
||||
IP: ip,
|
||||
Mask: net.IPv4Mask(mask[12], mask[13], mask[14], mask[15]),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ParseIPv6Prefix parses an IPv6 address and prefix length
|
||||
func ParseIPv6Prefix(address string, prefixLength int) (*net.IPNet, error) {
|
||||
if strings.Contains(address, "/") {
|
||||
_, ipNet, err := net.ParseCIDR(address)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid IPv6 address: %s", address)
|
||||
}
|
||||
return ipNet, nil
|
||||
}
|
||||
|
||||
ip := net.ParseIP(address)
|
||||
if ip == nil {
|
||||
return nil, fmt.Errorf("invalid IPv6 address: %s", address)
|
||||
}
|
||||
if ip.To16() == nil || ip.To4() != nil {
|
||||
return nil, fmt.Errorf("not an IPv6 address: %s", address)
|
||||
}
|
||||
|
||||
if prefixLength < 0 || prefixLength > 128 {
|
||||
return nil, fmt.Errorf("invalid IPv6 prefix length: %d (must be 0-128)", prefixLength)
|
||||
}
|
||||
|
||||
return &net.IPNet{
|
||||
IP: ip,
|
||||
Mask: net.CIDRMask(prefixLength, 128),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ValidateIPAddress validates an IP address
|
||||
func ValidateIPAddress(address string, isIPv6 bool) error {
|
||||
ip := net.ParseIP(address)
|
||||
if ip == nil {
|
||||
return fmt.Errorf("invalid IP address: %s", address)
|
||||
}
|
||||
|
||||
if isIPv6 {
|
||||
if ip.To16() == nil || ip.To4() != nil {
|
||||
return fmt.Errorf("not an IPv6 address: %s", address)
|
||||
}
|
||||
} else {
|
||||
if ip.To4() == nil {
|
||||
return fmt.Errorf("not an IPv4 address: %s", address)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
@ -0,0 +1,260 @@
|
|||
// Package nmlite provides a lightweight network management system.
|
||||
// It supports multiple network interfaces with static and DHCP configuration,
|
||||
// IPv4/IPv6 support, and proper separation of concerns.
|
||||
package nmlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/jetkvm/kvm/internal/sync"
|
||||
|
||||
"github.com/jetkvm/kvm/internal/logging"
|
||||
"github.com/jetkvm/kvm/internal/network/types"
|
||||
"github.com/jetkvm/kvm/pkg/nmlite/jetdhcpc"
|
||||
"github.com/jetkvm/kvm/pkg/nmlite/link"
|
||||
"github.com/rs/zerolog"
|
||||
)
|
||||
|
||||
// NetworkManager manages multiple network interfaces
|
||||
type NetworkManager struct {
|
||||
interfaces map[string]*InterfaceManager
|
||||
mu sync.RWMutex
|
||||
logger *zerolog.Logger
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
|
||||
resolvConf *ResolvConfManager
|
||||
|
||||
// Callback functions for state changes
|
||||
onInterfaceStateChange func(iface string, state types.InterfaceState)
|
||||
onConfigChange func(iface string, config *types.NetworkConfig)
|
||||
onDHCPLeaseChange func(iface string, lease *types.DHCPLease)
|
||||
}
|
||||
|
||||
// NewNetworkManager creates a new network manager
|
||||
func NewNetworkManager(ctx context.Context, logger *zerolog.Logger) *NetworkManager {
|
||||
if logger == nil {
|
||||
logger = logging.GetSubsystemLogger("nm")
|
||||
}
|
||||
|
||||
// Initialize the NetlinkManager singleton
|
||||
link.InitializeNetlinkManager(logger)
|
||||
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
|
||||
return &NetworkManager{
|
||||
interfaces: make(map[string]*InterfaceManager),
|
||||
logger: logger,
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
resolvConf: NewResolvConfManager(logger),
|
||||
}
|
||||
}
|
||||
|
||||
// SetHostname sets the hostname and domain for the network manager
|
||||
func (nm *NetworkManager) SetHostname(hostname string, domain string) error {
|
||||
return nm.resolvConf.SetHostname(hostname, domain)
|
||||
}
|
||||
|
||||
// Domain returns the effective domain for the network manager
|
||||
func (nm *NetworkManager) Domain() string {
|
||||
return nm.resolvConf.Domain()
|
||||
}
|
||||
|
||||
// AddInterface adds a new network interface to be managed
|
||||
func (nm *NetworkManager) AddInterface(iface string, config *types.NetworkConfig) error {
|
||||
nm.mu.Lock()
|
||||
defer nm.mu.Unlock()
|
||||
|
||||
if _, exists := nm.interfaces[iface]; exists {
|
||||
return fmt.Errorf("interface %s already managed", iface)
|
||||
}
|
||||
|
||||
im, err := NewInterfaceManager(nm.ctx, iface, config, nm.logger)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create interface manager for %s: %w", iface, err)
|
||||
}
|
||||
|
||||
// Set up callbacks
|
||||
im.SetOnStateChange(func(state types.InterfaceState) {
|
||||
if nm.onInterfaceStateChange != nil {
|
||||
state.Hostname = nm.Hostname()
|
||||
nm.onInterfaceStateChange(iface, state)
|
||||
}
|
||||
})
|
||||
|
||||
im.SetOnConfigChange(func(config *types.NetworkConfig) {
|
||||
if nm.onConfigChange != nil {
|
||||
nm.onConfigChange(iface, config)
|
||||
}
|
||||
})
|
||||
|
||||
im.SetOnDHCPLeaseChange(func(lease *types.DHCPLease) {
|
||||
if nm.onDHCPLeaseChange != nil {
|
||||
nm.onDHCPLeaseChange(iface, lease)
|
||||
}
|
||||
})
|
||||
|
||||
im.SetOnResolvConfChange(func(family int, resolvConf *types.InterfaceResolvConf) error {
|
||||
return nm.resolvConf.SetInterfaceConfig(iface, family, *resolvConf)
|
||||
})
|
||||
|
||||
nm.interfaces[iface] = im
|
||||
|
||||
// Start monitoring the interface
|
||||
if err := im.Start(); err != nil {
|
||||
delete(nm.interfaces, iface)
|
||||
return fmt.Errorf("failed to start interface manager for %s: %w", iface, err)
|
||||
}
|
||||
|
||||
nm.logger.Info().Str("interface", iface).Msg("added interface to network manager")
|
||||
return nil
|
||||
}
|
||||
|
||||
// RemoveInterface removes a network interface from management
|
||||
func (nm *NetworkManager) RemoveInterface(iface string) error {
|
||||
nm.mu.Lock()
|
||||
defer nm.mu.Unlock()
|
||||
|
||||
im, exists := nm.interfaces[iface]
|
||||
if !exists {
|
||||
return fmt.Errorf("interface %s not managed", iface)
|
||||
}
|
||||
|
||||
if err := im.Stop(); err != nil {
|
||||
nm.logger.Error().Err(err).Str("interface", iface).Msg("failed to stop interface manager")
|
||||
}
|
||||
|
||||
delete(nm.interfaces, iface)
|
||||
nm.logger.Info().Str("interface", iface).Msg("removed interface from network manager")
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetInterface returns the interface manager for a specific interface
|
||||
func (nm *NetworkManager) GetInterface(iface string) (*InterfaceManager, error) {
|
||||
nm.mu.RLock()
|
||||
defer nm.mu.RUnlock()
|
||||
|
||||
im, exists := nm.interfaces[iface]
|
||||
if !exists {
|
||||
return nil, fmt.Errorf("interface %s not managed", iface)
|
||||
}
|
||||
|
||||
return im, nil
|
||||
}
|
||||
|
||||
// ListInterfaces returns a list of all managed interfaces
|
||||
func (nm *NetworkManager) ListInterfaces() []string {
|
||||
nm.mu.RLock()
|
||||
defer nm.mu.RUnlock()
|
||||
|
||||
interfaces := make([]string, 0, len(nm.interfaces))
|
||||
for iface := range nm.interfaces {
|
||||
interfaces = append(interfaces, iface)
|
||||
}
|
||||
|
||||
return interfaces
|
||||
}
|
||||
|
||||
// GetInterfaceState returns the current state of a specific interface
|
||||
func (nm *NetworkManager) GetInterfaceState(iface string) (*types.InterfaceState, error) {
|
||||
im, err := nm.GetInterface(iface)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
state := im.GetState()
|
||||
state.Hostname = nm.Hostname()
|
||||
|
||||
return state, nil
|
||||
}
|
||||
|
||||
// GetInterfaceConfig returns the current configuration of a specific interface
|
||||
func (nm *NetworkManager) GetInterfaceConfig(iface string) (*types.NetworkConfig, error) {
|
||||
im, err := nm.GetInterface(iface)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return im.GetConfig(), nil
|
||||
}
|
||||
|
||||
// SetInterfaceConfig updates the configuration of a specific interface
|
||||
func (nm *NetworkManager) SetInterfaceConfig(iface string, config *types.NetworkConfig) error {
|
||||
im, err := nm.GetInterface(iface)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return im.SetConfig(config)
|
||||
}
|
||||
|
||||
// RenewDHCPLease renews the DHCP lease for a specific interface
|
||||
func (nm *NetworkManager) RenewDHCPLease(iface string) error {
|
||||
im, err := nm.GetInterface(iface)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return im.RenewDHCPLease()
|
||||
}
|
||||
|
||||
// SetOnInterfaceStateChange sets the callback for interface state changes
|
||||
func (nm *NetworkManager) SetOnInterfaceStateChange(callback func(iface string, state types.InterfaceState)) {
|
||||
nm.onInterfaceStateChange = callback
|
||||
}
|
||||
|
||||
// SetOnConfigChange sets the callback for configuration changes
|
||||
func (nm *NetworkManager) SetOnConfigChange(callback func(iface string, config *types.NetworkConfig)) {
|
||||
nm.onConfigChange = callback
|
||||
}
|
||||
|
||||
// SetOnDHCPLeaseChange sets the callback for DHCP lease changes
|
||||
func (nm *NetworkManager) SetOnDHCPLeaseChange(callback func(iface string, lease *types.DHCPLease)) {
|
||||
nm.onDHCPLeaseChange = callback
|
||||
}
|
||||
|
||||
func (nm *NetworkManager) shouldKillLegacyDHCPClients() bool {
|
||||
nm.mu.RLock()
|
||||
defer nm.mu.RUnlock()
|
||||
|
||||
// TODO: remove it when we need to support multiple interfaces
|
||||
for _, im := range nm.interfaces {
|
||||
if im.dhcpClient.clientType != "udhcpc" {
|
||||
return true
|
||||
}
|
||||
|
||||
if im.config.IPv4Mode.String != "dhcp" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// CleanUpLegacyDHCPClients cleans up legacy DHCP clients
|
||||
func (nm *NetworkManager) CleanUpLegacyDHCPClients() error {
|
||||
shouldKill := nm.shouldKillLegacyDHCPClients()
|
||||
if shouldKill {
|
||||
return jetdhcpc.KillUdhcpC(nm.logger)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Stop stops the network manager and all managed interfaces
|
||||
func (nm *NetworkManager) Stop() error {
|
||||
nm.mu.Lock()
|
||||
defer nm.mu.Unlock()
|
||||
|
||||
var lastErr error
|
||||
for iface, im := range nm.interfaces {
|
||||
if err := im.Stop(); err != nil {
|
||||
nm.logger.Error().Err(err).Str("interface", iface).Msg("failed to stop interface manager")
|
||||
lastErr = err
|
||||
}
|
||||
}
|
||||
|
||||
nm.cancel()
|
||||
nm.logger.Info().Msg("network manager stopped")
|
||||
return lastErr
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
package nmlite
|
||||
|
||||
import "github.com/jetkvm/kvm/pkg/nmlite/link"
|
||||
|
||||
func getNetlinkManager() *link.NetlinkManager {
|
||||
return link.GetNetlinkManager()
|
||||
}
|
||||
|
|
@ -0,0 +1,209 @@
|
|||
package nmlite
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/jetkvm/kvm/internal/network/types"
|
||||
"github.com/jetkvm/kvm/internal/sync"
|
||||
"github.com/jetkvm/kvm/pkg/nmlite/link"
|
||||
"github.com/rs/zerolog"
|
||||
)
|
||||
|
||||
const (
|
||||
resolvConfPath = "/etc/resolv.conf"
|
||||
resolvConfFileMode = 0644
|
||||
resolvConfTemplate = `# the resolv.conf file is managed by JetKVM
|
||||
# DO NOT EDIT THIS FILE BY HAND -- YOUR CHANGES WILL BE OVERWRITTEN
|
||||
|
||||
{{ if .searchList }}
|
||||
search {{ join .searchList " " }}
|
||||
{{- end -}}
|
||||
{{ if .domain }}
|
||||
domain {{ .domain }}
|
||||
{{- end -}}
|
||||
{{ range $ns, $comment := .nameservers }}
|
||||
nameserver {{ printf "%s" $ns }} # {{ join $comment ", " }}
|
||||
{{- end }}
|
||||
`
|
||||
)
|
||||
|
||||
var (
|
||||
tplFuncMap = template.FuncMap{
|
||||
"join": strings.Join,
|
||||
}
|
||||
)
|
||||
|
||||
// ResolvConfManager manages the resolv.conf file
|
||||
type ResolvConfManager struct {
|
||||
logger *zerolog.Logger
|
||||
mu sync.Mutex
|
||||
conf *types.ResolvConf
|
||||
|
||||
hostname string
|
||||
domain string
|
||||
}
|
||||
|
||||
// NewResolvConfManager creates a new resolv.conf manager
|
||||
func NewResolvConfManager(logger *zerolog.Logger) *ResolvConfManager {
|
||||
if logger == nil {
|
||||
// Create a no-op logger if none provided
|
||||
logger = &zerolog.Logger{}
|
||||
}
|
||||
|
||||
return &ResolvConfManager{
|
||||
logger: logger,
|
||||
mu: sync.Mutex{},
|
||||
conf: &types.ResolvConf{
|
||||
ConfigIPv4: make(map[string]types.InterfaceResolvConf),
|
||||
ConfigIPv6: make(map[string]types.InterfaceResolvConf),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// SetInterfaceConfig sets the resolv.conf configuration for a specific interface
|
||||
func (rcm *ResolvConfManager) SetInterfaceConfig(iface string, family int, config types.InterfaceResolvConf) error {
|
||||
// DO NOT USE defer HERE, rcm.update() also locks the mutex
|
||||
rcm.mu.Lock()
|
||||
switch family {
|
||||
case link.AfInet:
|
||||
rcm.conf.ConfigIPv4[iface] = config
|
||||
case link.AfInet6:
|
||||
rcm.conf.ConfigIPv6[iface] = config
|
||||
default:
|
||||
rcm.mu.Unlock()
|
||||
return fmt.Errorf("invalid family: %d", family)
|
||||
}
|
||||
rcm.mu.Unlock()
|
||||
|
||||
if err := rcm.reconcileHostname(); err != nil {
|
||||
return fmt.Errorf("failed to reconcile hostname: %w", err)
|
||||
}
|
||||
|
||||
return rcm.update()
|
||||
}
|
||||
|
||||
// SetConfig sets the resolv.conf configuration
|
||||
func (rcm *ResolvConfManager) SetConfig(resolvConf *types.ResolvConf) error {
|
||||
if resolvConf == nil {
|
||||
return fmt.Errorf("resolvConf cannot be nil")
|
||||
}
|
||||
|
||||
rcm.mu.Lock()
|
||||
rcm.conf = resolvConf
|
||||
defer rcm.mu.Unlock()
|
||||
|
||||
return rcm.update()
|
||||
}
|
||||
|
||||
// Reconcile reconciles the resolv.conf configuration
|
||||
func (rcm *ResolvConfManager) Reconcile() error {
|
||||
if err := rcm.reconcileHostname(); err != nil {
|
||||
return fmt.Errorf("failed to reconcile hostname: %w", err)
|
||||
}
|
||||
return rcm.update()
|
||||
}
|
||||
|
||||
// Update updates the resolv.conf file
|
||||
func (rcm *ResolvConfManager) update() error {
|
||||
rcm.mu.Lock()
|
||||
defer rcm.mu.Unlock()
|
||||
|
||||
rcm.logger.Debug().Msg("updating resolv.conf")
|
||||
|
||||
// Generate resolv.conf content
|
||||
content, err := rcm.generateResolvConf(rcm.conf)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to generate resolv.conf: %w", err)
|
||||
}
|
||||
|
||||
// Check if the file is the same
|
||||
if _, err := os.Stat(resolvConfPath); err == nil {
|
||||
existingContent, err := os.ReadFile(resolvConfPath)
|
||||
if err != nil {
|
||||
rcm.logger.Warn().Err(err).Msg("failed to read existing resolv.conf")
|
||||
}
|
||||
|
||||
if bytes.Equal(existingContent, content) {
|
||||
rcm.logger.Debug().Msg("resolv.conf is the same, skipping write")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// Write to file
|
||||
if err := os.WriteFile(resolvConfPath, content, resolvConfFileMode); err != nil {
|
||||
return fmt.Errorf("failed to write resolv.conf: %w", err)
|
||||
}
|
||||
|
||||
rcm.logger.Info().
|
||||
Interface("config", rcm.conf).
|
||||
Msg("resolv.conf updated successfully")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type configMap map[string][]string
|
||||
|
||||
func mergeConfig(nameservers *configMap, searchList *configMap, config *types.InterfaceResolvConfMap) {
|
||||
localNameservers := *nameservers
|
||||
localSearchList := *searchList
|
||||
|
||||
for ifname, iface := range *config {
|
||||
comment := ifname
|
||||
if iface.Source != "" {
|
||||
comment += fmt.Sprintf(" (%s)", iface.Source)
|
||||
}
|
||||
|
||||
for _, ip := range iface.NameServers {
|
||||
ns := ip.String()
|
||||
if _, ok := localNameservers[ns]; !ok {
|
||||
localNameservers[ns] = []string{}
|
||||
}
|
||||
localNameservers[ns] = append(localNameservers[ns], comment)
|
||||
}
|
||||
|
||||
for _, search := range iface.SearchList {
|
||||
search = strings.Trim(search, ".")
|
||||
if _, ok := localSearchList[search]; !ok {
|
||||
localSearchList[search] = []string{}
|
||||
}
|
||||
localSearchList[search] = append(localSearchList[search], comment)
|
||||
}
|
||||
}
|
||||
|
||||
*nameservers = localNameservers
|
||||
*searchList = localSearchList
|
||||
}
|
||||
|
||||
// generateResolvConf generates resolv.conf content
|
||||
func (rcm *ResolvConfManager) generateResolvConf(conf *types.ResolvConf) ([]byte, error) {
|
||||
tmpl, err := template.New("resolv.conf").Funcs(tplFuncMap).Parse(resolvConfTemplate)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse template: %w", err)
|
||||
}
|
||||
|
||||
// merge the nameservers and searchList
|
||||
nameservers := configMap{}
|
||||
searchList := configMap{}
|
||||
|
||||
mergeConfig(&nameservers, &searchList, &conf.ConfigIPv4)
|
||||
mergeConfig(&nameservers, &searchList, &conf.ConfigIPv6)
|
||||
|
||||
flattenedSearchList := []string{}
|
||||
for search := range searchList {
|
||||
flattenedSearchList = append(flattenedSearchList, search)
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := tmpl.Execute(&buf, map[string]any{
|
||||
"nameservers": nameservers,
|
||||
"searchList": flattenedSearchList,
|
||||
}); err != nil {
|
||||
return nil, fmt.Errorf("failed to execute template: %w", err)
|
||||
}
|
||||
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
|
@ -0,0 +1,106 @@
|
|||
package nmlite
|
||||
|
||||
import "net"
|
||||
|
||||
func (nm *NetworkManager) IsOnline() bool {
|
||||
for _, iface := range nm.interfaces {
|
||||
if iface.IsOnline() {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (nm *NetworkManager) IsUp() bool {
|
||||
for _, iface := range nm.interfaces {
|
||||
if iface.IsUp() {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (nm *NetworkManager) Hostname() string {
|
||||
return nm.resolvConf.Hostname()
|
||||
}
|
||||
|
||||
func (nm *NetworkManager) FQDN() string {
|
||||
return nm.resolvConf.FQDN()
|
||||
}
|
||||
|
||||
func (nm *NetworkManager) NTPServers() []net.IP {
|
||||
servers := []net.IP{}
|
||||
for _, iface := range nm.interfaces {
|
||||
servers = append(servers, iface.NTPServers()...)
|
||||
}
|
||||
return servers
|
||||
}
|
||||
|
||||
func (nm *NetworkManager) NTPServerStrings() []string {
|
||||
servers := []string{}
|
||||
for _, server := range nm.NTPServers() {
|
||||
servers = append(servers, server.String())
|
||||
}
|
||||
return servers
|
||||
}
|
||||
|
||||
func (nm *NetworkManager) GetIPv4Addresses() []string {
|
||||
for _, iface := range nm.interfaces {
|
||||
return iface.GetIPv4Addresses()
|
||||
}
|
||||
return []string{}
|
||||
}
|
||||
|
||||
func (nm *NetworkManager) GetIPv4Address() string {
|
||||
for _, iface := range nm.interfaces {
|
||||
return iface.GetIPv4Address()
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (nm *NetworkManager) GetIPv6Address() string {
|
||||
for _, iface := range nm.interfaces {
|
||||
return iface.GetIPv6Address()
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (nm *NetworkManager) GetIPv6Addresses() []string {
|
||||
for _, iface := range nm.interfaces {
|
||||
return iface.GetIPv6Addresses()
|
||||
}
|
||||
return []string{}
|
||||
}
|
||||
|
||||
func (nm *NetworkManager) GetMACAddress() string {
|
||||
for _, iface := range nm.interfaces {
|
||||
return iface.GetMACAddress()
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (nm *NetworkManager) IPv4Ready() bool {
|
||||
for _, iface := range nm.interfaces {
|
||||
return iface.IPv4Ready()
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (nm *NetworkManager) IPv6Ready() bool {
|
||||
for _, iface := range nm.interfaces {
|
||||
return iface.IPv6Ready()
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (nm *NetworkManager) IPv4String() string {
|
||||
return nm.GetIPv4Address()
|
||||
}
|
||||
|
||||
func (nm *NetworkManager) IPv6String() string {
|
||||
return nm.GetIPv6Address()
|
||||
}
|
||||
|
||||
func (nm *NetworkManager) MACString() string {
|
||||
return nm.GetMACAddress()
|
||||
}
|
||||
|
|
@ -0,0 +1,184 @@
|
|||
package nmlite
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
|
||||
"github.com/jetkvm/kvm/internal/network/types"
|
||||
"github.com/jetkvm/kvm/pkg/nmlite/link"
|
||||
"github.com/rs/zerolog"
|
||||
)
|
||||
|
||||
// StaticConfigManager manages static network configuration
|
||||
type StaticConfigManager struct {
|
||||
ifaceName string
|
||||
logger *zerolog.Logger
|
||||
}
|
||||
|
||||
// NewStaticConfigManager creates a new static configuration manager
|
||||
func NewStaticConfigManager(ifaceName string, logger *zerolog.Logger) (*StaticConfigManager, error) {
|
||||
if ifaceName == "" {
|
||||
return nil, fmt.Errorf("interface name cannot be empty")
|
||||
}
|
||||
|
||||
if logger == nil {
|
||||
return nil, fmt.Errorf("logger cannot be nil")
|
||||
}
|
||||
|
||||
return &StaticConfigManager{
|
||||
ifaceName: ifaceName,
|
||||
logger: logger,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ToIPv4Static applies static IPv4 configuration
|
||||
func (scm *StaticConfigManager) ToIPv4Static(config *types.IPv4StaticConfig) (*types.ParsedIPConfig, error) {
|
||||
if config == nil {
|
||||
return nil, fmt.Errorf("config is nil")
|
||||
}
|
||||
|
||||
// Parse IP address and netmask
|
||||
ipNet, err := link.ParseIPv4Netmask(config.Address.String, config.Netmask.String)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
scm.logger.Info().Str("ipNet", ipNet.String()).Interface("ipc", config).Msg("parsed IPv4 address and netmask")
|
||||
|
||||
// Parse gateway
|
||||
gateway := net.ParseIP(config.Gateway.String)
|
||||
if gateway == nil {
|
||||
return nil, fmt.Errorf("invalid gateway: %s", config.Gateway.String)
|
||||
}
|
||||
|
||||
// Parse DNS servers
|
||||
var dns []net.IP
|
||||
for _, dnsStr := range config.DNS {
|
||||
if err := link.ValidateIPAddress(dnsStr, false); err != nil {
|
||||
return nil, fmt.Errorf("invalid DNS server: %w", err)
|
||||
}
|
||||
dns = append(dns, net.ParseIP(dnsStr))
|
||||
}
|
||||
|
||||
address := types.IPAddress{
|
||||
Family: link.AfInet,
|
||||
Address: *ipNet,
|
||||
Gateway: gateway,
|
||||
Secondary: false,
|
||||
Permanent: true,
|
||||
}
|
||||
|
||||
return &types.ParsedIPConfig{
|
||||
Addresses: []types.IPAddress{address},
|
||||
Nameservers: dns,
|
||||
Interface: scm.ifaceName,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ToIPv6Static applies static IPv6 configuration
|
||||
func (scm *StaticConfigManager) ToIPv6Static(config *types.IPv6StaticConfig) (*types.ParsedIPConfig, error) {
|
||||
if config == nil {
|
||||
return nil, fmt.Errorf("config is nil")
|
||||
}
|
||||
|
||||
// Parse IP address and prefix
|
||||
ipNet, err := link.ParseIPv6Prefix(config.Prefix.String, 64) // Default to /64 if not specified
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Parse gateway
|
||||
gateway := net.ParseIP(config.Gateway.String)
|
||||
if gateway == nil {
|
||||
return nil, fmt.Errorf("invalid gateway: %s", config.Gateway.String)
|
||||
}
|
||||
|
||||
// Parse DNS servers
|
||||
var dns []net.IP
|
||||
for _, dnsStr := range config.DNS {
|
||||
dnsIP := net.ParseIP(dnsStr)
|
||||
if dnsIP == nil {
|
||||
return nil, fmt.Errorf("invalid DNS server: %s", dnsStr)
|
||||
}
|
||||
dns = append(dns, dnsIP)
|
||||
}
|
||||
|
||||
address := types.IPAddress{
|
||||
Family: link.AfInet6,
|
||||
Address: *ipNet,
|
||||
Gateway: gateway,
|
||||
Secondary: false,
|
||||
Permanent: true,
|
||||
}
|
||||
|
||||
return &types.ParsedIPConfig{
|
||||
Addresses: []types.IPAddress{address},
|
||||
Nameservers: dns,
|
||||
Interface: scm.ifaceName,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// DisableIPv4 disables IPv4 on the interface
|
||||
func (scm *StaticConfigManager) DisableIPv4() error {
|
||||
scm.logger.Info().Msg("disabling IPv4")
|
||||
|
||||
netlinkMgr := getNetlinkManager()
|
||||
iface, err := netlinkMgr.GetLinkByName(scm.ifaceName)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get interface: %w", err)
|
||||
}
|
||||
|
||||
// Remove all IPv4 addresses
|
||||
if err := netlinkMgr.RemoveAllAddresses(iface, link.AfInet); err != nil {
|
||||
return fmt.Errorf("failed to remove IPv4 addresses: %w", err)
|
||||
}
|
||||
|
||||
// Remove default route
|
||||
if err := scm.removeIPv4DefaultRoute(); err != nil {
|
||||
scm.logger.Warn().Err(err).Msg("failed to remove IPv4 default route")
|
||||
}
|
||||
|
||||
scm.logger.Info().Msg("IPv4 disabled")
|
||||
return nil
|
||||
}
|
||||
|
||||
// DisableIPv6 disables IPv6 on the interface
|
||||
func (scm *StaticConfigManager) DisableIPv6() error {
|
||||
scm.logger.Info().Msg("disabling IPv6")
|
||||
netlinkMgr := getNetlinkManager()
|
||||
return netlinkMgr.DisableIPv6(scm.ifaceName)
|
||||
}
|
||||
|
||||
// EnableIPv6SLAAC enables IPv6 SLAAC
|
||||
func (scm *StaticConfigManager) EnableIPv6SLAAC() error {
|
||||
scm.logger.Info().Msg("enabling IPv6 SLAAC")
|
||||
netlinkMgr := getNetlinkManager()
|
||||
return netlinkMgr.EnableIPv6SLAAC(scm.ifaceName)
|
||||
}
|
||||
|
||||
// EnableIPv6LinkLocal enables IPv6 link-local only
|
||||
func (scm *StaticConfigManager) EnableIPv6LinkLocal() error {
|
||||
scm.logger.Info().Msg("enabling IPv6 link-local only")
|
||||
|
||||
netlinkMgr := getNetlinkManager()
|
||||
if err := netlinkMgr.EnableIPv6LinkLocal(scm.ifaceName); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Remove all non-link-local IPv6 addresses
|
||||
link, err := netlinkMgr.GetLinkByName(scm.ifaceName)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get interface: %w", err)
|
||||
}
|
||||
|
||||
if err := netlinkMgr.RemoveNonLinkLocalIPv6Addresses(link); err != nil {
|
||||
return fmt.Errorf("failed to remove non-link-local IPv6 addresses: %w", err)
|
||||
}
|
||||
|
||||
return netlinkMgr.EnsureInterfaceUp(link)
|
||||
}
|
||||
|
||||
// removeIPv4DefaultRoute removes IPv4 default route
|
||||
func (scm *StaticConfigManager) removeIPv4DefaultRoute() error {
|
||||
netlinkMgr := getNetlinkManager()
|
||||
return netlinkMgr.RemoveDefaultRoute(link.AfInet)
|
||||
}
|
||||
|
|
@ -0,0 +1,171 @@
|
|||
package udhcpc
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jetkvm/kvm/internal/network/types"
|
||||
)
|
||||
|
||||
type Lease struct {
|
||||
types.DHCPLease
|
||||
// from https://udhcp.busybox.net/README.udhcpc
|
||||
isEmpty map[string]bool
|
||||
}
|
||||
|
||||
func (l *Lease) setIsEmpty(m map[string]bool) {
|
||||
l.isEmpty = m
|
||||
}
|
||||
|
||||
// IsEmpty returns true if the lease is empty for the given key.
|
||||
func (l *Lease) IsEmpty(key string) bool {
|
||||
return l.isEmpty[key]
|
||||
}
|
||||
|
||||
// ToJSON returns the lease as a JSON string.
|
||||
func (l *Lease) ToJSON() string {
|
||||
json, err := json.Marshal(l)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return string(json)
|
||||
}
|
||||
|
||||
// ToDHCPLease converts a lease to a DHCP lease.
|
||||
func (l *Lease) ToDHCPLease() *types.DHCPLease {
|
||||
lease := &l.DHCPLease
|
||||
lease.DHCPClient = "udhcpc"
|
||||
return lease
|
||||
}
|
||||
|
||||
// SetLeaseExpiry sets the lease expiry time.
|
||||
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
|
||||
}
|
||||
|
||||
// UnmarshalDHCPCLease unmarshals a lease from a string.
|
||||
func UnmarshalDHCPCLease(obj *Lease, str string) error {
|
||||
lease := &obj.DHCPLease
|
||||
|
||||
// parse the lease file as a map
|
||||
data := make(map[string]string)
|
||||
for line := range strings.SplitSeq(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.FieldsSeq(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
|
||||
}
|
||||
|
||||
obj.setIsEmpty(valuesParsed)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
@ -6,9 +6,13 @@ import (
|
|||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
|
||||
"time"
|
||||
|
||||
"github.com/jetkvm/kvm/internal/sync"
|
||||
|
||||
"github.com/fsnotify/fsnotify"
|
||||
"github.com/jetkvm/kvm/internal/network/types"
|
||||
"github.com/rs/zerolog"
|
||||
)
|
||||
|
||||
|
|
@ -18,20 +22,22 @@ const (
|
|||
)
|
||||
|
||||
type DHCPClient struct {
|
||||
types.DHCPClient
|
||||
InterfaceName string
|
||||
leaseFile string
|
||||
pidFile string
|
||||
lease *Lease
|
||||
logger *zerolog.Logger
|
||||
process *os.Process
|
||||
onLeaseChange func(lease *Lease)
|
||||
runOnce sync.Once
|
||||
onLeaseChange func(lease *types.DHCPLease)
|
||||
}
|
||||
|
||||
type DHCPClientOptions struct {
|
||||
InterfaceName string
|
||||
PidFile string
|
||||
Logger *zerolog.Logger
|
||||
OnLeaseChange func(lease *Lease)
|
||||
OnLeaseChange func(lease *types.DHCPLease)
|
||||
}
|
||||
|
||||
var defaultLogger = zerolog.New(os.Stdout).Level(zerolog.InfoLevel)
|
||||
|
|
@ -67,8 +73,8 @@ func (c *DHCPClient) getWatchPaths() []string {
|
|||
}
|
||||
|
||||
// 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 {
|
||||
// this is a blocking call.
|
||||
func (c *DHCPClient) run() error {
|
||||
err := c.loadLeaseFile()
|
||||
if err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return err
|
||||
|
|
@ -125,7 +131,7 @@ func (c *DHCPClient) Run() error {
|
|||
// c.logger.Error().Msg("udhcpc process not found")
|
||||
// }
|
||||
|
||||
// block the goroutine until the lease file is updated
|
||||
// block the goroutine
|
||||
<-make(chan struct{})
|
||||
|
||||
return nil
|
||||
|
|
@ -182,7 +188,7 @@ func (c *DHCPClient) loadLeaseFile() error {
|
|||
Msg("current dhcp lease expiry time calculated")
|
||||
}
|
||||
|
||||
c.onLeaseChange(lease)
|
||||
c.onLeaseChange(lease.ToDHCPLease())
|
||||
|
||||
c.logger.Info().
|
||||
Str("ip", lease.IPAddress.String()).
|
||||
|
|
@ -196,3 +202,47 @@ func (c *DHCPClient) loadLeaseFile() error {
|
|||
func (c *DHCPClient) GetLease() *Lease {
|
||||
return c.lease
|
||||
}
|
||||
|
||||
func (c *DHCPClient) Domain() string {
|
||||
return c.lease.Domain
|
||||
}
|
||||
|
||||
func (c *DHCPClient) Lease4() *types.DHCPLease {
|
||||
if c.lease == nil {
|
||||
return nil
|
||||
}
|
||||
return c.lease.ToDHCPLease()
|
||||
}
|
||||
|
||||
func (c *DHCPClient) Lease6() *types.DHCPLease {
|
||||
// TODO: implement
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *DHCPClient) SetIPv4(enabled bool) {
|
||||
// TODO: implement
|
||||
}
|
||||
|
||||
func (c *DHCPClient) SetIPv6(enabled bool) {
|
||||
// TODO: implement
|
||||
}
|
||||
|
||||
func (c *DHCPClient) SetOnLeaseChange(callback func(lease *types.DHCPLease)) {
|
||||
c.onLeaseChange = callback
|
||||
}
|
||||
|
||||
func (c *DHCPClient) Start() error {
|
||||
c.runOnce.Do(func() {
|
||||
go func() {
|
||||
err := c.run()
|
||||
if err != nil {
|
||||
c.logger.Error().Err(err).Msg("failed to run udhcpc")
|
||||
}
|
||||
}()
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *DHCPClient) Stop() error {
|
||||
return c.KillProcess() // udhcpc already has KillProcess()
|
||||
}
|
||||
|
|
@ -0,0 +1,76 @@
|
|||
package nmlite
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"github.com/jetkvm/kvm/internal/network/types"
|
||||
)
|
||||
|
||||
func lifetimeToTime(lifetime int) *time.Time {
|
||||
if lifetime == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check for infinite lifetime (0xFFFFFFFF = 4294967295)
|
||||
// This is used for static/permanent addresses
|
||||
// Use uint32 to avoid int overflow on 32-bit systems
|
||||
const infiniteLifetime uint32 = 0xFFFFFFFF
|
||||
if uint32(lifetime) == infiniteLifetime || lifetime < 0 {
|
||||
return nil // Infinite lifetime - no expiration
|
||||
}
|
||||
|
||||
// For finite lifetimes (SLAAC addresses)
|
||||
t := time.Now().Add(time.Duration(lifetime) * time.Second)
|
||||
return &t
|
||||
}
|
||||
|
||||
func sortAndCompareStringSlices(a, b []string) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
|
||||
sort.Strings(a)
|
||||
sort.Strings(b)
|
||||
|
||||
for i := range a {
|
||||
if a[i] != b[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
func sortAndCompareIPv6AddressSlices(a, b []types.IPv6Address) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
|
||||
sort.SliceStable(a, func(i, j int) bool {
|
||||
return a[i].Address.String() < b[j].Address.String()
|
||||
})
|
||||
sort.SliceStable(b, func(i, j int) bool {
|
||||
return b[i].Address.String() < a[j].Address.String()
|
||||
})
|
||||
|
||||
for i := range a {
|
||||
if a[i].Address.String() != b[i].Address.String() {
|
||||
return false
|
||||
}
|
||||
|
||||
if a[i].Prefix.String() != b[i].Prefix.String() {
|
||||
return false
|
||||
}
|
||||
|
||||
if a[i].Flags != b[i].Flags {
|
||||
return false
|
||||
}
|
||||
|
||||
// we don't compare the lifetimes because they are not always same
|
||||
if a[i].Scope != b[i].Scope {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
|
@ -17,6 +17,7 @@ show_help() {
|
|||
echo " --skip-ui-build Skip frontend/UI build"
|
||||
echo " --skip-native-build Skip native build"
|
||||
echo " --disable-docker Disable docker build"
|
||||
echo " --enable-sync-trace Enable sync trace (do not use in release builds)"
|
||||
echo " -i, --install Build for release and install the app"
|
||||
echo " --help Display this help message"
|
||||
echo
|
||||
|
|
@ -32,6 +33,7 @@ REMOTE_PATH="/userdata/jetkvm/bin"
|
|||
SKIP_UI_BUILD=false
|
||||
SKIP_UI_BUILD_RELEASE=0
|
||||
SKIP_NATIVE_BUILD=0
|
||||
ENABLE_SYNC_TRACE=0
|
||||
RESET_USB_HID_DEVICE=false
|
||||
LOG_TRACE_SCOPES="${LOG_TRACE_SCOPES:-jetkvm,cloud,websocket,native,jsonrpc}"
|
||||
RUN_GO_TESTS=false
|
||||
|
|
@ -64,6 +66,11 @@ while [[ $# -gt 0 ]]; do
|
|||
RESET_USB_HID_DEVICE=true
|
||||
shift
|
||||
;;
|
||||
--enable-sync-trace)
|
||||
ENABLE_SYNC_TRACE=1
|
||||
LOG_TRACE_SCOPES="${LOG_TRACE_SCOPES},synctrace"
|
||||
shift
|
||||
;;
|
||||
--disable-docker)
|
||||
BUILD_IN_DOCKER=false
|
||||
shift
|
||||
|
|
@ -180,7 +187,10 @@ fi
|
|||
if [ "$INSTALL_APP" = true ]
|
||||
then
|
||||
msg_info "▶ Building release binary"
|
||||
do_make build_release SKIP_NATIVE_IF_EXISTS=${SKIP_NATIVE_BUILD} SKIP_UI_BUILD=${SKIP_UI_BUILD_RELEASE}
|
||||
do_make build_release \
|
||||
SKIP_NATIVE_IF_EXISTS=${SKIP_NATIVE_BUILD} \
|
||||
SKIP_UI_BUILD=${SKIP_UI_BUILD_RELEASE} \
|
||||
ENABLE_SYNC_TRACE=${ENABLE_SYNC_TRACE}
|
||||
|
||||
# Copy the binary to the remote host as if we were the OTA updater.
|
||||
ssh "${REMOTE_USER}@${REMOTE_HOST}" "cat > /userdata/jetkvm/jetkvm_app.update" < bin/jetkvm_app
|
||||
|
|
@ -189,7 +199,10 @@ then
|
|||
ssh "${REMOTE_USER}@${REMOTE_HOST}" "reboot"
|
||||
else
|
||||
msg_info "▶ Building development binary"
|
||||
do_make build_dev SKIP_NATIVE_IF_EXISTS=${SKIP_NATIVE_BUILD} SKIP_UI_BUILD=${SKIP_UI_BUILD_RELEASE}
|
||||
do_make build_dev \
|
||||
SKIP_NATIVE_IF_EXISTS=${SKIP_NATIVE_BUILD} \
|
||||
SKIP_UI_BUILD=${SKIP_UI_BUILD_RELEASE} \
|
||||
ENABLE_SYNC_TRACE=${ENABLE_SYNC_TRACE}
|
||||
|
||||
# Kill any existing instances of the application
|
||||
ssh "${REMOTE_USER}@${REMOTE_HOST}" "killall jetkvm_app_debug || true"
|
||||
|
|
|
|||
14
timesync.go
14
timesync.go
|
|
@ -43,8 +43,20 @@ func initTimeSync() {
|
|||
timeSync = timesync.NewTimeSync(×ync.TimeSyncOptions{
|
||||
Logger: timesyncLogger,
|
||||
NetworkConfig: config.NetworkConfig,
|
||||
PreCheckIPv4: func() (bool, error) {
|
||||
if !networkManager.IPv4Ready() {
|
||||
return false, nil
|
||||
}
|
||||
return true, nil
|
||||
},
|
||||
PreCheckIPv6: func() (bool, error) {
|
||||
if !networkManager.IPv6Ready() {
|
||||
return false, nil
|
||||
}
|
||||
return true, nil
|
||||
},
|
||||
PreCheckFunc: func() (bool, error) {
|
||||
if !networkState.IsOnline() {
|
||||
if !networkManager.IsOnline() {
|
||||
return false, nil
|
||||
}
|
||||
return true, nil
|
||||
|
|
|
|||
|
|
@ -0,0 +1,21 @@
|
|||
#!/usr/bin/env python3
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
messages_dir = Path(__file__).resolve().parent.parent / 'ui' / 'localization' / 'messages'
|
||||
files = list(messages_dir.glob('*.json'))
|
||||
|
||||
for f in files:
|
||||
data = json.loads(f.read_text(encoding='utf-8'))
|
||||
# Keep $schema first if present
|
||||
schema = None
|
||||
if '$schema' in data:
|
||||
schema = data.pop('$schema')
|
||||
sorted_items = dict(sorted(data.items()))
|
||||
if schema is not None:
|
||||
out = {'$schema': schema}
|
||||
out.update(sorted_items)
|
||||
else:
|
||||
out = sorted_items
|
||||
f.write_text(json.dumps(out, ensure_ascii=False, indent=4) + '\n', encoding='utf-8')
|
||||
print(f'Processed {len(files)} files in {messages_dir}')
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
|
|
@ -107,10 +107,10 @@
|
|||
"already_adopted_title": "Device Already Registered",
|
||||
"appearance_description": "Choose your preferred color theme",
|
||||
"appearance_page_description": "Customize the look and feel of your JetKVM interface",
|
||||
"appearance_theme": "Theme",
|
||||
"appearance_theme_dark": "Dark",
|
||||
"appearance_theme_light": "Light",
|
||||
"appearance_theme_system": "System",
|
||||
"appearance_theme": "Theme",
|
||||
"appearance_title": "Appearance",
|
||||
"attach": "Attach",
|
||||
"atx_power_control_get_state_error": "Failed to get ATX power state: {error}",
|
||||
|
|
@ -121,85 +121,85 @@
|
|||
"atx_power_control_reset_button": "Reset",
|
||||
"atx_power_control_send_action_error": "Failed to send ATX power action {action}: {error}",
|
||||
"atx_power_control_short_power_button": "Short Press",
|
||||
"auth_authentication_mode": "Please select an authentication mode",
|
||||
"auth_authentication_mode_error": "An error occurred while setting the authentication mode",
|
||||
"auth_authentication_mode_invalid": "Invalid authentication mode",
|
||||
"auth_authentication_mode": "Please select an authentication mode",
|
||||
"auth_connect_to_cloud": "Connect your JetKVM to the cloud",
|
||||
"auth_connect_to_cloud_action": "Log in & Connect device",
|
||||
"auth_connect_to_cloud_description": "Unlock remote access and advanced features for your device",
|
||||
"auth_connect_to_cloud": "Connect your JetKVM to the cloud",
|
||||
"auth_header_cta_already_have_account": "Already have an account?",
|
||||
"auth_header_cta_dont_have_account": "Don't have an account?",
|
||||
"auth_header_cta_new_to_jetkvm": "New to JetKVM?",
|
||||
"auth_login": "Log in to your JetKVM account",
|
||||
"auth_login_action": "Log in",
|
||||
"auth_login_description": "Log in to access and manage your devices securely",
|
||||
"auth_login": "Log in to your JetKVM account",
|
||||
"auth_mode_local": "Local Authentication Method",
|
||||
"auth_mode_local_change_later": "You can always change your authentication method later in the settings.",
|
||||
"auth_mode_local_description": "Select how you would like to secure your JetKVM device locally.",
|
||||
"auth_mode_local_no_password_description": "Quick access without password authentication.",
|
||||
"auth_mode_local_no_password": "No Password",
|
||||
"auth_mode_local_no_password_description": "Quick access without password authentication.",
|
||||
"auth_mode_local_password": "Password",
|
||||
"auth_mode_local_password_confirm_description": "Confirm your password",
|
||||
"auth_mode_local_password_confirm_label": "Confirm Password",
|
||||
"auth_mode_local_password_description": "Secure your device with a password for added protection.",
|
||||
"auth_mode_local_password_do_not_match": "Passwords do not match",
|
||||
"auth_mode_local_password_failed_set": "Failed to set password: {error}",
|
||||
"auth_mode_local_password_note_local": "All data remains on your local device.",
|
||||
"auth_mode_local_password_note": "This password will be used to secure your device data and protect against unauthorized access.",
|
||||
"auth_mode_local_password_note_local": "All data remains on your local device.",
|
||||
"auth_mode_local_password_set": "Set a Password",
|
||||
"auth_mode_local_password_set_button": "Set Password",
|
||||
"auth_mode_local_password_set_description": "Create a strong password to secure your JetKVM device locally.",
|
||||
"auth_mode_local_password_set_label": "Enter a password",
|
||||
"auth_mode_local_password_set": "Set a Password",
|
||||
"auth_mode_local_password": "Password",
|
||||
"auth_mode_local": "Local Authentication Method",
|
||||
"auth_signup_connect_to_cloud_action": "Signup & Connect device",
|
||||
"auth_signup_create_account": "Create your JetKVM account",
|
||||
"auth_signup_create_account_action": "Create Account",
|
||||
"auth_signup_create_account_description": "Create your account and start managing your devices with ease.",
|
||||
"auth_signup_create_account": "Create your JetKVM account",
|
||||
"back_to_devices": "Back to Devices",
|
||||
"back": "Back",
|
||||
"back_to_devices": "Back to Devices",
|
||||
"cancel": "Cancel",
|
||||
"close": "Close",
|
||||
"cloud_kvms_description": "Manage your cloud KVMs and connect to them securely.",
|
||||
"cloud_kvms_no_devices_description": "You don't have any devices with enabled JetKVM Cloud yet.",
|
||||
"cloud_kvms_no_devices": "No devices found",
|
||||
"cloud_kvms": "Cloud KVMs",
|
||||
"cloud_kvms_description": "Manage your cloud KVMs and connect to them securely.",
|
||||
"cloud_kvms_no_devices": "No devices found",
|
||||
"cloud_kvms_no_devices_description": "You don't have any devices with enabled JetKVM Cloud yet.",
|
||||
"confirm": "Confirm",
|
||||
"connect_to_kvm": "Connect to KVM",
|
||||
"connecting_to_device": "Connecting to device…",
|
||||
"connection_established": "Connection established",
|
||||
"connection_stats_badge_jitter_buffer_avg_delay": "Jitter Buffer Avg. Delay",
|
||||
"connection_stats_badge_jitter": "Jitter",
|
||||
"connection_stats_connection_description": "The connection between the client and the JetKVM.",
|
||||
"connection_stats_badge_jitter_buffer_avg_delay": "Jitter Buffer Avg. Delay",
|
||||
"connection_stats_connection": "Connection",
|
||||
"connection_stats_frames_per_second_description": "Number of inbound video frames displayed per second.",
|
||||
"connection_stats_connection_description": "The connection between the client and the JetKVM.",
|
||||
"connection_stats_frames_per_second": "Frames per second",
|
||||
"connection_stats_network_stability_description": "How steady the flow of inbound video packets is across the network.",
|
||||
"connection_stats_frames_per_second_description": "Number of inbound video frames displayed per second.",
|
||||
"connection_stats_network_stability": "Network Stability",
|
||||
"connection_stats_packets_lost_description": "Count of lost inbound video RTP packets.",
|
||||
"connection_stats_network_stability_description": "How steady the flow of inbound video packets is across the network.",
|
||||
"connection_stats_packets_lost": "Packets Lost",
|
||||
"connection_stats_playback_delay_description": "Delay added by the jitter buffer to smooth playback when frames arrive unevenly.",
|
||||
"connection_stats_packets_lost_description": "Count of lost inbound video RTP packets.",
|
||||
"connection_stats_playback_delay": "Playback Delay",
|
||||
"connection_stats_round_trip_time_description": "Round-trip time for the active ICE candidate pair between peers.",
|
||||
"connection_stats_playback_delay_description": "Delay added by the jitter buffer to smooth playback when frames arrive unevenly.",
|
||||
"connection_stats_round_trip_time": "Round-Trip Time",
|
||||
"connection_stats_round_trip_time_description": "Round-trip time for the active ICE candidate pair between peers.",
|
||||
"connection_stats_sidebar": "Connection Stats",
|
||||
"connection_stats_video_description": "The video stream from the JetKVM to the client.",
|
||||
"connection_stats_video": "Video",
|
||||
"connection_stats_video_description": "The video stream from the JetKVM to the client.",
|
||||
"continue": "Continue",
|
||||
"creating_peer_connection": "Creating peer connection…",
|
||||
"dc_power_control_current_unit": "A",
|
||||
"dc_power_control_current": "Current",
|
||||
"dc_power_control_current_unit": "A",
|
||||
"dc_power_control_get_state_error": "Failed to get DC power state: {error}",
|
||||
"dc_power_control_power": "Power",
|
||||
"dc_power_control_power_off_button": "Power Off",
|
||||
"dc_power_control_power_off_state": "Power OFF",
|
||||
"dc_power_control_power_on_button": "Power On",
|
||||
"dc_power_control_power_on_state": "Power ON",
|
||||
"dc_power_control_power_unit": "W",
|
||||
"dc_power_control_power": "Power",
|
||||
"dc_power_control_restore_last_state": "Last State",
|
||||
"dc_power_control_restore_power_state": "Restore Power Loss",
|
||||
"dc_power_control_set_power_state_error": "Failed to send DC power state to {enabled}: {error}",
|
||||
"dc_power_control_set_restore_state_error": "Failed to send DC power restore state to {state}: {error}",
|
||||
"dc_power_control_voltage_unit": "V",
|
||||
"dc_power_control_voltage": "Voltage",
|
||||
"dc_power_control_voltage_unit": "V",
|
||||
"delete": "Delete",
|
||||
"deregister_button": "Deregister from Cloud",
|
||||
"deregister_cloud_devices": "Cloud Devices",
|
||||
|
|
@ -226,12 +226,12 @@
|
|||
"extension_popover_load_and_manage_extensions": "Load and manage your extensions",
|
||||
"extension_popover_set_error_notification": "Failed to set active extension: {error}",
|
||||
"extension_popover_unload_extension": "Unload Extension",
|
||||
"extension_serial_console_description": "Access your serial console extension",
|
||||
"extension_serial_console": "Serial Console",
|
||||
"extensions_atx_power_control_description": "Control the power state of your machine via ATX power control.",
|
||||
"extension_serial_console_description": "Access your serial console extension",
|
||||
"extensions_atx_power_control": "ATX Power Control",
|
||||
"extensions_dc_power_control_description": "Control your DC Power extension",
|
||||
"extensions_atx_power_control_description": "Control the power state of your machine via ATX power control.",
|
||||
"extensions_dc_power_control": "DC Power Control",
|
||||
"extensions_dc_power_control_description": "Control your DC Power extension",
|
||||
"extensions_popover_extensions": "Extensions",
|
||||
"gathering_ice_candidates": "Gathering ICE candidates…",
|
||||
"general_app_version": "App: {version}",
|
||||
|
|
@ -241,8 +241,8 @@
|
|||
"general_check_for_updates": "Check for Updates",
|
||||
"general_page_description": "Configure device settings and update preferences",
|
||||
"general_reboot_description": "Do you want to proceed with rebooting the system?",
|
||||
"general_reboot_device_description": "Power cycle the JetKVM",
|
||||
"general_reboot_device": "Reboot Device",
|
||||
"general_reboot_device_description": "Power cycle the JetKVM",
|
||||
"general_reboot_no_button": "No",
|
||||
"general_reboot_title": "Reboot JetKVM",
|
||||
"general_reboot_yes_button": "Yes",
|
||||
|
|
@ -295,9 +295,9 @@
|
|||
"hardware_display_orientation_title": "Display Orientation",
|
||||
"hardware_display_wake_up_note": "The display will wake up when the connection state changes, or when touched.",
|
||||
"hardware_page_description": "Configure display settings and hardware options for your JetKVM device",
|
||||
"hardware_time_10_minutes": "10 Minutes",
|
||||
"hardware_time_1_hour": "1 Hour",
|
||||
"hardware_time_1_minute": "1 Minute",
|
||||
"hardware_time_10_minutes": "10 Minutes",
|
||||
"hardware_time_30_minutes": "30 Minutes",
|
||||
"hardware_time_5_minutes": "5 Minutes",
|
||||
"hardware_time_never": "Never",
|
||||
|
|
@ -327,6 +327,7 @@
|
|||
"invalid_password": "Invalid password",
|
||||
"ip_address": "IP Address",
|
||||
"ipv6_address_label": "Address",
|
||||
"ipv6_gateway": "Gateway",
|
||||
"ipv6_information": "IPv6 Information",
|
||||
"ipv6_link_local": "Link-local",
|
||||
"ipv6_preferred_lifetime": "Preferred Lifetime",
|
||||
|
|
@ -409,8 +410,8 @@
|
|||
"log_in": "Log In",
|
||||
"log_out": "Log out",
|
||||
"logged_in_as": "Logged in as",
|
||||
"login_enter_password_description": "Enter your password to access your JetKVM.",
|
||||
"login_enter_password": "Enter your password",
|
||||
"login_enter_password_description": "Enter your password to access your JetKVM.",
|
||||
"login_error": "An error occurred while logging in",
|
||||
"login_forgot_password": "Forgot password?",
|
||||
"login_password_label": "Password",
|
||||
|
|
@ -424,8 +425,8 @@
|
|||
"macro_name_required": "Name is required",
|
||||
"macro_name_too_long": "Name must be less than 50 characters",
|
||||
"macro_please_fix_validation_errors": "Please fix the validation errors",
|
||||
"macro_save_error": "An error occurred while saving.",
|
||||
"macro_save": "Save Macro",
|
||||
"macro_save_error": "An error occurred while saving.",
|
||||
"macro_step_count": "{steps} / {max} steps",
|
||||
"macro_step_duration_description": "Time to wait before executing the next step.",
|
||||
"macro_step_duration_label": "Step Duration",
|
||||
|
|
@ -439,8 +440,8 @@
|
|||
"macro_steps_description": "Keys/modifiers executed in sequence with a delay between each step.",
|
||||
"macro_steps_label": "Steps",
|
||||
"macros_add_description": "Create a new keyboard macro",
|
||||
"macros_add_new_macro": "Add New Macro",
|
||||
"macros_add_new": "Add New Macro",
|
||||
"macros_add_new_macro": "Add New Macro",
|
||||
"macros_aria_add_new": "Add new macro",
|
||||
"macros_aria_delete": "Delete macro {name}",
|
||||
"macros_aria_duplicate": "Duplicate macro {name}",
|
||||
|
|
@ -462,16 +463,16 @@
|
|||
"macros_edit_button": "Edit",
|
||||
"macros_edit_description": "Modify your keyboard macro",
|
||||
"macros_edit_title": "Edit Macro",
|
||||
"macros_failed_create_error": "Failed to create macro: {error}",
|
||||
"macros_failed_create": "Failed to create macro",
|
||||
"macros_failed_delete_error": "Failed to delete macro: {error}",
|
||||
"macros_failed_create_error": "Failed to create macro: {error}",
|
||||
"macros_failed_delete": "Failed to delete macro",
|
||||
"macros_failed_duplicate_error": "Failed to duplicate macro: {error}",
|
||||
"macros_failed_delete_error": "Failed to delete macro: {error}",
|
||||
"macros_failed_duplicate": "Failed to duplicate macro",
|
||||
"macros_failed_reorder_error": "Failed to reorder macros: {error}",
|
||||
"macros_failed_duplicate_error": "Failed to duplicate macro: {error}",
|
||||
"macros_failed_reorder": "Failed to reorder macros",
|
||||
"macros_failed_update_error": "Failed to update macro: {error}",
|
||||
"macros_failed_reorder_error": "Failed to reorder macros: {error}",
|
||||
"macros_failed_update": "Failed to update macro",
|
||||
"macros_failed_update_error": "Failed to update macro: {error}",
|
||||
"macros_invalid_data": "Invalid macro data",
|
||||
"macros_loading": "Loading macros…",
|
||||
"macros_max_reached": "Max Reached",
|
||||
|
|
@ -506,8 +507,8 @@
|
|||
"mount_error_list_storage": "Error listing storage files: {error}",
|
||||
"mount_error_title": "Mount Error",
|
||||
"mount_get_state_error": "Failed to get virtual media state: {error}",
|
||||
"mount_jetkvm_storage_description": "Mount previously uploaded files from the JetKVM storage",
|
||||
"mount_jetkvm_storage": "JetKVM Storage Mount",
|
||||
"mount_jetkvm_storage_description": "Mount previously uploaded files from the JetKVM storage",
|
||||
"mount_mode_cdrom": "CD/DVD",
|
||||
"mount_mode_disk": "Disk",
|
||||
"mount_mounted_as": "Mounted as",
|
||||
|
|
@ -520,8 +521,8 @@
|
|||
"mount_popular_images": "Popular images",
|
||||
"mount_streaming_from_url": "Streaming from URL",
|
||||
"mount_supported_formats": "Supported formats: ISO, IMG",
|
||||
"mount_unmount_error": "Failed to unmount image: {error}",
|
||||
"mount_unmount": "Unmount",
|
||||
"mount_unmount_error": "Failed to unmount image: {error}",
|
||||
"mount_upload_description": "Select an image file to upload to JetKVM storage",
|
||||
"mount_upload_error": "Upload error: {error}",
|
||||
"mount_upload_failed_datachannel": "Failed to create data channel for file upload",
|
||||
|
|
@ -529,8 +530,8 @@
|
|||
"mount_upload_successful": "Upload successful",
|
||||
"mount_upload_title": "Upload New Image",
|
||||
"mount_uploaded_has_been_uploaded": "{name} has been uploaded",
|
||||
"mount_uploading_with_name": "Uploading {name}",
|
||||
"mount_uploading": "Uploading…",
|
||||
"mount_uploading_with_name": "Uploading {name}",
|
||||
"mount_url_description": "Mount files from any public web address",
|
||||
"mount_url_input_label": "Image URL",
|
||||
"mount_url_mount": "URL Mount",
|
||||
|
|
@ -538,10 +539,10 @@
|
|||
"mount_view_device_title": "Mount from JetKVM Storage",
|
||||
"mount_view_url_description": "Enter an URL to the image file to mount",
|
||||
"mount_view_url_title": "Mount from URL",
|
||||
"mount_virtual_media_description": "Mount an image to boot from or install an operating system.",
|
||||
"mount_virtual_media_source_description": "Choose how you want to mount your virtual media",
|
||||
"mount_virtual_media_source": "Virtual Media Source",
|
||||
"mount_virtual_media": "Virtual Media",
|
||||
"mount_virtual_media_description": "Mount an image to boot from or install an operating system.",
|
||||
"mount_virtual_media_source": "Virtual Media Source",
|
||||
"mount_virtual_media_source_description": "Choose how you want to mount your virtual media",
|
||||
"mouse_alt_finger": "Finger touching a screen",
|
||||
"mouse_alt_mouse": "Mouse icon",
|
||||
"mouse_description": "Configure cursor behavior and interaction settings for your device",
|
||||
|
|
@ -558,10 +559,10 @@
|
|||
"mouse_jiggler_light": "Light - 5m",
|
||||
"mouse_jiggler_standard": "Standard - 1m",
|
||||
"mouse_jiggler_title": "Jiggler",
|
||||
"mouse_mode_absolute_description": "Most convenient",
|
||||
"mouse_mode_absolute": "Absolute",
|
||||
"mouse_mode_relative_description": "Most compatible",
|
||||
"mouse_mode_absolute_description": "Most convenient",
|
||||
"mouse_mode_relative": "Relative",
|
||||
"mouse_mode_relative_description": "Most compatible",
|
||||
"mouse_modes_description": "Choose the mouse input mode",
|
||||
"mouse_modes_title": "Modes",
|
||||
"mouse_scroll_high": "High",
|
||||
|
|
@ -574,12 +575,17 @@
|
|||
"mouse_title": "Mouse",
|
||||
"network_custom_domain": "Custom Domain",
|
||||
"network_description": "Configure your network settings",
|
||||
"network_dhcp_client_description": "Configure which DHCP client to use",
|
||||
"network_dhcp_client_jetkvm": "JetKVM Internal",
|
||||
"network_dhcp_client_title": "DHCP Client",
|
||||
"network_dhcp_information": "DHCP Information",
|
||||
"network_dhcp_lease_renew_confirm_description": "This will request a new IP address from your DHCP server. Your device may temporarily lose network connectivity during this process.",
|
||||
"network_dhcp_lease_renew": "Renew DHCP Lease",
|
||||
"network_dhcp_lease_renew_confirm": "Renew Lease",
|
||||
"network_dhcp_lease_renew_confirm_description": "This will request a new IP address from your DHCP server. Your device may temporarily lose network connectivity during this process.",
|
||||
"network_dhcp_lease_renew_confirm_new_a": "If you receive a new IP address",
|
||||
"network_dhcp_lease_renew_confirm_new_b": "you may need to reconnect using the new address",
|
||||
"network_dhcp_lease_renew_failed": "Failed to renew lease: {error}",
|
||||
"network_dhcp_lease_renew_success": "DHCP lease renewed",
|
||||
"network_dhcp_lease_renew": "Renew DHCP Lease",
|
||||
"network_domain_custom": "Custom",
|
||||
"network_domain_description": "Network domain suffix for the device",
|
||||
"network_domain_dhcp_provided": "DHCP provided",
|
||||
|
|
@ -588,21 +594,35 @@
|
|||
"network_hostname_description": "Device identifier on the network. Blank for system default",
|
||||
"network_hostname_title": "Hostname",
|
||||
"network_http_proxy_description": "Proxy server for outgoing HTTP(S) requests from the device. Blank for none.",
|
||||
"network_http_proxy_invalid": "Invalid HTTP proxy URL",
|
||||
"network_http_proxy_title": "HTTP Proxy",
|
||||
"network_ipv4_address": "IPv4 Address",
|
||||
"network_ipv4_dns": "IPv4 DNS",
|
||||
"network_ipv4_gateway": "IPv4 Gateway",
|
||||
"network_ipv4_mode_description": "Configure the IPv4 mode",
|
||||
"network_ipv4_mode_dhcp": "DHCP",
|
||||
"network_ipv4_mode_static": "Static",
|
||||
"network_ipv4_mode_title": "IPv4 Mode",
|
||||
"network_ipv4_netmask": "IPv4 Netmask",
|
||||
"network_ipv6_address": "IPv6 Address",
|
||||
"network_ipv6_information": "IPv6 Information",
|
||||
"network_ipv6_mode_description": "Configure the IPv6 mode",
|
||||
"network_ipv6_mode_dhcpv6": "DHCPv6",
|
||||
"network_ipv6_mode_disabled": "Disabled",
|
||||
"network_ipv6_mode_link_local": "Link-local only",
|
||||
"network_ipv6_mode_slaac": "SLAAC",
|
||||
"network_ipv6_mode_slaac_dhcpv6": "SLAAC + DHCPv6",
|
||||
"network_ipv6_mode_static": "Static",
|
||||
"network_ipv6_mode_title": "IPv6 Mode",
|
||||
"network_ipv6_netmask": "IPv6 Netmask",
|
||||
"network_ipv6_no_addresses": "No IPv6 addresses configured",
|
||||
"network_ll_dp_all": "All",
|
||||
"network_ll_dp_basic": "Basic",
|
||||
"network_ll_dp_description": "Control which TLVs will be sent over Link Layer Discovery Protocol",
|
||||
"network_ll_dp_disabled": "Disabled",
|
||||
"network_ll_dp_title": "LLDP",
|
||||
"network_mac_address_copy_error": "Failed to copy MAC address",
|
||||
"network_mac_address_copy_success": "MAC address { mac } copied to clipboard",
|
||||
"network_mac_address_description": "Hardware identifier for the network interface",
|
||||
"network_mac_address_title": "MAC Address",
|
||||
"network_mdns_auto": "Auto",
|
||||
|
|
@ -612,9 +632,19 @@
|
|||
"network_mdns_ipv6_only": "IPv6 only",
|
||||
"network_mdns_title": "mDNS",
|
||||
"network_no_dhcp_lease": "No DHCP lease information available",
|
||||
"network_no_information_description": "No network configuration available",
|
||||
"network_no_information_headline": "Network Information",
|
||||
"network_pending_dhcp_mode_change_description": "Save settings to enable DHCP mode and view lease information",
|
||||
"network_pending_dhcp_mode_change_headline": "Pending DHCP IPv4 mode change",
|
||||
"network_save_settings": "Save Settings",
|
||||
"network_save_settings_apply_title": "Apply network settings",
|
||||
"network_save_settings_confirm": "Apply changes",
|
||||
"network_save_settings_confirm_description": "The following network settings will be applied. These changes may require a reboot and cause brief disconnection.",
|
||||
"network_save_settings_confirm_heading": "Configuration changes",
|
||||
"network_save_settings_failed": "Failed to save network settings: {error}",
|
||||
"network_save_settings_success": "Network settings saved",
|
||||
"network_save_settings": "Save Settings",
|
||||
"network_settings_invalid_ipv4_cidr": "Invalid CIDR notation for IPv4 address",
|
||||
"network_settings_load_error": "Failed to load network settings: {error}",
|
||||
"network_time_sync_description": "Configure time synchronization settings",
|
||||
"network_time_sync_http_only": "HTTP only",
|
||||
"network_time_sync_ntp_and_http": "NTP and HTTP",
|
||||
|
|
@ -640,8 +670,8 @@
|
|||
"paste_modal_failed_paste": "Failed to paste text: {error}",
|
||||
"paste_modal_invalid_chars_intro": "The following characters won't be pasted:",
|
||||
"paste_modal_paste_from_host": "Paste from host",
|
||||
"paste_modal_paste_text_description": "Paste text from your client to the remote host",
|
||||
"paste_modal_paste_text": "Paste text",
|
||||
"paste_modal_paste_text_description": "Paste text from your client to the remote host",
|
||||
"paste_modal_sending_using_layout": "Sending text using keyboard layout: {iso}-{name}",
|
||||
"peer_connection_closed": "Closed",
|
||||
"peer_connection_closing": "Closing",
|
||||
|
|
@ -668,20 +698,20 @@
|
|||
"retry": "Retry",
|
||||
"saving": "Saving…",
|
||||
"search_placeholder": "Search…",
|
||||
"serial_console": "Serial Console",
|
||||
"serial_console_baud_rate": "Baud Rate",
|
||||
"serial_console_configure_description": "Configure your serial console settings",
|
||||
"serial_console_data_bits": "Data Bits",
|
||||
"serial_console_get_settings_error": "Failed to get serial console settings: {error}",
|
||||
"serial_console_open_console": "Open Console",
|
||||
"serial_console_parity": "Parity",
|
||||
"serial_console_parity_even": "Even Parity",
|
||||
"serial_console_parity_mark": "Mark Parity",
|
||||
"serial_console_parity_none": "No Parity",
|
||||
"serial_console_parity_odd": "Odd Parity",
|
||||
"serial_console_parity_space": "Space Parity",
|
||||
"serial_console_parity": "Parity",
|
||||
"serial_console_set_settings_error": "Failed to set serial console settings to {settings}: {error}",
|
||||
"serial_console_stop_bits": "Stop Bits",
|
||||
"serial_console": "Serial Console",
|
||||
"setting_remote_description": "Setting remote description",
|
||||
"setting_remote_session_description": "Setting remote session description...",
|
||||
"setting_up_connection_to_device": "Setting up connection to device...",
|
||||
|
|
@ -691,8 +721,8 @@
|
|||
"settings_back_to_kvm": "Back to KVM",
|
||||
"settings_general": "General",
|
||||
"settings_hardware": "Hardware",
|
||||
"settings_keyboard_macros": "Keyboard Macros",
|
||||
"settings_keyboard": "Keyboard",
|
||||
"settings_keyboard_macros": "Keyboard Macros",
|
||||
"settings_mouse": "Mouse",
|
||||
"settings_network": "Network",
|
||||
"settings_video": "Video",
|
||||
|
|
@ -712,6 +742,7 @@
|
|||
"updates_failed_check": "Failed to check for updates: {error}",
|
||||
"updates_failed_get_device_version": "Failed to get device version: {error}",
|
||||
"updating_leave_device_on": "Please don't turn off your device…",
|
||||
"usb": "USB",
|
||||
"usb_config_custom": "Custom",
|
||||
"usb_config_default": "JetKVM Default",
|
||||
"usb_config_dell": "Dell Multimedia Pro Keyboard",
|
||||
|
|
@ -758,7 +789,6 @@
|
|||
"usb_state_connecting": "Connecting",
|
||||
"usb_state_disconnected": "Disconnected",
|
||||
"usb_state_low_power_mode": "Low power mode",
|
||||
"usb": "USB",
|
||||
"user_interface_language_description": "Select the language to use in the JetKVM user interface",
|
||||
"user_interface_language_title": "Interface Language",
|
||||
"video_brightness_description": "Brightness level ({value}x)",
|
||||
|
|
@ -825,6 +855,7 @@
|
|||
"video_title": "Video",
|
||||
"view_details": "View Details",
|
||||
"virtual_keyboard_header": "Virtual Keyboard",
|
||||
"wake_on_lan": "Wake On LAN",
|
||||
"wake_on_lan_add_device_device_name": "Device Name",
|
||||
"wake_on_lan_add_device_example_device_name": "Plex Media Server",
|
||||
"wake_on_lan_add_device_mac_address": "MAC Address",
|
||||
|
|
@ -840,7 +871,6 @@
|
|||
"wake_on_lan_failed_send_magic": "Failed to send Magic Packet",
|
||||
"wake_on_lan_invalid_mac": "Invalid MAC address",
|
||||
"wake_on_lan_magic_sent_success": "Magic Packet sent successfully",
|
||||
"wake_on_lan": "Wake On LAN",
|
||||
"welcome_to_jetkvm_description": "Control any computer remotely",
|
||||
"welcome_to_jetkvm": "Welcome to JetKVM"
|
||||
}
|
||||
"welcome_to_jetkvm": "Welcome to JetKVM",
|
||||
"welcome_to_jetkvm_description": "Control any computer remotely"
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
|
|
@ -28,6 +28,7 @@
|
|||
"react": "^19.2.0",
|
||||
"react-animate-height": "^3.2.3",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-hook-form": "^7.65.0",
|
||||
"react-hot-toast": "^2.6.0",
|
||||
"react-icons": "^5.5.0",
|
||||
"react-router": "^7.9.4",
|
||||
|
|
@ -78,7 +79,7 @@
|
|||
"vite-tsconfig-paths": "^5.1.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^22.15.0"
|
||||
"node": "^22.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@alloc/quick-lru": {
|
||||
|
|
@ -355,9 +356,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.25.10",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.10.tgz",
|
||||
"integrity": "sha512-0NFWnA+7l41irNuaSVlLfgNT12caWJVLzp5eAVhZ0z1qpxbockccEt3s+149rE64VUI3Ml2zt8Nv5JVc4QXTsw==",
|
||||
"version": "0.25.11",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.11.tgz",
|
||||
"integrity": "sha512-Xt1dOL13m8u0WE8iplx9Ibbm+hFAO0GsU2P34UNoDGvZYkY8ifSiy6Zuc1lYxfG7svWE2fzqCUmFp5HCn51gJg==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
|
|
@ -371,9 +372,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm": {
|
||||
"version": "0.25.10",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.10.tgz",
|
||||
"integrity": "sha512-dQAxF1dW1C3zpeCDc5KqIYuZ1tgAdRXNoZP7vkBIRtKZPYe2xVr/d3SkirklCHudW1B45tGiUlz2pUWDfbDD4w==",
|
||||
"version": "0.25.11",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.11.tgz",
|
||||
"integrity": "sha512-uoa7dU+Dt3HYsethkJ1k6Z9YdcHjTrSb5NUy66ZfZaSV8hEYGD5ZHbEMXnqLFlbBflLsl89Zke7CAdDJ4JI+Gg==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
|
|
@ -387,9 +388,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm64": {
|
||||
"version": "0.25.10",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.10.tgz",
|
||||
"integrity": "sha512-LSQa7eDahypv/VO6WKohZGPSJDq5OVOo3UoFR1E4t4Gj1W7zEQMUhI+lo81H+DtB+kP+tDgBp+M4oNCwp6kffg==",
|
||||
"version": "0.25.11",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.11.tgz",
|
||||
"integrity": "sha512-9slpyFBc4FPPz48+f6jyiXOx/Y4v34TUeDDXJpZqAWQn/08lKGeD8aDp9TMn9jDz2CiEuHwfhRmGBvpnd/PWIQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
|
|
@ -403,9 +404,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-x64": {
|
||||
"version": "0.25.10",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.10.tgz",
|
||||
"integrity": "sha512-MiC9CWdPrfhibcXwr39p9ha1x0lZJ9KaVfvzA0Wxwz9ETX4v5CHfF09bx935nHlhi+MxhA63dKRRQLiVgSUtEg==",
|
||||
"version": "0.25.11",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.11.tgz",
|
||||
"integrity": "sha512-Sgiab4xBjPU1QoPEIqS3Xx+R2lezu0LKIEcYe6pftr56PqPygbB7+szVnzoShbx64MUupqoE0KyRlN7gezbl8g==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
|
@ -419,9 +420,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-arm64": {
|
||||
"version": "0.25.10",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.10.tgz",
|
||||
"integrity": "sha512-JC74bdXcQEpW9KkV326WpZZjLguSZ3DfS8wrrvPMHgQOIEIG/sPXEN/V8IssoJhbefLRcRqw6RQH2NnpdprtMA==",
|
||||
"version": "0.25.11",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.11.tgz",
|
||||
"integrity": "sha512-VekY0PBCukppoQrycFxUqkCojnTQhdec0vevUL/EDOCnXd9LKWqD/bHwMPzigIJXPhC59Vd1WFIL57SKs2mg4w==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
|
|
@ -435,9 +436,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-x64": {
|
||||
"version": "0.25.10",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.10.tgz",
|
||||
"integrity": "sha512-tguWg1olF6DGqzws97pKZ8G2L7Ig1vjDmGTwcTuYHbuU6TTjJe5FXbgs5C1BBzHbJ2bo1m3WkQDbWO2PvamRcg==",
|
||||
"version": "0.25.11",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.11.tgz",
|
||||
"integrity": "sha512-+hfp3yfBalNEpTGp9loYgbknjR695HkqtY3d3/JjSRUyPg/xd6q+mQqIb5qdywnDxRZykIHs3axEqU6l1+oWEQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
|
@ -451,9 +452,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-arm64": {
|
||||
"version": "0.25.10",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.10.tgz",
|
||||
"integrity": "sha512-3ZioSQSg1HT2N05YxeJWYR+Libe3bREVSdWhEEgExWaDtyFbbXWb49QgPvFH8u03vUPX10JhJPcz7s9t9+boWg==",
|
||||
"version": "0.25.11",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.11.tgz",
|
||||
"integrity": "sha512-CmKjrnayyTJF2eVuO//uSjl/K3KsMIeYeyN7FyDBjsR3lnSJHaXlVoAK8DZa7lXWChbuOk7NjAc7ygAwrnPBhA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
|
|
@ -467,9 +468,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-x64": {
|
||||
"version": "0.25.10",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.10.tgz",
|
||||
"integrity": "sha512-LLgJfHJk014Aa4anGDbh8bmI5Lk+QidDmGzuC2D+vP7mv/GeSN+H39zOf7pN5N8p059FcOfs2bVlrRr4SK9WxA==",
|
||||
"version": "0.25.11",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.11.tgz",
|
||||
"integrity": "sha512-Dyq+5oscTJvMaYPvW3x3FLpi2+gSZTCE/1ffdwuM6G1ARang/mb3jvjxs0mw6n3Lsw84ocfo9CrNMqc5lTfGOw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
|
@ -483,9 +484,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm": {
|
||||
"version": "0.25.10",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.10.tgz",
|
||||
"integrity": "sha512-oR31GtBTFYCqEBALI9r6WxoU/ZofZl962pouZRTEYECvNF/dtXKku8YXcJkhgK/beU+zedXfIzHijSRapJY3vg==",
|
||||
"version": "0.25.11",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.11.tgz",
|
||||
"integrity": "sha512-TBMv6B4kCfrGJ8cUPo7vd6NECZH/8hPpBHHlYI3qzoYFvWu2AdTvZNuU/7hsbKWqu/COU7NIK12dHAAqBLLXgw==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
|
|
@ -499,9 +500,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm64": {
|
||||
"version": "0.25.10",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.10.tgz",
|
||||
"integrity": "sha512-5luJWN6YKBsawd5f9i4+c+geYiVEw20FVW5x0v1kEMWNq8UctFjDiMATBxLvmmHA4bf7F6hTRaJgtghFr9iziQ==",
|
||||
"version": "0.25.11",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.11.tgz",
|
||||
"integrity": "sha512-Qr8AzcplUhGvdyUF08A1kHU3Vr2O88xxP0Tm8GcdVOUm25XYcMPp2YqSVHbLuXzYQMf9Bh/iKx7YPqECs6ffLA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
|
|
@ -515,9 +516,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ia32": {
|
||||
"version": "0.25.10",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.10.tgz",
|
||||
"integrity": "sha512-NrSCx2Kim3EnnWgS4Txn0QGt0Xipoumb6z6sUtl5bOEZIVKhzfyp/Lyw4C1DIYvzeW/5mWYPBFJU3a/8Yr75DQ==",
|
||||
"version": "0.25.11",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.11.tgz",
|
||||
"integrity": "sha512-TmnJg8BMGPehs5JKrCLqyWTVAvielc615jbkOirATQvWWB1NMXY77oLMzsUjRLa0+ngecEmDGqt5jiDC6bfvOw==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
|
|
@ -531,9 +532,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-loong64": {
|
||||
"version": "0.25.10",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.10.tgz",
|
||||
"integrity": "sha512-xoSphrd4AZda8+rUDDfD9J6FUMjrkTz8itpTITM4/xgerAZZcFW7Dv+sun7333IfKxGG8gAq+3NbfEMJfiY+Eg==",
|
||||
"version": "0.25.11",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.11.tgz",
|
||||
"integrity": "sha512-DIGXL2+gvDaXlaq8xruNXUJdT5tF+SBbJQKbWy/0J7OhU8gOHOzKmGIlfTTl6nHaCOoipxQbuJi7O++ldrxgMw==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
|
|
@ -547,9 +548,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-mips64el": {
|
||||
"version": "0.25.10",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.10.tgz",
|
||||
"integrity": "sha512-ab6eiuCwoMmYDyTnyptoKkVS3k8fy/1Uvq7Dj5czXI6DF2GqD2ToInBI0SHOp5/X1BdZ26RKc5+qjQNGRBelRA==",
|
||||
"version": "0.25.11",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.11.tgz",
|
||||
"integrity": "sha512-Osx1nALUJu4pU43o9OyjSCXokFkFbyzjXb6VhGIJZQ5JZi8ylCQ9/LFagolPsHtgw6himDSyb5ETSfmp4rpiKQ==",
|
||||
"cpu": [
|
||||
"mips64el"
|
||||
],
|
||||
|
|
@ -563,9 +564,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ppc64": {
|
||||
"version": "0.25.10",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.10.tgz",
|
||||
"integrity": "sha512-NLinzzOgZQsGpsTkEbdJTCanwA5/wozN9dSgEl12haXJBzMTpssebuXR42bthOF3z7zXFWH1AmvWunUCkBE4EA==",
|
||||
"version": "0.25.11",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.11.tgz",
|
||||
"integrity": "sha512-nbLFgsQQEsBa8XSgSTSlrnBSrpoWh7ioFDUmwo158gIm5NNP+17IYmNWzaIzWmgCxq56vfr34xGkOcZ7jX6CPw==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
|
|
@ -579,9 +580,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-riscv64": {
|
||||
"version": "0.25.10",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.10.tgz",
|
||||
"integrity": "sha512-FE557XdZDrtX8NMIeA8LBJX3dC2M8VGXwfrQWU7LB5SLOajfJIxmSdyL/gU1m64Zs9CBKvm4UAuBp5aJ8OgnrA==",
|
||||
"version": "0.25.11",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.11.tgz",
|
||||
"integrity": "sha512-HfyAmqZi9uBAbgKYP1yGuI7tSREXwIb438q0nqvlpxAOs3XnZ8RsisRfmVsgV486NdjD7Mw2UrFSw51lzUk1ww==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
|
|
@ -595,9 +596,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-s390x": {
|
||||
"version": "0.25.10",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.10.tgz",
|
||||
"integrity": "sha512-3BBSbgzuB9ajLoVZk0mGu+EHlBwkusRmeNYdqmznmMc9zGASFjSsxgkNsqmXugpPk00gJ0JNKh/97nxmjctdew==",
|
||||
"version": "0.25.11",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.11.tgz",
|
||||
"integrity": "sha512-HjLqVgSSYnVXRisyfmzsH6mXqyvj0SA7pG5g+9W7ESgwA70AXYNpfKBqh1KbTxmQVaYxpzA/SvlB9oclGPbApw==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
|
|
@ -611,9 +612,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-x64": {
|
||||
"version": "0.25.10",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.10.tgz",
|
||||
"integrity": "sha512-QSX81KhFoZGwenVyPoberggdW1nrQZSvfVDAIUXr3WqLRZGZqWk/P4T8p2SP+de2Sr5HPcvjhcJzEiulKgnxtA==",
|
||||
"version": "0.25.11",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.11.tgz",
|
||||
"integrity": "sha512-HSFAT4+WYjIhrHxKBwGmOOSpphjYkcswF449j6EjsjbinTZbp8PJtjsVK1XFJStdzXdy/jaddAep2FGY+wyFAQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
|
@ -627,9 +628,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-arm64": {
|
||||
"version": "0.25.10",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.10.tgz",
|
||||
"integrity": "sha512-AKQM3gfYfSW8XRk8DdMCzaLUFB15dTrZfnX8WXQoOUpUBQ+NaAFCP1kPS/ykbbGYz7rxn0WS48/81l9hFl3u4A==",
|
||||
"version": "0.25.11",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.11.tgz",
|
||||
"integrity": "sha512-hr9Oxj1Fa4r04dNpWr3P8QKVVsjQhqrMSUzZzf+LZcYjZNqhA3IAfPQdEh1FLVUJSiu6sgAwp3OmwBfbFgG2Xg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
|
|
@ -643,9 +644,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-x64": {
|
||||
"version": "0.25.10",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.10.tgz",
|
||||
"integrity": "sha512-7RTytDPGU6fek/hWuN9qQpeGPBZFfB4zZgcz2VK2Z5VpdUxEI8JKYsg3JfO0n/Z1E/6l05n0unDCNc4HnhQGig==",
|
||||
"version": "0.25.11",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.11.tgz",
|
||||
"integrity": "sha512-u7tKA+qbzBydyj0vgpu+5h5AeudxOAGncb8N6C9Kh1N4n7wU1Xw1JDApsRjpShRpXRQlJLb9wY28ELpwdPcZ7A==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
|
@ -659,9 +660,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-arm64": {
|
||||
"version": "0.25.10",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.10.tgz",
|
||||
"integrity": "sha512-5Se0VM9Wtq797YFn+dLimf2Zx6McttsH2olUBsDml+lm0GOCRVebRWUvDtkY4BWYv/3NgzS8b/UM3jQNh5hYyw==",
|
||||
"version": "0.25.11",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.11.tgz",
|
||||
"integrity": "sha512-Qq6YHhayieor3DxFOoYM1q0q1uMFYb7cSpLD2qzDSvK1NAvqFi8Xgivv0cFC6J+hWVw2teCYltyy9/m/14ryHg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
|
|
@ -675,9 +676,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-x64": {
|
||||
"version": "0.25.10",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.10.tgz",
|
||||
"integrity": "sha512-XkA4frq1TLj4bEMB+2HnI0+4RnjbuGZfet2gs/LNs5Hc7D89ZQBHQ0gL2ND6Lzu1+QVkjp3x1gIcPKzRNP8bXw==",
|
||||
"version": "0.25.11",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.11.tgz",
|
||||
"integrity": "sha512-CN+7c++kkbrckTOz5hrehxWN7uIhFFlmS/hqziSFVWpAzpWrQoAG4chH+nN3Be+Kzv/uuo7zhX716x3Sn2Jduw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
|
@ -691,9 +692,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@esbuild/openharmony-arm64": {
|
||||
"version": "0.25.10",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.10.tgz",
|
||||
"integrity": "sha512-AVTSBhTX8Y/Fz6OmIVBip9tJzZEUcY8WLh7I59+upa5/GPhh2/aM6bvOMQySspnCCHvFi79kMtdJS1w0DXAeag==",
|
||||
"version": "0.25.11",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.11.tgz",
|
||||
"integrity": "sha512-rOREuNIQgaiR+9QuNkbkxubbp8MSO9rONmwP5nKncnWJ9v5jQ4JxFnLu4zDSRPf3x4u+2VN4pM4RdyIzDty/wQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
|
|
@ -707,9 +708,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@esbuild/sunos-x64": {
|
||||
"version": "0.25.10",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.10.tgz",
|
||||
"integrity": "sha512-fswk3XT0Uf2pGJmOpDB7yknqhVkJQkAQOcW/ccVOtfx05LkbWOaRAtn5SaqXypeKQra1QaEa841PgrSL9ubSPQ==",
|
||||
"version": "0.25.11",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.11.tgz",
|
||||
"integrity": "sha512-nq2xdYaWxyg9DcIyXkZhcYulC6pQ2FuCgem3LI92IwMgIZ69KHeY8T4Y88pcwoLIjbed8n36CyKoYRDygNSGhA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
|
@ -723,9 +724,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-arm64": {
|
||||
"version": "0.25.10",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.10.tgz",
|
||||
"integrity": "sha512-ah+9b59KDTSfpaCg6VdJoOQvKjI33nTaQr4UluQwW7aEwZQsbMCfTmfEO4VyewOxx4RaDT/xCy9ra2GPWmO7Kw==",
|
||||
"version": "0.25.11",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.11.tgz",
|
||||
"integrity": "sha512-3XxECOWJq1qMZ3MN8srCJ/QfoLpL+VaxD/WfNRm1O3B4+AZ/BnLVgFbUV3eiRYDMXetciH16dwPbbHqwe1uU0Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
|
|
@ -739,9 +740,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-ia32": {
|
||||
"version": "0.25.10",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.10.tgz",
|
||||
"integrity": "sha512-QHPDbKkrGO8/cz9LKVnJU22HOi4pxZnZhhA2HYHez5Pz4JeffhDjf85E57Oyco163GnzNCVkZK0b/n4Y0UHcSw==",
|
||||
"version": "0.25.11",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.11.tgz",
|
||||
"integrity": "sha512-3ukss6gb9XZ8TlRyJlgLn17ecsK4NSQTmdIXRASVsiS2sQ6zPPZklNJT5GR5tE/MUarymmy8kCEf5xPCNCqVOA==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
|
|
@ -755,9 +756,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-x64": {
|
||||
"version": "0.25.10",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.10.tgz",
|
||||
"integrity": "sha512-9KpxSVFCu0iK1owoez6aC/s/EdUQLDN3adTxGCqxMVhrPDj6bt5dbrHDXUuq+Bs2vATFBBrQS5vdQ/Ed2P+nbw==",
|
||||
"version": "0.25.11",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.11.tgz",
|
||||
"integrity": "sha512-D7Hpz6A2L4hzsRpPaCYkQnGOotdUpDzSGRIv9I+1ITdHROSFUWW95ZPZWQmGka1Fg7W3zFJowyn9WGwMJ0+KPA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
|
@ -3685,9 +3686,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/electron-to-chromium": {
|
||||
"version": "1.5.235",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.235.tgz",
|
||||
"integrity": "sha512-i/7ntLFwOdoHY7sgjlTIDo4Sl8EdoTjWIaKinYOVfC6bOp71bmwenyZthWHcasxgHDNWbWxvG9M3Ia116zIaYQ==",
|
||||
"version": "1.5.237",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.237.tgz",
|
||||
"integrity": "sha512-icUt1NvfhGLar5lSWH3tHNzablaA5js3HVHacQimfP8ViEBOQv+L7DKEuHdbTZ0SKCO1ogTJTIL1Gwk9S6Qvcg==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
|
|
@ -3886,9 +3887,9 @@
|
|||
]
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.25.10",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.10.tgz",
|
||||
"integrity": "sha512-9RiGKvCwaqxO2owP61uQ4BgNborAQskMR6QusfWzQqv7AZOg5oGehdY2pRJMTKuwxd1IDBP4rSbI5lHzU7SMsQ==",
|
||||
"version": "0.25.11",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.11.tgz",
|
||||
"integrity": "sha512-KohQwyzrKTQmhXDW1PjCv3Tyspn9n5GcY2RTDqeORIdIJY8yKIF7sTSopFmn/wpMPW4rdPXI0UE5LJLuq3bx0Q==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
|
|
@ -3898,32 +3899,32 @@
|
|||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@esbuild/aix-ppc64": "0.25.10",
|
||||
"@esbuild/android-arm": "0.25.10",
|
||||
"@esbuild/android-arm64": "0.25.10",
|
||||
"@esbuild/android-x64": "0.25.10",
|
||||
"@esbuild/darwin-arm64": "0.25.10",
|
||||
"@esbuild/darwin-x64": "0.25.10",
|
||||
"@esbuild/freebsd-arm64": "0.25.10",
|
||||
"@esbuild/freebsd-x64": "0.25.10",
|
||||
"@esbuild/linux-arm": "0.25.10",
|
||||
"@esbuild/linux-arm64": "0.25.10",
|
||||
"@esbuild/linux-ia32": "0.25.10",
|
||||
"@esbuild/linux-loong64": "0.25.10",
|
||||
"@esbuild/linux-mips64el": "0.25.10",
|
||||
"@esbuild/linux-ppc64": "0.25.10",
|
||||
"@esbuild/linux-riscv64": "0.25.10",
|
||||
"@esbuild/linux-s390x": "0.25.10",
|
||||
"@esbuild/linux-x64": "0.25.10",
|
||||
"@esbuild/netbsd-arm64": "0.25.10",
|
||||
"@esbuild/netbsd-x64": "0.25.10",
|
||||
"@esbuild/openbsd-arm64": "0.25.10",
|
||||
"@esbuild/openbsd-x64": "0.25.10",
|
||||
"@esbuild/openharmony-arm64": "0.25.10",
|
||||
"@esbuild/sunos-x64": "0.25.10",
|
||||
"@esbuild/win32-arm64": "0.25.10",
|
||||
"@esbuild/win32-ia32": "0.25.10",
|
||||
"@esbuild/win32-x64": "0.25.10"
|
||||
"@esbuild/aix-ppc64": "0.25.11",
|
||||
"@esbuild/android-arm": "0.25.11",
|
||||
"@esbuild/android-arm64": "0.25.11",
|
||||
"@esbuild/android-x64": "0.25.11",
|
||||
"@esbuild/darwin-arm64": "0.25.11",
|
||||
"@esbuild/darwin-x64": "0.25.11",
|
||||
"@esbuild/freebsd-arm64": "0.25.11",
|
||||
"@esbuild/freebsd-x64": "0.25.11",
|
||||
"@esbuild/linux-arm": "0.25.11",
|
||||
"@esbuild/linux-arm64": "0.25.11",
|
||||
"@esbuild/linux-ia32": "0.25.11",
|
||||
"@esbuild/linux-loong64": "0.25.11",
|
||||
"@esbuild/linux-mips64el": "0.25.11",
|
||||
"@esbuild/linux-ppc64": "0.25.11",
|
||||
"@esbuild/linux-riscv64": "0.25.11",
|
||||
"@esbuild/linux-s390x": "0.25.11",
|
||||
"@esbuild/linux-x64": "0.25.11",
|
||||
"@esbuild/netbsd-arm64": "0.25.11",
|
||||
"@esbuild/netbsd-x64": "0.25.11",
|
||||
"@esbuild/openbsd-arm64": "0.25.11",
|
||||
"@esbuild/openbsd-x64": "0.25.11",
|
||||
"@esbuild/openharmony-arm64": "0.25.11",
|
||||
"@esbuild/sunos-x64": "0.25.11",
|
||||
"@esbuild/win32-arm64": "0.25.11",
|
||||
"@esbuild/win32-ia32": "0.25.11",
|
||||
"@esbuild/win32-x64": "0.25.11"
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild-wasm": {
|
||||
|
|
@ -4231,9 +4232,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/eslint-plugin-react-refresh": {
|
||||
"version": "0.4.23",
|
||||
"resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.23.tgz",
|
||||
"integrity": "sha512-G4j+rv0NmbIR45kni5xJOrYvCtyD3/7LjpVH8MPPcudXDcNu8gv+4ATTDXTtbRR8rTCM5HxECvCSsRmxKnWDsA==",
|
||||
"version": "0.4.24",
|
||||
"resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.24.tgz",
|
||||
"integrity": "sha512-nLHIW7TEq3aLrEYWpVaJ1dRgFR+wLDPN8e8FpYAql/bMV2oBEfC37K0gLEGgv9fy66juNShSMV8OkTqzltcG/w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
|
|
@ -6476,6 +6477,22 @@
|
|||
"react": "^19.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-hook-form": {
|
||||
"version": "7.65.0",
|
||||
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.65.0.tgz",
|
||||
"integrity": "sha512-xtOzDz063WcXvGWaHgLNrNzlsdFgtUWcb32E6WFaGTd7kPZG3EeDusjdZfUsPwKCKVXy1ZlntifaHZ4l8pAsmw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/react-hook-form"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17 || ^18 || ^19"
|
||||
}
|
||||
},
|
||||
"node_modules/react-hot-toast": {
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/react-hot-toast/-/react-hot-toast-2.6.0.tgz",
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
"version": "2025.10.14.2130",
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": "^22.15.0"
|
||||
"node": "^22.20.0"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "./dev_device.sh",
|
||||
|
|
@ -42,6 +42,7 @@
|
|||
"react-animate-height": "^3.2.3",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-hot-toast": "^2.6.0",
|
||||
"react-hook-form": "^7.65.0",
|
||||
"react-icons": "^5.5.0",
|
||||
"react-router": "^7.9.4",
|
||||
"react-simple-keyboard": "^3.8.130",
|
||||
|
|
|
|||
|
|
@ -1,8 +1,5 @@
|
|||
import {
|
||||
CheckCircleIcon,
|
||||
ExclamationTriangleIcon,
|
||||
InformationCircleIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
import { CloseButton } from "@headlessui/react";
|
||||
import { LuCircleAlert, LuInfo, LuTriangleAlert } from "react-icons/lu";
|
||||
|
||||
import { m } from "@localizations/messages.js";
|
||||
import { Button } from "@components/Button";
|
||||
|
|
@ -25,27 +22,23 @@ interface ConfirmDialogProps {
|
|||
|
||||
const variantConfig = {
|
||||
danger: {
|
||||
icon: ExclamationTriangleIcon,
|
||||
iconClass: "text-red-600",
|
||||
iconBgClass: "bg-red-100",
|
||||
icon: LuCircleAlert,
|
||||
iconClass: "text-red-600 dark:text-red-400",
|
||||
buttonTheme: "danger",
|
||||
},
|
||||
success: {
|
||||
icon: CheckCircleIcon,
|
||||
iconClass: "text-green-600",
|
||||
iconBgClass: "bg-green-100",
|
||||
icon: LuCircleAlert,
|
||||
iconClass: "text-emerald-600 dark:text-emerald-400",
|
||||
buttonTheme: "primary",
|
||||
},
|
||||
warning: {
|
||||
icon: ExclamationTriangleIcon,
|
||||
iconClass: "text-yellow-600",
|
||||
iconBgClass: "bg-yellow-100",
|
||||
buttonTheme: "lightDanger",
|
||||
icon: LuTriangleAlert,
|
||||
iconClass: "text-amber-600 dark:text-amber-400",
|
||||
buttonTheme: "primary",
|
||||
},
|
||||
info: {
|
||||
icon: InformationCircleIcon,
|
||||
iconClass: "text-blue-600",
|
||||
iconBgClass: "bg-blue-100",
|
||||
icon: LuInfo,
|
||||
iconClass: "text-slate-700 dark:text-slate-300",
|
||||
buttonTheme: "primary",
|
||||
},
|
||||
} as Record<
|
||||
|
|
@ -53,7 +46,6 @@ const variantConfig = {
|
|||
{
|
||||
icon: React.ElementType;
|
||||
iconClass: string;
|
||||
iconBgClass: string;
|
||||
buttonTheme: "danger" | "primary" | "blank" | "light" | "lightDanger";
|
||||
}
|
||||
>;
|
||||
|
|
@ -69,47 +61,50 @@ export function ConfirmDialog({
|
|||
onConfirm,
|
||||
isConfirming = false,
|
||||
}: ConfirmDialogProps) {
|
||||
const { icon: Icon, iconClass, iconBgClass, buttonTheme } = variantConfig[variant];
|
||||
const { icon: Icon, iconClass, buttonTheme } = variantConfig[variant];
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
if (e.key === "Escape") {
|
||||
e.stopPropagation();
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal open={open} onClose={onClose}>
|
||||
<div className="mx-auto max-w-xl px-4 transition-all duration-300 ease-in-out">
|
||||
<div className="pointer-events-auto relative w-full overflow-hidden rounded-lg bg-white p-6 text-left align-middle shadow-xl transition-all dark:bg-slate-800">
|
||||
<div className="space-y-4">
|
||||
<div className="sm:flex sm:items-start">
|
||||
<div
|
||||
className={cx(
|
||||
"mx-auto flex size-12 shrink-0 items-center justify-center rounded-full sm:mx-0 sm:size-10",
|
||||
iconBgClass,
|
||||
)}
|
||||
>
|
||||
<Icon aria-hidden="true" className={cx("size-6", iconClass)} />
|
||||
</div>
|
||||
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">
|
||||
<h2 className="text-lg leading-tight font-bold text-black dark:text-white">
|
||||
{title}
|
||||
</h2>
|
||||
<div className="mt-2 text-sm leading-snug text-slate-600 dark:text-slate-400">
|
||||
{description}
|
||||
<div onKeyDown={handleKeyDown}>
|
||||
<Modal open={open} onClose={onClose}>
|
||||
<div className="mx-auto max-w-md px-4 transition-all duration-300 ease-in-out">
|
||||
<div className="pointer-events-auto relative w-full overflow-hidden rounded-lg border border-slate-200 bg-white shadow-sm transition-all dark:border-slate-800 dark:bg-slate-900">
|
||||
<div className="p-6">
|
||||
<div className="flex items-start gap-3.5">
|
||||
<Icon aria-hidden="true" className={cx("size-[18px] shrink-0 mt-[2px]", iconClass)} />
|
||||
<div className="flex-1 min-w-0 space-y-2">
|
||||
<h2 className="font-semibold text-slate-950 dark:text-white">
|
||||
{title}
|
||||
</h2>
|
||||
<div className="text-sm text-slate-700 dark:text-slate-300">
|
||||
{description}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-x-2">
|
||||
{cancelText && (
|
||||
<Button size="SM" theme="blank" text={cancelText} onClick={onClose} />
|
||||
)}
|
||||
<Button
|
||||
size="SM"
|
||||
theme={buttonTheme}
|
||||
text={isConfirming ? `${confirmText}...` : confirmText}
|
||||
onClick={onConfirm}
|
||||
disabled={isConfirming}
|
||||
/>
|
||||
<div className="mt-6 flex justify-end gap-2">
|
||||
{cancelText && (
|
||||
<CloseButton as={Button} size="SM" theme="blank" text={cancelText} />
|
||||
)}
|
||||
<Button
|
||||
size="SM"
|
||||
type="button"
|
||||
theme={buttonTheme}
|
||||
text={isConfirming ? `${confirmText}...` : confirmText}
|
||||
onClick={onConfirm}
|
||||
disabled={isConfirming}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,21 +6,48 @@ import { LifeTimeLabel } from "@routes/devices.$id.settings.network";
|
|||
import { NetworkState } from "@hooks/stores";
|
||||
import { m } from "@localizations/messages.js";
|
||||
|
||||
import EmptyCard from "./EmptyCard";
|
||||
|
||||
export default function DhcpLeaseCard({
|
||||
networkState,
|
||||
setShowRenewLeaseConfirm,
|
||||
}: {
|
||||
networkState: NetworkState;
|
||||
networkState: NetworkState | null;
|
||||
setShowRenewLeaseConfirm: (show: boolean) => void;
|
||||
}) {
|
||||
const isDhcpLeaseEmpty = Object.keys(networkState?.dhcp_lease || {}).length === 0;
|
||||
|
||||
if (isDhcpLeaseEmpty) {
|
||||
return (
|
||||
<EmptyCard
|
||||
headline="No DHCP Lease information"
|
||||
description="We haven't received any DHCP lease information from the device yet."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<GridCard>
|
||||
<div className="animate-fadeIn p-4 opacity-0 animation-duration-500 text-black dark:text-white">
|
||||
<div className="animate-fadeIn p-4 text-black opacity-0 animation-duration-500 dark:text-white">
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-base font-bold text-slate-900 dark:text-white">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-base font-bold text-slate-900 dark:text-white">
|
||||
{m.dhcp_lease_header()}
|
||||
</h3>
|
||||
|
||||
<div>
|
||||
<Button
|
||||
size="XS"
|
||||
theme="light"
|
||||
type="button"
|
||||
className="text-red-500"
|
||||
text="Renew DHCP Lease"
|
||||
LeadingIcon={LuRefreshCcw}
|
||||
onClick={() => setShowRenewLeaseConfirm(true)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-x-6 gap-y-2">
|
||||
<div className="flex-1 space-y-2">
|
||||
{networkState?.dhcp_lease?.ip && (
|
||||
|
|
@ -45,13 +72,15 @@ export default function DhcpLeaseCard({
|
|||
</div>
|
||||
)}
|
||||
|
||||
{networkState?.dhcp_lease?.dns && (
|
||||
{networkState?.dhcp_lease?.dns_servers && (
|
||||
<div className="flex justify-between border-t border-slate-800/10 pt-2 dark:border-slate-300/20">
|
||||
<span className="text-sm text-slate-600 dark:text-slate-400">
|
||||
{m.dns_servers()}
|
||||
</span>
|
||||
<span className="text-right text-sm font-medium">
|
||||
{networkState?.dhcp_lease?.dns.map(dns => <div key={dns}>{dns}</div>)}
|
||||
{networkState?.dhcp_lease?.dns_servers.map(dns => (
|
||||
<div key={dns}>{dns}</div>
|
||||
))}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -143,6 +172,17 @@ export default function DhcpLeaseCard({
|
|||
</div>
|
||||
)}
|
||||
|
||||
{networkState?.dhcp_lease?.broadcast && (
|
||||
<div className="flex justify-between border-t border-slate-800/10 pt-2 dark:border-slate-300/20">
|
||||
<span className="text-sm text-slate-600 dark:text-slate-400">
|
||||
Broadcast
|
||||
</span>
|
||||
<span className="text-sm font-medium">
|
||||
{networkState?.dhcp_lease?.broadcast}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{networkState?.dhcp_lease?.mtu && (
|
||||
<div className="flex justify-between border-t border-slate-800/10 pt-2 dark:border-slate-300/20">
|
||||
<span className="text-sm text-slate-600 dark:text-slate-400">MTU</span>
|
||||
|
|
@ -193,18 +233,15 @@ export default function DhcpLeaseCard({
|
|||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{networkState?.dhcp_lease?.dhcp_client && (
|
||||
<div className="flex justify-between border-t border-slate-800/10 pt-2 dark:border-slate-300/20">
|
||||
<span className="text-sm text-slate-600 dark:text-slate-400">DHCP Client</span>
|
||||
<span className="text-sm font-medium">{networkState?.dhcp_lease?.dhcp_client}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Button
|
||||
size="SM"
|
||||
theme="light"
|
||||
className="text-red-500"
|
||||
text={m.dhcp_lease_renew()}
|
||||
LeadingIcon={LuRefreshCcw}
|
||||
onClick={() => setShowRenewLeaseConfirm(true)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</GridCard>
|
||||
|
|
|
|||
|
|
@ -1,12 +1,25 @@
|
|||
import { cx } from "@/cva.config";
|
||||
import { NetworkState } from "@hooks/stores";
|
||||
import { GridCard } from "@components/Card";
|
||||
import { LifeTimeLabel } from "@routes/devices.$id.settings.network";
|
||||
import { m } from "@localizations/messages.js";
|
||||
|
||||
export function FlagLabel({ flag, className }: { flag: string, className?: string }) {
|
||||
const classes = cx(
|
||||
"ml-2 rounded-sm bg-red-500 px-2 py-1 text-[10px] font-medium leading-none text-white dark:border",
|
||||
"bg-red-500 text-white dark:border-red-700 dark:bg-red-800 dark:text-red-50",
|
||||
className,
|
||||
);
|
||||
|
||||
return <span className={classes}>
|
||||
{flag}
|
||||
</span>
|
||||
}
|
||||
|
||||
export default function Ipv6NetworkCard({
|
||||
networkState,
|
||||
}: {
|
||||
networkState: NetworkState;
|
||||
networkState: NetworkState | undefined;
|
||||
}) {
|
||||
return (
|
||||
<GridCard>
|
||||
|
|
@ -17,72 +30,84 @@ export default function Ipv6NetworkCard({
|
|||
</h3>
|
||||
|
||||
<div className="grid grid-cols-2 gap-x-6 gap-y-2">
|
||||
{networkState?.ipv6_link_local && (
|
||||
<div className="flex flex-col justify-between">
|
||||
<span className="text-sm text-slate-600 dark:text-slate-400">
|
||||
{m.ipv6_link_local()}
|
||||
</span>
|
||||
<span className="text-sm font-medium">
|
||||
{networkState?.ipv6_link_local}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col justify-between">
|
||||
<span className="text-sm text-slate-600 dark:text-slate-400">
|
||||
{m.ipv6_link_local()}
|
||||
</span>
|
||||
<span className="text-sm font-medium">
|
||||
{networkState?.ipv6_link_local}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col justify-between">
|
||||
<span className="text-sm text-slate-600 dark:text-slate-400">
|
||||
{m.ipv6_gateway()}
|
||||
</span>
|
||||
<span className="text-sm font-medium">
|
||||
{networkState?.ipv6_gateway}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 pt-2">
|
||||
{networkState?.ipv6_addresses && networkState?.ipv6_addresses.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-semibold">IPv6 Addresses</h4>
|
||||
{networkState.ipv6_addresses.map(
|
||||
addr => (
|
||||
<div
|
||||
key={addr.address}
|
||||
className="rounded-md rounded-l-none border border-slate-500/10 border-l-blue-700/50 bg-white p-4 pl-4 backdrop-blur-sm dark:bg-transparent"
|
||||
>
|
||||
<div className="grid grid-cols-2 gap-x-8 gap-y-4">
|
||||
<div className="col-span-2 flex flex-col justify-between">
|
||||
<span className="text-sm text-slate-600 dark:text-slate-400">
|
||||
{m.ipv6_address_label()}
|
||||
{networkState.ipv6_addresses.map(addr => (
|
||||
<div
|
||||
key={addr.address}
|
||||
className="rounded-md rounded-l-none border border-slate-500/10 border-l-blue-700/50 bg-white p-4 pl-4 backdrop-blur-sm dark:bg-transparent"
|
||||
>
|
||||
<div className="grid grid-cols-2 gap-x-8 gap-y-4">
|
||||
<div className="col-span-2 flex flex-col justify-between">
|
||||
<span className="text-sm text-slate-600 dark:text-slate-400">
|
||||
{m.ipv6_address_label()}
|
||||
</span>
|
||||
<span className="text-sm font-medium flex">
|
||||
<span className="flex-1">{addr.address}</span>
|
||||
<span className="text-sm font-medium flex gap-x-1">
|
||||
{addr.flag_deprecated ? <FlagLabel flag="Deprecated" /> : null}
|
||||
{addr.flag_dad_failed ? <FlagLabel flag="DAD Failed" /> : null}
|
||||
</span>
|
||||
<span className="text-sm font-medium">{addr.address}</span>
|
||||
</div>
|
||||
|
||||
{addr.valid_lifetime && (
|
||||
<div className="flex flex-col justify-between">
|
||||
<span className="text-sm text-slate-600 dark:text-slate-400">
|
||||
{m.ipv6_valid_lifetime()}
|
||||
</span>
|
||||
<span className="text-sm font-medium">
|
||||
{addr.valid_lifetime === "" ? (
|
||||
<span className="text-slate-400 dark:text-slate-600">
|
||||
{m.not_available()}
|
||||
</span>
|
||||
) : (
|
||||
<LifeTimeLabel lifetime={`${addr.valid_lifetime}`} />
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{addr.preferred_lifetime && (
|
||||
<div className="flex flex-col justify-between">
|
||||
<span className="text-sm text-slate-600 dark:text-slate-400">
|
||||
{m.ipv6_preferred_lifetime()}
|
||||
</span>
|
||||
<span className="text-sm font-medium">
|
||||
{addr.preferred_lifetime === "" ? (
|
||||
<span className="text-slate-400 dark:text-slate-600">
|
||||
{m.not_available()}
|
||||
</span>
|
||||
) : (
|
||||
<LifeTimeLabel lifetime={`${addr.preferred_lifetime}`} />
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{addr.valid_lifetime && (
|
||||
<div className="flex flex-col justify-between">
|
||||
<span className="text-sm text-slate-600 dark:text-slate-400">
|
||||
{m.ipv6_valid_lifetime()}
|
||||
</span>
|
||||
<span className="text-sm font-medium">
|
||||
{addr.valid_lifetime === "" ? (
|
||||
<span className="text-slate-400 dark:text-slate-600">
|
||||
{m.not_applicable()}
|
||||
</span>
|
||||
) : (
|
||||
<LifeTimeLabel lifetime={`${addr.valid_lifetime}`} />
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{addr.preferred_lifetime && (
|
||||
<div className="flex flex-col justify-between">
|
||||
<span className="text-sm text-slate-600 dark:text-slate-400">
|
||||
{m.ipv6_preferred_lifetime()}
|
||||
</span>
|
||||
<span className="text-sm font-medium">
|
||||
{addr.preferred_lifetime === "" ? (
|
||||
<span className="text-slate-400 dark:text-slate-600">
|
||||
{m.not_applicable()}
|
||||
</span>
|
||||
) : (
|
||||
<LifeTimeLabel lifetime={`${addr.preferred_lifetime}`} />
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@ const basePresetDelays = [
|
|||
];
|
||||
|
||||
const PRESET_DELAYS = basePresetDelays.map(delay => {
|
||||
if (parseInt(delay.value, 10) === DEFAULT_DELAY) {
|
||||
if (Number.parseInt(delay.value, 10) === DEFAULT_DELAY) {
|
||||
return { ...delay, label: "Default" };
|
||||
}
|
||||
return delay;
|
||||
|
|
|
|||
|
|
@ -3,14 +3,19 @@ import { ReactNode } from "react";
|
|||
export function SettingsPageHeader({
|
||||
title,
|
||||
description,
|
||||
action,
|
||||
}: {
|
||||
title: string | ReactNode;
|
||||
description: string | ReactNode;
|
||||
action?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="select-none">
|
||||
<h2 className=" text-xl font-extrabold text-black dark:text-white">{title}</h2>
|
||||
<div className="text-sm text-black dark:text-slate-300">{description}</div>
|
||||
<div className="flex items-center justify-between gap-x-2 select-none">
|
||||
<div className="flex flex-col gap-y-1">
|
||||
<h2 className="text-xl font-extrabold text-black dark:text-white">{title}</h2>
|
||||
<div className="text-sm text-black dark:text-slate-300">{description}</div>
|
||||
</div>
|
||||
{action && <div className="">{action}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,137 @@
|
|||
import { LuPlus, LuX } from "react-icons/lu";
|
||||
import { useFieldArray, useFormContext } from "react-hook-form";
|
||||
import { useEffect } from "react";
|
||||
import validator from "validator";
|
||||
import { cx } from "cva";
|
||||
|
||||
import { GridCard } from "@/components/Card";
|
||||
import { Button } from "@/components/Button";
|
||||
import { InputFieldWithLabel } from "@/components/InputField";
|
||||
import { NetworkSettings } from "@/hooks/stores";
|
||||
import { netMaskFromCidr4 } from "@/utils/ip";
|
||||
|
||||
export default function StaticIpv4Card() {
|
||||
const formMethods = useFormContext<NetworkSettings>();
|
||||
const { register, formState, watch, setValue } = formMethods;
|
||||
|
||||
const { fields, append, remove } = useFieldArray({ name: "ipv4_static.dns" });
|
||||
|
||||
useEffect(() => {
|
||||
if (fields.length === 0) append("");
|
||||
}, [append, fields.length]);
|
||||
|
||||
const dns = watch("ipv4_static.dns");
|
||||
|
||||
const ipv4StaticAddress = watch("ipv4_static.address");
|
||||
const hideSubnetMask = ipv4StaticAddress?.includes("/");
|
||||
useEffect(() => {
|
||||
const parts = ipv4StaticAddress?.split("/", 2);
|
||||
if (parts?.length !== 2) return;
|
||||
|
||||
const cidrNotation = parseInt(parts?.[1] ?? "");
|
||||
if (isNaN(cidrNotation) || cidrNotation < 0 || cidrNotation > 32) return;
|
||||
|
||||
const mask = netMaskFromCidr4(cidrNotation);
|
||||
setValue("ipv4_static.netmask", mask);
|
||||
}, [ipv4StaticAddress, setValue]);
|
||||
|
||||
const validate = (value: string) => {
|
||||
if (!validator.isIP(value)) return "Invalid IP address";
|
||||
return true;
|
||||
};
|
||||
|
||||
const validateIsIPOrCIDR4 = (value: string) => {
|
||||
if (!validator.isIP(value, 4) && !validator.isIPRange(value, 4)) return "Invalid IP address or CIDR notation";
|
||||
return true;
|
||||
};
|
||||
|
||||
return (
|
||||
<GridCard>
|
||||
<div className="animate-fadeIn p-4 text-black opacity-0 animation-duration-500 dark:text-white">
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-base font-bold text-slate-900 dark:text-white">
|
||||
Static IPv4 Configuration
|
||||
</h3>
|
||||
|
||||
<div className={cx("grid grid-cols-1 gap-4", hideSubnetMask ? "md:grid-cols-1" : "md:grid-cols-2")}>
|
||||
<InputFieldWithLabel
|
||||
label="IP Address"
|
||||
type="text"
|
||||
size="SM"
|
||||
placeholder="192.168.1.100"
|
||||
{
|
||||
...register("ipv4_static.address", {
|
||||
validate: (value: string | undefined) => validateIsIPOrCIDR4(value ?? "")
|
||||
})}
|
||||
error={formState.errors.ipv4_static?.address?.message}
|
||||
/>
|
||||
|
||||
{!hideSubnetMask && <InputFieldWithLabel
|
||||
label="Subnet Mask"
|
||||
type="text"
|
||||
size="SM"
|
||||
placeholder="255.255.255.0"
|
||||
{...register("ipv4_static.netmask", { validate: (value: string | undefined) => validate(value ?? "") })}
|
||||
error={formState.errors.ipv4_static?.netmask?.message}
|
||||
/>}
|
||||
</div>
|
||||
|
||||
<InputFieldWithLabel
|
||||
label="Gateway"
|
||||
type="text"
|
||||
size="SM"
|
||||
placeholder="192.168.1.1"
|
||||
{...register("ipv4_static.gateway", { validate: (value: string | undefined) => validate(value ?? "") })}
|
||||
error={formState.errors.ipv4_static?.gateway?.message}
|
||||
/>
|
||||
|
||||
{/* DNS server fields */}
|
||||
<div className="space-y-4">
|
||||
{fields.map((dns, index) => {
|
||||
return (
|
||||
<div key={dns.id}>
|
||||
<div className="flex items-start gap-x-2">
|
||||
<div className="flex-1">
|
||||
<InputFieldWithLabel
|
||||
label={index === 0 ? "DNS Server" : null}
|
||||
type="text"
|
||||
size="SM"
|
||||
placeholder="1.1.1.1"
|
||||
{...register(
|
||||
`ipv4_static.dns.${index}`,
|
||||
{ validate: (value: string | undefined) => validate(value ?? "") }
|
||||
)}
|
||||
error={formState.errors.ipv4_static?.dns?.[index]?.message}
|
||||
/>
|
||||
</div>
|
||||
{index > 0 && (
|
||||
<div className="flex-shrink-0">
|
||||
<Button
|
||||
size="SM"
|
||||
theme="light"
|
||||
type="button"
|
||||
onClick={() => remove(index)}
|
||||
LeadingIcon={LuX}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
size="SM"
|
||||
theme="light"
|
||||
onClick={() => append("", { shouldFocus: true })}
|
||||
LeadingIcon={LuPlus}
|
||||
type="button"
|
||||
text="Add DNS Server"
|
||||
disabled={dns?.[0] === ""}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</GridCard>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,117 @@
|
|||
import { LuPlus, LuX } from "react-icons/lu";
|
||||
import { useFieldArray, useFormContext } from "react-hook-form";
|
||||
import validator from "validator";
|
||||
import { useEffect } from "react";
|
||||
|
||||
import { GridCard } from "@/components/Card";
|
||||
import { Button } from "@/components/Button";
|
||||
import { InputFieldWithLabel } from "@/components/InputField";
|
||||
import { NetworkSettings } from "@/hooks/stores";
|
||||
|
||||
export default function StaticIpv6Card() {
|
||||
const formMethods = useFormContext<NetworkSettings>();
|
||||
const { register, formState, watch } = formMethods;
|
||||
|
||||
const { fields, append, remove } = useFieldArray({ name: "ipv6_static.dns" });
|
||||
|
||||
useEffect(() => {
|
||||
if (fields.length === 0) append("");
|
||||
}, [append, fields.length]);
|
||||
|
||||
const dns = watch("ipv6_static.dns");
|
||||
|
||||
const cidrValidation = (value: string) => {
|
||||
if (value === "") return true;
|
||||
|
||||
// Check if it's a valid IPv6 address with CIDR notation
|
||||
const parts = value.split("/");
|
||||
if (parts.length !== 2) return "Please use CIDR notation (e.g., 2001:db8::1/64)";
|
||||
|
||||
const [address, prefix] = parts;
|
||||
if (!validator.isIP(address, 6)) return "Invalid IPv6 address";
|
||||
const prefixNum = parseInt(prefix);
|
||||
if (isNaN(prefixNum) || prefixNum < 0 || prefixNum > 128) {
|
||||
return "Prefix must be between 0 and 128";
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
const ipv6Validation = (value: string) => {
|
||||
if (!validator.isIP(value, 6)) return "Invalid IPv6 address";
|
||||
return true;
|
||||
};
|
||||
|
||||
return (
|
||||
<GridCard>
|
||||
<div className="animate-fadeIn p-4 text-black opacity-0 animation-duration-500 dark:text-white">
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-base font-bold text-slate-900 dark:text-white">
|
||||
Static IPv6 Configuration
|
||||
</h3>
|
||||
|
||||
<InputFieldWithLabel
|
||||
label="IP Prefix"
|
||||
type="text"
|
||||
size="SM"
|
||||
placeholder="2001:db8::1/64"
|
||||
{...register("ipv6_static.prefix", { validate: (value: string | undefined) => cidrValidation(value ?? "") })}
|
||||
error={formState.errors.ipv6_static?.prefix?.message}
|
||||
/>
|
||||
|
||||
<InputFieldWithLabel
|
||||
label="Gateway"
|
||||
type="text"
|
||||
size="SM"
|
||||
placeholder="2001:db8::1"
|
||||
{...register("ipv6_static.gateway", { validate: (value: string | undefined) => ipv6Validation(value ?? "") })}
|
||||
error={formState.errors.ipv6_static?.gateway?.message}
|
||||
/>
|
||||
|
||||
{/* DNS server fields */}
|
||||
<div className="space-y-4">
|
||||
{fields.map((dns, index) => {
|
||||
return (
|
||||
<div key={dns.id}>
|
||||
<div className="flex items-start gap-x-2">
|
||||
<div className="flex-1">
|
||||
<InputFieldWithLabel
|
||||
label={index === 0 ? "DNS Server" : null}
|
||||
type="text"
|
||||
size="SM"
|
||||
placeholder="2001:4860:4860::8888"
|
||||
{...register(`ipv6_static.dns.${index}`, { validate: (value: string | undefined) => ipv6Validation(value ?? "") })}
|
||||
error={formState.errors.ipv6_static?.dns?.[index]?.message}
|
||||
/>
|
||||
</div>
|
||||
{index > 0 && (
|
||||
<div className="flex-shrink-0">
|
||||
<Button
|
||||
size="SM"
|
||||
theme="light"
|
||||
type="button"
|
||||
onClick={() => remove(index)}
|
||||
LeadingIcon={LuX}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
size="SM"
|
||||
theme="light"
|
||||
onClick={() => append("", { shouldFocus: true })}
|
||||
LeadingIcon={LuPlus}
|
||||
type="button"
|
||||
text="Add DNS Server"
|
||||
disabled={dns?.[0] === ""}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</GridCard>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import React from "react";
|
||||
import React, { useEffect, useState, useRef } from "react";
|
||||
import { ExclamationTriangleIcon } from "@heroicons/react/24/solid";
|
||||
import { ArrowPathIcon, ArrowRightIcon } from "@heroicons/react/16/solid";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
|
|
@ -9,6 +9,11 @@ import { m } from "@localizations/messages.js";
|
|||
import { Button, LinkButton } from "@components/Button";
|
||||
import LoadingSpinner from "@components/LoadingSpinner";
|
||||
import Card, { GridCard } from "@components/Card";
|
||||
import { useRTCStore, PostRebootAction } from "@/hooks/stores";
|
||||
import LogoBlue from "@/assets/logo-blue.svg";
|
||||
import LogoWhite from "@/assets/logo-white.svg";
|
||||
import { isOnDevice } from "@/main";
|
||||
|
||||
|
||||
interface OverlayContentProps {
|
||||
readonly children: React.ReactNode;
|
||||
|
|
@ -388,3 +393,184 @@ export function PointerLockBar({ show }: PointerLockBarProps) {
|
|||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
|
||||
interface RebootingOverlayProps {
|
||||
readonly show: boolean;
|
||||
readonly postRebootAction: PostRebootAction;
|
||||
}
|
||||
|
||||
export function RebootingOverlay({ show, postRebootAction }: RebootingOverlayProps) {
|
||||
const { peerConnectionState } = useRTCStore();
|
||||
|
||||
// Check if we've already seen the connection drop (confirms reboot actually started)
|
||||
const [hasSeenDisconnect, setHasSeenDisconnect] = useState(
|
||||
['disconnected', 'closed', 'failed'].includes(peerConnectionState ?? '')
|
||||
);
|
||||
|
||||
// Track if we've timed out
|
||||
const [hasTimedOut, setHasTimedOut] = useState(false);
|
||||
|
||||
// Monitor for disconnect after reboot is initiated
|
||||
useEffect(() => {
|
||||
if (!show) return;
|
||||
if (hasSeenDisconnect) return;
|
||||
|
||||
if (['disconnected', 'closed', 'failed'].includes(peerConnectionState ?? '')) {
|
||||
console.log('hasSeenDisconnect', hasSeenDisconnect);
|
||||
setHasSeenDisconnect(true);
|
||||
}
|
||||
}, [show, peerConnectionState, hasSeenDisconnect]);
|
||||
|
||||
// Set timeout after 30 seconds
|
||||
useEffect(() => {
|
||||
if (!show) {
|
||||
setHasTimedOut(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const timeoutId = setTimeout(() => {
|
||||
setHasTimedOut(true);
|
||||
}, 30 * 1000);
|
||||
|
||||
return () => {
|
||||
clearTimeout(timeoutId);
|
||||
};
|
||||
}, [show]);
|
||||
|
||||
|
||||
// Poll suggested IP in device mode to detect when it's available
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
const isFetchingRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
// Only run in device mode with a postRebootAction
|
||||
if (!isOnDevice || !postRebootAction || !show || !hasSeenDisconnect) {
|
||||
return;
|
||||
}
|
||||
|
||||
const checkPostRebootHealth = async () => {
|
||||
// Don't start a new fetch if one is already in progress
|
||||
if (isFetchingRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Cancel any pending fetch
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
}
|
||||
|
||||
// Create new abort controller for this fetch
|
||||
const abortController = new AbortController();
|
||||
abortControllerRef.current = abortController;
|
||||
isFetchingRef.current = true;
|
||||
|
||||
console.log('Checking post-reboot health endpoint:', postRebootAction.healthCheck);
|
||||
const timeoutId = window.setTimeout(() => abortController.abort(), 2000);
|
||||
try {
|
||||
const response = await fetch(
|
||||
postRebootAction.healthCheck,
|
||||
{ signal: abortController.signal, }
|
||||
);
|
||||
|
||||
if (response.ok) {
|
||||
// Device is available, redirect to the specified URL
|
||||
console.log('Device is available, redirecting to:', postRebootAction.redirectUrl);
|
||||
window.location.href = postRebootAction.redirectUrl;
|
||||
}
|
||||
} catch (err) {
|
||||
// Ignore errors - they're expected while device is rebooting
|
||||
// Only log if it's not an abort error
|
||||
if (err instanceof Error && err.name !== 'AbortError') {
|
||||
console.debug('Error checking post-reboot health:', err);
|
||||
}
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
isFetchingRef.current = false;
|
||||
}
|
||||
};
|
||||
|
||||
// Start interval (check every 2 seconds)
|
||||
const intervalId = setInterval(checkPostRebootHealth, 2000);
|
||||
|
||||
// Also check immediately
|
||||
checkPostRebootHealth();
|
||||
|
||||
// Cleanup on unmount or when dependencies change
|
||||
return () => {
|
||||
clearInterval(intervalId);
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
}
|
||||
isFetchingRef.current = false;
|
||||
};
|
||||
}, [show, postRebootAction, hasTimedOut, hasSeenDisconnect]);
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{show && (
|
||||
<motion.div
|
||||
className="aspect-video h-full w-full"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0, transition: { duration: 0 } }}
|
||||
transition={{
|
||||
duration: 0.4,
|
||||
ease: "easeInOut",
|
||||
}}
|
||||
>
|
||||
<OverlayContent>
|
||||
|
||||
<div className="flex flex-col items-start gap-y-4 w-full max-w-md">
|
||||
<div className="h-[24px]">
|
||||
<img src={LogoBlue} alt="" className="h-full dark:hidden" />
|
||||
<img src={LogoWhite} alt="" className="hidden h-full dark:block" />
|
||||
</div>
|
||||
<div className="text-left text-sm text-slate-700 dark:text-slate-300">
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2 text-black dark:text-white">
|
||||
<h2 className="text-xl font-bold">{hasTimedOut ? "Unable to Reconnect" : "Device is Rebooting"}</h2>
|
||||
<p className="text-sm text-slate-700 dark:text-slate-300">
|
||||
{hasTimedOut ? (
|
||||
<>
|
||||
The device may have restarted with a different IP address. Check the JetKVM's physical display to find the current IP address and reconnect.
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Please wait while the device restarts. This usually takes 20-30 seconds.
|
||||
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-x-2">
|
||||
<Card>
|
||||
<div className="flex items-center gap-x-2 p-4">
|
||||
{!hasTimedOut ? (
|
||||
<>
|
||||
<LoadingSpinner className="h-4 w-4 text-blue-800 dark:text-blue-200" />
|
||||
<p className="text-sm text-slate-700 dark:text-slate-300">
|
||||
Waiting for device to restart...
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex flex-col gap-y-2">
|
||||
<div className="flex items-center gap-x-2">
|
||||
<ExclamationTriangleIcon className="h-5 w-5 text-yellow-500" />
|
||||
<p className="text-sm text-black dark:text-white">
|
||||
Automatic Reconnection Timed Out
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</OverlayContent>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ import { keys } from "@/keyboardMappings";
|
|||
import notifications from "@/notifications";
|
||||
import { m } from "@localizations/messages.js";
|
||||
|
||||
export default function WebRTCVideo() {
|
||||
export default function WebRTCVideo({ hasConnectionIssues }: { hasConnectionIssues: boolean }) {
|
||||
// Video and stream related refs and states
|
||||
const videoElm = useRef<HTMLVideoElement>(null);
|
||||
const { mediaStream, peerConnectionState } = useRTCStore();
|
||||
|
|
@ -528,9 +528,10 @@ export default function WebRTCVideo() {
|
|||
"max-h-full min-h-[384px] max-w-full min-w-[512px] bg-black/50 object-contain transition-all duration-1000",
|
||||
{
|
||||
"cursor-none": settings.isCursorHidden,
|
||||
"opacity-0":
|
||||
"!opacity-0":
|
||||
isVideoLoading ||
|
||||
hdmiError ||
|
||||
hasConnectionIssues ||
|
||||
peerConnectionState !== "connected",
|
||||
"opacity-60!": showPointerLockBar,
|
||||
"animate-slideUpFade border border-slate-800/30 shadow-xs dark:border-slate-300/20":
|
||||
|
|
@ -538,7 +539,7 @@ export default function WebRTCVideo() {
|
|||
},
|
||||
)}
|
||||
/>
|
||||
{peerConnection?.connectionState == "connected" && (
|
||||
{peerConnection?.connectionState == "connected" && !hasConnectionIssues && (
|
||||
<div
|
||||
style={{ animationDuration: "500ms" }}
|
||||
className="animate-slideUpFade pointer-events-none absolute inset-0 flex items-center justify-center"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,49 @@
|
|||
import { useCallback, useState } from "react";
|
||||
|
||||
export function useCopyToClipboard(resetInterval = 2000) {
|
||||
const [isCopied, setIsCopied] = useState(false);
|
||||
|
||||
const copy = useCallback(async (text: string) => {
|
||||
if (!text) return false;
|
||||
|
||||
let success = false;
|
||||
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
success = true;
|
||||
} catch (err) {
|
||||
console.warn("Clipboard API failed:", err);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback for insecure contexts
|
||||
if (!success) {
|
||||
const textarea = document.createElement("textarea");
|
||||
textarea.value = text;
|
||||
textarea.style.position = "fixed";
|
||||
textarea.style.opacity = "0";
|
||||
document.body.appendChild(textarea);
|
||||
textarea.focus();
|
||||
textarea.select();
|
||||
|
||||
try {
|
||||
success = document.execCommand("copy");
|
||||
} catch (err) {
|
||||
console.error("Fallback copy failed:", err);
|
||||
success = false;
|
||||
} finally {
|
||||
document.body.removeChild(textarea);
|
||||
}
|
||||
}
|
||||
|
||||
setIsCopied(success);
|
||||
if (success && resetInterval > 0) {
|
||||
setTimeout(() => setIsCopied(false), resetInterval);
|
||||
}
|
||||
|
||||
return success;
|
||||
}, [resetInterval]);
|
||||
|
||||
return { copy, isCopied };
|
||||
}
|
||||
|
|
@ -19,6 +19,11 @@ interface JsonRpcResponse {
|
|||
id: number | string | null;
|
||||
}
|
||||
|
||||
export type PostRebootAction = {
|
||||
healthCheck: string;
|
||||
redirectUrl: string;
|
||||
} | null;
|
||||
|
||||
// Utility function to append stats to a Map
|
||||
const appendStatToMap = <T extends { timestamp: number }>(
|
||||
stat: T,
|
||||
|
|
@ -69,6 +74,11 @@ export interface UIState {
|
|||
|
||||
terminalType: AvailableTerminalTypes;
|
||||
setTerminalType: (type: UIState["terminalType"]) => void;
|
||||
|
||||
rebootState: { isRebooting: boolean; postRebootAction: PostRebootAction } | null;
|
||||
setRebootState: (
|
||||
state: { isRebooting: boolean; postRebootAction: PostRebootAction } | null,
|
||||
) => void;
|
||||
}
|
||||
|
||||
export const useUiStore = create<UIState>(set => ({
|
||||
|
|
@ -82,7 +92,8 @@ export const useUiStore = create<UIState>(set => ({
|
|||
setDisableVideoFocusTrap: (enabled: boolean) => set({ disableVideoFocusTrap: enabled }),
|
||||
|
||||
isWakeOnLanModalVisible: false,
|
||||
setWakeOnLanModalVisibility: (enabled: boolean) => set({ isWakeOnLanModalVisible: enabled }),
|
||||
setWakeOnLanModalVisibility: (enabled: boolean) =>
|
||||
set({ isWakeOnLanModalVisible: enabled }),
|
||||
|
||||
toggleSidebarView: view =>
|
||||
set(state => {
|
||||
|
|
@ -96,6 +107,9 @@ export const useUiStore = create<UIState>(set => ({
|
|||
isAttachedVirtualKeyboardVisible: true,
|
||||
setAttachedVirtualKeyboardVisibility: (enabled: boolean) =>
|
||||
set({ isAttachedVirtualKeyboardVisible: enabled }),
|
||||
|
||||
rebootState: null,
|
||||
setRebootState: state => set({ rebootState: state }),
|
||||
}));
|
||||
|
||||
export interface RTCState {
|
||||
|
|
@ -677,6 +691,7 @@ export interface DhcpLease {
|
|||
timezone?: string;
|
||||
routers?: string[];
|
||||
dns?: string[];
|
||||
dns_servers?: string[];
|
||||
ntp_servers?: string[];
|
||||
lpr_servers?: string[];
|
||||
_time_servers?: string[];
|
||||
|
|
@ -694,6 +709,7 @@ export interface DhcpLease {
|
|||
message?: string;
|
||||
tftp?: string;
|
||||
bootfile?: string;
|
||||
dhcp_client?: string;
|
||||
}
|
||||
|
||||
export interface IPv6Address {
|
||||
|
|
@ -702,6 +718,15 @@ export interface IPv6Address {
|
|||
valid_lifetime: string;
|
||||
preferred_lifetime: string;
|
||||
scope: string;
|
||||
flags: number;
|
||||
flag_secondary?: boolean;
|
||||
flag_permanent?: boolean;
|
||||
flag_temporary?: boolean;
|
||||
flag_stable_privacy?: boolean;
|
||||
flag_deprecated?: boolean;
|
||||
flag_optimistic?: boolean;
|
||||
flag_dad_failed?: boolean;
|
||||
flag_tentative?: boolean;
|
||||
}
|
||||
|
||||
export interface NetworkState {
|
||||
|
|
@ -712,7 +737,9 @@ export interface NetworkState {
|
|||
ipv6?: string;
|
||||
ipv6_addresses?: IPv6Address[];
|
||||
ipv6_link_local?: string;
|
||||
ipv6_gateway?: string;
|
||||
dhcp_lease?: DhcpLease;
|
||||
hostname?: string;
|
||||
|
||||
setNetworkState: (state: NetworkState) => void;
|
||||
setDhcpLease: (lease: NetworkState["dhcp_lease"]) => void;
|
||||
|
|
@ -737,12 +764,28 @@ export type TimeSyncMode =
|
|||
| "custom"
|
||||
| "unknown";
|
||||
|
||||
export interface IPv4StaticConfig {
|
||||
address: string;
|
||||
netmask: string;
|
||||
gateway: string;
|
||||
dns: string[];
|
||||
}
|
||||
|
||||
export interface IPv6StaticConfig {
|
||||
prefix: string;
|
||||
gateway: string;
|
||||
dns: string[];
|
||||
}
|
||||
|
||||
export interface NetworkSettings {
|
||||
hostname: string;
|
||||
domain: string;
|
||||
http_proxy: string;
|
||||
dhcp_client: string;
|
||||
hostname: string | null;
|
||||
domain: string | null;
|
||||
http_proxy: string | null;
|
||||
ipv4_mode: IPv4Mode;
|
||||
ipv4_static?: IPv4StaticConfig;
|
||||
ipv6_mode: IPv6Mode;
|
||||
ipv6_static?: IPv6StaticConfig;
|
||||
lldp_mode: LLDPMode;
|
||||
lldp_tx_tlvs: string[];
|
||||
mdns_mode: mDNSMode;
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue