Compare commits

..

19 Commits

Author SHA1 Message Date
Marc Brooks 29d1394af5
Update ui/src/components/popovers/PasteModal.tsx 2025-10-31 13:04:11 -05:00
Marc Brooks d58dcd9cc6
Reduce traffic during pastes
Suspend KeyDownMessages while processing a macro.
Make sure we don't emit huge debugging traces.
Allow 30 seconds for RPC to finish (not ideal)
Reduced default delay between keys (and allow as low as 0)
Move the HID keyboard descriptor LED state
as it seems to interfere with boot mode
Run paste/macros in background on their own queue and return a token for cancellation.
Fixed error in length check for macro key state.
Removed redundant clear operation.
Use Once instead of init()
Add a time limit for each message type/queue.
2025-10-31 13:04:10 -05:00
Marc Brooks 4b049c4b7c
Add iputils-ping to install dependencies script (#917)
The new dev_deploy.sh uses ping to check if we can see the JetKVM, but mcr.microsoft.com/devcontainers/go:1.25-trixie does not have ping installed.
2025-10-30 09:53:32 +01:00
Marc Brooks 7955ee9d35
Improves OTA update reporting and process (#838) 2025-10-29 23:10:23 +01:00
Adam Shiervani 1ce63664c0
fix: video quality (#913) 2025-10-29 16:11:07 +01:00
Adam Shiervani 4b6e796a0e
fix: ensure proper redirection and page reload (#909) 2025-10-29 02:04:58 +01:00
Adam Shiervani 79098d3546
feat: Enhance DHCP client timeout and retry logic (#908) 2025-10-28 18:50:29 +01:00
Adam Shiervani 50fc88aae1
bug: fix null pointer in wakeDisplay (#907) 2025-10-28 18:48:30 +01:00
Adam Shiervani 204909b49a
feat: Add connectivity checks, ensure killing of jetkvm process, and disable SSH host key verification (#905) 2025-10-28 07:11:16 +01:00
Marc Brooks b1c788cc5e
chore/Automatically sort language files after machine-translate run (#902)
This should ensure that newly added strings are kept in their correct location so later changes yield clean GIT diffs.
Also bumped a couple minor dependencies
2025-10-27 16:34:07 +01:00
Adam Shiervani 71fe95bf57
More UI translations (#899)
* Enhance localization for reboot messages in multiple languages, including descriptions for device rebooting, IP address changes, and reconnection prompts in Danish, German, English, Spanish, French, Italian, Norwegian, Swedish, and Chinese.

* Update Swedish localization strings

* Update German localization strings

* Update Spanish localization strings

* Update Danish localization strings

* Update French localization strings

* Update Italian localization strings

* Update Norwegian localization strings

* Update Chinese localization strings

* sort translation keys

---------

Co-authored-by: Adam Shiervani <adamshiervani@fastmail.com>
2025-10-27 16:33:58 +01:00
Adam Shiervani ce9f95b8c8
refactor: ota redirecting (#898)
* refactor: improve URL handling in RebootingOverlay component

* refactor: enhance redirect URL handling in TryUpdate function

* refactor: disable old ota rebooting method in new version

* refactor: streamline version retrieval logic and enhance error handling in useVersion hook

* refactor: rename to RedirectTo

* fix: force page reload when redirecting from reboot actions

* refactor: consolidate sleep utility and update usages across components

* refactor: update JsonRpcCallOptions to use maxAttempts and attemptTimeoutMs, implement exponential backoff for retries

---------

Co-authored-by: Adam Shiervani <adamshiervani@fastmail.com>
2025-10-27 16:21:11 +01:00
Marc Brooks 9a4d061034
Localize the client/browser UI with inlang paraglide-js (#864) 2025-10-23 14:27:29 +02:00
Aveline 2444817455
chore: disable sleep mode when detecting video format (#887)
Co-authored-by: Adam Shiervani <adam.shiervani@gmail.com>
Co-authored-by: Adam Shiervani <adamshiervani@fastmail.com>
2025-10-17 17:51:02 +02:00
Adam Shiervani 74e64f69a7
Add stale issues and PRs workflow (#890) 2025-10-16 16:21:37 +02:00
Adam Shiervani eb68c0ea5f
chore: add PR templates (feature, bug fix) (#889) 2025-10-16 15:51:56 +02:00
Aveline c775979ccb
feat: refactoring network stack (#878)
Co-authored-by: Adam Shiervani <adam.shiervani@gmail.com>
2025-10-15 18:32:58 +02:00
Adam Shiervani 403141c96a
refactor: safe Comboxbox onChange (#886) 2025-10-14 22:45:48 -05:00
Aveline cc9ff74276
feat: add HDMI sleep mode (#881) 2025-10-09 14:52:51 +02:00
226 changed files with 22226 additions and 4917 deletions

View File

@ -1,38 +1,41 @@
{
"name": "JetKVM docker devcontainer",
"image": "mcr.microsoft.com/devcontainers/go:1.25-trixie",
"features": {
"ghcr.io/devcontainers/features/node:1": {
// Should match what is defined in ui/package.json
"version": "22.19.0"
}
},
"mounts": [
"source=${localEnv:HOME}/.ssh,target=/home/vscode/.ssh,type=bind,consistency=cached"
],
"onCreateCommand": ".devcontainer/install-deps.sh",
"customizations": {
"vscode": {
"extensions": [
// coding styles
"chrislajoie.vscode-modelines",
"editorconfig.editorconfig",
// GitHub
"GitHub.vscode-pull-request-github",
"github.vscode-github-actions",
// Golang
"golang.go",
// C / C++
"ms-vscode.cpptools",
"ms-vscode.cpptools-extension-pack",
// CMake / Makefile
"ms-vscode.makefile-tools",
"ms-vscode.cmake-tools",
// Frontend
"esbenp.prettier-vscode",
"dbaeumer.vscode-eslint",
"bradlc.vscode-tailwindcss"
]
}
}
"name": "JetKVM docker devcontainer",
"image": "mcr.microsoft.com/devcontainers/go:1.25-trixie",
"features": {
"ghcr.io/devcontainers/features/node:1": {
// Should match what is defined in ui/package.json
"version": "22.20.0"
}
},
"mounts": [
"source=${localEnv:HOME}/.ssh,target=/home/vscode/.ssh,type=bind,consistency=cached"
],
"onCreateCommand": ".devcontainer/install-deps.sh",
"customizations": {
"vscode": {
"extensions": [
// coding styles
"chrislajoie.vscode-modelines",
"editorconfig.editorconfig",
// GitHub
"GitHub.vscode-pull-request-github",
"github.vscode-github-actions",
// Golang
"golang.go",
// C / C++
"ms-vscode.cpptools",
"ms-vscode.cpptools-extension-pack",
// CMake / Makefile
"ms-vscode.makefile-tools",
"ms-vscode.cmake-tools",
// Frontend
"esbenp.prettier-vscode",
"dbaeumer.vscode-eslint",
"bradlc.vscode-tailwindcss",
"codeandstuff.package-json-upgrade",
// Localization
"inlang.vs-code-extension"
]
}
}
}

View File

@ -14,6 +14,7 @@ set -ex
export DEBIAN_FRONTEND=noninteractive
sudo apt-get update && \
sudo apt-get install -y --no-install-recommends \
iputils-ping \
build-essential \
device-tree-compiler \
gperf g++-multilib gcc-multilib \

View File

@ -1,19 +1,50 @@
{
"name": "JetKVM podman devcontainer",
"image": "mcr.microsoft.com/devcontainers/go:1.25-trixie",
"features": {
"ghcr.io/devcontainers/features/node:1": {
// Should match what is defined in ui/package.json
"version": "22.19.0"
}
},
"runArgs": [
"--userns=keep-id",
"--security-opt=label=disable",
"--security-opt=label=nested"
],
"containerUser": "vscode",
"containerEnv": {
"HOME": "/home/vscode"
}
"name": "JetKVM podman devcontainer",
"image": "mcr.microsoft.com/devcontainers/go:1.25-trixie",
"features": {
"ghcr.io/devcontainers/features/node:1": {
// Should match what is defined in ui/package.json
"version": "22.20.0"
}
},
"runArgs": [
"--userns=keep-id",
"--security-opt=label=disable",
"--security-opt=label=nested"
],
"containerUser": "vscode",
"containerEnv": {
"HOME": "/home/vscode"
},
"mounts": [
"source=${localEnv:HOME}/.ssh,target=/home/vscode/.ssh,type=bind,consistency=cached"
],
"onCreateCommand": ".devcontainer/install-deps.sh",
"customizations": {
"vscode": {
"extensions": [
// coding styles
"chrislajoie.vscode-modelines",
"editorconfig.editorconfig",
// GitHub
"GitHub.vscode-pull-request-github",
"github.vscode-github-actions",
// Golang
"golang.go",
// C / C++
"ms-vscode.cpptools",
"ms-vscode.cpptools-extension-pack",
// CMake / Makefile
"ms-vscode.makefile-tools",
"ms-vscode.cmake-tools",
// Frontend
"esbenp.prettier-vscode",
"dbaeumer.vscode-eslint",
"bradlc.vscode-tailwindcss",
"codeandstuff.package-json-upgrade",
// Localization
"inlang.vs-code-extension"
]
}
}
}

View File

@ -0,0 +1,9 @@
Fixes #<issue-number>
### Summary
- What changed and why in 13 sentences.
### Checklist
- [ ] Linked to issue(s) above by issue number (e.g. `Closes #<issue-number>`)
- [ ] One problem per PR (no unrelated changes)
- [ ] Lints pass; CI green

View File

@ -0,0 +1,17 @@
Closes #<issue-number>
### Summary
- What and why in 13 sentences.
### UI Changes
- Add before/after images or a short clip.
### Checklist
- [ ] Linked to issue(s) above by issue number (e.g. `Closes #<issue-number>`)
- [ ] One problem per PR (no unrelated changes)
- [ ] Lints pass; CI green
- [ ] Tricky parts are commented in code
- [ ] Backward compatible with existing device firmware (See `DEVELOPMENT.md` for details)

100
.github/workflows/stale-issues.yml vendored Normal file
View File

@ -0,0 +1,100 @@
name: Close stale issues and PRs (dry-run)
on:
schedule:
- cron: '30 1 * * *' # Runs daily at 01:30 UTC
workflow_dispatch: # Allow manual runs from the Actions tab
permissions:
issues: write
pull-requests: write
jobs:
stale:
runs-on: ubuntu-latest
steps:
- uses: actions/stale@v10
with:
# ──────────────────────────────────────────────────────────────────────
# OVERVIEW — HOW THIS WORKS
# 1) The job scans issues/PRs once per run.
# 2) If an item has had no activity for `days-before-stale`, its labeled `Stale`
# and receives the relevant “stale” comment.
# 3) If theres still no activity for `days-before-close` AFTER being marked
# stale, the item is closed and receives a closing comment.
# 4) Any new activity (comment, label change, commit, edit) clears `Stale`
# and resets the timers if `remove-stale-when-updated` is true.
# ──────────────────────────────────────────────────────────────────────
# ── TIMING / BEHAVIOR ────────────────────────────────────────────────
# Number of idle days before applying the `Stale` label + stale comment.
days-before-stale: 60
# Number of days AFTER staling with no activity before auto-closing.
# (Measured from when `Stale` was added.) Set to -1 to never auto-close.
days-before-close: 14
# If someone comments/updates, automatically remove `Stale` and reset timers.
remove-stale-when-updated: true
# Dont nag draft PRs; they are explicitly a work-in-progress stage.
exempt-draft-pr: true
# Fetch ordering when scanning items. `updated` helps focus on the most recently touched.
sort-by: updated
# ── MESSAGES (markdown) ──────────────────────────────────────────────
stale-issue-message: |
**This issue has been inactive for 60 days and is now marked as stale.**
To keep the tracker focused, older inactive issues are flagged.
If this still applies:
- Add a comment with **reproduction steps**, **environment details**, and **JetKVM version**.
- Verify whether it still occurs with the current build: see [OTA / Updates](https://jetkvm.com/docs/advanced-usage/ota-updates).
- Any new comment or update will remove the *Stale* label automatically.
Issues not updated within 14 days after being marked stale may be closed.
stale-pr-message: |
**This pull request has been inactive for 60 days and is now marked as stale.**
To continue:
- Push a commit or add a comment about next steps — this removes the *Stale* label automatically.
- Ensure the changes work with the current build: see [OTA / Updates](https://jetkvm.com/docs/advanced-usage/ota-updates).
- If this is blocked or awaiting review, mention that for visibility.
PRs not updated within 14 days after being marked stale may be closed.
close-issue-message: |
**Closing this issue due to extended inactivity.**
It has been 14 days since it was marked as stale without further updates.
If the problem persists:
- Reopen this issue, or open a new one with **reproduction steps**, **logs**, **environment**, and **JetKVM version**.
- Confirm behavior with the current build: [OTA / Updates](https://jetkvm.com/docs/advanced-usage/ota-updates).
close-pr-message: |
**Closing this pull request due to extended inactivity.**
It has been 14 days since it was marked as stale with no updates or commits.
If the changes are still relevant:
- Reopen this PR or submit a refreshed PR rebased on the latest code.
- Confirm that it builds and works with the current build: [OTA / Updates](https://jetkvm.com/docs/advanced-usage/ota-updates).
# ── SAFETY / ROLLOUT ────────────────────────────────────────────────
# DRY-RUN: log what would happen, but do NOT write labels/comments/close.
debug-only: true
# Print a summary of how many items were staled/closed (or would be, in dry-run).
enable-statistics: true
# Limit GitHub API operations per run (gentle start for large repos).
# Increase later (e.g., 2001000) once youre confident with behavior.
operations-per-run: 50
# ── LABELS ───────────────────────────────────────────────────────────
# Names of the labels applied when staling items. Defaults shown for clarity.
stale-issue-label: 'Stale'
stale-pr-label: 'Stale'

2
.gitignore vendored
View File

@ -13,3 +13,5 @@ node_modules
# generated during the build process
#internal/native/include
#internal/native/lib
ui/reports

25
.vscode/extensions.json vendored Normal file
View File

@ -0,0 +1,25 @@
{
"recommendations": [
// coding styles
"chrislajoie.vscode-modelines",
"editorconfig.editorconfig",
// GitHub
"GitHub.vscode-pull-request-github",
"github.vscode-github-actions",
// Golang
"golang.go",
// C / C++
"ms-vscode.cpptools",
"ms-vscode.cpptools-extension-pack",
// CMake / Makefile
"ms-vscode.makefile-tools",
"ms-vscode.cmake-tools",
// Frontend
"esbenp.prettier-vscode",
"dbaeumer.vscode-eslint",
"bradlc.vscode-tailwindcss",
"codeandstuff.package-json-upgrade",
// Localization
"inlang.vs-code-extension"
]
}

View File

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

View File

@ -1,23 +1,20 @@
<div align="center">
<img alt="JetKVM logo" src="https://jetkvm.com/logo-blue.png" height="28">
# JetKVM Development Guide
### Development Guide
<div align="center" width="100%">
<img src="https://jetkvm.com/logo-blue.png" align="center" height="28px">
[Discord](https://jetkvm.com/discord) | [Website](https://jetkvm.com) | [Issues](https://github.com/jetkvm/cloud-api/issues) | [Docs](https://jetkvm.com/docs)
[![Twitter](https://img.shields.io/twitter/url/https/twitter.com/jetkvm.svg?style=social&label=Follow%20%40JetKVM)](https://twitter.com/jetkvm)
[![Go Report Card](https://goreportcard.com/badge/github.com/jetkvm/kvm)](https://goreportcard.com/report/github.com/jetkvm/kvm)
</div>
# JetKVM Development Guide
Welcome to JetKVM development! This guide will help you get started quickly, whether you're fixing bugs, adding features, or just exploring the codebase.
## Get Started
### Prerequisites
- **A JetKVM device** (for full development)
- **[Go 1.24.4+](https://go.dev/doc/install)** and **[Node.js 22.15.0](https://nodejs.org/en/download/)**
- **[Git](https://git-scm.com/downloads)** for version control
@ -28,6 +25,7 @@ Welcome to JetKVM development! This guide will help you get started quickly, whe
**Recommended:** Development is best done on **Linux** or **macOS**.
If you're using Windows, we strongly recommend using **WSL (Windows Subsystem for Linux)** for the best development experience:
- [Install WSL on Windows](https://docs.microsoft.com/en-us/windows/wsl/install)
- [WSL Setup Guide](https://docs.microsoft.com/en-us/windows/wsl/setup/environment)
@ -36,12 +34,14 @@ This ensures compatibility with shell scripts and build tools used in the projec
### Project Setup
1. **Clone the repository:**
```bash
git clone https://github.com/jetkvm/kvm.git
cd kvm
```
2. **Check your tools:**
```bash
go version && node --version
```
@ -49,6 +49,7 @@ This ensures compatibility with shell scripts and build tools used in the projec
3. **Find your JetKVM IP address** (check your router or device screen)
4. **Deploy and test:**
```bash
./dev_deploy.sh -r 192.168.1.100 # Replace with your device IP
```
@ -95,40 +96,44 @@ tail -f /var/log/jetkvm.log
## Project Layout
```
```plaintext
/kvm/
├── main.go # App entry point
├── config.go # Settings & configuration
├── display.go # Device UI control
├── web.go # API endpoints
├── cmd/ # Command line main
├── internal/ # Internal Go packages
│ ├── confparser/ # Configuration file implementation
│ ├── hidrpc/ # HIDRPC implementation for HID devices (keyboard, mouse, etc.)
│ ├── logging/ # Logging implementation
│ ├── mdns/ # mDNS implementation
│ ├── native/ # CGO / Native code glue layer (on-device hardware)
│ │ ├── cgo/ # C files for the native library (HDMI, Touchscreen, etc.)
│ │ └── eez/ # EEZ Studio Project files (for Touchscreen)
│ ├── network/ # Network implementation
│ ├── timesync/ # Time sync/NTP implementation
│ ├── tzdata/ # Timezone data and generation
│ ├── udhcpc/ # DHCP implementation
│ ├── usbgadget/ # USB gadget
│ ├── utils/ # SSH handling
│ └── websecure/ # TLS certificate management
├── resource/ # netboot iso and other resources
├── scripts/ # Bash shell scripts for building and deploying
└── static/ # (react client build output)
└── ui/ # React frontend
├── public/ # UI website static images and fonts
└── src/ # Client React UI
├── assets/ # UI in-page images
├── components/ # UI components
├── hooks/ # Hooks (stores, RPC handling, virtual devices)
├── keyboardLayouts/ # Keyboard layout definitions
├── providers/ # Feature flags
└── routes/ # Pages (login, settings, etc.)
├── main.go # App entry point
├── config.go # Settings & configuration
├── display.go # Device UI control
├── web.go # API endpoints
├── cmd/ # Command line main
├── internal/ # Internal Go packages
│ ├── confparser/ # Configuration file implementation
│ ├── hidrpc/ # HIDRPC implementation for HID devices (keyboard, mouse, etc.)
│ ├── logging/ # Logging implementation
│ ├── mdns/ # mDNS implementation
│ ├── native/ # CGO / Native code glue layer (on-device hardware)
│ │ ├── cgo/ # C files for the native library (HDMI, Touchscreen, etc.)
│ │ └── eez/ # EEZ Studio Project files (for Touchscreen)
│ ├── network/ # Network implementation
│ ├── timesync/ # Time sync/NTP implementation
│ ├── tzdata/ # Timezone data and generation
│ ├── udhcpc/ # DHCP implementation
│ ├── usbgadget/ # USB gadget
│ ├── utils/ # SSH handling
│ └── websecure/ # TLS certificate management
├── resource/ # netboot iso and other resources
├── scripts/ # Bash shell scripts for building and deploying
└── static/ # (react client build output)
└── ui/ # React frontend
├── localization/ # Client UI localization (i18n)
│ ├── jetKVM.UI.inlang/ # Settings for inlang
│ └── messages/ # Messages localized
├── public/ # UI website static images and fonts
└── src/ # Client React UI
├── assets/ # UI in-page images
├── components/ # UI components
├── hooks/ # Hooks (stores, RPC handling, virtual devices)
├── keyboardLayouts/ # Keyboard layout definitions
├── paraglide/ # (localization compiled messages output)
├── providers/ # Feature flags
└── routes/ # Pages (login, settings, etc.)
```
**Key files for beginners:**
@ -144,7 +149,7 @@ tail -f /var/log/jetkvm.log
### Full Development (Recommended)
*Best for: Complete feature development*
#### _Best for: Complete feature development_
```bash
# Deploy everything to your JetKVM device
@ -153,7 +158,7 @@ tail -f /var/log/jetkvm.log
### Frontend Only
*Best for: UI changes without device*
#### _Best for: UI changes without device_
```bash
cd ui
@ -167,7 +172,7 @@ Please click the `Build` button in EEZ Studio then run `./dev_deploy.sh -r <YOUR
### Quick Backend Changes
*Best for: API or backend logic changes*
#### _Best for: API or backend logic changes_
```bash
# Skip frontend build for faster deployment
@ -272,6 +277,7 @@ npm install
### "Device UI Fails to Build"
If while trying to build you run into an error message similar to :
```plaintext
In file included from /workspaces/kvm/internal/native/cgo/ctrl.c:15:
/workspaces/kvm/internal/native/cgo/ui_index.h:4:10: fatal error: ui/ui.h: No such file or directory
@ -279,17 +285,21 @@ In file included from /workspaces/kvm/internal/native/cgo/ctrl.c:15:
^~~~~~~~~
compilation terminated.
```
This means that your system didn't create the directory-link to from _./internal/native/cgo/ui_ to ./internal/native/eez/src/ui when the repository was checked out. You can verify this is the case if _./internal/native/cgo/ui_ appears as a plain text file with only the textual contents:
```plaintext
../eez/src/ui
```
If this happens to you need to [enable git creation of symbolic links](https://stackoverflow.com/a/59761201/2076) either globally or for the KVM repository:
```bash
# Globally enable git to create symlinks
git config --global core.symlinks true
git restore internal/native/cgo/ui
```
```bash
# Enable git to create symlinks only in this project
git config core.symlinks true
@ -297,13 +307,15 @@ If this happens to you need to [enable git creation of symbolic links](https://s
```
Or if you want to manually create the symlink use:
```bash
# linux
cd internal/native/cgo
rm ui
ln -s ../eez/src/ui ui
```
```dos
```batch
rem Windows
cd internal/native/cgo
del ui
@ -326,6 +338,7 @@ Or if you want to manually create the symlink use:
- **Go:** Follow standard Go conventions
- **TypeScript:** Use TypeScript for type safety
- **React:** Keep components small and reusable
- **Localization:** Ensure all user-facing strings in the frontend are [localized](#localization)
### Environment Variables
@ -358,11 +371,12 @@ export JETKVM_PROXY_URL="ws://<IP>"
4. Test thoroughly
5. Submit a pull request
### Before submitting:
### Before submitting
- [ ] Code works on device
- [ ] Tests pass
- [ ] Code follows style guidelines
- [ ] Frontend user-facing strings [localized](#localization)
- [ ] Documentation updated (if needed)
---
@ -418,7 +432,6 @@ The application uses a JSON configuration file stored at `/userdata/kvm_config.j
3. **Add migration logic if needed for existing installations**
### LVGL Build
We modified the LVGL code a little bit to remove unused fonts and examples.
@ -429,6 +442,79 @@ git diff --cached --diff-filter=d > ../internal/native/cgo/lvgl-minify.patch &&
git diff --name-only --diff-filter=D --cached > ../internal/native/cgo/lvgl-minify.del
```
### Localization
The browser/client frontend uses the [paraglide-js](https://inlang.com/m/gerre34r/library-inlang-paraglideJs) plug-in from the [inlang.com](https://inlang.com/) project to allow compile-time validated localization of all user-facing UI strings in the browser/client UI. This includes `title`, `text`, `name`, `description`, `placeholder`, `label`, `aria-label`, _message attributes_ (such as `confirmText`, `unit`, `badge`, `tag`, or `flag`), HTML _element text_ (such as `<h?>`, `<span>`, or `<p>` elements), _notifications messages_, and option _label_ strings, etc.
We **do not** translate the console log messages, CSS class names, theme names, nor the various _value_ strings (e.g. for value/label pair options), nor URL routes.
The localizations are stored in _.json_ files in the `ui/localizations/messages` directory, with one language-per-file using the [ISO 3166-1 alpha-2 country code](https://en.wikipedia.org/wiki/ISO_3166-1_alpha-2) (e.g. en for English, de for German, etc.)
#### m-function-matcher
The translations are extracted into language files (e.g. _en.json_ for English) and then paraglide-js compiles them into helpers for use with the [m-function-matcher](https://inlang.com/m/632iow21/plugin-inlang-mFunctionMatcher). An example:
```tsx
<SettingsPageHeader
title={m.extensions_atx_power_control()}
description={m.extensions_atx_power_control_description()}
/>
```
#### shakespere plug-in
If you enable the [Sherlock](https://inlang.com/m/r7kp499g/app-inlang-ideExtension) plug-in, the localized text "tooltip" is shown in the VSCode editor after any localized text in the language you've selected for preview. In this image, it's the blue text at the end of the line :
![Showing the translation preview](https://github.com/user-attachments/assets/f6d6dae6-919f-4319-b7bf-500cb1fd458d)
#### Process
##### Localizing a UI
1. Locate a string that is visible to the end user on the client/browser
2. Assign that string a "key" that reflects the logical meaning of the string in snake-case (look at existing localizations for examples), for example if there's a string `This is a test` on the _thing edit page_ it would be "thing_edit_this_is_a_test"
```json
"thing_edit_this_is_a_test": "This is a test",
```
3. Add the key and string to the _en.json_ like this:
- **Note** if the string has replacement parameters (line a user-entered name), the syntax for the localized string has `{ }` around the replacement token (e.g. _This is your name: {name}_). An complex example:
```react
{m.mount_button_showing_results({
from: indexOfFirstFile + 1,
to: Math.min(indexOfLastFile, onStorageFiles.length),
total: onStorageFiles.length
})}
```
4. Save the _en.json_ file and execute `npm run i18n` to resort the language files, validate the translations, and create the m-functions
5. Edit the _.tsx_ file and replace the string with the calls to the new m-function which will be the key-string you chose in snake-case. For example `This is a test` in _thing edit page_ turns into `m.thing_edit_this_is_a_test()`
- **Note** if the string has a replacement token, supply that to the m-function, for example for the literal `I will call you {name}`, use `m.profile_i_will_call_you({ name: edit.value })`
6. When all your strings are extracted, run `npm run i18n:machine-translate` to get a first-stab at the translations for the other supported languages. Make sure you use an LLM (you can use [aifiesta](https://chat.aifiesta.ai/chat/) to use multiple LLMs) or a [translator](https://translate.google.com) of some form to back-translate each **new** machine-generation in each _language_ to ensure those terms translate reasonably.
### Adding a new language
1. Get the [ISO 3166-1 alpha-2 country code](https://en.wikipedia.org/wiki/ISO_3166-1_alpha-2) (for example AT for Austria)
2. Create a new file in the _ui/localization/messages_ directory (example _at.json_)
3. Add the new country code to the _ui/localizations/settings.json_ file in both the `"locales"` and the `"languageTags"` section (inlang and Sherlock aren't exactly current to each other, so we need it in both places).
4. That file also declares the baseLocale/sourceLanguageTag which is `"en"` because this project started out in English. Do NOT change that.
5. Run `npm run i18n:machine-translate` to do an initial pass at localizing all existing messages to the new language.
- **Note** you will get an error _DB has been closed_, ignore that message, we're not using a database.
- **Note** you likely will get errors while running this command due to rate limits and such (it uses anonymous Google Translate). Just keep running the command over and over... it'll translate a bunch each time until it says _Machine translate complete_
### Other notes
- Run `npm run i18n:validate` to ensure that language files and settings are well-formed.
- Run `npm run i18n:find-excess` to look for extra keys in other language files that have been deleted from the master-list in _en.json_.
- Run `npm run i18n:find-dupes` to look for multiple keys in _en.json_ that have the same translated value (this is normal)
- Run `npm run i18n:find-unused` to look for keys in _en.json_ that are not referenced in the UI anywhere.
- **Note** there are a few that are not currently used, only concern yourself with ones you obsoleted.
- Run `npm run i18n:audit` to do all the above checks.
- Using [inlang CLI](https://inlang.com/m/2qj2w8pu/app-inlang-cli) to support the npm commands.
- You can install the [Sherlock VS Code extension](https://marketplace.visualstudio.com/items?itemName=inlang.vs-code-extension) in your devcontainer.
---

View File

@ -12,7 +12,13 @@ BUILDKIT_FLAVOR := arm-rockchip830-linux-uclibcgnueabihf
BUILDKIT_PATH ?= /opt/jetkvm-native-buildkit
SKIP_NATIVE_IF_EXISTS ?= 0
SKIP_UI_BUILD ?= 0
ENABLE_SYNC_TRACE ?= 0
GO_BUILD_ARGS := -tags netgo,timetzdata,nomsgpack
ifeq ($(ENABLE_SYNC_TRACE), 1)
GO_BUILD_ARGS := $(GO_BUILD_ARGS),synctrace
endif
GO_RELEASE_BUILD_ARGS := -trimpath $(GO_BUILD_ARGS)
GO_LDFLAGS := \
-s -w \

View File

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

View File

@ -16,10 +16,10 @@ import (
)
const (
envChildID = "JETKVM_CHILD_ID"
errorDumpDir = "/userdata/jetkvm/"
errorDumpStateFile = ".has_error_dump"
errorDumpTemplate = "jetkvm-%s.log"
envChildID = "JETKVM_CHILD_ID"
errorDumpDir = "/userdata/jetkvm/crashdump"
errorDumpLastFile = "last-crash.log"
errorDumpTemplate = "jetkvm-%s.log"
)
func program() {
@ -117,30 +117,47 @@ func supervise() error {
return nil
}
func createErrorDump(logFile *os.File) {
logFile.Close()
// touch the error dump state file
if err := os.WriteFile(filepath.Join(errorDumpDir, errorDumpStateFile), []byte{}, 0644); err != nil {
return
}
fileName := fmt.Sprintf(errorDumpTemplate, time.Now().Format("20060102150405"))
filePath := filepath.Join(errorDumpDir, fileName)
if err := os.Rename(logFile.Name(), filePath); err == nil {
fmt.Printf("error dump created: %s\n", filePath)
return
}
fnSrc, err := os.Open(logFile.Name())
func isSymlinkTo(oldName, newName string) bool {
file, err := os.Stat(newName)
if err != nil {
return
return false
}
if file.Mode()&os.ModeSymlink != os.ModeSymlink {
return false
}
target, err := os.Readlink(newName)
if err != nil {
return false
}
return target == oldName
}
func ensureSymlink(oldName, newName string) error {
if isSymlinkTo(oldName, newName) {
return nil
}
_ = os.Remove(newName)
return os.Symlink(oldName, newName)
}
func renameFile(f *os.File, newName string) error {
_ = f.Close()
// try to rename the file first
if err := os.Rename(f.Name(), newName); err == nil {
return nil
}
// copy the log file to the error dump directory
fnSrc, err := os.Open(f.Name())
if err != nil {
return fmt.Errorf("failed to open file: %w", err)
}
defer fnSrc.Close()
fnDst, err := os.Create(filePath)
fnDst, err := os.Create(newName)
if err != nil {
return
return fmt.Errorf("failed to create file: %w", err)
}
defer fnDst.Close()
@ -148,18 +165,60 @@ func createErrorDump(logFile *os.File) {
for {
n, err := fnSrc.Read(buf)
if err != nil && err != io.EOF {
return
return fmt.Errorf("failed to read file: %w", err)
}
if n == 0 {
break
}
if _, err := fnDst.Write(buf[:n]); err != nil {
return
return fmt.Errorf("failed to write file: %w", err)
}
}
fmt.Printf("error dump created: %s\n", filePath)
return nil
}
func ensureErrorDumpDir() error {
// TODO: check if the directory is writable
f, err := os.Stat(errorDumpDir)
if err == nil && f.IsDir() {
return nil
}
if err := os.MkdirAll(errorDumpDir, 0755); err != nil {
return fmt.Errorf("failed to create error dump directory: %w", err)
}
return nil
}
func createErrorDump(logFile *os.File) {
fmt.Println()
fileName := fmt.Sprintf(
errorDumpTemplate,
time.Now().Format("20060102-150405"),
)
// check if the directory exists
if err := ensureErrorDumpDir(); err != nil {
fmt.Printf("failed to ensure error dump directory: %v\n", err)
return
}
filePath := filepath.Join(errorDumpDir, fileName)
if err := renameFile(logFile, filePath); err != nil {
fmt.Printf("failed to rename file: %v\n", err)
return
}
fmt.Printf("error dump copied: %s\n", filePath)
lastFilePath := filepath.Join(errorDumpDir, errorDumpLastFile)
if err := ensureSymlink(filePath, lastFilePath); err != nil {
fmt.Printf("failed to create symlink: %v\n", err)
return
}
}
func doSupervise() {

143
config.go
View File

@ -7,8 +7,9 @@ import (
"strconv"
"sync"
"github.com/jetkvm/kvm/internal/confparser"
"github.com/jetkvm/kvm/internal/logging"
"github.com/jetkvm/kvm/internal/network"
"github.com/jetkvm/kvm/internal/network/types"
"github.com/jetkvm/kvm/internal/usbgadget"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
@ -78,32 +79,34 @@ func (m *KeyboardMacro) Validate() error {
}
type Config struct {
CloudURL string `json:"cloud_url"`
CloudAppURL string `json:"cloud_app_url"`
CloudToken string `json:"cloud_token"`
GoogleIdentity string `json:"google_identity"`
JigglerEnabled bool `json:"jiggler_enabled"`
JigglerConfig *JigglerConfig `json:"jiggler_config"`
AutoUpdateEnabled bool `json:"auto_update_enabled"`
IncludePreRelease bool `json:"include_pre_release"`
HashedPassword string `json:"hashed_password"`
LocalAuthToken string `json:"local_auth_token"`
LocalAuthMode string `json:"localAuthMode"` //TODO: fix it with migration
LocalLoopbackOnly bool `json:"local_loopback_only"`
WakeOnLanDevices []WakeOnLanDevice `json:"wake_on_lan_devices"`
KeyboardMacros []KeyboardMacro `json:"keyboard_macros"`
KeyboardLayout string `json:"keyboard_layout"`
EdidString string `json:"hdmi_edid_string"`
ActiveExtension string `json:"active_extension"`
DisplayRotation string `json:"display_rotation"`
DisplayMaxBrightness int `json:"display_max_brightness"`
DisplayDimAfterSec int `json:"display_dim_after_sec"`
DisplayOffAfterSec int `json:"display_off_after_sec"`
TLSMode string `json:"tls_mode"` // options: "self-signed", "user-defined", ""
UsbConfig *usbgadget.Config `json:"usb_config"`
UsbDevices *usbgadget.Devices `json:"usb_devices"`
NetworkConfig *network.NetworkConfig `json:"network_config"`
DefaultLogLevel string `json:"default_log_level"`
CloudURL string `json:"cloud_url"`
CloudAppURL string `json:"cloud_app_url"`
CloudToken string `json:"cloud_token"`
GoogleIdentity string `json:"google_identity"`
JigglerEnabled bool `json:"jiggler_enabled"`
JigglerConfig *JigglerConfig `json:"jiggler_config"`
AutoUpdateEnabled bool `json:"auto_update_enabled"`
IncludePreRelease bool `json:"include_pre_release"`
HashedPassword string `json:"hashed_password"`
LocalAuthToken string `json:"local_auth_token"`
LocalAuthMode string `json:"localAuthMode"` //TODO: fix it with migration
LocalLoopbackOnly bool `json:"local_loopback_only"`
WakeOnLanDevices []WakeOnLanDevice `json:"wake_on_lan_devices"`
KeyboardMacros []KeyboardMacro `json:"keyboard_macros"`
KeyboardLayout string `json:"keyboard_layout"`
EdidString string `json:"hdmi_edid_string"`
ActiveExtension string `json:"active_extension"`
DisplayRotation string `json:"display_rotation"`
DisplayMaxBrightness int `json:"display_max_brightness"`
DisplayDimAfterSec int `json:"display_dim_after_sec"`
DisplayOffAfterSec int `json:"display_off_after_sec"`
TLSMode string `json:"tls_mode"` // options: "self-signed", "user-defined", ""
UsbConfig *usbgadget.Config `json:"usb_config"`
UsbDevices *usbgadget.Devices `json:"usb_devices"`
NetworkConfig *types.NetworkConfig `json:"network_config"`
DefaultLogLevel string `json:"default_log_level"`
VideoSleepAfterSec int `json:"video_sleep_after_sec"`
VideoQualityFactor float64 `json:"video_quality_factor"`
}
func (c *Config) GetDisplayRotation() uint16 {
@ -127,41 +130,56 @@ func (c *Config) SetDisplayRotation(rotation string) error {
const configPath = "/userdata/kvm_config.json"
var defaultConfig = &Config{
CloudURL: "https://api.jetkvm.com",
CloudAppURL: "https://app.jetkvm.com",
AutoUpdateEnabled: true, // Set a default value
ActiveExtension: "",
KeyboardMacros: []KeyboardMacro{},
DisplayRotation: "270",
KeyboardLayout: "en-US",
DisplayMaxBrightness: 64,
DisplayDimAfterSec: 120, // 2 minutes
DisplayOffAfterSec: 1800, // 30 minutes
JigglerEnabled: false,
// This is the "Standard" jiggler option in the UI
JigglerConfig: &JigglerConfig{
// it's a temporary solution to avoid sharing the same pointer
// we should migrate to a proper config solution in the future
var (
defaultJigglerConfig = JigglerConfig{
InactivityLimitSeconds: 60,
JitterPercentage: 25,
ScheduleCronTab: "0 * * * * *",
Timezone: "UTC",
},
TLSMode: "",
UsbConfig: &usbgadget.Config{
}
defaultUsbConfig = usbgadget.Config{
VendorId: "0x1d6b", //The Linux Foundation
ProductId: "0x0104", //Multifunction Composite Gadget
SerialNumber: "",
Manufacturer: "JetKVM",
Product: "USB Emulation Device",
},
UsbDevices: &usbgadget.Devices{
}
defaultUsbDevices = usbgadget.Devices{
AbsoluteMouse: true,
RelativeMouse: true,
Keyboard: true,
MassStorage: true,
},
NetworkConfig: &network.NetworkConfig{},
DefaultLogLevel: "INFO",
}
)
func getDefaultConfig() Config {
return Config{
CloudURL: "https://api.jetkvm.com",
CloudAppURL: "https://app.jetkvm.com",
AutoUpdateEnabled: true, // Set a default value
ActiveExtension: "",
KeyboardMacros: []KeyboardMacro{},
DisplayRotation: "270",
KeyboardLayout: "en-US",
DisplayMaxBrightness: 64,
DisplayDimAfterSec: 120, // 2 minutes
DisplayOffAfterSec: 1800, // 30 minutes
JigglerEnabled: false,
// This is the "Standard" jiggler option in the UI
JigglerConfig: func() *JigglerConfig { c := defaultJigglerConfig; return &c }(),
TLSMode: "",
UsbConfig: func() *usbgadget.Config { c := defaultUsbConfig; return &c }(),
UsbDevices: func() *usbgadget.Devices { c := defaultUsbDevices; return &c }(),
NetworkConfig: func() *types.NetworkConfig {
c := &types.NetworkConfig{}
_ = confparser.SetDefaultsAndValidate(c)
return c
}(),
DefaultLogLevel: "INFO",
VideoQualityFactor: 1.0,
}
}
var (
@ -194,7 +212,8 @@ func LoadConfig() {
}
// load the default config
config = defaultConfig
defaultConfig := getDefaultConfig()
config = &defaultConfig
file, err := os.Open(configPath)
if err != nil {
@ -206,7 +225,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)
@ -215,19 +234,19 @@ func LoadConfig() {
// merge the user config with the default config
if loadedConfig.UsbConfig == nil {
loadedConfig.UsbConfig = defaultConfig.UsbConfig
loadedConfig.UsbConfig = getDefaultConfig().UsbConfig
}
if loadedConfig.UsbDevices == nil {
loadedConfig.UsbDevices = defaultConfig.UsbDevices
loadedConfig.UsbDevices = getDefaultConfig().UsbDevices
}
if loadedConfig.NetworkConfig == nil {
loadedConfig.NetworkConfig = defaultConfig.NetworkConfig
loadedConfig.NetworkConfig = getDefaultConfig().NetworkConfig
}
if loadedConfig.JigglerConfig == nil {
loadedConfig.JigglerConfig = defaultConfig.JigglerConfig
loadedConfig.JigglerConfig = getDefaultConfig().JigglerConfig
}
// fixup old keyboard layout value
@ -246,17 +265,25 @@ func LoadConfig() {
}
func SaveConfig() error {
return saveConfig(configPath)
}
func SaveBackupConfig() error {
return saveConfig(configPath + ".bak")
}
func saveConfig(path string) error {
configLock.Lock()
defer configLock.Unlock()
logger.Trace().Str("path", configPath).Msg("Saving config")
logger.Trace().Str("path", path).Msg("Saving config")
// fixup old keyboard layout value
if config.KeyboardLayout == "en_US" {
config.KeyboardLayout = "en-US"
}
file, err := os.Create(configPath)
file, err := os.Create(path)
if err != nil {
return fmt.Errorf("failed to create config file: %w", err)
}
@ -272,7 +299,7 @@ func SaveConfig() error {
return fmt.Errorf("failed to wite config: %w", err)
}
logger.Info().Str("path", configPath).Msg("config saved")
logger.Info().Str("path", path).Msg("config saved")
return nil
}

View File

@ -27,7 +27,12 @@ const (
)
func switchToMainScreen() {
if networkState.IsUp() {
if networkManager == nil {
nativeInstance.SwitchToScreenIfDifferent("no_network_screen")
return
}
if networkManager.IsUp() {
nativeInstance.SwitchToScreenIfDifferent("home_screen")
} else {
nativeInstance.SwitchToScreenIfDifferent("no_network_screen")
@ -35,13 +40,21 @@ func switchToMainScreen() {
}
func updateDisplay() {
nativeInstance.UpdateLabelIfChanged("home_info_ipv4_addr", networkState.IPv4String())
nativeInstance.UpdateLabelAndChangeVisibility("home_info_ipv6_addr", networkState.IPv6String())
if networkManager != nil {
nativeInstance.UpdateLabelIfChanged("home_info_ipv4_addr", networkManager.IPv4String())
nativeInstance.UpdateLabelAndChangeVisibility("home_info_ipv6_addr", networkManager.IPv6String())
nativeInstance.UpdateLabelIfChanged("home_info_mac_addr", networkManager.MACString())
}
_, _ = nativeInstance.UIObjHide("menu_btn_network")
_, _ = nativeInstance.UIObjHide("menu_btn_access")
nativeInstance.UpdateLabelIfChanged("home_info_mac_addr", networkState.MACString())
switch config.NetworkConfig.DHCPClient.String {
case "jetdhcpc":
nativeInstance.UpdateLabelIfChanged("dhcp_client_change_label", "Change to udhcpc")
case "udhcpc":
nativeInstance.UpdateLabelIfChanged("dhcp_client_change_label", "Change to JetKVM")
}
if usbState == "configured" {
nativeInstance.UpdateLabelIfChanged("usb_status_label", "Connected")
@ -59,7 +72,7 @@ func updateDisplay() {
}
nativeInstance.UpdateLabelIfChanged("cloud_status_label", fmt.Sprintf("%d active", actionSessions))
if networkState.IsUp() {
if networkManager != nil && networkManager.IsUp() {
nativeInstance.UISetVar("main_screen", "home_screen")
nativeInstance.SwitchToScreenIf("home_screen", []string{"no_network_screen", "boot_screen"})
} else {
@ -175,7 +188,7 @@ func requestDisplayUpdate(shouldWakeDisplay bool, reason string) {
wakeDisplay(false, reason)
}
displayLogger.Debug().Msg("display updating")
//TODO: only run once regardless how many pending updates
// TODO: only run once regardless how many pending updates
updateDisplay()
}()
}
@ -184,13 +197,14 @@ func waitCtrlAndRequestDisplayUpdate(shouldWakeDisplay bool, reason string) {
waitDisplayUpdate.Lock()
defer waitDisplayUpdate.Unlock()
// nativeInstance.WaitCtrlClientConnected()
requestDisplayUpdate(shouldWakeDisplay, reason)
}
func updateStaticContents() {
//contents that never change
nativeInstance.UpdateLabelIfChanged("home_info_mac_addr", networkState.MACString())
if networkManager != nil {
nativeInstance.UpdateLabelIfChanged("home_info_mac_addr", networkManager.MACString())
}
// get cpu info
if cpuInfo, err := os.ReadFile("/proc/cpuinfo"); err == nil {
@ -291,11 +305,11 @@ func wakeDisplay(force bool, reason string) {
displayLogger.Warn().Err(err).Msg("failed to wake display")
}
if config.DisplayDimAfterSec != 0 {
if config.DisplayDimAfterSec != 0 && dimTicker != nil {
dimTicker.Reset(time.Duration(config.DisplayDimAfterSec) * time.Second)
}
if config.DisplayOffAfterSec != 0 {
if config.DisplayOffAfterSec != 0 && offTicker != nil {
offTicker.Reset(time.Duration(config.DisplayOffAfterSec) * time.Second)
}
backlightState = 0
@ -326,11 +340,8 @@ func startBacklightTickers() {
dimTicker = time.NewTicker(time.Duration(config.DisplayDimAfterSec) * time.Second)
go func() {
for { //nolint:staticcheck
select {
case <-dimTicker.C:
tick_displayDim()
}
for range dimTicker.C {
tick_displayDim()
}
}()
}
@ -340,11 +351,8 @@ func startBacklightTickers() {
offTicker = time.NewTicker(time.Duration(config.DisplayOffAfterSec) * time.Second)
go func() {
for { //nolint:staticcheck
select {
case <-offTicker.C:
tick_displayOff()
}
for range offTicker.C {
tick_displayOff()
}
}()
}

8
go.mod
View File

@ -16,6 +16,7 @@ require (
github.com/google/uuid v1.6.0
github.com/guregu/null/v6 v6.0.0
github.com/gwatts/rootcerts v0.0.0-20250901182336-dc5ae18bd79f
github.com/insomniacslk/dhcp v0.0.0-20250919081422-f80a1952f48e
github.com/pion/logging v0.2.4
github.com/pion/mdns/v2 v2.0.7
github.com/pion/webrtc/v4 v4.1.4
@ -54,15 +55,20 @@ require (
github.com/go-playground/validator/v10 v10.27.0 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/jonboulle/clockwork v0.5.0 // indirect
github.com/josharian/native v1.1.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mdlayher/ndp v1.1.0 // indirect
github.com/mdlayher/packet v1.1.2 // indirect
github.com/mdlayher/socket v0.4.1 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/pierrec/lz4/v4 v4.1.14 // indirect
github.com/pilebones/go-udev v0.9.1 // indirect
github.com/pion/datachannel v1.5.10 // indirect
github.com/pion/dtls/v3 v3.0.7 // indirect
@ -82,12 +88,14 @@ require (
github.com/robfig/cron/v3 v3.0.1 // indirect
github.com/rogpeppe/go-internal v1.14.1 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/u-root/uio v0.0.0-20230220225925-ffce2a382923 // indirect
github.com/ugorji/go/codec v1.3.0 // indirect
github.com/vishvananda/netns v0.0.5 // indirect
github.com/wlynxg/anet v0.0.5 // indirect
go.yaml.in/yaml/v2 v2.4.2 // indirect
golang.org/x/arch v0.20.0 // indirect
golang.org/x/oauth2 v0.30.0 // indirect
golang.org/x/sync v0.17.0 // indirect
golang.org/x/text v0.29.0 // indirect
google.golang.org/protobuf v1.36.9 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect

22
go.sum
View File

@ -66,8 +66,15 @@ github.com/guregu/null/v6 v6.0.0 h1:N14VRS+4di81i1PXRiprbQJ9EM9gqBa0+KVMeS/QSjQ=
github.com/guregu/null/v6 v6.0.0/go.mod h1:hrMIhIfrOZeLPZhROSn149tpw2gHkidAqxoXNyeX3iQ=
github.com/gwatts/rootcerts v0.0.0-20250901182336-dc5ae18bd79f h1:08t2PbrkDgW2+mwCQ3jhKUBrCM9Bc9SeH5j2Dst3B+0=
github.com/gwatts/rootcerts v0.0.0-20250901182336-dc5ae18bd79f/go.mod h1:5Kt9XkWvkGi2OHOq0QsGxebHmhCcqJ8KCbNg/a6+n+g=
github.com/hugelgupf/socketpair v0.0.0-20190730060125-05d35a94e714 h1:/jC7qQFrv8CrSJVmaolDVOxTfS9kc36uB6H40kdbQq8=
github.com/hugelgupf/socketpair v0.0.0-20190730060125-05d35a94e714/go.mod h1:2Goc3h8EklBH5mspfHFxBnEoURQCGzQQH1ga9Myjvis=
github.com/insomniacslk/dhcp v0.0.0-20250919081422-f80a1952f48e h1:nu5z6Kg+gMNW6tdqnVjg/QEJ8Nw71IJQqOtWj00XHEU=
github.com/insomniacslk/dhcp v0.0.0-20250919081422-f80a1952f48e/go.mod h1:qfvBmyDNp+/liLEYWRvqny/PEz9hGe2Dz833eXILSmo=
github.com/jonboulle/clockwork v0.5.0 h1:Hyh9A8u51kptdkR+cqRpT1EebBwTn1oK9YfGYbdFz6I=
github.com/jonboulle/clockwork v0.5.0/go.mod h1:3mZlmanh0g2NDKO5TWZVJAfofYk64M7XN3SzBPjZF60=
github.com/josharian/native v1.0.1-0.20221213033349-c1e37c09b531/go.mod h1:7X/raswPFr05uY3HiLlYeyQntB6OO7E/d2Cu7qoaN2w=
github.com/josharian/native v1.1.0 h1:uuaP0hAbW7Y4l0ZRQ6C9zfb7Mg1mbFKry/xzDAfmtLA=
github.com/josharian/native v1.1.0/go.mod h1:7X/raswPFr05uY3HiLlYeyQntB6OO7E/d2Cu7qoaN2w=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
@ -92,6 +99,12 @@ github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mdlayher/ndp v1.1.0 h1:QylGKGVtH60sKZUE88+IW5ila1Z/M9/OXhWdsVKuscs=
github.com/mdlayher/ndp v1.1.0/go.mod h1:FmgESgemgjl38vuOIyAHWUUL6vQKA/pQNkvXdWsdQFM=
github.com/mdlayher/packet v1.1.2 h1:3Up1NG6LZrsgDVn6X4L9Ge/iyRyxFEFD9o6Pr3Q1nQY=
github.com/mdlayher/packet v1.1.2/go.mod h1:GEu1+n9sG5VtiRE4SydOmX5GTwyyYlteZiFU+x0kew4=
github.com/mdlayher/socket v0.4.1 h1:eM9y2/jlbs1M615oshPQOHZzj6R6wMT7bX5NPiQvn2U=
github.com/mdlayher/socket v0.4.1/go.mod h1:cAqeGjoufqdxWkD7DkpyS+wcefOtmu5OQ8KuoJGIReA=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
@ -101,6 +114,8 @@ github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pierrec/lz4/v4 v4.1.14 h1:+fL8AQEZtz/ijeNnpduH0bROTu0O3NZAlPjQxGn8LwE=
github.com/pierrec/lz4/v4 v4.1.14/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
github.com/pilebones/go-udev v0.9.1 h1:uN72M1C1fgzhsVmBGEM8w9RD1JY4iVsPZpr+Z6rb3O8=
github.com/pilebones/go-udev v0.9.1/go.mod h1:Bgcl07crebF3JSeS4+nuaRvhWFdCeFoBhXXeAp93XNo=
github.com/pion/datachannel v1.5.10 h1:ly0Q26K1i6ZkGf42W7D4hQYR90pZwzFOjTq5AuCKk4o=
@ -161,6 +176,8 @@ github.com/sourcegraph/tf-dag v0.2.2-0.20250131204052-3e8ff1477b4f/go.mod h1:pzr
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
@ -169,6 +186,8 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/u-root/uio v0.0.0-20230220225925-ffce2a382923 h1:tHNk7XK9GkmKUR6Gh8gVBKXc2MVSZ4G/NnWLtzw4gNA=
github.com/u-root/uio v0.0.0-20230220225925-ffce2a382923/go.mod h1:eLL9Nub3yfAho7qB0MzZizFhTU2QkLeoVsWdHtDW264=
github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA=
github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
github.com/vearutop/statigz v1.5.0 h1:FuWwZiT82yBw4xbWdWIawiP2XFTyEPhIo8upRxiKLqk=
@ -193,6 +212,9 @@ golang.org/x/net v0.44.0 h1:evd8IRDyfNBMBTTY5XRF1vaZlD+EmWx6x8PkhR04H/I=
golang.org/x/net v0.44.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY=
golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20220622161953-175b2fd9d664/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=

32
hw.go
View File

@ -3,6 +3,7 @@ package kvm
import (
"fmt"
"os"
"os/exec"
"regexp"
"strings"
"sync"
@ -36,6 +37,37 @@ func readOtpEntropy() ([]byte, error) { //nolint:unused
return content[0x17:0x1C], nil
}
func hwReboot(force bool, postRebootAction *PostRebootAction, delay time.Duration) error {
logger.Info().Msgf("Reboot requested, rebooting in %d seconds...", delay)
writeJSONRPCEvent("willReboot", postRebootAction, currentSession)
time.Sleep(1 * time.Second) // Wait for the JSONRPCEvent to be sent
nativeInstance.SwitchToScreenIfDifferent("rebooting_screen")
time.Sleep(delay - (1 * time.Second)) // wait requested extra settle time
args := []string{}
if force {
args = append(args, "-f")
}
cmd := exec.Command("reboot", args...)
err := cmd.Start()
if err != nil {
logger.Error().Err(err).Msg("failed to reboot")
switchToMainScreen()
return fmt.Errorf("failed to reboot: %w", err)
}
// If the reboot command is successful, exit the program after 5 seconds
go func() {
time.Sleep(5 * time.Second)
os.Exit(0)
}()
return nil
}
var deviceID string
var deviceIDOnce sync.Once

View File

@ -16,22 +16,22 @@ import (
type FieldConfig struct {
Name string
Required bool
RequiredIf map[string]any
RequiredIf map[string]interface{}
OneOf []string
ValidateTypes []string
Defaults any
Defaults interface{}
IsEmpty bool
CurrentValue any
CurrentValue interface{}
TypeString string
Delegated bool
shouldUpdateValue bool
}
func SetDefaultsAndValidate(config any) error {
func SetDefaultsAndValidate(config interface{}) error {
return setDefaultsAndValidate(config, true)
}
func setDefaultsAndValidate(config any, isRoot bool) error {
func setDefaultsAndValidate(config interface{}, isRoot bool) error {
// first we need to check if the config is a pointer
if reflect.TypeOf(config).Kind() != reflect.Ptr {
return fmt.Errorf("config is not a pointer")
@ -55,7 +55,7 @@ func setDefaultsAndValidate(config any, isRoot bool) error {
Name: field.Name,
OneOf: splitString(field.Tag.Get("one_of")),
ValidateTypes: splitString(field.Tag.Get("validate_type")),
RequiredIf: make(map[string]any),
RequiredIf: make(map[string]interface{}),
CurrentValue: fieldValue.Interface(),
IsEmpty: false,
TypeString: fieldType,
@ -142,8 +142,8 @@ func setDefaultsAndValidate(config any, isRoot bool) error {
// now check if the field has required_if
requiredIf := field.Tag.Get("required_if")
if requiredIf != "" {
requiredIfParts := strings.SplitSeq(requiredIf, ",")
for part := range requiredIfParts {
requiredIfParts := strings.Split(requiredIf, ",")
for _, part := range requiredIfParts {
partVal := strings.SplitN(part, "=", 2)
if len(partVal) != 2 {
return fmt.Errorf("invalid required_if for field `%s`: %s", field.Name, requiredIf)
@ -168,7 +168,7 @@ func setDefaultsAndValidate(config any, isRoot bool) error {
return nil
}
func validateFields(config any, fields map[string]FieldConfig) error {
func validateFields(config interface{}, fields map[string]FieldConfig) error {
// now we can start to validate the fields
for _, fieldConfig := range fields {
if err := fieldConfig.validate(fields); err != nil {
@ -215,7 +215,7 @@ func (f *FieldConfig) validate(fields map[string]FieldConfig) error {
return nil
}
func (f *FieldConfig) populate(config any) {
func (f *FieldConfig) populate(config interface{}) {
// update the field if it's not empty
if !f.shouldUpdateValue {
return
@ -346,6 +346,17 @@ func (f *FieldConfig) validateField() error {
return nil
}
// Handle []string types, like dns servers, time sync ntp servers, etc.
if slice, ok := f.CurrentValue.([]string); ok {
for i, item := range slice {
if err := f.validateSingleValue(item, i); err != nil {
return err
}
}
return nil
}
// Handle single string types
val, err := toString(f.CurrentValue)
if err != nil {
return fmt.Errorf("field `%s` cannot use validate_type: %s", f.Name, err)
@ -355,30 +366,71 @@ func (f *FieldConfig) validateField() error {
return nil
}
return f.validateSingleValue(val, -1)
}
func (f *FieldConfig) validateSingleValue(val string, index int) error {
for _, validateType := range f.ValidateTypes {
var fieldRef string
if index >= 0 {
fieldRef = fmt.Sprintf("field `%s[%d]`", f.Name, index)
} else {
fieldRef = fmt.Sprintf("field `%s`", f.Name)
}
switch validateType {
case "int":
if _, err := strconv.Atoi(val); err != nil {
return fmt.Errorf("field `%s` is not a valid integer: %s", fieldRef, val)
}
case "ipv6_prefix_length":
valInt, err := strconv.Atoi(val)
if err != nil {
return fmt.Errorf("field `%s` is not a valid IPv6 prefix length: %s", fieldRef, val)
}
if valInt < 0 || valInt > 128 {
return fmt.Errorf("field `%s` is not a valid IPv6 prefix length: %s", fieldRef, val)
}
case "ipv4":
if net.ParseIP(val).To4() == nil {
return fmt.Errorf("field `%s` is not a valid IPv4 address: %s", f.Name, val)
return fmt.Errorf("field `%s` is not a valid IPv4 address: %s", fieldRef, val)
}
case "ipv6":
if net.ParseIP(val).To16() == nil {
return fmt.Errorf("field `%s` is not a valid IPv6 address: %s", f.Name, val)
return fmt.Errorf("field `%s` is not a valid IPv6 address: %s", fieldRef, val)
}
case "ipv6_prefix":
if i, _, err := net.ParseCIDR(val); err != nil {
if i.To16() == nil {
return fmt.Errorf("field `%s` is not a valid IPv6 prefix: %s", fieldRef, val)
}
}
case "ipv4_or_ipv6":
if net.ParseIP(val) == nil {
return fmt.Errorf("%s is not a valid IPv4 or IPv6 address: %s", fieldRef, val)
}
case "hwaddr":
if _, err := net.ParseMAC(val); err != nil {
return fmt.Errorf("field `%s` is not a valid MAC address: %s", f.Name, val)
return fmt.Errorf("%s is not a valid MAC address: %s", fieldRef, val)
}
case "hostname":
if _, err := idna.Lookup.ToASCII(val); err != nil {
return fmt.Errorf("field `%s` is not a valid hostname: %s", f.Name, val)
return fmt.Errorf("%s is not a valid hostname: %s", fieldRef, val)
}
case "proxy":
if url, err := url.Parse(val); err != nil || (url.Scheme != "http" && url.Scheme != "https") || url.Host == "" {
return fmt.Errorf("field `%s` is not a valid HTTP proxy URL: %s", f.Name, val)
return fmt.Errorf("%s is not a valid HTTP proxy URL: %s", fieldRef, val)
}
case "url":
if _, err := url.Parse(val); err != nil {
return fmt.Errorf("%s is not a valid URL: %s", fieldRef, val)
}
case "cidr":
if _, _, err := net.ParseCIDR(val); err != nil {
return fmt.Errorf("%s is not a valid CIDR notation: %s", fieldRef, val)
}
default:
return fmt.Errorf("field `%s` cannot use validate_type: unsupported validator: %s", f.Name, validateType)
return fmt.Errorf("field `%s` cannot use validate_type: unsupported validator: %s", fieldRef, validateType)
}
}

View File

@ -24,10 +24,10 @@ type testIPv4StaticConfig struct {
}
type testIPv6StaticConfig struct {
Address null.String `json:"address" validate_type:"ipv6" required:"true"`
Prefix null.String `json:"prefix" validate_type:"ipv6" required:"true"`
Gateway null.String `json:"gateway" validate_type:"ipv6" required:"true"`
DNS []string `json:"dns" validate_type:"ipv6" required:"true"`
Address null.String `json:"address" validate_type:"ipv6" required:"true"`
PrefixLength null.Int `json:"prefix_length" validate_type:"ipv6_prefix_length" required:"true"`
Gateway null.String `json:"gateway" validate_type:"ipv6" required:"true"`
DNS []string `json:"dns" validate_type:"ipv6" required:"true"`
}
type testNetworkConfig struct {
Hostname null.String `json:"hostname,omitempty"`
@ -39,7 +39,7 @@ type testNetworkConfig struct {
IPv6Mode null.String `json:"ipv6_mode" one_of:"slaac,dhcpv6,slaac_and_dhcpv6,static,link_local,disabled" default:"slaac"`
IPv6Static *testIPv6StaticConfig `json:"ipv6_static,omitempty" required_if:"IPv6Mode=static"`
LLDPMode null.String `json:"lldp_mode,omitempty" one_of:"disabled,rx_only,tx_only,enabled" default:"enabled"`
LLDPMode null.String `json:"lldp_mode,omitempty" one_of:"disabled,basic,all" default:"basic"`
LLDPTxTLVs []string `json:"lldp_tx_tlvs,omitempty" one_of:"chassis,port,system,vlan" default:"chassis,port,system,vlan"`
MDNSMode null.String `json:"mdns_mode,omitempty" one_of:"disabled,auto,ipv4_only,ipv6_only" default:"auto"`
TimeSyncMode null.String `json:"time_sync_mode,omitempty" one_of:"ntp_only,ntp_and_http,http_only,custom" default:"ntp_and_http"`

View File

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

View File

@ -146,14 +146,17 @@ func (m *MDNS) start(allowRestart bool) error {
return nil
}
// Start starts the mDNS server
func (m *MDNS) Start() error {
return m.start(false)
}
// Restart restarts the mDNS server
func (m *MDNS) Restart() error {
return m.start(true)
}
// Stop stops the mDNS server
func (m *MDNS) Stop() error {
m.lock.Lock()
defer m.lock.Unlock()
@ -165,26 +168,45 @@ func (m *MDNS) Stop() error {
return m.conn.Close()
}
func (m *MDNS) SetLocalNames(localNames []string, always bool) error {
if reflect.DeepEqual(m.localNames, localNames) && !always {
return nil
func (m *MDNS) setLocalNames(localNames []string) {
m.lock.Lock()
defer m.lock.Unlock()
if reflect.DeepEqual(m.localNames, localNames) {
return
}
m.localNames = localNames
_ = m.Restart()
return nil
}
func (m *MDNS) SetListenOptions(listenOptions *MDNSListenOptions) error {
func (m *MDNS) setListenOptions(listenOptions *MDNSListenOptions) {
m.lock.Lock()
defer m.lock.Unlock()
if m.listenOptions != nil &&
m.listenOptions.IPv4 == listenOptions.IPv4 &&
m.listenOptions.IPv6 == listenOptions.IPv6 {
return nil
return
}
m.listenOptions = listenOptions
_ = m.Restart()
return nil
}
// SetLocalNames sets the local names and restarts the mDNS server
func (m *MDNS) SetLocalNames(localNames []string) error {
m.setLocalNames(localNames)
return m.Restart()
}
// SetListenOptions sets the listen options and restarts the mDNS server
func (m *MDNS) SetListenOptions(listenOptions *MDNSListenOptions) error {
m.setListenOptions(listenOptions)
return m.Restart()
}
// SetOptions sets the local names and listen options and restarts the mDNS server
func (m *MDNS) SetOptions(options *MDNSOptions) error {
m.setLocalNames(options.LocalNames)
m.setListenOptions(options.ListenOptions)
return m.Restart()
}

View File

@ -368,7 +368,7 @@ void jetkvm_video_stop() {
}
int jetkvm_video_set_quality_factor(float quality_factor) {
if (quality_factor < 0 || quality_factor > 1) {
if (quality_factor <= 0 || quality_factor > 1) {
return -1;
}
video_set_quality_factor(quality_factor);
@ -405,8 +405,8 @@ char *jetkvm_video_log_status() {
return (char *)videoc_log_status();
}
int jetkvm_video_init() {
return video_init();
int jetkvm_video_init(float factor) {
return video_init(factor);
}
void jetkvm_video_shutdown() {

View File

@ -52,7 +52,7 @@ const char *jetkvm_ui_get_lvgl_version();
const char *jetkvm_ui_event_code_to_name(int code);
int jetkvm_video_init();
int jetkvm_video_init(float quality_factor);
void jetkvm_video_shutdown();
void jetkvm_video_start();
void jetkvm_video_stop();

View File

@ -29,6 +29,7 @@
#define VIDEO_DEV "/dev/video0"
#define SUB_DEV "/dev/v4l-subdev2"
#define SLEEP_MODE_FILE "/sys/devices/platform/ff470000.i2c/i2c-4/4-000f/sleep_mode"
#define RK_ALIGN(x, a) (((x) + (a)-1) & ~((a)-1))
#define RK_ALIGN_2(x) RK_ALIGN(x, 2)
@ -39,6 +40,7 @@ int sub_dev_fd = -1;
#define VENC_CHANNEL 0
MB_POOL memPool = MB_INVALID_POOLID;
bool sleep_mode_available = false;
bool should_exit = false;
float quality_factor = 1.0f;
@ -51,6 +53,45 @@ RK_U64 get_us()
return (RK_U64)time.tv_sec * 1000000 + (RK_U64)time.tv_nsec / 1000; /* microseconds */
}
static void ensure_sleep_mode_disabled()
{
if (!sleep_mode_available)
{
return;
}
int fd = open(SLEEP_MODE_FILE, O_RDWR);
if (fd < 0)
{
log_error("Failed to open sleep mode file: %s", strerror(errno));
return;
}
lseek(fd, 0, SEEK_SET);
char buffer[1];
read(fd, buffer, 1);
if (buffer[0] == '0') {
close(fd);
return;
}
log_warn("HDMI sleep mode is not disabled, disabling it");
lseek(fd, 0, SEEK_SET);
write(fd, "0", 1);
close(fd);
usleep(1000); // give some time to the system to disable the sleep mode
return;
}
static void detect_sleep_mode()
{
if (access(SLEEP_MODE_FILE, F_OK) != 0) {
sleep_mode_available = false;
return;
}
sleep_mode_available = true;
ensure_sleep_mode_disabled();
}
double calculate_bitrate(float bitrate_factor, int width, int height)
{
const int32_t base_bitrate_high = 2000;
@ -190,8 +231,15 @@ static int32_t buf_init()
pthread_t *format_thread = NULL;
int video_init()
int video_init(float factor)
{
detect_sleep_mode();
if (factor <= 0 || factor > 1) {
factor = 1.0f;
}
quality_factor = factor;
if (RK_MPI_SYS_Init() != RK_SUCCESS)
{
log_error("RK_MPI_SYS_Init failed");
@ -301,11 +349,29 @@ static void *venc_read_stream(void *arg)
}
uint32_t detected_width, detected_height;
bool detected_signal = false, streaming_flag = false;
bool detected_signal = false, streaming_flag = false, streaming_stopped = true;
pthread_t *streaming_thread = NULL;
pthread_mutex_t streaming_mutex = PTHREAD_MUTEX_INITIALIZER;
bool get_streaming_flag()
{
log_info("getting streaming flag");
pthread_mutex_lock(&streaming_mutex);
bool flag = streaming_flag;
pthread_mutex_unlock(&streaming_mutex);
return flag;
}
void set_streaming_flag(bool flag)
{
log_info("setting streaming flag to %d", flag);
pthread_mutex_lock(&streaming_mutex);
streaming_flag = flag;
pthread_mutex_unlock(&streaming_mutex);
}
void write_buffer_to_file(const uint8_t *buffer, size_t length, const char *filename)
{
FILE *file = fopen(filename, "wb");
@ -319,6 +385,8 @@ void *run_video_stream(void *arg)
log_info("running video stream");
streaming_stopped = false;
while (streaming_flag)
{
if (detected_signal == false)
@ -401,7 +469,7 @@ void *run_video_stream(void *arg)
{
log_error("get mb blk failed!");
close(video_dev_fd);
return ;
return (void *)errno;
}
log_info("Got memory block for buffer %d", i);
@ -538,6 +606,18 @@ void *run_video_stream(void *arg)
log_error("VIDIOC_STREAMOFF failed: %s", strerror(errno));
}
// Explicitly free V4L2 buffer queue
struct v4l2_requestbuffers req_free;
memset(&req_free, 0, sizeof(req_free));
req_free.count = 0;
req_free.type = V4L2_BUF_TYPE_VIDEO_CAPTURE_MPLANE;
req_free.memory = V4L2_MEMORY_DMABUF;
if (ioctl(video_dev_fd, VIDIOC_REQBUFS, &req_free) < 0)
{
log_error("Failed to free V4L2 buffers: %s", strerror(errno));
}
venc_stop();
for (int i = 0; i < input_buffer_count; i++)
@ -553,6 +633,9 @@ void *run_video_stream(void *arg)
}
log_info("video stream thread exiting");
streaming_stopped = true;
return NULL;
}
@ -582,56 +665,75 @@ void video_shutdown()
log_info("Destroyed streaming mutex");
}
void video_start_streaming()
{
pthread_mutex_lock(&streaming_mutex);
log_info("starting video streaming");
if (streaming_thread != NULL)
{
if (streaming_stopped == true) {
log_error("video streaming already stopped but streaming_thread is not NULL");
assert(streaming_stopped == true);
}
log_warn("video streaming already started");
goto cleanup;
return;
}
pthread_t *new_thread = malloc(sizeof(pthread_t));
if (new_thread == NULL)
{
log_error("Failed to allocate memory for streaming thread");
goto cleanup;
return;
}
streaming_flag = true;
set_streaming_flag(true);
int result = pthread_create(new_thread, NULL, run_video_stream, NULL);
if (result != 0)
{
log_error("Failed to create streaming thread: %s", strerror(result));
streaming_flag = false;
set_streaming_flag(false);
free(new_thread);
goto cleanup;
return;
}
// Only set streaming_thread after successful creation, and before unlocking the mutex
// Only set streaming_thread after successful creation
streaming_thread = new_thread;
cleanup:
pthread_mutex_unlock(&streaming_mutex);
return;
}
void video_stop_streaming()
{
pthread_mutex_lock(&streaming_mutex);
if (streaming_thread != NULL)
{
streaming_flag = false;
log_info("stopping video streaming");
// wait 100ms for the thread to exit
usleep(1000000);
log_info("waiting for video streaming thread to exit");
pthread_join(*streaming_thread, NULL);
free(streaming_thread);
streaming_thread = NULL;
log_info("video streaming stopped");
if (streaming_thread == NULL) {
log_info("video streaming already stopped");
return;
}
pthread_mutex_unlock(&streaming_mutex);
log_info("stopping video streaming");
set_streaming_flag(false);
log_info("waiting for video streaming thread to exit");
int attempts = 0;
while (!streaming_stopped && attempts < 30) {
usleep(100000); // 100ms
attempts++;
}
if (!streaming_stopped) {
log_error("video streaming thread did not exit after 30s");
}
pthread_join(*streaming_thread, NULL);
free(streaming_thread);
streaming_thread = NULL;
log_info("video streaming stopped");
}
void video_restart_streaming()
{
if (get_streaming_flag() == true)
{
log_info("restarting video streaming");
video_stop_streaming();
}
video_start_streaming();
}
void *run_detect_format(void *arg)
@ -650,6 +752,8 @@ void *run_detect_format(void *arg)
while (!should_exit)
{
ensure_sleep_mode_disabled();
memset(&dv_timings, 0, sizeof(dv_timings));
if (ioctl(sub_dev_fd, VIDIOC_QUERY_DV_TIMINGS, &dv_timings) != 0)
{
@ -689,21 +793,17 @@ void *run_detect_format(void *arg)
(dv_timings.bt.width + dv_timings.bt.hfrontporch + dv_timings.bt.hsync +
dv_timings.bt.hbackporch));
log_info("Frames per second: %.2f fps", frames_per_second);
bool should_restart = dv_timings.bt.width != detected_width || dv_timings.bt.height != detected_height || !detected_signal;
detected_width = dv_timings.bt.width;
detected_height = dv_timings.bt.height;
detected_signal = true;
video_report_format(true, NULL, detected_width, detected_height, frames_per_second);
pthread_mutex_lock(&streaming_mutex);
if (streaming_flag == true)
{
pthread_mutex_unlock(&streaming_mutex);
log_info("restarting on going video streaming");
video_stop_streaming();
video_start_streaming();
}
else
{
pthread_mutex_unlock(&streaming_mutex);
if (should_restart) {
log_info("restarting video streaming due to format change");
video_restart_streaming();
}
}
@ -731,19 +831,7 @@ void video_set_quality_factor(float factor)
quality_factor = factor;
// TODO: update venc bitrate without stopping streaming
pthread_mutex_lock(&streaming_mutex);
if (streaming_flag == true)
{
pthread_mutex_unlock(&streaming_mutex);
log_info("restarting on going video streaming due to quality factor change");
video_stop_streaming();
video_start_streaming();
}
else
{
pthread_mutex_unlock(&streaming_mutex);
}
video_restart_streaming();
}
float video_get_quality_factor() {

View File

@ -6,7 +6,7 @@
*
* @return int 0 on success, -1 on failure
*/
int video_init();
int video_init(float quality_factor);
/**
* @brief Shutdown the video subsystem

View File

@ -129,11 +129,13 @@ func uiTick() {
C.jetkvm_ui_tick()
}
func videoInit() error {
func videoInit(factor float64) error {
cgoLock.Lock()
defer cgoLock.Unlock()
ret := C.jetkvm_video_init()
factorC := C.float(factor)
ret := C.jetkvm_video_init(factorC)
if ret != 0 {
return fmt.Errorf("failed to initialize video: %d", ret)
}

File diff suppressed because one or more lines are too long

View File

@ -48,6 +48,10 @@ void action_switch_to_reset_config(lv_event_t *e) {
loadScreen(SCREEN_ID_RESET_CONFIG_SCREEN);
}
void action_switch_to_dhcpc(lv_event_t *e) {
loadScreen(SCREEN_ID_SWITCH_DHCP_CLIENT_SCREEN);
}
void action_switch_to_reboot(lv_event_t *e) {
loadScreen(SCREEN_ID_REBOOT_SCREEN);
}
@ -75,15 +79,19 @@ void action_about_screen_gesture(lv_event_t * e) {
// user_data doesn't seem to be working, so we use a global variable here
static uint32_t t_reset_config;
static uint32_t t_reboot;
static uint32_t t_dhcpc;
static bool b_reboot = false;
static bool b_reset_config = false;
static bool b_dhcpc = false;
static bool b_reboot_lock = false;
static bool b_reset_config_lock = false;
static bool b_dhcpc_lock = false;
const int RESET_CONFIG_HOLD_TIME = 10;
const int REBOOT_HOLD_TIME = 5;
const int DHCPC_HOLD_TIME = 5;
typedef struct {
uint32_t *start_time;
@ -153,6 +161,22 @@ void action_reset_config(lv_event_t * e) {
handle_hold_action(e, &config);
}
void action_dhcpc(lv_event_t * e) {
hold_action_config_t config = {
.start_time = &t_dhcpc,
.completed = &b_dhcpc,
.lock = &b_dhcpc_lock,
.hold_time_seconds = DHCPC_HOLD_TIME,
.rpc_method = "toggleDHCPClient",
.button_obj = NULL, // No button/spinner for reboot
.spinner_obj = NULL,
.label_obj = objects.dhcpc_label,
.default_text = "Press and hold for\n5 seconds"
};
handle_hold_action(e, &config);
}
void action_reboot(lv_event_t * e) {
hold_action_config_t config = {
.start_time = &t_reboot,

View File

@ -24,6 +24,8 @@ extern void action_handle_common_press_event(lv_event_t * e);
extern void action_reset_config(lv_event_t * e);
extern void action_reboot(lv_event_t * e);
extern void action_switch_to_reboot(lv_event_t * e);
extern void action_dhcpc(lv_event_t * e);
extern void action_switch_to_dhcpc(lv_event_t * e);
#ifdef __cplusplus

View File

@ -887,6 +887,26 @@ void create_screen_menu_advanced_screen() {
}
}
}
{
// MenuBtnDHCPClient
lv_obj_t *obj = lv_button_create(parent_obj);
objects.menu_btn_dhcp_client = obj;
lv_obj_set_pos(obj, 0, 0);
lv_obj_set_size(obj, LV_PCT(100), 50);
lv_obj_add_event_cb(obj, action_switch_to_dhcpc, LV_EVENT_PRESSED, (void *)0);
lv_obj_clear_flag(obj, LV_OBJ_FLAG_SNAPPABLE);
add_style_menu_button(obj);
{
lv_obj_t *parent_obj = obj;
{
lv_obj_t *obj = lv_label_create(parent_obj);
lv_obj_set_pos(obj, 0, 0);
lv_obj_set_size(obj, LV_SIZE_CONTENT, LV_SIZE_CONTENT);
add_style_menu_button_label(obj);
lv_label_set_text(obj, "DHCP Client");
}
}
}
{
// MenuBtnAdvancedResetConfig
lv_obj_t *obj = lv_button_create(parent_obj);
@ -2197,6 +2217,221 @@ void create_screen_rebooting_screen() {
void tick_screen_rebooting_screen() {
}
void create_screen_switch_dhcp_client_screen() {
lv_obj_t *obj = lv_obj_create(0);
objects.switch_dhcp_client_screen = obj;
lv_obj_set_pos(obj, 0, 0);
lv_obj_set_size(obj, 300, 240);
lv_obj_add_event_cb(obj, action_about_screen_gesture, LV_EVENT_GESTURE, (void *)0);
add_style_flex_screen_menu(obj);
{
lv_obj_t *parent_obj = obj;
{
lv_obj_t *obj = lv_obj_create(parent_obj);
lv_obj_set_pos(obj, 0, 0);
lv_obj_set_size(obj, LV_PCT(100), LV_PCT(100));
lv_obj_set_style_pad_left(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT);
lv_obj_set_style_pad_top(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT);
lv_obj_set_style_pad_right(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT);
lv_obj_set_style_pad_bottom(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT);
lv_obj_set_style_bg_opa(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT);
lv_obj_set_style_border_width(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT);
lv_obj_set_style_radius(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT);
lv_obj_clear_flag(obj, LV_OBJ_FLAG_SCROLLABLE);
add_style_flex_start(obj);
{
lv_obj_t *parent_obj = obj;
{
// DHCPClientHeader
lv_obj_t *obj = lv_obj_create(parent_obj);
objects.dhcp_client_header = obj;
lv_obj_set_pos(obj, 0, 0);
lv_obj_set_size(obj, LV_PCT(100), LV_SIZE_CONTENT);
lv_obj_set_style_pad_left(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT);
lv_obj_set_style_pad_top(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT);
lv_obj_set_style_pad_bottom(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT);
lv_obj_set_style_bg_opa(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT);
lv_obj_set_style_border_width(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT);
lv_obj_set_style_radius(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT);
add_style_flow_row_space_between(obj);
lv_obj_set_style_pad_right(obj, 4, LV_PART_MAIN | LV_STATE_DEFAULT);
{
lv_obj_t *parent_obj = obj;
{
lv_obj_t *obj = lv_button_create(parent_obj);
lv_obj_set_pos(obj, 0, 0);
lv_obj_set_size(obj, 32, 32);
lv_obj_add_event_cb(obj, action_switch_to_menu, LV_EVENT_CLICKED, (void *)0);
add_style_back_button(obj);
{
lv_obj_t *parent_obj = obj;
{
lv_obj_t *obj = lv_image_create(parent_obj);
lv_obj_set_pos(obj, -1, 2);
lv_obj_set_size(obj, LV_SIZE_CONTENT, LV_SIZE_CONTENT);
lv_image_set_src(obj, &img_back_caret);
}
}
}
{
lv_obj_t *obj = lv_label_create(parent_obj);
lv_obj_set_pos(obj, LV_PCT(0), LV_PCT(0));
lv_obj_set_size(obj, LV_SIZE_CONTENT, LV_SIZE_CONTENT);
add_style_header_link(obj);
lv_label_set_text(obj, "DHCP Client");
}
}
}
{
// DHCPClientContainer
lv_obj_t *obj = lv_obj_create(parent_obj);
objects.dhcp_client_container = obj;
lv_obj_set_pos(obj, 0, 0);
lv_obj_set_size(obj, LV_PCT(100), LV_PCT(80));
lv_obj_set_style_pad_left(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT);
lv_obj_set_style_pad_top(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT);
lv_obj_set_style_pad_bottom(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT);
lv_obj_set_style_bg_opa(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT);
lv_obj_set_style_border_width(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT);
lv_obj_set_style_radius(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT);
lv_obj_set_scrollbar_mode(obj, LV_SCROLLBAR_MODE_AUTO);
lv_obj_set_scroll_dir(obj, LV_DIR_VER);
lv_obj_set_scroll_snap_x(obj, LV_SCROLL_SNAP_START);
add_style_flex_column_start(obj);
lv_obj_set_style_pad_right(obj, 4, LV_PART_MAIN | LV_STATE_DEFAULT);
{
lv_obj_t *parent_obj = obj;
{
lv_obj_t *obj = lv_obj_create(parent_obj);
lv_obj_set_pos(obj, 0, 0);
lv_obj_set_size(obj, LV_PCT(100), LV_SIZE_CONTENT);
lv_obj_set_style_pad_left(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT);
lv_obj_set_style_pad_top(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT);
lv_obj_set_style_pad_bottom(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT);
lv_obj_set_style_bg_opa(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT);
lv_obj_set_style_border_width(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT);
lv_obj_set_style_radius(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT);
lv_obj_clear_flag(obj, LV_OBJ_FLAG_SCROLLABLE);
add_style_flex_column_start(obj);
lv_obj_set_style_pad_right(obj, 10, LV_PART_MAIN | LV_STATE_DEFAULT);
{
lv_obj_t *parent_obj = obj;
{
// DHCPClientLabelContainer
lv_obj_t *obj = lv_obj_create(parent_obj);
objects.dhcp_client_label_container = obj;
lv_obj_set_pos(obj, 0, 0);
lv_obj_set_size(obj, LV_PCT(100), LV_SIZE_CONTENT);
lv_obj_set_style_bg_opa(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT);
lv_obj_set_style_border_width(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT);
lv_obj_set_style_radius(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT);
lv_obj_clear_flag(obj, LV_OBJ_FLAG_SCROLLABLE);
add_style_flex_column_start(obj);
lv_obj_set_style_pad_right(obj, 10, LV_PART_MAIN | LV_STATE_DEFAULT);
lv_obj_set_style_pad_left(obj, 10, LV_PART_MAIN | LV_STATE_DEFAULT);
lv_obj_set_style_pad_top(obj, 10, LV_PART_MAIN | LV_STATE_DEFAULT);
lv_obj_set_style_pad_bottom(obj, 10, LV_PART_MAIN | LV_STATE_DEFAULT);
{
lv_obj_t *parent_obj = obj;
{
// DHCPC_Label
lv_obj_t *obj = lv_label_create(parent_obj);
objects.dhcpc_label = obj;
lv_obj_set_pos(obj, 0, 0);
lv_obj_set_size(obj, LV_PCT(100), LV_SIZE_CONTENT);
add_style_info_content_label(obj);
lv_obj_set_style_text_font(obj, &ui_font_font_book20, LV_PART_MAIN | LV_STATE_DEFAULT);
lv_label_set_text(obj, "Press and hold for\n5 seconds");
}
}
}
{
// DHCPClientSpinner
lv_obj_t *obj = lv_obj_create(parent_obj);
objects.dhcp_client_spinner = obj;
lv_obj_set_pos(obj, 0, 0);
lv_obj_set_size(obj, LV_PCT(100), LV_SIZE_CONTENT);
lv_obj_set_style_pad_left(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT);
lv_obj_set_style_pad_top(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT);
lv_obj_set_style_pad_right(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT);
lv_obj_set_style_pad_bottom(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT);
lv_obj_set_style_bg_opa(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT);
lv_obj_set_style_border_width(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT);
lv_obj_set_style_radius(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT);
lv_obj_add_flag(obj, LV_OBJ_FLAG_HIDDEN);
lv_obj_clear_flag(obj, LV_OBJ_FLAG_CLICKABLE|LV_OBJ_FLAG_SCROLLABLE);
add_style_flex_column_start(obj);
lv_obj_set_style_flex_main_place(obj, LV_FLEX_ALIGN_CENTER, LV_PART_MAIN | LV_STATE_DEFAULT);
lv_obj_set_style_flex_cross_place(obj, LV_FLEX_ALIGN_CENTER, LV_PART_MAIN | LV_STATE_DEFAULT);
lv_obj_set_style_flex_track_place(obj, LV_FLEX_ALIGN_CENTER, LV_PART_MAIN | LV_STATE_DEFAULT);
{
lv_obj_t *parent_obj = obj;
{
lv_obj_t *obj = lv_spinner_create(parent_obj);
lv_obj_set_pos(obj, 0, 0);
lv_obj_set_size(obj, 80, 80);
lv_spinner_set_anim_params(obj, 1000, 60);
}
}
}
{
// DHCPClientButton
lv_obj_t *obj = lv_obj_create(parent_obj);
objects.dhcp_client_button = obj;
lv_obj_set_pos(obj, 0, 0);
lv_obj_set_size(obj, LV_PCT(100), LV_SIZE_CONTENT);
lv_obj_set_style_pad_left(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT);
lv_obj_set_style_pad_top(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT);
lv_obj_set_style_pad_right(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT);
lv_obj_set_style_pad_bottom(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT);
lv_obj_set_style_bg_opa(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT);
lv_obj_set_style_border_width(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT);
lv_obj_set_style_radius(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT);
lv_obj_clear_flag(obj, LV_OBJ_FLAG_SCROLLABLE);
add_style_flex_column_start(obj);
{
lv_obj_t *parent_obj = obj;
{
lv_obj_t *obj = lv_button_create(parent_obj);
objects.obj2 = obj;
lv_obj_set_pos(obj, 0, 0);
lv_obj_set_size(obj, LV_PCT(100), 50);
lv_obj_add_event_cb(obj, action_dhcpc, LV_EVENT_PRESSED, (void *)0);
lv_obj_add_event_cb(obj, action_dhcpc, LV_EVENT_PRESSING, (void *)0);
lv_obj_add_event_cb(obj, action_dhcpc, LV_EVENT_RELEASED, (void *)0);
lv_obj_set_style_bg_color(obj, lv_color_hex(0xffdc2626), LV_PART_MAIN | LV_STATE_DEFAULT);
lv_obj_set_style_text_align(obj, LV_TEXT_ALIGN_LEFT, LV_PART_MAIN | LV_STATE_DEFAULT);
lv_obj_set_style_pad_right(obj, 13, LV_PART_MAIN | LV_STATE_DEFAULT);
{
lv_obj_t *parent_obj = obj;
{
// DHCPClientChangeLabel
lv_obj_t *obj = lv_label_create(parent_obj);
objects.dhcp_client_change_label = obj;
lv_obj_set_pos(obj, 0, 0);
lv_obj_set_size(obj, LV_SIZE_CONTENT, LV_SIZE_CONTENT);
lv_obj_set_style_align(obj, LV_ALIGN_CENTER, LV_PART_MAIN | LV_STATE_DEFAULT);
lv_obj_set_style_text_align(obj, LV_TEXT_ALIGN_LEFT, LV_PART_MAIN | LV_STATE_DEFAULT);
lv_label_set_text(obj, "Switch to udhcpc");
}
}
}
}
}
}
}
}
}
}
}
}
tick_screen_switch_dhcp_client_screen();
}
void tick_screen_switch_dhcp_client_screen() {
}
typedef void (*tick_screen_func_t)();
@ -2212,6 +2447,7 @@ tick_screen_func_t tick_screen_funcs[] = {
tick_screen_reset_config_screen,
tick_screen_reboot_screen,
tick_screen_rebooting_screen,
tick_screen_switch_dhcp_client_screen,
};
void tick_screen(int screen_index) {
tick_screen_funcs[screen_index]();
@ -2236,4 +2472,5 @@ void create_screens() {
create_screen_reset_config_screen();
create_screen_reboot_screen();
create_screen_rebooting_screen();
create_screen_switch_dhcp_client_screen();
}

View File

@ -19,6 +19,7 @@ typedef struct _objects_t {
lv_obj_t *reset_config_screen;
lv_obj_t *reboot_screen;
lv_obj_t *rebooting_screen;
lv_obj_t *switch_dhcp_client_screen;
lv_obj_t *boot_logo;
lv_obj_t *boot_screen_version;
lv_obj_t *no_network_header_container;
@ -54,6 +55,7 @@ typedef struct _objects_t {
lv_obj_t *menu_btn_advanced_developer_mode;
lv_obj_t *menu_btn_advanced_usb_emulation;
lv_obj_t *menu_btn_advanced_reboot;
lv_obj_t *menu_btn_dhcp_client;
lv_obj_t *menu_btn_advanced_reset_config;
lv_obj_t *menu_header_container_2;
lv_obj_t *menu_items_container_2;
@ -101,6 +103,14 @@ typedef struct _objects_t {
lv_obj_t *obj1;
lv_obj_t *reboot_in_progress_logo;
lv_obj_t *reboot_in_progress_label;
lv_obj_t *dhcp_client_header;
lv_obj_t *dhcp_client_container;
lv_obj_t *dhcp_client_label_container;
lv_obj_t *dhcpc_label;
lv_obj_t *dhcp_client_spinner;
lv_obj_t *dhcp_client_button;
lv_obj_t *obj2;
lv_obj_t *dhcp_client_change_label;
} objects_t;
extern objects_t objects;
@ -117,6 +127,7 @@ enum ScreensEnum {
SCREEN_ID_RESET_CONFIG_SCREEN = 9,
SCREEN_ID_REBOOT_SCREEN = 10,
SCREEN_ID_REBOOTING_SCREEN = 11,
SCREEN_ID_SWITCH_DHCP_CLIENT_SCREEN = 12,
};
void create_screen_boot_screen();
@ -152,6 +163,9 @@ void tick_screen_reboot_screen();
void create_screen_rebooting_screen();
void tick_screen_rebooting_screen();
void create_screen_switch_dhcp_client_screen();
void tick_screen_switch_dhcp_client_screen();
void tick_screen_by_id(enum ScreensEnum screenId);
void tick_screen(int screen_index);

View File

@ -15,18 +15,22 @@ type Native struct {
systemVersion *semver.Version
appVersion *semver.Version
displayRotation uint16
defaultQualityFactor float64
onVideoStateChange func(state VideoState)
onVideoFrameReceived func(frame []byte, duration time.Duration)
onIndevEvent func(event string)
onRpcEvent func(event string)
sleepModeSupported bool
videoLock sync.Mutex
screenLock sync.Mutex
extraLock sync.Mutex
}
type NativeOptions struct {
SystemVersion *semver.Version
AppVersion *semver.Version
DisplayRotation uint16
DefaultQualityFactor float64
OnVideoStateChange func(state VideoState)
OnVideoFrameReceived func(frame []byte, duration time.Duration)
OnIndevEvent func(event string)
@ -62,6 +66,13 @@ func NewNative(opts NativeOptions) *Native {
}
}
sleepModeSupported := isSleepModeSupported()
defaultQualityFactor := opts.DefaultQualityFactor
if defaultQualityFactor <= 0 || defaultQualityFactor > 1 {
defaultQualityFactor = 1.0
}
return &Native{
ready: make(chan struct{}),
l: nativeLogger,
@ -69,10 +80,12 @@ func NewNative(opts NativeOptions) *Native {
systemVersion: opts.SystemVersion,
appVersion: opts.AppVersion,
displayRotation: opts.DisplayRotation,
defaultQualityFactor: defaultQualityFactor,
onVideoStateChange: onVideoStateChange,
onVideoFrameReceived: onVideoFrameReceived,
onIndevEvent: onIndevEvent,
onRpcEvent: onRpcEvent,
sleepModeSupported: sleepModeSupported,
videoLock: sync.Mutex{},
screenLock: sync.Mutex{},
}
@ -93,7 +106,7 @@ func (n *Native) Start() {
n.initUI()
go n.tickUI()
if err := videoInit(); err != nil {
if err := videoInit(n.defaultQualityFactor); err != nil {
n.l.Error().Err(err).Msg("failed to initialize video")
}

View File

@ -1,5 +1,19 @@
package native
import (
"fmt"
"os"
"time"
)
const sleepModeFile = "/sys/devices/platform/ff470000.i2c/i2c-4/4-000f/sleep_mode"
// DefaultEDID is the default EDID for the video stream.
const DefaultEDID = "00ffffffffffff0052620188008888881c150103800000780a0dc9a05747982712484c00000001010101010101010101010101010101023a801871382d40582c4500c48e2100001e011d007251d01e206e285500c48e2100001e000000fc00543734392d6648443732300a20000000fd00147801ff1d000a202020202020017b"
var extraLockTimeout = 5 * time.Second
// VideoState is the state of the video stream.
type VideoState struct {
Ready bool `json:"ready"`
Error string `json:"error,omitempty"` //no_signal, no_lock, out_of_range
@ -8,13 +22,86 @@ type VideoState struct {
FramePerSecond float64 `json:"fps"`
}
func isSleepModeSupported() bool {
_, err := os.Stat(sleepModeFile)
return err == nil
}
func (n *Native) setSleepMode(enabled bool) error {
if !n.sleepModeSupported {
return nil
}
bEnabled := "0"
if enabled {
bEnabled = "1"
}
return os.WriteFile(sleepModeFile, []byte(bEnabled), 0644)
}
func (n *Native) getSleepMode() (bool, error) {
if !n.sleepModeSupported {
return false, nil
}
data, err := os.ReadFile(sleepModeFile)
if err == nil {
return string(data) == "1", nil
}
return false, nil
}
// VideoSetSleepMode sets the sleep mode for the video stream.
func (n *Native) VideoSetSleepMode(enabled bool) error {
n.videoLock.Lock()
defer n.videoLock.Unlock()
return n.setSleepMode(enabled)
}
// VideoGetSleepMode gets the sleep mode for the video stream.
func (n *Native) VideoGetSleepMode() (bool, error) {
n.videoLock.Lock()
defer n.videoLock.Unlock()
return n.getSleepMode()
}
// VideoSleepModeSupported checks if the sleep mode is supported.
func (n *Native) VideoSleepModeSupported() bool {
return n.sleepModeSupported
}
// useExtraLock uses the extra lock to execute a function.
// if the lock is currently held by another goroutine, returns an error.
//
// it's used to ensure that only one change is made to the video stream at a time.
// as the change usually requires to restart video streaming
// TODO: check video streaming status instead of using a hardcoded timeout
func (n *Native) useExtraLock(fn func() error) error {
if !n.extraLock.TryLock() {
return fmt.Errorf("the previous change hasn't been completed yet")
}
err := fn()
if err == nil {
time.Sleep(extraLockTimeout)
}
n.extraLock.Unlock()
return err
}
// VideoSetQualityFactor sets the quality factor for the video stream.
func (n *Native) VideoSetQualityFactor(factor float64) error {
n.videoLock.Lock()
defer n.videoLock.Unlock()
return videoSetStreamQualityFactor(factor)
return n.useExtraLock(func() error {
return videoSetStreamQualityFactor(factor)
})
}
// VideoGetQualityFactor gets the quality factor for the video stream.
func (n *Native) VideoGetQualityFactor() (float64, error) {
n.videoLock.Lock()
defer n.videoLock.Unlock()
@ -22,13 +109,21 @@ func (n *Native) VideoGetQualityFactor() (float64, error) {
return videoGetStreamQualityFactor()
}
// VideoSetEDID sets the EDID for the video stream.
func (n *Native) VideoSetEDID(edid string) error {
n.videoLock.Lock()
defer n.videoLock.Unlock()
return videoSetEDID(edid)
if edid == "" {
edid = DefaultEDID
}
return n.useExtraLock(func() error {
return videoSetEDID(edid)
})
}
// VideoGetEDID gets the EDID for the video stream.
func (n *Native) VideoGetEDID() (string, error) {
n.videoLock.Lock()
defer n.videoLock.Unlock()
@ -36,6 +131,7 @@ func (n *Native) VideoGetEDID() (string, error) {
return videoGetEDID()
}
// VideoLogStatus gets the log status for the video stream.
func (n *Native) VideoLogStatus() (string, error) {
n.videoLock.Lock()
defer n.videoLock.Unlock()
@ -43,6 +139,7 @@ func (n *Native) VideoLogStatus() (string, error) {
return videoLogStatus(), nil
}
// VideoStop stops the video stream.
func (n *Native) VideoStop() error {
n.videoLock.Lock()
defer n.videoLock.Unlock()
@ -51,10 +148,14 @@ func (n *Native) VideoStop() error {
return nil
}
// VideoStart starts the video stream.
func (n *Native) VideoStart() error {
n.videoLock.Lock()
defer n.videoLock.Unlock()
// disable sleep mode before starting video
_ = n.setSleepMode(false)
videoStart()
return nil
}

View File

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

View File

@ -1,137 +0,0 @@
package network
import (
"fmt"
"io"
"os"
"os/exec"
"strings"
"sync"
"golang.org/x/net/idna"
)
const (
hostnamePath = "/etc/hostname"
hostsPath = "/etc/hosts"
)
var (
hostnameLock sync.Mutex = sync.Mutex{}
)
func updateEtcHosts(hostname string, fqdn string) error {
// update /etc/hosts
hostsFile, err := os.OpenFile(hostsPath, os.O_RDWR|os.O_SYNC, os.ModeExclusive)
if err != nil {
return fmt.Errorf("failed to open %s: %w", hostsPath, err)
}
defer hostsFile.Close()
// read all lines
if _, err := hostsFile.Seek(0, io.SeekStart); err != nil {
return fmt.Errorf("failed to seek %s: %w", hostsPath, err)
}
lines, err := io.ReadAll(hostsFile)
if err != nil {
return fmt.Errorf("failed to read %s: %w", hostsPath, err)
}
newLines := []string{}
hostLine := fmt.Sprintf("127.0.1.1\t%s %s", hostname, fqdn)
hostLineExists := false
for line := range strings.SplitSeq(string(lines), "\n") {
if strings.HasPrefix(line, "127.0.1.1") {
hostLineExists = true
line = hostLine
}
newLines = append(newLines, line)
}
if !hostLineExists {
newLines = append(newLines, hostLine)
}
if err := hostsFile.Truncate(0); err != nil {
return fmt.Errorf("failed to truncate %s: %w", hostsPath, err)
}
if _, err := hostsFile.Seek(0, io.SeekStart); err != nil {
return fmt.Errorf("failed to seek %s: %w", hostsPath, err)
}
if _, err := hostsFile.Write([]byte(strings.Join(newLines, "\n"))); err != nil {
return fmt.Errorf("failed to write %s: %w", hostsPath, err)
}
return nil
}
func ToValidHostname(hostname string) string {
ascii, err := idna.Lookup.ToASCII(hostname)
if err != nil {
return ""
}
return ascii
}
func SetHostname(hostname string, fqdn string) error {
hostnameLock.Lock()
defer hostnameLock.Unlock()
hostname = ToValidHostname(strings.TrimSpace(hostname))
fqdn = ToValidHostname(strings.TrimSpace(fqdn))
if hostname == "" {
return fmt.Errorf("invalid hostname: %s", hostname)
}
if fqdn == "" {
fqdn = hostname
}
// update /etc/hostname
if err := os.WriteFile(hostnamePath, []byte(hostname), 0644); err != nil {
return fmt.Errorf("failed to write %s: %w", hostnamePath, err)
}
// update /etc/hosts
if err := updateEtcHosts(hostname, fqdn); err != nil {
return fmt.Errorf("failed to update /etc/hosts: %w", err)
}
// run hostname
if err := exec.Command("hostname", "-F", hostnamePath).Run(); err != nil {
return fmt.Errorf("failed to run hostname: %w", err)
}
return nil
}
func (s *NetworkInterfaceState) setHostnameIfNotSame() error {
hostname := s.GetHostname()
currentHostname, _ := os.Hostname()
fqdn := fmt.Sprintf("%s.%s", hostname, s.GetDomain())
if currentHostname == hostname && s.currentFqdn == fqdn && s.currentHostname == hostname {
return nil
}
scopedLogger := s.l.With().Str("hostname", hostname).Str("fqdn", fqdn).Logger()
err := SetHostname(hostname, fqdn)
if err != nil {
scopedLogger.Error().Err(err).Msg("failed to set hostname")
return err
}
s.currentHostname = hostname
s.currentFqdn = fqdn
scopedLogger.Info().Msg("hostname set")
return nil
}

View File

@ -1,403 +0,0 @@
package network
import (
"fmt"
"net"
"sync"
"github.com/jetkvm/kvm/internal/confparser"
"github.com/jetkvm/kvm/internal/logging"
"github.com/jetkvm/kvm/internal/udhcpc"
"github.com/rs/zerolog"
"github.com/vishvananda/netlink"
)
type NetworkInterfaceState struct {
interfaceName string
interfaceUp bool
ipv4Addr *net.IP
ipv4Addresses []string
ipv6Addr *net.IP
ipv6Addresses []IPv6Address
ipv6LinkLocal *net.IP
ntpAddresses []*net.IP
macAddr *net.HardwareAddr
l *zerolog.Logger
stateLock sync.Mutex
config *NetworkConfig
dhcpClient *udhcpc.DHCPClient
defaultHostname string
currentHostname string
currentFqdn string
onStateChange func(state *NetworkInterfaceState)
onInitialCheck func(state *NetworkInterfaceState)
cbConfigChange func(config *NetworkConfig)
checked bool
}
type NetworkInterfaceOptions struct {
InterfaceName string
DhcpPidFile string
Logger *zerolog.Logger
DefaultHostname string
OnStateChange func(state *NetworkInterfaceState)
OnInitialCheck func(state *NetworkInterfaceState)
OnDhcpLeaseChange func(lease *udhcpc.Lease, state *NetworkInterfaceState)
OnConfigChange func(config *NetworkConfig)
NetworkConfig *NetworkConfig
}
func NewNetworkInterfaceState(opts *NetworkInterfaceOptions) (*NetworkInterfaceState, error) {
if opts.NetworkConfig == nil {
return nil, fmt.Errorf("NetworkConfig can not be nil")
}
if opts.DefaultHostname == "" {
opts.DefaultHostname = "jetkvm"
}
err := confparser.SetDefaultsAndValidate(opts.NetworkConfig)
if err != nil {
return nil, err
}
l := opts.Logger
s := &NetworkInterfaceState{
interfaceName: opts.InterfaceName,
defaultHostname: opts.DefaultHostname,
stateLock: sync.Mutex{},
l: l,
onStateChange: opts.OnStateChange,
onInitialCheck: opts.OnInitialCheck,
cbConfigChange: opts.OnConfigChange,
config: opts.NetworkConfig,
ntpAddresses: make([]*net.IP, 0),
}
// create the dhcp client
dhcpClient := udhcpc.NewDHCPClient(&udhcpc.DHCPClientOptions{
InterfaceName: opts.InterfaceName,
PidFile: opts.DhcpPidFile,
Logger: l,
OnLeaseChange: func(lease *udhcpc.Lease) {
_, err := s.update()
if err != nil {
opts.Logger.Error().Err(err).Msg("failed to update network state")
return
}
_ = s.updateNtpServersFromLease(lease)
_ = s.setHostnameIfNotSame()
opts.OnDhcpLeaseChange(lease, s)
},
})
s.dhcpClient = dhcpClient
return s, nil
}
func (s *NetworkInterfaceState) IsUp() bool {
return s.interfaceUp
}
func (s *NetworkInterfaceState) HasIPAssigned() bool {
return s.ipv4Addr != nil || s.ipv6Addr != nil
}
func (s *NetworkInterfaceState) IsOnline() bool {
return s.IsUp() && s.HasIPAssigned()
}
func (s *NetworkInterfaceState) IPv4() *net.IP {
return s.ipv4Addr
}
func (s *NetworkInterfaceState) IPv4String() string {
if s.ipv4Addr == nil {
return "..."
}
return s.ipv4Addr.String()
}
func (s *NetworkInterfaceState) IPv6() *net.IP {
return s.ipv6Addr
}
func (s *NetworkInterfaceState) IPv6String() string {
if s.ipv6Addr == nil {
return "..."
}
return s.ipv6Addr.String()
}
func (s *NetworkInterfaceState) NtpAddresses() []*net.IP {
return s.ntpAddresses
}
func (s *NetworkInterfaceState) NtpAddressesString() []string {
ntpServers := []string{}
if s != nil {
s.l.Debug().Any("s", s).Msg("getting NTP address strings")
if len(s.ntpAddresses) > 0 {
for _, server := range s.ntpAddresses {
s.l.Debug().IPAddr("server", *server).Msg("converting NTP address")
ntpServers = append(ntpServers, server.String())
}
}
}
return ntpServers
}
func (s *NetworkInterfaceState) MAC() *net.HardwareAddr {
return s.macAddr
}
func (s *NetworkInterfaceState) MACString() string {
if s.macAddr == nil {
return ""
}
return s.macAddr.String()
}
func (s *NetworkInterfaceState) update() (DhcpTargetState, error) {
s.stateLock.Lock()
defer s.stateLock.Unlock()
dhcpTargetState := DhcpTargetStateDoNothing
iface, err := netlink.LinkByName(s.interfaceName)
if err != nil {
s.l.Error().Err(err).Msg("failed to get interface")
return dhcpTargetState, err
}
// detect if the interface status changed
var changed bool
attrs := iface.Attrs()
state := attrs.OperState
newInterfaceUp := state == netlink.OperUp
// check if the interface is coming up
interfaceGoingUp := !s.interfaceUp && newInterfaceUp
interfaceGoingDown := s.interfaceUp && !newInterfaceUp
if s.interfaceUp != newInterfaceUp {
s.interfaceUp = newInterfaceUp
changed = true
}
if changed {
if interfaceGoingUp {
s.l.Info().Msg("interface state transitioned to up")
dhcpTargetState = DhcpTargetStateRenew
} else if interfaceGoingDown {
s.l.Info().Msg("interface state transitioned to down")
}
}
// set the mac address
s.macAddr = &attrs.HardwareAddr
// get the ip addresses
addrs, err := netlinkAddrs(iface)
if err != nil {
return dhcpTargetState, logging.ErrorfL(s.l, "failed to get ip addresses", err)
}
var (
ipv4Addresses = make([]net.IP, 0)
ipv4AddressesString = make([]string, 0)
ipv6Addresses = make([]IPv6Address, 0)
// ipv6AddressesString = make([]string, 0)
ipv6LinkLocal *net.IP
)
for _, addr := range addrs {
if addr.IP.To4() != nil {
scopedLogger := s.l.With().Str("ipv4", addr.IP.String()).Logger()
if interfaceGoingDown {
// remove all IPv4 addresses from the interface.
scopedLogger.Info().Msg("state transitioned to down, removing IPv4 address")
err := netlink.AddrDel(iface, &addr)
if err != nil {
scopedLogger.Warn().Err(err).Msg("failed to delete address")
}
// notify the DHCP client to release the lease
dhcpTargetState = DhcpTargetStateRelease
continue
}
ipv4Addresses = append(ipv4Addresses, addr.IP)
ipv4AddressesString = append(ipv4AddressesString, addr.IPNet.String())
} else if addr.IP.To16() != nil {
if s.config.IPv6Mode.String == "disabled" {
continue
}
scopedLogger := s.l.With().Str("ipv6", addr.IP.String()).Logger()
// check if it's a link local address
if addr.IP.IsLinkLocalUnicast() {
ipv6LinkLocal = &addr.IP
continue
}
if !addr.IP.IsGlobalUnicast() {
scopedLogger.Trace().Msg("not a global unicast address, skipping")
continue
}
if interfaceGoingDown {
scopedLogger.Info().Msg("state transitioned to down, removing IPv6 address")
err := netlink.AddrDel(iface, &addr)
if err != nil {
scopedLogger.Warn().Err(err).Msg("failed to delete address")
}
continue
}
ipv6Addresses = append(ipv6Addresses, IPv6Address{
Address: addr.IP,
Prefix: *addr.IPNet,
ValidLifetime: lifetimeToTime(addr.ValidLft),
PreferredLifetime: lifetimeToTime(addr.PreferedLft),
Scope: addr.Scope,
})
// ipv6AddressesString = append(ipv6AddressesString, addr.IPNet.String())
}
}
if len(ipv4Addresses) > 0 {
// compare the addresses to see if there's a change
if s.ipv4Addr == nil || s.ipv4Addr.String() != ipv4Addresses[0].String() {
scopedLogger := s.l.With().Str("ipv4", ipv4Addresses[0].String()).Logger()
if s.ipv4Addr != nil {
scopedLogger.Info().
Str("old_ipv4", s.ipv4Addr.String()).
Msg("IPv4 address changed")
} else {
scopedLogger.Info().Msg("IPv4 address found")
}
s.ipv4Addr = &ipv4Addresses[0]
changed = true
}
}
s.ipv4Addresses = ipv4AddressesString
if s.config.IPv6Mode.String != "disabled" {
if ipv6LinkLocal != nil {
if s.ipv6LinkLocal == nil || s.ipv6LinkLocal.String() != ipv6LinkLocal.String() {
scopedLogger := s.l.With().Str("ipv6", ipv6LinkLocal.String()).Logger()
if s.ipv6LinkLocal != nil {
scopedLogger.Info().
Str("old_ipv6", s.ipv6LinkLocal.String()).
Msg("IPv6 link local address changed")
} else {
scopedLogger.Info().Msg("IPv6 link local address found")
}
s.ipv6LinkLocal = ipv6LinkLocal
changed = true
}
}
s.ipv6Addresses = ipv6Addresses
if len(ipv6Addresses) > 0 {
// compare the addresses to see if there's a change
if s.ipv6Addr == nil || s.ipv6Addr.String() != ipv6Addresses[0].Address.String() {
scopedLogger := s.l.With().Str("ipv6", ipv6Addresses[0].Address.String()).Logger()
if s.ipv6Addr != nil {
scopedLogger.Info().
Str("old_ipv6", s.ipv6Addr.String()).
Msg("IPv6 address changed")
} else {
scopedLogger.Info().Msg("IPv6 address found")
}
s.ipv6Addr = &ipv6Addresses[0].Address
changed = true
}
}
}
// if it's the initial check, we'll set changed to false
initialCheck := !s.checked
if initialCheck {
s.checked = true
changed = false
if dhcpTargetState == DhcpTargetStateRenew {
// it's the initial check, we'll start the DHCP client
// dhcpTargetState = DhcpTargetStateStart
// TODO: manage DHCP client start/stop
dhcpTargetState = DhcpTargetStateDoNothing
}
}
if initialCheck {
s.handleInitialCheck()
} else if changed {
s.handleStateChange()
}
return dhcpTargetState, nil
}
func (s *NetworkInterfaceState) updateNtpServersFromLease(lease *udhcpc.Lease) error {
if lease != nil && len(lease.NTPServers) > 0 {
s.l.Info().Msg("lease found, updating DHCP NTP addresses")
s.ntpAddresses = make([]*net.IP, 0, len(lease.NTPServers))
for _, ntpServer := range lease.NTPServers {
if ntpServer != nil {
s.l.Info().IPAddr("ntp_server", ntpServer).Msg("NTP server found in lease")
s.ntpAddresses = append(s.ntpAddresses, &ntpServer)
}
}
} else {
s.l.Info().Msg("no NTP servers found in lease")
s.ntpAddresses = make([]*net.IP, 0, len(s.config.TimeSyncNTPServers))
}
return nil
}
func (s *NetworkInterfaceState) handleInitialCheck() {
// if s.IsUp() {}
s.onInitialCheck(s)
}
func (s *NetworkInterfaceState) handleStateChange() {
// if s.IsUp() {} else {}
s.onStateChange(s)
}
func (s *NetworkInterfaceState) CheckAndUpdateDhcp() error {
dhcpTargetState, err := s.update()
if err != nil {
return logging.ErrorfL(s.l, "failed to update network state", err)
}
switch dhcpTargetState {
case DhcpTargetStateRenew:
s.l.Info().Msg("renewing DHCP lease")
_ = s.dhcpClient.Renew()
case DhcpTargetStateRelease:
s.l.Info().Msg("releasing DHCP lease")
_ = s.dhcpClient.Release()
case DhcpTargetStateStart:
s.l.Warn().Msg("dhcpTargetStateStart not implemented")
case DhcpTargetStateStop:
s.l.Warn().Msg("dhcpTargetStateStop not implemented")
}
return nil
}
func (s *NetworkInterfaceState) onConfigChange(config *NetworkConfig) {
_ = s.setHostnameIfNotSame()
s.cbConfigChange(config)
}

View File

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

View File

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

View File

@ -1,126 +0,0 @@
package network
import (
"fmt"
"time"
"github.com/jetkvm/kvm/internal/confparser"
"github.com/jetkvm/kvm/internal/udhcpc"
)
type RpcIPv6Address struct {
Address string `json:"address"`
ValidLifetime *time.Time `json:"valid_lifetime,omitempty"`
PreferredLifetime *time.Time `json:"preferred_lifetime,omitempty"`
Scope int `json:"scope"`
}
type RpcNetworkState struct {
InterfaceName string `json:"interface_name"`
MacAddress string `json:"mac_address"`
IPv4 string `json:"ipv4,omitempty"`
IPv6 string `json:"ipv6,omitempty"`
IPv6LinkLocal string `json:"ipv6_link_local,omitempty"`
IPv4Addresses []string `json:"ipv4_addresses,omitempty"`
IPv6Addresses []RpcIPv6Address `json:"ipv6_addresses,omitempty"`
DHCPLease *udhcpc.Lease `json:"dhcp_lease,omitempty"`
}
type RpcNetworkSettings struct {
NetworkConfig
}
func (s *NetworkInterfaceState) MacAddress() string {
if s.macAddr == nil {
return ""
}
return s.macAddr.String()
}
func (s *NetworkInterfaceState) IPv4Address() string {
if s.ipv4Addr == nil {
return ""
}
return s.ipv4Addr.String()
}
func (s *NetworkInterfaceState) IPv6Address() string {
if s.ipv6Addr == nil {
return ""
}
return s.ipv6Addr.String()
}
func (s *NetworkInterfaceState) IPv6LinkLocalAddress() string {
if s.ipv6LinkLocal == nil {
return ""
}
return s.ipv6LinkLocal.String()
}
func (s *NetworkInterfaceState) RpcGetNetworkState() RpcNetworkState {
ipv6Addresses := make([]RpcIPv6Address, 0)
if s.ipv6Addresses != nil && s.config.IPv6Mode.String != "disabled" {
for _, addr := range s.ipv6Addresses {
ipv6Addresses = append(ipv6Addresses, RpcIPv6Address{
Address: addr.Prefix.String(),
ValidLifetime: addr.ValidLifetime,
PreferredLifetime: addr.PreferredLifetime,
Scope: addr.Scope,
})
}
}
return RpcNetworkState{
InterfaceName: s.interfaceName,
MacAddress: s.MacAddress(),
IPv4: s.IPv4Address(),
IPv6: s.IPv6Address(),
IPv6LinkLocal: s.IPv6LinkLocalAddress(),
IPv4Addresses: s.ipv4Addresses,
IPv6Addresses: ipv6Addresses,
DHCPLease: s.dhcpClient.GetLease(),
}
}
func (s *NetworkInterfaceState) RpcGetNetworkSettings() RpcNetworkSettings {
if s.config == nil {
return RpcNetworkSettings{}
}
return RpcNetworkSettings{
NetworkConfig: *s.config,
}
}
func (s *NetworkInterfaceState) RpcSetNetworkSettings(settings RpcNetworkSettings) error {
currentSettings := s.config
err := confparser.SetDefaultsAndValidate(&settings.NetworkConfig)
if err != nil {
return err
}
if IsSame(currentSettings, settings.NetworkConfig) {
// no changes, do nothing
return nil
}
s.config = &settings.NetworkConfig
s.onConfigChange(s.config)
return nil
}
func (s *NetworkInterfaceState) RpcRenewDHCPLease() error {
if s.dhcpClient == nil {
return fmt.Errorf("dhcp client not initialized")
}
return s.dhcpClient.Renew()
}

View File

@ -1,25 +1,13 @@
package network
package types
import (
"fmt"
"net"
"net/http"
"net/url"
"time"
"github.com/guregu/null/v6"
"github.com/jetkvm/kvm/internal/mdns"
"golang.org/x/net/idna"
)
type IPv6Address struct {
Address net.IP `json:"address"`
Prefix net.IPNet `json:"prefix"`
ValidLifetime *time.Time `json:"valid_lifetime"`
PreferredLifetime *time.Time `json:"preferred_lifetime"`
Scope int `json:"scope"`
}
// IPv4StaticConfig represents static IPv4 configuration
type IPv4StaticConfig struct {
Address null.String `json:"address,omitempty" validate_type:"ipv4" required:"true"`
Netmask null.String `json:"netmask,omitempty" validate_type:"ipv4" required:"true"`
@ -27,13 +15,23 @@ type IPv4StaticConfig struct {
DNS []string `json:"dns,omitempty" validate_type:"ipv4" required:"true"`
}
// IPv6StaticConfig represents static IPv6 configuration
type IPv6StaticConfig struct {
Address null.String `json:"address,omitempty" validate_type:"ipv6" required:"true"`
Prefix null.String `json:"prefix,omitempty" validate_type:"ipv6" required:"true"`
Prefix null.String `json:"prefix,omitempty" validate_type:"ipv6_prefix" required:"true"`
Gateway null.String `json:"gateway,omitempty" validate_type:"ipv6" required:"true"`
DNS []string `json:"dns,omitempty" validate_type:"ipv6" required:"true"`
}
// MDNSListenOptions represents MDNS listening options
type MDNSListenOptions struct {
IPv4 bool
IPv6 bool
}
// NetworkConfig represents the complete network configuration for an interface
type NetworkConfig struct {
DHCPClient null.String `json:"dhcp_client,omitempty" one_of:"jetdhcpc,udhcpc" default:"jetdhcpc"`
Hostname null.String `json:"hostname,omitempty" validate_type:"hostname"`
HTTPProxy null.String `json:"http_proxy,omitempty" validate_type:"proxy"`
Domain null.String `json:"domain,omitempty" validate_type:"hostname"`
@ -44,7 +42,7 @@ type NetworkConfig struct {
IPv6Mode null.String `json:"ipv6_mode,omitempty" one_of:"slaac,dhcpv6,slaac_and_dhcpv6,static,link_local,disabled" default:"slaac"`
IPv6Static *IPv6StaticConfig `json:"ipv6_static,omitempty" required_if:"IPv6Mode=static"`
LLDPMode null.String `json:"lldp_mode,omitempty" one_of:"disabled,rx_only,tx_only,basic,all,enabled" default:"enabled"`
LLDPMode null.String `json:"lldp_mode,omitempty" one_of:"disabled,basic,all" default:"basic"`
LLDPTxTLVs []string `json:"lldp_tx_tlvs,omitempty" one_of:"chassis,port,system,vlan" default:"chassis,port,system,vlan"`
MDNSMode null.String `json:"mdns_mode,omitempty" one_of:"disabled,auto,ipv4_only,ipv6_only" default:"auto"`
TimeSyncMode null.String `json:"time_sync_mode,omitempty" one_of:"ntp_only,ntp_and_http,http_only,custom" default:"ntp_and_http"`
@ -55,13 +53,15 @@ type NetworkConfig struct {
TimeSyncHTTPUrls []string `json:"time_sync_http_urls,omitempty" validate_type:"url" required_if:"TimeSyncOrdering=http_user_provided"`
}
func (c *NetworkConfig) GetMDNSMode() *mdns.MDNSListenOptions {
listenOptions := &mdns.MDNSListenOptions{
IPv4: c.IPv4Mode.String != "disabled",
IPv6: c.IPv6Mode.String != "disabled",
// GetMDNSMode returns the MDNS mode configuration
func (c *NetworkConfig) GetMDNSMode() *MDNSListenOptions {
mode := c.MDNSMode.String
listenOptions := &MDNSListenOptions{
IPv4: true,
IPv6: true,
}
switch c.MDNSMode.String {
switch mode {
case "ipv4_only":
listenOptions.IPv6 = false
case "ipv6_only":
@ -74,53 +74,21 @@ func (c *NetworkConfig) GetMDNSMode() *mdns.MDNSListenOptions {
return listenOptions
}
func (s *NetworkConfig) GetTransportProxyFunc() func(*http.Request) (*url.URL, error) {
// GetTransportProxyFunc returns a function for HTTP proxy configuration
func (c *NetworkConfig) GetTransportProxyFunc() func(*http.Request) (*url.URL, error) {
return func(*http.Request) (*url.URL, error) {
if s.HTTPProxy.String == "" {
if c.HTTPProxy.String == "" {
return nil, nil
} else {
proxyUrl, _ := url.Parse(s.HTTPProxy.String)
return proxyUrl, nil
proxyURL, _ := url.Parse(c.HTTPProxy.String)
return proxyURL, nil
}
}
}
func (s *NetworkInterfaceState) GetHostname() string {
hostname := ToValidHostname(s.config.Hostname.String)
if hostname == "" {
return s.defaultHostname
}
return hostname
}
func ToValidDomain(domain string) string {
ascii, err := idna.Lookup.ToASCII(domain)
if err != nil {
return ""
}
return ascii
}
func (s *NetworkInterfaceState) GetDomain() string {
domain := ToValidDomain(s.config.Domain.String)
if domain == "" {
lease := s.dhcpClient.GetLease()
if lease != nil && lease.Domain != "" {
domain = ToValidDomain(lease.Domain)
}
}
if domain == "" {
return "local"
}
return domain
}
func (s *NetworkInterfaceState) GetFQDN() string {
return fmt.Sprintf("%s.%s", s.GetHostname(), s.GetDomain())
// NetworkConfig interface for backward compatibility
type NetworkConfigInterface interface {
InterfaceName() string
IPv4Addresses() []IPAddress
IPv6Addresses() []IPAddress
}

View File

@ -1,18 +1,26 @@
package udhcpc
package types
import (
"bufio"
"encoding/json"
"fmt"
"net"
"os"
"reflect"
"strconv"
"strings"
"time"
)
type Lease struct {
// DHCPClient is the interface for a DHCP client.
type DHCPClient interface {
Domain() string
Lease4() *DHCPLease
Lease6() *DHCPLease
Renew() error
Release() error
SetIPv4(enabled bool)
SetIPv6(enabled bool)
SetOnLeaseChange(callback func(lease *DHCPLease))
Start() error
Stop() error
}
// DHCPLease is a network configuration obtained by DHCP.
type DHCPLease struct {
// from https://udhcp.busybox.net/README.udhcpc
IPAddress net.IP `env:"ip" json:"ip"` // The obtained IP
Netmask net.IP `env:"subnet" json:"netmask"` // The assigned subnet mask
@ -21,6 +29,7 @@ type Lease struct {
MTU int `env:"mtu" json:"mtu,omitempty"` // The MTU to use for this network
HostName string `env:"hostname" json:"hostname,omitempty"` // The assigned hostname
Domain string `env:"domain" json:"domain,omitempty"` // The domain name of the network
SearchList []string `env:"search" json:"search_list,omitempty"` // The search list for the network
BootPNextServer net.IP `env:"siaddr" json:"bootp_next_server,omitempty"` // The bootp next server option
BootPServerName string `env:"sname" json:"bootp_server_name,omitempty"` // The bootp server name option
BootPFile string `env:"boot_file" json:"bootp_file,omitempty"` // The bootp boot file option
@ -38,149 +47,46 @@ type Lease struct {
BootSize int `env:"bootsize" json:"bootsize,omitempty"` // The length in 512 octect blocks of the bootfile
RootPath string `env:"rootpath" json:"root_path,omitempty"` // The path name of the client's root disk
LeaseTime time.Duration `env:"lease" json:"lease,omitempty"` // The lease time, in seconds
RenewalTime time.Duration `env:"renewal" json:"renewal,omitempty"` // The renewal time, in seconds
RebindingTime time.Duration `env:"rebinding" json:"rebinding,omitempty"` // The rebinding time, in seconds
DHCPType string `env:"dhcptype" json:"dhcp_type,omitempty"` // DHCP message type (safely ignored)
ServerID string `env:"serverid" json:"server_id,omitempty"` // The IP of the server
Message string `env:"message" json:"reason,omitempty"` // Reason for a DHCPNAK
TFTPServerName string `env:"tftp" json:"tftp,omitempty"` // The TFTP server name
BootFileName string `env:"bootfile" json:"bootfile,omitempty"` // The boot file name
Uptime time.Duration `env:"uptime" json:"uptime,omitempty"` // The uptime of the device when the lease was obtained, in seconds
ClassIdentifier string `env:"classid" json:"class_identifier,omitempty"` // The class identifier
LeaseExpiry *time.Time `json:"lease_expiry,omitempty"` // The expiry time of the lease
isEmpty map[string]bool
InterfaceName string `json:"interface_name,omitempty"` // The name of the interface
DHCPClient string `json:"dhcp_client,omitempty"` // The DHCP client that obtained the lease
}
func (l *Lease) setIsEmpty(m map[string]bool) {
l.isEmpty = m
// IsIPv6 returns true if the DHCP lease is for an IPv6 address
func (d *DHCPLease) IsIPv6() bool {
return d.IPAddress.To4() == nil
}
func (l *Lease) IsEmpty(key string) bool {
return l.isEmpty[key]
// IPMask returns the IP mask for the DHCP lease
func (d *DHCPLease) IPMask() net.IPMask {
if d.IsIPv6() {
// TODO: not implemented
return nil
}
mask := net.ParseIP(d.Netmask.String())
return net.IPv4Mask(mask[12], mask[13], mask[14], mask[15])
}
func (l *Lease) ToJSON() string {
json, err := json.Marshal(l)
if err != nil {
return ""
// IPNet returns the IP net for the DHCP lease
func (d *DHCPLease) IPNet() *net.IPNet {
if d.IsIPv6() {
// TODO: not implemented
return nil
}
return &net.IPNet{
IP: d.IPAddress,
Mask: d.IPMask(),
}
return string(json)
}
func (l *Lease) SetLeaseExpiry() (time.Time, error) {
if l.Uptime == 0 || l.LeaseTime == 0 {
return time.Time{}, fmt.Errorf("uptime or lease time isn't set")
}
// get the uptime of the device
file, err := os.Open("/proc/uptime")
if err != nil {
return time.Time{}, fmt.Errorf("failed to open uptime file: %w", err)
}
defer file.Close()
var uptime time.Duration
scanner := bufio.NewScanner(file)
for scanner.Scan() {
text := scanner.Text()
parts := strings.Split(text, " ")
uptime, err = time.ParseDuration(parts[0] + "s")
if err != nil {
return time.Time{}, fmt.Errorf("failed to parse uptime: %w", err)
}
}
relativeLeaseRemaining := (l.Uptime + l.LeaseTime) - uptime
leaseExpiry := time.Now().Add(relativeLeaseRemaining)
l.LeaseExpiry = &leaseExpiry
return leaseExpiry, nil
}
func UnmarshalDHCPCLease(lease *Lease, str string) error {
// parse the lease file as a map
data := make(map[string]string)
for line := range strings.SplitSeq(str, "\n") {
line = strings.TrimSpace(line)
// skip empty lines and comments
if line == "" || strings.HasPrefix(line, "#") {
continue
}
parts := strings.SplitN(line, "=", 2)
if len(parts) != 2 {
continue
}
key := strings.TrimSpace(parts[0])
value := strings.TrimSpace(parts[1])
data[key] = value
}
// now iterate over the lease struct and set the values
leaseType := reflect.TypeOf(lease).Elem()
leaseValue := reflect.ValueOf(lease).Elem()
valuesParsed := make(map[string]bool)
for i := 0; i < leaseType.NumField(); i++ {
field := leaseValue.Field(i)
// get the env tag
key := leaseType.Field(i).Tag.Get("env")
if key == "" {
continue
}
valuesParsed[key] = false
// get the value from the data map
value, ok := data[key]
if !ok || value == "" {
continue
}
switch field.Interface().(type) {
case string:
field.SetString(value)
case int:
val, err := strconv.Atoi(value)
if err != nil {
continue
}
field.SetInt(int64(val))
case time.Duration:
val, err := time.ParseDuration(value + "s")
if err != nil {
continue
}
field.Set(reflect.ValueOf(val))
case net.IP:
ip := net.ParseIP(value)
if ip == nil {
continue
}
field.Set(reflect.ValueOf(ip))
case []net.IP:
val := make([]net.IP, 0)
for ipStr := range strings.FieldsSeq(value) {
ip := net.ParseIP(ipStr)
if ip == nil {
continue
}
val = append(val, ip)
}
field.Set(reflect.ValueOf(val))
default:
return fmt.Errorf("unsupported field `%s` type: %s", key, field.Type().String())
}
valuesParsed[key] = true
}
lease.setIsEmpty(valuesParsed)
return nil
}

View File

@ -0,0 +1,62 @@
package types
import (
"net"
"time"
"golang.org/x/sys/unix"
)
// InterfaceState represents the current state of a network interface
type InterfaceState struct {
InterfaceName string `json:"interface_name"`
Hostname string `json:"hostname"`
MACAddress string `json:"mac_address"`
Up bool `json:"up"`
Online bool `json:"online"`
IPv4Ready bool `json:"ipv4_ready"`
IPv6Ready bool `json:"ipv6_ready"`
IPv4Address string `json:"ipv4_address,omitempty"`
IPv6Address string `json:"ipv6_address,omitempty"`
IPv6LinkLocal string `json:"ipv6_link_local,omitempty"`
IPv6Gateway string `json:"ipv6_gateway,omitempty"`
IPv4Addresses []string `json:"ipv4_addresses,omitempty"`
IPv6Addresses []IPv6Address `json:"ipv6_addresses,omitempty"`
NTPServers []net.IP `json:"ntp_servers,omitempty"`
DHCPLease4 *DHCPLease `json:"dhcp_lease,omitempty"`
DHCPLease6 *DHCPLease `json:"dhcp_lease6,omitempty"`
LastUpdated time.Time `json:"last_updated"`
}
// RpcInterfaceState is the RPC representation of an interface state
type RpcInterfaceState struct {
InterfaceState
IPv6Addresses []RpcIPv6Address `json:"ipv6_addresses"`
}
// ToRpcInterfaceState converts an InterfaceState to a RpcInterfaceState
func (s *InterfaceState) ToRpcInterfaceState() *RpcInterfaceState {
addrs := make([]RpcIPv6Address, len(s.IPv6Addresses))
for i, addr := range s.IPv6Addresses {
addrs[i] = RpcIPv6Address{
Address: addr.Address.String(),
Prefix: addr.Prefix.String(),
ValidLifetime: addr.ValidLifetime,
PreferredLifetime: addr.PreferredLifetime,
Scope: addr.Scope,
Flags: addr.Flags,
FlagSecondary: addr.Flags&unix.IFA_F_SECONDARY != 0,
FlagPermanent: addr.Flags&unix.IFA_F_PERMANENT != 0,
FlagTemporary: addr.Flags&unix.IFA_F_TEMPORARY != 0,
FlagStablePrivacy: addr.Flags&unix.IFA_F_STABLE_PRIVACY != 0,
FlagDeprecated: addr.Flags&unix.IFA_F_DEPRECATED != 0,
FlagOptimistic: addr.Flags&unix.IFA_F_OPTIMISTIC != 0,
FlagDADFailed: addr.Flags&unix.IFA_F_DADFAILED != 0,
FlagTentative: addr.Flags&unix.IFA_F_TENTATIVE != 0,
}
}
return &RpcInterfaceState{
InterfaceState: *s,
IPv6Addresses: addrs,
}
}

View File

@ -0,0 +1,85 @@
package types
import (
"net"
"slices"
"time"
"github.com/vishvananda/netlink"
)
// IPAddress represents a network interface address
type IPAddress struct {
Family int
Address net.IPNet
Gateway net.IP
MTU int
Secondary bool
Permanent bool
}
func (a *IPAddress) String() string {
return a.Address.String()
}
func (a *IPAddress) Compare(n netlink.Addr) bool {
if !a.Address.IP.Equal(n.IP) {
return false
}
if slices.Compare(a.Address.Mask, n.Mask) != 0 {
return false
}
return true
}
func (a *IPAddress) NetlinkAddr() netlink.Addr {
return netlink.Addr{
IPNet: &a.Address,
}
}
func (a *IPAddress) DefaultRoute(linkIndex int) netlink.Route {
return netlink.Route{
Dst: nil,
Gw: a.Gateway,
LinkIndex: linkIndex,
}
}
// ParsedIPConfig represents the parsed IP configuration
type ParsedIPConfig struct {
Addresses []IPAddress
Nameservers []net.IP
SearchList []string
Domain string
MTU int
Interface string
}
// IPv6Address represents an IPv6 address with lifetime information
type IPv6Address struct {
Address net.IP `json:"address"`
Prefix net.IPNet `json:"prefix"`
ValidLifetime *time.Time `json:"valid_lifetime"`
PreferredLifetime *time.Time `json:"preferred_lifetime"`
Flags int `json:"flags"`
Scope int `json:"scope"`
}
// RpcIPv6Address is the RPC representation of an IPv6 address
type RpcIPv6Address struct {
Address string `json:"address"`
Prefix string `json:"prefix"`
ValidLifetime *time.Time `json:"valid_lifetime"`
PreferredLifetime *time.Time `json:"preferred_lifetime"`
Scope int `json:"scope"`
Flags int `json:"flags"`
FlagSecondary bool `json:"flag_secondary"`
FlagPermanent bool `json:"flag_permanent"`
FlagTemporary bool `json:"flag_temporary"`
FlagStablePrivacy bool `json:"flag_stable_privacy"`
FlagDeprecated bool `json:"flag_deprecated"`
FlagOptimistic bool `json:"flag_optimistic"`
FlagDADFailed bool `json:"flag_dad_failed"`
FlagTentative bool `json:"flag_tentative"`
}

View File

@ -0,0 +1,22 @@
package types
import "net"
// InterfaceResolvConf represents the DNS configuration for a network interface
type InterfaceResolvConf struct {
NameServers []net.IP `json:"nameservers"`
SearchList []string `json:"search_list"`
Domain string `json:"domain,omitempty"` // TODO: remove this once we have a better way to handle the domain
Source string `json:"source,omitempty"`
}
// InterfaceResolvConfMap ..
type InterfaceResolvConfMap map[string]InterfaceResolvConf
// ResolvConf represents the DNS configuration for the system
type ResolvConf struct {
ConfigIPv4 InterfaceResolvConfMap `json:"config_ipv4"`
ConfigIPv6 InterfaceResolvConfMap `json:"config_ipv6"`
Domain string `json:"domain"`
HostName string `json:"host_name"`
}

View File

@ -1,26 +0,0 @@
package network
import (
"encoding/json"
"time"
)
func lifetimeToTime(lifetime int) *time.Time {
if lifetime == 0 {
return nil
}
t := time.Now().Add(time.Duration(lifetime) * time.Second)
return &t
}
func IsSame(a, b any) bool {
aJSON, err := json.Marshal(a)
if err != nil {
return false
}
bJSON, err := json.Marshal(b)
if err != nil {
return false
}
return string(aJSON) == string(bJSON)
}

149
internal/sync/log.go Normal file
View File

@ -0,0 +1,149 @@
//go:build synctrace
package sync
import (
"fmt"
"reflect"
"runtime"
"sync"
"time"
"github.com/jetkvm/kvm/internal/logging"
"github.com/rs/zerolog"
)
var defaultLogger = logging.GetSubsystemLogger("synctrace")
func logTrace(msg string) {
if defaultLogger.GetLevel() > zerolog.TraceLevel {
return
}
logTrack(3).Trace().Msg(msg)
}
func logTrack(callerSkip int) *zerolog.Logger {
l := *defaultLogger
if l.GetLevel() > zerolog.TraceLevel {
return &l
}
pc, file, no, ok := runtime.Caller(callerSkip)
if ok {
l = l.With().
Str("file", file).
Int("line", no).
Logger()
details := runtime.FuncForPC(pc)
if details != nil {
l = l.With().
Str("func", details.Name()).
Logger()
}
}
return &l
}
func logLockTrack(i string) *zerolog.Logger {
l := logTrack(4).
With().
Str("index", i).
Logger()
return &l
}
var (
indexMu sync.Mutex
lockCount map[string]int = make(map[string]int)
unlockCount map[string]int = make(map[string]int)
lastLock map[string]time.Time = make(map[string]time.Time)
)
type trackable interface {
sync.Locker
}
func getIndex(t trackable) string {
ptr := reflect.ValueOf(t).Pointer()
return fmt.Sprintf("%x", ptr)
}
func increaseLockCount(i string) {
indexMu.Lock()
defer indexMu.Unlock()
if _, ok := lockCount[i]; !ok {
lockCount[i] = 0
}
lockCount[i]++
if _, ok := lastLock[i]; !ok {
lastLock[i] = time.Now()
}
}
func increaseUnlockCount(i string) {
indexMu.Lock()
defer indexMu.Unlock()
if _, ok := unlockCount[i]; !ok {
unlockCount[i] = 0
}
unlockCount[i]++
}
func logLock(t trackable) {
i := getIndex(t)
increaseLockCount(i)
logLockTrack(i).Trace().Msg("locking mutex")
}
func logUnlock(t trackable) {
i := getIndex(t)
increaseUnlockCount(i)
logLockTrack(i).Trace().Msg("unlocking mutex")
}
func logTryLock(t trackable) {
i := getIndex(t)
logLockTrack(i).Trace().Msg("trying to lock mutex")
}
func logTryLockResult(t trackable, l bool) {
if !l {
return
}
i := getIndex(t)
increaseLockCount(i)
logLockTrack(i).Trace().Msg("locked mutex")
}
func logRLock(t trackable) {
i := getIndex(t)
increaseLockCount(i)
logLockTrack(i).Trace().Msg("locking mutex for reading")
}
func logRUnlock(t trackable) {
i := getIndex(t)
increaseUnlockCount(i)
logLockTrack(i).Trace().Msg("unlocking mutex for reading")
}
func logTryRLock(t trackable) {
i := getIndex(t)
logLockTrack(i).Trace().Msg("trying to lock mutex for reading")
}
func logTryRLockResult(t trackable, l bool) {
if !l {
return
}
i := getIndex(t)
increaseLockCount(i)
logLockTrack(i).Trace().Msg("locked mutex for reading")
}

69
internal/sync/mutex.go Normal file
View File

@ -0,0 +1,69 @@
//go:build synctrace
package sync
import (
gosync "sync"
)
// Mutex is a wrapper around the sync.Mutex
type Mutex struct {
mu gosync.Mutex
}
// Lock locks the mutex
func (m *Mutex) Lock() {
logLock(m)
m.mu.Lock()
}
// Unlock unlocks the mutex
func (m *Mutex) Unlock() {
logUnlock(m)
m.mu.Unlock()
}
// TryLock tries to lock the mutex
func (m *Mutex) TryLock() bool {
logTryLock(m)
l := m.mu.TryLock()
logTryLockResult(m, l)
return l
}
// RWMutex is a wrapper around the sync.RWMutex
type RWMutex struct {
mu gosync.RWMutex
}
// Lock locks the mutex
func (m *RWMutex) Lock() {
logLock(m)
m.mu.Lock()
}
// Unlock unlocks the mutex
func (m *RWMutex) Unlock() {
logUnlock(m)
m.mu.Unlock()
}
// RLock locks the mutex for reading
func (m *RWMutex) RLock() {
logRLock(m)
m.mu.RLock()
}
// RUnlock unlocks the mutex for reading
func (m *RWMutex) RUnlock() {
logRUnlock(m)
m.mu.RUnlock()
}
// TryRLock tries to lock the mutex for reading
func (m *RWMutex) TryRLock() bool {
logTryRLock(m)
l := m.mu.TryRLock()
logTryRLockResult(m, l)
return l
}

18
internal/sync/once.go Normal file
View File

@ -0,0 +1,18 @@
//go:build synctrace
package sync
import (
gosync "sync"
)
// Once is a wrapper around the sync.Once
type Once struct {
mu gosync.Once
}
// Do calls the function f if and only if Do has not been called before for this instance of Once.
func (o *Once) Do(f func()) {
logTrace("Doing once")
o.mu.Do(f)
}

92
internal/sync/release.go Normal file
View File

@ -0,0 +1,92 @@
//go:build !synctrace
package sync
import (
gosync "sync"
)
// Mutex is a wrapper around the sync.Mutex
type Mutex struct {
mu gosync.Mutex
}
// Lock locks the mutex
func (m *Mutex) Lock() {
m.mu.Lock()
}
// Unlock unlocks the mutex
func (m *Mutex) Unlock() {
m.mu.Unlock()
}
// TryLock tries to lock the mutex
func (m *Mutex) TryLock() bool {
return m.mu.TryLock()
}
// RWMutex is a wrapper around the sync.RWMutex
type RWMutex struct {
mu gosync.RWMutex
}
// Lock locks the mutex
func (m *RWMutex) Lock() {
m.mu.Lock()
}
// Unlock unlocks the mutex
func (m *RWMutex) Unlock() {
m.mu.Unlock()
}
// RLock locks the mutex for reading
func (m *RWMutex) RLock() {
m.mu.RLock()
}
// RUnlock unlocks the mutex for reading
func (m *RWMutex) RUnlock() {
m.mu.RUnlock()
}
// TryRLock tries to lock the mutex for reading
func (m *RWMutex) TryRLock() bool {
return m.mu.TryRLock()
}
// TryLock tries to lock the mutex
func (m *RWMutex) TryLock() bool {
return m.mu.TryLock()
}
// WaitGroup is a wrapper around the sync.WaitGroup
type WaitGroup struct {
wg gosync.WaitGroup
}
// Add adds a function to the wait group
func (w *WaitGroup) Add(delta int) {
w.wg.Add(delta)
}
// Done decrements the wait group counter
func (w *WaitGroup) Done() {
w.wg.Done()
}
// Wait waits for the wait group to finish
func (w *WaitGroup) Wait() {
w.wg.Wait()
}
// Once is a wrapper around the sync.Once
type Once struct {
mu gosync.Once
}
// Do calls the function f if and only if Do has not been called before for this instance of Once.
func (o *Once) Do(f func()) {
o.mu.Do(f)
}

View File

@ -0,0 +1,30 @@
//go:build synctrace
package sync
import (
gosync "sync"
)
// WaitGroup is a wrapper around the sync.WaitGroup
type WaitGroup struct {
wg gosync.WaitGroup
}
// Add adds a function to the wait group
func (w *WaitGroup) Add(delta int) {
logTrace("Adding to wait group")
w.wg.Add(delta)
}
// Done decrements the wait group counter
func (w *WaitGroup) Done() {
logTrace("Done with wait group")
w.wg.Done()
}
// Wait waits for the wait group to finish
func (w *WaitGroup) Wait() {
logTrace("Waiting for wait group")
w.wg.Wait()
}

View File

@ -3,13 +3,14 @@ package timesync
import (
"context"
"math/rand/v2"
"net"
"strconv"
"time"
"github.com/beevik/ntp"
)
var defaultNTPServerIPs = []string{
var DefaultNTPServerIPs = []string{
// These servers are known by static IP and as such don't need DNS lookups
// These are from Google and Cloudflare since if they're down, the internet
// is broken anyway
@ -27,7 +28,7 @@ var defaultNTPServerIPs = []string{
"2001:4860:4806:c::", // time.google.com IPv6
}
var defaultNTPServerHostnames = []string{
var DefaultNTPServerHostnames = []string{
// should use something from https://github.com/jauderho/public-ntp-servers
"time.apple.com",
"time.aws.com",
@ -37,7 +38,48 @@ var defaultNTPServerHostnames = []string{
"pool.ntp.org",
}
func (t *TimeSync) filterNTPServers(ntpServers []string) ([]string, error) {
if len(ntpServers) == 0 {
return nil, nil
}
hasIPv4, err := t.preCheckIPv4()
if err != nil {
t.l.Error().Err(err).Msg("failed to check IPv4")
return nil, err
}
hasIPv6, err := t.preCheckIPv6()
if err != nil {
t.l.Error().Err(err).Msg("failed to check IPv6")
return nil, err
}
filteredServers := []string{}
for _, server := range ntpServers {
ip := net.ParseIP(server)
t.l.Trace().Str("server", server).Interface("ip", ip).Msg("checking NTP server")
if ip == nil {
continue
}
if hasIPv4 && ip.To4() != nil {
filteredServers = append(filteredServers, server)
}
if hasIPv6 && ip.To16() != nil {
filteredServers = append(filteredServers, server)
}
}
return filteredServers, nil
}
func (t *TimeSync) queryNetworkTime(ntpServers []string) (now *time.Time, offset *time.Duration) {
ntpServers, err := t.filterNTPServers(ntpServers)
if err != nil {
t.l.Error().Err(err).Msg("failed to filter NTP servers")
return nil, nil
}
chunkSize := int(t.networkConfig.TimeSyncParallel.ValueOr(4))
t.l.Info().Strs("servers", ntpServers).Int("chunkSize", chunkSize).Msg("querying NTP servers")

View File

@ -7,7 +7,7 @@ import (
"sync"
"time"
"github.com/jetkvm/kvm/internal/network"
"github.com/jetkvm/kvm/internal/network/types"
"github.com/rs/zerolog"
)
@ -24,11 +24,13 @@ var (
timeSyncRetryInterval = 0 * time.Second
)
type PreCheckFunc func() (bool, error)
type TimeSync struct {
syncLock *sync.Mutex
l *zerolog.Logger
networkConfig *network.NetworkConfig
networkConfig *types.NetworkConfig
dhcpNtpAddresses []string
rtcDevicePath string
@ -36,14 +38,19 @@ type TimeSync struct {
rtcLock *sync.Mutex
syncSuccess bool
timer *time.Timer
preCheckFunc func() (bool, error)
preCheckFunc PreCheckFunc
preCheckIPv4 PreCheckFunc
preCheckIPv6 PreCheckFunc
}
type TimeSyncOptions struct {
PreCheckFunc func() (bool, error)
PreCheckFunc PreCheckFunc
PreCheckIPv4 PreCheckFunc
PreCheckIPv6 PreCheckFunc
Logger *zerolog.Logger
NetworkConfig *network.NetworkConfig
NetworkConfig *types.NetworkConfig
}
type SyncMode struct {
@ -69,7 +76,10 @@ func NewTimeSync(opts *TimeSyncOptions) *TimeSync {
rtcDevicePath: rtcDevice,
rtcLock: &sync.Mutex{},
preCheckFunc: opts.PreCheckFunc,
preCheckIPv4: opts.PreCheckIPv4,
preCheckIPv6: opts.PreCheckIPv6,
networkConfig: opts.NetworkConfig,
timer: time.NewTimer(timeSyncWaitNetUpInt),
}
if t.rtcDevicePath != "" {
@ -112,49 +122,64 @@ func (t *TimeSync) getSyncMode() SyncMode {
}
}
t.l.Debug().Strs("Ordering", syncMode.Ordering).Bool("Ntp", syncMode.Ntp).Bool("Http", syncMode.Http).Bool("NtpUseFallback", syncMode.NtpUseFallback).Bool("HttpUseFallback", syncMode.HttpUseFallback).Msg("sync mode")
t.l.Debug().
Strs("Ordering", syncMode.Ordering).
Bool("Ntp", syncMode.Ntp).
Bool("Http", syncMode.Http).
Bool("NtpUseFallback", syncMode.NtpUseFallback).
Bool("HttpUseFallback", syncMode.HttpUseFallback).
Msg("sync mode")
return syncMode
}
func (t *TimeSync) doTimeSync() {
func (t *TimeSync) timeSyncLoop() {
metricTimeSyncStatus.Set(0)
for {
// use a timer here instead of sleep
for range t.timer.C {
if ok, err := t.preCheckFunc(); !ok {
if err != nil {
t.l.Error().Err(err).Msg("pre-check failed")
}
time.Sleep(timeSyncWaitNetChkInt)
t.timer.Reset(timeSyncWaitNetChkInt)
continue
}
t.l.Info().Msg("syncing system time")
start := time.Now()
err := t.Sync()
err := t.sync()
if err != nil {
t.l.Error().Str("error", err.Error()).Msg("failed to sync system time")
// retry after a delay
timeSyncRetryInterval += timeSyncRetryStep
time.Sleep(timeSyncRetryInterval)
t.timer.Reset(timeSyncRetryInterval)
// reset the retry interval if it exceeds the max interval
if timeSyncRetryInterval > timeSyncRetryMaxInt {
timeSyncRetryInterval = 0
}
continue
}
isInitialSync := !t.syncSuccess
t.syncSuccess = true
t.l.Info().Str("now", time.Now().Format(time.RFC3339)).
Str("time_taken", time.Since(start).String()).
Bool("is_initial_sync", isInitialSync).
Msg("time sync successful")
metricTimeSyncStatus.Set(1)
time.Sleep(timeSyncInterval) // after the first sync is done
t.timer.Reset(timeSyncInterval) // after the first sync is done
}
}
func (t *TimeSync) Sync() error {
func (t *TimeSync) sync() error {
t.syncLock.Lock()
defer t.syncLock.Unlock()
var (
now *time.Time
offset *time.Duration
@ -188,10 +213,10 @@ Orders:
case "ntp":
if syncMode.Ntp && syncMode.NtpUseFallback {
log.Info().Msg("using NTP fallback IPs")
now, offset = t.queryNetworkTime(defaultNTPServerIPs)
now, offset = t.queryNetworkTime(DefaultNTPServerIPs)
if now == nil {
log.Info().Msg("using NTP fallback hostnames")
now, offset = t.queryNetworkTime(defaultNTPServerHostnames)
now, offset = t.queryNetworkTime(DefaultNTPServerHostnames)
}
if now != nil {
break Orders
@ -239,12 +264,25 @@ Orders:
return nil
}
// Sync triggers a manual time sync
func (t *TimeSync) Sync() error {
if !t.syncLock.TryLock() {
t.l.Warn().Msg("sync already in progress, skipping")
return nil
}
t.syncLock.Unlock()
return t.sync()
}
// IsSyncSuccess returns true if the system time is synchronized
func (t *TimeSync) IsSyncSuccess() bool {
return t.syncSuccess
}
// Start starts the time sync
func (t *TimeSync) Start() {
go t.doTimeSync()
go t.timeSyncLoop()
}
func (t *TimeSync) setSystemTime(now time.Time) error {

View File

@ -173,36 +173,12 @@ func rpcGetDeviceID() (string, error) {
}
func rpcReboot(force bool) error {
logger.Info().Msg("Got reboot request from JSONRPC, rebooting...")
nativeInstance.SwitchToScreenIfDifferent("rebooting_screen")
args := []string{}
if force {
args = append(args, "-f")
}
cmd := exec.Command("reboot", args...)
err := cmd.Start()
if err != nil {
logger.Error().Err(err).Msg("failed to reboot")
switchToMainScreen()
return fmt.Errorf("failed to reboot: %w", err)
}
// If the reboot command is successful, exit the program after 5 seconds
go func() {
time.Sleep(5 * time.Second)
os.Exit(0)
}()
return nil
logger.Info().Msg("Got reboot request via RPC")
return hwReboot(force, nil, 0)
}
var streamFactor = 1.0
func rpcGetStreamQualityFactor() (float64, error) {
return streamFactor, nil
return config.VideoQualityFactor, nil
}
func rpcSetStreamQualityFactor(factor float64) error {
@ -212,7 +188,10 @@ func rpcSetStreamQualityFactor(factor float64) error {
return err
}
streamFactor = factor
config.VideoQualityFactor = factor
if err := SaveConfig(); err != nil {
return fmt.Errorf("failed to save config: %w", err)
}
return nil
}
@ -239,7 +218,6 @@ func rpcGetEDID() (string, error) {
func rpcSetEDID(edid string) error {
if edid == "" {
logger.Info().Msg("Restoring EDID to default")
edid = "00ffffffffffff0052620188008888881c150103800000780a0dc9a05747982712484c00000001010101010101010101010101010101023a801871382d40582c4500c48e2100001e011d007251d01e206e285500c48e2100001e000000fc00543734392d6648443732300a20000000fd00147801ff1d000a202020202020017b"
} else {
logger.Info().Str("edid", edid).Msg("Setting EDID")
}
@ -720,7 +698,8 @@ func rpcSetWakeOnLanDevices(params SetWakeOnLanDevicesParams) error {
}
func rpcResetConfig() error {
config = defaultConfig
defaultConfig := getDefaultConfig()
config = &defaultConfig
if err := SaveConfig(); err != nil {
return fmt.Errorf("failed to reset config: %w", err)
}
@ -1278,6 +1257,8 @@ var rpcHandlers = map[string]RPCHandler{
"getEDID": {Func: rpcGetEDID},
"setEDID": {Func: rpcSetEDID, Params: []string{"edid"}},
"getVideoLogStatus": {Func: rpcGetVideoLogStatus},
"getVideoSleepMode": {Func: rpcGetVideoSleepMode},
"setVideoSleepMode": {Func: rpcSetVideoSleepMode, Params: []string{"duration"}},
"getDevChannelState": {Func: rpcGetDevChannelState},
"setDevChannelState": {Func: rpcSetDevChannelState, Params: []string{"enabled"}},
"getLocalVersion": {Func: rpcGetLocalVersion},

32
main.go
View File

@ -14,6 +14,7 @@ import (
var appCtx context.Context
func Main() {
logger.Log().Msg("JetKVM Starting Up")
LoadConfig()
var cancel context.CancelFunc
@ -33,6 +34,7 @@ func Main() {
go runWatchdog()
go confirmCurrentSystem()
initDisplay()
initNative(systemVersionLocal, appVersionLocal)
http.DefaultClient.Timeout = 1 * time.Minute
@ -74,20 +76,20 @@ func Main() {
}
initJiggler()
// initialize display
initDisplay()
// start video sleep mode timer
startVideoSleepModeTicker()
go func() {
// wait for 15 minutes before starting auto-update checks
// this is to avoid interfering with initial setup processes
// and to ensure the system is stable before checking for updates
time.Sleep(15 * time.Minute)
for {
logger.Debug().Bool("auto_update_enabled", config.AutoUpdateEnabled).Msg("UPDATING")
if !config.AutoUpdateEnabled {
return
}
if isTimeSyncNeeded() || !timeSync.IsSyncSuccess() {
logger.Debug().Msg("system time is not synced, will retry in 30 seconds")
time.Sleep(30 * time.Second)
for {
logger.Info().Bool("auto_update_enabled", config.AutoUpdateEnabled).Msg("auto-update check")
if !config.AutoUpdateEnabled {
logger.Debug().Msg("auto-update disabled")
time.Sleep(5 * time.Minute) // we'll check if auto-updates are enabled in five minutes
continue
}
@ -97,6 +99,12 @@ func Main() {
continue
}
if isTimeSyncNeeded() || !timeSync.IsSyncSuccess() {
logger.Debug().Msg("system time is not synced, will retry in 30 seconds")
time.Sleep(30 * time.Second)
continue
}
includePreRelease := config.IncludePreRelease
err = TryUpdate(context.Background(), GetDeviceID(), includePreRelease)
if err != nil {
@ -106,6 +114,7 @@ func Main() {
time.Sleep(1 * time.Hour)
}
}()
//go RunFuseServer()
go RunWebServer()
@ -122,7 +131,8 @@ func Main() {
sigs := make(chan os.Signal, 1)
signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM)
<-sigs
logger.Info().Msg("JetKVM Shutting Down")
logger.Log().Msg("JetKVM Shutting Down")
//if fuseServer != nil {
// err := setMassStorageImage(" ")
// if err != nil {

16
mdns.go
View File

@ -1,19 +1,23 @@
package kvm
import (
"fmt"
"github.com/jetkvm/kvm/internal/mdns"
)
var mDNS *mdns.MDNS
func initMdns() error {
options := getMdnsOptions()
if options == nil {
return fmt.Errorf("failed to get mDNS options")
}
m, err := mdns.NewMDNS(&mdns.MDNSOptions{
Logger: logger,
LocalNames: []string{
networkState.GetHostname(),
networkState.GetFQDN(),
},
ListenOptions: config.NetworkConfig.GetMDNSMode(),
Logger: logger,
LocalNames: options.LocalNames,
ListenOptions: options.ListenOptions,
})
if err != nil {
return err

View File

@ -17,9 +17,10 @@ var (
func initNative(systemVersion *semver.Version, appVersion *semver.Version) {
nativeInstance = native.NewNative(native.NativeOptions{
SystemVersion: systemVersion,
AppVersion: appVersion,
DisplayRotation: config.GetDisplayRotation(),
SystemVersion: systemVersion,
AppVersion: appVersion,
DisplayRotation: config.GetDisplayRotation(),
DefaultQualityFactor: config.VideoQualityFactor,
OnVideoStateChange: func(state native.VideoState) {
lastVideoState = state
triggerVideoStateUpdate()
@ -36,13 +37,18 @@ func initNative(systemVersion *semver.Version, appVersion *semver.Version) {
nativeLogger.Trace().Str("event", event).Msg("rpc event received")
switch event {
case "resetConfig":
nativeLogger.Info().Msg("Reset configuration request via native rpc event")
err := rpcResetConfig()
if err != nil {
nativeLogger.Warn().Err(err).Msg("error resetting config")
}
_ = rpcReboot(true)
case "reboot":
nativeLogger.Info().Msg("Reboot request via native rpc event")
_ = rpcReboot(true)
case "toggleDHCPClient":
nativeLogger.Info().Msg("Toggle DHCP request via native rpc event")
_ = rpcToggleDHCPClient()
default:
nativeLogger.Warn().Str("event", event).Msg("unknown rpc event received")
}
@ -56,7 +62,13 @@ func initNative(systemVersion *semver.Version, appVersion *semver.Version) {
}
},
})
nativeInstance.Start()
go func() {
if err := nativeInstance.VideoSetEDID(config.EdidString); err != nil {
nativeLogger.Warn().Err(err).Msg("error setting EDID")
}
}()
if os.Getenv("JETKVM_CRASH_TESTING") == "1" {
nativeInstance.DoNotUseThisIsForCrashTestingOnly()

View File

@ -1,10 +1,14 @@
package kvm
import (
"context"
"fmt"
"reflect"
"github.com/jetkvm/kvm/internal/network"
"github.com/jetkvm/kvm/internal/udhcpc"
"github.com/jetkvm/kvm/internal/confparser"
"github.com/jetkvm/kvm/internal/mdns"
"github.com/jetkvm/kvm/internal/network/types"
"github.com/jetkvm/kvm/pkg/nmlite"
)
const (
@ -12,114 +16,299 @@ const (
)
var (
networkState *network.NetworkInterfaceState
networkManager *nmlite.NetworkManager
)
func networkStateChanged(isOnline bool) {
// do not block the main thread
go waitCtrlAndRequestDisplayUpdate(true, "network_state_changed")
type RpcNetworkSettings struct {
types.NetworkConfig
}
if timeSync != nil {
if networkState != nil {
timeSync.SetDhcpNtpAddresses(networkState.NtpAddressesString())
func (s *RpcNetworkSettings) ToNetworkConfig() *types.NetworkConfig {
return &s.NetworkConfig
}
type PostRebootAction struct {
HealthCheck string `json:"healthCheck"`
RedirectTo string `json:"redirectTo"`
}
func toRpcNetworkSettings(config *types.NetworkConfig) *RpcNetworkSettings {
return &RpcNetworkSettings{
NetworkConfig: *config,
}
}
func getMdnsOptions() *mdns.MDNSOptions {
if networkManager == nil {
return nil
}
var ipv4, ipv6 bool
switch config.NetworkConfig.MDNSMode.String {
case "auto":
ipv4 = true
ipv6 = true
case "ipv4_only":
ipv4 = true
case "ipv6_only":
ipv6 = true
}
return &mdns.MDNSOptions{
LocalNames: []string{
networkManager.Hostname(),
networkManager.FQDN(),
},
ListenOptions: &mdns.MDNSListenOptions{
IPv4: ipv4,
IPv6: ipv6,
},
}
}
func restartMdns() {
if mDNS == nil {
return
}
options := getMdnsOptions()
if options == nil {
return
}
if err := mDNS.SetOptions(options); err != nil {
networkLogger.Error().Err(err).Msg("failed to restart mDNS")
}
}
func triggerTimeSyncOnNetworkStateChange() {
if timeSync == nil {
return
}
// set the NTP servers from the network manager
if networkManager != nil {
ntpServers := make([]string, len(networkManager.NTPServers()))
for i, server := range networkManager.NTPServers() {
ntpServers[i] = server.String()
}
networkLogger.Info().Strs("ntpServers", ntpServers).Msg("setting NTP servers from network manager")
timeSync.SetDhcpNtpAddresses(ntpServers)
}
// sync time
go func() {
if err := timeSync.Sync(); err != nil {
networkLogger.Error().Err(err).Msg("failed to sync time after network state change")
}
}()
}
func networkStateChanged(_ string, state types.InterfaceState) {
// do not block the main thread
go waitCtrlAndRequestDisplayUpdate(true, "network_state_changed")
if currentSession != nil {
writeJSONRPCEvent("networkState", state.ToRpcInterfaceState(), currentSession)
}
if state.Online {
networkLogger.Info().Msg("network state changed to online, triggering time sync")
triggerTimeSyncOnNetworkStateChange()
}
// always restart mDNS when the network state changes
if mDNS != nil {
_ = mDNS.SetListenOptions(config.NetworkConfig.GetMDNSMode())
_ = mDNS.SetLocalNames([]string{
networkState.GetHostname(),
networkState.GetFQDN(),
}, true)
restartMdns()
}
}
func validateNetworkConfig() {
err := confparser.SetDefaultsAndValidate(config.NetworkConfig)
if err == nil {
return
}
// if the network is now online, trigger an NTP sync if still needed
if isOnline && timeSync != nil && (isTimeSyncNeeded() || !timeSync.IsSyncSuccess()) {
if err := timeSync.Sync(); err != nil {
logger.Warn().Str("error", err.Error()).Msg("unable to sync time on network state change")
}
networkLogger.Error().Err(err).Msg("failed to validate config, reverting to default config")
if err := SaveBackupConfig(); err != nil {
networkLogger.Error().Err(err).Msg("failed to save backup config")
}
// do not use a pointer to the default config
// it has been already changed during LoadConfig
config.NetworkConfig = &(types.NetworkConfig{})
if err := SaveConfig(); err != nil {
networkLogger.Error().Err(err).Msg("failed to save config")
}
}
func initNetwork() error {
ensureConfigLoaded()
state, err := network.NewNetworkInterfaceState(&network.NetworkInterfaceOptions{
DefaultHostname: GetDefaultHostname(),
InterfaceName: NetIfName,
NetworkConfig: config.NetworkConfig,
Logger: networkLogger,
OnStateChange: func(state *network.NetworkInterfaceState) {
networkStateChanged(state.IsOnline())
},
OnInitialCheck: func(state *network.NetworkInterfaceState) {
networkStateChanged(state.IsOnline())
},
OnDhcpLeaseChange: func(lease *udhcpc.Lease, state *network.NetworkInterfaceState) {
networkStateChanged(state.IsOnline())
// validate the config, if it's invalid, revert to the default config and save the backup
validateNetworkConfig()
if currentSession == nil {
return
}
nc := config.NetworkConfig
writeJSONRPCEvent("networkState", networkState.RpcGetNetworkState(), currentSession)
},
OnConfigChange: func(networkConfig *network.NetworkConfig) {
config.NetworkConfig = networkConfig
networkStateChanged(false)
if mDNS != nil {
_ = mDNS.SetListenOptions(networkConfig.GetMDNSMode())
_ = mDNS.SetLocalNames([]string{
networkState.GetHostname(),
networkState.GetFQDN(),
}, true)
}
},
})
if state == nil {
if err == nil {
return fmt.Errorf("failed to create NetworkInterfaceState")
}
return err
nm := nmlite.NewNetworkManager(context.Background(), networkLogger)
networkLogger.Info().Interface("networkConfig", nc).Str("hostname", nc.Hostname.String).Str("domain", nc.Domain.String).Msg("initializing network manager")
_ = setHostname(nm, nc.Hostname.String, nc.Domain.String)
nm.SetOnInterfaceStateChange(networkStateChanged)
if err := nm.AddInterface(NetIfName, nc); err != nil {
return fmt.Errorf("failed to add interface: %w", err)
}
_ = nm.CleanUpLegacyDHCPClients()
if err := state.Run(); err != nil {
return err
}
networkState = state
networkManager = nm
return nil
}
func rpcGetNetworkState() network.RpcNetworkState {
return networkState.RpcGetNetworkState()
func setHostname(nm *nmlite.NetworkManager, hostname, domain string) error {
if nm == nil {
return nil
}
if hostname == "" {
hostname = GetDefaultHostname()
}
return nm.SetHostname(hostname, domain)
}
func rpcGetNetworkSettings() network.RpcNetworkSettings {
return networkState.RpcGetNetworkSettings()
func shouldRebootForNetworkChange(oldConfig, newConfig *types.NetworkConfig) (rebootRequired bool, postRebootAction *PostRebootAction) {
oldDhcpClient := oldConfig.DHCPClient.String
l := networkLogger.With().
Interface("old", oldConfig).
Interface("new", newConfig).
Logger()
// DHCP client change always requires reboot
if newConfig.DHCPClient.String != oldDhcpClient {
rebootRequired = true
l.Info().Msg("DHCP client changed, reboot required")
return rebootRequired, postRebootAction
}
oldIPv4Mode := oldConfig.IPv4Mode.String
newIPv4Mode := newConfig.IPv4Mode.String
// IPv4 mode change requires reboot
if newIPv4Mode != oldIPv4Mode {
rebootRequired = true
l.Info().Msg("IPv4 mode changed with udhcpc, reboot required")
if newIPv4Mode == "static" && oldIPv4Mode != "static" {
postRebootAction = &PostRebootAction{
HealthCheck: fmt.Sprintf("//%s/device/status", newConfig.IPv4Static.Address.String),
RedirectTo: 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),
RedirectTo: fmt.Sprintf("//%s", newConfig.IPv4Static.Address.String),
}
l.Info().Interface("postRebootAction", postRebootAction).Msg("IPv4 static config changed, reboot required")
}
return rebootRequired, postRebootAction
}
// IPv6 mode change requires reboot when using udhcpc
if newConfig.IPv6Mode.String != oldConfig.IPv6Mode.String && oldDhcpClient == "udhcpc" {
rebootRequired = true
l.Info().Msg("IPv6 mode changed with udhcpc, reboot required")
}
return rebootRequired, postRebootAction
}
func rpcSetNetworkSettings(settings network.RpcNetworkSettings) (*network.RpcNetworkSettings, error) {
s := networkState.RpcSetNetworkSettings(settings)
func rpcGetNetworkState() *types.RpcInterfaceState {
state, _ := networkManager.GetInterfaceState(NetIfName)
return state.ToRpcInterfaceState()
}
func rpcGetNetworkSettings() *RpcNetworkSettings {
return toRpcNetworkSettings(config.NetworkConfig)
}
func rpcSetNetworkSettings(settings RpcNetworkSettings) (*RpcNetworkSettings, error) {
netConfig := settings.ToNetworkConfig()
l := networkLogger.With().
Str("interface", NetIfName).
Interface("newConfig", netConfig).
Logger()
l.Debug().Msg("setting new config")
// Check if reboot is needed
rebootRequired, postRebootAction := shouldRebootForNetworkChange(config.NetworkConfig, netConfig)
// If reboot required, send willReboot event before applying network config
if rebootRequired {
l.Info().Msg("Sending willReboot event before applying network config")
writeJSONRPCEvent("willReboot", postRebootAction, currentSession)
}
_ = setHostname(networkManager, netConfig.Hostname.String, netConfig.Domain.String)
s := networkManager.SetInterfaceConfig(NetIfName, netConfig)
if s != nil {
return nil, s
}
l.Debug().Msg("new config applied")
newConfig, err := networkManager.GetInterfaceConfig(NetIfName)
if err != nil {
return nil, err
}
config.NetworkConfig = newConfig
l.Debug().Msg("saving new config")
if err := SaveConfig(); err != nil {
return nil, err
}
return &network.RpcNetworkSettings{NetworkConfig: *config.NetworkConfig}, nil
if rebootRequired {
l.Info().Msg("Rebooting due to network changes")
if err := hwReboot(true, postRebootAction, 0); err != nil {
return nil, err
}
}
return toRpcNetworkSettings(newConfig), nil
}
func rpcRenewDHCPLease() error {
return networkState.RpcRenewDHCPLease()
return networkManager.RenewDHCPLease(NetIfName)
}
func rpcToggleDHCPClient() error {
switch config.NetworkConfig.DHCPClient.String {
case "jetdhcpc":
config.NetworkConfig.DHCPClient.String = "udhcpc"
case "udhcpc":
config.NetworkConfig.DHCPClient.String = "jetdhcpc"
}
if err := SaveConfig(); err != nil {
return err
}
return rpcReboot(true)
}

72
ota.go
View File

@ -176,7 +176,7 @@ func downloadFile(ctx context.Context, path string, url string, downloadProgress
if nr > 0 {
nw, ew := file.Write(buf[0:nr])
if nw < nr {
return fmt.Errorf("short write: %d < %d", nw, nr)
return fmt.Errorf("short file write: %d < %d", nw, nr)
}
written += int64(nw)
if ew != nil {
@ -240,7 +240,7 @@ func verifyFile(path string, expectedHash string, verifyProgress *float32, scope
if nr > 0 {
nw, ew := hash.Write(buf[0:nr])
if nw < nr {
return fmt.Errorf("short write: %d < %d", nw, nr)
return fmt.Errorf("short hash write: %d < %d", nw, nr)
}
verified += int64(nw)
if ew != nil {
@ -260,11 +260,16 @@ func verifyFile(path string, expectedHash string, verifyProgress *float32, scope
}
}
hashSum := hash.Sum(nil)
scopedLogger.Info().Str("path", path).Str("hash", hex.EncodeToString(hashSum)).Msg("SHA256 hash of")
// close the file so we can rename below
if err := fileToHash.Close(); err != nil {
return fmt.Errorf("error closing file: %w", err)
}
if hex.EncodeToString(hashSum) != expectedHash {
return fmt.Errorf("hash mismatch: %x != %s", hashSum, expectedHash)
hashSum := hex.EncodeToString(hash.Sum(nil))
scopedLogger.Info().Str("path", path).Str("hash", hashSum).Msg("SHA256 hash of")
if hashSum != expectedHash {
return fmt.Errorf("hash mismatch: %s != %s", hashSum, expectedHash)
}
if err := os.Rename(unverifiedPath, path); err != nil {
@ -313,7 +318,7 @@ func triggerOTAStateUpdate() {
func TryUpdate(ctx context.Context, deviceId string, includePreRelease bool) error {
scopedLogger := otaLogger.With().
Str("deviceId", deviceId).
Str("includePreRelease", fmt.Sprintf("%v", includePreRelease)).
Bool("includePreRelease", includePreRelease).
Logger()
scopedLogger.Info().Msg("Trying to update...")
@ -362,8 +367,9 @@ func TryUpdate(ctx context.Context, deviceId string, includePreRelease bool) err
otaState.Error = fmt.Sprintf("Error downloading app update: %v", err)
scopedLogger.Error().Err(err).Msg("Error downloading app update")
triggerOTAStateUpdate()
return err
return fmt.Errorf("error downloading app update: %w", err)
}
downloadFinished := time.Now()
otaState.AppDownloadFinishedAt = &downloadFinished
otaState.AppDownloadProgress = 1
@ -379,17 +385,21 @@ func TryUpdate(ctx context.Context, deviceId string, includePreRelease bool) err
otaState.Error = fmt.Sprintf("Error verifying app update hash: %v", err)
scopedLogger.Error().Err(err).Msg("Error verifying app update hash")
triggerOTAStateUpdate()
return err
return fmt.Errorf("error verifying app update: %w", err)
}
verifyFinished := time.Now()
otaState.AppVerifiedAt = &verifyFinished
otaState.AppVerificationProgress = 1
triggerOTAStateUpdate()
otaState.AppUpdatedAt = &verifyFinished
otaState.AppUpdateProgress = 1
triggerOTAStateUpdate()
scopedLogger.Info().Msg("App update downloaded")
rebootNeeded = true
triggerOTAStateUpdate()
} else {
scopedLogger.Info().Msg("App is up to date")
}
@ -405,8 +415,9 @@ func TryUpdate(ctx context.Context, deviceId string, includePreRelease bool) err
otaState.Error = fmt.Sprintf("Error downloading system update: %v", err)
scopedLogger.Error().Err(err).Msg("Error downloading system update")
triggerOTAStateUpdate()
return err
return fmt.Errorf("error downloading system update: %w", err)
}
downloadFinished := time.Now()
otaState.SystemDownloadFinishedAt = &downloadFinished
otaState.SystemDownloadProgress = 1
@ -422,8 +433,9 @@ func TryUpdate(ctx context.Context, deviceId string, includePreRelease bool) err
otaState.Error = fmt.Sprintf("Error verifying system update hash: %v", err)
scopedLogger.Error().Err(err).Msg("Error verifying system update hash")
triggerOTAStateUpdate()
return err
return fmt.Errorf("error verifying system update: %w", err)
}
scopedLogger.Info().Msg("System update downloaded")
verifyFinished := time.Now()
otaState.SystemVerifiedAt = &verifyFinished
@ -439,8 +451,10 @@ func TryUpdate(ctx context.Context, deviceId string, includePreRelease bool) err
if err != nil {
otaState.Error = fmt.Sprintf("Error starting rk_ota command: %v", err)
scopedLogger.Error().Err(err).Msg("Error starting rk_ota command")
triggerOTAStateUpdate()
return fmt.Errorf("error starting rk_ota command: %w", err)
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
@ -475,28 +489,42 @@ func TryUpdate(ctx context.Context, deviceId string, includePreRelease bool) err
Str("output", output).
Int("exitCode", cmd.ProcessState.ExitCode()).
Msg("Error executing rk_ota command")
triggerOTAStateUpdate()
return fmt.Errorf("error executing rk_ota command: %w\nOutput: %s", err, output)
}
scopedLogger.Info().Str("output", output).Msg("rk_ota success")
otaState.SystemUpdateProgress = 1
otaState.SystemUpdatedAt = &verifyFinished
triggerOTAStateUpdate()
rebootNeeded = true
triggerOTAStateUpdate()
} else {
scopedLogger.Info().Msg("System is up to date")
}
if rebootNeeded {
scopedLogger.Info().Msg("System Rebooting in 10s")
time.Sleep(10 * time.Second)
cmd := exec.Command("reboot")
err := cmd.Start()
if err != nil {
otaState.Error = fmt.Sprintf("Failed to start reboot: %v", err)
scopedLogger.Error().Err(err).Msg("Failed to start reboot")
return fmt.Errorf("failed to start reboot: %w", err)
} else {
os.Exit(0)
scopedLogger.Info().Msg("System Rebooting due to OTA update")
// Build redirect URL with conditional query parameters
redirectTo := "/settings/general/update"
queryParams := url.Values{}
if systemUpdateAvailable {
queryParams.Set("systemVersion", remote.SystemVersion)
}
if appUpdateAvailable {
queryParams.Set("appVersion", remote.AppVersion)
}
if len(queryParams) > 0 {
redirectTo += "?" + queryParams.Encode()
}
postRebootAction := &PostRebootAction{
HealthCheck: "/device/status",
RedirectTo: redirectTo,
}
if err := hwReboot(true, postRebootAction, 10*time.Second); err != nil {
return fmt.Errorf("error requesting reboot: %w", err)
}
}

219
pkg/nmlite/dhcp.go Normal file
View File

@ -0,0 +1,219 @@
// Package nmlite provides DHCP client functionality for the network manager.
package nmlite
import (
"context"
"fmt"
"github.com/jetkvm/kvm/internal/network/types"
"github.com/jetkvm/kvm/pkg/nmlite/jetdhcpc"
"github.com/jetkvm/kvm/pkg/nmlite/udhcpc"
"github.com/rs/zerolog"
)
// DHCPClient wraps the dhclient package for use in the network manager
type DHCPClient struct {
ctx context.Context
ifaceName string
logger *zerolog.Logger
client types.DHCPClient
clientType string
// Configuration
ipv4Enabled bool
ipv6Enabled bool
// Callbacks
onLeaseChange func(lease *types.DHCPLease)
}
// NewDHCPClient creates a new DHCP client
func NewDHCPClient(ctx context.Context, ifaceName string, logger *zerolog.Logger, clientType string) (*DHCPClient, error) {
if ifaceName == "" {
return nil, fmt.Errorf("interface name cannot be empty")
}
if logger == nil {
return nil, fmt.Errorf("logger cannot be nil")
}
return &DHCPClient{
ctx: ctx,
ifaceName: ifaceName,
logger: logger,
clientType: clientType,
}, nil
}
// SetIPv4 enables or disables IPv4 DHCP
func (dc *DHCPClient) SetIPv4(enabled bool) {
dc.ipv4Enabled = enabled
if dc.client != nil {
dc.client.SetIPv4(enabled)
}
}
// SetIPv6 enables or disables IPv6 DHCP
func (dc *DHCPClient) SetIPv6(enabled bool) {
dc.ipv6Enabled = enabled
if dc.client != nil {
dc.client.SetIPv6(enabled)
}
}
// SetOnLeaseChange sets the callback for lease changes
func (dc *DHCPClient) SetOnLeaseChange(callback func(lease *types.DHCPLease)) {
dc.onLeaseChange = callback
}
func (dc *DHCPClient) initClient() (types.DHCPClient, error) {
switch dc.clientType {
case "jetdhcpc":
return dc.initJetDHCPC()
case "udhcpc":
return dc.initUDHCPC()
default:
return nil, fmt.Errorf("invalid client type: %s", dc.clientType)
}
}
func (dc *DHCPClient) initJetDHCPC() (types.DHCPClient, error) {
return jetdhcpc.NewClient(dc.ctx, []string{dc.ifaceName}, &jetdhcpc.Config{
IPv4: dc.ipv4Enabled,
IPv6: dc.ipv6Enabled,
V4ClientIdentifier: true,
OnLease4Change: func(lease *types.DHCPLease) {
dc.handleLeaseChange(lease, false)
},
OnLease6Change: func(lease *types.DHCPLease) {
dc.handleLeaseChange(lease, true)
},
UpdateResolvConf: func(nameservers []string) error {
// This will be handled by the resolv.conf manager
dc.logger.Debug().
Interface("nameservers", nameservers).
Msg("DHCP client requested resolv.conf update")
return nil
},
}, dc.logger)
}
func (dc *DHCPClient) initUDHCPC() (types.DHCPClient, error) {
c := udhcpc.NewDHCPClient(&udhcpc.DHCPClientOptions{
InterfaceName: dc.ifaceName,
PidFile: "",
Logger: dc.logger,
OnLeaseChange: func(lease *types.DHCPLease) {
dc.handleLeaseChange(lease, false)
},
})
return c, nil
}
// Start starts the DHCP client
func (dc *DHCPClient) Start() error {
if dc.client != nil {
dc.logger.Warn().Msg("DHCP client already started")
return nil
}
dc.logger.Info().Msg("starting DHCP client")
// Create the underlying DHCP client
client, err := dc.initClient()
if err != nil {
return fmt.Errorf("failed to create DHCP client: %w", err)
}
dc.client = client
// Start the client
if err := dc.client.Start(); err != nil {
dc.client = nil
return fmt.Errorf("failed to start DHCP client: %w", err)
}
dc.logger.Info().Msg("DHCP client started")
return nil
}
func (dc *DHCPClient) Domain() string {
if dc.client == nil {
return ""
}
return dc.client.Domain()
}
func (dc *DHCPClient) Lease4() *types.DHCPLease {
if dc.client == nil {
return nil
}
return dc.client.Lease4()
}
func (dc *DHCPClient) Lease6() *types.DHCPLease {
if dc.client == nil {
return nil
}
return dc.client.Lease6()
}
// Stop stops the DHCP client
func (dc *DHCPClient) Stop() error {
if dc.client == nil {
return nil
}
dc.logger.Info().Msg("stopping DHCP client")
dc.client = nil
dc.logger.Info().Msg("DHCP client stopped")
return nil
}
// Renew renews the DHCP lease
func (dc *DHCPClient) Renew() error {
if dc.client == nil {
return fmt.Errorf("DHCP client not started")
}
dc.logger.Info().Msg("renewing DHCP lease")
if err := dc.client.Renew(); err != nil {
return fmt.Errorf("failed to renew DHCP lease: %w", err)
}
return nil
}
// Release releases the DHCP lease
func (dc *DHCPClient) Release() error {
if dc.client == nil {
return fmt.Errorf("DHCP client not started")
}
dc.logger.Info().Msg("releasing DHCP lease")
if err := dc.client.Release(); err != nil {
return fmt.Errorf("failed to release DHCP lease: %w", err)
}
return nil
}
// handleLeaseChange handles lease changes from the underlying DHCP client
func (dc *DHCPClient) handleLeaseChange(lease *types.DHCPLease, isIPv6 bool) {
if lease == nil {
return
}
dc.logger.Info().
Bool("ipv6", isIPv6).
Str("ip", lease.IPAddress.String()).
Msg("DHCP lease changed")
// copy the lease to avoid race conditions
leaseCopy := *lease
// Notify callback
if dc.onLeaseChange != nil {
dc.onLeaseChange(&leaseCopy)
}
}

261
pkg/nmlite/hostname.go Normal file
View File

@ -0,0 +1,261 @@
package nmlite
import (
"fmt"
"io"
"os"
"os/exec"
"strings"
"golang.org/x/net/idna"
)
const (
hostnamePath = "/etc/hostname"
hostsPath = "/etc/hosts"
)
// SetHostname sets the system hostname and updates /etc/hosts
func (hm *ResolvConfManager) SetHostname(hostname, domain string) error {
hostname = ToValidHostname(strings.TrimSpace(hostname))
domain = ToValidHostname(strings.TrimSpace(domain))
if hostname == "" {
return fmt.Errorf("invalid hostname: %s", hostname)
}
hm.hostname = hostname
hm.domain = domain
return hm.reconcileHostname()
}
func (hm *ResolvConfManager) Domain() string {
hm.mu.Lock()
defer hm.mu.Unlock()
return hm.getDomain()
}
func (hm *ResolvConfManager) Hostname() string {
hm.mu.Lock()
defer hm.mu.Unlock()
return hm.getHostname()
}
func (hm *ResolvConfManager) FQDN() string {
hm.mu.Lock()
defer hm.mu.Unlock()
return hm.getFQDN()
}
func (hm *ResolvConfManager) getFQDN() string {
hostname := hm.getHostname()
domain := hm.getDomain()
if domain == "" {
return hostname
}
return fmt.Sprintf("%s.%s", hostname, domain)
}
func (hm *ResolvConfManager) getHostname() string {
if hm.hostname != "" {
return hm.hostname
}
return "jetkvm"
}
func (hm *ResolvConfManager) getDomain() string {
if hm.domain != "" {
return hm.domain
}
for _, iface := range hm.conf.ConfigIPv4 {
if iface.Domain != "" {
return iface.Domain
}
}
for _, iface := range hm.conf.ConfigIPv6 {
if iface.Domain != "" {
return iface.Domain
}
}
return "local"
}
func (hm *ResolvConfManager) reconcileHostname() error {
hm.mu.Lock()
domain := hm.getDomain()
hostname := hm.hostname
if hostname == "" {
hostname = "jetkvm"
}
hm.mu.Unlock()
fqdn := hostname
if fqdn != "" {
fqdn = fmt.Sprintf("%s.%s", hostname, domain)
}
hm.logger.Info().
Str("hostname", hostname).
Str("fqdn", fqdn).
Msg("setting hostname")
// Update /etc/hostname
if err := hm.updateEtcHostname(hostname); err != nil {
return fmt.Errorf("failed to update /etc/hostname: %w", err)
}
// Update /etc/hosts
if err := hm.updateEtcHosts(hostname, fqdn); err != nil {
return fmt.Errorf("failed to update /etc/hosts: %w", err)
}
// Set the hostname using hostname command
if err := hm.setSystemHostname(hostname); err != nil {
return fmt.Errorf("failed to set system hostname: %w", err)
}
hm.logger.Info().
Str("hostname", hostname).
Str("fqdn", fqdn).
Msg("hostname set successfully")
return nil
}
// GetCurrentHostname returns the current system hostname
func (hm *ResolvConfManager) GetCurrentHostname() (string, error) {
return os.Hostname()
}
// GetCurrentFQDN returns the current FQDN
func (hm *ResolvConfManager) GetCurrentFQDN() (string, error) {
hostname, err := hm.GetCurrentHostname()
if err != nil {
return "", err
}
// Try to get the FQDN from /etc/hosts
return hm.getFQDNFromHosts(hostname)
}
// updateEtcHostname updates the /etc/hostname file
func (hm *ResolvConfManager) updateEtcHostname(hostname string) error {
if err := os.WriteFile(hostnamePath, []byte(hostname), 0644); err != nil {
return fmt.Errorf("failed to write %s: %w", hostnamePath, err)
}
hm.logger.Debug().Str("file", hostnamePath).Str("hostname", hostname).Msg("updated /etc/hostname")
return nil
}
// updateEtcHosts updates the /etc/hosts file
func (hm *ResolvConfManager) updateEtcHosts(hostname, fqdn string) error {
// Open /etc/hosts for reading and writing
hostsFile, err := os.OpenFile(hostsPath, os.O_RDWR|os.O_SYNC, os.ModeExclusive)
if err != nil {
return fmt.Errorf("failed to open %s: %w", hostsPath, err)
}
defer hostsFile.Close()
// Read all lines
if _, err := hostsFile.Seek(0, io.SeekStart); err != nil {
return fmt.Errorf("failed to seek %s: %w", hostsPath, err)
}
lines, err := io.ReadAll(hostsFile)
if err != nil {
return fmt.Errorf("failed to read %s: %w", hostsPath, err)
}
// Process lines
newLines := []string{}
hostLine := fmt.Sprintf("127.0.1.1\t%s %s", hostname, fqdn)
hostLineExists := false
for _, line := range strings.Split(string(lines), "\n") {
if strings.HasPrefix(line, "127.0.1.1") {
hostLineExists = true
line = hostLine
}
newLines = append(newLines, line)
}
// Add host line if it doesn't exist
if !hostLineExists {
newLines = append(newLines, hostLine)
}
// Write back to file
if err := hostsFile.Truncate(0); err != nil {
return fmt.Errorf("failed to truncate %s: %w", hostsPath, err)
}
if _, err := hostsFile.Seek(0, io.SeekStart); err != nil {
return fmt.Errorf("failed to seek %s: %w", hostsPath, err)
}
if _, err := hostsFile.Write([]byte(strings.Join(newLines, "\n"))); err != nil {
return fmt.Errorf("failed to write %s: %w", hostsPath, err)
}
hm.logger.Debug().
Str("file", hostsPath).
Str("hostname", hostname).
Str("fqdn", fqdn).
Msg("updated /etc/hosts")
return nil
}
// setSystemHostname sets the system hostname using the hostname command
func (hm *ResolvConfManager) setSystemHostname(hostname string) error {
cmd := exec.Command("hostname", "-F", hostnamePath)
if err := cmd.Run(); err != nil {
return fmt.Errorf("failed to run hostname command: %w", err)
}
hm.logger.Debug().Str("hostname", hostname).Msg("set system hostname")
return nil
}
// getFQDNFromHosts tries to get the FQDN from /etc/hosts
func (hm *ResolvConfManager) getFQDNFromHosts(hostname string) (string, error) {
content, err := os.ReadFile(hostsPath)
if err != nil {
return hostname, nil // Return hostname as fallback
}
lines := strings.Split(string(content), "\n")
for _, line := range lines {
if strings.HasPrefix(line, "127.0.1.1") {
parts := strings.Fields(line)
if len(parts) >= 2 {
// The second part should be the FQDN
return parts[1], nil
}
}
}
return hostname, nil // Return hostname as fallback
}
// ToValidHostname converts a hostname to a valid format
func ToValidHostname(hostname string) string {
ascii, err := idna.Lookup.ToASCII(hostname)
if err != nil {
return ""
}
return ascii
}
// ValidateHostname validates a hostname
func ValidateHostname(hostname string) error {
_, err := idna.Lookup.ToASCII(hostname)
return err
}

853
pkg/nmlite/interface.go Normal file
View File

@ -0,0 +1,853 @@
package nmlite
import (
"context"
"fmt"
"net"
"net/netip"
"time"
"github.com/jetkvm/kvm/internal/sync"
"github.com/jetkvm/kvm/internal/confparser"
"github.com/jetkvm/kvm/internal/logging"
"github.com/jetkvm/kvm/internal/network/types"
"github.com/jetkvm/kvm/pkg/nmlite/link"
"github.com/mdlayher/ndp"
"github.com/rs/zerolog"
"github.com/vishvananda/netlink"
)
type ResolvConfChangeCallback func(family int, resolvConf *types.InterfaceResolvConf) error
// InterfaceManager manages a single network interface
type InterfaceManager struct {
ctx context.Context
ifaceName string
config *types.NetworkConfig
logger *zerolog.Logger
state *types.InterfaceState
linkState *link.Link
stateMu sync.RWMutex
// Network components
staticConfig *StaticConfigManager
dhcpClient *DHCPClient
// Callbacks
onStateChange func(state types.InterfaceState)
onConfigChange func(config *types.NetworkConfig)
onDHCPLeaseChange func(lease *types.DHCPLease)
onResolvConfChange ResolvConfChangeCallback
// Control
stopCh chan struct{}
wg sync.WaitGroup
}
// NewInterfaceManager creates a new interface manager
func NewInterfaceManager(ctx context.Context, ifaceName string, config *types.NetworkConfig, logger *zerolog.Logger) (*InterfaceManager, error) {
if config == nil {
return nil, fmt.Errorf("config cannot be nil")
}
if logger == nil {
logger = logging.GetSubsystemLogger("interface")
}
scopedLogger := logger.With().Str("interface", ifaceName).Logger()
// Validate and set defaults
if err := confparser.SetDefaultsAndValidate(config); err != nil {
return nil, fmt.Errorf("invalid config: %w", err)
}
im := &InterfaceManager{
ctx: ctx,
ifaceName: ifaceName,
config: config,
logger: &scopedLogger,
state: &types.InterfaceState{
InterfaceName: ifaceName,
// LastUpdated: time.Now(),
},
stopCh: make(chan struct{}),
}
// Initialize components
var err error
im.staticConfig, err = NewStaticConfigManager(ifaceName, &scopedLogger)
if err != nil {
return nil, fmt.Errorf("failed to create static config manager: %w", err)
}
// create the dhcp client
im.dhcpClient, err = NewDHCPClient(ctx, ifaceName, &scopedLogger, config.DHCPClient.String)
if err != nil {
return nil, fmt.Errorf("failed to create DHCP client: %w", err)
}
// Set up DHCP client callbacks
im.dhcpClient.SetOnLeaseChange(func(lease *types.DHCPLease) {
if im.config.IPv4Mode.String != "dhcp" {
im.logger.Warn().Str("mode", im.config.IPv4Mode.String).Msg("ignoring DHCP lease, current mode is not DHCP")
return
}
if err := im.applyDHCPLease(lease); err != nil {
im.logger.Error().Err(err).Msg("failed to apply DHCP lease")
}
im.updateStateFromDHCPLease(lease)
if im.onDHCPLeaseChange != nil {
im.onDHCPLeaseChange(lease)
}
})
return im, nil
}
// Start starts managing the interface
func (im *InterfaceManager) Start() error {
im.stateMu.Lock()
defer im.stateMu.Unlock()
im.logger.Info().Msg("starting interface manager")
// Start monitoring interface state
im.wg.Add(1)
go im.monitorInterfaceState()
nl := getNetlinkManager()
// Set the link state
linkState, err := nl.GetLinkByName(im.ifaceName)
if err != nil {
return fmt.Errorf("failed to get interface: %w", err)
}
im.linkState = linkState
// Bring the interface up
_, linkUpErr := nl.EnsureInterfaceUpWithTimeout(
im.ctx,
im.linkState,
30*time.Second,
)
// Set callback after the interface is up
nl.AddStateChangeCallback(im.ifaceName, link.StateChangeCallback{
Async: true,
Func: func(link *link.Link) {
im.handleLinkStateChange(link)
},
})
if linkUpErr != nil {
im.logger.Error().Err(linkUpErr).Msg("failed to bring interface up, continuing anyway")
} else {
// Apply initial configuration
if err := im.applyConfiguration(); err != nil {
im.logger.Error().Err(err).Msg("failed to apply initial configuration")
return err
}
}
im.logger.Info().Msg("interface manager started")
return nil
}
// Stop stops managing the interface
func (im *InterfaceManager) Stop() error {
im.logger.Info().Msg("stopping interface manager")
close(im.stopCh)
im.wg.Wait()
// Stop DHCP client
if im.dhcpClient != nil {
if err := im.dhcpClient.Stop(); err != nil {
return fmt.Errorf("failed to stop DHCP client: %w", err)
}
}
im.logger.Info().Msg("interface manager stopped")
return nil
}
func (im *InterfaceManager) link() (*link.Link, error) {
nl := getNetlinkManager()
if nl == nil {
return nil, fmt.Errorf("netlink manager not initialized")
}
return nl.GetLinkByName(im.ifaceName)
}
// IsUp returns true if the interface is up
func (im *InterfaceManager) IsUp() bool {
im.stateMu.RLock()
defer im.stateMu.RUnlock()
if im.state == nil {
return false
}
return im.state.Up
}
// IsOnline returns true if the interface is online
func (im *InterfaceManager) IsOnline() bool {
im.stateMu.RLock()
defer im.stateMu.RUnlock()
if im.state == nil {
return false
}
return im.state.Online
}
// IPv4Ready returns true if the interface has an IPv4 address
func (im *InterfaceManager) IPv4Ready() bool {
im.stateMu.RLock()
defer im.stateMu.RUnlock()
if im.state == nil {
return false
}
return im.state.IPv4Ready
}
// IPv6Ready returns true if the interface has an IPv6 address
func (im *InterfaceManager) IPv6Ready() bool {
im.stateMu.RLock()
defer im.stateMu.RUnlock()
if im.state == nil {
return false
}
return im.state.IPv6Ready
}
// GetIPv4Addresses returns the IPv4 addresses of the interface
func (im *InterfaceManager) GetIPv4Addresses() []string {
im.stateMu.RLock()
defer im.stateMu.RUnlock()
if im.state == nil {
return []string{}
}
return im.state.IPv4Addresses
}
// GetIPv4Address returns the IPv4 address of the interface
func (im *InterfaceManager) GetIPv4Address() string {
im.stateMu.RLock()
defer im.stateMu.RUnlock()
if im.state == nil {
return ""
}
return im.state.IPv4Address
}
// GetIPv6Address returns the IPv6 address of the interface
func (im *InterfaceManager) GetIPv6Address() string {
im.stateMu.RLock()
defer im.stateMu.RUnlock()
if im.state == nil {
return ""
}
return im.state.IPv6Address
}
// GetIPv6Addresses returns the IPv6 addresses of the interface
func (im *InterfaceManager) GetIPv6Addresses() []string {
im.stateMu.RLock()
defer im.stateMu.RUnlock()
addresses := []string{}
if im.state == nil {
return addresses
}
for _, addr := range im.state.IPv6Addresses {
addresses = append(addresses, addr.Address.String())
}
return addresses
}
// GetMACAddress returns the MAC address of the interface
func (im *InterfaceManager) GetMACAddress() string {
im.stateMu.RLock()
defer im.stateMu.RUnlock()
if im.state == nil {
return ""
}
return im.state.MACAddress
}
// GetState returns the current interface state
func (im *InterfaceManager) GetState() *types.InterfaceState {
im.stateMu.RLock()
defer im.stateMu.RUnlock()
// Return a copy to avoid race conditions
im.logger.Debug().Interface("state", im.state).Msg("getting interface state")
state := *im.state
return &state
}
// NTPServers returns the NTP servers of the interface
func (im *InterfaceManager) NTPServers() []net.IP {
im.stateMu.RLock()
defer im.stateMu.RUnlock()
if im.state == nil {
return []net.IP{}
}
return im.state.NTPServers
}
func (im *InterfaceManager) Domain() string {
im.stateMu.RLock()
defer im.stateMu.RUnlock()
if im.state == nil {
return ""
}
if im.state.DHCPLease4 != nil {
return im.state.DHCPLease4.Domain
}
if im.state.DHCPLease6 != nil {
return im.state.DHCPLease6.Domain
}
return ""
}
// GetConfig returns the current interface configuration
func (im *InterfaceManager) GetConfig() *types.NetworkConfig {
// Return a copy to avoid race conditions
config := *im.config
return &config
}
// ApplyConfiguration applies the current configuration to the interface
func (im *InterfaceManager) ApplyConfiguration() error {
return im.applyConfiguration()
}
// SetConfig updates the interface configuration
func (im *InterfaceManager) SetConfig(config *types.NetworkConfig) error {
if config == nil {
return fmt.Errorf("config cannot be nil")
}
// Validate and set defaults
if err := confparser.SetDefaultsAndValidate(config); err != nil {
return fmt.Errorf("invalid config: %w", err)
}
im.config = config
// Apply the new configuration
if err := im.applyConfiguration(); err != nil {
im.logger.Error().Err(err).Msg("failed to apply new configuration")
return err
}
// Notify callback
if im.onConfigChange != nil {
im.onConfigChange(config)
}
im.logger.Info().Msg("configuration updated")
return nil
}
// RenewDHCPLease renews the DHCP lease
func (im *InterfaceManager) RenewDHCPLease() error {
if im.dhcpClient == nil {
return fmt.Errorf("DHCP client not available")
}
return im.dhcpClient.Renew()
}
// SetOnStateChange sets the callback for state changes
func (im *InterfaceManager) SetOnStateChange(callback func(state types.InterfaceState)) {
im.onStateChange = callback
}
// SetOnConfigChange sets the callback for configuration changes
func (im *InterfaceManager) SetOnConfigChange(callback func(config *types.NetworkConfig)) {
im.onConfigChange = callback
}
// SetOnDHCPLeaseChange sets the callback for DHCP lease changes
func (im *InterfaceManager) SetOnDHCPLeaseChange(callback func(lease *types.DHCPLease)) {
im.onDHCPLeaseChange = callback
}
// SetOnResolvConfChange sets the callback for resolv.conf changes
func (im *InterfaceManager) SetOnResolvConfChange(callback ResolvConfChangeCallback) {
im.onResolvConfChange = callback
}
// applyConfiguration applies the current configuration to the interface
func (im *InterfaceManager) applyConfiguration() error {
im.logger.Info().Msg("applying configuration")
// Apply IPv4 configuration
if err := im.applyIPv4Config(); err != nil {
return fmt.Errorf("failed to apply IPv4 config: %w", err)
}
// Apply IPv6 configuration
if err := im.applyIPv6Config(); err != nil {
return fmt.Errorf("failed to apply IPv6 config: %w", err)
}
return nil
}
// applyIPv4Config applies IPv4 configuration
func (im *InterfaceManager) applyIPv4Config() error {
mode := im.config.IPv4Mode.String
im.logger.Info().Str("mode", mode).Msg("applying IPv4 configuration")
switch mode {
case "static":
return im.applyIPv4Static()
case "dhcp":
return im.applyIPv4DHCP()
case "disabled":
return im.disableIPv4()
default:
return fmt.Errorf("invalid IPv4 mode: %s", mode)
}
}
// applyIPv6Config applies IPv6 configuration
func (im *InterfaceManager) applyIPv6Config() error {
mode := im.config.IPv6Mode.String
im.logger.Info().Str("mode", mode).Msg("applying IPv6 configuration")
switch mode {
case "static":
return im.applyIPv6Static()
case "dhcpv6":
return im.applyIPv6DHCP()
case "slaac":
return im.applyIPv6SLAAC()
case "slaac_and_dhcpv6":
return im.applyIPv6SLAACAndDHCP()
case "link_local":
return im.applyIPv6LinkLocal()
case "disabled":
return im.disableIPv6()
default:
return fmt.Errorf("invalid IPv6 mode: %s", mode)
}
}
// applyIPv4Static applies static IPv4 configuration
func (im *InterfaceManager) applyIPv4Static() error {
if im.config.IPv4Static == nil {
return fmt.Errorf("IPv4 static configuration is nil")
}
im.logger.Info().Msg("stopping DHCP")
// Disable DHCP
if im.dhcpClient != nil {
im.dhcpClient.SetIPv4(false)
}
im.logger.Info().Interface("config", im.config.IPv4Static).Msg("applying IPv4 static configuration")
config, err := im.staticConfig.ToIPv4Static(im.config.IPv4Static)
if err != nil {
return fmt.Errorf("failed to convert IPv4 static configuration: %w", err)
}
im.logger.Info().Interface("config", config).Msg("converted IPv4 static configuration")
if err := im.onResolvConfChange(link.AfInet, &types.InterfaceResolvConf{
NameServers: config.Nameservers,
Source: "static",
}); err != nil {
im.logger.Warn().Err(err).Msg("failed to update resolv.conf")
}
return im.ReconcileLinkAddrs(config.Addresses, link.AfInet)
}
// applyIPv4DHCP applies DHCP IPv4 configuration
func (im *InterfaceManager) applyIPv4DHCP() error {
if im.dhcpClient == nil {
return fmt.Errorf("DHCP client not available")
}
// Enable DHCP
im.dhcpClient.SetIPv4(true)
return im.dhcpClient.Start()
}
// disableIPv4 disables IPv4
func (im *InterfaceManager) disableIPv4() error {
// Disable DHCP
if im.dhcpClient != nil {
im.dhcpClient.SetIPv4(false)
}
// Remove all IPv4 addresses
return im.staticConfig.DisableIPv4()
}
// applyIPv6Static applies static IPv6 configuration
func (im *InterfaceManager) applyIPv6Static() error {
if im.config.IPv6Static == nil {
return fmt.Errorf("IPv6 static configuration is nil")
}
im.logger.Info().Msg("stopping DHCPv6")
// Disable DHCPv6
if im.dhcpClient != nil {
im.dhcpClient.SetIPv6(false)
}
// Apply static configuration
config, err := im.staticConfig.ToIPv6Static(im.config.IPv6Static)
if err != nil {
return fmt.Errorf("failed to convert IPv6 static configuration: %w", err)
}
im.logger.Info().Interface("config", config).Msg("converted IPv6 static configuration")
if err := im.onResolvConfChange(link.AfInet6, &types.InterfaceResolvConf{
NameServers: config.Nameservers,
Source: "static",
}); err != nil {
im.logger.Warn().Err(err).Msg("failed to update resolv.conf")
}
return im.ReconcileLinkAddrs(config.Addresses, link.AfInet6)
}
// applyIPv6DHCP applies DHCPv6 configuration
func (im *InterfaceManager) applyIPv6DHCP() error {
if im.dhcpClient == nil {
return fmt.Errorf("DHCP client not available")
}
// Enable DHCPv6
im.dhcpClient.SetIPv6(true)
return im.dhcpClient.Start()
}
// applyIPv6SLAAC applies SLAAC configuration
func (im *InterfaceManager) applyIPv6SLAAC() error {
// Disable DHCPv6
if im.dhcpClient != nil {
im.dhcpClient.SetIPv6(false)
}
// Remove static IPv6 configuration
l, err := im.link()
if err != nil {
return fmt.Errorf("failed to get interface: %w", err)
}
netlinkMgr := getNetlinkManager()
// Ensure interface is up
if err := netlinkMgr.EnsureInterfaceUp(l); err != nil {
return fmt.Errorf("failed to bring interface up: %w", err)
}
if err := netlinkMgr.RemoveNonLinkLocalIPv6Addresses(l); err != nil {
return fmt.Errorf("failed to remove non-link-local IPv6 addresses: %w", err)
}
if err := im.SendRouterSolicitation(); err != nil {
im.logger.Error().Err(err).Msg("failed to send router solicitation, continuing anyway")
}
// Enable SLAAC
return im.staticConfig.EnableIPv6SLAAC()
}
// applyIPv6SLAACAndDHCP applies SLAAC + DHCPv6 configuration
func (im *InterfaceManager) applyIPv6SLAACAndDHCP() error {
// Enable both SLAAC and DHCPv6
if im.dhcpClient != nil {
im.dhcpClient.SetIPv6(true)
if err := im.dhcpClient.Start(); err != nil {
return fmt.Errorf("failed to start DHCP client: %w", err)
}
}
return im.staticConfig.EnableIPv6SLAAC()
}
// applyIPv6LinkLocal applies link-local only IPv6 configuration
func (im *InterfaceManager) applyIPv6LinkLocal() error {
// Disable DHCPv6
if im.dhcpClient != nil {
im.dhcpClient.SetIPv6(false)
}
// Enable link-local only
return im.staticConfig.EnableIPv6LinkLocal()
}
// disableIPv6 disables IPv6
func (im *InterfaceManager) disableIPv6() error {
// Disable DHCPv6
if im.dhcpClient != nil {
im.dhcpClient.SetIPv6(false)
}
// Disable IPv6
return im.staticConfig.DisableIPv6()
}
func (im *InterfaceManager) handleLinkStateChange(link *link.Link) {
{
im.stateMu.Lock()
defer im.stateMu.Unlock()
if link.IsSame(im.linkState) {
return
}
im.linkState = link
}
im.logger.Info().Interface("link", link).Msg("link state changed")
operState := link.Attrs().OperState
if operState == netlink.OperUp {
im.handleLinkUp()
} else {
im.handleLinkDown()
}
}
// SendRouterSolicitation sends a router solicitation
func (im *InterfaceManager) SendRouterSolicitation() error {
im.logger.Info().Msg("sending router solicitation")
m := &ndp.RouterSolicitation{}
l, err := im.link()
if err != nil {
return fmt.Errorf("failed to get interface: %w", err)
}
if l.Attrs().OperState != netlink.OperUp {
return fmt.Errorf("interface %s is not up", im.ifaceName)
}
iface := l.Interface()
if iface == nil {
return fmt.Errorf("failed to get net.Interface for %s", im.ifaceName)
}
hwAddr := l.HardwareAddr()
if hwAddr == nil {
return fmt.Errorf("failed to get hardware address for %s", im.ifaceName)
}
c, _, err := ndp.Listen(iface, ndp.LinkLocal)
if err != nil {
return fmt.Errorf("failed to create NDP listener on %s: %w", im.ifaceName, err)
}
m.Options = append(m.Options, &ndp.LinkLayerAddress{
Addr: hwAddr,
Direction: ndp.Source,
})
targetAddr := netip.MustParseAddr("ff02::2")
if err := c.WriteTo(m, nil, targetAddr); err != nil {
c.Close()
return fmt.Errorf("failed to write to %s: %w", targetAddr.String(), err)
}
im.logger.Info().Msg("router solicitation sent")
c.Close()
return nil
}
func (im *InterfaceManager) handleLinkUp() {
im.logger.Info().Msg("link up")
if err := im.applyConfiguration(); err != nil {
im.logger.Error().Err(err).Msg("failed to apply configuration")
}
if im.config.IPv4Mode.String == "dhcp" {
if err := im.dhcpClient.Renew(); err != nil {
im.logger.Error().Err(err).Msg("failed to renew DHCP lease")
}
}
if im.config.IPv6Mode.String == "slaac" {
if err := im.staticConfig.EnableIPv6SLAAC(); err != nil {
im.logger.Error().Err(err).Msg("failed to enable IPv6 SLAAC")
}
if err := im.SendRouterSolicitation(); err != nil {
im.logger.Error().Err(err).Msg("failed to send router solicitation")
}
}
}
func (im *InterfaceManager) handleLinkDown() {
im.logger.Info().Msg("link down")
if im.config.IPv4Mode.String == "dhcp" {
if err := im.dhcpClient.Stop(); err != nil {
im.logger.Error().Err(err).Msg("failed to stop DHCP client")
}
}
netlinkMgr := getNetlinkManager()
if err := netlinkMgr.RemoveAllAddresses(im.linkState, link.AfInet); err != nil {
im.logger.Error().Err(err).Msg("failed to remove all IPv4 addresses")
}
if err := netlinkMgr.RemoveNonLinkLocalIPv6Addresses(im.linkState); err != nil {
im.logger.Error().Err(err).Msg("failed to remove non-link-local IPv6 addresses")
}
}
// monitorInterfaceState monitors the interface state and updates accordingly
func (im *InterfaceManager) monitorInterfaceState() {
defer im.wg.Done()
im.logger.Debug().Msg("monitoring interface state")
// TODO: use netlink subscription instead of polling
ticker := time.NewTicker(5 * time.Second)
defer ticker.Stop()
for {
select {
case <-im.ctx.Done():
return
case <-im.stopCh:
return
case <-ticker.C:
if err := im.updateInterfaceState(); err != nil {
im.logger.Error().Err(err).Msg("failed to update interface state")
}
}
}
}
// updateStateFromDHCPLease updates the state from a DHCP lease
func (im *InterfaceManager) updateStateFromDHCPLease(lease *types.DHCPLease) {
family := link.AfInet
im.stateMu.Lock()
if lease.IsIPv6() {
im.state.DHCPLease6 = lease
family = link.AfInet6
} else {
im.state.DHCPLease4 = lease
}
im.stateMu.Unlock()
// Update resolv.conf with DNS information
if im.onResolvConfChange == nil {
return
}
if im.ifaceName == "" {
im.logger.Warn().Msg("interface name is empty, skipping resolv.conf update")
return
}
if err := im.onResolvConfChange(family, &types.InterfaceResolvConf{
NameServers: lease.DNS,
SearchList: lease.SearchList,
Source: "dhcp",
}); err != nil {
im.logger.Warn().Err(err).Msg("failed to update resolv.conf")
}
}
// ReconcileLinkAddrs reconciles the link addresses
func (im *InterfaceManager) ReconcileLinkAddrs(addrs []types.IPAddress, family int) error {
nl := getNetlinkManager()
link, err := im.link()
if err != nil {
return fmt.Errorf("failed to get interface: %w", err)
}
if link == nil {
return fmt.Errorf("failed to get interface: %w", err)
}
return nl.ReconcileLink(link, addrs, family)
}
// applyDHCPLease applies DHCP lease configuration using ReconcileLinkAddrs
func (im *InterfaceManager) applyDHCPLease(lease *types.DHCPLease) error {
if lease == nil {
return fmt.Errorf("DHCP lease is nil")
}
if lease.DHCPClient != "jetdhcpc" {
im.logger.Warn().Str("dhcp_client", lease.DHCPClient).Msg("ignoring DHCP lease, not implemented yet")
return nil
}
if lease.IsIPv6() {
im.logger.Warn().Msg("ignoring IPv6 DHCP lease, not implemented yet")
return nil
}
// Convert DHCP lease to IPv4Config
ipv4Config := im.convertDHCPLeaseToIPv4Config(lease)
// Apply the configuration using ReconcileLinkAddrs
return im.ReconcileLinkAddrs([]types.IPAddress{*ipv4Config}, link.AfInet)
}
// convertDHCPLeaseToIPv4Config converts a DHCP lease to IPv4Config
func (im *InterfaceManager) convertDHCPLeaseToIPv4Config(lease *types.DHCPLease) *types.IPAddress {
ipNet := lease.IPNet()
if ipNet == nil {
return nil
}
// Create IPv4Address
ipv4Addr := types.IPAddress{
Address: *ipNet,
Gateway: lease.Routers[0],
Secondary: false,
Permanent: false,
}
im.logger.Trace().
Interface("ipv4Addr", ipv4Addr).
Interface("lease", lease).
Msg("converted DHCP lease to IPv4Config")
// Create IPv4Config
return &ipv4Addr
}

View File

@ -0,0 +1,163 @@
package nmlite
import (
"fmt"
"time"
"github.com/jetkvm/kvm/internal/network/types"
"github.com/jetkvm/kvm/pkg/nmlite/link"
"github.com/vishvananda/netlink"
)
// updateInterfaceState updates the current interface state
func (im *InterfaceManager) updateInterfaceState() error {
nl, err := im.link()
if err != nil {
return fmt.Errorf("failed to get interface: %w", err)
}
var stateChanged bool
attrs := nl.Attrs()
// We should release the lock before calling the callbacks
// to avoid deadlocks
im.stateMu.Lock()
// Check if the interface is up
isUp := attrs.OperState == netlink.OperUp
if im.state.Up != isUp {
im.state.Up = isUp
stateChanged = true
}
// Check if the interface is online
isOnline := isUp && nl.HasGlobalUnicastAddress()
if im.state.Online != isOnline {
im.state.Online = isOnline
stateChanged = true
}
// Check if the MAC address has changed
if im.state.MACAddress != attrs.HardwareAddr.String() {
im.state.MACAddress = attrs.HardwareAddr.String()
stateChanged = true
}
// Update IP addresses
if ipChanged, err := im.updateInterfaceStateAddresses(nl); err != nil {
im.logger.Error().Err(err).Msg("failed to update IP addresses")
} else if ipChanged {
stateChanged = true
}
im.state.LastUpdated = time.Now()
im.stateMu.Unlock()
// Notify callback if state changed
if stateChanged && im.onStateChange != nil {
im.logger.Debug().Interface("state", im.state).Msg("notifying state change")
im.onStateChange(*im.state)
}
return nil
}
// updateIPAddresses updates the IP addresses in the state
func (im *InterfaceManager) updateInterfaceStateAddresses(nl *link.Link) (bool, error) {
mgr := getNetlinkManager()
addrs, err := nl.AddrList(link.AfUnspec)
if err != nil {
return false, fmt.Errorf("failed to get addresses: %w", err)
}
var (
ipv4Addresses []string
ipv6Addresses []types.IPv6Address
ipv4Addr, ipv6Addr string
ipv6LinkLocal string
ipv6Gateway string
ipv4Ready, ipv6Ready = false, false
stateChanged = false
)
routes, _ := mgr.ListDefaultRoutes(link.AfInet6)
if len(routes) > 0 {
ipv6Gateway = routes[0].Gw.String()
}
for _, addr := range addrs {
if addr.IP.To4() != nil {
// IPv4 address
ipv4Addresses = append(ipv4Addresses, addr.IPNet.String())
if ipv4Addr == "" {
ipv4Addr = addr.IP.String()
ipv4Ready = true
}
continue
}
// IPv6 address (if it's not an IPv4 address, it must be an IPv6 address)
if addr.IP.IsLinkLocalUnicast() {
ipv6LinkLocal = addr.IP.String()
continue
} else if !addr.IP.IsGlobalUnicast() {
continue
}
ipv6Addresses = append(ipv6Addresses, types.IPv6Address{
Address: addr.IP,
Prefix: *addr.IPNet,
Scope: addr.Scope,
Flags: addr.Flags,
ValidLifetime: lifetimeToTime(addr.ValidLft),
PreferredLifetime: lifetimeToTime(addr.PreferedLft),
})
if ipv6Addr == "" {
ipv6Addr = addr.IP.String()
ipv6Ready = true
}
}
if !sortAndCompareStringSlices(im.state.IPv4Addresses, ipv4Addresses) {
im.state.IPv4Addresses = ipv4Addresses
stateChanged = true
}
if !sortAndCompareIPv6AddressSlices(im.state.IPv6Addresses, ipv6Addresses) {
im.state.IPv6Addresses = ipv6Addresses
stateChanged = true
}
if im.state.IPv4Address != ipv4Addr {
im.state.IPv4Address = ipv4Addr
stateChanged = true
}
if im.state.IPv6Address != ipv6Addr {
im.state.IPv6Address = ipv6Addr
stateChanged = true
}
if im.state.IPv6LinkLocal != ipv6LinkLocal {
im.state.IPv6LinkLocal = ipv6LinkLocal
stateChanged = true
}
if im.state.IPv6Gateway != ipv6Gateway {
im.state.IPv6Gateway = ipv6Gateway
stateChanged = true
}
if im.state.IPv4Ready != ipv4Ready {
im.state.IPv4Ready = ipv4Ready
stateChanged = true
}
if im.state.IPv6Ready != ipv6Ready {
im.state.IPv6Ready = ipv6Ready
stateChanged = true
}
return stateChanged, nil
}

View File

@ -0,0 +1,419 @@
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
defaultDHCPTimeout = 5 * time.Second // DHCP request timeout (not link up timeout)
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 = defaultDHCPTimeout
}
if cfg.Retries == 0 {
cfg.Retries = 4
}
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, attempt int, l *zerolog.Logger) {
// Exponential backoff: 1s, 2s, 4s, 8s, max 8s
backoffAttempt := attempt
if backoffAttempt > 3 {
backoffAttempt = 3
}
delay := time.Duration(1<<backoffAttempt) * time.Second
l.Debug().Dur("delay", delay).Int("attempt", attempt).Msg("will retry later")
t.Reset(delay)
}
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()
attempt := 0
for range t.C {
l.Info().Int("attempt", attempt).Msg("requesting lease")
if _, err := c.ensureInterfaceUp(ifname); err != nil {
l.Error().Err(err).Int("attempt", attempt).Msg("failed to ensure interface up")
resetTimer(t, attempt, c.l)
attempt++
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).Int("attempt", attempt).Msg("failed to request lease")
resetTimer(t, attempt, c.l)
attempt++
continue
}
// Successfully obtained lease, reset attempt counter
attempt = 0
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

@ -0,0 +1,85 @@
package jetdhcpc
import (
"fmt"
"github.com/insomniacslk/dhcp/dhcpv4"
"github.com/insomniacslk/dhcp/dhcpv4/nclient4"
"github.com/vishvananda/netlink"
)
func (c *Client) requestLease4(ifname string) (*Lease, error) {
iface, err := netlink.LinkByName(ifname)
if err != nil {
return nil, err
}
l := c.l.With().Str("interface", ifname).Logger()
mods := []nclient4.ClientOpt{
nclient4.WithTimeout(c.cfg.Timeout),
nclient4.WithRetry(c.cfg.Retries),
}
mods = append(mods, c.getDHCP4Logger(ifname))
if c.cfg.V4ServerAddr != nil {
mods = append(mods, nclient4.WithServerAddr(c.cfg.V4ServerAddr))
}
client, err := nclient4.New(ifname, mods...)
if err != nil {
return nil, err
}
defer client.Close()
// Prepend modifiers with default options, so they can be overridden.
reqmods := append(
[]dhcpv4.Modifier{
dhcpv4.WithOption(dhcpv4.OptClassIdentifier(VendorIdentifier)),
dhcpv4.WithRequestedOptions(
dhcpv4.OptionSubnetMask,
dhcpv4.OptionInterfaceMTU,
dhcpv4.OptionNTPServers,
dhcpv4.OptionDomainName,
dhcpv4.OptionDomainNameServer,
dhcpv4.OptionDNSDomainSearchList,
),
},
c.cfg.Modifiers4...)
if c.cfg.V4ClientIdentifier {
// Client Id is hardware type + mac per RFC 2132 9.14.
ident := []byte{0x01} // Type ethernet
ident = append(ident, iface.Attrs().HardwareAddr...)
reqmods = append(reqmods, dhcpv4.WithOption(dhcpv4.OptClientIdentifier(ident)))
}
if c.cfg.Hostname != "" {
reqmods = append(reqmods, dhcpv4.WithOption(dhcpv4.OptHostName(c.cfg.Hostname)))
}
l.Info().Msg("attempting to get DHCPv4 lease")
var (
lease *nclient4.Lease
reqErr error
)
if c.currentLease4 != nil {
l.Info().Msg("current lease is not nil, renewing")
lease, reqErr = client.Renew(c.ctx, c.currentLease4.p4, reqmods...)
} else {
l.Info().Msg("current lease is nil, requesting new lease")
lease, reqErr = client.Request(c.ctx, reqmods...)
}
if reqErr != nil {
return nil, reqErr
}
if lease == nil || lease.ACK == nil {
return nil, fmt.Errorf("failed to acquire DHCPv4 lease")
}
summaryStructured(lease.ACK, &l).Info().Msgf("DHCPv4 lease acquired: %s", lease.ACK.String())
l.Trace().Interface("options", lease.ACK.Options.String()).Msg("DHCPv4 lease options")
return fromNclient4Lease(lease, ifname), nil
}

View File

@ -0,0 +1,135 @@
package jetdhcpc
import (
"net"
"time"
"github.com/insomniacslk/dhcp/dhcpv6"
"github.com/insomniacslk/dhcp/dhcpv6/nclient6"
"github.com/rs/zerolog"
"github.com/vishvananda/netlink"
)
// isIPv6LinkReady returns true if the interface has a link-local address
// which is not tentative.
func isIPv6LinkReady(l netlink.Link, logger *zerolog.Logger) (bool, error) {
addrs, err := netlink.AddrList(l, 10) // AF_INET6
if err != nil {
return false, err
}
for _, addr := range addrs {
if addr.IP.IsLinkLocalUnicast() && (addr.Flags&0x40 == 0) { // IFA_F_TENTATIVE
if addr.Flags&0x80 != 0 { // IFA_F_DADFAILED
logger.Warn().Str("address", addr.IP.String()).Msg("DADFAILED for address, continuing anyhow")
}
return true, nil
}
}
return false, nil
}
// isIPv6RouteReady returns true if serverAddr is reachable.
func isIPv6RouteReady(serverAddr net.IP) waitForCondition {
return func(l netlink.Link, logger *zerolog.Logger) (bool, error) {
if serverAddr.IsMulticast() {
return true, nil
}
routes, err := netlink.RouteList(l, 10) // AF_INET6
if err != nil {
return false, err
}
for _, route := range routes {
if route.LinkIndex != l.Attrs().Index {
continue
}
// Default route.
if route.Dst == nil {
return true, nil
}
if route.Dst.Contains(serverAddr) {
return true, nil
}
}
return false, nil
}
}
func (c *Client) requestLease6(ifname string) (*Lease, error) {
l := c.l.With().Str("interface", ifname).Logger()
iface, err := netlink.LinkByName(ifname)
if err != nil {
return nil, err
}
clientPort := dhcpv6.DefaultClientPort
if c.cfg.V6ClientPort != nil {
clientPort = *c.cfg.V6ClientPort
}
// For ipv6, we cannot bind to the port until Duplicate Address
// Detection (DAD) is complete which is indicated by the link being no
// longer marked as "tentative". This usually takes about a second.
// If the link is never going to be ready, don't wait forever.
// (The user may not have configured a ctx with a timeout.)
linkUpTimeout := time.After(c.cfg.LinkUpTimeout)
if err := c.waitFor(
iface,
linkUpTimeout,
isIPv6LinkReady,
ErrIPv6LinkTimeout,
); err != nil {
return nil, err
}
// If user specified a non-multicast address, make sure it's routable before we start.
if c.cfg.V6ServerAddr != nil {
if err := c.waitFor(
iface,
linkUpTimeout,
isIPv6RouteReady(c.cfg.V6ServerAddr.IP),
ErrIPv6RouteTimeout,
); err != nil {
return nil, err
}
}
mods := []nclient6.ClientOpt{
nclient6.WithTimeout(c.cfg.Timeout),
nclient6.WithRetry(c.cfg.Retries),
c.getDHCP6Logger(),
}
if c.cfg.V6ServerAddr != nil {
mods = append(mods, nclient6.WithBroadcastAddr(c.cfg.V6ServerAddr))
}
conn, err := nclient6.NewIPv6UDPConn(iface.Attrs().Name, clientPort)
if err != nil {
return nil, err
}
client, err := nclient6.NewWithConn(conn, iface.Attrs().HardwareAddr, mods...)
if err != nil {
return nil, err
}
defer client.Close()
// Prepend modifiers with default options, so they can be overridden.
reqmods := append(
[]dhcpv6.Modifier{
dhcpv6.WithNetboot,
},
c.cfg.Modifiers6...)
l.Info().Msg("attempting to get DHCPv6 lease")
p, err := client.RapidSolicit(c.ctx, reqmods...)
if err != nil {
return nil, err
}
l.Info().Msgf("DHCPv6 lease acquired: %s", p.Summary())
return fromNclient6Lease(p, ifname), nil
}

View File

@ -0,0 +1,313 @@
package jetdhcpc
import (
"bufio"
"encoding/binary"
"encoding/json"
"fmt"
"net"
"os"
"reflect"
"strconv"
"strings"
"time"
"github.com/insomniacslk/dhcp/dhcpv4"
"github.com/insomniacslk/dhcp/dhcpv4/nclient4"
"github.com/insomniacslk/dhcp/dhcpv6"
"github.com/jetkvm/kvm/internal/network/types"
)
var (
defaultLeaseTime = time.Duration(30 * time.Minute)
defaultRenewalTime = time.Duration(15 * time.Minute)
)
// Lease is a network configuration obtained by DHCP.
type Lease struct {
types.DHCPLease
p4 *nclient4.Lease
p6 *dhcpv6.Message
isEmpty map[string]bool
}
// ToDHCPLease converts a lease to a DHCP lease.
func (l *Lease) ToDHCPLease() *types.DHCPLease {
lease := &l.DHCPLease
lease.DHCPClient = "jetdhcpc"
return lease
}
// fromNclient4Lease creates a lease from a nclient4.Lease.
func fromNclient4Lease(l *nclient4.Lease, iface string) *Lease {
lease := &Lease{}
lease.p4 = l
// only the fields that we need are set
lease.Routers = l.ACK.Router()
lease.IPAddress = l.ACK.YourIPAddr
lease.Netmask = net.IP(l.ACK.SubnetMask())
lease.Broadcast = l.ACK.BroadcastAddress()
lease.NTPServers = l.ACK.NTPServers()
lease.HostName = l.ACK.HostName()
lease.Domain = l.ACK.DomainName()
searchList := l.ACK.DomainSearch()
if searchList != nil {
lease.SearchList = searchList.Labels
}
lease.DNS = l.ACK.DNS()
lease.ClassIdentifier = l.ACK.ClassIdentifier()
lease.ServerID = l.ACK.ServerIdentifier().String()
mtu := l.ACK.Options.Get(dhcpv4.OptionInterfaceMTU)
if mtu != nil {
lease.MTU = int(binary.BigEndian.Uint16(mtu))
}
lease.Message = l.ACK.Message()
lease.LeaseTime = l.ACK.IPAddressLeaseTime(defaultLeaseTime)
lease.RenewalTime = l.ACK.IPAddressRenewalTime(defaultRenewalTime)
lease.InterfaceName = iface
return lease
}
// fromNclient6Lease creates a lease from a nclient6.Message.
func fromNclient6Lease(l *dhcpv6.Message, iface string) *Lease {
lease := &Lease{}
lease.p6 = l
iana := l.Options.OneIANA()
if iana == nil {
return nil
}
address := iana.Options.OneAddress()
if address == nil {
return nil
}
lease.IPAddress = address.IPv6Addr
lease.Netmask = net.IP(net.CIDRMask(128, 128))
lease.DNS = l.Options.DNS()
// lease.LeaseTime = iana.Options.OnePreferredLifetime()
// lease.RenewalTime = iana.Options.OneValidLifetime()
// lease.RebindingTime = iana.Options.OneRebindingTime()
lease.InterfaceName = iface
return lease
}
func (l *Lease) setIsEmpty(m map[string]bool) {
l.isEmpty = m
}
// IsEmpty returns true if the lease is empty for the given key.
func (l *Lease) IsEmpty(key string) bool {
return l.isEmpty[key]
}
// ToJSON returns the lease as a JSON string.
func (l *Lease) ToJSON() string {
json, err := json.Marshal(l)
if err != nil {
return ""
}
return string(json)
}
// SetLeaseExpiry sets the lease expiry time.
func (l *Lease) SetLeaseExpiry() (time.Time, error) {
if l.Uptime == 0 || l.LeaseTime == 0 {
return time.Time{}, fmt.Errorf("uptime or lease time isn't set")
}
// get the uptime of the device
file, err := os.Open("/proc/uptime")
if err != nil {
return time.Time{}, fmt.Errorf("failed to open uptime file: %w", err)
}
defer file.Close()
var uptime time.Duration
scanner := bufio.NewScanner(file)
for scanner.Scan() {
text := scanner.Text()
parts := strings.Split(text, " ")
uptime, err = time.ParseDuration(parts[0] + "s")
if err != nil {
return time.Time{}, fmt.Errorf("failed to parse uptime: %w", err)
}
}
relativeLeaseRemaining := (l.Uptime + l.LeaseTime) - uptime
leaseExpiry := time.Now().Add(relativeLeaseRemaining)
l.LeaseExpiry = &leaseExpiry
return leaseExpiry, nil
}
func (l *Lease) Apply() error {
if l.p4 != nil {
return l.applyIPv4()
}
if l.p6 != nil {
return l.applyIPv6()
}
return nil
}
func (l *Lease) applyIPv4() error {
return nil
}
func (l *Lease) applyIPv6() error {
return nil
}
// UnmarshalDHCPCLease unmarshals a lease from a string.
func UnmarshalDHCPCLease(lease *Lease, str string) error {
// parse the lease file as a map
data := make(map[string]string)
for _, line := range strings.Split(str, "\n") {
line = strings.TrimSpace(line)
// skip empty lines and comments
if line == "" || strings.HasPrefix(line, "#") {
continue
}
parts := strings.SplitN(line, "=", 2)
if len(parts) != 2 {
continue
}
key := strings.TrimSpace(parts[0])
value := strings.TrimSpace(parts[1])
data[key] = value
}
// now iterate over the lease struct and set the values
leaseType := reflect.TypeOf(lease).Elem()
leaseValue := reflect.ValueOf(lease).Elem()
valuesParsed := make(map[string]bool)
for i := 0; i < leaseType.NumField(); i++ {
field := leaseValue.Field(i)
// get the env tag
key := leaseType.Field(i).Tag.Get("env")
if key == "" {
continue
}
valuesParsed[key] = false
// get the value from the data map
value, ok := data[key]
if !ok || value == "" {
continue
}
switch field.Interface().(type) {
case string:
field.SetString(value)
case int:
val, err := strconv.Atoi(value)
if err != nil {
continue
}
field.SetInt(int64(val))
case time.Duration:
val, err := time.ParseDuration(value + "s")
if err != nil {
continue
}
field.Set(reflect.ValueOf(val))
case net.IP:
ip := net.ParseIP(value)
if ip == nil {
continue
}
field.Set(reflect.ValueOf(ip))
case []net.IP:
val := make([]net.IP, 0)
for _, ipStr := range strings.Fields(value) {
ip := net.ParseIP(ipStr)
if ip == nil {
continue
}
val = append(val, ip)
}
field.Set(reflect.ValueOf(val))
default:
return fmt.Errorf("unsupported field `%s` type: %s", key, field.Type().String())
}
valuesParsed[key] = true
}
lease.setIsEmpty(valuesParsed)
return nil
}
// MarshalDHCPCLease marshals a lease to a string.
func MarshalDHCPCLease(lease *Lease) (string, error) {
leaseType := reflect.TypeOf(lease).Elem()
leaseValue := reflect.ValueOf(lease).Elem()
leaseFile := ""
for i := 0; i < leaseType.NumField(); i++ {
field := leaseValue.Field(i)
key := leaseType.Field(i).Tag.Get("env")
if key == "" {
continue
}
outValue := ""
switch field.Interface().(type) {
case string:
outValue = field.String()
case int:
outValue = strconv.Itoa(int(field.Int()))
case time.Duration:
outValue = strconv.Itoa(int(field.Int()))
case net.IP:
outValue = field.String()
case []net.IP:
ips := field.Interface().([]net.IP)
ipStrings := make([]string, len(ips))
for i, ip := range ips {
ipStrings[i] = ip.String()
}
outValue = strings.Join(ipStrings, " ")
default:
return "", fmt.Errorf("unsupported field `%s` type: %s", key, field.Type().String())
}
leaseFile += fmt.Sprintf("%s=%s\n", key, outValue)
}
return leaseFile, nil
}

View File

@ -0,0 +1,102 @@
package jetdhcpc
import (
"bytes"
"io"
"os"
"path/filepath"
"strconv"
"strings"
"syscall"
"github.com/rs/zerolog"
)
func readFileNoStat(filename string) ([]byte, error) {
const maxBufferSize = 1024 * 1024
f, err := os.Open(filename)
if err != nil {
return nil, err
}
defer f.Close()
reader := io.LimitReader(f, maxBufferSize)
return io.ReadAll(reader)
}
func toCmdline(path string) ([]string, error) {
data, err := readFileNoStat(path)
if err != nil {
return nil, err
}
if len(data) < 1 {
return []string{}, nil
}
return strings.Split(string(bytes.TrimRight(data, "\x00")), "\x00"), nil
}
// KillUdhcpC kills all udhcpc processes
func KillUdhcpC(l *zerolog.Logger) error {
// read procfs for udhcpc processes
// we do not use procfs.AllProcs() because we want to avoid the overhead of reading the entire procfs
processes, err := os.ReadDir("/proc")
if err != nil {
return err
}
matchedPids := make([]int, 0)
// iterate over the processes
for _, d := range processes {
// check if file is numeric
pid, err := strconv.Atoi(d.Name())
if err != nil {
continue
}
// check if it's a directory
if !d.IsDir() {
continue
}
cmdline, err := toCmdline(filepath.Join("/proc", d.Name(), "cmdline"))
if err != nil {
continue
}
if len(cmdline) < 1 {
continue
}
if cmdline[0] != "udhcpc" {
continue
}
matchedPids = append(matchedPids, pid)
}
if len(matchedPids) == 0 {
l.Info().Msg("no udhcpc processes found")
return nil
}
l.Info().Ints("pids", matchedPids).Msg("found udhcpc processes, terminating")
for _, pid := range matchedPids {
err := syscall.Kill(pid, syscall.SIGTERM)
if err != nil {
return err
}
l.Info().Int("pid", pid).Msg("terminated udhcpc process")
}
return nil
}
func (c *Client) killUdhcpc() error {
return KillUdhcpC(c.l)
}

View File

@ -0,0 +1,64 @@
package jetdhcpc
import (
"github.com/insomniacslk/dhcp/dhcpv4"
"github.com/insomniacslk/dhcp/dhcpv4/nclient4"
"github.com/insomniacslk/dhcp/dhcpv6/nclient6"
"github.com/rs/zerolog"
)
type dhcpLogger struct {
// Printfer is used for actual output of the logger
nclient4.Printfer
l *zerolog.Logger
}
// Printf prints a log message as-is via predefined Printfer
func (s dhcpLogger) Printf(format string, v ...interface{}) {
s.l.Info().Msgf(format, v...)
}
// PrintMessage prints a DHCP message in the short format via predefined Printfer
func (s dhcpLogger) PrintMessage(prefix string, message *dhcpv4.DHCPv4) {
s.l.Info().Msgf("%s: %s", prefix, message.String())
}
func summaryStructured(d *dhcpv4.DHCPv4, l *zerolog.Logger) *zerolog.Logger {
logger := l.With().
Str("opCode", d.OpCode.String()).
Str("hwType", d.HWType.String()).
Int("hopCount", int(d.HopCount)).
Str("transactionID", d.TransactionID.String()).
Int("numSeconds", int(d.NumSeconds)).
Str("flagsString", d.FlagsToString()).
Int("flags", int(d.Flags)).
Str("clientIP", d.ClientIPAddr.String()).
Str("yourIP", d.YourIPAddr.String()).
Str("serverIP", d.ServerIPAddr.String()).
Str("gatewayIP", d.GatewayIPAddr.String()).
Str("clientMAC", d.ClientHWAddr.String()).
Str("serverHostname", d.ServerHostName).
Str("bootFileName", d.BootFileName).
Str("options", d.Options.Summary(nil)).
Logger()
return &logger
}
func (c *Client) getDHCP4Logger(ifname string) nclient4.ClientOpt {
logger := c.l.With().
Str("interface", ifname).
Str("source", "dhcp4").
Logger()
return nclient4.WithLogger(dhcpLogger{
l: &logger,
})
}
// TODO: nclient6 doesn't implement the WithLogger option,
// we might need to open a PR to add it
func (c *Client) getDHCP6Logger() nclient6.ClientOpt {
return nclient6.WithSummaryLogger()
}

View File

@ -0,0 +1,247 @@
package jetdhcpc
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"time"
"github.com/jetkvm/kvm/internal/network/types"
)
const (
// DefaultStateDir is the default state directory
DefaultStateDir = "/var/run/"
// DHCPStateFile is the name of the DHCP state file
DHCPStateFile = "jetkvm_dhcp_state.json"
)
// DHCPState represents the persistent state of DHCP clients
type DHCPState struct {
InterfaceStates map[string]*InterfaceDHCPState `json:"interface_states"`
LastUpdated time.Time `json:"last_updated"`
Version string `json:"version"`
}
// InterfaceDHCPState represents the DHCP state for a specific interface
type InterfaceDHCPState struct {
InterfaceName string `json:"interface_name"`
IPv4Enabled bool `json:"ipv4_enabled"`
IPv6Enabled bool `json:"ipv6_enabled"`
IPv4Lease *Lease `json:"ipv4_lease,omitempty"`
IPv6Lease *Lease `json:"ipv6_lease,omitempty"`
LastRenewal time.Time `json:"last_renewal"`
Config *types.NetworkConfig `json:"config,omitempty"`
}
// SaveState saves the current DHCP state to disk
func (c *Client) SaveState(state *DHCPState) error {
if state == nil {
return fmt.Errorf("state cannot be nil")
}
// Return error if state directory doesn't exist
if _, err := os.Stat(c.stateDir); os.IsNotExist(err) {
return fmt.Errorf("state directory does not exist: %w", err)
}
// Update timestamp
state.LastUpdated = time.Now()
state.Version = "1.0"
// Serialize state
data, err := json.MarshalIndent(state, "", " ")
if err != nil {
return fmt.Errorf("failed to marshal state: %w", err)
}
// Write to temporary file first, then rename to ensure atomic operation
tmpFile, err := os.CreateTemp(c.stateDir, DHCPStateFile)
if err != nil {
return fmt.Errorf("failed to create temporary file: %w", err)
}
defer tmpFile.Close()
if err := os.WriteFile(tmpFile.Name(), data, 0644); err != nil {
return fmt.Errorf("failed to write state file: %w", err)
}
stateFile := filepath.Join(c.stateDir, DHCPStateFile)
if err := os.Rename(tmpFile.Name(), stateFile); err != nil {
os.Remove(tmpFile.Name())
return fmt.Errorf("failed to rename state file: %w", err)
}
c.l.Debug().Str("file", stateFile).Msg("DHCP state saved")
return nil
}
// LoadState loads the DHCP state from disk
func (c *Client) LoadState() (*DHCPState, error) {
stateFile := filepath.Join(c.stateDir, DHCPStateFile)
// Check if state file exists
if _, err := os.Stat(stateFile); os.IsNotExist(err) {
c.l.Debug().Msg("No existing DHCP state file found")
return &DHCPState{
InterfaceStates: make(map[string]*InterfaceDHCPState),
LastUpdated: time.Now(),
Version: "1.0",
}, nil
}
// Read state file
data, err := os.ReadFile(stateFile)
if err != nil {
return nil, fmt.Errorf("failed to read state file: %w", err)
}
// Deserialize state
var state DHCPState
if err := json.Unmarshal(data, &state); err != nil {
return nil, fmt.Errorf("failed to unmarshal state: %w", err)
}
// Initialize interface states map if nil
if state.InterfaceStates == nil {
state.InterfaceStates = make(map[string]*InterfaceDHCPState)
}
c.l.Debug().Str("file", stateFile).Msg("DHCP state loaded")
return &state, nil
}
// UpdateInterfaceState updates the state for a specific interface
func (c *Client) UpdateInterfaceState(ifaceName string, state *InterfaceDHCPState) error {
// Load current state
currentState, err := c.LoadState()
if err != nil {
return fmt.Errorf("failed to load current state: %w", err)
}
// Update interface state
currentState.InterfaceStates[ifaceName] = state
// Save updated state
return c.SaveState(currentState)
}
// GetInterfaceState gets the state for a specific interface
func (c *Client) GetInterfaceState(ifaceName string) (*InterfaceDHCPState, error) {
state, err := c.LoadState()
if err != nil {
return nil, fmt.Errorf("failed to load state: %w", err)
}
return state.InterfaceStates[ifaceName], nil
}
// RemoveInterfaceState removes the state for a specific interface
func (c *Client) RemoveInterfaceState(ifaceName string) error {
// Load current state
currentState, err := c.LoadState()
if err != nil {
return fmt.Errorf("failed to load current state: %w", err)
}
// Remove interface state
delete(currentState.InterfaceStates, ifaceName)
// Save updated state
return c.SaveState(currentState)
}
// IsLeaseValid checks if a DHCP lease is still valid
func (c *Client) IsLeaseValid(lease *Lease) bool {
if lease == nil {
return false
}
// Check if lease has expired
if lease.LeaseExpiry == nil {
return false
}
return time.Now().Before(*lease.LeaseExpiry)
}
// ShouldRenewLease checks if a lease should be renewed
func (c *Client) ShouldRenewLease(lease *Lease) bool {
if !c.IsLeaseValid(lease) {
return false
}
expiry := *lease.LeaseExpiry
leaseTime := time.Now().Add(time.Duration(lease.LeaseTime) * time.Second)
// Renew if lease expires within 50% of its lifetime
leaseDuration := expiry.Sub(leaseTime)
renewalTime := leaseTime.Add(leaseDuration / 2)
return time.Now().After(renewalTime)
}
// CleanupExpiredStates removes expired states from the state file
func (c *Client) CleanupExpiredStates() error {
state, err := c.LoadState()
if err != nil {
return fmt.Errorf("failed to load state: %w", err)
}
cleaned := false
for ifaceName, ifaceState := range state.InterfaceStates {
// Remove interface state if both leases are expired
ipv4Valid := c.IsLeaseValid(ifaceState.IPv4Lease)
ipv6Valid := c.IsLeaseValid(ifaceState.IPv6Lease)
if !ipv4Valid && !ipv6Valid {
delete(state.InterfaceStates, ifaceName)
cleaned = true
c.l.Debug().Str("interface", ifaceName).Msg("Removed expired DHCP state")
}
}
if cleaned {
return c.SaveState(state)
}
return nil
}
// GetStateSummary returns a summary of the current state
func (c *Client) GetStateSummary() (map[string]interface{}, error) {
state, err := c.LoadState()
if err != nil {
return nil, fmt.Errorf("failed to load state: %w", err)
}
summary := map[string]interface{}{
"last_updated": state.LastUpdated,
"version": state.Version,
"interface_count": len(state.InterfaceStates),
"interfaces": make(map[string]interface{}),
}
interfaces := summary["interfaces"].(map[string]interface{})
for ifaceName, ifaceState := range state.InterfaceStates {
interfaceInfo := map[string]interface{}{
"ipv4_enabled": ifaceState.IPv4Enabled,
"ipv6_enabled": ifaceState.IPv6Enabled,
"last_renewal": ifaceState.LastRenewal,
// "ipv4_lease_valid": c.IsLeaseValid(ifaceState.IPv4Lease.(*Lease)),
// "ipv6_lease_valid": c.IsLeaseValid(ifaceState.IPv6Lease),
}
if ifaceState.IPv4Lease != nil {
interfaceInfo["ipv4_lease_expiry"] = ifaceState.IPv4Lease.LeaseExpiry
}
if ifaceState.IPv6Lease != nil {
interfaceInfo["ipv6_lease_expiry"] = ifaceState.IPv6Lease.LeaseExpiry
}
interfaces[ifaceName] = interfaceInfo
}
return summary, nil
}

View File

@ -0,0 +1,48 @@
package jetdhcpc
import (
"context"
"time"
"github.com/rs/zerolog"
"github.com/vishvananda/netlink"
)
type waitForCondition func(l netlink.Link, logger *zerolog.Logger) (ready bool, err error)
func (c *Client) waitFor(
link netlink.Link,
timeout <-chan time.Time,
condition waitForCondition,
timeoutError error,
) error {
return waitFor(c.ctx, link, c.l, timeout, condition, timeoutError)
}
func waitFor(
ctx context.Context,
link netlink.Link,
logger *zerolog.Logger,
timeout <-chan time.Time,
condition waitForCondition,
timeoutError error,
) error {
for {
if ready, err := condition(link, logger); err != nil {
return err
} else if ready {
break
}
select {
case <-time.After(100 * time.Millisecond):
continue
case <-timeout:
return timeoutError
case <-ctx.Done():
return timeoutError
}
}
return nil
}

13
pkg/nmlite/link/consts.go Normal file
View File

@ -0,0 +1,13 @@
package link
const (
// AfUnspec is the unspecified address family constant
AfUnspec = 0
// AfInet is the IPv4 address family constant
AfInet = 2
// AfInet6 is the IPv6 address family constant
AfInet6 = 10
sysctlBase = "/proc/sys"
sysctlFileMode = 0640
)

544
pkg/nmlite/link/manager.go Normal file
View File

@ -0,0 +1,544 @@
package link
import (
"context"
"fmt"
"net"
"time"
"github.com/jetkvm/kvm/internal/sync"
"github.com/jetkvm/kvm/internal/network/types"
"github.com/rs/zerolog"
"github.com/vishvananda/netlink"
)
// StateChangeHandler is the function type for link state callbacks
type StateChangeHandler func(link *Link)
// StateChangeCallback is the struct for link state callbacks
type StateChangeCallback struct {
Async bool
Func StateChangeHandler
}
// NetlinkManager provides centralized netlink operations
type NetlinkManager struct {
logger *zerolog.Logger
mu sync.RWMutex
stateChangeCallbacks map[string][]StateChangeCallback
}
func newNetlinkManager(logger *zerolog.Logger) *NetlinkManager {
if logger == nil {
logger = &zerolog.Logger{} // Default no-op logger
}
n := &NetlinkManager{
logger: logger,
stateChangeCallbacks: make(map[string][]StateChangeCallback),
}
n.monitorStateChange()
return n
}
// GetNetlinkManager returns the singleton NetlinkManager instance
func GetNetlinkManager() *NetlinkManager {
netlinkManagerOnce.Do(func() {
netlinkManagerInstance = newNetlinkManager(nil)
})
return netlinkManagerInstance
}
// InitializeNetlinkManager initializes the singleton NetlinkManager with a logger
func InitializeNetlinkManager(logger *zerolog.Logger) *NetlinkManager {
netlinkManagerOnce.Do(func() {
netlinkManagerInstance = newNetlinkManager(logger)
})
return netlinkManagerInstance
}
// AddStateChangeCallback adds a callback for link state changes
func (nm *NetlinkManager) AddStateChangeCallback(ifname string, callback StateChangeCallback) {
nm.mu.Lock()
defer nm.mu.Unlock()
if _, ok := nm.stateChangeCallbacks[ifname]; !ok {
nm.stateChangeCallbacks[ifname] = make([]StateChangeCallback, 0)
}
nm.stateChangeCallbacks[ifname] = append(nm.stateChangeCallbacks[ifname], callback)
}
// Interface operations
func (nm *NetlinkManager) monitorStateChange() {
updateCh := make(chan netlink.LinkUpdate)
// we don't need to stop the subscription, as it will be closed when the program exits
stopCh := make(chan struct{}) //nolint:unused
if err := netlink.LinkSubscribe(updateCh, stopCh); err != nil {
nm.logger.Error().Err(err).Msg("failed to subscribe to link state changes")
}
nm.logger.Info().Msg("state change monitoring started")
go func() {
for update := range updateCh {
nm.runCallbacks(update)
}
}()
}
func (nm *NetlinkManager) runCallbacks(update netlink.LinkUpdate) {
nm.mu.RLock()
defer nm.mu.RUnlock()
ifname := update.Link.Attrs().Name
callbacks, ok := nm.stateChangeCallbacks[ifname]
l := nm.logger.With().Str("interface", ifname).Logger()
if !ok {
l.Trace().Msg("no state change callbacks for interface")
return
}
for _, callback := range callbacks {
l.Trace().
Interface("callback", callback).
Bool("async", callback.Async).
Msg("calling callback")
if callback.Async {
go callback.Func(&Link{Link: update.Link})
} else {
callback.Func(&Link{Link: update.Link})
}
}
}
// GetLinkByName gets a network link by name
func (nm *NetlinkManager) GetLinkByName(name string) (*Link, error) {
nm.mu.RLock()
defer nm.mu.RUnlock()
link, err := netlink.LinkByName(name)
if err != nil {
return nil, err
}
return &Link{Link: link}, nil
}
// LinkSetUp brings a network interface up
func (nm *NetlinkManager) LinkSetUp(link *Link) error {
nm.mu.RLock()
defer nm.mu.RUnlock()
return netlink.LinkSetUp(link)
}
// LinkSetDown brings a network interface down
func (nm *NetlinkManager) LinkSetDown(link *Link) error {
nm.mu.RLock()
defer nm.mu.RUnlock()
return netlink.LinkSetDown(link)
}
// EnsureInterfaceUp ensures the interface is up
func (nm *NetlinkManager) EnsureInterfaceUp(link *Link) error {
if link.Attrs().OperState == netlink.OperUp {
return nil
}
return nm.LinkSetUp(link)
}
// EnsureInterfaceUpWithTimeout ensures the interface is up with timeout and retry logic
func (nm *NetlinkManager) EnsureInterfaceUpWithTimeout(ctx context.Context, iface *Link, timeout time.Duration) (*Link, error) {
ifname := iface.Attrs().Name
l := nm.logger.With().Str("interface", ifname).Logger()
linkUpTimeout := time.After(timeout)
var attempt int
start := time.Now()
for {
link, err := nm.GetLinkByName(ifname)
if err != nil {
return nil, err
}
state := link.Attrs().OperState
l = l.With().
Int("attempt", attempt).
Dur("duration", time.Since(start)).
Str("state", state.String()).
Logger()
if state == netlink.OperUp || state == netlink.OperUnknown {
if attempt > 0 {
l.Info().Int("attempt", attempt-1).Msg("interface is up")
}
return link, nil
}
l.Info().Msg("bringing up interface")
// bring up the interface
if err = nm.LinkSetUp(link); err != nil {
l.Error().Err(err).Msg("interface can't make it up")
}
// refresh the link attributes
if err = link.Refresh(); err != nil {
l.Error().Err(err).Msg("failed to refresh link attributes")
}
// check the state again
state = link.Attrs().OperState
l = l.With().Str("new_state", state.String()).Logger()
if state == netlink.OperUp {
l.Info().Msg("interface is up")
return link, nil
}
l.Warn().Msg("interface is still down, retrying")
select {
case <-time.After(500 * time.Millisecond):
attempt++
continue
case <-ctx.Done():
if err != nil {
return nil, err
}
return nil, ErrInterfaceUpCanceled
case <-linkUpTimeout:
attempt++
l.Error().
Int("attempt", attempt).Msg("interface is still down after timeout")
if err != nil {
return nil, err
}
return nil, ErrInterfaceUpTimeout
}
}
}
// Address operations
// AddrList gets all addresses for a link
func (nm *NetlinkManager) AddrList(link *Link, family int) ([]netlink.Addr, error) {
nm.mu.RLock()
defer nm.mu.RUnlock()
return netlink.AddrList(link, family)
}
// AddrAdd adds an address to a link
func (nm *NetlinkManager) AddrAdd(link *Link, addr *netlink.Addr) error {
nm.mu.RLock()
defer nm.mu.RUnlock()
return netlink.AddrAdd(link, addr)
}
// AddrDel removes an address from a link
func (nm *NetlinkManager) AddrDel(link *Link, addr *netlink.Addr) error {
nm.mu.RLock()
defer nm.mu.RUnlock()
return netlink.AddrDel(link, addr)
}
// RemoveAllAddresses removes all addresses of a specific family from a link
func (nm *NetlinkManager) RemoveAllAddresses(link *Link, family int) error {
addrs, err := nm.AddrList(link, family)
if err != nil {
return fmt.Errorf("failed to get addresses: %w", err)
}
for _, addr := range addrs {
if err := nm.AddrDel(link, &addr); err != nil {
nm.logger.Warn().Err(err).Str("address", addr.IP.String()).Msg("failed to remove address")
}
}
return nil
}
// RemoveNonLinkLocalIPv6Addresses removes all non-link-local IPv6 addresses
func (nm *NetlinkManager) RemoveNonLinkLocalIPv6Addresses(link *Link) error {
addrs, err := nm.AddrList(link, AfInet6)
if err != nil {
return fmt.Errorf("failed to get IPv6 addresses: %w", err)
}
for _, addr := range addrs {
if !addr.IP.IsLinkLocalUnicast() {
if err := nm.AddrDel(link, &addr); err != nil {
nm.logger.Warn().Err(err).Str("address", addr.IP.String()).Msg("failed to remove IPv6 address")
}
}
}
return nil
}
// RouteList gets all routes
func (nm *NetlinkManager) RouteList(link *Link, family int) ([]netlink.Route, error) {
nm.mu.RLock()
defer nm.mu.RUnlock()
return netlink.RouteList(link, family)
}
// RouteAdd adds a route
func (nm *NetlinkManager) RouteAdd(route *netlink.Route) error {
nm.mu.RLock()
defer nm.mu.RUnlock()
return netlink.RouteAdd(route)
}
// RouteDel removes a route
func (nm *NetlinkManager) RouteDel(route *netlink.Route) error {
nm.mu.RLock()
defer nm.mu.RUnlock()
return netlink.RouteDel(route)
}
// RouteReplace replaces a route
func (nm *NetlinkManager) RouteReplace(route *netlink.Route) error {
nm.mu.RLock()
defer nm.mu.RUnlock()
return netlink.RouteReplace(route)
}
// ListDefaultRoutes lists the default routes for the given family
func (nm *NetlinkManager) ListDefaultRoutes(family int) ([]netlink.Route, error) {
routes, err := netlink.RouteListFiltered(
family,
&netlink.Route{Dst: nil, Table: 254},
netlink.RT_FILTER_DST|netlink.RT_FILTER_TABLE,
)
if err != nil {
nm.logger.Error().Err(err).Int("family", family).Msg("failed to list default routes")
return nil, err
}
return routes, nil
}
// HasDefaultRoute checks if a default route exists for the given family
func (nm *NetlinkManager) HasDefaultRoute(family int) bool {
routes, err := nm.ListDefaultRoutes(family)
if err != nil {
return false
}
return len(routes) > 0
}
// AddDefaultRoute adds a default route
func (nm *NetlinkManager) AddDefaultRoute(link *Link, gateway net.IP, family int) error {
var dst *net.IPNet
switch family {
case AfInet:
dst = &ipv4DefaultRoute
case AfInet6:
dst = &ipv6DefaultRoute
default:
return fmt.Errorf("unsupported address family: %d", family)
}
route := &netlink.Route{
Dst: dst,
Gw: gateway,
LinkIndex: link.Attrs().Index,
}
return nm.RouteReplace(route)
}
// RemoveDefaultRoute removes the default route for the given family
func (nm *NetlinkManager) RemoveDefaultRoute(family int) error {
routes, err := nm.RouteList(nil, family)
if err != nil {
return fmt.Errorf("failed to get routes: %w", err)
}
for _, route := range routes {
if route.Dst != nil {
if family == AfInet && route.Dst.IP.Equal(net.IPv4zero) && route.Dst.Mask.String() == "0.0.0.0/0" {
if err := nm.RouteDel(&route); err != nil {
nm.logger.Warn().Err(err).Msg("failed to remove IPv4 default route")
}
}
if family == AfInet6 && route.Dst.IP.Equal(net.IPv6zero) && route.Dst.Mask.String() == "::/0" {
if err := nm.RouteDel(&route); err != nil {
nm.logger.Warn().Err(err).Msg("failed to remove IPv6 default route")
}
}
}
}
return nil
}
func (nm *NetlinkManager) reconcileDefaultRoute(link *Link, expected map[string]net.IP, family int) error {
linkIndex := link.Attrs().Index
added := 0
toRemove := make([]*netlink.Route, 0)
defaultRoutes, err := nm.ListDefaultRoutes(family)
if err != nil {
return fmt.Errorf("failed to get default routes: %w", err)
}
// check existing default routes
for _, defaultRoute := range defaultRoutes {
// only check the default routes for the current link
// TODO: we should also check others later
if defaultRoute.LinkIndex != linkIndex {
continue
}
key := defaultRoute.Gw.String()
if _, ok := expected[key]; !ok {
toRemove = append(toRemove, &defaultRoute)
continue
}
nm.logger.Warn().Str("gateway", key).Msg("keeping default route")
delete(expected, key)
}
// remove remaining default routes
for _, defaultRoute := range toRemove {
nm.logger.Warn().Str("gateway", defaultRoute.Gw.String()).Msg("removing default route")
if err := nm.RouteDel(defaultRoute); err != nil {
nm.logger.Warn().Err(err).Msg("failed to remove default route")
}
}
// add remaining expected default routes
for _, gateway := range expected {
nm.logger.Warn().Str("gateway", gateway.String()).Msg("adding default route")
route := &netlink.Route{
Dst: &ipv4DefaultRoute,
Gw: gateway,
LinkIndex: linkIndex,
}
if family == AfInet6 {
route.Dst = &ipv6DefaultRoute
}
if err := nm.RouteAdd(route); err != nil {
nm.logger.Warn().Err(err).Interface("route", route).Msg("failed to add default route")
}
added++
}
nm.logger.Info().
Int("added", added).
Int("removed", len(toRemove)).
Msg("default routes reconciled")
return nil
}
// ReconcileLink reconciles the addresses and routes of a link
func (nm *NetlinkManager) ReconcileLink(link *Link, expected []types.IPAddress, family int) error {
toAdd := make([]*types.IPAddress, 0)
toRemove := make([]*netlink.Addr, 0)
toUpdate := make([]*types.IPAddress, 0)
expectedAddrs := make(map[string]*types.IPAddress)
expectedGateways := make(map[string]net.IP)
mtu := link.Attrs().MTU
expectedMTU := mtu
// add all expected addresses to the map
for _, addr := range expected {
expectedAddrs[addr.String()] = &addr
if addr.Gateway != nil {
expectedGateways[addr.String()] = addr.Gateway
}
if addr.MTU != 0 {
mtu = addr.MTU
}
}
if expectedMTU != mtu {
if err := link.SetMTU(expectedMTU); err != nil {
nm.logger.Warn().Err(err).Int("expected_mtu", expectedMTU).Int("mtu", mtu).Msg("failed to set MTU")
}
}
addrs, err := nm.AddrList(link, family)
if err != nil {
return fmt.Errorf("failed to get addresses: %w", err)
}
// check existing addresses
for _, addr := range addrs {
// skip the link-local address
if addr.IP.IsLinkLocalUnicast() {
continue
}
expectedAddr, ok := expectedAddrs[addr.IPNet.String()]
if !ok {
toRemove = append(toRemove, &addr)
continue
}
// if it's not fully equal, we need to update it
if !expectedAddr.Compare(addr) {
toUpdate = append(toUpdate, expectedAddr)
continue
}
// remove it from expected addresses
delete(expectedAddrs, addr.IPNet.String())
}
// add remaining expected addresses
for _, addr := range expectedAddrs {
toAdd = append(toAdd, addr)
}
for _, addr := range toUpdate {
netlinkAddr := addr.NetlinkAddr()
if err := nm.AddrDel(link, &netlinkAddr); err != nil {
nm.logger.Warn().Err(err).Str("address", addr.Address.String()).Msg("failed to update address")
}
// we'll add it again later
toAdd = append(toAdd, addr)
}
for _, addr := range toAdd {
netlinkAddr := addr.NetlinkAddr()
if err := nm.AddrAdd(link, &netlinkAddr); err != nil {
nm.logger.Warn().Err(err).Str("address", addr.Address.String()).Msg("failed to add address")
}
}
for _, netlinkAddr := range toRemove {
if err := nm.AddrDel(link, netlinkAddr); err != nil {
nm.logger.Warn().Err(err).Str("address", netlinkAddr.IP.String()).Msg("failed to remove address")
}
}
for _, addr := range toAdd {
netlinkAddr := addr.NetlinkAddr()
if err := nm.AddrAdd(link, &netlinkAddr); err != nil {
nm.logger.Warn().Err(err).Str("address", addr.Address.String()).Msg("failed to add address")
}
}
actualToAdd := len(toAdd) - len(toUpdate)
if len(toAdd) > 0 || len(toUpdate) > 0 || len(toRemove) > 0 {
nm.logger.Info().
Int("added", actualToAdd).
Int("updated", len(toUpdate)).
Int("removed", len(toRemove)).
Msg("addresses reconciled")
}
if err := nm.reconcileDefaultRoute(link, expectedGateways, family); err != nil {
nm.logger.Warn().Err(err).Msg("failed to reconcile default route")
}
return nil
}

164
pkg/nmlite/link/netlink.go Normal file
View File

@ -0,0 +1,164 @@
// Package link provides a wrapper around netlink.Link and provides a singleton netlink manager.
package link
import (
"errors"
"fmt"
"net"
"github.com/jetkvm/kvm/internal/sync"
"github.com/vishvananda/netlink"
)
var (
ipv4DefaultRoute = net.IPNet{
IP: net.IPv4zero,
Mask: net.CIDRMask(0, 0),
}
ipv6DefaultRoute = net.IPNet{
IP: net.IPv6zero,
Mask: net.CIDRMask(0, 0),
}
// Singleton instance
netlinkManagerInstance *NetlinkManager
netlinkManagerOnce sync.Once
// ErrInterfaceUpTimeout is the error returned when the interface does not come up within the timeout
ErrInterfaceUpTimeout = errors.New("timeout after waiting for an interface to come up")
// ErrInterfaceUpCanceled is the error returned when the interface does not come up due to context cancellation
ErrInterfaceUpCanceled = errors.New("context canceled while waiting for an interface to come up")
)
// Link is a wrapper around netlink.Link
type Link struct {
netlink.Link
mu sync.Mutex
}
// All lock actions should be done in external functions
// and the internal functions should not be called directly
func (l *Link) refresh() error {
linkName := l.ifName()
link, err := netlink.LinkByName(linkName)
if err != nil {
return err
}
if link == nil {
return fmt.Errorf("link not found: %s", linkName)
}
l.Link = link
return nil
}
func (l *Link) attrs() *netlink.LinkAttrs {
return l.Link.Attrs()
}
func (l *Link) ifName() string {
attrs := l.attrs()
if attrs.Name == "" {
return ""
}
return attrs.Name
}
// Refresh refreshes the link
func (l *Link) Refresh() error {
l.mu.Lock()
defer l.mu.Unlock()
return l.refresh()
}
// Attrs returns the attributes of the link
func (l *Link) Attrs() *netlink.LinkAttrs {
l.mu.Lock()
defer l.mu.Unlock()
return l.attrs()
}
// Interface returns the interface of the link
func (l *Link) Interface() *net.Interface {
l.mu.Lock()
defer l.mu.Unlock()
ifname := l.ifName()
if ifname == "" {
return nil
}
iface, err := net.InterfaceByName(ifname)
if err != nil {
return nil
}
return iface
}
// HardwareAddr returns the hardware address of the link
func (l *Link) HardwareAddr() net.HardwareAddr {
l.mu.Lock()
defer l.mu.Unlock()
attrs := l.attrs()
if attrs.HardwareAddr == nil {
return nil
}
return attrs.HardwareAddr
}
// AddrList returns the addresses of the link
func (l *Link) AddrList(family int) ([]netlink.Addr, error) {
l.mu.Lock()
defer l.mu.Unlock()
return netlink.AddrList(l.Link, family)
}
func (l *Link) SetMTU(mtu int) error {
l.mu.Lock()
defer l.mu.Unlock()
return netlink.LinkSetMTU(l.Link, mtu)
}
// HasGlobalUnicastAddress returns true if the link has a global unicast address
func (l *Link) HasGlobalUnicastAddress() bool {
addrs, err := l.AddrList(AfUnspec)
if err != nil {
return false
}
for _, addr := range addrs {
if addr.IP.IsGlobalUnicast() {
return true
}
}
return false
}
// IsSame checks if the link is the same as another link
func (l *Link) IsSame(other *Link) bool {
if l == nil || other == nil {
return false
}
a := l.Attrs()
b := other.Attrs()
if a.OperState != b.OperState {
return false
}
if a.Index != b.Index {
return false
}
if a.MTU != b.MTU {
return false
}
if a.HardwareAddr.String() != b.HardwareAddr.String() {
return false
}
return true
}

52
pkg/nmlite/link/sysctl.go Normal file
View File

@ -0,0 +1,52 @@
package link
import (
"fmt"
"os"
"path"
"strconv"
"strings"
)
func (nm *NetlinkManager) setSysctlValues(ifaceName string, values map[string]int) error {
for name, value := range values {
name = fmt.Sprintf(name, ifaceName)
name = strings.ReplaceAll(name, ".", "/")
if err := os.WriteFile(path.Join(sysctlBase, name), []byte(strconv.Itoa(value)), sysctlFileMode); err != nil {
return fmt.Errorf("failed to set sysctl %s=%d: %w", name, value, err)
}
}
return nil
}
// EnableIPv6 enables IPv6 on the interface
func (nm *NetlinkManager) EnableIPv6(ifaceName string) error {
return nm.setSysctlValues(ifaceName, map[string]int{
"net.ipv6.conf.%s.disable_ipv6": 0,
"net.ipv6.conf.%s.accept_ra": 2,
})
}
// DisableIPv6 disables IPv6 on the interface
func (nm *NetlinkManager) DisableIPv6(ifaceName string) error {
return nm.setSysctlValues(ifaceName, map[string]int{
"net.ipv6.conf.%s.disable_ipv6": 1,
})
}
// EnableIPv6SLAAC enables IPv6 SLAAC on the interface
func (nm *NetlinkManager) EnableIPv6SLAAC(ifaceName string) error {
return nm.setSysctlValues(ifaceName, map[string]int{
"net.ipv6.conf.%s.disable_ipv6": 0,
"net.ipv6.conf.%s.accept_ra": 2,
})
}
// EnableIPv6LinkLocal enables IPv6 link-local only on the interface
func (nm *NetlinkManager) EnableIPv6LinkLocal(ifaceName string) error {
return nm.setSysctlValues(ifaceName, map[string]int{
"net.ipv6.conf.%s.disable_ipv6": 0,
"net.ipv6.conf.%s.accept_ra": 0,
})
}

13
pkg/nmlite/link/types.go Normal file
View File

@ -0,0 +1,13 @@
package link
import (
"net"
)
// IPv4Address represents an IPv4 address and its gateway
type IPv4Address struct {
Address net.IPNet
Gateway net.IP
Secondary bool
Permanent bool
}

87
pkg/nmlite/link/utils.go Normal file
View File

@ -0,0 +1,87 @@
package link
import (
"fmt"
"net"
"strings"
)
// ParseIPv4Netmask parses an IPv4 netmask string and returns the IPNet
func ParseIPv4Netmask(address, netmask string) (*net.IPNet, error) {
if strings.Contains(address, "/") {
_, ipNet, err := net.ParseCIDR(address)
if err != nil {
return nil, fmt.Errorf("invalid IPv4 address: %s", address)
}
return ipNet, nil
}
ip := net.ParseIP(address)
if ip == nil {
return nil, fmt.Errorf("invalid IPv4 address: %s", address)
}
if ip.To4() == nil {
return nil, fmt.Errorf("not an IPv4 address: %s", address)
}
mask := net.ParseIP(netmask)
if mask == nil {
return nil, fmt.Errorf("invalid IPv4 netmask: %s", netmask)
}
if mask.To4() == nil {
return nil, fmt.Errorf("not an IPv4 netmask: %s", netmask)
}
return &net.IPNet{
IP: ip,
Mask: net.IPv4Mask(mask[12], mask[13], mask[14], mask[15]),
}, nil
}
// ParseIPv6Prefix parses an IPv6 address and prefix length
func ParseIPv6Prefix(address string, prefixLength int) (*net.IPNet, error) {
if strings.Contains(address, "/") {
_, ipNet, err := net.ParseCIDR(address)
if err != nil {
return nil, fmt.Errorf("invalid IPv6 address: %s", address)
}
return ipNet, nil
}
ip := net.ParseIP(address)
if ip == nil {
return nil, fmt.Errorf("invalid IPv6 address: %s", address)
}
if ip.To16() == nil || ip.To4() != nil {
return nil, fmt.Errorf("not an IPv6 address: %s", address)
}
if prefixLength < 0 || prefixLength > 128 {
return nil, fmt.Errorf("invalid IPv6 prefix length: %d (must be 0-128)", prefixLength)
}
return &net.IPNet{
IP: ip,
Mask: net.CIDRMask(prefixLength, 128),
}, nil
}
// ValidateIPAddress validates an IP address
func ValidateIPAddress(address string, isIPv6 bool) error {
ip := net.ParseIP(address)
if ip == nil {
return fmt.Errorf("invalid IP address: %s", address)
}
if isIPv6 {
if ip.To16() == nil || ip.To4() != nil {
return fmt.Errorf("not an IPv6 address: %s", address)
}
} else {
if ip.To4() == nil {
return fmt.Errorf("not an IPv4 address: %s", address)
}
}
return nil
}

260
pkg/nmlite/manager.go Normal file
View File

@ -0,0 +1,260 @@
// Package nmlite provides a lightweight network management system.
// It supports multiple network interfaces with static and DHCP configuration,
// IPv4/IPv6 support, and proper separation of concerns.
package nmlite
import (
"context"
"fmt"
"github.com/jetkvm/kvm/internal/sync"
"github.com/jetkvm/kvm/internal/logging"
"github.com/jetkvm/kvm/internal/network/types"
"github.com/jetkvm/kvm/pkg/nmlite/jetdhcpc"
"github.com/jetkvm/kvm/pkg/nmlite/link"
"github.com/rs/zerolog"
)
// NetworkManager manages multiple network interfaces
type NetworkManager struct {
interfaces map[string]*InterfaceManager
mu sync.RWMutex
logger *zerolog.Logger
ctx context.Context
cancel context.CancelFunc
resolvConf *ResolvConfManager
// Callback functions for state changes
onInterfaceStateChange func(iface string, state types.InterfaceState)
onConfigChange func(iface string, config *types.NetworkConfig)
onDHCPLeaseChange func(iface string, lease *types.DHCPLease)
}
// NewNetworkManager creates a new network manager
func NewNetworkManager(ctx context.Context, logger *zerolog.Logger) *NetworkManager {
if logger == nil {
logger = logging.GetSubsystemLogger("nm")
}
// Initialize the NetlinkManager singleton
link.InitializeNetlinkManager(logger)
ctx, cancel := context.WithCancel(ctx)
return &NetworkManager{
interfaces: make(map[string]*InterfaceManager),
logger: logger,
ctx: ctx,
cancel: cancel,
resolvConf: NewResolvConfManager(logger),
}
}
// SetHostname sets the hostname and domain for the network manager
func (nm *NetworkManager) SetHostname(hostname string, domain string) error {
return nm.resolvConf.SetHostname(hostname, domain)
}
// Domain returns the effective domain for the network manager
func (nm *NetworkManager) Domain() string {
return nm.resolvConf.Domain()
}
// AddInterface adds a new network interface to be managed
func (nm *NetworkManager) AddInterface(iface string, config *types.NetworkConfig) error {
nm.mu.Lock()
defer nm.mu.Unlock()
if _, exists := nm.interfaces[iface]; exists {
return fmt.Errorf("interface %s already managed", iface)
}
im, err := NewInterfaceManager(nm.ctx, iface, config, nm.logger)
if err != nil {
return fmt.Errorf("failed to create interface manager for %s: %w", iface, err)
}
// Set up callbacks
im.SetOnStateChange(func(state types.InterfaceState) {
if nm.onInterfaceStateChange != nil {
state.Hostname = nm.Hostname()
nm.onInterfaceStateChange(iface, state)
}
})
im.SetOnConfigChange(func(config *types.NetworkConfig) {
if nm.onConfigChange != nil {
nm.onConfigChange(iface, config)
}
})
im.SetOnDHCPLeaseChange(func(lease *types.DHCPLease) {
if nm.onDHCPLeaseChange != nil {
nm.onDHCPLeaseChange(iface, lease)
}
})
im.SetOnResolvConfChange(func(family int, resolvConf *types.InterfaceResolvConf) error {
return nm.resolvConf.SetInterfaceConfig(iface, family, *resolvConf)
})
nm.interfaces[iface] = im
// Start monitoring the interface
if err := im.Start(); err != nil {
delete(nm.interfaces, iface)
return fmt.Errorf("failed to start interface manager for %s: %w", iface, err)
}
nm.logger.Info().Str("interface", iface).Msg("added interface to network manager")
return nil
}
// RemoveInterface removes a network interface from management
func (nm *NetworkManager) RemoveInterface(iface string) error {
nm.mu.Lock()
defer nm.mu.Unlock()
im, exists := nm.interfaces[iface]
if !exists {
return fmt.Errorf("interface %s not managed", iface)
}
if err := im.Stop(); err != nil {
nm.logger.Error().Err(err).Str("interface", iface).Msg("failed to stop interface manager")
}
delete(nm.interfaces, iface)
nm.logger.Info().Str("interface", iface).Msg("removed interface from network manager")
return nil
}
// GetInterface returns the interface manager for a specific interface
func (nm *NetworkManager) GetInterface(iface string) (*InterfaceManager, error) {
nm.mu.RLock()
defer nm.mu.RUnlock()
im, exists := nm.interfaces[iface]
if !exists {
return nil, fmt.Errorf("interface %s not managed", iface)
}
return im, nil
}
// ListInterfaces returns a list of all managed interfaces
func (nm *NetworkManager) ListInterfaces() []string {
nm.mu.RLock()
defer nm.mu.RUnlock()
interfaces := make([]string, 0, len(nm.interfaces))
for iface := range nm.interfaces {
interfaces = append(interfaces, iface)
}
return interfaces
}
// GetInterfaceState returns the current state of a specific interface
func (nm *NetworkManager) GetInterfaceState(iface string) (*types.InterfaceState, error) {
im, err := nm.GetInterface(iface)
if err != nil {
return nil, err
}
state := im.GetState()
state.Hostname = nm.Hostname()
return state, nil
}
// GetInterfaceConfig returns the current configuration of a specific interface
func (nm *NetworkManager) GetInterfaceConfig(iface string) (*types.NetworkConfig, error) {
im, err := nm.GetInterface(iface)
if err != nil {
return nil, err
}
return im.GetConfig(), nil
}
// SetInterfaceConfig updates the configuration of a specific interface
func (nm *NetworkManager) SetInterfaceConfig(iface string, config *types.NetworkConfig) error {
im, err := nm.GetInterface(iface)
if err != nil {
return err
}
return im.SetConfig(config)
}
// RenewDHCPLease renews the DHCP lease for a specific interface
func (nm *NetworkManager) RenewDHCPLease(iface string) error {
im, err := nm.GetInterface(iface)
if err != nil {
return err
}
return im.RenewDHCPLease()
}
// SetOnInterfaceStateChange sets the callback for interface state changes
func (nm *NetworkManager) SetOnInterfaceStateChange(callback func(iface string, state types.InterfaceState)) {
nm.onInterfaceStateChange = callback
}
// SetOnConfigChange sets the callback for configuration changes
func (nm *NetworkManager) SetOnConfigChange(callback func(iface string, config *types.NetworkConfig)) {
nm.onConfigChange = callback
}
// SetOnDHCPLeaseChange sets the callback for DHCP lease changes
func (nm *NetworkManager) SetOnDHCPLeaseChange(callback func(iface string, lease *types.DHCPLease)) {
nm.onDHCPLeaseChange = callback
}
func (nm *NetworkManager) shouldKillLegacyDHCPClients() bool {
nm.mu.RLock()
defer nm.mu.RUnlock()
// TODO: remove it when we need to support multiple interfaces
for _, im := range nm.interfaces {
if im.dhcpClient.clientType != "udhcpc" {
return true
}
if im.config.IPv4Mode.String != "dhcp" {
return true
}
}
return false
}
// CleanUpLegacyDHCPClients cleans up legacy DHCP clients
func (nm *NetworkManager) CleanUpLegacyDHCPClients() error {
shouldKill := nm.shouldKillLegacyDHCPClients()
if shouldKill {
return jetdhcpc.KillUdhcpC(nm.logger)
}
return nil
}
// Stop stops the network manager and all managed interfaces
func (nm *NetworkManager) Stop() error {
nm.mu.Lock()
defer nm.mu.Unlock()
var lastErr error
for iface, im := range nm.interfaces {
if err := im.Stop(); err != nil {
nm.logger.Error().Err(err).Str("interface", iface).Msg("failed to stop interface manager")
lastErr = err
}
}
nm.cancel()
nm.logger.Info().Msg("network manager stopped")
return lastErr
}

7
pkg/nmlite/netlink.go Normal file
View File

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

209
pkg/nmlite/resolvconf.go Normal file
View File

@ -0,0 +1,209 @@
package nmlite
import (
"bytes"
"fmt"
"html/template"
"os"
"strings"
"github.com/jetkvm/kvm/internal/network/types"
"github.com/jetkvm/kvm/internal/sync"
"github.com/jetkvm/kvm/pkg/nmlite/link"
"github.com/rs/zerolog"
)
const (
resolvConfPath = "/etc/resolv.conf"
resolvConfFileMode = 0644
resolvConfTemplate = `# the resolv.conf file is managed by JetKVM
# DO NOT EDIT THIS FILE BY HAND -- YOUR CHANGES WILL BE OVERWRITTEN
{{ if .searchList }}
search {{ join .searchList " " }}
{{- end -}}
{{ if .domain }}
domain {{ .domain }}
{{- end -}}
{{ range $ns, $comment := .nameservers }}
nameserver {{ printf "%s" $ns }} # {{ join $comment ", " }}
{{- end }}
`
)
var (
tplFuncMap = template.FuncMap{
"join": strings.Join,
}
)
// ResolvConfManager manages the resolv.conf file
type ResolvConfManager struct {
logger *zerolog.Logger
mu sync.Mutex
conf *types.ResolvConf
hostname string
domain string
}
// NewResolvConfManager creates a new resolv.conf manager
func NewResolvConfManager(logger *zerolog.Logger) *ResolvConfManager {
if logger == nil {
// Create a no-op logger if none provided
logger = &zerolog.Logger{}
}
return &ResolvConfManager{
logger: logger,
mu: sync.Mutex{},
conf: &types.ResolvConf{
ConfigIPv4: make(map[string]types.InterfaceResolvConf),
ConfigIPv6: make(map[string]types.InterfaceResolvConf),
},
}
}
// SetInterfaceConfig sets the resolv.conf configuration for a specific interface
func (rcm *ResolvConfManager) SetInterfaceConfig(iface string, family int, config types.InterfaceResolvConf) error {
// DO NOT USE defer HERE, rcm.update() also locks the mutex
rcm.mu.Lock()
switch family {
case link.AfInet:
rcm.conf.ConfigIPv4[iface] = config
case link.AfInet6:
rcm.conf.ConfigIPv6[iface] = config
default:
rcm.mu.Unlock()
return fmt.Errorf("invalid family: %d", family)
}
rcm.mu.Unlock()
if err := rcm.reconcileHostname(); err != nil {
return fmt.Errorf("failed to reconcile hostname: %w", err)
}
return rcm.update()
}
// SetConfig sets the resolv.conf configuration
func (rcm *ResolvConfManager) SetConfig(resolvConf *types.ResolvConf) error {
if resolvConf == nil {
return fmt.Errorf("resolvConf cannot be nil")
}
rcm.mu.Lock()
rcm.conf = resolvConf
defer rcm.mu.Unlock()
return rcm.update()
}
// Reconcile reconciles the resolv.conf configuration
func (rcm *ResolvConfManager) Reconcile() error {
if err := rcm.reconcileHostname(); err != nil {
return fmt.Errorf("failed to reconcile hostname: %w", err)
}
return rcm.update()
}
// Update updates the resolv.conf file
func (rcm *ResolvConfManager) update() error {
rcm.mu.Lock()
defer rcm.mu.Unlock()
rcm.logger.Debug().Msg("updating resolv.conf")
// Generate resolv.conf content
content, err := rcm.generateResolvConf(rcm.conf)
if err != nil {
return fmt.Errorf("failed to generate resolv.conf: %w", err)
}
// Check if the file is the same
if _, err := os.Stat(resolvConfPath); err == nil {
existingContent, err := os.ReadFile(resolvConfPath)
if err != nil {
rcm.logger.Warn().Err(err).Msg("failed to read existing resolv.conf")
}
if bytes.Equal(existingContent, content) {
rcm.logger.Debug().Msg("resolv.conf is the same, skipping write")
return nil
}
}
// Write to file
if err := os.WriteFile(resolvConfPath, content, resolvConfFileMode); err != nil {
return fmt.Errorf("failed to write resolv.conf: %w", err)
}
rcm.logger.Info().
Interface("config", rcm.conf).
Msg("resolv.conf updated successfully")
return nil
}
type configMap map[string][]string
func mergeConfig(nameservers *configMap, searchList *configMap, config *types.InterfaceResolvConfMap) {
localNameservers := *nameservers
localSearchList := *searchList
for ifname, iface := range *config {
comment := ifname
if iface.Source != "" {
comment += fmt.Sprintf(" (%s)", iface.Source)
}
for _, ip := range iface.NameServers {
ns := ip.String()
if _, ok := localNameservers[ns]; !ok {
localNameservers[ns] = []string{}
}
localNameservers[ns] = append(localNameservers[ns], comment)
}
for _, search := range iface.SearchList {
search = strings.Trim(search, ".")
if _, ok := localSearchList[search]; !ok {
localSearchList[search] = []string{}
}
localSearchList[search] = append(localSearchList[search], comment)
}
}
*nameservers = localNameservers
*searchList = localSearchList
}
// generateResolvConf generates resolv.conf content
func (rcm *ResolvConfManager) generateResolvConf(conf *types.ResolvConf) ([]byte, error) {
tmpl, err := template.New("resolv.conf").Funcs(tplFuncMap).Parse(resolvConfTemplate)
if err != nil {
return nil, fmt.Errorf("failed to parse template: %w", err)
}
// merge the nameservers and searchList
nameservers := configMap{}
searchList := configMap{}
mergeConfig(&nameservers, &searchList, &conf.ConfigIPv4)
mergeConfig(&nameservers, &searchList, &conf.ConfigIPv6)
flattenedSearchList := []string{}
for search := range searchList {
flattenedSearchList = append(flattenedSearchList, search)
}
var buf bytes.Buffer
if err := tmpl.Execute(&buf, map[string]any{
"nameservers": nameservers,
"searchList": flattenedSearchList,
}); err != nil {
return nil, fmt.Errorf("failed to execute template: %w", err)
}
return buf.Bytes(), nil
}

106
pkg/nmlite/state.go Normal file
View File

@ -0,0 +1,106 @@
package nmlite
import "net"
func (nm *NetworkManager) IsOnline() bool {
for _, iface := range nm.interfaces {
if iface.IsOnline() {
return true
}
}
return false
}
func (nm *NetworkManager) IsUp() bool {
for _, iface := range nm.interfaces {
if iface.IsUp() {
return true
}
}
return false
}
func (nm *NetworkManager) Hostname() string {
return nm.resolvConf.Hostname()
}
func (nm *NetworkManager) FQDN() string {
return nm.resolvConf.FQDN()
}
func (nm *NetworkManager) NTPServers() []net.IP {
servers := []net.IP{}
for _, iface := range nm.interfaces {
servers = append(servers, iface.NTPServers()...)
}
return servers
}
func (nm *NetworkManager) NTPServerStrings() []string {
servers := []string{}
for _, server := range nm.NTPServers() {
servers = append(servers, server.String())
}
return servers
}
func (nm *NetworkManager) GetIPv4Addresses() []string {
for _, iface := range nm.interfaces {
return iface.GetIPv4Addresses()
}
return []string{}
}
func (nm *NetworkManager) GetIPv4Address() string {
for _, iface := range nm.interfaces {
return iface.GetIPv4Address()
}
return ""
}
func (nm *NetworkManager) GetIPv6Address() string {
for _, iface := range nm.interfaces {
return iface.GetIPv6Address()
}
return ""
}
func (nm *NetworkManager) GetIPv6Addresses() []string {
for _, iface := range nm.interfaces {
return iface.GetIPv6Addresses()
}
return []string{}
}
func (nm *NetworkManager) GetMACAddress() string {
for _, iface := range nm.interfaces {
return iface.GetMACAddress()
}
return ""
}
func (nm *NetworkManager) IPv4Ready() bool {
for _, iface := range nm.interfaces {
return iface.IPv4Ready()
}
return false
}
func (nm *NetworkManager) IPv6Ready() bool {
for _, iface := range nm.interfaces {
return iface.IPv6Ready()
}
return false
}
func (nm *NetworkManager) IPv4String() string {
return nm.GetIPv4Address()
}
func (nm *NetworkManager) IPv6String() string {
return nm.GetIPv6Address()
}
func (nm *NetworkManager) MACString() string {
return nm.GetMACAddress()
}

184
pkg/nmlite/static.go Normal file
View File

@ -0,0 +1,184 @@
package nmlite
import (
"fmt"
"net"
"github.com/jetkvm/kvm/internal/network/types"
"github.com/jetkvm/kvm/pkg/nmlite/link"
"github.com/rs/zerolog"
)
// StaticConfigManager manages static network configuration
type StaticConfigManager struct {
ifaceName string
logger *zerolog.Logger
}
// NewStaticConfigManager creates a new static configuration manager
func NewStaticConfigManager(ifaceName string, logger *zerolog.Logger) (*StaticConfigManager, error) {
if ifaceName == "" {
return nil, fmt.Errorf("interface name cannot be empty")
}
if logger == nil {
return nil, fmt.Errorf("logger cannot be nil")
}
return &StaticConfigManager{
ifaceName: ifaceName,
logger: logger,
}, nil
}
// ToIPv4Static applies static IPv4 configuration
func (scm *StaticConfigManager) ToIPv4Static(config *types.IPv4StaticConfig) (*types.ParsedIPConfig, error) {
if config == nil {
return nil, fmt.Errorf("config is nil")
}
// Parse IP address and netmask
ipNet, err := link.ParseIPv4Netmask(config.Address.String, config.Netmask.String)
if err != nil {
return nil, err
}
scm.logger.Info().Str("ipNet", ipNet.String()).Interface("ipc", config).Msg("parsed IPv4 address and netmask")
// Parse gateway
gateway := net.ParseIP(config.Gateway.String)
if gateway == nil {
return nil, fmt.Errorf("invalid gateway: %s", config.Gateway.String)
}
// Parse DNS servers
var dns []net.IP
for _, dnsStr := range config.DNS {
if err := link.ValidateIPAddress(dnsStr, false); err != nil {
return nil, fmt.Errorf("invalid DNS server: %w", err)
}
dns = append(dns, net.ParseIP(dnsStr))
}
address := types.IPAddress{
Family: link.AfInet,
Address: *ipNet,
Gateway: gateway,
Secondary: false,
Permanent: true,
}
return &types.ParsedIPConfig{
Addresses: []types.IPAddress{address},
Nameservers: dns,
Interface: scm.ifaceName,
}, nil
}
// ToIPv6Static applies static IPv6 configuration
func (scm *StaticConfigManager) ToIPv6Static(config *types.IPv6StaticConfig) (*types.ParsedIPConfig, error) {
if config == nil {
return nil, fmt.Errorf("config is nil")
}
// Parse IP address and prefix
ipNet, err := link.ParseIPv6Prefix(config.Prefix.String, 64) // Default to /64 if not specified
if err != nil {
return nil, err
}
// Parse gateway
gateway := net.ParseIP(config.Gateway.String)
if gateway == nil {
return nil, fmt.Errorf("invalid gateway: %s", config.Gateway.String)
}
// Parse DNS servers
var dns []net.IP
for _, dnsStr := range config.DNS {
dnsIP := net.ParseIP(dnsStr)
if dnsIP == nil {
return nil, fmt.Errorf("invalid DNS server: %s", dnsStr)
}
dns = append(dns, dnsIP)
}
address := types.IPAddress{
Family: link.AfInet6,
Address: *ipNet,
Gateway: gateway,
Secondary: false,
Permanent: true,
}
return &types.ParsedIPConfig{
Addresses: []types.IPAddress{address},
Nameservers: dns,
Interface: scm.ifaceName,
}, nil
}
// DisableIPv4 disables IPv4 on the interface
func (scm *StaticConfigManager) DisableIPv4() error {
scm.logger.Info().Msg("disabling IPv4")
netlinkMgr := getNetlinkManager()
iface, err := netlinkMgr.GetLinkByName(scm.ifaceName)
if err != nil {
return fmt.Errorf("failed to get interface: %w", err)
}
// Remove all IPv4 addresses
if err := netlinkMgr.RemoveAllAddresses(iface, link.AfInet); err != nil {
return fmt.Errorf("failed to remove IPv4 addresses: %w", err)
}
// Remove default route
if err := scm.removeIPv4DefaultRoute(); err != nil {
scm.logger.Warn().Err(err).Msg("failed to remove IPv4 default route")
}
scm.logger.Info().Msg("IPv4 disabled")
return nil
}
// DisableIPv6 disables IPv6 on the interface
func (scm *StaticConfigManager) DisableIPv6() error {
scm.logger.Info().Msg("disabling IPv6")
netlinkMgr := getNetlinkManager()
return netlinkMgr.DisableIPv6(scm.ifaceName)
}
// EnableIPv6SLAAC enables IPv6 SLAAC
func (scm *StaticConfigManager) EnableIPv6SLAAC() error {
scm.logger.Info().Msg("enabling IPv6 SLAAC")
netlinkMgr := getNetlinkManager()
return netlinkMgr.EnableIPv6SLAAC(scm.ifaceName)
}
// EnableIPv6LinkLocal enables IPv6 link-local only
func (scm *StaticConfigManager) EnableIPv6LinkLocal() error {
scm.logger.Info().Msg("enabling IPv6 link-local only")
netlinkMgr := getNetlinkManager()
if err := netlinkMgr.EnableIPv6LinkLocal(scm.ifaceName); err != nil {
return err
}
// Remove all non-link-local IPv6 addresses
link, err := netlinkMgr.GetLinkByName(scm.ifaceName)
if err != nil {
return fmt.Errorf("failed to get interface: %w", err)
}
if err := netlinkMgr.RemoveNonLinkLocalIPv6Addresses(link); err != nil {
return fmt.Errorf("failed to remove non-link-local IPv6 addresses: %w", err)
}
return netlinkMgr.EnsureInterfaceUp(link)
}
// removeIPv4DefaultRoute removes IPv4 default route
func (scm *StaticConfigManager) removeIPv4DefaultRoute() error {
netlinkMgr := getNetlinkManager()
return netlinkMgr.RemoveDefaultRoute(link.AfInet)
}

171
pkg/nmlite/udhcpc/parser.go Normal file
View File

@ -0,0 +1,171 @@
package udhcpc
import (
"bufio"
"encoding/json"
"fmt"
"net"
"os"
"reflect"
"strconv"
"strings"
"time"
"github.com/jetkvm/kvm/internal/network/types"
)
type Lease struct {
types.DHCPLease
// from https://udhcp.busybox.net/README.udhcpc
isEmpty map[string]bool
}
func (l *Lease) setIsEmpty(m map[string]bool) {
l.isEmpty = m
}
// IsEmpty returns true if the lease is empty for the given key.
func (l *Lease) IsEmpty(key string) bool {
return l.isEmpty[key]
}
// ToJSON returns the lease as a JSON string.
func (l *Lease) ToJSON() string {
json, err := json.Marshal(l)
if err != nil {
return ""
}
return string(json)
}
// ToDHCPLease converts a lease to a DHCP lease.
func (l *Lease) ToDHCPLease() *types.DHCPLease {
lease := &l.DHCPLease
lease.DHCPClient = "udhcpc"
return lease
}
// SetLeaseExpiry sets the lease expiry time.
func (l *Lease) SetLeaseExpiry() (time.Time, error) {
if l.Uptime == 0 || l.LeaseTime == 0 {
return time.Time{}, fmt.Errorf("uptime or lease time isn't set")
}
// get the uptime of the device
file, err := os.Open("/proc/uptime")
if err != nil {
return time.Time{}, fmt.Errorf("failed to open uptime file: %w", err)
}
defer file.Close()
var uptime time.Duration
scanner := bufio.NewScanner(file)
for scanner.Scan() {
text := scanner.Text()
parts := strings.Split(text, " ")
uptime, err = time.ParseDuration(parts[0] + "s")
if err != nil {
return time.Time{}, fmt.Errorf("failed to parse uptime: %w", err)
}
}
relativeLeaseRemaining := (l.Uptime + l.LeaseTime) - uptime
leaseExpiry := time.Now().Add(relativeLeaseRemaining)
l.LeaseExpiry = &leaseExpiry
return leaseExpiry, nil
}
// UnmarshalDHCPCLease unmarshals a lease from a string.
func UnmarshalDHCPCLease(obj *Lease, str string) error {
lease := &obj.DHCPLease
// parse the lease file as a map
data := make(map[string]string)
for line := range strings.SplitSeq(str, "\n") {
line = strings.TrimSpace(line)
// skip empty lines and comments
if line == "" || strings.HasPrefix(line, "#") {
continue
}
parts := strings.SplitN(line, "=", 2)
if len(parts) != 2 {
continue
}
key := strings.TrimSpace(parts[0])
value := strings.TrimSpace(parts[1])
data[key] = value
}
// now iterate over the lease struct and set the values
leaseType := reflect.TypeOf(lease).Elem()
leaseValue := reflect.ValueOf(lease).Elem()
valuesParsed := make(map[string]bool)
for i := 0; i < leaseType.NumField(); i++ {
field := leaseValue.Field(i)
// get the env tag
key := leaseType.Field(i).Tag.Get("env")
if key == "" {
continue
}
valuesParsed[key] = false
// get the value from the data map
value, ok := data[key]
if !ok || value == "" {
continue
}
switch field.Interface().(type) {
case string:
field.SetString(value)
case int:
val, err := strconv.Atoi(value)
if err != nil {
continue
}
field.SetInt(int64(val))
case time.Duration:
val, err := time.ParseDuration(value + "s")
if err != nil {
continue
}
field.Set(reflect.ValueOf(val))
case net.IP:
ip := net.ParseIP(value)
if ip == nil {
continue
}
field.Set(reflect.ValueOf(ip))
case []net.IP:
val := make([]net.IP, 0)
for ipStr := range strings.FieldsSeq(value) {
ip := net.ParseIP(ipStr)
if ip == nil {
continue
}
val = append(val, ip)
}
field.Set(reflect.ValueOf(val))
default:
return fmt.Errorf("unsupported field `%s` type: %s", key, field.Type().String())
}
valuesParsed[key] = true
}
obj.setIsEmpty(valuesParsed)
return nil
}

View File

@ -6,9 +6,13 @@ import (
"os"
"path/filepath"
"reflect"
"time"
"github.com/jetkvm/kvm/internal/sync"
"github.com/fsnotify/fsnotify"
"github.com/jetkvm/kvm/internal/network/types"
"github.com/rs/zerolog"
)
@ -18,20 +22,22 @@ const (
)
type DHCPClient struct {
types.DHCPClient
InterfaceName string
leaseFile string
pidFile string
lease *Lease
logger *zerolog.Logger
process *os.Process
onLeaseChange func(lease *Lease)
runOnce sync.Once
onLeaseChange func(lease *types.DHCPLease)
}
type DHCPClientOptions struct {
InterfaceName string
PidFile string
Logger *zerolog.Logger
OnLeaseChange func(lease *Lease)
OnLeaseChange func(lease *types.DHCPLease)
}
var defaultLogger = zerolog.New(os.Stdout).Level(zerolog.InfoLevel)
@ -67,8 +73,8 @@ func (c *DHCPClient) getWatchPaths() []string {
}
// Run starts the DHCP client and watches the lease file for changes.
// this isn't a blocking call, and the lease file is reloaded when a change is detected.
func (c *DHCPClient) Run() error {
// this is a blocking call.
func (c *DHCPClient) run() error {
err := c.loadLeaseFile()
if err != nil && !errors.Is(err, os.ErrNotExist) {
return err
@ -125,7 +131,7 @@ func (c *DHCPClient) Run() error {
// c.logger.Error().Msg("udhcpc process not found")
// }
// block the goroutine until the lease file is updated
// block the goroutine
<-make(chan struct{})
return nil
@ -182,7 +188,7 @@ func (c *DHCPClient) loadLeaseFile() error {
Msg("current dhcp lease expiry time calculated")
}
c.onLeaseChange(lease)
c.onLeaseChange(lease.ToDHCPLease())
c.logger.Info().
Str("ip", lease.IPAddress.String()).
@ -196,3 +202,47 @@ func (c *DHCPClient) loadLeaseFile() error {
func (c *DHCPClient) GetLease() *Lease {
return c.lease
}
func (c *DHCPClient) Domain() string {
return c.lease.Domain
}
func (c *DHCPClient) Lease4() *types.DHCPLease {
if c.lease == nil {
return nil
}
return c.lease.ToDHCPLease()
}
func (c *DHCPClient) Lease6() *types.DHCPLease {
// TODO: implement
return nil
}
func (c *DHCPClient) SetIPv4(enabled bool) {
// TODO: implement
}
func (c *DHCPClient) SetIPv6(enabled bool) {
// TODO: implement
}
func (c *DHCPClient) SetOnLeaseChange(callback func(lease *types.DHCPLease)) {
c.onLeaseChange = callback
}
func (c *DHCPClient) Start() error {
c.runOnce.Do(func() {
go func() {
err := c.run()
if err != nil {
c.logger.Error().Err(err).Msg("failed to run udhcpc")
}
}()
})
return nil
}
func (c *DHCPClient) Stop() error {
return c.KillProcess() // udhcpc already has KillProcess()
}

76
pkg/nmlite/utils.go Normal file
View File

@ -0,0 +1,76 @@
package nmlite
import (
"sort"
"time"
"github.com/jetkvm/kvm/internal/network/types"
)
func lifetimeToTime(lifetime int) *time.Time {
if lifetime == 0 {
return nil
}
// Check for infinite lifetime (0xFFFFFFFF = 4294967295)
// This is used for static/permanent addresses
// Use uint32 to avoid int overflow on 32-bit systems
const infiniteLifetime uint32 = 0xFFFFFFFF
if uint32(lifetime) == infiniteLifetime || lifetime < 0 {
return nil // Infinite lifetime - no expiration
}
// For finite lifetimes (SLAAC addresses)
t := time.Now().Add(time.Duration(lifetime) * time.Second)
return &t
}
func sortAndCompareStringSlices(a, b []string) bool {
if len(a) != len(b) {
return false
}
sort.Strings(a)
sort.Strings(b)
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}
func sortAndCompareIPv6AddressSlices(a, b []types.IPv6Address) bool {
if len(a) != len(b) {
return false
}
sort.SliceStable(a, func(i, j int) bool {
return a[i].Address.String() < b[j].Address.String()
})
sort.SliceStable(b, func(i, j int) bool {
return b[i].Address.String() < a[j].Address.String()
})
for i := range a {
if a[i].Address.String() != b[i].Address.String() {
return false
}
if a[i].Prefix.String() != b[i].Prefix.String() {
return false
}
if a[i].Flags != b[i].Flags {
return false
}
// we don't compare the lifetimes because they are not always same
if a[i].Scope != b[i].Scope {
return false
}
}
return true
}

View File

@ -17,6 +17,7 @@ show_help() {
echo " --skip-ui-build Skip frontend/UI build"
echo " --skip-native-build Skip native build"
echo " --disable-docker Disable docker build"
echo " --enable-sync-trace Enable sync trace (do not use in release builds)"
echo " -i, --install Build for release and install the app"
echo " --help Display this help message"
echo
@ -25,6 +26,31 @@ show_help() {
echo " $0 -r 192.168.0.17 -u admin"
}
# Function to check if device is pingable
check_ping() {
local host=$1
msg_info "▶ Checking if device is reachable at ${host}..."
if ! ping -c 3 -W 5 "${host}" > /dev/null 2>&1; then
msg_err "Error: Cannot reach device at ${host}"
msg_err "Please verify the IP address and network connectivity"
exit 1
fi
msg_info "✓ Device is reachable"
}
# Function to check if SSH is accessible
check_ssh() {
local user=$1
local host=$2
msg_info "▶ Checking SSH connectivity to ${user}@${host}..."
if ! ssh -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no -o ConnectTimeout=10 "${user}@${host}" "echo 'SSH connection successful'" > /dev/null 2>&1; then
msg_err "Error: Cannot establish SSH connection to ${user}@${host}"
msg_err "Please verify SSH access and credentials"
exit 1
fi
msg_info "✓ SSH connection successful"
}
# Default values
SCRIPT_PATH=$(realpath "$(dirname $(realpath "${BASH_SOURCE[0]}"))")
REMOTE_USER="root"
@ -32,6 +58,7 @@ REMOTE_PATH="/userdata/jetkvm/bin"
SKIP_UI_BUILD=false
SKIP_UI_BUILD_RELEASE=0
SKIP_NATIVE_BUILD=0
ENABLE_SYNC_TRACE=0
RESET_USB_HID_DEVICE=false
LOG_TRACE_SCOPES="${LOG_TRACE_SCOPES:-jetkvm,cloud,websocket,native,jsonrpc}"
RUN_GO_TESTS=false
@ -64,6 +91,11 @@ while [[ $# -gt 0 ]]; do
RESET_USB_HID_DEVICE=true
shift
;;
--enable-sync-trace)
ENABLE_SYNC_TRACE=1
LOG_TRACE_SCOPES="${LOG_TRACE_SCOPES},synctrace"
shift
;;
--disable-docker)
BUILD_IN_DOCKER=false
shift
@ -106,6 +138,10 @@ if [ -z "$REMOTE_HOST" ]; then
exit 1
fi
# Check device connectivity before proceeding
check_ping "${REMOTE_HOST}"
check_ssh "${REMOTE_USER}" "${REMOTE_HOST}"
# check if the current CPU architecture is x86_64
if [ "$(uname -m)" != "x86_64" ]; then
msg_warn "Warning: This script is only supported on x86_64 architecture"
@ -140,10 +176,10 @@ if [ "$RUN_GO_TESTS" = true ]; then
make build_dev_test
msg_info "▶ Copying device-tests.tar.gz to remote host"
ssh "${REMOTE_USER}@${REMOTE_HOST}" "cat > /tmp/device-tests.tar.gz" < device-tests.tar.gz
ssh -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no "${REMOTE_USER}@${REMOTE_HOST}" "cat > /tmp/device-tests.tar.gz" < device-tests.tar.gz
msg_info "▶ Running go tests"
ssh "${REMOTE_USER}@${REMOTE_HOST}" ash << 'EOF'
ssh -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no "${REMOTE_USER}@${REMOTE_HOST}" ash << 'EOF'
set -e
TMP_DIR=$(mktemp -d)
cd ${TMP_DIR}
@ -180,33 +216,39 @@ fi
if [ "$INSTALL_APP" = true ]
then
msg_info "▶ Building release binary"
do_make build_release SKIP_NATIVE_IF_EXISTS=${SKIP_NATIVE_BUILD} SKIP_UI_BUILD=${SKIP_UI_BUILD_RELEASE}
do_make build_release \
SKIP_NATIVE_IF_EXISTS=${SKIP_NATIVE_BUILD} \
SKIP_UI_BUILD=${SKIP_UI_BUILD_RELEASE} \
ENABLE_SYNC_TRACE=${ENABLE_SYNC_TRACE}
# Copy the binary to the remote host as if we were the OTA updater.
ssh "${REMOTE_USER}@${REMOTE_HOST}" "cat > /userdata/jetkvm/jetkvm_app.update" < bin/jetkvm_app
ssh -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no "${REMOTE_USER}@${REMOTE_HOST}" "cat > /userdata/jetkvm/jetkvm_app.update" < bin/jetkvm_app
# Reboot the device, the new app will be deployed by the startup process.
ssh "${REMOTE_USER}@${REMOTE_HOST}" "reboot"
ssh -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no "${REMOTE_USER}@${REMOTE_HOST}" "reboot"
else
msg_info "▶ Building development binary"
do_make build_dev SKIP_NATIVE_IF_EXISTS=${SKIP_NATIVE_BUILD} SKIP_UI_BUILD=${SKIP_UI_BUILD_RELEASE}
do_make build_dev \
SKIP_NATIVE_IF_EXISTS=${SKIP_NATIVE_BUILD} \
SKIP_UI_BUILD=${SKIP_UI_BUILD_RELEASE} \
ENABLE_SYNC_TRACE=${ENABLE_SYNC_TRACE}
# Kill any existing instances of the application
ssh "${REMOTE_USER}@${REMOTE_HOST}" "killall jetkvm_app_debug || true"
ssh -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no "${REMOTE_USER}@${REMOTE_HOST}" "killall jetkvm_app_debug || true"
# Copy the binary to the remote host
ssh "${REMOTE_USER}@${REMOTE_HOST}" "cat > ${REMOTE_PATH}/jetkvm_app_debug" < bin/jetkvm_app
ssh -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no "${REMOTE_USER}@${REMOTE_HOST}" "cat > ${REMOTE_PATH}/jetkvm_app_debug" < bin/jetkvm_app
if [ "$RESET_USB_HID_DEVICE" = true ]; then
msg_info "▶ Resetting USB HID device"
msg_warn "The option has been deprecated and will be removed in a future version, as JetKVM will now reset USB gadget configuration when needed"
# Remove the old USB gadget configuration
ssh "${REMOTE_USER}@${REMOTE_HOST}" "rm -rf /sys/kernel/config/usb_gadget/jetkvm/configs/c.1/hid.usb*"
ssh "${REMOTE_USER}@${REMOTE_HOST}" "ls /sys/class/udc > /sys/kernel/config/usb_gadget/jetkvm/UDC"
ssh -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no "${REMOTE_USER}@${REMOTE_HOST}" "rm -rf /sys/kernel/config/usb_gadget/jetkvm/configs/c.1/hid.usb*"
ssh -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no "${REMOTE_USER}@${REMOTE_HOST}" "ls /sys/class/udc > /sys/kernel/config/usb_gadget/jetkvm/UDC"
fi
# Deploy and run the application on the remote host
ssh "${REMOTE_USER}@${REMOTE_HOST}" ash << EOF
ssh -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no "${REMOTE_USER}@${REMOTE_HOST}" ash << EOF
set -e
# Set the library path to include the directory where librockit.so is located
@ -216,6 +258,17 @@ export LD_LIBRARY_PATH=/oem/usr/lib:\$LD_LIBRARY_PATH
killall jetkvm_app || true
killall jetkvm_app_debug || true
# Wait until both binaries are killed, max 10 seconds
i=1
while [ \$i -le 10 ]; do
echo "Waiting for jetkvm_app and jetkvm_app_debug to be killed, \$i/10 ..."
if ! pgrep -f "jetkvm_app" > /dev/null && ! pgrep -f "jetkvm_app_debug" > /dev/null; then
break
fi
sleep 1
i=\$((i + 1))
done
# Navigate to the directory where the binary will be stored
cd "${REMOTE_PATH}"

View File

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

View File

@ -9,8 +9,6 @@ const {
fixupConfigRules,
} = require("@eslint/compat");
const tsParser = require("@typescript-eslint/parser");
const reactRefresh = require("eslint-plugin-react-refresh");
const js = require("@eslint/js");
const {
@ -23,6 +21,9 @@ const compat = new FlatCompat({
allConfig: js.configs.all
});
const tsParser = require("@typescript-eslint/parser");
const reactRefresh = require("eslint-plugin-react-refresh");
module.exports = defineConfig([{
languageOptions: {
globals: {
@ -81,7 +82,10 @@ module.exports = defineConfig([{
map: [
["@components", "./src/components"],
["@routes", "./src/routes"],
["@hooks", "./src/hooks"],
["@providers", "./src/providers"],
["@assets", "./src/assets"],
["@localizations", "./localization/paraglide"],
["@", "./src"],
],

View File

@ -45,31 +45,39 @@
<link rel="shortcut icon" href="/favicon.ico" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<meta name="apple-mobile-web-app-title" content="JetKVM" />
<link rel="manifest" href="/site.webmanifest" />
<link rel="manifest" href="/public/site.webmanifest" />
<meta name="theme-color" content="#051946" />
<meta name="description" content="A web-based KVM console for managing remote servers." />
<meta
name="description"
content="A web-based KVM console for managing remote servers."
/>
<script>
function applyThemeFromPreference() {
// dark theme setup
var darkDesired = localStorage.theme === "dark" ||
var darkDesired =
localStorage.theme === "dark" ||
(!("theme" in localStorage) &&
window.matchMedia("(prefers-color-scheme: dark)").matches)
window.matchMedia("(prefers-color-scheme: dark)").matches);
document.documentElement.classList.toggle("dark", darkDesired)
document.documentElement.classList.toggle("dark", darkDesired);
}
// initial theme application
applyThemeFromPreference();
// Listen for system theme changes
window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change", applyThemeFromPreference);
window.matchMedia("(prefers-color-scheme: light)").addEventListener("change", applyThemeFromPreference);
window
.matchMedia("(prefers-color-scheme: dark)")
.addEventListener("change", applyThemeFromPreference);
window
.matchMedia("(prefers-color-scheme: light)")
.addEventListener("change", applyThemeFromPreference);
</script>
</head>
<body
class="h-full w-full bg-[#f3f9ff] font-sans text-sm antialiased dark:bg-slate-900 md:text-base"
class="h-full w-full bg-[#f3f9ff] font-sans text-sm antialiased md:text-base dark:bg-slate-900"
>
<div id="root" class="w-full h-full"></div>
<div id="root" class="h-full w-full"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View File

@ -0,0 +1 @@
cache

View File

@ -0,0 +1 @@
TI1a2RjjH4qkImNj0w

View File

@ -0,0 +1,48 @@
{
"$schema": "https://inlang.com/schema/project-settings",
"baseLocale": "en",
"sourceLanguageTag": "en",
"locales": [
"en",
"da",
"de",
"es",
"fr",
"it",
"nb",
"sv",
"zh"
],
"languageTags": [
"en",
"da",
"de",
"es",
"fr",
"it",
"nb",
"sv",
"zh"
],
"modules": [
"https://cdn.jsdelivr.net/npm/@inlang/plugin-message-format@latest/dist/index.js",
"https://cdn.jsdelivr.net/npm/@inlang/plugin-m-function-matcher@latest/dist/index.js"
],
"plugin.inlang.messageFormat": {
"pathPattern": "./messages/{locale}.json"
},
"plugin.inlang.mFunctionMatcher": {
"matchers": [
{
"type": "m-function",
"function": "plural",
"parameter": "count"
}
]
},
"strategy": [
"cookie",
"preferredLanguage",
"baseLocale"
]
}

View File

@ -0,0 +1,901 @@
{
"$schema": "https://inlang.com/schema/inlang-message-format",
"access_adopt_kvm": "Adopter KVM til skyen",
"access_adopted_message": "Din enhed er tilknyttet skyen",
"access_auth_mode_no_password": "Aktuel tilstand: Ingen adgangskode",
"access_auth_mode_password": "Aktuel tilstand: Adgangskodebeskyttet",
"access_authentication_mode_title": "Godkendelsestilstand",
"access_certificate_label": "Certifikat",
"access_change_password_button": "Skift adgangskode",
"access_change_password_description": "Opdater din adgangskode til enheden",
"access_change_password_title": "Skift adgangskode",
"access_cloud_api_url_label": "Cloud API-URL",
"access_cloud_app_url_label": "URL til cloud-applikation",
"access_cloud_provider_description": "Vælg cloud-udbyderen til din enhed",
"access_cloud_provider_title": "Cloud-udbyder",
"access_cloud_security_title": "Cloud-sikkerhed",
"access_confirm_deregister": "Er du sikker på, at du vil afregistrere denne enhed?",
"access_deregister": "Afregistrer fra skyen",
"access_description": "Administrer adgangskontrollen for enheden",
"access_disable_protection": "Deaktiver beskyttelse",
"access_enable_password": "Aktivér adgangskode",
"access_failed_deregister": "Kunne ikke afregistrere enhed: {error}",
"access_failed_update_cloud_url": "Kunne ikke opdatere cloud-URL: {error}",
"access_failed_update_tls": "Kunne ikke opdatere TLS-indstillinger: {error}",
"access_github_link": "GitHub",
"access_https_description": "Konfigurer sikker HTTPS-adgang til din enhed",
"access_https_mode_title": "HTTPS-tilstand",
"access_learn_security": "Lær om vores cloud-sikkerhed",
"access_local_description": "Administrer tilstanden for lokal adgang til enheden",
"access_local_title": "Lokal",
"access_no_device_id": "Intet enheds-ID tilgængeligt",
"access_private_key_description": "Af sikkerhedsmæssige årsager vil den ikke blive vist efter lagring.",
"access_private_key_label": "Privat nøgle",
"access_provider_custom": "Tilpasset",
"access_provider_jetkvm": "JetKVM Cloud",
"access_remote_description": "Administrer tilstanden for fjernadgang til enheden",
"access_security_encryption": "End-to-end-kryptering ved hjælp af WebRTC (DTLS og SRTP)",
"access_security_oidc": "OIDC (OpenID Connect) godkendelse",
"access_security_open_source": "Alle cloud-komponenter er open source og tilgængelige på GitHub.",
"access_security_streams": "Alle streams krypteret under transit",
"access_security_zero_trust": "Zero Trust-sikkerhedsmodel",
"access_title": "Adgang",
"access_tls_certificate_description": "Indsæt dit TLS-certifikat nedenfor. For certifikatkæder skal du inkludere hele kæden (brugercertifikat, mellemliggende og rodcertifikater).",
"access_tls_certificate_title": "TLS-certifikat",
"access_tls_custom": "Tilpasset",
"access_tls_disabled": "Deaktiveret",
"access_tls_self_signed": "Selvsigneret",
"access_tls_updated": "TLS-indstillingerne er blevet opdateret",
"access_update_tls_settings": "Opdater TLS-indstillinger",
"action_bar_connection_stats": "Forbindelsesstatistik",
"action_bar_extension": "Udvidelse",
"action_bar_fullscreen": "Fuldskærm",
"action_bar_settings": "Indstillinger",
"action_bar_virtual_keyboard": "Virtuelt tastatur",
"action_bar_virtual_media": "Virtuelle medier",
"action_bar_wake_on_lan": "Vågn på LAN",
"action_bar_web_terminal": "Webterminal",
"advanced_description": "Få adgang til avancerede indstillinger til fejlfinding og tilpasning",
"advanced_dev_channel_description": "Modtag tidlige opdateringer fra udviklingskanalen",
"advanced_dev_channel_title": "Opdateringer af udviklerkanaler",
"advanced_developer_mode_description": "Aktivér avancerede funktioner for udviklere",
"advanced_developer_mode_enabled_title": "Udviklertilstand aktiveret",
"advanced_developer_mode_title": "Udviklertilstand",
"advanced_developer_mode_warning_advanced": "Kun for avancerede brugere. Ikke til produktionsbrug.",
"advanced_developer_mode_warning_risks": "Brug kun hvis du forstår risiciene",
"advanced_developer_mode_warning_security": "Sikkerheden svækkes, mens den er aktiv",
"advanced_disable_usb_emulation": "Deaktiver USB-emulering",
"advanced_enable_usb_emulation": "Aktivér USB-emulering",
"advanced_error_loopback_disable": "Kunne ikke deaktivere loopback-only-tilstand: {error}",
"advanced_error_loopback_enable": "Kunne ikke aktivere loopback-only-tilstand: {error}",
"advanced_error_reset_config": "Konfigurationen kunne ikke nulstilles: {error}",
"advanced_error_set_dev_channel": "Kunne ikke indstille udviklerkanaltilstand: {error}",
"advanced_error_set_dev_mode": "Kunne ikke indstille udviklertilstand: {error}",
"advanced_error_update_ssh_key": "Kunne ikke opdatere SSH-nøglen: {error}",
"advanced_error_usb_emulation_disable": "Kunne ikke deaktivere USB-emulering: {error}",
"advanced_error_usb_emulation_enable": "Kunne ikke aktivere USB-emulering: {error}",
"advanced_loopback_only_description": "Begræns webgrænsefladeadgang kun til localhost (127.0.0.1)",
"advanced_loopback_only_title": "Kun loopback-tilstand",
"advanced_loopback_warning_before": "Før du aktiverer denne funktion, skal du sikre dig, at du har enten:",
"advanced_loopback_warning_cloud": "Cloud-adgang aktiveret og fungerer",
"advanced_loopback_warning_confirm": "Jeg forstår, aktivér alligevel",
"advanced_loopback_warning_description": "ADVARSEL: Dette vil begrænse adgangen til webgrænsefladen til kun localhost (127.0.0.1).",
"advanced_loopback_warning_ssh": "SSH-adgang konfigureret og testet",
"advanced_loopback_warning_title": "Aktivér kun loopback-tilstand?",
"advanced_reset_config_button": "Nulstil konfiguration",
"advanced_reset_config_description": "Nulstil konfigurationen til standard. Dette vil logge dig ud.",
"advanced_reset_config_title": "Nulstil konfiguration",
"advanced_ssh_access_description": "Tilføj din offentlige SSH-nøgle for at aktivere sikker fjernadgang til enheden",
"advanced_ssh_access_title": "SSH-adgang",
"advanced_ssh_default_user": "Standard SSH-brugeren er",
"advanced_ssh_public_key_label": "Offentlig SSH-nøgle",
"advanced_ssh_public_key_placeholder": "Indtast din offentlige SSH-nøgle",
"advanced_success_loopback_disabled": "Kun loopback-tilstand er deaktiveret. Genstart din enhed for at anvende den.",
"advanced_success_loopback_enabled": "Kun loopback-tilstand aktiveret. Genstart din enhed for at anvende den.",
"advanced_success_reset_config": "Konfigurationen er nulstillet til standard",
"advanced_success_update_ssh_key": "SSH-nøglen er blevet opdateret",
"advanced_title": "Avanceret",
"advanced_troubleshooting_mode_description": "Diagnostiske værktøjer og yderligere kontroller til fejlfinding og udviklingsformål",
"advanced_troubleshooting_mode_title": "Fejlfindingstilstand",
"advanced_update_ssh_key_button": "Opdater SSH-nøgle",
"advanced_usb_emulation_description": "Styr USB-emuleringstilstanden",
"advanced_usb_emulation_title": "USB-emulering",
"already_adopted_new_owner": "Hvis du er den nye ejer, bedes du bede den tidligere ejer om at afregistrere enheden fra sin konto i cloud-dashboardet. Hvis du mener, at dette er en fejl, kan du kontakte vores supportteam for at få hjælp.",
"already_adopted_other_user": "Denne enhed er i øjeblikket registreret til en anden bruger i vores cloud-dashboard.",
"already_adopted_return_to_dashboard": "Tilbage til dashboardet",
"already_adopted_title": "Enheden er allerede registreret",
"appearance_description": "Vælg dit foretrukne tema",
"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_title": "Udseende",
"attach": "Vedhæft",
"atx_power_control_get_state_error": "Kunne ikke hente ATX-strømtilstand: {error}",
"atx_power_control_hdd_led": "HDD-LED",
"atx_power_control_long_power_button": "Langt tryk",
"atx_power_control_power_button": "Strøm",
"atx_power_control_power_led": "Strøm-LED",
"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_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_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 sikkert at få adgang til og administrere dine enheder",
"auth_mode_local": "Lokal godkendelsesmetode",
"auth_mode_local_change_later": "Du kan altid ændre din godkendelsesmetode senere i indstillingerne.",
"auth_mode_local_description": "Vælg, hvordan du vil sikre din JetKVM-enhed lokalt.",
"auth_mode_local_no_password": "Ingen adgangskode",
"auth_mode_local_no_password_description": "Hurtig adgang uden adgangskodegodkendelse.",
"auth_mode_local_password": "Adgangskode",
"auth_mode_local_password_confirm_description": "Bekræft din adgangskode",
"auth_mode_local_password_confirm_label": "Bekræft adgangskode",
"auth_mode_local_password_description": "Sikr din enhed med en adgangskode for ekstra beskyttelse.",
"auth_mode_local_password_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_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 adgangskode",
"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",
"back_to_devices": "Tilbage til Enheder",
"cancel": "Annuller",
"close": "Luk",
"cloud_kvms": "Cloud KVM'er",
"cloud_kvms_description": "Administrer dine cloud-KVM'er, og opret forbindelse til dem sikkert.",
"cloud_kvms_no_devices": "Ingen enheder fundet",
"cloud_kvms_no_devices_description": "Du har endnu ingen enheder med aktiveret JetKVM Cloud.",
"confirm": "Bekræft",
"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_connection_description": "Forbindelsen mellem klienten og JetKVM'en.",
"connection_stats_frames_per_second": "Billeder per sekund",
"connection_stats_frames_per_second_description": "Antal indgående videobilleder vist pr. sekund.",
"connection_stats_network_stability": "Netværksstabilitet",
"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_packets_lost_description": "Antal mistede indgående video-RTP-pakker.",
"connection_stats_playback_delay": "Afspilningsforsinkelse",
"connection_stats_playback_delay_description": "Forsinkelse tilføjet af jitterbufferen for at jævne afspilningen, når billeder ankommer ujævnt.",
"connection_stats_round_trip_time": "Rundturstid",
"connection_stats_round_trip_time_description": "Rundrejsetid for det aktive ICE-kandidatpar mellem peers.",
"connection_stats_sidebar": "Forbindelsesstatistik",
"connection_stats_unit_frames_per_second": " fps",
"connection_stats_unit_milliseconds": " ms",
"connection_stats_unit_packets": " pakker",
"connection_stats_video": "Video",
"connection_stats_video_description": "Videostreamen fra JetKVM'en til klienten.",
"continue": "Fortsæt",
"creating_peer_connection": "Opretter peer-forbindelse…",
"dc_power_control_current": "Strøm",
"dc_power_control_current_unit": "EN",
"dc_power_control_get_state_error": "Kunne ikke hente DC-strømtilstand: {error}",
"dc_power_control_power": "Strøm",
"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_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",
"delete": "Slet",
"deregister_cloud_devices": "Cloud-enheder",
"deregister_description": "Dette vil fjerne enheden fra din cloud-konto og tilbagekalde fjernadgang til den. Bemærk venligst, at lokal adgang stadig vil være mulig.",
"deregister_error": "Der opstod en fejl {status} under afregistreringen af din enhed. Prøv igen.",
"deregister_from_cloud": "Afregistrer fra skyen",
"deregister_headline": "Afregistrér {device} fra din cloud-konto",
"detach": "Løsrive",
"dhcp_empty_lease_description": "Vi har endnu ikke modtaget nogen DHCP-leaseoplysninger fra enheden.",
"dhcp_empty_lease_headline": "Ingen DHCP-leaseoplysninger",
"dhcp_lease_boot_file": "Boot-fil",
"dhcp_lease_boot_next_server": "Start næste server",
"dhcp_lease_boot_server_name": "Navn på bootserver",
"dhcp_lease_broadcast": "Udsende",
"dhcp_lease_domain": "Domæne",
"dhcp_lease_gateway": "Gateway",
"dhcp_lease_header": "DHCP-leaseoplysninger",
"dhcp_lease_hostname": "Værtsnavn",
"dhcp_lease_lease_expires": "Lejekontrakten udløber",
"dhcp_lease_maximum_transfer_unit": "MTU",
"dhcp_lease_renew": "Forny DHCP-lease",
"dhcp_lease_time_to_live": "TTL",
"dhcp_server": "DHCP-server",
"dns_servers": "DNS-servere",
"establishing_secure_connection": "Opretter sikker forbindelse…",
"experimental": "Eksperimentel",
"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",
"extensions_atx_power_control_description": "Styr din maskines strømtilstand via ATX-strømstyring.",
"extensions_dc_power_control": "DC-strømstyring",
"extensions_dc_power_control_description": "Styr din DC-strømforlænger",
"extensions_popover_extensions": "Udvidelser",
"gathering_ice_candidates": "Samler ICE-kandidater…",
"general_app_version": "App: {version}",
"general_auto_update_description": "Opdater automatisk enheden til den nyeste version",
"general_auto_update_error": "Kunne ikke indstille automatisk opdatering: {error}",
"general_auto_update_title": "Automatisk opdatering",
"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_no_button": "Ingen",
"general_reboot_title": "Genstart JetKVM",
"general_reboot_yes_button": "Ja",
"general_system_version": "System: {version}",
"general_title": "Generel",
"general_update_app_update_title": "App-opdatering",
"general_update_application_type": "App",
"general_update_available_description": "En ny opdatering er tilgængelig, som forbedrer ydeevne og kompatibilitet. Vi anbefaler at opdatere for at sikre, at alt kører problemfrit.",
"general_update_available_title": "Opdatering tilgængelig",
"general_update_background_button": "Opdatering i baggrunden",
"general_update_check_again_button": "Tjek igen",
"general_update_checking_description": "Vi sørger for, at din enhed har de nyeste funktioner og forbedringer.",
"general_update_checking_title": "Søger efter opdateringer…",
"general_update_completed_description": "Din enhed er blevet opdateret til den nyeste version. Nyd de nye funktioner og forbedringer!",
"general_update_completed_title": "Opdatering gennemført",
"general_update_error_description": "Der opstod en fejl under opdateringen af din enhed. Prøv igen senere.",
"general_update_error_details": "Fejldetaljer: {errorMessage}",
"general_update_error_title": "Opdateringsfejl",
"general_update_later_button": "Opdater senere",
"general_update_now_button": "Opdater nu",
"general_update_rebooting": "Genstarter for at fuldføre opdateringen…",
"general_update_status_awaiting_reboot": "Venter på genstart",
"general_update_status_downloading": "Downloader {update_type} opdatering…",
"general_update_status_fetching": "Henter opdateringsoplysninger…",
"general_update_status_installing": "Installation af {update_type} opdatering…",
"general_update_status_progress": "{part} fremskridt",
"general_update_status_verifying": "Bekræfter {update_type} opdatering…",
"general_update_system_type": "System",
"general_update_system_update_title": "Linux-systemopdatering",
"general_update_up_to_date_description": "Dit system kører den nyeste version. Der er ingen tilgængelige opdateringer i øjeblikket.",
"general_update_up_to_date_title": "Systemet er opdateret",
"general_update_updating_description": "Sluk ikke enheden. Denne proces kan tage et par minutter.",
"general_update_updating_title": "Opdatering af din enhed",
"getting_remote_session_description": "Henter beskrivelse af fjernsessionsforsøg {attempt}",
"hardware_backlight_settings_error": "Kunne ikke indstille baggrundsbelysningsindstillinger: {error}",
"hardware_backlight_settings_get_error": "Kunne ikke hente indstillinger for baggrundsbelysning: {error}",
"hardware_backlight_settings_success": "Baggrundsbelysningsindstillingerne er blevet opdateret",
"hardware_dim_display_after_description": "Indstil, hvor længe der skal ventes, før displayet dæmpes",
"hardware_dim_display_after_title": "Dæmp displayet efter",
"hardware_display_brightness_description": "Indstil skærmens lysstyrke",
"hardware_display_brightness_high": "Høj",
"hardware_display_brightness_low": "Lav",
"hardware_display_brightness_medium": "Medium",
"hardware_display_brightness_off": "Slukket",
"hardware_display_brightness_title": "Skærmens lysstyrke",
"hardware_display_orientation_description": "Indstil skærmens retning",
"hardware_display_orientation_error": "Kunne ikke indstille visningsretning: {error}",
"hardware_display_orientation_inverted": "Omvendt",
"hardware_display_orientation_normal": "Normal",
"hardware_display_orientation_success": "Skærmretningen er blevet opdateret",
"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_power_saving_description": "Reducer strømforbruget, når det ikke er i brug",
"hardware_power_saving_disabled": "Strømsparetilstand deaktiveret",
"hardware_power_saving_enabled": "Strømsparetilstand aktiveret",
"hardware_power_saving_failed_error": "Kunne ikke indstille strømsparetilstand: {error}",
"hardware_power_saving_hdmi_sleep_description": "Slå optagelse fra efter 90 sekunders inaktivitet",
"hardware_power_saving_hdmi_sleep_title": "HDMI-dvaletilstand",
"hardware_power_saving_title": "Strømsparetilstand",
"hardware_time_10_minutes": "10 minutter",
"hardware_time_1_hour": "1 time",
"hardware_time_1_minute": "1 minut",
"hardware_time_30_minutes": "30 minutter",
"hardware_time_5_minutes": "5 minutter",
"hardware_time_never": "Aldrig",
"hardware_title": "Hardware",
"hardware_turn_off_display_after_description": "Periode med inaktivitet før displayet automatisk slukker",
"hardware_turn_off_display_after_title": "Sluk skærmen efter",
"hide": "Skjul",
"ice_gathering_completed": "ICE-indsamlingen er afsluttet",
"info_caps_lock": "Caps Lock",
"info_compose": "Skriv",
"info_hdmi_state": "HDMI-tilstand:",
"info_hidrpc_state": "HidRPC-tilstand:",
"info_kana": "Kana",
"info_keys": "Nøgler:",
"info_last_move": "Sidste træk:",
"info_num_lock": "Num Lock",
"info_paste_enabled": "Aktiveret",
"info_paste_mode": "Indsætningstilstand:",
"info_pointer": "Peger:",
"info_relayed_by_cloudflare": "Videresendt af Cloudflare",
"info_resolution": "Opløsning:",
"info_scroll_lock": "Scroll Lock",
"info_shift": "Flytte",
"info_usb_state": "USB-tilstand:",
"info_video_size": "Videostørrelse:",
"input_disabled": "Input deaktiveret",
"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",
"ipv6_valid_lifetime": "Gyldig livstid",
"jetkvm_description": "JetKVM kombinerer kraftfuld hardware med intuitiv software for at give en problemfri fjernbetjeningsoplevelse.",
"jetkvm_device": "JetKVM-enhed",
"jetkvm_logo": "JetKVM-logo",
"jetkvm_setup": "Opsæt din JetKVM",
"jiggler_cron_schedule_description": "Cron-udtryk til planlægning",
"jiggler_cron_schedule_label": "Cron-skema",
"jiggler_example_business_hours_early": "Åbningstider 8-17",
"jiggler_example_business_hours_late": "Åbningstider 9-17",
"jiggler_examples_label": "Eksempler",
"jiggler_inactivity_limit_description": "Inaktivitetstid før rystelse",
"jiggler_inactivity_limit_label": "Inaktivitetsgrænse i sekunder",
"jiggler_more_examples": "Flere eksempler",
"jiggler_random_delay_description": "For at undgå genkendelige mønstre",
"jiggler_random_delay_label": "Tilfældig forsinkelse",
"jiggler_save_jiggler_config": "Gem Jiggler-konfiguration",
"jiggler_timezone_description": "Tidszone for cron-plan",
"jiggler_timezone_label": "Tidszone",
"keyboard_description": "Konfigurer tastaturindstillinger for din enhed",
"keyboard_layout_description": "Tastaturlayout for måloperativsystemet",
"keyboard_layout_error": "Kunne ikke indstille tastaturlayout: {error}",
"keyboard_layout_long_description": "Det virtuelle tastatur, indsættelse af tekst og tastaturmakroer sender individuelle tastetryk til målenheden. Tastaturlayoutet bestemmer, hvilke tastekoder der sendes. Sørg for, at tastaturlayoutet i JetKVM matcher indstillingerne i operativsystemet.",
"keyboard_layout_success": "Tastaturlayoutet er nu indstillet til {layout}",
"keyboard_layout_title": "Tastaturlayout",
"keyboard_show_pressed_keys_description": "Vis de aktuelt nedtrykkede taster i statuslinjen",
"keyboard_show_pressed_keys_title": "Vis trykkede taster",
"keyboard_title": "Tastatur",
"kvm_terminal": "KVM-terminal",
"last_online": "Sidst online {time}",
"learn_more": "Lær mere",
"load": "Indlæs",
"loading": "Indlæser…",
"local_auth_change_local_device_password_description": "Indtast din nuværende adgangskode og en ny adgangskode for at opdatere din lokale enhedsbeskyttelse.",
"local_auth_change_local_device_password_title": "Skift adgangskode til den lokale enhed",
"local_auth_confirm_new_password_label": "Bekræft ny adgangskode",
"local_auth_create_confirm_password_placeholder": "Indtast din adgangskode igen",
"local_auth_create_description": "Opret en adgangskode for at beskytte din enhed mod uautoriseret lokal adgang.",
"local_auth_create_new_password_label": "Ny adgangskode",
"local_auth_create_new_password_placeholder": "Indtast en stærk adgangskode",
"local_auth_create_not_now_button": "Ikke nu",
"local_auth_create_secure_button": "Sikr enheden",
"local_auth_create_title": "Beskyttelse af lokal enhed",
"local_auth_current_password_label": "Nuværende adgangskode",
"local_auth_disable_local_device_protection_description": "Indtast din nuværende adgangskode for at deaktivere lokal enhedsbeskyttelse.",
"local_auth_disable_local_device_protection_title": "Deaktiver beskyttelse for lokal adgang",
"local_auth_disable_protection_button": "Deaktiver beskyttelse",
"local_auth_enter_current_password_placeholder": "Indtast din nuværende adgangskode",
"local_auth_enter_new_password_placeholder": "Indtast en ny stærk adgangskode",
"local_auth_error_changing_password": "Der opstod en fejl under ændring af adgangskoden",
"local_auth_error_disabling_password": "Der opstod en fejl under deaktivering af adgangskoden",
"local_auth_error_enter_current_password": "Indtast venligst din nuværende adgangskode",
"local_auth_error_enter_new_password": "Indtast venligst en ny adgangskode",
"local_auth_error_enter_old_password": "Indtast venligst din gamle adgangskode",
"local_auth_error_enter_password": "Indtast venligst en adgangskode",
"local_auth_error_passwords_not_match": "Adgangskoderne stemmer ikke overens",
"local_auth_error_setting_password": "Der opstod en fejl under indstilling af adgangskoden",
"local_auth_new_password_label": "Ny adgangskode",
"local_auth_reenter_new_password_placeholder": "Indtast din nye adgangskode igen",
"local_auth_success_password_disabled_description": "Du har deaktiveret adgangskodebeskyttelsen for lokal adgang. Husk, at din enhed nu er mindre sikker.",
"local_auth_success_password_disabled_title": "Adgangskodebeskyttelse deaktiveret",
"local_auth_success_password_set_description": "Du har nu konfigureret lokal enhedsbeskyttelse. Din enhed er nu beskyttet mod uautoriseret lokal adgang.",
"local_auth_success_password_set_title": "Adgangskode indstillet",
"local_auth_success_password_updated_description": "Du har ændret din adgangskode til beskyttelse af din lokale enhed. Husk din nye adgangskode til senere brug.",
"local_auth_success_password_updated_title": "Adgangskode opdateret",
"local_auth_update_password_button": "Opdater adgangskode",
"locale_auto": "Auto",
"locale_change_success": "Sproget er ændret til {locale}",
"locale_da": "Dansk",
"locale_de": "Tysk",
"locale_en": "Engelsk",
"locale_es": "Spansk",
"locale_fr": "Fransk",
"locale_it": "Italiensk",
"locale_nb": "Norsk (bokmål)",
"locale_sv": "Svensk",
"locale_zh": "中文 (简体)",
"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_error": "Der opstod en fejl under login",
"login_forgot_password": "Glemt adgangskode?",
"login_password_label": "Adgangskode",
"login_welcome_back": "Velkommen tilbage til JetKVM",
"macro_add_step": "Tilføj trin {maxed_out}",
"macro_at_least_one_step_keys_or_modifiers": "Mindst ét trin skal have nøgler eller modifikatorer",
"macro_at_least_one_step_required": "Mindst ét trin er påkrævet",
"macro_max_steps_error": "Du kan maksimalt tilføje {max} trin pr. makro.",
"macro_max_steps_reached": "( {max} maks)",
"macro_name_label": "Makronavn",
"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_failed": "Der opstod en fejl under lagring.",
"macro_save_failed_error": "Der opstod en fejl under lagring: {error}.",
"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",
"macro_step_keys_description": "Maksimalt antal {max} nøgler pr. trin.",
"macro_step_keys_label": "Nøgler",
"macro_step_max_keys_reached": "Maksimalt antal nøgler nået",
"macro_step_modifiers_description": "Hvilke modifikatorer (Shift/Ctrl/Alt/Meta) trykkes ned i dette trin?",
"macro_step_modifiers_label": "Modifikatorer",
"macro_step_no_matching_keys_found": "Ingen matchende nøgler fundet",
"macro_step_search_for_key": "Søg efter nøgle…",
"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_aria_add_new": "Tilføj ny makro",
"macros_aria_delete": "Slet makro {name}",
"macros_aria_duplicate": "Dupliker makro {name}",
"macros_aria_edit": "Rediger makro {name}",
"macros_aria_move_down": "Flyt {name} ned",
"macros_aria_move_up": "Flyt {name} op",
"macros_confirm_delete_description": "Er du sikker på, at du vil slette \" {name} \"? Denne handling kan ikke fortrydes.",
"macros_confirm_delete_title": "Slet makro",
"macros_confirm_deleting": "Sletter…",
"macros_create_first_description": "Kombinér tastetryk i én handling",
"macros_create_first_headline": "Opret din første makro",
"macros_created_success": "Makro \" {name} \" blev oprettet",
"macros_delay_only": "Kun forsinkelse",
"macros_delete_confirm": "Er du sikker på, at du vil slette denne makro? Denne handling kan ikke fortrydes.",
"macros_delete_macro": "Slet makro",
"macros_deleted_success": "Makro \" {name} \" slettet",
"macros_deleting": "Sletning",
"macros_duplicated_success": "Makro \" {name} \" duplikeret",
"macros_edit_button": "Rediger",
"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_delete_error": "Kunne ikke slette makroen: {error}",
"macros_failed_duplicate": "Makroen kunne ikke duplikeres",
"macros_failed_duplicate_error": "Kunne ikke duplikere makro: {error}",
"macros_failed_reorder": "Kunne ikke omarrangere makroer",
"macros_failed_reorder_error": "Kunne ikke omarrangere makroer: {error}",
"macros_failed_update": "Makroen kunne ikke opdateres",
"macros_failed_update_error": "Kunne ikke opdatere makroen: {error}",
"macros_invalid_data": "Ugyldige makrodata",
"macros_loading": "Indlæser makroer…",
"macros_max_reached": "Maksimum nået",
"macros_maximum_macros_reached": "Du har nået det maksimale antal {maximum} makroer.",
"macros_no_macros_available": "Ingen makroer tilgængelige",
"macros_order_updated": "Makroordren er blevet opdateret",
"macros_title": "Tastaturmakroer",
"macros_updated_success": "Makro \" {name} \" opdateret",
"metric_not_supported": "Metrik understøttes ikke",
"metric_waiting_for_data": "Venter på data…",
"mount_add_file_to_get_started": "Tilføj en fil for at komme i gang",
"mount_add_new_media": "Tilføj nyt medie",
"mount_available_storage": "Tilgængelig lagerplads",
"mount_button_back_to_overview": "Tilbage til oversigt",
"mount_button_cancel_upload": "Annuller upload",
"mount_button_continue_upload": "Fortsæt upload",
"mount_button_mount_file": "Monter fil",
"mount_button_mount_url": "Monterings-URL",
"mount_button_select": "Vælg",
"mount_button_showing_results": "Viser resultater fra {from} til {to} af resultaterne fra {total}",
"mount_button_upload_new_image": "Upload et nyt billede",
"mount_bytes_free": "{bytesFree} fri",
"mount_bytes_used": "{bytesUsed} brugt",
"mount_calculating": "Beregner…",
"mount_click_to_select_file": "Klik for at vælge en fil",
"mount_click_to_select_incomplete": "Klik for at vælge \" {name} \"",
"mount_confirm_delete": "Er du sikker på, at du vil slette {name} ?",
"mount_continue_uploading_with_name": "Fortsæt med at uploade \" {name} \"",
"mount_error_delete_file": "Fejl ved sletning af fil: {error}",
"mount_error_description": "Der opstod en fejl under forsøget på at montere mediet. Prøv igen.",
"mount_error_get_storage_space": "Fejl ved hentning af lagerplads: {error}",
"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_mode_cdrom": "CD/DVD",
"mount_mode_disk": "Disk",
"mount_mounted_as": "Monteret som",
"mount_mounted_from_storage": "Monteret fra JetKVM-lager",
"mount_no_images_description": "Upload et billede for at begynde at montere et virtuelt medie.",
"mount_no_images_title": "Ingen billeder tilgængelige",
"mount_no_mounted_media": "Ingen monterede medier",
"mount_percentage_used": "{percentageUsed} % brugt",
"mount_please_select_file": "Vælg venligst filen \" {name} \" for at fortsætte uploaden.",
"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_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",
"mount_upload_failed_rtc": "Upload mislykkedes: {error}",
"mount_upload_successful": "Upload fuldfø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_url_description": "Monter filer fra enhver offentlig webadresse",
"mount_url_input_label": "Billed-URL",
"mount_url_mount": "URL-montering",
"mount_view_device_description": "Vælg et billede, der skal monteres, fra JetKVM-lageret",
"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",
"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",
"mouse_hide_cursor_description": "Skjul markøren, når du sender musebevægelser",
"mouse_hide_cursor_title": "Skjul markør",
"mouse_jiggler_config_updated": "Jiggler-konfigurationen er blevet opdateret",
"mouse_jiggler_custom": "Tilpasset",
"mouse_jiggler_description": "Simuler bevægelsen af en computermus",
"mouse_jiggler_disabled": "Deaktiveret",
"mouse_jiggler_error_config": "Der opstod en fejl under indstilling af jiggler-konfigurationen",
"mouse_jiggler_failed_state": "Kunne ikke indstille jiggler-tilstand: {error}",
"mouse_jiggler_frequent": "Hyppig - 30'erne",
"mouse_jiggler_invalid_cron": "Ugyldigt cron-udtryk. Kontroller venligst dit tidsplanformat (f.eks. '0 * * * * *' for hvert minut).",
"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_relative_description": "Mest kompatible",
"mouse_modes_description": "Vælg musens inputtilstand",
"mouse_modes_title": "Tilstande",
"mouse_scroll_high": "Høj",
"mouse_scroll_low": "Lav",
"mouse_scroll_medium": "Medium",
"mouse_scroll_off": "Slukket",
"mouse_scroll_throttling_description": "Reducer hyppigheden af rullehændelser",
"mouse_scroll_throttling_title": "Rulningsbegrænsning",
"mouse_scroll_very_high": "Meget høj",
"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_lease_renew_confirm": "Forny lejekontrakt",
"network_dhcp_lease_renew_confirm_description": "Dette vil anmode om en ny IP-adresse fra din DHCP-server. Din enhed kan midlertidigt miste netværksforbindelsen under denne proces.",
"network_dhcp_lease_renew_confirm_new_a": "Hvis du modtager en ny IP-adresse",
"network_dhcp_lease_renew_confirm_new_b": "du skal muligvis genoprette forbindelsen ved hjælp af den nye adresse",
"network_dhcp_lease_renew_failed": "Kunne ikke forny leasing: {error}",
"network_dhcp_lease_renew_success": "DHCP-lease fornyet",
"network_domain_custom": "Tilpasset",
"network_domain_description": "Netværksdomænesuffiks for enheden",
"network_domain_dhcp_provided": "DHCP leveret",
"network_domain_local": ".lokal",
"network_domain_title": "Domæne",
"network_hostname_description": "Enhedens navn på netværket. Efterlad tomt for standardværdi.",
"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_invalid": "Ugyldig IPv4-adresse",
"network_ipv4_invalid_cidr": "Ugyldig CIDR-notation for IPv4-adresse",
"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_addresses_header": "IPv6-adresser",
"network_ipv6_cidr_suggestion": "Brug venligst CIDR-notation (f.eks. 2001:db8::1/64)",
"network_ipv6_dns": "IPv6 DNS",
"network_ipv6_flag_dad_failed": "DAD mislykkedes",
"network_ipv6_flag_deprecated": "Udfaset",
"network_ipv6_gateway": "IPv6-gateway",
"network_ipv6_information": "IPv6-oplysninger",
"network_ipv6_invalid": "Ugyldig IPv6-adresse",
"network_ipv6_mode_description": "Konfigurer IPv6-tilstanden",
"network_ipv6_mode_dhcpv6": "DHCPv6",
"network_ipv6_mode_disabled": "Deaktiveret",
"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_prefix": "IP-præfiks",
"network_ipv6_prefix_invalid": "Præfikset skal være mellem 0 og 128",
"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": "Deaktiveret",
"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": "Auto",
"network_mdns_description": "Styr mDNS (multicast DNS) driftstilstand",
"network_mdns_disabled": "Deaktiveret",
"network_mdns_ipv4_only": "Kun IPv4",
"network_mdns_ipv6_only": "Kun IPv6",
"network_mdns_title": "mDNS",
"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": "Ændringer i konfigurationen",
"network_save_settings_failed": "Kunne ikke gemme netværksindstillinger: {error}",
"network_save_settings_success": "Netværksindstillinger gemt",
"network_settings_add_dns": "Tilføj DNS-server",
"network_settings_load_error": "Kunne ikke indlæse netværksindstillinger: {error}",
"network_static_ipv4_header": "Statisk IPv4-konfiguration",
"network_static_ipv6_header": "Statisk IPv6-konfiguration",
"network_time_sync_description": "Konfigurer indstillinger for tidssynkronisering",
"network_time_sync_http_only": "Kun HTTP",
"network_time_sync_ntp_and_http": "NTP og HTTP",
"network_time_sync_ntp_only": "Kun NTP",
"network_time_sync_title": "Tidssynkronisering",
"network_title": "Netværk",
"never_seen_online": "Aldrig set online",
"next": "Næste",
"no_results_found": "Ingen resultater fundet",
"not_applicable": "Ikke tilgængelig",
"not_available": "Ikke tilgængelig",
"not_found": "Ikke fundet",
"ntp_servers": "NTP-servere",
"oh_no": "Åh nej!",
"online": "Online",
"other_session_detected": "Endnu en aktiv session registreret",
"other_session_take_over": " Kun én aktiv session understøttes ad gangen. Vil du overtage denne session?",
"other_session_use_here_button": "Brug her",
"page_not_found_description": "Den side, du ledte efter, findes ikke.",
"paste_modal_confirm_paste": "Bekræft indsætning",
"paste_modal_delay_between_keys": "Forsinkelse mellem taster",
"paste_modal_delay_out_of_range": "Forsinkelsen skal være mellem {min} og {max}",
"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_sending_using_layout": "Sender tekst ved hjælp af tastaturlayout: {iso} - {name}",
"paste_text": "Indsæt tekst",
"paste_text_description": "Indsæt tekst fra din klient til den eksterne vært",
"peer_connection_closed": "Lukket",
"peer_connection_closing": "Lukker",
"peer_connection_connected": "Forbundet",
"peer_connection_connecting": "Forbinder",
"peer_connection_disconnected": "Afbrudt",
"peer_connection_error": "Forbindelsesfejl",
"peer_connection_failed": "Forbindelsen mislykkedes",
"peer_connection_new": "Forbinder",
"previous": "Tidligere",
"register_device_error": "Der opstod en fejl {error} under registrering af din enhed.",
"register_device_finish_button": "Afslut opsætning",
"register_device_name_description": "Navngiv din enhed, så du nemt kan identificere den senere. Du kan til enhver tid ændre dette navn.",
"register_device_name_label": "Enhedsnavn",
"register_device_name_placeholder": "Plex-medieserver",
"register_device_no_name": "Angiv venligst et navn",
"rename_device": "Omdøb enhed",
"rename_device_description": "Navngiv din enhed korrekt, så den nemt kan identificeres.",
"rename_device_error": "Der opstod en fejl {error} under omdøbning af din enhed.",
"rename_device_headline": "Omdøb {name}",
"rename_device_new_name_label": "Nyt enhedsnavn",
"rename_device_new_name_placeholder": "Plex-medieserver",
"rename_device_no_name": "Angiv venligst et navn",
"retry": "Prøv igen",
"saving": "Gemmer…",
"search_placeholder": "Søg…",
"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_set_settings_error": "Kunne ikke indstille seriel konsolindstillinger til {settings} : {error}",
"serial_console_stop_bits": "Stopbits",
"setting_remote_description": "Indstilling af fjernbetjeningsbeskrivelse",
"setting_remote_session_description": "Indstilling af beskrivelse af fjernsession...",
"setting_up_connection_to_device": "Opretter forbindelse til enhed...",
"settings_access": "Adgang",
"settings_advanced": "Avanceret",
"settings_appearance": "Udseende",
"settings_back_to_kvm": "Tilbage til KVM",
"settings_general": "Generel",
"settings_hardware": "Hardware",
"settings_keyboard": "Tastatur",
"settings_keyboard_macros": "Tastaturmakroer",
"settings_mouse": "Mus",
"settings_network": "Netværk",
"settings_video": "Video",
"something_went_wrong": "Noget gik galt. Prøv igen senere, eller kontakt support.",
"step_counter_step": "Trin {step}",
"subnet_mask": "Undernetmaske",
"time_division_days": "dage",
"time_division_hours": "timer",
"time_division_minutes": "minutter",
"time_division_months": "måneder",
"time_division_seconds": "sekunder",
"time_division_weeks": "uger",
"time_division_years": "år",
"troubleshoot_connection": "Fejlfinding af forbindelse",
"unknown_error": "Ukendt fejl",
"update_in_progress": "Opdatering i gang",
"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": "Tilpasset",
"usb_config_default": "JetKVM-standard",
"usb_config_dell": "Dell Multimedia Pro-tastatur",
"usb_config_failed_load": "Kunne ikke indlæse USB-konfiguration: {error}",
"usb_config_failed_set": "Kunne ikke indstille USB-konfiguration: {error}",
"usb_config_identifiers_description": "USB-enhedsidentifikatorer eksponeret for målcomputeren",
"usb_config_identifiers_title": "Identifikatorer",
"usb_config_logitech": "Logitech universaladapter",
"usb_config_manufacturer_label": "Fabrikant",
"usb_config_manufacturer_placeholder": "Indtast producent",
"usb_config_microsoft": "Microsoft trådløst multimedietastatur",
"usb_config_product_id_label": "Produkt-ID",
"usb_config_product_id_placeholder": "Indtast produkt-ID",
"usb_config_product_name_label": "Produktnavn",
"usb_config_product_name_placeholder": "Indtast produktnavn",
"usb_config_restore_default": "Gendan til standard",
"usb_config_serial_number_label": "Serienummer",
"usb_config_serial_number_placeholder": "Indtast serienummer",
"usb_config_set_success": "USB-konfiguration indstillet til {manufacturer} {product}",
"usb_config_update_identifiers": "Opdater USB-identifikatorer",
"usb_config_vendor_id_label": "Leverandør-ID",
"usb_config_vendor_id_placeholder": "Indtast leverandør-ID",
"usb_device_classes_description": "USB-enhedsklasser i den sammensatte enhed",
"usb_device_classes_title": "Klasser",
"usb_device_custom": "Tilpasset",
"usb_device_description": "USB-enheder, der skal emuleres på målcomputeren",
"usb_device_enable_absolute_mouse_description": "Aktivér absolut mus (markør)",
"usb_device_enable_absolute_mouse_title": "Aktivér absolut mus (markør)",
"usb_device_enable_keyboard_description": "Aktivér tastatur",
"usb_device_enable_keyboard_title": "Aktivér tastatur",
"usb_device_enable_mass_storage_description": "Nogle gange skal det muligvis deaktiveres for at forhindre problemer med bestemte enheder.",
"usb_device_enable_mass_storage_title": "Aktivér USB-masselagring",
"usb_device_enable_relative_mouse_description": "Aktivér relativ mus",
"usb_device_enable_relative_mouse_title": "Aktivér relativ mus",
"usb_device_failed_load": "Kunne ikke indlæse USB-enheder: {error}",
"usb_device_failed_set": "Kunne ikke indstille USB-enheder: {error}",
"usb_device_keyboard_mouse_and_mass_storage": "Tastatur, mus og masselagring",
"usb_device_keyboard_only": "Kun tastatur",
"usb_device_restore_default": "Gendan til standard",
"usb_device_title": "USB-enhed",
"usb_device_update_classes": "Opdater USB-klasser",
"usb_device_updated": "USB-enheder opdateret",
"usb_state_connected": "Forbundet",
"usb_state_connecting": "Forbinder",
"usb_state_disconnected": "Afbrudt",
"usb_state_low_power_mode": "Lavstrømstilstand",
"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)",
"video_brightness_title": "Lysstyrke",
"video_contrast_description": "Kontrastniveau ( {value} x)",
"video_contrast_title": "Kontrast",
"video_custom_edid_description": "EDID beskriver kompatibilitet med videotilstande. Standardindstillingerne fungerer i de fleste tilfælde, men unikke UEFI/BIOS-indstillinger skal muligvis justeres.",
"video_custom_edid_title": "Brugerdefineret EDID",
"video_debugging_info_description": "Fejlfindingsoplysninger for video",
"video_debugging_info_title": "Fejlfindingsoplysninger",
"video_description": "Konfigurer video- og skærmindstillinger for optimal kompatibilitet",
"video_edid_acer_b246wl": "Acer B246WL, 1920x1200",
"video_edid_asus_pa248qv": "ASUS PA248QV, 1920x1200",
"video_edid_custom": "Tilpasset",
"video_edid_dell_d2721h": "DELL D2721H, 1920x1080",
"video_edid_dell_idrac": "DELL IDRAC EDID, 1280x1024",
"video_edid_description": "Juster EDID-indstillingerne for skærmen",
"video_edid_file_label": "EDID-fil",
"video_edid_jetkvm_default": "JetKVM-standard",
"video_edid_set_success": "EDID er korrekt indstillet til {edid}",
"video_edid_title": "EDID",
"video_enhancement_description": "Juster farveindstillingerne for at gøre videooutputtet mere levende og farverigt",
"video_enhancement_title": "Videoforbedring",
"video_failed_get_debug_info": "Kunne ikke hente fejlfindingsoplysninger: {error}",
"video_failed_get_edid": "Kunne ikke hente EDID: {error}",
"video_failed_set_edid": "Kunne ikke indstille EDID: {error}",
"video_failed_set_stream_quality": "Kunne ikke indstille streamkvalitet: {error}",
"video_get_debugging_info": "Få fejlfindingsoplysninger",
"video_overlay_autoplay_permissions_required": "Tilladelser til automatisk afspilning kræves",
"video_overlay_conn_check_cables": "Kontroller alle kabelforbindelser for løse eller beskadigede ledninger",
"video_overlay_conn_ensure_network": "Sørg for, at din netværksforbindelse er stabil og aktiv",
"video_overlay_conn_restart": "Prøv at genstarte både enheden og computeren",
"video_overlay_conn_verify_power": "Sørg for, at enheden er tændt og korrekt tilsluttet",
"video_overlay_connection_issue_title": "Forbindelsesproblem registreret",
"video_overlay_enable_autoplay_settings": "Juster venligst browserindstillingerne for at aktivere automatisk afspilning",
"video_overlay_hdmi_error_title": "HDMI-signalfejl registreret.",
"video_overlay_hdmi_incompatible_resolution": "Inkompatible indstillinger for opløsning eller opdateringshastighed",
"video_overlay_hdmi_loose_faulty": "En løs eller defekt HDMI-forbindelse",
"video_overlay_hdmi_source_issue": "Problemer med kildeenhedens HDMI-udgang",
"video_overlay_learn_more": "Lær mere",
"video_overlay_loading_stream": "Indlæser videostream…",
"video_overlay_manually_start_stream": "Start stream manuelt",
"video_overlay_no_hdmi_adapter_compat": "Hvis du bruger en adapter, skal du sørge for, at den er kompatibel og fungerer korrekt.",
"video_overlay_no_hdmi_ensure_cable": "Sørg for, at HDMI-kablet er korrekt tilsluttet i begge ender",
"video_overlay_no_hdmi_ensure_power": "Sørg for, at kildeenheden er tændt og sender et signal",
"video_overlay_no_hdmi_signal": "Intet HDMI-signal registreret.",
"video_overlay_pointerlock_click_to_enable": "Klik på videoen for at aktivere musestyring",
"video_overlay_reboot_device_is_rebooting": "Enheden genstarter",
"video_overlay_reboot_different_ip_message": "Enheden er muligvis genstartet med en anden IP-adresse. Tjek JetKVM'ens fysiske display for at finde den aktuelle IP-adresse, og genopret forbindelsen.",
"video_overlay_reboot_please_wait_message": "Vent venligst, mens enheden genstarter. Dette tager normalt 20-30 sekunder.",
"video_overlay_reboot_timeout_message": "Timeout for automatisk genoprettelse",
"video_overlay_reboot_unable_to_reconnect": "Kan ikke genoprette forbindelsen",
"video_overlay_reboot_waiting_for_restart": "Venter på, at enheden genstarter…",
"video_overlay_retrying_connection": "Prøver at oprette forbindelse igen…",
"video_overlay_troubleshooting_guide": "Fejlfindingsvejledning",
"video_overlay_try_again": "Prøv igen",
"video_pointer_lock_disabled": "Markørlås deaktiveret",
"video_pointer_lock_enabled": "Markørlås aktiveret — tryk på Escape for at låse op",
"video_quality_high": "Høj",
"video_quality_low": "Lav",
"video_quality_medium": "Medium",
"video_reset_to_default": "Nulstil til standard",
"video_restore_to_default": "Gendan til standard",
"video_saturation_description": "Farvemætning ( {value} x)",
"video_saturation_title": "Mætning",
"video_set_custom_edid": "Indstil brugerdefineret EDID",
"video_stream_quality_description": "Juster kvaliteten af videostreamen",
"video_stream_quality_set": "Streamkvalitet indstillet til {quality}",
"video_stream_quality_title": "Streamkvalitet",
"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",
"wake_on_lan_add_device_save_device": "Gem enhed",
"wake_on_lan_description": "Send en magisk pakke for at vække en fjern enhed.",
"wake_on_lan_device_list_add_new_device": "Tilføj ny enhed",
"wake_on_lan_device_list_delete_device": "Slet enhed",
"wake_on_lan_device_list_wake": "Vågne",
"wake_on_lan_empty_add_device_to_start": "Tilføj en enhed for at begynde at bruge Wake-on-LAN",
"wake_on_lan_empty_add_new_device": "Tilføj ny enhed",
"wake_on_lan_empty_no_devices_added": "Ingen enheder tilføjet",
"wake_on_lan_failed_add_device": "Kunne ikke tilføje enhed",
"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"
}

View File

@ -0,0 +1,901 @@
{
"$schema": "https://inlang.com/schema/inlang-message-format",
"access_adopt_kvm": "KVM in die Cloud integrieren",
"access_adopted_message": "Ihr Gerät wurde in die Cloud übernommen",
"access_auth_mode_no_password": "Aktueller Modus: Kein Passwort",
"access_auth_mode_password": "Aktueller Modus: Passwortgeschützt",
"access_authentication_mode_title": "Authentifizierungsmodus",
"access_certificate_label": "Zertifikat",
"access_change_password_button": "Kennwort ändern",
"access_change_password_description": "Aktualisieren Sie Ihr Gerätezugriffskennwort",
"access_change_password_title": "Kennwort ändern",
"access_cloud_api_url_label": "Cloud-API-URL",
"access_cloud_app_url_label": "Cloud-Anwendungs-URL",
"access_cloud_provider_description": "Wählen Sie den Cloud-Anbieter für Ihr Gerät",
"access_cloud_provider_title": "Cloud-Anbieter",
"access_cloud_security_title": "Cloud-Sicherheit",
"access_confirm_deregister": "Möchten Sie dieses Gerät wirklich abmelden?",
"access_deregister": "Abmelden von der Cloud",
"access_description": "Verwalten Sie die Zugriffssteuerung Ihres Geräts",
"access_disable_protection": "Schutz deaktivieren",
"access_enable_password": "Kennwort aktivieren",
"access_failed_deregister": "Abmeldung des Geräts fehlgeschlagen: {error}",
"access_failed_update_cloud_url": "Fehler beim Aktualisieren der Cloud-URL: {error}",
"access_failed_update_tls": "TLS-Einstellungen konnten nicht aktualisiert werden: {error}",
"access_github_link": "GitHub",
"access_https_description": "Konfigurieren Sie den sicheren HTTPS-Zugriff auf Ihr Gerät",
"access_https_mode_title": "HTTPS-Modus",
"access_learn_security": "Erfahren Sie mehr über unsere Cloud-Sicherheit",
"access_local_description": "Verwalten Sie den Modus des lokalen Zugriffs auf das Gerät",
"access_local_title": "Lokal",
"access_no_device_id": "Keine Geräte-ID verfügbar",
"access_private_key_description": "Aus Sicherheitsgründen wird es nach dem Speichern nicht angezeigt.",
"access_private_key_label": "Privater Schlüssel",
"access_provider_custom": "Benutzerdefiniert",
"access_provider_jetkvm": "JetKVM Cloud",
"access_remote_description": "Verwalten Sie die Einstellungen für den Fernzugriff",
"access_security_encryption": "Ende-zu-Ende-Verschlüsselung mit WebRTC (DTLS und SRTP)",
"access_security_oidc": "OIDC (OpenID Connect)-Authentifizierung",
"access_security_open_source": "Alle Cloud-Komponenten sind Open Source und auf GitHub verfügbar.",
"access_security_streams": "Alle Streams werden während der Übertragung verschlüsselt",
"access_security_zero_trust": "Zero Trust-Sicherheitsmodell",
"access_title": "Zugriff",
"access_tls_certificate_description": "Fügen Sie unten Ihr TLS-Zertifikat ein. Geben Sie bei Zertifikatsketten die gesamte Kette an (Blatt-, Zwischen- und Stammzertifikate).",
"access_tls_certificate_title": "TLS-Zertifikat",
"access_tls_custom": "Benutzerdefiniert",
"access_tls_disabled": "Deaktiviert",
"access_tls_self_signed": "Selbstsigniert",
"access_tls_updated": "TLS-Einstellungen erfolgreich aktualisiert",
"access_update_tls_settings": "TLS-Einstellungen aktualisieren",
"action_bar_connection_stats": "Verbindungsstatistiken",
"action_bar_extension": "Erweiterung",
"action_bar_fullscreen": "Vollbild",
"action_bar_settings": "Einstellungen",
"action_bar_virtual_keyboard": "Virtuelle Tastatur",
"action_bar_virtual_media": "Virtuelle Medien",
"action_bar_wake_on_lan": "Wake-on-LAN",
"action_bar_web_terminal": "Web-Terminal",
"advanced_description": "Greifen Sie auf zusätzliche Einstellungen zur Fehlerbehebung und Anpassung zu",
"advanced_dev_channel_description": "Erhalten Sie frühzeitig Updates vom Entwicklungskanal",
"advanced_dev_channel_title": "Dev Channel-Updates",
"advanced_developer_mode_description": "Erweiterte Funktionen für Entwickler aktivieren",
"advanced_developer_mode_enabled_title": "Entwicklermodus aktiviert",
"advanced_developer_mode_title": "Entwicklermodus",
"advanced_developer_mode_warning_advanced": "Nur für fortgeschrittene Benutzer. Nicht für den Produktionseinsatz.",
"advanced_developer_mode_warning_risks": "Verwenden Sie es nur, wenn Sie die Risiken verstehen",
"advanced_developer_mode_warning_security": "Die Sicherheit wird im aktiven Zustand geschwächt",
"advanced_disable_usb_emulation": "USB-Emulation deaktivieren",
"advanced_enable_usb_emulation": "USB-Emulation aktivieren",
"advanced_error_loopback_disable": "Der Nur-Loopback-Modus konnte nicht deaktiviert werden: {error}",
"advanced_error_loopback_enable": "Der Nur-Loopback-Modus konnte nicht aktiviert werden: {error}",
"advanced_error_reset_config": "Konfiguration konnte nicht zurückgesetzt werden: {error}",
"advanced_error_set_dev_channel": "Der Dev-Kanalstatus konnte nicht festgelegt werden: {error}",
"advanced_error_set_dev_mode": "Fehler beim Festlegen des Entwicklungsmodus: {error}",
"advanced_error_update_ssh_key": "SSH-Schlüssel konnte nicht aktualisiert werden: {error}",
"advanced_error_usb_emulation_disable": "USB-Emulation konnte nicht deaktiviert werden: {error}",
"advanced_error_usb_emulation_enable": "USB-Emulation konnte nicht aktiviert werden: {error}",
"advanced_loopback_only_description": "Beschränken Sie den Zugriff auf die Weboberfläche nur auf den lokalen Host (127.0.0.1).",
"advanced_loopback_only_title": "Nur-Loopback-Modus",
"advanced_loopback_warning_before": "Bevor Sie diese Funktion aktivieren, stellen Sie sicher, dass Sie über Folgendes verfügen:",
"advanced_loopback_warning_cloud": "Cloud-Zugriff aktiviert und funktioniert",
"advanced_loopback_warning_confirm": "Ich verstehe, trotzdem aktivieren",
"advanced_loopback_warning_description": "WARNUNG: Dadurch wird der Zugriff auf die Weboberfläche ausschließlich auf den lokalen Host (127.0.0.1) beschränkt.",
"advanced_loopback_warning_ssh": "SSH-Zugriff konfiguriert und getestet",
"advanced_loopback_warning_title": "Nur-Loopback-Modus aktivieren?",
"advanced_reset_config_button": "Konfiguration zurücksetzen",
"advanced_reset_config_description": "Konfiguration auf Standard zurücksetzen. Dadurch werden Sie abgemeldet.",
"advanced_reset_config_title": "Konfiguration zurücksetzen",
"advanced_ssh_access_description": "Fügen Sie Ihren öffentlichen SSH-Schlüssel hinzu, um einen sicheren Fernzugriff auf das Gerät zu ermöglichen",
"advanced_ssh_access_title": "SSH-Zugriff",
"advanced_ssh_default_user": "Der Standard-SSH-Benutzer ist",
"advanced_ssh_public_key_label": "Öffentlicher SSH-Schlüssel",
"advanced_ssh_public_key_placeholder": "Geben Sie Ihren öffentlichen SSH-Schlüssel ein",
"advanced_success_loopback_disabled": "Nur-Loopback-Modus deaktiviert. Starten Sie Ihr Gerät neu, um die Funktion anzuwenden.",
"advanced_success_loopback_enabled": "Nur-Loopback-Modus aktiviert. Starten Sie Ihr Gerät neu, um die Funktion anzuwenden.",
"advanced_success_reset_config": "Konfiguration erfolgreich auf Standard zurückgesetzt",
"advanced_success_update_ssh_key": "SSH-Schlüssel erfolgreich aktualisiert",
"advanced_title": "Erweitert",
"advanced_troubleshooting_mode_description": "Diagnosetools und zusätzliche Steuerelemente für Fehlerbehebungs- und Entwicklungszwecke",
"advanced_troubleshooting_mode_title": "Fehlerbehebungsmodus",
"advanced_update_ssh_key_button": "SSH-Schlüssel aktualisieren",
"advanced_usb_emulation_description": "Steuern des USB-Emulationsstatus",
"advanced_usb_emulation_title": "USB-Emulation",
"already_adopted_new_owner": "Wenn Sie der neue Besitzer sind, bitten Sie den Vorbesitzer, das Gerät im Cloud-Dashboard von seinem Konto abzumelden. Wenn Sie glauben, dass dies ein Fehler ist, wenden Sie sich an unser Support-Team.",
"already_adopted_other_user": "Dieses Gerät ist derzeit in unserem Cloud-Dashboard auf einen anderen Benutzer registriert.",
"already_adopted_return_to_dashboard": "Zurück zum Dashboard",
"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": "Hell",
"appearance_theme_system": "System",
"appearance_title": "Darstellung",
"attach": "Anhängen",
"atx_power_control_get_state_error": "ATX-Stromversorgungsstatus konnte nicht abgerufen werden: {error}",
"atx_power_control_hdd_led": "Festplatten-LED",
"atx_power_control_long_power_button": "Langes Drücken",
"atx_power_control_power_button": "Power-Taste",
"atx_power_control_power_led": "Betriebs-LED",
"atx_power_control_reset_button": "Reset-Taste",
"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": "JetKVM mit der Cloud verbinden",
"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_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": "Anmelden",
"auth_login_description": "Melden Sie sich an, um sicher auf Ihre Geräte zuzugreifen und sie zu verwalten",
"auth_mode_local": "Lokale Authentifizierungsmethode",
"auth_mode_local_change_later": "Sie können Ihre Authentifizierungsmethode später jederzeit in den Einstellungen ändern.",
"auth_mode_local_description": "Wählen Sie aus, wie Sie Ihr JetKVM-Gerät lokal sichern möchten.",
"auth_mode_local_no_password": "Kein Passwort",
"auth_mode_local_no_password_description": "Schneller Zugriff ohne Passwortauthentifizierung.",
"auth_mode_local_password": "Passwort",
"auth_mode_local_password_confirm_description": "Bestätigen Sie Ihr Passwort",
"auth_mode_local_password_confirm_label": "Passwort bestätigen",
"auth_mode_local_password_description": "Sichern Sie Ihr Gerät für zusätzlichen Schutz mit einem Passwort.",
"auth_mode_local_password_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_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_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",
"back_to_devices": "Zurück zu den Geräten",
"cancel": "Abbrechen",
"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.",
"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_connection_description": "Die Verbindung zwischen dem Client und dem JetKVM.",
"connection_stats_frames_per_second": "Bilder pro Sekunde",
"connection_stats_frames_per_second_description": "Anzahl der pro Sekunde angezeigten eingehenden Videobilder.",
"connection_stats_network_stability": "Netzwerkstabilität",
"connection_stats_network_stability_description": "Wie gleichmäßig der Fluss eingehender Videopakete im Netzwerk ist.",
"connection_stats_packets_lost": "Verlorene Pakete",
"connection_stats_packets_lost_description": "Anzahl der verlorenen eingehenden Video-RTP-Pakete.",
"connection_stats_playback_delay": "Wiedergabeverzögerung",
"connection_stats_playback_delay_description": "Durch den Jitter-Puffer hinzugefügte Verzögerung, um die Wiedergabe zu glätten, wenn die Frames ungleichmäßig ankommen.",
"connection_stats_round_trip_time": "Round-Trip-Zeit",
"connection_stats_round_trip_time_description": "Roundtrip-Zeit für das aktive ICE-Kandidatenpaar zwischen Peers.",
"connection_stats_sidebar": "Verbindungsstatistiken",
"connection_stats_unit_frames_per_second": " fps",
"connection_stats_unit_milliseconds": " ms",
"connection_stats_unit_packets": " Pakete",
"connection_stats_video": "Video",
"connection_stats_video_description": "Der Videostream vom JetKVM zum Client.",
"continue": "Weiter",
"creating_peer_connection": "Peer-Verbindung wird hergestellt …",
"dc_power_control_current": "Aktuell",
"dc_power_control_current_unit": "A",
"dc_power_control_get_state_error": "Der Gleichstromstatus konnte nicht abgerufen werden: {error}",
"dc_power_control_power": "Stromversorgung",
"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_restore_last_state": "Letzter Zustand",
"dc_power_control_restore_power_state": "Nach Stromausfall wiederherstellen",
"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",
"delete": "Löschen",
"deregister_cloud_devices": "Cloud-Geräte",
"deregister_description": "Dadurch wird das Gerät aus Ihrem Cloud-Konto entfernt und der Fernzugriff darauf widerrufen. Bitte beachten Sie, dass der lokale Zugriff weiterhin möglich ist.",
"deregister_error": "Beim Abmelden Ihres Geräts ist ein Fehler aufgetreten {status} . Bitte versuchen Sie es erneut.",
"deregister_from_cloud": "Abmelden von der Cloud",
"deregister_headline": "Melden Sie {device}",
"detach": "Abtrennen",
"dhcp_empty_lease_description": "Wir haben noch keine DHCP-Lease-Informationen vom Gerät erhalten.",
"dhcp_empty_lease_headline": "Keine DHCP-Lease-Informationen",
"dhcp_lease_boot_file": "Boot-Datei",
"dhcp_lease_boot_next_server": "Nächsten Server starten",
"dhcp_lease_boot_server_name": "Name des Boot-Servers",
"dhcp_lease_broadcast": "Übertragen",
"dhcp_lease_domain": "Domain",
"dhcp_lease_gateway": "Tor",
"dhcp_lease_header": "DHCP-Lease-Informationen",
"dhcp_lease_hostname": "Hostname",
"dhcp_lease_lease_expires": "Mietvertrag läuft ab",
"dhcp_lease_maximum_transfer_unit": "MTU",
"dhcp_lease_renew": "DHCP-Lease erneuern",
"dhcp_lease_time_to_live": "TTL",
"dhcp_server": "DHCP-Server",
"dns_servers": "DNS-Server",
"establishing_secure_connection": "Sichere Verbindung wird hergestellt …",
"experimental": "Experimental",
"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",
"extensions_atx_power_control_description": "Steuern Sie den Energiezustand Ihrer Maschine über die ATX-Energiesteuerung.",
"extensions_dc_power_control": "Gleichstromsteuerung",
"extensions_dc_power_control_description": "Steuern Sie Ihre DC-Stromerweiterung",
"extensions_popover_extensions": "Erweiterungen",
"gathering_ice_candidates": "ICE-Kandidaten zusammenbringen …",
"general_app_version": "App: {version}",
"general_auto_update_description": "Aktualisieren Sie das Gerät automatisch auf die neueste Version",
"general_auto_update_error": "Automatische Aktualisierung konnte nicht eingestellt werden: {error}",
"general_auto_update_title": "Automatische Aktualisierung",
"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_no_button": "NEIN",
"general_reboot_title": "Starten Sie JetKVM neu",
"general_reboot_yes_button": "Ja",
"general_system_version": "System: {version}",
"general_title": "Allgemein",
"general_update_app_update_title": "App-Update",
"general_update_application_type": "App",
"general_update_available_description": "Ein neues Update zur Verbesserung der Systemleistung und Kompatibilität ist verfügbar. Wir empfehlen die Aktualisierung, um einen reibungslosen Betrieb zu gewährleisten.",
"general_update_available_title": "Update verfügbar",
"general_update_background_button": "Aktualisierung im Hintergrund",
"general_update_check_again_button": "Erneut prüfen",
"general_update_checking_description": "Wir stellen sicher, dass Ihr Gerät über die neuesten Funktionen und Verbesserungen verfügt.",
"general_update_checking_title": "Suche nach Updates…",
"general_update_completed_description": "Ihr Gerät wurde erfolgreich auf die neueste Version aktualisiert. Viel Spaß mit den neuen Funktionen und Verbesserungen!",
"general_update_completed_title": "Update erfolgreich abgeschlossen",
"general_update_error_description": "Beim Aktualisieren Ihres Geräts ist ein Fehler aufgetreten. Bitte versuchen Sie es später noch einmal.",
"general_update_error_details": "Fehlerdetails: {errorMessage}",
"general_update_error_title": "Aktualisierungsfehler",
"general_update_later_button": "Später",
"general_update_now_button": "Jetzt aktualisieren",
"general_update_rebooting": "Neustart zum Abschließen des Updates …",
"general_update_status_awaiting_reboot": "Warte auf Neustart",
"general_update_status_downloading": "Das Update {update_type} wird heruntergeladen…",
"general_update_status_fetching": "Update-Informationen werden abgerufen …",
"general_update_status_installing": "Das Update {update_type} wird installiert…",
"general_update_status_progress": "{part} Fortschritt",
"general_update_status_verifying": "Überprüfung des Updates {update_type} …",
"general_update_system_type": "System",
"general_update_system_update_title": "Linux-Systemupdate",
"general_update_up_to_date_description": "Auf Ihrem System läuft die neueste Version. Derzeit sind keine Updates verfügbar.",
"general_update_up_to_date_title": "Das System ist auf dem neuesten Stand",
"general_update_updating_description": "Bitte schalten Sie Ihr Gerät nicht aus. Dieser Vorgang kann einige Minuten dauern.",
"general_update_updating_title": "Aktualisieren Ihres Geräts",
"getting_remote_session_description": "Versuch, eine Beschreibung der Remote-Sitzung abzurufen {attempt}",
"hardware_backlight_settings_error": "Fehler beim Festlegen der Hintergrundbeleuchtungseinstellungen: {error}",
"hardware_backlight_settings_get_error": "Die Einstellungen für die Hintergrundbeleuchtung konnten nicht abgerufen werden: {error}",
"hardware_backlight_settings_success": "Hintergrundbeleuchtungseinstellungen erfolgreich aktualisiert",
"hardware_dim_display_after_description": "Legen Sie fest, wie lange gewartet werden soll, bevor das Display gedimmt wird",
"hardware_dim_display_after_title": "Anzeige dimmen nach",
"hardware_display_brightness_description": "Stellen Sie die Helligkeit des Displays ein",
"hardware_display_brightness_high": "Hoch",
"hardware_display_brightness_low": "Niedrig",
"hardware_display_brightness_medium": "Medium",
"hardware_display_brightness_off": "Aus",
"hardware_display_brightness_title": "Displayhelligkeit",
"hardware_display_orientation_description": "Stellen Sie die Ausrichtung der Anzeige ein",
"hardware_display_orientation_error": "Fehler beim Festlegen der Anzeigeausrichtung: {error}",
"hardware_display_orientation_inverted": "Invertiert",
"hardware_display_orientation_normal": "Normal",
"hardware_display_orientation_success": "Displayausrichtung erfolgreich aktualisiert",
"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_power_saving_description": "Reduzieren Sie den Stromverbrauch bei Nichtgebrauch",
"hardware_power_saving_disabled": "Energiesparmodus deaktiviert",
"hardware_power_saving_enabled": "Energiesparmodus aktiviert",
"hardware_power_saving_failed_error": "Fehler beim Einstellen des Energiesparmodus: {error}",
"hardware_power_saving_hdmi_sleep_description": "Schalten Sie die Aufnahme nach 90 Sekunden Inaktivität aus",
"hardware_power_saving_hdmi_sleep_title": "HDMI-Ruhemodus",
"hardware_power_saving_title": "Energiesparen",
"hardware_time_10_minutes": "10 Minuten",
"hardware_time_1_hour": "1 Stunde",
"hardware_time_1_minute": "1 Minute",
"hardware_time_30_minutes": "30 Minuten",
"hardware_time_5_minutes": "5 Minuten",
"hardware_time_never": "Niemals",
"hardware_title": "Hardware",
"hardware_turn_off_display_after_description": "Zeitraum der Inaktivität, bevor sich das Display automatisch ausschaltet",
"hardware_turn_off_display_after_title": "Display ausschalten nach",
"hide": "Ausblenden",
"ice_gathering_completed": "ICE-Treffen abgeschlossen",
"info_caps_lock": "Feststelltaste",
"info_compose": "Komponieren",
"info_hdmi_state": "HDMI-Status:",
"info_hidrpc_state": "HidRPC-Status:",
"info_kana": "Kana",
"info_keys": "Schlüssel:",
"info_last_move": "Letzter Zug:",
"info_num_lock": "Num Lock",
"info_paste_enabled": "Ermöglicht",
"info_paste_mode": "Einfügemodus:",
"info_pointer": "Zeiger:",
"info_relayed_by_cloudflare": "Weitergeleitet von Cloudflare",
"info_resolution": "Auflösung:",
"info_scroll_lock": "Rollen-Taste",
"info_shift": "Schicht",
"info_usb_state": "USB-Status:",
"info_video_size": "Videogröße:",
"input_disabled": "Eingabe deaktiviert",
"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",
"ipv6_valid_lifetime": "Gültig für die gesamte Lebensdauer",
"jetkvm_description": "JetKVM kombiniert leistungsstarke Hardware mit intuitiver Software, um ein nahtloses Fernsteuerungserlebnis zu bieten.",
"jetkvm_device": "JetKVM-Gerät",
"jetkvm_logo": "JetKVM Logo",
"jetkvm_setup": "Richten Sie Ihr JetKVM ein",
"jiggler_cron_schedule_description": "Cron-Ausdruck für die Planung",
"jiggler_cron_schedule_label": "Cron-Zeitplan",
"jiggler_example_business_hours_early": "Öffnungszeiten 8-17",
"jiggler_example_business_hours_late": "Öffnungszeiten 9-17",
"jiggler_examples_label": "Beispiele",
"jiggler_inactivity_limit_description": "Inaktivitätszeit vor dem Wackeln",
"jiggler_inactivity_limit_label": "Inaktivitätslimit in Sekunden",
"jiggler_more_examples": "Weitere Beispiele",
"jiggler_random_delay_description": "Um erkennbare Muster zu vermeiden",
"jiggler_random_delay_label": "Zufällige Verzögerung",
"jiggler_save_jiggler_config": "Jiggler-Konfiguration speichern",
"jiggler_timezone_description": "Zeitzone für Cron-Zeitplan",
"jiggler_timezone_label": "Zeitzone",
"keyboard_description": "Konfigurieren Sie die Tastatureinstellungen für Ihr Gerät",
"keyboard_layout_description": "Tastaturlayout des Zielbetriebssystems",
"keyboard_layout_error": "Tastaturlayout konnte nicht festgelegt werden: {error}",
"keyboard_layout_long_description": "Die virtuelle Tastatur, das Einfügen von Text und Tastaturmakros senden einzelne Tastenanschläge an das Zielgerät. Das Tastaturlayout bestimmt, welche Tastencodes gesendet werden. Stellen Sie sicher, dass das Tastaturlayout in JetKVM mit den Einstellungen im Betriebssystem übereinstimmt.",
"keyboard_layout_success": "Tastaturlayout erfolgreich auf {layout} eingestellt",
"keyboard_layout_title": "Tastaturlayout",
"keyboard_show_pressed_keys_description": "Anzeige der aktuell gedrückten Tasten in der Statusleiste",
"keyboard_show_pressed_keys_title": "Gedrückte Tasten anzeigen",
"keyboard_title": "Tastatur",
"kvm_terminal": "KVM-Terminal",
"last_online": "Zuletzt online {time}",
"learn_more": "Mehr erfahren",
"load": "Laden",
"loading": "Laden…",
"local_auth_change_local_device_password_description": "Geben Sie Ihr aktuelles Passwort und ein neues Passwort ein, um den Schutz Ihres lokalen Geräts zu aktualisieren.",
"local_auth_change_local_device_password_title": "Ändern des lokalen Gerätekennworts",
"local_auth_confirm_new_password_label": "Neues Passwort bestätigen",
"local_auth_create_confirm_password_placeholder": "Geben Sie Ihr Passwort erneut ein",
"local_auth_create_description": "Erstellen Sie ein Passwort, um Ihr Gerät vor unbefugtem lokalem Zugriff zu schützen.",
"local_auth_create_new_password_label": "Neues Passwort",
"local_auth_create_new_password_placeholder": "Geben Sie ein sicheres Passwort ein",
"local_auth_create_not_now_button": "Nicht jetzt",
"local_auth_create_secure_button": "Sicheres Gerät",
"local_auth_create_title": "Lokaler Geräteschutz",
"local_auth_current_password_label": "Aktuelles Passwort",
"local_auth_disable_local_device_protection_description": "Geben Sie Ihr aktuelles Passwort ein, um den lokalen Geräteschutz zu deaktivieren.",
"local_auth_disable_local_device_protection_title": "Lokalen Geräteschutz deaktivieren",
"local_auth_disable_protection_button": "Schutz deaktivieren",
"local_auth_enter_current_password_placeholder": "Geben Sie Ihr aktuelles Passwort ein",
"local_auth_enter_new_password_placeholder": "Geben Sie ein neues sicheres Passwort ein",
"local_auth_error_changing_password": "Beim Ändern des Passworts ist ein Fehler aufgetreten",
"local_auth_error_disabling_password": "Beim Deaktivieren des Passworts ist ein Fehler aufgetreten",
"local_auth_error_enter_current_password": "Bitte geben Sie Ihr aktuelles Passwort ein",
"local_auth_error_enter_new_password": "Bitte geben Sie ein neues Passwort ein",
"local_auth_error_enter_old_password": "Bitte geben Sie Ihr altes Passwort ein",
"local_auth_error_enter_password": "Bitte geben Sie ein Passwort ein",
"local_auth_error_passwords_not_match": "Passwörter stimmen nicht überein",
"local_auth_error_setting_password": "Beim Festlegen des Passworts ist ein Fehler aufgetreten",
"local_auth_new_password_label": "Neues Passwort",
"local_auth_reenter_new_password_placeholder": "Geben Sie Ihr neues Passwort erneut ein",
"local_auth_success_password_disabled_description": "Sie haben den Passwortschutz für den lokalen Zugriff erfolgreich deaktiviert. Bedenken Sie, dass Ihr Gerät nun weniger sicher ist.",
"local_auth_success_password_disabled_title": "Kennwortschutz deaktiviert",
"local_auth_success_password_set_description": "Sie haben den lokalen Geräteschutz erfolgreich eingerichtet. Ihr Gerät ist nun vor unbefugtem lokalen Zugriff geschützt.",
"local_auth_success_password_set_title": "Passwort erfolgreich festgelegt",
"local_auth_success_password_updated_description": "Sie haben Ihr lokales Geräteschutzkennwort erfolgreich geändert. Merken Sie sich das neue Kennwort für zukünftige Zugriffe.",
"local_auth_success_password_updated_title": "Passwort erfolgreich aktualisiert",
"local_auth_update_password_button": "Kennwort aktualisieren",
"locale_auto": "Auto",
"locale_change_success": "Die Sprache wurde erfolgreich in {locale} geändert.",
"locale_da": "Dänisch",
"locale_de": "Deutsch",
"locale_en": "Englisch",
"locale_es": "Spanisch",
"locale_fr": "Französisch",
"locale_it": "Italienisch",
"locale_nb": "Norwegisch (bokmål)",
"locale_sv": "Schwedisch",
"locale_zh": "中文 (简体)",
"log_in": "Einloggen",
"log_out": "Abmelden",
"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_error": "Beim Anmelden ist ein Fehler aufgetreten",
"login_forgot_password": "Passwort vergessen?",
"login_password_label": "Passwort",
"login_welcome_back": "Willkommen zurück bei JetKVM",
"macro_add_step": "Schritt hinzufügen {maxed_out}",
"macro_at_least_one_step_keys_or_modifiers": "Mindestens ein Schritt muss Schlüssel oder Modifikatoren haben",
"macro_at_least_one_step_required": "Mindestens ein Schritt ist erforderlich",
"macro_max_steps_error": "Sie können pro Makro maximal {max} Schritte hinzufügen.",
"macro_max_steps_reached": "( {max} max)",
"macro_name_label": "Makroname",
"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_failed": "Beim Speichern ist ein Fehler aufgetreten.",
"macro_save_failed_error": "Beim Speichern ist ein Fehler aufgetreten: {error}.",
"macro_step_count": "{steps} / {max} Schritte",
"macro_step_duration_description": "Wartezeit vor der Ausführung des nächsten Schritts.",
"macro_step_duration_label": "Schrittdauer",
"macro_step_keys_description": "Maximale Anzahl {max} Schlüsseln pro Schritt.",
"macro_step_keys_label": "Schlüssel",
"macro_step_max_keys_reached": "Maximale Anzahl an Schlüsseln erreicht",
"macro_step_modifiers_description": "Welche Modifikatoren (Umschalt/Strg/Alt/Meta) werden während dieses Schritts gedrückt.",
"macro_step_modifiers_label": "Modifikatoren",
"macro_step_no_matching_keys_found": "Keine passenden Schlüssel gefunden",
"macro_step_search_for_key": "Suche nach Schlüssel…",
"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",
"macros_add_new_macro": "Neues Makro hinzufügen",
"macros_aria_add_new": "Neues Makro hinzufügen",
"macros_aria_delete": "Makro löschen {name}",
"macros_aria_duplicate": "Doppeltes Makro {name}",
"macros_aria_edit": "Makro bearbeiten {name}",
"macros_aria_move_down": "Verschiebe {name} unten",
"macros_aria_move_up": "Verschiebe {name} nach oben",
"macros_confirm_delete_description": "Möchten Sie „ {name} „ wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden.",
"macros_confirm_delete_title": "Makro löschen",
"macros_confirm_deleting": "Löschen…",
"macros_create_first_description": "Kombinieren Sie Tastenanschläge zu einer einzigen Aktion",
"macros_create_first_headline": "Erstellen Sie Ihr erstes Makro",
"macros_created_success": "Makro \" {name} \" erfolgreich erstellt",
"macros_delay_only": "Nur Verzögerung",
"macros_delete_confirm": "Möchten Sie dieses Makro wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden.",
"macros_delete_macro": "Makro löschen",
"macros_deleted_success": "Makro \" {name} \" erfolgreich gelöscht",
"macros_deleting": "Löschen",
"macros_duplicated_success": "Makro \" {name} \" erfolgreich dupliziert",
"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_delete_error": "Makro konnte nicht gelöscht werden: {error}",
"macros_failed_duplicate": "Makro konnte nicht dupliziert werden",
"macros_failed_duplicate_error": "Makro konnte nicht dupliziert werden: {error}",
"macros_failed_reorder": "Makros konnten nicht neu angeordnet werden",
"macros_failed_reorder_error": "Fehler beim Neuordnen der Makros: {error}",
"macros_failed_update": "Makro konnte nicht aktualisiert werden",
"macros_failed_update_error": "Makro konnte nicht aktualisiert werden: {error}",
"macros_invalid_data": "Ungültige Makrodaten",
"macros_loading": "Makros werden geladen …",
"macros_max_reached": "Max. erreicht",
"macros_maximum_macros_reached": "Sie haben die maximal zulässige Anzahl von {maximum} Makros erreicht.",
"macros_no_macros_available": "Keine Makros verfügbar",
"macros_order_updated": "Makroreihenfolge erfolgreich aktualisiert",
"macros_title": "Tastaturmakros",
"macros_updated_success": "Makro \" {name} \" erfolgreich aktualisiert",
"metric_not_supported": "Metrik wird nicht unterstützt",
"metric_waiting_for_data": "Warten auf Daten…",
"mount_add_file_to_get_started": "Fügen Sie eine Datei hinzu, um zu beginnen",
"mount_add_new_media": "Neue Medien hinzufügen",
"mount_available_storage": "Verfügbarer Speicher",
"mount_button_back_to_overview": "Zurück zur Übersicht",
"mount_button_cancel_upload": "Hochladen abbrechen",
"mount_button_continue_upload": "Weiter hochladen",
"mount_button_mount_file": "Datei einbinden",
"mount_button_mount_url": "Mount-URL",
"mount_button_select": "Wählen",
"mount_button_showing_results": "Anzeige von {from} bis {to} von {total} Ergebnissen",
"mount_button_upload_new_image": "Laden Sie ein neues Bild hoch",
"mount_bytes_free": "{bytesFree} frei",
"mount_bytes_used": "{bytesUsed} verwendet",
"mount_calculating": "Berechnung…",
"mount_click_to_select_file": "Klicken Sie, um eine Datei auszuwählen",
"mount_click_to_select_incomplete": "Klicken Sie, um \" {name} \" auszuwählen.",
"mount_confirm_delete": "Möchten Sie {name} wirklich löschen?",
"mount_continue_uploading_with_name": "Weiter hochladen \" {name} \"",
"mount_error_delete_file": "Fehler beim Löschen der Datei: {error}",
"mount_error_description": "Beim Einbinden des Mediums ist ein Fehler aufgetreten. Bitte versuchen Sie es erneut.",
"mount_error_get_storage_space": "Fehler beim Abrufen des Speicherplatzes: {error}",
"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_mode_cdrom": "CD/DVD",
"mount_mode_disk": "Scheibe",
"mount_mounted_as": "Montiert als",
"mount_mounted_from_storage": "Vom JetKVM-Speicher gemountet",
"mount_no_images_description": "Laden Sie ein Image hoch, um mit der Bereitstellung virtueller Medien zu beginnen.",
"mount_no_images_title": "Keine Bilder verfügbar",
"mount_no_mounted_media": "Keine gemounteten Medien",
"mount_percentage_used": "{percentageUsed} % verwendet",
"mount_please_select_file": "Bitte wählen Sie die Datei \" {name} \" aus, um den Upload fortzusetzen.",
"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_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",
"mount_upload_failed_rtc": "Upload fehlgeschlagen: {error}",
"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_url_description": "Mounten Sie Dateien von jeder öffentlichen Webadresse",
"mount_url_input_label": "Bild-URL",
"mount_url_mount": "URL-Mount",
"mount_view_device_description": "Wählen Sie ein Image zum Mounten aus dem JetKVM-Speicher aus",
"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",
"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",
"mouse_hide_cursor_description": "Den Cursor beim Senden von Mausbewegungen ausblenden",
"mouse_hide_cursor_title": "Cursor ausblenden",
"mouse_jiggler_config_updated": "Jiggler-Konfiguration erfolgreich aktualisiert",
"mouse_jiggler_custom": "Benutzerdefiniert",
"mouse_jiggler_description": "Simulieren Sie die Bewegung einer Computermaus",
"mouse_jiggler_disabled": "Deaktiviert",
"mouse_jiggler_error_config": "Beim Festlegen der Jiggler-Konfiguration ist ein Fehler aufgetreten",
"mouse_jiggler_failed_state": "Jiggler-Status konnte nicht festgelegt werden: {error}",
"mouse_jiggler_frequent": "Häufig alle 30 Sek.",
"mouse_jiggler_invalid_cron": "Ungültiger Cron-Ausdruck. Bitte überprüfen Sie Ihr Zeitplanformat (z. B. „0 * * * * *“ für jede Minute).",
"mouse_jiggler_light": "Leicht 5 Min",
"mouse_jiggler_standard": "Standard 1 Min",
"mouse_jiggler_title": "Jiggler",
"mouse_mode_absolute": "Absolute",
"mouse_mode_absolute_description": "Am bequemsten",
"mouse_mode_relative": "Relativ",
"mouse_mode_relative_description": "Am kompatibelsten",
"mouse_modes_description": "Wählen Sie den Mauseingabemodus",
"mouse_modes_title": "Modi",
"mouse_scroll_high": "Hoch",
"mouse_scroll_low": "Niedrig",
"mouse_scroll_medium": "Medium",
"mouse_scroll_off": "Aus",
"mouse_scroll_throttling_description": "Reduzieren Sie die Häufigkeit von Scroll-Ereignissen",
"mouse_scroll_throttling_title": "Scroll-Drosselung",
"mouse_scroll_very_high": "Sehr hoch",
"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_lease_renew_confirm": "Mietvertrag verlängern",
"network_dhcp_lease_renew_confirm_description": "Dadurch wird eine neue IP-Adresse von Ihrem DHCP-Server angefordert. Während dieses Vorgangs kann die Netzwerkverbindung Ihres Geräts vorübergehend unterbrochen werden.",
"network_dhcp_lease_renew_confirm_new_a": "Wenn Sie eine neue IP-Adresse erhalten",
"network_dhcp_lease_renew_confirm_new_b": "Möglicherweise müssen Sie die Verbindung mit der neuen Adresse erneut herstellen",
"network_dhcp_lease_renew_failed": "Leasing konnte nicht erneuert werden: {error}",
"network_dhcp_lease_renew_success": "DHCP-Lease erneuert",
"network_domain_custom": "Benutzerdefiniert",
"network_domain_description": "Netzwerkdomänensuffix für das Gerät",
"network_domain_dhcp_provided": "DHCP bereitgestellt",
"network_domain_local": ".lokal",
"network_domain_title": "Domain",
"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_invalid": "Ungültige IPv4-Adresse",
"network_ipv4_invalid_cidr": "Ungültige CIDR-Notation für IPv4-Adresse",
"network_ipv4_mode_description": "IPv4-Modus konfigurieren",
"network_ipv4_mode_dhcp": "DHCP",
"network_ipv4_mode_static": "Statisch",
"network_ipv4_mode_title": "IPv4-Modus",
"network_ipv4_netmask": "IPv4-Netzmaske",
"network_ipv6_addresses_header": "IPv6-Adressen",
"network_ipv6_cidr_suggestion": "Bitte verwenden Sie die CIDR-Notation (z. B. 2001:db8::1/64).",
"network_ipv6_dns": "IPv6 DNS",
"network_ipv6_flag_dad_failed": "DAD ist fehlgeschlagen",
"network_ipv6_flag_deprecated": "Veraltet",
"network_ipv6_gateway": "IPv6-Gateway",
"network_ipv6_information": "IPv6-Informationen",
"network_ipv6_invalid": "Ungültige IPv6-Adresse",
"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_prefix": "IP-Präfix",
"network_ipv6_prefix_invalid": "Das Präfix muss zwischen 0 und 128 liegen",
"network_ll_dp_all": "Alle",
"network_ll_dp_basic": "Standard",
"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",
"network_mdns_description": "mDNS-Betriebsmodus steuern (Multicast-DNS)",
"network_mdns_disabled": "Deaktiviert",
"network_mdns_ipv4_only": "Nur IPv4",
"network_mdns_ipv6_only": "Nur IPv6",
"network_mdns_title": "mDNS",
"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 anwenden",
"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 erfolgreich gespeichert",
"network_settings_add_dns": "DNS-Server hinzufügen",
"network_settings_load_error": "Netzwerkeinstellungen konnten nicht geladen werden: {error}",
"network_static_ipv4_header": "Statische IPv4-Konfiguration",
"network_static_ipv6_header": "Statische IPv6-Konfiguration",
"network_time_sync_description": "Konfigurieren der Zeitsynchronisierungseinstellungen",
"network_time_sync_http_only": "Nur HTTP",
"network_time_sync_ntp_and_http": "NTP und HTTP",
"network_time_sync_ntp_only": "Nur NTP",
"network_time_sync_title": "Zeitsynchronisation",
"network_title": "Netzwerk",
"never_seen_online": "Noch nie online gesehen",
"next": "Nächste",
"no_results_found": "Keine Ergebnisse gefunden",
"not_applicable": "N / A",
"not_available": "N / A",
"not_found": "Nicht gefunden",
"ntp_servers": "NTP-Server",
"oh_no": "Oh nein!",
"online": "Online",
"other_session_detected": "Eine weitere aktive Sitzung erkannt",
"other_session_take_over": " Es wird jeweils nur eine aktive Sitzung unterstützt. Möchten Sie diese Sitzung übernehmen?",
"other_session_use_here_button": "Hier verwenden",
"page_not_found_description": "Die von Ihnen gesuchte Seite existiert nicht.",
"paste_modal_confirm_paste": "Einfügen bestätigen",
"paste_modal_delay_between_keys": "Verzögerung zwischen den Tasten",
"paste_modal_delay_out_of_range": "Die Verzögerung muss zwischen {min} und {max}",
"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_sending_using_layout": "Senden von Text mithilfe des Tastaturlayouts: {iso} - {name}",
"paste_text": "Text einfügen",
"paste_text_description": "Fügen Sie Text von Ihrem Client in den Remote-Host ein",
"peer_connection_closed": "Geschlossen",
"peer_connection_closing": "Schließen",
"peer_connection_connected": "Verbunden",
"peer_connection_connecting": "Verbinden",
"peer_connection_disconnected": "Getrennt",
"peer_connection_error": "Verbindungsfehler",
"peer_connection_failed": "Verbindung fehlgeschlagen",
"peer_connection_new": "Verbinden",
"previous": "Vorherige",
"register_device_error": "Beim Registrieren Ihres Geräts ist ein Fehler {error} aufgetreten.",
"register_device_finish_button": "Einrichtung abschließen",
"register_device_name_description": "Geben Sie Ihrem Gerät einen Namen, damit Sie es später leicht identifizieren können. Sie können diesen Namen jederzeit ändern.",
"register_device_name_label": "Gerätename",
"register_device_name_placeholder": "Plex Media Server",
"register_device_no_name": "Bitte geben Sie einen Namen an",
"rename_device": "Gerät umbenennen",
"rename_device_description": "Geben Sie Ihrem Gerät einen passenden Namen, damit es leicht identifiziert werden kann.",
"rename_device_error": "Beim Umbenennen Ihres Geräts ist ein Fehler {error} aufgetreten.",
"rename_device_headline": "Umbenennen {name}",
"rename_device_new_name_label": "Neuer Gerätename",
"rename_device_new_name_placeholder": "Plex Media Server",
"rename_device_no_name": "Bitte geben Sie einen Namen an",
"retry": "Erneut versuchen",
"saving": "Wird gespeichert…",
"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_set_settings_error": "Die Einstellungen der seriellen Konsole konnten nicht auf {settings} festgelegt werden: {error}",
"serial_console_stop_bits": "Stoppbits",
"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 …",
"settings_access": "Zugriff",
"settings_advanced": "Erweitert",
"settings_appearance": "Darstellung",
"settings_back_to_kvm": "Zurück zu KVM",
"settings_general": "Allgemein",
"settings_hardware": "Hardware",
"settings_keyboard": "Tastatur",
"settings_keyboard_macros": "Tastaturmakros",
"settings_mouse": "Maus",
"settings_network": "Netzwerk",
"settings_video": "Video",
"something_went_wrong": "Ein Fehler ist aufgetreten. Bitte versuchen Sie es später noch einmal oder wenden Sie sich an den Support.",
"step_counter_step": "Schritt {step}",
"subnet_mask": "Subnetzmaske",
"time_division_days": "Tage",
"time_division_hours": "Std.",
"time_division_minutes": "Minuten",
"time_division_months": "Monate",
"time_division_seconds": "Sekunden",
"time_division_weeks": "Wochen",
"time_division_years": "Jahre",
"troubleshoot_connection": "Verbindungsprobleme beheben",
"unknown_error": "Unbekannter Fehler",
"update_in_progress": "Update wird durchgeführt",
"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": "Benutzerdefiniert",
"usb_config_default": "JetKVM-Standard",
"usb_config_dell": "Dell Multimedia Pro-Tastatur",
"usb_config_failed_load": "USB-Konfiguration konnte nicht geladen werden: {error}",
"usb_config_failed_set": "USB-Konfiguration konnte nicht festgelegt werden: {error}",
"usb_config_identifiers_description": "Dem Zielcomputer zugänglich gemachte USB-Gerätekennungen",
"usb_config_identifiers_title": "Kennungen",
"usb_config_logitech": "Logitech Universaladapter",
"usb_config_manufacturer_label": "Hersteller",
"usb_config_manufacturer_placeholder": "Hersteller eingeben",
"usb_config_microsoft": "Microsoft Wireless MultiMedia Keyboard",
"usb_config_product_id_label": "Produkt-ID",
"usb_config_product_id_placeholder": "Produkt-ID eingeben",
"usb_config_product_name_label": "Produktname",
"usb_config_product_name_placeholder": "Produktnamen eingeben",
"usb_config_restore_default": "Auf Standard zurücksetzen",
"usb_config_serial_number_label": "Seriennummer",
"usb_config_serial_number_placeholder": "Seriennummer eingeben",
"usb_config_set_success": "USB-Konfiguration eingestellt auf {manufacturer} {product}",
"usb_config_update_identifiers": "USB-Kennungen aktualisieren",
"usb_config_vendor_id_label": "Lieferanten-ID",
"usb_config_vendor_id_placeholder": "Geben Sie die Lieferanten-ID ein",
"usb_device_classes_description": "USB-Geräteklassen im Verbundgerät",
"usb_device_classes_title": "Klassen",
"usb_device_custom": "Benutzerdefiniert",
"usb_device_description": "USB-Geräte zum Emulieren auf dem Zielcomputer",
"usb_device_enable_absolute_mouse_description": "Absolute Maus (Zeiger) aktivieren",
"usb_device_enable_absolute_mouse_title": "Absolute Maus (Zeiger) aktivieren",
"usb_device_enable_keyboard_description": "Tastatur aktivieren",
"usb_device_enable_keyboard_title": "Tastatur aktivieren",
"usb_device_enable_mass_storage_description": "Manchmal muss es möglicherweise deaktiviert werden, um Probleme mit bestimmten Geräten zu vermeiden",
"usb_device_enable_mass_storage_title": "USB-Massenspeicher aktivieren",
"usb_device_enable_relative_mouse_description": "Relative Maus aktivieren",
"usb_device_enable_relative_mouse_title": "Relative Maus aktivieren",
"usb_device_failed_load": "USB-Geräte konnten nicht geladen werden: {error}",
"usb_device_failed_set": "Fehler beim Festlegen der USB-Geräte: {error}",
"usb_device_keyboard_mouse_and_mass_storage": "Tastatur, Maus und Massenspeicher",
"usb_device_keyboard_only": "Nur Tastatur",
"usb_device_restore_default": "Auf Standard zurücksetzen",
"usb_device_title": "USB-Gerät",
"usb_device_update_classes": "USB-Klassen aktualisieren",
"usb_device_updated": "USB-Geräte aktualisiert",
"usb_state_connected": "Verbunden",
"usb_state_connecting": "Verbinden",
"usb_state_disconnected": "Getrennt",
"usb_state_low_power_mode": "Energiesparmodus",
"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)",
"video_brightness_title": "Helligkeit",
"video_contrast_description": "Kontraststufe ( {value} x)",
"video_contrast_title": "Kontrast",
"video_custom_edid_description": "EDID gibt die Kompatibilität des Videomodus an. Die Standardeinstellungen funktionieren in den meisten Fällen, aber individuelle UEFI/BIOS-Einstellungen müssen möglicherweise angepasst werden.",
"video_custom_edid_title": "Eigene EDID",
"video_debugging_info_description": "Debugging-Informationen für Videos",
"video_debugging_info_title": "Debugging-Informationen",
"video_description": "Konfigurieren Sie Anzeigeeinstellungen und EDID für optimale Kompatibilität",
"video_edid_acer_b246wl": "Acer B246WL, 1920x1200",
"video_edid_asus_pa248qv": "ASUS PA248QV, 1920x1200",
"video_edid_custom": "Benutzerdefiniert",
"video_edid_dell_d2721h": "DELL D2721H, 1920 x 1080",
"video_edid_dell_idrac": "DELL IDRAC EDID, 1280x1024",
"video_edid_description": "Passen Sie die EDID-Einstellungen für das Display an",
"video_edid_file_label": "EDID-Datei",
"video_edid_jetkvm_default": "JetKVM-Standard",
"video_edid_set_success": "EDID erfolgreich auf {edid} gesetzt",
"video_edid_title": "EDID",
"video_enhancement_description": "Passen Sie die Farbeinstellungen an, um die Videoausgabe lebendiger und farbenfroher zu gestalten",
"video_enhancement_title": "Videoverbesserung",
"video_failed_get_debug_info": "Debug-Informationen konnten nicht abgerufen werden: {error}",
"video_failed_get_edid": "EDID konnte nicht abgerufen werden: {error}",
"video_failed_set_edid": "EDID konnte nicht festgelegt werden: {error}",
"video_failed_set_stream_quality": "Fehler beim Festlegen der Streamqualität: {error}",
"video_get_debugging_info": "Debugging-Informationen abrufen",
"video_overlay_autoplay_permissions_required": "Autoplay-Berechtigungen erforderlich",
"video_overlay_conn_check_cables": "Überprüfen Sie alle Kabelverbindungen auf lockere oder beschädigte Kabel",
"video_overlay_conn_ensure_network": "Stellen Sie sicher, dass Ihre Netzwerkverbindung stabil und aktiv ist",
"video_overlay_conn_restart": "Versuchen Sie, sowohl das Gerät als auch Ihren Computer neu zu starten",
"video_overlay_conn_verify_power": "Stellen Sie sicher, dass das Gerät eingeschaltet und richtig angeschlossen ist",
"video_overlay_connection_issue_title": "Verbindungsproblem erkannt",
"video_overlay_enable_autoplay_settings": "Bitte passen Sie die Browsereinstellungen an, um die automatische Wiedergabe zu aktivieren",
"video_overlay_hdmi_error_title": "HDMI-Signalfehler erkannt.",
"video_overlay_hdmi_incompatible_resolution": "Inkompatible Auflösungs- oder Bildwiederholfrequenzeinstellungen",
"video_overlay_hdmi_loose_faulty": "Lose oder fehlerhafte HDMI-Verbindung",
"video_overlay_hdmi_source_issue": "Probleme mit dem HDMI-Ausgang des Quellgeräts",
"video_overlay_learn_more": "Mehr erfahren",
"video_overlay_loading_stream": "Videostream wird geladen …",
"video_overlay_manually_start_stream": "Stream manuell starten",
"video_overlay_no_hdmi_adapter_compat": "Wenn Sie einen Adapter verwenden, stellen Sie sicher, dass dieser kompatibel ist und ordnungsgemäß funktioniert",
"video_overlay_no_hdmi_ensure_cable": "Stellen Sie sicher, dass das HDMI-Kabel an beiden Enden fest angeschlossen ist",
"video_overlay_no_hdmi_ensure_power": "Stellen Sie sicher, dass das Quellgerät eingeschaltet ist und ein Signal ausgibt",
"video_overlay_no_hdmi_signal": "Kein HDMI-Signal erkannt.",
"video_overlay_pointerlock_click_to_enable": "Klicken Sie auf das Video, um die Maussteuerung zu aktivieren",
"video_overlay_reboot_device_is_rebooting": "Das Gerät wird neu gestartet",
"video_overlay_reboot_different_ip_message": "Das Gerät wurde möglicherweise mit einer anderen IP-Adresse neu gestartet. Überprüfen Sie die physische Anzeige des JetKVM, um die aktuelle IP-Adresse zu ermitteln und die Verbindung wiederherzustellen.",
"video_overlay_reboot_please_wait_message": "Bitte warten Sie, während das Gerät neu gestartet wird. Dies dauert normalerweise 2030 Sekunden.",
"video_overlay_reboot_timeout_message": "Zeitüberschreitung bei der automatischen Wiederverbindung",
"video_overlay_reboot_unable_to_reconnect": "Verbindung konnte nicht wiederhergestellt werden",
"video_overlay_reboot_waiting_for_restart": "Warten auf den Neustart des Geräts …",
"video_overlay_retrying_connection": "Verbindung wird erneut versucht …",
"video_overlay_troubleshooting_guide": "Handbuch zur Fehlerbehebung",
"video_overlay_try_again": "Versuchen Sie es erneut",
"video_pointer_lock_disabled": "Zeigersperre deaktiviert",
"video_pointer_lock_enabled": "Zeigersperre aktiviert drücken Sie Escape, um die Sperre aufzuheben",
"video_quality_high": "Hoch",
"video_quality_low": "Niedrig",
"video_quality_medium": "Medium",
"video_reset_to_default": "Auf Werkseinstellungen zurücksetzen",
"video_restore_to_default": "Auf Standard zurücksetzen",
"video_saturation_description": "Farbsättigung ( {value} x)",
"video_saturation_title": "Sättigung",
"video_set_custom_edid": "Benutzerdefinierte EDID festlegen",
"video_stream_quality_description": "Passen Sie die Qualität des Videostreams an",
"video_stream_quality_set": "Streamqualität eingestellt auf {quality}",
"video_stream_quality_title": "Stream-Qualität",
"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",
"wake_on_lan_add_device_save_device": "Gerät speichern",
"wake_on_lan_description": "Senden Sie ein Magic Packet, um ein Remote-Gerät zu wecken.",
"wake_on_lan_device_list_add_new_device": "Neues Gerät hinzufügen",
"wake_on_lan_device_list_delete_device": "Gerät löschen",
"wake_on_lan_device_list_wake": "Aufwachen",
"wake_on_lan_empty_add_device_to_start": "Fügen Sie ein Gerät hinzu, um Wake-on-LAN zu verwenden",
"wake_on_lan_empty_add_new_device": "Neues Gerät hinzufügen",
"wake_on_lan_empty_no_devices_added": "Keine Geräte hinzugefügt",
"wake_on_lan_failed_add_device": "Gerät konnte nicht hinzugefügt werden",
"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"
}

View File

@ -0,0 +1,901 @@
{
"$schema": "https://inlang.com/schema/inlang-message-format",
"access_adopt_kvm": "Adopt KVM to Cloud",
"access_adopted_message": "Your device is adopted to the Cloud",
"access_auth_mode_no_password": "Current mode: No password",
"access_auth_mode_password": "Current mode: Password protected",
"access_authentication_mode_title": "Authentication Mode",
"access_certificate_label": "Certificate",
"access_change_password_button": "Change Password",
"access_change_password_description": "Update your device access password",
"access_change_password_title": "Change Password",
"access_cloud_api_url_label": "Cloud API URL",
"access_cloud_app_url_label": "Cloud Application URL",
"access_cloud_provider_description": "Select the cloud provider for your device",
"access_cloud_provider_title": "Cloud Provider",
"access_cloud_security_title": "Cloud Security",
"access_confirm_deregister": "Are you sure you want to de-register this device?",
"access_deregister": "De-register from Cloud",
"access_description": "Manage the Access Control of the device",
"access_disable_protection": "Disable Protection",
"access_enable_password": "Enable Password",
"access_failed_deregister": "Failed to de-register device: {error}",
"access_failed_update_cloud_url": "Failed to update cloud URL: {error}",
"access_failed_update_tls": "Failed to update TLS settings: {error}",
"access_github_link": "GitHub",
"access_https_description": "Configure secure HTTPS access to your device",
"access_https_mode_title": "HTTPS Mode",
"access_learn_security": "Learn about our cloud security",
"access_local_description": "Manage the mode of local access to the device",
"access_local_title": "Local",
"access_no_device_id": "No device ID available",
"access_private_key_description": "For security reasons, it will not be displayed after saving.",
"access_private_key_label": "Private Key",
"access_provider_custom": "Custom",
"access_provider_jetkvm": "JetKVM Cloud",
"access_remote_description": "Manage the mode of Remote access to the device",
"access_security_encryption": "End-to-end encryption using WebRTC (DTLS and SRTP)",
"access_security_oidc": "OIDC (OpenID Connect) authentication",
"access_security_open_source": "All cloud components are open-source and available on GitHub.",
"access_security_streams": "All streams encrypted in transit",
"access_security_zero_trust": "Zero Trust security model",
"access_title": "Access",
"access_tls_certificate_description": "Paste your TLS certificate below. For certificate chains, include the entire chain (leaf, intermediate, and root certificates).",
"access_tls_certificate_title": "TLS Certificate",
"access_tls_custom": "Custom",
"access_tls_disabled": "Disabled",
"access_tls_self_signed": "Self-signed",
"access_tls_updated": "TLS settings updated successfully",
"access_update_tls_settings": "Update TLS Settings",
"action_bar_connection_stats": "Connection Stats",
"action_bar_extension": "Extension",
"action_bar_fullscreen": "Fullscreen",
"action_bar_settings": "Settings",
"action_bar_virtual_keyboard": "Virtual Keyboard",
"action_bar_virtual_media": "Virtual Media",
"action_bar_wake_on_lan": "Wake on LAN",
"action_bar_web_terminal": "Web Terminal",
"advanced_description": "Access additional settings for troubleshooting and customization",
"advanced_dev_channel_description": "Receive early updates from the development channel",
"advanced_dev_channel_title": "Dev Channel Updates",
"advanced_developer_mode_description": "Enable advanced features for developers",
"advanced_developer_mode_enabled_title": "Developer Mode Enabled",
"advanced_developer_mode_title": "Developer Mode",
"advanced_developer_mode_warning_advanced": "For advanced users only. Not for production use.",
"advanced_developer_mode_warning_risks": "Only use if you understand the risks",
"advanced_developer_mode_warning_security": "Security is weakened while active",
"advanced_disable_usb_emulation": "Disable USB Emulation",
"advanced_enable_usb_emulation": "Enable USB Emulation",
"advanced_error_loopback_disable": "Failed to disable loopback-only mode: {error}",
"advanced_error_loopback_enable": "Failed to enable loopback-only mode: {error}",
"advanced_error_reset_config": "Failed to reset configuration: {error}",
"advanced_error_set_dev_channel": "Failed to set dev channel state: {error}",
"advanced_error_set_dev_mode": "Failed to set dev mode: {error}",
"advanced_error_update_ssh_key": "Failed to update SSH key: {error}",
"advanced_error_usb_emulation_disable": "Failed to disable USB emulation: {error}",
"advanced_error_usb_emulation_enable": "Failed to enable USB emulation: {error}",
"advanced_loopback_only_description": "Restrict web interface access to localhost only (127.0.0.1)",
"advanced_loopback_only_title": "Loopback-Only Mode",
"advanced_loopback_warning_before": "Before enabling this feature, make sure you have either:",
"advanced_loopback_warning_cloud": "Cloud access enabled and working",
"advanced_loopback_warning_confirm": "I Understand, Enable Anyway",
"advanced_loopback_warning_description": "WARNING: This will restrict web interface access to localhost (127.0.0.1) only.",
"advanced_loopback_warning_ssh": "SSH access configured and tested",
"advanced_loopback_warning_title": "Enable Loopback-Only Mode?",
"advanced_reset_config_button": "Reset Config",
"advanced_reset_config_description": "Reset configuration to default. This will log you out.",
"advanced_reset_config_title": "Reset Configuration",
"advanced_ssh_access_description": "Add your SSH public key to enable secure remote access to the device",
"advanced_ssh_access_title": "SSH Access",
"advanced_ssh_default_user": "The default SSH user is",
"advanced_ssh_public_key_label": "SSH Public Key",
"advanced_ssh_public_key_placeholder": "Enter your SSH public key",
"advanced_success_loopback_disabled": "Loopback-only mode disabled. Restart your device to apply.",
"advanced_success_loopback_enabled": "Loopback-only mode enabled. Restart your device to apply.",
"advanced_success_reset_config": "Configuration reset to default successfully",
"advanced_success_update_ssh_key": "SSH key updated successfully",
"advanced_title": "Advanced",
"advanced_troubleshooting_mode_description": "Diagnostic tools and additional controls for troubleshooting and development purposes",
"advanced_troubleshooting_mode_title": "Troubleshooting Mode",
"advanced_update_ssh_key_button": "Update SSH Key",
"advanced_usb_emulation_description": "Control the USB emulation state",
"advanced_usb_emulation_title": "USB Emulation",
"already_adopted_new_owner": "If you're the new owner, please ask the previous owner to de-register the device from their account in the cloud dashboard. If you believe this is an error, contact our support team for assistance.",
"already_adopted_other_user": "This device is currently registered to another user in our cloud dashboard.",
"already_adopted_return_to_dashboard": "Return to Dashboard",
"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_title": "Appearance",
"attach": "Attach",
"atx_power_control_get_state_error": "Failed to get ATX power state: {error}",
"atx_power_control_hdd_led": "HDD LED",
"atx_power_control_long_power_button": "Long Press",
"atx_power_control_power_button": "Power",
"atx_power_control_power_led": "Power LED",
"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_connect_to_cloud_action": "Log in & Connect device",
"auth_connect_to_cloud_description": "Unlock remote access and advanced features for your device",
"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_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_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_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_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_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",
"back_to_devices": "Back to Devices",
"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.",
"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_connection_description": "The connection between the client and the JetKVM.",
"connection_stats_frames_per_second": "Frames per second",
"connection_stats_frames_per_second_description": "Number of inbound video frames displayed per second.",
"connection_stats_network_stability": "Network Stability",
"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_packets_lost_description": "Count of lost inbound video RTP packets.",
"connection_stats_playback_delay": "Playback Delay",
"connection_stats_playback_delay_description": "Delay added by the jitter buffer to smooth playback when frames arrive unevenly.",
"connection_stats_round_trip_time": "Round-Trip Time",
"connection_stats_round_trip_time_description": "Round-trip time for the active ICE candidate pair between peers.",
"connection_stats_sidebar": "Connection Stats",
"connection_stats_unit_frames_per_second": " fps",
"connection_stats_unit_milliseconds": " ms",
"connection_stats_unit_packets": " packets",
"connection_stats_video": "Video",
"connection_stats_video_description": "The video stream from the JetKVM to the client.",
"continue": "Continue",
"creating_peer_connection": "Creating peer connection…",
"dc_power_control_current": "Current",
"dc_power_control_current_unit": "A",
"dc_power_control_get_state_error": "Failed to get DC power state: {error}",
"dc_power_control_power": "Power",
"dc_power_control_power_off_button": "Power Off",
"dc_power_control_power_off_state": "Power OFF",
"dc_power_control_power_on_button": "Power On",
"dc_power_control_power_on_state": "Power ON",
"dc_power_control_power_unit": "W",
"dc_power_control_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",
"delete": "Delete",
"deregister_cloud_devices": "Cloud Devices",
"deregister_description": "This will remove the device from your cloud account and revoke remote access to it. Please note that local access will still be possible",
"deregister_error": "There was an error {status} deregistering your device. Please try again.",
"deregister_from_cloud": "Deregister from Cloud",
"deregister_headline": "Deregister {device} from your cloud account",
"detach": "Detach",
"dhcp_empty_lease_description": "We haven't received any DHCP lease information from the device yet.",
"dhcp_empty_lease_headline": "No DHCP Lease information",
"dhcp_lease_boot_file": "Boot File",
"dhcp_lease_boot_next_server": "Boot Next Server",
"dhcp_lease_boot_server_name": "Boot Server Name",
"dhcp_lease_broadcast": "Broadcast",
"dhcp_lease_domain": "Domain",
"dhcp_lease_gateway": "Gateway",
"dhcp_lease_header": "DHCP Lease Information",
"dhcp_lease_hostname": "Hostname",
"dhcp_lease_lease_expires": "Lease Expires",
"dhcp_lease_maximum_transfer_unit": "MTU",
"dhcp_lease_renew": "Renew DHCP Lease",
"dhcp_lease_time_to_live": "TTL",
"dhcp_server": "DHCP Server",
"dns_servers": "DNS Servers",
"establishing_secure_connection": "Establishing secure connection…",
"experimental": "Experimental",
"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",
"extensions_atx_power_control_description": "Control the power state of your machine via ATX power control.",
"extensions_dc_power_control": "DC Power Control",
"extensions_dc_power_control_description": "Control your DC Power extension",
"extensions_popover_extensions": "Extensions",
"gathering_ice_candidates": "Gathering ICE candidates…",
"general_app_version": "App: {version}",
"general_auto_update_description": "Automatically update the device to the latest version",
"general_auto_update_error": "Failed to set auto-update: {error}",
"general_auto_update_title": "Auto Update",
"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_no_button": "No",
"general_reboot_title": "Reboot JetKVM",
"general_reboot_yes_button": "Yes",
"general_system_version": "System: {version}",
"general_title": "General",
"general_update_app_update_title": "App Update",
"general_update_application_type": "App",
"general_update_available_description": "A new update is available to enhance system performance and improve compatibility. We recommend updating to ensure everything runs smoothly.",
"general_update_available_title": "Update available",
"general_update_background_button": "Update in Background",
"general_update_check_again_button": "Check Again",
"general_update_checking_description": "We're ensuring your device has the latest features and improvements.",
"general_update_checking_title": "Checking for updates…",
"general_update_completed_description": "Your device has been successfully updated to the latest version. Enjoy the new features and improvements!",
"general_update_completed_title": "Update Completed Successfully",
"general_update_error_description": "An error occurred while updating your device. Please try again later.",
"general_update_error_details": "Error details: {errorMessage}",
"general_update_error_title": "Update Error",
"general_update_later_button": "Do it later",
"general_update_now_button": "Update Now",
"general_update_rebooting": "Rebooting to complete the update…",
"general_update_status_awaiting_reboot": "Awaiting reboot",
"general_update_status_downloading": "Downloading {update_type} update…",
"general_update_status_fetching": "Fetching update information…",
"general_update_status_installing": "Installing {update_type} update…",
"general_update_status_progress": "{part} progress",
"general_update_status_verifying": "Verifying {update_type} update…",
"general_update_system_type": "System",
"general_update_system_update_title": "Linux System Update",
"general_update_up_to_date_description": "Your system is running the latest version. No updates are currently available.",
"general_update_up_to_date_title": "System is up to date",
"general_update_updating_description": "Please don't turn off your device. This process may take a few minutes.",
"general_update_updating_title": "Updating your device",
"getting_remote_session_description": "Getting remote session description attempt {attempt}",
"hardware_backlight_settings_error": "Failed to set backlight settings: {error}",
"hardware_backlight_settings_get_error": "Failed to get backlight settings: {error}",
"hardware_backlight_settings_success": "Backlight settings updated successfully",
"hardware_dim_display_after_description": "Set how long to wait before dimming the display",
"hardware_dim_display_after_title": "Dim Display After",
"hardware_display_brightness_description": "Set the brightness of the display",
"hardware_display_brightness_high": "High",
"hardware_display_brightness_low": "Low",
"hardware_display_brightness_medium": "Medium",
"hardware_display_brightness_off": "Off",
"hardware_display_brightness_title": "Display Brightness",
"hardware_display_orientation_description": "Set the orientation of the display",
"hardware_display_orientation_error": "Failed to set display orientation: {error}",
"hardware_display_orientation_inverted": "Inverted",
"hardware_display_orientation_normal": "Normal",
"hardware_display_orientation_success": "Display orientation updated successfully",
"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_power_saving_description": "Reduce power consumption when not in use",
"hardware_power_saving_disabled": "Power saving mode disabled",
"hardware_power_saving_enabled": "Power saving mode enabled",
"hardware_power_saving_failed_error": "Failed to set power saving mode: {error}",
"hardware_power_saving_hdmi_sleep_description": "Turn off capture after 90 seconds of inactivity",
"hardware_power_saving_hdmi_sleep_title": "HDMI Sleep Mode",
"hardware_power_saving_title": "Power Saving",
"hardware_time_10_minutes": "10 Minutes",
"hardware_time_1_hour": "1 Hour",
"hardware_time_1_minute": "1 Minute",
"hardware_time_30_minutes": "30 Minutes",
"hardware_time_5_minutes": "5 Minutes",
"hardware_time_never": "Never",
"hardware_title": "Hardware",
"hardware_turn_off_display_after_description": "Period of inactivity before display automatically turns off",
"hardware_turn_off_display_after_title": "Turn off Display After",
"hide": "Hide",
"ice_gathering_completed": "ICE Gathering completed",
"info_caps_lock": "Caps Lock",
"info_compose": "Compose",
"info_hdmi_state": "HDMI State:",
"info_hidrpc_state": "HidRPC State:",
"info_kana": "Kana",
"info_keys": "Keys:",
"info_last_move": "Last Move:",
"info_num_lock": "Num Lock",
"info_paste_enabled": "Enabled",
"info_paste_mode": "Paste Mode:",
"info_pointer": "Pointer:",
"info_relayed_by_cloudflare": "Relayed by Cloudflare",
"info_resolution": "Resolution:",
"info_scroll_lock": "Scroll Lock",
"info_shift": "Shift",
"info_usb_state": "USB State:",
"info_video_size": "Video Size:",
"input_disabled": "Input disabled",
"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",
"ipv6_valid_lifetime": "Valid Lifetime",
"jetkvm_description": "JetKVM combines powerful hardware with intuitive software to provide a seamless remote control experience.",
"jetkvm_device": "JetKVM Device",
"jetkvm_logo": "JetKVM Logo",
"jetkvm_setup": "Set up your JetKVM",
"jiggler_cron_schedule_description": "Cron expression for scheduling",
"jiggler_cron_schedule_label": "Cron Schedule",
"jiggler_example_business_hours_early": "Business Hours 8-17",
"jiggler_example_business_hours_late": "Business Hours 9-17",
"jiggler_examples_label": "Examples",
"jiggler_inactivity_limit_description": "Inactivity time before jiggle",
"jiggler_inactivity_limit_label": "Inactivity Limit Seconds",
"jiggler_more_examples": "More examples",
"jiggler_random_delay_description": "To avoid recognizable patterns",
"jiggler_random_delay_label": "Random delay",
"jiggler_save_jiggler_config": "Save Jiggler Config",
"jiggler_timezone_description": "Timezone for cron schedule",
"jiggler_timezone_label": "Timezone",
"keyboard_description": "Configure keyboard settings for your device",
"keyboard_layout_description": "Keyboard layout of target operating system",
"keyboard_layout_error": "Failed to set keyboard layout: {error}",
"keyboard_layout_long_description": "The virtual keyboard, paste text, and keyboard macros send individual key strokes to the target device. The keyboard layout determines which key codes are being sent. Ensure that the keyboard layout in JetKVM matches the settings in the operating system.",
"keyboard_layout_success": "Keyboard layout set successfully to {layout}",
"keyboard_layout_title": "Keyboard Layout",
"keyboard_show_pressed_keys_description": "Display currently pressed keys in the status bar",
"keyboard_show_pressed_keys_title": "Show Pressed Keys",
"keyboard_title": "Keyboard",
"kvm_terminal": "KVM Terminal",
"last_online": "Last online {time}",
"learn_more": "Learn more",
"load": "Load",
"loading": "Loading…",
"local_auth_change_local_device_password_description": "Enter your current password and a new password to update your local device protection.",
"local_auth_change_local_device_password_title": "Change Local Device Password",
"local_auth_confirm_new_password_label": "Confirm New Password",
"local_auth_create_confirm_password_placeholder": "Re-enter your password",
"local_auth_create_description": "Create a password to protect your device from unauthorized local access.",
"local_auth_create_new_password_label": "New Password",
"local_auth_create_new_password_placeholder": "Enter a strong password",
"local_auth_create_not_now_button": "Not Now",
"local_auth_create_secure_button": "Secure Device",
"local_auth_create_title": "Local Device Protection",
"local_auth_current_password_label": "Current Password",
"local_auth_disable_local_device_protection_description": "Enter your current password to disable local device protection.",
"local_auth_disable_local_device_protection_title": "Disable Local Device Protection",
"local_auth_disable_protection_button": "Disable Protection",
"local_auth_enter_current_password_placeholder": "Enter your current password",
"local_auth_enter_new_password_placeholder": "Enter a new strong password",
"local_auth_error_changing_password": "An error occurred while changing the password",
"local_auth_error_disabling_password": "An error occurred while disabling the password",
"local_auth_error_enter_current_password": "Please enter your current password",
"local_auth_error_enter_new_password": "Please enter a new password",
"local_auth_error_enter_old_password": "Please enter your old password",
"local_auth_error_enter_password": "Please enter a password",
"local_auth_error_passwords_not_match": "Passwords do not match",
"local_auth_error_setting_password": "An error occurred while setting the password",
"local_auth_new_password_label": "New Password",
"local_auth_reenter_new_password_placeholder": "Re-enter your new password",
"local_auth_success_password_disabled_description": "You've successfully disabled the password protection for local access. Remember, your device is now less secure.",
"local_auth_success_password_disabled_title": "Password Protection Disabled",
"local_auth_success_password_set_description": "You've successfully set up local device protection. Your device is now secure against unauthorized local access.",
"local_auth_success_password_set_title": "Password Set Successfully",
"local_auth_success_password_updated_description": "You've successfully changed your local device protection password. Make sure to remember your new password for future access.",
"local_auth_success_password_updated_title": "Password Updated Successfully",
"local_auth_update_password_button": "Update Password",
"locale_auto": "Auto",
"locale_change_success": "Language changed successfully to {locale}",
"locale_da": "Dansk",
"locale_de": "Deutsch",
"locale_en": "English",
"locale_es": "Español",
"locale_fr": "Français",
"locale_it": "Italiano",
"locale_nb": "Norsk (bokmål)",
"locale_sv": "Svenska",
"locale_zh": "中文 (简体)",
"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_error": "An error occurred while logging in",
"login_forgot_password": "Forgot password?",
"login_password_label": "Password",
"login_welcome_back": "Welcome back to JetKVM",
"macro_add_step": "Add Step{maxed_out}",
"macro_at_least_one_step_keys_or_modifiers": "At least one step must have keys or modifiers",
"macro_at_least_one_step_required": "At least one step is required",
"macro_max_steps_error": "You can only add a maximum of {max} steps per macro.",
"macro_max_steps_reached": "({max} max)",
"macro_name_label": "Macro Name",
"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_failed": "An error occurred while saving.",
"macro_save_failed_error": "An error occurred while saving: {error}.",
"macro_step_count": "{steps} / {max} steps",
"macro_step_duration_description": "Time to wait before executing the next step.",
"macro_step_duration_label": "Step Duration",
"macro_step_keys_description": "Maximum {max} keys per step.",
"macro_step_keys_label": "Keys",
"macro_step_max_keys_reached": "Maximum keys reached",
"macro_step_modifiers_description": "What modifiers (Shift/Ctrl/Alt/Meta) are pressed during this step.",
"macro_step_modifiers_label": "Modifiers",
"macro_step_no_matching_keys_found": "No matching keys found",
"macro_step_search_for_key": "Search for key…",
"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_aria_add_new": "Add new macro",
"macros_aria_delete": "Delete macro {name}",
"macros_aria_duplicate": "Duplicate macro {name}",
"macros_aria_edit": "Edit macro {name}",
"macros_aria_move_down": "Move {name} down",
"macros_aria_move_up": "Move {name} up",
"macros_confirm_delete_description": "Are you sure you want to delete \"{name}\"? This action cannot be undone.",
"macros_confirm_delete_title": "Delete Macro",
"macros_confirm_deleting": "Deleting…",
"macros_create_first_description": "Combine keystrokes into a single action",
"macros_create_first_headline": "Create Your First Macro",
"macros_created_success": "Macro \"{name}\" created successfully",
"macros_delay_only": "Delay only",
"macros_delete_confirm": "Are you sure you want to delete this macro? This action cannot be undone.",
"macros_delete_macro": "Delete Macro",
"macros_deleted_success": "Macro \"{name}\" deleted successfully",
"macros_deleting": "Deleting",
"macros_duplicated_success": "Macro \"{name}\" duplicated successfully",
"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_delete_error": "Failed to delete macro: {error}",
"macros_failed_duplicate": "Failed to duplicate macro",
"macros_failed_duplicate_error": "Failed to duplicate macro: {error}",
"macros_failed_reorder": "Failed to reorder macros",
"macros_failed_reorder_error": "Failed to reorder macros: {error}",
"macros_failed_update": "Failed to update macro",
"macros_failed_update_error": "Failed to update macro: {error}",
"macros_invalid_data": "Invalid macro data",
"macros_loading": "Loading macros…",
"macros_max_reached": "Max Reached",
"macros_maximum_macros_reached": "You have reached the maximum number of {maximum} macros allowed.",
"macros_no_macros_available": "No macros available",
"macros_order_updated": "Macro order updated successfully",
"macros_title": "Keyboard Macros",
"macros_updated_success": "Macro \"{name}\" updated successfully",
"metric_not_supported": "Metric not supported",
"metric_waiting_for_data": "Waiting for data…",
"mount_add_file_to_get_started": "Add a file to get started",
"mount_add_new_media": "Add New Media",
"mount_available_storage": "Available Storage",
"mount_button_back_to_overview": "Back to Overview",
"mount_button_cancel_upload": "Cancel Upload",
"mount_button_continue_upload": "Continue uploading",
"mount_button_mount_file": "Mount File",
"mount_button_mount_url": "Mount URL",
"mount_button_select": "Select",
"mount_button_showing_results": "Showing {from} to {to} of {total} results",
"mount_button_upload_new_image": "Upload a new image",
"mount_bytes_free": "{bytesFree} free",
"mount_bytes_used": "{bytesUsed} used",
"mount_calculating": "Calculating…",
"mount_click_to_select_file": "Click to select a file",
"mount_click_to_select_incomplete": "Click to select \"{name}\"",
"mount_confirm_delete": "Are you sure you want to delete {name}?",
"mount_continue_uploading_with_name": "Continue uploading \"{name}\"",
"mount_error_delete_file": "Error deleting file: {error}",
"mount_error_description": "An error occurred while attempting to mount the media. Please try again.",
"mount_error_get_storage_space": "Error getting storage space: {error}",
"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_mode_cdrom": "CD/DVD",
"mount_mode_disk": "Disk",
"mount_mounted_as": "Mounted as",
"mount_mounted_from_storage": "Mounted from JetKVM Storage",
"mount_no_images_description": "Upload an image to start virtual media mounting.",
"mount_no_images_title": "No images available",
"mount_no_mounted_media": "No mounted media",
"mount_percentage_used": "{percentageUsed}% used",
"mount_please_select_file": "Please select the file \"{name}\" to continue the upload.",
"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_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",
"mount_upload_failed_rtc": "Upload failed: {error}",
"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_url_description": "Mount files from any public web address",
"mount_url_input_label": "Image URL",
"mount_url_mount": "URL Mount",
"mount_view_device_description": "Select an image to mount from the JetKVM storage",
"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",
"mouse_alt_finger": "Finger touching a screen",
"mouse_alt_mouse": "Mouse icon",
"mouse_description": "Configure cursor behavior and interaction settings for your device",
"mouse_hide_cursor_description": "Hide the cursor when sending mouse movements",
"mouse_hide_cursor_title": "Hide Cursor",
"mouse_jiggler_config_updated": "Jiggler config successfully updated",
"mouse_jiggler_custom": "Custom",
"mouse_jiggler_description": "Simulate movement of a computer mouse",
"mouse_jiggler_disabled": "Disabled",
"mouse_jiggler_error_config": "There was an error setting the jiggler config",
"mouse_jiggler_failed_state": "Failed to set jiggler state: {error}",
"mouse_jiggler_frequent": "Frequent - 30s",
"mouse_jiggler_invalid_cron": "Invalid cron expression. Please check your schedule format (e.g., '0 * * * * *' for every minute).",
"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_relative_description": "Most compatible",
"mouse_modes_description": "Choose the mouse input mode",
"mouse_modes_title": "Modes",
"mouse_scroll_high": "High",
"mouse_scroll_low": "Low",
"mouse_scroll_medium": "Medium",
"mouse_scroll_off": "Off",
"mouse_scroll_throttling_description": "Reduce the frequency of scroll events",
"mouse_scroll_throttling_title": "Scroll Throttling",
"mouse_scroll_very_high": "Very High",
"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_lease_renew_confirm": "Renew Lease",
"network_dhcp_lease_renew_confirm_description": "This will request a new IP address from your DHCP server. Your device may temporarily lose network connectivity during this process.",
"network_dhcp_lease_renew_confirm_new_a": "If you receive a new IP address",
"network_dhcp_lease_renew_confirm_new_b": "you may need to reconnect using the new address",
"network_dhcp_lease_renew_failed": "Failed to renew lease: {error}",
"network_dhcp_lease_renew_success": "DHCP lease renewed",
"network_domain_custom": "Custom",
"network_domain_description": "Network domain suffix for the device",
"network_domain_dhcp_provided": "DHCP provided",
"network_domain_local": ".local",
"network_domain_title": "Domain",
"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_invalid": "Invalid IPv4 address",
"network_ipv4_invalid_cidr": "Invalid CIDR notation for IPv4 address",
"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_addresses_header": "IPv6 Addresses",
"network_ipv6_cidr_suggestion": "Please use CIDR notation (e.g., 2001:db8::1/64)",
"network_ipv6_dns": "IPv6 DNS",
"network_ipv6_flag_dad_failed": "DAD Failed",
"network_ipv6_flag_deprecated": "Deprecated",
"network_ipv6_gateway": "IPv6 Gateway",
"network_ipv6_information": "IPv6 Information",
"network_ipv6_invalid": "Invalid IPv6 address",
"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_prefix": "IP Prefix",
"network_ipv6_prefix_invalid": "Prefix must be between 0 and 128",
"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",
"network_mdns_description": "Control mDNS (multicast DNS) operational mode",
"network_mdns_disabled": "Disabled",
"network_mdns_ipv4_only": "IPv4 only",
"network_mdns_ipv6_only": "IPv6 only",
"network_mdns_title": "mDNS",
"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_add_dns": "Add DNS Server",
"network_settings_load_error": "Failed to load network settings: {error}",
"network_static_ipv4_header": "Static IPv4 Configuration",
"network_static_ipv6_header": "Static IPv6 Configuration",
"network_time_sync_description": "Configure time synchronization settings",
"network_time_sync_http_only": "HTTP only",
"network_time_sync_ntp_and_http": "NTP and HTTP",
"network_time_sync_ntp_only": "NTP only",
"network_time_sync_title": "Time synchronization",
"network_title": "Network",
"never_seen_online": "Never seen online",
"next": "Next",
"no_results_found": "No results found",
"not_applicable": "N/A",
"not_available": "N/A",
"not_found": "Not found",
"ntp_servers": "NTP Servers",
"oh_no": "Oh no!",
"online": "Online",
"other_session_detected": "Another Active Session Detected",
"other_session_take_over": " Only one active session is supported at a time. Would you like to take over this session?",
"other_session_use_here_button": "Use Here",
"page_not_found_description": "The page you were looking for does not exist.",
"paste_modal_confirm_paste": "Confirm Paste",
"paste_modal_delay_between_keys": "Delay between keys",
"paste_modal_delay_out_of_range": "Delay must be between {min} and {max}",
"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_sending_using_layout": "Sending text using keyboard layout: {iso}-{name}",
"paste_text": "Paste text",
"paste_text_description": "Paste text from your client to the remote host",
"peer_connection_closed": "Closed",
"peer_connection_closing": "Closing",
"peer_connection_connected": "Connected",
"peer_connection_connecting": "Connecting",
"peer_connection_disconnected": "Disconnected",
"peer_connection_error": "Connection error",
"peer_connection_failed": "Connection failed",
"peer_connection_new": "Connecting",
"previous": "Previous",
"register_device_error": "There was an error {error} registering your device.",
"register_device_finish_button": "Finish Setup",
"register_device_name_description": "Name your device so you can easily identify it later. You can change this name at any time.",
"register_device_name_label": "Device Name",
"register_device_name_placeholder": "Plex Media Server",
"register_device_no_name": "Please specify a name",
"rename_device": "Rename Device",
"rename_device_description": "Properly name your device to easily identify it.",
"rename_device_error": "There was an error {error} renaming your device.",
"rename_device_headline": "Rename {name}",
"rename_device_new_name_label": "New device name",
"rename_device_new_name_placeholder": "Plex Media Server",
"rename_device_no_name": "Please specify a name",
"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_set_settings_error": "Failed to set serial console settings to {settings}: {error}",
"serial_console_stop_bits": "Stop Bits",
"setting_remote_description": "Setting remote description",
"setting_remote_session_description": "Setting remote session description...",
"setting_up_connection_to_device": "Setting up connection to device...",
"settings_access": "Access",
"settings_advanced": "Advanced",
"settings_appearance": "Appearance",
"settings_back_to_kvm": "Back to KVM",
"settings_general": "General",
"settings_hardware": "Hardware",
"settings_keyboard": "Keyboard",
"settings_keyboard_macros": "Keyboard Macros",
"settings_mouse": "Mouse",
"settings_network": "Network",
"settings_video": "Video",
"something_went_wrong": "Something went wrong. Please try again later or contact support",
"step_counter_step": "Step {step}",
"subnet_mask": "Subnet Mask",
"time_division_days": "days",
"time_division_hours": "hours",
"time_division_minutes": "minutes",
"time_division_months": "months",
"time_division_seconds": "seconds",
"time_division_weeks": "weeks",
"time_division_years": "years",
"troubleshoot_connection": "Troubleshoot Connection",
"unknown_error": "Unknown error",
"update_in_progress": "Update in Progress",
"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",
"usb_config_failed_load": "Failed to load USB Config: {error}",
"usb_config_failed_set": "Failed to set USB config: {error}",
"usb_config_identifiers_description": "USB device identifiers exposed to the target computer",
"usb_config_identifiers_title": "Identifiers",
"usb_config_logitech": "Logitech Universal Adapter",
"usb_config_manufacturer_label": "Manufacturer",
"usb_config_manufacturer_placeholder": "Enter Manufacturer",
"usb_config_microsoft": "Microsoft Wireless MultiMedia Keyboard",
"usb_config_product_id_label": "Product ID",
"usb_config_product_id_placeholder": "Enter Product ID",
"usb_config_product_name_label": "Product Name",
"usb_config_product_name_placeholder": "Enter Product Name",
"usb_config_restore_default": "Restore to Default",
"usb_config_serial_number_label": "Serial Number",
"usb_config_serial_number_placeholder": "Enter Serial Number",
"usb_config_set_success": "USB Config set to {manufacturer} {product}",
"usb_config_update_identifiers": "Update USB Identifiers",
"usb_config_vendor_id_label": "Vendor ID",
"usb_config_vendor_id_placeholder": "Enter Vendor ID",
"usb_device_classes_description": "USB device classes in the composite device",
"usb_device_classes_title": "Classes",
"usb_device_custom": "Custom",
"usb_device_description": "USB devices to emulate on the target computer",
"usb_device_enable_absolute_mouse_description": "Enable Absolute Mouse (Pointer)",
"usb_device_enable_absolute_mouse_title": "Enable Absolute Mouse (Pointer)",
"usb_device_enable_keyboard_description": "Enable Keyboard",
"usb_device_enable_keyboard_title": "Enable Keyboard",
"usb_device_enable_mass_storage_description": "Sometimes it might need to be disabled to prevent issues with certain devices",
"usb_device_enable_mass_storage_title": "Enable USB Mass Storage",
"usb_device_enable_relative_mouse_description": "Enable Relative Mouse",
"usb_device_enable_relative_mouse_title": "Enable Relative Mouse",
"usb_device_failed_load": "Failed to load USB devices: {error}",
"usb_device_failed_set": "Failed to set USB devices: {error}",
"usb_device_keyboard_mouse_and_mass_storage": "Keyboard, Mouse and Mass Storage",
"usb_device_keyboard_only": "Keyboard Only",
"usb_device_restore_default": "Restore to Default",
"usb_device_title": "USB Device",
"usb_device_update_classes": "Update USB Classes",
"usb_device_updated": "USB Devices updated",
"usb_state_connected": "Connected",
"usb_state_connecting": "Connecting",
"usb_state_disconnected": "Disconnected",
"usb_state_low_power_mode": "Low power mode",
"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)",
"video_brightness_title": "Brightness",
"video_contrast_description": "Contrast level ({value}x)",
"video_contrast_title": "Contrast",
"video_custom_edid_description": "EDID details video mode compatibility. Default settings works in most cases, but unique UEFI/BIOS might need adjustments.",
"video_custom_edid_title": "Custom EDID",
"video_debugging_info_description": "Debugging information for video",
"video_debugging_info_title": "Debugging Info",
"video_description": "Configure display settings and EDID for optimal compatibility",
"video_edid_acer_b246wl": "Acer B246WL, 1920x1200",
"video_edid_asus_pa248qv": "ASUS PA248QV, 1920x1200",
"video_edid_custom": "Custom",
"video_edid_dell_d2721h": "DELL D2721H, 1920x1080",
"video_edid_dell_idrac": "DELL IDRAC EDID, 1280x1024",
"video_edid_description": "Adjust the EDID settings for the display",
"video_edid_file_label": "EDID File",
"video_edid_jetkvm_default": "JetKVM Default",
"video_edid_set_success": "EDID set successfully to {edid}",
"video_edid_title": "EDID",
"video_enhancement_description": "Adjust color settings to make the video output more vibrant and colorful",
"video_enhancement_title": "Video Enhancement",
"video_failed_get_debug_info": "Failed to get debug info: {error}",
"video_failed_get_edid": "Failed to get EDID: {error}",
"video_failed_set_edid": "Failed to set EDID: {error}",
"video_failed_set_stream_quality": "Failed to set stream quality: {error}",
"video_get_debugging_info": "Get Debugging Info",
"video_overlay_autoplay_permissions_required": "Autoplay permissions required",
"video_overlay_conn_check_cables": "Check all cable connections for any loose or damaged wires",
"video_overlay_conn_ensure_network": "Ensure your network connection is stable and active",
"video_overlay_conn_restart": "Try restarting both the device and your computer",
"video_overlay_conn_verify_power": "Verify that the device is powered on and properly connected",
"video_overlay_connection_issue_title": "Connection Issue Detected",
"video_overlay_enable_autoplay_settings": "Please adjust browser settings to enable autoplay",
"video_overlay_hdmi_error_title": "HDMI signal error detected.",
"video_overlay_hdmi_incompatible_resolution": "Incompatible resolution or refresh rate settings",
"video_overlay_hdmi_loose_faulty": "A loose or faulty HDMI connection",
"video_overlay_hdmi_source_issue": "Issues with the source device's HDMI output",
"video_overlay_learn_more": "Learn more",
"video_overlay_loading_stream": "Loading video stream…",
"video_overlay_manually_start_stream": "Manually start stream",
"video_overlay_no_hdmi_adapter_compat": "If using an adapter, ensure it's compatible and functioning correctly",
"video_overlay_no_hdmi_ensure_cable": "Ensure the HDMI cable securely connected at both ends",
"video_overlay_no_hdmi_ensure_power": "Ensure source device is powered on and outputting a signal",
"video_overlay_no_hdmi_signal": "No HDMI signal detected.",
"video_overlay_pointerlock_click_to_enable": "Click on the video to enable mouse control",
"video_overlay_reboot_device_is_rebooting": "Device is Rebooting",
"video_overlay_reboot_different_ip_message": "The device may have restarted with a different IP address. Check the JetKVM's physical display to find the current IP address and reconnect.",
"video_overlay_reboot_please_wait_message": "Please wait while the device restarts. This usually takes 20-30 seconds.",
"video_overlay_reboot_timeout_message": "Automatic Reconnection Timed Out",
"video_overlay_reboot_unable_to_reconnect": "Unable to Reconnect",
"video_overlay_reboot_waiting_for_restart": "Waiting for device to restart…",
"video_overlay_retrying_connection": "Retrying connection…",
"video_overlay_troubleshooting_guide": "Troubleshooting Guide",
"video_overlay_try_again": "Try again",
"video_pointer_lock_disabled": "Pointer lock disabled",
"video_pointer_lock_enabled": "Pointer lock enabled — press Escape to unlock",
"video_quality_high": "High",
"video_quality_low": "Low",
"video_quality_medium": "Medium",
"video_reset_to_default": "Reset to Default",
"video_restore_to_default": "Restore to default",
"video_saturation_description": "Color saturation ({value}x)",
"video_saturation_title": "Saturation",
"video_set_custom_edid": "Set Custom EDID",
"video_stream_quality_description": "Adjust the quality of the video stream",
"video_stream_quality_set": "Stream quality set to {quality}",
"video_stream_quality_title": "Stream Quality",
"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",
"wake_on_lan_add_device_save_device": "Save Device",
"wake_on_lan_description": "Send a Magic Packet to wake up a remote device.",
"wake_on_lan_device_list_add_new_device": "Add New Device",
"wake_on_lan_device_list_delete_device": "Delete device",
"wake_on_lan_device_list_wake": "Wake",
"wake_on_lan_empty_add_device_to_start": "Add a device to start using Wake-on-LAN",
"wake_on_lan_empty_add_new_device": "Add New Device",
"wake_on_lan_empty_no_devices_added": "No devices added",
"wake_on_lan_failed_add_device": "Failed to add device",
"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"
}

View File

@ -0,0 +1,901 @@
{
"$schema": "https://inlang.com/schema/inlang-message-format",
"access_adopt_kvm": "Vincular KVM con la nube",
"access_adopted_message": "Su dispositivo está vinculado a la nube",
"access_auth_mode_no_password": "Modo actual: Sin contraseña",
"access_auth_mode_password": "Modo actual: Protegido con contraseña",
"access_authentication_mode_title": "Modo de autenticación",
"access_certificate_label": "Certificado",
"access_change_password_button": "Cambiar la contraseña",
"access_change_password_description": "Actualice la contraseña de acceso a su dispositivo",
"access_change_password_title": "Cambiar la contraseña",
"access_cloud_api_url_label": "URL de la API de Cloud",
"access_cloud_app_url_label": "URL de la aplicación Cloud",
"access_cloud_provider_description": "Seleccione el proveedor de nube para su dispositivo",
"access_cloud_provider_title": "Proveedor de la nube",
"access_cloud_security_title": "Seguridad en la nube",
"access_confirm_deregister": "¿Está seguro de que desea cancelar el registro de este dispositivo?",
"access_deregister": "Darse de baja de la nube",
"access_description": "Administre el control de acceso del dispositivo",
"access_disable_protection": "Desactivar la protección",
"access_enable_password": "Activar contraseña",
"access_failed_deregister": "No se pudo cancelar el registro del dispositivo: {error}",
"access_failed_update_cloud_url": "No se pudo actualizar la URL de la nube: {error}",
"access_failed_update_tls": "No se pudo actualizar la configuración de TLS: {error}",
"access_github_link": "GitHub",
"access_https_description": "Configurar el acceso HTTPS seguro a su dispositivo",
"access_https_mode_title": "Modo HTTPS",
"access_learn_security": "Más información sobre nuestra seguridad en la nube",
"access_local_description": "Administrar el modo de acceso local al dispositivo",
"access_local_title": "Local",
"access_no_device_id": "No hay ningún ID de dispositivo disponible",
"access_private_key_description": "Por razones de seguridad, no se mostrará después de guardar.",
"access_private_key_label": "Clave privada",
"access_provider_custom": "Personalizado",
"access_provider_jetkvm": "Nube JetKVM",
"access_remote_description": "Administrar el modo de acceso remoto al dispositivo",
"access_security_encryption": "Cifrado de extremo a extremo mediante WebRTC (DTLS y SRTP)",
"access_security_oidc": "Autenticación OIDC (OpenID Connect)",
"access_security_open_source": "Todos los componentes de la nube son de código abierto y están disponibles en GitHub.",
"access_security_streams": "Todas las transmisiones están cifradas en tránsito",
"access_security_zero_trust": "Modelo de seguridad de confianza cero",
"access_title": "Acceso",
"access_tls_certificate_description": "Pegue su certificado TLS a continuación. Para las cadenas de certificados, incluya la cadena completa (certificados hoja, intermedios y raíz).",
"access_tls_certificate_title": "Certificado TLS",
"access_tls_custom": "Personalizado",
"access_tls_disabled": "Desactivado",
"access_tls_self_signed": "Autofirmado",
"access_tls_updated": "La configuración de TLS se actualizó correctamente",
"access_update_tls_settings": "Actualizar la configuración de TLS",
"action_bar_connection_stats": "Estadísticas de conexión",
"action_bar_extension": "Extensión",
"action_bar_fullscreen": "Pantalla completa",
"action_bar_settings": "Ajustes",
"action_bar_virtual_keyboard": "Teclado virtual",
"action_bar_virtual_media": "Medios virtuales",
"action_bar_wake_on_lan": "Activación en LAN",
"action_bar_web_terminal": "Terminal web",
"advanced_description": "Acceda a configuraciones adicionales para la resolución de problemas y personalización",
"advanced_dev_channel_description": "Reciba actualizaciones anticipadas del canal de desarrollo",
"advanced_dev_channel_title": "Actualizaciones del canal de desarrollo",
"advanced_developer_mode_description": "Habilitar funciones avanzadas para desarrolladores",
"advanced_developer_mode_enabled_title": "Modo de desarrollador habilitado",
"advanced_developer_mode_title": "Modo de desarrollador",
"advanced_developer_mode_warning_advanced": "Solo para usuarios avanzados. No apto para producción.",
"advanced_developer_mode_warning_risks": "Úselo solo si comprende los riesgos.",
"advanced_developer_mode_warning_security": "La seguridad se debilita mientras está activa",
"advanced_disable_usb_emulation": "Deshabilitar la emulación USB",
"advanced_enable_usb_emulation": "Habilitar emulación USB",
"advanced_error_loopback_disable": "No se pudo deshabilitar el modo de solo bucle invertido: {error}",
"advanced_error_loopback_enable": "No se pudo habilitar el modo de solo bucle invertido: {error}",
"advanced_error_reset_config": "No se pudo restablecer la configuración: {error}",
"advanced_error_set_dev_channel": "No se pudo establecer el estado del canal de desarrollo: {error}",
"advanced_error_set_dev_mode": "No se pudo establecer el modo de desarrollo: {error}",
"advanced_error_update_ssh_key": "No se pudo actualizar la clave SSH: {error}",
"advanced_error_usb_emulation_disable": "No se pudo deshabilitar la emulación USB: {error}",
"advanced_error_usb_emulation_enable": "No se pudo habilitar la emulación USB: {error}",
"advanced_loopback_only_description": "Restringir el acceso a la interfaz web solo al host local (127.0.0.1)",
"advanced_loopback_only_title": "Modo de solo bucle invertido",
"advanced_loopback_warning_before": "Antes de habilitar esta función, asegúrese de tener:",
"advanced_loopback_warning_cloud": "Acceso a la nube habilitado y funcionando",
"advanced_loopback_warning_confirm": "Entiendo, habilitar de todas formas",
"advanced_loopback_warning_description": "ADVERTENCIA: Esto restringirá el acceso a la interfaz web únicamente al host local (127.0.0.1).",
"advanced_loopback_warning_ssh": "Acceso SSH configurado y probado",
"advanced_loopback_warning_title": "¿Habilitar el modo de solo bucle invertido?",
"advanced_reset_config_button": "Restablecer configuración",
"advanced_reset_config_description": "Restablecer la configuración predeterminada. Esto cerrará la sesión.",
"advanced_reset_config_title": "Restablecer configuración",
"advanced_ssh_access_description": "Agregue su clave pública SSH para habilitar el acceso remoto seguro al dispositivo",
"advanced_ssh_access_title": "Acceso SSH",
"advanced_ssh_default_user": "El usuario SSH predeterminado es",
"advanced_ssh_public_key_label": "Clave pública SSH",
"advanced_ssh_public_key_placeholder": "Ingrese su clave pública SSH",
"advanced_success_loopback_disabled": "El modo de solo bucle invertido está deshabilitado. Reinicie el dispositivo para aplicarlo.",
"advanced_success_loopback_enabled": "Modo de solo bucle invertido habilitado. Reinicie el dispositivo para aplicarlo.",
"advanced_success_reset_config": "La configuración se restableció a los valores predeterminados correctamente",
"advanced_success_update_ssh_key": "Clave SSH actualizada exitosamente",
"advanced_title": "Avanzado",
"advanced_troubleshooting_mode_description": "Herramientas de diagnóstico y controles adicionales para resolución de problemas y fines de desarrollo",
"advanced_troubleshooting_mode_title": "Modo de solución de problemas",
"advanced_update_ssh_key_button": "Actualizar clave SSH",
"advanced_usb_emulation_description": "Controlar el estado de emulación USB",
"advanced_usb_emulation_title": "Emulación USB",
"already_adopted_new_owner": "Si eres el nuevo propietario, solicita al anterior propietario que cancele el registro del dispositivo en su cuenta en el panel de control de la nube. Si crees que se trata de un error, contacta con nuestro equipo de soporte para obtener ayuda.",
"already_adopted_other_user": "Este dispositivo está actualmente registrado por otro usuario en nuestro panel de control en la nube.",
"already_adopted_return_to_dashboard": "Regresar al panel de control",
"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_title": "Apariencia",
"attach": "Adjuntar",
"atx_power_control_get_state_error": "No se pudo obtener el estado de energía ATX: {error}",
"atx_power_control_hdd_led": "LED del disco duro",
"atx_power_control_long_power_button": "Pulsación larga",
"atx_power_control_power_button": "Fuerza",
"atx_power_control_power_led": "LED de encendido",
"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_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_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_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_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_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_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_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",
"back_to_devices": "Volver a Dispositivos",
"cancel": "Cancelar",
"close": "Cerrar",
"cloud_kvms": "KVM en la nube",
"cloud_kvms_description": "Administre sus KVM en la nube y conéctese a ellos de forma segura.",
"cloud_kvms_no_devices": "No se encontraron dispositivos",
"cloud_kvms_no_devices_description": "Aún no tienes ningún dispositivo con JetKVM Cloud habilitado.",
"confirm": "Confirmar",
"connect_to_kvm": "Conectarse a KVM",
"connecting_to_device": "Conectando al dispositivo…",
"connection_established": "Conexión establecida",
"connection_stats_badge_jitter": "Jitter",
"connection_stats_badge_jitter_buffer_avg_delay": "Retraso medio del búfer de jitter",
"connection_stats_connection": "Conexión",
"connection_stats_connection_description": "La conexión entre el cliente y JetKVM.",
"connection_stats_frames_per_second": "Fotogramas por segundo",
"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_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_packets_lost_description": "Recuento de paquetes de vídeo RTP entrantes perdidos.",
"connection_stats_playback_delay": "Retraso de reproducción",
"connection_stats_playback_delay_description": "Retraso agregado por el buffer de fluctuación para suavizar la reproducción cuando los cuadros llegan de manera desigual.",
"connection_stats_round_trip_time": "Tiempo de ida y vuelta",
"connection_stats_round_trip_time_description": "Tiempo de ida y vuelta para el par de candidatos ICE activos entre pares.",
"connection_stats_sidebar": "Estadísticas de conexión",
"connection_stats_unit_frames_per_second": " fps",
"connection_stats_unit_milliseconds": " ms",
"connection_stats_unit_packets": " paquetes",
"connection_stats_video": "Video",
"connection_stats_video_description": "La transmisión de vídeo desde JetKVM al cliente.",
"continue": "Continuar",
"creating_peer_connection": "Creando conexión entre pares…",
"dc_power_control_current": "Actual",
"dc_power_control_current_unit": "A",
"dc_power_control_get_state_error": "No se pudo obtener el estado de la alimentación de CC: {error}",
"dc_power_control_power": "Fuerza",
"dc_power_control_power_off_button": "Apagado",
"dc_power_control_power_off_state": "Apagado",
"dc_power_control_power_on_button": "Encendido",
"dc_power_control_power_on_state": "Encendido",
"dc_power_control_power_unit": "O",
"dc_power_control_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",
"delete": "Eliminar",
"deregister_cloud_devices": "Dispositivos en la nube",
"deregister_description": "Esto eliminará el dispositivo de su cuenta en la nube y revocará el acceso remoto. Tenga en cuenta que el acceso local seguirá siendo posible.",
"deregister_error": "Se produjo un error {status} al cancelar el registro de su dispositivo. Inténtelo de nuevo.",
"deregister_from_cloud": "Darse de baja de la nube",
"deregister_headline": "Anular el registro de {device} en su cuenta en la nube",
"detach": "Despegar",
"dhcp_empty_lease_description": "Aún no hemos recibido ninguna información de concesión de DHCP del dispositivo.",
"dhcp_empty_lease_headline": "No hay información de arrendamiento de DHCP",
"dhcp_lease_boot_file": "Archivo de arranque",
"dhcp_lease_boot_next_server": "Arrancar el siguiente servidor",
"dhcp_lease_boot_server_name": "Nombre del servidor de arranque",
"dhcp_lease_broadcast": "Transmisión",
"dhcp_lease_domain": "Dominio",
"dhcp_lease_gateway": "Puerta",
"dhcp_lease_header": "Información de arrendamiento de DHCP",
"dhcp_lease_hostname": "Nombre de host",
"dhcp_lease_lease_expires": "El contrato de arrendamiento vence",
"dhcp_lease_maximum_transfer_unit": "Unidad de transmisión máxima (MTU)",
"dhcp_lease_renew": "Renovar la concesión de DHCP",
"dhcp_lease_time_to_live": "Tiempo de vida",
"dhcp_server": "Servidor DHCP",
"dns_servers": "Servidores DNS",
"establishing_secure_connection": "Estableciendo conexión segura…",
"experimental": "Experimental",
"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",
"extensions_atx_power_control_description": "Controle el estado de energía de su máquina a través del control de energía ATX.",
"extensions_dc_power_control": "Control de potencia de CC",
"extensions_dc_power_control_description": "Controle su extensión de alimentación de CC",
"extensions_popover_extensions": "Extensiones",
"gathering_ice_candidates": "Reuniendo candidatos del ICE…",
"general_app_version": "Aplicación: {version}",
"general_auto_update_description": "Actualizar automáticamente el dispositivo a la última versión",
"general_auto_update_error": "No se pudo configurar la actualización automática: {error}",
"general_auto_update_title": "Actualización automática",
"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": "Reinicie el JetKVM",
"general_reboot_no_button": "No",
"general_reboot_title": "Reiniciar JetKVM",
"general_reboot_yes_button": "Sí",
"general_system_version": "Sistema: {version}",
"general_title": "General",
"general_update_app_update_title": "Actualización de la aplicación",
"general_update_application_type": "Aplicación",
"general_update_available_description": "Hay una nueva actualización disponible para mejorar el rendimiento del sistema y la compatibilidad. Recomendamos actualizar para garantizar un funcionamiento fluido.",
"general_update_available_title": "Actualización disponible",
"general_update_background_button": "Actualización en segundo plano",
"general_update_check_again_button": "Comprobar de nuevo",
"general_update_checking_description": "Nos aseguramos de que su dispositivo tenga las últimas funciones y mejoras.",
"general_update_checking_title": "Buscando actualizaciones…",
"general_update_completed_description": "Tu dispositivo se ha actualizado correctamente a la última versión. ¡Disfruta de las nuevas funciones y mejoras!",
"general_update_completed_title": "Actualización completada con éxito",
"general_update_error_description": "Se produjo un error al actualizar tu dispositivo. Inténtalo de nuevo más tarde.",
"general_update_error_details": "Detalles del error: {errorMessage}",
"general_update_error_title": "Error de actualización",
"general_update_later_button": "Posponer",
"general_update_now_button": "Actualizar ahora",
"general_update_rebooting": "Reiniciando para completar la actualización…",
"general_update_status_awaiting_reboot": "Esperando reinicio",
"general_update_status_downloading": "Descargando actualización {update_type} …",
"general_update_status_fetching": "Obteniendo información de actualización…",
"general_update_status_installing": "Instalando {update_type} actualización…",
"general_update_status_progress": "{part} progreso",
"general_update_status_verifying": "Verificando la actualización {update_type} …",
"general_update_system_type": "Sistema",
"general_update_system_update_title": "Actualización del sistema Linux",
"general_update_up_to_date_description": "Su sistema está ejecutando la última versión. No hay actualizaciones disponibles actualmente.",
"general_update_up_to_date_title": "El sistema está actualizado",
"general_update_updating_description": "No apagues tu dispositivo. Este proceso puede tardar unos minutos.",
"general_update_updating_title": "Actualizar su dispositivo",
"getting_remote_session_description": "Obtener un intento de descripción de sesión remota {attempt}",
"hardware_backlight_settings_error": "No se pudieron configurar los ajustes de la retroiluminación: {error}",
"hardware_backlight_settings_get_error": "No se pudieron obtener los ajustes de la retroiluminación: {error}",
"hardware_backlight_settings_success": "La configuración de la luz de fondo se actualizó correctamente",
"hardware_dim_display_after_description": "Establezca cuánto tiempo esperar antes de atenuar la pantalla",
"hardware_dim_display_after_title": "Atenuar la pantalla después",
"hardware_display_brightness_description": "Establecer el brillo de la pantalla",
"hardware_display_brightness_high": "Alto",
"hardware_display_brightness_low": "Bajo",
"hardware_display_brightness_medium": "Medio",
"hardware_display_brightness_off": "Apagado",
"hardware_display_brightness_title": "Brillo de la pantalla",
"hardware_display_orientation_description": "Establecer la orientación de la pantalla",
"hardware_display_orientation_error": "No se pudo establecer la orientación de la pantalla: {error}",
"hardware_display_orientation_inverted": "Invertido",
"hardware_display_orientation_normal": "Normal",
"hardware_display_orientation_success": "La orientación de la pantalla se actualizó correctamente",
"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_power_saving_description": "Reduce el consumo de energía cuando el dispositivo no esté en uso",
"hardware_power_saving_disabled": "Modo de ahorro de energía deshabilitado",
"hardware_power_saving_enabled": "Modo de ahorro de energía habilitado",
"hardware_power_saving_failed_error": "No se pudo establecer el modo de ahorro de energía: {error}",
"hardware_power_saving_hdmi_sleep_description": "Desactivar la captura después de 90 segundos de inactividad",
"hardware_power_saving_hdmi_sleep_title": "Modo de suspensión HDMI",
"hardware_power_saving_title": "Ahorro de energía",
"hardware_time_10_minutes": "10 minutos",
"hardware_time_1_hour": "1 hora",
"hardware_time_1_minute": "1 minuto",
"hardware_time_30_minutes": "30 minutos",
"hardware_time_5_minutes": "5 minutos",
"hardware_time_never": "Nunca",
"hardware_title": "Hardware",
"hardware_turn_off_display_after_description": "Periodo de inactividad antes de que la pantalla se apague automáticamente",
"hardware_turn_off_display_after_title": "Apagar la pantalla después",
"hide": "Ocultar",
"ice_gathering_completed": "Reunión de ICE completada",
"info_caps_lock": "Bloq Mayús",
"info_compose": "Componer",
"info_hdmi_state": "Estado HDMI:",
"info_hidrpc_state": "Estado de HidRPC:",
"info_kana": "Kana",
"info_keys": "Llaves:",
"info_last_move": "Último movimiento:",
"info_num_lock": "Bloq Num",
"info_paste_enabled": "Activado",
"info_paste_mode": "Modo pegar:",
"info_pointer": "Puntero:",
"info_relayed_by_cloudflare": "Retransmitido por Cloudflare",
"info_resolution": "Resolución:",
"info_scroll_lock": "Bloq Despl",
"info_shift": "Cambio",
"info_usb_state": "Estado USB:",
"info_video_size": "Tamaño del vídeo:",
"input_disabled": "Entrada deshabilitada",
"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",
"ipv6_valid_lifetime": "Válido de por vida",
"jetkvm_description": "JetKVM combina hardware potente con software intuitivo para brindar una experiencia de control remoto perfecta.",
"jetkvm_device": "Dispositivo JetKVM",
"jetkvm_logo": "Logotipo de JetKVM",
"jetkvm_setup": "Configura tu JetKVM",
"jiggler_cron_schedule_description": "Expresión cron para la programación",
"jiggler_cron_schedule_label": "Programación cron",
"jiggler_example_business_hours_early": "Horario comercial 8-17",
"jiggler_example_business_hours_late": "Horario comercial: 9 a 17",
"jiggler_examples_label": "Ejemplos",
"jiggler_inactivity_limit_description": "Tiempo de inactividad antes del movimiento",
"jiggler_inactivity_limit_label": "Límite de segundos de inactividad",
"jiggler_more_examples": "Más ejemplos",
"jiggler_random_delay_description": "Para evitar patrones reconocibles",
"jiggler_random_delay_label": "Retraso aleatorio",
"jiggler_save_jiggler_config": "Guardar configuración de Jiggler",
"jiggler_timezone_description": "Zona horaria para la programación cron",
"jiggler_timezone_label": "Zona horaria",
"keyboard_description": "Configure los ajustes del teclado para su dispositivo",
"keyboard_layout_description": "Disposición del teclado del sistema operativo de destino",
"keyboard_layout_error": "No se pudo establecer la distribución del teclado: {error}",
"keyboard_layout_long_description": "El teclado virtual, la función de pegar texto y las macros de teclado envían pulsaciones de teclas individuales al dispositivo de destino. La distribución del teclado determina qué códigos de tecla se envían. Asegúrese de que la distribución del teclado en JetKVM coincida con la configuración del sistema operativo.",
"keyboard_layout_success": "La distribución del teclado se ha establecido correctamente en {layout}",
"keyboard_layout_title": "Distribución del teclado",
"keyboard_show_pressed_keys_description": "Mostrar las teclas presionadas actualmente en la barra de estado",
"keyboard_show_pressed_keys_title": "Mostrar teclas presionadas",
"keyboard_title": "Teclado",
"kvm_terminal": "Terminal KVM",
"last_online": "Última conexión {time}",
"learn_more": "Más información",
"load": "Cargar",
"loading": "Cargando…",
"local_auth_change_local_device_password_description": "Ingrese su contraseña actual y una nueva contraseña para actualizar la protección de su dispositivo local.",
"local_auth_change_local_device_password_title": "Cambiar la contraseña del dispositivo local",
"local_auth_confirm_new_password_label": "Confirmar nueva contraseña",
"local_auth_create_confirm_password_placeholder": "Vuelva a introducir su contraseña",
"local_auth_create_description": "Cree una contraseña para proteger su dispositivo contra acceso local no autorizado.",
"local_auth_create_new_password_label": "Nueva contraseña",
"local_auth_create_new_password_placeholder": "Introduzca una contraseña segura",
"local_auth_create_not_now_button": "Ahora no",
"local_auth_create_secure_button": "Dispositivo seguro",
"local_auth_create_title": "Protección de dispositivos locales",
"local_auth_current_password_label": "Contraseña actual",
"local_auth_disable_local_device_protection_description": "Ingrese su contraseña actual para deshabilitar la protección del dispositivo local.",
"local_auth_disable_local_device_protection_title": "Deshabilitar la protección del dispositivo local",
"local_auth_disable_protection_button": "Desactivar protección",
"local_auth_enter_current_password_placeholder": "Ingrese su contraseña actual",
"local_auth_enter_new_password_placeholder": "Introduzca una nueva contraseña segura",
"local_auth_error_changing_password": "Se produjo un error al cambiar la contraseña",
"local_auth_error_disabling_password": "Se produjo un error al deshabilitar la contraseña",
"local_auth_error_enter_current_password": "Por favor, introduzca su contraseña actual",
"local_auth_error_enter_new_password": "Por favor, introduzca una nueva contraseña",
"local_auth_error_enter_old_password": "Por favor, introduzca su contraseña anterior",
"local_auth_error_enter_password": "Por favor, introduzca una contraseña",
"local_auth_error_passwords_not_match": "Las contraseñas no coinciden",
"local_auth_error_setting_password": "Se produjo un error al configurar la contraseña",
"local_auth_new_password_label": "Nueva contraseña",
"local_auth_reenter_new_password_placeholder": "Vuelva a ingresar su nueva contraseña",
"local_auth_success_password_disabled_description": "Has desactivado correctamente la protección con contraseña para el acceso local. Recuerda que tu dispositivo ahora es menos seguro.",
"local_auth_success_password_disabled_title": "Protección con contraseña deshabilitada",
"local_auth_success_password_set_description": "Has configurado correctamente la protección local del dispositivo. Tu dispositivo ahora está protegido contra el acceso local no autorizado.",
"local_auth_success_password_set_title": "Contraseña establecida exitosamente",
"local_auth_success_password_updated_description": "Has cambiado correctamente la contraseña de protección de tu dispositivo local. Recuerda la nueva contraseña para acceder en el futuro.",
"local_auth_success_password_updated_title": "Contraseña actualizada exitosamente",
"local_auth_update_password_button": "Actualizar contraseña",
"locale_auto": "Auto",
"locale_change_success": "El idioma se cambió correctamente a {locale}",
"locale_da": "Danés",
"locale_de": "Alemán",
"locale_en": "Inglés",
"locale_es": "Español",
"locale_fr": "Francés",
"locale_it": "Italiano",
"locale_nb": "Noruego (bokmål)",
"locale_sv": "Sueco",
"locale_zh": "中文 (简体)",
"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_error": "Se produjo un error al iniciar sesión",
"login_forgot_password": "¿Has olvidado tu contraseña?",
"login_password_label": "Contraseña",
"login_welcome_back": "Bienvenido de nuevo a JetKVM",
"macro_add_step": "Agregar paso {maxed_out}",
"macro_at_least_one_step_keys_or_modifiers": "Al menos un paso debe tener claves o modificadores",
"macro_at_least_one_step_required": "Se requiere al menos un paso",
"macro_max_steps_error": "Solo puede agregar un máximo de {max} pasos por macro.",
"macro_max_steps_reached": "( {max} máx.)",
"macro_name_label": "Nombre de la macro",
"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_failed": "Se produjo un error al guardar.",
"macro_save_failed_error": "Se produjo un error al guardar: {error}.",
"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",
"macro_step_keys_description": "Máximo de {max} claves por paso.",
"macro_step_keys_label": "Teclas",
"macro_step_max_keys_reached": "Se alcanzó el máximo de claves",
"macro_step_modifiers_description": "¿Qué modificadores (Shift/Ctrl/Alt/Meta) se presionan durante este paso?",
"macro_step_modifiers_label": "Modificadores",
"macro_step_no_matching_keys_found": "No se encontraron claves coincidentes",
"macro_step_search_for_key": "Buscar tecla…",
"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_aria_add_new": "Agregar nueva macro",
"macros_aria_delete": "Eliminar macro {name}",
"macros_aria_duplicate": "Macro duplicada {name}",
"macros_aria_edit": "Editar macro {name}",
"macros_aria_move_down": "Mover {name} hacia abajo",
"macros_aria_move_up": "Mover {name} hacia arriba",
"macros_confirm_delete_description": "¿Seguro que desea eliminar \" {name} \"? Esta acción no se puede deshacer.",
"macros_confirm_delete_title": "Eliminar macro",
"macros_confirm_deleting": "Borrando…",
"macros_create_first_description": "Combine pulsaciones de teclas en una sola acción",
"macros_create_first_headline": "Crea tu primera macro",
"macros_created_success": "Macro \" {name} \" creada correctamente",
"macros_delay_only": "Solo retraso",
"macros_delete_confirm": "¿Seguro que desea eliminar esta macro? Esta acción no se puede deshacer.",
"macros_delete_macro": "Eliminar macro",
"macros_deleted_success": "Macro \" {name} \" eliminada correctamente",
"macros_deleting": "Eliminando",
"macros_duplicated_success": "Macro \" {name} \" duplicada correctamente",
"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_delete_error": "No se pudo eliminar la macro: {error}",
"macros_failed_duplicate": "No se pudo duplicar la macro",
"macros_failed_duplicate_error": "No se pudo duplicar la macro: {error}",
"macros_failed_reorder": "No se pudieron reordenar las macros",
"macros_failed_reorder_error": "No se pudieron reordenar las macros: {error}",
"macros_failed_update": "No se pudo actualizar la macro",
"macros_failed_update_error": "No se pudo actualizar la macro: {error}",
"macros_invalid_data": "Datos de macro no válidos",
"macros_loading": "Cargando macros…",
"macros_max_reached": "Máximo alcanzado",
"macros_maximum_macros_reached": "Ha alcanzado el número máximo de macros {maximum} permitidas.",
"macros_no_macros_available": "No hay macros disponibles",
"macros_order_updated": "La orden macro se actualizó correctamente",
"macros_title": "Macros del teclado",
"macros_updated_success": "Macro \" {name} \" actualizada correctamente",
"metric_not_supported": "Métrica no compatible",
"metric_waiting_for_data": "Esperando datos…",
"mount_add_file_to_get_started": "Añade un archivo para comenzar",
"mount_add_new_media": "Agregar nuevos medios",
"mount_available_storage": "Almacenamiento disponible",
"mount_button_back_to_overview": "Volver a la descripción general",
"mount_button_cancel_upload": "Cancelar carga",
"mount_button_continue_upload": "Continuar subiendo",
"mount_button_mount_file": "Montar archivo",
"mount_button_mount_url": "URL de montaje",
"mount_button_select": "Seleccionar",
"mount_button_showing_results": "Mostrando {from} a {to} de {total} resultados",
"mount_button_upload_new_image": "Subir una nueva imagen",
"mount_bytes_free": "{bytesFree} libre",
"mount_bytes_used": "{bytesUsed} usado",
"mount_calculating": "Calculando…",
"mount_click_to_select_file": "Haga clic para seleccionar un archivo",
"mount_click_to_select_incomplete": "Haga clic para seleccionar \" {name} \"",
"mount_confirm_delete": "¿Estás seguro de que deseas eliminar {name} ?",
"mount_continue_uploading_with_name": "Continuar cargando \" {name} \"",
"mount_error_delete_file": "Error al eliminar el archivo: {error}",
"mount_error_description": "Se produjo un error al intentar montar el medio. Inténtelo de nuevo.",
"mount_error_get_storage_space": "Error al obtener espacio de almacenamiento: {error}",
"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_mode_cdrom": "CD/DVD",
"mount_mode_disk": "Disco",
"mount_mounted_as": "Montado como",
"mount_mounted_from_storage": "Montado desde el almacenamiento JetKVM",
"mount_no_images_description": "Sube una imagen para iniciar el montaje de medios virtuales.",
"mount_no_images_title": "No hay imágenes disponibles",
"mount_no_mounted_media": "No hay medios montados",
"mount_percentage_used": "{percentageUsed} % usado",
"mount_please_select_file": "Seleccione el archivo \" {name} \" para continuar con la carga.",
"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_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",
"mount_upload_failed_rtc": "Error de carga: {error}",
"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_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",
"mount_view_device_description": "Seleccione una imagen para montar desde el almacenamiento JetKVM",
"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",
"mouse_alt_finger": "Dedo tocando una pantalla",
"mouse_alt_mouse": "Icono del ratón",
"mouse_description": "Configure el comportamiento del cursor y las opciones de interacción del dispositivo",
"mouse_hide_cursor_description": "Ocultar el cursor al enviar movimientos del mouse",
"mouse_hide_cursor_title": "Ocultar el cursor",
"mouse_jiggler_config_updated": "La configuración de Jiggler se actualizó correctamente",
"mouse_jiggler_custom": "Personalizado",
"mouse_jiggler_description": "Simular el movimiento de un ratón de ordenador",
"mouse_jiggler_disabled": "Desactivado",
"mouse_jiggler_error_config": "Se produjo un error al configurar el jiggler.",
"mouse_jiggler_failed_state": "No se pudo establecer el estado del jiggler: {error}",
"mouse_jiggler_frequent": "Frecuente - 30s",
"mouse_jiggler_invalid_cron": "Expresión cron no válida. Verifique el formato de su programación (p. ej., '0 * * * * *' para cada minuto).",
"mouse_jiggler_light": "Ligero - 5 min",
"mouse_jiggler_standard": "Estándar - 1 m",
"mouse_jiggler_title": "Jiggler",
"mouse_mode_absolute": "Absoluto",
"mouse_mode_absolute_description": "Más preciso",
"mouse_mode_relative": "Relativo",
"mouse_mode_relative_description": "Más compatible con sistemas antiguos",
"mouse_modes_description": "Elija el modo de entrada del mouse",
"mouse_modes_title": "Modos",
"mouse_scroll_high": "Alto",
"mouse_scroll_low": "Bajo",
"mouse_scroll_medium": "Medio",
"mouse_scroll_off": "Apagado",
"mouse_scroll_throttling_description": "Reducir la frecuencia de los eventos de desplazamiento",
"mouse_scroll_throttling_title": "Regulación del desplazamiento",
"mouse_scroll_very_high": "Muy alto",
"mouse_title": "Ratón",
"network_custom_domain": "Dominio personalizado",
"network_description": "Configure los 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_lease_renew_confirm": "Renovar el contrato de arrendamiento",
"network_dhcp_lease_renew_confirm_description": "Esto solicitará una nueva dirección IP a su servidor DHCP. Es posible que su dispositivo pierda temporalmente la conexión a la red durante este proceso.",
"network_dhcp_lease_renew_confirm_new_a": "Si recibe una nueva dirección IP",
"network_dhcp_lease_renew_confirm_new_b": "Es posible que necesites volver a conectarte usando la nueva dirección",
"network_dhcp_lease_renew_failed": "No se pudo renovar el contrato de arrendamiento: {error}",
"network_dhcp_lease_renew_success": "Se renovó la concesión de DHCP",
"network_domain_custom": "Personalizado",
"network_domain_description": "Sufijo de dominio de red para el dispositivo",
"network_domain_dhcp_provided": "DHCP proporcionado",
"network_domain_local": ".local",
"network_domain_title": "Dominio",
"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_invalid": "Dirección IPv4 no válida",
"network_ipv4_invalid_cidr": "Notación CIDR no válida para la dirección 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_addresses_header": "Direcciones IPv6",
"network_ipv6_cidr_suggestion": "Utilice la notación CIDR (por ejemplo, 2001:db8::1/64)",
"network_ipv6_dns": "DNS IPv6",
"network_ipv6_flag_dad_failed": "Papá falló",
"network_ipv6_flag_deprecated": "Obsoleto",
"network_ipv6_gateway": "Puerta de enlace IPv6",
"network_ipv6_information": "Información de IPv6",
"network_ipv6_invalid": "Dirección IPv6 no válida",
"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_prefix": "Prefijo IP",
"network_ipv6_prefix_invalid": "El prefijo debe estar entre 0 y 128",
"network_ll_dp_all": "Todo",
"network_ll_dp_basic": "Básico",
"network_ll_dp_description": "Controla qué TLV se enviarán mediante LLDP (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",
"network_mdns_description": "Controlar el modo operativo mDNS (DNS multidifusión)",
"network_mdns_disabled": "Desactivado",
"network_mdns_ipv4_only": "Sólo IPv4",
"network_mdns_ipv6_only": "Sólo IPv6",
"network_mdns_title": "mDNS",
"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_add_dns": "Agregar servidor DNS",
"network_settings_load_error": "No se pudo cargar la configuración de red: {error}",
"network_static_ipv4_header": "Configuración estática de IPv4",
"network_static_ipv6_header": "Configuración estática de IPv6",
"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",
"network_time_sync_ntp_only": "Sólo NTP",
"network_time_sync_title": "Sincronización horaria",
"network_title": "Red",
"never_seen_online": "Nunca visto en línea",
"next": "Siguiente",
"no_results_found": "No se encontraron resultados",
"not_applicable": "N / A",
"not_available": "N / A",
"not_found": "No encontrado",
"ntp_servers": "Servidores NTP",
"oh_no": "¡Oh, no!",
"online": "En línea",
"other_session_detected": "Otra sesión activa detectada",
"other_session_take_over": " Solo se admite una sesión activa a la vez. ¿Desea controlar esta sesión?",
"other_session_use_here_button": "Usar aquí",
"page_not_found_description": "La página que estás buscando no existe.",
"paste_modal_confirm_paste": "Confirmar Pegar",
"paste_modal_delay_between_keys": "Retraso entre teclas",
"paste_modal_delay_out_of_range": "El retraso debe estar entre {min} y {max}",
"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_sending_using_layout": "Envío de texto mediante la distribución del teclado: {iso} - {name}",
"paste_text": "Pegar texto",
"paste_text_description": "Pegue el texto de su cliente al host remoto",
"peer_connection_closed": "Cerrado",
"peer_connection_closing": "Cierre",
"peer_connection_connected": "Conectado",
"peer_connection_connecting": "Conectando",
"peer_connection_disconnected": "Desconectado",
"peer_connection_error": "Error de conexión",
"peer_connection_failed": "La conexión falló",
"peer_connection_new": "Conectando",
"previous": "Anterior",
"register_device_error": "Se produjo un error {error} al registrar su dispositivo.",
"register_device_finish_button": "Finalizar configuración",
"register_device_name_description": "Ponle un nombre a tu dispositivo para que puedas identificarlo fácilmente más tarde. Puedes cambiarlo en cualquier momento.",
"register_device_name_label": "Nombre del dispositivo",
"register_device_name_placeholder": "Servidor multimedia Plex",
"register_device_no_name": "Por favor especifique un nombre",
"rename_device": "Cambiar el nombre del dispositivo",
"rename_device_description": "Nombra correctamente tu dispositivo para identificarlo fácilmente.",
"rename_device_error": "Se produjo un error {error} al cambiar el nombre de su dispositivo.",
"rename_device_headline": "Cambiar nombre {name}",
"rename_device_new_name_label": "Nuevo nombre del dispositivo",
"rename_device_new_name_placeholder": "Servidor multimedia Plex",
"rename_device_no_name": "Por favor especifique un nombre",
"retry": "Reintentar",
"saving": "Guardando…",
"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_set_settings_error": "No se pudieron establecer los ajustes de la consola serial en {settings} : {error}",
"serial_console_stop_bits": "Bits de parada",
"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...",
"settings_access": "Acceso",
"settings_advanced": "Avanzado",
"settings_appearance": "Apariencia",
"settings_back_to_kvm": "Volver a KVM",
"settings_general": "General",
"settings_hardware": "Hardware",
"settings_keyboard": "Teclado",
"settings_keyboard_macros": "Macros del teclado",
"settings_mouse": "Ratón",
"settings_network": "Red",
"settings_video": "Video",
"something_went_wrong": "Algo salió mal. Inténtalo de nuevo más tarde o contacta con el servicio de asistencia.",
"step_counter_step": "Paso {step}",
"subnet_mask": "Máscara de subred",
"time_division_days": "días",
"time_division_hours": "horas",
"time_division_minutes": "minutos",
"time_division_months": "meses",
"time_division_seconds": "segundos",
"time_division_weeks": "semanas",
"time_division_years": "años",
"troubleshoot_connection": "Solucionar problemas de conexión",
"unknown_error": "Error desconocido",
"update_in_progress": "Actualización en progreso",
"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",
"usb_config_failed_load": "No se pudo cargar la configuración USB: {error}",
"usb_config_failed_set": "No se pudo establecer la configuración USB: {error}",
"usb_config_identifiers_description": "Identificadores de dispositivos USB expuestos al ordenador de destino",
"usb_config_identifiers_title": "Identificadores",
"usb_config_logitech": "Adaptador universal Logitech",
"usb_config_manufacturer_label": "Fabricante",
"usb_config_manufacturer_placeholder": "Introduzca el fabricante",
"usb_config_microsoft": "Teclado multimedia inalámbrico de Microsoft",
"usb_config_product_id_label": "Identificación del producto",
"usb_config_product_id_placeholder": "Ingrese el ID del producto",
"usb_config_product_name_label": "Nombre del producto",
"usb_config_product_name_placeholder": "Introduzca el nombre del producto",
"usb_config_restore_default": "Restaurar a valores predeterminados",
"usb_config_serial_number_label": "Número de serie",
"usb_config_serial_number_placeholder": "Introduzca el número de serie",
"usb_config_set_success": "Configuración USB establecida en {manufacturer} {product}",
"usb_config_update_identifiers": "Actualizar identificadores USB",
"usb_config_vendor_id_label": "Identificación del proveedor",
"usb_config_vendor_id_placeholder": "Ingrese el ID del proveedor",
"usb_device_classes_description": "Clases de dispositivos USB en el dispositivo compuesto",
"usb_device_classes_title": "Clases",
"usb_device_custom": "Costumbre",
"usb_device_description": "Dispositivos USB para emular en la computadora de destino",
"usb_device_enable_absolute_mouse_description": "Habilitar el puntero absoluto del ratón",
"usb_device_enable_absolute_mouse_title": "Habilitar el puntero absoluto del ratón",
"usb_device_enable_keyboard_description": "Habilitar el teclado",
"usb_device_enable_keyboard_title": "Habilitar el teclado",
"usb_device_enable_mass_storage_description": "A veces puede ser necesario desactivarlo para evitar problemas con ciertos dispositivos.",
"usb_device_enable_mass_storage_title": "Habilitar almacenamiento masivo USB",
"usb_device_enable_relative_mouse_description": "Habilitar el ratón relativo",
"usb_device_enable_relative_mouse_title": "Habilitar el ratón relativo",
"usb_device_failed_load": "No se pudieron cargar los dispositivos USB: {error}",
"usb_device_failed_set": "No se pudieron configurar los dispositivos USB: {error}",
"usb_device_keyboard_mouse_and_mass_storage": "Teclado, ratón y almacenamiento masivo",
"usb_device_keyboard_only": "Sólo teclado",
"usb_device_restore_default": "Restaurar a valores predeterminados",
"usb_device_title": "Dispositivo USB",
"usb_device_update_classes": "Actualizar clases USB",
"usb_device_updated": "Dispositivos USB actualizados",
"usb_state_connected": "Conectado",
"usb_state_connecting": "Conectando",
"usb_state_disconnected": "Desconectado",
"usb_state_low_power_mode": "Modo de bajo consumo",
"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)",
"video_brightness_title": "Brillo",
"video_contrast_description": "Nivel de contraste ( {value} x)",
"video_contrast_title": "Contraste",
"video_custom_edid_description": "El EDID detalla la compatibilidad de los modos de vídeo. La configuración predeterminada funciona en la mayoría de los casos, pero es posible que sea necesario ajustar la UEFI/BIOS específica.",
"video_custom_edid_title": "EDID personalizado",
"video_debugging_info_description": "Información de depuración para vídeo",
"video_debugging_info_title": "Información de depuración",
"video_description": "Configure los ajustes de pantalla y EDID para una compatibilidad óptima",
"video_edid_acer_b246wl": "Acer B246WL, 1920x1200",
"video_edid_asus_pa248qv": "ASUS PA248QV, 1920x1200",
"video_edid_custom": "Costumbre",
"video_edid_dell_d2721h": "DELL D2721H, 1920 x 1080",
"video_edid_dell_idrac": "EDID de DELL IDRAC, 1280 x 1024",
"video_edid_description": "Ajuste la configuración EDID para la pantalla",
"video_edid_file_label": "Archivo EDID",
"video_edid_jetkvm_default": "JetKVM predeterminado",
"video_edid_set_success": "EDID establecido correctamente en {edid}",
"video_edid_title": "EDID",
"video_enhancement_description": "Ajuste la configuración de color para que la salida de video sea más vibrante y colorida.",
"video_enhancement_title": "Mejoras de vídeo",
"video_failed_get_debug_info": "No se pudo obtener la información de depuración: {error}",
"video_failed_get_edid": "No se pudo obtener el EDID: {error}",
"video_failed_set_edid": "No se pudo establecer EDID: {error}",
"video_failed_set_stream_quality": "No se pudo establecer la calidad de la transmisión: {error}",
"video_get_debugging_info": "Obtener información de depuración",
"video_overlay_autoplay_permissions_required": "Se requieren permisos de reproducción automática",
"video_overlay_conn_check_cables": "Revise todas las conexiones de cables para detectar cables sueltos o dañados.",
"video_overlay_conn_ensure_network": "Asegúrese de que su conexión de red sea estable y activa",
"video_overlay_conn_restart": "Intente reiniciar el dispositivo y el ordenador",
"video_overlay_conn_verify_power": "Verifique que el dispositivo esté encendido y conectado correctamente",
"video_overlay_connection_issue_title": "Problema de conexión detectado",
"video_overlay_enable_autoplay_settings": "Ajuste la configuración del navegador para habilitar la reproducción automática.",
"video_overlay_hdmi_error_title": "Se detectó un error en la señal HDMI.",
"video_overlay_hdmi_incompatible_resolution": "Configuraciones de resolución o frecuencia de actualización incompatibles",
"video_overlay_hdmi_loose_faulty": "Una conexión HDMI suelta o defectuosa",
"video_overlay_hdmi_source_issue": "Problemas con la salida HDMI del dispositivo fuente",
"video_overlay_learn_more": "Más información",
"video_overlay_loading_stream": "Cargando transmisión de video…",
"video_overlay_manually_start_stream": "Iniciar transmisión manualmente",
"video_overlay_no_hdmi_adapter_compat": "Si utiliza un adaptador, asegúrese de que sea compatible y funcione correctamente.",
"video_overlay_no_hdmi_ensure_cable": "Asegúrese de que el cable HDMI esté conectado de forma segura en ambos extremos",
"video_overlay_no_hdmi_ensure_power": "Asegúrese de que el dispositivo fuente esté encendido y emitiendo una señal",
"video_overlay_no_hdmi_signal": "No se detectó señal HDMI.",
"video_overlay_pointerlock_click_to_enable": "Haga clic en el vídeo para habilitar el control del mouse.",
"video_overlay_reboot_device_is_rebooting": "El dispositivo se está reiniciando",
"video_overlay_reboot_different_ip_message": "Es posible que el dispositivo se haya reiniciado con una dirección IP diferente. Revise la pantalla física del JetKVM para encontrar la dirección IP actual y volver a conectarlo.",
"video_overlay_reboot_please_wait_message": "Espere mientras el dispositivo se reinicia. Suele tardar entre 20 y 30 segundos.",
"video_overlay_reboot_timeout_message": "Tiempo de reconexión automática agotado",
"video_overlay_reboot_unable_to_reconnect": "No se puede reconectar",
"video_overlay_reboot_waiting_for_restart": "Esperando que el dispositivo se reinicie…",
"video_overlay_retrying_connection": "Reintentando conexión…",
"video_overlay_troubleshooting_guide": "Guía de solución de problemas",
"video_overlay_try_again": "Intentar otra vez",
"video_pointer_lock_disabled": "Bloqueo del puntero deshabilitado",
"video_pointer_lock_enabled": "Bloqueo del puntero habilitado: presione Escape para desbloquear",
"video_quality_high": "Alto",
"video_quality_low": "Bajo",
"video_quality_medium": "Medio",
"video_reset_to_default": "Restablecer a valores predeterminados",
"video_restore_to_default": "Restaurar a valores predeterminados",
"video_saturation_description": "Saturación de color ( {value} x)",
"video_saturation_title": "Saturación",
"video_set_custom_edid": "Establecer EDID personalizado",
"video_stream_quality_description": "Ajustar la calidad de la transmisión de vídeo",
"video_stream_quality_set": "Calidad de transmisión establecida en {quality}",
"video_stream_quality_title": "Calidad de la transmisión",
"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",
"wake_on_lan_add_device_save_device": "Guardar dispositivo",
"wake_on_lan_description": "Envíe un paquete mágico para activar un dispositivo remoto.",
"wake_on_lan_device_list_add_new_device": "Agregar nuevo dispositivo",
"wake_on_lan_device_list_delete_device": "Eliminar dispositivo",
"wake_on_lan_device_list_wake": "Despertar",
"wake_on_lan_empty_add_device_to_start": "Agregue un dispositivo para comenzar a usar Wake-on-LAN",
"wake_on_lan_empty_add_new_device": "Agregar nuevo dispositivo",
"wake_on_lan_empty_no_devices_added": "No hay dispositivos añadidos",
"wake_on_lan_failed_add_device": "No se pudo agregar el dispositivo",
"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"
}

View File

@ -0,0 +1,901 @@
{
"$schema": "https://inlang.com/schema/inlang-message-format",
"access_adopt_kvm": "Adopter KVM vers le Cloud",
"access_adopted_message": "Votre appareil est adopté dans le Cloud",
"access_auth_mode_no_password": "Mode actuel : Aucun mot de passe",
"access_auth_mode_password": "Mode actuel : protégé par mot de passe",
"access_authentication_mode_title": "Mode d'authentification",
"access_certificate_label": "Certificat",
"access_change_password_button": "Changer le mot de passe",
"access_change_password_description": "Mettez à jour le mot de passe d'accès de votre appareil",
"access_change_password_title": "Changer le mot de passe",
"access_cloud_api_url_label": "URL de l'API Cloud",
"access_cloud_app_url_label": "URL de l'application cloud",
"access_cloud_provider_description": "Sélectionnez le fournisseur de cloud pour votre appareil",
"access_cloud_provider_title": "Fournisseur de cloud",
"access_cloud_security_title": "Sécurité du cloud",
"access_confirm_deregister": "Êtes-vous sûr de vouloir désenregistrer cet appareil ?",
"access_deregister": "Se désinscrire du Cloud",
"access_description": "Gérer le contrôle d'accès de l'appareil",
"access_disable_protection": "Désactiver la protection",
"access_enable_password": "Activer le mot de passe",
"access_failed_deregister": "Échec de la désinscription du périphérique : {error}",
"access_failed_update_cloud_url": "Échec de la mise à jour de l'URL du cloud : {error}",
"access_failed_update_tls": "Échec de la mise à jour des paramètres TLS : {error}",
"access_github_link": "GitHub",
"access_https_description": "Configurer un accès HTTPS sécurisé à votre appareil",
"access_https_mode_title": "Mode HTTPS",
"access_learn_security": "En savoir plus sur la sécurité cloud",
"access_local_description": "Gérer le mode d'accès local à l'appareil",
"access_local_title": "Local",
"access_no_device_id": "Aucun identifiant d'appareil disponible",
"access_private_key_description": "Pour des raisons de sécurité, il ne sera pas affiché après l'enregistrement.",
"access_private_key_label": "Clé privée",
"access_provider_custom": "Personnalisé",
"access_provider_jetkvm": "JetKVM Cloud",
"access_remote_description": "Gérer le mode d'accès à distance à l'appareil",
"access_security_encryption": "Chiffrement de bout en bout utilisant WebRTC (DTLS et SRTP)",
"access_security_oidc": "Authentification OIDC (OpenID Connect)",
"access_security_open_source": "Tous les composants cloud sont open source et disponibles sur GitHub.",
"access_security_streams": "Tous les flux sont cryptés en transit",
"access_security_zero_trust": "Modèle de sécurité Zero Trust",
"access_title": "Accéder",
"access_tls_certificate_description": "Collez votre certificat TLS ci-dessous. Pour les chaînes de certificats, incluez la chaîne complète (certificats feuille, intermédiaire et racine).",
"access_tls_certificate_title": "Certificat TLS",
"access_tls_custom": "Personnalisé",
"access_tls_disabled": "Désactivé",
"access_tls_self_signed": "Auto-signé",
"access_tls_updated": "Les paramètres TLS ont été mis à jour avec succès",
"access_update_tls_settings": "Mettre à jour les paramètres TLS",
"action_bar_connection_stats": "Statistiques de connexion",
"action_bar_extension": "Extension",
"action_bar_fullscreen": "Plein écran",
"action_bar_settings": "Paramètres",
"action_bar_virtual_keyboard": "Clavier virtuel",
"action_bar_virtual_media": "Médias virtuels",
"action_bar_wake_on_lan": "Réveil sur LAN",
"action_bar_web_terminal": "Terminal Web",
"advanced_description": "Accéder à des paramètres supplémentaires pour le dépannage et la personnalisation",
"advanced_dev_channel_description": "Recevez les premières mises à jour du canal de développement",
"advanced_dev_channel_title": "Mises à jour du canal de développement",
"advanced_developer_mode_description": "Activer les fonctionnalités avancées pour les développeurs",
"advanced_developer_mode_enabled_title": "Mode développeur activé",
"advanced_developer_mode_title": "Mode développeur",
"advanced_developer_mode_warning_advanced": "Réservé aux utilisateurs avancés. Non destiné à une utilisation en production.",
"advanced_developer_mode_warning_risks": "À utiliser uniquement si vous comprenez les risques",
"advanced_developer_mode_warning_security": "La sécurité est affaiblie lorsqu'elle est active",
"advanced_disable_usb_emulation": "Désactiver l'émulation USB",
"advanced_enable_usb_emulation": "Activer l'émulation USB",
"advanced_error_loopback_disable": "Échec de la désactivation du mode de bouclage uniquement : {error}",
"advanced_error_loopback_enable": "Échec de l'activation du mode de bouclage uniquement : {error}",
"advanced_error_reset_config": "Échec de la réinitialisation de la configuration : {error}",
"advanced_error_set_dev_channel": "Échec de la définition de l'état du canal de développement : {error}",
"advanced_error_set_dev_mode": "Échec de la définition du mode de développement : {error}",
"advanced_error_update_ssh_key": "Échec de la mise à jour de la clé SSH : {error}",
"advanced_error_usb_emulation_disable": "Échec de la désactivation de l'émulation USB : {error}",
"advanced_error_usb_emulation_enable": "Échec de l'activation de l'émulation USB : {error}",
"advanced_loopback_only_description": "Restreindre l'accès à l'interface Web à l'hôte local uniquement (127.0.0.1)",
"advanced_loopback_only_title": "Mode de bouclage uniquement",
"advanced_loopback_warning_before": "Avant d'activer cette fonctionnalité, assurez-vous d'avoir :",
"advanced_loopback_warning_cloud": "Accès au cloud activé et fonctionnel",
"advanced_loopback_warning_confirm": "Je comprends, j'active quand même",
"advanced_loopback_warning_description": "Avertissement : cela restreindra l'accès à l'interface Web à localhost (127.0.0.1) uniquement.",
"advanced_loopback_warning_ssh": "Accès SSH configuré et testé",
"advanced_loopback_warning_title": "Activer le mode de bouclage uniquement ?",
"advanced_reset_config_button": "Réinitialiser la configuration",
"advanced_reset_config_description": "Réinitialiser la configuration par défaut. Cela vous déconnectera.",
"advanced_reset_config_title": "Réinitialiser la configuration",
"advanced_ssh_access_description": "Ajoutez votre clé publique SSH pour activer l'accès à distance sécurisé à l'appareil",
"advanced_ssh_access_title": "Accès SSH",
"advanced_ssh_default_user": "L'utilisateur SSH par défaut est",
"advanced_ssh_public_key_label": "Clé publique SSH",
"advanced_ssh_public_key_placeholder": "Entrez votre clé publique SSH",
"advanced_success_loopback_disabled": "Mode de bouclage désactivé. Redémarrez votre appareil pour appliquer le mode de bouclage.",
"advanced_success_loopback_enabled": "Mode de bouclage activé. Redémarrez votre appareil pour appliquer la fonction.",
"advanced_success_reset_config": "La configuration par défaut a été réinitialisée avec succès",
"advanced_success_update_ssh_key": "Clé SSH mise à jour avec succès",
"advanced_title": "Avancé",
"advanced_troubleshooting_mode_description": "Outils de diagnostic et contrôles supplémentaires à des fins de dépannage et de développement",
"advanced_troubleshooting_mode_title": "Mode de dépannage",
"advanced_update_ssh_key_button": "Mettre à jour la clé SSH",
"advanced_usb_emulation_description": "Contrôler l'état de l'émulation USB",
"advanced_usb_emulation_title": "Émulation USB",
"already_adopted_new_owner": "Si vous êtes le nouveau propriétaire, veuillez demander à l'ancien propriétaire de désenregistrer l'appareil de son compte dans le tableau de bord cloud. Si vous pensez qu'il s'agit d'une erreur, contactez notre équipe d'assistance pour obtenir de l'aide.",
"already_adopted_other_user": "Cet appareil est actuellement enregistré auprès d'un autre utilisateur dans notre tableau de bord cloud.",
"already_adopted_return_to_dashboard": "Retour au tableau de bord",
"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": "Clair",
"appearance_theme_system": "Système",
"appearance_title": "Apparence",
"attach": "Attacher",
"atx_power_control_get_state_error": "Échec de l'obtention de l'état d'alimentation ATX : {error}",
"atx_power_control_hdd_led": "Voyant du disque dur",
"atx_power_control_long_power_button": "Appui long",
"atx_power_control_power_button": "Alimentation",
"atx_power_control_power_led": "LED d'alimentation",
"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_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_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_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_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_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_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_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": "Retour",
"back_to_devices": "Retour aux appareils",
"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é.",
"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_connection_description": "La connexion entre le client et le JetKVM.",
"connection_stats_frames_per_second": "Images par seconde",
"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_network_stability_description": "La stabilité du flux de paquets vidéo entrants sur le réseau.",
"connection_stats_packets_lost": "Paquets perdus",
"connection_stats_packets_lost_description": "Nombre de paquets vidéo RTP entrants perdus.",
"connection_stats_playback_delay": "Délai de lecture",
"connection_stats_playback_delay_description": "Retard ajouté par le tampon de gigue pour fluidifier la lecture lorsque les images arrivent de manière inégale.",
"connection_stats_round_trip_time": "Temps de trajet aller-retour",
"connection_stats_round_trip_time_description": "Temps de trajet aller-retour pour la paire de candidats ICE actifs entre pairs.",
"connection_stats_sidebar": "Statistiques de connexion",
"connection_stats_unit_frames_per_second": " fps",
"connection_stats_unit_milliseconds": " ms",
"connection_stats_unit_packets": " paquets",
"connection_stats_video": "Vidéo",
"connection_stats_video_description": "Le flux vidéo du JetKVM vers le client.",
"continue": "Continuer",
"creating_peer_connection": "Créer des liens entre pairs…",
"dc_power_control_current": "Actuel",
"dc_power_control_current_unit": "A",
"dc_power_control_get_state_error": "Échec de l'obtention de l'état d'alimentation CC : {error}",
"dc_power_control_power": "Alimentation",
"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_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",
"delete": "Supprimer",
"deregister_cloud_devices": "Appareils Cloud",
"deregister_description": "Cela supprimera l'appareil de votre compte cloud et révoquera l'accès à distance. Veuillez noter que l'accès local restera possible.",
"deregister_error": "Une erreur {status} s'est produite lors de l'annulation de l'enregistrement de votre appareil. Veuillez réessayer.",
"deregister_from_cloud": "Se désinscrire du Cloud",
"deregister_headline": "Désinscrivez {device} de votre compte cloud",
"detach": "Détacher",
"dhcp_empty_lease_description": "Nous n'avons pas encore reçu d'informations sur le bail DHCP de l'appareil.",
"dhcp_empty_lease_headline": "Aucune information sur le bail DHCP",
"dhcp_lease_boot_file": "Fichier de démarrage",
"dhcp_lease_boot_next_server": "Démarrer le serveur suivant",
"dhcp_lease_boot_server_name": "Nom du serveur de démarrage",
"dhcp_lease_broadcast": "Diffuser",
"dhcp_lease_domain": "Domaine",
"dhcp_lease_gateway": "Passerelle",
"dhcp_lease_header": "Informations sur le bail DHCP",
"dhcp_lease_hostname": "Nom d'hôte",
"dhcp_lease_lease_expires": "Le bail expire",
"dhcp_lease_maximum_transfer_unit": "MTU",
"dhcp_lease_renew": "Renouveler le bail DHCP",
"dhcp_lease_time_to_live": "TTL",
"dhcp_server": "Serveur DHCP",
"dns_servers": "Serveurs DNS",
"establishing_secure_connection": "Établissement dune connexion sécurisée…",
"experimental": "Expérimental",
"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",
"extensions_atx_power_control_description": "Contrôlez l'état d'alimentation de votre machine via le contrôle d'alimentation ATX.",
"extensions_dc_power_control": "Contrôle de l'alimentation CC",
"extensions_dc_power_control_description": "Contrôlez votre extension d'alimentation CC",
"extensions_popover_extensions": "Extensions",
"gathering_ice_candidates": "Rassemblement des candidats de l'ICE…",
"general_app_version": "Application : {version}",
"general_auto_update_description": "Mettre à jour automatiquement l'appareil vers la dernière version",
"general_auto_update_error": "Échec de la définition de la mise à jour automatique : {error}",
"general_auto_update_title": "Mise à jour automatique",
"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_no_button": "Non",
"general_reboot_title": "Redémarrer JetKVM",
"general_reboot_yes_button": "Oui",
"general_system_version": "Système : {version}",
"general_title": "Général",
"general_update_app_update_title": "Mise à jour de l'application",
"general_update_application_type": "Application",
"general_update_available_description": "Une nouvelle mise à jour est disponible pour améliorer les performances du système et la compatibilité. Nous vous recommandons de la mettre à jour pour garantir le bon fonctionnement de votre système.",
"general_update_available_title": "Mise à jour disponible",
"general_update_background_button": "Mise à jour en arrière-plan",
"general_update_check_again_button": "Revérifier",
"general_update_checking_description": "Nous veillons à ce que votre appareil dispose des dernières fonctionnalités et améliorations.",
"general_update_checking_title": "Vérification des mises à jour…",
"general_update_completed_description": "Votre appareil a été mis à jour avec succès vers la dernière version. Profitez des nouvelles fonctionnalités et améliorations !",
"general_update_completed_title": "Mise à jour terminée avec succès",
"general_update_error_description": "Une erreur s'est produite lors de la mise à jour de votre appareil. Veuillez réessayer ultérieurement.",
"general_update_error_details": "Détails de l'erreur : {errorMessage}",
"general_update_error_title": "Erreur de mise à jour",
"general_update_later_button": "Faire plus tard",
"general_update_now_button": "Mettre à jour maintenant",
"general_update_rebooting": "Redémarrage pour terminer la mise à jour…",
"general_update_status_awaiting_reboot": "En attente de redémarrage",
"general_update_status_downloading": "Téléchargement de la mise à jour {update_type} …",
"general_update_status_fetching": "Récupération des informations de mise à jour…",
"general_update_status_installing": "Installation de la mise à jour {update_type} …",
"general_update_status_progress": "{part} progression",
"general_update_status_verifying": "Vérification de la mise à jour de {update_type} …",
"general_update_system_type": "Système",
"general_update_system_update_title": "Mise à jour du système Linux",
"general_update_up_to_date_description": "Votre système utilise la dernière version. Aucune mise à jour n'est actuellement disponible.",
"general_update_up_to_date_title": "Le système est à jour",
"general_update_updating_description": "Veuillez ne pas éteindre votre appareil. Ce processus peut prendre quelques minutes.",
"general_update_updating_title": "Mise à jour de votre appareil",
"getting_remote_session_description": "Obtention d'{attempt} description de session à distance",
"hardware_backlight_settings_error": "Échec de la définition des paramètres de rétroéclairage : {error}",
"hardware_backlight_settings_get_error": "Échec de l'obtention des paramètres de rétroéclairage : {error}",
"hardware_backlight_settings_success": "Les paramètres de rétroéclairage ont été mis à jour avec succès",
"hardware_dim_display_after_description": "Définir le délai d'attente avant de réduire la luminosité de l'écran",
"hardware_dim_display_after_title": "Affichage atténué après",
"hardware_display_brightness_description": "Régler la luminosité de l'écran",
"hardware_display_brightness_high": "Haut",
"hardware_display_brightness_low": "Faible",
"hardware_display_brightness_medium": "Moyen",
"hardware_display_brightness_off": "Désactivé",
"hardware_display_brightness_title": "Luminosité de l'écran",
"hardware_display_orientation_description": "Définir l'orientation de l'affichage",
"hardware_display_orientation_error": "Échec de la définition de l'orientation d'affichage : {error}",
"hardware_display_orientation_inverted": "Inversé",
"hardware_display_orientation_normal": "Normal",
"hardware_display_orientation_success": "L'orientation de l'affichage a été mise à jour avec succès",
"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_power_saving_description": "Réduisez la consommation d'énergie lorsque vous ne l'utilisez pas",
"hardware_power_saving_disabled": "Mode d'économie d'énergie désactivé",
"hardware_power_saving_enabled": "Mode d'économie d'énergie activé",
"hardware_power_saving_failed_error": "Échec de la définition du mode d'économie d'énergie : {error}",
"hardware_power_saving_hdmi_sleep_description": "Désactiver la capture après 90 secondes d'inactivité",
"hardware_power_saving_hdmi_sleep_title": "Mode veille HDMI",
"hardware_power_saving_title": "Économie d'énergie",
"hardware_time_10_minutes": "10 minutes",
"hardware_time_1_hour": "1 heure",
"hardware_time_1_minute": "1 minute",
"hardware_time_30_minutes": "30 minutes",
"hardware_time_5_minutes": "5 minutes",
"hardware_time_never": "Jamais",
"hardware_title": "Matériel",
"hardware_turn_off_display_after_description": "Période d'inactivité avant que l'écran ne s'éteigne automatiquement",
"hardware_turn_off_display_after_title": "Désactiver l'affichage après",
"hide": "Masquer",
"ice_gathering_completed": "Rassemblement ICE terminé",
"info_caps_lock": "Verrouillage des majuscules",
"info_compose": "Composer",
"info_hdmi_state": "État HDMI :",
"info_hidrpc_state": "État HidRPC :",
"info_kana": "Kana",
"info_keys": "Clés :",
"info_last_move": "Dernier mouvement :",
"info_num_lock": "Verr Num",
"info_paste_enabled": "Activé",
"info_paste_mode": "Mode Coller :",
"info_pointer": "Pointeur :",
"info_relayed_by_cloudflare": "Relayé par Cloudflare",
"info_resolution": "Résolution :",
"info_scroll_lock": "Verrouillage du défilement",
"info_shift": "Maj",
"info_usb_state": "État USB :",
"info_video_size": "Taille de la vidéo :",
"input_disabled": "Entrée désactivée",
"invalid_password": "Mot de passe invalide",
"ip_address": "Adresse IP",
"ipv6_address_label": "Adresse",
"ipv6_gateway": "Passerelle",
"ipv6_information": "Informations IPv6",
"ipv6_link_local": "Lien local",
"ipv6_preferred_lifetime": "Durée de vie préférée",
"ipv6_valid_lifetime": "Valable à vie",
"jetkvm_description": "JetKVM combine un matériel puissant avec un logiciel intuitif pour offrir une expérience de contrôle à distance transparente.",
"jetkvm_device": "Périphérique JetKVM",
"jetkvm_logo": "Logo JetKVM",
"jetkvm_setup": "Configurez votre JetKVM",
"jiggler_cron_schedule_description": "Expression Cron pour la planification",
"jiggler_cron_schedule_label": "Planification Cron",
"jiggler_example_business_hours_early": "Heures d'ouverture 8-17",
"jiggler_example_business_hours_late": "Horaires d'ouverture 9-17",
"jiggler_examples_label": "Exemples",
"jiggler_inactivity_limit_description": "Temps d'inactivité avant le tremblement",
"jiggler_inactivity_limit_label": "Limite d'inactivité en secondes",
"jiggler_more_examples": "Voir plus d'exemples",
"jiggler_random_delay_description": "Pour éviter les modèles reconnaissables",
"jiggler_random_delay_label": "Délai aléatoire",
"jiggler_save_jiggler_config": "Enregistrer la configuration de Jiggler",
"jiggler_timezone_description": "Fuseau horaire pour la planification cron",
"jiggler_timezone_label": "Fuseau horaire",
"keyboard_description": "Configurer les paramètres du clavier pour votre appareil",
"keyboard_layout_description": "Disposition du clavier du système d'exploitation cible",
"keyboard_layout_error": "Échec de la définition de la disposition du clavier : {error}",
"keyboard_layout_long_description": "Le clavier virtuel, le collage de texte et les macros clavier envoient des frappes de touches individuelles au périphérique cible. La disposition du clavier détermine les codes de touches envoyés. Assurez-vous que la disposition du clavier dans JetKVM correspond aux paramètres du système d'exploitation.",
"keyboard_layout_success": "La disposition du clavier a été définie avec succès sur {layout}",
"keyboard_layout_title": "Disposition du clavier",
"keyboard_show_pressed_keys_description": "Afficher les touches actuellement enfoncées dans la barre d'état",
"keyboard_show_pressed_keys_title": "Afficher les touches enfoncées",
"keyboard_title": "Clavier",
"kvm_terminal": "Terminal KVM",
"last_online": "Dernière connexion {time}",
"learn_more": "En savoir plus",
"load": "Charger",
"loading": "Chargement…",
"local_auth_change_local_device_password_description": "Saisissez votre mot de passe actuel et un nouveau mot de passe pour mettre à jour la protection de votre appareil local.",
"local_auth_change_local_device_password_title": "Modifier le mot de passe de l'appareil local",
"local_auth_confirm_new_password_label": "Confirmer le nouveau mot de passe",
"local_auth_create_confirm_password_placeholder": "Ré-entrez votre mot de passe",
"local_auth_create_description": "Créez un mot de passe pour protéger votre appareil contre tout accès local non autorisé.",
"local_auth_create_new_password_label": "Nouveau mot de passe",
"local_auth_create_new_password_placeholder": "Entrez un mot de passe fort",
"local_auth_create_not_now_button": "Plus tard",
"local_auth_create_secure_button": "Sécuriser l'appareil",
"local_auth_create_title": "Protection des périphériques locaux",
"local_auth_current_password_label": "Mot de passe actuel",
"local_auth_disable_local_device_protection_description": "Saisissez votre mot de passe actuel pour désactiver la protection de l'appareil local.",
"local_auth_disable_local_device_protection_title": "Désactiver la protection des périphériques locaux",
"local_auth_disable_protection_button": "Désactiver la protection",
"local_auth_enter_current_password_placeholder": "Entrez votre mot de passe actuel",
"local_auth_enter_new_password_placeholder": "Entrez un nouveau mot de passe fort",
"local_auth_error_changing_password": "Une erreur s'est produite lors du changement du mot de passe",
"local_auth_error_disabling_password": "Une erreur s'est produite lors de la désactivation du mot de passe",
"local_auth_error_enter_current_password": "Veuillez entrer votre mot de passe actuel",
"local_auth_error_enter_new_password": "Veuillez entrer un nouveau mot de passe",
"local_auth_error_enter_old_password": "Veuillez saisir votre ancien mot de passe",
"local_auth_error_enter_password": "Veuillez entrer un mot de passe",
"local_auth_error_passwords_not_match": "Les mots de passe ne correspondent pas",
"local_auth_error_setting_password": "Une erreur s'est produite lors de la définition du mot de passe",
"local_auth_new_password_label": "Nouveau mot de passe",
"local_auth_reenter_new_password_placeholder": "Saisissez à nouveau votre nouveau mot de passe",
"local_auth_success_password_disabled_description": "Vous avez désactivé avec succès la protection par mot de passe pour l'accès local. N'oubliez pas que votre appareil est désormais moins sécurisé.",
"local_auth_success_password_disabled_title": "Protection par mot de passe désactivée",
"local_auth_success_password_set_description": "Vous avez correctement configuré la protection locale de votre appareil. Votre appareil est désormais protégé contre tout accès local non autorisé.",
"local_auth_success_password_set_title": "Mot de passe défini avec succès",
"local_auth_success_password_updated_description": "Vous avez modifié avec succès le mot de passe de protection de votre appareil local. N'oubliez pas de le mémoriser pour y accéder ultérieurement.",
"local_auth_success_password_updated_title": "Mot de passe mis à jour avec succès",
"local_auth_update_password_button": "Mettre à jour le mot de passe",
"locale_auto": "Automatique",
"locale_change_success": "La langue a été modifiée avec succès en {locale}",
"locale_da": "Danois",
"locale_de": "Allemand",
"locale_en": "Anglais",
"locale_es": "Espagnol",
"locale_fr": "Français",
"locale_it": "Italien",
"locale_nb": "Norvégien (bokmål)",
"locale_sv": "Suédois",
"locale_zh": "中文 (简体)",
"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_error": "Une erreur s'est produite lors de la connexion",
"login_forgot_password": "Mot de passe oublié ?",
"login_password_label": "Mot de passe",
"login_welcome_back": "Bienvenue à JetKVM",
"macro_add_step": "Ajouter l'étape {maxed_out}",
"macro_at_least_one_step_keys_or_modifiers": "Au moins une étape doit avoir des clés ou des modificateurs",
"macro_at_least_one_step_required": "Au moins une étape est requise",
"macro_max_steps_error": "Vous ne pouvez ajouter qu'un maximum de {max} étapes par macro.",
"macro_max_steps_reached": "( {max} max)",
"macro_name_label": "Nom de la macro",
"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_failed": "Une erreur s'est produite lors de l'enregistrement.",
"macro_save_failed_error": "Une erreur s'est produite lors de l'enregistrement: {error}.",
"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",
"macro_step_keys_description": "Nombre maximal de clés {max} par étape.",
"macro_step_keys_label": "Clés",
"macro_step_max_keys_reached": "Nombre maximal de clés atteint",
"macro_step_modifiers_description": "Quels modificateurs (Maj/Ctrl/Alt/Meta) sont enfoncés pendant cette étape.",
"macro_step_modifiers_label": "Modificateurs",
"macro_step_no_matching_keys_found": "Aucune clé correspondante trouvée",
"macro_step_search_for_key": "Rechercher la clé…",
"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_aria_add_new": "Ajouter une nouvelle macro",
"macros_aria_delete": "Supprimer la macro {name}",
"macros_aria_duplicate": "Macro dupliquée {name}",
"macros_aria_edit": "Modifier la macro {name}",
"macros_aria_move_down": "Déplacer {name} vers le bas",
"macros_aria_move_up": "Déplacer {name} vers le haut",
"macros_confirm_delete_description": "Voulez-vous vraiment supprimer « {name} » ? Cette action est irréversible.",
"macros_confirm_delete_title": "Supprimer la macro",
"macros_confirm_deleting": "Suppression…",
"macros_create_first_description": "Combinez les frappes en une seule action",
"macros_create_first_headline": "Créez votre première macro",
"macros_created_success": "Macro « {name} » créée avec succès",
"macros_delay_only": "Retard seulement",
"macros_delete_confirm": "Êtes-vous sûr de vouloir supprimer cette macro ? Cette action est irréversible.",
"macros_delete_macro": "Supprimer la macro",
"macros_deleted_success": "Macro « {name} » supprimée avec succès",
"macros_deleting": "Suppression",
"macros_duplicated_success": "Macro « {name} » dupliquée avec succès",
"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_delete_error": "Échec de la suppression de la macro : {error}",
"macros_failed_duplicate": "Échec de la duplication 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_reorder_error": "Échec de la réorganisation des macros : {error}",
"macros_failed_update": "Échec de la mise à jour de la macro",
"macros_failed_update_error": "Échec de la mise à jour de la macro : {error}",
"macros_invalid_data": "Données de macro non valides",
"macros_loading": "Chargement des macros…",
"macros_max_reached": "Max atteint",
"macros_maximum_macros_reached": "Vous avez atteint le nombre maximal de macros {maximum} autorisées.",
"macros_no_macros_available": "Aucune macro disponible",
"macros_order_updated": "L'ordre des macros a été mis à jour avec succès",
"macros_title": "Macros de clavier",
"macros_updated_success": "Macro « {name} » mise à jour avec succès",
"metric_not_supported": "Métrique non prise en charge",
"metric_waiting_for_data": "En attente de données…",
"mount_add_file_to_get_started": "Ajoutez un fichier pour commencer",
"mount_add_new_media": "Ajouter un nouveau média",
"mount_available_storage": "Stockage disponible",
"mount_button_back_to_overview": "Retour à l'aperçu",
"mount_button_cancel_upload": "Annuler le téléchargement",
"mount_button_continue_upload": "Continuer le téléchargement",
"mount_button_mount_file": "Fichier de montage",
"mount_button_mount_url": "URL de montage",
"mount_button_select": "Sélectionner",
"mount_button_showing_results": "Affichage des résultats {from} à {to} sur {total}",
"mount_button_upload_new_image": "Télécharger une nouvelle image",
"mount_bytes_free": "{bytesFree} gratuit",
"mount_bytes_used": "{bytesUsed} utilisé",
"mount_calculating": "Calcul en cours…",
"mount_click_to_select_file": "Cliquez pour sélectionner un fichier",
"mount_click_to_select_incomplete": "Cliquez pour sélectionner « {name} »",
"mount_confirm_delete": "Êtes-vous sûr de vouloir supprimer {name} ?",
"mount_continue_uploading_with_name": "Continuer le téléchargement de « {name} »",
"mount_error_delete_file": "Erreur lors de la suppression du fichier : {error}",
"mount_error_description": "Une erreur s'est produite lors du montage du support. Veuillez réessayer.",
"mount_error_get_storage_space": "Erreur lors de l'obtention de l'espace de stockage : {error}",
"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_mode_cdrom": "CD/DVD",
"mount_mode_disk": "Disque",
"mount_mounted_as": "Monté comme",
"mount_mounted_from_storage": "Monté à partir du stockage JetKVM",
"mount_no_images_description": "Téléchargez une image pour démarrer le montage du support virtuel.",
"mount_no_images_title": "Aucune image disponible",
"mount_no_mounted_media": "Aucun support monté",
"mount_percentage_used": "{percentageUsed} % utilisé",
"mount_please_select_file": "Veuillez sélectionner le fichier « {name} » pour continuer le téléchargement.",
"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_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",
"mount_upload_failed_rtc": "Échec du téléchargement : {error}",
"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_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",
"mount_view_device_description": "Sélectionnez une image à monter à partir du stockage JetKVM",
"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",
"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",
"mouse_hide_cursor_description": "Masquer le curseur lors de l'envoi de mouvements de souris",
"mouse_hide_cursor_title": "Masquer le curseur",
"mouse_jiggler_config_updated": "Configuration de Jiggler mise à jour avec succès",
"mouse_jiggler_custom": "Personnalisé",
"mouse_jiggler_description": "Simuler le mouvement d'une souris d'ordinateur",
"mouse_jiggler_disabled": "Désactivé",
"mouse_jiggler_error_config": "Une erreur s'est produite lors de la configuration du jiggler",
"mouse_jiggler_failed_state": "Échec de la définition de l'état du jiggler : {error}",
"mouse_jiggler_frequent": "Fréquent - 30 s",
"mouse_jiggler_invalid_cron": "Expression cron non valide. Veuillez vérifier le format de votre planification (par exemple, « 0 * * * * * » pour chaque minute).",
"mouse_jiggler_light": "Léger - 5m",
"mouse_jiggler_standard": "Standard - 1 m",
"mouse_jiggler_title": "Jiggler",
"mouse_mode_absolute": "Absolu",
"mouse_mode_absolute_description": "Le plus pratique",
"mouse_mode_relative": "Relatif",
"mouse_mode_relative_description": "Le plus compatible",
"mouse_modes_description": "Choisissez le mode de saisie de la souris",
"mouse_modes_title": "Modes",
"mouse_scroll_high": "Haut",
"mouse_scroll_low": "Faible",
"mouse_scroll_medium": "Moyen",
"mouse_scroll_off": "Désactivé",
"mouse_scroll_throttling_description": "Réduire la fréquence des événements de défilement",
"mouse_scroll_throttling_title": "Ralentissement du défilement",
"mouse_scroll_very_high": "Très élevé",
"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_lease_renew_confirm": "Renouveler le bail",
"network_dhcp_lease_renew_confirm_description": "Cette opération demandera une nouvelle adresse IP à votre serveur DHCP. Votre appareil pourrait perdre temporairement sa connectivité réseau pendant cette opération.",
"network_dhcp_lease_renew_confirm_new_a": "Si vous recevez une nouvelle adresse IP",
"network_dhcp_lease_renew_confirm_new_b": "vous devrez peut-être vous reconnecter en utilisant la nouvelle adresse",
"network_dhcp_lease_renew_failed": "Échec du renouvellement du bail : {error}",
"network_dhcp_lease_renew_success": "Renouvellement du bail DHCP",
"network_domain_custom": "Personnalisé",
"network_domain_description": "Suffixe de domaine réseau pour l'appareil",
"network_domain_dhcp_provided": "DHCP fourni",
"network_domain_local": ".local",
"network_domain_title": "Domaine",
"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_invalid": "Adresse IPv4 non valide",
"network_ipv4_invalid_cidr": "Notation CIDR non valide pour l'adresse 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_addresses_header": "Adresses IPv6",
"network_ipv6_cidr_suggestion": "Veuillez utiliser la notation CIDR (par exemple, 2001:db8::1/64)",
"network_ipv6_dns": "DNS IPv6",
"network_ipv6_flag_dad_failed": "DAD a échoué",
"network_ipv6_flag_deprecated": "Obsolète",
"network_ipv6_gateway": "Passerelle IPv6",
"network_ipv6_information": "Informations IPv6",
"network_ipv6_invalid": "Adresse IPv6 non valide",
"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_prefix": "Préfixe IP",
"network_ipv6_prefix_invalid": "Le préfixe doit être compris entre 0 et 128",
"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 Protocol",
"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": "Automatique",
"network_mdns_description": "Contrôler le mode opérationnel mDNS (DNS multicast)",
"network_mdns_disabled": "Désactivé",
"network_mdns_ipv4_only": "IPv4 uniquement",
"network_mdns_ipv6_only": "IPv6 uniquement",
"network_mdns_title": "mDNS",
"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_add_dns": "Ajouter un serveur DNS",
"network_settings_load_error": "Échec du chargement des paramètres réseau : {error}",
"network_static_ipv4_header": "Configuration IPv4 statique",
"network_static_ipv6_header": "Configuration IPv6 statique",
"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",
"network_time_sync_ntp_only": "NTP uniquement",
"network_time_sync_title": "Synchronisation horaire",
"network_title": "Réseau",
"never_seen_online": "Jamais vu en ligne",
"next": "Suivant",
"no_results_found": "Aucun résultat trouvé",
"not_applicable": "N / A",
"not_available": "N / A",
"not_found": "Non trouvé",
"ntp_servers": "Serveurs NTP",
"oh_no": "Oh non !",
"online": "En ligne",
"other_session_detected": "Une autre session active détectée",
"other_session_take_over": " Une seule session active est prise en charge à la fois. Souhaitez-vous prendre le contrôle de cette session ?",
"other_session_use_here_button": "Utiliser ici",
"page_not_found_description": "La page que vous recherchez n'existe pas.",
"paste_modal_confirm_paste": "Confirmer le collage",
"paste_modal_delay_between_keys": "Délai entre les touches",
"paste_modal_delay_out_of_range": "Le délai doit être compris entre {min} et {max}",
"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_sending_using_layout": "Envoi de texte à l'aide de la disposition du clavier : {iso} - {name}",
"paste_text": "Coller du texte",
"paste_text_description": "Collez le texte de votre client sur l'hôte distant",
"peer_connection_closed": "Fermé",
"peer_connection_closing": "Fermeture",
"peer_connection_connected": "Connecté",
"peer_connection_connecting": "Connexion",
"peer_connection_disconnected": "Déconnecté",
"peer_connection_error": "Erreur de connexion",
"peer_connection_failed": "La connexion a échoué",
"peer_connection_new": "Nouveau",
"previous": "Précédent",
"register_device_error": "Une erreur {error} s'est produite lors de l'enregistrement de votre appareil.",
"register_device_finish_button": "Terminer la configuration",
"register_device_name_description": "Nommez votre appareil pour pouvoir l'identifier facilement plus tard. Vous pouvez modifier ce nom à tout moment.",
"register_device_name_label": "Nom de l'appareil",
"register_device_name_placeholder": "Serveur multimédia Plex",
"register_device_no_name": "Veuillez spécifier un nom",
"rename_device": "Renommer l'appareil",
"rename_device_description": "Nommez correctement votre appareil pour l'identifier facilement.",
"rename_device_error": "Une erreur {error} s'est produite lors du changement de nom de votre appareil.",
"rename_device_headline": "Renommer {name}",
"rename_device_new_name_label": "Nouveau nom de l'appareil",
"rename_device_new_name_placeholder": "Serveur multimédia Plex",
"rename_device_no_name": "Veuillez spécifier un nom",
"retry": "Réessayer",
"saving": "Enregistrement…",
"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_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",
"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…",
"settings_access": "Accéder",
"settings_advanced": "Avancé",
"settings_appearance": "Apparence",
"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_mouse": "Souris",
"settings_network": "Réseau",
"settings_video": "Vidéo",
"something_went_wrong": "Une erreur s'est produite. Veuillez réessayer ultérieurement ou contacter le support.",
"step_counter_step": "Étape {step}",
"subnet_mask": "Masque de sous-réseau",
"time_division_days": "jours",
"time_division_hours": "heures",
"time_division_minutes": "minutes",
"time_division_months": "mois",
"time_division_seconds": "secondes",
"time_division_weeks": "semaines",
"time_division_years": "années",
"troubleshoot_connection": "Dépannage de connexion",
"unknown_error": "Erreur inconnue",
"update_in_progress": "Mise à jour en cours",
"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": "Personnalisé",
"usb_config_default": "JetKVM par défaut",
"usb_config_dell": "Clavier Dell Multimedia Pro",
"usb_config_failed_load": "Échec du chargement de la configuration USB : {error}",
"usb_config_failed_set": "Échec de la définition de la configuration USB : {error}",
"usb_config_identifiers_description": "Identifiants de périphériques USB exposés à l'ordinateur cible",
"usb_config_identifiers_title": "Identifiants",
"usb_config_logitech": "Adaptateur universel Logitech",
"usb_config_manufacturer_label": "Fabricant",
"usb_config_manufacturer_placeholder": "Entrez le fabricant",
"usb_config_microsoft": "Clavier multimédia sans fil Microsoft",
"usb_config_product_id_label": "ID du produit",
"usb_config_product_id_placeholder": "Entrez l'ID du produit",
"usb_config_product_name_label": "Nom du produit",
"usb_config_product_name_placeholder": "Entrez le nom du produit",
"usb_config_restore_default": "Restaurer les paramètres par défaut",
"usb_config_serial_number_label": "Numéro de série",
"usb_config_serial_number_placeholder": "Entrez le numéro de série",
"usb_config_set_success": "Configuration USB définie sur {manufacturer} {product}",
"usb_config_update_identifiers": "Mettre à jour les identifiants USB",
"usb_config_vendor_id_label": "ID du fournisseur",
"usb_config_vendor_id_placeholder": "Entrez l'ID du fournisseur",
"usb_device_classes_description": "Classes de périphériques USB dans le périphérique composite",
"usb_device_classes_title": "Classes",
"usb_device_custom": "Personnalisé",
"usb_device_description": "Périphériques USB à émuler sur l'ordinateur cible",
"usb_device_enable_absolute_mouse_description": "Activer la souris absolue (pointeur)",
"usb_device_enable_absolute_mouse_title": "Activer la souris absolue (pointeur)",
"usb_device_enable_keyboard_description": "Activer le clavier",
"usb_device_enable_keyboard_title": "Activer le clavier",
"usb_device_enable_mass_storage_description": "Parfois, il peut être nécessaire de le désactiver pour éviter des problèmes avec certains appareils",
"usb_device_enable_mass_storage_title": "Activer le stockage de masse USB",
"usb_device_enable_relative_mouse_description": "Activer la souris relative",
"usb_device_enable_relative_mouse_title": "Activer la souris relative",
"usb_device_failed_load": "Échec du chargement des périphériques USB : {error}",
"usb_device_failed_set": "Échec de la configuration des périphériques USB : {error}",
"usb_device_keyboard_mouse_and_mass_storage": "Clavier, souris et stockage de masse",
"usb_device_keyboard_only": "Clavier uniquement",
"usb_device_restore_default": "Restaurer les paramètres par défaut",
"usb_device_title": "périphérique USB",
"usb_device_update_classes": "Mettre à jour les classes USB",
"usb_device_updated": "Périphériques USB mis à jour",
"usb_state_connected": "Connecté",
"usb_state_connecting": "De liaison",
"usb_state_disconnected": "Déconnecté",
"usb_state_low_power_mode": "Mode basse consommation",
"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)",
"video_brightness_title": "Luminosité",
"video_contrast_description": "Niveau de contraste ( {value} x)",
"video_contrast_title": "Contraste",
"video_custom_edid_description": "Compatibilité du mode vidéo avec les détails EDID. Les paramètres par défaut fonctionnent dans la plupart des cas, mais des ajustements spécifiques à l'UEFI/BIOS peuvent être nécessaires.",
"video_custom_edid_title": "EDID personnalisé",
"video_debugging_info_description": "Informations de débogage pour la vidéo",
"video_debugging_info_title": "Informations de débogage",
"video_description": "Configurer les paramètres d'affichage et l'EDID pour une compatibilité optimale",
"video_edid_acer_b246wl": "Acer B246WL, 1920x1200",
"video_edid_asus_pa248qv": "ASUS PA248QV, 1920x1200",
"video_edid_custom": "Personnalisé",
"video_edid_dell_d2721h": "DELL D2721H, 1920x1080",
"video_edid_dell_idrac": "DELL IDRAC EDID, 1280x1024",
"video_edid_description": "Ajuster les paramètres EDID pour l'affichage",
"video_edid_file_label": "Fichier EDID",
"video_edid_jetkvm_default": "JetKVM par défaut",
"video_edid_set_success": "EDID défini avec succès sur {edid}",
"video_edid_title": "EDID",
"video_enhancement_description": "Ajustez les paramètres de couleur pour rendre la sortie vidéo plus dynamique et colorée",
"video_enhancement_title": "Amélioration vidéo",
"video_failed_get_debug_info": "Échec de l'obtention des informations de débogage : {error}",
"video_failed_get_edid": "Échec de l'obtention de l'EDID : {error}",
"video_failed_set_edid": "Échec de la définition de l'EDID : {error}",
"video_failed_set_stream_quality": "Échec de la définition de la qualité du flux : {error}",
"video_get_debugging_info": "Obtenir des informations de débogage",
"video_overlay_autoplay_permissions_required": "Autorisations de lecture automatique requises",
"video_overlay_conn_check_cables": "Vérifiez toutes les connexions de câbles pour détecter tout fil desserré ou endommagé",
"video_overlay_conn_ensure_network": "Assurez-vous que votre connexion réseau est stable et active",
"video_overlay_conn_restart": "Essayez de redémarrer l'appareil et votre ordinateur",
"video_overlay_conn_verify_power": "Vérifiez que l'appareil est sous tension et correctement connecté",
"video_overlay_connection_issue_title": "Problème de connexion détecté",
"video_overlay_enable_autoplay_settings": "Veuillez ajuster les paramètres du navigateur pour activer la lecture automatique",
"video_overlay_hdmi_error_title": "Erreur de signal HDMI détectée.",
"video_overlay_hdmi_incompatible_resolution": "Paramètres de résolution ou de taux de rafraîchissement incompatibles",
"video_overlay_hdmi_loose_faulty": "Une connexion HDMI lâche ou défectueuse",
"video_overlay_hdmi_source_issue": "Problèmes avec la sortie HDMI de l'appareil source",
"video_overlay_learn_more": "En savoir plus",
"video_overlay_loading_stream": "Chargement du flux vidéo…",
"video_overlay_manually_start_stream": "Démarrer le flux manuellement",
"video_overlay_no_hdmi_adapter_compat": "Si vous utilisez un adaptateur, assurez-vous qu'il est compatible et qu'il fonctionne correctement",
"video_overlay_no_hdmi_ensure_cable": "Assurez-vous que le câble HDMI est bien connecté aux deux extrémités",
"video_overlay_no_hdmi_ensure_power": "Assurez-vous que l'appareil source est sous tension et émet un signal",
"video_overlay_no_hdmi_signal": "Aucun signal HDMI détecté.",
"video_overlay_pointerlock_click_to_enable": "Cliquez sur la vidéo pour activer le contrôle de la souris",
"video_overlay_reboot_device_is_rebooting": "L'appareil redémarre",
"video_overlay_reboot_different_ip_message": "L'appareil a peut-être redémarré avec une adresse IP différente. Vérifiez l'écran physique du JetKVM pour trouver l'adresse IP actuelle et reconnectez-vous.",
"video_overlay_reboot_please_wait_message": "Veuillez patienter pendant le redémarrage de l'appareil. Cela prend généralement 20 à 30 secondes.",
"video_overlay_reboot_timeout_message": "Délai de reconnexion automatique expiré",
"video_overlay_reboot_unable_to_reconnect": "Impossible de se reconnecter",
"video_overlay_reboot_waiting_for_restart": "En attente du redémarrage de l'appareil…",
"video_overlay_retrying_connection": "Nouvelle tentative de connexion…",
"video_overlay_troubleshooting_guide": "Guide de dépannage",
"video_overlay_try_again": "Essayer à nouveau",
"video_pointer_lock_disabled": "Verrouillage du pointeur désactivé",
"video_pointer_lock_enabled": "Verrouillage du pointeur activé — appuyez sur Échap pour déverrouiller",
"video_quality_high": "Haut",
"video_quality_low": "Faible",
"video_quality_medium": "Moyen",
"video_reset_to_default": "Réinitialiser aux paramètres par défaut",
"video_restore_to_default": "Restaurer les paramètres par défaut",
"video_saturation_description": "Saturation des couleurs ( {value} x)",
"video_saturation_title": "Saturation",
"video_set_custom_edid": "Définir un EDID personnalisé",
"video_stream_quality_description": "Ajuster la qualité du flux vidéo",
"video_stream_quality_set": "Qualité du flux définie sur {quality}",
"video_stream_quality_title": "Qualité du flux",
"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",
"wake_on_lan_add_device_save_device": "Enregistrer l'appareil",
"wake_on_lan_description": "Envoyez un paquet magique pour réveiller un appareil distant.",
"wake_on_lan_device_list_add_new_device": "Ajouter un nouvel appareil",
"wake_on_lan_device_list_delete_device": "Supprimer l'appareil",
"wake_on_lan_device_list_wake": "Se réveiller",
"wake_on_lan_empty_add_device_to_start": "Ajoutez un appareil pour commencer à utiliser Wake-on-LAN",
"wake_on_lan_empty_add_new_device": "Ajouter un nouvel appareil",
"wake_on_lan_empty_no_devices_added": "Aucun appareil ajouté",
"wake_on_lan_failed_add_device": "Échec de l'ajout de l'appareil",
"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"
}

Some files were not shown because too many files have changed in this diff Show More