mirror of https://github.com/jetkvm/kvm.git
Compare commits
119 Commits
ff3727b1fe
...
d79f359c43
Author | SHA1 | Date |
---|---|---|
|
d79f359c43 | |
|
189b84380b | |
|
2b2a14204d | |
|
440f85f091 | |
|
009b0abbe9 | |
|
951e673e0c | |
|
edca8a4cb5 | |
|
87ee954e70 | |
|
94e83249ef | |
|
f98eaddf15 | |
|
8888d13824 | |
|
334b3bee60 | |
|
0ba7902f82 | |
|
924b55059f | |
|
6489421605 | |
|
e08ff425c3 | |
|
d5f8e51a14 | |
|
612c50bfe2 | |
|
48a917fd76 | |
|
5f7dded973 | |
|
04aa35249a | |
|
82c018a2f6 | |
|
4c37f7e079 | |
|
8f6e64fd9c | |
|
76efa56083 | |
|
dc1ce03697 | |
|
66a3352e5d | |
|
9c758b6d57 | |
|
647250c32b | |
|
3f20c23ea1 | |
|
b94de38510 | |
|
1505ca1bc1 | |
|
960ef230ba | |
|
98af805089 | |
|
84b35d5deb | |
|
652e845d83 | |
|
1a30977085 | |
|
fa1b11b228 | |
|
abc6d92331 | |
|
73e715117e | |
|
8268b20f32 | |
|
1a26431147 | |
|
f3b5011d65 | |
|
1e9adf81d4 | |
|
65e4a58ad9 | |
|
df0d083a28 | |
|
1f8f885a1d | |
|
aed453cc8c | |
|
edafe996a9 | |
|
a9180c972c | |
|
b5e0f894bc | |
|
a3580b5465 | |
|
3b711db781 | |
|
9d511d7f58 | |
|
5d7d4db4aa | |
|
0a7847c5ab | |
|
1b8954e9f3 | |
|
ab03aded74 | |
|
204e6c7faf | |
|
caf3922ecd | |
|
ec5226ebdb | |
|
f198df816c | |
|
f30eb0355e | |
|
439ef01687 | |
|
f3c49b853d | |
|
8e2ed6059d | |
|
d52e7d04d1 | |
|
e426515ce9 | |
|
d291053e06 | |
|
c4348c7eb4 | |
|
369bd3fb18 | |
|
38d6f57786 | |
|
e66190df0b | |
|
a55774b0de | |
|
f72cf0cbff | |
|
c818d498a9 | |
|
97ce785056 | |
|
75296b4b7e | |
|
d3641bb4b9 | |
|
4884240f5f | |
|
34e33e45bf | |
|
c5cec99797 | |
|
d1948adca8 | |
|
c088534d34 | |
|
285de31ade | |
|
8b59a3e387 | |
|
5c7accae0d | |
|
536e823243 | |
|
3b83f4c7a1 | |
|
1ec87f043f | |
|
554121a20b | |
|
d4efd72731 | |
|
08a315d908 | |
|
16f83e6136 | |
|
da97a17977 | |
|
a60d373849 | |
|
7e6a24800e | |
|
7f43ba869f | |
|
b499482c5d | |
|
e4bb4f288c | |
|
482c64ad02 | |
|
543ef2114e | |
|
a4863f6999 | |
|
d49a567a38 | |
|
a3355bb81c | |
|
4052b3d225 | |
|
77263e73f7 | |
|
92aec30c8f | |
|
ba0c937e2a | |
|
f4a86a2d11 | |
|
7304e6b672 | |
|
e1ea783fc7 | |
|
de5403eada | |
|
4351cc8dd7 | |
|
16efeee31d | |
|
99b3017344 | |
|
2ce327ed15 | |
|
7bca9cb827 | |
|
1d6b7ad83a |
|
@ -6,5 +6,9 @@
|
|||
// Should match what is defined in ui/package.json
|
||||
"version": "21.1.0"
|
||||
}
|
||||
}
|
||||
},
|
||||
"mounts": [
|
||||
"source=${localEnv:HOME}/.ssh,target=/home/vscode/.ssh,type=bind,consistency=cached"
|
||||
]
|
||||
}
|
||||
|
||||
|
|
|
@ -0,0 +1,39 @@
|
|||
name: build image
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- dev
|
||||
- main
|
||||
workflow_dispatch:
|
||||
pull_request_review:
|
||||
types: [submitted]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: buildjet-4vcpu-ubuntu-2204
|
||||
name: Build
|
||||
if: "github.event.review.state == 'approved' || github.event.event_type != 'pull_request_review'"
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: v21.1.0
|
||||
cache: "npm"
|
||||
cache-dependency-path: "**/package-lock.json"
|
||||
- name: Set up Golang
|
||||
uses: actions/setup-go@v4
|
||||
with:
|
||||
go-version: "1.24.0"
|
||||
- name: Build frontend
|
||||
run: |
|
||||
make frontend
|
||||
- name: Build application
|
||||
run: |
|
||||
make build_dev
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: jetkvm-app
|
||||
path: bin/jetkvm_app
|
|
@ -0,0 +1,37 @@
|
|||
---
|
||||
name: golangci-lint
|
||||
on:
|
||||
push:
|
||||
paths:
|
||||
- "go.sum"
|
||||
- "go.mod"
|
||||
- "**.go"
|
||||
- ".github/workflows/golangci-lint.yml"
|
||||
- ".golangci.yml"
|
||||
pull_request:
|
||||
|
||||
permissions: # added using https://github.com/step-security/secure-repo
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
golangci:
|
||||
permissions:
|
||||
contents: read # for actions/checkout to fetch code
|
||||
pull-requests: read # for golangci/golangci-lint-action to fetch pull requests
|
||||
name: lint
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
- name: Install Go
|
||||
uses: actions/setup-go@41dfa10bad2bb2ae585af6ee5bb4d7d973ad74ed # v5.1.0
|
||||
with:
|
||||
go-version: 1.24.x
|
||||
- name: Create empty resource directory
|
||||
run: |
|
||||
mkdir -p static && touch static/.gitkeep
|
||||
- name: Lint
|
||||
uses: golangci/golangci-lint-action@1481404843c368bc19ca9406f87d6e0fc97bdcfd # v7.0.0
|
||||
with:
|
||||
args: --verbose
|
||||
version: v2.0.2
|
|
@ -0,0 +1,122 @@
|
|||
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: 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 "+ Waiting for 10 seconds to allow all services to start"
|
||||
sleep 10
|
||||
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
|
|
@ -0,0 +1,34 @@
|
|||
---
|
||||
name: ui-lint
|
||||
on:
|
||||
push:
|
||||
paths:
|
||||
- "ui/**"
|
||||
- "package.json"
|
||||
- "package-lock.json"
|
||||
- ".github/workflows/ui-lint.yml"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
ui-lint:
|
||||
name: UI Lint
|
||||
runs-on: buildjet-4vcpu-ubuntu-2204
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: v21.1.0
|
||||
cache: "npm"
|
||||
cache-dependency-path: "ui/package-lock.json"
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
cd ui
|
||||
npm ci
|
||||
- name: Lint UI
|
||||
run: |
|
||||
cd ui
|
||||
npm run lint
|
|
@ -0,0 +1,37 @@
|
|||
version: "2"
|
||||
linters:
|
||||
enable:
|
||||
- forbidigo
|
||||
- misspell
|
||||
- whitespace
|
||||
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
|
||||
paths:
|
||||
- third_party$
|
||||
- builtin$
|
||||
- examples$
|
||||
formatters:
|
||||
enable:
|
||||
- goimports
|
||||
exclusions:
|
||||
generated: lax
|
||||
paths:
|
||||
- third_party$
|
||||
- builtin$
|
||||
- examples$
|
24
Makefile
24
Makefile
|
@ -1,17 +1,31 @@
|
|||
VERSION_DEV := 0.3.8-dev$(shell date +%Y%m%d%H%M)
|
||||
VERSION := 0.3.7
|
||||
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.4.0-dev$(shell date +%Y%m%d%H%M)
|
||||
VERSION := 0.3.9
|
||||
|
||||
PROMETHEUS_TAG := github.com/prometheus/common/version
|
||||
KVM_PKG_NAME := github.com/jetkvm/kvm
|
||||
|
||||
GO_LDFLAGS := \
|
||||
-s -w \
|
||||
-X $(PROMETHEUS_TAG).Branch=$(BRANCH) \
|
||||
-X $(PROMETHEUS_TAG).BuildDate=$(BUILDDATE) \
|
||||
-X $(PROMETHEUS_TAG).Revision=$(REVISION) \
|
||||
-X $(KVM_PKG_NAME).builtTimestamp=$(BUILDTS)
|
||||
|
||||
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="-s -w -X kvm.builtAppVersion=$(VERSION_DEV)" -o bin/jetkvm_app cmd/main.go
|
||||
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
|
||||
|
||||
frontend:
|
||||
cd ui && npm ci && npm run build:device
|
||||
|
||||
dev_release: build_dev
|
||||
dev_release: frontend build_dev
|
||||
@echo "Uploading release..."
|
||||
@shasum -a 256 bin/jetkvm_app | cut -d ' ' -f 1 > bin/jetkvm_app.sha256
|
||||
rclone copyto bin/jetkvm_app r2://jetkvm-update/app/$(VERSION_DEV)/jetkvm_app
|
||||
|
@ -19,7 +33,7 @@ dev_release: build_dev
|
|||
|
||||
build_release: frontend hash_resource
|
||||
@echo "Building release..."
|
||||
GOOS=linux GOARCH=arm GOARM=7 go build -ldflags="-s -w -X kvm.builtAppVersion=$(VERSION)" -o bin/jetkvm_app cmd/main.go
|
||||
GOOS=linux GOARCH=arm GOARM=7 go build -ldflags="$(GO_LDFLAGS) -X $(KVM_PKG_NAME).builtAppVersion=$(VERSION)" -o bin/jetkvm_app cmd/main.go
|
||||
|
||||
release:
|
||||
@if rclone lsf r2://jetkvm-update/app/$(VERSION)/ | grep -q "jetkvm_app"; then \
|
||||
|
|
|
@ -23,7 +23,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
|
||||
|
||||
|
|
|
@ -3,13 +3,12 @@ package kvm
|
|||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log"
|
||||
"net"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/pojntfx/go-nbd/pkg/client"
|
||||
"github.com/pojntfx/go-nbd/pkg/server"
|
||||
"github.com/rs/zerolog"
|
||||
)
|
||||
|
||||
type remoteImageBackend struct {
|
||||
|
@ -17,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")
|
||||
}
|
||||
|
@ -34,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")
|
||||
}
|
||||
}
|
||||
|
@ -73,6 +73,8 @@ type NBDDevice struct {
|
|||
serverConn net.Conn
|
||||
clientConn net.Conn
|
||||
dev *os.File
|
||||
|
||||
l *zerolog.Logger
|
||||
}
|
||||
|
||||
func NewNBDDevice() *NBDDevice {
|
||||
|
@ -91,10 +93,19 @@ 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 {
|
||||
log.Fatalf("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,
|
||||
})
|
||||
log.Println("nbd server exited:", err)
|
||||
}
|
||||
|
||||
func (d *NBDDevice) runClientConn() {
|
||||
err := client.Connect(d.clientConn, d.dev, &client.Options{
|
||||
ExportName: "jetkvm",
|
||||
BlockSize: uint32(4 * 1024),
|
||||
})
|
||||
log.Println("nbd client exited:", err)
|
||||
}
|
||||
|
||||
func (d *NBDDevice) Close() {
|
||||
if d.dev != nil {
|
||||
err := client.Disconnect(d.dev)
|
||||
if err != nil {
|
||||
log.Println("error disconnecting nbd client:", 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")
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
369
cloud.go
369
cloud.go
|
@ -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",
|
||||
Help: "The timestamp when the cloud connection was established",
|
||||
},
|
||||
)
|
||||
metricConnectionLastPingTimestamp = promauto.NewGaugeVec(
|
||||
prometheus.GaugeOpts{
|
||||
Name: "jetkvm_connection_last_ping_timestamp",
|
||||
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",
|
||||
Help: "The timestamp when the last ping request was received",
|
||||
},
|
||||
[]string{"type", "source"},
|
||||
)
|
||||
metricConnectionLastPingDuration = promauto.NewGaugeVec(
|
||||
prometheus.GaugeOpts{
|
||||
Name: "jetkvm_connection_last_ping_duration",
|
||||
Help: "The duration of the last ping response",
|
||||
},
|
||||
[]string{"type", "source"},
|
||||
)
|
||||
metricConnectionPingDuration = promauto.NewHistogramVec(
|
||||
prometheus.HistogramOpts{
|
||||
Name: "jetkvm_connection_ping_duration",
|
||||
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_total_ping_sent",
|
||||
Help: "The total number of pings sent to the connection",
|
||||
},
|
||||
[]string{"type", "source"},
|
||||
)
|
||||
metricConnectionTotalPingReceivedCount = promauto.NewCounterVec(
|
||||
prometheus.CounterOpts{
|
||||
Name: "jetkvm_connection_total_ping_received",
|
||||
Help: "The total number of pings received from the connection",
|
||||
},
|
||||
[]string{"type", "source"},
|
||||
)
|
||||
metricConnectionSessionRequestCount = promauto.NewCounterVec(
|
||||
prometheus.CounterOpts{
|
||||
Name: "jetkvm_connection_session_total_requests",
|
||||
Help: "The total number of session requests received",
|
||||
},
|
||||
[]string{"type", "source"},
|
||||
)
|
||||
metricConnectionSessionRequestDuration = promauto.NewHistogramVec(
|
||||
prometheus.HistogramOpts{
|
||||
Name: "jetkvm_connection_session_request_duration",
|
||||
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",
|
||||
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_count",
|
||||
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,13 +249,7 @@ func handleCloudRegister(c *gin.Context) {
|
|||
return
|
||||
}
|
||||
|
||||
if config.CloudToken == "" {
|
||||
cloudLogger.Info("Starting websocket client due to adoption")
|
||||
go RunWebsocketClient()
|
||||
}
|
||||
|
||||
config.CloudToken = tokenResp.SecretToken
|
||||
config.CloudURL = req.CloudAPI
|
||||
|
||||
provider, err := oidc.NewProvider(c, "https://accounts.google.com")
|
||||
if err != nil {
|
||||
|
@ -126,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()
|
||||
cloudLogger.Infof("websocket connected to %s", wsURL.String())
|
||||
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
|
||||
}
|
||||
defer c.CloseNow() //nolint:errcheck
|
||||
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")
|
||||
|
@ -201,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
|
||||
}
|
||||
|
||||
|
@ -221,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})
|
||||
|
@ -245,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)
|
||||
}
|
||||
}
|
||||
|
@ -265,12 +515,14 @@ func RunWebsocketClient() {
|
|||
type CloudState struct {
|
||||
Connected bool `json:"connected"`
|
||||
URL string `json:"url,omitempty"`
|
||||
AppURL string `json:"appUrl,omitempty"`
|
||||
}
|
||||
|
||||
func rpcGetCloudState() CloudState {
|
||||
return CloudState{
|
||||
Connected: config.CloudToken != "" && config.CloudURL != "",
|
||||
URL: config.CloudURL,
|
||||
AppURL: config.CloudAppURL,
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -298,12 +550,17 @@ func rpcDeregisterDevice() error {
|
|||
// (e.g., wrong cloud token, already deregistered). Regardless of the reason, we can safely remove it.
|
||||
if resp.StatusCode == http.StatusNotFound || (resp.StatusCode >= 200 && resp.StatusCode < 300) {
|
||||
config.CloudToken = ""
|
||||
config.CloudURL = ""
|
||||
config.GoogleIdentity = ""
|
||||
|
||||
if err := SaveConfig(); err != nil {
|
||||
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
|
||||
}
|
||||
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"kvm"
|
||||
"github.com/jetkvm/kvm"
|
||||
)
|
||||
|
||||
func main() {
|
||||
|
|
148
config.go
148
config.go
|
@ -5,6 +5,10 @@ import (
|
|||
"fmt"
|
||||
"os"
|
||||
"sync"
|
||||
|
||||
"github.com/jetkvm/kvm/internal/logging"
|
||||
"github.com/jetkvm/kvm/internal/network"
|
||||
"github.com/jetkvm/kvm/internal/usbgadget"
|
||||
)
|
||||
|
||||
type WakeOnLanDevice struct {
|
||||
|
@ -12,33 +16,116 @@ 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"`
|
||||
CloudToken string `json:"cloud_token"`
|
||||
GoogleIdentity string `json:"google_identity"`
|
||||
JigglerEnabled bool `json:"jiggler_enabled"`
|
||||
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"`
|
||||
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"`
|
||||
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"`
|
||||
KeyboardMacros []KeyboardMacro `json:"keyboard_macros"`
|
||||
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"` // 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"
|
||||
|
||||
var defaultConfig = &Config{
|
||||
CloudURL: "https://api.jetkvm.com",
|
||||
CloudAppURL: "https://app.jetkvm.com",
|
||||
AutoUpdateEnabled: true, // Set a default value
|
||||
ActiveExtension: "",
|
||||
KeyboardMacros: []KeyboardMacro{},
|
||||
DisplayMaxBrightness: 64,
|
||||
DisplayDimAfterSec: 120, // 2 minutes
|
||||
DisplayOffAfterSec: 1800, // 30 minutes
|
||||
TLSMode: "",
|
||||
UsbConfig: &usbgadget.Config{
|
||||
VendorId: "0x1d6b", //The Linux Foundation
|
||||
ProductId: "0x0104", //Multifunction Composite Gadget
|
||||
SerialNumber: "",
|
||||
Manufacturer: "JetKVM",
|
||||
Product: "USB Emulation Device",
|
||||
},
|
||||
UsbDevices: &usbgadget.Devices{
|
||||
AbsoluteMouse: true,
|
||||
RelativeMouse: true,
|
||||
Keyboard: true,
|
||||
MassStorage: true,
|
||||
},
|
||||
NetworkConfig: &network.NetworkConfig{},
|
||||
DefaultLogLevel: "INFO",
|
||||
}
|
||||
|
||||
var (
|
||||
|
@ -51,7 +138,7 @@ func LoadConfig() {
|
|||
defer configLock.Unlock()
|
||||
|
||||
if config != nil {
|
||||
logger.Info("config already loaded, skipping")
|
||||
logger.Debug().Msg("config already loaded, skipping")
|
||||
return
|
||||
}
|
||||
|
||||
|
@ -60,7 +147,7 @@ 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")
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
@ -68,17 +155,36 @@ 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")
|
||||
return
|
||||
}
|
||||
|
||||
// merge the user config with the default config
|
||||
if loadedConfig.UsbConfig == nil {
|
||||
loadedConfig.UsbConfig = defaultConfig.UsbConfig
|
||||
}
|
||||
|
||||
if loadedConfig.UsbDevices == nil {
|
||||
loadedConfig.UsbDevices = defaultConfig.UsbDevices
|
||||
}
|
||||
|
||||
if loadedConfig.NetworkConfig == nil {
|
||||
loadedConfig.NetworkConfig = defaultConfig.NetworkConfig
|
||||
}
|
||||
|
||||
config = &loadedConfig
|
||||
|
||||
logging.GetRootLogger().UpdateLogLevel(config.DefaultLogLevel)
|
||||
|
||||
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)
|
||||
|
@ -93,3 +199,9 @@ func SaveConfig() error {
|
|||
|
||||
return nil
|
||||
}
|
||||
|
||||
func ensureConfigLoaded() {
|
||||
if config == nil {
|
||||
LoadConfig()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
#!/usr/bin/env bash
|
||||
#
|
||||
# Exit immediately if a command exits with a non-zero status
|
||||
set -e
|
||||
|
||||
|
@ -10,17 +12,19 @@ show_help() {
|
|||
echo
|
||||
echo "Optional:"
|
||||
echo " -u, --user <remote_user> Remote username (default: root)"
|
||||
echo " --skip-ui-build Skip frontend/UI build"
|
||||
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
|
||||
LOG_TRACE_SCOPES="${LOG_TRACE_SCOPES:-jetkvm,cloud,websocket,native,jsonrpc}"
|
||||
|
||||
# Parse command line arguments
|
||||
while [[ $# -gt 0 ]]; do
|
||||
|
@ -33,6 +37,10 @@ while [[ $# -gt 0 ]]; do
|
|||
REMOTE_USER="$2"
|
||||
shift 2
|
||||
;;
|
||||
--skip-ui-build)
|
||||
SKIP_UI_BUILD=true
|
||||
shift
|
||||
;;
|
||||
--help)
|
||||
show_help
|
||||
exit 0
|
||||
|
@ -52,7 +60,9 @@ if [ -z "$REMOTE_HOST" ]; then
|
|||
fi
|
||||
|
||||
# Build the development version on the host
|
||||
make frontend
|
||||
if [ "$SKIP_UI_BUILD" = false ]; then
|
||||
make frontend
|
||||
fi
|
||||
make build_dev
|
||||
|
||||
# Change directory to the binary output directory
|
||||
|
@ -62,10 +72,10 @@ cd bin
|
|||
ssh "${REMOTE_USER}@${REMOTE_HOST}" "killall jetkvm_app_debug || true"
|
||||
|
||||
# Copy the binary to the remote host
|
||||
cat jetkvm_app | ssh "${REMOTE_USER}@${REMOTE_HOST}" "cat > $REMOTE_PATH/jetkvm_app_debug"
|
||||
ssh "${REMOTE_USER}@${REMOTE_HOST}" "cat > ${REMOTE_PATH}/jetkvm_app_debug" < jetkvm_app
|
||||
|
||||
# Deploy and run the application on the remote host
|
||||
ssh "${REMOTE_USER}@${REMOTE_HOST}" ash <<EOF
|
||||
ssh "${REMOTE_USER}@${REMOTE_HOST}" ash << EOF
|
||||
set -e
|
||||
|
||||
# Set the library path to include the directory where librockit.so is located
|
||||
|
@ -76,13 +86,13 @@ 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
|
||||
EOF
|
||||
|
||||
echo "Deployment complete."
|
||||
|
|
181
display.go
181
display.go
|
@ -3,9 +3,9 @@ package kvm
|
|||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
|
@ -25,7 +25,7 @@ const (
|
|||
func switchToScreen(screen string) {
|
||||
_, err := CallCtrlAction("lv_scr_load", map[string]interface{}{"obj": screen})
|
||||
if err != nil {
|
||||
log.Printf("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
|
||||
|
@ -33,62 +33,181 @@ func switchToScreen(screen string) {
|
|||
|
||||
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 updateLabelIfChanged(objName string, newText string) {
|
||||
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) {
|
||||
fmt.Println("switching screen from", currentScreen, screenName)
|
||||
if currentScreen != screenName {
|
||||
displayLogger.Info().Str("from", currentScreen).Str("to", screenName).Msg("switching screen")
|
||||
switchToScreen(screenName)
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
fmt.Println("display not inited, skipping updates")
|
||||
displayLogger.Info().Msg("display not inited, skipping updates")
|
||||
return
|
||||
}
|
||||
go func() {
|
||||
wakeDisplay(false)
|
||||
fmt.Println("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())
|
||||
|
@ -119,7 +238,7 @@ func setDisplayBrightness(brightness int) error {
|
|||
return err
|
||||
}
|
||||
|
||||
fmt.Printf("display: set brightness to %v\n", brightness)
|
||||
displayLogger.Info().Int("brightness", brightness).Msg("set brightness")
|
||||
return nil
|
||||
}
|
||||
|
||||
|
@ -128,7 +247,7 @@ func setDisplayBrightness(brightness int) error {
|
|||
func tick_displayDim() {
|
||||
err := setDisplayBrightness(config.DisplayMaxBrightness / 2)
|
||||
if err != nil {
|
||||
fmt.Printf("display: failed to dim display: %s\n", err)
|
||||
displayLogger.Warn().Err(err).Msg("failed to dim display")
|
||||
}
|
||||
|
||||
dimTicker.Stop()
|
||||
|
@ -141,7 +260,7 @@ func tick_displayDim() {
|
|||
func tick_displayOff() {
|
||||
err := setDisplayBrightness(0)
|
||||
if err != nil {
|
||||
fmt.Printf("display: failed to turn off display: %s\n", err)
|
||||
displayLogger.Warn().Err(err).Msg("failed to turn off display")
|
||||
}
|
||||
|
||||
offTicker.Stop()
|
||||
|
@ -164,7 +283,7 @@ func wakeDisplay(force bool) {
|
|||
|
||||
err := setDisplayBrightness(config.DisplayMaxBrightness)
|
||||
if err != nil {
|
||||
fmt.Printf("display wake failed, %s\n", err)
|
||||
displayLogger.Warn().Err(err).Msg("failed to wake display")
|
||||
}
|
||||
|
||||
if config.DisplayDimAfterSec != 0 {
|
||||
|
@ -184,7 +303,7 @@ func wakeDisplay(force bool) {
|
|||
func watchTsEvents() {
|
||||
ts, err := os.OpenFile(touchscreenDevice, os.O_RDONLY, 0666)
|
||||
if err != nil {
|
||||
fmt.Printf("display: failed to open touchscreen device: %s\n", err)
|
||||
displayLogger.Warn().Err(err).Msg("failed to open touchscreen device")
|
||||
return
|
||||
}
|
||||
|
||||
|
@ -197,7 +316,7 @@ func watchTsEvents() {
|
|||
for {
|
||||
_, err := ts.Read(buf)
|
||||
if err != nil {
|
||||
fmt.Printf("display: failed to read from touchscreen device: %s\n", err)
|
||||
displayLogger.Warn().Err(err).Msg("failed to read from touchscreen device")
|
||||
return
|
||||
}
|
||||
|
||||
|
@ -212,17 +331,17 @@ func startBacklightTickers() {
|
|||
// Don't start the tickers if the display is switched off.
|
||||
// Set the display to off if that's the case.
|
||||
if config.DisplayMaxBrightness == 0 {
|
||||
setDisplayBrightness(0)
|
||||
_ = setDisplayBrightness(0)
|
||||
return
|
||||
}
|
||||
|
||||
if dimTicker == nil && config.DisplayDimAfterSec != 0 {
|
||||
fmt.Printf("display: dim_ticker has started\n")
|
||||
displayLogger.Info().Msg("dim_ticker has started")
|
||||
dimTicker = time.NewTicker(time.Duration(config.DisplayDimAfterSec) * time.Second)
|
||||
defer dimTicker.Stop()
|
||||
|
||||
go func() {
|
||||
for {
|
||||
for { //nolint:staticcheck
|
||||
select {
|
||||
case <-dimTicker.C:
|
||||
tick_displayDim()
|
||||
|
@ -232,12 +351,12 @@ func startBacklightTickers() {
|
|||
}
|
||||
|
||||
if offTicker == nil && config.DisplayOffAfterSec != 0 {
|
||||
fmt.Printf("display: off_ticker has started\n")
|
||||
displayLogger.Info().Msg("off_ticker has started")
|
||||
offTicker = time.NewTicker(time.Duration(config.DisplayOffAfterSec) * time.Second)
|
||||
defer offTicker.Stop()
|
||||
|
||||
go func() {
|
||||
for {
|
||||
for { //nolint:staticcheck
|
||||
select {
|
||||
case <-offTicker.C:
|
||||
tick_displayOff()
|
||||
|
@ -248,16 +367,18 @@ func startBacklightTickers() {
|
|||
}
|
||||
|
||||
func init() {
|
||||
ensureConfigLoaded()
|
||||
|
||||
go func() {
|
||||
waitCtrlClientConnected()
|
||||
fmt.Println("setting initial display contents")
|
||||
displayLogger.Info().Msg("setting initial display contents")
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
updateStaticContents()
|
||||
displayInited = true
|
||||
fmt.Println("display inited")
|
||||
displayLogger.Info().Msg("display inited")
|
||||
startBacklightTickers()
|
||||
wakeDisplay(true)
|
||||
requestDisplayUpdate()
|
||||
requestDisplayUpdate(true)
|
||||
}()
|
||||
|
||||
go watchTsEvents()
|
||||
|
|
5
fuse.go
5
fuse.go
|
@ -2,7 +2,6 @@ package kvm
|
|||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"sync"
|
||||
"syscall"
|
||||
|
@ -38,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
|
||||
}
|
||||
|
||||
|
@ -104,7 +103,7 @@ func RunFuseServer() {
|
|||
var err error
|
||||
fuseServer, err = fs.Mount(fuseMountPoint, &FuseRoot{}, opts)
|
||||
if err != nil {
|
||||
fmt.Println("failed to mount fuse: %w", err)
|
||||
logger.Warn().Err(err).Msg("failed to mount fuse")
|
||||
}
|
||||
fuseServer.Wait()
|
||||
}
|
||||
|
|
60
go.mod
60
go.mod
|
@ -1,55 +1,61 @@
|
|||
module kvm
|
||||
module github.com/jetkvm/kvm
|
||||
|
||||
go 1.21.0
|
||||
|
||||
toolchain go1.21.1
|
||||
go 1.23.0
|
||||
|
||||
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/coder/websocket v1.8.13
|
||||
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/fsnotify/fsnotify v1.9.0
|
||||
github.com/gin-contrib/logger v1.2.5
|
||||
github.com/gin-gonic/gin v1.10.0
|
||||
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/openstadia/go-usb-gadget v0.0.0-20231115171102-aebd56bbb965
|
||||
github.com/pion/logging v0.2.2
|
||||
github.com/pion/mdns/v2 v2.0.7
|
||||
github.com/pion/webrtc/v4 v4.0.0
|
||||
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/psanford/httpreadat v0.1.0
|
||||
github.com/rs/zerolog v1.34.0
|
||||
github.com/vishvananda/netlink v1.3.0
|
||||
go.bug.st/serial v1.6.2
|
||||
golang.org/x/crypto v0.28.0
|
||||
golang.org/x/net v0.30.0
|
||||
golang.org/x/crypto v0.36.0
|
||||
golang.org/x/net v0.38.0
|
||||
)
|
||||
|
||||
replace github.com/pojntfx/go-nbd v0.3.2 => github.com/chemhack/go-nbd v0.0.0-20241006125820-59e45f5b1e7b
|
||||
|
||||
require (
|
||||
github.com/bytedance/sonic v1.11.6 // indirect
|
||||
github.com/bytedance/sonic/loader v0.1.1 // indirect
|
||||
github.com/cloudwego/base64x v0.1.4 // indirect
|
||||
github.com/cloudwego/iasm v0.2.0 // indirect
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/bytedance/sonic v1.13.2 // 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.5 // 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/gabriel-vasile/mimetype v1.4.8 // indirect
|
||||
github.com/gin-contrib/sse v1.0.0 // indirect
|
||||
github.com/go-jose/go-jose/v4 v4.0.2 // 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/guregu/null/v6 v6.0.0 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.7 // indirect
|
||||
github.com/kr/pretty v0.3.0 // indirect
|
||||
github.com/klauspost/compress v1.17.11 // 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/pelletier/go-toml/v2 v2.2.2 // indirect
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.3 // 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
|
||||
|
@ -64,16 +70,16 @@ require (
|
|||
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/rogpeppe/go-internal v1.8.0 // indirect
|
||||
github.com/prometheus/client_model v0.6.1 // indirect
|
||||
github.com/prometheus/procfs v0.15.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/wlynxg/anet v0.0.5 // indirect
|
||||
golang.org/x/arch v0.8.0 // indirect
|
||||
golang.org/x/oauth2 v0.21.0 // indirect
|
||||
golang.org/x/sys v0.29.0 // indirect
|
||||
golang.org/x/text v0.19.0 // indirect
|
||||
google.golang.org/protobuf v1.34.0 // indirect
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
|
||||
golang.org/x/arch v0.15.0 // indirect
|
||||
golang.org/x/oauth2 v0.24.0 // indirect
|
||||
golang.org/x/sys v0.32.0 // indirect
|
||||
golang.org/x/text v0.23.0 // indirect
|
||||
google.golang.org/protobuf v1.36.6 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
|
136
go.sum
136
go.sum
|
@ -2,34 +2,42 @@ github.com/Masterminds/semver/v3 v3.3.0 h1:B8LGeaivUe71a5qox1ICM/JLl0NqZSW5CHyL+
|
|||
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/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/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.13.2 h1:8/H1FempDZqC4VqjptGo14QQlJx8VdZJegxs6wwfqpQ=
|
||||
github.com/bytedance/sonic v1.13.2/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/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.11.0 h1:Ia3MxdwpSw702YW0xgfmP1GVCMA9aEFWu12XUZ3/OtI=
|
||||
github.com/coreos/go-oidc/v3 v3.11.0/go.mod h1:gE3LgjOgFoHi9a4ce4/tJczr0Ai2/BoDhf0r5lltWI0=
|
||||
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
|
||||
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.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
github.com/creack/pty v1.1.23 h1:4M6+isWdcStXEf15G/RbrMPOQj1dZ7HPZCGwE4kOeP0=
|
||||
github.com/creack/pty v1.1.23/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/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.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
|
||||
github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
|
||||
github.com/gin-contrib/logger v1.2.5 h1:qVQI4omayQecuN4zX9ZZnsOq7w9J/ZLds3J/FMn8ypM=
|
||||
github.com/gin-contrib/logger v1.2.5/go.mod h1:/bj+vNMuA2xOEQ1aRHoJ1m9+uyaaXIAxQTvM2llsc6I=
|
||||
github.com/gin-contrib/sse v1.0.0 h1:y3bT1mUWUxDpW4JLQg/HnTqV4rozuW4tC9eFKTxYI9E=
|
||||
github.com/gin-contrib/sse v1.0.0/go.mod h1:zNuFdwarAygJBht0NTKiSi3jRf6RbqeILZ9Sp6Slhe0=
|
||||
github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
|
||||
github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
|
||||
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/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||
|
@ -38,15 +46,18 @@ github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/o
|
|||
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.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
|
||||
github.com/google/go-cmp v0.5.9/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.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
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/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-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=
|
||||
|
@ -55,22 +66,29 @@ github.com/hashicorp/go-envparse v0.1.0 h1:bE++6bhIsNCPLvgDZkYqo3nA+/PFI51pkrHdm
|
|||
github.com/hashicorp/go-envparse v0.1.0/go.mod h1:OHheN1GoygLlAkTlXLXvAdnXdZxy8JUweQ1rAXx1xnc=
|
||||
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/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.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
|
||||
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
|
||||
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
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 h1:MtvEpTB6LX3vkb4ax0b5D2DHbNAUsen0Gx5wZoq3lV4=
|
||||
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=
|
||||
|
@ -80,10 +98,10 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w
|
|||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/openstadia/go-usb-gadget v0.0.0-20231115171102-aebd56bbb965 h1:bZGtUfkOl0dqvem8ltx9KCYied0gSlRuDhaZDxgppN4=
|
||||
github.com/openstadia/go-usb-gadget v0.0.0-20231115171102-aebd56bbb965/go.mod h1:6cAIK2c4O3/yETSrRjmNwsBL3yE4Vcu9M9p/Qwx5+gM=
|
||||
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/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.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M=
|
||||
github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc=
|
||||
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=
|
||||
|
@ -118,14 +136,24 @@ 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/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
|
||||
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/psanford/httpreadat v0.1.0 h1:VleW1HS2zO7/4c7c7zNl33fO6oYACSagjJIyMIwZLUE=
|
||||
github.com/psanford/httpreadat v0.1.0/go.mod h1:Zg7P+TlBm3bYbyHTKv/EdtSJZn3qwbPwpfZ/I9GKCRE=
|
||||
github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
|
||||
github.com/rogpeppe/go-internal v1.8.0 h1:FCbCCtXNOY3UtUuHUYaghJg4y7Fd14rXifAYUAtL9R8=
|
||||
github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE=
|
||||
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/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/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=
|
||||
|
@ -136,8 +164,9 @@ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
|
|||
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 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||
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=
|
||||
|
@ -150,34 +179,31 @@ 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.28.0 h1:GBDwsMXVQi34v5CCYUm2jkJvu4cbtru2U4TN2PSyQnw=
|
||||
golang.org/x/crypto v0.28.0/go.mod h1:rmgy+3RHxRZMyY0jjAJShp2zgEdOqj2AO7U0pYmeQ7U=
|
||||
golang.org/x/net v0.30.0 h1:AcW1SDZMkb8IpzCdQUaIq2sP4sZ4zw+55h6ynffypl4=
|
||||
golang.org/x/net v0.30.0/go.mod h1:2wGyMJ5iFasEhkwi13ChkO/t1ECNC4X4eBKkVFyYFlU=
|
||||
golang.org/x/oauth2 v0.21.0 h1:tsimM75w1tF/uws5rbeHzIWxEqElMehnc+iW793zsZs=
|
||||
golang.org/x/oauth2 v0.21.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
|
||||
golang.org/x/arch v0.15.0 h1:QtOrQd0bTUnhNVNndMpLHNWrDmYzZ2KDqSrEymqInZw=
|
||||
golang.org/x/arch v0.15.0/go.mod h1:JmwW7aLIoRUKgaTzhkiEFxvcEiQGyOg9BMonBJUS7EE=
|
||||
golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34=
|
||||
golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc=
|
||||
golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8=
|
||||
golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
|
||||
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=
|
||||
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.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
|
||||
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/text v0.19.0 h1:kTxAhCbGbxhK0IwgSKiMO5awPoDQ0RpfiVYBfK860YM=
|
||||
golang.org/x/text v0.19.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
|
||||
google.golang.org/protobuf v1.34.0 h1:Qo/qEd2RZPCf2nKuorzksSknv0d3ERwp1vFG38gSmH4=
|
||||
google.golang.org/protobuf v1.34.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
|
||||
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
|
||||
golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
|
||||
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
|
||||
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-20180628173108-788fd7840127/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=
|
||||
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
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=
|
||||
|
|
22
hw.go
22
hw.go
|
@ -4,6 +4,7 @@ import (
|
|||
"fmt"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
@ -14,7 +15,7 @@ func extractSerialNumber() (string, error) {
|
|||
return "", err
|
||||
}
|
||||
|
||||
r, err := regexp.Compile("Serial\\s*:\\s*(\\S+)")
|
||||
r, err := regexp.Compile(`Serial\s*:\s*(\S+)`)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to compile regex: %w", err)
|
||||
}
|
||||
|
@ -27,7 +28,7 @@ func extractSerialNumber() (string, error) {
|
|||
return matches[1], nil
|
||||
}
|
||||
|
||||
func readOtpEntropy() ([]byte, error) {
|
||||
func readOtpEntropy() ([]byte, error) { //nolint:unused
|
||||
content, err := os.ReadFile("/sys/bus/nvmem/devices/rockchip-otp0/nvmem")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
@ -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
|
||||
}
|
||||
|
|
|
@ -0,0 +1,381 @@
|
|||
package confparser
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"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)
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("field `%s` cannot use validate_type: unsupported validator: %s", f.Name, validateType)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
|
@ -0,0 +1,100 @@
|
|||
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,ntp_fallback" default:"ntp,http"`
|
||||
TimeSyncDisableFallback null.Bool `json:"time_sync_disable_fallback,omitempty" default:"false"`
|
||||
TimeSyncParallel null.Int `json:"time_sync_parallel,omitempty" default:"4"`
|
||||
}
|
||||
|
||||
func TestValidateConfig(t *testing.T) {
|
||||
config := &testNetworkConfig{}
|
||||
|
||||
err := SetDefaultsAndValidate(config)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateIPv4StaticConfigRequired(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 error, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
}
|
|
@ -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))
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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)
|
||||
}
|
|
@ -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
|
||||
})
|
||||
})
|
||||
}
|
|
@ -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>
|
|
@ -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...)
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
package mdns
|
|
@ -0,0 +1,110 @@
|
|||
package network
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"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"`
|
||||
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,ntp_fallback" default:"ntp,http"`
|
||||
TimeSyncDisableFallback null.Bool `json:"time_sync_disable_fallback,omitempty" default:"false"`
|
||||
TimeSyncParallel null.Int `json:"time_sync_parallel,omitempty" default:"4"`
|
||||
}
|
||||
|
||||
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 *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())
|
||||
}
|
|
@ -0,0 +1,11 @@
|
|||
package network
|
||||
|
||||
type DhcpTargetState int
|
||||
|
||||
const (
|
||||
DhcpTargetStateDoNothing DhcpTargetState = iota
|
||||
DhcpTargetStateStart
|
||||
DhcpTargetStateStop
|
||||
DhcpTargetStateRenew
|
||||
DhcpTargetStateRelease
|
||||
)
|
|
@ -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
|
||||
}
|
|
@ -0,0 +1,346 @@
|
|||
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
|
||||
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,
|
||||
}
|
||||
|
||||
// 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.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) 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) 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)
|
||||
}
|
|
@ -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)
|
||||
}
|
|
@ -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")
|
||||
}
|
|
@ -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()
|
||||
}
|
|
@ -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)
|
||||
}
|
|
@ -0,0 +1,132 @@
|
|||
package timesync
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"math/rand"
|
||||
"net/http"
|
||||
"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() (now *time.Time) {
|
||||
chunkSize := 4
|
||||
httpUrls := t.httpUrls
|
||||
|
||||
// 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,
|
||||
)
|
||||
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()
|
||||
} else {
|
||||
scopedLogger.Warn().
|
||||
Str("error", err.Error()).
|
||||
Int("status", status).
|
||||
Msg("failed to query HTTP server")
|
||||
}
|
||||
}(url)
|
||||
}
|
||||
|
||||
return <-results
|
||||
}
|
||||
|
||||
func queryHttpTime(
|
||||
ctx context.Context,
|
||||
url string,
|
||||
timeout time.Duration,
|
||||
) (now *time.Time, response *http.Response, err error) {
|
||||
client := http.Client{
|
||||
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
|
||||
}
|
|
@ -0,0 +1,147 @@
|
|||
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_count",
|
||||
Help: "The number of times the timesync has been run",
|
||||
},
|
||||
)
|
||||
metricTimeSyncSuccessCount = promauto.NewCounter(
|
||||
prometheus.CounterOpts{
|
||||
Name: "jetkvm_timesync_success_count",
|
||||
Help: "The number of times the timesync has been successful",
|
||||
},
|
||||
)
|
||||
metricRTCUpdateCount = promauto.NewCounter( //nolint:unused
|
||||
prometheus.CounterOpts{
|
||||
Name: "jetkvm_timesync_rtc_update_count",
|
||||
Help: "The number of times the RTC has been updated",
|
||||
},
|
||||
)
|
||||
metricNtpTotalSuccessCount = promauto.NewCounter(
|
||||
prometheus.CounterOpts{
|
||||
Name: "jetkvm_timesync_ntp_total_success_count",
|
||||
Help: "The total number of successful NTP requests",
|
||||
},
|
||||
)
|
||||
metricNtpTotalRequestCount = promauto.NewCounter(
|
||||
prometheus.CounterOpts{
|
||||
Name: "jetkvm_timesync_ntp_total_request_count",
|
||||
Help: "The total number of NTP requests sent",
|
||||
},
|
||||
)
|
||||
metricNtpSuccessCount = promauto.NewCounterVec(
|
||||
prometheus.CounterOpts{
|
||||
Name: "jetkvm_timesync_ntp_success_count",
|
||||
Help: "The number of successful NTP requests",
|
||||
},
|
||||
[]string{"url"},
|
||||
)
|
||||
metricNtpRequestCount = promauto.NewCounterVec(
|
||||
prometheus.CounterOpts{
|
||||
Name: "jetkvm_timesync_ntp_request_count",
|
||||
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_count",
|
||||
Help: "The total number of successful HTTP requests",
|
||||
},
|
||||
)
|
||||
metricHttpTotalRequestCount = promauto.NewCounter(
|
||||
prometheus.CounterOpts{
|
||||
Name: "jetkvm_timesync_http_total_request_count",
|
||||
Help: "The total number of HTTP requests sent",
|
||||
},
|
||||
)
|
||||
metricHttpTotalCancelCount = promauto.NewCounter(
|
||||
prometheus.CounterOpts{
|
||||
Name: "jetkvm_timesync_http_total_cancel_count",
|
||||
Help: "The total number of HTTP requests cancelled",
|
||||
},
|
||||
)
|
||||
metricHttpSuccessCount = promauto.NewCounterVec(
|
||||
prometheus.CounterOpts{
|
||||
Name: "jetkvm_timesync_http_success_count",
|
||||
Help: "The number of successful HTTP requests",
|
||||
},
|
||||
[]string{"url"},
|
||||
)
|
||||
metricHttpRequestCount = promauto.NewCounterVec(
|
||||
prometheus.CounterOpts{
|
||||
Name: "jetkvm_timesync_http_request_count",
|
||||
Help: "The number of HTTP requests sent to the server",
|
||||
},
|
||||
[]string{"url"},
|
||||
)
|
||||
metricHttpCancelCount = promauto.NewCounterVec(
|
||||
prometheus.CounterOpts{
|
||||
Name: "jetkvm_timesync_http_cancel_count",
|
||||
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"},
|
||||
)
|
||||
)
|
|
@ -0,0 +1,113 @@
|
|||
package timesync
|
||||
|
||||
import (
|
||||
"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
|
||||
"0.pool.ntp.org",
|
||||
"1.pool.ntp.org",
|
||||
"2.pool.ntp.org",
|
||||
"3.pool.ntp.org",
|
||||
}
|
||||
|
||||
func (t *TimeSync) queryNetworkTime() (now *time.Time, offset *time.Duration) {
|
||||
chunkSize := 4
|
||||
ntpServers := t.ntpServers
|
||||
|
||||
// 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))
|
||||
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)
|
||||
|
||||
// set the last RTT
|
||||
metricNtpServerLastRTT.WithLabelValues(
|
||||
server,
|
||||
).Set(float64(response.RTT.Milliseconds()))
|
||||
|
||||
// set the RTT histogram
|
||||
metricNtpServerRttHistogram.WithLabelValues(
|
||||
server,
|
||||
).Observe(float64(response.RTT.Milliseconds()))
|
||||
|
||||
// set the server info
|
||||
metricNtpServerInfo.WithLabelValues(
|
||||
server,
|
||||
response.ReferenceString(),
|
||||
strconv.Itoa(int(response.Stratum)),
|
||||
strconv.Itoa(int(response.Precision)),
|
||||
).Set(1)
|
||||
|
||||
if err == nil {
|
||||
// increase success count
|
||||
metricNtpTotalSuccessCount.Inc()
|
||||
metricNtpSuccessCount.WithLabelValues(server).Inc()
|
||||
|
||||
scopedLogger.Info().
|
||||
Str("time", now.Format(time.RFC3339)).
|
||||
Str("reference", response.ReferenceString()).
|
||||
Str("rtt", response.RTT.String()).
|
||||
Str("clockOffset", response.ClockOffset.String()).
|
||||
Uint8("stratum", response.Stratum).
|
||||
Msg("NTP server returned time")
|
||||
results <- &ntpResult{
|
||||
now: now,
|
||||
offset: &response.ClockOffset,
|
||||
}
|
||||
} else {
|
||||
scopedLogger.Warn().
|
||||
Str("error", err.Error()).
|
||||
Msg("failed to query NTP server")
|
||||
}
|
||||
}(server)
|
||||
}
|
||||
|
||||
result := <-results
|
||||
return result.now, result.offset
|
||||
}
|
||||
|
||||
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
|
||||
}
|
|
@ -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")
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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")
|
||||
}
|
|
@ -0,0 +1,208 @@
|
|||
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
|
||||
|
||||
ntpServers []string
|
||||
httpUrls []string
|
||||
networkConfig *network.NetworkConfig
|
||||
|
||||
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,
|
||||
rtcDevicePath: rtcDevice,
|
||||
rtcLock: &sync.Mutex{},
|
||||
preCheckFunc: opts.PreCheckFunc,
|
||||
ntpServers: defaultNTPServers,
|
||||
httpUrls: defaultHTTPUrls,
|
||||
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) getSyncMode() SyncMode {
|
||||
syncMode := SyncMode{
|
||||
NtpUseFallback: true,
|
||||
HttpUseFallback: true,
|
||||
}
|
||||
var syncModeString string
|
||||
|
||||
if t.networkConfig != nil {
|
||||
syncModeString = t.networkConfig.TimeSyncMode.String
|
||||
if t.networkConfig.TimeSyncDisableFallback.Bool {
|
||||
syncMode.NtpUseFallback = false
|
||||
syncMode.HttpUseFallback = false
|
||||
}
|
||||
}
|
||||
|
||||
switch syncModeString {
|
||||
case "ntp_only":
|
||||
syncMode.Ntp = true
|
||||
case "http_only":
|
||||
syncMode.Http = true
|
||||
default:
|
||||
syncMode.Ntp = true
|
||||
syncMode.Http = true
|
||||
}
|
||||
|
||||
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
|
||||
)
|
||||
|
||||
syncMode := t.getSyncMode()
|
||||
|
||||
metricTimeSyncCount.Inc()
|
||||
|
||||
if syncMode.Ntp {
|
||||
now, offset = t.queryNetworkTime()
|
||||
}
|
||||
|
||||
if syncMode.Http && now == nil {
|
||||
now = t.queryAllHttpTime()
|
||||
}
|
||||
|
||||
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
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
|
@ -0,0 +1,191 @@
|
|||
package udhcpc
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"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
|
||||
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
|
||||
}
|
|
@ -0,0 +1,336 @@
|
|||
package usbgadget
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
)
|
||||
|
||||
type gadgetConfigItem struct {
|
||||
order uint
|
||||
device string
|
||||
path []string
|
||||
attrs gadgetAttributes
|
||||
configAttrs gadgetAttributes
|
||||
configPath []string
|
||||
reportDesc []byte
|
||||
}
|
||||
|
||||
type gadgetAttributes map[string]string
|
||||
|
||||
type gadgetConfigItemWithKey struct {
|
||||
key string
|
||||
item gadgetConfigItem
|
||||
}
|
||||
|
||||
type orderedGadgetConfigItems []gadgetConfigItemWithKey
|
||||
|
||||
var defaultGadgetConfig = map[string]gadgetConfigItem{
|
||||
"base": {
|
||||
order: 0,
|
||||
attrs: gadgetAttributes{
|
||||
"bcdUSB": "0x0200", // USB 2.0
|
||||
"idVendor": "0x1d6b", // The Linux Foundation
|
||||
"idProduct": "0104", // Multifunction Composite Gadget
|
||||
"bcdDevice": "0100",
|
||||
},
|
||||
configAttrs: gadgetAttributes{
|
||||
"MaxPower": "250", // in unit of 2mA
|
||||
},
|
||||
},
|
||||
"base_info": {
|
||||
order: 1,
|
||||
path: []string{"strings", "0x409"},
|
||||
configPath: []string{"strings", "0x409"},
|
||||
attrs: gadgetAttributes{
|
||||
"serialnumber": "",
|
||||
"manufacturer": "JetKVM",
|
||||
"product": "JetKVM USB Emulation Device",
|
||||
},
|
||||
configAttrs: gadgetAttributes{
|
||||
"configuration": "Config 1: HID",
|
||||
},
|
||||
},
|
||||
// keyboard HID
|
||||
"keyboard": keyboardConfig,
|
||||
// mouse HID
|
||||
"absolute_mouse": absoluteMouseConfig,
|
||||
// relative mouse HID
|
||||
"relative_mouse": relativeMouseConfig,
|
||||
// mass storage
|
||||
"mass_storage_base": massStorageBaseConfig,
|
||||
"mass_storage_lun0": massStorageLun0Config,
|
||||
}
|
||||
|
||||
func (u *UsbGadget) isGadgetConfigItemEnabled(itemKey string) bool {
|
||||
switch itemKey {
|
||||
case "absolute_mouse":
|
||||
return u.enabledDevices.AbsoluteMouse
|
||||
case "relative_mouse":
|
||||
return u.enabledDevices.RelativeMouse
|
||||
case "keyboard":
|
||||
return u.enabledDevices.Keyboard
|
||||
case "mass_storage_base":
|
||||
return u.enabledDevices.MassStorage
|
||||
case "mass_storage_lun0":
|
||||
return u.enabledDevices.MassStorage
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
func (u *UsbGadget) loadGadgetConfig() {
|
||||
if u.customConfig.isEmpty {
|
||||
u.log.Trace().Msg("using default gadget config")
|
||||
return
|
||||
}
|
||||
|
||||
u.configMap["base"].attrs["idVendor"] = u.customConfig.VendorId
|
||||
u.configMap["base"].attrs["idProduct"] = u.customConfig.ProductId
|
||||
|
||||
u.configMap["base_info"].attrs["serialnumber"] = u.customConfig.SerialNumber
|
||||
u.configMap["base_info"].attrs["manufacturer"] = u.customConfig.Manufacturer
|
||||
u.configMap["base_info"].attrs["product"] = u.customConfig.Product
|
||||
}
|
||||
|
||||
func (u *UsbGadget) SetGadgetConfig(config *Config) {
|
||||
u.configLock.Lock()
|
||||
defer u.configLock.Unlock()
|
||||
|
||||
if config == nil {
|
||||
return // nothing to do
|
||||
}
|
||||
|
||||
u.customConfig = *config
|
||||
u.loadGadgetConfig()
|
||||
}
|
||||
|
||||
func (u *UsbGadget) SetGadgetDevices(devices *Devices) {
|
||||
u.configLock.Lock()
|
||||
defer u.configLock.Unlock()
|
||||
|
||||
if devices == nil {
|
||||
return // nothing to do
|
||||
}
|
||||
|
||||
u.enabledDevices = *devices
|
||||
}
|
||||
|
||||
// GetConfigPath returns the path to the config item.
|
||||
func (u *UsbGadget) GetConfigPath(itemKey string) (string, error) {
|
||||
item, ok := u.configMap[itemKey]
|
||||
if !ok {
|
||||
return "", fmt.Errorf("config item %s not found", itemKey)
|
||||
}
|
||||
return joinPath(u.kvmGadgetPath, item.configPath), nil
|
||||
}
|
||||
|
||||
// GetPath returns the path to the item.
|
||||
func (u *UsbGadget) GetPath(itemKey string) (string, error) {
|
||||
item, ok := u.configMap[itemKey]
|
||||
if !ok {
|
||||
return "", fmt.Errorf("config item %s not found", itemKey)
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (u *UsbGadget) Init() error {
|
||||
u.configLock.Lock()
|
||||
defer u.configLock.Unlock()
|
||||
|
||||
u.loadGadgetConfig()
|
||||
|
||||
udcs := getUdcs()
|
||||
if len(udcs) < 1 {
|
||||
u.log.Error().Msg("no udc found, skipping USB stack init")
|
||||
return nil
|
||||
}
|
||||
|
||||
u.udc = udcs[0]
|
||||
_, err := os.Stat(u.kvmGadgetPath)
|
||||
if err == nil {
|
||||
u.log.Info().Msg("usb gadget already exists")
|
||||
}
|
||||
|
||||
if err := mountConfigFS(); err != nil {
|
||||
u.log.Error().Err(err).Msg("failed to mount configfs, usb stack might not function properly")
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(u.configC1Path, 0755); err != nil {
|
||||
u.log.Error().Err(err).Msg("failed to create config path")
|
||||
}
|
||||
|
||||
if err := u.writeGadgetConfig(); err != nil {
|
||||
u.log.Error().Err(err).Msg("failed to start gadget")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (u *UsbGadget) UpdateGadgetConfig() error {
|
||||
u.configLock.Lock()
|
||||
defer u.configLock.Unlock()
|
||||
|
||||
u.loadGadgetConfig()
|
||||
|
||||
if err := u.writeGadgetConfig(); err != nil {
|
||||
u.log.Error().Err(err).Msg("failed to update gadget")
|
||||
}
|
||||
|
||||
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 (u *UsbGadget) writeGadgetConfig() error {
|
||||
// create kvm gadget path
|
||||
err := os.MkdirAll(u.kvmGadgetPath, 0755)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
u.log.Trace().Msg("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.Trace().Str("key", key).Msg("disabling gadget config")
|
||||
err = u.disableGadgetItemConfig(item)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
u.log.Trace().Str("key", key).Msg("writing gadget config")
|
||||
err = u.writeGadgetItemConfig(item)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if err = u.writeUDC(); err != nil {
|
||||
u.log.Error().Err(err).Msg("failed to write UDC")
|
||||
return err
|
||||
}
|
||||
|
||||
if err = u.rebindUsb(true); err != nil {
|
||||
u.log.Info().Err(err).Msg("failed to rebind usb")
|
||||
}
|
||||
|
||||
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.Trace().Str("path", configPath).Msg("symlink does not exist")
|
||||
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.Trace().Str("source", configPath).Str("target", gadgetItemPath).Msg("creating symlink")
|
||||
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
|
||||
}
|
|
@ -0,0 +1,3 @@
|
|||
package usbgadget
|
||||
|
||||
const dwc3Path = "/sys/bus/platform/drivers/dwc3"
|
|
@ -0,0 +1,11 @@
|
|||
package usbgadget
|
||||
|
||||
import "time"
|
||||
|
||||
func (u *UsbGadget) resetUserInputTime() {
|
||||
u.lastUserInput = time.Now()
|
||||
}
|
||||
|
||||
func (u *UsbGadget) GetLastUserInputTime() time.Time {
|
||||
return u.lastUserInput
|
||||
}
|
|
@ -0,0 +1,95 @@
|
|||
package usbgadget
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
)
|
||||
|
||||
var keyboardConfig = gadgetConfigItem{
|
||||
order: 1000,
|
||||
device: "hid.usb0",
|
||||
path: []string{"functions", "hid.usb0"},
|
||||
configPath: []string{"hid.usb0"},
|
||||
attrs: gadgetAttributes{
|
||||
"protocol": "1",
|
||||
"subclass": "1",
|
||||
"report_length": "8",
|
||||
},
|
||||
reportDesc: keyboardReportDesc,
|
||||
}
|
||||
|
||||
// Source: https://www.kernel.org/doc/Documentation/usb/gadget_hid.txt
|
||||
var keyboardReportDesc = []byte{
|
||||
0x05, 0x01, /* USAGE_PAGE (Generic Desktop) */
|
||||
0x09, 0x06, /* USAGE (Keyboard) */
|
||||
0xa1, 0x01, /* COLLECTION (Application) */
|
||||
0x05, 0x07, /* USAGE_PAGE (Keyboard) */
|
||||
0x19, 0xe0, /* USAGE_MINIMUM (Keyboard LeftControl) */
|
||||
0x29, 0xe7, /* USAGE_MAXIMUM (Keyboard Right GUI) */
|
||||
0x15, 0x00, /* LOGICAL_MINIMUM (0) */
|
||||
0x25, 0x01, /* LOGICAL_MAXIMUM (1) */
|
||||
0x75, 0x01, /* REPORT_SIZE (1) */
|
||||
0x95, 0x08, /* REPORT_COUNT (8) */
|
||||
0x81, 0x02, /* INPUT (Data,Var,Abs) */
|
||||
0x95, 0x01, /* REPORT_COUNT (1) */
|
||||
0x75, 0x08, /* REPORT_SIZE (8) */
|
||||
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) */
|
||||
0x91, 0x02, /* OUTPUT (Data,Var,Abs) */
|
||||
0x95, 0x01, /* REPORT_COUNT (1) */
|
||||
0x75, 0x03, /* REPORT_SIZE (3) */
|
||||
0x91, 0x03, /* OUTPUT (Cnst,Var,Abs) */
|
||||
0x95, 0x06, /* REPORT_COUNT (6) */
|
||||
0x75, 0x08, /* REPORT_SIZE (8) */
|
||||
0x15, 0x00, /* LOGICAL_MINIMUM (0) */
|
||||
0x25, 0x65, /* LOGICAL_MAXIMUM (101) */
|
||||
0x05, 0x07, /* USAGE_PAGE (Keyboard) */
|
||||
0x19, 0x00, /* USAGE_MINIMUM (Reserved) */
|
||||
0x29, 0x65, /* USAGE_MAXIMUM (Keyboard Application) */
|
||||
0x81, 0x00, /* INPUT (Data,Ary,Abs) */
|
||||
0xc0, /* END_COLLECTION */
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
_, err := u.keyboardHidFile.Write(data)
|
||||
if err != nil {
|
||||
u.log.Error().Err(err).Msg("failed to write to hidg0")
|
||||
u.keyboardHidFile.Close()
|
||||
u.keyboardHidFile = nil
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (u *UsbGadget) KeyboardReport(modifier uint8, keys []uint8) error {
|
||||
u.keyboardLock.Lock()
|
||||
defer u.keyboardLock.Unlock()
|
||||
|
||||
if len(keys) > 6 {
|
||||
keys = keys[:6]
|
||||
}
|
||||
if len(keys) < 6 {
|
||||
keys = append(keys, make([]uint8, 6-len(keys))...)
|
||||
}
|
||||
|
||||
err := u.keyboardWriteHidFile([]byte{modifier, 0, keys[0], keys[1], keys[2], keys[3], keys[4], keys[5]})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
u.resetUserInputTime()
|
||||
return nil
|
||||
}
|
|
@ -0,0 +1,128 @@
|
|||
package usbgadget
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
)
|
||||
|
||||
var absoluteMouseConfig = gadgetConfigItem{
|
||||
order: 1001,
|
||||
device: "hid.usb1",
|
||||
path: []string{"functions", "hid.usb1"},
|
||||
configPath: []string{"hid.usb1"},
|
||||
attrs: gadgetAttributes{
|
||||
"protocol": "2",
|
||||
"subclass": "1",
|
||||
"report_length": "6",
|
||||
},
|
||||
reportDesc: absoluteMouseCombinedReportDesc,
|
||||
}
|
||||
|
||||
var absoluteMouseCombinedReportDesc = []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 (u *UsbGadget) absMouseWriteHidFile(data []byte) error {
|
||||
if u.absMouseHidFile == nil {
|
||||
var err error
|
||||
u.absMouseHidFile, err = os.OpenFile("/dev/hidg1", os.O_RDWR, 0666)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to open hidg1: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
_, err := u.absMouseHidFile.Write(data)
|
||||
if err != nil {
|
||||
u.log.Error().Err(err).Msg("failed to write to hidg1")
|
||||
u.absMouseHidFile.Close()
|
||||
u.absMouseHidFile = nil
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (u *UsbGadget) AbsMouseReport(x, y int, buttons uint8) error {
|
||||
u.absMouseLock.Lock()
|
||||
defer u.absMouseLock.Unlock()
|
||||
|
||||
err := u.absMouseWriteHidFile([]byte{
|
||||
1, // Report ID 1
|
||||
buttons, // Buttons
|
||||
uint8(x), // X Low Byte
|
||||
uint8(x >> 8), // X High Byte
|
||||
uint8(y), // Y Low Byte
|
||||
uint8(y >> 8), // Y High Byte
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
u.resetUserInputTime()
|
||||
return nil
|
||||
}
|
||||
|
||||
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 {
|
||||
return nil
|
||||
}
|
||||
|
||||
scaledWheelY := int8(u.absMouseAccumulatedWheelY)
|
||||
|
||||
err := u.absMouseWriteHidFile([]byte{
|
||||
2, // Report ID 2
|
||||
byte(scaledWheelY), // Scaled Wheel Y (signed)
|
||||
})
|
||||
|
||||
// Reset the accumulator, keeping any remainder
|
||||
u.absMouseAccumulatedWheelY -= float64(scaledWheelY)
|
||||
|
||||
u.resetUserInputTime()
|
||||
return err
|
||||
}
|
|
@ -0,0 +1,92 @@
|
|||
package usbgadget
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
)
|
||||
|
||||
var relativeMouseConfig = gadgetConfigItem{
|
||||
order: 1002,
|
||||
device: "hid.usb2",
|
||||
path: []string{"functions", "hid.usb2"},
|
||||
configPath: []string{"hid.usb2"},
|
||||
attrs: gadgetAttributes{
|
||||
"protocol": "2",
|
||||
"subclass": "1",
|
||||
"report_length": "4",
|
||||
},
|
||||
reportDesc: relativeMouseCombinedReportDesc,
|
||||
}
|
||||
|
||||
// from: https://github.com/NicoHood/HID/blob/b16be57caef4295c6cd382a7e4c64db5073647f7/src/SingleReport/BootMouse.cpp#L26
|
||||
var relativeMouseCombinedReportDesc = []byte{
|
||||
0x05, 0x01, // USAGE_PAGE (Generic Desktop) 54
|
||||
0x09, 0x02, // USAGE (Mouse)
|
||||
0xa1, 0x01, // COLLECTION (Application)
|
||||
|
||||
// Pointer and Physical are required by Apple Recovery
|
||||
0x09, 0x01, // USAGE (Pointer)
|
||||
0xa1, 0x00, // COLLECTION (Physical)
|
||||
|
||||
// 8 Buttons
|
||||
0x05, 0x09, // USAGE_PAGE (Button)
|
||||
0x19, 0x01, // USAGE_MINIMUM (Button 1)
|
||||
0x29, 0x08, // USAGE_MAXIMUM (Button 8)
|
||||
0x15, 0x00, // LOGICAL_MINIMUM (0)
|
||||
0x25, 0x01, // LOGICAL_MAXIMUM (1)
|
||||
0x95, 0x08, // REPORT_COUNT (8)
|
||||
0x75, 0x01, // REPORT_SIZE (1)
|
||||
0x81, 0x02, // INPUT (Data,Var,Abs)
|
||||
|
||||
// X, Y, Wheel
|
||||
0x05, 0x01, // USAGE_PAGE (Generic Desktop)
|
||||
0x09, 0x30, // USAGE (X)
|
||||
0x09, 0x31, // USAGE (Y)
|
||||
0x09, 0x38, // USAGE (Wheel)
|
||||
0x15, 0x81, // LOGICAL_MINIMUM (-127)
|
||||
0x25, 0x7f, // LOGICAL_MAXIMUM (127)
|
||||
0x75, 0x08, // REPORT_SIZE (8)
|
||||
0x95, 0x03, // REPORT_COUNT (3)
|
||||
0x81, 0x06, // INPUT (Data,Var,Rel)
|
||||
|
||||
// End
|
||||
0xc0, // End Collection (Physical)
|
||||
0xc0, // End Collection
|
||||
}
|
||||
|
||||
func (u *UsbGadget) relMouseWriteHidFile(data []byte) error {
|
||||
if u.relMouseHidFile == nil {
|
||||
var err error
|
||||
u.relMouseHidFile, err = os.OpenFile("/dev/hidg2", os.O_RDWR, 0666)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to open hidg1: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
_, err := u.relMouseHidFile.Write(data)
|
||||
if err != nil {
|
||||
u.log.Error().Err(err).Msg("failed to write to hidg2")
|
||||
u.relMouseHidFile.Close()
|
||||
u.relMouseHidFile = nil
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (u *UsbGadget) RelMouseReport(mx, my int8, buttons uint8) error {
|
||||
u.relMouseLock.Lock()
|
||||
defer u.relMouseLock.Unlock()
|
||||
|
||||
err := u.relMouseWriteHidFile([]byte{
|
||||
buttons, // Buttons
|
||||
uint8(mx), // X
|
||||
uint8(my), // Y
|
||||
0, // Wheel
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
u.resetUserInputTime()
|
||||
return nil
|
||||
}
|
|
@ -0,0 +1,23 @@
|
|||
package usbgadget
|
||||
|
||||
var massStorageBaseConfig = gadgetConfigItem{
|
||||
order: 3000,
|
||||
device: "mass_storage.usb0",
|
||||
path: []string{"functions", "mass_storage.usb0"},
|
||||
configPath: []string{"mass_storage.usb0"},
|
||||
attrs: gadgetAttributes{
|
||||
"stall": "1",
|
||||
},
|
||||
}
|
||||
|
||||
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",
|
||||
},
|
||||
}
|
|
@ -0,0 +1,109 @@
|
|||
package usbgadget
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func getUdcs() []string {
|
||||
var udcs []string
|
||||
|
||||
files, err := os.ReadDir("/sys/devices/platform/usbdrd")
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
for _, file := range files {
|
||||
if !file.IsDir() || !strings.HasSuffix(file.Name(), ".usb") {
|
||||
continue
|
||||
}
|
||||
udcs = append(udcs, file.Name())
|
||||
}
|
||||
|
||||
return udcs
|
||||
}
|
||||
|
||||
func rebindUsb(udc string, ignoreUnbindError bool) error {
|
||||
err := os.WriteFile(path.Join(dwc3Path, "unbind"), []byte(udc), 0644)
|
||||
if err != nil && !ignoreUnbindError {
|
||||
return err
|
||||
}
|
||||
err = os.WriteFile(path.Join(dwc3Path, "bind"), []byte(udc), 0644)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (u *UsbGadget) rebindUsb(ignoreUnbindError bool) error {
|
||||
u.log.Info().Str("udc", u.udc).Msg("rebinding USB gadget to UDC")
|
||||
return rebindUsb(u.udc, ignoreUnbindError)
|
||||
}
|
||||
|
||||
// RebindUsb rebinds the USB gadget to the UDC.
|
||||
func (u *UsbGadget) RebindUsb(ignoreUnbindError bool) error {
|
||||
u.configLock.Lock()
|
||||
defer u.configLock.Unlock()
|
||||
|
||||
return u.rebindUsb(ignoreUnbindError)
|
||||
}
|
||||
|
||||
func (u *UsbGadget) writeUDC() error {
|
||||
path := path.Join(u.kvmGadgetPath, "UDC")
|
||||
|
||||
u.log.Trace().Str("udc", u.udc).Str("path", path).Msg("writing UDC")
|
||||
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")
|
||||
stateBytes, err := os.ReadFile(stateFile)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return "not attached"
|
||||
} else {
|
||||
u.log.Trace().Err(err).Msg("failed to read usb state")
|
||||
}
|
||||
return "unknown"
|
||||
}
|
||||
return strings.TrimSpace(string(stateBytes))
|
||||
}
|
||||
|
||||
// IsUDCBound checks if the UDC state is bound.
|
||||
func (u *UsbGadget) IsUDCBound() (bool, error) {
|
||||
udcFilePath := path.Join(dwc3Path, u.udc)
|
||||
_, err := os.Stat(udcFilePath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return false, nil
|
||||
}
|
||||
return false, fmt.Errorf("error checking USB emulation state: %w", err)
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// BindUDC binds the gadget to the UDC.
|
||||
func (u *UsbGadget) BindUDC() error {
|
||||
err := os.WriteFile(path.Join(dwc3Path, "bind"), []byte(u.udc), 0644)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error binding UDC: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// UnbindUDC unbinds the gadget from the UDC.
|
||||
func (u *UsbGadget) UnbindUDC() error {
|
||||
err := os.WriteFile(path.Join(dwc3Path, "unbind"), []byte(u.udc), 0644)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error unbinding UDC: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
|
@ -0,0 +1,110 @@
|
|||
// Package usbgadget provides a high-level interface to manage USB gadgets
|
||||
// THIS PACKAGE IS FOR INTERNAL USE ONLY AND ITS API MAY CHANGE WITHOUT NOTICE
|
||||
package usbgadget
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/rs/zerolog"
|
||||
)
|
||||
|
||||
// Devices is a struct that represents the USB devices that can be enabled on a USB gadget.
|
||||
type Devices struct {
|
||||
AbsoluteMouse bool `json:"absolute_mouse"`
|
||||
RelativeMouse bool `json:"relative_mouse"`
|
||||
Keyboard bool `json:"keyboard"`
|
||||
MassStorage bool `json:"mass_storage"`
|
||||
}
|
||||
|
||||
// Config is a struct that represents the customizations for a USB gadget.
|
||||
// TODO: rename to something else that won't confuse with the USB gadget configuration
|
||||
type Config struct {
|
||||
VendorId string `json:"vendor_id"`
|
||||
ProductId string `json:"product_id"`
|
||||
SerialNumber string `json:"serial_number"`
|
||||
Manufacturer string `json:"manufacturer"`
|
||||
Product string `json:"product"`
|
||||
|
||||
isEmpty bool
|
||||
}
|
||||
|
||||
var defaultUsbGadgetDevices = Devices{
|
||||
AbsoluteMouse: true,
|
||||
RelativeMouse: true,
|
||||
Keyboard: true,
|
||||
MassStorage: true,
|
||||
}
|
||||
|
||||
// UsbGadget is a struct that represents a USB gadget.
|
||||
type UsbGadget struct {
|
||||
name string
|
||||
udc string
|
||||
kvmGadgetPath string
|
||||
configC1Path string
|
||||
|
||||
configMap map[string]gadgetConfigItem
|
||||
customConfig Config
|
||||
|
||||
configLock sync.Mutex
|
||||
|
||||
keyboardHidFile *os.File
|
||||
keyboardLock sync.Mutex
|
||||
absMouseHidFile *os.File
|
||||
absMouseLock sync.Mutex
|
||||
relMouseHidFile *os.File
|
||||
relMouseLock sync.Mutex
|
||||
|
||||
enabledDevices Devices
|
||||
|
||||
absMouseAccumulatedWheelY float64
|
||||
|
||||
lastUserInput time.Time
|
||||
|
||||
log *zerolog.Logger
|
||||
}
|
||||
|
||||
const configFSPath = "/sys/kernel/config"
|
||||
const gadgetPath = "/sys/kernel/config/usb_gadget"
|
||||
|
||||
var defaultLogger = zerolog.New(os.Stdout).Level(zerolog.InfoLevel)
|
||||
|
||||
// NewUsbGadget creates a new UsbGadget.
|
||||
func NewUsbGadget(name string, enabledDevices *Devices, config *Config, logger *zerolog.Logger) *UsbGadget {
|
||||
if logger == nil {
|
||||
logger = &defaultLogger
|
||||
}
|
||||
|
||||
if enabledDevices == nil {
|
||||
enabledDevices = &defaultUsbGadgetDevices
|
||||
}
|
||||
|
||||
if config == nil {
|
||||
config = &Config{isEmpty: true}
|
||||
}
|
||||
|
||||
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,
|
||||
|
||||
absMouseAccumulatedWheelY: 0,
|
||||
}
|
||||
if err := g.Init(); err != nil {
|
||||
logger.Error().Err(err).Msg("failed to init USB gadget")
|
||||
return nil
|
||||
}
|
||||
|
||||
return g
|
||||
}
|
|
@ -0,0 +1,63 @@
|
|||
package usbgadget
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// Helper function to get absolute value of float64
|
||||
func abs(x float64) float64 {
|
||||
if x < 0 {
|
||||
return -x
|
||||
}
|
||||
return x
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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.Trace().Str("path", filePath).Msg("skipping writing to as it already has the correct content")
|
||||
return nil
|
||||
}
|
||||
|
||||
if len(oldContent) == len(content)+1 &&
|
||||
bytes.Equal(oldContent[:len(content)], content) &&
|
||||
oldContent[len(content)] == 10 {
|
||||
u.log.Trace().Str("path", filePath).Msg("skipping writing to as it already has the correct content")
|
||||
return nil
|
||||
}
|
||||
|
||||
u.log.Trace().Str("path", filePath).Bytes("old", oldContent).Bytes("new", content).Msg("writing to as it has different content")
|
||||
}
|
||||
}
|
||||
return os.WriteFile(filePath, content, permMode)
|
||||
}
|
|
@ -0,0 +1,9 @@
|
|||
package websecure
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"github.com/rs/zerolog"
|
||||
)
|
||||
|
||||
var defaultLogger = zerolog.New(os.Stdout).With().Str("component", "websecure").Logger()
|
|
@ -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
|
||||
}
|
|
@ -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")
|
||||
}
|
|
@ -0,0 +1,80 @@
|
|||
package websecure
|
||||
|
||||
import (
|
||||
"crypto/ecdsa"
|
||||
"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,
|
||||
}
|
||||
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)
|
||||
}
|
10
jiggler.go
10
jiggler.go
|
@ -6,10 +6,6 @@ import (
|
|||
|
||||
var lastUserInput = time.Now()
|
||||
|
||||
func resetUserInputTime() {
|
||||
lastUserInput = time.Now()
|
||||
}
|
||||
|
||||
var jigglerEnabled = false
|
||||
|
||||
func rpcSetJigglerState(enabled bool) {
|
||||
|
@ -20,6 +16,8 @@ func rpcGetJigglerState() bool {
|
|||
}
|
||||
|
||||
func init() {
|
||||
ensureConfigLoaded()
|
||||
|
||||
go runJiggler()
|
||||
}
|
||||
|
||||
|
@ -30,11 +28,11 @@ func runJiggler() {
|
|||
//TODO: change to rel mouse
|
||||
err := rpcAbsMouseReport(1, 1, 0)
|
||||
if err != nil {
|
||||
logger.Warnf("Failed to jiggle mouse: %v", err)
|
||||
logger.Warn().Err(err).Msg("Failed to jiggle mouse")
|
||||
}
|
||||
err = rpcAbsMouseReport(0, 0, 0)
|
||||
if err != nil {
|
||||
logger.Warnf("Failed to reset mouse position: %v", err)
|
||||
logger.Warn().Err(err).Msg("Failed to reset mouse position")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
342
jsonrpc.go
342
jsonrpc.go
|
@ -5,7 +5,6 @@ import (
|
|||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
|
@ -15,6 +14,8 @@ import (
|
|||
|
||||
"github.com/pion/webrtc/v4"
|
||||
"go.bug.st/serial"
|
||||
|
||||
"github.com/jetkvm/kvm/internal/usbgadget"
|
||||
)
|
||||
|
||||
type JSONRPCRequest struct {
|
||||
|
@ -46,12 +47,12 @@ type BacklightSettings struct {
|
|||
func writeJSONRPCResponse(response JSONRPCResponse, session *Session) {
|
||||
responseBytes, err := json.Marshal(response)
|
||||
if err != nil {
|
||||
log.Println("Error marshalling JSONRPC response:", err)
|
||||
jsonRpcLogger.Warn().Err(err).Msg("Error marshalling JSONRPC response")
|
||||
return
|
||||
}
|
||||
err = session.RPCChannel.SendText(string(responseBytes))
|
||||
if err != nil {
|
||||
log.Println("Error sending JSONRPC response:", err)
|
||||
jsonRpcLogger.Warn().Err(err).Msg("Error sending JSONRPC response")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
@ -64,16 +65,24 @@ func writeJSONRPCEvent(event string, params interface{}, session *Session) {
|
|||
}
|
||||
requestBytes, err := json.Marshal(request)
|
||||
if err != nil {
|
||||
log.Println("Error marshalling JSONRPC event:", err)
|
||||
jsonRpcLogger.Warn().Err(err).Msg("Error marshalling JSONRPC event")
|
||||
return
|
||||
}
|
||||
if session == nil || session.RPCChannel == nil {
|
||||
log.Println("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 {
|
||||
log.Println("Error sending JSONRPC event:", err)
|
||||
scopedLogger.Warn().Err(err).Msg("error sending JSONRPC event")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
@ -82,6 +91,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{}{
|
||||
|
@ -94,7 +108,13 @@ func onRPCMessage(message webrtc.DataChannelMessage, session *Session) {
|
|||
return
|
||||
}
|
||||
|
||||
//log.Printf("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{
|
||||
|
@ -109,8 +129,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{}{
|
||||
|
@ -124,6 +146,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,
|
||||
|
@ -140,6 +164,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) {
|
||||
|
@ -147,7 +195,7 @@ func rpcGetStreamQualityFactor() (float64, error) {
|
|||
}
|
||||
|
||||
func rpcSetStreamQualityFactor(factor float64) error {
|
||||
log.Printf("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
|
||||
|
@ -183,10 +231,10 @@ func rpcGetEDID() (string, error) {
|
|||
|
||||
func rpcSetEDID(edid string) error {
|
||||
if edid == "" {
|
||||
log.Println("Restoring EDID to default")
|
||||
logger.Info().Msg("Restoring EDID to default")
|
||||
edid = "00ffffffffffff0052620188008888881c150103800000780a0dc9a05747982712484c00000001010101010101010101010101010101023a801871382d40582c4500c48e2100001e011d007251d01e206e285500c48e2100001e000000fc00543734392d6648443732300a20000000fd00147801ff1d000a202020202020017b"
|
||||
} else {
|
||||
log.Printf("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 {
|
||||
|
@ -195,8 +243,7 @@ func rpcSetEDID(edid string) error {
|
|||
|
||||
// Save EDID to config, allowing it to be restored on reboot.
|
||||
config.EdidString = edid
|
||||
SaveConfig()
|
||||
|
||||
_ = SaveConfig()
|
||||
return nil
|
||||
}
|
||||
|
||||
|
@ -227,7 +274,7 @@ 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
|
||||
|
@ -257,7 +304,7 @@ func rpcSetBacklightSettings(params BacklightSettings) error {
|
|||
return fmt.Errorf("failed to save config: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("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 +365,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 +374,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 +384,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,6 +422,23 @@ func rpcSetSSHKeyState(sshKey string) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
func callRPCHandler(handler RPCHandler, params map[string]interface{}) (interface{}, error) {
|
||||
handlerValue := reflect.ValueOf(handler.Func)
|
||||
handlerType := handlerValue.Type()
|
||||
|
@ -478,23 +542,23 @@ type RPCHandler struct {
|
|||
}
|
||||
|
||||
func rpcSetMassStorageMode(mode string) (string, error) {
|
||||
log.Printf("[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" {
|
||||
cdrom = true
|
||||
} else if mode != "file" {
|
||||
log.Printf("[jsonrpc.go:rpcSetMassStorageMode] Invalid mode provided: %s", mode)
|
||||
logger.Info().Str("mode", mode).Msg("Invalid mode provided")
|
||||
return "", fmt.Errorf("invalid mode: %s", mode)
|
||||
}
|
||||
|
||||
log.Printf("[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)
|
||||
}
|
||||
|
||||
log.Printf("[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()
|
||||
|
@ -517,27 +581,30 @@ func rpcIsUpdatePending() (bool, error) {
|
|||
return IsUpdatePending(), nil
|
||||
}
|
||||
|
||||
var udcFilePath = filepath.Join("/sys/bus/platform/drivers/dwc3", udc)
|
||||
|
||||
func rpcGetUsbEmulationState() (bool, error) {
|
||||
_, err := os.Stat(udcFilePath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return false, nil
|
||||
}
|
||||
return false, fmt.Errorf("error checking USB emulation state: %w", err)
|
||||
}
|
||||
return true, nil
|
||||
return gadget.IsUDCBound()
|
||||
}
|
||||
|
||||
func rpcSetUsbEmulationState(enabled bool) error {
|
||||
if enabled {
|
||||
return os.WriteFile("/sys/bus/platform/drivers/dwc3/bind", []byte(udc), 0644)
|
||||
return gadget.BindUDC()
|
||||
} else {
|
||||
return os.WriteFile("/sys/bus/platform/drivers/dwc3/unbind", []byte(udc), 0644)
|
||||
return gadget.UnbindUDC()
|
||||
}
|
||||
}
|
||||
|
||||
func rpcGetUsbConfig() (usbgadget.Config, error) {
|
||||
LoadConfig()
|
||||
return *config.UsbConfig, nil
|
||||
}
|
||||
|
||||
func rpcSetUsbConfig(usbConfig usbgadget.Config) error {
|
||||
LoadConfig()
|
||||
config.UsbConfig = &usbConfig
|
||||
gadget.SetGadgetConfig(config.UsbConfig)
|
||||
return updateUsbRelatedConfig()
|
||||
}
|
||||
|
||||
func rpcGetWakeOnLanDevices() ([]WakeOnLanDevice, error) {
|
||||
if config.WakeOnLanDevices == nil {
|
||||
return []WakeOnLanDevice{}, nil
|
||||
|
@ -560,7 +627,7 @@ func rpcResetConfig() error {
|
|||
return fmt.Errorf("failed to reset config: %w", err)
|
||||
}
|
||||
|
||||
log.Println("Configuration reset to default")
|
||||
logger.Info().Msg("Configuration reset to default")
|
||||
return nil
|
||||
}
|
||||
|
||||
|
@ -576,7 +643,7 @@ func rpcGetDCPowerState() (DCPowerState, error) {
|
|||
}
|
||||
|
||||
func rpcSetDCPowerState(enabled bool) error {
|
||||
log.Printf("[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)
|
||||
|
@ -592,34 +659,36 @@ func rpcSetActiveExtension(extensionId string) error {
|
|||
if config.ActiveExtension == extensionId {
|
||||
return nil
|
||||
}
|
||||
if config.ActiveExtension == "atx-power" {
|
||||
unmountATXControl()
|
||||
} else if config.ActiveExtension == "dc-power" {
|
||||
unmountDCControl()
|
||||
switch config.ActiveExtension {
|
||||
case "atx-power":
|
||||
_ = unmountATXControl()
|
||||
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" {
|
||||
mountATXControl()
|
||||
} else if extensionId == "dc-power" {
|
||||
mountDCControl()
|
||||
switch extensionId {
|
||||
case "atx-power":
|
||||
_ = mountATXControl()
|
||||
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)
|
||||
|
@ -725,18 +794,181 @@ func rpcSetSerialSettings(settings SerialSettings) error {
|
|||
Parity: parity,
|
||||
}
|
||||
|
||||
port.SetMode(serialPortMode)
|
||||
_ = port.SetMode(serialPortMode)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func rpcGetUsbDevices() (usbgadget.Devices, error) {
|
||||
return *config.UsbDevices, nil
|
||||
}
|
||||
|
||||
func updateUsbRelatedConfig() error {
|
||||
if err := gadget.UpdateGadgetConfig(); err != nil {
|
||||
return fmt.Errorf("failed to write gadget config: %w", err)
|
||||
}
|
||||
if err := SaveConfig(); err != nil {
|
||||
return fmt.Errorf("failed to save config: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func rpcSetUsbDevices(usbDevices usbgadget.Devices) error {
|
||||
config.UsbDevices = &usbDevices
|
||||
gadget.SetGadgetDevices(config.UsbDevices)
|
||||
return updateUsbRelatedConfig()
|
||||
}
|
||||
|
||||
func rpcSetUsbDeviceState(device string, enabled bool) error {
|
||||
switch device {
|
||||
case "absoluteMouse":
|
||||
config.UsbDevices.AbsoluteMouse = enabled
|
||||
case "relativeMouse":
|
||||
config.UsbDevices.RelativeMouse = enabled
|
||||
case "keyboard":
|
||||
config.UsbDevices.Keyboard = enabled
|
||||
case "massStorage":
|
||||
config.UsbDevices.MassStorage = enabled
|
||||
default:
|
||||
return fmt.Errorf("invalid device: %s", device)
|
||||
}
|
||||
gadget.SetGadgetDevices(config.UsbDevices)
|
||||
return updateUsbRelatedConfig()
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
var currentScrollSensitivity string = "default"
|
||||
|
||||
func rpcGetScrollSensitivity() (string, error) {
|
||||
return currentScrollSensitivity, nil
|
||||
}
|
||||
|
||||
func rpcSetScrollSensitivity(sensitivity string) error {
|
||||
currentScrollSensitivity = sensitivity
|
||||
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
|
||||
}
|
||||
|
||||
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"}},
|
||||
"absMouseReport": {Func: rpcAbsMouseReport, Params: []string{"x", "y", "buttons"}},
|
||||
"relMouseReport": {Func: rpcRelMouseReport, Params: []string{"dx", "dy", "buttons"}},
|
||||
"wheelReport": {Func: rpcWheelReport, Params: []string{"wheelY"}},
|
||||
"getVideoState": {Func: rpcGetVideoState},
|
||||
"getUSBState": {Func: rpcGetUSBState},
|
||||
|
@ -759,11 +991,15 @@ 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},
|
||||
"getUsbEmulationState": {Func: rpcGetUsbEmulationState},
|
||||
"setUsbEmulationState": {Func: rpcSetUsbEmulationState, Params: []string{"enabled"}},
|
||||
"getUsbConfig": {Func: rpcGetUsbConfig},
|
||||
"setUsbConfig": {Func: rpcSetUsbConfig, Params: []string{"usbConfig"}},
|
||||
"checkMountUrl": {Func: rpcCheckMountUrl, Params: []string{"url"}},
|
||||
"getVirtualMediaState": {Func: rpcGetVirtualMediaState},
|
||||
"getStorageSpace": {Func: rpcGetStorageSpace},
|
||||
|
@ -786,4 +1022,12 @@ var rpcHandlers = map[string]RPCHandler{
|
|||
"setATXPowerAction": {Func: rpcSetATXPowerAction, Params: []string{"action"}},
|
||||
"getSerialSettings": {Func: rpcGetSerialSettings},
|
||||
"setSerialSettings": {Func: rpcSetSerialSettings, Params: []string{"settings"}},
|
||||
"getUsbDevices": {Func: rpcGetUsbDevices},
|
||||
"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"}},
|
||||
"getKeyboardMacros": {Func: getKeyboardMacros},
|
||||
"setKeyboardMacros": {Func: setKeyboardMacros, Params: []string{"params"}},
|
||||
}
|
||||
|
|
35
log.go
35
log.go
|
@ -1,9 +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 usbLogger = logging.NewDefaultLoggerFactory().NewLogger("usb")
|
||||
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")
|
||||
)
|
||||
|
|
76
main.go
76
main.go
|
@ -2,7 +2,6 @@ package kvm
|
|||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
|
@ -15,75 +14,114 @@ 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()
|
||||
|
||||
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
|
||||
}
|
||||
}()
|
||||
|
||||
initUsbGadget()
|
||||
|
||||
go func() {
|
||||
time.Sleep(15 * time.Minute)
|
||||
for {
|
||||
logger.Debugf("UPDATING - Auto update enabled: %v", config.AutoUpdateEnabled)
|
||||
if config.AutoUpdateEnabled == false {
|
||||
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()
|
||||
// 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()
|
||||
|
||||
go RunWebSecureServer()
|
||||
// Web secure server is started only if TLS mode is enabled
|
||||
if config.TLSMode != "" {
|
||||
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
|
||||
log.Println("JetKVM Shutting Down")
|
||||
logger.Info().Msg("JetKVM Shutting Down")
|
||||
//if fuseServer != nil {
|
||||
// err := setMassStorageImage(" ")
|
||||
// if err != nil {
|
||||
// log.Printf("Failed to unmount mass storage image: %v", err)
|
||||
// logger.Infof("Failed to unmount mass storage image: %v", err)
|
||||
// }
|
||||
// err = fuseServer.Unmount()
|
||||
// if err != nil {
|
||||
// log.Printf("Failed to unmount fuse: %v", err)
|
||||
// logger.Infof("Failed to unmount fuse: %v", err)
|
||||
// }
|
||||
|
||||
// os.Exit(0)
|
||||
|
|
|
@ -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
|
||||
}
|
117
native.go
117
native.go
|
@ -3,17 +3,16 @@ package kvm
|
|||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"kvm/resource"
|
||||
"log"
|
||||
"net"
|
||||
"os"
|
||||
"os/exec"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/jetkvm/kvm/resource"
|
||||
|
||||
"github.com/pion/webrtc/v4/pkg/media"
|
||||
)
|
||||
|
||||
|
@ -61,25 +60,33 @@ func CallCtrlAction(action string, params map[string]interface{}) (*CtrlResponse
|
|||
return nil, fmt.Errorf("error marshaling ctrl action: %w", err)
|
||||
}
|
||||
|
||||
fmt.Println("sending ctrl action", 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)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -91,8 +98,8 @@ func WriteCtrlMessage(message []byte) error {
|
|||
return err
|
||||
}
|
||||
|
||||
var nativeCtrlSocketListener net.Listener
|
||||
var nativeVideoSocketListener net.Listener
|
||||
var nativeCtrlSocketListener net.Listener //nolint:unused
|
||||
var nativeVideoSocketListener net.Listener //nolint:unused
|
||||
|
||||
var ctrlClientConnected = make(chan struct{})
|
||||
|
||||
|
@ -101,29 +108,35 @@ 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 {
|
||||
log.Fatalf("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 {
|
||||
log.Fatalf("Failed to start server on %s: %v", socketPath, err)
|
||||
scopedLogger.Warn().Err(err).Msg("failed to start server")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
log.Printf("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)
|
||||
scopedLogger.Warn().Err(err).Msg("failed to accept socket")
|
||||
}
|
||||
if isCtrl {
|
||||
close(ctrlClientConnected)
|
||||
logger.Debug("first native ctrl socket client connected")
|
||||
scopedLogger.Debug().Msg("first native ctrl socket client connected")
|
||||
}
|
||||
handleClient(conn)
|
||||
}()
|
||||
|
@ -133,20 +146,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()
|
||||
}
|
||||
|
||||
|
@ -159,17 +177,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 {
|
||||
|
@ -182,30 +202,34 @@ func handleCtrlClient(conn net.Conn) {
|
|||
}
|
||||
}
|
||||
|
||||
logger.Debug("ctrl sock disconnected")
|
||||
scopedLogger.Debug().Msg("ctrl sock disconnected")
|
||||
}
|
||||
|
||||
func handleVideoClient(conn net.Conn) {
|
||||
defer conn.Close()
|
||||
|
||||
log.Printf("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 {
|
||||
log.Println("error during read: %s", err)
|
||||
scopedLogger.Warn().Err(err).Msg("error during read")
|
||||
return
|
||||
}
|
||||
now := time.Now()
|
||||
sinceLastFrame := now.Sub(lastFrame)
|
||||
lastFrame = now
|
||||
//fmt.Println("Video packet received", n, sinceLastFrame)
|
||||
if currentSession != nil {
|
||||
err := currentSession.VideoTrack.WriteSample(media.Sample{Data: inboundPacket[:n], Duration: sinceLastFrame})
|
||||
if err != nil {
|
||||
log.Println("Error writing sample", err)
|
||||
scopedLogger.Warn().Err(err).Msg("error writing sample")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -222,48 +246,36 @@ 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 := startNativeBinary(binaryPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to start binary: %w", err)
|
||||
}
|
||||
|
||||
//TODO: add auto restart
|
||||
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
|
||||
}
|
||||
}()
|
||||
|
||||
fmt.Printf("Binary started with PID: %d\n", 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
|
||||
}
|
||||
|
||||
|
@ -279,13 +291,16 @@ func ensureBinaryUpdated(destPath string) error {
|
|||
|
||||
srcHash, err := resource.ResourceFS.ReadFile("jetkvm_native.sha256")
|
||||
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 {
|
||||
|
@ -302,7 +317,7 @@ func ensureBinaryUpdated(destPath string) error {
|
|||
return err
|
||||
}
|
||||
}
|
||||
logger.Info("jetkvm_native updated")
|
||||
nativeLogger.Info().Msg("jetkvm_native updated")
|
||||
}
|
||||
|
||||
return nil
|
||||
|
@ -312,10 +327,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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
|
@ -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")
|
||||
}
|
276
network.go
276
network.go
|
@ -1,225 +1,107 @@
|
|||
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
|
||||
)
|
||||
|
||||
cmd := exec.Command("/usr/bin/killall", signal, "udhcpc")
|
||||
if err := cmd.Run(); err != nil {
|
||||
fmt.Printf("network: setDhcpClientState: failed to change udhcpc state: %s\n", err)
|
||||
func networkStateChanged() {
|
||||
// do not block the main thread
|
||||
go waitCtrlAndRequestDisplayUpdate(true)
|
||||
|
||||
// always restart mDNS when the network state changes
|
||||
if mDNS != nil {
|
||||
_ = mDNS.SetLocalNames([]string{
|
||||
networkState.GetHostname(),
|
||||
networkState.GetFQDN(),
|
||||
}, true)
|
||||
}
|
||||
}
|
||||
|
||||
func checkNetworkState() {
|
||||
iface, err := netlink.LinkByName(NetIfName)
|
||||
if err != nil {
|
||||
fmt.Printf("failed to get [%s] interface: %v\n", 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 {
|
||||
fmt.Printf("failed to get addresses for [%s]: %v\n", 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.
|
||||
fmt.Printf("network: state transitioned to down, removing IPv4 address %s\n", addr.IP.String())
|
||||
err := netlink.AddrDel(iface, &addr)
|
||||
if err != nil {
|
||||
fmt.Printf("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 {
|
||||
fmt.Println("network state changed")
|
||||
// restart MDNS
|
||||
startMDNS()
|
||||
networkState = newState
|
||||
requestDisplayUpdate()
|
||||
}
|
||||
}
|
||||
writeJSONRPCEvent("networkState", networkState.RpcGetNetworkState(), currentSession)
|
||||
},
|
||||
OnConfigChange: func(networkConfig *network.NetworkConfig) {
|
||||
config.NetworkConfig = networkConfig
|
||||
networkStateChanged()
|
||||
|
||||
func startMDNS() error {
|
||||
// If server was previously running, stop it
|
||||
if mDNSConn != nil {
|
||||
fmt.Printf("Stopping mDNS server\n")
|
||||
err := mDNSConn.Close()
|
||||
if err != nil {
|
||||
fmt.Printf("failed to stop mDNS server: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Start a new server
|
||||
fmt.Printf("Starting mDNS server on jetkvm.local\n")
|
||||
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
|
||||
if mDNS != nil {
|
||||
_ = mDNS.SetListenOptions(networkConfig.GetMDNSMode())
|
||||
_ = mDNS.SetLocalNames([]string{
|
||||
networkState.GetHostname(),
|
||||
networkState.GetFQDN(),
|
||||
}, true)
|
||||
}
|
||||
},
|
||||
})
|
||||
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 {
|
||||
fmt.Printf("invalid NTP server IP: %s, ignoring ... \n", server)
|
||||
}
|
||||
servers = append(servers, server)
|
||||
}
|
||||
|
||||
return servers, nil
|
||||
func rpcGetNetworkState() network.RpcNetworkState {
|
||||
return networkState.RpcGetNetworkState()
|
||||
}
|
||||
|
||||
func init() {
|
||||
updates := make(chan netlink.LinkUpdate)
|
||||
done := make(chan struct{})
|
||||
|
||||
if err := netlink.LinkSubscribe(updates, done); err != nil {
|
||||
fmt.Println("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 {
|
||||
fmt.Printf("link update: %+v\n", update)
|
||||
checkNetworkState()
|
||||
}
|
||||
case <-ticker.C:
|
||||
checkNetworkState()
|
||||
case <-done:
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
err := startMDNS()
|
||||
if err != nil {
|
||||
fmt.Println("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()
|
||||
}
|
||||
|
|
143
ntp.go
143
ntp.go
|
@ -1,143 +0,0 @@
|
|||
package kvm
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"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 (
|
||||
timeSynced = false
|
||||
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 {
|
||||
log.Printf("Waiting for network to come up")
|
||||
time.Sleep(timeSyncWaitNetUpInt)
|
||||
continue
|
||||
}
|
||||
|
||||
log.Printf("Syncing system time")
|
||||
start := time.Now()
|
||||
err := SyncSystemTime()
|
||||
if err != nil {
|
||||
log.Printf("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
|
||||
}
|
||||
log.Printf("Time sync successful, now is: %v, time taken: %v", time.Now(), time.Since(start))
|
||||
timeSynced = true
|
||||
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 {
|
||||
log.Printf("failed to get NTP servers from DHCP info: %v\n", err)
|
||||
}
|
||||
|
||||
if ntpServers == nil {
|
||||
ntpServers = defaultNTPServers
|
||||
log.Printf("Using default NTP servers: %v\n", ntpServers)
|
||||
} else {
|
||||
log.Printf("Using NTP servers from DHCP: %v\n", ntpServers)
|
||||
}
|
||||
|
||||
for _, server := range ntpServers {
|
||||
now, err := queryNtpServer(server, timeSyncTimeout)
|
||||
if err == nil {
|
||||
log.Printf("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
|
||||
}
|
88
ota.go
88
ota.go
|
@ -4,11 +4,11 @@ import (
|
|||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"crypto/tls"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
|
@ -17,6 +17,8 @@ import (
|
|||
"time"
|
||||
|
||||
"github.com/Masterminds/semver/v3"
|
||||
"github.com/gwatts/rootcerts"
|
||||
"github.com/rs/zerolog"
|
||||
)
|
||||
|
||||
type UpdateMetadata struct {
|
||||
|
@ -77,7 +79,7 @@ func fetchUpdateMetadata(ctx context.Context, deviceId string, includePreRelease
|
|||
query.Set("prerelease", fmt.Sprintf("%v", includePreRelease))
|
||||
updateUrl.RawQuery = query.Encode()
|
||||
|
||||
fmt.Println("Checking for updates at:", updateUrl.String())
|
||||
logger.Info().Str("url", updateUrl.String()).Msg("Checking for updates")
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", updateUrl.String(), nil)
|
||||
if err != nil {
|
||||
|
@ -127,7 +129,17 @@ 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{
|
||||
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)
|
||||
}
|
||||
|
@ -186,7 +198,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 {
|
||||
|
@ -230,7 +246,7 @@ func verifyFile(path string, expectedHash string, verifyProgress *float32) error
|
|||
}
|
||||
|
||||
hashSum := hash.Sum(nil)
|
||||
fmt.Printf("SHA256 hash of %s: %x\n", 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)
|
||||
|
@ -272,7 +288,7 @@ var otaState = OTAState{}
|
|||
func triggerOTAStateUpdate() {
|
||||
go func() {
|
||||
if currentSession == nil {
|
||||
log.Println("No active RPC session, skipping update state update")
|
||||
logger.Info().Msg("No active RPC session, skipping update state update")
|
||||
return
|
||||
}
|
||||
writeJSONRPCEvent("otaState", otaState, currentSession)
|
||||
|
@ -280,7 +296,12 @@ func triggerOTAStateUpdate() {
|
|||
}
|
||||
|
||||
func TryUpdate(ctx context.Context, deviceId string, includePreRelease bool) error {
|
||||
log.Println("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")
|
||||
}
|
||||
|
@ -298,6 +319,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)
|
||||
}
|
||||
|
||||
|
@ -315,11 +337,15 @@ func TryUpdate(ctx context.Context, deviceId string, includePreRelease bool) err
|
|||
rebootNeeded := false
|
||||
|
||||
if appUpdateAvailable {
|
||||
fmt.Printf("App update available: %s -> %s\n", 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
|
||||
}
|
||||
|
@ -328,9 +354,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
|
||||
}
|
||||
|
@ -341,17 +373,22 @@ func TryUpdate(ctx context.Context, deviceId string, includePreRelease bool) err
|
|||
otaState.AppUpdateProgress = 1
|
||||
triggerOTAStateUpdate()
|
||||
|
||||
fmt.Println("App update downloaded")
|
||||
scopedLogger.Info().Msg("App update downloaded")
|
||||
rebootNeeded = true
|
||||
} else {
|
||||
fmt.Println("App is up to date")
|
||||
scopedLogger.Info().Msg("App is up to date")
|
||||
}
|
||||
|
||||
if systemUpdateAvailable {
|
||||
fmt.Printf("System update available: %s -> %s\n", 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
|
||||
}
|
||||
|
@ -360,18 +397,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
|
||||
}
|
||||
fmt.Println("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
|
||||
|
@ -379,6 +423,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())
|
||||
|
@ -410,25 +455,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)
|
||||
}
|
||||
|
||||
fmt.Printf("rk_ota success, output: %s\n", output)
|
||||
scopedLogger.Info().Str("output", output).Msg("rk_ota success")
|
||||
otaState.SystemUpdateProgress = 1
|
||||
otaState.SystemUpdatedAt = &verifyFinished
|
||||
triggerOTAStateUpdate()
|
||||
rebootNeeded = true
|
||||
} else {
|
||||
fmt.Println("System is up to date")
|
||||
scopedLogger.Info().Msg("System is up to date")
|
||||
}
|
||||
|
||||
if rebootNeeded {
|
||||
fmt.Println("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)
|
||||
|
@ -498,6 +548,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")
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,13 @@
|
|||
package kvm
|
||||
|
||||
import (
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
versioncollector "github.com/prometheus/client_golang/prometheus/collectors/version"
|
||||
"github.com/prometheus/common/version"
|
||||
)
|
||||
|
||||
func initPrometheus() {
|
||||
// A Prometheus metrics endpoint.
|
||||
version.Version = builtAppVersion
|
||||
prometheus.MustRegister(versioncollector.NewCollector("jetkvm"))
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -44,12 +44,12 @@ 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
|
||||
}
|
||||
buf := make([]byte, 0)
|
||||
var buf []byte
|
||||
for {
|
||||
select {
|
||||
case data := <-diskReadChan:
|
||||
|
|
Binary file not shown.
|
@ -1 +1 @@
|
|||
c0803a9185298398eff9a925de69bd0ca882cd5983b989a45b748648146475c6
|
||||
4b925c7aa73d2e35a227833e806658cb17e1d25900611f93ed70b11ac9f1716d
|
||||
|
|
68
serial.go
68
serial.go
|
@ -16,14 +16,14 @@ const serialPortPath = "/dev/ttyS3"
|
|||
var port serial.Port
|
||||
|
||||
func mountATXControl() error {
|
||||
port.SetMode(defaultMode)
|
||||
_ = port.SetMode(defaultMode)
|
||||
go runATXControl()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func unmountATXControl() error {
|
||||
reopenSerialPort()
|
||||
_ = reopenSerialPort()
|
||||
return nil
|
||||
}
|
||||
|
||||
|
@ -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
|
||||
|
@ -122,58 +127,59 @@ func pressATXResetButton(duration time.Duration) error {
|
|||
}
|
||||
|
||||
func mountDCControl() error {
|
||||
port.SetMode(defaultMode)
|
||||
_ = port.SetMode(defaultMode)
|
||||
go runDCControl()
|
||||
return nil
|
||||
}
|
||||
|
||||
func unmountDCControl() error {
|
||||
reopenSerialPort()
|
||||
_ = reopenSerialPort()
|
||||
return nil
|
||||
}
|
||||
|
||||
var dcState DCPowerState
|
||||
|
||||
func runDCControl() {
|
||||
scopedLogger := serialLogger.With().Str("service", "dc_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
|
||||
}
|
||||
|
||||
// Split the line by semicolon
|
||||
parts := strings.Split(strings.TrimSpace(line), ";")
|
||||
if len(parts) != 4 {
|
||||
logger.Warnf("Invalid line: %s", line)
|
||||
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
|
||||
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
|
||||
|
@ -212,11 +218,12 @@ var defaultMode = &serial.Mode{
|
|||
}
|
||||
|
||||
func initSerialPort() {
|
||||
reopenSerialPort()
|
||||
if config.ActiveExtension == "atx-power" {
|
||||
mountATXControl()
|
||||
} else if config.ActiveExtension == "dc-power" {
|
||||
mountDCControl()
|
||||
_ = reopenSerialPort()
|
||||
switch config.ActiveExtension {
|
||||
case "atx-power":
|
||||
_ = mountATXControl()
|
||||
case "dc-power":
|
||||
_ = mountDCControl()
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -227,12 +234,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 +254,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 +273,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")
|
||||
})
|
||||
}
|
||||
|
|
26
terminal.go
26
terminal.go
|
@ -16,6 +16,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 +26,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 +37,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
|
||||
}
|
||||
}
|
||||
|
@ -55,17 +58,19 @@ func handleTerminalChannel(d *webrtc.DataChannel) {
|
|||
var size TerminalSize
|
||||
err := json.Unmarshal([]byte(msg.Data), &size)
|
||||
if err == nil {
|
||||
pty.Setsize(ptmx, &pty.Winsize{
|
||||
err = pty.Setsize(ptmx, &pty.Winsize{
|
||||
Rows: uint16(size.Rows),
|
||||
Cols: uint16(size.Cols),
|
||||
})
|
||||
return
|
||||
if err == nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
logger.Errorf("Failed to parse terminal size: %v", err)
|
||||
scopedLogger.Warn().Err(err).Msg("Failed to parse terminal size")
|
||||
}
|
||||
_, 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")
|
||||
}
|
||||
})
|
||||
|
||||
|
@ -74,7 +79,12 @@ func handleTerminalChannel(d *webrtc.DataChannel) {
|
|||
ptmx.Close()
|
||||
}
|
||||
if cmd != nil && cmd.Process != nil {
|
||||
cmd.Process.Kill()
|
||||
_ = cmd.Process.Kill()
|
||||
}
|
||||
scopedLogger.Info().Msg("Terminal channel closed")
|
||||
})
|
||||
|
||||
d.OnError(func(err error) {
|
||||
scopedLogger.Warn().Err(err).Msg("Terminal channel error")
|
||||
})
|
||||
}
|
||||
|
|
|
@ -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(×ync.TimeSyncOptions{
|
||||
Logger: timesyncLogger,
|
||||
NetworkConfig: config.NetworkConfig,
|
||||
PreCheckFunc: func() (bool, error) {
|
||||
if !networkState.IsOnline() {
|
||||
return false, nil
|
||||
}
|
||||
return true, nil
|
||||
},
|
||||
})
|
||||
}
|
|
@ -0,0 +1,4 @@
|
|||
# No need for VITE_CLOUD_APP it's only needed for the device build
|
||||
|
||||
# We use this for all the cloud API requests from the browser
|
||||
VITE_CLOUD_API=http://localhost:3000
|
|
@ -0,0 +1,4 @@
|
|||
# No need for VITE_CLOUD_APP it's only needed for the device build
|
||||
|
||||
# We use this for all the cloud API requests from the browser
|
||||
VITE_CLOUD_API=https://api.jetkvm.com
|
|
@ -0,0 +1,4 @@
|
|||
# No need for VITE_CLOUD_APP it's only needed for the device build
|
||||
|
||||
# We use this for all the cloud API requests from the browser
|
||||
VITE_CLOUD_API=https://staging-api.jetkvm.com
|
|
@ -1,6 +0,0 @@
|
|||
VITE_SIGNAL_API=http://localhost:3000
|
||||
|
||||
VITE_CLOUD_APP=http://localhost:5173
|
||||
VITE_CLOUD_API=http://localhost:3000
|
||||
|
||||
VITE_JETKVM_HEAD=
|
|
@ -1,6 +0,0 @@
|
|||
VITE_SIGNAL_API= # Uses the KVM device's IP address as the signal API endpoint
|
||||
|
||||
VITE_CLOUD_APP=https://app.jetkvm.com
|
||||
VITE_CLOUD_API=https://api.jetkvm.com
|
||||
|
||||
VITE_JETKVM_HEAD=<script src="/device/ui-config.js"></script>
|
|
@ -1,6 +0,0 @@
|
|||
VITE_SIGNAL_API=https://api.jetkvm.com
|
||||
|
||||
VITE_CLOUD_APP=https://app.jetkvm.com
|
||||
VITE_CLOUD_API=https://api.jetkvm.com
|
||||
|
||||
VITE_JETKVM_HEAD=
|
|
@ -1,4 +0,0 @@
|
|||
VITE_SIGNAL_API=https://staging-api.jetkvm.com
|
||||
|
||||
VITE_CLOUD_APP=https://staging-app.jetkvm.com
|
||||
VITE_CLOUD_API=https://staging-api.jetkvm.com
|
|
@ -8,6 +8,8 @@ module.exports = {
|
|||
"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",
|
||||
|
@ -20,5 +22,45 @@ module.exports = {
|
|||
},
|
||||
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"],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
|
@ -5,11 +5,7 @@
|
|||
"useTabs": false,
|
||||
"arrowParens": "avoid",
|
||||
"singleQuote": false,
|
||||
"plugins": [
|
||||
"prettier-plugin-tailwindcss"
|
||||
],
|
||||
"tailwindFunctions": [
|
||||
"clsx"
|
||||
],
|
||||
"plugins": ["prettier-plugin-tailwindcss"],
|
||||
"tailwindFunctions": ["clsx"],
|
||||
"printWidth": 90
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
#!/bin/bash
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# Check if an IP address was provided as an argument
|
||||
if [ -z "$1" ]; then
|
||||
|
@ -15,5 +15,15 @@ echo "└───────────────────────
|
|||
|
||||
# Set the environment variable and run Vite
|
||||
echo "Starting development server with JetKVM device at: $ip_address"
|
||||
|
||||
# Check if pwd is the current directory of the script
|
||||
if [ "$(pwd)" != "$(dirname "$0")" ]; then
|
||||
pushd "$(dirname "$0")" > /dev/null
|
||||
echo "Changed directory to: $(pwd)"
|
||||
fi
|
||||
|
||||
sleep 1
|
||||
JETKVM_PROXY_URL="http://$ip_address" npx vite dev --mode=device
|
||||
|
||||
JETKVM_PROXY_URL="ws://$ip_address" npx vite dev --mode=device
|
||||
|
||||
popd > /dev/null
|
||||
|
|
|
@ -28,7 +28,6 @@
|
|||
<title>JetKVM</title>
|
||||
<link rel="stylesheet" href="/fonts/fonts.css" />
|
||||
<link rel="icon" href="/favicon.png" />
|
||||
%VITE_JETKVM_HEAD%
|
||||
<script>
|
||||
// Initial theme setup
|
||||
document.documentElement.classList.toggle(
|
||||
|
|
File diff suppressed because it is too large
Load Diff
|
@ -8,18 +8,21 @@
|
|||
},
|
||||
"scripts": {
|
||||
"dev": "./dev_device.sh",
|
||||
"dev:cloud": "vite dev --mode=development",
|
||||
"dev:ssl": "USE_SSL=true ./dev_device.sh",
|
||||
"dev:cloud": "vite dev --mode=cloud-development",
|
||||
"build": "npm run build:prod",
|
||||
"build:device": "tsc && vite build --mode=device --emptyOutDir",
|
||||
"build:staging": "tsc && vite build --mode=staging",
|
||||
"build:prod": "tsc && vite build --mode=production",
|
||||
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
|
||||
"build:staging": "tsc && vite build --mode=cloud-staging",
|
||||
"build:prod": "tsc && vite build --mode=cloud-production",
|
||||
"lint": "eslint './src/**/*.{ts,tsx}'",
|
||||
"lint:fix": "eslint './src/**/*.{ts,tsx}' --fix",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@headlessui/react": "^2.2.0",
|
||||
"@headlessui/tailwindcss": "^0.2.1",
|
||||
"@heroicons/react": "^2.2.0",
|
||||
"@vitejs/plugin-basic-ssl": "^1.2.0",
|
||||
"@xterm/addon-clipboard": "^0.1.0",
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
"@xterm/addon-unicode11": "^0.8.0",
|
||||
|
@ -27,6 +30,8 @@
|
|||
"@xterm/addon-webgl": "^0.18.0",
|
||||
"@xterm/xterm": "^5.5.0",
|
||||
"cva": "^1.0.0-beta.1",
|
||||
"dayjs": "^1.11.13",
|
||||
"eslint-import-resolver-alias": "^1.1.2",
|
||||
"focus-trap-react": "^10.2.3",
|
||||
"framer-motion": "^11.15.0",
|
||||
"lodash.throttle": "^4.1.1",
|
||||
|
@ -38,6 +43,7 @@
|
|||
"react-icons": "^5.4.0",
|
||||
"react-router-dom": "^6.22.3",
|
||||
"react-simple-keyboard": "^3.7.112",
|
||||
"react-use-websocket": "^4.13.0",
|
||||
"react-xtermjs": "^1.0.9",
|
||||
"recharts": "^2.15.0",
|
||||
"tailwind-merge": "^2.5.5",
|
||||
|
@ -51,21 +57,24 @@
|
|||
"@tailwindcss/typography": "^0.5.15",
|
||||
"@types/react": "^18.2.66",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@types/semver": "^7.5.8",
|
||||
"@types/validator": "^13.12.2",
|
||||
"@typescript-eslint/eslint-plugin": "^7.2.0",
|
||||
"@typescript-eslint/parser": "^7.2.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.25.0",
|
||||
"@typescript-eslint/parser": "^8.25.0",
|
||||
"@vitejs/plugin-react-swc": "^3.7.2",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-plugin-react": "^7.34.1",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.6",
|
||||
"eslint": "^8.20.0",
|
||||
"eslint-config-prettier": "^10.0.1",
|
||||
"eslint-plugin-import": "^2.31.0",
|
||||
"eslint-plugin-react": "^7.37.4",
|
||||
"eslint-plugin-react-hooks": "^5.1.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.19",
|
||||
"postcss": "^8.4.49",
|
||||
"prettier": "^3.4.2",
|
||||
"prettier-plugin-tailwindcss": "^0.5.13",
|
||||
"prettier-plugin-tailwindcss": "^0.6.11",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"typescript": "^5.7.2",
|
||||
"vite": "^5.2.0",
|
||||
"vite-tsconfig-paths": "^4.3.2"
|
||||
"vite-tsconfig-paths": "^5.1.4"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1 @@
|
|||
../../internal/logging/sse.html
|
File diff suppressed because one or more lines are too long
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 10 KiB |
|
@ -1,3 +1,10 @@
|
|||
import { MdOutlineContentPasteGo } from "react-icons/md";
|
||||
import { LuCable, LuHardDrive, LuMaximize, LuSettings, LuSignal } from "react-icons/lu";
|
||||
import { FaKeyboard } from "react-icons/fa6";
|
||||
import { Popover, PopoverButton, PopoverPanel } from "@headlessui/react";
|
||||
import { Fragment, useCallback, useRef } from "react";
|
||||
import { CommandLineIcon } from "@heroicons/react/20/solid";
|
||||
|
||||
import { Button } from "@components/Button";
|
||||
import {
|
||||
useHidStore,
|
||||
|
@ -5,24 +12,20 @@ import {
|
|||
useSettingsStore,
|
||||
useUiStore,
|
||||
} from "@/hooks/stores";
|
||||
import { MdOutlineContentPasteGo } from "react-icons/md";
|
||||
import Container from "@components/Container";
|
||||
import { LuCable, LuHardDrive, LuMaximize, LuSettings, LuSignal } from "react-icons/lu";
|
||||
import { cx } from "@/cva.config";
|
||||
import PasteModal from "@/components/popovers/PasteModal";
|
||||
import { FaKeyboard } from "react-icons/fa6";
|
||||
import WakeOnLanModal from "@/components/popovers/WakeOnLan/Index";
|
||||
import { Popover, PopoverButton, PopoverPanel } from "@headlessui/react";
|
||||
import MountPopopover from "./popovers/MountPopover";
|
||||
import { Fragment, useCallback, useRef } from "react";
|
||||
import { CommandLineIcon } from "@heroicons/react/20/solid";
|
||||
import ExtensionPopover from "./popovers/ExtensionPopover";
|
||||
import MountPopopover from "@/components/popovers/MountPopover";
|
||||
import ExtensionPopover from "@/components/popovers/ExtensionPopover";
|
||||
import { useDeviceUiNavigation } from "@/hooks/useAppNavigation";
|
||||
|
||||
export default function Actionbar({
|
||||
requestFullscreen,
|
||||
}: {
|
||||
requestFullscreen: () => Promise<void>;
|
||||
}) {
|
||||
const { navigateTo } = useDeviceUiNavigation();
|
||||
const virtualKeyboard = useHidStore(state => state.isVirtualKeyboardEnabled);
|
||||
|
||||
const setVirtualKeyboard = useHidStore(state => state.setVirtualKeyboardEnabled);
|
||||
|
@ -149,7 +152,7 @@ export default function Actionbar({
|
|||
<Button
|
||||
size="XS"
|
||||
theme="light"
|
||||
text="Wake on Lan"
|
||||
text="Wake on LAN"
|
||||
onClick={() => {
|
||||
setDisableFocusTrap(true);
|
||||
}}
|
||||
|
@ -260,15 +263,16 @@ export default function Actionbar({
|
|||
/>
|
||||
</div>
|
||||
|
||||
<div className="hidden xs:block ">
|
||||
<div>
|
||||
<Button
|
||||
size="XS"
|
||||
theme="light"
|
||||
text="Settings"
|
||||
LeadingIcon={LuSettings}
|
||||
onClick={() => toggleSidebarView("system")}
|
||||
onClick={() => navigateTo("/settings")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="hidden items-center gap-x-2 lg:flex">
|
||||
<div className="h-4 w-[1px] bg-slate-300 dark:bg-slate-600" />
|
||||
<Button
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue