mirror of https://github.com/jetkvm/kvm.git
Compare commits
3 Commits
56a13da763
...
5b8f34f150
| Author | SHA1 | Date |
|---|---|---|
|
|
5b8f34f150 | |
|
|
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
|
||||
|
|
|
|||
107
cmd/main.go
107
cmd/main.go
|
|
@ -17,8 +17,8 @@ import (
|
|||
|
||||
const (
|
||||
envChildID = "JETKVM_CHILD_ID"
|
||||
errorDumpDir = "/userdata/jetkvm/"
|
||||
errorDumpStateFile = ".has_error_dump"
|
||||
errorDumpDir = "/userdata/jetkvm/crashdump"
|
||||
errorDumpLastFile = "last-crash.log"
|
||||
errorDumpTemplate = "jetkvm-%s.log"
|
||||
)
|
||||
|
||||
|
|
@ -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() {
|
||||
|
|
|
|||
88
config.go
88
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"
|
||||
|
|
@ -102,7 +103,7 @@ type Config struct {
|
|||
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"`
|
||||
NetworkConfig *types.NetworkConfig `json:"network_config"`
|
||||
DefaultLogLevel string `json:"default_log_level"`
|
||||
VideoSleepAfterSec int `json:"video_sleep_after_sec"`
|
||||
}
|
||||
|
|
@ -128,7 +129,32 @@ func (c *Config) SetDisplayRotation(rotation string) error {
|
|||
|
||||
const configPath = "/userdata/kvm_config.json"
|
||||
|
||||
var defaultConfig = &Config{
|
||||
// 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",
|
||||
}
|
||||
defaultUsbConfig = usbgadget.Config{
|
||||
VendorId: "0x1d6b", //The Linux Foundation
|
||||
ProductId: "0x0104", //Multifunction Composite Gadget
|
||||
SerialNumber: "",
|
||||
Manufacturer: "JetKVM",
|
||||
Product: "USB Emulation Device",
|
||||
}
|
||||
defaultUsbDevices = usbgadget.Devices{
|
||||
AbsoluteMouse: true,
|
||||
RelativeMouse: true,
|
||||
Keyboard: true,
|
||||
MassStorage: true,
|
||||
}
|
||||
)
|
||||
|
||||
func getDefaultConfig() Config {
|
||||
return Config{
|
||||
CloudURL: "https://api.jetkvm.com",
|
||||
CloudAppURL: "https://app.jetkvm.com",
|
||||
AutoUpdateEnabled: true, // Set a default value
|
||||
|
|
@ -141,28 +167,17 @@ var defaultConfig = &Config{
|
|||
DisplayOffAfterSec: 1800, // 30 minutes
|
||||
JigglerEnabled: false,
|
||||
// This is the "Standard" jiggler option in the UI
|
||||
JigglerConfig: &JigglerConfig{
|
||||
InactivityLimitSeconds: 60,
|
||||
JitterPercentage: 25,
|
||||
ScheduleCronTab: "0 * * * * *",
|
||||
Timezone: "UTC",
|
||||
},
|
||||
JigglerConfig: func() *JigglerConfig { c := defaultJigglerConfig; return &c }(),
|
||||
TLSMode: "",
|
||||
UsbConfig: &usbgadget.Config{
|
||||
VendorId: "0x1d6b", //The Linux Foundation
|
||||
ProductId: "0x0104", //Multifunction Composite Gadget
|
||||
SerialNumber: "",
|
||||
Manufacturer: "JetKVM",
|
||||
Product: "USB Emulation Device",
|
||||
},
|
||||
UsbDevices: &usbgadget.Devices{
|
||||
AbsoluteMouse: true,
|
||||
RelativeMouse: true,
|
||||
Keyboard: true,
|
||||
MassStorage: true,
|
||||
},
|
||||
NetworkConfig: &network.NetworkConfig{},
|
||||
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
|
||||
}
|
||||
|
||||
|
|
|
|||
40
display.go
40
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,12 +340,9 @@ func startBacklightTickers() {
|
|||
dimTicker = time.NewTicker(time.Duration(config.DisplayDimAfterSec) * time.Second)
|
||||
|
||||
go func() {
|
||||
for { //nolint:staticcheck
|
||||
select {
|
||||
case <-dimTicker.C:
|
||||
for range dimTicker.C {
|
||||
tick_displayDim()
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
|
|
@ -340,12 +351,9 @@ func startBacklightTickers() {
|
|||
offTicker = time.NewTicker(time.Duration(config.DisplayOffAfterSec) * time.Second)
|
||||
|
||||
go func() {
|
||||
for { //nolint:staticcheck
|
||||
select {
|
||||
case <-offTicker.C:
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ 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"`
|
||||
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"`
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
@ -152,6 +163,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]
|
||||
}
|
||||
|
||||
func (l *Lease) ToJSON() string {
|
||||
json, err := json.Marshal(l)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return string(json)
|
||||
}
|
||||
|
||||
func (l *Lease) SetLeaseExpiry() (time.Time, error) {
|
||||
if l.Uptime == 0 || l.LeaseTime == 0 {
|
||||
return time.Time{}, fmt.Errorf("uptime or lease time isn't set")
|
||||
}
|
||||
|
||||
// get the uptime of the device
|
||||
|
||||
file, err := os.Open("/proc/uptime")
|
||||
if err != nil {
|
||||
return time.Time{}, fmt.Errorf("failed to open uptime file: %w", err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
var uptime time.Duration
|
||||
|
||||
scanner := bufio.NewScanner(file)
|
||||
for scanner.Scan() {
|
||||
text := scanner.Text()
|
||||
parts := strings.Split(text, " ")
|
||||
uptime, err = time.ParseDuration(parts[0] + "s")
|
||||
|
||||
if err != nil {
|
||||
return time.Time{}, fmt.Errorf("failed to parse uptime: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
relativeLeaseRemaining := (l.Uptime + l.LeaseTime) - uptime
|
||||
leaseExpiry := time.Now().Add(relativeLeaseRemaining)
|
||||
|
||||
l.LeaseExpiry = &leaseExpiry
|
||||
|
||||
return leaseExpiry, nil
|
||||
}
|
||||
|
||||
func UnmarshalDHCPCLease(lease *Lease, str string) error {
|
||||
// parse the lease file as a map
|
||||
data := make(map[string]string)
|
||||
for line := range strings.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)
|
||||
|
||||
// IPMask returns the IP mask for the DHCP lease
|
||||
func (d *DHCPLease) IPMask() net.IPMask {
|
||||
if d.IsIPv6() {
|
||||
// TODO: not implemented
|
||||
return nil
|
||||
}
|
||||
|
||||
mask := net.ParseIP(d.Netmask.String())
|
||||
return net.IPv4Mask(mask[12], mask[13], mask[14], mask[15])
|
||||
}
|
||||
|
||||
// IPNet returns the IP net for the DHCP lease
|
||||
func (d *DHCPLease) IPNet() *net.IPNet {
|
||||
if d.IsIPv6() {
|
||||
// TODO: not implemented
|
||||
return nil
|
||||
}
|
||||
|
||||
return &net.IPNet{
|
||||
IP: d.IPAddress,
|
||||
Mask: d.IPMask(),
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
14
mdns.go
14
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(),
|
||||
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
|
||||
|
||||
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()
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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}')
|
||||
|
|
@ -107,10 +107,10 @@
|
|||
"already_adopted_title": "Enheden er allerede registreret",
|
||||
"appearance_description": "Vælg dit foretrukne farvetema",
|
||||
"appearance_page_description": "Tilpas udseendet og følelsen af din JetKVM-grænseflade",
|
||||
"appearance_theme": "Tema",
|
||||
"appearance_theme_dark": "Mørk",
|
||||
"appearance_theme_light": "Lys",
|
||||
"appearance_theme_system": "System",
|
||||
"appearance_theme": "Tema",
|
||||
"appearance_title": "Udseende",
|
||||
"attach": "Vedhæft",
|
||||
"atx_power_control_get_state_error": "Kunne ikke hente ATX-strømtilstand: {error}",
|
||||
|
|
@ -121,85 +121,85 @@
|
|||
"atx_power_control_reset_button": "Nulstil",
|
||||
"atx_power_control_send_action_error": "Kunne ikke sende ATX-strømfunktion {action} : {error}",
|
||||
"atx_power_control_short_power_button": "Kort tryk",
|
||||
"auth_authentication_mode": "Vælg venligst en godkendelsestilstand",
|
||||
"auth_authentication_mode_error": "Der opstod en fejl under indstilling af godkendelsestilstanden",
|
||||
"auth_authentication_mode_invalid": "Ugyldig godkendelsestilstand",
|
||||
"auth_authentication_mode": "Vælg venligst en godkendelsestilstand",
|
||||
"auth_connect_to_cloud": "Tilslut din JetKVM til skyen",
|
||||
"auth_connect_to_cloud_action": "Log ind og tilslut enhed",
|
||||
"auth_connect_to_cloud_description": "Lås op for fjernadgang og avancerede funktioner på din enhed",
|
||||
"auth_connect_to_cloud": "Tilslut din JetKVM til skyen",
|
||||
"auth_header_cta_already_have_account": "Har du allerede en konto?",
|
||||
"auth_header_cta_dont_have_account": "Har du ikke en konto?",
|
||||
"auth_header_cta_new_to_jetkvm": "Ny bruger af JetKVM?",
|
||||
"auth_login": "Log ind på din JetKVM-konto",
|
||||
"auth_login_action": "Log ind",
|
||||
"auth_login_description": "Log ind for at få adgang til og administrere dine enheder sikkert",
|
||||
"auth_login": "Log ind på din JetKVM-konto",
|
||||
"auth_mode_local": "Lokal godkendelsesmetode",
|
||||
"auth_mode_local_change_later": "Du kan altid ændre din godkendelsesmetode senere i indstillingerne.",
|
||||
"auth_mode_local_description": "Vælg, hvordan du vil sikre din JetKVM-enhed lokalt.",
|
||||
"auth_mode_local_no_password_description": "Hurtig adgang uden adgangskodegodkendelse.",
|
||||
"auth_mode_local_no_password": "Ingen adgangskode",
|
||||
"auth_mode_local_no_password_description": "Hurtig adgang uden adgangskodegodkendelse.",
|
||||
"auth_mode_local_password": "Adgangskode",
|
||||
"auth_mode_local_password_confirm_description": "Bekræft din adgangskode",
|
||||
"auth_mode_local_password_confirm_label": "Bekræft adgangskode",
|
||||
"auth_mode_local_password_description": "Sikr din enhed med en adgangskode for ekstra beskyttelse.",
|
||||
"auth_mode_local_password_do_not_match": "Adgangskoderne stemmer ikke overens",
|
||||
"auth_mode_local_password_failed_set": "Kunne ikke angive adgangskode: {error}",
|
||||
"auth_mode_local_password_note_local": "Alle data forbliver på din lokale enhed.",
|
||||
"auth_mode_local_password_note": "Denne adgangskode vil blive brugt til at sikre dine enhedsdata og beskytte mod uautoriseret adgang.",
|
||||
"auth_mode_local_password_note_local": "Alle data forbliver på din lokale enhed.",
|
||||
"auth_mode_local_password_set": "Indstil en adgangskode",
|
||||
"auth_mode_local_password_set_button": "Indstil adgangskode",
|
||||
"auth_mode_local_password_set_description": "Opret en stærk adgangskode for at sikre din JetKVM-enhed lokalt.",
|
||||
"auth_mode_local_password_set_label": "Indtast en adgangskode",
|
||||
"auth_mode_local_password_set": "Indstil en adgangskode",
|
||||
"auth_mode_local_password": "Adgangskode",
|
||||
"auth_mode_local": "Lokal godkendelsesmetode",
|
||||
"auth_signup_connect_to_cloud_action": "Tilmeld og tilslut enhed",
|
||||
"auth_signup_create_account": "Opret din JetKVM-konto",
|
||||
"auth_signup_create_account_action": "Opret konto",
|
||||
"auth_signup_create_account_description": "Opret din konto, og begynd nemt at administrere dine enheder.",
|
||||
"auth_signup_create_account": "Opret din JetKVM-konto",
|
||||
"back_to_devices": "Tilbage til Enheder",
|
||||
"back": "Tilbage",
|
||||
"back_to_devices": "Tilbage til Enheder",
|
||||
"cancel": "Ophæve",
|
||||
"close": "Tæt",
|
||||
"cloud_kvms_description": "Administrer dine cloud-KVM'er, og opret forbindelse til dem sikkert.",
|
||||
"cloud_kvms_no_devices_description": "Du har endnu ingen enheder med aktiveret JetKVM Cloud.",
|
||||
"cloud_kvms_no_devices": "Ingen enheder fundet",
|
||||
"cloud_kvms": "Cloud KVM'er",
|
||||
"cloud_kvms_description": "Administrer dine cloud-KVM'er, og opret forbindelse til dem sikkert.",
|
||||
"cloud_kvms_no_devices": "Ingen enheder fundet",
|
||||
"cloud_kvms_no_devices_description": "Du har endnu ingen enheder med aktiveret JetKVM Cloud.",
|
||||
"confirm": "Bekræfte",
|
||||
"connect_to_kvm": "Opret forbindelse til KVM",
|
||||
"connecting_to_device": "Forbinder til enhed…",
|
||||
"connection_established": "Forbindelse etableret",
|
||||
"connection_stats_badge_jitter_buffer_avg_delay": "Jitterbuffer gennemsnitlig forsinkelse",
|
||||
"connection_stats_badge_jitter": "Jitter",
|
||||
"connection_stats_connection_description": "Forbindelsen mellem klienten og JetKVM'en.",
|
||||
"connection_stats_badge_jitter_buffer_avg_delay": "Jitterbuffer gennemsnitlig forsinkelse",
|
||||
"connection_stats_connection": "Forbindelse",
|
||||
"connection_stats_frames_per_second_description": "Antal indgående videobilleder vist pr. sekund.",
|
||||
"connection_stats_connection_description": "Forbindelsen mellem klienten og JetKVM'en.",
|
||||
"connection_stats_frames_per_second": "Billeder per sekund",
|
||||
"connection_stats_network_stability_description": "Hvor stabil strømmen af indgående videopakker er på tværs af netværket.",
|
||||
"connection_stats_frames_per_second_description": "Antal indgående videobilleder vist pr. sekund.",
|
||||
"connection_stats_network_stability": "Netværksstabilitet",
|
||||
"connection_stats_packets_lost_description": "Antal mistede indgående video-RTP-pakker.",
|
||||
"connection_stats_network_stability_description": "Hvor stabil strømmen af indgående videopakker er på tværs af netværket.",
|
||||
"connection_stats_packets_lost": "Pakker mistet",
|
||||
"connection_stats_playback_delay_description": "Forsinkelse tilføjet af jitterbufferen for at jævne afspilningen, når billeder ankommer ujævnt.",
|
||||
"connection_stats_packets_lost_description": "Antal mistede indgående video-RTP-pakker.",
|
||||
"connection_stats_playback_delay": "Afspilningsforsinkelse",
|
||||
"connection_stats_round_trip_time_description": "Rundrejsetid for det aktive ICE-kandidatpar mellem peers.",
|
||||
"connection_stats_playback_delay_description": "Forsinkelse tilføjet af jitterbufferen for at jævne afspilningen, når billeder ankommer ujævnt.",
|
||||
"connection_stats_round_trip_time": "Rundturstid",
|
||||
"connection_stats_round_trip_time_description": "Rundrejsetid for det aktive ICE-kandidatpar mellem peers.",
|
||||
"connection_stats_sidebar": "Forbindelsesstatistik",
|
||||
"connection_stats_video_description": "Videostreamen fra JetKVM'en til klienten.",
|
||||
"connection_stats_video": "Video",
|
||||
"connection_stats_video_description": "Videostreamen fra JetKVM'en til klienten.",
|
||||
"continue": "Fortsætte",
|
||||
"creating_peer_connection": "Opretter peer-forbindelse…",
|
||||
"dc_power_control_current_unit": "EN",
|
||||
"dc_power_control_current": "Strøm",
|
||||
"dc_power_control_current_unit": "EN",
|
||||
"dc_power_control_get_state_error": "Kunne ikke hente DC-strømtilstand: {error}",
|
||||
"dc_power_control_power": "Magt",
|
||||
"dc_power_control_power_off_button": "Sluk",
|
||||
"dc_power_control_power_off_state": "Sluk",
|
||||
"dc_power_control_power_on_button": "Tænd",
|
||||
"dc_power_control_power_on_state": "Tænd",
|
||||
"dc_power_control_power_unit": "V",
|
||||
"dc_power_control_power": "Magt",
|
||||
"dc_power_control_restore_last_state": "Sidste stat",
|
||||
"dc_power_control_restore_power_state": "Gendan strømtab",
|
||||
"dc_power_control_set_power_state_error": "Kunne ikke sende DC-strømstatus til {enabled} : {error}",
|
||||
"dc_power_control_set_restore_state_error": "Kunne ikke sende DC-strømgendannelsesstatus til {state} : {error}",
|
||||
"dc_power_control_voltage_unit": "V",
|
||||
"dc_power_control_voltage": "Spænding",
|
||||
"dc_power_control_voltage_unit": "V",
|
||||
"delete": "Slet",
|
||||
"deregister_button": "Afregistrering fra Cloud",
|
||||
"deregister_cloud_devices": "Cloud-enheder",
|
||||
|
|
@ -226,12 +226,12 @@
|
|||
"extension_popover_load_and_manage_extensions": "Indlæs og administrer dine udvidelser",
|
||||
"extension_popover_set_error_notification": "Kunne ikke angive aktiv udvidelse: {error}",
|
||||
"extension_popover_unload_extension": "Fjern udvidelse",
|
||||
"extension_serial_console_description": "Få adgang til din serielle konsoludvidelse",
|
||||
"extension_serial_console": "Seriel konsol",
|
||||
"extensions_atx_power_control_description": "Styr din maskines strømtilstand via ATX-strømstyring.",
|
||||
"extension_serial_console_description": "Få adgang til din serielle konsoludvidelse",
|
||||
"extensions_atx_power_control": "ATX-strømstyring",
|
||||
"extensions_dc_power_control_description": "Styr din DC-strømforlænger",
|
||||
"extensions_atx_power_control_description": "Styr din maskines strømtilstand via ATX-strømstyring.",
|
||||
"extensions_dc_power_control": "DC-strømstyring",
|
||||
"extensions_dc_power_control_description": "Styr din DC-strømforlænger",
|
||||
"extensions_popover_extensions": "Udvidelser",
|
||||
"gathering_ice_candidates": "Samler ICE-kandidater…",
|
||||
"general_app_version": "App: {version}",
|
||||
|
|
@ -241,8 +241,8 @@
|
|||
"general_check_for_updates": "Tjek for opdateringer",
|
||||
"general_page_description": "Konfigurer enhedsindstillinger og opdater præferencer",
|
||||
"general_reboot_description": "Vil du fortsætte med at genstarte systemet?",
|
||||
"general_reboot_device_description": "Sluk og tænd for JetKVM'en",
|
||||
"general_reboot_device": "Genstart enhed",
|
||||
"general_reboot_device_description": "Sluk og tænd for JetKVM'en",
|
||||
"general_reboot_no_button": "Ingen",
|
||||
"general_reboot_title": "Genstart JetKVM",
|
||||
"general_reboot_yes_button": "Ja",
|
||||
|
|
@ -295,9 +295,9 @@
|
|||
"hardware_display_orientation_title": "Skærmretning",
|
||||
"hardware_display_wake_up_note": "Skærmen vågner op, når forbindelsestilstanden ændres, eller når den berøres.",
|
||||
"hardware_page_description": "Konfigurer skærmindstillinger og hardwareindstillinger for din JetKVM-enhed",
|
||||
"hardware_time_10_minutes": "10 minutter",
|
||||
"hardware_time_1_hour": "1 time",
|
||||
"hardware_time_1_minute": "1 minut",
|
||||
"hardware_time_10_minutes": "10 minutter",
|
||||
"hardware_time_30_minutes": "30 minutter",
|
||||
"hardware_time_5_minutes": "5 minutter",
|
||||
"hardware_time_never": "Aldrig",
|
||||
|
|
@ -327,6 +327,7 @@
|
|||
"invalid_password": "Ugyldig adgangskode",
|
||||
"ip_address": "IP-adresse",
|
||||
"ipv6_address_label": "Adresse",
|
||||
"ipv6_gateway": "Gateway",
|
||||
"ipv6_information": "IPv6-oplysninger",
|
||||
"ipv6_link_local": "Link-lokal",
|
||||
"ipv6_preferred_lifetime": "Foretrukken levetid",
|
||||
|
|
@ -409,8 +410,8 @@
|
|||
"log_in": "Log ind",
|
||||
"log_out": "Log ud",
|
||||
"logged_in_as": "Logget ind som",
|
||||
"login_enter_password_description": "Indtast din adgangskode for at få adgang til din JetKVM.",
|
||||
"login_enter_password": "Indtast din adgangskode",
|
||||
"login_enter_password_description": "Indtast din adgangskode for at få adgang til din JetKVM.",
|
||||
"login_error": "Der opstod en fejl under login",
|
||||
"login_forgot_password": "Glemt adgangskode?",
|
||||
"login_password_label": "Adgangskode",
|
||||
|
|
@ -424,8 +425,8 @@
|
|||
"macro_name_required": "Navn er påkrævet",
|
||||
"macro_name_too_long": "Navnet skal være mindre end 50 tegn",
|
||||
"macro_please_fix_validation_errors": "Ret venligst valideringsfejlene",
|
||||
"macro_save_error": "Der opstod en fejl under lagring.",
|
||||
"macro_save": "Gem makro",
|
||||
"macro_save_error": "Der opstod en fejl under lagring.",
|
||||
"macro_step_count": "{steps} / {max} trin",
|
||||
"macro_step_duration_description": "Tid til at vente, før man udfører det næste trin.",
|
||||
"macro_step_duration_label": "Trinvarighed",
|
||||
|
|
@ -439,8 +440,8 @@
|
|||
"macro_steps_description": "Taster/modifikatorer udføres i rækkefølge med en forsinkelse mellem hvert trin.",
|
||||
"macro_steps_label": "Trin",
|
||||
"macros_add_description": "Opret en ny tastaturmakro",
|
||||
"macros_add_new_macro": "Tilføj ny makro",
|
||||
"macros_add_new": "Tilføj ny makro",
|
||||
"macros_add_new_macro": "Tilføj ny makro",
|
||||
"macros_aria_add_new": "Tilføj ny makro",
|
||||
"macros_aria_delete": "Slet makro {name}",
|
||||
"macros_aria_duplicate": "Dupliker makro {name}",
|
||||
|
|
@ -462,14 +463,14 @@
|
|||
"macros_edit_button": "Redigere",
|
||||
"macros_edit_description": "Rediger din tastaturmakro",
|
||||
"macros_edit_title": "Rediger makro",
|
||||
"macros_failed_create_error": "Kunne ikke oprette makro: {error}",
|
||||
"macros_failed_create": "Kunne ikke oprette makro",
|
||||
"macros_failed_delete_error": "Kunne ikke slette makroen: {error}",
|
||||
"macros_failed_create_error": "Kunne ikke oprette makro: {error}",
|
||||
"macros_failed_delete": "Makroen kunne ikke slettes",
|
||||
"macros_failed_duplicate_error": "Kunne ikke duplikere makro: {error}",
|
||||
"macros_failed_delete_error": "Kunne ikke slette makroen: {error}",
|
||||
"macros_failed_duplicate": "Makroen kunne ikke duplikeres",
|
||||
"macros_failed_reorder_error": "Kunne ikke omarrangere makroer: {error}",
|
||||
"macros_failed_duplicate_error": "Kunne ikke duplikere makro: {error}",
|
||||
"macros_failed_reorder": "Kunne ikke omarrangere makroer",
|
||||
"macros_failed_reorder_error": "Kunne ikke omarrangere makroer: {error}",
|
||||
"macros_failed_update": "Makroen kunne ikke opdateres",
|
||||
"macros_failed_update_error": "Kunne ikke opdatere makroen: {error}",
|
||||
"macros_invalid_data": "Ugyldige makrodata",
|
||||
|
|
@ -506,8 +507,8 @@
|
|||
"mount_error_list_storage": "Fejl ved liste over lagerfiler: {error}",
|
||||
"mount_error_title": "Monteringsfejl",
|
||||
"mount_get_state_error": "Kunne ikke hente virtuel medietilstand: {error}",
|
||||
"mount_jetkvm_storage_description": "Monter tidligere uploadede filer fra JetKVM-lageret",
|
||||
"mount_jetkvm_storage": "JetKVM-lagerbeslag",
|
||||
"mount_jetkvm_storage_description": "Monter tidligere uploadede filer fra JetKVM-lageret",
|
||||
"mount_mode_cdrom": "CD/DVD",
|
||||
"mount_mode_disk": "Disk",
|
||||
"mount_mounted_as": "Monteret som",
|
||||
|
|
@ -520,8 +521,8 @@
|
|||
"mount_popular_images": "Populære billeder",
|
||||
"mount_streaming_from_url": "Streaming fra URL",
|
||||
"mount_supported_formats": "Understøttede formater: ISO, IMG",
|
||||
"mount_unmount_error": "Kunne ikke afmontere billede: {error}",
|
||||
"mount_unmount": "Afmonter",
|
||||
"mount_unmount_error": "Kunne ikke afmontere billede: {error}",
|
||||
"mount_upload_description": "Vælg en billedfil, der skal uploades til JetKVM-lageret",
|
||||
"mount_upload_error": "Uploadfejl: {error}",
|
||||
"mount_upload_failed_datachannel": "Kunne ikke oprette datakanal til filupload",
|
||||
|
|
@ -529,8 +530,8 @@
|
|||
"mount_upload_successful": "Uploaden er gennemført",
|
||||
"mount_upload_title": "Upload nyt billede",
|
||||
"mount_uploaded_has_been_uploaded": "{name} er blevet uploadet",
|
||||
"mount_uploading_with_name": "Uploader {name}",
|
||||
"mount_uploading": "Uploader…",
|
||||
"mount_uploading_with_name": "Uploader {name}",
|
||||
"mount_url_description": "Monter filer fra enhver offentlig webadresse",
|
||||
"mount_url_input_label": "Billed-URL",
|
||||
"mount_url_mount": "URL-montering",
|
||||
|
|
@ -538,10 +539,10 @@
|
|||
"mount_view_device_title": "Monter fra JetKVM-lager",
|
||||
"mount_view_url_description": "Indtast en URL til den billedfil, der skal monteres",
|
||||
"mount_view_url_title": "Monter fra URL",
|
||||
"mount_virtual_media_description": "Monter et billede for at starte fra eller installere et operativsystem.",
|
||||
"mount_virtual_media_source_description": "Vælg hvordan du vil montere dine virtuelle medier",
|
||||
"mount_virtual_media_source": "Virtuel mediekilde",
|
||||
"mount_virtual_media": "Virtuelle medier",
|
||||
"mount_virtual_media_description": "Monter et billede for at starte fra eller installere et operativsystem.",
|
||||
"mount_virtual_media_source": "Virtuel mediekilde",
|
||||
"mount_virtual_media_source_description": "Vælg hvordan du vil montere dine virtuelle medier",
|
||||
"mouse_alt_finger": "Fingerberøring af en skærm",
|
||||
"mouse_alt_mouse": "Musikon",
|
||||
"mouse_description": "Konfigurer markørens adfærd og interaktionsindstillinger for din enhed",
|
||||
|
|
@ -558,10 +559,10 @@
|
|||
"mouse_jiggler_light": "Lys - 5m",
|
||||
"mouse_jiggler_standard": "Standard - 1 m",
|
||||
"mouse_jiggler_title": "Jiggler",
|
||||
"mouse_mode_absolute_description": "Mest bekvemme",
|
||||
"mouse_mode_absolute": "Absolut",
|
||||
"mouse_mode_relative_description": "Mest kompatible",
|
||||
"mouse_mode_absolute_description": "Mest bekvemme",
|
||||
"mouse_mode_relative": "Relativ",
|
||||
"mouse_mode_relative_description": "Mest kompatible",
|
||||
"mouse_modes_description": "Vælg musens inputtilstand",
|
||||
"mouse_modes_title": "Tilstande",
|
||||
"mouse_scroll_high": "Høj",
|
||||
|
|
@ -574,12 +575,17 @@
|
|||
"mouse_title": "Mus",
|
||||
"network_custom_domain": "Brugerdefineret domæne",
|
||||
"network_description": "Konfigurer dine netværksindstillinger",
|
||||
"network_dhcp_client_description": "Konfigurer hvilken DHCP-klient der skal bruges",
|
||||
"network_dhcp_client_jetkvm": "JetKVM Intern",
|
||||
"network_dhcp_client_title": "DHCP-klient",
|
||||
"network_dhcp_information": "DHCP-oplysninger",
|
||||
"network_dhcp_lease_renew_confirm_description": "Dette vil anmode om en ny IP-adresse fra din DHCP-server. Din enhed kan midlertidigt miste netværksforbindelsen under denne proces.",
|
||||
"network_dhcp_lease_renew": "Forny DHCP-lease",
|
||||
"network_dhcp_lease_renew_confirm": "Forny lejekontrakt",
|
||||
"network_dhcp_lease_renew_confirm_description": "Dette vil anmode om en ny IP-adresse fra din DHCP-server. Din enhed kan midlertidigt miste netværksforbindelsen under denne proces.",
|
||||
"network_dhcp_lease_renew_confirm_new_a": "Hvis du modtager en ny IP-adresse",
|
||||
"network_dhcp_lease_renew_confirm_new_b": "du skal muligvis genoprette forbindelsen ved hjælp af den nye adresse",
|
||||
"network_dhcp_lease_renew_failed": "Kunne ikke forny leasing: {error}",
|
||||
"network_dhcp_lease_renew_success": "DHCP-lease fornyet",
|
||||
"network_dhcp_lease_renew": "Forny DHCP-lease",
|
||||
"network_domain_custom": "Skik",
|
||||
"network_domain_description": "Netværksdomænesuffiks for enheden",
|
||||
"network_domain_dhcp_provided": "DHCP leveret",
|
||||
|
|
@ -588,21 +594,35 @@
|
|||
"network_hostname_description": "Enhedsidentifikator på netværket. Tom for systemstandard",
|
||||
"network_hostname_title": "Værtsnavn",
|
||||
"network_http_proxy_description": "Proxyserver til udgående HTTP(S)-anmodninger fra enheden. Tom, hvis ingen er til stede.",
|
||||
"network_http_proxy_invalid": "Ugyldig HTTP-proxy-URL",
|
||||
"network_http_proxy_title": "HTTP-proxy",
|
||||
"network_ipv4_address": "IPv4-adresse",
|
||||
"network_ipv4_dns": "IPv4 DNS",
|
||||
"network_ipv4_gateway": "IPv4-gateway",
|
||||
"network_ipv4_mode_description": "Konfigurer IPv4-tilstanden",
|
||||
"network_ipv4_mode_dhcp": "DHCP",
|
||||
"network_ipv4_mode_static": "Statisk",
|
||||
"network_ipv4_mode_title": "IPv4-tilstand",
|
||||
"network_ipv4_netmask": "IPv4-netmaske",
|
||||
"network_ipv6_address": "IPv6-adresse",
|
||||
"network_ipv6_information": "IPv6-oplysninger",
|
||||
"network_ipv6_mode_description": "Konfigurer IPv6-tilstanden",
|
||||
"network_ipv6_mode_dhcpv6": "DHCPv6",
|
||||
"network_ipv6_mode_disabled": "Handicappet",
|
||||
"network_ipv6_mode_link_local": "Kun link-lokal",
|
||||
"network_ipv6_mode_slaac": "SLAAC",
|
||||
"network_ipv6_mode_slaac_dhcpv6": "SLAAC + DHCPv6",
|
||||
"network_ipv6_mode_static": "Statisk",
|
||||
"network_ipv6_mode_title": "IPv6-tilstand",
|
||||
"network_ipv6_netmask": "IPv6-netmaske",
|
||||
"network_ipv6_no_addresses": "Ingen IPv6-adresser konfigureret",
|
||||
"network_ll_dp_all": "Alle",
|
||||
"network_ll_dp_basic": "Grundlæggende",
|
||||
"network_ll_dp_description": "Styr hvilke TLV'er der sendes via Link Layer Discovery Protocol",
|
||||
"network_ll_dp_disabled": "Handicappet",
|
||||
"network_ll_dp_title": "LLDP",
|
||||
"network_mac_address_copy_error": "Kunne ikke kopiere MAC-adressen",
|
||||
"network_mac_address_copy_success": "MAC-adresse { mac } kopieret til udklipsholder",
|
||||
"network_mac_address_description": "Hardware-identifikator for netværksgrænsefladen",
|
||||
"network_mac_address_title": "MAC-adresse",
|
||||
"network_mdns_auto": "Bil",
|
||||
|
|
@ -612,9 +632,19 @@
|
|||
"network_mdns_ipv6_only": "Kun IPv6",
|
||||
"network_mdns_title": "mDNS",
|
||||
"network_no_dhcp_lease": "Ingen DHCP-leaseoplysninger tilgængelige",
|
||||
"network_no_information_description": "Ingen netværkskonfiguration tilgængelig",
|
||||
"network_no_information_headline": "Netværksoplysninger",
|
||||
"network_pending_dhcp_mode_change_description": "Gem indstillinger for at aktivere DHCP-tilstand og se leasingoplysninger",
|
||||
"network_pending_dhcp_mode_change_headline": "Afventer ændring af DHCP IPv4-tilstand",
|
||||
"network_save_settings": "Gem indstillinger",
|
||||
"network_save_settings_apply_title": "Anvend netværksindstillinger",
|
||||
"network_save_settings_confirm": "Anvend ændringer",
|
||||
"network_save_settings_confirm_description": "Følgende netværksindstillinger vil blive anvendt. Disse ændringer kan kræve en genstart og forårsage en kortvarig afbrydelse.",
|
||||
"network_save_settings_confirm_heading": "Konfigurationsændringer",
|
||||
"network_save_settings_failed": "Kunne ikke gemme netværksindstillinger: {error}",
|
||||
"network_save_settings_success": "Netværksindstillinger gemt",
|
||||
"network_save_settings": "Gem indstillinger",
|
||||
"network_settings_invalid_ipv4_cidr": "Ugyldig CIDR-notation for IPv4-adresse",
|
||||
"network_settings_load_error": "Kunne ikke indlæse netværksindstillinger: {error}",
|
||||
"network_time_sync_description": "Konfigurer indstillinger for tidssynkronisering",
|
||||
"network_time_sync_http_only": "Kun HTTP",
|
||||
"network_time_sync_ntp_and_http": "NTP og HTTP",
|
||||
|
|
@ -640,8 +670,8 @@
|
|||
"paste_modal_failed_paste": "Kunne ikke indsætte tekst: {error}",
|
||||
"paste_modal_invalid_chars_intro": "Følgende tegn vil ikke blive indsat:",
|
||||
"paste_modal_paste_from_host": "Indsæt fra vært",
|
||||
"paste_modal_paste_text_description": "Indsæt tekst fra din klient til den eksterne vært",
|
||||
"paste_modal_paste_text": "Indsæt tekst",
|
||||
"paste_modal_paste_text_description": "Indsæt tekst fra din klient til den eksterne vært",
|
||||
"paste_modal_sending_using_layout": "Sender tekst ved hjælp af tastaturlayout: {iso} - {name}",
|
||||
"peer_connection_closed": "Lukket",
|
||||
"peer_connection_closing": "Lukker",
|
||||
|
|
@ -668,20 +698,20 @@
|
|||
"retry": "Prøv igen",
|
||||
"saving": "Gemmer…",
|
||||
"search_placeholder": "Søge…",
|
||||
"serial_console": "Seriel konsol",
|
||||
"serial_console_baud_rate": "Baudhastighed",
|
||||
"serial_console_configure_description": "Konfigurer dine serielle konsolindstillinger",
|
||||
"serial_console_data_bits": "Databits",
|
||||
"serial_console_get_settings_error": "Kunne ikke hente indstillinger for seriel konsol: {error}",
|
||||
"serial_console_open_console": "Åbn konsol",
|
||||
"serial_console_parity": "Paritet",
|
||||
"serial_console_parity_even": "Lige paritet",
|
||||
"serial_console_parity_mark": "Mark Paritet",
|
||||
"serial_console_parity_none": "Ingen paritet",
|
||||
"serial_console_parity_odd": "Ulige paritet",
|
||||
"serial_console_parity_space": "Rumparitet",
|
||||
"serial_console_parity": "Paritet",
|
||||
"serial_console_set_settings_error": "Kunne ikke indstille seriel konsolindstillinger til {settings} : {error}",
|
||||
"serial_console_stop_bits": "Stopbits",
|
||||
"serial_console": "Seriel konsol",
|
||||
"setting_remote_description": "Indstilling af fjernbetjeningsbeskrivelse",
|
||||
"setting_remote_session_description": "Indstilling af beskrivelse af fjernsession...",
|
||||
"setting_up_connection_to_device": "Opretter forbindelse til enhed...",
|
||||
|
|
@ -691,8 +721,8 @@
|
|||
"settings_back_to_kvm": "Tilbage til KVM",
|
||||
"settings_general": "Generel",
|
||||
"settings_hardware": "Hardware",
|
||||
"settings_keyboard_macros": "Tastaturmakroer",
|
||||
"settings_keyboard": "Tastatur",
|
||||
"settings_keyboard_macros": "Tastaturmakroer",
|
||||
"settings_mouse": "Mus",
|
||||
"settings_network": "Netværk",
|
||||
"settings_video": "Video",
|
||||
|
|
@ -712,6 +742,7 @@
|
|||
"updates_failed_check": "Kunne ikke søge efter opdateringer: {error}",
|
||||
"updates_failed_get_device_version": "Kunne ikke hente enhedsversion: {error}",
|
||||
"updating_leave_device_on": "Sluk venligst ikke din enhed…",
|
||||
"usb": "USB",
|
||||
"usb_config_custom": "Skik",
|
||||
"usb_config_default": "JetKVM-standard",
|
||||
"usb_config_dell": "Dell Multimedia Pro-tastatur",
|
||||
|
|
@ -758,7 +789,6 @@
|
|||
"usb_state_connecting": "Forbinder",
|
||||
"usb_state_disconnected": "Afbrudt",
|
||||
"usb_state_low_power_mode": "Lavstrømstilstand",
|
||||
"usb": "USB",
|
||||
"user_interface_language_description": "Vælg det sprog, der skal bruges i JetKVM-brugergrænsefladen",
|
||||
"user_interface_language_title": "Grænsefladesprog",
|
||||
"video_brightness_description": "Lysstyrkeniveau ( {value} x)",
|
||||
|
|
@ -825,6 +855,7 @@
|
|||
"video_title": "Video",
|
||||
"view_details": "Se detaljer",
|
||||
"virtual_keyboard_header": "Virtuelt tastatur",
|
||||
"wake_on_lan": "Vågn på LAN",
|
||||
"wake_on_lan_add_device_device_name": "Enhedsnavn",
|
||||
"wake_on_lan_add_device_example_device_name": "Plex-medieserver",
|
||||
"wake_on_lan_add_device_mac_address": "MAC-adresse",
|
||||
|
|
@ -840,7 +871,6 @@
|
|||
"wake_on_lan_failed_send_magic": "Kunne ikke sende Magic Packet",
|
||||
"wake_on_lan_invalid_mac": "Ugyldig MAC-adresse",
|
||||
"wake_on_lan_magic_sent_success": "Magisk pakke sendt",
|
||||
"wake_on_lan": "Vågn på LAN",
|
||||
"welcome_to_jetkvm_description": "Styr enhver computer eksternt",
|
||||
"welcome_to_jetkvm": "Velkommen til JetKVM"
|
||||
"welcome_to_jetkvm": "Velkommen til JetKVM",
|
||||
"welcome_to_jetkvm_description": "Styr enhver computer eksternt"
|
||||
}
|
||||
|
|
@ -107,10 +107,10 @@
|
|||
"already_adopted_title": "Gerät bereits registriert",
|
||||
"appearance_description": "Wählen Sie Ihr bevorzugtes Farbthema",
|
||||
"appearance_page_description": "Passen Sie das Erscheinungsbild Ihrer JetKVM-Schnittstelle an",
|
||||
"appearance_theme": "Thema",
|
||||
"appearance_theme_dark": "Dunkel",
|
||||
"appearance_theme_light": "Licht",
|
||||
"appearance_theme_system": "System",
|
||||
"appearance_theme": "Thema",
|
||||
"appearance_title": "Aussehen",
|
||||
"attach": "Befestigen",
|
||||
"atx_power_control_get_state_error": "ATX-Stromversorgungsstatus konnte nicht abgerufen werden: {error}",
|
||||
|
|
@ -121,85 +121,85 @@
|
|||
"atx_power_control_reset_button": "Zurücksetzen",
|
||||
"atx_power_control_send_action_error": "ATX-Stromversorgungsaktion {action} konnte nicht gesendet werden: {error}",
|
||||
"atx_power_control_short_power_button": "Kurzes Drücken",
|
||||
"auth_authentication_mode": "Bitte wählen Sie einen Authentifizierungsmodus",
|
||||
"auth_authentication_mode_error": "Beim Einstellen des Authentifizierungsmodus ist ein Fehler aufgetreten",
|
||||
"auth_authentication_mode_invalid": "Ungültiger Authentifizierungsmodus",
|
||||
"auth_authentication_mode": "Bitte wählen Sie einen Authentifizierungsmodus",
|
||||
"auth_connect_to_cloud": "Verbinden Sie Ihr JetKVM mit der Cloud",
|
||||
"auth_connect_to_cloud_action": "Anmelden und Gerät verbinden",
|
||||
"auth_connect_to_cloud_description": "Schalten Sie den Fernzugriff und erweiterte Funktionen für Ihr Gerät frei",
|
||||
"auth_connect_to_cloud": "Verbinden Sie Ihr JetKVM mit der Cloud",
|
||||
"auth_header_cta_already_have_account": "Hast du schon ein Konto?",
|
||||
"auth_header_cta_dont_have_account": "Sie haben noch kein Konto?",
|
||||
"auth_header_cta_new_to_jetkvm": "Neu bei JetKVM?",
|
||||
"auth_login": "Melden Sie sich bei Ihrem JetKVM-Konto an",
|
||||
"auth_login_action": "Einloggen",
|
||||
"auth_login_description": "Melden Sie sich an, um sicher auf Ihre Geräte zuzugreifen und sie zu verwalten",
|
||||
"auth_login": "Melden Sie sich bei Ihrem JetKVM-Konto an",
|
||||
"auth_mode_local": "Lokale Authentifizierungsmethode",
|
||||
"auth_mode_local_change_later": "Sie können Ihre Authentifizierungsmethode später jederzeit in den Einstellungen ändern.",
|
||||
"auth_mode_local_description": "Wählen Sie aus, wie Sie Ihr JetKVM-Gerät lokal sichern möchten.",
|
||||
"auth_mode_local_no_password_description": "Schneller Zugriff ohne Passwortauthentifizierung.",
|
||||
"auth_mode_local_no_password": "Kein Passwort",
|
||||
"auth_mode_local_no_password_description": "Schneller Zugriff ohne Passwortauthentifizierung.",
|
||||
"auth_mode_local_password": "Passwort",
|
||||
"auth_mode_local_password_confirm_description": "Bestätigen Sie Ihr Passwort",
|
||||
"auth_mode_local_password_confirm_label": "Passwort bestätigen",
|
||||
"auth_mode_local_password_description": "Sichern Sie Ihr Gerät für zusätzlichen Schutz mit einem Passwort.",
|
||||
"auth_mode_local_password_do_not_match": "Passwörter stimmen nicht überein",
|
||||
"auth_mode_local_password_failed_set": "Kennwort konnte nicht festgelegt werden: {error}",
|
||||
"auth_mode_local_password_note_local": "Alle Daten verbleiben auf Ihrem lokalen Gerät.",
|
||||
"auth_mode_local_password_note": "Dieses Passwort wird verwendet, um Ihre Gerätedaten zu sichern und vor unbefugtem Zugriff zu schützen.",
|
||||
"auth_mode_local_password_note_local": "Alle Daten verbleiben auf Ihrem lokalen Gerät.",
|
||||
"auth_mode_local_password_set": "Legen Sie ein Passwort fest",
|
||||
"auth_mode_local_password_set_button": "Passwort festlegen",
|
||||
"auth_mode_local_password_set_description": "Erstellen Sie ein sicheres Passwort, um Ihr JetKVM-Gerät lokal zu sichern.",
|
||||
"auth_mode_local_password_set_label": "Geben Sie ein Passwort ein",
|
||||
"auth_mode_local_password_set": "Legen Sie ein Passwort fest",
|
||||
"auth_mode_local_password": "Passwort",
|
||||
"auth_mode_local": "Lokale Authentifizierungsmethode",
|
||||
"auth_signup_connect_to_cloud_action": "Anmelden und Gerät verbinden",
|
||||
"auth_signup_create_account": "Erstellen Sie Ihr JetKVM-Konto",
|
||||
"auth_signup_create_account_action": "Benutzerkonto erstellen",
|
||||
"auth_signup_create_account_description": "Erstellen Sie Ihr Konto und beginnen Sie mit der einfachen Verwaltung Ihrer Geräte.",
|
||||
"auth_signup_create_account": "Erstellen Sie Ihr JetKVM-Konto",
|
||||
"back_to_devices": "Zurück zu Geräte",
|
||||
"back": "Zurück",
|
||||
"back_to_devices": "Zurück zu Geräte",
|
||||
"cancel": "Stornieren",
|
||||
"close": "Schließen",
|
||||
"cloud_kvms_description": "Verwalten Sie Ihre Cloud-KVMs und stellen Sie eine sichere Verbindung zu ihnen her.",
|
||||
"cloud_kvms_no_devices_description": "Sie haben noch keine Geräte mit aktivierter JetKVM Cloud.",
|
||||
"cloud_kvms_no_devices": "Keine Geräte gefunden",
|
||||
"cloud_kvms": "Cloud-KVMs",
|
||||
"cloud_kvms_description": "Verwalten Sie Ihre Cloud-KVMs und stellen Sie eine sichere Verbindung zu ihnen her.",
|
||||
"cloud_kvms_no_devices": "Keine Geräte gefunden",
|
||||
"cloud_kvms_no_devices_description": "Sie haben noch keine Geräte mit aktivierter JetKVM Cloud.",
|
||||
"confirm": "Bestätigen",
|
||||
"connect_to_kvm": "Mit KVM verbinden",
|
||||
"connecting_to_device": "Verbindung zum Gerät wird hergestellt…",
|
||||
"connection_established": "Verbindung hergestellt",
|
||||
"connection_stats_badge_jitter_buffer_avg_delay": "Jitter-Puffer – Durchschnittliche Verzögerung",
|
||||
"connection_stats_badge_jitter": "Jitter",
|
||||
"connection_stats_connection_description": "Die Verbindung zwischen dem Client und dem JetKVM.",
|
||||
"connection_stats_badge_jitter_buffer_avg_delay": "Jitter-Puffer – Durchschnittliche Verzögerung",
|
||||
"connection_stats_connection": "Verbindung",
|
||||
"connection_stats_frames_per_second_description": "Anzahl der pro Sekunde angezeigten eingehenden Videobilder.",
|
||||
"connection_stats_connection_description": "Die Verbindung zwischen dem Client und dem JetKVM.",
|
||||
"connection_stats_frames_per_second": "Bilder pro Sekunde",
|
||||
"connection_stats_network_stability_description": "Wie gleichmäßig der Fluss eingehender Videopakete im Netzwerk ist.",
|
||||
"connection_stats_frames_per_second_description": "Anzahl der pro Sekunde angezeigten eingehenden Videobilder.",
|
||||
"connection_stats_network_stability": "Netzwerkstabilität",
|
||||
"connection_stats_packets_lost_description": "Anzahl der verlorenen eingehenden Video-RTP-Pakete.",
|
||||
"connection_stats_network_stability_description": "Wie gleichmäßig der Fluss eingehender Videopakete im Netzwerk ist.",
|
||||
"connection_stats_packets_lost": "Verlorene Pakete",
|
||||
"connection_stats_playback_delay_description": "Durch den Jitter-Puffer hinzugefügte Verzögerung, um die Wiedergabe zu glätten, wenn die Frames ungleichmäßig ankommen.",
|
||||
"connection_stats_packets_lost_description": "Anzahl der verlorenen eingehenden Video-RTP-Pakete.",
|
||||
"connection_stats_playback_delay": "Wiedergabeverzögerung",
|
||||
"connection_stats_round_trip_time_description": "Roundtrip-Zeit für das aktive ICE-Kandidatenpaar zwischen Peers.",
|
||||
"connection_stats_playback_delay_description": "Durch den Jitter-Puffer hinzugefügte Verzögerung, um die Wiedergabe zu glätten, wenn die Frames ungleichmäßig ankommen.",
|
||||
"connection_stats_round_trip_time": "Round-Trip-Zeit",
|
||||
"connection_stats_round_trip_time_description": "Roundtrip-Zeit für das aktive ICE-Kandidatenpaar zwischen Peers.",
|
||||
"connection_stats_sidebar": "Verbindungsstatistiken",
|
||||
"connection_stats_video_description": "Der Videostream vom JetKVM zum Client.",
|
||||
"connection_stats_video": "Video",
|
||||
"connection_stats_video_description": "Der Videostream vom JetKVM zum Client.",
|
||||
"continue": "Weitermachen",
|
||||
"creating_peer_connection": "Peer-Verbindung wird hergestellt …",
|
||||
"dc_power_control_current_unit": "A",
|
||||
"dc_power_control_current": "Aktuell",
|
||||
"dc_power_control_current_unit": "A",
|
||||
"dc_power_control_get_state_error": "Der Gleichstromstatus konnte nicht abgerufen werden: {error}",
|
||||
"dc_power_control_power": "Leistung",
|
||||
"dc_power_control_power_off_button": "Ausschalten",
|
||||
"dc_power_control_power_off_state": "Ausschalten",
|
||||
"dc_power_control_power_on_button": "Einschalten",
|
||||
"dc_power_control_power_on_state": "Einschalten",
|
||||
"dc_power_control_power_unit": "W",
|
||||
"dc_power_control_power": "Leistung",
|
||||
"dc_power_control_restore_last_state": "Letzter Zustand",
|
||||
"dc_power_control_restore_power_state": "Wiederherstellung nach Stromausfall",
|
||||
"dc_power_control_set_power_state_error": "Der DC-Stromversorgungsstatus konnte nicht an {enabled} werden: {error}",
|
||||
"dc_power_control_set_restore_state_error": "Der Status zur Wiederherstellung der Gleichstromversorgung konnte nicht an {state} gesendet werden: {error}",
|
||||
"dc_power_control_voltage_unit": "V",
|
||||
"dc_power_control_voltage": "Stromspannung",
|
||||
"dc_power_control_voltage_unit": "V",
|
||||
"delete": "Löschen",
|
||||
"deregister_button": "Abmelden von der Cloud",
|
||||
"deregister_cloud_devices": "Cloud-Geräte",
|
||||
|
|
@ -226,12 +226,12 @@
|
|||
"extension_popover_load_and_manage_extensions": "Laden und verwalten Sie Ihre Erweiterungen",
|
||||
"extension_popover_set_error_notification": "Fehler beim Festlegen der aktiven Erweiterung: {error}",
|
||||
"extension_popover_unload_extension": "Erweiterung entladen",
|
||||
"extension_serial_console_description": "Greifen Sie auf Ihre serielle Konsolenerweiterung zu",
|
||||
"extension_serial_console": "Serielle Konsole",
|
||||
"extensions_atx_power_control_description": "Steuern Sie den Energiezustand Ihrer Maschine über die ATX-Energiesteuerung.",
|
||||
"extension_serial_console_description": "Greifen Sie auf Ihre serielle Konsolenerweiterung zu",
|
||||
"extensions_atx_power_control": "ATX-Stromsteuerung",
|
||||
"extensions_dc_power_control_description": "Steuern Sie Ihre DC-Stromerweiterung",
|
||||
"extensions_atx_power_control_description": "Steuern Sie den Energiezustand Ihrer Maschine über die ATX-Energiesteuerung.",
|
||||
"extensions_dc_power_control": "Gleichstromsteuerung",
|
||||
"extensions_dc_power_control_description": "Steuern Sie Ihre DC-Stromerweiterung",
|
||||
"extensions_popover_extensions": "Erweiterungen",
|
||||
"gathering_ice_candidates": "ICE-Kandidaten zusammenbringen …",
|
||||
"general_app_version": "App: {version}",
|
||||
|
|
@ -241,8 +241,8 @@
|
|||
"general_check_for_updates": "Nach Updates suchen",
|
||||
"general_page_description": "Geräteeinstellungen konfigurieren und Voreinstellungen aktualisieren",
|
||||
"general_reboot_description": "Möchten Sie mit dem Neustart des Systems fortfahren?",
|
||||
"general_reboot_device_description": "Schalten Sie den JetKVM aus und wieder ein",
|
||||
"general_reboot_device": "Gerät neu starten",
|
||||
"general_reboot_device_description": "Schalten Sie den JetKVM aus und wieder ein",
|
||||
"general_reboot_no_button": "NEIN",
|
||||
"general_reboot_title": "Starten Sie JetKVM neu",
|
||||
"general_reboot_yes_button": "Ja",
|
||||
|
|
@ -295,9 +295,9 @@
|
|||
"hardware_display_orientation_title": "Anzeigeausrichtung",
|
||||
"hardware_display_wake_up_note": "Das Display wird aktiviert, wenn sich der Verbindungsstatus ändert oder wenn es berührt wird.",
|
||||
"hardware_page_description": "Konfigurieren Sie Anzeigeeinstellungen und Hardwareoptionen für Ihr JetKVM-Gerät",
|
||||
"hardware_time_10_minutes": "10 Minuten",
|
||||
"hardware_time_1_hour": "1 Stunde",
|
||||
"hardware_time_1_minute": "1 Minute",
|
||||
"hardware_time_10_minutes": "10 Minuten",
|
||||
"hardware_time_30_minutes": "30 Minuten",
|
||||
"hardware_time_5_minutes": "5 Minuten",
|
||||
"hardware_time_never": "Niemals",
|
||||
|
|
@ -327,6 +327,7 @@
|
|||
"invalid_password": "Ungültiges Passwort",
|
||||
"ip_address": "IP-Adresse",
|
||||
"ipv6_address_label": "Adresse",
|
||||
"ipv6_gateway": "Tor",
|
||||
"ipv6_information": "IPv6-Informationen",
|
||||
"ipv6_link_local": "Link-lokal",
|
||||
"ipv6_preferred_lifetime": "Bevorzugte Lebensdauer",
|
||||
|
|
@ -409,8 +410,8 @@
|
|||
"log_in": "Einloggen",
|
||||
"log_out": "Ausloggen",
|
||||
"logged_in_as": "Angemeldet als",
|
||||
"login_enter_password_description": "Geben Sie Ihr Passwort ein, um auf Ihr JetKVM zuzugreifen.",
|
||||
"login_enter_password": "Geben Sie Ihr Passwort ein",
|
||||
"login_enter_password_description": "Geben Sie Ihr Passwort ein, um auf Ihr JetKVM zuzugreifen.",
|
||||
"login_error": "Beim Anmelden ist ein Fehler aufgetreten",
|
||||
"login_forgot_password": "Passwort vergessen?",
|
||||
"login_password_label": "Passwort",
|
||||
|
|
@ -424,8 +425,8 @@
|
|||
"macro_name_required": "Name ist erforderlich",
|
||||
"macro_name_too_long": "Der Name muss weniger als 50 Zeichen lang sein",
|
||||
"macro_please_fix_validation_errors": "Bitte beheben Sie die Validierungsfehler",
|
||||
"macro_save_error": "Beim Speichern ist ein Fehler aufgetreten.",
|
||||
"macro_save": "Makro speichern",
|
||||
"macro_save_error": "Beim Speichern ist ein Fehler aufgetreten.",
|
||||
"macro_step_count": "{steps} / {max} Schritte",
|
||||
"macro_step_duration_description": "Wartezeit vor der Ausführung des nächsten Schritts.",
|
||||
"macro_step_duration_label": "Schrittdauer",
|
||||
|
|
@ -439,8 +440,8 @@
|
|||
"macro_steps_description": "Tasten/Modifikatoren werden nacheinander mit einer Verzögerung zwischen den einzelnen Schritten ausgeführt.",
|
||||
"macro_steps_label": "Schritte",
|
||||
"macros_add_description": "Erstellen Sie ein neues Tastaturmakro",
|
||||
"macros_add_new_macro": "Neues Makro hinzufügen",
|
||||
"macros_add_new": "Neues Makro hinzufügen",
|
||||
"macros_add_new_macro": "Neues Makro hinzufügen",
|
||||
"macros_aria_add_new": "Neues Makro hinzufügen",
|
||||
"macros_aria_delete": "Makro löschen {name}",
|
||||
"macros_aria_duplicate": "Doppeltes Makro {name}",
|
||||
|
|
@ -462,16 +463,16 @@
|
|||
"macros_edit_button": "Bearbeiten",
|
||||
"macros_edit_description": "Ändern Sie Ihr Tastaturmakro",
|
||||
"macros_edit_title": "Makro bearbeiten",
|
||||
"macros_failed_create_error": "Makro konnte nicht erstellt werden: {error}",
|
||||
"macros_failed_create": "Makro konnte nicht erstellt werden",
|
||||
"macros_failed_delete_error": "Makro konnte nicht gelöscht werden: {error}",
|
||||
"macros_failed_create_error": "Makro konnte nicht erstellt werden: {error}",
|
||||
"macros_failed_delete": "Makro konnte nicht gelöscht werden",
|
||||
"macros_failed_duplicate_error": "Makro konnte nicht dupliziert werden: {error}",
|
||||
"macros_failed_delete_error": "Makro konnte nicht gelöscht werden: {error}",
|
||||
"macros_failed_duplicate": "Makro konnte nicht dupliziert werden",
|
||||
"macros_failed_reorder_error": "Fehler beim Neuordnen der Makros: {error}",
|
||||
"macros_failed_duplicate_error": "Makro konnte nicht dupliziert werden: {error}",
|
||||
"macros_failed_reorder": "Makros konnten nicht neu angeordnet werden",
|
||||
"macros_failed_update_error": "Makro konnte nicht aktualisiert werden: {error}",
|
||||
"macros_failed_reorder_error": "Fehler beim Neuordnen der Makros: {error}",
|
||||
"macros_failed_update": "Makro konnte nicht aktualisiert werden",
|
||||
"macros_failed_update_error": "Makro konnte nicht aktualisiert werden: {error}",
|
||||
"macros_invalid_data": "Ungültige Makrodaten",
|
||||
"macros_loading": "Makros werden geladen …",
|
||||
"macros_max_reached": "Max. erreicht",
|
||||
|
|
@ -506,8 +507,8 @@
|
|||
"mount_error_list_storage": "Fehler beim Auflisten der Speicherdateien: {error}",
|
||||
"mount_error_title": "Mount-Fehler",
|
||||
"mount_get_state_error": "Der Status des virtuellen Mediums konnte nicht abgerufen werden: {error}",
|
||||
"mount_jetkvm_storage_description": "Mounten Sie zuvor hochgeladene Dateien aus dem JetKVM-Speicher",
|
||||
"mount_jetkvm_storage": "JetKVM-Speicherhalterung",
|
||||
"mount_jetkvm_storage_description": "Mounten Sie zuvor hochgeladene Dateien aus dem JetKVM-Speicher",
|
||||
"mount_mode_cdrom": "CD/DVD",
|
||||
"mount_mode_disk": "Scheibe",
|
||||
"mount_mounted_as": "Montiert als",
|
||||
|
|
@ -520,8 +521,8 @@
|
|||
"mount_popular_images": "Beliebte Bilder",
|
||||
"mount_streaming_from_url": "Streaming von URL",
|
||||
"mount_supported_formats": "Unterstützte Formate: ISO, IMG",
|
||||
"mount_unmount_error": "Abbild konnte nicht ausgehängt werden: {error}",
|
||||
"mount_unmount": "Aushängen",
|
||||
"mount_unmount_error": "Abbild konnte nicht ausgehängt werden: {error}",
|
||||
"mount_upload_description": "Wählen Sie eine Bilddatei zum Hochladen in den JetKVM-Speicher aus",
|
||||
"mount_upload_error": "Upload-Fehler: {error}",
|
||||
"mount_upload_failed_datachannel": "Fehler beim Erstellen des Datenkanals für den Datei-Upload",
|
||||
|
|
@ -529,8 +530,8 @@
|
|||
"mount_upload_successful": "Upload erfolgreich",
|
||||
"mount_upload_title": "Neues Bild hochladen",
|
||||
"mount_uploaded_has_been_uploaded": "{name} wurde hochgeladen",
|
||||
"mount_uploading_with_name": "Hochladen von {name}",
|
||||
"mount_uploading": "Hochladen…",
|
||||
"mount_uploading_with_name": "Hochladen von {name}",
|
||||
"mount_url_description": "Mounten Sie Dateien von jeder öffentlichen Webadresse",
|
||||
"mount_url_input_label": "Bild-URL",
|
||||
"mount_url_mount": "URL-Mount",
|
||||
|
|
@ -538,10 +539,10 @@
|
|||
"mount_view_device_title": "Mounten vom JetKVM-Speicher",
|
||||
"mount_view_url_description": "Geben Sie eine URL zur zu mountenden Bilddatei ein",
|
||||
"mount_view_url_title": "Von URL einbinden",
|
||||
"mount_virtual_media_description": "Mounten Sie ein Image, um von einem Betriebssystem zu booten oder es zu installieren.",
|
||||
"mount_virtual_media_source_description": "Wählen Sie, wie Sie Ihr virtuelles Medium mounten möchten",
|
||||
"mount_virtual_media_source": "Virtuelle Medienquelle",
|
||||
"mount_virtual_media": "Virtuelle Medien",
|
||||
"mount_virtual_media_description": "Mounten Sie ein Image, um von einem Betriebssystem zu booten oder es zu installieren.",
|
||||
"mount_virtual_media_source": "Virtuelle Medienquelle",
|
||||
"mount_virtual_media_source_description": "Wählen Sie, wie Sie Ihr virtuelles Medium mounten möchten",
|
||||
"mouse_alt_finger": "Finger berührt einen Bildschirm",
|
||||
"mouse_alt_mouse": "Maussymbol",
|
||||
"mouse_description": "Konfigurieren Sie das Cursorverhalten und die Interaktionseinstellungen für Ihr Gerät",
|
||||
|
|
@ -558,10 +559,10 @@
|
|||
"mouse_jiggler_light": "Licht - 5m",
|
||||
"mouse_jiggler_standard": "Standard - 1 m",
|
||||
"mouse_jiggler_title": "Wackel",
|
||||
"mouse_mode_absolute_description": "Am bequemsten",
|
||||
"mouse_mode_absolute": "Absolute",
|
||||
"mouse_mode_relative_description": "Am kompatibelsten",
|
||||
"mouse_mode_absolute_description": "Am bequemsten",
|
||||
"mouse_mode_relative": "Relativ",
|
||||
"mouse_mode_relative_description": "Am kompatibelsten",
|
||||
"mouse_modes_description": "Wählen Sie den Mauseingabemodus",
|
||||
"mouse_modes_title": "Modi",
|
||||
"mouse_scroll_high": "Hoch",
|
||||
|
|
@ -574,12 +575,17 @@
|
|||
"mouse_title": "Maus",
|
||||
"network_custom_domain": "Benutzerdefinierte Domäne",
|
||||
"network_description": "Konfigurieren Sie Ihre Netzwerkeinstellungen",
|
||||
"network_dhcp_client_description": "Konfigurieren Sie, welcher DHCP-Client verwendet werden soll",
|
||||
"network_dhcp_client_jetkvm": "JetKVM intern",
|
||||
"network_dhcp_client_title": "DHCP-Client",
|
||||
"network_dhcp_information": "DHCP-Informationen",
|
||||
"network_dhcp_lease_renew_confirm_description": "Dadurch wird eine neue IP-Adresse von Ihrem DHCP-Server angefordert. Während dieses Vorgangs kann die Netzwerkverbindung Ihres Geräts vorübergehend unterbrochen werden.",
|
||||
"network_dhcp_lease_renew": "DHCP-Lease erneuern",
|
||||
"network_dhcp_lease_renew_confirm": "Mietvertrag verlängern",
|
||||
"network_dhcp_lease_renew_confirm_description": "Dadurch wird eine neue IP-Adresse von Ihrem DHCP-Server angefordert. Während dieses Vorgangs kann die Netzwerkverbindung Ihres Geräts vorübergehend unterbrochen werden.",
|
||||
"network_dhcp_lease_renew_confirm_new_a": "Wenn Sie eine neue IP-Adresse erhalten",
|
||||
"network_dhcp_lease_renew_confirm_new_b": "Möglicherweise müssen Sie die Verbindung mit der neuen Adresse erneut herstellen",
|
||||
"network_dhcp_lease_renew_failed": "Leasing konnte nicht erneuert werden: {error}",
|
||||
"network_dhcp_lease_renew_success": "DHCP-Lease erneuert",
|
||||
"network_dhcp_lease_renew": "DHCP-Lease erneuern",
|
||||
"network_domain_custom": "Brauch",
|
||||
"network_domain_description": "Netzwerkdomänensuffix für das Gerät",
|
||||
"network_domain_dhcp_provided": "DHCP bereitgestellt",
|
||||
|
|
@ -588,21 +594,35 @@
|
|||
"network_hostname_description": "Gerätekennung im Netzwerk. Leer für Systemstandard",
|
||||
"network_hostname_title": "Hostname",
|
||||
"network_http_proxy_description": "Proxyserver für ausgehende HTTP(S)-Anfragen vom Gerät. Leer für keine.",
|
||||
"network_http_proxy_invalid": "Ungültige HTTP-Proxy-URL",
|
||||
"network_http_proxy_title": "HTTP-Proxy",
|
||||
"network_ipv4_address": "IPv4-Adresse",
|
||||
"network_ipv4_dns": "IPv4 DNS",
|
||||
"network_ipv4_gateway": "IPv4-Gateway",
|
||||
"network_ipv4_mode_description": "Konfigurieren des IPv4-Modus",
|
||||
"network_ipv4_mode_dhcp": "DHCP",
|
||||
"network_ipv4_mode_static": "Statisch",
|
||||
"network_ipv4_mode_title": "IPv4-Modus",
|
||||
"network_ipv4_netmask": "IPv4-Netzmaske",
|
||||
"network_ipv6_address": "IPv6-Adresse",
|
||||
"network_ipv6_information": "IPv6-Informationen",
|
||||
"network_ipv6_mode_description": "Konfigurieren des IPv6-Modus",
|
||||
"network_ipv6_mode_dhcpv6": "DHCPv6",
|
||||
"network_ipv6_mode_disabled": "Deaktiviert",
|
||||
"network_ipv6_mode_link_local": "Nur Link-Local",
|
||||
"network_ipv6_mode_slaac": "SLAAC",
|
||||
"network_ipv6_mode_slaac_dhcpv6": "SLAAC + DHCPv6",
|
||||
"network_ipv6_mode_static": "Statisch",
|
||||
"network_ipv6_mode_title": "IPv6-Modus",
|
||||
"network_ipv6_netmask": "IPv6-Netzmaske",
|
||||
"network_ipv6_no_addresses": "Keine IPv6-Adressen konfiguriert",
|
||||
"network_ll_dp_all": "Alle",
|
||||
"network_ll_dp_basic": "Basic",
|
||||
"network_ll_dp_description": "Steuern Sie, welche TLVs über das Link Layer Discovery Protocol gesendet werden",
|
||||
"network_ll_dp_disabled": "Deaktiviert",
|
||||
"network_ll_dp_title": "LLDP",
|
||||
"network_mac_address_copy_error": "MAC-Adresse konnte nicht kopiert werden",
|
||||
"network_mac_address_copy_success": "MAC-Adresse { mac } in die Zwischenablage kopiert",
|
||||
"network_mac_address_description": "Hardwarekennung für die Netzwerkschnittstelle",
|
||||
"network_mac_address_title": "MAC-Adresse",
|
||||
"network_mdns_auto": "Auto",
|
||||
|
|
@ -612,9 +632,19 @@
|
|||
"network_mdns_ipv6_only": "Nur IPv6",
|
||||
"network_mdns_title": "mDNS",
|
||||
"network_no_dhcp_lease": "Keine DHCP-Lease-Informationen verfügbar",
|
||||
"network_no_information_description": "Keine Netzwerkkonfiguration verfügbar",
|
||||
"network_no_information_headline": "Netzwerkinformationen",
|
||||
"network_pending_dhcp_mode_change_description": "Speichern Sie die Einstellungen, um den DHCP-Modus zu aktivieren und Leasinginformationen anzuzeigen",
|
||||
"network_pending_dhcp_mode_change_headline": "Ausstehende Änderung des DHCP-IPv4-Modus",
|
||||
"network_save_settings": "Einstellungen speichern",
|
||||
"network_save_settings_apply_title": "Netzwerkeinstellungen anwenden",
|
||||
"network_save_settings_confirm": "Änderungen übernehmen",
|
||||
"network_save_settings_confirm_description": "Die folgenden Netzwerkeinstellungen werden angewendet. Diese Änderungen erfordern möglicherweise einen Neustart und können zu einer kurzen Unterbrechung der Verbindung führen.",
|
||||
"network_save_settings_confirm_heading": "Konfigurationsänderungen",
|
||||
"network_save_settings_failed": "Netzwerkeinstellungen konnten nicht gespeichert werden: {error}",
|
||||
"network_save_settings_success": "Netzwerkeinstellungen gespeichert",
|
||||
"network_save_settings": "Einstellungen speichern",
|
||||
"network_settings_invalid_ipv4_cidr": "Ungültige CIDR-Notation für IPv4-Adresse",
|
||||
"network_settings_load_error": "Netzwerkeinstellungen konnten nicht geladen werden: {error}",
|
||||
"network_time_sync_description": "Konfigurieren der Zeitsynchronisierungseinstellungen",
|
||||
"network_time_sync_http_only": "Nur HTTP",
|
||||
"network_time_sync_ntp_and_http": "NTP und HTTP",
|
||||
|
|
@ -640,8 +670,8 @@
|
|||
"paste_modal_failed_paste": "Fehler beim Einfügen des Textes: {error}",
|
||||
"paste_modal_invalid_chars_intro": "Die folgenden Zeichen werden nicht eingefügt:",
|
||||
"paste_modal_paste_from_host": "Vom Host einfügen",
|
||||
"paste_modal_paste_text_description": "Fügen Sie Text von Ihrem Client in den Remote-Host ein",
|
||||
"paste_modal_paste_text": "Text einfügen",
|
||||
"paste_modal_paste_text_description": "Fügen Sie Text von Ihrem Client in den Remote-Host ein",
|
||||
"paste_modal_sending_using_layout": "Senden von Text mithilfe des Tastaturlayouts: {iso} - {name}",
|
||||
"peer_connection_closed": "Geschlossen",
|
||||
"peer_connection_closing": "Schließen",
|
||||
|
|
@ -668,20 +698,20 @@
|
|||
"retry": "Wiederholen",
|
||||
"saving": "Speichern…",
|
||||
"search_placeholder": "Suchen…",
|
||||
"serial_console": "Serielle Konsole",
|
||||
"serial_console_baud_rate": "Baudrate",
|
||||
"serial_console_configure_description": "Konfigurieren Sie die Einstellungen Ihrer seriellen Konsole",
|
||||
"serial_console_data_bits": "Datenbits",
|
||||
"serial_console_get_settings_error": "Die seriellen Konsoleneinstellungen konnten nicht abgerufen werden: {error}",
|
||||
"serial_console_open_console": "Konsole öffnen",
|
||||
"serial_console_parity": "Parität",
|
||||
"serial_console_parity_even": "Gerade Parität",
|
||||
"serial_console_parity_mark": "Parität markieren",
|
||||
"serial_console_parity_none": "Keine Parität",
|
||||
"serial_console_parity_odd": "Ungerade Parität",
|
||||
"serial_console_parity_space": "Raumparität",
|
||||
"serial_console_parity": "Parität",
|
||||
"serial_console_set_settings_error": "Die Einstellungen der seriellen Konsole konnten nicht auf {settings} festgelegt werden: {error}",
|
||||
"serial_console_stop_bits": "Stoppbits",
|
||||
"serial_console": "Serielle Konsole",
|
||||
"setting_remote_description": "Beschreibung der Fernbedienung einstellen",
|
||||
"setting_remote_session_description": "Beschreibung der Remote-Sitzung festlegen ...",
|
||||
"setting_up_connection_to_device": "Verbindung zum Gerät wird eingerichtet …",
|
||||
|
|
@ -691,8 +721,8 @@
|
|||
"settings_back_to_kvm": "Zurück zu KVM",
|
||||
"settings_general": "Allgemein",
|
||||
"settings_hardware": "Hardware",
|
||||
"settings_keyboard_macros": "Tastaturmakros",
|
||||
"settings_keyboard": "Tastatur",
|
||||
"settings_keyboard_macros": "Tastaturmakros",
|
||||
"settings_mouse": "Maus",
|
||||
"settings_network": "Netzwerk",
|
||||
"settings_video": "Video",
|
||||
|
|
@ -712,6 +742,7 @@
|
|||
"updates_failed_check": "Fehler beim Suchen nach Updates: {error}",
|
||||
"updates_failed_get_device_version": "Geräteversion konnte nicht abgerufen werden: {error}",
|
||||
"updating_leave_device_on": "Bitte schalten Sie Ihr Gerät nicht aus…",
|
||||
"usb": "USB",
|
||||
"usb_config_custom": "Brauch",
|
||||
"usb_config_default": "JetKVM-Standard",
|
||||
"usb_config_dell": "Dell Multimedia Pro-Tastatur",
|
||||
|
|
@ -758,7 +789,6 @@
|
|||
"usb_state_connecting": "Verbinden",
|
||||
"usb_state_disconnected": "Getrennt",
|
||||
"usb_state_low_power_mode": "Energiesparmodus",
|
||||
"usb": "USB",
|
||||
"user_interface_language_description": "Wählen Sie die Sprache aus, die in der JetKVM-Benutzeroberfläche verwendet werden soll",
|
||||
"user_interface_language_title": "Schnittstellensprache",
|
||||
"video_brightness_description": "Helligkeitsstufe ( {value} x)",
|
||||
|
|
@ -825,6 +855,7 @@
|
|||
"video_title": "Video",
|
||||
"view_details": "Details anzeigen",
|
||||
"virtual_keyboard_header": "Virtuelle Tastatur",
|
||||
"wake_on_lan": "Wake-On-LAN",
|
||||
"wake_on_lan_add_device_device_name": "Gerätename",
|
||||
"wake_on_lan_add_device_example_device_name": "Plex Media Server",
|
||||
"wake_on_lan_add_device_mac_address": "MAC-Adresse",
|
||||
|
|
@ -840,7 +871,6 @@
|
|||
"wake_on_lan_failed_send_magic": "Das Senden des Magic Packets ist fehlgeschlagen.",
|
||||
"wake_on_lan_invalid_mac": "Ungültige MAC-Adresse",
|
||||
"wake_on_lan_magic_sent_success": "Magic Packet erfolgreich gesendet",
|
||||
"wake_on_lan": "Wake-On-LAN",
|
||||
"welcome_to_jetkvm_description": "Steuern Sie jeden Computer aus der Ferne",
|
||||
"welcome_to_jetkvm": "Willkommen bei JetKVM"
|
||||
"welcome_to_jetkvm": "Willkommen bei JetKVM",
|
||||
"welcome_to_jetkvm_description": "Steuern Sie jeden Computer aus der Ferne"
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -107,10 +107,10 @@
|
|||
"already_adopted_title": "Dispositivo ya registrado",
|
||||
"appearance_description": "Elige tu tema de color preferido",
|
||||
"appearance_page_description": "Personalice la apariencia de su interfaz JetKVM",
|
||||
"appearance_theme": "Tema",
|
||||
"appearance_theme_dark": "Oscuro",
|
||||
"appearance_theme_light": "Luz",
|
||||
"appearance_theme_system": "Sistema",
|
||||
"appearance_theme": "Tema",
|
||||
"appearance_title": "Apariencia",
|
||||
"attach": "Adjuntar",
|
||||
"atx_power_control_get_state_error": "No se pudo obtener el estado de energía ATX: {error}",
|
||||
|
|
@ -121,85 +121,85 @@
|
|||
"atx_power_control_reset_button": "Reiniciar",
|
||||
"atx_power_control_send_action_error": "No se pudo enviar la acción de alimentación ATX {action} : {error}",
|
||||
"atx_power_control_short_power_button": "Prensa corta",
|
||||
"auth_authentication_mode": "Por favor seleccione un modo de autenticación",
|
||||
"auth_authentication_mode_error": "Se produjo un error al configurar el modo de autenticación",
|
||||
"auth_authentication_mode_invalid": "Modo de autenticación no válido",
|
||||
"auth_authentication_mode": "Por favor seleccione un modo de autenticación",
|
||||
"auth_connect_to_cloud": "Conecte su JetKVM a la nube",
|
||||
"auth_connect_to_cloud_action": "Iniciar sesión y conectar el dispositivo",
|
||||
"auth_connect_to_cloud_description": "Desbloquee el acceso remoto y las funciones avanzadas para su dispositivo",
|
||||
"auth_connect_to_cloud": "Conecte su JetKVM a la nube",
|
||||
"auth_header_cta_already_have_account": "¿Ya tienes una cuenta?",
|
||||
"auth_header_cta_dont_have_account": "¿No tienes una cuenta?",
|
||||
"auth_header_cta_new_to_jetkvm": "¿Eres nuevo en JetKVM?",
|
||||
"auth_login": "Inicie sesión en su cuenta JetKVM",
|
||||
"auth_login_action": "Acceso",
|
||||
"auth_login_description": "Inicie sesión para acceder y administrar sus dispositivos de forma segura",
|
||||
"auth_login": "Inicie sesión en su cuenta JetKVM",
|
||||
"auth_mode_local": "Método de autenticación local",
|
||||
"auth_mode_local_change_later": "Siempre puedes cambiar tu método de autenticación más tarde en la configuración.",
|
||||
"auth_mode_local_description": "Seleccione cómo desea proteger su dispositivo JetKVM localmente.",
|
||||
"auth_mode_local_no_password_description": "Acceso rápido sin autenticación de contraseña.",
|
||||
"auth_mode_local_no_password": "Sin contraseña",
|
||||
"auth_mode_local_no_password_description": "Acceso rápido sin autenticación de contraseña.",
|
||||
"auth_mode_local_password": "Contraseña",
|
||||
"auth_mode_local_password_confirm_description": "Confirma tu contraseña",
|
||||
"auth_mode_local_password_confirm_label": "confirmar Contraseña",
|
||||
"auth_mode_local_password_description": "Proteja su dispositivo con una contraseña para mayor protección.",
|
||||
"auth_mode_local_password_do_not_match": "Las contraseñas no coinciden",
|
||||
"auth_mode_local_password_failed_set": "No se pudo establecer la contraseña: {error}",
|
||||
"auth_mode_local_password_note_local": "Todos los datos permanecen en su dispositivo local.",
|
||||
"auth_mode_local_password_note": "Esta contraseña se utilizará para proteger los datos de su dispositivo y contra accesos no autorizados.",
|
||||
"auth_mode_local_password_note_local": "Todos los datos permanecen en su dispositivo local.",
|
||||
"auth_mode_local_password_set": "Establecer una contraseña",
|
||||
"auth_mode_local_password_set_button": "Establecer contraseña",
|
||||
"auth_mode_local_password_set_description": "Cree una contraseña segura para proteger su dispositivo JetKVM localmente.",
|
||||
"auth_mode_local_password_set_label": "Introduzca una contraseña",
|
||||
"auth_mode_local_password_set": "Establecer una contraseña",
|
||||
"auth_mode_local_password": "Contraseña",
|
||||
"auth_mode_local": "Método de autenticación local",
|
||||
"auth_signup_connect_to_cloud_action": "Registrarse y conectar dispositivo",
|
||||
"auth_signup_create_account": "Crea tu cuenta JetKVM",
|
||||
"auth_signup_create_account_action": "Crear una cuenta",
|
||||
"auth_signup_create_account_description": "Crea tu cuenta y comienza a administrar tus dispositivos con facilidad.",
|
||||
"auth_signup_create_account": "Crea tu cuenta JetKVM",
|
||||
"back_to_devices": "Volver a Dispositivos",
|
||||
"back": "Atrás",
|
||||
"back_to_devices": "Volver a Dispositivos",
|
||||
"cancel": "Cancelar",
|
||||
"close": "Cerca",
|
||||
"cloud_kvms_description": "Administre sus KVM en la nube y conéctese a ellos de forma segura.",
|
||||
"cloud_kvms_no_devices_description": "Aún no tienes ningún dispositivo con JetKVM Cloud habilitado.",
|
||||
"cloud_kvms_no_devices": "No se encontraron dispositivos",
|
||||
"cloud_kvms": "KVM en la nube",
|
||||
"cloud_kvms_description": "Administre sus KVM en la nube y conéctese a ellos de forma segura.",
|
||||
"cloud_kvms_no_devices": "No se encontraron dispositivos",
|
||||
"cloud_kvms_no_devices_description": "Aún no tienes ningún dispositivo con JetKVM Cloud habilitado.",
|
||||
"confirm": "Confirmar",
|
||||
"connect_to_kvm": "Conectarse a KVM",
|
||||
"connecting_to_device": "Conectando al dispositivo…",
|
||||
"connection_established": "Conexión establecida",
|
||||
"connection_stats_badge_jitter_buffer_avg_delay": "Retardo promedio del búfer de fluctuación",
|
||||
"connection_stats_badge_jitter": "Estar nervioso",
|
||||
"connection_stats_connection_description": "La conexión entre el cliente y JetKVM.",
|
||||
"connection_stats_badge_jitter_buffer_avg_delay": "Retardo promedio del búfer de fluctuación",
|
||||
"connection_stats_connection": "Conexión",
|
||||
"connection_stats_frames_per_second_description": "Número de fotogramas de vídeo entrantes que se muestran por segundo.",
|
||||
"connection_stats_connection_description": "La conexión entre el cliente y JetKVM.",
|
||||
"connection_stats_frames_per_second": "Fotogramas por segundo",
|
||||
"connection_stats_network_stability_description": "Qué tan constante es el flujo de paquetes de vídeo entrantes a través de la red.",
|
||||
"connection_stats_frames_per_second_description": "Número de fotogramas de vídeo entrantes que se muestran por segundo.",
|
||||
"connection_stats_network_stability": "Estabilidad de la red",
|
||||
"connection_stats_packets_lost_description": "Recuento de paquetes de vídeo RTP entrantes perdidos.",
|
||||
"connection_stats_network_stability_description": "Qué tan constante es el flujo de paquetes de vídeo entrantes a través de la red.",
|
||||
"connection_stats_packets_lost": "Paquetes perdidos",
|
||||
"connection_stats_playback_delay_description": "Retraso agregado por el buffer de fluctuación para suavizar la reproducción cuando los cuadros llegan de manera desigual.",
|
||||
"connection_stats_packets_lost_description": "Recuento de paquetes de vídeo RTP entrantes perdidos.",
|
||||
"connection_stats_playback_delay": "Retraso de reproducción",
|
||||
"connection_stats_round_trip_time_description": "Tiempo de ida y vuelta para el par de candidatos ICE activos entre pares.",
|
||||
"connection_stats_playback_delay_description": "Retraso agregado por el buffer de fluctuación para suavizar la reproducción cuando los cuadros llegan de manera desigual.",
|
||||
"connection_stats_round_trip_time": "Tiempo de ida y vuelta",
|
||||
"connection_stats_round_trip_time_description": "Tiempo de ida y vuelta para el par de candidatos ICE activos entre pares.",
|
||||
"connection_stats_sidebar": "Estadísticas de conexión",
|
||||
"connection_stats_video_description": "La transmisión de vídeo desde JetKVM al cliente.",
|
||||
"connection_stats_video": "Video",
|
||||
"connection_stats_video_description": "La transmisión de vídeo desde JetKVM al cliente.",
|
||||
"continue": "Continuar",
|
||||
"creating_peer_connection": "Creando conexión entre pares…",
|
||||
"dc_power_control_current_unit": "A",
|
||||
"dc_power_control_current": "Actual",
|
||||
"dc_power_control_current_unit": "A",
|
||||
"dc_power_control_get_state_error": "No se pudo obtener el estado de la alimentación de CC: {error}",
|
||||
"dc_power_control_power": "Fuerza",
|
||||
"dc_power_control_power_off_button": "Apagado",
|
||||
"dc_power_control_power_off_state": "Apagado",
|
||||
"dc_power_control_power_on_button": "Encendido",
|
||||
"dc_power_control_power_on_state": "Encendido",
|
||||
"dc_power_control_power_unit": "O",
|
||||
"dc_power_control_power": "Fuerza",
|
||||
"dc_power_control_restore_last_state": "Último estado",
|
||||
"dc_power_control_restore_power_state": "Restaurar pérdida de energía",
|
||||
"dc_power_control_set_power_state_error": "No se pudo enviar el estado de alimentación de CC a {enabled} : {error}",
|
||||
"dc_power_control_set_restore_state_error": "No se pudo enviar el estado de restauración de energía de CC a {state} : {error}",
|
||||
"dc_power_control_voltage_unit": "V",
|
||||
"dc_power_control_voltage": "Voltaje",
|
||||
"dc_power_control_voltage_unit": "V",
|
||||
"delete": "Borrar",
|
||||
"deregister_button": "Darse de baja de la nube",
|
||||
"deregister_cloud_devices": "Dispositivos en la nube",
|
||||
|
|
@ -226,12 +226,12 @@
|
|||
"extension_popover_load_and_manage_extensions": "Cargar y administrar sus extensiones",
|
||||
"extension_popover_set_error_notification": "No se pudo establecer la extensión activa: {error}",
|
||||
"extension_popover_unload_extension": "Extensión de descarga",
|
||||
"extension_serial_console_description": "Acceda a la extensión de su consola serie",
|
||||
"extension_serial_console": "Consola serial",
|
||||
"extensions_atx_power_control_description": "Controle el estado de energía de su máquina a través del control de energía ATX.",
|
||||
"extension_serial_console_description": "Acceda a la extensión de su consola serie",
|
||||
"extensions_atx_power_control": "Control de alimentación ATX",
|
||||
"extensions_dc_power_control_description": "Controle su extensión de alimentación de CC",
|
||||
"extensions_atx_power_control_description": "Controle el estado de energía de su máquina a través del control de energía ATX.",
|
||||
"extensions_dc_power_control": "Control de potencia de CC",
|
||||
"extensions_dc_power_control_description": "Controle su extensión de alimentación de CC",
|
||||
"extensions_popover_extensions": "Extensiones",
|
||||
"gathering_ice_candidates": "Reuniendo candidatos del ICE…",
|
||||
"general_app_version": "Aplicación: {version}",
|
||||
|
|
@ -241,8 +241,8 @@
|
|||
"general_check_for_updates": "Buscar actualizaciones",
|
||||
"general_page_description": "Configurar los ajustes del dispositivo y actualizar las preferencias",
|
||||
"general_reboot_description": "¿Desea continuar con el reinicio del sistema?",
|
||||
"general_reboot_device_description": "Apague y encienda el JetKVM",
|
||||
"general_reboot_device": "Reiniciar el dispositivo",
|
||||
"general_reboot_device_description": "Apague y encienda el JetKVM",
|
||||
"general_reboot_no_button": "No",
|
||||
"general_reboot_title": "Reiniciar JetKVM",
|
||||
"general_reboot_yes_button": "Sí",
|
||||
|
|
@ -295,9 +295,9 @@
|
|||
"hardware_display_orientation_title": "Orientación de la pantalla",
|
||||
"hardware_display_wake_up_note": "La pantalla se activará cuando cambie el estado de la conexión o cuando se toque.",
|
||||
"hardware_page_description": "Configure los ajustes de pantalla y las opciones de hardware para su dispositivo JetKVM",
|
||||
"hardware_time_10_minutes": "10 minutos",
|
||||
"hardware_time_1_hour": "1 hora",
|
||||
"hardware_time_1_minute": "1 minuto",
|
||||
"hardware_time_10_minutes": "10 minutos",
|
||||
"hardware_time_30_minutes": "30 minutos",
|
||||
"hardware_time_5_minutes": "5 minutos",
|
||||
"hardware_time_never": "Nunca",
|
||||
|
|
@ -327,6 +327,7 @@
|
|||
"invalid_password": "Contraseña inválida",
|
||||
"ip_address": "Dirección IP",
|
||||
"ipv6_address_label": "DIRECCIÓN",
|
||||
"ipv6_gateway": "Puerta",
|
||||
"ipv6_information": "Información de IPv6",
|
||||
"ipv6_link_local": "Enlace local",
|
||||
"ipv6_preferred_lifetime": "Vida útil preferida",
|
||||
|
|
@ -409,8 +410,8 @@
|
|||
"log_in": "Acceso",
|
||||
"log_out": "Finalizar la sesión",
|
||||
"logged_in_as": "Inició sesión como",
|
||||
"login_enter_password_description": "Introduzca su contraseña para acceder a su JetKVM.",
|
||||
"login_enter_password": "Ingrese su contraseña",
|
||||
"login_enter_password_description": "Introduzca su contraseña para acceder a su JetKVM.",
|
||||
"login_error": "Se produjo un error al iniciar sesión",
|
||||
"login_forgot_password": "¿Has olvidado tu contraseña?",
|
||||
"login_password_label": "Contraseña",
|
||||
|
|
@ -424,8 +425,8 @@
|
|||
"macro_name_required": "El nombre es obligatorio",
|
||||
"macro_name_too_long": "El nombre debe tener menos de 50 caracteres.",
|
||||
"macro_please_fix_validation_errors": "Por favor corrija los errores de validación",
|
||||
"macro_save_error": "Se produjo un error al guardar.",
|
||||
"macro_save": "Guardar macro",
|
||||
"macro_save_error": "Se produjo un error al guardar.",
|
||||
"macro_step_count": "{steps} / {max} pasos",
|
||||
"macro_step_duration_description": "Tiempo de espera antes de ejecutar el siguiente paso.",
|
||||
"macro_step_duration_label": "Duración del paso",
|
||||
|
|
@ -439,8 +440,8 @@
|
|||
"macro_steps_description": "Teclas/modificadores que se ejecutan en secuencia con un retraso entre cada paso.",
|
||||
"macro_steps_label": "Pasos",
|
||||
"macros_add_description": "Crear una nueva macro de teclado",
|
||||
"macros_add_new_macro": "Agregar nueva macro",
|
||||
"macros_add_new": "Agregar nueva macro",
|
||||
"macros_add_new_macro": "Agregar nueva macro",
|
||||
"macros_aria_add_new": "Agregar nueva macro",
|
||||
"macros_aria_delete": "Eliminar macro {name}",
|
||||
"macros_aria_duplicate": "Macro duplicada {name}",
|
||||
|
|
@ -462,16 +463,16 @@
|
|||
"macros_edit_button": "Editar",
|
||||
"macros_edit_description": "Modificar la macro del teclado",
|
||||
"macros_edit_title": "Editar macro",
|
||||
"macros_failed_create_error": "No se pudo crear la macro: {error}",
|
||||
"macros_failed_create": "No se pudo crear la macro",
|
||||
"macros_failed_delete_error": "No se pudo eliminar la macro: {error}",
|
||||
"macros_failed_create_error": "No se pudo crear la macro: {error}",
|
||||
"macros_failed_delete": "No se pudo eliminar la macro",
|
||||
"macros_failed_duplicate_error": "No se pudo duplicar la macro: {error}",
|
||||
"macros_failed_delete_error": "No se pudo eliminar la macro: {error}",
|
||||
"macros_failed_duplicate": "No se pudo duplicar la macro",
|
||||
"macros_failed_reorder_error": "No se pudieron reordenar las macros: {error}",
|
||||
"macros_failed_duplicate_error": "No se pudo duplicar la macro: {error}",
|
||||
"macros_failed_reorder": "No se pudieron reordenar las macros",
|
||||
"macros_failed_update_error": "No se pudo actualizar la macro: {error}",
|
||||
"macros_failed_reorder_error": "No se pudieron reordenar las macros: {error}",
|
||||
"macros_failed_update": "No se pudo actualizar la macro",
|
||||
"macros_failed_update_error": "No se pudo actualizar la macro: {error}",
|
||||
"macros_invalid_data": "Datos de macro no válidos",
|
||||
"macros_loading": "Cargando macros…",
|
||||
"macros_max_reached": "Máximo alcanzado",
|
||||
|
|
@ -506,8 +507,8 @@
|
|||
"mount_error_list_storage": "Error al listar archivos de almacenamiento: {error}",
|
||||
"mount_error_title": "Error de montaje",
|
||||
"mount_get_state_error": "No se pudo obtener el estado del medio virtual: {error}",
|
||||
"mount_jetkvm_storage_description": "Montar archivos cargados previamente desde el almacenamiento JetKVM",
|
||||
"mount_jetkvm_storage": "Soporte de almacenamiento JetKVM",
|
||||
"mount_jetkvm_storage_description": "Montar archivos cargados previamente desde el almacenamiento JetKVM",
|
||||
"mount_mode_cdrom": "CD/DVD",
|
||||
"mount_mode_disk": "Disco",
|
||||
"mount_mounted_as": "Montado como",
|
||||
|
|
@ -520,8 +521,8 @@
|
|||
"mount_popular_images": "Imágenes populares",
|
||||
"mount_streaming_from_url": "Transmisión desde URL",
|
||||
"mount_supported_formats": "Formatos compatibles: ISO, IMG",
|
||||
"mount_unmount_error": "Error al desmontar la imagen: {error}",
|
||||
"mount_unmount": "Desmontar",
|
||||
"mount_unmount_error": "Error al desmontar la imagen: {error}",
|
||||
"mount_upload_description": "Seleccione un archivo de imagen para cargar al almacenamiento de JetKVM",
|
||||
"mount_upload_error": "Error de carga: {error}",
|
||||
"mount_upload_failed_datachannel": "No se pudo crear el canal de datos para la carga de archivos",
|
||||
|
|
@ -529,8 +530,8 @@
|
|||
"mount_upload_successful": "Subida exitosa",
|
||||
"mount_upload_title": "Subir nueva imagen",
|
||||
"mount_uploaded_has_been_uploaded": "Se ha cargado {name}",
|
||||
"mount_uploading_with_name": "Subiendo {name}",
|
||||
"mount_uploading": "Subiendo…",
|
||||
"mount_uploading_with_name": "Subiendo {name}",
|
||||
"mount_url_description": "Montar archivos desde cualquier dirección web pública",
|
||||
"mount_url_input_label": "URL de la imagen",
|
||||
"mount_url_mount": "Montaje de URL",
|
||||
|
|
@ -538,10 +539,10 @@
|
|||
"mount_view_device_title": "Montar desde el almacenamiento JetKVM",
|
||||
"mount_view_url_description": "Introduzca una URL al archivo de imagen que desea montar",
|
||||
"mount_view_url_title": "Montar desde URL",
|
||||
"mount_virtual_media_description": "Montar una imagen para arrancar o instalar un sistema operativo.",
|
||||
"mount_virtual_media_source_description": "Elige cómo quieres montar tus medios virtuales",
|
||||
"mount_virtual_media_source": "Fuente de medios virtuales",
|
||||
"mount_virtual_media": "Medios virtuales",
|
||||
"mount_virtual_media_description": "Montar una imagen para arrancar o instalar un sistema operativo.",
|
||||
"mount_virtual_media_source": "Fuente de medios virtuales",
|
||||
"mount_virtual_media_source_description": "Elige cómo quieres montar tus medios virtuales",
|
||||
"mouse_alt_finger": "Dedo tocando una pantalla",
|
||||
"mouse_alt_mouse": "Icono del ratón",
|
||||
"mouse_description": "Configure el comportamiento del cursor y los ajustes de interacción para su dispositivo",
|
||||
|
|
@ -558,10 +559,10 @@
|
|||
"mouse_jiggler_light": "Luz - 5m",
|
||||
"mouse_jiggler_standard": "Estándar - 1 m",
|
||||
"mouse_jiggler_title": "Jiggler",
|
||||
"mouse_mode_absolute_description": "Lo más conveniente",
|
||||
"mouse_mode_absolute": "Absoluto",
|
||||
"mouse_mode_relative_description": "Más compatible",
|
||||
"mouse_mode_absolute_description": "Lo más conveniente",
|
||||
"mouse_mode_relative": "Relativo",
|
||||
"mouse_mode_relative_description": "Más compatible",
|
||||
"mouse_modes_description": "Elija el modo de entrada del mouse",
|
||||
"mouse_modes_title": "Modos",
|
||||
"mouse_scroll_high": "Alto",
|
||||
|
|
@ -574,12 +575,17 @@
|
|||
"mouse_title": "Ratón",
|
||||
"network_custom_domain": "Dominio personalizado",
|
||||
"network_description": "Configurar sus ajustes de red",
|
||||
"network_dhcp_client_description": "Configurar qué cliente DHCP utilizar",
|
||||
"network_dhcp_client_jetkvm": "JetKVM interno",
|
||||
"network_dhcp_client_title": "Cliente DHCP",
|
||||
"network_dhcp_information": "Información de DHCP",
|
||||
"network_dhcp_lease_renew_confirm_description": "Esto solicitará una nueva dirección IP a su servidor DHCP. Es posible que su dispositivo pierda temporalmente la conexión a la red durante este proceso.",
|
||||
"network_dhcp_lease_renew": "Renovar la concesión de DHCP",
|
||||
"network_dhcp_lease_renew_confirm": "Renovar el contrato de arrendamiento",
|
||||
"network_dhcp_lease_renew_confirm_description": "Esto solicitará una nueva dirección IP a su servidor DHCP. Es posible que su dispositivo pierda temporalmente la conexión a la red durante este proceso.",
|
||||
"network_dhcp_lease_renew_confirm_new_a": "Si recibe una nueva dirección IP",
|
||||
"network_dhcp_lease_renew_confirm_new_b": "Es posible que necesites volver a conectarte usando la nueva dirección",
|
||||
"network_dhcp_lease_renew_failed": "No se pudo renovar el contrato de arrendamiento: {error}",
|
||||
"network_dhcp_lease_renew_success": "Se renovó la concesión de DHCP",
|
||||
"network_dhcp_lease_renew": "Renovar la concesión de DHCP",
|
||||
"network_domain_custom": "Costumbre",
|
||||
"network_domain_description": "Sufijo de dominio de red para el dispositivo",
|
||||
"network_domain_dhcp_provided": "DHCP proporcionado",
|
||||
|
|
@ -588,21 +594,35 @@
|
|||
"network_hostname_description": "Identificador del dispositivo en la red. En blanco para el valor predeterminado del sistema.",
|
||||
"network_hostname_title": "Nombre de host",
|
||||
"network_http_proxy_description": "Servidor proxy para solicitudes HTTP(S) salientes desde el dispositivo. En blanco si no hay ninguna.",
|
||||
"network_http_proxy_invalid": "URL de proxy HTTP no válida",
|
||||
"network_http_proxy_title": "Proxy HTTP",
|
||||
"network_ipv4_address": "Dirección IPv4",
|
||||
"network_ipv4_dns": "DNS IPv4",
|
||||
"network_ipv4_gateway": "Puerta de enlace IPv4",
|
||||
"network_ipv4_mode_description": "Configurar el modo IPv4",
|
||||
"network_ipv4_mode_dhcp": "DHCP",
|
||||
"network_ipv4_mode_static": "Estático",
|
||||
"network_ipv4_mode_title": "Modo IPv4",
|
||||
"network_ipv4_netmask": "Máscara de red IPv4",
|
||||
"network_ipv6_address": "Dirección IPv6",
|
||||
"network_ipv6_information": "Información de IPv6",
|
||||
"network_ipv6_mode_description": "Configurar el modo IPv6",
|
||||
"network_ipv6_mode_dhcpv6": "DHCPv6",
|
||||
"network_ipv6_mode_disabled": "Desactivado",
|
||||
"network_ipv6_mode_link_local": "Solo enlace local",
|
||||
"network_ipv6_mode_slaac": "SLAAC",
|
||||
"network_ipv6_mode_slaac_dhcpv6": "SLAAC + DHCPv6",
|
||||
"network_ipv6_mode_static": "Estático",
|
||||
"network_ipv6_mode_title": "Modo IPv6",
|
||||
"network_ipv6_netmask": "Máscara de red IPv6",
|
||||
"network_ipv6_no_addresses": "No hay direcciones IPv6 configuradas",
|
||||
"network_ll_dp_all": "Todo",
|
||||
"network_ll_dp_basic": "Básico",
|
||||
"network_ll_dp_description": "Controlar qué TLV se enviarán a través del Protocolo de descubrimiento de capa de enlace",
|
||||
"network_ll_dp_disabled": "Desactivado",
|
||||
"network_ll_dp_title": "LLDP",
|
||||
"network_mac_address_copy_error": "No se pudo copiar la dirección MAC",
|
||||
"network_mac_address_copy_success": "Dirección MAC { mac } copiada al portapapeles",
|
||||
"network_mac_address_description": "Identificador de hardware para la interfaz de red",
|
||||
"network_mac_address_title": "Dirección MAC",
|
||||
"network_mdns_auto": "Auto",
|
||||
|
|
@ -612,9 +632,19 @@
|
|||
"network_mdns_ipv6_only": "Sólo IPv6",
|
||||
"network_mdns_title": "mDNS",
|
||||
"network_no_dhcp_lease": "No hay información de arrendamiento de DHCP disponible",
|
||||
"network_no_information_description": "No hay configuración de red disponible",
|
||||
"network_no_information_headline": "Información de la red",
|
||||
"network_pending_dhcp_mode_change_description": "Guardar la configuración para habilitar el modo DHCP y ver la información de arrendamiento",
|
||||
"network_pending_dhcp_mode_change_headline": "Cambio de modo DHCP IPv4 pendiente",
|
||||
"network_save_settings": "Guardar configuración",
|
||||
"network_save_settings_apply_title": "Aplicar configuración de red",
|
||||
"network_save_settings_confirm": "Aplicar cambios",
|
||||
"network_save_settings_confirm_description": "Se aplicará la siguiente configuración de red. Estos cambios pueden requerir un reinicio y causar una breve desconexión.",
|
||||
"network_save_settings_confirm_heading": "Cambios de configuración",
|
||||
"network_save_settings_failed": "No se pudo guardar la configuración de red: {error}",
|
||||
"network_save_settings_success": "Configuración de red guardada",
|
||||
"network_save_settings": "Guardar configuración",
|
||||
"network_settings_invalid_ipv4_cidr": "Notación CIDR no válida para la dirección IPv4",
|
||||
"network_settings_load_error": "No se pudo cargar la configuración de red: {error}",
|
||||
"network_time_sync_description": "Configurar los ajustes de sincronización horaria",
|
||||
"network_time_sync_http_only": "Sólo HTTP",
|
||||
"network_time_sync_ntp_and_http": "NTP y HTTP",
|
||||
|
|
@ -640,8 +670,8 @@
|
|||
"paste_modal_failed_paste": "No se pudo pegar el texto: {error}",
|
||||
"paste_modal_invalid_chars_intro": "Los siguientes caracteres no se pegarán:",
|
||||
"paste_modal_paste_from_host": "Pegar desde el host",
|
||||
"paste_modal_paste_text_description": "Pegue el texto de su cliente al host remoto",
|
||||
"paste_modal_paste_text": "Pegar texto",
|
||||
"paste_modal_paste_text_description": "Pegue el texto de su cliente al host remoto",
|
||||
"paste_modal_sending_using_layout": "Envío de texto mediante la distribución del teclado: {iso} - {name}",
|
||||
"peer_connection_closed": "Cerrado",
|
||||
"peer_connection_closing": "Cierre",
|
||||
|
|
@ -668,20 +698,20 @@
|
|||
"retry": "Rever",
|
||||
"saving": "Ahorro…",
|
||||
"search_placeholder": "Buscar…",
|
||||
"serial_console": "Consola serial",
|
||||
"serial_console_baud_rate": "Tasa de Baud",
|
||||
"serial_console_configure_description": "Configure los ajustes de su consola serie",
|
||||
"serial_console_data_bits": "Bits de datos",
|
||||
"serial_console_get_settings_error": "No se pudo obtener la configuración de la consola serial: {error}",
|
||||
"serial_console_open_console": "Consola abierta",
|
||||
"serial_console_parity": "Paridad",
|
||||
"serial_console_parity_even": "Paridad uniforme",
|
||||
"serial_console_parity_mark": "Paridad de marca",
|
||||
"serial_console_parity_none": "Sin paridad",
|
||||
"serial_console_parity_odd": "Paridad impar",
|
||||
"serial_console_parity_space": "Paridad espacial",
|
||||
"serial_console_parity": "Paridad",
|
||||
"serial_console_set_settings_error": "No se pudieron establecer los ajustes de la consola serial en {settings} : {error}",
|
||||
"serial_console_stop_bits": "Bits de parada",
|
||||
"serial_console": "Consola serial",
|
||||
"setting_remote_description": "Configuración de la descripción remota",
|
||||
"setting_remote_session_description": "Establecer la descripción de la sesión remota...",
|
||||
"setting_up_connection_to_device": "Configurando la conexión al dispositivo...",
|
||||
|
|
@ -691,8 +721,8 @@
|
|||
"settings_back_to_kvm": "Volver a KVM",
|
||||
"settings_general": "General",
|
||||
"settings_hardware": "Hardware",
|
||||
"settings_keyboard_macros": "Macros del teclado",
|
||||
"settings_keyboard": "Teclado",
|
||||
"settings_keyboard_macros": "Macros del teclado",
|
||||
"settings_mouse": "Ratón",
|
||||
"settings_network": "Red",
|
||||
"settings_video": "Video",
|
||||
|
|
@ -712,6 +742,7 @@
|
|||
"updates_failed_check": "No se pudieron buscar actualizaciones: {error}",
|
||||
"updates_failed_get_device_version": "No se pudo obtener la versión del dispositivo: {error}",
|
||||
"updating_leave_device_on": "Por favor, no apagues tu dispositivo…",
|
||||
"usb": "USB",
|
||||
"usb_config_custom": "Costumbre",
|
||||
"usb_config_default": "JetKVM predeterminado",
|
||||
"usb_config_dell": "Teclado multimedia Dell Pro",
|
||||
|
|
@ -758,7 +789,6 @@
|
|||
"usb_state_connecting": "Conectando",
|
||||
"usb_state_disconnected": "Desconectado",
|
||||
"usb_state_low_power_mode": "Modo de bajo consumo",
|
||||
"usb": "USB",
|
||||
"user_interface_language_description": "Seleccione el idioma que se utilizará en la interfaz de usuario de JetKVM",
|
||||
"user_interface_language_title": "Lenguaje de interfaz",
|
||||
"video_brightness_description": "Nivel de brillo ( {value} x)",
|
||||
|
|
@ -825,6 +855,7 @@
|
|||
"video_title": "Video",
|
||||
"view_details": "Ver detalles",
|
||||
"virtual_keyboard_header": "Teclado virtual",
|
||||
"wake_on_lan": "Activación en LAN",
|
||||
"wake_on_lan_add_device_device_name": "Nombre del dispositivo",
|
||||
"wake_on_lan_add_device_example_device_name": "Servidor multimedia Plex",
|
||||
"wake_on_lan_add_device_mac_address": "Dirección MAC",
|
||||
|
|
@ -840,7 +871,6 @@
|
|||
"wake_on_lan_failed_send_magic": "No se pudo enviar el paquete mágico",
|
||||
"wake_on_lan_invalid_mac": "Dirección MAC no válida",
|
||||
"wake_on_lan_magic_sent_success": "Paquete mágico enviado con éxito",
|
||||
"wake_on_lan": "Activación en LAN",
|
||||
"welcome_to_jetkvm_description": "Controla cualquier computadora de forma remota",
|
||||
"welcome_to_jetkvm": "Bienvenido a JetKVM"
|
||||
"welcome_to_jetkvm": "Bienvenido a JetKVM",
|
||||
"welcome_to_jetkvm_description": "Controla cualquier computadora de forma remota"
|
||||
}
|
||||
|
|
@ -107,10 +107,10 @@
|
|||
"already_adopted_title": "Appareil déjà enregistré",
|
||||
"appearance_description": "Choisissez votre thème de couleur préféré",
|
||||
"appearance_page_description": "Personnalisez l'apparence de votre interface JetKVM",
|
||||
"appearance_theme": "Thème",
|
||||
"appearance_theme_dark": "Sombre",
|
||||
"appearance_theme_light": "Lumière",
|
||||
"appearance_theme_system": "Système",
|
||||
"appearance_theme": "Thème",
|
||||
"appearance_title": "Apparence",
|
||||
"attach": "Attacher",
|
||||
"atx_power_control_get_state_error": "Échec de l'obtention de l'état d'alimentation ATX : {error}",
|
||||
|
|
@ -121,85 +121,85 @@
|
|||
"atx_power_control_reset_button": "Réinitialiser",
|
||||
"atx_power_control_send_action_error": "Échec de l'envoi de l'action d'alimentation ATX {action} : {error}",
|
||||
"atx_power_control_short_power_button": "Appui court",
|
||||
"auth_authentication_mode": "Veuillez sélectionner un mode d'authentification",
|
||||
"auth_authentication_mode_error": "Une erreur s'est produite lors de la définition du mode d'authentification",
|
||||
"auth_authentication_mode_invalid": "Mode d'authentification non valide",
|
||||
"auth_authentication_mode": "Veuillez sélectionner un mode d'authentification",
|
||||
"auth_connect_to_cloud": "Connectez votre JetKVM au cloud",
|
||||
"auth_connect_to_cloud_action": "Connectez-vous et connectez l'appareil",
|
||||
"auth_connect_to_cloud_description": "Déverrouillez l'accès à distance et les fonctionnalités avancées de votre appareil",
|
||||
"auth_connect_to_cloud": "Connectez votre JetKVM au cloud",
|
||||
"auth_header_cta_already_have_account": "Vous avez déjà un compte ?",
|
||||
"auth_header_cta_dont_have_account": "Vous n'avez pas de compte ?",
|
||||
"auth_header_cta_new_to_jetkvm": "Nouveau sur JetKVM ?",
|
||||
"auth_login": "Connectez-vous à votre compte JetKVM",
|
||||
"auth_login_action": "Se connecter",
|
||||
"auth_login_description": "Connectez-vous pour accéder et gérer vos appareils en toute sécurité",
|
||||
"auth_login": "Connectez-vous à votre compte JetKVM",
|
||||
"auth_mode_local": "Méthode d'authentification locale",
|
||||
"auth_mode_local_change_later": "Vous pouvez toujours modifier votre méthode d'authentification ultérieurement dans les paramètres.",
|
||||
"auth_mode_local_description": "Sélectionnez la manière dont vous souhaitez sécuriser votre périphérique JetKVM localement.",
|
||||
"auth_mode_local_no_password_description": "Accès rapide sans authentification par mot de passe.",
|
||||
"auth_mode_local_no_password": "Pas de mot de passe",
|
||||
"auth_mode_local_no_password_description": "Accès rapide sans authentification par mot de passe.",
|
||||
"auth_mode_local_password": "Mot de passe",
|
||||
"auth_mode_local_password_confirm_description": "Confirmez votre mot de passe",
|
||||
"auth_mode_local_password_confirm_label": "Confirmez le mot de passe",
|
||||
"auth_mode_local_password_description": "Sécurisez votre appareil avec un mot de passe pour une protection supplémentaire.",
|
||||
"auth_mode_local_password_do_not_match": "Les mots de passe ne correspondent pas",
|
||||
"auth_mode_local_password_failed_set": "Échec de la définition du mot de passe : {error}",
|
||||
"auth_mode_local_password_note_local": "Toutes les données restent sur votre appareil local.",
|
||||
"auth_mode_local_password_note": "Ce mot de passe sera utilisé pour sécuriser les données de votre appareil et les protéger contre tout accès non autorisé.",
|
||||
"auth_mode_local_password_note_local": "Toutes les données restent sur votre appareil local.",
|
||||
"auth_mode_local_password_set": "Définir un mot de passe",
|
||||
"auth_mode_local_password_set_button": "Définir le mot de passe",
|
||||
"auth_mode_local_password_set_description": "Créez un mot de passe fort pour sécuriser votre périphérique JetKVM localement.",
|
||||
"auth_mode_local_password_set_label": "Entrez un mot de passe",
|
||||
"auth_mode_local_password_set": "Définir un mot de passe",
|
||||
"auth_mode_local_password": "Mot de passe",
|
||||
"auth_mode_local": "Méthode d'authentification locale",
|
||||
"auth_signup_connect_to_cloud_action": "Inscription et connexion de l'appareil",
|
||||
"auth_signup_create_account": "Créez votre compte JetKVM",
|
||||
"auth_signup_create_account_action": "Créer un compte",
|
||||
"auth_signup_create_account_description": "Créez votre compte et commencez à gérer vos appareils en toute simplicité.",
|
||||
"auth_signup_create_account": "Créez votre compte JetKVM",
|
||||
"back_to_devices": "Retour aux appareils",
|
||||
"back": "Dos",
|
||||
"back_to_devices": "Retour aux appareils",
|
||||
"cancel": "Annuler",
|
||||
"close": "Fermer",
|
||||
"cloud_kvms_description": "Gérez vos KVM cloud et connectez-vous à eux en toute sécurité.",
|
||||
"cloud_kvms_no_devices_description": "Vous n'avez pas encore d'appareils avec JetKVM Cloud activé.",
|
||||
"cloud_kvms_no_devices": "Aucun appareil trouvé",
|
||||
"cloud_kvms": "KVM Cloud",
|
||||
"cloud_kvms_description": "Gérez vos KVM cloud et connectez-vous à eux en toute sécurité.",
|
||||
"cloud_kvms_no_devices": "Aucun appareil trouvé",
|
||||
"cloud_kvms_no_devices_description": "Vous n'avez pas encore d'appareils avec JetKVM Cloud activé.",
|
||||
"confirm": "Confirmer",
|
||||
"connect_to_kvm": "Se connecter à KVM",
|
||||
"connecting_to_device": "Connexion à l'appareil…",
|
||||
"connection_established": "Connexion établie",
|
||||
"connection_stats_badge_jitter_buffer_avg_delay": "Délai moyen du tampon de gigue",
|
||||
"connection_stats_badge_jitter": "Gigue",
|
||||
"connection_stats_connection_description": "La connexion entre le client et le JetKVM.",
|
||||
"connection_stats_badge_jitter_buffer_avg_delay": "Délai moyen du tampon de gigue",
|
||||
"connection_stats_connection": "Connexion",
|
||||
"connection_stats_frames_per_second_description": "Nombre d'images vidéo entrantes affichées par seconde.",
|
||||
"connection_stats_connection_description": "La connexion entre le client et le JetKVM.",
|
||||
"connection_stats_frames_per_second": "Images par seconde",
|
||||
"connection_stats_network_stability_description": "La stabilité du flux de paquets vidéo entrants sur le réseau.",
|
||||
"connection_stats_frames_per_second_description": "Nombre d'images vidéo entrantes affichées par seconde.",
|
||||
"connection_stats_network_stability": "Stabilité du réseau",
|
||||
"connection_stats_packets_lost_description": "Nombre de paquets vidéo RTP entrants perdus.",
|
||||
"connection_stats_network_stability_description": "La stabilité du flux de paquets vidéo entrants sur le réseau.",
|
||||
"connection_stats_packets_lost": "Paquets perdus",
|
||||
"connection_stats_playback_delay_description": "Retard ajouté par le tampon de gigue pour fluidifier la lecture lorsque les images arrivent de manière inégale.",
|
||||
"connection_stats_packets_lost_description": "Nombre de paquets vidéo RTP entrants perdus.",
|
||||
"connection_stats_playback_delay": "Délai de lecture",
|
||||
"connection_stats_round_trip_time_description": "Temps de trajet aller-retour pour la paire de candidats ICE actifs entre pairs.",
|
||||
"connection_stats_playback_delay_description": "Retard ajouté par le tampon de gigue pour fluidifier la lecture lorsque les images arrivent de manière inégale.",
|
||||
"connection_stats_round_trip_time": "Temps de trajet aller-retour",
|
||||
"connection_stats_round_trip_time_description": "Temps de trajet aller-retour pour la paire de candidats ICE actifs entre pairs.",
|
||||
"connection_stats_sidebar": "Statistiques de connexion",
|
||||
"connection_stats_video_description": "Le flux vidéo du JetKVM vers le client.",
|
||||
"connection_stats_video": "Vidéo",
|
||||
"connection_stats_video_description": "Le flux vidéo du JetKVM vers le client.",
|
||||
"continue": "Continuer",
|
||||
"creating_peer_connection": "Créer des liens entre pairs…",
|
||||
"dc_power_control_current_unit": "UN",
|
||||
"dc_power_control_current": "Actuel",
|
||||
"dc_power_control_current_unit": "UN",
|
||||
"dc_power_control_get_state_error": "Échec de l'obtention de l'état d'alimentation CC : {error}",
|
||||
"dc_power_control_power": "Pouvoir",
|
||||
"dc_power_control_power_off_button": "Éteindre",
|
||||
"dc_power_control_power_off_state": "Éteindre",
|
||||
"dc_power_control_power_on_button": "Mise sous tension",
|
||||
"dc_power_control_power_on_state": "Mise sous tension",
|
||||
"dc_power_control_power_unit": "W",
|
||||
"dc_power_control_power": "Pouvoir",
|
||||
"dc_power_control_restore_last_state": "Dernier état",
|
||||
"dc_power_control_restore_power_state": "Restaurer la perte de puissance",
|
||||
"dc_power_control_set_power_state_error": "Échec de l'envoi de l'état d'alimentation CC à {enabled} : {error}",
|
||||
"dc_power_control_set_restore_state_error": "Échec de l'envoi de l'état de restauration de l'alimentation CC à {state} : {error}",
|
||||
"dc_power_control_voltage_unit": "V",
|
||||
"dc_power_control_voltage": "Tension",
|
||||
"dc_power_control_voltage_unit": "V",
|
||||
"delete": "Supprimer",
|
||||
"deregister_button": "Se désinscrire du Cloud",
|
||||
"deregister_cloud_devices": "Appareils Cloud",
|
||||
|
|
@ -226,12 +226,12 @@
|
|||
"extension_popover_load_and_manage_extensions": "Chargez et gérez vos extensions",
|
||||
"extension_popover_set_error_notification": "Échec de la définition de l'extension active : {error}",
|
||||
"extension_popover_unload_extension": "Extension de déchargement",
|
||||
"extension_serial_console_description": "Accédez à votre extension de console série",
|
||||
"extension_serial_console": "Console série",
|
||||
"extensions_atx_power_control_description": "Contrôlez l'état d'alimentation de votre machine via le contrôle d'alimentation ATX.",
|
||||
"extension_serial_console_description": "Accédez à votre extension de console série",
|
||||
"extensions_atx_power_control": "Contrôle d'alimentation ATX",
|
||||
"extensions_dc_power_control_description": "Contrôlez votre extension d'alimentation CC",
|
||||
"extensions_atx_power_control_description": "Contrôlez l'état d'alimentation de votre machine via le contrôle d'alimentation ATX.",
|
||||
"extensions_dc_power_control": "Contrôle de l'alimentation CC",
|
||||
"extensions_dc_power_control_description": "Contrôlez votre extension d'alimentation CC",
|
||||
"extensions_popover_extensions": "Extensions",
|
||||
"gathering_ice_candidates": "Rassemblement des candidats de l'ICE…",
|
||||
"general_app_version": "Application : {version}",
|
||||
|
|
@ -241,8 +241,8 @@
|
|||
"general_check_for_updates": "Vérifier les mises à jour",
|
||||
"general_page_description": "Configurer les paramètres de l'appareil et mettre à jour les préférences",
|
||||
"general_reboot_description": "Voulez-vous procéder au redémarrage du système ?",
|
||||
"general_reboot_device_description": "Redémarrez le JetKVM",
|
||||
"general_reboot_device": "Redémarrer l'appareil",
|
||||
"general_reboot_device_description": "Redémarrez le JetKVM",
|
||||
"general_reboot_no_button": "Non",
|
||||
"general_reboot_title": "Redémarrer JetKVM",
|
||||
"general_reboot_yes_button": "Oui",
|
||||
|
|
@ -295,9 +295,9 @@
|
|||
"hardware_display_orientation_title": "Orientation de l'affichage",
|
||||
"hardware_display_wake_up_note": "L'écran se réveille lorsque l'état de connexion change ou lorsqu'il est touché.",
|
||||
"hardware_page_description": "Configurer les paramètres d'affichage et les options matérielles de votre périphérique JetKVM",
|
||||
"hardware_time_10_minutes": "10 minutes",
|
||||
"hardware_time_1_hour": "1 heure",
|
||||
"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": "Jamais",
|
||||
|
|
@ -327,6 +327,7 @@
|
|||
"invalid_password": "Mot de passe invalide",
|
||||
"ip_address": "Adresse IP",
|
||||
"ipv6_address_label": "Adresse",
|
||||
"ipv6_gateway": "Porte",
|
||||
"ipv6_information": "Informations IPv6",
|
||||
"ipv6_link_local": "Lien local",
|
||||
"ipv6_preferred_lifetime": "Durée de vie préférée",
|
||||
|
|
@ -409,8 +410,8 @@
|
|||
"log_in": "Se connecter",
|
||||
"log_out": "Se déconnecter",
|
||||
"logged_in_as": "Connecté en tant que",
|
||||
"login_enter_password_description": "Entrez votre mot de passe pour accéder à votre JetKVM.",
|
||||
"login_enter_password": "Entrez votre mot de passe",
|
||||
"login_enter_password_description": "Entrez votre mot de passe pour accéder à votre JetKVM.",
|
||||
"login_error": "Une erreur s'est produite lors de la connexion",
|
||||
"login_forgot_password": "Mot de passe oublié?",
|
||||
"login_password_label": "Mot de passe",
|
||||
|
|
@ -424,8 +425,8 @@
|
|||
"macro_name_required": "Le nom est obligatoire",
|
||||
"macro_name_too_long": "Le nom doit comporter moins de 50 caractères",
|
||||
"macro_please_fix_validation_errors": "Veuillez corriger les erreurs de validation",
|
||||
"macro_save_error": "Une erreur s'est produite lors de l'enregistrement.",
|
||||
"macro_save": "Enregistrer la macro",
|
||||
"macro_save_error": "Une erreur s'est produite lors de l'enregistrement.",
|
||||
"macro_step_count": "{steps} / {max} étapes",
|
||||
"macro_step_duration_description": "Il est temps d’attendre avant d’exécuter l’étape suivante.",
|
||||
"macro_step_duration_label": "Durée de l'étape",
|
||||
|
|
@ -439,8 +440,8 @@
|
|||
"macro_steps_description": "Clés/modificateurs exécutés en séquence avec un délai entre chaque étape.",
|
||||
"macro_steps_label": "Mesures",
|
||||
"macros_add_description": "Créer une nouvelle macro de clavier",
|
||||
"macros_add_new_macro": "Ajouter une nouvelle macro",
|
||||
"macros_add_new": "Ajouter une nouvelle macro",
|
||||
"macros_add_new_macro": "Ajouter une nouvelle macro",
|
||||
"macros_aria_add_new": "Ajouter une nouvelle macro",
|
||||
"macros_aria_delete": "Supprimer la macro {name}",
|
||||
"macros_aria_duplicate": "Macro dupliquée {name}",
|
||||
|
|
@ -462,16 +463,16 @@
|
|||
"macros_edit_button": "Modifier",
|
||||
"macros_edit_description": "Modifiez votre macro de clavier",
|
||||
"macros_edit_title": "Modifier la macro",
|
||||
"macros_failed_create_error": "Échec de la création de la macro : {error}",
|
||||
"macros_failed_create": "Échec de la création de la macro",
|
||||
"macros_failed_delete_error": "Échec de la suppression de la macro : {error}",
|
||||
"macros_failed_create_error": "Échec de la création de la macro : {error}",
|
||||
"macros_failed_delete": "Échec de la suppression de la macro",
|
||||
"macros_failed_duplicate_error": "Échec de la duplication de la macro : {error}",
|
||||
"macros_failed_delete_error": "Échec de la suppression de la macro : {error}",
|
||||
"macros_failed_duplicate": "Échec de la duplication de la macro",
|
||||
"macros_failed_reorder_error": "Échec de la réorganisation des macros : {error}",
|
||||
"macros_failed_duplicate_error": "Échec de la duplication de la macro : {error}",
|
||||
"macros_failed_reorder": "Échec de la réorganisation des macros",
|
||||
"macros_failed_update_error": "Échec de la mise à jour de la macro : {error}",
|
||||
"macros_failed_reorder_error": "Échec de la réorganisation des macros : {error}",
|
||||
"macros_failed_update": "Échec de la mise à jour de la macro",
|
||||
"macros_failed_update_error": "Échec de la mise à jour de la macro : {error}",
|
||||
"macros_invalid_data": "Données de macro non valides",
|
||||
"macros_loading": "Chargement des macros…",
|
||||
"macros_max_reached": "Max atteint",
|
||||
|
|
@ -506,8 +507,8 @@
|
|||
"mount_error_list_storage": "Erreur lors de la liste des fichiers de stockage : {error}",
|
||||
"mount_error_title": "Erreur de montage",
|
||||
"mount_get_state_error": "Échec de l'obtention de l'état du support virtuel : {error}",
|
||||
"mount_jetkvm_storage_description": "Monter les fichiers précédemment téléchargés à partir du stockage JetKVM",
|
||||
"mount_jetkvm_storage": "Support de stockage JetKVM",
|
||||
"mount_jetkvm_storage_description": "Monter les fichiers précédemment téléchargés à partir du stockage JetKVM",
|
||||
"mount_mode_cdrom": "CD/DVD",
|
||||
"mount_mode_disk": "Disque",
|
||||
"mount_mounted_as": "Monté comme",
|
||||
|
|
@ -520,8 +521,8 @@
|
|||
"mount_popular_images": "Images populaires",
|
||||
"mount_streaming_from_url": "Diffusion à partir d'une URL",
|
||||
"mount_supported_formats": "Formats pris en charge : ISO, IMG",
|
||||
"mount_unmount_error": "Échec du démontage de l'image : {error}",
|
||||
"mount_unmount": "Démonter",
|
||||
"mount_unmount_error": "Échec du démontage de l'image : {error}",
|
||||
"mount_upload_description": "Sélectionnez un fichier image à télécharger sur le stockage JetKVM",
|
||||
"mount_upload_error": "Erreur de téléchargement : {error}",
|
||||
"mount_upload_failed_datachannel": "Échec de la création du canal de données pour le téléchargement du fichier",
|
||||
|
|
@ -529,8 +530,8 @@
|
|||
"mount_upload_successful": "Téléchargement réussi",
|
||||
"mount_upload_title": "Télécharger une nouvelle image",
|
||||
"mount_uploaded_has_been_uploaded": "{name} a été téléchargé",
|
||||
"mount_uploading_with_name": "Téléchargement de {name}",
|
||||
"mount_uploading": "Téléchargement en cours…",
|
||||
"mount_uploading_with_name": "Téléchargement de {name}",
|
||||
"mount_url_description": "Monter des fichiers à partir de n'importe quelle adresse Web publique",
|
||||
"mount_url_input_label": "URL de l'image",
|
||||
"mount_url_mount": "Montage d'URL",
|
||||
|
|
@ -538,10 +539,10 @@
|
|||
"mount_view_device_title": "Montage à partir du stockage JetKVM",
|
||||
"mount_view_url_description": "Entrez une URL vers le fichier image à monter",
|
||||
"mount_view_url_title": "Monter à partir de l'URL",
|
||||
"mount_virtual_media_description": "Monter une image pour démarrer ou installer un système d'exploitation.",
|
||||
"mount_virtual_media_source_description": "Choisissez comment vous souhaitez monter votre support virtuel",
|
||||
"mount_virtual_media_source": "Source de média virtuel",
|
||||
"mount_virtual_media": "Médias virtuels",
|
||||
"mount_virtual_media_description": "Monter une image pour démarrer ou installer un système d'exploitation.",
|
||||
"mount_virtual_media_source": "Source de média virtuel",
|
||||
"mount_virtual_media_source_description": "Choisissez comment vous souhaitez monter votre support virtuel",
|
||||
"mouse_alt_finger": "Doigt touchant un écran",
|
||||
"mouse_alt_mouse": "Icône de la souris",
|
||||
"mouse_description": "Configurer le comportement du curseur et les paramètres d'interaction pour votre appareil",
|
||||
|
|
@ -558,10 +559,10 @@
|
|||
"mouse_jiggler_light": "Lumière - 5m",
|
||||
"mouse_jiggler_standard": "Norme - 1 m",
|
||||
"mouse_jiggler_title": "Jiggler",
|
||||
"mouse_mode_absolute_description": "Le plus pratique",
|
||||
"mouse_mode_absolute": "Absolu",
|
||||
"mouse_mode_relative_description": "Le plus compatible",
|
||||
"mouse_mode_absolute_description": "Le plus pratique",
|
||||
"mouse_mode_relative": "Relatif",
|
||||
"mouse_mode_relative_description": "Le plus compatible",
|
||||
"mouse_modes_description": "Choisissez le mode de saisie de la souris",
|
||||
"mouse_modes_title": "Modes",
|
||||
"mouse_scroll_high": "Haut",
|
||||
|
|
@ -574,12 +575,17 @@
|
|||
"mouse_title": "Souris",
|
||||
"network_custom_domain": "Domaine personnalisé",
|
||||
"network_description": "Configurez vos paramètres réseau",
|
||||
"network_dhcp_client_description": "Configurer le client DHCP à utiliser",
|
||||
"network_dhcp_client_jetkvm": "JetKVM interne",
|
||||
"network_dhcp_client_title": "Client DHCP",
|
||||
"network_dhcp_information": "Informations DHCP",
|
||||
"network_dhcp_lease_renew_confirm_description": "Cette opération demandera une nouvelle adresse IP à votre serveur DHCP. Votre appareil pourrait perdre temporairement sa connectivité réseau pendant cette opération.",
|
||||
"network_dhcp_lease_renew": "Renouveler le bail DHCP",
|
||||
"network_dhcp_lease_renew_confirm": "Renouveler le bail",
|
||||
"network_dhcp_lease_renew_confirm_description": "Cette opération demandera une nouvelle adresse IP à votre serveur DHCP. Votre appareil pourrait perdre temporairement sa connectivité réseau pendant cette opération.",
|
||||
"network_dhcp_lease_renew_confirm_new_a": "Si vous recevez une nouvelle adresse IP",
|
||||
"network_dhcp_lease_renew_confirm_new_b": "vous devrez peut-être vous reconnecter en utilisant la nouvelle adresse",
|
||||
"network_dhcp_lease_renew_failed": "Échec du renouvellement du bail : {error}",
|
||||
"network_dhcp_lease_renew_success": "Renouvellement du bail DHCP",
|
||||
"network_dhcp_lease_renew": "Renouveler le bail DHCP",
|
||||
"network_domain_custom": "Coutume",
|
||||
"network_domain_description": "Suffixe de domaine réseau pour l'appareil",
|
||||
"network_domain_dhcp_provided": "DHCP fourni",
|
||||
|
|
@ -588,21 +594,35 @@
|
|||
"network_hostname_description": "Identifiant de l'appareil sur le réseau. Vide pour la valeur par défaut du système.",
|
||||
"network_hostname_title": "Nom d'hôte",
|
||||
"network_http_proxy_description": "Serveur proxy pour les requêtes HTTP(S) sortantes de l'appareil. Vide pour aucun.",
|
||||
"network_http_proxy_invalid": "URL de proxy HTTP non valide",
|
||||
"network_http_proxy_title": "Proxy HTTP",
|
||||
"network_ipv4_address": "Adresse IPv4",
|
||||
"network_ipv4_dns": "DNS IPv4",
|
||||
"network_ipv4_gateway": "Passerelle IPv4",
|
||||
"network_ipv4_mode_description": "Configurer le mode IPv4",
|
||||
"network_ipv4_mode_dhcp": "DHCP",
|
||||
"network_ipv4_mode_static": "Statique",
|
||||
"network_ipv4_mode_title": "Mode IPv4",
|
||||
"network_ipv4_netmask": "Masque de réseau IPv4",
|
||||
"network_ipv6_address": "Adresse IPv6",
|
||||
"network_ipv6_information": "Informations IPv6",
|
||||
"network_ipv6_mode_description": "Configurer le mode IPv6",
|
||||
"network_ipv6_mode_dhcpv6": "DHCPv6",
|
||||
"network_ipv6_mode_disabled": "Désactivé",
|
||||
"network_ipv6_mode_link_local": "Lien local uniquement",
|
||||
"network_ipv6_mode_slaac": "SLAAC",
|
||||
"network_ipv6_mode_slaac_dhcpv6": "SLAAC + DHCPv6",
|
||||
"network_ipv6_mode_static": "Statique",
|
||||
"network_ipv6_mode_title": "Mode IPv6",
|
||||
"network_ipv6_netmask": "Masque de réseau IPv6",
|
||||
"network_ipv6_no_addresses": "Aucune adresse IPv6 configurée",
|
||||
"network_ll_dp_all": "Tous",
|
||||
"network_ll_dp_basic": "Basique",
|
||||
"network_ll_dp_description": "Contrôler les TLV qui seront envoyés via le protocole Link Layer Discovery",
|
||||
"network_ll_dp_disabled": "Désactivé",
|
||||
"network_ll_dp_title": "LLDP",
|
||||
"network_mac_address_copy_error": "Échec de la copie de l'adresse MAC",
|
||||
"network_mac_address_copy_success": "Adresse MAC { mac } copiée dans le presse-papiers",
|
||||
"network_mac_address_description": "Identifiant matériel de l'interface réseau",
|
||||
"network_mac_address_title": "Adresse MAC",
|
||||
"network_mdns_auto": "Auto",
|
||||
|
|
@ -612,9 +632,19 @@
|
|||
"network_mdns_ipv6_only": "IPv6 uniquement",
|
||||
"network_mdns_title": "mDNS",
|
||||
"network_no_dhcp_lease": "Aucune information de bail DHCP disponible",
|
||||
"network_no_information_description": "Aucune configuration réseau disponible",
|
||||
"network_no_information_headline": "Informations sur le réseau",
|
||||
"network_pending_dhcp_mode_change_description": "Enregistrer les paramètres pour activer le mode DHCP et afficher les informations de bail",
|
||||
"network_pending_dhcp_mode_change_headline": "Changement de mode DHCP IPv4 en attente",
|
||||
"network_save_settings": "Enregistrer les paramètres",
|
||||
"network_save_settings_apply_title": "Appliquer les paramètres réseau",
|
||||
"network_save_settings_confirm": "Appliquer les modifications",
|
||||
"network_save_settings_confirm_description": "Les paramètres réseau suivants seront appliqués. Ces modifications peuvent nécessiter un redémarrage et provoquer une brève déconnexion.",
|
||||
"network_save_settings_confirm_heading": "Modifications de configuration",
|
||||
"network_save_settings_failed": "Échec de l'enregistrement des paramètres réseau : {error}",
|
||||
"network_save_settings_success": "Paramètres réseau enregistrés",
|
||||
"network_save_settings": "Enregistrer les paramètres",
|
||||
"network_settings_invalid_ipv4_cidr": "Notation CIDR non valide pour l'adresse IPv4",
|
||||
"network_settings_load_error": "Échec du chargement des paramètres réseau : {error}",
|
||||
"network_time_sync_description": "Configurer les paramètres de synchronisation de l'heure",
|
||||
"network_time_sync_http_only": "HTTP uniquement",
|
||||
"network_time_sync_ntp_and_http": "NTP et HTTP",
|
||||
|
|
@ -640,8 +670,8 @@
|
|||
"paste_modal_failed_paste": "Échec du collage du texte : {error}",
|
||||
"paste_modal_invalid_chars_intro": "Les caractères suivants ne seront pas collés :",
|
||||
"paste_modal_paste_from_host": "Coller depuis l'hôte",
|
||||
"paste_modal_paste_text_description": "Collez le texte de votre client sur l'hôte distant",
|
||||
"paste_modal_paste_text": "Coller du texte",
|
||||
"paste_modal_paste_text_description": "Collez le texte de votre client sur l'hôte distant",
|
||||
"paste_modal_sending_using_layout": "Envoi de texte à l'aide de la disposition du clavier : {iso} - {name}",
|
||||
"peer_connection_closed": "Fermé",
|
||||
"peer_connection_closing": "Clôture",
|
||||
|
|
@ -668,20 +698,20 @@
|
|||
"retry": "Réessayer",
|
||||
"saving": "Économie…",
|
||||
"search_placeholder": "Rechercher…",
|
||||
"serial_console": "Console série",
|
||||
"serial_console_baud_rate": "Débit en bauds",
|
||||
"serial_console_configure_description": "Configurez les paramètres de votre console série",
|
||||
"serial_console_data_bits": "Bits de données",
|
||||
"serial_console_get_settings_error": "Échec de l'obtention des paramètres de la console série : {error}",
|
||||
"serial_console_open_console": "Ouvrir la console",
|
||||
"serial_console_parity": "Parité",
|
||||
"serial_console_parity_even": "Parité égale",
|
||||
"serial_console_parity_mark": "Marquer la parité",
|
||||
"serial_console_parity_none": "Pas de parité",
|
||||
"serial_console_parity_odd": "Parité impaire",
|
||||
"serial_console_parity_space": "Parité spatiale",
|
||||
"serial_console_parity": "Parité",
|
||||
"serial_console_set_settings_error": "Échec de la définition des paramètres de la console série sur {settings} : {error}",
|
||||
"serial_console_stop_bits": "Bits d'arrêt",
|
||||
"serial_console": "Console série",
|
||||
"setting_remote_description": "Description de la télécommande",
|
||||
"setting_remote_session_description": "Définition de la description de la session à distance...",
|
||||
"setting_up_connection_to_device": "Configuration de la connexion à l'appareil...",
|
||||
|
|
@ -691,8 +721,8 @@
|
|||
"settings_back_to_kvm": "Retour à KVM",
|
||||
"settings_general": "Général",
|
||||
"settings_hardware": "Matériel",
|
||||
"settings_keyboard_macros": "Macros de clavier",
|
||||
"settings_keyboard": "Clavier",
|
||||
"settings_keyboard_macros": "Macros de clavier",
|
||||
"settings_mouse": "Souris",
|
||||
"settings_network": "Réseau",
|
||||
"settings_video": "Vidéo",
|
||||
|
|
@ -712,6 +742,7 @@
|
|||
"updates_failed_check": "Échec de la vérification des mises à jour : {error}",
|
||||
"updates_failed_get_device_version": "Échec de l'obtention de la version de l'appareil : {error}",
|
||||
"updating_leave_device_on": "S'il vous plaît, n'éteignez pas votre appareil…",
|
||||
"usb": "USB",
|
||||
"usb_config_custom": "Coutume",
|
||||
"usb_config_default": "JetKVM par défaut",
|
||||
"usb_config_dell": "Clavier Dell Multimedia Pro",
|
||||
|
|
@ -758,7 +789,6 @@
|
|||
"usb_state_connecting": "De liaison",
|
||||
"usb_state_disconnected": "Déconnecté",
|
||||
"usb_state_low_power_mode": "Mode basse consommation",
|
||||
"usb": "USB",
|
||||
"user_interface_language_description": "Sélectionnez la langue à utiliser dans l'interface utilisateur de JetKVM",
|
||||
"user_interface_language_title": "Langue de l'interface",
|
||||
"video_brightness_description": "Niveau de luminosité ( {value} x)",
|
||||
|
|
@ -825,6 +855,7 @@
|
|||
"video_title": "Vidéo",
|
||||
"view_details": "Voir les détails",
|
||||
"virtual_keyboard_header": "Clavier virtuel",
|
||||
"wake_on_lan": "Wake On LAN",
|
||||
"wake_on_lan_add_device_device_name": "Nom de l'appareil",
|
||||
"wake_on_lan_add_device_example_device_name": "Serveur multimédia Plex",
|
||||
"wake_on_lan_add_device_mac_address": "Adresse MAC",
|
||||
|
|
@ -840,7 +871,6 @@
|
|||
"wake_on_lan_failed_send_magic": "Échec de l'envoi du paquet magique",
|
||||
"wake_on_lan_invalid_mac": "Adresse MAC invalide",
|
||||
"wake_on_lan_magic_sent_success": "Paquet magique envoyé avec succès",
|
||||
"wake_on_lan": "Wake On LAN",
|
||||
"welcome_to_jetkvm_description": "Contrôlez n'importe quel ordinateur à distance",
|
||||
"welcome_to_jetkvm": "Bienvenue chez JetKVM"
|
||||
"welcome_to_jetkvm": "Bienvenue chez JetKVM",
|
||||
"welcome_to_jetkvm_description": "Contrôlez n'importe quel ordinateur à distance"
|
||||
}
|
||||
|
|
@ -107,10 +107,10 @@
|
|||
"already_adopted_title": "Dispositivo già registrato",
|
||||
"appearance_description": "Scegli il tuo tema colore preferito",
|
||||
"appearance_page_description": "Personalizza l'aspetto e le funzionalità della tua interfaccia JetKVM",
|
||||
"appearance_theme": "Tema",
|
||||
"appearance_theme_dark": "Buio",
|
||||
"appearance_theme_light": "Leggero",
|
||||
"appearance_theme_system": "Sistema",
|
||||
"appearance_theme": "Tema",
|
||||
"appearance_title": "Aspetto",
|
||||
"attach": "Allegare",
|
||||
"atx_power_control_get_state_error": "Impossibile ottenere lo stato di alimentazione ATX: {error}",
|
||||
|
|
@ -121,85 +121,85 @@
|
|||
"atx_power_control_reset_button": "Reset",
|
||||
"atx_power_control_send_action_error": "Impossibile inviare l'azione di alimentazione ATX {action} : {error}",
|
||||
"atx_power_control_short_power_button": "Pressione breve",
|
||||
"auth_authentication_mode": "Seleziona una modalità di autenticazione",
|
||||
"auth_authentication_mode_error": "Si è verificato un errore durante l'impostazione della modalità di autenticazione",
|
||||
"auth_authentication_mode_invalid": "Modalità di autenticazione non valida",
|
||||
"auth_authentication_mode": "Seleziona una modalità di autenticazione",
|
||||
"auth_connect_to_cloud": "Collega il tuo JetKVM al cloud",
|
||||
"auth_connect_to_cloud_action": "Accedi e connetti il dispositivo",
|
||||
"auth_connect_to_cloud_description": "Sblocca l'accesso remoto e le funzionalità avanzate per il tuo dispositivo",
|
||||
"auth_connect_to_cloud": "Collega il tuo JetKVM al cloud",
|
||||
"auth_header_cta_already_have_account": "Hai già un account?",
|
||||
"auth_header_cta_dont_have_account": "Non hai un account?",
|
||||
"auth_header_cta_new_to_jetkvm": "Nuovo su JetKVM?",
|
||||
"auth_login": "Accedi al tuo account JetKVM",
|
||||
"auth_login_action": "Login",
|
||||
"auth_login_description": "Accedi per accedere e gestire i tuoi dispositivi in modo sicuro",
|
||||
"auth_login": "Accedi al tuo account JetKVM",
|
||||
"auth_mode_local": "Metodo di autenticazione locale",
|
||||
"auth_mode_local_change_later": "Potrai sempre modificare il metodo di autenticazione in un secondo momento nelle impostazioni.",
|
||||
"auth_mode_local_description": "Seleziona come desideri proteggere localmente il tuo dispositivo JetKVM.",
|
||||
"auth_mode_local_no_password_description": "Accesso rapido senza autenticazione tramite password.",
|
||||
"auth_mode_local_no_password": "Nessuna password",
|
||||
"auth_mode_local_no_password_description": "Accesso rapido senza autenticazione tramite password.",
|
||||
"auth_mode_local_password": "Password",
|
||||
"auth_mode_local_password_confirm_description": "Conferma la tua password",
|
||||
"auth_mode_local_password_confirm_label": "Conferma password",
|
||||
"auth_mode_local_password_description": "Per una maggiore protezione, proteggi il tuo dispositivo con una password.",
|
||||
"auth_mode_local_password_do_not_match": "Le password non corrispondono",
|
||||
"auth_mode_local_password_failed_set": "Impossibile impostare la password: {error}",
|
||||
"auth_mode_local_password_note_local": "Tutti i dati rimangono sul tuo dispositivo locale.",
|
||||
"auth_mode_local_password_note": "Questa password verrà utilizzata per proteggere i dati del tuo dispositivo e proteggerli da accessi non autorizzati.",
|
||||
"auth_mode_local_password_note_local": "Tutti i dati rimangono sul tuo dispositivo locale.",
|
||||
"auth_mode_local_password_set": "Imposta una password",
|
||||
"auth_mode_local_password_set_button": "Imposta password",
|
||||
"auth_mode_local_password_set_description": "Crea una password complessa per proteggere localmente il tuo dispositivo JetKVM.",
|
||||
"auth_mode_local_password_set_label": "Inserisci una password",
|
||||
"auth_mode_local_password_set": "Imposta una password",
|
||||
"auth_mode_local_password": "Password",
|
||||
"auth_mode_local": "Metodo di autenticazione locale",
|
||||
"auth_signup_connect_to_cloud_action": "Registrati e connetti il dispositivo",
|
||||
"auth_signup_create_account": "Crea il tuo account JetKVM",
|
||||
"auth_signup_create_account_action": "Creare un account",
|
||||
"auth_signup_create_account_description": "Crea il tuo account e inizia a gestire i tuoi dispositivi con facilità.",
|
||||
"auth_signup_create_account": "Crea il tuo account JetKVM",
|
||||
"back_to_devices": "Torna ai dispositivi",
|
||||
"back": "Indietro",
|
||||
"back_to_devices": "Torna ai dispositivi",
|
||||
"cancel": "Cancellare",
|
||||
"close": "Vicino",
|
||||
"cloud_kvms_description": "Gestisci i tuoi KVM cloud e connettiti ad essi in modo sicuro.",
|
||||
"cloud_kvms_no_devices_description": "Non hai ancora alcun dispositivo con JetKVM Cloud abilitato.",
|
||||
"cloud_kvms_no_devices": "Nessun dispositivo trovato",
|
||||
"cloud_kvms": "KVM cloud",
|
||||
"cloud_kvms_description": "Gestisci i tuoi KVM cloud e connettiti ad essi in modo sicuro.",
|
||||
"cloud_kvms_no_devices": "Nessun dispositivo trovato",
|
||||
"cloud_kvms_no_devices_description": "Non hai ancora alcun dispositivo con JetKVM Cloud abilitato.",
|
||||
"confirm": "Confermare",
|
||||
"connect_to_kvm": "Connettiti a KVM",
|
||||
"connecting_to_device": "Connessione al dispositivo…",
|
||||
"connection_established": "Connessione stabilita",
|
||||
"connection_stats_badge_jitter_buffer_avg_delay": "Ritardo medio del buffer di jitter",
|
||||
"connection_stats_badge_jitter": "tremolio",
|
||||
"connection_stats_connection_description": "La connessione tra il client e JetKVM.",
|
||||
"connection_stats_badge_jitter_buffer_avg_delay": "Ritardo medio del buffer di jitter",
|
||||
"connection_stats_connection": "Connessione",
|
||||
"connection_stats_frames_per_second_description": "Numero di fotogrammi video in entrata visualizzati al secondo.",
|
||||
"connection_stats_connection_description": "La connessione tra il client e JetKVM.",
|
||||
"connection_stats_frames_per_second": "Fotogrammi al secondo",
|
||||
"connection_stats_network_stability_description": "Quanto è costante il flusso di pacchetti video in entrata sulla rete.",
|
||||
"connection_stats_frames_per_second_description": "Numero di fotogrammi video in entrata visualizzati al secondo.",
|
||||
"connection_stats_network_stability": "Stabilità della rete",
|
||||
"connection_stats_packets_lost_description": "Conteggio dei pacchetti video RTP in entrata persi.",
|
||||
"connection_stats_network_stability_description": "Quanto è costante il flusso di pacchetti video in entrata sulla rete.",
|
||||
"connection_stats_packets_lost": "Pacchetti persi",
|
||||
"connection_stats_playback_delay_description": "Ritardo aggiunto dal buffer jitter per rendere più fluida la riproduzione quando i fotogrammi arrivano in modo non uniforme.",
|
||||
"connection_stats_packets_lost_description": "Conteggio dei pacchetti video RTP in entrata persi.",
|
||||
"connection_stats_playback_delay": "Ritardo di riproduzione",
|
||||
"connection_stats_round_trip_time_description": "Tempo di andata e ritorno per la coppia di candidati ICE attivi tra pari.",
|
||||
"connection_stats_playback_delay_description": "Ritardo aggiunto dal buffer jitter per rendere più fluida la riproduzione quando i fotogrammi arrivano in modo non uniforme.",
|
||||
"connection_stats_round_trip_time": "Tempo di andata e ritorno",
|
||||
"connection_stats_round_trip_time_description": "Tempo di andata e ritorno per la coppia di candidati ICE attivi tra pari.",
|
||||
"connection_stats_sidebar": "Statistiche di connessione",
|
||||
"connection_stats_video_description": "Il flusso video dal JetKVM al client.",
|
||||
"connection_stats_video": "Video",
|
||||
"connection_stats_video_description": "Il flusso video dal JetKVM al client.",
|
||||
"continue": "Continuare",
|
||||
"creating_peer_connection": "Creazione di una connessione tra pari…",
|
||||
"dc_power_control_current_unit": "UN",
|
||||
"dc_power_control_current": "Attuale",
|
||||
"dc_power_control_current_unit": "UN",
|
||||
"dc_power_control_get_state_error": "Impossibile ottenere lo stato di alimentazione CC: {error}",
|
||||
"dc_power_control_power": "Energia",
|
||||
"dc_power_control_power_off_button": "Spegnimento",
|
||||
"dc_power_control_power_off_state": "Spegnimento",
|
||||
"dc_power_control_power_on_button": "Accensione",
|
||||
"dc_power_control_power_on_state": "Accensione",
|
||||
"dc_power_control_power_unit": "O",
|
||||
"dc_power_control_power": "Energia",
|
||||
"dc_power_control_restore_last_state": "Ultimo stato",
|
||||
"dc_power_control_restore_power_state": "Ripristinare la perdita di potenza",
|
||||
"dc_power_control_set_power_state_error": "Impossibile inviare lo stato di alimentazione CC a {enabled} : {error}",
|
||||
"dc_power_control_set_restore_state_error": "Impossibile inviare lo stato di ripristino dell'alimentazione CC a {state} : {error}",
|
||||
"dc_power_control_voltage_unit": "V",
|
||||
"dc_power_control_voltage": "Voltaggio",
|
||||
"dc_power_control_voltage_unit": "V",
|
||||
"delete": "Eliminare",
|
||||
"deregister_button": "Annulla registrazione dal cloud",
|
||||
"deregister_cloud_devices": "Dispositivi cloud",
|
||||
|
|
@ -226,12 +226,12 @@
|
|||
"extension_popover_load_and_manage_extensions": "Carica e gestisci le tue estensioni",
|
||||
"extension_popover_set_error_notification": "Impossibile impostare l'estensione attiva: {error}",
|
||||
"extension_popover_unload_extension": "Estensione di scaricamento",
|
||||
"extension_serial_console_description": "Accedi all'estensione della tua console seriale",
|
||||
"extension_serial_console": "Console seriale",
|
||||
"extensions_atx_power_control_description": "Controlla lo stato di alimentazione del tuo computer tramite il controllo di alimentazione ATX.",
|
||||
"extension_serial_console_description": "Accedi all'estensione della tua console seriale",
|
||||
"extensions_atx_power_control": "Controllo di potenza ATX",
|
||||
"extensions_dc_power_control_description": "Controlla la tua estensione di alimentazione CC",
|
||||
"extensions_atx_power_control_description": "Controlla lo stato di alimentazione del tuo computer tramite il controllo di alimentazione ATX.",
|
||||
"extensions_dc_power_control": "Controllo di potenza CC",
|
||||
"extensions_dc_power_control_description": "Controlla la tua estensione di alimentazione CC",
|
||||
"extensions_popover_extensions": "Estensioni",
|
||||
"gathering_ice_candidates": "Raduno dei candidati ICE…",
|
||||
"general_app_version": "App: {version}",
|
||||
|
|
@ -241,8 +241,8 @@
|
|||
"general_check_for_updates": "Controlla gli aggiornamenti",
|
||||
"general_page_description": "Configurare le impostazioni del dispositivo e aggiornare le preferenze",
|
||||
"general_reboot_description": "Vuoi procedere con il riavvio del sistema?",
|
||||
"general_reboot_device_description": "Spegnere e riaccendere JetKVM",
|
||||
"general_reboot_device": "Riavvia il dispositivo",
|
||||
"general_reboot_device_description": "Spegnere e riaccendere JetKVM",
|
||||
"general_reboot_no_button": "NO",
|
||||
"general_reboot_title": "Riavviare JetKVM",
|
||||
"general_reboot_yes_button": "SÌ",
|
||||
|
|
@ -295,9 +295,9 @@
|
|||
"hardware_display_orientation_title": "Orientamento dello schermo",
|
||||
"hardware_display_wake_up_note": "Il display si riattiverà quando cambia lo stato della connessione o quando viene toccato.",
|
||||
"hardware_page_description": "Configura le impostazioni di visualizzazione e le opzioni hardware per il tuo dispositivo JetKVM",
|
||||
"hardware_time_10_minutes": "10 minuti",
|
||||
"hardware_time_1_hour": "1 ora",
|
||||
"hardware_time_1_minute": "1 minuto",
|
||||
"hardware_time_10_minutes": "10 minuti",
|
||||
"hardware_time_30_minutes": "30 minuti",
|
||||
"hardware_time_5_minutes": "5 minuti",
|
||||
"hardware_time_never": "Mai",
|
||||
|
|
@ -327,6 +327,7 @@
|
|||
"invalid_password": "Password non valida",
|
||||
"ip_address": "Indirizzo IP",
|
||||
"ipv6_address_label": "Indirizzo",
|
||||
"ipv6_gateway": "Portale",
|
||||
"ipv6_information": "Informazioni IPv6",
|
||||
"ipv6_link_local": "Collegamento locale",
|
||||
"ipv6_preferred_lifetime": "Durata preferita",
|
||||
|
|
@ -409,8 +410,8 @@
|
|||
"log_in": "Login",
|
||||
"log_out": "Disconnetti",
|
||||
"logged_in_as": "Accedi come",
|
||||
"login_enter_password_description": "Inserisci la tua password per accedere al tuo JetKVM.",
|
||||
"login_enter_password": "Inserisci la tua password",
|
||||
"login_enter_password_description": "Inserisci la tua password per accedere al tuo JetKVM.",
|
||||
"login_error": "Si è verificato un errore durante l'accesso",
|
||||
"login_forgot_password": "Ha dimenticato la password?",
|
||||
"login_password_label": "Password",
|
||||
|
|
@ -424,8 +425,8 @@
|
|||
"macro_name_required": "Il nome è obbligatorio",
|
||||
"macro_name_too_long": "Il nome deve contenere meno di 50 caratteri",
|
||||
"macro_please_fix_validation_errors": "Si prega di correggere gli errori di convalida",
|
||||
"macro_save_error": "Si è verificato un errore durante il salvataggio.",
|
||||
"macro_save": "Salva macro",
|
||||
"macro_save_error": "Si è verificato un errore durante il salvataggio.",
|
||||
"macro_step_count": "{steps} / {max} steps",
|
||||
"macro_step_duration_description": "Tempo di attesa prima di eseguire il passaggio successivo.",
|
||||
"macro_step_duration_label": "Durata del passo",
|
||||
|
|
@ -439,8 +440,8 @@
|
|||
"macro_steps_description": "Tasti/modificatori eseguiti in sequenza con un ritardo tra ogni passaggio.",
|
||||
"macro_steps_label": "Passi",
|
||||
"macros_add_description": "Crea una nuova macro della tastiera",
|
||||
"macros_add_new_macro": "Aggiungi nuova macro",
|
||||
"macros_add_new": "Aggiungi nuova macro",
|
||||
"macros_add_new_macro": "Aggiungi nuova macro",
|
||||
"macros_aria_add_new": "Aggiungi nuova macro",
|
||||
"macros_aria_delete": "Elimina macro {name}",
|
||||
"macros_aria_duplicate": "Macro duplicata {name}",
|
||||
|
|
@ -462,16 +463,16 @@
|
|||
"macros_edit_button": "Modificare",
|
||||
"macros_edit_description": "Modifica la macro della tastiera",
|
||||
"macros_edit_title": "Modifica macro",
|
||||
"macros_failed_create_error": "Impossibile creare la macro: {error}",
|
||||
"macros_failed_create": "Impossibile creare la macro",
|
||||
"macros_failed_delete_error": "Impossibile eliminare la macro: {error}",
|
||||
"macros_failed_create_error": "Impossibile creare la macro: {error}",
|
||||
"macros_failed_delete": "Impossibile eliminare la macro",
|
||||
"macros_failed_duplicate_error": "Impossibile duplicare la macro: {error}",
|
||||
"macros_failed_delete_error": "Impossibile eliminare la macro: {error}",
|
||||
"macros_failed_duplicate": "Impossibile duplicare la macro",
|
||||
"macros_failed_reorder_error": "Impossibile riordinare le macro: {error}",
|
||||
"macros_failed_duplicate_error": "Impossibile duplicare la macro: {error}",
|
||||
"macros_failed_reorder": "Impossibile riordinare le macro",
|
||||
"macros_failed_update_error": "Impossibile aggiornare la macro: {error}",
|
||||
"macros_failed_reorder_error": "Impossibile riordinare le macro: {error}",
|
||||
"macros_failed_update": "Impossibile aggiornare la macro",
|
||||
"macros_failed_update_error": "Impossibile aggiornare la macro: {error}",
|
||||
"macros_invalid_data": "Dati macro non validi",
|
||||
"macros_loading": "Caricamento macro in corso…",
|
||||
"macros_max_reached": "Massimo raggiunto",
|
||||
|
|
@ -506,8 +507,8 @@
|
|||
"mount_error_list_storage": "Errore nell'elenco dei file di archiviazione: {error}",
|
||||
"mount_error_title": "Errore di montaggio",
|
||||
"mount_get_state_error": "Impossibile ottenere lo stato del supporto virtuale: {error}",
|
||||
"mount_jetkvm_storage_description": "Montare i file caricati in precedenza dall'archiviazione JetKVM",
|
||||
"mount_jetkvm_storage": "Montaggio di archiviazione JetKVM",
|
||||
"mount_jetkvm_storage_description": "Montare i file caricati in precedenza dall'archiviazione JetKVM",
|
||||
"mount_mode_cdrom": "CD/DVD",
|
||||
"mount_mode_disk": "Disco",
|
||||
"mount_mounted_as": "Montato come",
|
||||
|
|
@ -520,8 +521,8 @@
|
|||
"mount_popular_images": "Immagini popolari",
|
||||
"mount_streaming_from_url": "Streaming da URL",
|
||||
"mount_supported_formats": "Formati supportati: ISO, IMG",
|
||||
"mount_unmount_error": "Impossibile smontare l'immagine: {error}",
|
||||
"mount_unmount": "Smontare",
|
||||
"mount_unmount_error": "Impossibile smontare l'immagine: {error}",
|
||||
"mount_upload_description": "Seleziona un file immagine da caricare nell'archiviazione JetKVM",
|
||||
"mount_upload_error": "Errore di caricamento: {error}",
|
||||
"mount_upload_failed_datachannel": "Impossibile creare il canale dati per il caricamento del file",
|
||||
|
|
@ -529,8 +530,8 @@
|
|||
"mount_upload_successful": "Caricamento riuscito",
|
||||
"mount_upload_title": "Carica nuova immagine",
|
||||
"mount_uploaded_has_been_uploaded": "{name} è stato caricato",
|
||||
"mount_uploading_with_name": "Caricamento in corso {name}",
|
||||
"mount_uploading": "Caricamento in corso…",
|
||||
"mount_uploading_with_name": "Caricamento in corso {name}",
|
||||
"mount_url_description": "Montare file da qualsiasi indirizzo web pubblico",
|
||||
"mount_url_input_label": "URL dell'immagine",
|
||||
"mount_url_mount": "Montaggio URL",
|
||||
|
|
@ -538,10 +539,10 @@
|
|||
"mount_view_device_title": "Monta da JetKVM Storage",
|
||||
"mount_view_url_description": "Inserisci un URL al file immagine da montare",
|
||||
"mount_view_url_title": "Monta da URL",
|
||||
"mount_virtual_media_description": "Montare un'immagine da cui avviare o installare un sistema operativo.",
|
||||
"mount_virtual_media_source_description": "Scegli come vuoi montare i tuoi media virtuali",
|
||||
"mount_virtual_media_source": "Fonte multimediale virtuale",
|
||||
"mount_virtual_media": "Media virtuali",
|
||||
"mount_virtual_media_description": "Montare un'immagine da cui avviare o installare un sistema operativo.",
|
||||
"mount_virtual_media_source": "Fonte multimediale virtuale",
|
||||
"mount_virtual_media_source_description": "Scegli come vuoi montare i tuoi media virtuali",
|
||||
"mouse_alt_finger": "Dito che tocca uno schermo",
|
||||
"mouse_alt_mouse": "Icona del mouse",
|
||||
"mouse_description": "Configura il comportamento del cursore e le impostazioni di interazione per il tuo dispositivo",
|
||||
|
|
@ -558,10 +559,10 @@
|
|||
"mouse_jiggler_light": "Luce - 5m",
|
||||
"mouse_jiggler_standard": "Standard - 1m",
|
||||
"mouse_jiggler_title": "Jiggler",
|
||||
"mouse_mode_absolute_description": "Il più conveniente",
|
||||
"mouse_mode_absolute": "Assoluto",
|
||||
"mouse_mode_relative_description": "Più compatibile",
|
||||
"mouse_mode_absolute_description": "Il più conveniente",
|
||||
"mouse_mode_relative": "Relativo",
|
||||
"mouse_mode_relative_description": "Più compatibile",
|
||||
"mouse_modes_description": "Scegli la modalità di input del mouse",
|
||||
"mouse_modes_title": "Modalità",
|
||||
"mouse_scroll_high": "Alto",
|
||||
|
|
@ -574,12 +575,17 @@
|
|||
"mouse_title": "Topo",
|
||||
"network_custom_domain": "Dominio personalizzato",
|
||||
"network_description": "Configura le impostazioni di rete",
|
||||
"network_dhcp_client_description": "Configurare quale client DHCP utilizzare",
|
||||
"network_dhcp_client_jetkvm": "JetKVM interno",
|
||||
"network_dhcp_client_title": "Cliente DHCP",
|
||||
"network_dhcp_information": "Informazioni DHCP",
|
||||
"network_dhcp_lease_renew_confirm_description": "Verrà richiesto un nuovo indirizzo IP al server DHCP. Durante questo processo, il dispositivo potrebbe perdere temporaneamente la connettività di rete.",
|
||||
"network_dhcp_lease_renew": "Rinnova il contratto di locazione DHCP",
|
||||
"network_dhcp_lease_renew_confirm": "Rinnovare il contratto di locazione",
|
||||
"network_dhcp_lease_renew_confirm_description": "Verrà richiesto un nuovo indirizzo IP al server DHCP. Durante questo processo, il dispositivo potrebbe perdere temporaneamente la connettività di rete.",
|
||||
"network_dhcp_lease_renew_confirm_new_a": "Se ricevi un nuovo indirizzo IP",
|
||||
"network_dhcp_lease_renew_confirm_new_b": "potrebbe essere necessario riconnettersi utilizzando il nuovo indirizzo",
|
||||
"network_dhcp_lease_renew_failed": "Impossibile rinnovare il contratto di locazione: {error}",
|
||||
"network_dhcp_lease_renew_success": "Rinnovo del contratto di locazione DHCP",
|
||||
"network_dhcp_lease_renew": "Rinnova il contratto di locazione DHCP",
|
||||
"network_domain_custom": "Costume",
|
||||
"network_domain_description": "Suffisso del dominio di rete per il dispositivo",
|
||||
"network_domain_dhcp_provided": "DHCP fornito",
|
||||
|
|
@ -588,21 +594,35 @@
|
|||
"network_hostname_description": "Identificatore del dispositivo sulla rete. Vuoto per impostazione predefinita del sistema",
|
||||
"network_hostname_title": "Nome host",
|
||||
"network_http_proxy_description": "Server proxy per le richieste HTTP(S) in uscita dal dispositivo. Vuoto per nessuna richiesta.",
|
||||
"network_http_proxy_invalid": "URL proxy HTTP non valido",
|
||||
"network_http_proxy_title": "Proxy HTTP",
|
||||
"network_ipv4_address": "Indirizzo IPv4",
|
||||
"network_ipv4_dns": "DNS IPv4",
|
||||
"network_ipv4_gateway": "Gateway IPv4",
|
||||
"network_ipv4_mode_description": "Configurare la modalità IPv4",
|
||||
"network_ipv4_mode_dhcp": "DHCP",
|
||||
"network_ipv4_mode_static": "Statico",
|
||||
"network_ipv4_mode_title": "Modalità IPv4",
|
||||
"network_ipv4_netmask": "Maschera di rete IPv4",
|
||||
"network_ipv6_address": "Indirizzo IPv6",
|
||||
"network_ipv6_information": "Informazioni IPv6",
|
||||
"network_ipv6_mode_description": "Configurare la modalità IPv6",
|
||||
"network_ipv6_mode_dhcpv6": "DHCPv6",
|
||||
"network_ipv6_mode_disabled": "Disabili",
|
||||
"network_ipv6_mode_link_local": "Solo collegamento locale",
|
||||
"network_ipv6_mode_slaac": "SLAAC",
|
||||
"network_ipv6_mode_slaac_dhcpv6": "SLAAC + DHCPv6",
|
||||
"network_ipv6_mode_static": "Statico",
|
||||
"network_ipv6_mode_title": "Modalità IPv6",
|
||||
"network_ipv6_netmask": "Maschera di rete IPv6",
|
||||
"network_ipv6_no_addresses": "Nessun indirizzo IPv6 configurato",
|
||||
"network_ll_dp_all": "Tutto",
|
||||
"network_ll_dp_basic": "Di base",
|
||||
"network_ll_dp_description": "Controlla quali TLV verranno inviati tramite Link Layer Discovery Protocol",
|
||||
"network_ll_dp_disabled": "Disabili",
|
||||
"network_ll_dp_title": "LLDP",
|
||||
"network_mac_address_copy_error": "Impossibile copiare l'indirizzo MAC",
|
||||
"network_mac_address_copy_success": "Indirizzo MAC { mac } copiato negli appunti",
|
||||
"network_mac_address_description": "Identificatore hardware per l'interfaccia di rete",
|
||||
"network_mac_address_title": "Indirizzo MAC",
|
||||
"network_mdns_auto": "Auto",
|
||||
|
|
@ -612,9 +632,19 @@
|
|||
"network_mdns_ipv6_only": "Solo IPv6",
|
||||
"network_mdns_title": "mDNS",
|
||||
"network_no_dhcp_lease": "Nessuna informazione disponibile sul lease DHCP",
|
||||
"network_no_information_description": "Nessuna configurazione di rete disponibile",
|
||||
"network_no_information_headline": "Informazioni di rete",
|
||||
"network_pending_dhcp_mode_change_description": "Salva le impostazioni per abilitare la modalità DHCP e visualizzare le informazioni di locazione",
|
||||
"network_pending_dhcp_mode_change_headline": "In attesa di modifica della modalità DHCP IPv4",
|
||||
"network_save_settings": "Salva impostazioni",
|
||||
"network_save_settings_apply_title": "Applica le impostazioni di rete",
|
||||
"network_save_settings_confirm": "Applica modifiche",
|
||||
"network_save_settings_confirm_description": "Verranno applicate le seguenti impostazioni di rete. Queste modifiche potrebbero richiedere un riavvio e causare una breve disconnessione.",
|
||||
"network_save_settings_confirm_heading": "Modifiche alla configurazione",
|
||||
"network_save_settings_failed": "Impossibile salvare le impostazioni di rete: {error}",
|
||||
"network_save_settings_success": "Impostazioni di rete salvate",
|
||||
"network_save_settings": "Salva impostazioni",
|
||||
"network_settings_invalid_ipv4_cidr": "Notazione CIDR non valida per l'indirizzo IPv4",
|
||||
"network_settings_load_error": "Impossibile caricare le impostazioni di rete: {error}",
|
||||
"network_time_sync_description": "Configurare le impostazioni di sincronizzazione dell'ora",
|
||||
"network_time_sync_http_only": "Solo HTTP",
|
||||
"network_time_sync_ntp_and_http": "NTP e HTTP",
|
||||
|
|
@ -640,8 +670,8 @@
|
|||
"paste_modal_failed_paste": "Impossibile incollare il testo: {error}",
|
||||
"paste_modal_invalid_chars_intro": "I seguenti caratteri non verranno incollati:",
|
||||
"paste_modal_paste_from_host": "Incolla dall'host",
|
||||
"paste_modal_paste_text_description": "Incolla il testo dal tuo client all'host remoto",
|
||||
"paste_modal_paste_text": "Incolla il testo",
|
||||
"paste_modal_paste_text_description": "Incolla il testo dal tuo client all'host remoto",
|
||||
"paste_modal_sending_using_layout": "Invio di testo tramite layout di tastiera: {iso} - {name}",
|
||||
"peer_connection_closed": "Chiuso",
|
||||
"peer_connection_closing": "Chiusura",
|
||||
|
|
@ -668,20 +698,20 @@
|
|||
"retry": "Riprova",
|
||||
"saving": "Risparmio…",
|
||||
"search_placeholder": "Ricerca…",
|
||||
"serial_console": "Console seriale",
|
||||
"serial_console_baud_rate": "Velocità in baud",
|
||||
"serial_console_configure_description": "Configura le impostazioni della tua console seriale",
|
||||
"serial_console_data_bits": "Bit di dati",
|
||||
"serial_console_get_settings_error": "Impossibile ottenere le impostazioni della console seriale: {error}",
|
||||
"serial_console_open_console": "Apri console",
|
||||
"serial_console_parity": "Parità",
|
||||
"serial_console_parity_even": "Parità pari",
|
||||
"serial_console_parity_mark": "Segna parità",
|
||||
"serial_console_parity_none": "Nessuna parità",
|
||||
"serial_console_parity_odd": "Parità dispari",
|
||||
"serial_console_parity_space": "Parità spaziale",
|
||||
"serial_console_parity": "Parità",
|
||||
"serial_console_set_settings_error": "Impossibile impostare le impostazioni della console seriale su {settings} : {error}",
|
||||
"serial_console_stop_bits": "Bit di stop",
|
||||
"serial_console": "Console seriale",
|
||||
"setting_remote_description": "Impostazione della descrizione remota",
|
||||
"setting_remote_session_description": "Impostazione della descrizione della sessione remota...",
|
||||
"setting_up_connection_to_device": "Impostazione della connessione al dispositivo...",
|
||||
|
|
@ -691,8 +721,8 @@
|
|||
"settings_back_to_kvm": "Torna a KVM",
|
||||
"settings_general": "Generale",
|
||||
"settings_hardware": "Hardware",
|
||||
"settings_keyboard_macros": "Macro della tastiera",
|
||||
"settings_keyboard": "Tastiera",
|
||||
"settings_keyboard_macros": "Macro della tastiera",
|
||||
"settings_mouse": "Topo",
|
||||
"settings_network": "Rete",
|
||||
"settings_video": "Video",
|
||||
|
|
@ -712,6 +742,7 @@
|
|||
"updates_failed_check": "Impossibile verificare gli aggiornamenti: {error}",
|
||||
"updates_failed_get_device_version": "Impossibile ottenere la versione del dispositivo: {error}",
|
||||
"updating_leave_device_on": "Per favore, non spegnere il tuo dispositivo…",
|
||||
"usb": "USB",
|
||||
"usb_config_custom": "Costume",
|
||||
"usb_config_default": "JetKVM predefinito",
|
||||
"usb_config_dell": "Tastiera Dell Multimedia Pro",
|
||||
|
|
@ -758,7 +789,6 @@
|
|||
"usb_state_connecting": "Collegamento",
|
||||
"usb_state_disconnected": "Disconnesso",
|
||||
"usb_state_low_power_mode": "Modalità a basso consumo",
|
||||
"usb": "USB",
|
||||
"user_interface_language_description": "Seleziona la lingua da utilizzare nell'interfaccia utente JetKVM",
|
||||
"user_interface_language_title": "Lingua dell'interfaccia",
|
||||
"video_brightness_description": "Livello di luminosità ( {value} x)",
|
||||
|
|
@ -825,6 +855,7 @@
|
|||
"video_title": "Video",
|
||||
"view_details": "Visualizza dettagli",
|
||||
"virtual_keyboard_header": "Tastiera virtuale",
|
||||
"wake_on_lan": "Wake On LAN",
|
||||
"wake_on_lan_add_device_device_name": "Nome del dispositivo",
|
||||
"wake_on_lan_add_device_example_device_name": "Server multimediale Plex",
|
||||
"wake_on_lan_add_device_mac_address": "Indirizzo MAC",
|
||||
|
|
@ -840,7 +871,6 @@
|
|||
"wake_on_lan_failed_send_magic": "Impossibile inviare il pacchetto magico",
|
||||
"wake_on_lan_invalid_mac": "Indirizzo MAC non valido",
|
||||
"wake_on_lan_magic_sent_success": "Pacchetto magico inviato con successo",
|
||||
"wake_on_lan": "Wake On LAN",
|
||||
"welcome_to_jetkvm_description": "Controlla qualsiasi computer da remoto",
|
||||
"welcome_to_jetkvm": "Benvenuti a JetKVM"
|
||||
"welcome_to_jetkvm": "Benvenuti a JetKVM",
|
||||
"welcome_to_jetkvm_description": "Controlla qualsiasi computer da remoto"
|
||||
}
|
||||
|
|
@ -107,10 +107,10 @@
|
|||
"already_adopted_title": "Enheten er allerede registrert",
|
||||
"appearance_description": "Velg ditt foretrukne fargetema",
|
||||
"appearance_page_description": "Tilpass utseendet og følelsen til JetKVM-grensesnittet ditt",
|
||||
"appearance_theme": "Tema",
|
||||
"appearance_theme_dark": "Mørk",
|
||||
"appearance_theme_light": "Lys",
|
||||
"appearance_theme_system": "System",
|
||||
"appearance_theme": "Tema",
|
||||
"appearance_title": "Utseende",
|
||||
"attach": "Feste",
|
||||
"atx_power_control_get_state_error": "Klarte ikke å hente ATX-strømstatus: {error}",
|
||||
|
|
@ -121,85 +121,85 @@
|
|||
"atx_power_control_reset_button": "Tilbakestill",
|
||||
"atx_power_control_send_action_error": "Kunne ikke sende ATX-strømhandling {action} : {error}",
|
||||
"atx_power_control_short_power_button": "Kort trykk",
|
||||
"auth_authentication_mode": "Vennligst velg en autentiseringsmodus",
|
||||
"auth_authentication_mode_error": "Det oppsto en feil under angivelse av autentiseringsmodus",
|
||||
"auth_authentication_mode_invalid": "Ugyldig autentiseringsmodus",
|
||||
"auth_authentication_mode": "Vennligst velg en autentiseringsmodus",
|
||||
"auth_connect_to_cloud": "Koble JetKVM-en din til skyen",
|
||||
"auth_connect_to_cloud_action": "Logg inn og koble til enheten",
|
||||
"auth_connect_to_cloud_description": "Lås opp fjerntilgang og avanserte funksjoner for enheten din",
|
||||
"auth_connect_to_cloud": "Koble JetKVM-en din til skyen",
|
||||
"auth_header_cta_already_have_account": "Har du allerede en konto?",
|
||||
"auth_header_cta_dont_have_account": "Har du ikke en konto?",
|
||||
"auth_header_cta_new_to_jetkvm": "Ny bruker av JetKVM?",
|
||||
"auth_login": "Logg inn på JetKVM-kontoen din",
|
||||
"auth_login_action": "Logg inn",
|
||||
"auth_login_description": "Logg inn for å få tilgang til og administrere enhetene dine på en sikker måte",
|
||||
"auth_login": "Logg inn på JetKVM-kontoen din",
|
||||
"auth_mode_local": "Lokal autentiseringsmetode",
|
||||
"auth_mode_local_change_later": "Du kan alltid endre autentiseringsmetoden din senere i innstillingene.",
|
||||
"auth_mode_local_description": "Velg hvordan du vil sikre JetKVM-enheten din lokalt.",
|
||||
"auth_mode_local_no_password_description": "Rask tilgang uten passordgodkjenning.",
|
||||
"auth_mode_local_no_password": "Ikke noe passord",
|
||||
"auth_mode_local_no_password_description": "Rask tilgang uten passordgodkjenning.",
|
||||
"auth_mode_local_password": "Passord",
|
||||
"auth_mode_local_password_confirm_description": "Bekreft passordet ditt",
|
||||
"auth_mode_local_password_confirm_label": "Bekreft passord",
|
||||
"auth_mode_local_password_description": "Sikre enheten din med et passord for ekstra beskyttelse.",
|
||||
"auth_mode_local_password_do_not_match": "Passordene stemmer ikke overens",
|
||||
"auth_mode_local_password_failed_set": "Klarte ikke å angi passord: {error}",
|
||||
"auth_mode_local_password_note_local": "Alle dataene forblir på din lokale enhet.",
|
||||
"auth_mode_local_password_note": "Dette passordet vil bli brukt til å sikre enhetsdataene dine og beskytte mot uautorisert tilgang.",
|
||||
"auth_mode_local_password_note_local": "Alle dataene forblir på din lokale enhet.",
|
||||
"auth_mode_local_password_set": "Angi et passord",
|
||||
"auth_mode_local_password_set_button": "Angi passord",
|
||||
"auth_mode_local_password_set_description": "Opprett et sterkt passord for å sikre JetKVM-enheten din lokalt.",
|
||||
"auth_mode_local_password_set_label": "Skriv inn et passord",
|
||||
"auth_mode_local_password_set": "Angi et passord",
|
||||
"auth_mode_local_password": "Passord",
|
||||
"auth_mode_local": "Lokal autentiseringsmetode",
|
||||
"auth_signup_connect_to_cloud_action": "Registrer og koble til enhet",
|
||||
"auth_signup_create_account": "Opprett JetKVM-kontoen din",
|
||||
"auth_signup_create_account_action": "Opprett konto",
|
||||
"auth_signup_create_account_description": "Opprett kontoen din og begynn å administrere enhetene dine med letthet.",
|
||||
"auth_signup_create_account": "Opprett JetKVM-kontoen din",
|
||||
"back_to_devices": "Tilbake til Enheter",
|
||||
"back": "Tilbake",
|
||||
"back_to_devices": "Tilbake til Enheter",
|
||||
"cancel": "Kansellere",
|
||||
"close": "Lukke",
|
||||
"cloud_kvms_description": "Administrer skybaserte KVM-er og koble til dem sikkert.",
|
||||
"cloud_kvms_no_devices_description": "Du har ingen enheter med aktivert JetKVM Cloud ennå.",
|
||||
"cloud_kvms_no_devices": "Ingen enheter funnet",
|
||||
"cloud_kvms": "Cloud KVM-er",
|
||||
"cloud_kvms_description": "Administrer skybaserte KVM-er og koble til dem sikkert.",
|
||||
"cloud_kvms_no_devices": "Ingen enheter funnet",
|
||||
"cloud_kvms_no_devices_description": "Du har ingen enheter med aktivert JetKVM Cloud ennå.",
|
||||
"confirm": "Bekrefte",
|
||||
"connect_to_kvm": "Koble til KVM",
|
||||
"connecting_to_device": "Kobler til enhet …",
|
||||
"connection_established": "Forbindelse opprettet",
|
||||
"connection_stats_badge_jitter_buffer_avg_delay": "Jitterbuffer Gjns. forsinkelse",
|
||||
"connection_stats_badge_jitter": "Jitter",
|
||||
"connection_stats_connection_description": "Forbindelsen mellom klienten og JetKVM-en.",
|
||||
"connection_stats_badge_jitter_buffer_avg_delay": "Jitterbuffer Gjns. forsinkelse",
|
||||
"connection_stats_connection": "Forbindelse",
|
||||
"connection_stats_frames_per_second_description": "Antall innkommende videobilder som vises per sekund.",
|
||||
"connection_stats_connection_description": "Forbindelsen mellom klienten og JetKVM-en.",
|
||||
"connection_stats_frames_per_second": "Bilder per sekund",
|
||||
"connection_stats_network_stability_description": "Hvor jevn flyten av innkommende videopakker er over nettverket.",
|
||||
"connection_stats_frames_per_second_description": "Antall innkommende videobilder som vises per sekund.",
|
||||
"connection_stats_network_stability": "Nettverksstabilitet",
|
||||
"connection_stats_packets_lost_description": "Antall tapte innkommende RTP-videopakker.",
|
||||
"connection_stats_network_stability_description": "Hvor jevn flyten av innkommende videopakker er over nettverket.",
|
||||
"connection_stats_packets_lost": "Pakker tapt",
|
||||
"connection_stats_playback_delay_description": "Forsinkelse lagt til av jitterbufferen for jevn avspilling når bilder ankommer ujevnt.",
|
||||
"connection_stats_packets_lost_description": "Antall tapte innkommende RTP-videopakker.",
|
||||
"connection_stats_playback_delay": "Avspillingsforsinkelse",
|
||||
"connection_stats_round_trip_time_description": "Rundturstid for det aktive ICE-kandidatparet mellom jevnaldrende.",
|
||||
"connection_stats_playback_delay_description": "Forsinkelse lagt til av jitterbufferen for jevn avspilling når bilder ankommer ujevnt.",
|
||||
"connection_stats_round_trip_time": "Tur-retur-tid",
|
||||
"connection_stats_round_trip_time_description": "Rundturstid for det aktive ICE-kandidatparet mellom jevnaldrende.",
|
||||
"connection_stats_sidebar": "Tilkoblingsstatistikk",
|
||||
"connection_stats_video_description": "Videostrømmen fra JetKVM til klienten.",
|
||||
"connection_stats_video": "Video",
|
||||
"connection_stats_video_description": "Videostrømmen fra JetKVM til klienten.",
|
||||
"continue": "Fortsette",
|
||||
"creating_peer_connection": "Oppretter kontakt med andre personer …",
|
||||
"dc_power_control_current_unit": "EN",
|
||||
"dc_power_control_current": "Nåværende",
|
||||
"dc_power_control_current_unit": "EN",
|
||||
"dc_power_control_get_state_error": "Klarte ikke å hente likestrømsstatus: {error}",
|
||||
"dc_power_control_power": "Makt",
|
||||
"dc_power_control_power_off_button": "Slå av",
|
||||
"dc_power_control_power_off_state": "Slå av",
|
||||
"dc_power_control_power_on_button": "Slå på",
|
||||
"dc_power_control_power_on_state": "Slå PÅ",
|
||||
"dc_power_control_power_unit": "V",
|
||||
"dc_power_control_power": "Makt",
|
||||
"dc_power_control_restore_last_state": "Siste stat",
|
||||
"dc_power_control_restore_power_state": "Gjenopprett strømtap",
|
||||
"dc_power_control_set_power_state_error": "Kunne ikke sende likestrømsstatus til {enabled} : {error}",
|
||||
"dc_power_control_set_restore_state_error": "Kunne ikke sende gjenopprettingsstatus for likestrøm til {state} : {error}",
|
||||
"dc_power_control_voltage_unit": "V",
|
||||
"dc_power_control_voltage": "Spenning",
|
||||
"dc_power_control_voltage_unit": "V",
|
||||
"delete": "Slett",
|
||||
"deregister_button": "Avregistrer deg fra skyen",
|
||||
"deregister_cloud_devices": "Skyenheter",
|
||||
|
|
@ -226,12 +226,12 @@
|
|||
"extension_popover_load_and_manage_extensions": "Last inn og administrer utvidelsene dine",
|
||||
"extension_popover_set_error_notification": "Klarte ikke å angi aktiv utvidelse: {error}",
|
||||
"extension_popover_unload_extension": "Fjern utvidelse",
|
||||
"extension_serial_console_description": "Få tilgang til seriekonsollutvidelsen din",
|
||||
"extension_serial_console": "Seriell konsoll",
|
||||
"extensions_atx_power_control_description": "Kontroller maskinens strømstatus via ATX-strømkontroll.",
|
||||
"extension_serial_console_description": "Få tilgang til seriekonsollutvidelsen din",
|
||||
"extensions_atx_power_control": "ATX-strømstyring",
|
||||
"extensions_dc_power_control_description": "Kontroller DC-strømutvidelsen din",
|
||||
"extensions_atx_power_control_description": "Kontroller maskinens strømstatus via ATX-strømkontroll.",
|
||||
"extensions_dc_power_control": "DC-strømkontroll",
|
||||
"extensions_dc_power_control_description": "Kontroller DC-strømutvidelsen din",
|
||||
"extensions_popover_extensions": "Utvidelser",
|
||||
"gathering_ice_candidates": "Samler ICE-kandidater…",
|
||||
"general_app_version": "App: {version}",
|
||||
|
|
@ -241,8 +241,8 @@
|
|||
"general_check_for_updates": "Se etter oppdateringer",
|
||||
"general_page_description": "Konfigurer enhetsinnstillinger og oppdater preferanser",
|
||||
"general_reboot_description": "Vil du fortsette med å starte systemet på nytt?",
|
||||
"general_reboot_device_description": "Slå av og på JetKVM-en",
|
||||
"general_reboot_device": "Start enheten på nytt",
|
||||
"general_reboot_device_description": "Slå av og på JetKVM-en",
|
||||
"general_reboot_no_button": "Ingen",
|
||||
"general_reboot_title": "Start JetKVM på nytt",
|
||||
"general_reboot_yes_button": "Ja",
|
||||
|
|
@ -295,9 +295,9 @@
|
|||
"hardware_display_orientation_title": "Skjermretning",
|
||||
"hardware_display_wake_up_note": "Skjermen vil våkne når tilkoblingsstatusen endres, eller når den berøres.",
|
||||
"hardware_page_description": "Konfigurer skjerminnstillinger og maskinvarealternativer for JetKVM-enheten din",
|
||||
"hardware_time_10_minutes": "10 minutter",
|
||||
"hardware_time_1_hour": "1 time",
|
||||
"hardware_time_1_minute": "1 minutt",
|
||||
"hardware_time_10_minutes": "10 minutter",
|
||||
"hardware_time_30_minutes": "30 minutter",
|
||||
"hardware_time_5_minutes": "5 minutter",
|
||||
"hardware_time_never": "Aldri",
|
||||
|
|
@ -327,6 +327,7 @@
|
|||
"invalid_password": "Ugyldig passord",
|
||||
"ip_address": "IP-adresse",
|
||||
"ipv6_address_label": "Adresse",
|
||||
"ipv6_gateway": "Inngangsport",
|
||||
"ipv6_information": "IPv6-informasjon",
|
||||
"ipv6_link_local": "Link-local",
|
||||
"ipv6_preferred_lifetime": "Foretrukket levetid",
|
||||
|
|
@ -409,8 +410,8 @@
|
|||
"log_in": "Logg inn",
|
||||
"log_out": "Logg ut",
|
||||
"logged_in_as": "Logget inn som",
|
||||
"login_enter_password_description": "Skriv inn passordet ditt for å få tilgang til JetKVM-en din.",
|
||||
"login_enter_password": "Skriv inn passordet ditt",
|
||||
"login_enter_password_description": "Skriv inn passordet ditt for å få tilgang til JetKVM-en din.",
|
||||
"login_error": "Det oppsto en feil under innlogging",
|
||||
"login_forgot_password": "Glemt passord?",
|
||||
"login_password_label": "Passord",
|
||||
|
|
@ -424,8 +425,8 @@
|
|||
"macro_name_required": "Navn er obligatorisk",
|
||||
"macro_name_too_long": "Navnet må være mindre enn 50 tegn",
|
||||
"macro_please_fix_validation_errors": "Vennligst rett opp valideringsfeilene",
|
||||
"macro_save_error": "Det oppsto en feil under lagring.",
|
||||
"macro_save": "Lagre makro",
|
||||
"macro_save_error": "Det oppsto en feil under lagring.",
|
||||
"macro_step_count": "{steps} / {max} trinn",
|
||||
"macro_step_duration_description": "Tid for å vente før man tar neste steg.",
|
||||
"macro_step_duration_label": "Stegvarighet",
|
||||
|
|
@ -439,8 +440,8 @@
|
|||
"macro_steps_description": "Taster/modifikatorer utføres i rekkefølge med en forsinkelse mellom hvert trinn.",
|
||||
"macro_steps_label": "Trinn",
|
||||
"macros_add_description": "Opprett en ny tastaturmakro",
|
||||
"macros_add_new_macro": "Legg til ny makro",
|
||||
"macros_add_new": "Legg til ny makro",
|
||||
"macros_add_new_macro": "Legg til ny makro",
|
||||
"macros_aria_add_new": "Legg til ny makro",
|
||||
"macros_aria_delete": "Slett makro {name}",
|
||||
"macros_aria_duplicate": "Duplikatmakro {name}",
|
||||
|
|
@ -462,16 +463,16 @@
|
|||
"macros_edit_button": "Redigere",
|
||||
"macros_edit_description": "Endre tastaturmakroen din",
|
||||
"macros_edit_title": "Rediger makro",
|
||||
"macros_failed_create_error": "Klarte ikke å opprette makro: {error}",
|
||||
"macros_failed_create": "Kunne ikke opprette makroen",
|
||||
"macros_failed_delete_error": "Klarte ikke å slette makroen: {error}",
|
||||
"macros_failed_create_error": "Klarte ikke å opprette makro: {error}",
|
||||
"macros_failed_delete": "Kunne ikke slette makroen",
|
||||
"macros_failed_duplicate_error": "Klarte ikke å duplisere makroen: {error}",
|
||||
"macros_failed_delete_error": "Klarte ikke å slette makroen: {error}",
|
||||
"macros_failed_duplicate": "Kunne ikke duplisere makroen",
|
||||
"macros_failed_reorder_error": "Kunne ikke endre rekkefølgen på makroer: {error}",
|
||||
"macros_failed_duplicate_error": "Klarte ikke å duplisere makroen: {error}",
|
||||
"macros_failed_reorder": "Kunne ikke endre rekkefølgen på makroene",
|
||||
"macros_failed_update_error": "Kunne ikke oppdatere makroen: {error}",
|
||||
"macros_failed_reorder_error": "Kunne ikke endre rekkefølgen på makroer: {error}",
|
||||
"macros_failed_update": "Kunne ikke oppdatere makroen",
|
||||
"macros_failed_update_error": "Kunne ikke oppdatere makroen: {error}",
|
||||
"macros_invalid_data": "Ugyldige makrodata",
|
||||
"macros_loading": "Laster inn makroer …",
|
||||
"macros_max_reached": "Maks nådd",
|
||||
|
|
@ -506,8 +507,8 @@
|
|||
"mount_error_list_storage": "Feil ved oppføring av lagringsfiler: {error}",
|
||||
"mount_error_title": "Monteringsfeil",
|
||||
"mount_get_state_error": "Klarte ikke å hente status for virtuelle medier: {error}",
|
||||
"mount_jetkvm_storage_description": "Monter tidligere opplastede filer fra JetKVM-lagringen",
|
||||
"mount_jetkvm_storage": "JetKVM-lagringsmontering",
|
||||
"mount_jetkvm_storage_description": "Monter tidligere opplastede filer fra JetKVM-lagringen",
|
||||
"mount_mode_cdrom": "CD/DVD",
|
||||
"mount_mode_disk": "Disk",
|
||||
"mount_mounted_as": "Montert som",
|
||||
|
|
@ -520,8 +521,8 @@
|
|||
"mount_popular_images": "Populære bilder",
|
||||
"mount_streaming_from_url": "Strømming fra URL",
|
||||
"mount_supported_formats": "Støttede formater: ISO, IMG",
|
||||
"mount_unmount_error": "Klarte ikke å demontere bildet: {error}",
|
||||
"mount_unmount": "Avmonter",
|
||||
"mount_unmount_error": "Klarte ikke å demontere bildet: {error}",
|
||||
"mount_upload_description": "Velg en bildefil som skal lastes opp til JetKVM-lagring",
|
||||
"mount_upload_error": "Opplastingsfeil: {error}",
|
||||
"mount_upload_failed_datachannel": "Kunne ikke opprette datakanal for filopplasting",
|
||||
|
|
@ -529,8 +530,8 @@
|
|||
"mount_upload_successful": "Opplastingen var vellykket",
|
||||
"mount_upload_title": "Last opp nytt bilde",
|
||||
"mount_uploaded_has_been_uploaded": "{name} har blitt lastet opp",
|
||||
"mount_uploading_with_name": "Laster opp {name}",
|
||||
"mount_uploading": "Laster opp…",
|
||||
"mount_uploading_with_name": "Laster opp {name}",
|
||||
"mount_url_description": "Monter filer fra en hvilken som helst offentlig nettadresse",
|
||||
"mount_url_input_label": "Bilde-URL",
|
||||
"mount_url_mount": "URL-montering",
|
||||
|
|
@ -538,10 +539,10 @@
|
|||
"mount_view_device_title": "Monter fra JetKVM-lagring",
|
||||
"mount_view_url_description": "Skriv inn en URL til bildefilen som skal monteres",
|
||||
"mount_view_url_title": "Monter fra URL",
|
||||
"mount_virtual_media_description": "Monter et image for å starte opp fra eller installere et operativsystem.",
|
||||
"mount_virtual_media_source_description": "Velg hvordan du vil montere virtuelle medier",
|
||||
"mount_virtual_media_source": "Virtuell mediekilde",
|
||||
"mount_virtual_media": "Virtuelle medier",
|
||||
"mount_virtual_media_description": "Monter et image for å starte opp fra eller installere et operativsystem.",
|
||||
"mount_virtual_media_source": "Virtuell mediekilde",
|
||||
"mount_virtual_media_source_description": "Velg hvordan du vil montere virtuelle medier",
|
||||
"mouse_alt_finger": "Fingerberøring av en skjerm",
|
||||
"mouse_alt_mouse": "Musikon",
|
||||
"mouse_description": "Konfigurer markørens oppførsel og interaksjonsinnstillinger for enheten din",
|
||||
|
|
@ -558,10 +559,10 @@
|
|||
"mouse_jiggler_light": "Lys - 5m",
|
||||
"mouse_jiggler_standard": "Standard - 1 m",
|
||||
"mouse_jiggler_title": "Jiggler",
|
||||
"mouse_mode_absolute_description": "Mest praktisk",
|
||||
"mouse_mode_absolute": "Absolutt",
|
||||
"mouse_mode_relative_description": "Mest kompatible",
|
||||
"mouse_mode_absolute_description": "Mest praktisk",
|
||||
"mouse_mode_relative": "Slektning",
|
||||
"mouse_mode_relative_description": "Mest kompatible",
|
||||
"mouse_modes_description": "Velg museinndatamodus",
|
||||
"mouse_modes_title": "Moduser",
|
||||
"mouse_scroll_high": "Høy",
|
||||
|
|
@ -574,12 +575,17 @@
|
|||
"mouse_title": "Mus",
|
||||
"network_custom_domain": "Tilpasset domene",
|
||||
"network_description": "Konfigurer nettverksinnstillingene dine",
|
||||
"network_dhcp_client_description": "Konfigurer hvilken DHCP-klient som skal brukes",
|
||||
"network_dhcp_client_jetkvm": "JetKVM intern",
|
||||
"network_dhcp_client_title": "DHCP-klient",
|
||||
"network_dhcp_information": "DHCP-informasjon",
|
||||
"network_dhcp_lease_renew_confirm_description": "Dette vil be om en ny IP-adresse fra DHCP-serveren din. Enheten din kan midlertidig miste nettverkstilkoblingen under denne prosessen.",
|
||||
"network_dhcp_lease_renew": "Forny DHCP-leieavtale",
|
||||
"network_dhcp_lease_renew_confirm": "Forny leieavtalen",
|
||||
"network_dhcp_lease_renew_confirm_description": "Dette vil be om en ny IP-adresse fra DHCP-serveren din. Enheten din kan midlertidig miste nettverkstilkoblingen under denne prosessen.",
|
||||
"network_dhcp_lease_renew_confirm_new_a": "Hvis du får en ny IP-adresse",
|
||||
"network_dhcp_lease_renew_confirm_new_b": "du må kanskje koble til på nytt med den nye adressen",
|
||||
"network_dhcp_lease_renew_failed": "Kunne ikke fornye leieavtalen: {error}",
|
||||
"network_dhcp_lease_renew_success": "DHCP-leieavtale fornyet",
|
||||
"network_dhcp_lease_renew": "Forny DHCP-leieavtale",
|
||||
"network_domain_custom": "Skikk",
|
||||
"network_domain_description": "Nettverksdomenesuffiks for enheten",
|
||||
"network_domain_dhcp_provided": "DHCP levert",
|
||||
|
|
@ -588,21 +594,35 @@
|
|||
"network_hostname_description": "Enhetsidentifikator på nettverket. Blank for systemstandard",
|
||||
"network_hostname_title": "Vertsnavn",
|
||||
"network_http_proxy_description": "Proxy-server for utgående HTTP(S)-forespørsler fra enheten. Tomt hvis ingen.",
|
||||
"network_http_proxy_invalid": "Ugyldig HTTP-proxy-URL",
|
||||
"network_http_proxy_title": "HTTP-proxy",
|
||||
"network_ipv4_address": "IPv4-adresse",
|
||||
"network_ipv4_dns": "IPv4 DNS",
|
||||
"network_ipv4_gateway": "IPv4-gateway",
|
||||
"network_ipv4_mode_description": "Konfigurer IPv4-modusen",
|
||||
"network_ipv4_mode_dhcp": "DHCP",
|
||||
"network_ipv4_mode_static": "Statisk",
|
||||
"network_ipv4_mode_title": "IPv4-modus",
|
||||
"network_ipv4_netmask": "IPv4-nettmaske",
|
||||
"network_ipv6_address": "IPv6-adresse",
|
||||
"network_ipv6_information": "IPv6-informasjon",
|
||||
"network_ipv6_mode_description": "Konfigurer IPv6-modusen",
|
||||
"network_ipv6_mode_dhcpv6": "DHCPv6",
|
||||
"network_ipv6_mode_disabled": "Funksjonshemmet",
|
||||
"network_ipv6_mode_link_local": "Kun lenkelokal",
|
||||
"network_ipv6_mode_slaac": "SLAAC",
|
||||
"network_ipv6_mode_slaac_dhcpv6": "SLAAC + DHCPv6",
|
||||
"network_ipv6_mode_static": "Statisk",
|
||||
"network_ipv6_mode_title": "IPv6-modus",
|
||||
"network_ipv6_netmask": "IPv6-nettmaske",
|
||||
"network_ipv6_no_addresses": "Ingen IPv6-adresser konfigurert",
|
||||
"network_ll_dp_all": "Alle",
|
||||
"network_ll_dp_basic": "Grunnleggende",
|
||||
"network_ll_dp_description": "Kontroller hvilke TLV-er som skal sendes over Link Layer Discovery Protocol",
|
||||
"network_ll_dp_disabled": "Funksjonshemmet",
|
||||
"network_ll_dp_title": "LLDP",
|
||||
"network_mac_address_copy_error": "Kunne ikke kopiere MAC-adressen",
|
||||
"network_mac_address_copy_success": "MAC-adresse { mac } kopiert til utklippstavlen",
|
||||
"network_mac_address_description": "Maskinvareidentifikator for nettverksgrensesnittet",
|
||||
"network_mac_address_title": "MAC-adresse",
|
||||
"network_mdns_auto": "Bil",
|
||||
|
|
@ -612,9 +632,19 @@
|
|||
"network_mdns_ipv6_only": "Kun IPv6",
|
||||
"network_mdns_title": "mDNS",
|
||||
"network_no_dhcp_lease": "Ingen DHCP-leaseinformasjon tilgjengelig",
|
||||
"network_no_information_description": "Ingen nettverkskonfigurasjon tilgjengelig",
|
||||
"network_no_information_headline": "Nettverksinformasjon",
|
||||
"network_pending_dhcp_mode_change_description": "Lagre innstillinger for å aktivere DHCP-modus og vise leieavtaleinformasjon",
|
||||
"network_pending_dhcp_mode_change_headline": "Venter på endring av DHCP IPv4-modus",
|
||||
"network_save_settings": "Lagre innstillinger",
|
||||
"network_save_settings_apply_title": "Bruk nettverksinnstillinger",
|
||||
"network_save_settings_confirm": "Bruk endringer",
|
||||
"network_save_settings_confirm_description": "Følgende nettverksinnstillinger vil bli brukt. Disse endringene kan kreve en omstart og forårsake en kortvarig frakobling.",
|
||||
"network_save_settings_confirm_heading": "Konfigurasjonsendringer",
|
||||
"network_save_settings_failed": "Kunne ikke lagre nettverksinnstillinger: {error}",
|
||||
"network_save_settings_success": "Nettverksinnstillinger lagret",
|
||||
"network_save_settings": "Lagre innstillinger",
|
||||
"network_settings_invalid_ipv4_cidr": "Ugyldig CIDR-notasjon for IPv4-adresse",
|
||||
"network_settings_load_error": "Kunne ikke laste inn nettverksinnstillinger: {error}",
|
||||
"network_time_sync_description": "Konfigurer innstillinger for tidssynkronisering",
|
||||
"network_time_sync_http_only": "Kun HTTP",
|
||||
"network_time_sync_ntp_and_http": "NTP og HTTP",
|
||||
|
|
@ -640,8 +670,8 @@
|
|||
"paste_modal_failed_paste": "Klarte ikke å lime inn tekst: {error}",
|
||||
"paste_modal_invalid_chars_intro": "Følgende tegn vil ikke bli limt inn:",
|
||||
"paste_modal_paste_from_host": "Lim inn fra verten",
|
||||
"paste_modal_paste_text_description": "Lim inn tekst fra klienten din til den eksterne verten",
|
||||
"paste_modal_paste_text": "Lim inn tekst",
|
||||
"paste_modal_paste_text_description": "Lim inn tekst fra klienten din til den eksterne verten",
|
||||
"paste_modal_sending_using_layout": "Sende tekst ved hjelp av tastaturoppsett: {iso} - {name}",
|
||||
"peer_connection_closed": "Lukket",
|
||||
"peer_connection_closing": "Lukking",
|
||||
|
|
@ -668,20 +698,20 @@
|
|||
"retry": "Prøv på nytt",
|
||||
"saving": "Lagrer…",
|
||||
"search_placeholder": "Søk…",
|
||||
"serial_console": "Seriell konsoll",
|
||||
"serial_console_baud_rate": "Baudhastighet",
|
||||
"serial_console_configure_description": "Konfigurer innstillingene for seriekonsollen",
|
||||
"serial_console_data_bits": "Databiter",
|
||||
"serial_console_get_settings_error": "Klarte ikke å hente innstillinger for seriell konsoll: {error}",
|
||||
"serial_console_open_console": "Åpne konsollen",
|
||||
"serial_console_parity": "Paritet",
|
||||
"serial_console_parity_even": "Paritet",
|
||||
"serial_console_parity_mark": "Mark Paritet",
|
||||
"serial_console_parity_none": "Ingen paritet",
|
||||
"serial_console_parity_odd": "Oddeparitet",
|
||||
"serial_console_parity_space": "Romparitet",
|
||||
"serial_console_parity": "Paritet",
|
||||
"serial_console_set_settings_error": "Klarte ikke å sette innstillingene for seriell konsoll til {settings} : {error}",
|
||||
"serial_console_stop_bits": "Stoppbiter",
|
||||
"serial_console": "Seriell konsoll",
|
||||
"setting_remote_description": "Innstilling av fjernkontrollbeskrivelse",
|
||||
"setting_remote_session_description": "Angi beskrivelse av ekstern økt...",
|
||||
"setting_up_connection_to_device": "Setter opp tilkobling til enhet...",
|
||||
|
|
@ -691,8 +721,8 @@
|
|||
"settings_back_to_kvm": "Tilbake til KVM",
|
||||
"settings_general": "General",
|
||||
"settings_hardware": "Maskinvare",
|
||||
"settings_keyboard_macros": "Tastaturmakroer",
|
||||
"settings_keyboard": "Tastatur",
|
||||
"settings_keyboard_macros": "Tastaturmakroer",
|
||||
"settings_mouse": "Mus",
|
||||
"settings_network": "Nettverk",
|
||||
"settings_video": "Video",
|
||||
|
|
@ -712,6 +742,7 @@
|
|||
"updates_failed_check": "Klarte ikke å se etter oppdateringer: {error}",
|
||||
"updates_failed_get_device_version": "Klarte ikke å hente enhetsversjon: {error}",
|
||||
"updating_leave_device_on": "Vennligst ikke slå av enheten din ...",
|
||||
"usb": "USB",
|
||||
"usb_config_custom": "Skikk",
|
||||
"usb_config_default": "JetKVM-standard",
|
||||
"usb_config_dell": "Dell Multimedia Pro-tastatur",
|
||||
|
|
@ -758,7 +789,6 @@
|
|||
"usb_state_connecting": "Tilkobling",
|
||||
"usb_state_disconnected": "Frakoblet",
|
||||
"usb_state_low_power_mode": "Lavstrømsmodus",
|
||||
"usb": "USB",
|
||||
"user_interface_language_description": "Velg språket som skal brukes i JetKVM-brukergrensesnittet",
|
||||
"user_interface_language_title": "Grensesnittspråk",
|
||||
"video_brightness_description": "Lysstyrkenivå ( {value} x)",
|
||||
|
|
@ -825,6 +855,7 @@
|
|||
"video_title": "Video",
|
||||
"view_details": "Vis detaljer",
|
||||
"virtual_keyboard_header": "Virtuelt tastatur",
|
||||
"wake_on_lan": "Vekk på LAN",
|
||||
"wake_on_lan_add_device_device_name": "Enhetsnavn",
|
||||
"wake_on_lan_add_device_example_device_name": "Plex Media Server",
|
||||
"wake_on_lan_add_device_mac_address": "MAC-adresse",
|
||||
|
|
@ -840,7 +871,6 @@
|
|||
"wake_on_lan_failed_send_magic": "Kunne ikke sende magisk pakke",
|
||||
"wake_on_lan_invalid_mac": "Ugyldig MAC-adresse",
|
||||
"wake_on_lan_magic_sent_success": "Magisk pakke sendt",
|
||||
"wake_on_lan": "Vekk på LAN",
|
||||
"welcome_to_jetkvm_description": "Kontroller hvilken som helst datamaskin eksternt",
|
||||
"welcome_to_jetkvm": "Velkommen til JetKVM"
|
||||
"welcome_to_jetkvm": "Velkommen til JetKVM",
|
||||
"welcome_to_jetkvm_description": "Kontroller hvilken som helst datamaskin eksternt"
|
||||
}
|
||||
|
|
@ -107,10 +107,10 @@
|
|||
"already_adopted_title": "Enheten är redan registrerad",
|
||||
"appearance_description": "Välj ditt önskade färgtema",
|
||||
"appearance_page_description": "Anpassa utseendet och känslan hos ditt JetKVM-gränssnitt",
|
||||
"appearance_theme": "Tema",
|
||||
"appearance_theme_dark": "Mörk",
|
||||
"appearance_theme_light": "Ljus",
|
||||
"appearance_theme_system": "System",
|
||||
"appearance_theme": "Tema",
|
||||
"appearance_title": "Utseende",
|
||||
"attach": "Bifoga",
|
||||
"atx_power_control_get_state_error": "Misslyckades med att hämta ATX-strömstatus: {error}",
|
||||
|
|
@ -121,85 +121,85 @@
|
|||
"atx_power_control_reset_button": "Återställa",
|
||||
"atx_power_control_send_action_error": "Misslyckades med att skicka ATX-strömåtgärd {action} : {error}",
|
||||
"atx_power_control_short_power_button": "Kort tryck",
|
||||
"auth_authentication_mode": "Välj ett autentiseringsläge",
|
||||
"auth_authentication_mode_error": "Ett fel uppstod när autentiseringsläget ställdes in",
|
||||
"auth_authentication_mode_invalid": "Ogiltigt autentiseringsläge",
|
||||
"auth_authentication_mode": "Välj ett autentiseringsläge",
|
||||
"auth_connect_to_cloud": "Anslut din JetKVM till molnet",
|
||||
"auth_connect_to_cloud_action": "Logga in och anslut enheten",
|
||||
"auth_connect_to_cloud_description": "Lås upp fjärråtkomst och avancerade funktioner för din enhet",
|
||||
"auth_connect_to_cloud": "Anslut din JetKVM till molnet",
|
||||
"auth_header_cta_already_have_account": "Har du redan ett konto?",
|
||||
"auth_header_cta_dont_have_account": "Har du inget konto?",
|
||||
"auth_header_cta_new_to_jetkvm": "Nybörjare på JetKVM?",
|
||||
"auth_login": "Logga in på ditt JetKVM-konto",
|
||||
"auth_login_action": "Logga in",
|
||||
"auth_login_description": "Logga in för att få åtkomst till och hantera dina enheter säkert",
|
||||
"auth_login": "Logga in på ditt JetKVM-konto",
|
||||
"auth_mode_local": "Lokal autentiseringsmetod",
|
||||
"auth_mode_local_change_later": "Du kan alltid ändra din autentiseringsmetod senare i inställningarna.",
|
||||
"auth_mode_local_description": "Välj hur du vill säkra din JetKVM-enhet lokalt.",
|
||||
"auth_mode_local_no_password_description": "Snabb åtkomst utan lösenordsautentisering.",
|
||||
"auth_mode_local_no_password": "Inget lösenord",
|
||||
"auth_mode_local_no_password_description": "Snabb åtkomst utan lösenordsautentisering.",
|
||||
"auth_mode_local_password": "Lösenord",
|
||||
"auth_mode_local_password_confirm_description": "Bekräfta ditt lösenord",
|
||||
"auth_mode_local_password_confirm_label": "Bekräfta lösenord",
|
||||
"auth_mode_local_password_description": "Säkra din enhet med ett lösenord för extra skydd.",
|
||||
"auth_mode_local_password_do_not_match": "Lösenorden matchar inte",
|
||||
"auth_mode_local_password_failed_set": "Misslyckades med att ange lösenord: {error}",
|
||||
"auth_mode_local_password_note_local": "All data finns kvar på din lokala enhet.",
|
||||
"auth_mode_local_password_note": "Detta lösenord kommer att användas för att säkra dina enhetsdata och skydda mot obehörig åtkomst.",
|
||||
"auth_mode_local_password_note_local": "All data finns kvar på din lokala enhet.",
|
||||
"auth_mode_local_password_set": "Ange ett lösenord",
|
||||
"auth_mode_local_password_set_button": "Ange lösenord",
|
||||
"auth_mode_local_password_set_description": "Skapa ett starkt lösenord för att säkra din JetKVM-enhet lokalt.",
|
||||
"auth_mode_local_password_set_label": "Ange ett lösenord",
|
||||
"auth_mode_local_password_set": "Ange ett lösenord",
|
||||
"auth_mode_local_password": "Lösenord",
|
||||
"auth_mode_local": "Lokal autentiseringsmetod",
|
||||
"auth_signup_connect_to_cloud_action": "Registrera och anslut enhet",
|
||||
"auth_signup_create_account": "Skapa ditt JetKVM-konto",
|
||||
"auth_signup_create_account_action": "Skapa konto",
|
||||
"auth_signup_create_account_description": "Skapa ditt konto och börja enkelt hantera dina enheter.",
|
||||
"auth_signup_create_account": "Skapa ditt JetKVM-konto",
|
||||
"back_to_devices": "Tillbaka till Enheter",
|
||||
"back": "Tillbaka",
|
||||
"back_to_devices": "Tillbaka till Enheter",
|
||||
"cancel": "Avboka",
|
||||
"close": "Nära",
|
||||
"cloud_kvms_description": "Hantera dina moln-KVM:er och anslut till dem säkert.",
|
||||
"cloud_kvms_no_devices_description": "Du har inga enheter med aktiverat JetKVM Cloud ännu.",
|
||||
"cloud_kvms_no_devices": "Inga enheter hittades",
|
||||
"cloud_kvms": "Moln-KVM:er",
|
||||
"cloud_kvms_description": "Hantera dina moln-KVM:er och anslut till dem säkert.",
|
||||
"cloud_kvms_no_devices": "Inga enheter hittades",
|
||||
"cloud_kvms_no_devices_description": "Du har inga enheter med aktiverat JetKVM Cloud ännu.",
|
||||
"confirm": "Bekräfta",
|
||||
"connect_to_kvm": "Anslut till KVM",
|
||||
"connecting_to_device": "Ansluter till enhet…",
|
||||
"connection_established": "Anslutning upprättad",
|
||||
"connection_stats_badge_jitter_buffer_avg_delay": "Genomsnittlig fördröjning för jitterbuffert",
|
||||
"connection_stats_badge_jitter": "Jitter",
|
||||
"connection_stats_connection_description": "Anslutningen mellan klienten och JetKVM:n.",
|
||||
"connection_stats_badge_jitter_buffer_avg_delay": "Genomsnittlig fördröjning för jitterbuffert",
|
||||
"connection_stats_connection": "Förbindelse",
|
||||
"connection_stats_frames_per_second_description": "Antal inkommande videobildrutor som visas per sekund.",
|
||||
"connection_stats_connection_description": "Anslutningen mellan klienten och JetKVM:n.",
|
||||
"connection_stats_frames_per_second": "Bildrutor per sekund",
|
||||
"connection_stats_network_stability_description": "Hur jämnt flödet av inkommande videopaket är över nätverket.",
|
||||
"connection_stats_frames_per_second_description": "Antal inkommande videobildrutor som visas per sekund.",
|
||||
"connection_stats_network_stability": "Nätverksstabilitet",
|
||||
"connection_stats_packets_lost_description": "Antal förlorade inkommande RTP-videopaket.",
|
||||
"connection_stats_network_stability_description": "Hur jämnt flödet av inkommande videopaket är över nätverket.",
|
||||
"connection_stats_packets_lost": "Paket förlorade",
|
||||
"connection_stats_playback_delay_description": "Fördröjning som läggs till av jitterbufferten för att jämna ut uppspelningen när bildrutor anländer ojämnt.",
|
||||
"connection_stats_packets_lost_description": "Antal förlorade inkommande RTP-videopaket.",
|
||||
"connection_stats_playback_delay": "Uppspelningsfördröjning",
|
||||
"connection_stats_round_trip_time_description": "Tur- och returtid för det aktiva ICE-kandidatparet mellan peers.",
|
||||
"connection_stats_playback_delay_description": "Fördröjning som läggs till av jitterbufferten för att jämna ut uppspelningen när bildrutor anländer ojämnt.",
|
||||
"connection_stats_round_trip_time": "Tur- och returtid",
|
||||
"connection_stats_round_trip_time_description": "Tur- och returtid för det aktiva ICE-kandidatparet mellan peers.",
|
||||
"connection_stats_sidebar": "Anslutningsstatistik",
|
||||
"connection_stats_video_description": "Videoströmmen från JetKVM till klienten.",
|
||||
"connection_stats_video": "Video",
|
||||
"connection_stats_video_description": "Videoströmmen från JetKVM till klienten.",
|
||||
"continue": "Fortsätta",
|
||||
"creating_peer_connection": "Skapar peer-kontakt…",
|
||||
"dc_power_control_current_unit": "En",
|
||||
"dc_power_control_current": "Nuvarande",
|
||||
"dc_power_control_current_unit": "En",
|
||||
"dc_power_control_get_state_error": "Misslyckades med att hämta likströmsstatus: {error}",
|
||||
"dc_power_control_power": "Driva",
|
||||
"dc_power_control_power_off_button": "Stäng av",
|
||||
"dc_power_control_power_off_state": "Stäng av",
|
||||
"dc_power_control_power_on_button": "Slå på",
|
||||
"dc_power_control_power_on_state": "Slå på",
|
||||
"dc_power_control_power_unit": "V",
|
||||
"dc_power_control_power": "Driva",
|
||||
"dc_power_control_restore_last_state": "Senaste delstaten",
|
||||
"dc_power_control_restore_power_state": "Återställ strömförlust",
|
||||
"dc_power_control_set_power_state_error": "Misslyckades med att skicka likströmsstatus till {enabled} : {error}",
|
||||
"dc_power_control_set_restore_state_error": "Misslyckades med att skicka återställningsstatus för likström till {state} : {error}",
|
||||
"dc_power_control_voltage_unit": "V",
|
||||
"dc_power_control_voltage": "Spänning",
|
||||
"dc_power_control_voltage_unit": "V",
|
||||
"delete": "Radera",
|
||||
"deregister_button": "Avregistrera dig från molnet",
|
||||
"deregister_cloud_devices": "Molnenheter",
|
||||
|
|
@ -226,12 +226,12 @@
|
|||
"extension_popover_load_and_manage_extensions": "Ladda och hantera dina tillägg",
|
||||
"extension_popover_set_error_notification": "Misslyckades med att ange aktivt tillägg: {error}",
|
||||
"extension_popover_unload_extension": "Avlasta tillägg",
|
||||
"extension_serial_console_description": "Åtkomst till din seriella konsoltillägg",
|
||||
"extension_serial_console": "Seriell konsol",
|
||||
"extensions_atx_power_control_description": "Styr din maskins strömförsörjning via ATX-strömkontroll.",
|
||||
"extension_serial_console_description": "Åtkomst till din seriella konsoltillägg",
|
||||
"extensions_atx_power_control": "ATX-strömkontroll",
|
||||
"extensions_dc_power_control_description": "Styr din DC-strömförlängning",
|
||||
"extensions_atx_power_control_description": "Styr din maskins strömförsörjning via ATX-strömkontroll.",
|
||||
"extensions_dc_power_control": "DC-strömstyrning",
|
||||
"extensions_dc_power_control_description": "Styr din DC-strömförlängning",
|
||||
"extensions_popover_extensions": "Tillägg",
|
||||
"gathering_ice_candidates": "Samlar ICE-kandidater…",
|
||||
"general_app_version": "App: {version}",
|
||||
|
|
@ -241,8 +241,8 @@
|
|||
"general_check_for_updates": "Kontrollera efter uppdateringar",
|
||||
"general_page_description": "Konfigurera enhetsinställningar och uppdatera inställningar",
|
||||
"general_reboot_description": "Vill du fortsätta med att starta om systemet?",
|
||||
"general_reboot_device_description": "Stäng av och på JetKVM:en",
|
||||
"general_reboot_device": "Starta om enheten",
|
||||
"general_reboot_device_description": "Stäng av och på JetKVM:en",
|
||||
"general_reboot_no_button": "Inga",
|
||||
"general_reboot_title": "Starta om JetKVM",
|
||||
"general_reboot_yes_button": "Ja",
|
||||
|
|
@ -295,9 +295,9 @@
|
|||
"hardware_display_orientation_title": "Skärmorientering",
|
||||
"hardware_display_wake_up_note": "Skärmen vaknar när anslutningsstatusen ändras eller när den berörs.",
|
||||
"hardware_page_description": "Konfigurera skärminställningar och maskinvarualternativ för din JetKVM-enhet",
|
||||
"hardware_time_10_minutes": "10 minuter",
|
||||
"hardware_time_1_hour": "1 timme",
|
||||
"hardware_time_1_minute": "1 minut",
|
||||
"hardware_time_10_minutes": "10 minuter",
|
||||
"hardware_time_30_minutes": "30 minuter",
|
||||
"hardware_time_5_minutes": "5 minuter",
|
||||
"hardware_time_never": "Aldrig",
|
||||
|
|
@ -327,6 +327,7 @@
|
|||
"invalid_password": "Ogiltigt lösenord",
|
||||
"ip_address": "IP-adress",
|
||||
"ipv6_address_label": "Adress",
|
||||
"ipv6_gateway": "Inkörsport",
|
||||
"ipv6_information": "IPv6-information",
|
||||
"ipv6_link_local": "Länklokal",
|
||||
"ipv6_preferred_lifetime": "Föredragen livslängd",
|
||||
|
|
@ -409,8 +410,8 @@
|
|||
"log_in": "Logga in",
|
||||
"log_out": "Logga ut",
|
||||
"logged_in_as": "Inloggad som",
|
||||
"login_enter_password_description": "Ange ditt lösenord för att komma åt din JetKVM.",
|
||||
"login_enter_password": "Ange ditt lösenord",
|
||||
"login_enter_password_description": "Ange ditt lösenord för att komma åt din JetKVM.",
|
||||
"login_error": "Ett fel uppstod vid inloggning",
|
||||
"login_forgot_password": "Glömt lösenordet?",
|
||||
"login_password_label": "Lösenord",
|
||||
|
|
@ -424,8 +425,8 @@
|
|||
"macro_name_required": "Namn krävs",
|
||||
"macro_name_too_long": "Namnet måste vara kortare än 50 tecken",
|
||||
"macro_please_fix_validation_errors": "Vänligen åtgärda valideringsfelen",
|
||||
"macro_save_error": "Ett fel uppstod när dokumentet skulle sparas.",
|
||||
"macro_save": "Spara makro",
|
||||
"macro_save_error": "Ett fel uppstod när dokumentet skulle sparas.",
|
||||
"macro_step_count": "{steps} / {max} steg",
|
||||
"macro_step_duration_description": "Dags att vänta innan nästa steg genomförs.",
|
||||
"macro_step_duration_label": "Steglängd",
|
||||
|
|
@ -439,8 +440,8 @@
|
|||
"macro_steps_description": "Tangenter/modifierare exekveras i sekvens med en fördröjning mellan varje steg.",
|
||||
"macro_steps_label": "Steg",
|
||||
"macros_add_description": "Skapa ett nytt tangentbordsmakro",
|
||||
"macros_add_new_macro": "Lägg till nytt makro",
|
||||
"macros_add_new": "Lägg till nytt makro",
|
||||
"macros_add_new_macro": "Lägg till nytt makro",
|
||||
"macros_aria_add_new": "Lägg till nytt makro",
|
||||
"macros_aria_delete": "Ta bort makro {name}",
|
||||
"macros_aria_duplicate": "Duplicera makro {name}",
|
||||
|
|
@ -462,16 +463,16 @@
|
|||
"macros_edit_button": "Redigera",
|
||||
"macros_edit_description": "Ändra ditt tangentbordsmakro",
|
||||
"macros_edit_title": "Redigera makro",
|
||||
"macros_failed_create_error": "Misslyckades med att skapa makrot: {error}",
|
||||
"macros_failed_create": "Misslyckades med att skapa makrot",
|
||||
"macros_failed_delete_error": "Misslyckades med att ta bort makrot: {error}",
|
||||
"macros_failed_create_error": "Misslyckades med att skapa makrot: {error}",
|
||||
"macros_failed_delete": "Misslyckades med att ta bort makrot",
|
||||
"macros_failed_duplicate_error": "Misslyckades med att duplicera makrot: {error}",
|
||||
"macros_failed_delete_error": "Misslyckades med att ta bort makrot: {error}",
|
||||
"macros_failed_duplicate": "Misslyckades med att duplicera makrot",
|
||||
"macros_failed_reorder_error": "Misslyckades med att ändra ordning på makron: {error}",
|
||||
"macros_failed_duplicate_error": "Misslyckades med att duplicera makrot: {error}",
|
||||
"macros_failed_reorder": "Misslyckades med att ändra ordningen på makrona",
|
||||
"macros_failed_update_error": "Misslyckades med att uppdatera makrot: {error}",
|
||||
"macros_failed_reorder_error": "Misslyckades med att ändra ordning på makron: {error}",
|
||||
"macros_failed_update": "Misslyckades med att uppdatera makrot",
|
||||
"macros_failed_update_error": "Misslyckades med att uppdatera makrot: {error}",
|
||||
"macros_invalid_data": "Ogiltig makrodata",
|
||||
"macros_loading": "Läser in makron…",
|
||||
"macros_max_reached": "Max uppnått",
|
||||
|
|
@ -506,8 +507,8 @@
|
|||
"mount_error_list_storage": "Fel vid lista av lagringsfiler: {error}",
|
||||
"mount_error_title": "Monteringsfel",
|
||||
"mount_get_state_error": "Misslyckades med att hämta virtuellt medietillstånd: {error}",
|
||||
"mount_jetkvm_storage_description": "Montera tidigare uppladdade filer från JetKVM-lagringen",
|
||||
"mount_jetkvm_storage": "JetKVM-lagringsmontering",
|
||||
"mount_jetkvm_storage_description": "Montera tidigare uppladdade filer från JetKVM-lagringen",
|
||||
"mount_mode_cdrom": "CD/DVD",
|
||||
"mount_mode_disk": "Disk",
|
||||
"mount_mounted_as": "Monterad som",
|
||||
|
|
@ -520,8 +521,8 @@
|
|||
"mount_popular_images": "Populära bilder",
|
||||
"mount_streaming_from_url": "Streaming från URL",
|
||||
"mount_supported_formats": "Format som stöds: ISO, IMG",
|
||||
"mount_unmount_error": "Misslyckades med att avmontera bilden: {error}",
|
||||
"mount_unmount": "Avmontera",
|
||||
"mount_unmount_error": "Misslyckades med att avmontera bilden: {error}",
|
||||
"mount_upload_description": "Välj en bildfil att ladda upp till JetKVM-lagring",
|
||||
"mount_upload_error": "Uppladdningsfel: {error}",
|
||||
"mount_upload_failed_datachannel": "Misslyckades med att skapa datakanal för filuppladdning",
|
||||
|
|
@ -529,8 +530,8 @@
|
|||
"mount_upload_successful": "Uppladdningen lyckades",
|
||||
"mount_upload_title": "Ladda upp ny bild",
|
||||
"mount_uploaded_has_been_uploaded": "{name} har laddats upp",
|
||||
"mount_uploading_with_name": "Laddar upp {name}",
|
||||
"mount_uploading": "Laddar upp…",
|
||||
"mount_uploading_with_name": "Laddar upp {name}",
|
||||
"mount_url_description": "Montera filer från valfri offentlig webbadress",
|
||||
"mount_url_input_label": "Bild-URL",
|
||||
"mount_url_mount": "URL-montering",
|
||||
|
|
@ -538,10 +539,10 @@
|
|||
"mount_view_device_title": "Montera från JetKVM-lagring",
|
||||
"mount_view_url_description": "Ange en URL till bildfilen som ska monteras",
|
||||
"mount_view_url_title": "Montera från URL",
|
||||
"mount_virtual_media_description": "Montera en avbildning för att starta från eller installera ett operativsystem.",
|
||||
"mount_virtual_media_source_description": "Välj hur du vill montera ditt virtuella media",
|
||||
"mount_virtual_media_source": "Virtuell mediekälla",
|
||||
"mount_virtual_media": "Virtuella medier",
|
||||
"mount_virtual_media_description": "Montera en avbildning för att starta från eller installera ett operativsystem.",
|
||||
"mount_virtual_media_source": "Virtuell mediekälla",
|
||||
"mount_virtual_media_source_description": "Välj hur du vill montera ditt virtuella media",
|
||||
"mouse_alt_finger": "Finger som rör vid en skärm",
|
||||
"mouse_alt_mouse": "Musikon",
|
||||
"mouse_description": "Konfigurera markörens beteende och interaktionsinställningar för din enhet",
|
||||
|
|
@ -558,10 +559,10 @@
|
|||
"mouse_jiggler_light": "Ljus - 5m",
|
||||
"mouse_jiggler_standard": "Standard - 1 m",
|
||||
"mouse_jiggler_title": "Jiggler",
|
||||
"mouse_mode_absolute_description": "Mest bekvämt",
|
||||
"mouse_mode_absolute": "Absolut",
|
||||
"mouse_mode_relative_description": "Mest kompatibla",
|
||||
"mouse_mode_absolute_description": "Mest bekvämt",
|
||||
"mouse_mode_relative": "Relativ",
|
||||
"mouse_mode_relative_description": "Mest kompatibla",
|
||||
"mouse_modes_description": "Välj musinmatningsläge",
|
||||
"mouse_modes_title": "Lägen",
|
||||
"mouse_scroll_high": "Hög",
|
||||
|
|
@ -574,12 +575,17 @@
|
|||
"mouse_title": "Mus",
|
||||
"network_custom_domain": "Anpassad domän",
|
||||
"network_description": "Konfigurera dina nätverksinställningar",
|
||||
"network_dhcp_client_description": "Konfigurera vilken DHCP-klient som ska användas",
|
||||
"network_dhcp_client_jetkvm": "JetKVM Intern",
|
||||
"network_dhcp_client_title": "DHCP-klient",
|
||||
"network_dhcp_information": "DHCP-information",
|
||||
"network_dhcp_lease_renew_confirm_description": "Detta kommer att begära en ny IP-adress från din DHCP-server. Din enhet kan tillfälligt förlora nätverksanslutningen under denna process.",
|
||||
"network_dhcp_lease_renew": "Förnya DHCP-lease",
|
||||
"network_dhcp_lease_renew_confirm": "Förnya hyresavtalet",
|
||||
"network_dhcp_lease_renew_confirm_description": "Detta kommer att begära en ny IP-adress från din DHCP-server. Din enhet kan tillfälligt förlora nätverksanslutningen under denna process.",
|
||||
"network_dhcp_lease_renew_confirm_new_a": "Om du får en ny IP-adress",
|
||||
"network_dhcp_lease_renew_confirm_new_b": "du kan behöva återansluta med den nya adressen",
|
||||
"network_dhcp_lease_renew_failed": "Misslyckades med att förnya leasingavtalet: {error}",
|
||||
"network_dhcp_lease_renew_success": "DHCP-lease förnyad",
|
||||
"network_dhcp_lease_renew": "Förnya DHCP-lease",
|
||||
"network_domain_custom": "Beställnings",
|
||||
"network_domain_description": "Nätverksdomänsuffix för enheten",
|
||||
"network_domain_dhcp_provided": "DHCP tillhandahålls",
|
||||
|
|
@ -588,21 +594,35 @@
|
|||
"network_hostname_description": "Enhetsidentifierare i nätverket. Tomt för systemstandard",
|
||||
"network_hostname_title": "Värdnamn",
|
||||
"network_http_proxy_description": "Proxyserver för utgående HTTP(S)-förfrågningar från enheten. Tomt för inga.",
|
||||
"network_http_proxy_invalid": "Ogiltig HTTP-proxy-URL",
|
||||
"network_http_proxy_title": "HTTP-proxy",
|
||||
"network_ipv4_address": "IPv4-adress",
|
||||
"network_ipv4_dns": "IPv4 DNS",
|
||||
"network_ipv4_gateway": "IPv4-gateway",
|
||||
"network_ipv4_mode_description": "Konfigurera IPv4-läget",
|
||||
"network_ipv4_mode_dhcp": "DHCP",
|
||||
"network_ipv4_mode_static": "Statisk",
|
||||
"network_ipv4_mode_title": "IPv4-läge",
|
||||
"network_ipv4_netmask": "IPv4-nätmask",
|
||||
"network_ipv6_address": "IPv6-adress",
|
||||
"network_ipv6_information": "IPv6-information",
|
||||
"network_ipv6_mode_description": "Konfigurera IPv6-läget",
|
||||
"network_ipv6_mode_dhcpv6": "DHCPv6",
|
||||
"network_ipv6_mode_disabled": "Funktionshindrad",
|
||||
"network_ipv6_mode_link_local": "Endast länklokal",
|
||||
"network_ipv6_mode_slaac": "SLAAC",
|
||||
"network_ipv6_mode_slaac_dhcpv6": "SLAAC + DHCPv6",
|
||||
"network_ipv6_mode_static": "Statisk",
|
||||
"network_ipv6_mode_title": "IPv6-läge",
|
||||
"network_ipv6_netmask": "IPv6-nätmask",
|
||||
"network_ipv6_no_addresses": "Inga IPv6-adresser konfigurerade",
|
||||
"network_ll_dp_all": "Alla",
|
||||
"network_ll_dp_basic": "Grundläggande",
|
||||
"network_ll_dp_description": "Kontrollera vilka TLV:er som ska skickas via Link Layer Discovery Protocol",
|
||||
"network_ll_dp_disabled": "Funktionshindrad",
|
||||
"network_ll_dp_title": "LLDP",
|
||||
"network_mac_address_copy_error": "Misslyckades med att kopiera MAC-adressen",
|
||||
"network_mac_address_copy_success": "MAC-adress { mac } kopierad till urklipp",
|
||||
"network_mac_address_description": "Maskinvaruidentifierare för nätverksgränssnittet",
|
||||
"network_mac_address_title": "MAC-adress",
|
||||
"network_mdns_auto": "Bil",
|
||||
|
|
@ -612,9 +632,19 @@
|
|||
"network_mdns_ipv6_only": "Endast IPv6",
|
||||
"network_mdns_title": "mDNS",
|
||||
"network_no_dhcp_lease": "Ingen DHCP-leaseinformation tillgänglig",
|
||||
"network_no_information_description": "Ingen nätverkskonfiguration tillgänglig",
|
||||
"network_no_information_headline": "Nätverksinformation",
|
||||
"network_pending_dhcp_mode_change_description": "Spara inställningar för att aktivera DHCP-läge och visa leasinginformation",
|
||||
"network_pending_dhcp_mode_change_headline": "Väntar på ändring av DHCP IPv4-läge",
|
||||
"network_save_settings": "Spara inställningar",
|
||||
"network_save_settings_apply_title": "Tillämpa nätverksinställningar",
|
||||
"network_save_settings_confirm": "Tillämpa ändringar",
|
||||
"network_save_settings_confirm_description": "Följande nätverksinställningar kommer att tillämpas. Dessa ändringar kan kräva en omstart och orsaka en kortvarig frånkoppling.",
|
||||
"network_save_settings_confirm_heading": "Konfigurationsändringar",
|
||||
"network_save_settings_failed": "Misslyckades med att spara nätverksinställningar: {error}",
|
||||
"network_save_settings_success": "Nätverksinställningar sparade",
|
||||
"network_save_settings": "Spara inställningar",
|
||||
"network_settings_invalid_ipv4_cidr": "Ogiltig CIDR-notation för IPv4-adress",
|
||||
"network_settings_load_error": "Misslyckades med att läsa in nätverksinställningar: {error}",
|
||||
"network_time_sync_description": "Konfigurera inställningar för tidssynkronisering",
|
||||
"network_time_sync_http_only": "Endast HTTP",
|
||||
"network_time_sync_ntp_and_http": "NTP och HTTP",
|
||||
|
|
@ -640,8 +670,8 @@
|
|||
"paste_modal_failed_paste": "Misslyckades med att klistra in text: {error}",
|
||||
"paste_modal_invalid_chars_intro": "Följande tecken klistras inte in:",
|
||||
"paste_modal_paste_from_host": "Klistra in från värd",
|
||||
"paste_modal_paste_text_description": "Klistra in text från din klient till fjärrdatorn",
|
||||
"paste_modal_paste_text": "Klistra in text",
|
||||
"paste_modal_paste_text_description": "Klistra in text från din klient till fjärrdatorn",
|
||||
"paste_modal_sending_using_layout": "Skicka text med tangentbordslayout: {iso} - {name}",
|
||||
"peer_connection_closed": "Stängd",
|
||||
"peer_connection_closing": "Stängning",
|
||||
|
|
@ -668,20 +698,20 @@
|
|||
"retry": "Försöka igen",
|
||||
"saving": "Sparande…",
|
||||
"search_placeholder": "Söka…",
|
||||
"serial_console": "Seriell konsol",
|
||||
"serial_console_baud_rate": "Baudhastighet",
|
||||
"serial_console_configure_description": "Konfigurera dina seriella konsolinställningar",
|
||||
"serial_console_data_bits": "Databitar",
|
||||
"serial_console_get_settings_error": "Misslyckades med att hämta inställningar för seriekonsolen: {error}",
|
||||
"serial_console_open_console": "Öppna konsolen",
|
||||
"serial_console_parity": "Paritet",
|
||||
"serial_console_parity_even": "Jämn paritet",
|
||||
"serial_console_parity_mark": "Markera paritet",
|
||||
"serial_console_parity_none": "Ingen paritet",
|
||||
"serial_console_parity_odd": "Udda paritet",
|
||||
"serial_console_parity_space": "Rymdparitet",
|
||||
"serial_console_parity": "Paritet",
|
||||
"serial_console_set_settings_error": "Misslyckades med att ställa in seriekonsolinställningarna till {settings} : {error}",
|
||||
"serial_console_stop_bits": "Stoppbitar",
|
||||
"serial_console": "Seriell konsol",
|
||||
"setting_remote_description": "Ställa in fjärrkontrollens beskrivning",
|
||||
"setting_remote_session_description": "Ställer in beskrivning av fjärrsession...",
|
||||
"setting_up_connection_to_device": "Konfigurerar anslutning till enhet...",
|
||||
|
|
@ -691,8 +721,8 @@
|
|||
"settings_back_to_kvm": "Tillbaka till KVM",
|
||||
"settings_general": "Allmän",
|
||||
"settings_hardware": "Hårdvara",
|
||||
"settings_keyboard_macros": "Tangentbordsmakron",
|
||||
"settings_keyboard": "Tangentbord",
|
||||
"settings_keyboard_macros": "Tangentbordsmakron",
|
||||
"settings_mouse": "Mus",
|
||||
"settings_network": "Nätverk",
|
||||
"settings_video": "Video",
|
||||
|
|
@ -712,6 +742,7 @@
|
|||
"updates_failed_check": "Misslyckades med att söka efter uppdateringar: {error}",
|
||||
"updates_failed_get_device_version": "Misslyckades med att hämta enhetsversionen: {error}",
|
||||
"updating_leave_device_on": "Stäng inte av din enhet…",
|
||||
"usb": "USB",
|
||||
"usb_config_custom": "Beställnings",
|
||||
"usb_config_default": "JetKVM-standard",
|
||||
"usb_config_dell": "Dell Multimedia Pro-tangentbord",
|
||||
|
|
@ -758,7 +789,6 @@
|
|||
"usb_state_connecting": "Ansluter",
|
||||
"usb_state_disconnected": "Osammanhängande",
|
||||
"usb_state_low_power_mode": "Lågströmsläge",
|
||||
"usb": "USB",
|
||||
"user_interface_language_description": "Välj språket som ska användas i JetKVM-användargränssnittet",
|
||||
"user_interface_language_title": "Gränssnittsspråk",
|
||||
"video_brightness_description": "Ljusstyrka ( {value} x)",
|
||||
|
|
@ -825,6 +855,7 @@
|
|||
"video_title": "Video",
|
||||
"view_details": "Visa detaljer",
|
||||
"virtual_keyboard_header": "Virtuellt tangentbord",
|
||||
"wake_on_lan": "Vakna på LAN",
|
||||
"wake_on_lan_add_device_device_name": "Enhetsnamn",
|
||||
"wake_on_lan_add_device_example_device_name": "Plex Media Server",
|
||||
"wake_on_lan_add_device_mac_address": "MAC-adress",
|
||||
|
|
@ -840,7 +871,6 @@
|
|||
"wake_on_lan_failed_send_magic": "Misslyckades med att skicka Magic Packet",
|
||||
"wake_on_lan_invalid_mac": "Ogiltig MAC-adress",
|
||||
"wake_on_lan_magic_sent_success": "Magiskt paket skickades",
|
||||
"wake_on_lan": "Vakna på LAN",
|
||||
"welcome_to_jetkvm_description": "Styr vilken dator som helst på distans",
|
||||
"welcome_to_jetkvm": "Välkommen till JetKVM"
|
||||
"welcome_to_jetkvm": "Välkommen till JetKVM",
|
||||
"welcome_to_jetkvm_description": "Styr vilken dator som helst på distans"
|
||||
}
|
||||
|
|
@ -107,10 +107,10 @@
|
|||
"already_adopted_title": "设备已注册",
|
||||
"appearance_description": "选择您喜欢的颜色主题",
|
||||
"appearance_page_description": "自定义 JetKVM 界面的外观和感觉",
|
||||
"appearance_theme": "主题",
|
||||
"appearance_theme_dark": "黑暗的",
|
||||
"appearance_theme_light": "光",
|
||||
"appearance_theme_system": "系统",
|
||||
"appearance_theme": "主题",
|
||||
"appearance_title": "外貌",
|
||||
"attach": "附",
|
||||
"atx_power_control_get_state_error": "无法获取 ATX 电源状态:{error}",
|
||||
|
|
@ -121,85 +121,85 @@
|
|||
"atx_power_control_reset_button": "重置",
|
||||
"atx_power_control_send_action_error": "无法发送 ATX 电源操作 {action} : {error}",
|
||||
"atx_power_control_short_power_button": "短按",
|
||||
"auth_authentication_mode": "请选择身份验证方式",
|
||||
"auth_authentication_mode_error": "设置身份验证模式时发生错误",
|
||||
"auth_authentication_mode_invalid": "身份验证模式无效",
|
||||
"auth_authentication_mode": "请选择身份验证方式",
|
||||
"auth_connect_to_cloud": "将您的 JetKVM 连接到云端",
|
||||
"auth_connect_to_cloud_action": "登录并连接设备",
|
||||
"auth_connect_to_cloud_description": "解锁设备的远程访问和高级功能",
|
||||
"auth_connect_to_cloud": "将您的 JetKVM 连接到云端",
|
||||
"auth_header_cta_already_have_account": "已有账户?",
|
||||
"auth_header_cta_dont_have_account": "沒有帳戶?",
|
||||
"auth_header_cta_new_to_jetkvm": "JetKVM 新手?",
|
||||
"auth_login": "登录您的 JetKVM 帐户",
|
||||
"auth_login_action": "登录",
|
||||
"auth_login_description": "登录以安全地访问和管理您的设备",
|
||||
"auth_login": "登录您的 JetKVM 帐户",
|
||||
"auth_mode_local": "本地身份验证方法",
|
||||
"auth_mode_local_change_later": "您可以随时在设置中更改您的身份验证方法。",
|
||||
"auth_mode_local_description": "选择您希望如何在本地保护您的 JetKVM 设备。",
|
||||
"auth_mode_local_no_password_description": "无需密码验证即可快速访问。",
|
||||
"auth_mode_local_no_password": "没有密码",
|
||||
"auth_mode_local_no_password_description": "无需密码验证即可快速访问。",
|
||||
"auth_mode_local_password": "密码",
|
||||
"auth_mode_local_password_confirm_description": "确认您的密码",
|
||||
"auth_mode_local_password_confirm_label": "确认密码",
|
||||
"auth_mode_local_password_description": "使用密码保护您的设备以增强保护。",
|
||||
"auth_mode_local_password_do_not_match": "密码不匹配",
|
||||
"auth_mode_local_password_failed_set": "无法设置密码: {error}",
|
||||
"auth_mode_local_password_note_local": "所有数据都保留在您的本地设备上。",
|
||||
"auth_mode_local_password_note": "此密码将用于保护您的设备数据并防止未经授权的访问。",
|
||||
"auth_mode_local_password_note_local": "所有数据都保留在您的本地设备上。",
|
||||
"auth_mode_local_password_set": "设置密码",
|
||||
"auth_mode_local_password_set_button": "设置密码",
|
||||
"auth_mode_local_password_set_description": "创建一个强密码来本地保护您的 JetKVM 设备。",
|
||||
"auth_mode_local_password_set_label": "输入密码",
|
||||
"auth_mode_local_password_set": "设置密码",
|
||||
"auth_mode_local_password": "密码",
|
||||
"auth_mode_local": "本地身份验证方法",
|
||||
"auth_signup_connect_to_cloud_action": "注册并连接设备",
|
||||
"auth_signup_create_account": "创建您的 JetKVM 帐户",
|
||||
"auth_signup_create_account_action": "创建账户",
|
||||
"auth_signup_create_account_description": "创建您的帐户并开始轻松管理您的设备。",
|
||||
"auth_signup_create_account": "创建您的 JetKVM 帐户",
|
||||
"back_to_devices": "返回设备",
|
||||
"back": "后退",
|
||||
"back_to_devices": "返回设备",
|
||||
"cancel": "取消",
|
||||
"close": "关闭",
|
||||
"cloud_kvms_description": "管理您的云 KVM 并安全地连接到它们。",
|
||||
"cloud_kvms_no_devices_description": "您还没有任何启用 JetKVM Cloud 的设备。",
|
||||
"cloud_kvms_no_devices": "未找到设备",
|
||||
"cloud_kvms": "云 KVM",
|
||||
"cloud_kvms_description": "管理您的云 KVM 并安全地连接到它们。",
|
||||
"cloud_kvms_no_devices": "未找到设备",
|
||||
"cloud_kvms_no_devices_description": "您还没有任何启用 JetKVM Cloud 的设备。",
|
||||
"confirm": "确认",
|
||||
"connect_to_kvm": "连接到 KVM",
|
||||
"connecting_to_device": "正在连接设备…",
|
||||
"connection_established": "已建立连接",
|
||||
"connection_stats_badge_jitter_buffer_avg_delay": "抖动缓冲区平均延迟",
|
||||
"connection_stats_badge_jitter": "抖动",
|
||||
"connection_stats_connection_description": "客户端与JetKVM之间的连接。",
|
||||
"connection_stats_badge_jitter_buffer_avg_delay": "抖动缓冲区平均延迟",
|
||||
"connection_stats_connection": "联系",
|
||||
"connection_stats_frames_per_second_description": "每秒显示的入站视频帧数。",
|
||||
"connection_stats_connection_description": "客户端与JetKVM之间的连接。",
|
||||
"connection_stats_frames_per_second": "每秒帧数",
|
||||
"connection_stats_network_stability_description": "网络上传入的视频数据包的流动有多稳定。",
|
||||
"connection_stats_frames_per_second_description": "每秒显示的入站视频帧数。",
|
||||
"connection_stats_network_stability": "网络稳定性",
|
||||
"connection_stats_packets_lost_description": "丢失的入站视频 RTP 数据包的数量。",
|
||||
"connection_stats_network_stability_description": "网络上传入的视频数据包的流动有多稳定。",
|
||||
"connection_stats_packets_lost": "数据包丢失",
|
||||
"connection_stats_playback_delay_description": "当帧不均匀到达时,抖动缓冲区添加延迟以平滑播放。",
|
||||
"connection_stats_packets_lost_description": "丢失的入站视频 RTP 数据包的数量。",
|
||||
"connection_stats_playback_delay": "播放延迟",
|
||||
"connection_stats_round_trip_time_description": "对等体之间活跃 ICE 候选对的往返时间。",
|
||||
"connection_stats_playback_delay_description": "当帧不均匀到达时,抖动缓冲区添加延迟以平滑播放。",
|
||||
"connection_stats_round_trip_time": "往返时间",
|
||||
"connection_stats_round_trip_time_description": "对等体之间活跃 ICE 候选对的往返时间。",
|
||||
"connection_stats_sidebar": "连接统计",
|
||||
"connection_stats_video_description": "从 JetKVM 到客户端的视频流。",
|
||||
"connection_stats_video": "视频",
|
||||
"connection_stats_video_description": "从 JetKVM 到客户端的视频流。",
|
||||
"continue": "继续",
|
||||
"creating_peer_connection": "正在创建对等连接...",
|
||||
"dc_power_control_current_unit": "A",
|
||||
"dc_power_control_current": "电流",
|
||||
"dc_power_control_current_unit": "A",
|
||||
"dc_power_control_get_state_error": "无法获取直流电源状态:{error}",
|
||||
"dc_power_control_power": "瓦特",
|
||||
"dc_power_control_power_off_button": "关闭电源",
|
||||
"dc_power_control_power_off_state": "关闭电源",
|
||||
"dc_power_control_power_on_button": "开机",
|
||||
"dc_power_control_power_on_state": "开启电源",
|
||||
"dc_power_control_power_unit": "W",
|
||||
"dc_power_control_power": "瓦特",
|
||||
"dc_power_control_restore_last_state": "最后状态",
|
||||
"dc_power_control_restore_power_state": "恢复断电",
|
||||
"dc_power_control_set_power_state_error": "无法将直流电源状态发送到 {enabled} : {error}",
|
||||
"dc_power_control_set_restore_state_error": "无法将直流电源恢复状态发送到 {state} : {error}",
|
||||
"dc_power_control_voltage_unit": "V",
|
||||
"dc_power_control_voltage": "电压",
|
||||
"dc_power_control_voltage_unit": "V",
|
||||
"delete": "删除",
|
||||
"deregister_button": "从云端注销",
|
||||
"deregister_cloud_devices": "云设备",
|
||||
|
|
@ -226,12 +226,12 @@
|
|||
"extension_popover_load_and_manage_extensions": "加载和管理您的扩展",
|
||||
"extension_popover_set_error_notification": "无法设置活动扩展:{error}",
|
||||
"extension_popover_unload_extension": "卸载扩展",
|
||||
"extension_serial_console_description": "访问串行控制台扩展",
|
||||
"extension_serial_console": "串行控制台",
|
||||
"extensions_atx_power_control_description": "通过 ATX 电源控制来控制机器的电源状态。",
|
||||
"extension_serial_console_description": "访问串行控制台扩展",
|
||||
"extensions_atx_power_control": "ATX 电源控制",
|
||||
"extensions_dc_power_control_description": "控制您的直流电源扩展",
|
||||
"extensions_atx_power_control_description": "通过 ATX 电源控制来控制机器的电源状态。",
|
||||
"extensions_dc_power_control": "直流电源控制",
|
||||
"extensions_dc_power_control_description": "控制您的直流电源扩展",
|
||||
"extensions_popover_extensions": "扩展",
|
||||
"gathering_ice_candidates": "召集 ICE 候选人……",
|
||||
"general_app_version": "应用程序: {version}",
|
||||
|
|
@ -241,8 +241,8 @@
|
|||
"general_check_for_updates": "检查更新",
|
||||
"general_page_description": "配置设备设置并更新首选项",
|
||||
"general_reboot_description": "您想继续重新启动系统吗?",
|
||||
"general_reboot_device_description": "对 JetKVM 进行电源循环",
|
||||
"general_reboot_device": "重启设备",
|
||||
"general_reboot_device_description": "对 JetKVM 进行电源循环",
|
||||
"general_reboot_no_button": "不",
|
||||
"general_reboot_title": "重启 JetKVM",
|
||||
"general_reboot_yes_button": "是的",
|
||||
|
|
@ -295,9 +295,9 @@
|
|||
"hardware_display_orientation_title": "显示方向",
|
||||
"hardware_display_wake_up_note": "当连接状态改变或被触摸时,显示屏将会唤醒。",
|
||||
"hardware_page_description": "为您的 JetKVM 设备配置显示设置和硬件选项",
|
||||
"hardware_time_10_minutes": "10分钟",
|
||||
"hardware_time_1_hour": "1小时",
|
||||
"hardware_time_1_minute": "1分钟",
|
||||
"hardware_time_10_minutes": "10分钟",
|
||||
"hardware_time_30_minutes": "30分钟",
|
||||
"hardware_time_5_minutes": "5分钟",
|
||||
"hardware_time_never": "绝不",
|
||||
|
|
@ -327,6 +327,7 @@
|
|||
"invalid_password": "密码无效",
|
||||
"ip_address": "IP 地址",
|
||||
"ipv6_address_label": "地址",
|
||||
"ipv6_gateway": "网关",
|
||||
"ipv6_information": "IPv6 信息",
|
||||
"ipv6_link_local": "本地链路",
|
||||
"ipv6_preferred_lifetime": "首选寿命",
|
||||
|
|
@ -409,8 +410,8 @@
|
|||
"log_in": "登录",
|
||||
"log_out": "登出",
|
||||
"logged_in_as": "登录身份",
|
||||
"login_enter_password_description": "输入您的密码以访问您的 JetKVM。",
|
||||
"login_enter_password": "输入您的密码",
|
||||
"login_enter_password_description": "输入您的密码以访问您的 JetKVM。",
|
||||
"login_error": "登录时发生错误",
|
||||
"login_forgot_password": "忘记密码?",
|
||||
"login_password_label": "密码",
|
||||
|
|
@ -424,8 +425,8 @@
|
|||
"macro_name_required": "姓名为必填项",
|
||||
"macro_name_too_long": "名称必须少于 50 个字符",
|
||||
"macro_please_fix_validation_errors": "请修复验证错误",
|
||||
"macro_save_error": "保存时发生错误。",
|
||||
"macro_save": "保存宏",
|
||||
"macro_save_error": "保存时发生错误。",
|
||||
"macro_step_count": "{steps} / {max}步骤",
|
||||
"macro_step_duration_description": "执行下一步之前需要等待的时间。",
|
||||
"macro_step_duration_label": "步骤持续时间",
|
||||
|
|
@ -439,8 +440,8 @@
|
|||
"macro_steps_description": "键/修饰键按顺序执行,每个步骤之间有延迟。",
|
||||
"macro_steps_label": "步骤",
|
||||
"macros_add_description": "创建新的键盘宏",
|
||||
"macros_add_new_macro": "添加新宏",
|
||||
"macros_add_new": "添加新宏",
|
||||
"macros_add_new_macro": "添加新宏",
|
||||
"macros_aria_add_new": "添加新宏",
|
||||
"macros_aria_delete": "删除宏{name}",
|
||||
"macros_aria_duplicate": "重复宏{name}",
|
||||
|
|
@ -462,16 +463,16 @@
|
|||
"macros_edit_button": "编辑",
|
||||
"macros_edit_description": "修改键盘宏",
|
||||
"macros_edit_title": "编辑宏",
|
||||
"macros_failed_create_error": "无法创建宏: {error}",
|
||||
"macros_failed_create": "无法创建宏",
|
||||
"macros_failed_delete_error": "无法删除宏: {error}",
|
||||
"macros_failed_create_error": "无法创建宏: {error}",
|
||||
"macros_failed_delete": "删除宏失败",
|
||||
"macros_failed_duplicate_error": "无法复制宏: {error}",
|
||||
"macros_failed_delete_error": "无法删除宏: {error}",
|
||||
"macros_failed_duplicate": "复制宏失败",
|
||||
"macros_failed_reorder_error": "无法重新排序宏: {error}",
|
||||
"macros_failed_duplicate_error": "无法复制宏: {error}",
|
||||
"macros_failed_reorder": "无法重新排序宏",
|
||||
"macros_failed_update_error": "无法更新宏: {error}",
|
||||
"macros_failed_reorder_error": "无法重新排序宏: {error}",
|
||||
"macros_failed_update": "更新宏失败",
|
||||
"macros_failed_update_error": "无法更新宏: {error}",
|
||||
"macros_invalid_data": "无效的宏数据",
|
||||
"macros_loading": "正在加载宏...",
|
||||
"macros_max_reached": "已达到最大值",
|
||||
|
|
@ -506,8 +507,8 @@
|
|||
"mount_error_list_storage": "列出存储文件时出错: {error}",
|
||||
"mount_error_title": "安装错误",
|
||||
"mount_get_state_error": "无法获取虚拟媒体状态: {error}",
|
||||
"mount_jetkvm_storage_description": "从 JetKVM 存储挂载之前上传的文件",
|
||||
"mount_jetkvm_storage": "JetKVM 存储支架",
|
||||
"mount_jetkvm_storage_description": "从 JetKVM 存储挂载之前上传的文件",
|
||||
"mount_mode_cdrom": "CD/DVD",
|
||||
"mount_mode_disk": "磁盘",
|
||||
"mount_mounted_as": "安装为",
|
||||
|
|
@ -520,8 +521,8 @@
|
|||
"mount_popular_images": "热门图片",
|
||||
"mount_streaming_from_url": "从 URL 流式传输",
|
||||
"mount_supported_formats": "支持的格式:ISO、IMG",
|
||||
"mount_unmount_error": "无法卸载映像: {error}",
|
||||
"mount_unmount": "卸载",
|
||||
"mount_unmount_error": "无法卸载映像: {error}",
|
||||
"mount_upload_description": "选择要上传到 JetKVM 存储的图像文件",
|
||||
"mount_upload_error": "上传错误: {error}",
|
||||
"mount_upload_failed_datachannel": "无法创建文件上传数据通道",
|
||||
|
|
@ -529,8 +530,8 @@
|
|||
"mount_upload_successful": "上传成功",
|
||||
"mount_upload_title": "上传新图片",
|
||||
"mount_uploaded_has_been_uploaded": "{name}已上传",
|
||||
"mount_uploading_with_name": "正在上传{name}",
|
||||
"mount_uploading": "正在上传…",
|
||||
"mount_uploading_with_name": "正在上传{name}",
|
||||
"mount_url_description": "从任何公共网址挂载文件",
|
||||
"mount_url_input_label": "图片网址",
|
||||
"mount_url_mount": "URL 挂载",
|
||||
|
|
@ -538,10 +539,10 @@
|
|||
"mount_view_device_title": "从 JetKVM 存储挂载",
|
||||
"mount_view_url_description": "输入要挂载的镜像文件的 URL",
|
||||
"mount_view_url_title": "从 URL 挂载",
|
||||
"mount_virtual_media_description": "挂载映像以进行启动或安装操作系统。",
|
||||
"mount_virtual_media_source_description": "选择如何安装虚拟媒体",
|
||||
"mount_virtual_media_source": "虚拟媒体源",
|
||||
"mount_virtual_media": "虚拟媒体",
|
||||
"mount_virtual_media_description": "挂载映像以进行启动或安装操作系统。",
|
||||
"mount_virtual_media_source": "虚拟媒体源",
|
||||
"mount_virtual_media_source_description": "选择如何安装虚拟媒体",
|
||||
"mouse_alt_finger": "手指触摸屏幕",
|
||||
"mouse_alt_mouse": "鼠标图标",
|
||||
"mouse_description": "为您的设备配置光标行为和交互设置",
|
||||
|
|
@ -558,10 +559,10 @@
|
|||
"mouse_jiggler_light": "光 - 5米",
|
||||
"mouse_jiggler_standard": "标准 - 1米",
|
||||
"mouse_jiggler_title": "吉格勒",
|
||||
"mouse_mode_absolute_description": "最方便",
|
||||
"mouse_mode_absolute": "绝对",
|
||||
"mouse_mode_relative_description": "最兼容",
|
||||
"mouse_mode_absolute_description": "最方便",
|
||||
"mouse_mode_relative": "相对的",
|
||||
"mouse_mode_relative_description": "最兼容",
|
||||
"mouse_modes_description": "选择鼠标输入模式",
|
||||
"mouse_modes_title": "模式",
|
||||
"mouse_scroll_high": "高的",
|
||||
|
|
@ -574,12 +575,17 @@
|
|||
"mouse_title": "老鼠",
|
||||
"network_custom_domain": "自定义域",
|
||||
"network_description": "配置您的网络设置",
|
||||
"network_dhcp_client_description": "配置要使用的 DHCP 客户端",
|
||||
"network_dhcp_client_jetkvm": "JetKVM 内部",
|
||||
"network_dhcp_client_title": "DHCP客户端",
|
||||
"network_dhcp_information": "DHCP 信息",
|
||||
"network_dhcp_lease_renew_confirm_description": "这将从您的 DHCP 服务器请求新的 IP 地址。在此过程中,您的设备可能会暂时失去网络连接。",
|
||||
"network_dhcp_lease_renew": "续订 DHCP 租约",
|
||||
"network_dhcp_lease_renew_confirm": "续租",
|
||||
"network_dhcp_lease_renew_confirm_description": "这将从您的 DHCP 服务器请求新的 IP 地址。在此过程中,您的设备可能会暂时失去网络连接。",
|
||||
"network_dhcp_lease_renew_confirm_new_a": "如果您收到新的 IP 地址",
|
||||
"network_dhcp_lease_renew_confirm_new_b": "您可能需要使用新地址重新连接",
|
||||
"network_dhcp_lease_renew_failed": "无法续订租约: {error}",
|
||||
"network_dhcp_lease_renew_success": "DHCP 租约已续订",
|
||||
"network_dhcp_lease_renew": "续订 DHCP 租约",
|
||||
"network_domain_custom": "风俗",
|
||||
"network_domain_description": "设备的网络域后缀",
|
||||
"network_domain_dhcp_provided": "DHCP 提供",
|
||||
|
|
@ -588,21 +594,35 @@
|
|||
"network_hostname_description": "网络上的设备标识符。系统默认为空白",
|
||||
"network_hostname_title": "主机名",
|
||||
"network_http_proxy_description": "设备发出 HTTP(S) 请求的代理服务器。空白表示无。",
|
||||
"network_http_proxy_invalid": "HTTP 代理 URL 无效",
|
||||
"network_http_proxy_title": "HTTP 代理",
|
||||
"network_ipv4_address": "IPv4 地址",
|
||||
"network_ipv4_dns": "IPv4 域名服务器",
|
||||
"network_ipv4_gateway": "IPv4 网关",
|
||||
"network_ipv4_mode_description": "配置 IPv4 模式",
|
||||
"network_ipv4_mode_dhcp": "DHCP",
|
||||
"network_ipv4_mode_static": "静止的",
|
||||
"network_ipv4_mode_title": "IPv4 模式",
|
||||
"network_ipv4_netmask": "IPv4 网络掩码",
|
||||
"network_ipv6_address": "IPv6 地址",
|
||||
"network_ipv6_information": "IPv6 信息",
|
||||
"network_ipv6_mode_description": "配置 IPv6 模式",
|
||||
"network_ipv6_mode_dhcpv6": "DHCPv6",
|
||||
"network_ipv6_mode_disabled": "已禁用",
|
||||
"network_ipv6_mode_link_local": "仅限本地链路",
|
||||
"network_ipv6_mode_slaac": "斯坦福直线加速器",
|
||||
"network_ipv6_mode_slaac_dhcpv6": "SLAAC + DHCPv6",
|
||||
"network_ipv6_mode_static": "静止的",
|
||||
"network_ipv6_mode_title": "IPv6模式",
|
||||
"network_ipv6_netmask": "IPv6 网络掩码",
|
||||
"network_ipv6_no_addresses": "未配置 IPv6 地址",
|
||||
"network_ll_dp_all": "全部",
|
||||
"network_ll_dp_basic": "基本的",
|
||||
"network_ll_dp_description": "控制哪些 TLV 将通过链路层发现协议发送",
|
||||
"network_ll_dp_disabled": "已禁用",
|
||||
"network_ll_dp_title": "链路层发现协议",
|
||||
"network_mac_address_copy_error": "复制 MAC 地址失败",
|
||||
"network_mac_address_copy_success": "MAC 地址{ mac }已复制到剪贴板",
|
||||
"network_mac_address_description": "网络接口的硬件标识符",
|
||||
"network_mac_address_title": "MAC 地址",
|
||||
"network_mdns_auto": "汽车",
|
||||
|
|
@ -612,9 +632,19 @@
|
|||
"network_mdns_ipv6_only": "仅限 IPv6",
|
||||
"network_mdns_title": "移动DNS",
|
||||
"network_no_dhcp_lease": "没有可用的 DHCP 租约信息",
|
||||
"network_no_information_description": "没有可用的网络配置",
|
||||
"network_no_information_headline": "网络信息",
|
||||
"network_pending_dhcp_mode_change_description": "保存设置以启用 DHCP 模式并查看租约信息",
|
||||
"network_pending_dhcp_mode_change_headline": "待处理的 DHCP IPv4 模式更改",
|
||||
"network_save_settings": "保存设置",
|
||||
"network_save_settings_apply_title": "应用网络设置",
|
||||
"network_save_settings_confirm": "应用更改",
|
||||
"network_save_settings_confirm_description": "将应用以下网络设置。这些更改可能需要重新启动并导致短暂断网。",
|
||||
"network_save_settings_confirm_heading": "配置更改",
|
||||
"network_save_settings_failed": "无法保存网络设置: {error}",
|
||||
"network_save_settings_success": "网络设置已保存",
|
||||
"network_save_settings": "保存设置",
|
||||
"network_settings_invalid_ipv4_cidr": "IPv4 地址的 CIDR 表示法无效",
|
||||
"network_settings_load_error": "无法加载网络设置: {error}",
|
||||
"network_time_sync_description": "配置时间同步设置",
|
||||
"network_time_sync_http_only": "仅 HTTP",
|
||||
"network_time_sync_ntp_and_http": "NTP 和 HTTP",
|
||||
|
|
@ -640,8 +670,8 @@
|
|||
"paste_modal_failed_paste": "粘贴文本失败: {error}",
|
||||
"paste_modal_invalid_chars_intro": "以下字符将不会被粘贴:",
|
||||
"paste_modal_paste_from_host": "从主机粘贴",
|
||||
"paste_modal_paste_text_description": "将文本从客户端粘贴到远程主机",
|
||||
"paste_modal_paste_text": "粘贴文本",
|
||||
"paste_modal_paste_text_description": "将文本从客户端粘贴到远程主机",
|
||||
"paste_modal_sending_using_layout": "使用键盘布局发送文本: {iso} - {name}",
|
||||
"peer_connection_closed": "关闭",
|
||||
"peer_connection_closing": "结束语",
|
||||
|
|
@ -668,20 +698,20 @@
|
|||
"retry": "重试",
|
||||
"saving": "保存…",
|
||||
"search_placeholder": "搜索…",
|
||||
"serial_console": "串行控制台",
|
||||
"serial_console_baud_rate": "波特率",
|
||||
"serial_console_configure_description": "配置串行控制台设置",
|
||||
"serial_console_data_bits": "数据位",
|
||||
"serial_console_get_settings_error": "无法获取串行控制台设置: {error}",
|
||||
"serial_console_open_console": "打开控制台",
|
||||
"serial_console_parity": "奇偶校验位",
|
||||
"serial_console_parity_even": "偶校验",
|
||||
"serial_console_parity_mark": "Mark",
|
||||
"serial_console_parity_none": "无",
|
||||
"serial_console_parity_odd": "奇校验",
|
||||
"serial_console_parity_space": "Space",
|
||||
"serial_console_parity": "奇偶校验位",
|
||||
"serial_console_set_settings_error": "无法将串行控制台设置设置为 {settings} : {error}",
|
||||
"serial_console_stop_bits": "停止位",
|
||||
"serial_console": "串行控制台",
|
||||
"setting_remote_description": "设置远程描述",
|
||||
"setting_remote_session_description": "正在设置远程会话描述...",
|
||||
"setting_up_connection_to_device": "正在设置与设备的连接...",
|
||||
|
|
@ -691,8 +721,8 @@
|
|||
"settings_back_to_kvm": "返回 KVM",
|
||||
"settings_general": "一般的",
|
||||
"settings_hardware": "硬件",
|
||||
"settings_keyboard_macros": "键盘宏",
|
||||
"settings_keyboard": "键盘",
|
||||
"settings_keyboard_macros": "键盘宏",
|
||||
"settings_mouse": "老鼠",
|
||||
"settings_network": "网络",
|
||||
"settings_video": "视频",
|
||||
|
|
@ -712,6 +742,7 @@
|
|||
"updates_failed_check": "无法检查更新: {error}",
|
||||
"updates_failed_get_device_version": "无法获取设备版本: {error}",
|
||||
"updating_leave_device_on": "请不要关闭您的设备……",
|
||||
"usb": "USB",
|
||||
"usb_config_custom": "风俗",
|
||||
"usb_config_default": "JetKVM 默认",
|
||||
"usb_config_dell": "戴尔多媒体专业键盘",
|
||||
|
|
@ -758,7 +789,6 @@
|
|||
"usb_state_connecting": "正在连接",
|
||||
"usb_state_disconnected": "断开连接",
|
||||
"usb_state_low_power_mode": "低功耗模式",
|
||||
"usb": "USB",
|
||||
"user_interface_language_description": "选择 JetKVM 用户界面使用的语言",
|
||||
"user_interface_language_title": "界面语言",
|
||||
"video_brightness_description": "亮度级别( {value} x)",
|
||||
|
|
@ -825,6 +855,7 @@
|
|||
"video_title": "视频",
|
||||
"view_details": "查看详情",
|
||||
"virtual_keyboard_header": "虚拟键盘",
|
||||
"wake_on_lan": "局域网唤醒",
|
||||
"wake_on_lan_add_device_device_name": "设备名称",
|
||||
"wake_on_lan_add_device_example_device_name": "Plex媒体服务器",
|
||||
"wake_on_lan_add_device_mac_address": "MAC 地址",
|
||||
|
|
@ -840,7 +871,6 @@
|
|||
"wake_on_lan_failed_send_magic": "发送魔术包失败",
|
||||
"wake_on_lan_invalid_mac": "无效的 MAC 地址",
|
||||
"wake_on_lan_magic_sent_success": "魔包发送成功",
|
||||
"wake_on_lan": "局域网唤醒",
|
||||
"welcome_to_jetkvm_description": "远程控制任何计算机",
|
||||
"welcome_to_jetkvm": "欢迎来到 JetKVM"
|
||||
"welcome_to_jetkvm": "欢迎来到 JetKVM",
|
||||
"welcome_to_jetkvm_description": "远程控制任何计算机"
|
||||
}
|
||||
|
|
@ -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,38 +61,40 @@ 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 (
|
||||
<div onKeyDown={handleKeyDown}>
|
||||
<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">
|
||||
<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="mt-2 text-sm leading-snug text-slate-600 dark:text-slate-400">
|
||||
<div className="text-sm text-slate-700 dark:text-slate-300">
|
||||
{description}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-x-2">
|
||||
<div className="mt-6 flex justify-end gap-2">
|
||||
{cancelText && (
|
||||
<Button size="SM" theme="blank" text={cancelText} onClick={onClose} />
|
||||
<CloseButton as={Button} size="SM" theme="blank" text={cancelText} />
|
||||
)}
|
||||
<Button
|
||||
size="SM"
|
||||
type="button"
|
||||
theme={buttonTheme}
|
||||
text={isConfirming ? `${confirmText}...` : confirmText}
|
||||
onClick={onConfirm}
|
||||
|
|
@ -111,5 +105,6 @@ export function ConfirmDialog({
|
|||
</div>
|
||||
</div>
|
||||
</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">
|
||||
<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,17 +233,14 @@ 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>
|
||||
<Button
|
||||
size="SM"
|
||||
theme="light"
|
||||
className="text-red-500"
|
||||
text={m.dhcp_lease_renew()}
|
||||
LeadingIcon={LuRefreshCcw}
|
||||
onClick={() => setShowRenewLeaseConfirm(true)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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,7 +30,6 @@ 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()}
|
||||
|
|
@ -26,15 +38,22 @@ export default function Ipv6NetworkCard({
|
|||
{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 => (
|
||||
{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"
|
||||
|
|
@ -44,7 +63,13 @@ export default function Ipv6NetworkCard({
|
|||
<span className="text-sm text-slate-600 dark:text-slate-400">
|
||||
{m.ipv6_address_label()}
|
||||
</span>
|
||||
<span className="text-sm font-medium">{addr.address}</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>
|
||||
</div>
|
||||
|
||||
{addr.valid_lifetime && (
|
||||
|
|
@ -55,7 +80,7 @@ export default function Ipv6NetworkCard({
|
|||
<span className="text-sm font-medium">
|
||||
{addr.valid_lifetime === "" ? (
|
||||
<span className="text-slate-400 dark:text-slate-600">
|
||||
{m.not_available()}
|
||||
{m.not_applicable()}
|
||||
</span>
|
||||
) : (
|
||||
<LifeTimeLabel lifetime={`${addr.valid_lifetime}`} />
|
||||
|
|
@ -63,6 +88,7 @@ export default function Ipv6NetworkCard({
|
|||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{addr.preferred_lifetime && (
|
||||
<div className="flex flex-col justify-between">
|
||||
<span className="text-sm text-slate-600 dark:text-slate-400">
|
||||
|
|
@ -71,7 +97,7 @@ export default function Ipv6NetworkCard({
|
|||
<span className="text-sm font-medium">
|
||||
{addr.preferred_lifetime === "" ? (
|
||||
<span className="text-slate-400 dark:text-slate-600">
|
||||
{m.not_available()}
|
||||
{m.not_applicable()}
|
||||
</span>
|
||||
) : (
|
||||
<LifeTimeLabel lifetime={`${addr.preferred_lifetime}`} />
|
||||
|
|
@ -81,8 +107,7 @@ export default function Ipv6NetworkCard({
|
|||
)}
|
||||
</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="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