mirror of https://github.com/jetkvm/kvm.git
Compare commits
19 Commits
61ee27e931
...
29d1394af5
| Author | SHA1 | Date |
|---|---|---|
|
|
29d1394af5 | |
|
|
d58dcd9cc6 | |
|
|
4b049c4b7c | |
|
|
7955ee9d35 | |
|
|
1ce63664c0 | |
|
|
4b6e796a0e | |
|
|
79098d3546 | |
|
|
50fc88aae1 | |
|
|
204909b49a | |
|
|
b1c788cc5e | |
|
|
71fe95bf57 | |
|
|
ce9f95b8c8 | |
|
|
9a4d061034 | |
|
|
2444817455 | |
|
|
74e64f69a7 | |
|
|
eb68c0ea5f | |
|
|
c775979ccb | |
|
|
403141c96a | |
|
|
cc9ff74276 |
|
|
@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,9 @@
|
|||
Fixes #<issue-number>
|
||||
|
||||
### Summary
|
||||
- What changed and why in 1–3 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
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
Closes #<issue-number>
|
||||
|
||||
### Summary
|
||||
|
||||
- What and why in 1–3 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)
|
||||
|
|
@ -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`, it’s labeled `Stale`
|
||||
# and receives the relevant “stale” comment.
|
||||
# 3) If there’s 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
|
||||
|
||||
# Don’t 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., 200–1000) once you’re 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'
|
||||
|
|
@ -12,4 +12,6 @@ node_modules
|
|||
|
||||
# generated during the build process
|
||||
#internal/native/include
|
||||
#internal/native/lib
|
||||
#internal/native/lib
|
||||
|
||||
ui/reports
|
||||
|
|
@ -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"
|
||||
]
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
180
DEVELOPMENT.md
180
DEVELOPMENT.md
|
|
@ -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)
|
||||
|
||||
[](https://twitter.com/jetkvm)
|
||||
|
||||
[](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 :
|
||||
|
||||

|
||||
|
||||
#### 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.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
6
Makefile
6
Makefile
|
|
@ -12,7 +12,13 @@ BUILDKIT_FLAVOR := arm-rockchip830-linux-uclibcgnueabihf
|
|||
BUILDKIT_PATH ?= /opt/jetkvm-native-buildkit
|
||||
SKIP_NATIVE_IF_EXISTS ?= 0
|
||||
SKIP_UI_BUILD ?= 0
|
||||
ENABLE_SYNC_TRACE ?= 0
|
||||
|
||||
GO_BUILD_ARGS := -tags netgo,timetzdata,nomsgpack
|
||||
ifeq ($(ENABLE_SYNC_TRACE), 1)
|
||||
GO_BUILD_ARGS := $(GO_BUILD_ARGS),synctrace
|
||||
endif
|
||||
|
||||
GO_RELEASE_BUILD_ARGS := -trimpath $(GO_BUILD_ARGS)
|
||||
GO_LDFLAGS := \
|
||||
-s -w \
|
||||
|
|
|
|||
4
cloud.go
4
cloud.go
|
|
@ -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
|
||||
|
|
|
|||
111
cmd/main.go
111
cmd/main.go
|
|
@ -16,10 +16,10 @@ import (
|
|||
)
|
||||
|
||||
const (
|
||||
envChildID = "JETKVM_CHILD_ID"
|
||||
errorDumpDir = "/userdata/jetkvm/"
|
||||
errorDumpStateFile = ".has_error_dump"
|
||||
errorDumpTemplate = "jetkvm-%s.log"
|
||||
envChildID = "JETKVM_CHILD_ID"
|
||||
errorDumpDir = "/userdata/jetkvm/crashdump"
|
||||
errorDumpLastFile = "last-crash.log"
|
||||
errorDumpTemplate = "jetkvm-%s.log"
|
||||
)
|
||||
|
||||
func program() {
|
||||
|
|
@ -117,30 +117,47 @@ func supervise() error {
|
|||
return nil
|
||||
}
|
||||
|
||||
func createErrorDump(logFile *os.File) {
|
||||
logFile.Close()
|
||||
|
||||
// touch the error dump state file
|
||||
if err := os.WriteFile(filepath.Join(errorDumpDir, errorDumpStateFile), []byte{}, 0644); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
fileName := fmt.Sprintf(errorDumpTemplate, time.Now().Format("20060102150405"))
|
||||
filePath := filepath.Join(errorDumpDir, fileName)
|
||||
if err := os.Rename(logFile.Name(), filePath); err == nil {
|
||||
fmt.Printf("error dump created: %s\n", filePath)
|
||||
return
|
||||
}
|
||||
|
||||
fnSrc, err := os.Open(logFile.Name())
|
||||
func isSymlinkTo(oldName, newName string) bool {
|
||||
file, err := os.Stat(newName)
|
||||
if err != nil {
|
||||
return
|
||||
return false
|
||||
}
|
||||
if file.Mode()&os.ModeSymlink != os.ModeSymlink {
|
||||
return false
|
||||
}
|
||||
target, err := os.Readlink(newName)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return target == oldName
|
||||
}
|
||||
|
||||
func ensureSymlink(oldName, newName string) error {
|
||||
if isSymlinkTo(oldName, newName) {
|
||||
return nil
|
||||
}
|
||||
_ = os.Remove(newName)
|
||||
return os.Symlink(oldName, newName)
|
||||
}
|
||||
|
||||
func renameFile(f *os.File, newName string) error {
|
||||
_ = f.Close()
|
||||
|
||||
// try to rename the file first
|
||||
if err := os.Rename(f.Name(), newName); err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// copy the log file to the error dump directory
|
||||
fnSrc, err := os.Open(f.Name())
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to open file: %w", err)
|
||||
}
|
||||
defer fnSrc.Close()
|
||||
|
||||
fnDst, err := os.Create(filePath)
|
||||
fnDst, err := os.Create(newName)
|
||||
if err != nil {
|
||||
return
|
||||
return fmt.Errorf("failed to create file: %w", err)
|
||||
}
|
||||
defer fnDst.Close()
|
||||
|
||||
|
|
@ -148,18 +165,60 @@ func createErrorDump(logFile *os.File) {
|
|||
for {
|
||||
n, err := fnSrc.Read(buf)
|
||||
if err != nil && err != io.EOF {
|
||||
return
|
||||
return fmt.Errorf("failed to read file: %w", err)
|
||||
}
|
||||
if n == 0 {
|
||||
break
|
||||
}
|
||||
|
||||
if _, err := fnDst.Write(buf[:n]); err != nil {
|
||||
return
|
||||
return fmt.Errorf("failed to write file: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Printf("error dump created: %s\n", filePath)
|
||||
return nil
|
||||
}
|
||||
|
||||
func ensureErrorDumpDir() error {
|
||||
// TODO: check if the directory is writable
|
||||
f, err := os.Stat(errorDumpDir)
|
||||
if err == nil && f.IsDir() {
|
||||
return nil
|
||||
}
|
||||
if err := os.MkdirAll(errorDumpDir, 0755); err != nil {
|
||||
return fmt.Errorf("failed to create error dump directory: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func createErrorDump(logFile *os.File) {
|
||||
fmt.Println()
|
||||
|
||||
fileName := fmt.Sprintf(
|
||||
errorDumpTemplate,
|
||||
time.Now().Format("20060102-150405"),
|
||||
)
|
||||
|
||||
// check if the directory exists
|
||||
if err := ensureErrorDumpDir(); err != nil {
|
||||
fmt.Printf("failed to ensure error dump directory: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
filePath := filepath.Join(errorDumpDir, fileName)
|
||||
if err := renameFile(logFile, filePath); err != nil {
|
||||
fmt.Printf("failed to rename file: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Printf("error dump copied: %s\n", filePath)
|
||||
|
||||
lastFilePath := filepath.Join(errorDumpDir, errorDumpLastFile)
|
||||
|
||||
if err := ensureSymlink(filePath, lastFilePath); err != nil {
|
||||
fmt.Printf("failed to create symlink: %v\n", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func doSupervise() {
|
||||
|
|
|
|||
143
config.go
143
config.go
|
|
@ -7,8 +7,9 @@ import (
|
|||
"strconv"
|
||||
"sync"
|
||||
|
||||
"github.com/jetkvm/kvm/internal/confparser"
|
||||
"github.com/jetkvm/kvm/internal/logging"
|
||||
"github.com/jetkvm/kvm/internal/network"
|
||||
"github.com/jetkvm/kvm/internal/network/types"
|
||||
"github.com/jetkvm/kvm/internal/usbgadget"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/prometheus/client_golang/prometheus/promauto"
|
||||
|
|
@ -78,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
|
||||
}
|
||||
|
||||
|
|
|
|||
48
display.go
48
display.go
|
|
@ -27,7 +27,12 @@ const (
|
|||
)
|
||||
|
||||
func switchToMainScreen() {
|
||||
if networkState.IsUp() {
|
||||
if networkManager == nil {
|
||||
nativeInstance.SwitchToScreenIfDifferent("no_network_screen")
|
||||
return
|
||||
}
|
||||
|
||||
if networkManager.IsUp() {
|
||||
nativeInstance.SwitchToScreenIfDifferent("home_screen")
|
||||
} else {
|
||||
nativeInstance.SwitchToScreenIfDifferent("no_network_screen")
|
||||
|
|
@ -35,13 +40,21 @@ func switchToMainScreen() {
|
|||
}
|
||||
|
||||
func updateDisplay() {
|
||||
nativeInstance.UpdateLabelIfChanged("home_info_ipv4_addr", networkState.IPv4String())
|
||||
nativeInstance.UpdateLabelAndChangeVisibility("home_info_ipv6_addr", networkState.IPv6String())
|
||||
if networkManager != nil {
|
||||
nativeInstance.UpdateLabelIfChanged("home_info_ipv4_addr", networkManager.IPv4String())
|
||||
nativeInstance.UpdateLabelAndChangeVisibility("home_info_ipv6_addr", networkManager.IPv6String())
|
||||
nativeInstance.UpdateLabelIfChanged("home_info_mac_addr", networkManager.MACString())
|
||||
}
|
||||
|
||||
_, _ = nativeInstance.UIObjHide("menu_btn_network")
|
||||
_, _ = nativeInstance.UIObjHide("menu_btn_access")
|
||||
|
||||
nativeInstance.UpdateLabelIfChanged("home_info_mac_addr", networkState.MACString())
|
||||
switch config.NetworkConfig.DHCPClient.String {
|
||||
case "jetdhcpc":
|
||||
nativeInstance.UpdateLabelIfChanged("dhcp_client_change_label", "Change to udhcpc")
|
||||
case "udhcpc":
|
||||
nativeInstance.UpdateLabelIfChanged("dhcp_client_change_label", "Change to JetKVM")
|
||||
}
|
||||
|
||||
if usbState == "configured" {
|
||||
nativeInstance.UpdateLabelIfChanged("usb_status_label", "Connected")
|
||||
|
|
@ -59,7 +72,7 @@ func updateDisplay() {
|
|||
}
|
||||
nativeInstance.UpdateLabelIfChanged("cloud_status_label", fmt.Sprintf("%d active", actionSessions))
|
||||
|
||||
if networkState.IsUp() {
|
||||
if networkManager != nil && networkManager.IsUp() {
|
||||
nativeInstance.UISetVar("main_screen", "home_screen")
|
||||
nativeInstance.SwitchToScreenIf("home_screen", []string{"no_network_screen", "boot_screen"})
|
||||
} else {
|
||||
|
|
@ -175,7 +188,7 @@ func requestDisplayUpdate(shouldWakeDisplay bool, reason string) {
|
|||
wakeDisplay(false, reason)
|
||||
}
|
||||
displayLogger.Debug().Msg("display updating")
|
||||
//TODO: only run once regardless how many pending updates
|
||||
// TODO: only run once regardless how many pending updates
|
||||
updateDisplay()
|
||||
}()
|
||||
}
|
||||
|
|
@ -184,13 +197,14 @@ func waitCtrlAndRequestDisplayUpdate(shouldWakeDisplay bool, reason string) {
|
|||
waitDisplayUpdate.Lock()
|
||||
defer waitDisplayUpdate.Unlock()
|
||||
|
||||
// nativeInstance.WaitCtrlClientConnected()
|
||||
requestDisplayUpdate(shouldWakeDisplay, reason)
|
||||
}
|
||||
|
||||
func updateStaticContents() {
|
||||
//contents that never change
|
||||
nativeInstance.UpdateLabelIfChanged("home_info_mac_addr", networkState.MACString())
|
||||
if networkManager != nil {
|
||||
nativeInstance.UpdateLabelIfChanged("home_info_mac_addr", networkManager.MACString())
|
||||
}
|
||||
|
||||
// get cpu info
|
||||
if cpuInfo, err := os.ReadFile("/proc/cpuinfo"); err == nil {
|
||||
|
|
@ -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
8
go.mod
|
|
@ -16,6 +16,7 @@ require (
|
|||
github.com/google/uuid v1.6.0
|
||||
github.com/guregu/null/v6 v6.0.0
|
||||
github.com/gwatts/rootcerts v0.0.0-20250901182336-dc5ae18bd79f
|
||||
github.com/insomniacslk/dhcp v0.0.0-20250919081422-f80a1952f48e
|
||||
github.com/pion/logging v0.2.4
|
||||
github.com/pion/mdns/v2 v2.0.7
|
||||
github.com/pion/webrtc/v4 v4.1.4
|
||||
|
|
@ -54,15 +55,20 @@ require (
|
|||
github.com/go-playground/validator/v10 v10.27.0 // indirect
|
||||
github.com/goccy/go-json v0.10.5 // indirect
|
||||
github.com/jonboulle/clockwork v0.5.0 // indirect
|
||||
github.com/josharian/native v1.1.0 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mdlayher/ndp v1.1.0 // indirect
|
||||
github.com/mdlayher/packet v1.1.2 // indirect
|
||||
github.com/mdlayher/socket v0.4.1 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||
github.com/pierrec/lz4/v4 v4.1.14 // indirect
|
||||
github.com/pilebones/go-udev v0.9.1 // indirect
|
||||
github.com/pion/datachannel v1.5.10 // indirect
|
||||
github.com/pion/dtls/v3 v3.0.7 // indirect
|
||||
|
|
@ -82,12 +88,14 @@ require (
|
|||
github.com/robfig/cron/v3 v3.0.1 // indirect
|
||||
github.com/rogpeppe/go-internal v1.14.1 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/u-root/uio v0.0.0-20230220225925-ffce2a382923 // indirect
|
||||
github.com/ugorji/go/codec v1.3.0 // indirect
|
||||
github.com/vishvananda/netns v0.0.5 // indirect
|
||||
github.com/wlynxg/anet v0.0.5 // indirect
|
||||
go.yaml.in/yaml/v2 v2.4.2 // indirect
|
||||
golang.org/x/arch v0.20.0 // indirect
|
||||
golang.org/x/oauth2 v0.30.0 // indirect
|
||||
golang.org/x/sync v0.17.0 // indirect
|
||||
golang.org/x/text v0.29.0 // indirect
|
||||
google.golang.org/protobuf v1.36.9 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
|
|
|
|||
22
go.sum
22
go.sum
|
|
@ -66,8 +66,15 @@ github.com/guregu/null/v6 v6.0.0 h1:N14VRS+4di81i1PXRiprbQJ9EM9gqBa0+KVMeS/QSjQ=
|
|||
github.com/guregu/null/v6 v6.0.0/go.mod h1:hrMIhIfrOZeLPZhROSn149tpw2gHkidAqxoXNyeX3iQ=
|
||||
github.com/gwatts/rootcerts v0.0.0-20250901182336-dc5ae18bd79f h1:08t2PbrkDgW2+mwCQ3jhKUBrCM9Bc9SeH5j2Dst3B+0=
|
||||
github.com/gwatts/rootcerts v0.0.0-20250901182336-dc5ae18bd79f/go.mod h1:5Kt9XkWvkGi2OHOq0QsGxebHmhCcqJ8KCbNg/a6+n+g=
|
||||
github.com/hugelgupf/socketpair v0.0.0-20190730060125-05d35a94e714 h1:/jC7qQFrv8CrSJVmaolDVOxTfS9kc36uB6H40kdbQq8=
|
||||
github.com/hugelgupf/socketpair v0.0.0-20190730060125-05d35a94e714/go.mod h1:2Goc3h8EklBH5mspfHFxBnEoURQCGzQQH1ga9Myjvis=
|
||||
github.com/insomniacslk/dhcp v0.0.0-20250919081422-f80a1952f48e h1:nu5z6Kg+gMNW6tdqnVjg/QEJ8Nw71IJQqOtWj00XHEU=
|
||||
github.com/insomniacslk/dhcp v0.0.0-20250919081422-f80a1952f48e/go.mod h1:qfvBmyDNp+/liLEYWRvqny/PEz9hGe2Dz833eXILSmo=
|
||||
github.com/jonboulle/clockwork v0.5.0 h1:Hyh9A8u51kptdkR+cqRpT1EebBwTn1oK9YfGYbdFz6I=
|
||||
github.com/jonboulle/clockwork v0.5.0/go.mod h1:3mZlmanh0g2NDKO5TWZVJAfofYk64M7XN3SzBPjZF60=
|
||||
github.com/josharian/native v1.0.1-0.20221213033349-c1e37c09b531/go.mod h1:7X/raswPFr05uY3HiLlYeyQntB6OO7E/d2Cu7qoaN2w=
|
||||
github.com/josharian/native v1.1.0 h1:uuaP0hAbW7Y4l0ZRQ6C9zfb7Mg1mbFKry/xzDAfmtLA=
|
||||
github.com/josharian/native v1.1.0/go.mod h1:7X/raswPFr05uY3HiLlYeyQntB6OO7E/d2Cu7qoaN2w=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
|
||||
|
|
@ -92,6 +99,12 @@ github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/
|
|||
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mdlayher/ndp v1.1.0 h1:QylGKGVtH60sKZUE88+IW5ila1Z/M9/OXhWdsVKuscs=
|
||||
github.com/mdlayher/ndp v1.1.0/go.mod h1:FmgESgemgjl38vuOIyAHWUUL6vQKA/pQNkvXdWsdQFM=
|
||||
github.com/mdlayher/packet v1.1.2 h1:3Up1NG6LZrsgDVn6X4L9Ge/iyRyxFEFD9o6Pr3Q1nQY=
|
||||
github.com/mdlayher/packet v1.1.2/go.mod h1:GEu1+n9sG5VtiRE4SydOmX5GTwyyYlteZiFU+x0kew4=
|
||||
github.com/mdlayher/socket v0.4.1 h1:eM9y2/jlbs1M615oshPQOHZzj6R6wMT7bX5NPiQvn2U=
|
||||
github.com/mdlayher/socket v0.4.1/go.mod h1:cAqeGjoufqdxWkD7DkpyS+wcefOtmu5OQ8KuoJGIReA=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
|
|
@ -101,6 +114,8 @@ github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq
|
|||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/pierrec/lz4/v4 v4.1.14 h1:+fL8AQEZtz/ijeNnpduH0bROTu0O3NZAlPjQxGn8LwE=
|
||||
github.com/pierrec/lz4/v4 v4.1.14/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
|
||||
github.com/pilebones/go-udev v0.9.1 h1:uN72M1C1fgzhsVmBGEM8w9RD1JY4iVsPZpr+Z6rb3O8=
|
||||
github.com/pilebones/go-udev v0.9.1/go.mod h1:Bgcl07crebF3JSeS4+nuaRvhWFdCeFoBhXXeAp93XNo=
|
||||
github.com/pion/datachannel v1.5.10 h1:ly0Q26K1i6ZkGf42W7D4hQYR90pZwzFOjTq5AuCKk4o=
|
||||
|
|
@ -161,6 +176,8 @@ github.com/sourcegraph/tf-dag v0.2.2-0.20250131204052-3e8ff1477b4f/go.mod h1:pzr
|
|||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
|
|
@ -169,6 +186,8 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu
|
|||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/u-root/uio v0.0.0-20230220225925-ffce2a382923 h1:tHNk7XK9GkmKUR6Gh8gVBKXc2MVSZ4G/NnWLtzw4gNA=
|
||||
github.com/u-root/uio v0.0.0-20230220225925-ffce2a382923/go.mod h1:eLL9Nub3yfAho7qB0MzZizFhTU2QkLeoVsWdHtDW264=
|
||||
github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA=
|
||||
github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
||||
github.com/vearutop/statigz v1.5.0 h1:FuWwZiT82yBw4xbWdWIawiP2XFTyEPhIo8upRxiKLqk=
|
||||
|
|
@ -193,6 +212,9 @@ golang.org/x/net v0.44.0 h1:evd8IRDyfNBMBTTY5XRF1vaZlD+EmWx6x8PkhR04H/I=
|
|||
golang.org/x/net v0.44.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY=
|
||||
golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
|
||||
golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
|
||||
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
|
||||
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.0.0-20220622161953-175b2fd9d664/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
|
|
|
|||
42
hidrpc.go
42
hidrpc.go
|
|
@ -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
32
hw.go
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -16,22 +16,22 @@ import (
|
|||
type FieldConfig struct {
|
||||
Name string
|
||||
Required bool
|
||||
RequiredIf map[string]any
|
||||
RequiredIf map[string]interface{}
|
||||
OneOf []string
|
||||
ValidateTypes []string
|
||||
Defaults any
|
||||
Defaults interface{}
|
||||
IsEmpty bool
|
||||
CurrentValue any
|
||||
CurrentValue interface{}
|
||||
TypeString string
|
||||
Delegated bool
|
||||
shouldUpdateValue bool
|
||||
}
|
||||
|
||||
func SetDefaultsAndValidate(config any) error {
|
||||
func SetDefaultsAndValidate(config interface{}) error {
|
||||
return setDefaultsAndValidate(config, true)
|
||||
}
|
||||
|
||||
func setDefaultsAndValidate(config any, isRoot bool) error {
|
||||
func setDefaultsAndValidate(config interface{}, isRoot bool) error {
|
||||
// first we need to check if the config is a pointer
|
||||
if reflect.TypeOf(config).Kind() != reflect.Ptr {
|
||||
return fmt.Errorf("config is not a pointer")
|
||||
|
|
@ -55,7 +55,7 @@ func setDefaultsAndValidate(config any, isRoot bool) error {
|
|||
Name: field.Name,
|
||||
OneOf: splitString(field.Tag.Get("one_of")),
|
||||
ValidateTypes: splitString(field.Tag.Get("validate_type")),
|
||||
RequiredIf: make(map[string]any),
|
||||
RequiredIf: make(map[string]interface{}),
|
||||
CurrentValue: fieldValue.Interface(),
|
||||
IsEmpty: false,
|
||||
TypeString: fieldType,
|
||||
|
|
@ -142,8 +142,8 @@ func setDefaultsAndValidate(config any, isRoot bool) error {
|
|||
// now check if the field has required_if
|
||||
requiredIf := field.Tag.Get("required_if")
|
||||
if requiredIf != "" {
|
||||
requiredIfParts := strings.SplitSeq(requiredIf, ",")
|
||||
for part := range requiredIfParts {
|
||||
requiredIfParts := strings.Split(requiredIf, ",")
|
||||
for _, part := range requiredIfParts {
|
||||
partVal := strings.SplitN(part, "=", 2)
|
||||
if len(partVal) != 2 {
|
||||
return fmt.Errorf("invalid required_if for field `%s`: %s", field.Name, requiredIf)
|
||||
|
|
@ -168,7 +168,7 @@ func setDefaultsAndValidate(config any, isRoot bool) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
func validateFields(config any, fields map[string]FieldConfig) error {
|
||||
func validateFields(config interface{}, fields map[string]FieldConfig) error {
|
||||
// now we can start to validate the fields
|
||||
for _, fieldConfig := range fields {
|
||||
if err := fieldConfig.validate(fields); err != nil {
|
||||
|
|
@ -215,7 +215,7 @@ func (f *FieldConfig) validate(fields map[string]FieldConfig) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
func (f *FieldConfig) populate(config any) {
|
||||
func (f *FieldConfig) populate(config interface{}) {
|
||||
// update the field if it's not empty
|
||||
if !f.shouldUpdateValue {
|
||||
return
|
||||
|
|
@ -346,6 +346,17 @@ func (f *FieldConfig) validateField() error {
|
|||
return nil
|
||||
}
|
||||
|
||||
// Handle []string types, like dns servers, time sync ntp servers, etc.
|
||||
if slice, ok := f.CurrentValue.([]string); ok {
|
||||
for i, item := range slice {
|
||||
if err := f.validateSingleValue(item, i); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Handle single string types
|
||||
val, err := toString(f.CurrentValue)
|
||||
if err != nil {
|
||||
return fmt.Errorf("field `%s` cannot use validate_type: %s", f.Name, err)
|
||||
|
|
@ -355,30 +366,71 @@ func (f *FieldConfig) validateField() error {
|
|||
return nil
|
||||
}
|
||||
|
||||
return f.validateSingleValue(val, -1)
|
||||
}
|
||||
|
||||
func (f *FieldConfig) validateSingleValue(val string, index int) error {
|
||||
for _, validateType := range f.ValidateTypes {
|
||||
var fieldRef string
|
||||
if index >= 0 {
|
||||
fieldRef = fmt.Sprintf("field `%s[%d]`", f.Name, index)
|
||||
} else {
|
||||
fieldRef = fmt.Sprintf("field `%s`", f.Name)
|
||||
}
|
||||
|
||||
switch validateType {
|
||||
case "int":
|
||||
if _, err := strconv.Atoi(val); err != nil {
|
||||
return fmt.Errorf("field `%s` is not a valid integer: %s", fieldRef, val)
|
||||
}
|
||||
case "ipv6_prefix_length":
|
||||
valInt, err := strconv.Atoi(val)
|
||||
if err != nil {
|
||||
return fmt.Errorf("field `%s` is not a valid IPv6 prefix length: %s", fieldRef, val)
|
||||
}
|
||||
if valInt < 0 || valInt > 128 {
|
||||
return fmt.Errorf("field `%s` is not a valid IPv6 prefix length: %s", fieldRef, val)
|
||||
}
|
||||
case "ipv4":
|
||||
if net.ParseIP(val).To4() == nil {
|
||||
return fmt.Errorf("field `%s` is not a valid IPv4 address: %s", f.Name, val)
|
||||
return fmt.Errorf("field `%s` is not a valid IPv4 address: %s", fieldRef, val)
|
||||
}
|
||||
case "ipv6":
|
||||
if net.ParseIP(val).To16() == nil {
|
||||
return fmt.Errorf("field `%s` is not a valid IPv6 address: %s", f.Name, val)
|
||||
return fmt.Errorf("field `%s` is not a valid IPv6 address: %s", fieldRef, val)
|
||||
}
|
||||
case "ipv6_prefix":
|
||||
if i, _, err := net.ParseCIDR(val); err != nil {
|
||||
if i.To16() == nil {
|
||||
return fmt.Errorf("field `%s` is not a valid IPv6 prefix: %s", fieldRef, val)
|
||||
}
|
||||
}
|
||||
case "ipv4_or_ipv6":
|
||||
if net.ParseIP(val) == nil {
|
||||
return fmt.Errorf("%s is not a valid IPv4 or IPv6 address: %s", fieldRef, val)
|
||||
}
|
||||
case "hwaddr":
|
||||
if _, err := net.ParseMAC(val); err != nil {
|
||||
return fmt.Errorf("field `%s` is not a valid MAC address: %s", f.Name, val)
|
||||
return fmt.Errorf("%s is not a valid MAC address: %s", fieldRef, val)
|
||||
}
|
||||
case "hostname":
|
||||
if _, err := idna.Lookup.ToASCII(val); err != nil {
|
||||
return fmt.Errorf("field `%s` is not a valid hostname: %s", f.Name, val)
|
||||
return fmt.Errorf("%s is not a valid hostname: %s", fieldRef, val)
|
||||
}
|
||||
case "proxy":
|
||||
if url, err := url.Parse(val); err != nil || (url.Scheme != "http" && url.Scheme != "https") || url.Host == "" {
|
||||
return fmt.Errorf("field `%s` is not a valid HTTP proxy URL: %s", f.Name, val)
|
||||
return fmt.Errorf("%s is not a valid HTTP proxy URL: %s", fieldRef, val)
|
||||
}
|
||||
case "url":
|
||||
if _, err := url.Parse(val); err != nil {
|
||||
return fmt.Errorf("%s is not a valid URL: %s", fieldRef, val)
|
||||
}
|
||||
case "cidr":
|
||||
if _, _, err := net.ParseCIDR(val); err != nil {
|
||||
return fmt.Errorf("%s is not a valid CIDR notation: %s", fieldRef, val)
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("field `%s` cannot use validate_type: unsupported validator: %s", f.Name, validateType)
|
||||
return fmt.Errorf("field `%s` cannot use validate_type: unsupported validator: %s", fieldRef, validateType)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -24,10 +24,10 @@ type testIPv4StaticConfig struct {
|
|||
}
|
||||
|
||||
type testIPv6StaticConfig struct {
|
||||
Address null.String `json:"address" validate_type:"ipv6" required:"true"`
|
||||
Prefix null.String `json:"prefix" validate_type:"ipv6" required:"true"`
|
||||
Gateway null.String `json:"gateway" validate_type:"ipv6" required:"true"`
|
||||
DNS []string `json:"dns" validate_type:"ipv6" required:"true"`
|
||||
Address null.String `json:"address" validate_type:"ipv6" required:"true"`
|
||||
PrefixLength null.Int `json:"prefix_length" validate_type:"ipv6_prefix_length" required:"true"`
|
||||
Gateway null.String `json:"gateway" validate_type:"ipv6" required:"true"`
|
||||
DNS []string `json:"dns" validate_type:"ipv6" required:"true"`
|
||||
}
|
||||
type testNetworkConfig struct {
|
||||
Hostname null.String `json:"hostname,omitempty"`
|
||||
|
|
@ -39,7 +39,7 @@ type testNetworkConfig struct {
|
|||
IPv6Mode null.String `json:"ipv6_mode" one_of:"slaac,dhcpv6,slaac_and_dhcpv6,static,link_local,disabled" default:"slaac"`
|
||||
IPv6Static *testIPv6StaticConfig `json:"ipv6_static,omitempty" required_if:"IPv6Mode=static"`
|
||||
|
||||
LLDPMode null.String `json:"lldp_mode,omitempty" one_of:"disabled,rx_only,tx_only,enabled" default:"enabled"`
|
||||
LLDPMode null.String `json:"lldp_mode,omitempty" one_of:"disabled,basic,all" default:"basic"`
|
||||
LLDPTxTLVs []string `json:"lldp_tx_tlvs,omitempty" one_of:"chassis,port,system,vlan" default:"chassis,port,system,vlan"`
|
||||
MDNSMode null.String `json:"mdns_mode,omitempty" one_of:"disabled,auto,ipv4_only,ipv6_only" default:"auto"`
|
||||
TimeSyncMode null.String `json:"time_sync_mode,omitempty" one_of:"ntp_only,ntp_and_http,http_only,custom" default:"ntp_and_http"`
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ func splitString(s string) []string {
|
|||
return strings.Split(s, ",")
|
||||
}
|
||||
|
||||
func toString(v any) (string, error) {
|
||||
func toString(v interface{}) (string, error) {
|
||||
switch v := v.(type) {
|
||||
case string:
|
||||
return v, nil
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -48,6 +48,10 @@ void action_switch_to_reset_config(lv_event_t *e) {
|
|||
loadScreen(SCREEN_ID_RESET_CONFIG_SCREEN);
|
||||
}
|
||||
|
||||
void action_switch_to_dhcpc(lv_event_t *e) {
|
||||
loadScreen(SCREEN_ID_SWITCH_DHCP_CLIENT_SCREEN);
|
||||
}
|
||||
|
||||
void action_switch_to_reboot(lv_event_t *e) {
|
||||
loadScreen(SCREEN_ID_REBOOT_SCREEN);
|
||||
}
|
||||
|
|
@ -75,15 +79,19 @@ void action_about_screen_gesture(lv_event_t * e) {
|
|||
// user_data doesn't seem to be working, so we use a global variable here
|
||||
static uint32_t t_reset_config;
|
||||
static uint32_t t_reboot;
|
||||
static uint32_t t_dhcpc;
|
||||
|
||||
static bool b_reboot = false;
|
||||
static bool b_reset_config = false;
|
||||
static bool b_dhcpc = false;
|
||||
|
||||
static bool b_reboot_lock = false;
|
||||
static bool b_reset_config_lock = false;
|
||||
static bool b_dhcpc_lock = false;
|
||||
|
||||
const int RESET_CONFIG_HOLD_TIME = 10;
|
||||
const int REBOOT_HOLD_TIME = 5;
|
||||
const int DHCPC_HOLD_TIME = 5;
|
||||
|
||||
typedef struct {
|
||||
uint32_t *start_time;
|
||||
|
|
@ -153,6 +161,22 @@ void action_reset_config(lv_event_t * e) {
|
|||
handle_hold_action(e, &config);
|
||||
}
|
||||
|
||||
void action_dhcpc(lv_event_t * e) {
|
||||
hold_action_config_t config = {
|
||||
.start_time = &t_dhcpc,
|
||||
.completed = &b_dhcpc,
|
||||
.lock = &b_dhcpc_lock,
|
||||
.hold_time_seconds = DHCPC_HOLD_TIME,
|
||||
.rpc_method = "toggleDHCPClient",
|
||||
.button_obj = NULL, // No button/spinner for reboot
|
||||
.spinner_obj = NULL,
|
||||
.label_obj = objects.dhcpc_label,
|
||||
.default_text = "Press and hold for\n5 seconds"
|
||||
};
|
||||
|
||||
handle_hold_action(e, &config);
|
||||
}
|
||||
|
||||
void action_reboot(lv_event_t * e) {
|
||||
hold_action_config_t config = {
|
||||
.start_time = &t_reboot,
|
||||
|
|
|
|||
|
|
@ -24,6 +24,8 @@ extern void action_handle_common_press_event(lv_event_t * e);
|
|||
extern void action_reset_config(lv_event_t * e);
|
||||
extern void action_reboot(lv_event_t * e);
|
||||
extern void action_switch_to_reboot(lv_event_t * e);
|
||||
extern void action_dhcpc(lv_event_t * e);
|
||||
extern void action_switch_to_dhcpc(lv_event_t * e);
|
||||
|
||||
|
||||
#ifdef __cplusplus
|
||||
|
|
|
|||
|
|
@ -887,6 +887,26 @@ void create_screen_menu_advanced_screen() {
|
|||
}
|
||||
}
|
||||
}
|
||||
{
|
||||
// MenuBtnDHCPClient
|
||||
lv_obj_t *obj = lv_button_create(parent_obj);
|
||||
objects.menu_btn_dhcp_client = obj;
|
||||
lv_obj_set_pos(obj, 0, 0);
|
||||
lv_obj_set_size(obj, LV_PCT(100), 50);
|
||||
lv_obj_add_event_cb(obj, action_switch_to_dhcpc, LV_EVENT_PRESSED, (void *)0);
|
||||
lv_obj_clear_flag(obj, LV_OBJ_FLAG_SNAPPABLE);
|
||||
add_style_menu_button(obj);
|
||||
{
|
||||
lv_obj_t *parent_obj = obj;
|
||||
{
|
||||
lv_obj_t *obj = lv_label_create(parent_obj);
|
||||
lv_obj_set_pos(obj, 0, 0);
|
||||
lv_obj_set_size(obj, LV_SIZE_CONTENT, LV_SIZE_CONTENT);
|
||||
add_style_menu_button_label(obj);
|
||||
lv_label_set_text(obj, "DHCP Client");
|
||||
}
|
||||
}
|
||||
}
|
||||
{
|
||||
// MenuBtnAdvancedResetConfig
|
||||
lv_obj_t *obj = lv_button_create(parent_obj);
|
||||
|
|
@ -2197,6 +2217,221 @@ void create_screen_rebooting_screen() {
|
|||
void tick_screen_rebooting_screen() {
|
||||
}
|
||||
|
||||
void create_screen_switch_dhcp_client_screen() {
|
||||
lv_obj_t *obj = lv_obj_create(0);
|
||||
objects.switch_dhcp_client_screen = obj;
|
||||
lv_obj_set_pos(obj, 0, 0);
|
||||
lv_obj_set_size(obj, 300, 240);
|
||||
lv_obj_add_event_cb(obj, action_about_screen_gesture, LV_EVENT_GESTURE, (void *)0);
|
||||
add_style_flex_screen_menu(obj);
|
||||
{
|
||||
lv_obj_t *parent_obj = obj;
|
||||
{
|
||||
lv_obj_t *obj = lv_obj_create(parent_obj);
|
||||
lv_obj_set_pos(obj, 0, 0);
|
||||
lv_obj_set_size(obj, LV_PCT(100), LV_PCT(100));
|
||||
lv_obj_set_style_pad_left(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT);
|
||||
lv_obj_set_style_pad_top(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT);
|
||||
lv_obj_set_style_pad_right(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT);
|
||||
lv_obj_set_style_pad_bottom(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT);
|
||||
lv_obj_set_style_bg_opa(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT);
|
||||
lv_obj_set_style_border_width(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT);
|
||||
lv_obj_set_style_radius(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT);
|
||||
lv_obj_clear_flag(obj, LV_OBJ_FLAG_SCROLLABLE);
|
||||
add_style_flex_start(obj);
|
||||
{
|
||||
lv_obj_t *parent_obj = obj;
|
||||
{
|
||||
// DHCPClientHeader
|
||||
lv_obj_t *obj = lv_obj_create(parent_obj);
|
||||
objects.dhcp_client_header = obj;
|
||||
lv_obj_set_pos(obj, 0, 0);
|
||||
lv_obj_set_size(obj, LV_PCT(100), LV_SIZE_CONTENT);
|
||||
lv_obj_set_style_pad_left(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT);
|
||||
lv_obj_set_style_pad_top(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT);
|
||||
lv_obj_set_style_pad_bottom(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT);
|
||||
lv_obj_set_style_bg_opa(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT);
|
||||
lv_obj_set_style_border_width(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT);
|
||||
lv_obj_set_style_radius(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT);
|
||||
add_style_flow_row_space_between(obj);
|
||||
lv_obj_set_style_pad_right(obj, 4, LV_PART_MAIN | LV_STATE_DEFAULT);
|
||||
{
|
||||
lv_obj_t *parent_obj = obj;
|
||||
{
|
||||
lv_obj_t *obj = lv_button_create(parent_obj);
|
||||
lv_obj_set_pos(obj, 0, 0);
|
||||
lv_obj_set_size(obj, 32, 32);
|
||||
lv_obj_add_event_cb(obj, action_switch_to_menu, LV_EVENT_CLICKED, (void *)0);
|
||||
add_style_back_button(obj);
|
||||
{
|
||||
lv_obj_t *parent_obj = obj;
|
||||
{
|
||||
lv_obj_t *obj = lv_image_create(parent_obj);
|
||||
lv_obj_set_pos(obj, -1, 2);
|
||||
lv_obj_set_size(obj, LV_SIZE_CONTENT, LV_SIZE_CONTENT);
|
||||
lv_image_set_src(obj, &img_back_caret);
|
||||
}
|
||||
}
|
||||
}
|
||||
{
|
||||
lv_obj_t *obj = lv_label_create(parent_obj);
|
||||
lv_obj_set_pos(obj, LV_PCT(0), LV_PCT(0));
|
||||
lv_obj_set_size(obj, LV_SIZE_CONTENT, LV_SIZE_CONTENT);
|
||||
add_style_header_link(obj);
|
||||
lv_label_set_text(obj, "DHCP Client");
|
||||
}
|
||||
}
|
||||
}
|
||||
{
|
||||
// DHCPClientContainer
|
||||
lv_obj_t *obj = lv_obj_create(parent_obj);
|
||||
objects.dhcp_client_container = obj;
|
||||
lv_obj_set_pos(obj, 0, 0);
|
||||
lv_obj_set_size(obj, LV_PCT(100), LV_PCT(80));
|
||||
lv_obj_set_style_pad_left(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT);
|
||||
lv_obj_set_style_pad_top(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT);
|
||||
lv_obj_set_style_pad_bottom(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT);
|
||||
lv_obj_set_style_bg_opa(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT);
|
||||
lv_obj_set_style_border_width(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT);
|
||||
lv_obj_set_style_radius(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT);
|
||||
lv_obj_set_scrollbar_mode(obj, LV_SCROLLBAR_MODE_AUTO);
|
||||
lv_obj_set_scroll_dir(obj, LV_DIR_VER);
|
||||
lv_obj_set_scroll_snap_x(obj, LV_SCROLL_SNAP_START);
|
||||
add_style_flex_column_start(obj);
|
||||
lv_obj_set_style_pad_right(obj, 4, LV_PART_MAIN | LV_STATE_DEFAULT);
|
||||
{
|
||||
lv_obj_t *parent_obj = obj;
|
||||
{
|
||||
lv_obj_t *obj = lv_obj_create(parent_obj);
|
||||
lv_obj_set_pos(obj, 0, 0);
|
||||
lv_obj_set_size(obj, LV_PCT(100), LV_SIZE_CONTENT);
|
||||
lv_obj_set_style_pad_left(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT);
|
||||
lv_obj_set_style_pad_top(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT);
|
||||
lv_obj_set_style_pad_bottom(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT);
|
||||
lv_obj_set_style_bg_opa(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT);
|
||||
lv_obj_set_style_border_width(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT);
|
||||
lv_obj_set_style_radius(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT);
|
||||
lv_obj_clear_flag(obj, LV_OBJ_FLAG_SCROLLABLE);
|
||||
add_style_flex_column_start(obj);
|
||||
lv_obj_set_style_pad_right(obj, 10, LV_PART_MAIN | LV_STATE_DEFAULT);
|
||||
{
|
||||
lv_obj_t *parent_obj = obj;
|
||||
{
|
||||
// DHCPClientLabelContainer
|
||||
lv_obj_t *obj = lv_obj_create(parent_obj);
|
||||
objects.dhcp_client_label_container = obj;
|
||||
lv_obj_set_pos(obj, 0, 0);
|
||||
lv_obj_set_size(obj, LV_PCT(100), LV_SIZE_CONTENT);
|
||||
lv_obj_set_style_bg_opa(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT);
|
||||
lv_obj_set_style_border_width(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT);
|
||||
lv_obj_set_style_radius(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT);
|
||||
lv_obj_clear_flag(obj, LV_OBJ_FLAG_SCROLLABLE);
|
||||
add_style_flex_column_start(obj);
|
||||
lv_obj_set_style_pad_right(obj, 10, LV_PART_MAIN | LV_STATE_DEFAULT);
|
||||
lv_obj_set_style_pad_left(obj, 10, LV_PART_MAIN | LV_STATE_DEFAULT);
|
||||
lv_obj_set_style_pad_top(obj, 10, LV_PART_MAIN | LV_STATE_DEFAULT);
|
||||
lv_obj_set_style_pad_bottom(obj, 10, LV_PART_MAIN | LV_STATE_DEFAULT);
|
||||
{
|
||||
lv_obj_t *parent_obj = obj;
|
||||
{
|
||||
// DHCPC_Label
|
||||
lv_obj_t *obj = lv_label_create(parent_obj);
|
||||
objects.dhcpc_label = obj;
|
||||
lv_obj_set_pos(obj, 0, 0);
|
||||
lv_obj_set_size(obj, LV_PCT(100), LV_SIZE_CONTENT);
|
||||
add_style_info_content_label(obj);
|
||||
lv_obj_set_style_text_font(obj, &ui_font_font_book20, LV_PART_MAIN | LV_STATE_DEFAULT);
|
||||
lv_label_set_text(obj, "Press and hold for\n5 seconds");
|
||||
}
|
||||
}
|
||||
}
|
||||
{
|
||||
// DHCPClientSpinner
|
||||
lv_obj_t *obj = lv_obj_create(parent_obj);
|
||||
objects.dhcp_client_spinner = obj;
|
||||
lv_obj_set_pos(obj, 0, 0);
|
||||
lv_obj_set_size(obj, LV_PCT(100), LV_SIZE_CONTENT);
|
||||
lv_obj_set_style_pad_left(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT);
|
||||
lv_obj_set_style_pad_top(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT);
|
||||
lv_obj_set_style_pad_right(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT);
|
||||
lv_obj_set_style_pad_bottom(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT);
|
||||
lv_obj_set_style_bg_opa(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT);
|
||||
lv_obj_set_style_border_width(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT);
|
||||
lv_obj_set_style_radius(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT);
|
||||
lv_obj_add_flag(obj, LV_OBJ_FLAG_HIDDEN);
|
||||
lv_obj_clear_flag(obj, LV_OBJ_FLAG_CLICKABLE|LV_OBJ_FLAG_SCROLLABLE);
|
||||
add_style_flex_column_start(obj);
|
||||
lv_obj_set_style_flex_main_place(obj, LV_FLEX_ALIGN_CENTER, LV_PART_MAIN | LV_STATE_DEFAULT);
|
||||
lv_obj_set_style_flex_cross_place(obj, LV_FLEX_ALIGN_CENTER, LV_PART_MAIN | LV_STATE_DEFAULT);
|
||||
lv_obj_set_style_flex_track_place(obj, LV_FLEX_ALIGN_CENTER, LV_PART_MAIN | LV_STATE_DEFAULT);
|
||||
{
|
||||
lv_obj_t *parent_obj = obj;
|
||||
{
|
||||
lv_obj_t *obj = lv_spinner_create(parent_obj);
|
||||
lv_obj_set_pos(obj, 0, 0);
|
||||
lv_obj_set_size(obj, 80, 80);
|
||||
lv_spinner_set_anim_params(obj, 1000, 60);
|
||||
}
|
||||
}
|
||||
}
|
||||
{
|
||||
// DHCPClientButton
|
||||
lv_obj_t *obj = lv_obj_create(parent_obj);
|
||||
objects.dhcp_client_button = obj;
|
||||
lv_obj_set_pos(obj, 0, 0);
|
||||
lv_obj_set_size(obj, LV_PCT(100), LV_SIZE_CONTENT);
|
||||
lv_obj_set_style_pad_left(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT);
|
||||
lv_obj_set_style_pad_top(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT);
|
||||
lv_obj_set_style_pad_right(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT);
|
||||
lv_obj_set_style_pad_bottom(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT);
|
||||
lv_obj_set_style_bg_opa(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT);
|
||||
lv_obj_set_style_border_width(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT);
|
||||
lv_obj_set_style_radius(obj, 0, LV_PART_MAIN | LV_STATE_DEFAULT);
|
||||
lv_obj_clear_flag(obj, LV_OBJ_FLAG_SCROLLABLE);
|
||||
add_style_flex_column_start(obj);
|
||||
{
|
||||
lv_obj_t *parent_obj = obj;
|
||||
{
|
||||
lv_obj_t *obj = lv_button_create(parent_obj);
|
||||
objects.obj2 = obj;
|
||||
lv_obj_set_pos(obj, 0, 0);
|
||||
lv_obj_set_size(obj, LV_PCT(100), 50);
|
||||
lv_obj_add_event_cb(obj, action_dhcpc, LV_EVENT_PRESSED, (void *)0);
|
||||
lv_obj_add_event_cb(obj, action_dhcpc, LV_EVENT_PRESSING, (void *)0);
|
||||
lv_obj_add_event_cb(obj, action_dhcpc, LV_EVENT_RELEASED, (void *)0);
|
||||
lv_obj_set_style_bg_color(obj, lv_color_hex(0xffdc2626), LV_PART_MAIN | LV_STATE_DEFAULT);
|
||||
lv_obj_set_style_text_align(obj, LV_TEXT_ALIGN_LEFT, LV_PART_MAIN | LV_STATE_DEFAULT);
|
||||
lv_obj_set_style_pad_right(obj, 13, LV_PART_MAIN | LV_STATE_DEFAULT);
|
||||
{
|
||||
lv_obj_t *parent_obj = obj;
|
||||
{
|
||||
// DHCPClientChangeLabel
|
||||
lv_obj_t *obj = lv_label_create(parent_obj);
|
||||
objects.dhcp_client_change_label = obj;
|
||||
lv_obj_set_pos(obj, 0, 0);
|
||||
lv_obj_set_size(obj, LV_SIZE_CONTENT, LV_SIZE_CONTENT);
|
||||
lv_obj_set_style_align(obj, LV_ALIGN_CENTER, LV_PART_MAIN | LV_STATE_DEFAULT);
|
||||
lv_obj_set_style_text_align(obj, LV_TEXT_ALIGN_LEFT, LV_PART_MAIN | LV_STATE_DEFAULT);
|
||||
lv_label_set_text(obj, "Switch to udhcpc");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tick_screen_switch_dhcp_client_screen();
|
||||
}
|
||||
|
||||
void tick_screen_switch_dhcp_client_screen() {
|
||||
}
|
||||
|
||||
|
||||
|
||||
typedef void (*tick_screen_func_t)();
|
||||
|
|
@ -2212,6 +2447,7 @@ tick_screen_func_t tick_screen_funcs[] = {
|
|||
tick_screen_reset_config_screen,
|
||||
tick_screen_reboot_screen,
|
||||
tick_screen_rebooting_screen,
|
||||
tick_screen_switch_dhcp_client_screen,
|
||||
};
|
||||
void tick_screen(int screen_index) {
|
||||
tick_screen_funcs[screen_index]();
|
||||
|
|
@ -2236,4 +2472,5 @@ void create_screens() {
|
|||
create_screen_reset_config_screen();
|
||||
create_screen_reboot_screen();
|
||||
create_screen_rebooting_screen();
|
||||
create_screen_switch_dhcp_client_screen();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ typedef struct _objects_t {
|
|||
lv_obj_t *reset_config_screen;
|
||||
lv_obj_t *reboot_screen;
|
||||
lv_obj_t *rebooting_screen;
|
||||
lv_obj_t *switch_dhcp_client_screen;
|
||||
lv_obj_t *boot_logo;
|
||||
lv_obj_t *boot_screen_version;
|
||||
lv_obj_t *no_network_header_container;
|
||||
|
|
@ -54,6 +55,7 @@ typedef struct _objects_t {
|
|||
lv_obj_t *menu_btn_advanced_developer_mode;
|
||||
lv_obj_t *menu_btn_advanced_usb_emulation;
|
||||
lv_obj_t *menu_btn_advanced_reboot;
|
||||
lv_obj_t *menu_btn_dhcp_client;
|
||||
lv_obj_t *menu_btn_advanced_reset_config;
|
||||
lv_obj_t *menu_header_container_2;
|
||||
lv_obj_t *menu_items_container_2;
|
||||
|
|
@ -101,6 +103,14 @@ typedef struct _objects_t {
|
|||
lv_obj_t *obj1;
|
||||
lv_obj_t *reboot_in_progress_logo;
|
||||
lv_obj_t *reboot_in_progress_label;
|
||||
lv_obj_t *dhcp_client_header;
|
||||
lv_obj_t *dhcp_client_container;
|
||||
lv_obj_t *dhcp_client_label_container;
|
||||
lv_obj_t *dhcpc_label;
|
||||
lv_obj_t *dhcp_client_spinner;
|
||||
lv_obj_t *dhcp_client_button;
|
||||
lv_obj_t *obj2;
|
||||
lv_obj_t *dhcp_client_change_label;
|
||||
} objects_t;
|
||||
|
||||
extern objects_t objects;
|
||||
|
|
@ -117,6 +127,7 @@ enum ScreensEnum {
|
|||
SCREEN_ID_RESET_CONFIG_SCREEN = 9,
|
||||
SCREEN_ID_REBOOT_SCREEN = 10,
|
||||
SCREEN_ID_REBOOTING_SCREEN = 11,
|
||||
SCREEN_ID_SWITCH_DHCP_CLIENT_SCREEN = 12,
|
||||
};
|
||||
|
||||
void create_screen_boot_screen();
|
||||
|
|
@ -151,6 +162,9 @@ void tick_screen_reboot_screen();
|
|||
|
||||
void create_screen_rebooting_screen();
|
||||
void tick_screen_rebooting_screen();
|
||||
|
||||
void create_screen_switch_dhcp_client_screen();
|
||||
void tick_screen_switch_dhcp_client_screen();
|
||||
|
||||
void tick_screen_by_id(enum ScreensEnum screenId);
|
||||
void tick_screen(int screen_index);
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +0,0 @@
|
|||
package network
|
||||
|
||||
type DhcpTargetState int
|
||||
|
||||
const (
|
||||
DhcpTargetStateDoNothing DhcpTargetState = iota
|
||||
DhcpTargetStateStart
|
||||
DhcpTargetStateStop
|
||||
DhcpTargetStateRenew
|
||||
DhcpTargetStateRelease
|
||||
)
|
||||
|
|
@ -1,137 +0,0 @@
|
|||
package network
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"golang.org/x/net/idna"
|
||||
)
|
||||
|
||||
const (
|
||||
hostnamePath = "/etc/hostname"
|
||||
hostsPath = "/etc/hosts"
|
||||
)
|
||||
|
||||
var (
|
||||
hostnameLock sync.Mutex = sync.Mutex{}
|
||||
)
|
||||
|
||||
func updateEtcHosts(hostname string, fqdn string) error {
|
||||
// update /etc/hosts
|
||||
hostsFile, err := os.OpenFile(hostsPath, os.O_RDWR|os.O_SYNC, os.ModeExclusive)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to open %s: %w", hostsPath, err)
|
||||
}
|
||||
defer hostsFile.Close()
|
||||
|
||||
// read all lines
|
||||
if _, err := hostsFile.Seek(0, io.SeekStart); err != nil {
|
||||
return fmt.Errorf("failed to seek %s: %w", hostsPath, err)
|
||||
}
|
||||
|
||||
lines, err := io.ReadAll(hostsFile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read %s: %w", hostsPath, err)
|
||||
}
|
||||
|
||||
newLines := []string{}
|
||||
hostLine := fmt.Sprintf("127.0.1.1\t%s %s", hostname, fqdn)
|
||||
hostLineExists := false
|
||||
|
||||
for line := range strings.SplitSeq(string(lines), "\n") {
|
||||
if strings.HasPrefix(line, "127.0.1.1") {
|
||||
hostLineExists = true
|
||||
line = hostLine
|
||||
}
|
||||
newLines = append(newLines, line)
|
||||
}
|
||||
|
||||
if !hostLineExists {
|
||||
newLines = append(newLines, hostLine)
|
||||
}
|
||||
|
||||
if err := hostsFile.Truncate(0); err != nil {
|
||||
return fmt.Errorf("failed to truncate %s: %w", hostsPath, err)
|
||||
}
|
||||
|
||||
if _, err := hostsFile.Seek(0, io.SeekStart); err != nil {
|
||||
return fmt.Errorf("failed to seek %s: %w", hostsPath, err)
|
||||
}
|
||||
|
||||
if _, err := hostsFile.Write([]byte(strings.Join(newLines, "\n"))); err != nil {
|
||||
return fmt.Errorf("failed to write %s: %w", hostsPath, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func ToValidHostname(hostname string) string {
|
||||
ascii, err := idna.Lookup.ToASCII(hostname)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return ascii
|
||||
}
|
||||
|
||||
func SetHostname(hostname string, fqdn string) error {
|
||||
hostnameLock.Lock()
|
||||
defer hostnameLock.Unlock()
|
||||
|
||||
hostname = ToValidHostname(strings.TrimSpace(hostname))
|
||||
fqdn = ToValidHostname(strings.TrimSpace(fqdn))
|
||||
|
||||
if hostname == "" {
|
||||
return fmt.Errorf("invalid hostname: %s", hostname)
|
||||
}
|
||||
|
||||
if fqdn == "" {
|
||||
fqdn = hostname
|
||||
}
|
||||
|
||||
// update /etc/hostname
|
||||
if err := os.WriteFile(hostnamePath, []byte(hostname), 0644); err != nil {
|
||||
return fmt.Errorf("failed to write %s: %w", hostnamePath, err)
|
||||
}
|
||||
|
||||
// update /etc/hosts
|
||||
if err := updateEtcHosts(hostname, fqdn); err != nil {
|
||||
return fmt.Errorf("failed to update /etc/hosts: %w", err)
|
||||
}
|
||||
|
||||
// run hostname
|
||||
if err := exec.Command("hostname", "-F", hostnamePath).Run(); err != nil {
|
||||
return fmt.Errorf("failed to run hostname: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *NetworkInterfaceState) setHostnameIfNotSame() error {
|
||||
hostname := s.GetHostname()
|
||||
currentHostname, _ := os.Hostname()
|
||||
|
||||
fqdn := fmt.Sprintf("%s.%s", hostname, s.GetDomain())
|
||||
|
||||
if currentHostname == hostname && s.currentFqdn == fqdn && s.currentHostname == hostname {
|
||||
return nil
|
||||
}
|
||||
|
||||
scopedLogger := s.l.With().Str("hostname", hostname).Str("fqdn", fqdn).Logger()
|
||||
|
||||
err := SetHostname(hostname, fqdn)
|
||||
if err != nil {
|
||||
scopedLogger.Error().Err(err).Msg("failed to set hostname")
|
||||
return err
|
||||
}
|
||||
|
||||
s.currentHostname = hostname
|
||||
s.currentFqdn = fqdn
|
||||
|
||||
scopedLogger.Info().Msg("hostname set")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
@ -1,403 +0,0 @@
|
|||
package network
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"sync"
|
||||
|
||||
"github.com/jetkvm/kvm/internal/confparser"
|
||||
"github.com/jetkvm/kvm/internal/logging"
|
||||
"github.com/jetkvm/kvm/internal/udhcpc"
|
||||
"github.com/rs/zerolog"
|
||||
|
||||
"github.com/vishvananda/netlink"
|
||||
)
|
||||
|
||||
type NetworkInterfaceState struct {
|
||||
interfaceName string
|
||||
interfaceUp bool
|
||||
ipv4Addr *net.IP
|
||||
ipv4Addresses []string
|
||||
ipv6Addr *net.IP
|
||||
ipv6Addresses []IPv6Address
|
||||
ipv6LinkLocal *net.IP
|
||||
ntpAddresses []*net.IP
|
||||
macAddr *net.HardwareAddr
|
||||
|
||||
l *zerolog.Logger
|
||||
stateLock sync.Mutex
|
||||
|
||||
config *NetworkConfig
|
||||
dhcpClient *udhcpc.DHCPClient
|
||||
|
||||
defaultHostname string
|
||||
currentHostname string
|
||||
currentFqdn string
|
||||
|
||||
onStateChange func(state *NetworkInterfaceState)
|
||||
onInitialCheck func(state *NetworkInterfaceState)
|
||||
cbConfigChange func(config *NetworkConfig)
|
||||
|
||||
checked bool
|
||||
}
|
||||
|
||||
type NetworkInterfaceOptions struct {
|
||||
InterfaceName string
|
||||
DhcpPidFile string
|
||||
Logger *zerolog.Logger
|
||||
DefaultHostname string
|
||||
OnStateChange func(state *NetworkInterfaceState)
|
||||
OnInitialCheck func(state *NetworkInterfaceState)
|
||||
OnDhcpLeaseChange func(lease *udhcpc.Lease, state *NetworkInterfaceState)
|
||||
OnConfigChange func(config *NetworkConfig)
|
||||
NetworkConfig *NetworkConfig
|
||||
}
|
||||
|
||||
func NewNetworkInterfaceState(opts *NetworkInterfaceOptions) (*NetworkInterfaceState, error) {
|
||||
if opts.NetworkConfig == nil {
|
||||
return nil, fmt.Errorf("NetworkConfig can not be nil")
|
||||
}
|
||||
|
||||
if opts.DefaultHostname == "" {
|
||||
opts.DefaultHostname = "jetkvm"
|
||||
}
|
||||
|
||||
err := confparser.SetDefaultsAndValidate(opts.NetworkConfig)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
l := opts.Logger
|
||||
s := &NetworkInterfaceState{
|
||||
interfaceName: opts.InterfaceName,
|
||||
defaultHostname: opts.DefaultHostname,
|
||||
stateLock: sync.Mutex{},
|
||||
l: l,
|
||||
onStateChange: opts.OnStateChange,
|
||||
onInitialCheck: opts.OnInitialCheck,
|
||||
cbConfigChange: opts.OnConfigChange,
|
||||
config: opts.NetworkConfig,
|
||||
ntpAddresses: make([]*net.IP, 0),
|
||||
}
|
||||
|
||||
// create the dhcp client
|
||||
dhcpClient := udhcpc.NewDHCPClient(&udhcpc.DHCPClientOptions{
|
||||
InterfaceName: opts.InterfaceName,
|
||||
PidFile: opts.DhcpPidFile,
|
||||
Logger: l,
|
||||
OnLeaseChange: func(lease *udhcpc.Lease) {
|
||||
_, err := s.update()
|
||||
if err != nil {
|
||||
opts.Logger.Error().Err(err).Msg("failed to update network state")
|
||||
return
|
||||
}
|
||||
_ = s.updateNtpServersFromLease(lease)
|
||||
_ = s.setHostnameIfNotSame()
|
||||
|
||||
opts.OnDhcpLeaseChange(lease, s)
|
||||
},
|
||||
})
|
||||
|
||||
s.dhcpClient = dhcpClient
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (s *NetworkInterfaceState) IsUp() bool {
|
||||
return s.interfaceUp
|
||||
}
|
||||
|
||||
func (s *NetworkInterfaceState) HasIPAssigned() bool {
|
||||
return s.ipv4Addr != nil || s.ipv6Addr != nil
|
||||
}
|
||||
|
||||
func (s *NetworkInterfaceState) IsOnline() bool {
|
||||
return s.IsUp() && s.HasIPAssigned()
|
||||
}
|
||||
|
||||
func (s *NetworkInterfaceState) IPv4() *net.IP {
|
||||
return s.ipv4Addr
|
||||
}
|
||||
|
||||
func (s *NetworkInterfaceState) IPv4String() string {
|
||||
if s.ipv4Addr == nil {
|
||||
return "..."
|
||||
}
|
||||
return s.ipv4Addr.String()
|
||||
}
|
||||
|
||||
func (s *NetworkInterfaceState) IPv6() *net.IP {
|
||||
return s.ipv6Addr
|
||||
}
|
||||
|
||||
func (s *NetworkInterfaceState) IPv6String() string {
|
||||
if s.ipv6Addr == nil {
|
||||
return "..."
|
||||
}
|
||||
return s.ipv6Addr.String()
|
||||
}
|
||||
|
||||
func (s *NetworkInterfaceState) NtpAddresses() []*net.IP {
|
||||
return s.ntpAddresses
|
||||
}
|
||||
|
||||
func (s *NetworkInterfaceState) NtpAddressesString() []string {
|
||||
ntpServers := []string{}
|
||||
|
||||
if s != nil {
|
||||
s.l.Debug().Any("s", s).Msg("getting NTP address strings")
|
||||
|
||||
if len(s.ntpAddresses) > 0 {
|
||||
for _, server := range s.ntpAddresses {
|
||||
s.l.Debug().IPAddr("server", *server).Msg("converting NTP address")
|
||||
ntpServers = append(ntpServers, server.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ntpServers
|
||||
}
|
||||
|
||||
func (s *NetworkInterfaceState) MAC() *net.HardwareAddr {
|
||||
return s.macAddr
|
||||
}
|
||||
|
||||
func (s *NetworkInterfaceState) MACString() string {
|
||||
if s.macAddr == nil {
|
||||
return ""
|
||||
}
|
||||
return s.macAddr.String()
|
||||
}
|
||||
|
||||
func (s *NetworkInterfaceState) update() (DhcpTargetState, error) {
|
||||
s.stateLock.Lock()
|
||||
defer s.stateLock.Unlock()
|
||||
|
||||
dhcpTargetState := DhcpTargetStateDoNothing
|
||||
|
||||
iface, err := netlink.LinkByName(s.interfaceName)
|
||||
if err != nil {
|
||||
s.l.Error().Err(err).Msg("failed to get interface")
|
||||
return dhcpTargetState, err
|
||||
}
|
||||
|
||||
// detect if the interface status changed
|
||||
var changed bool
|
||||
attrs := iface.Attrs()
|
||||
state := attrs.OperState
|
||||
newInterfaceUp := state == netlink.OperUp
|
||||
|
||||
// check if the interface is coming up
|
||||
interfaceGoingUp := !s.interfaceUp && newInterfaceUp
|
||||
interfaceGoingDown := s.interfaceUp && !newInterfaceUp
|
||||
|
||||
if s.interfaceUp != newInterfaceUp {
|
||||
s.interfaceUp = newInterfaceUp
|
||||
changed = true
|
||||
}
|
||||
|
||||
if changed {
|
||||
if interfaceGoingUp {
|
||||
s.l.Info().Msg("interface state transitioned to up")
|
||||
dhcpTargetState = DhcpTargetStateRenew
|
||||
} else if interfaceGoingDown {
|
||||
s.l.Info().Msg("interface state transitioned to down")
|
||||
}
|
||||
}
|
||||
|
||||
// set the mac address
|
||||
s.macAddr = &attrs.HardwareAddr
|
||||
|
||||
// get the ip addresses
|
||||
addrs, err := netlinkAddrs(iface)
|
||||
if err != nil {
|
||||
return dhcpTargetState, logging.ErrorfL(s.l, "failed to get ip addresses", err)
|
||||
}
|
||||
|
||||
var (
|
||||
ipv4Addresses = make([]net.IP, 0)
|
||||
ipv4AddressesString = make([]string, 0)
|
||||
ipv6Addresses = make([]IPv6Address, 0)
|
||||
// ipv6AddressesString = make([]string, 0)
|
||||
ipv6LinkLocal *net.IP
|
||||
)
|
||||
|
||||
for _, addr := range addrs {
|
||||
if addr.IP.To4() != nil {
|
||||
scopedLogger := s.l.With().Str("ipv4", addr.IP.String()).Logger()
|
||||
if interfaceGoingDown {
|
||||
// remove all IPv4 addresses from the interface.
|
||||
scopedLogger.Info().Msg("state transitioned to down, removing IPv4 address")
|
||||
err := netlink.AddrDel(iface, &addr)
|
||||
if err != nil {
|
||||
scopedLogger.Warn().Err(err).Msg("failed to delete address")
|
||||
}
|
||||
// notify the DHCP client to release the lease
|
||||
dhcpTargetState = DhcpTargetStateRelease
|
||||
continue
|
||||
}
|
||||
ipv4Addresses = append(ipv4Addresses, addr.IP)
|
||||
ipv4AddressesString = append(ipv4AddressesString, addr.IPNet.String())
|
||||
} else if addr.IP.To16() != nil {
|
||||
if s.config.IPv6Mode.String == "disabled" {
|
||||
continue
|
||||
}
|
||||
|
||||
scopedLogger := s.l.With().Str("ipv6", addr.IP.String()).Logger()
|
||||
// check if it's a link local address
|
||||
if addr.IP.IsLinkLocalUnicast() {
|
||||
ipv6LinkLocal = &addr.IP
|
||||
continue
|
||||
}
|
||||
|
||||
if !addr.IP.IsGlobalUnicast() {
|
||||
scopedLogger.Trace().Msg("not a global unicast address, skipping")
|
||||
continue
|
||||
}
|
||||
|
||||
if interfaceGoingDown {
|
||||
scopedLogger.Info().Msg("state transitioned to down, removing IPv6 address")
|
||||
err := netlink.AddrDel(iface, &addr)
|
||||
if err != nil {
|
||||
scopedLogger.Warn().Err(err).Msg("failed to delete address")
|
||||
}
|
||||
continue
|
||||
}
|
||||
ipv6Addresses = append(ipv6Addresses, IPv6Address{
|
||||
Address: addr.IP,
|
||||
Prefix: *addr.IPNet,
|
||||
ValidLifetime: lifetimeToTime(addr.ValidLft),
|
||||
PreferredLifetime: lifetimeToTime(addr.PreferedLft),
|
||||
Scope: addr.Scope,
|
||||
})
|
||||
// ipv6AddressesString = append(ipv6AddressesString, addr.IPNet.String())
|
||||
}
|
||||
}
|
||||
|
||||
if len(ipv4Addresses) > 0 {
|
||||
// compare the addresses to see if there's a change
|
||||
if s.ipv4Addr == nil || s.ipv4Addr.String() != ipv4Addresses[0].String() {
|
||||
scopedLogger := s.l.With().Str("ipv4", ipv4Addresses[0].String()).Logger()
|
||||
if s.ipv4Addr != nil {
|
||||
scopedLogger.Info().
|
||||
Str("old_ipv4", s.ipv4Addr.String()).
|
||||
Msg("IPv4 address changed")
|
||||
} else {
|
||||
scopedLogger.Info().Msg("IPv4 address found")
|
||||
}
|
||||
s.ipv4Addr = &ipv4Addresses[0]
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
s.ipv4Addresses = ipv4AddressesString
|
||||
|
||||
if s.config.IPv6Mode.String != "disabled" {
|
||||
if ipv6LinkLocal != nil {
|
||||
if s.ipv6LinkLocal == nil || s.ipv6LinkLocal.String() != ipv6LinkLocal.String() {
|
||||
scopedLogger := s.l.With().Str("ipv6", ipv6LinkLocal.String()).Logger()
|
||||
if s.ipv6LinkLocal != nil {
|
||||
scopedLogger.Info().
|
||||
Str("old_ipv6", s.ipv6LinkLocal.String()).
|
||||
Msg("IPv6 link local address changed")
|
||||
} else {
|
||||
scopedLogger.Info().Msg("IPv6 link local address found")
|
||||
}
|
||||
s.ipv6LinkLocal = ipv6LinkLocal
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
s.ipv6Addresses = ipv6Addresses
|
||||
|
||||
if len(ipv6Addresses) > 0 {
|
||||
// compare the addresses to see if there's a change
|
||||
if s.ipv6Addr == nil || s.ipv6Addr.String() != ipv6Addresses[0].Address.String() {
|
||||
scopedLogger := s.l.With().Str("ipv6", ipv6Addresses[0].Address.String()).Logger()
|
||||
if s.ipv6Addr != nil {
|
||||
scopedLogger.Info().
|
||||
Str("old_ipv6", s.ipv6Addr.String()).
|
||||
Msg("IPv6 address changed")
|
||||
} else {
|
||||
scopedLogger.Info().Msg("IPv6 address found")
|
||||
}
|
||||
s.ipv6Addr = &ipv6Addresses[0].Address
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// if it's the initial check, we'll set changed to false
|
||||
initialCheck := !s.checked
|
||||
if initialCheck {
|
||||
s.checked = true
|
||||
changed = false
|
||||
if dhcpTargetState == DhcpTargetStateRenew {
|
||||
// it's the initial check, we'll start the DHCP client
|
||||
// dhcpTargetState = DhcpTargetStateStart
|
||||
// TODO: manage DHCP client start/stop
|
||||
dhcpTargetState = DhcpTargetStateDoNothing
|
||||
}
|
||||
}
|
||||
|
||||
if initialCheck {
|
||||
s.handleInitialCheck()
|
||||
} else if changed {
|
||||
s.handleStateChange()
|
||||
}
|
||||
|
||||
return dhcpTargetState, nil
|
||||
}
|
||||
|
||||
func (s *NetworkInterfaceState) updateNtpServersFromLease(lease *udhcpc.Lease) error {
|
||||
if lease != nil && len(lease.NTPServers) > 0 {
|
||||
s.l.Info().Msg("lease found, updating DHCP NTP addresses")
|
||||
s.ntpAddresses = make([]*net.IP, 0, len(lease.NTPServers))
|
||||
|
||||
for _, ntpServer := range lease.NTPServers {
|
||||
if ntpServer != nil {
|
||||
s.l.Info().IPAddr("ntp_server", ntpServer).Msg("NTP server found in lease")
|
||||
s.ntpAddresses = append(s.ntpAddresses, &ntpServer)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
s.l.Info().Msg("no NTP servers found in lease")
|
||||
s.ntpAddresses = make([]*net.IP, 0, len(s.config.TimeSyncNTPServers))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *NetworkInterfaceState) handleInitialCheck() {
|
||||
// if s.IsUp() {}
|
||||
s.onInitialCheck(s)
|
||||
}
|
||||
|
||||
func (s *NetworkInterfaceState) handleStateChange() {
|
||||
// if s.IsUp() {} else {}
|
||||
s.onStateChange(s)
|
||||
}
|
||||
|
||||
func (s *NetworkInterfaceState) CheckAndUpdateDhcp() error {
|
||||
dhcpTargetState, err := s.update()
|
||||
if err != nil {
|
||||
return logging.ErrorfL(s.l, "failed to update network state", err)
|
||||
}
|
||||
|
||||
switch dhcpTargetState {
|
||||
case DhcpTargetStateRenew:
|
||||
s.l.Info().Msg("renewing DHCP lease")
|
||||
_ = s.dhcpClient.Renew()
|
||||
case DhcpTargetStateRelease:
|
||||
s.l.Info().Msg("releasing DHCP lease")
|
||||
_ = s.dhcpClient.Release()
|
||||
case DhcpTargetStateStart:
|
||||
s.l.Warn().Msg("dhcpTargetStateStart not implemented")
|
||||
case DhcpTargetStateStop:
|
||||
s.l.Warn().Msg("dhcpTargetStateStop not implemented")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *NetworkInterfaceState) onConfigChange(config *NetworkConfig) {
|
||||
_ = s.setHostnameIfNotSame()
|
||||
s.cbConfigChange(config)
|
||||
}
|
||||
|
|
@ -1,58 +0,0 @@
|
|||
//go:build linux
|
||||
|
||||
package network
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/vishvananda/netlink"
|
||||
"github.com/vishvananda/netlink/nl"
|
||||
)
|
||||
|
||||
func (s *NetworkInterfaceState) HandleLinkUpdate(update netlink.LinkUpdate) {
|
||||
if update.Link.Attrs().Name == s.interfaceName {
|
||||
s.l.Info().Interface("update", update).Msg("interface link update received")
|
||||
_ = s.CheckAndUpdateDhcp()
|
||||
}
|
||||
}
|
||||
|
||||
func (s *NetworkInterfaceState) Run() error {
|
||||
updates := make(chan netlink.LinkUpdate)
|
||||
done := make(chan struct{})
|
||||
|
||||
if err := netlink.LinkSubscribe(updates, done); err != nil {
|
||||
s.l.Warn().Err(err).Msg("failed to subscribe to link updates")
|
||||
return err
|
||||
}
|
||||
|
||||
_ = s.setHostnameIfNotSame()
|
||||
|
||||
// run the dhcp client
|
||||
go s.dhcpClient.Run() // nolint:errcheck
|
||||
|
||||
if err := s.CheckAndUpdateDhcp(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
go func() {
|
||||
ticker := time.NewTicker(1 * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case update := <-updates:
|
||||
s.HandleLinkUpdate(update)
|
||||
case <-ticker.C:
|
||||
_ = s.CheckAndUpdateDhcp()
|
||||
case <-done:
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func netlinkAddrs(iface netlink.Link) ([]netlink.Addr, error) {
|
||||
return netlink.AddrList(iface, nl.FAMILY_ALL)
|
||||
}
|
||||
|
|
@ -1,21 +0,0 @@
|
|||
//go:build !linux
|
||||
|
||||
package network
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/vishvananda/netlink"
|
||||
)
|
||||
|
||||
func (s *NetworkInterfaceState) HandleLinkUpdate() error {
|
||||
return fmt.Errorf("not implemented")
|
||||
}
|
||||
|
||||
func (s *NetworkInterfaceState) Run() error {
|
||||
return fmt.Errorf("not implemented")
|
||||
}
|
||||
|
||||
func netlinkAddrs(iface netlink.Link) ([]netlink.Addr, error) {
|
||||
return nil, fmt.Errorf("not implemented")
|
||||
}
|
||||
|
|
@ -1,126 +0,0 @@
|
|||
package network
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/jetkvm/kvm/internal/confparser"
|
||||
"github.com/jetkvm/kvm/internal/udhcpc"
|
||||
)
|
||||
|
||||
type RpcIPv6Address struct {
|
||||
Address string `json:"address"`
|
||||
ValidLifetime *time.Time `json:"valid_lifetime,omitempty"`
|
||||
PreferredLifetime *time.Time `json:"preferred_lifetime,omitempty"`
|
||||
Scope int `json:"scope"`
|
||||
}
|
||||
|
||||
type RpcNetworkState struct {
|
||||
InterfaceName string `json:"interface_name"`
|
||||
MacAddress string `json:"mac_address"`
|
||||
IPv4 string `json:"ipv4,omitempty"`
|
||||
IPv6 string `json:"ipv6,omitempty"`
|
||||
IPv6LinkLocal string `json:"ipv6_link_local,omitempty"`
|
||||
IPv4Addresses []string `json:"ipv4_addresses,omitempty"`
|
||||
IPv6Addresses []RpcIPv6Address `json:"ipv6_addresses,omitempty"`
|
||||
DHCPLease *udhcpc.Lease `json:"dhcp_lease,omitempty"`
|
||||
}
|
||||
|
||||
type RpcNetworkSettings struct {
|
||||
NetworkConfig
|
||||
}
|
||||
|
||||
func (s *NetworkInterfaceState) MacAddress() string {
|
||||
if s.macAddr == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
return s.macAddr.String()
|
||||
}
|
||||
|
||||
func (s *NetworkInterfaceState) IPv4Address() string {
|
||||
if s.ipv4Addr == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
return s.ipv4Addr.String()
|
||||
}
|
||||
|
||||
func (s *NetworkInterfaceState) IPv6Address() string {
|
||||
if s.ipv6Addr == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
return s.ipv6Addr.String()
|
||||
}
|
||||
|
||||
func (s *NetworkInterfaceState) IPv6LinkLocalAddress() string {
|
||||
if s.ipv6LinkLocal == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
return s.ipv6LinkLocal.String()
|
||||
}
|
||||
|
||||
func (s *NetworkInterfaceState) RpcGetNetworkState() RpcNetworkState {
|
||||
ipv6Addresses := make([]RpcIPv6Address, 0)
|
||||
|
||||
if s.ipv6Addresses != nil && s.config.IPv6Mode.String != "disabled" {
|
||||
for _, addr := range s.ipv6Addresses {
|
||||
ipv6Addresses = append(ipv6Addresses, RpcIPv6Address{
|
||||
Address: addr.Prefix.String(),
|
||||
ValidLifetime: addr.ValidLifetime,
|
||||
PreferredLifetime: addr.PreferredLifetime,
|
||||
Scope: addr.Scope,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return RpcNetworkState{
|
||||
InterfaceName: s.interfaceName,
|
||||
MacAddress: s.MacAddress(),
|
||||
IPv4: s.IPv4Address(),
|
||||
IPv6: s.IPv6Address(),
|
||||
IPv6LinkLocal: s.IPv6LinkLocalAddress(),
|
||||
IPv4Addresses: s.ipv4Addresses,
|
||||
IPv6Addresses: ipv6Addresses,
|
||||
DHCPLease: s.dhcpClient.GetLease(),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *NetworkInterfaceState) RpcGetNetworkSettings() RpcNetworkSettings {
|
||||
if s.config == nil {
|
||||
return RpcNetworkSettings{}
|
||||
}
|
||||
|
||||
return RpcNetworkSettings{
|
||||
NetworkConfig: *s.config,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *NetworkInterfaceState) RpcSetNetworkSettings(settings RpcNetworkSettings) error {
|
||||
currentSettings := s.config
|
||||
|
||||
err := confparser.SetDefaultsAndValidate(&settings.NetworkConfig)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if IsSame(currentSettings, settings.NetworkConfig) {
|
||||
// no changes, do nothing
|
||||
return nil
|
||||
}
|
||||
|
||||
s.config = &settings.NetworkConfig
|
||||
s.onConfigChange(s.config)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *NetworkInterfaceState) RpcRenewDHCPLease() error {
|
||||
if s.dhcpClient == nil {
|
||||
return fmt.Errorf("dhcp client not initialized")
|
||||
}
|
||||
|
||||
return s.dhcpClient.Renew()
|
||||
}
|
||||
|
|
@ -1,25 +1,13 @@
|
|||
package network
|
||||
package types
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"github.com/guregu/null/v6"
|
||||
"github.com/jetkvm/kvm/internal/mdns"
|
||||
"golang.org/x/net/idna"
|
||||
)
|
||||
|
||||
type IPv6Address struct {
|
||||
Address net.IP `json:"address"`
|
||||
Prefix net.IPNet `json:"prefix"`
|
||||
ValidLifetime *time.Time `json:"valid_lifetime"`
|
||||
PreferredLifetime *time.Time `json:"preferred_lifetime"`
|
||||
Scope int `json:"scope"`
|
||||
}
|
||||
|
||||
// IPv4StaticConfig represents static IPv4 configuration
|
||||
type IPv4StaticConfig struct {
|
||||
Address null.String `json:"address,omitempty" validate_type:"ipv4" required:"true"`
|
||||
Netmask null.String `json:"netmask,omitempty" validate_type:"ipv4" required:"true"`
|
||||
|
|
@ -27,13 +15,23 @@ type IPv4StaticConfig struct {
|
|||
DNS []string `json:"dns,omitempty" validate_type:"ipv4" required:"true"`
|
||||
}
|
||||
|
||||
// IPv6StaticConfig represents static IPv6 configuration
|
||||
type IPv6StaticConfig struct {
|
||||
Address null.String `json:"address,omitempty" validate_type:"ipv6" required:"true"`
|
||||
Prefix null.String `json:"prefix,omitempty" validate_type:"ipv6" required:"true"`
|
||||
Prefix null.String `json:"prefix,omitempty" validate_type:"ipv6_prefix" required:"true"`
|
||||
Gateway null.String `json:"gateway,omitempty" validate_type:"ipv6" required:"true"`
|
||||
DNS []string `json:"dns,omitempty" validate_type:"ipv6" required:"true"`
|
||||
}
|
||||
|
||||
// MDNSListenOptions represents MDNS listening options
|
||||
type MDNSListenOptions struct {
|
||||
IPv4 bool
|
||||
IPv6 bool
|
||||
}
|
||||
|
||||
// NetworkConfig represents the complete network configuration for an interface
|
||||
type NetworkConfig struct {
|
||||
DHCPClient null.String `json:"dhcp_client,omitempty" one_of:"jetdhcpc,udhcpc" default:"jetdhcpc"`
|
||||
|
||||
Hostname null.String `json:"hostname,omitempty" validate_type:"hostname"`
|
||||
HTTPProxy null.String `json:"http_proxy,omitempty" validate_type:"proxy"`
|
||||
Domain null.String `json:"domain,omitempty" validate_type:"hostname"`
|
||||
|
|
@ -44,7 +42,7 @@ type NetworkConfig struct {
|
|||
IPv6Mode null.String `json:"ipv6_mode,omitempty" one_of:"slaac,dhcpv6,slaac_and_dhcpv6,static,link_local,disabled" default:"slaac"`
|
||||
IPv6Static *IPv6StaticConfig `json:"ipv6_static,omitempty" required_if:"IPv6Mode=static"`
|
||||
|
||||
LLDPMode null.String `json:"lldp_mode,omitempty" one_of:"disabled,rx_only,tx_only,basic,all,enabled" default:"enabled"`
|
||||
LLDPMode null.String `json:"lldp_mode,omitempty" one_of:"disabled,basic,all" default:"basic"`
|
||||
LLDPTxTLVs []string `json:"lldp_tx_tlvs,omitempty" one_of:"chassis,port,system,vlan" default:"chassis,port,system,vlan"`
|
||||
MDNSMode null.String `json:"mdns_mode,omitempty" one_of:"disabled,auto,ipv4_only,ipv6_only" default:"auto"`
|
||||
TimeSyncMode null.String `json:"time_sync_mode,omitempty" one_of:"ntp_only,ntp_and_http,http_only,custom" default:"ntp_and_http"`
|
||||
|
|
@ -55,13 +53,15 @@ type NetworkConfig struct {
|
|||
TimeSyncHTTPUrls []string `json:"time_sync_http_urls,omitempty" validate_type:"url" required_if:"TimeSyncOrdering=http_user_provided"`
|
||||
}
|
||||
|
||||
func (c *NetworkConfig) GetMDNSMode() *mdns.MDNSListenOptions {
|
||||
listenOptions := &mdns.MDNSListenOptions{
|
||||
IPv4: c.IPv4Mode.String != "disabled",
|
||||
IPv6: c.IPv6Mode.String != "disabled",
|
||||
// GetMDNSMode returns the MDNS mode configuration
|
||||
func (c *NetworkConfig) GetMDNSMode() *MDNSListenOptions {
|
||||
mode := c.MDNSMode.String
|
||||
listenOptions := &MDNSListenOptions{
|
||||
IPv4: true,
|
||||
IPv6: true,
|
||||
}
|
||||
|
||||
switch c.MDNSMode.String {
|
||||
switch mode {
|
||||
case "ipv4_only":
|
||||
listenOptions.IPv6 = false
|
||||
case "ipv6_only":
|
||||
|
|
@ -74,53 +74,21 @@ func (c *NetworkConfig) GetMDNSMode() *mdns.MDNSListenOptions {
|
|||
return listenOptions
|
||||
}
|
||||
|
||||
func (s *NetworkConfig) GetTransportProxyFunc() func(*http.Request) (*url.URL, error) {
|
||||
// GetTransportProxyFunc returns a function for HTTP proxy configuration
|
||||
func (c *NetworkConfig) GetTransportProxyFunc() func(*http.Request) (*url.URL, error) {
|
||||
return func(*http.Request) (*url.URL, error) {
|
||||
if s.HTTPProxy.String == "" {
|
||||
if c.HTTPProxy.String == "" {
|
||||
return nil, nil
|
||||
} else {
|
||||
proxyUrl, _ := url.Parse(s.HTTPProxy.String)
|
||||
return proxyUrl, nil
|
||||
proxyURL, _ := url.Parse(c.HTTPProxy.String)
|
||||
return proxyURL, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *NetworkInterfaceState) GetHostname() string {
|
||||
hostname := ToValidHostname(s.config.Hostname.String)
|
||||
|
||||
if hostname == "" {
|
||||
return s.defaultHostname
|
||||
}
|
||||
|
||||
return hostname
|
||||
}
|
||||
|
||||
func ToValidDomain(domain string) string {
|
||||
ascii, err := idna.Lookup.ToASCII(domain)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
return ascii
|
||||
}
|
||||
|
||||
func (s *NetworkInterfaceState) GetDomain() string {
|
||||
domain := ToValidDomain(s.config.Domain.String)
|
||||
|
||||
if domain == "" {
|
||||
lease := s.dhcpClient.GetLease()
|
||||
if lease != nil && lease.Domain != "" {
|
||||
domain = ToValidDomain(lease.Domain)
|
||||
}
|
||||
}
|
||||
|
||||
if domain == "" {
|
||||
return "local"
|
||||
}
|
||||
|
||||
return domain
|
||||
}
|
||||
|
||||
func (s *NetworkInterfaceState) GetFQDN() string {
|
||||
return fmt.Sprintf("%s.%s", s.GetHostname(), s.GetDomain())
|
||||
// NetworkConfig interface for backward compatibility
|
||||
type NetworkConfigInterface interface {
|
||||
InterfaceName() string
|
||||
IPv4Addresses() []IPAddress
|
||||
IPv6Addresses() []IPAddress
|
||||
}
|
||||
|
|
@ -1,18 +1,26 @@
|
|||
package udhcpc
|
||||
package types
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Lease struct {
|
||||
// DHCPClient is the interface for a DHCP client.
|
||||
type DHCPClient interface {
|
||||
Domain() string
|
||||
Lease4() *DHCPLease
|
||||
Lease6() *DHCPLease
|
||||
Renew() error
|
||||
Release() error
|
||||
SetIPv4(enabled bool)
|
||||
SetIPv6(enabled bool)
|
||||
SetOnLeaseChange(callback func(lease *DHCPLease))
|
||||
Start() error
|
||||
Stop() error
|
||||
}
|
||||
|
||||
// DHCPLease is a network configuration obtained by DHCP.
|
||||
type DHCPLease struct {
|
||||
// from https://udhcp.busybox.net/README.udhcpc
|
||||
IPAddress net.IP `env:"ip" json:"ip"` // The obtained IP
|
||||
Netmask net.IP `env:"subnet" json:"netmask"` // The assigned subnet mask
|
||||
|
|
@ -21,6 +29,7 @@ type Lease struct {
|
|||
MTU int `env:"mtu" json:"mtu,omitempty"` // The MTU to use for this network
|
||||
HostName string `env:"hostname" json:"hostname,omitempty"` // The assigned hostname
|
||||
Domain string `env:"domain" json:"domain,omitempty"` // The domain name of the network
|
||||
SearchList []string `env:"search" json:"search_list,omitempty"` // The search list for the network
|
||||
BootPNextServer net.IP `env:"siaddr" json:"bootp_next_server,omitempty"` // The bootp next server option
|
||||
BootPServerName string `env:"sname" json:"bootp_server_name,omitempty"` // The bootp server name option
|
||||
BootPFile string `env:"boot_file" json:"bootp_file,omitempty"` // The bootp boot file option
|
||||
|
|
@ -38,149 +47,46 @@ type Lease struct {
|
|||
BootSize int `env:"bootsize" json:"bootsize,omitempty"` // The length in 512 octect blocks of the bootfile
|
||||
RootPath string `env:"rootpath" json:"root_path,omitempty"` // The path name of the client's root disk
|
||||
LeaseTime time.Duration `env:"lease" json:"lease,omitempty"` // The lease time, in seconds
|
||||
RenewalTime time.Duration `env:"renewal" json:"renewal,omitempty"` // The renewal time, in seconds
|
||||
RebindingTime time.Duration `env:"rebinding" json:"rebinding,omitempty"` // The rebinding time, in seconds
|
||||
DHCPType string `env:"dhcptype" json:"dhcp_type,omitempty"` // DHCP message type (safely ignored)
|
||||
ServerID string `env:"serverid" json:"server_id,omitempty"` // The IP of the server
|
||||
Message string `env:"message" json:"reason,omitempty"` // Reason for a DHCPNAK
|
||||
TFTPServerName string `env:"tftp" json:"tftp,omitempty"` // The TFTP server name
|
||||
BootFileName string `env:"bootfile" json:"bootfile,omitempty"` // The boot file name
|
||||
Uptime time.Duration `env:"uptime" json:"uptime,omitempty"` // The uptime of the device when the lease was obtained, in seconds
|
||||
ClassIdentifier string `env:"classid" json:"class_identifier,omitempty"` // The class identifier
|
||||
LeaseExpiry *time.Time `json:"lease_expiry,omitempty"` // The expiry time of the lease
|
||||
isEmpty map[string]bool
|
||||
|
||||
InterfaceName string `json:"interface_name,omitempty"` // The name of the interface
|
||||
DHCPClient string `json:"dhcp_client,omitempty"` // The DHCP client that obtained the lease
|
||||
}
|
||||
|
||||
func (l *Lease) setIsEmpty(m map[string]bool) {
|
||||
l.isEmpty = m
|
||||
// IsIPv6 returns true if the DHCP lease is for an IPv6 address
|
||||
func (d *DHCPLease) IsIPv6() bool {
|
||||
return d.IPAddress.To4() == nil
|
||||
}
|
||||
|
||||
func (l *Lease) IsEmpty(key string) bool {
|
||||
return l.isEmpty[key]
|
||||
// IPMask returns the IP mask for the DHCP lease
|
||||
func (d *DHCPLease) IPMask() net.IPMask {
|
||||
if d.IsIPv6() {
|
||||
// TODO: not implemented
|
||||
return nil
|
||||
}
|
||||
|
||||
mask := net.ParseIP(d.Netmask.String())
|
||||
return net.IPv4Mask(mask[12], mask[13], mask[14], mask[15])
|
||||
}
|
||||
|
||||
func (l *Lease) ToJSON() string {
|
||||
json, err := json.Marshal(l)
|
||||
if err != nil {
|
||||
return ""
|
||||
// IPNet returns the IP net for the DHCP lease
|
||||
func (d *DHCPLease) IPNet() *net.IPNet {
|
||||
if d.IsIPv6() {
|
||||
// TODO: not implemented
|
||||
return nil
|
||||
}
|
||||
|
||||
return &net.IPNet{
|
||||
IP: d.IPAddress,
|
||||
Mask: d.IPMask(),
|
||||
}
|
||||
return string(json)
|
||||
}
|
||||
|
||||
func (l *Lease) SetLeaseExpiry() (time.Time, error) {
|
||||
if l.Uptime == 0 || l.LeaseTime == 0 {
|
||||
return time.Time{}, fmt.Errorf("uptime or lease time isn't set")
|
||||
}
|
||||
|
||||
// get the uptime of the device
|
||||
|
||||
file, err := os.Open("/proc/uptime")
|
||||
if err != nil {
|
||||
return time.Time{}, fmt.Errorf("failed to open uptime file: %w", err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
var uptime time.Duration
|
||||
|
||||
scanner := bufio.NewScanner(file)
|
||||
for scanner.Scan() {
|
||||
text := scanner.Text()
|
||||
parts := strings.Split(text, " ")
|
||||
uptime, err = time.ParseDuration(parts[0] + "s")
|
||||
|
||||
if err != nil {
|
||||
return time.Time{}, fmt.Errorf("failed to parse uptime: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
relativeLeaseRemaining := (l.Uptime + l.LeaseTime) - uptime
|
||||
leaseExpiry := time.Now().Add(relativeLeaseRemaining)
|
||||
|
||||
l.LeaseExpiry = &leaseExpiry
|
||||
|
||||
return leaseExpiry, nil
|
||||
}
|
||||
|
||||
func UnmarshalDHCPCLease(lease *Lease, str string) error {
|
||||
// parse the lease file as a map
|
||||
data := make(map[string]string)
|
||||
for line := range strings.SplitSeq(str, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
// skip empty lines and comments
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
|
||||
parts := strings.SplitN(line, "=", 2)
|
||||
if len(parts) != 2 {
|
||||
continue
|
||||
}
|
||||
|
||||
key := strings.TrimSpace(parts[0])
|
||||
value := strings.TrimSpace(parts[1])
|
||||
|
||||
data[key] = value
|
||||
}
|
||||
|
||||
// now iterate over the lease struct and set the values
|
||||
leaseType := reflect.TypeOf(lease).Elem()
|
||||
leaseValue := reflect.ValueOf(lease).Elem()
|
||||
|
||||
valuesParsed := make(map[string]bool)
|
||||
|
||||
for i := 0; i < leaseType.NumField(); i++ {
|
||||
field := leaseValue.Field(i)
|
||||
|
||||
// get the env tag
|
||||
key := leaseType.Field(i).Tag.Get("env")
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
valuesParsed[key] = false
|
||||
|
||||
// get the value from the data map
|
||||
value, ok := data[key]
|
||||
if !ok || value == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
switch field.Interface().(type) {
|
||||
case string:
|
||||
field.SetString(value)
|
||||
case int:
|
||||
val, err := strconv.Atoi(value)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
field.SetInt(int64(val))
|
||||
case time.Duration:
|
||||
val, err := time.ParseDuration(value + "s")
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
field.Set(reflect.ValueOf(val))
|
||||
case net.IP:
|
||||
ip := net.ParseIP(value)
|
||||
if ip == nil {
|
||||
continue
|
||||
}
|
||||
field.Set(reflect.ValueOf(ip))
|
||||
case []net.IP:
|
||||
val := make([]net.IP, 0)
|
||||
for ipStr := range strings.FieldsSeq(value) {
|
||||
ip := net.ParseIP(ipStr)
|
||||
if ip == nil {
|
||||
continue
|
||||
}
|
||||
val = append(val, ip)
|
||||
}
|
||||
field.Set(reflect.ValueOf(val))
|
||||
default:
|
||||
return fmt.Errorf("unsupported field `%s` type: %s", key, field.Type().String())
|
||||
}
|
||||
|
||||
valuesParsed[key] = true
|
||||
}
|
||||
|
||||
lease.setIsEmpty(valuesParsed)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
@ -0,0 +1,62 @@
|
|||
package types
|
||||
|
||||
import (
|
||||
"net"
|
||||
"time"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
// InterfaceState represents the current state of a network interface
|
||||
type InterfaceState struct {
|
||||
InterfaceName string `json:"interface_name"`
|
||||
Hostname string `json:"hostname"`
|
||||
MACAddress string `json:"mac_address"`
|
||||
Up bool `json:"up"`
|
||||
Online bool `json:"online"`
|
||||
IPv4Ready bool `json:"ipv4_ready"`
|
||||
IPv6Ready bool `json:"ipv6_ready"`
|
||||
IPv4Address string `json:"ipv4_address,omitempty"`
|
||||
IPv6Address string `json:"ipv6_address,omitempty"`
|
||||
IPv6LinkLocal string `json:"ipv6_link_local,omitempty"`
|
||||
IPv6Gateway string `json:"ipv6_gateway,omitempty"`
|
||||
IPv4Addresses []string `json:"ipv4_addresses,omitempty"`
|
||||
IPv6Addresses []IPv6Address `json:"ipv6_addresses,omitempty"`
|
||||
NTPServers []net.IP `json:"ntp_servers,omitempty"`
|
||||
DHCPLease4 *DHCPLease `json:"dhcp_lease,omitempty"`
|
||||
DHCPLease6 *DHCPLease `json:"dhcp_lease6,omitempty"`
|
||||
LastUpdated time.Time `json:"last_updated"`
|
||||
}
|
||||
|
||||
// RpcInterfaceState is the RPC representation of an interface state
|
||||
type RpcInterfaceState struct {
|
||||
InterfaceState
|
||||
IPv6Addresses []RpcIPv6Address `json:"ipv6_addresses"`
|
||||
}
|
||||
|
||||
// ToRpcInterfaceState converts an InterfaceState to a RpcInterfaceState
|
||||
func (s *InterfaceState) ToRpcInterfaceState() *RpcInterfaceState {
|
||||
addrs := make([]RpcIPv6Address, len(s.IPv6Addresses))
|
||||
for i, addr := range s.IPv6Addresses {
|
||||
addrs[i] = RpcIPv6Address{
|
||||
Address: addr.Address.String(),
|
||||
Prefix: addr.Prefix.String(),
|
||||
ValidLifetime: addr.ValidLifetime,
|
||||
PreferredLifetime: addr.PreferredLifetime,
|
||||
Scope: addr.Scope,
|
||||
Flags: addr.Flags,
|
||||
FlagSecondary: addr.Flags&unix.IFA_F_SECONDARY != 0,
|
||||
FlagPermanent: addr.Flags&unix.IFA_F_PERMANENT != 0,
|
||||
FlagTemporary: addr.Flags&unix.IFA_F_TEMPORARY != 0,
|
||||
FlagStablePrivacy: addr.Flags&unix.IFA_F_STABLE_PRIVACY != 0,
|
||||
FlagDeprecated: addr.Flags&unix.IFA_F_DEPRECATED != 0,
|
||||
FlagOptimistic: addr.Flags&unix.IFA_F_OPTIMISTIC != 0,
|
||||
FlagDADFailed: addr.Flags&unix.IFA_F_DADFAILED != 0,
|
||||
FlagTentative: addr.Flags&unix.IFA_F_TENTATIVE != 0,
|
||||
}
|
||||
}
|
||||
return &RpcInterfaceState{
|
||||
InterfaceState: *s,
|
||||
IPv6Addresses: addrs,
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,85 @@
|
|||
package types
|
||||
|
||||
import (
|
||||
"net"
|
||||
"slices"
|
||||
"time"
|
||||
|
||||
"github.com/vishvananda/netlink"
|
||||
)
|
||||
|
||||
// IPAddress represents a network interface address
|
||||
type IPAddress struct {
|
||||
Family int
|
||||
Address net.IPNet
|
||||
Gateway net.IP
|
||||
MTU int
|
||||
Secondary bool
|
||||
Permanent bool
|
||||
}
|
||||
|
||||
func (a *IPAddress) String() string {
|
||||
return a.Address.String()
|
||||
}
|
||||
|
||||
func (a *IPAddress) Compare(n netlink.Addr) bool {
|
||||
if !a.Address.IP.Equal(n.IP) {
|
||||
return false
|
||||
}
|
||||
if slices.Compare(a.Address.Mask, n.Mask) != 0 {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (a *IPAddress) NetlinkAddr() netlink.Addr {
|
||||
return netlink.Addr{
|
||||
IPNet: &a.Address,
|
||||
}
|
||||
}
|
||||
|
||||
func (a *IPAddress) DefaultRoute(linkIndex int) netlink.Route {
|
||||
return netlink.Route{
|
||||
Dst: nil,
|
||||
Gw: a.Gateway,
|
||||
LinkIndex: linkIndex,
|
||||
}
|
||||
}
|
||||
|
||||
// ParsedIPConfig represents the parsed IP configuration
|
||||
type ParsedIPConfig struct {
|
||||
Addresses []IPAddress
|
||||
Nameservers []net.IP
|
||||
SearchList []string
|
||||
Domain string
|
||||
MTU int
|
||||
Interface string
|
||||
}
|
||||
|
||||
// IPv6Address represents an IPv6 address with lifetime information
|
||||
type IPv6Address struct {
|
||||
Address net.IP `json:"address"`
|
||||
Prefix net.IPNet `json:"prefix"`
|
||||
ValidLifetime *time.Time `json:"valid_lifetime"`
|
||||
PreferredLifetime *time.Time `json:"preferred_lifetime"`
|
||||
Flags int `json:"flags"`
|
||||
Scope int `json:"scope"`
|
||||
}
|
||||
|
||||
// RpcIPv6Address is the RPC representation of an IPv6 address
|
||||
type RpcIPv6Address struct {
|
||||
Address string `json:"address"`
|
||||
Prefix string `json:"prefix"`
|
||||
ValidLifetime *time.Time `json:"valid_lifetime"`
|
||||
PreferredLifetime *time.Time `json:"preferred_lifetime"`
|
||||
Scope int `json:"scope"`
|
||||
Flags int `json:"flags"`
|
||||
FlagSecondary bool `json:"flag_secondary"`
|
||||
FlagPermanent bool `json:"flag_permanent"`
|
||||
FlagTemporary bool `json:"flag_temporary"`
|
||||
FlagStablePrivacy bool `json:"flag_stable_privacy"`
|
||||
FlagDeprecated bool `json:"flag_deprecated"`
|
||||
FlagOptimistic bool `json:"flag_optimistic"`
|
||||
FlagDADFailed bool `json:"flag_dad_failed"`
|
||||
FlagTentative bool `json:"flag_tentative"`
|
||||
}
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
package types
|
||||
|
||||
import "net"
|
||||
|
||||
// InterfaceResolvConf represents the DNS configuration for a network interface
|
||||
type InterfaceResolvConf struct {
|
||||
NameServers []net.IP `json:"nameservers"`
|
||||
SearchList []string `json:"search_list"`
|
||||
Domain string `json:"domain,omitempty"` // TODO: remove this once we have a better way to handle the domain
|
||||
Source string `json:"source,omitempty"`
|
||||
}
|
||||
|
||||
// InterfaceResolvConfMap ..
|
||||
type InterfaceResolvConfMap map[string]InterfaceResolvConf
|
||||
|
||||
// ResolvConf represents the DNS configuration for the system
|
||||
type ResolvConf struct {
|
||||
ConfigIPv4 InterfaceResolvConfMap `json:"config_ipv4"`
|
||||
ConfigIPv6 InterfaceResolvConfMap `json:"config_ipv6"`
|
||||
Domain string `json:"domain"`
|
||||
HostName string `json:"host_name"`
|
||||
}
|
||||
|
|
@ -1,26 +0,0 @@
|
|||
package network
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"time"
|
||||
)
|
||||
|
||||
func lifetimeToTime(lifetime int) *time.Time {
|
||||
if lifetime == 0 {
|
||||
return nil
|
||||
}
|
||||
t := time.Now().Add(time.Duration(lifetime) * time.Second)
|
||||
return &t
|
||||
}
|
||||
|
||||
func IsSame(a, b any) bool {
|
||||
aJSON, err := json.Marshal(a)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
bJSON, err := json.Marshal(b)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return string(aJSON) == string(bJSON)
|
||||
}
|
||||
|
|
@ -0,0 +1,149 @@
|
|||
//go:build synctrace
|
||||
|
||||
package sync
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"reflect"
|
||||
"runtime"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/jetkvm/kvm/internal/logging"
|
||||
"github.com/rs/zerolog"
|
||||
)
|
||||
|
||||
var defaultLogger = logging.GetSubsystemLogger("synctrace")
|
||||
|
||||
func logTrace(msg string) {
|
||||
if defaultLogger.GetLevel() > zerolog.TraceLevel {
|
||||
return
|
||||
}
|
||||
|
||||
logTrack(3).Trace().Msg(msg)
|
||||
}
|
||||
|
||||
func logTrack(callerSkip int) *zerolog.Logger {
|
||||
l := *defaultLogger
|
||||
if l.GetLevel() > zerolog.TraceLevel {
|
||||
return &l
|
||||
}
|
||||
|
||||
pc, file, no, ok := runtime.Caller(callerSkip)
|
||||
if ok {
|
||||
l = l.With().
|
||||
Str("file", file).
|
||||
Int("line", no).
|
||||
Logger()
|
||||
|
||||
details := runtime.FuncForPC(pc)
|
||||
if details != nil {
|
||||
l = l.With().
|
||||
Str("func", details.Name()).
|
||||
Logger()
|
||||
}
|
||||
}
|
||||
|
||||
return &l
|
||||
}
|
||||
|
||||
func logLockTrack(i string) *zerolog.Logger {
|
||||
l := logTrack(4).
|
||||
With().
|
||||
Str("index", i).
|
||||
Logger()
|
||||
return &l
|
||||
}
|
||||
|
||||
var (
|
||||
indexMu sync.Mutex
|
||||
|
||||
lockCount map[string]int = make(map[string]int)
|
||||
unlockCount map[string]int = make(map[string]int)
|
||||
lastLock map[string]time.Time = make(map[string]time.Time)
|
||||
)
|
||||
|
||||
type trackable interface {
|
||||
sync.Locker
|
||||
}
|
||||
|
||||
func getIndex(t trackable) string {
|
||||
ptr := reflect.ValueOf(t).Pointer()
|
||||
return fmt.Sprintf("%x", ptr)
|
||||
}
|
||||
|
||||
func increaseLockCount(i string) {
|
||||
indexMu.Lock()
|
||||
defer indexMu.Unlock()
|
||||
|
||||
if _, ok := lockCount[i]; !ok {
|
||||
lockCount[i] = 0
|
||||
}
|
||||
lockCount[i]++
|
||||
|
||||
if _, ok := lastLock[i]; !ok {
|
||||
lastLock[i] = time.Now()
|
||||
}
|
||||
}
|
||||
|
||||
func increaseUnlockCount(i string) {
|
||||
indexMu.Lock()
|
||||
defer indexMu.Unlock()
|
||||
|
||||
if _, ok := unlockCount[i]; !ok {
|
||||
unlockCount[i] = 0
|
||||
}
|
||||
unlockCount[i]++
|
||||
}
|
||||
|
||||
func logLock(t trackable) {
|
||||
i := getIndex(t)
|
||||
increaseLockCount(i)
|
||||
logLockTrack(i).Trace().Msg("locking mutex")
|
||||
}
|
||||
|
||||
func logUnlock(t trackable) {
|
||||
i := getIndex(t)
|
||||
increaseUnlockCount(i)
|
||||
logLockTrack(i).Trace().Msg("unlocking mutex")
|
||||
}
|
||||
|
||||
func logTryLock(t trackable) {
|
||||
i := getIndex(t)
|
||||
logLockTrack(i).Trace().Msg("trying to lock mutex")
|
||||
}
|
||||
|
||||
func logTryLockResult(t trackable, l bool) {
|
||||
if !l {
|
||||
return
|
||||
}
|
||||
i := getIndex(t)
|
||||
increaseLockCount(i)
|
||||
logLockTrack(i).Trace().Msg("locked mutex")
|
||||
}
|
||||
|
||||
func logRLock(t trackable) {
|
||||
i := getIndex(t)
|
||||
increaseLockCount(i)
|
||||
logLockTrack(i).Trace().Msg("locking mutex for reading")
|
||||
}
|
||||
|
||||
func logRUnlock(t trackable) {
|
||||
i := getIndex(t)
|
||||
increaseUnlockCount(i)
|
||||
logLockTrack(i).Trace().Msg("unlocking mutex for reading")
|
||||
}
|
||||
|
||||
func logTryRLock(t trackable) {
|
||||
i := getIndex(t)
|
||||
logLockTrack(i).Trace().Msg("trying to lock mutex for reading")
|
||||
}
|
||||
|
||||
func logTryRLockResult(t trackable, l bool) {
|
||||
if !l {
|
||||
return
|
||||
}
|
||||
i := getIndex(t)
|
||||
increaseLockCount(i)
|
||||
logLockTrack(i).Trace().Msg("locked mutex for reading")
|
||||
}
|
||||
|
|
@ -0,0 +1,69 @@
|
|||
//go:build synctrace
|
||||
|
||||
package sync
|
||||
|
||||
import (
|
||||
gosync "sync"
|
||||
)
|
||||
|
||||
// Mutex is a wrapper around the sync.Mutex
|
||||
type Mutex struct {
|
||||
mu gosync.Mutex
|
||||
}
|
||||
|
||||
// Lock locks the mutex
|
||||
func (m *Mutex) Lock() {
|
||||
logLock(m)
|
||||
m.mu.Lock()
|
||||
}
|
||||
|
||||
// Unlock unlocks the mutex
|
||||
func (m *Mutex) Unlock() {
|
||||
logUnlock(m)
|
||||
m.mu.Unlock()
|
||||
}
|
||||
|
||||
// TryLock tries to lock the mutex
|
||||
func (m *Mutex) TryLock() bool {
|
||||
logTryLock(m)
|
||||
l := m.mu.TryLock()
|
||||
logTryLockResult(m, l)
|
||||
return l
|
||||
}
|
||||
|
||||
// RWMutex is a wrapper around the sync.RWMutex
|
||||
type RWMutex struct {
|
||||
mu gosync.RWMutex
|
||||
}
|
||||
|
||||
// Lock locks the mutex
|
||||
func (m *RWMutex) Lock() {
|
||||
logLock(m)
|
||||
m.mu.Lock()
|
||||
}
|
||||
|
||||
// Unlock unlocks the mutex
|
||||
func (m *RWMutex) Unlock() {
|
||||
logUnlock(m)
|
||||
m.mu.Unlock()
|
||||
}
|
||||
|
||||
// RLock locks the mutex for reading
|
||||
func (m *RWMutex) RLock() {
|
||||
logRLock(m)
|
||||
m.mu.RLock()
|
||||
}
|
||||
|
||||
// RUnlock unlocks the mutex for reading
|
||||
func (m *RWMutex) RUnlock() {
|
||||
logRUnlock(m)
|
||||
m.mu.RUnlock()
|
||||
}
|
||||
|
||||
// TryRLock tries to lock the mutex for reading
|
||||
func (m *RWMutex) TryRLock() bool {
|
||||
logTryRLock(m)
|
||||
l := m.mu.TryRLock()
|
||||
logTryRLockResult(m, l)
|
||||
return l
|
||||
}
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
//go:build synctrace
|
||||
|
||||
package sync
|
||||
|
||||
import (
|
||||
gosync "sync"
|
||||
)
|
||||
|
||||
// Once is a wrapper around the sync.Once
|
||||
type Once struct {
|
||||
mu gosync.Once
|
||||
}
|
||||
|
||||
// Do calls the function f if and only if Do has not been called before for this instance of Once.
|
||||
func (o *Once) Do(f func()) {
|
||||
logTrace("Doing once")
|
||||
o.mu.Do(f)
|
||||
}
|
||||
|
|
@ -0,0 +1,92 @@
|
|||
//go:build !synctrace
|
||||
|
||||
package sync
|
||||
|
||||
import (
|
||||
gosync "sync"
|
||||
)
|
||||
|
||||
// Mutex is a wrapper around the sync.Mutex
|
||||
type Mutex struct {
|
||||
mu gosync.Mutex
|
||||
}
|
||||
|
||||
// Lock locks the mutex
|
||||
func (m *Mutex) Lock() {
|
||||
m.mu.Lock()
|
||||
}
|
||||
|
||||
// Unlock unlocks the mutex
|
||||
func (m *Mutex) Unlock() {
|
||||
m.mu.Unlock()
|
||||
}
|
||||
|
||||
// TryLock tries to lock the mutex
|
||||
func (m *Mutex) TryLock() bool {
|
||||
return m.mu.TryLock()
|
||||
}
|
||||
|
||||
// RWMutex is a wrapper around the sync.RWMutex
|
||||
type RWMutex struct {
|
||||
mu gosync.RWMutex
|
||||
}
|
||||
|
||||
// Lock locks the mutex
|
||||
func (m *RWMutex) Lock() {
|
||||
m.mu.Lock()
|
||||
}
|
||||
|
||||
// Unlock unlocks the mutex
|
||||
func (m *RWMutex) Unlock() {
|
||||
m.mu.Unlock()
|
||||
}
|
||||
|
||||
// RLock locks the mutex for reading
|
||||
func (m *RWMutex) RLock() {
|
||||
m.mu.RLock()
|
||||
}
|
||||
|
||||
// RUnlock unlocks the mutex for reading
|
||||
func (m *RWMutex) RUnlock() {
|
||||
m.mu.RUnlock()
|
||||
}
|
||||
|
||||
// TryRLock tries to lock the mutex for reading
|
||||
func (m *RWMutex) TryRLock() bool {
|
||||
return m.mu.TryRLock()
|
||||
}
|
||||
|
||||
// TryLock tries to lock the mutex
|
||||
func (m *RWMutex) TryLock() bool {
|
||||
return m.mu.TryLock()
|
||||
}
|
||||
|
||||
// WaitGroup is a wrapper around the sync.WaitGroup
|
||||
type WaitGroup struct {
|
||||
wg gosync.WaitGroup
|
||||
}
|
||||
|
||||
// Add adds a function to the wait group
|
||||
func (w *WaitGroup) Add(delta int) {
|
||||
w.wg.Add(delta)
|
||||
}
|
||||
|
||||
// Done decrements the wait group counter
|
||||
func (w *WaitGroup) Done() {
|
||||
w.wg.Done()
|
||||
}
|
||||
|
||||
// Wait waits for the wait group to finish
|
||||
func (w *WaitGroup) Wait() {
|
||||
w.wg.Wait()
|
||||
}
|
||||
|
||||
// Once is a wrapper around the sync.Once
|
||||
type Once struct {
|
||||
mu gosync.Once
|
||||
}
|
||||
|
||||
// Do calls the function f if and only if Do has not been called before for this instance of Once.
|
||||
func (o *Once) Do(f func()) {
|
||||
o.mu.Do(f)
|
||||
}
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
//go:build synctrace
|
||||
|
||||
package sync
|
||||
|
||||
import (
|
||||
gosync "sync"
|
||||
)
|
||||
|
||||
// WaitGroup is a wrapper around the sync.WaitGroup
|
||||
type WaitGroup struct {
|
||||
wg gosync.WaitGroup
|
||||
}
|
||||
|
||||
// Add adds a function to the wait group
|
||||
func (w *WaitGroup) Add(delta int) {
|
||||
logTrace("Adding to wait group")
|
||||
w.wg.Add(delta)
|
||||
}
|
||||
|
||||
// Done decrements the wait group counter
|
||||
func (w *WaitGroup) Done() {
|
||||
logTrace("Done with wait group")
|
||||
w.wg.Done()
|
||||
}
|
||||
|
||||
// Wait waits for the wait group to finish
|
||||
func (w *WaitGroup) Wait() {
|
||||
logTrace("Waiting for wait group")
|
||||
w.wg.Wait()
|
||||
}
|
||||
|
|
@ -3,13 +3,14 @@ package timesync
|
|||
import (
|
||||
"context"
|
||||
"math/rand/v2"
|
||||
"net"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/beevik/ntp"
|
||||
)
|
||||
|
||||
var defaultNTPServerIPs = []string{
|
||||
var DefaultNTPServerIPs = []string{
|
||||
// These servers are known by static IP and as such don't need DNS lookups
|
||||
// These are from Google and Cloudflare since if they're down, the internet
|
||||
// is broken anyway
|
||||
|
|
@ -27,7 +28,7 @@ var defaultNTPServerIPs = []string{
|
|||
"2001:4860:4806:c::", // time.google.com IPv6
|
||||
}
|
||||
|
||||
var defaultNTPServerHostnames = []string{
|
||||
var DefaultNTPServerHostnames = []string{
|
||||
// should use something from https://github.com/jauderho/public-ntp-servers
|
||||
"time.apple.com",
|
||||
"time.aws.com",
|
||||
|
|
@ -37,7 +38,48 @@ var defaultNTPServerHostnames = []string{
|
|||
"pool.ntp.org",
|
||||
}
|
||||
|
||||
func (t *TimeSync) filterNTPServers(ntpServers []string) ([]string, error) {
|
||||
if len(ntpServers) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
hasIPv4, err := t.preCheckIPv4()
|
||||
if err != nil {
|
||||
t.l.Error().Err(err).Msg("failed to check IPv4")
|
||||
return nil, err
|
||||
}
|
||||
|
||||
hasIPv6, err := t.preCheckIPv6()
|
||||
if err != nil {
|
||||
t.l.Error().Err(err).Msg("failed to check IPv6")
|
||||
return nil, err
|
||||
}
|
||||
|
||||
filteredServers := []string{}
|
||||
for _, server := range ntpServers {
|
||||
ip := net.ParseIP(server)
|
||||
t.l.Trace().Str("server", server).Interface("ip", ip).Msg("checking NTP server")
|
||||
if ip == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if hasIPv4 && ip.To4() != nil {
|
||||
filteredServers = append(filteredServers, server)
|
||||
}
|
||||
if hasIPv6 && ip.To16() != nil {
|
||||
filteredServers = append(filteredServers, server)
|
||||
}
|
||||
}
|
||||
return filteredServers, nil
|
||||
}
|
||||
|
||||
func (t *TimeSync) queryNetworkTime(ntpServers []string) (now *time.Time, offset *time.Duration) {
|
||||
ntpServers, err := t.filterNTPServers(ntpServers)
|
||||
if err != nil {
|
||||
t.l.Error().Err(err).Msg("failed to filter NTP servers")
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
chunkSize := int(t.networkConfig.TimeSyncParallel.ValueOr(4))
|
||||
t.l.Info().Strs("servers", ntpServers).Int("chunkSize", chunkSize).Msg("querying NTP servers")
|
||||
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import (
|
|||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/jetkvm/kvm/internal/network"
|
||||
"github.com/jetkvm/kvm/internal/network/types"
|
||||
"github.com/rs/zerolog"
|
||||
)
|
||||
|
||||
|
|
@ -24,11 +24,13 @@ var (
|
|||
timeSyncRetryInterval = 0 * time.Second
|
||||
)
|
||||
|
||||
type PreCheckFunc func() (bool, error)
|
||||
|
||||
type TimeSync struct {
|
||||
syncLock *sync.Mutex
|
||||
l *zerolog.Logger
|
||||
|
||||
networkConfig *network.NetworkConfig
|
||||
networkConfig *types.NetworkConfig
|
||||
dhcpNtpAddresses []string
|
||||
|
||||
rtcDevicePath string
|
||||
|
|
@ -36,14 +38,19 @@ type TimeSync struct {
|
|||
rtcLock *sync.Mutex
|
||||
|
||||
syncSuccess bool
|
||||
timer *time.Timer
|
||||
|
||||
preCheckFunc func() (bool, error)
|
||||
preCheckFunc PreCheckFunc
|
||||
preCheckIPv4 PreCheckFunc
|
||||
preCheckIPv6 PreCheckFunc
|
||||
}
|
||||
|
||||
type TimeSyncOptions struct {
|
||||
PreCheckFunc func() (bool, error)
|
||||
PreCheckFunc PreCheckFunc
|
||||
PreCheckIPv4 PreCheckFunc
|
||||
PreCheckIPv6 PreCheckFunc
|
||||
Logger *zerolog.Logger
|
||||
NetworkConfig *network.NetworkConfig
|
||||
NetworkConfig *types.NetworkConfig
|
||||
}
|
||||
|
||||
type SyncMode struct {
|
||||
|
|
@ -69,7 +76,10 @@ func NewTimeSync(opts *TimeSyncOptions) *TimeSync {
|
|||
rtcDevicePath: rtcDevice,
|
||||
rtcLock: &sync.Mutex{},
|
||||
preCheckFunc: opts.PreCheckFunc,
|
||||
preCheckIPv4: opts.PreCheckIPv4,
|
||||
preCheckIPv6: opts.PreCheckIPv6,
|
||||
networkConfig: opts.NetworkConfig,
|
||||
timer: time.NewTimer(timeSyncWaitNetUpInt),
|
||||
}
|
||||
|
||||
if t.rtcDevicePath != "" {
|
||||
|
|
@ -112,49 +122,64 @@ func (t *TimeSync) getSyncMode() SyncMode {
|
|||
}
|
||||
}
|
||||
|
||||
t.l.Debug().Strs("Ordering", syncMode.Ordering).Bool("Ntp", syncMode.Ntp).Bool("Http", syncMode.Http).Bool("NtpUseFallback", syncMode.NtpUseFallback).Bool("HttpUseFallback", syncMode.HttpUseFallback).Msg("sync mode")
|
||||
t.l.Debug().
|
||||
Strs("Ordering", syncMode.Ordering).
|
||||
Bool("Ntp", syncMode.Ntp).
|
||||
Bool("Http", syncMode.Http).
|
||||
Bool("NtpUseFallback", syncMode.NtpUseFallback).
|
||||
Bool("HttpUseFallback", syncMode.HttpUseFallback).
|
||||
Msg("sync mode")
|
||||
|
||||
return syncMode
|
||||
}
|
||||
func (t *TimeSync) doTimeSync() {
|
||||
func (t *TimeSync) timeSyncLoop() {
|
||||
metricTimeSyncStatus.Set(0)
|
||||
for {
|
||||
|
||||
// use a timer here instead of sleep
|
||||
|
||||
for range t.timer.C {
|
||||
if ok, err := t.preCheckFunc(); !ok {
|
||||
if err != nil {
|
||||
t.l.Error().Err(err).Msg("pre-check failed")
|
||||
}
|
||||
time.Sleep(timeSyncWaitNetChkInt)
|
||||
t.timer.Reset(timeSyncWaitNetChkInt)
|
||||
continue
|
||||
}
|
||||
|
||||
t.l.Info().Msg("syncing system time")
|
||||
start := time.Now()
|
||||
err := t.Sync()
|
||||
err := t.sync()
|
||||
if err != nil {
|
||||
t.l.Error().Str("error", err.Error()).Msg("failed to sync system time")
|
||||
|
||||
// retry after a delay
|
||||
timeSyncRetryInterval += timeSyncRetryStep
|
||||
time.Sleep(timeSyncRetryInterval)
|
||||
t.timer.Reset(timeSyncRetryInterval)
|
||||
// reset the retry interval if it exceeds the max interval
|
||||
if timeSyncRetryInterval > timeSyncRetryMaxInt {
|
||||
timeSyncRetryInterval = 0
|
||||
}
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
isInitialSync := !t.syncSuccess
|
||||
t.syncSuccess = true
|
||||
|
||||
t.l.Info().Str("now", time.Now().Format(time.RFC3339)).
|
||||
Str("time_taken", time.Since(start).String()).
|
||||
Bool("is_initial_sync", isInitialSync).
|
||||
Msg("time sync successful")
|
||||
|
||||
metricTimeSyncStatus.Set(1)
|
||||
|
||||
time.Sleep(timeSyncInterval) // after the first sync is done
|
||||
t.timer.Reset(timeSyncInterval) // after the first sync is done
|
||||
}
|
||||
}
|
||||
|
||||
func (t *TimeSync) Sync() error {
|
||||
func (t *TimeSync) sync() error {
|
||||
t.syncLock.Lock()
|
||||
defer t.syncLock.Unlock()
|
||||
|
||||
var (
|
||||
now *time.Time
|
||||
offset *time.Duration
|
||||
|
|
@ -188,10 +213,10 @@ Orders:
|
|||
case "ntp":
|
||||
if syncMode.Ntp && syncMode.NtpUseFallback {
|
||||
log.Info().Msg("using NTP fallback IPs")
|
||||
now, offset = t.queryNetworkTime(defaultNTPServerIPs)
|
||||
now, offset = t.queryNetworkTime(DefaultNTPServerIPs)
|
||||
if now == nil {
|
||||
log.Info().Msg("using NTP fallback hostnames")
|
||||
now, offset = t.queryNetworkTime(defaultNTPServerHostnames)
|
||||
now, offset = t.queryNetworkTime(DefaultNTPServerHostnames)
|
||||
}
|
||||
if now != nil {
|
||||
break Orders
|
||||
|
|
@ -239,12 +264,25 @@ Orders:
|
|||
return nil
|
||||
}
|
||||
|
||||
// Sync triggers a manual time sync
|
||||
func (t *TimeSync) Sync() error {
|
||||
if !t.syncLock.TryLock() {
|
||||
t.l.Warn().Msg("sync already in progress, skipping")
|
||||
return nil
|
||||
}
|
||||
t.syncLock.Unlock()
|
||||
|
||||
return t.sync()
|
||||
}
|
||||
|
||||
// IsSyncSuccess returns true if the system time is synchronized
|
||||
func (t *TimeSync) IsSyncSuccess() bool {
|
||||
return t.syncSuccess
|
||||
}
|
||||
|
||||
// Start starts the time sync
|
||||
func (t *TimeSync) Start() {
|
||||
go t.doTimeSync()
|
||||
go t.timeSyncLoop()
|
||||
}
|
||||
|
||||
func (t *TimeSync) setSystemTime(now time.Time) error {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
194
jsonrpc.go
194
jsonrpc.go
|
|
@ -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
32
main.go
|
|
@ -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
16
mdns.go
|
|
@ -1,19 +1,23 @@
|
|||
package kvm
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/jetkvm/kvm/internal/mdns"
|
||||
)
|
||||
|
||||
var mDNS *mdns.MDNS
|
||||
|
||||
func initMdns() error {
|
||||
options := getMdnsOptions()
|
||||
if options == nil {
|
||||
return fmt.Errorf("failed to get mDNS options")
|
||||
}
|
||||
|
||||
m, err := mdns.NewMDNS(&mdns.MDNSOptions{
|
||||
Logger: logger,
|
||||
LocalNames: []string{
|
||||
networkState.GetHostname(),
|
||||
networkState.GetFQDN(),
|
||||
},
|
||||
ListenOptions: config.NetworkConfig.GetMDNSMode(),
|
||||
Logger: logger,
|
||||
LocalNames: options.LocalNames,
|
||||
ListenOptions: options.ListenOptions,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
|
|
|
|||
18
native.go
18
native.go
|
|
@ -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()
|
||||
|
|
|
|||
327
network.go
327
network.go
|
|
@ -1,10 +1,14 @@
|
|||
package kvm
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"reflect"
|
||||
|
||||
"github.com/jetkvm/kvm/internal/network"
|
||||
"github.com/jetkvm/kvm/internal/udhcpc"
|
||||
"github.com/jetkvm/kvm/internal/confparser"
|
||||
"github.com/jetkvm/kvm/internal/mdns"
|
||||
"github.com/jetkvm/kvm/internal/network/types"
|
||||
"github.com/jetkvm/kvm/pkg/nmlite"
|
||||
)
|
||||
|
||||
const (
|
||||
|
|
@ -12,114 +16,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
72
ota.go
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,219 @@
|
|||
// Package nmlite provides DHCP client functionality for the network manager.
|
||||
package nmlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/jetkvm/kvm/internal/network/types"
|
||||
"github.com/jetkvm/kvm/pkg/nmlite/jetdhcpc"
|
||||
"github.com/jetkvm/kvm/pkg/nmlite/udhcpc"
|
||||
"github.com/rs/zerolog"
|
||||
)
|
||||
|
||||
// DHCPClient wraps the dhclient package for use in the network manager
|
||||
type DHCPClient struct {
|
||||
ctx context.Context
|
||||
ifaceName string
|
||||
logger *zerolog.Logger
|
||||
client types.DHCPClient
|
||||
clientType string
|
||||
|
||||
// Configuration
|
||||
ipv4Enabled bool
|
||||
ipv6Enabled bool
|
||||
|
||||
// Callbacks
|
||||
onLeaseChange func(lease *types.DHCPLease)
|
||||
}
|
||||
|
||||
// NewDHCPClient creates a new DHCP client
|
||||
func NewDHCPClient(ctx context.Context, ifaceName string, logger *zerolog.Logger, clientType string) (*DHCPClient, error) {
|
||||
if ifaceName == "" {
|
||||
return nil, fmt.Errorf("interface name cannot be empty")
|
||||
}
|
||||
|
||||
if logger == nil {
|
||||
return nil, fmt.Errorf("logger cannot be nil")
|
||||
}
|
||||
|
||||
return &DHCPClient{
|
||||
ctx: ctx,
|
||||
ifaceName: ifaceName,
|
||||
logger: logger,
|
||||
clientType: clientType,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// SetIPv4 enables or disables IPv4 DHCP
|
||||
func (dc *DHCPClient) SetIPv4(enabled bool) {
|
||||
dc.ipv4Enabled = enabled
|
||||
if dc.client != nil {
|
||||
dc.client.SetIPv4(enabled)
|
||||
}
|
||||
}
|
||||
|
||||
// SetIPv6 enables or disables IPv6 DHCP
|
||||
func (dc *DHCPClient) SetIPv6(enabled bool) {
|
||||
dc.ipv6Enabled = enabled
|
||||
if dc.client != nil {
|
||||
dc.client.SetIPv6(enabled)
|
||||
}
|
||||
}
|
||||
|
||||
// SetOnLeaseChange sets the callback for lease changes
|
||||
func (dc *DHCPClient) SetOnLeaseChange(callback func(lease *types.DHCPLease)) {
|
||||
dc.onLeaseChange = callback
|
||||
}
|
||||
|
||||
func (dc *DHCPClient) initClient() (types.DHCPClient, error) {
|
||||
switch dc.clientType {
|
||||
case "jetdhcpc":
|
||||
return dc.initJetDHCPC()
|
||||
case "udhcpc":
|
||||
return dc.initUDHCPC()
|
||||
default:
|
||||
return nil, fmt.Errorf("invalid client type: %s", dc.clientType)
|
||||
}
|
||||
}
|
||||
|
||||
func (dc *DHCPClient) initJetDHCPC() (types.DHCPClient, error) {
|
||||
return jetdhcpc.NewClient(dc.ctx, []string{dc.ifaceName}, &jetdhcpc.Config{
|
||||
IPv4: dc.ipv4Enabled,
|
||||
IPv6: dc.ipv6Enabled,
|
||||
V4ClientIdentifier: true,
|
||||
OnLease4Change: func(lease *types.DHCPLease) {
|
||||
dc.handleLeaseChange(lease, false)
|
||||
},
|
||||
OnLease6Change: func(lease *types.DHCPLease) {
|
||||
dc.handleLeaseChange(lease, true)
|
||||
},
|
||||
UpdateResolvConf: func(nameservers []string) error {
|
||||
// This will be handled by the resolv.conf manager
|
||||
dc.logger.Debug().
|
||||
Interface("nameservers", nameservers).
|
||||
Msg("DHCP client requested resolv.conf update")
|
||||
return nil
|
||||
},
|
||||
}, dc.logger)
|
||||
}
|
||||
|
||||
func (dc *DHCPClient) initUDHCPC() (types.DHCPClient, error) {
|
||||
c := udhcpc.NewDHCPClient(&udhcpc.DHCPClientOptions{
|
||||
InterfaceName: dc.ifaceName,
|
||||
PidFile: "",
|
||||
Logger: dc.logger,
|
||||
OnLeaseChange: func(lease *types.DHCPLease) {
|
||||
dc.handleLeaseChange(lease, false)
|
||||
},
|
||||
})
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// Start starts the DHCP client
|
||||
func (dc *DHCPClient) Start() error {
|
||||
if dc.client != nil {
|
||||
dc.logger.Warn().Msg("DHCP client already started")
|
||||
return nil
|
||||
}
|
||||
|
||||
dc.logger.Info().Msg("starting DHCP client")
|
||||
|
||||
// Create the underlying DHCP client
|
||||
client, err := dc.initClient()
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create DHCP client: %w", err)
|
||||
}
|
||||
|
||||
dc.client = client
|
||||
|
||||
// Start the client
|
||||
if err := dc.client.Start(); err != nil {
|
||||
dc.client = nil
|
||||
return fmt.Errorf("failed to start DHCP client: %w", err)
|
||||
}
|
||||
|
||||
dc.logger.Info().Msg("DHCP client started")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (dc *DHCPClient) Domain() string {
|
||||
if dc.client == nil {
|
||||
return ""
|
||||
}
|
||||
return dc.client.Domain()
|
||||
}
|
||||
|
||||
func (dc *DHCPClient) Lease4() *types.DHCPLease {
|
||||
if dc.client == nil {
|
||||
return nil
|
||||
}
|
||||
return dc.client.Lease4()
|
||||
}
|
||||
|
||||
func (dc *DHCPClient) Lease6() *types.DHCPLease {
|
||||
if dc.client == nil {
|
||||
return nil
|
||||
}
|
||||
return dc.client.Lease6()
|
||||
}
|
||||
|
||||
// Stop stops the DHCP client
|
||||
func (dc *DHCPClient) Stop() error {
|
||||
if dc.client == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
dc.logger.Info().Msg("stopping DHCP client")
|
||||
|
||||
dc.client = nil
|
||||
dc.logger.Info().Msg("DHCP client stopped")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Renew renews the DHCP lease
|
||||
func (dc *DHCPClient) Renew() error {
|
||||
if dc.client == nil {
|
||||
return fmt.Errorf("DHCP client not started")
|
||||
}
|
||||
|
||||
dc.logger.Info().Msg("renewing DHCP lease")
|
||||
if err := dc.client.Renew(); err != nil {
|
||||
return fmt.Errorf("failed to renew DHCP lease: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Release releases the DHCP lease
|
||||
func (dc *DHCPClient) Release() error {
|
||||
if dc.client == nil {
|
||||
return fmt.Errorf("DHCP client not started")
|
||||
}
|
||||
|
||||
dc.logger.Info().Msg("releasing DHCP lease")
|
||||
if err := dc.client.Release(); err != nil {
|
||||
return fmt.Errorf("failed to release DHCP lease: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleLeaseChange handles lease changes from the underlying DHCP client
|
||||
func (dc *DHCPClient) handleLeaseChange(lease *types.DHCPLease, isIPv6 bool) {
|
||||
if lease == nil {
|
||||
return
|
||||
}
|
||||
|
||||
dc.logger.Info().
|
||||
Bool("ipv6", isIPv6).
|
||||
Str("ip", lease.IPAddress.String()).
|
||||
Msg("DHCP lease changed")
|
||||
|
||||
// copy the lease to avoid race conditions
|
||||
leaseCopy := *lease
|
||||
|
||||
// Notify callback
|
||||
if dc.onLeaseChange != nil {
|
||||
dc.onLeaseChange(&leaseCopy)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,261 @@
|
|||
package nmlite
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/net/idna"
|
||||
)
|
||||
|
||||
const (
|
||||
hostnamePath = "/etc/hostname"
|
||||
hostsPath = "/etc/hosts"
|
||||
)
|
||||
|
||||
// SetHostname sets the system hostname and updates /etc/hosts
|
||||
func (hm *ResolvConfManager) SetHostname(hostname, domain string) error {
|
||||
hostname = ToValidHostname(strings.TrimSpace(hostname))
|
||||
domain = ToValidHostname(strings.TrimSpace(domain))
|
||||
|
||||
if hostname == "" {
|
||||
return fmt.Errorf("invalid hostname: %s", hostname)
|
||||
}
|
||||
|
||||
hm.hostname = hostname
|
||||
hm.domain = domain
|
||||
|
||||
return hm.reconcileHostname()
|
||||
}
|
||||
|
||||
func (hm *ResolvConfManager) Domain() string {
|
||||
hm.mu.Lock()
|
||||
defer hm.mu.Unlock()
|
||||
return hm.getDomain()
|
||||
}
|
||||
|
||||
func (hm *ResolvConfManager) Hostname() string {
|
||||
hm.mu.Lock()
|
||||
defer hm.mu.Unlock()
|
||||
return hm.getHostname()
|
||||
}
|
||||
|
||||
func (hm *ResolvConfManager) FQDN() string {
|
||||
hm.mu.Lock()
|
||||
defer hm.mu.Unlock()
|
||||
return hm.getFQDN()
|
||||
}
|
||||
|
||||
func (hm *ResolvConfManager) getFQDN() string {
|
||||
hostname := hm.getHostname()
|
||||
domain := hm.getDomain()
|
||||
|
||||
if domain == "" {
|
||||
return hostname
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%s.%s", hostname, domain)
|
||||
}
|
||||
|
||||
func (hm *ResolvConfManager) getHostname() string {
|
||||
if hm.hostname != "" {
|
||||
return hm.hostname
|
||||
}
|
||||
return "jetkvm"
|
||||
}
|
||||
|
||||
func (hm *ResolvConfManager) getDomain() string {
|
||||
if hm.domain != "" {
|
||||
return hm.domain
|
||||
}
|
||||
|
||||
for _, iface := range hm.conf.ConfigIPv4 {
|
||||
if iface.Domain != "" {
|
||||
return iface.Domain
|
||||
}
|
||||
}
|
||||
|
||||
for _, iface := range hm.conf.ConfigIPv6 {
|
||||
if iface.Domain != "" {
|
||||
return iface.Domain
|
||||
}
|
||||
}
|
||||
|
||||
return "local"
|
||||
}
|
||||
|
||||
func (hm *ResolvConfManager) reconcileHostname() error {
|
||||
hm.mu.Lock()
|
||||
domain := hm.getDomain()
|
||||
hostname := hm.hostname
|
||||
if hostname == "" {
|
||||
hostname = "jetkvm"
|
||||
}
|
||||
hm.mu.Unlock()
|
||||
|
||||
fqdn := hostname
|
||||
if fqdn != "" {
|
||||
fqdn = fmt.Sprintf("%s.%s", hostname, domain)
|
||||
}
|
||||
|
||||
hm.logger.Info().
|
||||
Str("hostname", hostname).
|
||||
Str("fqdn", fqdn).
|
||||
Msg("setting hostname")
|
||||
|
||||
// Update /etc/hostname
|
||||
if err := hm.updateEtcHostname(hostname); err != nil {
|
||||
return fmt.Errorf("failed to update /etc/hostname: %w", err)
|
||||
}
|
||||
|
||||
// Update /etc/hosts
|
||||
if err := hm.updateEtcHosts(hostname, fqdn); err != nil {
|
||||
return fmt.Errorf("failed to update /etc/hosts: %w", err)
|
||||
}
|
||||
|
||||
// Set the hostname using hostname command
|
||||
if err := hm.setSystemHostname(hostname); err != nil {
|
||||
return fmt.Errorf("failed to set system hostname: %w", err)
|
||||
}
|
||||
|
||||
hm.logger.Info().
|
||||
Str("hostname", hostname).
|
||||
Str("fqdn", fqdn).
|
||||
Msg("hostname set successfully")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetCurrentHostname returns the current system hostname
|
||||
func (hm *ResolvConfManager) GetCurrentHostname() (string, error) {
|
||||
return os.Hostname()
|
||||
}
|
||||
|
||||
// GetCurrentFQDN returns the current FQDN
|
||||
func (hm *ResolvConfManager) GetCurrentFQDN() (string, error) {
|
||||
hostname, err := hm.GetCurrentHostname()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Try to get the FQDN from /etc/hosts
|
||||
return hm.getFQDNFromHosts(hostname)
|
||||
}
|
||||
|
||||
// updateEtcHostname updates the /etc/hostname file
|
||||
func (hm *ResolvConfManager) updateEtcHostname(hostname string) error {
|
||||
if err := os.WriteFile(hostnamePath, []byte(hostname), 0644); err != nil {
|
||||
return fmt.Errorf("failed to write %s: %w", hostnamePath, err)
|
||||
}
|
||||
|
||||
hm.logger.Debug().Str("file", hostnamePath).Str("hostname", hostname).Msg("updated /etc/hostname")
|
||||
return nil
|
||||
}
|
||||
|
||||
// updateEtcHosts updates the /etc/hosts file
|
||||
func (hm *ResolvConfManager) updateEtcHosts(hostname, fqdn string) error {
|
||||
// Open /etc/hosts for reading and writing
|
||||
hostsFile, err := os.OpenFile(hostsPath, os.O_RDWR|os.O_SYNC, os.ModeExclusive)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to open %s: %w", hostsPath, err)
|
||||
}
|
||||
defer hostsFile.Close()
|
||||
|
||||
// Read all lines
|
||||
if _, err := hostsFile.Seek(0, io.SeekStart); err != nil {
|
||||
return fmt.Errorf("failed to seek %s: %w", hostsPath, err)
|
||||
}
|
||||
|
||||
lines, err := io.ReadAll(hostsFile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read %s: %w", hostsPath, err)
|
||||
}
|
||||
|
||||
// Process lines
|
||||
newLines := []string{}
|
||||
hostLine := fmt.Sprintf("127.0.1.1\t%s %s", hostname, fqdn)
|
||||
hostLineExists := false
|
||||
|
||||
for _, line := range strings.Split(string(lines), "\n") {
|
||||
if strings.HasPrefix(line, "127.0.1.1") {
|
||||
hostLineExists = true
|
||||
line = hostLine
|
||||
}
|
||||
newLines = append(newLines, line)
|
||||
}
|
||||
|
||||
// Add host line if it doesn't exist
|
||||
if !hostLineExists {
|
||||
newLines = append(newLines, hostLine)
|
||||
}
|
||||
|
||||
// Write back to file
|
||||
if err := hostsFile.Truncate(0); err != nil {
|
||||
return fmt.Errorf("failed to truncate %s: %w", hostsPath, err)
|
||||
}
|
||||
|
||||
if _, err := hostsFile.Seek(0, io.SeekStart); err != nil {
|
||||
return fmt.Errorf("failed to seek %s: %w", hostsPath, err)
|
||||
}
|
||||
|
||||
if _, err := hostsFile.Write([]byte(strings.Join(newLines, "\n"))); err != nil {
|
||||
return fmt.Errorf("failed to write %s: %w", hostsPath, err)
|
||||
}
|
||||
|
||||
hm.logger.Debug().
|
||||
Str("file", hostsPath).
|
||||
Str("hostname", hostname).
|
||||
Str("fqdn", fqdn).
|
||||
Msg("updated /etc/hosts")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// setSystemHostname sets the system hostname using the hostname command
|
||||
func (hm *ResolvConfManager) setSystemHostname(hostname string) error {
|
||||
cmd := exec.Command("hostname", "-F", hostnamePath)
|
||||
if err := cmd.Run(); err != nil {
|
||||
return fmt.Errorf("failed to run hostname command: %w", err)
|
||||
}
|
||||
|
||||
hm.logger.Debug().Str("hostname", hostname).Msg("set system hostname")
|
||||
return nil
|
||||
}
|
||||
|
||||
// getFQDNFromHosts tries to get the FQDN from /etc/hosts
|
||||
func (hm *ResolvConfManager) getFQDNFromHosts(hostname string) (string, error) {
|
||||
content, err := os.ReadFile(hostsPath)
|
||||
if err != nil {
|
||||
return hostname, nil // Return hostname as fallback
|
||||
}
|
||||
|
||||
lines := strings.Split(string(content), "\n")
|
||||
for _, line := range lines {
|
||||
if strings.HasPrefix(line, "127.0.1.1") {
|
||||
parts := strings.Fields(line)
|
||||
if len(parts) >= 2 {
|
||||
// The second part should be the FQDN
|
||||
return parts[1], nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return hostname, nil // Return hostname as fallback
|
||||
}
|
||||
|
||||
// ToValidHostname converts a hostname to a valid format
|
||||
func ToValidHostname(hostname string) string {
|
||||
ascii, err := idna.Lookup.ToASCII(hostname)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return ascii
|
||||
}
|
||||
|
||||
// ValidateHostname validates a hostname
|
||||
func ValidateHostname(hostname string) error {
|
||||
_, err := idna.Lookup.ToASCII(hostname)
|
||||
return err
|
||||
}
|
||||
|
|
@ -0,0 +1,853 @@
|
|||
package nmlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/netip"
|
||||
|
||||
"time"
|
||||
|
||||
"github.com/jetkvm/kvm/internal/sync"
|
||||
|
||||
"github.com/jetkvm/kvm/internal/confparser"
|
||||
"github.com/jetkvm/kvm/internal/logging"
|
||||
"github.com/jetkvm/kvm/internal/network/types"
|
||||
"github.com/jetkvm/kvm/pkg/nmlite/link"
|
||||
"github.com/mdlayher/ndp"
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/vishvananda/netlink"
|
||||
)
|
||||
|
||||
type ResolvConfChangeCallback func(family int, resolvConf *types.InterfaceResolvConf) error
|
||||
|
||||
// InterfaceManager manages a single network interface
|
||||
type InterfaceManager struct {
|
||||
ctx context.Context
|
||||
ifaceName string
|
||||
config *types.NetworkConfig
|
||||
logger *zerolog.Logger
|
||||
state *types.InterfaceState
|
||||
linkState *link.Link
|
||||
stateMu sync.RWMutex
|
||||
|
||||
// Network components
|
||||
staticConfig *StaticConfigManager
|
||||
dhcpClient *DHCPClient
|
||||
|
||||
// Callbacks
|
||||
onStateChange func(state types.InterfaceState)
|
||||
onConfigChange func(config *types.NetworkConfig)
|
||||
onDHCPLeaseChange func(lease *types.DHCPLease)
|
||||
onResolvConfChange ResolvConfChangeCallback
|
||||
|
||||
// Control
|
||||
stopCh chan struct{}
|
||||
wg sync.WaitGroup
|
||||
}
|
||||
|
||||
// NewInterfaceManager creates a new interface manager
|
||||
func NewInterfaceManager(ctx context.Context, ifaceName string, config *types.NetworkConfig, logger *zerolog.Logger) (*InterfaceManager, error) {
|
||||
if config == nil {
|
||||
return nil, fmt.Errorf("config cannot be nil")
|
||||
}
|
||||
|
||||
if logger == nil {
|
||||
logger = logging.GetSubsystemLogger("interface")
|
||||
}
|
||||
|
||||
scopedLogger := logger.With().Str("interface", ifaceName).Logger()
|
||||
|
||||
// Validate and set defaults
|
||||
if err := confparser.SetDefaultsAndValidate(config); err != nil {
|
||||
return nil, fmt.Errorf("invalid config: %w", err)
|
||||
}
|
||||
|
||||
im := &InterfaceManager{
|
||||
ctx: ctx,
|
||||
ifaceName: ifaceName,
|
||||
config: config,
|
||||
logger: &scopedLogger,
|
||||
state: &types.InterfaceState{
|
||||
InterfaceName: ifaceName,
|
||||
// LastUpdated: time.Now(),
|
||||
},
|
||||
stopCh: make(chan struct{}),
|
||||
}
|
||||
|
||||
// Initialize components
|
||||
var err error
|
||||
im.staticConfig, err = NewStaticConfigManager(ifaceName, &scopedLogger)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create static config manager: %w", err)
|
||||
}
|
||||
|
||||
// create the dhcp client
|
||||
im.dhcpClient, err = NewDHCPClient(ctx, ifaceName, &scopedLogger, config.DHCPClient.String)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create DHCP client: %w", err)
|
||||
}
|
||||
|
||||
// Set up DHCP client callbacks
|
||||
im.dhcpClient.SetOnLeaseChange(func(lease *types.DHCPLease) {
|
||||
if im.config.IPv4Mode.String != "dhcp" {
|
||||
im.logger.Warn().Str("mode", im.config.IPv4Mode.String).Msg("ignoring DHCP lease, current mode is not DHCP")
|
||||
return
|
||||
}
|
||||
|
||||
if err := im.applyDHCPLease(lease); err != nil {
|
||||
im.logger.Error().Err(err).Msg("failed to apply DHCP lease")
|
||||
}
|
||||
im.updateStateFromDHCPLease(lease)
|
||||
if im.onDHCPLeaseChange != nil {
|
||||
im.onDHCPLeaseChange(lease)
|
||||
}
|
||||
})
|
||||
|
||||
return im, nil
|
||||
}
|
||||
|
||||
// Start starts managing the interface
|
||||
func (im *InterfaceManager) Start() error {
|
||||
im.stateMu.Lock()
|
||||
defer im.stateMu.Unlock()
|
||||
|
||||
im.logger.Info().Msg("starting interface manager")
|
||||
|
||||
// Start monitoring interface state
|
||||
im.wg.Add(1)
|
||||
go im.monitorInterfaceState()
|
||||
|
||||
nl := getNetlinkManager()
|
||||
|
||||
// Set the link state
|
||||
linkState, err := nl.GetLinkByName(im.ifaceName)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get interface: %w", err)
|
||||
}
|
||||
im.linkState = linkState
|
||||
|
||||
// Bring the interface up
|
||||
_, linkUpErr := nl.EnsureInterfaceUpWithTimeout(
|
||||
im.ctx,
|
||||
im.linkState,
|
||||
30*time.Second,
|
||||
)
|
||||
|
||||
// Set callback after the interface is up
|
||||
nl.AddStateChangeCallback(im.ifaceName, link.StateChangeCallback{
|
||||
Async: true,
|
||||
Func: func(link *link.Link) {
|
||||
im.handleLinkStateChange(link)
|
||||
},
|
||||
})
|
||||
|
||||
if linkUpErr != nil {
|
||||
im.logger.Error().Err(linkUpErr).Msg("failed to bring interface up, continuing anyway")
|
||||
} else {
|
||||
// Apply initial configuration
|
||||
if err := im.applyConfiguration(); err != nil {
|
||||
im.logger.Error().Err(err).Msg("failed to apply initial configuration")
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
im.logger.Info().Msg("interface manager started")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Stop stops managing the interface
|
||||
func (im *InterfaceManager) Stop() error {
|
||||
im.logger.Info().Msg("stopping interface manager")
|
||||
|
||||
close(im.stopCh)
|
||||
im.wg.Wait()
|
||||
|
||||
// Stop DHCP client
|
||||
if im.dhcpClient != nil {
|
||||
if err := im.dhcpClient.Stop(); err != nil {
|
||||
return fmt.Errorf("failed to stop DHCP client: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
im.logger.Info().Msg("interface manager stopped")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (im *InterfaceManager) link() (*link.Link, error) {
|
||||
nl := getNetlinkManager()
|
||||
if nl == nil {
|
||||
return nil, fmt.Errorf("netlink manager not initialized")
|
||||
}
|
||||
return nl.GetLinkByName(im.ifaceName)
|
||||
}
|
||||
|
||||
// IsUp returns true if the interface is up
|
||||
func (im *InterfaceManager) IsUp() bool {
|
||||
im.stateMu.RLock()
|
||||
defer im.stateMu.RUnlock()
|
||||
|
||||
if im.state == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
return im.state.Up
|
||||
}
|
||||
|
||||
// IsOnline returns true if the interface is online
|
||||
func (im *InterfaceManager) IsOnline() bool {
|
||||
im.stateMu.RLock()
|
||||
defer im.stateMu.RUnlock()
|
||||
|
||||
if im.state == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
return im.state.Online
|
||||
}
|
||||
|
||||
// IPv4Ready returns true if the interface has an IPv4 address
|
||||
func (im *InterfaceManager) IPv4Ready() bool {
|
||||
im.stateMu.RLock()
|
||||
defer im.stateMu.RUnlock()
|
||||
|
||||
if im.state == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
return im.state.IPv4Ready
|
||||
}
|
||||
|
||||
// IPv6Ready returns true if the interface has an IPv6 address
|
||||
func (im *InterfaceManager) IPv6Ready() bool {
|
||||
im.stateMu.RLock()
|
||||
defer im.stateMu.RUnlock()
|
||||
|
||||
if im.state == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
return im.state.IPv6Ready
|
||||
}
|
||||
|
||||
// GetIPv4Addresses returns the IPv4 addresses of the interface
|
||||
func (im *InterfaceManager) GetIPv4Addresses() []string {
|
||||
im.stateMu.RLock()
|
||||
defer im.stateMu.RUnlock()
|
||||
|
||||
if im.state == nil {
|
||||
return []string{}
|
||||
}
|
||||
|
||||
return im.state.IPv4Addresses
|
||||
}
|
||||
|
||||
// GetIPv4Address returns the IPv4 address of the interface
|
||||
func (im *InterfaceManager) GetIPv4Address() string {
|
||||
im.stateMu.RLock()
|
||||
defer im.stateMu.RUnlock()
|
||||
|
||||
if im.state == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
return im.state.IPv4Address
|
||||
}
|
||||
|
||||
// GetIPv6Address returns the IPv6 address of the interface
|
||||
func (im *InterfaceManager) GetIPv6Address() string {
|
||||
im.stateMu.RLock()
|
||||
defer im.stateMu.RUnlock()
|
||||
|
||||
if im.state == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
return im.state.IPv6Address
|
||||
}
|
||||
|
||||
// GetIPv6Addresses returns the IPv6 addresses of the interface
|
||||
func (im *InterfaceManager) GetIPv6Addresses() []string {
|
||||
im.stateMu.RLock()
|
||||
defer im.stateMu.RUnlock()
|
||||
|
||||
addresses := []string{}
|
||||
|
||||
if im.state == nil {
|
||||
return addresses
|
||||
}
|
||||
|
||||
for _, addr := range im.state.IPv6Addresses {
|
||||
addresses = append(addresses, addr.Address.String())
|
||||
}
|
||||
|
||||
return addresses
|
||||
}
|
||||
|
||||
// GetMACAddress returns the MAC address of the interface
|
||||
func (im *InterfaceManager) GetMACAddress() string {
|
||||
im.stateMu.RLock()
|
||||
defer im.stateMu.RUnlock()
|
||||
|
||||
if im.state == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
return im.state.MACAddress
|
||||
}
|
||||
|
||||
// GetState returns the current interface state
|
||||
func (im *InterfaceManager) GetState() *types.InterfaceState {
|
||||
im.stateMu.RLock()
|
||||
defer im.stateMu.RUnlock()
|
||||
|
||||
// Return a copy to avoid race conditions
|
||||
im.logger.Debug().Interface("state", im.state).Msg("getting interface state")
|
||||
|
||||
state := *im.state
|
||||
return &state
|
||||
}
|
||||
|
||||
// NTPServers returns the NTP servers of the interface
|
||||
func (im *InterfaceManager) NTPServers() []net.IP {
|
||||
im.stateMu.RLock()
|
||||
defer im.stateMu.RUnlock()
|
||||
|
||||
if im.state == nil {
|
||||
return []net.IP{}
|
||||
}
|
||||
|
||||
return im.state.NTPServers
|
||||
}
|
||||
|
||||
func (im *InterfaceManager) Domain() string {
|
||||
im.stateMu.RLock()
|
||||
defer im.stateMu.RUnlock()
|
||||
|
||||
if im.state == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
if im.state.DHCPLease4 != nil {
|
||||
return im.state.DHCPLease4.Domain
|
||||
}
|
||||
|
||||
if im.state.DHCPLease6 != nil {
|
||||
return im.state.DHCPLease6.Domain
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// GetConfig returns the current interface configuration
|
||||
func (im *InterfaceManager) GetConfig() *types.NetworkConfig {
|
||||
// Return a copy to avoid race conditions
|
||||
config := *im.config
|
||||
return &config
|
||||
}
|
||||
|
||||
// ApplyConfiguration applies the current configuration to the interface
|
||||
func (im *InterfaceManager) ApplyConfiguration() error {
|
||||
return im.applyConfiguration()
|
||||
}
|
||||
|
||||
// SetConfig updates the interface configuration
|
||||
func (im *InterfaceManager) SetConfig(config *types.NetworkConfig) error {
|
||||
if config == nil {
|
||||
return fmt.Errorf("config cannot be nil")
|
||||
}
|
||||
|
||||
// Validate and set defaults
|
||||
if err := confparser.SetDefaultsAndValidate(config); err != nil {
|
||||
return fmt.Errorf("invalid config: %w", err)
|
||||
}
|
||||
|
||||
im.config = config
|
||||
|
||||
// Apply the new configuration
|
||||
if err := im.applyConfiguration(); err != nil {
|
||||
im.logger.Error().Err(err).Msg("failed to apply new configuration")
|
||||
return err
|
||||
}
|
||||
|
||||
// Notify callback
|
||||
if im.onConfigChange != nil {
|
||||
im.onConfigChange(config)
|
||||
}
|
||||
|
||||
im.logger.Info().Msg("configuration updated")
|
||||
return nil
|
||||
}
|
||||
|
||||
// RenewDHCPLease renews the DHCP lease
|
||||
func (im *InterfaceManager) RenewDHCPLease() error {
|
||||
if im.dhcpClient == nil {
|
||||
return fmt.Errorf("DHCP client not available")
|
||||
}
|
||||
|
||||
return im.dhcpClient.Renew()
|
||||
}
|
||||
|
||||
// SetOnStateChange sets the callback for state changes
|
||||
func (im *InterfaceManager) SetOnStateChange(callback func(state types.InterfaceState)) {
|
||||
im.onStateChange = callback
|
||||
}
|
||||
|
||||
// SetOnConfigChange sets the callback for configuration changes
|
||||
func (im *InterfaceManager) SetOnConfigChange(callback func(config *types.NetworkConfig)) {
|
||||
im.onConfigChange = callback
|
||||
}
|
||||
|
||||
// SetOnDHCPLeaseChange sets the callback for DHCP lease changes
|
||||
func (im *InterfaceManager) SetOnDHCPLeaseChange(callback func(lease *types.DHCPLease)) {
|
||||
im.onDHCPLeaseChange = callback
|
||||
}
|
||||
|
||||
// SetOnResolvConfChange sets the callback for resolv.conf changes
|
||||
func (im *InterfaceManager) SetOnResolvConfChange(callback ResolvConfChangeCallback) {
|
||||
im.onResolvConfChange = callback
|
||||
}
|
||||
|
||||
// applyConfiguration applies the current configuration to the interface
|
||||
func (im *InterfaceManager) applyConfiguration() error {
|
||||
im.logger.Info().Msg("applying configuration")
|
||||
|
||||
// Apply IPv4 configuration
|
||||
if err := im.applyIPv4Config(); err != nil {
|
||||
return fmt.Errorf("failed to apply IPv4 config: %w", err)
|
||||
}
|
||||
|
||||
// Apply IPv6 configuration
|
||||
if err := im.applyIPv6Config(); err != nil {
|
||||
return fmt.Errorf("failed to apply IPv6 config: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// applyIPv4Config applies IPv4 configuration
|
||||
func (im *InterfaceManager) applyIPv4Config() error {
|
||||
mode := im.config.IPv4Mode.String
|
||||
im.logger.Info().Str("mode", mode).Msg("applying IPv4 configuration")
|
||||
|
||||
switch mode {
|
||||
case "static":
|
||||
return im.applyIPv4Static()
|
||||
case "dhcp":
|
||||
return im.applyIPv4DHCP()
|
||||
case "disabled":
|
||||
return im.disableIPv4()
|
||||
default:
|
||||
return fmt.Errorf("invalid IPv4 mode: %s", mode)
|
||||
}
|
||||
}
|
||||
|
||||
// applyIPv6Config applies IPv6 configuration
|
||||
func (im *InterfaceManager) applyIPv6Config() error {
|
||||
mode := im.config.IPv6Mode.String
|
||||
im.logger.Info().Str("mode", mode).Msg("applying IPv6 configuration")
|
||||
|
||||
switch mode {
|
||||
case "static":
|
||||
return im.applyIPv6Static()
|
||||
case "dhcpv6":
|
||||
return im.applyIPv6DHCP()
|
||||
case "slaac":
|
||||
return im.applyIPv6SLAAC()
|
||||
case "slaac_and_dhcpv6":
|
||||
return im.applyIPv6SLAACAndDHCP()
|
||||
case "link_local":
|
||||
return im.applyIPv6LinkLocal()
|
||||
case "disabled":
|
||||
return im.disableIPv6()
|
||||
default:
|
||||
return fmt.Errorf("invalid IPv6 mode: %s", mode)
|
||||
}
|
||||
}
|
||||
|
||||
// applyIPv4Static applies static IPv4 configuration
|
||||
func (im *InterfaceManager) applyIPv4Static() error {
|
||||
if im.config.IPv4Static == nil {
|
||||
return fmt.Errorf("IPv4 static configuration is nil")
|
||||
}
|
||||
|
||||
im.logger.Info().Msg("stopping DHCP")
|
||||
|
||||
// Disable DHCP
|
||||
if im.dhcpClient != nil {
|
||||
im.dhcpClient.SetIPv4(false)
|
||||
}
|
||||
|
||||
im.logger.Info().Interface("config", im.config.IPv4Static).Msg("applying IPv4 static configuration")
|
||||
|
||||
config, err := im.staticConfig.ToIPv4Static(im.config.IPv4Static)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to convert IPv4 static configuration: %w", err)
|
||||
}
|
||||
|
||||
im.logger.Info().Interface("config", config).Msg("converted IPv4 static configuration")
|
||||
|
||||
if err := im.onResolvConfChange(link.AfInet, &types.InterfaceResolvConf{
|
||||
NameServers: config.Nameservers,
|
||||
Source: "static",
|
||||
}); err != nil {
|
||||
im.logger.Warn().Err(err).Msg("failed to update resolv.conf")
|
||||
}
|
||||
|
||||
return im.ReconcileLinkAddrs(config.Addresses, link.AfInet)
|
||||
}
|
||||
|
||||
// applyIPv4DHCP applies DHCP IPv4 configuration
|
||||
func (im *InterfaceManager) applyIPv4DHCP() error {
|
||||
if im.dhcpClient == nil {
|
||||
return fmt.Errorf("DHCP client not available")
|
||||
}
|
||||
|
||||
// Enable DHCP
|
||||
im.dhcpClient.SetIPv4(true)
|
||||
return im.dhcpClient.Start()
|
||||
}
|
||||
|
||||
// disableIPv4 disables IPv4
|
||||
func (im *InterfaceManager) disableIPv4() error {
|
||||
// Disable DHCP
|
||||
if im.dhcpClient != nil {
|
||||
im.dhcpClient.SetIPv4(false)
|
||||
}
|
||||
|
||||
// Remove all IPv4 addresses
|
||||
return im.staticConfig.DisableIPv4()
|
||||
}
|
||||
|
||||
// applyIPv6Static applies static IPv6 configuration
|
||||
func (im *InterfaceManager) applyIPv6Static() error {
|
||||
if im.config.IPv6Static == nil {
|
||||
return fmt.Errorf("IPv6 static configuration is nil")
|
||||
}
|
||||
|
||||
im.logger.Info().Msg("stopping DHCPv6")
|
||||
// Disable DHCPv6
|
||||
if im.dhcpClient != nil {
|
||||
im.dhcpClient.SetIPv6(false)
|
||||
}
|
||||
|
||||
// Apply static configuration
|
||||
config, err := im.staticConfig.ToIPv6Static(im.config.IPv6Static)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to convert IPv6 static configuration: %w", err)
|
||||
}
|
||||
im.logger.Info().Interface("config", config).Msg("converted IPv6 static configuration")
|
||||
|
||||
if err := im.onResolvConfChange(link.AfInet6, &types.InterfaceResolvConf{
|
||||
NameServers: config.Nameservers,
|
||||
Source: "static",
|
||||
}); err != nil {
|
||||
im.logger.Warn().Err(err).Msg("failed to update resolv.conf")
|
||||
}
|
||||
|
||||
return im.ReconcileLinkAddrs(config.Addresses, link.AfInet6)
|
||||
}
|
||||
|
||||
// applyIPv6DHCP applies DHCPv6 configuration
|
||||
func (im *InterfaceManager) applyIPv6DHCP() error {
|
||||
if im.dhcpClient == nil {
|
||||
return fmt.Errorf("DHCP client not available")
|
||||
}
|
||||
|
||||
// Enable DHCPv6
|
||||
im.dhcpClient.SetIPv6(true)
|
||||
return im.dhcpClient.Start()
|
||||
}
|
||||
|
||||
// applyIPv6SLAAC applies SLAAC configuration
|
||||
func (im *InterfaceManager) applyIPv6SLAAC() error {
|
||||
// Disable DHCPv6
|
||||
if im.dhcpClient != nil {
|
||||
im.dhcpClient.SetIPv6(false)
|
||||
}
|
||||
|
||||
// Remove static IPv6 configuration
|
||||
l, err := im.link()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get interface: %w", err)
|
||||
}
|
||||
|
||||
netlinkMgr := getNetlinkManager()
|
||||
|
||||
// Ensure interface is up
|
||||
if err := netlinkMgr.EnsureInterfaceUp(l); err != nil {
|
||||
return fmt.Errorf("failed to bring interface up: %w", err)
|
||||
}
|
||||
|
||||
if err := netlinkMgr.RemoveNonLinkLocalIPv6Addresses(l); err != nil {
|
||||
return fmt.Errorf("failed to remove non-link-local IPv6 addresses: %w", err)
|
||||
}
|
||||
|
||||
if err := im.SendRouterSolicitation(); err != nil {
|
||||
im.logger.Error().Err(err).Msg("failed to send router solicitation, continuing anyway")
|
||||
}
|
||||
|
||||
// Enable SLAAC
|
||||
return im.staticConfig.EnableIPv6SLAAC()
|
||||
}
|
||||
|
||||
// applyIPv6SLAACAndDHCP applies SLAAC + DHCPv6 configuration
|
||||
func (im *InterfaceManager) applyIPv6SLAACAndDHCP() error {
|
||||
// Enable both SLAAC and DHCPv6
|
||||
if im.dhcpClient != nil {
|
||||
im.dhcpClient.SetIPv6(true)
|
||||
if err := im.dhcpClient.Start(); err != nil {
|
||||
return fmt.Errorf("failed to start DHCP client: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return im.staticConfig.EnableIPv6SLAAC()
|
||||
}
|
||||
|
||||
// applyIPv6LinkLocal applies link-local only IPv6 configuration
|
||||
func (im *InterfaceManager) applyIPv6LinkLocal() error {
|
||||
// Disable DHCPv6
|
||||
if im.dhcpClient != nil {
|
||||
im.dhcpClient.SetIPv6(false)
|
||||
}
|
||||
|
||||
// Enable link-local only
|
||||
return im.staticConfig.EnableIPv6LinkLocal()
|
||||
}
|
||||
|
||||
// disableIPv6 disables IPv6
|
||||
func (im *InterfaceManager) disableIPv6() error {
|
||||
// Disable DHCPv6
|
||||
if im.dhcpClient != nil {
|
||||
im.dhcpClient.SetIPv6(false)
|
||||
}
|
||||
|
||||
// Disable IPv6
|
||||
return im.staticConfig.DisableIPv6()
|
||||
}
|
||||
|
||||
func (im *InterfaceManager) handleLinkStateChange(link *link.Link) {
|
||||
{
|
||||
im.stateMu.Lock()
|
||||
defer im.stateMu.Unlock()
|
||||
|
||||
if link.IsSame(im.linkState) {
|
||||
return
|
||||
}
|
||||
|
||||
im.linkState = link
|
||||
}
|
||||
|
||||
im.logger.Info().Interface("link", link).Msg("link state changed")
|
||||
|
||||
operState := link.Attrs().OperState
|
||||
if operState == netlink.OperUp {
|
||||
im.handleLinkUp()
|
||||
} else {
|
||||
im.handleLinkDown()
|
||||
}
|
||||
}
|
||||
|
||||
// SendRouterSolicitation sends a router solicitation
|
||||
func (im *InterfaceManager) SendRouterSolicitation() error {
|
||||
im.logger.Info().Msg("sending router solicitation")
|
||||
m := &ndp.RouterSolicitation{}
|
||||
|
||||
l, err := im.link()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get interface: %w", err)
|
||||
}
|
||||
|
||||
if l.Attrs().OperState != netlink.OperUp {
|
||||
return fmt.Errorf("interface %s is not up", im.ifaceName)
|
||||
}
|
||||
|
||||
iface := l.Interface()
|
||||
if iface == nil {
|
||||
return fmt.Errorf("failed to get net.Interface for %s", im.ifaceName)
|
||||
}
|
||||
|
||||
hwAddr := l.HardwareAddr()
|
||||
if hwAddr == nil {
|
||||
return fmt.Errorf("failed to get hardware address for %s", im.ifaceName)
|
||||
}
|
||||
|
||||
c, _, err := ndp.Listen(iface, ndp.LinkLocal)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create NDP listener on %s: %w", im.ifaceName, err)
|
||||
}
|
||||
|
||||
m.Options = append(m.Options, &ndp.LinkLayerAddress{
|
||||
Addr: hwAddr,
|
||||
Direction: ndp.Source,
|
||||
})
|
||||
|
||||
targetAddr := netip.MustParseAddr("ff02::2")
|
||||
|
||||
if err := c.WriteTo(m, nil, targetAddr); err != nil {
|
||||
c.Close()
|
||||
return fmt.Errorf("failed to write to %s: %w", targetAddr.String(), err)
|
||||
}
|
||||
|
||||
im.logger.Info().Msg("router solicitation sent")
|
||||
c.Close()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (im *InterfaceManager) handleLinkUp() {
|
||||
im.logger.Info().Msg("link up")
|
||||
|
||||
if err := im.applyConfiguration(); err != nil {
|
||||
im.logger.Error().Err(err).Msg("failed to apply configuration")
|
||||
}
|
||||
|
||||
if im.config.IPv4Mode.String == "dhcp" {
|
||||
if err := im.dhcpClient.Renew(); err != nil {
|
||||
im.logger.Error().Err(err).Msg("failed to renew DHCP lease")
|
||||
}
|
||||
}
|
||||
|
||||
if im.config.IPv6Mode.String == "slaac" {
|
||||
if err := im.staticConfig.EnableIPv6SLAAC(); err != nil {
|
||||
im.logger.Error().Err(err).Msg("failed to enable IPv6 SLAAC")
|
||||
}
|
||||
if err := im.SendRouterSolicitation(); err != nil {
|
||||
im.logger.Error().Err(err).Msg("failed to send router solicitation")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (im *InterfaceManager) handleLinkDown() {
|
||||
im.logger.Info().Msg("link down")
|
||||
|
||||
if im.config.IPv4Mode.String == "dhcp" {
|
||||
if err := im.dhcpClient.Stop(); err != nil {
|
||||
im.logger.Error().Err(err).Msg("failed to stop DHCP client")
|
||||
}
|
||||
}
|
||||
|
||||
netlinkMgr := getNetlinkManager()
|
||||
if err := netlinkMgr.RemoveAllAddresses(im.linkState, link.AfInet); err != nil {
|
||||
im.logger.Error().Err(err).Msg("failed to remove all IPv4 addresses")
|
||||
}
|
||||
|
||||
if err := netlinkMgr.RemoveNonLinkLocalIPv6Addresses(im.linkState); err != nil {
|
||||
im.logger.Error().Err(err).Msg("failed to remove non-link-local IPv6 addresses")
|
||||
}
|
||||
}
|
||||
|
||||
// monitorInterfaceState monitors the interface state and updates accordingly
|
||||
func (im *InterfaceManager) monitorInterfaceState() {
|
||||
defer im.wg.Done()
|
||||
|
||||
im.logger.Debug().Msg("monitoring interface state")
|
||||
// TODO: use netlink subscription instead of polling
|
||||
ticker := time.NewTicker(5 * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-im.ctx.Done():
|
||||
return
|
||||
case <-im.stopCh:
|
||||
return
|
||||
case <-ticker.C:
|
||||
if err := im.updateInterfaceState(); err != nil {
|
||||
im.logger.Error().Err(err).Msg("failed to update interface state")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// updateStateFromDHCPLease updates the state from a DHCP lease
|
||||
func (im *InterfaceManager) updateStateFromDHCPLease(lease *types.DHCPLease) {
|
||||
family := link.AfInet
|
||||
|
||||
im.stateMu.Lock()
|
||||
if lease.IsIPv6() {
|
||||
im.state.DHCPLease6 = lease
|
||||
family = link.AfInet6
|
||||
} else {
|
||||
im.state.DHCPLease4 = lease
|
||||
}
|
||||
im.stateMu.Unlock()
|
||||
|
||||
// Update resolv.conf with DNS information
|
||||
if im.onResolvConfChange == nil {
|
||||
return
|
||||
}
|
||||
|
||||
if im.ifaceName == "" {
|
||||
im.logger.Warn().Msg("interface name is empty, skipping resolv.conf update")
|
||||
return
|
||||
}
|
||||
|
||||
if err := im.onResolvConfChange(family, &types.InterfaceResolvConf{
|
||||
NameServers: lease.DNS,
|
||||
SearchList: lease.SearchList,
|
||||
Source: "dhcp",
|
||||
}); err != nil {
|
||||
im.logger.Warn().Err(err).Msg("failed to update resolv.conf")
|
||||
}
|
||||
}
|
||||
|
||||
// ReconcileLinkAddrs reconciles the link addresses
|
||||
func (im *InterfaceManager) ReconcileLinkAddrs(addrs []types.IPAddress, family int) error {
|
||||
nl := getNetlinkManager()
|
||||
link, err := im.link()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get interface: %w", err)
|
||||
}
|
||||
if link == nil {
|
||||
return fmt.Errorf("failed to get interface: %w", err)
|
||||
}
|
||||
return nl.ReconcileLink(link, addrs, family)
|
||||
}
|
||||
|
||||
// applyDHCPLease applies DHCP lease configuration using ReconcileLinkAddrs
|
||||
func (im *InterfaceManager) applyDHCPLease(lease *types.DHCPLease) error {
|
||||
if lease == nil {
|
||||
return fmt.Errorf("DHCP lease is nil")
|
||||
}
|
||||
|
||||
if lease.DHCPClient != "jetdhcpc" {
|
||||
im.logger.Warn().Str("dhcp_client", lease.DHCPClient).Msg("ignoring DHCP lease, not implemented yet")
|
||||
return nil
|
||||
}
|
||||
|
||||
if lease.IsIPv6() {
|
||||
im.logger.Warn().Msg("ignoring IPv6 DHCP lease, not implemented yet")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Convert DHCP lease to IPv4Config
|
||||
ipv4Config := im.convertDHCPLeaseToIPv4Config(lease)
|
||||
|
||||
// Apply the configuration using ReconcileLinkAddrs
|
||||
return im.ReconcileLinkAddrs([]types.IPAddress{*ipv4Config}, link.AfInet)
|
||||
}
|
||||
|
||||
// convertDHCPLeaseToIPv4Config converts a DHCP lease to IPv4Config
|
||||
func (im *InterfaceManager) convertDHCPLeaseToIPv4Config(lease *types.DHCPLease) *types.IPAddress {
|
||||
ipNet := lease.IPNet()
|
||||
if ipNet == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Create IPv4Address
|
||||
ipv4Addr := types.IPAddress{
|
||||
Address: *ipNet,
|
||||
Gateway: lease.Routers[0],
|
||||
Secondary: false,
|
||||
Permanent: false,
|
||||
}
|
||||
|
||||
im.logger.Trace().
|
||||
Interface("ipv4Addr", ipv4Addr).
|
||||
Interface("lease", lease).
|
||||
Msg("converted DHCP lease to IPv4Config")
|
||||
|
||||
// Create IPv4Config
|
||||
return &ipv4Addr
|
||||
}
|
||||
|
|
@ -0,0 +1,163 @@
|
|||
package nmlite
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/jetkvm/kvm/internal/network/types"
|
||||
"github.com/jetkvm/kvm/pkg/nmlite/link"
|
||||
"github.com/vishvananda/netlink"
|
||||
)
|
||||
|
||||
// updateInterfaceState updates the current interface state
|
||||
func (im *InterfaceManager) updateInterfaceState() error {
|
||||
nl, err := im.link()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get interface: %w", err)
|
||||
}
|
||||
|
||||
var stateChanged bool
|
||||
|
||||
attrs := nl.Attrs()
|
||||
|
||||
// We should release the lock before calling the callbacks
|
||||
// to avoid deadlocks
|
||||
im.stateMu.Lock()
|
||||
|
||||
// Check if the interface is up
|
||||
isUp := attrs.OperState == netlink.OperUp
|
||||
if im.state.Up != isUp {
|
||||
im.state.Up = isUp
|
||||
stateChanged = true
|
||||
}
|
||||
|
||||
// Check if the interface is online
|
||||
isOnline := isUp && nl.HasGlobalUnicastAddress()
|
||||
if im.state.Online != isOnline {
|
||||
im.state.Online = isOnline
|
||||
stateChanged = true
|
||||
}
|
||||
|
||||
// Check if the MAC address has changed
|
||||
if im.state.MACAddress != attrs.HardwareAddr.String() {
|
||||
im.state.MACAddress = attrs.HardwareAddr.String()
|
||||
stateChanged = true
|
||||
}
|
||||
|
||||
// Update IP addresses
|
||||
if ipChanged, err := im.updateInterfaceStateAddresses(nl); err != nil {
|
||||
im.logger.Error().Err(err).Msg("failed to update IP addresses")
|
||||
} else if ipChanged {
|
||||
stateChanged = true
|
||||
}
|
||||
|
||||
im.state.LastUpdated = time.Now()
|
||||
im.stateMu.Unlock()
|
||||
|
||||
// Notify callback if state changed
|
||||
if stateChanged && im.onStateChange != nil {
|
||||
im.logger.Debug().Interface("state", im.state).Msg("notifying state change")
|
||||
im.onStateChange(*im.state)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// updateIPAddresses updates the IP addresses in the state
|
||||
func (im *InterfaceManager) updateInterfaceStateAddresses(nl *link.Link) (bool, error) {
|
||||
mgr := getNetlinkManager()
|
||||
|
||||
addrs, err := nl.AddrList(link.AfUnspec)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("failed to get addresses: %w", err)
|
||||
}
|
||||
|
||||
var (
|
||||
ipv4Addresses []string
|
||||
ipv6Addresses []types.IPv6Address
|
||||
ipv4Addr, ipv6Addr string
|
||||
ipv6LinkLocal string
|
||||
ipv6Gateway string
|
||||
ipv4Ready, ipv6Ready = false, false
|
||||
stateChanged = false
|
||||
)
|
||||
|
||||
routes, _ := mgr.ListDefaultRoutes(link.AfInet6)
|
||||
if len(routes) > 0 {
|
||||
ipv6Gateway = routes[0].Gw.String()
|
||||
}
|
||||
|
||||
for _, addr := range addrs {
|
||||
if addr.IP.To4() != nil {
|
||||
// IPv4 address
|
||||
ipv4Addresses = append(ipv4Addresses, addr.IPNet.String())
|
||||
if ipv4Addr == "" {
|
||||
ipv4Addr = addr.IP.String()
|
||||
ipv4Ready = true
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// IPv6 address (if it's not an IPv4 address, it must be an IPv6 address)
|
||||
if addr.IP.IsLinkLocalUnicast() {
|
||||
ipv6LinkLocal = addr.IP.String()
|
||||
continue
|
||||
} else if !addr.IP.IsGlobalUnicast() {
|
||||
continue
|
||||
}
|
||||
|
||||
ipv6Addresses = append(ipv6Addresses, types.IPv6Address{
|
||||
Address: addr.IP,
|
||||
Prefix: *addr.IPNet,
|
||||
Scope: addr.Scope,
|
||||
Flags: addr.Flags,
|
||||
ValidLifetime: lifetimeToTime(addr.ValidLft),
|
||||
PreferredLifetime: lifetimeToTime(addr.PreferedLft),
|
||||
})
|
||||
if ipv6Addr == "" {
|
||||
ipv6Addr = addr.IP.String()
|
||||
ipv6Ready = true
|
||||
}
|
||||
}
|
||||
|
||||
if !sortAndCompareStringSlices(im.state.IPv4Addresses, ipv4Addresses) {
|
||||
im.state.IPv4Addresses = ipv4Addresses
|
||||
stateChanged = true
|
||||
}
|
||||
|
||||
if !sortAndCompareIPv6AddressSlices(im.state.IPv6Addresses, ipv6Addresses) {
|
||||
im.state.IPv6Addresses = ipv6Addresses
|
||||
stateChanged = true
|
||||
}
|
||||
|
||||
if im.state.IPv4Address != ipv4Addr {
|
||||
im.state.IPv4Address = ipv4Addr
|
||||
stateChanged = true
|
||||
}
|
||||
|
||||
if im.state.IPv6Address != ipv6Addr {
|
||||
im.state.IPv6Address = ipv6Addr
|
||||
stateChanged = true
|
||||
}
|
||||
if im.state.IPv6LinkLocal != ipv6LinkLocal {
|
||||
im.state.IPv6LinkLocal = ipv6LinkLocal
|
||||
stateChanged = true
|
||||
}
|
||||
|
||||
if im.state.IPv6Gateway != ipv6Gateway {
|
||||
im.state.IPv6Gateway = ipv6Gateway
|
||||
stateChanged = true
|
||||
}
|
||||
|
||||
if im.state.IPv4Ready != ipv4Ready {
|
||||
im.state.IPv4Ready = ipv4Ready
|
||||
stateChanged = true
|
||||
}
|
||||
|
||||
if im.state.IPv6Ready != ipv6Ready {
|
||||
im.state.IPv6Ready = ipv6Ready
|
||||
stateChanged = true
|
||||
}
|
||||
|
||||
return stateChanged, nil
|
||||
}
|
||||
|
|
@ -0,0 +1,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")
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,85 @@
|
|||
package jetdhcpc
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/insomniacslk/dhcp/dhcpv4"
|
||||
"github.com/insomniacslk/dhcp/dhcpv4/nclient4"
|
||||
"github.com/vishvananda/netlink"
|
||||
)
|
||||
|
||||
func (c *Client) requestLease4(ifname string) (*Lease, error) {
|
||||
iface, err := netlink.LinkByName(ifname)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
l := c.l.With().Str("interface", ifname).Logger()
|
||||
|
||||
mods := []nclient4.ClientOpt{
|
||||
nclient4.WithTimeout(c.cfg.Timeout),
|
||||
nclient4.WithRetry(c.cfg.Retries),
|
||||
}
|
||||
mods = append(mods, c.getDHCP4Logger(ifname))
|
||||
if c.cfg.V4ServerAddr != nil {
|
||||
mods = append(mods, nclient4.WithServerAddr(c.cfg.V4ServerAddr))
|
||||
}
|
||||
|
||||
client, err := nclient4.New(ifname, mods...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
// Prepend modifiers with default options, so they can be overridden.
|
||||
reqmods := append(
|
||||
[]dhcpv4.Modifier{
|
||||
dhcpv4.WithOption(dhcpv4.OptClassIdentifier(VendorIdentifier)),
|
||||
dhcpv4.WithRequestedOptions(
|
||||
dhcpv4.OptionSubnetMask,
|
||||
dhcpv4.OptionInterfaceMTU,
|
||||
dhcpv4.OptionNTPServers,
|
||||
dhcpv4.OptionDomainName,
|
||||
dhcpv4.OptionDomainNameServer,
|
||||
dhcpv4.OptionDNSDomainSearchList,
|
||||
),
|
||||
},
|
||||
c.cfg.Modifiers4...)
|
||||
|
||||
if c.cfg.V4ClientIdentifier {
|
||||
// Client Id is hardware type + mac per RFC 2132 9.14.
|
||||
ident := []byte{0x01} // Type ethernet
|
||||
ident = append(ident, iface.Attrs().HardwareAddr...)
|
||||
reqmods = append(reqmods, dhcpv4.WithOption(dhcpv4.OptClientIdentifier(ident)))
|
||||
}
|
||||
|
||||
if c.cfg.Hostname != "" {
|
||||
reqmods = append(reqmods, dhcpv4.WithOption(dhcpv4.OptHostName(c.cfg.Hostname)))
|
||||
}
|
||||
|
||||
l.Info().Msg("attempting to get DHCPv4 lease")
|
||||
var (
|
||||
lease *nclient4.Lease
|
||||
reqErr error
|
||||
)
|
||||
if c.currentLease4 != nil {
|
||||
l.Info().Msg("current lease is not nil, renewing")
|
||||
lease, reqErr = client.Renew(c.ctx, c.currentLease4.p4, reqmods...)
|
||||
} else {
|
||||
l.Info().Msg("current lease is nil, requesting new lease")
|
||||
lease, reqErr = client.Request(c.ctx, reqmods...)
|
||||
}
|
||||
|
||||
if reqErr != nil {
|
||||
return nil, reqErr
|
||||
}
|
||||
|
||||
if lease == nil || lease.ACK == nil {
|
||||
return nil, fmt.Errorf("failed to acquire DHCPv4 lease")
|
||||
}
|
||||
|
||||
summaryStructured(lease.ACK, &l).Info().Msgf("DHCPv4 lease acquired: %s", lease.ACK.String())
|
||||
l.Trace().Interface("options", lease.ACK.Options.String()).Msg("DHCPv4 lease options")
|
||||
|
||||
return fromNclient4Lease(lease, ifname), nil
|
||||
}
|
||||
|
|
@ -0,0 +1,135 @@
|
|||
package jetdhcpc
|
||||
|
||||
import (
|
||||
"net"
|
||||
"time"
|
||||
|
||||
"github.com/insomniacslk/dhcp/dhcpv6"
|
||||
"github.com/insomniacslk/dhcp/dhcpv6/nclient6"
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/vishvananda/netlink"
|
||||
)
|
||||
|
||||
// isIPv6LinkReady returns true if the interface has a link-local address
|
||||
// which is not tentative.
|
||||
func isIPv6LinkReady(l netlink.Link, logger *zerolog.Logger) (bool, error) {
|
||||
addrs, err := netlink.AddrList(l, 10) // AF_INET6
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
for _, addr := range addrs {
|
||||
if addr.IP.IsLinkLocalUnicast() && (addr.Flags&0x40 == 0) { // IFA_F_TENTATIVE
|
||||
if addr.Flags&0x80 != 0 { // IFA_F_DADFAILED
|
||||
logger.Warn().Str("address", addr.IP.String()).Msg("DADFAILED for address, continuing anyhow")
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// isIPv6RouteReady returns true if serverAddr is reachable.
|
||||
func isIPv6RouteReady(serverAddr net.IP) waitForCondition {
|
||||
return func(l netlink.Link, logger *zerolog.Logger) (bool, error) {
|
||||
if serverAddr.IsMulticast() {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
routes, err := netlink.RouteList(l, 10) // AF_INET6
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
for _, route := range routes {
|
||||
if route.LinkIndex != l.Attrs().Index {
|
||||
continue
|
||||
}
|
||||
// Default route.
|
||||
if route.Dst == nil {
|
||||
return true, nil
|
||||
}
|
||||
if route.Dst.Contains(serverAddr) {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) requestLease6(ifname string) (*Lease, error) {
|
||||
l := c.l.With().Str("interface", ifname).Logger()
|
||||
|
||||
iface, err := netlink.LinkByName(ifname)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
clientPort := dhcpv6.DefaultClientPort
|
||||
if c.cfg.V6ClientPort != nil {
|
||||
clientPort = *c.cfg.V6ClientPort
|
||||
}
|
||||
|
||||
// For ipv6, we cannot bind to the port until Duplicate Address
|
||||
// Detection (DAD) is complete which is indicated by the link being no
|
||||
// longer marked as "tentative". This usually takes about a second.
|
||||
|
||||
// If the link is never going to be ready, don't wait forever.
|
||||
// (The user may not have configured a ctx with a timeout.)
|
||||
|
||||
linkUpTimeout := time.After(c.cfg.LinkUpTimeout)
|
||||
if err := c.waitFor(
|
||||
iface,
|
||||
linkUpTimeout,
|
||||
isIPv6LinkReady,
|
||||
ErrIPv6LinkTimeout,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// If user specified a non-multicast address, make sure it's routable before we start.
|
||||
if c.cfg.V6ServerAddr != nil {
|
||||
if err := c.waitFor(
|
||||
iface,
|
||||
linkUpTimeout,
|
||||
isIPv6RouteReady(c.cfg.V6ServerAddr.IP),
|
||||
ErrIPv6RouteTimeout,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
mods := []nclient6.ClientOpt{
|
||||
nclient6.WithTimeout(c.cfg.Timeout),
|
||||
nclient6.WithRetry(c.cfg.Retries),
|
||||
c.getDHCP6Logger(),
|
||||
}
|
||||
if c.cfg.V6ServerAddr != nil {
|
||||
mods = append(mods, nclient6.WithBroadcastAddr(c.cfg.V6ServerAddr))
|
||||
}
|
||||
|
||||
conn, err := nclient6.NewIPv6UDPConn(iface.Attrs().Name, clientPort)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
client, err := nclient6.NewWithConn(conn, iface.Attrs().HardwareAddr, mods...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
// Prepend modifiers with default options, so they can be overridden.
|
||||
reqmods := append(
|
||||
[]dhcpv6.Modifier{
|
||||
dhcpv6.WithNetboot,
|
||||
},
|
||||
c.cfg.Modifiers6...)
|
||||
|
||||
l.Info().Msg("attempting to get DHCPv6 lease")
|
||||
p, err := client.RapidSolicit(c.ctx, reqmods...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
l.Info().Msgf("DHCPv6 lease acquired: %s", p.Summary())
|
||||
return fromNclient6Lease(p, ifname), nil
|
||||
}
|
||||
|
|
@ -0,0 +1,313 @@
|
|||
package jetdhcpc
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/insomniacslk/dhcp/dhcpv4"
|
||||
"github.com/insomniacslk/dhcp/dhcpv4/nclient4"
|
||||
"github.com/insomniacslk/dhcp/dhcpv6"
|
||||
"github.com/jetkvm/kvm/internal/network/types"
|
||||
)
|
||||
|
||||
var (
|
||||
defaultLeaseTime = time.Duration(30 * time.Minute)
|
||||
defaultRenewalTime = time.Duration(15 * time.Minute)
|
||||
)
|
||||
|
||||
// Lease is a network configuration obtained by DHCP.
|
||||
type Lease struct {
|
||||
types.DHCPLease
|
||||
|
||||
p4 *nclient4.Lease
|
||||
p6 *dhcpv6.Message
|
||||
|
||||
isEmpty map[string]bool
|
||||
}
|
||||
|
||||
// ToDHCPLease converts a lease to a DHCP lease.
|
||||
func (l *Lease) ToDHCPLease() *types.DHCPLease {
|
||||
lease := &l.DHCPLease
|
||||
lease.DHCPClient = "jetdhcpc"
|
||||
return lease
|
||||
}
|
||||
|
||||
// fromNclient4Lease creates a lease from a nclient4.Lease.
|
||||
func fromNclient4Lease(l *nclient4.Lease, iface string) *Lease {
|
||||
lease := &Lease{}
|
||||
|
||||
lease.p4 = l
|
||||
|
||||
// only the fields that we need are set
|
||||
lease.Routers = l.ACK.Router()
|
||||
lease.IPAddress = l.ACK.YourIPAddr
|
||||
|
||||
lease.Netmask = net.IP(l.ACK.SubnetMask())
|
||||
lease.Broadcast = l.ACK.BroadcastAddress()
|
||||
|
||||
lease.NTPServers = l.ACK.NTPServers()
|
||||
|
||||
lease.HostName = l.ACK.HostName()
|
||||
lease.Domain = l.ACK.DomainName()
|
||||
|
||||
searchList := l.ACK.DomainSearch()
|
||||
if searchList != nil {
|
||||
lease.SearchList = searchList.Labels
|
||||
}
|
||||
|
||||
lease.DNS = l.ACK.DNS()
|
||||
|
||||
lease.ClassIdentifier = l.ACK.ClassIdentifier()
|
||||
lease.ServerID = l.ACK.ServerIdentifier().String()
|
||||
|
||||
mtu := l.ACK.Options.Get(dhcpv4.OptionInterfaceMTU)
|
||||
if mtu != nil {
|
||||
lease.MTU = int(binary.BigEndian.Uint16(mtu))
|
||||
}
|
||||
|
||||
lease.Message = l.ACK.Message()
|
||||
lease.LeaseTime = l.ACK.IPAddressLeaseTime(defaultLeaseTime)
|
||||
lease.RenewalTime = l.ACK.IPAddressRenewalTime(defaultRenewalTime)
|
||||
|
||||
lease.InterfaceName = iface
|
||||
|
||||
return lease
|
||||
}
|
||||
|
||||
// fromNclient6Lease creates a lease from a nclient6.Message.
|
||||
func fromNclient6Lease(l *dhcpv6.Message, iface string) *Lease {
|
||||
lease := &Lease{}
|
||||
|
||||
lease.p6 = l
|
||||
|
||||
iana := l.Options.OneIANA()
|
||||
if iana == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
address := iana.Options.OneAddress()
|
||||
if address == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
lease.IPAddress = address.IPv6Addr
|
||||
lease.Netmask = net.IP(net.CIDRMask(128, 128))
|
||||
lease.DNS = l.Options.DNS()
|
||||
// lease.LeaseTime = iana.Options.OnePreferredLifetime()
|
||||
// lease.RenewalTime = iana.Options.OneValidLifetime()
|
||||
// lease.RebindingTime = iana.Options.OneRebindingTime()
|
||||
|
||||
lease.InterfaceName = iface
|
||||
|
||||
return lease
|
||||
}
|
||||
|
||||
func (l *Lease) setIsEmpty(m map[string]bool) {
|
||||
l.isEmpty = m
|
||||
}
|
||||
|
||||
// IsEmpty returns true if the lease is empty for the given key.
|
||||
func (l *Lease) IsEmpty(key string) bool {
|
||||
return l.isEmpty[key]
|
||||
}
|
||||
|
||||
// ToJSON returns the lease as a JSON string.
|
||||
func (l *Lease) ToJSON() string {
|
||||
json, err := json.Marshal(l)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return string(json)
|
||||
}
|
||||
|
||||
// SetLeaseExpiry sets the lease expiry time.
|
||||
func (l *Lease) SetLeaseExpiry() (time.Time, error) {
|
||||
if l.Uptime == 0 || l.LeaseTime == 0 {
|
||||
return time.Time{}, fmt.Errorf("uptime or lease time isn't set")
|
||||
}
|
||||
|
||||
// get the uptime of the device
|
||||
file, err := os.Open("/proc/uptime")
|
||||
if err != nil {
|
||||
return time.Time{}, fmt.Errorf("failed to open uptime file: %w", err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
var uptime time.Duration
|
||||
|
||||
scanner := bufio.NewScanner(file)
|
||||
for scanner.Scan() {
|
||||
text := scanner.Text()
|
||||
parts := strings.Split(text, " ")
|
||||
uptime, err = time.ParseDuration(parts[0] + "s")
|
||||
|
||||
if err != nil {
|
||||
return time.Time{}, fmt.Errorf("failed to parse uptime: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
relativeLeaseRemaining := (l.Uptime + l.LeaseTime) - uptime
|
||||
leaseExpiry := time.Now().Add(relativeLeaseRemaining)
|
||||
|
||||
l.LeaseExpiry = &leaseExpiry
|
||||
|
||||
return leaseExpiry, nil
|
||||
}
|
||||
|
||||
func (l *Lease) Apply() error {
|
||||
if l.p4 != nil {
|
||||
return l.applyIPv4()
|
||||
}
|
||||
|
||||
if l.p6 != nil {
|
||||
return l.applyIPv6()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (l *Lease) applyIPv4() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (l *Lease) applyIPv6() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// UnmarshalDHCPCLease unmarshals a lease from a string.
|
||||
func UnmarshalDHCPCLease(lease *Lease, str string) error {
|
||||
// parse the lease file as a map
|
||||
data := make(map[string]string)
|
||||
for _, line := range strings.Split(str, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
// skip empty lines and comments
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
|
||||
parts := strings.SplitN(line, "=", 2)
|
||||
if len(parts) != 2 {
|
||||
continue
|
||||
}
|
||||
|
||||
key := strings.TrimSpace(parts[0])
|
||||
value := strings.TrimSpace(parts[1])
|
||||
|
||||
data[key] = value
|
||||
}
|
||||
|
||||
// now iterate over the lease struct and set the values
|
||||
leaseType := reflect.TypeOf(lease).Elem()
|
||||
leaseValue := reflect.ValueOf(lease).Elem()
|
||||
|
||||
valuesParsed := make(map[string]bool)
|
||||
|
||||
for i := 0; i < leaseType.NumField(); i++ {
|
||||
field := leaseValue.Field(i)
|
||||
|
||||
// get the env tag
|
||||
key := leaseType.Field(i).Tag.Get("env")
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
valuesParsed[key] = false
|
||||
|
||||
// get the value from the data map
|
||||
value, ok := data[key]
|
||||
if !ok || value == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
switch field.Interface().(type) {
|
||||
case string:
|
||||
field.SetString(value)
|
||||
case int:
|
||||
val, err := strconv.Atoi(value)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
field.SetInt(int64(val))
|
||||
case time.Duration:
|
||||
val, err := time.ParseDuration(value + "s")
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
field.Set(reflect.ValueOf(val))
|
||||
case net.IP:
|
||||
ip := net.ParseIP(value)
|
||||
if ip == nil {
|
||||
continue
|
||||
}
|
||||
field.Set(reflect.ValueOf(ip))
|
||||
case []net.IP:
|
||||
val := make([]net.IP, 0)
|
||||
for _, ipStr := range strings.Fields(value) {
|
||||
ip := net.ParseIP(ipStr)
|
||||
if ip == nil {
|
||||
continue
|
||||
}
|
||||
val = append(val, ip)
|
||||
}
|
||||
field.Set(reflect.ValueOf(val))
|
||||
default:
|
||||
return fmt.Errorf("unsupported field `%s` type: %s", key, field.Type().String())
|
||||
}
|
||||
|
||||
valuesParsed[key] = true
|
||||
}
|
||||
|
||||
lease.setIsEmpty(valuesParsed)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// MarshalDHCPCLease marshals a lease to a string.
|
||||
func MarshalDHCPCLease(lease *Lease) (string, error) {
|
||||
leaseType := reflect.TypeOf(lease).Elem()
|
||||
leaseValue := reflect.ValueOf(lease).Elem()
|
||||
|
||||
leaseFile := ""
|
||||
|
||||
for i := 0; i < leaseType.NumField(); i++ {
|
||||
field := leaseValue.Field(i)
|
||||
key := leaseType.Field(i).Tag.Get("env")
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
outValue := ""
|
||||
|
||||
switch field.Interface().(type) {
|
||||
case string:
|
||||
outValue = field.String()
|
||||
case int:
|
||||
outValue = strconv.Itoa(int(field.Int()))
|
||||
case time.Duration:
|
||||
outValue = strconv.Itoa(int(field.Int()))
|
||||
case net.IP:
|
||||
outValue = field.String()
|
||||
case []net.IP:
|
||||
ips := field.Interface().([]net.IP)
|
||||
ipStrings := make([]string, len(ips))
|
||||
for i, ip := range ips {
|
||||
ipStrings[i] = ip.String()
|
||||
}
|
||||
outValue = strings.Join(ipStrings, " ")
|
||||
default:
|
||||
return "", fmt.Errorf("unsupported field `%s` type: %s", key, field.Type().String())
|
||||
}
|
||||
|
||||
leaseFile += fmt.Sprintf("%s=%s\n", key, outValue)
|
||||
}
|
||||
|
||||
return leaseFile, nil
|
||||
}
|
||||
|
|
@ -0,0 +1,102 @@
|
|||
package jetdhcpc
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
|
||||
"github.com/rs/zerolog"
|
||||
)
|
||||
|
||||
func readFileNoStat(filename string) ([]byte, error) {
|
||||
const maxBufferSize = 1024 * 1024
|
||||
|
||||
f, err := os.Open(filename)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
reader := io.LimitReader(f, maxBufferSize)
|
||||
return io.ReadAll(reader)
|
||||
}
|
||||
|
||||
func toCmdline(path string) ([]string, error) {
|
||||
data, err := readFileNoStat(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(data) < 1 {
|
||||
return []string{}, nil
|
||||
}
|
||||
|
||||
return strings.Split(string(bytes.TrimRight(data, "\x00")), "\x00"), nil
|
||||
}
|
||||
|
||||
// KillUdhcpC kills all udhcpc processes
|
||||
func KillUdhcpC(l *zerolog.Logger) error {
|
||||
// read procfs for udhcpc processes
|
||||
// we do not use procfs.AllProcs() because we want to avoid the overhead of reading the entire procfs
|
||||
processes, err := os.ReadDir("/proc")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
matchedPids := make([]int, 0)
|
||||
|
||||
// iterate over the processes
|
||||
for _, d := range processes {
|
||||
// check if file is numeric
|
||||
pid, err := strconv.Atoi(d.Name())
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// check if it's a directory
|
||||
if !d.IsDir() {
|
||||
continue
|
||||
}
|
||||
|
||||
cmdline, err := toCmdline(filepath.Join("/proc", d.Name(), "cmdline"))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if len(cmdline) < 1 {
|
||||
continue
|
||||
}
|
||||
|
||||
if cmdline[0] != "udhcpc" {
|
||||
continue
|
||||
}
|
||||
|
||||
matchedPids = append(matchedPids, pid)
|
||||
}
|
||||
|
||||
if len(matchedPids) == 0 {
|
||||
l.Info().Msg("no udhcpc processes found")
|
||||
return nil
|
||||
}
|
||||
|
||||
l.Info().Ints("pids", matchedPids).Msg("found udhcpc processes, terminating")
|
||||
|
||||
for _, pid := range matchedPids {
|
||||
err := syscall.Kill(pid, syscall.SIGTERM)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
l.Info().Int("pid", pid).Msg("terminated udhcpc process")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) killUdhcpc() error {
|
||||
return KillUdhcpC(c.l)
|
||||
}
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
package jetdhcpc
|
||||
|
||||
import (
|
||||
"github.com/insomniacslk/dhcp/dhcpv4"
|
||||
"github.com/insomniacslk/dhcp/dhcpv4/nclient4"
|
||||
"github.com/insomniacslk/dhcp/dhcpv6/nclient6"
|
||||
"github.com/rs/zerolog"
|
||||
)
|
||||
|
||||
type dhcpLogger struct {
|
||||
// Printfer is used for actual output of the logger
|
||||
nclient4.Printfer
|
||||
|
||||
l *zerolog.Logger
|
||||
}
|
||||
|
||||
// Printf prints a log message as-is via predefined Printfer
|
||||
func (s dhcpLogger) Printf(format string, v ...interface{}) {
|
||||
s.l.Info().Msgf(format, v...)
|
||||
}
|
||||
|
||||
// PrintMessage prints a DHCP message in the short format via predefined Printfer
|
||||
func (s dhcpLogger) PrintMessage(prefix string, message *dhcpv4.DHCPv4) {
|
||||
s.l.Info().Msgf("%s: %s", prefix, message.String())
|
||||
}
|
||||
|
||||
func summaryStructured(d *dhcpv4.DHCPv4, l *zerolog.Logger) *zerolog.Logger {
|
||||
logger := l.With().
|
||||
Str("opCode", d.OpCode.String()).
|
||||
Str("hwType", d.HWType.String()).
|
||||
Int("hopCount", int(d.HopCount)).
|
||||
Str("transactionID", d.TransactionID.String()).
|
||||
Int("numSeconds", int(d.NumSeconds)).
|
||||
Str("flagsString", d.FlagsToString()).
|
||||
Int("flags", int(d.Flags)).
|
||||
Str("clientIP", d.ClientIPAddr.String()).
|
||||
Str("yourIP", d.YourIPAddr.String()).
|
||||
Str("serverIP", d.ServerIPAddr.String()).
|
||||
Str("gatewayIP", d.GatewayIPAddr.String()).
|
||||
Str("clientMAC", d.ClientHWAddr.String()).
|
||||
Str("serverHostname", d.ServerHostName).
|
||||
Str("bootFileName", d.BootFileName).
|
||||
Str("options", d.Options.Summary(nil)).
|
||||
Logger()
|
||||
return &logger
|
||||
}
|
||||
|
||||
func (c *Client) getDHCP4Logger(ifname string) nclient4.ClientOpt {
|
||||
logger := c.l.With().
|
||||
Str("interface", ifname).
|
||||
Str("source", "dhcp4").
|
||||
Logger()
|
||||
|
||||
return nclient4.WithLogger(dhcpLogger{
|
||||
l: &logger,
|
||||
})
|
||||
}
|
||||
|
||||
// TODO: nclient6 doesn't implement the WithLogger option,
|
||||
// we might need to open a PR to add it
|
||||
|
||||
func (c *Client) getDHCP6Logger() nclient6.ClientOpt {
|
||||
return nclient6.WithSummaryLogger()
|
||||
}
|
||||
|
|
@ -0,0 +1,247 @@
|
|||
package jetdhcpc
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/jetkvm/kvm/internal/network/types"
|
||||
)
|
||||
|
||||
const (
|
||||
// DefaultStateDir is the default state directory
|
||||
DefaultStateDir = "/var/run/"
|
||||
// DHCPStateFile is the name of the DHCP state file
|
||||
DHCPStateFile = "jetkvm_dhcp_state.json"
|
||||
)
|
||||
|
||||
// DHCPState represents the persistent state of DHCP clients
|
||||
type DHCPState struct {
|
||||
InterfaceStates map[string]*InterfaceDHCPState `json:"interface_states"`
|
||||
LastUpdated time.Time `json:"last_updated"`
|
||||
Version string `json:"version"`
|
||||
}
|
||||
|
||||
// InterfaceDHCPState represents the DHCP state for a specific interface
|
||||
type InterfaceDHCPState struct {
|
||||
InterfaceName string `json:"interface_name"`
|
||||
IPv4Enabled bool `json:"ipv4_enabled"`
|
||||
IPv6Enabled bool `json:"ipv6_enabled"`
|
||||
IPv4Lease *Lease `json:"ipv4_lease,omitempty"`
|
||||
IPv6Lease *Lease `json:"ipv6_lease,omitempty"`
|
||||
LastRenewal time.Time `json:"last_renewal"`
|
||||
Config *types.NetworkConfig `json:"config,omitempty"`
|
||||
}
|
||||
|
||||
// SaveState saves the current DHCP state to disk
|
||||
func (c *Client) SaveState(state *DHCPState) error {
|
||||
if state == nil {
|
||||
return fmt.Errorf("state cannot be nil")
|
||||
}
|
||||
|
||||
// Return error if state directory doesn't exist
|
||||
if _, err := os.Stat(c.stateDir); os.IsNotExist(err) {
|
||||
return fmt.Errorf("state directory does not exist: %w", err)
|
||||
}
|
||||
|
||||
// Update timestamp
|
||||
state.LastUpdated = time.Now()
|
||||
state.Version = "1.0"
|
||||
|
||||
// Serialize state
|
||||
data, err := json.MarshalIndent(state, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal state: %w", err)
|
||||
}
|
||||
|
||||
// Write to temporary file first, then rename to ensure atomic operation
|
||||
tmpFile, err := os.CreateTemp(c.stateDir, DHCPStateFile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create temporary file: %w", err)
|
||||
}
|
||||
defer tmpFile.Close()
|
||||
|
||||
if err := os.WriteFile(tmpFile.Name(), data, 0644); err != nil {
|
||||
return fmt.Errorf("failed to write state file: %w", err)
|
||||
}
|
||||
|
||||
stateFile := filepath.Join(c.stateDir, DHCPStateFile)
|
||||
if err := os.Rename(tmpFile.Name(), stateFile); err != nil {
|
||||
os.Remove(tmpFile.Name())
|
||||
return fmt.Errorf("failed to rename state file: %w", err)
|
||||
}
|
||||
|
||||
c.l.Debug().Str("file", stateFile).Msg("DHCP state saved")
|
||||
return nil
|
||||
}
|
||||
|
||||
// LoadState loads the DHCP state from disk
|
||||
func (c *Client) LoadState() (*DHCPState, error) {
|
||||
stateFile := filepath.Join(c.stateDir, DHCPStateFile)
|
||||
|
||||
// Check if state file exists
|
||||
if _, err := os.Stat(stateFile); os.IsNotExist(err) {
|
||||
c.l.Debug().Msg("No existing DHCP state file found")
|
||||
return &DHCPState{
|
||||
InterfaceStates: make(map[string]*InterfaceDHCPState),
|
||||
LastUpdated: time.Now(),
|
||||
Version: "1.0",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Read state file
|
||||
data, err := os.ReadFile(stateFile)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read state file: %w", err)
|
||||
}
|
||||
|
||||
// Deserialize state
|
||||
var state DHCPState
|
||||
if err := json.Unmarshal(data, &state); err != nil {
|
||||
return nil, fmt.Errorf("failed to unmarshal state: %w", err)
|
||||
}
|
||||
|
||||
// Initialize interface states map if nil
|
||||
if state.InterfaceStates == nil {
|
||||
state.InterfaceStates = make(map[string]*InterfaceDHCPState)
|
||||
}
|
||||
|
||||
c.l.Debug().Str("file", stateFile).Msg("DHCP state loaded")
|
||||
return &state, nil
|
||||
}
|
||||
|
||||
// UpdateInterfaceState updates the state for a specific interface
|
||||
func (c *Client) UpdateInterfaceState(ifaceName string, state *InterfaceDHCPState) error {
|
||||
// Load current state
|
||||
currentState, err := c.LoadState()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to load current state: %w", err)
|
||||
}
|
||||
|
||||
// Update interface state
|
||||
currentState.InterfaceStates[ifaceName] = state
|
||||
|
||||
// Save updated state
|
||||
return c.SaveState(currentState)
|
||||
}
|
||||
|
||||
// GetInterfaceState gets the state for a specific interface
|
||||
func (c *Client) GetInterfaceState(ifaceName string) (*InterfaceDHCPState, error) {
|
||||
state, err := c.LoadState()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to load state: %w", err)
|
||||
}
|
||||
|
||||
return state.InterfaceStates[ifaceName], nil
|
||||
}
|
||||
|
||||
// RemoveInterfaceState removes the state for a specific interface
|
||||
func (c *Client) RemoveInterfaceState(ifaceName string) error {
|
||||
// Load current state
|
||||
currentState, err := c.LoadState()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to load current state: %w", err)
|
||||
}
|
||||
|
||||
// Remove interface state
|
||||
delete(currentState.InterfaceStates, ifaceName)
|
||||
|
||||
// Save updated state
|
||||
return c.SaveState(currentState)
|
||||
}
|
||||
|
||||
// IsLeaseValid checks if a DHCP lease is still valid
|
||||
func (c *Client) IsLeaseValid(lease *Lease) bool {
|
||||
if lease == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check if lease has expired
|
||||
if lease.LeaseExpiry == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
return time.Now().Before(*lease.LeaseExpiry)
|
||||
}
|
||||
|
||||
// ShouldRenewLease checks if a lease should be renewed
|
||||
func (c *Client) ShouldRenewLease(lease *Lease) bool {
|
||||
if !c.IsLeaseValid(lease) {
|
||||
return false
|
||||
}
|
||||
|
||||
expiry := *lease.LeaseExpiry
|
||||
leaseTime := time.Now().Add(time.Duration(lease.LeaseTime) * time.Second)
|
||||
|
||||
// Renew if lease expires within 50% of its lifetime
|
||||
leaseDuration := expiry.Sub(leaseTime)
|
||||
renewalTime := leaseTime.Add(leaseDuration / 2)
|
||||
|
||||
return time.Now().After(renewalTime)
|
||||
}
|
||||
|
||||
// CleanupExpiredStates removes expired states from the state file
|
||||
func (c *Client) CleanupExpiredStates() error {
|
||||
state, err := c.LoadState()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to load state: %w", err)
|
||||
}
|
||||
|
||||
cleaned := false
|
||||
for ifaceName, ifaceState := range state.InterfaceStates {
|
||||
// Remove interface state if both leases are expired
|
||||
ipv4Valid := c.IsLeaseValid(ifaceState.IPv4Lease)
|
||||
ipv6Valid := c.IsLeaseValid(ifaceState.IPv6Lease)
|
||||
|
||||
if !ipv4Valid && !ipv6Valid {
|
||||
delete(state.InterfaceStates, ifaceName)
|
||||
cleaned = true
|
||||
c.l.Debug().Str("interface", ifaceName).Msg("Removed expired DHCP state")
|
||||
}
|
||||
}
|
||||
|
||||
if cleaned {
|
||||
return c.SaveState(state)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetStateSummary returns a summary of the current state
|
||||
func (c *Client) GetStateSummary() (map[string]interface{}, error) {
|
||||
state, err := c.LoadState()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to load state: %w", err)
|
||||
}
|
||||
|
||||
summary := map[string]interface{}{
|
||||
"last_updated": state.LastUpdated,
|
||||
"version": state.Version,
|
||||
"interface_count": len(state.InterfaceStates),
|
||||
"interfaces": make(map[string]interface{}),
|
||||
}
|
||||
|
||||
interfaces := summary["interfaces"].(map[string]interface{})
|
||||
for ifaceName, ifaceState := range state.InterfaceStates {
|
||||
interfaceInfo := map[string]interface{}{
|
||||
"ipv4_enabled": ifaceState.IPv4Enabled,
|
||||
"ipv6_enabled": ifaceState.IPv6Enabled,
|
||||
"last_renewal": ifaceState.LastRenewal,
|
||||
// "ipv4_lease_valid": c.IsLeaseValid(ifaceState.IPv4Lease.(*Lease)),
|
||||
// "ipv6_lease_valid": c.IsLeaseValid(ifaceState.IPv6Lease),
|
||||
}
|
||||
|
||||
if ifaceState.IPv4Lease != nil {
|
||||
interfaceInfo["ipv4_lease_expiry"] = ifaceState.IPv4Lease.LeaseExpiry
|
||||
}
|
||||
if ifaceState.IPv6Lease != nil {
|
||||
interfaceInfo["ipv6_lease_expiry"] = ifaceState.IPv6Lease.LeaseExpiry
|
||||
}
|
||||
|
||||
interfaces[ifaceName] = interfaceInfo
|
||||
}
|
||||
|
||||
return summary, nil
|
||||
}
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
package jetdhcpc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/vishvananda/netlink"
|
||||
)
|
||||
|
||||
type waitForCondition func(l netlink.Link, logger *zerolog.Logger) (ready bool, err error)
|
||||
|
||||
func (c *Client) waitFor(
|
||||
link netlink.Link,
|
||||
timeout <-chan time.Time,
|
||||
condition waitForCondition,
|
||||
timeoutError error,
|
||||
) error {
|
||||
return waitFor(c.ctx, link, c.l, timeout, condition, timeoutError)
|
||||
}
|
||||
|
||||
func waitFor(
|
||||
ctx context.Context,
|
||||
link netlink.Link,
|
||||
logger *zerolog.Logger,
|
||||
timeout <-chan time.Time,
|
||||
condition waitForCondition,
|
||||
timeoutError error,
|
||||
) error {
|
||||
for {
|
||||
if ready, err := condition(link, logger); err != nil {
|
||||
return err
|
||||
} else if ready {
|
||||
break
|
||||
}
|
||||
|
||||
select {
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
continue
|
||||
case <-timeout:
|
||||
return timeoutError
|
||||
case <-ctx.Done():
|
||||
return timeoutError
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
package link
|
||||
|
||||
const (
|
||||
// AfUnspec is the unspecified address family constant
|
||||
AfUnspec = 0
|
||||
// AfInet is the IPv4 address family constant
|
||||
AfInet = 2
|
||||
// AfInet6 is the IPv6 address family constant
|
||||
AfInet6 = 10
|
||||
|
||||
sysctlBase = "/proc/sys"
|
||||
sysctlFileMode = 0640
|
||||
)
|
||||
|
|
@ -0,0 +1,544 @@
|
|||
package link
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"time"
|
||||
|
||||
"github.com/jetkvm/kvm/internal/sync"
|
||||
|
||||
"github.com/jetkvm/kvm/internal/network/types"
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/vishvananda/netlink"
|
||||
)
|
||||
|
||||
// StateChangeHandler is the function type for link state callbacks
|
||||
type StateChangeHandler func(link *Link)
|
||||
|
||||
// StateChangeCallback is the struct for link state callbacks
|
||||
type StateChangeCallback struct {
|
||||
Async bool
|
||||
Func StateChangeHandler
|
||||
}
|
||||
|
||||
// NetlinkManager provides centralized netlink operations
|
||||
type NetlinkManager struct {
|
||||
logger *zerolog.Logger
|
||||
mu sync.RWMutex
|
||||
stateChangeCallbacks map[string][]StateChangeCallback
|
||||
}
|
||||
|
||||
func newNetlinkManager(logger *zerolog.Logger) *NetlinkManager {
|
||||
if logger == nil {
|
||||
logger = &zerolog.Logger{} // Default no-op logger
|
||||
}
|
||||
n := &NetlinkManager{
|
||||
logger: logger,
|
||||
stateChangeCallbacks: make(map[string][]StateChangeCallback),
|
||||
}
|
||||
n.monitorStateChange()
|
||||
return n
|
||||
}
|
||||
|
||||
// GetNetlinkManager returns the singleton NetlinkManager instance
|
||||
func GetNetlinkManager() *NetlinkManager {
|
||||
netlinkManagerOnce.Do(func() {
|
||||
netlinkManagerInstance = newNetlinkManager(nil)
|
||||
})
|
||||
return netlinkManagerInstance
|
||||
}
|
||||
|
||||
// InitializeNetlinkManager initializes the singleton NetlinkManager with a logger
|
||||
func InitializeNetlinkManager(logger *zerolog.Logger) *NetlinkManager {
|
||||
netlinkManagerOnce.Do(func() {
|
||||
netlinkManagerInstance = newNetlinkManager(logger)
|
||||
})
|
||||
return netlinkManagerInstance
|
||||
}
|
||||
|
||||
// AddStateChangeCallback adds a callback for link state changes
|
||||
func (nm *NetlinkManager) AddStateChangeCallback(ifname string, callback StateChangeCallback) {
|
||||
nm.mu.Lock()
|
||||
defer nm.mu.Unlock()
|
||||
|
||||
if _, ok := nm.stateChangeCallbacks[ifname]; !ok {
|
||||
nm.stateChangeCallbacks[ifname] = make([]StateChangeCallback, 0)
|
||||
}
|
||||
|
||||
nm.stateChangeCallbacks[ifname] = append(nm.stateChangeCallbacks[ifname], callback)
|
||||
}
|
||||
|
||||
// Interface operations
|
||||
func (nm *NetlinkManager) monitorStateChange() {
|
||||
updateCh := make(chan netlink.LinkUpdate)
|
||||
// we don't need to stop the subscription, as it will be closed when the program exits
|
||||
stopCh := make(chan struct{}) //nolint:unused
|
||||
if err := netlink.LinkSubscribe(updateCh, stopCh); err != nil {
|
||||
nm.logger.Error().Err(err).Msg("failed to subscribe to link state changes")
|
||||
}
|
||||
|
||||
nm.logger.Info().Msg("state change monitoring started")
|
||||
|
||||
go func() {
|
||||
for update := range updateCh {
|
||||
nm.runCallbacks(update)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (nm *NetlinkManager) runCallbacks(update netlink.LinkUpdate) {
|
||||
nm.mu.RLock()
|
||||
defer nm.mu.RUnlock()
|
||||
|
||||
ifname := update.Link.Attrs().Name
|
||||
callbacks, ok := nm.stateChangeCallbacks[ifname]
|
||||
|
||||
l := nm.logger.With().Str("interface", ifname).Logger()
|
||||
if !ok {
|
||||
l.Trace().Msg("no state change callbacks for interface")
|
||||
return
|
||||
}
|
||||
|
||||
for _, callback := range callbacks {
|
||||
l.Trace().
|
||||
Interface("callback", callback).
|
||||
Bool("async", callback.Async).
|
||||
Msg("calling callback")
|
||||
|
||||
if callback.Async {
|
||||
go callback.Func(&Link{Link: update.Link})
|
||||
} else {
|
||||
callback.Func(&Link{Link: update.Link})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// GetLinkByName gets a network link by name
|
||||
func (nm *NetlinkManager) GetLinkByName(name string) (*Link, error) {
|
||||
nm.mu.RLock()
|
||||
defer nm.mu.RUnlock()
|
||||
link, err := netlink.LinkByName(name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Link{Link: link}, nil
|
||||
}
|
||||
|
||||
// LinkSetUp brings a network interface up
|
||||
func (nm *NetlinkManager) LinkSetUp(link *Link) error {
|
||||
nm.mu.RLock()
|
||||
defer nm.mu.RUnlock()
|
||||
return netlink.LinkSetUp(link)
|
||||
}
|
||||
|
||||
// LinkSetDown brings a network interface down
|
||||
func (nm *NetlinkManager) LinkSetDown(link *Link) error {
|
||||
nm.mu.RLock()
|
||||
defer nm.mu.RUnlock()
|
||||
return netlink.LinkSetDown(link)
|
||||
}
|
||||
|
||||
// EnsureInterfaceUp ensures the interface is up
|
||||
func (nm *NetlinkManager) EnsureInterfaceUp(link *Link) error {
|
||||
if link.Attrs().OperState == netlink.OperUp {
|
||||
return nil
|
||||
}
|
||||
return nm.LinkSetUp(link)
|
||||
}
|
||||
|
||||
// EnsureInterfaceUpWithTimeout ensures the interface is up with timeout and retry logic
|
||||
func (nm *NetlinkManager) EnsureInterfaceUpWithTimeout(ctx context.Context, iface *Link, timeout time.Duration) (*Link, error) {
|
||||
ifname := iface.Attrs().Name
|
||||
|
||||
l := nm.logger.With().Str("interface", ifname).Logger()
|
||||
|
||||
linkUpTimeout := time.After(timeout)
|
||||
|
||||
var attempt int
|
||||
start := time.Now()
|
||||
|
||||
for {
|
||||
link, err := nm.GetLinkByName(ifname)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
state := link.Attrs().OperState
|
||||
|
||||
l = l.With().
|
||||
Int("attempt", attempt).
|
||||
Dur("duration", time.Since(start)).
|
||||
Str("state", state.String()).
|
||||
Logger()
|
||||
if state == netlink.OperUp || state == netlink.OperUnknown {
|
||||
if attempt > 0 {
|
||||
l.Info().Int("attempt", attempt-1).Msg("interface is up")
|
||||
}
|
||||
return link, nil
|
||||
}
|
||||
|
||||
l.Info().Msg("bringing up interface")
|
||||
|
||||
// bring up the interface
|
||||
if err = nm.LinkSetUp(link); err != nil {
|
||||
l.Error().Err(err).Msg("interface can't make it up")
|
||||
}
|
||||
|
||||
// refresh the link attributes
|
||||
if err = link.Refresh(); err != nil {
|
||||
l.Error().Err(err).Msg("failed to refresh link attributes")
|
||||
}
|
||||
|
||||
// check the state again
|
||||
state = link.Attrs().OperState
|
||||
l = l.With().Str("new_state", state.String()).Logger()
|
||||
if state == netlink.OperUp {
|
||||
l.Info().Msg("interface is up")
|
||||
return link, nil
|
||||
}
|
||||
l.Warn().Msg("interface is still down, retrying")
|
||||
|
||||
select {
|
||||
case <-time.After(500 * time.Millisecond):
|
||||
attempt++
|
||||
continue
|
||||
case <-ctx.Done():
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return nil, ErrInterfaceUpCanceled
|
||||
case <-linkUpTimeout:
|
||||
attempt++
|
||||
l.Error().
|
||||
Int("attempt", attempt).Msg("interface is still down after timeout")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return nil, ErrInterfaceUpTimeout
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Address operations
|
||||
|
||||
// AddrList gets all addresses for a link
|
||||
func (nm *NetlinkManager) AddrList(link *Link, family int) ([]netlink.Addr, error) {
|
||||
nm.mu.RLock()
|
||||
defer nm.mu.RUnlock()
|
||||
return netlink.AddrList(link, family)
|
||||
}
|
||||
|
||||
// AddrAdd adds an address to a link
|
||||
func (nm *NetlinkManager) AddrAdd(link *Link, addr *netlink.Addr) error {
|
||||
nm.mu.RLock()
|
||||
defer nm.mu.RUnlock()
|
||||
return netlink.AddrAdd(link, addr)
|
||||
}
|
||||
|
||||
// AddrDel removes an address from a link
|
||||
func (nm *NetlinkManager) AddrDel(link *Link, addr *netlink.Addr) error {
|
||||
nm.mu.RLock()
|
||||
defer nm.mu.RUnlock()
|
||||
return netlink.AddrDel(link, addr)
|
||||
}
|
||||
|
||||
// RemoveAllAddresses removes all addresses of a specific family from a link
|
||||
func (nm *NetlinkManager) RemoveAllAddresses(link *Link, family int) error {
|
||||
addrs, err := nm.AddrList(link, family)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get addresses: %w", err)
|
||||
}
|
||||
|
||||
for _, addr := range addrs {
|
||||
if err := nm.AddrDel(link, &addr); err != nil {
|
||||
nm.logger.Warn().Err(err).Str("address", addr.IP.String()).Msg("failed to remove address")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// RemoveNonLinkLocalIPv6Addresses removes all non-link-local IPv6 addresses
|
||||
func (nm *NetlinkManager) RemoveNonLinkLocalIPv6Addresses(link *Link) error {
|
||||
addrs, err := nm.AddrList(link, AfInet6)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get IPv6 addresses: %w", err)
|
||||
}
|
||||
|
||||
for _, addr := range addrs {
|
||||
if !addr.IP.IsLinkLocalUnicast() {
|
||||
if err := nm.AddrDel(link, &addr); err != nil {
|
||||
nm.logger.Warn().Err(err).Str("address", addr.IP.String()).Msg("failed to remove IPv6 address")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// RouteList gets all routes
|
||||
func (nm *NetlinkManager) RouteList(link *Link, family int) ([]netlink.Route, error) {
|
||||
nm.mu.RLock()
|
||||
defer nm.mu.RUnlock()
|
||||
return netlink.RouteList(link, family)
|
||||
}
|
||||
|
||||
// RouteAdd adds a route
|
||||
func (nm *NetlinkManager) RouteAdd(route *netlink.Route) error {
|
||||
nm.mu.RLock()
|
||||
defer nm.mu.RUnlock()
|
||||
return netlink.RouteAdd(route)
|
||||
}
|
||||
|
||||
// RouteDel removes a route
|
||||
func (nm *NetlinkManager) RouteDel(route *netlink.Route) error {
|
||||
nm.mu.RLock()
|
||||
defer nm.mu.RUnlock()
|
||||
return netlink.RouteDel(route)
|
||||
}
|
||||
|
||||
// RouteReplace replaces a route
|
||||
func (nm *NetlinkManager) RouteReplace(route *netlink.Route) error {
|
||||
nm.mu.RLock()
|
||||
defer nm.mu.RUnlock()
|
||||
return netlink.RouteReplace(route)
|
||||
}
|
||||
|
||||
// ListDefaultRoutes lists the default routes for the given family
|
||||
func (nm *NetlinkManager) ListDefaultRoutes(family int) ([]netlink.Route, error) {
|
||||
routes, err := netlink.RouteListFiltered(
|
||||
family,
|
||||
&netlink.Route{Dst: nil, Table: 254},
|
||||
netlink.RT_FILTER_DST|netlink.RT_FILTER_TABLE,
|
||||
)
|
||||
if err != nil {
|
||||
nm.logger.Error().Err(err).Int("family", family).Msg("failed to list default routes")
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return routes, nil
|
||||
}
|
||||
|
||||
// HasDefaultRoute checks if a default route exists for the given family
|
||||
func (nm *NetlinkManager) HasDefaultRoute(family int) bool {
|
||||
routes, err := nm.ListDefaultRoutes(family)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return len(routes) > 0
|
||||
}
|
||||
|
||||
// AddDefaultRoute adds a default route
|
||||
func (nm *NetlinkManager) AddDefaultRoute(link *Link, gateway net.IP, family int) error {
|
||||
var dst *net.IPNet
|
||||
switch family {
|
||||
case AfInet:
|
||||
dst = &ipv4DefaultRoute
|
||||
case AfInet6:
|
||||
dst = &ipv6DefaultRoute
|
||||
default:
|
||||
return fmt.Errorf("unsupported address family: %d", family)
|
||||
}
|
||||
|
||||
route := &netlink.Route{
|
||||
Dst: dst,
|
||||
Gw: gateway,
|
||||
LinkIndex: link.Attrs().Index,
|
||||
}
|
||||
|
||||
return nm.RouteReplace(route)
|
||||
}
|
||||
|
||||
// RemoveDefaultRoute removes the default route for the given family
|
||||
func (nm *NetlinkManager) RemoveDefaultRoute(family int) error {
|
||||
routes, err := nm.RouteList(nil, family)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get routes: %w", err)
|
||||
}
|
||||
|
||||
for _, route := range routes {
|
||||
if route.Dst != nil {
|
||||
if family == AfInet && route.Dst.IP.Equal(net.IPv4zero) && route.Dst.Mask.String() == "0.0.0.0/0" {
|
||||
if err := nm.RouteDel(&route); err != nil {
|
||||
nm.logger.Warn().Err(err).Msg("failed to remove IPv4 default route")
|
||||
}
|
||||
}
|
||||
if family == AfInet6 && route.Dst.IP.Equal(net.IPv6zero) && route.Dst.Mask.String() == "::/0" {
|
||||
if err := nm.RouteDel(&route); err != nil {
|
||||
nm.logger.Warn().Err(err).Msg("failed to remove IPv6 default route")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (nm *NetlinkManager) reconcileDefaultRoute(link *Link, expected map[string]net.IP, family int) error {
|
||||
linkIndex := link.Attrs().Index
|
||||
|
||||
added := 0
|
||||
toRemove := make([]*netlink.Route, 0)
|
||||
|
||||
defaultRoutes, err := nm.ListDefaultRoutes(family)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get default routes: %w", err)
|
||||
}
|
||||
|
||||
// check existing default routes
|
||||
for _, defaultRoute := range defaultRoutes {
|
||||
// only check the default routes for the current link
|
||||
// TODO: we should also check others later
|
||||
if defaultRoute.LinkIndex != linkIndex {
|
||||
continue
|
||||
}
|
||||
|
||||
key := defaultRoute.Gw.String()
|
||||
if _, ok := expected[key]; !ok {
|
||||
toRemove = append(toRemove, &defaultRoute)
|
||||
continue
|
||||
}
|
||||
|
||||
nm.logger.Warn().Str("gateway", key).Msg("keeping default route")
|
||||
delete(expected, key)
|
||||
}
|
||||
|
||||
// remove remaining default routes
|
||||
for _, defaultRoute := range toRemove {
|
||||
nm.logger.Warn().Str("gateway", defaultRoute.Gw.String()).Msg("removing default route")
|
||||
if err := nm.RouteDel(defaultRoute); err != nil {
|
||||
nm.logger.Warn().Err(err).Msg("failed to remove default route")
|
||||
}
|
||||
}
|
||||
|
||||
// add remaining expected default routes
|
||||
for _, gateway := range expected {
|
||||
nm.logger.Warn().Str("gateway", gateway.String()).Msg("adding default route")
|
||||
|
||||
route := &netlink.Route{
|
||||
Dst: &ipv4DefaultRoute,
|
||||
Gw: gateway,
|
||||
LinkIndex: linkIndex,
|
||||
}
|
||||
if family == AfInet6 {
|
||||
route.Dst = &ipv6DefaultRoute
|
||||
}
|
||||
if err := nm.RouteAdd(route); err != nil {
|
||||
nm.logger.Warn().Err(err).Interface("route", route).Msg("failed to add default route")
|
||||
}
|
||||
added++
|
||||
}
|
||||
|
||||
nm.logger.Info().
|
||||
Int("added", added).
|
||||
Int("removed", len(toRemove)).
|
||||
Msg("default routes reconciled")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ReconcileLink reconciles the addresses and routes of a link
|
||||
func (nm *NetlinkManager) ReconcileLink(link *Link, expected []types.IPAddress, family int) error {
|
||||
toAdd := make([]*types.IPAddress, 0)
|
||||
toRemove := make([]*netlink.Addr, 0)
|
||||
toUpdate := make([]*types.IPAddress, 0)
|
||||
expectedAddrs := make(map[string]*types.IPAddress)
|
||||
|
||||
expectedGateways := make(map[string]net.IP)
|
||||
|
||||
mtu := link.Attrs().MTU
|
||||
expectedMTU := mtu
|
||||
// add all expected addresses to the map
|
||||
for _, addr := range expected {
|
||||
expectedAddrs[addr.String()] = &addr
|
||||
if addr.Gateway != nil {
|
||||
expectedGateways[addr.String()] = addr.Gateway
|
||||
}
|
||||
if addr.MTU != 0 {
|
||||
mtu = addr.MTU
|
||||
}
|
||||
}
|
||||
if expectedMTU != mtu {
|
||||
if err := link.SetMTU(expectedMTU); err != nil {
|
||||
nm.logger.Warn().Err(err).Int("expected_mtu", expectedMTU).Int("mtu", mtu).Msg("failed to set MTU")
|
||||
}
|
||||
}
|
||||
|
||||
addrs, err := nm.AddrList(link, family)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get addresses: %w", err)
|
||||
}
|
||||
|
||||
// check existing addresses
|
||||
for _, addr := range addrs {
|
||||
// skip the link-local address
|
||||
if addr.IP.IsLinkLocalUnicast() {
|
||||
continue
|
||||
}
|
||||
|
||||
expectedAddr, ok := expectedAddrs[addr.IPNet.String()]
|
||||
if !ok {
|
||||
toRemove = append(toRemove, &addr)
|
||||
continue
|
||||
}
|
||||
|
||||
// if it's not fully equal, we need to update it
|
||||
if !expectedAddr.Compare(addr) {
|
||||
toUpdate = append(toUpdate, expectedAddr)
|
||||
continue
|
||||
}
|
||||
|
||||
// remove it from expected addresses
|
||||
delete(expectedAddrs, addr.IPNet.String())
|
||||
}
|
||||
|
||||
// add remaining expected addresses
|
||||
for _, addr := range expectedAddrs {
|
||||
toAdd = append(toAdd, addr)
|
||||
}
|
||||
|
||||
for _, addr := range toUpdate {
|
||||
netlinkAddr := addr.NetlinkAddr()
|
||||
if err := nm.AddrDel(link, &netlinkAddr); err != nil {
|
||||
nm.logger.Warn().Err(err).Str("address", addr.Address.String()).Msg("failed to update address")
|
||||
}
|
||||
// we'll add it again later
|
||||
toAdd = append(toAdd, addr)
|
||||
}
|
||||
|
||||
for _, addr := range toAdd {
|
||||
netlinkAddr := addr.NetlinkAddr()
|
||||
if err := nm.AddrAdd(link, &netlinkAddr); err != nil {
|
||||
nm.logger.Warn().Err(err).Str("address", addr.Address.String()).Msg("failed to add address")
|
||||
}
|
||||
}
|
||||
|
||||
for _, netlinkAddr := range toRemove {
|
||||
if err := nm.AddrDel(link, netlinkAddr); err != nil {
|
||||
nm.logger.Warn().Err(err).Str("address", netlinkAddr.IP.String()).Msg("failed to remove address")
|
||||
}
|
||||
}
|
||||
|
||||
for _, addr := range toAdd {
|
||||
netlinkAddr := addr.NetlinkAddr()
|
||||
if err := nm.AddrAdd(link, &netlinkAddr); err != nil {
|
||||
nm.logger.Warn().Err(err).Str("address", addr.Address.String()).Msg("failed to add address")
|
||||
}
|
||||
}
|
||||
|
||||
actualToAdd := len(toAdd) - len(toUpdate)
|
||||
if len(toAdd) > 0 || len(toUpdate) > 0 || len(toRemove) > 0 {
|
||||
nm.logger.Info().
|
||||
Int("added", actualToAdd).
|
||||
Int("updated", len(toUpdate)).
|
||||
Int("removed", len(toRemove)).
|
||||
Msg("addresses reconciled")
|
||||
}
|
||||
|
||||
if err := nm.reconcileDefaultRoute(link, expectedGateways, family); err != nil {
|
||||
nm.logger.Warn().Err(err).Msg("failed to reconcile default route")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
@ -0,0 +1,164 @@
|
|||
// Package link provides a wrapper around netlink.Link and provides a singleton netlink manager.
|
||||
package link
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
|
||||
"github.com/jetkvm/kvm/internal/sync"
|
||||
|
||||
"github.com/vishvananda/netlink"
|
||||
)
|
||||
|
||||
var (
|
||||
ipv4DefaultRoute = net.IPNet{
|
||||
IP: net.IPv4zero,
|
||||
Mask: net.CIDRMask(0, 0),
|
||||
}
|
||||
|
||||
ipv6DefaultRoute = net.IPNet{
|
||||
IP: net.IPv6zero,
|
||||
Mask: net.CIDRMask(0, 0),
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
netlinkManagerInstance *NetlinkManager
|
||||
netlinkManagerOnce sync.Once
|
||||
|
||||
// ErrInterfaceUpTimeout is the error returned when the interface does not come up within the timeout
|
||||
ErrInterfaceUpTimeout = errors.New("timeout after waiting for an interface to come up")
|
||||
// ErrInterfaceUpCanceled is the error returned when the interface does not come up due to context cancellation
|
||||
ErrInterfaceUpCanceled = errors.New("context canceled while waiting for an interface to come up")
|
||||
)
|
||||
|
||||
// Link is a wrapper around netlink.Link
|
||||
type Link struct {
|
||||
netlink.Link
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
// All lock actions should be done in external functions
|
||||
// and the internal functions should not be called directly
|
||||
|
||||
func (l *Link) refresh() error {
|
||||
linkName := l.ifName()
|
||||
link, err := netlink.LinkByName(linkName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if link == nil {
|
||||
return fmt.Errorf("link not found: %s", linkName)
|
||||
}
|
||||
l.Link = link
|
||||
return nil
|
||||
}
|
||||
|
||||
func (l *Link) attrs() *netlink.LinkAttrs {
|
||||
return l.Link.Attrs()
|
||||
}
|
||||
|
||||
func (l *Link) ifName() string {
|
||||
attrs := l.attrs()
|
||||
if attrs.Name == "" {
|
||||
return ""
|
||||
}
|
||||
return attrs.Name
|
||||
}
|
||||
|
||||
// Refresh refreshes the link
|
||||
func (l *Link) Refresh() error {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
|
||||
return l.refresh()
|
||||
}
|
||||
|
||||
// Attrs returns the attributes of the link
|
||||
func (l *Link) Attrs() *netlink.LinkAttrs {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
|
||||
return l.attrs()
|
||||
}
|
||||
|
||||
// Interface returns the interface of the link
|
||||
func (l *Link) Interface() *net.Interface {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
|
||||
ifname := l.ifName()
|
||||
if ifname == "" {
|
||||
return nil
|
||||
}
|
||||
iface, err := net.InterfaceByName(ifname)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return iface
|
||||
}
|
||||
|
||||
// HardwareAddr returns the hardware address of the link
|
||||
func (l *Link) HardwareAddr() net.HardwareAddr {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
|
||||
attrs := l.attrs()
|
||||
if attrs.HardwareAddr == nil {
|
||||
return nil
|
||||
}
|
||||
return attrs.HardwareAddr
|
||||
}
|
||||
|
||||
// AddrList returns the addresses of the link
|
||||
func (l *Link) AddrList(family int) ([]netlink.Addr, error) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
|
||||
return netlink.AddrList(l.Link, family)
|
||||
}
|
||||
|
||||
func (l *Link) SetMTU(mtu int) error {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
|
||||
return netlink.LinkSetMTU(l.Link, mtu)
|
||||
}
|
||||
|
||||
// HasGlobalUnicastAddress returns true if the link has a global unicast address
|
||||
func (l *Link) HasGlobalUnicastAddress() bool {
|
||||
addrs, err := l.AddrList(AfUnspec)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
for _, addr := range addrs {
|
||||
if addr.IP.IsGlobalUnicast() {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// IsSame checks if the link is the same as another link
|
||||
func (l *Link) IsSame(other *Link) bool {
|
||||
if l == nil || other == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
a := l.Attrs()
|
||||
b := other.Attrs()
|
||||
if a.OperState != b.OperState {
|
||||
return false
|
||||
}
|
||||
if a.Index != b.Index {
|
||||
return false
|
||||
}
|
||||
if a.MTU != b.MTU {
|
||||
return false
|
||||
}
|
||||
if a.HardwareAddr.String() != b.HardwareAddr.String() {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
package link
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func (nm *NetlinkManager) setSysctlValues(ifaceName string, values map[string]int) error {
|
||||
for name, value := range values {
|
||||
name = fmt.Sprintf(name, ifaceName)
|
||||
name = strings.ReplaceAll(name, ".", "/")
|
||||
|
||||
if err := os.WriteFile(path.Join(sysctlBase, name), []byte(strconv.Itoa(value)), sysctlFileMode); err != nil {
|
||||
return fmt.Errorf("failed to set sysctl %s=%d: %w", name, value, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// EnableIPv6 enables IPv6 on the interface
|
||||
func (nm *NetlinkManager) EnableIPv6(ifaceName string) error {
|
||||
return nm.setSysctlValues(ifaceName, map[string]int{
|
||||
"net.ipv6.conf.%s.disable_ipv6": 0,
|
||||
"net.ipv6.conf.%s.accept_ra": 2,
|
||||
})
|
||||
}
|
||||
|
||||
// DisableIPv6 disables IPv6 on the interface
|
||||
func (nm *NetlinkManager) DisableIPv6(ifaceName string) error {
|
||||
return nm.setSysctlValues(ifaceName, map[string]int{
|
||||
"net.ipv6.conf.%s.disable_ipv6": 1,
|
||||
})
|
||||
}
|
||||
|
||||
// EnableIPv6SLAAC enables IPv6 SLAAC on the interface
|
||||
func (nm *NetlinkManager) EnableIPv6SLAAC(ifaceName string) error {
|
||||
return nm.setSysctlValues(ifaceName, map[string]int{
|
||||
"net.ipv6.conf.%s.disable_ipv6": 0,
|
||||
"net.ipv6.conf.%s.accept_ra": 2,
|
||||
})
|
||||
}
|
||||
|
||||
// EnableIPv6LinkLocal enables IPv6 link-local only on the interface
|
||||
func (nm *NetlinkManager) EnableIPv6LinkLocal(ifaceName string) error {
|
||||
return nm.setSysctlValues(ifaceName, map[string]int{
|
||||
"net.ipv6.conf.%s.disable_ipv6": 0,
|
||||
"net.ipv6.conf.%s.accept_ra": 0,
|
||||
})
|
||||
}
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
package link
|
||||
|
||||
import (
|
||||
"net"
|
||||
)
|
||||
|
||||
// IPv4Address represents an IPv4 address and its gateway
|
||||
type IPv4Address struct {
|
||||
Address net.IPNet
|
||||
Gateway net.IP
|
||||
Secondary bool
|
||||
Permanent bool
|
||||
}
|
||||
|
|
@ -0,0 +1,87 @@
|
|||
package link
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ParseIPv4Netmask parses an IPv4 netmask string and returns the IPNet
|
||||
func ParseIPv4Netmask(address, netmask string) (*net.IPNet, error) {
|
||||
if strings.Contains(address, "/") {
|
||||
_, ipNet, err := net.ParseCIDR(address)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid IPv4 address: %s", address)
|
||||
}
|
||||
return ipNet, nil
|
||||
}
|
||||
|
||||
ip := net.ParseIP(address)
|
||||
if ip == nil {
|
||||
return nil, fmt.Errorf("invalid IPv4 address: %s", address)
|
||||
}
|
||||
if ip.To4() == nil {
|
||||
return nil, fmt.Errorf("not an IPv4 address: %s", address)
|
||||
}
|
||||
|
||||
mask := net.ParseIP(netmask)
|
||||
if mask == nil {
|
||||
return nil, fmt.Errorf("invalid IPv4 netmask: %s", netmask)
|
||||
}
|
||||
if mask.To4() == nil {
|
||||
return nil, fmt.Errorf("not an IPv4 netmask: %s", netmask)
|
||||
}
|
||||
|
||||
return &net.IPNet{
|
||||
IP: ip,
|
||||
Mask: net.IPv4Mask(mask[12], mask[13], mask[14], mask[15]),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ParseIPv6Prefix parses an IPv6 address and prefix length
|
||||
func ParseIPv6Prefix(address string, prefixLength int) (*net.IPNet, error) {
|
||||
if strings.Contains(address, "/") {
|
||||
_, ipNet, err := net.ParseCIDR(address)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid IPv6 address: %s", address)
|
||||
}
|
||||
return ipNet, nil
|
||||
}
|
||||
|
||||
ip := net.ParseIP(address)
|
||||
if ip == nil {
|
||||
return nil, fmt.Errorf("invalid IPv6 address: %s", address)
|
||||
}
|
||||
if ip.To16() == nil || ip.To4() != nil {
|
||||
return nil, fmt.Errorf("not an IPv6 address: %s", address)
|
||||
}
|
||||
|
||||
if prefixLength < 0 || prefixLength > 128 {
|
||||
return nil, fmt.Errorf("invalid IPv6 prefix length: %d (must be 0-128)", prefixLength)
|
||||
}
|
||||
|
||||
return &net.IPNet{
|
||||
IP: ip,
|
||||
Mask: net.CIDRMask(prefixLength, 128),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ValidateIPAddress validates an IP address
|
||||
func ValidateIPAddress(address string, isIPv6 bool) error {
|
||||
ip := net.ParseIP(address)
|
||||
if ip == nil {
|
||||
return fmt.Errorf("invalid IP address: %s", address)
|
||||
}
|
||||
|
||||
if isIPv6 {
|
||||
if ip.To16() == nil || ip.To4() != nil {
|
||||
return fmt.Errorf("not an IPv6 address: %s", address)
|
||||
}
|
||||
} else {
|
||||
if ip.To4() == nil {
|
||||
return fmt.Errorf("not an IPv4 address: %s", address)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
@ -0,0 +1,260 @@
|
|||
// Package nmlite provides a lightweight network management system.
|
||||
// It supports multiple network interfaces with static and DHCP configuration,
|
||||
// IPv4/IPv6 support, and proper separation of concerns.
|
||||
package nmlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/jetkvm/kvm/internal/sync"
|
||||
|
||||
"github.com/jetkvm/kvm/internal/logging"
|
||||
"github.com/jetkvm/kvm/internal/network/types"
|
||||
"github.com/jetkvm/kvm/pkg/nmlite/jetdhcpc"
|
||||
"github.com/jetkvm/kvm/pkg/nmlite/link"
|
||||
"github.com/rs/zerolog"
|
||||
)
|
||||
|
||||
// NetworkManager manages multiple network interfaces
|
||||
type NetworkManager struct {
|
||||
interfaces map[string]*InterfaceManager
|
||||
mu sync.RWMutex
|
||||
logger *zerolog.Logger
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
|
||||
resolvConf *ResolvConfManager
|
||||
|
||||
// Callback functions for state changes
|
||||
onInterfaceStateChange func(iface string, state types.InterfaceState)
|
||||
onConfigChange func(iface string, config *types.NetworkConfig)
|
||||
onDHCPLeaseChange func(iface string, lease *types.DHCPLease)
|
||||
}
|
||||
|
||||
// NewNetworkManager creates a new network manager
|
||||
func NewNetworkManager(ctx context.Context, logger *zerolog.Logger) *NetworkManager {
|
||||
if logger == nil {
|
||||
logger = logging.GetSubsystemLogger("nm")
|
||||
}
|
||||
|
||||
// Initialize the NetlinkManager singleton
|
||||
link.InitializeNetlinkManager(logger)
|
||||
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
|
||||
return &NetworkManager{
|
||||
interfaces: make(map[string]*InterfaceManager),
|
||||
logger: logger,
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
resolvConf: NewResolvConfManager(logger),
|
||||
}
|
||||
}
|
||||
|
||||
// SetHostname sets the hostname and domain for the network manager
|
||||
func (nm *NetworkManager) SetHostname(hostname string, domain string) error {
|
||||
return nm.resolvConf.SetHostname(hostname, domain)
|
||||
}
|
||||
|
||||
// Domain returns the effective domain for the network manager
|
||||
func (nm *NetworkManager) Domain() string {
|
||||
return nm.resolvConf.Domain()
|
||||
}
|
||||
|
||||
// AddInterface adds a new network interface to be managed
|
||||
func (nm *NetworkManager) AddInterface(iface string, config *types.NetworkConfig) error {
|
||||
nm.mu.Lock()
|
||||
defer nm.mu.Unlock()
|
||||
|
||||
if _, exists := nm.interfaces[iface]; exists {
|
||||
return fmt.Errorf("interface %s already managed", iface)
|
||||
}
|
||||
|
||||
im, err := NewInterfaceManager(nm.ctx, iface, config, nm.logger)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create interface manager for %s: %w", iface, err)
|
||||
}
|
||||
|
||||
// Set up callbacks
|
||||
im.SetOnStateChange(func(state types.InterfaceState) {
|
||||
if nm.onInterfaceStateChange != nil {
|
||||
state.Hostname = nm.Hostname()
|
||||
nm.onInterfaceStateChange(iface, state)
|
||||
}
|
||||
})
|
||||
|
||||
im.SetOnConfigChange(func(config *types.NetworkConfig) {
|
||||
if nm.onConfigChange != nil {
|
||||
nm.onConfigChange(iface, config)
|
||||
}
|
||||
})
|
||||
|
||||
im.SetOnDHCPLeaseChange(func(lease *types.DHCPLease) {
|
||||
if nm.onDHCPLeaseChange != nil {
|
||||
nm.onDHCPLeaseChange(iface, lease)
|
||||
}
|
||||
})
|
||||
|
||||
im.SetOnResolvConfChange(func(family int, resolvConf *types.InterfaceResolvConf) error {
|
||||
return nm.resolvConf.SetInterfaceConfig(iface, family, *resolvConf)
|
||||
})
|
||||
|
||||
nm.interfaces[iface] = im
|
||||
|
||||
// Start monitoring the interface
|
||||
if err := im.Start(); err != nil {
|
||||
delete(nm.interfaces, iface)
|
||||
return fmt.Errorf("failed to start interface manager for %s: %w", iface, err)
|
||||
}
|
||||
|
||||
nm.logger.Info().Str("interface", iface).Msg("added interface to network manager")
|
||||
return nil
|
||||
}
|
||||
|
||||
// RemoveInterface removes a network interface from management
|
||||
func (nm *NetworkManager) RemoveInterface(iface string) error {
|
||||
nm.mu.Lock()
|
||||
defer nm.mu.Unlock()
|
||||
|
||||
im, exists := nm.interfaces[iface]
|
||||
if !exists {
|
||||
return fmt.Errorf("interface %s not managed", iface)
|
||||
}
|
||||
|
||||
if err := im.Stop(); err != nil {
|
||||
nm.logger.Error().Err(err).Str("interface", iface).Msg("failed to stop interface manager")
|
||||
}
|
||||
|
||||
delete(nm.interfaces, iface)
|
||||
nm.logger.Info().Str("interface", iface).Msg("removed interface from network manager")
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetInterface returns the interface manager for a specific interface
|
||||
func (nm *NetworkManager) GetInterface(iface string) (*InterfaceManager, error) {
|
||||
nm.mu.RLock()
|
||||
defer nm.mu.RUnlock()
|
||||
|
||||
im, exists := nm.interfaces[iface]
|
||||
if !exists {
|
||||
return nil, fmt.Errorf("interface %s not managed", iface)
|
||||
}
|
||||
|
||||
return im, nil
|
||||
}
|
||||
|
||||
// ListInterfaces returns a list of all managed interfaces
|
||||
func (nm *NetworkManager) ListInterfaces() []string {
|
||||
nm.mu.RLock()
|
||||
defer nm.mu.RUnlock()
|
||||
|
||||
interfaces := make([]string, 0, len(nm.interfaces))
|
||||
for iface := range nm.interfaces {
|
||||
interfaces = append(interfaces, iface)
|
||||
}
|
||||
|
||||
return interfaces
|
||||
}
|
||||
|
||||
// GetInterfaceState returns the current state of a specific interface
|
||||
func (nm *NetworkManager) GetInterfaceState(iface string) (*types.InterfaceState, error) {
|
||||
im, err := nm.GetInterface(iface)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
state := im.GetState()
|
||||
state.Hostname = nm.Hostname()
|
||||
|
||||
return state, nil
|
||||
}
|
||||
|
||||
// GetInterfaceConfig returns the current configuration of a specific interface
|
||||
func (nm *NetworkManager) GetInterfaceConfig(iface string) (*types.NetworkConfig, error) {
|
||||
im, err := nm.GetInterface(iface)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return im.GetConfig(), nil
|
||||
}
|
||||
|
||||
// SetInterfaceConfig updates the configuration of a specific interface
|
||||
func (nm *NetworkManager) SetInterfaceConfig(iface string, config *types.NetworkConfig) error {
|
||||
im, err := nm.GetInterface(iface)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return im.SetConfig(config)
|
||||
}
|
||||
|
||||
// RenewDHCPLease renews the DHCP lease for a specific interface
|
||||
func (nm *NetworkManager) RenewDHCPLease(iface string) error {
|
||||
im, err := nm.GetInterface(iface)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return im.RenewDHCPLease()
|
||||
}
|
||||
|
||||
// SetOnInterfaceStateChange sets the callback for interface state changes
|
||||
func (nm *NetworkManager) SetOnInterfaceStateChange(callback func(iface string, state types.InterfaceState)) {
|
||||
nm.onInterfaceStateChange = callback
|
||||
}
|
||||
|
||||
// SetOnConfigChange sets the callback for configuration changes
|
||||
func (nm *NetworkManager) SetOnConfigChange(callback func(iface string, config *types.NetworkConfig)) {
|
||||
nm.onConfigChange = callback
|
||||
}
|
||||
|
||||
// SetOnDHCPLeaseChange sets the callback for DHCP lease changes
|
||||
func (nm *NetworkManager) SetOnDHCPLeaseChange(callback func(iface string, lease *types.DHCPLease)) {
|
||||
nm.onDHCPLeaseChange = callback
|
||||
}
|
||||
|
||||
func (nm *NetworkManager) shouldKillLegacyDHCPClients() bool {
|
||||
nm.mu.RLock()
|
||||
defer nm.mu.RUnlock()
|
||||
|
||||
// TODO: remove it when we need to support multiple interfaces
|
||||
for _, im := range nm.interfaces {
|
||||
if im.dhcpClient.clientType != "udhcpc" {
|
||||
return true
|
||||
}
|
||||
|
||||
if im.config.IPv4Mode.String != "dhcp" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// CleanUpLegacyDHCPClients cleans up legacy DHCP clients
|
||||
func (nm *NetworkManager) CleanUpLegacyDHCPClients() error {
|
||||
shouldKill := nm.shouldKillLegacyDHCPClients()
|
||||
if shouldKill {
|
||||
return jetdhcpc.KillUdhcpC(nm.logger)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Stop stops the network manager and all managed interfaces
|
||||
func (nm *NetworkManager) Stop() error {
|
||||
nm.mu.Lock()
|
||||
defer nm.mu.Unlock()
|
||||
|
||||
var lastErr error
|
||||
for iface, im := range nm.interfaces {
|
||||
if err := im.Stop(); err != nil {
|
||||
nm.logger.Error().Err(err).Str("interface", iface).Msg("failed to stop interface manager")
|
||||
lastErr = err
|
||||
}
|
||||
}
|
||||
|
||||
nm.cancel()
|
||||
nm.logger.Info().Msg("network manager stopped")
|
||||
return lastErr
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
package nmlite
|
||||
|
||||
import "github.com/jetkvm/kvm/pkg/nmlite/link"
|
||||
|
||||
func getNetlinkManager() *link.NetlinkManager {
|
||||
return link.GetNetlinkManager()
|
||||
}
|
||||
|
|
@ -0,0 +1,209 @@
|
|||
package nmlite
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/jetkvm/kvm/internal/network/types"
|
||||
"github.com/jetkvm/kvm/internal/sync"
|
||||
"github.com/jetkvm/kvm/pkg/nmlite/link"
|
||||
"github.com/rs/zerolog"
|
||||
)
|
||||
|
||||
const (
|
||||
resolvConfPath = "/etc/resolv.conf"
|
||||
resolvConfFileMode = 0644
|
||||
resolvConfTemplate = `# the resolv.conf file is managed by JetKVM
|
||||
# DO NOT EDIT THIS FILE BY HAND -- YOUR CHANGES WILL BE OVERWRITTEN
|
||||
|
||||
{{ if .searchList }}
|
||||
search {{ join .searchList " " }}
|
||||
{{- end -}}
|
||||
{{ if .domain }}
|
||||
domain {{ .domain }}
|
||||
{{- end -}}
|
||||
{{ range $ns, $comment := .nameservers }}
|
||||
nameserver {{ printf "%s" $ns }} # {{ join $comment ", " }}
|
||||
{{- end }}
|
||||
`
|
||||
)
|
||||
|
||||
var (
|
||||
tplFuncMap = template.FuncMap{
|
||||
"join": strings.Join,
|
||||
}
|
||||
)
|
||||
|
||||
// ResolvConfManager manages the resolv.conf file
|
||||
type ResolvConfManager struct {
|
||||
logger *zerolog.Logger
|
||||
mu sync.Mutex
|
||||
conf *types.ResolvConf
|
||||
|
||||
hostname string
|
||||
domain string
|
||||
}
|
||||
|
||||
// NewResolvConfManager creates a new resolv.conf manager
|
||||
func NewResolvConfManager(logger *zerolog.Logger) *ResolvConfManager {
|
||||
if logger == nil {
|
||||
// Create a no-op logger if none provided
|
||||
logger = &zerolog.Logger{}
|
||||
}
|
||||
|
||||
return &ResolvConfManager{
|
||||
logger: logger,
|
||||
mu: sync.Mutex{},
|
||||
conf: &types.ResolvConf{
|
||||
ConfigIPv4: make(map[string]types.InterfaceResolvConf),
|
||||
ConfigIPv6: make(map[string]types.InterfaceResolvConf),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// SetInterfaceConfig sets the resolv.conf configuration for a specific interface
|
||||
func (rcm *ResolvConfManager) SetInterfaceConfig(iface string, family int, config types.InterfaceResolvConf) error {
|
||||
// DO NOT USE defer HERE, rcm.update() also locks the mutex
|
||||
rcm.mu.Lock()
|
||||
switch family {
|
||||
case link.AfInet:
|
||||
rcm.conf.ConfigIPv4[iface] = config
|
||||
case link.AfInet6:
|
||||
rcm.conf.ConfigIPv6[iface] = config
|
||||
default:
|
||||
rcm.mu.Unlock()
|
||||
return fmt.Errorf("invalid family: %d", family)
|
||||
}
|
||||
rcm.mu.Unlock()
|
||||
|
||||
if err := rcm.reconcileHostname(); err != nil {
|
||||
return fmt.Errorf("failed to reconcile hostname: %w", err)
|
||||
}
|
||||
|
||||
return rcm.update()
|
||||
}
|
||||
|
||||
// SetConfig sets the resolv.conf configuration
|
||||
func (rcm *ResolvConfManager) SetConfig(resolvConf *types.ResolvConf) error {
|
||||
if resolvConf == nil {
|
||||
return fmt.Errorf("resolvConf cannot be nil")
|
||||
}
|
||||
|
||||
rcm.mu.Lock()
|
||||
rcm.conf = resolvConf
|
||||
defer rcm.mu.Unlock()
|
||||
|
||||
return rcm.update()
|
||||
}
|
||||
|
||||
// Reconcile reconciles the resolv.conf configuration
|
||||
func (rcm *ResolvConfManager) Reconcile() error {
|
||||
if err := rcm.reconcileHostname(); err != nil {
|
||||
return fmt.Errorf("failed to reconcile hostname: %w", err)
|
||||
}
|
||||
return rcm.update()
|
||||
}
|
||||
|
||||
// Update updates the resolv.conf file
|
||||
func (rcm *ResolvConfManager) update() error {
|
||||
rcm.mu.Lock()
|
||||
defer rcm.mu.Unlock()
|
||||
|
||||
rcm.logger.Debug().Msg("updating resolv.conf")
|
||||
|
||||
// Generate resolv.conf content
|
||||
content, err := rcm.generateResolvConf(rcm.conf)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to generate resolv.conf: %w", err)
|
||||
}
|
||||
|
||||
// Check if the file is the same
|
||||
if _, err := os.Stat(resolvConfPath); err == nil {
|
||||
existingContent, err := os.ReadFile(resolvConfPath)
|
||||
if err != nil {
|
||||
rcm.logger.Warn().Err(err).Msg("failed to read existing resolv.conf")
|
||||
}
|
||||
|
||||
if bytes.Equal(existingContent, content) {
|
||||
rcm.logger.Debug().Msg("resolv.conf is the same, skipping write")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// Write to file
|
||||
if err := os.WriteFile(resolvConfPath, content, resolvConfFileMode); err != nil {
|
||||
return fmt.Errorf("failed to write resolv.conf: %w", err)
|
||||
}
|
||||
|
||||
rcm.logger.Info().
|
||||
Interface("config", rcm.conf).
|
||||
Msg("resolv.conf updated successfully")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type configMap map[string][]string
|
||||
|
||||
func mergeConfig(nameservers *configMap, searchList *configMap, config *types.InterfaceResolvConfMap) {
|
||||
localNameservers := *nameservers
|
||||
localSearchList := *searchList
|
||||
|
||||
for ifname, iface := range *config {
|
||||
comment := ifname
|
||||
if iface.Source != "" {
|
||||
comment += fmt.Sprintf(" (%s)", iface.Source)
|
||||
}
|
||||
|
||||
for _, ip := range iface.NameServers {
|
||||
ns := ip.String()
|
||||
if _, ok := localNameservers[ns]; !ok {
|
||||
localNameservers[ns] = []string{}
|
||||
}
|
||||
localNameservers[ns] = append(localNameservers[ns], comment)
|
||||
}
|
||||
|
||||
for _, search := range iface.SearchList {
|
||||
search = strings.Trim(search, ".")
|
||||
if _, ok := localSearchList[search]; !ok {
|
||||
localSearchList[search] = []string{}
|
||||
}
|
||||
localSearchList[search] = append(localSearchList[search], comment)
|
||||
}
|
||||
}
|
||||
|
||||
*nameservers = localNameservers
|
||||
*searchList = localSearchList
|
||||
}
|
||||
|
||||
// generateResolvConf generates resolv.conf content
|
||||
func (rcm *ResolvConfManager) generateResolvConf(conf *types.ResolvConf) ([]byte, error) {
|
||||
tmpl, err := template.New("resolv.conf").Funcs(tplFuncMap).Parse(resolvConfTemplate)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse template: %w", err)
|
||||
}
|
||||
|
||||
// merge the nameservers and searchList
|
||||
nameservers := configMap{}
|
||||
searchList := configMap{}
|
||||
|
||||
mergeConfig(&nameservers, &searchList, &conf.ConfigIPv4)
|
||||
mergeConfig(&nameservers, &searchList, &conf.ConfigIPv6)
|
||||
|
||||
flattenedSearchList := []string{}
|
||||
for search := range searchList {
|
||||
flattenedSearchList = append(flattenedSearchList, search)
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := tmpl.Execute(&buf, map[string]any{
|
||||
"nameservers": nameservers,
|
||||
"searchList": flattenedSearchList,
|
||||
}); err != nil {
|
||||
return nil, fmt.Errorf("failed to execute template: %w", err)
|
||||
}
|
||||
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
|
@ -0,0 +1,106 @@
|
|||
package nmlite
|
||||
|
||||
import "net"
|
||||
|
||||
func (nm *NetworkManager) IsOnline() bool {
|
||||
for _, iface := range nm.interfaces {
|
||||
if iface.IsOnline() {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (nm *NetworkManager) IsUp() bool {
|
||||
for _, iface := range nm.interfaces {
|
||||
if iface.IsUp() {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (nm *NetworkManager) Hostname() string {
|
||||
return nm.resolvConf.Hostname()
|
||||
}
|
||||
|
||||
func (nm *NetworkManager) FQDN() string {
|
||||
return nm.resolvConf.FQDN()
|
||||
}
|
||||
|
||||
func (nm *NetworkManager) NTPServers() []net.IP {
|
||||
servers := []net.IP{}
|
||||
for _, iface := range nm.interfaces {
|
||||
servers = append(servers, iface.NTPServers()...)
|
||||
}
|
||||
return servers
|
||||
}
|
||||
|
||||
func (nm *NetworkManager) NTPServerStrings() []string {
|
||||
servers := []string{}
|
||||
for _, server := range nm.NTPServers() {
|
||||
servers = append(servers, server.String())
|
||||
}
|
||||
return servers
|
||||
}
|
||||
|
||||
func (nm *NetworkManager) GetIPv4Addresses() []string {
|
||||
for _, iface := range nm.interfaces {
|
||||
return iface.GetIPv4Addresses()
|
||||
}
|
||||
return []string{}
|
||||
}
|
||||
|
||||
func (nm *NetworkManager) GetIPv4Address() string {
|
||||
for _, iface := range nm.interfaces {
|
||||
return iface.GetIPv4Address()
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (nm *NetworkManager) GetIPv6Address() string {
|
||||
for _, iface := range nm.interfaces {
|
||||
return iface.GetIPv6Address()
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (nm *NetworkManager) GetIPv6Addresses() []string {
|
||||
for _, iface := range nm.interfaces {
|
||||
return iface.GetIPv6Addresses()
|
||||
}
|
||||
return []string{}
|
||||
}
|
||||
|
||||
func (nm *NetworkManager) GetMACAddress() string {
|
||||
for _, iface := range nm.interfaces {
|
||||
return iface.GetMACAddress()
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (nm *NetworkManager) IPv4Ready() bool {
|
||||
for _, iface := range nm.interfaces {
|
||||
return iface.IPv4Ready()
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (nm *NetworkManager) IPv6Ready() bool {
|
||||
for _, iface := range nm.interfaces {
|
||||
return iface.IPv6Ready()
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (nm *NetworkManager) IPv4String() string {
|
||||
return nm.GetIPv4Address()
|
||||
}
|
||||
|
||||
func (nm *NetworkManager) IPv6String() string {
|
||||
return nm.GetIPv6Address()
|
||||
}
|
||||
|
||||
func (nm *NetworkManager) MACString() string {
|
||||
return nm.GetMACAddress()
|
||||
}
|
||||
|
|
@ -0,0 +1,184 @@
|
|||
package nmlite
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
|
||||
"github.com/jetkvm/kvm/internal/network/types"
|
||||
"github.com/jetkvm/kvm/pkg/nmlite/link"
|
||||
"github.com/rs/zerolog"
|
||||
)
|
||||
|
||||
// StaticConfigManager manages static network configuration
|
||||
type StaticConfigManager struct {
|
||||
ifaceName string
|
||||
logger *zerolog.Logger
|
||||
}
|
||||
|
||||
// NewStaticConfigManager creates a new static configuration manager
|
||||
func NewStaticConfigManager(ifaceName string, logger *zerolog.Logger) (*StaticConfigManager, error) {
|
||||
if ifaceName == "" {
|
||||
return nil, fmt.Errorf("interface name cannot be empty")
|
||||
}
|
||||
|
||||
if logger == nil {
|
||||
return nil, fmt.Errorf("logger cannot be nil")
|
||||
}
|
||||
|
||||
return &StaticConfigManager{
|
||||
ifaceName: ifaceName,
|
||||
logger: logger,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ToIPv4Static applies static IPv4 configuration
|
||||
func (scm *StaticConfigManager) ToIPv4Static(config *types.IPv4StaticConfig) (*types.ParsedIPConfig, error) {
|
||||
if config == nil {
|
||||
return nil, fmt.Errorf("config is nil")
|
||||
}
|
||||
|
||||
// Parse IP address and netmask
|
||||
ipNet, err := link.ParseIPv4Netmask(config.Address.String, config.Netmask.String)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
scm.logger.Info().Str("ipNet", ipNet.String()).Interface("ipc", config).Msg("parsed IPv4 address and netmask")
|
||||
|
||||
// Parse gateway
|
||||
gateway := net.ParseIP(config.Gateway.String)
|
||||
if gateway == nil {
|
||||
return nil, fmt.Errorf("invalid gateway: %s", config.Gateway.String)
|
||||
}
|
||||
|
||||
// Parse DNS servers
|
||||
var dns []net.IP
|
||||
for _, dnsStr := range config.DNS {
|
||||
if err := link.ValidateIPAddress(dnsStr, false); err != nil {
|
||||
return nil, fmt.Errorf("invalid DNS server: %w", err)
|
||||
}
|
||||
dns = append(dns, net.ParseIP(dnsStr))
|
||||
}
|
||||
|
||||
address := types.IPAddress{
|
||||
Family: link.AfInet,
|
||||
Address: *ipNet,
|
||||
Gateway: gateway,
|
||||
Secondary: false,
|
||||
Permanent: true,
|
||||
}
|
||||
|
||||
return &types.ParsedIPConfig{
|
||||
Addresses: []types.IPAddress{address},
|
||||
Nameservers: dns,
|
||||
Interface: scm.ifaceName,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ToIPv6Static applies static IPv6 configuration
|
||||
func (scm *StaticConfigManager) ToIPv6Static(config *types.IPv6StaticConfig) (*types.ParsedIPConfig, error) {
|
||||
if config == nil {
|
||||
return nil, fmt.Errorf("config is nil")
|
||||
}
|
||||
|
||||
// Parse IP address and prefix
|
||||
ipNet, err := link.ParseIPv6Prefix(config.Prefix.String, 64) // Default to /64 if not specified
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Parse gateway
|
||||
gateway := net.ParseIP(config.Gateway.String)
|
||||
if gateway == nil {
|
||||
return nil, fmt.Errorf("invalid gateway: %s", config.Gateway.String)
|
||||
}
|
||||
|
||||
// Parse DNS servers
|
||||
var dns []net.IP
|
||||
for _, dnsStr := range config.DNS {
|
||||
dnsIP := net.ParseIP(dnsStr)
|
||||
if dnsIP == nil {
|
||||
return nil, fmt.Errorf("invalid DNS server: %s", dnsStr)
|
||||
}
|
||||
dns = append(dns, dnsIP)
|
||||
}
|
||||
|
||||
address := types.IPAddress{
|
||||
Family: link.AfInet6,
|
||||
Address: *ipNet,
|
||||
Gateway: gateway,
|
||||
Secondary: false,
|
||||
Permanent: true,
|
||||
}
|
||||
|
||||
return &types.ParsedIPConfig{
|
||||
Addresses: []types.IPAddress{address},
|
||||
Nameservers: dns,
|
||||
Interface: scm.ifaceName,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// DisableIPv4 disables IPv4 on the interface
|
||||
func (scm *StaticConfigManager) DisableIPv4() error {
|
||||
scm.logger.Info().Msg("disabling IPv4")
|
||||
|
||||
netlinkMgr := getNetlinkManager()
|
||||
iface, err := netlinkMgr.GetLinkByName(scm.ifaceName)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get interface: %w", err)
|
||||
}
|
||||
|
||||
// Remove all IPv4 addresses
|
||||
if err := netlinkMgr.RemoveAllAddresses(iface, link.AfInet); err != nil {
|
||||
return fmt.Errorf("failed to remove IPv4 addresses: %w", err)
|
||||
}
|
||||
|
||||
// Remove default route
|
||||
if err := scm.removeIPv4DefaultRoute(); err != nil {
|
||||
scm.logger.Warn().Err(err).Msg("failed to remove IPv4 default route")
|
||||
}
|
||||
|
||||
scm.logger.Info().Msg("IPv4 disabled")
|
||||
return nil
|
||||
}
|
||||
|
||||
// DisableIPv6 disables IPv6 on the interface
|
||||
func (scm *StaticConfigManager) DisableIPv6() error {
|
||||
scm.logger.Info().Msg("disabling IPv6")
|
||||
netlinkMgr := getNetlinkManager()
|
||||
return netlinkMgr.DisableIPv6(scm.ifaceName)
|
||||
}
|
||||
|
||||
// EnableIPv6SLAAC enables IPv6 SLAAC
|
||||
func (scm *StaticConfigManager) EnableIPv6SLAAC() error {
|
||||
scm.logger.Info().Msg("enabling IPv6 SLAAC")
|
||||
netlinkMgr := getNetlinkManager()
|
||||
return netlinkMgr.EnableIPv6SLAAC(scm.ifaceName)
|
||||
}
|
||||
|
||||
// EnableIPv6LinkLocal enables IPv6 link-local only
|
||||
func (scm *StaticConfigManager) EnableIPv6LinkLocal() error {
|
||||
scm.logger.Info().Msg("enabling IPv6 link-local only")
|
||||
|
||||
netlinkMgr := getNetlinkManager()
|
||||
if err := netlinkMgr.EnableIPv6LinkLocal(scm.ifaceName); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Remove all non-link-local IPv6 addresses
|
||||
link, err := netlinkMgr.GetLinkByName(scm.ifaceName)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get interface: %w", err)
|
||||
}
|
||||
|
||||
if err := netlinkMgr.RemoveNonLinkLocalIPv6Addresses(link); err != nil {
|
||||
return fmt.Errorf("failed to remove non-link-local IPv6 addresses: %w", err)
|
||||
}
|
||||
|
||||
return netlinkMgr.EnsureInterfaceUp(link)
|
||||
}
|
||||
|
||||
// removeIPv4DefaultRoute removes IPv4 default route
|
||||
func (scm *StaticConfigManager) removeIPv4DefaultRoute() error {
|
||||
netlinkMgr := getNetlinkManager()
|
||||
return netlinkMgr.RemoveDefaultRoute(link.AfInet)
|
||||
}
|
||||
|
|
@ -0,0 +1,171 @@
|
|||
package udhcpc
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jetkvm/kvm/internal/network/types"
|
||||
)
|
||||
|
||||
type Lease struct {
|
||||
types.DHCPLease
|
||||
// from https://udhcp.busybox.net/README.udhcpc
|
||||
isEmpty map[string]bool
|
||||
}
|
||||
|
||||
func (l *Lease) setIsEmpty(m map[string]bool) {
|
||||
l.isEmpty = m
|
||||
}
|
||||
|
||||
// IsEmpty returns true if the lease is empty for the given key.
|
||||
func (l *Lease) IsEmpty(key string) bool {
|
||||
return l.isEmpty[key]
|
||||
}
|
||||
|
||||
// ToJSON returns the lease as a JSON string.
|
||||
func (l *Lease) ToJSON() string {
|
||||
json, err := json.Marshal(l)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return string(json)
|
||||
}
|
||||
|
||||
// ToDHCPLease converts a lease to a DHCP lease.
|
||||
func (l *Lease) ToDHCPLease() *types.DHCPLease {
|
||||
lease := &l.DHCPLease
|
||||
lease.DHCPClient = "udhcpc"
|
||||
return lease
|
||||
}
|
||||
|
||||
// SetLeaseExpiry sets the lease expiry time.
|
||||
func (l *Lease) SetLeaseExpiry() (time.Time, error) {
|
||||
if l.Uptime == 0 || l.LeaseTime == 0 {
|
||||
return time.Time{}, fmt.Errorf("uptime or lease time isn't set")
|
||||
}
|
||||
|
||||
// get the uptime of the device
|
||||
|
||||
file, err := os.Open("/proc/uptime")
|
||||
if err != nil {
|
||||
return time.Time{}, fmt.Errorf("failed to open uptime file: %w", err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
var uptime time.Duration
|
||||
|
||||
scanner := bufio.NewScanner(file)
|
||||
for scanner.Scan() {
|
||||
text := scanner.Text()
|
||||
parts := strings.Split(text, " ")
|
||||
uptime, err = time.ParseDuration(parts[0] + "s")
|
||||
|
||||
if err != nil {
|
||||
return time.Time{}, fmt.Errorf("failed to parse uptime: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
relativeLeaseRemaining := (l.Uptime + l.LeaseTime) - uptime
|
||||
leaseExpiry := time.Now().Add(relativeLeaseRemaining)
|
||||
|
||||
l.LeaseExpiry = &leaseExpiry
|
||||
|
||||
return leaseExpiry, nil
|
||||
}
|
||||
|
||||
// UnmarshalDHCPCLease unmarshals a lease from a string.
|
||||
func UnmarshalDHCPCLease(obj *Lease, str string) error {
|
||||
lease := &obj.DHCPLease
|
||||
|
||||
// parse the lease file as a map
|
||||
data := make(map[string]string)
|
||||
for line := range strings.SplitSeq(str, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
// skip empty lines and comments
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
|
||||
parts := strings.SplitN(line, "=", 2)
|
||||
if len(parts) != 2 {
|
||||
continue
|
||||
}
|
||||
|
||||
key := strings.TrimSpace(parts[0])
|
||||
value := strings.TrimSpace(parts[1])
|
||||
|
||||
data[key] = value
|
||||
}
|
||||
|
||||
// now iterate over the lease struct and set the values
|
||||
leaseType := reflect.TypeOf(lease).Elem()
|
||||
leaseValue := reflect.ValueOf(lease).Elem()
|
||||
|
||||
valuesParsed := make(map[string]bool)
|
||||
|
||||
for i := 0; i < leaseType.NumField(); i++ {
|
||||
field := leaseValue.Field(i)
|
||||
|
||||
// get the env tag
|
||||
key := leaseType.Field(i).Tag.Get("env")
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
valuesParsed[key] = false
|
||||
|
||||
// get the value from the data map
|
||||
value, ok := data[key]
|
||||
if !ok || value == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
switch field.Interface().(type) {
|
||||
case string:
|
||||
field.SetString(value)
|
||||
case int:
|
||||
val, err := strconv.Atoi(value)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
field.SetInt(int64(val))
|
||||
case time.Duration:
|
||||
val, err := time.ParseDuration(value + "s")
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
field.Set(reflect.ValueOf(val))
|
||||
case net.IP:
|
||||
ip := net.ParseIP(value)
|
||||
if ip == nil {
|
||||
continue
|
||||
}
|
||||
field.Set(reflect.ValueOf(ip))
|
||||
case []net.IP:
|
||||
val := make([]net.IP, 0)
|
||||
for ipStr := range strings.FieldsSeq(value) {
|
||||
ip := net.ParseIP(ipStr)
|
||||
if ip == nil {
|
||||
continue
|
||||
}
|
||||
val = append(val, ip)
|
||||
}
|
||||
field.Set(reflect.ValueOf(val))
|
||||
default:
|
||||
return fmt.Errorf("unsupported field `%s` type: %s", key, field.Type().String())
|
||||
}
|
||||
|
||||
valuesParsed[key] = true
|
||||
}
|
||||
|
||||
obj.setIsEmpty(valuesParsed)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
@ -6,9 +6,13 @@ import (
|
|||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
|
||||
"time"
|
||||
|
||||
"github.com/jetkvm/kvm/internal/sync"
|
||||
|
||||
"github.com/fsnotify/fsnotify"
|
||||
"github.com/jetkvm/kvm/internal/network/types"
|
||||
"github.com/rs/zerolog"
|
||||
)
|
||||
|
||||
|
|
@ -18,20 +22,22 @@ const (
|
|||
)
|
||||
|
||||
type DHCPClient struct {
|
||||
types.DHCPClient
|
||||
InterfaceName string
|
||||
leaseFile string
|
||||
pidFile string
|
||||
lease *Lease
|
||||
logger *zerolog.Logger
|
||||
process *os.Process
|
||||
onLeaseChange func(lease *Lease)
|
||||
runOnce sync.Once
|
||||
onLeaseChange func(lease *types.DHCPLease)
|
||||
}
|
||||
|
||||
type DHCPClientOptions struct {
|
||||
InterfaceName string
|
||||
PidFile string
|
||||
Logger *zerolog.Logger
|
||||
OnLeaseChange func(lease *Lease)
|
||||
OnLeaseChange func(lease *types.DHCPLease)
|
||||
}
|
||||
|
||||
var defaultLogger = zerolog.New(os.Stdout).Level(zerolog.InfoLevel)
|
||||
|
|
@ -67,8 +73,8 @@ func (c *DHCPClient) getWatchPaths() []string {
|
|||
}
|
||||
|
||||
// Run starts the DHCP client and watches the lease file for changes.
|
||||
// this isn't a blocking call, and the lease file is reloaded when a change is detected.
|
||||
func (c *DHCPClient) Run() error {
|
||||
// this is a blocking call.
|
||||
func (c *DHCPClient) run() error {
|
||||
err := c.loadLeaseFile()
|
||||
if err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return err
|
||||
|
|
@ -125,7 +131,7 @@ func (c *DHCPClient) Run() error {
|
|||
// c.logger.Error().Msg("udhcpc process not found")
|
||||
// }
|
||||
|
||||
// block the goroutine until the lease file is updated
|
||||
// block the goroutine
|
||||
<-make(chan struct{})
|
||||
|
||||
return nil
|
||||
|
|
@ -182,7 +188,7 @@ func (c *DHCPClient) loadLeaseFile() error {
|
|||
Msg("current dhcp lease expiry time calculated")
|
||||
}
|
||||
|
||||
c.onLeaseChange(lease)
|
||||
c.onLeaseChange(lease.ToDHCPLease())
|
||||
|
||||
c.logger.Info().
|
||||
Str("ip", lease.IPAddress.String()).
|
||||
|
|
@ -196,3 +202,47 @@ func (c *DHCPClient) loadLeaseFile() error {
|
|||
func (c *DHCPClient) GetLease() *Lease {
|
||||
return c.lease
|
||||
}
|
||||
|
||||
func (c *DHCPClient) Domain() string {
|
||||
return c.lease.Domain
|
||||
}
|
||||
|
||||
func (c *DHCPClient) Lease4() *types.DHCPLease {
|
||||
if c.lease == nil {
|
||||
return nil
|
||||
}
|
||||
return c.lease.ToDHCPLease()
|
||||
}
|
||||
|
||||
func (c *DHCPClient) Lease6() *types.DHCPLease {
|
||||
// TODO: implement
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *DHCPClient) SetIPv4(enabled bool) {
|
||||
// TODO: implement
|
||||
}
|
||||
|
||||
func (c *DHCPClient) SetIPv6(enabled bool) {
|
||||
// TODO: implement
|
||||
}
|
||||
|
||||
func (c *DHCPClient) SetOnLeaseChange(callback func(lease *types.DHCPLease)) {
|
||||
c.onLeaseChange = callback
|
||||
}
|
||||
|
||||
func (c *DHCPClient) Start() error {
|
||||
c.runOnce.Do(func() {
|
||||
go func() {
|
||||
err := c.run()
|
||||
if err != nil {
|
||||
c.logger.Error().Err(err).Msg("failed to run udhcpc")
|
||||
}
|
||||
}()
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *DHCPClient) Stop() error {
|
||||
return c.KillProcess() // udhcpc already has KillProcess()
|
||||
}
|
||||
|
|
@ -0,0 +1,76 @@
|
|||
package nmlite
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"github.com/jetkvm/kvm/internal/network/types"
|
||||
)
|
||||
|
||||
func lifetimeToTime(lifetime int) *time.Time {
|
||||
if lifetime == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check for infinite lifetime (0xFFFFFFFF = 4294967295)
|
||||
// This is used for static/permanent addresses
|
||||
// Use uint32 to avoid int overflow on 32-bit systems
|
||||
const infiniteLifetime uint32 = 0xFFFFFFFF
|
||||
if uint32(lifetime) == infiniteLifetime || lifetime < 0 {
|
||||
return nil // Infinite lifetime - no expiration
|
||||
}
|
||||
|
||||
// For finite lifetimes (SLAAC addresses)
|
||||
t := time.Now().Add(time.Duration(lifetime) * time.Second)
|
||||
return &t
|
||||
}
|
||||
|
||||
func sortAndCompareStringSlices(a, b []string) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
|
||||
sort.Strings(a)
|
||||
sort.Strings(b)
|
||||
|
||||
for i := range a {
|
||||
if a[i] != b[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
func sortAndCompareIPv6AddressSlices(a, b []types.IPv6Address) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
|
||||
sort.SliceStable(a, func(i, j int) bool {
|
||||
return a[i].Address.String() < b[j].Address.String()
|
||||
})
|
||||
sort.SliceStable(b, func(i, j int) bool {
|
||||
return b[i].Address.String() < a[j].Address.String()
|
||||
})
|
||||
|
||||
for i := range a {
|
||||
if a[i].Address.String() != b[i].Address.String() {
|
||||
return false
|
||||
}
|
||||
|
||||
if a[i].Prefix.String() != b[i].Prefix.String() {
|
||||
return false
|
||||
}
|
||||
|
||||
if a[i].Flags != b[i].Flags {
|
||||
return false
|
||||
}
|
||||
|
||||
// we don't compare the lifetimes because they are not always same
|
||||
if a[i].Scope != b[i].Scope {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
|
@ -17,6 +17,7 @@ show_help() {
|
|||
echo " --skip-ui-build Skip frontend/UI build"
|
||||
echo " --skip-native-build Skip native build"
|
||||
echo " --disable-docker Disable docker build"
|
||||
echo " --enable-sync-trace Enable sync trace (do not use in release builds)"
|
||||
echo " -i, --install Build for release and install the app"
|
||||
echo " --help Display this help message"
|
||||
echo
|
||||
|
|
@ -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}"
|
||||
|
||||
|
|
|
|||
14
timesync.go
14
timesync.go
|
|
@ -43,8 +43,20 @@ func initTimeSync() {
|
|||
timeSync = timesync.NewTimeSync(×ync.TimeSyncOptions{
|
||||
Logger: timesyncLogger,
|
||||
NetworkConfig: config.NetworkConfig,
|
||||
PreCheckIPv4: func() (bool, error) {
|
||||
if !networkManager.IPv4Ready() {
|
||||
return false, nil
|
||||
}
|
||||
return true, nil
|
||||
},
|
||||
PreCheckIPv6: func() (bool, error) {
|
||||
if !networkManager.IPv6Ready() {
|
||||
return false, nil
|
||||
}
|
||||
return true, nil
|
||||
},
|
||||
PreCheckFunc: func() (bool, error) {
|
||||
if !networkState.IsOnline() {
|
||||
if !networkManager.IsOnline() {
|
||||
return false, nil
|
||||
}
|
||||
return true, nil
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
],
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -0,0 +1 @@
|
|||
cache
|
||||
|
|
@ -0,0 +1 @@
|
|||
TI1a2RjjH4qkImNj0w
|
||||
|
|
@ -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"
|
||||
]
|
||||
}
|
||||
|
|
@ -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
Loading…
Reference in New Issue