Compare commits

..

1 Commits

Author SHA1 Message Date
Marc Brooks 83d544f0f7
Add inlang/paraglide-js localization
Remove the temporary directory after extracting buildkit
Localize the extension popovers.
Update package and fix tsconfig.json
Expand development directory guide
Move messages under localization
Popovers and sidebar
Update Chinese translations
Accidentally lost the changes that @ym provided, brought them back
File formatting pass
Localized all components, hooks, providers, hooks
Localize all pages except Settings
Bump packages
Settings Access page
Settings local auth page
Fix ref lint warning
Settings Advanced page
Fix UI lint warnings there were a bunch of ref and useEffect violations.
Settings appearance page
Settings general pages
Settings hardware page
Settings keyboard page
Settings macros pages
Settings mouse page
Settings page
Settings video page
Settings network page
Fix compilation issues
Ran machine translate
Use getLocale for date, relative time, and money formatting
Fix eslint
Delete unused messages
Added setting to choose locale
Merged in dev hotfix
Fix update status rendering
2025-10-15 10:09:56 -05:00
108 changed files with 8728 additions and 16036 deletions

View File

@ -4,11 +4,12 @@
"features": {
"ghcr.io/devcontainers/features/node:1": {
// Should match what is defined in ui/package.json
"version": "22.20.0"
"version": "22.19.0"
}
},
"mounts": [
"source=${localEnv:HOME}/.ssh,target=/home/vscode/.ssh,type=bind,consistency=cached"
"source=${localEnv:HOME}/.ssh,target=/home/vscode/.ssh,type=bind,consistency=cached",
"source=/var/run/docker.sock,target=/var/run/docker.sock,type=bind"
],
"onCreateCommand": ".devcontainer/install-deps.sh",
"customizations": {

View File

@ -4,7 +4,7 @@
"features": {
"ghcr.io/devcontainers/features/node:1": {
// Should match what is defined in ui/package.json
"version": "22.20.0"
"version": "22.19.0"
}
},
"runArgs": [

View File

@ -3,12 +3,5 @@
"cva",
"cx"
],
"gopls": {
"build.buildFlags": [
"-tags",
"synctrace"
]
},
"git.ignoreLimitWarning": true,
"cmake.sourceDirectory": "/workspaces/kvm-static-ip/internal/native/cgo"
"git.ignoreLimitWarning": true
}

View File

@ -12,13 +12,7 @@ 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 \

View File

@ -494,7 +494,7 @@ func RunWebsocketClient() {
}
// If the network is not up, well, we can't connect to the cloud.
if !networkManager.IsOnline() {
if !networkState.IsOnline() {
cloudLogger.Warn().Msg("waiting for network to be online, will retry in 3 seconds")
time.Sleep(3 * time.Second)
continue

View File

@ -17,8 +17,8 @@ import (
const (
envChildID = "JETKVM_CHILD_ID"
errorDumpDir = "/userdata/jetkvm/crashdump"
errorDumpLastFile = "last-crash.log"
errorDumpDir = "/userdata/jetkvm/"
errorDumpStateFile = ".has_error_dump"
errorDumpTemplate = "jetkvm-%s.log"
)
@ -117,47 +117,30 @@ func supervise() error {
return nil
}
func isSymlinkTo(oldName, newName string) bool {
file, err := os.Stat(newName)
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())
if err != nil {
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)
return
}
defer fnSrc.Close()
fnDst, err := os.Create(newName)
fnDst, err := os.Create(filePath)
if err != nil {
return fmt.Errorf("failed to create file: %w", err)
return
}
defer fnDst.Close()
@ -165,60 +148,18 @@ func renameFile(f *os.File, newName string) error {
for {
n, err := fnSrc.Read(buf)
if err != nil && err != io.EOF {
return fmt.Errorf("failed to read file: %w", err)
return
}
if n == 0 {
break
}
if _, err := fnDst.Write(buf[:n]); err != nil {
return fmt.Errorf("failed to write file: %w", err)
}
}
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
}
fmt.Printf("error dump created: %s\n", filePath)
}
func doSupervise() {

View File

@ -7,9 +7,8 @@ import (
"strconv"
"sync"
"github.com/jetkvm/kvm/internal/confparser"
"github.com/jetkvm/kvm/internal/logging"
"github.com/jetkvm/kvm/internal/network/types"
"github.com/jetkvm/kvm/internal/network"
"github.com/jetkvm/kvm/internal/usbgadget"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
@ -103,7 +102,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 *types.NetworkConfig `json:"network_config"`
NetworkConfig *network.NetworkConfig `json:"network_config"`
DefaultLogLevel string `json:"default_log_level"`
VideoSleepAfterSec int `json:"video_sleep_after_sec"`
}
@ -129,32 +128,7 @@ func (c *Config) SetDisplayRotation(rotation string) error {
const configPath = "/userdata/kvm_config.json"
// 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{
var defaultConfig = &Config{
CloudURL: "https://api.jetkvm.com",
CloudAppURL: "https://app.jetkvm.com",
AutoUpdateEnabled: true, // Set a default value
@ -167,17 +141,28 @@ func getDefaultConfig() Config {
DisplayOffAfterSec: 1800, // 30 minutes
JigglerEnabled: false,
// This is the "Standard" jiggler option in the UI
JigglerConfig: func() *JigglerConfig { c := defaultJigglerConfig; return &c }(),
JigglerConfig: &JigglerConfig{
InactivityLimitSeconds: 60,
JitterPercentage: 25,
ScheduleCronTab: "0 * * * * *",
Timezone: "UTC",
},
TLSMode: "",
UsbConfig: func() *usbgadget.Config { c := defaultUsbConfig; return &c }(),
UsbDevices: func() *usbgadget.Devices { c := defaultUsbDevices; return &c }(),
NetworkConfig: func() *types.NetworkConfig {
c := &types.NetworkConfig{}
_ = confparser.SetDefaultsAndValidate(c)
return c
}(),
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{},
DefaultLogLevel: "INFO",
}
}
var (
@ -210,8 +195,7 @@ func LoadConfig() {
}
// load the default config
defaultConfig := getDefaultConfig()
config = &defaultConfig
config = defaultConfig
file, err := os.Open(configPath)
if err != nil {
@ -223,7 +207,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)
@ -232,19 +216,19 @@ func LoadConfig() {
// merge the user config with the default config
if loadedConfig.UsbConfig == nil {
loadedConfig.UsbConfig = getDefaultConfig().UsbConfig
loadedConfig.UsbConfig = defaultConfig.UsbConfig
}
if loadedConfig.UsbDevices == nil {
loadedConfig.UsbDevices = getDefaultConfig().UsbDevices
loadedConfig.UsbDevices = defaultConfig.UsbDevices
}
if loadedConfig.NetworkConfig == nil {
loadedConfig.NetworkConfig = getDefaultConfig().NetworkConfig
loadedConfig.NetworkConfig = defaultConfig.NetworkConfig
}
if loadedConfig.JigglerConfig == nil {
loadedConfig.JigglerConfig = getDefaultConfig().JigglerConfig
loadedConfig.JigglerConfig = defaultConfig.JigglerConfig
}
// fixup old keyboard layout value
@ -263,25 +247,17 @@ 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", path).Msg("Saving config")
logger.Trace().Str("path", configPath).Msg("Saving config")
// fixup old keyboard layout value
if config.KeyboardLayout == "en_US" {
config.KeyboardLayout = "en-US"
}
file, err := os.Create(path)
file, err := os.Create(configPath)
if err != nil {
return fmt.Errorf("failed to create config file: %w", err)
}
@ -297,7 +273,7 @@ func saveConfig(path string) error {
return fmt.Errorf("failed to wite config: %w", err)
}
logger.Info().Str("path", path).Msg("config saved")
logger.Info().Str("path", configPath).Msg("config saved")
return nil
}

View File

@ -27,12 +27,7 @@ const (
)
func switchToMainScreen() {
if networkManager == nil {
nativeInstance.SwitchToScreenIfDifferent("no_network_screen")
return
}
if networkManager.IsUp() {
if networkState.IsUp() {
nativeInstance.SwitchToScreenIfDifferent("home_screen")
} else {
nativeInstance.SwitchToScreenIfDifferent("no_network_screen")
@ -40,21 +35,13 @@ func switchToMainScreen() {
}
func updateDisplay() {
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.UpdateLabelIfChanged("home_info_ipv4_addr", networkState.IPv4String())
nativeInstance.UpdateLabelAndChangeVisibility("home_info_ipv6_addr", networkState.IPv6String())
_, _ = nativeInstance.UIObjHide("menu_btn_network")
_, _ = nativeInstance.UIObjHide("menu_btn_access")
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")
}
nativeInstance.UpdateLabelIfChanged("home_info_mac_addr", networkState.MACString())
if usbState == "configured" {
nativeInstance.UpdateLabelIfChanged("usb_status_label", "Connected")
@ -72,7 +59,7 @@ func updateDisplay() {
}
nativeInstance.UpdateLabelIfChanged("cloud_status_label", fmt.Sprintf("%d active", actionSessions))
if networkManager != nil && networkManager.IsUp() {
if networkState.IsUp() {
nativeInstance.UISetVar("main_screen", "home_screen")
nativeInstance.SwitchToScreenIf("home_screen", []string{"no_network_screen", "boot_screen"})
} else {
@ -188,7 +175,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()
}()
}
@ -197,14 +184,13 @@ func waitCtrlAndRequestDisplayUpdate(shouldWakeDisplay bool, reason string) {
waitDisplayUpdate.Lock()
defer waitDisplayUpdate.Unlock()
// nativeInstance.WaitCtrlClientConnected()
requestDisplayUpdate(shouldWakeDisplay, reason)
}
func updateStaticContents() {
//contents that never change
if networkManager != nil {
nativeInstance.UpdateLabelIfChanged("home_info_mac_addr", networkManager.MACString())
}
nativeInstance.UpdateLabelIfChanged("home_info_mac_addr", networkState.MACString())
// get cpu info
if cpuInfo, err := os.ReadFile("/proc/cpuinfo"); err == nil {
@ -340,9 +326,12 @@ func startBacklightTickers() {
dimTicker = time.NewTicker(time.Duration(config.DisplayDimAfterSec) * time.Second)
go func() {
for range dimTicker.C {
for { //nolint:staticcheck
select {
case <-dimTicker.C:
tick_displayDim()
}
}
}()
}
@ -351,9 +340,12 @@ func startBacklightTickers() {
offTicker = time.NewTicker(time.Duration(config.DisplayOffAfterSec) * time.Second)
go func() {
for range offTicker.C {
for { //nolint:staticcheck
select {
case <-offTicker.C:
tick_displayOff()
}
}
}()
}
}

8
go.mod
View File

@ -16,7 +16,6 @@ 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
@ -55,20 +54,15 @@ 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
@ -88,14 +82,12 @@ 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
View File

@ -66,15 +66,8 @@ 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=
@ -99,12 +92,6 @@ 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=
@ -114,8 +101,6 @@ 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=
@ -176,8 +161,6 @@ 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=
@ -186,8 +169,6 @@ 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=
@ -212,9 +193,6 @@ 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=

View File

@ -16,22 +16,22 @@ import (
type FieldConfig struct {
Name string
Required bool
RequiredIf map[string]interface{}
RequiredIf map[string]any
OneOf []string
ValidateTypes []string
Defaults interface{}
Defaults any
IsEmpty bool
CurrentValue interface{}
CurrentValue any
TypeString string
Delegated bool
shouldUpdateValue bool
}
func SetDefaultsAndValidate(config interface{}) error {
func SetDefaultsAndValidate(config any) error {
return setDefaultsAndValidate(config, true)
}
func setDefaultsAndValidate(config interface{}, isRoot bool) error {
func setDefaultsAndValidate(config any, 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 interface{}, isRoot bool) error {
Name: field.Name,
OneOf: splitString(field.Tag.Get("one_of")),
ValidateTypes: splitString(field.Tag.Get("validate_type")),
RequiredIf: make(map[string]interface{}),
RequiredIf: make(map[string]any),
CurrentValue: fieldValue.Interface(),
IsEmpty: false,
TypeString: fieldType,
@ -142,8 +142,8 @@ func setDefaultsAndValidate(config interface{}, isRoot bool) error {
// now check if the field has required_if
requiredIf := field.Tag.Get("required_if")
if requiredIf != "" {
requiredIfParts := strings.Split(requiredIf, ",")
for _, part := range requiredIfParts {
requiredIfParts := strings.SplitSeq(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 interface{}, isRoot bool) error {
return nil
}
func validateFields(config interface{}, fields map[string]FieldConfig) error {
func validateFields(config any, 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 interface{}) {
func (f *FieldConfig) populate(config any) {
// update the field if it's not empty
if !f.shouldUpdateValue {
return
@ -346,17 +346,6 @@ 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)
@ -366,71 +355,30 @@ 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", fieldRef, val)
return fmt.Errorf("field `%s` is not a valid IPv4 address: %s", f.Name, val)
}
case "ipv6":
if net.ParseIP(val).To16() == nil {
return fmt.Errorf("field `%s` is not a valid IPv6 address: %s", 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)
return fmt.Errorf("field `%s` is not a valid IPv6 address: %s", f.Name, val)
}
case "hwaddr":
if _, err := net.ParseMAC(val); err != nil {
return fmt.Errorf("%s is not a valid MAC address: %s", fieldRef, val)
return fmt.Errorf("field `%s` is not a valid MAC address: %s", f.Name, val)
}
case "hostname":
if _, err := idna.Lookup.ToASCII(val); err != nil {
return fmt.Errorf("%s is not a valid hostname: %s", fieldRef, val)
return fmt.Errorf("field `%s` is not a valid hostname: %s", f.Name, val)
}
case "proxy":
if url, err := url.Parse(val); err != nil || (url.Scheme != "http" && url.Scheme != "https") || url.Host == "" {
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)
return fmt.Errorf("field `%s` is not a valid HTTP proxy URL: %s", f.Name, val)
}
default:
return fmt.Errorf("field `%s` cannot use validate_type: unsupported validator: %s", fieldRef, validateType)
return fmt.Errorf("field `%s` cannot use validate_type: unsupported validator: %s", f.Name, validateType)
}
}

View File

@ -25,7 +25,7 @@ type testIPv4StaticConfig struct {
type testIPv6StaticConfig struct {
Address null.String `json:"address" validate_type:"ipv6" required:"true"`
PrefixLength null.Int `json:"prefix_length" validate_type:"ipv6_prefix_length" required:"true"`
Prefix null.String `json:"prefix" validate_type:"ipv6" required:"true"`
Gateway null.String `json:"gateway" validate_type:"ipv6" required:"true"`
DNS []string `json:"dns" validate_type:"ipv6" required:"true"`
}
@ -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,basic,all" default:"basic"`
LLDPMode null.String `json:"lldp_mode,omitempty" one_of:"disabled,rx_only,tx_only,enabled" default:"enabled"`
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"`

View File

@ -16,7 +16,7 @@ func splitString(s string) []string {
return strings.Split(s, ",")
}
func toString(v interface{}) (string, error) {
func toString(v any) (string, error) {
switch v := v.(type) {
case string:
return v, nil

View File

@ -146,17 +146,14 @@ 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()
@ -168,45 +165,26 @@ func (m *MDNS) Stop() error {
return m.conn.Close()
}
func (m *MDNS) setLocalNames(localNames []string) {
m.lock.Lock()
defer m.lock.Unlock()
if reflect.DeepEqual(m.localNames, localNames) {
return
func (m *MDNS) SetLocalNames(localNames []string, always bool) error {
if reflect.DeepEqual(m.localNames, localNames) && !always {
return nil
}
m.localNames = localNames
_ = m.Restart()
return nil
}
func (m *MDNS) setListenOptions(listenOptions *MDNSListenOptions) {
m.lock.Lock()
defer m.lock.Unlock()
func (m *MDNS) SetListenOptions(listenOptions *MDNSListenOptions) error {
if m.listenOptions != nil &&
m.listenOptions.IPv4 == listenOptions.IPv4 &&
m.listenOptions.IPv6 == listenOptions.IPv6 {
return
return nil
}
m.listenOptions = listenOptions
}
_ = m.Restart()
// 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()
return nil
}

File diff suppressed because one or more lines are too long

View File

@ -48,10 +48,6 @@ 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);
}
@ -79,19 +75,15 @@ 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;
@ -161,22 +153,6 @@ 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,

View File

@ -24,8 +24,6 @@ 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

View File

@ -887,26 +887,6 @@ 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);
@ -2217,221 +2197,6 @@ 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)();
@ -2447,7 +2212,6 @@ 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]();
@ -2472,5 +2236,4 @@ void create_screens() {
create_screen_reset_config_screen();
create_screen_reboot_screen();
create_screen_rebooting_screen();
create_screen_switch_dhcp_client_screen();
}

View File

@ -19,7 +19,6 @@ 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;
@ -55,7 +54,6 @@ 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;
@ -103,14 +101,6 @@ 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;
@ -127,7 +117,6 @@ 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();
@ -163,9 +152,6 @@ 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);

View File

@ -1,13 +1,25 @@
package types
package network
import (
"fmt"
"net"
"net/http"
"net/url"
"time"
"github.com/guregu/null/v6"
"github.com/jetkvm/kvm/internal/mdns"
"golang.org/x/net/idna"
)
// IPv4StaticConfig represents static IPv4 configuration
type IPv6Address struct {
Address net.IP `json:"address"`
Prefix net.IPNet `json:"prefix"`
ValidLifetime *time.Time `json:"valid_lifetime"`
PreferredLifetime *time.Time `json:"preferred_lifetime"`
Scope int `json:"scope"`
}
type IPv4StaticConfig struct {
Address null.String `json:"address,omitempty" validate_type:"ipv4" required:"true"`
Netmask null.String `json:"netmask,omitempty" validate_type:"ipv4" required:"true"`
@ -15,23 +27,13 @@ type IPv4StaticConfig struct {
DNS []string `json:"dns,omitempty" validate_type:"ipv4" required:"true"`
}
// IPv6StaticConfig represents static IPv6 configuration
type IPv6StaticConfig struct {
Prefix null.String `json:"prefix,omitempty" validate_type:"ipv6_prefix" required:"true"`
Address null.String `json:"address,omitempty" validate_type:"ipv6" required:"true"`
Prefix null.String `json:"prefix,omitempty" validate_type:"ipv6" required:"true"`
Gateway null.String `json:"gateway,omitempty" validate_type:"ipv6" required:"true"`
DNS []string `json:"dns,omitempty" validate_type:"ipv6" required:"true"`
}
// 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"`
@ -42,7 +44,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,basic,all" default:"basic"`
LLDPMode null.String `json:"lldp_mode,omitempty" one_of:"disabled,rx_only,tx_only,basic,all,enabled" default:"enabled"`
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"`
@ -53,15 +55,13 @@ type NetworkConfig struct {
TimeSyncHTTPUrls []string `json:"time_sync_http_urls,omitempty" validate_type:"url" required_if:"TimeSyncOrdering=http_user_provided"`
}
// GetMDNSMode returns the MDNS mode configuration
func (c *NetworkConfig) GetMDNSMode() *MDNSListenOptions {
mode := c.MDNSMode.String
listenOptions := &MDNSListenOptions{
IPv4: true,
IPv6: true,
func (c *NetworkConfig) GetMDNSMode() *mdns.MDNSListenOptions {
listenOptions := &mdns.MDNSListenOptions{
IPv4: c.IPv4Mode.String != "disabled",
IPv6: c.IPv6Mode.String != "disabled",
}
switch mode {
switch c.MDNSMode.String {
case "ipv4_only":
listenOptions.IPv6 = false
case "ipv6_only":
@ -74,21 +74,53 @@ func (c *NetworkConfig) GetMDNSMode() *MDNSListenOptions {
return listenOptions
}
// GetTransportProxyFunc returns a function for HTTP proxy configuration
func (c *NetworkConfig) GetTransportProxyFunc() func(*http.Request) (*url.URL, error) {
func (s *NetworkConfig) GetTransportProxyFunc() func(*http.Request) (*url.URL, error) {
return func(*http.Request) (*url.URL, error) {
if c.HTTPProxy.String == "" {
if s.HTTPProxy.String == "" {
return nil, nil
} else {
proxyURL, _ := url.Parse(c.HTTPProxy.String)
return proxyURL, nil
proxyUrl, _ := url.Parse(s.HTTPProxy.String)
return proxyUrl, nil
}
}
}
// NetworkConfig interface for backward compatibility
type NetworkConfigInterface interface {
InterfaceName() string
IPv4Addresses() []IPAddress
IPv6Addresses() []IPAddress
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())
}

11
internal/network/dhcp.go Normal file
View File

@ -0,0 +1,11 @@
package network
type DhcpTargetState int
const (
DhcpTargetStateDoNothing DhcpTargetState = iota
DhcpTargetStateStart
DhcpTargetStateStop
DhcpTargetStateRenew
DhcpTargetStateRelease
)

View File

@ -0,0 +1,137 @@
package network
import (
"fmt"
"io"
"os"
"os/exec"
"strings"
"sync"
"golang.org/x/net/idna"
)
const (
hostnamePath = "/etc/hostname"
hostsPath = "/etc/hosts"
)
var (
hostnameLock sync.Mutex = sync.Mutex{}
)
func updateEtcHosts(hostname string, fqdn string) error {
// update /etc/hosts
hostsFile, err := os.OpenFile(hostsPath, os.O_RDWR|os.O_SYNC, os.ModeExclusive)
if err != nil {
return fmt.Errorf("failed to open %s: %w", hostsPath, err)
}
defer hostsFile.Close()
// read all lines
if _, err := hostsFile.Seek(0, io.SeekStart); err != nil {
return fmt.Errorf("failed to seek %s: %w", hostsPath, err)
}
lines, err := io.ReadAll(hostsFile)
if err != nil {
return fmt.Errorf("failed to read %s: %w", hostsPath, err)
}
newLines := []string{}
hostLine := fmt.Sprintf("127.0.1.1\t%s %s", hostname, fqdn)
hostLineExists := false
for line := range strings.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
}

403
internal/network/netif.go Normal file
View File

@ -0,0 +1,403 @@
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)
}

View File

@ -0,0 +1,58 @@
//go:build linux
package network
import (
"time"
"github.com/vishvananda/netlink"
"github.com/vishvananda/netlink/nl"
)
func (s *NetworkInterfaceState) HandleLinkUpdate(update netlink.LinkUpdate) {
if update.Link.Attrs().Name == s.interfaceName {
s.l.Info().Interface("update", update).Msg("interface link update received")
_ = s.CheckAndUpdateDhcp()
}
}
func (s *NetworkInterfaceState) Run() error {
updates := make(chan netlink.LinkUpdate)
done := make(chan struct{})
if err := netlink.LinkSubscribe(updates, done); err != nil {
s.l.Warn().Err(err).Msg("failed to subscribe to link updates")
return err
}
_ = s.setHostnameIfNotSame()
// run the dhcp client
go s.dhcpClient.Run() // nolint:errcheck
if err := s.CheckAndUpdateDhcp(); err != nil {
return err
}
go func() {
ticker := time.NewTicker(1 * time.Second)
defer ticker.Stop()
for {
select {
case update := <-updates:
s.HandleLinkUpdate(update)
case <-ticker.C:
_ = s.CheckAndUpdateDhcp()
case <-done:
return
}
}
}()
return nil
}
func netlinkAddrs(iface netlink.Link) ([]netlink.Addr, error) {
return netlink.AddrList(iface, nl.FAMILY_ALL)
}

View File

@ -0,0 +1,21 @@
//go:build !linux
package network
import (
"fmt"
"github.com/vishvananda/netlink"
)
func (s *NetworkInterfaceState) HandleLinkUpdate() error {
return fmt.Errorf("not implemented")
}
func (s *NetworkInterfaceState) Run() error {
return fmt.Errorf("not implemented")
}
func netlinkAddrs(iface netlink.Link) ([]netlink.Addr, error) {
return nil, fmt.Errorf("not implemented")
}

126
internal/network/rpc.go Normal file
View File

@ -0,0 +1,126 @@
package network
import (
"fmt"
"time"
"github.com/jetkvm/kvm/internal/confparser"
"github.com/jetkvm/kvm/internal/udhcpc"
)
type RpcIPv6Address struct {
Address string `json:"address"`
ValidLifetime *time.Time `json:"valid_lifetime,omitempty"`
PreferredLifetime *time.Time `json:"preferred_lifetime,omitempty"`
Scope int `json:"scope"`
}
type RpcNetworkState struct {
InterfaceName string `json:"interface_name"`
MacAddress string `json:"mac_address"`
IPv4 string `json:"ipv4,omitempty"`
IPv6 string `json:"ipv6,omitempty"`
IPv6LinkLocal string `json:"ipv6_link_local,omitempty"`
IPv4Addresses []string `json:"ipv4_addresses,omitempty"`
IPv6Addresses []RpcIPv6Address `json:"ipv6_addresses,omitempty"`
DHCPLease *udhcpc.Lease `json:"dhcp_lease,omitempty"`
}
type RpcNetworkSettings struct {
NetworkConfig
}
func (s *NetworkInterfaceState) MacAddress() string {
if s.macAddr == nil {
return ""
}
return s.macAddr.String()
}
func (s *NetworkInterfaceState) IPv4Address() string {
if s.ipv4Addr == nil {
return ""
}
return s.ipv4Addr.String()
}
func (s *NetworkInterfaceState) IPv6Address() string {
if s.ipv6Addr == nil {
return ""
}
return s.ipv6Addr.String()
}
func (s *NetworkInterfaceState) IPv6LinkLocalAddress() string {
if s.ipv6LinkLocal == nil {
return ""
}
return s.ipv6LinkLocal.String()
}
func (s *NetworkInterfaceState) RpcGetNetworkState() RpcNetworkState {
ipv6Addresses := make([]RpcIPv6Address, 0)
if s.ipv6Addresses != nil && 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()
}

View File

@ -1,62 +0,0 @@
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,
}
}

View File

@ -1,85 +0,0 @@
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"`
}

View File

@ -1,22 +0,0 @@
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"`
}

26
internal/network/utils.go Normal file
View File

@ -0,0 +1,26 @@
package network
import (
"encoding/json"
"time"
)
func lifetimeToTime(lifetime int) *time.Time {
if lifetime == 0 {
return nil
}
t := time.Now().Add(time.Duration(lifetime) * time.Second)
return &t
}
func IsSame(a, b 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)
}

View File

@ -1,149 +0,0 @@
//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")
}

View File

@ -1,69 +0,0 @@
//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
}

View File

@ -1,18 +0,0 @@
//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)
}

View File

@ -1,92 +0,0 @@
//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)
}

View File

@ -1,30 +0,0 @@
//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()
}

View File

@ -3,14 +3,13 @@ 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
@ -28,7 +27,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",
@ -38,48 +37,7 @@ 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")

View File

@ -7,7 +7,7 @@ import (
"sync"
"time"
"github.com/jetkvm/kvm/internal/network/types"
"github.com/jetkvm/kvm/internal/network"
"github.com/rs/zerolog"
)
@ -24,13 +24,11 @@ var (
timeSyncRetryInterval = 0 * time.Second
)
type PreCheckFunc func() (bool, error)
type TimeSync struct {
syncLock *sync.Mutex
l *zerolog.Logger
networkConfig *types.NetworkConfig
networkConfig *network.NetworkConfig
dhcpNtpAddresses []string
rtcDevicePath string
@ -38,19 +36,14 @@ type TimeSync struct {
rtcLock *sync.Mutex
syncSuccess bool
timer *time.Timer
preCheckFunc PreCheckFunc
preCheckIPv4 PreCheckFunc
preCheckIPv6 PreCheckFunc
preCheckFunc func() (bool, error)
}
type TimeSyncOptions struct {
PreCheckFunc PreCheckFunc
PreCheckIPv4 PreCheckFunc
PreCheckIPv6 PreCheckFunc
PreCheckFunc func() (bool, error)
Logger *zerolog.Logger
NetworkConfig *types.NetworkConfig
NetworkConfig *network.NetworkConfig
}
type SyncMode struct {
@ -76,10 +69,7 @@ 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 != "" {
@ -122,64 +112,49 @@ 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) timeSyncLoop() {
func (t *TimeSync) doTimeSync() {
metricTimeSyncStatus.Set(0)
// use a timer here instead of sleep
for range t.timer.C {
for {
if ok, err := t.preCheckFunc(); !ok {
if err != nil {
t.l.Error().Err(err).Msg("pre-check failed")
}
t.timer.Reset(timeSyncWaitNetChkInt)
time.Sleep(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
t.timer.Reset(timeSyncRetryInterval)
time.Sleep(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)
t.timer.Reset(timeSyncInterval) // after the first sync is done
time.Sleep(timeSyncInterval) // after the first sync is done
}
}
func (t *TimeSync) sync() error {
t.syncLock.Lock()
defer t.syncLock.Unlock()
func (t *TimeSync) Sync() error {
var (
now *time.Time
offset *time.Duration
@ -213,10 +188,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
@ -264,25 +239,12 @@ 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.timeSyncLoop()
go t.doTimeSync()
}
func (t *TimeSync) setSystemTime(now time.Time) error {

View File

@ -1,26 +1,18 @@
package types
package udhcpc
import (
"bufio"
"encoding/json"
"fmt"
"net"
"os"
"reflect"
"strconv"
"strings"
"time"
)
// 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 {
type Lease struct {
// from https://udhcp.busybox.net/README.udhcpc
IPAddress net.IP `env:"ip" json:"ip"` // The obtained IP
Netmask net.IP `env:"subnet" json:"netmask"` // The assigned subnet mask
@ -29,7 +21,6 @@ type DHCPLease 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
@ -47,46 +38,149 @@ type DHCPLease 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
InterfaceName string `json:"interface_name,omitempty"` // The name of the interface
DHCPClient string `json:"dhcp_client,omitempty"` // The DHCP client that obtained the lease
isEmpty map[string]bool
}
// 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) setIsEmpty(m map[string]bool) {
l.isEmpty = m
}
// IPMask returns the IP mask for the DHCP lease
func (d *DHCPLease) IPMask() net.IPMask {
if d.IsIPv6() {
// TODO: not implemented
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)
return nil
}
mask := net.ParseIP(d.Netmask.String())
return net.IPv4Mask(mask[12], mask[13], mask[14], mask[15])
}
// IPNet returns the IP net for the DHCP lease
func (d *DHCPLease) IPNet() *net.IPNet {
if d.IsIPv6() {
// TODO: not implemented
return nil
}
return &net.IPNet{
IP: d.IPAddress,
Mask: d.IPMask(),
}
}

View File

@ -6,13 +6,9 @@ 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"
)
@ -22,22 +18,20 @@ const (
)
type DHCPClient struct {
types.DHCPClient
InterfaceName string
leaseFile string
pidFile string
lease *Lease
logger *zerolog.Logger
process *os.Process
runOnce sync.Once
onLeaseChange func(lease *types.DHCPLease)
onLeaseChange func(lease *Lease)
}
type DHCPClientOptions struct {
InterfaceName string
PidFile string
Logger *zerolog.Logger
OnLeaseChange func(lease *types.DHCPLease)
OnLeaseChange func(lease *Lease)
}
var defaultLogger = zerolog.New(os.Stdout).Level(zerolog.InfoLevel)
@ -73,8 +67,8 @@ func (c *DHCPClient) getWatchPaths() []string {
}
// Run starts the DHCP client and watches the lease file for changes.
// this is a blocking call.
func (c *DHCPClient) run() error {
// this isn't a blocking call, and the lease file is reloaded when a change is detected.
func (c *DHCPClient) Run() error {
err := c.loadLeaseFile()
if err != nil && !errors.Is(err, os.ErrNotExist) {
return err
@ -131,7 +125,7 @@ func (c *DHCPClient) run() error {
// c.logger.Error().Msg("udhcpc process not found")
// }
// block the goroutine
// block the goroutine until the lease file is updated
<-make(chan struct{})
return nil
@ -188,7 +182,7 @@ func (c *DHCPClient) loadLeaseFile() error {
Msg("current dhcp lease expiry time calculated")
}
c.onLeaseChange(lease.ToDHCPLease())
c.onLeaseChange(lease)
c.logger.Info().
Str("ip", lease.IPAddress.String()).
@ -202,47 +196,3 @@ 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()
}

View File

@ -175,10 +175,6 @@ 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{}
@ -724,8 +720,7 @@ func rpcSetWakeOnLanDevices(params SetWakeOnLanDevicesParams) error {
}
func rpcResetConfig() error {
defaultConfig := getDefaultConfig()
config = &defaultConfig
config = defaultConfig
if err := SaveConfig(); err != nil {
return fmt.Errorf("failed to reset config: %w", err)
}

View File

@ -33,7 +33,6 @@ func Main() {
go runWatchdog()
go confirmCurrentSystem()
initDisplay()
initNative(systemVersionLocal, appVersionLocal)
http.DefaultClient.Timeout = 1 * time.Minute
@ -75,6 +74,9 @@ func Main() {
}
initJiggler()
// initialize display
initDisplay()
// start video sleep mode timer
startVideoSleepModeTicker()

14
mdns.go
View File

@ -1,23 +1,19 @@
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: options.LocalNames,
ListenOptions: options.ListenOptions,
LocalNames: []string{
networkState.GetHostname(),
networkState.GetFQDN(),
},
ListenOptions: config.NetworkConfig.GetMDNSMode(),
})
if err != nil {
return err

View File

@ -43,8 +43,6 @@ 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")
}

View File

@ -1,14 +1,10 @@
package kvm
import (
"context"
"fmt"
"reflect"
"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"
"github.com/jetkvm/kvm/internal/network"
"github.com/jetkvm/kvm/internal/udhcpc"
)
const (
@ -16,297 +12,114 @@ const (
)
var (
networkManager *nmlite.NetworkManager
networkState *network.NetworkInterfaceState
)
type RpcNetworkSettings struct {
types.NetworkConfig
}
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) {
func networkStateChanged(isOnline bool) {
// do not block the main thread
go waitCtrlAndRequestDisplayUpdate(true, "network_state_changed")
if currentSession != nil {
writeJSONRPCEvent("networkState", state.ToRpcInterfaceState(), currentSession)
if timeSync != nil {
if networkState != nil {
timeSync.SetDhcpNtpAddresses(networkState.NtpAddressesString())
}
if state.Online {
networkLogger.Info().Msg("network state changed to online, triggering time sync")
triggerTimeSyncOnNetworkStateChange()
if err := timeSync.Sync(); err != nil {
networkLogger.Error().Err(err).Msg("failed to sync time after network state change")
}
}
// always restart mDNS when the network state changes
if mDNS != nil {
restartMdns()
}
}
func validateNetworkConfig() {
err := confparser.SetDefaultsAndValidate(config.NetworkConfig)
if err == nil {
return
_ = mDNS.SetListenOptions(config.NetworkConfig.GetMDNSMode())
_ = mDNS.SetLocalNames([]string{
networkState.GetHostname(),
networkState.GetFQDN(),
}, true)
}
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")
// 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")
}
// 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()
// validate the config, if it's invalid, revert to the default config and save the backup
validateNetworkConfig()
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())
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()
networkManager = nm
return nil
}
func setHostname(nm *nmlite.NetworkManager, hostname, domain string) error {
if nm == nil {
return nil
if currentSession == nil {
return
}
if hostname == "" {
hostname = GetDefaultHostname()
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)
}
},
})
return nm.SetHostname(hostname, domain)
}
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
if state == nil {
if err == nil {
return fmt.Errorf("failed to create NetworkInterfaceState")
}
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 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
}
if rebootRequired {
if err := rpcReboot(false); err != nil {
return nil, err
}
}
return toRpcNetworkSettings(newConfig), nil
}
func rpcRenewDHCPLease() error {
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)
if err := state.Run(); err != nil {
return err
}
networkState = state
return nil
}
func rpcGetNetworkState() network.RpcNetworkState {
return networkState.RpcGetNetworkState()
}
func rpcGetNetworkSettings() network.RpcNetworkSettings {
return networkState.RpcGetNetworkSettings()
}
func rpcSetNetworkSettings(settings network.RpcNetworkSettings) (*network.RpcNetworkSettings, error) {
s := networkState.RpcSetNetworkSettings(settings)
if s != nil {
return nil, s
}
if err := SaveConfig(); err != nil {
return nil, err
}
return &network.RpcNetworkSettings{NetworkConfig: *config.NetworkConfig}, nil
}
func rpcRenewDHCPLease() error {
return networkState.RpcRenewDHCPLease()
}

9
ota.go
View File

@ -488,15 +488,6 @@ 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()

View File

@ -1,219 +0,0 @@
// 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)
}
}

View File

@ -1,261 +0,0 @@
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
}

View File

@ -1,853 +0,0 @@
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
}

View File

@ -1,163 +0,0 @@
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
}

View File

@ -1,407 +0,0 @@
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")
}
}

View File

@ -1,85 +0,0 @@
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
}

View File

@ -1,135 +0,0 @@
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
}

View File

@ -1,313 +0,0 @@
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
}

View File

@ -1,102 +0,0 @@
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)
}

View File

@ -1,64 +0,0 @@
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()
}

View File

@ -1,247 +0,0 @@
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
}

View File

@ -1,48 +0,0 @@
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
}

View File

@ -1,13 +0,0 @@
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
)

View File

@ -1,544 +0,0 @@
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
}

View File

@ -1,164 +0,0 @@
// 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
}

View File

@ -1,52 +0,0 @@
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,
})
}

View File

@ -1,13 +0,0 @@
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
}

View File

@ -1,87 +0,0 @@
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
}

View File

@ -1,260 +0,0 @@
// 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
}

View File

@ -1,7 +0,0 @@
package nmlite
import "github.com/jetkvm/kvm/pkg/nmlite/link"
func getNetlinkManager() *link.NetlinkManager {
return link.GetNetlinkManager()
}

View File

@ -1,209 +0,0 @@
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
}

View File

@ -1,106 +0,0 @@
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()
}

View File

@ -1,184 +0,0 @@
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)
}

View File

@ -1,171 +0,0 @@
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
}

View File

@ -1,76 +0,0 @@
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
}

View File

@ -17,7 +17,6 @@ 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
@ -33,7 +32,6 @@ 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
@ -66,11 +64,6 @@ 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
@ -187,10 +180,7 @@ 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} \
ENABLE_SYNC_TRACE=${ENABLE_SYNC_TRACE}
do_make build_release SKIP_NATIVE_IF_EXISTS=${SKIP_NATIVE_BUILD} SKIP_UI_BUILD=${SKIP_UI_BUILD_RELEASE}
# 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
@ -199,10 +189,7 @@ 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} \
ENABLE_SYNC_TRACE=${ENABLE_SYNC_TRACE}
do_make build_dev SKIP_NATIVE_IF_EXISTS=${SKIP_NATIVE_BUILD} SKIP_UI_BUILD=${SKIP_UI_BUILD_RELEASE}
# Kill any existing instances of the application
ssh "${REMOTE_USER}@${REMOTE_HOST}" "killall jetkvm_app_debug || true"

View File

@ -43,20 +43,8 @@ func initTimeSync() {
timeSync = timesync.NewTimeSync(&timesync.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 !networkManager.IsOnline() {
if !networkState.IsOnline() {
return false, nil
}
return true, nil

View File

@ -1,21 +0,0 @@
#!/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}')

View File

@ -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_connect_to_cloud": "Tilslut din JetKVM til skyen",
"auth_authentication_mode": "Vælg venligst en godkendelsestilstand",
"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_mode_local": "Lokal godkendelsesmetode",
"auth_login": "Log ind på din JetKVM-konto",
"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": "Ingen adgangskode",
"auth_mode_local_no_password_description": "Hurtig adgang uden adgangskodegodkendelse.",
"auth_mode_local_password": "Adgangskode",
"auth_mode_local_no_password": "Ingen 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": "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_note": "Denne adgangskode vil blive brugt til at sikre dine enhedsdata og beskytte mod uautoriseret adgang.",
"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.",
"back": "Tilbage",
"auth_signup_create_account": "Opret din JetKVM-konto",
"back_to_devices": "Tilbage til Enheder",
"back": "Tilbage",
"cancel": "Ophæve",
"close": "Tæt",
"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.",
"cloud_kvms_no_devices": "Ingen enheder fundet",
"cloud_kvms": "Cloud KVM'er",
"confirm": "Bekræfte",
"connect_to_kvm": "Opret forbindelse til KVM",
"connecting_to_device": "Forbinder til enhed…",
"connection_established": "Forbindelse etableret",
"connection_stats_badge_jitter": "Jitter",
"connection_stats_badge_jitter_buffer_avg_delay": "Jitterbuffer gennemsnitlig forsinkelse",
"connection_stats_connection": "Forbindelse",
"connection_stats_badge_jitter": "Jitter",
"connection_stats_connection_description": "Forbindelsen mellem klienten og JetKVM'en.",
"connection_stats_frames_per_second": "Billeder per sekund",
"connection_stats_connection": "Forbindelse",
"connection_stats_frames_per_second_description": "Antal indgående videobilleder vist pr. sekund.",
"connection_stats_network_stability": "Netværksstabilitet",
"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_packets_lost": "Pakker mistet",
"connection_stats_network_stability": "Netværksstabilitet",
"connection_stats_packets_lost_description": "Antal mistede indgående video-RTP-pakker.",
"connection_stats_playback_delay": "Afspilningsforsinkelse",
"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_round_trip_time": "Rundturstid",
"connection_stats_playback_delay": "Afspilningsforsinkelse",
"connection_stats_round_trip_time_description": "Rundrejsetid for det aktive ICE-kandidatpar mellem peers.",
"connection_stats_round_trip_time": "Rundturstid",
"connection_stats_sidebar": "Forbindelsesstatistik",
"connection_stats_video": "Video",
"connection_stats_video_description": "Videostreamen fra JetKVM'en til klienten.",
"connection_stats_video": "Video",
"continue": "Fortsætte",
"creating_peer_connection": "Opretter peer-forbindelse…",
"dc_power_control_current": "Strøm",
"dc_power_control_current_unit": "EN",
"dc_power_control_current": "Strøm",
"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": "Spænding",
"dc_power_control_voltage_unit": "V",
"dc_power_control_voltage": "Spænding",
"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": "Seriel konsol",
"extension_serial_console_description": "Få adgang til din serielle konsoludvidelse",
"extensions_atx_power_control": "ATX-strømstyring",
"extension_serial_console": "Seriel konsol",
"extensions_atx_power_control_description": "Styr din maskines strømtilstand via ATX-strømstyring.",
"extensions_dc_power_control": "DC-strømstyring",
"extensions_atx_power_control": "ATX-strømstyring",
"extensions_dc_power_control_description": "Styr din DC-strømforlænger",
"extensions_dc_power_control": "DC-strømstyring",
"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": "Genstart enhed",
"general_reboot_device_description": "Sluk og tænd for JetKVM'en",
"general_reboot_device": "Genstart enhed",
"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,7 +327,6 @@
"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",
@ -410,8 +409,8 @@
"log_in": "Log ind",
"log_out": "Log ud",
"logged_in_as": "Logget ind som",
"login_enter_password": "Indtast din adgangskode",
"login_enter_password_description": "Indtast din adgangskode for at få adgang til din JetKVM.",
"login_enter_password": "Indtast din adgangskode",
"login_error": "Der opstod en fejl under login",
"login_forgot_password": "Glemt adgangskode?",
"login_password_label": "Adgangskode",
@ -425,8 +424,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": "Gem makro",
"macro_save_error": "Der opstod en fejl under lagring.",
"macro_save": "Gem makro",
"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",
@ -440,8 +439,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": "Tilføj ny makro",
"macros_add_new_macro": "Tilføj ny makro",
"macros_add_new": "Tilføj ny makro",
"macros_aria_add_new": "Tilføj ny makro",
"macros_aria_delete": "Slet makro {name}",
"macros_aria_duplicate": "Dupliker makro {name}",
@ -463,14 +462,14 @@
"macros_edit_button": "Redigere",
"macros_edit_description": "Rediger din tastaturmakro",
"macros_edit_title": "Rediger makro",
"macros_failed_create": "Kunne ikke oprette makro",
"macros_failed_create_error": "Kunne ikke oprette makro: {error}",
"macros_failed_delete": "Makroen kunne ikke slettes",
"macros_failed_create": "Kunne ikke oprette makro",
"macros_failed_delete_error": "Kunne ikke slette makroen: {error}",
"macros_failed_duplicate": "Makroen kunne ikke duplikeres",
"macros_failed_delete": "Makroen kunne ikke slettes",
"macros_failed_duplicate_error": "Kunne ikke duplikere makro: {error}",
"macros_failed_reorder": "Kunne ikke omarrangere makroer",
"macros_failed_duplicate": "Makroen kunne ikke duplikeres",
"macros_failed_reorder_error": "Kunne ikke omarrangere makroer: {error}",
"macros_failed_reorder": "Kunne ikke omarrangere makroer",
"macros_failed_update": "Makroen kunne ikke opdateres",
"macros_failed_update_error": "Kunne ikke opdatere makroen: {error}",
"macros_invalid_data": "Ugyldige makrodata",
@ -507,8 +506,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": "JetKVM-lagerbeslag",
"mount_jetkvm_storage_description": "Monter tidligere uploadede filer fra JetKVM-lageret",
"mount_jetkvm_storage": "JetKVM-lagerbeslag",
"mount_mode_cdrom": "CD/DVD",
"mount_mode_disk": "Disk",
"mount_mounted_as": "Monteret som",
@ -521,8 +520,8 @@
"mount_popular_images": "Populære billeder",
"mount_streaming_from_url": "Streaming fra URL",
"mount_supported_formats": "Understøttede formater: ISO, IMG",
"mount_unmount": "Afmonter",
"mount_unmount_error": "Kunne ikke afmontere billede: {error}",
"mount_unmount": "Afmonter",
"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",
@ -530,8 +529,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": "Uploader…",
"mount_uploading_with_name": "Uploader {name}",
"mount_uploading": "Uploader…",
"mount_url_description": "Monter filer fra enhver offentlig webadresse",
"mount_url_input_label": "Billed-URL",
"mount_url_mount": "URL-montering",
@ -539,10 +538,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": "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",
"mount_virtual_media_source": "Virtuel mediekilde",
"mount_virtual_media": "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",
@ -559,10 +558,10 @@
"mouse_jiggler_light": "Lys - 5m",
"mouse_jiggler_standard": "Standard - 1 m",
"mouse_jiggler_title": "Jiggler",
"mouse_mode_absolute": "Absolut",
"mouse_mode_absolute_description": "Mest bekvemme",
"mouse_mode_relative": "Relativ",
"mouse_mode_absolute": "Absolut",
"mouse_mode_relative_description": "Mest kompatible",
"mouse_mode_relative": "Relativ",
"mouse_modes_description": "Vælg musens inputtilstand",
"mouse_modes_title": "Tilstande",
"mouse_scroll_high": "Høj",
@ -575,17 +574,12 @@
"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": "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_confirm": "Forny lejekontrakt",
"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",
@ -594,35 +588,21 @@
"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",
@ -632,19 +612,9 @@
"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_settings_invalid_ipv4_cidr": "Ugyldig CIDR-notation for IPv4-adresse",
"network_settings_load_error": "Kunne ikke indlæse netværksindstillinger: {error}",
"network_save_settings": "Gem indstillinger",
"network_time_sync_description": "Konfigurer indstillinger for tidssynkronisering",
"network_time_sync_http_only": "Kun HTTP",
"network_time_sync_ntp_and_http": "NTP og HTTP",
@ -670,8 +640,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": "Indsæt tekst",
"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_sending_using_layout": "Sender tekst ved hjælp af tastaturlayout: {iso} - {name}",
"peer_connection_closed": "Lukket",
"peer_connection_closing": "Lukker",
@ -698,20 +668,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...",
@ -721,8 +691,8 @@
"settings_back_to_kvm": "Tilbage til KVM",
"settings_general": "Generel",
"settings_hardware": "Hardware",
"settings_keyboard": "Tastatur",
"settings_keyboard_macros": "Tastaturmakroer",
"settings_keyboard": "Tastatur",
"settings_mouse": "Mus",
"settings_network": "Netværk",
"settings_video": "Video",
@ -742,7 +712,6 @@
"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",
@ -789,6 +758,7 @@
"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)",
@ -855,7 +825,6 @@
"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",
@ -871,6 +840,7 @@
"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",
"welcome_to_jetkvm": "Velkommen til JetKVM",
"welcome_to_jetkvm_description": "Styr enhver computer eksternt"
"wake_on_lan": "Vågn på LAN",
"welcome_to_jetkvm_description": "Styr enhver computer eksternt",
"welcome_to_jetkvm": "Velkommen til JetKVM"
}

View File

@ -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_connect_to_cloud": "Verbinden Sie Ihr JetKVM mit der Cloud",
"auth_authentication_mode": "Bitte wählen Sie einen Authentifizierungsmodus",
"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_mode_local": "Lokale Authentifizierungsmethode",
"auth_login": "Melden Sie sich bei Ihrem JetKVM-Konto an",
"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": "Kein Passwort",
"auth_mode_local_no_password_description": "Schneller Zugriff ohne Passwortauthentifizierung.",
"auth_mode_local_password": "Passwort",
"auth_mode_local_no_password": "Kein 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": "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_note": "Dieses Passwort wird verwendet, um Ihre Gerätedaten zu sichern und vor unbefugtem Zugriff zu schützen.",
"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.",
"back": "Zurück",
"auth_signup_create_account": "Erstellen Sie Ihr JetKVM-Konto",
"back_to_devices": "Zurück zu Geräte",
"back": "Zurück",
"cancel": "Stornieren",
"close": "Schließen",
"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.",
"cloud_kvms_no_devices": "Keine Geräte gefunden",
"cloud_kvms": "Cloud-KVMs",
"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": "Jitter",
"connection_stats_badge_jitter_buffer_avg_delay": "Jitter-Puffer Durchschnittliche Verzögerung",
"connection_stats_connection": "Verbindung",
"connection_stats_badge_jitter": "Jitter",
"connection_stats_connection_description": "Die Verbindung zwischen dem Client und dem JetKVM.",
"connection_stats_frames_per_second": "Bilder pro Sekunde",
"connection_stats_connection": "Verbindung",
"connection_stats_frames_per_second_description": "Anzahl der pro Sekunde angezeigten eingehenden Videobilder.",
"connection_stats_network_stability": "Netzwerkstabilität",
"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_packets_lost": "Verlorene Pakete",
"connection_stats_network_stability": "Netzwerkstabilität",
"connection_stats_packets_lost_description": "Anzahl der verlorenen eingehenden Video-RTP-Pakete.",
"connection_stats_playback_delay": "Wiedergabeverzögerung",
"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_round_trip_time": "Round-Trip-Zeit",
"connection_stats_playback_delay": "Wiedergabeverzögerung",
"connection_stats_round_trip_time_description": "Roundtrip-Zeit für das aktive ICE-Kandidatenpaar zwischen Peers.",
"connection_stats_round_trip_time": "Round-Trip-Zeit",
"connection_stats_sidebar": "Verbindungsstatistiken",
"connection_stats_video": "Video",
"connection_stats_video_description": "Der Videostream vom JetKVM zum Client.",
"connection_stats_video": "Video",
"continue": "Weitermachen",
"creating_peer_connection": "Peer-Verbindung wird hergestellt …",
"dc_power_control_current": "Aktuell",
"dc_power_control_current_unit": "A",
"dc_power_control_current": "Aktuell",
"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": "Stromspannung",
"dc_power_control_voltage_unit": "V",
"dc_power_control_voltage": "Stromspannung",
"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": "Serielle Konsole",
"extension_serial_console_description": "Greifen Sie auf Ihre serielle Konsolenerweiterung zu",
"extensions_atx_power_control": "ATX-Stromsteuerung",
"extension_serial_console": "Serielle Konsole",
"extensions_atx_power_control_description": "Steuern Sie den Energiezustand Ihrer Maschine über die ATX-Energiesteuerung.",
"extensions_dc_power_control": "Gleichstromsteuerung",
"extensions_atx_power_control": "ATX-Stromsteuerung",
"extensions_dc_power_control_description": "Steuern Sie Ihre DC-Stromerweiterung",
"extensions_dc_power_control": "Gleichstromsteuerung",
"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": "Gerät neu starten",
"general_reboot_device_description": "Schalten Sie den JetKVM aus und wieder ein",
"general_reboot_device": "Gerät neu starten",
"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,7 +327,6 @@
"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",
@ -410,8 +409,8 @@
"log_in": "Einloggen",
"log_out": "Ausloggen",
"logged_in_as": "Angemeldet als",
"login_enter_password": "Geben Sie Ihr Passwort ein",
"login_enter_password_description": "Geben Sie Ihr Passwort ein, um auf Ihr JetKVM zuzugreifen.",
"login_enter_password": "Geben Sie Ihr Passwort ein",
"login_error": "Beim Anmelden ist ein Fehler aufgetreten",
"login_forgot_password": "Passwort vergessen?",
"login_password_label": "Passwort",
@ -425,8 +424,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": "Makro speichern",
"macro_save_error": "Beim Speichern ist ein Fehler aufgetreten.",
"macro_save": "Makro speichern",
"macro_step_count": "{steps} / {max} Schritte",
"macro_step_duration_description": "Wartezeit vor der Ausführung des nächsten Schritts.",
"macro_step_duration_label": "Schrittdauer",
@ -440,8 +439,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": "Neues Makro hinzufügen",
"macros_add_new_macro": "Neues Makro hinzufügen",
"macros_add_new": "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}",
@ -463,16 +462,16 @@
"macros_edit_button": "Bearbeiten",
"macros_edit_description": "Ändern Sie Ihr Tastaturmakro",
"macros_edit_title": "Makro bearbeiten",
"macros_failed_create": "Makro konnte nicht erstellt werden",
"macros_failed_create_error": "Makro konnte nicht erstellt werden: {error}",
"macros_failed_delete": "Makro konnte nicht gelöscht werden",
"macros_failed_create": "Makro konnte nicht erstellt werden",
"macros_failed_delete_error": "Makro konnte nicht gelöscht werden: {error}",
"macros_failed_duplicate": "Makro konnte nicht dupliziert werden",
"macros_failed_delete": "Makro konnte nicht gelöscht werden",
"macros_failed_duplicate_error": "Makro konnte nicht dupliziert werden: {error}",
"macros_failed_reorder": "Makros konnten nicht neu angeordnet werden",
"macros_failed_duplicate": "Makro konnte nicht dupliziert werden",
"macros_failed_reorder_error": "Fehler beim Neuordnen der Makros: {error}",
"macros_failed_update": "Makro konnte nicht aktualisiert werden",
"macros_failed_reorder": "Makros konnten nicht neu angeordnet werden",
"macros_failed_update_error": "Makro konnte nicht aktualisiert werden: {error}",
"macros_failed_update": "Makro konnte nicht aktualisiert werden",
"macros_invalid_data": "Ungültige Makrodaten",
"macros_loading": "Makros werden geladen …",
"macros_max_reached": "Max. erreicht",
@ -507,8 +506,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": "JetKVM-Speicherhalterung",
"mount_jetkvm_storage_description": "Mounten Sie zuvor hochgeladene Dateien aus dem JetKVM-Speicher",
"mount_jetkvm_storage": "JetKVM-Speicherhalterung",
"mount_mode_cdrom": "CD/DVD",
"mount_mode_disk": "Scheibe",
"mount_mounted_as": "Montiert als",
@ -521,8 +520,8 @@
"mount_popular_images": "Beliebte Bilder",
"mount_streaming_from_url": "Streaming von URL",
"mount_supported_formats": "Unterstützte Formate: ISO, IMG",
"mount_unmount": "Aushängen",
"mount_unmount_error": "Abbild konnte nicht ausgehängt werden: {error}",
"mount_unmount": "Aushängen",
"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",
@ -530,8 +529,8 @@
"mount_upload_successful": "Upload erfolgreich",
"mount_upload_title": "Neues Bild hochladen",
"mount_uploaded_has_been_uploaded": "{name} wurde hochgeladen",
"mount_uploading": "Hochladen…",
"mount_uploading_with_name": "Hochladen von {name}",
"mount_uploading": "Hochladen…",
"mount_url_description": "Mounten Sie Dateien von jeder öffentlichen Webadresse",
"mount_url_input_label": "Bild-URL",
"mount_url_mount": "URL-Mount",
@ -539,10 +538,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": "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",
"mount_virtual_media_source": "Virtuelle Medienquelle",
"mount_virtual_media": "Virtuelle Medien",
"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",
@ -559,10 +558,10 @@
"mouse_jiggler_light": "Licht - 5m",
"mouse_jiggler_standard": "Standard - 1 m",
"mouse_jiggler_title": "Wackel",
"mouse_mode_absolute": "Absolute",
"mouse_mode_absolute_description": "Am bequemsten",
"mouse_mode_relative": "Relativ",
"mouse_mode_absolute": "Absolute",
"mouse_mode_relative_description": "Am kompatibelsten",
"mouse_mode_relative": "Relativ",
"mouse_modes_description": "Wählen Sie den Mauseingabemodus",
"mouse_modes_title": "Modi",
"mouse_scroll_high": "Hoch",
@ -575,17 +574,12 @@
"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": "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_confirm": "Mietvertrag verlängern",
"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",
@ -594,35 +588,21 @@
"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",
@ -632,19 +612,9 @@
"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_settings_invalid_ipv4_cidr": "Ungültige CIDR-Notation für IPv4-Adresse",
"network_settings_load_error": "Netzwerkeinstellungen konnten nicht geladen werden: {error}",
"network_save_settings": "Einstellungen speichern",
"network_time_sync_description": "Konfigurieren der Zeitsynchronisierungseinstellungen",
"network_time_sync_http_only": "Nur HTTP",
"network_time_sync_ntp_and_http": "NTP und HTTP",
@ -670,8 +640,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": "Text 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_sending_using_layout": "Senden von Text mithilfe des Tastaturlayouts: {iso} - {name}",
"peer_connection_closed": "Geschlossen",
"peer_connection_closing": "Schließen",
@ -698,20 +668,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 …",
@ -721,8 +691,8 @@
"settings_back_to_kvm": "Zurück zu KVM",
"settings_general": "Allgemein",
"settings_hardware": "Hardware",
"settings_keyboard": "Tastatur",
"settings_keyboard_macros": "Tastaturmakros",
"settings_keyboard": "Tastatur",
"settings_mouse": "Maus",
"settings_network": "Netzwerk",
"settings_video": "Video",
@ -742,7 +712,6 @@
"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",
@ -789,6 +758,7 @@
"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)",
@ -855,7 +825,6 @@
"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",
@ -871,6 +840,7 @@
"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",
"welcome_to_jetkvm": "Willkommen bei JetKVM",
"welcome_to_jetkvm_description": "Steuern Sie jeden Computer aus der Ferne"
"wake_on_lan": "Wake-On-LAN",
"welcome_to_jetkvm_description": "Steuern Sie jeden Computer aus der Ferne",
"welcome_to_jetkvm": "Willkommen bei JetKVM"
}

View File

@ -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_connect_to_cloud": "Connect your JetKVM to the cloud",
"auth_authentication_mode": "Please select an authentication mode",
"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_mode_local": "Local Authentication Method",
"auth_login": "Log in to your JetKVM account",
"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": "No Password",
"auth_mode_local_no_password_description": "Quick access without password authentication.",
"auth_mode_local_password": "Password",
"auth_mode_local_no_password": "No 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": "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_note": "This password will be used to secure your device data and protect against unauthorized access.",
"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.",
"back": "Back",
"auth_signup_create_account": "Create your JetKVM account",
"back_to_devices": "Back to Devices",
"back": "Back",
"cancel": "Cancel",
"close": "Close",
"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.",
"cloud_kvms_no_devices": "No devices found",
"cloud_kvms": "Cloud KVMs",
"confirm": "Confirm",
"connect_to_kvm": "Connect to KVM",
"connecting_to_device": "Connecting to device…",
"connection_established": "Connection established",
"connection_stats_badge_jitter": "Jitter",
"connection_stats_badge_jitter_buffer_avg_delay": "Jitter Buffer Avg. Delay",
"connection_stats_connection": "Connection",
"connection_stats_badge_jitter": "Jitter",
"connection_stats_connection_description": "The connection between the client and the JetKVM.",
"connection_stats_frames_per_second": "Frames per second",
"connection_stats_connection": "Connection",
"connection_stats_frames_per_second_description": "Number of inbound video frames displayed per second.",
"connection_stats_network_stability": "Network Stability",
"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_packets_lost": "Packets Lost",
"connection_stats_network_stability": "Network Stability",
"connection_stats_packets_lost_description": "Count of lost inbound video RTP packets.",
"connection_stats_playback_delay": "Playback Delay",
"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_round_trip_time": "Round-Trip Time",
"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_round_trip_time": "Round-Trip Time",
"connection_stats_sidebar": "Connection Stats",
"connection_stats_video": "Video",
"connection_stats_video_description": "The video stream from the JetKVM to the client.",
"connection_stats_video": "Video",
"continue": "Continue",
"creating_peer_connection": "Creating peer connection…",
"dc_power_control_current": "Current",
"dc_power_control_current_unit": "A",
"dc_power_control_current": "Current",
"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": "Voltage",
"dc_power_control_voltage_unit": "V",
"dc_power_control_voltage": "Voltage",
"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": "Serial Console",
"extension_serial_console_description": "Access your serial console extension",
"extensions_atx_power_control": "ATX Power Control",
"extension_serial_console": "Serial Console",
"extensions_atx_power_control_description": "Control the power state of your machine via ATX power control.",
"extensions_dc_power_control": "DC Power Control",
"extensions_atx_power_control": "ATX Power Control",
"extensions_dc_power_control_description": "Control your DC Power extension",
"extensions_dc_power_control": "DC Power Control",
"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": "Reboot Device",
"general_reboot_device_description": "Power cycle the JetKVM",
"general_reboot_device": "Reboot Device",
"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,7 +327,6 @@
"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",
@ -410,8 +409,8 @@
"log_in": "Log In",
"log_out": "Log out",
"logged_in_as": "Logged in as",
"login_enter_password": "Enter your password",
"login_enter_password_description": "Enter your password to access your JetKVM.",
"login_enter_password": "Enter your password",
"login_error": "An error occurred while logging in",
"login_forgot_password": "Forgot password?",
"login_password_label": "Password",
@ -425,8 +424,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": "Save Macro",
"macro_save_error": "An error occurred while saving.",
"macro_save": "Save Macro",
"macro_step_count": "{steps} / {max} steps",
"macro_step_duration_description": "Time to wait before executing the next step.",
"macro_step_duration_label": "Step Duration",
@ -440,8 +439,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": "Add New Macro",
"macros_add_new_macro": "Add New Macro",
"macros_add_new": "Add New Macro",
"macros_aria_add_new": "Add new macro",
"macros_aria_delete": "Delete macro {name}",
"macros_aria_duplicate": "Duplicate macro {name}",
@ -463,16 +462,16 @@
"macros_edit_button": "Edit",
"macros_edit_description": "Modify your keyboard macro",
"macros_edit_title": "Edit Macro",
"macros_failed_create": "Failed to create macro",
"macros_failed_create_error": "Failed to create macro: {error}",
"macros_failed_delete": "Failed to delete macro",
"macros_failed_create": "Failed to create macro",
"macros_failed_delete_error": "Failed to delete macro: {error}",
"macros_failed_duplicate": "Failed to duplicate macro",
"macros_failed_delete": "Failed to delete macro",
"macros_failed_duplicate_error": "Failed to duplicate macro: {error}",
"macros_failed_reorder": "Failed to reorder macros",
"macros_failed_duplicate": "Failed to duplicate macro",
"macros_failed_reorder_error": "Failed to reorder macros: {error}",
"macros_failed_update": "Failed to update macro",
"macros_failed_reorder": "Failed to reorder macros",
"macros_failed_update_error": "Failed to update macro: {error}",
"macros_failed_update": "Failed to update macro",
"macros_invalid_data": "Invalid macro data",
"macros_loading": "Loading macros…",
"macros_max_reached": "Max Reached",
@ -507,8 +506,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": "JetKVM Storage Mount",
"mount_jetkvm_storage_description": "Mount previously uploaded files from the JetKVM storage",
"mount_jetkvm_storage": "JetKVM Storage Mount",
"mount_mode_cdrom": "CD/DVD",
"mount_mode_disk": "Disk",
"mount_mounted_as": "Mounted as",
@ -521,8 +520,8 @@
"mount_popular_images": "Popular images",
"mount_streaming_from_url": "Streaming from URL",
"mount_supported_formats": "Supported formats: ISO, IMG",
"mount_unmount": "Unmount",
"mount_unmount_error": "Failed to unmount image: {error}",
"mount_unmount": "Unmount",
"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",
@ -530,8 +529,8 @@
"mount_upload_successful": "Upload successful",
"mount_upload_title": "Upload New Image",
"mount_uploaded_has_been_uploaded": "{name} has been uploaded",
"mount_uploading": "Uploading…",
"mount_uploading_with_name": "Uploading {name}",
"mount_uploading": "Uploading…",
"mount_url_description": "Mount files from any public web address",
"mount_url_input_label": "Image URL",
"mount_url_mount": "URL Mount",
@ -539,10 +538,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": "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",
"mount_virtual_media_source": "Virtual Media Source",
"mount_virtual_media": "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",
@ -559,10 +558,10 @@
"mouse_jiggler_light": "Light - 5m",
"mouse_jiggler_standard": "Standard - 1m",
"mouse_jiggler_title": "Jiggler",
"mouse_mode_absolute": "Absolute",
"mouse_mode_absolute_description": "Most convenient",
"mouse_mode_relative": "Relative",
"mouse_mode_absolute": "Absolute",
"mouse_mode_relative_description": "Most compatible",
"mouse_mode_relative": "Relative",
"mouse_modes_description": "Choose the mouse input mode",
"mouse_modes_title": "Modes",
"mouse_scroll_high": "High",
@ -575,17 +574,12 @@
"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": "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_confirm": "Renew Lease",
"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",
@ -594,35 +588,21 @@
"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",
@ -632,19 +612,9 @@
"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_settings_invalid_ipv4_cidr": "Invalid CIDR notation for IPv4 address",
"network_settings_load_error": "Failed to load network settings: {error}",
"network_save_settings": "Save Settings",
"network_time_sync_description": "Configure time synchronization settings",
"network_time_sync_http_only": "HTTP only",
"network_time_sync_ntp_and_http": "NTP and HTTP",
@ -670,8 +640,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": "Paste text",
"paste_modal_paste_text_description": "Paste text from your client to the remote host",
"paste_modal_paste_text": "Paste text",
"paste_modal_sending_using_layout": "Sending text using keyboard layout: {iso}-{name}",
"peer_connection_closed": "Closed",
"peer_connection_closing": "Closing",
@ -698,20 +668,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...",
@ -721,8 +691,8 @@
"settings_back_to_kvm": "Back to KVM",
"settings_general": "General",
"settings_hardware": "Hardware",
"settings_keyboard": "Keyboard",
"settings_keyboard_macros": "Keyboard Macros",
"settings_keyboard": "Keyboard",
"settings_mouse": "Mouse",
"settings_network": "Network",
"settings_video": "Video",
@ -742,7 +712,6 @@
"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",
@ -789,6 +758,7 @@
"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)",
@ -855,7 +825,6 @@
"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",
@ -871,6 +840,7 @@
"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",
"welcome_to_jetkvm": "Welcome to JetKVM",
"welcome_to_jetkvm_description": "Control any computer remotely"
"wake_on_lan": "Wake On LAN",
"welcome_to_jetkvm_description": "Control any computer remotely",
"welcome_to_jetkvm": "Welcome to JetKVM"
}

