Compare commits
66 Commits
bde0a086ab
...
fe77acd5f0
| Author | SHA1 | Date |
|---|---|---|
|
|
fe77acd5f0 | |
|
|
83caa8f82d | |
|
|
27750b9cc2 | |
|
|
5112bef19c | |
|
|
1ffdca4fd6 | |
|
|
c6dba4d59f | |
|
|
afb146d78c | |
|
|
72e3013337 | |
|
|
25b102ac34 | |
|
|
5c94c6c87f | |
|
|
cf679978be | |
|
|
80a8b9e9e3 | |
|
|
1717549578 | |
|
|
37b1a8bf34 | |
|
|
ca8b06f4cf | |
|
|
33e099f258 | |
|
|
ea068414dc | |
|
|
8d1a66806c | |
|
|
6202e3cafa | |
|
|
c866230711 | |
|
|
c8dd84c6b7 | |
|
|
c98592a412 | |
|
|
8fbad0112e | |
|
|
8a90555fad | |
|
|
a7db0e8408 | |
|
|
bcc307b147 | |
|
|
e8ef82e582 | |
|
|
5f3dd89d55 | |
|
|
1dda6184da | |
|
|
825d0311d6 | |
|
|
f3fe78af5d | |
|
|
d0b3781aaa | |
|
|
c68e15bf89 | |
|
|
94521ef6db | |
|
|
66cccfe9e1 | |
|
|
a42384fed6 | |
|
|
3ec243255b | |
|
|
05bf61152b | |
|
|
d952480c2a | |
|
|
8e27cd6b60 | |
|
|
bb87fb5a1a | |
|
|
8527b1eff1 | |
|
|
9f573200b1 | |
|
|
608f69db13 | |
|
|
f7b8efde7c | |
|
|
33ac9fe0b6 | |
|
|
55fbd6c359 | |
|
|
cff3ddad29 | |
|
|
b4525b8760 | |
|
|
5a3ce2d6ec | |
|
|
f1953fddbc | |
|
|
9ba97ebe67 | |
|
|
5fb8d866ba | |
|
|
3359f8fca4 | |
|
|
ef95643a86 | |
|
|
1fc603b553 | |
|
|
aada3d95e0 | |
|
|
d704fcc6c7 | |
|
|
ab3dda6dee | |
|
|
4a23f22a55 | |
|
|
11a095c0f6 | |
|
|
584768bacf | |
|
|
488276f3a8 | |
|
|
7267347261 | |
|
|
393bc122d4 | |
|
|
6d13e1be12 |
|
|
@ -0,0 +1,76 @@
|
||||||
|
---
|
||||||
|
name: Bug report
|
||||||
|
description: 🐛 Let us know about an unexpected error, a crash, or an unexpected behavior.
|
||||||
|
type: 'Bug'
|
||||||
|
labels:
|
||||||
|
- 'type: bug'
|
||||||
|
body:
|
||||||
|
- type: checkboxes
|
||||||
|
attributes:
|
||||||
|
label: Disclaimer
|
||||||
|
description: |
|
||||||
|
For support questions, please use the [discussions][] or [Discord][] instead. Before
|
||||||
|
opening a bug report, ensure you have read the [documentation][],
|
||||||
|
[Troubleshooting][] and [Device FAQs][]. Only use bug reports for actual
|
||||||
|
bugs.
|
||||||
|
|
||||||
|
[documentation]: https://jetkvm.com/docs
|
||||||
|
[Troubleshooting]: https://jetkvm.com/docs/getting-started/troubleshooting
|
||||||
|
[Device FAQs]: https://jetkvm.com/docs/getting-started/faq
|
||||||
|
[discussions]: https://github.com/jetkvm/kvm/discussions
|
||||||
|
[Discord]: https://jetkvm.com/discord
|
||||||
|
options:
|
||||||
|
- label: I have read and understood the disclaimer.
|
||||||
|
required: true
|
||||||
|
- type: input
|
||||||
|
attributes:
|
||||||
|
label: Application version
|
||||||
|
description: |
|
||||||
|
Provide the application version (can be found in General settings)
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: input
|
||||||
|
attributes:
|
||||||
|
label: System version
|
||||||
|
description: |
|
||||||
|
Provide the system version (can be found in General settings)
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: dropdown
|
||||||
|
attributes:
|
||||||
|
label: Device model
|
||||||
|
description: Provide the device model
|
||||||
|
options:
|
||||||
|
- JetKVM
|
||||||
|
- JetKVM (POE)
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
|
- type: dropdown
|
||||||
|
attributes:
|
||||||
|
label: Extension model
|
||||||
|
description: Provide the extension model (if the bug is related to the extension)
|
||||||
|
options:
|
||||||
|
- ATX Power Control
|
||||||
|
- DC Power Control
|
||||||
|
- Serial Console
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
|
- type: input
|
||||||
|
attributes:
|
||||||
|
label: Remote device Hardware
|
||||||
|
description: If the bug is related to a remote device, please provide its hardware information e.g. Raspberry Pi 5
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
|
- type: input
|
||||||
|
attributes:
|
||||||
|
label: Remote device OS
|
||||||
|
description: If the bug is related to a remote device, please provide its OS information as detailed as possible e.g. Debian 12.
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
|
- type: textarea
|
||||||
|
attributes:
|
||||||
|
label: Bug description
|
||||||
|
description: |
|
||||||
|
Provide a description of the problem: steps to reproduce it, what you are expecting and what you got.
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
blank_issues_enabled: true
|
||||||
|
|
||||||
|
contact_links:
|
||||||
|
- name: Hardware Issues
|
||||||
|
url: https://jetkvm.com/contact
|
||||||
|
about: If your hardware is not powering on or is not working, please contact us.
|
||||||
|
|
||||||
|
- name: Discord
|
||||||
|
url: https://jetkvm.com/discord
|
||||||
|
about: Engage with the JetKVM team and other community members.
|
||||||
|
|
@ -0,0 +1,46 @@
|
||||||
|
name: Feature
|
||||||
|
type: 'Feature'
|
||||||
|
description: 🚀 Request a new feature.
|
||||||
|
labels:
|
||||||
|
- 'type: feature'
|
||||||
|
body:
|
||||||
|
- type: textarea
|
||||||
|
attributes:
|
||||||
|
label: A note for the community
|
||||||
|
value: |
|
||||||
|
> [!NOTE]
|
||||||
|
> Please vote on this issue by adding a 👍 [reaction](https://blog.github.com/2016-03-10-add-reactions-to-pull-requests-issues-and-comments/) to the original issue to help the community and maintainers prioritize this request.
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: checkboxes
|
||||||
|
attributes:
|
||||||
|
label: Disclaimer
|
||||||
|
description: |
|
||||||
|
Before requesting a feature, check it does not already exist in the [documentation](https://jetkvm.com/docs) or our [roadmap](https://jetkvm.com/roadmap).
|
||||||
|
You are quite welcome opening a feature request before spending time to implement it yourself.
|
||||||
|
options:
|
||||||
|
- label: I have read and understood the disclaimer.
|
||||||
|
required: true
|
||||||
|
- label: I plan to implement the feature myself.
|
||||||
|
- type: dropdown
|
||||||
|
attributes:
|
||||||
|
label: Subsystem
|
||||||
|
description: Provide the subsystem of the feature you request, you can choose multiple if you think it fits in multiple areas.
|
||||||
|
options:
|
||||||
|
- Hardware
|
||||||
|
- Device Compatibility
|
||||||
|
- Keyboard
|
||||||
|
- Mouse
|
||||||
|
- Power
|
||||||
|
- UI: Screen
|
||||||
|
- UI: Application
|
||||||
|
- UI: Cloud
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
|
- type: textarea
|
||||||
|
attributes:
|
||||||
|
label: Feature description
|
||||||
|
description: |
|
||||||
|
Provide a description of the feature you request.
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
@ -15,7 +15,7 @@ jobs:
|
||||||
if: github.event_name != 'pull_request_review' || github.event.review.state == 'approved'
|
if: github.event_name != 'pull_request_review' || github.event.review.state == 'approved'
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v5
|
||||||
- name: Set up Node.js
|
- name: Set up Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,7 @@ jobs:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
uses: actions/checkout@ff7abcd0c3c05ccf6adc123a8cd1fd4fb30fb493 # v4.2.2
|
||||||
- name: Install Go
|
- name: Install Go
|
||||||
uses: actions/setup-go@fa96338abe5531f6e34c5cc0bbe28c1a533d5505 # v4.2.1
|
uses: actions/setup-go@fa96338abe5531f6e34c5cc0bbe28c1a533d5505 # v4.2.1
|
||||||
with:
|
with:
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ jobs:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v5
|
||||||
- name: Set up Node.js
|
- name: Set up Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,9 @@ linters:
|
||||||
- linters:
|
- linters:
|
||||||
- errcheck
|
- errcheck
|
||||||
path: _test.go
|
path: _test.go
|
||||||
|
- linters:
|
||||||
|
- forbidigo
|
||||||
|
path: cmd/main.go
|
||||||
- linters:
|
- linters:
|
||||||
- gochecknoinits
|
- gochecknoinits
|
||||||
path: internal/logging/sse.go
|
path: internal/logging/sse.go
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,356 @@
|
||||||
|
<div align="center">
|
||||||
|
<img alt="JetKVM logo" src="https://jetkvm.com/logo-blue.png" height="28">
|
||||||
|
|
||||||
|
### Development Guide
|
||||||
|
|
||||||
|
[Discord](https://jetkvm.com/discord) | [Website](https://jetkvm.com) | [Issues](https://github.com/jetkvm/cloud-api/issues) | [Docs](https://jetkvm.com/docs)
|
||||||
|
|
||||||
|
[](https://twitter.com/jetkvm)
|
||||||
|
|
||||||
|
[](https://goreportcard.com/report/github.com/jetkvm/kvm)
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
# JetKVM Development Guide
|
||||||
|
|
||||||
|
Welcome to JetKVM development! This guide will help you get started quickly, whether you're fixing bugs, adding features, or just exploring the codebase.
|
||||||
|
|
||||||
|
## Get Started
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
- **A JetKVM device** (for full development)
|
||||||
|
- **[Go 1.24.4+](https://go.dev/doc/install)** and **[Node.js 22.15.0](https://nodejs.org/en/download/)**
|
||||||
|
- **[Git](https://git-scm.com/downloads)** for version control
|
||||||
|
- **[SSH access](https://jetkvm.com/docs/advanced-usage/developing#developer-mode)** to your JetKVM device
|
||||||
|
|
||||||
|
### Development Environment
|
||||||
|
|
||||||
|
**Recommended:** Development is best done on **Linux** or **macOS**.
|
||||||
|
|
||||||
|
If you're using Windows, we strongly recommend using **WSL (Windows Subsystem for Linux)** for the best development experience:
|
||||||
|
- [Install WSL on Windows](https://docs.microsoft.com/en-us/windows/wsl/install)
|
||||||
|
- [WSL Setup Guide](https://docs.microsoft.com/en-us/windows/wsl/setup/environment)
|
||||||
|
|
||||||
|
This ensures compatibility with shell scripts and build tools used in the project.
|
||||||
|
|
||||||
|
### Project Setup
|
||||||
|
|
||||||
|
1. **Clone the repository:**
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/jetkvm/kvm.git
|
||||||
|
cd kvm
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Check your tools:**
|
||||||
|
```bash
|
||||||
|
go version && node --version
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Find your JetKVM IP address** (check your router or device screen)
|
||||||
|
|
||||||
|
4. **Deploy and test:**
|
||||||
|
```bash
|
||||||
|
./dev_deploy.sh -r 192.168.1.100 # Replace with your device IP
|
||||||
|
```
|
||||||
|
|
||||||
|
5. **Open in browser:** `http://192.168.1.100`
|
||||||
|
|
||||||
|
That's it! You're now running your own development version of JetKVM.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Common Tasks
|
||||||
|
|
||||||
|
### Modify the UI
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ui
|
||||||
|
npm install
|
||||||
|
./dev_device.sh 192.168.1.100 # Replace with your device IP
|
||||||
|
```
|
||||||
|
|
||||||
|
Now edit files in `ui/src/` and see changes live in your browser!
|
||||||
|
|
||||||
|
### Modify the backend
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Edit Go files (config.go, web.go, etc.)
|
||||||
|
./dev_deploy.sh -r 192.168.1.100 --skip-ui-build
|
||||||
|
```
|
||||||
|
|
||||||
|
### Run tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./dev_deploy.sh -r 192.168.1.100 --run-go-tests
|
||||||
|
```
|
||||||
|
|
||||||
|
### View logs
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh root@192.168.1.100
|
||||||
|
tail -f /var/log/jetkvm.log
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Project Layout
|
||||||
|
|
||||||
|
```
|
||||||
|
/kvm/
|
||||||
|
├── main.go # App entry point
|
||||||
|
├── config.go # Settings & configuration
|
||||||
|
├── web.go # API endpoints
|
||||||
|
├── ui/ # React frontend
|
||||||
|
│ ├── src/routes/ # Pages (login, settings, etc.)
|
||||||
|
│ └── src/components/ # UI components
|
||||||
|
└── internal/ # Internal Go packages
|
||||||
|
```
|
||||||
|
|
||||||
|
**Key files for beginners:**
|
||||||
|
|
||||||
|
- `web.go` - Add new API endpoints here
|
||||||
|
- `config.go` - Add new settings here
|
||||||
|
- `ui/src/routes/` - Add new pages here
|
||||||
|
- `ui/src/components/` - Add new UI components here
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Development Modes
|
||||||
|
|
||||||
|
### Full Development (Recommended)
|
||||||
|
|
||||||
|
*Best for: Complete feature development*
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Deploy everything to your JetKVM device
|
||||||
|
./dev_deploy.sh -r <YOUR_DEVICE_IP>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Frontend Only
|
||||||
|
|
||||||
|
*Best for: UI changes without device*
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ui
|
||||||
|
npm install
|
||||||
|
./dev_device.sh <YOUR_DEVICE_IP>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Quick Backend Changes
|
||||||
|
|
||||||
|
*Best for: API or backend logic changes*
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Skip frontend build for faster deployment
|
||||||
|
./dev_deploy.sh -r <YOUR_DEVICE_IP> --skip-ui-build
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Debugging Made Easy
|
||||||
|
|
||||||
|
### Check if everything is working
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Test connection to device
|
||||||
|
ping 192.168.1.100
|
||||||
|
|
||||||
|
# Check if JetKVM is running
|
||||||
|
ssh root@192.168.1.100 ps aux | grep jetkvm
|
||||||
|
```
|
||||||
|
|
||||||
|
### View live logs
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh root@192.168.1.100
|
||||||
|
tail -f /var/log/jetkvm.log
|
||||||
|
```
|
||||||
|
|
||||||
|
### Reset everything (if stuck)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh root@192.168.1.100
|
||||||
|
rm /userdata/kvm_config.json
|
||||||
|
systemctl restart jetkvm
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testing Your Changes
|
||||||
|
|
||||||
|
### Manual Testing
|
||||||
|
|
||||||
|
1. Deploy your changes: `./dev_deploy.sh -r <IP>`
|
||||||
|
2. Open browser: `http://<IP>`
|
||||||
|
3. Test your feature
|
||||||
|
4. Check logs: `ssh root@<IP> tail -f /var/log/jetkvm.log`
|
||||||
|
|
||||||
|
### Automated Testing
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run all tests
|
||||||
|
./dev_deploy.sh -r <IP> --run-go-tests
|
||||||
|
|
||||||
|
# Frontend linting
|
||||||
|
cd ui && npm run lint
|
||||||
|
```
|
||||||
|
|
||||||
|
### API Testing
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Test login endpoint
|
||||||
|
curl -X POST http://<IP>/auth/password-local \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"password": "test123"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Common Issues & Solutions
|
||||||
|
|
||||||
|
### "Build failed" or "Permission denied"
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Fix permissions
|
||||||
|
ssh root@<IP> chmod +x /userdata/jetkvm/bin/jetkvm_app_debug
|
||||||
|
|
||||||
|
# Clean and rebuild
|
||||||
|
go clean -modcache
|
||||||
|
go mod tidy
|
||||||
|
make build_dev
|
||||||
|
```
|
||||||
|
|
||||||
|
### "Can't connect to device"
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check network
|
||||||
|
ping <IP>
|
||||||
|
|
||||||
|
# Check SSH
|
||||||
|
ssh root@<IP> echo "Connection OK"
|
||||||
|
```
|
||||||
|
|
||||||
|
### "Frontend not updating"
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Clear cache and rebuild
|
||||||
|
cd ui
|
||||||
|
npm cache clean --force
|
||||||
|
rm -rf node_modules
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Next Steps
|
||||||
|
|
||||||
|
### Adding a New Feature
|
||||||
|
|
||||||
|
1. **Backend:** Add API endpoint in `web.go`
|
||||||
|
2. **Config:** Add settings in `config.go`
|
||||||
|
3. **Frontend:** Add UI in `ui/src/routes/`
|
||||||
|
4. **Test:** Deploy and test with `./dev_deploy.sh`
|
||||||
|
|
||||||
|
### Code Style
|
||||||
|
|
||||||
|
- **Go:** Follow standard Go conventions
|
||||||
|
- **TypeScript:** Use TypeScript for type safety
|
||||||
|
- **React:** Keep components small and reusable
|
||||||
|
|
||||||
|
### Environment Variables
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Enable debug logging
|
||||||
|
export LOG_TRACE_SCOPES="jetkvm,cloud,websocket,native,jsonrpc"
|
||||||
|
|
||||||
|
# Frontend development
|
||||||
|
export JETKVM_PROXY_URL="ws://<IP>"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Need Help?
|
||||||
|
|
||||||
|
1. **Check logs first:** `ssh root@<IP> tail -f /var/log/jetkvm.log`
|
||||||
|
2. **Search issues:** [GitHub Issues](https://github.com/jetkvm/kvm/issues)
|
||||||
|
3. **Ask on Discord:** [JetKVM Discord](https://jetkvm.com/discord)
|
||||||
|
4. **Read docs:** [JetKVM Documentation](https://jetkvm.com/docs)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Contributing
|
||||||
|
|
||||||
|
### Ready to contribute?
|
||||||
|
|
||||||
|
1. Fork the repository
|
||||||
|
2. Create a feature branch
|
||||||
|
3. Make your changes
|
||||||
|
4. Test thoroughly
|
||||||
|
5. Submit a pull request
|
||||||
|
|
||||||
|
### Before submitting:
|
||||||
|
|
||||||
|
- [ ] Code works on device
|
||||||
|
- [ ] Tests pass
|
||||||
|
- [ ] Code follows style guidelines
|
||||||
|
- [ ] Documentation updated (if needed)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Advanced Topics
|
||||||
|
|
||||||
|
### Performance Profiling
|
||||||
|
|
||||||
|
1. Enable `Developer Mode` on your JetKVM device
|
||||||
|
2. Add a password on the `Access` tab
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Access profiling
|
||||||
|
curl http://api:$JETKVM_PASSWORD@YOUR_DEVICE_IP/developer/pprof/
|
||||||
|
```
|
||||||
|
|
||||||
|
### Advanced Environment Variables
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Enable trace logging (useful for debugging)
|
||||||
|
export LOG_TRACE_SCOPES="jetkvm,cloud,websocket,native,jsonrpc"
|
||||||
|
|
||||||
|
# For frontend development
|
||||||
|
export JETKVM_PROXY_URL="ws://<JETKVM_IP>"
|
||||||
|
|
||||||
|
# Enable SSL in development
|
||||||
|
export USE_SSL=true
|
||||||
|
```
|
||||||
|
|
||||||
|
### Configuration Management
|
||||||
|
|
||||||
|
The application uses a JSON configuration file stored at `/userdata/kvm_config.json`.
|
||||||
|
|
||||||
|
#### Adding New Configuration Options
|
||||||
|
|
||||||
|
1. **Update the Config struct in `config.go`:**
|
||||||
|
|
||||||
|
```go
|
||||||
|
type Config struct {
|
||||||
|
// ... existing fields
|
||||||
|
NewFeatureEnabled bool `json:"new_feature_enabled"`
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Update the default configuration:**
|
||||||
|
|
||||||
|
```go
|
||||||
|
var defaultConfig = &Config{
|
||||||
|
// ... existing defaults
|
||||||
|
NewFeatureEnabled: false,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Add migration logic if needed for existing installations**
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Happy coding!**
|
||||||
|
|
||||||
|
For more information, visit the [JetKVM Documentation](https://jetkvm.com/docs) or join our [Discord Server](https://jetkvm.com/discord).
|
||||||
33
Makefile
|
|
@ -1,14 +1,14 @@
|
||||||
BRANCH ?= $(shell git rev-parse --abbrev-ref HEAD)
|
BRANCH := $(shell git rev-parse --abbrev-ref HEAD)
|
||||||
BUILDDATE ?= $(shell date -u +%FT%T%z)
|
BUILDDATE := $(shell date -u +%FT%T%z)
|
||||||
BUILDTS ?= $(shell date -u +%s)
|
BUILDTS := $(shell date -u +%s)
|
||||||
REVISION ?= $(shell git rev-parse HEAD)
|
REVISION := $(shell git rev-parse HEAD)
|
||||||
VERSION_DEV ?= 0.4.7-dev$(shell date +%Y%m%d%H%M)
|
VERSION_DEV := 0.4.9-dev$(shell date +%Y%m%d%H%M)
|
||||||
VERSION ?= 0.4.6
|
VERSION := 0.4.8
|
||||||
|
|
||||||
PROMETHEUS_TAG := github.com/prometheus/common/version
|
PROMETHEUS_TAG := github.com/prometheus/common/version
|
||||||
KVM_PKG_NAME := github.com/jetkvm/kvm
|
KVM_PKG_NAME := github.com/jetkvm/kvm
|
||||||
|
|
||||||
GO_BUILD_ARGS := -tags netgo
|
GO_BUILD_ARGS := -tags netgo -tags timetzdata
|
||||||
GO_RELEASE_BUILD_ARGS := -trimpath $(GO_BUILD_ARGS)
|
GO_RELEASE_BUILD_ARGS := -trimpath $(GO_BUILD_ARGS)
|
||||||
GO_LDFLAGS := \
|
GO_LDFLAGS := \
|
||||||
-s -w \
|
-s -w \
|
||||||
|
|
@ -62,10 +62,25 @@ build_dev_test: build_test2json build_gotestsum
|
||||||
tar czfv device-tests.tar.gz -C $(BIN_DIR)/tests .
|
tar czfv device-tests.tar.gz -C $(BIN_DIR)/tests .
|
||||||
|
|
||||||
frontend:
|
frontend:
|
||||||
cd ui && npm ci && npm run build:device
|
cd ui && npm ci && npm run build:device && \
|
||||||
|
find ../static/ \
|
||||||
|
-type f \
|
||||||
|
\( -name '*.js' \
|
||||||
|
-o -name '*.css' \
|
||||||
|
-o -name '*.html' \
|
||||||
|
-o -name '*.ico' \
|
||||||
|
-o -name '*.png' \
|
||||||
|
-o -name '*.jpg' \
|
||||||
|
-o -name '*.jpeg' \
|
||||||
|
-o -name '*.gif' \
|
||||||
|
-o -name '*.svg' \
|
||||||
|
-o -name '*.webp' \
|
||||||
|
-o -name '*.woff2' \
|
||||||
|
\) \
|
||||||
|
-exec sh -c 'gzip -9 -kfv {}' \;
|
||||||
|
|
||||||
dev_release: frontend build_dev
|
dev_release: frontend build_dev
|
||||||
@echo "Uploading release..."
|
@echo "Uploading release... $(VERSION_DEV)"
|
||||||
@shasum -a 256 bin/jetkvm_app | cut -d ' ' -f 1 > bin/jetkvm_app.sha256
|
@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
|
rclone copyto bin/jetkvm_app r2://jetkvm-update/app/$(VERSION_DEV)/jetkvm_app
|
||||||
rclone copyto bin/jetkvm_app.sha256 r2://jetkvm-update/app/$(VERSION_DEV)/jetkvm_app.sha256
|
rclone copyto bin/jetkvm_app.sha256 r2://jetkvm-update/app/$(VERSION_DEV)/jetkvm_app.sha256
|
||||||
|
|
|
||||||
|
|
@ -37,7 +37,9 @@ JetKVM is written in Go & TypeScript. with some bits and pieces written in C. An
|
||||||
|
|
||||||
The project contains two main parts, the backend software that runs on the KVM device and the frontend software that is served by the KVM device, and also the cloud.
|
The project contains two main parts, the backend software that runs on the KVM device and the frontend software that is served by the KVM device, and also the cloud.
|
||||||
|
|
||||||
For most of local device development, all you need is to use the `./dev_deploy.sh` script. It will build the frontend and backend and deploy them to the local KVM device. Run `./dev_deploy.sh --help` for more information.
|
For comprehensive development information, including setup, testing, debugging, and contribution guidelines, see **[DEVELOPMENT.md](DEVELOPMENT.md)**.
|
||||||
|
|
||||||
|
For quick device development, use the `./dev_deploy.sh` script. It will build the frontend and backend and deploy them to the local KVM device. Run `./dev_deploy.sh --help` for more information.
|
||||||
|
|
||||||
## Backend
|
## Backend
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -22,25 +22,12 @@ func (r remoteImageBackend) ReadAt(p []byte, off int64) (n int, err error) {
|
||||||
return 0, errors.New("image not mounted")
|
return 0, errors.New("image not mounted")
|
||||||
}
|
}
|
||||||
source := currentVirtualMediaState.Source
|
source := currentVirtualMediaState.Source
|
||||||
mountedImageSize := currentVirtualMediaState.Size
|
|
||||||
virtualMediaStateMutex.RUnlock()
|
virtualMediaStateMutex.RUnlock()
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
_, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
readLen := int64(len(p))
|
|
||||||
if off+readLen > mountedImageSize {
|
|
||||||
readLen = mountedImageSize - off
|
|
||||||
}
|
|
||||||
var data []byte
|
|
||||||
switch source {
|
switch source {
|
||||||
case WebRTC:
|
|
||||||
data, err = webRTCDiskReader.Read(ctx, off, readLen)
|
|
||||||
if err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
n = copy(p, data)
|
|
||||||
return n, nil
|
|
||||||
case HTTP:
|
case HTTP:
|
||||||
return httpRangeReader.ReadAt(p, off)
|
return httpRangeReader.ReadAt(p, off)
|
||||||
default:
|
default:
|
||||||
|
|
|
||||||
4
cloud.go
|
|
@ -475,6 +475,10 @@ func handleSessionRequest(
|
||||||
|
|
||||||
cloudLogger.Info().Interface("session", session).Msg("new session accepted")
|
cloudLogger.Info().Interface("session", session).Msg("new session accepted")
|
||||||
cloudLogger.Trace().Interface("session", session).Msg("new session accepted")
|
cloudLogger.Trace().Interface("session", session).Msg("new session accepted")
|
||||||
|
|
||||||
|
// Cancel any ongoing keyboard macro when session changes
|
||||||
|
cancelKeyboardMacro()
|
||||||
|
|
||||||
currentSession = session
|
currentSession = session
|
||||||
_ = wsjson.Write(context.Background(), c, gin.H{"type": "answer", "data": sd})
|
_ = wsjson.Write(context.Background(), c, gin.H{"type": "answer", "data": sd})
|
||||||
return nil
|
return nil
|
||||||
|
|
|
||||||
18
cmd/main.go
|
|
@ -1,9 +1,27 @@
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
|
||||||
"github.com/jetkvm/kvm"
|
"github.com/jetkvm/kvm"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
versionPtr := flag.Bool("version", false, "print version and exit")
|
||||||
|
versionJsonPtr := flag.Bool("version-json", false, "print version as json and exit")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
if *versionPtr || *versionJsonPtr {
|
||||||
|
versionData, err := kvm.GetVersionData(*versionJsonPtr)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("failed to get version data: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
fmt.Println(string(versionData))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
kvm.Main()
|
kvm.Main()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
55
config.go
|
|
@ -9,6 +9,8 @@ import (
|
||||||
"github.com/jetkvm/kvm/internal/logging"
|
"github.com/jetkvm/kvm/internal/logging"
|
||||||
"github.com/jetkvm/kvm/internal/network"
|
"github.com/jetkvm/kvm/internal/network"
|
||||||
"github.com/jetkvm/kvm/internal/usbgadget"
|
"github.com/jetkvm/kvm/internal/usbgadget"
|
||||||
|
"github.com/prometheus/client_golang/prometheus"
|
||||||
|
"github.com/prometheus/client_golang/prometheus/promauto"
|
||||||
)
|
)
|
||||||
|
|
||||||
type WakeOnLanDevice struct {
|
type WakeOnLanDevice struct {
|
||||||
|
|
@ -80,6 +82,7 @@ type Config struct {
|
||||||
CloudToken string `json:"cloud_token"`
|
CloudToken string `json:"cloud_token"`
|
||||||
GoogleIdentity string `json:"google_identity"`
|
GoogleIdentity string `json:"google_identity"`
|
||||||
JigglerEnabled bool `json:"jiggler_enabled"`
|
JigglerEnabled bool `json:"jiggler_enabled"`
|
||||||
|
JigglerConfig *JigglerConfig `json:"jiggler_config"`
|
||||||
AutoUpdateEnabled bool `json:"auto_update_enabled"`
|
AutoUpdateEnabled bool `json:"auto_update_enabled"`
|
||||||
IncludePreRelease bool `json:"include_pre_release"`
|
IncludePreRelease bool `json:"include_pre_release"`
|
||||||
HashedPassword string `json:"hashed_password"`
|
HashedPassword string `json:"hashed_password"`
|
||||||
|
|
@ -111,11 +114,19 @@ var defaultConfig = &Config{
|
||||||
ActiveExtension: "",
|
ActiveExtension: "",
|
||||||
KeyboardMacros: []KeyboardMacro{},
|
KeyboardMacros: []KeyboardMacro{},
|
||||||
DisplayRotation: "270",
|
DisplayRotation: "270",
|
||||||
KeyboardLayout: "en_US",
|
KeyboardLayout: "en-US",
|
||||||
DisplayMaxBrightness: 64,
|
DisplayMaxBrightness: 64,
|
||||||
DisplayDimAfterSec: 120, // 2 minutes
|
DisplayDimAfterSec: 120, // 2 minutes
|
||||||
DisplayOffAfterSec: 1800, // 30 minutes
|
DisplayOffAfterSec: 1800, // 30 minutes
|
||||||
TLSMode: "",
|
JigglerEnabled: false,
|
||||||
|
// This is the "Standard" jiggler option in the UI
|
||||||
|
JigglerConfig: &JigglerConfig{
|
||||||
|
InactivityLimitSeconds: 60,
|
||||||
|
JitterPercentage: 25,
|
||||||
|
ScheduleCronTab: "0 * * * * *",
|
||||||
|
Timezone: "UTC",
|
||||||
|
},
|
||||||
|
TLSMode: "",
|
||||||
UsbConfig: &usbgadget.Config{
|
UsbConfig: &usbgadget.Config{
|
||||||
VendorId: "0x1d6b", //The Linux Foundation
|
VendorId: "0x1d6b", //The Linux Foundation
|
||||||
ProductId: "0x0104", //Multifunction Composite Gadget
|
ProductId: "0x0104", //Multifunction Composite Gadget
|
||||||
|
|
@ -138,6 +149,21 @@ var (
|
||||||
configLock = &sync.Mutex{}
|
configLock = &sync.Mutex{}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
configSuccess = promauto.NewGauge(
|
||||||
|
prometheus.GaugeOpts{
|
||||||
|
Name: "jetkvm_config_last_reload_successful",
|
||||||
|
Help: "The last configuration load succeeded",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
configSuccessTime = promauto.NewGauge(
|
||||||
|
prometheus.GaugeOpts{
|
||||||
|
Name: "jetkvm_config_last_reload_success_timestamp_seconds",
|
||||||
|
Help: "Timestamp of last successful config load",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
func LoadConfig() {
|
func LoadConfig() {
|
||||||
configLock.Lock()
|
configLock.Lock()
|
||||||
defer configLock.Unlock()
|
defer configLock.Unlock()
|
||||||
|
|
@ -153,6 +179,8 @@ func LoadConfig() {
|
||||||
file, err := os.Open(configPath)
|
file, err := os.Open(configPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Debug().Msg("default config file doesn't exist, using default")
|
logger.Debug().Msg("default config file doesn't exist, using default")
|
||||||
|
configSuccess.Set(1.0)
|
||||||
|
configSuccessTime.SetToCurrentTime()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer file.Close()
|
defer file.Close()
|
||||||
|
|
@ -161,6 +189,7 @@ func LoadConfig() {
|
||||||
loadedConfig := *defaultConfig
|
loadedConfig := *defaultConfig
|
||||||
if err := json.NewDecoder(file).Decode(&loadedConfig); err != nil {
|
if err := json.NewDecoder(file).Decode(&loadedConfig); err != nil {
|
||||||
logger.Warn().Err(err).Msg("config file JSON parsing failed")
|
logger.Warn().Err(err).Msg("config file JSON parsing failed")
|
||||||
|
configSuccess.Set(0.0)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -177,10 +206,22 @@ func LoadConfig() {
|
||||||
loadedConfig.NetworkConfig = defaultConfig.NetworkConfig
|
loadedConfig.NetworkConfig = defaultConfig.NetworkConfig
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if loadedConfig.JigglerConfig == nil {
|
||||||
|
loadedConfig.JigglerConfig = defaultConfig.JigglerConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
// fixup old keyboard layout value
|
||||||
|
if loadedConfig.KeyboardLayout == "en_US" {
|
||||||
|
loadedConfig.KeyboardLayout = "en-US"
|
||||||
|
}
|
||||||
|
|
||||||
config = &loadedConfig
|
config = &loadedConfig
|
||||||
|
|
||||||
logging.GetRootLogger().UpdateLogLevel(config.DefaultLogLevel)
|
logging.GetRootLogger().UpdateLogLevel(config.DefaultLogLevel)
|
||||||
|
|
||||||
|
configSuccess.Set(1.0)
|
||||||
|
configSuccessTime.SetToCurrentTime()
|
||||||
|
|
||||||
logger.Info().Str("path", configPath).Msg("config loaded")
|
logger.Info().Str("path", configPath).Msg("config loaded")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -190,6 +231,11 @@ func SaveConfig() error {
|
||||||
|
|
||||||
logger.Trace().Str("path", configPath).Msg("Saving config")
|
logger.Trace().Str("path", configPath).Msg("Saving config")
|
||||||
|
|
||||||
|
// fixup old keyboard layout value
|
||||||
|
if config.KeyboardLayout == "en_US" {
|
||||||
|
config.KeyboardLayout = "en-US"
|
||||||
|
}
|
||||||
|
|
||||||
file, err := os.Create(configPath)
|
file, err := os.Create(configPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to create config file: %w", err)
|
return fmt.Errorf("failed to create config file: %w", err)
|
||||||
|
|
@ -202,6 +248,11 @@ func SaveConfig() error {
|
||||||
return fmt.Errorf("failed to encode config: %w", err)
|
return fmt.Errorf("failed to encode config: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err := file.Sync(); err != nil {
|
||||||
|
return fmt.Errorf("failed to wite config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Info().Str("path", configPath).Msg("config saved")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,53 @@
|
||||||
|
package kvm
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/prometheus/client_golang/prometheus"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
dcCurrentGauge = prometheus.NewGauge(prometheus.GaugeOpts{
|
||||||
|
Name: "jetkvm_dc_current_amperes",
|
||||||
|
Help: "Current DC power consumption in amperes",
|
||||||
|
})
|
||||||
|
|
||||||
|
dcPowerGauge = prometheus.NewGauge(prometheus.GaugeOpts{
|
||||||
|
Name: "jetkvm_dc_power_watts",
|
||||||
|
Help: "DC power consumption in watts",
|
||||||
|
})
|
||||||
|
|
||||||
|
dcVoltageGauge = prometheus.NewGauge(prometheus.GaugeOpts{
|
||||||
|
Name: "jetkvm_dc_voltage_volts",
|
||||||
|
Help: "DC voltage in volts",
|
||||||
|
})
|
||||||
|
|
||||||
|
dcStateGauge = prometheus.NewGauge(prometheus.GaugeOpts{
|
||||||
|
Name: "jetkvm_dc_power_state",
|
||||||
|
Help: "DC power state (1 = on, 0 = off)",
|
||||||
|
})
|
||||||
|
|
||||||
|
dcMetricsRegistered sync.Once
|
||||||
|
)
|
||||||
|
|
||||||
|
// registerDCMetrics registers the DC power metrics with Prometheus (called once when DC control is mounted)
|
||||||
|
func registerDCMetrics() {
|
||||||
|
dcMetricsRegistered.Do(func() {
|
||||||
|
prometheus.MustRegister(dcCurrentGauge)
|
||||||
|
prometheus.MustRegister(dcPowerGauge)
|
||||||
|
prometheus.MustRegister(dcVoltageGauge)
|
||||||
|
prometheus.MustRegister(dcStateGauge)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// updateDCMetrics updates the Prometheus metrics with current DC power state values
|
||||||
|
func updateDCMetrics(state DCPowerState) {
|
||||||
|
dcCurrentGauge.Set(state.Current)
|
||||||
|
dcPowerGauge.Set(state.Power)
|
||||||
|
dcVoltageGauge.Set(state.Voltage)
|
||||||
|
if state.IsOn {
|
||||||
|
dcStateGauge.Set(1)
|
||||||
|
} else {
|
||||||
|
dcStateGauge.Set(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -28,6 +28,7 @@ show_help() {
|
||||||
echo " --run-go-tests Run go tests"
|
echo " --run-go-tests Run go tests"
|
||||||
echo " --run-go-tests-only Run go tests and exit"
|
echo " --run-go-tests-only Run go tests and exit"
|
||||||
echo " --skip-ui-build Skip frontend/UI build"
|
echo " --skip-ui-build Skip frontend/UI build"
|
||||||
|
echo " -i, --install Build for release and install the app"
|
||||||
echo " --help Display this help message"
|
echo " --help Display this help message"
|
||||||
echo
|
echo
|
||||||
echo "Example:"
|
echo "Example:"
|
||||||
|
|
@ -43,6 +44,7 @@ RESET_USB_HID_DEVICE=false
|
||||||
LOG_TRACE_SCOPES="${LOG_TRACE_SCOPES:-jetkvm,cloud,websocket,native,jsonrpc}"
|
LOG_TRACE_SCOPES="${LOG_TRACE_SCOPES:-jetkvm,cloud,websocket,native,jsonrpc}"
|
||||||
RUN_GO_TESTS=false
|
RUN_GO_TESTS=false
|
||||||
RUN_GO_TESTS_ONLY=false
|
RUN_GO_TESTS_ONLY=false
|
||||||
|
INSTALL_APP=false
|
||||||
|
|
||||||
# Parse command line arguments
|
# Parse command line arguments
|
||||||
while [[ $# -gt 0 ]]; do
|
while [[ $# -gt 0 ]]; do
|
||||||
|
|
@ -72,6 +74,10 @@ while [[ $# -gt 0 ]]; do
|
||||||
RUN_GO_TESTS=true
|
RUN_GO_TESTS=true
|
||||||
shift
|
shift
|
||||||
;;
|
;;
|
||||||
|
-i|--install)
|
||||||
|
INSTALL_APP=true
|
||||||
|
shift
|
||||||
|
;;
|
||||||
--help)
|
--help)
|
||||||
show_help
|
show_help
|
||||||
exit 0
|
exit 0
|
||||||
|
|
@ -139,25 +145,36 @@ EOF
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
msg_info "▶ Building go binary"
|
if [ "$INSTALL_APP" = true ]
|
||||||
make build_dev
|
then
|
||||||
|
msg_info "▶ Building release binary"
|
||||||
# Kill any existing instances of the application
|
make build_release
|
||||||
ssh "${REMOTE_USER}@${REMOTE_HOST}" "killall jetkvm_app_debug || true"
|
|
||||||
|
# Copy the binary to the remote host as if we were the OTA updater.
|
||||||
# Copy the binary to the remote host
|
ssh "${REMOTE_USER}@${REMOTE_HOST}" "cat > /userdata/jetkvm/jetkvm_app.update" < bin/jetkvm_app
|
||||||
ssh "${REMOTE_USER}@${REMOTE_HOST}" "cat > ${REMOTE_PATH}/jetkvm_app_debug" < bin/jetkvm_app
|
|
||||||
|
# Reboot the device, the new app will be deployed by the startup process.
|
||||||
if [ "$RESET_USB_HID_DEVICE" = true ]; then
|
ssh "${REMOTE_USER}@${REMOTE_HOST}" "reboot"
|
||||||
msg_info "▶ Resetting USB HID device"
|
else
|
||||||
msg_warn "The option has been deprecated and will be removed in a future version, as JetKVM will now reset USB gadget configuration when needed"
|
msg_info "▶ Building development binary"
|
||||||
# Remove the old USB gadget configuration
|
make build_dev
|
||||||
ssh "${REMOTE_USER}@${REMOTE_HOST}" "rm -rf /sys/kernel/config/usb_gadget/jetkvm/configs/c.1/hid.usb*"
|
|
||||||
ssh "${REMOTE_USER}@${REMOTE_HOST}" "ls /sys/class/udc > /sys/kernel/config/usb_gadget/jetkvm/UDC"
|
# Kill any existing instances of the application
|
||||||
fi
|
ssh "${REMOTE_USER}@${REMOTE_HOST}" "killall jetkvm_app_debug || true"
|
||||||
|
|
||||||
# Deploy and run the application on the remote host
|
# Copy the binary to the remote host
|
||||||
ssh "${REMOTE_USER}@${REMOTE_HOST}" ash << EOF
|
ssh "${REMOTE_USER}@${REMOTE_HOST}" "cat > ${REMOTE_PATH}/jetkvm_app_debug" < bin/jetkvm_app
|
||||||
|
|
||||||
|
if [ "$RESET_USB_HID_DEVICE" = true ]; then
|
||||||
|
msg_info "▶ Resetting USB HID device"
|
||||||
|
msg_warn "The option has been deprecated and will be removed in a future version, as JetKVM will now reset USB gadget configuration when needed"
|
||||||
|
# Remove the old USB gadget configuration
|
||||||
|
ssh "${REMOTE_USER}@${REMOTE_HOST}" "rm -rf /sys/kernel/config/usb_gadget/jetkvm/configs/c.1/hid.usb*"
|
||||||
|
ssh "${REMOTE_USER}@${REMOTE_HOST}" "ls /sys/class/udc > /sys/kernel/config/usb_gadget/jetkvm/UDC"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Deploy and run the application on the remote host
|
||||||
|
ssh "${REMOTE_USER}@${REMOTE_HOST}" ash << EOF
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
# Set the library path to include the directory where librockit.so is located
|
# Set the library path to include the directory where librockit.so is located
|
||||||
|
|
@ -174,7 +191,8 @@ cd "${REMOTE_PATH}"
|
||||||
chmod +x jetkvm_app_debug
|
chmod +x jetkvm_app_debug
|
||||||
|
|
||||||
# Run the application in the background
|
# Run the application in the background
|
||||||
PION_LOG_TRACE=${LOG_TRACE_SCOPES} GODEBUG=netdns=1 ./jetkvm_app_debug
|
PION_LOG_TRACE=${LOG_TRACE_SCOPES} ./jetkvm_app_debug | tee -a /tmp/jetkvm_app_debug.log
|
||||||
EOF
|
EOF
|
||||||
|
fi
|
||||||
|
|
||||||
echo "Deployment complete."
|
echo "Deployment complete."
|
||||||
|
|
|
||||||
112
display.go
|
|
@ -1,6 +1,7 @@
|
||||||
package kvm
|
package kvm
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
|
@ -30,7 +31,7 @@ const (
|
||||||
// do not call this function directly, use switchToScreenIfDifferent instead
|
// do not call this function directly, use switchToScreenIfDifferent instead
|
||||||
// this function is not thread safe
|
// this function is not thread safe
|
||||||
func switchToScreen(screen string) {
|
func switchToScreen(screen string) {
|
||||||
_, err := CallCtrlAction("lv_scr_load", map[string]interface{}{"obj": screen})
|
_, err := CallCtrlAction("lv_scr_load", map[string]any{"obj": screen})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
displayLogger.Warn().Err(err).Str("screen", screen).Msg("failed to switch to screen")
|
displayLogger.Warn().Err(err).Str("screen", screen).Msg("failed to switch to screen")
|
||||||
return
|
return
|
||||||
|
|
@ -39,15 +40,15 @@ func switchToScreen(screen string) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func lvObjSetState(objName string, state string) (*CtrlResponse, error) {
|
func lvObjSetState(objName string, state string) (*CtrlResponse, error) {
|
||||||
return CallCtrlAction("lv_obj_set_state", map[string]interface{}{"obj": objName, "state": state})
|
return CallCtrlAction("lv_obj_set_state", map[string]any{"obj": objName, "state": state})
|
||||||
}
|
}
|
||||||
|
|
||||||
func lvObjAddFlag(objName string, flag string) (*CtrlResponse, error) {
|
func lvObjAddFlag(objName string, flag string) (*CtrlResponse, error) {
|
||||||
return CallCtrlAction("lv_obj_add_flag", map[string]interface{}{"obj": objName, "flag": flag})
|
return CallCtrlAction("lv_obj_add_flag", map[string]any{"obj": objName, "flag": flag})
|
||||||
}
|
}
|
||||||
|
|
||||||
func lvObjClearFlag(objName string, flag string) (*CtrlResponse, error) {
|
func lvObjClearFlag(objName string, flag string) (*CtrlResponse, error) {
|
||||||
return CallCtrlAction("lv_obj_clear_flag", map[string]interface{}{"obj": objName, "flag": flag})
|
return CallCtrlAction("lv_obj_clear_flag", map[string]any{"obj": objName, "flag": flag})
|
||||||
}
|
}
|
||||||
|
|
||||||
func lvObjHide(objName string) (*CtrlResponse, error) {
|
func lvObjHide(objName string) (*CtrlResponse, error) {
|
||||||
|
|
@ -59,27 +60,27 @@ func lvObjShow(objName string) (*CtrlResponse, error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func lvObjSetOpacity(objName string, opacity int) (*CtrlResponse, error) { // nolint:unused
|
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})
|
return CallCtrlAction("lv_obj_set_style_opa_layered", map[string]any{"obj": objName, "opa": opacity})
|
||||||
}
|
}
|
||||||
|
|
||||||
func lvObjFadeIn(objName string, duration uint32) (*CtrlResponse, error) {
|
func lvObjFadeIn(objName string, duration uint32) (*CtrlResponse, error) {
|
||||||
return CallCtrlAction("lv_obj_fade_in", map[string]interface{}{"obj": objName, "time": duration})
|
return CallCtrlAction("lv_obj_fade_in", map[string]any{"obj": objName, "duration": duration})
|
||||||
}
|
}
|
||||||
|
|
||||||
func lvObjFadeOut(objName string, duration uint32) (*CtrlResponse, error) {
|
func lvObjFadeOut(objName string, duration uint32) (*CtrlResponse, error) {
|
||||||
return CallCtrlAction("lv_obj_fade_out", map[string]interface{}{"obj": objName, "time": duration})
|
return CallCtrlAction("lv_obj_fade_out", map[string]any{"obj": objName, "duration": duration})
|
||||||
}
|
}
|
||||||
|
|
||||||
func lvLabelSetText(objName string, text string) (*CtrlResponse, error) {
|
func lvLabelSetText(objName string, text string) (*CtrlResponse, error) {
|
||||||
return CallCtrlAction("lv_label_set_text", map[string]interface{}{"obj": objName, "text": text})
|
return CallCtrlAction("lv_label_set_text", map[string]any{"obj": objName, "text": text})
|
||||||
}
|
}
|
||||||
|
|
||||||
func lvImgSetSrc(objName string, src string) (*CtrlResponse, error) {
|
func lvImgSetSrc(objName string, src string) (*CtrlResponse, error) {
|
||||||
return CallCtrlAction("lv_img_set_src", map[string]interface{}{"obj": objName, "src": src})
|
return CallCtrlAction("lv_img_set_src", map[string]any{"obj": objName, "src": src})
|
||||||
}
|
}
|
||||||
|
|
||||||
func lvDispSetRotation(rotation string) (*CtrlResponse, error) {
|
func lvDispSetRotation(rotation string) (*CtrlResponse, error) {
|
||||||
return CallCtrlAction("lv_disp_set_rotation", map[string]interface{}{"rotation": rotation})
|
return CallCtrlAction("lv_disp_set_rotation", map[string]any{"rotation": rotation})
|
||||||
}
|
}
|
||||||
|
|
||||||
func updateLabelIfChanged(objName string, newText string) {
|
func updateLabelIfChanged(objName string, newText string) {
|
||||||
|
|
@ -110,12 +111,6 @@ func clearDisplayState() {
|
||||||
currentScreen = "ui_Boot_Screen"
|
currentScreen = "ui_Boot_Screen"
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
|
||||||
cloudBlinkLock sync.Mutex = sync.Mutex{}
|
|
||||||
cloudBlinkStopped bool
|
|
||||||
cloudBlinkTicker *time.Ticker
|
|
||||||
)
|
|
||||||
|
|
||||||
func updateDisplay() {
|
func updateDisplay() {
|
||||||
updateLabelIfChanged("ui_Home_Content_Ip", networkState.IPv4String())
|
updateLabelIfChanged("ui_Home_Content_Ip", networkState.IPv4String())
|
||||||
if usbState == "configured" {
|
if usbState == "configured" {
|
||||||
|
|
@ -152,48 +147,81 @@ func updateDisplay() {
|
||||||
stopCloudBlink()
|
stopCloudBlink()
|
||||||
case CloudConnectionStateConnecting:
|
case CloudConnectionStateConnecting:
|
||||||
_, _ = lvImgSetSrc("ui_Home_Header_Cloud_Status_Icon", "cloud.png")
|
_, _ = lvImgSetSrc("ui_Home_Header_Cloud_Status_Icon", "cloud.png")
|
||||||
startCloudBlink()
|
restartCloudBlink()
|
||||||
case CloudConnectionStateConnected:
|
case CloudConnectionStateConnected:
|
||||||
_, _ = lvImgSetSrc("ui_Home_Header_Cloud_Status_Icon", "cloud.png")
|
_, _ = lvImgSetSrc("ui_Home_Header_Cloud_Status_Icon", "cloud.png")
|
||||||
stopCloudBlink()
|
stopCloudBlink()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func startCloudBlink() {
|
const (
|
||||||
if cloudBlinkTicker == nil {
|
cloudBlinkInterval = 2 * time.Second
|
||||||
cloudBlinkTicker = time.NewTicker(2 * time.Second)
|
cloudBlinkDuration = 1 * time.Second
|
||||||
} else {
|
)
|
||||||
// do nothing if the blink isn't stopped
|
|
||||||
if cloudBlinkStopped {
|
|
||||||
cloudBlinkLock.Lock()
|
|
||||||
defer cloudBlinkLock.Unlock()
|
|
||||||
|
|
||||||
cloudBlinkStopped = false
|
var (
|
||||||
cloudBlinkTicker.Reset(2 * time.Second)
|
cloudBlinkTicker *time.Ticker
|
||||||
|
cloudBlinkCancel context.CancelFunc
|
||||||
|
cloudBlinkLock = sync.Mutex{}
|
||||||
|
)
|
||||||
|
|
||||||
|
func doCloudBlink(ctx context.Context) {
|
||||||
|
for range cloudBlinkTicker.C {
|
||||||
|
if cloudConnectionState != CloudConnectionStateConnecting {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
_, _ = lvObjFadeOut("ui_Home_Header_Cloud_Status_Icon", uint32(cloudBlinkDuration.Milliseconds()))
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-time.After(cloudBlinkDuration):
|
||||||
|
}
|
||||||
|
|
||||||
|
_, _ = lvObjFadeIn("ui_Home_Header_Cloud_Status_Icon", uint32(cloudBlinkDuration.Milliseconds()))
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-time.After(cloudBlinkDuration):
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
go func() {
|
func restartCloudBlink() {
|
||||||
for range cloudBlinkTicker.C {
|
stopCloudBlink()
|
||||||
if cloudConnectionState != CloudConnectionStateConnecting {
|
startCloudBlink()
|
||||||
continue
|
}
|
||||||
}
|
|
||||||
_, _ = lvObjFadeOut("ui_Home_Header_Cloud_Status_Icon", 1000)
|
func startCloudBlink() {
|
||||||
time.Sleep(1000 * time.Millisecond)
|
cloudBlinkLock.Lock()
|
||||||
_, _ = lvObjFadeIn("ui_Home_Header_Cloud_Status_Icon", 1000)
|
defer cloudBlinkLock.Unlock()
|
||||||
time.Sleep(1000 * time.Millisecond)
|
|
||||||
}
|
if cloudBlinkTicker == nil {
|
||||||
}()
|
cloudBlinkTicker = time.NewTicker(cloudBlinkInterval)
|
||||||
|
} else {
|
||||||
|
cloudBlinkTicker.Reset(cloudBlinkInterval)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
cloudBlinkCancel = cancel
|
||||||
|
|
||||||
|
go doCloudBlink(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
func stopCloudBlink() {
|
func stopCloudBlink() {
|
||||||
|
cloudBlinkLock.Lock()
|
||||||
|
defer cloudBlinkLock.Unlock()
|
||||||
|
|
||||||
|
if cloudBlinkCancel != nil {
|
||||||
|
cloudBlinkCancel()
|
||||||
|
cloudBlinkCancel = nil
|
||||||
|
}
|
||||||
|
|
||||||
if cloudBlinkTicker != nil {
|
if cloudBlinkTicker != nil {
|
||||||
cloudBlinkTicker.Stop()
|
cloudBlinkTicker.Stop()
|
||||||
}
|
}
|
||||||
|
|
||||||
cloudBlinkLock.Lock()
|
|
||||||
defer cloudBlinkLock.Unlock()
|
|
||||||
cloudBlinkStopped = true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
|
|
|
||||||
114
fuse.go
|
|
@ -1,114 +0,0 @@
|
||||||
package kvm
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"os"
|
|
||||||
"sync"
|
|
||||||
"syscall"
|
|
||||||
|
|
||||||
"github.com/hanwen/go-fuse/v2/fs"
|
|
||||||
"github.com/hanwen/go-fuse/v2/fuse"
|
|
||||||
)
|
|
||||||
|
|
||||||
type WebRTCStreamFile struct {
|
|
||||||
fs.Inode
|
|
||||||
mu sync.Mutex
|
|
||||||
Attr fuse.Attr
|
|
||||||
size uint64
|
|
||||||
}
|
|
||||||
|
|
||||||
var _ = (fs.NodeOpener)((*WebRTCStreamFile)(nil))
|
|
||||||
var _ = (fs.NodeOpener)((*WebRTCStreamFile)(nil))
|
|
||||||
var _ = (fs.NodeOpener)((*WebRTCStreamFile)(nil))
|
|
||||||
var _ = (fs.NodeOpener)((*WebRTCStreamFile)(nil))
|
|
||||||
var _ = (fs.NodeOpener)((*WebRTCStreamFile)(nil))
|
|
||||||
|
|
||||||
func (f *WebRTCStreamFile) Open(ctx context.Context, flags uint32) (fh fs.FileHandle, fuseFlags uint32, errno syscall.Errno) {
|
|
||||||
return nil, fuse.FOPEN_KEEP_CACHE, fs.OK
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f *WebRTCStreamFile) Write(ctx context.Context, fh fs.FileHandle, data []byte, off int64) (uint32, syscall.Errno) {
|
|
||||||
return 0, syscall.EROFS
|
|
||||||
}
|
|
||||||
|
|
||||||
var _ = (fs.NodeGetattrer)((*WebRTCStreamFile)(nil))
|
|
||||||
|
|
||||||
func (f *WebRTCStreamFile) Getattr(ctx context.Context, fh fs.FileHandle, out *fuse.AttrOut) syscall.Errno {
|
|
||||||
f.mu.Lock()
|
|
||||||
defer f.mu.Unlock()
|
|
||||||
out.Attr = f.Attr
|
|
||||||
out.Size = f.size
|
|
||||||
return fs.OK
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f *WebRTCStreamFile) Setattr(ctx context.Context, fh fs.FileHandle, in *fuse.SetAttrIn, out *fuse.AttrOut) syscall.Errno {
|
|
||||||
f.mu.Lock()
|
|
||||||
defer f.mu.Unlock()
|
|
||||||
out.Attr = f.Attr
|
|
||||||
return fs.OK
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f *WebRTCStreamFile) Flush(ctx context.Context, fh fs.FileHandle) syscall.Errno {
|
|
||||||
return fs.OK
|
|
||||||
}
|
|
||||||
|
|
||||||
type DiskReadRequest struct {
|
|
||||||
Start uint64 `json:"start"`
|
|
||||||
End uint64 `json:"end"`
|
|
||||||
}
|
|
||||||
|
|
||||||
var diskReadChan = make(chan []byte, 1)
|
|
||||||
|
|
||||||
func (f *WebRTCStreamFile) Read(ctx context.Context, fh fs.FileHandle, dest []byte, off int64) (fuse.ReadResult, syscall.Errno) {
|
|
||||||
buf, err := webRTCDiskReader.Read(ctx, off, int64(len(dest)))
|
|
||||||
if err != nil {
|
|
||||||
return nil, syscall.EIO
|
|
||||||
}
|
|
||||||
return fuse.ReadResultData(buf), fs.OK
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f *WebRTCStreamFile) SetSize(size uint64) {
|
|
||||||
f.mu.Lock()
|
|
||||||
defer f.mu.Unlock()
|
|
||||||
f.size = size
|
|
||||||
}
|
|
||||||
|
|
||||||
type FuseRoot struct {
|
|
||||||
fs.Inode
|
|
||||||
}
|
|
||||||
|
|
||||||
var webRTCStreamFile = &WebRTCStreamFile{}
|
|
||||||
|
|
||||||
func (r *FuseRoot) OnAdd(ctx context.Context) {
|
|
||||||
ch := r.NewPersistentInode(ctx, webRTCStreamFile, fs.StableAttr{Ino: 2})
|
|
||||||
r.AddChild("disk", ch, false)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *FuseRoot) Getattr(ctx context.Context, fh fs.FileHandle, out *fuse.AttrOut) syscall.Errno {
|
|
||||||
out.Mode = 0755
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
var _ = (fs.NodeGetattrer)((*FuseRoot)(nil))
|
|
||||||
var _ = (fs.NodeOnAdder)((*FuseRoot)(nil))
|
|
||||||
|
|
||||||
const fuseMountPoint = "/mnt/webrtc"
|
|
||||||
|
|
||||||
var fuseServer *fuse.Server
|
|
||||||
|
|
||||||
func RunFuseServer() {
|
|
||||||
opts := &fs.Options{}
|
|
||||||
opts.DirectMountStrict = true
|
|
||||||
_ = os.Mkdir(fuseMountPoint, 0755)
|
|
||||||
var err error
|
|
||||||
fuseServer, err = fs.Mount(fuseMountPoint, &FuseRoot{}, opts)
|
|
||||||
if err != nil {
|
|
||||||
logger.Warn().Err(err).Msg("failed to mount fuse")
|
|
||||||
}
|
|
||||||
fuseServer.Wait()
|
|
||||||
}
|
|
||||||
|
|
||||||
type WebRTCImage struct {
|
|
||||||
Size uint64 `json:"size"`
|
|
||||||
Filename string `json:"filename"`
|
|
||||||
}
|
|
||||||
43
go.mod
|
|
@ -6,31 +6,33 @@ require (
|
||||||
github.com/Masterminds/semver/v3 v3.4.0
|
github.com/Masterminds/semver/v3 v3.4.0
|
||||||
github.com/beevik/ntp v1.4.3
|
github.com/beevik/ntp v1.4.3
|
||||||
github.com/coder/websocket v1.8.13
|
github.com/coder/websocket v1.8.13
|
||||||
github.com/coreos/go-oidc/v3 v3.14.1
|
github.com/coreos/go-oidc/v3 v3.15.0
|
||||||
github.com/creack/pty v1.1.24
|
github.com/creack/pty v1.1.24
|
||||||
github.com/fsnotify/fsnotify v1.9.0
|
github.com/fsnotify/fsnotify v1.9.0
|
||||||
github.com/gin-contrib/logger v1.2.6
|
github.com/gin-contrib/logger v1.2.6
|
||||||
github.com/gin-gonic/gin v1.10.1
|
github.com/gin-gonic/gin v1.10.1
|
||||||
|
github.com/go-co-op/gocron/v2 v2.16.5
|
||||||
|
github.com/google/flatbuffers v25.2.10+incompatible
|
||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
github.com/guregu/null/v6 v6.0.0
|
github.com/guregu/null/v6 v6.0.0
|
||||||
github.com/gwatts/rootcerts v0.0.0-20250601184604-370a9a75f341
|
github.com/gwatts/rootcerts v0.0.0-20250901182336-dc5ae18bd79f
|
||||||
github.com/hanwen/go-fuse/v2 v2.8.0
|
|
||||||
github.com/pion/logging v0.2.4
|
github.com/pion/logging v0.2.4
|
||||||
github.com/pion/mdns/v2 v2.0.7
|
github.com/pion/mdns/v2 v2.0.7
|
||||||
github.com/pion/webrtc/v4 v4.1.3
|
github.com/pion/webrtc/v4 v4.1.4
|
||||||
github.com/pojntfx/go-nbd v0.3.2
|
github.com/pojntfx/go-nbd v0.3.2
|
||||||
github.com/prometheus/client_golang v1.22.0
|
github.com/prometheus/client_golang v1.23.0
|
||||||
github.com/prometheus/common v0.65.0
|
github.com/prometheus/common v0.66.0
|
||||||
github.com/prometheus/procfs v0.16.1
|
github.com/prometheus/procfs v0.17.0
|
||||||
github.com/psanford/httpreadat v0.1.0
|
github.com/psanford/httpreadat v0.1.0
|
||||||
|
github.com/rs/xid v1.6.0
|
||||||
github.com/rs/zerolog v1.34.0
|
github.com/rs/zerolog v1.34.0
|
||||||
github.com/sourcegraph/tf-dag v0.2.2-0.20250131204052-3e8ff1477b4f
|
github.com/sourcegraph/tf-dag v0.2.2-0.20250131204052-3e8ff1477b4f
|
||||||
github.com/stretchr/testify v1.10.0
|
github.com/stretchr/testify v1.11.1
|
||||||
github.com/vishvananda/netlink v1.3.1
|
github.com/vishvananda/netlink v1.3.1
|
||||||
go.bug.st/serial v1.6.4
|
go.bug.st/serial v1.6.4
|
||||||
golang.org/x/crypto v0.39.0
|
golang.org/x/crypto v0.41.0
|
||||||
golang.org/x/net v0.41.0
|
golang.org/x/net v0.43.0
|
||||||
golang.org/x/sys v0.33.0
|
golang.org/x/sys v0.35.0
|
||||||
)
|
)
|
||||||
|
|
||||||
replace github.com/pojntfx/go-nbd v0.3.2 => github.com/chemhack/go-nbd v0.0.0-20241006125820-59e45f5b1e7b
|
replace github.com/pojntfx/go-nbd v0.3.2 => github.com/chemhack/go-nbd v0.0.0-20241006125820-59e45f5b1e7b
|
||||||
|
|
@ -50,6 +52,8 @@ require (
|
||||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||||
github.com/go-playground/validator/v10 v10.26.0 // indirect
|
github.com/go-playground/validator/v10 v10.26.0 // indirect
|
||||||
github.com/goccy/go-json v0.10.5 // indirect
|
github.com/goccy/go-json v0.10.5 // indirect
|
||||||
|
github.com/grafana/regexp v0.0.0-20240518133315-a468a5bfb3bc // indirect
|
||||||
|
github.com/jonboulle/clockwork v0.5.0 // indirect
|
||||||
github.com/json-iterator/go v1.1.12 // indirect
|
github.com/json-iterator/go v1.1.12 // indirect
|
||||||
github.com/klauspost/cpuid/v2 v2.2.10 // indirect
|
github.com/klauspost/cpuid/v2 v2.2.10 // indirect
|
||||||
github.com/leodido/go-urn v1.4.0 // indirect
|
github.com/leodido/go-urn v1.4.0 // indirect
|
||||||
|
|
@ -61,28 +65,31 @@ require (
|
||||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||||
github.com/pilebones/go-udev v0.9.1 // indirect
|
github.com/pilebones/go-udev v0.9.1 // indirect
|
||||||
github.com/pion/datachannel v1.5.10 // indirect
|
github.com/pion/datachannel v1.5.10 // indirect
|
||||||
github.com/pion/dtls/v3 v3.0.6 // indirect
|
github.com/pion/dtls/v3 v3.0.7 // indirect
|
||||||
github.com/pion/ice/v4 v4.0.10 // indirect
|
github.com/pion/ice/v4 v4.0.10 // indirect
|
||||||
github.com/pion/interceptor v0.1.40 // indirect
|
github.com/pion/interceptor v0.1.40 // indirect
|
||||||
github.com/pion/randutil v0.1.0 // indirect
|
github.com/pion/randutil v0.1.0 // indirect
|
||||||
github.com/pion/rtcp v1.2.15 // indirect
|
github.com/pion/rtcp v1.2.15 // indirect
|
||||||
github.com/pion/rtp v1.8.20 // indirect
|
github.com/pion/rtp v1.8.22 // indirect
|
||||||
github.com/pion/sctp v1.8.39 // indirect
|
github.com/pion/sctp v1.8.39 // indirect
|
||||||
github.com/pion/sdp/v3 v3.0.14 // indirect
|
github.com/pion/sdp/v3 v3.0.16 // indirect
|
||||||
github.com/pion/srtp/v3 v3.0.6 // indirect
|
github.com/pion/srtp/v3 v3.0.7 // indirect
|
||||||
github.com/pion/stun/v3 v3.0.0 // indirect
|
github.com/pion/stun/v3 v3.0.0 // indirect
|
||||||
github.com/pion/transport/v3 v3.0.7 // indirect
|
github.com/pion/transport/v3 v3.0.7 // indirect
|
||||||
github.com/pion/turn/v4 v4.0.2 // indirect
|
github.com/pion/turn/v4 v4.1.1 // indirect
|
||||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||||
github.com/prometheus/client_model v0.6.2 // indirect
|
github.com/prometheus/client_model v0.6.2 // indirect
|
||||||
|
github.com/robfig/cron/v3 v3.0.1 // indirect
|
||||||
github.com/rogpeppe/go-internal v1.14.1 // indirect
|
github.com/rogpeppe/go-internal v1.14.1 // indirect
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||||
github.com/ugorji/go/codec v1.3.0 // indirect
|
github.com/ugorji/go/codec v1.3.0 // indirect
|
||||||
|
github.com/vearutop/statigz v1.5.0 // indirect
|
||||||
github.com/vishvananda/netns v0.0.5 // indirect
|
github.com/vishvananda/netns v0.0.5 // indirect
|
||||||
github.com/wlynxg/anet v0.0.5 // indirect
|
github.com/wlynxg/anet v0.0.5 // indirect
|
||||||
golang.org/x/arch v0.18.0 // indirect
|
golang.org/x/arch v0.18.0 // indirect
|
||||||
golang.org/x/oauth2 v0.30.0 // indirect
|
golang.org/x/oauth2 v0.30.0 // indirect
|
||||||
golang.org/x/text v0.26.0 // indirect
|
golang.org/x/text v0.28.0 // indirect
|
||||||
google.golang.org/protobuf v1.36.6 // indirect
|
google.golang.org/protobuf v1.36.8 // indirect
|
||||||
|
gopkg.in/yaml.v2 v2.4.0 // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
)
|
)
|
||||||
|
|
|
||||||
89
go.sum
|
|
@ -18,8 +18,8 @@ github.com/cloudwego/base64x v0.1.5/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJ
|
||||||
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
|
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
|
||||||
github.com/coder/websocket v1.8.13 h1:f3QZdXy7uGVz+4uCJy2nTZyM0yTBj8yANEHhqlXZ9FE=
|
github.com/coder/websocket v1.8.13 h1:f3QZdXy7uGVz+4uCJy2nTZyM0yTBj8yANEHhqlXZ9FE=
|
||||||
github.com/coder/websocket v1.8.13/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs=
|
github.com/coder/websocket v1.8.13/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs=
|
||||||
github.com/coreos/go-oidc/v3 v3.14.1 h1:9ePWwfdwC4QKRlCXsJGou56adA/owXczOzwKdOumLqk=
|
github.com/coreos/go-oidc/v3 v3.15.0 h1:R6Oz8Z4bqWR7VFQ+sPSvZPQv4x8M+sJkDO5ojgwlyAg=
|
||||||
github.com/coreos/go-oidc/v3 v3.14.1/go.mod h1:HaZ3szPaZ0e4r6ebqvsLWlk2Tn+aejfmrfah6hnSYEU=
|
github.com/coreos/go-oidc/v3 v3.15.0/go.mod h1:HaZ3szPaZ0e4r6ebqvsLWlk2Tn+aejfmrfah6hnSYEU=
|
||||||
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
|
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
|
||||||
github.com/creack/goselect v0.1.3 h1:MaGNMclRo7P2Jl21hBpR1Cn33ITSbKP6E49RtfblLKc=
|
github.com/creack/goselect v0.1.3 h1:MaGNMclRo7P2Jl21hBpR1Cn33ITSbKP6E49RtfblLKc=
|
||||||
github.com/creack/goselect v0.1.3/go.mod h1:a/NhLweNvqIYMuxcMOuWY516Cimucms3DglDzQP3hKY=
|
github.com/creack/goselect v0.1.3/go.mod h1:a/NhLweNvqIYMuxcMOuWY516Cimucms3DglDzQP3hKY=
|
||||||
|
|
@ -38,6 +38,8 @@ github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w
|
||||||
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
|
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
|
||||||
github.com/gin-gonic/gin v1.10.1 h1:T0ujvqyCSqRopADpgPgiTT63DUQVSfojyME59Ei63pQ=
|
github.com/gin-gonic/gin v1.10.1 h1:T0ujvqyCSqRopADpgPgiTT63DUQVSfojyME59Ei63pQ=
|
||||||
github.com/gin-gonic/gin v1.10.1/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
|
github.com/gin-gonic/gin v1.10.1/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
|
||||||
|
github.com/go-co-op/gocron/v2 v2.16.5 h1:j228Jxk7bb9CF8LKR3gS+bK3rcjRUINjlVI+ZMp26Ss=
|
||||||
|
github.com/go-co-op/gocron/v2 v2.16.5/go.mod h1:zAfC/GFQ668qHxOVl/D68Jh5Ce7sDqX6TJnSQyRkRBc=
|
||||||
github.com/go-jose/go-jose/v4 v4.1.0 h1:cYSYxd3pw5zd2FSXk2vGdn9igQU2PS8MuxrCOCl0FdY=
|
github.com/go-jose/go-jose/v4 v4.1.0 h1:cYSYxd3pw5zd2FSXk2vGdn9igQU2PS8MuxrCOCl0FdY=
|
||||||
github.com/go-jose/go-jose/v4 v4.1.0/go.mod h1:GG/vqmYm3Von2nYiB2vGTXzdoNKE5tix5tuc6iAd+sw=
|
github.com/go-jose/go-jose/v4 v4.1.0/go.mod h1:GG/vqmYm3Von2nYiB2vGTXzdoNKE5tix5tuc6iAd+sw=
|
||||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||||
|
|
@ -51,17 +53,21 @@ github.com/go-playground/validator/v10 v10.26.0/go.mod h1:I5QpIEbmr8On7W0TktmJAu
|
||||||
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
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/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/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||||
|
github.com/google/flatbuffers v25.2.10+incompatible h1:F3vclr7C3HpB1k9mxCGRMXq6FdUalZ6H/pNX4FP1v0Q=
|
||||||
|
github.com/google/flatbuffers v25.2.10+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8=
|
||||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
github.com/google/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 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/grafana/regexp v0.0.0-20240518133315-a468a5bfb3bc h1:GN2Lv3MGO7AS6PrRoT6yV5+wkrOpcszoIsO4+4ds248=
|
||||||
|
github.com/grafana/regexp v0.0.0-20240518133315-a468a5bfb3bc/go.mod h1:+JKpmjMGhpgPL+rXZ5nsZieVzvarn86asRlBg4uNGnk=
|
||||||
github.com/guregu/null/v6 v6.0.0 h1:N14VRS+4di81i1PXRiprbQJ9EM9gqBa0+KVMeS/QSjQ=
|
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/guregu/null/v6 v6.0.0/go.mod h1:hrMIhIfrOZeLPZhROSn149tpw2gHkidAqxoXNyeX3iQ=
|
||||||
github.com/gwatts/rootcerts v0.0.0-20250601184604-370a9a75f341 h1:zPrkLSKi7kKJoNJH4uUmsQ86+0/QqpwEns0NyNLwKv0=
|
github.com/gwatts/rootcerts v0.0.0-20250901182336-dc5ae18bd79f h1:08t2PbrkDgW2+mwCQ3jhKUBrCM9Bc9SeH5j2Dst3B+0=
|
||||||
github.com/gwatts/rootcerts v0.0.0-20250601184604-370a9a75f341/go.mod h1:5Kt9XkWvkGi2OHOq0QsGxebHmhCcqJ8KCbNg/a6+n+g=
|
github.com/gwatts/rootcerts v0.0.0-20250901182336-dc5ae18bd79f/go.mod h1:5Kt9XkWvkGi2OHOq0QsGxebHmhCcqJ8KCbNg/a6+n+g=
|
||||||
github.com/hanwen/go-fuse/v2 v2.8.0 h1:wV8rG7rmCz8XHSOwBZhG5YcVqcYjkzivjmbaMafPlAs=
|
github.com/jonboulle/clockwork v0.5.0 h1:Hyh9A8u51kptdkR+cqRpT1EebBwTn1oK9YfGYbdFz6I=
|
||||||
github.com/hanwen/go-fuse/v2 v2.8.0/go.mod h1:yE6D2PqWwm3CbYRxFXV9xUd8Md5d6NG0WBs5spCswmI=
|
github.com/jonboulle/clockwork v0.5.0/go.mod h1:3mZlmanh0g2NDKO5TWZVJAfofYk64M7XN3SzBPjZF60=
|
||||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||||
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
|
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
|
||||||
|
|
@ -88,8 +94,6 @@ github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/
|
||||||
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
github.com/mattn/go-isatty v0.0.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 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
github.com/moby/sys/mountinfo v0.7.2 h1:1shs6aH5s4o5H2zQLn796ADW1wMrIwHsyJ2v9KouLrg=
|
|
||||||
github.com/moby/sys/mountinfo v0.7.2/go.mod h1:1YOa8w8Ih7uW0wALDUgT1dTTSBrZ+HiBLGws92L2RU4=
|
|
||||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
|
|
@ -103,8 +107,8 @@ github.com/pilebones/go-udev v0.9.1 h1:uN72M1C1fgzhsVmBGEM8w9RD1JY4iVsPZpr+Z6rb3
|
||||||
github.com/pilebones/go-udev v0.9.1/go.mod h1:Bgcl07crebF3JSeS4+nuaRvhWFdCeFoBhXXeAp93XNo=
|
github.com/pilebones/go-udev v0.9.1/go.mod h1:Bgcl07crebF3JSeS4+nuaRvhWFdCeFoBhXXeAp93XNo=
|
||||||
github.com/pion/datachannel v1.5.10 h1:ly0Q26K1i6ZkGf42W7D4hQYR90pZwzFOjTq5AuCKk4o=
|
github.com/pion/datachannel v1.5.10 h1:ly0Q26K1i6ZkGf42W7D4hQYR90pZwzFOjTq5AuCKk4o=
|
||||||
github.com/pion/datachannel v1.5.10/go.mod h1:p/jJfC9arb29W7WrxyKbepTU20CFgyx5oLo8Rs4Py/M=
|
github.com/pion/datachannel v1.5.10/go.mod h1:p/jJfC9arb29W7WrxyKbepTU20CFgyx5oLo8Rs4Py/M=
|
||||||
github.com/pion/dtls/v3 v3.0.6 h1:7Hkd8WhAJNbRgq9RgdNh1aaWlZlGpYTzdqjy9x9sK2E=
|
github.com/pion/dtls/v3 v3.0.7 h1:bItXtTYYhZwkPFk4t1n3Kkf5TDrfj6+4wG+CZR8uI9Q=
|
||||||
github.com/pion/dtls/v3 v3.0.6/go.mod h1:iJxNQ3Uhn1NZWOMWlLxEEHAN5yX7GyPvvKw04v9bzYU=
|
github.com/pion/dtls/v3 v3.0.7/go.mod h1:uDlH5VPrgOQIw59irKYkMudSFprY9IEFCqz/eTz16f8=
|
||||||
github.com/pion/ice/v4 v4.0.10 h1:P59w1iauC/wPk9PdY8Vjl4fOFL5B+USq1+xbDcN6gT4=
|
github.com/pion/ice/v4 v4.0.10 h1:P59w1iauC/wPk9PdY8Vjl4fOFL5B+USq1+xbDcN6gT4=
|
||||||
github.com/pion/ice/v4 v4.0.10/go.mod h1:y3M18aPhIxLlcO/4dn9X8LzLLSma84cx6emMSu14FGw=
|
github.com/pion/ice/v4 v4.0.10/go.mod h1:y3M18aPhIxLlcO/4dn9X8LzLLSma84cx6emMSu14FGw=
|
||||||
github.com/pion/interceptor v0.1.40 h1:e0BjnPcGpr2CFQgKhrQisBU7V3GXK6wrfYrGYaU6Jq4=
|
github.com/pion/interceptor v0.1.40 h1:e0BjnPcGpr2CFQgKhrQisBU7V3GXK6wrfYrGYaU6Jq4=
|
||||||
|
|
@ -117,37 +121,40 @@ github.com/pion/randutil v0.1.0 h1:CFG1UdESneORglEsnimhUjf33Rwjubwj6xfiOXBa3mA=
|
||||||
github.com/pion/randutil v0.1.0/go.mod h1:XcJrSMMbbMRhASFVOlj/5hQial/Y8oH/HVo7TBZq+j8=
|
github.com/pion/randutil v0.1.0/go.mod h1:XcJrSMMbbMRhASFVOlj/5hQial/Y8oH/HVo7TBZq+j8=
|
||||||
github.com/pion/rtcp v1.2.15 h1:LZQi2JbdipLOj4eBjK4wlVoQWfrZbh3Q6eHtWtJBZBo=
|
github.com/pion/rtcp v1.2.15 h1:LZQi2JbdipLOj4eBjK4wlVoQWfrZbh3Q6eHtWtJBZBo=
|
||||||
github.com/pion/rtcp v1.2.15/go.mod h1:jlGuAjHMEXwMUHK78RgX0UmEJFV4zUKOFHR7OP+D3D0=
|
github.com/pion/rtcp v1.2.15/go.mod h1:jlGuAjHMEXwMUHK78RgX0UmEJFV4zUKOFHR7OP+D3D0=
|
||||||
github.com/pion/rtp v1.8.20 h1:8zcyqohadZE8FCBeGdyEvHiclPIezcwRQH9zfapFyYI=
|
github.com/pion/rtp v1.8.22 h1:8NCVDDF+uSJmMUkjLJVnIr/HX7gPesyMV1xFt5xozXc=
|
||||||
github.com/pion/rtp v1.8.20/go.mod h1:bAu2UFKScgzyFqvUKmbvzSdPr+NGbZtv6UB2hesqXBk=
|
github.com/pion/rtp v1.8.22/go.mod h1:rF5nS1GqbR7H/TCpKwylzeq6yDM+MM6k+On5EgeThEM=
|
||||||
github.com/pion/sctp v1.8.39 h1:PJma40vRHa3UTO3C4MyeJDQ+KIobVYRZQZ0Nt7SjQnE=
|
github.com/pion/sctp v1.8.39 h1:PJma40vRHa3UTO3C4MyeJDQ+KIobVYRZQZ0Nt7SjQnE=
|
||||||
github.com/pion/sctp v1.8.39/go.mod h1:cNiLdchXra8fHQwmIoqw0MbLLMs+f7uQ+dGMG2gWebE=
|
github.com/pion/sctp v1.8.39/go.mod h1:cNiLdchXra8fHQwmIoqw0MbLLMs+f7uQ+dGMG2gWebE=
|
||||||
github.com/pion/sdp/v3 v3.0.14 h1:1h7gBr9FhOWH5GjWWY5lcw/U85MtdcibTyt/o6RxRUI=
|
github.com/pion/sdp/v3 v3.0.16 h1:0dKzYO6gTAvuLaAKQkC02eCPjMIi4NuAr/ibAwrGDCo=
|
||||||
github.com/pion/sdp/v3 v3.0.14/go.mod h1:88GMahN5xnScv1hIMTqLdu/cOcUkj6a9ytbncwMCq2E=
|
github.com/pion/sdp/v3 v3.0.16/go.mod h1:9tyKzznud3qiweZcD86kS0ff1pGYB3VX+Bcsmkx6IXo=
|
||||||
github.com/pion/srtp/v3 v3.0.6 h1:E2gyj1f5X10sB/qILUGIkL4C2CqK269Xq167PbGCc/4=
|
github.com/pion/srtp/v3 v3.0.7 h1:QUElw0A/FUg3MP8/KNMZB3i0m8F9XeMnTum86F7S4bs=
|
||||||
github.com/pion/srtp/v3 v3.0.6/go.mod h1:BxvziG3v/armJHAaJ87euvkhHqWe9I7iiOy50K2QkhY=
|
github.com/pion/srtp/v3 v3.0.7/go.mod h1:qvnHeqbhT7kDdB+OGB05KA/P067G3mm7XBfLaLiaNF0=
|
||||||
github.com/pion/stun/v3 v3.0.0 h1:4h1gwhWLWuZWOJIJR9s2ferRO+W3zA/b6ijOI6mKzUw=
|
github.com/pion/stun/v3 v3.0.0 h1:4h1gwhWLWuZWOJIJR9s2ferRO+W3zA/b6ijOI6mKzUw=
|
||||||
github.com/pion/stun/v3 v3.0.0/go.mod h1:HvCN8txt8mwi4FBvS3EmDghW6aQJ24T+y+1TKjB5jyU=
|
github.com/pion/stun/v3 v3.0.0/go.mod h1:HvCN8txt8mwi4FBvS3EmDghW6aQJ24T+y+1TKjB5jyU=
|
||||||
github.com/pion/transport/v3 v3.0.7 h1:iRbMH05BzSNwhILHoBoAPxoB9xQgOaJk+591KC9P1o0=
|
github.com/pion/transport/v3 v3.0.7 h1:iRbMH05BzSNwhILHoBoAPxoB9xQgOaJk+591KC9P1o0=
|
||||||
github.com/pion/transport/v3 v3.0.7/go.mod h1:YleKiTZ4vqNxVwh77Z0zytYi7rXHl7j6uPLGhhz9rwo=
|
github.com/pion/transport/v3 v3.0.7/go.mod h1:YleKiTZ4vqNxVwh77Z0zytYi7rXHl7j6uPLGhhz9rwo=
|
||||||
github.com/pion/turn/v4 v4.0.2 h1:ZqgQ3+MjP32ug30xAbD6Mn+/K4Sxi3SdNOTFf+7mpps=
|
github.com/pion/turn/v4 v4.1.1 h1:9UnY2HB99tpDyz3cVVZguSxcqkJ1DsTSZ+8TGruh4fc=
|
||||||
github.com/pion/turn/v4 v4.0.2/go.mod h1:pMMKP/ieNAG/fN5cZiN4SDuyKsXtNTr0ccN7IToA1zs=
|
github.com/pion/turn/v4 v4.1.1/go.mod h1:2123tHk1O++vmjI5VSD0awT50NywDAq5A2NNNU4Jjs8=
|
||||||
github.com/pion/webrtc/v4 v4.1.3 h1:YZ67Boj9X/hk190jJZ8+HFGQ6DqSZ/fYP3sLAZv7c3c=
|
github.com/pion/webrtc/v4 v4.1.4 h1:/gK1ACGHXQmtyVVbJFQDxNoODg4eSRiFLB7t9r9pg8M=
|
||||||
github.com/pion/webrtc/v4 v4.1.3/go.mod h1:rsq+zQ82ryfR9vbb0L1umPJ6Ogq7zm8mcn9fcGnxomM=
|
github.com/pion/webrtc/v4 v4.1.4/go.mod h1:Oab9npu1iZtQRMic3K3toYq5zFPvToe/QBw7dMI2ok4=
|
||||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/prometheus/client_golang v1.22.0 h1:rb93p9lokFEsctTys46VnV1kLCDpVZ0a/Y92Vm0Zc6Q=
|
github.com/prometheus/client_golang v1.23.0 h1:ust4zpdl9r4trLY/gSjlm07PuiBq2ynaXXlptpfy8Uc=
|
||||||
github.com/prometheus/client_golang v1.22.0/go.mod h1:R7ljNsLXhuQXYZYtw6GAE9AZg8Y7vEW5scdCXrWRXC0=
|
github.com/prometheus/client_golang v1.23.0/go.mod h1:i/o0R9ByOnHX0McrTMTyhYvKE4haaf2mW08I+jGAjEE=
|
||||||
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
|
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
|
||||||
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
|
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
|
||||||
github.com/prometheus/common v0.65.0 h1:QDwzd+G1twt//Kwj/Ww6E9FQq1iVMmODnILtW1t2VzE=
|
github.com/prometheus/common v0.66.0 h1:K/rJPHrG3+AoQs50r2+0t7zMnMzek2Vbv31OFVsMeVY=
|
||||||
github.com/prometheus/common v0.65.0/go.mod h1:0gZns+BLRQ3V6NdaerOhMbwwRbNh9hkGINtQAsP5GS8=
|
github.com/prometheus/common v0.66.0/go.mod h1:Ux6NtV1B4LatamKE63tJBntoxD++xmtI/lK0VtEplN4=
|
||||||
github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg=
|
github.com/prometheus/procfs v0.17.0 h1:FuLQ+05u4ZI+SS/w9+BWEM2TXiHKsUQ9TADiRH7DuK0=
|
||||||
github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is=
|
github.com/prometheus/procfs v0.17.0/go.mod h1:oPQLaDAMRbA+u8H5Pbfq+dl3VDAvHxMUOVhe0wYB2zw=
|
||||||
github.com/psanford/httpreadat v0.1.0 h1:VleW1HS2zO7/4c7c7zNl33fO6oYACSagjJIyMIwZLUE=
|
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/psanford/httpreadat v0.1.0/go.mod h1:Zg7P+TlBm3bYbyHTKv/EdtSJZn3qwbPwpfZ/I9GKCRE=
|
||||||
|
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
|
||||||
|
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
|
||||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||||
|
github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU=
|
||||||
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
|
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 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
|
||||||
github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=
|
github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=
|
||||||
|
|
@ -161,12 +168,14 @@ github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
|
||||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
github.com/stretchr/testify v1.8.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.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||||
github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA=
|
github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA=
|
||||||
github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
||||||
|
github.com/vearutop/statigz v1.5.0 h1:FuWwZiT82yBw4xbWdWIawiP2XFTyEPhIo8upRxiKLqk=
|
||||||
|
github.com/vearutop/statigz v1.5.0/go.mod h1:oHmjFf3izfCO804Di1ZjB666P3fAlVzJEx2k6jNt/Gk=
|
||||||
github.com/vishvananda/netlink v1.3.1 h1:3AEMt62VKqz90r0tmNhog0r/PpWKmrEShJU0wJW6bV0=
|
github.com/vishvananda/netlink v1.3.1 h1:3AEMt62VKqz90r0tmNhog0r/PpWKmrEShJU0wJW6bV0=
|
||||||
github.com/vishvananda/netlink v1.3.1/go.mod h1:ARtKouGSTGchR8aMwmkzC0qiNPrrWO5JS/XMVl45+b4=
|
github.com/vishvananda/netlink v1.3.1/go.mod h1:ARtKouGSTGchR8aMwmkzC0qiNPrrWO5JS/XMVl45+b4=
|
||||||
github.com/vishvananda/netns v0.0.5 h1:DfiHV+j8bA32MFM7bfEunvT8IAqQ/NzSJHtcmW5zdEY=
|
github.com/vishvananda/netns v0.0.5 h1:DfiHV+j8bA32MFM7bfEunvT8IAqQ/NzSJHtcmW5zdEY=
|
||||||
|
|
@ -175,12 +184,14 @@ github.com/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU=
|
||||||
github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA=
|
github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA=
|
||||||
go.bug.st/serial v1.6.4 h1:7FmqNPgVp3pu2Jz5PoPtbZ9jJO5gnEnZIvnI1lzve8A=
|
go.bug.st/serial v1.6.4 h1:7FmqNPgVp3pu2Jz5PoPtbZ9jJO5gnEnZIvnI1lzve8A=
|
||||||
go.bug.st/serial v1.6.4/go.mod h1:nofMJxTeNVny/m6+KaafC6vJGj3miwQZ6vW4BZUGJPI=
|
go.bug.st/serial v1.6.4/go.mod h1:nofMJxTeNVny/m6+KaafC6vJGj3miwQZ6vW4BZUGJPI=
|
||||||
|
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||||
|
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||||
golang.org/x/arch v0.18.0 h1:WN9poc33zL4AzGxqf8VtpKUnGvMi8O9lhNyBMF/85qc=
|
golang.org/x/arch v0.18.0 h1:WN9poc33zL4AzGxqf8VtpKUnGvMi8O9lhNyBMF/85qc=
|
||||||
golang.org/x/arch v0.18.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
|
golang.org/x/arch v0.18.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
|
||||||
golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM=
|
golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4=
|
||||||
golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U=
|
golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc=
|
||||||
golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw=
|
golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE=
|
||||||
golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA=
|
golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg=
|
||||||
golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
|
golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
|
||||||
golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
|
golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
|
||||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
|
@ -188,15 +199,17 @@ golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.6.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.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
|
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
|
||||||
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||||
golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M=
|
golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
|
||||||
golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA=
|
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
|
||||||
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
|
google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc=
|
||||||
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
|
google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||||
|
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||||
|
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
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 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,259 @@
|
||||||
|
package kvm
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jetkvm/kvm/internal/hidrpc"
|
||||||
|
"github.com/jetkvm/kvm/internal/usbgadget"
|
||||||
|
"github.com/rs/zerolog"
|
||||||
|
)
|
||||||
|
|
||||||
|
func handleHidRPCMessage(message hidrpc.Message, session *Session) {
|
||||||
|
var rpcErr error
|
||||||
|
|
||||||
|
switch message.Type() {
|
||||||
|
case hidrpc.TypeHandshake:
|
||||||
|
message, err := hidrpc.NewHandshakeMessage().Marshal()
|
||||||
|
if err != nil {
|
||||||
|
logger.Warn().Err(err).Msg("failed to marshal handshake message")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := session.HidChannel.Send(message); err != nil {
|
||||||
|
logger.Warn().Err(err).Msg("failed to send handshake message")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
session.hidRPCAvailable = true
|
||||||
|
case hidrpc.TypeKeypressReport, hidrpc.TypeKeyboardReport:
|
||||||
|
rpcErr = handleHidRPCKeyboardInput(message)
|
||||||
|
case hidrpc.TypeKeyboardMacroReport:
|
||||||
|
keyboardMacroReport, err := message.KeyboardMacroReport()
|
||||||
|
if err != nil {
|
||||||
|
logger.Warn().Err(err).Msg("failed to get keyboard macro report")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rpcErr = rpcExecuteKeyboardMacro(keyboardMacroReport.Steps)
|
||||||
|
case hidrpc.TypeCancelKeyboardMacroReport:
|
||||||
|
rpcCancelKeyboardMacro()
|
||||||
|
return
|
||||||
|
case hidrpc.TypeKeypressKeepAliveReport:
|
||||||
|
rpcErr = handleHidRPCKeypressKeepAlive(session)
|
||||||
|
case hidrpc.TypePointerReport:
|
||||||
|
pointerReport, err := message.PointerReport()
|
||||||
|
if err != nil {
|
||||||
|
logger.Warn().Err(err).Msg("failed to get pointer report")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rpcErr = rpcAbsMouseReport(pointerReport.X, pointerReport.Y, pointerReport.Button)
|
||||||
|
case hidrpc.TypeMouseReport:
|
||||||
|
mouseReport, err := message.MouseReport()
|
||||||
|
if err != nil {
|
||||||
|
logger.Warn().Err(err).Msg("failed to get mouse report")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rpcErr = rpcRelMouseReport(mouseReport.DX, mouseReport.DY, mouseReport.Button)
|
||||||
|
default:
|
||||||
|
logger.Warn().Uint8("type", uint8(message.Type())).Msg("unknown HID RPC message type")
|
||||||
|
}
|
||||||
|
|
||||||
|
if rpcErr != nil {
|
||||||
|
logger.Warn().Err(rpcErr).Msg("failed to handle HID RPC message")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func onHidMessage(msg hidQueueMessage, session *Session) {
|
||||||
|
data := msg.Data
|
||||||
|
|
||||||
|
scopedLogger := hidRPCLogger.With().
|
||||||
|
Str("channel", msg.channel).
|
||||||
|
Bytes("data", data).
|
||||||
|
Logger()
|
||||||
|
scopedLogger.Debug().Msg("HID RPC message received")
|
||||||
|
|
||||||
|
if len(data) < 1 {
|
||||||
|
scopedLogger.Warn().Int("length", len(data)).Msg("received empty data in HID RPC message handler")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var message hidrpc.Message
|
||||||
|
|
||||||
|
if err := hidrpc.Unmarshal(data, &message); err != nil {
|
||||||
|
scopedLogger.Warn().Err(err).Msg("failed to unmarshal HID RPC message")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if scopedLogger.GetLevel() <= zerolog.DebugLevel {
|
||||||
|
scopedLogger = scopedLogger.With().Str("descr", message.String()).Logger()
|
||||||
|
}
|
||||||
|
|
||||||
|
t := time.Now()
|
||||||
|
|
||||||
|
r := make(chan interface{})
|
||||||
|
go func() {
|
||||||
|
handleHidRPCMessage(message, session)
|
||||||
|
r <- nil
|
||||||
|
}()
|
||||||
|
select {
|
||||||
|
case <-time.After(1 * time.Second):
|
||||||
|
scopedLogger.Warn().Msg("HID RPC message timed out")
|
||||||
|
case <-r:
|
||||||
|
scopedLogger.Debug().Dur("duration", time.Since(t)).Msg("HID RPC message handled")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tunables
|
||||||
|
// Keep in mind
|
||||||
|
// macOS default: 15 * 15 = 225ms https://discussions.apple.com/thread/1316947?sortBy=rank
|
||||||
|
// Linux default: 250ms https://man.archlinux.org/man/kbdrate.8.en
|
||||||
|
// Windows default: 1s `HKEY_CURRENT_USER\Control Panel\Accessibility\Keyboard Response\AutoRepeatDelay`
|
||||||
|
|
||||||
|
const expectedRate = 50 * time.Millisecond // expected keepalive interval
|
||||||
|
const maxLateness = 50 * time.Millisecond // max jitter we'll tolerate OR jitter budget
|
||||||
|
const baseExtension = expectedRate + maxLateness // 100ms extension on perfect tick
|
||||||
|
|
||||||
|
const maxStaleness = 225 * time.Millisecond // discard ancient packets outright
|
||||||
|
|
||||||
|
func handleHidRPCKeypressKeepAlive(session *Session) error {
|
||||||
|
session.keepAliveJitterLock.Lock()
|
||||||
|
defer session.keepAliveJitterLock.Unlock()
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
|
// 1) Staleness guard: ensures packets that arrive far beyond the life of a valid key hold
|
||||||
|
// (e.g. after a network stall, retransmit burst, or machine sleep) are ignored outright.
|
||||||
|
// This prevents “zombie” keepalives from reviving a key that should already be released.
|
||||||
|
if !session.lastTimerResetTime.IsZero() && now.Sub(session.lastTimerResetTime) > maxStaleness {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
validTick := true
|
||||||
|
timerExtension := baseExtension
|
||||||
|
|
||||||
|
if !session.lastKeepAliveArrivalTime.IsZero() {
|
||||||
|
timeSinceLastTick := now.Sub(session.lastKeepAliveArrivalTime)
|
||||||
|
lateness := timeSinceLastTick - expectedRate
|
||||||
|
|
||||||
|
if lateness > 0 {
|
||||||
|
if lateness <= maxLateness {
|
||||||
|
// --- Small lateness (within jitterBudget) ---
|
||||||
|
// This is normal jitter (e.g., Wi-Fi contention).
|
||||||
|
// We still accept the tick, but *reduce the extension*
|
||||||
|
// so that the total hold time stays aligned with REAL client side intent.
|
||||||
|
timerExtension -= lateness
|
||||||
|
} else {
|
||||||
|
// --- Large lateness (beyond jitterBudget) ---
|
||||||
|
// This is likely a retransmit stall or ordering delay.
|
||||||
|
// We reject the tick entirely and DO NOT extend,
|
||||||
|
// so the auto-release still fires on time.
|
||||||
|
validTick = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !validTick {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// Only valid ticks update our state and extend the timer.
|
||||||
|
session.lastKeepAliveArrivalTime = now
|
||||||
|
session.lastTimerResetTime = now
|
||||||
|
if gadget != nil {
|
||||||
|
gadget.DelayAutoReleaseWithDuration(timerExtension)
|
||||||
|
}
|
||||||
|
|
||||||
|
// On a miss: do not advance any state — keeps baseline stable.
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleHidRPCKeyboardInput(message hidrpc.Message) error {
|
||||||
|
switch message.Type() {
|
||||||
|
case hidrpc.TypeKeypressReport:
|
||||||
|
keypressReport, err := message.KeypressReport()
|
||||||
|
if err != nil {
|
||||||
|
logger.Warn().Err(err).Msg("failed to get keypress report")
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return rpcKeypressReport(keypressReport.Key, keypressReport.Press)
|
||||||
|
case hidrpc.TypeKeyboardReport:
|
||||||
|
keyboardReport, err := message.KeyboardReport()
|
||||||
|
if err != nil {
|
||||||
|
logger.Warn().Err(err).Msg("failed to get keyboard report")
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return rpcKeyboardReport(keyboardReport.Modifier, keyboardReport.Keys)
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Errorf("unknown HID RPC message type: %d", message.Type())
|
||||||
|
}
|
||||||
|
|
||||||
|
func reportHidRPC(params any, session *Session) {
|
||||||
|
if session == nil {
|
||||||
|
logger.Warn().Msg("session is nil, skipping reportHidRPC")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !session.hidRPCAvailable || session.HidChannel == nil {
|
||||||
|
logger.Warn().
|
||||||
|
Bool("hidRPCAvailable", session.hidRPCAvailable).
|
||||||
|
Bool("HidChannel", session.HidChannel != nil).
|
||||||
|
Msg("HID RPC is not available, skipping reportHidRPC")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
message []byte
|
||||||
|
err error
|
||||||
|
)
|
||||||
|
switch params := params.(type) {
|
||||||
|
case usbgadget.KeyboardState:
|
||||||
|
message, err = hidrpc.NewKeyboardLedMessage(params).Marshal()
|
||||||
|
case usbgadget.KeysDownState:
|
||||||
|
message, err = hidrpc.NewKeydownStateMessage(params).Marshal()
|
||||||
|
case hidrpc.KeyboardMacroState:
|
||||||
|
message, err = hidrpc.NewKeyboardMacroStateMessage(params.State, params.IsPaste).Marshal()
|
||||||
|
default:
|
||||||
|
err = fmt.Errorf("unknown HID RPC message type: %T", params)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
logger.Warn().Err(err).Msg("failed to marshal HID RPC message")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if message == nil {
|
||||||
|
logger.Warn().Msg("failed to marshal HID RPC message")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := session.HidChannel.Send(message); err != nil {
|
||||||
|
if errors.Is(err, io.ErrClosedPipe) {
|
||||||
|
logger.Debug().Err(err).Msg("HID RPC channel closed, skipping reportHidRPC")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
logger.Warn().Err(err).Msg("failed to send HID RPC message")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Session) reportHidRPCKeyboardLedState(state usbgadget.KeyboardState) {
|
||||||
|
if !s.hidRPCAvailable {
|
||||||
|
writeJSONRPCEvent("keyboardLedState", state, s)
|
||||||
|
}
|
||||||
|
reportHidRPC(state, s)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Session) reportHidRPCKeysDownState(state usbgadget.KeysDownState) {
|
||||||
|
if !s.hidRPCAvailable {
|
||||||
|
usbLogger.Debug().Interface("state", state).Msg("reporting keys down state")
|
||||||
|
writeJSONRPCEvent("keysDownState", state, s)
|
||||||
|
}
|
||||||
|
usbLogger.Debug().Interface("state", state).Msg("reporting keys down state, calling reportHidRPC")
|
||||||
|
reportHidRPC(state, s)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Session) reportHidRPCKeyboardMacroState(state hidrpc.KeyboardMacroState) {
|
||||||
|
if !s.hidRPCAvailable {
|
||||||
|
writeJSONRPCEvent("keyboardMacroState", state, s)
|
||||||
|
}
|
||||||
|
reportHidRPC(state, s)
|
||||||
|
}
|
||||||
|
|
@ -3,6 +3,7 @@ package confparser
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
|
"net/url"
|
||||||
"reflect"
|
"reflect"
|
||||||
"slices"
|
"slices"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
@ -15,22 +16,22 @@ import (
|
||||||
type FieldConfig struct {
|
type FieldConfig struct {
|
||||||
Name string
|
Name string
|
||||||
Required bool
|
Required bool
|
||||||
RequiredIf map[string]interface{}
|
RequiredIf map[string]any
|
||||||
OneOf []string
|
OneOf []string
|
||||||
ValidateTypes []string
|
ValidateTypes []string
|
||||||
Defaults interface{}
|
Defaults any
|
||||||
IsEmpty bool
|
IsEmpty bool
|
||||||
CurrentValue interface{}
|
CurrentValue any
|
||||||
TypeString string
|
TypeString string
|
||||||
Delegated bool
|
Delegated bool
|
||||||
shouldUpdateValue bool
|
shouldUpdateValue bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func SetDefaultsAndValidate(config interface{}) error {
|
func SetDefaultsAndValidate(config any) error {
|
||||||
return setDefaultsAndValidate(config, true)
|
return setDefaultsAndValidate(config, true)
|
||||||
}
|
}
|
||||||
|
|
||||||
func setDefaultsAndValidate(config interface{}, isRoot bool) error {
|
func setDefaultsAndValidate(config any, isRoot bool) error {
|
||||||
// first we need to check if the config is a pointer
|
// first we need to check if the config is a pointer
|
||||||
if reflect.TypeOf(config).Kind() != reflect.Ptr {
|
if reflect.TypeOf(config).Kind() != reflect.Ptr {
|
||||||
return fmt.Errorf("config is not a pointer")
|
return fmt.Errorf("config is not a pointer")
|
||||||
|
|
@ -54,7 +55,7 @@ func setDefaultsAndValidate(config interface{}, isRoot bool) error {
|
||||||
Name: field.Name,
|
Name: field.Name,
|
||||||
OneOf: splitString(field.Tag.Get("one_of")),
|
OneOf: splitString(field.Tag.Get("one_of")),
|
||||||
ValidateTypes: splitString(field.Tag.Get("validate_type")),
|
ValidateTypes: splitString(field.Tag.Get("validate_type")),
|
||||||
RequiredIf: make(map[string]interface{}),
|
RequiredIf: make(map[string]any),
|
||||||
CurrentValue: fieldValue.Interface(),
|
CurrentValue: fieldValue.Interface(),
|
||||||
IsEmpty: false,
|
IsEmpty: false,
|
||||||
TypeString: fieldType,
|
TypeString: fieldType,
|
||||||
|
|
@ -141,8 +142,8 @@ func setDefaultsAndValidate(config interface{}, isRoot bool) error {
|
||||||
// now check if the field has required_if
|
// now check if the field has required_if
|
||||||
requiredIf := field.Tag.Get("required_if")
|
requiredIf := field.Tag.Get("required_if")
|
||||||
if requiredIf != "" {
|
if requiredIf != "" {
|
||||||
requiredIfParts := strings.Split(requiredIf, ",")
|
requiredIfParts := strings.SplitSeq(requiredIf, ",")
|
||||||
for _, part := range requiredIfParts {
|
for part := range requiredIfParts {
|
||||||
partVal := strings.SplitN(part, "=", 2)
|
partVal := strings.SplitN(part, "=", 2)
|
||||||
if len(partVal) != 2 {
|
if len(partVal) != 2 {
|
||||||
return fmt.Errorf("invalid required_if for field `%s`: %s", field.Name, requiredIf)
|
return fmt.Errorf("invalid required_if for field `%s`: %s", field.Name, requiredIf)
|
||||||
|
|
@ -167,7 +168,7 @@ func setDefaultsAndValidate(config interface{}, isRoot bool) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func validateFields(config interface{}, fields map[string]FieldConfig) error {
|
func validateFields(config any, fields map[string]FieldConfig) error {
|
||||||
// now we can start to validate the fields
|
// now we can start to validate the fields
|
||||||
for _, fieldConfig := range fields {
|
for _, fieldConfig := range fields {
|
||||||
if err := fieldConfig.validate(fields); err != nil {
|
if err := fieldConfig.validate(fields); err != nil {
|
||||||
|
|
@ -214,7 +215,7 @@ func (f *FieldConfig) validate(fields map[string]FieldConfig) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *FieldConfig) populate(config interface{}) {
|
func (f *FieldConfig) populate(config any) {
|
||||||
// update the field if it's not empty
|
// update the field if it's not empty
|
||||||
if !f.shouldUpdateValue {
|
if !f.shouldUpdateValue {
|
||||||
return
|
return
|
||||||
|
|
@ -372,6 +373,10 @@ func (f *FieldConfig) validateField() error {
|
||||||
if _, err := idna.Lookup.ToASCII(val); err != nil {
|
if _, err := idna.Lookup.ToASCII(val); err != nil {
|
||||||
return fmt.Errorf("field `%s` is not a valid hostname: %s", f.Name, val)
|
return fmt.Errorf("field `%s` is not a valid hostname: %s", f.Name, val)
|
||||||
}
|
}
|
||||||
|
case "proxy":
|
||||||
|
if url, err := url.Parse(val); err != nil || (url.Scheme != "http" && url.Scheme != "https") || url.Host == "" {
|
||||||
|
return fmt.Errorf("field `%s` is not a valid HTTP proxy URL: %s", f.Name, val)
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
return fmt.Errorf("field `%s` cannot use validate_type: unsupported validator: %s", f.Name, validateType)
|
return fmt.Errorf("field `%s` cannot use validate_type: unsupported validator: %s", f.Name, validateType)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -43,9 +43,11 @@ type testNetworkConfig struct {
|
||||||
LLDPTxTLVs []string `json:"lldp_tx_tlvs,omitempty" one_of:"chassis,port,system,vlan" default:"chassis,port,system,vlan"`
|
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"`
|
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"`
|
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"`
|
TimeSyncOrdering []string `json:"time_sync_ordering,omitempty" one_of:"http,ntp,ntp_dhcp,ntp_user_provided,http_user_provided" default:"ntp,http"`
|
||||||
TimeSyncDisableFallback null.Bool `json:"time_sync_disable_fallback,omitempty" default:"false"`
|
TimeSyncDisableFallback null.Bool `json:"time_sync_disable_fallback,omitempty" default:"false"`
|
||||||
TimeSyncParallel null.Int `json:"time_sync_parallel,omitempty" default:"4"`
|
TimeSyncParallel null.Int `json:"time_sync_parallel,omitempty" default:"4"`
|
||||||
|
TimeSyncNTPServers []string `json:"time_sync_ntp_servers,omitempty" validate_type:"ipv4_or_ipv6" required_if:"TimeSyncOrdering=ntp_user_provided"`
|
||||||
|
TimeSyncHTTPUrls []string `json:"time_sync_http_urls,omitempty" validate_type:"url" required_if:"TimeSyncOrdering=http_user_provided"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestValidateConfig(t *testing.T) {
|
func TestValidateConfig(t *testing.T) {
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ func splitString(s string) []string {
|
||||||
return strings.Split(s, ",")
|
return strings.Split(s, ",")
|
||||||
}
|
}
|
||||||
|
|
||||||
func toString(v interface{}) (string, error) {
|
func toString(v any) (string, error) {
|
||||||
switch v := v.(type) {
|
switch v := v.(type) {
|
||||||
case string:
|
case string:
|
||||||
return v, nil
|
return v, nil
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,123 @@
|
||||||
|
package hidrpc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/jetkvm/kvm/internal/usbgadget"
|
||||||
|
)
|
||||||
|
|
||||||
|
// MessageType is the type of the HID RPC message
|
||||||
|
type MessageType byte
|
||||||
|
|
||||||
|
const (
|
||||||
|
TypeHandshake MessageType = 0x01
|
||||||
|
TypeKeyboardReport MessageType = 0x02
|
||||||
|
TypePointerReport MessageType = 0x03
|
||||||
|
TypeWheelReport MessageType = 0x04
|
||||||
|
TypeKeypressReport MessageType = 0x05
|
||||||
|
TypeKeypressKeepAliveReport MessageType = 0x09
|
||||||
|
TypeMouseReport MessageType = 0x06
|
||||||
|
TypeKeyboardMacroReport MessageType = 0x07
|
||||||
|
TypeCancelKeyboardMacroReport MessageType = 0x08
|
||||||
|
TypeKeyboardLedState MessageType = 0x32
|
||||||
|
TypeKeydownState MessageType = 0x33
|
||||||
|
TypeKeyboardMacroState MessageType = 0x34
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
Version byte = 0x01 // Version of the HID RPC protocol
|
||||||
|
)
|
||||||
|
|
||||||
|
// GetQueueIndex returns the index of the queue to which the message should be enqueued.
|
||||||
|
func GetQueueIndex(messageType MessageType) int {
|
||||||
|
switch messageType {
|
||||||
|
case TypeHandshake:
|
||||||
|
return 0
|
||||||
|
case TypeKeyboardReport, TypeKeypressReport, TypeKeyboardMacroReport, TypeKeyboardLedState, TypeKeydownState, TypeKeyboardMacroState:
|
||||||
|
return 1
|
||||||
|
case TypePointerReport, TypeMouseReport, TypeWheelReport:
|
||||||
|
return 2
|
||||||
|
// we don't want to block the queue for this message
|
||||||
|
case TypeCancelKeyboardMacroReport:
|
||||||
|
return 3
|
||||||
|
default:
|
||||||
|
return 3
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unmarshal unmarshals the HID RPC message from the data.
|
||||||
|
func Unmarshal(data []byte, message *Message) error {
|
||||||
|
l := len(data)
|
||||||
|
if l < 1 {
|
||||||
|
return fmt.Errorf("invalid data length: %d", l)
|
||||||
|
}
|
||||||
|
|
||||||
|
message.t = MessageType(data[0])
|
||||||
|
message.d = data[1:]
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Marshal marshals the HID RPC message to the data.
|
||||||
|
func Marshal(message *Message) ([]byte, error) {
|
||||||
|
if message.t == 0 {
|
||||||
|
return nil, fmt.Errorf("invalid message type: %d", message.t)
|
||||||
|
}
|
||||||
|
|
||||||
|
data := make([]byte, len(message.d)+1)
|
||||||
|
data[0] = byte(message.t)
|
||||||
|
copy(data[1:], message.d)
|
||||||
|
|
||||||
|
return data, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewHandshakeMessage creates a new handshake message.
|
||||||
|
func NewHandshakeMessage() *Message {
|
||||||
|
return &Message{
|
||||||
|
t: TypeHandshake,
|
||||||
|
d: []byte{Version},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewKeyboardReportMessage creates a new keyboard report message.
|
||||||
|
func NewKeyboardReportMessage(keys []byte, modifier uint8) *Message {
|
||||||
|
return &Message{
|
||||||
|
t: TypeKeyboardReport,
|
||||||
|
d: append([]byte{modifier}, keys...),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewKeyboardLedMessage creates a new keyboard LED message.
|
||||||
|
func NewKeyboardLedMessage(state usbgadget.KeyboardState) *Message {
|
||||||
|
return &Message{
|
||||||
|
t: TypeKeyboardLedState,
|
||||||
|
d: []byte{state.Byte()},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewKeydownStateMessage creates a new keydown state message.
|
||||||
|
func NewKeydownStateMessage(state usbgadget.KeysDownState) *Message {
|
||||||
|
data := make([]byte, len(state.Keys)+1)
|
||||||
|
data[0] = state.Modifier
|
||||||
|
copy(data[1:], state.Keys)
|
||||||
|
|
||||||
|
return &Message{
|
||||||
|
t: TypeKeydownState,
|
||||||
|
d: data,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewKeyboardMacroStateMessage creates a new keyboard macro state message.
|
||||||
|
func NewKeyboardMacroStateMessage(state bool, isPaste bool) *Message {
|
||||||
|
data := make([]byte, 2)
|
||||||
|
if state {
|
||||||
|
data[0] = 1
|
||||||
|
}
|
||||||
|
if isPaste {
|
||||||
|
data[1] = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
return &Message{
|
||||||
|
t: TypeKeyboardMacroState,
|
||||||
|
d: data,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,207 @@
|
||||||
|
package hidrpc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/binary"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Message ..
|
||||||
|
type Message struct {
|
||||||
|
t MessageType
|
||||||
|
d []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
// Marshal marshals the message to a byte array.
|
||||||
|
func (m *Message) Marshal() ([]byte, error) {
|
||||||
|
return Marshal(m)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Message) Type() MessageType {
|
||||||
|
return m.t
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Message) String() string {
|
||||||
|
switch m.t {
|
||||||
|
case TypeHandshake:
|
||||||
|
return "Handshake"
|
||||||
|
case TypeKeypressReport:
|
||||||
|
if len(m.d) < 2 {
|
||||||
|
return fmt.Sprintf("KeypressReport{Malformed: %v}", m.d)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("KeypressReport{Key: %d, Press: %v}", m.d[0], m.d[1] == uint8(1))
|
||||||
|
case TypeKeyboardReport:
|
||||||
|
if len(m.d) < 2 {
|
||||||
|
return fmt.Sprintf("KeyboardReport{Malformed: %v}", m.d)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("KeyboardReport{Modifier: %d, Keys: %v}", m.d[0], m.d[1:])
|
||||||
|
case TypePointerReport:
|
||||||
|
if len(m.d) < 9 {
|
||||||
|
return fmt.Sprintf("PointerReport{Malformed: %v}", m.d)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("PointerReport{X: %d, Y: %d, Button: %d}", m.d[0:4], m.d[4:8], m.d[8])
|
||||||
|
case TypeMouseReport:
|
||||||
|
if len(m.d) < 3 {
|
||||||
|
return fmt.Sprintf("MouseReport{Malformed: %v}", m.d)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("MouseReport{DX: %d, DY: %d, Button: %d}", m.d[0], m.d[1], m.d[2])
|
||||||
|
case TypeKeypressKeepAliveReport:
|
||||||
|
return "KeypressKeepAliveReport"
|
||||||
|
case TypeKeyboardMacroReport:
|
||||||
|
if len(m.d) < 5 {
|
||||||
|
return fmt.Sprintf("KeyboardMacroReport{Malformed: %v}", m.d)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("KeyboardMacroReport{IsPaste: %v, Length: %d}", m.d[0] == uint8(1), binary.BigEndian.Uint32(m.d[1:5]))
|
||||||
|
default:
|
||||||
|
return fmt.Sprintf("Unknown{Type: %d, Data: %v}", m.t, m.d)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// KeypressReport ..
|
||||||
|
type KeypressReport struct {
|
||||||
|
Key byte
|
||||||
|
Press bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// KeypressReport returns the keypress report from the message.
|
||||||
|
func (m *Message) KeypressReport() (KeypressReport, error) {
|
||||||
|
if m.t != TypeKeypressReport {
|
||||||
|
return KeypressReport{}, fmt.Errorf("invalid message type: %d", m.t)
|
||||||
|
}
|
||||||
|
|
||||||
|
return KeypressReport{
|
||||||
|
Key: m.d[0],
|
||||||
|
Press: m.d[1] == uint8(1),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// KeyboardReport ..
|
||||||
|
type KeyboardReport struct {
|
||||||
|
Modifier byte
|
||||||
|
Keys []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
// KeyboardReport returns the keyboard report from the message.
|
||||||
|
func (m *Message) KeyboardReport() (KeyboardReport, error) {
|
||||||
|
if m.t != TypeKeyboardReport {
|
||||||
|
return KeyboardReport{}, fmt.Errorf("invalid message type: %d", m.t)
|
||||||
|
}
|
||||||
|
|
||||||
|
return KeyboardReport{
|
||||||
|
Modifier: m.d[0],
|
||||||
|
Keys: m.d[1:],
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Macro ..
|
||||||
|
type KeyboardMacroStep struct {
|
||||||
|
Modifier byte // 1 byte
|
||||||
|
Keys []byte // 6 bytes: hidKeyBufferSize
|
||||||
|
Delay uint16 // 2 bytes
|
||||||
|
}
|
||||||
|
type KeyboardMacroReport struct {
|
||||||
|
IsPaste bool
|
||||||
|
StepCount uint32
|
||||||
|
Steps []KeyboardMacroStep
|
||||||
|
}
|
||||||
|
|
||||||
|
// HidKeyBufferSize is the size of the keys buffer in the keyboard report.
|
||||||
|
const HidKeyBufferSize = 6
|
||||||
|
|
||||||
|
// KeyboardMacroReport returns the keyboard macro report from the message.
|
||||||
|
func (m *Message) KeyboardMacroReport() (KeyboardMacroReport, error) {
|
||||||
|
if m.t != TypeKeyboardMacroReport {
|
||||||
|
return KeyboardMacroReport{}, fmt.Errorf("invalid message type: %d", m.t)
|
||||||
|
}
|
||||||
|
|
||||||
|
isPaste := m.d[0] == uint8(1)
|
||||||
|
stepCount := binary.BigEndian.Uint32(m.d[1:5])
|
||||||
|
|
||||||
|
// check total length
|
||||||
|
expectedLength := int(stepCount)*9 + 5
|
||||||
|
if len(m.d) != expectedLength {
|
||||||
|
return KeyboardMacroReport{}, fmt.Errorf("invalid length: %d, expected: %d", len(m.d), expectedLength)
|
||||||
|
}
|
||||||
|
|
||||||
|
steps := make([]KeyboardMacroStep, 0, int(stepCount))
|
||||||
|
offset := 5
|
||||||
|
for i := 0; i < int(stepCount); i++ {
|
||||||
|
steps = append(steps, KeyboardMacroStep{
|
||||||
|
Modifier: m.d[offset],
|
||||||
|
Keys: m.d[offset+1 : offset+7],
|
||||||
|
Delay: binary.BigEndian.Uint16(m.d[offset+7 : offset+9]),
|
||||||
|
})
|
||||||
|
|
||||||
|
offset += 1 + HidKeyBufferSize + 2
|
||||||
|
}
|
||||||
|
|
||||||
|
return KeyboardMacroReport{
|
||||||
|
IsPaste: isPaste,
|
||||||
|
Steps: steps,
|
||||||
|
StepCount: stepCount,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PointerReport ..
|
||||||
|
type PointerReport struct {
|
||||||
|
X int
|
||||||
|
Y int
|
||||||
|
Button uint8
|
||||||
|
}
|
||||||
|
|
||||||
|
func toInt(b []byte) int {
|
||||||
|
return int(b[0])<<24 + int(b[1])<<16 + int(b[2])<<8 + int(b[3])<<0
|
||||||
|
}
|
||||||
|
|
||||||
|
// PointerReport returns the point report from the message.
|
||||||
|
func (m *Message) PointerReport() (PointerReport, error) {
|
||||||
|
if m.t != TypePointerReport {
|
||||||
|
return PointerReport{}, fmt.Errorf("invalid message type: %d", m.t)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(m.d) != 9 {
|
||||||
|
return PointerReport{}, fmt.Errorf("invalid message length: %d", len(m.d))
|
||||||
|
}
|
||||||
|
|
||||||
|
return PointerReport{
|
||||||
|
X: toInt(m.d[0:4]),
|
||||||
|
Y: toInt(m.d[4:8]),
|
||||||
|
Button: uint8(m.d[8]),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MouseReport ..
|
||||||
|
type MouseReport struct {
|
||||||
|
DX int8
|
||||||
|
DY int8
|
||||||
|
Button uint8
|
||||||
|
}
|
||||||
|
|
||||||
|
// MouseReport returns the mouse report from the message.
|
||||||
|
func (m *Message) MouseReport() (MouseReport, error) {
|
||||||
|
if m.t != TypeMouseReport {
|
||||||
|
return MouseReport{}, fmt.Errorf("invalid message type: %d", m.t)
|
||||||
|
}
|
||||||
|
|
||||||
|
return MouseReport{
|
||||||
|
DX: int8(m.d[0]),
|
||||||
|
DY: int8(m.d[1]),
|
||||||
|
Button: uint8(m.d[2]),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type KeyboardMacroState struct {
|
||||||
|
State bool
|
||||||
|
IsPaste bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// KeyboardMacroState returns the keyboard macro state report from the message.
|
||||||
|
func (m *Message) KeyboardMacroState() (KeyboardMacroState, error) {
|
||||||
|
if m.t != TypeKeyboardMacroState {
|
||||||
|
return KeyboardMacroState{}, fmt.Errorf("invalid message type: %d", m.t)
|
||||||
|
}
|
||||||
|
|
||||||
|
return KeyboardMacroState{
|
||||||
|
State: m.d[0] == uint8(1),
|
||||||
|
IsPaste: m.d[1] == uint8(1),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
@ -50,7 +50,7 @@ var (
|
||||||
TimeFormat: time.RFC3339,
|
TimeFormat: time.RFC3339,
|
||||||
PartsOrder: []string{"time", "level", "scope", "component", "message"},
|
PartsOrder: []string{"time", "level", "scope", "component", "message"},
|
||||||
FieldsExclude: []string{"scope", "component"},
|
FieldsExclude: []string{"scope", "component"},
|
||||||
FormatPartValueByName: func(value interface{}, name string) string {
|
FormatPartValueByName: func(value any, name string) string {
|
||||||
val := fmt.Sprintf("%s", value)
|
val := fmt.Sprintf("%s", value)
|
||||||
if name == "component" {
|
if name == "component" {
|
||||||
if value == nil {
|
if value == nil {
|
||||||
|
|
@ -121,8 +121,8 @@ func (l *Logger) updateLogLevel() {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
scopes := strings.Split(strings.ToLower(env), ",")
|
scopes := strings.SplitSeq(strings.ToLower(env), ",")
|
||||||
for _, scope := range scopes {
|
for scope := range scopes {
|
||||||
l.scopeLevels[scope] = level
|
l.scopeLevels[scope] = level
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,32 +13,32 @@ type pionLogger struct {
|
||||||
func (c pionLogger) Trace(msg string) {
|
func (c pionLogger) Trace(msg string) {
|
||||||
c.logger.Trace().Msg(msg)
|
c.logger.Trace().Msg(msg)
|
||||||
}
|
}
|
||||||
func (c pionLogger) Tracef(format string, args ...interface{}) {
|
func (c pionLogger) Tracef(format string, args ...any) {
|
||||||
c.logger.Trace().Msgf(format, args...)
|
c.logger.Trace().Msgf(format, args...)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c pionLogger) Debug(msg string) {
|
func (c pionLogger) Debug(msg string) {
|
||||||
c.logger.Debug().Msg(msg)
|
c.logger.Debug().Msg(msg)
|
||||||
}
|
}
|
||||||
func (c pionLogger) Debugf(format string, args ...interface{}) {
|
func (c pionLogger) Debugf(format string, args ...any) {
|
||||||
c.logger.Debug().Msgf(format, args...)
|
c.logger.Debug().Msgf(format, args...)
|
||||||
}
|
}
|
||||||
func (c pionLogger) Info(msg string) {
|
func (c pionLogger) Info(msg string) {
|
||||||
c.logger.Info().Msg(msg)
|
c.logger.Info().Msg(msg)
|
||||||
}
|
}
|
||||||
func (c pionLogger) Infof(format string, args ...interface{}) {
|
func (c pionLogger) Infof(format string, args ...any) {
|
||||||
c.logger.Info().Msgf(format, args...)
|
c.logger.Info().Msgf(format, args...)
|
||||||
}
|
}
|
||||||
func (c pionLogger) Warn(msg string) {
|
func (c pionLogger) Warn(msg string) {
|
||||||
c.logger.Warn().Msg(msg)
|
c.logger.Warn().Msg(msg)
|
||||||
}
|
}
|
||||||
func (c pionLogger) Warnf(format string, args ...interface{}) {
|
func (c pionLogger) Warnf(format string, args ...any) {
|
||||||
c.logger.Warn().Msgf(format, args...)
|
c.logger.Warn().Msgf(format, args...)
|
||||||
}
|
}
|
||||||
func (c pionLogger) Error(msg string) {
|
func (c pionLogger) Error(msg string) {
|
||||||
c.logger.Error().Msg(msg)
|
c.logger.Error().Msg(msg)
|
||||||
}
|
}
|
||||||
func (c pionLogger) Errorf(format string, args ...interface{}) {
|
func (c pionLogger) Errorf(format string, args ...any) {
|
||||||
c.logger.Error().Msgf(format, args...)
|
c.logger.Error().Msgf(format, args...)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ func GetDefaultLogger() *zerolog.Logger {
|
||||||
return &defaultLogger
|
return &defaultLogger
|
||||||
}
|
}
|
||||||
|
|
||||||
func ErrorfL(l *zerolog.Logger, format string, err error, args ...interface{}) error {
|
func ErrorfL(l *zerolog.Logger, format string, err error, args ...any) error {
|
||||||
// TODO: move rootLogger to logging package
|
// TODO: move rootLogger to logging package
|
||||||
if l == nil {
|
if l == nil {
|
||||||
l = &defaultLogger
|
l = &defaultLogger
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,8 @@ package network
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/guregu/null/v6"
|
"github.com/guregu/null/v6"
|
||||||
|
|
@ -32,8 +34,9 @@ type IPv6StaticConfig struct {
|
||||||
DNS []string `json:"dns,omitempty" validate_type:"ipv6" required:"true"`
|
DNS []string `json:"dns,omitempty" validate_type:"ipv6" required:"true"`
|
||||||
}
|
}
|
||||||
type NetworkConfig struct {
|
type NetworkConfig struct {
|
||||||
Hostname null.String `json:"hostname,omitempty" validate_type:"hostname"`
|
Hostname null.String `json:"hostname,omitempty" validate_type:"hostname"`
|
||||||
Domain null.String `json:"domain,omitempty" validate_type:"hostname"`
|
HTTPProxy null.String `json:"http_proxy,omitempty" validate_type:"proxy"`
|
||||||
|
Domain null.String `json:"domain,omitempty" validate_type:"hostname"`
|
||||||
|
|
||||||
IPv4Mode null.String `json:"ipv4_mode,omitempty" one_of:"dhcp,static,disabled" default:"dhcp"`
|
IPv4Mode null.String `json:"ipv4_mode,omitempty" one_of:"dhcp,static,disabled" default:"dhcp"`
|
||||||
IPv4Static *IPv4StaticConfig `json:"ipv4_static,omitempty" required_if:"IPv4Mode=static"`
|
IPv4Static *IPv4StaticConfig `json:"ipv4_static,omitempty" required_if:"IPv4Mode=static"`
|
||||||
|
|
@ -45,19 +48,20 @@ type NetworkConfig struct {
|
||||||
LLDPTxTLVs []string `json:"lldp_tx_tlvs,omitempty" one_of:"chassis,port,system,vlan" default:"chassis,port,system,vlan"`
|
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"`
|
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"`
|
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"`
|
TimeSyncOrdering []string `json:"time_sync_ordering,omitempty" one_of:"http,ntp,ntp_dhcp,ntp_user_provided,http_user_provided" default:"ntp,http"`
|
||||||
TimeSyncDisableFallback null.Bool `json:"time_sync_disable_fallback,omitempty" default:"false"`
|
TimeSyncDisableFallback null.Bool `json:"time_sync_disable_fallback,omitempty" default:"false"`
|
||||||
TimeSyncParallel null.Int `json:"time_sync_parallel,omitempty" default:"4"`
|
TimeSyncParallel null.Int `json:"time_sync_parallel,omitempty" default:"4"`
|
||||||
|
TimeSyncNTPServers []string `json:"time_sync_ntp_servers,omitempty" validate_type:"ipv4_or_ipv6" required_if:"TimeSyncOrdering=ntp_user_provided"`
|
||||||
|
TimeSyncHTTPUrls []string `json:"time_sync_http_urls,omitempty" validate_type:"url" required_if:"TimeSyncOrdering=http_user_provided"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *NetworkConfig) GetMDNSMode() *mdns.MDNSListenOptions {
|
func (c *NetworkConfig) GetMDNSMode() *mdns.MDNSListenOptions {
|
||||||
mode := c.MDNSMode.String
|
|
||||||
listenOptions := &mdns.MDNSListenOptions{
|
listenOptions := &mdns.MDNSListenOptions{
|
||||||
IPv4: true,
|
IPv4: c.IPv4Mode.String != "disabled",
|
||||||
IPv6: true,
|
IPv6: c.IPv6Mode.String != "disabled",
|
||||||
}
|
}
|
||||||
|
|
||||||
switch mode {
|
switch c.MDNSMode.String {
|
||||||
case "ipv4_only":
|
case "ipv4_only":
|
||||||
listenOptions.IPv6 = false
|
listenOptions.IPv6 = false
|
||||||
case "ipv6_only":
|
case "ipv6_only":
|
||||||
|
|
@ -69,6 +73,18 @@ func (c *NetworkConfig) GetMDNSMode() *mdns.MDNSListenOptions {
|
||||||
|
|
||||||
return listenOptions
|
return listenOptions
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *NetworkConfig) GetTransportProxyFunc() func(*http.Request) (*url.URL, error) {
|
||||||
|
return func(*http.Request) (*url.URL, error) {
|
||||||
|
if s.HTTPProxy.String == "" {
|
||||||
|
return nil, nil
|
||||||
|
} else {
|
||||||
|
proxyUrl, _ := url.Parse(s.HTTPProxy.String)
|
||||||
|
return proxyUrl, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (s *NetworkInterfaceState) GetHostname() string {
|
func (s *NetworkInterfaceState) GetHostname() string {
|
||||||
hostname := ToValidHostname(s.config.Hostname.String)
|
hostname := ToValidHostname(s.config.Hostname.String)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -42,7 +42,7 @@ func updateEtcHosts(hostname string, fqdn string) error {
|
||||||
hostLine := fmt.Sprintf("127.0.1.1\t%s %s", hostname, fqdn)
|
hostLine := fmt.Sprintf("127.0.1.1\t%s %s", hostname, fqdn)
|
||||||
hostLineExists := false
|
hostLineExists := false
|
||||||
|
|
||||||
for _, line := range strings.Split(string(lines), "\n") {
|
for line := range strings.SplitSeq(string(lines), "\n") {
|
||||||
if strings.HasPrefix(line, "127.0.1.1") {
|
if strings.HasPrefix(line, "127.0.1.1") {
|
||||||
hostLineExists = true
|
hostLineExists = true
|
||||||
line = hostLine
|
line = hostLine
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ type NetworkInterfaceState struct {
|
||||||
ipv6Addr *net.IP
|
ipv6Addr *net.IP
|
||||||
ipv6Addresses []IPv6Address
|
ipv6Addresses []IPv6Address
|
||||||
ipv6LinkLocal *net.IP
|
ipv6LinkLocal *net.IP
|
||||||
|
ntpAddresses []*net.IP
|
||||||
macAddr *net.HardwareAddr
|
macAddr *net.HardwareAddr
|
||||||
|
|
||||||
l *zerolog.Logger
|
l *zerolog.Logger
|
||||||
|
|
@ -47,7 +48,7 @@ type NetworkInterfaceOptions struct {
|
||||||
DefaultHostname string
|
DefaultHostname string
|
||||||
OnStateChange func(state *NetworkInterfaceState)
|
OnStateChange func(state *NetworkInterfaceState)
|
||||||
OnInitialCheck func(state *NetworkInterfaceState)
|
OnInitialCheck func(state *NetworkInterfaceState)
|
||||||
OnDhcpLeaseChange func(lease *udhcpc.Lease)
|
OnDhcpLeaseChange func(lease *udhcpc.Lease, state *NetworkInterfaceState)
|
||||||
OnConfigChange func(config *NetworkConfig)
|
OnConfigChange func(config *NetworkConfig)
|
||||||
NetworkConfig *NetworkConfig
|
NetworkConfig *NetworkConfig
|
||||||
}
|
}
|
||||||
|
|
@ -76,6 +77,7 @@ func NewNetworkInterfaceState(opts *NetworkInterfaceOptions) (*NetworkInterfaceS
|
||||||
onInitialCheck: opts.OnInitialCheck,
|
onInitialCheck: opts.OnInitialCheck,
|
||||||
cbConfigChange: opts.OnConfigChange,
|
cbConfigChange: opts.OnConfigChange,
|
||||||
config: opts.NetworkConfig,
|
config: opts.NetworkConfig,
|
||||||
|
ntpAddresses: make([]*net.IP, 0),
|
||||||
}
|
}
|
||||||
|
|
||||||
// create the dhcp client
|
// create the dhcp client
|
||||||
|
|
@ -89,10 +91,10 @@ func NewNetworkInterfaceState(opts *NetworkInterfaceOptions) (*NetworkInterfaceS
|
||||||
opts.Logger.Error().Err(err).Msg("failed to update network state")
|
opts.Logger.Error().Err(err).Msg("failed to update network state")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
_ = s.updateNtpServersFromLease(lease)
|
||||||
_ = s.setHostnameIfNotSame()
|
_ = s.setHostnameIfNotSame()
|
||||||
|
|
||||||
opts.OnDhcpLeaseChange(lease)
|
opts.OnDhcpLeaseChange(lease, s)
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -135,6 +137,27 @@ func (s *NetworkInterfaceState) IPv6String() string {
|
||||||
return s.ipv6Addr.String()
|
return s.ipv6Addr.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *NetworkInterfaceState) NtpAddresses() []*net.IP {
|
||||||
|
return s.ntpAddresses
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *NetworkInterfaceState) NtpAddressesString() []string {
|
||||||
|
ntpServers := []string{}
|
||||||
|
|
||||||
|
if s != nil {
|
||||||
|
s.l.Debug().Any("s", s).Msg("getting NTP address strings")
|
||||||
|
|
||||||
|
if len(s.ntpAddresses) > 0 {
|
||||||
|
for _, server := range s.ntpAddresses {
|
||||||
|
s.l.Debug().IPAddr("server", *server).Msg("converting NTP address")
|
||||||
|
ntpServers = append(ntpServers, server.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ntpServers
|
||||||
|
}
|
||||||
|
|
||||||
func (s *NetworkInterfaceState) MAC() *net.HardwareAddr {
|
func (s *NetworkInterfaceState) MAC() *net.HardwareAddr {
|
||||||
return s.macAddr
|
return s.macAddr
|
||||||
}
|
}
|
||||||
|
|
@ -216,6 +239,10 @@ func (s *NetworkInterfaceState) update() (DhcpTargetState, error) {
|
||||||
ipv4Addresses = append(ipv4Addresses, addr.IP)
|
ipv4Addresses = append(ipv4Addresses, addr.IP)
|
||||||
ipv4AddressesString = append(ipv4AddressesString, addr.IPNet.String())
|
ipv4AddressesString = append(ipv4AddressesString, addr.IPNet.String())
|
||||||
} else if addr.IP.To16() != nil {
|
} else if addr.IP.To16() != nil {
|
||||||
|
if s.config.IPv6Mode.String == "disabled" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
scopedLogger := s.l.With().Str("ipv6", addr.IP.String()).Logger()
|
scopedLogger := s.l.With().Str("ipv6", addr.IP.String()).Logger()
|
||||||
// check if it's a link local address
|
// check if it's a link local address
|
||||||
if addr.IP.IsLinkLocalUnicast() {
|
if addr.IP.IsLinkLocalUnicast() {
|
||||||
|
|
@ -264,35 +291,37 @@ func (s *NetworkInterfaceState) update() (DhcpTargetState, error) {
|
||||||
}
|
}
|
||||||
s.ipv4Addresses = ipv4AddressesString
|
s.ipv4Addresses = ipv4AddressesString
|
||||||
|
|
||||||
if ipv6LinkLocal != nil {
|
if s.config.IPv6Mode.String != "disabled" {
|
||||||
if s.ipv6LinkLocal == nil || s.ipv6LinkLocal.String() != ipv6LinkLocal.String() {
|
if ipv6LinkLocal != nil {
|
||||||
scopedLogger := s.l.With().Str("ipv6", ipv6LinkLocal.String()).Logger()
|
if s.ipv6LinkLocal == nil || s.ipv6LinkLocal.String() != ipv6LinkLocal.String() {
|
||||||
if s.ipv6LinkLocal != nil {
|
scopedLogger := s.l.With().Str("ipv6", ipv6LinkLocal.String()).Logger()
|
||||||
scopedLogger.Info().
|
if s.ipv6LinkLocal != nil {
|
||||||
Str("old_ipv6", s.ipv6LinkLocal.String()).
|
scopedLogger.Info().
|
||||||
Msg("IPv6 link local address changed")
|
Str("old_ipv6", s.ipv6LinkLocal.String()).
|
||||||
} else {
|
Msg("IPv6 link local address changed")
|
||||||
scopedLogger.Info().Msg("IPv6 link local address found")
|
} else {
|
||||||
|
scopedLogger.Info().Msg("IPv6 link local address found")
|
||||||
|
}
|
||||||
|
s.ipv6LinkLocal = ipv6LinkLocal
|
||||||
|
changed = true
|
||||||
}
|
}
|
||||||
s.ipv6LinkLocal = ipv6LinkLocal
|
|
||||||
changed = true
|
|
||||||
}
|
}
|
||||||
}
|
s.ipv6Addresses = ipv6Addresses
|
||||||
s.ipv6Addresses = ipv6Addresses
|
|
||||||
|
|
||||||
if len(ipv6Addresses) > 0 {
|
if len(ipv6Addresses) > 0 {
|
||||||
// compare the addresses to see if there's a change
|
// compare the addresses to see if there's a change
|
||||||
if s.ipv6Addr == nil || s.ipv6Addr.String() != ipv6Addresses[0].Address.String() {
|
if s.ipv6Addr == nil || s.ipv6Addr.String() != ipv6Addresses[0].Address.String() {
|
||||||
scopedLogger := s.l.With().Str("ipv6", ipv6Addresses[0].Address.String()).Logger()
|
scopedLogger := s.l.With().Str("ipv6", ipv6Addresses[0].Address.String()).Logger()
|
||||||
if s.ipv6Addr != nil {
|
if s.ipv6Addr != nil {
|
||||||
scopedLogger.Info().
|
scopedLogger.Info().
|
||||||
Str("old_ipv6", s.ipv6Addr.String()).
|
Str("old_ipv6", s.ipv6Addr.String()).
|
||||||
Msg("IPv6 address changed")
|
Msg("IPv6 address changed")
|
||||||
} else {
|
} else {
|
||||||
scopedLogger.Info().Msg("IPv6 address found")
|
scopedLogger.Info().Msg("IPv6 address found")
|
||||||
|
}
|
||||||
|
s.ipv6Addr = &ipv6Addresses[0].Address
|
||||||
|
changed = true
|
||||||
}
|
}
|
||||||
s.ipv6Addr = &ipv6Addresses[0].Address
|
|
||||||
changed = true
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -318,6 +347,25 @@ func (s *NetworkInterfaceState) update() (DhcpTargetState, error) {
|
||||||
return dhcpTargetState, nil
|
return dhcpTargetState, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *NetworkInterfaceState) updateNtpServersFromLease(lease *udhcpc.Lease) error {
|
||||||
|
if lease != nil && len(lease.NTPServers) > 0 {
|
||||||
|
s.l.Info().Msg("lease found, updating DHCP NTP addresses")
|
||||||
|
s.ntpAddresses = make([]*net.IP, 0, len(lease.NTPServers))
|
||||||
|
|
||||||
|
for _, ntpServer := range lease.NTPServers {
|
||||||
|
if ntpServer != nil {
|
||||||
|
s.l.Info().IPAddr("ntp_server", ntpServer).Msg("NTP server found in lease")
|
||||||
|
s.ntpAddresses = append(s.ntpAddresses, &ntpServer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
s.l.Info().Msg("no NTP servers found in lease")
|
||||||
|
s.ntpAddresses = make([]*net.IP, 0, len(s.config.TimeSyncNTPServers))
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *NetworkInterfaceState) CheckAndUpdateDhcp() error {
|
func (s *NetworkInterfaceState) CheckAndUpdateDhcp() error {
|
||||||
dhcpTargetState, err := s.update()
|
dhcpTargetState, err := s.update()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
||||||
|
|
@ -65,7 +65,7 @@ func (s *NetworkInterfaceState) IPv6LinkLocalAddress() string {
|
||||||
func (s *NetworkInterfaceState) RpcGetNetworkState() RpcNetworkState {
|
func (s *NetworkInterfaceState) RpcGetNetworkState() RpcNetworkState {
|
||||||
ipv6Addresses := make([]RpcIPv6Address, 0)
|
ipv6Addresses := make([]RpcIPv6Address, 0)
|
||||||
|
|
||||||
if s.ipv6Addresses != nil {
|
if s.ipv6Addresses != nil && s.config.IPv6Mode.String != "disabled" {
|
||||||
for _, addr := range s.ipv6Addresses {
|
for _, addr := range s.ipv6Addresses {
|
||||||
ipv6Addresses = append(ipv6Addresses, RpcIPv6Address{
|
ipv6Addresses = append(ipv6Addresses, RpcIPv6Address{
|
||||||
Address: addr.Prefix.String(),
|
Address: addr.Prefix.String(),
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ func lifetimeToTime(lifetime int) *time.Time {
|
||||||
return &t
|
return &t
|
||||||
}
|
}
|
||||||
|
|
||||||
func IsSame(a, b interface{}) bool {
|
func IsSame(a, b any) bool {
|
||||||
aJSON, err := json.Marshal(a)
|
aJSON, err := json.Marshal(a)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false
|
return false
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import (
|
||||||
"errors"
|
"errors"
|
||||||
"math/rand"
|
"math/rand"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
@ -19,9 +20,9 @@ var defaultHTTPUrls = []string{
|
||||||
// "http://www.msftconnecttest.com/connecttest.txt",
|
// "http://www.msftconnecttest.com/connecttest.txt",
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *TimeSync) queryAllHttpTime() (now *time.Time) {
|
func (t *TimeSync) queryAllHttpTime(httpUrls []string) (now *time.Time) {
|
||||||
chunkSize := 4
|
chunkSize := int(t.networkConfig.TimeSyncParallel.ValueOr(4))
|
||||||
httpUrls := t.httpUrls
|
t.l.Info().Strs("httpUrls", httpUrls).Int("chunkSize", chunkSize).Msg("querying HTTP URLs")
|
||||||
|
|
||||||
// shuffle the http urls to avoid always querying the same servers
|
// 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] })
|
rand.Shuffle(len(httpUrls), func(i, j int) { httpUrls[i], httpUrls[j] = httpUrls[j], httpUrls[i] })
|
||||||
|
|
@ -57,6 +58,7 @@ func (t *TimeSync) queryMultipleHttp(urls []string, timeout time.Duration) (now
|
||||||
ctx,
|
ctx,
|
||||||
url,
|
url,
|
||||||
timeout,
|
timeout,
|
||||||
|
t.networkConfig.GetTransportProxyFunc(),
|
||||||
)
|
)
|
||||||
duration := time.Since(startTime)
|
duration := time.Since(startTime)
|
||||||
|
|
||||||
|
|
@ -122,10 +124,16 @@ func queryHttpTime(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
url string,
|
url string,
|
||||||
timeout time.Duration,
|
timeout time.Duration,
|
||||||
|
proxyFunc func(*http.Request) (*url.URL, error),
|
||||||
) (now *time.Time, response *http.Response, err error) {
|
) (now *time.Time, response *http.Response, err error) {
|
||||||
|
transport := http.DefaultTransport.(*http.Transport).Clone()
|
||||||
|
transport.Proxy = proxyFunc
|
||||||
|
|
||||||
client := http.Client{
|
client := http.Client{
|
||||||
Timeout: timeout,
|
Transport: transport,
|
||||||
|
Timeout: timeout,
|
||||||
}
|
}
|
||||||
|
|
||||||
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
|
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
|
|
|
||||||
|
|
@ -73,6 +73,7 @@ var (
|
||||||
},
|
},
|
||||||
[]string{"url"},
|
[]string{"url"},
|
||||||
)
|
)
|
||||||
|
|
||||||
metricNtpServerInfo = promauto.NewGaugeVec(
|
metricNtpServerInfo = promauto.NewGaugeVec(
|
||||||
prometheus.GaugeOpts{
|
prometheus.GaugeOpts{
|
||||||
Name: "jetkvm_timesync_ntp_server_info",
|
Name: "jetkvm_timesync_ntp_server_info",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
package timesync
|
package timesync
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"math/rand/v2"
|
"math/rand/v2"
|
||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
@ -8,22 +9,37 @@ import (
|
||||||
"github.com/beevik/ntp"
|
"github.com/beevik/ntp"
|
||||||
)
|
)
|
||||||
|
|
||||||
var defaultNTPServers = []string{
|
var defaultNTPServerIPs = []string{
|
||||||
|
// These servers are known by static IP and as such don't need DNS lookups
|
||||||
|
// These are from Google and Cloudflare since if they're down, the internet
|
||||||
|
// is broken anyway
|
||||||
|
"162.159.200.1", // time.cloudflare.com IPv4
|
||||||
|
"162.159.200.123", // time.cloudflare.com IPv4
|
||||||
|
"2606:4700:f1::1", // time.cloudflare.com IPv6
|
||||||
|
"2606:4700:f1::123", // time.cloudflare.com IPv6
|
||||||
|
"216.239.35.0", // time.google.com IPv4
|
||||||
|
"216.239.35.4", // time.google.com IPv4
|
||||||
|
"216.239.35.8", // time.google.com IPv4
|
||||||
|
"216.239.35.12", // time.google.com IPv4
|
||||||
|
"2001:4860:4806::", // time.google.com IPv6
|
||||||
|
"2001:4860:4806:4::", // time.google.com IPv6
|
||||||
|
"2001:4860:4806:8::", // time.google.com IPv6
|
||||||
|
"2001:4860:4806:c::", // time.google.com IPv6
|
||||||
|
}
|
||||||
|
|
||||||
|
var defaultNTPServerHostnames = []string{
|
||||||
|
// should use something from https://github.com/jauderho/public-ntp-servers
|
||||||
"time.apple.com",
|
"time.apple.com",
|
||||||
"time.aws.com",
|
"time.aws.com",
|
||||||
"time.windows.com",
|
"time.windows.com",
|
||||||
"time.google.com",
|
"time.google.com",
|
||||||
"162.159.200.123", // time.cloudflare.com IPv4
|
"time.cloudflare.com",
|
||||||
"2606:4700:f1::123", // time.cloudflare.com IPv6
|
"pool.ntp.org",
|
||||||
"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) {
|
func (t *TimeSync) queryNetworkTime(ntpServers []string) (now *time.Time, offset *time.Duration) {
|
||||||
chunkSize := 4
|
chunkSize := int(t.networkConfig.TimeSyncParallel.ValueOr(4))
|
||||||
ntpServers := t.ntpServers
|
t.l.Info().Strs("servers", ntpServers).Int("chunkSize", chunkSize).Msg("querying NTP servers")
|
||||||
|
|
||||||
// shuffle the ntp servers to avoid always querying the same servers
|
// shuffle the ntp servers to avoid always querying the same servers
|
||||||
rand.Shuffle(len(ntpServers), func(i, j int) { ntpServers[i], ntpServers[j] = ntpServers[j], ntpServers[i] })
|
rand.Shuffle(len(ntpServers), func(i, j int) { ntpServers[i], ntpServers[j] = ntpServers[j], ntpServers[i] })
|
||||||
|
|
@ -46,6 +62,10 @@ type ntpResult struct {
|
||||||
|
|
||||||
func (t *TimeSync) queryMultipleNTP(servers []string, timeout time.Duration) (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))
|
results := make(chan *ntpResult, len(servers))
|
||||||
|
|
||||||
|
_, cancel := context.WithTimeout(context.Background(), timeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
for _, server := range servers {
|
for _, server := range servers {
|
||||||
go func(server string) {
|
go func(server string) {
|
||||||
scopedLogger := t.l.With().
|
scopedLogger := t.l.With().
|
||||||
|
|
@ -66,15 +86,25 @@ func (t *TimeSync) queryMultipleNTP(servers []string, timeout time.Duration) (no
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if response.IsKissOfDeath() {
|
||||||
|
scopedLogger.Warn().
|
||||||
|
Str("kiss_code", response.KissCode).
|
||||||
|
Msg("ignoring NTP server kiss of death")
|
||||||
|
results <- nil
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
rtt := float64(response.RTT.Milliseconds())
|
||||||
|
|
||||||
// set the last RTT
|
// set the last RTT
|
||||||
metricNtpServerLastRTT.WithLabelValues(
|
metricNtpServerLastRTT.WithLabelValues(
|
||||||
server,
|
server,
|
||||||
).Set(float64(response.RTT.Milliseconds()))
|
).Set(rtt)
|
||||||
|
|
||||||
// set the RTT histogram
|
// set the RTT histogram
|
||||||
metricNtpServerRttHistogram.WithLabelValues(
|
metricNtpServerRttHistogram.WithLabelValues(
|
||||||
server,
|
server,
|
||||||
).Observe(float64(response.RTT.Milliseconds()))
|
).Observe(rtt)
|
||||||
|
|
||||||
// set the server info
|
// set the server info
|
||||||
metricNtpServerInfo.WithLabelValues(
|
metricNtpServerInfo.WithLabelValues(
|
||||||
|
|
@ -91,10 +121,13 @@ func (t *TimeSync) queryMultipleNTP(servers []string, timeout time.Duration) (no
|
||||||
scopedLogger.Info().
|
scopedLogger.Info().
|
||||||
Str("time", now.Format(time.RFC3339)).
|
Str("time", now.Format(time.RFC3339)).
|
||||||
Str("reference", response.ReferenceString()).
|
Str("reference", response.ReferenceString()).
|
||||||
Str("rtt", response.RTT.String()).
|
Float64("rtt", rtt).
|
||||||
Str("clockOffset", response.ClockOffset.String()).
|
Str("clockOffset", response.ClockOffset.String()).
|
||||||
Uint8("stratum", response.Stratum).
|
Uint8("stratum", response.Stratum).
|
||||||
Msg("NTP server returned time")
|
Msg("NTP server returned time")
|
||||||
|
|
||||||
|
cancel()
|
||||||
|
|
||||||
results <- &ntpResult{
|
results <- &ntpResult{
|
||||||
now: now,
|
now: now,
|
||||||
offset: &response.ClockOffset,
|
offset: &response.ClockOffset,
|
||||||
|
|
|
||||||
|
|
@ -28,9 +28,8 @@ type TimeSync struct {
|
||||||
syncLock *sync.Mutex
|
syncLock *sync.Mutex
|
||||||
l *zerolog.Logger
|
l *zerolog.Logger
|
||||||
|
|
||||||
ntpServers []string
|
networkConfig *network.NetworkConfig
|
||||||
httpUrls []string
|
dhcpNtpAddresses []string
|
||||||
networkConfig *network.NetworkConfig
|
|
||||||
|
|
||||||
rtcDevicePath string
|
rtcDevicePath string
|
||||||
rtcDevice *os.File //nolint:unused
|
rtcDevice *os.File //nolint:unused
|
||||||
|
|
@ -64,14 +63,13 @@ func NewTimeSync(opts *TimeSyncOptions) *TimeSync {
|
||||||
}
|
}
|
||||||
|
|
||||||
t := &TimeSync{
|
t := &TimeSync{
|
||||||
syncLock: &sync.Mutex{},
|
syncLock: &sync.Mutex{},
|
||||||
l: opts.Logger,
|
l: opts.Logger,
|
||||||
rtcDevicePath: rtcDevice,
|
dhcpNtpAddresses: []string{},
|
||||||
rtcLock: &sync.Mutex{},
|
rtcDevicePath: rtcDevice,
|
||||||
preCheckFunc: opts.PreCheckFunc,
|
rtcLock: &sync.Mutex{},
|
||||||
ntpServers: defaultNTPServers,
|
preCheckFunc: opts.PreCheckFunc,
|
||||||
httpUrls: defaultHTTPUrls,
|
networkConfig: opts.NetworkConfig,
|
||||||
networkConfig: opts.NetworkConfig,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if t.rtcDevicePath != "" {
|
if t.rtcDevicePath != "" {
|
||||||
|
|
@ -82,34 +80,42 @@ func NewTimeSync(opts *TimeSyncOptions) *TimeSync {
|
||||||
return t
|
return t
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (t *TimeSync) SetDhcpNtpAddresses(addresses []string) {
|
||||||
|
t.dhcpNtpAddresses = addresses
|
||||||
|
}
|
||||||
|
|
||||||
func (t *TimeSync) getSyncMode() SyncMode {
|
func (t *TimeSync) getSyncMode() SyncMode {
|
||||||
syncMode := SyncMode{
|
syncMode := SyncMode{
|
||||||
|
Ntp: true,
|
||||||
|
Http: true,
|
||||||
|
Ordering: []string{"ntp_dhcp", "ntp", "http"},
|
||||||
NtpUseFallback: true,
|
NtpUseFallback: true,
|
||||||
HttpUseFallback: true,
|
HttpUseFallback: true,
|
||||||
}
|
}
|
||||||
var syncModeString string
|
|
||||||
|
|
||||||
if t.networkConfig != nil {
|
if t.networkConfig != nil {
|
||||||
syncModeString = t.networkConfig.TimeSyncMode.String
|
switch t.networkConfig.TimeSyncMode.String {
|
||||||
|
case "ntp_only":
|
||||||
|
syncMode.Http = false
|
||||||
|
case "http_only":
|
||||||
|
syncMode.Ntp = false
|
||||||
|
}
|
||||||
|
|
||||||
if t.networkConfig.TimeSyncDisableFallback.Bool {
|
if t.networkConfig.TimeSyncDisableFallback.Bool {
|
||||||
syncMode.NtpUseFallback = false
|
syncMode.NtpUseFallback = false
|
||||||
syncMode.HttpUseFallback = false
|
syncMode.HttpUseFallback = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var syncOrdering = t.networkConfig.TimeSyncOrdering
|
||||||
|
if len(syncOrdering) > 0 {
|
||||||
|
syncMode.Ordering = syncOrdering
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
switch syncModeString {
|
t.l.Debug().Strs("Ordering", syncMode.Ordering).Bool("Ntp", syncMode.Ntp).Bool("Http", syncMode.Http).Bool("NtpUseFallback", syncMode.NtpUseFallback).Bool("HttpUseFallback", syncMode.HttpUseFallback).Msg("sync mode")
|
||||||
case "ntp_only":
|
|
||||||
syncMode.Ntp = true
|
|
||||||
case "http_only":
|
|
||||||
syncMode.Http = true
|
|
||||||
default:
|
|
||||||
syncMode.Ntp = true
|
|
||||||
syncMode.Http = true
|
|
||||||
}
|
|
||||||
|
|
||||||
return syncMode
|
return syncMode
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *TimeSync) doTimeSync() {
|
func (t *TimeSync) doTimeSync() {
|
||||||
metricTimeSyncStatus.Set(0)
|
metricTimeSyncStatus.Set(0)
|
||||||
for {
|
for {
|
||||||
|
|
@ -152,18 +158,64 @@ func (t *TimeSync) Sync() error {
|
||||||
var (
|
var (
|
||||||
now *time.Time
|
now *time.Time
|
||||||
offset *time.Duration
|
offset *time.Duration
|
||||||
|
log zerolog.Logger
|
||||||
)
|
)
|
||||||
|
|
||||||
syncMode := t.getSyncMode()
|
|
||||||
|
|
||||||
metricTimeSyncCount.Inc()
|
metricTimeSyncCount.Inc()
|
||||||
|
|
||||||
if syncMode.Ntp {
|
syncMode := t.getSyncMode()
|
||||||
now, offset = t.queryNetworkTime()
|
|
||||||
}
|
|
||||||
|
|
||||||
if syncMode.Http && now == nil {
|
Orders:
|
||||||
now = t.queryAllHttpTime()
|
for _, mode := range syncMode.Ordering {
|
||||||
|
log = t.l.With().Str("mode", mode).Logger()
|
||||||
|
switch mode {
|
||||||
|
case "ntp_user_provided":
|
||||||
|
if syncMode.Ntp {
|
||||||
|
log.Info().Msg("using NTP custom servers")
|
||||||
|
now, offset = t.queryNetworkTime(t.networkConfig.TimeSyncNTPServers)
|
||||||
|
if now != nil {
|
||||||
|
break Orders
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case "ntp_dhcp":
|
||||||
|
if syncMode.Ntp {
|
||||||
|
log.Info().Msg("using NTP servers from DHCP")
|
||||||
|
now, offset = t.queryNetworkTime(t.dhcpNtpAddresses)
|
||||||
|
if now != nil {
|
||||||
|
break Orders
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case "ntp":
|
||||||
|
if syncMode.Ntp && syncMode.NtpUseFallback {
|
||||||
|
log.Info().Msg("using NTP fallback IPs")
|
||||||
|
now, offset = t.queryNetworkTime(defaultNTPServerIPs)
|
||||||
|
if now == nil {
|
||||||
|
log.Info().Msg("using NTP fallback hostnames")
|
||||||
|
now, offset = t.queryNetworkTime(defaultNTPServerHostnames)
|
||||||
|
}
|
||||||
|
if now != nil {
|
||||||
|
break Orders
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case "http_user_provided":
|
||||||
|
if syncMode.Http {
|
||||||
|
log.Info().Msg("using HTTP custom URLs")
|
||||||
|
now = t.queryAllHttpTime(t.networkConfig.TimeSyncHTTPUrls)
|
||||||
|
if now != nil {
|
||||||
|
break Orders
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case "http":
|
||||||
|
if syncMode.Http && syncMode.HttpUseFallback {
|
||||||
|
log.Info().Msg("using HTTP fallback")
|
||||||
|
now = t.queryAllHttpTime(defaultHTTPUrls)
|
||||||
|
if now != nil {
|
||||||
|
break Orders
|
||||||
|
}
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
log.Warn().Msg("unknown time sync mode, skipping")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if now == nil {
|
if now == nil {
|
||||||
|
|
@ -175,6 +227,8 @@ func (t *TimeSync) Sync() error {
|
||||||
now = &newNow
|
now = &newNow
|
||||||
}
|
}
|
||||||
|
|
||||||
|
log.Info().Time("now", *now).Msg("time obtained")
|
||||||
|
|
||||||
err := t.setSystemTime(*now)
|
err := t.setSystemTime(*now)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to set system time: %w", err)
|
return fmt.Errorf("failed to set system time: %w", err)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,72 @@
|
||||||
|
//go:build ignore
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/zip"
|
||||||
|
"bytes"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"text/template"
|
||||||
|
)
|
||||||
|
|
||||||
|
var tmpl = `// Code generated by "go run gen.go". DO NOT EDIT.
|
||||||
|
//go:generate env ZONEINFO=$GOROOT/lib/time/zoneinfo.zip go run gen.go -output tzdata.go
|
||||||
|
package tzdata
|
||||||
|
var TimeZones = []string{
|
||||||
|
{{- range . }}
|
||||||
|
"{{.}}",
|
||||||
|
{{- end }}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
var filename = flag.String("output", "tzdata.go", "output file name")
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
path := os.Getenv("ZONEINFO")
|
||||||
|
if path == "" {
|
||||||
|
fmt.Println("ZONEINFO is not set")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := os.Stat(path); os.IsNotExist(err) {
|
||||||
|
fmt.Printf("ZONEINFO %s does not exist\n", path)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
zipfile, err := zip.OpenReader(path)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("Error opening ZONEINFO %s: %v\n", path, err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
defer zipfile.Close()
|
||||||
|
|
||||||
|
timezones := []string{}
|
||||||
|
|
||||||
|
for _, file := range zipfile.File {
|
||||||
|
timezones = append(timezones, file.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
|
||||||
|
tmpl, err := template.New("tzdata").Parse(tmpl)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("Error parsing template: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = tmpl.Execute(&buf, timezones)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("Error executing template: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = os.WriteFile(*filename, buf.Bytes(), 0644)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("Error writing file %s: %v\n", *filename, err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,602 @@
|
||||||
|
// Code generated by "go run gen.go". DO NOT EDIT.
|
||||||
|
//go:generate env ZONEINFO=$GOROOT/lib/time/zoneinfo.zip go run gen.go -output tzdata.go
|
||||||
|
package tzdata
|
||||||
|
var TimeZones = []string{
|
||||||
|
"Africa/Abidjan",
|
||||||
|
"Africa/Accra",
|
||||||
|
"Africa/Addis_Ababa",
|
||||||
|
"Africa/Algiers",
|
||||||
|
"Africa/Asmara",
|
||||||
|
"Africa/Asmera",
|
||||||
|
"Africa/Bamako",
|
||||||
|
"Africa/Bangui",
|
||||||
|
"Africa/Banjul",
|
||||||
|
"Africa/Bissau",
|
||||||
|
"Africa/Blantyre",
|
||||||
|
"Africa/Brazzaville",
|
||||||
|
"Africa/Bujumbura",
|
||||||
|
"Africa/Cairo",
|
||||||
|
"Africa/Casablanca",
|
||||||
|
"Africa/Ceuta",
|
||||||
|
"Africa/Conakry",
|
||||||
|
"Africa/Dakar",
|
||||||
|
"Africa/Dar_es_Salaam",
|
||||||
|
"Africa/Djibouti",
|
||||||
|
"Africa/Douala",
|
||||||
|
"Africa/El_Aaiun",
|
||||||
|
"Africa/Freetown",
|
||||||
|
"Africa/Gaborone",
|
||||||
|
"Africa/Harare",
|
||||||
|
"Africa/Johannesburg",
|
||||||
|
"Africa/Juba",
|
||||||
|
"Africa/Kampala",
|
||||||
|
"Africa/Khartoum",
|
||||||
|
"Africa/Kigali",
|
||||||
|
"Africa/Kinshasa",
|
||||||
|
"Africa/Lagos",
|
||||||
|
"Africa/Libreville",
|
||||||
|
"Africa/Lome",
|
||||||
|
"Africa/Luanda",
|
||||||
|
"Africa/Lubumbashi",
|
||||||
|
"Africa/Lusaka",
|
||||||
|
"Africa/Malabo",
|
||||||
|
"Africa/Maputo",
|
||||||
|
"Africa/Maseru",
|
||||||
|
"Africa/Mbabane",
|
||||||
|
"Africa/Mogadishu",
|
||||||
|
"Africa/Monrovia",
|
||||||
|
"Africa/Nairobi",
|
||||||
|
"Africa/Ndjamena",
|
||||||
|
"Africa/Niamey",
|
||||||
|
"Africa/Nouakchott",
|
||||||
|
"Africa/Ouagadougou",
|
||||||
|
"Africa/Porto-Novo",
|
||||||
|
"Africa/Sao_Tome",
|
||||||
|
"Africa/Timbuktu",
|
||||||
|
"Africa/Tripoli",
|
||||||
|
"Africa/Tunis",
|
||||||
|
"Africa/Windhoek",
|
||||||
|
"America/Adak",
|
||||||
|
"America/Anchorage",
|
||||||
|
"America/Anguilla",
|
||||||
|
"America/Antigua",
|
||||||
|
"America/Araguaina",
|
||||||
|
"America/Argentina/Buenos_Aires",
|
||||||
|
"America/Argentina/Catamarca",
|
||||||
|
"America/Argentina/ComodRivadavia",
|
||||||
|
"America/Argentina/Cordoba",
|
||||||
|
"America/Argentina/Jujuy",
|
||||||
|
"America/Argentina/La_Rioja",
|
||||||
|
"America/Argentina/Mendoza",
|
||||||
|
"America/Argentina/Rio_Gallegos",
|
||||||
|
"America/Argentina/Salta",
|
||||||
|
"America/Argentina/San_Juan",
|
||||||
|
"America/Argentina/San_Luis",
|
||||||
|
"America/Argentina/Tucuman",
|
||||||
|
"America/Argentina/Ushuaia",
|
||||||
|
"America/Aruba",
|
||||||
|
"America/Asuncion",
|
||||||
|
"America/Atikokan",
|
||||||
|
"America/Atka",
|
||||||
|
"America/Bahia",
|
||||||
|
"America/Bahia_Banderas",
|
||||||
|
"America/Barbados",
|
||||||
|
"America/Belem",
|
||||||
|
"America/Belize",
|
||||||
|
"America/Blanc-Sablon",
|
||||||
|
"America/Boa_Vista",
|
||||||
|
"America/Bogota",
|
||||||
|
"America/Boise",
|
||||||
|
"America/Buenos_Aires",
|
||||||
|
"America/Cambridge_Bay",
|
||||||
|
"America/Campo_Grande",
|
||||||
|
"America/Cancun",
|
||||||
|
"America/Caracas",
|
||||||
|
"America/Catamarca",
|
||||||
|
"America/Cayenne",
|
||||||
|
"America/Cayman",
|
||||||
|
"America/Chicago",
|
||||||
|
"America/Chihuahua",
|
||||||
|
"America/Ciudad_Juarez",
|
||||||
|
"America/Coral_Harbour",
|
||||||
|
"America/Cordoba",
|
||||||
|
"America/Costa_Rica",
|
||||||
|
"America/Creston",
|
||||||
|
"America/Cuiaba",
|
||||||
|
"America/Curacao",
|
||||||
|
"America/Danmarkshavn",
|
||||||
|
"America/Dawson",
|
||||||
|
"America/Dawson_Creek",
|
||||||
|
"America/Denver",
|
||||||
|
"America/Detroit",
|
||||||
|
"America/Dominica",
|
||||||
|
"America/Edmonton",
|
||||||
|
"America/Eirunepe",
|
||||||
|
"America/El_Salvador",
|
||||||
|
"America/Ensenada",
|
||||||
|
"America/Fort_Nelson",
|
||||||
|
"America/Fort_Wayne",
|
||||||
|
"America/Fortaleza",
|
||||||
|
"America/Glace_Bay",
|
||||||
|
"America/Godthab",
|
||||||
|
"America/Goose_Bay",
|
||||||
|
"America/Grand_Turk",
|
||||||
|
"America/Grenada",
|
||||||
|
"America/Guadeloupe",
|
||||||
|
"America/Guatemala",
|
||||||
|
"America/Guayaquil",
|
||||||
|
"America/Guyana",
|
||||||
|
"America/Halifax",
|
||||||
|
"America/Havana",
|
||||||
|
"America/Hermosillo",
|
||||||
|
"America/Indiana/Indianapolis",
|
||||||
|
"America/Indiana/Knox",
|
||||||
|
"America/Indiana/Marengo",
|
||||||
|
"America/Indiana/Petersburg",
|
||||||
|
"America/Indiana/Tell_City",
|
||||||
|
"America/Indiana/Vevay",
|
||||||
|
"America/Indiana/Vincennes",
|
||||||
|
"America/Indiana/Winamac",
|
||||||
|
"America/Indianapolis",
|
||||||
|
"America/Inuvik",
|
||||||
|
"America/Iqaluit",
|
||||||
|
"America/Jamaica",
|
||||||
|
"America/Jujuy",
|
||||||
|
"America/Juneau",
|
||||||
|
"America/Kentucky/Louisville",
|
||||||
|
"America/Kentucky/Monticello",
|
||||||
|
"America/Knox_IN",
|
||||||
|
"America/Kralendijk",
|
||||||
|
"America/La_Paz",
|
||||||
|
"America/Lima",
|
||||||
|
"America/Los_Angeles",
|
||||||
|
"America/Louisville",
|
||||||
|
"America/Lower_Princes",
|
||||||
|
"America/Maceio",
|
||||||
|
"America/Managua",
|
||||||
|
"America/Manaus",
|
||||||
|
"America/Marigot",
|
||||||
|
"America/Martinique",
|
||||||
|
"America/Matamoros",
|
||||||
|
"America/Mazatlan",
|
||||||
|
"America/Mendoza",
|
||||||
|
"America/Menominee",
|
||||||
|
"America/Merida",
|
||||||
|
"America/Metlakatla",
|
||||||
|
"America/Mexico_City",
|
||||||
|
"America/Miquelon",
|
||||||
|
"America/Moncton",
|
||||||
|
"America/Monterrey",
|
||||||
|
"America/Montevideo",
|
||||||
|
"America/Montreal",
|
||||||
|
"America/Montserrat",
|
||||||
|
"America/Nassau",
|
||||||
|
"America/New_York",
|
||||||
|
"America/Nipigon",
|
||||||
|
"America/Nome",
|
||||||
|
"America/Noronha",
|
||||||
|
"America/North_Dakota/Beulah",
|
||||||
|
"America/North_Dakota/Center",
|
||||||
|
"America/North_Dakota/New_Salem",
|
||||||
|
"America/Nuuk",
|
||||||
|
"America/Ojinaga",
|
||||||
|
"America/Panama",
|
||||||
|
"America/Pangnirtung",
|
||||||
|
"America/Paramaribo",
|
||||||
|
"America/Phoenix",
|
||||||
|
"America/Port-au-Prince",
|
||||||
|
"America/Port_of_Spain",
|
||||||
|
"America/Porto_Acre",
|
||||||
|
"America/Porto_Velho",
|
||||||
|
"America/Puerto_Rico",
|
||||||
|
"America/Punta_Arenas",
|
||||||
|
"America/Rainy_River",
|
||||||
|
"America/Rankin_Inlet",
|
||||||
|
"America/Recife",
|
||||||
|
"America/Regina",
|
||||||
|
"America/Resolute",
|
||||||
|
"America/Rio_Branco",
|
||||||
|
"America/Rosario",
|
||||||
|
"America/Santa_Isabel",
|
||||||
|
"America/Santarem",
|
||||||
|
"America/Santiago",
|
||||||
|
"America/Santo_Domingo",
|
||||||
|
"America/Sao_Paulo",
|
||||||
|
"America/Scoresbysund",
|
||||||
|
"America/Shiprock",
|
||||||
|
"America/Sitka",
|
||||||
|
"America/St_Barthelemy",
|
||||||
|
"America/St_Johns",
|
||||||
|
"America/St_Kitts",
|
||||||
|
"America/St_Lucia",
|
||||||
|
"America/St_Thomas",
|
||||||
|
"America/St_Vincent",
|
||||||
|
"America/Swift_Current",
|
||||||
|
"America/Tegucigalpa",
|
||||||
|
"America/Thule",
|
||||||
|
"America/Thunder_Bay",
|
||||||
|
"America/Tijuana",
|
||||||
|
"America/Toronto",
|
||||||
|
"America/Tortola",
|
||||||
|
"America/Vancouver",
|
||||||
|
"America/Virgin",
|
||||||
|
"America/Whitehorse",
|
||||||
|
"America/Winnipeg",
|
||||||
|
"America/Yakutat",
|
||||||
|
"America/Yellowknife",
|
||||||
|
"Antarctica/Casey",
|
||||||
|
"Antarctica/Davis",
|
||||||
|
"Antarctica/DumontDUrville",
|
||||||
|
"Antarctica/Macquarie",
|
||||||
|
"Antarctica/Mawson",
|
||||||
|
"Antarctica/McMurdo",
|
||||||
|
"Antarctica/Palmer",
|
||||||
|
"Antarctica/Rothera",
|
||||||
|
"Antarctica/South_Pole",
|
||||||
|
"Antarctica/Syowa",
|
||||||
|
"Antarctica/Troll",
|
||||||
|
"Antarctica/Vostok",
|
||||||
|
"Arctic/Longyearbyen",
|
||||||
|
"Asia/Aden",
|
||||||
|
"Asia/Almaty",
|
||||||
|
"Asia/Amman",
|
||||||
|
"Asia/Anadyr",
|
||||||
|
"Asia/Aqtau",
|
||||||
|
"Asia/Aqtobe",
|
||||||
|
"Asia/Ashgabat",
|
||||||
|
"Asia/Ashkhabad",
|
||||||
|
"Asia/Atyrau",
|
||||||
|
"Asia/Baghdad",
|
||||||
|
"Asia/Bahrain",
|
||||||
|
"Asia/Baku",
|
||||||
|
"Asia/Bangkok",
|
||||||
|
"Asia/Barnaul",
|
||||||
|
"Asia/Beirut",
|
||||||
|
"Asia/Bishkek",
|
||||||
|
"Asia/Brunei",
|
||||||
|
"Asia/Calcutta",
|
||||||
|
"Asia/Chita",
|
||||||
|
"Asia/Choibalsan",
|
||||||
|
"Asia/Chongqing",
|
||||||
|
"Asia/Chungking",
|
||||||
|
"Asia/Colombo",
|
||||||
|
"Asia/Dacca",
|
||||||
|
"Asia/Damascus",
|
||||||
|
"Asia/Dhaka",
|
||||||
|
"Asia/Dili",
|
||||||
|
"Asia/Dubai",
|
||||||
|
"Asia/Dushanbe",
|
||||||
|
"Asia/Famagusta",
|
||||||
|
"Asia/Gaza",
|
||||||
|
"Asia/Harbin",
|
||||||
|
"Asia/Hebron",
|
||||||
|
"Asia/Ho_Chi_Minh",
|
||||||
|
"Asia/Hong_Kong",
|
||||||
|
"Asia/Hovd",
|
||||||
|
"Asia/Irkutsk",
|
||||||
|
"Asia/Istanbul",
|
||||||
|
"Asia/Jakarta",
|
||||||
|
"Asia/Jayapura",
|
||||||
|
"Asia/Jerusalem",
|
||||||
|
"Asia/Kabul",
|
||||||
|
"Asia/Kamchatka",
|
||||||
|
"Asia/Karachi",
|
||||||
|
"Asia/Kashgar",
|
||||||
|
"Asia/Kathmandu",
|
||||||
|
"Asia/Katmandu",
|
||||||
|
"Asia/Khandyga",
|
||||||
|
"Asia/Kolkata",
|
||||||
|
"Asia/Krasnoyarsk",
|
||||||
|
"Asia/Kuala_Lumpur",
|
||||||
|
"Asia/Kuching",
|
||||||
|
"Asia/Kuwait",
|
||||||
|
"Asia/Macao",
|
||||||
|
"Asia/Macau",
|
||||||
|
"Asia/Magadan",
|
||||||
|
"Asia/Makassar",
|
||||||
|
"Asia/Manila",
|
||||||
|
"Asia/Muscat",
|
||||||
|
"Asia/Nicosia",
|
||||||
|
"Asia/Novokuznetsk",
|
||||||
|
"Asia/Novosibirsk",
|
||||||
|
"Asia/Omsk",
|
||||||
|
"Asia/Oral",
|
||||||
|
"Asia/Phnom_Penh",
|
||||||
|
"Asia/Pontianak",
|
||||||
|
"Asia/Pyongyang",
|
||||||
|
"Asia/Qatar",
|
||||||
|
"Asia/Qostanay",
|
||||||
|
"Asia/Qyzylorda",
|
||||||
|
"Asia/Rangoon",
|
||||||
|
"Asia/Riyadh",
|
||||||
|
"Asia/Saigon",
|
||||||
|
"Asia/Sakhalin",
|
||||||
|
"Asia/Samarkand",
|
||||||
|
"Asia/Seoul",
|
||||||
|
"Asia/Shanghai",
|
||||||
|
"Asia/Singapore",
|
||||||
|
"Asia/Srednekolymsk",
|
||||||
|
"Asia/Taipei",
|
||||||
|
"Asia/Tashkent",
|
||||||
|
"Asia/Tbilisi",
|
||||||
|
"Asia/Tehran",
|
||||||
|
"Asia/Tel_Aviv",
|
||||||
|
"Asia/Thimbu",
|
||||||
|
"Asia/Thimphu",
|
||||||
|
"Asia/Tokyo",
|
||||||
|
"Asia/Tomsk",
|
||||||
|
"Asia/Ujung_Pandang",
|
||||||
|
"Asia/Ulaanbaatar",
|
||||||
|
"Asia/Ulan_Bator",
|
||||||
|
"Asia/Urumqi",
|
||||||
|
"Asia/Ust-Nera",
|
||||||
|
"Asia/Vientiane",
|
||||||
|
"Asia/Vladivostok",
|
||||||
|
"Asia/Yakutsk",
|
||||||
|
"Asia/Yangon",
|
||||||
|
"Asia/Yekaterinburg",
|
||||||
|
"Asia/Yerevan",
|
||||||
|
"Atlantic/Azores",
|
||||||
|
"Atlantic/Bermuda",
|
||||||
|
"Atlantic/Canary",
|
||||||
|
"Atlantic/Cape_Verde",
|
||||||
|
"Atlantic/Faeroe",
|
||||||
|
"Atlantic/Faroe",
|
||||||
|
"Atlantic/Jan_Mayen",
|
||||||
|
"Atlantic/Madeira",
|
||||||
|
"Atlantic/Reykjavik",
|
||||||
|
"Atlantic/South_Georgia",
|
||||||
|
"Atlantic/St_Helena",
|
||||||
|
"Atlantic/Stanley",
|
||||||
|
"Australia/ACT",
|
||||||
|
"Australia/Adelaide",
|
||||||
|
"Australia/Brisbane",
|
||||||
|
"Australia/Broken_Hill",
|
||||||
|
"Australia/Canberra",
|
||||||
|
"Australia/Currie",
|
||||||
|
"Australia/Darwin",
|
||||||
|
"Australia/Eucla",
|
||||||
|
"Australia/Hobart",
|
||||||
|
"Australia/LHI",
|
||||||
|
"Australia/Lindeman",
|
||||||
|
"Australia/Lord_Howe",
|
||||||
|
"Australia/Melbourne",
|
||||||
|
"Australia/NSW",
|
||||||
|
"Australia/North",
|
||||||
|
"Australia/Perth",
|
||||||
|
"Australia/Queensland",
|
||||||
|
"Australia/South",
|
||||||
|
"Australia/Sydney",
|
||||||
|
"Australia/Tasmania",
|
||||||
|
"Australia/Victoria",
|
||||||
|
"Australia/West",
|
||||||
|
"Australia/Yancowinna",
|
||||||
|
"Brazil/Acre",
|
||||||
|
"Brazil/DeNoronha",
|
||||||
|
"Brazil/East",
|
||||||
|
"Brazil/West",
|
||||||
|
"CET",
|
||||||
|
"CST6CDT",
|
||||||
|
"Canada/Atlantic",
|
||||||
|
"Canada/Central",
|
||||||
|
"Canada/Eastern",
|
||||||
|
"Canada/Mountain",
|
||||||
|
"Canada/Newfoundland",
|
||||||
|
"Canada/Pacific",
|
||||||
|
"Canada/Saskatchewan",
|
||||||
|
"Canada/Yukon",
|
||||||
|
"Chile/Continental",
|
||||||
|
"Chile/EasterIsland",
|
||||||
|
"Cuba",
|
||||||
|
"EET",
|
||||||
|
"EST",
|
||||||
|
"EST5EDT",
|
||||||
|
"Egypt",
|
||||||
|
"Eire",
|
||||||
|
"Etc/GMT",
|
||||||
|
"Etc/GMT+0",
|
||||||
|
"Etc/GMT+1",
|
||||||
|
"Etc/GMT+10",
|
||||||
|
"Etc/GMT+11",
|
||||||
|
"Etc/GMT+12",
|
||||||
|
"Etc/GMT+2",
|
||||||
|
"Etc/GMT+3",
|
||||||
|
"Etc/GMT+4",
|
||||||
|
"Etc/GMT+5",
|
||||||
|
"Etc/GMT+6",
|
||||||
|
"Etc/GMT+7",
|
||||||
|
"Etc/GMT+8",
|
||||||
|
"Etc/GMT+9",
|
||||||
|
"Etc/GMT-0",
|
||||||
|
"Etc/GMT-1",
|
||||||
|
"Etc/GMT-10",
|
||||||
|
"Etc/GMT-11",
|
||||||
|
"Etc/GMT-12",
|
||||||
|
"Etc/GMT-13",
|
||||||
|
"Etc/GMT-14",
|
||||||
|
"Etc/GMT-2",
|
||||||
|
"Etc/GMT-3",
|
||||||
|
"Etc/GMT-4",
|
||||||
|
"Etc/GMT-5",
|
||||||
|
"Etc/GMT-6",
|
||||||
|
"Etc/GMT-7",
|
||||||
|
"Etc/GMT-8",
|
||||||
|
"Etc/GMT-9",
|
||||||
|
"Etc/GMT0",
|
||||||
|
"Etc/Greenwich",
|
||||||
|
"Etc/UCT",
|
||||||
|
"Etc/UTC",
|
||||||
|
"Etc/Universal",
|
||||||
|
"Etc/Zulu",
|
||||||
|
"Europe/Amsterdam",
|
||||||
|
"Europe/Andorra",
|
||||||
|
"Europe/Astrakhan",
|
||||||
|
"Europe/Athens",
|
||||||
|
"Europe/Belfast",
|
||||||
|
"Europe/Belgrade",
|
||||||
|
"Europe/Berlin",
|
||||||
|
"Europe/Bratislava",
|
||||||
|
"Europe/Brussels",
|
||||||
|
"Europe/Bucharest",
|
||||||
|
"Europe/Budapest",
|
||||||
|
"Europe/Busingen",
|
||||||
|
"Europe/Chisinau",
|
||||||
|
"Europe/Copenhagen",
|
||||||
|
"Europe/Dublin",
|
||||||
|
"Europe/Gibraltar",
|
||||||
|
"Europe/Guernsey",
|
||||||
|
"Europe/Helsinki",
|
||||||
|
"Europe/Isle_of_Man",
|
||||||
|
"Europe/Istanbul",
|
||||||
|
"Europe/Jersey",
|
||||||
|
"Europe/Kaliningrad",
|
||||||
|
"Europe/Kiev",
|
||||||
|
"Europe/Kirov",
|
||||||
|
"Europe/Kyiv",
|
||||||
|
"Europe/Lisbon",
|
||||||
|
"Europe/Ljubljana",
|
||||||
|
"Europe/London",
|
||||||
|
"Europe/Luxembourg",
|
||||||
|
"Europe/Madrid",
|
||||||
|
"Europe/Malta",
|
||||||
|
"Europe/Mariehamn",
|
||||||
|
"Europe/Minsk",
|
||||||
|
"Europe/Monaco",
|
||||||
|
"Europe/Moscow",
|
||||||
|
"Europe/Nicosia",
|
||||||
|
"Europe/Oslo",
|
||||||
|
"Europe/Paris",
|
||||||
|
"Europe/Podgorica",
|
||||||
|
"Europe/Prague",
|
||||||
|
"Europe/Riga",
|
||||||
|
"Europe/Rome",
|
||||||
|
"Europe/Samara",
|
||||||
|
"Europe/San_Marino",
|
||||||
|
"Europe/Sarajevo",
|
||||||
|
"Europe/Saratov",
|
||||||
|
"Europe/Simferopol",
|
||||||
|
"Europe/Skopje",
|
||||||
|
"Europe/Sofia",
|
||||||
|
"Europe/Stockholm",
|
||||||
|
"Europe/Tallinn",
|
||||||
|
"Europe/Tirane",
|
||||||
|
"Europe/Tiraspol",
|
||||||
|
"Europe/Ulyanovsk",
|
||||||
|
"Europe/Uzhgorod",
|
||||||
|
"Europe/Vaduz",
|
||||||
|
"Europe/Vatican",
|
||||||
|
"Europe/Vienna",
|
||||||
|
"Europe/Vilnius",
|
||||||
|
"Europe/Volgograd",
|
||||||
|
"Europe/Warsaw",
|
||||||
|
"Europe/Zagreb",
|
||||||
|
"Europe/Zaporozhye",
|
||||||
|
"Europe/Zurich",
|
||||||
|
"Factory",
|
||||||
|
"GB",
|
||||||
|
"GB-Eire",
|
||||||
|
"GMT",
|
||||||
|
"GMT+0",
|
||||||
|
"GMT-0",
|
||||||
|
"GMT0",
|
||||||
|
"Greenwich",
|
||||||
|
"HST",
|
||||||
|
"Hongkong",
|
||||||
|
"Iceland",
|
||||||
|
"Indian/Antananarivo",
|
||||||
|
"Indian/Chagos",
|
||||||
|
"Indian/Christmas",
|
||||||
|
"Indian/Cocos",
|
||||||
|
"Indian/Comoro",
|
||||||
|
"Indian/Kerguelen",
|
||||||
|
"Indian/Mahe",
|
||||||
|
"Indian/Maldives",
|
||||||
|
"Indian/Mauritius",
|
||||||
|
"Indian/Mayotte",
|
||||||
|
"Indian/Reunion",
|
||||||
|
"Iran",
|
||||||
|
"Israel",
|
||||||
|
"Jamaica",
|
||||||
|
"Japan",
|
||||||
|
"Kwajalein",
|
||||||
|
"Libya",
|
||||||
|
"MET",
|
||||||
|
"MST",
|
||||||
|
"MST7MDT",
|
||||||
|
"Mexico/BajaNorte",
|
||||||
|
"Mexico/BajaSur",
|
||||||
|
"Mexico/General",
|
||||||
|
"NZ",
|
||||||
|
"NZ-CHAT",
|
||||||
|
"Navajo",
|
||||||
|
"PRC",
|
||||||
|
"PST8PDT",
|
||||||
|
"Pacific/Apia",
|
||||||
|
"Pacific/Auckland",
|
||||||
|
"Pacific/Bougainville",
|
||||||
|
"Pacific/Chatham",
|
||||||
|
"Pacific/Chuuk",
|
||||||
|
"Pacific/Easter",
|
||||||
|
"Pacific/Efate",
|
||||||
|
"Pacific/Enderbury",
|
||||||
|
"Pacific/Fakaofo",
|
||||||
|
"Pacific/Fiji",
|
||||||
|
"Pacific/Funafuti",
|
||||||
|
"Pacific/Galapagos",
|
||||||
|
"Pacific/Gambier",
|
||||||
|
"Pacific/Guadalcanal",
|
||||||
|
"Pacific/Guam",
|
||||||
|
"Pacific/Honolulu",
|
||||||
|
"Pacific/Johnston",
|
||||||
|
"Pacific/Kanton",
|
||||||
|
"Pacific/Kiritimati",
|
||||||
|
"Pacific/Kosrae",
|
||||||
|
"Pacific/Kwajalein",
|
||||||
|
"Pacific/Majuro",
|
||||||
|
"Pacific/Marquesas",
|
||||||
|
"Pacific/Midway",
|
||||||
|
"Pacific/Nauru",
|
||||||
|
"Pacific/Niue",
|
||||||
|
"Pacific/Norfolk",
|
||||||
|
"Pacific/Noumea",
|
||||||
|
"Pacific/Pago_Pago",
|
||||||
|
"Pacific/Palau",
|
||||||
|
"Pacific/Pitcairn",
|
||||||
|
"Pacific/Pohnpei",
|
||||||
|
"Pacific/Ponape",
|
||||||
|
"Pacific/Port_Moresby",
|
||||||
|
"Pacific/Rarotonga",
|
||||||
|
"Pacific/Saipan",
|
||||||
|
"Pacific/Samoa",
|
||||||
|
"Pacific/Tahiti",
|
||||||
|
"Pacific/Tarawa",
|
||||||
|
"Pacific/Tongatapu",
|
||||||
|
"Pacific/Truk",
|
||||||
|
"Pacific/Wake",
|
||||||
|
"Pacific/Wallis",
|
||||||
|
"Pacific/Yap",
|
||||||
|
"Poland",
|
||||||
|
"Portugal",
|
||||||
|
"ROC",
|
||||||
|
"ROK",
|
||||||
|
"Singapore",
|
||||||
|
"Turkey",
|
||||||
|
"UCT",
|
||||||
|
"US/Alaska",
|
||||||
|
"US/Aleutian",
|
||||||
|
"US/Arizona",
|
||||||
|
"US/Central",
|
||||||
|
"US/East-Indiana",
|
||||||
|
"US/Eastern",
|
||||||
|
"US/Hawaii",
|
||||||
|
"US/Indiana-Starke",
|
||||||
|
"US/Michigan",
|
||||||
|
"US/Mountain",
|
||||||
|
"US/Pacific",
|
||||||
|
"US/Samoa",
|
||||||
|
"UTC",
|
||||||
|
"Universal",
|
||||||
|
"W-SU",
|
||||||
|
"WET",
|
||||||
|
"Zulu",
|
||||||
|
}
|
||||||
|
|
@ -101,7 +101,7 @@ func (l *Lease) SetLeaseExpiry() (time.Time, error) {
|
||||||
func UnmarshalDHCPCLease(lease *Lease, str string) error {
|
func UnmarshalDHCPCLease(lease *Lease, str string) error {
|
||||||
// parse the lease file as a map
|
// parse the lease file as a map
|
||||||
data := make(map[string]string)
|
data := make(map[string]string)
|
||||||
for _, line := range strings.Split(str, "\n") {
|
for line := range strings.SplitSeq(str, "\n") {
|
||||||
line = strings.TrimSpace(line)
|
line = strings.TrimSpace(line)
|
||||||
// skip empty lines and comments
|
// skip empty lines and comments
|
||||||
if line == "" || strings.HasPrefix(line, "#") {
|
if line == "" || strings.HasPrefix(line, "#") {
|
||||||
|
|
@ -165,7 +165,7 @@ func UnmarshalDHCPCLease(lease *Lease, str string) error {
|
||||||
field.Set(reflect.ValueOf(ip))
|
field.Set(reflect.ValueOf(ip))
|
||||||
case []net.IP:
|
case []net.IP:
|
||||||
val := make([]net.IP, 0)
|
val := make([]net.IP, 0)
|
||||||
for _, ipStr := range strings.Fields(value) {
|
for ipStr := range strings.FieldsSeq(value) {
|
||||||
ip := net.ParseIP(ipStr)
|
ip := net.ParseIP(ipStr)
|
||||||
if ip == nil {
|
if ip == nil {
|
||||||
continue
|
continue
|
||||||
|
|
|
||||||
|
|
@ -52,7 +52,7 @@ func NewDHCPClient(options *DHCPClientOptions) *DHCPClient {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *DHCPClient) getWatchPaths() []string {
|
func (c *DHCPClient) getWatchPaths() []string {
|
||||||
watchPaths := make(map[string]interface{})
|
watchPaths := make(map[string]any)
|
||||||
watchPaths[filepath.Dir(c.leaseFile)] = nil
|
watchPaths[filepath.Dir(c.leaseFile)] = nil
|
||||||
|
|
||||||
if c.pidFile != "" {
|
if c.pidFile != "" {
|
||||||
|
|
|
||||||
|
|
@ -30,8 +30,8 @@ var defaultGadgetConfig = map[string]gadgetConfigItem{
|
||||||
attrs: gadgetAttributes{
|
attrs: gadgetAttributes{
|
||||||
"bcdUSB": "0x0200", // USB 2.0
|
"bcdUSB": "0x0200", // USB 2.0
|
||||||
"idVendor": "0x1d6b", // The Linux Foundation
|
"idVendor": "0x1d6b", // The Linux Foundation
|
||||||
"idProduct": "0104", // Multifunction Composite Gadget
|
"idProduct": "0x0104", // Multifunction Composite Gadget
|
||||||
"bcdDevice": "0100",
|
"bcdDevice": "0x0100", // USB2
|
||||||
},
|
},
|
||||||
configAttrs: gadgetAttributes{
|
configAttrs: gadgetAttributes{
|
||||||
"MaxPower": "250", // in unit of 2mA
|
"MaxPower": "250", // in unit of 2mA
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,7 @@
|
||||||
package usbgadget
|
package usbgadget
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
const dwc3Path = "/sys/bus/platform/drivers/dwc3"
|
const dwc3Path = "/sys/bus/platform/drivers/dwc3"
|
||||||
|
|
||||||
|
const hidWriteTimeout = 10 * time.Millisecond
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,15 @@
|
||||||
package usbgadget
|
package usbgadget
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"reflect"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/rs/xid"
|
||||||
|
"github.com/rs/zerolog"
|
||||||
)
|
)
|
||||||
|
|
||||||
var keyboardConfig = gadgetConfigItem{
|
var keyboardConfig = gadgetConfigItem{
|
||||||
|
|
@ -14,9 +18,10 @@ var keyboardConfig = gadgetConfigItem{
|
||||||
path: []string{"functions", "hid.usb0"},
|
path: []string{"functions", "hid.usb0"},
|
||||||
configPath: []string{"hid.usb0"},
|
configPath: []string{"hid.usb0"},
|
||||||
attrs: gadgetAttributes{
|
attrs: gadgetAttributes{
|
||||||
"protocol": "1",
|
"protocol": "1",
|
||||||
"subclass": "1",
|
"subclass": "1",
|
||||||
"report_length": "8",
|
"report_length": "8",
|
||||||
|
"no_out_endpoint": "0",
|
||||||
},
|
},
|
||||||
reportDesc: keyboardReportDesc,
|
reportDesc: keyboardReportDesc,
|
||||||
}
|
}
|
||||||
|
|
@ -60,6 +65,8 @@ var keyboardReportDesc = []byte{
|
||||||
|
|
||||||
const (
|
const (
|
||||||
hidReadBufferSize = 8
|
hidReadBufferSize = 8
|
||||||
|
hidKeyBufferSize = 6
|
||||||
|
hidErrorRollOver = 0x01
|
||||||
// https://www.usb.org/sites/default/files/documents/hid1_11.pdf
|
// https://www.usb.org/sites/default/files/documents/hid1_11.pdf
|
||||||
// https://www.usb.org/sites/default/files/hut1_2.pdf
|
// https://www.usb.org/sites/default/files/hut1_2.pdf
|
||||||
KeyboardLedMaskNumLock = 1 << 0
|
KeyboardLedMaskNumLock = 1 << 0
|
||||||
|
|
@ -67,7 +74,9 @@ const (
|
||||||
KeyboardLedMaskScrollLock = 1 << 2
|
KeyboardLedMaskScrollLock = 1 << 2
|
||||||
KeyboardLedMaskCompose = 1 << 3
|
KeyboardLedMaskCompose = 1 << 3
|
||||||
KeyboardLedMaskKana = 1 << 4
|
KeyboardLedMaskKana = 1 << 4
|
||||||
ValidKeyboardLedMasks = KeyboardLedMaskNumLock | KeyboardLedMaskCapsLock | KeyboardLedMaskScrollLock | KeyboardLedMaskCompose | KeyboardLedMaskKana
|
// power on/off LED is 5
|
||||||
|
KeyboardLedMaskShift = 1 << 6
|
||||||
|
ValidKeyboardLedMasks = KeyboardLedMaskNumLock | KeyboardLedMaskCapsLock | KeyboardLedMaskScrollLock | KeyboardLedMaskCompose | KeyboardLedMaskKana | KeyboardLedMaskShift
|
||||||
)
|
)
|
||||||
|
|
||||||
// Synchronization between LED states and CAPS LOCK, NUM LOCK, SCROLL LOCK,
|
// Synchronization between LED states and CAPS LOCK, NUM LOCK, SCROLL LOCK,
|
||||||
|
|
@ -80,6 +89,13 @@ type KeyboardState struct {
|
||||||
ScrollLock bool `json:"scroll_lock"`
|
ScrollLock bool `json:"scroll_lock"`
|
||||||
Compose bool `json:"compose"`
|
Compose bool `json:"compose"`
|
||||||
Kana bool `json:"kana"`
|
Kana bool `json:"kana"`
|
||||||
|
Shift bool `json:"shift"` // This is not part of the main USB HID spec
|
||||||
|
raw byte
|
||||||
|
}
|
||||||
|
|
||||||
|
// Byte returns the raw byte representation of the keyboard state.
|
||||||
|
func (k *KeyboardState) Byte() byte {
|
||||||
|
return k.raw
|
||||||
}
|
}
|
||||||
|
|
||||||
func getKeyboardState(b byte) KeyboardState {
|
func getKeyboardState(b byte) KeyboardState {
|
||||||
|
|
@ -90,27 +106,28 @@ func getKeyboardState(b byte) KeyboardState {
|
||||||
ScrollLock: b&KeyboardLedMaskScrollLock != 0,
|
ScrollLock: b&KeyboardLedMaskScrollLock != 0,
|
||||||
Compose: b&KeyboardLedMaskCompose != 0,
|
Compose: b&KeyboardLedMaskCompose != 0,
|
||||||
Kana: b&KeyboardLedMaskKana != 0,
|
Kana: b&KeyboardLedMaskKana != 0,
|
||||||
|
Shift: b&KeyboardLedMaskShift != 0,
|
||||||
|
raw: b,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (u *UsbGadget) updateKeyboardState(b byte) {
|
func (u *UsbGadget) updateKeyboardState(state byte) {
|
||||||
u.keyboardStateLock.Lock()
|
u.keyboardStateLock.Lock()
|
||||||
defer u.keyboardStateLock.Unlock()
|
defer u.keyboardStateLock.Unlock()
|
||||||
|
|
||||||
if b&^ValidKeyboardLedMasks != 0 {
|
if state&^ValidKeyboardLedMasks != 0 {
|
||||||
u.log.Trace().Uint8("b", b).Msg("contains invalid bits, ignoring")
|
u.log.Warn().Uint8("state", state).Msg("ignoring invalid bits")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
newState := getKeyboardState(b)
|
if u.keyboardState == state {
|
||||||
if reflect.DeepEqual(u.keyboardState, newState) {
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
u.log.Info().Interface("old", u.keyboardState).Interface("new", newState).Msg("keyboardState updated")
|
u.log.Trace().Uint8("old", u.keyboardState).Uint8("new", state).Msg("keyboardState updated")
|
||||||
u.keyboardState = newState
|
u.keyboardState = state
|
||||||
|
|
||||||
if u.onKeyboardStateChange != nil {
|
if u.onKeyboardStateChange != nil {
|
||||||
(*u.onKeyboardStateChange)(newState)
|
(*u.onKeyboardStateChange)(getKeyboardState(state))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -122,7 +139,105 @@ func (u *UsbGadget) GetKeyboardState() KeyboardState {
|
||||||
u.keyboardStateLock.Lock()
|
u.keyboardStateLock.Lock()
|
||||||
defer u.keyboardStateLock.Unlock()
|
defer u.keyboardStateLock.Unlock()
|
||||||
|
|
||||||
return u.keyboardState
|
return getKeyboardState(u.keyboardState)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *UsbGadget) GetKeysDownState() KeysDownState {
|
||||||
|
u.keyboardStateLock.Lock()
|
||||||
|
defer u.keyboardStateLock.Unlock()
|
||||||
|
|
||||||
|
return u.keysDownState
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *UsbGadget) SetOnKeysDownChange(f func(state KeysDownState)) {
|
||||||
|
u.onKeysDownChange = &f
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *UsbGadget) SetOnKeepAliveReset(f func()) {
|
||||||
|
u.onKeepAliveReset = &f
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultAutoReleaseDuration is the default duration for auto-release of a key.
|
||||||
|
const DefaultAutoReleaseDuration = 100 * time.Millisecond
|
||||||
|
|
||||||
|
func (u *UsbGadget) scheduleAutoRelease(key byte) {
|
||||||
|
u.kbdAutoReleaseLock.Lock()
|
||||||
|
defer unlockWithLog(&u.kbdAutoReleaseLock, u.log, "autoRelease scheduled")
|
||||||
|
|
||||||
|
if u.kbdAutoReleaseTimers[key] != nil {
|
||||||
|
u.kbdAutoReleaseTimers[key].Stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: make this configurable
|
||||||
|
// We currently hardcode the duration to 100ms
|
||||||
|
// However, it should be the same as the duration of the keep-alive reset called baseExtension.
|
||||||
|
u.kbdAutoReleaseTimers[key] = time.AfterFunc(100*time.Millisecond, func() {
|
||||||
|
u.performAutoRelease(key)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *UsbGadget) cancelAutoRelease(key byte) {
|
||||||
|
u.kbdAutoReleaseLock.Lock()
|
||||||
|
defer unlockWithLog(&u.kbdAutoReleaseLock, u.log, "autoRelease cancelled")
|
||||||
|
|
||||||
|
if timer := u.kbdAutoReleaseTimers[key]; timer != nil {
|
||||||
|
timer.Stop()
|
||||||
|
u.kbdAutoReleaseTimers[key] = nil
|
||||||
|
delete(u.kbdAutoReleaseTimers, key)
|
||||||
|
|
||||||
|
// Reset keep-alive timing when key is released
|
||||||
|
if u.onKeepAliveReset != nil {
|
||||||
|
(*u.onKeepAliveReset)()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *UsbGadget) DelayAutoReleaseWithDuration(resetDuration time.Duration) {
|
||||||
|
u.kbdAutoReleaseLock.Lock()
|
||||||
|
defer unlockWithLog(&u.kbdAutoReleaseLock, u.log, "autoRelease delayed")
|
||||||
|
|
||||||
|
u.log.Debug().Dur("reset_duration", resetDuration).Msg("delaying auto-release with dynamic duration")
|
||||||
|
|
||||||
|
for _, timer := range u.kbdAutoReleaseTimers {
|
||||||
|
if timer != nil {
|
||||||
|
timer.Reset(resetDuration)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *UsbGadget) performAutoRelease(key byte) {
|
||||||
|
u.kbdAutoReleaseLock.Lock()
|
||||||
|
|
||||||
|
if u.kbdAutoReleaseTimers[key] == nil {
|
||||||
|
u.log.Warn().Uint8("key", key).Msg("autoRelease timer not found")
|
||||||
|
u.kbdAutoReleaseLock.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
u.kbdAutoReleaseTimers[key].Stop()
|
||||||
|
u.kbdAutoReleaseTimers[key] = nil
|
||||||
|
delete(u.kbdAutoReleaseTimers, key)
|
||||||
|
u.kbdAutoReleaseLock.Unlock()
|
||||||
|
|
||||||
|
// Skip if already released
|
||||||
|
state := u.GetKeysDownState()
|
||||||
|
alreadyReleased := true
|
||||||
|
|
||||||
|
for i := range state.Keys {
|
||||||
|
if state.Keys[i] == key {
|
||||||
|
alreadyReleased = false
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if alreadyReleased {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := u.keypressReport(key, false)
|
||||||
|
if err != nil {
|
||||||
|
u.log.Warn().Uint8("key", key).Msg("failed to release key")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (u *UsbGadget) listenKeyboardEvents() {
|
func (u *UsbGadget) listenKeyboardEvents() {
|
||||||
|
|
@ -141,7 +256,7 @@ func (u *UsbGadget) listenKeyboardEvents() {
|
||||||
l.Info().Msg("context done")
|
l.Info().Msg("context done")
|
||||||
return
|
return
|
||||||
default:
|
default:
|
||||||
l.Trace().Msg("reading from keyboard")
|
l.Trace().Msg("reading from keyboard for LED state changes")
|
||||||
if u.keyboardHidFile == nil {
|
if u.keyboardHidFile == nil {
|
||||||
u.logWithSuppression("keyboardHidFileNil", 100, &l, nil, "keyboardHidFile is nil")
|
u.logWithSuppression("keyboardHidFileNil", 100, &l, nil, "keyboardHidFile is nil")
|
||||||
// show the error every 100 times to avoid spamming the logs
|
// show the error every 100 times to avoid spamming the logs
|
||||||
|
|
@ -158,7 +273,7 @@ func (u *UsbGadget) listenKeyboardEvents() {
|
||||||
}
|
}
|
||||||
u.resetLogSuppressionCounter("keyboardHidFileRead")
|
u.resetLogSuppressionCounter("keyboardHidFileRead")
|
||||||
|
|
||||||
l.Trace().Int("n", n).Bytes("buf", buf).Msg("got data from keyboard")
|
l.Trace().Int("n", n).Uints8("buf", buf).Msg("got data from keyboard")
|
||||||
if n != 1 {
|
if n != 1 {
|
||||||
l.Trace().Int("n", n).Msg("expected 1 byte, got")
|
l.Trace().Int("n", n).Msg("expected 1 byte, got")
|
||||||
continue
|
continue
|
||||||
|
|
@ -194,12 +309,16 @@ func (u *UsbGadget) OpenKeyboardHidFile() error {
|
||||||
return u.openKeyboardHidFile()
|
return u.openKeyboardHidFile()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (u *UsbGadget) keyboardWriteHidFile(data []byte) error {
|
var keyboardWriteHidFileLock sync.Mutex
|
||||||
|
|
||||||
|
func (u *UsbGadget) keyboardWriteHidFile(modifier byte, keys []byte) error {
|
||||||
|
keyboardWriteHidFileLock.Lock()
|
||||||
|
defer keyboardWriteHidFileLock.Unlock()
|
||||||
if err := u.openKeyboardHidFile(); err != nil {
|
if err := u.openKeyboardHidFile(); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err := u.keyboardHidFile.Write(data)
|
_, err := u.writeWithTimeout(u.keyboardHidFile, append([]byte{modifier, 0x00}, keys[:hidKeyBufferSize]...))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
u.logWithSuppression("keyboardWriteHidFile", 100, u.log, err, "failed to write to hidg0")
|
u.logWithSuppression("keyboardWriteHidFile", 100, u.log, err, "failed to write to hidg0")
|
||||||
u.keyboardHidFile.Close()
|
u.keyboardHidFile.Close()
|
||||||
|
|
@ -210,22 +329,178 @@ func (u *UsbGadget) keyboardWriteHidFile(data []byte) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (u *UsbGadget) KeyboardReport(modifier uint8, keys []uint8) error {
|
func (u *UsbGadget) UpdateKeysDown(modifier byte, keys []byte) KeysDownState {
|
||||||
u.keyboardLock.Lock()
|
// if we just reported an error roll over, we should clear the keys
|
||||||
defer u.keyboardLock.Unlock()
|
if keys[0] == hidErrorRollOver {
|
||||||
|
for i := range keys {
|
||||||
if len(keys) > 6 {
|
keys[i] = 0
|
||||||
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]})
|
state := KeysDownState{
|
||||||
if err != nil {
|
Modifier: modifier,
|
||||||
return err
|
Keys: []byte(keys[:]),
|
||||||
}
|
}
|
||||||
|
|
||||||
u.resetUserInputTime()
|
u.keyboardStateLock.Lock()
|
||||||
return nil
|
|
||||||
|
if u.keysDownState.Modifier == state.Modifier &&
|
||||||
|
bytes.Equal(u.keysDownState.Keys, state.Keys) {
|
||||||
|
u.keyboardStateLock.Unlock()
|
||||||
|
return state // No change in key down state
|
||||||
|
}
|
||||||
|
|
||||||
|
u.keysDownState = state
|
||||||
|
u.keyboardStateLock.Unlock()
|
||||||
|
|
||||||
|
if u.onKeysDownChange != nil {
|
||||||
|
(*u.onKeysDownChange)(state) // this enques to the outgoing hidrpc queue via usb.go → currentSession.enqueueKeysDownState(...)
|
||||||
|
}
|
||||||
|
return state
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *UsbGadget) KeyboardReport(modifier byte, keys []byte) error {
|
||||||
|
defer u.resetUserInputTime()
|
||||||
|
|
||||||
|
if len(keys) > hidKeyBufferSize {
|
||||||
|
keys = keys[:hidKeyBufferSize]
|
||||||
|
}
|
||||||
|
if len(keys) < hidKeyBufferSize {
|
||||||
|
keys = append(keys, make([]byte, hidKeyBufferSize-len(keys))...)
|
||||||
|
}
|
||||||
|
|
||||||
|
err := u.keyboardWriteHidFile(modifier, keys)
|
||||||
|
if err != nil {
|
||||||
|
u.log.Warn().Uint8("modifier", modifier).Uints8("keys", keys).Msg("Could not write keyboard report to hidg0")
|
||||||
|
}
|
||||||
|
|
||||||
|
u.UpdateKeysDown(modifier, keys)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
// https://www.usb.org/sites/default/files/documents/hut1_2.pdf
|
||||||
|
// Dynamic Flags (DV)
|
||||||
|
LeftControl = 0xE0
|
||||||
|
LeftShift = 0xE1
|
||||||
|
LeftAlt = 0xE2
|
||||||
|
LeftSuper = 0xE3 // Left GUI (e.g. Windows key, Apple Command key)
|
||||||
|
RightControl = 0xE4
|
||||||
|
RightShift = 0xE5
|
||||||
|
RightAlt = 0xE6
|
||||||
|
RightSuper = 0xE7 // Right GUI (e.g. Windows key, Apple Command key)
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// https://www.usb.org/sites/default/files/documents/hid1_11.pdf Appendix C
|
||||||
|
ModifierMaskLeftControl = 0x01
|
||||||
|
ModifierMaskRightControl = 0x10
|
||||||
|
ModifierMaskLeftShift = 0x02
|
||||||
|
ModifierMaskRightShift = 0x20
|
||||||
|
ModifierMaskLeftAlt = 0x04
|
||||||
|
ModifierMaskRightAlt = 0x40
|
||||||
|
ModifierMaskLeftSuper = 0x08
|
||||||
|
ModifierMaskRightSuper = 0x80
|
||||||
|
)
|
||||||
|
|
||||||
|
// KeyCodeToMaskMap is a slice of KeyCodeMask for quick lookup
|
||||||
|
var KeyCodeToMaskMap = map[byte]byte{
|
||||||
|
LeftControl: ModifierMaskLeftControl,
|
||||||
|
LeftShift: ModifierMaskLeftShift,
|
||||||
|
LeftAlt: ModifierMaskLeftAlt,
|
||||||
|
LeftSuper: ModifierMaskLeftSuper,
|
||||||
|
RightControl: ModifierMaskRightControl,
|
||||||
|
RightShift: ModifierMaskRightShift,
|
||||||
|
RightAlt: ModifierMaskRightAlt,
|
||||||
|
RightSuper: ModifierMaskRightSuper,
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *UsbGadget) keypressReport(key byte, press bool) (KeysDownState, error) {
|
||||||
|
defer u.resetUserInputTime()
|
||||||
|
|
||||||
|
l := u.log.With().Uint8("key", key).Bool("press", press).Logger()
|
||||||
|
if l.GetLevel() <= zerolog.DebugLevel {
|
||||||
|
requestID := xid.New()
|
||||||
|
l = l.With().Str("requestID", requestID.String()).Logger()
|
||||||
|
}
|
||||||
|
|
||||||
|
// IMPORTANT: This code parallels the logic in the kernel's hid-gadget driver
|
||||||
|
// for handling key presses and releases. It ensures that the USB gadget
|
||||||
|
// behaves similarly to a real USB HID keyboard. This logic is paralleled
|
||||||
|
// in the client/browser-side code in useKeyboard.ts so make sure to keep
|
||||||
|
// them in sync.
|
||||||
|
var state = u.GetKeysDownState()
|
||||||
|
l.Trace().Interface("state", state).Msg("got keys down state")
|
||||||
|
|
||||||
|
modifier := state.Modifier
|
||||||
|
keys := append([]byte(nil), state.Keys...)
|
||||||
|
|
||||||
|
if mask, exists := KeyCodeToMaskMap[key]; exists {
|
||||||
|
// If the key is a modifier key, we update the keyboardModifier state
|
||||||
|
// by setting or clearing the corresponding bit in the modifier byte.
|
||||||
|
// This allows us to track the state of dynamic modifier keys like
|
||||||
|
// Shift, Control, Alt, and Super.
|
||||||
|
if press {
|
||||||
|
modifier |= mask
|
||||||
|
} else {
|
||||||
|
modifier &^= mask
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// handle other keys that are not modifier keys by placing or removing them
|
||||||
|
// from the key buffer since the buffer tracks currently pressed keys
|
||||||
|
overrun := true
|
||||||
|
for i := range hidKeyBufferSize {
|
||||||
|
// If we find the key in the buffer the buffer, we either remove it (if press is false)
|
||||||
|
// or do nothing (if down is true) because the buffer tracks currently pressed keys
|
||||||
|
// and if we find a zero byte, we can place the key there (if press is true)
|
||||||
|
if keys[i] == key || keys[i] == 0 {
|
||||||
|
if press {
|
||||||
|
keys[i] = key // overwrites the zero byte or the same key if already pressed
|
||||||
|
} else {
|
||||||
|
// we are releasing the key, remove it from the buffer
|
||||||
|
if keys[i] != 0 {
|
||||||
|
copy(keys[i:], keys[i+1:])
|
||||||
|
keys[hidKeyBufferSize-1] = 0 // Clear the last byte
|
||||||
|
}
|
||||||
|
}
|
||||||
|
overrun = false // We found a slot for the key
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we reach here it means we didn't find an empty slot or the key in the buffer
|
||||||
|
if overrun {
|
||||||
|
if press {
|
||||||
|
l.Error().Msg("keyboard buffer overflow, key not added")
|
||||||
|
// Fill all key slots with ErrorRollOver (0x01) to indicate overflow
|
||||||
|
for i := range keys {
|
||||||
|
keys[i] = hidErrorRollOver
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// If we are releasing a key, and we didn't find it in a slot, who cares?
|
||||||
|
l.Warn().Msg("key not found in buffer, nothing to release")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
err := u.keyboardWriteHidFile(modifier, keys)
|
||||||
|
return u.UpdateKeysDown(modifier, keys), err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *UsbGadget) KeypressReport(key byte, press bool) error {
|
||||||
|
state, err := u.keypressReport(key, press)
|
||||||
|
if err != nil {
|
||||||
|
u.log.Warn().Uint8("key", key).Bool("press", press).Msg("failed to report key")
|
||||||
|
}
|
||||||
|
isRolledOver := state.Keys[0] == hidErrorRollOver
|
||||||
|
|
||||||
|
if isRolledOver {
|
||||||
|
u.cancelAutoRelease(key)
|
||||||
|
} else if press {
|
||||||
|
u.scheduleAutoRelease(key)
|
||||||
|
} else {
|
||||||
|
u.cancelAutoRelease(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,9 +11,10 @@ var absoluteMouseConfig = gadgetConfigItem{
|
||||||
path: []string{"functions", "hid.usb1"},
|
path: []string{"functions", "hid.usb1"},
|
||||||
configPath: []string{"hid.usb1"},
|
configPath: []string{"hid.usb1"},
|
||||||
attrs: gadgetAttributes{
|
attrs: gadgetAttributes{
|
||||||
"protocol": "2",
|
"protocol": "2",
|
||||||
"subclass": "0",
|
"subclass": "0",
|
||||||
"report_length": "6",
|
"report_length": "6",
|
||||||
|
"no_out_endpoint": "1",
|
||||||
},
|
},
|
||||||
reportDesc: absoluteMouseCombinedReportDesc,
|
reportDesc: absoluteMouseCombinedReportDesc,
|
||||||
}
|
}
|
||||||
|
|
@ -73,7 +74,7 @@ func (u *UsbGadget) absMouseWriteHidFile(data []byte) error {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err := u.absMouseHidFile.Write(data)
|
_, err := u.writeWithTimeout(u.absMouseHidFile, data)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
u.logWithSuppression("absMouseWriteHidFile", 100, u.log, err, "failed to write to hidg1")
|
u.logWithSuppression("absMouseWriteHidFile", 100, u.log, err, "failed to write to hidg1")
|
||||||
u.absMouseHidFile.Close()
|
u.absMouseHidFile.Close()
|
||||||
|
|
@ -84,17 +85,17 @@ func (u *UsbGadget) absMouseWriteHidFile(data []byte) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (u *UsbGadget) AbsMouseReport(x, y int, buttons uint8) error {
|
func (u *UsbGadget) AbsMouseReport(x int, y int, buttons uint8) error {
|
||||||
u.absMouseLock.Lock()
|
u.absMouseLock.Lock()
|
||||||
defer u.absMouseLock.Unlock()
|
defer u.absMouseLock.Unlock()
|
||||||
|
|
||||||
err := u.absMouseWriteHidFile([]byte{
|
err := u.absMouseWriteHidFile([]byte{
|
||||||
1, // Report ID 1
|
1, // Report ID 1
|
||||||
buttons, // Buttons
|
buttons, // Buttons
|
||||||
uint8(x), // X Low Byte
|
byte(x), // X Low Byte
|
||||||
uint8(x >> 8), // X High Byte
|
byte(x >> 8), // X High Byte
|
||||||
uint8(y), // Y Low Byte
|
byte(y), // Y Low Byte
|
||||||
uint8(y >> 8), // Y High Byte
|
byte(y >> 8), // Y High Byte
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|
|
||||||
|
|
@ -11,9 +11,10 @@ var relativeMouseConfig = gadgetConfigItem{
|
||||||
path: []string{"functions", "hid.usb2"},
|
path: []string{"functions", "hid.usb2"},
|
||||||
configPath: []string{"hid.usb2"},
|
configPath: []string{"hid.usb2"},
|
||||||
attrs: gadgetAttributes{
|
attrs: gadgetAttributes{
|
||||||
"protocol": "2",
|
"protocol": "2",
|
||||||
"subclass": "1",
|
"subclass": "1",
|
||||||
"report_length": "4",
|
"report_length": "4",
|
||||||
|
"no_out_endpoint": "1",
|
||||||
},
|
},
|
||||||
reportDesc: relativeMouseCombinedReportDesc,
|
reportDesc: relativeMouseCombinedReportDesc,
|
||||||
}
|
}
|
||||||
|
|
@ -63,7 +64,7 @@ func (u *UsbGadget) relMouseWriteHidFile(data []byte) error {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err := u.relMouseHidFile.Write(data)
|
_, err := u.writeWithTimeout(u.relMouseHidFile, data)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
u.logWithSuppression("relMouseWriteHidFile", 100, u.log, err, "failed to write to hidg2")
|
u.logWithSuppression("relMouseWriteHidFile", 100, u.log, err, "failed to write to hidg2")
|
||||||
u.relMouseHidFile.Close()
|
u.relMouseHidFile.Close()
|
||||||
|
|
@ -74,15 +75,15 @@ func (u *UsbGadget) relMouseWriteHidFile(data []byte) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (u *UsbGadget) RelMouseReport(mx, my int8, buttons uint8) error {
|
func (u *UsbGadget) RelMouseReport(mx int8, my int8, buttons uint8) error {
|
||||||
u.relMouseLock.Lock()
|
u.relMouseLock.Lock()
|
||||||
defer u.relMouseLock.Unlock()
|
defer u.relMouseLock.Unlock()
|
||||||
|
|
||||||
err := u.relMouseWriteHidFile([]byte{
|
err := u.relMouseWriteHidFile([]byte{
|
||||||
buttons, // Buttons
|
buttons, // Buttons
|
||||||
uint8(mx), // X
|
byte(mx), // X
|
||||||
uint8(my), // Y
|
byte(my), // Y
|
||||||
0, // Wheel
|
0, // Wheel
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,11 @@ var defaultUsbGadgetDevices = Devices{
|
||||||
MassStorage: true,
|
MassStorage: true,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type KeysDownState struct {
|
||||||
|
Modifier byte `json:"modifier"`
|
||||||
|
Keys ByteSlice `json:"keys"`
|
||||||
|
}
|
||||||
|
|
||||||
// UsbGadget is a struct that represents a USB gadget.
|
// UsbGadget is a struct that represents a USB gadget.
|
||||||
type UsbGadget struct {
|
type UsbGadget struct {
|
||||||
name string
|
name string
|
||||||
|
|
@ -60,7 +65,12 @@ type UsbGadget struct {
|
||||||
relMouseHidFile *os.File
|
relMouseHidFile *os.File
|
||||||
relMouseLock sync.Mutex
|
relMouseLock sync.Mutex
|
||||||
|
|
||||||
keyboardState KeyboardState
|
keyboardState byte // keyboard latched state (NumLock, CapsLock, ScrollLock, Compose, Kana)
|
||||||
|
keysDownState KeysDownState // keyboard dynamic state (modifier keys and pressed keys)
|
||||||
|
|
||||||
|
kbdAutoReleaseLock sync.Mutex
|
||||||
|
kbdAutoReleaseTimers map[byte]*time.Timer
|
||||||
|
|
||||||
keyboardStateLock sync.Mutex
|
keyboardStateLock sync.Mutex
|
||||||
keyboardStateCtx context.Context
|
keyboardStateCtx context.Context
|
||||||
keyboardStateCancel context.CancelFunc
|
keyboardStateCancel context.CancelFunc
|
||||||
|
|
@ -77,6 +87,8 @@ type UsbGadget struct {
|
||||||
txLock sync.Mutex
|
txLock sync.Mutex
|
||||||
|
|
||||||
onKeyboardStateChange *func(state KeyboardState)
|
onKeyboardStateChange *func(state KeyboardState)
|
||||||
|
onKeysDownChange *func(state KeysDownState)
|
||||||
|
onKeepAliveReset *func()
|
||||||
|
|
||||||
log *zerolog.Logger
|
log *zerolog.Logger
|
||||||
|
|
||||||
|
|
@ -110,22 +122,24 @@ func newUsbGadget(name string, configMap map[string]gadgetConfigItem, enabledDev
|
||||||
keyboardCtx, keyboardCancel := context.WithCancel(context.Background())
|
keyboardCtx, keyboardCancel := context.WithCancel(context.Background())
|
||||||
|
|
||||||
g := &UsbGadget{
|
g := &UsbGadget{
|
||||||
name: name,
|
name: name,
|
||||||
kvmGadgetPath: path.Join(gadgetPath, name),
|
kvmGadgetPath: path.Join(gadgetPath, name),
|
||||||
configC1Path: path.Join(gadgetPath, name, "configs/c.1"),
|
configC1Path: path.Join(gadgetPath, name, "configs/c.1"),
|
||||||
configMap: configMap,
|
configMap: configMap,
|
||||||
customConfig: *config,
|
customConfig: *config,
|
||||||
configLock: sync.Mutex{},
|
configLock: sync.Mutex{},
|
||||||
keyboardLock: sync.Mutex{},
|
keyboardLock: sync.Mutex{},
|
||||||
absMouseLock: sync.Mutex{},
|
absMouseLock: sync.Mutex{},
|
||||||
relMouseLock: sync.Mutex{},
|
relMouseLock: sync.Mutex{},
|
||||||
txLock: sync.Mutex{},
|
txLock: sync.Mutex{},
|
||||||
keyboardStateCtx: keyboardCtx,
|
keyboardStateCtx: keyboardCtx,
|
||||||
keyboardStateCancel: keyboardCancel,
|
keyboardStateCancel: keyboardCancel,
|
||||||
keyboardState: KeyboardState{},
|
keyboardState: 0,
|
||||||
enabledDevices: *enabledDevices,
|
keysDownState: KeysDownState{Modifier: 0, Keys: []byte{0, 0, 0, 0, 0, 0}}, // must be initialized to hidKeyBufferSize (6) zero bytes
|
||||||
lastUserInput: time.Now(),
|
kbdAutoReleaseTimers: make(map[byte]*time.Timer),
|
||||||
log: logger,
|
enabledDevices: *enabledDevices,
|
||||||
|
lastUserInput: time.Now(),
|
||||||
|
log: logger,
|
||||||
|
|
||||||
strictMode: config.strictMode,
|
strictMode: config.strictMode,
|
||||||
|
|
||||||
|
|
@ -140,3 +154,37 @@ func newUsbGadget(name string, configMap map[string]gadgetConfigItem, enabledDev
|
||||||
|
|
||||||
return g
|
return g
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Close cleans up resources used by the USB gadget
|
||||||
|
func (u *UsbGadget) Close() error {
|
||||||
|
// Cancel keyboard state context
|
||||||
|
if u.keyboardStateCancel != nil {
|
||||||
|
u.keyboardStateCancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop auto-release timer
|
||||||
|
u.kbdAutoReleaseLock.Lock()
|
||||||
|
for _, timer := range u.kbdAutoReleaseTimers {
|
||||||
|
if timer != nil {
|
||||||
|
timer.Stop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
u.kbdAutoReleaseTimers = make(map[byte]*time.Timer)
|
||||||
|
u.kbdAutoReleaseLock.Unlock()
|
||||||
|
|
||||||
|
// Close HID files
|
||||||
|
if u.keyboardHidFile != nil {
|
||||||
|
u.keyboardHidFile.Close()
|
||||||
|
u.keyboardHidFile = nil
|
||||||
|
}
|
||||||
|
if u.absMouseHidFile != nil {
|
||||||
|
u.absMouseHidFile.Close()
|
||||||
|
u.absMouseHidFile = nil
|
||||||
|
}
|
||||||
|
if u.relMouseHidFile != nil {
|
||||||
|
u.relMouseHidFile.Close()
|
||||||
|
u.relMouseHidFile = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,14 +2,44 @@ package usbgadget
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/rs/zerolog"
|
"github.com/rs/zerolog"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type ByteSlice []byte
|
||||||
|
|
||||||
|
func (s ByteSlice) MarshalJSON() ([]byte, error) {
|
||||||
|
vals := make([]int, len(s))
|
||||||
|
for i, v := range s {
|
||||||
|
vals[i] = int(v)
|
||||||
|
}
|
||||||
|
return json.Marshal(vals)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ByteSlice) UnmarshalJSON(data []byte) error {
|
||||||
|
var vals []int
|
||||||
|
if err := json.Unmarshal(data, &vals); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
*s = make([]byte, len(vals))
|
||||||
|
for i, v := range vals {
|
||||||
|
if v < 0 || v > 255 {
|
||||||
|
return fmt.Errorf("value %d out of byte range", v)
|
||||||
|
}
|
||||||
|
(*s)[i] = byte(v)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func joinPath(basePath string, paths []string) string {
|
func joinPath(basePath string, paths []string) string {
|
||||||
pathArr := append([]string{basePath}, paths...)
|
pathArr := append([]string{basePath}, paths...)
|
||||||
return filepath.Join(pathArr...)
|
return filepath.Join(pathArr...)
|
||||||
|
|
@ -81,7 +111,38 @@ func compareFileContent(oldContent []byte, newContent []byte, looserMatch bool)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
func (u *UsbGadget) logWithSuppression(counterName string, every int, logger *zerolog.Logger, err error, msg string, args ...interface{}) {
|
func (u *UsbGadget) writeWithTimeout(file *os.File, data []byte) (n int, err error) {
|
||||||
|
if err := file.SetWriteDeadline(time.Now().Add(hidWriteTimeout)); err != nil {
|
||||||
|
return -1, err
|
||||||
|
}
|
||||||
|
|
||||||
|
n, err = file.Write(data)
|
||||||
|
if err == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
u.log.Trace().
|
||||||
|
Str("file", file.Name()).
|
||||||
|
Bytes("data", data).
|
||||||
|
Err(err).
|
||||||
|
Msg("write failed")
|
||||||
|
|
||||||
|
if errors.Is(err, os.ErrDeadlineExceeded) {
|
||||||
|
u.logWithSuppression(
|
||||||
|
fmt.Sprintf("writeWithTimeout_%s", file.Name()),
|
||||||
|
1000,
|
||||||
|
u.log,
|
||||||
|
err,
|
||||||
|
"write timed out: %s",
|
||||||
|
file.Name(),
|
||||||
|
)
|
||||||
|
err = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *UsbGadget) logWithSuppression(counterName string, every int, logger *zerolog.Logger, err error, msg string, args ...any) {
|
||||||
u.logSuppressionLock.Lock()
|
u.logSuppressionLock.Lock()
|
||||||
defer u.logSuppressionLock.Unlock()
|
defer u.logSuppressionLock.Unlock()
|
||||||
|
|
||||||
|
|
@ -110,3 +171,8 @@ func (u *UsbGadget) resetLogSuppressionCounter(counterName string) {
|
||||||
u.logSuppressionCounter[counterName] = 0
|
u.logSuppressionCounter[counterName] = 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func unlockWithLog(lock *sync.Mutex, logger *zerolog.Logger, msg string, args ...any) {
|
||||||
|
logger.Trace().Msgf(msg, args...)
|
||||||
|
lock.Unlock()
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,73 @@
|
||||||
|
package utils
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"golang.org/x/crypto/ssh"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ValidSSHKeyTypes is a list of valid SSH key types
|
||||||
|
//
|
||||||
|
// Please make sure that all the types in this list are supported by dropbear
|
||||||
|
// https://github.com/mkj/dropbear/blob/003c5fcaabc114430d5d14142e95ffdbbd2d19b6/src/signkey.c#L37
|
||||||
|
//
|
||||||
|
// ssh-dss is not allowed here as it's insecure
|
||||||
|
var ValidSSHKeyTypes = []string{
|
||||||
|
ssh.KeyAlgoRSA,
|
||||||
|
ssh.KeyAlgoED25519,
|
||||||
|
ssh.KeyAlgoECDSA256,
|
||||||
|
ssh.KeyAlgoECDSA384,
|
||||||
|
ssh.KeyAlgoECDSA521,
|
||||||
|
ssh.KeyAlgoSKED25519,
|
||||||
|
ssh.KeyAlgoSKECDSA256,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateSSHKey validates authorized_keys file content
|
||||||
|
func ValidateSSHKey(sshKey string) error {
|
||||||
|
// validate SSH key
|
||||||
|
var (
|
||||||
|
hasValidPublicKey = false
|
||||||
|
lastError = fmt.Errorf("no valid SSH key found")
|
||||||
|
)
|
||||||
|
for _, key := range strings.Split(sshKey, "\n") {
|
||||||
|
key = strings.TrimSpace(key)
|
||||||
|
|
||||||
|
// skip empty lines and comments
|
||||||
|
if key == "" || strings.HasPrefix(key, "#") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
parsedPublicKey, _, _, _, err := ssh.ParseAuthorizedKey([]byte(key))
|
||||||
|
if err != nil {
|
||||||
|
lastError = err
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if parsedPublicKey == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
parsedType := parsedPublicKey.Type()
|
||||||
|
textType := strings.Fields(key)[0]
|
||||||
|
|
||||||
|
if parsedType != textType {
|
||||||
|
lastError = fmt.Errorf("parsed SSH key type %s does not match type in text %s", parsedType, textType)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if !slices.Contains(ValidSSHKeyTypes, parsedType) {
|
||||||
|
lastError = fmt.Errorf("invalid SSH key type: %s", parsedType)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
hasValidPublicKey = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if !hasValidPublicKey {
|
||||||
|
return lastError
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,220 @@
|
||||||
|
package utils
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestValidateSSHKey(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
sshKey string
|
||||||
|
expectError bool
|
||||||
|
errorMsg string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "valid RSA key",
|
||||||
|
sshKey: "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDiYUb9Fy2vlPfO+HwubnshimpVrWPoePyvyN+jPC5gWqZSycjMy6Is2vFVn7oQc72bkY0wZalspT5wUOwKtltSoLpL7vcqGL9zHVw4yjYXtPGIRd3zLpU9wdngevnepPQWTX3LvZTZfmOsrGoMDKIG+Lbmiq/STMuWYecIqMp7tUKRGS8vfAmpu6MsrN9/4UTcdWWXYWJQQn+2nCyMz28jYlWRsKtqFK6owrdZWt8WQnPN+9Upcf2ByQje+0NLnpNrnh+yd2ocuVW9wQYKAZXy7IaTfEJwd5m34sLwkqlZTaBBcmWJU+3RfpYXE763cf3rUoPIGQ8eUEBJ8IdM4vhp test@example.com",
|
||||||
|
expectError: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "valid ED25519 key",
|
||||||
|
sshKey: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBSbM8wuD5ab0nHsXaYOqaD3GLLUwmDzSk79Xi/N+H2j test@example.com",
|
||||||
|
expectError: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "valid ECDSA key",
|
||||||
|
sshKey: "ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBAlTkxIo4mXBR+gEX0Q74BpYX4bFFHoX+8Uz7tsob8HvsnMvsEE+BW9h9XrbWX4/4ppL/o6sHbvsqNr9HcyKfdc= test@example.com",
|
||||||
|
expectError: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "valid SK-backed ED25519 key",
|
||||||
|
sshKey: "sk-ssh-ed25519@openssh.com AAAAGnNrLXNzaC1lZDI1NTE5QG9wZW5zc2guY29tAAAAIHHSRVC3qISk/mOorf24au6esimA9Uu1/BkEnVKJ+4bFAAAABHNzaDo= test@example.com",
|
||||||
|
expectError: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "valid SK-backed ECDSA key",
|
||||||
|
sshKey: "sk-ecdsa-sha2-nistp256@openssh.com AAAAInNrLWVjZHNhLXNoYTItbmlzdHAyNTZAb3BlbnNzaC5jb20AAAAIbmlzdHAyNTYAAABBBL/CFBZksvs+gJODMB9StxnkY6xRKH73npOzJBVb0UEGCPTAhDrvzW1PE5X5GDYXmZw1s7c/nS+GH0LF0OFCpwAAAAAEc3NoOg== test@example.com",
|
||||||
|
expectError: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "multiple valid keys",
|
||||||
|
sshKey: "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDiYUb9Fy2vlPfO+HwubnshimpVrWPoePyvyN+jPC5gWqZSycjMy6Is2vFVn7oQc72bkY0wZalspT5wUOwKtltSoLpL7vcqGL9zHVw4yjYXtPGIRd3zLpU9wdngevnepPQWTX3LvZTZfmOsrGoMDKIG+Lbmiq/STMuWYecIqMp7tUKRGS8vfAmpu6MsrN9/4UTcdWWXYWJQQn+2nCyMz28jYlWRsKtqFK6owrdZWt8WQnPN+9Upcf2ByQje+0NLnpNrnh+yd2ocuVW9wQYKAZXy7IaTfEJwd5m34sLwkqlZTaBBcmWJU+3RfpYXE763cf3rUoPIGQ8eUEBJ8IdM4vhp test@example.com\nssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBSbM8wuD5ab0nHsXaYOqaD3GLLUwmDzSk79Xi/N+H2j test@example.com",
|
||||||
|
expectError: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "valid key with comment",
|
||||||
|
sshKey: "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDiYUb9Fy2vlPfO+HwubnshimpVrWPoePyvyN+jPC5gWqZSycjMy6Is2vFVn7oQc72bkY0wZalspT5wUOwKtltSoLpL7vcqGL9zHVw4yjYXtPGIRd3zLpU9wdngevnepPQWTX3LvZTZfmOsrGoMDKIG+Lbmiq/STMuWYecIqMp7tUKRGS8vfAmpu6MsrN9/4UTcdWWXYWJQQn+2nCyMz28jYlWRsKtqFK6owrdZWt8WQnPN+9Upcf2ByQje+0NLnpNrnh+yd2ocuVW9wQYKAZXy7IaTfEJwd5m34sLwkqlZTaBBcmWJU+3RfpYXE763cf3rUoPIGQ8eUEBJ8IdM4vhp user@example.com",
|
||||||
|
expectError: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "valid key with options and comment (we don't support options yet)",
|
||||||
|
sshKey: "command=\"echo hello\" ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDiYUb9Fy2vlPfO+HwubnshimpVrWPoePyvyN+jPC5gWqZSycjMy6Is2vFVn7oQc72bkY0wZalspT5wUOwKtltSoLpL7vcqGL9zHVw4yjYXtPGIRd3zLpU9wdngevnepPQWTX3LvZTZfmOsrGoMDKIG+Lbmiq/STMuWYecIqMp7tUKRGS8vfAmpu6MsrN9/4UTcdWWXYWJQQn+2nCyMz28jYlWRsKtqFK6owrdZWt8WQnPN+9Upcf2ByQje+0NLnpNrnh+yd2ocuVW9wQYKAZXy7IaTfEJwd5m34sLwkqlZTaBBcmWJU+3RfpYXE763cf3rUoPIGQ8eUEBJ8IdM4vhp user@example.com",
|
||||||
|
expectError: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty string",
|
||||||
|
sshKey: "",
|
||||||
|
expectError: true,
|
||||||
|
errorMsg: "no valid SSH key found",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "whitespace only",
|
||||||
|
sshKey: " \n\t \n ",
|
||||||
|
expectError: true,
|
||||||
|
errorMsg: "no valid SSH key found",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "comment only",
|
||||||
|
sshKey: "# This is a comment\n# Another comment",
|
||||||
|
expectError: true,
|
||||||
|
errorMsg: "no valid SSH key found",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid key format",
|
||||||
|
sshKey: "not-a-valid-ssh-key",
|
||||||
|
expectError: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid key type",
|
||||||
|
sshKey: "ssh-dss AAAAB3NzaC1kc3MAAACBAOeB...",
|
||||||
|
expectError: true,
|
||||||
|
errorMsg: "invalid SSH key type: ssh-dss",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unsupported key type",
|
||||||
|
sshKey: "ssh-rsa-cert-v01@openssh.com AAAAB3NzaC1yc2EAAAADAQABAAABgQC7vbqajDhA...",
|
||||||
|
expectError: true,
|
||||||
|
errorMsg: "invalid SSH key type: ssh-rsa-cert-v01@openssh.com",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "malformed key data",
|
||||||
|
sshKey: "ssh-rsa invalid-base64-data",
|
||||||
|
expectError: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "type mismatch",
|
||||||
|
sshKey: "ssh-rsa AAAAC3NzaC1lZDI1NTE5AAAAIGomKoH...",
|
||||||
|
expectError: true,
|
||||||
|
errorMsg: "parsed SSH key type ssh-ed25519 does not match type in text ssh-rsa",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "mixed valid and invalid keys",
|
||||||
|
sshKey: "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDiYUb9Fy2vlPfO+HwubnshimpVrWPoePyvyN+jPC5gWqZSycjMy6Is2vFVn7oQc72bkY0wZalspT5wUOwKtltSoLpL7vcqGL9zHVw4yjYXtPGIRd3zLpU9wdngevnepPQWTX3LvZTZfmOsrGoMDKIG+Lbmiq/STMuWYecIqMp7tUKRGS8vfAmpu6MsrN9/4UTcdWWXYWJQQn+2nCyMz28jYlWRsKtqFK6owrdZWt8WQnPN+9Upcf2ByQje+0NLnpNrnh+yd2ocuVW9wQYKAZXy7IaTfEJwd5m34sLwkqlZTaBBcmWJU+3RfpYXE763cf3rUoPIGQ8eUEBJ8IdM4vhp test@example.com\ninvalid-key\nssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBSbM8wuD5ab0nHsXaYOqaD3GLLUwmDzSk79Xi/N+H2j test@example.com",
|
||||||
|
expectError: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "valid key with empty lines and comments",
|
||||||
|
sshKey: "# Comment line\n\nssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDiYUb9Fy2vlPfO+HwubnshimpVrWPoePyvyN+jPC5gWqZSycjMy6Is2vFVn7oQc72bkY0wZalspT5wUOwKtltSoLpL7vcqGL9zHVw4yjYXtPGIRd3zLpU9wdngevnepPQWTX3LvZTZfmOsrGoMDKIG+Lbmiq/STMuWYecIqMp7tUKRGS8vfAmpu6MsrN9/4UTcdWWXYWJQQn+2nCyMz28jYlWRsKtqFK6owrdZWt8WQnPN+9Upcf2ByQje+0NLnpNrnh+yd2ocuVW9wQYKAZXy7IaTfEJwd5m34sLwkqlZTaBBcmWJU+3RfpYXE763cf3rUoPIGQ8eUEBJ8IdM4vhp test@example.com\n# Another comment\n\t\n",
|
||||||
|
expectError: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "all invalid keys",
|
||||||
|
sshKey: "invalid-key-1\ninvalid-key-2\nssh-dss AAAAB3NzaC1kc3MAAACBAOeB...",
|
||||||
|
expectError: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
err := ValidateSSHKey(tt.sshKey)
|
||||||
|
|
||||||
|
if tt.expectError {
|
||||||
|
if err == nil {
|
||||||
|
t.Errorf("ValidateSSHKey() expected error but got none")
|
||||||
|
} else if tt.errorMsg != "" && !strings.ContainsAny(err.Error(), tt.errorMsg) {
|
||||||
|
t.Errorf("ValidateSSHKey() error = %v, expected to contain %v", err, tt.errorMsg)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("ValidateSSHKey() unexpected error = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidSSHKeyTypes(t *testing.T) {
|
||||||
|
expectedTypes := []string{
|
||||||
|
"ssh-rsa",
|
||||||
|
"ssh-ed25519",
|
||||||
|
"ecdsa-sha2-nistp256",
|
||||||
|
"ecdsa-sha2-nistp384",
|
||||||
|
"ecdsa-sha2-nistp521",
|
||||||
|
"sk-ecdsa-sha2-nistp256@openssh.com",
|
||||||
|
"sk-ssh-ed25519@openssh.com",
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(ValidSSHKeyTypes) != len(expectedTypes) {
|
||||||
|
t.Errorf("ValidSSHKeyTypes length = %d, expected %d", len(ValidSSHKeyTypes), len(expectedTypes))
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, expectedType := range expectedTypes {
|
||||||
|
found := false
|
||||||
|
for _, actualType := range ValidSSHKeyTypes {
|
||||||
|
if actualType == expectedType {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Errorf("ValidSSHKeyTypes missing expected type: %s", expectedType)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestValidateSSHKeyEdgeCases tests edge cases and boundary conditions
|
||||||
|
func TestValidateSSHKeyEdgeCases(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
sshKey string
|
||||||
|
expectError bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "key with only type",
|
||||||
|
sshKey: "ssh-rsa",
|
||||||
|
expectError: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "key with type and empty data",
|
||||||
|
sshKey: "ssh-rsa ",
|
||||||
|
expectError: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "key with type and whitespace data",
|
||||||
|
sshKey: "ssh-rsa \t ",
|
||||||
|
expectError: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "key with multiple spaces between type and data",
|
||||||
|
sshKey: "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDiYUb9Fy2vlPfO+HwubnshimpVrWPoePyvyN+jPC5gWqZSycjMy6Is2vFVn7oQc72bkY0wZalspT5wUOwKtltSoLpL7vcqGL9zHVw4yjYXtPGIRd3zLpU9wdngevnepPQWTX3LvZTZfmOsrGoMDKIG+Lbmiq/STMuWYecIqMp7tUKRGS8vfAmpu6MsrN9/4UTcdWWXYWJQQn+2nCyMz28jYlWRsKtqFK6owrdZWt8WQnPN+9Upcf2ByQje+0NLnpNrnh+yd2ocuVW9wQYKAZXy7IaTfEJwd5m34sLwkqlZTaBBcmWJU+3RfpYXE763cf3rUoPIGQ8eUEBJ8IdM4vhp test@example.com",
|
||||||
|
expectError: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "key with tabs",
|
||||||
|
sshKey: "\tssh-rsa\tAAAAB3NzaC1yc2EAAAADAQABAAABAQDiYUb9Fy2vlPfO+HwubnshimpVrWPoePyvyN+jPC5gWqZSycjMy6Is2vFVn7oQc72bkY0wZalspT5wUOwKtltSoLpL7vcqGL9zHVw4yjYXtPGIRd3zLpU9wdngevnepPQWTX3LvZTZfmOsrGoMDKIG+Lbmiq/STMuWYecIqMp7tUKRGS8vfAmpu6MsrN9/4UTcdWWXYWJQQn+2nCyMz28jYlWRsKtqFK6owrdZWt8WQnPN+9Upcf2ByQje+0NLnpNrnh+yd2ocuVW9wQYKAZXy7IaTfEJwd5m34sLwkqlZTaBBcmWJU+3RfpYXE763cf3rUoPIGQ8eUEBJ8IdM4vhp test@example.com",
|
||||||
|
expectError: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "very long line",
|
||||||
|
sshKey: "ssh-rsa " + string(make([]byte, 10000)),
|
||||||
|
expectError: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
err := ValidateSSHKey(tt.sshKey)
|
||||||
|
|
||||||
|
if tt.expectError {
|
||||||
|
if err == nil {
|
||||||
|
t.Errorf("ValidateSSHKey() expected error but got none")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("ValidateSSHKey() unexpected error = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
163
jiggler.go
|
|
@ -1,39 +1,160 @@
|
||||||
package kvm
|
package kvm
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
|
"math/rand"
|
||||||
"time"
|
"time"
|
||||||
|
_ "time/tzdata"
|
||||||
|
|
||||||
|
"github.com/go-co-op/gocron/v2"
|
||||||
|
"github.com/jetkvm/kvm/internal/tzdata"
|
||||||
)
|
)
|
||||||
|
|
||||||
var lastUserInput = time.Now()
|
type JigglerConfig struct {
|
||||||
|
InactivityLimitSeconds int `json:"inactivity_limit_seconds"`
|
||||||
var jigglerEnabled = false
|
JitterPercentage int `json:"jitter_percentage"`
|
||||||
|
ScheduleCronTab string `json:"schedule_cron_tab"`
|
||||||
func rpcSetJigglerState(enabled bool) {
|
Timezone string `json:"timezone,omitempty"`
|
||||||
jigglerEnabled = enabled
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var jobDelta time.Duration = 0
|
||||||
|
var scheduler gocron.Scheduler = nil
|
||||||
|
|
||||||
|
func rpcSetJigglerState(enabled bool) error {
|
||||||
|
config.JigglerEnabled = enabled
|
||||||
|
err := SaveConfig()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to save config: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func rpcGetJigglerState() bool {
|
func rpcGetJigglerState() bool {
|
||||||
return jigglerEnabled
|
return config.JigglerEnabled
|
||||||
|
}
|
||||||
|
|
||||||
|
func rpcGetTimezones() []string {
|
||||||
|
return tzdata.TimeZones
|
||||||
|
}
|
||||||
|
|
||||||
|
func rpcGetJigglerConfig() (JigglerConfig, error) {
|
||||||
|
return *config.JigglerConfig, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func rpcSetJigglerConfig(jigglerConfig JigglerConfig) error {
|
||||||
|
logger.Info().Msgf("jigglerConfig: %v, %v, %v, %v", jigglerConfig.InactivityLimitSeconds, jigglerConfig.JitterPercentage, jigglerConfig.ScheduleCronTab, jigglerConfig.Timezone)
|
||||||
|
config.JigglerConfig = &jigglerConfig
|
||||||
|
err := removeExistingCrobJobs(scheduler)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("error removing cron jobs from scheduler %v", err)
|
||||||
|
}
|
||||||
|
err = runJigglerCronTab()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("error scheduling jiggler crontab: %v", err)
|
||||||
|
}
|
||||||
|
err = SaveConfig()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to save config: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func removeExistingCrobJobs(s gocron.Scheduler) error {
|
||||||
|
for _, j := range s.Jobs() {
|
||||||
|
err := s.RemoveJob(j.ID())
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func initJiggler() {
|
func initJiggler() {
|
||||||
go runJiggler()
|
ensureConfigLoaded()
|
||||||
|
err := runJigglerCronTab()
|
||||||
|
if err != nil {
|
||||||
|
logger.Error().Msgf("Error scheduling jiggler crontab: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func runJigglerCronTab() error {
|
||||||
|
cronTab := config.JigglerConfig.ScheduleCronTab
|
||||||
|
|
||||||
|
// Apply timezone if specified and valid
|
||||||
|
if config.JigglerConfig.Timezone != "" && config.JigglerConfig.Timezone != "UTC" {
|
||||||
|
// Validate timezone before applying
|
||||||
|
if _, err := time.LoadLocation(config.JigglerConfig.Timezone); err != nil {
|
||||||
|
logger.Warn().Msgf("Invalid timezone '%s', falling back to UTC: %v", config.JigglerConfig.Timezone, err)
|
||||||
|
// Don't add TZ prefix, let it run in UTC
|
||||||
|
} else {
|
||||||
|
cronTab = fmt.Sprintf("TZ=%s %s", config.JigglerConfig.Timezone, cronTab)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
s, err := gocron.NewScheduler()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
scheduler = s
|
||||||
|
_, err = s.NewJob(
|
||||||
|
gocron.CronJob(
|
||||||
|
cronTab,
|
||||||
|
true,
|
||||||
|
),
|
||||||
|
gocron.NewTask(
|
||||||
|
func() {
|
||||||
|
runJiggler()
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
s.Start()
|
||||||
|
delta, err := calculateJobDelta(s)
|
||||||
|
jobDelta = delta
|
||||||
|
logger.Info().Msgf("Time between jiggler runs: %v", jobDelta)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func runJiggler() {
|
func runJiggler() {
|
||||||
for {
|
if config.JigglerEnabled {
|
||||||
if jigglerEnabled {
|
if config.JigglerConfig.JitterPercentage != 0 {
|
||||||
if time.Since(lastUserInput) > 20*time.Second {
|
jitter := calculateJitterDuration(jobDelta)
|
||||||
//TODO: change to rel mouse
|
time.Sleep(jitter)
|
||||||
err := rpcAbsMouseReport(1, 1, 0)
|
}
|
||||||
if err != nil {
|
inactivitySeconds := config.JigglerConfig.InactivityLimitSeconds
|
||||||
logger.Warn().Err(err).Msg("Failed to jiggle mouse")
|
timeSinceLastInput := time.Since(gadget.GetLastUserInputTime())
|
||||||
}
|
logger.Debug().Msgf("Time since last user input %v", timeSinceLastInput)
|
||||||
err = rpcAbsMouseReport(0, 0, 0)
|
if timeSinceLastInput > time.Duration(inactivitySeconds)*time.Second {
|
||||||
if err != nil {
|
logger.Debug().Msg("Jiggling mouse...")
|
||||||
logger.Warn().Err(err).Msg("Failed to reset mouse position")
|
//TODO: change to rel mouse
|
||||||
}
|
err := rpcAbsMouseReport(1, 1, 0)
|
||||||
|
if err != nil {
|
||||||
|
logger.Warn().Msgf("Failed to jiggle mouse: %v", err)
|
||||||
|
}
|
||||||
|
err = rpcAbsMouseReport(0, 0, 0)
|
||||||
|
if err != nil {
|
||||||
|
logger.Warn().Msgf("Failed to reset mouse position: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
time.Sleep(20 * time.Second)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func calculateJobDelta(s gocron.Scheduler) (time.Duration, error) {
|
||||||
|
j := s.Jobs()[0]
|
||||||
|
runs, err := j.NextRuns(2)
|
||||||
|
if err != nil {
|
||||||
|
return 0.0, err
|
||||||
|
}
|
||||||
|
return runs[1].Sub(runs[0]), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func calculateJitterDuration(delta time.Duration) time.Duration {
|
||||||
|
jitter := rand.Float64() * float64(config.JigglerConfig.JitterPercentage) / 100 * delta.Seconds()
|
||||||
|
return time.Duration(jitter * float64(time.Second))
|
||||||
|
}
|
||||||
|
|
|
||||||
288
jsonrpc.go
|
|
@ -1,6 +1,7 @@
|
||||||
package kvm
|
package kvm
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
|
@ -10,32 +11,36 @@ import (
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"reflect"
|
"reflect"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/pion/webrtc/v4"
|
"github.com/pion/webrtc/v4"
|
||||||
|
"github.com/rs/zerolog"
|
||||||
"go.bug.st/serial"
|
"go.bug.st/serial"
|
||||||
|
|
||||||
|
"github.com/jetkvm/kvm/internal/hidrpc"
|
||||||
"github.com/jetkvm/kvm/internal/usbgadget"
|
"github.com/jetkvm/kvm/internal/usbgadget"
|
||||||
|
"github.com/jetkvm/kvm/internal/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
type JSONRPCRequest struct {
|
type JSONRPCRequest struct {
|
||||||
JSONRPC string `json:"jsonrpc"`
|
JSONRPC string `json:"jsonrpc"`
|
||||||
Method string `json:"method"`
|
Method string `json:"method"`
|
||||||
Params map[string]interface{} `json:"params,omitempty"`
|
Params map[string]any `json:"params,omitempty"`
|
||||||
ID interface{} `json:"id,omitempty"`
|
ID any `json:"id,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type JSONRPCResponse struct {
|
type JSONRPCResponse struct {
|
||||||
JSONRPC string `json:"jsonrpc"`
|
JSONRPC string `json:"jsonrpc"`
|
||||||
Result interface{} `json:"result,omitempty"`
|
Result any `json:"result,omitempty"`
|
||||||
Error interface{} `json:"error,omitempty"`
|
Error any `json:"error,omitempty"`
|
||||||
ID interface{} `json:"id"`
|
ID any `json:"id"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type JSONRPCEvent struct {
|
type JSONRPCEvent struct {
|
||||||
JSONRPC string `json:"jsonrpc"`
|
JSONRPC string `json:"jsonrpc"`
|
||||||
Method string `json:"method"`
|
Method string `json:"method"`
|
||||||
Params interface{} `json:"params,omitempty"`
|
Params any `json:"params,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type DisplayRotationSettings struct {
|
type DisplayRotationSettings struct {
|
||||||
|
|
@ -61,7 +66,7 @@ func writeJSONRPCResponse(response JSONRPCResponse, session *Session) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func writeJSONRPCEvent(event string, params interface{}, session *Session) {
|
func writeJSONRPCEvent(event string, params any, session *Session) {
|
||||||
request := JSONRPCEvent{
|
request := JSONRPCEvent{
|
||||||
JSONRPC: "2.0",
|
JSONRPC: "2.0",
|
||||||
Method: event,
|
Method: event,
|
||||||
|
|
@ -82,7 +87,7 @@ func writeJSONRPCEvent(event string, params interface{}, session *Session) {
|
||||||
Str("data", requestString).
|
Str("data", requestString).
|
||||||
Logger()
|
Logger()
|
||||||
|
|
||||||
scopedLogger.Info().Msg("sending JSONRPC event")
|
scopedLogger.Trace().Msg("sending JSONRPC event")
|
||||||
|
|
||||||
err = session.RPCChannel.SendText(requestString)
|
err = session.RPCChannel.SendText(requestString)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -102,7 +107,7 @@ func onRPCMessage(message webrtc.DataChannelMessage, session *Session) {
|
||||||
|
|
||||||
errorResponse := JSONRPCResponse{
|
errorResponse := JSONRPCResponse{
|
||||||
JSONRPC: "2.0",
|
JSONRPC: "2.0",
|
||||||
Error: map[string]interface{}{
|
Error: map[string]any{
|
||||||
"code": -32700,
|
"code": -32700,
|
||||||
"message": "Parse error",
|
"message": "Parse error",
|
||||||
},
|
},
|
||||||
|
|
@ -123,7 +128,7 @@ func onRPCMessage(message webrtc.DataChannelMessage, session *Session) {
|
||||||
if !ok {
|
if !ok {
|
||||||
errorResponse := JSONRPCResponse{
|
errorResponse := JSONRPCResponse{
|
||||||
JSONRPC: "2.0",
|
JSONRPC: "2.0",
|
||||||
Error: map[string]interface{}{
|
Error: map[string]any{
|
||||||
"code": -32601,
|
"code": -32601,
|
||||||
"message": "Method not found",
|
"message": "Method not found",
|
||||||
},
|
},
|
||||||
|
|
@ -133,13 +138,12 @@ func onRPCMessage(message webrtc.DataChannelMessage, session *Session) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
scopedLogger.Trace().Msg("Calling RPC handler")
|
result, err := callRPCHandler(scopedLogger, handler, request.Params)
|
||||||
result, err := callRPCHandler(handler, request.Params)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
scopedLogger.Error().Err(err).Msg("Error calling RPC handler")
|
scopedLogger.Error().Err(err).Msg("Error calling RPC handler")
|
||||||
errorResponse := JSONRPCResponse{
|
errorResponse := JSONRPCResponse{
|
||||||
JSONRPC: "2.0",
|
JSONRPC: "2.0",
|
||||||
Error: map[string]interface{}{
|
Error: map[string]any{
|
||||||
"code": -32603,
|
"code": -32603,
|
||||||
"message": "Internal error",
|
"message": "Internal error",
|
||||||
"data": err.Error(),
|
"data": err.Error(),
|
||||||
|
|
@ -200,7 +204,7 @@ func rpcGetStreamQualityFactor() (float64, error) {
|
||||||
|
|
||||||
func rpcSetStreamQualityFactor(factor float64) error {
|
func rpcSetStreamQualityFactor(factor float64) error {
|
||||||
logger.Info().Float64("factor", factor).Msg("Setting stream quality factor")
|
logger.Info().Float64("factor", factor).Msg("Setting stream quality factor")
|
||||||
var _, err = CallCtrlAction("set_video_quality_factor", map[string]interface{}{"quality_factor": factor})
|
var _, err = CallCtrlAction("set_video_quality_factor", map[string]any{"quality_factor": factor})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
@ -240,7 +244,7 @@ func rpcSetEDID(edid string) error {
|
||||||
} else {
|
} else {
|
||||||
logger.Info().Str("edid", edid).Msg("Setting EDID")
|
logger.Info().Str("edid", edid).Msg("Setting EDID")
|
||||||
}
|
}
|
||||||
_, err := CallCtrlAction("set_edid", map[string]interface{}{"edid": edid})
|
_, err := CallCtrlAction("set_edid", map[string]any{"edid": edid})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
@ -278,6 +282,17 @@ func rpcGetUpdateStatus() (*UpdateStatus, error) {
|
||||||
return updateStatus, nil
|
return updateStatus, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func rpcGetLocalVersion() (*LocalMetadata, error) {
|
||||||
|
systemVersion, appVersion, err := GetLocalVersion()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("error getting local version: %w", err)
|
||||||
|
}
|
||||||
|
return &LocalMetadata{
|
||||||
|
AppVersion: appVersion.String(),
|
||||||
|
SystemVersion: systemVersion.String(),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
func rpcTryUpdate() error {
|
func rpcTryUpdate() error {
|
||||||
includePreRelease := config.IncludePreRelease
|
includePreRelease := config.IncludePreRelease
|
||||||
go func() {
|
go func() {
|
||||||
|
|
@ -429,21 +444,27 @@ func rpcGetSSHKeyState() (string, error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func rpcSetSSHKeyState(sshKey string) error {
|
func rpcSetSSHKeyState(sshKey string) error {
|
||||||
if sshKey != "" {
|
if sshKey == "" {
|
||||||
// Create directory if it doesn't exist
|
|
||||||
if err := os.MkdirAll(sshKeyDir, 0700); err != nil {
|
|
||||||
return fmt.Errorf("failed to create SSH key directory: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Write SSH key to file
|
|
||||||
if err := os.WriteFile(sshKeyFile, []byte(sshKey), 0600); err != nil {
|
|
||||||
return fmt.Errorf("failed to write SSH key: %w", err)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Remove SSH key file if empty string is provided
|
// Remove SSH key file if empty string is provided
|
||||||
if err := os.Remove(sshKeyFile); err != nil && !os.IsNotExist(err) {
|
if err := os.Remove(sshKeyFile); err != nil && !os.IsNotExist(err) {
|
||||||
return fmt.Errorf("failed to remove SSH key file: %w", err)
|
return fmt.Errorf("failed to remove SSH key file: %w", err)
|
||||||
}
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate SSH key
|
||||||
|
if err := utils.ValidateSSHKey(sshKey); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create directory if it doesn't exist
|
||||||
|
if err := os.MkdirAll(sshKeyDir, 0700); err != nil {
|
||||||
|
return fmt.Errorf("failed to create SSH key directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write SSH key to file
|
||||||
|
if err := os.WriteFile(sshKeyFile, []byte(sshKey), 0600); err != nil {
|
||||||
|
return fmt.Errorf("failed to write SSH key: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
|
|
@ -467,12 +488,12 @@ func rpcSetTLSState(state TLSState) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
type RPCHandler struct {
|
type RPCHandler struct {
|
||||||
Func interface{}
|
Func any
|
||||||
Params []string
|
Params []string
|
||||||
}
|
}
|
||||||
|
|
||||||
// call the handler but recover from a panic to ensure our RPC thread doesn't collapse on malformed calls
|
// call the handler but recover from a panic to ensure our RPC thread doesn't collapse on malformed calls
|
||||||
func callRPCHandler(handler RPCHandler, params map[string]interface{}) (result interface{}, err error) {
|
func callRPCHandler(logger zerolog.Logger, handler RPCHandler, params map[string]any) (result any, err error) {
|
||||||
// Use defer to recover from a panic
|
// Use defer to recover from a panic
|
||||||
defer func() {
|
defer func() {
|
||||||
if r := recover(); r != nil {
|
if r := recover(); r != nil {
|
||||||
|
|
@ -486,11 +507,11 @@ func callRPCHandler(handler RPCHandler, params map[string]interface{}) (result i
|
||||||
}()
|
}()
|
||||||
|
|
||||||
// Call the handler
|
// Call the handler
|
||||||
result, err = riskyCallRPCHandler(handler, params)
|
result, err = riskyCallRPCHandler(logger, handler, params)
|
||||||
return result, err
|
return result, err // do not combine these two lines into one, as it breaks the above defer function's setting of err
|
||||||
}
|
}
|
||||||
|
|
||||||
func riskyCallRPCHandler(handler RPCHandler, params map[string]interface{}) (interface{}, error) {
|
func riskyCallRPCHandler(logger zerolog.Logger, handler RPCHandler, params map[string]any) (any, error) {
|
||||||
handlerValue := reflect.ValueOf(handler.Func)
|
handlerValue := reflect.ValueOf(handler.Func)
|
||||||
handlerType := handlerValue.Type()
|
handlerType := handlerValue.Type()
|
||||||
|
|
||||||
|
|
@ -499,20 +520,24 @@ func riskyCallRPCHandler(handler RPCHandler, params map[string]interface{}) (int
|
||||||
}
|
}
|
||||||
|
|
||||||
numParams := handlerType.NumIn()
|
numParams := handlerType.NumIn()
|
||||||
args := make([]reflect.Value, numParams)
|
paramNames := handler.Params // Get the parameter names from the RPCHandler
|
||||||
// Get the parameter names from the RPCHandler
|
|
||||||
paramNames := handler.Params
|
|
||||||
|
|
||||||
if len(paramNames) != numParams {
|
if len(paramNames) != numParams {
|
||||||
return nil, errors.New("mismatch between handler parameters and defined parameter names")
|
err := fmt.Errorf("mismatch between handler parameters (%d) and defined parameter names (%d)", numParams, len(paramNames))
|
||||||
|
logger.Error().Strs("paramNames", paramNames).Err(err).Msg("Cannot call RPC handler")
|
||||||
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
for i := 0; i < numParams; i++ {
|
args := make([]reflect.Value, numParams)
|
||||||
|
|
||||||
|
for i := range numParams {
|
||||||
paramType := handlerType.In(i)
|
paramType := handlerType.In(i)
|
||||||
paramName := paramNames[i]
|
paramName := paramNames[i]
|
||||||
paramValue, ok := params[paramName]
|
paramValue, ok := params[paramName]
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil, errors.New("missing parameter: " + paramName)
|
err := fmt.Errorf("missing parameter: %s", paramName)
|
||||||
|
logger.Error().Err(err).Msg("Cannot marshal arguments for RPC handler")
|
||||||
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
convertedValue := reflect.ValueOf(paramValue)
|
convertedValue := reflect.ValueOf(paramValue)
|
||||||
|
|
@ -529,7 +554,7 @@ func riskyCallRPCHandler(handler RPCHandler, params map[string]interface{}) (int
|
||||||
if elemValue.Kind() == reflect.Float64 && paramType.Elem().Kind() == reflect.Uint8 {
|
if elemValue.Kind() == reflect.Float64 && paramType.Elem().Kind() == reflect.Uint8 {
|
||||||
intValue := int(elemValue.Float())
|
intValue := int(elemValue.Float())
|
||||||
if intValue < 0 || intValue > 255 {
|
if intValue < 0 || intValue > 255 {
|
||||||
return nil, fmt.Errorf("value out of range for uint8: %v", intValue)
|
return nil, fmt.Errorf("value out of range for uint8: %v for parameter %s", intValue, paramName)
|
||||||
}
|
}
|
||||||
newSlice.Index(j).SetUint(uint64(intValue))
|
newSlice.Index(j).SetUint(uint64(intValue))
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -545,12 +570,12 @@ func riskyCallRPCHandler(handler RPCHandler, params map[string]interface{}) (int
|
||||||
} else if paramType.Kind() == reflect.Struct && convertedValue.Kind() == reflect.Map {
|
} else if paramType.Kind() == reflect.Struct && convertedValue.Kind() == reflect.Map {
|
||||||
jsonData, err := json.Marshal(convertedValue.Interface())
|
jsonData, err := json.Marshal(convertedValue.Interface())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to marshal map to JSON: %v", err)
|
return nil, fmt.Errorf("failed to marshal map to JSON: %v for parameter %s", err, paramName)
|
||||||
}
|
}
|
||||||
|
|
||||||
newStruct := reflect.New(paramType).Interface()
|
newStruct := reflect.New(paramType).Interface()
|
||||||
if err := json.Unmarshal(jsonData, newStruct); err != nil {
|
if err := json.Unmarshal(jsonData, newStruct); err != nil {
|
||||||
return nil, fmt.Errorf("failed to unmarshal JSON into struct: %v", err)
|
return nil, fmt.Errorf("failed to unmarshal JSON into struct: %v for parameter %s", err, paramName)
|
||||||
}
|
}
|
||||||
args[i] = reflect.ValueOf(newStruct).Elem()
|
args[i] = reflect.ValueOf(newStruct).Elem()
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -561,6 +586,7 @@ func riskyCallRPCHandler(handler RPCHandler, params map[string]interface{}) (int
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
logger.Trace().Msg("Calling RPC handler")
|
||||||
results := handlerValue.Call(args)
|
results := handlerValue.Call(args)
|
||||||
|
|
||||||
if len(results) == 0 {
|
if len(results) == 0 {
|
||||||
|
|
@ -568,23 +594,32 @@ func riskyCallRPCHandler(handler RPCHandler, params map[string]interface{}) (int
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(results) == 1 {
|
if len(results) == 1 {
|
||||||
if results[0].Type().Implements(reflect.TypeOf((*error)(nil)).Elem()) {
|
if ok, err := asError(results[0]); ok {
|
||||||
if !results[0].IsNil() {
|
return nil, err
|
||||||
return nil, results[0].Interface().(error)
|
}
|
||||||
|
return results[0].Interface(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(results) == 2 {
|
||||||
|
if ok, err := asError(results[1]); ok {
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
}
|
}
|
||||||
return nil, nil
|
|
||||||
}
|
}
|
||||||
return results[0].Interface(), nil
|
return results[0].Interface(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(results) == 2 && results[1].Type().Implements(reflect.TypeOf((*error)(nil)).Elem()) {
|
return nil, fmt.Errorf("too many return values from handler: %d", len(results))
|
||||||
if !results[1].IsNil() {
|
}
|
||||||
return nil, results[1].Interface().(error)
|
|
||||||
}
|
|
||||||
return results[0].Interface(), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil, errors.New("unexpected return values from handler")
|
func asError(value reflect.Value) (bool, error) {
|
||||||
|
if value.Type().Implements(reflect.TypeOf((*error)(nil)).Elem()) {
|
||||||
|
if value.IsNil() {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
return true, value.Interface().(error)
|
||||||
|
}
|
||||||
|
return false, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func rpcSetMassStorageMode(mode string) (string, error) {
|
func rpcSetMassStorageMode(mode string) (string, error) {
|
||||||
|
|
@ -681,10 +716,11 @@ func rpcResetConfig() error {
|
||||||
}
|
}
|
||||||
|
|
||||||
type DCPowerState struct {
|
type DCPowerState struct {
|
||||||
IsOn bool `json:"isOn"`
|
IsOn bool `json:"isOn"`
|
||||||
Voltage float64 `json:"voltage"`
|
Voltage float64 `json:"voltage"`
|
||||||
Current float64 `json:"current"`
|
Current float64 `json:"current"`
|
||||||
Power float64 `json:"power"`
|
Power float64 `json:"power"`
|
||||||
|
RestoreState int `json:"restoreState"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func rpcGetDCPowerState() (DCPowerState, error) {
|
func rpcGetDCPowerState() (DCPowerState, error) {
|
||||||
|
|
@ -700,6 +736,15 @@ func rpcSetDCPowerState(enabled bool) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func rpcSetDCRestoreState(state int) error {
|
||||||
|
logger.Info().Int("state", state).Msg("Setting DC restore state")
|
||||||
|
err := setDCRestoreState(state)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to set DC restore state: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func rpcGetActiveExtension() (string, error) {
|
func rpcGetActiveExtension() (string, error) {
|
||||||
return config.ActiveExtension, nil
|
return config.ActiveExtension, nil
|
||||||
}
|
}
|
||||||
|
|
@ -913,7 +958,7 @@ func rpcSetKeyboardLayout(layout string) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func getKeyboardMacros() (interface{}, error) {
|
func getKeyboardMacros() (any, error) {
|
||||||
macros := make([]KeyboardMacro, len(config.KeyboardMacros))
|
macros := make([]KeyboardMacro, len(config.KeyboardMacros))
|
||||||
copy(macros, config.KeyboardMacros)
|
copy(macros, config.KeyboardMacros)
|
||||||
|
|
||||||
|
|
@ -921,10 +966,10 @@ func getKeyboardMacros() (interface{}, error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
type KeyboardMacrosParams struct {
|
type KeyboardMacrosParams struct {
|
||||||
Macros []interface{} `json:"macros"`
|
Macros []any `json:"macros"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func setKeyboardMacros(params KeyboardMacrosParams) (interface{}, error) {
|
func setKeyboardMacros(params KeyboardMacrosParams) (any, error) {
|
||||||
if params.Macros == nil {
|
if params.Macros == nil {
|
||||||
return nil, fmt.Errorf("missing or invalid macros parameter")
|
return nil, fmt.Errorf("missing or invalid macros parameter")
|
||||||
}
|
}
|
||||||
|
|
@ -932,7 +977,7 @@ func setKeyboardMacros(params KeyboardMacrosParams) (interface{}, error) {
|
||||||
newMacros := make([]KeyboardMacro, 0, len(params.Macros))
|
newMacros := make([]KeyboardMacro, 0, len(params.Macros))
|
||||||
|
|
||||||
for i, item := range params.Macros {
|
for i, item := range params.Macros {
|
||||||
macroMap, ok := item.(map[string]interface{})
|
macroMap, ok := item.(map[string]any)
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil, fmt.Errorf("invalid macro at index %d", i)
|
return nil, fmt.Errorf("invalid macro at index %d", i)
|
||||||
}
|
}
|
||||||
|
|
@ -950,16 +995,16 @@ func setKeyboardMacros(params KeyboardMacrosParams) (interface{}, error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
steps := []KeyboardMacroStep{}
|
steps := []KeyboardMacroStep{}
|
||||||
if stepsArray, ok := macroMap["steps"].([]interface{}); ok {
|
if stepsArray, ok := macroMap["steps"].([]any); ok {
|
||||||
for _, stepItem := range stepsArray {
|
for _, stepItem := range stepsArray {
|
||||||
stepMap, ok := stepItem.(map[string]interface{})
|
stepMap, ok := stepItem.(map[string]any)
|
||||||
if !ok {
|
if !ok {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
step := KeyboardMacroStep{}
|
step := KeyboardMacroStep{}
|
||||||
|
|
||||||
if keysArray, ok := stepMap["keys"].([]interface{}); ok {
|
if keysArray, ok := stepMap["keys"].([]any); ok {
|
||||||
for _, k := range keysArray {
|
for _, k := range keysArray {
|
||||||
if keyStr, ok := k.(string); ok {
|
if keyStr, ok := k.(string); ok {
|
||||||
step.Keys = append(step.Keys, keyStr)
|
step.Keys = append(step.Keys, keyStr)
|
||||||
|
|
@ -967,7 +1012,7 @@ func setKeyboardMacros(params KeyboardMacrosParams) (interface{}, error) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if modsArray, ok := stepMap["modifiers"].([]interface{}); ok {
|
if modsArray, ok := stepMap["modifiers"].([]any); ok {
|
||||||
for _, m := range modsArray {
|
for _, m := range modsArray {
|
||||||
if modStr, ok := m.(string); ok {
|
if modStr, ok := m.(string); ok {
|
||||||
step.Modifiers = append(step.Modifiers, modStr)
|
step.Modifiers = append(step.Modifiers, modStr)
|
||||||
|
|
@ -1025,6 +1070,103 @@ func rpcSetLocalLoopbackOnly(enabled bool) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
keyboardMacroCancel context.CancelFunc
|
||||||
|
keyboardMacroLock sync.Mutex
|
||||||
|
)
|
||||||
|
|
||||||
|
// cancelKeyboardMacro cancels any ongoing keyboard macro execution
|
||||||
|
func cancelKeyboardMacro() {
|
||||||
|
keyboardMacroLock.Lock()
|
||||||
|
defer keyboardMacroLock.Unlock()
|
||||||
|
|
||||||
|
if keyboardMacroCancel != nil {
|
||||||
|
keyboardMacroCancel()
|
||||||
|
logger.Info().Msg("canceled keyboard macro")
|
||||||
|
keyboardMacroCancel = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func setKeyboardMacroCancel(cancel context.CancelFunc) {
|
||||||
|
keyboardMacroLock.Lock()
|
||||||
|
defer keyboardMacroLock.Unlock()
|
||||||
|
|
||||||
|
keyboardMacroCancel = cancel
|
||||||
|
}
|
||||||
|
|
||||||
|
func rpcExecuteKeyboardMacro(macro []hidrpc.KeyboardMacroStep) error {
|
||||||
|
cancelKeyboardMacro()
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
setKeyboardMacroCancel(cancel)
|
||||||
|
|
||||||
|
s := hidrpc.KeyboardMacroState{
|
||||||
|
State: true,
|
||||||
|
IsPaste: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
if currentSession != nil {
|
||||||
|
currentSession.reportHidRPCKeyboardMacroState(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
err := rpcDoExecuteKeyboardMacro(ctx, macro)
|
||||||
|
|
||||||
|
setKeyboardMacroCancel(nil)
|
||||||
|
|
||||||
|
s.State = false
|
||||||
|
if currentSession != nil {
|
||||||
|
currentSession.reportHidRPCKeyboardMacroState(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func rpcCancelKeyboardMacro() {
|
||||||
|
cancelKeyboardMacro()
|
||||||
|
}
|
||||||
|
|
||||||
|
var keyboardClearStateKeys = make([]byte, hidrpc.HidKeyBufferSize)
|
||||||
|
|
||||||
|
func isClearKeyStep(step hidrpc.KeyboardMacroStep) bool {
|
||||||
|
return step.Modifier == 0 && bytes.Equal(step.Keys, keyboardClearStateKeys)
|
||||||
|
}
|
||||||
|
|
||||||
|
func rpcDoExecuteKeyboardMacro(ctx context.Context, macro []hidrpc.KeyboardMacroStep) error {
|
||||||
|
logger.Debug().Interface("macro", macro).Msg("Executing keyboard macro")
|
||||||
|
|
||||||
|
for i, step := range macro {
|
||||||
|
delay := time.Duration(step.Delay) * time.Millisecond
|
||||||
|
|
||||||
|
err := rpcKeyboardReport(step.Modifier, step.Keys)
|
||||||
|
if err != nil {
|
||||||
|
logger.Warn().Err(err).Msg("failed to execute keyboard macro")
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// notify the device that the keyboard state is being cleared
|
||||||
|
if isClearKeyStep(step) {
|
||||||
|
gadget.UpdateKeysDown(0, keyboardClearStateKeys)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use context-aware sleep that can be cancelled
|
||||||
|
select {
|
||||||
|
case <-time.After(delay):
|
||||||
|
// Sleep completed normally
|
||||||
|
case <-ctx.Done():
|
||||||
|
// make sure keyboard state is reset
|
||||||
|
err := rpcKeyboardReport(0, keyboardClearStateKeys)
|
||||||
|
if err != nil {
|
||||||
|
logger.Warn().Err(err).Msg("failed to reset keyboard state")
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Debug().Int("step", i).Msg("Keyboard macro cancelled during sleep")
|
||||||
|
return ctx.Err()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
var rpcHandlers = map[string]RPCHandler{
|
var rpcHandlers = map[string]RPCHandler{
|
||||||
"ping": {Func: rpcPing},
|
"ping": {Func: rpcPing},
|
||||||
"reboot": {Func: rpcReboot, Params: []string{"force"}},
|
"reboot": {Func: rpcReboot, Params: []string{"force"}},
|
||||||
|
|
@ -1035,8 +1177,10 @@ var rpcHandlers = map[string]RPCHandler{
|
||||||
"getNetworkSettings": {Func: rpcGetNetworkSettings},
|
"getNetworkSettings": {Func: rpcGetNetworkSettings},
|
||||||
"setNetworkSettings": {Func: rpcSetNetworkSettings, Params: []string{"settings"}},
|
"setNetworkSettings": {Func: rpcSetNetworkSettings, Params: []string{"settings"}},
|
||||||
"renewDHCPLease": {Func: rpcRenewDHCPLease},
|
"renewDHCPLease": {Func: rpcRenewDHCPLease},
|
||||||
"keyboardReport": {Func: rpcKeyboardReport, Params: []string{"modifier", "keys"}},
|
|
||||||
"getKeyboardLedState": {Func: rpcGetKeyboardLedState},
|
"getKeyboardLedState": {Func: rpcGetKeyboardLedState},
|
||||||
|
"getKeyDownState": {Func: rpcGetKeysDownState},
|
||||||
|
"keyboardReport": {Func: rpcKeyboardReport, Params: []string{"modifier", "keys"}},
|
||||||
|
"keypressReport": {Func: rpcKeypressReport, Params: []string{"key", "press"}},
|
||||||
"absMouseReport": {Func: rpcAbsMouseReport, Params: []string{"x", "y", "buttons"}},
|
"absMouseReport": {Func: rpcAbsMouseReport, Params: []string{"x", "y", "buttons"}},
|
||||||
"relMouseReport": {Func: rpcRelMouseReport, Params: []string{"dx", "dy", "buttons"}},
|
"relMouseReport": {Func: rpcRelMouseReport, Params: []string{"dx", "dy", "buttons"}},
|
||||||
"wheelReport": {Func: rpcWheelReport, Params: []string{"wheelY"}},
|
"wheelReport": {Func: rpcWheelReport, Params: []string{"wheelY"}},
|
||||||
|
|
@ -1046,6 +1190,9 @@ var rpcHandlers = map[string]RPCHandler{
|
||||||
"rpcMountBuiltInImage": {Func: rpcMountBuiltInImage, Params: []string{"filename"}},
|
"rpcMountBuiltInImage": {Func: rpcMountBuiltInImage, Params: []string{"filename"}},
|
||||||
"setJigglerState": {Func: rpcSetJigglerState, Params: []string{"enabled"}},
|
"setJigglerState": {Func: rpcSetJigglerState, Params: []string{"enabled"}},
|
||||||
"getJigglerState": {Func: rpcGetJigglerState},
|
"getJigglerState": {Func: rpcGetJigglerState},
|
||||||
|
"setJigglerConfig": {Func: rpcSetJigglerConfig, Params: []string{"jigglerConfig"}},
|
||||||
|
"getJigglerConfig": {Func: rpcGetJigglerConfig},
|
||||||
|
"getTimezones": {Func: rpcGetTimezones},
|
||||||
"sendWOLMagicPacket": {Func: rpcSendWOLMagicPacket, Params: []string{"macAddress"}},
|
"sendWOLMagicPacket": {Func: rpcSendWOLMagicPacket, Params: []string{"macAddress"}},
|
||||||
"getStreamQualityFactor": {Func: rpcGetStreamQualityFactor},
|
"getStreamQualityFactor": {Func: rpcGetStreamQualityFactor},
|
||||||
"setStreamQualityFactor": {Func: rpcSetStreamQualityFactor, Params: []string{"factor"}},
|
"setStreamQualityFactor": {Func: rpcSetStreamQualityFactor, Params: []string{"factor"}},
|
||||||
|
|
@ -1055,6 +1202,7 @@ var rpcHandlers = map[string]RPCHandler{
|
||||||
"setEDID": {Func: rpcSetEDID, Params: []string{"edid"}},
|
"setEDID": {Func: rpcSetEDID, Params: []string{"edid"}},
|
||||||
"getDevChannelState": {Func: rpcGetDevChannelState},
|
"getDevChannelState": {Func: rpcGetDevChannelState},
|
||||||
"setDevChannelState": {Func: rpcSetDevChannelState, Params: []string{"enabled"}},
|
"setDevChannelState": {Func: rpcSetDevChannelState, Params: []string{"enabled"}},
|
||||||
|
"getLocalVersion": {Func: rpcGetLocalVersion},
|
||||||
"getUpdateStatus": {Func: rpcGetUpdateStatus},
|
"getUpdateStatus": {Func: rpcGetUpdateStatus},
|
||||||
"tryUpdate": {Func: rpcTryUpdate},
|
"tryUpdate": {Func: rpcTryUpdate},
|
||||||
"getDevModeState": {Func: rpcGetDevModeState},
|
"getDevModeState": {Func: rpcGetDevModeState},
|
||||||
|
|
@ -1074,7 +1222,6 @@ var rpcHandlers = map[string]RPCHandler{
|
||||||
"getVirtualMediaState": {Func: rpcGetVirtualMediaState},
|
"getVirtualMediaState": {Func: rpcGetVirtualMediaState},
|
||||||
"getStorageSpace": {Func: rpcGetStorageSpace},
|
"getStorageSpace": {Func: rpcGetStorageSpace},
|
||||||
"mountWithHTTP": {Func: rpcMountWithHTTP, Params: []string{"url", "mode"}},
|
"mountWithHTTP": {Func: rpcMountWithHTTP, Params: []string{"url", "mode"}},
|
||||||
"mountWithWebRTC": {Func: rpcMountWithWebRTC, Params: []string{"filename", "size", "mode"}},
|
|
||||||
"mountWithStorage": {Func: rpcMountWithStorage, Params: []string{"filename", "mode"}},
|
"mountWithStorage": {Func: rpcMountWithStorage, Params: []string{"filename", "mode"}},
|
||||||
"listStorageFiles": {Func: rpcListStorageFiles},
|
"listStorageFiles": {Func: rpcListStorageFiles},
|
||||||
"deleteStorageFile": {Func: rpcDeleteStorageFile, Params: []string{"filename"}},
|
"deleteStorageFile": {Func: rpcDeleteStorageFile, Params: []string{"filename"}},
|
||||||
|
|
@ -1088,6 +1235,7 @@ var rpcHandlers = map[string]RPCHandler{
|
||||||
"getBacklightSettings": {Func: rpcGetBacklightSettings},
|
"getBacklightSettings": {Func: rpcGetBacklightSettings},
|
||||||
"getDCPowerState": {Func: rpcGetDCPowerState},
|
"getDCPowerState": {Func: rpcGetDCPowerState},
|
||||||
"setDCPowerState": {Func: rpcSetDCPowerState, Params: []string{"enabled"}},
|
"setDCPowerState": {Func: rpcSetDCPowerState, Params: []string{"enabled"}},
|
||||||
|
"setDCRestoreState": {Func: rpcSetDCRestoreState, Params: []string{"state"}},
|
||||||
"getActiveExtension": {Func: rpcGetActiveExtension},
|
"getActiveExtension": {Func: rpcGetActiveExtension},
|
||||||
"setActiveExtension": {Func: rpcSetActiveExtension, Params: []string{"extensionId"}},
|
"setActiveExtension": {Func: rpcSetActiveExtension, Params: []string{"extensionId"}},
|
||||||
"getATXState": {Func: rpcGetATXState},
|
"getATXState": {Func: rpcGetATXState},
|
||||||
|
|
|
||||||
3
log.go
|
|
@ -5,7 +5,7 @@ import (
|
||||||
"github.com/rs/zerolog"
|
"github.com/rs/zerolog"
|
||||||
)
|
)
|
||||||
|
|
||||||
func ErrorfL(l *zerolog.Logger, format string, err error, args ...interface{}) error {
|
func ErrorfL(l *zerolog.Logger, format string, err error, args ...any) error {
|
||||||
return logging.ErrorfL(l, format, err, args...)
|
return logging.ErrorfL(l, format, err, args...)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -19,6 +19,7 @@ var (
|
||||||
nbdLogger = logging.GetSubsystemLogger("nbd")
|
nbdLogger = logging.GetSubsystemLogger("nbd")
|
||||||
timesyncLogger = logging.GetSubsystemLogger("timesync")
|
timesyncLogger = logging.GetSubsystemLogger("timesync")
|
||||||
jsonRpcLogger = logging.GetSubsystemLogger("jsonrpc")
|
jsonRpcLogger = logging.GetSubsystemLogger("jsonrpc")
|
||||||
|
hidRPCLogger = logging.GetSubsystemLogger("hidrpc")
|
||||||
watchdogLogger = logging.GetSubsystemLogger("watchdog")
|
watchdogLogger = logging.GetSubsystemLogger("watchdog")
|
||||||
websecureLogger = logging.GetSubsystemLogger("websecure")
|
websecureLogger = logging.GetSubsystemLogger("websecure")
|
||||||
otaLogger = logging.GetSubsystemLogger("ota")
|
otaLogger = logging.GetSubsystemLogger("ota")
|
||||||
|
|
|
||||||
9
main.go
|
|
@ -96,16 +96,25 @@ func Main() {
|
||||||
if !config.AutoUpdateEnabled {
|
if !config.AutoUpdateEnabled {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if isTimeSyncNeeded() || !timeSync.IsSyncSuccess() {
|
||||||
|
logger.Debug().Msg("system time is not synced, will retry in 30 seconds")
|
||||||
|
time.Sleep(30 * time.Second)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
if currentSession != nil {
|
if currentSession != nil {
|
||||||
logger.Debug().Msg("skipping update since a session is active")
|
logger.Debug().Msg("skipping update since a session is active")
|
||||||
time.Sleep(1 * time.Minute)
|
time.Sleep(1 * time.Minute)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
includePreRelease := config.IncludePreRelease
|
includePreRelease := config.IncludePreRelease
|
||||||
err = TryUpdate(context.Background(), GetDeviceID(), includePreRelease)
|
err = TryUpdate(context.Background(), GetDeviceID(), includePreRelease)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Warn().Err(err).Msg("failed to auto update")
|
logger.Warn().Err(err).Msg("failed to auto update")
|
||||||
}
|
}
|
||||||
|
|
||||||
time.Sleep(1 * time.Hour)
|
time.Sleep(1 * time.Hour)
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
|
||||||
5
mdns.go
|
|
@ -13,10 +13,7 @@ func initMdns() error {
|
||||||
networkState.GetHostname(),
|
networkState.GetHostname(),
|
||||||
networkState.GetFQDN(),
|
networkState.GetFQDN(),
|
||||||
},
|
},
|
||||||
ListenOptions: &mdns.MDNSListenOptions{
|
ListenOptions: config.NetworkConfig.GetMDNSMode(),
|
||||||
IPv4: true,
|
|
||||||
IPv6: true,
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|
|
||||||
41
native.go
|
|
@ -9,6 +9,7 @@ import (
|
||||||
"net"
|
"net"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
|
@ -20,18 +21,18 @@ import (
|
||||||
var ctrlSocketConn net.Conn
|
var ctrlSocketConn net.Conn
|
||||||
|
|
||||||
type CtrlAction struct {
|
type CtrlAction struct {
|
||||||
Action string `json:"action"`
|
Action string `json:"action"`
|
||||||
Seq int32 `json:"seq,omitempty"`
|
Seq int32 `json:"seq,omitempty"`
|
||||||
Params map[string]interface{} `json:"params,omitempty"`
|
Params map[string]any `json:"params,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type CtrlResponse struct {
|
type CtrlResponse struct {
|
||||||
Seq int32 `json:"seq,omitempty"`
|
Seq int32 `json:"seq,omitempty"`
|
||||||
Error string `json:"error,omitempty"`
|
Error string `json:"error,omitempty"`
|
||||||
Errno int32 `json:"errno,omitempty"`
|
Errno int32 `json:"errno,omitempty"`
|
||||||
Result map[string]interface{} `json:"result,omitempty"`
|
Result map[string]any `json:"result,omitempty"`
|
||||||
Event string `json:"event,omitempty"`
|
Event string `json:"event,omitempty"`
|
||||||
Data json.RawMessage `json:"data,omitempty"`
|
Data json.RawMessage `json:"data,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type EventHandler func(event CtrlResponse)
|
type EventHandler func(event CtrlResponse)
|
||||||
|
|
@ -47,7 +48,7 @@ var (
|
||||||
nativeCmdLock = &sync.Mutex{}
|
nativeCmdLock = &sync.Mutex{}
|
||||||
)
|
)
|
||||||
|
|
||||||
func CallCtrlAction(action string, params map[string]interface{}) (*CtrlResponse, error) {
|
func CallCtrlAction(action string, params map[string]any) (*CtrlResponse, error) {
|
||||||
lock.Lock()
|
lock.Lock()
|
||||||
defer lock.Unlock()
|
defer lock.Unlock()
|
||||||
ctrlAction := CtrlAction{
|
ctrlAction := CtrlAction{
|
||||||
|
|
@ -366,6 +367,22 @@ func shouldOverwrite(destPath string, srcHash []byte) bool {
|
||||||
return !bytes.Equal(srcHash, dstHash)
|
return !bytes.Equal(srcHash, dstHash)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func getNativeSha256() ([]byte, error) {
|
||||||
|
version, err := resource.ResourceFS.ReadFile("jetkvm_native.sha256")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return version, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetNativeVersion() (string, error) {
|
||||||
|
version, err := getNativeSha256()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(string(version)), nil
|
||||||
|
}
|
||||||
|
|
||||||
func ensureBinaryUpdated(destPath string) error {
|
func ensureBinaryUpdated(destPath string) error {
|
||||||
srcFile, err := resource.ResourceFS.Open("jetkvm_native")
|
srcFile, err := resource.ResourceFS.Open("jetkvm_native")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -373,7 +390,7 @@ func ensureBinaryUpdated(destPath string) error {
|
||||||
}
|
}
|
||||||
defer srcFile.Close()
|
defer srcFile.Close()
|
||||||
|
|
||||||
srcHash, err := resource.ResourceFS.ReadFile("jetkvm_native.sha256")
|
srcHash, err := getNativeSha256()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
nativeLogger.Debug().Msg("error reading embedded jetkvm_native.sha256, proceeding with update")
|
nativeLogger.Debug().Msg("error reading embedded jetkvm_native.sha256, proceeding with update")
|
||||||
srcHash = nil
|
srcHash = nil
|
||||||
|
|
@ -412,7 +429,7 @@ func ensureBinaryUpdated(destPath string) error {
|
||||||
func restoreHdmiEdid() {
|
func restoreHdmiEdid() {
|
||||||
if config.EdidString != "" {
|
if config.EdidString != "" {
|
||||||
nativeLogger.Info().Str("edid", config.EdidString).Msg("Restoring HDMI EDID")
|
nativeLogger.Info().Str("edid", config.EdidString).Msg("Restoring HDMI EDID")
|
||||||
_, err := CallCtrlAction("set_edid", map[string]interface{}{"edid": config.EdidString})
|
_, err := CallCtrlAction("set_edid", map[string]any{"edid": config.EdidString})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
nativeLogger.Warn().Err(err).Msg("Failed to restore HDMI EDID")
|
nativeLogger.Warn().Err(err).Msg("Failed to restore HDMI EDID")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
37
network.go
|
|
@ -15,10 +15,20 @@ var (
|
||||||
networkState *network.NetworkInterfaceState
|
networkState *network.NetworkInterfaceState
|
||||||
)
|
)
|
||||||
|
|
||||||
func networkStateChanged() {
|
func networkStateChanged(isOnline bool) {
|
||||||
// do not block the main thread
|
// do not block the main thread
|
||||||
go waitCtrlAndRequestDisplayUpdate(true)
|
go waitCtrlAndRequestDisplayUpdate(true)
|
||||||
|
|
||||||
|
if timeSync != nil {
|
||||||
|
if networkState != nil {
|
||||||
|
timeSync.SetDhcpNtpAddresses(networkState.NtpAddressesString())
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := timeSync.Sync(); err != nil {
|
||||||
|
networkLogger.Error().Err(err).Msg("failed to sync time after network state change")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// always restart mDNS when the network state changes
|
// always restart mDNS when the network state changes
|
||||||
if mDNS != nil {
|
if mDNS != nil {
|
||||||
_ = mDNS.SetListenOptions(config.NetworkConfig.GetMDNSMode())
|
_ = mDNS.SetListenOptions(config.NetworkConfig.GetMDNSMode())
|
||||||
|
|
@ -27,6 +37,13 @@ func networkStateChanged() {
|
||||||
networkState.GetFQDN(),
|
networkState.GetFQDN(),
|
||||||
}, true)
|
}, true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// if the network is now online, trigger an NTP sync if still needed
|
||||||
|
if isOnline && timeSync != nil && (isTimeSyncNeeded() || !timeSync.IsSyncSuccess()) {
|
||||||
|
if err := timeSync.Sync(); err != nil {
|
||||||
|
logger.Warn().Str("error", err.Error()).Msg("unable to sync time on network state change")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func initNetwork() error {
|
func initNetwork() error {
|
||||||
|
|
@ -38,13 +55,13 @@ func initNetwork() error {
|
||||||
NetworkConfig: config.NetworkConfig,
|
NetworkConfig: config.NetworkConfig,
|
||||||
Logger: networkLogger,
|
Logger: networkLogger,
|
||||||
OnStateChange: func(state *network.NetworkInterfaceState) {
|
OnStateChange: func(state *network.NetworkInterfaceState) {
|
||||||
networkStateChanged()
|
networkStateChanged(state.IsOnline())
|
||||||
},
|
},
|
||||||
OnInitialCheck: func(state *network.NetworkInterfaceState) {
|
OnInitialCheck: func(state *network.NetworkInterfaceState) {
|
||||||
networkStateChanged()
|
networkStateChanged(state.IsOnline())
|
||||||
},
|
},
|
||||||
OnDhcpLeaseChange: func(lease *udhcpc.Lease) {
|
OnDhcpLeaseChange: func(lease *udhcpc.Lease, state *network.NetworkInterfaceState) {
|
||||||
networkStateChanged()
|
networkStateChanged(state.IsOnline())
|
||||||
|
|
||||||
if currentSession == nil {
|
if currentSession == nil {
|
||||||
return
|
return
|
||||||
|
|
@ -54,7 +71,15 @@ func initNetwork() error {
|
||||||
},
|
},
|
||||||
OnConfigChange: func(networkConfig *network.NetworkConfig) {
|
OnConfigChange: func(networkConfig *network.NetworkConfig) {
|
||||||
config.NetworkConfig = networkConfig
|
config.NetworkConfig = networkConfig
|
||||||
networkStateChanged()
|
networkStateChanged(false)
|
||||||
|
|
||||||
|
if mDNS != nil {
|
||||||
|
_ = mDNS.SetListenOptions(networkConfig.GetMDNSMode())
|
||||||
|
_ = mDNS.SetLocalNames([]string{
|
||||||
|
networkState.GetHostname(),
|
||||||
|
networkState.GetFQDN(),
|
||||||
|
}, true)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
14
ota.go
|
|
@ -50,6 +50,10 @@ const UpdateMetadataUrl = "https://api.jetkvm.com/releases"
|
||||||
|
|
||||||
var builtAppVersion = "0.1.0+dev"
|
var builtAppVersion = "0.1.0+dev"
|
||||||
|
|
||||||
|
func GetBuiltAppVersion() string {
|
||||||
|
return builtAppVersion
|
||||||
|
}
|
||||||
|
|
||||||
func GetLocalVersion() (systemVersion *semver.Version, appVersion *semver.Version, err error) {
|
func GetLocalVersion() (systemVersion *semver.Version, appVersion *semver.Version, err error) {
|
||||||
appVersion, err = semver.NewVersion(builtAppVersion)
|
appVersion, err = semver.NewVersion(builtAppVersion)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -89,7 +93,14 @@ func fetchUpdateMetadata(ctx context.Context, deviceId string, includePreRelease
|
||||||
return nil, fmt.Errorf("error creating request: %w", err)
|
return nil, fmt.Errorf("error creating request: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
resp, err := http.DefaultClient.Do(req)
|
transport := http.DefaultTransport.(*http.Transport).Clone()
|
||||||
|
transport.Proxy = config.NetworkConfig.GetTransportProxyFunc()
|
||||||
|
|
||||||
|
client := &http.Client{
|
||||||
|
Transport: transport,
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := client.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("error sending request: %w", err)
|
return nil, fmt.Errorf("error sending request: %w", err)
|
||||||
}
|
}
|
||||||
|
|
@ -135,6 +146,7 @@ func downloadFile(ctx context.Context, path string, url string, downloadProgress
|
||||||
client := http.Client{
|
client := http.Client{
|
||||||
Timeout: 10 * time.Minute,
|
Timeout: 10 * time.Minute,
|
||||||
Transport: &http.Transport{
|
Transport: &http.Transport{
|
||||||
|
Proxy: config.NetworkConfig.GetTransportProxyFunc(),
|
||||||
TLSHandshakeTimeout: 30 * time.Second,
|
TLSHandshakeTimeout: 30 * time.Second,
|
||||||
TLSClientConfig: &tls.Config{
|
TLSClientConfig: &tls.Config{
|
||||||
RootCAs: rootcerts.ServerCertPool(),
|
RootCAs: rootcerts.ServerCertPool(),
|
||||||
|
|
|
||||||
|
|
@ -1,65 +0,0 @@
|
||||||
package kvm
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
)
|
|
||||||
|
|
||||||
type RemoteImageReader interface {
|
|
||||||
Read(ctx context.Context, offset int64, size int64) ([]byte, error)
|
|
||||||
}
|
|
||||||
|
|
||||||
type WebRTCDiskReader struct {
|
|
||||||
}
|
|
||||||
|
|
||||||
var webRTCDiskReader WebRTCDiskReader
|
|
||||||
|
|
||||||
func (w *WebRTCDiskReader) Read(ctx context.Context, offset int64, size int64) ([]byte, error) {
|
|
||||||
virtualMediaStateMutex.RLock()
|
|
||||||
if currentVirtualMediaState == nil {
|
|
||||||
virtualMediaStateMutex.RUnlock()
|
|
||||||
return nil, errors.New("image not mounted")
|
|
||||||
}
|
|
||||||
if currentVirtualMediaState.Source != WebRTC {
|
|
||||||
virtualMediaStateMutex.RUnlock()
|
|
||||||
return nil, errors.New("image not mounted from webrtc")
|
|
||||||
}
|
|
||||||
mountedImageSize := currentVirtualMediaState.Size
|
|
||||||
virtualMediaStateMutex.RUnlock()
|
|
||||||
end := offset + size
|
|
||||||
if end > mountedImageSize {
|
|
||||||
end = mountedImageSize
|
|
||||||
}
|
|
||||||
req := DiskReadRequest{
|
|
||||||
Start: uint64(offset),
|
|
||||||
End: uint64(end),
|
|
||||||
}
|
|
||||||
jsonBytes, err := json.Marshal(req)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
if currentSession == nil || currentSession.DiskChannel == nil {
|
|
||||||
return nil, errors.New("not active session")
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.Debug().Str("request", string(jsonBytes)).Msg("reading from webrtc")
|
|
||||||
err = currentSession.DiskChannel.SendText(string(jsonBytes))
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
var buf []byte
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case data := <-diskReadChan:
|
|
||||||
buf = data[16:]
|
|
||||||
case <-ctx.Done():
|
|
||||||
return nil, context.Canceled
|
|
||||||
}
|
|
||||||
if len(buf) >= int(end-offset) {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return buf, nil
|
|
||||||
}
|
|
||||||
|
|
@ -1 +1 @@
|
||||||
6dabd0e657dd099280d9173069687786a4a8c9c25cf7f9e7ce2f940cab67c521
|
a4fca98710932aaa2765b57404e080105190cfa3af69171f4b4d95d4b78f9af0
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,77 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# Exit immediately if a command exits with a non-zero status
|
||||||
|
set -e
|
||||||
|
|
||||||
|
C_RST="$(tput sgr0)"
|
||||||
|
C_ERR="$(tput setaf 1)"
|
||||||
|
C_OK="$(tput setaf 2)"
|
||||||
|
C_WARN="$(tput setaf 3)"
|
||||||
|
C_INFO="$(tput setaf 5)"
|
||||||
|
|
||||||
|
msg() { printf '%s%s%s\n' $2 "$1" $C_RST; }
|
||||||
|
|
||||||
|
msg_info() { msg "$1" $C_INFO; }
|
||||||
|
msg_ok() { msg "$1" $C_OK; }
|
||||||
|
msg_err() { msg "$1" $C_ERR; }
|
||||||
|
msg_warn() { msg "$1" $C_WARN; }
|
||||||
|
|
||||||
|
# Get the latest release information
|
||||||
|
msg_info "Getting latest release information ..."
|
||||||
|
LATEST_RELEASE=$(curl -s \
|
||||||
|
-H "Accept: application/vnd.github+json" \
|
||||||
|
-H "X-GitHub-Api-Version: 2022-11-28" \
|
||||||
|
https://api.github.com/repos/netbootxyz/netboot.xyz/releases | jq '
|
||||||
|
[.[] | select(.prerelease == false and .draft == false and .assets != null and (.assets | length > 0))] |
|
||||||
|
sort_by(.created_at) |
|
||||||
|
.[-1]')
|
||||||
|
|
||||||
|
# Extract version, download URL, and digest
|
||||||
|
VERSION=$(echo "$LATEST_RELEASE" | jq -r '.tag_name')
|
||||||
|
ISO_URL=$(echo "$LATEST_RELEASE" | jq -r '.assets[] | select(.name == "netboot.xyz-multiarch.iso") | .browser_download_url')
|
||||||
|
EXPECTED_CHECKSUM=$(echo "$LATEST_RELEASE" | jq -r '.assets[] | select(.name == "netboot.xyz-multiarch.iso") | .digest' | sed 's/sha256://')
|
||||||
|
|
||||||
|
msg_ok "Latest version: $VERSION"
|
||||||
|
msg_ok "ISO URL: $ISO_URL"
|
||||||
|
msg_ok "Expected SHA256: $EXPECTED_CHECKSUM"
|
||||||
|
|
||||||
|
|
||||||
|
# Check if we already have the same version
|
||||||
|
if [ -f "resource/netboot.xyz-multiarch.iso" ]; then
|
||||||
|
msg_info "Checking current resource file ..."
|
||||||
|
|
||||||
|
# First check by checksum (fastest)
|
||||||
|
CURRENT_CHECKSUM=$(shasum -a 256 resource/netboot.xyz-multiarch.iso | awk '{print $1}')
|
||||||
|
|
||||||
|
if [ "$CURRENT_CHECKSUM" = "$EXPECTED_CHECKSUM" ]; then
|
||||||
|
msg_ok "Resource file is already up to date (version $VERSION). No update needed."
|
||||||
|
exit 0
|
||||||
|
else
|
||||||
|
msg_info "Checksums differ, proceeding with download ..."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Download ISO file
|
||||||
|
TMP_ISO=$(mktemp -t netbootxyziso)
|
||||||
|
msg_info "Downloading ISO file ..."
|
||||||
|
curl -L -o "$TMP_ISO" "$ISO_URL"
|
||||||
|
|
||||||
|
# Verify SHA256 checksum
|
||||||
|
msg_info "Verifying SHA256 checksum ..."
|
||||||
|
ACTUAL_CHECKSUM=$(shasum -a 256 "$TMP_ISO" | awk '{print $1}')
|
||||||
|
|
||||||
|
if [ "$EXPECTED_CHECKSUM" = "$ACTUAL_CHECKSUM" ]; then
|
||||||
|
msg_ok "Verified SHA256 checksum."
|
||||||
|
mv -f "$TMP_ISO" "resource/netboot.xyz-multiarch.iso"
|
||||||
|
msg_ok "Updated ISO file."
|
||||||
|
git add "resource/netboot.xyz-multiarch.iso"
|
||||||
|
git commit -m "chore: update netboot.xyz-multiarch.iso to $VERSION"
|
||||||
|
msg_ok "Committed changes."
|
||||||
|
msg_ok "You can now push the changes to the remote repository."
|
||||||
|
exit 0
|
||||||
|
else
|
||||||
|
msg_err "Inconsistent SHA256 checksum."
|
||||||
|
msg_err "Expected: $EXPECTED_CHECKSUM"
|
||||||
|
msg_err "Actual: $ACTUAL_CHECKSUM"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
43
serial.go
|
|
@ -128,6 +128,7 @@ func pressATXResetButton(duration time.Duration) error {
|
||||||
|
|
||||||
func mountDCControl() error {
|
func mountDCControl() error {
|
||||||
_ = port.SetMode(defaultMode)
|
_ = port.SetMode(defaultMode)
|
||||||
|
registerDCMetrics()
|
||||||
go runDCControl()
|
go runDCControl()
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
@ -142,6 +143,7 @@ var dcState DCPowerState
|
||||||
func runDCControl() {
|
func runDCControl() {
|
||||||
scopedLogger := serialLogger.With().Str("service", "dc_control").Logger()
|
scopedLogger := serialLogger.With().Str("service", "dc_control").Logger()
|
||||||
reader := bufio.NewReader(port)
|
reader := bufio.NewReader(port)
|
||||||
|
hasRestoreFeature := false
|
||||||
for {
|
for {
|
||||||
line, err := reader.ReadString('\n')
|
line, err := reader.ReadString('\n')
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -151,7 +153,13 @@ func runDCControl() {
|
||||||
|
|
||||||
// Split the line by semicolon
|
// Split the line by semicolon
|
||||||
parts := strings.Split(strings.TrimSpace(line), ";")
|
parts := strings.Split(strings.TrimSpace(line), ";")
|
||||||
if len(parts) != 4 {
|
if len(parts) == 5 {
|
||||||
|
scopedLogger.Debug().Str("line", line).Msg("Detected DC extension with restore feature")
|
||||||
|
hasRestoreFeature = true
|
||||||
|
} else if len(parts) == 4 {
|
||||||
|
scopedLogger.Debug().Str("line", line).Msg("Detected DC extension without restore feature")
|
||||||
|
hasRestoreFeature = false
|
||||||
|
} else {
|
||||||
scopedLogger.Warn().Str("line", line).Msg("Invalid line")
|
scopedLogger.Warn().Str("line", line).Msg("Invalid line")
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
@ -163,6 +171,17 @@ func runDCControl() {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
dcState.IsOn = powerState == 1
|
dcState.IsOn = powerState == 1
|
||||||
|
if hasRestoreFeature {
|
||||||
|
restoreState, err := strconv.Atoi(parts[4])
|
||||||
|
if err != nil {
|
||||||
|
scopedLogger.Warn().Err(err).Msg("Invalid restore state")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
dcState.RestoreState = restoreState
|
||||||
|
} else {
|
||||||
|
// -1 means not supported
|
||||||
|
dcState.RestoreState = -1
|
||||||
|
}
|
||||||
milliVolts, err := strconv.ParseFloat(parts[1], 64)
|
milliVolts, err := strconv.ParseFloat(parts[1], 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
scopedLogger.Warn().Err(err).Msg("Invalid voltage")
|
scopedLogger.Warn().Err(err).Msg("Invalid voltage")
|
||||||
|
|
@ -188,6 +207,9 @@ func runDCControl() {
|
||||||
dcState.Current = amps
|
dcState.Current = amps
|
||||||
dcState.Power = watts
|
dcState.Power = watts
|
||||||
|
|
||||||
|
// Update Prometheus metrics
|
||||||
|
updateDCMetrics(dcState)
|
||||||
|
|
||||||
if currentSession != nil {
|
if currentSession != nil {
|
||||||
writeJSONRPCEvent("dcState", dcState, currentSession)
|
writeJSONRPCEvent("dcState", dcState, currentSession)
|
||||||
}
|
}
|
||||||
|
|
@ -210,6 +232,25 @@ func setDCPowerState(on bool) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func setDCRestoreState(state int) error {
|
||||||
|
_, err := port.Write([]byte("\n"))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
command := "RESTORE_MODE_OFF\n"
|
||||||
|
switch state {
|
||||||
|
case 1:
|
||||||
|
command = "RESTORE_MODE_ON\n"
|
||||||
|
case 2:
|
||||||
|
command = "RESTORE_MODE_LAST_STATE\n"
|
||||||
|
}
|
||||||
|
_, err = port.Write([]byte(command))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
var defaultMode = &serial.Mode{
|
var defaultMode = &serial.Mode{
|
||||||
BaudRate: 115200,
|
BaudRate: 115200,
|
||||||
DataBits: 8,
|
DataBits: 8,
|
||||||
|
|
|
||||||
|
|
@ -66,6 +66,10 @@ module.exports = defineConfig([{
|
||||||
groups: ["builtin", "external", "internal", "parent", "sibling"],
|
groups: ["builtin", "external", "internal", "parent", "sibling"],
|
||||||
"newlines-between": "always",
|
"newlines-between": "always",
|
||||||
}],
|
}],
|
||||||
|
|
||||||
|
"@typescript-eslint/no-unused-vars": ["warn", {
|
||||||
|
"argsIgnorePattern": "^_", "varsIgnorePattern": "^_"
|
||||||
|
}],
|
||||||
},
|
},
|
||||||
|
|
||||||
settings: {
|
settings: {
|
||||||
|
|
|
||||||
|
|
@ -1,51 +1,63 @@
|
||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<!-- These are the fonts used in the app -->
|
<!-- These are the fonts used in the app -->
|
||||||
<link
|
<link
|
||||||
rel="preload"
|
rel="preload"
|
||||||
href="/fonts/CircularXXWeb-Medium.woff2"
|
href="./public/fonts/CircularXXWeb-Medium.woff2"
|
||||||
as="font"
|
as="font"
|
||||||
type="font/woff2"
|
type="font/woff2"
|
||||||
crossorigin
|
crossorigin
|
||||||
/>
|
/>
|
||||||
<link
|
<link
|
||||||
rel="preload"
|
rel="preload"
|
||||||
href="/fonts/CircularXXWeb-Book.woff2"
|
href="./public/fonts/CircularXXWeb-Book.woff2"
|
||||||
as="font"
|
as="font"
|
||||||
type="font/woff2"
|
type="font/woff2"
|
||||||
crossorigin
|
crossorigin
|
||||||
/>
|
/>
|
||||||
<link
|
<link
|
||||||
rel="preload"
|
rel="preload"
|
||||||
href="/fonts/CircularXXWeb-Regular.woff2"
|
href="./public/fonts/CircularXXWeb-Regular.woff2"
|
||||||
|
as="font"
|
||||||
|
type="font/woff2"
|
||||||
|
crossorigin
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="preload"
|
||||||
|
href="./public/fonts/CircularXXWeb-Black.woff2"
|
||||||
as="font"
|
as="font"
|
||||||
type="font/woff2"
|
type="font/woff2"
|
||||||
crossorigin
|
crossorigin
|
||||||
/>
|
/>
|
||||||
<title>JetKVM</title>
|
<title>JetKVM</title>
|
||||||
<link rel="stylesheet" href="/fonts/fonts.css" />
|
<link rel="stylesheet" href="./public/fonts/fonts.css" />
|
||||||
<link rel="icon" href="/favicon.png" />
|
<link rel="icon" type="image/png" href="/favicon-96x96.png" sizes="96x96" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
|
<link rel="shortcut icon" href="/favicon.ico" />
|
||||||
|
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
||||||
|
<meta name="apple-mobile-web-app-title" content="JetKVM" />
|
||||||
|
<link rel="manifest" href="/site.webmanifest" />
|
||||||
|
<meta name="theme-color" content="#051946" />
|
||||||
|
<meta name="description" content="A web-based KVM console for managing remote servers." />
|
||||||
<script>
|
<script>
|
||||||
// Initial theme setup
|
function applyThemeFromPreference() {
|
||||||
document.documentElement.classList.toggle(
|
// dark theme setup
|
||||||
"dark",
|
var darkDesired = localStorage.theme === "dark" ||
|
||||||
localStorage.theme === "dark" ||
|
|
||||||
(!("theme" in localStorage) &&
|
(!("theme" in localStorage) &&
|
||||||
window.matchMedia("(prefers-color-scheme: dark)").matches),
|
window.matchMedia("(prefers-color-scheme: dark)").matches)
|
||||||
);
|
|
||||||
|
document.documentElement.classList.toggle("dark", darkDesired)
|
||||||
|
}
|
||||||
|
|
||||||
|
// initial theme application
|
||||||
|
applyThemeFromPreference();
|
||||||
|
|
||||||
// Listen for system theme changes
|
// Listen for system theme changes
|
||||||
window
|
window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change", applyThemeFromPreference);
|
||||||
.matchMedia("(prefers-color-scheme: dark)")
|
window.matchMedia("(prefers-color-scheme: light)").addEventListener("change", applyThemeFromPreference);
|
||||||
.addEventListener("change", ({ matches }) => {
|
|
||||||
if (!("theme" in localStorage)) {
|
|
||||||
// Only auto-switch if user hasn't manually set a theme
|
|
||||||
document.documentElement.classList.toggle("dark", matches);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
</head>
|
</head>
|
||||||
<body
|
<body
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"name": "kvm-ui",
|
"name": "kvm-ui",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.0.0",
|
"version": "2025.09.03.2100",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "22.15.0"
|
"node": "22.15.0"
|
||||||
|
|
@ -19,67 +19,67 @@
|
||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@headlessui/react": "^2.2.3",
|
"@headlessui/react": "^2.2.7",
|
||||||
"@headlessui/tailwindcss": "^0.2.2",
|
"@headlessui/tailwindcss": "^0.2.2",
|
||||||
"@heroicons/react": "^2.2.0",
|
"@heroicons/react": "^2.2.0",
|
||||||
"@vitejs/plugin-basic-ssl": "^2.0.0",
|
"@vitejs/plugin-basic-ssl": "^2.1.0",
|
||||||
"@xterm/addon-clipboard": "^0.1.0",
|
"@xterm/addon-clipboard": "^0.1.0",
|
||||||
"@xterm/addon-fit": "^0.10.0",
|
"@xterm/addon-fit": "^0.10.0",
|
||||||
"@xterm/addon-unicode11": "^0.8.0",
|
"@xterm/addon-unicode11": "^0.8.0",
|
||||||
"@xterm/addon-web-links": "^0.11.0",
|
"@xterm/addon-web-links": "^0.11.0",
|
||||||
"@xterm/addon-webgl": "^0.18.0",
|
"@xterm/addon-webgl": "^0.18.0",
|
||||||
"@xterm/xterm": "^5.5.0",
|
"@xterm/xterm": "^5.5.0",
|
||||||
"cva": "^1.0.0-beta.3",
|
"cva": "^1.0.0-beta.4",
|
||||||
"dayjs": "^1.11.13",
|
"dayjs": "^1.11.18",
|
||||||
"eslint-import-resolver-alias": "^1.1.2",
|
"eslint-import-resolver-alias": "^1.1.2",
|
||||||
"focus-trap-react": "^11.0.3",
|
"focus-trap-react": "^11.0.4",
|
||||||
"framer-motion": "^12.11.4",
|
"framer-motion": "^12.23.12",
|
||||||
"lodash.throttle": "^4.1.1",
|
"lodash.throttle": "^4.1.1",
|
||||||
"mini-svg-data-uri": "^1.4.4",
|
"mini-svg-data-uri": "^1.4.4",
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.1",
|
||||||
"react-animate-height": "^3.2.3",
|
"react-animate-height": "^3.2.3",
|
||||||
"react-dom": "^19.1.0",
|
"react-dom": "^19.1.1",
|
||||||
"react-hot-toast": "^2.5.2",
|
"react-hot-toast": "^2.6.0",
|
||||||
"react-icons": "^5.5.0",
|
"react-icons": "^5.5.0",
|
||||||
"react-router-dom": "^6.22.3",
|
"react-router": "^7.8.2",
|
||||||
"react-simple-keyboard": "^3.8.72",
|
"react-simple-keyboard": "^3.8.119",
|
||||||
"react-use-websocket": "^4.13.0",
|
"react-use-websocket": "^4.13.0",
|
||||||
"react-xtermjs": "^1.0.10",
|
"react-xtermjs": "^1.0.10",
|
||||||
"recharts": "^2.15.3",
|
"recharts": "^3.1.2",
|
||||||
"tailwind-merge": "^3.3.0",
|
"tailwind-merge": "^3.3.1",
|
||||||
"usehooks-ts": "^3.1.1",
|
"usehooks-ts": "^3.1.1",
|
||||||
"validator": "^13.15.0",
|
"validator": "^13.15.15",
|
||||||
"zustand": "^4.5.2"
|
"zustand": "^4.5.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/compat": "^1.2.9",
|
"@eslint/compat": "^1.3.2",
|
||||||
"@eslint/eslintrc": "^3.3.1",
|
"@eslint/eslintrc": "^3.3.1",
|
||||||
"@eslint/js": "^9.26.0",
|
"@eslint/js": "^9.34.0",
|
||||||
"@tailwindcss/forms": "^0.5.10",
|
"@tailwindcss/forms": "^0.5.10",
|
||||||
"@tailwindcss/postcss": "^4.1.7",
|
"@tailwindcss/postcss": "^4.1.12",
|
||||||
"@tailwindcss/typography": "^0.5.16",
|
"@tailwindcss/typography": "^0.5.16",
|
||||||
"@tailwindcss/vite": "^4.1.7",
|
"@tailwindcss/vite": "^4.1.12",
|
||||||
"@types/react": "^19.1.4",
|
"@types/react": "^19.1.12",
|
||||||
"@types/react-dom": "^19.1.5",
|
"@types/react-dom": "^19.1.9",
|
||||||
"@types/semver": "^7.7.0",
|
"@types/semver": "^7.7.1",
|
||||||
"@types/validator": "^13.15.0",
|
"@types/validator": "^13.15.3",
|
||||||
"@typescript-eslint/eslint-plugin": "^8.32.1",
|
"@typescript-eslint/eslint-plugin": "^8.42.0",
|
||||||
"@typescript-eslint/parser": "^8.32.1",
|
"@typescript-eslint/parser": "^8.42.0",
|
||||||
"@vitejs/plugin-react-swc": "^3.9.0",
|
"@vitejs/plugin-react-swc": "^4.0.1",
|
||||||
"autoprefixer": "^10.4.21",
|
"autoprefixer": "^10.4.21",
|
||||||
"eslint": "^9.26.0",
|
"eslint": "^9.34.0",
|
||||||
"eslint-config-prettier": "^10.1.5",
|
"eslint-config-prettier": "^10.1.8",
|
||||||
"eslint-plugin-import": "^2.31.0",
|
"eslint-plugin-import": "^2.32.0",
|
||||||
"eslint-plugin-react": "^7.37.5",
|
"eslint-plugin-react": "^7.37.5",
|
||||||
"eslint-plugin-react-hooks": "^5.2.0",
|
"eslint-plugin-react-hooks": "^5.2.0",
|
||||||
"eslint-plugin-react-refresh": "^0.4.20",
|
"eslint-plugin-react-refresh": "^0.4.20",
|
||||||
"globals": "^16.1.0",
|
"globals": "^16.3.0",
|
||||||
"postcss": "^8.5.3",
|
"postcss": "^8.5.6",
|
||||||
"prettier": "^3.5.3",
|
"prettier": "^3.6.2",
|
||||||
"prettier-plugin-tailwindcss": "^0.6.11",
|
"prettier-plugin-tailwindcss": "^0.6.14",
|
||||||
"tailwindcss": "^4.1.7",
|
"tailwindcss": "^4.1.12",
|
||||||
"typescript": "^5.8.3",
|
"typescript": "^5.9.2",
|
||||||
"vite": "^6.3.5",
|
"vite": "^7.1.5",
|
||||||
"vite-tsconfig-paths": "^5.1.4"
|
"vite-tsconfig-paths": "^5.1.4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 972 B |
|
After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 2.7 KiB After Width: | Height: | Size: 1.2 KiB |
|
|
@ -0,0 +1 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24"><svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#1D4ED8" d="M0 6a6 6 0 0 1 6-6h12a6 6 0 0 1 6 6v12a6 6 0 0 1-6 6H6a6 6 0 0 1-6-6V6Z"/><path fill="#fff" d="M13.885 12a1.895 1.895 0 1 1-3.79 0 1.895 1.895 0 0 1 3.79 0Z"/><path fill="#fff" fill-rule="evenodd" d="M7.59 9.363c.49.182.74.727.558 1.218A4.103 4.103 0 0 0 12 16.105a4.103 4.103 0 0 0 3.852-5.526.947.947 0 0 1 1.777-.658A5.998 5.998 0 0 1 12 18a5.998 5.998 0 0 1-5.628-8.078.947.947 0 0 1 1.218-.56ZM11.993 7.895c-.628 0-1.22.14-1.75.39a.947.947 0 1 1-.808-1.714A5.985 5.985 0 0 1 11.993 6c.913 0 1.78.204 2.557.57a.947.947 0 1 1-.808 1.715 4.09 4.09 0 0 0-1.75-.39Z" clip-rule="evenodd"/><path fill="#1D4ED8" d="M0 6a6 6 0 0 1 6-6h12a6 6 0 0 1 6 6v12a6 6 0 0 1-6 6H6a6 6 0 0 1-6-6V6Z"/><path fill="#fff" d="M13.885 12a1.895 1.895 0 1 1-3.79 0 1.895 1.895 0 0 1 3.79 0Z"/><path fill="#fff" fill-rule="evenodd" d="M7.59 9.363c.49.182.74.727.558 1.218A4.103 4.103 0 0 0 12 16.105a4.103 4.103 0 0 0 3.852-5.526.947.947 0 0 1 1.777-.658A5.998 5.998 0 0 1 12 18a5.998 5.998 0 0 1-5.628-8.078.947.947 0 0 1 1.218-.56ZM11.993 7.895c-.628 0-1.22.14-1.75.39a.947.947 0 1 1-.808-1.714A5.985 5.985 0 0 1 11.993 6c.913 0 1.78.204 2.557.57a.947.947 0 1 1-.808 1.715 4.09 4.09 0 0 0-1.75-.39Z" clip-rule="evenodd"/></svg><style>@media (prefers-color-scheme:light){:root{filter:none}}@media (prefers-color-scheme:dark){:root{filter:none}}</style></svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
|
|
@ -0,0 +1 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#1D4ED8" d="M0 6a6 6 0 0 1 6-6h12a6 6 0 0 1 6 6v12a6 6 0 0 1-6 6H6a6 6 0 0 1-6-6V6Z"/><path fill="#fff" d="M13.885 12a1.895 1.895 0 1 1-3.79 0 1.895 1.895 0 0 1 3.79 0Z"/><path fill="#fff" fill-rule="evenodd" d="M7.59 9.363c.49.182.74.727.558 1.218A4.103 4.103 0 0 0 12 16.105a4.103 4.103 0 0 0 3.852-5.526.947.947 0 0 1 1.777-.658A5.998 5.998 0 0 1 12 18a5.998 5.998 0 0 1-5.628-8.078.947.947 0 0 1 1.218-.56ZM11.993 7.895c-.628 0-1.22.14-1.75.39a.947.947 0 1 1-.808-1.714A5.985 5.985 0 0 1 11.993 6c.913 0 1.78.204 2.557.57a.947.947 0 1 1-.808 1.715 4.09 4.09 0 0 0-1.75-.39Z" clip-rule="evenodd"/><path fill="#1D4ED8" d="M0 6a6 6 0 0 1 6-6h12a6 6 0 0 1 6 6v12a6 6 0 0 1-6 6H6a6 6 0 0 1-6-6V6Z"/><path fill="#fff" d="M13.885 12a1.895 1.895 0 1 1-3.79 0 1.895 1.895 0 0 1 3.79 0Z"/><path fill="#fff" fill-rule="evenodd" d="M7.59 9.363c.49.182.74.727.558 1.218A4.103 4.103 0 0 0 12 16.105a4.103 4.103 0 0 0 3.852-5.526.947.947 0 0 1 1.777-.658A5.998 5.998 0 0 1 12 18a5.998 5.998 0 0 1-5.628-8.078.947.947 0 0 1 1.218-.56ZM11.993 7.895c-.628 0-1.22.14-1.75.39a.947.947 0 1 1-.808-1.714A5.985 5.985 0 0 1 11.993 6c.913 0 1.78.204 2.557.57a.947.947 0 1 1-.808 1.715 4.09 4.09 0 0 0-1.75-.39Z" clip-rule="evenodd"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.3 KiB |
|
|
@ -1,2 +0,0 @@
|
||||||
User-agent: *
|
|
||||||
Disallow: /
|
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
{
|
||||||
|
"name": "JetKVM",
|
||||||
|
"short_name": "JetKVM",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "/web-app-manifest-192x192.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "maskable"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/web-app-manifest-512x512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "maskable"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"theme_color": "#002b36",
|
||||||
|
"background_color": "#051946",
|
||||||
|
"display": "standalone"
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 7.9 KiB |
|
|
@ -1,8 +0,0 @@
|
||||||
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<path d="M2 6C2 4.89543 2.89543 4 4 4H20C21.1046 4 22 4.89543 22 6V18C22 19.1046 21.1046 20 20 20H4C2.89543 20 2 19.1046 2 18V6Z"
|
|
||||||
fill="white"/>
|
|
||||||
<path fill-rule="evenodd" clip-rule="evenodd"
|
|
||||||
d="M20 6H4V18H20V6ZM4 4C2.89543 4 2 4.89543 2 6V18C2 19.1046 2.89543 20 4 20H20C21.1046 20 22 19.1046 22 18V6C22 4.89543 21.1046 4 20 4H4Z"
|
|
||||||
fill="black"/>
|
|
||||||
<path d="M4 13H20V18H4V13Z" fill="black"/>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 511 B |
|
|
@ -26,17 +26,13 @@ export default function Actionbar({
|
||||||
requestFullscreen: () => Promise<void>;
|
requestFullscreen: () => Promise<void>;
|
||||||
}) {
|
}) {
|
||||||
const { navigateTo } = useDeviceUiNavigation();
|
const { navigateTo } = useDeviceUiNavigation();
|
||||||
const virtualKeyboard = useHidStore(state => state.isVirtualKeyboardEnabled);
|
const { isVirtualKeyboardEnabled, setVirtualKeyboardEnabled } = useHidStore();
|
||||||
|
const { setDisableVideoFocusTrap, terminalType, setTerminalType, toggleSidebarView } = useUiStore();
|
||||||
|
|
||||||
const setVirtualKeyboard = useHidStore(state => state.setVirtualKeyboardEnabled);
|
|
||||||
const toggleSidebarView = useUiStore(state => state.toggleSidebarView);
|
|
||||||
const setDisableFocusTrap = useUiStore(state => state.setDisableVideoFocusTrap);
|
|
||||||
const terminalType = useUiStore(state => state.terminalType);
|
|
||||||
const setTerminalType = useUiStore(state => state.setTerminalType);
|
|
||||||
const remoteVirtualMediaState = useMountMediaStore(
|
const remoteVirtualMediaState = useMountMediaStore(
|
||||||
state => state.remoteVirtualMediaState,
|
state => state.remoteVirtualMediaState,
|
||||||
);
|
);
|
||||||
const developerMode = useSettingsStore(state => state.developerMode);
|
const { developerMode } = useSettingsStore();
|
||||||
|
|
||||||
// This is the only way to get a reliable state change for the popover
|
// This is the only way to get a reliable state change for the popover
|
||||||
// at time of writing this there is no mount, or unmount event for the popover
|
// at time of writing this there is no mount, or unmount event for the popover
|
||||||
|
|
@ -47,13 +43,13 @@ export default function Actionbar({
|
||||||
isOpen.current = open;
|
isOpen.current = open;
|
||||||
if (!open) {
|
if (!open) {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
setDisableFocusTrap(false);
|
setDisableVideoFocusTrap(false);
|
||||||
console.log("Popover is closing. Returning focus trap to video");
|
console.debug("Popover is closing. Returning focus trap to video");
|
||||||
}, 0);
|
}, 0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[setDisableFocusTrap],
|
[setDisableVideoFocusTrap],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -81,7 +77,7 @@ export default function Actionbar({
|
||||||
text="Paste text"
|
text="Paste text"
|
||||||
LeadingIcon={MdOutlineContentPasteGo}
|
LeadingIcon={MdOutlineContentPasteGo}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setDisableFocusTrap(true);
|
setDisableVideoFocusTrap(true);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</PopoverButton>
|
</PopoverButton>
|
||||||
|
|
@ -123,7 +119,7 @@ export default function Actionbar({
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setDisableFocusTrap(true);
|
setDisableVideoFocusTrap(true);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</PopoverButton>
|
</PopoverButton>
|
||||||
|
|
@ -154,7 +150,7 @@ export default function Actionbar({
|
||||||
theme="light"
|
theme="light"
|
||||||
text="Wake on LAN"
|
text="Wake on LAN"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setDisableFocusTrap(true);
|
setDisableVideoFocusTrap(true);
|
||||||
}}
|
}}
|
||||||
LeadingIcon={({ className }) => (
|
LeadingIcon={({ className }) => (
|
||||||
<svg
|
<svg
|
||||||
|
|
@ -204,7 +200,7 @@ export default function Actionbar({
|
||||||
theme="light"
|
theme="light"
|
||||||
text="Virtual Keyboard"
|
text="Virtual Keyboard"
|
||||||
LeadingIcon={FaKeyboard}
|
LeadingIcon={FaKeyboard}
|
||||||
onClick={() => setVirtualKeyboard(!virtualKeyboard)}
|
onClick={() => setVirtualKeyboardEnabled(!isVirtualKeyboardEnabled)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -218,7 +214,7 @@ export default function Actionbar({
|
||||||
text="Extension"
|
text="Extension"
|
||||||
LeadingIcon={LuCable}
|
LeadingIcon={LuCable}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setDisableFocusTrap(true);
|
setDisableVideoFocusTrap(true);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</PopoverButton>
|
</PopoverButton>
|
||||||
|
|
@ -243,7 +239,7 @@ export default function Actionbar({
|
||||||
theme="light"
|
theme="light"
|
||||||
text="Virtual Keyboard"
|
text="Virtual Keyboard"
|
||||||
LeadingIcon={FaKeyboard}
|
LeadingIcon={FaKeyboard}
|
||||||
onClick={() => setVirtualKeyboard(!virtualKeyboard)}
|
onClick={() => setVirtualKeyboardEnabled(!isVirtualKeyboardEnabled)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="hidden md:block">
|
<div className="hidden md:block">
|
||||||
|
|
@ -262,30 +258,16 @@ export default function Actionbar({
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{/* {useSettingsStore().actionBarCtrlAltDel && (
|
|
||||||
<div className="hidden lg:block">
|
|
||||||
<Button
|
|
||||||
size="XS"
|
|
||||||
theme="light"
|
|
||||||
text="Ctrl + Alt + Del"
|
|
||||||
LeadingIcon={FaLock}
|
|
||||||
onClick={() => {
|
|
||||||
sendKeyboardEvent(
|
|
||||||
[keys["Delete"]],
|
|
||||||
[modifiers["ControlLeft"], modifiers["AltLeft"]],
|
|
||||||
);
|
|
||||||
setTimeout(resetKeyboardState, 100);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)} */}
|
|
||||||
<div>
|
<div>
|
||||||
<Button
|
<Button
|
||||||
size="XS"
|
size="XS"
|
||||||
theme="light"
|
theme="light"
|
||||||
text="Settings"
|
text="Settings"
|
||||||
LeadingIcon={LuSettings}
|
LeadingIcon={LuSettings}
|
||||||
onClick={() => navigateTo("/settings")}
|
onClick={() => {
|
||||||
|
setDisableVideoFocusTrap(true);
|
||||||
|
navigateTo("/settings")
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useLocation, useNavigation, useSearchParams } from "react-router-dom";
|
import { useLocation, useNavigation, useSearchParams } from "react-router";
|
||||||
|
|
||||||
import { Button, LinkButton } from "@components/Button";
|
import { Button, LinkButton } from "@components/Button";
|
||||||
import { GoogleIcon } from "@components/Icons";
|
import { GoogleIcon } from "@components/Icons";
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import React, { JSX } from "react";
|
import React, { JSX } from "react";
|
||||||
import { FetcherWithComponents, Link, LinkProps, useNavigation } from "react-router-dom";
|
import { Link, useNavigation } from "react-router";
|
||||||
|
import type { FetcherWithComponents, LinkProps } from "react-router";
|
||||||
|
|
||||||
import ExtLink from "@/components/ExtLink";
|
import ExtLink from "@/components/ExtLink";
|
||||||
import LoadingSpinner from "@/components/LoadingSpinner";
|
import LoadingSpinner from "@/components/LoadingSpinner";
|
||||||
|
|
@ -175,7 +176,7 @@ type ButtonPropsType = Pick<
|
||||||
export const Button = React.forwardRef<HTMLButtonElement, ButtonPropsType>(
|
export const Button = React.forwardRef<HTMLButtonElement, ButtonPropsType>(
|
||||||
({ type, disabled, onClick, formNoValidate, loading, fetcher, ...props }, ref) => {
|
({ type, disabled, onClick, formNoValidate, loading, fetcher, ...props }, ref) => {
|
||||||
const classes = cx(
|
const classes = cx(
|
||||||
"group outline-hidden",
|
"group outline-hidden cursor-pointer",
|
||||||
props.fullWidth ? "w-full" : "",
|
props.fullWidth ? "w-full" : "",
|
||||||
loading ? "pointer-events-none" : "",
|
loading ? "pointer-events-none" : "",
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,25 +1,25 @@
|
||||||
import Card from "@components/Card";
|
import Card from "@components/Card";
|
||||||
|
|
||||||
export interface CustomTooltipProps {
|
export interface CustomTooltipProps {
|
||||||
payload: { payload: { date: number; stat: number }; unit: string }[];
|
payload: { payload: { date: number; metric: number }; unit: string }[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function CustomTooltip({ payload }: CustomTooltipProps) {
|
export default function CustomTooltip({ payload }: CustomTooltipProps) {
|
||||||
if (payload?.length) {
|
if (payload?.length) {
|
||||||
const toolTipData = payload[0];
|
const toolTipData = payload[0];
|
||||||
const { date, stat } = toolTipData.payload;
|
const { date, metric } = toolTipData.payload;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<div className="p-2 text-black dark:text-white">
|
<div className="px-2 py-1.5 text-black dark:text-white">
|
||||||
<div className="font-semibold">
|
<div className="text-[13px] font-semibold">
|
||||||
{new Date(date * 1000).toLocaleTimeString()}
|
{new Date(date * 1000).toLocaleTimeString()}
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<div className="flex items-center gap-x-1">
|
<div className="flex items-center gap-x-1">
|
||||||
<div className="h-[2px] w-2 bg-blue-700" />
|
<div className="h-[2px] w-2 bg-blue-700" />
|
||||||
<span >
|
<span className="text-[13px]">
|
||||||
{stat} {toolTipData?.unit}
|
{metric} {toolTipData?.unit}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,7 @@ export default function FieldLabel({
|
||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
{description && (
|
{description && (
|
||||||
<span className="my-0.5 text-[13px] font-normal text-slate-600 dark:text-slate-400">
|
<span className="mb-0.5 text-[13px] font-normal text-slate-600 dark:text-slate-400">
|
||||||
{description}
|
{description}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
@ -36,11 +36,11 @@ export default function FieldLabel({
|
||||||
} else if (as === "span") {
|
} else if (as === "span") {
|
||||||
return (
|
return (
|
||||||
<div className="flex select-none flex-col">
|
<div className="flex select-none flex-col">
|
||||||
<span className="font-display text-[13px] font-medium leading-snug text-black dark:text-white">
|
<span className="font-display text-[13px] font-semibold leading-snug text-black dark:text-white">
|
||||||
{label}
|
{label}
|
||||||
</span>
|
</span>
|
||||||
{description && (
|
{description && (
|
||||||
<span className="my-0.5 text-[13px] font-normal text-slate-600 dark:text-slate-400">
|
<span className="mb-0.5 text-[13px] font-normal text-slate-600 dark:text-slate-400">
|
||||||
{description}
|
{description}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
@ -49,4 +49,4 @@ export default function FieldLabel({
|
||||||
} else {
|
} else {
|
||||||
return <></>;
|
return <></>;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { FetcherWithComponents, useNavigation } from "react-router-dom";
|
import { useNavigation } from "react-router";
|
||||||
|
import type { FetcherWithComponents } from "react-router";
|
||||||
|
|
||||||
export default function Fieldset({
|
export default function Fieldset({
|
||||||
children,
|
children,
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router";
|
||||||
import { ArrowLeftEndOnRectangleIcon, ChevronDownIcon } from "@heroicons/react/16/solid";
|
import { ArrowLeftEndOnRectangleIcon, ChevronDownIcon } from "@heroicons/react/16/solid";
|
||||||
import { Button, Menu, MenuButton, MenuItem, MenuItems } from "@headlessui/react";
|
import { Button, Menu, MenuButton, MenuItem, MenuItems } from "@headlessui/react";
|
||||||
import { LuMonitorSmartphone } from "react-icons/lu";
|
import { LuMonitorSmartphone } from "react-icons/lu";
|
||||||
|
|
@ -48,7 +48,7 @@ export default function DashboardNavbar({
|
||||||
navigate("/");
|
navigate("/");
|
||||||
}, [navigate, setUser]);
|
}, [navigate, setUser]);
|
||||||
|
|
||||||
const usbState = useHidStore(state => state.usbState);
|
const { usbState } = useHidStore();
|
||||||
|
|
||||||
// for testing
|
// for testing
|
||||||
//userEmail = "user@example.org";
|
//userEmail = "user@example.org";
|
||||||
|
|
@ -103,7 +103,7 @@ export default function DashboardNavbar({
|
||||||
<hr className="h-[20px] w-px self-center border-none bg-slate-800/20 dark:bg-slate-300/20" />
|
<hr className="h-[20px] w-px self-center border-none bg-slate-800/20 dark:bg-slate-300/20" />
|
||||||
<div className="relative inline-block text-left">
|
<div className="relative inline-block text-left">
|
||||||
<Menu>
|
<Menu>
|
||||||
<MenuButton className="h-full">
|
<MenuButton as="div" className="h-full">
|
||||||
<Button className="flex h-full items-center gap-x-3 rounded-md border border-slate-800/20 bg-white px-2 py-1.5 dark:border-slate-600 dark:bg-slate-800 dark:text-white">
|
<Button className="flex h-full items-center gap-x-3 rounded-md border border-slate-800/20 bg-white px-2 py-1.5 dark:border-slate-600 dark:bg-slate-800 dark:text-white">
|
||||||
{picture ? (
|
{picture ? (
|
||||||
<img
|
<img
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useEffect } from "react";
|
import { useEffect, useMemo } from "react";
|
||||||
|
|
||||||
import { cx } from "@/cva.config";
|
import { cx } from "@/cva.config";
|
||||||
import {
|
import {
|
||||||
|
|
@ -7,65 +7,71 @@ import {
|
||||||
useRTCStore,
|
useRTCStore,
|
||||||
useSettingsStore,
|
useSettingsStore,
|
||||||
useVideoStore,
|
useVideoStore,
|
||||||
|
VideoState
|
||||||
} from "@/hooks/stores";
|
} from "@/hooks/stores";
|
||||||
import { keys, modifiers } from "@/keyboardMappings";
|
import { keys, modifiers } from "@/keyboardMappings";
|
||||||
|
import { useHidRpc } from "@/hooks/useHidRpc";
|
||||||
|
|
||||||
export default function InfoBar() {
|
export default function InfoBar() {
|
||||||
const activeKeys = useHidStore(state => state.activeKeys);
|
const { keysDownState } = useHidStore();
|
||||||
const activeModifiers = useHidStore(state => state.activeModifiers);
|
const { mouseX, mouseY, mouseMove } = useMouseStore();
|
||||||
const mouseX = useMouseStore(state => state.mouseX);
|
const { rpcHidStatus } = useHidRpc();
|
||||||
const mouseY = useMouseStore(state => state.mouseY);
|
|
||||||
const mouseMove = useMouseStore(state => state.mouseMove);
|
|
||||||
|
|
||||||
const videoClientSize = useVideoStore(
|
const videoClientSize = useVideoStore(
|
||||||
state => `${Math.round(state.clientWidth)}x${Math.round(state.clientHeight)}`,
|
(state: VideoState) => `${Math.round(state.clientWidth)}x${Math.round(state.clientHeight)}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
const videoSize = useVideoStore(
|
const videoSize = useVideoStore(
|
||||||
state => `${Math.round(state.width)}x${Math.round(state.height)}`,
|
(state: VideoState) => `${Math.round(state.width)}x${Math.round(state.height)}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
const rpcDataChannel = useRTCStore(state => state.rpcDataChannel);
|
const { rpcDataChannel } = useRTCStore();
|
||||||
|
const { debugMode, mouseMode, showPressedKeys } = useSettingsStore();
|
||||||
const settings = useSettingsStore();
|
const { isPasteInProgress } = useHidStore();
|
||||||
const showPressedKeys = useSettingsStore(state => state.showPressedKeys);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!rpcDataChannel) return;
|
if (!rpcDataChannel) return;
|
||||||
rpcDataChannel.onclose = () => console.log("rpcDataChannel has closed");
|
rpcDataChannel.onclose = () => console.log("rpcDataChannel has closed");
|
||||||
rpcDataChannel.onerror = e =>
|
rpcDataChannel.onerror = (e: Event) =>
|
||||||
console.log(`Error on DataChannel '${rpcDataChannel.label}': ${e}`);
|
console.error(`Error on DataChannel '${rpcDataChannel.label}': ${e}`);
|
||||||
}, [rpcDataChannel]);
|
}, [rpcDataChannel]);
|
||||||
|
|
||||||
const keyboardLedState = useHidStore(state => state.keyboardLedState);
|
const { keyboardLedState, usbState } = useHidStore();
|
||||||
const keyboardLedStateSyncAvailable = useHidStore(state => state.keyboardLedStateSyncAvailable);
|
const { isTurnServerInUse } = useRTCStore();
|
||||||
const keyboardLedSync = useSettingsStore(state => state.keyboardLedSync);
|
const { hdmiState } = useVideoStore();
|
||||||
|
|
||||||
const isTurnServerInUse = useRTCStore(state => state.isTurnServerInUse);
|
const displayKeys = useMemo(() => {
|
||||||
|
if (!showPressedKeys)
|
||||||
|
return "";
|
||||||
|
|
||||||
const usbState = useHidStore(state => state.usbState);
|
const activeModifierMask = keysDownState.modifier || 0;
|
||||||
const hdmiState = useVideoStore(state => state.hdmiState);
|
const keysDown = keysDownState.keys || [];
|
||||||
|
const modifierNames = Object.entries(modifiers).filter(([_, mask]) => (activeModifierMask & mask) !== 0).map(([name, _]) => name);
|
||||||
|
const keyNames = Object.entries(keys).filter(([_, value]) => keysDown.includes(value)).map(([name, _]) => name);
|
||||||
|
|
||||||
|
return [...modifierNames, ...keyNames].join(", ");
|
||||||
|
}, [keysDownState, showPressedKeys]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-white border-t border-t-slate-800/30 text-slate-800 dark:border-t-slate-300/20 dark:bg-slate-900 dark:text-slate-300">
|
<div className="bg-white border-t border-t-slate-800/30 text-slate-800 dark:border-t-slate-300/20 dark:bg-slate-900 dark:text-slate-300">
|
||||||
<div className="flex flex-wrap items-stretch justify-between gap-1">
|
<div className="flex flex-wrap items-stretch justify-between gap-1">
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<div className="flex flex-wrap items-center pl-2 gap-x-4">
|
<div className="flex flex-wrap items-center pl-2 gap-x-4">
|
||||||
{settings.debugMode ? (
|
{debugMode ? (
|
||||||
<div className="flex">
|
<div className="flex">
|
||||||
<span className="text-xs font-semibold">Resolution:</span>{" "}
|
<span className="text-xs font-semibold">Resolution:</span>{" "}
|
||||||
<span className="text-xs">{videoSize}</span>
|
<span className="text-xs">{videoSize}</span>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{settings.debugMode ? (
|
{debugMode ? (
|
||||||
<div className="flex">
|
<div className="flex">
|
||||||
<span className="text-xs font-semibold">Video Size: </span>
|
<span className="text-xs font-semibold">Video Size: </span>
|
||||||
<span className="text-xs">{videoClientSize}</span>
|
<span className="text-xs">{videoClientSize}</span>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{(settings.debugMode && settings.mouseMode == "absolute") ? (
|
{(debugMode && mouseMode == "absolute") ? (
|
||||||
<div className="flex w-[118px] items-center gap-x-1">
|
<div className="flex w-[118px] items-center gap-x-1">
|
||||||
<span className="text-xs font-semibold">Pointer:</span>
|
<span className="text-xs font-semibold">Pointer:</span>
|
||||||
<span className="text-xs">
|
<span className="text-xs">
|
||||||
|
|
@ -74,7 +80,7 @@ export default function InfoBar() {
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{(settings.debugMode && settings.mouseMode == "relative") ? (
|
{(debugMode && mouseMode == "relative") ? (
|
||||||
<div className="flex w-[118px] items-center gap-x-1">
|
<div className="flex w-[118px] items-center gap-x-1">
|
||||||
<span className="text-xs font-semibold">Last Move:</span>
|
<span className="text-xs font-semibold">Last Move:</span>
|
||||||
<span className="text-xs">
|
<span className="text-xs">
|
||||||
|
|
@ -85,31 +91,35 @@ export default function InfoBar() {
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{settings.debugMode && (
|
{debugMode && (
|
||||||
<div className="flex w-[156px] items-center gap-x-1">
|
<div className="flex w-[156px] items-center gap-x-1">
|
||||||
<span className="text-xs font-semibold">USB State:</span>
|
<span className="text-xs font-semibold">USB State:</span>
|
||||||
<span className="text-xs">{usbState}</span>
|
<span className="text-xs">{usbState}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{settings.debugMode && (
|
{debugMode && (
|
||||||
<div className="flex w-[156px] items-center gap-x-1">
|
<div className="flex w-[156px] items-center gap-x-1">
|
||||||
<span className="text-xs font-semibold">HDMI State:</span>
|
<span className="text-xs font-semibold">HDMI State:</span>
|
||||||
<span className="text-xs">{hdmiState}</span>
|
<span className="text-xs">{hdmiState}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{debugMode && (
|
||||||
|
<div className="flex w-[156px] items-center gap-x-1">
|
||||||
|
<span className="text-xs font-semibold">HidRPC State:</span>
|
||||||
|
<span className="text-xs">{rpcHidStatus}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{isPasteInProgress && (
|
||||||
|
<div className="flex w-[156px] items-center gap-x-1">
|
||||||
|
<span className="text-xs font-semibold">Paste Mode:</span>
|
||||||
|
<span className="text-xs">Enabled</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{showPressedKeys && (
|
{showPressedKeys && (
|
||||||
<div className="flex items-center gap-x-1">
|
<div className="flex items-center gap-x-1">
|
||||||
<span className="text-xs font-semibold">Keys:</span>
|
<span className="text-xs font-semibold">Keys:</span>
|
||||||
<h2 className="text-xs">
|
<h2 className="text-xs">
|
||||||
{[
|
{displayKeys}
|
||||||
...activeKeys.map(
|
|
||||||
x => Object.entries(keys).filter(y => y[1] === x)[0][0],
|
|
||||||
),
|
|
||||||
activeModifiers.map(
|
|
||||||
x => Object.entries(modifiers).filter(y => y[1] === x)[0][0],
|
|
||||||
),
|
|
||||||
].join(", ")}
|
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -122,23 +132,10 @@ export default function InfoBar() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{keyboardLedStateSyncAvailable ? (
|
|
||||||
<div
|
|
||||||
className={cx(
|
|
||||||
"shrink-0 p-1 px-1.5 text-xs",
|
|
||||||
keyboardLedSync !== "browser"
|
|
||||||
? "text-black dark:text-white"
|
|
||||||
: "text-slate-800/20 dark:text-slate-300/20",
|
|
||||||
)}
|
|
||||||
title={"Your keyboard LED state is managed by" + (keyboardLedSync === "browser" ? " the browser" : " the host")}
|
|
||||||
>
|
|
||||||
{keyboardLedSync === "browser" ? "Browser" : "Host"}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
<div
|
<div
|
||||||
className={cx(
|
className={cx(
|
||||||
"shrink-0 p-1 px-1.5 text-xs",
|
"shrink-0 p-1 px-1.5 text-xs",
|
||||||
keyboardLedState?.caps_lock
|
keyboardLedState.caps_lock
|
||||||
? "text-black dark:text-white"
|
? "text-black dark:text-white"
|
||||||
: "text-slate-800/20 dark:text-slate-300/20",
|
: "text-slate-800/20 dark:text-slate-300/20",
|
||||||
)}
|
)}
|
||||||
|
|
@ -148,7 +145,7 @@ export default function InfoBar() {
|
||||||
<div
|
<div
|
||||||
className={cx(
|
className={cx(
|
||||||
"shrink-0 p-1 px-1.5 text-xs",
|
"shrink-0 p-1 px-1.5 text-xs",
|
||||||
keyboardLedState?.num_lock
|
keyboardLedState.num_lock
|
||||||
? "text-black dark:text-white"
|
? "text-black dark:text-white"
|
||||||
: "text-slate-800/20 dark:text-slate-300/20",
|
: "text-slate-800/20 dark:text-slate-300/20",
|
||||||
)}
|
)}
|
||||||
|
|
@ -158,23 +155,28 @@ export default function InfoBar() {
|
||||||
<div
|
<div
|
||||||
className={cx(
|
className={cx(
|
||||||
"shrink-0 p-1 px-1.5 text-xs",
|
"shrink-0 p-1 px-1.5 text-xs",
|
||||||
keyboardLedState?.scroll_lock
|
keyboardLedState.scroll_lock
|
||||||
? "text-black dark:text-white"
|
? "text-black dark:text-white"
|
||||||
: "text-slate-800/20 dark:text-slate-300/20",
|
: "text-slate-800/20 dark:text-slate-300/20",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
Scroll Lock
|
Scroll Lock
|
||||||
</div>
|
</div>
|
||||||
{keyboardLedState?.compose ? (
|
{keyboardLedState.compose ? (
|
||||||
<div className="shrink-0 p-1 px-1.5 text-xs">
|
<div className="shrink-0 p-1 px-1.5 text-xs">
|
||||||
Compose
|
Compose
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{keyboardLedState?.kana ? (
|
{keyboardLedState.kana ? (
|
||||||
<div className="shrink-0 p-1 px-1.5 text-xs">
|
<div className="shrink-0 p-1 px-1.5 text-xs">
|
||||||
Kana
|
Kana
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
{keyboardLedState.shift ? (
|
||||||
|
<div className="shrink-0 p-1 px-1.5 text-xs">
|
||||||
|
Shift
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,7 @@ type InputFieldProps = {
|
||||||
|
|
||||||
type InputFieldWithLabelProps = InputFieldProps & {
|
type InputFieldWithLabelProps = InputFieldProps & {
|
||||||
label: React.ReactNode;
|
label: React.ReactNode;
|
||||||
description?: string | null;
|
description?: React.ReactNode | string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const InputField = forwardRef<HTMLInputElement, InputFieldProps>(function InputField(
|
const InputField = forwardRef<HTMLInputElement, InputFieldProps>(function InputField(
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ export default function Ipv6NetworkCard({
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-x-6 gap-y-2">
|
<div className="grid grid-cols-2 gap-x-6 gap-y-2">
|
||||||
{networkState?.dhcp_lease?.ip && (
|
{networkState?.ipv6_link_local && (
|
||||||
<div className="flex flex-col justify-between">
|
<div className="flex flex-col justify-between">
|
||||||
<span className="text-sm text-slate-600 dark:text-slate-400">
|
<span className="text-sm text-slate-600 dark:text-slate-400">
|
||||||
Link-local
|
Link-local
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,176 @@
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { LuExternalLink } from "react-icons/lu";
|
||||||
|
|
||||||
|
import { Button, LinkButton } from "@components/Button";
|
||||||
|
import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc";
|
||||||
|
|
||||||
|
import { InputFieldWithLabel } from "./InputField";
|
||||||
|
import { SelectMenuBasic } from "./SelectMenuBasic";
|
||||||
|
|
||||||
|
export interface JigglerConfig {
|
||||||
|
inactivity_limit_seconds: number;
|
||||||
|
jitter_percentage: number;
|
||||||
|
schedule_cron_tab: string;
|
||||||
|
timezone?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function JigglerSetting({
|
||||||
|
onSave,
|
||||||
|
defaultJigglerState,
|
||||||
|
}: {
|
||||||
|
onSave: (jigglerConfig: JigglerConfig) => void;
|
||||||
|
defaultJigglerState?: JigglerConfig;
|
||||||
|
}) {
|
||||||
|
const [jigglerConfigState, setJigglerConfigState] = useState<JigglerConfig>(
|
||||||
|
defaultJigglerState || {
|
||||||
|
inactivity_limit_seconds: 20,
|
||||||
|
jitter_percentage: 0,
|
||||||
|
schedule_cron_tab: "*/20 * * * * *",
|
||||||
|
timezone: "UTC",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const { send } = useJsonRpc();
|
||||||
|
const [timezones, setTimezones] = useState<string[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
send("getTimezones", {}, (resp: JsonRpcResponse) => {
|
||||||
|
if ("error" in resp) return;
|
||||||
|
setTimezones(resp.result as string[]);
|
||||||
|
});
|
||||||
|
}, [send]);
|
||||||
|
|
||||||
|
const timezoneOptions = useMemo(
|
||||||
|
() =>
|
||||||
|
timezones.map((timezone: string) => ({
|
||||||
|
value: timezone,
|
||||||
|
label: timezone,
|
||||||
|
})),
|
||||||
|
[timezones],
|
||||||
|
);
|
||||||
|
|
||||||
|
const exampleConfigs = [
|
||||||
|
{
|
||||||
|
name: "Business Hours 9-17",
|
||||||
|
config: {
|
||||||
|
inactivity_limit_seconds: 60,
|
||||||
|
jitter_percentage: 25,
|
||||||
|
schedule_cron_tab: "0 * 9-17 * * 1-5",
|
||||||
|
timezone: jigglerConfigState.timezone || "UTC",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Business Hours 8-17",
|
||||||
|
config: {
|
||||||
|
inactivity_limit_seconds: 60,
|
||||||
|
jitter_percentage: 25,
|
||||||
|
schedule_cron_tab: "0 * 8-17 * * 1-5",
|
||||||
|
timezone: jigglerConfigState.timezone || "UTC",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h4 className="text-sm font-semibold text-gray-900 dark:text-gray-100">
|
||||||
|
Examples
|
||||||
|
</h4>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{exampleConfigs.map((example, index) => (
|
||||||
|
<Button
|
||||||
|
key={index}
|
||||||
|
size="XS"
|
||||||
|
theme="light"
|
||||||
|
text={example.name}
|
||||||
|
onClick={() => setJigglerConfigState(example.config)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<LinkButton
|
||||||
|
to="https://crontab.guru/examples.html"
|
||||||
|
size="XS"
|
||||||
|
theme="light"
|
||||||
|
text="More examples"
|
||||||
|
LeadingIcon={LuExternalLink}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 items-end gap-4 md:grid-cols-2">
|
||||||
|
<InputFieldWithLabel
|
||||||
|
required
|
||||||
|
size="SM"
|
||||||
|
label="Cron Schedule"
|
||||||
|
description="Cron expression for scheduling"
|
||||||
|
placeholder="*/20 * * * * *"
|
||||||
|
value={jigglerConfigState.schedule_cron_tab}
|
||||||
|
onChange={e =>
|
||||||
|
setJigglerConfigState({
|
||||||
|
...jigglerConfigState,
|
||||||
|
schedule_cron_tab: e.target.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<InputFieldWithLabel
|
||||||
|
size="SM"
|
||||||
|
label="Inactivity Limit Seconds"
|
||||||
|
description="Inactivity time before jiggle"
|
||||||
|
value={jigglerConfigState.inactivity_limit_seconds}
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="100"
|
||||||
|
onChange={e =>
|
||||||
|
setJigglerConfigState({
|
||||||
|
...jigglerConfigState,
|
||||||
|
inactivity_limit_seconds: Number(e.target.value),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<InputFieldWithLabel
|
||||||
|
required
|
||||||
|
size="SM"
|
||||||
|
label="Random delay"
|
||||||
|
description="To avoid recognizable patterns"
|
||||||
|
placeholder="25"
|
||||||
|
TrailingElm={<span className="px-2 text-xs text-slate-500">%</span>}
|
||||||
|
value={jigglerConfigState.jitter_percentage}
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
max="100"
|
||||||
|
onChange={e =>
|
||||||
|
setJigglerConfigState({
|
||||||
|
...jigglerConfigState,
|
||||||
|
jitter_percentage: Number(e.target.value),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<SelectMenuBasic
|
||||||
|
size="SM"
|
||||||
|
label="Timezone"
|
||||||
|
description="Timezone for cron schedule"
|
||||||
|
value={jigglerConfigState.timezone || "UTC"}
|
||||||
|
disabled={timezones.length === 0}
|
||||||
|
onChange={e =>
|
||||||
|
setJigglerConfigState({
|
||||||
|
...jigglerConfigState,
|
||||||
|
timezone: e.target.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
options={timezoneOptions}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-x-2">
|
||||||
|
<Button
|
||||||
|
size="SM"
|
||||||
|
theme="primary"
|
||||||
|
text="Save Jiggler Config"
|
||||||
|
onClick={() => onSave(jigglerConfigState)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { MdConnectWithoutContact } from "react-icons/md";
|
import { MdConnectWithoutContact } from "react-icons/md";
|
||||||
import { Menu, MenuButton, MenuItem, MenuItems } from "@headlessui/react";
|
import { Menu, MenuButton, MenuItem, MenuItems } from "@headlessui/react";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router";
|
||||||
import { LuEllipsisVertical } from "react-icons/lu";
|
import { LuEllipsisVertical } from "react-icons/lu";
|
||||||
|
|
||||||
import Card from "@components/Card";
|
import Card from "@components/Card";
|
||||||
|
|
@ -100,15 +100,12 @@ export default function KvmCard({
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<Menu as="div" className="relative inline-block text-left">
|
<Menu as="div" className="relative inline-block text-left">
|
||||||
<div>
|
<MenuButton
|
||||||
<MenuButton
|
as={Button}
|
||||||
as={Button}
|
theme="light"
|
||||||
theme="light"
|
TrailingIcon={LuEllipsisVertical}
|
||||||
TrailingIcon={LuEllipsisVertical}
|
size="MD"
|
||||||
size="MD"
|
></MenuButton>
|
||||||
></MenuButton>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<MenuItems
|
<MenuItems
|
||||||
transition
|
transition
|
||||||
className="data-closed:scale-95 data-closed:transform data-closed:opacity-0 data-enter:duration-100 data-leave:duration-75 data-enter:ease-out data-leave:ease-in"
|
className="data-closed:scale-95 data-closed:transform data-closed:opacity-0 data-enter:duration-100 data-leave:duration-75 data-enter:ease-out data-leave:ease-in"
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ import { useJsonRpc } from "@/hooks/useJsonRpc";
|
||||||
export default function MacroBar() {
|
export default function MacroBar() {
|
||||||
const { macros, initialized, loadMacros, setSendFn } = useMacrosStore();
|
const { macros, initialized, loadMacros, setSendFn } = useMacrosStore();
|
||||||
const { executeMacro } = useKeyboard();
|
const { executeMacro } = useKeyboard();
|
||||||
const [send] = useJsonRpc();
|
const { send } = useJsonRpc();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setSendFn(send);
|
setSendFn(send);
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,18 @@
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { LuPlus } from "react-icons/lu";
|
import { LuPlus } from "react-icons/lu";
|
||||||
|
|
||||||
import { KeySequence } from "@/hooks/stores";
|
|
||||||
import { Button } from "@/components/Button";
|
import { Button } from "@/components/Button";
|
||||||
import { InputFieldWithLabel, FieldError } from "@/components/InputField";
|
import FieldLabel from "@/components/FieldLabel";
|
||||||
import Fieldset from "@/components/Fieldset";
|
import Fieldset from "@/components/Fieldset";
|
||||||
|
import { InputFieldWithLabel, FieldError } from "@/components/InputField";
|
||||||
import { MacroStepCard } from "@/components/MacroStepCard";
|
import { MacroStepCard } from "@/components/MacroStepCard";
|
||||||
import {
|
import {
|
||||||
DEFAULT_DELAY,
|
DEFAULT_DELAY,
|
||||||
MAX_STEPS_PER_MACRO,
|
MAX_STEPS_PER_MACRO,
|
||||||
MAX_KEYS_PER_STEP,
|
MAX_KEYS_PER_STEP,
|
||||||
} from "@/constants/macros";
|
} from "@/constants/macros";
|
||||||
import FieldLabel from "@/components/FieldLabel";
|
import { KeySequence } from "@/hooks/stores";
|
||||||
|
import useKeyboardLayout from "@/hooks/useKeyboardLayout";
|
||||||
|
|
||||||
interface ValidationErrors {
|
interface ValidationErrors {
|
||||||
name?: string;
|
name?: string;
|
||||||
|
|
@ -44,6 +45,7 @@ export function MacroForm({
|
||||||
const [keyQueries, setKeyQueries] = useState<Record<number, string>>({});
|
const [keyQueries, setKeyQueries] = useState<Record<number, string>>({});
|
||||||
const [errors, setErrors] = useState<ValidationErrors>({});
|
const [errors, setErrors] = useState<ValidationErrors>({});
|
||||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||||
|
const { selectedKeyboard } = useKeyboardLayout();
|
||||||
|
|
||||||
const showTemporaryError = (message: string) => {
|
const showTemporaryError = (message: string) => {
|
||||||
setErrorMessage(message);
|
setErrorMessage(message);
|
||||||
|
|
@ -234,6 +236,7 @@ export function MacroForm({
|
||||||
}
|
}
|
||||||
onDelayChange={delay => handleDelayChange(stepIndex, delay)}
|
onDelayChange={delay => handleDelayChange(stepIndex, delay)}
|
||||||
isLastStep={stepIndex === (macro.steps?.length || 0) - 1}
|
isLastStep={stepIndex === (macro.steps?.length || 0) - 1}
|
||||||
|
keyboard={selectedKeyboard}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,23 +1,18 @@
|
||||||
|
import { useMemo } from "react";
|
||||||
import { LuArrowUp, LuArrowDown, LuX, LuTrash2 } from "react-icons/lu";
|
import { LuArrowUp, LuArrowDown, LuX, LuTrash2 } from "react-icons/lu";
|
||||||
|
|
||||||
import { Button } from "@/components/Button";
|
import { Button } from "@/components/Button";
|
||||||
import { Combobox } from "@/components/Combobox";
|
import { Combobox } from "@/components/Combobox";
|
||||||
import { SelectMenuBasic } from "@/components/SelectMenuBasic";
|
import { SelectMenuBasic } from "@/components/SelectMenuBasic";
|
||||||
import Card from "@/components/Card";
|
import Card from "@/components/Card";
|
||||||
import { keys, modifiers, keyDisplayMap } from "@/keyboardMappings";
|
|
||||||
import { MAX_KEYS_PER_STEP, DEFAULT_DELAY } from "@/constants/macros";
|
|
||||||
import FieldLabel from "@/components/FieldLabel";
|
import FieldLabel from "@/components/FieldLabel";
|
||||||
|
import { MAX_KEYS_PER_STEP, DEFAULT_DELAY } from "@/constants/macros";
|
||||||
|
import { KeyboardLayout } from "@/keyboardLayouts";
|
||||||
|
import { keys, modifiers } from "@/keyboardMappings";
|
||||||
|
|
||||||
// Filter out modifier keys since they're handled in the modifiers section
|
// Filter out modifier keys since they're handled in the modifiers section
|
||||||
const modifierKeyPrefixes = ['Alt', 'Control', 'Shift', 'Meta'];
|
const modifierKeyPrefixes = ['Alt', 'Control', 'Shift', 'Meta'];
|
||||||
|
|
||||||
const keyOptions = Object.keys(keys)
|
|
||||||
.filter(key => !modifierKeyPrefixes.some(prefix => key.startsWith(prefix)))
|
|
||||||
.map(key => ({
|
|
||||||
value: key,
|
|
||||||
label: keyDisplayMap[key] || key,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const modifierOptions = Object.keys(modifiers).map(modifier => ({
|
const modifierOptions = Object.keys(modifiers).map(modifier => ({
|
||||||
value: modifier,
|
value: modifier,
|
||||||
label: modifier.replace(/^(Control|Alt|Shift|Meta)(Left|Right)$/, "$1 $2"),
|
label: modifier.replace(/^(Control|Alt|Shift|Meta)(Left|Right)$/, "$1 $2"),
|
||||||
|
|
@ -67,6 +62,7 @@ interface MacroStepCardProps {
|
||||||
onModifierChange: (modifiers: string[]) => void;
|
onModifierChange: (modifiers: string[]) => void;
|
||||||
onDelayChange: (delay: number) => void;
|
onDelayChange: (delay: number) => void;
|
||||||
isLastStep: boolean;
|
isLastStep: boolean;
|
||||||
|
keyboard: KeyboardLayout
|
||||||
}
|
}
|
||||||
|
|
||||||
const ensureArray = <T,>(arr: T[] | null | undefined): T[] => {
|
const ensureArray = <T,>(arr: T[] | null | undefined): T[] => {
|
||||||
|
|
@ -84,9 +80,22 @@ export function MacroStepCard({
|
||||||
keyQuery,
|
keyQuery,
|
||||||
onModifierChange,
|
onModifierChange,
|
||||||
onDelayChange,
|
onDelayChange,
|
||||||
isLastStep
|
isLastStep,
|
||||||
|
keyboard
|
||||||
}: MacroStepCardProps) {
|
}: MacroStepCardProps) {
|
||||||
const getFilteredKeys = () => {
|
const { keyDisplayMap } = keyboard;
|
||||||
|
|
||||||
|
const keyOptions = useMemo(() =>
|
||||||
|
Object.keys(keys)
|
||||||
|
.filter(key => !modifierKeyPrefixes.some(prefix => key.startsWith(prefix)))
|
||||||
|
.map(key => ({
|
||||||
|
value: key,
|
||||||
|
label: keyDisplayMap[key] || key,
|
||||||
|
})),
|
||||||
|
[keyDisplayMap]
|
||||||
|
);
|
||||||
|
|
||||||
|
const filteredKeys = useMemo(() => {
|
||||||
const selectedKeys = ensureArray(step.keys);
|
const selectedKeys = ensureArray(step.keys);
|
||||||
const availableKeys = keyOptions.filter(option => !selectedKeys.includes(option.value));
|
const availableKeys = keyOptions.filter(option => !selectedKeys.includes(option.value));
|
||||||
|
|
||||||
|
|
@ -95,7 +104,7 @@ export function MacroStepCard({
|
||||||
} else {
|
} else {
|
||||||
return availableKeys.filter(option => option.label.toLowerCase().includes(keyQuery.toLowerCase()));
|
return availableKeys.filter(option => option.label.toLowerCase().includes(keyQuery.toLowerCase()));
|
||||||
}
|
}
|
||||||
};
|
}, [keyOptions, keyQuery, step.keys]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="p-4">
|
<Card className="p-4">
|
||||||
|
|
@ -204,7 +213,7 @@ export function MacroStepCard({
|
||||||
}}
|
}}
|
||||||
displayValue={() => keyQuery}
|
displayValue={() => keyQuery}
|
||||||
onInputChange={onKeyQueryChange}
|
onInputChange={onKeyQueryChange}
|
||||||
options={getFilteredKeys}
|
options={() => filteredKeys}
|
||||||
disabledMessage="Max keys reached"
|
disabledMessage="Max keys reached"
|
||||||
size="SM"
|
size="SM"
|
||||||
immediate
|
immediate
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,180 @@
|
||||||
|
/* eslint-disable react-refresh/only-export-components */
|
||||||
|
import { ComponentProps } from "react";
|
||||||
|
import { cva, cx } from "cva";
|
||||||
|
|
||||||
|
import { someIterable } from "../utils";
|
||||||
|
|
||||||
|
import { GridCard } from "./Card";
|
||||||
|
import MetricsChart from "./MetricsChart";
|
||||||
|
|
||||||
|
interface ChartPoint {
|
||||||
|
date: number;
|
||||||
|
metric: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MetricProps<T, K extends keyof T> {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
stream?: Map<number, T>;
|
||||||
|
metric?: K;
|
||||||
|
data?: ChartPoint[];
|
||||||
|
gate?: Map<number, unknown>;
|
||||||
|
supported?: boolean;
|
||||||
|
map?: (p: { date: number; metric: number | null }) => ChartPoint;
|
||||||
|
domain?: [number, number];
|
||||||
|
unit: string;
|
||||||
|
heightClassName?: string;
|
||||||
|
referenceValue?: number;
|
||||||
|
badge?: ComponentProps<typeof MetricHeader>["badge"];
|
||||||
|
badgeTheme?: ComponentProps<typeof MetricHeader>["badgeTheme"];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a chart array from a metrics map and a metric name.
|
||||||
|
*
|
||||||
|
* @param metrics - Expected to be ordered from oldest to newest.
|
||||||
|
* @param metricName - Name of the metric to create a chart array for.
|
||||||
|
*/
|
||||||
|
export function createChartArray<T, K extends keyof T>(
|
||||||
|
metrics: Map<number, T>,
|
||||||
|
metricName: K,
|
||||||
|
) {
|
||||||
|
const result: { date: number; metric: number | null }[] = [];
|
||||||
|
const iter = metrics.entries();
|
||||||
|
let next = iter.next() as IteratorResult<[number, T]>;
|
||||||
|
const nowSeconds = Math.floor(Date.now() / 1000);
|
||||||
|
|
||||||
|
// We want 120 data points, in the chart.
|
||||||
|
const firstDate = Math.min(next.value?.[0] ?? nowSeconds, nowSeconds - 120);
|
||||||
|
|
||||||
|
for (let t = firstDate; t < nowSeconds; t++) {
|
||||||
|
while (!next.done && next.value[0] < t) next = iter.next();
|
||||||
|
const has = !next.done && next.value[0] === t;
|
||||||
|
|
||||||
|
let metric = null;
|
||||||
|
if (has) metric = next.value[1][metricName] as number;
|
||||||
|
result.push({ date: t, metric });
|
||||||
|
|
||||||
|
if (has) next = iter.next();
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeReferenceValue(points: ChartPoint[]): number | undefined {
|
||||||
|
const values = points
|
||||||
|
.filter(p => p.metric != null && Number.isFinite(p.metric))
|
||||||
|
.map(p => Number(p.metric));
|
||||||
|
|
||||||
|
if (values.length === 0) return undefined;
|
||||||
|
|
||||||
|
const sum = values.reduce((acc, v) => acc + v, 0);
|
||||||
|
const mean = sum / values.length;
|
||||||
|
return Math.round(mean);
|
||||||
|
}
|
||||||
|
|
||||||
|
const theme = {
|
||||||
|
light:
|
||||||
|
"bg-white text-black border border-slate-800/20 dark:border dark:border-slate-700 dark:bg-slate-800 dark:text-slate-300",
|
||||||
|
danger: "bg-red-500 dark:border-red-700 dark:bg-red-800 dark:text-red-50",
|
||||||
|
primary: "bg-blue-500 dark:border-blue-700 dark:bg-blue-800 dark:text-blue-50",
|
||||||
|
};
|
||||||
|
|
||||||
|
interface SettingsItemProps {
|
||||||
|
readonly title: string;
|
||||||
|
readonly description: string | React.ReactNode;
|
||||||
|
readonly badge?: string;
|
||||||
|
readonly className?: string;
|
||||||
|
readonly children?: React.ReactNode;
|
||||||
|
readonly badgeTheme?: keyof typeof theme;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MetricHeader(props: SettingsItemProps) {
|
||||||
|
const { title, description, badge } = props;
|
||||||
|
const badgeVariants = cva({ variants: { theme: theme } });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<div className="flex items-center gap-x-2">
|
||||||
|
<div className="flex w-full items-center justify-between text-base font-semibold text-black dark:text-white">
|
||||||
|
{title}
|
||||||
|
{badge && (
|
||||||
|
<span
|
||||||
|
className={cx(
|
||||||
|
"ml-2 rounded-sm px-2 py-1 font-mono text-[10px] leading-none font-medium",
|
||||||
|
badgeVariants({ theme: props.badgeTheme ?? "light" }),
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{badge}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-slate-700 dark:text-slate-300">{description}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Metric<T, K extends keyof T>({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
stream,
|
||||||
|
metric,
|
||||||
|
data,
|
||||||
|
gate,
|
||||||
|
supported,
|
||||||
|
map,
|
||||||
|
domain = [0, 600],
|
||||||
|
unit = "",
|
||||||
|
heightClassName = "h-[127px]",
|
||||||
|
badge,
|
||||||
|
badgeTheme,
|
||||||
|
}: MetricProps<T, K>) {
|
||||||
|
const ready = gate ? gate.size > 0 : stream ? stream.size > 0 : true;
|
||||||
|
const supportedFinal =
|
||||||
|
supported ??
|
||||||
|
(stream && metric ? someIterable(stream, ([, s]) => s[metric] !== undefined) : true);
|
||||||
|
|
||||||
|
// Either we let the consumer provide their own chartArray, or we create one from the stream and metric.
|
||||||
|
const raw = data ?? ((stream && metric && createChartArray(stream, metric)) || []);
|
||||||
|
|
||||||
|
// If the consumer provides a map function, we apply it to the raw data.
|
||||||
|
const dataFinal: ChartPoint[] = map ? raw.map(map) : raw;
|
||||||
|
|
||||||
|
// Compute the average value of the metric.
|
||||||
|
const referenceValue = computeReferenceValue(dataFinal);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<MetricHeader
|
||||||
|
title={title}
|
||||||
|
description={description}
|
||||||
|
badge={badge}
|
||||||
|
badgeTheme={badgeTheme}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<GridCard>
|
||||||
|
<div
|
||||||
|
className={`flex ${heightClassName} w-full items-center justify-center text-sm text-slate-500`}
|
||||||
|
>
|
||||||
|
{!ready ? (
|
||||||
|
<div className="flex flex-col items-center space-y-1">
|
||||||
|
<p className="text-slate-700">Waiting for data...</p>
|
||||||
|
</div>
|
||||||
|
) : supportedFinal ? (
|
||||||
|
<MetricsChart
|
||||||
|
data={dataFinal}
|
||||||
|
domain={domain}
|
||||||
|
unit={unit}
|
||||||
|
referenceValue={referenceValue}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col items-center space-y-1">
|
||||||
|
<p className="text-black">Metric not supported</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</GridCard>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -12,13 +12,13 @@ import {
|
||||||
|
|
||||||
import CustomTooltip, { CustomTooltipProps } from "@components/CustomTooltip";
|
import CustomTooltip, { CustomTooltipProps } from "@components/CustomTooltip";
|
||||||
|
|
||||||
export default function StatChart({
|
export default function MetricsChart({
|
||||||
data,
|
data,
|
||||||
domain,
|
domain,
|
||||||
unit,
|
unit,
|
||||||
referenceValue,
|
referenceValue,
|
||||||
}: {
|
}: {
|
||||||
data: { date: number; stat: number | null | undefined }[];
|
data: { date: number; metric: number | null | undefined }[];
|
||||||
domain?: [string | number, string | number];
|
domain?: [string | number, string | number];
|
||||||
unit?: string;
|
unit?: string;
|
||||||
referenceValue?: number;
|
referenceValue?: number;
|
||||||
|
|
@ -33,7 +33,7 @@ export default function StatChart({
|
||||||
strokeLinecap="butt"
|
strokeLinecap="butt"
|
||||||
stroke="rgba(30, 41, 59, 0.1)"
|
stroke="rgba(30, 41, 59, 0.1)"
|
||||||
/>
|
/>
|
||||||
{referenceValue && (
|
{referenceValue !== undefined && (
|
||||||
<ReferenceLine
|
<ReferenceLine
|
||||||
y={referenceValue}
|
y={referenceValue}
|
||||||
strokeDasharray="3 3"
|
strokeDasharray="3 3"
|
||||||
|
|
@ -64,7 +64,7 @@ export default function StatChart({
|
||||||
.map(x => x.date)}
|
.map(x => x.date)}
|
||||||
/>
|
/>
|
||||||
<YAxis
|
<YAxis
|
||||||
dataKey="stat"
|
dataKey="metric"
|
||||||
axisLine={false}
|
axisLine={false}
|
||||||
orientation="right"
|
orientation="right"
|
||||||
tick={{
|
tick={{
|
||||||
|
|
@ -73,6 +73,7 @@ export default function StatChart({
|
||||||
fill: "rgba(107, 114, 128, 1)",
|
fill: "rgba(107, 114, 128, 1)",
|
||||||
}}
|
}}
|
||||||
padding={{ top: 0, bottom: 0 }}
|
padding={{ top: 0, bottom: 0 }}
|
||||||
|
allowDecimals
|
||||||
tickLine={false}
|
tickLine={false}
|
||||||
unit={unit}
|
unit={unit}
|
||||||
domain={domain || ["auto", "auto"]}
|
domain={domain || ["auto", "auto"]}
|
||||||
|
|
@ -87,7 +88,7 @@ export default function StatChart({
|
||||||
<Line
|
<Line
|
||||||
type="monotone"
|
type="monotone"
|
||||||
isAnimationActive={false}
|
isAnimationActive={false}
|
||||||
dataKey="stat"
|
dataKey="metric"
|
||||||
stroke="rgb(29 78 216)"
|
stroke="rgb(29 78 216)"
|
||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
strokeWidth={2}
|
strokeWidth={2}
|
||||||
|
|
@ -26,7 +26,7 @@ type SelectMenuProps = Pick<
|
||||||
|
|
||||||
const sizes = {
|
const sizes = {
|
||||||
XS: "h-[24.5px] pl-3 pr-8 text-xs",
|
XS: "h-[24.5px] pl-3 pr-8 text-xs",
|
||||||
SM: "h-[32px] pl-3 pr-8 text-[13px]",
|
SM: "h-[36px] pl-3 pr-8 text-[13px]",
|
||||||
MD: "h-[40px] pl-4 pr-10 text-sm",
|
MD: "h-[40px] pl-4 pr-10 text-sm",
|
||||||
LG: "h-[48px] pl-4 pr-10 px-5 text-base",
|
LG: "h-[48px] pl-4 pr-10 px-5 text-base",
|
||||||
};
|
};
|
||||||
|
|
@ -62,7 +62,7 @@ export const SelectMenuBasic = React.forwardRef<HTMLSelectElement, SelectMenuPro
|
||||||
"text-sm",
|
"text-sm",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{label && <FieldLabel label={label} id={id} as="span" />}
|
{label && <FieldLabel label={label} id={id} />}
|
||||||
<Card className="w-auto border! border-solid border-slate-800/30! shadow-xs outline-0 dark:border-slate-300/30!">
|
<Card className="w-auto border! border-solid border-slate-800/30! shadow-xs outline-0 dark:border-slate-300/30!">
|
||||||
<select
|
<select
|
||||||
ref={ref}
|
ref={ref}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
export default function SettingsNestedSection({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="ml-2 border-l border-slate-800/30 pl-4 dark:border-slate-300/30">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
|
||||||
import Container from "@/components/Container";
|
import Container from "@/components/Container";
|
||||||
|
|
|
||||||