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
230 changed files with 22594 additions and 5025 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 \
@ -33,4 +34,4 @@ wget https://github.com/jetkvm/rv1106-system/releases/download/${BUILDKIT_VERSIO
sudo tar --use-compress-program="unzstd --long=31" -xvf buildkit.tar.zst -C /opt/jetkvm-native-buildkit && \
rm buildkit.tar.zst
popd
rm -rf "${BUILDKIT_TMPDIR}"
rm -rf "${BUILDKIT_TMPDIR}"

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'

4
.gitignore vendored
View File

@ -12,4 +12,6 @@ node_modules
# generated during the build process
#internal/native/include
#internal/native/lib
#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
@ -25,9 +22,10 @@ Welcome to JetKVM development! This guide will help you get started quickly, whe
### Development Environment
**Recommended:** Development is best done on **Linux** or **macOS**.
**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

@ -478,7 +478,7 @@ func handleSessionRequest(
cloudLogger.Trace().Interface("session", session).Msg("new session accepted")
// Cancel any ongoing keyboard macro when session changes
cancelKeyboardMacro()
cancelAllRunningKeyboardMacros()
currentSession = session
_ = wsjson.Write(context.Background(), c, gin.H{"type": "answer", "data": sd})
@ -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=

View File

@ -26,20 +26,45 @@ func handleHidRPCMessage(message hidrpc.Message, session *Session) {
return
}
session.hidRPCAvailable = true
case hidrpc.TypeKeypressReport, hidrpc.TypeKeyboardReport:
rpcErr = handleHidRPCKeyboardInput(message)
case hidrpc.TypeKeyboardMacroReport:
keyboardMacroReport, err := message.KeyboardMacroReport()
if err != nil {
logger.Warn().Err(err).Msg("failed to get keyboard macro report")
return
}
rpcErr = rpcExecuteKeyboardMacro(keyboardMacroReport.Steps)
token := rpcExecuteKeyboardMacro(keyboardMacroReport.IsPaste, keyboardMacroReport.Steps)
logger.Debug().Str("token", token.String()).Msg("started keyboard macro")
message, err := hidrpc.NewKeyboardMacroTokenMessage(token).Marshal()
if err != nil {
logger.Warn().Err(err).Msg("failed to marshal running macro token message")
return
}
if err := session.HidChannel.Send(message); err != nil {
logger.Warn().Err(err).Msg("failed to send running macro token message")
return
}
case hidrpc.TypeCancelKeyboardMacroReport:
rpcCancelKeyboardMacro()
return
case hidrpc.TypeKeyboardMacroTokenState:
tokenState, err := message.KeyboardMacroTokenState()
if err != nil {
logger.Warn().Err(err).Msg("failed to get keyboard macro token")
return
}
rpcCancelKeyboardMacroByToken(tokenState.Token)
return
case hidrpc.TypeKeypressKeepAliveReport:
rpcErr = handleHidRPCKeypressKeepAlive(session)
case hidrpc.TypePointerReport:
pointerReport, err := message.PointerReport()
if err != nil {
@ -47,6 +72,7 @@ func handleHidRPCMessage(message hidrpc.Message, session *Session) {
return
}
rpcErr = rpcAbsMouseReport(pointerReport.X, pointerReport.Y, pointerReport.Button)
case hidrpc.TypeMouseReport:
mouseReport, err := message.MouseReport()
if err != nil {
@ -54,6 +80,7 @@ func handleHidRPCMessage(message hidrpc.Message, session *Session) {
return
}
rpcErr = rpcRelMouseReport(mouseReport.DX, mouseReport.DY, mouseReport.Button)
default:
logger.Warn().Uint8("type", uint8(message.Type())).Msg("unknown HID RPC message type")
}
@ -65,15 +92,18 @@ func handleHidRPCMessage(message hidrpc.Message, session *Session) {
func onHidMessage(msg hidQueueMessage, session *Session) {
data := msg.Data
dataLen := len(data)
scopedLogger := hidRPCLogger.With().
Str("channel", msg.channel).
Bytes("data", data).
Dur("timelimit", msg.timelimit).
Int("data_len", dataLen).
Bytes("data", data[:min(dataLen, 32)]).
Logger()
scopedLogger.Debug().Msg("HID RPC message received")
if len(data) < 1 {
scopedLogger.Warn().Int("length", len(data)).Msg("received empty data in HID RPC message handler")
if dataLen < 1 {
scopedLogger.Warn().Msg("received empty data in HID RPC message handler")
return
}
@ -96,7 +126,7 @@ func onHidMessage(msg hidQueueMessage, session *Session) {
r <- nil
}()
select {
case <-time.After(1 * time.Second):
case <-time.After(msg.timelimit * time.Second):
scopedLogger.Warn().Msg("HID RPC message timed out")
case <-r:
scopedLogger.Debug().Dur("duration", time.Since(t)).Msg("HID RPC message handled")
@ -212,6 +242,8 @@ func reportHidRPC(params any, session *Session) {
message, err = hidrpc.NewKeydownStateMessage(params).Marshal()
case hidrpc.KeyboardMacroState:
message, err = hidrpc.NewKeyboardMacroStateMessage(params.State, params.IsPaste).Marshal()
case hidrpc.KeyboardMacroTokenState:
message, err = hidrpc.NewKeyboardMacroTokenMessage(params.Token).Marshal()
default:
err = fmt.Errorf("unknown HID RPC message type: %T", params)
}

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

@ -2,7 +2,9 @@ package hidrpc
import (
"fmt"
"time"
"github.com/google/uuid"
"github.com/jetkvm/kvm/internal/usbgadget"
)
@ -22,26 +24,34 @@ const (
TypeKeyboardLedState MessageType = 0x32
TypeKeydownState MessageType = 0x33
TypeKeyboardMacroState MessageType = 0x34
TypeKeyboardMacroTokenState MessageType = 0x35
)
type QueueIndex int
const (
Version byte = 0x01 // Version of the HID RPC protocol
Version byte = 0x01 // Version of the HID RPC protocol
HandshakeQueue int = 0 // Queue index for handshake messages
KeyboardQueue int = 1 // Queue index for keyboard messages
MouseQueue int = 2 // Queue index for mouse messages
MacroQueue int = 3 // Queue index for macro messages
OtherQueue int = 4 // Queue index for other messages
)
// GetQueueIndex returns the index of the queue to which the message should be enqueued.
func GetQueueIndex(messageType MessageType) int {
func GetQueueIndex(messageType MessageType) (int, time.Duration) {
switch messageType {
case TypeHandshake:
return 0
case TypeKeyboardReport, TypeKeypressReport, TypeKeyboardMacroReport, TypeKeyboardLedState, TypeKeydownState, TypeKeyboardMacroState:
return 1
return HandshakeQueue, 1
case TypeKeyboardReport, TypeKeypressReport, TypeKeyboardLedState, TypeKeydownState, TypeKeyboardMacroState:
return KeyboardQueue, 1
case TypePointerReport, TypeMouseReport, TypeWheelReport:
return 2
// we don't want to block the queue for this message
case TypeCancelKeyboardMacroReport:
return 3
return MouseQueue, 1
// we don't want to block the queue for these messages
case TypeKeyboardMacroReport, TypeCancelKeyboardMacroReport, TypeKeyboardMacroTokenState:
return MacroQueue, 60 // 1 minute timeout
default:
return 3
return OtherQueue, 5
}
}
@ -121,3 +131,13 @@ func NewKeyboardMacroStateMessage(state bool, isPaste bool) *Message {
d: data,
}
}
// NewKeyboardMacroTokenMessage creates a new keyboard macro token message.
func NewKeyboardMacroTokenMessage(token uuid.UUID) *Message {
data, _ := token.MarshalBinary()
return &Message{
t: TypeKeyboardMacroTokenState,
d: data,
}
}

View File

@ -3,6 +3,8 @@ package hidrpc
import (
"encoding/binary"
"fmt"
"github.com/google/uuid"
)
// Message ..
@ -23,6 +25,9 @@ func (m *Message) Type() MessageType {
func (m *Message) String() string {
switch m.t {
case TypeHandshake:
if len(m.d) != 0 {
return fmt.Sprintf("Handshake{Malformed: %v}", m.d)
}
return "Handshake"
case TypeKeypressReport:
if len(m.d) < 2 {
@ -45,12 +50,45 @@ func (m *Message) String() string {
}
return fmt.Sprintf("MouseReport{DX: %d, DY: %d, Button: %d}", m.d[0], m.d[1], m.d[2])
case TypeKeypressKeepAliveReport:
if len(m.d) != 0 {
return fmt.Sprintf("KeypressKeepAliveReport{Malformed: %v}", m.d)
}
return "KeypressKeepAliveReport"
case TypeWheelReport:
if len(m.d) < 3 {
return fmt.Sprintf("WheelReport{Malformed: %v}", m.d)
}
return fmt.Sprintf("WheelReport{Vertical: %d, Horizontal: %d}", int8(m.d[0]), int8(m.d[1]))
case TypeKeyboardMacroReport:
if len(m.d) < 5 {
return fmt.Sprintf("KeyboardMacroReport{Malformed: %v}", m.d)
}
return fmt.Sprintf("KeyboardMacroReport{IsPaste: %v, Length: %d}", m.d[0] == uint8(1), binary.BigEndian.Uint32(m.d[1:5]))
case TypeCancelKeyboardMacroReport:
if len(m.d) != 0 {
return fmt.Sprintf("CancelKeyboardMacroReport{Malformed: %v}", m.d)
}
return "CancelKeyboardMacroReport"
case TypeKeyboardMacroTokenState:
if len(m.d) != 16 {
return fmt.Sprintf("KeyboardMacroTokenState{Malformed: %v}", m.d)
}
return fmt.Sprintf("KeyboardMacroTokenState{Token: %s}", uuid.Must(uuid.FromBytes(m.d)).String())
case TypeKeyboardLedState:
if len(m.d) < 1 {
return fmt.Sprintf("KeyboardLedState{Malformed: %v}", m.d)
}
return fmt.Sprintf("KeyboardLedState{State: %d}", m.d[0])
case TypeKeydownState:
if len(m.d) < 1 {
return fmt.Sprintf("KeydownState{Malformed: %v}", m.d)
}
return fmt.Sprintf("KeydownState{State: %d}", m.d[0])
case TypeKeyboardMacroState:
if len(m.d) < 2 {
return fmt.Sprintf("KeyboardMacroState{Malformed: %v}", m.d)
}
return fmt.Sprintf("KeyboardMacroState{State: %v, IsPaste: %v}", m.d[0] == uint8(1), m.d[1] == uint8(1))
default:
return fmt.Sprintf("Unknown{Type: %d, Data: %v}", m.t, m.d)
}
@ -67,7 +105,9 @@ func (m *Message) KeypressReport() (KeypressReport, error) {
if m.t != TypeKeypressReport {
return KeypressReport{}, fmt.Errorf("invalid message type: %d", m.t)
}
if len(m.d) < 2 {
return KeypressReport{}, fmt.Errorf("invalid message data length: %d", len(m.d))
}
return KeypressReport{
Key: m.d[0],
Press: m.d[1] == uint8(1),
@ -95,7 +135,7 @@ func (m *Message) KeyboardReport() (KeyboardReport, error) {
// Macro ..
type KeyboardMacroStep struct {
Modifier byte // 1 byte
Keys []byte // 6 bytes: hidKeyBufferSize
Keys []byte // 6 bytes: HidKeyBufferSize
Delay uint16 // 2 bytes
}
type KeyboardMacroReport struct {
@ -105,7 +145,7 @@ type KeyboardMacroReport struct {
}
// HidKeyBufferSize is the size of the keys buffer in the keyboard report.
const HidKeyBufferSize = 6
const HidKeyBufferSize int = 6
// KeyboardMacroReport returns the keyboard macro report from the message.
func (m *Message) KeyboardMacroReport() (KeyboardMacroReport, error) {
@ -205,3 +245,29 @@ func (m *Message) KeyboardMacroState() (KeyboardMacroState, error) {
IsPaste: m.d[1] == uint8(1),
}, nil
}
type KeyboardMacroTokenState struct {
Token uuid.UUID
}
// KeyboardMacroTokenState returns the keyboard macro token UUID from the message.
func (m *Message) KeyboardMacroTokenState() (KeyboardMacroTokenState, error) {
if m.t != TypeKeyboardMacroTokenState {
return KeyboardMacroTokenState{}, fmt.Errorf("invalid message type: %d", m.t)
}
if len(m.d) == 0 {
return KeyboardMacroTokenState{Token: uuid.Nil}, nil
}
if len(m.d) != 16 {
return KeyboardMacroTokenState{}, fmt.Errorf("invalid UUID length: %d", len(m.d))
}
token, err := uuid.FromBytes(m.d)
if err != nil {
return KeyboardMacroTokenState{}, fmt.Errorf("invalid UUID: %v", err)
}
return KeyboardMacroTokenState{Token: token}, 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

@ -306,7 +306,7 @@ int jetkvm_ui_add_flag(const char *obj_name, const char *flag_name) {
if (obj == NULL) {
return -1;
}
lv_obj_flag_t flag_val = str_to_lv_obj_flag(flag_name);
if (flag_val == 0)
{
@ -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() {
@ -417,4 +417,4 @@ void jetkvm_crash() {
// let's call a function that will crash the program
int* p = 0;
*p = 0;
}
}

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;
}
@ -577,61 +660,80 @@ void video_shutdown()
RK_MPI_MB_DestroyPool(memPool);
}
log_info("Destroyed memory pool");
pthread_mutex_destroy(&streaming_mutex);
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,21 +831,9 @@ 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() {
return 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();
@ -151,6 +162,9 @@ void tick_screen_reboot_screen();
void create_screen_rebooting_screen();
void tick_screen_rebooting_screen();
void create_screen_switch_dhcp_client_screen();
void tick_screen_switch_dhcp_client_screen();
void tick_screen_by_id(enum ScreensEnum screenId);
void tick_screen(int screen_index);

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

@ -31,6 +31,8 @@ var keyboardReportDesc = []byte{
0x05, 0x01, /* USAGE_PAGE (Generic Desktop) */
0x09, 0x06, /* USAGE (Keyboard) */
0xa1, 0x01, /* COLLECTION (Application) */
/* 8 modifier bits */
0x05, 0x07, /* USAGE_PAGE (Keyboard) */
0x19, 0xe0, /* USAGE_MINIMUM (Keyboard LeftControl) */
0x29, 0xe7, /* USAGE_MAXIMUM (Keyboard Right GUI) */
@ -39,27 +41,47 @@ var keyboardReportDesc = []byte{
0x75, 0x01, /* REPORT_SIZE (1) */
0x95, 0x08, /* REPORT_COUNT (8) */
0x81, 0x02, /* INPUT (Data,Var,Abs) */
/* 8 bits of padding */
0x95, 0x01, /* REPORT_COUNT (1) */
0x75, 0x08, /* REPORT_SIZE (8) */
0x81, 0x03, /* INPUT (Cnst,Var,Abs) */
/* 6 key codes for the 104 key keyboard */
0x95, 0x06, /* REPORT_COUNT (6) */
0x75, 0x08, /* REPORT_SIZE (8) */
0x15, 0x00, /* LOGICAL_MINIMUM (0) */
0x25, 0xE7, /* LOGICAL_MAXIMUM (104-key HID) */
0x05, 0x07, /* USAGE_PAGE (Keyboard) */
0x19, 0x00, /* USAGE_MINIMUM (Reserved) */
0x29, 0xE7, /* USAGE_MAXIMUM (Keyboard Right GUI) */
0x81, 0x00, /* INPUT (Data,Ary,Abs) */
/* LED report 5 bits for Num Lock through Kana */
0x95, 0x05, /* REPORT_COUNT (5) */
0x75, 0x01, /* REPORT_SIZE (1) */
0x05, 0x08, /* USAGE_PAGE (LEDs) */
0x19, 0x01, /* USAGE_MINIMUM (Num Lock) */
0x29, 0x05, /* USAGE_MAXIMUM (Kana) */
0x91, 0x02, /* OUTPUT (Data,Var,Abs) */
/* 1 bit of padding for the Power LED (ignored) */
0x95, 0x01, /* REPORT_COUNT (1) */
0x75, 0x03, /* REPORT_SIZE (3) */
0x91, 0x03, /* OUTPUT (Cnst,Var,Abs) */
/* LED report 1 bit for Shift */
0x95, 0x01, /* REPORT_COUNT (1) */
0x75, 0x01, /* REPORT_SIZE (1) */
0x05, 0x08, /* USAGE_PAGE (LEDs) */
0x19, 0x07, /* USAGE_MINIMUM (Shift) */
0x29, 0x07, /* USAGE_MAXIMUM (Shift) */
0x91, 0x02, /* OUTPUT (Data,Var,Abs) */
/* 1 bit of padding for the rest of the byte */
0x95, 0x01, /* REPORT_COUNT (1) */
0x75, 0x03, /* REPORT_SIZE (3) */
0x91, 0x03, /* OUTPUT (Cnst,Var,Abs) */
0x95, 0x06, /* REPORT_COUNT (6) */
0x75, 0x08, /* REPORT_SIZE (8) */
0x15, 0x00, /* LOGICAL_MINIMUM (0) */
0x25, 0x65, /* LOGICAL_MAXIMUM (101) */
0x05, 0x07, /* USAGE_PAGE (Keyboard) */
0x19, 0x00, /* USAGE_MINIMUM (Reserved) */
0x29, 0x65, /* USAGE_MAXIMUM (Keyboard Application) */
0x81, 0x00, /* INPUT (Data,Ary,Abs) */
0xc0, /* END_COLLECTION */
}
@ -153,6 +175,16 @@ func (u *UsbGadget) SetOnKeysDownChange(f func(state KeysDownState)) {
u.onKeysDownChange = &f
}
var suspendedKeyDownMessages bool = false
func (u *UsbGadget) SuspendKeyDownMessages() {
suspendedKeyDownMessages = true
}
func (u *UsbGadget) ResumeSuspendKeyDownMessages() {
suspendedKeyDownMessages = false
}
func (u *UsbGadget) SetOnKeepAliveReset(f func()) {
u.onKeepAliveReset = &f
}
@ -169,9 +201,9 @@ func (u *UsbGadget) scheduleAutoRelease(key byte) {
}
// TODO: make this configurable
// We currently hardcode the duration to 100ms
// We currently hardcode the duration to the default of 100ms
// However, it should be the same as the duration of the keep-alive reset called baseExtension.
u.kbdAutoReleaseTimers[key] = time.AfterFunc(100*time.Millisecond, func() {
u.kbdAutoReleaseTimers[key] = time.AfterFunc(DefaultAutoReleaseDuration, func() {
u.performAutoRelease(key)
})
}
@ -314,6 +346,7 @@ var keyboardWriteHidFileLock sync.Mutex
func (u *UsbGadget) keyboardWriteHidFile(modifier byte, keys []byte) error {
keyboardWriteHidFileLock.Lock()
defer keyboardWriteHidFileLock.Unlock()
if err := u.openKeyboardHidFile(); err != nil {
return err
}
@ -353,7 +386,7 @@ func (u *UsbGadget) UpdateKeysDown(modifier byte, keys []byte) KeysDownState {
u.keysDownState = state
u.keyboardStateLock.Unlock()
if u.onKeysDownChange != nil {
if u.onKeysDownChange != nil && !suspendedKeyDownMessages {
(*u.onKeysDownChange)(state) // this enques to the outgoing hidrpc queue via usb.go → currentSession.enqueueKeysDownState(...)
}
return state
@ -484,6 +517,10 @@ func (u *UsbGadget) keypressReport(key byte, press bool) (KeysDownState, error)
}
err := u.keyboardWriteHidFile(modifier, keys)
if err != nil {
u.log.Warn().Uint8("modifier", modifier).Uints8("keys", keys).Msg("Could not write keyboard report to hidg0")
}
return u.UpdateKeysDown(modifier, keys), err
}

View File

@ -1,7 +1,6 @@
package kvm
import (
"bytes"
"context"
"encoding/json"
"errors"
@ -14,6 +13,7 @@ import (
"sync"
"time"
"github.com/google/uuid"
"github.com/pion/webrtc/v4"
"github.com/rs/zerolog"
"go.bug.st/serial"
@ -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)
}
@ -1084,91 +1063,154 @@ func rpcSetLocalLoopbackOnly(enabled bool) error {
return nil
}
type RunningMacro struct {
cancel context.CancelFunc
isPaste bool
}
var (
keyboardMacroCancel context.CancelFunc
keyboardMacroLock sync.Mutex
keyboardMacroCancelMap map[uuid.UUID]RunningMacro
keyboardMacroLock sync.Mutex
keyboardMacroOnce sync.Once
)
// cancelKeyboardMacro cancels any ongoing keyboard macro execution
func cancelKeyboardMacro() {
func getKeyboardMacroCancelMap() map[uuid.UUID]RunningMacro {
keyboardMacroOnce.Do(func() {
keyboardMacroCancelMap = make(map[uuid.UUID]RunningMacro)
})
return keyboardMacroCancelMap
}
func addKeyboardMacro(isPaste bool, cancel context.CancelFunc) uuid.UUID {
keyboardMacroLock.Lock()
defer keyboardMacroLock.Unlock()
cancelMap := getKeyboardMacroCancelMap()
if keyboardMacroCancel != nil {
keyboardMacroCancel()
logger.Info().Msg("canceled keyboard macro")
keyboardMacroCancel = nil
token := uuid.New() // Generate a unique token
cancelMap[token] = RunningMacro{
isPaste: isPaste,
cancel: cancel,
}
return token
}
func removeRunningKeyboardMacro(token uuid.UUID) {
keyboardMacroLock.Lock()
defer keyboardMacroLock.Unlock()
cancelMap := getKeyboardMacroCancelMap()
delete(cancelMap, token)
}
func cancelRunningKeyboardMacro(token uuid.UUID) {
keyboardMacroLock.Lock()
defer keyboardMacroLock.Unlock()
cancelMap := getKeyboardMacroCancelMap()
if runningMacro, exists := cancelMap[token]; exists {
runningMacro.cancel()
delete(cancelMap, token)
logger.Info().Interface("token", token).Msg("canceled keyboard macro by token")
} else {
logger.Debug().Interface("token", token).Msg("no running keyboard macro found for token")
}
}
func setKeyboardMacroCancel(cancel context.CancelFunc) {
func cancelAllRunningKeyboardMacros() {
keyboardMacroLock.Lock()
defer keyboardMacroLock.Unlock()
cancelMap := getKeyboardMacroCancelMap()
keyboardMacroCancel = cancel
for token, runningMacro := range cancelMap {
runningMacro.cancel()
delete(cancelMap, token)
logger.Info().Interface("token", token).Msg("cancelled keyboard macro")
}
}
func rpcExecuteKeyboardMacro(macro []hidrpc.KeyboardMacroStep) error {
cancelKeyboardMacro()
func reportRunningMacrosState() {
if currentSession != nil {
keyboardMacroLock.Lock()
defer keyboardMacroLock.Unlock()
cancelMap := getKeyboardMacroCancelMap()
isPaste := false
for _, runningMacro := range cancelMap {
if runningMacro.isPaste {
isPaste = true
break
}
}
state := hidrpc.KeyboardMacroState{
State: len(cancelMap) > 0,
IsPaste: isPaste,
}
currentSession.reportHidRPCKeyboardMacroState(state)
}
}
func rpcExecuteKeyboardMacro(isPaste bool, macro []hidrpc.KeyboardMacroStep) uuid.UUID {
ctx, cancel := context.WithCancel(context.Background())
setKeyboardMacroCancel(cancel)
token := addKeyboardMacro(isPaste, cancel)
reportRunningMacrosState()
s := hidrpc.KeyboardMacroState{
State: true,
IsPaste: true,
}
go func() {
defer reportRunningMacrosState() // this executes last, so the map is already updated
defer removeRunningKeyboardMacro(token) // this executes first, to update the map
if currentSession != nil {
currentSession.reportHidRPCKeyboardMacroState(s)
}
err := executeKeyboardMacro(ctx, isPaste, macro)
if err != nil {
logger.Error().Err(err).Interface("token", token).Bool("isPaste", isPaste).Msg("keyboard macro execution failed")
}
}()
err := rpcDoExecuteKeyboardMacro(ctx, macro)
setKeyboardMacroCancel(nil)
s.State = false
if currentSession != nil {
currentSession.reportHidRPCKeyboardMacroState(s)
}
return err
return token
}
func rpcCancelKeyboardMacro() {
cancelKeyboardMacro()
defer reportRunningMacrosState()
cancelAllRunningKeyboardMacros()
}
var keyboardClearStateKeys = make([]byte, hidrpc.HidKeyBufferSize)
func rpcCancelKeyboardMacroByToken(token uuid.UUID) {
defer reportRunningMacrosState()
func isClearKeyStep(step hidrpc.KeyboardMacroStep) bool {
return step.Modifier == 0 && bytes.Equal(step.Keys, keyboardClearStateKeys)
if token == uuid.Nil {
cancelAllRunningKeyboardMacros()
} else {
cancelRunningKeyboardMacro(token)
}
}
func rpcDoExecuteKeyboardMacro(ctx context.Context, macro []hidrpc.KeyboardMacroStep) error {
logger.Debug().Interface("macro", macro).Msg("Executing keyboard macro")
func executeKeyboardMacro(ctx context.Context, isPaste bool, macro []hidrpc.KeyboardMacroStep) error {
logger.Debug().
Int("macro_steps", len(macro)).
Bool("isPaste", isPaste).
Msg("Executing keyboard macro")
// don't report keyboard state changes while executing the macro
gadget.SuspendKeyDownMessages()
defer gadget.ResumeSuspendKeyDownMessages()
for i, step := range macro {
delay := time.Duration(step.Delay) * time.Millisecond
err := rpcKeyboardReport(step.Modifier, step.Keys)
if err != nil {
logger.Warn().Err(err).Msg("failed to execute keyboard macro")
logger.Warn().Err(err).Int("step", i).Msg("failed to execute keyboard macro")
return err
}
// notify the device that the keyboard state is being cleared
if isClearKeyStep(step) {
gadget.UpdateKeysDown(0, keyboardClearStateKeys)
}
// Use context-aware sleep that can be cancelled
select {
case <-time.After(delay):
// Sleep completed normally
case <-ctx.Done():
// make sure keyboard state is reset
err := rpcKeyboardReport(0, keyboardClearStateKeys)
// make sure keyboard state is reset and the client gets notified
gadget.ResumeSuspendKeyDownMessages()
err := rpcKeyboardReport(0, make([]byte, hidrpc.HidKeyBufferSize))
if err != nil {
logger.Warn().Err(err).Msg("failed to reset keyboard state")
}
@ -1215,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"
@ -124,7 +160,7 @@ if [[ "$SKIP_UI_BUILD" = true && ! -f "static/index.html" ]]; then
SKIP_UI_BUILD=false
fi
if [[ "$SKIP_UI_BUILD" = false && "$JETKVM_INSIDE_DOCKER" != 1 ]]; then
if [[ "$SKIP_UI_BUILD" = false && "$JETKVM_INSIDE_DOCKER" != 1 ]]; then
msg_info "▶ Building frontend"
make frontend SKIP_UI_BUILD=0
SKIP_UI_BUILD_RELEASE=1
@ -137,13 +173,13 @@ fi
if [ "$RUN_GO_TESTS" = true ]; then
msg_info "▶ Building go tests"
make build_dev_test
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: {
@ -66,7 +67,7 @@ module.exports = defineConfig([{
groups: ["builtin", "external", "internal", "parent", "sibling"],
"newlines-between": "always",
}],
"@typescript-eslint/no-unused-vars": ["warn", {
"argsIgnorePattern": "^_", "varsIgnorePattern": "^_"
}],
@ -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"
}

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