View File

@ -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_connect_to_cloud": "Conecte su JetKVM a la nube",
"auth_authentication_mode": "Por favor seleccione un modo de autenticación",
"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_mode_local": "Método de autenticación local",
"auth_login": "Inicie sesión en su cuenta JetKVM",
"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": "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_no_password": "Sin 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": "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_note": "Esta contraseña se utilizará para proteger los datos de su dispositivo y contra accesos no autorizados.",
"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.",
"back": "Atrás",
"auth_signup_create_account": "Crea tu cuenta JetKVM",
"back_to_devices": "Volver a Dispositivos",
"back": "Atrás",
"cancel": "Cancelar",
"close": "Cerca",
"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.",
"cloud_kvms_no_devices": "No se encontraron dispositivos",
"cloud_kvms": "KVM en la nube",
"confirm": "Confirmar",
"connect_to_kvm": "Conectarse a KVM",
"connecting_to_device": "Conectando al dispositivo…",
"connection_established": "Conexión establecida",
"connection_stats_badge_jitter": "Estar nervioso",
"connection_stats_badge_jitter_buffer_avg_delay": "Retardo promedio del búfer de fluctuación",
"connection_stats_connection": "Conexión",
"connection_stats_badge_jitter": "Estar nervioso",
"connection_stats_connection_description": "La conexión entre el cliente y JetKVM.",
"connection_stats_frames_per_second": "Fotogramas por segundo",
"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_network_stability": "Estabilidad de la red",
"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_packets_lost": "Paquetes perdidos",
"connection_stats_network_stability": "Estabilidad de la red",
"connection_stats_packets_lost_description": "Recuento de paquetes de vídeo RTP entrantes perdidos.",
"connection_stats_playback_delay": "Retraso de reproducción",
"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_round_trip_time": "Tiempo de ida y vuelta",
"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_round_trip_time": "Tiempo de ida y vuelta",
"connection_stats_sidebar": "Estadísticas de conexión",
"connection_stats_video": "Video",
"connection_stats_video_description": "La transmisión de vídeo desde JetKVM al cliente.",
"connection_stats_video": "Video",
"continue": "Continuar",
"creating_peer_connection": "Creando conexión entre pares…",
"dc_power_control_current": "Actual",
"dc_power_control_current_unit": "A",
"dc_power_control_current": "Actual",
"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": "Voltaje",
"dc_power_control_voltage_unit": "V",
"dc_power_control_voltage": "Voltaje",
"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": "Consola serial",
"extension_serial_console_description": "Acceda a la extensión de su consola serie",
"extensions_atx_power_control": "Control de alimentación ATX",
"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.",
"extensions_dc_power_control": "Control de potencia de CC",
"extensions_atx_power_control": "Control de alimentación ATX",
"extensions_dc_power_control_description": "Controle su extensión de alimentación de CC",
"extensions_dc_power_control": "Control de potencia 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": "Reiniciar el dispositivo",
"general_reboot_device_description": "Apague y encienda el JetKVM",
"general_reboot_device": "Reiniciar el dispositivo",
"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,7 +327,6 @@
"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",
@ -410,8 +409,8 @@
"log_in": "Acceso",
"log_out": "Finalizar la sesión",
"logged_in_as": "Inició sesión como",
"login_enter_password": "Ingrese su contraseña",
"login_enter_password_description": "Introduzca su contraseña para acceder a su JetKVM.",
"login_enter_password": "Ingrese su contraseña",
"login_error": "Se produjo un error al iniciar sesión",
"login_forgot_password": "¿Has olvidado tu contraseña?",
"login_password_label": "Contraseña",
@ -425,8 +424,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": "Guardar macro",
"macro_save_error": "Se produjo un error al guardar.",
"macro_save": "Guardar macro",
"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",
@ -440,8 +439,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": "Agregar nueva macro",
"macros_add_new_macro": "Agregar nueva macro",
"macros_add_new": "Agregar nueva macro",
"macros_aria_add_new": "Agregar nueva macro",
"macros_aria_delete": "Eliminar macro {name}",
"macros_aria_duplicate": "Macro duplicada {name}",
@ -463,16 +462,16 @@
"macros_edit_button": "Editar",
"macros_edit_description": "Modificar la macro del teclado",
"macros_edit_title": "Editar macro",
"macros_failed_create": "No se pudo crear la macro",
"macros_failed_create_error": "No se pudo crear la macro: {error}",
"macros_failed_delete": "No se pudo eliminar la macro",
"macros_failed_create": "No se pudo crear la macro",
"macros_failed_delete_error": "No se pudo eliminar la macro: {error}",
"macros_failed_duplicate": "No se pudo duplicar la macro",
"macros_failed_delete": "No se pudo eliminar la macro",
"macros_failed_duplicate_error": "No se pudo duplicar la macro: {error}",
"macros_failed_reorder": "No se pudieron reordenar las macros",
"macros_failed_duplicate": "No se pudo duplicar la macro",
"macros_failed_reorder_error": "No se pudieron reordenar las macros: {error}",
"macros_failed_update": "No se pudo actualizar la macro",
"macros_failed_reorder": "No se pudieron reordenar las macros",
"macros_failed_update_error": "No se pudo actualizar la macro: {error}",
"macros_failed_update": "No se pudo actualizar la macro",
"macros_invalid_data": "Datos de macro no válidos",
"macros_loading": "Cargando macros…",
"macros_max_reached": "Máximo alcanzado",
@ -507,8 +506,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": "Soporte de almacenamiento JetKVM",
"mount_jetkvm_storage_description": "Montar archivos cargados previamente desde el almacenamiento JetKVM",
"mount_jetkvm_storage": "Soporte de almacenamiento JetKVM",
"mount_mode_cdrom": "CD/DVD",
"mount_mode_disk": "Disco",
"mount_mounted_as": "Montado como",
@ -521,8 +520,8 @@
"mount_popular_images": "Imágenes populares",
"mount_streaming_from_url": "Transmisión desde URL",
"mount_supported_formats": "Formatos compatibles: ISO, IMG",
"mount_unmount": "Desmontar",
"mount_unmount_error": "Error al desmontar la imagen: {error}",
"mount_unmount": "Desmontar",
"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",
@ -530,8 +529,8 @@
"mount_upload_successful": "Subida exitosa",
"mount_upload_title": "Subir nueva imagen",
"mount_uploaded_has_been_uploaded": "Se ha cargado {name}",
"mount_uploading": "Subiendo…",
"mount_uploading_with_name": "Subiendo {name}",
"mount_uploading": "Subiendo…",
"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",
@ -539,10 +538,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": "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",
"mount_virtual_media_source": "Fuente de medios virtuales",
"mount_virtual_media": "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",
@ -559,10 +558,10 @@
"mouse_jiggler_light": "Luz - 5m",
"mouse_jiggler_standard": "Estándar - 1 m",
"mouse_jiggler_title": "Jiggler",
"mouse_mode_absolute": "Absoluto",
"mouse_mode_absolute_description": "Lo más conveniente",
"mouse_mode_relative": "Relativo",
"mouse_mode_absolute": "Absoluto",
"mouse_mode_relative_description": "Más compatible",
"mouse_mode_relative": "Relativo",
"mouse_modes_description": "Elija el modo de entrada del mouse",
"mouse_modes_title": "Modos",
"mouse_scroll_high": "Alto",
@ -575,17 +574,12 @@
"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": "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_confirm": "Renovar el contrato de arrendamiento",
"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",
@ -594,35 +588,21 @@
"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",
@ -632,19 +612,9 @@
"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_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_save_settings": "Guardar configuración",
"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",
@ -670,8 +640,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": "Pegar texto",
"paste_modal_paste_text_description": "Pegue el texto de su cliente al host remoto",
"paste_modal_paste_text": "Pegar texto",
"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",
@ -698,20 +668,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...",
@ -721,8 +691,8 @@
"settings_back_to_kvm": "Volver a KVM",
"settings_general": "General",
"settings_hardware": "Hardware",
"settings_keyboard": "Teclado",
"settings_keyboard_macros": "Macros del teclado",
"settings_keyboard": "Teclado",
"settings_mouse": "Ratón",
"settings_network": "Red",
"settings_video": "Video",
@ -742,7 +712,6 @@
"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",
@ -789,6 +758,7 @@
"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)",
@ -855,7 +825,6 @@
"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",
@ -871,6 +840,7 @@
"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",
"welcome_to_jetkvm": "Bienvenido a JetKVM",
"welcome_to_jetkvm_description": "Controla cualquier computadora de forma remota"
"wake_on_lan": "Activación en LAN",
"welcome_to_jetkvm_description": "Controla cualquier computadora de forma remota",
"welcome_to_jetkvm": "Bienvenido a JetKVM"
}

