Merge branch 'dev' into feature/jiggler-scheduler

This commit is contained in:
Adam Shiervani 2025-08-12 12:15:08 +02:00
commit 69b3585bae
207 changed files with 19400 additions and 5123 deletions

View File

@ -4,11 +4,24 @@
"features": {
"ghcr.io/devcontainers/features/node:1": {
// Should match what is defined in ui/package.json
"version": "21.1.0"
"version": "22.15.0"
}
},
"mounts": [
"source=${localEnv:HOME}/.ssh,target=/home/vscode/.ssh,type=bind,consistency=cached"
]
],
"customizations": {
"vscode": {
"extensions": [
"bradlc.vscode-tailwindcss",
"GitHub.vscode-pull-request-github",
"dbaeumer.vscode-eslint",
"golang.go",
"ms-vscode.makefile-tools",
"esbenp.prettier-vscode",
"github.vscode-github-actions"
]
}
}
}

76
.github/ISSUE_TEMPLATE/bug.yml vendored Normal file
View File

@ -0,0 +1,76 @@
---
name: Bug report
description: 🐛 Let us know about an unexpected error, a crash, or an unexpected behavior.
type: 'Bug'
labels:
- 'type: bug'
body:
- type: checkboxes
attributes:
label: Disclaimer
description: |
For support questions, please use the [discussions][] or [Discord][] instead. Before
opening a bug report, ensure you have read the [documentation][],
[Troubleshooting][] and [Device FAQs][]. Only use bug reports for actual
bugs.
[documentation]: https://jetkvm.com/docs
[Troubleshooting]: https://jetkvm.com/docs/getting-started/troubleshooting
[Device FAQs]: https://jetkvm.com/docs/getting-started/faq
[discussions]: https://github.com/jetkvm/kvm/discussions
[Discord]: https://jetkvm.com/discord
options:
- label: I have read and understood the disclaimer.
required: true
- type: input
attributes:
label: Application version
description: |
Provide the application version (can be found in General settings)
validations:
required: true
- type: input
attributes:
label: System version
description: |
Provide the system version (can be found in General settings)
validations:
required: true
- type: dropdown
attributes:
label: Device model
description: Provide the device model
options:
- JetKVM
- JetKVM (POE)
validations:
required: false
- type: dropdown
attributes:
label: Extension model
description: Provide the extension model (if the bug is related to the extension)
options:
- ATX Power Control
- DC Power Control
- Serial Console
validations:
required: false
- type: input
attributes:
label: Remote device Hardware
description: If the bug is related to a remote device, please provide its hardware information e.g. Raspberry Pi 5
validations:
required: false
- type: input
attributes:
label: Remote device OS
description: If the bug is related to a remote device, please provide its OS information as detailed as possible e.g. Debian 12.
validations:
required: false
- type: textarea
attributes:
label: Bug description
description: |
Provide a description of the problem: steps to reproduce it, what you are expecting and what you got.
validations:
required: true

10
.github/ISSUE_TEMPLATE/config.yml vendored Normal file
View File

@ -0,0 +1,10 @@
blank_issues_enabled: true
contact_links:
- name: Hardware Issues
url: https://jetkvm.com/contact
about: If your hardware is not powering on or is not working, please contact us.
- name: Discord
url: https://jetkvm.com/discord
about: Engage with the JetKVM team and other community members.

46
.github/ISSUE_TEMPLATE/feature.yml vendored Normal file
View File

@ -0,0 +1,46 @@
name: Feature
type: 'Feature'
description: 🚀 Request a new feature.
labels:
- 'type: feature'
body:
- type: textarea
attributes:
label: A note for the community
value: |
> [!NOTE]
> Please vote on this issue by adding a 👍 [reaction](https://blog.github.com/2016-03-10-add-reactions-to-pull-requests-issues-and-comments/) to the original issue to help the community and maintainers prioritize this request.
validations:
required: true
- type: checkboxes
attributes:
label: Disclaimer
description: |
Before requesting a feature, check it does not already exist in the [documentation](https://jetkvm.com/docs) or our [roadmap](https://jetkvm.com/roadmap).
You are quite welcome opening a feature request before spending time to implement it yourself.
options:
- label: I have read and understood the disclaimer.
required: true
- label: I plan to implement the feature myself.
- type: dropdown
attributes:
label: Subsystem
description: Provide the subsystem of the feature you request, you can choose multiple if you think it fits in multiple areas.
options:
- Hardware
- Device Compatibility
- Keyboard
- Mouse
- Power
- UI: Screen
- UI: Application
- UI: Cloud
validations:
required: false
- type: textarea
attributes:
label: Feature description
description: |
Provide a description of the feature you request.
validations:
required: true

17
.github/dependabot.yml vendored Normal file
View File

@ -0,0 +1,17 @@
version: 2
updates:
- package-ecosystem: gomod
directory: /
schedule:
interval: monthly
open-pull-requests-limit: 10
- package-ecosystem: github-actions
directory: /
schedule:
interval: monthly
open-pull-requests-limit: 10
- package-ecosystem: npm
directory: /ui
open-pull-requests-limit: 10
schedule:
interval: monthly

View File

@ -10,133 +10,42 @@ on:
jobs:
build:
runs-on: buildjet-4vcpu-ubuntu-2204
runs-on: ubuntu-latest
name: Build
if: github.event_name != 'pull_request_review' || github.event.review.state == 'approved'
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: v21.1.0
node-version: "22"
cache: "npm"
cache-dependency-path: "**/package-lock.json"
- name: Set up Golang
uses: actions/setup-go@v4
uses: actions/setup-go@v5.5.0
with:
go-version: "1.24.0"
go-version: "1.24.4"
- name: Build frontend
run: |
make frontend
- name: Build application
run: |
make build_dev
- name: Run tests
run: |
go test ./... -json > testreport.json
- name: Make test cases
run: |
make build_dev_test
- name: Golang Test Report
uses: becheran/go-testreport@v0.3.2
with:
input: "testreport.json"
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: jetkvm-app
path: bin/jetkvm_app
deploy_and_test:
runs-on: buildjet-4vcpu-ubuntu-2204
name: Smoke test
needs: build
concurrency:
group: smoketest-jk
steps:
- name: Download artifact
uses: actions/download-artifact@v4
with:
name: jetkvm-app
- name: Configure WireGuard and check connectivity
run: |
WG_KEY_FILE=$(mktemp)
echo -n "$CI_WG_PRIVATE" > $WG_KEY_FILE && \
sudo apt-get update && sudo apt-get install -y wireguard-tools && \
sudo ip link add dev wg-ci type wireguard && \
sudo ip addr add $CI_WG_IPS dev wg-ci && \
sudo wg set wg-ci listen-port 51820 \
private-key $WG_KEY_FILE \
peer $CI_WG_PUBLIC \
allowed-ips $CI_WG_ALLOWED_IPS \
endpoint $CI_WG_ENDPOINT \
persistent-keepalive 15 && \
sudo ip link set up dev wg-ci && \
sudo ip r r $CI_HOST via $CI_WG_GATEWAY dev wg-ci
ping -c1 $CI_HOST || (echo "Failed to ping $CI_HOST" && sudo wg show wg-ci && ip r && exit 1)
env:
CI_HOST: ${{ vars.JETKVM_CI_HOST }}
CI_WG_IPS: ${{ vars.JETKVM_CI_WG_IPS }}
CI_WG_GATEWAY: ${{ vars.JETKVM_CI_GATEWAY }}
CI_WG_ALLOWED_IPS: ${{ vars.JETKVM_CI_WG_ALLOWED_IPS }}
CI_WG_PUBLIC: ${{ secrets.JETKVM_CI_WG_PUBLIC }}
CI_WG_PRIVATE: ${{ secrets.JETKVM_CI_WG_PRIVATE }}
CI_WG_ENDPOINT: ${{ secrets.JETKVM_CI_WG_ENDPOINT }}
- name: Configure SSH
run: |
# Write SSH private key to a file
SSH_PRIVATE_KEY=$(mktemp)
echo "$CI_SSH_PRIVATE" > $SSH_PRIVATE_KEY
chmod 0600 $SSH_PRIVATE_KEY
# Configure SSH
mkdir -p ~/.ssh
cat <<EOF >> ~/.ssh/config
Host jkci
HostName $CI_HOST
User $CI_USER
StrictHostKeyChecking no
UserKnownHostsFile /dev/null
IdentityFile $SSH_PRIVATE_KEY
EOF
env:
CI_USER: ${{ vars.JETKVM_CI_USER }}
CI_HOST: ${{ vars.JETKVM_CI_HOST }}
CI_SSH_PRIVATE: ${{ secrets.JETKVM_CI_SSH_PRIVATE }}
- name: Deploy application
run: |
set -e
# Copy the binary to the remote host
echo "+ Copying the application to the remote host"
cat jetkvm_app | gzip | ssh jkci "cat > /userdata/jetkvm/jetkvm_app.update.gz"
# Deploy and run the application on the remote host
echo "+ Deploying the application on the remote host"
ssh jkci ash <<EOF
# Extract the binary
gzip -d /userdata/jetkvm/jetkvm_app.update.gz
# Flush filesystem buffers to ensure all data is written to disk
sync
# Clear the filesystem caches to force a read from disk
echo 1 > /proc/sys/vm/drop_caches
# Reboot the application
reboot -d 5 -f &
EOF
sleep 10
echo "Deployment complete, waiting for JetKVM to come back online "
function check_online() {
for i in {1..60}; do
if ping -c1 -w1 -W1 -q $CI_HOST >/dev/null; then
echo "JetKVM is back online"
return 0
fi
echo -n "."
sleep 1
done
echo "JetKVM did not come back online within 60 seconds"
return 1
}
check_online
env:
CI_HOST: ${{ vars.JETKVM_CI_HOST }}
- name: Run smoke tests
run: |
echo "+ Checking the status of the device"
curl -v http://$CI_HOST/device/status && echo
echo "+ Collecting logs"
ssh jkci "cat /userdata/jetkvm/last.log" > last.log
cat last.log
env:
CI_HOST: ${{ vars.JETKVM_CI_HOST }}
- name: Upload logs
uses: actions/upload-artifact@v4
with:
name: device-logs
path: last.log
path: |
bin/jetkvm_app
device-tests.tar.gz

View File

@ -24,14 +24,14 @@ jobs:
- name: Checkout repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Install Go
uses: actions/setup-go@41dfa10bad2bb2ae585af6ee5bb4d7d973ad74ed # v5.1.0
uses: actions/setup-go@fa96338abe5531f6e34c5cc0bbe28c1a533d5505 # v4.2.1
with:
go-version: 1.23.x
go-version: 1.24.4
- name: Create empty resource directory
run: |
mkdir -p static && touch static/.gitkeep
- name: Lint
uses: golangci/golangci-lint-action@971e284b6050e8a5849b72094c50ab08da042db8 # v6.1.1
uses: golangci/golangci-lint-action@1481404843c368bc19ca9406f87d6e0fc97bdcfd # v7.0.0
with:
args: --verbose
version: v1.62.0
version: v2.0.2

174
.github/workflows/smoketest.yml vendored Normal file
View File

@ -0,0 +1,174 @@
name: smoketest
on:
repository_dispatch:
types: [smoketest]
jobs:
ghbot_payload:
name: Ghbot payload
runs-on: ubuntu-latest
steps:
- name: "GH_CHECK_RUN_ID=${{ github.event.client_payload.check_run_id }}"
run: |
echo "== START GHBOT_PAYLOAD =="
cat <<'GHPAYLOAD_EOF' | base64
${{ toJson(github.event.client_payload) }}
GHPAYLOAD_EOF
echo "== END GHBOT_PAYLOAD =="
deploy_and_test:
runs-on: buildjet-4vcpu-ubuntu-2204
name: Smoke test
concurrency:
group: smoketest-jk
steps:
- name: Download artifact
run: |
wget -O /tmp/jk.zip "${{ github.event.client_payload.artifact_download_url }}"
unzip /tmp/jk.zip
- name: Configure WireGuard and check connectivity
run: |
WG_KEY_FILE=$(mktemp)
echo -n "$CI_WG_PRIVATE" > $WG_KEY_FILE && \
sudo apt-get update && sudo apt-get install -y wireguard-tools && \
sudo ip link add dev wg-ci type wireguard && \
sudo ip addr add $CI_WG_IPS dev wg-ci && \
sudo wg set wg-ci listen-port 51820 \
private-key $WG_KEY_FILE \
peer $CI_WG_PUBLIC \
allowed-ips $CI_WG_ALLOWED_IPS \
endpoint $CI_WG_ENDPOINT \
persistent-keepalive 15 && \
sudo ip link set up dev wg-ci && \
sudo ip r r $CI_HOST via $CI_WG_GATEWAY dev wg-ci
ping -c1 $CI_HOST || (echo "Failed to ping $CI_HOST" && sudo wg show wg-ci && ip r && exit 1)
env:
CI_HOST: ${{ vars.JETKVM_CI_HOST }}
CI_WG_IPS: ${{ vars.JETKVM_CI_WG_IPS }}
CI_WG_GATEWAY: ${{ vars.JETKVM_CI_GATEWAY }}
CI_WG_ALLOWED_IPS: ${{ vars.JETKVM_CI_WG_ALLOWED_IPS }}
CI_WG_PUBLIC: ${{ secrets.JETKVM_CI_WG_PUBLIC }}
CI_WG_PRIVATE: ${{ secrets.JETKVM_CI_WG_PRIVATE }}
CI_WG_ENDPOINT: ${{ secrets.JETKVM_CI_WG_ENDPOINT }}
- name: Configure SSH
run: |
# Write SSH private key to a file
SSH_PRIVATE_KEY=$(mktemp)
echo "$CI_SSH_PRIVATE" > $SSH_PRIVATE_KEY
chmod 0600 $SSH_PRIVATE_KEY
# Configure SSH
mkdir -p ~/.ssh
cat <<EOF >> ~/.ssh/config
Host jkci
HostName $CI_HOST
User $CI_USER
StrictHostKeyChecking no
UserKnownHostsFile /dev/null
IdentityFile $SSH_PRIVATE_KEY
EOF
env:
CI_USER: ${{ vars.JETKVM_CI_USER }}
CI_HOST: ${{ vars.JETKVM_CI_HOST }}
CI_SSH_PRIVATE: ${{ secrets.JETKVM_CI_SSH_PRIVATE }}
- name: Run tests
run: |
set -e
echo "+ Copying device-tests.tar.gz to remote host"
ssh jkci "cat > /tmp/device-tests.tar.gz" < device-tests.tar.gz
echo "+ Running go tests"
ssh jkci ash << 'EOF'
set -e
TMP_DIR=$(mktemp -d)
cd ${TMP_DIR}
tar zxf /tmp/device-tests.tar.gz
./gotestsum --format=testdox \
--jsonfile=/tmp/device-tests.json \
--post-run-command 'sh -c "echo $TESTS_FAILED > /tmp/device-tests.failed"' \
--raw-command -- ./run_all_tests -json
GOTESTSUM_EXIT_CODE=$?
if [ $GOTESTSUM_EXIT_CODE -ne 0 ]; then
echo "❌ Tests failed (exit code: $GOTESTSUM_EXIT_CODE)"
rm -rf ${TMP_DIR} /tmp/device-tests.tar.gz
exit 1
fi
TESTS_FAILED=$(cat /tmp/device-tests.failed)
if [ "$TESTS_FAILED" -ne 0 ]; then
echo "❌ Tests failed $TESTS_FAILED tests failed"
rm -rf ${TMP_DIR} /tmp/device-tests.tar.gz
exit 1
fi
echo "✅ Tests passed"
rm -rf ${TMP_DIR} /tmp/device-tests.tar.gz
EOF
ssh jkci "cat /tmp/device-tests.json" > device-tests.json
- name: Set up Golang
uses: actions/setup-go@v5.5.0
with:
go-version: "1.24.4"
- name: Golang Test Report
uses: becheran/go-testreport@v0.3.2
with:
input: "device-tests.json"
- name: Deploy application
run: |
set -e
# Copy the binary to the remote host
echo "+ Copying the application to the remote host"
cat bin/jetkvm_app | gzip | ssh jkci "cat > /userdata/jetkvm/jetkvm_app.update.gz"
# Deploy and run the application on the remote host
echo "+ Deploying the application on the remote host"
ssh jkci ash <<EOF
# Extract the binary
gzip -d /userdata/jetkvm/jetkvm_app.update.gz
# Flush filesystem buffers to ensure all data is written to disk
sync
# Clear the filesystem caches to force a read from disk
echo 1 > /proc/sys/vm/drop_caches
# Reboot the application
reboot -d 5 -f &
EOF
sleep 10
echo "Deployment complete, waiting for JetKVM to come back online "
function check_online() {
for i in {1..60}; do
if ping -c1 -w1 -W1 -q $CI_HOST >/dev/null; then
echo "JetKVM is back online"
return 0
fi
echo -n "."
sleep 1
done
echo "JetKVM did not come back online within 60 seconds"
return 1
}
check_online
env:
CI_HOST: ${{ vars.JETKVM_CI_HOST }}
- name: Run smoke tests
run: |
echo "+ Checking the status of the device"
curl -v http://$CI_HOST/device/status && echo
echo "+ Waiting for 15 seconds to allow all services to start"
sleep 15
echo "+ Collecting logs"
local_log_tar=$(mktemp)
ssh jkci ash > $local_log_tar <<'EOF'
log_path=$(mktemp -d)
dmesg > $log_path/dmesg.log
cp /userdata/jetkvm/last.log $log_path/last.log
tar -czf - -C $log_path .
EOF
tar -xf $local_log_tar
cat dmesg.log last.log
env:
CI_HOST: ${{ vars.JETKVM_CI_HOST }}
- name: Upload logs
uses: actions/upload-artifact@v4
with:
name: device-logs
path: |
last.log
dmesg.log
device-tests.json

View File

@ -14,16 +14,16 @@ permissions:
jobs:
ui-lint:
name: UI Lint
runs-on: buildjet-4vcpu-ubuntu-2204
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: v21.1.0
node-version: "22"
cache: "npm"
cache-dependency-path: "ui/package-lock.json"
cache-dependency-path: "**/package-lock.json"
- name: Install dependencies
run: |
cd ui

3
.gitignore vendored
View File

@ -1,3 +1,6 @@
bin/*
static/*
.idea
.DS_Store
device-tests.tar.gz

View File

@ -1,12 +1,44 @@
---
version: "2"
linters:
enable:
# - goimports
# - misspell
# - revive
issues:
exclude-rules:
- path: _test.go
linters:
- errcheck
- forbidigo
- misspell
- whitespace
- gochecknoinits
settings:
forbidigo:
forbid:
- pattern: ^fmt\.Print.*$
msg: Do not commit print statements. Use logger package.
- pattern: ^log\.(Fatal|Panic|Print)(f|ln)?.*$
msg: Do not commit log statements. Use logger package.
exclusions:
generated: lax
presets:
- comments
- common-false-positives
- legacy
- std-error-handling
rules:
- linters:
- errcheck
path: _test.go
- linters:
- forbidigo
path: cmd/main.go
- linters:
- gochecknoinits
path: internal/logging/sse.go
paths:
- third_party$
- builtin$
- examples$
formatters:
enable:
- goimports
exclusions:
generated: lax
paths:
- third_party$
- builtin$
- examples$

3
.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,3 @@
{
"tailwindCSS.classFunctions": ["cva", "cx"]
}

355
DEVELOPMENT.md Normal file
View File

@ -0,0 +1,355 @@
<div align="center">
<img alt="JetKVM logo" src="https://jetkvm.com/logo-blue.png" height="28">
### Development Guide
[Discord](https://jetkvm.com/discord) | [Website](https://jetkvm.com) | [Issues](https://github.com/jetkvm/cloud-api/issues) | [Docs](https://jetkvm.com/docs)
[![Twitter](https://img.shields.io/twitter/url/https/twitter.com/jetkvm.svg?style=social&label=Follow%20%40JetKVM)](https://twitter.com/jetkvm)
[![Go Report Card](https://goreportcard.com/badge/github.com/jetkvm/kvm)](https://goreportcard.com/report/github.com/jetkvm/kvm)
</div>
# JetKVM Development Guide
Welcome to JetKVM development! This guide will help you get started quickly, whether you're fixing bugs, adding features, or just exploring the codebase.
## Get Started
### Prerequisites
- **A JetKVM device** (for full development)
- **[Go 1.24.4+](https://go.dev/doc/install)** and **[Node.js 22.15.0](https://nodejs.org/en/download/)**
- **[Git](https://git-scm.com/downloads)** for version control
- **[SSH access](https://jetkvm.com/docs/advanced-usage/developing#developer-mode)** to your JetKVM device
### Development Environment
**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)
This ensures compatibility with shell scripts and build tools used in the project.
### 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
```
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
```
5. **Open in browser:** `http://192.168.1.100`
That's it! You're now running your own development version of JetKVM.
---
## Common Tasks
### Modify the UI
```bash
cd ui
npm install
./dev_device.sh 192.168.1.100 # Replace with your device IP
```
Now edit files in `ui/src/` and see changes live in your browser!
### Modify the backend
```bash
# Edit Go files (config.go, web.go, etc.)
./dev_deploy.sh -r 192.168.1.100 --skip-ui-build
```
### Run tests
```bash
./dev_deploy.sh -r 192.168.1.100 --run-go-tests
```
### View logs
```bash
ssh root@192.168.1.100
tail -f /var/log/jetkvm.log
```
---
## Project Layout
```
/kvm/
├── main.go # App entry point
├── config.go # Settings & configuration
├── web.go # API endpoints
├── ui/ # React frontend
│ ├── src/routes/ # Pages (login, settings, etc.)
│ └── src/components/ # UI components
└── internal/ # Internal Go packages
```
**Key files for beginners:**
- `web.go` - Add new API endpoints here
- `config.go` - Add new settings here
- `ui/src/routes/` - Add new pages here
- `ui/src/components/` - Add new UI components here
---
## Development Modes
### Full Development (Recommended)
*Best for: Complete feature development*
```bash
# Deploy everything to your JetKVM device
./dev_deploy.sh -r <YOUR_DEVICE_IP>
```
### Frontend Only
*Best for: UI changes without device*
```bash
cd ui
npm install
./dev_device.sh <YOUR_DEVICE_IP>
```
### Quick Backend Changes
*Best for: API or backend logic changes*
```bash
# Skip frontend build for faster deployment
./dev_deploy.sh -r <YOUR_DEVICE_IP> --skip-ui-build
```
---
## Debugging Made Easy
### Check if everything is working
```bash
# Test connection to device
ping 192.168.1.100
# Check if JetKVM is running
ssh root@192.168.1.100 ps aux | grep jetkvm
```
### View live logs
```bash
ssh root@192.168.1.100
tail -f /var/log/jetkvm.log
```
### Reset everything (if stuck)
```bash
ssh root@192.168.1.100
rm /userdata/kvm_config.json
systemctl restart jetkvm
```
---
## Testing Your Changes
### Manual Testing
1. Deploy your changes: `./dev_deploy.sh -r <IP>`
2. Open browser: `http://<IP>`
3. Test your feature
4. Check logs: `ssh root@<IP> tail -f /var/log/jetkvm.log`
### Automated Testing
```bash
# Run all tests
./dev_deploy.sh -r <IP> --run-go-tests
# Frontend linting
cd ui && npm run lint
```
### API Testing
```bash
# Test login endpoint
curl -X POST http://<IP>/auth/password-local \
-H "Content-Type: application/json" \
-d '{"password": "test123"}'
```
---
## Common Issues & Solutions
### "Build failed" or "Permission denied"
```bash
# Fix permissions
ssh root@<IP> chmod +x /userdata/jetkvm/bin/jetkvm_app_debug
# Clean and rebuild
go clean -modcache
go mod tidy
make build_dev
```
### "Can't connect to device"
```bash
# Check network
ping <IP>
# Check SSH
ssh root@<IP> echo "Connection OK"
```
### "Frontend not updating"
```bash
# Clear cache and rebuild
cd ui
npm cache clean --force
rm -rf node_modules
npm install
```
---
## Next Steps
### Adding a New Feature
1. **Backend:** Add API endpoint in `web.go`
2. **Config:** Add settings in `config.go`
3. **Frontend:** Add UI in `ui/src/routes/`
4. **Test:** Deploy and test with `./dev_deploy.sh`
### Code Style
- **Go:** Follow standard Go conventions
- **TypeScript:** Use TypeScript for type safety
- **React:** Keep components small and reusable
### Environment Variables
```bash
# Enable debug logging
export LOG_TRACE_SCOPES="jetkvm,cloud,websocket,native,jsonrpc"
# Frontend development
export JETKVM_PROXY_URL="ws://<IP>"
```
---
## Need Help?
1. **Check logs first:** `ssh root@<IP> tail -f /var/log/jetkvm.log`
2. **Search issues:** [GitHub Issues](https://github.com/jetkvm/kvm/issues)
3. **Ask on Discord:** [JetKVM Discord](https://jetkvm.com/discord)
4. **Read docs:** [JetKVM Documentation](https://jetkvm.com/docs)
---
## Contributing
### Ready to contribute?
1. Fork the repository
2. Create a feature branch
3. Make your changes
4. Test thoroughly
5. Submit a pull request
### Before submitting:
- [ ] Code works on device
- [ ] Tests pass
- [ ] Code follows style guidelines
- [ ] Documentation updated (if needed)
---
## Advanced Topics
### Performance Profiling
```bash
# Enable profiling
go build -o bin/jetkvm_app -ldflags="-X main.enableProfiling=true" cmd/main.go
# Access profiling
curl http://<IP>:6060/debug/pprof/
```
### Advanced Environment Variables
```bash
# Enable trace logging (useful for debugging)
export LOG_TRACE_SCOPES="jetkvm,cloud,websocket,native,jsonrpc"
# For frontend development
export JETKVM_PROXY_URL="ws://<JETKVM_IP>"
# Enable SSL in development
export USE_SSL=true
```
### Configuration Management
The application uses a JSON configuration file stored at `/userdata/kvm_config.json`.
#### Adding New Configuration Options
1. **Update the Config struct in `config.go`:**
```go
type Config struct {
// ... existing fields
NewFeatureEnabled bool `json:"new_feature_enabled"`
}
```
2. **Update the default configuration:**
```go
var defaultConfig = &Config{
// ... existing defaults
NewFeatureEnabled: false,
}
```
3. **Add migration logic if needed for existing installations**
---
**Happy coding!**
For more information, visit the [JetKVM Documentation](https://jetkvm.com/docs) or join our [Discord Server](https://jetkvm.com/discord).

View File

@ -2,12 +2,14 @@ BRANCH ?= $(shell git rev-parse --abbrev-ref HEAD)
BUILDDATE ?= $(shell date -u +%FT%T%z)
BUILDTS ?= $(shell date -u +%s)
REVISION ?= $(shell git rev-parse HEAD)
VERSION_DEV := 0.3.9-dev$(shell date +%Y%m%d%H%M)
VERSION := 0.3.8
VERSION_DEV ?= 0.4.7-dev$(shell date +%Y%m%d%H%M)
VERSION ?= 0.4.6
PROMETHEUS_TAG := github.com/prometheus/common/version
KVM_PKG_NAME := github.com/jetkvm/kvm
GO_BUILD_ARGS := -tags netgo
GO_RELEASE_BUILD_ARGS := -trimpath $(GO_BUILD_ARGS)
GO_LDFLAGS := \
-s -w \
-X $(PROMETHEUS_TAG).Branch=$(BRANCH) \
@ -15,12 +17,49 @@ GO_LDFLAGS := \
-X $(PROMETHEUS_TAG).Revision=$(REVISION) \
-X $(KVM_PKG_NAME).builtTimestamp=$(BUILDTS)
GO_CMD := GOOS=linux GOARCH=arm GOARM=7 go
BIN_DIR := $(shell pwd)/bin
TEST_DIRS := $(shell find . -name "*_test.go" -type f -exec dirname {} \; | sort -u)
hash_resource:
@shasum -a 256 resource/jetkvm_native | cut -d ' ' -f 1 > resource/jetkvm_native.sha256
build_dev: hash_resource
@echo "Building..."
GOOS=linux GOARCH=arm GOARM=7 go build -ldflags="$(GO_LDFLAGS) -X $(KVM_PKG_NAME).builtAppVersion=$(VERSION_DEV)" -o bin/jetkvm_app cmd/main.go
$(GO_CMD) build \
-ldflags="$(GO_LDFLAGS) -X $(KVM_PKG_NAME).builtAppVersion=$(VERSION_DEV)" \
$(GO_RELEASE_BUILD_ARGS) \
-o $(BIN_DIR)/jetkvm_app cmd/main.go
build_test2json:
$(GO_CMD) build -o $(BIN_DIR)/test2json cmd/test2json
build_gotestsum:
@echo "Building gotestsum..."
$(GO_CMD) install gotest.tools/gotestsum@latest
cp $(shell $(GO_CMD) env GOPATH)/bin/linux_arm/gotestsum $(BIN_DIR)/gotestsum
build_dev_test: build_test2json build_gotestsum
# collect all directories that contain tests
@echo "Building tests for devices ..."
@rm -rf $(BIN_DIR)/tests && mkdir -p $(BIN_DIR)/tests
@cat resource/dev_test.sh > $(BIN_DIR)/tests/run_all_tests
@for test in $(TEST_DIRS); do \
test_pkg_name=$$(echo $$test | sed 's/^.\///g'); \
test_pkg_full_name=$(KVM_PKG_NAME)/$$(echo $$test | sed 's/^.\///g'); \
test_filename=$$(echo $$test_pkg_name | sed 's/\//__/g')_test; \
$(GO_CMD) test -v \
-ldflags="$(GO_LDFLAGS) -X $(KVM_PKG_NAME).builtAppVersion=$(VERSION_DEV)" \
$(GO_BUILD_ARGS) \
-c -o $(BIN_DIR)/tests/$$test_filename $$test; \
echo "runTest ./$$test_filename $$test_pkg_full_name" >> $(BIN_DIR)/tests/run_all_tests; \
done; \
chmod +x $(BIN_DIR)/tests/run_all_tests; \
cp $(BIN_DIR)/test2json $(BIN_DIR)/tests/ && chmod +x $(BIN_DIR)/tests/test2json; \
cp $(BIN_DIR)/gotestsum $(BIN_DIR)/tests/ && chmod +x $(BIN_DIR)/tests/gotestsum; \
tar czfv device-tests.tar.gz -C $(BIN_DIR)/tests .
frontend:
cd ui && npm ci && npm run build:device
@ -33,7 +72,10 @@ dev_release: frontend build_dev
build_release: frontend hash_resource
@echo "Building release..."
GOOS=linux GOARCH=arm GOARM=7 go build -ldflags="$(GO_LDFLAGS) -X $(KVM_PKG_NAME).builtAppVersion=$(VERSION)" -o bin/jetkvm_app cmd/main.go
$(GO_CMD) build \
-ldflags="$(GO_LDFLAGS) -X $(KVM_PKG_NAME).builtAppVersion=$(VERSION)" \
$(GO_RELEASE_BUILD_ARGS) \
-o bin/jetkvm_app cmd/main.go
release:
@if rclone lsf r2://jetkvm-update/app/$(VERSION)/ | grep -q "jetkvm_app"; then \

View File

@ -7,6 +7,8 @@
[![Twitter](https://img.shields.io/twitter/url/https/twitter.com/jetkvm.svg?style=social&label=Follow%20%40JetKVM)](https://twitter.com/jetkvm)
[![Go Report Card](https://goreportcard.com/badge/github.com/jetkvm/kvm)](https://goreportcard.com/report/github.com/jetkvm/kvm)
</div>
JetKVM is a high-performance, open-source KVM over IP (Keyboard, Video, Mouse) solution designed for efficient remote management of computers, servers, and workstations. Whether you're dealing with boot failures, installing a new operating system, adjusting BIOS settings, or simply taking control of a machine from afar, JetKVM provides the tools to get it done effectively.
@ -23,7 +25,7 @@ We welcome contributions from the community! Whether it's improving the firmware
## I need help
The best place to search for answers is our [Documentation](https://jetkvm.com/docs). If you can't find the answer there, check our [Discord Server](https://discord.gg/8MaAhua7NW).
The best place to search for answers is our [Documentation](https://jetkvm.com/docs). If you can't find the answer there, check our [Discord Server](https://jetkvm.com/discord).
## I want to report an issue
@ -35,7 +37,9 @@ JetKVM is written in Go & TypeScript. with some bits and pieces written in C. An
The project contains two main parts, the backend software that runs on the KVM device and the frontend software that is served by the KVM device, and also the cloud.
For most of local device development, all you need is to use the `./dev_deploy.sh` script. It will build the frontend and backend and deploy them to the local KVM device. Run `./dev_deploy.sh --help` for more information.
For comprehensive development information, including setup, testing, debugging, and contribution guidelines, see **[DEVELOPMENT.md](DEVELOPMENT.md)**.
For quick device development, use the `./dev_deploy.sh` script. It will build the frontend and backend and deploy them to the local KVM device. Run `./dev_deploy.sh --help` for more information.
## Backend

View File

@ -7,8 +7,8 @@ import (
"os"
"time"
"github.com/pojntfx/go-nbd/pkg/client"
"github.com/pojntfx/go-nbd/pkg/server"
"github.com/rs/zerolog"
)
type remoteImageBackend struct {
@ -16,8 +16,8 @@ type remoteImageBackend struct {
func (r remoteImageBackend) ReadAt(p []byte, off int64) (n int, err error) {
virtualMediaStateMutex.RLock()
logger.Debugf("currentVirtualMediaState is %v", currentVirtualMediaState)
logger.Debugf("read size: %d, off: %d", len(p), off)
logger.Debug().Interface("currentVirtualMediaState", currentVirtualMediaState).Msg("currentVirtualMediaState")
logger.Debug().Int64("read size", int64(len(p))).Int64("off", off).Msg("read size and off")
if currentVirtualMediaState == nil {
return 0, errors.New("image not mounted")
}
@ -33,16 +33,17 @@ func (r remoteImageBackend) ReadAt(p []byte, off int64) (n int, err error) {
readLen = mountedImageSize - off
}
var data []byte
if source == WebRTC {
switch source {
case WebRTC:
data, err = webRTCDiskReader.Read(ctx, off, readLen)
if err != nil {
return 0, err
}
n = copy(p, data)
return n, nil
} else if source == HTTP {
case HTTP:
return httpRangeReader.ReadAt(p, off)
} else {
default:
return 0, errors.New("unknown image source")
}
}
@ -72,6 +73,8 @@ type NBDDevice struct {
serverConn net.Conn
clientConn net.Conn
dev *os.File
l *zerolog.Logger
}
func NewNBDDevice() *NBDDevice {
@ -90,10 +93,18 @@ func (d *NBDDevice) Start() error {
return err
}
if d.l == nil {
scopedLogger := nbdLogger.With().
Str("socket_path", nbdSocketPath).
Str("device_path", nbdDevicePath).
Logger()
d.l = &scopedLogger
}
// Remove the socket file if it already exists
if _, err := os.Stat(nbdSocketPath); err == nil {
if err := os.Remove(nbdSocketPath); err != nil {
logger.Errorf("Failed to remove existing socket file %s: %v", nbdSocketPath, err)
d.l.Error().Err(err).Msg("failed to remove existing socket file")
os.Exit(1)
}
}
@ -134,32 +145,6 @@ func (d *NBDDevice) runServerConn() {
MaximumBlockSize: uint32(16 * 1024),
SupportsMultiConn: false,
})
logger.Infof("nbd server exited: %v", err)
}
func (d *NBDDevice) runClientConn() {
err := client.Connect(d.clientConn, d.dev, &client.Options{
ExportName: "jetkvm",
BlockSize: uint32(4 * 1024),
})
logger.Infof("nbd client exited: %v", err)
}
func (d *NBDDevice) Close() {
if d.dev != nil {
err := client.Disconnect(d.dev)
if err != nil {
logger.Warnf("error disconnecting nbd client: %v", err)
}
_ = d.dev.Close()
}
if d.listener != nil {
_ = d.listener.Close()
}
if d.clientConn != nil {
_ = d.clientConn.Close()
}
if d.serverConn != nil {
_ = d.serverConn.Close()
}
d.l.Info().Err(err).Msg("nbd server exited")
}

34
block_device_linux.go Normal file
View File

@ -0,0 +1,34 @@
//go:build linux
package kvm
import (
"github.com/pojntfx/go-nbd/pkg/client"
)
func (d *NBDDevice) runClientConn() {
err := client.Connect(d.clientConn, d.dev, &client.Options{
ExportName: "jetkvm",
BlockSize: uint32(4 * 1024),
})
d.l.Info().Err(err).Msg("nbd client exited")
}
func (d *NBDDevice) Close() {
if d.dev != nil {
err := client.Disconnect(d.dev)
if err != nil {
d.l.Warn().Err(err).Msg("error disconnecting nbd client")
}
_ = d.dev.Close()
}
if d.listener != nil {
_ = d.listener.Close()
}
if d.clientConn != nil {
_ = d.clientConn.Close()
}
if d.serverConn != nil {
_ = d.serverConn.Close()
}
}

17
block_device_notlinux.go Normal file
View File

@ -0,0 +1,17 @@
//go:build !linux
package kvm
import (
"os"
)
func (d *NBDDevice) runClientConn() {
d.l.Error().Msg("platform not supported")
os.Exit(1)
}
func (d *NBDDevice) Close() {
d.l.Error().Msg("platform not supported")
os.Exit(1)
}

362
cloud.go
View File

@ -4,17 +4,23 @@ import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
"sync"
"time"
"github.com/coder/websocket/wsjson"
"github.com/google/uuid"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
"github.com/coreos/go-oidc/v3/oidc"
"github.com/coder/websocket"
"github.com/gin-gonic/gin"
"github.com/rs/zerolog"
)
type CloudRegisterRequest struct {
@ -32,10 +38,163 @@ const (
// CloudOidcRequestTimeout is the timeout for OIDC token verification requests
// should be lower than the websocket response timeout set in cloud-api
CloudOidcRequestTimeout = 10 * time.Second
// CloudWebSocketPingInterval is the interval at which the websocket client sends ping messages to the cloud
CloudWebSocketPingInterval = 15 * time.Second
// WebsocketPingInterval is the interval at which the websocket client sends ping messages to the cloud
WebsocketPingInterval = 15 * time.Second
)
var (
metricCloudConnectionStatus = promauto.NewGauge(
prometheus.GaugeOpts{
Name: "jetkvm_cloud_connection_status",
Help: "The status of the cloud connection",
},
)
metricCloudConnectionEstablishedTimestamp = promauto.NewGauge(
prometheus.GaugeOpts{
Name: "jetkvm_cloud_connection_established_timestamp_seconds",
Help: "The timestamp when the cloud connection was established",
},
)
metricConnectionLastPingTimestamp = promauto.NewGaugeVec(
prometheus.GaugeOpts{
Name: "jetkvm_connection_last_ping_timestamp_seconds",
Help: "The timestamp when the last ping response was received",
},
[]string{"type", "source"},
)
metricConnectionLastPingReceivedTimestamp = promauto.NewGaugeVec(
prometheus.GaugeOpts{
Name: "jetkvm_connection_last_ping_received_timestamp_seconds",
Help: "The timestamp when the last ping request was received",
},
[]string{"type", "source"},
)
metricConnectionLastPingDuration = promauto.NewGaugeVec(
prometheus.GaugeOpts{
Name: "jetkvm_connection_last_ping_duration_seconds",
Help: "The duration of the last ping response",
},
[]string{"type", "source"},
)
metricConnectionPingDuration = promauto.NewHistogramVec(
prometheus.HistogramOpts{
Name: "jetkvm_connection_ping_duration_seconds",
Help: "The duration of the ping response",
Buckets: []float64{
0.1, 0.5, 1, 10,
},
},
[]string{"type", "source"},
)
metricConnectionTotalPingSentCount = promauto.NewCounterVec(
prometheus.CounterOpts{
Name: "jetkvm_connection_ping_sent_total",
Help: "The total number of pings sent to the connection",
},
[]string{"type", "source"},
)
metricConnectionTotalPingReceivedCount = promauto.NewCounterVec(
prometheus.CounterOpts{
Name: "jetkvm_connection_ping_received_total",
Help: "The total number of pings received from the connection",
},
[]string{"type", "source"},
)
metricConnectionSessionRequestCount = promauto.NewCounterVec(
prometheus.CounterOpts{
Name: "jetkvm_connection_session_requests_total",
Help: "The total number of session requests received",
},
[]string{"type", "source"},
)
metricConnectionSessionRequestDuration = promauto.NewHistogramVec(
prometheus.HistogramOpts{
Name: "jetkvm_connection_session_request_duration_seconds",
Help: "The duration of session requests",
Buckets: []float64{
0.1, 0.5, 1, 10,
},
},
[]string{"type", "source"},
)
metricConnectionLastSessionRequestTimestamp = promauto.NewGaugeVec(
prometheus.GaugeOpts{
Name: "jetkvm_connection_last_session_request_timestamp_seconds",
Help: "The timestamp of the last session request",
},
[]string{"type", "source"},
)
metricConnectionLastSessionRequestDuration = promauto.NewGaugeVec(
prometheus.GaugeOpts{
Name: "jetkvm_connection_last_session_request_duration",
Help: "The duration of the last session request",
},
[]string{"type", "source"},
)
metricCloudConnectionFailureCount = promauto.NewCounter(
prometheus.CounterOpts{
Name: "jetkvm_cloud_connection_failure_total",
Help: "The number of times the cloud connection has failed",
},
)
)
type CloudConnectionState uint8
const (
CloudConnectionStateNotConfigured CloudConnectionState = iota
CloudConnectionStateDisconnected
CloudConnectionStateConnecting
CloudConnectionStateConnected
)
var (
cloudConnectionState CloudConnectionState = CloudConnectionStateNotConfigured
cloudConnectionStateLock = &sync.Mutex{}
cloudDisconnectChan chan error
cloudDisconnectLock = &sync.Mutex{}
)
func setCloudConnectionState(state CloudConnectionState) {
cloudConnectionStateLock.Lock()
defer cloudConnectionStateLock.Unlock()
if cloudConnectionState == CloudConnectionStateDisconnected &&
(config.CloudToken == "" || config.CloudURL == "") {
state = CloudConnectionStateNotConfigured
}
previousState := cloudConnectionState
cloudConnectionState = state
go waitCtrlAndRequestDisplayUpdate(
previousState != state,
)
}
func wsResetMetrics(established bool, sourceType string, source string) {
metricConnectionLastPingTimestamp.WithLabelValues(sourceType, source).Set(-1)
metricConnectionLastPingDuration.WithLabelValues(sourceType, source).Set(-1)
metricConnectionLastPingReceivedTimestamp.WithLabelValues(sourceType, source).Set(-1)
metricConnectionLastSessionRequestTimestamp.WithLabelValues(sourceType, source).Set(-1)
metricConnectionLastSessionRequestDuration.WithLabelValues(sourceType, source).Set(-1)
if sourceType != "cloud" {
return
}
if established {
metricCloudConnectionEstablishedTimestamp.SetToCurrentTime()
metricCloudConnectionStatus.Set(1)
} else {
metricCloudConnectionEstablishedTimestamp.Set(-1)
metricCloudConnectionStatus.Set(-1)
}
}
func handleCloudRegister(c *gin.Context) {
var req CloudRegisterRequest
@ -90,11 +249,6 @@ func handleCloudRegister(c *gin.Context) {
return
}
if config.CloudToken == "" {
cloudLogger.Info("Starting websocket client due to adoption")
go RunWebsocketClient()
}
config.CloudToken = tokenResp.SecretToken
provider, err := oidc.NewProvider(c, "https://accounts.google.com")
@ -125,74 +279,116 @@ func handleCloudRegister(c *gin.Context) {
c.JSON(200, gin.H{"message": "Cloud registration successful"})
}
func disconnectCloud(reason error) {
cloudDisconnectLock.Lock()
defer cloudDisconnectLock.Unlock()
if cloudDisconnectChan == nil {
cloudLogger.Trace().Msg("cloud disconnect channel is not set, no need to disconnect")
return
}
// just in case the channel is closed, we don't want to panic
defer func() {
if r := recover(); r != nil {
cloudLogger.Warn().Interface("reason", r).Msg("cloud disconnect channel is closed, no need to disconnect")
}
}()
cloudDisconnectChan <- reason
}
func runWebsocketClient() error {
if config.CloudToken == "" {
time.Sleep(5 * time.Second)
return fmt.Errorf("cloud token is not set")
}
wsURL, err := url.Parse(config.CloudURL)
if err != nil {
return fmt.Errorf("failed to parse config.CloudURL: %w", err)
}
if wsURL.Scheme == "http" {
wsURL.Scheme = "ws"
} else {
wsURL.Scheme = "wss"
}
setCloudConnectionState(CloudConnectionStateConnecting)
header := http.Header{}
header.Set("X-Device-ID", GetDeviceID())
header.Set("X-App-Version", builtAppVersion)
header.Set("Authorization", "Bearer "+config.CloudToken)
dialCtx, cancelDial := context.WithTimeout(context.Background(), CloudWebSocketConnectTimeout)
l := websocketLogger.With().
Str("source", wsURL.Host).
Str("sourceType", "cloud").
Logger()
scopedLogger := &l
defer cancelDial()
c, _, err := websocket.Dial(dialCtx, wsURL.String(), &websocket.DialOptions{
c, resp, err := websocket.Dial(dialCtx, wsURL.String(), &websocket.DialOptions{
HTTPHeader: header,
OnPingReceived: func(ctx context.Context, payload []byte) bool {
scopedLogger.Debug().Bytes("payload", payload).Int("length", len(payload)).Msg("ping frame received")
metricConnectionTotalPingReceivedCount.WithLabelValues("cloud", wsURL.Host).Inc()
metricConnectionLastPingReceivedTimestamp.WithLabelValues("cloud", wsURL.Host).SetToCurrentTime()
setCloudConnectionState(CloudConnectionStateConnected)
return true
},
})
var connectionId string
if resp != nil {
// get the request id from the response header
connectionId = resp.Header.Get("X-Request-ID")
if connectionId == "" {
connectionId = resp.Header.Get("Cf-Ray")
}
}
if connectionId == "" {
connectionId = uuid.New().String()
scopedLogger.Warn().
Str("connectionId", connectionId).
Msg("no connection id received from the server, generating a new one")
}
lWithConnectionId := scopedLogger.With().
Str("connectionID", connectionId).
Logger()
scopedLogger = &lWithConnectionId
// if the context is canceled, we don't want to return an error
if err != nil {
if errors.Is(err, context.Canceled) {
cloudLogger.Info().Msg("websocket connection canceled")
setCloudConnectionState(CloudConnectionStateDisconnected)
return nil
}
return err
}
defer c.CloseNow() //nolint:errcheck
cloudLogger.Infof("websocket connected to %s", wsURL)
runCtx, cancelRun := context.WithCancel(context.Background())
defer cancelRun()
go func() {
for {
time.Sleep(CloudWebSocketPingInterval)
err := c.Ping(runCtx)
if err != nil {
cloudLogger.Warnf("websocket ping error: %v", err)
cancelRun()
return
}
}
}()
for {
typ, msg, err := c.Read(runCtx)
if err != nil {
return err
}
if typ != websocket.MessageText {
// ignore non-text messages
continue
}
var req WebRTCSessionRequest
err = json.Unmarshal(msg, &req)
if err != nil {
cloudLogger.Warnf("unable to parse ws message: %v", string(msg))
continue
}
cloudLogger.Info().
Str("url", wsURL.String()).
Str("connectionID", connectionId).
Msg("websocket connected")
cloudLogger.Infof("new session request: %v", req.OidcGoogle)
cloudLogger.Tracef("session request info: %v", req)
// set the metrics when we successfully connect to the cloud.
wsResetMetrics(true, "cloud", wsURL.Host)
err = handleSessionRequest(runCtx, c, req)
if err != nil {
cloudLogger.Infof("error starting new session: %v", err)
continue
}
}
// we don't have a source for the cloud connection
return handleWebRTCSignalWsMessages(c, true, wsURL.Host, connectionId, scopedLogger)
}
func handleSessionRequest(ctx context.Context, c *websocket.Conn, req WebRTCSessionRequest) error {
func authenticateSession(ctx context.Context, c *websocket.Conn, req WebRTCSessionRequest) error {
oidcCtx, cancelOIDC := context.WithTimeout(ctx, CloudOidcRequestTimeout)
defer cancelOIDC()
provider, err := oidc.NewProvider(oidcCtx, "https://accounts.google.com")
@ -200,7 +396,7 @@ func handleSessionRequest(ctx context.Context, c *websocket.Conn, req WebRTCSess
_ = wsjson.Write(context.Background(), c, gin.H{
"error": fmt.Sprintf("failed to initialize OIDC provider: %v", err),
})
cloudLogger.Errorf("failed to initialize OIDC provider: %v", err)
cloudLogger.Warn().Err(err).Msg("failed to initialize OIDC provider")
return err
}
@ -220,10 +416,43 @@ func handleSessionRequest(ctx context.Context, c *websocket.Conn, req WebRTCSess
return fmt.Errorf("google identity mismatch")
}
return nil
}
func handleSessionRequest(
ctx context.Context,
c *websocket.Conn,
req WebRTCSessionRequest,
isCloudConnection bool,
source string,
scopedLogger *zerolog.Logger,
) error {
var sourceType string
if isCloudConnection {
sourceType = "cloud"
} else {
sourceType = "local"
}
timer := prometheus.NewTimer(prometheus.ObserverFunc(func(v float64) {
metricConnectionLastSessionRequestDuration.WithLabelValues(sourceType, source).Set(v)
metricConnectionSessionRequestDuration.WithLabelValues(sourceType, source).Observe(v)
}))
defer timer.ObserveDuration()
// If the message is from the cloud, we need to authenticate the session.
if isCloudConnection {
if err := authenticateSession(ctx, c, req); err != nil {
return err
}
}
session, err := newSession(SessionConfig{
ICEServers: req.ICEServers,
ws: c,
IsCloud: isCloudConnection,
LocalIP: req.IP,
IsCloud: true,
ICEServers: req.ICEServers,
Logger: scopedLogger,
})
if err != nil {
_ = wsjson.Write(context.Background(), c, gin.H{"error": err})
@ -244,18 +473,40 @@ func handleSessionRequest(ctx context.Context, c *websocket.Conn, req WebRTCSess
}()
}
cloudLogger.Info("new session accepted")
cloudLogger.Tracef("new session accepted: %v", session)
cloudLogger.Info().Interface("session", session).Msg("new session accepted")
cloudLogger.Trace().Interface("session", session).Msg("new session accepted")
currentSession = session
_ = wsjson.Write(context.Background(), c, gin.H{"sd": sd})
_ = wsjson.Write(context.Background(), c, gin.H{"type": "answer", "data": sd})
return nil
}
func RunWebsocketClient() {
for {
// If the cloud token is not set, we don't need to run the websocket client.
if config.CloudToken == "" {
time.Sleep(5 * time.Second)
continue
}
// If the network is not up, well, we can't connect to the cloud.
if !networkState.IsOnline() {
cloudLogger.Warn().Msg("waiting for network to be online, will retry in 3 seconds")
time.Sleep(3 * time.Second)
continue
}
// If the system time is not synchronized, the API request will fail anyway because the TLS handshake will fail.
if isTimeSyncNeeded() && !timeSync.IsSyncSuccess() {
cloudLogger.Warn().Msg("system time is not synced, will retry in 3 seconds")
time.Sleep(3 * time.Second)
continue
}
err := runWebsocketClient()
if err != nil {
cloudLogger.Errorf("websocket client error: %v", err)
cloudLogger.Warn().Err(err).Msg("websocket client error")
metricCloudConnectionStatus.Set(0)
metricCloudConnectionFailureCount.Inc()
time.Sleep(5 * time.Second)
}
}
@ -305,6 +556,11 @@ func rpcDeregisterDevice() error {
return fmt.Errorf("failed to save configuration after deregistering: %w", err)
}
cloudLogger.Info().Msg("device deregistered, disconnecting from cloud")
disconnectCloud(fmt.Errorf("device deregistered"))
setCloudConnectionState(CloudConnectionStateNotConfigured)
return nil
}

View File

@ -1,9 +1,27 @@
package main
import (
"flag"
"fmt"
"os"
"github.com/jetkvm/kvm"
)
func main() {
versionPtr := flag.Bool("version", false, "print version and exit")
versionJsonPtr := flag.Bool("version-json", false, "print version as json and exit")
flag.Parse()
if *versionPtr || *versionJsonPtr {
versionData, err := kvm.GetVersionData(*versionJsonPtr)
if err != nil {
fmt.Printf("failed to get version data: %v\n", err)
os.Exit(1)
}
fmt.Println(string(versionData))
return
}
kvm.Main()
}

150
config.go
View File

@ -6,7 +6,11 @@ import (
"os"
"sync"
"github.com/jetkvm/kvm/internal/logging"
"github.com/jetkvm/kvm/internal/network"
"github.com/jetkvm/kvm/internal/usbgadget"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
)
type WakeOnLanDevice struct {
@ -14,27 +18,91 @@ type WakeOnLanDevice struct {
MacAddress string `json:"macAddress"`
}
// Constants for keyboard macro limits
const (
MaxMacrosPerDevice = 25
MaxStepsPerMacro = 10
MaxKeysPerStep = 10
MinStepDelay = 50
MaxStepDelay = 2000
)
type KeyboardMacroStep struct {
Keys []string `json:"keys"`
Modifiers []string `json:"modifiers"`
Delay int `json:"delay"`
}
func (s *KeyboardMacroStep) Validate() error {
if len(s.Keys) > MaxKeysPerStep {
return fmt.Errorf("too many keys in step (max %d)", MaxKeysPerStep)
}
if s.Delay < MinStepDelay {
s.Delay = MinStepDelay
} else if s.Delay > MaxStepDelay {
s.Delay = MaxStepDelay
}
return nil
}
type KeyboardMacro struct {
ID string `json:"id"`
Name string `json:"name"`
Steps []KeyboardMacroStep `json:"steps"`
SortOrder int `json:"sortOrder,omitempty"`
}
func (m *KeyboardMacro) Validate() error {
if m.Name == "" {
return fmt.Errorf("macro name cannot be empty")
}
if len(m.Steps) == 0 {
return fmt.Errorf("macro must have at least one step")
}
if len(m.Steps) > MaxStepsPerMacro {
return fmt.Errorf("too many steps in macro (max %d)", MaxStepsPerMacro)
}
for i := range m.Steps {
if err := m.Steps[i].Validate(); err != nil {
return fmt.Errorf("invalid step %d: %w", i+1, err)
}
}
return nil
}
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
WakeOnLanDevices []WakeOnLanDevice `json:"wake_on_lan_devices"`
EdidString string `json:"hdmi_edid_string"`
ActiveExtension string `json:"active_extension"`
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"`
UsbConfig *usbgadget.Config `json:"usb_config"`
UsbDevices *usbgadget.Devices `json:"usb_devices"`
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"`
}
const configPath = "/userdata/kvm_config.json"
@ -44,6 +112,9 @@ var defaultConfig = &Config{
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
@ -66,6 +137,8 @@ var defaultConfig = &Config{
Keyboard: true,
MassStorage: true,
},
NetworkConfig: &network.NetworkConfig{},
DefaultLogLevel: "INFO",
}
var (
@ -73,12 +146,27 @@ var (
configLock = &sync.Mutex{}
)
var (
configSuccess = promauto.NewGauge(
prometheus.GaugeOpts{
Name: "jetkvm_config_last_reload_successful",
Help: "The last configuration load succeeded",
},
)
configSuccessTime = promauto.NewGauge(
prometheus.GaugeOpts{
Name: "jetkvm_config_last_reload_success_timestamp_seconds",
Help: "Timestamp of last successful config load",
},
)
)
func LoadConfig() {
configLock.Lock()
defer configLock.Unlock()
if config != nil {
logger.Info("config already loaded, skipping")
logger.Debug().Msg("config already loaded, skipping")
return
}
@ -87,7 +175,9 @@ func LoadConfig() {
file, err := os.Open(configPath)
if err != nil {
logger.Debug("default config file doesn't exist, using default")
logger.Debug().Msg("default config file doesn't exist, using default")
configSuccess.Set(1.0)
configSuccessTime.SetToCurrentTime()
return
}
defer file.Close()
@ -95,7 +185,8 @@ func LoadConfig() {
// load and merge the default config with the user config
loadedConfig := *defaultConfig
if err := json.NewDecoder(file).Decode(&loadedConfig); err != nil {
logger.Errorf("config file JSON parsing failed, %v", err)
logger.Warn().Err(err).Msg("config file JSON parsing failed")
configSuccess.Set(0.0)
return
}
@ -108,13 +199,26 @@ func LoadConfig() {
loadedConfig.UsbDevices = defaultConfig.UsbDevices
}
if loadedConfig.NetworkConfig == nil {
loadedConfig.NetworkConfig = defaultConfig.NetworkConfig
}
config = &loadedConfig
logging.GetRootLogger().UpdateLogLevel(config.DefaultLogLevel)
configSuccess.Set(1.0)
configSuccessTime.SetToCurrentTime()
logger.Info().Str("path", configPath).Msg("config loaded")
}
func SaveConfig() error {
configLock.Lock()
defer configLock.Unlock()
logger.Trace().Str("path", configPath).Msg("Saving config")
file, err := os.Create(configPath)
if err != nil {
return fmt.Errorf("failed to create config file: %w", err)

53
dc_metrics.go Normal file
View File

@ -0,0 +1,53 @@
package kvm
import (
"sync"
"github.com/prometheus/client_golang/prometheus"
)
var (
dcCurrentGauge = prometheus.NewGauge(prometheus.GaugeOpts{
Name: "jetkvm_dc_current_amperes",
Help: "Current DC power consumption in amperes",
})
dcPowerGauge = prometheus.NewGauge(prometheus.GaugeOpts{
Name: "jetkvm_dc_power_watts",
Help: "DC power consumption in watts",
})
dcVoltageGauge = prometheus.NewGauge(prometheus.GaugeOpts{
Name: "jetkvm_dc_voltage_volts",
Help: "DC voltage in volts",
})
dcStateGauge = prometheus.NewGauge(prometheus.GaugeOpts{
Name: "jetkvm_dc_power_state",
Help: "DC power state (1 = on, 0 = off)",
})
dcMetricsRegistered sync.Once
)
// registerDCMetrics registers the DC power metrics with Prometheus (called once when DC control is mounted)
func registerDCMetrics() {
dcMetricsRegistered.Do(func() {
prometheus.MustRegister(dcCurrentGauge)
prometheus.MustRegister(dcPowerGauge)
prometheus.MustRegister(dcVoltageGauge)
prometheus.MustRegister(dcStateGauge)
})
}
// updateDCMetrics updates the Prometheus metrics with current DC power state values
func updateDCMetrics(state DCPowerState) {
dcCurrentGauge.Set(state.Current)
dcPowerGauge.Set(state.Power)
dcVoltageGauge.Set(state.Voltage)
if state.IsOn {
dcStateGauge.Set(1)
} else {
dcStateGauge.Set(0)
}
}

View File

@ -1,6 +1,21 @@
#!/usr/bin/env bash
#
# Exit immediately if a command exits with a non-zero status
set -e
C_RST="$(tput sgr0)"
C_ERR="$(tput setaf 1)"
C_OK="$(tput setaf 2)"
C_WARN="$(tput setaf 3)"
C_INFO="$(tput setaf 5)"
msg() { printf '%s%s%s\n' $2 "$1" $C_RST; }
msg_info() { msg "$1" $C_INFO; }
msg_ok() { msg "$1" $C_OK; }
msg_err() { msg "$1" $C_ERR; }
msg_warn() { msg "$1" $C_WARN; }
# Function to display help message
show_help() {
echo "Usage: $0 [options] -r <remote_ip>"
@ -10,19 +25,26 @@ show_help() {
echo
echo "Optional:"
echo " -u, --user <remote_user> Remote username (default: root)"
echo " --run-go-tests Run go tests"
echo " --run-go-tests-only Run go tests and exit"
echo " --skip-ui-build Skip frontend/UI build"
echo " -i, --install Build for release and install the app"
echo " --help Display this help message"
echo
echo "Example:"
echo " $0 -r 192.168.0.17"
echo " $0 -r 192.168.0.17 -u admin"
exit 0
}
# Default values
REMOTE_USER="root"
REMOTE_PATH="/userdata/jetkvm/bin"
SKIP_UI_BUILD=false
RESET_USB_HID_DEVICE=false
LOG_TRACE_SCOPES="${LOG_TRACE_SCOPES:-jetkvm,cloud,websocket,native,jsonrpc}"
RUN_GO_TESTS=false
RUN_GO_TESTS_ONLY=false
INSTALL_APP=false
# Parse command line arguments
while [[ $# -gt 0 ]]; do
@ -39,6 +61,23 @@ while [[ $# -gt 0 ]]; do
SKIP_UI_BUILD=true
shift
;;
--reset-usb-hid)
RESET_USB_HID_DEVICE=true
shift
;;
--run-go-tests)
RUN_GO_TESTS=true
shift
;;
--run-go-tests-only)
RUN_GO_TESTS_ONLY=true
RUN_GO_TESTS=true
shift
;;
-i|--install)
INSTALL_APP=true
shift
;;
--help)
show_help
exit 0
@ -53,27 +92,89 @@ done
# Verify required parameters
if [ -z "$REMOTE_HOST" ]; then
echo "Error: Remote IP is a required parameter"
msg_err "Error: Remote IP is a required parameter"
show_help
exit 1
fi
# Build the development version on the host
if [ "$SKIP_UI_BUILD" = false ]; then
msg_info "▶ Building frontend"
make frontend
fi
make build_dev
# Change directory to the binary output directory
cd bin
if [ "$RUN_GO_TESTS" = true ]; then
msg_info "▶ Building go tests"
make build_dev_test
# Kill any existing instances of the application
ssh "${REMOTE_USER}@${REMOTE_HOST}" "killall jetkvm_app_debug || true"
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
# Copy the binary to the remote host
cat jetkvm_app | ssh "${REMOTE_USER}@${REMOTE_HOST}" "cat > $REMOTE_PATH/jetkvm_app_debug"
msg_info "▶ Running go tests"
ssh "${REMOTE_USER}@${REMOTE_HOST}" ash << 'EOF'
set -e
TMP_DIR=$(mktemp -d)
cd ${TMP_DIR}
tar zxf /tmp/device-tests.tar.gz
./gotestsum --format=testdox \
--jsonfile=/tmp/device-tests.json \
--post-run-command 'sh -c "echo $TESTS_FAILED > /tmp/device-tests.failed"' \
--raw-command -- ./run_all_tests -json
# Deploy and run the application on the remote host
ssh "${REMOTE_USER}@${REMOTE_HOST}" ash <<EOF
GOTESTSUM_EXIT_CODE=$?
if [ $GOTESTSUM_EXIT_CODE -ne 0 ]; then
echo "❌ Tests failed (exit code: $GOTESTSUM_EXIT_CODE)"
rm -rf ${TMP_DIR} /tmp/device-tests.tar.gz
exit 1
fi
TESTS_FAILED=$(cat /tmp/device-tests.failed)
if [ "$TESTS_FAILED" -ne 0 ]; then
echo "❌ Tests failed $TESTS_FAILED tests failed"
rm -rf ${TMP_DIR} /tmp/device-tests.tar.gz
exit 1
fi
echo "✅ Tests passed"
rm -rf ${TMP_DIR} /tmp/device-tests.tar.gz
EOF
if [ "$RUN_GO_TESTS_ONLY" = true ]; then
msg_info "▶ Go tests completed"
exit 0
fi
fi
if [ "$INSTALL_APP" = true ]
then
msg_info "▶ Building release binary"
make build_release
# Copy the binary to the remote host as if we were the OTA updater.
ssh "${REMOTE_USER}@${REMOTE_HOST}" "cat > /userdata/jetkvm/jetkvm_app.update" < bin/jetkvm_app
# Reboot the device, the new app will be deployed by the startup process.
ssh "${REMOTE_USER}@${REMOTE_HOST}" "reboot"
else
msg_info "▶ Building development binary"
make build_dev
# Kill any existing instances of the application
ssh "${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
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"
fi
# Deploy and run the application on the remote host
ssh "${REMOTE_USER}@${REMOTE_HOST}" ash << EOF
set -e
# Set the library path to include the directory where librockit.so is located
@ -84,13 +185,14 @@ killall jetkvm_app || true
killall jetkvm_app_debug || true
# Navigate to the directory where the binary will be stored
cd "$REMOTE_PATH"
cd "${REMOTE_PATH}"
# Make the new binary executable
chmod +x jetkvm_app_debug
# Run the application in the background
PION_LOG_TRACE=jetkvm,cloud ./jetkvm_app_debug
PION_LOG_TRACE=${LOG_TRACE_SCOPES} ./jetkvm_app_debug | tee -a /tmp/jetkvm_app_debug.log
EOF
fi
echo "Deployment complete."

View File

@ -5,12 +5,18 @@ import (
"fmt"
"os"
"strconv"
"sync"
"time"
)
var currentScreen = "ui_Boot_Screen"
var backlightState = 0 // 0 - NORMAL, 1 - DIMMED, 2 - OFF
var (
currentScreen = "ui_Boot_Screen"
displayedTexts = make(map[string]string)
screenStateLock = sync.Mutex{}
)
var (
dimTicker *time.Ticker
offTicker *time.Ticker
@ -21,73 +27,210 @@ const (
backlightControlClass string = "/sys/class/backlight/backlight/brightness"
)
// do not call this function directly, use switchToScreenIfDifferent instead
// this function is not thread safe
func switchToScreen(screen string) {
_, err := CallCtrlAction("lv_scr_load", map[string]interface{}{"obj": screen})
if err != nil {
logger.Warnf("failed to switch to screen %s: %v", screen, err)
displayLogger.Warn().Err(err).Str("screen", screen).Msg("failed to switch to screen")
return
}
currentScreen = screen
}
var displayedTexts = make(map[string]string)
func lvObjSetState(objName string, state string) (*CtrlResponse, error) {
return CallCtrlAction("lv_obj_set_state", map[string]interface{}{"obj": objName, "state": state})
}
func lvObjAddFlag(objName string, flag string) (*CtrlResponse, error) {
return CallCtrlAction("lv_obj_add_flag", map[string]interface{}{"obj": objName, "flag": flag})
}
func lvObjClearFlag(objName string, flag string) (*CtrlResponse, error) {
return CallCtrlAction("lv_obj_clear_flag", map[string]interface{}{"obj": objName, "flag": flag})
}
func lvObjHide(objName string) (*CtrlResponse, error) {
return lvObjAddFlag(objName, "LV_OBJ_FLAG_HIDDEN")
}
func lvObjShow(objName string) (*CtrlResponse, error) {
return lvObjClearFlag(objName, "LV_OBJ_FLAG_HIDDEN")
}
func lvObjSetOpacity(objName string, opacity int) (*CtrlResponse, error) { // nolint:unused
return CallCtrlAction("lv_obj_set_style_opa_layered", map[string]interface{}{"obj": objName, "opa": opacity})
}
func lvObjFadeIn(objName string, duration uint32) (*CtrlResponse, error) {
return CallCtrlAction("lv_obj_fade_in", map[string]interface{}{"obj": objName, "time": duration})
}
func lvObjFadeOut(objName string, duration uint32) (*CtrlResponse, error) {
return CallCtrlAction("lv_obj_fade_out", map[string]interface{}{"obj": objName, "time": duration})
}
func lvLabelSetText(objName string, text string) (*CtrlResponse, error) {
return CallCtrlAction("lv_label_set_text", map[string]interface{}{"obj": objName, "text": text})
}
func lvImgSetSrc(objName string, src string) (*CtrlResponse, error) {
return CallCtrlAction("lv_img_set_src", map[string]interface{}{"obj": objName, "src": src})
}
func lvDispSetRotation(rotation string) (*CtrlResponse, error) {
return CallCtrlAction("lv_disp_set_rotation", map[string]interface{}{"rotation": rotation})
}
func updateLabelIfChanged(objName string, newText string) {
screenStateLock.Lock()
defer screenStateLock.Unlock()
if newText != "" && newText != displayedTexts[objName] {
_, _ = CallCtrlAction("lv_label_set_text", map[string]interface{}{"obj": objName, "text": newText})
_, _ = lvLabelSetText(objName, newText)
displayedTexts[objName] = newText
}
}
func switchToScreenIfDifferent(screenName string) {
logger.Infof("switching screen from %s to %s", currentScreen, screenName)
screenStateLock.Lock()
defer screenStateLock.Unlock()
if currentScreen != screenName {
displayLogger.Info().Str("from", currentScreen).Str("to", screenName).Msg("switching screen")
switchToScreen(screenName)
}
}
func clearDisplayState() {
screenStateLock.Lock()
defer screenStateLock.Unlock()
displayedTexts = make(map[string]string)
currentScreen = "ui_Boot_Screen"
}
var (
cloudBlinkLock sync.Mutex = sync.Mutex{}
cloudBlinkStopped bool
cloudBlinkTicker *time.Ticker
)
func updateDisplay() {
updateLabelIfChanged("ui_Home_Content_Ip", networkState.IPv4)
updateLabelIfChanged("ui_Home_Content_Ip", networkState.IPv4String())
if usbState == "configured" {
updateLabelIfChanged("ui_Home_Footer_Usb_Status_Label", "Connected")
_, _ = CallCtrlAction("lv_obj_set_state", map[string]interface{}{"obj": "ui_Home_Footer_Usb_Status_Label", "state": "LV_STATE_DEFAULT"})
_, _ = lvObjSetState("ui_Home_Footer_Usb_Status_Label", "LV_STATE_DEFAULT")
} else {
updateLabelIfChanged("ui_Home_Footer_Usb_Status_Label", "Disconnected")
_, _ = CallCtrlAction("lv_obj_set_state", map[string]interface{}{"obj": "ui_Home_Footer_Usb_Status_Label", "state": "LV_STATE_USER_2"})
_, _ = lvObjSetState("ui_Home_Footer_Usb_Status_Label", "LV_STATE_USER_2")
}
if lastVideoState.Ready {
updateLabelIfChanged("ui_Home_Footer_Hdmi_Status_Label", "Connected")
_, _ = CallCtrlAction("lv_obj_set_state", map[string]interface{}{"obj": "ui_Home_Footer_Hdmi_Status_Label", "state": "LV_STATE_DEFAULT"})
_, _ = lvObjSetState("ui_Home_Footer_Hdmi_Status_Label", "LV_STATE_DEFAULT")
} else {
updateLabelIfChanged("ui_Home_Footer_Hdmi_Status_Label", "Disconnected")
_, _ = CallCtrlAction("lv_obj_set_state", map[string]interface{}{"obj": "ui_Home_Footer_Hdmi_Status_Label", "state": "LV_STATE_USER_2"})
_, _ = lvObjSetState("ui_Home_Footer_Hdmi_Status_Label", "LV_STATE_USER_2")
}
updateLabelIfChanged("ui_Home_Header_Cloud_Status_Label", fmt.Sprintf("%d active", actionSessions))
if networkState.Up {
if networkState.IsUp() {
switchToScreenIfDifferent("ui_Home_Screen")
} else {
switchToScreenIfDifferent("ui_No_Network_Screen")
}
if cloudConnectionState == CloudConnectionStateNotConfigured {
_, _ = lvObjHide("ui_Home_Header_Cloud_Status_Icon")
} else {
_, _ = lvObjShow("ui_Home_Header_Cloud_Status_Icon")
}
switch cloudConnectionState {
case CloudConnectionStateDisconnected:
_, _ = lvImgSetSrc("ui_Home_Header_Cloud_Status_Icon", "cloud_disconnected.png")
stopCloudBlink()
case CloudConnectionStateConnecting:
_, _ = lvImgSetSrc("ui_Home_Header_Cloud_Status_Icon", "cloud.png")
startCloudBlink()
case CloudConnectionStateConnected:
_, _ = lvImgSetSrc("ui_Home_Header_Cloud_Status_Icon", "cloud.png")
stopCloudBlink()
}
}
var displayInited = false
func startCloudBlink() {
if cloudBlinkTicker == nil {
cloudBlinkTicker = time.NewTicker(2 * time.Second)
} else {
// do nothing if the blink isn't stopped
if cloudBlinkStopped {
cloudBlinkLock.Lock()
defer cloudBlinkLock.Unlock()
cloudBlinkStopped = false
cloudBlinkTicker.Reset(2 * time.Second)
}
}
go func() {
for range cloudBlinkTicker.C {
if cloudConnectionState != CloudConnectionStateConnecting {
continue
}
_, _ = lvObjFadeOut("ui_Home_Header_Cloud_Status_Icon", 1000)
time.Sleep(1000 * time.Millisecond)
_, _ = lvObjFadeIn("ui_Home_Header_Cloud_Status_Icon", 1000)
time.Sleep(1000 * time.Millisecond)
}
}()
}
func stopCloudBlink() {
if cloudBlinkTicker != nil {
cloudBlinkTicker.Stop()
}
cloudBlinkLock.Lock()
defer cloudBlinkLock.Unlock()
cloudBlinkStopped = true
}
var (
displayInited = false
displayUpdateLock = sync.Mutex{}
waitDisplayUpdate = sync.Mutex{}
)
func requestDisplayUpdate(shouldWakeDisplay bool) {
displayUpdateLock.Lock()
defer displayUpdateLock.Unlock()
func requestDisplayUpdate() {
if !displayInited {
logger.Info("display not inited, skipping updates")
displayLogger.Info().Msg("display not inited, skipping updates")
return
}
go func() {
wakeDisplay(false)
logger.Info("display updating")
if shouldWakeDisplay {
wakeDisplay(false)
}
displayLogger.Debug().Msg("display updating")
//TODO: only run once regardless how many pending updates
updateDisplay()
}()
}
func waitCtrlAndRequestDisplayUpdate(shouldWakeDisplay bool) {
waitDisplayUpdate.Lock()
defer waitDisplayUpdate.Unlock()
waitCtrlClientConnected()
requestDisplayUpdate(shouldWakeDisplay)
}
func updateStaticContents() {
//contents that never change
updateLabelIfChanged("ui_Home_Content_Mac", networkState.MAC)
updateLabelIfChanged("ui_Home_Content_Mac", networkState.MACString())
systemVersion, appVersion, err := GetLocalVersion()
if err == nil {
updateLabelIfChanged("ui_About_Content_Operating_System_Version_ContentLabel", systemVersion.String())
@ -118,7 +261,7 @@ func setDisplayBrightness(brightness int) error {
return err
}
logger.Infof("display: set brightness to %v", brightness)
displayLogger.Info().Int("brightness", brightness).Msg("set brightness")
return nil
}
@ -127,7 +270,7 @@ func setDisplayBrightness(brightness int) error {
func tick_displayDim() {
err := setDisplayBrightness(config.DisplayMaxBrightness / 2)
if err != nil {
logger.Warnf("display: failed to dim display: %s", err)
displayLogger.Warn().Err(err).Msg("failed to dim display")
}
dimTicker.Stop()
@ -140,7 +283,7 @@ func tick_displayDim() {
func tick_displayOff() {
err := setDisplayBrightness(0)
if err != nil {
logger.Warnf("display: failed to turn off display: %s", err)
displayLogger.Warn().Err(err).Msg("failed to turn off display")
}
offTicker.Stop()
@ -163,7 +306,7 @@ func wakeDisplay(force bool) {
err := setDisplayBrightness(config.DisplayMaxBrightness)
if err != nil {
logger.Warnf("display wake failed, %s", err)
displayLogger.Warn().Err(err).Msg("failed to wake display")
}
if config.DisplayDimAfterSec != 0 {
@ -183,7 +326,7 @@ func wakeDisplay(force bool) {
func watchTsEvents() {
ts, err := os.OpenFile(touchscreenDevice, os.O_RDONLY, 0666)
if err != nil {
logger.Warnf("display: failed to open touchscreen device: %s", err)
displayLogger.Warn().Err(err).Msg("failed to open touchscreen device")
return
}
@ -196,7 +339,7 @@ func watchTsEvents() {
for {
_, err := ts.Read(buf)
if err != nil {
logger.Warnf("display: failed to read from touchscreen device: %s", err)
displayLogger.Warn().Err(err).Msg("failed to read from touchscreen device")
return
}
@ -215,13 +358,21 @@ func startBacklightTickers() {
return
}
if dimTicker == nil && config.DisplayDimAfterSec != 0 {
logger.Info("display: dim_ticker has started")
// Stop existing tickers to prevent multiple active instances on repeated calls
if dimTicker != nil {
dimTicker.Stop()
}
if offTicker != nil {
offTicker.Stop()
}
if config.DisplayDimAfterSec != 0 {
displayLogger.Info().Msg("dim_ticker has started")
dimTicker = time.NewTicker(time.Duration(config.DisplayDimAfterSec) * time.Second)
defer dimTicker.Stop()
go func() {
for { //nolint:gosimple
for { //nolint:staticcheck
select {
case <-dimTicker.C:
tick_displayDim()
@ -230,13 +381,12 @@ func startBacklightTickers() {
}()
}
if offTicker == nil && config.DisplayOffAfterSec != 0 {
logger.Info("display: off_ticker has started")
if config.DisplayOffAfterSec != 0 {
displayLogger.Info().Msg("off_ticker has started")
offTicker = time.NewTicker(time.Duration(config.DisplayOffAfterSec) * time.Second)
defer offTicker.Stop()
go func() {
for { //nolint:gosimple
for { //nolint:staticcheck
select {
case <-offTicker.C:
tick_displayOff()
@ -246,19 +396,18 @@ func startBacklightTickers() {
}
}
func init() {
ensureConfigLoaded()
func initDisplay() {
go func() {
waitCtrlClientConnected()
logger.Info("setting initial display contents")
displayLogger.Info().Msg("setting initial display contents")
time.Sleep(500 * time.Millisecond)
_, _ = lvDispSetRotation(config.DisplayRotation)
updateStaticContents()
displayInited = true
logger.Info("display inited")
displayLogger.Info().Msg("display inited")
startBacklightTickers()
wakeDisplay(true)
requestDisplayUpdate()
requestDisplayUpdate(true)
}()
go watchTsEvents()

View File

@ -37,7 +37,7 @@ func (f *WebRTCStreamFile) Getattr(ctx context.Context, fh fs.FileHandle, out *f
f.mu.Lock()
defer f.mu.Unlock()
out.Attr = f.Attr
out.Attr.Size = f.size
out.Size = f.size
return fs.OK
}
@ -103,7 +103,7 @@ func RunFuseServer() {
var err error
fuseServer, err = fs.Mount(fuseMountPoint, &FuseRoot{}, opts)
if err != nil {
logger.Warnf("failed to mount fuse: %v", err)
logger.Warn().Err(err).Msg("failed to mount fuse")
}
fuseServer.Wait()
}

113
go.mod
View File

@ -1,86 +1,91 @@
module github.com/jetkvm/kvm
go 1.21.0
toolchain go1.21.1
go 1.24.4
require (
github.com/Masterminds/semver/v3 v3.3.0
github.com/beevik/ntp v1.3.1
github.com/coder/websocket v1.8.12
github.com/coreos/go-oidc/v3 v3.11.0
github.com/creack/pty v1.1.23
github.com/gin-gonic/gin v1.9.1
github.com/Masterminds/semver/v3 v3.4.0
github.com/beevik/ntp v1.4.3
github.com/coder/websocket v1.8.13
github.com/coreos/go-oidc/v3 v3.14.1
github.com/creack/pty v1.1.24
github.com/fsnotify/fsnotify v1.9.0
github.com/gin-contrib/logger v1.2.6
github.com/gin-gonic/gin v1.10.1
github.com/go-co-op/gocron/v2 v2.16.3
github.com/google/uuid v1.6.0
github.com/gwatts/rootcerts v0.0.0-20240401182218-3ab9db955caf
github.com/hanwen/go-fuse/v2 v2.5.1
github.com/hashicorp/go-envparse v0.1.0
github.com/pion/logging v0.2.2
github.com/guregu/null/v6 v6.0.0
github.com/gwatts/rootcerts v0.0.0-20250601184604-370a9a75f341
github.com/hanwen/go-fuse/v2 v2.8.0
github.com/pion/logging v0.2.4
github.com/pion/mdns/v2 v2.0.7
github.com/pion/webrtc/v4 v4.0.0
github.com/pion/webrtc/v4 v4.1.3
github.com/pojntfx/go-nbd v0.3.2
github.com/prometheus/client_golang v1.21.0
github.com/prometheus/common v0.62.0
github.com/prometheus/client_golang v1.22.0
github.com/prometheus/common v0.65.0
github.com/prometheus/procfs v0.16.1
github.com/psanford/httpreadat v0.1.0
github.com/vishvananda/netlink v1.3.0
go.bug.st/serial v1.6.2
golang.org/x/crypto v0.31.0
golang.org/x/net v0.33.0
github.com/rs/zerolog v1.34.0
github.com/sourcegraph/tf-dag v0.2.2-0.20250131204052-3e8ff1477b4f
github.com/stretchr/testify v1.10.0
github.com/vishvananda/netlink v1.3.1
go.bug.st/serial v1.6.4
golang.org/x/crypto v0.40.0
golang.org/x/net v0.41.0
golang.org/x/sys v0.34.0
)
replace github.com/pojntfx/go-nbd v0.3.2 => github.com/chemhack/go-nbd v0.0.0-20241006125820-59e45f5b1e7b
require (
github.com/beorn7/perks v1.0.1 // indirect
github.com/bytedance/sonic v1.11.6 // indirect
github.com/bytedance/sonic/loader v0.1.1 // indirect
github.com/bytedance/sonic v1.13.3 // indirect
github.com/bytedance/sonic/loader v0.2.4 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/cloudwego/base64x v0.1.4 // indirect
github.com/cloudwego/iasm v0.2.0 // indirect
github.com/creack/goselect v0.1.2 // indirect
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
github.com/gin-contrib/sse v0.1.0 // indirect
github.com/go-co-op/gocron/v2 v2.16.1 // indirect
github.com/go-jose/go-jose/v4 v4.0.2 // indirect
github.com/cloudwego/base64x v0.1.5 // indirect
github.com/creack/goselect v0.1.3 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/gabriel-vasile/mimetype v1.4.9 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect
github.com/go-jose/go-jose/v4 v4.1.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.20.0 // indirect
github.com/goccy/go-json v0.10.2 // indirect
github.com/go-playground/validator/v10 v10.26.0 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/jonboulle/clockwork v0.5.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/compress v1.17.11 // indirect
github.com/klauspost/cpuid/v2 v2.2.7 // indirect
github.com/klauspost/cpuid/v2 v2.2.10 // 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/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.2 // indirect
github.com/pilebones/go-udev v0.9.0 // indirect
github.com/pion/datachannel v1.5.9 // indirect
github.com/pion/dtls/v3 v3.0.3 // indirect
github.com/pion/ice/v4 v4.0.2 // indirect
github.com/pion/interceptor v0.1.37 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // 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.6 // indirect
github.com/pion/ice/v4 v4.0.10 // indirect
github.com/pion/interceptor v0.1.40 // indirect
github.com/pion/randutil v0.1.0 // indirect
github.com/pion/rtcp v1.2.14 // indirect
github.com/pion/rtp v1.8.9 // indirect
github.com/pion/sctp v1.8.33 // indirect
github.com/pion/sdp/v3 v3.0.9 // indirect
github.com/pion/srtp/v3 v3.0.4 // indirect
github.com/pion/rtcp v1.2.15 // indirect
github.com/pion/rtp v1.8.20 // indirect
github.com/pion/sctp v1.8.39 // indirect
github.com/pion/sdp/v3 v3.0.14 // indirect
github.com/pion/srtp/v3 v3.0.6 // indirect
github.com/pion/stun/v3 v3.0.0 // indirect
github.com/pion/transport/v3 v3.0.7 // indirect
github.com/pion/turn/v4 v4.0.0 // indirect
github.com/prometheus/client_model v0.6.1 // indirect
github.com/prometheus/procfs v0.15.1 // indirect
github.com/pion/turn/v4 v4.0.2 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
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/ugorji/go/codec v1.2.12 // indirect
github.com/vishvananda/netns v0.0.4 // 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
golang.org/x/arch v0.8.0 // indirect
golang.org/x/oauth2 v0.24.0 // indirect
golang.org/x/sys v0.28.0 // indirect
golang.org/x/text v0.21.0 // indirect
google.golang.org/protobuf v1.36.1 // indirect
golang.org/x/arch v0.18.0 // indirect
golang.org/x/oauth2 v0.30.0 // indirect
golang.org/x/text v0.27.0 // indirect
google.golang.org/protobuf v1.36.6 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

242
go.sum
View File

@ -1,72 +1,78 @@
github.com/Masterminds/semver/v3 v3.3.0 h1:B8LGeaivUe71a5qox1ICM/JLl0NqZSW5CHyL+hmvYS0=
github.com/Masterminds/semver/v3 v3.3.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
github.com/beevik/ntp v1.3.1 h1:Y/srlT8L1yQr58kyPWFPZIxRL8ttx2SRIpVYJqZIlAM=
github.com/beevik/ntp v1.3.1/go.mod h1:fT6PylBq86Tsq23ZMEe47b7QQrZfYBFPnpzt0a9kJxw=
github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0=
github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
github.com/beevik/ntp v1.4.3 h1:PlbTvE5NNy4QHmA4Mg57n7mcFTmr1W1j3gcK7L1lqho=
github.com/beevik/ntp v1.4.3/go.mod h1:Unr8Zg+2dRn7d8bHFuehIMSvvUYssHMxW3Q5Nx4RW5Q=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0=
github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4=
github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM=
github.com/bytedance/sonic v1.13.3 h1:MS8gmaH16Gtirygw7jV91pDCN33NyMrPbN7qiYhEsF0=
github.com/bytedance/sonic v1.13.3/go.mod h1:o68xyaF9u2gvVBuGHPlUVCy+ZfmNNO5ETf1+KgkJhz4=
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
github.com/bytedance/sonic/loader v0.2.4 h1:ZWCw4stuXUsn1/+zQDqeE7JKP+QO47tz7QCNan80NzY=
github.com/bytedance/sonic/loader v0.2.4/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/chemhack/go-nbd v0.0.0-20241006125820-59e45f5b1e7b h1:dSbDgy72Y1sjLPWLv7vs0fMFuhMBMViiT9PJZiZWZNs=
github.com/chemhack/go-nbd v0.0.0-20241006125820-59e45f5b1e7b/go.mod h1:SehHnbi2e8NiSAKby42Itm8SIoS7b+wAprsfPH3qgYk=
github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
github.com/cloudwego/base64x v0.1.5 h1:XPciSp1xaq2VCSt6lF0phncD4koWyULpl5bUxbfCyP4=
github.com/cloudwego/base64x v0.1.5/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
github.com/coder/websocket v1.8.12 h1:5bUXkEPPIbewrnkU8LTCLVaxi4N4J8ahufH2vlo4NAo=
github.com/coder/websocket v1.8.12/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs=
github.com/coreos/go-oidc/v3 v3.11.0 h1:Ia3MxdwpSw702YW0xgfmP1GVCMA9aEFWu12XUZ3/OtI=
github.com/coreos/go-oidc/v3 v3.11.0/go.mod h1:gE3LgjOgFoHi9a4ce4/tJczr0Ai2/BoDhf0r5lltWI0=
github.com/creack/goselect v0.1.2 h1:2DNy14+JPjRBgPzAd1thbQp4BSIihxcBf0IXhQXDRa0=
github.com/creack/goselect v0.1.2/go.mod h1:a/NhLweNvqIYMuxcMOuWY516Cimucms3DglDzQP3hKY=
github.com/creack/pty v1.1.23 h1:4M6+isWdcStXEf15G/RbrMPOQj1dZ7HPZCGwE4kOeP0=
github.com/creack/pty v1.1.23/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
github.com/coder/websocket v1.8.13 h1:f3QZdXy7uGVz+4uCJy2nTZyM0yTBj8yANEHhqlXZ9FE=
github.com/coder/websocket v1.8.13/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs=
github.com/coreos/go-oidc/v3 v3.14.1 h1:9ePWwfdwC4QKRlCXsJGou56adA/owXczOzwKdOumLqk=
github.com/coreos/go-oidc/v3 v3.14.1/go.mod h1:HaZ3szPaZ0e4r6ebqvsLWlk2Tn+aejfmrfah6hnSYEU=
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/creack/goselect v0.1.3 h1:MaGNMclRo7P2Jl21hBpR1Cn33ITSbKP6E49RtfblLKc=
github.com/creack/goselect v0.1.3/go.mod h1:a/NhLweNvqIYMuxcMOuWY516Cimucms3DglDzQP3hKY=
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg=
github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU=
github.com/go-co-op/gocron/v2 v2.16.1 h1:ux/5zxVRveCaCuTtNI3DiOk581KC1KpJbpJFYUEVYwo=
github.com/go-co-op/gocron/v2 v2.16.1/go.mod h1:opexeOFy5BplhsKdA7bzY9zeYih8I8/WNJ4arTIFPVc=
github.com/go-jose/go-jose/v4 v4.0.2 h1:R3l3kkBds16bO7ZFAEEcofK0MkrAJt3jlJznWZG0nvk=
github.com/go-jose/go-jose/v4 v4.0.2/go.mod h1:WVf9LFMHh/QVrmqrOfqun0C45tMe3RoiKJMPvgWwLfY=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/gabriel-vasile/mimetype v1.4.9 h1:5k+WDwEsD9eTLL8Tz3L0VnmVh9QxGjRmjBvAG7U/oYY=
github.com/gabriel-vasile/mimetype v1.4.9/go.mod h1:WnSQhFKJuBlRyLiKohA/2DtIlPFAbguNaG7QCHcyGok=
github.com/gin-contrib/logger v1.2.6 h1:EPolruKUTzNXMVBD9LuAFQmRjTs7AH7yKGuXgYqrKWc=
github.com/gin-contrib/logger v1.2.6/go.mod h1:7niPrd7F0Nscw/zvgz8RiGJxSdbKM2yfQNy8xCHcm64=
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
github.com/gin-gonic/gin v1.10.1 h1:T0ujvqyCSqRopADpgPgiTT63DUQVSfojyME59Ei63pQ=
github.com/gin-gonic/gin v1.10.1/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
github.com/go-co-op/gocron/v2 v2.16.3 h1:kYqukZqBa8RC2+AFAHnunmKcs9GRTjwBo8WRF3I6cbI=
github.com/go-co-op/gocron/v2 v2.16.3/go.mod h1:aTf7/+5Jo2E+cyAqq625UQ6DzpkV96b22VHIUAt6l3c=
github.com/go-jose/go-jose/v4 v4.1.0 h1:cYSYxd3pw5zd2FSXk2vGdn9igQU2PS8MuxrCOCl0FdY=
github.com/go-jose/go-jose/v4 v4.1.0/go.mod h1:GG/vqmYm3Von2nYiB2vGTXzdoNKE5tix5tuc6iAd+sw=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8=
github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/go-playground/validator/v10 v10.26.0 h1:SP05Nqhjcvz81uJaRfEV0YBSSSGMc/iMaVtFbr3Sw2k=
github.com/go-playground/validator/v10 v10.26.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gwatts/rootcerts v0.0.0-20240401182218-3ab9db955caf h1:JO6ISZIvEUitto5zjQ3/VEnDM5rPbqIFuOhS0U0ByeA=
github.com/gwatts/rootcerts v0.0.0-20240401182218-3ab9db955caf/go.mod h1:5Kt9XkWvkGi2OHOq0QsGxebHmhCcqJ8KCbNg/a6+n+g=
github.com/hanwen/go-fuse/v2 v2.5.1 h1:OQBE8zVemSocRxA4OaFJbjJ5hlpCmIWbGr7r0M4uoQQ=
github.com/hanwen/go-fuse/v2 v2.5.1/go.mod h1:xKwi1cF7nXAOBCXujD5ie0ZKsxc8GGSA1rlMJc+8IJs=
github.com/hashicorp/go-envparse v0.1.0 h1:bE++6bhIsNCPLvgDZkYqo3nA+/PFI51pkrHdmPSDFPY=
github.com/hashicorp/go-envparse v0.1.0/go.mod h1:OHheN1GoygLlAkTlXLXvAdnXdZxy8JUweQ1rAXx1xnc=
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-20250601184604-370a9a75f341 h1:zPrkLSKi7kKJoNJH4uUmsQ86+0/QqpwEns0NyNLwKv0=
github.com/gwatts/rootcerts v0.0.0-20250601184604-370a9a75f341/go.mod h1:5Kt9XkWvkGi2OHOq0QsGxebHmhCcqJ8KCbNg/a6+n+g=
github.com/hanwen/go-fuse/v2 v2.8.0 h1:wV8rG7rmCz8XHSOwBZhG5YcVqcYjkzivjmbaMafPlAs=
github.com/hanwen/go-fuse/v2 v2.8.0/go.mod h1:yE6D2PqWwm3CbYRxFXV9xUd8Md5d6NG0WBs5spCswmI=
github.com/jonboulle/clockwork v0.5.0 h1:Hyh9A8u51kptdkR+cqRpT1EebBwTn1oK9YfGYbdFz6I=
github.com/jonboulle/clockwork v0.5.0/go.mod h1:3mZlmanh0g2NDKO5TWZVJAfofYk64M7XN3SzBPjZF60=
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.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc=
github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM=
github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE=
github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
@ -75,15 +81,19 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v0.0.0-20170820004349-d65d576e9348/go.mod h1:B69LEHPfb2qLo0BaaOLcbitczOKLWTsrBG9LczfCD4k=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
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/moby/sys/mountinfo v0.6.2 h1:BzJjoreD5BMFNmD9Rus6gdd1pLuecOFPt8wC+Vygl78=
github.com/moby/sys/mountinfo v0.6.2/go.mod h1:IJb6JQeOklcdMU9F5xQ8ZALD+CUr5VlGpwtX+VE0rpI=
github.com/moby/sys/mountinfo v0.7.2 h1:1shs6aH5s4o5H2zQLn796ADW1wMrIwHsyJ2v9KouLrg=
github.com/moby/sys/mountinfo v0.7.2/go.mod h1:1YOa8w8Ih7uW0wALDUgT1dTTSBrZ+HiBLGws92L2RU4=
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=
@ -91,104 +101,107 @@ github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9G
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
github.com/pilebones/go-udev v0.9.0 h1:N1uEO/SxUwtIctc0WLU0t69JeBxIYEYnj8lT/Nabl9Q=
github.com/pilebones/go-udev v0.9.0/go.mod h1:T2eI2tUSK0hA2WS5QLjXJUfQkluZQu+18Cqvem3CaXI=
github.com/pion/datachannel v1.5.9 h1:LpIWAOYPyDrXtU+BW7X0Yt/vGtYxtXQ8ql7dFfYUVZA=
github.com/pion/datachannel v1.5.9/go.mod h1:kDUuk4CU4Uxp82NH4LQZbISULkX/HtzKa4P7ldf9izE=
github.com/pion/dtls/v3 v3.0.3 h1:j5ajZbQwff7Z8k3pE3S+rQ4STvKvXUdKsi/07ka+OWM=
github.com/pion/dtls/v3 v3.0.3/go.mod h1:weOTUyIV4z0bQaVzKe8kpaP17+us3yAuiQsEAG1STMU=
github.com/pion/ice/v4 v4.0.2 h1:1JhBRX8iQLi0+TfcavTjPjI6GO41MFn4CeTBX+Y9h5s=
github.com/pion/ice/v4 v4.0.2/go.mod h1:DCdqyzgtsDNYN6/3U8044j3U7qsJ9KFJC92VnOWHvXg=
github.com/pion/interceptor v0.1.37 h1:aRA8Zpab/wE7/c0O3fh1PqY0AJI3fCSEM5lRWJVorwI=
github.com/pion/interceptor v0.1.37/go.mod h1:JzxbJ4umVTlZAf+/utHzNesY8tmRkM2lVmkS82TTj8Y=
github.com/pion/logging v0.2.2 h1:M9+AIj/+pxNsDfAT64+MAVgJO0rsyLnoJKCqf//DoeY=
github.com/pion/logging v0.2.2/go.mod h1:k0/tDVsRCX2Mb2ZEmTqNa7CWsQPc+YYCB7Q+5pahoms=
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/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=
github.com/pion/datachannel v1.5.10/go.mod h1:p/jJfC9arb29W7WrxyKbepTU20CFgyx5oLo8Rs4Py/M=
github.com/pion/dtls/v3 v3.0.6 h1:7Hkd8WhAJNbRgq9RgdNh1aaWlZlGpYTzdqjy9x9sK2E=
github.com/pion/dtls/v3 v3.0.6/go.mod h1:iJxNQ3Uhn1NZWOMWlLxEEHAN5yX7GyPvvKw04v9bzYU=
github.com/pion/ice/v4 v4.0.10 h1:P59w1iauC/wPk9PdY8Vjl4fOFL5B+USq1+xbDcN6gT4=
github.com/pion/ice/v4 v4.0.10/go.mod h1:y3M18aPhIxLlcO/4dn9X8LzLLSma84cx6emMSu14FGw=
github.com/pion/interceptor v0.1.40 h1:e0BjnPcGpr2CFQgKhrQisBU7V3GXK6wrfYrGYaU6Jq4=
github.com/pion/interceptor v0.1.40/go.mod h1:Z6kqH7M/FYirg3frjGJ21VLSRJGBXB/KqaTIrdqnOic=
github.com/pion/logging v0.2.4 h1:tTew+7cmQ+Mc1pTBLKH2puKsOvhm32dROumOZ655zB8=
github.com/pion/logging v0.2.4/go.mod h1:DffhXTKYdNZU+KtJ5pyQDjvOAh/GsNSyv1lbkFbe3so=
github.com/pion/mdns/v2 v2.0.7 h1:c9kM8ewCgjslaAmicYMFQIde2H9/lrZpjBkN8VwoVtM=
github.com/pion/mdns/v2 v2.0.7/go.mod h1:vAdSYNAT0Jy3Ru0zl2YiW3Rm/fJCwIeM0nToenfOJKA=
github.com/pion/randutil v0.1.0 h1:CFG1UdESneORglEsnimhUjf33Rwjubwj6xfiOXBa3mA=
github.com/pion/randutil v0.1.0/go.mod h1:XcJrSMMbbMRhASFVOlj/5hQial/Y8oH/HVo7TBZq+j8=
github.com/pion/rtcp v1.2.14 h1:KCkGV3vJ+4DAJmvP0vaQShsb0xkRfWkO540Gy102KyE=
github.com/pion/rtcp v1.2.14/go.mod h1:sn6qjxvnwyAkkPzPULIbVqSKI5Dv54Rv7VG0kNxh9L4=
github.com/pion/rtp v1.8.9 h1:E2HX740TZKaqdcPmf4pw6ZZuG8u5RlMMt+l3dxeu6Wk=
github.com/pion/rtp v1.8.9/go.mod h1:pBGHaFt/yW7bf1jjWAoUjpSNoDnw98KTMg+jWWvziqU=
github.com/pion/sctp v1.8.33 h1:dSE4wX6uTJBcNm8+YlMg7lw1wqyKHggsP5uKbdj+NZw=
github.com/pion/sctp v1.8.33/go.mod h1:beTnqSzewI53KWoG3nqB282oDMGrhNxBdb+JZnkCwRM=
github.com/pion/sdp/v3 v3.0.9 h1:pX++dCHoHUwq43kuwf3PyJfHlwIj4hXA7Vrifiq0IJY=
github.com/pion/sdp/v3 v3.0.9/go.mod h1:B5xmvENq5IXJimIO4zfp6LAe1fD9N+kFv+V/1lOdz8M=
github.com/pion/srtp/v3 v3.0.4 h1:2Z6vDVxzrX3UHEgrUyIGM4rRouoC7v+NiF1IHtp9B5M=
github.com/pion/srtp/v3 v3.0.4/go.mod h1:1Jx3FwDoxpRaTh1oRV8A/6G1BnFL+QI82eK4ms8EEJQ=
github.com/pion/rtcp v1.2.15 h1:LZQi2JbdipLOj4eBjK4wlVoQWfrZbh3Q6eHtWtJBZBo=
github.com/pion/rtcp v1.2.15/go.mod h1:jlGuAjHMEXwMUHK78RgX0UmEJFV4zUKOFHR7OP+D3D0=
github.com/pion/rtp v1.8.20 h1:8zcyqohadZE8FCBeGdyEvHiclPIezcwRQH9zfapFyYI=
github.com/pion/rtp v1.8.20/go.mod h1:bAu2UFKScgzyFqvUKmbvzSdPr+NGbZtv6UB2hesqXBk=
github.com/pion/sctp v1.8.39 h1:PJma40vRHa3UTO3C4MyeJDQ+KIobVYRZQZ0Nt7SjQnE=
github.com/pion/sctp v1.8.39/go.mod h1:cNiLdchXra8fHQwmIoqw0MbLLMs+f7uQ+dGMG2gWebE=
github.com/pion/sdp/v3 v3.0.14 h1:1h7gBr9FhOWH5GjWWY5lcw/U85MtdcibTyt/o6RxRUI=
github.com/pion/sdp/v3 v3.0.14/go.mod h1:88GMahN5xnScv1hIMTqLdu/cOcUkj6a9ytbncwMCq2E=
github.com/pion/srtp/v3 v3.0.6 h1:E2gyj1f5X10sB/qILUGIkL4C2CqK269Xq167PbGCc/4=
github.com/pion/srtp/v3 v3.0.6/go.mod h1:BxvziG3v/armJHAaJ87euvkhHqWe9I7iiOy50K2QkhY=
github.com/pion/stun/v3 v3.0.0 h1:4h1gwhWLWuZWOJIJR9s2ferRO+W3zA/b6ijOI6mKzUw=
github.com/pion/stun/v3 v3.0.0/go.mod h1:HvCN8txt8mwi4FBvS3EmDghW6aQJ24T+y+1TKjB5jyU=
github.com/pion/transport/v3 v3.0.7 h1:iRbMH05BzSNwhILHoBoAPxoB9xQgOaJk+591KC9P1o0=
github.com/pion/transport/v3 v3.0.7/go.mod h1:YleKiTZ4vqNxVwh77Z0zytYi7rXHl7j6uPLGhhz9rwo=
github.com/pion/turn/v4 v4.0.0 h1:qxplo3Rxa9Yg1xXDxxH8xaqcyGUtbHYw4QSCvmFWvhM=
github.com/pion/turn/v4 v4.0.0/go.mod h1:MuPDkm15nYSklKpN8vWJ9W2M0PlyQZqYt1McGuxG7mA=
github.com/pion/webrtc/v4 v4.0.0 h1:x8ec7uJQPP3D1iI8ojPAiTOylPI7Fa7QgqZrhpLyqZ8=
github.com/pion/webrtc/v4 v4.0.0/go.mod h1:SfNn8CcFxR6OUVjLXVslAQ3a3994JhyE3Hw1jAuqEto=
github.com/pion/turn/v4 v4.0.2 h1:ZqgQ3+MjP32ug30xAbD6Mn+/K4Sxi3SdNOTFf+7mpps=
github.com/pion/turn/v4 v4.0.2/go.mod h1:pMMKP/ieNAG/fN5cZiN4SDuyKsXtNTr0ccN7IToA1zs=
github.com/pion/webrtc/v4 v4.1.3 h1:YZ67Boj9X/hk190jJZ8+HFGQ6DqSZ/fYP3sLAZv7c3c=
github.com/pion/webrtc/v4 v4.1.3/go.mod h1:rsq+zQ82ryfR9vbb0L1umPJ6Ogq7zm8mcn9fcGnxomM=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_golang v1.21.0 h1:DIsaGmiaBkSangBgMtWdNfxbMNdku5IK6iNhrEqWvdA=
github.com/prometheus/client_golang v1.21.0/go.mod h1:U9NM32ykUErtVBxdvD3zfi+EuFkkaBvMb09mIfe0Zgg=
github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E=
github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY=
github.com/prometheus/common v0.62.0 h1:xasJaQlnWAeyHdUBeGjXmutelfJHWMRr+Fg4QszZ2Io=
github.com/prometheus/common v0.62.0/go.mod h1:vyBcEuLSvWos9B1+CyL7JZ2up+uFzXhkqml0W5zIY1I=
github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc=
github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk=
github.com/prometheus/client_golang v1.22.0 h1:rb93p9lokFEsctTys46VnV1kLCDpVZ0a/Y92Vm0Zc6Q=
github.com/prometheus/client_golang v1.22.0/go.mod h1:R7ljNsLXhuQXYZYtw6GAE9AZg8Y7vEW5scdCXrWRXC0=
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
github.com/prometheus/common v0.65.0 h1:QDwzd+G1twt//Kwj/Ww6E9FQq1iVMmODnILtW1t2VzE=
github.com/prometheus/common v0.65.0/go.mod h1:0gZns+BLRQ3V6NdaerOhMbwwRbNh9hkGINtQAsP5GS8=
github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg=
github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is=
github.com/psanford/httpreadat v0.1.0 h1:VleW1HS2zO7/4c7c7zNl33fO6oYACSagjJIyMIwZLUE=
github.com/psanford/httpreadat v0.1.0/go.mod h1:Zg7P+TlBm3bYbyHTKv/EdtSJZn3qwbPwpfZ/I9GKCRE=
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=
github.com/sourcegraph/tf-dag v0.2.2-0.20250131204052-3e8ff1477b4f h1:VgoRCP1efSCEZIcF2THLQ46+pIBzzgNiaUBe9wEDwYU=
github.com/sourcegraph/tf-dag v0.2.2-0.20250131204052-3e8ff1477b4f/go.mod h1:pzro7BGorij2WgrjEammtrkbo3+xldxo+KaGLGUiD+Q=
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/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.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
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=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
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/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
github.com/vishvananda/netlink v1.3.0 h1:X7l42GfcV4S6E4vHTsw48qbrV+9PVojNfIhZcwQdrZk=
github.com/vishvananda/netlink v1.3.0/go.mod h1:i6NetklAujEcC6fK0JPjT8qSwWyO0HLn4UKG+hGqeJs=
github.com/vishvananda/netns v0.0.4 h1:Oeaw1EM2JMxD51g9uhtC0D7erkIjgmj8+JZc26m1YX8=
github.com/vishvananda/netns v0.0.4/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM=
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/vishvananda/netlink v1.3.1 h1:3AEMt62VKqz90r0tmNhog0r/PpWKmrEShJU0wJW6bV0=
github.com/vishvananda/netlink v1.3.1/go.mod h1:ARtKouGSTGchR8aMwmkzC0qiNPrrWO5JS/XMVl45+b4=
github.com/vishvananda/netns v0.0.5 h1:DfiHV+j8bA32MFM7bfEunvT8IAqQ/NzSJHtcmW5zdEY=
github.com/vishvananda/netns v0.0.5/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM=
github.com/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU=
github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA=
go.bug.st/serial v1.6.2 h1:kn9LRX3sdm+WxWKufMlIRndwGfPWsH1/9lCWXQCasq8=
go.bug.st/serial v1.6.2/go.mod h1:UABfsluHAiaNI+La2iESysd9Vetq7VRdpxvjx7CmmOE=
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I=
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
golang.org/x/oauth2 v0.24.0 h1:KTBBxWqUa0ykRPLtV69rRto9TLXcqYkeswu48x/gvNE=
golang.org/x/oauth2 v0.24.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
go.bug.st/serial v1.6.4 h1:7FmqNPgVp3pu2Jz5PoPtbZ9jJO5gnEnZIvnI1lzve8A=
go.bug.st/serial v1.6.4/go.mod h1:nofMJxTeNVny/m6+KaafC6vJGj3miwQZ6vW4BZUGJPI=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
golang.org/x/arch v0.18.0 h1:WN9poc33zL4AzGxqf8VtpKUnGvMi8O9lhNyBMF/85qc=
golang.org/x/arch v0.18.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM=
golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY=
golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw=
golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA=
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/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.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
google.golang.org/protobuf v1.36.1 h1:yBPeRvTftaleIgM3PZ/WBIZ7XM/eEYAaEyCwvyjq/gk=
google.golang.org/protobuf v1.36.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA=
golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4=
golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU=
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
@ -196,4 +209,3 @@ gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=

18
hw.go
View File

@ -4,6 +4,7 @@ import (
"fmt"
"os"
"regexp"
"strings"
"sync"
"time"
)
@ -42,7 +43,7 @@ func GetDeviceID() string {
deviceIDOnce.Do(func() {
serial, err := extractSerialNumber()
if err != nil {
logger.Warn("unknown serial number, the program likely not running on RV1106")
logger.Warn().Msg("unknown serial number, the program likely not running on RV1106")
deviceID = "unknown_device_id"
} else {
deviceID = serial
@ -51,10 +52,19 @@ func GetDeviceID() string {
return deviceID
}
func GetDefaultHostname() string {
deviceId := GetDeviceID()
if deviceId == "unknown_device_id" {
return "jetkvm"
}
return fmt.Sprintf("jetkvm-%s", strings.ToLower(deviceId))
}
func runWatchdog() {
file, err := os.OpenFile("/dev/watchdog", os.O_WRONLY, 0)
if err != nil {
logger.Warnf("unable to open /dev/watchdog: %v, skipping watchdog reset", err)
watchdogLogger.Warn().Err(err).Msg("unable to open /dev/watchdog, skipping watchdog reset")
return
}
defer file.Close()
@ -65,13 +75,13 @@ func runWatchdog() {
case <-ticker.C:
_, err = file.Write([]byte{0})
if err != nil {
logger.Errorf("error writing to /dev/watchdog, system may reboot: %v", err)
watchdogLogger.Warn().Err(err).Msg("error writing to /dev/watchdog, system may reboot")
}
case <-appCtx.Done():
//disarm watchdog with magic value
_, err := file.Write([]byte("V"))
if err != nil {
logger.Errorf("failed to disarm watchdog, system may reboot: %v", err)
watchdogLogger.Warn().Err(err).Msg("failed to disarm watchdog, system may reboot")
}
return
}

View File

@ -0,0 +1,386 @@
package confparser
import (
"fmt"
"net"
"net/url"
"reflect"
"slices"
"strconv"
"strings"
"github.com/guregu/null/v6"
"golang.org/x/net/idna"
)
type FieldConfig struct {
Name string
Required bool
RequiredIf map[string]interface{}
OneOf []string
ValidateTypes []string
Defaults interface{}
IsEmpty bool
CurrentValue interface{}
TypeString string
Delegated bool
shouldUpdateValue bool
}
func SetDefaultsAndValidate(config interface{}) error {
return setDefaultsAndValidate(config, true)
}
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")
}
// now iterate over the lease struct and set the values
configType := reflect.TypeOf(config).Elem()
configValue := reflect.ValueOf(config).Elem()
fields := make(map[string]FieldConfig)
for i := 0; i < configType.NumField(); i++ {
field := configType.Field(i)
fieldValue := configValue.Field(i)
defaultValue := field.Tag.Get("default")
fieldType := field.Type.String()
fieldConfig := FieldConfig{
Name: field.Name,
OneOf: splitString(field.Tag.Get("one_of")),
ValidateTypes: splitString(field.Tag.Get("validate_type")),
RequiredIf: make(map[string]interface{}),
CurrentValue: fieldValue.Interface(),
IsEmpty: false,
TypeString: fieldType,
}
// check if the field is required
required := field.Tag.Get("required")
if required != "" {
requiredBool, _ := strconv.ParseBool(required)
fieldConfig.Required = requiredBool
}
var canUseOneOff = false
// use switch to get the type
switch fieldValue.Interface().(type) {
case string, null.String:
if defaultValue != "" {
fieldConfig.Defaults = defaultValue
}
canUseOneOff = true
case []string:
if defaultValue != "" {
fieldConfig.Defaults = strings.Split(defaultValue, ",")
}
canUseOneOff = true
case int, null.Int:
if defaultValue != "" {
defaultValueInt, err := strconv.Atoi(defaultValue)
if err != nil {
return fmt.Errorf("invalid default value for field `%s`: %s", field.Name, defaultValue)
}
fieldConfig.Defaults = defaultValueInt
}
case bool, null.Bool:
if defaultValue != "" {
defaultValueBool, err := strconv.ParseBool(defaultValue)
if err != nil {
return fmt.Errorf("invalid default value for field `%s`: %s", field.Name, defaultValue)
}
fieldConfig.Defaults = defaultValueBool
}
default:
if defaultValue != "" {
return fmt.Errorf("field `%s` cannot use default value: unsupported type: %s", field.Name, fieldType)
}
// check if it's a pointer
if fieldValue.Kind() == reflect.Ptr {
// check if the pointer is nil
if fieldValue.IsNil() {
fieldConfig.IsEmpty = true
} else {
fieldConfig.CurrentValue = fieldValue.Elem().Addr()
fieldConfig.Delegated = true
}
} else {
fieldConfig.Delegated = true
}
}
// now check if the field is nullable interface
switch fieldValue.Interface().(type) {
case null.String:
if fieldValue.Interface().(null.String).IsZero() {
fieldConfig.IsEmpty = true
}
case null.Int:
if fieldValue.Interface().(null.Int).IsZero() {
fieldConfig.IsEmpty = true
}
case null.Bool:
if fieldValue.Interface().(null.Bool).IsZero() {
fieldConfig.IsEmpty = true
}
case []string:
if len(fieldValue.Interface().([]string)) == 0 {
fieldConfig.IsEmpty = true
}
}
// now check if the field has required_if
requiredIf := field.Tag.Get("required_if")
if requiredIf != "" {
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)
}
fieldConfig.RequiredIf[partVal[0]] = partVal[1]
}
}
// check if the field can use one_of
if !canUseOneOff && len(fieldConfig.OneOf) > 0 {
return fmt.Errorf("field `%s` cannot use one_of: unsupported type: %s", field.Name, fieldType)
}
fields[field.Name] = fieldConfig
}
if err := validateFields(config, fields); err != nil {
return err
}
return nil
}
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 {
return err
}
fieldConfig.populate(config)
}
return nil
}
func (f *FieldConfig) validate(fields map[string]FieldConfig) error {
var required bool
var err error
if required, err = f.validateRequired(fields); err != nil {
return err
}
// check if the field needs to be updated and set defaults if needed
if err := f.checkIfFieldNeedsUpdate(); err != nil {
return err
}
// then we can check if the field is one_of
if err := f.validateOneOf(); err != nil {
return err
}
// and validate the type
if err := f.validateField(); err != nil {
return err
}
// if the field is delegated, we need to validate the nested field
// but before that, let's check if the field is required
if required && f.Delegated {
if err := setDefaultsAndValidate(f.CurrentValue.(reflect.Value).Interface(), false); err != nil {
return err
}
}
return nil
}
func (f *FieldConfig) populate(config interface{}) {
// update the field if it's not empty
if !f.shouldUpdateValue {
return
}
reflect.ValueOf(config).Elem().FieldByName(f.Name).Set(reflect.ValueOf(f.CurrentValue))
}
func (f *FieldConfig) checkIfFieldNeedsUpdate() error {
// populate the field if it's empty and has a default value
if f.IsEmpty && f.Defaults != nil {
switch f.CurrentValue.(type) {
case null.String:
f.CurrentValue = null.StringFrom(f.Defaults.(string))
case null.Int:
f.CurrentValue = null.IntFrom(int64(f.Defaults.(int)))
case null.Bool:
f.CurrentValue = null.BoolFrom(f.Defaults.(bool))
case string:
f.CurrentValue = f.Defaults.(string)
case int:
f.CurrentValue = f.Defaults.(int)
case bool:
f.CurrentValue = f.Defaults.(bool)
case []string:
f.CurrentValue = f.Defaults.([]string)
default:
return fmt.Errorf("field `%s` cannot use default value: unsupported type: %s", f.Name, f.TypeString)
}
f.shouldUpdateValue = true
}
return nil
}
func (f *FieldConfig) validateRequired(fields map[string]FieldConfig) (bool, error) {
var required = f.Required
// if the field is not required, we need to check if it's required_if
if !required && len(f.RequiredIf) > 0 {
for key, value := range f.RequiredIf {
// check if the field's result matches the required_if
// right now we only support string and int
requiredField, ok := fields[key]
if !ok {
return required, fmt.Errorf("required_if field `%s` not found", key)
}
switch requiredField.CurrentValue.(type) {
case string:
if requiredField.CurrentValue.(string) == value.(string) {
required = true
}
case int:
if requiredField.CurrentValue.(int) == value.(int) {
required = true
}
case null.String:
if !requiredField.CurrentValue.(null.String).IsZero() &&
requiredField.CurrentValue.(null.String).String == value.(string) {
required = true
}
case null.Int:
if !requiredField.CurrentValue.(null.Int).IsZero() &&
requiredField.CurrentValue.(null.Int).Int64 == value.(int64) {
required = true
}
}
// if the field is required, we can break the loop
// because we only need one of the required_if fields to be true
if required {
break
}
}
}
if required && f.IsEmpty {
return false, fmt.Errorf("field `%s` is required", f.Name)
}
return required, nil
}
func checkIfSliceContains(slice []string, one_of []string) bool {
for _, oneOf := range one_of {
if slices.Contains(slice, oneOf) {
return true
}
}
return false
}
func (f *FieldConfig) validateOneOf() error {
if len(f.OneOf) == 0 {
return nil
}
var val []string
switch f.CurrentValue.(type) {
case string:
val = []string{f.CurrentValue.(string)}
case null.String:
val = []string{f.CurrentValue.(null.String).String}
case []string:
// let's validate the value here
val = f.CurrentValue.([]string)
default:
return fmt.Errorf("field `%s` cannot use one_of: unsupported type: %s", f.Name, f.TypeString)
}
if !checkIfSliceContains(val, f.OneOf) {
return fmt.Errorf(
"field `%s` is not one of the allowed values: %s, current value: %s",
f.Name,
strings.Join(f.OneOf, ", "),
strings.Join(val, ", "),
)
}
return nil
}
func (f *FieldConfig) validateField() error {
if len(f.ValidateTypes) == 0 || f.IsEmpty {
return nil
}
val, err := toString(f.CurrentValue)
if err != nil {
return fmt.Errorf("field `%s` cannot use validate_type: %s", f.Name, err)
}
if val == "" {
return nil
}
for _, validateType := range f.ValidateTypes {
switch validateType {
case "ipv4":
if net.ParseIP(val).To4() == nil {
return fmt.Errorf("field `%s` is not a valid IPv4 address: %s", f.Name, val)
}
case "ipv6":
if net.ParseIP(val).To16() == nil {
return fmt.Errorf("field `%s` is not a valid IPv6 address: %s", f.Name, 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)
}
case "hostname":
if _, err := idna.Lookup.ToASCII(val); err != nil {
return fmt.Errorf("field `%s` is not a valid hostname: %s", f.Name, val)
}
case "proxy":
if url, err := url.Parse(val); err != nil || (url.Scheme != "http" && url.Scheme != "https") || url.Host == "" {
return fmt.Errorf("field `%s` is not a valid HTTP proxy URL: %s", f.Name, val)
}
default:
return fmt.Errorf("field `%s` cannot use validate_type: unsupported validator: %s", f.Name, validateType)
}
}
return nil
}

View File

@ -0,0 +1,117 @@
package confparser
import (
"net"
"testing"
"time"
"github.com/guregu/null/v6"
)
type testIPv6Address struct { //nolint:unused
Address net.IP `json:"address"`
Prefix net.IPNet `json:"prefix"`
ValidLifetime *time.Time `json:"valid_lifetime"`
PreferredLifetime *time.Time `json:"preferred_lifetime"`
Scope int `json:"scope"`
}
type testIPv4StaticConfig struct {
Address null.String `json:"address" validate_type:"ipv4" required:"true"`
Netmask null.String `json:"netmask" validate_type:"ipv4" required:"true"`
Gateway null.String `json:"gateway" validate_type:"ipv4" required:"true"`
DNS []string `json:"dns" validate_type:"ipv4" required:"true"`
}
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"`
}
type testNetworkConfig struct {
Hostname null.String `json:"hostname,omitempty"`
Domain null.String `json:"domain,omitempty"`
IPv4Mode null.String `json:"ipv4_mode" one_of:"dhcp,static,disabled" default:"dhcp"`
IPv4Static *testIPv4StaticConfig `json:"ipv4_static,omitempty" required_if:"IPv4Mode=static"`
IPv6Mode null.String `json:"ipv6_mode" one_of:"slaac,dhcpv6,slaac_and_dhcpv6,static,link_local,disabled" default:"slaac"`
IPv6Static *testIPv6StaticConfig `json:"ipv6_static,omitempty" required_if:"IPv6Mode=static"`
LLDPMode null.String `json:"lldp_mode,omitempty" one_of:"disabled,basic,all" default:"basic"`
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"`
TimeSyncOrdering []string `json:"time_sync_ordering,omitempty" one_of:"http,ntp,ntp_dhcp,ntp_user_provided,http_user_provided" default:"ntp,http"`
TimeSyncDisableFallback null.Bool `json:"time_sync_disable_fallback,omitempty" default:"false"`
TimeSyncParallel null.Int `json:"time_sync_parallel,omitempty" default:"4"`
TimeSyncNTPServers []string `json:"time_sync_ntp_servers,omitempty" validate_type:"ipv4_or_ipv6" required_if:"TimeSyncOrdering=ntp_user_provided"`
TimeSyncHTTPUrls []string `json:"time_sync_http_urls,omitempty" validate_type:"url" required_if:"TimeSyncOrdering=http_user_provided"`
}
func TestValidateConfig(t *testing.T) {
config := &testNetworkConfig{}
err := SetDefaultsAndValidate(config)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
}
func TestValidateIPv4StaticConfigNetmaskRequiredIfStatic(t *testing.T) {
config := &testNetworkConfig{
IPv4Static: &testIPv4StaticConfig{
Address: null.StringFrom("192.168.1.1"),
Gateway: null.StringFrom("192.168.1.1"),
},
IPv4Mode: null.StringFrom("static"),
}
err := SetDefaultsAndValidate(config)
if err == nil {
t.Fatalf("expected error, got nil")
}
}
func TestValidateIPv4StaticConfigNetmaskNotRequiredIfStatic(t *testing.T) {
config := &testNetworkConfig{
IPv4Static: &testIPv4StaticConfig{
Address: null.StringFrom("192.168.1.1"),
Gateway: null.StringFrom("192.168.1.1"),
},
}
err := SetDefaultsAndValidate(config)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
}
func TestValidateIPv4StaticConfigRequiredIf(t *testing.T) {
config := &testNetworkConfig{
IPv4Mode: null.StringFrom("static"),
}
err := SetDefaultsAndValidate(config)
if err == nil {
t.Fatalf("expected error, got nil")
}
}
func TestValidateIPv4StaticConfigValidateType(t *testing.T) {
config := &testNetworkConfig{
IPv4Static: &testIPv4StaticConfig{
Address: null.StringFrom("X"),
Netmask: null.StringFrom("255.255.255.0"),
Gateway: null.StringFrom("192.168.1.1"),
DNS: []string{"8.8.8.8", "8.8.4.4"},
},
IPv4Mode: null.StringFrom("static"),
}
err := SetDefaultsAndValidate(config)
if err == nil {
t.Fatalf("expected error, got nil")
}
}

View File

@ -0,0 +1,28 @@
package confparser
import (
"fmt"
"reflect"
"strings"
"github.com/guregu/null/v6"
)
func splitString(s string) []string {
if s == "" {
return []string{}
}
return strings.Split(s, ",")
}
func toString(v interface{}) (string, error) {
switch v := v.(type) {
case string:
return v, nil
case null.String:
return v.String, nil
}
return "", fmt.Errorf("unsupported type: %s", reflect.TypeOf(v))
}

197
internal/logging/logger.go Normal file
View File

@ -0,0 +1,197 @@
package logging
import (
"fmt"
"io"
"os"
"strings"
"sync"
"time"
"github.com/rs/zerolog"
)
type Logger struct {
l *zerolog.Logger
scopeLoggers map[string]*zerolog.Logger
scopeLevels map[string]zerolog.Level
scopeLevelMutex sync.Mutex
defaultLogLevelFromEnv zerolog.Level
defaultLogLevelFromConfig zerolog.Level
defaultLogLevel zerolog.Level
}
const (
defaultLogLevel = zerolog.ErrorLevel
)
type logOutput struct {
mu *sync.Mutex
}
func (w *logOutput) Write(p []byte) (n int, err error) {
w.mu.Lock()
defer w.mu.Unlock()
// TODO: write to file or syslog
if sseServer != nil {
// use a goroutine to avoid blocking the Write method
go func() {
sseServer.Message <- string(p)
}()
}
return len(p), nil
}
var (
consoleLogOutput io.Writer = zerolog.ConsoleWriter{
Out: os.Stdout,
TimeFormat: time.RFC3339,
PartsOrder: []string{"time", "level", "scope", "component", "message"},
FieldsExclude: []string{"scope", "component"},
FormatPartValueByName: func(value interface{}, name string) string {
val := fmt.Sprintf("%s", value)
if name == "component" {
if value == nil {
return "-"
}
}
return val
},
}
fileLogOutput io.Writer = &logOutput{mu: &sync.Mutex{}}
defaultLogOutput = zerolog.MultiLevelWriter(consoleLogOutput, fileLogOutput)
zerologLevels = map[string]zerolog.Level{
"DISABLE": zerolog.Disabled,
"NOLEVEL": zerolog.NoLevel,
"PANIC": zerolog.PanicLevel,
"FATAL": zerolog.FatalLevel,
"ERROR": zerolog.ErrorLevel,
"WARN": zerolog.WarnLevel,
"INFO": zerolog.InfoLevel,
"DEBUG": zerolog.DebugLevel,
"TRACE": zerolog.TraceLevel,
}
)
func NewLogger(zerologLogger zerolog.Logger) *Logger {
return &Logger{
l: &zerologLogger,
scopeLoggers: make(map[string]*zerolog.Logger),
scopeLevels: make(map[string]zerolog.Level),
scopeLevelMutex: sync.Mutex{},
defaultLogLevelFromEnv: -2,
defaultLogLevelFromConfig: -2,
defaultLogLevel: defaultLogLevel,
}
}
func (l *Logger) updateLogLevel() {
l.scopeLevelMutex.Lock()
defer l.scopeLevelMutex.Unlock()
l.scopeLevels = make(map[string]zerolog.Level)
finalDefaultLogLevel := l.defaultLogLevel
for name, level := range zerologLevels {
env := os.Getenv(fmt.Sprintf("JETKVM_LOG_%s", name))
if env == "" {
env = os.Getenv(fmt.Sprintf("PION_LOG_%s", name))
}
if env == "" {
env = os.Getenv(fmt.Sprintf("PIONS_LOG_%s", name))
}
if env == "" {
continue
}
if strings.ToLower(env) == "all" {
l.defaultLogLevelFromEnv = level
if finalDefaultLogLevel > level {
finalDefaultLogLevel = level
}
continue
}
scopes := strings.Split(strings.ToLower(env), ",")
for _, scope := range scopes {
l.scopeLevels[scope] = level
}
}
l.defaultLogLevel = finalDefaultLogLevel
}
func (l *Logger) getScopeLoggerLevel(scope string) zerolog.Level {
if l.scopeLevels == nil {
l.updateLogLevel()
}
var scopeLevel zerolog.Level
if l.defaultLogLevelFromConfig != -2 {
scopeLevel = l.defaultLogLevelFromConfig
}
if l.defaultLogLevelFromEnv != -2 {
scopeLevel = l.defaultLogLevelFromEnv
}
// if the scope is not in the map, use the default level from the root logger
if level, ok := l.scopeLevels[scope]; ok {
scopeLevel = level
}
return scopeLevel
}
func (l *Logger) newScopeLogger(scope string) zerolog.Logger {
scopeLevel := l.getScopeLoggerLevel(scope)
logger := l.l.Level(scopeLevel).With().Str("component", scope).Logger()
return logger
}
func (l *Logger) getLogger(scope string) *zerolog.Logger {
logger, ok := l.scopeLoggers[scope]
if !ok || logger == nil {
scopeLogger := l.newScopeLogger(scope)
l.scopeLoggers[scope] = &scopeLogger
}
return l.scopeLoggers[scope]
}
func (l *Logger) UpdateLogLevel(configDefaultLogLevel string) {
needUpdate := false
if configDefaultLogLevel != "" {
if logLevel, ok := zerologLevels[configDefaultLogLevel]; ok {
l.defaultLogLevelFromConfig = logLevel
} else {
l.l.Warn().Str("logLevel", configDefaultLogLevel).Msg("invalid defaultLogLevel from config, using ERROR")
}
if l.defaultLogLevelFromConfig != l.defaultLogLevel {
needUpdate = true
}
}
l.updateLogLevel()
if needUpdate {
for scope, logger := range l.scopeLoggers {
currentLevel := logger.GetLevel()
targetLevel := l.getScopeLoggerLevel(scope)
if currentLevel != targetLevel {
*logger = l.newScopeLogger(scope)
}
}
}
}

63
internal/logging/pion.go Normal file
View File

@ -0,0 +1,63 @@
package logging
import (
"github.com/pion/logging"
"github.com/rs/zerolog"
)
type pionLogger struct {
logger *zerolog.Logger
}
// Print all messages except trace.
func (c pionLogger) Trace(msg string) {
c.logger.Trace().Msg(msg)
}
func (c pionLogger) Tracef(format string, args ...interface{}) {
c.logger.Trace().Msgf(format, args...)
}
func (c pionLogger) Debug(msg string) {
c.logger.Debug().Msg(msg)
}
func (c pionLogger) Debugf(format string, args ...interface{}) {
c.logger.Debug().Msgf(format, args...)
}
func (c pionLogger) Info(msg string) {
c.logger.Info().Msg(msg)
}
func (c pionLogger) Infof(format string, args ...interface{}) {
c.logger.Info().Msgf(format, args...)
}
func (c pionLogger) Warn(msg string) {
c.logger.Warn().Msg(msg)
}
func (c pionLogger) Warnf(format string, args ...interface{}) {
c.logger.Warn().Msgf(format, args...)
}
func (c pionLogger) Error(msg string) {
c.logger.Error().Msg(msg)
}
func (c pionLogger) Errorf(format string, args ...interface{}) {
c.logger.Error().Msgf(format, args...)
}
// customLoggerFactory satisfies the interface logging.LoggerFactory
// This allows us to create different loggers per subsystem. So we can
// add custom behavior.
type pionLoggerFactory struct{}
func (c pionLoggerFactory) NewLogger(subsystem string) logging.LeveledLogger {
logger := rootLogger.getLogger(subsystem).With().
Str("scope", "pion").
Str("component", subsystem).
Logger()
return pionLogger{logger: &logger}
}
var defaultLoggerFactory = &pionLoggerFactory{}
func GetPionDefaultLoggerFactory() logging.LoggerFactory {
return defaultLoggerFactory
}

20
internal/logging/root.go Normal file
View File

@ -0,0 +1,20 @@
package logging
import "github.com/rs/zerolog"
var (
rootZerologLogger = zerolog.New(defaultLogOutput).With().
Str("scope", "jetkvm").
Timestamp().
Stack().
Logger()
rootLogger = NewLogger(rootZerologLogger)
)
func GetRootLogger() *Logger {
return rootLogger
}
func GetSubsystemLogger(subsystem string) *zerolog.Logger {
return rootLogger.getLogger(subsystem)
}

137
internal/logging/sse.go Normal file
View File

@ -0,0 +1,137 @@
package logging
import (
"embed"
"io"
"net/http"
"github.com/gin-gonic/gin"
"github.com/rs/zerolog"
)
//go:embed sse.html
var sseHTML embed.FS
type sseEvent struct {
Message chan string
NewClients chan chan string
ClosedClients chan chan string
TotalClients map[chan string]bool
}
// New event messages are broadcast to all registered client connection channels
type sseClientChan chan string
var (
sseServer *sseEvent
sseLogger *zerolog.Logger
)
func init() {
sseServer = newSseServer()
sseLogger = GetSubsystemLogger("sse")
}
// Initialize event and Start procnteessing requests
func newSseServer() (event *sseEvent) {
event = &sseEvent{
Message: make(chan string),
NewClients: make(chan chan string),
ClosedClients: make(chan chan string),
TotalClients: make(map[chan string]bool),
}
go event.listen()
return
}
// It Listens all incoming requests from clients.
// Handles addition and removal of clients and broadcast messages to clients.
func (stream *sseEvent) listen() {
for {
select {
// Add new available client
case client := <-stream.NewClients:
stream.TotalClients[client] = true
sseLogger.Info().
Int("total_clients", len(stream.TotalClients)).
Msg("new client connected")
// Remove closed client
case client := <-stream.ClosedClients:
delete(stream.TotalClients, client)
close(client)
sseLogger.Info().Int("total_clients", len(stream.TotalClients)).Msg("client disconnected")
// Broadcast message to client
case eventMsg := <-stream.Message:
for clientMessageChan := range stream.TotalClients {
select {
case clientMessageChan <- eventMsg:
// Message sent successfully
default:
// Failed to send, dropping message
}
}
}
}
}
func (stream *sseEvent) serveHTTP() gin.HandlerFunc {
return func(c *gin.Context) {
clientChan := make(sseClientChan)
stream.NewClients <- clientChan
go func() {
<-c.Writer.CloseNotify()
for range clientChan {
}
stream.ClosedClients <- clientChan
}()
c.Set("clientChan", clientChan)
c.Next()
}
}
func sseHeadersMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
if c.Request.Method == "GET" && c.NegotiateFormat(gin.MIMEHTML) == gin.MIMEHTML {
c.FileFromFS("/sse.html", http.FS(sseHTML))
c.Status(http.StatusOK)
c.Abort()
return
}
c.Writer.Header().Set("Content-Type", "text/event-stream")
c.Writer.Header().Set("Cache-Control", "no-cache")
c.Writer.Header().Set("Connection", "keep-alive")
c.Writer.Header().Set("Transfer-Encoding", "chunked")
c.Writer.Header().Set("Access-Control-Allow-Origin", "*")
c.Next()
}
}
func AttachSSEHandler(router *gin.RouterGroup) {
router.StaticFS("/log-stream", http.FS(sseHTML))
router.GET("/log-stream", sseHeadersMiddleware(), sseServer.serveHTTP(), func(c *gin.Context) {
v, ok := c.Get("clientChan")
if !ok {
return
}
clientChan, ok := v.(sseClientChan)
if !ok {
return
}
c.Stream(func(w io.Writer) bool {
if msg, ok := <-clientChan; ok {
c.SSEvent("message", msg)
return true
}
return false
})
})
}

319
internal/logging/sse.html Normal file
View File

@ -0,0 +1,319 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Server Sent Event</title>
<style>
.main-container {
display: flex;
flex-direction: column;
gap: 10px;
font-family: 'Hack', monospace;
font-size: 12px;
}
#loading {
font-style: italic;
}
.log-entry {
font-size: 12px;
line-height: 1.2;
}
.log-entry > span {
min-width: 0;
overflow-wrap: break-word;
word-break: break-word;
margin-right: 10px;
}
.log-entry > span:last-child {
margin-right: 0;
}
.log-entry.log-entry-trace .log-level {
color: blue;
}
.log-entry.log-entry-debug .log-level {
color: gray;
}
.log-entry.log-entry-info .log-level {
color: green;
}
.log-entry.log-entry-warn .log-level {
color: yellow;
}
.log-entry.log-entry-error .log-level,
.log-entry.log-entry-fatal .log-level,
.log-entry.log-entry-panic .log-level {
color: red;
}
.log-entry.log-entry-info .log-message,
.log-entry.log-entry-warn .log-message,
.log-entry.log-entry-error .log-message,
.log-entry.log-entry-fatal .log-message,
.log-entry.log-entry-panic .log-message {
font-weight: bold;
}
.log-timestamp {
color: #666;
min-width: 150px;
}
.log-level {
font-size: 12px;
min-width: 50px;
}
.log-scope {
font-size: 12px;
min-width: 40px;
}
.log-component {
font-size: 12px;
min-width: 80px;
}
.log-message {
font-size: 12px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 500px;
}
.log-extras {
color: #000;
}
.log-extras .log-extras-header {
font-weight: bold;
color:cornflowerblue;
}
</style>
</head>
<body>
<div class="main-container">
<div id="header">
<span id="loading">
Connecting to log stream...
</span>
<span id="stats">
</span>
</div>
<div id="event-data">
</div>
</div>
</body>
<script>
class LogStream {
constructor(url, eventDataElement, loadingElement, statsElement) {
this.url = url;
this.eventDataElement = eventDataElement;
this.loadingElement = loadingElement;
this.statsElement = statsElement;
this.stream = null;
this.reconnectAttempts = 0;
this.maxReconnectAttempts = 10;
this.reconnectDelay = 1000; // Start with 1 second
this.maxReconnectDelay = 30000; // Max 30 seconds
this.isConnecting = false;
this.totalMessages = 0;
this.connect();
}
connect() {
if (this.isConnecting) return;
this.isConnecting = true;
this.loadingElement.innerText = "Connecting to log stream...";
this.stream = new EventSource(this.url);
this.stream.onopen = () => {
this.isConnecting = false;
this.reconnectAttempts = 0;
this.reconnectDelay = 1000;
this.loadingElement.innerText = "Log stream connected.";
this.totalMessages = 0;
this.totalBytes = 0;
};
this.stream.onmessage = (event) => {
this.totalBytes += event.data.length;
this.totalMessages++;
const data = JSON.parse(event.data);
this.addLogEntry(data);
this.updateStats();
};
this.stream.onerror = () => {
this.isConnecting = false;
this.loadingElement.innerText = "Log stream disconnected.";
this.stream.close();
this.handleReconnect();
};
}
updateStats() {
this.statsElement.innerHTML = `Messages: <strong>${this.totalMessages}</strong>, Bytes: <strong>${this.totalBytes}</strong> `;
}
handleReconnect() {
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
this.loadingElement.innerText = "Failed to reconnect after multiple attempts";
return;
}
this.reconnectAttempts++;
this.reconnectDelay = Math.min(this.reconnectDelay * 1, this.maxReconnectDelay);
this.loadingElement.innerText = `Reconnecting in ${this.reconnectDelay/1000} seconds... (Attempt ${this.reconnectAttempts}/${this.maxReconnectAttempts})`;
setTimeout(() => {
this.connect();
}, this.reconnectDelay);
}
addLogEntry(data) {
const el = document.createElement("div");
el.className = "log-entry log-entry-" + data.level;
const timestamp = document.createElement("span");
timestamp.className = "log-timestamp";
timestamp.innerText = data.time;
el.appendChild(timestamp);
const level = document.createElement("span");
level.className = "log-level";
level.innerText = this.shortLogLevel(data.level);
el.appendChild(level);
const scope = document.createElement("span");
scope.className = "log-scope";
scope.innerText = data.scope;
el.appendChild(scope);
const component = document.createElement("span");
component.className = "log-component";
component.innerText = data.component;
el.appendChild(component);
const message = document.createElement("span");
message.className = "log-message";
message.innerText = data.message;
el.appendChild(message);
this.addLogExtras(el, data);
this.eventDataElement.appendChild(el);
window.scrollTo(0, document.body.scrollHeight);
}
shortLogLevel(level) {
switch (level) {
case "trace":
return "TRC";
case "debug":
return "DBG";
case "info":
return "INF";
case "warn":
return "WRN";
case "error":
return "ERR";
case "fatal":
return "FTL";
case "panic":
return "PNC";
default:
return level;
}
}
addLogExtras(el, data) {
const excludeKeys = [
"timestamp",
"time",
"level",
"scope",
"component",
"message",
];
const extras = {};
for (const key in data) {
if (excludeKeys.includes(key)) {
continue;
}
extras[key] = data[key];
}
for (const key in extras) {
const extra = document.createElement("span");
extra.className = "log-extras log-extras-" + key;
const extraKey = document.createElement("span");
extraKey.className = "log-extras-header";
extraKey.innerText = key + '=';
extra.appendChild(extraKey);
const extraValue = document.createElement("span");
extraValue.className = "log-extras-value";
let value = extras[key];
if (typeof value === 'object') {
value = JSON.stringify(value);
}
extraValue.innerText = value;
extra.appendChild(extraValue);
el.appendChild(extra);
}
}
disconnect() {
if (this.stream) {
this.stream.close();
this.stream = null;
}
}
}
// Initialize the log stream when the page loads
document.addEventListener('DOMContentLoaded', () => {
const logStream = new LogStream(
"/developer/log-stream",
document.getElementById("event-data"),
document.getElementById("loading"),
document.getElementById("stats"),
);
// Clean up when the page is unloaded
window.addEventListener('beforeunload', () => {
logStream.disconnect();
});
});
</script>
</html>

32
internal/logging/utils.go Normal file
View File

@ -0,0 +1,32 @@
package logging
import (
"fmt"
"os"
"github.com/rs/zerolog"
)
var defaultLogger = zerolog.New(os.Stdout).Level(zerolog.InfoLevel)
func GetDefaultLogger() *zerolog.Logger {
return &defaultLogger
}
func ErrorfL(l *zerolog.Logger, format string, err error, args ...interface{}) error {
// TODO: move rootLogger to logging package
if l == nil {
l = &defaultLogger
}
l.Error().Err(err).Msgf(format, args...)
if err == nil {
return fmt.Errorf(format, args...)
}
err_msg := err.Error() + ": %v"
err_args := append(args, err)
return fmt.Errorf(err_msg, err_args...)
}

190
internal/mdns/mdns.go Normal file
View File

@ -0,0 +1,190 @@
package mdns
import (
"fmt"
"net"
"reflect"
"strings"
"sync"
"github.com/jetkvm/kvm/internal/logging"
pion_mdns "github.com/pion/mdns/v2"
"github.com/rs/zerolog"
"golang.org/x/net/ipv4"
"golang.org/x/net/ipv6"
)
type MDNS struct {
conn *pion_mdns.Conn
lock sync.Mutex
l *zerolog.Logger
localNames []string
listenOptions *MDNSListenOptions
}
type MDNSListenOptions struct {
IPv4 bool
IPv6 bool
}
type MDNSOptions struct {
Logger *zerolog.Logger
LocalNames []string
ListenOptions *MDNSListenOptions
}
const (
DefaultAddressIPv4 = pion_mdns.DefaultAddressIPv4
DefaultAddressIPv6 = pion_mdns.DefaultAddressIPv6
)
func NewMDNS(opts *MDNSOptions) (*MDNS, error) {
if opts.Logger == nil {
opts.Logger = logging.GetDefaultLogger()
}
if opts.ListenOptions == nil {
opts.ListenOptions = &MDNSListenOptions{
IPv4: true,
IPv6: true,
}
}
return &MDNS{
l: opts.Logger,
lock: sync.Mutex{},
localNames: opts.LocalNames,
listenOptions: opts.ListenOptions,
}, nil
}
func (m *MDNS) start(allowRestart bool) error {
m.lock.Lock()
defer m.lock.Unlock()
if m.conn != nil {
if !allowRestart {
return fmt.Errorf("mDNS server already running")
}
m.conn.Close()
}
if m.listenOptions == nil {
return fmt.Errorf("listen options not set")
}
if !m.listenOptions.IPv4 && !m.listenOptions.IPv6 {
m.l.Info().Msg("mDNS server disabled")
return nil
}
var (
addr4, addr6 *net.UDPAddr
l4, l6 *net.UDPConn
p4 *ipv4.PacketConn
p6 *ipv6.PacketConn
err error
)
if m.listenOptions.IPv4 {
addr4, err = net.ResolveUDPAddr("udp4", DefaultAddressIPv4)
if err != nil {
return err
}
l4, err = net.ListenUDP("udp4", addr4)
if err != nil {
return err
}
p4 = ipv4.NewPacketConn(l4)
}
if m.listenOptions.IPv6 {
addr6, err = net.ResolveUDPAddr("udp6", DefaultAddressIPv6)
if err != nil {
return err
}
l6, err = net.ListenUDP("udp6", addr6)
if err != nil {
return err
}
p6 = ipv6.NewPacketConn(l6)
}
scopeLogger := m.l.With().
Interface("local_names", m.localNames).
Bool("ipv4", m.listenOptions.IPv4).
Bool("ipv6", m.listenOptions.IPv6).
Logger()
newLocalNames := make([]string, len(m.localNames))
for i, name := range m.localNames {
newLocalNames[i] = strings.TrimRight(strings.ToLower(name), ".")
if !strings.HasSuffix(newLocalNames[i], ".local") {
newLocalNames[i] = newLocalNames[i] + ".local"
}
}
mDNSConn, err := pion_mdns.Server(p4, p6, &pion_mdns.Config{
LocalNames: newLocalNames,
LoggerFactory: logging.GetPionDefaultLoggerFactory(),
})
if err != nil {
scopeLogger.Warn().Err(err).Msg("failed to start mDNS server")
return err
}
m.conn = mDNSConn
scopeLogger.Info().Msg("mDNS server started")
return nil
}
func (m *MDNS) Start() error {
return m.start(false)
}
func (m *MDNS) Restart() error {
return m.start(true)
}
func (m *MDNS) Stop() error {
m.lock.Lock()
defer m.lock.Unlock()
if m.conn == nil {
return nil
}
return m.conn.Close()
}
func (m *MDNS) SetLocalNames(localNames []string, always bool) error {
if reflect.DeepEqual(m.localNames, localNames) && !always {
return nil
}
m.localNames = localNames
_ = m.Restart()
return nil
}
func (m *MDNS) SetListenOptions(listenOptions *MDNSListenOptions) error {
if m.listenOptions != nil &&
m.listenOptions.IPv4 == listenOptions.IPv4 &&
m.listenOptions.IPv6 == listenOptions.IPv6 {
return nil
}
m.listenOptions = listenOptions
_ = m.Restart()
return nil
}

1
internal/mdns/utils.go Normal file
View File

@ -0,0 +1 @@
package mdns

127
internal/network/config.go Normal file
View File

@ -0,0 +1,127 @@
package network
import (
"fmt"
"net"
"net/http"
"net/url"
"time"
"github.com/guregu/null/v6"
"github.com/jetkvm/kvm/internal/mdns"
"golang.org/x/net/idna"
)
type IPv6Address struct {
Address net.IP `json:"address"`
Prefix net.IPNet `json:"prefix"`
ValidLifetime *time.Time `json:"valid_lifetime"`
PreferredLifetime *time.Time `json:"preferred_lifetime"`
Scope int `json:"scope"`
}
type IPv4StaticConfig struct {
Address null.String `json:"address,omitempty" validate_type:"ipv4" required:"true"`
Netmask null.String `json:"netmask,omitempty" validate_type:"ipv4" required:"true"`
Gateway null.String `json:"gateway,omitempty" validate_type:"ipv4" required:"true"`
DNS []string `json:"dns,omitempty" validate_type:"ipv4" required:"true"`
}
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"`
Gateway null.String `json:"gateway,omitempty" validate_type:"ipv6" required:"true"`
DNS []string `json:"dns,omitempty" validate_type:"ipv6" required:"true"`
}
type NetworkConfig struct {
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"`
IPv4Mode null.String `json:"ipv4_mode,omitempty" one_of:"dhcp,static,disabled" default:"dhcp"`
IPv4Static *IPv4StaticConfig `json:"ipv4_static,omitempty" required_if:"IPv4Mode=static"`
IPv6Mode null.String `json:"ipv6_mode,omitempty" one_of:"slaac,dhcpv6,slaac_and_dhcpv6,static,link_local,disabled" default:"slaac"`
IPv6Static *IPv6StaticConfig `json:"ipv6_static,omitempty" required_if:"IPv6Mode=static"`
LLDPMode null.String `json:"lldp_mode,omitempty" one_of:"disabled,basic,all" default:"basic"`
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"`
TimeSyncOrdering []string `json:"time_sync_ordering,omitempty" one_of:"http,ntp,ntp_dhcp,ntp_user_provided,http_user_provided" default:"ntp,http"`
TimeSyncDisableFallback null.Bool `json:"time_sync_disable_fallback,omitempty" default:"false"`
TimeSyncParallel null.Int `json:"time_sync_parallel,omitempty" default:"4"`
TimeSyncNTPServers []string `json:"time_sync_ntp_servers,omitempty" validate_type:"ipv4_or_ipv6" required_if:"TimeSyncOrdering=ntp_user_provided"`
TimeSyncHTTPUrls []string `json:"time_sync_http_urls,omitempty" validate_type:"url" required_if:"TimeSyncOrdering=http_user_provided"`
}
func (c *NetworkConfig) GetMDNSMode() *mdns.MDNSListenOptions {
mode := c.MDNSMode.String
listenOptions := &mdns.MDNSListenOptions{
IPv4: true,
IPv6: true,
}
switch mode {
case "ipv4_only":
listenOptions.IPv6 = false
case "ipv6_only":
listenOptions.IPv4 = false
case "disabled":
listenOptions.IPv4 = false
listenOptions.IPv6 = false
}
return listenOptions
}
func (s *NetworkConfig) GetTransportProxyFunc() func(*http.Request) (*url.URL, error) {
return func(*http.Request) (*url.URL, error) {
if s.HTTPProxy.String == "" {
return nil, nil
} else {
proxyUrl, _ := url.Parse(s.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())
}

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

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

View File

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

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

@ -0,0 +1,388 @@
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)
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.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 {
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 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.onInitialCheck(s)
} else if changed {
s.onStateChange(s)
}
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) CheckAndUpdateDhcp() error {
dhcpTargetState, err := s.update()
if err != nil {
return logging.ErrorfL(s.l, "failed to update network state", err)
}
switch dhcpTargetState {
case DhcpTargetStateRenew:
s.l.Info().Msg("renewing DHCP lease")
_ = s.dhcpClient.Renew()
case DhcpTargetStateRelease:
s.l.Info().Msg("releasing DHCP lease")
_ = s.dhcpClient.Release()
case DhcpTargetStateStart:
s.l.Warn().Msg("dhcpTargetStateStart not implemented")
case DhcpTargetStateStop:
s.l.Warn().Msg("dhcpTargetStateStop not implemented")
}
return nil
}
func (s *NetworkInterfaceState) onConfigChange(config *NetworkConfig) {
_ = s.setHostnameIfNotSame()
s.cbConfigChange(config)
}

View File

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

View File

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

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

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

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

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

151
internal/timesync/http.go Normal file
View File

@ -0,0 +1,151 @@
package timesync
import (
"context"
"errors"
"math/rand"
"net/http"
"net/url"
"strconv"
"time"
)
var defaultHTTPUrls = []string{
"http://www.gstatic.com/generate_204",
"http://cp.cloudflare.com/",
"http://edge-http.microsoft.com/captiveportal/generate_204",
// Firefox, Apple, and Microsoft have inconsistent results, so we don't use it
// "http://detectportal.firefox.com/",
// "http://www.apple.com/library/test/success.html",
// "http://www.msftconnecttest.com/connecttest.txt",
}
func (t *TimeSync) queryAllHttpTime(httpUrls []string) (now *time.Time) {
chunkSize := int(t.networkConfig.TimeSyncParallel.ValueOr(4))
t.l.Info().Strs("httpUrls", httpUrls).Int("chunkSize", chunkSize).Msg("querying HTTP URLs")
// shuffle the http urls to avoid always querying the same servers
rand.Shuffle(len(httpUrls), func(i, j int) { httpUrls[i], httpUrls[j] = httpUrls[j], httpUrls[i] })
for i := 0; i < len(httpUrls); i += chunkSize {
chunk := httpUrls[i:min(i+chunkSize, len(httpUrls))]
results := t.queryMultipleHttp(chunk, timeSyncTimeout)
if results != nil {
return results
}
}
return nil
}
func (t *TimeSync) queryMultipleHttp(urls []string, timeout time.Duration) (now *time.Time) {
results := make(chan *time.Time, len(urls))
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
for _, url := range urls {
go func(url string) {
scopedLogger := t.l.With().
Str("http_url", url).
Logger()
metricHttpRequestCount.WithLabelValues(url).Inc()
metricHttpTotalRequestCount.Inc()
startTime := time.Now()
now, response, err := queryHttpTime(
ctx,
url,
timeout,
t.networkConfig.GetTransportProxyFunc(),
)
duration := time.Since(startTime)
metricHttpServerLastRTT.WithLabelValues(url).Set(float64(duration.Milliseconds()))
metricHttpServerRttHistogram.WithLabelValues(url).Observe(float64(duration.Milliseconds()))
status := 0
if response != nil {
status = response.StatusCode
}
metricHttpServerInfo.WithLabelValues(
url,
strconv.Itoa(status),
).Set(1)
if err == nil {
metricHttpTotalSuccessCount.Inc()
metricHttpSuccessCount.WithLabelValues(url).Inc()
requestId := response.Header.Get("X-Request-Id")
if requestId != "" {
requestId = response.Header.Get("X-Msedge-Ref")
}
if requestId == "" {
requestId = response.Header.Get("Cf-Ray")
}
scopedLogger.Info().
Str("time", now.Format(time.RFC3339)).
Int("status", status).
Str("request_id", requestId).
Str("time_taken", duration.String()).
Msg("HTTP server returned time")
cancel()
results <- now
} else if errors.Is(err, context.Canceled) {
metricHttpCancelCount.WithLabelValues(url).Inc()
metricHttpTotalCancelCount.Inc()
results <- nil
} else {
scopedLogger.Warn().
Str("error", err.Error()).
Int("status", status).
Msg("failed to query HTTP server")
results <- nil
}
}(url)
}
for range urls {
result := <-results
if result == nil {
continue
}
now = result
return
}
return
}
func queryHttpTime(
ctx context.Context,
url string,
timeout time.Duration,
proxyFunc func(*http.Request) (*url.URL, error),
) (now *time.Time, response *http.Response, err error) {
transport := http.DefaultTransport.(*http.Transport).Clone()
transport.Proxy = proxyFunc
client := http.Client{
Transport: transport,
Timeout: timeout,
}
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
return nil, nil, err
}
resp, err := client.Do(req)
if err != nil {
return nil, nil, err
}
dateStr := resp.Header.Get("Date")
parsedTime, err := time.Parse(time.RFC1123, dateStr)
if err != nil {
return nil, nil, err
}
return &parsedTime, resp, nil
}

View File

@ -0,0 +1,148 @@
package timesync
import (
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
)
var (
metricTimeSyncStatus = promauto.NewGauge(
prometheus.GaugeOpts{
Name: "jetkvm_timesync_status",
Help: "The status of the timesync, 1 if successful, 0 if not",
},
)
metricTimeSyncCount = promauto.NewCounter(
prometheus.CounterOpts{
Name: "jetkvm_timesync_total",
Help: "The number of times the timesync has been run",
},
)
metricTimeSyncSuccessCount = promauto.NewCounter(
prometheus.CounterOpts{
Name: "jetkvm_timesync_success_total",
Help: "The number of times the timesync has been successful",
},
)
metricRTCUpdateCount = promauto.NewCounter( //nolint:unused
prometheus.CounterOpts{
Name: "jetkvm_timesync_rtc_update_total",
Help: "The number of times the RTC has been updated",
},
)
metricNtpTotalSuccessCount = promauto.NewCounter(
prometheus.CounterOpts{
Name: "jetkvm_timesync_ntp_total_success_total",
Help: "The total number of successful NTP requests",
},
)
metricNtpTotalRequestCount = promauto.NewCounter(
prometheus.CounterOpts{
Name: "jetkvm_timesync_ntp_total_request_total",
Help: "The total number of NTP requests sent",
},
)
metricNtpSuccessCount = promauto.NewCounterVec(
prometheus.CounterOpts{
Name: "jetkvm_timesync_ntp_success_total",
Help: "The number of successful NTP requests",
},
[]string{"url"},
)
metricNtpRequestCount = promauto.NewCounterVec(
prometheus.CounterOpts{
Name: "jetkvm_timesync_ntp_request_total",
Help: "The number of NTP requests sent to the server",
},
[]string{"url"},
)
metricNtpServerLastRTT = promauto.NewGaugeVec(
prometheus.GaugeOpts{
Name: "jetkvm_timesync_ntp_server_last_rtt",
Help: "The last RTT of the NTP server in milliseconds",
},
[]string{"url"},
)
metricNtpServerRttHistogram = promauto.NewHistogramVec(
prometheus.HistogramOpts{
Name: "jetkvm_timesync_ntp_server_rtt",
Help: "The histogram of the RTT of the NTP server in milliseconds",
Buckets: []float64{
10, 25, 50, 100, 200, 300, 500, 1000,
},
},
[]string{"url"},
)
metricNtpServerInfo = promauto.NewGaugeVec(
prometheus.GaugeOpts{
Name: "jetkvm_timesync_ntp_server_info",
Help: "The info of the NTP server",
},
[]string{"url", "reference", "stratum", "precision"},
)
metricHttpTotalSuccessCount = promauto.NewCounter(
prometheus.CounterOpts{
Name: "jetkvm_timesync_http_total_success_total",
Help: "The total number of successful HTTP requests",
},
)
metricHttpTotalRequestCount = promauto.NewCounter(
prometheus.CounterOpts{
Name: "jetkvm_timesync_http_total_request_total",
Help: "The total number of HTTP requests sent",
},
)
metricHttpTotalCancelCount = promauto.NewCounter(
prometheus.CounterOpts{
Name: "jetkvm_timesync_http_total_cancel_total",
Help: "The total number of HTTP requests cancelled",
},
)
metricHttpSuccessCount = promauto.NewCounterVec(
prometheus.CounterOpts{
Name: "jetkvm_timesync_http_success_total",
Help: "The number of successful HTTP requests",
},
[]string{"url"},
)
metricHttpRequestCount = promauto.NewCounterVec(
prometheus.CounterOpts{
Name: "jetkvm_timesync_http_request_total",
Help: "The number of HTTP requests sent to the server",
},
[]string{"url"},
)
metricHttpCancelCount = promauto.NewCounterVec(
prometheus.CounterOpts{
Name: "jetkvm_timesync_http_cancel_total",
Help: "The number of HTTP requests cancelled",
},
[]string{"url"},
)
metricHttpServerLastRTT = promauto.NewGaugeVec(
prometheus.GaugeOpts{
Name: "jetkvm_timesync_http_server_last_rtt",
Help: "The last RTT of the HTTP server in milliseconds",
},
[]string{"url"},
)
metricHttpServerRttHistogram = promauto.NewHistogramVec(
prometheus.HistogramOpts{
Name: "jetkvm_timesync_http_server_rtt",
Help: "The histogram of the RTT of the HTTP server in milliseconds",
Buckets: []float64{
10, 25, 50, 100, 200, 300, 500, 1000,
},
},
[]string{"url"},
)
metricHttpServerInfo = promauto.NewGaugeVec(
prometheus.GaugeOpts{
Name: "jetkvm_timesync_http_server_info",
Help: "The info of the HTTP server",
},
[]string{"url", "http_code"},
)
)

140
internal/timesync/ntp.go Normal file
View File

@ -0,0 +1,140 @@
package timesync
import (
"context"
"math/rand/v2"
"strconv"
"time"
"github.com/beevik/ntp"
)
var defaultNTPServers = []string{
"time.apple.com",
"time.aws.com",
"time.windows.com",
"time.google.com",
"162.159.200.123", // time.cloudflare.com IPv4
"2606:4700:f1::123", // time.cloudflare.com IPv6
"0.pool.ntp.org",
"1.pool.ntp.org",
"2.pool.ntp.org",
"3.pool.ntp.org",
}
func (t *TimeSync) queryNetworkTime(ntpServers []string) (now *time.Time, offset *time.Duration) {
chunkSize := int(t.networkConfig.TimeSyncParallel.ValueOr(4))
t.l.Info().Strs("servers", ntpServers).Int("chunkSize", chunkSize).Msg("querying NTP servers")
// shuffle the ntp servers to avoid always querying the same servers
rand.Shuffle(len(ntpServers), func(i, j int) { ntpServers[i], ntpServers[j] = ntpServers[j], ntpServers[i] })
for i := 0; i < len(ntpServers); i += chunkSize {
chunk := ntpServers[i:min(i+chunkSize, len(ntpServers))]
now, offset := t.queryMultipleNTP(chunk, timeSyncTimeout)
if now != nil {
return now, offset
}
}
return nil, nil
}
type ntpResult struct {
now *time.Time
offset *time.Duration
}
func (t *TimeSync) queryMultipleNTP(servers []string, timeout time.Duration) (now *time.Time, offset *time.Duration) {
results := make(chan *ntpResult, len(servers))
_, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
for _, server := range servers {
go func(server string) {
scopedLogger := t.l.With().
Str("server", server).
Logger()
// increase request count
metricNtpTotalRequestCount.Inc()
metricNtpRequestCount.WithLabelValues(server).Inc()
// query the server
now, response, err := queryNtpServer(server, timeout)
if err != nil {
scopedLogger.Warn().
Str("error", err.Error()).
Msg("failed to query NTP server")
results <- nil
return
}
if response.IsKissOfDeath() {
scopedLogger.Warn().
Str("kiss_code", response.KissCode).
Msg("ignoring NTP server kiss of death")
results <- nil
return
}
rtt := float64(response.RTT.Milliseconds())
// set the last RTT
metricNtpServerLastRTT.WithLabelValues(
server,
).Set(rtt)
// set the RTT histogram
metricNtpServerRttHistogram.WithLabelValues(
server,
).Observe(rtt)
// set the server info
metricNtpServerInfo.WithLabelValues(
server,
response.ReferenceString(),
strconv.Itoa(int(response.Stratum)),
strconv.Itoa(int(response.Precision)),
).Set(1)
// increase success count
metricNtpTotalSuccessCount.Inc()
metricNtpSuccessCount.WithLabelValues(server).Inc()
scopedLogger.Info().
Str("time", now.Format(time.RFC3339)).
Str("reference", response.ReferenceString()).
Float64("rtt", rtt).
Str("clockOffset", response.ClockOffset.String()).
Uint8("stratum", response.Stratum).
Msg("NTP server returned time")
cancel()
results <- &ntpResult{
now: now,
offset: &response.ClockOffset,
}
}(server)
}
for range servers {
result := <-results
if result == nil {
continue
}
now, offset = result.now, result.offset
return
}
return
}
func queryNtpServer(server string, timeout time.Duration) (now *time.Time, response *ntp.Response, err error) {
resp, err := ntp.QueryWithOptions(server, ntp.QueryOptions{Timeout: timeout})
if err != nil {
return nil, nil, err
}
return &resp.Time, resp, nil
}

26
internal/timesync/rtc.go Normal file
View File

@ -0,0 +1,26 @@
package timesync
import (
"fmt"
"os"
)
var (
rtcDeviceSearchPaths = []string{
"/dev/rtc",
"/dev/rtc0",
"/dev/rtc1",
"/dev/misc/rtc",
"/dev/misc/rtc0",
"/dev/misc/rtc1",
}
)
func getRtcDevicePath() (string, error) {
for _, path := range rtcDeviceSearchPaths {
if _, err := os.Stat(path); err == nil {
return path, nil
}
}
return "", fmt.Errorf("rtc device not found")
}

View File

@ -0,0 +1,105 @@
//go:build linux
package timesync
import (
"fmt"
"os"
"time"
"golang.org/x/sys/unix"
)
func TimetoRtcTime(t time.Time) unix.RTCTime {
return unix.RTCTime{
Sec: int32(t.Second()),
Min: int32(t.Minute()),
Hour: int32(t.Hour()),
Mday: int32(t.Day()),
Mon: int32(t.Month() - 1),
Year: int32(t.Year() - 1900),
Wday: int32(0),
Yday: int32(0),
Isdst: int32(0),
}
}
func RtcTimetoTime(t unix.RTCTime) time.Time {
return time.Date(
int(t.Year)+1900,
time.Month(t.Mon+1),
int(t.Mday),
int(t.Hour),
int(t.Min),
int(t.Sec),
0,
time.UTC,
)
}
func (t *TimeSync) getRtcDevice() (*os.File, error) {
if t.rtcDevice == nil {
file, err := os.OpenFile(t.rtcDevicePath, os.O_RDWR, 0666)
if err != nil {
return nil, err
}
t.rtcDevice = file
}
return t.rtcDevice, nil
}
func (t *TimeSync) getRtcDeviceFd() (int, error) {
device, err := t.getRtcDevice()
if err != nil {
return 0, err
}
return int(device.Fd()), nil
}
// Read implements Read for the Linux RTC
func (t *TimeSync) readRtcTime() (time.Time, error) {
fd, err := t.getRtcDeviceFd()
if err != nil {
return time.Time{}, fmt.Errorf("failed to get RTC device fd: %w", err)
}
rtcTime, err := unix.IoctlGetRTCTime(fd)
if err != nil {
return time.Time{}, fmt.Errorf("failed to get RTC time: %w", err)
}
date := RtcTimetoTime(*rtcTime)
return date, nil
}
// Set implements Set for the Linux RTC
// ...
// It might be not accurate as the time consumed by the system call is not taken into account
// but it's good enough for our purposes
func (t *TimeSync) setRtcTime(tu time.Time) error {
rt := TimetoRtcTime(tu)
fd, err := t.getRtcDeviceFd()
if err != nil {
return fmt.Errorf("failed to get RTC device fd: %w", err)
}
currentRtcTime, err := t.readRtcTime()
if err != nil {
return fmt.Errorf("failed to read RTC time: %w", err)
}
t.l.Info().
Interface("rtc_time", tu).
Str("offset", tu.Sub(currentRtcTime).String()).
Msg("set rtc time")
if err := unix.IoctlSetRTCTime(fd, &rt); err != nil {
return fmt.Errorf("failed to set RTC time: %w", err)
}
metricRTCUpdateCount.Inc()
return nil
}

View File

@ -0,0 +1,16 @@
//go:build !linux
package timesync
import (
"errors"
"time"
)
func (t *TimeSync) readRtcTime() (time.Time, error) {
return time.Now(), nil
}
func (t *TimeSync) setRtcTime(tu time.Time) error {
return errors.New("not supported")
}

View File

@ -0,0 +1,259 @@
package timesync
import (
"fmt"
"os"
"os/exec"
"sync"
"time"
"github.com/jetkvm/kvm/internal/network"
"github.com/rs/zerolog"
)
const (
timeSyncRetryStep = 5 * time.Second
timeSyncRetryMaxInt = 1 * time.Minute
timeSyncWaitNetChkInt = 100 * time.Millisecond
timeSyncWaitNetUpInt = 3 * time.Second
timeSyncInterval = 1 * time.Hour
timeSyncTimeout = 2 * time.Second
)
var (
timeSyncRetryInterval = 0 * time.Second
)
type TimeSync struct {
syncLock *sync.Mutex
l *zerolog.Logger
networkConfig *network.NetworkConfig
dhcpNtpAddresses []string
rtcDevicePath string
rtcDevice *os.File //nolint:unused
rtcLock *sync.Mutex
syncSuccess bool
preCheckFunc func() (bool, error)
}
type TimeSyncOptions struct {
PreCheckFunc func() (bool, error)
Logger *zerolog.Logger
NetworkConfig *network.NetworkConfig
}
type SyncMode struct {
Ntp bool
Http bool
Ordering []string
NtpUseFallback bool
HttpUseFallback bool
}
func NewTimeSync(opts *TimeSyncOptions) *TimeSync {
rtcDevice, err := getRtcDevicePath()
if err != nil {
opts.Logger.Error().Err(err).Msg("failed to get RTC device path")
} else {
opts.Logger.Info().Str("path", rtcDevice).Msg("RTC device found")
}
t := &TimeSync{
syncLock: &sync.Mutex{},
l: opts.Logger,
dhcpNtpAddresses: []string{},
rtcDevicePath: rtcDevice,
rtcLock: &sync.Mutex{},
preCheckFunc: opts.PreCheckFunc,
networkConfig: opts.NetworkConfig,
}
if t.rtcDevicePath != "" {
rtcTime, _ := t.readRtcTime()
t.l.Info().Interface("rtc_time", rtcTime).Msg("read RTC time")
}
return t
}
func (t *TimeSync) SetDhcpNtpAddresses(addresses []string) {
t.dhcpNtpAddresses = addresses
}
func (t *TimeSync) getSyncMode() SyncMode {
syncMode := SyncMode{
Ntp: true,
Http: true,
Ordering: []string{"ntp_dhcp", "ntp", "http"},
NtpUseFallback: true,
HttpUseFallback: true,
}
if t.networkConfig != nil {
switch t.networkConfig.TimeSyncMode.String {
case "ntp_only":
syncMode.Http = false
case "http_only":
syncMode.Ntp = false
}
if t.networkConfig.TimeSyncDisableFallback.Bool {
syncMode.NtpUseFallback = false
syncMode.HttpUseFallback = false
}
var syncOrdering = t.networkConfig.TimeSyncOrdering
if len(syncOrdering) > 0 {
syncMode.Ordering = syncOrdering
}
}
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() {
metricTimeSyncStatus.Set(0)
for {
if ok, err := t.preCheckFunc(); !ok {
if err != nil {
t.l.Error().Err(err).Msg("pre-check failed")
}
time.Sleep(timeSyncWaitNetChkInt)
continue
}
t.l.Info().Msg("syncing system time")
start := time.Now()
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)
// reset the retry interval if it exceeds the max interval
if timeSyncRetryInterval > timeSyncRetryMaxInt {
timeSyncRetryInterval = 0
}
continue
}
t.syncSuccess = true
t.l.Info().Str("now", time.Now().Format(time.RFC3339)).
Str("time_taken", time.Since(start).String()).
Msg("time sync successful")
metricTimeSyncStatus.Set(1)
time.Sleep(timeSyncInterval) // after the first sync is done
}
}
func (t *TimeSync) Sync() error {
var (
now *time.Time
offset *time.Duration
)
metricTimeSyncCount.Inc()
syncMode := t.getSyncMode()
Orders:
for _, mode := range syncMode.Ordering {
switch mode {
case "ntp_user_provided":
if syncMode.Ntp {
t.l.Info().Msg("using NTP custom servers")
now, offset = t.queryNetworkTime(t.networkConfig.TimeSyncNTPServers)
if now != nil {
t.l.Info().Str("source", "NTP").Time("now", *now).Msg("time obtained")
break Orders
}
}
case "ntp_dhcp":
if syncMode.Ntp {
t.l.Info().Msg("using NTP servers from DHCP")
now, offset = t.queryNetworkTime(t.dhcpNtpAddresses)
if now != nil {
t.l.Info().Str("source", "NTP DHCP").Time("now", *now).Msg("time obtained")
break Orders
}
}
case "ntp":
if syncMode.Ntp && syncMode.NtpUseFallback {
t.l.Info().Msg("using NTP fallback")
now, offset = t.queryNetworkTime(defaultNTPServers)
if now != nil {
t.l.Info().Str("source", "NTP fallback").Time("now", *now).Msg("time obtained")
break Orders
}
}
case "http_user_provided":
if syncMode.Http {
t.l.Info().Msg("using HTTP custom URLs")
now = t.queryAllHttpTime(t.networkConfig.TimeSyncHTTPUrls)
if now != nil {
t.l.Info().Str("source", "HTTP").Time("now", *now).Msg("time obtained")
break Orders
}
}
case "http":
if syncMode.Http && syncMode.HttpUseFallback {
t.l.Info().Msg("using HTTP fallback")
now = t.queryAllHttpTime(defaultHTTPUrls)
if now != nil {
t.l.Info().Str("source", "HTTP fallback").Time("now", *now).Msg("time obtained")
break Orders
}
}
default:
t.l.Warn().Str("mode", mode).Msg("unknown time sync mode, skipping")
}
}
if now == nil {
return fmt.Errorf("failed to get time from any source")
}
if offset != nil {
newNow := time.Now().Add(*offset)
now = &newNow
}
err := t.setSystemTime(*now)
if err != nil {
return fmt.Errorf("failed to set system time: %w", err)
}
metricTimeSyncSuccessCount.Inc()
return nil
}
func (t *TimeSync) IsSyncSuccess() bool {
return t.syncSuccess
}
func (t *TimeSync) Start() {
go t.doTimeSync()
}
func (t *TimeSync) setSystemTime(now time.Time) error {
nowStr := now.Format("2006-01-02 15:04:05")
output, err := exec.Command("date", "-s", nowStr).CombinedOutput()
if err != nil {
return fmt.Errorf("failed to run date -s: %w, %s", err, string(output))
}
if t.rtcDevicePath != "" {
return t.setRtcTime(now)
}
return nil
}

View File

@ -0,0 +1,12 @@
package udhcpc
func (u *DHCPClient) GetNtpServers() []string {
if u.lease == nil {
return nil
}
servers := make([]string, len(u.lease.NTPServers))
for i, server := range u.lease.NTPServers {
servers[i] = server.String()
}
return servers
}

186
internal/udhcpc/parser.go Normal file
View File

@ -0,0 +1,186 @@
package udhcpc
import (
"bufio"
"encoding/json"
"fmt"
"net"
"os"
"reflect"
"strconv"
"strings"
"time"
)
type Lease struct {
// from https://udhcp.busybox.net/README.udhcpc
IPAddress net.IP `env:"ip" json:"ip"` // The obtained IP
Netmask net.IP `env:"subnet" json:"netmask"` // The assigned subnet mask
Broadcast net.IP `env:"broadcast" json:"broadcast"` // The broadcast address for this network
TTL int `env:"ipttl" json:"ttl,omitempty"` // The TTL to use for this network
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
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
Timezone string `env:"timezone" json:"timezone,omitempty"` // Offset in seconds from UTC
Routers []net.IP `env:"router" json:"routers,omitempty"` // A list of routers
DNS []net.IP `env:"dns" json:"dns_servers,omitempty"` // A list of DNS servers
NTPServers []net.IP `env:"ntpsrv" json:"ntp_servers,omitempty"` // A list of NTP servers
LPRServers []net.IP `env:"lprsvr" json:"lpr_servers,omitempty"` // A list of LPR servers
TimeServers []net.IP `env:"timesvr" json:"_time_servers,omitempty"` // A list of time servers (obsolete)
IEN116NameServers []net.IP `env:"namesvr" json:"_name_servers,omitempty"` // A list of IEN 116 name servers (obsolete)
LogServers []net.IP `env:"logsvr" json:"_log_servers,omitempty"` // A list of MIT-LCS UDP log servers (obsolete)
CookieServers []net.IP `env:"cookiesvr" json:"_cookie_servers,omitempty"` // A list of RFC 865 cookie servers (obsolete)
WINSServers []net.IP `env:"wins" json:"_wins_servers,omitempty"` // A list of WINS servers
SwapServer net.IP `env:"swapsvr" json:"_swap_server,omitempty"` // The IP address of the client's swap server
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
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
LeaseExpiry *time.Time `json:"lease_expiry,omitempty"` // The expiry time of the lease
isEmpty map[string]bool
}
func (l *Lease) setIsEmpty(m map[string]bool) {
l.isEmpty = m
}
func (l *Lease) IsEmpty(key string) bool {
return l.isEmpty[key]
}
func (l *Lease) ToJSON() string {
json, err := json.Marshal(l)
if err != nil {
return ""
}
return string(json)
}
func (l *Lease) SetLeaseExpiry() (time.Time, error) {
if l.Uptime == 0 || l.LeaseTime == 0 {
return time.Time{}, fmt.Errorf("uptime or lease time isn't set")
}
// get the uptime of the device
file, err := os.Open("/proc/uptime")
if err != nil {
return time.Time{}, fmt.Errorf("failed to open uptime file: %w", err)
}
defer file.Close()
var uptime time.Duration
scanner := bufio.NewScanner(file)
for scanner.Scan() {
text := scanner.Text()
parts := strings.Split(text, " ")
uptime, err = time.ParseDuration(parts[0] + "s")
if err != nil {
return time.Time{}, fmt.Errorf("failed to parse uptime: %w", err)
}
}
relativeLeaseRemaining := (l.Uptime + l.LeaseTime) - uptime
leaseExpiry := time.Now().Add(relativeLeaseRemaining)
l.LeaseExpiry = &leaseExpiry
return leaseExpiry, nil
}
func UnmarshalDHCPCLease(lease *Lease, str string) error {
// parse the lease file as a map
data := make(map[string]string)
for _, line := range strings.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
}

View File

@ -0,0 +1,74 @@
package udhcpc
import (
"testing"
"time"
)
func TestUnmarshalDHCPCLease(t *testing.T) {
lease := &Lease{}
err := UnmarshalDHCPCLease(lease, `
# generated @ Mon Jan 4 19:31:53 UTC 2021
# 19:31:53 up 0 min, 0 users, load average: 0.72, 0.14, 0.04
# the date might be inaccurate if the clock is not set
ip=192.168.0.240
siaddr=192.168.0.1
sname=
boot_file=
subnet=255.255.255.0
timezone=
router=192.168.0.1
timesvr=
namesvr=
dns=172.19.53.2
logsvr=
cookiesvr=
lprsvr=
hostname=
bootsize=
domain=
swapsvr=
rootpath=
ipttl=
mtu=
broadcast=
ntpsrv=162.159.200.123
wins=
lease=172800
dhcptype=
serverid=192.168.0.1
message=
tftp=
bootfile=
`)
if lease.IPAddress.String() != "192.168.0.240" {
t.Fatalf("expected ip to be 192.168.0.240, got %s", lease.IPAddress.String())
}
if lease.Netmask.String() != "255.255.255.0" {
t.Fatalf("expected netmask to be 255.255.255.0, got %s", lease.Netmask.String())
}
if len(lease.Routers) != 1 {
t.Fatalf("expected 1 router, got %d", len(lease.Routers))
}
if lease.Routers[0].String() != "192.168.0.1" {
t.Fatalf("expected router to be 192.168.0.1, got %s", lease.Routers[0].String())
}
if len(lease.NTPServers) != 1 {
t.Fatalf("expected 1 timeserver, got %d", len(lease.NTPServers))
}
if lease.NTPServers[0].String() != "162.159.200.123" {
t.Fatalf("expected timeserver to be 162.159.200.123, got %s", lease.NTPServers[0].String())
}
if len(lease.DNS) != 1 {
t.Fatalf("expected 1 dns, got %d", len(lease.DNS))
}
if lease.DNS[0].String() != "172.19.53.2" {
t.Fatalf("expected dns to be 172.19.53.2, got %s", lease.DNS[0].String())
}
if lease.LeaseTime != 172800*time.Second {
t.Fatalf("expected lease time to be 172800 seconds, got %d", lease.LeaseTime)
}
if err != nil {
t.Fatal(err)
}
}

212
internal/udhcpc/proc.go Normal file
View File

@ -0,0 +1,212 @@
package udhcpc
import (
"bytes"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strconv"
"strings"
"syscall"
)
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
}
func (p *DHCPClient) findUdhcpcProcess() (int, 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 0, err
}
// 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
}
cmdlineText := strings.Join(cmdline, " ")
// check if it's a udhcpc process
if strings.Contains(cmdlineText, fmt.Sprintf("-i %s", p.InterfaceName)) {
p.logger.Debug().
Str("pid", d.Name()).
Interface("cmdline", cmdline).
Msg("found udhcpc process")
return pid, nil
}
}
return 0, errors.New("udhcpc process not found")
}
func (c *DHCPClient) getProcessPid() (int, error) {
var pid int
if c.pidFile != "" {
// try to read the pid file
pidHandle, err := os.ReadFile(c.pidFile)
if err != nil {
c.logger.Warn().Err(err).
Str("pidFile", c.pidFile).Msg("failed to read udhcpc pid file")
}
// if it exists, try to read the pid
if pidHandle != nil {
pidFromFile, err := strconv.Atoi(string(pidHandle))
if err != nil {
c.logger.Warn().Err(err).
Str("pidFile", c.pidFile).Msg("failed to convert pid file to int")
}
pid = pidFromFile
}
}
// if the pid is 0, try to find the pid using procfs
if pid == 0 {
newPid, err := c.findUdhcpcProcess()
if err != nil {
return 0, err
}
pid = newPid
}
return pid, nil
}
func (c *DHCPClient) getProcess() *os.Process {
pid, err := c.getProcessPid()
if err != nil {
return nil
}
process, err := os.FindProcess(pid)
if err != nil {
c.logger.Warn().Err(err).
Int("pid", pid).Msg("failed to find process")
return nil
}
return process
}
func (c *DHCPClient) GetProcess() *os.Process {
if c.process == nil {
process := c.getProcess()
if process == nil {
return nil
}
c.process = process
}
err := c.process.Signal(syscall.Signal(0))
if err != nil && errors.Is(err, os.ErrProcessDone) {
oldPid := c.process.Pid
c.process = nil
c.process = c.getProcess()
if c.process == nil {
c.logger.Error().Msg("failed to find new udhcpc process")
return nil
}
c.logger.Warn().
Int("oldPid", oldPid).
Int("newPid", c.process.Pid).
Msg("udhcpc process pid changed")
} else if err != nil {
c.logger.Warn().Err(err).
Int("pid", c.process.Pid).Msg("udhcpc process is not running")
}
return c.process
}
func (c *DHCPClient) KillProcess() error {
process := c.GetProcess()
if process == nil {
return nil
}
return process.Kill()
}
func (c *DHCPClient) ReleaseProcess() error {
process := c.GetProcess()
if process == nil {
return nil
}
return process.Release()
}
func (c *DHCPClient) signalProcess(sig syscall.Signal) error {
process := c.GetProcess()
if process == nil {
return nil
}
s := process.Signal(sig)
if s != nil {
c.logger.Warn().Err(s).
Int("pid", process.Pid).
Str("signal", sig.String()).
Msg("failed to signal udhcpc process")
return s
}
return nil
}
func (c *DHCPClient) Renew() error {
return c.signalProcess(syscall.SIGUSR1)
}
func (c *DHCPClient) Release() error {
return c.signalProcess(syscall.SIGUSR2)
}

198
internal/udhcpc/udhcpc.go Normal file
View File

@ -0,0 +1,198 @@
package udhcpc
import (
"errors"
"fmt"
"os"
"path/filepath"
"reflect"
"time"
"github.com/fsnotify/fsnotify"
"github.com/rs/zerolog"
)
const (
DHCPLeaseFile = "/run/udhcpc.%s.info"
DHCPPidFile = "/run/udhcpc.%s.pid"
)
type DHCPClient struct {
InterfaceName string
leaseFile string
pidFile string
lease *Lease
logger *zerolog.Logger
process *os.Process
onLeaseChange func(lease *Lease)
}
type DHCPClientOptions struct {
InterfaceName string
PidFile string
Logger *zerolog.Logger
OnLeaseChange func(lease *Lease)
}
var defaultLogger = zerolog.New(os.Stdout).Level(zerolog.InfoLevel)
func NewDHCPClient(options *DHCPClientOptions) *DHCPClient {
if options.Logger == nil {
options.Logger = &defaultLogger
}
l := options.Logger.With().Str("interface", options.InterfaceName).Logger()
return &DHCPClient{
InterfaceName: options.InterfaceName,
logger: &l,
leaseFile: fmt.Sprintf(DHCPLeaseFile, options.InterfaceName),
pidFile: options.PidFile,
onLeaseChange: options.OnLeaseChange,
}
}
func (c *DHCPClient) getWatchPaths() []string {
watchPaths := make(map[string]interface{})
watchPaths[filepath.Dir(c.leaseFile)] = nil
if c.pidFile != "" {
watchPaths[filepath.Dir(c.pidFile)] = nil
}
paths := make([]string, 0)
for path := range watchPaths {
paths = append(paths, path)
}
return paths
}
// 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 {
err := c.loadLeaseFile()
if err != nil && !errors.Is(err, os.ErrNotExist) {
return err
}
watcher, err := fsnotify.NewWatcher()
if err != nil {
return err
}
defer watcher.Close()
go func() {
for {
select {
case event, ok := <-watcher.Events:
if !ok {
continue
}
if !event.Has(fsnotify.Write) && !event.Has(fsnotify.Create) {
continue
}
if event.Name == c.leaseFile {
c.logger.Debug().
Str("event", event.Op.String()).
Str("path", event.Name).
Msg("udhcpc lease file updated, reloading lease")
_ = c.loadLeaseFile()
}
case err, ok := <-watcher.Errors:
if !ok {
return
}
c.logger.Error().Err(err).Msg("error watching lease file")
}
}
}()
for _, path := range c.getWatchPaths() {
err = watcher.Add(path)
if err != nil {
c.logger.Error().
Err(err).
Str("path", path).
Msg("failed to watch directory")
return err
}
}
// TODO: update udhcpc pid file
// we'll comment this out for now because the pid might change
// process := c.GetProcess()
// if process == nil {
// c.logger.Error().Msg("udhcpc process not found")
// }
// block the goroutine until the lease file is updated
<-make(chan struct{})
return nil
}
func (c *DHCPClient) loadLeaseFile() error {
file, err := os.ReadFile(c.leaseFile)
if err != nil {
return err
}
data := string(file)
if data == "" {
c.logger.Debug().Msg("udhcpc lease file is empty")
return nil
}
lease := &Lease{}
err = UnmarshalDHCPCLease(lease, string(file))
if err != nil {
return err
}
isFirstLoad := c.lease == nil
// Skip processing if lease hasn't changed to avoid unnecessary wake-ups.
if reflect.DeepEqual(c.lease, lease) {
return nil
}
c.lease = lease
if lease.IPAddress == nil {
c.logger.Info().
Interface("lease", lease).
Str("data", string(file)).
Msg("udhcpc lease cleared")
return nil
}
msg := "udhcpc lease updated"
if isFirstLoad {
msg = "udhcpc lease loaded"
}
leaseExpiry, err := lease.SetLeaseExpiry()
if err != nil {
c.logger.Error().Err(err).Msg("failed to get dhcp lease expiry")
} else {
expiresIn := time.Until(leaseExpiry)
c.logger.Info().
Interface("expiry", leaseExpiry).
Str("expiresIn", expiresIn.String()).
Msg("current dhcp lease expiry time calculated")
}
c.onLeaseChange(lease)
c.logger.Info().
Str("ip", lease.IPAddress.String()).
Str("leaseTime", lease.LeaseTime.String()).
Interface("data", lease).
Msg(msg)
return nil
}
func (c *DHCPClient) GetLease() *Lease {
return c.lease
}

View File

@ -0,0 +1,436 @@
package usbgadget
import (
"bytes"
"fmt"
"os"
"path/filepath"
"reflect"
"time"
"github.com/prometheus/procfs"
"github.com/sourcegraph/tf-dag/dag"
)
// it's a minimalistic implementation of ansible's file module with some modifications
// to make it more suitable for our use case
// https://docs.ansible.com/ansible/latest/modules/file_module.html
// we use this to check if the files in the gadget config are in the expected state
// and to update them if they are not in the expected state
type FileState uint8
type ChangeState uint8
type FileChangeResolvedAction uint8
type ApplyFunc func(c *ChangeSet, changes []*FileChange) error
const (
FileStateUnknown FileState = iota
FileStateAbsent
FileStateDirectory
FileStateFile
FileStateFileContentMatch
FileStateFileWrite // update file content without checking
FileStateMounted
FileStateMountedConfigFS
FileStateSymlink
FileStateSymlinkInOrderConfigFS // configfs is a shithole, so we need to check if the symlinks are created in the correct order
FileStateSymlinkNotInOrderConfigFS
FileStateTouch
)
var FileStateString = map[FileState]string{
FileStateUnknown: "UNKNOWN",
FileStateAbsent: "ABSENT",
FileStateDirectory: "DIRECTORY",
FileStateFile: "FILE",
FileStateFileContentMatch: "FILE_CONTENT_MATCH",
FileStateFileWrite: "FILE_WRITE",
FileStateMounted: "MOUNTED",
FileStateMountedConfigFS: "CONFIGFS_MOUNTED",
FileStateSymlink: "SYMLINK",
FileStateSymlinkInOrderConfigFS: "SYMLINK_IN_ORDER_CONFIGFS",
FileStateTouch: "TOUCH",
}
const (
ChangeStateUnknown ChangeState = iota
ChangeStateRequired
ChangeStateNotChanged
ChangeStateChanged
ChangeStateError
)
const (
FileChangeResolvedActionUnknown FileChangeResolvedAction = iota
FileChangeResolvedActionDoNothing
FileChangeResolvedActionRemove
FileChangeResolvedActionCreateFile
FileChangeResolvedActionWriteFile
FileChangeResolvedActionUpdateFile
FileChangeResolvedActionAppendFile
FileChangeResolvedActionCreateSymlink
FileChangeResolvedActionRecreateSymlink
FileChangeResolvedActionCreateDirectoryAndSymlinks
FileChangeResolvedActionReorderSymlinks
FileChangeResolvedActionCreateDirectory
FileChangeResolvedActionRemoveDirectory
FileChangeResolvedActionTouch
FileChangeResolvedActionMountConfigFS
)
var FileChangeResolvedActionString = map[FileChangeResolvedAction]string{
FileChangeResolvedActionUnknown: "UNKNOWN",
FileChangeResolvedActionDoNothing: "DO_NOTHING",
FileChangeResolvedActionRemove: "REMOVE",
FileChangeResolvedActionCreateFile: "FILE_CREATE",
FileChangeResolvedActionWriteFile: "FILE_WRITE",
FileChangeResolvedActionUpdateFile: "FILE_UPDATE",
FileChangeResolvedActionAppendFile: "FILE_APPEND",
FileChangeResolvedActionCreateSymlink: "SYMLINK_CREATE",
FileChangeResolvedActionRecreateSymlink: "SYMLINK_RECREATE",
FileChangeResolvedActionCreateDirectoryAndSymlinks: "DIR_CREATE_AND_SYMLINKS",
FileChangeResolvedActionReorderSymlinks: "SYMLINK_REORDER",
FileChangeResolvedActionCreateDirectory: "DIR_CREATE",
FileChangeResolvedActionRemoveDirectory: "DIR_REMOVE",
FileChangeResolvedActionTouch: "TOUCH",
FileChangeResolvedActionMountConfigFS: "CONFIGFS_MOUNT",
}
type ChangeSet struct {
Changes []FileChange
}
type RequestedFileChange struct {
Component string
Key string
Path string // will be used as Key if Key is empty
ParamSymlinks []symlink
ExpectedState FileState
ExpectedContent []byte
DependsOn []string
BeforeChange []string // if the file is going to be changed, apply the change first
Description string
IgnoreErrors bool
When string // only apply the change if when meets the condition
}
type FileChange struct {
RequestedFileChange
ActualState FileState
ActualContent []byte
resolvedDeps []string
checked bool
changed ChangeState
action FileChangeResolvedAction
}
func (f *RequestedFileChange) String() string {
var s string
switch f.ExpectedState {
case FileStateDirectory:
s = fmt.Sprintf("dir: %s", f.Path)
case FileStateFile:
s = fmt.Sprintf("file: %s", f.Path)
case FileStateSymlink:
s = fmt.Sprintf("symlink: %s -> %s", f.Path, f.ExpectedContent)
case FileStateSymlinkInOrderConfigFS:
s = fmt.Sprintf("symlink_in_order_configfs: %s -> %s", f.Path, f.ExpectedContent)
case FileStateSymlinkNotInOrderConfigFS:
s = fmt.Sprintf("symlink_not_in_order_configfs: %s -> %s", f.Path, f.ExpectedContent)
case FileStateAbsent:
s = fmt.Sprintf("absent: %s", f.Path)
case FileStateFileContentMatch:
s = fmt.Sprintf("file: %s with content [%s]", f.Path, f.ExpectedContent)
case FileStateFileWrite:
s = fmt.Sprintf("write: %s with content [%s]", f.Path, f.ExpectedContent)
case FileStateMountedConfigFS:
s = fmt.Sprintf("configfs: %s", f.Path)
case FileStateTouch:
s = fmt.Sprintf("touch: %s", f.Path)
case FileStateUnknown:
s = fmt.Sprintf("unknown change for %s", f.Path)
default:
s = fmt.Sprintf("unknown expected state %d for %s", f.ExpectedState, f.Path)
}
if len(f.Description) > 0 {
s += fmt.Sprintf(" (%s)", f.Description)
}
return s
}
func (f *RequestedFileChange) IsSame(other *RequestedFileChange) bool {
return f.Path == other.Path &&
f.ExpectedState == other.ExpectedState &&
reflect.DeepEqual(f.ExpectedContent, other.ExpectedContent) &&
reflect.DeepEqual(f.DependsOn, other.DependsOn) &&
f.IgnoreErrors == other.IgnoreErrors
}
func (fc *FileChange) checkIfDirIsMountPoint() error {
// check if the file is a mount point
mounts, err := procfs.GetMounts()
if err != nil {
return fmt.Errorf("failed to get mounts")
}
for _, mount := range mounts {
if mount.MountPoint == fc.Path {
fc.ActualState = FileStateMounted
fc.ActualContent = []byte(mount.Source)
if mount.FSType == "configfs" {
fc.ActualState = FileStateMountedConfigFS
}
return nil
}
}
return nil
}
// GetActualState returns the actual state of the file at the given path.
func (fc *FileChange) getActualState() error {
l := defaultLogger.With().Str("path", fc.Path).Logger()
fi, err := os.Lstat(fc.Path)
if err != nil {
if os.IsNotExist(err) {
fc.ActualState = FileStateAbsent
} else {
l.Warn().Err(err).Msg("failed to stat file")
fc.ActualState = FileStateUnknown
}
return nil
}
// check if the file is a symlink
if fi.Mode()&os.ModeSymlink == os.ModeSymlink {
fc.ActualState = FileStateSymlink
// get the target of the symlink
target, err := os.Readlink(fc.Path)
if err != nil {
l.Warn().Err(err).Msg("failed to read symlink")
return fmt.Errorf("failed to read symlink")
}
// check if the target is a relative path
if !filepath.IsAbs(target) {
// make it absolute
target, err = filepath.Abs(filepath.Join(filepath.Dir(fc.Path), target))
if err != nil {
l.Warn().Err(err).Msg("failed to make symlink target absolute")
return fmt.Errorf("failed to make symlink target absolute")
}
}
fc.ActualContent = []byte(target)
return nil
}
if fi.IsDir() {
fc.ActualState = FileStateDirectory
switch fc.ExpectedState {
case FileStateMountedConfigFS:
err := fc.checkIfDirIsMountPoint()
if err != nil {
l.Warn().Err(err).Msg("failed to check if dir is mount point")
return err
}
case FileStateSymlinkInOrderConfigFS:
state, err := checkIfSymlinksInOrder(fc, &l)
if err != nil {
l.Warn().Err(err).Msg("failed to check if symlinks are in order")
return err
}
fc.ActualState = state
}
return nil
}
if fi.Mode()&os.ModeDevice == os.ModeDevice {
l.Info().Msg("file is a device")
return nil
}
// check if the file is a regular file
if fi.Mode().IsRegular() {
fc.ActualState = FileStateFile
// get the content of the file
content, err := os.ReadFile(fc.Path)
if err != nil {
l.Warn().Err(err).Msg("failed to read file")
return fmt.Errorf("failed to read file")
}
fc.ActualContent = content
return nil
}
l.Warn().Interface("file_info", fi.Mode()).Bool("is_dir", fi.IsDir()).Msg("unknown file type")
return fmt.Errorf("unknown file type")
}
func (fc *FileChange) ResetActionResolution() {
fc.checked = false
fc.action = FileChangeResolvedActionUnknown
fc.changed = ChangeStateUnknown
}
func (fc *FileChange) Action() FileChangeResolvedAction {
if !fc.checked {
fc.action = fc.getFileChangeResolvedAction()
fc.checked = true
}
return fc.action
}
func (fc *FileChange) getFileChangeResolvedAction() FileChangeResolvedAction {
l := defaultLogger.With().Str("path", fc.Path).Logger()
// some actions are not needed to be checked
switch fc.ExpectedState {
case FileStateFileWrite:
return FileChangeResolvedActionWriteFile
case FileStateTouch:
return FileChangeResolvedActionTouch
}
// get the actual state of the file
err := fc.getActualState()
if err != nil {
return FileChangeResolvedActionDoNothing
}
baseName := filepath.Base(fc.Path)
switch fc.ExpectedState {
case FileStateDirectory:
// if the file is already a directory, do nothing
if fc.ActualState == FileStateDirectory {
return FileChangeResolvedActionDoNothing
}
return FileChangeResolvedActionCreateDirectory
case FileStateFile:
// if the file is already a file, do nothing
if fc.ActualState == FileStateFile {
return FileChangeResolvedActionDoNothing
}
return FileChangeResolvedActionCreateFile
case FileStateFileContentMatch:
// if the file is already a file with the expected content, do nothing
if fc.ActualState == FileStateFile {
looserMatch := baseName == "inquiry_string"
if compareFileContent(fc.ActualContent, fc.ExpectedContent, looserMatch) {
return FileChangeResolvedActionDoNothing
}
// TODO: move this to somewhere else
// this is a workaround for the fact that the file is not updated if it has no content
if baseName == "file" &&
bytes.Equal(fc.ActualContent, []byte{}) &&
bytes.Equal(fc.ExpectedContent, []byte{0x0a}) {
return FileChangeResolvedActionDoNothing
}
return FileChangeResolvedActionUpdateFile
}
return FileChangeResolvedActionCreateFile
case FileStateSymlink:
// if the file is already a symlink, check if the target is the same
if fc.ActualState == FileStateSymlink {
if reflect.DeepEqual(fc.ActualContent, fc.ExpectedContent) {
return FileChangeResolvedActionDoNothing
}
return FileChangeResolvedActionRecreateSymlink
}
return FileChangeResolvedActionCreateSymlink
case FileStateSymlinkInOrderConfigFS:
// if the file is already a symlink, check if the target is the same
if fc.ActualState == FileStateSymlinkInOrderConfigFS {
return FileChangeResolvedActionDoNothing
}
return FileChangeResolvedActionReorderSymlinks
case FileStateAbsent:
if fc.ActualState == FileStateAbsent {
return FileChangeResolvedActionDoNothing
}
return FileChangeResolvedActionRemove
case FileStateMountedConfigFS:
if fc.ActualState == FileStateMountedConfigFS {
return FileChangeResolvedActionDoNothing
}
return FileChangeResolvedActionMountConfigFS
default:
l.Warn().Interface("file_change", FileStateString[fc.ExpectedState]).Msg("unknown expected state")
return FileChangeResolvedActionDoNothing
}
}
func (c *ChangeSet) AddFileChangeStruct(r RequestedFileChange) {
fc := FileChange{
RequestedFileChange: r,
}
c.Changes = append(c.Changes, fc)
}
func (c *ChangeSet) AddFileChange(component string, path string, expectedState FileState, expectedContent []byte, dependsOn []string, description string) {
c.AddFileChangeStruct(RequestedFileChange{
Component: component,
Path: path,
ExpectedState: expectedState,
ExpectedContent: expectedContent,
DependsOn: dependsOn,
Description: description,
})
}
func (c *ChangeSet) ApplyChanges() error {
r := ChangeSetResolver{
changeset: c,
g: &dag.AcyclicGraph{},
l: defaultLogger,
}
return r.Apply()
}
func (c *ChangeSet) applyChange(change *FileChange) error {
switch change.Action() {
case FileChangeResolvedActionWriteFile:
return os.WriteFile(change.Path, change.ExpectedContent, 0644)
case FileChangeResolvedActionUpdateFile:
return os.WriteFile(change.Path, change.ExpectedContent, 0644)
case FileChangeResolvedActionCreateFile:
return os.WriteFile(change.Path, change.ExpectedContent, 0644)
case FileChangeResolvedActionCreateSymlink:
return os.Symlink(string(change.ExpectedContent), change.Path)
case FileChangeResolvedActionRecreateSymlink:
if err := os.Remove(change.Path); err != nil {
return fmt.Errorf("failed to remove symlink: %w", err)
}
return os.Symlink(string(change.ExpectedContent), change.Path)
case FileChangeResolvedActionReorderSymlinks:
return recreateSymlinks(change, nil)
case FileChangeResolvedActionCreateDirectory:
return os.MkdirAll(change.Path, 0755)
case FileChangeResolvedActionRemove:
return os.Remove(change.Path)
case FileChangeResolvedActionRemoveDirectory:
return os.RemoveAll(change.Path)
case FileChangeResolvedActionTouch:
return os.Chtimes(change.Path, time.Now(), time.Now())
case FileChangeResolvedActionMountConfigFS:
return mountConfigFS(change.Path)
case FileChangeResolvedActionDoNothing:
return nil
default:
return fmt.Errorf("unknown action: %d", change.Action())
}
}
func (c *ChangeSet) Apply() error {
return c.ApplyChanges()
}

View File

@ -0,0 +1,115 @@
//go:build arm && linux
package usbgadget
import (
"os"
"strings"
"testing"
"github.com/stretchr/testify/assert"
)
var (
usbConfig = &Config{
VendorId: "0x1d6b", //The Linux Foundation
ProductId: "0x0104", //Multifunction Composite Gadget
SerialNumber: "",
Manufacturer: "JetKVM",
Product: "USB Emulation Device",
strictMode: true,
}
usbDevices = &Devices{
AbsoluteMouse: true,
RelativeMouse: true,
Keyboard: true,
MassStorage: true,
}
usbGadgetName = "jetkvm"
usbGadget *UsbGadget
)
var oldAbsoluteMouseCombinedReportDesc = []byte{
0x05, 0x01, // Usage Page (Generic Desktop Ctrls)
0x09, 0x02, // Usage (Mouse)
0xA1, 0x01, // Collection (Application)
// Report ID 1: Absolute Mouse Movement
0x85, 0x01, // Report ID (1)
0x09, 0x01, // Usage (Pointer)
0xA1, 0x00, // Collection (Physical)
0x05, 0x09, // Usage Page (Button)
0x19, 0x01, // Usage Minimum (0x01)
0x29, 0x03, // Usage Maximum (0x03)
0x15, 0x00, // Logical Minimum (0)
0x25, 0x01, // Logical Maximum (1)
0x75, 0x01, // Report Size (1)
0x95, 0x03, // Report Count (3)
0x81, 0x02, // Input (Data, Var, Abs)
0x95, 0x01, // Report Count (1)
0x75, 0x05, // Report Size (5)
0x81, 0x03, // Input (Cnst, Var, Abs)
0x05, 0x01, // Usage Page (Generic Desktop Ctrls)
0x09, 0x30, // Usage (X)
0x09, 0x31, // Usage (Y)
0x16, 0x00, 0x00, // Logical Minimum (0)
0x26, 0xFF, 0x7F, // Logical Maximum (32767)
0x36, 0x00, 0x00, // Physical Minimum (0)
0x46, 0xFF, 0x7F, // Physical Maximum (32767)
0x75, 0x10, // Report Size (16)
0x95, 0x02, // Report Count (2)
0x81, 0x02, // Input (Data, Var, Abs)
0xC0, // End Collection
// Report ID 2: Relative Wheel Movement
0x85, 0x02, // Report ID (2)
0x09, 0x38, // Usage (Wheel)
0x15, 0x81, // Logical Minimum (-127)
0x25, 0x7F, // Logical Maximum (127)
0x75, 0x08, // Report Size (8)
0x95, 0x01, // Report Count (1)
0x81, 0x06, // Input (Data, Var, Rel)
0xC0, // End Collection
}
func TestUsbGadgetInit(t *testing.T) {
assert := assert.New(t)
usbGadget = NewUsbGadget(usbGadgetName, usbDevices, usbConfig, nil)
assert.NotNil(usbGadget)
}
func TestUsbGadgetStrictModeInitFail(t *testing.T) {
usbConfig.strictMode = true
u := NewUsbGadget("test", usbDevices, usbConfig, nil)
assert.Nil(t, u, "should be nil")
}
func TestUsbGadgetUDCNotBoundAfterReportDescrChanged(t *testing.T) {
assert := assert.New(t)
usbGadget = NewUsbGadget(usbGadgetName, usbDevices, usbConfig, nil)
assert.NotNil(usbGadget)
// release the usb gadget and create a new one
usbGadget = nil
altGadgetConfig := defaultGadgetConfig
oldAbsoluteMouseConfig := altGadgetConfig["absolute_mouse"]
oldAbsoluteMouseConfig.reportDesc = oldAbsoluteMouseCombinedReportDesc
altGadgetConfig["absolute_mouse"] = oldAbsoluteMouseConfig
usbGadget = newUsbGadget(usbGadgetName, altGadgetConfig, usbDevices, usbConfig, nil)
assert.NotNil(usbGadget)
udcs := getUdcs()
assert.Equal(1, len(udcs), "should be only one UDC")
// check if the UDC is bound
udc := udcs[0]
assert.NotNil(udc, "UDC should exist")
udcStr, err := os.ReadFile("/sys/kernel/config/usb_gadget/jetkvm/UDC")
assert.Nil(err, "usb_gadget/UDC should exist")
assert.Equal(strings.TrimSpace(udc), strings.TrimSpace(string(udcStr)), "UDC should be the same")
}

View File

@ -0,0 +1,192 @@
package usbgadget
import (
"fmt"
"github.com/rs/zerolog"
"github.com/sourcegraph/tf-dag/dag"
)
type ChangeSetResolver struct {
changeset *ChangeSet
l *zerolog.Logger
g *dag.AcyclicGraph
changesMap map[string]*FileChange
conditionalChangesMap map[string]*FileChange
orderedChanges []dag.Vertex
resolvedChanges []*FileChange
additionalResolveRequired bool
}
func (c *ChangeSetResolver) toOrderedChanges() error {
for key, change := range c.changesMap {
v := c.g.Add(key)
for _, dependsOn := range change.DependsOn {
c.g.Connect(dag.BasicEdge(dependsOn, v))
}
for _, dependsOn := range change.resolvedDeps {
c.g.Connect(dag.BasicEdge(dependsOn, v))
}
}
cycles := c.g.Cycles()
if len(cycles) > 0 {
return fmt.Errorf("cycles detected: %v", cycles)
}
orderedChanges := c.g.TopologicalOrder()
c.orderedChanges = orderedChanges
return nil
}
func (c *ChangeSetResolver) doResolveChanges(initial bool) error {
resolvedChanges := make([]*FileChange, 0)
for _, key := range c.orderedChanges {
change := c.changesMap[key.(string)]
if change == nil {
c.l.Error().Str("key", key.(string)).Msg("fileChange not found")
continue
}
if !initial {
change.ResetActionResolution()
}
resolvedAction := change.Action()
resolvedChanges = append(resolvedChanges, change)
// no need to check the triggers if there's no change
if resolvedAction == FileChangeResolvedActionDoNothing {
continue
}
if !initial {
continue
}
if change.BeforeChange != nil {
change.resolvedDeps = append(change.resolvedDeps, change.BeforeChange...)
c.additionalResolveRequired = true
// add the dependencies to the changes map
for _, dep := range change.BeforeChange {
depChange, ok := c.conditionalChangesMap[dep]
if !ok {
return fmt.Errorf("dependency %s not found", dep)
}
c.changesMap[dep] = depChange
}
}
}
c.resolvedChanges = resolvedChanges
return nil
}
func (c *ChangeSetResolver) resolveChanges(initial bool) error {
// get the ordered changes
err := c.toOrderedChanges()
if err != nil {
return err
}
// resolve the changes
err = c.doResolveChanges(initial)
if err != nil {
return err
}
for _, change := range c.resolvedChanges {
c.l.Trace().Str("change", change.String()).Msg("resolved change")
}
if !c.additionalResolveRequired || !initial {
return nil
}
return c.resolveChanges(false)
}
func (c *ChangeSetResolver) applyChanges() error {
for _, change := range c.resolvedChanges {
change.ResetActionResolution()
action := change.Action()
actionStr := FileChangeResolvedActionString[action]
l := c.l.Info()
if action == FileChangeResolvedActionDoNothing {
l = c.l.Trace()
}
l.Str("action", actionStr).Str("change", change.String()).Msg("applying change")
err := c.changeset.applyChange(change)
if err != nil {
if change.IgnoreErrors {
c.l.Warn().Str("change", change.String()).Err(err).Msg("ignoring error")
} else {
return err
}
}
}
return nil
}
func (c *ChangeSetResolver) GetChanges() ([]*FileChange, error) {
localChanges := c.changeset.Changes
changesMap := make(map[string]*FileChange)
conditionalChangesMap := make(map[string]*FileChange)
// build the map of the changes
for _, change := range localChanges {
key := change.Key
if key == "" {
key = change.Path
}
// remove it from the map first
if change.When != "" {
conditionalChangesMap[key] = &change
continue
}
if _, ok := changesMap[key]; ok {
if changesMap[key].IsSame(&change.RequestedFileChange) {
continue
}
return nil, fmt.Errorf(
"duplicate change: %s, current: %s, requested: %s",
key,
changesMap[key].String(),
change.String(),
)
}
changesMap[key] = &change
}
c.changesMap = changesMap
c.conditionalChangesMap = conditionalChangesMap
err := c.resolveChanges(true)
if err != nil {
return nil, err
}
return c.resolvedChanges, nil
}
func (c *ChangeSetResolver) Apply() error {
if _, err := c.GetChanges(); err != nil {
return err
}
return c.applyChanges()
}

View File

@ -0,0 +1,136 @@
package usbgadget
import (
"fmt"
"os"
"path"
"path/filepath"
"reflect"
"github.com/rs/zerolog"
)
type symlink struct {
Path string
Target string
}
func compareSymlinks(expected []symlink, actual []symlink) bool {
if len(expected) != len(actual) {
return false
}
return reflect.DeepEqual(expected, actual)
}
func checkIfSymlinksInOrder(fc *FileChange, logger *zerolog.Logger) (FileState, error) {
if logger == nil {
logger = defaultLogger
}
l := logger.With().Str("path", fc.Path).Logger()
if len(fc.ParamSymlinks) == 0 {
return FileStateUnknown, fmt.Errorf("no symlinks to check")
}
fi, err := os.Lstat(fc.Path)
if err != nil {
if os.IsNotExist(err) {
return FileStateAbsent, nil
} else {
l.Warn().Err(err).Msg("failed to stat file")
return FileStateUnknown, fmt.Errorf("failed to stat file")
}
}
if !fi.IsDir() {
return FileStateUnknown, fmt.Errorf("file is not a directory")
}
files, err := os.ReadDir(fc.Path)
symlinks := make([]symlink, 0)
if err != nil {
return FileStateUnknown, fmt.Errorf("failed to read directory")
}
for _, file := range files {
if file.Type()&os.ModeSymlink != os.ModeSymlink {
continue
}
path := filepath.Join(fc.Path, file.Name())
target, err := os.Readlink(path)
if err != nil {
return FileStateUnknown, fmt.Errorf("failed to read symlink")
}
if !filepath.IsAbs(target) {
target = filepath.Join(fc.Path, target)
newTarget, err := filepath.Abs(target)
if err != nil {
return FileStateUnknown, fmt.Errorf("failed to get absolute path")
}
target = newTarget
}
symlinks = append(symlinks, symlink{
Path: path,
Target: target,
})
}
// compare the symlinks with the expected symlinks
if compareSymlinks(fc.ParamSymlinks, symlinks) {
return FileStateSymlinkInOrderConfigFS, nil
}
l.Trace().Interface("expected", fc.ParamSymlinks).Interface("actual", symlinks).Msg("symlinks are not in order")
return FileStateSymlinkNotInOrderConfigFS, nil
}
func recreateSymlinks(fc *FileChange, logger *zerolog.Logger) error {
if logger == nil {
logger = defaultLogger
}
// remove all symlinks
files, err := os.ReadDir(fc.Path)
if err != nil {
return fmt.Errorf("failed to read directory")
}
l := logger.With().Str("path", fc.Path).Logger()
l.Info().Msg("recreate symlinks")
for _, file := range files {
if file.Type()&os.ModeSymlink != os.ModeSymlink {
continue
}
l.Info().Str("name", file.Name()).Msg("remove symlink")
err := os.Remove(path.Join(fc.Path, file.Name()))
if err != nil {
return fmt.Errorf("failed to remove symlink")
}
}
l.Info().Interface("param-symlinks", fc.ParamSymlinks).Msg("create symlinks")
// create the symlinks
for _, symlink := range fc.ParamSymlinks {
l.Info().Str("name", symlink.Path).Str("target", symlink.Target).Msg("create symlink")
path := symlink.Path
if !filepath.IsAbs(path) {
path = filepath.Join(fc.Path, path)
}
err := os.Symlink(symlink.Target, path)
if err != nil {
l.Warn().Err(err).Msg("failed to create symlink")
return fmt.Errorf("failed to create symlink")
}
}
return nil
}

View File

@ -2,11 +2,7 @@ package usbgadget
import (
"fmt"
"os"
"os/exec"
"path"
"path/filepath"
"sort"
)
type gadgetConfigItem struct {
@ -34,8 +30,8 @@ var defaultGadgetConfig = map[string]gadgetConfigItem{
attrs: gadgetAttributes{
"bcdUSB": "0x0200", // USB 2.0
"idVendor": "0x1d6b", // The Linux Foundation
"idProduct": "0104", // Multifunction Composite Gadget
"bcdDevice": "0100",
"idProduct": "0x0104", // Multifunction Composite Gadget
"bcdDevice": "0x0100", // USB2
},
configAttrs: gadgetAttributes{
"MaxPower": "250", // in unit of 2mA
@ -84,7 +80,7 @@ func (u *UsbGadget) isGadgetConfigItemEnabled(itemKey string) bool {
func (u *UsbGadget) loadGadgetConfig() {
if u.customConfig.isEmpty {
u.log.Trace("using default gadget config")
u.log.Trace().Msg("using default gadget config")
return
}
@ -137,20 +133,33 @@ func (u *UsbGadget) GetPath(itemKey string) (string, error) {
return joinPath(u.kvmGadgetPath, item.path), nil
}
func mountConfigFS() error {
_, err := os.Stat(gadgetPath)
// TODO: check if it's mounted properly
if err == nil {
return nil
// OverrideGadgetConfig overrides the gadget config for the given item and attribute.
// It returns an error if the item is not found or the attribute is not found.
// It returns true if the attribute is overridden, false otherwise.
func (u *UsbGadget) OverrideGadgetConfig(itemKey string, itemAttr string, value string) (error, bool) {
u.configLock.Lock()
defer u.configLock.Unlock()
// get it as a pointer
_, ok := u.configMap[itemKey]
if !ok {
return fmt.Errorf("config item %s not found", itemKey), false
}
if os.IsNotExist(err) {
err = exec.Command("mount", "-t", "configfs", "none", configFSPath).Run()
if err != nil {
return fmt.Errorf("failed to mount configfs: %w", err)
}
} else {
return fmt.Errorf("unable to access usb gadget path: %w", err)
if u.configMap[itemKey].attrs[itemAttr] == value {
return nil, false
}
u.configMap[itemKey].attrs[itemAttr] = value
u.log.Info().Str("itemKey", itemKey).Str("itemAttr", itemAttr).Str("value", value).Msg("overriding gadget config")
return nil, true
}
func mountConfigFS(path string) error {
err := exec.Command("mount", "-t", "configfs", "none", path).Run()
if err != nil {
return fmt.Errorf("failed to mount configfs: %w", err)
}
return nil
}
@ -163,26 +172,14 @@ func (u *UsbGadget) Init() error {
udcs := getUdcs()
if len(udcs) < 1 {
u.log.Error("no udc found, skipping USB stack init")
return nil
return u.logWarn("no udc found, skipping USB stack init", nil)
}
u.udc = udcs[0]
_, err := os.Stat(u.kvmGadgetPath)
if err == nil {
u.log.Info("usb gadget already exists")
}
if err := mountConfigFS(); err != nil {
u.log.Errorf("failed to mount configfs: %v, usb stack might not function properly", err)
}
if err := os.MkdirAll(u.configC1Path, 0755); err != nil {
u.log.Errorf("failed to create config path: %v", err)
}
if err := u.writeGadgetConfig(); err != nil {
u.log.Errorf("failed to start gadget: %v", err)
err := u.configureUsbGadget(false)
if err != nil {
return u.logError("unable to initialize USB stack", err)
}
return nil
@ -194,143 +191,22 @@ func (u *UsbGadget) UpdateGadgetConfig() error {
u.loadGadgetConfig()
if err := u.writeGadgetConfig(); err != nil {
u.log.Errorf("failed to update gadget: %v", err)
err := u.configureUsbGadget(true)
if err != nil {
return u.logError("unable to update gadget config", err)
}
return nil
}
func (u *UsbGadget) getOrderedConfigItems() orderedGadgetConfigItems {
items := make([]gadgetConfigItemWithKey, 0)
for key, item := range u.configMap {
items = append(items, gadgetConfigItemWithKey{key, item})
}
sort.Slice(items, func(i, j int) bool {
return items[i].item.order < items[j].item.order
func (u *UsbGadget) configureUsbGadget(resetUsb bool) error {
return u.WithTransaction(func() error {
u.tx.MountConfigFS()
u.tx.CreateConfigPath()
u.tx.WriteGadgetConfig()
if resetUsb {
u.tx.RebindUsb(true)
}
return nil
})
return items
}
func (u *UsbGadget) writeGadgetConfig() error {
// create kvm gadget path
err := os.MkdirAll(u.kvmGadgetPath, 0755)
if err != nil {
return err
}
u.log.Tracef("writing gadget config")
for _, val := range u.getOrderedConfigItems() {
key := val.key
item := val.item
// check if the item is enabled in the config
if !u.isGadgetConfigItemEnabled(key) {
u.log.Tracef("disabling gadget config: %s", key)
err = u.disableGadgetItemConfig(item)
if err != nil {
return err
}
continue
}
u.log.Tracef("writing gadget config: %s", key)
err = u.writeGadgetItemConfig(item)
if err != nil {
return err
}
}
if err = u.writeUDC(); err != nil {
u.log.Errorf("failed to write UDC: %v", err)
return err
}
if err = u.rebindUsb(true); err != nil {
u.log.Infof("failed to rebind usb: %v", err)
}
return nil
}
func (u *UsbGadget) disableGadgetItemConfig(item gadgetConfigItem) error {
// remove symlink if exists
if item.configPath == nil {
return nil
}
configPath := joinPath(u.configC1Path, item.configPath)
if _, err := os.Lstat(configPath); os.IsNotExist(err) {
u.log.Tracef("symlink %s does not exist", item.configPath)
return nil
}
if err := os.Remove(configPath); err != nil {
return fmt.Errorf("failed to remove symlink %s: %w", item.configPath, err)
}
return nil
}
func (u *UsbGadget) writeGadgetItemConfig(item gadgetConfigItem) error {
// create directory for the item
gadgetItemPath := joinPath(u.kvmGadgetPath, item.path)
err := os.MkdirAll(gadgetItemPath, 0755)
if err != nil {
return fmt.Errorf("failed to create path %s: %w", gadgetItemPath, err)
}
if len(item.attrs) > 0 {
// write attributes for the item
err = u.writeGadgetAttrs(gadgetItemPath, item.attrs)
if err != nil {
return fmt.Errorf("failed to write attributes for %s: %w", gadgetItemPath, err)
}
}
// write report descriptor if available
if item.reportDesc != nil {
err = u.writeIfDifferent(path.Join(gadgetItemPath, "report_desc"), item.reportDesc, 0644)
if err != nil {
return err
}
}
// create config directory if configAttrs are set
if len(item.configAttrs) > 0 {
configItemPath := joinPath(u.configC1Path, item.configPath)
err = os.MkdirAll(configItemPath, 0755)
if err != nil {
return fmt.Errorf("failed to create path %s: %w", configItemPath, err)
}
err = u.writeGadgetAttrs(configItemPath, item.configAttrs)
if err != nil {
return fmt.Errorf("failed to write config attributes for %s: %w", configItemPath, err)
}
}
// create symlink if configPath is set
if item.configPath != nil && item.configAttrs == nil {
configPath := joinPath(u.configC1Path, item.configPath)
u.log.Tracef("Creating symlink from %s to %s", configPath, gadgetItemPath)
if err := ensureSymlink(configPath, gadgetItemPath); err != nil {
return err
}
}
return nil
}
func (u *UsbGadget) writeGadgetAttrs(basePath string, attrs gadgetAttributes) error {
for key, val := range attrs {
filePath := filepath.Join(basePath, key)
err := u.writeIfDifferent(filePath, []byte(val), 0644)
if err != nil {
return fmt.Errorf("failed to write to %s: %w", filePath, err)
}
}
return nil
}

View File

@ -0,0 +1,349 @@
package usbgadget
import (
"fmt"
"path"
"path/filepath"
"sort"
"github.com/rs/zerolog"
)
// no os package should occur in this file
type UsbGadgetTransaction struct {
c *ChangeSet
// below are the fields that are needed to be set by the caller
log *zerolog.Logger
udc string
dwc3Path string
kvmGadgetPath string
configC1Path string
orderedConfigItems orderedGadgetConfigItems
isGadgetConfigItemEnabled func(key string) bool
reorderSymlinkChanges *RequestedFileChange
}
func (u *UsbGadget) newUsbGadgetTransaction(lock bool) error {
if lock {
u.txLock.Lock()
defer u.txLock.Unlock()
}
if u.tx != nil {
return fmt.Errorf("transaction already exists")
}
tx := &UsbGadgetTransaction{
c: &ChangeSet{},
log: u.log,
udc: u.udc,
dwc3Path: dwc3Path,
kvmGadgetPath: u.kvmGadgetPath,
configC1Path: u.configC1Path,
orderedConfigItems: u.getOrderedConfigItems(),
isGadgetConfigItemEnabled: u.isGadgetConfigItemEnabled,
}
u.tx = tx
return nil
}
func (u *UsbGadget) WithTransaction(fn func() error) error {
u.txLock.Lock()
defer u.txLock.Unlock()
err := u.newUsbGadgetTransaction(false)
if err != nil {
u.log.Error().Err(err).Msg("failed to create transaction")
return err
}
if err := fn(); err != nil {
u.log.Error().Err(err).Msg("transaction failed")
return err
}
result := u.tx.Commit()
u.tx = nil
return result
}
func (tx *UsbGadgetTransaction) addFileChange(component string, change RequestedFileChange) string {
change.Component = component
tx.c.AddFileChangeStruct(change)
key := change.Key
if key == "" {
key = change.Path
}
return key
}
func (tx *UsbGadgetTransaction) mkdirAll(component string, path string, description string, deps []string) string {
return tx.addFileChange(component, RequestedFileChange{
Path: path,
ExpectedState: FileStateDirectory,
Description: description,
DependsOn: deps,
})
}
func (tx *UsbGadgetTransaction) removeFile(component string, path string, description string) string {
return tx.addFileChange(component, RequestedFileChange{
Path: path,
ExpectedState: FileStateAbsent,
Description: description,
})
}
func (tx *UsbGadgetTransaction) Commit() error {
tx.addFileChange("gadget-finalize", *tx.reorderSymlinkChanges)
err := tx.c.Apply()
if err != nil {
tx.log.Error().Err(err).Msg("failed to update usbgadget configuration")
return err
}
tx.log.Info().Msg("usbgadget configuration updated")
return nil
}
func (u *UsbGadget) getOrderedConfigItems() orderedGadgetConfigItems {
items := make([]gadgetConfigItemWithKey, 0)
for key, item := range u.configMap {
items = append(items, gadgetConfigItemWithKey{key, item})
}
sort.Slice(items, func(i, j int) bool {
return items[i].item.order < items[j].item.order
})
return items
}
func (tx *UsbGadgetTransaction) MountConfigFS() {
tx.addFileChange("gadget", RequestedFileChange{
Path: configFSPath,
ExpectedState: FileStateMountedConfigFS,
Description: "mount configfs",
})
}
func (tx *UsbGadgetTransaction) CreateConfigPath() {
tx.mkdirAll(
"gadget",
tx.configC1Path,
"create config path",
[]string{configFSPath},
)
}
func (tx *UsbGadgetTransaction) WriteGadgetConfig() {
// create kvm gadget path
tx.mkdirAll(
"gadget",
tx.kvmGadgetPath,
"create kvm gadget path",
[]string{tx.configC1Path},
)
deps := make([]string, 0)
deps = append(deps, tx.kvmGadgetPath)
for _, val := range tx.orderedConfigItems {
key := val.key
item := val.item
// check if the item is enabled in the config
if !tx.isGadgetConfigItemEnabled(key) {
tx.DisableGadgetItemConfig(item)
continue
}
deps = tx.writeGadgetItemConfig(item, deps)
}
tx.WriteUDC()
}
func (tx *UsbGadgetTransaction) getDisableKeys() []string {
disableKeys := make([]string, 0)
for _, item := range tx.orderedConfigItems {
if !tx.isGadgetConfigItemEnabled(item.key) {
continue
}
if item.item.configPath == nil || item.item.configAttrs != nil {
continue
}
disableKeys = append(disableKeys, fmt.Sprintf("disable-%s", item.item.device))
}
return disableKeys
}
func (tx *UsbGadgetTransaction) DisableGadgetItemConfig(item gadgetConfigItem) {
// remove symlink if exists
if item.configPath == nil {
return
}
configPath := joinPath(tx.configC1Path, item.configPath)
_ = tx.removeFile("gadget", configPath, "remove symlink: disable gadget config")
}
func (tx *UsbGadgetTransaction) writeGadgetItemConfig(item gadgetConfigItem, deps []string) []string {
component := item.device
// create directory for the item
files := make([]string, 0)
files = append(files, deps...)
gadgetItemPath := joinPath(tx.kvmGadgetPath, item.path)
if gadgetItemPath != tx.kvmGadgetPath {
gadgetItemDir := tx.mkdirAll(component, gadgetItemPath, "create gadget item directory", files)
files = append(files, gadgetItemDir)
}
beforeChange := make([]string, 0)
disableGadgetItemKey := fmt.Sprintf("disable-%s", item.device)
if item.configPath != nil && item.configAttrs == nil {
beforeChange = append(beforeChange, tx.getDisableKeys()...)
}
if len(item.attrs) > 0 {
// write attributes for the item
files = append(files, tx.writeGadgetAttrs(
gadgetItemPath,
item.attrs,
component,
beforeChange,
)...)
}
// write report descriptor if available
reportDescPath := path.Join(gadgetItemPath, "report_desc")
if item.reportDesc != nil {
tx.addFileChange(component, RequestedFileChange{
Path: reportDescPath,
ExpectedState: FileStateFileContentMatch,
ExpectedContent: item.reportDesc,
Description: "write report descriptor",
BeforeChange: beforeChange,
DependsOn: files,
})
} else {
tx.addFileChange(component, RequestedFileChange{
Path: reportDescPath,
ExpectedState: FileStateAbsent,
Description: "remove report descriptor",
BeforeChange: beforeChange,
DependsOn: files,
})
}
files = append(files, reportDescPath)
// create config directory if configAttrs are set
if len(item.configAttrs) > 0 {
configItemPath := joinPath(tx.configC1Path, item.configPath)
if configItemPath != tx.configC1Path {
configItemDir := tx.mkdirAll(component, configItemPath, "create config item directory", files)
files = append(files, configItemDir)
}
files = append(files, tx.writeGadgetAttrs(
configItemPath,
item.configAttrs,
component,
beforeChange,
)...)
}
// create symlink if configPath is set
if item.configPath != nil && item.configAttrs == nil {
configPath := joinPath(tx.configC1Path, item.configPath)
// the change will be only applied by `beforeChange`
tx.addFileChange(component, RequestedFileChange{
Key: disableGadgetItemKey,
Path: configPath,
ExpectedState: FileStateAbsent,
When: "beforeChange", // TODO: make it more flexible
Description: "remove symlink",
})
tx.addReorderSymlinkChange(configPath, gadgetItemPath, files)
}
return files
}
func (tx *UsbGadgetTransaction) writeGadgetAttrs(basePath string, attrs gadgetAttributes, component string, beforeChange []string) (files []string) {
files = make([]string, 0)
for key, val := range attrs {
filePath := filepath.Join(basePath, key)
tx.addFileChange(component, RequestedFileChange{
Path: filePath,
ExpectedState: FileStateFileContentMatch,
ExpectedContent: []byte(val),
Description: "write gadget attribute",
DependsOn: []string{basePath},
BeforeChange: beforeChange,
})
files = append(files, filePath)
}
return files
}
func (tx *UsbGadgetTransaction) addReorderSymlinkChange(path string, target string, deps []string) {
tx.log.Trace().Str("path", path).Str("target", target).Msg("add reorder symlink change")
if tx.reorderSymlinkChanges == nil {
tx.reorderSymlinkChanges = &RequestedFileChange{
Component: "gadget-finalize",
Key: "reorder-symlinks",
Path: tx.configC1Path,
ExpectedState: FileStateSymlinkInOrderConfigFS,
Description: "order symlinks",
ParamSymlinks: []symlink{},
}
}
tx.reorderSymlinkChanges.DependsOn = append(tx.reorderSymlinkChanges.DependsOn, deps...)
tx.reorderSymlinkChanges.ParamSymlinks = append(tx.reorderSymlinkChanges.ParamSymlinks, symlink{
Path: path,
Target: target,
})
}
func (tx *UsbGadgetTransaction) WriteUDC() {
// bound the gadget to a UDC (USB Device Controller)
path := path.Join(tx.kvmGadgetPath, "UDC")
tx.addFileChange("udc", RequestedFileChange{
Key: "udc",
Path: path,
ExpectedState: FileStateFileContentMatch,
ExpectedContent: []byte(tx.udc),
DependsOn: []string{"reorder-symlinks"},
Description: "write UDC",
})
}
func (tx *UsbGadgetTransaction) RebindUsb(ignoreUnbindError bool) {
// remove the gadget from the UDC
tx.addFileChange("udc", RequestedFileChange{
Path: path.Join(tx.dwc3Path, "unbind"),
ExpectedState: FileStateFileWrite,
ExpectedContent: []byte(tx.udc),
Description: "unbind UDC",
DependsOn: []string{"udc"},
IgnoreErrors: ignoreUnbindError,
})
// bind the gadget to the UDC
tx.addFileChange("udc", RequestedFileChange{
Path: path.Join(tx.dwc3Path, "bind"),
ExpectedState: FileStateFileWrite,
ExpectedContent: []byte(tx.udc),
Description: "bind UDC",
DependsOn: []string{path.Join(tx.dwc3Path, "unbind")},
})
}

View File

@ -1,8 +1,11 @@
package usbgadget
import (
"context"
"fmt"
"os"
"reflect"
"time"
)
var keyboardConfig = gadgetConfigItem{
@ -11,9 +14,10 @@ var keyboardConfig = gadgetConfigItem{
path: []string{"functions", "hid.usb0"},
configPath: []string{"hid.usb0"},
attrs: gadgetAttributes{
"protocol": "1",
"subclass": "1",
"report_length": "8",
"protocol": "1",
"subclass": "1",
"report_length": "8",
"no_out_endpoint": "0",
},
reportDesc: keyboardReportDesc,
}
@ -36,6 +40,7 @@ var keyboardReportDesc = []byte{
0x81, 0x03, /* INPUT (Cnst,Var,Abs) */
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) */
@ -54,23 +59,155 @@ var keyboardReportDesc = []byte{
0xc0, /* END_COLLECTION */
}
func (u *UsbGadget) keyboardWriteHidFile(data []byte) error {
if u.keyboardHidFile == nil {
var err error
u.keyboardHidFile, err = os.OpenFile("/dev/hidg0", os.O_RDWR, 0666)
if err != nil {
return fmt.Errorf("failed to open hidg0: %w", err)
const (
hidReadBufferSize = 8
// https://www.usb.org/sites/default/files/documents/hid1_11.pdf
// https://www.usb.org/sites/default/files/hut1_2.pdf
KeyboardLedMaskNumLock = 1 << 0
KeyboardLedMaskCapsLock = 1 << 1
KeyboardLedMaskScrollLock = 1 << 2
KeyboardLedMaskCompose = 1 << 3
KeyboardLedMaskKana = 1 << 4
ValidKeyboardLedMasks = KeyboardLedMaskNumLock | KeyboardLedMaskCapsLock | KeyboardLedMaskScrollLock | KeyboardLedMaskCompose | KeyboardLedMaskKana
)
// Synchronization between LED states and CAPS LOCK, NUM LOCK, SCROLL LOCK,
// COMPOSE, and KANA events is maintained by the host and NOT the keyboard. If
// using the keyboard descriptor in Appendix B, LED states are set by sending a
// 5-bit absolute report to the keyboard via a Set_Report(Output) request.
type KeyboardState struct {
NumLock bool `json:"num_lock"`
CapsLock bool `json:"caps_lock"`
ScrollLock bool `json:"scroll_lock"`
Compose bool `json:"compose"`
Kana bool `json:"kana"`
}
func getKeyboardState(b byte) KeyboardState {
// should we check if it's the correct usage page?
return KeyboardState{
NumLock: b&KeyboardLedMaskNumLock != 0,
CapsLock: b&KeyboardLedMaskCapsLock != 0,
ScrollLock: b&KeyboardLedMaskScrollLock != 0,
Compose: b&KeyboardLedMaskCompose != 0,
Kana: b&KeyboardLedMaskKana != 0,
}
}
func (u *UsbGadget) updateKeyboardState(b byte) {
u.keyboardStateLock.Lock()
defer u.keyboardStateLock.Unlock()
if b&^ValidKeyboardLedMasks != 0 {
u.log.Trace().Uint8("b", b).Msg("contains invalid bits, ignoring")
return
}
newState := getKeyboardState(b)
if reflect.DeepEqual(u.keyboardState, newState) {
return
}
u.log.Info().Interface("old", u.keyboardState).Interface("new", newState).Msg("keyboardState updated")
u.keyboardState = newState
if u.onKeyboardStateChange != nil {
(*u.onKeyboardStateChange)(newState)
}
}
func (u *UsbGadget) SetOnKeyboardStateChange(f func(state KeyboardState)) {
u.onKeyboardStateChange = &f
}
func (u *UsbGadget) GetKeyboardState() KeyboardState {
u.keyboardStateLock.Lock()
defer u.keyboardStateLock.Unlock()
return u.keyboardState
}
func (u *UsbGadget) listenKeyboardEvents() {
var path string
if u.keyboardHidFile != nil {
path = u.keyboardHidFile.Name()
}
l := u.log.With().Str("listener", "keyboardEvents").Str("path", path).Logger()
l.Trace().Msg("starting")
go func() {
buf := make([]byte, hidReadBufferSize)
for {
select {
case <-u.keyboardStateCtx.Done():
l.Info().Msg("context done")
return
default:
l.Trace().Msg("reading from keyboard")
if u.keyboardHidFile == nil {
u.logWithSuppression("keyboardHidFileNil", 100, &l, nil, "keyboardHidFile is nil")
// show the error every 100 times to avoid spamming the logs
time.Sleep(time.Second)
continue
}
// reset the counter
u.resetLogSuppressionCounter("keyboardHidFileNil")
n, err := u.keyboardHidFile.Read(buf)
if err != nil {
u.logWithSuppression("keyboardHidFileRead", 100, &l, err, "failed to read")
continue
}
u.resetLogSuppressionCounter("keyboardHidFileRead")
l.Trace().Int("n", n).Bytes("buf", buf).Msg("got data from keyboard")
if n != 1 {
l.Trace().Int("n", n).Msg("expected 1 byte, got")
continue
}
u.updateKeyboardState(buf[0])
}
}
}()
}
func (u *UsbGadget) openKeyboardHidFile() error {
if u.keyboardHidFile != nil {
return nil
}
var err error
u.keyboardHidFile, err = os.OpenFile("/dev/hidg0", os.O_RDWR, 0666)
if err != nil {
return fmt.Errorf("failed to open hidg0: %w", err)
}
if u.keyboardStateCancel != nil {
u.keyboardStateCancel()
}
u.keyboardStateCtx, u.keyboardStateCancel = context.WithCancel(context.Background())
u.listenKeyboardEvents()
return nil
}
func (u *UsbGadget) OpenKeyboardHidFile() error {
return u.openKeyboardHidFile()
}
func (u *UsbGadget) keyboardWriteHidFile(data []byte) error {
if err := u.openKeyboardHidFile(); err != nil {
return err
}
_, err := u.keyboardHidFile.Write(data)
if err != nil {
u.log.Errorf("failed to write to hidg0: %w", err)
u.logWithSuppression("keyboardWriteHidFile", 100, u.log, err, "failed to write to hidg0")
u.keyboardHidFile.Close()
u.keyboardHidFile = nil
return err
}
u.resetLogSuppressionCounter("keyboardWriteHidFile")
return nil
}

View File

@ -11,9 +11,10 @@ var absoluteMouseConfig = gadgetConfigItem{
path: []string{"functions", "hid.usb1"},
configPath: []string{"hid.usb1"},
attrs: gadgetAttributes{
"protocol": "2",
"subclass": "1",
"report_length": "6",
"protocol": "2",
"subclass": "0",
"report_length": "6",
"no_out_endpoint": "1",
},
reportDesc: absoluteMouseCombinedReportDesc,
}
@ -55,6 +56,8 @@ var absoluteMouseCombinedReportDesc = []byte{
0x09, 0x38, // Usage (Wheel)
0x15, 0x81, // Logical Minimum (-127)
0x25, 0x7F, // Logical Maximum (127)
0x35, 0x00, // Physical Minimum (0) = Reset Physical Minimum
0x45, 0x00, // Physical Maximum (0) = Reset Physical Maximum
0x75, 0x08, // Report Size (8)
0x95, 0x01, // Report Count (1)
0x81, 0x06, // Input (Data, Var, Rel)
@ -73,11 +76,12 @@ func (u *UsbGadget) absMouseWriteHidFile(data []byte) error {
_, err := u.absMouseHidFile.Write(data)
if err != nil {
u.log.Errorf("failed to write to hidg1: %w", err)
u.logWithSuppression("absMouseWriteHidFile", 100, u.log, err, "failed to write to hidg1")
u.absMouseHidFile.Close()
u.absMouseHidFile = nil
return err
}
u.resetLogSuppressionCounter("absMouseWriteHidFile")
return nil
}
@ -105,24 +109,16 @@ func (u *UsbGadget) AbsMouseWheelReport(wheelY int8) error {
u.absMouseLock.Lock()
defer u.absMouseLock.Unlock()
// Accumulate the wheelY value
u.absMouseAccumulatedWheelY += float64(wheelY) / 8.0
// Only send a report if the accumulated value is significant
if abs(u.absMouseAccumulatedWheelY) < 1.0 {
// Only send a report if the value is non-zero
if wheelY == 0 {
return nil
}
scaledWheelY := int8(u.absMouseAccumulatedWheelY)
err := u.absMouseWriteHidFile([]byte{
2, // Report ID 2
byte(scaledWheelY), // Scaled Wheel Y (signed)
2, // Report ID 2
byte(wheelY), // Wheel Y (signed)
})
// Reset the accumulator, keeping any remainder
u.absMouseAccumulatedWheelY -= float64(scaledWheelY)
u.resetUserInputTime()
return err
}

View File

@ -11,9 +11,10 @@ var relativeMouseConfig = gadgetConfigItem{
path: []string{"functions", "hid.usb2"},
configPath: []string{"hid.usb2"},
attrs: gadgetAttributes{
"protocol": "2",
"subclass": "1",
"report_length": "4",
"protocol": "2",
"subclass": "1",
"report_length": "4",
"no_out_endpoint": "1",
},
reportDesc: relativeMouseCombinedReportDesc,
}
@ -65,11 +66,12 @@ func (u *UsbGadget) relMouseWriteHidFile(data []byte) error {
_, err := u.relMouseHidFile.Write(data)
if err != nil {
u.log.Errorf("failed to write to hidg2: %w", err)
u.logWithSuppression("relMouseWriteHidFile", 100, u.log, err, "failed to write to hidg2")
u.relMouseHidFile.Close()
u.relMouseHidFile = nil
return err
}
u.resetLogSuppressionCounter("relMouseWriteHidFile")
return nil
}

27
internal/usbgadget/log.go Normal file
View File

@ -0,0 +1,27 @@
package usbgadget
import (
"errors"
)
func (u *UsbGadget) logWarn(msg string, err error) error {
if err == nil {
err = errors.New(msg)
}
if u.strictMode {
return err
}
u.log.Warn().Err(err).Msg(msg)
return nil
}
func (u *UsbGadget) logError(msg string, err error) error {
if err == nil {
err = errors.New(msg)
}
if u.strictMode {
return err
}
u.log.Error().Err(err).Msg(msg)
return nil
}

View File

@ -14,10 +14,13 @@ var massStorageLun0Config = gadgetConfigItem{
order: 3001,
path: []string{"functions", "mass_storage.usb0", "lun.0"},
attrs: gadgetAttributes{
"cdrom": "1",
"ro": "1",
"removable": "1",
"file": "\n",
"inquiry_string": "JetKVM Virtual Media",
"cdrom": "1",
"ro": "1",
"removable": "1",
"file": "\n",
// the additional whitespace is intentional to avoid the "JetKVM V irtual Media" string
// https://github.com/jetkvm/rv1106-system/blob/778133a1c153041e73f7de86c9c434a2753ea65d/sysdrv/source/uboot/u-boot/drivers/usb/gadget/f_mass_storage.c#L2556
// Vendor (8 chars), product (16 chars)
"inquiry_string": "JetKVM Virtual Media",
},
}

View File

@ -38,7 +38,7 @@ func rebindUsb(udc string, ignoreUnbindError bool) error {
}
func (u *UsbGadget) rebindUsb(ignoreUnbindError bool) error {
u.log.Infof("rebinding USB gadget to UDC %s", u.udc)
u.log.Info().Str("udc", u.udc).Msg("rebinding USB gadget to UDC")
return rebindUsb(u.udc, ignoreUnbindError)
}
@ -50,18 +50,6 @@ func (u *UsbGadget) RebindUsb(ignoreUnbindError bool) error {
return u.rebindUsb(ignoreUnbindError)
}
func (u *UsbGadget) writeUDC() error {
path := path.Join(u.kvmGadgetPath, "UDC")
u.log.Tracef("writing UDC %s to %s", u.udc, path)
err := u.writeIfDifferent(path, []byte(u.udc), 0644)
if err != nil {
return fmt.Errorf("failed to write UDC: %w", err)
}
return nil
}
// GetUsbState returns the current state of the USB gadget
func (u *UsbGadget) GetUsbState() (state string) {
stateFile := path.Join("/sys/class/udc", u.udc, "state")
@ -70,7 +58,7 @@ func (u *UsbGadget) GetUsbState() (state string) {
if os.IsNotExist(err) {
return "not attached"
} else {
u.log.Tracef("failed to read usb state: %v", err)
u.log.Trace().Err(err).Msg("failed to read usb state")
}
return "unknown"
}

View File

@ -3,12 +3,14 @@
package usbgadget
import (
"context"
"os"
"path"
"sync"
"time"
"github.com/pion/logging"
"github.com/jetkvm/kvm/internal/logging"
"github.com/rs/zerolog"
)
// Devices is a struct that represents the USB devices that can be enabled on a USB gadget.
@ -28,7 +30,8 @@ type Config struct {
Manufacturer string `json:"manufacturer"`
Product string `json:"product"`
isEmpty bool
strictMode bool // when it's enabled, all warnings will be converted to errors
isEmpty bool
}
var defaultUsbGadgetDevices = Devices{
@ -57,24 +60,43 @@ type UsbGadget struct {
relMouseHidFile *os.File
relMouseLock sync.Mutex
keyboardState KeyboardState
keyboardStateLock sync.Mutex
keyboardStateCtx context.Context
keyboardStateCancel context.CancelFunc
enabledDevices Devices
strictMode bool // only intended for testing for now
absMouseAccumulatedWheelY float64
lastUserInput time.Time
log logging.LeveledLogger
tx *UsbGadgetTransaction
txLock sync.Mutex
onKeyboardStateChange *func(state KeyboardState)
log *zerolog.Logger
logSuppressionCounter map[string]int
logSuppressionLock sync.Mutex
}
const configFSPath = "/sys/kernel/config"
const gadgetPath = "/sys/kernel/config/usb_gadget"
var defaultLogger = logging.NewDefaultLoggerFactory().NewLogger("usbgadget")
var defaultLogger = logging.GetSubsystemLogger("usbgadget")
// NewUsbGadget creates a new UsbGadget.
func NewUsbGadget(name string, enabledDevices *Devices, config *Config, logger *logging.LeveledLogger) *UsbGadget {
func NewUsbGadget(name string, enabledDevices *Devices, config *Config, logger *zerolog.Logger) *UsbGadget {
return newUsbGadget(name, defaultGadgetConfig, enabledDevices, config, logger)
}
func newUsbGadget(name string, configMap map[string]gadgetConfigItem, enabledDevices *Devices, config *Config, logger *zerolog.Logger) *UsbGadget {
if logger == nil {
logger = &defaultLogger
logger = defaultLogger
}
if enabledDevices == nil {
@ -85,24 +107,34 @@ func NewUsbGadget(name string, enabledDevices *Devices, config *Config, logger *
config = &Config{isEmpty: true}
}
keyboardCtx, keyboardCancel := context.WithCancel(context.Background())
g := &UsbGadget{
name: name,
kvmGadgetPath: path.Join(gadgetPath, name),
configC1Path: path.Join(gadgetPath, name, "configs/c.1"),
configMap: defaultGadgetConfig,
customConfig: *config,
configLock: sync.Mutex{},
keyboardLock: sync.Mutex{},
absMouseLock: sync.Mutex{},
relMouseLock: sync.Mutex{},
enabledDevices: *enabledDevices,
lastUserInput: time.Now(),
log: *logger,
name: name,
kvmGadgetPath: path.Join(gadgetPath, name),
configC1Path: path.Join(gadgetPath, name, "configs/c.1"),
configMap: configMap,
customConfig: *config,
configLock: sync.Mutex{},
keyboardLock: sync.Mutex{},
absMouseLock: sync.Mutex{},
relMouseLock: sync.Mutex{},
txLock: sync.Mutex{},
keyboardStateCtx: keyboardCtx,
keyboardStateCancel: keyboardCancel,
keyboardState: KeyboardState{},
enabledDevices: *enabledDevices,
lastUserInput: time.Now(),
log: logger,
strictMode: config.strictMode,
logSuppressionCounter: make(map[string]int),
absMouseAccumulatedWheelY: 0,
}
if err := g.Init(); err != nil {
g.log.Errorf("failed to init USB gadget: %v", err)
logger.Error().Err(err).Msg("failed to init USB gadget")
return nil
}

View File

@ -3,61 +3,110 @@ package usbgadget
import (
"bytes"
"fmt"
"os"
"path/filepath"
)
"strconv"
"strings"
// Helper function to get absolute value of float64
func abs(x float64) float64 {
if x < 0 {
return -x
}
return x
}
"github.com/rs/zerolog"
)
func joinPath(basePath string, paths []string) string {
pathArr := append([]string{basePath}, paths...)
return filepath.Join(pathArr...)
}
func ensureSymlink(linkPath string, target string) error {
if _, err := os.Lstat(linkPath); err == nil {
currentTarget, err := os.Readlink(linkPath)
if err != nil || currentTarget != target {
err = os.Remove(linkPath)
if err != nil {
return fmt.Errorf("failed to remove existing symlink %s: %w", linkPath, err)
}
}
} else if !os.IsNotExist(err) {
return fmt.Errorf("failed to check if symlink exists: %w", err)
func hexToDecimal(hex string) (int64, error) {
decimal, err := strconv.ParseInt(hex, 16, 64)
if err != nil {
return 0, err
}
if err := os.Symlink(target, linkPath); err != nil {
return fmt.Errorf("failed to create symlink from %s to %s: %w", linkPath, target, err)
}
return nil
return decimal, nil
}
func (u *UsbGadget) writeIfDifferent(filePath string, content []byte, permMode os.FileMode) error {
if _, err := os.Stat(filePath); err == nil {
oldContent, err := os.ReadFile(filePath)
if err == nil {
if bytes.Equal(oldContent, content) {
u.log.Tracef("skipping writing to %s as it already has the correct content", filePath)
return nil
}
func decimalToOctal(decimal int64) string {
return fmt.Sprintf("%04o", decimal)
}
if len(oldContent) == len(content)+1 &&
bytes.Equal(oldContent[:len(content)], content) &&
oldContent[len(content)] == 10 {
u.log.Tracef("skipping writing to %s as it already has the correct content", filePath)
return nil
}
func hexToOctal(hex string) (string, error) {
hex = strings.ToLower(hex)
hex = strings.Replace(hex, "0x", "", 1) //remove 0x or 0X
u.log.Tracef("writing to %s as it has different content old%v new%v", filePath, oldContent, content)
decimal, err := hexToDecimal(hex)
if err != nil {
return "", err
}
// Convert the decimal integer to an octal string.
octal := decimalToOctal(decimal)
return octal, nil
}
func compareFileContent(oldContent []byte, newContent []byte, looserMatch bool) bool {
if bytes.Equal(oldContent, newContent) {
return true
}
if len(oldContent) == len(newContent)+1 &&
bytes.Equal(oldContent[:len(newContent)], newContent) &&
oldContent[len(newContent)] == 10 {
return true
}
if len(newContent) == 4 {
if len(oldContent) < 6 || len(oldContent) > 7 {
return false
}
if len(oldContent) == 7 && oldContent[6] == 0x0a {
oldContent = oldContent[:6]
}
oldOctalValue, err := hexToOctal(string(oldContent))
if err != nil {
return false
}
if oldOctalValue == string(newContent) {
return true
}
}
return os.WriteFile(filePath, content, permMode)
if looserMatch {
oldContentStr := strings.TrimSpace(string(oldContent))
newContentStr := strings.TrimSpace(string(newContent))
return oldContentStr == newContentStr
}
return false
}
func (u *UsbGadget) logWithSuppression(counterName string, every int, logger *zerolog.Logger, err error, msg string, args ...interface{}) {
u.logSuppressionLock.Lock()
defer u.logSuppressionLock.Unlock()
if _, ok := u.logSuppressionCounter[counterName]; !ok {
u.logSuppressionCounter[counterName] = 0
} else {
u.logSuppressionCounter[counterName]++
}
l := logger.With().Int("counter", u.logSuppressionCounter[counterName]).Logger()
if u.logSuppressionCounter[counterName]%every == 0 {
if err != nil {
l.Error().Err(err).Msgf(msg, args...)
} else {
l.Error().Msgf(msg, args...)
}
}
}
func (u *UsbGadget) resetLogSuppressionCounter(counterName string) {
u.logSuppressionLock.Lock()
defer u.logSuppressionLock.Unlock()
if _, ok := u.logSuppressionCounter[counterName]; !ok {
u.logSuppressionCounter[counterName] = 0
}
}

View File

@ -0,0 +1,55 @@
package websecure
import (
"os"
"testing"
)
var (
fixtureEd25519Certificate = `-----BEGIN CERTIFICATE-----
MIIBQDCB86ADAgECAhQdB4qB6dV0/u1lwhJofQgkmjjV1zAFBgMrZXAwLzELMAkG
A1UEBhMCREUxIDAeBgNVBAMMF2VkMjU1MTktdGVzdC5qZXRrdm0uY29tMB4XDTI1
MDUyMzEyNTkyN1oXDTI3MDQyMzEyNTkyN1owLzELMAkGA1UEBhMCREUxIDAeBgNV
BAMMF2VkMjU1MTktdGVzdC5qZXRrdm0uY29tMCowBQYDK2VwAyEA9tLyoulJn7Ev
bf8kuD1ZGdA092773pCRjFEDKpXHonyjITAfMB0GA1UdDgQWBBRkmrVMfsLY57iy
r/0POP0S4QxCADAFBgMrZXADQQBfTRvqavLHDYQiKQTgbGod+Yn+fIq2lE584+1U
C4wh9peIJDFocLBEAYTQpEMKxa4s0AIRxD+a7aCS5oz0e/0I
-----END CERTIFICATE-----`
fixtureEd25519PrivateKey = `-----BEGIN PRIVATE KEY-----
MC4CAQAwBQYDK2VwBCIEIKV08xUsLRHBfMXqZwxVRzIbViOp8G7aQGjPvoRFjujB
-----END PRIVATE KEY-----`
certStore *CertStore
certSigner *SelfSigner
)
func TestMain(m *testing.M) {
tlsStorePath, err := os.MkdirTemp("", "jktls.*")
if err != nil {
defaultLogger.Fatal().Err(err).Msg("failed to create temp directory")
}
certStore = NewCertStore(tlsStorePath, nil)
certStore.LoadCertificates()
certSigner = NewSelfSigner(
certStore,
nil,
"ci.jetkvm.com",
"JetKVM",
"JetKVM",
"JetKVM",
)
m.Run()
os.RemoveAll(tlsStorePath)
}
func TestSaveEd25519Certificate(t *testing.T) {
err, _ := certStore.ValidateAndSaveCertificate("ed25519-test.jetkvm.com", fixtureEd25519Certificate, fixtureEd25519PrivateKey, true)
if err != nil {
t.Fatalf("failed to save certificate: %v", err)
}
}

View File

@ -0,0 +1,9 @@
package websecure
import (
"os"
"github.com/rs/zerolog"
)
var defaultLogger = zerolog.New(os.Stdout).With().Str("component", "websecure").Logger()

View File

@ -0,0 +1,191 @@
package websecure
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"net"
"strings"
"time"
"github.com/rs/zerolog"
"golang.org/x/net/idna"
)
const selfSignerCAMagicName = "__ca__"
type SelfSigner struct {
store *CertStore
log *zerolog.Logger
caInfo pkix.Name
DefaultDomain string
DefaultOrg string
DefaultOU string
}
func NewSelfSigner(
store *CertStore,
log *zerolog.Logger,
defaultDomain,
defaultOrg,
defaultOU,
caName string,
) *SelfSigner {
return &SelfSigner{
store: store,
log: log,
DefaultDomain: defaultDomain,
DefaultOrg: defaultOrg,
DefaultOU: defaultOU,
caInfo: pkix.Name{
CommonName: caName,
Organization: []string{defaultOrg},
OrganizationalUnit: []string{defaultOU},
},
}
}
func (s *SelfSigner) getCA() *tls.Certificate {
return s.createSelfSignedCert(selfSignerCAMagicName)
}
func (s *SelfSigner) createSelfSignedCert(hostname string) *tls.Certificate {
if tlsCert := s.store.certificates[hostname]; tlsCert != nil {
return tlsCert
}
// check if hostname is the CA magic name
var ca *tls.Certificate
if hostname != selfSignerCAMagicName {
ca = s.getCA()
if ca == nil {
s.log.Error().Msg("Failed to get CA certificate")
return nil
}
}
s.log.Info().Str("hostname", hostname).Msg("Creating self-signed certificate")
// lock the store while creating the certificate (do not move upwards)
s.store.certLock.Lock()
defer s.store.certLock.Unlock()
priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
s.log.Error().Err(err).Msg("Failed to generate private key")
return nil
}
notBefore := time.Now()
notAfter := notBefore.AddDate(1, 0, 0)
serialNumber, err := generateSerialNumber()
if err != nil {
s.log.Error().Err(err).Msg("Failed to generate serial number")
return nil
}
dnsName := hostname
ip := net.ParseIP(hostname)
if ip != nil {
dnsName = s.DefaultDomain
}
// set up CSR
isCA := hostname == selfSignerCAMagicName
subject := pkix.Name{
CommonName: hostname,
Organization: []string{s.DefaultOrg},
OrganizationalUnit: []string{s.DefaultOU},
}
keyUsage := x509.KeyUsageDigitalSignature
extKeyUsage := []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}
// check if hostname is the CA magic name, and if so, set the subject to the CA info
if isCA {
subject = s.caInfo
keyUsage |= x509.KeyUsageCertSign
extKeyUsage = append(extKeyUsage, x509.ExtKeyUsageClientAuth)
notAfter = notBefore.AddDate(10, 0, 0)
}
cert := x509.Certificate{
SerialNumber: serialNumber,
Subject: subject,
NotBefore: notBefore,
NotAfter: notAfter,
IsCA: isCA,
KeyUsage: keyUsage,
ExtKeyUsage: extKeyUsage,
BasicConstraintsValid: true,
}
// set up DNS names and IP addresses
if !isCA {
cert.DNSNames = []string{dnsName}
if ip != nil {
cert.IPAddresses = []net.IP{ip}
}
}
// set up parent certificate
parent := &cert
parentPriv := priv
if ca != nil {
parent, err = x509.ParseCertificate(ca.Certificate[0])
if err != nil {
s.log.Error().Err(err).Msg("Failed to parse parent certificate")
return nil
}
parentPriv = ca.PrivateKey.(*ecdsa.PrivateKey)
}
certBytes, err := x509.CreateCertificate(rand.Reader, &cert, parent, &priv.PublicKey, parentPriv)
if err != nil {
s.log.Error().Err(err).Msg("Failed to create certificate")
return nil
}
tlsCert := &tls.Certificate{
Certificate: [][]byte{certBytes},
PrivateKey: priv,
}
if ca != nil {
tlsCert.Certificate = append(tlsCert.Certificate, ca.Certificate...)
}
s.store.certificates[hostname] = tlsCert
s.store.saveCertificate(hostname)
return tlsCert
}
// GetCertificate returns the certificate for the given hostname
// returns nil if the certificate is not found
func (s *SelfSigner) GetCertificate(info *tls.ClientHelloInfo) (*tls.Certificate, error) {
var hostname string
if info.ServerName != "" && info.ServerName != selfSignerCAMagicName {
hostname = info.ServerName
} else {
hostname = strings.Split(info.Conn.LocalAddr().String(), ":")[0]
}
s.log.Info().Str("hostname", hostname).Strs("supported_protos", info.SupportedProtos).Msg("TLS handshake")
// convert hostname to punycode
h, err := idna.Lookup.ToASCII(hostname)
if err != nil {
s.log.Warn().Str("hostname", hostname).Err(err).Str("remote_addr", info.Conn.RemoteAddr().String()).Msg("Hostname is not valid")
hostname = s.DefaultDomain
} else {
hostname = h
}
cert := s.createSelfSignedCert(hostname)
return cert, nil
}

179
internal/websecure/store.go Normal file
View File

@ -0,0 +1,179 @@
package websecure
import (
"crypto/tls"
"fmt"
"os"
"path"
"strings"
"sync"
"github.com/rs/zerolog"
)
type CertStore struct {
certificates map[string]*tls.Certificate
certLock *sync.Mutex
storePath string
log *zerolog.Logger
}
func NewCertStore(storePath string, log *zerolog.Logger) *CertStore {
if log == nil {
log = &defaultLogger
}
return &CertStore{
certificates: make(map[string]*tls.Certificate),
certLock: &sync.Mutex{},
storePath: storePath,
log: log,
}
}
func (s *CertStore) ensureStorePath() error {
// check if directory exists
stat, err := os.Stat(s.storePath)
if err == nil {
if stat.IsDir() {
return nil
}
return fmt.Errorf("TLS store path exists but is not a directory: %s", s.storePath)
}
if os.IsNotExist(err) {
s.log.Trace().Str("path", s.storePath).Msg("TLS store directory does not exist, creating directory")
err = os.MkdirAll(s.storePath, 0755)
if err != nil {
return fmt.Errorf("failed to create TLS store path: %w", err)
}
return nil
}
return fmt.Errorf("failed to check TLS store path: %w", err)
}
func (s *CertStore) LoadCertificates() {
err := s.ensureStorePath()
if err != nil {
s.log.Error().Err(err).Msg("Failed to ensure store path")
return
}
files, err := os.ReadDir(s.storePath)
if err != nil {
s.log.Error().Err(err).Msg("Failed to read TLS directory")
return
}
for _, file := range files {
if file.IsDir() {
continue
}
if strings.HasSuffix(file.Name(), ".crt") {
s.loadCertificate(strings.TrimSuffix(file.Name(), ".crt"))
}
}
}
func (s *CertStore) loadCertificate(hostname string) {
s.certLock.Lock()
defer s.certLock.Unlock()
keyFile := path.Join(s.storePath, hostname+".key")
crtFile := path.Join(s.storePath, hostname+".crt")
cert, err := tls.LoadX509KeyPair(crtFile, keyFile)
if err != nil {
s.log.Error().Err(err).Str("hostname", hostname).Msg("Failed to load certificate")
return
}
s.certificates[hostname] = &cert
if hostname == selfSignerCAMagicName {
s.log.Info().Msg("loaded CA certificate")
} else {
s.log.Info().Str("hostname", hostname).Msg("loaded certificate")
}
}
// GetCertificate returns the certificate for the given hostname
// returns nil if the certificate is not found
func (s *CertStore) GetCertificate(hostname string) *tls.Certificate {
s.certLock.Lock()
defer s.certLock.Unlock()
return s.certificates[hostname]
}
// ValidateAndSaveCertificate validates the certificate and saves it to the store
// returns are:
// - error: if the certificate is invalid or if there's any error during saving the certificate
// - error: if there's any warning or error during saving the certificate
func (s *CertStore) ValidateAndSaveCertificate(hostname string, cert string, key string, ignoreWarning bool) (error, error) {
tlsCert, err := tls.X509KeyPair([]byte(cert), []byte(key))
if err != nil {
return fmt.Errorf("failed to parse certificate: %w", err), nil
}
// this can be skipped as current implementation supports one custom certificate only
if tlsCert.Leaf != nil {
// add recover to avoid panic
defer func() {
if r := recover(); r != nil {
s.log.Error().Interface("recovered", r).Msg("Failed to verify hostname")
}
}()
if err = tlsCert.Leaf.VerifyHostname(hostname); err != nil {
if !ignoreWarning {
return nil, fmt.Errorf("certificate does not match hostname: %w", err)
}
s.log.Warn().Err(err).Msg("certificate does not match hostname")
}
}
s.certLock.Lock()
s.certificates[hostname] = &tlsCert
s.certLock.Unlock()
s.saveCertificate(hostname)
return nil, nil
}
func (s *CertStore) saveCertificate(hostname string) {
// check if certificate already exists
tlsCert := s.certificates[hostname]
if tlsCert == nil {
s.log.Error().Str("hostname", hostname).Msg("Certificate for hostname does not exist, skipping saving certificate")
return
}
err := s.ensureStorePath()
if err != nil {
s.log.Error().Err(err).Msg("Failed to ensure store path")
return
}
keyFile := path.Join(s.storePath, hostname+".key")
crtFile := path.Join(s.storePath, hostname+".crt")
if err := keyToFile(tlsCert, keyFile); err != nil {
s.log.Error().Err(err).Msg("Failed to save key file")
return
}
if err := certToFile(tlsCert, crtFile); err != nil {
s.log.Error().Err(err).Msg("Failed to save certificate")
return
}
s.log.Info().Str("hostname", hostname).Msg("Saved certificate")
}

View File

@ -0,0 +1,85 @@
package websecure
import (
"crypto/ecdsa"
"crypto/ed25519"
"crypto/rand"
"crypto/rsa"
"crypto/tls"
"crypto/x509"
"encoding/pem"
"fmt"
"math/big"
"os"
)
var serialNumberLimit = new(big.Int).Lsh(big.NewInt(1), 4096)
func withSecretFile(filename string, f func(*os.File) error) error {
file, err := os.OpenFile(filename, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0600)
if err != nil {
return err
}
defer file.Close()
return f(file)
}
func keyToFile(cert *tls.Certificate, filename string) error {
var keyBlock pem.Block
switch k := cert.PrivateKey.(type) {
case *rsa.PrivateKey:
keyBlock = pem.Block{
Type: "RSA PRIVATE KEY",
Bytes: x509.MarshalPKCS1PrivateKey(k),
}
case *ecdsa.PrivateKey:
b, e := x509.MarshalECPrivateKey(k)
if e != nil {
return fmt.Errorf("failed to marshal EC private key: %v", e)
}
keyBlock = pem.Block{
Type: "EC PRIVATE KEY",
Bytes: b,
}
case ed25519.PrivateKey:
keyBlock = pem.Block{
Type: "ED25519 PRIVATE KEY",
Bytes: k,
}
default:
return fmt.Errorf("unknown private key type: %T", k)
}
err := withSecretFile(filename, func(file *os.File) error {
return pem.Encode(file, &keyBlock)
})
if err != nil {
return fmt.Errorf("failed to save private key: %w", err)
}
return nil
}
func certToFile(cert *tls.Certificate, filename string) error {
return withSecretFile(filename, func(file *os.File) error {
for _, c := range cert.Certificate {
block := pem.Block{
Type: "CERTIFICATE",
Bytes: c,
}
err := pem.Encode(file, &block)
if err != nil {
return fmt.Errorf("failed to save certificate: %w", err)
}
}
return nil
})
}
func generateSerialNumber() (*big.Int, error) {
return rand.Int(rand.Reader, serialNumberLimit)
}

View File

@ -2,9 +2,10 @@ package kvm
import (
"fmt"
"github.com/go-co-op/gocron/v2"
"math/rand"
"time"
"github.com/go-co-op/gocron/v2"
)
type JigglerConfig struct {
@ -29,7 +30,7 @@ func rpcGetJigglerConfig() (JigglerConfig, error) {
}
func rpcSetJigglerConfig(jigglerConfig JigglerConfig) error {
logger.Infof("jigglerConfig: %v, %v, %v", jigglerConfig.InactivityLimitSeconds, jigglerConfig.JitterPercentage, jigglerConfig.ScheduleCronTab)
logger.Info().Msgf("jigglerConfig: %v, %v, %v", jigglerConfig.InactivityLimitSeconds, jigglerConfig.JitterPercentage, jigglerConfig.ScheduleCronTab)
config.JigglerConfig = &jigglerConfig
err := removeExistingCrobJobs(scheduler)
if err != nil {
@ -60,7 +61,7 @@ func init() {
ensureConfigLoaded()
err := runJigglerCronTab()
if err != nil {
logger.Errorf("Error scheduling jiggler crontab: %v", err)
logger.Error().Msgf("Error scheduling jiggler crontab: %v", err)
return
}
}
@ -89,7 +90,7 @@ func runJigglerCronTab() error {
s.Start()
delta, err := calculateJobDelta(s)
jobDelta = delta
logger.Infof("Time between jiggler runs: %v", jobDelta)
logger.Info().Msgf("Time between jiggler runs: %v", jobDelta)
if err != nil {
return err
}
@ -104,17 +105,17 @@ func runJiggler() {
}
inactivitySeconds := config.JigglerConfig.InactivityLimitSeconds
timeSinceLastInput := time.Since(gadget.GetLastUserInputTime())
logger.Debugf("Time since last user input %v", timeSinceLastInput)
logger.Debug().Msgf("Time since last user input %v", timeSinceLastInput)
if timeSinceLastInput > time.Duration(inactivitySeconds)*time.Second {
logger.Debug("Jiggling mouse...")
logger.Debug().Msg("Jiggling mouse...")
//TODO: change to rel mouse
err := rpcAbsMouseReport(1, 1, 0)
if err != nil {
logger.Warnf("Failed to jiggle mouse: %v", err)
logger.Warn().Msgf("Failed to jiggle mouse: %v", err)
}
err = rpcAbsMouseReport(0, 0, 0)
if err != nil {
logger.Warnf("Failed to reset mouse position: %v", err)
logger.Warn().Msgf("Failed to reset mouse position: %v", err)
}
}
}

View File

@ -38,6 +38,10 @@ type JSONRPCEvent struct {
Params interface{} `json:"params,omitempty"`
}
type DisplayRotationSettings struct {
Rotation string `json:"rotation"`
}
type BacklightSettings struct {
MaxBrightness int `json:"max_brightness"`
DimAfter int `json:"dim_after"`
@ -47,12 +51,12 @@ type BacklightSettings struct {
func writeJSONRPCResponse(response JSONRPCResponse, session *Session) {
responseBytes, err := json.Marshal(response)
if err != nil {
logger.Warnf("Error marshalling JSONRPC response: %v", err)
jsonRpcLogger.Warn().Err(err).Msg("Error marshalling JSONRPC response")
return
}
err = session.RPCChannel.SendText(string(responseBytes))
if err != nil {
logger.Warnf("Error sending JSONRPC response: %v", err)
jsonRpcLogger.Warn().Err(err).Msg("Error sending JSONRPC response")
return
}
}
@ -65,16 +69,24 @@ func writeJSONRPCEvent(event string, params interface{}, session *Session) {
}
requestBytes, err := json.Marshal(request)
if err != nil {
logger.Warnf("Error marshalling JSONRPC event: %v", err)
jsonRpcLogger.Warn().Err(err).Msg("Error marshalling JSONRPC event")
return
}
if session == nil || session.RPCChannel == nil {
logger.Info("RPC channel not available")
jsonRpcLogger.Info().Msg("RPC channel not available")
return
}
err = session.RPCChannel.SendText(string(requestBytes))
requestString := string(requestBytes)
scopedLogger := jsonRpcLogger.With().
Str("data", requestString).
Logger()
scopedLogger.Info().Msg("sending JSONRPC event")
err = session.RPCChannel.SendText(requestString)
if err != nil {
logger.Warnf("Error sending JSONRPC event: %v", err)
scopedLogger.Warn().Err(err).Msg("error sending JSONRPC event")
return
}
}
@ -83,6 +95,11 @@ func onRPCMessage(message webrtc.DataChannelMessage, session *Session) {
var request JSONRPCRequest
err := json.Unmarshal(message.Data, &request)
if err != nil {
jsonRpcLogger.Warn().
Str("data", string(message.Data)).
Err(err).
Msg("Error unmarshalling JSONRPC request")
errorResponse := JSONRPCResponse{
JSONRPC: "2.0",
Error: map[string]interface{}{
@ -95,7 +112,13 @@ func onRPCMessage(message webrtc.DataChannelMessage, session *Session) {
return
}
//logger.Infof("Received RPC request: Method=%s, Params=%v, ID=%d", request.Method, request.Params, request.ID)
scopedLogger := jsonRpcLogger.With().
Str("method", request.Method).
Interface("params", request.Params).
Interface("id", request.ID).Logger()
scopedLogger.Trace().Msg("Received RPC request")
handler, ok := rpcHandlers[request.Method]
if !ok {
errorResponse := JSONRPCResponse{
@ -110,8 +133,10 @@ func onRPCMessage(message webrtc.DataChannelMessage, session *Session) {
return
}
scopedLogger.Trace().Msg("Calling RPC handler")
result, err := callRPCHandler(handler, request.Params)
if err != nil {
scopedLogger.Error().Err(err).Msg("Error calling RPC handler")
errorResponse := JSONRPCResponse{
JSONRPC: "2.0",
Error: map[string]interface{}{
@ -125,6 +150,8 @@ func onRPCMessage(message webrtc.DataChannelMessage, session *Session) {
return
}
scopedLogger.Trace().Interface("result", result).Msg("RPC handler returned")
response := JSONRPCResponse{
JSONRPC: "2.0",
Result: result,
@ -141,6 +168,30 @@ func rpcGetDeviceID() (string, error) {
return GetDeviceID(), nil
}
func rpcReboot(force bool) error {
logger.Info().Msg("Got reboot request from JSONRPC, rebooting...")
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")
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 streamFactor = 1.0
func rpcGetStreamQualityFactor() (float64, error) {
@ -148,7 +199,7 @@ func rpcGetStreamQualityFactor() (float64, error) {
}
func rpcSetStreamQualityFactor(factor float64) error {
logger.Infof("Setting stream quality factor to: %f", factor)
logger.Info().Float64("factor", factor).Msg("Setting stream quality factor")
var _, err = CallCtrlAction("set_video_quality_factor", map[string]interface{}{"quality_factor": factor})
if err != nil {
return err
@ -184,10 +235,10 @@ func rpcGetEDID() (string, error) {
func rpcSetEDID(edid string) error {
if edid == "" {
logger.Info("Restoring EDID to default")
logger.Info().Msg("Restoring EDID to default")
edid = "00ffffffffffff0052620188008888881c150103800000780a0dc9a05747982712484c00000001010101010101010101010101010101023a801871382d40582c4500c48e2100001e011d007251d01e206e285500c48e2100001e000000fc00543734392d6648443732300a20000000fd00147801ff1d000a202020202020017b"
} else {
logger.Infof("Setting EDID to: %s", edid)
logger.Info().Str("edid", edid).Msg("Setting EDID")
}
_, err := CallCtrlAction("set_edid", map[string]interface{}{"edid": edid})
if err != nil {
@ -215,8 +266,13 @@ func rpcSetDevChannelState(enabled bool) error {
func rpcGetUpdateStatus() (*UpdateStatus, error) {
includePreRelease := config.IncludePreRelease
updateStatus, err := GetUpdateStatus(context.Background(), GetDeviceID(), includePreRelease)
// to ensure backwards compatibility,
// if there's an error, we won't return an error, but we will set the error field
if err != nil {
return nil, fmt.Errorf("error checking for updates: %w", err)
if updateStatus == nil {
return nil, fmt.Errorf("error checking for updates: %w", err)
}
updateStatus.Error = err.Error()
}
return updateStatus, nil
@ -227,12 +283,30 @@ func rpcTryUpdate() error {
go func() {
err := TryUpdate(context.Background(), GetDeviceID(), includePreRelease)
if err != nil {
logger.Warnf("failed to try update: %v", err)
logger.Warn().Err(err).Msg("failed to try update")
}
}()
return nil
}
func rpcSetDisplayRotation(params DisplayRotationSettings) error {
var err error
_, err = lvDispSetRotation(params.Rotation)
if err == nil {
config.DisplayRotation = params.Rotation
if err := SaveConfig(); err != nil {
return fmt.Errorf("failed to save config: %w", err)
}
}
return err
}
func rpcGetDisplayRotation() (*DisplayRotationSettings, error) {
return &DisplayRotationSettings{
Rotation: config.DisplayRotation,
}, nil
}
func rpcSetBacklightSettings(params BacklightSettings) error {
blConfig := params
@ -257,7 +331,7 @@ func rpcSetBacklightSettings(params BacklightSettings) error {
return fmt.Errorf("failed to save config: %w", err)
}
logger.Infof("rpc: display: settings applied, max_brightness: %d, dim after: %ds, off after: %ds", config.DisplayMaxBrightness, config.DisplayDimAfterSec, config.DisplayOffAfterSec)
logger.Info().Int("max_brightness", config.DisplayMaxBrightness).Int("dim_after", config.DisplayDimAfterSec).Int("off_after", config.DisplayOffAfterSec).Msg("rpc: display: settings applied")
// If the device started up with auto-dim and/or auto-off set to zero, the display init
// method will not have started the tickers. So in case that has changed, attempt to start the tickers now.
@ -318,7 +392,7 @@ func rpcSetDevModeState(enabled bool) error {
return fmt.Errorf("failed to create devmode file: %w", err)
}
} else {
logger.Debug("dev mode already enabled")
logger.Debug().Msg("dev mode already enabled")
return nil
}
} else {
@ -327,7 +401,7 @@ func rpcSetDevModeState(enabled bool) error {
return fmt.Errorf("failed to remove devmode file: %w", err)
}
} else if os.IsNotExist(err) {
logger.Debug("dev mode already disabled")
logger.Debug().Msg("dev mode already disabled")
return nil
} else {
return fmt.Errorf("error checking dev mode file: %w", err)
@ -337,7 +411,7 @@ func rpcSetDevModeState(enabled bool) error {
cmd := exec.Command("dropbear.sh")
output, err := cmd.CombinedOutput()
if err != nil {
logger.Warnf("Failed to start/stop SSH: %v, %v", err, output)
logger.Warn().Err(err).Bytes("output", output).Msg("Failed to start/stop SSH")
return fmt.Errorf("failed to start/stop SSH, you may need to reboot for changes to take effect")
}
@ -375,7 +449,48 @@ func rpcSetSSHKeyState(sshKey string) error {
return nil
}
func callRPCHandler(handler RPCHandler, params map[string]interface{}) (interface{}, error) {
func rpcGetTLSState() TLSState {
return getTLSState()
}
func rpcSetTLSState(state TLSState) error {
err := setTLSState(state)
if err != nil {
return fmt.Errorf("failed to set TLS state: %w", err)
}
if err := SaveConfig(); err != nil {
return fmt.Errorf("failed to save config: %w", err)
}
return nil
}
type RPCHandler struct {
Func interface{}
Params []string
}
// call the handler but recover from a panic to ensure our RPC thread doesn't collapse on malformed calls
func callRPCHandler(handler RPCHandler, params map[string]interface{}) (result interface{}, err error) {
// Use defer to recover from a panic
defer func() {
if r := recover(); r != nil {
// Convert the panic to an error
if e, ok := r.(error); ok {
err = e
} else {
err = fmt.Errorf("panic occurred: %v", r)
}
}
}()
// Call the handler
result, err = riskyCallRPCHandler(handler, params)
return result, err
}
func riskyCallRPCHandler(handler RPCHandler, params map[string]interface{}) (interface{}, error) {
handlerValue := reflect.ValueOf(handler.Func)
handlerType := handlerValue.Type()
@ -472,36 +587,34 @@ func callRPCHandler(handler RPCHandler, params map[string]interface{}) (interfac
return nil, errors.New("unexpected return values from handler")
}
type RPCHandler struct {
Func interface{}
Params []string
}
func rpcSetMassStorageMode(mode string) (string, error) {
logger.Infof("[jsonrpc.go:rpcSetMassStorageMode] Setting mass storage mode to: %s", mode)
logger.Info().Str("mode", mode).Msg("Setting mass storage mode")
var cdrom bool
if mode == "cdrom" {
switch mode {
case "cdrom":
cdrom = true
} else if mode != "file" {
logger.Infof("[jsonrpc.go:rpcSetMassStorageMode] Invalid mode provided: %s", mode)
case "file":
cdrom = false
default:
logger.Info().Str("mode", mode).Msg("Invalid mode provided")
return "", fmt.Errorf("invalid mode: %s", mode)
}
logger.Infof("[jsonrpc.go:rpcSetMassStorageMode] Setting mass storage mode to: %s", mode)
logger.Info().Str("mode", mode).Msg("Setting mass storage mode")
err := setMassStorageMode(cdrom)
if err != nil {
return "", fmt.Errorf("failed to set mass storage mode: %w", err)
}
logger.Infof("[jsonrpc.go:rpcSetMassStorageMode] Mass storage mode set to %s", mode)
logger.Info().Str("mode", mode).Msg("Mass storage mode set")
// Get the updated mode after setting
return rpcGetMassStorageMode()
}
func rpcGetMassStorageMode() (string, error) {
cdrom, err := getMassStorageMode()
cdrom, err := getMassStorageCDROMEnabled()
if err != nil {
return "", fmt.Errorf("failed to get mass storage mode: %w", err)
}
@ -563,15 +676,16 @@ func rpcResetConfig() error {
return fmt.Errorf("failed to reset config: %w", err)
}
logger.Info("Configuration reset to default")
logger.Info().Msg("Configuration reset to default")
return nil
}
type DCPowerState struct {
IsOn bool `json:"isOn"`
Voltage float64 `json:"voltage"`
Current float64 `json:"current"`
Power float64 `json:"power"`
IsOn bool `json:"isOn"`
Voltage float64 `json:"voltage"`
Current float64 `json:"current"`
Power float64 `json:"power"`
RestoreState int `json:"restoreState"`
}
func rpcGetDCPowerState() (DCPowerState, error) {
@ -579,7 +693,7 @@ func rpcGetDCPowerState() (DCPowerState, error) {
}
func rpcSetDCPowerState(enabled bool) error {
logger.Infof("[jsonrpc.go:rpcSetDCPowerState] Setting DC power state to: %v", enabled)
logger.Info().Bool("enabled", enabled).Msg("Setting DC power state")
err := setDCPowerState(enabled)
if err != nil {
return fmt.Errorf("failed to set DC power state: %w", err)
@ -587,6 +701,15 @@ func rpcSetDCPowerState(enabled bool) error {
return nil
}
func rpcSetDCRestoreState(state int) error {
logger.Info().Int("state", state).Msg("Setting DC restore state")
err := setDCRestoreState(state)
if err != nil {
return fmt.Errorf("failed to set DC restore state: %w", err)
}
return nil
}
func rpcGetActiveExtension() (string, error) {
return config.ActiveExtension, nil
}
@ -595,34 +718,36 @@ func rpcSetActiveExtension(extensionId string) error {
if config.ActiveExtension == extensionId {
return nil
}
if config.ActiveExtension == "atx-power" {
switch config.ActiveExtension {
case "atx-power":
_ = unmountATXControl()
} else if config.ActiveExtension == "dc-power" {
case "dc-power":
_ = unmountDCControl()
}
config.ActiveExtension = extensionId
if err := SaveConfig(); err != nil {
return fmt.Errorf("failed to save config: %w", err)
}
if extensionId == "atx-power" {
switch extensionId {
case "atx-power":
_ = mountATXControl()
} else if extensionId == "dc-power" {
case "dc-power":
_ = mountDCControl()
}
return nil
}
func rpcSetATXPowerAction(action string) error {
logger.Debugf("[jsonrpc.go:rpcSetATXPowerAction] Executing ATX power action: %s", action)
logger.Debug().Str("action", action).Msg("Executing ATX power action")
switch action {
case "power-short":
logger.Debug("[jsonrpc.go:rpcSetATXPowerAction] Simulating short power button press")
logger.Debug().Msg("Simulating short power button press")
return pressATXPowerButton(200 * time.Millisecond)
case "power-long":
logger.Debug("[jsonrpc.go:rpcSetATXPowerAction] Simulating long power button press")
logger.Debug().Msg("Simulating long power button press")
return pressATXPowerButton(5 * time.Second)
case "reset":
logger.Debug("[jsonrpc.go:rpcSetATXPowerAction] Simulating reset button press")
logger.Debug().Msg("Simulating reset button press")
return pressATXResetButton(200 * time.Millisecond)
default:
return fmt.Errorf("invalid action: %s", action)
@ -771,9 +896,14 @@ func rpcSetUsbDeviceState(device string, enabled bool) error {
}
func rpcSetCloudUrl(apiUrl string, appUrl string) error {
currentCloudURL := config.CloudURL
config.CloudURL = apiUrl
config.CloudAppURL = appUrl
if currentCloudURL != apiUrl {
disconnectCloud(fmt.Errorf("cloud url changed from %s to %s", currentCloudURL, apiUrl))
}
if err := SaveConfig(); err != nil {
return fmt.Errorf("failed to save config: %w", err)
}
@ -781,23 +911,142 @@ func rpcSetCloudUrl(apiUrl string, appUrl string) error {
return nil
}
var currentScrollSensitivity string = "default"
func rpcGetScrollSensitivity() (string, error) {
return currentScrollSensitivity, nil
func rpcGetKeyboardLayout() (string, error) {
return config.KeyboardLayout, nil
}
func rpcSetScrollSensitivity(sensitivity string) error {
currentScrollSensitivity = sensitivity
func rpcSetKeyboardLayout(layout string) error {
config.KeyboardLayout = layout
if err := SaveConfig(); err != nil {
return fmt.Errorf("failed to save config: %w", err)
}
return nil
}
func getKeyboardMacros() (interface{}, error) {
macros := make([]KeyboardMacro, len(config.KeyboardMacros))
copy(macros, config.KeyboardMacros)
return macros, nil
}
type KeyboardMacrosParams struct {
Macros []interface{} `json:"macros"`
}
func setKeyboardMacros(params KeyboardMacrosParams) (interface{}, error) {
if params.Macros == nil {
return nil, fmt.Errorf("missing or invalid macros parameter")
}
newMacros := make([]KeyboardMacro, 0, len(params.Macros))
for i, item := range params.Macros {
macroMap, ok := item.(map[string]interface{})
if !ok {
return nil, fmt.Errorf("invalid macro at index %d", i)
}
id, _ := macroMap["id"].(string)
if id == "" {
id = fmt.Sprintf("macro-%d", time.Now().UnixNano())
}
name, _ := macroMap["name"].(string)
sortOrder := i + 1
if sortOrderFloat, ok := macroMap["sortOrder"].(float64); ok {
sortOrder = int(sortOrderFloat)
}
steps := []KeyboardMacroStep{}
if stepsArray, ok := macroMap["steps"].([]interface{}); ok {
for _, stepItem := range stepsArray {
stepMap, ok := stepItem.(map[string]interface{})
if !ok {
continue
}
step := KeyboardMacroStep{}
if keysArray, ok := stepMap["keys"].([]interface{}); ok {
for _, k := range keysArray {
if keyStr, ok := k.(string); ok {
step.Keys = append(step.Keys, keyStr)
}
}
}
if modsArray, ok := stepMap["modifiers"].([]interface{}); ok {
for _, m := range modsArray {
if modStr, ok := m.(string); ok {
step.Modifiers = append(step.Modifiers, modStr)
}
}
}
if delay, ok := stepMap["delay"].(float64); ok {
step.Delay = int(delay)
}
steps = append(steps, step)
}
}
macro := KeyboardMacro{
ID: id,
Name: name,
Steps: steps,
SortOrder: sortOrder,
}
if err := macro.Validate(); err != nil {
return nil, fmt.Errorf("invalid macro at index %d: %w", i, err)
}
newMacros = append(newMacros, macro)
}
config.KeyboardMacros = newMacros
if err := SaveConfig(); err != nil {
return nil, err
}
return nil, nil
}
func rpcGetLocalLoopbackOnly() (bool, error) {
return config.LocalLoopbackOnly, nil
}
func rpcSetLocalLoopbackOnly(enabled bool) error {
// Check if the setting is actually changing
if config.LocalLoopbackOnly == enabled {
return nil
}
// Update the setting
config.LocalLoopbackOnly = enabled
if err := SaveConfig(); err != nil {
return fmt.Errorf("failed to save config: %w", err)
}
return nil
}
var rpcHandlers = map[string]RPCHandler{
"ping": {Func: rpcPing},
"reboot": {Func: rpcReboot, Params: []string{"force"}},
"getDeviceID": {Func: rpcGetDeviceID},
"deregisterDevice": {Func: rpcDeregisterDevice},
"getCloudState": {Func: rpcGetCloudState},
"getNetworkState": {Func: rpcGetNetworkState},
"getNetworkSettings": {Func: rpcGetNetworkSettings},
"setNetworkSettings": {Func: rpcSetNetworkSettings, Params: []string{"settings"}},
"renewDHCPLease": {Func: rpcRenewDHCPLease},
"keyboardReport": {Func: rpcKeyboardReport, Params: []string{"modifier", "keys"}},
"getKeyboardLedState": {Func: rpcGetKeyboardLedState},
"absMouseReport": {Func: rpcAbsMouseReport, Params: []string{"x", "y", "buttons"}},
"relMouseReport": {Func: rpcRelMouseReport, Params: []string{"dx", "dy", "buttons"}},
"wheelReport": {Func: rpcWheelReport, Params: []string{"wheelY"}},
@ -824,6 +1073,8 @@ var rpcHandlers = map[string]RPCHandler{
"setDevModeState": {Func: rpcSetDevModeState, Params: []string{"enabled"}},
"getSSHKeyState": {Func: rpcGetSSHKeyState},
"setSSHKeyState": {Func: rpcSetSSHKeyState, Params: []string{"sshKey"}},
"getTLSState": {Func: rpcGetTLSState},
"setTLSState": {Func: rpcSetTLSState, Params: []string{"state"}},
"setMassStorageMode": {Func: rpcSetMassStorageMode, Params: []string{"mode"}},
"getMassStorageMode": {Func: rpcGetMassStorageMode},
"isUpdatePending": {Func: rpcIsUpdatePending},
@ -843,10 +1094,13 @@ var rpcHandlers = map[string]RPCHandler{
"getWakeOnLanDevices": {Func: rpcGetWakeOnLanDevices},
"setWakeOnLanDevices": {Func: rpcSetWakeOnLanDevices, Params: []string{"params"}},
"resetConfig": {Func: rpcResetConfig},
"setDisplayRotation": {Func: rpcSetDisplayRotation, Params: []string{"params"}},
"getDisplayRotation": {Func: rpcGetDisplayRotation},
"setBacklightSettings": {Func: rpcSetBacklightSettings, Params: []string{"params"}},
"getBacklightSettings": {Func: rpcGetBacklightSettings},
"getDCPowerState": {Func: rpcGetDCPowerState},
"setDCPowerState": {Func: rpcSetDCPowerState, Params: []string{"enabled"}},
"setDCRestoreState": {Func: rpcSetDCRestoreState, Params: []string{"state"}},
"getActiveExtension": {Func: rpcGetActiveExtension},
"setActiveExtension": {Func: rpcSetActiveExtension, Params: []string{"extensionId"}},
"getATXState": {Func: rpcGetATXState},
@ -857,6 +1111,10 @@ var rpcHandlers = map[string]RPCHandler{
"setUsbDevices": {Func: rpcSetUsbDevices, Params: []string{"devices"}},
"setUsbDeviceState": {Func: rpcSetUsbDeviceState, Params: []string{"device", "enabled"}},
"setCloudUrl": {Func: rpcSetCloudUrl, Params: []string{"apiUrl", "appUrl"}},
"getScrollSensitivity": {Func: rpcGetScrollSensitivity},
"setScrollSensitivity": {Func: rpcSetScrollSensitivity, Params: []string{"sensitivity"}},
"getKeyboardLayout": {Func: rpcGetKeyboardLayout},
"setKeyboardLayout": {Func: rpcSetKeyboardLayout, Params: []string{"layout"}},
"getKeyboardMacros": {Func: getKeyboardMacros},
"setKeyboardMacros": {Func: setKeyboardMacros, Params: []string{"params"}},
"getLocalLoopbackOnly": {Func: rpcGetLocalLoopbackOnly},
"setLocalLoopbackOnly": {Func: rpcSetLocalLoopbackOnly, Params: []string{"enabled"}},
}

34
log.go
View File

@ -1,8 +1,32 @@
package kvm
import "github.com/pion/logging"
import (
"github.com/jetkvm/kvm/internal/logging"
"github.com/rs/zerolog"
)
// we use logging framework from pion
// ref: https://github.com/pion/webrtc/wiki/Debugging-WebRTC
var logger = logging.NewDefaultLoggerFactory().NewLogger("jetkvm")
var cloudLogger = logging.NewDefaultLoggerFactory().NewLogger("cloud")
func ErrorfL(l *zerolog.Logger, format string, err error, args ...interface{}) error {
return logging.ErrorfL(l, format, err, args...)
}
var (
logger = logging.GetSubsystemLogger("jetkvm")
networkLogger = logging.GetSubsystemLogger("network")
cloudLogger = logging.GetSubsystemLogger("cloud")
websocketLogger = logging.GetSubsystemLogger("websocket")
webrtcLogger = logging.GetSubsystemLogger("webrtc")
nativeLogger = logging.GetSubsystemLogger("native")
nbdLogger = logging.GetSubsystemLogger("nbd")
timesyncLogger = logging.GetSubsystemLogger("timesync")
jsonRpcLogger = logging.GetSubsystemLogger("jsonrpc")
watchdogLogger = logging.GetSubsystemLogger("watchdog")
websecureLogger = logging.GetSubsystemLogger("websecure")
otaLogger = logging.GetSubsystemLogger("ota")
serialLogger = logging.GetSubsystemLogger("serial")
terminalLogger = logging.GetSubsystemLogger("terminal")
displayLogger = logging.GetSubsystemLogger("display")
wolLogger = logging.GetSubsystemLogger("wol")
usbLogger = logging.GetSubsystemLogger("usb")
// external components
ginLogger = logging.GetSubsystemLogger("gin")
)

78
main.go
View File

@ -14,25 +14,55 @@ import (
var appCtx context.Context
func Main() {
LoadConfig()
var cancel context.CancelFunc
appCtx, cancel = context.WithCancel(context.Background())
defer cancel()
logger.Info("Starting JetKvm")
systemVersionLocal, appVersionLocal, err := GetLocalVersion()
if err != nil {
logger.Warn().Err(err).Msg("failed to get local version")
}
logger.Info().
Interface("system_version", systemVersionLocal).
Interface("app_version", appVersionLocal).
Msg("starting JetKVM")
go runWatchdog()
go confirmCurrentSystem()
http.DefaultClient.Timeout = 1 * time.Minute
LoadConfig()
logger.Debug("config loaded")
err := rootcerts.UpdateDefaultTransport()
err = rootcerts.UpdateDefaultTransport()
if err != nil {
logger.Errorf("failed to load CA certs: %v", err)
logger.Warn().Err(err).Msg("failed to load Root CA certificates")
}
logger.Info().
Int("ca_certs_loaded", len(rootcerts.Certs())).
Msg("loaded Root CA certificates")
// Initialize network
if err := initNetwork(); err != nil {
logger.Error().Err(err).Msg("failed to initialize network")
os.Exit(1)
}
go TimeSyncLoop()
// Initialize time sync
initTimeSync()
timeSync.Start()
// Initialize mDNS
if err := initMdns(); err != nil {
logger.Error().Err(err).Msg("failed to initialize mDNS")
os.Exit(1)
}
// Initialize native ctrl socket server
StartNativeCtrlSocketServer()
// Initialize native video socket server
StartNativeVideoSocketServer()
initPrometheus()
@ -40,48 +70,62 @@ func Main() {
go func() {
err = ExtractAndRunNativeBin()
if err != nil {
logger.Errorf("failed to extract and run native bin: %v", err)
logger.Warn().Err(err).Msg("failed to extract and run native bin")
//TODO: prepare an error message screen buffer to show on kvm screen
}
}()
// initialize usb gadget
initUsbGadget()
if err := setInitialVirtualMediaState(); err != nil {
logger.Warn().Err(err).Msg("failed to set initial virtual media state")
}
if err := initImagesFolder(); err != nil {
logger.Warn().Err(err).Msg("failed to init images folder")
}
initJiggler()
// initialize display
initDisplay()
go func() {
time.Sleep(15 * time.Minute)
for {
logger.Debugf("UPDATING - Auto update enabled: %v", config.AutoUpdateEnabled)
logger.Debug().Bool("auto_update_enabled", config.AutoUpdateEnabled).Msg("UPDATING")
if !config.AutoUpdateEnabled {
return
}
if currentSession != nil {
logger.Debugf("skipping update since a session is active")
logger.Debug().Msg("skipping update since a session is active")
time.Sleep(1 * time.Minute)
continue
}
includePreRelease := config.IncludePreRelease
err = TryUpdate(context.Background(), GetDeviceID(), includePreRelease)
if err != nil {
logger.Errorf("failed to auto update: %v", err)
logger.Warn().Err(err).Msg("failed to auto update")
}
time.Sleep(1 * time.Hour)
}
}()
//go RunFuseServer()
go RunWebServer()
go RunWebSecureServer()
// Web secure server is started only if TLS mode is enabled
if config.TLSMode != "" {
go RunWebSecureServer()
}
// If the cloud token isn't set, the client won't be started by default.
// However, if the user adopts the device via the web interface, handleCloudRegister will start the client.
if config.CloudToken != "" {
go RunWebsocketClient()
startWebSecureServer()
}
// As websocket client already checks if the cloud token is set, we can start it here.
go RunWebsocketClient()
initSerialPort()
sigs := make(chan os.Signal, 1)
signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM)
<-sigs
logger.Info("JetKVM Shutting Down")
logger.Info().Msg("JetKVM Shutting Down")
//if fuseServer != nil {
// err := setMassStorageImage(" ")
// if err != nil {

29
mdns.go Normal file
View File

@ -0,0 +1,29 @@
package kvm
import (
"github.com/jetkvm/kvm/internal/mdns"
)
var mDNS *mdns.MDNS
func initMdns() error {
m, err := mdns.NewMDNS(&mdns.MDNSOptions{
Logger: logger,
LocalNames: []string{
networkState.GetHostname(),
networkState.GetFQDN(),
},
ListenOptions: &mdns.MDNSListenOptions{
IPv4: true,
IPv6: true,
},
})
if err != nil {
return err
}
// do not start the server yet, as we need to wait for the network state to be set
mDNS = m
return nil
}

223
native.go
View File

@ -3,13 +3,14 @@ package kvm
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"os"
"os/exec"
"strings"
"sync"
"syscall"
"time"
"github.com/jetkvm/kvm/resource"
@ -42,6 +43,11 @@ var ongoingRequests = make(map[int32]chan *CtrlResponse)
var lock = &sync.Mutex{}
var (
nativeCmd *exec.Cmd
nativeCmdLock = &sync.Mutex{}
)
func CallCtrlAction(action string, params map[string]interface{}) (*CtrlResponse, error) {
lock.Lock()
defer lock.Unlock()
@ -61,25 +67,33 @@ func CallCtrlAction(action string, params map[string]interface{}) (*CtrlResponse
return nil, fmt.Errorf("error marshaling ctrl action: %w", err)
}
logger.Infof("sending ctrl action: %s", string(jsonData))
scopedLogger := nativeLogger.With().
Str("action", ctrlAction.Action).
Interface("params", ctrlAction.Params).Logger()
scopedLogger.Debug().Msg("sending ctrl action")
err = WriteCtrlMessage(jsonData)
if err != nil {
delete(ongoingRequests, ctrlAction.Seq)
return nil, fmt.Errorf("error writing ctrl message: %w", err)
return nil, ErrorfL(&scopedLogger, "error writing ctrl message", err)
}
select {
case response := <-responseChan:
delete(ongoingRequests, seq)
if response.Error != "" {
return nil, fmt.Errorf("error native response: %s", response.Error)
return nil, ErrorfL(
&scopedLogger,
"error native response: %s",
errors.New(response.Error),
)
}
return response, nil
case <-time.After(5 * time.Second):
close(responseChan)
delete(ongoingRequests, seq)
return nil, fmt.Errorf("timeout waiting for response")
return nil, ErrorfL(&scopedLogger, "timeout waiting for response", nil)
}
}
@ -101,33 +115,47 @@ func waitCtrlClientConnected() {
}
func StartNativeSocketServer(socketPath string, handleClient func(net.Conn), isCtrl bool) net.Listener {
scopedLogger := nativeLogger.With().
Str("socket_path", socketPath).
Logger()
// Remove the socket file if it already exists
if _, err := os.Stat(socketPath); err == nil {
if err := os.Remove(socketPath); err != nil {
logger.Errorf("Failed to remove existing socket file %s: %v", socketPath, err)
scopedLogger.Warn().Err(err).Msg("failed to remove existing socket file")
os.Exit(1)
}
}
listener, err := net.Listen("unixpacket", socketPath)
if err != nil {
logger.Errorf("Failed to start server on %s: %v", socketPath, err)
scopedLogger.Warn().Err(err).Msg("failed to start server")
os.Exit(1)
}
logger.Infof("Server listening on %s", socketPath)
scopedLogger.Info().Msg("server listening")
go func() {
conn, err := listener.Accept()
listener.Close()
if err != nil {
logger.Errorf("failed to accept sock: %v", err)
for {
conn, err := listener.Accept()
if err != nil {
scopedLogger.Warn().Err(err).Msg("failed to accept socket")
continue
}
if isCtrl {
// check if the channel is closed
select {
case <-ctrlClientConnected:
scopedLogger.Debug().Msg("ctrl client reconnected")
default:
close(ctrlClientConnected)
scopedLogger.Debug().Msg("first native ctrl socket client connected")
}
}
go handleClient(conn)
}
if isCtrl {
close(ctrlClientConnected)
logger.Debug("first native ctrl socket client connected")
}
handleClient(conn)
}()
return listener
@ -135,20 +163,25 @@ func StartNativeSocketServer(socketPath string, handleClient func(net.Conn), isC
func StartNativeCtrlSocketServer() {
nativeCtrlSocketListener = StartNativeSocketServer("/var/run/jetkvm_ctrl.sock", handleCtrlClient, true)
logger.Debug("native app ctrl sock started")
nativeLogger.Debug().Msg("native app ctrl sock started")
}
func StartNativeVideoSocketServer() {
nativeVideoSocketListener = StartNativeSocketServer("/var/run/jetkvm_video.sock", handleVideoClient, false)
logger.Debug("native app video sock started")
nativeLogger.Debug().Msg("native app video sock started")
}
func handleCtrlClient(conn net.Conn) {
defer conn.Close()
logger.Debug("native socket client connected")
scopedLogger := nativeLogger.With().
Str("addr", conn.RemoteAddr().String()).
Str("type", "ctrl").
Logger()
scopedLogger.Info().Msg("native ctrl socket client connected")
if ctrlSocketConn != nil {
logger.Debugf("closing existing native socket connection")
scopedLogger.Debug().Msg("closing existing native socket connection")
ctrlSocketConn.Close()
}
@ -161,17 +194,19 @@ func handleCtrlClient(conn net.Conn) {
for {
n, err := conn.Read(readBuf)
if err != nil {
logger.Errorf("error reading from ctrl sock: %v", err)
scopedLogger.Warn().Err(err).Msg("error reading from ctrl sock")
break
}
readMsg := string(readBuf[:n])
logger.Tracef("ctrl sock msg: %v", readMsg)
ctrlResp := CtrlResponse{}
err = json.Unmarshal([]byte(readMsg), &ctrlResp)
if err != nil {
logger.Warnf("error parsing ctrl sock msg: %v", err)
scopedLogger.Warn().Err(err).Str("data", readMsg).Msg("error parsing ctrl sock msg")
continue
}
scopedLogger.Trace().Interface("data", ctrlResp).Msg("ctrl sock msg")
if ctrlResp.Seq != 0 {
responseChan, ok := ongoingRequests[ctrlResp.Seq]
if ok {
@ -184,20 +219,25 @@ func handleCtrlClient(conn net.Conn) {
}
}
logger.Debug("ctrl sock disconnected")
scopedLogger.Debug().Msg("ctrl sock disconnected")
}
func handleVideoClient(conn net.Conn) {
defer conn.Close()
logger.Infof("Native video socket client connected: %v", conn.RemoteAddr())
scopedLogger := nativeLogger.With().
Str("addr", conn.RemoteAddr().String()).
Str("type", "video").
Logger()
scopedLogger.Info().Msg("native video socket client connected")
inboundPacket := make([]byte, maxFrameSize)
lastFrame := time.Now()
for {
n, err := conn.Read(inboundPacket)
if err != nil {
logger.Warnf("error during read: %v", err)
scopedLogger.Warn().Err(err).Msg("error during read")
return
}
now := time.Now()
@ -206,12 +246,64 @@ func handleVideoClient(conn net.Conn) {
if currentSession != nil {
err := currentSession.VideoTrack.WriteSample(media.Sample{Data: inboundPacket[:n], Duration: sinceLastFrame})
if err != nil {
logger.Warnf("error writing sample: %v", err)
scopedLogger.Warn().Err(err).Msg("error writing sample")
}
}
}
}
func startNativeBinaryWithLock(binaryPath string) (*exec.Cmd, error) {
nativeCmdLock.Lock()
defer nativeCmdLock.Unlock()
cmd, err := startNativeBinary(binaryPath)
if err != nil {
return nil, err
}
nativeCmd = cmd
return cmd, nil
}
func restartNativeBinary(binaryPath string) error {
time.Sleep(10 * time.Second)
// restart the binary
nativeLogger.Info().Msg("restarting jetkvm_native binary")
cmd, err := startNativeBinary(binaryPath)
if err != nil {
nativeLogger.Warn().Err(err).Msg("failed to restart binary")
}
nativeCmd = cmd
// reset the display state
time.Sleep(1 * time.Second)
clearDisplayState()
updateStaticContents()
requestDisplayUpdate(true)
return err
}
func superviseNativeBinary(binaryPath string) error {
nativeCmdLock.Lock()
defer nativeCmdLock.Unlock()
if nativeCmd == nil || nativeCmd.Process == nil {
return restartNativeBinary(binaryPath)
}
err := nativeCmd.Wait()
if err == nil {
nativeLogger.Info().Err(err).Msg("jetkvm_native binary exited with no error")
} else if exiterr, ok := err.(*exec.ExitError); ok {
nativeLogger.Warn().Int("exit_code", exiterr.ExitCode()).Msg("jetkvm_native binary exited with error")
} else {
nativeLogger.Warn().Err(err).Msg("jetkvm_native binary exited with unknown error")
}
return restartNativeBinary(binaryPath)
}
func ExtractAndRunNativeBin() error {
binaryPath := "/userdata/jetkvm/bin/jetkvm_native"
if err := ensureBinaryUpdated(binaryPath); err != nil {
@ -223,54 +315,74 @@ func ExtractAndRunNativeBin() error {
return fmt.Errorf("failed to make binary executable: %w", err)
}
// Run the binary in the background
cmd := exec.Command(binaryPath)
// Redirect stdout and stderr to the current process
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
// Set the process group ID so we can kill the process and its children when this process exits
cmd.SysProcAttr = &syscall.SysProcAttr{
Setpgid: true,
Pdeathsig: syscall.SIGKILL,
}
// Start the command
if err := cmd.Start(); err != nil {
cmd, err := startNativeBinaryWithLock(binaryPath)
if err != nil {
return fmt.Errorf("failed to start binary: %w", err)
}
//TODO: add auto restart
// check if the binary is still running every 10 seconds
go func() {
for {
select {
case <-appCtx.Done():
nativeLogger.Info().Msg("stopping native binary supervisor")
return
default:
err := superviseNativeBinary(binaryPath)
if err != nil {
nativeLogger.Warn().Err(err).Msg("failed to supervise native binary")
time.Sleep(1 * time.Second) // Add a short delay to prevent rapid successive calls
}
}
}
}()
go func() {
<-appCtx.Done()
logger.Infof("killing process PID: %d", cmd.Process.Pid)
nativeLogger.Info().Int("pid", cmd.Process.Pid).Msg("killing process")
err := cmd.Process.Kill()
if err != nil {
logger.Errorf("failed to kill process: %v", err)
nativeLogger.Warn().Err(err).Msg("failed to kill process")
return
}
}()
logger.Infof("Binary started with PID: %d", cmd.Process.Pid)
nativeLogger.Info().Int("pid", cmd.Process.Pid).Msg("jetkvm_native binary started")
return nil
}
func shouldOverwrite(destPath string, srcHash []byte) bool {
if srcHash == nil {
logger.Debug("error reading embedded jetkvm_native.sha256, doing overwriting")
nativeLogger.Debug().Msg("error reading embedded jetkvm_native.sha256, doing overwriting")
return true
}
dstHash, err := os.ReadFile(destPath + ".sha256")
if err != nil {
logger.Debug("error reading existing jetkvm_native.sha256, doing overwriting")
nativeLogger.Debug().Msg("error reading existing jetkvm_native.sha256, doing overwriting")
return true
}
return !bytes.Equal(srcHash, dstHash)
}
func getNativeSha256() ([]byte, error) {
version, err := resource.ResourceFS.ReadFile("jetkvm_native.sha256")
if err != nil {
return nil, err
}
return version, nil
}
func GetNativeVersion() (string, error) {
version, err := getNativeSha256()
if err != nil {
return "", err
}
return strings.TrimSpace(string(version)), nil
}
func ensureBinaryUpdated(destPath string) error {
srcFile, err := resource.ResourceFS.Open("jetkvm_native")
if err != nil {
@ -278,15 +390,18 @@ func ensureBinaryUpdated(destPath string) error {
}
defer srcFile.Close()
srcHash, err := resource.ResourceFS.ReadFile("jetkvm_native.sha256")
srcHash, err := getNativeSha256()
if err != nil {
logger.Debug("error reading embedded jetkvm_native.sha256, proceeding with update")
nativeLogger.Debug().Msg("error reading embedded jetkvm_native.sha256, proceeding with update")
srcHash = nil
}
_, err = os.Stat(destPath)
if shouldOverwrite(destPath, srcHash) || err != nil {
logger.Info("writing jetkvm_native")
nativeLogger.Info().
Interface("hash", srcHash).
Msg("writing jetkvm_native")
_ = os.Remove(destPath)
destFile, err := os.OpenFile(destPath, os.O_CREATE|os.O_RDWR, 0755)
if err != nil {
@ -303,7 +418,7 @@ func ensureBinaryUpdated(destPath string) error {
return err
}
}
logger.Info("jetkvm_native updated")
nativeLogger.Info().Msg("jetkvm_native updated")
}
return nil
@ -313,10 +428,10 @@ func ensureBinaryUpdated(destPath string) error {
// Called after successful connection to jetkvm_native.
func restoreHdmiEdid() {
if config.EdidString != "" {
logger.Infof("Restoring HDMI EDID to %v", config.EdidString)
nativeLogger.Info().Str("edid", config.EdidString).Msg("Restoring HDMI EDID")
_, err := CallCtrlAction("set_edid", map[string]interface{}{"edid": config.EdidString})
if err != nil {
logger.Errorf("Failed to restore HDMI EDID: %v", err)
nativeLogger.Warn().Err(err).Msg("Failed to restore HDMI EDID")
}
}
}

57
native_linux.go Normal file
View File

@ -0,0 +1,57 @@
//go:build linux
package kvm
import (
"fmt"
"os/exec"
"sync"
"syscall"
"github.com/rs/zerolog"
)
type nativeOutput struct {
mu *sync.Mutex
logger *zerolog.Event
}
func (w *nativeOutput) Write(p []byte) (n int, err error) {
w.mu.Lock()
defer w.mu.Unlock()
w.logger.Msg(string(p))
return len(p), nil
}
func startNativeBinary(binaryPath string) (*exec.Cmd, error) {
// Run the binary in the background
cmd := exec.Command(binaryPath)
nativeOutputLock := sync.Mutex{}
nativeStdout := &nativeOutput{
mu: &nativeOutputLock,
logger: nativeLogger.Info().Str("pipe", "stdout"),
}
nativeStderr := &nativeOutput{
mu: &nativeOutputLock,
logger: nativeLogger.Info().Str("pipe", "stderr"),
}
// Redirect stdout and stderr to the current process
cmd.Stdout = nativeStdout
cmd.Stderr = nativeStderr
// Set the process group ID so we can kill the process and its children when this process exits
cmd.SysProcAttr = &syscall.SysProcAttr{
Setpgid: true,
Pdeathsig: syscall.SIGKILL,
}
// Start the command
if err := cmd.Start(); err != nil {
return nil, fmt.Errorf("failed to start binary: %w", err)
}
return cmd, nil
}

12
native_notlinux.go Normal file
View File

@ -0,0 +1,12 @@
//go:build !linux
package kvm
import (
"fmt"
"os/exec"
)
func startNativeBinary(binaryPath string) (*exec.Cmd, error) {
return nil, fmt.Errorf("not supported")
}

View File

@ -1,227 +1,110 @@
package kvm
import (
"bytes"
"fmt"
"net"
"os"
"strings"
"time"
"os/exec"
"github.com/hashicorp/go-envparse"
"github.com/pion/mdns/v2"
"golang.org/x/net/ipv4"
"golang.org/x/net/ipv6"
"github.com/vishvananda/netlink"
"github.com/vishvananda/netlink/nl"
"github.com/jetkvm/kvm/internal/network"
"github.com/jetkvm/kvm/internal/udhcpc"
)
var mDNSConn *mdns.Conn
var networkState NetworkState
type NetworkState struct {
Up bool
IPv4 string
IPv6 string
MAC string
checked bool
}
type LocalIpInfo struct {
IPv4 string
IPv6 string
MAC string
}
const (
NetIfName = "eth0"
DHCPLeaseFile = "/run/udhcpc.%s.info"
NetIfName = "eth0"
)
// setDhcpClientState sends signals to udhcpc to change it's current mode
// of operation. Setting active to true will force udhcpc to renew the DHCP lease.
// Setting active to false will put udhcpc into idle mode.
func setDhcpClientState(active bool) {
var signal string
if active {
signal = "-SIGUSR1"
} else {
signal = "-SIGUSR2"
var (
networkState *network.NetworkInterfaceState
)
func networkStateChanged() {
// do not block the main thread
go waitCtrlAndRequestDisplayUpdate(true)
if timeSync != nil {
if networkState != nil {
timeSync.SetDhcpNtpAddresses(networkState.NtpAddressesString())
}
if err := timeSync.Sync(); err != nil {
networkLogger.Error().Err(err).Msg("failed to sync time after network state change")
}
}
cmd := exec.Command("/usr/bin/killall", signal, "udhcpc")
if err := cmd.Run(); err != nil {
logger.Warnf("network: setDhcpClientState: failed to change udhcpc state: %s", err)
// always restart mDNS when the network state changes
if mDNS != nil {
_ = mDNS.SetListenOptions(config.NetworkConfig.GetMDNSMode())
_ = mDNS.SetLocalNames([]string{
networkState.GetHostname(),
networkState.GetFQDN(),
}, true)
}
}
func checkNetworkState() {
iface, err := netlink.LinkByName(NetIfName)
if err != nil {
logger.Warnf("failed to get [%s] interface: %v", NetIfName, err)
return
}
func initNetwork() error {
ensureConfigLoaded()
newState := NetworkState{
Up: iface.Attrs().OperState == netlink.OperUp,
MAC: iface.Attrs().HardwareAddr.String(),
state, err := network.NewNetworkInterfaceState(&network.NetworkInterfaceOptions{
DefaultHostname: GetDefaultHostname(),
InterfaceName: NetIfName,
NetworkConfig: config.NetworkConfig,
Logger: networkLogger,
OnStateChange: func(state *network.NetworkInterfaceState) {
networkStateChanged()
},
OnInitialCheck: func(state *network.NetworkInterfaceState) {
networkStateChanged()
},
OnDhcpLeaseChange: func(lease *udhcpc.Lease) {
networkStateChanged()
checked: true,
}
addrs, err := netlink.AddrList(iface, nl.FAMILY_ALL)
if err != nil {
logger.Warnf("failed to get addresses for [%s]: %v", NetIfName, err)
}
// If the link is going down, put udhcpc into idle mode.
// If the link is coming back up, activate udhcpc and force it to renew the lease.
if newState.Up != networkState.Up {
setDhcpClientState(newState.Up)
}
for _, addr := range addrs {
if addr.IP.To4() != nil {
if !newState.Up && networkState.Up {
// If the network is going down, remove all IPv4 addresses from the interface.
logger.Infof("network: state transitioned to down, removing IPv4 address %s", addr.IP.String())
err := netlink.AddrDel(iface, &addr)
if err != nil {
logger.Warnf("network: failed to delete %s", addr.IP.String())
}
newState.IPv4 = "..."
} else {
newState.IPv4 = addr.IP.String()
if currentSession == nil {
return
}
} else if addr.IP.To16() != nil && newState.IPv6 == "" {
newState.IPv6 = addr.IP.String()
}
}
if newState != networkState {
logger.Info("network state changed")
// restart MDNS
_ = startMDNS()
networkState = newState
requestDisplayUpdate()
}
}
func startMDNS() error {
// If server was previously running, stop it
if mDNSConn != nil {
logger.Info("Stopping mDNS server")
err := mDNSConn.Close()
if err != nil {
logger.Warnf("failed to stop mDNS server: %v", err)
}
}
// Start a new server
logger.Info("Starting mDNS server on jetkvm.local")
addr4, err := net.ResolveUDPAddr("udp4", mdns.DefaultAddressIPv4)
if err != nil {
return err
}
addr6, err := net.ResolveUDPAddr("udp6", mdns.DefaultAddressIPv6)
if err != nil {
return err
}
l4, err := net.ListenUDP("udp4", addr4)
if err != nil {
return err
}
l6, err := net.ListenUDP("udp6", addr6)
if err != nil {
return err
}
mDNSConn, err = mdns.Server(ipv4.NewPacketConn(l4), ipv6.NewPacketConn(l6), &mdns.Config{
LocalNames: []string{"jetkvm.local"}, //TODO: make it configurable
writeJSONRPCEvent("networkState", networkState.RpcGetNetworkState(), currentSession)
},
OnConfigChange: func(networkConfig *network.NetworkConfig) {
config.NetworkConfig = networkConfig
networkStateChanged()
},
})
if err != nil {
mDNSConn = nil
if state == nil {
if err == nil {
return fmt.Errorf("failed to create NetworkInterfaceState")
}
return err
}
//defer server.Close()
if err := state.Run(); err != nil {
return err
}
networkState = state
return nil
}
func getNTPServersFromDHCPInfo() ([]string, error) {
buf, err := os.ReadFile(fmt.Sprintf(DHCPLeaseFile, NetIfName))
if err != nil {
// do not return error if file does not exist
if os.IsNotExist(err) {
return nil, nil
}
return nil, fmt.Errorf("failed to load udhcpc info: %w", err)
}
// parse udhcpc info
env, err := envparse.Parse(bytes.NewReader(buf))
if err != nil {
return nil, fmt.Errorf("failed to parse udhcpc info: %w", err)
}
val, ok := env["ntpsrv"]
if !ok {
return nil, nil
}
var servers []string
for _, server := range strings.Fields(val) {
if net.ParseIP(server) == nil {
logger.Infof("invalid NTP server IP: %s, ignoring", server)
}
servers = append(servers, server)
}
return servers, nil
func rpcGetNetworkState() network.RpcNetworkState {
return networkState.RpcGetNetworkState()
}
func init() {
ensureConfigLoaded()
updates := make(chan netlink.LinkUpdate)
done := make(chan struct{})
if err := netlink.LinkSubscribe(updates, done); err != nil {
logger.Warnf("failed to subscribe to link updates: %v", err)
return
}
go func() {
waitCtrlClientConnected()
checkNetworkState()
ticker := time.NewTicker(1 * time.Second)
defer ticker.Stop()
for {
select {
case update := <-updates:
if update.Link.Attrs().Name == NetIfName {
logger.Infof("link update: %+v", update)
checkNetworkState()
}
case <-ticker.C:
checkNetworkState()
case <-done:
return
}
}
}()
err := startMDNS()
if err != nil {
logger.Warnf("failed to run mDNS: %v", err)
}
func rpcGetNetworkSettings() network.RpcNetworkSettings {
return networkState.RpcGetNetworkSettings()
}
func rpcSetNetworkSettings(settings network.RpcNetworkSettings) (*network.RpcNetworkSettings, error) {
s := networkState.RpcSetNetworkSettings(settings)
if s != nil {
return nil, s
}
if err := SaveConfig(); err != nil {
return nil, err
}
return &network.RpcNetworkSettings{NetworkConfig: *config.NetworkConfig}, nil
}
func rpcRenewDHCPLease() error {
return networkState.RpcRenewDHCPLease()
}

140
ntp.go
View File

@ -1,140 +0,0 @@
package kvm
import (
"errors"
"fmt"
"net/http"
"os/exec"
"time"
"github.com/beevik/ntp"
)
const (
timeSyncRetryStep = 5 * time.Second
timeSyncRetryMaxInt = 1 * time.Minute
timeSyncWaitNetChkInt = 100 * time.Millisecond
timeSyncWaitNetUpInt = 3 * time.Second
timeSyncInterval = 1 * time.Hour
timeSyncTimeout = 2 * time.Second
)
var (
timeSyncRetryInterval = 0 * time.Second
defaultNTPServers = []string{
"time.cloudflare.com",
"time.apple.com",
}
)
func TimeSyncLoop() {
for {
if !networkState.checked {
time.Sleep(timeSyncWaitNetChkInt)
continue
}
if !networkState.Up {
logger.Infof("Waiting for network to come up")
time.Sleep(timeSyncWaitNetUpInt)
continue
}
logger.Infof("Syncing system time")
start := time.Now()
err := SyncSystemTime()
if err != nil {
logger.Warnf("Failed to sync system time: %v", err)
// retry after a delay
timeSyncRetryInterval += timeSyncRetryStep
time.Sleep(timeSyncRetryInterval)
// reset the retry interval if it exceeds the max interval
if timeSyncRetryInterval > timeSyncRetryMaxInt {
timeSyncRetryInterval = 0
}
continue
}
logger.Infof("Time sync successful, now is: %v, time taken: %v", time.Now(), time.Since(start))
time.Sleep(timeSyncInterval) // after the first sync is done
}
}
func SyncSystemTime() (err error) {
now, err := queryNetworkTime()
if err != nil {
return fmt.Errorf("failed to query network time: %w", err)
}
err = setSystemTime(*now)
if err != nil {
return fmt.Errorf("failed to set system time: %w", err)
}
return nil
}
func queryNetworkTime() (*time.Time, error) {
ntpServers, err := getNTPServersFromDHCPInfo()
if err != nil {
logger.Warnf("failed to get NTP servers from DHCP info: %v\n", err)
}
if ntpServers == nil {
ntpServers = defaultNTPServers
logger.Infof("Using default NTP servers: %v\n", ntpServers)
} else {
logger.Infof("Using NTP servers from DHCP: %v\n", ntpServers)
}
for _, server := range ntpServers {
now, err := queryNtpServer(server, timeSyncTimeout)
if err == nil {
logger.Infof("NTP server [%s] returned time: %v\n", server, now)
return now, nil
}
}
httpUrls := []string{
"http://apple.com",
"http://cloudflare.com",
}
for _, url := range httpUrls {
now, err := queryHttpTime(url, timeSyncTimeout)
if err == nil {
return now, nil
}
}
return nil, errors.New("failed to query network time")
}
func queryNtpServer(server string, timeout time.Duration) (now *time.Time, err error) {
resp, err := ntp.QueryWithOptions(server, ntp.QueryOptions{Timeout: timeout})
if err != nil {
return nil, err
}
return &resp.Time, nil
}
func queryHttpTime(url string, timeout time.Duration) (*time.Time, error) {
client := http.Client{
Timeout: timeout,
}
resp, err := client.Head(url)
if err != nil {
return nil, err
}
dateStr := resp.Header.Get("Date")
now, err := time.Parse(time.RFC1123, dateStr)
if err != nil {
return nil, err
}
return &now, nil
}
func setSystemTime(now time.Time) error {
nowStr := now.Format("2006-01-02 15:04:05")
output, err := exec.Command("date", "-s", nowStr).CombinedOutput()
if err != nil {
return fmt.Errorf("failed to run date -s: %w, %s", err, string(output))
}
return nil
}

141
ota.go
View File

@ -4,6 +4,7 @@ import (
"bytes"
"context"
"crypto/sha256"
"crypto/tls"
"encoding/hex"
"encoding/json"
"fmt"
@ -16,6 +17,8 @@ import (
"time"
"github.com/Masterminds/semver/v3"
"github.com/gwatts/rootcerts"
"github.com/rs/zerolog"
)
type UpdateMetadata struct {
@ -38,12 +41,19 @@ type UpdateStatus struct {
Remote *UpdateMetadata `json:"remote"`
SystemUpdateAvailable bool `json:"systemUpdateAvailable"`
AppUpdateAvailable bool `json:"appUpdateAvailable"`
// for backwards compatibility
Error string `json:"error,omitempty"`
}
const UpdateMetadataUrl = "https://api.jetkvm.com/releases"
var builtAppVersion = "0.1.0+dev"
func GetBuiltAppVersion() string {
return builtAppVersion
}
func GetLocalVersion() (systemVersion *semver.Version, appVersion *semver.Version, err error) {
appVersion, err = semver.NewVersion(builtAppVersion)
if err != nil {
@ -76,14 +86,21 @@ func fetchUpdateMetadata(ctx context.Context, deviceId string, includePreRelease
query.Set("prerelease", fmt.Sprintf("%v", includePreRelease))
updateUrl.RawQuery = query.Encode()
logger.Infof("Checking for updates at: %s", updateUrl)
logger.Info().Str("url", updateUrl.String()).Msg("Checking for updates")
req, err := http.NewRequestWithContext(ctx, "GET", updateUrl.String(), nil)
if err != nil {
return nil, fmt.Errorf("error creating request: %w", err)
}
resp, err := http.DefaultClient.Do(req)
transport := http.DefaultTransport.(*http.Transport).Clone()
transport.Proxy = config.NetworkConfig.GetTransportProxyFunc()
client := &http.Client{
Transport: transport,
}
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("error sending request: %w", err)
}
@ -126,7 +143,18 @@ func downloadFile(ctx context.Context, path string, url string, downloadProgress
return fmt.Errorf("error creating request: %w", err)
}
resp, err := http.DefaultClient.Do(req)
client := http.Client{
Timeout: 10 * time.Minute,
Transport: &http.Transport{
Proxy: config.NetworkConfig.GetTransportProxyFunc(),
TLSHandshakeTimeout: 30 * time.Second,
TLSClientConfig: &tls.Config{
RootCAs: rootcerts.ServerCertPool(),
},
},
}
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("error downloading file: %w", err)
}
@ -185,7 +213,11 @@ func downloadFile(ctx context.Context, path string, url string, downloadProgress
return nil
}
func verifyFile(path string, expectedHash string, verifyProgress *float32) error {
func verifyFile(path string, expectedHash string, verifyProgress *float32, scopedLogger *zerolog.Logger) error {
if scopedLogger == nil {
scopedLogger = otaLogger
}
unverifiedPath := path + ".unverified"
fileToHash, err := os.Open(unverifiedPath)
if err != nil {
@ -229,7 +261,7 @@ func verifyFile(path string, expectedHash string, verifyProgress *float32) error
}
hashSum := hash.Sum(nil)
logger.Infof("SHA256 hash of %s: %x", path, hashSum)
scopedLogger.Info().Str("path", path).Str("hash", hex.EncodeToString(hashSum)).Msg("SHA256 hash of")
if hex.EncodeToString(hashSum) != expectedHash {
return fmt.Errorf("hash mismatch: %x != %s", hashSum, expectedHash)
@ -271,7 +303,7 @@ var otaState = OTAState{}
func triggerOTAStateUpdate() {
go func() {
if currentSession == nil {
logger.Info("No active RPC session, skipping update state update")
logger.Info().Msg("No active RPC session, skipping update state update")
return
}
writeJSONRPCEvent("otaState", otaState, currentSession)
@ -279,7 +311,12 @@ func triggerOTAStateUpdate() {
}
func TryUpdate(ctx context.Context, deviceId string, includePreRelease bool) error {
logger.Info("Trying to update...")
scopedLogger := otaLogger.With().
Str("deviceId", deviceId).
Str("includePreRelease", fmt.Sprintf("%v", includePreRelease)).
Logger()
scopedLogger.Info().Msg("Trying to update...")
if otaState.Updating {
return fmt.Errorf("update already in progress")
}
@ -297,6 +334,7 @@ func TryUpdate(ctx context.Context, deviceId string, includePreRelease bool) err
updateStatus, err := GetUpdateStatus(ctx, deviceId, includePreRelease)
if err != nil {
otaState.Error = fmt.Sprintf("Error checking for updates: %v", err)
scopedLogger.Error().Err(err).Msg("Error checking for updates")
return fmt.Errorf("error checking for updates: %w", err)
}
@ -314,11 +352,15 @@ func TryUpdate(ctx context.Context, deviceId string, includePreRelease bool) err
rebootNeeded := false
if appUpdateAvailable {
logger.Infof("App update available: %s -> %s", local.AppVersion, remote.AppVersion)
scopedLogger.Info().
Str("local", local.AppVersion).
Str("remote", remote.AppVersion).
Msg("App update available")
err := downloadFile(ctx, "/userdata/jetkvm/jetkvm_app.update", remote.AppUrl, &otaState.AppDownloadProgress)
if err != nil {
otaState.Error = fmt.Sprintf("Error downloading app update: %v", err)
scopedLogger.Error().Err(err).Msg("Error downloading app update")
triggerOTAStateUpdate()
return err
}
@ -327,9 +369,15 @@ func TryUpdate(ctx context.Context, deviceId string, includePreRelease bool) err
otaState.AppDownloadProgress = 1
triggerOTAStateUpdate()
err = verifyFile("/userdata/jetkvm/jetkvm_app.update", remote.AppHash, &otaState.AppVerificationProgress)
err = verifyFile(
"/userdata/jetkvm/jetkvm_app.update",
remote.AppHash,
&otaState.AppVerificationProgress,
&scopedLogger,
)
if err != nil {
otaState.Error = fmt.Sprintf("Error verifying app update hash: %v", err)
scopedLogger.Error().Err(err).Msg("Error verifying app update hash")
triggerOTAStateUpdate()
return err
}
@ -340,17 +388,22 @@ func TryUpdate(ctx context.Context, deviceId string, includePreRelease bool) err
otaState.AppUpdateProgress = 1
triggerOTAStateUpdate()
logger.Info("App update downloaded")
scopedLogger.Info().Msg("App update downloaded")
rebootNeeded = true
} else {
logger.Info("App is up to date")
scopedLogger.Info().Msg("App is up to date")
}
if systemUpdateAvailable {
logger.Infof("System update available: %s -> %s", local.SystemVersion, remote.SystemVersion)
scopedLogger.Info().
Str("local", local.SystemVersion).
Str("remote", remote.SystemVersion).
Msg("System update available")
err := downloadFile(ctx, "/userdata/jetkvm/update_system.tar", remote.SystemUrl, &otaState.SystemDownloadProgress)
if err != nil {
otaState.Error = fmt.Sprintf("Error downloading system update: %v", err)
scopedLogger.Error().Err(err).Msg("Error downloading system update")
triggerOTAStateUpdate()
return err
}
@ -359,18 +412,25 @@ func TryUpdate(ctx context.Context, deviceId string, includePreRelease bool) err
otaState.SystemDownloadProgress = 1
triggerOTAStateUpdate()
err = verifyFile("/userdata/jetkvm/update_system.tar", remote.SystemHash, &otaState.SystemVerificationProgress)
err = verifyFile(
"/userdata/jetkvm/update_system.tar",
remote.SystemHash,
&otaState.SystemVerificationProgress,
&scopedLogger,
)
if err != nil {
otaState.Error = fmt.Sprintf("Error verifying system update hash: %v", err)
scopedLogger.Error().Err(err).Msg("Error verifying system update hash")
triggerOTAStateUpdate()
return err
}
logger.Info("System update downloaded")
scopedLogger.Info().Msg("System update downloaded")
verifyFinished := time.Now()
otaState.SystemVerifiedAt = &verifyFinished
otaState.SystemVerificationProgress = 1
triggerOTAStateUpdate()
scopedLogger.Info().Msg("Starting rk_ota command")
cmd := exec.Command("rk_ota", "--misc=update", "--tar_path=/userdata/jetkvm/update_system.tar", "--save_dir=/userdata/jetkvm/ota_save", "--partition=all")
var b bytes.Buffer
cmd.Stdout = &b
@ -378,6 +438,7 @@ func TryUpdate(ctx context.Context, deviceId string, includePreRelease bool) err
err = cmd.Start()
if err != nil {
otaState.Error = fmt.Sprintf("Error starting rk_ota command: %v", err)
scopedLogger.Error().Err(err).Msg("Error starting rk_ota command")
return fmt.Errorf("error starting rk_ota command: %w", err)
}
ctx, cancel := context.WithCancel(context.Background())
@ -409,25 +470,30 @@ func TryUpdate(ctx context.Context, deviceId string, includePreRelease bool) err
output := b.String()
if err != nil {
otaState.Error = fmt.Sprintf("Error executing rk_ota command: %v\nOutput: %s", err, output)
scopedLogger.Error().
Err(err).
Str("output", output).
Int("exitCode", cmd.ProcessState.ExitCode()).
Msg("Error executing rk_ota command")
return fmt.Errorf("error executing rk_ota command: %w\nOutput: %s", err, output)
}
logger.Infof("rk_ota success, output: %s", output)
scopedLogger.Info().Str("output", output).Msg("rk_ota success")
otaState.SystemUpdateProgress = 1
otaState.SystemUpdatedAt = &verifyFinished
triggerOTAStateUpdate()
rebootNeeded = true
} else {
logger.Info("System is up to date")
scopedLogger.Info().Msg("System is up to date")
}
if rebootNeeded {
logger.Info("System Rebooting in 10s")
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)
@ -438,52 +504,47 @@ func TryUpdate(ctx context.Context, deviceId string, includePreRelease bool) err
}
func GetUpdateStatus(ctx context.Context, deviceId string, includePreRelease bool) (*UpdateStatus, error) {
updateStatus := &UpdateStatus{}
// Get local versions
systemVersionLocal, appVersionLocal, err := GetLocalVersion()
if err != nil {
return nil, fmt.Errorf("error getting local version: %w", err)
return updateStatus, fmt.Errorf("error getting local version: %w", err)
}
updateStatus.Local = &LocalMetadata{
AppVersion: appVersionLocal.String(),
SystemVersion: systemVersionLocal.String(),
}
// Get remote metadata
remoteMetadata, err := fetchUpdateMetadata(ctx, deviceId, includePreRelease)
if err != nil {
return nil, fmt.Errorf("error checking for updates: %w", err)
}
// Build local UpdateMetadata
localMetadata := &LocalMetadata{
AppVersion: appVersionLocal.String(),
SystemVersion: systemVersionLocal.String(),
return updateStatus, fmt.Errorf("error checking for updates: %w", err)
}
updateStatus.Remote = remoteMetadata
// Get remote versions
systemVersionRemote, err := semver.NewVersion(remoteMetadata.SystemVersion)
if err != nil {
return nil, fmt.Errorf("error parsing remote system version: %w", err)
return updateStatus, fmt.Errorf("error parsing remote system version: %w", err)
}
appVersionRemote, err := semver.NewVersion(remoteMetadata.AppVersion)
if err != nil {
return nil, fmt.Errorf("error parsing remote app version: %w, %s", err, remoteMetadata.AppVersion)
return updateStatus, fmt.Errorf("error parsing remote app version: %w, %s", err, remoteMetadata.AppVersion)
}
systemUpdateAvailable := systemVersionRemote.GreaterThan(systemVersionLocal)
appUpdateAvailable := appVersionRemote.GreaterThan(appVersionLocal)
updateStatus.SystemUpdateAvailable = systemVersionRemote.GreaterThan(systemVersionLocal)
updateStatus.AppUpdateAvailable = appVersionRemote.GreaterThan(appVersionLocal)
// Handle pre-release updates
isRemoteSystemPreRelease := systemVersionRemote.Prerelease() != ""
isRemoteAppPreRelease := appVersionRemote.Prerelease() != ""
if isRemoteSystemPreRelease && !includePreRelease {
systemUpdateAvailable = false
updateStatus.SystemUpdateAvailable = false
}
if isRemoteAppPreRelease && !includePreRelease {
appUpdateAvailable = false
}
updateStatus := &UpdateStatus{
Local: localMetadata,
Remote: remoteMetadata,
SystemUpdateAvailable: systemUpdateAvailable,
AppUpdateAvailable: appUpdateAvailable,
updateStatus.AppUpdateAvailable = false
}
return updateStatus, nil
@ -497,6 +558,6 @@ func IsUpdatePending() bool {
func confirmCurrentSystem() {
output, err := exec.Command("rk_ota", "--misc=now").CombinedOutput()
if err != nil {
logger.Warnf("failed to set current partition in A/B setup: %s", string(output))
logger.Warn().Str("output", string(output)).Msg("failed to set current partition in A/B setup")
}
}

View File

@ -1,15 +1,11 @@
package kvm
import (
"net/http"
"github.com/prometheus/client_golang/prometheus"
versioncollector "github.com/prometheus/client_golang/prometheus/collectors/version"
"github.com/prometheus/common/version"
)
var promHandler http.Handler
func initPrometheus() {
// A Prometheus metrics endpoint.
version.Version = builtAppVersion

View File

@ -1,4 +1,4 @@
#!/bin/bash
#!/usr/bin/env bash
# Check if a commit message was provided
if [ -z "$1" ]; then
@ -26,7 +26,7 @@ git checkout -b release-temp
if git ls-remote --heads public main | grep -q 'refs/heads/main'; then
git reset --soft public/main
else
git reset --soft $(git rev-list --max-parents=0 HEAD)
git reset --soft "$(git rev-list --max-parents=0 HEAD)"
fi
# Merge changes from main

View File

@ -44,7 +44,7 @@ func (w *WebRTCDiskReader) Read(ctx context.Context, offset int64, size int64) (
return nil, errors.New("not active session")
}
logger.Debugf("reading from webrtc %v", string(jsonBytes))
logger.Debug().Str("request", string(jsonBytes)).Msg("reading from webrtc")
err = currentSession.DiskChannel.SendText(string(jsonBytes))
if err != nil {
return nil, err

37
resource/dev_test.sh Normal file
View File

@ -0,0 +1,37 @@
#!/bin/sh
JSON_OUTPUT=false
GET_COMMANDS=false
if [ "$1" = "-json" ]; then
JSON_OUTPUT=true
shift
fi
ADDITIONAL_ARGS=$@
EXIT_CODE=0
runTest() {
PKG_ARGS=""
if [ "$2" != "" ]; then
PKG_ARGS="-p $2"
fi
if [ "$JSON_OUTPUT" = true ]; then
./test2json $PKG_ARGS -t $1 -test.v $ADDITIONAL_ARGS | tee $1.result.json
if [ $? -ne 0 ]; then
EXIT_CODE=1
fi
else
$@
if [ $? -ne 0 ]; then
EXIT_CODE=1
fi
fi
}
function exit_with_code() {
if [ $EXIT_CODE -ne 0 ]; then
printf "\e[0;31m❌ Test failed\e[0m\n"
fi
exit $EXIT_CODE
}
trap exit_with_code EXIT

Binary file not shown.

View File

@ -1 +1 @@
c0803a9185298398eff9a925de69bd0ca882cd5983b989a45b748648146475c6
6dabd0e657dd099280d9173069687786a4a8c9c25cf7f9e7ce2f940cab67c521

View File

@ -35,17 +35,19 @@ var (
)
func runATXControl() {
scopedLogger := serialLogger.With().Str("service", "atx_control").Logger()
reader := bufio.NewReader(port)
for {
line, err := reader.ReadString('\n')
if err != nil {
logger.Errorf("Error reading from serial port: %v", err)
scopedLogger.Warn().Err(err).Msg("Error reading from serial port")
return
}
// Each line should be 4 binary digits + newline
if len(line) != 5 {
logger.Warnf("Invalid line length: %d", len(line))
scopedLogger.Warn().Int("length", len(line)).Msg("Invalid line length")
continue
}
@ -66,9 +68,12 @@ func runATXControl() {
newLedPWRState != ledPWRState ||
newBtnRSTState != btnRSTState ||
newBtnPWRState != btnPWRState {
logger.Debugf("Status changed: HDD LED: %v, PWR LED: %v, RST BTN: %v, PWR BTN: %v",
newLedHDDState, newLedPWRState, newBtnRSTState, newBtnPWRState)
scopedLogger.Debug().
Bool("hdd", newLedHDDState).
Bool("pwr", newLedPWRState).
Bool("rst", newBtnRSTState).
Bool("pwr", newBtnPWRState).
Msg("Status changed")
// Update states
ledHDDState = newLedHDDState
@ -123,6 +128,7 @@ func pressATXResetButton(duration time.Duration) error {
func mountDCControl() error {
_ = port.SetMode(defaultMode)
registerDCMetrics()
go runDCControl()
return nil
}
@ -135,45 +141,64 @@ func unmountDCControl() error {
var dcState DCPowerState
func runDCControl() {
scopedLogger := serialLogger.With().Str("service", "dc_control").Logger()
reader := bufio.NewReader(port)
hasRestoreFeature := false
for {
line, err := reader.ReadString('\n')
if err != nil {
logger.Errorf("Error reading from serial port: %v", err)
scopedLogger.Warn().Err(err).Msg("Error reading from serial port")
return
}
// Split the line by semicolon
parts := strings.Split(strings.TrimSpace(line), ";")
if len(parts) != 4 {
logger.Warnf("Invalid line: %s", line)
if len(parts) == 5 {
scopedLogger.Debug().Str("line", line).Msg("Detected DC extension with restore feature")
hasRestoreFeature = true
} else if len(parts) == 4 {
scopedLogger.Debug().Str("line", line).Msg("Detected DC extension without restore feature")
hasRestoreFeature = false
} else {
scopedLogger.Warn().Str("line", line).Msg("Invalid line")
continue
}
// Parse new states
powerState, err := strconv.Atoi(parts[0])
if err != nil {
logger.Warnf("Invalid power state: %v", err)
scopedLogger.Warn().Err(err).Msg("Invalid power state")
continue
}
dcState.IsOn = powerState == 1
if hasRestoreFeature {
restoreState, err := strconv.Atoi(parts[4])
if err != nil {
scopedLogger.Warn().Err(err).Msg("Invalid restore state")
continue
}
dcState.RestoreState = restoreState
} else {
// -1 means not supported
dcState.RestoreState = -1
}
milliVolts, err := strconv.ParseFloat(parts[1], 64)
if err != nil {
logger.Warnf("Invalid voltage: %v", err)
scopedLogger.Warn().Err(err).Msg("Invalid voltage")
continue
}
volts := milliVolts / 1000 // Convert mV to V
milliAmps, err := strconv.ParseFloat(parts[2], 64)
if err != nil {
logger.Warnf("Invalid current: %v", err)
scopedLogger.Warn().Err(err).Msg("Invalid current")
continue
}
amps := milliAmps / 1000 // Convert mA to A
milliWatts, err := strconv.ParseFloat(parts[3], 64)
if err != nil {
logger.Warnf("Invalid power: %v", err)
scopedLogger.Warn().Err(err).Msg("Invalid power")
continue
}
watts := milliWatts / 1000 // Convert mW to W
@ -182,6 +207,9 @@ func runDCControl() {
dcState.Current = amps
dcState.Power = watts
// Update Prometheus metrics
updateDCMetrics(dcState)
if currentSession != nil {
writeJSONRPCEvent("dcState", dcState, currentSession)
}
@ -204,6 +232,25 @@ func setDCPowerState(on bool) error {
return nil
}
func setDCRestoreState(state int) error {
_, err := port.Write([]byte("\n"))
if err != nil {
return err
}
command := "RESTORE_MODE_OFF\n"
switch state {
case 1:
command = "RESTORE_MODE_ON\n"
case 2:
command = "RESTORE_MODE_LAST_STATE\n"
}
_, err = port.Write([]byte(command))
if err != nil {
return err
}
return nil
}
var defaultMode = &serial.Mode{
BaudRate: 115200,
DataBits: 8,
@ -213,9 +260,10 @@ var defaultMode = &serial.Mode{
func initSerialPort() {
_ = reopenSerialPort()
if config.ActiveExtension == "atx-power" {
switch config.ActiveExtension {
case "atx-power":
_ = mountATXControl()
} else if config.ActiveExtension == "dc-power" {
case "dc-power":
_ = mountDCControl()
}
}
@ -227,12 +275,19 @@ func reopenSerialPort() error {
var err error
port, err = serial.Open(serialPortPath, defaultMode)
if err != nil {
logger.Errorf("Error opening serial port: %v", err)
serialLogger.Error().
Err(err).
Str("path", serialPortPath).
Interface("mode", defaultMode).
Msg("Error opening serial port")
}
return nil
}
func handleSerialChannel(d *webrtc.DataChannel) {
scopedLogger := serialLogger.With().
Uint16("data_channel_id", *d.ID()).Logger()
d.OnOpen(func() {
go func() {
buf := make([]byte, 1024)
@ -240,13 +295,13 @@ func handleSerialChannel(d *webrtc.DataChannel) {
n, err := port.Read(buf)
if err != nil {
if err != io.EOF {
logger.Errorf("Failed to read from serial port: %v", err)
scopedLogger.Warn().Err(err).Msg("Failed to read from serial port")
}
break
}
err = d.Send(buf[:n])
if err != nil {
logger.Errorf("Failed to send serial output: %v", err)
scopedLogger.Warn().Err(err).Msg("Failed to send serial output")
break
}
}
@ -259,11 +314,15 @@ func handleSerialChannel(d *webrtc.DataChannel) {
}
_, err := port.Write(msg.Data)
if err != nil {
logger.Errorf("Failed to write to serial: %v", err)
scopedLogger.Warn().Err(err).Msg("Failed to write to serial")
}
})
d.OnClose(func() {
d.OnError(func(err error) {
scopedLogger.Warn().Err(err).Msg("Serial channel error")
})
d.OnClose(func() {
scopedLogger.Info().Msg("Serial channel closed")
})
}

View File

@ -1,6 +1,7 @@
package kvm
import (
"bytes"
"encoding/json"
"io"
"os"
@ -16,6 +17,9 @@ type TerminalSize struct {
}
func handleTerminalChannel(d *webrtc.DataChannel) {
scopedLogger := terminalLogger.With().
Uint16("data_channel_id", *d.ID()).Logger()
var ptmx *os.File
var cmd *exec.Cmd
d.OnOpen(func() {
@ -23,7 +27,7 @@ func handleTerminalChannel(d *webrtc.DataChannel) {
var err error
ptmx, err = pty.Start(cmd)
if err != nil {
logger.Errorf("Failed to start pty: %v", err)
scopedLogger.Warn().Err(err).Msg("Failed to start pty")
d.Close()
return
}
@ -34,13 +38,13 @@ func handleTerminalChannel(d *webrtc.DataChannel) {
n, err := ptmx.Read(buf)
if err != nil {
if err != io.EOF {
logger.Errorf("Failed to read from pty: %v", err)
scopedLogger.Warn().Err(err).Msg("Failed to read from pty")
}
break
}
err = d.Send(buf[:n])
if err != nil {
logger.Errorf("Failed to send pty output: %v", err)
scopedLogger.Warn().Err(err).Msg("Failed to send pty output")
break
}
}
@ -52,22 +56,27 @@ func handleTerminalChannel(d *webrtc.DataChannel) {
return
}
if msg.IsString {
var size TerminalSize
err := json.Unmarshal([]byte(msg.Data), &size)
if err == nil {
err = pty.Setsize(ptmx, &pty.Winsize{
Rows: uint16(size.Rows),
Cols: uint16(size.Cols),
})
maybeJson := bytes.TrimSpace(msg.Data)
// Cheap check to see if this resembles JSON
if len(maybeJson) > 1 && maybeJson[0] == '{' && maybeJson[len(maybeJson)-1] == '}' {
var size TerminalSize
err := json.Unmarshal(maybeJson, &size)
if err == nil {
return
err = pty.Setsize(ptmx, &pty.Winsize{
Rows: uint16(size.Rows),
Cols: uint16(size.Cols),
})
if err == nil {
scopedLogger.Info().Int("rows", size.Rows).Int("cols", size.Cols).Msg("Set terminal size")
return
}
}
scopedLogger.Warn().Err(err).Msg("Failed to parse terminal size")
}
logger.Errorf("Failed to parse terminal size: %v", err)
}
_, err := ptmx.Write(msg.Data)
if err != nil {
logger.Errorf("Failed to write to pty: %v", err)
scopedLogger.Warn().Err(err).Msg("Failed to write to pty")
}
})
@ -78,5 +87,10 @@ func handleTerminalChannel(d *webrtc.DataChannel) {
if cmd != nil && cmd.Process != nil {
_ = cmd.Process.Kill()
}
scopedLogger.Info().Msg("Terminal channel closed")
})
d.OnError(func(err error) {
scopedLogger.Warn().Err(err).Msg("Terminal channel error")
})
}

53
timesync.go Normal file
View File

@ -0,0 +1,53 @@
package kvm
import (
"strconv"
"time"
"github.com/jetkvm/kvm/internal/timesync"
)
var (
timeSync *timesync.TimeSync
builtTimestamp string
)
func isTimeSyncNeeded() bool {
if builtTimestamp == "" {
timesyncLogger.Warn().Msg("built timestamp is not set, time sync is needed")
return true
}
ts, err := strconv.Atoi(builtTimestamp)
if err != nil {
timesyncLogger.Warn().Str("error", err.Error()).Msg("failed to parse built timestamp")
return true
}
// builtTimestamp is UNIX timestamp in seconds
builtTime := time.Unix(int64(ts), 0)
now := time.Now()
if now.Sub(builtTime) < 0 {
timesyncLogger.Warn().
Str("built_time", builtTime.Format(time.RFC3339)).
Str("now", now.Format(time.RFC3339)).
Msg("system time is behind the built time, time sync is needed")
return true
}
return false
}
func initTimeSync() {
timeSync = timesync.NewTimeSync(&timesync.TimeSyncOptions{
Logger: timesyncLogger,
NetworkConfig: config.NetworkConfig,
PreCheckFunc: func() (bool, error) {
if !networkState.IsOnline() {
return false, nil
}
return true, nil
},
})
}

View File

@ -1,66 +0,0 @@
module.exports = {
root: true,
env: { browser: true, es2020: true },
extends: [
"eslint:recommended",
"plugin:@typescript-eslint/recommended",
"plugin:@typescript-eslint/stylistic",
"plugin:react-hooks/recommended",
"plugin:react/recommended",
"plugin:react/jsx-runtime",
"plugin:import/recommended",
"prettier",
],
ignorePatterns: ["dist", ".eslintrc.cjs", "tailwind.config.js", "postcss.config.js"],
parser: "@typescript-eslint/parser",
plugins: ["react-refresh"],
parserOptions: {
ecmaVersion: "latest",
sourceType: "module",
project: ["./tsconfig.json", "./tsconfig.node.json"],
tsconfigRootDir: __dirname,
},
rules: {
"react-refresh/only-export-components": ["warn", { allowConstantExport: true }],
"import/order": [
"error",
{
/**
* @description
*
* This keeps imports separate from one another, ensuring that imports are separated
* by their relative groups. As you move through the groups, imports become closer
* to the current file.
*
* @example
* ```
* import fs from 'fs';
*
* import package from 'npm-package';
*
* import xyz from '~/project-file';
*
* import index from '../';
*
* import sibling from './foo';
* ```
*/
groups: ["builtin", "external", "internal", "parent", "sibling"],
"newlines-between": "always",
},
],
},
settings: {
"import/resolver": {
alias: {
map: [
["@components", "./src/components"],
["@routes", "./src/routes"],
["@assets", "./src/assets"],
["@", "./src"],
],
extensions: [".ts", ".tsx", ".js", ".jsx", ".json"],
},
},
},
};

View File

@ -6,6 +6,7 @@
"arrowParens": "avoid",
"singleQuote": false,
"plugins": ["prettier-plugin-tailwindcss"],
"tailwindFunctions": ["clsx"],
"printWidth": 90
"tailwindFunctions": ["clsx", "cx"],
"printWidth": 90,
"tailwindStylesheet": "./src/index.css"
}

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