View File

@ -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_connect_to_cloud": "Connectez votre JetKVM au cloud",
"auth_authentication_mode": "Veuillez sélectionner un mode d'authentification",
"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_mode_local": "Méthode d'authentification locale",
"auth_login": "Connectez-vous à votre compte JetKVM",
"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": "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_no_password": "Pas de 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": "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_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_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é.",
"back": "Dos",
"auth_signup_create_account": "Créez votre compte JetKVM",
"back_to_devices": "Retour aux appareils",
"back": "Dos",
"cancel": "Annuler",
"close": "Fermer",
"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é.",
"cloud_kvms_no_devices": "Aucun appareil trouvé",
"cloud_kvms": "KVM Cloud",
"confirm": "Confirmer",
"connect_to_kvm": "Se connecter à KVM",
"connecting_to_device": "Connexion à l'appareil…",
"connection_established": "Connexion établie",
"connection_stats_badge_jitter": "Gigue",
"connection_stats_badge_jitter_buffer_avg_delay": "Délai moyen du tampon de gigue",
"connection_stats_connection": "Connexion",
"connection_stats_badge_jitter": "Gigue",
"connection_stats_connection_description": "La connexion entre le client et le JetKVM.",
"connection_stats_frames_per_second": "Images par seconde",
"connection_stats_connection": "Connexion",
"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_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_packets_lost": "Paquets perdus",
"connection_stats_network_stability": "Stabilité du réseau",
"connection_stats_packets_lost_description": "Nombre de paquets vidéo RTP entrants perdus.",
"connection_stats_playback_delay": "Délai de lecture",
"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_round_trip_time": "Temps de trajet aller-retour",
"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_round_trip_time": "Temps de trajet aller-retour",
"connection_stats_sidebar": "Statistiques de connexion",
"connection_stats_video": "Vidéo",
"connection_stats_video_description": "Le flux vidéo du JetKVM vers le client.",
"connection_stats_video": "Vidéo",
"continue": "Continuer",
"creating_peer_connection": "Créer des liens entre pairs…",
"dc_power_control_current": "Actuel",
"dc_power_control_current_unit": "UN",
"dc_power_control_current": "Actuel",
"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": "Tension",
"dc_power_control_voltage_unit": "V",
"dc_power_control_voltage": "Tension",
"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": "Console série",
"extension_serial_console_description": "Accédez à votre extension de console série",
"extensions_atx_power_control": "Contrôle d'alimentation ATX",
"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.",
"extensions_dc_power_control": "Contrôle de l'alimentation CC",
"extensions_atx_power_control": "Contrôle d'alimentation ATX",
"extensions_dc_power_control_description": "Contrôlez votre extension d'alimentation CC",
"extensions_dc_power_control": "Contrôle de l'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": "Redémarrer l'appareil",
"general_reboot_device_description": "Redémarrez le JetKVM",
"general_reboot_device": "Redémarrer l'appareil",
"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,7 +327,6 @@
"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",
@ -410,8 +409,8 @@
"log_in": "Se connecter",
"log_out": "Se déconnecter",
"logged_in_as": "Connecté en tant que",
"login_enter_password": "Entrez votre mot de passe",
"login_enter_password_description": "Entrez votre mot de passe pour accéder à votre JetKVM.",
"login_enter_password": "Entrez votre mot de passe",
"login_error": "Une erreur s'est produite lors de la connexion",
"login_forgot_password": "Mot de passe oublié?",
"login_password_label": "Mot de passe",
@ -425,8 +424,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": "Enregistrer la macro",
"macro_save_error": "Une erreur s'est produite lors de l'enregistrement.",
"macro_save": "Enregistrer la macro",
"macro_step_count": "{steps} / {max} étapes",
"macro_step_duration_description": "Il est temps dattendre avant dexécuter létape suivante.",
"macro_step_duration_label": "Durée de l'étape",
@ -440,8 +439,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": "Ajouter une nouvelle macro",
"macros_add_new_macro": "Ajouter une nouvelle macro",
"macros_add_new": "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}",
@ -463,16 +462,16 @@
"macros_edit_button": "Modifier",
"macros_edit_description": "Modifiez votre macro de clavier",
"macros_edit_title": "Modifier la macro",
"macros_failed_create": "Échec de la création de la macro",
"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_create": "Échec de la création de la macro",
"macros_failed_delete_error": "Échec de la suppression de la macro : {error}",
"macros_failed_duplicate": "Échec de la duplication de la macro",
"macros_failed_delete": "Échec de la suppression de la macro",
"macros_failed_duplicate_error": "Échec de la duplication de la macro : {error}",
"macros_failed_reorder": "Échec de la réorganisation des macros",
"macros_failed_duplicate": "Échec de la duplication de la macro",
"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_reorder": "Échec de la réorganisation des macros",
"macros_failed_update_error": "Échec de la mise à jour de la macro : {error}",
"macros_failed_update": "Échec de la mise à jour de la macro",
"macros_invalid_data": "Données de macro non valides",
"macros_loading": "Chargement des macros…",
"macros_max_reached": "Max atteint",
@ -507,8 +506,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": "Support de stockage JetKVM",
"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_mode_cdrom": "CD/DVD",
"mount_mode_disk": "Disque",
"mount_mounted_as": "Monté comme",
@ -521,8 +520,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": "Démonter",
"mount_unmount_error": "Échec du démontage de l'image : {error}",
"mount_unmount": "Démonter",
"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",
@ -530,8 +529,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": "Téléchargement en cours…",
"mount_uploading_with_name": "Téléchargement de {name}",
"mount_uploading": "Téléchargement en cours…",
"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",
@ -539,10 +538,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": "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",
"mount_virtual_media_source": "Source de média virtuel",
"mount_virtual_media": "Médias virtuels",
"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",
@ -559,10 +558,10 @@
"mouse_jiggler_light": "Lumière - 5m",
"mouse_jiggler_standard": "Norme - 1 m",
"mouse_jiggler_title": "Jiggler",
"mouse_mode_absolute": "Absolu",
"mouse_mode_absolute_description": "Le plus pratique",
"mouse_mode_relative": "Relatif",
"mouse_mode_absolute": "Absolu",
"mouse_mode_relative_description": "Le plus compatible",
"mouse_mode_relative": "Relatif",
"mouse_modes_description": "Choisissez le mode de saisie de la souris",
"mouse_modes_title": "Modes",
"mouse_scroll_high": "Haut",
@ -575,17 +574,12 @@
"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": "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_confirm": "Renouveler le bail",
"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",
@ -594,35 +588,21 @@
"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",
@ -632,19 +612,9 @@
"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_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_save_settings": "Enregistrer les paramètres",
"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",
@ -670,8 +640,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": "Coller du texte",
"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_sending_using_layout": "Envoi de texte à l'aide de la disposition du clavier : {iso} - {name}",
"peer_connection_closed": "Fermé",
"peer_connection_closing": "Clôture",
@ -698,20 +668,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...",
@ -721,8 +691,8 @@
"settings_back_to_kvm": "Retour à KVM",
"settings_general": "Général",
"settings_hardware": "Matériel",
"settings_keyboard": "Clavier",
"settings_keyboard_macros": "Macros de clavier",
"settings_keyboard": "Clavier",
"settings_mouse": "Souris",
"settings_network": "Réseau",
"settings_video": "Vidéo",
@ -742,7 +712,6 @@
"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",
@ -789,6 +758,7 @@
"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)",
@ -855,7 +825,6 @@
"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",
@ -871,6 +840,7 @@
"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",
"welcome_to_jetkvm": "Bienvenue chez JetKVM",
"welcome_to_jetkvm_description": "Contrôlez n'importe quel ordinateur à distance"
"wake_on_lan": "Wake On LAN",
"welcome_to_jetkvm_description": "Contrôlez n'importe quel ordinateur à distance",
"welcome_to_jetkvm": "Bienvenue chez JetKVM"
}

View File

@ -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_connect_to_cloud": "Collega il tuo JetKVM al cloud",
"auth_authentication_mode": "Seleziona una modalità di autenticazione",
"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_mode_local": "Metodo di autenticazione locale",
"auth_login": "Accedi al tuo account JetKVM",
"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": "Nessuna password",
"auth_mode_local_no_password_description": "Accesso rapido senza autenticazione tramite password.",
"auth_mode_local_password": "Password",
"auth_mode_local_no_password": "Nessuna 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": "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_note": "Questa password verrà utilizzata per proteggere i dati del tuo dispositivo e proteggerli da accessi non autorizzati.",
"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à.",
"back": "Indietro",
"auth_signup_create_account": "Crea il tuo account JetKVM",
"back_to_devices": "Torna ai dispositivi",
"back": "Indietro",
"cancel": "Cancellare",
"close": "Vicino",
"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.",
"cloud_kvms_no_devices": "Nessun dispositivo trovato",
"cloud_kvms": "KVM cloud",
"confirm": "Confermare",
"connect_to_kvm": "Connettiti a KVM",
"connecting_to_device": "Connessione al dispositivo…",
"connection_established": "Connessione stabilita",
"connection_stats_badge_jitter": "tremolio",
"connection_stats_badge_jitter_buffer_avg_delay": "Ritardo medio del buffer di jitter",
"connection_stats_connection": "Connessione",
"connection_stats_badge_jitter": "tremolio",
"connection_stats_connection_description": "La connessione tra il client e JetKVM.",
"connection_stats_frames_per_second": "Fotogrammi al secondo",
"connection_stats_connection": "Connessione",
"connection_stats_frames_per_second_description": "Numero di fotogrammi video in entrata visualizzati al secondo.",
"connection_stats_network_stability": "Stabilità della rete",
"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_packets_lost": "Pacchetti persi",
"connection_stats_network_stability": "Stabilità della rete",
"connection_stats_packets_lost_description": "Conteggio dei pacchetti video RTP in entrata persi.",
"connection_stats_playback_delay": "Ritardo di riproduzione",
"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_round_trip_time": "Tempo di andata e ritorno",
"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_round_trip_time": "Tempo di andata e ritorno",
"connection_stats_sidebar": "Statistiche di connessione",
"connection_stats_video": "Video",
"connection_stats_video_description": "Il flusso video dal JetKVM al client.",
"connection_stats_video": "Video",
"continue": "Continuare",
"creating_peer_connection": "Creazione di una connessione tra pari…",
"dc_power_control_current": "Attuale",
"dc_power_control_current_unit": "UN",
"dc_power_control_current": "Attuale",
"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": "Voltaggio",
"dc_power_control_voltage_unit": "V",
"dc_power_control_voltage": "Voltaggio",
"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": "Console seriale",
"extension_serial_console_description": "Accedi all'estensione della tua console seriale",
"extensions_atx_power_control": "Controllo di potenza ATX",
"extension_serial_console": "Console seriale",
"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_atx_power_control": "Controllo di potenza ATX",
"extensions_dc_power_control_description": "Controlla la tua estensione di alimentazione CC",
"extensions_dc_power_control": "Controllo di potenza 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": "Riavvia il dispositivo",
"general_reboot_device_description": "Spegnere e riaccendere JetKVM",
"general_reboot_device": "Riavvia il dispositivo",
"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,7 +327,6 @@
"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",
@ -410,8 +409,8 @@
"log_in": "Login",
"log_out": "Disconnetti",
"logged_in_as": "Accedi come",
"login_enter_password": "Inserisci la tua password",
"login_enter_password_description": "Inserisci la tua password per accedere al tuo JetKVM.",
"login_enter_password": "Inserisci la tua password",
"login_error": "Si è verificato un errore durante l'accesso",
"login_forgot_password": "Ha dimenticato la password?",
"login_password_label": "Password",
@ -425,8 +424,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": "Salva macro",
"macro_save_error": "Si è verificato un errore durante il salvataggio.",
"macro_save": "Salva macro",
"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",
@ -440,8 +439,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": "Aggiungi nuova macro",
"macros_add_new_macro": "Aggiungi nuova macro",
"macros_add_new": "Aggiungi nuova macro",
"macros_aria_add_new": "Aggiungi nuova macro",
"macros_aria_delete": "Elimina macro {name}",
"macros_aria_duplicate": "Macro duplicata {name}",
@ -463,16 +462,16 @@
"macros_edit_button": "Modificare",
"macros_edit_description": "Modifica la macro della tastiera",
"macros_edit_title": "Modifica macro",
"macros_failed_create": "Impossibile creare la macro",
"macros_failed_create_error": "Impossibile creare la macro: {error}",
"macros_failed_delete": "Impossibile eliminare la macro",
"macros_failed_create": "Impossibile creare la macro",
"macros_failed_delete_error": "Impossibile eliminare la macro: {error}",
"macros_failed_duplicate": "Impossibile duplicare la macro",
"macros_failed_delete": "Impossibile eliminare la macro",
"macros_failed_duplicate_error": "Impossibile duplicare la macro: {error}",
"macros_failed_reorder": "Impossibile riordinare le macro",
"macros_failed_duplicate": "Impossibile duplicare la macro",
"macros_failed_reorder_error": "Impossibile riordinare le macro: {error}",
"macros_failed_update": "Impossibile aggiornare la macro",
"macros_failed_reorder": "Impossibile riordinare le macro",
"macros_failed_update_error": "Impossibile aggiornare la macro: {error}",
"macros_failed_update": "Impossibile aggiornare la macro",
"macros_invalid_data": "Dati macro non validi",
"macros_loading": "Caricamento macro in corso…",
"macros_max_reached": "Massimo raggiunto",
@ -507,8 +506,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": "Montaggio di archiviazione JetKVM",
"mount_jetkvm_storage_description": "Montare i file caricati in precedenza dall'archiviazione JetKVM",
"mount_jetkvm_storage": "Montaggio di archiviazione JetKVM",
"mount_mode_cdrom": "CD/DVD",
"mount_mode_disk": "Disco",
"mount_mounted_as": "Montato come",
@ -521,8 +520,8 @@
"mount_popular_images": "Immagini popolari",
"mount_streaming_from_url": "Streaming da URL",
"mount_supported_formats": "Formati supportati: ISO, IMG",
"mount_unmount": "Smontare",
"mount_unmount_error": "Impossibile smontare l'immagine: {error}",
"mount_unmount": "Smontare",
"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",
@ -530,8 +529,8 @@
"mount_upload_successful": "Caricamento riuscito",
"mount_upload_title": "Carica nuova immagine",
"mount_uploaded_has_been_uploaded": "{name} è stato caricato",
"mount_uploading": "Caricamento in corso…",
"mount_uploading_with_name": "Caricamento in corso {name}",
"mount_uploading": "Caricamento in corso…",
"mount_url_description": "Montare file da qualsiasi indirizzo web pubblico",
"mount_url_input_label": "URL dell'immagine",
"mount_url_mount": "Montaggio URL",
@ -539,10 +538,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": "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",
"mount_virtual_media_source": "Fonte multimediale virtuale",
"mount_virtual_media": "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",
@ -559,10 +558,10 @@
"mouse_jiggler_light": "Luce - 5m",
"mouse_jiggler_standard": "Standard - 1m",
"mouse_jiggler_title": "Jiggler",
"mouse_mode_absolute": "Assoluto",
"mouse_mode_absolute_description": "Il più conveniente",
"mouse_mode_relative": "Relativo",
"mouse_mode_absolute": "Assoluto",
"mouse_mode_relative_description": "Più compatibile",
"mouse_mode_relative": "Relativo",
"mouse_modes_description": "Scegli la modalità di input del mouse",
"mouse_modes_title": "Modalità",
"mouse_scroll_high": "Alto",
@ -575,17 +574,12 @@
"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": "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_confirm": "Rinnovare il contratto di locazione",
"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",
@ -594,35 +588,21 @@
"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",
@ -632,19 +612,9 @@
"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_settings_invalid_ipv4_cidr": "Notazione CIDR non valida per l'indirizzo IPv4",
"network_settings_load_error": "Impossibile caricare le impostazioni di rete: {error}",
"network_save_settings": "Salva impostazioni",
"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",
@ -670,8 +640,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": "Incolla il testo",
"paste_modal_paste_text_description": "Incolla il testo dal tuo client all'host remoto",
"paste_modal_paste_text": "Incolla il testo",
"paste_modal_sending_using_layout": "Invio di testo tramite layout di tastiera: {iso} - {name}",
"peer_connection_closed": "Chiuso",
"peer_connection_closing": "Chiusura",
@ -698,20 +668,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...",
@ -721,8 +691,8 @@
"settings_back_to_kvm": "Torna a KVM",
"settings_general": "Generale",
"settings_hardware": "Hardware",
"settings_keyboard": "Tastiera",
"settings_keyboard_macros": "Macro della tastiera",
"settings_keyboard": "Tastiera",
"settings_mouse": "Topo",
"settings_network": "Rete",
"settings_video": "Video",
@ -742,7 +712,6 @@
"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",
@ -789,6 +758,7 @@
"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)",
@ -855,7 +825,6 @@
"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",
@ -871,6 +840,7 @@
"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",
"welcome_to_jetkvm": "Benvenuti a JetKVM",
"welcome_to_jetkvm_description": "Controlla qualsiasi computer da remoto"
"wake_on_lan": "Wake On LAN",
"welcome_to_jetkvm_description": "Controlla qualsiasi computer da remoto",
"welcome_to_jetkvm": "Benvenuti a JetKVM"
}

View File

@ -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_connect_to_cloud": "Koble JetKVM-en din til skyen",
"auth_authentication_mode": "Vennligst velg en autentiseringsmodus",
"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_mode_local": "Lokal autentiseringsmetode",
"auth_login": "Logg inn på JetKVM-kontoen din",
"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": "Ikke noe passord",
"auth_mode_local_no_password_description": "Rask tilgang uten passordgodkjenning.",
"auth_mode_local_password": "Passord",
"auth_mode_local_no_password": "Ikke noe 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": "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_note": "Dette passordet vil bli brukt til å sikre enhetsdataene dine og beskytte mot uautorisert tilgang.",
"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.",
"back": "Tilbake",
"auth_signup_create_account": "Opprett JetKVM-kontoen din",
"back_to_devices": "Tilbake til Enheter",
"back": "Tilbake",
"cancel": "Kansellere",
"close": "Lukke",
"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å.",
"cloud_kvms_no_devices": "Ingen enheter funnet",
"cloud_kvms": "Cloud KVM-er",
"confirm": "Bekrefte",
"connect_to_kvm": "Koble til KVM",
"connecting_to_device": "Kobler til enhet …",
"connection_established": "Forbindelse opprettet",
"connection_stats_badge_jitter": "Jitter",
"connection_stats_badge_jitter_buffer_avg_delay": "Jitterbuffer Gjns. forsinkelse",
"connection_stats_connection": "Forbindelse",
"connection_stats_badge_jitter": "Jitter",
"connection_stats_connection_description": "Forbindelsen mellom klienten og JetKVM-en.",
"connection_stats_frames_per_second": "Bilder per sekund",
"connection_stats_connection": "Forbindelse",
"connection_stats_frames_per_second_description": "Antall innkommende videobilder som vises per sekund.",
"connection_stats_network_stability": "Nettverksstabilitet",
"connection_stats_frames_per_second": "Bilder per sekund",
"connection_stats_network_stability_description": "Hvor jevn flyten av innkommende videopakker er over nettverket.",
"connection_stats_packets_lost": "Pakker tapt",
"connection_stats_network_stability": "Nettverksstabilitet",
"connection_stats_packets_lost_description": "Antall tapte innkommende RTP-videopakker.",
"connection_stats_playback_delay": "Avspillingsforsinkelse",
"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_round_trip_time": "Tur-retur-tid",
"connection_stats_playback_delay": "Avspillingsforsinkelse",
"connection_stats_round_trip_time_description": "Rundturstid for det aktive ICE-kandidatparet mellom jevnaldrende.",
"connection_stats_round_trip_time": "Tur-retur-tid",
"connection_stats_sidebar": "Tilkoblingsstatistikk",
"connection_stats_video": "Video",
"connection_stats_video_description": "Videostrømmen fra JetKVM til klienten.",
"connection_stats_video": "Video",
"continue": "Fortsette",
"creating_peer_connection": "Oppretter kontakt med andre personer …",
"dc_power_control_current": "Nåværende",
"dc_power_control_current_unit": "EN",
"dc_power_control_current": "Nåværende",
"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": "Spenning",
"dc_power_control_voltage_unit": "V",
"dc_power_control_voltage": "Spenning",
"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": "Seriell konsoll",
"extension_serial_console_description": "Få tilgang til seriekonsollutvidelsen din",
"extensions_atx_power_control": "ATX-strømstyring",
"extension_serial_console": "Seriell konsoll",
"extensions_atx_power_control_description": "Kontroller maskinens strømstatus via ATX-strømkontroll.",
"extensions_dc_power_control": "DC-strømkontroll",
"extensions_atx_power_control": "ATX-strømstyring",
"extensions_dc_power_control_description": "Kontroller DC-strømutvidelsen din",
"extensions_dc_power_control": "DC-strømkontroll",
"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": "Start enheten på nytt",
"general_reboot_device_description": "Slå av og på JetKVM-en",
"general_reboot_device": "Start enheten på nytt",
"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,7 +327,6 @@
"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",
@ -410,8 +409,8 @@
"log_in": "Logg inn",
"log_out": "Logg ut",
"logged_in_as": "Logget inn som",
"login_enter_password": "Skriv inn passordet ditt",
"login_enter_password_description": "Skriv inn passordet ditt for å få tilgang til JetKVM-en din.",
"login_enter_password": "Skriv inn passordet ditt",
"login_error": "Det oppsto en feil under innlogging",
"login_forgot_password": "Glemt passord?",
"login_password_label": "Passord",
@ -425,8 +424,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": "Lagre makro",
"macro_save_error": "Det oppsto en feil under lagring.",
"macro_save": "Lagre makro",
"macro_step_count": "{steps} / {max} trinn",
"macro_step_duration_description": "Tid for å vente før man tar neste steg.",
"macro_step_duration_label": "Stegvarighet",
@ -440,8 +439,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": "Legg til ny makro",
"macros_add_new_macro": "Legg til ny makro",
"macros_add_new": "Legg til ny makro",
"macros_aria_add_new": "Legg til ny makro",
"macros_aria_delete": "Slett makro {name}",
"macros_aria_duplicate": "Duplikatmakro {name}",
@ -463,16 +462,16 @@
"macros_edit_button": "Redigere",
"macros_edit_description": "Endre tastaturmakroen din",
"macros_edit_title": "Rediger makro",
"macros_failed_create": "Kunne ikke opprette makroen",
"macros_failed_create_error": "Klarte ikke å opprette makro: {error}",
"macros_failed_delete": "Kunne ikke slette makroen",
"macros_failed_create": "Kunne ikke opprette makroen",
"macros_failed_delete_error": "Klarte ikke å slette makroen: {error}",
"macros_failed_duplicate": "Kunne ikke duplisere makroen",
"macros_failed_delete": "Kunne ikke slette makroen",
"macros_failed_duplicate_error": "Klarte ikke å duplisere makroen: {error}",
"macros_failed_reorder": "Kunne ikke endre rekkefølgen på makroene",
"macros_failed_duplicate": "Kunne ikke duplisere makroen",
"macros_failed_reorder_error": "Kunne ikke endre rekkefølgen på makroer: {error}",
"macros_failed_update": "Kunne ikke oppdatere makroen",
"macros_failed_reorder": "Kunne ikke endre rekkefølgen på makroene",
"macros_failed_update_error": "Kunne ikke oppdatere makroen: {error}",
"macros_failed_update": "Kunne ikke oppdatere makroen",
"macros_invalid_data": "Ugyldige makrodata",
"macros_loading": "Laster inn makroer …",
"macros_max_reached": "Maks nådd",
@ -507,8 +506,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": "JetKVM-lagringsmontering",
"mount_jetkvm_storage_description": "Monter tidligere opplastede filer fra JetKVM-lagringen",
"mount_jetkvm_storage": "JetKVM-lagringsmontering",
"mount_mode_cdrom": "CD/DVD",
"mount_mode_disk": "Disk",
"mount_mounted_as": "Montert som",
@ -521,8 +520,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": "Avmonter",
"mount_unmount_error": "Klarte ikke å demontere bildet: {error}",
"mount_unmount": "Avmonter",
"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",
@ -530,8 +529,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": "Laster opp…",
"mount_uploading_with_name": "Laster opp {name}",
"mount_uploading": "Laster opp…",
"mount_url_description": "Monter filer fra en hvilken som helst offentlig nettadresse",
"mount_url_input_label": "Bilde-URL",
"mount_url_mount": "URL-montering",
@ -539,10 +538,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": "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",
"mount_virtual_media_source": "Virtuell mediekilde",
"mount_virtual_media": "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",
@ -559,10 +558,10 @@
"mouse_jiggler_light": "Lys - 5m",
"mouse_jiggler_standard": "Standard - 1 m",
"mouse_jiggler_title": "Jiggler",
"mouse_mode_absolute": "Absolutt",
"mouse_mode_absolute_description": "Mest praktisk",
"mouse_mode_relative": "Slektning",
"mouse_mode_absolute": "Absolutt",
"mouse_mode_relative_description": "Mest kompatible",
"mouse_mode_relative": "Slektning",
"mouse_modes_description": "Velg museinndatamodus",
"mouse_modes_title": "Moduser",
"mouse_scroll_high": "Høy",
@ -575,17 +574,12 @@
"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": "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_confirm": "Forny leieavtalen",
"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",
@ -594,35 +588,21 @@
"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",
@ -632,19 +612,9 @@
"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_settings_invalid_ipv4_cidr": "Ugyldig CIDR-notasjon for IPv4-adresse",
"network_settings_load_error": "Kunne ikke laste inn nettverksinnstillinger: {error}",
"network_save_settings": "Lagre innstillinger",
"network_time_sync_description": "Konfigurer innstillinger for tidssynkronisering",
"network_time_sync_http_only": "Kun HTTP",
"network_time_sync_ntp_and_http": "NTP og HTTP",
@ -670,8 +640,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": "Lim inn tekst",
"paste_modal_paste_text_description": "Lim inn tekst fra klienten din til den eksterne verten",
"paste_modal_paste_text": "Lim inn tekst",
"paste_modal_sending_using_layout": "Sende tekst ved hjelp av tastaturoppsett: {iso} - {name}",
"peer_connection_closed": "Lukket",
"peer_connection_closing": "Lukking",
@ -698,20 +668,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...",
@ -721,8 +691,8 @@
"settings_back_to_kvm": "Tilbake til KVM",
"settings_general": "General",
"settings_hardware": "Maskinvare",
"settings_keyboard": "Tastatur",
"settings_keyboard_macros": "Tastaturmakroer",
"settings_keyboard": "Tastatur",
"settings_mouse": "Mus",
"settings_network": "Nettverk",
"settings_video": "Video",
@ -742,7 +712,6 @@
"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",
@ -789,6 +758,7 @@
"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)",
@ -855,7 +825,6 @@
"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",
@ -871,6 +840,7 @@
"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",
"welcome_to_jetkvm": "Velkommen til JetKVM",
"welcome_to_jetkvm_description": "Kontroller hvilken som helst datamaskin eksternt"
"wake_on_lan": "Vekk på LAN",
"welcome_to_jetkvm_description": "Kontroller hvilken som helst datamaskin eksternt",
"welcome_to_jetkvm": "Velkommen til JetKVM"
}

View File

@ -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_connect_to_cloud": "Anslut din JetKVM till molnet",
"auth_authentication_mode": "Välj ett autentiseringsläge",
"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_mode_local": "Lokal autentiseringsmetod",
"auth_login": "Logga in på ditt JetKVM-konto",
"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": "Inget lösenord",
"auth_mode_local_no_password_description": "Snabb åtkomst utan lösenordsautentisering.",
"auth_mode_local_password": "Lösenord",
"auth_mode_local_no_password": "Inget 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": "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_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_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.",
"back": "Tillbaka",
"auth_signup_create_account": "Skapa ditt JetKVM-konto",
"back_to_devices": "Tillbaka till Enheter",
"back": "Tillbaka",
"cancel": "Avboka",
"close": "Nära",
"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.",
"cloud_kvms_no_devices": "Inga enheter hittades",
"cloud_kvms": "Moln-KVM:er",
"confirm": "Bekräfta",
"connect_to_kvm": "Anslut till KVM",
"connecting_to_device": "Ansluter till enhet…",
"connection_established": "Anslutning upprättad",
"connection_stats_badge_jitter": "Jitter",
"connection_stats_badge_jitter_buffer_avg_delay": "Genomsnittlig fördröjning för jitterbuffert",
"connection_stats_connection": "Förbindelse",
"connection_stats_badge_jitter": "Jitter",
"connection_stats_connection_description": "Anslutningen mellan klienten och JetKVM:n.",
"connection_stats_frames_per_second": "Bildrutor per sekund",
"connection_stats_connection": "Förbindelse",
"connection_stats_frames_per_second_description": "Antal inkommande videobildrutor som visas per sekund.",
"connection_stats_network_stability": "Nätverksstabilitet",
"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_packets_lost": "Paket förlorade",
"connection_stats_network_stability": "Nätverksstabilitet",
"connection_stats_packets_lost_description": "Antal förlorade inkommande RTP-videopaket.",
"connection_stats_playback_delay": "Uppspelningsfördröjning",
"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_round_trip_time": "Tur- och returtid",
"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_round_trip_time": "Tur- och returtid",
"connection_stats_sidebar": "Anslutningsstatistik",
"connection_stats_video": "Video",
"connection_stats_video_description": "Videoströmmen från JetKVM till klienten.",
"connection_stats_video": "Video",
"continue": "Fortsätta",
"creating_peer_connection": "Skapar peer-kontakt…",
"dc_power_control_current": "Nuvarande",
"dc_power_control_current_unit": "En",
"dc_power_control_current": "Nuvarande",
"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": "Spänning",
"dc_power_control_voltage_unit": "V",
"dc_power_control_voltage": "Spänning",
"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": "Seriell konsol",
"extension_serial_console_description": "Åtkomst till din seriella konsoltillägg",
"extensions_atx_power_control": "ATX-strömkontroll",
"extension_serial_console": "Seriell konsol",
"extensions_atx_power_control_description": "Styr din maskins strömförsörjning via ATX-strömkontroll.",
"extensions_dc_power_control": "DC-strömstyrning",
"extensions_atx_power_control": "ATX-strömkontroll",
"extensions_dc_power_control_description": "Styr din DC-strömförlängning",
"extensions_dc_power_control": "DC-strömstyrning",
"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": "Starta om enheten",
"general_reboot_device_description": "Stäng av och på JetKVM:en",
"general_reboot_device": "Starta om enheten",
"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,7 +327,6 @@
"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",
@ -410,8 +409,8 @@
"log_in": "Logga in",
"log_out": "Logga ut",
"logged_in_as": "Inloggad som",
"login_enter_password": "Ange ditt lösenord",
"login_enter_password_description": "Ange ditt lösenord för att komma åt din JetKVM.",
"login_enter_password": "Ange ditt lösenord",
"login_error": "Ett fel uppstod vid inloggning",
"login_forgot_password": "Glömt lösenordet?",
"login_password_label": "Lösenord",
@ -425,8 +424,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": "Spara makro",
"macro_save_error": "Ett fel uppstod när dokumentet skulle sparas.",
"macro_save": "Spara makro",
"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",
@ -440,8 +439,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": "Lägg till nytt makro",
"macros_add_new_macro": "Lägg till nytt makro",
"macros_add_new": "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}",
@ -463,16 +462,16 @@
"macros_edit_button": "Redigera",
"macros_edit_description": "Ändra ditt tangentbordsmakro",
"macros_edit_title": "Redigera makro",
"macros_failed_create": "Misslyckades med att skapa makrot",
"macros_failed_create_error": "Misslyckades med att skapa makrot: {error}",
"macros_failed_delete": "Misslyckades med att ta bort makrot",
"macros_failed_create": "Misslyckades med att skapa makrot",
"macros_failed_delete_error": "Misslyckades med att ta bort makrot: {error}",
"macros_failed_duplicate": "Misslyckades med att duplicera makrot",
"macros_failed_delete": "Misslyckades med att ta bort makrot",
"macros_failed_duplicate_error": "Misslyckades med att duplicera makrot: {error}",
"macros_failed_reorder": "Misslyckades med att ändra ordningen på makrona",
"macros_failed_duplicate": "Misslyckades med att duplicera makrot",
"macros_failed_reorder_error": "Misslyckades med att ändra ordning på makron: {error}",
"macros_failed_update": "Misslyckades med att uppdatera makrot",
"macros_failed_reorder": "Misslyckades med att ändra ordningen på makrona",
"macros_failed_update_error": "Misslyckades med att uppdatera makrot: {error}",
"macros_failed_update": "Misslyckades med att uppdatera makrot",
"macros_invalid_data": "Ogiltig makrodata",
"macros_loading": "Läser in makron…",
"macros_max_reached": "Max uppnått",
@ -507,8 +506,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": "JetKVM-lagringsmontering",
"mount_jetkvm_storage_description": "Montera tidigare uppladdade filer från JetKVM-lagringen",
"mount_jetkvm_storage": "JetKVM-lagringsmontering",
"mount_mode_cdrom": "CD/DVD",
"mount_mode_disk": "Disk",
"mount_mounted_as": "Monterad som",
@ -521,8 +520,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": "Avmontera",
"mount_unmount_error": "Misslyckades med att avmontera bilden: {error}",
"mount_unmount": "Avmontera",
"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",
@ -530,8 +529,8 @@
"mount_upload_successful": "Uppladdningen lyckades",
"mount_upload_title": "Ladda upp ny bild",
"mount_uploaded_has_been_uploaded": "{name} har laddats upp",
"mount_uploading": "Laddar upp…",
"mount_uploading_with_name": "Laddar upp {name}",
"mount_uploading": "Laddar upp…",
"mount_url_description": "Montera filer från valfri offentlig webbadress",
"mount_url_input_label": "Bild-URL",
"mount_url_mount": "URL-montering",
@ -539,10 +538,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": "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",
"mount_virtual_media_source": "Virtuell mediekälla",
"mount_virtual_media": "Virtuella medier",
"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",
@ -559,10 +558,10 @@
"mouse_jiggler_light": "Ljus - 5m",
"mouse_jiggler_standard": "Standard - 1 m",
"mouse_jiggler_title": "Jiggler",
"mouse_mode_absolute": "Absolut",
"mouse_mode_absolute_description": "Mest bekvämt",
"mouse_mode_relative": "Relativ",
"mouse_mode_absolute": "Absolut",
"mouse_mode_relative_description": "Mest kompatibla",
"mouse_mode_relative": "Relativ",
"mouse_modes_description": "Välj musinmatningsläge",
"mouse_modes_title": "Lägen",
"mouse_scroll_high": "Hög",
@ -575,17 +574,12 @@
"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": "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_confirm": "Förnya hyresavtalet",
"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",
@ -594,35 +588,21 @@
"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",
@ -632,19 +612,9 @@
"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_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_save_settings": "Spara inställningar",
"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",
@ -670,8 +640,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": "Klistra in text",
"paste_modal_paste_text_description": "Klistra in text från din klient till fjärrdatorn",
"paste_modal_paste_text": "Klistra in text",
"paste_modal_sending_using_layout": "Skicka text med tangentbordslayout: {iso} - {name}",
"peer_connection_closed": "Stängd",
"peer_connection_closing": "Stängning",
@ -698,20 +668,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...",
@ -721,8 +691,8 @@
"settings_back_to_kvm": "Tillbaka till KVM",
"settings_general": "Allmän",
"settings_hardware": "Hårdvara",
"settings_keyboard": "Tangentbord",
"settings_keyboard_macros": "Tangentbordsmakron",
"settings_keyboard": "Tangentbord",
"settings_mouse": "Mus",
"settings_network": "Nätverk",
"settings_video": "Video",
@ -742,7 +712,6 @@
"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",
@ -789,6 +758,7 @@
"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)",
@ -855,7 +825,6 @@
"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",
@ -871,6 +840,7 @@
"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",
"welcome_to_jetkvm": "Välkommen till JetKVM",
"welcome_to_jetkvm_description": "Styr vilken dator som helst på distans"
"wake_on_lan": "Vakna på LAN",
"welcome_to_jetkvm_description": "Styr vilken dator som helst på distans",
"welcome_to_jetkvm": "Välkommen till JetKVM"
}

View File

@ -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_connect_to_cloud": "将您的 JetKVM 连接到云端",
"auth_authentication_mode": "请选择身份验证方式",
"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_mode_local": "本地身份验证方法",
"auth_login": "登录您的 JetKVM 帐户",
"auth_mode_local_change_later": "您可以随时在设置中更改您的身份验证方法。",
"auth_mode_local_description": "选择您希望如何在本地保护您的 JetKVM 设备。",
"auth_mode_local_no_password": "没有密码",
"auth_mode_local_no_password_description": "无需密码验证即可快速访问。",
"auth_mode_local_password": "密码",
"auth_mode_local_no_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": "此密码将用于保护您的设备数据并防止未经授权的访问。",
"auth_mode_local_password_note_local": "所有数据都保留在您的本地设备上。",
"auth_mode_local_password_set": "设置密码",
"auth_mode_local_password_note": "此密码将用于保护您的设备数据并防止未经授权的访问。",
"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": "创建您的帐户并开始轻松管理您的设备。",
"back": "后退",
"auth_signup_create_account": "创建您的 JetKVM 帐户",
"back_to_devices": "返回设备",
"back": "后退",
"cancel": "取消",
"close": "关闭",
"cloud_kvms": "云 KVM",
"cloud_kvms_description": "管理您的云 KVM 并安全地连接到它们。",
"cloud_kvms_no_devices": "未找到设备",
"cloud_kvms_no_devices_description": "您还没有任何启用 JetKVM Cloud 的设备。",
"cloud_kvms_no_devices": "未找到设备",
"cloud_kvms": "云 KVM",
"confirm": "确认",
"connect_to_kvm": "连接到 KVM",
"connecting_to_device": "正在连接设备…",
"connection_established": "已建立连接",
"connection_stats_badge_jitter": "抖动",
"connection_stats_badge_jitter_buffer_avg_delay": "抖动缓冲区平均延迟",
"connection_stats_connection": "联系",
"connection_stats_badge_jitter": "抖动",
"connection_stats_connection_description": "客户端与JetKVM之间的连接。",
"connection_stats_frames_per_second": "每秒帧数",
"connection_stats_connection": "联系",
"connection_stats_frames_per_second_description": "每秒显示的入站视频帧数。",
"connection_stats_network_stability": "网络稳定性",
"connection_stats_frames_per_second": "每秒帧数",
"connection_stats_network_stability_description": "网络上传入的视频数据包的流动有多稳定。",
"connection_stats_packets_lost": "数据包丢失",
"connection_stats_network_stability": "网络稳定性",
"connection_stats_packets_lost_description": "丢失的入站视频 RTP 数据包的数量。",
"connection_stats_playback_delay": "播放延迟",
"connection_stats_packets_lost": "数据包丢失",
"connection_stats_playback_delay_description": "当帧不均匀到达时,抖动缓冲区添加延迟以平滑播放。",
"connection_stats_round_trip_time": "往返时间",
"connection_stats_playback_delay": "播放延迟",
"connection_stats_round_trip_time_description": "对等体之间活跃 ICE 候选对的往返时间。",
"connection_stats_round_trip_time": "往返时间",
"connection_stats_sidebar": "连接统计",
"connection_stats_video": "视频",
"connection_stats_video_description": "从 JetKVM 到客户端的视频流。",
"connection_stats_video": "视频",
"continue": "继续",
"creating_peer_connection": "正在创建对等连接...",
"dc_power_control_current": "电流",
"dc_power_control_current_unit": "A",
"dc_power_control_current": "电流",
"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": "电压",
"dc_power_control_voltage_unit": "V",
"dc_power_control_voltage": "电压",
"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": "串行控制台",
"extension_serial_console_description": "访问串行控制台扩展",
"extensions_atx_power_control": "ATX 电源控制",
"extension_serial_console": "串行控制台",
"extensions_atx_power_control_description": "通过 ATX 电源控制来控制机器的电源状态。",
"extensions_dc_power_control": "直流电源控制",
"extensions_atx_power_control": "ATX 电源控制",
"extensions_dc_power_control_description": "控制您的直流电源扩展",
"extensions_dc_power_control": "直流电源控制",
"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": "重启设备",
"general_reboot_device_description": "对 JetKVM 进行电源循环",
"general_reboot_device": "重启设备",
"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,7 +327,6 @@
"invalid_password": "密码无效",
"ip_address": "IP 地址",
"ipv6_address_label": "地址",
"ipv6_gateway": "网关",
"ipv6_information": "IPv6 信息",
"ipv6_link_local": "本地链路",
"ipv6_preferred_lifetime": "首选寿命",
@ -410,8 +409,8 @@
"log_in": "登录",
"log_out": "登出",
"logged_in_as": "登录身份",
"login_enter_password": "输入您的密码",
"login_enter_password_description": "输入您的密码以访问您的 JetKVM。",
"login_enter_password": "输入您的密码",
"login_error": "登录时发生错误",
"login_forgot_password": "忘记密码?",
"login_password_label": "密码",
@ -425,8 +424,8 @@
"macro_name_required": "姓名为必填项",
"macro_name_too_long": "名称必须少于 50 个字符",
"macro_please_fix_validation_errors": "请修复验证错误",
"macro_save": "保存宏",
"macro_save_error": "保存时发生错误。",
"macro_save": "保存宏",
"macro_step_count": "{steps} / {max}步骤",
"macro_step_duration_description": "执行下一步之前需要等待的时间。",
"macro_step_duration_label": "步骤持续时间",
@ -440,8 +439,8 @@
"macro_steps_description": "键/修饰键按顺序执行,每个步骤之间有延迟。",
"macro_steps_label": "步骤",
"macros_add_description": "创建新的键盘宏",
"macros_add_new": "添加新宏",
"macros_add_new_macro": "添加新宏",
"macros_add_new": "添加新宏",
"macros_aria_add_new": "添加新宏",
"macros_aria_delete": "删除宏{name}",
"macros_aria_duplicate": "重复宏{name}",
@ -463,16 +462,16 @@
"macros_edit_button": "编辑",
"macros_edit_description": "修改键盘宏",
"macros_edit_title": "编辑宏",
"macros_failed_create": "无法创建宏",
"macros_failed_create_error": "无法创建宏: {error}",
"macros_failed_delete": "删除宏失败",
"macros_failed_create": "无法创建宏",
"macros_failed_delete_error": "无法删除宏: {error}",
"macros_failed_duplicate": "复制宏失败",
"macros_failed_delete": "删除宏失败",
"macros_failed_duplicate_error": "无法复制宏: {error}",
"macros_failed_reorder": "无法重新排序宏",
"macros_failed_duplicate": "复制宏失败",
"macros_failed_reorder_error": "无法重新排序宏: {error}",
"macros_failed_update": "更新宏失败",
"macros_failed_reorder": "无法重新排序宏",
"macros_failed_update_error": "无法更新宏: {error}",
"macros_failed_update": "更新宏失败",
"macros_invalid_data": "无效的宏数据",
"macros_loading": "正在加载宏...",
"macros_max_reached": "已达到最大值",
@ -507,8 +506,8 @@
"mount_error_list_storage": "列出存储文件时出错: {error}",
"mount_error_title": "安装错误",
"mount_get_state_error": "无法获取虚拟媒体状态: {error}",
"mount_jetkvm_storage": "JetKVM 存储支架",
"mount_jetkvm_storage_description": "从 JetKVM 存储挂载之前上传的文件",
"mount_jetkvm_storage": "JetKVM 存储支架",
"mount_mode_cdrom": "CD/DVD",
"mount_mode_disk": "磁盘",
"mount_mounted_as": "安装为",
@ -521,8 +520,8 @@
"mount_popular_images": "热门图片",
"mount_streaming_from_url": "从 URL 流式传输",
"mount_supported_formats": "支持的格式ISO、IMG",
"mount_unmount": "卸载",
"mount_unmount_error": "无法卸载映像: {error}",
"mount_unmount": "卸载",
"mount_upload_description": "选择要上传到 JetKVM 存储的图像文件",
"mount_upload_error": "上传错误: {error}",
"mount_upload_failed_datachannel": "无法创建文件上传数据通道",
@ -530,8 +529,8 @@
"mount_upload_successful": "上传成功",
"mount_upload_title": "上传新图片",
"mount_uploaded_has_been_uploaded": "{name}已上传",
"mount_uploading": "正在上传…",
"mount_uploading_with_name": "正在上传{name}",
"mount_uploading": "正在上传…",
"mount_url_description": "从任何公共网址挂载文件",
"mount_url_input_label": "图片网址",
"mount_url_mount": "URL 挂载",
@ -539,10 +538,10 @@
"mount_view_device_title": "从 JetKVM 存储挂载",
"mount_view_url_description": "输入要挂载的镜像文件的 URL",
"mount_view_url_title": "从 URL 挂载",
"mount_virtual_media": "虚拟媒体",
"mount_virtual_media_description": "挂载映像以进行启动或安装操作系统。",
"mount_virtual_media_source": "虚拟媒体源",
"mount_virtual_media_source_description": "选择如何安装虚拟媒体",
"mount_virtual_media_source": "虚拟媒体源",
"mount_virtual_media": "虚拟媒体",
"mouse_alt_finger": "手指触摸屏幕",
"mouse_alt_mouse": "鼠标图标",
"mouse_description": "为您的设备配置光标行为和交互设置",
@ -559,10 +558,10 @@
"mouse_jiggler_light": "光 - 5米",
"mouse_jiggler_standard": "标准 - 1米",
"mouse_jiggler_title": "吉格勒",
"mouse_mode_absolute": "绝对",
"mouse_mode_absolute_description": "最方便",
"mouse_mode_relative": "相对的",
"mouse_mode_absolute": "绝对",
"mouse_mode_relative_description": "最兼容",
"mouse_mode_relative": "相对的",
"mouse_modes_description": "选择鼠标输入模式",
"mouse_modes_title": "模式",
"mouse_scroll_high": "高的",
@ -575,17 +574,12 @@
"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": "续订 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_confirm": "续租",
"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 提供",
@ -594,35 +588,21 @@
"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": "汽车",
@ -632,19 +612,9 @@
"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_settings_invalid_ipv4_cidr": "IPv4 地址的 CIDR 表示法无效",
"network_settings_load_error": "无法加载网络设置: {error}",
"network_save_settings": "保存设置",
"network_time_sync_description": "配置时间同步设置",
"network_time_sync_http_only": "仅 HTTP",
"network_time_sync_ntp_and_http": "NTP 和 HTTP",
@ -670,8 +640,8 @@
"paste_modal_failed_paste": "粘贴文本失败: {error}",
"paste_modal_invalid_chars_intro": "以下字符将不会被粘贴:",
"paste_modal_paste_from_host": "从主机粘贴",
"paste_modal_paste_text": "粘贴文本",
"paste_modal_paste_text_description": "将文本从客户端粘贴到远程主机",
"paste_modal_paste_text": "粘贴文本",
"paste_modal_sending_using_layout": "使用键盘布局发送文本: {iso} - {name}",
"peer_connection_closed": "关闭",
"peer_connection_closing": "结束语",
@ -698,20 +668,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": "正在设置与设备的连接...",
@ -721,8 +691,8 @@
"settings_back_to_kvm": "返回 KVM",
"settings_general": "一般的",
"settings_hardware": "硬件",
"settings_keyboard": "键盘",
"settings_keyboard_macros": "键盘宏",
"settings_keyboard": "键盘",
"settings_mouse": "老鼠",
"settings_network": "网络",
"settings_video": "视频",
@ -742,7 +712,6 @@
"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": "戴尔多媒体专业键盘",
@ -789,6 +758,7 @@
"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",
@ -855,7 +825,6 @@
"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 地址",
@ -871,6 +840,7 @@
"wake_on_lan_failed_send_magic": "发送魔术包失败",
"wake_on_lan_invalid_mac": "无效的 MAC 地址",
"wake_on_lan_magic_sent_success": "魔包发送成功",
"welcome_to_jetkvm": "欢迎来到 JetKVM",
"welcome_to_jetkvm_description": "远程控制任何计算机"
"wake_on_lan": "局域网唤醒",
"welcome_to_jetkvm_description": "远程控制任何计算机",
"welcome_to_jetkvm": "欢迎来到 JetKVM"
}

245
ui/package-lock.json generated
View File

@ -28,7 +28,6 @@
"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",
@ -79,7 +78,7 @@
"vite-tsconfig-paths": "^5.1.4"
},
"engines": {
"node": "^22.20.0"
"node": "^22.15.0"
}
},
"node_modules/@alloc/quick-lru": {
@ -356,9 +355,9 @@
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.25.11",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.11.tgz",
"integrity": "sha512-Xt1dOL13m8u0WE8iplx9Ibbm+hFAO0GsU2P34UNoDGvZYkY8ifSiy6Zuc1lYxfG7svWE2fzqCUmFp5HCn51gJg==",
"version": "0.25.10",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.10.tgz",
"integrity": "sha512-0NFWnA+7l41irNuaSVlLfgNT12caWJVLzp5eAVhZ0z1qpxbockccEt3s+149rE64VUI3Ml2zt8Nv5JVc4QXTsw==",
"cpu": [
"ppc64"
],
@ -372,9 +371,9 @@
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.25.11",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.11.tgz",
"integrity": "sha512-uoa7dU+Dt3HYsethkJ1k6Z9YdcHjTrSb5NUy66ZfZaSV8hEYGD5ZHbEMXnqLFlbBflLsl89Zke7CAdDJ4JI+Gg==",
"version": "0.25.10",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.10.tgz",
"integrity": "sha512-dQAxF1dW1C3zpeCDc5KqIYuZ1tgAdRXNoZP7vkBIRtKZPYe2xVr/d3SkirklCHudW1B45tGiUlz2pUWDfbDD4w==",
"cpu": [
"arm"
],
@ -388,9 +387,9 @@
}
},
"node_modules/@esbuild/android-arm64": {
"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==",
"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==",
"cpu": [
"arm64"
],
@ -404,9 +403,9 @@
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.25.11",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.11.tgz",
"integrity": "sha512-Sgiab4xBjPU1QoPEIqS3Xx+R2lezu0LKIEcYe6pftr56PqPygbB7+szVnzoShbx64MUupqoE0KyRlN7gezbl8g==",
"version": "0.25.10",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.10.tgz",
"integrity": "sha512-MiC9CWdPrfhibcXwr39p9ha1x0lZJ9KaVfvzA0Wxwz9ETX4v5CHfF09bx935nHlhi+MxhA63dKRRQLiVgSUtEg==",
"cpu": [
"x64"
],
@ -420,9 +419,9 @@
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.25.11",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.11.tgz",
"integrity": "sha512-VekY0PBCukppoQrycFxUqkCojnTQhdec0vevUL/EDOCnXd9LKWqD/bHwMPzigIJXPhC59Vd1WFIL57SKs2mg4w==",
"version": "0.25.10",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.10.tgz",
"integrity": "sha512-JC74bdXcQEpW9KkV326WpZZjLguSZ3DfS8wrrvPMHgQOIEIG/sPXEN/V8IssoJhbefLRcRqw6RQH2NnpdprtMA==",
"cpu": [
"arm64"
],
@ -436,9 +435,9 @@
}
},
"node_modules/@esbuild/darwin-x64": {
"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==",
"version": "0.25.10",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.10.tgz",
"integrity": "sha512-tguWg1olF6DGqzws97pKZ8G2L7Ig1vjDmGTwcTuYHbuU6TTjJe5FXbgs5C1BBzHbJ2bo1m3WkQDbWO2PvamRcg==",
"cpu": [
"x64"
],
@ -452,9 +451,9 @@
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.25.11",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.11.tgz",
"integrity": "sha512-CmKjrnayyTJF2eVuO//uSjl/K3KsMIeYeyN7FyDBjsR3lnSJHaXlVoAK8DZa7lXWChbuOk7NjAc7ygAwrnPBhA==",
"version": "0.25.10",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.10.tgz",
"integrity": "sha512-3ZioSQSg1HT2N05YxeJWYR+Libe3bREVSdWhEEgExWaDtyFbbXWb49QgPvFH8u03vUPX10JhJPcz7s9t9+boWg==",
"cpu": [
"arm64"
],
@ -468,9 +467,9 @@
}
},
"node_modules/@esbuild/freebsd-x64": {
"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==",
"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==",
"cpu": [
"x64"
],
@ -484,9 +483,9 @@
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.25.11",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.11.tgz",
"integrity": "sha512-TBMv6B4kCfrGJ8cUPo7vd6NECZH/8hPpBHHlYI3qzoYFvWu2AdTvZNuU/7hsbKWqu/COU7NIK12dHAAqBLLXgw==",
"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==",
"cpu": [
"arm"
],
@ -500,9 +499,9 @@
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.25.11",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.11.tgz",
"integrity": "sha512-Qr8AzcplUhGvdyUF08A1kHU3Vr2O88xxP0Tm8GcdVOUm25XYcMPp2YqSVHbLuXzYQMf9Bh/iKx7YPqECs6ffLA==",
"version": "0.25.10",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.10.tgz",
"integrity": "sha512-5luJWN6YKBsawd5f9i4+c+geYiVEw20FVW5x0v1kEMWNq8UctFjDiMATBxLvmmHA4bf7F6hTRaJgtghFr9iziQ==",
"cpu": [
"arm64"
],
@ -516,9 +515,9 @@
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.25.11",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.11.tgz",
"integrity": "sha512-TmnJg8BMGPehs5JKrCLqyWTVAvielc615jbkOirATQvWWB1NMXY77oLMzsUjRLa0+ngecEmDGqt5jiDC6bfvOw==",
"version": "0.25.10",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.10.tgz",
"integrity": "sha512-NrSCx2Kim3EnnWgS4Txn0QGt0Xipoumb6z6sUtl5bOEZIVKhzfyp/Lyw4C1DIYvzeW/5mWYPBFJU3a/8Yr75DQ==",
"cpu": [
"ia32"
],
@ -532,9 +531,9 @@
}
},
"node_modules/@esbuild/linux-loong64": {
"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==",
"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==",
"cpu": [
"loong64"
],
@ -548,9 +547,9 @@
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.25.11",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.11.tgz",
"integrity": "sha512-Osx1nALUJu4pU43o9OyjSCXokFkFbyzjXb6VhGIJZQ5JZi8ylCQ9/LFagolPsHtgw6himDSyb5ETSfmp4rpiKQ==",
"version": "0.25.10",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.10.tgz",
"integrity": "sha512-ab6eiuCwoMmYDyTnyptoKkVS3k8fy/1Uvq7Dj5czXI6DF2GqD2ToInBI0SHOp5/X1BdZ26RKc5+qjQNGRBelRA==",
"cpu": [
"mips64el"
],
@ -564,9 +563,9 @@
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.25.11",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.11.tgz",
"integrity": "sha512-nbLFgsQQEsBa8XSgSTSlrnBSrpoWh7ioFDUmwo158gIm5NNP+17IYmNWzaIzWmgCxq56vfr34xGkOcZ7jX6CPw==",
"version": "0.25.10",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.10.tgz",
"integrity": "sha512-NLinzzOgZQsGpsTkEbdJTCanwA5/wozN9dSgEl12haXJBzMTpssebuXR42bthOF3z7zXFWH1AmvWunUCkBE4EA==",
"cpu": [
"ppc64"
],
@ -580,9 +579,9 @@
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.25.11",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.11.tgz",
"integrity": "sha512-HfyAmqZi9uBAbgKYP1yGuI7tSREXwIb438q0nqvlpxAOs3XnZ8RsisRfmVsgV486NdjD7Mw2UrFSw51lzUk1ww==",
"version": "0.25.10",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.10.tgz",
"integrity": "sha512-FE557XdZDrtX8NMIeA8LBJX3dC2M8VGXwfrQWU7LB5SLOajfJIxmSdyL/gU1m64Zs9CBKvm4UAuBp5aJ8OgnrA==",
"cpu": [
"riscv64"
],
@ -596,9 +595,9 @@
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.25.11",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.11.tgz",
"integrity": "sha512-HjLqVgSSYnVXRisyfmzsH6mXqyvj0SA7pG5g+9W7ESgwA70AXYNpfKBqh1KbTxmQVaYxpzA/SvlB9oclGPbApw==",
"version": "0.25.10",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.10.tgz",
"integrity": "sha512-3BBSbgzuB9ajLoVZk0mGu+EHlBwkusRmeNYdqmznmMc9zGASFjSsxgkNsqmXugpPk00gJ0JNKh/97nxmjctdew==",
"cpu": [
"s390x"
],
@ -612,9 +611,9 @@
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.25.11",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.11.tgz",
"integrity": "sha512-HSFAT4+WYjIhrHxKBwGmOOSpphjYkcswF449j6EjsjbinTZbp8PJtjsVK1XFJStdzXdy/jaddAep2FGY+wyFAQ==",
"version": "0.25.10",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.10.tgz",
"integrity": "sha512-QSX81KhFoZGwenVyPoberggdW1nrQZSvfVDAIUXr3WqLRZGZqWk/P4T8p2SP+de2Sr5HPcvjhcJzEiulKgnxtA==",
"cpu": [
"x64"
],
@ -628,9 +627,9 @@
}
},
"node_modules/@esbuild/netbsd-arm64": {
"version": "0.25.11",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.11.tgz",
"integrity": "sha512-hr9Oxj1Fa4r04dNpWr3P8QKVVsjQhqrMSUzZzf+LZcYjZNqhA3IAfPQdEh1FLVUJSiu6sgAwp3OmwBfbFgG2Xg==",
"version": "0.25.10",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.10.tgz",
"integrity": "sha512-AKQM3gfYfSW8XRk8DdMCzaLUFB15dTrZfnX8WXQoOUpUBQ+NaAFCP1kPS/ykbbGYz7rxn0WS48/81l9hFl3u4A==",
"cpu": [
"arm64"
],
@ -644,9 +643,9 @@
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.25.11",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.11.tgz",
"integrity": "sha512-u7tKA+qbzBydyj0vgpu+5h5AeudxOAGncb8N6C9Kh1N4n7wU1Xw1JDApsRjpShRpXRQlJLb9wY28ELpwdPcZ7A==",
"version": "0.25.10",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.10.tgz",
"integrity": "sha512-7RTytDPGU6fek/hWuN9qQpeGPBZFfB4zZgcz2VK2Z5VpdUxEI8JKYsg3JfO0n/Z1E/6l05n0unDCNc4HnhQGig==",
"cpu": [
"x64"
],
@ -660,9 +659,9 @@
}
},
"node_modules/@esbuild/openbsd-arm64": {
"version": "0.25.11",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.11.tgz",
"integrity": "sha512-Qq6YHhayieor3DxFOoYM1q0q1uMFYb7cSpLD2qzDSvK1NAvqFi8Xgivv0cFC6J+hWVw2teCYltyy9/m/14ryHg==",
"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==",
"cpu": [
"arm64"
],
@ -676,9 +675,9 @@
}
},
"node_modules/@esbuild/openbsd-x64": {
"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==",
"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==",
"cpu": [
"x64"
],
@ -692,9 +691,9 @@
}
},
"node_modules/@esbuild/openharmony-arm64": {
"version": "0.25.11",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.11.tgz",
"integrity": "sha512-rOREuNIQgaiR+9QuNkbkxubbp8MSO9rONmwP5nKncnWJ9v5jQ4JxFnLu4zDSRPf3x4u+2VN4pM4RdyIzDty/wQ==",
"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==",
"cpu": [
"arm64"
],
@ -708,9 +707,9 @@
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.25.11",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.11.tgz",
"integrity": "sha512-nq2xdYaWxyg9DcIyXkZhcYulC6pQ2FuCgem3LI92IwMgIZ69KHeY8T4Y88pcwoLIjbed8n36CyKoYRDygNSGhA==",
"version": "0.25.10",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.10.tgz",
"integrity": "sha512-fswk3XT0Uf2pGJmOpDB7yknqhVkJQkAQOcW/ccVOtfx05LkbWOaRAtn5SaqXypeKQra1QaEa841PgrSL9ubSPQ==",
"cpu": [
"x64"
],
@ -724,9 +723,9 @@
}
},
"node_modules/@esbuild/win32-arm64": {
"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==",
"version": "0.25.10",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.10.tgz",
"integrity": "sha512-ah+9b59KDTSfpaCg6VdJoOQvKjI33nTaQr4UluQwW7aEwZQsbMCfTmfEO4VyewOxx4RaDT/xCy9ra2GPWmO7Kw==",
"cpu": [
"arm64"
],
@ -740,9 +739,9 @@
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.25.11",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.11.tgz",
"integrity": "sha512-3ukss6gb9XZ8TlRyJlgLn17ecsK4NSQTmdIXRASVsiS2sQ6zPPZklNJT5GR5tE/MUarymmy8kCEf5xPCNCqVOA==",
"version": "0.25.10",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.10.tgz",
"integrity": "sha512-QHPDbKkrGO8/cz9LKVnJU22HOi4pxZnZhhA2HYHez5Pz4JeffhDjf85E57Oyco163GnzNCVkZK0b/n4Y0UHcSw==",
"cpu": [
"ia32"
],
@ -756,9 +755,9 @@
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.25.11",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.11.tgz",
"integrity": "sha512-D7Hpz6A2L4hzsRpPaCYkQnGOotdUpDzSGRIv9I+1ITdHROSFUWW95ZPZWQmGka1Fg7W3zFJowyn9WGwMJ0+KPA==",
"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==",
"cpu": [
"x64"
],
@ -3686,9 +3685,9 @@
}
},
"node_modules/electron-to-chromium": {
"version": "1.5.237",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.237.tgz",
"integrity": "sha512-icUt1NvfhGLar5lSWH3tHNzablaA5js3HVHacQimfP8ViEBOQv+L7DKEuHdbTZ0SKCO1ogTJTIL1Gwk9S6Qvcg==",
"version": "1.5.235",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.235.tgz",
"integrity": "sha512-i/7ntLFwOdoHY7sgjlTIDo4Sl8EdoTjWIaKinYOVfC6bOp71bmwenyZthWHcasxgHDNWbWxvG9M3Ia116zIaYQ==",
"dev": true,
"license": "ISC"
},
@ -3887,9 +3886,9 @@
]
},
"node_modules/esbuild": {
"version": "0.25.11",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.11.tgz",
"integrity": "sha512-KohQwyzrKTQmhXDW1PjCv3Tyspn9n5GcY2RTDqeORIdIJY8yKIF7sTSopFmn/wpMPW4rdPXI0UE5LJLuq3bx0Q==",
"version": "0.25.10",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.10.tgz",
"integrity": "sha512-9RiGKvCwaqxO2owP61uQ4BgNborAQskMR6QusfWzQqv7AZOg5oGehdY2pRJMTKuwxd1IDBP4rSbI5lHzU7SMsQ==",
"hasInstallScript": true,
"license": "MIT",
"bin": {
@ -3899,32 +3898,32 @@
"node": ">=18"
},
"optionalDependencies": {
"@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"
"@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"
}
},
"node_modules/esbuild-wasm": {
@ -4232,9 +4231,9 @@
}
},
"node_modules/eslint-plugin-react-refresh": {
"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==",
"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==",
"dev": true,
"license": "MIT",
"peerDependencies": {
@ -6477,22 +6476,6 @@
"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",

View File

@ -4,7 +4,7 @@
"version": "2025.10.14.2130",
"type": "module",
"engines": {
"node": "^22.20.0"
"node": "^22.15.0"
},
"scripts": {
"dev": "./dev_device.sh",
@ -42,7 +42,6 @@
"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",

View File

@ -1,5 +1,8 @@
import { CloseButton } from "@headlessui/react";
import { LuCircleAlert, LuInfo, LuTriangleAlert } from "react-icons/lu";
import {
CheckCircleIcon,
ExclamationTriangleIcon,
InformationCircleIcon,
} from "@heroicons/react/24/outline";
import { m } from "@localizations/messages.js";
import { Button } from "@components/Button";
@ -22,23 +25,27 @@ interface ConfirmDialogProps {
const variantConfig = {
danger: {
icon: LuCircleAlert,
iconClass: "text-red-600 dark:text-red-400",
icon: ExclamationTriangleIcon,
iconClass: "text-red-600",
iconBgClass: "bg-red-100",
buttonTheme: "danger",
},
success: {
icon: LuCircleAlert,
iconClass: "text-emerald-600 dark:text-emerald-400",
icon: CheckCircleIcon,
iconClass: "text-green-600",
iconBgClass: "bg-green-100",
buttonTheme: "primary",
},
warning: {
icon: LuTriangleAlert,
iconClass: "text-amber-600 dark:text-amber-400",
buttonTheme: "primary",
icon: ExclamationTriangleIcon,
iconClass: "text-yellow-600",
iconBgClass: "bg-yellow-100",
buttonTheme: "lightDanger",
},
info: {
icon: LuInfo,
iconClass: "text-slate-700 dark:text-slate-300",
icon: InformationCircleIcon,
iconClass: "text-blue-600",
iconBgClass: "bg-blue-100",
buttonTheme: "primary",
},
} as Record<
@ -46,6 +53,7 @@ const variantConfig = {
{
icon: React.ElementType;
iconClass: string;
iconBgClass: string;
buttonTheme: "danger" | "primary" | "blank" | "light" | "lightDanger";
}
>;
@ -61,40 +69,38 @@ export function ConfirmDialog({
onConfirm,
isConfirming = false,
}: ConfirmDialogProps) {
const { icon: Icon, iconClass, buttonTheme } = variantConfig[variant];
const handleKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
if (e.key === "Escape") {
e.stopPropagation();
onClose();
}
};
const { icon: Icon, iconClass, iconBgClass, buttonTheme } = variantConfig[variant];
return (
<div onKeyDown={handleKeyDown}>
<Modal open={open} onClose={onClose}>
<div className="mx-auto max-w-md px-4 transition-all duration-300 ease-in-out">
<div className="pointer-events-auto relative w-full overflow-hidden rounded-lg border border-slate-200 bg-white shadow-sm transition-all dark:border-slate-800 dark:bg-slate-900">
<div className="p-6">
<div className="flex items-start gap-3.5">
<Icon aria-hidden="true" className={cx("size-[18px] shrink-0 mt-[2px]", iconClass)} />
<div className="flex-1 min-w-0 space-y-2">
<h2 className="font-semibold text-slate-950 dark:text-white">
<div className="mx-auto max-w-xl px-4 transition-all duration-300 ease-in-out">
<div className="pointer-events-auto relative w-full overflow-hidden rounded-lg bg-white p-6 text-left align-middle shadow-xl transition-all dark:bg-slate-800">
<div className="space-y-4">
<div className="sm:flex sm:items-start">
<div
className={cx(
"mx-auto flex size-12 shrink-0 items-center justify-center rounded-full sm:mx-0 sm:size-10",
iconBgClass,
)}
>
<Icon aria-hidden="true" className={cx("size-6", iconClass)} />
</div>
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">
<h2 className="text-lg leading-tight font-bold text-black dark:text-white">
{title}
</h2>
<div className="text-sm text-slate-700 dark:text-slate-300">
<div className="mt-2 text-sm leading-snug text-slate-600 dark:text-slate-400">
{description}
</div>
</div>
</div>
<div className="mt-6 flex justify-end gap-2">
<div className="flex justify-end gap-x-2">
{cancelText && (
<CloseButton as={Button} size="SM" theme="blank" text={cancelText} />
<Button size="SM" theme="blank" text={cancelText} onClick={onClose} />
)}
<Button
size="SM"
type="button"
theme={buttonTheme}
text={isConfirming ? `${confirmText}...` : confirmText}
onClick={onConfirm}
@ -105,6 +111,5 @@ export function ConfirmDialog({
</div>
</div>
</Modal>
</div>
);
}

View File

@ -6,48 +6,21 @@ 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 | null;
networkState: NetworkState;
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 text-black opacity-0 animation-duration-500 dark:text-white">
<div className="animate-fadeIn p-4 opacity-0 animation-duration-500 text-black 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 && (
@ -72,15 +45,13 @@ export default function DhcpLeaseCard({
</div>
)}
{networkState?.dhcp_lease?.dns_servers && (
{networkState?.dhcp_lease?.dns && (
<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_servers.map(dns => (
<div key={dns}>{dns}</div>
))}
{networkState?.dhcp_lease?.dns.map(dns => <div key={dns}>{dns}</div>)}
</span>
</div>
)}
@ -172,17 +143,6 @@ 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>
@ -233,14 +193,17 @@ 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>

View File

@ -1,25 +1,12 @@
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 | undefined;
networkState: NetworkState;
}) {
return (
<GridCard>
@ -30,6 +17,7 @@ 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()}
@ -38,22 +26,15 @@ 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"
@ -63,13 +44,7 @@ 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 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>
<span className="text-sm font-medium">{addr.address}</span>
</div>
{addr.valid_lifetime && (
@ -80,7 +55,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_applicable()}
{m.not_available()}
</span>
) : (
<LifeTimeLabel lifetime={`${addr.valid_lifetime}`} />
@ -88,7 +63,6 @@ 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">
@ -97,7 +71,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_applicable()}
{m.not_available()}
</span>
) : (
<LifeTimeLabel lifetime={`${addr.preferred_lifetime}`} />
@ -107,7 +81,8 @@ export default function Ipv6NetworkCard({
)}
</div>
</div>
))}
),
)}
</div>
)}
</div>

View File

@ -40,7 +40,7 @@ const basePresetDelays = [
];
const PRESET_DELAYS = basePresetDelays.map(delay => {
if (Number.parseInt(delay.value, 10) === DEFAULT_DELAY) {
if (parseInt(delay.value, 10) === DEFAULT_DELAY) {
return { ...delay, label: "Default" };
}
return delay;

View File

@ -3,19 +3,14 @@ import { ReactNode } from "react";
export function SettingsPageHeader({
title,
description,
action,
}: {
title: string | ReactNode;
description: string | ReactNode;
action?: ReactNode;
}) {
return (
<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="select-none">
<h2 className=" text-xl font-extrabold text-black dark:text-white">{title}</h2>
<div className="text-sm text-black dark:text-slate-300">{description}</div>
</div>
{action && <div className="">{action}</div>}
</div>
);
}

View File

@ -1,137 +0,0 @@
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>
);
}

View File

@ -1,117 +0,0 @@
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>
);
}

View File

@ -1,4 +1,4 @@
import React, { useEffect, useState, useRef } from "react";
import React from "react";
import { ExclamationTriangleIcon } from "@heroicons/react/24/solid";
import { ArrowPathIcon, ArrowRightIcon } from "@heroicons/react/16/solid";
import { motion, AnimatePresence } from "framer-motion";
@ -9,11 +9,6 @@ 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;
@ -393,184 +388,3 @@ 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&apos;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>
);
}

View File

@ -23,7 +23,7 @@ import { keys } from "@/keyboardMappings";
import notifications from "@/notifications";
import { m } from "@localizations/messages.js";
export default function WebRTCVideo({ hasConnectionIssues }: { hasConnectionIssues: boolean }) {
export default function WebRTCVideo() {
// Video and stream related refs and states
const videoElm = useRef<HTMLVideoElement>(null);
const { mediaStream, peerConnectionState } = useRTCStore();
@ -528,10 +528,9 @@ export default function WebRTCVideo({ hasConnectionIssues }: { hasConnectionIssu
"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":
@ -539,7 +538,7 @@ export default function WebRTCVideo({ hasConnectionIssues }: { hasConnectionIssu
},
)}
/>
{peerConnection?.connectionState == "connected" && !hasConnectionIssues && (
{peerConnection?.connectionState == "connected" && (
<div
style={{ animationDuration: "500ms" }}
className="animate-slideUpFade pointer-events-none absolute inset-0 flex items-center justify-center"

View File

@ -1,49 +0,0 @@
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 };
}

View File

@ -19,11 +19,6 @@ 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,
@ -74,11 +69,6 @@ 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 => ({
@ -92,8 +82,7 @@ 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 => {
@ -107,9 +96,6 @@ export const useUiStore = create<UIState>(set => ({
isAttachedVirtualKeyboardVisible: true,
setAttachedVirtualKeyboardVisibility: (enabled: boolean) =>
set({ isAttachedVirtualKeyboardVisible: enabled }),
rebootState: null,
setRebootState: state => set({ rebootState: state }),
}));
export interface RTCState {
@ -691,7 +677,6 @@ export interface DhcpLease {
timezone?: string;
routers?: string[];
dns?: string[];
dns_servers?: string[];
ntp_servers?: string[];
lpr_servers?: string[];
_time_servers?: string[];
@ -709,7 +694,6 @@ export interface DhcpLease {
message?: string;
tftp?: string;
bootfile?: string;
dhcp_client?: string;
}
export interface IPv6Address {
@ -718,15 +702,6 @@ 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 {
@ -737,9 +712,7 @@ 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;
@ -764,28 +737,12 @@ 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 {
dhcp_client: string;
hostname: string | null;
domain: string | null;
http_proxy: string | null;
hostname: string;
domain: string;
http_proxy: string;
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