mirror of https://github.com/jetkvm/kvm.git
				
				
				
			Compare commits
	
		
			225 Commits
		
	
	
		
			release/0.
			...
			main
		
	
	| 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 | |
|  | bde0a086ab | |
|  | 9c9335da31 | |
|  | 090e0b4b47 | |
|  | 48a7a638a3 | |
|  | e4f6a713a5 | |
|  | 9fcf74b398 | |
|  | 353099001f | |
|  | 73f5659618 | |
|  | 960f555790 | |
|  | fe127ed41c | |
|  | 3e7d8fb0f5 | |
|  | 0d7f47c109 | |
|  | 254c001572 | |
|  | 6f037a832d | |
|  | ccba27cedd | |
|  | cf9c6e5cc8 | |
|  | ffeaf8cced | |
|  | a1ed28c676 | |
|  | 1674a6666c | |
|  | 772527849f | |
|  | 19871517ec | |
|  | b822b73a03 | |
|  | 58ade3b551 | |
|  | 3cc119c646 | |
|  | c494cf26ef | |
|  | 4bfbc66ea7 | |
|  | 0636cc9aff | |
|  | 4f6026e182 | |
|  | 89f3bc8c40 | |
|  | 91171d9bf7 | |
|  | 0d955a8d95 | |
|  | a40d26ab9b | |
|  | 9bd587b52e | |
|  | 7ef9a7ba93 | |
|  | bfbc1a5a57 | |
|  | abb4350316 | |
|  | 52825da68d | |
|  | 9d2abd9fb0 | |
|  | 52dd675e52 | |
|  | e95e30e48c | |
|  | eaa58492ab | |
|  | f4bb47c544 | |
|  | a7693df92c | |
|  | 8d77d75294 | |
|  | 718b343713 | |
|  | 1f7c5c94d8 | |
|  | 55d7f22c47 | |
|  | a28676cd94 | |
|  | 2ec061b3a8 | |
|  | 7e64a529f8 | |
|  | 1b5062c504 | |
|  | c1d771cced | |
|  | 019934d33e | |
|  | 0c5c69f2d3 | |
|  | 0cee284561 | |
|  | 2272247668 | |
|  | 21e30c60ea | |
|  | 25e30f6420 | |
|  | b91a995918 | |
|  | 590c606bb1 | |
|  | a60e1a5e98 | |
|  | 4e90883bf8 | |
|  | 8eaa86ae45 | |
|  | 354941b54d | |
|  | 4b91c758fa | |
|  | 222a8470a5 | |
|  | 860327bfcd | |
|  | 66fbda864a | |
|  | a0f6d01465 | |
|  | b4dd4961fc | |
|  | eeb103adf9 | |
|  | 8cf6b40dc3 | |
|  | c6b05d4abe | |
|  | 51814dcc5e | |
|  | 5ba08de566 | |
|  | 3f320e50f7 | |
|  | 7a9fb7cbb1 | |
|  | 0a4a1af80e | |
|  | fc3dbcd820 | |
|  | 17baf1647f | |
|  | 840743fcf7 | |
|  | 3ec1bdf388 | |
|  | fea89a0d23 | |
|  | d54568642b | |
|  | c9068af568 | |
|  | 033bdcd645 | |
|  | baf85dcbec | |
|  | c9dd3cd926 | |
|  | 7ccb8e617c | |
|  | 340babac24 | |
|  | 2aa7b8569f | |
|  | 19bd161a7f | |
|  | 38252de03c | |
|  | 63c2272c45 | |
|  | 8ee0532f0e | |
|  | d0faf03239 | |
|  | 77b4c1c531 | |
|  | 5f8b451cd7 | |
|  | 5a4f1766b7 | |
|  | d79f359c43 | |
|  | 189b84380b | |
|  | 2b2a14204d | |
|  | 440f85f091 | |
|  | 009b0abbe9 | |
|  | 951e673e0c | |
|  | edca8a4cb5 | |
|  | 87ee954e70 | |
|  | 94e83249ef | |
|  | f98eaddf15 | |
|  | 8888d13824 | |
|  | 334b3bee60 | |
|  | 0ba7902f82 | |
|  | 924b55059f | |
|  | 6489421605 | |
|  | e08ff425c3 | |
|  | d5f8e51a14 | |
|  | 612c50bfe2 | |
|  | 48a917fd76 | |
|  | 5f7dded973 | |
|  | 04aa35249a | |
|  | 82c018a2f6 | |
|  | 4c37f7e079 | |
|  | 8f6e64fd9c | |
|  | 76efa56083 | |
|  | dc1ce03697 | |
|  | 66a3352e5d | |
|  | 9c758b6d57 | |
|  | 647250c32b | |
|  | 3f20c23ea1 | |
|  | b94de38510 | |
|  | 1505ca1bc1 | |
|  | 960ef230ba | |
|  | 98af805089 | |
|  | 84b35d5deb | |
|  | 652e845d83 | |
|  | 1a30977085 | |
|  | fa1b11b228 | |
|  | abc6d92331 | |
|  | 73e715117e | |
|  | 8268b20f32 | |
|  | 1a26431147 | |
|  | f3b5011d65 | |
|  | 1e9adf81d4 | |
|  | 65e4a58ad9 | |
|  | df0d083a28 | |
|  | 1f8f885a1d | |
|  | aed453cc8c | |
|  | edafe996a9 | |
|  | a9180c972c | |
|  | b5e0f894bc | |
|  | a3580b5465 | |
|  | 3b711db781 | |
|  | 9d511d7f58 | |
|  | 5d7d4db4aa | |
|  | 0a7847c5ab | |
|  | 1b8954e9f3 | |
|  | ab03aded74 | |
|  | 204e6c7faf | |
|  | caf3922ecd | 
|  | @ -4,11 +4,24 @@ | |||
| 	"features": { | ||||
| 		"ghcr.io/devcontainers/features/node:1": { | ||||
| 			// Should match what is defined in ui/package.json | ||||
| 			"version": "21.1.0" | ||||
| 			"version": "22.15.0" | ||||
| 		} | ||||
| 	}, | ||||
| 	"mounts": [ | ||||
|     	"source=${localEnv:HOME}/.ssh,target=/home/vscode/.ssh,type=bind,consistency=cached" | ||||
| 	] | ||||
| 	], | ||||
| 	"customizations": { | ||||
| 		"vscode": { | ||||
| 			"extensions": [ | ||||
| 				"bradlc.vscode-tailwindcss", | ||||
| 				"GitHub.vscode-pull-request-github", | ||||
| 				"dbaeumer.vscode-eslint", | ||||
| 				"golang.go", | ||||
| 				"ms-vscode.makefile-tools", | ||||
| 				"esbenp.prettier-vscode", | ||||
| 				"github.vscode-github-actions" | ||||
| 			] | ||||
| 		} | ||||
| 	} | ||||
| } | ||||
| 
 | ||||
|  |  | |||
|  | @ -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 | ||||
|  | @ -0,0 +1,17 @@ | |||
| version: 2 | ||||
| updates: | ||||
|   - package-ecosystem: gomod | ||||
|     directory: / | ||||
|     schedule: | ||||
|       interval: monthly | ||||
|     open-pull-requests-limit: 10 | ||||
|   - package-ecosystem: github-actions | ||||
|     directory: / | ||||
|     schedule: | ||||
|       interval: monthly | ||||
|     open-pull-requests-limit: 10 | ||||
|   - package-ecosystem: npm | ||||
|     directory: /ui | ||||
|     open-pull-requests-limit: 10 | ||||
|     schedule: | ||||
|       interval: monthly | ||||
|  | @ -10,133 +10,42 @@ on: | |||
| 
 | ||||
| jobs: | ||||
|   build: | ||||
|     runs-on: buildjet-4vcpu-ubuntu-2204 | ||||
|     runs-on: ubuntu-latest | ||||
|     name: Build | ||||
|     if: github.event_name != 'pull_request_review' || github.event.review.state == 'approved' | ||||
|     steps: | ||||
|       - name: Checkout | ||||
|         uses: actions/checkout@v4 | ||||
|         uses: actions/checkout@v5 | ||||
|       - name: Set up Node.js | ||||
|         uses: actions/setup-node@v4 | ||||
|         with: | ||||
|           node-version: v21.1.0 | ||||
|           cache: 'npm' | ||||
|           cache-dependency-path: '**/package-lock.json' | ||||
|           node-version: "22" | ||||
|           cache: "npm" | ||||
|           cache-dependency-path: "**/package-lock.json" | ||||
|       - name: Set up Golang | ||||
|         uses: actions/setup-go@v4 | ||||
|         uses: actions/setup-go@v5.5.0 | ||||
|         with: | ||||
|           go-version: '1.24.0' | ||||
|           go-version: "1.24.4" | ||||
|       - name: Build frontend | ||||
|         run: | | ||||
|           make frontend | ||||
|       - name: Build application | ||||
|         run: | | ||||
|           make build_dev | ||||
|       - name: Run tests | ||||
|         run: | | ||||
|           go test ./... -json > testreport.json | ||||
|       - name: Make test cases | ||||
|         run: | | ||||
|           make build_dev_test | ||||
|       - name: Golang Test Report | ||||
|         uses: becheran/go-testreport@v0.3.2 | ||||
|         with: | ||||
|           input: "testreport.json" | ||||
|       - name: Upload artifact | ||||
|         uses: actions/upload-artifact@v4 | ||||
|         with: | ||||
|           name: jetkvm-app | ||||
|           path: bin/jetkvm_app | ||||
|   deploy_and_test: | ||||
|     runs-on: buildjet-4vcpu-ubuntu-2204 | ||||
|     name: Smoke test | ||||
|     needs: build | ||||
|     concurrency: | ||||
|       group: smoketest-jk | ||||
|     steps: | ||||
|       - name: Download artifact | ||||
|         uses: actions/download-artifact@v4 | ||||
|         with: | ||||
|           name: jetkvm-app | ||||
|       - name: Configure WireGuard and check connectivity | ||||
|         run: | | ||||
|           WG_KEY_FILE=$(mktemp) | ||||
|           echo -n "$CI_WG_PRIVATE" > $WG_KEY_FILE && \ | ||||
|           sudo apt-get update && sudo apt-get install -y wireguard-tools && \ | ||||
|           sudo ip link add dev wg-ci type wireguard && \ | ||||
|           sudo ip addr add $CI_WG_IPS dev wg-ci && \ | ||||
|           sudo wg set wg-ci listen-port 51820 \ | ||||
|             private-key $WG_KEY_FILE \ | ||||
|             peer $CI_WG_PUBLIC \ | ||||
|             allowed-ips $CI_WG_ALLOWED_IPS \ | ||||
|             endpoint $CI_WG_ENDPOINT \ | ||||
|             persistent-keepalive 15 && \ | ||||
|           sudo ip link set up dev wg-ci && \ | ||||
|           sudo ip r r $CI_HOST via $CI_WG_GATEWAY dev wg-ci | ||||
|           ping -c1 $CI_HOST || (echo "Failed to ping $CI_HOST" && sudo wg show wg-ci && ip r && exit 1) | ||||
|         env: | ||||
|           CI_HOST: ${{ vars.JETKVM_CI_HOST }} | ||||
|           CI_WG_IPS: ${{ vars.JETKVM_CI_WG_IPS }} | ||||
|           CI_WG_GATEWAY: ${{ vars.JETKVM_CI_GATEWAY }} | ||||
|           CI_WG_ALLOWED_IPS: ${{ vars.JETKVM_CI_WG_ALLOWED_IPS }} | ||||
|           CI_WG_PUBLIC: ${{ secrets.JETKVM_CI_WG_PUBLIC }} | ||||
|           CI_WG_PRIVATE: ${{ secrets.JETKVM_CI_WG_PRIVATE }} | ||||
|           CI_WG_ENDPOINT: ${{ secrets.JETKVM_CI_WG_ENDPOINT }} | ||||
|       - name: Configure SSH | ||||
|         run: | | ||||
|           # Write SSH private key to a file | ||||
|           SSH_PRIVATE_KEY=$(mktemp) | ||||
|           echo "$CI_SSH_PRIVATE" > $SSH_PRIVATE_KEY | ||||
|           chmod 0600 $SSH_PRIVATE_KEY | ||||
|           # Configure SSH | ||||
|           mkdir -p ~/.ssh | ||||
|           cat <<EOF >> ~/.ssh/config | ||||
|           Host jkci | ||||
|             HostName $CI_HOST | ||||
|             User $CI_USER | ||||
|             StrictHostKeyChecking no | ||||
|             UserKnownHostsFile /dev/null | ||||
|             IdentityFile $SSH_PRIVATE_KEY | ||||
|           EOF | ||||
|         env: | ||||
|           CI_USER: ${{ vars.JETKVM_CI_USER }} | ||||
|           CI_HOST: ${{ vars.JETKVM_CI_HOST }} | ||||
|           CI_SSH_PRIVATE: ${{ secrets.JETKVM_CI_SSH_PRIVATE }} | ||||
|       - name: Deploy application | ||||
|         run: | | ||||
|           set -e | ||||
|           # Copy the binary to the remote host | ||||
|           echo "+ Copying the application to the remote host" | ||||
|           cat jetkvm_app | gzip | ssh jkci "cat > /userdata/jetkvm/jetkvm_app.update.gz" | ||||
|           # Deploy and run the application on the remote host | ||||
|           echo "+ Deploying the application on the remote host" | ||||
|           ssh jkci ash <<EOF | ||||
|           # Extract the binary | ||||
|           gzip -d /userdata/jetkvm/jetkvm_app.update.gz | ||||
|           # Flush filesystem buffers to ensure all data is written to disk | ||||
|           sync | ||||
|           # Clear the filesystem caches to force a read from disk | ||||
|           echo 1 > /proc/sys/vm/drop_caches | ||||
|           # Reboot the application | ||||
|           reboot -d 5 -f & | ||||
|           EOF | ||||
|           sleep 10 | ||||
|           echo "Deployment complete, waiting for JetKVM to come back online " | ||||
|           function check_online() { | ||||
|             for i in {1..60}; do | ||||
|                 if ping -c1 -w1 -W1 -q $CI_HOST >/dev/null; then | ||||
|                     echo "JetKVM is back online" | ||||
|                     return 0 | ||||
|                 fi | ||||
|                 echo -n "." | ||||
|                 sleep 1 | ||||
|             done | ||||
|             echo "JetKVM did not come back online within 60 seconds" | ||||
|             return 1 | ||||
|           } | ||||
|           check_online | ||||
|         env: | ||||
|           CI_HOST: ${{ vars.JETKVM_CI_HOST }} | ||||
|       - name: Run smoke tests | ||||
|         run: | | ||||
|           echo "+ Checking the status of the device" | ||||
|           curl -v http://$CI_HOST/device/status && echo | ||||
|           echo "+ Collecting logs" | ||||
|           ssh jkci "cat /userdata/jetkvm/last.log" > last.log | ||||
|           cat last.log | ||||
|         env: | ||||
|           CI_HOST: ${{ vars.JETKVM_CI_HOST }} | ||||
|       - name: Upload logs | ||||
|         uses: actions/upload-artifact@v4 | ||||
|         with: | ||||
|           name: device-logs | ||||
|           path: last.log | ||||
|           path: | | ||||
|             bin/jetkvm_app | ||||
|             device-tests.tar.gz | ||||
|  | @ -22,13 +22,16 @@ jobs: | |||
|     runs-on: ubuntu-latest | ||||
|     steps: | ||||
|       - name: Checkout repository | ||||
|         uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 | ||||
|         uses: actions/checkout@ff7abcd0c3c05ccf6adc123a8cd1fd4fb30fb493 # v4.2.2 | ||||
|       - name: Install Go | ||||
|         uses: actions/setup-go@41dfa10bad2bb2ae585af6ee5bb4d7d973ad74ed # v5.1.0 | ||||
|         uses: actions/setup-go@fa96338abe5531f6e34c5cc0bbe28c1a533d5505 # v4.2.1 | ||||
|         with: | ||||
|           go-version: 1.23.x | ||||
|           go-version: 1.24.4 | ||||
|       - name: Create empty resource directory | ||||
|         run: | | ||||
|           mkdir -p static && touch static/.gitkeep | ||||
|       - name: Lint | ||||
|         uses: golangci/golangci-lint-action@971e284b6050e8a5849b72094c50ab08da042db8 # v6.1.1 | ||||
|         uses: golangci/golangci-lint-action@1481404843c368bc19ca9406f87d6e0fc97bdcfd # v7.0.0 | ||||
|         with: | ||||
|           args: --verbose | ||||
|           version: v1.62.0 | ||||
|           version: v2.0.2 | ||||
|  |  | |||
|  | @ -0,0 +1,174 @@ | |||
| name: smoketest | ||||
| on: | ||||
|   repository_dispatch: | ||||
|     types: [smoketest] | ||||
| 
 | ||||
| jobs: | ||||
|   ghbot_payload: | ||||
|     name: Ghbot payload | ||||
|     runs-on: ubuntu-latest | ||||
|     steps: | ||||
|       - name: "GH_CHECK_RUN_ID=${{ github.event.client_payload.check_run_id }}" | ||||
|         run: | | ||||
|           echo "== START GHBOT_PAYLOAD ==" | ||||
|           cat <<'GHPAYLOAD_EOF' | base64 | ||||
|           ${{ toJson(github.event.client_payload) }} | ||||
|           GHPAYLOAD_EOF | ||||
|           echo "== END GHBOT_PAYLOAD ==" | ||||
|   deploy_and_test: | ||||
|     runs-on: buildjet-4vcpu-ubuntu-2204 | ||||
|     name: Smoke test | ||||
|     concurrency: | ||||
|       group: smoketest-jk | ||||
|     steps: | ||||
|       - name: Download artifact | ||||
|         run: | | ||||
|           wget -O /tmp/jk.zip "${{ github.event.client_payload.artifact_download_url }}" | ||||
|           unzip /tmp/jk.zip | ||||
|       - name: Configure WireGuard and check connectivity | ||||
|         run: | | ||||
|           WG_KEY_FILE=$(mktemp) | ||||
|           echo -n "$CI_WG_PRIVATE" > $WG_KEY_FILE && \ | ||||
|           sudo apt-get update && sudo apt-get install -y wireguard-tools && \ | ||||
|           sudo ip link add dev wg-ci type wireguard && \ | ||||
|           sudo ip addr add $CI_WG_IPS dev wg-ci && \ | ||||
|           sudo wg set wg-ci listen-port 51820 \ | ||||
|             private-key $WG_KEY_FILE \ | ||||
|             peer $CI_WG_PUBLIC \ | ||||
|             allowed-ips $CI_WG_ALLOWED_IPS \ | ||||
|             endpoint $CI_WG_ENDPOINT \ | ||||
|             persistent-keepalive 15 && \ | ||||
|           sudo ip link set up dev wg-ci && \ | ||||
|           sudo ip r r $CI_HOST via $CI_WG_GATEWAY dev wg-ci | ||||
|           ping -c1 $CI_HOST || (echo "Failed to ping $CI_HOST" && sudo wg show wg-ci && ip r && exit 1) | ||||
|         env: | ||||
|           CI_HOST: ${{ vars.JETKVM_CI_HOST }} | ||||
|           CI_WG_IPS: ${{ vars.JETKVM_CI_WG_IPS }} | ||||
|           CI_WG_GATEWAY: ${{ vars.JETKVM_CI_GATEWAY }} | ||||
|           CI_WG_ALLOWED_IPS: ${{ vars.JETKVM_CI_WG_ALLOWED_IPS }} | ||||
|           CI_WG_PUBLIC: ${{ secrets.JETKVM_CI_WG_PUBLIC }} | ||||
|           CI_WG_PRIVATE: ${{ secrets.JETKVM_CI_WG_PRIVATE }} | ||||
|           CI_WG_ENDPOINT: ${{ secrets.JETKVM_CI_WG_ENDPOINT }} | ||||
|       - name: Configure SSH | ||||
|         run: | | ||||
|           # Write SSH private key to a file | ||||
|           SSH_PRIVATE_KEY=$(mktemp) | ||||
|           echo "$CI_SSH_PRIVATE" > $SSH_PRIVATE_KEY | ||||
|           chmod 0600 $SSH_PRIVATE_KEY | ||||
|           # Configure SSH | ||||
|           mkdir -p ~/.ssh | ||||
|           cat <<EOF >> ~/.ssh/config | ||||
|           Host jkci | ||||
|             HostName $CI_HOST | ||||
|             User $CI_USER | ||||
|             StrictHostKeyChecking no | ||||
|             UserKnownHostsFile /dev/null | ||||
|             IdentityFile $SSH_PRIVATE_KEY | ||||
|           EOF | ||||
|         env: | ||||
|           CI_USER: ${{ vars.JETKVM_CI_USER }} | ||||
|           CI_HOST: ${{ vars.JETKVM_CI_HOST }} | ||||
|           CI_SSH_PRIVATE: ${{ secrets.JETKVM_CI_SSH_PRIVATE }} | ||||
|       - name: Run tests | ||||
|         run: | | ||||
|           set -e | ||||
|           echo "+ Copying device-tests.tar.gz to remote host" | ||||
|           ssh jkci "cat > /tmp/device-tests.tar.gz" < device-tests.tar.gz | ||||
|           echo "+ Running go tests" | ||||
|           ssh jkci ash << 'EOF' | ||||
|           set -e | ||||
|           TMP_DIR=$(mktemp -d) | ||||
|           cd ${TMP_DIR} | ||||
|           tar zxf /tmp/device-tests.tar.gz | ||||
|           ./gotestsum --format=testdox \ | ||||
|               --jsonfile=/tmp/device-tests.json \ | ||||
|               --post-run-command 'sh -c "echo $TESTS_FAILED > /tmp/device-tests.failed"' \ | ||||
|               --raw-command -- ./run_all_tests -json | ||||
| 
 | ||||
|           GOTESTSUM_EXIT_CODE=$? | ||||
|           if [ $GOTESTSUM_EXIT_CODE -ne 0 ]; then | ||||
|               echo "❌ Tests failed (exit code: $GOTESTSUM_EXIT_CODE)" | ||||
|               rm -rf ${TMP_DIR} /tmp/device-tests.tar.gz | ||||
|               exit 1 | ||||
|           fi | ||||
| 
 | ||||
|           TESTS_FAILED=$(cat /tmp/device-tests.failed) | ||||
|           if [ "$TESTS_FAILED" -ne 0 ]; then | ||||
|               echo "❌ Tests failed $TESTS_FAILED tests failed" | ||||
|               rm -rf ${TMP_DIR} /tmp/device-tests.tar.gz | ||||
|               exit 1 | ||||
|           fi | ||||
| 
 | ||||
|           echo "✅ Tests passed" | ||||
|           rm -rf ${TMP_DIR} /tmp/device-tests.tar.gz | ||||
|           EOF | ||||
|           ssh jkci "cat /tmp/device-tests.json" > device-tests.json | ||||
|       - name: Set up Golang | ||||
|         uses: actions/setup-go@v5.5.0 | ||||
|         with: | ||||
|           go-version: "1.24.4" | ||||
|       - name: Golang Test Report | ||||
|         uses: becheran/go-testreport@v0.3.2 | ||||
|         with: | ||||
|           input: "device-tests.json" | ||||
|       - name: Deploy application | ||||
|         run: | | ||||
|           set -e | ||||
|           # Copy the binary to the remote host | ||||
|           echo "+ Copying the application to the remote host" | ||||
|           cat bin/jetkvm_app | gzip | ssh jkci "cat > /userdata/jetkvm/jetkvm_app.update.gz" | ||||
|           # Deploy and run the application on the remote host | ||||
|           echo "+ Deploying the application on the remote host" | ||||
|           ssh jkci ash <<EOF | ||||
|           # Extract the binary | ||||
|           gzip -d /userdata/jetkvm/jetkvm_app.update.gz | ||||
|           # Flush filesystem buffers to ensure all data is written to disk | ||||
|           sync | ||||
|           # Clear the filesystem caches to force a read from disk | ||||
|           echo 1 > /proc/sys/vm/drop_caches | ||||
|           # Reboot the application | ||||
|           reboot -d 5 -f & | ||||
|           EOF | ||||
|           sleep 10 | ||||
|           echo "Deployment complete, waiting for JetKVM to come back online " | ||||
|           function check_online() { | ||||
|             for i in {1..60}; do | ||||
|                 if ping -c1 -w1 -W1 -q $CI_HOST >/dev/null; then | ||||
|                     echo "JetKVM is back online" | ||||
|                     return 0 | ||||
|                 fi | ||||
|                 echo -n "." | ||||
|                 sleep 1 | ||||
|             done | ||||
|             echo "JetKVM did not come back online within 60 seconds" | ||||
|             return 1 | ||||
|           } | ||||
|           check_online | ||||
|         env: | ||||
|           CI_HOST: ${{ vars.JETKVM_CI_HOST }} | ||||
|       - name: Run smoke tests | ||||
|         run: | | ||||
|           echo "+ Checking the status of the device" | ||||
|           curl -v http://$CI_HOST/device/status && echo | ||||
|           echo "+ Waiting for 15 seconds to allow all services to start" | ||||
|           sleep 15 | ||||
|           echo "+ Collecting logs" | ||||
|           local_log_tar=$(mktemp) | ||||
|           ssh jkci ash > $local_log_tar <<'EOF' | ||||
|           log_path=$(mktemp -d) | ||||
|           dmesg > $log_path/dmesg.log | ||||
|           cp /userdata/jetkvm/last.log $log_path/last.log | ||||
|           tar -czf - -C $log_path . | ||||
|           EOF | ||||
|           tar -xf $local_log_tar | ||||
|           cat dmesg.log last.log | ||||
|         env: | ||||
|           CI_HOST: ${{ vars.JETKVM_CI_HOST }} | ||||
|       - name: Upload logs | ||||
|         uses: actions/upload-artifact@v4 | ||||
|         with: | ||||
|           name: device-logs | ||||
|           path: | | ||||
|             last.log | ||||
|             dmesg.log | ||||
|             device-tests.json | ||||
|  | @ -0,0 +1,34 @@ | |||
| --- | ||||
| name: ui-lint | ||||
| on: | ||||
|   push: | ||||
|     paths: | ||||
|       - "ui/**" | ||||
|       - "package.json" | ||||
|       - "package-lock.json" | ||||
|       - ".github/workflows/ui-lint.yml" | ||||
| 
 | ||||
| permissions: | ||||
|   contents: read | ||||
| 
 | ||||
| jobs: | ||||
|   ui-lint: | ||||
|     name: UI Lint | ||||
|     runs-on: ubuntu-latest | ||||
|     steps: | ||||
|       - name: Checkout repository | ||||
|         uses: actions/checkout@v5 | ||||
|       - name: Set up Node.js | ||||
|         uses: actions/setup-node@v4 | ||||
|         with: | ||||
|           node-version: "22" | ||||
|           cache: "npm" | ||||
|           cache-dependency-path: "**/package-lock.json" | ||||
|       - name: Install dependencies | ||||
|         run: | | ||||
|           cd ui | ||||
|           npm ci | ||||
|       - name: Lint UI | ||||
|         run: | | ||||
|           cd ui | ||||
|           npm run lint | ||||
|  | @ -1,3 +1,6 @@ | |||
| bin/* | ||||
| static/* | ||||
| .idea | ||||
| .DS_Store | ||||
| 
 | ||||
| device-tests.tar.gz | ||||
|  | @ -1,12 +1,44 @@ | |||
| --- | ||||
| version: "2" | ||||
| linters: | ||||
|   enable: | ||||
|   # - goimports | ||||
|   # - misspell | ||||
|   # - revive | ||||
| 
 | ||||
| issues: | ||||
|   exclude-rules: | ||||
|     - path: _test.go | ||||
|       linters: | ||||
|         - errcheck | ||||
|     - forbidigo | ||||
|     - misspell | ||||
|     - whitespace | ||||
|     - gochecknoinits | ||||
|   settings: | ||||
|     forbidigo: | ||||
|       forbid: | ||||
|         - pattern: ^fmt\.Print.*$ | ||||
|           msg: Do not commit print statements. Use logger package. | ||||
|         - pattern: ^log\.(Fatal|Panic|Print)(f|ln)?.*$ | ||||
|           msg: Do not commit log statements. Use logger package. | ||||
|   exclusions: | ||||
|     generated: lax | ||||
|     presets: | ||||
|       - comments | ||||
|       - common-false-positives | ||||
|       - legacy | ||||
|       - std-error-handling | ||||
|     rules: | ||||
|       - linters: | ||||
|           - errcheck | ||||
|         path: _test.go | ||||
|       - linters: | ||||
|           - forbidigo | ||||
|         path: cmd/main.go | ||||
|       - linters: | ||||
|           - gochecknoinits | ||||
|         path: internal/logging/sse.go | ||||
|     paths: | ||||
|       - third_party$ | ||||
|       - builtin$ | ||||
|       - examples$ | ||||
| formatters: | ||||
|   enable: | ||||
|     - goimports | ||||
|   exclusions: | ||||
|     generated: lax | ||||
|     paths: | ||||
|       - third_party$ | ||||
|       - builtin$ | ||||
|       - examples$ | ||||
|  |  | |||
|  | @ -0,0 +1,3 @@ | |||
| { | ||||
|   "tailwindCSS.classFunctions": ["cva", "cx"] | ||||
| } | ||||
|  | @ -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). | ||||
							
								
								
									
										77
									
								
								Makefile
								
								
								
								
							
							
						
						
									
										77
									
								
								Makefile
								
								
								
								
							|  | @ -1,13 +1,15 @@ | |||
| BRANCH    ?= $(shell git rev-parse --abbrev-ref HEAD) | ||||
| BUILDDATE ?= $(shell date -u +%FT%T%z) | ||||
| BUILDTS   ?= $(shell date -u +%s) | ||||
| REVISION  ?= $(shell git rev-parse HEAD) | ||||
| VERSION_DEV := 0.3.9-dev$(shell date +%Y%m%d%H%M) | ||||
| VERSION := 0.3.8 | ||||
| BRANCH    := $(shell git rev-parse --abbrev-ref HEAD) | ||||
| BUILDDATE := $(shell date -u +%FT%T%z) | ||||
| BUILDTS   := $(shell date -u +%s) | ||||
| REVISION  := $(shell git rev-parse HEAD) | ||||
| VERSION_DEV := 0.4.9-dev$(shell date +%Y%m%d%H%M) | ||||
| VERSION := 0.4.8 | ||||
| 
 | ||||
| PROMETHEUS_TAG := github.com/prometheus/common/version | ||||
| KVM_PKG_NAME := github.com/jetkvm/kvm | ||||
| 
 | ||||
| GO_BUILD_ARGS := -tags netgo -tags timetzdata | ||||
| GO_RELEASE_BUILD_ARGS := -trimpath $(GO_BUILD_ARGS) | ||||
| GO_LDFLAGS := \
 | ||||
|   -s -w \
 | ||||
|   -X $(PROMETHEUS_TAG).Branch=$(BRANCH) \
 | ||||
|  | @ -15,25 +17,80 @@ GO_LDFLAGS := \ | |||
|   -X $(PROMETHEUS_TAG).Revision=$(REVISION) \
 | ||||
|   -X $(KVM_PKG_NAME).builtTimestamp=$(BUILDTS) | ||||
| 
 | ||||
| GO_CMD := GOOS=linux GOARCH=arm GOARM=7 go | ||||
| BIN_DIR := $(shell pwd)/bin | ||||
| 
 | ||||
| TEST_DIRS := $(shell find . -name "*_test.go" -type f -exec dirname {} \; | sort -u) | ||||
| 
 | ||||
| hash_resource: | ||||
| 	@shasum -a 256 resource/jetkvm_native | cut -d ' ' -f 1 > resource/jetkvm_native.sha256 | ||||
| 
 | ||||
| build_dev: hash_resource | ||||
| 	@echo "Building..." | ||||
| 	GOOS=linux GOARCH=arm GOARM=7 go build -ldflags="$(GO_LDFLAGS) -X $(KVM_PKG_NAME).builtAppVersion=$(VERSION_DEV)" -o bin/jetkvm_app cmd/main.go | ||||
| 	$(GO_CMD) build \
 | ||||
| 		-ldflags="$(GO_LDFLAGS) -X $(KVM_PKG_NAME).builtAppVersion=$(VERSION_DEV)" \
 | ||||
| 		$(GO_RELEASE_BUILD_ARGS) \
 | ||||
| 		-o $(BIN_DIR)/jetkvm_app cmd/main.go | ||||
| 
 | ||||
| build_test2json: | ||||
| 	$(GO_CMD) build -o $(BIN_DIR)/test2json cmd/test2json | ||||
| 
 | ||||
| build_gotestsum: | ||||
| 	@echo "Building gotestsum..." | ||||
| 	$(GO_CMD) install gotest.tools/gotestsum@latest | ||||
| 	cp $(shell $(GO_CMD) env GOPATH)/bin/linux_arm/gotestsum $(BIN_DIR)/gotestsum | ||||
| 
 | ||||
| build_dev_test: build_test2json build_gotestsum | ||||
| # collect all directories that contain tests
 | ||||
| 	@echo "Building tests for devices ..." | ||||
| 	@rm -rf $(BIN_DIR)/tests && mkdir -p $(BIN_DIR)/tests | ||||
| 
 | ||||
| 	@cat resource/dev_test.sh > $(BIN_DIR)/tests/run_all_tests | ||||
| 	@for test in $(TEST_DIRS); do \
 | ||||
| 		test_pkg_name=$$(echo $$test | sed 's/^.\///g'); \
 | ||||
| 		test_pkg_full_name=$(KVM_PKG_NAME)/$$(echo $$test | sed 's/^.\///g'); \
 | ||||
| 		test_filename=$$(echo $$test_pkg_name | sed 's/\//__/g')_test; \
 | ||||
| 		$(GO_CMD) test -v \
 | ||||
| 			-ldflags="$(GO_LDFLAGS) -X $(KVM_PKG_NAME).builtAppVersion=$(VERSION_DEV)" \
 | ||||
| 			$(GO_BUILD_ARGS) \
 | ||||
| 			-c -o $(BIN_DIR)/tests/$$test_filename $$test; \
 | ||||
| 		echo "runTest ./$$test_filename $$test_pkg_full_name" >> $(BIN_DIR)/tests/run_all_tests; \
 | ||||
| 	done; \
 | ||||
| 	chmod +x $(BIN_DIR)/tests/run_all_tests; \
 | ||||
| 	cp $(BIN_DIR)/test2json $(BIN_DIR)/tests/ && chmod +x $(BIN_DIR)/tests/test2json; \
 | ||||
| 	cp $(BIN_DIR)/gotestsum $(BIN_DIR)/tests/ && chmod +x $(BIN_DIR)/tests/gotestsum; \
 | ||||
| 	tar czfv device-tests.tar.gz -C $(BIN_DIR)/tests . | ||||
| 
 | ||||
| frontend: | ||||
| 	cd ui && npm ci && npm run build:device | ||||
| 	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 | ||||
| 	@echo "Uploading release..." | ||||
| 	@echo "Uploading release... $(VERSION_DEV)" | ||||
| 	@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.sha256 r2://jetkvm-update/app/$(VERSION_DEV)/jetkvm_app.sha256 | ||||
| 
 | ||||
| build_release: frontend hash_resource | ||||
| 	@echo "Building release..." | ||||
| 	GOOS=linux GOARCH=arm GOARM=7 go build -ldflags="$(GO_LDFLAGS) -X $(KVM_PKG_NAME).builtAppVersion=$(VERSION)" -o bin/jetkvm_app cmd/main.go | ||||
| 	$(GO_CMD) build \
 | ||||
| 		-ldflags="$(GO_LDFLAGS) -X $(KVM_PKG_NAME).builtAppVersion=$(VERSION)" \
 | ||||
| 		$(GO_RELEASE_BUILD_ARGS) \
 | ||||
| 		-o bin/jetkvm_app cmd/main.go | ||||
| 
 | ||||
| release: | ||||
| 	@if rclone lsf r2://jetkvm-update/app/$(VERSION)/ | grep -q "jetkvm_app"; then \
 | ||||
|  |  | |||
|  | @ -7,6 +7,8 @@ | |||
| 
 | ||||
| [](https://twitter.com/jetkvm) | ||||
| 
 | ||||
| [](https://goreportcard.com/report/github.com/jetkvm/kvm) | ||||
| 
 | ||||
| </div> | ||||
| 
 | ||||
| JetKVM is a high-performance, open-source KVM over IP (Keyboard, Video, Mouse) solution designed for efficient remote management of computers, servers, and workstations. Whether you're dealing with boot failures, installing a new operating system, adjusting BIOS settings, or simply taking control of a machine from afar, JetKVM provides the tools to get it done effectively. | ||||
|  | @ -23,7 +25,7 @@ We welcome contributions from the community! Whether it's improving the firmware | |||
| 
 | ||||
| ## I need help | ||||
| 
 | ||||
| The best place to search for answers is our [Documentation](https://jetkvm.com/docs). If you can't find the answer there, check our [Discord Server](https://discord.gg/8MaAhua7NW). | ||||
| The best place to search for answers is our [Documentation](https://jetkvm.com/docs). If you can't find the answer there, check our [Discord Server](https://jetkvm.com/discord). | ||||
| 
 | ||||
| ## I want to report an issue | ||||
| 
 | ||||
|  | @ -35,7 +37,9 @@ JetKVM is written in Go & TypeScript. with some bits and pieces written in C. An | |||
| 
 | ||||
| The project contains two main parts, the backend software that runs on the KVM device and the frontend software that is served by the KVM device, and also the cloud. | ||||
| 
 | ||||
| For most of local device development, all you need is to use the `./dev_deploy.sh` script. It will build the frontend and backend and deploy them to the local KVM device. Run `./dev_deploy.sh --help` for more information. | ||||
| For comprehensive development information, including setup, testing, debugging, and contribution guidelines, see **[DEVELOPMENT.md](DEVELOPMENT.md)**. | ||||
| 
 | ||||
| For quick device development, use the `./dev_deploy.sh` script. It will build the frontend and backend and deploy them to the local KVM device. Run `./dev_deploy.sh --help` for more information. | ||||
| 
 | ||||
| ## Backend | ||||
| 
 | ||||
|  |  | |||
|  | @ -7,8 +7,8 @@ import ( | |||
| 	"os" | ||||
| 	"time" | ||||
| 
 | ||||
| 	"github.com/pojntfx/go-nbd/pkg/client" | ||||
| 	"github.com/pojntfx/go-nbd/pkg/server" | ||||
| 	"github.com/rs/zerolog" | ||||
| ) | ||||
| 
 | ||||
| type remoteImageBackend struct { | ||||
|  | @ -16,33 +16,21 @@ type remoteImageBackend struct { | |||
| 
 | ||||
| func (r remoteImageBackend) ReadAt(p []byte, off int64) (n int, err error) { | ||||
| 	virtualMediaStateMutex.RLock() | ||||
| 	logger.Debugf("currentVirtualMediaState is %v", currentVirtualMediaState) | ||||
| 	logger.Debugf("read size: %d, off: %d", len(p), off) | ||||
| 	logger.Debug().Interface("currentVirtualMediaState", currentVirtualMediaState).Msg("currentVirtualMediaState") | ||||
| 	logger.Debug().Int64("read size", int64(len(p))).Int64("off", off).Msg("read size and off") | ||||
| 	if currentVirtualMediaState == nil { | ||||
| 		return 0, errors.New("image not mounted") | ||||
| 	} | ||||
| 	source := currentVirtualMediaState.Source | ||||
| 	mountedImageSize := currentVirtualMediaState.Size | ||||
| 	virtualMediaStateMutex.RUnlock() | ||||
| 
 | ||||
| 	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) | ||||
| 	_, cancel := context.WithTimeout(context.Background(), 5*time.Second) | ||||
| 	defer cancel() | ||||
| 
 | ||||
| 	readLen := int64(len(p)) | ||||
| 	if off+readLen > mountedImageSize { | ||||
| 		readLen = mountedImageSize - off | ||||
| 	} | ||||
| 	var data []byte | ||||
| 	if source == WebRTC { | ||||
| 		data, err = webRTCDiskReader.Read(ctx, off, readLen) | ||||
| 		if err != nil { | ||||
| 			return 0, err | ||||
| 		} | ||||
| 		n = copy(p, data) | ||||
| 		return n, nil | ||||
| 	} else if source == HTTP { | ||||
| 	switch source { | ||||
| 	case HTTP: | ||||
| 		return httpRangeReader.ReadAt(p, off) | ||||
| 	} else { | ||||
| 	default: | ||||
| 		return 0, errors.New("unknown image source") | ||||
| 	} | ||||
| } | ||||
|  | @ -72,6 +60,8 @@ type NBDDevice struct { | |||
| 	serverConn net.Conn | ||||
| 	clientConn net.Conn | ||||
| 	dev        *os.File | ||||
| 
 | ||||
| 	l *zerolog.Logger | ||||
| } | ||||
| 
 | ||||
| func NewNBDDevice() *NBDDevice { | ||||
|  | @ -90,10 +80,18 @@ func (d *NBDDevice) Start() error { | |||
| 		return err | ||||
| 	} | ||||
| 
 | ||||
| 	if d.l == nil { | ||||
| 		scopedLogger := nbdLogger.With(). | ||||
| 			Str("socket_path", nbdSocketPath). | ||||
| 			Str("device_path", nbdDevicePath). | ||||
| 			Logger() | ||||
| 		d.l = &scopedLogger | ||||
| 	} | ||||
| 
 | ||||
| 	// Remove the socket file if it already exists
 | ||||
| 	if _, err := os.Stat(nbdSocketPath); err == nil { | ||||
| 		if err := os.Remove(nbdSocketPath); err != nil { | ||||
| 			logger.Errorf("Failed to remove existing socket file %s: %v", nbdSocketPath, err) | ||||
| 			d.l.Error().Err(err).Msg("failed to remove existing socket file") | ||||
| 			os.Exit(1) | ||||
| 		} | ||||
| 	} | ||||
|  | @ -134,32 +132,6 @@ func (d *NBDDevice) runServerConn() { | |||
| 			MaximumBlockSize:   uint32(16 * 1024), | ||||
| 			SupportsMultiConn:  false, | ||||
| 		}) | ||||
| 	logger.Infof("nbd server exited: %v", err) | ||||
| } | ||||
| 
 | ||||
| func (d *NBDDevice) runClientConn() { | ||||
| 	err := client.Connect(d.clientConn, d.dev, &client.Options{ | ||||
| 		ExportName: "jetkvm", | ||||
| 		BlockSize:  uint32(4 * 1024), | ||||
| 	}) | ||||
| 	logger.Infof("nbd client exited: %v", err) | ||||
| } | ||||
| 
 | ||||
| func (d *NBDDevice) Close() { | ||||
| 	if d.dev != nil { | ||||
| 		err := client.Disconnect(d.dev) | ||||
| 		if err != nil { | ||||
| 			logger.Warnf("error disconnecting nbd client: %v", err) | ||||
| 		} | ||||
| 		_ = d.dev.Close() | ||||
| 	} | ||||
| 	if d.listener != nil { | ||||
| 		_ = d.listener.Close() | ||||
| 	} | ||||
| 	if d.clientConn != nil { | ||||
| 		_ = d.clientConn.Close() | ||||
| 	} | ||||
| 	if d.serverConn != nil { | ||||
| 		_ = d.serverConn.Close() | ||||
| 	} | ||||
| 	d.l.Info().Err(err).Msg("nbd server exited") | ||||
| } | ||||
|  |  | |||
|  | @ -0,0 +1,34 @@ | |||
| //go:build linux
 | ||||
| 
 | ||||
| package kvm | ||||
| 
 | ||||
| import ( | ||||
| 	"github.com/pojntfx/go-nbd/pkg/client" | ||||
| ) | ||||
| 
 | ||||
| func (d *NBDDevice) runClientConn() { | ||||
| 	err := client.Connect(d.clientConn, d.dev, &client.Options{ | ||||
| 		ExportName: "jetkvm", | ||||
| 		BlockSize:  uint32(4 * 1024), | ||||
| 	}) | ||||
| 	d.l.Info().Err(err).Msg("nbd client exited") | ||||
| } | ||||
| 
 | ||||
| func (d *NBDDevice) Close() { | ||||
| 	if d.dev != nil { | ||||
| 		err := client.Disconnect(d.dev) | ||||
| 		if err != nil { | ||||
| 			d.l.Warn().Err(err).Msg("error disconnecting nbd client") | ||||
| 		} | ||||
| 		_ = d.dev.Close() | ||||
| 	} | ||||
| 	if d.listener != nil { | ||||
| 		_ = d.listener.Close() | ||||
| 	} | ||||
| 	if d.clientConn != nil { | ||||
| 		_ = d.clientConn.Close() | ||||
| 	} | ||||
| 	if d.serverConn != nil { | ||||
| 		_ = d.serverConn.Close() | ||||
| 	} | ||||
| } | ||||
|  | @ -0,0 +1,17 @@ | |||
| //go:build !linux
 | ||||
| 
 | ||||
| package kvm | ||||
| 
 | ||||
| import ( | ||||
| 	"os" | ||||
| ) | ||||
| 
 | ||||
| func (d *NBDDevice) runClientConn() { | ||||
| 	d.l.Error().Msg("platform not supported") | ||||
| 	os.Exit(1) | ||||
| } | ||||
| 
 | ||||
| func (d *NBDDevice) Close() { | ||||
| 	d.l.Error().Msg("platform not supported") | ||||
| 	os.Exit(1) | ||||
| } | ||||
							
								
								
									
										366
									
								
								cloud.go
								
								
								
								
							
							
						
						
									
										366
									
								
								cloud.go
								
								
								
								
							|  | @ -4,17 +4,23 @@ import ( | |||
| 	"bytes" | ||||
| 	"context" | ||||
| 	"encoding/json" | ||||
| 	"errors" | ||||
| 	"fmt" | ||||
| 	"net/http" | ||||
| 	"net/url" | ||||
| 	"sync" | ||||
| 	"time" | ||||
| 
 | ||||
| 	"github.com/coder/websocket/wsjson" | ||||
| 	"github.com/google/uuid" | ||||
| 	"github.com/prometheus/client_golang/prometheus" | ||||
| 	"github.com/prometheus/client_golang/prometheus/promauto" | ||||
| 
 | ||||
| 	"github.com/coreos/go-oidc/v3/oidc" | ||||
| 
 | ||||
| 	"github.com/coder/websocket" | ||||
| 	"github.com/gin-gonic/gin" | ||||
| 	"github.com/rs/zerolog" | ||||
| ) | ||||
| 
 | ||||
| type CloudRegisterRequest struct { | ||||
|  | @ -32,10 +38,163 @@ const ( | |||
| 	// CloudOidcRequestTimeout is the timeout for OIDC token verification requests
 | ||||
| 	// should be lower than the websocket response timeout set in cloud-api
 | ||||
| 	CloudOidcRequestTimeout = 10 * time.Second | ||||
| 	// CloudWebSocketPingInterval is the interval at which the websocket client sends ping messages to the cloud
 | ||||
| 	CloudWebSocketPingInterval = 15 * time.Second | ||||
| 	// WebsocketPingInterval is the interval at which the websocket client sends ping messages to the cloud
 | ||||
| 	WebsocketPingInterval = 15 * time.Second | ||||
| ) | ||||
| 
 | ||||
| var ( | ||||
| 	metricCloudConnectionStatus = promauto.NewGauge( | ||||
| 		prometheus.GaugeOpts{ | ||||
| 			Name: "jetkvm_cloud_connection_status", | ||||
| 			Help: "The status of the cloud connection", | ||||
| 		}, | ||||
| 	) | ||||
| 	metricCloudConnectionEstablishedTimestamp = promauto.NewGauge( | ||||
| 		prometheus.GaugeOpts{ | ||||
| 			Name: "jetkvm_cloud_connection_established_timestamp_seconds", | ||||
| 			Help: "The timestamp when the cloud connection was established", | ||||
| 		}, | ||||
| 	) | ||||
| 	metricConnectionLastPingTimestamp = promauto.NewGaugeVec( | ||||
| 		prometheus.GaugeOpts{ | ||||
| 			Name: "jetkvm_connection_last_ping_timestamp_seconds", | ||||
| 			Help: "The timestamp when the last ping response was received", | ||||
| 		}, | ||||
| 		[]string{"type", "source"}, | ||||
| 	) | ||||
| 	metricConnectionLastPingReceivedTimestamp = promauto.NewGaugeVec( | ||||
| 		prometheus.GaugeOpts{ | ||||
| 			Name: "jetkvm_connection_last_ping_received_timestamp_seconds", | ||||
| 			Help: "The timestamp when the last ping request was received", | ||||
| 		}, | ||||
| 		[]string{"type", "source"}, | ||||
| 	) | ||||
| 	metricConnectionLastPingDuration = promauto.NewGaugeVec( | ||||
| 		prometheus.GaugeOpts{ | ||||
| 			Name: "jetkvm_connection_last_ping_duration_seconds", | ||||
| 			Help: "The duration of the last ping response", | ||||
| 		}, | ||||
| 		[]string{"type", "source"}, | ||||
| 	) | ||||
| 	metricConnectionPingDuration = promauto.NewHistogramVec( | ||||
| 		prometheus.HistogramOpts{ | ||||
| 			Name: "jetkvm_connection_ping_duration_seconds", | ||||
| 			Help: "The duration of the ping response", | ||||
| 			Buckets: []float64{ | ||||
| 				0.1, 0.5, 1, 10, | ||||
| 			}, | ||||
| 		}, | ||||
| 		[]string{"type", "source"}, | ||||
| 	) | ||||
| 	metricConnectionTotalPingSentCount = promauto.NewCounterVec( | ||||
| 		prometheus.CounterOpts{ | ||||
| 			Name: "jetkvm_connection_ping_sent_total", | ||||
| 			Help: "The total number of pings sent to the connection", | ||||
| 		}, | ||||
| 		[]string{"type", "source"}, | ||||
| 	) | ||||
| 	metricConnectionTotalPingReceivedCount = promauto.NewCounterVec( | ||||
| 		prometheus.CounterOpts{ | ||||
| 			Name: "jetkvm_connection_ping_received_total", | ||||
| 			Help: "The total number of pings received from the connection", | ||||
| 		}, | ||||
| 		[]string{"type", "source"}, | ||||
| 	) | ||||
| 	metricConnectionSessionRequestCount = promauto.NewCounterVec( | ||||
| 		prometheus.CounterOpts{ | ||||
| 			Name: "jetkvm_connection_session_requests_total", | ||||
| 			Help: "The total number of session requests received", | ||||
| 		}, | ||||
| 		[]string{"type", "source"}, | ||||
| 	) | ||||
| 	metricConnectionSessionRequestDuration = promauto.NewHistogramVec( | ||||
| 		prometheus.HistogramOpts{ | ||||
| 			Name: "jetkvm_connection_session_request_duration_seconds", | ||||
| 			Help: "The duration of session requests", | ||||
| 			Buckets: []float64{ | ||||
| 				0.1, 0.5, 1, 10, | ||||
| 			}, | ||||
| 		}, | ||||
| 		[]string{"type", "source"}, | ||||
| 	) | ||||
| 	metricConnectionLastSessionRequestTimestamp = promauto.NewGaugeVec( | ||||
| 		prometheus.GaugeOpts{ | ||||
| 			Name: "jetkvm_connection_last_session_request_timestamp_seconds", | ||||
| 			Help: "The timestamp of the last session request", | ||||
| 		}, | ||||
| 		[]string{"type", "source"}, | ||||
| 	) | ||||
| 	metricConnectionLastSessionRequestDuration = promauto.NewGaugeVec( | ||||
| 		prometheus.GaugeOpts{ | ||||
| 			Name: "jetkvm_connection_last_session_request_duration", | ||||
| 			Help: "The duration of the last session request", | ||||
| 		}, | ||||
| 		[]string{"type", "source"}, | ||||
| 	) | ||||
| 	metricCloudConnectionFailureCount = promauto.NewCounter( | ||||
| 		prometheus.CounterOpts{ | ||||
| 			Name: "jetkvm_cloud_connection_failure_total", | ||||
| 			Help: "The number of times the cloud connection has failed", | ||||
| 		}, | ||||
| 	) | ||||
| ) | ||||
| 
 | ||||
| type CloudConnectionState uint8 | ||||
| 
 | ||||
| const ( | ||||
| 	CloudConnectionStateNotConfigured CloudConnectionState = iota | ||||
| 	CloudConnectionStateDisconnected | ||||
| 	CloudConnectionStateConnecting | ||||
| 	CloudConnectionStateConnected | ||||
| ) | ||||
| 
 | ||||
| var ( | ||||
| 	cloudConnectionState     CloudConnectionState = CloudConnectionStateNotConfigured | ||||
| 	cloudConnectionStateLock                      = &sync.Mutex{} | ||||
| 
 | ||||
| 	cloudDisconnectChan chan error | ||||
| 	cloudDisconnectLock = &sync.Mutex{} | ||||
| ) | ||||
| 
 | ||||
| func setCloudConnectionState(state CloudConnectionState) { | ||||
| 	cloudConnectionStateLock.Lock() | ||||
| 	defer cloudConnectionStateLock.Unlock() | ||||
| 
 | ||||
| 	if cloudConnectionState == CloudConnectionStateDisconnected && | ||||
| 		(config.CloudToken == "" || config.CloudURL == "") { | ||||
| 		state = CloudConnectionStateNotConfigured | ||||
| 	} | ||||
| 
 | ||||
| 	previousState := cloudConnectionState | ||||
| 	cloudConnectionState = state | ||||
| 
 | ||||
| 	go waitCtrlAndRequestDisplayUpdate( | ||||
| 		previousState != state, | ||||
| 	) | ||||
| } | ||||
| 
 | ||||
| func wsResetMetrics(established bool, sourceType string, source string) { | ||||
| 	metricConnectionLastPingTimestamp.WithLabelValues(sourceType, source).Set(-1) | ||||
| 	metricConnectionLastPingDuration.WithLabelValues(sourceType, source).Set(-1) | ||||
| 
 | ||||
| 	metricConnectionLastPingReceivedTimestamp.WithLabelValues(sourceType, source).Set(-1) | ||||
| 
 | ||||
| 	metricConnectionLastSessionRequestTimestamp.WithLabelValues(sourceType, source).Set(-1) | ||||
| 	metricConnectionLastSessionRequestDuration.WithLabelValues(sourceType, source).Set(-1) | ||||
| 
 | ||||
| 	if sourceType != "cloud" { | ||||
| 		return | ||||
| 	} | ||||
| 
 | ||||
| 	if established { | ||||
| 		metricCloudConnectionEstablishedTimestamp.SetToCurrentTime() | ||||
| 		metricCloudConnectionStatus.Set(1) | ||||
| 	} else { | ||||
| 		metricCloudConnectionEstablishedTimestamp.Set(-1) | ||||
| 		metricCloudConnectionStatus.Set(-1) | ||||
| 	} | ||||
| } | ||||
| 
 | ||||
| func handleCloudRegister(c *gin.Context) { | ||||
| 	var req CloudRegisterRequest | ||||
| 
 | ||||
|  | @ -90,11 +249,6 @@ func handleCloudRegister(c *gin.Context) { | |||
| 		return | ||||
| 	} | ||||
| 
 | ||||
| 	if config.CloudToken == "" { | ||||
| 		cloudLogger.Info("Starting websocket client due to adoption") | ||||
| 		go RunWebsocketClient() | ||||
| 	} | ||||
| 
 | ||||
| 	config.CloudToken = tokenResp.SecretToken | ||||
| 
 | ||||
| 	provider, err := oidc.NewProvider(c, "https://accounts.google.com") | ||||
|  | @ -125,74 +279,116 @@ func handleCloudRegister(c *gin.Context) { | |||
| 	c.JSON(200, gin.H{"message": "Cloud registration successful"}) | ||||
| } | ||||
| 
 | ||||
| func disconnectCloud(reason error) { | ||||
| 	cloudDisconnectLock.Lock() | ||||
| 	defer cloudDisconnectLock.Unlock() | ||||
| 
 | ||||
| 	if cloudDisconnectChan == nil { | ||||
| 		cloudLogger.Trace().Msg("cloud disconnect channel is not set, no need to disconnect") | ||||
| 		return | ||||
| 	} | ||||
| 
 | ||||
| 	// just in case the channel is closed, we don't want to panic
 | ||||
| 	defer func() { | ||||
| 		if r := recover(); r != nil { | ||||
| 			cloudLogger.Warn().Interface("reason", r).Msg("cloud disconnect channel is closed, no need to disconnect") | ||||
| 		} | ||||
| 	}() | ||||
| 	cloudDisconnectChan <- reason | ||||
| } | ||||
| 
 | ||||
| func runWebsocketClient() error { | ||||
| 	if config.CloudToken == "" { | ||||
| 		time.Sleep(5 * time.Second) | ||||
| 		return fmt.Errorf("cloud token is not set") | ||||
| 	} | ||||
| 
 | ||||
| 	wsURL, err := url.Parse(config.CloudURL) | ||||
| 	if err != nil { | ||||
| 		return fmt.Errorf("failed to parse config.CloudURL: %w", err) | ||||
| 	} | ||||
| 
 | ||||
| 	if wsURL.Scheme == "http" { | ||||
| 		wsURL.Scheme = "ws" | ||||
| 	} else { | ||||
| 		wsURL.Scheme = "wss" | ||||
| 	} | ||||
| 
 | ||||
| 	setCloudConnectionState(CloudConnectionStateConnecting) | ||||
| 
 | ||||
| 	header := http.Header{} | ||||
| 	header.Set("X-Device-ID", GetDeviceID()) | ||||
| 	header.Set("X-App-Version", builtAppVersion) | ||||
| 	header.Set("Authorization", "Bearer "+config.CloudToken) | ||||
| 	dialCtx, cancelDial := context.WithTimeout(context.Background(), CloudWebSocketConnectTimeout) | ||||
| 
 | ||||
| 	l := websocketLogger.With(). | ||||
| 		Str("source", wsURL.Host). | ||||
| 		Str("sourceType", "cloud"). | ||||
| 		Logger() | ||||
| 
 | ||||
| 	scopedLogger := &l | ||||
| 
 | ||||
| 	defer cancelDial() | ||||
| 	c, _, err := websocket.Dial(dialCtx, wsURL.String(), &websocket.DialOptions{ | ||||
| 	c, resp, err := websocket.Dial(dialCtx, wsURL.String(), &websocket.DialOptions{ | ||||
| 		HTTPHeader: header, | ||||
| 		OnPingReceived: func(ctx context.Context, payload []byte) bool { | ||||
| 			scopedLogger.Debug().Bytes("payload", payload).Int("length", len(payload)).Msg("ping frame received") | ||||
| 
 | ||||
| 			metricConnectionTotalPingReceivedCount.WithLabelValues("cloud", wsURL.Host).Inc() | ||||
| 			metricConnectionLastPingReceivedTimestamp.WithLabelValues("cloud", wsURL.Host).SetToCurrentTime() | ||||
| 
 | ||||
| 			setCloudConnectionState(CloudConnectionStateConnected) | ||||
| 
 | ||||
| 			return true | ||||
| 		}, | ||||
| 	}) | ||||
| 
 | ||||
| 	var connectionId string | ||||
| 	if resp != nil { | ||||
| 		// get the request id from the response header
 | ||||
| 		connectionId = resp.Header.Get("X-Request-ID") | ||||
| 		if connectionId == "" { | ||||
| 			connectionId = resp.Header.Get("Cf-Ray") | ||||
| 		} | ||||
| 	} | ||||
| 
 | ||||
| 	if connectionId == "" { | ||||
| 		connectionId = uuid.New().String() | ||||
| 		scopedLogger.Warn(). | ||||
| 			Str("connectionId", connectionId). | ||||
| 			Msg("no connection id received from the server, generating a new one") | ||||
| 	} | ||||
| 
 | ||||
| 	lWithConnectionId := scopedLogger.With(). | ||||
| 		Str("connectionID", connectionId). | ||||
| 		Logger() | ||||
| 	scopedLogger = &lWithConnectionId | ||||
| 
 | ||||
| 	// if the context is canceled, we don't want to return an error
 | ||||
| 	if err != nil { | ||||
| 		if errors.Is(err, context.Canceled) { | ||||
| 			cloudLogger.Info().Msg("websocket connection canceled") | ||||
| 			setCloudConnectionState(CloudConnectionStateDisconnected) | ||||
| 
 | ||||
| 			return nil | ||||
| 		} | ||||
| 		return err | ||||
| 	} | ||||
| 	defer c.CloseNow() //nolint:errcheck
 | ||||
| 	cloudLogger.Infof("websocket connected to %s", wsURL) | ||||
| 	runCtx, cancelRun := context.WithCancel(context.Background()) | ||||
| 	defer cancelRun() | ||||
| 	go func() { | ||||
| 		for { | ||||
| 			time.Sleep(CloudWebSocketPingInterval) | ||||
| 			err := c.Ping(runCtx) | ||||
| 			if err != nil { | ||||
| 				cloudLogger.Warnf("websocket ping error: %v", err) | ||||
| 				cancelRun() | ||||
| 				return | ||||
| 			} | ||||
| 		} | ||||
| 	}() | ||||
| 	for { | ||||
| 		typ, msg, err := c.Read(runCtx) | ||||
| 		if err != nil { | ||||
| 			return err | ||||
| 		} | ||||
| 		if typ != websocket.MessageText { | ||||
| 			// ignore non-text messages
 | ||||
| 			continue | ||||
| 		} | ||||
| 		var req WebRTCSessionRequest | ||||
| 		err = json.Unmarshal(msg, &req) | ||||
| 		if err != nil { | ||||
| 			cloudLogger.Warnf("unable to parse ws message: %v", string(msg)) | ||||
| 			continue | ||||
| 		} | ||||
| 	cloudLogger.Info(). | ||||
| 		Str("url", wsURL.String()). | ||||
| 		Str("connectionID", connectionId). | ||||
| 		Msg("websocket connected") | ||||
| 
 | ||||
| 		cloudLogger.Infof("new session request: %v", req.OidcGoogle) | ||||
| 		cloudLogger.Tracef("session request info: %v", req) | ||||
| 	// set the metrics when we successfully connect to the cloud.
 | ||||
| 	wsResetMetrics(true, "cloud", wsURL.Host) | ||||
| 
 | ||||
| 		err = handleSessionRequest(runCtx, c, req) | ||||
| 		if err != nil { | ||||
| 			cloudLogger.Infof("error starting new session: %v", err) | ||||
| 			continue | ||||
| 		} | ||||
| 	} | ||||
| 	// we don't have a source for the cloud connection
 | ||||
| 	return handleWebRTCSignalWsMessages(c, true, wsURL.Host, connectionId, scopedLogger) | ||||
| } | ||||
| 
 | ||||
| func handleSessionRequest(ctx context.Context, c *websocket.Conn, req WebRTCSessionRequest) error { | ||||
| func authenticateSession(ctx context.Context, c *websocket.Conn, req WebRTCSessionRequest) error { | ||||
| 	oidcCtx, cancelOIDC := context.WithTimeout(ctx, CloudOidcRequestTimeout) | ||||
| 	defer cancelOIDC() | ||||
| 	provider, err := oidc.NewProvider(oidcCtx, "https://accounts.google.com") | ||||
|  | @ -200,7 +396,7 @@ func handleSessionRequest(ctx context.Context, c *websocket.Conn, req WebRTCSess | |||
| 		_ = wsjson.Write(context.Background(), c, gin.H{ | ||||
| 			"error": fmt.Sprintf("failed to initialize OIDC provider: %v", err), | ||||
| 		}) | ||||
| 		cloudLogger.Errorf("failed to initialize OIDC provider: %v", err) | ||||
| 		cloudLogger.Warn().Err(err).Msg("failed to initialize OIDC provider") | ||||
| 		return err | ||||
| 	} | ||||
| 
 | ||||
|  | @ -220,10 +416,43 @@ func handleSessionRequest(ctx context.Context, c *websocket.Conn, req WebRTCSess | |||
| 		return fmt.Errorf("google identity mismatch") | ||||
| 	} | ||||
| 
 | ||||
| 	return nil | ||||
| } | ||||
| 
 | ||||
| func handleSessionRequest( | ||||
| 	ctx context.Context, | ||||
| 	c *websocket.Conn, | ||||
| 	req WebRTCSessionRequest, | ||||
| 	isCloudConnection bool, | ||||
| 	source string, | ||||
| 	scopedLogger *zerolog.Logger, | ||||
| ) error { | ||||
| 	var sourceType string | ||||
| 	if isCloudConnection { | ||||
| 		sourceType = "cloud" | ||||
| 	} else { | ||||
| 		sourceType = "local" | ||||
| 	} | ||||
| 
 | ||||
| 	timer := prometheus.NewTimer(prometheus.ObserverFunc(func(v float64) { | ||||
| 		metricConnectionLastSessionRequestDuration.WithLabelValues(sourceType, source).Set(v) | ||||
| 		metricConnectionSessionRequestDuration.WithLabelValues(sourceType, source).Observe(v) | ||||
| 	})) | ||||
| 	defer timer.ObserveDuration() | ||||
| 
 | ||||
| 	// If the message is from the cloud, we need to authenticate the session.
 | ||||
| 	if isCloudConnection { | ||||
| 		if err := authenticateSession(ctx, c, req); err != nil { | ||||
| 			return err | ||||
| 		} | ||||
| 	} | ||||
| 
 | ||||
| 	session, err := newSession(SessionConfig{ | ||||
| 		ICEServers: req.ICEServers, | ||||
| 		ws:         c, | ||||
| 		IsCloud:    isCloudConnection, | ||||
| 		LocalIP:    req.IP, | ||||
| 		IsCloud:    true, | ||||
| 		ICEServers: req.ICEServers, | ||||
| 		Logger:     scopedLogger, | ||||
| 	}) | ||||
| 	if err != nil { | ||||
| 		_ = wsjson.Write(context.Background(), c, gin.H{"error": err}) | ||||
|  | @ -244,18 +473,44 @@ func handleSessionRequest(ctx context.Context, c *websocket.Conn, req WebRTCSess | |||
| 		}() | ||||
| 	} | ||||
| 
 | ||||
| 	cloudLogger.Info("new session accepted") | ||||
| 	cloudLogger.Tracef("new session accepted: %v", session) | ||||
| 	cloudLogger.Info().Interface("session", session).Msg("new session accepted") | ||||
| 	cloudLogger.Trace().Interface("session", session).Msg("new session accepted") | ||||
| 
 | ||||
| 	// Cancel any ongoing keyboard macro when session changes
 | ||||
| 	cancelKeyboardMacro() | ||||
| 
 | ||||
| 	currentSession = session | ||||
| 	_ = wsjson.Write(context.Background(), c, gin.H{"sd": sd}) | ||||
| 	_ = wsjson.Write(context.Background(), c, gin.H{"type": "answer", "data": sd}) | ||||
| 	return nil | ||||
| } | ||||
| 
 | ||||
| func RunWebsocketClient() { | ||||
| 	for { | ||||
| 		// If the cloud token is not set, we don't need to run the websocket client.
 | ||||
| 		if config.CloudToken == "" { | ||||
| 			time.Sleep(5 * time.Second) | ||||
| 			continue | ||||
| 		} | ||||
| 
 | ||||
| 		// If the network is not up, well, we can't connect to the cloud.
 | ||||
| 		if !networkState.IsOnline() { | ||||
| 			cloudLogger.Warn().Msg("waiting for network to be online, will retry in 3 seconds") | ||||
| 			time.Sleep(3 * time.Second) | ||||
| 			continue | ||||
| 		} | ||||
| 
 | ||||
| 		// If the system time is not synchronized, the API request will fail anyway because the TLS handshake will fail.
 | ||||
| 		if isTimeSyncNeeded() && !timeSync.IsSyncSuccess() { | ||||
| 			cloudLogger.Warn().Msg("system time is not synced, will retry in 3 seconds") | ||||
| 			time.Sleep(3 * time.Second) | ||||
| 			continue | ||||
| 		} | ||||
| 
 | ||||
| 		err := runWebsocketClient() | ||||
| 		if err != nil { | ||||
| 			cloudLogger.Errorf("websocket client error: %v", err) | ||||
| 			cloudLogger.Warn().Err(err).Msg("websocket client error") | ||||
| 			metricCloudConnectionStatus.Set(0) | ||||
| 			metricCloudConnectionFailureCount.Inc() | ||||
| 			time.Sleep(5 * time.Second) | ||||
| 		} | ||||
| 	} | ||||
|  | @ -305,6 +560,11 @@ func rpcDeregisterDevice() error { | |||
| 			return fmt.Errorf("failed to save configuration after deregistering: %w", err) | ||||
| 		} | ||||
| 
 | ||||
| 		cloudLogger.Info().Msg("device deregistered, disconnecting from cloud") | ||||
| 		disconnectCloud(fmt.Errorf("device deregistered")) | ||||
| 
 | ||||
| 		setCloudConnectionState(CloudConnectionStateNotConfigured) | ||||
| 
 | ||||
| 		return nil | ||||
| 	} | ||||
| 
 | ||||
|  |  | |||
							
								
								
									
										18
									
								
								cmd/main.go
								
								
								
								
							
							
						
						
									
										18
									
								
								cmd/main.go
								
								
								
								
							|  | @ -1,9 +1,27 @@ | |||
| package main | ||||
| 
 | ||||
| import ( | ||||
| 	"flag" | ||||
| 	"fmt" | ||||
| 	"os" | ||||
| 
 | ||||
| 	"github.com/jetkvm/kvm" | ||||
| ) | ||||
| 
 | ||||
| func main() { | ||||
| 	versionPtr := flag.Bool("version", false, "print version and exit") | ||||
| 	versionJsonPtr := flag.Bool("version-json", false, "print version as json and exit") | ||||
| 	flag.Parse() | ||||
| 
 | ||||
| 	if *versionPtr || *versionJsonPtr { | ||||
| 		versionData, err := kvm.GetVersionData(*versionJsonPtr) | ||||
| 		if err != nil { | ||||
| 			fmt.Printf("failed to get version data: %v\n", err) | ||||
| 			os.Exit(1) | ||||
| 		} | ||||
| 		fmt.Println(string(versionData)) | ||||
| 		return | ||||
| 	} | ||||
| 
 | ||||
| 	kvm.Main() | ||||
| } | ||||
|  |  | |||
							
								
								
									
										178
									
								
								config.go
								
								
								
								
							
							
						
						
									
										178
									
								
								config.go
								
								
								
								
							|  | @ -6,7 +6,11 @@ import ( | |||
| 	"os" | ||||
| 	"sync" | ||||
| 
 | ||||
| 	"github.com/jetkvm/kvm/internal/logging" | ||||
| 	"github.com/jetkvm/kvm/internal/network" | ||||
| 	"github.com/jetkvm/kvm/internal/usbgadget" | ||||
| 	"github.com/prometheus/client_golang/prometheus" | ||||
| 	"github.com/prometheus/client_golang/prometheus/promauto" | ||||
| ) | ||||
| 
 | ||||
| type WakeOnLanDevice struct { | ||||
|  | @ -14,26 +18,91 @@ type WakeOnLanDevice struct { | |||
| 	MacAddress string `json:"macAddress"` | ||||
| } | ||||
| 
 | ||||
| // Constants for keyboard macro limits
 | ||||
| const ( | ||||
| 	MaxMacrosPerDevice = 25 | ||||
| 	MaxStepsPerMacro   = 10 | ||||
| 	MaxKeysPerStep     = 10 | ||||
| 	MinStepDelay       = 50 | ||||
| 	MaxStepDelay       = 2000 | ||||
| ) | ||||
| 
 | ||||
| type KeyboardMacroStep struct { | ||||
| 	Keys      []string `json:"keys"` | ||||
| 	Modifiers []string `json:"modifiers"` | ||||
| 	Delay     int      `json:"delay"` | ||||
| } | ||||
| 
 | ||||
| func (s *KeyboardMacroStep) Validate() error { | ||||
| 	if len(s.Keys) > MaxKeysPerStep { | ||||
| 		return fmt.Errorf("too many keys in step (max %d)", MaxKeysPerStep) | ||||
| 	} | ||||
| 
 | ||||
| 	if s.Delay < MinStepDelay { | ||||
| 		s.Delay = MinStepDelay | ||||
| 	} else if s.Delay > MaxStepDelay { | ||||
| 		s.Delay = MaxStepDelay | ||||
| 	} | ||||
| 
 | ||||
| 	return nil | ||||
| } | ||||
| 
 | ||||
| type KeyboardMacro struct { | ||||
| 	ID        string              `json:"id"` | ||||
| 	Name      string              `json:"name"` | ||||
| 	Steps     []KeyboardMacroStep `json:"steps"` | ||||
| 	SortOrder int                 `json:"sortOrder,omitempty"` | ||||
| } | ||||
| 
 | ||||
| func (m *KeyboardMacro) Validate() error { | ||||
| 	if m.Name == "" { | ||||
| 		return fmt.Errorf("macro name cannot be empty") | ||||
| 	} | ||||
| 
 | ||||
| 	if len(m.Steps) == 0 { | ||||
| 		return fmt.Errorf("macro must have at least one step") | ||||
| 	} | ||||
| 
 | ||||
| 	if len(m.Steps) > MaxStepsPerMacro { | ||||
| 		return fmt.Errorf("too many steps in macro (max %d)", MaxStepsPerMacro) | ||||
| 	} | ||||
| 
 | ||||
| 	for i := range m.Steps { | ||||
| 		if err := m.Steps[i].Validate(); err != nil { | ||||
| 			return fmt.Errorf("invalid step %d: %w", i+1, err) | ||||
| 		} | ||||
| 	} | ||||
| 
 | ||||
| 	return nil | ||||
| } | ||||
| 
 | ||||
| type Config struct { | ||||
| 	CloudURL             string             `json:"cloud_url"` | ||||
| 	CloudAppURL          string             `json:"cloud_app_url"` | ||||
| 	CloudToken           string             `json:"cloud_token"` | ||||
| 	GoogleIdentity       string             `json:"google_identity"` | ||||
| 	JigglerEnabled       bool               `json:"jiggler_enabled"` | ||||
| 	AutoUpdateEnabled    bool               `json:"auto_update_enabled"` | ||||
| 	IncludePreRelease    bool               `json:"include_pre_release"` | ||||
| 	HashedPassword       string             `json:"hashed_password"` | ||||
| 	LocalAuthToken       string             `json:"local_auth_token"` | ||||
| 	LocalAuthMode        string             `json:"localAuthMode"` //TODO: fix it with migration
 | ||||
| 	WakeOnLanDevices     []WakeOnLanDevice  `json:"wake_on_lan_devices"` | ||||
| 	EdidString           string             `json:"hdmi_edid_string"` | ||||
| 	ActiveExtension      string             `json:"active_extension"` | ||||
| 	DisplayMaxBrightness int                `json:"display_max_brightness"` | ||||
| 	DisplayDimAfterSec   int                `json:"display_dim_after_sec"` | ||||
| 	DisplayOffAfterSec   int                `json:"display_off_after_sec"` | ||||
| 	TLSMode              string             `json:"tls_mode"` | ||||
| 	UsbConfig            *usbgadget.Config  `json:"usb_config"` | ||||
| 	UsbDevices           *usbgadget.Devices `json:"usb_devices"` | ||||
| 	CloudURL             string                 `json:"cloud_url"` | ||||
| 	CloudAppURL          string                 `json:"cloud_app_url"` | ||||
| 	CloudToken           string                 `json:"cloud_token"` | ||||
| 	GoogleIdentity       string                 `json:"google_identity"` | ||||
| 	JigglerEnabled       bool                   `json:"jiggler_enabled"` | ||||
| 	JigglerConfig        *JigglerConfig         `json:"jiggler_config"` | ||||
| 	AutoUpdateEnabled    bool                   `json:"auto_update_enabled"` | ||||
| 	IncludePreRelease    bool                   `json:"include_pre_release"` | ||||
| 	HashedPassword       string                 `json:"hashed_password"` | ||||
| 	LocalAuthToken       string                 `json:"local_auth_token"` | ||||
| 	LocalAuthMode        string                 `json:"localAuthMode"` //TODO: fix it with migration
 | ||||
| 	LocalLoopbackOnly    bool                   `json:"local_loopback_only"` | ||||
| 	WakeOnLanDevices     []WakeOnLanDevice      `json:"wake_on_lan_devices"` | ||||
| 	KeyboardMacros       []KeyboardMacro        `json:"keyboard_macros"` | ||||
| 	KeyboardLayout       string                 `json:"keyboard_layout"` | ||||
| 	EdidString           string                 `json:"hdmi_edid_string"` | ||||
| 	ActiveExtension      string                 `json:"active_extension"` | ||||
| 	DisplayRotation      string                 `json:"display_rotation"` | ||||
| 	DisplayMaxBrightness int                    `json:"display_max_brightness"` | ||||
| 	DisplayDimAfterSec   int                    `json:"display_dim_after_sec"` | ||||
| 	DisplayOffAfterSec   int                    `json:"display_off_after_sec"` | ||||
| 	TLSMode              string                 `json:"tls_mode"` // options: "self-signed", "user-defined", ""
 | ||||
| 	UsbConfig            *usbgadget.Config      `json:"usb_config"` | ||||
| 	UsbDevices           *usbgadget.Devices     `json:"usb_devices"` | ||||
| 	NetworkConfig        *network.NetworkConfig `json:"network_config"` | ||||
| 	DefaultLogLevel      string                 `json:"default_log_level"` | ||||
| } | ||||
| 
 | ||||
| const configPath = "/userdata/kvm_config.json" | ||||
|  | @ -43,10 +112,21 @@ var defaultConfig = &Config{ | |||
| 	CloudAppURL:          "https://app.jetkvm.com", | ||||
| 	AutoUpdateEnabled:    true, // Set a default value
 | ||||
| 	ActiveExtension:      "", | ||||
| 	KeyboardMacros:       []KeyboardMacro{}, | ||||
| 	DisplayRotation:      "270", | ||||
| 	KeyboardLayout:       "en-US", | ||||
| 	DisplayMaxBrightness: 64, | ||||
| 	DisplayDimAfterSec:   120,  // 2 minutes
 | ||||
| 	DisplayOffAfterSec:   1800, // 30 minutes
 | ||||
| 	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{ | ||||
| 		VendorId:     "0x1d6b", //The Linux Foundation
 | ||||
| 		ProductId:    "0x0104", //Multifunction Composite Gadget
 | ||||
|  | @ -60,6 +140,8 @@ var defaultConfig = &Config{ | |||
| 		Keyboard:      true, | ||||
| 		MassStorage:   true, | ||||
| 	}, | ||||
| 	NetworkConfig:   &network.NetworkConfig{}, | ||||
| 	DefaultLogLevel: "INFO", | ||||
| } | ||||
| 
 | ||||
| var ( | ||||
|  | @ -67,12 +149,27 @@ var ( | |||
| 	configLock = &sync.Mutex{} | ||||
| ) | ||||
| 
 | ||||
| var ( | ||||
| 	configSuccess = promauto.NewGauge( | ||||
| 		prometheus.GaugeOpts{ | ||||
| 			Name: "jetkvm_config_last_reload_successful", | ||||
| 			Help: "The last configuration load succeeded", | ||||
| 		}, | ||||
| 	) | ||||
| 	configSuccessTime = promauto.NewGauge( | ||||
| 		prometheus.GaugeOpts{ | ||||
| 			Name: "jetkvm_config_last_reload_success_timestamp_seconds", | ||||
| 			Help: "Timestamp of last successful config load", | ||||
| 		}, | ||||
| 	) | ||||
| ) | ||||
| 
 | ||||
| func LoadConfig() { | ||||
| 	configLock.Lock() | ||||
| 	defer configLock.Unlock() | ||||
| 
 | ||||
| 	if config != nil { | ||||
| 		logger.Info("config already loaded, skipping") | ||||
| 		logger.Debug().Msg("config already loaded, skipping") | ||||
| 		return | ||||
| 	} | ||||
| 
 | ||||
|  | @ -81,7 +178,9 @@ func LoadConfig() { | |||
| 
 | ||||
| 	file, err := os.Open(configPath) | ||||
| 	if err != nil { | ||||
| 		logger.Debug("default config file doesn't exist, using default") | ||||
| 		logger.Debug().Msg("default config file doesn't exist, using default") | ||||
| 		configSuccess.Set(1.0) | ||||
| 		configSuccessTime.SetToCurrentTime() | ||||
| 		return | ||||
| 	} | ||||
| 	defer file.Close() | ||||
|  | @ -89,7 +188,8 @@ func LoadConfig() { | |||
| 	// load and merge the default config with the user config
 | ||||
| 	loadedConfig := *defaultConfig | ||||
| 	if err := json.NewDecoder(file).Decode(&loadedConfig); err != nil { | ||||
| 		logger.Errorf("config file JSON parsing failed, %v", err) | ||||
| 		logger.Warn().Err(err).Msg("config file JSON parsing failed") | ||||
| 		configSuccess.Set(0.0) | ||||
| 		return | ||||
| 	} | ||||
| 
 | ||||
|  | @ -102,13 +202,40 @@ func LoadConfig() { | |||
| 		loadedConfig.UsbDevices = defaultConfig.UsbDevices | ||||
| 	} | ||||
| 
 | ||||
| 	if loadedConfig.NetworkConfig == nil { | ||||
| 		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 | ||||
| 
 | ||||
| 	logging.GetRootLogger().UpdateLogLevel(config.DefaultLogLevel) | ||||
| 
 | ||||
| 	configSuccess.Set(1.0) | ||||
| 	configSuccessTime.SetToCurrentTime() | ||||
| 
 | ||||
| 	logger.Info().Str("path", configPath).Msg("config loaded") | ||||
| } | ||||
| 
 | ||||
| func SaveConfig() error { | ||||
| 	configLock.Lock() | ||||
| 	defer configLock.Unlock() | ||||
| 
 | ||||
| 	logger.Trace().Str("path", configPath).Msg("Saving config") | ||||
| 
 | ||||
| 	// fixup old keyboard layout value
 | ||||
| 	if config.KeyboardLayout == "en_US" { | ||||
| 		config.KeyboardLayout = "en-US" | ||||
| 	} | ||||
| 
 | ||||
| 	file, err := os.Create(configPath) | ||||
| 	if err != nil { | ||||
| 		return fmt.Errorf("failed to create config file: %w", err) | ||||
|  | @ -121,6 +248,11 @@ func SaveConfig() error { | |||
| 		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 | ||||
| } | ||||
| 
 | ||||
|  |  | |||
|  | @ -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) | ||||
| 	} | ||||
| } | ||||
							
								
								
									
										128
									
								
								dev_deploy.sh
								
								
								
								
							
							
						
						
									
										128
									
								
								dev_deploy.sh
								
								
								
								
							|  | @ -1,6 +1,21 @@ | |||
| #!/usr/bin/env bash | ||||
| # | ||||
| # Exit immediately if a command exits with a non-zero status | ||||
| set -e | ||||
| 
 | ||||
| C_RST="$(tput sgr0)" | ||||
| C_ERR="$(tput setaf 1)" | ||||
| C_OK="$(tput setaf 2)" | ||||
| C_WARN="$(tput setaf 3)" | ||||
| C_INFO="$(tput setaf 5)" | ||||
| 
 | ||||
| msg() { printf '%s%s%s\n' $2 "$1" $C_RST; } | ||||
| 
 | ||||
| msg_info() { msg "$1" $C_INFO; } | ||||
| msg_ok() { msg "$1" $C_OK; } | ||||
| msg_err() { msg "$1" $C_ERR; } | ||||
| msg_warn() { msg "$1" $C_WARN; } | ||||
| 
 | ||||
| # Function to display help message | ||||
| show_help() { | ||||
|     echo "Usage: $0 [options] -r <remote_ip>" | ||||
|  | @ -10,19 +25,26 @@ show_help() { | |||
|     echo | ||||
|     echo "Optional:" | ||||
|     echo "  -u, --user <remote_user>   Remote username (default: root)" | ||||
|     echo "      --run-go-tests         Run go tests" | ||||
|     echo "      --run-go-tests-only    Run go tests and exit" | ||||
|     echo "      --skip-ui-build        Skip frontend/UI build" | ||||
|     echo "  -i, --install              Build for release and install the app" | ||||
|     echo "      --help                 Display this help message" | ||||
|     echo | ||||
|     echo "Example:" | ||||
|     echo "  $0 -r 192.168.0.17" | ||||
|     echo "  $0 -r 192.168.0.17 -u admin" | ||||
|     exit 0 | ||||
| } | ||||
| 
 | ||||
| # Default values | ||||
| REMOTE_USER="root" | ||||
| REMOTE_PATH="/userdata/jetkvm/bin" | ||||
| SKIP_UI_BUILD=false | ||||
| RESET_USB_HID_DEVICE=false | ||||
| LOG_TRACE_SCOPES="${LOG_TRACE_SCOPES:-jetkvm,cloud,websocket,native,jsonrpc}" | ||||
| RUN_GO_TESTS=false | ||||
| RUN_GO_TESTS_ONLY=false | ||||
| INSTALL_APP=false | ||||
| 
 | ||||
| # Parse command line arguments | ||||
| while [[ $# -gt 0 ]]; do | ||||
|  | @ -39,6 +61,23 @@ while [[ $# -gt 0 ]]; do | |||
|             SKIP_UI_BUILD=true | ||||
|             shift | ||||
|             ;; | ||||
|         --reset-usb-hid) | ||||
|             RESET_USB_HID_DEVICE=true | ||||
|             shift | ||||
|             ;; | ||||
|         --run-go-tests) | ||||
|             RUN_GO_TESTS=true | ||||
|             shift | ||||
|             ;; | ||||
|         --run-go-tests-only) | ||||
|             RUN_GO_TESTS_ONLY=true | ||||
|             RUN_GO_TESTS=true | ||||
|             shift | ||||
|             ;; | ||||
|         -i|--install) | ||||
|             INSTALL_APP=true | ||||
|             shift | ||||
|             ;; | ||||
|         --help) | ||||
|             show_help | ||||
|             exit 0 | ||||
|  | @ -53,27 +92,89 @@ done | |||
| 
 | ||||
| # Verify required parameters | ||||
| if [ -z "$REMOTE_HOST" ]; then | ||||
|     echo "Error: Remote IP is a required parameter" | ||||
|     msg_err "Error: Remote IP is a required parameter" | ||||
|     show_help | ||||
|     exit 1 | ||||
| fi | ||||
| 
 | ||||
| # Build the development version on the host | ||||
| if [ "$SKIP_UI_BUILD" = false ]; then | ||||
|     msg_info "▶ Building frontend" | ||||
|     make frontend | ||||
| fi | ||||
| make build_dev | ||||
| 
 | ||||
| # Change directory to the binary output directory | ||||
| cd bin | ||||
| if [ "$RUN_GO_TESTS" = true ]; then | ||||
|     msg_info "▶ Building go tests" | ||||
|     make build_dev_test   | ||||
| 
 | ||||
| # Kill any existing instances of the application | ||||
| ssh "${REMOTE_USER}@${REMOTE_HOST}" "killall jetkvm_app_debug || true" | ||||
|     msg_info "▶ Copying device-tests.tar.gz to remote host" | ||||
|     ssh "${REMOTE_USER}@${REMOTE_HOST}" "cat > /tmp/device-tests.tar.gz" < device-tests.tar.gz | ||||
| 
 | ||||
| # Copy the binary to the remote host | ||||
| cat jetkvm_app | ssh "${REMOTE_USER}@${REMOTE_HOST}" "cat > $REMOTE_PATH/jetkvm_app_debug" | ||||
|     msg_info "▶ Running go tests" | ||||
|     ssh "${REMOTE_USER}@${REMOTE_HOST}" ash << 'EOF' | ||||
| set -e | ||||
| TMP_DIR=$(mktemp -d) | ||||
| cd ${TMP_DIR} | ||||
| tar zxf /tmp/device-tests.tar.gz | ||||
| ./gotestsum --format=testdox \ | ||||
|     --jsonfile=/tmp/device-tests.json \ | ||||
|     --post-run-command 'sh -c "echo $TESTS_FAILED > /tmp/device-tests.failed"' \ | ||||
|     --raw-command -- ./run_all_tests -json | ||||
| 
 | ||||
| # Deploy and run the application on the remote host | ||||
| ssh "${REMOTE_USER}@${REMOTE_HOST}" ash <<EOF | ||||
| GOTESTSUM_EXIT_CODE=$? | ||||
| if [ $GOTESTSUM_EXIT_CODE -ne 0 ]; then | ||||
|     echo "❌ Tests failed (exit code: $GOTESTSUM_EXIT_CODE)" | ||||
|     rm -rf ${TMP_DIR} /tmp/device-tests.tar.gz | ||||
|     exit 1 | ||||
| fi | ||||
| 
 | ||||
| TESTS_FAILED=$(cat /tmp/device-tests.failed) | ||||
| if [ "$TESTS_FAILED" -ne 0 ]; then | ||||
|     echo "❌ Tests failed $TESTS_FAILED tests failed" | ||||
|     rm -rf ${TMP_DIR} /tmp/device-tests.tar.gz | ||||
|     exit 1 | ||||
| fi | ||||
| 
 | ||||
| echo "✅ Tests passed" | ||||
| rm -rf ${TMP_DIR} /tmp/device-tests.tar.gz | ||||
| EOF | ||||
| 
 | ||||
|     if [ "$RUN_GO_TESTS_ONLY" = true ]; then | ||||
|         msg_info "▶ Go tests completed" | ||||
|         exit 0 | ||||
|     fi | ||||
| fi | ||||
| 
 | ||||
| if [ "$INSTALL_APP" = true ] | ||||
| then | ||||
| 	msg_info "▶ Building release binary" | ||||
| 	make build_release | ||||
| 	 | ||||
| 	# Copy the binary to the remote host as if we were the OTA updater. | ||||
| 	ssh "${REMOTE_USER}@${REMOTE_HOST}" "cat > /userdata/jetkvm/jetkvm_app.update" < bin/jetkvm_app | ||||
| 	 | ||||
| 	# Reboot the device, the new app will be deployed by the startup process. | ||||
| 	ssh "${REMOTE_USER}@${REMOTE_HOST}" "reboot" | ||||
| else | ||||
| 	msg_info "▶ Building development binary" | ||||
| 	make build_dev | ||||
| 	 | ||||
| 	# Kill any existing instances of the application | ||||
| 	ssh "${REMOTE_USER}@${REMOTE_HOST}" "killall jetkvm_app_debug || true" | ||||
| 	 | ||||
| 	# Copy the binary to the remote host | ||||
| 	ssh "${REMOTE_USER}@${REMOTE_HOST}" "cat > ${REMOTE_PATH}/jetkvm_app_debug" < bin/jetkvm_app | ||||
| 	 | ||||
| 	if [ "$RESET_USB_HID_DEVICE" = true ]; then | ||||
| 	msg_info "▶ Resetting USB HID device" | ||||
| 	msg_warn "The option has been deprecated and will be removed in a future version, as JetKVM will now reset USB gadget configuration when needed" | ||||
| 	# Remove the old USB gadget configuration | ||||
| 	ssh "${REMOTE_USER}@${REMOTE_HOST}" "rm -rf /sys/kernel/config/usb_gadget/jetkvm/configs/c.1/hid.usb*" | ||||
| 	ssh "${REMOTE_USER}@${REMOTE_HOST}" "ls /sys/class/udc > /sys/kernel/config/usb_gadget/jetkvm/UDC" | ||||
| 	fi | ||||
| 	 | ||||
| 	# Deploy and run the application on the remote host | ||||
| 	ssh "${REMOTE_USER}@${REMOTE_HOST}" ash << EOF | ||||
| set -e | ||||
| 
 | ||||
| # Set the library path to include the directory where librockit.so is located | ||||
|  | @ -84,13 +185,14 @@ killall jetkvm_app || true | |||
| killall jetkvm_app_debug || true | ||||
| 
 | ||||
| # Navigate to the directory where the binary will be stored | ||||
| cd "$REMOTE_PATH" | ||||
| cd "${REMOTE_PATH}" | ||||
| 
 | ||||
| # Make the new binary executable | ||||
| chmod +x jetkvm_app_debug | ||||
| 
 | ||||
| # Run the application in the background | ||||
| PION_LOG_TRACE=jetkvm,cloud ./jetkvm_app_debug | ||||
| PION_LOG_TRACE=${LOG_TRACE_SCOPES} ./jetkvm_app_debug | tee -a /tmp/jetkvm_app_debug.log | ||||
| EOF | ||||
| fi | ||||
| 
 | ||||
| echo "Deployment complete." | ||||
|  |  | |||
							
								
								
									
										253
									
								
								display.go
								
								
								
								
							
							
						
						
									
										253
									
								
								display.go
								
								
								
								
							|  | @ -1,16 +1,23 @@ | |||
| package kvm | ||||
| 
 | ||||
| import ( | ||||
| 	"context" | ||||
| 	"errors" | ||||
| 	"fmt" | ||||
| 	"os" | ||||
| 	"strconv" | ||||
| 	"sync" | ||||
| 	"time" | ||||
| ) | ||||
| 
 | ||||
| var currentScreen = "ui_Boot_Screen" | ||||
| var backlightState = 0 // 0 - NORMAL, 1 - DIMMED, 2 - OFF
 | ||||
| 
 | ||||
| var ( | ||||
| 	currentScreen   = "ui_Boot_Screen" | ||||
| 	displayedTexts  = make(map[string]string) | ||||
| 	screenStateLock = sync.Mutex{} | ||||
| ) | ||||
| 
 | ||||
| var ( | ||||
| 	dimTicker *time.Ticker | ||||
| 	offTicker *time.Ticker | ||||
|  | @ -21,73 +28,237 @@ const ( | |||
| 	backlightControlClass string = "/sys/class/backlight/backlight/brightness" | ||||
| ) | ||||
| 
 | ||||
| // do not call this function directly, use switchToScreenIfDifferent instead
 | ||||
| // this function is not thread safe
 | ||||
| func switchToScreen(screen string) { | ||||
| 	_, err := CallCtrlAction("lv_scr_load", map[string]interface{}{"obj": screen}) | ||||
| 	_, err := CallCtrlAction("lv_scr_load", map[string]any{"obj": screen}) | ||||
| 	if err != nil { | ||||
| 		logger.Warnf("failed to switch to screen %s: %v", screen, err) | ||||
| 		displayLogger.Warn().Err(err).Str("screen", screen).Msg("failed to switch to screen") | ||||
| 		return | ||||
| 	} | ||||
| 	currentScreen = screen | ||||
| } | ||||
| 
 | ||||
| var displayedTexts = make(map[string]string) | ||||
| func lvObjSetState(objName string, state string) (*CtrlResponse, error) { | ||||
| 	return CallCtrlAction("lv_obj_set_state", map[string]any{"obj": objName, "state": state}) | ||||
| } | ||||
| 
 | ||||
| func lvObjAddFlag(objName string, flag string) (*CtrlResponse, error) { | ||||
| 	return CallCtrlAction("lv_obj_add_flag", map[string]any{"obj": objName, "flag": flag}) | ||||
| } | ||||
| 
 | ||||
| func lvObjClearFlag(objName string, flag string) (*CtrlResponse, error) { | ||||
| 	return CallCtrlAction("lv_obj_clear_flag", map[string]any{"obj": objName, "flag": flag}) | ||||
| } | ||||
| 
 | ||||
| func lvObjHide(objName string) (*CtrlResponse, error) { | ||||
| 	return lvObjAddFlag(objName, "LV_OBJ_FLAG_HIDDEN") | ||||
| } | ||||
| 
 | ||||
| func lvObjShow(objName string) (*CtrlResponse, error) { | ||||
| 	return lvObjClearFlag(objName, "LV_OBJ_FLAG_HIDDEN") | ||||
| } | ||||
| 
 | ||||
| func lvObjSetOpacity(objName string, opacity int) (*CtrlResponse, error) { // nolint:unused
 | ||||
| 	return CallCtrlAction("lv_obj_set_style_opa_layered", map[string]any{"obj": objName, "opa": opacity}) | ||||
| } | ||||
| 
 | ||||
| func lvObjFadeIn(objName string, duration uint32) (*CtrlResponse, error) { | ||||
| 	return CallCtrlAction("lv_obj_fade_in", map[string]any{"obj": objName, "duration": duration}) | ||||
| } | ||||
| 
 | ||||
| func lvObjFadeOut(objName string, duration uint32) (*CtrlResponse, error) { | ||||
| 	return CallCtrlAction("lv_obj_fade_out", map[string]any{"obj": objName, "duration": duration}) | ||||
| } | ||||
| 
 | ||||
| func lvLabelSetText(objName string, text string) (*CtrlResponse, error) { | ||||
| 	return CallCtrlAction("lv_label_set_text", map[string]any{"obj": objName, "text": text}) | ||||
| } | ||||
| 
 | ||||
| func lvImgSetSrc(objName string, src string) (*CtrlResponse, error) { | ||||
| 	return CallCtrlAction("lv_img_set_src", map[string]any{"obj": objName, "src": src}) | ||||
| } | ||||
| 
 | ||||
| func lvDispSetRotation(rotation string) (*CtrlResponse, error) { | ||||
| 	return CallCtrlAction("lv_disp_set_rotation", map[string]any{"rotation": rotation}) | ||||
| } | ||||
| 
 | ||||
| func updateLabelIfChanged(objName string, newText string) { | ||||
| 	screenStateLock.Lock() | ||||
| 	defer screenStateLock.Unlock() | ||||
| 
 | ||||
| 	if newText != "" && newText != displayedTexts[objName] { | ||||
| 		_, _ = CallCtrlAction("lv_label_set_text", map[string]interface{}{"obj": objName, "text": newText}) | ||||
| 		_, _ = lvLabelSetText(objName, newText) | ||||
| 		displayedTexts[objName] = newText | ||||
| 	} | ||||
| } | ||||
| 
 | ||||
| func switchToScreenIfDifferent(screenName string) { | ||||
| 	logger.Infof("switching screen from %s to %s", currentScreen, screenName) | ||||
| 	screenStateLock.Lock() | ||||
| 	defer screenStateLock.Unlock() | ||||
| 
 | ||||
| 	if currentScreen != screenName { | ||||
| 		displayLogger.Info().Str("from", currentScreen).Str("to", screenName).Msg("switching screen") | ||||
| 		switchToScreen(screenName) | ||||
| 	} | ||||
| } | ||||
| 
 | ||||
| func clearDisplayState() { | ||||
| 	screenStateLock.Lock() | ||||
| 	defer screenStateLock.Unlock() | ||||
| 
 | ||||
| 	displayedTexts = make(map[string]string) | ||||
| 	currentScreen = "ui_Boot_Screen" | ||||
| } | ||||
| 
 | ||||
| func updateDisplay() { | ||||
| 	updateLabelIfChanged("ui_Home_Content_Ip", networkState.IPv4) | ||||
| 	updateLabelIfChanged("ui_Home_Content_Ip", networkState.IPv4String()) | ||||
| 	if usbState == "configured" { | ||||
| 		updateLabelIfChanged("ui_Home_Footer_Usb_Status_Label", "Connected") | ||||
| 		_, _ = CallCtrlAction("lv_obj_set_state", map[string]interface{}{"obj": "ui_Home_Footer_Usb_Status_Label", "state": "LV_STATE_DEFAULT"}) | ||||
| 		_, _ = lvObjSetState("ui_Home_Footer_Usb_Status_Label", "LV_STATE_DEFAULT") | ||||
| 	} else { | ||||
| 		updateLabelIfChanged("ui_Home_Footer_Usb_Status_Label", "Disconnected") | ||||
| 		_, _ = CallCtrlAction("lv_obj_set_state", map[string]interface{}{"obj": "ui_Home_Footer_Usb_Status_Label", "state": "LV_STATE_USER_2"}) | ||||
| 		_, _ = lvObjSetState("ui_Home_Footer_Usb_Status_Label", "LV_STATE_USER_2") | ||||
| 	} | ||||
| 	if lastVideoState.Ready { | ||||
| 		updateLabelIfChanged("ui_Home_Footer_Hdmi_Status_Label", "Connected") | ||||
| 		_, _ = CallCtrlAction("lv_obj_set_state", map[string]interface{}{"obj": "ui_Home_Footer_Hdmi_Status_Label", "state": "LV_STATE_DEFAULT"}) | ||||
| 		_, _ = lvObjSetState("ui_Home_Footer_Hdmi_Status_Label", "LV_STATE_DEFAULT") | ||||
| 	} else { | ||||
| 		updateLabelIfChanged("ui_Home_Footer_Hdmi_Status_Label", "Disconnected") | ||||
| 		_, _ = CallCtrlAction("lv_obj_set_state", map[string]interface{}{"obj": "ui_Home_Footer_Hdmi_Status_Label", "state": "LV_STATE_USER_2"}) | ||||
| 		_, _ = lvObjSetState("ui_Home_Footer_Hdmi_Status_Label", "LV_STATE_USER_2") | ||||
| 	} | ||||
| 	updateLabelIfChanged("ui_Home_Header_Cloud_Status_Label", fmt.Sprintf("%d active", actionSessions)) | ||||
| 	if networkState.Up { | ||||
| 
 | ||||
| 	if networkState.IsUp() { | ||||
| 		switchToScreenIfDifferent("ui_Home_Screen") | ||||
| 	} else { | ||||
| 		switchToScreenIfDifferent("ui_No_Network_Screen") | ||||
| 	} | ||||
| 
 | ||||
| 	if cloudConnectionState == CloudConnectionStateNotConfigured { | ||||
| 		_, _ = lvObjHide("ui_Home_Header_Cloud_Status_Icon") | ||||
| 	} else { | ||||
| 		_, _ = lvObjShow("ui_Home_Header_Cloud_Status_Icon") | ||||
| 	} | ||||
| 
 | ||||
| 	switch cloudConnectionState { | ||||
| 	case CloudConnectionStateDisconnected: | ||||
| 		_, _ = lvImgSetSrc("ui_Home_Header_Cloud_Status_Icon", "cloud_disconnected.png") | ||||
| 		stopCloudBlink() | ||||
| 	case CloudConnectionStateConnecting: | ||||
| 		_, _ = lvImgSetSrc("ui_Home_Header_Cloud_Status_Icon", "cloud.png") | ||||
| 		restartCloudBlink() | ||||
| 	case CloudConnectionStateConnected: | ||||
| 		_, _ = lvImgSetSrc("ui_Home_Header_Cloud_Status_Icon", "cloud.png") | ||||
| 		stopCloudBlink() | ||||
| 	} | ||||
| } | ||||
| 
 | ||||
| var displayInited = false | ||||
| const ( | ||||
| 	cloudBlinkInterval = 2 * time.Second | ||||
| 	cloudBlinkDuration = 1 * time.Second | ||||
| ) | ||||
| 
 | ||||
| var ( | ||||
| 	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): | ||||
| 		} | ||||
| 	} | ||||
| } | ||||
| 
 | ||||
| func restartCloudBlink() { | ||||
| 	stopCloudBlink() | ||||
| 	startCloudBlink() | ||||
| } | ||||
| 
 | ||||
| func startCloudBlink() { | ||||
| 	cloudBlinkLock.Lock() | ||||
| 	defer cloudBlinkLock.Unlock() | ||||
| 
 | ||||
| 	if cloudBlinkTicker == nil { | ||||
| 		cloudBlinkTicker = time.NewTicker(cloudBlinkInterval) | ||||
| 	} else { | ||||
| 		cloudBlinkTicker.Reset(cloudBlinkInterval) | ||||
| 	} | ||||
| 
 | ||||
| 	ctx, cancel := context.WithCancel(context.Background()) | ||||
| 	cloudBlinkCancel = cancel | ||||
| 
 | ||||
| 	go doCloudBlink(ctx) | ||||
| } | ||||
| 
 | ||||
| func stopCloudBlink() { | ||||
| 	cloudBlinkLock.Lock() | ||||
| 	defer cloudBlinkLock.Unlock() | ||||
| 
 | ||||
| 	if cloudBlinkCancel != nil { | ||||
| 		cloudBlinkCancel() | ||||
| 		cloudBlinkCancel = nil | ||||
| 	} | ||||
| 
 | ||||
| 	if cloudBlinkTicker != nil { | ||||
| 		cloudBlinkTicker.Stop() | ||||
| 	} | ||||
| } | ||||
| 
 | ||||
| var ( | ||||
| 	displayInited     = false | ||||
| 	displayUpdateLock = sync.Mutex{} | ||||
| 	waitDisplayUpdate = sync.Mutex{} | ||||
| ) | ||||
| 
 | ||||
| func requestDisplayUpdate(shouldWakeDisplay bool) { | ||||
| 	displayUpdateLock.Lock() | ||||
| 	defer displayUpdateLock.Unlock() | ||||
| 
 | ||||
| func requestDisplayUpdate() { | ||||
| 	if !displayInited { | ||||
| 		logger.Info("display not inited, skipping updates") | ||||
| 		displayLogger.Info().Msg("display not inited, skipping updates") | ||||
| 		return | ||||
| 	} | ||||
| 	go func() { | ||||
| 		wakeDisplay(false) | ||||
| 		logger.Info("display updating") | ||||
| 		if shouldWakeDisplay { | ||||
| 			wakeDisplay(false) | ||||
| 		} | ||||
| 		displayLogger.Debug().Msg("display updating") | ||||
| 		//TODO: only run once regardless how many pending updates
 | ||||
| 		updateDisplay() | ||||
| 	}() | ||||
| } | ||||
| 
 | ||||
| func waitCtrlAndRequestDisplayUpdate(shouldWakeDisplay bool) { | ||||
| 	waitDisplayUpdate.Lock() | ||||
| 	defer waitDisplayUpdate.Unlock() | ||||
| 
 | ||||
| 	waitCtrlClientConnected() | ||||
| 	requestDisplayUpdate(shouldWakeDisplay) | ||||
| } | ||||
| 
 | ||||
| func updateStaticContents() { | ||||
| 	//contents that never change
 | ||||
| 	updateLabelIfChanged("ui_Home_Content_Mac", networkState.MAC) | ||||
| 	updateLabelIfChanged("ui_Home_Content_Mac", networkState.MACString()) | ||||
| 	systemVersion, appVersion, err := GetLocalVersion() | ||||
| 	if err == nil { | ||||
| 		updateLabelIfChanged("ui_About_Content_Operating_System_Version_ContentLabel", systemVersion.String()) | ||||
|  | @ -118,7 +289,7 @@ func setDisplayBrightness(brightness int) error { | |||
| 		return err | ||||
| 	} | ||||
| 
 | ||||
| 	logger.Infof("display: set brightness to %v", brightness) | ||||
| 	displayLogger.Info().Int("brightness", brightness).Msg("set brightness") | ||||
| 	return nil | ||||
| } | ||||
| 
 | ||||
|  | @ -127,7 +298,7 @@ func setDisplayBrightness(brightness int) error { | |||
| func tick_displayDim() { | ||||
| 	err := setDisplayBrightness(config.DisplayMaxBrightness / 2) | ||||
| 	if err != nil { | ||||
| 		logger.Warnf("display: failed to dim display: %s", err) | ||||
| 		displayLogger.Warn().Err(err).Msg("failed to dim display") | ||||
| 	} | ||||
| 
 | ||||
| 	dimTicker.Stop() | ||||
|  | @ -140,7 +311,7 @@ func tick_displayDim() { | |||
| func tick_displayOff() { | ||||
| 	err := setDisplayBrightness(0) | ||||
| 	if err != nil { | ||||
| 		logger.Warnf("display: failed to turn off display: %s", err) | ||||
| 		displayLogger.Warn().Err(err).Msg("failed to turn off display") | ||||
| 	} | ||||
| 
 | ||||
| 	offTicker.Stop() | ||||
|  | @ -163,7 +334,7 @@ func wakeDisplay(force bool) { | |||
| 
 | ||||
| 	err := setDisplayBrightness(config.DisplayMaxBrightness) | ||||
| 	if err != nil { | ||||
| 		logger.Warnf("display wake failed, %s", err) | ||||
| 		displayLogger.Warn().Err(err).Msg("failed to wake display") | ||||
| 	} | ||||
| 
 | ||||
| 	if config.DisplayDimAfterSec != 0 { | ||||
|  | @ -183,7 +354,7 @@ func wakeDisplay(force bool) { | |||
| func watchTsEvents() { | ||||
| 	ts, err := os.OpenFile(touchscreenDevice, os.O_RDONLY, 0666) | ||||
| 	if err != nil { | ||||
| 		logger.Warnf("display: failed to open touchscreen device: %s", err) | ||||
| 		displayLogger.Warn().Err(err).Msg("failed to open touchscreen device") | ||||
| 		return | ||||
| 	} | ||||
| 
 | ||||
|  | @ -196,7 +367,7 @@ func watchTsEvents() { | |||
| 	for { | ||||
| 		_, err := ts.Read(buf) | ||||
| 		if err != nil { | ||||
| 			logger.Warnf("display: failed to read from touchscreen device: %s", err) | ||||
| 			displayLogger.Warn().Err(err).Msg("failed to read from touchscreen device") | ||||
| 			return | ||||
| 		} | ||||
| 
 | ||||
|  | @ -215,13 +386,21 @@ func startBacklightTickers() { | |||
| 		return | ||||
| 	} | ||||
| 
 | ||||
| 	if dimTicker == nil && config.DisplayDimAfterSec != 0 { | ||||
| 		logger.Info("display: dim_ticker has started") | ||||
| 	// Stop existing tickers to prevent multiple active instances on repeated calls
 | ||||
| 	if dimTicker != nil { | ||||
| 		dimTicker.Stop() | ||||
| 	} | ||||
| 
 | ||||
| 	if offTicker != nil { | ||||
| 		offTicker.Stop() | ||||
| 	} | ||||
| 
 | ||||
| 	if config.DisplayDimAfterSec != 0 { | ||||
| 		displayLogger.Info().Msg("dim_ticker has started") | ||||
| 		dimTicker = time.NewTicker(time.Duration(config.DisplayDimAfterSec) * time.Second) | ||||
| 		defer dimTicker.Stop() | ||||
| 
 | ||||
| 		go func() { | ||||
| 			for { //nolint:gosimple
 | ||||
| 			for { //nolint:staticcheck
 | ||||
| 				select { | ||||
| 				case <-dimTicker.C: | ||||
| 					tick_displayDim() | ||||
|  | @ -230,13 +409,12 @@ func startBacklightTickers() { | |||
| 		}() | ||||
| 	} | ||||
| 
 | ||||
| 	if offTicker == nil && config.DisplayOffAfterSec != 0 { | ||||
| 		logger.Info("display: off_ticker has started") | ||||
| 	if config.DisplayOffAfterSec != 0 { | ||||
| 		displayLogger.Info().Msg("off_ticker has started") | ||||
| 		offTicker = time.NewTicker(time.Duration(config.DisplayOffAfterSec) * time.Second) | ||||
| 		defer offTicker.Stop() | ||||
| 
 | ||||
| 		go func() { | ||||
| 			for { //nolint:gosimple
 | ||||
| 			for { //nolint:staticcheck
 | ||||
| 				select { | ||||
| 				case <-offTicker.C: | ||||
| 					tick_displayOff() | ||||
|  | @ -246,19 +424,18 @@ func startBacklightTickers() { | |||
| 	} | ||||
| } | ||||
| 
 | ||||
| func init() { | ||||
| 	ensureConfigLoaded() | ||||
| 
 | ||||
| func initDisplay() { | ||||
| 	go func() { | ||||
| 		waitCtrlClientConnected() | ||||
| 		logger.Info("setting initial display contents") | ||||
| 		displayLogger.Info().Msg("setting initial display contents") | ||||
| 		time.Sleep(500 * time.Millisecond) | ||||
| 		_, _ = lvDispSetRotation(config.DisplayRotation) | ||||
| 		updateStaticContents() | ||||
| 		displayInited = true | ||||
| 		logger.Info("display inited") | ||||
| 		displayLogger.Info().Msg("display inited") | ||||
| 		startBacklightTickers() | ||||
| 		wakeDisplay(true) | ||||
| 		requestDisplayUpdate() | ||||
| 		requestDisplayUpdate(true) | ||||
| 	}() | ||||
| 
 | ||||
| 	go watchTsEvents() | ||||
|  |  | |||
							
								
								
									
										114
									
								
								fuse.go
								
								
								
								
							
							
						
						
									
										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.Attr.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.Warnf("failed to mount fuse: %v", err) | ||||
| 	} | ||||
| 	fuseServer.Wait() | ||||
| } | ||||
| 
 | ||||
| type WebRTCImage struct { | ||||
| 	Size     uint64 `json:"size"` | ||||
| 	Filename string `json:"filename"` | ||||
| } | ||||
							
								
								
									
										118
									
								
								go.mod
								
								
								
								
							
							
						
						
									
										118
									
								
								go.mod
								
								
								
								
							|  | @ -1,83 +1,95 @@ | |||
| module github.com/jetkvm/kvm | ||||
| 
 | ||||
| go 1.21.0 | ||||
| 
 | ||||
| toolchain go1.21.1 | ||||
| go 1.24.4 | ||||
| 
 | ||||
| require ( | ||||
| 	github.com/Masterminds/semver/v3 v3.3.0 | ||||
| 	github.com/beevik/ntp v1.3.1 | ||||
| 	github.com/coder/websocket v1.8.12 | ||||
| 	github.com/coreos/go-oidc/v3 v3.11.0 | ||||
| 	github.com/creack/pty v1.1.23 | ||||
| 	github.com/gin-gonic/gin v1.9.1 | ||||
| 	github.com/Masterminds/semver/v3 v3.4.0 | ||||
| 	github.com/beevik/ntp v1.4.3 | ||||
| 	github.com/coder/websocket v1.8.13 | ||||
| 	github.com/coreos/go-oidc/v3 v3.15.0 | ||||
| 	github.com/creack/pty v1.1.24 | ||||
| 	github.com/fsnotify/fsnotify v1.9.0 | ||||
| 	github.com/gin-contrib/logger v1.2.6 | ||||
| 	github.com/gin-gonic/gin v1.10.1 | ||||
| 	github.com/go-co-op/gocron/v2 v2.16.5 | ||||
| 	github.com/google/flatbuffers v25.2.10+incompatible | ||||
| 	github.com/google/uuid v1.6.0 | ||||
| 	github.com/gwatts/rootcerts v0.0.0-20240401182218-3ab9db955caf | ||||
| 	github.com/hanwen/go-fuse/v2 v2.5.1 | ||||
| 	github.com/hashicorp/go-envparse v0.1.0 | ||||
| 	github.com/pion/logging v0.2.2 | ||||
| 	github.com/guregu/null/v6 v6.0.0 | ||||
| 	github.com/gwatts/rootcerts v0.0.0-20250901182336-dc5ae18bd79f | ||||
| 	github.com/pion/logging v0.2.4 | ||||
| 	github.com/pion/mdns/v2 v2.0.7 | ||||
| 	github.com/pion/webrtc/v4 v4.0.0 | ||||
| 	github.com/pion/webrtc/v4 v4.1.4 | ||||
| 	github.com/pojntfx/go-nbd v0.3.2 | ||||
| 	github.com/prometheus/client_golang v1.21.0 | ||||
| 	github.com/prometheus/common v0.62.0 | ||||
| 	github.com/prometheus/client_golang v1.23.0 | ||||
| 	github.com/prometheus/common v0.66.0 | ||||
| 	github.com/prometheus/procfs v0.17.0 | ||||
| 	github.com/psanford/httpreadat v0.1.0 | ||||
| 	github.com/vishvananda/netlink v1.3.0 | ||||
| 	go.bug.st/serial v1.6.2 | ||||
| 	golang.org/x/crypto v0.31.0 | ||||
| 	golang.org/x/net v0.33.0 | ||||
| 	github.com/rs/xid v1.6.0 | ||||
| 	github.com/rs/zerolog v1.34.0 | ||||
| 	github.com/sourcegraph/tf-dag v0.2.2-0.20250131204052-3e8ff1477b4f | ||||
| 	github.com/stretchr/testify v1.11.1 | ||||
| 	github.com/vishvananda/netlink v1.3.1 | ||||
| 	go.bug.st/serial v1.6.4 | ||||
| 	golang.org/x/crypto v0.41.0 | ||||
| 	golang.org/x/net v0.43.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 | ||||
| 
 | ||||
| require ( | ||||
| 	github.com/beorn7/perks v1.0.1 // indirect | ||||
| 	github.com/bytedance/sonic v1.11.6 // indirect | ||||
| 	github.com/bytedance/sonic/loader v0.1.1 // indirect | ||||
| 	github.com/bytedance/sonic v1.13.3 // indirect | ||||
| 	github.com/bytedance/sonic/loader v0.2.4 // indirect | ||||
| 	github.com/cespare/xxhash/v2 v2.3.0 // indirect | ||||
| 	github.com/cloudwego/base64x v0.1.4 // indirect | ||||
| 	github.com/cloudwego/iasm v0.2.0 // indirect | ||||
| 	github.com/creack/goselect v0.1.2 // indirect | ||||
| 	github.com/gabriel-vasile/mimetype v1.4.3 // indirect | ||||
| 	github.com/gin-contrib/sse v0.1.0 // indirect | ||||
| 	github.com/go-jose/go-jose/v4 v4.0.2 // indirect | ||||
| 	github.com/cloudwego/base64x v0.1.5 // indirect | ||||
| 	github.com/creack/goselect v0.1.3 // indirect | ||||
| 	github.com/davecgh/go-spew v1.1.1 // indirect | ||||
| 	github.com/gabriel-vasile/mimetype v1.4.9 // indirect | ||||
| 	github.com/gin-contrib/sse v1.1.0 // indirect | ||||
| 	github.com/go-jose/go-jose/v4 v4.1.0 // indirect | ||||
| 	github.com/go-playground/locales v0.14.1 // indirect | ||||
| 	github.com/go-playground/universal-translator v0.18.1 // indirect | ||||
| 	github.com/go-playground/validator/v10 v10.20.0 // indirect | ||||
| 	github.com/goccy/go-json v0.10.2 // indirect | ||||
| 	github.com/go-playground/validator/v10 v10.26.0 // indirect | ||||
| 	github.com/goccy/go-json v0.10.5 // indirect | ||||
| 	github.com/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/klauspost/compress v1.17.11 // indirect | ||||
| 	github.com/klauspost/cpuid/v2 v2.2.7 // indirect | ||||
| 	github.com/klauspost/cpuid/v2 v2.2.10 // indirect | ||||
| 	github.com/leodido/go-urn v1.4.0 // indirect | ||||
| 	github.com/mattn/go-colorable v0.1.14 // indirect | ||||
| 	github.com/mattn/go-isatty v0.0.20 // indirect | ||||
| 	github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect | ||||
| 	github.com/modern-go/reflect2 v1.0.2 // indirect | ||||
| 	github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect | ||||
| 	github.com/pelletier/go-toml/v2 v2.2.2 // indirect | ||||
| 	github.com/pilebones/go-udev v0.9.0 // indirect | ||||
| 	github.com/pion/datachannel v1.5.9 // indirect | ||||
| 	github.com/pion/dtls/v3 v3.0.3 // indirect | ||||
| 	github.com/pion/ice/v4 v4.0.2 // indirect | ||||
| 	github.com/pion/interceptor v0.1.37 // indirect | ||||
| 	github.com/pelletier/go-toml/v2 v2.2.4 // indirect | ||||
| 	github.com/pilebones/go-udev v0.9.1 // indirect | ||||
| 	github.com/pion/datachannel v1.5.10 // indirect | ||||
| 	github.com/pion/dtls/v3 v3.0.7 // indirect | ||||
| 	github.com/pion/ice/v4 v4.0.10 // indirect | ||||
| 	github.com/pion/interceptor v0.1.40 // indirect | ||||
| 	github.com/pion/randutil v0.1.0 // indirect | ||||
| 	github.com/pion/rtcp v1.2.14 // indirect | ||||
| 	github.com/pion/rtp v1.8.9 // indirect | ||||
| 	github.com/pion/sctp v1.8.33 // indirect | ||||
| 	github.com/pion/sdp/v3 v3.0.9 // indirect | ||||
| 	github.com/pion/srtp/v3 v3.0.4 // indirect | ||||
| 	github.com/pion/rtcp v1.2.15 // indirect | ||||
| 	github.com/pion/rtp v1.8.22 // indirect | ||||
| 	github.com/pion/sctp v1.8.39 // indirect | ||||
| 	github.com/pion/sdp/v3 v3.0.16 // indirect | ||||
| 	github.com/pion/srtp/v3 v3.0.7 // indirect | ||||
| 	github.com/pion/stun/v3 v3.0.0 // indirect | ||||
| 	github.com/pion/transport/v3 v3.0.7 // indirect | ||||
| 	github.com/pion/turn/v4 v4.0.0 // indirect | ||||
| 	github.com/prometheus/client_model v0.6.1 // indirect | ||||
| 	github.com/prometheus/procfs v0.15.1 // indirect | ||||
| 	github.com/pion/turn/v4 v4.1.1 // indirect | ||||
| 	github.com/pmezard/go-difflib v1.0.0 // indirect | ||||
| 	github.com/prometheus/client_model v0.6.2 // indirect | ||||
| 	github.com/robfig/cron/v3 v3.0.1 // indirect | ||||
| 	github.com/rogpeppe/go-internal v1.14.1 // indirect | ||||
| 	github.com/twitchyliquid64/golang-asm v0.15.1 // indirect | ||||
| 	github.com/ugorji/go/codec v1.2.12 // indirect | ||||
| 	github.com/vishvananda/netns v0.0.4 // indirect | ||||
| 	github.com/ugorji/go/codec v1.3.0 // indirect | ||||
| 	github.com/vearutop/statigz v1.5.0 // indirect | ||||
| 	github.com/vishvananda/netns v0.0.5 // indirect | ||||
| 	github.com/wlynxg/anet v0.0.5 // indirect | ||||
| 	golang.org/x/arch v0.8.0 // indirect | ||||
| 	golang.org/x/oauth2 v0.24.0 // indirect | ||||
| 	golang.org/x/sys v0.28.0 // indirect | ||||
| 	golang.org/x/text v0.21.0 // indirect | ||||
| 	google.golang.org/protobuf v1.36.1 // indirect | ||||
| 	golang.org/x/arch v0.18.0 // indirect | ||||
| 	golang.org/x/oauth2 v0.30.0 // indirect | ||||
| 	golang.org/x/text v0.28.0 // 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 | ||||
| ) | ||||
|  |  | |||
							
								
								
									
										253
									
								
								go.sum
								
								
								
								
							
							
						
						
									
										253
									
								
								go.sum
								
								
								
								
							|  | @ -1,68 +1,80 @@ | |||
| github.com/Masterminds/semver/v3 v3.3.0 h1:B8LGeaivUe71a5qox1ICM/JLl0NqZSW5CHyL+hmvYS0= | ||||
| github.com/Masterminds/semver/v3 v3.3.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= | ||||
| github.com/beevik/ntp v1.3.1 h1:Y/srlT8L1yQr58kyPWFPZIxRL8ttx2SRIpVYJqZIlAM= | ||||
| github.com/beevik/ntp v1.3.1/go.mod h1:fT6PylBq86Tsq23ZMEe47b7QQrZfYBFPnpzt0a9kJxw= | ||||
| github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= | ||||
| github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= | ||||
| github.com/beevik/ntp v1.4.3 h1:PlbTvE5NNy4QHmA4Mg57n7mcFTmr1W1j3gcK7L1lqho= | ||||
| github.com/beevik/ntp v1.4.3/go.mod h1:Unr8Zg+2dRn7d8bHFuehIMSvvUYssHMxW3Q5Nx4RW5Q= | ||||
| github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= | ||||
| github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= | ||||
| github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0= | ||||
| github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4= | ||||
| github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM= | ||||
| github.com/bytedance/sonic v1.13.3 h1:MS8gmaH16Gtirygw7jV91pDCN33NyMrPbN7qiYhEsF0= | ||||
| github.com/bytedance/sonic v1.13.3/go.mod h1:o68xyaF9u2gvVBuGHPlUVCy+ZfmNNO5ETf1+KgkJhz4= | ||||
| github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= | ||||
| github.com/bytedance/sonic/loader v0.2.4 h1:ZWCw4stuXUsn1/+zQDqeE7JKP+QO47tz7QCNan80NzY= | ||||
| github.com/bytedance/sonic/loader v0.2.4/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI= | ||||
| github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= | ||||
| github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= | ||||
| github.com/chemhack/go-nbd v0.0.0-20241006125820-59e45f5b1e7b h1:dSbDgy72Y1sjLPWLv7vs0fMFuhMBMViiT9PJZiZWZNs= | ||||
| github.com/chemhack/go-nbd v0.0.0-20241006125820-59e45f5b1e7b/go.mod h1:SehHnbi2e8NiSAKby42Itm8SIoS7b+wAprsfPH3qgYk= | ||||
| github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y= | ||||
| github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w= | ||||
| github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg= | ||||
| github.com/cloudwego/base64x v0.1.5 h1:XPciSp1xaq2VCSt6lF0phncD4koWyULpl5bUxbfCyP4= | ||||
| github.com/cloudwego/base64x v0.1.5/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w= | ||||
| github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY= | ||||
| github.com/coder/websocket v1.8.12 h1:5bUXkEPPIbewrnkU8LTCLVaxi4N4J8ahufH2vlo4NAo= | ||||
| github.com/coder/websocket v1.8.12/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs= | ||||
| github.com/coreos/go-oidc/v3 v3.11.0 h1:Ia3MxdwpSw702YW0xgfmP1GVCMA9aEFWu12XUZ3/OtI= | ||||
| github.com/coreos/go-oidc/v3 v3.11.0/go.mod h1:gE3LgjOgFoHi9a4ce4/tJczr0Ai2/BoDhf0r5lltWI0= | ||||
| github.com/creack/goselect v0.1.2 h1:2DNy14+JPjRBgPzAd1thbQp4BSIihxcBf0IXhQXDRa0= | ||||
| github.com/creack/goselect v0.1.2/go.mod h1:a/NhLweNvqIYMuxcMOuWY516Cimucms3DglDzQP3hKY= | ||||
| github.com/creack/pty v1.1.23 h1:4M6+isWdcStXEf15G/RbrMPOQj1dZ7HPZCGwE4kOeP0= | ||||
| github.com/creack/pty v1.1.23/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= | ||||
| github.com/coder/websocket v1.8.13 h1:f3QZdXy7uGVz+4uCJy2nTZyM0yTBj8yANEHhqlXZ9FE= | ||||
| github.com/coder/websocket v1.8.13/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs= | ||||
| github.com/coreos/go-oidc/v3 v3.15.0 h1:R6Oz8Z4bqWR7VFQ+sPSvZPQv4x8M+sJkDO5ojgwlyAg= | ||||
| 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/creack/goselect v0.1.3 h1:MaGNMclRo7P2Jl21hBpR1Cn33ITSbKP6E49RtfblLKc= | ||||
| github.com/creack/goselect v0.1.3/go.mod h1:a/NhLweNvqIYMuxcMOuWY516Cimucms3DglDzQP3hKY= | ||||
| github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= | ||||
| github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= | ||||
| github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= | ||||
| github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= | ||||
| github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= | ||||
| github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0= | ||||
| github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk= | ||||
| github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= | ||||
| github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= | ||||
| github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg= | ||||
| github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU= | ||||
| github.com/go-jose/go-jose/v4 v4.0.2 h1:R3l3kkBds16bO7ZFAEEcofK0MkrAJt3jlJznWZG0nvk= | ||||
| github.com/go-jose/go-jose/v4 v4.0.2/go.mod h1:WVf9LFMHh/QVrmqrOfqun0C45tMe3RoiKJMPvgWwLfY= | ||||
| github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= | ||||
| github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= | ||||
| github.com/gabriel-vasile/mimetype v1.4.9 h1:5k+WDwEsD9eTLL8Tz3L0VnmVh9QxGjRmjBvAG7U/oYY= | ||||
| github.com/gabriel-vasile/mimetype v1.4.9/go.mod h1:WnSQhFKJuBlRyLiKohA/2DtIlPFAbguNaG7QCHcyGok= | ||||
| github.com/gin-contrib/logger v1.2.6 h1:EPolruKUTzNXMVBD9LuAFQmRjTs7AH7yKGuXgYqrKWc= | ||||
| github.com/gin-contrib/logger v1.2.6/go.mod h1:7niPrd7F0Nscw/zvgz8RiGJxSdbKM2yfQNy8xCHcm64= | ||||
| github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w= | ||||
| github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= | ||||
| github.com/gin-gonic/gin v1.10.1 h1:T0ujvqyCSqRopADpgPgiTT63DUQVSfojyME59Ei63pQ= | ||||
| github.com/gin-gonic/gin v1.10.1/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y= | ||||
| github.com/go-co-op/gocron/v2 v2.16.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/go.mod h1:GG/vqmYm3Von2nYiB2vGTXzdoNKE5tix5tuc6iAd+sw= | ||||
| github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= | ||||
| github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= | ||||
| github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= | ||||
| github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= | ||||
| github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= | ||||
| github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= | ||||
| github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8= | ||||
| github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM= | ||||
| github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= | ||||
| github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= | ||||
| github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= | ||||
| github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= | ||||
| github.com/go-playground/validator/v10 v10.26.0 h1:SP05Nqhjcvz81uJaRfEV0YBSSSGMc/iMaVtFbr3Sw2k= | ||||
| github.com/go-playground/validator/v10 v10.26.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo= | ||||
| github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= | ||||
| github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= | ||||
| github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= | ||||
| github.com/google/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/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= | ||||
| github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= | ||||
| github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= | ||||
| github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= | ||||
| github.com/gwatts/rootcerts v0.0.0-20240401182218-3ab9db955caf h1:JO6ISZIvEUitto5zjQ3/VEnDM5rPbqIFuOhS0U0ByeA= | ||||
| github.com/gwatts/rootcerts v0.0.0-20240401182218-3ab9db955caf/go.mod h1:5Kt9XkWvkGi2OHOq0QsGxebHmhCcqJ8KCbNg/a6+n+g= | ||||
| github.com/hanwen/go-fuse/v2 v2.5.1 h1:OQBE8zVemSocRxA4OaFJbjJ5hlpCmIWbGr7r0M4uoQQ= | ||||
| github.com/hanwen/go-fuse/v2 v2.5.1/go.mod h1:xKwi1cF7nXAOBCXujD5ie0ZKsxc8GGSA1rlMJc+8IJs= | ||||
| github.com/hashicorp/go-envparse v0.1.0 h1:bE++6bhIsNCPLvgDZkYqo3nA+/PFI51pkrHdmPSDFPY= | ||||
| github.com/hashicorp/go-envparse v0.1.0/go.mod h1:OHheN1GoygLlAkTlXLXvAdnXdZxy8JUweQ1rAXx1xnc= | ||||
| github.com/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/go.mod h1:hrMIhIfrOZeLPZhROSn149tpw2gHkidAqxoXNyeX3iQ= | ||||
| github.com/gwatts/rootcerts v0.0.0-20250901182336-dc5ae18bd79f h1:08t2PbrkDgW2+mwCQ3jhKUBrCM9Bc9SeH5j2Dst3B+0= | ||||
| github.com/gwatts/rootcerts v0.0.0-20250901182336-dc5ae18bd79f/go.mod h1:5Kt9XkWvkGi2OHOq0QsGxebHmhCcqJ8KCbNg/a6+n+g= | ||||
| github.com/jonboulle/clockwork v0.5.0 h1:Hyh9A8u51kptdkR+cqRpT1EebBwTn1oK9YfGYbdFz6I= | ||||
| github.com/jonboulle/clockwork v0.5.0/go.mod h1:3mZlmanh0g2NDKO5TWZVJAfofYk64M7XN3SzBPjZF60= | ||||
| github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= | ||||
| github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= | ||||
| github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc= | ||||
| github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0= | ||||
| github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= | ||||
| github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= | ||||
| github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= | ||||
| github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM= | ||||
| github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= | ||||
| github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE= | ||||
| github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= | ||||
| github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M= | ||||
| github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= | ||||
| github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= | ||||
|  | @ -71,15 +83,17 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= | |||
| github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= | ||||
| github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= | ||||
| github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= | ||||
| github.com/kylelemons/godebug v0.0.0-20170820004349-d65d576e9348/go.mod h1:B69LEHPfb2qLo0BaaOLcbitczOKLWTsrBG9LczfCD4k= | ||||
| github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= | ||||
| github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= | ||||
| github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= | ||||
| github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= | ||||
| github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= | ||||
| github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= | ||||
| github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= | ||||
| github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= | ||||
| github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= | ||||
| github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= | ||||
| github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= | ||||
| github.com/moby/sys/mountinfo v0.6.2 h1:BzJjoreD5BMFNmD9Rus6gdd1pLuecOFPt8wC+Vygl78= | ||||
| github.com/moby/sys/mountinfo v0.6.2/go.mod h1:IJb6JQeOklcdMU9F5xQ8ZALD+CUr5VlGpwtX+VE0rpI= | ||||
| github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= | ||||
| github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= | ||||
| github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= | ||||
|  | @ -87,107 +101,116 @@ github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9G | |||
| github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= | ||||
| github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= | ||||
| github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= | ||||
| github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM= | ||||
| github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= | ||||
| github.com/pilebones/go-udev v0.9.0 h1:N1uEO/SxUwtIctc0WLU0t69JeBxIYEYnj8lT/Nabl9Q= | ||||
| github.com/pilebones/go-udev v0.9.0/go.mod h1:T2eI2tUSK0hA2WS5QLjXJUfQkluZQu+18Cqvem3CaXI= | ||||
| github.com/pion/datachannel v1.5.9 h1:LpIWAOYPyDrXtU+BW7X0Yt/vGtYxtXQ8ql7dFfYUVZA= | ||||
| github.com/pion/datachannel v1.5.9/go.mod h1:kDUuk4CU4Uxp82NH4LQZbISULkX/HtzKa4P7ldf9izE= | ||||
| github.com/pion/dtls/v3 v3.0.3 h1:j5ajZbQwff7Z8k3pE3S+rQ4STvKvXUdKsi/07ka+OWM= | ||||
| github.com/pion/dtls/v3 v3.0.3/go.mod h1:weOTUyIV4z0bQaVzKe8kpaP17+us3yAuiQsEAG1STMU= | ||||
| github.com/pion/ice/v4 v4.0.2 h1:1JhBRX8iQLi0+TfcavTjPjI6GO41MFn4CeTBX+Y9h5s= | ||||
| github.com/pion/ice/v4 v4.0.2/go.mod h1:DCdqyzgtsDNYN6/3U8044j3U7qsJ9KFJC92VnOWHvXg= | ||||
| github.com/pion/interceptor v0.1.37 h1:aRA8Zpab/wE7/c0O3fh1PqY0AJI3fCSEM5lRWJVorwI= | ||||
| github.com/pion/interceptor v0.1.37/go.mod h1:JzxbJ4umVTlZAf+/utHzNesY8tmRkM2lVmkS82TTj8Y= | ||||
| github.com/pion/logging v0.2.2 h1:M9+AIj/+pxNsDfAT64+MAVgJO0rsyLnoJKCqf//DoeY= | ||||
| github.com/pion/logging v0.2.2/go.mod h1:k0/tDVsRCX2Mb2ZEmTqNa7CWsQPc+YYCB7Q+5pahoms= | ||||
| github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= | ||||
| github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= | ||||
| github.com/pilebones/go-udev v0.9.1 h1:uN72M1C1fgzhsVmBGEM8w9RD1JY4iVsPZpr+Z6rb3O8= | ||||
| github.com/pilebones/go-udev v0.9.1/go.mod h1:Bgcl07crebF3JSeS4+nuaRvhWFdCeFoBhXXeAp93XNo= | ||||
| github.com/pion/datachannel v1.5.10 h1:ly0Q26K1i6ZkGf42W7D4hQYR90pZwzFOjTq5AuCKk4o= | ||||
| github.com/pion/datachannel v1.5.10/go.mod h1:p/jJfC9arb29W7WrxyKbepTU20CFgyx5oLo8Rs4Py/M= | ||||
| github.com/pion/dtls/v3 v3.0.7 h1:bItXtTYYhZwkPFk4t1n3Kkf5TDrfj6+4wG+CZR8uI9Q= | ||||
| 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/go.mod h1:y3M18aPhIxLlcO/4dn9X8LzLLSma84cx6emMSu14FGw= | ||||
| github.com/pion/interceptor v0.1.40 h1:e0BjnPcGpr2CFQgKhrQisBU7V3GXK6wrfYrGYaU6Jq4= | ||||
| github.com/pion/interceptor v0.1.40/go.mod h1:Z6kqH7M/FYirg3frjGJ21VLSRJGBXB/KqaTIrdqnOic= | ||||
| github.com/pion/logging v0.2.4 h1:tTew+7cmQ+Mc1pTBLKH2puKsOvhm32dROumOZ655zB8= | ||||
| github.com/pion/logging v0.2.4/go.mod h1:DffhXTKYdNZU+KtJ5pyQDjvOAh/GsNSyv1lbkFbe3so= | ||||
| github.com/pion/mdns/v2 v2.0.7 h1:c9kM8ewCgjslaAmicYMFQIde2H9/lrZpjBkN8VwoVtM= | ||||
| github.com/pion/mdns/v2 v2.0.7/go.mod h1:vAdSYNAT0Jy3Ru0zl2YiW3Rm/fJCwIeM0nToenfOJKA= | ||||
| github.com/pion/randutil v0.1.0 h1:CFG1UdESneORglEsnimhUjf33Rwjubwj6xfiOXBa3mA= | ||||
| github.com/pion/randutil v0.1.0/go.mod h1:XcJrSMMbbMRhASFVOlj/5hQial/Y8oH/HVo7TBZq+j8= | ||||
| github.com/pion/rtcp v1.2.14 h1:KCkGV3vJ+4DAJmvP0vaQShsb0xkRfWkO540Gy102KyE= | ||||
| github.com/pion/rtcp v1.2.14/go.mod h1:sn6qjxvnwyAkkPzPULIbVqSKI5Dv54Rv7VG0kNxh9L4= | ||||
| github.com/pion/rtp v1.8.9 h1:E2HX740TZKaqdcPmf4pw6ZZuG8u5RlMMt+l3dxeu6Wk= | ||||
| github.com/pion/rtp v1.8.9/go.mod h1:pBGHaFt/yW7bf1jjWAoUjpSNoDnw98KTMg+jWWvziqU= | ||||
| github.com/pion/sctp v1.8.33 h1:dSE4wX6uTJBcNm8+YlMg7lw1wqyKHggsP5uKbdj+NZw= | ||||
| github.com/pion/sctp v1.8.33/go.mod h1:beTnqSzewI53KWoG3nqB282oDMGrhNxBdb+JZnkCwRM= | ||||
| github.com/pion/sdp/v3 v3.0.9 h1:pX++dCHoHUwq43kuwf3PyJfHlwIj4hXA7Vrifiq0IJY= | ||||
| github.com/pion/sdp/v3 v3.0.9/go.mod h1:B5xmvENq5IXJimIO4zfp6LAe1fD9N+kFv+V/1lOdz8M= | ||||
| github.com/pion/srtp/v3 v3.0.4 h1:2Z6vDVxzrX3UHEgrUyIGM4rRouoC7v+NiF1IHtp9B5M= | ||||
| github.com/pion/srtp/v3 v3.0.4/go.mod h1:1Jx3FwDoxpRaTh1oRV8A/6G1BnFL+QI82eK4ms8EEJQ= | ||||
| github.com/pion/rtcp v1.2.15 h1:LZQi2JbdipLOj4eBjK4wlVoQWfrZbh3Q6eHtWtJBZBo= | ||||
| github.com/pion/rtcp v1.2.15/go.mod h1:jlGuAjHMEXwMUHK78RgX0UmEJFV4zUKOFHR7OP+D3D0= | ||||
| github.com/pion/rtp v1.8.22 h1:8NCVDDF+uSJmMUkjLJVnIr/HX7gPesyMV1xFt5xozXc= | ||||
| 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/go.mod h1:cNiLdchXra8fHQwmIoqw0MbLLMs+f7uQ+dGMG2gWebE= | ||||
| github.com/pion/sdp/v3 v3.0.16 h1:0dKzYO6gTAvuLaAKQkC02eCPjMIi4NuAr/ibAwrGDCo= | ||||
| github.com/pion/sdp/v3 v3.0.16/go.mod h1:9tyKzznud3qiweZcD86kS0ff1pGYB3VX+Bcsmkx6IXo= | ||||
| github.com/pion/srtp/v3 v3.0.7 h1:QUElw0A/FUg3MP8/KNMZB3i0m8F9XeMnTum86F7S4bs= | ||||
| 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/go.mod h1:HvCN8txt8mwi4FBvS3EmDghW6aQJ24T+y+1TKjB5jyU= | ||||
| github.com/pion/transport/v3 v3.0.7 h1:iRbMH05BzSNwhILHoBoAPxoB9xQgOaJk+591KC9P1o0= | ||||
| github.com/pion/transport/v3 v3.0.7/go.mod h1:YleKiTZ4vqNxVwh77Z0zytYi7rXHl7j6uPLGhhz9rwo= | ||||
| github.com/pion/turn/v4 v4.0.0 h1:qxplo3Rxa9Yg1xXDxxH8xaqcyGUtbHYw4QSCvmFWvhM= | ||||
| github.com/pion/turn/v4 v4.0.0/go.mod h1:MuPDkm15nYSklKpN8vWJ9W2M0PlyQZqYt1McGuxG7mA= | ||||
| github.com/pion/webrtc/v4 v4.0.0 h1:x8ec7uJQPP3D1iI8ojPAiTOylPI7Fa7QgqZrhpLyqZ8= | ||||
| github.com/pion/webrtc/v4 v4.0.0/go.mod h1:SfNn8CcFxR6OUVjLXVslAQ3a3994JhyE3Hw1jAuqEto= | ||||
| github.com/pion/turn/v4 v4.1.1 h1:9UnY2HB99tpDyz3cVVZguSxcqkJ1DsTSZ+8TGruh4fc= | ||||
| github.com/pion/turn/v4 v4.1.1/go.mod h1:2123tHk1O++vmjI5VSD0awT50NywDAq5A2NNNU4Jjs8= | ||||
| github.com/pion/webrtc/v4 v4.1.4 h1:/gK1ACGHXQmtyVVbJFQDxNoODg4eSRiFLB7t9r9pg8M= | ||||
| 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/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= | ||||
| github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= | ||||
| github.com/prometheus/client_golang v1.21.0 h1:DIsaGmiaBkSangBgMtWdNfxbMNdku5IK6iNhrEqWvdA= | ||||
| github.com/prometheus/client_golang v1.21.0/go.mod h1:U9NM32ykUErtVBxdvD3zfi+EuFkkaBvMb09mIfe0Zgg= | ||||
| github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E= | ||||
| github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY= | ||||
| github.com/prometheus/common v0.62.0 h1:xasJaQlnWAeyHdUBeGjXmutelfJHWMRr+Fg4QszZ2Io= | ||||
| github.com/prometheus/common v0.62.0/go.mod h1:vyBcEuLSvWos9B1+CyL7JZ2up+uFzXhkqml0W5zIY1I= | ||||
| github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc= | ||||
| github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= | ||||
| github.com/prometheus/client_golang v1.23.0 h1:ust4zpdl9r4trLY/gSjlm07PuiBq2ynaXXlptpfy8Uc= | ||||
| 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/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= | ||||
| github.com/prometheus/common v0.66.0 h1:K/rJPHrG3+AoQs50r2+0t7zMnMzek2Vbv31OFVsMeVY= | ||||
| github.com/prometheus/common v0.66.0/go.mod h1:Ux6NtV1B4LatamKE63tJBntoxD++xmtI/lK0VtEplN4= | ||||
| github.com/prometheus/procfs v0.17.0 h1:FuLQ+05u4ZI+SS/w9+BWEM2TXiHKsUQ9TADiRH7DuK0= | ||||
| 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/go.mod h1:Zg7P+TlBm3bYbyHTKv/EdtSJZn3qwbPwpfZ/I9GKCRE= | ||||
| github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= | ||||
| github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= | ||||
| github.com/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/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/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY= | ||||
| github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ= | ||||
| github.com/sourcegraph/tf-dag v0.2.2-0.20250131204052-3e8ff1477b4f h1:VgoRCP1efSCEZIcF2THLQ46+pIBzzgNiaUBe9wEDwYU= | ||||
| github.com/sourcegraph/tf-dag v0.2.2-0.20250131204052-3e8ff1477b4f/go.mod h1:pzro7BGorij2WgrjEammtrkbo3+xldxo+KaGLGUiD+Q= | ||||
| github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= | ||||
| github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= | ||||
| github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= | ||||
| github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= | ||||
| github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= | ||||
| github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= | ||||
| github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= | ||||
| github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= | ||||
| github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= | ||||
| github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= | ||||
| github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= | ||||
| github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= | ||||
| github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= | ||||
| github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= | ||||
| github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= | ||||
| github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= | ||||
| github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= | ||||
| github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE= | ||||
| github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= | ||||
| github.com/vishvananda/netlink v1.3.0 h1:X7l42GfcV4S6E4vHTsw48qbrV+9PVojNfIhZcwQdrZk= | ||||
| github.com/vishvananda/netlink v1.3.0/go.mod h1:i6NetklAujEcC6fK0JPjT8qSwWyO0HLn4UKG+hGqeJs= | ||||
| github.com/vishvananda/netns v0.0.4 h1:Oeaw1EM2JMxD51g9uhtC0D7erkIjgmj8+JZc26m1YX8= | ||||
| github.com/vishvananda/netns v0.0.4/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM= | ||||
| github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA= | ||||
| github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= | ||||
| github.com/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/go.mod h1:ARtKouGSTGchR8aMwmkzC0qiNPrrWO5JS/XMVl45+b4= | ||||
| github.com/vishvananda/netns v0.0.5 h1:DfiHV+j8bA32MFM7bfEunvT8IAqQ/NzSJHtcmW5zdEY= | ||||
| github.com/vishvananda/netns v0.0.5/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM= | ||||
| github.com/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU= | ||||
| github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA= | ||||
| go.bug.st/serial v1.6.2 h1:kn9LRX3sdm+WxWKufMlIRndwGfPWsH1/9lCWXQCasq8= | ||||
| go.bug.st/serial v1.6.2/go.mod h1:UABfsluHAiaNI+La2iESysd9Vetq7VRdpxvjx7CmmOE= | ||||
| golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= | ||||
| golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc= | ||||
| golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys= | ||||
| golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U= | ||||
| golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= | ||||
| golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I= | ||||
| golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= | ||||
| golang.org/x/oauth2 v0.24.0 h1:KTBBxWqUa0ykRPLtV69rRto9TLXcqYkeswu48x/gvNE= | ||||
| golang.org/x/oauth2 v0.24.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= | ||||
| golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= | ||||
| golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= | ||||
| go.bug.st/serial v1.6.4 h1:7FmqNPgVp3pu2Jz5PoPtbZ9jJO5gnEnZIvnI1lzve8A= | ||||
| go.bug.st/serial v1.6.4/go.mod h1:nofMJxTeNVny/m6+KaafC6vJGj3miwQZ6vW4BZUGJPI= | ||||
| go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= | ||||
| go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= | ||||
| golang.org/x/arch v0.18.0 h1:WN9poc33zL4AzGxqf8VtpKUnGvMi8O9lhNyBMF/85qc= | ||||
| golang.org/x/arch v0.18.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk= | ||||
| golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4= | ||||
| golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc= | ||||
| golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE= | ||||
| 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/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= | ||||
| golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= | ||||
| golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= | ||||
| golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= | ||||
| golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= | ||||
| golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= | ||||
| golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= | ||||
| golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= | ||||
| golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= | ||||
| golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= | ||||
| google.golang.org/protobuf v1.36.1 h1:yBPeRvTftaleIgM3PZ/WBIZ7XM/eEYAaEyCwvyjq/gk= | ||||
| google.golang.org/protobuf v1.36.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= | ||||
| golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= | ||||
| golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= | ||||
| golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= | ||||
| golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= | ||||
| golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= | ||||
| google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc= | ||||
| 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 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= | ||||
| 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.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= | ||||
| gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= | ||||
| nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50= | ||||
| rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= | ||||
|  |  | |||
|  | @ -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) | ||||
| } | ||||
							
								
								
									
										18
									
								
								hw.go
								
								
								
								
							
							
						
						
									
										18
									
								
								hw.go
								
								
								
								
							|  | @ -4,6 +4,7 @@ import ( | |||
| 	"fmt" | ||||
| 	"os" | ||||
| 	"regexp" | ||||
| 	"strings" | ||||
| 	"sync" | ||||
| 	"time" | ||||
| ) | ||||
|  | @ -42,7 +43,7 @@ func GetDeviceID() string { | |||
| 	deviceIDOnce.Do(func() { | ||||
| 		serial, err := extractSerialNumber() | ||||
| 		if err != nil { | ||||
| 			logger.Warn("unknown serial number, the program likely not running on RV1106") | ||||
| 			logger.Warn().Msg("unknown serial number, the program likely not running on RV1106") | ||||
| 			deviceID = "unknown_device_id" | ||||
| 		} else { | ||||
| 			deviceID = serial | ||||
|  | @ -51,10 +52,19 @@ func GetDeviceID() string { | |||
| 	return deviceID | ||||
| } | ||||
| 
 | ||||
| func GetDefaultHostname() string { | ||||
| 	deviceId := GetDeviceID() | ||||
| 	if deviceId == "unknown_device_id" { | ||||
| 		return "jetkvm" | ||||
| 	} | ||||
| 
 | ||||
| 	return fmt.Sprintf("jetkvm-%s", strings.ToLower(deviceId)) | ||||
| } | ||||
| 
 | ||||
| func runWatchdog() { | ||||
| 	file, err := os.OpenFile("/dev/watchdog", os.O_WRONLY, 0) | ||||
| 	if err != nil { | ||||
| 		logger.Warnf("unable to open /dev/watchdog: %v, skipping watchdog reset", err) | ||||
| 		watchdogLogger.Warn().Err(err).Msg("unable to open /dev/watchdog, skipping watchdog reset") | ||||
| 		return | ||||
| 	} | ||||
| 	defer file.Close() | ||||
|  | @ -65,13 +75,13 @@ func runWatchdog() { | |||
| 		case <-ticker.C: | ||||
| 			_, err = file.Write([]byte{0}) | ||||
| 			if err != nil { | ||||
| 				logger.Errorf("error writing to /dev/watchdog, system may reboot: %v", err) | ||||
| 				watchdogLogger.Warn().Err(err).Msg("error writing to /dev/watchdog, system may reboot") | ||||
| 			} | ||||
| 		case <-appCtx.Done(): | ||||
| 			//disarm watchdog with magic value
 | ||||
| 			_, err := file.Write([]byte("V")) | ||||
| 			if err != nil { | ||||
| 				logger.Errorf("failed to disarm watchdog, system may reboot: %v", err) | ||||
| 				watchdogLogger.Warn().Err(err).Msg("failed to disarm watchdog, system may reboot") | ||||
| 			} | ||||
| 			return | ||||
| 		} | ||||
|  |  | |||
|  | @ -0,0 +1,386 @@ | |||
| package confparser | ||||
| 
 | ||||
| import ( | ||||
| 	"fmt" | ||||
| 	"net" | ||||
| 	"net/url" | ||||
| 	"reflect" | ||||
| 	"slices" | ||||
| 	"strconv" | ||||
| 	"strings" | ||||
| 
 | ||||
| 	"github.com/guregu/null/v6" | ||||
| 	"golang.org/x/net/idna" | ||||
| ) | ||||
| 
 | ||||
| type FieldConfig struct { | ||||
| 	Name              string | ||||
| 	Required          bool | ||||
| 	RequiredIf        map[string]any | ||||
| 	OneOf             []string | ||||
| 	ValidateTypes     []string | ||||
| 	Defaults          any | ||||
| 	IsEmpty           bool | ||||
| 	CurrentValue      any | ||||
| 	TypeString        string | ||||
| 	Delegated         bool | ||||
| 	shouldUpdateValue bool | ||||
| } | ||||
| 
 | ||||
| func SetDefaultsAndValidate(config any) error { | ||||
| 	return setDefaultsAndValidate(config, true) | ||||
| } | ||||
| 
 | ||||
| func setDefaultsAndValidate(config any, isRoot bool) error { | ||||
| 	// first we need to check if the config is a pointer
 | ||||
| 	if reflect.TypeOf(config).Kind() != reflect.Ptr { | ||||
| 		return fmt.Errorf("config is not a pointer") | ||||
| 	} | ||||
| 
 | ||||
| 	// now iterate over the lease struct and set the values
 | ||||
| 	configType := reflect.TypeOf(config).Elem() | ||||
| 	configValue := reflect.ValueOf(config).Elem() | ||||
| 
 | ||||
| 	fields := make(map[string]FieldConfig) | ||||
| 
 | ||||
| 	for i := 0; i < configType.NumField(); i++ { | ||||
| 		field := configType.Field(i) | ||||
| 		fieldValue := configValue.Field(i) | ||||
| 
 | ||||
| 		defaultValue := field.Tag.Get("default") | ||||
| 
 | ||||
| 		fieldType := field.Type.String() | ||||
| 
 | ||||
| 		fieldConfig := FieldConfig{ | ||||
| 			Name:          field.Name, | ||||
| 			OneOf:         splitString(field.Tag.Get("one_of")), | ||||
| 			ValidateTypes: splitString(field.Tag.Get("validate_type")), | ||||
| 			RequiredIf:    make(map[string]any), | ||||
| 			CurrentValue:  fieldValue.Interface(), | ||||
| 			IsEmpty:       false, | ||||
| 			TypeString:    fieldType, | ||||
| 		} | ||||
| 
 | ||||
| 		// check if the field is required
 | ||||
| 		required := field.Tag.Get("required") | ||||
| 		if required != "" { | ||||
| 			requiredBool, _ := strconv.ParseBool(required) | ||||
| 			fieldConfig.Required = requiredBool | ||||
| 		} | ||||
| 
 | ||||
| 		var canUseOneOff = false | ||||
| 
 | ||||
| 		// use switch to get the type
 | ||||
| 		switch fieldValue.Interface().(type) { | ||||
| 		case string, null.String: | ||||
| 			if defaultValue != "" { | ||||
| 				fieldConfig.Defaults = defaultValue | ||||
| 			} | ||||
| 			canUseOneOff = true | ||||
| 		case []string: | ||||
| 			if defaultValue != "" { | ||||
| 				fieldConfig.Defaults = strings.Split(defaultValue, ",") | ||||
| 			} | ||||
| 			canUseOneOff = true | ||||
| 		case int, null.Int: | ||||
| 			if defaultValue != "" { | ||||
| 				defaultValueInt, err := strconv.Atoi(defaultValue) | ||||
| 				if err != nil { | ||||
| 					return fmt.Errorf("invalid default value for field `%s`: %s", field.Name, defaultValue) | ||||
| 				} | ||||
| 
 | ||||
| 				fieldConfig.Defaults = defaultValueInt | ||||
| 			} | ||||
| 		case bool, null.Bool: | ||||
| 			if defaultValue != "" { | ||||
| 				defaultValueBool, err := strconv.ParseBool(defaultValue) | ||||
| 				if err != nil { | ||||
| 					return fmt.Errorf("invalid default value for field `%s`: %s", field.Name, defaultValue) | ||||
| 				} | ||||
| 
 | ||||
| 				fieldConfig.Defaults = defaultValueBool | ||||
| 			} | ||||
| 		default: | ||||
| 			if defaultValue != "" { | ||||
| 				return fmt.Errorf("field `%s` cannot use default value: unsupported type: %s", field.Name, fieldType) | ||||
| 			} | ||||
| 
 | ||||
| 			// check if it's a pointer
 | ||||
| 			if fieldValue.Kind() == reflect.Ptr { | ||||
| 				// check if the pointer is nil
 | ||||
| 				if fieldValue.IsNil() { | ||||
| 					fieldConfig.IsEmpty = true | ||||
| 				} else { | ||||
| 					fieldConfig.CurrentValue = fieldValue.Elem().Addr() | ||||
| 					fieldConfig.Delegated = true | ||||
| 				} | ||||
| 			} else { | ||||
| 				fieldConfig.Delegated = true | ||||
| 			} | ||||
| 		} | ||||
| 
 | ||||
| 		// now check if the field is nullable interface
 | ||||
| 		switch fieldValue.Interface().(type) { | ||||
| 		case null.String: | ||||
| 			if fieldValue.Interface().(null.String).IsZero() { | ||||
| 				fieldConfig.IsEmpty = true | ||||
| 			} | ||||
| 		case null.Int: | ||||
| 			if fieldValue.Interface().(null.Int).IsZero() { | ||||
| 				fieldConfig.IsEmpty = true | ||||
| 			} | ||||
| 		case null.Bool: | ||||
| 			if fieldValue.Interface().(null.Bool).IsZero() { | ||||
| 				fieldConfig.IsEmpty = true | ||||
| 			} | ||||
| 		case []string: | ||||
| 			if len(fieldValue.Interface().([]string)) == 0 { | ||||
| 				fieldConfig.IsEmpty = true | ||||
| 			} | ||||
| 		} | ||||
| 
 | ||||
| 		// now check if the field has required_if
 | ||||
| 		requiredIf := field.Tag.Get("required_if") | ||||
| 		if requiredIf != "" { | ||||
| 			requiredIfParts := strings.SplitSeq(requiredIf, ",") | ||||
| 			for part := range requiredIfParts { | ||||
| 				partVal := strings.SplitN(part, "=", 2) | ||||
| 				if len(partVal) != 2 { | ||||
| 					return fmt.Errorf("invalid required_if for field `%s`: %s", field.Name, requiredIf) | ||||
| 				} | ||||
| 
 | ||||
| 				fieldConfig.RequiredIf[partVal[0]] = partVal[1] | ||||
| 			} | ||||
| 		} | ||||
| 
 | ||||
| 		// check if the field can use one_of
 | ||||
| 		if !canUseOneOff && len(fieldConfig.OneOf) > 0 { | ||||
| 			return fmt.Errorf("field `%s` cannot use one_of: unsupported type: %s", field.Name, fieldType) | ||||
| 		} | ||||
| 
 | ||||
| 		fields[field.Name] = fieldConfig | ||||
| 	} | ||||
| 
 | ||||
| 	if err := validateFields(config, fields); err != nil { | ||||
| 		return err | ||||
| 	} | ||||
| 
 | ||||
| 	return nil | ||||
| } | ||||
| 
 | ||||
| func validateFields(config any, fields map[string]FieldConfig) error { | ||||
| 	// now we can start to validate the fields
 | ||||
| 	for _, fieldConfig := range fields { | ||||
| 		if err := fieldConfig.validate(fields); err != nil { | ||||
| 			return err | ||||
| 		} | ||||
| 
 | ||||
| 		fieldConfig.populate(config) | ||||
| 	} | ||||
| 
 | ||||
| 	return nil | ||||
| } | ||||
| 
 | ||||
| func (f *FieldConfig) validate(fields map[string]FieldConfig) error { | ||||
| 	var required bool | ||||
| 	var err error | ||||
| 
 | ||||
| 	if required, err = f.validateRequired(fields); err != nil { | ||||
| 		return err | ||||
| 	} | ||||
| 
 | ||||
| 	// check if the field needs to be updated and set defaults if needed
 | ||||
| 	if err := f.checkIfFieldNeedsUpdate(); err != nil { | ||||
| 		return err | ||||
| 	} | ||||
| 
 | ||||
| 	// then we can check if the field is one_of
 | ||||
| 	if err := f.validateOneOf(); err != nil { | ||||
| 		return err | ||||
| 	} | ||||
| 
 | ||||
| 	// and validate the type
 | ||||
| 	if err := f.validateField(); err != nil { | ||||
| 		return err | ||||
| 	} | ||||
| 
 | ||||
| 	// if the field is delegated, we need to validate the nested field
 | ||||
| 	// but before that, let's check if the field is required
 | ||||
| 	if required && f.Delegated { | ||||
| 		if err := setDefaultsAndValidate(f.CurrentValue.(reflect.Value).Interface(), false); err != nil { | ||||
| 			return err | ||||
| 		} | ||||
| 	} | ||||
| 
 | ||||
| 	return nil | ||||
| } | ||||
| 
 | ||||
| func (f *FieldConfig) populate(config any) { | ||||
| 	// update the field if it's not empty
 | ||||
| 	if !f.shouldUpdateValue { | ||||
| 		return | ||||
| 	} | ||||
| 
 | ||||
| 	reflect.ValueOf(config).Elem().FieldByName(f.Name).Set(reflect.ValueOf(f.CurrentValue)) | ||||
| } | ||||
| 
 | ||||
| func (f *FieldConfig) checkIfFieldNeedsUpdate() error { | ||||
| 	// populate the field if it's empty and has a default value
 | ||||
| 	if f.IsEmpty && f.Defaults != nil { | ||||
| 		switch f.CurrentValue.(type) { | ||||
| 		case null.String: | ||||
| 			f.CurrentValue = null.StringFrom(f.Defaults.(string)) | ||||
| 		case null.Int: | ||||
| 			f.CurrentValue = null.IntFrom(int64(f.Defaults.(int))) | ||||
| 		case null.Bool: | ||||
| 			f.CurrentValue = null.BoolFrom(f.Defaults.(bool)) | ||||
| 		case string: | ||||
| 			f.CurrentValue = f.Defaults.(string) | ||||
| 		case int: | ||||
| 			f.CurrentValue = f.Defaults.(int) | ||||
| 		case bool: | ||||
| 			f.CurrentValue = f.Defaults.(bool) | ||||
| 		case []string: | ||||
| 			f.CurrentValue = f.Defaults.([]string) | ||||
| 		default: | ||||
| 			return fmt.Errorf("field `%s` cannot use default value: unsupported type: %s", f.Name, f.TypeString) | ||||
| 		} | ||||
| 
 | ||||
| 		f.shouldUpdateValue = true | ||||
| 	} | ||||
| 
 | ||||
| 	return nil | ||||
| } | ||||
| 
 | ||||
| func (f *FieldConfig) validateRequired(fields map[string]FieldConfig) (bool, error) { | ||||
| 	var required = f.Required | ||||
| 
 | ||||
| 	// if the field is not required, we need to check if it's required_if
 | ||||
| 	if !required && len(f.RequiredIf) > 0 { | ||||
| 		for key, value := range f.RequiredIf { | ||||
| 			// check if the field's result matches the required_if
 | ||||
| 			// right now we only support string and int
 | ||||
| 			requiredField, ok := fields[key] | ||||
| 			if !ok { | ||||
| 				return required, fmt.Errorf("required_if field `%s` not found", key) | ||||
| 			} | ||||
| 
 | ||||
| 			switch requiredField.CurrentValue.(type) { | ||||
| 			case string: | ||||
| 				if requiredField.CurrentValue.(string) == value.(string) { | ||||
| 					required = true | ||||
| 				} | ||||
| 			case int: | ||||
| 				if requiredField.CurrentValue.(int) == value.(int) { | ||||
| 					required = true | ||||
| 				} | ||||
| 			case null.String: | ||||
| 				if !requiredField.CurrentValue.(null.String).IsZero() && | ||||
| 					requiredField.CurrentValue.(null.String).String == value.(string) { | ||||
| 					required = true | ||||
| 				} | ||||
| 			case null.Int: | ||||
| 				if !requiredField.CurrentValue.(null.Int).IsZero() && | ||||
| 					requiredField.CurrentValue.(null.Int).Int64 == value.(int64) { | ||||
| 					required = true | ||||
| 				} | ||||
| 			} | ||||
| 
 | ||||
| 			// if the field is required, we can break the loop
 | ||||
| 			// because we only need one of the required_if fields to be true
 | ||||
| 			if required { | ||||
| 				break | ||||
| 			} | ||||
| 		} | ||||
| 	} | ||||
| 
 | ||||
| 	if required && f.IsEmpty { | ||||
| 		return false, fmt.Errorf("field `%s` is required", f.Name) | ||||
| 	} | ||||
| 
 | ||||
| 	return required, nil | ||||
| } | ||||
| 
 | ||||
| func checkIfSliceContains(slice []string, one_of []string) bool { | ||||
| 	for _, oneOf := range one_of { | ||||
| 		if slices.Contains(slice, oneOf) { | ||||
| 			return true | ||||
| 		} | ||||
| 	} | ||||
| 
 | ||||
| 	return false | ||||
| } | ||||
| 
 | ||||
| func (f *FieldConfig) validateOneOf() error { | ||||
| 	if len(f.OneOf) == 0 { | ||||
| 		return nil | ||||
| 	} | ||||
| 
 | ||||
| 	var val []string | ||||
| 	switch f.CurrentValue.(type) { | ||||
| 	case string: | ||||
| 		val = []string{f.CurrentValue.(string)} | ||||
| 	case null.String: | ||||
| 		val = []string{f.CurrentValue.(null.String).String} | ||||
| 	case []string: | ||||
| 		// let's validate the value here
 | ||||
| 		val = f.CurrentValue.([]string) | ||||
| 	default: | ||||
| 		return fmt.Errorf("field `%s` cannot use one_of: unsupported type: %s", f.Name, f.TypeString) | ||||
| 	} | ||||
| 
 | ||||
| 	if !checkIfSliceContains(val, f.OneOf) { | ||||
| 		return fmt.Errorf( | ||||
| 			"field `%s` is not one of the allowed values: %s, current value: %s", | ||||
| 			f.Name, | ||||
| 			strings.Join(f.OneOf, ", "), | ||||
| 			strings.Join(val, ", "), | ||||
| 		) | ||||
| 	} | ||||
| 
 | ||||
| 	return nil | ||||
| } | ||||
| 
 | ||||
| func (f *FieldConfig) validateField() error { | ||||
| 	if len(f.ValidateTypes) == 0 || f.IsEmpty { | ||||
| 		return nil | ||||
| 	} | ||||
| 
 | ||||
| 	val, err := toString(f.CurrentValue) | ||||
| 	if err != nil { | ||||
| 		return fmt.Errorf("field `%s` cannot use validate_type: %s", f.Name, err) | ||||
| 	} | ||||
| 
 | ||||
| 	if val == "" { | ||||
| 		return nil | ||||
| 	} | ||||
| 
 | ||||
| 	for _, validateType := range f.ValidateTypes { | ||||
| 		switch validateType { | ||||
| 		case "ipv4": | ||||
| 			if net.ParseIP(val).To4() == nil { | ||||
| 				return fmt.Errorf("field `%s` is not a valid IPv4 address: %s", f.Name, val) | ||||
| 			} | ||||
| 		case "ipv6": | ||||
| 			if net.ParseIP(val).To16() == nil { | ||||
| 				return fmt.Errorf("field `%s` is not a valid IPv6 address: %s", f.Name, val) | ||||
| 			} | ||||
| 		case "hwaddr": | ||||
| 			if _, err := net.ParseMAC(val); err != nil { | ||||
| 				return fmt.Errorf("field `%s` is not a valid MAC address: %s", f.Name, val) | ||||
| 			} | ||||
| 		case "hostname": | ||||
| 			if _, err := idna.Lookup.ToASCII(val); err != nil { | ||||
| 				return fmt.Errorf("field `%s` is not a valid hostname: %s", f.Name, val) | ||||
| 			} | ||||
| 		case "proxy": | ||||
| 			if url, err := url.Parse(val); err != nil || (url.Scheme != "http" && url.Scheme != "https") || url.Host == "" { | ||||
| 				return fmt.Errorf("field `%s` is not a valid HTTP proxy URL: %s", f.Name, val) | ||||
| 			} | ||||
| 		default: | ||||
| 			return fmt.Errorf("field `%s` cannot use validate_type: unsupported validator: %s", f.Name, validateType) | ||||
| 		} | ||||
| 	} | ||||
| 
 | ||||
| 	return nil | ||||
| } | ||||
|  | @ -0,0 +1,117 @@ | |||
| package confparser | ||||
| 
 | ||||
| import ( | ||||
| 	"net" | ||||
| 	"testing" | ||||
| 	"time" | ||||
| 
 | ||||
| 	"github.com/guregu/null/v6" | ||||
| ) | ||||
| 
 | ||||
| type testIPv6Address struct { //nolint:unused
 | ||||
| 	Address           net.IP     `json:"address"` | ||||
| 	Prefix            net.IPNet  `json:"prefix"` | ||||
| 	ValidLifetime     *time.Time `json:"valid_lifetime"` | ||||
| 	PreferredLifetime *time.Time `json:"preferred_lifetime"` | ||||
| 	Scope             int        `json:"scope"` | ||||
| } | ||||
| 
 | ||||
| type testIPv4StaticConfig struct { | ||||
| 	Address null.String `json:"address" validate_type:"ipv4" required:"true"` | ||||
| 	Netmask null.String `json:"netmask" validate_type:"ipv4" required:"true"` | ||||
| 	Gateway null.String `json:"gateway" validate_type:"ipv4" required:"true"` | ||||
| 	DNS     []string    `json:"dns" validate_type:"ipv4" required:"true"` | ||||
| } | ||||
| 
 | ||||
| type testIPv6StaticConfig struct { | ||||
| 	Address null.String `json:"address" validate_type:"ipv6" required:"true"` | ||||
| 	Prefix  null.String `json:"prefix" validate_type:"ipv6" required:"true"` | ||||
| 	Gateway null.String `json:"gateway" validate_type:"ipv6" required:"true"` | ||||
| 	DNS     []string    `json:"dns" validate_type:"ipv6" required:"true"` | ||||
| } | ||||
| type testNetworkConfig struct { | ||||
| 	Hostname null.String `json:"hostname,omitempty"` | ||||
| 	Domain   null.String `json:"domain,omitempty"` | ||||
| 
 | ||||
| 	IPv4Mode   null.String           `json:"ipv4_mode" one_of:"dhcp,static,disabled" default:"dhcp"` | ||||
| 	IPv4Static *testIPv4StaticConfig `json:"ipv4_static,omitempty" required_if:"IPv4Mode=static"` | ||||
| 
 | ||||
| 	IPv6Mode   null.String           `json:"ipv6_mode" one_of:"slaac,dhcpv6,slaac_and_dhcpv6,static,link_local,disabled" default:"slaac"` | ||||
| 	IPv6Static *testIPv6StaticConfig `json:"ipv6_static,omitempty" required_if:"IPv6Mode=static"` | ||||
| 
 | ||||
| 	LLDPMode                null.String `json:"lldp_mode,omitempty" one_of:"disabled,basic,all" default:"basic"` | ||||
| 	LLDPTxTLVs              []string    `json:"lldp_tx_tlvs,omitempty" one_of:"chassis,port,system,vlan" default:"chassis,port,system,vlan"` | ||||
| 	MDNSMode                null.String `json:"mdns_mode,omitempty" one_of:"disabled,auto,ipv4_only,ipv6_only" default:"auto"` | ||||
| 	TimeSyncMode            null.String `json:"time_sync_mode,omitempty" one_of:"ntp_only,ntp_and_http,http_only,custom" default:"ntp_and_http"` | ||||
| 	TimeSyncOrdering        []string    `json:"time_sync_ordering,omitempty" one_of:"http,ntp,ntp_dhcp,ntp_user_provided,http_user_provided" default:"ntp,http"` | ||||
| 	TimeSyncDisableFallback null.Bool   `json:"time_sync_disable_fallback,omitempty" default:"false"` | ||||
| 	TimeSyncParallel        null.Int    `json:"time_sync_parallel,omitempty" default:"4"` | ||||
| 	TimeSyncNTPServers      []string    `json:"time_sync_ntp_servers,omitempty" validate_type:"ipv4_or_ipv6" required_if:"TimeSyncOrdering=ntp_user_provided"` | ||||
| 	TimeSyncHTTPUrls        []string    `json:"time_sync_http_urls,omitempty" validate_type:"url" required_if:"TimeSyncOrdering=http_user_provided"` | ||||
| } | ||||
| 
 | ||||
| func TestValidateConfig(t *testing.T) { | ||||
| 	config := &testNetworkConfig{} | ||||
| 
 | ||||
| 	err := SetDefaultsAndValidate(config) | ||||
| 	if err != nil { | ||||
| 		t.Fatalf("expected no error, got %v", err) | ||||
| 	} | ||||
| } | ||||
| 
 | ||||
| func TestValidateIPv4StaticConfigNetmaskRequiredIfStatic(t *testing.T) { | ||||
| 	config := &testNetworkConfig{ | ||||
| 		IPv4Static: &testIPv4StaticConfig{ | ||||
| 			Address: null.StringFrom("192.168.1.1"), | ||||
| 			Gateway: null.StringFrom("192.168.1.1"), | ||||
| 		}, | ||||
| 		IPv4Mode: null.StringFrom("static"), | ||||
| 	} | ||||
| 
 | ||||
| 	err := SetDefaultsAndValidate(config) | ||||
| 	if err == nil { | ||||
| 		t.Fatalf("expected error, got nil") | ||||
| 	} | ||||
| } | ||||
| 
 | ||||
| func TestValidateIPv4StaticConfigNetmaskNotRequiredIfStatic(t *testing.T) { | ||||
| 	config := &testNetworkConfig{ | ||||
| 		IPv4Static: &testIPv4StaticConfig{ | ||||
| 			Address: null.StringFrom("192.168.1.1"), | ||||
| 			Gateway: null.StringFrom("192.168.1.1"), | ||||
| 		}, | ||||
| 	} | ||||
| 
 | ||||
| 	err := SetDefaultsAndValidate(config) | ||||
| 	if err != nil { | ||||
| 		t.Fatalf("expected no error, got %v", err) | ||||
| 	} | ||||
| } | ||||
| 
 | ||||
| func TestValidateIPv4StaticConfigRequiredIf(t *testing.T) { | ||||
| 	config := &testNetworkConfig{ | ||||
| 		IPv4Mode: null.StringFrom("static"), | ||||
| 	} | ||||
| 
 | ||||
| 	err := SetDefaultsAndValidate(config) | ||||
| 	if err == nil { | ||||
| 		t.Fatalf("expected error, got nil") | ||||
| 	} | ||||
| } | ||||
| 
 | ||||
| func TestValidateIPv4StaticConfigValidateType(t *testing.T) { | ||||
| 	config := &testNetworkConfig{ | ||||
| 		IPv4Static: &testIPv4StaticConfig{ | ||||
| 			Address: null.StringFrom("X"), | ||||
| 			Netmask: null.StringFrom("255.255.255.0"), | ||||
| 			Gateway: null.StringFrom("192.168.1.1"), | ||||
| 			DNS:     []string{"8.8.8.8", "8.8.4.4"}, | ||||
| 		}, | ||||
| 		IPv4Mode: null.StringFrom("static"), | ||||
| 	} | ||||
| 
 | ||||
| 	err := SetDefaultsAndValidate(config) | ||||
| 	if err == nil { | ||||
| 		t.Fatalf("expected error, got nil") | ||||
| 	} | ||||
| } | ||||
|  | @ -0,0 +1,28 @@ | |||
| package confparser | ||||
| 
 | ||||
| import ( | ||||
| 	"fmt" | ||||
| 	"reflect" | ||||
| 	"strings" | ||||
| 
 | ||||
| 	"github.com/guregu/null/v6" | ||||
| ) | ||||
| 
 | ||||
| func splitString(s string) []string { | ||||
| 	if s == "" { | ||||
| 		return []string{} | ||||
| 	} | ||||
| 
 | ||||
| 	return strings.Split(s, ",") | ||||
| } | ||||
| 
 | ||||
| func toString(v any) (string, error) { | ||||
| 	switch v := v.(type) { | ||||
| 	case string: | ||||
| 		return v, nil | ||||
| 	case null.String: | ||||
| 		return v.String, nil | ||||
| 	} | ||||
| 
 | ||||
| 	return "", fmt.Errorf("unsupported type: %s", reflect.TypeOf(v)) | ||||
| } | ||||
|  | @ -0,0 +1,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 | ||||
| } | ||||
|  | @ -0,0 +1,197 @@ | |||
| package logging | ||||
| 
 | ||||
| import ( | ||||
| 	"fmt" | ||||
| 	"io" | ||||
| 	"os" | ||||
| 	"strings" | ||||
| 	"sync" | ||||
| 	"time" | ||||
| 
 | ||||
| 	"github.com/rs/zerolog" | ||||
| ) | ||||
| 
 | ||||
| type Logger struct { | ||||
| 	l               *zerolog.Logger | ||||
| 	scopeLoggers    map[string]*zerolog.Logger | ||||
| 	scopeLevels     map[string]zerolog.Level | ||||
| 	scopeLevelMutex sync.Mutex | ||||
| 
 | ||||
| 	defaultLogLevelFromEnv    zerolog.Level | ||||
| 	defaultLogLevelFromConfig zerolog.Level | ||||
| 	defaultLogLevel           zerolog.Level | ||||
| } | ||||
| 
 | ||||
| const ( | ||||
| 	defaultLogLevel = zerolog.ErrorLevel | ||||
| ) | ||||
| 
 | ||||
| type logOutput struct { | ||||
| 	mu *sync.Mutex | ||||
| } | ||||
| 
 | ||||
| func (w *logOutput) Write(p []byte) (n int, err error) { | ||||
| 	w.mu.Lock() | ||||
| 	defer w.mu.Unlock() | ||||
| 
 | ||||
| 	// TODO: write to file or syslog
 | ||||
| 	if sseServer != nil { | ||||
| 		// use a goroutine to avoid blocking the Write method
 | ||||
| 		go func() { | ||||
| 			sseServer.Message <- string(p) | ||||
| 		}() | ||||
| 	} | ||||
| 	return len(p), nil | ||||
| } | ||||
| 
 | ||||
| var ( | ||||
| 	consoleLogOutput io.Writer = zerolog.ConsoleWriter{ | ||||
| 		Out:           os.Stdout, | ||||
| 		TimeFormat:    time.RFC3339, | ||||
| 		PartsOrder:    []string{"time", "level", "scope", "component", "message"}, | ||||
| 		FieldsExclude: []string{"scope", "component"}, | ||||
| 		FormatPartValueByName: func(value any, name string) string { | ||||
| 			val := fmt.Sprintf("%s", value) | ||||
| 			if name == "component" { | ||||
| 				if value == nil { | ||||
| 					return "-" | ||||
| 				} | ||||
| 			} | ||||
| 			return val | ||||
| 		}, | ||||
| 	} | ||||
| 	fileLogOutput    io.Writer = &logOutput{mu: &sync.Mutex{}} | ||||
| 	defaultLogOutput           = zerolog.MultiLevelWriter(consoleLogOutput, fileLogOutput) | ||||
| 
 | ||||
| 	zerologLevels = map[string]zerolog.Level{ | ||||
| 		"DISABLE": zerolog.Disabled, | ||||
| 		"NOLEVEL": zerolog.NoLevel, | ||||
| 		"PANIC":   zerolog.PanicLevel, | ||||
| 		"FATAL":   zerolog.FatalLevel, | ||||
| 		"ERROR":   zerolog.ErrorLevel, | ||||
| 		"WARN":    zerolog.WarnLevel, | ||||
| 		"INFO":    zerolog.InfoLevel, | ||||
| 		"DEBUG":   zerolog.DebugLevel, | ||||
| 		"TRACE":   zerolog.TraceLevel, | ||||
| 	} | ||||
| ) | ||||
| 
 | ||||
| func NewLogger(zerologLogger zerolog.Logger) *Logger { | ||||
| 	return &Logger{ | ||||
| 		l:                         &zerologLogger, | ||||
| 		scopeLoggers:              make(map[string]*zerolog.Logger), | ||||
| 		scopeLevels:               make(map[string]zerolog.Level), | ||||
| 		scopeLevelMutex:           sync.Mutex{}, | ||||
| 		defaultLogLevelFromEnv:    -2, | ||||
| 		defaultLogLevelFromConfig: -2, | ||||
| 		defaultLogLevel:           defaultLogLevel, | ||||
| 	} | ||||
| } | ||||
| 
 | ||||
| func (l *Logger) updateLogLevel() { | ||||
| 	l.scopeLevelMutex.Lock() | ||||
| 	defer l.scopeLevelMutex.Unlock() | ||||
| 
 | ||||
| 	l.scopeLevels = make(map[string]zerolog.Level) | ||||
| 
 | ||||
| 	finalDefaultLogLevel := l.defaultLogLevel | ||||
| 
 | ||||
| 	for name, level := range zerologLevels { | ||||
| 		env := os.Getenv(fmt.Sprintf("JETKVM_LOG_%s", name)) | ||||
| 
 | ||||
| 		if env == "" { | ||||
| 			env = os.Getenv(fmt.Sprintf("PION_LOG_%s", name)) | ||||
| 		} | ||||
| 
 | ||||
| 		if env == "" { | ||||
| 			env = os.Getenv(fmt.Sprintf("PIONS_LOG_%s", name)) | ||||
| 		} | ||||
| 
 | ||||
| 		if env == "" { | ||||
| 			continue | ||||
| 		} | ||||
| 
 | ||||
| 		if strings.ToLower(env) == "all" { | ||||
| 			l.defaultLogLevelFromEnv = level | ||||
| 
 | ||||
| 			if finalDefaultLogLevel > level { | ||||
| 				finalDefaultLogLevel = level | ||||
| 			} | ||||
| 
 | ||||
| 			continue | ||||
| 		} | ||||
| 
 | ||||
| 		scopes := strings.SplitSeq(strings.ToLower(env), ",") | ||||
| 		for scope := range scopes { | ||||
| 			l.scopeLevels[scope] = level | ||||
| 		} | ||||
| 	} | ||||
| 
 | ||||
| 	l.defaultLogLevel = finalDefaultLogLevel | ||||
| } | ||||
| 
 | ||||
| func (l *Logger) getScopeLoggerLevel(scope string) zerolog.Level { | ||||
| 	if l.scopeLevels == nil { | ||||
| 		l.updateLogLevel() | ||||
| 	} | ||||
| 
 | ||||
| 	var scopeLevel zerolog.Level | ||||
| 	if l.defaultLogLevelFromConfig != -2 { | ||||
| 		scopeLevel = l.defaultLogLevelFromConfig | ||||
| 	} | ||||
| 	if l.defaultLogLevelFromEnv != -2 { | ||||
| 		scopeLevel = l.defaultLogLevelFromEnv | ||||
| 	} | ||||
| 
 | ||||
| 	// if the scope is not in the map, use the default level from the root logger
 | ||||
| 	if level, ok := l.scopeLevels[scope]; ok { | ||||
| 		scopeLevel = level | ||||
| 	} | ||||
| 
 | ||||
| 	return scopeLevel | ||||
| } | ||||
| 
 | ||||
| func (l *Logger) newScopeLogger(scope string) zerolog.Logger { | ||||
| 	scopeLevel := l.getScopeLoggerLevel(scope) | ||||
| 	logger := l.l.Level(scopeLevel).With().Str("component", scope).Logger() | ||||
| 
 | ||||
| 	return logger | ||||
| } | ||||
| 
 | ||||
| func (l *Logger) getLogger(scope string) *zerolog.Logger { | ||||
| 	logger, ok := l.scopeLoggers[scope] | ||||
| 	if !ok || logger == nil { | ||||
| 		scopeLogger := l.newScopeLogger(scope) | ||||
| 		l.scopeLoggers[scope] = &scopeLogger | ||||
| 	} | ||||
| 
 | ||||
| 	return l.scopeLoggers[scope] | ||||
| } | ||||
| 
 | ||||
| func (l *Logger) UpdateLogLevel(configDefaultLogLevel string) { | ||||
| 	needUpdate := false | ||||
| 
 | ||||
| 	if configDefaultLogLevel != "" { | ||||
| 		if logLevel, ok := zerologLevels[configDefaultLogLevel]; ok { | ||||
| 			l.defaultLogLevelFromConfig = logLevel | ||||
| 		} else { | ||||
| 			l.l.Warn().Str("logLevel", configDefaultLogLevel).Msg("invalid defaultLogLevel from config, using ERROR") | ||||
| 		} | ||||
| 
 | ||||
| 		if l.defaultLogLevelFromConfig != l.defaultLogLevel { | ||||
| 			needUpdate = true | ||||
| 		} | ||||
| 	} | ||||
| 
 | ||||
| 	l.updateLogLevel() | ||||
| 
 | ||||
| 	if needUpdate { | ||||
| 		for scope, logger := range l.scopeLoggers { | ||||
| 			currentLevel := logger.GetLevel() | ||||
| 			targetLevel := l.getScopeLoggerLevel(scope) | ||||
| 			if currentLevel != targetLevel { | ||||
| 				*logger = l.newScopeLogger(scope) | ||||
| 			} | ||||
| 		} | ||||
| 	} | ||||
| } | ||||
|  | @ -0,0 +1,63 @@ | |||
| package logging | ||||
| 
 | ||||
| import ( | ||||
| 	"github.com/pion/logging" | ||||
| 	"github.com/rs/zerolog" | ||||
| ) | ||||
| 
 | ||||
| type pionLogger struct { | ||||
| 	logger *zerolog.Logger | ||||
| } | ||||
| 
 | ||||
| // Print all messages except trace.
 | ||||
| func (c pionLogger) Trace(msg string) { | ||||
| 	c.logger.Trace().Msg(msg) | ||||
| } | ||||
| func (c pionLogger) Tracef(format string, args ...any) { | ||||
| 	c.logger.Trace().Msgf(format, args...) | ||||
| } | ||||
| 
 | ||||
| func (c pionLogger) Debug(msg string) { | ||||
| 	c.logger.Debug().Msg(msg) | ||||
| } | ||||
| func (c pionLogger) Debugf(format string, args ...any) { | ||||
| 	c.logger.Debug().Msgf(format, args...) | ||||
| } | ||||
| func (c pionLogger) Info(msg string) { | ||||
| 	c.logger.Info().Msg(msg) | ||||
| } | ||||
| func (c pionLogger) Infof(format string, args ...any) { | ||||
| 	c.logger.Info().Msgf(format, args...) | ||||
| } | ||||
| func (c pionLogger) Warn(msg string) { | ||||
| 	c.logger.Warn().Msg(msg) | ||||
| } | ||||
| func (c pionLogger) Warnf(format string, args ...any) { | ||||
| 	c.logger.Warn().Msgf(format, args...) | ||||
| } | ||||
| func (c pionLogger) Error(msg string) { | ||||
| 	c.logger.Error().Msg(msg) | ||||
| } | ||||
| func (c pionLogger) Errorf(format string, args ...any) { | ||||
| 	c.logger.Error().Msgf(format, args...) | ||||
| } | ||||
| 
 | ||||
| // customLoggerFactory satisfies the interface logging.LoggerFactory
 | ||||
| // This allows us to create different loggers per subsystem. So we can
 | ||||
| // add custom behavior.
 | ||||
| type pionLoggerFactory struct{} | ||||
| 
 | ||||
| func (c pionLoggerFactory) NewLogger(subsystem string) logging.LeveledLogger { | ||||
| 	logger := rootLogger.getLogger(subsystem).With(). | ||||
| 		Str("scope", "pion"). | ||||
| 		Str("component", subsystem). | ||||
| 		Logger() | ||||
| 
 | ||||
| 	return pionLogger{logger: &logger} | ||||
| } | ||||
| 
 | ||||
| var defaultLoggerFactory = &pionLoggerFactory{} | ||||
| 
 | ||||
| func GetPionDefaultLoggerFactory() logging.LoggerFactory { | ||||
| 	return defaultLoggerFactory | ||||
| } | ||||
|  | @ -0,0 +1,20 @@ | |||
| package logging | ||||
| 
 | ||||
| import "github.com/rs/zerolog" | ||||
| 
 | ||||
| var ( | ||||
| 	rootZerologLogger = zerolog.New(defaultLogOutput).With(). | ||||
| 				Str("scope", "jetkvm"). | ||||
| 				Timestamp(). | ||||
| 				Stack(). | ||||
| 				Logger() | ||||
| 	rootLogger = NewLogger(rootZerologLogger) | ||||
| ) | ||||
| 
 | ||||
| func GetRootLogger() *Logger { | ||||
| 	return rootLogger | ||||
| } | ||||
| 
 | ||||
| func GetSubsystemLogger(subsystem string) *zerolog.Logger { | ||||
| 	return rootLogger.getLogger(subsystem) | ||||
| } | ||||
|  | @ -0,0 +1,137 @@ | |||
| package logging | ||||
| 
 | ||||
| import ( | ||||
| 	"embed" | ||||
| 	"io" | ||||
| 	"net/http" | ||||
| 
 | ||||
| 	"github.com/gin-gonic/gin" | ||||
| 	"github.com/rs/zerolog" | ||||
| ) | ||||
| 
 | ||||
| //go:embed sse.html
 | ||||
| var sseHTML embed.FS | ||||
| 
 | ||||
| type sseEvent struct { | ||||
| 	Message       chan string | ||||
| 	NewClients    chan chan string | ||||
| 	ClosedClients chan chan string | ||||
| 	TotalClients  map[chan string]bool | ||||
| } | ||||
| 
 | ||||
| // New event messages are broadcast to all registered client connection channels
 | ||||
| type sseClientChan chan string | ||||
| 
 | ||||
| var ( | ||||
| 	sseServer *sseEvent | ||||
| 	sseLogger *zerolog.Logger | ||||
| ) | ||||
| 
 | ||||
| func init() { | ||||
| 	sseServer = newSseServer() | ||||
| 	sseLogger = GetSubsystemLogger("sse") | ||||
| } | ||||
| 
 | ||||
| // Initialize event and Start procnteessing requests
 | ||||
| func newSseServer() (event *sseEvent) { | ||||
| 	event = &sseEvent{ | ||||
| 		Message:       make(chan string), | ||||
| 		NewClients:    make(chan chan string), | ||||
| 		ClosedClients: make(chan chan string), | ||||
| 		TotalClients:  make(map[chan string]bool), | ||||
| 	} | ||||
| 
 | ||||
| 	go event.listen() | ||||
| 
 | ||||
| 	return | ||||
| } | ||||
| 
 | ||||
| // It Listens all incoming requests from clients.
 | ||||
| // Handles addition and removal of clients and broadcast messages to clients.
 | ||||
| func (stream *sseEvent) listen() { | ||||
| 	for { | ||||
| 		select { | ||||
| 		// Add new available client
 | ||||
| 		case client := <-stream.NewClients: | ||||
| 			stream.TotalClients[client] = true | ||||
| 			sseLogger.Info(). | ||||
| 				Int("total_clients", len(stream.TotalClients)). | ||||
| 				Msg("new client connected") | ||||
| 
 | ||||
| 		// Remove closed client
 | ||||
| 		case client := <-stream.ClosedClients: | ||||
| 			delete(stream.TotalClients, client) | ||||
| 			close(client) | ||||
| 			sseLogger.Info().Int("total_clients", len(stream.TotalClients)).Msg("client disconnected") | ||||
| 
 | ||||
| 		// Broadcast message to client
 | ||||
| 		case eventMsg := <-stream.Message: | ||||
| 			for clientMessageChan := range stream.TotalClients { | ||||
| 				select { | ||||
| 				case clientMessageChan <- eventMsg: | ||||
| 					// Message sent successfully
 | ||||
| 				default: | ||||
| 					// Failed to send, dropping message
 | ||||
| 				} | ||||
| 			} | ||||
| 		} | ||||
| 	} | ||||
| } | ||||
| 
 | ||||
| func (stream *sseEvent) serveHTTP() gin.HandlerFunc { | ||||
| 	return func(c *gin.Context) { | ||||
| 		clientChan := make(sseClientChan) | ||||
| 		stream.NewClients <- clientChan | ||||
| 
 | ||||
| 		go func() { | ||||
| 			<-c.Writer.CloseNotify() | ||||
| 
 | ||||
| 			for range clientChan { | ||||
| 			} | ||||
| 
 | ||||
| 			stream.ClosedClients <- clientChan | ||||
| 		}() | ||||
| 
 | ||||
| 		c.Set("clientChan", clientChan) | ||||
| 		c.Next() | ||||
| 	} | ||||
| } | ||||
| 
 | ||||
| func sseHeadersMiddleware() gin.HandlerFunc { | ||||
| 	return func(c *gin.Context) { | ||||
| 		if c.Request.Method == "GET" && c.NegotiateFormat(gin.MIMEHTML) == gin.MIMEHTML { | ||||
| 			c.FileFromFS("/sse.html", http.FS(sseHTML)) | ||||
| 			c.Status(http.StatusOK) | ||||
| 			c.Abort() | ||||
| 			return | ||||
| 		} | ||||
| 
 | ||||
| 		c.Writer.Header().Set("Content-Type", "text/event-stream") | ||||
| 		c.Writer.Header().Set("Cache-Control", "no-cache") | ||||
| 		c.Writer.Header().Set("Connection", "keep-alive") | ||||
| 		c.Writer.Header().Set("Transfer-Encoding", "chunked") | ||||
| 		c.Writer.Header().Set("Access-Control-Allow-Origin", "*") | ||||
| 		c.Next() | ||||
| 	} | ||||
| } | ||||
| 
 | ||||
| func AttachSSEHandler(router *gin.RouterGroup) { | ||||
| 	router.StaticFS("/log-stream", http.FS(sseHTML)) | ||||
| 	router.GET("/log-stream", sseHeadersMiddleware(), sseServer.serveHTTP(), func(c *gin.Context) { | ||||
| 		v, ok := c.Get("clientChan") | ||||
| 		if !ok { | ||||
| 			return | ||||
| 		} | ||||
| 		clientChan, ok := v.(sseClientChan) | ||||
| 		if !ok { | ||||
| 			return | ||||
| 		} | ||||
| 		c.Stream(func(w io.Writer) bool { | ||||
| 			if msg, ok := <-clientChan; ok { | ||||
| 				c.SSEvent("message", msg) | ||||
| 				return true | ||||
| 			} | ||||
| 			return false | ||||
| 		}) | ||||
| 	}) | ||||
| } | ||||
|  | @ -0,0 +1,319 @@ | |||
| <!DOCTYPE html> | ||||
| <html lang="en"> | ||||
| 
 | ||||
| <head> | ||||
|     <meta charset="UTF-8"> | ||||
|     <title>Server Sent Event</title> | ||||
|     <style> | ||||
|         .main-container { | ||||
|             display: flex; | ||||
|             flex-direction: column; | ||||
|             gap: 10px; | ||||
| 
 | ||||
|             font-family: 'Hack', monospace; | ||||
|             font-size: 12px; | ||||
|         } | ||||
| 
 | ||||
|         #loading { | ||||
|             font-style: italic; | ||||
|         } | ||||
| 
 | ||||
|         .log-entry { | ||||
|             font-size: 12px; | ||||
|             line-height: 1.2; | ||||
|         } | ||||
| 
 | ||||
|         .log-entry > span { | ||||
|             min-width: 0; | ||||
|             overflow-wrap: break-word; | ||||
|             word-break: break-word; | ||||
|             margin-right: 10px; | ||||
|         } | ||||
| 
 | ||||
|         .log-entry > span:last-child { | ||||
|             margin-right: 0; | ||||
|         } | ||||
| 
 | ||||
|         .log-entry.log-entry-trace .log-level { | ||||
|             color: blue; | ||||
|         } | ||||
| 
 | ||||
|         .log-entry.log-entry-debug .log-level { | ||||
|             color: gray; | ||||
|         } | ||||
| 
 | ||||
|         .log-entry.log-entry-info .log-level { | ||||
|             color: green; | ||||
|         } | ||||
| 
 | ||||
|         .log-entry.log-entry-warn .log-level { | ||||
|             color: yellow; | ||||
|         } | ||||
| 
 | ||||
|         .log-entry.log-entry-error .log-level, | ||||
|         .log-entry.log-entry-fatal .log-level, | ||||
|         .log-entry.log-entry-panic .log-level { | ||||
|             color: red; | ||||
|         } | ||||
| 
 | ||||
|         .log-entry.log-entry-info .log-message, | ||||
|         .log-entry.log-entry-warn .log-message, | ||||
|         .log-entry.log-entry-error .log-message, | ||||
|         .log-entry.log-entry-fatal .log-message, | ||||
|         .log-entry.log-entry-panic .log-message { | ||||
|             font-weight: bold; | ||||
|         } | ||||
| 
 | ||||
|         .log-timestamp { | ||||
|             color: #666; | ||||
|             min-width: 150px; | ||||
|         } | ||||
| 
 | ||||
|         .log-level { | ||||
|             font-size: 12px; | ||||
|             min-width: 50px; | ||||
|         } | ||||
| 
 | ||||
|         .log-scope { | ||||
|             font-size: 12px; | ||||
|             min-width: 40px; | ||||
|         } | ||||
| 
 | ||||
|         .log-component { | ||||
|             font-size: 12px; | ||||
|             min-width: 80px; | ||||
|         } | ||||
| 
 | ||||
|         .log-message { | ||||
|             font-size: 12px; | ||||
|             white-space: nowrap; | ||||
|             overflow: hidden; | ||||
|             text-overflow: ellipsis; | ||||
|             max-width: 500px; | ||||
|         } | ||||
| 
 | ||||
|         .log-extras { | ||||
|             color: #000; | ||||
|         } | ||||
|         .log-extras .log-extras-header { | ||||
|             font-weight: bold; | ||||
|             color:cornflowerblue; | ||||
|         } | ||||
|     </style> | ||||
| </head> | ||||
| 
 | ||||
| <body> | ||||
|     <div class="main-container"> | ||||
|         <div id="header"> | ||||
|             <span id="loading"> | ||||
|             Connecting to log stream... | ||||
|             </span> | ||||
| 
 | ||||
|             <span id="stats"> | ||||
| 
 | ||||
|             </span> | ||||
|         </div> | ||||
|         <div id="event-data"> | ||||
| 
 | ||||
|         </div> | ||||
|     </div> | ||||
| </body> | ||||
| 
 | ||||
| <script> | ||||
|     class LogStream { | ||||
|         constructor(url, eventDataElement, loadingElement, statsElement) { | ||||
|             this.url = url; | ||||
|             this.eventDataElement = eventDataElement; | ||||
|             this.loadingElement = loadingElement; | ||||
|             this.statsElement = statsElement; | ||||
|             this.stream = null; | ||||
|             this.reconnectAttempts = 0; | ||||
|             this.maxReconnectAttempts = 10; | ||||
|             this.reconnectDelay = 1000; // Start with 1 second | ||||
|             this.maxReconnectDelay = 30000; // Max 30 seconds | ||||
|             this.isConnecting = false; | ||||
| 
 | ||||
|             this.totalMessages = 0; | ||||
|              | ||||
|             this.connect(); | ||||
|         } | ||||
| 
 | ||||
|         connect() { | ||||
|             if (this.isConnecting) return; | ||||
|             this.isConnecting = true; | ||||
|              | ||||
|             this.loadingElement.innerText = "Connecting to log stream..."; | ||||
|              | ||||
|             this.stream = new EventSource(this.url); | ||||
|              | ||||
|             this.stream.onopen = () => { | ||||
|                 this.isConnecting = false; | ||||
|                 this.reconnectAttempts = 0; | ||||
|                 this.reconnectDelay = 1000; | ||||
|                 this.loadingElement.innerText = "Log stream connected."; | ||||
| 
 | ||||
| 
 | ||||
|                 this.totalMessages = 0; | ||||
|                 this.totalBytes = 0; | ||||
|             }; | ||||
|              | ||||
|             this.stream.onmessage = (event) => { | ||||
|                 this.totalBytes += event.data.length; | ||||
|                 this.totalMessages++; | ||||
| 
 | ||||
|                 const data = JSON.parse(event.data); | ||||
|                 this.addLogEntry(data); | ||||
|                 this.updateStats(); | ||||
|             }; | ||||
|              | ||||
|             this.stream.onerror = () => { | ||||
|                 this.isConnecting = false; | ||||
|                 this.loadingElement.innerText = "Log stream disconnected."; | ||||
|                 this.stream.close(); | ||||
|                 this.handleReconnect(); | ||||
|             }; | ||||
|         } | ||||
| 
 | ||||
|         updateStats() { | ||||
|             this.statsElement.innerHTML = `Messages: <strong>${this.totalMessages}</strong>, Bytes: <strong>${this.totalBytes}</strong> `; | ||||
|         } | ||||
|          | ||||
|         handleReconnect() { | ||||
|             if (this.reconnectAttempts >= this.maxReconnectAttempts) { | ||||
|                 this.loadingElement.innerText = "Failed to reconnect after multiple attempts"; | ||||
|                 return; | ||||
|             } | ||||
|              | ||||
|             this.reconnectAttempts++; | ||||
|             this.reconnectDelay = Math.min(this.reconnectDelay * 1, this.maxReconnectDelay); | ||||
|              | ||||
|             this.loadingElement.innerText = `Reconnecting in ${this.reconnectDelay/1000} seconds... (Attempt ${this.reconnectAttempts}/${this.maxReconnectAttempts})`; | ||||
|              | ||||
|             setTimeout(() => { | ||||
|                 this.connect(); | ||||
|             }, this.reconnectDelay); | ||||
|         } | ||||
|          | ||||
|         addLogEntry(data) { | ||||
|             const el = document.createElement("div"); | ||||
|             el.className = "log-entry log-entry-" + data.level; | ||||
| 
 | ||||
|             const timestamp = document.createElement("span"); | ||||
|             timestamp.className = "log-timestamp"; | ||||
|             timestamp.innerText = data.time; | ||||
|             el.appendChild(timestamp); | ||||
| 
 | ||||
|             const level = document.createElement("span"); | ||||
|             level.className = "log-level"; | ||||
|             level.innerText = this.shortLogLevel(data.level); | ||||
|             el.appendChild(level); | ||||
| 
 | ||||
|             const scope = document.createElement("span"); | ||||
|             scope.className = "log-scope"; | ||||
|             scope.innerText = data.scope; | ||||
|             el.appendChild(scope); | ||||
| 
 | ||||
|             const component = document.createElement("span"); | ||||
|             component.className = "log-component"; | ||||
|             component.innerText = data.component; | ||||
|             el.appendChild(component); | ||||
| 
 | ||||
|             const message = document.createElement("span"); | ||||
|             message.className = "log-message"; | ||||
|             message.innerText = data.message; | ||||
|             el.appendChild(message); | ||||
| 
 | ||||
|             this.addLogExtras(el, data); | ||||
| 
 | ||||
|             this.eventDataElement.appendChild(el); | ||||
| 
 | ||||
|             window.scrollTo(0, document.body.scrollHeight); | ||||
|         } | ||||
|          | ||||
|         shortLogLevel(level) { | ||||
|             switch (level) { | ||||
|                 case "trace": | ||||
|                     return "TRC"; | ||||
|                 case "debug": | ||||
|                     return "DBG"; | ||||
|                 case "info": | ||||
|                     return "INF"; | ||||
|                 case "warn": | ||||
|                     return "WRN"; | ||||
|                 case "error": | ||||
|                     return "ERR"; | ||||
|                 case "fatal": | ||||
|                     return "FTL"; | ||||
|                 case "panic": | ||||
|                     return "PNC"; | ||||
|                 default: | ||||
|                     return level; | ||||
|             } | ||||
|         } | ||||
|          | ||||
|         addLogExtras(el, data) { | ||||
|             const excludeKeys = [ | ||||
|                 "timestamp", | ||||
|                 "time", | ||||
|                 "level", | ||||
|                 "scope", | ||||
|                 "component", | ||||
|                 "message", | ||||
|             ]; | ||||
|              | ||||
|             const extras = {}; | ||||
|             for (const key in data) { | ||||
|                 if (excludeKeys.includes(key)) { | ||||
|                     continue; | ||||
|                 } | ||||
|                 extras[key] = data[key]; | ||||
|             } | ||||
| 
 | ||||
|             for (const key in extras) { | ||||
|                 const extra = document.createElement("span"); | ||||
|                 extra.className = "log-extras log-extras-" + key; | ||||
| 
 | ||||
|                 const extraKey = document.createElement("span"); | ||||
|                 extraKey.className = "log-extras-header"; | ||||
|                 extraKey.innerText = key + '='; | ||||
|                 extra.appendChild(extraKey); | ||||
| 
 | ||||
|                 const extraValue = document.createElement("span"); | ||||
|                 extraValue.className = "log-extras-value"; | ||||
|                  | ||||
|                 let value = extras[key]; | ||||
|                 if (typeof value === 'object') { | ||||
|                     value = JSON.stringify(value); | ||||
|                 }    | ||||
|                 extraValue.innerText = value; | ||||
|                 extra.appendChild(extraValue); | ||||
| 
 | ||||
|                 el.appendChild(extra); | ||||
|             } | ||||
|         } | ||||
|          | ||||
|         disconnect() { | ||||
|             if (this.stream) { | ||||
|                 this.stream.close(); | ||||
|                 this.stream = null; | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     // Initialize the log stream when the page loads | ||||
|     document.addEventListener('DOMContentLoaded', () => { | ||||
|         const logStream = new LogStream( | ||||
|             "/developer/log-stream", | ||||
|             document.getElementById("event-data"), | ||||
|             document.getElementById("loading"), | ||||
|             document.getElementById("stats"), | ||||
|         ); | ||||
|          | ||||
|         // Clean up when the page is unloaded | ||||
|         window.addEventListener('beforeunload', () => { | ||||
|             logStream.disconnect(); | ||||
|         }); | ||||
|     }); | ||||
| </script> | ||||
| 
 | ||||
| </html> | ||||
|  | @ -0,0 +1,32 @@ | |||
| package logging | ||||
| 
 | ||||
| import ( | ||||
| 	"fmt" | ||||
| 	"os" | ||||
| 
 | ||||
| 	"github.com/rs/zerolog" | ||||
| ) | ||||
| 
 | ||||
| var defaultLogger = zerolog.New(os.Stdout).Level(zerolog.InfoLevel) | ||||
| 
 | ||||
| func GetDefaultLogger() *zerolog.Logger { | ||||
| 	return &defaultLogger | ||||
| } | ||||
| 
 | ||||
| func ErrorfL(l *zerolog.Logger, format string, err error, args ...any) error { | ||||
| 	// TODO: move rootLogger to logging package
 | ||||
| 	if l == nil { | ||||
| 		l = &defaultLogger | ||||
| 	} | ||||
| 
 | ||||
| 	l.Error().Err(err).Msgf(format, args...) | ||||
| 
 | ||||
| 	if err == nil { | ||||
| 		return fmt.Errorf(format, args...) | ||||
| 	} | ||||
| 
 | ||||
| 	err_msg := err.Error() + ": %v" | ||||
| 	err_args := append(args, err) | ||||
| 
 | ||||
| 	return fmt.Errorf(err_msg, err_args...) | ||||
| } | ||||
|  | @ -0,0 +1,190 @@ | |||
| package mdns | ||||
| 
 | ||||
| import ( | ||||
| 	"fmt" | ||||
| 	"net" | ||||
| 	"reflect" | ||||
| 	"strings" | ||||
| 	"sync" | ||||
| 
 | ||||
| 	"github.com/jetkvm/kvm/internal/logging" | ||||
| 	pion_mdns "github.com/pion/mdns/v2" | ||||
| 	"github.com/rs/zerolog" | ||||
| 	"golang.org/x/net/ipv4" | ||||
| 	"golang.org/x/net/ipv6" | ||||
| ) | ||||
| 
 | ||||
| type MDNS struct { | ||||
| 	conn *pion_mdns.Conn | ||||
| 	lock sync.Mutex | ||||
| 	l    *zerolog.Logger | ||||
| 
 | ||||
| 	localNames    []string | ||||
| 	listenOptions *MDNSListenOptions | ||||
| } | ||||
| 
 | ||||
| type MDNSListenOptions struct { | ||||
| 	IPv4 bool | ||||
| 	IPv6 bool | ||||
| } | ||||
| 
 | ||||
| type MDNSOptions struct { | ||||
| 	Logger        *zerolog.Logger | ||||
| 	LocalNames    []string | ||||
| 	ListenOptions *MDNSListenOptions | ||||
| } | ||||
| 
 | ||||
| const ( | ||||
| 	DefaultAddressIPv4 = pion_mdns.DefaultAddressIPv4 | ||||
| 	DefaultAddressIPv6 = pion_mdns.DefaultAddressIPv6 | ||||
| ) | ||||
| 
 | ||||
| func NewMDNS(opts *MDNSOptions) (*MDNS, error) { | ||||
| 	if opts.Logger == nil { | ||||
| 		opts.Logger = logging.GetDefaultLogger() | ||||
| 	} | ||||
| 
 | ||||
| 	if opts.ListenOptions == nil { | ||||
| 		opts.ListenOptions = &MDNSListenOptions{ | ||||
| 			IPv4: true, | ||||
| 			IPv6: true, | ||||
| 		} | ||||
| 	} | ||||
| 
 | ||||
| 	return &MDNS{ | ||||
| 		l:             opts.Logger, | ||||
| 		lock:          sync.Mutex{}, | ||||
| 		localNames:    opts.LocalNames, | ||||
| 		listenOptions: opts.ListenOptions, | ||||
| 	}, nil | ||||
| } | ||||
| 
 | ||||
| func (m *MDNS) start(allowRestart bool) error { | ||||
| 	m.lock.Lock() | ||||
| 	defer m.lock.Unlock() | ||||
| 
 | ||||
| 	if m.conn != nil { | ||||
| 		if !allowRestart { | ||||
| 			return fmt.Errorf("mDNS server already running") | ||||
| 		} | ||||
| 
 | ||||
| 		m.conn.Close() | ||||
| 	} | ||||
| 
 | ||||
| 	if m.listenOptions == nil { | ||||
| 		return fmt.Errorf("listen options not set") | ||||
| 	} | ||||
| 
 | ||||
| 	if !m.listenOptions.IPv4 && !m.listenOptions.IPv6 { | ||||
| 		m.l.Info().Msg("mDNS server disabled") | ||||
| 		return nil | ||||
| 	} | ||||
| 
 | ||||
| 	var ( | ||||
| 		addr4, addr6 *net.UDPAddr | ||||
| 		l4, l6       *net.UDPConn | ||||
| 		p4           *ipv4.PacketConn | ||||
| 		p6           *ipv6.PacketConn | ||||
| 		err          error | ||||
| 	) | ||||
| 
 | ||||
| 	if m.listenOptions.IPv4 { | ||||
| 		addr4, err = net.ResolveUDPAddr("udp4", DefaultAddressIPv4) | ||||
| 		if err != nil { | ||||
| 			return err | ||||
| 		} | ||||
| 
 | ||||
| 		l4, err = net.ListenUDP("udp4", addr4) | ||||
| 		if err != nil { | ||||
| 			return err | ||||
| 		} | ||||
| 
 | ||||
| 		p4 = ipv4.NewPacketConn(l4) | ||||
| 	} | ||||
| 
 | ||||
| 	if m.listenOptions.IPv6 { | ||||
| 		addr6, err = net.ResolveUDPAddr("udp6", DefaultAddressIPv6) | ||||
| 		if err != nil { | ||||
| 			return err | ||||
| 		} | ||||
| 
 | ||||
| 		l6, err = net.ListenUDP("udp6", addr6) | ||||
| 		if err != nil { | ||||
| 			return err | ||||
| 		} | ||||
| 
 | ||||
| 		p6 = ipv6.NewPacketConn(l6) | ||||
| 	} | ||||
| 
 | ||||
| 	scopeLogger := m.l.With(). | ||||
| 		Interface("local_names", m.localNames). | ||||
| 		Bool("ipv4", m.listenOptions.IPv4). | ||||
| 		Bool("ipv6", m.listenOptions.IPv6). | ||||
| 		Logger() | ||||
| 
 | ||||
| 	newLocalNames := make([]string, len(m.localNames)) | ||||
| 	for i, name := range m.localNames { | ||||
| 		newLocalNames[i] = strings.TrimRight(strings.ToLower(name), ".") | ||||
| 		if !strings.HasSuffix(newLocalNames[i], ".local") { | ||||
| 			newLocalNames[i] = newLocalNames[i] + ".local" | ||||
| 		} | ||||
| 	} | ||||
| 
 | ||||
| 	mDNSConn, err := pion_mdns.Server(p4, p6, &pion_mdns.Config{ | ||||
| 		LocalNames:    newLocalNames, | ||||
| 		LoggerFactory: logging.GetPionDefaultLoggerFactory(), | ||||
| 	}) | ||||
| 
 | ||||
| 	if err != nil { | ||||
| 		scopeLogger.Warn().Err(err).Msg("failed to start mDNS server") | ||||
| 		return err | ||||
| 	} | ||||
| 
 | ||||
| 	m.conn = mDNSConn | ||||
| 	scopeLogger.Info().Msg("mDNS server started") | ||||
| 
 | ||||
| 	return nil | ||||
| } | ||||
| 
 | ||||
| func (m *MDNS) Start() error { | ||||
| 	return m.start(false) | ||||
| } | ||||
| 
 | ||||
| func (m *MDNS) Restart() error { | ||||
| 	return m.start(true) | ||||
| } | ||||
| 
 | ||||
| func (m *MDNS) Stop() error { | ||||
| 	m.lock.Lock() | ||||
| 	defer m.lock.Unlock() | ||||
| 
 | ||||
| 	if m.conn == nil { | ||||
| 		return nil | ||||
| 	} | ||||
| 
 | ||||
| 	return m.conn.Close() | ||||
| } | ||||
| 
 | ||||
| func (m *MDNS) SetLocalNames(localNames []string, always bool) error { | ||||
| 	if reflect.DeepEqual(m.localNames, localNames) && !always { | ||||
| 		return nil | ||||
| 	} | ||||
| 
 | ||||
| 	m.localNames = localNames | ||||
| 	_ = m.Restart() | ||||
| 
 | ||||
| 	return nil | ||||
| } | ||||
| 
 | ||||
| func (m *MDNS) SetListenOptions(listenOptions *MDNSListenOptions) error { | ||||
| 	if m.listenOptions != nil && | ||||
| 		m.listenOptions.IPv4 == listenOptions.IPv4 && | ||||
| 		m.listenOptions.IPv6 == listenOptions.IPv6 { | ||||
| 		return nil | ||||
| 	} | ||||
| 
 | ||||
| 	m.listenOptions = listenOptions | ||||
| 	_ = m.Restart() | ||||
| 
 | ||||
| 	return nil | ||||
| } | ||||
|  | @ -0,0 +1 @@ | |||
| package mdns | ||||
|  | @ -0,0 +1,126 @@ | |||
| package network | ||||
| 
 | ||||
| import ( | ||||
| 	"fmt" | ||||
| 	"net" | ||||
| 	"net/http" | ||||
| 	"net/url" | ||||
| 	"time" | ||||
| 
 | ||||
| 	"github.com/guregu/null/v6" | ||||
| 	"github.com/jetkvm/kvm/internal/mdns" | ||||
| 	"golang.org/x/net/idna" | ||||
| ) | ||||
| 
 | ||||
| type IPv6Address struct { | ||||
| 	Address           net.IP     `json:"address"` | ||||
| 	Prefix            net.IPNet  `json:"prefix"` | ||||
| 	ValidLifetime     *time.Time `json:"valid_lifetime"` | ||||
| 	PreferredLifetime *time.Time `json:"preferred_lifetime"` | ||||
| 	Scope             int        `json:"scope"` | ||||
| } | ||||
| 
 | ||||
| type IPv4StaticConfig struct { | ||||
| 	Address null.String `json:"address,omitempty" validate_type:"ipv4" required:"true"` | ||||
| 	Netmask null.String `json:"netmask,omitempty" validate_type:"ipv4" required:"true"` | ||||
| 	Gateway null.String `json:"gateway,omitempty" validate_type:"ipv4" required:"true"` | ||||
| 	DNS     []string    `json:"dns,omitempty" validate_type:"ipv4" required:"true"` | ||||
| } | ||||
| 
 | ||||
| type IPv6StaticConfig struct { | ||||
| 	Address null.String `json:"address,omitempty" validate_type:"ipv6" required:"true"` | ||||
| 	Prefix  null.String `json:"prefix,omitempty" validate_type:"ipv6" required:"true"` | ||||
| 	Gateway null.String `json:"gateway,omitempty" validate_type:"ipv6" required:"true"` | ||||
| 	DNS     []string    `json:"dns,omitempty" validate_type:"ipv6" required:"true"` | ||||
| } | ||||
| type NetworkConfig struct { | ||||
| 	Hostname  null.String `json:"hostname,omitempty" validate_type:"hostname"` | ||||
| 	HTTPProxy null.String `json:"http_proxy,omitempty" validate_type:"proxy"` | ||||
| 	Domain    null.String `json:"domain,omitempty" validate_type:"hostname"` | ||||
| 
 | ||||
| 	IPv4Mode   null.String       `json:"ipv4_mode,omitempty" one_of:"dhcp,static,disabled" default:"dhcp"` | ||||
| 	IPv4Static *IPv4StaticConfig `json:"ipv4_static,omitempty" required_if:"IPv4Mode=static"` | ||||
| 
 | ||||
| 	IPv6Mode   null.String       `json:"ipv6_mode,omitempty" one_of:"slaac,dhcpv6,slaac_and_dhcpv6,static,link_local,disabled" default:"slaac"` | ||||
| 	IPv6Static *IPv6StaticConfig `json:"ipv6_static,omitempty" required_if:"IPv6Mode=static"` | ||||
| 
 | ||||
| 	LLDPMode                null.String `json:"lldp_mode,omitempty" one_of:"disabled,basic,all" default:"basic"` | ||||
| 	LLDPTxTLVs              []string    `json:"lldp_tx_tlvs,omitempty" one_of:"chassis,port,system,vlan" default:"chassis,port,system,vlan"` | ||||
| 	MDNSMode                null.String `json:"mdns_mode,omitempty" one_of:"disabled,auto,ipv4_only,ipv6_only" default:"auto"` | ||||
| 	TimeSyncMode            null.String `json:"time_sync_mode,omitempty" one_of:"ntp_only,ntp_and_http,http_only,custom" default:"ntp_and_http"` | ||||
| 	TimeSyncOrdering        []string    `json:"time_sync_ordering,omitempty" one_of:"http,ntp,ntp_dhcp,ntp_user_provided,http_user_provided" default:"ntp,http"` | ||||
| 	TimeSyncDisableFallback null.Bool   `json:"time_sync_disable_fallback,omitempty" default:"false"` | ||||
| 	TimeSyncParallel        null.Int    `json:"time_sync_parallel,omitempty" default:"4"` | ||||
| 	TimeSyncNTPServers      []string    `json:"time_sync_ntp_servers,omitempty" validate_type:"ipv4_or_ipv6" required_if:"TimeSyncOrdering=ntp_user_provided"` | ||||
| 	TimeSyncHTTPUrls        []string    `json:"time_sync_http_urls,omitempty" validate_type:"url" required_if:"TimeSyncOrdering=http_user_provided"` | ||||
| } | ||||
| 
 | ||||
| func (c *NetworkConfig) GetMDNSMode() *mdns.MDNSListenOptions { | ||||
| 	listenOptions := &mdns.MDNSListenOptions{ | ||||
| 		IPv4: c.IPv4Mode.String != "disabled", | ||||
| 		IPv6: c.IPv6Mode.String != "disabled", | ||||
| 	} | ||||
| 
 | ||||
| 	switch c.MDNSMode.String { | ||||
| 	case "ipv4_only": | ||||
| 		listenOptions.IPv6 = false | ||||
| 	case "ipv6_only": | ||||
| 		listenOptions.IPv4 = false | ||||
| 	case "disabled": | ||||
| 		listenOptions.IPv4 = false | ||||
| 		listenOptions.IPv6 = false | ||||
| 	} | ||||
| 
 | ||||
| 	return listenOptions | ||||
| } | ||||
| 
 | ||||
| func (s *NetworkConfig) GetTransportProxyFunc() func(*http.Request) (*url.URL, error) { | ||||
| 	return func(*http.Request) (*url.URL, error) { | ||||
| 		if s.HTTPProxy.String == "" { | ||||
| 			return nil, nil | ||||
| 		} else { | ||||
| 			proxyUrl, _ := url.Parse(s.HTTPProxy.String) | ||||
| 			return proxyUrl, nil | ||||
| 		} | ||||
| 	} | ||||
| } | ||||
| 
 | ||||
| func (s *NetworkInterfaceState) GetHostname() string { | ||||
| 	hostname := ToValidHostname(s.config.Hostname.String) | ||||
| 
 | ||||
| 	if hostname == "" { | ||||
| 		return s.defaultHostname | ||||
| 	} | ||||
| 
 | ||||
| 	return hostname | ||||
| } | ||||
| 
 | ||||
| func ToValidDomain(domain string) string { | ||||
| 	ascii, err := idna.Lookup.ToASCII(domain) | ||||
| 	if err != nil { | ||||
| 		return "" | ||||
| 	} | ||||
| 
 | ||||
| 	return ascii | ||||
| } | ||||
| 
 | ||||
| func (s *NetworkInterfaceState) GetDomain() string { | ||||
| 	domain := ToValidDomain(s.config.Domain.String) | ||||
| 
 | ||||
| 	if domain == "" { | ||||
| 		lease := s.dhcpClient.GetLease() | ||||
| 		if lease != nil && lease.Domain != "" { | ||||
| 			domain = ToValidDomain(lease.Domain) | ||||
| 		} | ||||
| 	} | ||||
| 
 | ||||
| 	if domain == "" { | ||||
| 		return "local" | ||||
| 	} | ||||
| 
 | ||||
| 	return domain | ||||
| } | ||||
| 
 | ||||
| func (s *NetworkInterfaceState) GetFQDN() string { | ||||
| 	return fmt.Sprintf("%s.%s", s.GetHostname(), s.GetDomain()) | ||||
| } | ||||
|  | @ -0,0 +1,11 @@ | |||
| package network | ||||
| 
 | ||||
| type DhcpTargetState int | ||||
| 
 | ||||
| const ( | ||||
| 	DhcpTargetStateDoNothing DhcpTargetState = iota | ||||
| 	DhcpTargetStateStart | ||||
| 	DhcpTargetStateStop | ||||
| 	DhcpTargetStateRenew | ||||
| 	DhcpTargetStateRelease | ||||
| ) | ||||
|  | @ -0,0 +1,137 @@ | |||
| package network | ||||
| 
 | ||||
| import ( | ||||
| 	"fmt" | ||||
| 	"io" | ||||
| 	"os" | ||||
| 	"os/exec" | ||||
| 	"strings" | ||||
| 	"sync" | ||||
| 
 | ||||
| 	"golang.org/x/net/idna" | ||||
| ) | ||||
| 
 | ||||
| const ( | ||||
| 	hostnamePath = "/etc/hostname" | ||||
| 	hostsPath    = "/etc/hosts" | ||||
| ) | ||||
| 
 | ||||
| var ( | ||||
| 	hostnameLock sync.Mutex = sync.Mutex{} | ||||
| ) | ||||
| 
 | ||||
| func updateEtcHosts(hostname string, fqdn string) error { | ||||
| 	// update /etc/hosts
 | ||||
| 	hostsFile, err := os.OpenFile(hostsPath, os.O_RDWR|os.O_SYNC, os.ModeExclusive) | ||||
| 	if err != nil { | ||||
| 		return fmt.Errorf("failed to open %s: %w", hostsPath, err) | ||||
| 	} | ||||
| 	defer hostsFile.Close() | ||||
| 
 | ||||
| 	// read all lines
 | ||||
| 	if _, err := hostsFile.Seek(0, io.SeekStart); err != nil { | ||||
| 		return fmt.Errorf("failed to seek %s: %w", hostsPath, err) | ||||
| 	} | ||||
| 
 | ||||
| 	lines, err := io.ReadAll(hostsFile) | ||||
| 	if err != nil { | ||||
| 		return fmt.Errorf("failed to read %s: %w", hostsPath, err) | ||||
| 	} | ||||
| 
 | ||||
| 	newLines := []string{} | ||||
| 	hostLine := fmt.Sprintf("127.0.1.1\t%s %s", hostname, fqdn) | ||||
| 	hostLineExists := false | ||||
| 
 | ||||
| 	for line := range strings.SplitSeq(string(lines), "\n") { | ||||
| 		if strings.HasPrefix(line, "127.0.1.1") { | ||||
| 			hostLineExists = true | ||||
| 			line = hostLine | ||||
| 		} | ||||
| 		newLines = append(newLines, line) | ||||
| 	} | ||||
| 
 | ||||
| 	if !hostLineExists { | ||||
| 		newLines = append(newLines, hostLine) | ||||
| 	} | ||||
| 
 | ||||
| 	if err := hostsFile.Truncate(0); err != nil { | ||||
| 		return fmt.Errorf("failed to truncate %s: %w", hostsPath, err) | ||||
| 	} | ||||
| 
 | ||||
| 	if _, err := hostsFile.Seek(0, io.SeekStart); err != nil { | ||||
| 		return fmt.Errorf("failed to seek %s: %w", hostsPath, err) | ||||
| 	} | ||||
| 
 | ||||
| 	if _, err := hostsFile.Write([]byte(strings.Join(newLines, "\n"))); err != nil { | ||||
| 		return fmt.Errorf("failed to write %s: %w", hostsPath, err) | ||||
| 	} | ||||
| 
 | ||||
| 	return nil | ||||
| } | ||||
| 
 | ||||
| func ToValidHostname(hostname string) string { | ||||
| 	ascii, err := idna.Lookup.ToASCII(hostname) | ||||
| 	if err != nil { | ||||
| 		return "" | ||||
| 	} | ||||
| 	return ascii | ||||
| } | ||||
| 
 | ||||
| func SetHostname(hostname string, fqdn string) error { | ||||
| 	hostnameLock.Lock() | ||||
| 	defer hostnameLock.Unlock() | ||||
| 
 | ||||
| 	hostname = ToValidHostname(strings.TrimSpace(hostname)) | ||||
| 	fqdn = ToValidHostname(strings.TrimSpace(fqdn)) | ||||
| 
 | ||||
| 	if hostname == "" { | ||||
| 		return fmt.Errorf("invalid hostname: %s", hostname) | ||||
| 	} | ||||
| 
 | ||||
| 	if fqdn == "" { | ||||
| 		fqdn = hostname | ||||
| 	} | ||||
| 
 | ||||
| 	// update /etc/hostname
 | ||||
| 	if err := os.WriteFile(hostnamePath, []byte(hostname), 0644); err != nil { | ||||
| 		return fmt.Errorf("failed to write %s: %w", hostnamePath, err) | ||||
| 	} | ||||
| 
 | ||||
| 	// update /etc/hosts
 | ||||
| 	if err := updateEtcHosts(hostname, fqdn); err != nil { | ||||
| 		return fmt.Errorf("failed to update /etc/hosts: %w", err) | ||||
| 	} | ||||
| 
 | ||||
| 	// run hostname
 | ||||
| 	if err := exec.Command("hostname", "-F", hostnamePath).Run(); err != nil { | ||||
| 		return fmt.Errorf("failed to run hostname: %w", err) | ||||
| 	} | ||||
| 
 | ||||
| 	return nil | ||||
| } | ||||
| 
 | ||||
| func (s *NetworkInterfaceState) setHostnameIfNotSame() error { | ||||
| 	hostname := s.GetHostname() | ||||
| 	currentHostname, _ := os.Hostname() | ||||
| 
 | ||||
| 	fqdn := fmt.Sprintf("%s.%s", hostname, s.GetDomain()) | ||||
| 
 | ||||
| 	if currentHostname == hostname && s.currentFqdn == fqdn && s.currentHostname == hostname { | ||||
| 		return nil | ||||
| 	} | ||||
| 
 | ||||
| 	scopedLogger := s.l.With().Str("hostname", hostname).Str("fqdn", fqdn).Logger() | ||||
| 
 | ||||
| 	err := SetHostname(hostname, fqdn) | ||||
| 	if err != nil { | ||||
| 		scopedLogger.Error().Err(err).Msg("failed to set hostname") | ||||
| 		return err | ||||
| 	} | ||||
| 
 | ||||
| 	s.currentHostname = hostname | ||||
| 	s.currentFqdn = fqdn | ||||
| 
 | ||||
| 	scopedLogger.Info().Msg("hostname set") | ||||
| 
 | ||||
| 	return nil | ||||
| } | ||||
|  | @ -0,0 +1,394 @@ | |||
| package network | ||||
| 
 | ||||
| import ( | ||||
| 	"fmt" | ||||
| 	"net" | ||||
| 	"sync" | ||||
| 
 | ||||
| 	"github.com/jetkvm/kvm/internal/confparser" | ||||
| 	"github.com/jetkvm/kvm/internal/logging" | ||||
| 	"github.com/jetkvm/kvm/internal/udhcpc" | ||||
| 	"github.com/rs/zerolog" | ||||
| 
 | ||||
| 	"github.com/vishvananda/netlink" | ||||
| ) | ||||
| 
 | ||||
| type NetworkInterfaceState struct { | ||||
| 	interfaceName string | ||||
| 	interfaceUp   bool | ||||
| 	ipv4Addr      *net.IP | ||||
| 	ipv4Addresses []string | ||||
| 	ipv6Addr      *net.IP | ||||
| 	ipv6Addresses []IPv6Address | ||||
| 	ipv6LinkLocal *net.IP | ||||
| 	ntpAddresses  []*net.IP | ||||
| 	macAddr       *net.HardwareAddr | ||||
| 
 | ||||
| 	l         *zerolog.Logger | ||||
| 	stateLock sync.Mutex | ||||
| 
 | ||||
| 	config     *NetworkConfig | ||||
| 	dhcpClient *udhcpc.DHCPClient | ||||
| 
 | ||||
| 	defaultHostname string | ||||
| 	currentHostname string | ||||
| 	currentFqdn     string | ||||
| 
 | ||||
| 	onStateChange  func(state *NetworkInterfaceState) | ||||
| 	onInitialCheck func(state *NetworkInterfaceState) | ||||
| 	cbConfigChange func(config *NetworkConfig) | ||||
| 
 | ||||
| 	checked bool | ||||
| } | ||||
| 
 | ||||
| type NetworkInterfaceOptions struct { | ||||
| 	InterfaceName     string | ||||
| 	DhcpPidFile       string | ||||
| 	Logger            *zerolog.Logger | ||||
| 	DefaultHostname   string | ||||
| 	OnStateChange     func(state *NetworkInterfaceState) | ||||
| 	OnInitialCheck    func(state *NetworkInterfaceState) | ||||
| 	OnDhcpLeaseChange func(lease *udhcpc.Lease, state *NetworkInterfaceState) | ||||
| 	OnConfigChange    func(config *NetworkConfig) | ||||
| 	NetworkConfig     *NetworkConfig | ||||
| } | ||||
| 
 | ||||
| func NewNetworkInterfaceState(opts *NetworkInterfaceOptions) (*NetworkInterfaceState, error) { | ||||
| 	if opts.NetworkConfig == nil { | ||||
| 		return nil, fmt.Errorf("NetworkConfig can not be nil") | ||||
| 	} | ||||
| 
 | ||||
| 	if opts.DefaultHostname == "" { | ||||
| 		opts.DefaultHostname = "jetkvm" | ||||
| 	} | ||||
| 
 | ||||
| 	err := confparser.SetDefaultsAndValidate(opts.NetworkConfig) | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
| 
 | ||||
| 	l := opts.Logger | ||||
| 	s := &NetworkInterfaceState{ | ||||
| 		interfaceName:   opts.InterfaceName, | ||||
| 		defaultHostname: opts.DefaultHostname, | ||||
| 		stateLock:       sync.Mutex{}, | ||||
| 		l:               l, | ||||
| 		onStateChange:   opts.OnStateChange, | ||||
| 		onInitialCheck:  opts.OnInitialCheck, | ||||
| 		cbConfigChange:  opts.OnConfigChange, | ||||
| 		config:          opts.NetworkConfig, | ||||
| 		ntpAddresses:    make([]*net.IP, 0), | ||||
| 	} | ||||
| 
 | ||||
| 	// create the dhcp client
 | ||||
| 	dhcpClient := udhcpc.NewDHCPClient(&udhcpc.DHCPClientOptions{ | ||||
| 		InterfaceName: opts.InterfaceName, | ||||
| 		PidFile:       opts.DhcpPidFile, | ||||
| 		Logger:        l, | ||||
| 		OnLeaseChange: func(lease *udhcpc.Lease) { | ||||
| 			_, err := s.update() | ||||
| 			if err != nil { | ||||
| 				opts.Logger.Error().Err(err).Msg("failed to update network state") | ||||
| 				return | ||||
| 			} | ||||
| 			_ = s.updateNtpServersFromLease(lease) | ||||
| 			_ = s.setHostnameIfNotSame() | ||||
| 
 | ||||
| 			opts.OnDhcpLeaseChange(lease, s) | ||||
| 		}, | ||||
| 	}) | ||||
| 
 | ||||
| 	s.dhcpClient = dhcpClient | ||||
| 
 | ||||
| 	return s, nil | ||||
| } | ||||
| 
 | ||||
| func (s *NetworkInterfaceState) IsUp() bool { | ||||
| 	return s.interfaceUp | ||||
| } | ||||
| 
 | ||||
| func (s *NetworkInterfaceState) HasIPAssigned() bool { | ||||
| 	return s.ipv4Addr != nil || s.ipv6Addr != nil | ||||
| } | ||||
| 
 | ||||
| func (s *NetworkInterfaceState) IsOnline() bool { | ||||
| 	return s.IsUp() && s.HasIPAssigned() | ||||
| } | ||||
| 
 | ||||
| func (s *NetworkInterfaceState) IPv4() *net.IP { | ||||
| 	return s.ipv4Addr | ||||
| } | ||||
| 
 | ||||
| func (s *NetworkInterfaceState) IPv4String() string { | ||||
| 	if s.ipv4Addr == nil { | ||||
| 		return "..." | ||||
| 	} | ||||
| 	return s.ipv4Addr.String() | ||||
| } | ||||
| 
 | ||||
| func (s *NetworkInterfaceState) IPv6() *net.IP { | ||||
| 	return s.ipv6Addr | ||||
| } | ||||
| 
 | ||||
| func (s *NetworkInterfaceState) IPv6String() string { | ||||
| 	if s.ipv6Addr == nil { | ||||
| 		return "..." | ||||
| 	} | ||||
| 	return s.ipv6Addr.String() | ||||
| } | ||||
| 
 | ||||
| func (s *NetworkInterfaceState) NtpAddresses() []*net.IP { | ||||
| 	return s.ntpAddresses | ||||
| } | ||||
| 
 | ||||
| func (s *NetworkInterfaceState) NtpAddressesString() []string { | ||||
| 	ntpServers := []string{} | ||||
| 
 | ||||
| 	if s != nil { | ||||
| 		s.l.Debug().Any("s", s).Msg("getting NTP address strings") | ||||
| 
 | ||||
| 		if len(s.ntpAddresses) > 0 { | ||||
| 			for _, server := range s.ntpAddresses { | ||||
| 				s.l.Debug().IPAddr("server", *server).Msg("converting NTP address") | ||||
| 				ntpServers = append(ntpServers, server.String()) | ||||
| 			} | ||||
| 		} | ||||
| 	} | ||||
| 
 | ||||
| 	return ntpServers | ||||
| } | ||||
| 
 | ||||
| func (s *NetworkInterfaceState) MAC() *net.HardwareAddr { | ||||
| 	return s.macAddr | ||||
| } | ||||
| 
 | ||||
| func (s *NetworkInterfaceState) MACString() string { | ||||
| 	if s.macAddr == nil { | ||||
| 		return "" | ||||
| 	} | ||||
| 	return s.macAddr.String() | ||||
| } | ||||
| 
 | ||||
| func (s *NetworkInterfaceState) update() (DhcpTargetState, error) { | ||||
| 	s.stateLock.Lock() | ||||
| 	defer s.stateLock.Unlock() | ||||
| 
 | ||||
| 	dhcpTargetState := DhcpTargetStateDoNothing | ||||
| 
 | ||||
| 	iface, err := netlink.LinkByName(s.interfaceName) | ||||
| 	if err != nil { | ||||
| 		s.l.Error().Err(err).Msg("failed to get interface") | ||||
| 		return dhcpTargetState, err | ||||
| 	} | ||||
| 
 | ||||
| 	// detect if the interface status changed
 | ||||
| 	var changed bool | ||||
| 	attrs := iface.Attrs() | ||||
| 	state := attrs.OperState | ||||
| 	newInterfaceUp := state == netlink.OperUp | ||||
| 
 | ||||
| 	// check if the interface is coming up
 | ||||
| 	interfaceGoingUp := !s.interfaceUp && newInterfaceUp | ||||
| 	interfaceGoingDown := s.interfaceUp && !newInterfaceUp | ||||
| 
 | ||||
| 	if s.interfaceUp != newInterfaceUp { | ||||
| 		s.interfaceUp = newInterfaceUp | ||||
| 		changed = true | ||||
| 	} | ||||
| 
 | ||||
| 	if changed { | ||||
| 		if interfaceGoingUp { | ||||
| 			s.l.Info().Msg("interface state transitioned to up") | ||||
| 			dhcpTargetState = DhcpTargetStateRenew | ||||
| 		} else if interfaceGoingDown { | ||||
| 			s.l.Info().Msg("interface state transitioned to down") | ||||
| 		} | ||||
| 	} | ||||
| 
 | ||||
| 	// set the mac address
 | ||||
| 	s.macAddr = &attrs.HardwareAddr | ||||
| 
 | ||||
| 	// get the ip addresses
 | ||||
| 	addrs, err := netlinkAddrs(iface) | ||||
| 	if err != nil { | ||||
| 		return dhcpTargetState, logging.ErrorfL(s.l, "failed to get ip addresses", err) | ||||
| 	} | ||||
| 
 | ||||
| 	var ( | ||||
| 		ipv4Addresses       = make([]net.IP, 0) | ||||
| 		ipv4AddressesString = make([]string, 0) | ||||
| 		ipv6Addresses       = make([]IPv6Address, 0) | ||||
| 		// ipv6AddressesString = make([]string, 0)
 | ||||
| 		ipv6LinkLocal *net.IP | ||||
| 	) | ||||
| 
 | ||||
| 	for _, addr := range addrs { | ||||
| 		if addr.IP.To4() != nil { | ||||
| 			scopedLogger := s.l.With().Str("ipv4", addr.IP.String()).Logger() | ||||
| 			if interfaceGoingDown { | ||||
| 				// remove all IPv4 addresses from the interface.
 | ||||
| 				scopedLogger.Info().Msg("state transitioned to down, removing IPv4 address") | ||||
| 				err := netlink.AddrDel(iface, &addr) | ||||
| 				if err != nil { | ||||
| 					scopedLogger.Warn().Err(err).Msg("failed to delete address") | ||||
| 				} | ||||
| 				// notify the DHCP client to release the lease
 | ||||
| 				dhcpTargetState = DhcpTargetStateRelease | ||||
| 				continue | ||||
| 			} | ||||
| 			ipv4Addresses = append(ipv4Addresses, addr.IP) | ||||
| 			ipv4AddressesString = append(ipv4AddressesString, addr.IPNet.String()) | ||||
| 		} else if addr.IP.To16() != nil { | ||||
| 			if s.config.IPv6Mode.String == "disabled" { | ||||
| 				continue | ||||
| 			} | ||||
| 
 | ||||
| 			scopedLogger := s.l.With().Str("ipv6", addr.IP.String()).Logger() | ||||
| 			// check if it's a link local address
 | ||||
| 			if addr.IP.IsLinkLocalUnicast() { | ||||
| 				ipv6LinkLocal = &addr.IP | ||||
| 				continue | ||||
| 			} | ||||
| 
 | ||||
| 			if !addr.IP.IsGlobalUnicast() { | ||||
| 				scopedLogger.Trace().Msg("not a global unicast address, skipping") | ||||
| 				continue | ||||
| 			} | ||||
| 
 | ||||
| 			if interfaceGoingDown { | ||||
| 				scopedLogger.Info().Msg("state transitioned to down, removing IPv6 address") | ||||
| 				err := netlink.AddrDel(iface, &addr) | ||||
| 				if err != nil { | ||||
| 					scopedLogger.Warn().Err(err).Msg("failed to delete address") | ||||
| 				} | ||||
| 				continue | ||||
| 			} | ||||
| 			ipv6Addresses = append(ipv6Addresses, IPv6Address{ | ||||
| 				Address:           addr.IP, | ||||
| 				Prefix:            *addr.IPNet, | ||||
| 				ValidLifetime:     lifetimeToTime(addr.ValidLft), | ||||
| 				PreferredLifetime: lifetimeToTime(addr.PreferedLft), | ||||
| 				Scope:             addr.Scope, | ||||
| 			}) | ||||
| 			// ipv6AddressesString = append(ipv6AddressesString, addr.IPNet.String())
 | ||||
| 		} | ||||
| 	} | ||||
| 
 | ||||
| 	if len(ipv4Addresses) > 0 { | ||||
| 		// compare the addresses to see if there's a change
 | ||||
| 		if s.ipv4Addr == nil || s.ipv4Addr.String() != ipv4Addresses[0].String() { | ||||
| 			scopedLogger := s.l.With().Str("ipv4", ipv4Addresses[0].String()).Logger() | ||||
| 			if s.ipv4Addr != nil { | ||||
| 				scopedLogger.Info(). | ||||
| 					Str("old_ipv4", s.ipv4Addr.String()). | ||||
| 					Msg("IPv4 address changed") | ||||
| 			} else { | ||||
| 				scopedLogger.Info().Msg("IPv4 address found") | ||||
| 			} | ||||
| 			s.ipv4Addr = &ipv4Addresses[0] | ||||
| 			changed = true | ||||
| 		} | ||||
| 	} | ||||
| 	s.ipv4Addresses = ipv4AddressesString | ||||
| 
 | ||||
| 	if s.config.IPv6Mode.String != "disabled" { | ||||
| 		if ipv6LinkLocal != nil { | ||||
| 			if s.ipv6LinkLocal == nil || s.ipv6LinkLocal.String() != ipv6LinkLocal.String() { | ||||
| 				scopedLogger := s.l.With().Str("ipv6", ipv6LinkLocal.String()).Logger() | ||||
| 				if s.ipv6LinkLocal != nil { | ||||
| 					scopedLogger.Info(). | ||||
| 						Str("old_ipv6", s.ipv6LinkLocal.String()). | ||||
| 						Msg("IPv6 link local address changed") | ||||
| 				} else { | ||||
| 					scopedLogger.Info().Msg("IPv6 link local address found") | ||||
| 				} | ||||
| 				s.ipv6LinkLocal = ipv6LinkLocal | ||||
| 				changed = true | ||||
| 			} | ||||
| 		} | ||||
| 		s.ipv6Addresses = ipv6Addresses | ||||
| 
 | ||||
| 		if len(ipv6Addresses) > 0 { | ||||
| 			// compare the addresses to see if there's a change
 | ||||
| 			if s.ipv6Addr == nil || s.ipv6Addr.String() != ipv6Addresses[0].Address.String() { | ||||
| 				scopedLogger := s.l.With().Str("ipv6", ipv6Addresses[0].Address.String()).Logger() | ||||
| 				if s.ipv6Addr != nil { | ||||
| 					scopedLogger.Info(). | ||||
| 						Str("old_ipv6", s.ipv6Addr.String()). | ||||
| 						Msg("IPv6 address changed") | ||||
| 				} else { | ||||
| 					scopedLogger.Info().Msg("IPv6 address found") | ||||
| 				} | ||||
| 				s.ipv6Addr = &ipv6Addresses[0].Address | ||||
| 				changed = true | ||||
| 			} | ||||
| 		} | ||||
| 	} | ||||
| 
 | ||||
| 	// if it's the initial check, we'll set changed to false
 | ||||
| 	initialCheck := !s.checked | ||||
| 	if initialCheck { | ||||
| 		s.checked = true | ||||
| 		changed = false | ||||
| 		if dhcpTargetState == DhcpTargetStateRenew { | ||||
| 			// it's the initial check, we'll start the DHCP client
 | ||||
| 			// dhcpTargetState = DhcpTargetStateStart
 | ||||
| 			// TODO: manage DHCP client start/stop
 | ||||
| 			dhcpTargetState = DhcpTargetStateDoNothing | ||||
| 		} | ||||
| 	} | ||||
| 
 | ||||
| 	if initialCheck { | ||||
| 		s.onInitialCheck(s) | ||||
| 	} else if changed { | ||||
| 		s.onStateChange(s) | ||||
| 	} | ||||
| 
 | ||||
| 	return dhcpTargetState, nil | ||||
| } | ||||
| 
 | ||||
| func (s *NetworkInterfaceState) updateNtpServersFromLease(lease *udhcpc.Lease) error { | ||||
| 	if lease != nil && len(lease.NTPServers) > 0 { | ||||
| 		s.l.Info().Msg("lease found, updating DHCP NTP addresses") | ||||
| 		s.ntpAddresses = make([]*net.IP, 0, len(lease.NTPServers)) | ||||
| 
 | ||||
| 		for _, ntpServer := range lease.NTPServers { | ||||
| 			if ntpServer != nil { | ||||
| 				s.l.Info().IPAddr("ntp_server", ntpServer).Msg("NTP server found in lease") | ||||
| 				s.ntpAddresses = append(s.ntpAddresses, &ntpServer) | ||||
| 			} | ||||
| 		} | ||||
| 	} else { | ||||
| 		s.l.Info().Msg("no NTP servers found in lease") | ||||
| 		s.ntpAddresses = make([]*net.IP, 0, len(s.config.TimeSyncNTPServers)) | ||||
| 	} | ||||
| 
 | ||||
| 	return nil | ||||
| } | ||||
| 
 | ||||
| func (s *NetworkInterfaceState) CheckAndUpdateDhcp() error { | ||||
| 	dhcpTargetState, err := s.update() | ||||
| 	if err != nil { | ||||
| 		return logging.ErrorfL(s.l, "failed to update network state", err) | ||||
| 	} | ||||
| 
 | ||||
| 	switch dhcpTargetState { | ||||
| 	case DhcpTargetStateRenew: | ||||
| 		s.l.Info().Msg("renewing DHCP lease") | ||||
| 		_ = s.dhcpClient.Renew() | ||||
| 	case DhcpTargetStateRelease: | ||||
| 		s.l.Info().Msg("releasing DHCP lease") | ||||
| 		_ = s.dhcpClient.Release() | ||||
| 	case DhcpTargetStateStart: | ||||
| 		s.l.Warn().Msg("dhcpTargetStateStart not implemented") | ||||
| 	case DhcpTargetStateStop: | ||||
| 		s.l.Warn().Msg("dhcpTargetStateStop not implemented") | ||||
| 	} | ||||
| 
 | ||||
| 	return nil | ||||
| } | ||||
| 
 | ||||
| func (s *NetworkInterfaceState) onConfigChange(config *NetworkConfig) { | ||||
| 	_ = s.setHostnameIfNotSame() | ||||
| 	s.cbConfigChange(config) | ||||
| } | ||||
|  | @ -0,0 +1,58 @@ | |||
| //go:build linux
 | ||||
| 
 | ||||
| package network | ||||
| 
 | ||||
| import ( | ||||
| 	"time" | ||||
| 
 | ||||
| 	"github.com/vishvananda/netlink" | ||||
| 	"github.com/vishvananda/netlink/nl" | ||||
| ) | ||||
| 
 | ||||
| func (s *NetworkInterfaceState) HandleLinkUpdate(update netlink.LinkUpdate) { | ||||
| 	if update.Link.Attrs().Name == s.interfaceName { | ||||
| 		s.l.Info().Interface("update", update).Msg("interface link update received") | ||||
| 		_ = s.CheckAndUpdateDhcp() | ||||
| 	} | ||||
| } | ||||
| 
 | ||||
| func (s *NetworkInterfaceState) Run() error { | ||||
| 	updates := make(chan netlink.LinkUpdate) | ||||
| 	done := make(chan struct{}) | ||||
| 
 | ||||
| 	if err := netlink.LinkSubscribe(updates, done); err != nil { | ||||
| 		s.l.Warn().Err(err).Msg("failed to subscribe to link updates") | ||||
| 		return err | ||||
| 	} | ||||
| 
 | ||||
| 	_ = s.setHostnameIfNotSame() | ||||
| 
 | ||||
| 	// run the dhcp client
 | ||||
| 	go s.dhcpClient.Run() // nolint:errcheck
 | ||||
| 
 | ||||
| 	if err := s.CheckAndUpdateDhcp(); err != nil { | ||||
| 		return err | ||||
| 	} | ||||
| 
 | ||||
| 	go func() { | ||||
| 		ticker := time.NewTicker(1 * time.Second) | ||||
| 		defer ticker.Stop() | ||||
| 
 | ||||
| 		for { | ||||
| 			select { | ||||
| 			case update := <-updates: | ||||
| 				s.HandleLinkUpdate(update) | ||||
| 			case <-ticker.C: | ||||
| 				_ = s.CheckAndUpdateDhcp() | ||||
| 			case <-done: | ||||
| 				return | ||||
| 			} | ||||
| 		} | ||||
| 	}() | ||||
| 
 | ||||
| 	return nil | ||||
| } | ||||
| 
 | ||||
| func netlinkAddrs(iface netlink.Link) ([]netlink.Addr, error) { | ||||
| 	return netlink.AddrList(iface, nl.FAMILY_ALL) | ||||
| } | ||||
|  | @ -0,0 +1,21 @@ | |||
| //go:build !linux
 | ||||
| 
 | ||||
| package network | ||||
| 
 | ||||
| import ( | ||||
| 	"fmt" | ||||
| 
 | ||||
| 	"github.com/vishvananda/netlink" | ||||
| ) | ||||
| 
 | ||||
| func (s *NetworkInterfaceState) HandleLinkUpdate() error { | ||||
| 	return fmt.Errorf("not implemented") | ||||
| } | ||||
| 
 | ||||
| func (s *NetworkInterfaceState) Run() error { | ||||
| 	return fmt.Errorf("not implemented") | ||||
| } | ||||
| 
 | ||||
| func netlinkAddrs(iface netlink.Link) ([]netlink.Addr, error) { | ||||
| 	return nil, fmt.Errorf("not implemented") | ||||
| } | ||||
|  | @ -0,0 +1,126 @@ | |||
| package network | ||||
| 
 | ||||
| import ( | ||||
| 	"fmt" | ||||
| 	"time" | ||||
| 
 | ||||
| 	"github.com/jetkvm/kvm/internal/confparser" | ||||
| 	"github.com/jetkvm/kvm/internal/udhcpc" | ||||
| ) | ||||
| 
 | ||||
| type RpcIPv6Address struct { | ||||
| 	Address           string     `json:"address"` | ||||
| 	ValidLifetime     *time.Time `json:"valid_lifetime,omitempty"` | ||||
| 	PreferredLifetime *time.Time `json:"preferred_lifetime,omitempty"` | ||||
| 	Scope             int        `json:"scope"` | ||||
| } | ||||
| 
 | ||||
| type RpcNetworkState struct { | ||||
| 	InterfaceName string           `json:"interface_name"` | ||||
| 	MacAddress    string           `json:"mac_address"` | ||||
| 	IPv4          string           `json:"ipv4,omitempty"` | ||||
| 	IPv6          string           `json:"ipv6,omitempty"` | ||||
| 	IPv6LinkLocal string           `json:"ipv6_link_local,omitempty"` | ||||
| 	IPv4Addresses []string         `json:"ipv4_addresses,omitempty"` | ||||
| 	IPv6Addresses []RpcIPv6Address `json:"ipv6_addresses,omitempty"` | ||||
| 	DHCPLease     *udhcpc.Lease    `json:"dhcp_lease,omitempty"` | ||||
| } | ||||
| 
 | ||||
| type RpcNetworkSettings struct { | ||||
| 	NetworkConfig | ||||
| } | ||||
| 
 | ||||
| func (s *NetworkInterfaceState) MacAddress() string { | ||||
| 	if s.macAddr == nil { | ||||
| 		return "" | ||||
| 	} | ||||
| 
 | ||||
| 	return s.macAddr.String() | ||||
| } | ||||
| 
 | ||||
| func (s *NetworkInterfaceState) IPv4Address() string { | ||||
| 	if s.ipv4Addr == nil { | ||||
| 		return "" | ||||
| 	} | ||||
| 
 | ||||
| 	return s.ipv4Addr.String() | ||||
| } | ||||
| 
 | ||||
| func (s *NetworkInterfaceState) IPv6Address() string { | ||||
| 	if s.ipv6Addr == nil { | ||||
| 		return "" | ||||
| 	} | ||||
| 
 | ||||
| 	return s.ipv6Addr.String() | ||||
| } | ||||
| 
 | ||||
| func (s *NetworkInterfaceState) IPv6LinkLocalAddress() string { | ||||
| 	if s.ipv6LinkLocal == nil { | ||||
| 		return "" | ||||
| 	} | ||||
| 
 | ||||
| 	return s.ipv6LinkLocal.String() | ||||
| } | ||||
| 
 | ||||
| func (s *NetworkInterfaceState) RpcGetNetworkState() RpcNetworkState { | ||||
| 	ipv6Addresses := make([]RpcIPv6Address, 0) | ||||
| 
 | ||||
| 	if s.ipv6Addresses != nil && s.config.IPv6Mode.String != "disabled" { | ||||
| 		for _, addr := range s.ipv6Addresses { | ||||
| 			ipv6Addresses = append(ipv6Addresses, RpcIPv6Address{ | ||||
| 				Address:           addr.Prefix.String(), | ||||
| 				ValidLifetime:     addr.ValidLifetime, | ||||
| 				PreferredLifetime: addr.PreferredLifetime, | ||||
| 				Scope:             addr.Scope, | ||||
| 			}) | ||||
| 		} | ||||
| 	} | ||||
| 
 | ||||
| 	return RpcNetworkState{ | ||||
| 		InterfaceName: s.interfaceName, | ||||
| 		MacAddress:    s.MacAddress(), | ||||
| 		IPv4:          s.IPv4Address(), | ||||
| 		IPv6:          s.IPv6Address(), | ||||
| 		IPv6LinkLocal: s.IPv6LinkLocalAddress(), | ||||
| 		IPv4Addresses: s.ipv4Addresses, | ||||
| 		IPv6Addresses: ipv6Addresses, | ||||
| 		DHCPLease:     s.dhcpClient.GetLease(), | ||||
| 	} | ||||
| } | ||||
| 
 | ||||
| func (s *NetworkInterfaceState) RpcGetNetworkSettings() RpcNetworkSettings { | ||||
| 	if s.config == nil { | ||||
| 		return RpcNetworkSettings{} | ||||
| 	} | ||||
| 
 | ||||
| 	return RpcNetworkSettings{ | ||||
| 		NetworkConfig: *s.config, | ||||
| 	} | ||||
| } | ||||
| 
 | ||||
| func (s *NetworkInterfaceState) RpcSetNetworkSettings(settings RpcNetworkSettings) error { | ||||
| 	currentSettings := s.config | ||||
| 
 | ||||
| 	err := confparser.SetDefaultsAndValidate(&settings.NetworkConfig) | ||||
| 	if err != nil { | ||||
| 		return err | ||||
| 	} | ||||
| 
 | ||||
| 	if IsSame(currentSettings, settings.NetworkConfig) { | ||||
| 		// no changes, do nothing
 | ||||
| 		return nil | ||||
| 	} | ||||
| 
 | ||||
| 	s.config = &settings.NetworkConfig | ||||
| 	s.onConfigChange(s.config) | ||||
| 
 | ||||
| 	return nil | ||||
| } | ||||
| 
 | ||||
| func (s *NetworkInterfaceState) RpcRenewDHCPLease() error { | ||||
| 	if s.dhcpClient == nil { | ||||
| 		return fmt.Errorf("dhcp client not initialized") | ||||
| 	} | ||||
| 
 | ||||
| 	return s.dhcpClient.Renew() | ||||
| } | ||||
|  | @ -0,0 +1,26 @@ | |||
| package network | ||||
| 
 | ||||
| import ( | ||||
| 	"encoding/json" | ||||
| 	"time" | ||||
| ) | ||||
| 
 | ||||
| func lifetimeToTime(lifetime int) *time.Time { | ||||
| 	if lifetime == 0 { | ||||
| 		return nil | ||||
| 	} | ||||
| 	t := time.Now().Add(time.Duration(lifetime) * time.Second) | ||||
| 	return &t | ||||
| } | ||||
| 
 | ||||
| func IsSame(a, b any) bool { | ||||
| 	aJSON, err := json.Marshal(a) | ||||
| 	if err != nil { | ||||
| 		return false | ||||
| 	} | ||||
| 	bJSON, err := json.Marshal(b) | ||||
| 	if err != nil { | ||||
| 		return false | ||||
| 	} | ||||
| 	return string(aJSON) == string(bJSON) | ||||
| } | ||||
|  | @ -0,0 +1,151 @@ | |||
| package timesync | ||||
| 
 | ||||
| import ( | ||||
| 	"context" | ||||
| 	"errors" | ||||
| 	"math/rand" | ||||
| 	"net/http" | ||||
| 	"net/url" | ||||
| 	"strconv" | ||||
| 	"time" | ||||
| ) | ||||
| 
 | ||||
| var defaultHTTPUrls = []string{ | ||||
| 	"http://www.gstatic.com/generate_204", | ||||
| 	"http://cp.cloudflare.com/", | ||||
| 	"http://edge-http.microsoft.com/captiveportal/generate_204", | ||||
| 	// Firefox, Apple, and Microsoft have inconsistent results, so we don't use it
 | ||||
| 	// "http://detectportal.firefox.com/",
 | ||||
| 	// "http://www.apple.com/library/test/success.html",
 | ||||
| 	// "http://www.msftconnecttest.com/connecttest.txt",
 | ||||
| } | ||||
| 
 | ||||
| func (t *TimeSync) queryAllHttpTime(httpUrls []string) (now *time.Time) { | ||||
| 	chunkSize := int(t.networkConfig.TimeSyncParallel.ValueOr(4)) | ||||
| 	t.l.Info().Strs("httpUrls", httpUrls).Int("chunkSize", chunkSize).Msg("querying HTTP URLs") | ||||
| 
 | ||||
| 	// shuffle the http urls to avoid always querying the same servers
 | ||||
| 	rand.Shuffle(len(httpUrls), func(i, j int) { httpUrls[i], httpUrls[j] = httpUrls[j], httpUrls[i] }) | ||||
| 
 | ||||
| 	for i := 0; i < len(httpUrls); i += chunkSize { | ||||
| 		chunk := httpUrls[i:min(i+chunkSize, len(httpUrls))] | ||||
| 		results := t.queryMultipleHttp(chunk, timeSyncTimeout) | ||||
| 		if results != nil { | ||||
| 			return results | ||||
| 		} | ||||
| 	} | ||||
| 
 | ||||
| 	return nil | ||||
| } | ||||
| 
 | ||||
| func (t *TimeSync) queryMultipleHttp(urls []string, timeout time.Duration) (now *time.Time) { | ||||
| 	results := make(chan *time.Time, len(urls)) | ||||
| 
 | ||||
| 	ctx, cancel := context.WithTimeout(context.Background(), timeout) | ||||
| 	defer cancel() | ||||
| 
 | ||||
| 	for _, url := range urls { | ||||
| 		go func(url string) { | ||||
| 			scopedLogger := t.l.With(). | ||||
| 				Str("http_url", url). | ||||
| 				Logger() | ||||
| 
 | ||||
| 			metricHttpRequestCount.WithLabelValues(url).Inc() | ||||
| 			metricHttpTotalRequestCount.Inc() | ||||
| 
 | ||||
| 			startTime := time.Now() | ||||
| 			now, response, err := queryHttpTime( | ||||
| 				ctx, | ||||
| 				url, | ||||
| 				timeout, | ||||
| 				t.networkConfig.GetTransportProxyFunc(), | ||||
| 			) | ||||
| 			duration := time.Since(startTime) | ||||
| 
 | ||||
| 			metricHttpServerLastRTT.WithLabelValues(url).Set(float64(duration.Milliseconds())) | ||||
| 			metricHttpServerRttHistogram.WithLabelValues(url).Observe(float64(duration.Milliseconds())) | ||||
| 
 | ||||
| 			status := 0 | ||||
| 			if response != nil { | ||||
| 				status = response.StatusCode | ||||
| 			} | ||||
| 			metricHttpServerInfo.WithLabelValues( | ||||
| 				url, | ||||
| 				strconv.Itoa(status), | ||||
| 			).Set(1) | ||||
| 
 | ||||
| 			if err == nil { | ||||
| 				metricHttpTotalSuccessCount.Inc() | ||||
| 				metricHttpSuccessCount.WithLabelValues(url).Inc() | ||||
| 
 | ||||
| 				requestId := response.Header.Get("X-Request-Id") | ||||
| 				if requestId != "" { | ||||
| 					requestId = response.Header.Get("X-Msedge-Ref") | ||||
| 				} | ||||
| 				if requestId == "" { | ||||
| 					requestId = response.Header.Get("Cf-Ray") | ||||
| 				} | ||||
| 				scopedLogger.Info(). | ||||
| 					Str("time", now.Format(time.RFC3339)). | ||||
| 					Int("status", status). | ||||
| 					Str("request_id", requestId). | ||||
| 					Str("time_taken", duration.String()). | ||||
| 					Msg("HTTP server returned time") | ||||
| 
 | ||||
| 				cancel() | ||||
| 				results <- now | ||||
| 			} else if errors.Is(err, context.Canceled) { | ||||
| 				metricHttpCancelCount.WithLabelValues(url).Inc() | ||||
| 				metricHttpTotalCancelCount.Inc() | ||||
| 				results <- nil | ||||
| 			} else { | ||||
| 				scopedLogger.Warn(). | ||||
| 					Str("error", err.Error()). | ||||
| 					Int("status", status). | ||||
| 					Msg("failed to query HTTP server") | ||||
| 				results <- nil | ||||
| 			} | ||||
| 		}(url) | ||||
| 	} | ||||
| 
 | ||||
| 	for range urls { | ||||
| 		result := <-results | ||||
| 		if result == nil { | ||||
| 			continue | ||||
| 		} | ||||
| 		now = result | ||||
| 		return | ||||
| 	} | ||||
| 
 | ||||
| 	return | ||||
| } | ||||
| 
 | ||||
| func queryHttpTime( | ||||
| 	ctx context.Context, | ||||
| 	url string, | ||||
| 	timeout time.Duration, | ||||
| 	proxyFunc func(*http.Request) (*url.URL, error), | ||||
| ) (now *time.Time, response *http.Response, err error) { | ||||
| 	transport := http.DefaultTransport.(*http.Transport).Clone() | ||||
| 	transport.Proxy = proxyFunc | ||||
| 
 | ||||
| 	client := http.Client{ | ||||
| 		Transport: transport, | ||||
| 		Timeout:   timeout, | ||||
| 	} | ||||
| 
 | ||||
| 	req, err := http.NewRequestWithContext(ctx, "GET", url, nil) | ||||
| 	if err != nil { | ||||
| 		return nil, nil, err | ||||
| 	} | ||||
| 	resp, err := client.Do(req) | ||||
| 	if err != nil { | ||||
| 		return nil, nil, err | ||||
| 	} | ||||
| 	dateStr := resp.Header.Get("Date") | ||||
| 	parsedTime, err := time.Parse(time.RFC1123, dateStr) | ||||
| 	if err != nil { | ||||
| 		return nil, nil, err | ||||
| 	} | ||||
| 	return &parsedTime, resp, nil | ||||
| } | ||||
|  | @ -0,0 +1,148 @@ | |||
| package timesync | ||||
| 
 | ||||
| import ( | ||||
| 	"github.com/prometheus/client_golang/prometheus" | ||||
| 	"github.com/prometheus/client_golang/prometheus/promauto" | ||||
| ) | ||||
| 
 | ||||
| var ( | ||||
| 	metricTimeSyncStatus = promauto.NewGauge( | ||||
| 		prometheus.GaugeOpts{ | ||||
| 			Name: "jetkvm_timesync_status", | ||||
| 			Help: "The status of the timesync, 1 if successful, 0 if not", | ||||
| 		}, | ||||
| 	) | ||||
| 	metricTimeSyncCount = promauto.NewCounter( | ||||
| 		prometheus.CounterOpts{ | ||||
| 			Name: "jetkvm_timesync_total", | ||||
| 			Help: "The number of times the timesync has been run", | ||||
| 		}, | ||||
| 	) | ||||
| 	metricTimeSyncSuccessCount = promauto.NewCounter( | ||||
| 		prometheus.CounterOpts{ | ||||
| 			Name: "jetkvm_timesync_success_total", | ||||
| 			Help: "The number of times the timesync has been successful", | ||||
| 		}, | ||||
| 	) | ||||
| 	metricRTCUpdateCount = promauto.NewCounter( //nolint:unused
 | ||||
| 		prometheus.CounterOpts{ | ||||
| 			Name: "jetkvm_timesync_rtc_update_total", | ||||
| 			Help: "The number of times the RTC has been updated", | ||||
| 		}, | ||||
| 	) | ||||
| 	metricNtpTotalSuccessCount = promauto.NewCounter( | ||||
| 		prometheus.CounterOpts{ | ||||
| 			Name: "jetkvm_timesync_ntp_total_success_total", | ||||
| 			Help: "The total number of successful NTP requests", | ||||
| 		}, | ||||
| 	) | ||||
| 	metricNtpTotalRequestCount = promauto.NewCounter( | ||||
| 		prometheus.CounterOpts{ | ||||
| 			Name: "jetkvm_timesync_ntp_total_request_total", | ||||
| 			Help: "The total number of NTP requests sent", | ||||
| 		}, | ||||
| 	) | ||||
| 	metricNtpSuccessCount = promauto.NewCounterVec( | ||||
| 		prometheus.CounterOpts{ | ||||
| 			Name: "jetkvm_timesync_ntp_success_total", | ||||
| 			Help: "The number of successful NTP requests", | ||||
| 		}, | ||||
| 		[]string{"url"}, | ||||
| 	) | ||||
| 	metricNtpRequestCount = promauto.NewCounterVec( | ||||
| 		prometheus.CounterOpts{ | ||||
| 			Name: "jetkvm_timesync_ntp_request_total", | ||||
| 			Help: "The number of NTP requests sent to the server", | ||||
| 		}, | ||||
| 		[]string{"url"}, | ||||
| 	) | ||||
| 	metricNtpServerLastRTT = promauto.NewGaugeVec( | ||||
| 		prometheus.GaugeOpts{ | ||||
| 			Name: "jetkvm_timesync_ntp_server_last_rtt", | ||||
| 			Help: "The last RTT of the NTP server in milliseconds", | ||||
| 		}, | ||||
| 		[]string{"url"}, | ||||
| 	) | ||||
| 	metricNtpServerRttHistogram = promauto.NewHistogramVec( | ||||
| 		prometheus.HistogramOpts{ | ||||
| 			Name: "jetkvm_timesync_ntp_server_rtt", | ||||
| 			Help: "The histogram of the RTT of the NTP server in milliseconds", | ||||
| 			Buckets: []float64{ | ||||
| 				10, 25, 50, 100, 200, 300, 500, 1000, | ||||
| 			}, | ||||
| 		}, | ||||
| 		[]string{"url"}, | ||||
| 	) | ||||
| 
 | ||||
| 	metricNtpServerInfo = promauto.NewGaugeVec( | ||||
| 		prometheus.GaugeOpts{ | ||||
| 			Name: "jetkvm_timesync_ntp_server_info", | ||||
| 			Help: "The info of the NTP server", | ||||
| 		}, | ||||
| 		[]string{"url", "reference", "stratum", "precision"}, | ||||
| 	) | ||||
| 
 | ||||
| 	metricHttpTotalSuccessCount = promauto.NewCounter( | ||||
| 		prometheus.CounterOpts{ | ||||
| 			Name: "jetkvm_timesync_http_total_success_total", | ||||
| 			Help: "The total number of successful HTTP requests", | ||||
| 		}, | ||||
| 	) | ||||
| 	metricHttpTotalRequestCount = promauto.NewCounter( | ||||
| 		prometheus.CounterOpts{ | ||||
| 			Name: "jetkvm_timesync_http_total_request_total", | ||||
| 			Help: "The total number of HTTP requests sent", | ||||
| 		}, | ||||
| 	) | ||||
| 	metricHttpTotalCancelCount = promauto.NewCounter( | ||||
| 		prometheus.CounterOpts{ | ||||
| 			Name: "jetkvm_timesync_http_total_cancel_total", | ||||
| 			Help: "The total number of HTTP requests cancelled", | ||||
| 		}, | ||||
| 	) | ||||
| 	metricHttpSuccessCount = promauto.NewCounterVec( | ||||
| 		prometheus.CounterOpts{ | ||||
| 			Name: "jetkvm_timesync_http_success_total", | ||||
| 			Help: "The number of successful HTTP requests", | ||||
| 		}, | ||||
| 		[]string{"url"}, | ||||
| 	) | ||||
| 	metricHttpRequestCount = promauto.NewCounterVec( | ||||
| 		prometheus.CounterOpts{ | ||||
| 			Name: "jetkvm_timesync_http_request_total", | ||||
| 			Help: "The number of HTTP requests sent to the server", | ||||
| 		}, | ||||
| 		[]string{"url"}, | ||||
| 	) | ||||
| 	metricHttpCancelCount = promauto.NewCounterVec( | ||||
| 		prometheus.CounterOpts{ | ||||
| 			Name: "jetkvm_timesync_http_cancel_total", | ||||
| 			Help: "The number of HTTP requests cancelled", | ||||
| 		}, | ||||
| 		[]string{"url"}, | ||||
| 	) | ||||
| 	metricHttpServerLastRTT = promauto.NewGaugeVec( | ||||
| 		prometheus.GaugeOpts{ | ||||
| 			Name: "jetkvm_timesync_http_server_last_rtt", | ||||
| 			Help: "The last RTT of the HTTP server in milliseconds", | ||||
| 		}, | ||||
| 		[]string{"url"}, | ||||
| 	) | ||||
| 	metricHttpServerRttHistogram = promauto.NewHistogramVec( | ||||
| 		prometheus.HistogramOpts{ | ||||
| 			Name: "jetkvm_timesync_http_server_rtt", | ||||
| 			Help: "The histogram of the RTT of the HTTP server in milliseconds", | ||||
| 			Buckets: []float64{ | ||||
| 				10, 25, 50, 100, 200, 300, 500, 1000, | ||||
| 			}, | ||||
| 		}, | ||||
| 		[]string{"url"}, | ||||
| 	) | ||||
| 	metricHttpServerInfo = promauto.NewGaugeVec( | ||||
| 		prometheus.GaugeOpts{ | ||||
| 			Name: "jetkvm_timesync_http_server_info", | ||||
| 			Help: "The info of the HTTP server", | ||||
| 		}, | ||||
| 		[]string{"url", "http_code"}, | ||||
| 	) | ||||
| ) | ||||
|  | @ -0,0 +1,155 @@ | |||
| package timesync | ||||
| 
 | ||||
| import ( | ||||
| 	"context" | ||||
| 	"math/rand/v2" | ||||
| 	"strconv" | ||||
| 	"time" | ||||
| 
 | ||||
| 	"github.com/beevik/ntp" | ||||
| ) | ||||
| 
 | ||||
| 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.aws.com", | ||||
| 	"time.windows.com", | ||||
| 	"time.google.com", | ||||
| 	"time.cloudflare.com", | ||||
| 	"pool.ntp.org", | ||||
| } | ||||
| 
 | ||||
| func (t *TimeSync) queryNetworkTime(ntpServers []string) (now *time.Time, offset *time.Duration) { | ||||
| 	chunkSize := int(t.networkConfig.TimeSyncParallel.ValueOr(4)) | ||||
| 	t.l.Info().Strs("servers", ntpServers).Int("chunkSize", chunkSize).Msg("querying NTP servers") | ||||
| 
 | ||||
| 	// shuffle the ntp servers to avoid always querying the same servers
 | ||||
| 	rand.Shuffle(len(ntpServers), func(i, j int) { ntpServers[i], ntpServers[j] = ntpServers[j], ntpServers[i] }) | ||||
| 
 | ||||
| 	for i := 0; i < len(ntpServers); i += chunkSize { | ||||
| 		chunk := ntpServers[i:min(i+chunkSize, len(ntpServers))] | ||||
| 		now, offset := t.queryMultipleNTP(chunk, timeSyncTimeout) | ||||
| 		if now != nil { | ||||
| 			return now, offset | ||||
| 		} | ||||
| 	} | ||||
| 
 | ||||
| 	return nil, nil | ||||
| } | ||||
| 
 | ||||
| type ntpResult struct { | ||||
| 	now    *time.Time | ||||
| 	offset *time.Duration | ||||
| } | ||||
| 
 | ||||
| func (t *TimeSync) queryMultipleNTP(servers []string, timeout time.Duration) (now *time.Time, offset *time.Duration) { | ||||
| 	results := make(chan *ntpResult, len(servers)) | ||||
| 
 | ||||
| 	_, cancel := context.WithTimeout(context.Background(), timeout) | ||||
| 	defer cancel() | ||||
| 
 | ||||
| 	for _, server := range servers { | ||||
| 		go func(server string) { | ||||
| 			scopedLogger := t.l.With(). | ||||
| 				Str("server", server). | ||||
| 				Logger() | ||||
| 
 | ||||
| 			// increase request count
 | ||||
| 			metricNtpTotalRequestCount.Inc() | ||||
| 			metricNtpRequestCount.WithLabelValues(server).Inc() | ||||
| 
 | ||||
| 			// query the server
 | ||||
| 			now, response, err := queryNtpServer(server, timeout) | ||||
| 			if err != nil { | ||||
| 				scopedLogger.Warn(). | ||||
| 					Str("error", err.Error()). | ||||
| 					Msg("failed to query NTP server") | ||||
| 				results <- nil | ||||
| 				return | ||||
| 			} | ||||
| 
 | ||||
| 			if response.IsKissOfDeath() { | ||||
| 				scopedLogger.Warn(). | ||||
| 					Str("kiss_code", response.KissCode). | ||||
| 					Msg("ignoring NTP server kiss of death") | ||||
| 				results <- nil | ||||
| 				return | ||||
| 			} | ||||
| 
 | ||||
| 			rtt := float64(response.RTT.Milliseconds()) | ||||
| 
 | ||||
| 			// set the last RTT
 | ||||
| 			metricNtpServerLastRTT.WithLabelValues( | ||||
| 				server, | ||||
| 			).Set(rtt) | ||||
| 
 | ||||
| 			// set the RTT histogram
 | ||||
| 			metricNtpServerRttHistogram.WithLabelValues( | ||||
| 				server, | ||||
| 			).Observe(rtt) | ||||
| 
 | ||||
| 			// set the server info
 | ||||
| 			metricNtpServerInfo.WithLabelValues( | ||||
| 				server, | ||||
| 				response.ReferenceString(), | ||||
| 				strconv.Itoa(int(response.Stratum)), | ||||
| 				strconv.Itoa(int(response.Precision)), | ||||
| 			).Set(1) | ||||
| 
 | ||||
| 			// increase success count
 | ||||
| 			metricNtpTotalSuccessCount.Inc() | ||||
| 			metricNtpSuccessCount.WithLabelValues(server).Inc() | ||||
| 
 | ||||
| 			scopedLogger.Info(). | ||||
| 				Str("time", now.Format(time.RFC3339)). | ||||
| 				Str("reference", response.ReferenceString()). | ||||
| 				Float64("rtt", rtt). | ||||
| 				Str("clockOffset", response.ClockOffset.String()). | ||||
| 				Uint8("stratum", response.Stratum). | ||||
| 				Msg("NTP server returned time") | ||||
| 
 | ||||
| 			cancel() | ||||
| 
 | ||||
| 			results <- &ntpResult{ | ||||
| 				now:    now, | ||||
| 				offset: &response.ClockOffset, | ||||
| 			} | ||||
| 		}(server) | ||||
| 	} | ||||
| 
 | ||||
| 	for range servers { | ||||
| 		result := <-results | ||||
| 		if result == nil { | ||||
| 			continue | ||||
| 		} | ||||
| 		now, offset = result.now, result.offset | ||||
| 		return | ||||
| 	} | ||||
| 	return | ||||
| } | ||||
| 
 | ||||
| func queryNtpServer(server string, timeout time.Duration) (now *time.Time, response *ntp.Response, err error) { | ||||
| 	resp, err := ntp.QueryWithOptions(server, ntp.QueryOptions{Timeout: timeout}) | ||||
| 	if err != nil { | ||||
| 		return nil, nil, err | ||||
| 	} | ||||
| 	return &resp.Time, resp, nil | ||||
| } | ||||
|  | @ -0,0 +1,26 @@ | |||
| package timesync | ||||
| 
 | ||||
| import ( | ||||
| 	"fmt" | ||||
| 	"os" | ||||
| ) | ||||
| 
 | ||||
| var ( | ||||
| 	rtcDeviceSearchPaths = []string{ | ||||
| 		"/dev/rtc", | ||||
| 		"/dev/rtc0", | ||||
| 		"/dev/rtc1", | ||||
| 		"/dev/misc/rtc", | ||||
| 		"/dev/misc/rtc0", | ||||
| 		"/dev/misc/rtc1", | ||||
| 	} | ||||
| ) | ||||
| 
 | ||||
| func getRtcDevicePath() (string, error) { | ||||
| 	for _, path := range rtcDeviceSearchPaths { | ||||
| 		if _, err := os.Stat(path); err == nil { | ||||
| 			return path, nil | ||||
| 		} | ||||
| 	} | ||||
| 	return "", fmt.Errorf("rtc device not found") | ||||
| } | ||||
|  | @ -0,0 +1,105 @@ | |||
| //go:build linux
 | ||||
| 
 | ||||
| package timesync | ||||
| 
 | ||||
| import ( | ||||
| 	"fmt" | ||||
| 	"os" | ||||
| 	"time" | ||||
| 
 | ||||
| 	"golang.org/x/sys/unix" | ||||
| ) | ||||
| 
 | ||||
| func TimetoRtcTime(t time.Time) unix.RTCTime { | ||||
| 	return unix.RTCTime{ | ||||
| 		Sec:   int32(t.Second()), | ||||
| 		Min:   int32(t.Minute()), | ||||
| 		Hour:  int32(t.Hour()), | ||||
| 		Mday:  int32(t.Day()), | ||||
| 		Mon:   int32(t.Month() - 1), | ||||
| 		Year:  int32(t.Year() - 1900), | ||||
| 		Wday:  int32(0), | ||||
| 		Yday:  int32(0), | ||||
| 		Isdst: int32(0), | ||||
| 	} | ||||
| } | ||||
| 
 | ||||
| func RtcTimetoTime(t unix.RTCTime) time.Time { | ||||
| 	return time.Date( | ||||
| 		int(t.Year)+1900, | ||||
| 		time.Month(t.Mon+1), | ||||
| 		int(t.Mday), | ||||
| 		int(t.Hour), | ||||
| 		int(t.Min), | ||||
| 		int(t.Sec), | ||||
| 		0, | ||||
| 		time.UTC, | ||||
| 	) | ||||
| } | ||||
| 
 | ||||
| func (t *TimeSync) getRtcDevice() (*os.File, error) { | ||||
| 	if t.rtcDevice == nil { | ||||
| 		file, err := os.OpenFile(t.rtcDevicePath, os.O_RDWR, 0666) | ||||
| 		if err != nil { | ||||
| 			return nil, err | ||||
| 		} | ||||
| 		t.rtcDevice = file | ||||
| 	} | ||||
| 	return t.rtcDevice, nil | ||||
| } | ||||
| 
 | ||||
| func (t *TimeSync) getRtcDeviceFd() (int, error) { | ||||
| 	device, err := t.getRtcDevice() | ||||
| 	if err != nil { | ||||
| 		return 0, err | ||||
| 	} | ||||
| 	return int(device.Fd()), nil | ||||
| } | ||||
| 
 | ||||
| // Read implements Read for the Linux RTC
 | ||||
| func (t *TimeSync) readRtcTime() (time.Time, error) { | ||||
| 	fd, err := t.getRtcDeviceFd() | ||||
| 	if err != nil { | ||||
| 		return time.Time{}, fmt.Errorf("failed to get RTC device fd: %w", err) | ||||
| 	} | ||||
| 
 | ||||
| 	rtcTime, err := unix.IoctlGetRTCTime(fd) | ||||
| 	if err != nil { | ||||
| 		return time.Time{}, fmt.Errorf("failed to get RTC time: %w", err) | ||||
| 	} | ||||
| 
 | ||||
| 	date := RtcTimetoTime(*rtcTime) | ||||
| 
 | ||||
| 	return date, nil | ||||
| } | ||||
| 
 | ||||
| // Set implements Set for the Linux RTC
 | ||||
| // ...
 | ||||
| // It might be not accurate as the time consumed by the system call is not taken into account
 | ||||
| // but it's good enough for our purposes
 | ||||
| func (t *TimeSync) setRtcTime(tu time.Time) error { | ||||
| 	rt := TimetoRtcTime(tu) | ||||
| 
 | ||||
| 	fd, err := t.getRtcDeviceFd() | ||||
| 	if err != nil { | ||||
| 		return fmt.Errorf("failed to get RTC device fd: %w", err) | ||||
| 	} | ||||
| 
 | ||||
| 	currentRtcTime, err := t.readRtcTime() | ||||
| 	if err != nil { | ||||
| 		return fmt.Errorf("failed to read RTC time: %w", err) | ||||
| 	} | ||||
| 
 | ||||
| 	t.l.Info(). | ||||
| 		Interface("rtc_time", tu). | ||||
| 		Str("offset", tu.Sub(currentRtcTime).String()). | ||||
| 		Msg("set rtc time") | ||||
| 
 | ||||
| 	if err := unix.IoctlSetRTCTime(fd, &rt); err != nil { | ||||
| 		return fmt.Errorf("failed to set RTC time: %w", err) | ||||
| 	} | ||||
| 
 | ||||
| 	metricRTCUpdateCount.Inc() | ||||
| 
 | ||||
| 	return nil | ||||
| } | ||||
|  | @ -0,0 +1,16 @@ | |||
| //go:build !linux
 | ||||
| 
 | ||||
| package timesync | ||||
| 
 | ||||
| import ( | ||||
| 	"errors" | ||||
| 	"time" | ||||
| ) | ||||
| 
 | ||||
| func (t *TimeSync) readRtcTime() (time.Time, error) { | ||||
| 	return time.Now(), nil | ||||
| } | ||||
| 
 | ||||
| func (t *TimeSync) setRtcTime(tu time.Time) error { | ||||
| 	return errors.New("not supported") | ||||
| } | ||||
|  | @ -0,0 +1,262 @@ | |||
| package timesync | ||||
| 
 | ||||
| import ( | ||||
| 	"fmt" | ||||
| 	"os" | ||||
| 	"os/exec" | ||||
| 	"sync" | ||||
| 	"time" | ||||
| 
 | ||||
| 	"github.com/jetkvm/kvm/internal/network" | ||||
| 	"github.com/rs/zerolog" | ||||
| ) | ||||
| 
 | ||||
| const ( | ||||
| 	timeSyncRetryStep     = 5 * time.Second | ||||
| 	timeSyncRetryMaxInt   = 1 * time.Minute | ||||
| 	timeSyncWaitNetChkInt = 100 * time.Millisecond | ||||
| 	timeSyncWaitNetUpInt  = 3 * time.Second | ||||
| 	timeSyncInterval      = 1 * time.Hour | ||||
| 	timeSyncTimeout       = 2 * time.Second | ||||
| ) | ||||
| 
 | ||||
| var ( | ||||
| 	timeSyncRetryInterval = 0 * time.Second | ||||
| ) | ||||
| 
 | ||||
| type TimeSync struct { | ||||
| 	syncLock *sync.Mutex | ||||
| 	l        *zerolog.Logger | ||||
| 
 | ||||
| 	networkConfig    *network.NetworkConfig | ||||
| 	dhcpNtpAddresses []string | ||||
| 
 | ||||
| 	rtcDevicePath string | ||||
| 	rtcDevice     *os.File //nolint:unused
 | ||||
| 	rtcLock       *sync.Mutex | ||||
| 
 | ||||
| 	syncSuccess bool | ||||
| 
 | ||||
| 	preCheckFunc func() (bool, error) | ||||
| } | ||||
| 
 | ||||
| type TimeSyncOptions struct { | ||||
| 	PreCheckFunc  func() (bool, error) | ||||
| 	Logger        *zerolog.Logger | ||||
| 	NetworkConfig *network.NetworkConfig | ||||
| } | ||||
| 
 | ||||
| type SyncMode struct { | ||||
| 	Ntp             bool | ||||
| 	Http            bool | ||||
| 	Ordering        []string | ||||
| 	NtpUseFallback  bool | ||||
| 	HttpUseFallback bool | ||||
| } | ||||
| 
 | ||||
| func NewTimeSync(opts *TimeSyncOptions) *TimeSync { | ||||
| 	rtcDevice, err := getRtcDevicePath() | ||||
| 	if err != nil { | ||||
| 		opts.Logger.Error().Err(err).Msg("failed to get RTC device path") | ||||
| 	} else { | ||||
| 		opts.Logger.Info().Str("path", rtcDevice).Msg("RTC device found") | ||||
| 	} | ||||
| 
 | ||||
| 	t := &TimeSync{ | ||||
| 		syncLock:         &sync.Mutex{}, | ||||
| 		l:                opts.Logger, | ||||
| 		dhcpNtpAddresses: []string{}, | ||||
| 		rtcDevicePath:    rtcDevice, | ||||
| 		rtcLock:          &sync.Mutex{}, | ||||
| 		preCheckFunc:     opts.PreCheckFunc, | ||||
| 		networkConfig:    opts.NetworkConfig, | ||||
| 	} | ||||
| 
 | ||||
| 	if t.rtcDevicePath != "" { | ||||
| 		rtcTime, _ := t.readRtcTime() | ||||
| 		t.l.Info().Interface("rtc_time", rtcTime).Msg("read RTC time") | ||||
| 	} | ||||
| 
 | ||||
| 	return t | ||||
| } | ||||
| 
 | ||||
| func (t *TimeSync) SetDhcpNtpAddresses(addresses []string) { | ||||
| 	t.dhcpNtpAddresses = addresses | ||||
| } | ||||
| 
 | ||||
| func (t *TimeSync) getSyncMode() SyncMode { | ||||
| 	syncMode := SyncMode{ | ||||
| 		Ntp:             true, | ||||
| 		Http:            true, | ||||
| 		Ordering:        []string{"ntp_dhcp", "ntp", "http"}, | ||||
| 		NtpUseFallback:  true, | ||||
| 		HttpUseFallback: true, | ||||
| 	} | ||||
| 
 | ||||
| 	if t.networkConfig != nil { | ||||
| 		switch t.networkConfig.TimeSyncMode.String { | ||||
| 		case "ntp_only": | ||||
| 			syncMode.Http = false | ||||
| 		case "http_only": | ||||
| 			syncMode.Ntp = false | ||||
| 		} | ||||
| 
 | ||||
| 		if t.networkConfig.TimeSyncDisableFallback.Bool { | ||||
| 			syncMode.NtpUseFallback = false | ||||
| 			syncMode.HttpUseFallback = false | ||||
| 		} | ||||
| 
 | ||||
| 		var syncOrdering = t.networkConfig.TimeSyncOrdering | ||||
| 		if len(syncOrdering) > 0 { | ||||
| 			syncMode.Ordering = syncOrdering | ||||
| 		} | ||||
| 	} | ||||
| 
 | ||||
| 	t.l.Debug().Strs("Ordering", syncMode.Ordering).Bool("Ntp", syncMode.Ntp).Bool("Http", syncMode.Http).Bool("NtpUseFallback", syncMode.NtpUseFallback).Bool("HttpUseFallback", syncMode.HttpUseFallback).Msg("sync mode") | ||||
| 
 | ||||
| 	return syncMode | ||||
| } | ||||
| func (t *TimeSync) doTimeSync() { | ||||
| 	metricTimeSyncStatus.Set(0) | ||||
| 	for { | ||||
| 		if ok, err := t.preCheckFunc(); !ok { | ||||
| 			if err != nil { | ||||
| 				t.l.Error().Err(err).Msg("pre-check failed") | ||||
| 			} | ||||
| 			time.Sleep(timeSyncWaitNetChkInt) | ||||
| 			continue | ||||
| 		} | ||||
| 
 | ||||
| 		t.l.Info().Msg("syncing system time") | ||||
| 		start := time.Now() | ||||
| 		err := t.Sync() | ||||
| 		if err != nil { | ||||
| 			t.l.Error().Str("error", err.Error()).Msg("failed to sync system time") | ||||
| 
 | ||||
| 			// retry after a delay
 | ||||
| 			timeSyncRetryInterval += timeSyncRetryStep | ||||
| 			time.Sleep(timeSyncRetryInterval) | ||||
| 			// reset the retry interval if it exceeds the max interval
 | ||||
| 			if timeSyncRetryInterval > timeSyncRetryMaxInt { | ||||
| 				timeSyncRetryInterval = 0 | ||||
| 			} | ||||
| 
 | ||||
| 			continue | ||||
| 		} | ||||
| 		t.syncSuccess = true | ||||
| 		t.l.Info().Str("now", time.Now().Format(time.RFC3339)). | ||||
| 			Str("time_taken", time.Since(start).String()). | ||||
| 			Msg("time sync successful") | ||||
| 
 | ||||
| 		metricTimeSyncStatus.Set(1) | ||||
| 
 | ||||
| 		time.Sleep(timeSyncInterval) // after the first sync is done
 | ||||
| 	} | ||||
| } | ||||
| 
 | ||||
| func (t *TimeSync) Sync() error { | ||||
| 	var ( | ||||
| 		now    *time.Time | ||||
| 		offset *time.Duration | ||||
| 		log    zerolog.Logger | ||||
| 	) | ||||
| 
 | ||||
| 	metricTimeSyncCount.Inc() | ||||
| 
 | ||||
| 	syncMode := t.getSyncMode() | ||||
| 
 | ||||
| Orders: | ||||
| 	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 { | ||||
| 		return fmt.Errorf("failed to get time from any source") | ||||
| 	} | ||||
| 
 | ||||
| 	if offset != nil { | ||||
| 		newNow := time.Now().Add(*offset) | ||||
| 		now = &newNow | ||||
| 	} | ||||
| 
 | ||||
| 	log.Info().Time("now", *now).Msg("time obtained") | ||||
| 
 | ||||
| 	err := t.setSystemTime(*now) | ||||
| 	if err != nil { | ||||
| 		return fmt.Errorf("failed to set system time: %w", err) | ||||
| 	} | ||||
| 
 | ||||
| 	metricTimeSyncSuccessCount.Inc() | ||||
| 
 | ||||
| 	return nil | ||||
| } | ||||
| 
 | ||||
| func (t *TimeSync) IsSyncSuccess() bool { | ||||
| 	return t.syncSuccess | ||||
| } | ||||
| 
 | ||||
| func (t *TimeSync) Start() { | ||||
| 	go t.doTimeSync() | ||||
| } | ||||
| 
 | ||||
| func (t *TimeSync) setSystemTime(now time.Time) error { | ||||
| 	nowStr := now.Format("2006-01-02 15:04:05") | ||||
| 	output, err := exec.Command("date", "-s", nowStr).CombinedOutput() | ||||
| 	if err != nil { | ||||
| 		return fmt.Errorf("failed to run date -s: %w, %s", err, string(output)) | ||||
| 	} | ||||
| 
 | ||||
| 	if t.rtcDevicePath != "" { | ||||
| 		return t.setRtcTime(now) | ||||
| 	} | ||||
| 
 | ||||
| 	return nil | ||||
| } | ||||
|  | @ -0,0 +1,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", | ||||
| } | ||||
|  | @ -0,0 +1,12 @@ | |||
| package udhcpc | ||||
| 
 | ||||
| func (u *DHCPClient) GetNtpServers() []string { | ||||
| 	if u.lease == nil { | ||||
| 		return nil | ||||
| 	} | ||||
| 	servers := make([]string, len(u.lease.NTPServers)) | ||||
| 	for i, server := range u.lease.NTPServers { | ||||
| 		servers[i] = server.String() | ||||
| 	} | ||||
| 	return servers | ||||
| } | ||||
|  | @ -0,0 +1,186 @@ | |||
| package udhcpc | ||||
| 
 | ||||
| import ( | ||||
| 	"bufio" | ||||
| 	"encoding/json" | ||||
| 	"fmt" | ||||
| 	"net" | ||||
| 	"os" | ||||
| 	"reflect" | ||||
| 	"strconv" | ||||
| 	"strings" | ||||
| 	"time" | ||||
| ) | ||||
| 
 | ||||
| type Lease struct { | ||||
| 	// from https://udhcp.busybox.net/README.udhcpc
 | ||||
| 	IPAddress         net.IP        `env:"ip" json:"ip"`                               // The obtained IP
 | ||||
| 	Netmask           net.IP        `env:"subnet" json:"netmask"`                      // The assigned subnet mask
 | ||||
| 	Broadcast         net.IP        `env:"broadcast" json:"broadcast"`                 // The broadcast address for this network
 | ||||
| 	TTL               int           `env:"ipttl" json:"ttl,omitempty"`                 // The TTL to use for this network
 | ||||
| 	MTU               int           `env:"mtu" json:"mtu,omitempty"`                   // The MTU to use for this network
 | ||||
| 	HostName          string        `env:"hostname" json:"hostname,omitempty"`         // The assigned hostname
 | ||||
| 	Domain            string        `env:"domain" json:"domain,omitempty"`             // The domain name of the network
 | ||||
| 	BootPNextServer   net.IP        `env:"siaddr" json:"bootp_next_server,omitempty"`  // The bootp next server option
 | ||||
| 	BootPServerName   string        `env:"sname" json:"bootp_server_name,omitempty"`   // The bootp server name option
 | ||||
| 	BootPFile         string        `env:"boot_file" json:"bootp_file,omitempty"`      // The bootp boot file option
 | ||||
| 	Timezone          string        `env:"timezone" json:"timezone,omitempty"`         // Offset in seconds from UTC
 | ||||
| 	Routers           []net.IP      `env:"router" json:"routers,omitempty"`            // A list of routers
 | ||||
| 	DNS               []net.IP      `env:"dns" json:"dns_servers,omitempty"`           // A list of DNS servers
 | ||||
| 	NTPServers        []net.IP      `env:"ntpsrv" json:"ntp_servers,omitempty"`        // A list of NTP servers
 | ||||
| 	LPRServers        []net.IP      `env:"lprsvr" json:"lpr_servers,omitempty"`        // A list of LPR servers
 | ||||
| 	TimeServers       []net.IP      `env:"timesvr" json:"_time_servers,omitempty"`     // A list of time servers (obsolete)
 | ||||
| 	IEN116NameServers []net.IP      `env:"namesvr" json:"_name_servers,omitempty"`     // A list of IEN 116 name servers (obsolete)
 | ||||
| 	LogServers        []net.IP      `env:"logsvr" json:"_log_servers,omitempty"`       // A list of MIT-LCS UDP log servers (obsolete)
 | ||||
| 	CookieServers     []net.IP      `env:"cookiesvr" json:"_cookie_servers,omitempty"` // A list of RFC 865 cookie servers (obsolete)
 | ||||
| 	WINSServers       []net.IP      `env:"wins" json:"_wins_servers,omitempty"`        // A list of WINS servers
 | ||||
| 	SwapServer        net.IP        `env:"swapsvr" json:"_swap_server,omitempty"`      // The IP address of the client's swap server
 | ||||
| 	BootSize          int           `env:"bootsize" json:"bootsize,omitempty"`         // The length in 512 octect blocks of the bootfile
 | ||||
| 	RootPath          string        `env:"rootpath" json:"root_path,omitempty"`        // The path name of the client's root disk
 | ||||
| 	LeaseTime         time.Duration `env:"lease" json:"lease,omitempty"`               // The lease time, in seconds
 | ||||
| 	DHCPType          string        `env:"dhcptype" json:"dhcp_type,omitempty"`        // DHCP message type (safely ignored)
 | ||||
| 	ServerID          string        `env:"serverid" json:"server_id,omitempty"`        // The IP of the server
 | ||||
| 	Message           string        `env:"message" json:"reason,omitempty"`            // Reason for a DHCPNAK
 | ||||
| 	TFTPServerName    string        `env:"tftp" json:"tftp,omitempty"`                 // The TFTP server name
 | ||||
| 	BootFileName      string        `env:"bootfile" json:"bootfile,omitempty"`         // The boot file name
 | ||||
| 	Uptime            time.Duration `env:"uptime" json:"uptime,omitempty"`             // The uptime of the device when the lease was obtained, in seconds
 | ||||
| 	LeaseExpiry       *time.Time    `json:"lease_expiry,omitempty"`                    // The expiry time of the lease
 | ||||
| 	isEmpty           map[string]bool | ||||
| } | ||||
| 
 | ||||
| func (l *Lease) setIsEmpty(m map[string]bool) { | ||||
| 	l.isEmpty = m | ||||
| } | ||||
| 
 | ||||
| func (l *Lease) IsEmpty(key string) bool { | ||||
| 	return l.isEmpty[key] | ||||
| } | ||||
| 
 | ||||
| func (l *Lease) ToJSON() string { | ||||
| 	json, err := json.Marshal(l) | ||||
| 	if err != nil { | ||||
| 		return "" | ||||
| 	} | ||||
| 	return string(json) | ||||
| } | ||||
| 
 | ||||
| func (l *Lease) SetLeaseExpiry() (time.Time, error) { | ||||
| 	if l.Uptime == 0 || l.LeaseTime == 0 { | ||||
| 		return time.Time{}, fmt.Errorf("uptime or lease time isn't set") | ||||
| 	} | ||||
| 
 | ||||
| 	// get the uptime of the device
 | ||||
| 
 | ||||
| 	file, err := os.Open("/proc/uptime") | ||||
| 	if err != nil { | ||||
| 		return time.Time{}, fmt.Errorf("failed to open uptime file: %w", err) | ||||
| 	} | ||||
| 	defer file.Close() | ||||
| 
 | ||||
| 	var uptime time.Duration | ||||
| 
 | ||||
| 	scanner := bufio.NewScanner(file) | ||||
| 	for scanner.Scan() { | ||||
| 		text := scanner.Text() | ||||
| 		parts := strings.Split(text, " ") | ||||
| 		uptime, err = time.ParseDuration(parts[0] + "s") | ||||
| 
 | ||||
| 		if err != nil { | ||||
| 			return time.Time{}, fmt.Errorf("failed to parse uptime: %w", err) | ||||
| 		} | ||||
| 	} | ||||
| 
 | ||||
| 	relativeLeaseRemaining := (l.Uptime + l.LeaseTime) - uptime | ||||
| 	leaseExpiry := time.Now().Add(relativeLeaseRemaining) | ||||
| 
 | ||||
| 	l.LeaseExpiry = &leaseExpiry | ||||
| 
 | ||||
| 	return leaseExpiry, nil | ||||
| } | ||||
| 
 | ||||
| func UnmarshalDHCPCLease(lease *Lease, str string) error { | ||||
| 	// parse the lease file as a map
 | ||||
| 	data := make(map[string]string) | ||||
| 	for line := range strings.SplitSeq(str, "\n") { | ||||
| 		line = strings.TrimSpace(line) | ||||
| 		// skip empty lines and comments
 | ||||
| 		if line == "" || strings.HasPrefix(line, "#") { | ||||
| 			continue | ||||
| 		} | ||||
| 
 | ||||
| 		parts := strings.SplitN(line, "=", 2) | ||||
| 		if len(parts) != 2 { | ||||
| 			continue | ||||
| 		} | ||||
| 
 | ||||
| 		key := strings.TrimSpace(parts[0]) | ||||
| 		value := strings.TrimSpace(parts[1]) | ||||
| 
 | ||||
| 		data[key] = value | ||||
| 	} | ||||
| 
 | ||||
| 	// now iterate over the lease struct and set the values
 | ||||
| 	leaseType := reflect.TypeOf(lease).Elem() | ||||
| 	leaseValue := reflect.ValueOf(lease).Elem() | ||||
| 
 | ||||
| 	valuesParsed := make(map[string]bool) | ||||
| 
 | ||||
| 	for i := 0; i < leaseType.NumField(); i++ { | ||||
| 		field := leaseValue.Field(i) | ||||
| 
 | ||||
| 		// get the env tag
 | ||||
| 		key := leaseType.Field(i).Tag.Get("env") | ||||
| 		if key == "" { | ||||
| 			continue | ||||
| 		} | ||||
| 
 | ||||
| 		valuesParsed[key] = false | ||||
| 
 | ||||
| 		// get the value from the data map
 | ||||
| 		value, ok := data[key] | ||||
| 		if !ok || value == "" { | ||||
| 			continue | ||||
| 		} | ||||
| 
 | ||||
| 		switch field.Interface().(type) { | ||||
| 		case string: | ||||
| 			field.SetString(value) | ||||
| 		case int: | ||||
| 			val, err := strconv.Atoi(value) | ||||
| 			if err != nil { | ||||
| 				continue | ||||
| 			} | ||||
| 			field.SetInt(int64(val)) | ||||
| 		case time.Duration: | ||||
| 			val, err := time.ParseDuration(value + "s") | ||||
| 			if err != nil { | ||||
| 				continue | ||||
| 			} | ||||
| 			field.Set(reflect.ValueOf(val)) | ||||
| 		case net.IP: | ||||
| 			ip := net.ParseIP(value) | ||||
| 			if ip == nil { | ||||
| 				continue | ||||
| 			} | ||||
| 			field.Set(reflect.ValueOf(ip)) | ||||
| 		case []net.IP: | ||||
| 			val := make([]net.IP, 0) | ||||
| 			for ipStr := range strings.FieldsSeq(value) { | ||||
| 				ip := net.ParseIP(ipStr) | ||||
| 				if ip == nil { | ||||
| 					continue | ||||
| 				} | ||||
| 				val = append(val, ip) | ||||
| 			} | ||||
| 			field.Set(reflect.ValueOf(val)) | ||||
| 		default: | ||||
| 			return fmt.Errorf("unsupported field `%s` type: %s", key, field.Type().String()) | ||||
| 		} | ||||
| 
 | ||||
| 		valuesParsed[key] = true | ||||
| 	} | ||||
| 
 | ||||
| 	lease.setIsEmpty(valuesParsed) | ||||
| 
 | ||||
| 	return nil | ||||
| } | ||||
|  | @ -0,0 +1,74 @@ | |||
| package udhcpc | ||||
| 
 | ||||
| import ( | ||||
| 	"testing" | ||||
| 	"time" | ||||
| ) | ||||
| 
 | ||||
| func TestUnmarshalDHCPCLease(t *testing.T) { | ||||
| 	lease := &Lease{} | ||||
| 	err := UnmarshalDHCPCLease(lease, ` | ||||
| # generated @ Mon Jan  4 19:31:53 UTC 2021 | ||||
| #  19:31:53 up 0 min,  0 users,  load average: 0.72, 0.14, 0.04 | ||||
| # the date might be inaccurate if the clock is not set | ||||
| ip=192.168.0.240 | ||||
| siaddr=192.168.0.1 | ||||
| sname= | ||||
| boot_file= | ||||
| subnet=255.255.255.0 | ||||
| timezone= | ||||
| router=192.168.0.1 | ||||
| timesvr= | ||||
| namesvr= | ||||
| dns=172.19.53.2 | ||||
| logsvr= | ||||
| cookiesvr= | ||||
| lprsvr= | ||||
| hostname= | ||||
| bootsize= | ||||
| domain= | ||||
| swapsvr= | ||||
| rootpath= | ||||
| ipttl= | ||||
| mtu= | ||||
| broadcast= | ||||
| ntpsrv=162.159.200.123 | ||||
| wins= | ||||
| lease=172800 | ||||
| dhcptype= | ||||
| serverid=192.168.0.1 | ||||
| message= | ||||
| tftp= | ||||
| bootfile= | ||||
| 	`) | ||||
| 	if lease.IPAddress.String() != "192.168.0.240" { | ||||
| 		t.Fatalf("expected ip to be 192.168.0.240, got %s", lease.IPAddress.String()) | ||||
| 	} | ||||
| 	if lease.Netmask.String() != "255.255.255.0" { | ||||
| 		t.Fatalf("expected netmask to be 255.255.255.0, got %s", lease.Netmask.String()) | ||||
| 	} | ||||
| 	if len(lease.Routers) != 1 { | ||||
| 		t.Fatalf("expected 1 router, got %d", len(lease.Routers)) | ||||
| 	} | ||||
| 	if lease.Routers[0].String() != "192.168.0.1" { | ||||
| 		t.Fatalf("expected router to be 192.168.0.1, got %s", lease.Routers[0].String()) | ||||
| 	} | ||||
| 	if len(lease.NTPServers) != 1 { | ||||
| 		t.Fatalf("expected 1 timeserver, got %d", len(lease.NTPServers)) | ||||
| 	} | ||||
| 	if lease.NTPServers[0].String() != "162.159.200.123" { | ||||
| 		t.Fatalf("expected timeserver to be 162.159.200.123, got %s", lease.NTPServers[0].String()) | ||||
| 	} | ||||
| 	if len(lease.DNS) != 1 { | ||||
| 		t.Fatalf("expected 1 dns, got %d", len(lease.DNS)) | ||||
| 	} | ||||
| 	if lease.DNS[0].String() != "172.19.53.2" { | ||||
| 		t.Fatalf("expected dns to be 172.19.53.2, got %s", lease.DNS[0].String()) | ||||
| 	} | ||||
| 	if lease.LeaseTime != 172800*time.Second { | ||||
| 		t.Fatalf("expected lease time to be 172800 seconds, got %d", lease.LeaseTime) | ||||
| 	} | ||||
| 	if err != nil { | ||||
| 		t.Fatal(err) | ||||
| 	} | ||||
| } | ||||
|  | @ -0,0 +1,212 @@ | |||
| package udhcpc | ||||
| 
 | ||||
| import ( | ||||
| 	"bytes" | ||||
| 	"errors" | ||||
| 	"fmt" | ||||
| 	"io" | ||||
| 	"os" | ||||
| 	"path/filepath" | ||||
| 	"strconv" | ||||
| 	"strings" | ||||
| 	"syscall" | ||||
| ) | ||||
| 
 | ||||
| func readFileNoStat(filename string) ([]byte, error) { | ||||
| 	const maxBufferSize = 1024 * 1024 | ||||
| 
 | ||||
| 	f, err := os.Open(filename) | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
| 	defer f.Close() | ||||
| 
 | ||||
| 	reader := io.LimitReader(f, maxBufferSize) | ||||
| 	return io.ReadAll(reader) | ||||
| } | ||||
| 
 | ||||
| func toCmdline(path string) ([]string, error) { | ||||
| 	data, err := readFileNoStat(path) | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
| 
 | ||||
| 	if len(data) < 1 { | ||||
| 		return []string{}, nil | ||||
| 	} | ||||
| 
 | ||||
| 	return strings.Split(string(bytes.TrimRight(data, "\x00")), "\x00"), nil | ||||
| } | ||||
| 
 | ||||
| func (p *DHCPClient) findUdhcpcProcess() (int, error) { | ||||
| 	// read procfs for udhcpc processes
 | ||||
| 	// we do not use procfs.AllProcs() because we want to avoid the overhead of reading the entire procfs
 | ||||
| 	processes, err := os.ReadDir("/proc") | ||||
| 	if err != nil { | ||||
| 		return 0, err | ||||
| 	} | ||||
| 
 | ||||
| 	// iterate over the processes
 | ||||
| 	for _, d := range processes { | ||||
| 		// check if file is numeric
 | ||||
| 		pid, err := strconv.Atoi(d.Name()) | ||||
| 		if err != nil { | ||||
| 			continue | ||||
| 		} | ||||
| 
 | ||||
| 		// check if it's a directory
 | ||||
| 		if !d.IsDir() { | ||||
| 			continue | ||||
| 		} | ||||
| 
 | ||||
| 		cmdline, err := toCmdline(filepath.Join("/proc", d.Name(), "cmdline")) | ||||
| 		if err != nil { | ||||
| 			continue | ||||
| 		} | ||||
| 
 | ||||
| 		if len(cmdline) < 1 { | ||||
| 			continue | ||||
| 		} | ||||
| 
 | ||||
| 		if cmdline[0] != "udhcpc" { | ||||
| 			continue | ||||
| 		} | ||||
| 
 | ||||
| 		cmdlineText := strings.Join(cmdline, " ") | ||||
| 
 | ||||
| 		// check if it's a udhcpc process
 | ||||
| 		if strings.Contains(cmdlineText, fmt.Sprintf("-i %s", p.InterfaceName)) { | ||||
| 			p.logger.Debug(). | ||||
| 				Str("pid", d.Name()). | ||||
| 				Interface("cmdline", cmdline). | ||||
| 				Msg("found udhcpc process") | ||||
| 			return pid, nil | ||||
| 		} | ||||
| 	} | ||||
| 
 | ||||
| 	return 0, errors.New("udhcpc process not found") | ||||
| } | ||||
| 
 | ||||
| func (c *DHCPClient) getProcessPid() (int, error) { | ||||
| 	var pid int | ||||
| 	if c.pidFile != "" { | ||||
| 		// try to read the pid file
 | ||||
| 		pidHandle, err := os.ReadFile(c.pidFile) | ||||
| 		if err != nil { | ||||
| 			c.logger.Warn().Err(err). | ||||
| 				Str("pidFile", c.pidFile).Msg("failed to read udhcpc pid file") | ||||
| 		} | ||||
| 
 | ||||
| 		// if it exists, try to read the pid
 | ||||
| 		if pidHandle != nil { | ||||
| 			pidFromFile, err := strconv.Atoi(string(pidHandle)) | ||||
| 			if err != nil { | ||||
| 				c.logger.Warn().Err(err). | ||||
| 					Str("pidFile", c.pidFile).Msg("failed to convert pid file to int") | ||||
| 			} | ||||
| 			pid = pidFromFile | ||||
| 		} | ||||
| 	} | ||||
| 
 | ||||
| 	// if the pid is 0, try to find the pid using procfs
 | ||||
| 	if pid == 0 { | ||||
| 		newPid, err := c.findUdhcpcProcess() | ||||
| 		if err != nil { | ||||
| 			return 0, err | ||||
| 		} | ||||
| 		pid = newPid | ||||
| 	} | ||||
| 
 | ||||
| 	return pid, nil | ||||
| } | ||||
| 
 | ||||
| func (c *DHCPClient) getProcess() *os.Process { | ||||
| 	pid, err := c.getProcessPid() | ||||
| 	if err != nil { | ||||
| 		return nil | ||||
| 	} | ||||
| 
 | ||||
| 	process, err := os.FindProcess(pid) | ||||
| 	if err != nil { | ||||
| 		c.logger.Warn().Err(err). | ||||
| 			Int("pid", pid).Msg("failed to find process") | ||||
| 		return nil | ||||
| 	} | ||||
| 
 | ||||
| 	return process | ||||
| } | ||||
| 
 | ||||
| func (c *DHCPClient) GetProcess() *os.Process { | ||||
| 	if c.process == nil { | ||||
| 		process := c.getProcess() | ||||
| 		if process == nil { | ||||
| 			return nil | ||||
| 		} | ||||
| 		c.process = process | ||||
| 	} | ||||
| 
 | ||||
| 	err := c.process.Signal(syscall.Signal(0)) | ||||
| 	if err != nil && errors.Is(err, os.ErrProcessDone) { | ||||
| 		oldPid := c.process.Pid | ||||
| 
 | ||||
| 		c.process = nil | ||||
| 		c.process = c.getProcess() | ||||
| 		if c.process == nil { | ||||
| 			c.logger.Error().Msg("failed to find new udhcpc process") | ||||
| 			return nil | ||||
| 		} | ||||
| 		c.logger.Warn(). | ||||
| 			Int("oldPid", oldPid). | ||||
| 			Int("newPid", c.process.Pid). | ||||
| 			Msg("udhcpc process pid changed") | ||||
| 	} else if err != nil { | ||||
| 		c.logger.Warn().Err(err). | ||||
| 			Int("pid", c.process.Pid).Msg("udhcpc process is not running") | ||||
| 	} | ||||
| 
 | ||||
| 	return c.process | ||||
| } | ||||
| 
 | ||||
| func (c *DHCPClient) KillProcess() error { | ||||
| 	process := c.GetProcess() | ||||
| 	if process == nil { | ||||
| 		return nil | ||||
| 	} | ||||
| 
 | ||||
| 	return process.Kill() | ||||
| } | ||||
| 
 | ||||
| func (c *DHCPClient) ReleaseProcess() error { | ||||
| 	process := c.GetProcess() | ||||
| 	if process == nil { | ||||
| 		return nil | ||||
| 	} | ||||
| 
 | ||||
| 	return process.Release() | ||||
| } | ||||
| 
 | ||||
| func (c *DHCPClient) signalProcess(sig syscall.Signal) error { | ||||
| 	process := c.GetProcess() | ||||
| 	if process == nil { | ||||
| 		return nil | ||||
| 	} | ||||
| 
 | ||||
| 	s := process.Signal(sig) | ||||
| 	if s != nil { | ||||
| 		c.logger.Warn().Err(s). | ||||
| 			Int("pid", process.Pid). | ||||
| 			Str("signal", sig.String()). | ||||
| 			Msg("failed to signal udhcpc process") | ||||
| 		return s | ||||
| 	} | ||||
| 
 | ||||
| 	return nil | ||||
| } | ||||
| 
 | ||||
| func (c *DHCPClient) Renew() error { | ||||
| 	return c.signalProcess(syscall.SIGUSR1) | ||||
| } | ||||
| 
 | ||||
| func (c *DHCPClient) Release() error { | ||||
| 	return c.signalProcess(syscall.SIGUSR2) | ||||
| } | ||||
|  | @ -0,0 +1,198 @@ | |||
| package udhcpc | ||||
| 
 | ||||
| import ( | ||||
| 	"errors" | ||||
| 	"fmt" | ||||
| 	"os" | ||||
| 	"path/filepath" | ||||
| 	"reflect" | ||||
| 	"time" | ||||
| 
 | ||||
| 	"github.com/fsnotify/fsnotify" | ||||
| 	"github.com/rs/zerolog" | ||||
| ) | ||||
| 
 | ||||
| const ( | ||||
| 	DHCPLeaseFile = "/run/udhcpc.%s.info" | ||||
| 	DHCPPidFile   = "/run/udhcpc.%s.pid" | ||||
| ) | ||||
| 
 | ||||
| type DHCPClient struct { | ||||
| 	InterfaceName string | ||||
| 	leaseFile     string | ||||
| 	pidFile       string | ||||
| 	lease         *Lease | ||||
| 	logger        *zerolog.Logger | ||||
| 	process       *os.Process | ||||
| 	onLeaseChange func(lease *Lease) | ||||
| } | ||||
| 
 | ||||
| type DHCPClientOptions struct { | ||||
| 	InterfaceName string | ||||
| 	PidFile       string | ||||
| 	Logger        *zerolog.Logger | ||||
| 	OnLeaseChange func(lease *Lease) | ||||
| } | ||||
| 
 | ||||
| var defaultLogger = zerolog.New(os.Stdout).Level(zerolog.InfoLevel) | ||||
| 
 | ||||
| func NewDHCPClient(options *DHCPClientOptions) *DHCPClient { | ||||
| 	if options.Logger == nil { | ||||
| 		options.Logger = &defaultLogger | ||||
| 	} | ||||
| 
 | ||||
| 	l := options.Logger.With().Str("interface", options.InterfaceName).Logger() | ||||
| 	return &DHCPClient{ | ||||
| 		InterfaceName: options.InterfaceName, | ||||
| 		logger:        &l, | ||||
| 		leaseFile:     fmt.Sprintf(DHCPLeaseFile, options.InterfaceName), | ||||
| 		pidFile:       options.PidFile, | ||||
| 		onLeaseChange: options.OnLeaseChange, | ||||
| 	} | ||||
| } | ||||
| 
 | ||||
| func (c *DHCPClient) getWatchPaths() []string { | ||||
| 	watchPaths := make(map[string]any) | ||||
| 	watchPaths[filepath.Dir(c.leaseFile)] = nil | ||||
| 
 | ||||
| 	if c.pidFile != "" { | ||||
| 		watchPaths[filepath.Dir(c.pidFile)] = nil | ||||
| 	} | ||||
| 
 | ||||
| 	paths := make([]string, 0) | ||||
| 	for path := range watchPaths { | ||||
| 		paths = append(paths, path) | ||||
| 	} | ||||
| 	return paths | ||||
| } | ||||
| 
 | ||||
| // Run starts the DHCP client and watches the lease file for changes.
 | ||||
| // this isn't a blocking call, and the lease file is reloaded when a change is detected.
 | ||||
| func (c *DHCPClient) Run() error { | ||||
| 	err := c.loadLeaseFile() | ||||
| 	if err != nil && !errors.Is(err, os.ErrNotExist) { | ||||
| 		return err | ||||
| 	} | ||||
| 
 | ||||
| 	watcher, err := fsnotify.NewWatcher() | ||||
| 	if err != nil { | ||||
| 		return err | ||||
| 	} | ||||
| 	defer watcher.Close() | ||||
| 
 | ||||
| 	go func() { | ||||
| 		for { | ||||
| 			select { | ||||
| 			case event, ok := <-watcher.Events: | ||||
| 				if !ok { | ||||
| 					continue | ||||
| 				} | ||||
| 				if !event.Has(fsnotify.Write) && !event.Has(fsnotify.Create) { | ||||
| 					continue | ||||
| 				} | ||||
| 
 | ||||
| 				if event.Name == c.leaseFile { | ||||
| 					c.logger.Debug(). | ||||
| 						Str("event", event.Op.String()). | ||||
| 						Str("path", event.Name). | ||||
| 						Msg("udhcpc lease file updated, reloading lease") | ||||
| 					_ = c.loadLeaseFile() | ||||
| 				} | ||||
| 			case err, ok := <-watcher.Errors: | ||||
| 				if !ok { | ||||
| 					return | ||||
| 				} | ||||
| 				c.logger.Error().Err(err).Msg("error watching lease file") | ||||
| 			} | ||||
| 		} | ||||
| 	}() | ||||
| 
 | ||||
| 	for _, path := range c.getWatchPaths() { | ||||
| 		err = watcher.Add(path) | ||||
| 		if err != nil { | ||||
| 			c.logger.Error(). | ||||
| 				Err(err). | ||||
| 				Str("path", path). | ||||
| 				Msg("failed to watch directory") | ||||
| 			return err | ||||
| 		} | ||||
| 	} | ||||
| 
 | ||||
| 	// TODO: update udhcpc pid file
 | ||||
| 	// we'll comment this out for now because the pid might change
 | ||||
| 	// process := c.GetProcess()
 | ||||
| 	// if process == nil {
 | ||||
| 	// 	c.logger.Error().Msg("udhcpc process not found")
 | ||||
| 	// }
 | ||||
| 
 | ||||
| 	// block the goroutine until the lease file is updated
 | ||||
| 	<-make(chan struct{}) | ||||
| 
 | ||||
| 	return nil | ||||
| } | ||||
| 
 | ||||
| func (c *DHCPClient) loadLeaseFile() error { | ||||
| 	file, err := os.ReadFile(c.leaseFile) | ||||
| 	if err != nil { | ||||
| 		return err | ||||
| 	} | ||||
| 
 | ||||
| 	data := string(file) | ||||
| 	if data == "" { | ||||
| 		c.logger.Debug().Msg("udhcpc lease file is empty") | ||||
| 		return nil | ||||
| 	} | ||||
| 
 | ||||
| 	lease := &Lease{} | ||||
| 	err = UnmarshalDHCPCLease(lease, string(file)) | ||||
| 	if err != nil { | ||||
| 		return err | ||||
| 	} | ||||
| 
 | ||||
| 	isFirstLoad := c.lease == nil | ||||
| 
 | ||||
| 	// Skip processing if lease hasn't changed to avoid unnecessary wake-ups.
 | ||||
| 	if reflect.DeepEqual(c.lease, lease) { | ||||
| 		return nil | ||||
| 	} | ||||
| 
 | ||||
| 	c.lease = lease | ||||
| 
 | ||||
| 	if lease.IPAddress == nil { | ||||
| 		c.logger.Info(). | ||||
| 			Interface("lease", lease). | ||||
| 			Str("data", string(file)). | ||||
| 			Msg("udhcpc lease cleared") | ||||
| 		return nil | ||||
| 	} | ||||
| 
 | ||||
| 	msg := "udhcpc lease updated" | ||||
| 	if isFirstLoad { | ||||
| 		msg = "udhcpc lease loaded" | ||||
| 	} | ||||
| 
 | ||||
| 	leaseExpiry, err := lease.SetLeaseExpiry() | ||||
| 	if err != nil { | ||||
| 		c.logger.Error().Err(err).Msg("failed to get dhcp lease expiry") | ||||
| 	} else { | ||||
| 		expiresIn := time.Until(leaseExpiry) | ||||
| 		c.logger.Info(). | ||||
| 			Interface("expiry", leaseExpiry). | ||||
| 			Str("expiresIn", expiresIn.String()). | ||||
| 			Msg("current dhcp lease expiry time calculated") | ||||
| 	} | ||||
| 
 | ||||
| 	c.onLeaseChange(lease) | ||||
| 
 | ||||
| 	c.logger.Info(). | ||||
| 		Str("ip", lease.IPAddress.String()). | ||||
| 		Str("leaseTime", lease.LeaseTime.String()). | ||||
| 		Interface("data", lease). | ||||
| 		Msg(msg) | ||||
| 
 | ||||
| 	return nil | ||||
| } | ||||
| 
 | ||||
| func (c *DHCPClient) GetLease() *Lease { | ||||
| 	return c.lease | ||||
| } | ||||
|  | @ -0,0 +1,436 @@ | |||
| package usbgadget | ||||
| 
 | ||||
| import ( | ||||
| 	"bytes" | ||||
| 	"fmt" | ||||
| 	"os" | ||||
| 	"path/filepath" | ||||
| 	"reflect" | ||||
| 	"time" | ||||
| 
 | ||||
| 	"github.com/prometheus/procfs" | ||||
| 	"github.com/sourcegraph/tf-dag/dag" | ||||
| ) | ||||
| 
 | ||||
| // it's a minimalistic implementation of ansible's file module with some modifications
 | ||||
| // to make it more suitable for our use case
 | ||||
| // https://docs.ansible.com/ansible/latest/modules/file_module.html
 | ||||
| 
 | ||||
| // we use this to check if the files in the gadget config are in the expected state
 | ||||
| // and to update them if they are not in the expected state
 | ||||
| 
 | ||||
| type FileState uint8 | ||||
| type ChangeState uint8 | ||||
| type FileChangeResolvedAction uint8 | ||||
| 
 | ||||
| type ApplyFunc func(c *ChangeSet, changes []*FileChange) error | ||||
| 
 | ||||
| const ( | ||||
| 	FileStateUnknown FileState = iota | ||||
| 	FileStateAbsent | ||||
| 	FileStateDirectory | ||||
| 	FileStateFile | ||||
| 	FileStateFileContentMatch | ||||
| 	FileStateFileWrite // update file content without checking
 | ||||
| 	FileStateMounted | ||||
| 	FileStateMountedConfigFS | ||||
| 	FileStateSymlink | ||||
| 	FileStateSymlinkInOrderConfigFS // configfs is a shithole, so we need to check if the symlinks are created in the correct order
 | ||||
| 	FileStateSymlinkNotInOrderConfigFS | ||||
| 	FileStateTouch | ||||
| ) | ||||
| 
 | ||||
| var FileStateString = map[FileState]string{ | ||||
| 	FileStateUnknown:                "UNKNOWN", | ||||
| 	FileStateAbsent:                 "ABSENT", | ||||
| 	FileStateDirectory:              "DIRECTORY", | ||||
| 	FileStateFile:                   "FILE", | ||||
| 	FileStateFileContentMatch:       "FILE_CONTENT_MATCH", | ||||
| 	FileStateFileWrite:              "FILE_WRITE", | ||||
| 	FileStateMounted:                "MOUNTED", | ||||
| 	FileStateMountedConfigFS:        "CONFIGFS_MOUNTED", | ||||
| 	FileStateSymlink:                "SYMLINK", | ||||
| 	FileStateSymlinkInOrderConfigFS: "SYMLINK_IN_ORDER_CONFIGFS", | ||||
| 	FileStateTouch:                  "TOUCH", | ||||
| } | ||||
| 
 | ||||
| const ( | ||||
| 	ChangeStateUnknown ChangeState = iota | ||||
| 	ChangeStateRequired | ||||
| 	ChangeStateNotChanged | ||||
| 	ChangeStateChanged | ||||
| 	ChangeStateError | ||||
| ) | ||||
| 
 | ||||
| const ( | ||||
| 	FileChangeResolvedActionUnknown FileChangeResolvedAction = iota | ||||
| 	FileChangeResolvedActionDoNothing | ||||
| 	FileChangeResolvedActionRemove | ||||
| 	FileChangeResolvedActionCreateFile | ||||
| 	FileChangeResolvedActionWriteFile | ||||
| 	FileChangeResolvedActionUpdateFile | ||||
| 	FileChangeResolvedActionAppendFile | ||||
| 	FileChangeResolvedActionCreateSymlink | ||||
| 	FileChangeResolvedActionRecreateSymlink | ||||
| 	FileChangeResolvedActionCreateDirectoryAndSymlinks | ||||
| 	FileChangeResolvedActionReorderSymlinks | ||||
| 	FileChangeResolvedActionCreateDirectory | ||||
| 	FileChangeResolvedActionRemoveDirectory | ||||
| 	FileChangeResolvedActionTouch | ||||
| 	FileChangeResolvedActionMountConfigFS | ||||
| ) | ||||
| 
 | ||||
| var FileChangeResolvedActionString = map[FileChangeResolvedAction]string{ | ||||
| 	FileChangeResolvedActionUnknown:                    "UNKNOWN", | ||||
| 	FileChangeResolvedActionDoNothing:                  "DO_NOTHING", | ||||
| 	FileChangeResolvedActionRemove:                     "REMOVE", | ||||
| 	FileChangeResolvedActionCreateFile:                 "FILE_CREATE", | ||||
| 	FileChangeResolvedActionWriteFile:                  "FILE_WRITE", | ||||
| 	FileChangeResolvedActionUpdateFile:                 "FILE_UPDATE", | ||||
| 	FileChangeResolvedActionAppendFile:                 "FILE_APPEND", | ||||
| 	FileChangeResolvedActionCreateSymlink:              "SYMLINK_CREATE", | ||||
| 	FileChangeResolvedActionRecreateSymlink:            "SYMLINK_RECREATE", | ||||
| 	FileChangeResolvedActionCreateDirectoryAndSymlinks: "DIR_CREATE_AND_SYMLINKS", | ||||
| 	FileChangeResolvedActionReorderSymlinks:            "SYMLINK_REORDER", | ||||
| 	FileChangeResolvedActionCreateDirectory:            "DIR_CREATE", | ||||
| 	FileChangeResolvedActionRemoveDirectory:            "DIR_REMOVE", | ||||
| 	FileChangeResolvedActionTouch:                      "TOUCH", | ||||
| 	FileChangeResolvedActionMountConfigFS:              "CONFIGFS_MOUNT", | ||||
| } | ||||
| 
 | ||||
| type ChangeSet struct { | ||||
| 	Changes []FileChange | ||||
| } | ||||
| 
 | ||||
| type RequestedFileChange struct { | ||||
| 	Component       string | ||||
| 	Key             string | ||||
| 	Path            string // will be used as Key if Key is empty
 | ||||
| 	ParamSymlinks   []symlink | ||||
| 	ExpectedState   FileState | ||||
| 	ExpectedContent []byte | ||||
| 	DependsOn       []string | ||||
| 	BeforeChange    []string // if the file is going to be changed, apply the change first
 | ||||
| 	Description     string | ||||
| 	IgnoreErrors    bool | ||||
| 	When            string // only apply the change if when meets the condition
 | ||||
| } | ||||
| 
 | ||||
| type FileChange struct { | ||||
| 	RequestedFileChange | ||||
| 	ActualState   FileState | ||||
| 	ActualContent []byte | ||||
| 	resolvedDeps  []string | ||||
| 	checked       bool | ||||
| 	changed       ChangeState | ||||
| 	action        FileChangeResolvedAction | ||||
| } | ||||
| 
 | ||||
| func (f *RequestedFileChange) String() string { | ||||
| 	var s string | ||||
| 	switch f.ExpectedState { | ||||
| 	case FileStateDirectory: | ||||
| 		s = fmt.Sprintf("dir: %s", f.Path) | ||||
| 	case FileStateFile: | ||||
| 		s = fmt.Sprintf("file: %s", f.Path) | ||||
| 	case FileStateSymlink: | ||||
| 		s = fmt.Sprintf("symlink: %s -> %s", f.Path, f.ExpectedContent) | ||||
| 	case FileStateSymlinkInOrderConfigFS: | ||||
| 		s = fmt.Sprintf("symlink_in_order_configfs: %s -> %s", f.Path, f.ExpectedContent) | ||||
| 	case FileStateSymlinkNotInOrderConfigFS: | ||||
| 		s = fmt.Sprintf("symlink_not_in_order_configfs: %s -> %s", f.Path, f.ExpectedContent) | ||||
| 	case FileStateAbsent: | ||||
| 		s = fmt.Sprintf("absent: %s", f.Path) | ||||
| 	case FileStateFileContentMatch: | ||||
| 		s = fmt.Sprintf("file: %s with content [%s]", f.Path, f.ExpectedContent) | ||||
| 	case FileStateFileWrite: | ||||
| 		s = fmt.Sprintf("write: %s with content [%s]", f.Path, f.ExpectedContent) | ||||
| 	case FileStateMountedConfigFS: | ||||
| 		s = fmt.Sprintf("configfs: %s", f.Path) | ||||
| 	case FileStateTouch: | ||||
| 		s = fmt.Sprintf("touch: %s", f.Path) | ||||
| 	case FileStateUnknown: | ||||
| 		s = fmt.Sprintf("unknown change for %s", f.Path) | ||||
| 	default: | ||||
| 		s = fmt.Sprintf("unknown expected state %d for %s", f.ExpectedState, f.Path) | ||||
| 	} | ||||
| 
 | ||||
| 	if len(f.Description) > 0 { | ||||
| 		s += fmt.Sprintf(" (%s)", f.Description) | ||||
| 	} | ||||
| 
 | ||||
| 	return s | ||||
| } | ||||
| 
 | ||||
| func (f *RequestedFileChange) IsSame(other *RequestedFileChange) bool { | ||||
| 	return f.Path == other.Path && | ||||
| 		f.ExpectedState == other.ExpectedState && | ||||
| 		reflect.DeepEqual(f.ExpectedContent, other.ExpectedContent) && | ||||
| 		reflect.DeepEqual(f.DependsOn, other.DependsOn) && | ||||
| 		f.IgnoreErrors == other.IgnoreErrors | ||||
| } | ||||
| 
 | ||||
| func (fc *FileChange) checkIfDirIsMountPoint() error { | ||||
| 	// check if the file is a mount point
 | ||||
| 	mounts, err := procfs.GetMounts() | ||||
| 	if err != nil { | ||||
| 		return fmt.Errorf("failed to get mounts") | ||||
| 	} | ||||
| 
 | ||||
| 	for _, mount := range mounts { | ||||
| 		if mount.MountPoint == fc.Path { | ||||
| 			fc.ActualState = FileStateMounted | ||||
| 			fc.ActualContent = []byte(mount.Source) | ||||
| 
 | ||||
| 			if mount.FSType == "configfs" { | ||||
| 				fc.ActualState = FileStateMountedConfigFS | ||||
| 			} | ||||
| 
 | ||||
| 			return nil | ||||
| 		} | ||||
| 	} | ||||
| 
 | ||||
| 	return nil | ||||
| } | ||||
| 
 | ||||
| // GetActualState returns the actual state of the file at the given path.
 | ||||
| func (fc *FileChange) getActualState() error { | ||||
| 	l := defaultLogger.With().Str("path", fc.Path).Logger() | ||||
| 
 | ||||
| 	fi, err := os.Lstat(fc.Path) | ||||
| 	if err != nil { | ||||
| 		if os.IsNotExist(err) { | ||||
| 			fc.ActualState = FileStateAbsent | ||||
| 		} else { | ||||
| 			l.Warn().Err(err).Msg("failed to stat file") | ||||
| 			fc.ActualState = FileStateUnknown | ||||
| 		} | ||||
| 		return nil | ||||
| 	} | ||||
| 
 | ||||
| 	// check if the file is a symlink
 | ||||
| 	if fi.Mode()&os.ModeSymlink == os.ModeSymlink { | ||||
| 		fc.ActualState = FileStateSymlink | ||||
| 		// get the target of the symlink
 | ||||
| 		target, err := os.Readlink(fc.Path) | ||||
| 		if err != nil { | ||||
| 			l.Warn().Err(err).Msg("failed to read symlink") | ||||
| 			return fmt.Errorf("failed to read symlink") | ||||
| 		} | ||||
| 		// check if the target is a relative path
 | ||||
| 		if !filepath.IsAbs(target) { | ||||
| 			// make it absolute
 | ||||
| 			target, err = filepath.Abs(filepath.Join(filepath.Dir(fc.Path), target)) | ||||
| 			if err != nil { | ||||
| 				l.Warn().Err(err).Msg("failed to make symlink target absolute") | ||||
| 				return fmt.Errorf("failed to make symlink target absolute") | ||||
| 			} | ||||
| 		} | ||||
| 		fc.ActualContent = []byte(target) | ||||
| 		return nil | ||||
| 	} | ||||
| 
 | ||||
| 	if fi.IsDir() { | ||||
| 		fc.ActualState = FileStateDirectory | ||||
| 
 | ||||
| 		switch fc.ExpectedState { | ||||
| 		case FileStateMountedConfigFS: | ||||
| 			err := fc.checkIfDirIsMountPoint() | ||||
| 			if err != nil { | ||||
| 				l.Warn().Err(err).Msg("failed to check if dir is mount point") | ||||
| 				return err | ||||
| 			} | ||||
| 		case FileStateSymlinkInOrderConfigFS: | ||||
| 			state, err := checkIfSymlinksInOrder(fc, &l) | ||||
| 			if err != nil { | ||||
| 				l.Warn().Err(err).Msg("failed to check if symlinks are in order") | ||||
| 				return err | ||||
| 			} | ||||
| 			fc.ActualState = state | ||||
| 		} | ||||
| 		return nil | ||||
| 	} | ||||
| 
 | ||||
| 	if fi.Mode()&os.ModeDevice == os.ModeDevice { | ||||
| 		l.Info().Msg("file is a device") | ||||
| 		return nil | ||||
| 	} | ||||
| 
 | ||||
| 	// check if the file is a regular file
 | ||||
| 	if fi.Mode().IsRegular() { | ||||
| 		fc.ActualState = FileStateFile | ||||
| 		// get the content of the file
 | ||||
| 		content, err := os.ReadFile(fc.Path) | ||||
| 		if err != nil { | ||||
| 			l.Warn().Err(err).Msg("failed to read file") | ||||
| 			return fmt.Errorf("failed to read file") | ||||
| 		} | ||||
| 		fc.ActualContent = content | ||||
| 		return nil | ||||
| 	} | ||||
| 
 | ||||
| 	l.Warn().Interface("file_info", fi.Mode()).Bool("is_dir", fi.IsDir()).Msg("unknown file type") | ||||
| 
 | ||||
| 	return fmt.Errorf("unknown file type") | ||||
| } | ||||
| 
 | ||||
| func (fc *FileChange) ResetActionResolution() { | ||||
| 	fc.checked = false | ||||
| 	fc.action = FileChangeResolvedActionUnknown | ||||
| 	fc.changed = ChangeStateUnknown | ||||
| } | ||||
| 
 | ||||
| func (fc *FileChange) Action() FileChangeResolvedAction { | ||||
| 	if !fc.checked { | ||||
| 		fc.action = fc.getFileChangeResolvedAction() | ||||
| 		fc.checked = true | ||||
| 	} | ||||
| 
 | ||||
| 	return fc.action | ||||
| } | ||||
| 
 | ||||
| func (fc *FileChange) getFileChangeResolvedAction() FileChangeResolvedAction { | ||||
| 	l := defaultLogger.With().Str("path", fc.Path).Logger() | ||||
| 
 | ||||
| 	// some actions are not needed to be checked
 | ||||
| 	switch fc.ExpectedState { | ||||
| 	case FileStateFileWrite: | ||||
| 		return FileChangeResolvedActionWriteFile | ||||
| 	case FileStateTouch: | ||||
| 		return FileChangeResolvedActionTouch | ||||
| 	} | ||||
| 
 | ||||
| 	// get the actual state of the file
 | ||||
| 	err := fc.getActualState() | ||||
| 	if err != nil { | ||||
| 		return FileChangeResolvedActionDoNothing | ||||
| 	} | ||||
| 
 | ||||
| 	baseName := filepath.Base(fc.Path) | ||||
| 
 | ||||
| 	switch fc.ExpectedState { | ||||
| 	case FileStateDirectory: | ||||
| 		// if the file is already a directory, do nothing
 | ||||
| 		if fc.ActualState == FileStateDirectory { | ||||
| 			return FileChangeResolvedActionDoNothing | ||||
| 		} | ||||
| 		return FileChangeResolvedActionCreateDirectory | ||||
| 	case FileStateFile: | ||||
| 		// if the file is already a file, do nothing
 | ||||
| 		if fc.ActualState == FileStateFile { | ||||
| 			return FileChangeResolvedActionDoNothing | ||||
| 		} | ||||
| 		return FileChangeResolvedActionCreateFile | ||||
| 	case FileStateFileContentMatch: | ||||
| 		// if the file is already a file with the expected content, do nothing
 | ||||
| 		if fc.ActualState == FileStateFile { | ||||
| 			looserMatch := baseName == "inquiry_string" | ||||
| 			if compareFileContent(fc.ActualContent, fc.ExpectedContent, looserMatch) { | ||||
| 				return FileChangeResolvedActionDoNothing | ||||
| 			} | ||||
| 			// TODO: move this to somewhere else
 | ||||
| 			// this is a workaround for the fact that the file is not updated if it has no content
 | ||||
| 			if baseName == "file" && | ||||
| 				bytes.Equal(fc.ActualContent, []byte{}) && | ||||
| 				bytes.Equal(fc.ExpectedContent, []byte{0x0a}) { | ||||
| 				return FileChangeResolvedActionDoNothing | ||||
| 			} | ||||
| 			return FileChangeResolvedActionUpdateFile | ||||
| 		} | ||||
| 		return FileChangeResolvedActionCreateFile | ||||
| 	case FileStateSymlink: | ||||
| 		// if the file is already a symlink, check if the target is the same
 | ||||
| 		if fc.ActualState == FileStateSymlink { | ||||
| 			if reflect.DeepEqual(fc.ActualContent, fc.ExpectedContent) { | ||||
| 				return FileChangeResolvedActionDoNothing | ||||
| 			} | ||||
| 			return FileChangeResolvedActionRecreateSymlink | ||||
| 		} | ||||
| 		return FileChangeResolvedActionCreateSymlink | ||||
| 	case FileStateSymlinkInOrderConfigFS: | ||||
| 		// if the file is already a symlink, check if the target is the same
 | ||||
| 		if fc.ActualState == FileStateSymlinkInOrderConfigFS { | ||||
| 			return FileChangeResolvedActionDoNothing | ||||
| 		} | ||||
| 		return FileChangeResolvedActionReorderSymlinks | ||||
| 	case FileStateAbsent: | ||||
| 		if fc.ActualState == FileStateAbsent { | ||||
| 			return FileChangeResolvedActionDoNothing | ||||
| 		} | ||||
| 		return FileChangeResolvedActionRemove | ||||
| 	case FileStateMountedConfigFS: | ||||
| 		if fc.ActualState == FileStateMountedConfigFS { | ||||
| 			return FileChangeResolvedActionDoNothing | ||||
| 		} | ||||
| 		return FileChangeResolvedActionMountConfigFS | ||||
| 	default: | ||||
| 		l.Warn().Interface("file_change", FileStateString[fc.ExpectedState]).Msg("unknown expected state") | ||||
| 		return FileChangeResolvedActionDoNothing | ||||
| 	} | ||||
| } | ||||
| 
 | ||||
| func (c *ChangeSet) AddFileChangeStruct(r RequestedFileChange) { | ||||
| 	fc := FileChange{ | ||||
| 		RequestedFileChange: r, | ||||
| 	} | ||||
| 	c.Changes = append(c.Changes, fc) | ||||
| } | ||||
| 
 | ||||
| func (c *ChangeSet) AddFileChange(component string, path string, expectedState FileState, expectedContent []byte, dependsOn []string, description string) { | ||||
| 	c.AddFileChangeStruct(RequestedFileChange{ | ||||
| 		Component:       component, | ||||
| 		Path:            path, | ||||
| 		ExpectedState:   expectedState, | ||||
| 		ExpectedContent: expectedContent, | ||||
| 		DependsOn:       dependsOn, | ||||
| 		Description:     description, | ||||
| 	}) | ||||
| } | ||||
| 
 | ||||
| func (c *ChangeSet) ApplyChanges() error { | ||||
| 	r := ChangeSetResolver{ | ||||
| 		changeset: c, | ||||
| 		g:         &dag.AcyclicGraph{}, | ||||
| 		l:         defaultLogger, | ||||
| 	} | ||||
| 
 | ||||
| 	return r.Apply() | ||||
| } | ||||
| 
 | ||||
| func (c *ChangeSet) applyChange(change *FileChange) error { | ||||
| 	switch change.Action() { | ||||
| 	case FileChangeResolvedActionWriteFile: | ||||
| 		return os.WriteFile(change.Path, change.ExpectedContent, 0644) | ||||
| 	case FileChangeResolvedActionUpdateFile: | ||||
| 		return os.WriteFile(change.Path, change.ExpectedContent, 0644) | ||||
| 	case FileChangeResolvedActionCreateFile: | ||||
| 		return os.WriteFile(change.Path, change.ExpectedContent, 0644) | ||||
| 	case FileChangeResolvedActionCreateSymlink: | ||||
| 		return os.Symlink(string(change.ExpectedContent), change.Path) | ||||
| 	case FileChangeResolvedActionRecreateSymlink: | ||||
| 		if err := os.Remove(change.Path); err != nil { | ||||
| 			return fmt.Errorf("failed to remove symlink: %w", err) | ||||
| 		} | ||||
| 		return os.Symlink(string(change.ExpectedContent), change.Path) | ||||
| 	case FileChangeResolvedActionReorderSymlinks: | ||||
| 		return recreateSymlinks(change, nil) | ||||
| 	case FileChangeResolvedActionCreateDirectory: | ||||
| 		return os.MkdirAll(change.Path, 0755) | ||||
| 	case FileChangeResolvedActionRemove: | ||||
| 		return os.Remove(change.Path) | ||||
| 	case FileChangeResolvedActionRemoveDirectory: | ||||
| 		return os.RemoveAll(change.Path) | ||||
| 	case FileChangeResolvedActionTouch: | ||||
| 		return os.Chtimes(change.Path, time.Now(), time.Now()) | ||||
| 	case FileChangeResolvedActionMountConfigFS: | ||||
| 		return mountConfigFS(change.Path) | ||||
| 	case FileChangeResolvedActionDoNothing: | ||||
| 		return nil | ||||
| 	default: | ||||
| 		return fmt.Errorf("unknown action: %d", change.Action()) | ||||
| 	} | ||||
| } | ||||
| 
 | ||||
| func (c *ChangeSet) Apply() error { | ||||
| 	return c.ApplyChanges() | ||||
| } | ||||
|  | @ -0,0 +1,115 @@ | |||
| //go:build arm && linux
 | ||||
| 
 | ||||
| package usbgadget | ||||
| 
 | ||||
| import ( | ||||
| 	"os" | ||||
| 	"strings" | ||||
| 	"testing" | ||||
| 
 | ||||
| 	"github.com/stretchr/testify/assert" | ||||
| ) | ||||
| 
 | ||||
| var ( | ||||
| 	usbConfig = &Config{ | ||||
| 		VendorId:     "0x1d6b", //The Linux Foundation
 | ||||
| 		ProductId:    "0x0104", //Multifunction Composite Gadget
 | ||||
| 		SerialNumber: "", | ||||
| 		Manufacturer: "JetKVM", | ||||
| 		Product:      "USB Emulation Device", | ||||
| 		strictMode:   true, | ||||
| 	} | ||||
| 	usbDevices = &Devices{ | ||||
| 		AbsoluteMouse: true, | ||||
| 		RelativeMouse: true, | ||||
| 		Keyboard:      true, | ||||
| 		MassStorage:   true, | ||||
| 	} | ||||
| 	usbGadgetName = "jetkvm" | ||||
| 	usbGadget     *UsbGadget | ||||
| ) | ||||
| 
 | ||||
| var oldAbsoluteMouseCombinedReportDesc = []byte{ | ||||
| 	0x05, 0x01, // Usage Page (Generic Desktop Ctrls)
 | ||||
| 	0x09, 0x02, // Usage (Mouse)
 | ||||
| 	0xA1, 0x01, // Collection (Application)
 | ||||
| 
 | ||||
| 	// Report ID 1: Absolute Mouse Movement
 | ||||
| 	0x85, 0x01, //     Report ID (1)
 | ||||
| 	0x09, 0x01, //     Usage (Pointer)
 | ||||
| 	0xA1, 0x00, //     Collection (Physical)
 | ||||
| 	0x05, 0x09, //         Usage Page (Button)
 | ||||
| 	0x19, 0x01, //         Usage Minimum (0x01)
 | ||||
| 	0x29, 0x03, //         Usage Maximum (0x03)
 | ||||
| 	0x15, 0x00, //         Logical Minimum (0)
 | ||||
| 	0x25, 0x01, //         Logical Maximum (1)
 | ||||
| 	0x75, 0x01, //         Report Size (1)
 | ||||
| 	0x95, 0x03, //         Report Count (3)
 | ||||
| 	0x81, 0x02, //         Input (Data, Var, Abs)
 | ||||
| 	0x95, 0x01, //         Report Count (1)
 | ||||
| 	0x75, 0x05, //         Report Size (5)
 | ||||
| 	0x81, 0x03, //         Input (Cnst, Var, Abs)
 | ||||
| 	0x05, 0x01, //         Usage Page (Generic Desktop Ctrls)
 | ||||
| 	0x09, 0x30, //         Usage (X)
 | ||||
| 	0x09, 0x31, //         Usage (Y)
 | ||||
| 	0x16, 0x00, 0x00, //         Logical Minimum (0)
 | ||||
| 	0x26, 0xFF, 0x7F, //         Logical Maximum (32767)
 | ||||
| 	0x36, 0x00, 0x00, //         Physical Minimum (0)
 | ||||
| 	0x46, 0xFF, 0x7F, //         Physical Maximum (32767)
 | ||||
| 	0x75, 0x10, //         Report Size (16)
 | ||||
| 	0x95, 0x02, //         Report Count (2)
 | ||||
| 	0x81, 0x02, //         Input (Data, Var, Abs)
 | ||||
| 	0xC0, //     End Collection
 | ||||
| 
 | ||||
| 	// Report ID 2: Relative Wheel Movement
 | ||||
| 	0x85, 0x02, //     Report ID (2)
 | ||||
| 	0x09, 0x38, //     Usage (Wheel)
 | ||||
| 	0x15, 0x81, //     Logical Minimum (-127)
 | ||||
| 	0x25, 0x7F, //     Logical Maximum (127)
 | ||||
| 	0x75, 0x08, //     Report Size (8)
 | ||||
| 	0x95, 0x01, //     Report Count (1)
 | ||||
| 	0x81, 0x06, //     Input (Data, Var, Rel)
 | ||||
| 
 | ||||
| 	0xC0, // End Collection
 | ||||
| } | ||||
| 
 | ||||
| func TestUsbGadgetInit(t *testing.T) { | ||||
| 	assert := assert.New(t) | ||||
| 	usbGadget = NewUsbGadget(usbGadgetName, usbDevices, usbConfig, nil) | ||||
| 
 | ||||
| 	assert.NotNil(usbGadget) | ||||
| } | ||||
| 
 | ||||
| func TestUsbGadgetStrictModeInitFail(t *testing.T) { | ||||
| 	usbConfig.strictMode = true | ||||
| 	u := NewUsbGadget("test", usbDevices, usbConfig, nil) | ||||
| 	assert.Nil(t, u, "should be nil") | ||||
| } | ||||
| 
 | ||||
| func TestUsbGadgetUDCNotBoundAfterReportDescrChanged(t *testing.T) { | ||||
| 	assert := assert.New(t) | ||||
| 	usbGadget = NewUsbGadget(usbGadgetName, usbDevices, usbConfig, nil) | ||||
| 	assert.NotNil(usbGadget) | ||||
| 
 | ||||
| 	// release the usb gadget and create a new one
 | ||||
| 	usbGadget = nil | ||||
| 
 | ||||
| 	altGadgetConfig := defaultGadgetConfig | ||||
| 
 | ||||
| 	oldAbsoluteMouseConfig := altGadgetConfig["absolute_mouse"] | ||||
| 	oldAbsoluteMouseConfig.reportDesc = oldAbsoluteMouseCombinedReportDesc | ||||
| 	altGadgetConfig["absolute_mouse"] = oldAbsoluteMouseConfig | ||||
| 
 | ||||
| 	usbGadget = newUsbGadget(usbGadgetName, altGadgetConfig, usbDevices, usbConfig, nil) | ||||
| 	assert.NotNil(usbGadget) | ||||
| 
 | ||||
| 	udcs := getUdcs() | ||||
| 	assert.Equal(1, len(udcs), "should be only one UDC") | ||||
| 	// check if the UDC is bound
 | ||||
| 	udc := udcs[0] | ||||
| 	assert.NotNil(udc, "UDC should exist") | ||||
| 
 | ||||
| 	udcStr, err := os.ReadFile("/sys/kernel/config/usb_gadget/jetkvm/UDC") | ||||
| 	assert.Nil(err, "usb_gadget/UDC should exist") | ||||
| 	assert.Equal(strings.TrimSpace(udc), strings.TrimSpace(string(udcStr)), "UDC should be the same") | ||||
| } | ||||
|  | @ -0,0 +1,192 @@ | |||
| package usbgadget | ||||
| 
 | ||||
| import ( | ||||
| 	"fmt" | ||||
| 
 | ||||
| 	"github.com/rs/zerolog" | ||||
| 	"github.com/sourcegraph/tf-dag/dag" | ||||
| ) | ||||
| 
 | ||||
| type ChangeSetResolver struct { | ||||
| 	changeset *ChangeSet | ||||
| 
 | ||||
| 	l *zerolog.Logger | ||||
| 	g *dag.AcyclicGraph | ||||
| 
 | ||||
| 	changesMap            map[string]*FileChange | ||||
| 	conditionalChangesMap map[string]*FileChange | ||||
| 
 | ||||
| 	orderedChanges            []dag.Vertex | ||||
| 	resolvedChanges           []*FileChange | ||||
| 	additionalResolveRequired bool | ||||
| } | ||||
| 
 | ||||
| func (c *ChangeSetResolver) toOrderedChanges() error { | ||||
| 	for key, change := range c.changesMap { | ||||
| 		v := c.g.Add(key) | ||||
| 
 | ||||
| 		for _, dependsOn := range change.DependsOn { | ||||
| 			c.g.Connect(dag.BasicEdge(dependsOn, v)) | ||||
| 		} | ||||
| 		for _, dependsOn := range change.resolvedDeps { | ||||
| 			c.g.Connect(dag.BasicEdge(dependsOn, v)) | ||||
| 		} | ||||
| 	} | ||||
| 
 | ||||
| 	cycles := c.g.Cycles() | ||||
| 	if len(cycles) > 0 { | ||||
| 		return fmt.Errorf("cycles detected: %v", cycles) | ||||
| 	} | ||||
| 
 | ||||
| 	orderedChanges := c.g.TopologicalOrder() | ||||
| 	c.orderedChanges = orderedChanges | ||||
| 	return nil | ||||
| } | ||||
| 
 | ||||
| func (c *ChangeSetResolver) doResolveChanges(initial bool) error { | ||||
| 	resolvedChanges := make([]*FileChange, 0) | ||||
| 
 | ||||
| 	for _, key := range c.orderedChanges { | ||||
| 		change := c.changesMap[key.(string)] | ||||
| 		if change == nil { | ||||
| 			c.l.Error().Str("key", key.(string)).Msg("fileChange not found") | ||||
| 			continue | ||||
| 		} | ||||
| 
 | ||||
| 		if !initial { | ||||
| 			change.ResetActionResolution() | ||||
| 		} | ||||
| 
 | ||||
| 		resolvedAction := change.Action() | ||||
| 
 | ||||
| 		resolvedChanges = append(resolvedChanges, change) | ||||
| 		// no need to check the triggers if there's no change
 | ||||
| 		if resolvedAction == FileChangeResolvedActionDoNothing { | ||||
| 			continue | ||||
| 		} | ||||
| 
 | ||||
| 		if !initial { | ||||
| 			continue | ||||
| 		} | ||||
| 
 | ||||
| 		if change.BeforeChange != nil { | ||||
| 			change.resolvedDeps = append(change.resolvedDeps, change.BeforeChange...) | ||||
| 			c.additionalResolveRequired = true | ||||
| 
 | ||||
| 			// add the dependencies to the changes map
 | ||||
| 			for _, dep := range change.BeforeChange { | ||||
| 				depChange, ok := c.conditionalChangesMap[dep] | ||||
| 				if !ok { | ||||
| 					return fmt.Errorf("dependency %s not found", dep) | ||||
| 				} | ||||
| 
 | ||||
| 				c.changesMap[dep] = depChange | ||||
| 			} | ||||
| 		} | ||||
| 	} | ||||
| 
 | ||||
| 	c.resolvedChanges = resolvedChanges | ||||
| 	return nil | ||||
| } | ||||
| 
 | ||||
| func (c *ChangeSetResolver) resolveChanges(initial bool) error { | ||||
| 	// get the ordered changes
 | ||||
| 	err := c.toOrderedChanges() | ||||
| 	if err != nil { | ||||
| 		return err | ||||
| 	} | ||||
| 
 | ||||
| 	// resolve the changes
 | ||||
| 	err = c.doResolveChanges(initial) | ||||
| 	if err != nil { | ||||
| 		return err | ||||
| 	} | ||||
| 
 | ||||
| 	for _, change := range c.resolvedChanges { | ||||
| 		c.l.Trace().Str("change", change.String()).Msg("resolved change") | ||||
| 	} | ||||
| 
 | ||||
| 	if !c.additionalResolveRequired || !initial { | ||||
| 		return nil | ||||
| 	} | ||||
| 
 | ||||
| 	return c.resolveChanges(false) | ||||
| } | ||||
| 
 | ||||
| func (c *ChangeSetResolver) applyChanges() error { | ||||
| 	for _, change := range c.resolvedChanges { | ||||
| 		change.ResetActionResolution() | ||||
| 		action := change.Action() | ||||
| 		actionStr := FileChangeResolvedActionString[action] | ||||
| 
 | ||||
| 		l := c.l.Info() | ||||
| 		if action == FileChangeResolvedActionDoNothing { | ||||
| 			l = c.l.Trace() | ||||
| 		} | ||||
| 
 | ||||
| 		l.Str("action", actionStr).Str("change", change.String()).Msg("applying change") | ||||
| 
 | ||||
| 		err := c.changeset.applyChange(change) | ||||
| 		if err != nil { | ||||
| 			if change.IgnoreErrors { | ||||
| 				c.l.Warn().Str("change", change.String()).Err(err).Msg("ignoring error") | ||||
| 			} else { | ||||
| 				return err | ||||
| 			} | ||||
| 		} | ||||
| 	} | ||||
| 
 | ||||
| 	return nil | ||||
| } | ||||
| 
 | ||||
| func (c *ChangeSetResolver) GetChanges() ([]*FileChange, error) { | ||||
| 	localChanges := c.changeset.Changes | ||||
| 	changesMap := make(map[string]*FileChange) | ||||
| 	conditionalChangesMap := make(map[string]*FileChange) | ||||
| 
 | ||||
| 	// build the map of the changes
 | ||||
| 	for _, change := range localChanges { | ||||
| 		key := change.Key | ||||
| 		if key == "" { | ||||
| 			key = change.Path | ||||
| 		} | ||||
| 
 | ||||
| 		// remove it from the map first
 | ||||
| 		if change.When != "" { | ||||
| 			conditionalChangesMap[key] = &change | ||||
| 			continue | ||||
| 		} | ||||
| 
 | ||||
| 		if _, ok := changesMap[key]; ok { | ||||
| 			if changesMap[key].IsSame(&change.RequestedFileChange) { | ||||
| 				continue | ||||
| 			} | ||||
| 			return nil, fmt.Errorf( | ||||
| 				"duplicate change: %s, current: %s, requested: %s", | ||||
| 				key, | ||||
| 				changesMap[key].String(), | ||||
| 				change.String(), | ||||
| 			) | ||||
| 		} | ||||
| 
 | ||||
| 		changesMap[key] = &change | ||||
| 	} | ||||
| 
 | ||||
| 	c.changesMap = changesMap | ||||
| 	c.conditionalChangesMap = conditionalChangesMap | ||||
| 
 | ||||
| 	err := c.resolveChanges(true) | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
| 
 | ||||
| 	return c.resolvedChanges, nil | ||||
| } | ||||
| 
 | ||||
| func (c *ChangeSetResolver) Apply() error { | ||||
| 	if _, err := c.GetChanges(); err != nil { | ||||
| 		return err | ||||
| 	} | ||||
| 
 | ||||
| 	return c.applyChanges() | ||||
| } | ||||
|  | @ -0,0 +1,136 @@ | |||
| package usbgadget | ||||
| 
 | ||||
| import ( | ||||
| 	"fmt" | ||||
| 	"os" | ||||
| 	"path" | ||||
| 	"path/filepath" | ||||
| 	"reflect" | ||||
| 
 | ||||
| 	"github.com/rs/zerolog" | ||||
| ) | ||||
| 
 | ||||
| type symlink struct { | ||||
| 	Path   string | ||||
| 	Target string | ||||
| } | ||||
| 
 | ||||
| func compareSymlinks(expected []symlink, actual []symlink) bool { | ||||
| 	if len(expected) != len(actual) { | ||||
| 		return false | ||||
| 	} | ||||
| 
 | ||||
| 	return reflect.DeepEqual(expected, actual) | ||||
| } | ||||
| 
 | ||||
| func checkIfSymlinksInOrder(fc *FileChange, logger *zerolog.Logger) (FileState, error) { | ||||
| 	if logger == nil { | ||||
| 		logger = defaultLogger | ||||
| 	} | ||||
| 	l := logger.With().Str("path", fc.Path).Logger() | ||||
| 
 | ||||
| 	if len(fc.ParamSymlinks) == 0 { | ||||
| 		return FileStateUnknown, fmt.Errorf("no symlinks to check") | ||||
| 	} | ||||
| 
 | ||||
| 	fi, err := os.Lstat(fc.Path) | ||||
| 
 | ||||
| 	if err != nil { | ||||
| 		if os.IsNotExist(err) { | ||||
| 			return FileStateAbsent, nil | ||||
| 		} else { | ||||
| 			l.Warn().Err(err).Msg("failed to stat file") | ||||
| 			return FileStateUnknown, fmt.Errorf("failed to stat file") | ||||
| 		} | ||||
| 	} | ||||
| 
 | ||||
| 	if !fi.IsDir() { | ||||
| 		return FileStateUnknown, fmt.Errorf("file is not a directory") | ||||
| 	} | ||||
| 
 | ||||
| 	files, err := os.ReadDir(fc.Path) | ||||
| 	symlinks := make([]symlink, 0) | ||||
| 	if err != nil { | ||||
| 		return FileStateUnknown, fmt.Errorf("failed to read directory") | ||||
| 	} | ||||
| 
 | ||||
| 	for _, file := range files { | ||||
| 		if file.Type()&os.ModeSymlink != os.ModeSymlink { | ||||
| 			continue | ||||
| 		} | ||||
| 
 | ||||
| 		path := filepath.Join(fc.Path, file.Name()) | ||||
| 		target, err := os.Readlink(path) | ||||
| 		if err != nil { | ||||
| 			return FileStateUnknown, fmt.Errorf("failed to read symlink") | ||||
| 		} | ||||
| 
 | ||||
| 		if !filepath.IsAbs(target) { | ||||
| 			target = filepath.Join(fc.Path, target) | ||||
| 			newTarget, err := filepath.Abs(target) | ||||
| 			if err != nil { | ||||
| 				return FileStateUnknown, fmt.Errorf("failed to get absolute path") | ||||
| 			} | ||||
| 			target = newTarget | ||||
| 		} | ||||
| 
 | ||||
| 		symlinks = append(symlinks, symlink{ | ||||
| 			Path:   path, | ||||
| 			Target: target, | ||||
| 		}) | ||||
| 	} | ||||
| 
 | ||||
| 	// compare the symlinks with the expected symlinks
 | ||||
| 	if compareSymlinks(fc.ParamSymlinks, symlinks) { | ||||
| 		return FileStateSymlinkInOrderConfigFS, nil | ||||
| 	} | ||||
| 
 | ||||
| 	l.Trace().Interface("expected", fc.ParamSymlinks).Interface("actual", symlinks).Msg("symlinks are not in order") | ||||
| 
 | ||||
| 	return FileStateSymlinkNotInOrderConfigFS, nil | ||||
| } | ||||
| 
 | ||||
| func recreateSymlinks(fc *FileChange, logger *zerolog.Logger) error { | ||||
| 	if logger == nil { | ||||
| 		logger = defaultLogger | ||||
| 	} | ||||
| 	// remove all symlinks
 | ||||
| 	files, err := os.ReadDir(fc.Path) | ||||
| 	if err != nil { | ||||
| 		return fmt.Errorf("failed to read directory") | ||||
| 	} | ||||
| 
 | ||||
| 	l := logger.With().Str("path", fc.Path).Logger() | ||||
| 	l.Info().Msg("recreate symlinks") | ||||
| 
 | ||||
| 	for _, file := range files { | ||||
| 		if file.Type()&os.ModeSymlink != os.ModeSymlink { | ||||
| 			continue | ||||
| 		} | ||||
| 		l.Info().Str("name", file.Name()).Msg("remove symlink") | ||||
| 		err := os.Remove(path.Join(fc.Path, file.Name())) | ||||
| 		if err != nil { | ||||
| 			return fmt.Errorf("failed to remove symlink") | ||||
| 		} | ||||
| 	} | ||||
| 
 | ||||
| 	l.Info().Interface("param-symlinks", fc.ParamSymlinks).Msg("create symlinks") | ||||
| 
 | ||||
| 	// create the symlinks
 | ||||
| 	for _, symlink := range fc.ParamSymlinks { | ||||
| 		l.Info().Str("name", symlink.Path).Str("target", symlink.Target).Msg("create symlink") | ||||
| 
 | ||||
| 		path := symlink.Path | ||||
| 		if !filepath.IsAbs(path) { | ||||
| 			path = filepath.Join(fc.Path, path) | ||||
| 		} | ||||
| 
 | ||||
| 		err := os.Symlink(symlink.Target, path) | ||||
| 		if err != nil { | ||||
| 			l.Warn().Err(err).Msg("failed to create symlink") | ||||
| 			return fmt.Errorf("failed to create symlink") | ||||
| 		} | ||||
| 	} | ||||
| 
 | ||||
| 	return nil | ||||
| } | ||||
|  | @ -2,11 +2,7 @@ package usbgadget | |||
| 
 | ||||
| import ( | ||||
| 	"fmt" | ||||
| 	"os" | ||||
| 	"os/exec" | ||||
| 	"path" | ||||
| 	"path/filepath" | ||||
| 	"sort" | ||||
| ) | ||||
| 
 | ||||
| type gadgetConfigItem struct { | ||||
|  | @ -34,8 +30,8 @@ var defaultGadgetConfig = map[string]gadgetConfigItem{ | |||
| 		attrs: gadgetAttributes{ | ||||
| 			"bcdUSB":    "0x0200", // USB 2.0
 | ||||
| 			"idVendor":  "0x1d6b", // The Linux Foundation
 | ||||
| 			"idProduct": "0104",   // Multifunction Composite Gadget
 | ||||
| 			"bcdDevice": "0100", | ||||
| 			"idProduct": "0x0104", // Multifunction Composite Gadget
 | ||||
| 			"bcdDevice": "0x0100", // USB2
 | ||||
| 		}, | ||||
| 		configAttrs: gadgetAttributes{ | ||||
| 			"MaxPower": "250", // in unit of 2mA
 | ||||
|  | @ -84,7 +80,7 @@ func (u *UsbGadget) isGadgetConfigItemEnabled(itemKey string) bool { | |||
| 
 | ||||
| func (u *UsbGadget) loadGadgetConfig() { | ||||
| 	if u.customConfig.isEmpty { | ||||
| 		u.log.Trace("using default gadget config") | ||||
| 		u.log.Trace().Msg("using default gadget config") | ||||
| 		return | ||||
| 	} | ||||
| 
 | ||||
|  | @ -137,20 +133,33 @@ func (u *UsbGadget) GetPath(itemKey string) (string, error) { | |||
| 	return joinPath(u.kvmGadgetPath, item.path), nil | ||||
| } | ||||
| 
 | ||||
| func mountConfigFS() error { | ||||
| 	_, err := os.Stat(gadgetPath) | ||||
| 	// TODO: check if it's mounted properly
 | ||||
| 	if err == nil { | ||||
| 		return nil | ||||
| // OverrideGadgetConfig overrides the gadget config for the given item and attribute.
 | ||||
| // It returns an error if the item is not found or the attribute is not found.
 | ||||
| // It returns true if the attribute is overridden, false otherwise.
 | ||||
| func (u *UsbGadget) OverrideGadgetConfig(itemKey string, itemAttr string, value string) (error, bool) { | ||||
| 	u.configLock.Lock() | ||||
| 	defer u.configLock.Unlock() | ||||
| 
 | ||||
| 	// get it as a pointer
 | ||||
| 	_, ok := u.configMap[itemKey] | ||||
| 	if !ok { | ||||
| 		return fmt.Errorf("config item %s not found", itemKey), false | ||||
| 	} | ||||
| 
 | ||||
| 	if os.IsNotExist(err) { | ||||
| 		err = exec.Command("mount", "-t", "configfs", "none", configFSPath).Run() | ||||
| 		if err != nil { | ||||
| 			return fmt.Errorf("failed to mount configfs: %w", err) | ||||
| 		} | ||||
| 	} else { | ||||
| 		return fmt.Errorf("unable to access usb gadget path: %w", err) | ||||
| 	if u.configMap[itemKey].attrs[itemAttr] == value { | ||||
| 		return nil, false | ||||
| 	} | ||||
| 
 | ||||
| 	u.configMap[itemKey].attrs[itemAttr] = value | ||||
| 	u.log.Info().Str("itemKey", itemKey).Str("itemAttr", itemAttr).Str("value", value).Msg("overriding gadget config") | ||||
| 
 | ||||
| 	return nil, true | ||||
| } | ||||
| 
 | ||||
| func mountConfigFS(path string) error { | ||||
| 	err := exec.Command("mount", "-t", "configfs", "none", path).Run() | ||||
| 	if err != nil { | ||||
| 		return fmt.Errorf("failed to mount configfs: %w", err) | ||||
| 	} | ||||
| 	return nil | ||||
| } | ||||
|  | @ -163,26 +172,14 @@ func (u *UsbGadget) Init() error { | |||
| 
 | ||||
| 	udcs := getUdcs() | ||||
| 	if len(udcs) < 1 { | ||||
| 		u.log.Error("no udc found, skipping USB stack init") | ||||
| 		return nil | ||||
| 		return u.logWarn("no udc found, skipping USB stack init", nil) | ||||
| 	} | ||||
| 
 | ||||
| 	u.udc = udcs[0] | ||||
| 	_, err := os.Stat(u.kvmGadgetPath) | ||||
| 	if err == nil { | ||||
| 		u.log.Info("usb gadget already exists") | ||||
| 	} | ||||
| 
 | ||||
| 	if err := mountConfigFS(); err != nil { | ||||
| 		u.log.Errorf("failed to mount configfs: %v, usb stack might not function properly", err) | ||||
| 	} | ||||
| 
 | ||||
| 	if err := os.MkdirAll(u.configC1Path, 0755); err != nil { | ||||
| 		u.log.Errorf("failed to create config path: %v", err) | ||||
| 	} | ||||
| 
 | ||||
| 	if err := u.writeGadgetConfig(); err != nil { | ||||
| 		u.log.Errorf("failed to start gadget: %v", err) | ||||
| 	err := u.configureUsbGadget(false) | ||||
| 	if err != nil { | ||||
| 		return u.logError("unable to initialize USB stack", err) | ||||
| 	} | ||||
| 
 | ||||
| 	return nil | ||||
|  | @ -194,143 +191,22 @@ func (u *UsbGadget) UpdateGadgetConfig() error { | |||
| 
 | ||||
| 	u.loadGadgetConfig() | ||||
| 
 | ||||
| 	if err := u.writeGadgetConfig(); err != nil { | ||||
| 		u.log.Errorf("failed to update gadget: %v", err) | ||||
| 	err := u.configureUsbGadget(true) | ||||
| 	if err != nil { | ||||
| 		return u.logError("unable to update gadget config", err) | ||||
| 	} | ||||
| 
 | ||||
| 	return nil | ||||
| } | ||||
| 
 | ||||
| func (u *UsbGadget) getOrderedConfigItems() orderedGadgetConfigItems { | ||||
| 	items := make([]gadgetConfigItemWithKey, 0) | ||||
| 	for key, item := range u.configMap { | ||||
| 		items = append(items, gadgetConfigItemWithKey{key, item}) | ||||
| 	} | ||||
| 
 | ||||
| 	sort.Slice(items, func(i, j int) bool { | ||||
| 		return items[i].item.order < items[j].item.order | ||||
| func (u *UsbGadget) configureUsbGadget(resetUsb bool) error { | ||||
| 	return u.WithTransaction(func() error { | ||||
| 		u.tx.MountConfigFS() | ||||
| 		u.tx.CreateConfigPath() | ||||
| 		u.tx.WriteGadgetConfig() | ||||
| 		if resetUsb { | ||||
| 			u.tx.RebindUsb(true) | ||||
| 		} | ||||
| 		return nil | ||||
| 	}) | ||||
| 
 | ||||
| 	return items | ||||
| } | ||||
| 
 | ||||
| func (u *UsbGadget) writeGadgetConfig() error { | ||||
| 	// create kvm gadget path
 | ||||
| 	err := os.MkdirAll(u.kvmGadgetPath, 0755) | ||||
| 	if err != nil { | ||||
| 		return err | ||||
| 	} | ||||
| 
 | ||||
| 	u.log.Tracef("writing gadget config") | ||||
| 	for _, val := range u.getOrderedConfigItems() { | ||||
| 		key := val.key | ||||
| 		item := val.item | ||||
| 
 | ||||
| 		// check if the item is enabled in the config
 | ||||
| 		if !u.isGadgetConfigItemEnabled(key) { | ||||
| 			u.log.Tracef("disabling gadget config: %s", key) | ||||
| 			err = u.disableGadgetItemConfig(item) | ||||
| 			if err != nil { | ||||
| 				return err | ||||
| 			} | ||||
| 			continue | ||||
| 		} | ||||
| 		u.log.Tracef("writing gadget config: %s", key) | ||||
| 		err = u.writeGadgetItemConfig(item) | ||||
| 		if err != nil { | ||||
| 			return err | ||||
| 		} | ||||
| 	} | ||||
| 
 | ||||
| 	if err = u.writeUDC(); err != nil { | ||||
| 		u.log.Errorf("failed to write UDC: %v", err) | ||||
| 		return err | ||||
| 	} | ||||
| 
 | ||||
| 	if err = u.rebindUsb(true); err != nil { | ||||
| 		u.log.Infof("failed to rebind usb: %v", err) | ||||
| 	} | ||||
| 
 | ||||
| 	return nil | ||||
| } | ||||
| 
 | ||||
| func (u *UsbGadget) disableGadgetItemConfig(item gadgetConfigItem) error { | ||||
| 	// remove symlink if exists
 | ||||
| 	if item.configPath == nil { | ||||
| 		return nil | ||||
| 	} | ||||
| 
 | ||||
| 	configPath := joinPath(u.configC1Path, item.configPath) | ||||
| 
 | ||||
| 	if _, err := os.Lstat(configPath); os.IsNotExist(err) { | ||||
| 		u.log.Tracef("symlink %s does not exist", item.configPath) | ||||
| 		return nil | ||||
| 	} | ||||
| 
 | ||||
| 	if err := os.Remove(configPath); err != nil { | ||||
| 		return fmt.Errorf("failed to remove symlink %s: %w", item.configPath, err) | ||||
| 	} | ||||
| 
 | ||||
| 	return nil | ||||
| } | ||||
| 
 | ||||
| func (u *UsbGadget) writeGadgetItemConfig(item gadgetConfigItem) error { | ||||
| 	// create directory for the item
 | ||||
| 	gadgetItemPath := joinPath(u.kvmGadgetPath, item.path) | ||||
| 	err := os.MkdirAll(gadgetItemPath, 0755) | ||||
| 	if err != nil { | ||||
| 		return fmt.Errorf("failed to create path %s: %w", gadgetItemPath, err) | ||||
| 	} | ||||
| 
 | ||||
| 	if len(item.attrs) > 0 { | ||||
| 		// write attributes for the item
 | ||||
| 		err = u.writeGadgetAttrs(gadgetItemPath, item.attrs) | ||||
| 		if err != nil { | ||||
| 			return fmt.Errorf("failed to write attributes for %s: %w", gadgetItemPath, err) | ||||
| 		} | ||||
| 	} | ||||
| 
 | ||||
| 	// write report descriptor if available
 | ||||
| 	if item.reportDesc != nil { | ||||
| 		err = u.writeIfDifferent(path.Join(gadgetItemPath, "report_desc"), item.reportDesc, 0644) | ||||
| 		if err != nil { | ||||
| 			return err | ||||
| 		} | ||||
| 	} | ||||
| 
 | ||||
| 	// create config directory if configAttrs are set
 | ||||
| 	if len(item.configAttrs) > 0 { | ||||
| 		configItemPath := joinPath(u.configC1Path, item.configPath) | ||||
| 		err = os.MkdirAll(configItemPath, 0755) | ||||
| 		if err != nil { | ||||
| 			return fmt.Errorf("failed to create path %s: %w", configItemPath, err) | ||||
| 		} | ||||
| 
 | ||||
| 		err = u.writeGadgetAttrs(configItemPath, item.configAttrs) | ||||
| 		if err != nil { | ||||
| 			return fmt.Errorf("failed to write config attributes for %s: %w", configItemPath, err) | ||||
| 		} | ||||
| 	} | ||||
| 
 | ||||
| 	// create symlink if configPath is set
 | ||||
| 	if item.configPath != nil && item.configAttrs == nil { | ||||
| 		configPath := joinPath(u.configC1Path, item.configPath) | ||||
| 		u.log.Tracef("Creating symlink from %s to %s", configPath, gadgetItemPath) | ||||
| 		if err := ensureSymlink(configPath, gadgetItemPath); err != nil { | ||||
| 			return err | ||||
| 		} | ||||
| 	} | ||||
| 
 | ||||
| 	return nil | ||||
| } | ||||
| 
 | ||||
| func (u *UsbGadget) writeGadgetAttrs(basePath string, attrs gadgetAttributes) error { | ||||
| 	for key, val := range attrs { | ||||
| 		filePath := filepath.Join(basePath, key) | ||||
| 		err := u.writeIfDifferent(filePath, []byte(val), 0644) | ||||
| 		if err != nil { | ||||
| 			return fmt.Errorf("failed to write to %s: %w", filePath, err) | ||||
| 		} | ||||
| 	} | ||||
| 	return nil | ||||
| } | ||||
|  |  | |||
|  | @ -0,0 +1,349 @@ | |||
| package usbgadget | ||||
| 
 | ||||
| import ( | ||||
| 	"fmt" | ||||
| 	"path" | ||||
| 	"path/filepath" | ||||
| 	"sort" | ||||
| 
 | ||||
| 	"github.com/rs/zerolog" | ||||
| ) | ||||
| 
 | ||||
| // no os package should occur in this file
 | ||||
| 
 | ||||
| type UsbGadgetTransaction struct { | ||||
| 	c *ChangeSet | ||||
| 
 | ||||
| 	// below are the fields that are needed to be set by the caller
 | ||||
| 	log                       *zerolog.Logger | ||||
| 	udc                       string | ||||
| 	dwc3Path                  string | ||||
| 	kvmGadgetPath             string | ||||
| 	configC1Path              string | ||||
| 	orderedConfigItems        orderedGadgetConfigItems | ||||
| 	isGadgetConfigItemEnabled func(key string) bool | ||||
| 
 | ||||
| 	reorderSymlinkChanges *RequestedFileChange | ||||
| } | ||||
| 
 | ||||
| func (u *UsbGadget) newUsbGadgetTransaction(lock bool) error { | ||||
| 	if lock { | ||||
| 		u.txLock.Lock() | ||||
| 		defer u.txLock.Unlock() | ||||
| 	} | ||||
| 
 | ||||
| 	if u.tx != nil { | ||||
| 		return fmt.Errorf("transaction already exists") | ||||
| 	} | ||||
| 
 | ||||
| 	tx := &UsbGadgetTransaction{ | ||||
| 		c:                         &ChangeSet{}, | ||||
| 		log:                       u.log, | ||||
| 		udc:                       u.udc, | ||||
| 		dwc3Path:                  dwc3Path, | ||||
| 		kvmGadgetPath:             u.kvmGadgetPath, | ||||
| 		configC1Path:              u.configC1Path, | ||||
| 		orderedConfigItems:        u.getOrderedConfigItems(), | ||||
| 		isGadgetConfigItemEnabled: u.isGadgetConfigItemEnabled, | ||||
| 	} | ||||
| 	u.tx = tx | ||||
| 
 | ||||
| 	return nil | ||||
| } | ||||
| 
 | ||||
| func (u *UsbGadget) WithTransaction(fn func() error) error { | ||||
| 	u.txLock.Lock() | ||||
| 	defer u.txLock.Unlock() | ||||
| 
 | ||||
| 	err := u.newUsbGadgetTransaction(false) | ||||
| 	if err != nil { | ||||
| 		u.log.Error().Err(err).Msg("failed to create transaction") | ||||
| 		return err | ||||
| 	} | ||||
| 	if err := fn(); err != nil { | ||||
| 		u.log.Error().Err(err).Msg("transaction failed") | ||||
| 		return err | ||||
| 	} | ||||
| 	result := u.tx.Commit() | ||||
| 	u.tx = nil | ||||
| 
 | ||||
| 	return result | ||||
| } | ||||
| 
 | ||||
| func (tx *UsbGadgetTransaction) addFileChange(component string, change RequestedFileChange) string { | ||||
| 	change.Component = component | ||||
| 	tx.c.AddFileChangeStruct(change) | ||||
| 
 | ||||
| 	key := change.Key | ||||
| 	if key == "" { | ||||
| 		key = change.Path | ||||
| 	} | ||||
| 	return key | ||||
| } | ||||
| 
 | ||||
| func (tx *UsbGadgetTransaction) mkdirAll(component string, path string, description string, deps []string) string { | ||||
| 	return tx.addFileChange(component, RequestedFileChange{ | ||||
| 		Path:          path, | ||||
| 		ExpectedState: FileStateDirectory, | ||||
| 		Description:   description, | ||||
| 		DependsOn:     deps, | ||||
| 	}) | ||||
| } | ||||
| 
 | ||||
| func (tx *UsbGadgetTransaction) removeFile(component string, path string, description string) string { | ||||
| 	return tx.addFileChange(component, RequestedFileChange{ | ||||
| 		Path:          path, | ||||
| 		ExpectedState: FileStateAbsent, | ||||
| 		Description:   description, | ||||
| 	}) | ||||
| } | ||||
| 
 | ||||
| func (tx *UsbGadgetTransaction) Commit() error { | ||||
| 	tx.addFileChange("gadget-finalize", *tx.reorderSymlinkChanges) | ||||
| 
 | ||||
| 	err := tx.c.Apply() | ||||
| 	if err != nil { | ||||
| 		tx.log.Error().Err(err).Msg("failed to update usbgadget configuration") | ||||
| 		return err | ||||
| 	} | ||||
| 	tx.log.Info().Msg("usbgadget configuration updated") | ||||
| 	return nil | ||||
| } | ||||
| 
 | ||||
| func (u *UsbGadget) getOrderedConfigItems() orderedGadgetConfigItems { | ||||
| 	items := make([]gadgetConfigItemWithKey, 0) | ||||
| 	for key, item := range u.configMap { | ||||
| 		items = append(items, gadgetConfigItemWithKey{key, item}) | ||||
| 	} | ||||
| 
 | ||||
| 	sort.Slice(items, func(i, j int) bool { | ||||
| 		return items[i].item.order < items[j].item.order | ||||
| 	}) | ||||
| 
 | ||||
| 	return items | ||||
| } | ||||
| 
 | ||||
| func (tx *UsbGadgetTransaction) MountConfigFS() { | ||||
| 	tx.addFileChange("gadget", RequestedFileChange{ | ||||
| 		Path:          configFSPath, | ||||
| 		ExpectedState: FileStateMountedConfigFS, | ||||
| 		Description:   "mount configfs", | ||||
| 	}) | ||||
| } | ||||
| 
 | ||||
| func (tx *UsbGadgetTransaction) CreateConfigPath() { | ||||
| 	tx.mkdirAll( | ||||
| 		"gadget", | ||||
| 		tx.configC1Path, | ||||
| 		"create config path", | ||||
| 		[]string{configFSPath}, | ||||
| 	) | ||||
| } | ||||
| 
 | ||||
| func (tx *UsbGadgetTransaction) WriteGadgetConfig() { | ||||
| 	// create kvm gadget path
 | ||||
| 	tx.mkdirAll( | ||||
| 		"gadget", | ||||
| 		tx.kvmGadgetPath, | ||||
| 		"create kvm gadget path", | ||||
| 		[]string{tx.configC1Path}, | ||||
| 	) | ||||
| 
 | ||||
| 	deps := make([]string, 0) | ||||
| 	deps = append(deps, tx.kvmGadgetPath) | ||||
| 
 | ||||
| 	for _, val := range tx.orderedConfigItems { | ||||
| 		key := val.key | ||||
| 		item := val.item | ||||
| 
 | ||||
| 		// check if the item is enabled in the config
 | ||||
| 		if !tx.isGadgetConfigItemEnabled(key) { | ||||
| 			tx.DisableGadgetItemConfig(item) | ||||
| 			continue | ||||
| 		} | ||||
| 		deps = tx.writeGadgetItemConfig(item, deps) | ||||
| 	} | ||||
| 
 | ||||
| 	tx.WriteUDC() | ||||
| } | ||||
| 
 | ||||
| func (tx *UsbGadgetTransaction) getDisableKeys() []string { | ||||
| 	disableKeys := make([]string, 0) | ||||
| 	for _, item := range tx.orderedConfigItems { | ||||
| 		if !tx.isGadgetConfigItemEnabled(item.key) { | ||||
| 			continue | ||||
| 		} | ||||
| 		if item.item.configPath == nil || item.item.configAttrs != nil { | ||||
| 			continue | ||||
| 		} | ||||
| 
 | ||||
| 		disableKeys = append(disableKeys, fmt.Sprintf("disable-%s", item.item.device)) | ||||
| 	} | ||||
| 	return disableKeys | ||||
| } | ||||
| 
 | ||||
| func (tx *UsbGadgetTransaction) DisableGadgetItemConfig(item gadgetConfigItem) { | ||||
| 	// remove symlink if exists
 | ||||
| 	if item.configPath == nil { | ||||
| 		return | ||||
| 	} | ||||
| 
 | ||||
| 	configPath := joinPath(tx.configC1Path, item.configPath) | ||||
| 	_ = tx.removeFile("gadget", configPath, "remove symlink: disable gadget config") | ||||
| } | ||||
| 
 | ||||
| func (tx *UsbGadgetTransaction) writeGadgetItemConfig(item gadgetConfigItem, deps []string) []string { | ||||
| 	component := item.device | ||||
| 
 | ||||
| 	// create directory for the item
 | ||||
| 	files := make([]string, 0) | ||||
| 	files = append(files, deps...) | ||||
| 
 | ||||
| 	gadgetItemPath := joinPath(tx.kvmGadgetPath, item.path) | ||||
| 	if gadgetItemPath != tx.kvmGadgetPath { | ||||
| 		gadgetItemDir := tx.mkdirAll(component, gadgetItemPath, "create gadget item directory", files) | ||||
| 		files = append(files, gadgetItemDir) | ||||
| 	} | ||||
| 
 | ||||
| 	beforeChange := make([]string, 0) | ||||
| 	disableGadgetItemKey := fmt.Sprintf("disable-%s", item.device) | ||||
| 	if item.configPath != nil && item.configAttrs == nil { | ||||
| 		beforeChange = append(beforeChange, tx.getDisableKeys()...) | ||||
| 	} | ||||
| 
 | ||||
| 	if len(item.attrs) > 0 { | ||||
| 		// write attributes for the item
 | ||||
| 		files = append(files, tx.writeGadgetAttrs( | ||||
| 			gadgetItemPath, | ||||
| 			item.attrs, | ||||
| 			component, | ||||
| 			beforeChange, | ||||
| 		)...) | ||||
| 	} | ||||
| 
 | ||||
| 	// write report descriptor if available
 | ||||
| 	reportDescPath := path.Join(gadgetItemPath, "report_desc") | ||||
| 	if item.reportDesc != nil { | ||||
| 		tx.addFileChange(component, RequestedFileChange{ | ||||
| 			Path:            reportDescPath, | ||||
| 			ExpectedState:   FileStateFileContentMatch, | ||||
| 			ExpectedContent: item.reportDesc, | ||||
| 			Description:     "write report descriptor", | ||||
| 			BeforeChange:    beforeChange, | ||||
| 			DependsOn:       files, | ||||
| 		}) | ||||
| 	} else { | ||||
| 		tx.addFileChange(component, RequestedFileChange{ | ||||
| 			Path:          reportDescPath, | ||||
| 			ExpectedState: FileStateAbsent, | ||||
| 			Description:   "remove report descriptor", | ||||
| 			BeforeChange:  beforeChange, | ||||
| 			DependsOn:     files, | ||||
| 		}) | ||||
| 	} | ||||
| 	files = append(files, reportDescPath) | ||||
| 
 | ||||
| 	// create config directory if configAttrs are set
 | ||||
| 	if len(item.configAttrs) > 0 { | ||||
| 		configItemPath := joinPath(tx.configC1Path, item.configPath) | ||||
| 		if configItemPath != tx.configC1Path { | ||||
| 			configItemDir := tx.mkdirAll(component, configItemPath, "create config item directory", files) | ||||
| 			files = append(files, configItemDir) | ||||
| 		} | ||||
| 		files = append(files, tx.writeGadgetAttrs( | ||||
| 			configItemPath, | ||||
| 			item.configAttrs, | ||||
| 			component, | ||||
| 			beforeChange, | ||||
| 		)...) | ||||
| 	} | ||||
| 
 | ||||
| 	// create symlink if configPath is set
 | ||||
| 	if item.configPath != nil && item.configAttrs == nil { | ||||
| 		configPath := joinPath(tx.configC1Path, item.configPath) | ||||
| 
 | ||||
| 		// the change will be only applied by `beforeChange`
 | ||||
| 		tx.addFileChange(component, RequestedFileChange{ | ||||
| 			Key:           disableGadgetItemKey, | ||||
| 			Path:          configPath, | ||||
| 			ExpectedState: FileStateAbsent, | ||||
| 			When:          "beforeChange", // TODO: make it more flexible
 | ||||
| 			Description:   "remove symlink", | ||||
| 		}) | ||||
| 
 | ||||
| 		tx.addReorderSymlinkChange(configPath, gadgetItemPath, files) | ||||
| 	} | ||||
| 
 | ||||
| 	return files | ||||
| } | ||||
| 
 | ||||
| func (tx *UsbGadgetTransaction) writeGadgetAttrs(basePath string, attrs gadgetAttributes, component string, beforeChange []string) (files []string) { | ||||
| 	files = make([]string, 0) | ||||
| 	for key, val := range attrs { | ||||
| 		filePath := filepath.Join(basePath, key) | ||||
| 		tx.addFileChange(component, RequestedFileChange{ | ||||
| 			Path:            filePath, | ||||
| 			ExpectedState:   FileStateFileContentMatch, | ||||
| 			ExpectedContent: []byte(val), | ||||
| 			Description:     "write gadget attribute", | ||||
| 			DependsOn:       []string{basePath}, | ||||
| 			BeforeChange:    beforeChange, | ||||
| 		}) | ||||
| 		files = append(files, filePath) | ||||
| 	} | ||||
| 	return files | ||||
| } | ||||
| 
 | ||||
| func (tx *UsbGadgetTransaction) addReorderSymlinkChange(path string, target string, deps []string) { | ||||
| 	tx.log.Trace().Str("path", path).Str("target", target).Msg("add reorder symlink change") | ||||
| 
 | ||||
| 	if tx.reorderSymlinkChanges == nil { | ||||
| 		tx.reorderSymlinkChanges = &RequestedFileChange{ | ||||
| 			Component:     "gadget-finalize", | ||||
| 			Key:           "reorder-symlinks", | ||||
| 			Path:          tx.configC1Path, | ||||
| 			ExpectedState: FileStateSymlinkInOrderConfigFS, | ||||
| 			Description:   "order symlinks", | ||||
| 			ParamSymlinks: []symlink{}, | ||||
| 		} | ||||
| 	} | ||||
| 
 | ||||
| 	tx.reorderSymlinkChanges.DependsOn = append(tx.reorderSymlinkChanges.DependsOn, deps...) | ||||
| 	tx.reorderSymlinkChanges.ParamSymlinks = append(tx.reorderSymlinkChanges.ParamSymlinks, symlink{ | ||||
| 		Path:   path, | ||||
| 		Target: target, | ||||
| 	}) | ||||
| } | ||||
| 
 | ||||
| func (tx *UsbGadgetTransaction) WriteUDC() { | ||||
| 	// bound the gadget to a UDC (USB Device Controller)
 | ||||
| 	path := path.Join(tx.kvmGadgetPath, "UDC") | ||||
| 	tx.addFileChange("udc", RequestedFileChange{ | ||||
| 		Key:             "udc", | ||||
| 		Path:            path, | ||||
| 		ExpectedState:   FileStateFileContentMatch, | ||||
| 		ExpectedContent: []byte(tx.udc), | ||||
| 		DependsOn:       []string{"reorder-symlinks"}, | ||||
| 		Description:     "write UDC", | ||||
| 	}) | ||||
| } | ||||
| 
 | ||||
| func (tx *UsbGadgetTransaction) RebindUsb(ignoreUnbindError bool) { | ||||
| 	// remove the gadget from the UDC
 | ||||
| 	tx.addFileChange("udc", RequestedFileChange{ | ||||
| 		Path:            path.Join(tx.dwc3Path, "unbind"), | ||||
| 		ExpectedState:   FileStateFileWrite, | ||||
| 		ExpectedContent: []byte(tx.udc), | ||||
| 		Description:     "unbind UDC", | ||||
| 		DependsOn:       []string{"udc"}, | ||||
| 		IgnoreErrors:    ignoreUnbindError, | ||||
| 	}) | ||||
| 	// bind the gadget to the UDC
 | ||||
| 	tx.addFileChange("udc", RequestedFileChange{ | ||||
| 		Path:            path.Join(tx.dwc3Path, "bind"), | ||||
| 		ExpectedState:   FileStateFileWrite, | ||||
| 		ExpectedContent: []byte(tx.udc), | ||||
| 		Description:     "bind UDC", | ||||
| 		DependsOn:       []string{path.Join(tx.dwc3Path, "unbind")}, | ||||
| 	}) | ||||
| } | ||||
|  | @ -1,3 +1,7 @@ | |||
| package usbgadget | ||||
| 
 | ||||
| import "time" | ||||
| 
 | ||||
| const dwc3Path = "/sys/bus/platform/drivers/dwc3" | ||||
| 
 | ||||
| const hidWriteTimeout = 10 * time.Millisecond | ||||
|  |  | |||
|  | @ -1,8 +1,15 @@ | |||
| package usbgadget | ||||
| 
 | ||||
| import ( | ||||
| 	"bytes" | ||||
| 	"context" | ||||
| 	"fmt" | ||||
| 	"os" | ||||
| 	"sync" | ||||
| 	"time" | ||||
| 
 | ||||
| 	"github.com/rs/xid" | ||||
| 	"github.com/rs/zerolog" | ||||
| ) | ||||
| 
 | ||||
| var keyboardConfig = gadgetConfigItem{ | ||||
|  | @ -11,9 +18,10 @@ var keyboardConfig = gadgetConfigItem{ | |||
| 	path:       []string{"functions", "hid.usb0"}, | ||||
| 	configPath: []string{"hid.usb0"}, | ||||
| 	attrs: gadgetAttributes{ | ||||
| 		"protocol":      "1", | ||||
| 		"subclass":      "1", | ||||
| 		"report_length": "8", | ||||
| 		"protocol":        "1", | ||||
| 		"subclass":        "1", | ||||
| 		"report_length":   "8", | ||||
| 		"no_out_endpoint": "0", | ||||
| 	}, | ||||
| 	reportDesc: keyboardReportDesc, | ||||
| } | ||||
|  | @ -36,6 +44,7 @@ var keyboardReportDesc = []byte{ | |||
| 	0x81, 0x03, /*   INPUT (Cnst,Var,Abs)                 */ | ||||
| 	0x95, 0x05, /*   REPORT_COUNT (5)                     */ | ||||
| 	0x75, 0x01, /*   REPORT_SIZE (1)                      */ | ||||
| 
 | ||||
| 	0x05, 0x08, /*   USAGE_PAGE (LEDs)                    */ | ||||
| 	0x19, 0x01, /*   USAGE_MINIMUM (Num Lock)             */ | ||||
| 	0x29, 0x05, /*   USAGE_MAXIMUM (Kana)                 */ | ||||
|  | @ -54,42 +63,444 @@ var keyboardReportDesc = []byte{ | |||
| 	0xc0, /* END_COLLECTION                         */ | ||||
| } | ||||
| 
 | ||||
| func (u *UsbGadget) keyboardWriteHidFile(data []byte) error { | ||||
| 	if u.keyboardHidFile == nil { | ||||
| 		var err error | ||||
| 		u.keyboardHidFile, err = os.OpenFile("/dev/hidg0", os.O_RDWR, 0666) | ||||
| 		if err != nil { | ||||
| 			return fmt.Errorf("failed to open hidg0: %w", err) | ||||
| const ( | ||||
| 	hidReadBufferSize = 8 | ||||
| 	hidKeyBufferSize  = 6 | ||||
| 	hidErrorRollOver  = 0x01 | ||||
| 	// https://www.usb.org/sites/default/files/documents/hid1_11.pdf
 | ||||
| 	// https://www.usb.org/sites/default/files/hut1_2.pdf
 | ||||
| 	KeyboardLedMaskNumLock    = 1 << 0 | ||||
| 	KeyboardLedMaskCapsLock   = 1 << 1 | ||||
| 	KeyboardLedMaskScrollLock = 1 << 2 | ||||
| 	KeyboardLedMaskCompose    = 1 << 3 | ||||
| 	KeyboardLedMaskKana       = 1 << 4 | ||||
| 	// 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,
 | ||||
| // COMPOSE, and KANA events is maintained by the host and NOT the keyboard. If
 | ||||
| // using the keyboard descriptor in Appendix B, LED states are set by sending a
 | ||||
| // 5-bit absolute report to the keyboard via a Set_Report(Output) request.
 | ||||
| type KeyboardState struct { | ||||
| 	NumLock    bool `json:"num_lock"` | ||||
| 	CapsLock   bool `json:"caps_lock"` | ||||
| 	ScrollLock bool `json:"scroll_lock"` | ||||
| 	Compose    bool `json:"compose"` | ||||
| 	Kana       bool `json:"kana"` | ||||
| 	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 { | ||||
| 	// should we check if it's the correct usage page?
 | ||||
| 	return KeyboardState{ | ||||
| 		NumLock:    b&KeyboardLedMaskNumLock != 0, | ||||
| 		CapsLock:   b&KeyboardLedMaskCapsLock != 0, | ||||
| 		ScrollLock: b&KeyboardLedMaskScrollLock != 0, | ||||
| 		Compose:    b&KeyboardLedMaskCompose != 0, | ||||
| 		Kana:       b&KeyboardLedMaskKana != 0, | ||||
| 		Shift:      b&KeyboardLedMaskShift != 0, | ||||
| 		raw:        b, | ||||
| 	} | ||||
| } | ||||
| 
 | ||||
| func (u *UsbGadget) updateKeyboardState(state byte) { | ||||
| 	u.keyboardStateLock.Lock() | ||||
| 	defer u.keyboardStateLock.Unlock() | ||||
| 
 | ||||
| 	if state&^ValidKeyboardLedMasks != 0 { | ||||
| 		u.log.Warn().Uint8("state", state).Msg("ignoring invalid bits") | ||||
| 		return | ||||
| 	} | ||||
| 
 | ||||
| 	if u.keyboardState == state { | ||||
| 		return | ||||
| 	} | ||||
| 	u.log.Trace().Uint8("old", u.keyboardState).Uint8("new", state).Msg("keyboardState updated") | ||||
| 	u.keyboardState = state | ||||
| 
 | ||||
| 	if u.onKeyboardStateChange != nil { | ||||
| 		(*u.onKeyboardStateChange)(getKeyboardState(state)) | ||||
| 	} | ||||
| } | ||||
| 
 | ||||
| func (u *UsbGadget) SetOnKeyboardStateChange(f func(state KeyboardState)) { | ||||
| 	u.onKeyboardStateChange = &f | ||||
| } | ||||
| 
 | ||||
| func (u *UsbGadget) GetKeyboardState() KeyboardState { | ||||
| 	u.keyboardStateLock.Lock() | ||||
| 	defer u.keyboardStateLock.Unlock() | ||||
| 
 | ||||
| 	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 | ||||
| 		} | ||||
| 	} | ||||
| 
 | ||||
| 	_, err := u.keyboardHidFile.Write(data) | ||||
| 	if alreadyReleased { | ||||
| 		return | ||||
| 	} | ||||
| 
 | ||||
| 	_, err := u.keypressReport(key, false) | ||||
| 	if err != nil { | ||||
| 		u.log.Errorf("failed to write to hidg0: %w", err) | ||||
| 		u.log.Warn().Uint8("key", key).Msg("failed to release key") | ||||
| 	} | ||||
| } | ||||
| 
 | ||||
| func (u *UsbGadget) listenKeyboardEvents() { | ||||
| 	var path string | ||||
| 	if u.keyboardHidFile != nil { | ||||
| 		path = u.keyboardHidFile.Name() | ||||
| 	} | ||||
| 	l := u.log.With().Str("listener", "keyboardEvents").Str("path", path).Logger() | ||||
| 	l.Trace().Msg("starting") | ||||
| 
 | ||||
| 	go func() { | ||||
| 		buf := make([]byte, hidReadBufferSize) | ||||
| 		for { | ||||
| 			select { | ||||
| 			case <-u.keyboardStateCtx.Done(): | ||||
| 				l.Info().Msg("context done") | ||||
| 				return | ||||
| 			default: | ||||
| 				l.Trace().Msg("reading from keyboard for LED state changes") | ||||
| 				if u.keyboardHidFile == nil { | ||||
| 					u.logWithSuppression("keyboardHidFileNil", 100, &l, nil, "keyboardHidFile is nil") | ||||
| 					// show the error every 100 times to avoid spamming the logs
 | ||||
| 					time.Sleep(time.Second) | ||||
| 					continue | ||||
| 				} | ||||
| 				// reset the counter
 | ||||
| 				u.resetLogSuppressionCounter("keyboardHidFileNil") | ||||
| 
 | ||||
| 				n, err := u.keyboardHidFile.Read(buf) | ||||
| 				if err != nil { | ||||
| 					u.logWithSuppression("keyboardHidFileRead", 100, &l, err, "failed to read") | ||||
| 					continue | ||||
| 				} | ||||
| 				u.resetLogSuppressionCounter("keyboardHidFileRead") | ||||
| 
 | ||||
| 				l.Trace().Int("n", n).Uints8("buf", buf).Msg("got data from keyboard") | ||||
| 				if n != 1 { | ||||
| 					l.Trace().Int("n", n).Msg("expected 1 byte, got") | ||||
| 					continue | ||||
| 				} | ||||
| 				u.updateKeyboardState(buf[0]) | ||||
| 			} | ||||
| 		} | ||||
| 	}() | ||||
| } | ||||
| 
 | ||||
| func (u *UsbGadget) openKeyboardHidFile() error { | ||||
| 	if u.keyboardHidFile != nil { | ||||
| 		return nil | ||||
| 	} | ||||
| 
 | ||||
| 	var err error | ||||
| 	u.keyboardHidFile, err = os.OpenFile("/dev/hidg0", os.O_RDWR, 0666) | ||||
| 	if err != nil { | ||||
| 		return fmt.Errorf("failed to open hidg0: %w", err) | ||||
| 	} | ||||
| 
 | ||||
| 	if u.keyboardStateCancel != nil { | ||||
| 		u.keyboardStateCancel() | ||||
| 	} | ||||
| 
 | ||||
| 	u.keyboardStateCtx, u.keyboardStateCancel = context.WithCancel(context.Background()) | ||||
| 	u.listenKeyboardEvents() | ||||
| 
 | ||||
| 	return nil | ||||
| } | ||||
| 
 | ||||
| func (u *UsbGadget) OpenKeyboardHidFile() error { | ||||
| 	return u.openKeyboardHidFile() | ||||
| } | ||||
| 
 | ||||
| var keyboardWriteHidFileLock sync.Mutex | ||||
| 
 | ||||
| func (u *UsbGadget) keyboardWriteHidFile(modifier byte, keys []byte) error { | ||||
| 	keyboardWriteHidFileLock.Lock() | ||||
| 	defer keyboardWriteHidFileLock.Unlock() | ||||
| 	if err := u.openKeyboardHidFile(); err != nil { | ||||
| 		return err | ||||
| 	} | ||||
| 
 | ||||
| 	_, err := u.writeWithTimeout(u.keyboardHidFile, append([]byte{modifier, 0x00}, keys[:hidKeyBufferSize]...)) | ||||
| 	if err != nil { | ||||
| 		u.logWithSuppression("keyboardWriteHidFile", 100, u.log, err, "failed to write to hidg0") | ||||
| 		u.keyboardHidFile.Close() | ||||
| 		u.keyboardHidFile = nil | ||||
| 		return err | ||||
| 	} | ||||
| 
 | ||||
| 	u.resetLogSuppressionCounter("keyboardWriteHidFile") | ||||
| 	return nil | ||||
| } | ||||
| 
 | ||||
| func (u *UsbGadget) KeyboardReport(modifier uint8, keys []uint8) error { | ||||
| 	u.keyboardLock.Lock() | ||||
| 	defer u.keyboardLock.Unlock() | ||||
| 
 | ||||
| 	if len(keys) > 6 { | ||||
| 		keys = keys[:6] | ||||
| 	} | ||||
| 	if len(keys) < 6 { | ||||
| 		keys = append(keys, make([]uint8, 6-len(keys))...) | ||||
| func (u *UsbGadget) UpdateKeysDown(modifier byte, keys []byte) KeysDownState { | ||||
| 	// if we just reported an error roll over, we should clear the keys
 | ||||
| 	if keys[0] == hidErrorRollOver { | ||||
| 		for i := range keys { | ||||
| 			keys[i] = 0 | ||||
| 		} | ||||
| 	} | ||||
| 
 | ||||
| 	err := u.keyboardWriteHidFile([]byte{modifier, 0, keys[0], keys[1], keys[2], keys[3], keys[4], keys[5]}) | ||||
| 	state := KeysDownState{ | ||||
| 		Modifier: modifier, | ||||
| 		Keys:     []byte(keys[:]), | ||||
| 	} | ||||
| 
 | ||||
| 	u.keyboardStateLock.Lock() | ||||
| 
 | ||||
| 	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 { | ||||
| 		return err | ||||
| 		u.log.Warn().Uint8("modifier", modifier).Uints8("keys", keys).Msg("Could not write keyboard report to hidg0") | ||||
| 	} | ||||
| 
 | ||||
| 	u.resetUserInputTime() | ||||
| 	return nil | ||||
| 	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"}, | ||||
| 	configPath: []string{"hid.usb1"}, | ||||
| 	attrs: gadgetAttributes{ | ||||
| 		"protocol":      "2", | ||||
| 		"subclass":      "1", | ||||
| 		"report_length": "6", | ||||
| 		"protocol":        "2", | ||||
| 		"subclass":        "0", | ||||
| 		"report_length":   "6", | ||||
| 		"no_out_endpoint": "1", | ||||
| 	}, | ||||
| 	reportDesc: absoluteMouseCombinedReportDesc, | ||||
| } | ||||
|  | @ -55,6 +56,8 @@ var absoluteMouseCombinedReportDesc = []byte{ | |||
| 	0x09, 0x38, //     Usage (Wheel)
 | ||||
| 	0x15, 0x81, //     Logical Minimum (-127)
 | ||||
| 	0x25, 0x7F, //     Logical Maximum (127)
 | ||||
| 	0x35, 0x00, //     Physical Minimum (0) = Reset Physical Minimum
 | ||||
| 	0x45, 0x00, //     Physical Maximum (0) = Reset Physical Maximum
 | ||||
| 	0x75, 0x08, //     Report Size (8)
 | ||||
| 	0x95, 0x01, //     Report Count (1)
 | ||||
| 	0x81, 0x06, //     Input (Data, Var, Rel)
 | ||||
|  | @ -71,27 +74,28 @@ func (u *UsbGadget) absMouseWriteHidFile(data []byte) error { | |||
| 		} | ||||
| 	} | ||||
| 
 | ||||
| 	_, err := u.absMouseHidFile.Write(data) | ||||
| 	_, err := u.writeWithTimeout(u.absMouseHidFile, data) | ||||
| 	if err != nil { | ||||
| 		u.log.Errorf("failed to write to hidg1: %w", err) | ||||
| 		u.logWithSuppression("absMouseWriteHidFile", 100, u.log, err, "failed to write to hidg1") | ||||
| 		u.absMouseHidFile.Close() | ||||
| 		u.absMouseHidFile = nil | ||||
| 		return err | ||||
| 	} | ||||
| 	u.resetLogSuppressionCounter("absMouseWriteHidFile") | ||||
| 	return nil | ||||
| } | ||||
| 
 | ||||
| func (u *UsbGadget) AbsMouseReport(x, y int, buttons uint8) error { | ||||
| func (u *UsbGadget) AbsMouseReport(x int, y int, buttons uint8) error { | ||||
| 	u.absMouseLock.Lock() | ||||
| 	defer u.absMouseLock.Unlock() | ||||
| 
 | ||||
| 	err := u.absMouseWriteHidFile([]byte{ | ||||
| 		1,             // Report ID 1
 | ||||
| 		buttons,       // Buttons
 | ||||
| 		uint8(x),      // X Low Byte
 | ||||
| 		uint8(x >> 8), // X High Byte
 | ||||
| 		uint8(y),      // Y Low Byte
 | ||||
| 		uint8(y >> 8), // Y High Byte
 | ||||
| 		1,            // Report ID 1
 | ||||
| 		buttons,      // Buttons
 | ||||
| 		byte(x),      // X Low Byte
 | ||||
| 		byte(x >> 8), // X High Byte
 | ||||
| 		byte(y),      // Y Low Byte
 | ||||
| 		byte(y >> 8), // Y High Byte
 | ||||
| 	}) | ||||
| 	if err != nil { | ||||
| 		return err | ||||
|  | @ -105,24 +109,16 @@ func (u *UsbGadget) AbsMouseWheelReport(wheelY int8) error { | |||
| 	u.absMouseLock.Lock() | ||||
| 	defer u.absMouseLock.Unlock() | ||||
| 
 | ||||
| 	// Accumulate the wheelY value
 | ||||
| 	u.absMouseAccumulatedWheelY += float64(wheelY) / 8.0 | ||||
| 
 | ||||
| 	// Only send a report if the accumulated value is significant
 | ||||
| 	if abs(u.absMouseAccumulatedWheelY) < 1.0 { | ||||
| 	// Only send a report if the value is non-zero
 | ||||
| 	if wheelY == 0 { | ||||
| 		return nil | ||||
| 	} | ||||
| 
 | ||||
| 	scaledWheelY := int8(u.absMouseAccumulatedWheelY) | ||||
| 
 | ||||
| 	err := u.absMouseWriteHidFile([]byte{ | ||||
| 		2,                  // Report ID 2
 | ||||
| 		byte(scaledWheelY), // Scaled Wheel Y (signed)
 | ||||
| 		2,            // Report ID 2
 | ||||
| 		byte(wheelY), // Wheel Y (signed)
 | ||||
| 	}) | ||||
| 
 | ||||
| 	// Reset the accumulator, keeping any remainder
 | ||||
| 	u.absMouseAccumulatedWheelY -= float64(scaledWheelY) | ||||
| 
 | ||||
| 	u.resetUserInputTime() | ||||
| 	return err | ||||
| } | ||||
|  |  | |||
|  | @ -11,9 +11,10 @@ var relativeMouseConfig = gadgetConfigItem{ | |||
| 	path:       []string{"functions", "hid.usb2"}, | ||||
| 	configPath: []string{"hid.usb2"}, | ||||
| 	attrs: gadgetAttributes{ | ||||
| 		"protocol":      "2", | ||||
| 		"subclass":      "1", | ||||
| 		"report_length": "4", | ||||
| 		"protocol":        "2", | ||||
| 		"subclass":        "1", | ||||
| 		"report_length":   "4", | ||||
| 		"no_out_endpoint": "1", | ||||
| 	}, | ||||
| 	reportDesc: relativeMouseCombinedReportDesc, | ||||
| } | ||||
|  | @ -63,25 +64,26 @@ func (u *UsbGadget) relMouseWriteHidFile(data []byte) error { | |||
| 		} | ||||
| 	} | ||||
| 
 | ||||
| 	_, err := u.relMouseHidFile.Write(data) | ||||
| 	_, err := u.writeWithTimeout(u.relMouseHidFile, data) | ||||
| 	if err != nil { | ||||
| 		u.log.Errorf("failed to write to hidg2: %w", err) | ||||
| 		u.logWithSuppression("relMouseWriteHidFile", 100, u.log, err, "failed to write to hidg2") | ||||
| 		u.relMouseHidFile.Close() | ||||
| 		u.relMouseHidFile = nil | ||||
| 		return err | ||||
| 	} | ||||
| 	u.resetLogSuppressionCounter("relMouseWriteHidFile") | ||||
| 	return nil | ||||
| } | ||||
| 
 | ||||
| func (u *UsbGadget) RelMouseReport(mx, my int8, buttons uint8) error { | ||||
| func (u *UsbGadget) RelMouseReport(mx int8, my int8, buttons uint8) error { | ||||
| 	u.relMouseLock.Lock() | ||||
| 	defer u.relMouseLock.Unlock() | ||||
| 
 | ||||
| 	err := u.relMouseWriteHidFile([]byte{ | ||||
| 		buttons,   // Buttons
 | ||||
| 		uint8(mx), // X
 | ||||
| 		uint8(my), // Y
 | ||||
| 		0,         // Wheel
 | ||||
| 		buttons,  // Buttons
 | ||||
| 		byte(mx), // X
 | ||||
| 		byte(my), // Y
 | ||||
| 		0,        // Wheel
 | ||||
| 	}) | ||||
| 	if err != nil { | ||||
| 		return err | ||||
|  |  | |||
|  | @ -0,0 +1,27 @@ | |||
| package usbgadget | ||||
| 
 | ||||
| import ( | ||||
| 	"errors" | ||||
| ) | ||||
| 
 | ||||
| func (u *UsbGadget) logWarn(msg string, err error) error { | ||||
| 	if err == nil { | ||||
| 		err = errors.New(msg) | ||||
| 	} | ||||
| 	if u.strictMode { | ||||
| 		return err | ||||
| 	} | ||||
| 	u.log.Warn().Err(err).Msg(msg) | ||||
| 	return nil | ||||
| } | ||||
| 
 | ||||
| func (u *UsbGadget) logError(msg string, err error) error { | ||||
| 	if err == nil { | ||||
| 		err = errors.New(msg) | ||||
| 	} | ||||
| 	if u.strictMode { | ||||
| 		return err | ||||
| 	} | ||||
| 	u.log.Error().Err(err).Msg(msg) | ||||
| 	return nil | ||||
| } | ||||
|  | @ -14,10 +14,13 @@ var massStorageLun0Config = gadgetConfigItem{ | |||
| 	order: 3001, | ||||
| 	path:  []string{"functions", "mass_storage.usb0", "lun.0"}, | ||||
| 	attrs: gadgetAttributes{ | ||||
| 		"cdrom":          "1", | ||||
| 		"ro":             "1", | ||||
| 		"removable":      "1", | ||||
| 		"file":           "\n", | ||||
| 		"inquiry_string": "JetKVM Virtual Media", | ||||
| 		"cdrom":     "1", | ||||
| 		"ro":        "1", | ||||
| 		"removable": "1", | ||||
| 		"file":      "\n", | ||||
| 		// the additional whitespace is intentional to avoid the "JetKVM V irtual Media" string
 | ||||
| 		// https://github.com/jetkvm/rv1106-system/blob/778133a1c153041e73f7de86c9c434a2753ea65d/sysdrv/source/uboot/u-boot/drivers/usb/gadget/f_mass_storage.c#L2556
 | ||||
| 		// Vendor (8 chars), product (16 chars)
 | ||||
| 		"inquiry_string": "JetKVM  Virtual Media", | ||||
| 	}, | ||||
| } | ||||
|  |  | |||
|  | @ -38,7 +38,7 @@ func rebindUsb(udc string, ignoreUnbindError bool) error { | |||
| } | ||||
| 
 | ||||
| func (u *UsbGadget) rebindUsb(ignoreUnbindError bool) error { | ||||
| 	u.log.Infof("rebinding USB gadget to UDC %s", u.udc) | ||||
| 	u.log.Info().Str("udc", u.udc).Msg("rebinding USB gadget to UDC") | ||||
| 	return rebindUsb(u.udc, ignoreUnbindError) | ||||
| } | ||||
| 
 | ||||
|  | @ -50,18 +50,6 @@ func (u *UsbGadget) RebindUsb(ignoreUnbindError bool) error { | |||
| 	return u.rebindUsb(ignoreUnbindError) | ||||
| } | ||||
| 
 | ||||
| func (u *UsbGadget) writeUDC() error { | ||||
| 	path := path.Join(u.kvmGadgetPath, "UDC") | ||||
| 
 | ||||
| 	u.log.Tracef("writing UDC %s to %s", u.udc, path) | ||||
| 	err := u.writeIfDifferent(path, []byte(u.udc), 0644) | ||||
| 	if err != nil { | ||||
| 		return fmt.Errorf("failed to write UDC: %w", err) | ||||
| 	} | ||||
| 
 | ||||
| 	return nil | ||||
| } | ||||
| 
 | ||||
| // GetUsbState returns the current state of the USB gadget
 | ||||
| func (u *UsbGadget) GetUsbState() (state string) { | ||||
| 	stateFile := path.Join("/sys/class/udc", u.udc, "state") | ||||
|  | @ -70,7 +58,7 @@ func (u *UsbGadget) GetUsbState() (state string) { | |||
| 		if os.IsNotExist(err) { | ||||
| 			return "not attached" | ||||
| 		} else { | ||||
| 			u.log.Tracef("failed to read usb state: %v", err) | ||||
| 			u.log.Trace().Err(err).Msg("failed to read usb state") | ||||
| 		} | ||||
| 		return "unknown" | ||||
| 	} | ||||
|  |  | |||
|  | @ -3,12 +3,14 @@ | |||
| package usbgadget | ||||
| 
 | ||||
| import ( | ||||
| 	"context" | ||||
| 	"os" | ||||
| 	"path" | ||||
| 	"sync" | ||||
| 	"time" | ||||
| 
 | ||||
| 	"github.com/pion/logging" | ||||
| 	"github.com/jetkvm/kvm/internal/logging" | ||||
| 	"github.com/rs/zerolog" | ||||
| ) | ||||
| 
 | ||||
| // Devices is a struct that represents the USB devices that can be enabled on a USB gadget.
 | ||||
|  | @ -28,7 +30,8 @@ type Config struct { | |||
| 	Manufacturer string `json:"manufacturer"` | ||||
| 	Product      string `json:"product"` | ||||
| 
 | ||||
| 	isEmpty bool | ||||
| 	strictMode bool // when it's enabled, all warnings will be converted to errors
 | ||||
| 	isEmpty    bool | ||||
| } | ||||
| 
 | ||||
| var defaultUsbGadgetDevices = Devices{ | ||||
|  | @ -38,6 +41,11 @@ var defaultUsbGadgetDevices = Devices{ | |||
| 	MassStorage:   true, | ||||
| } | ||||
| 
 | ||||
| type KeysDownState struct { | ||||
| 	Modifier byte      `json:"modifier"` | ||||
| 	Keys     ByteSlice `json:"keys"` | ||||
| } | ||||
| 
 | ||||
| // UsbGadget is a struct that represents a USB gadget.
 | ||||
| type UsbGadget struct { | ||||
| 	name          string | ||||
|  | @ -57,24 +65,50 @@ type UsbGadget struct { | |||
| 	relMouseHidFile *os.File | ||||
| 	relMouseLock    sync.Mutex | ||||
| 
 | ||||
| 	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 | ||||
| 	keyboardStateCtx    context.Context | ||||
| 	keyboardStateCancel context.CancelFunc | ||||
| 
 | ||||
| 	enabledDevices Devices | ||||
| 
 | ||||
| 	strictMode bool // only intended for testing for now
 | ||||
| 
 | ||||
| 	absMouseAccumulatedWheelY float64 | ||||
| 
 | ||||
| 	lastUserInput time.Time | ||||
| 
 | ||||
| 	log logging.LeveledLogger | ||||
| 	tx     *UsbGadgetTransaction | ||||
| 	txLock sync.Mutex | ||||
| 
 | ||||
| 	onKeyboardStateChange *func(state KeyboardState) | ||||
| 	onKeysDownChange      *func(state KeysDownState) | ||||
| 	onKeepAliveReset      *func() | ||||
| 
 | ||||
| 	log *zerolog.Logger | ||||
| 
 | ||||
| 	logSuppressionCounter map[string]int | ||||
| 	logSuppressionLock    sync.Mutex | ||||
| } | ||||
| 
 | ||||
| const configFSPath = "/sys/kernel/config" | ||||
| const gadgetPath = "/sys/kernel/config/usb_gadget" | ||||
| 
 | ||||
| var defaultLogger = logging.NewDefaultLoggerFactory().NewLogger("usbgadget") | ||||
| var defaultLogger = logging.GetSubsystemLogger("usbgadget") | ||||
| 
 | ||||
| // NewUsbGadget creates a new UsbGadget.
 | ||||
| func NewUsbGadget(name string, enabledDevices *Devices, config *Config, logger *logging.LeveledLogger) *UsbGadget { | ||||
| func NewUsbGadget(name string, enabledDevices *Devices, config *Config, logger *zerolog.Logger) *UsbGadget { | ||||
| 	return newUsbGadget(name, defaultGadgetConfig, enabledDevices, config, logger) | ||||
| } | ||||
| 
 | ||||
| func newUsbGadget(name string, configMap map[string]gadgetConfigItem, enabledDevices *Devices, config *Config, logger *zerolog.Logger) *UsbGadget { | ||||
| 	if logger == nil { | ||||
| 		logger = &defaultLogger | ||||
| 		logger = defaultLogger | ||||
| 	} | ||||
| 
 | ||||
| 	if enabledDevices == nil { | ||||
|  | @ -85,26 +119,72 @@ func NewUsbGadget(name string, enabledDevices *Devices, config *Config, logger * | |||
| 		config = &Config{isEmpty: true} | ||||
| 	} | ||||
| 
 | ||||
| 	keyboardCtx, keyboardCancel := context.WithCancel(context.Background()) | ||||
| 
 | ||||
| 	g := &UsbGadget{ | ||||
| 		name:           name, | ||||
| 		kvmGadgetPath:  path.Join(gadgetPath, name), | ||||
| 		configC1Path:   path.Join(gadgetPath, name, "configs/c.1"), | ||||
| 		configMap:      defaultGadgetConfig, | ||||
| 		customConfig:   *config, | ||||
| 		configLock:     sync.Mutex{}, | ||||
| 		keyboardLock:   sync.Mutex{}, | ||||
| 		absMouseLock:   sync.Mutex{}, | ||||
| 		relMouseLock:   sync.Mutex{}, | ||||
| 		enabledDevices: *enabledDevices, | ||||
| 		lastUserInput:  time.Now(), | ||||
| 		log:            *logger, | ||||
| 		name:                 name, | ||||
| 		kvmGadgetPath:        path.Join(gadgetPath, name), | ||||
| 		configC1Path:         path.Join(gadgetPath, name, "configs/c.1"), | ||||
| 		configMap:            configMap, | ||||
| 		customConfig:         *config, | ||||
| 		configLock:           sync.Mutex{}, | ||||
| 		keyboardLock:         sync.Mutex{}, | ||||
| 		absMouseLock:         sync.Mutex{}, | ||||
| 		relMouseLock:         sync.Mutex{}, | ||||
| 		txLock:               sync.Mutex{}, | ||||
| 		keyboardStateCtx:     keyboardCtx, | ||||
| 		keyboardStateCancel:  keyboardCancel, | ||||
| 		keyboardState:        0, | ||||
| 		keysDownState:        KeysDownState{Modifier: 0, Keys: []byte{0, 0, 0, 0, 0, 0}}, // must be initialized to hidKeyBufferSize (6) zero bytes
 | ||||
| 		kbdAutoReleaseTimers: make(map[byte]*time.Timer), | ||||
| 		enabledDevices:       *enabledDevices, | ||||
| 		lastUserInput:        time.Now(), | ||||
| 		log:                  logger, | ||||
| 
 | ||||
| 		strictMode: config.strictMode, | ||||
| 
 | ||||
| 		logSuppressionCounter: make(map[string]int), | ||||
| 
 | ||||
| 		absMouseAccumulatedWheelY: 0, | ||||
| 	} | ||||
| 	if err := g.Init(); err != nil { | ||||
| 		g.log.Errorf("failed to init USB gadget: %v", err) | ||||
| 		logger.Error().Err(err).Msg("failed to init USB gadget") | ||||
| 		return nil | ||||
| 	} | ||||
| 
 | ||||
| 	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,17 +2,42 @@ package usbgadget | |||
| 
 | ||||
| import ( | ||||
| 	"bytes" | ||||
| 	"encoding/json" | ||||
| 	"errors" | ||||
| 	"fmt" | ||||
| 	"os" | ||||
| 	"path/filepath" | ||||
| 	"strconv" | ||||
| 	"strings" | ||||
| 	"sync" | ||||
| 	"time" | ||||
| 
 | ||||
| 	"github.com/rs/zerolog" | ||||
| ) | ||||
| 
 | ||||
| // Helper function to get absolute value of float64
 | ||||
| func abs(x float64) float64 { | ||||
| 	if x < 0 { | ||||
| 		return -x | ||||
| type ByteSlice []byte | ||||
| 
 | ||||
| func (s ByteSlice) MarshalJSON() ([]byte, error) { | ||||
| 	vals := make([]int, len(s)) | ||||
| 	for i, v := range s { | ||||
| 		vals[i] = int(v) | ||||
| 	} | ||||
| 	return x | ||||
| 	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 { | ||||
|  | @ -20,44 +45,134 @@ func joinPath(basePath string, paths []string) string { | |||
| 	return filepath.Join(pathArr...) | ||||
| } | ||||
| 
 | ||||
| func ensureSymlink(linkPath string, target string) error { | ||||
| 	if _, err := os.Lstat(linkPath); err == nil { | ||||
| 		currentTarget, err := os.Readlink(linkPath) | ||||
| 		if err != nil || currentTarget != target { | ||||
| 			err = os.Remove(linkPath) | ||||
| 			if err != nil { | ||||
| 				return fmt.Errorf("failed to remove existing symlink %s: %w", linkPath, err) | ||||
| 			} | ||||
| 		} | ||||
| 	} else if !os.IsNotExist(err) { | ||||
| 		return fmt.Errorf("failed to check if symlink exists: %w", err) | ||||
| func hexToDecimal(hex string) (int64, error) { | ||||
| 	decimal, err := strconv.ParseInt(hex, 16, 64) | ||||
| 	if err != nil { | ||||
| 		return 0, err | ||||
| 	} | ||||
| 
 | ||||
| 	if err := os.Symlink(target, linkPath); err != nil { | ||||
| 		return fmt.Errorf("failed to create symlink from %s to %s: %w", linkPath, target, err) | ||||
| 	} | ||||
| 
 | ||||
| 	return nil | ||||
| 	return decimal, nil | ||||
| } | ||||
| 
 | ||||
| func (u *UsbGadget) writeIfDifferent(filePath string, content []byte, permMode os.FileMode) error { | ||||
| 	if _, err := os.Stat(filePath); err == nil { | ||||
| 		oldContent, err := os.ReadFile(filePath) | ||||
| 		if err == nil { | ||||
| 			if bytes.Equal(oldContent, content) { | ||||
| 				u.log.Tracef("skipping writing to %s as it already has the correct content", filePath) | ||||
| 				return nil | ||||
| 			} | ||||
| func decimalToOctal(decimal int64) string { | ||||
| 	return fmt.Sprintf("%04o", decimal) | ||||
| } | ||||
| 
 | ||||
| 			if len(oldContent) == len(content)+1 && | ||||
| 				bytes.Equal(oldContent[:len(content)], content) && | ||||
| 				oldContent[len(content)] == 10 { | ||||
| 				u.log.Tracef("skipping writing to %s as it already has the correct content", filePath) | ||||
| 				return nil | ||||
| 			} | ||||
| func hexToOctal(hex string) (string, error) { | ||||
| 	hex = strings.ToLower(hex) | ||||
| 	hex = strings.Replace(hex, "0x", "", 1) //remove 0x or 0X
 | ||||
| 
 | ||||
| 			u.log.Tracef("writing to %s as it has different content old%v new%v", filePath, oldContent, content) | ||||
| 	decimal, err := hexToDecimal(hex) | ||||
| 	if err != nil { | ||||
| 		return "", err | ||||
| 	} | ||||
| 
 | ||||
| 	// Convert the decimal integer to an octal string.
 | ||||
| 	octal := decimalToOctal(decimal) | ||||
| 	return octal, nil | ||||
| } | ||||
| 
 | ||||
| func compareFileContent(oldContent []byte, newContent []byte, looserMatch bool) bool { | ||||
| 	if bytes.Equal(oldContent, newContent) { | ||||
| 		return true | ||||
| 	} | ||||
| 
 | ||||
| 	if len(oldContent) == len(newContent)+1 && | ||||
| 		bytes.Equal(oldContent[:len(newContent)], newContent) && | ||||
| 		oldContent[len(newContent)] == 10 { | ||||
| 		return true | ||||
| 	} | ||||
| 
 | ||||
| 	if len(newContent) == 4 { | ||||
| 		if len(oldContent) < 6 || len(oldContent) > 7 { | ||||
| 			return false | ||||
| 		} | ||||
| 
 | ||||
| 		if len(oldContent) == 7 && oldContent[6] == 0x0a { | ||||
| 			oldContent = oldContent[:6] | ||||
| 		} | ||||
| 
 | ||||
| 		oldOctalValue, err := hexToOctal(string(oldContent)) | ||||
| 		if err != nil { | ||||
| 			return false | ||||
| 		} | ||||
| 
 | ||||
| 		if oldOctalValue == string(newContent) { | ||||
| 			return true | ||||
| 		} | ||||
| 	} | ||||
| 	return os.WriteFile(filePath, content, permMode) | ||||
| 
 | ||||
| 	if looserMatch { | ||||
| 		oldContentStr := strings.TrimSpace(string(oldContent)) | ||||
| 		newContentStr := strings.TrimSpace(string(newContent)) | ||||
| 
 | ||||
| 		return oldContentStr == newContentStr | ||||
| 	} | ||||
| 
 | ||||
| 	return false | ||||
| } | ||||
| 
 | ||||
| func (u *UsbGadget) 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() | ||||
| 	defer u.logSuppressionLock.Unlock() | ||||
| 
 | ||||
| 	if _, ok := u.logSuppressionCounter[counterName]; !ok { | ||||
| 		u.logSuppressionCounter[counterName] = 0 | ||||
| 	} else { | ||||
| 		u.logSuppressionCounter[counterName]++ | ||||
| 	} | ||||
| 
 | ||||
| 	l := logger.With().Int("counter", u.logSuppressionCounter[counterName]).Logger() | ||||
| 
 | ||||
| 	if u.logSuppressionCounter[counterName]%every == 0 { | ||||
| 		if err != nil { | ||||
| 			l.Error().Err(err).Msgf(msg, args...) | ||||
| 		} else { | ||||
| 			l.Error().Msgf(msg, args...) | ||||
| 		} | ||||
| 	} | ||||
| } | ||||
| 
 | ||||
| func (u *UsbGadget) resetLogSuppressionCounter(counterName string) { | ||||
| 	u.logSuppressionLock.Lock() | ||||
| 	defer u.logSuppressionLock.Unlock() | ||||
| 
 | ||||
| 	if _, ok := u.logSuppressionCounter[counterName]; !ok { | ||||
| 		u.logSuppressionCounter[counterName] = 0 | ||||
| 	} | ||||
| } | ||||
| 
 | ||||
| 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) | ||||
| 				} | ||||
| 			} | ||||
| 		}) | ||||
| 	} | ||||
| } | ||||
|  | @ -0,0 +1,55 @@ | |||
| package websecure | ||||
| 
 | ||||
| import ( | ||||
| 	"os" | ||||
| 	"testing" | ||||
| ) | ||||
| 
 | ||||
| var ( | ||||
| 	fixtureEd25519Certificate = `-----BEGIN CERTIFICATE----- | ||||
| MIIBQDCB86ADAgECAhQdB4qB6dV0/u1lwhJofQgkmjjV1zAFBgMrZXAwLzELMAkG | ||||
| A1UEBhMCREUxIDAeBgNVBAMMF2VkMjU1MTktdGVzdC5qZXRrdm0uY29tMB4XDTI1 | ||||
| MDUyMzEyNTkyN1oXDTI3MDQyMzEyNTkyN1owLzELMAkGA1UEBhMCREUxIDAeBgNV | ||||
| BAMMF2VkMjU1MTktdGVzdC5qZXRrdm0uY29tMCowBQYDK2VwAyEA9tLyoulJn7Ev | ||||
| bf8kuD1ZGdA092773pCRjFEDKpXHonyjITAfMB0GA1UdDgQWBBRkmrVMfsLY57iy | ||||
| r/0POP0S4QxCADAFBgMrZXADQQBfTRvqavLHDYQiKQTgbGod+Yn+fIq2lE584+1U | ||||
| C4wh9peIJDFocLBEAYTQpEMKxa4s0AIRxD+a7aCS5oz0e/0I | ||||
| -----END CERTIFICATE-----` | ||||
| 
 | ||||
| 	fixtureEd25519PrivateKey = `-----BEGIN PRIVATE KEY----- | ||||
| MC4CAQAwBQYDK2VwBCIEIKV08xUsLRHBfMXqZwxVRzIbViOp8G7aQGjPvoRFjujB | ||||
| -----END PRIVATE KEY-----` | ||||
| 
 | ||||
| 	certStore  *CertStore | ||||
| 	certSigner *SelfSigner | ||||
| ) | ||||
| 
 | ||||
| func TestMain(m *testing.M) { | ||||
| 	tlsStorePath, err := os.MkdirTemp("", "jktls.*") | ||||
| 	if err != nil { | ||||
| 		defaultLogger.Fatal().Err(err).Msg("failed to create temp directory") | ||||
| 	} | ||||
| 
 | ||||
| 	certStore = NewCertStore(tlsStorePath, nil) | ||||
| 	certStore.LoadCertificates() | ||||
| 
 | ||||
| 	certSigner = NewSelfSigner( | ||||
| 		certStore, | ||||
| 		nil, | ||||
| 		"ci.jetkvm.com", | ||||
| 		"JetKVM", | ||||
| 		"JetKVM", | ||||
| 		"JetKVM", | ||||
| 	) | ||||
| 
 | ||||
| 	m.Run() | ||||
| 
 | ||||
| 	os.RemoveAll(tlsStorePath) | ||||
| } | ||||
| 
 | ||||
| func TestSaveEd25519Certificate(t *testing.T) { | ||||
| 	err, _ := certStore.ValidateAndSaveCertificate("ed25519-test.jetkvm.com", fixtureEd25519Certificate, fixtureEd25519PrivateKey, true) | ||||
| 	if err != nil { | ||||
| 		t.Fatalf("failed to save certificate: %v", err) | ||||
| 	} | ||||
| } | ||||
|  | @ -0,0 +1,9 @@ | |||
| package websecure | ||||
| 
 | ||||
| import ( | ||||
| 	"os" | ||||
| 
 | ||||
| 	"github.com/rs/zerolog" | ||||
| ) | ||||
| 
 | ||||
| var defaultLogger = zerolog.New(os.Stdout).With().Str("component", "websecure").Logger() | ||||
|  | @ -0,0 +1,191 @@ | |||
| package websecure | ||||
| 
 | ||||
| import ( | ||||
| 	"crypto/ecdsa" | ||||
| 	"crypto/elliptic" | ||||
| 	"crypto/rand" | ||||
| 	"crypto/tls" | ||||
| 	"crypto/x509" | ||||
| 	"crypto/x509/pkix" | ||||
| 	"net" | ||||
| 	"strings" | ||||
| 	"time" | ||||
| 
 | ||||
| 	"github.com/rs/zerolog" | ||||
| 	"golang.org/x/net/idna" | ||||
| ) | ||||
| 
 | ||||
| const selfSignerCAMagicName = "__ca__" | ||||
| 
 | ||||
| type SelfSigner struct { | ||||
| 	store *CertStore | ||||
| 	log   *zerolog.Logger | ||||
| 
 | ||||
| 	caInfo pkix.Name | ||||
| 
 | ||||
| 	DefaultDomain string | ||||
| 	DefaultOrg    string | ||||
| 	DefaultOU     string | ||||
| } | ||||
| 
 | ||||
| func NewSelfSigner( | ||||
| 	store *CertStore, | ||||
| 	log *zerolog.Logger, | ||||
| 	defaultDomain, | ||||
| 	defaultOrg, | ||||
| 	defaultOU, | ||||
| 	caName string, | ||||
| ) *SelfSigner { | ||||
| 	return &SelfSigner{ | ||||
| 		store:         store, | ||||
| 		log:           log, | ||||
| 		DefaultDomain: defaultDomain, | ||||
| 		DefaultOrg:    defaultOrg, | ||||
| 		DefaultOU:     defaultOU, | ||||
| 		caInfo: pkix.Name{ | ||||
| 			CommonName:         caName, | ||||
| 			Organization:       []string{defaultOrg}, | ||||
| 			OrganizationalUnit: []string{defaultOU}, | ||||
| 		}, | ||||
| 	} | ||||
| } | ||||
| 
 | ||||
| func (s *SelfSigner) getCA() *tls.Certificate { | ||||
| 	return s.createSelfSignedCert(selfSignerCAMagicName) | ||||
| } | ||||
| 
 | ||||
| func (s *SelfSigner) createSelfSignedCert(hostname string) *tls.Certificate { | ||||
| 	if tlsCert := s.store.certificates[hostname]; tlsCert != nil { | ||||
| 		return tlsCert | ||||
| 	} | ||||
| 
 | ||||
| 	// check if hostname is the CA magic name
 | ||||
| 	var ca *tls.Certificate | ||||
| 	if hostname != selfSignerCAMagicName { | ||||
| 		ca = s.getCA() | ||||
| 		if ca == nil { | ||||
| 			s.log.Error().Msg("Failed to get CA certificate") | ||||
| 			return nil | ||||
| 		} | ||||
| 	} | ||||
| 
 | ||||
| 	s.log.Info().Str("hostname", hostname).Msg("Creating self-signed certificate") | ||||
| 
 | ||||
| 	// lock the store while creating the certificate (do not move upwards)
 | ||||
| 	s.store.certLock.Lock() | ||||
| 	defer s.store.certLock.Unlock() | ||||
| 
 | ||||
| 	priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) | ||||
| 	if err != nil { | ||||
| 		s.log.Error().Err(err).Msg("Failed to generate private key") | ||||
| 		return nil | ||||
| 	} | ||||
| 
 | ||||
| 	notBefore := time.Now() | ||||
| 	notAfter := notBefore.AddDate(1, 0, 0) | ||||
| 
 | ||||
| 	serialNumber, err := generateSerialNumber() | ||||
| 	if err != nil { | ||||
| 		s.log.Error().Err(err).Msg("Failed to generate serial number") | ||||
| 		return nil | ||||
| 	} | ||||
| 
 | ||||
| 	dnsName := hostname | ||||
| 	ip := net.ParseIP(hostname) | ||||
| 	if ip != nil { | ||||
| 		dnsName = s.DefaultDomain | ||||
| 	} | ||||
| 
 | ||||
| 	// set up CSR
 | ||||
| 	isCA := hostname == selfSignerCAMagicName | ||||
| 	subject := pkix.Name{ | ||||
| 		CommonName:         hostname, | ||||
| 		Organization:       []string{s.DefaultOrg}, | ||||
| 		OrganizationalUnit: []string{s.DefaultOU}, | ||||
| 	} | ||||
| 	keyUsage := x509.KeyUsageDigitalSignature | ||||
| 	extKeyUsage := []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth} | ||||
| 
 | ||||
| 	// check if hostname is the CA magic name, and if so, set the subject to the CA info
 | ||||
| 	if isCA { | ||||
| 		subject = s.caInfo | ||||
| 		keyUsage |= x509.KeyUsageCertSign | ||||
| 		extKeyUsage = append(extKeyUsage, x509.ExtKeyUsageClientAuth) | ||||
| 		notAfter = notBefore.AddDate(10, 0, 0) | ||||
| 	} | ||||
| 
 | ||||
| 	cert := x509.Certificate{ | ||||
| 		SerialNumber:          serialNumber, | ||||
| 		Subject:               subject, | ||||
| 		NotBefore:             notBefore, | ||||
| 		NotAfter:              notAfter, | ||||
| 		IsCA:                  isCA, | ||||
| 		KeyUsage:              keyUsage, | ||||
| 		ExtKeyUsage:           extKeyUsage, | ||||
| 		BasicConstraintsValid: true, | ||||
| 	} | ||||
| 
 | ||||
| 	// set up DNS names and IP addresses
 | ||||
| 	if !isCA { | ||||
| 		cert.DNSNames = []string{dnsName} | ||||
| 		if ip != nil { | ||||
| 			cert.IPAddresses = []net.IP{ip} | ||||
| 		} | ||||
| 	} | ||||
| 
 | ||||
| 	// set up parent certificate
 | ||||
| 	parent := &cert | ||||
| 	parentPriv := priv | ||||
| 	if ca != nil { | ||||
| 		parent, err = x509.ParseCertificate(ca.Certificate[0]) | ||||
| 		if err != nil { | ||||
| 			s.log.Error().Err(err).Msg("Failed to parse parent certificate") | ||||
| 			return nil | ||||
| 		} | ||||
| 		parentPriv = ca.PrivateKey.(*ecdsa.PrivateKey) | ||||
| 	} | ||||
| 
 | ||||
| 	certBytes, err := x509.CreateCertificate(rand.Reader, &cert, parent, &priv.PublicKey, parentPriv) | ||||
| 	if err != nil { | ||||
| 		s.log.Error().Err(err).Msg("Failed to create certificate") | ||||
| 		return nil | ||||
| 	} | ||||
| 
 | ||||
| 	tlsCert := &tls.Certificate{ | ||||
| 		Certificate: [][]byte{certBytes}, | ||||
| 		PrivateKey:  priv, | ||||
| 	} | ||||
| 	if ca != nil { | ||||
| 		tlsCert.Certificate = append(tlsCert.Certificate, ca.Certificate...) | ||||
| 	} | ||||
| 
 | ||||
| 	s.store.certificates[hostname] = tlsCert | ||||
| 	s.store.saveCertificate(hostname) | ||||
| 
 | ||||
| 	return tlsCert | ||||
| } | ||||
| 
 | ||||
| // GetCertificate returns the certificate for the given hostname
 | ||||
| // returns nil if the certificate is not found
 | ||||
| func (s *SelfSigner) GetCertificate(info *tls.ClientHelloInfo) (*tls.Certificate, error) { | ||||
| 	var hostname string | ||||
| 	if info.ServerName != "" && info.ServerName != selfSignerCAMagicName { | ||||
| 		hostname = info.ServerName | ||||
| 	} else { | ||||
| 		hostname = strings.Split(info.Conn.LocalAddr().String(), ":")[0] | ||||
| 	} | ||||
| 
 | ||||
| 	s.log.Info().Str("hostname", hostname).Strs("supported_protos", info.SupportedProtos).Msg("TLS handshake") | ||||
| 
 | ||||
| 	// convert hostname to punycode
 | ||||
| 	h, err := idna.Lookup.ToASCII(hostname) | ||||
| 	if err != nil { | ||||
| 		s.log.Warn().Str("hostname", hostname).Err(err).Str("remote_addr", info.Conn.RemoteAddr().String()).Msg("Hostname is not valid") | ||||
| 		hostname = s.DefaultDomain | ||||
| 	} else { | ||||
| 		hostname = h | ||||
| 	} | ||||
| 
 | ||||
| 	cert := s.createSelfSignedCert(hostname) | ||||
| 	return cert, nil | ||||
| } | ||||
|  | @ -0,0 +1,179 @@ | |||
| package websecure | ||||
| 
 | ||||
| import ( | ||||
| 	"crypto/tls" | ||||
| 	"fmt" | ||||
| 	"os" | ||||
| 	"path" | ||||
| 	"strings" | ||||
| 	"sync" | ||||
| 
 | ||||
| 	"github.com/rs/zerolog" | ||||
| ) | ||||
| 
 | ||||
| type CertStore struct { | ||||
| 	certificates map[string]*tls.Certificate | ||||
| 	certLock     *sync.Mutex | ||||
| 
 | ||||
| 	storePath string | ||||
| 
 | ||||
| 	log *zerolog.Logger | ||||
| } | ||||
| 
 | ||||
| func NewCertStore(storePath string, log *zerolog.Logger) *CertStore { | ||||
| 	if log == nil { | ||||
| 		log = &defaultLogger | ||||
| 	} | ||||
| 
 | ||||
| 	return &CertStore{ | ||||
| 		certificates: make(map[string]*tls.Certificate), | ||||
| 		certLock:     &sync.Mutex{}, | ||||
| 
 | ||||
| 		storePath: storePath, | ||||
| 		log:       log, | ||||
| 	} | ||||
| } | ||||
| 
 | ||||
| func (s *CertStore) ensureStorePath() error { | ||||
| 	// check if directory exists
 | ||||
| 	stat, err := os.Stat(s.storePath) | ||||
| 	if err == nil { | ||||
| 		if stat.IsDir() { | ||||
| 			return nil | ||||
| 		} | ||||
| 
 | ||||
| 		return fmt.Errorf("TLS store path exists but is not a directory: %s", s.storePath) | ||||
| 	} | ||||
| 
 | ||||
| 	if os.IsNotExist(err) { | ||||
| 		s.log.Trace().Str("path", s.storePath).Msg("TLS store directory does not exist, creating directory") | ||||
| 		err = os.MkdirAll(s.storePath, 0755) | ||||
| 		if err != nil { | ||||
| 			return fmt.Errorf("failed to create TLS store path: %w", err) | ||||
| 		} | ||||
| 		return nil | ||||
| 	} | ||||
| 
 | ||||
| 	return fmt.Errorf("failed to check TLS store path: %w", err) | ||||
| } | ||||
| 
 | ||||
| func (s *CertStore) LoadCertificates() { | ||||
| 	err := s.ensureStorePath() | ||||
| 	if err != nil { | ||||
| 		s.log.Error().Err(err).Msg("Failed to ensure store path") | ||||
| 		return | ||||
| 	} | ||||
| 
 | ||||
| 	files, err := os.ReadDir(s.storePath) | ||||
| 	if err != nil { | ||||
| 		s.log.Error().Err(err).Msg("Failed to read TLS directory") | ||||
| 		return | ||||
| 	} | ||||
| 
 | ||||
| 	for _, file := range files { | ||||
| 		if file.IsDir() { | ||||
| 			continue | ||||
| 		} | ||||
| 
 | ||||
| 		if strings.HasSuffix(file.Name(), ".crt") { | ||||
| 			s.loadCertificate(strings.TrimSuffix(file.Name(), ".crt")) | ||||
| 		} | ||||
| 	} | ||||
| } | ||||
| 
 | ||||
| func (s *CertStore) loadCertificate(hostname string) { | ||||
| 	s.certLock.Lock() | ||||
| 	defer s.certLock.Unlock() | ||||
| 
 | ||||
| 	keyFile := path.Join(s.storePath, hostname+".key") | ||||
| 	crtFile := path.Join(s.storePath, hostname+".crt") | ||||
| 
 | ||||
| 	cert, err := tls.LoadX509KeyPair(crtFile, keyFile) | ||||
| 	if err != nil { | ||||
| 		s.log.Error().Err(err).Str("hostname", hostname).Msg("Failed to load certificate") | ||||
| 		return | ||||
| 	} | ||||
| 
 | ||||
| 	s.certificates[hostname] = &cert | ||||
| 
 | ||||
| 	if hostname == selfSignerCAMagicName { | ||||
| 		s.log.Info().Msg("loaded CA certificate") | ||||
| 	} else { | ||||
| 		s.log.Info().Str("hostname", hostname).Msg("loaded certificate") | ||||
| 	} | ||||
| } | ||||
| 
 | ||||
| // GetCertificate returns the certificate for the given hostname
 | ||||
| // returns nil if the certificate is not found
 | ||||
| func (s *CertStore) GetCertificate(hostname string) *tls.Certificate { | ||||
| 	s.certLock.Lock() | ||||
| 	defer s.certLock.Unlock() | ||||
| 
 | ||||
| 	return s.certificates[hostname] | ||||
| } | ||||
| 
 | ||||
| // ValidateAndSaveCertificate validates the certificate and saves it to the store
 | ||||
| // returns are:
 | ||||
| // - error: if the certificate is invalid or if there's any error during saving the certificate
 | ||||
| // - error: if there's any warning or error during saving the certificate
 | ||||
| func (s *CertStore) ValidateAndSaveCertificate(hostname string, cert string, key string, ignoreWarning bool) (error, error) { | ||||
| 	tlsCert, err := tls.X509KeyPair([]byte(cert), []byte(key)) | ||||
| 	if err != nil { | ||||
| 		return fmt.Errorf("failed to parse certificate: %w", err), nil | ||||
| 	} | ||||
| 
 | ||||
| 	// this can be skipped as current implementation supports one custom certificate only
 | ||||
| 	if tlsCert.Leaf != nil { | ||||
| 		// add recover to avoid panic
 | ||||
| 		defer func() { | ||||
| 			if r := recover(); r != nil { | ||||
| 				s.log.Error().Interface("recovered", r).Msg("Failed to verify hostname") | ||||
| 			} | ||||
| 		}() | ||||
| 
 | ||||
| 		if err = tlsCert.Leaf.VerifyHostname(hostname); err != nil { | ||||
| 			if !ignoreWarning { | ||||
| 				return nil, fmt.Errorf("certificate does not match hostname: %w", err) | ||||
| 			} | ||||
| 			s.log.Warn().Err(err).Msg("certificate does not match hostname") | ||||
| 		} | ||||
| 	} | ||||
| 
 | ||||
| 	s.certLock.Lock() | ||||
| 	s.certificates[hostname] = &tlsCert | ||||
| 	s.certLock.Unlock() | ||||
| 
 | ||||
| 	s.saveCertificate(hostname) | ||||
| 
 | ||||
| 	return nil, nil | ||||
| } | ||||
| 
 | ||||
| func (s *CertStore) saveCertificate(hostname string) { | ||||
| 	// check if certificate already exists
 | ||||
| 	tlsCert := s.certificates[hostname] | ||||
| 	if tlsCert == nil { | ||||
| 		s.log.Error().Str("hostname", hostname).Msg("Certificate for hostname does not exist, skipping saving certificate") | ||||
| 		return | ||||
| 	} | ||||
| 
 | ||||
| 	err := s.ensureStorePath() | ||||
| 	if err != nil { | ||||
| 		s.log.Error().Err(err).Msg("Failed to ensure store path") | ||||
| 		return | ||||
| 	} | ||||
| 
 | ||||
| 	keyFile := path.Join(s.storePath, hostname+".key") | ||||
| 	crtFile := path.Join(s.storePath, hostname+".crt") | ||||
| 
 | ||||
| 	if err := keyToFile(tlsCert, keyFile); err != nil { | ||||
| 		s.log.Error().Err(err).Msg("Failed to save key file") | ||||
| 		return | ||||
| 	} | ||||
| 
 | ||||
| 	if err := certToFile(tlsCert, crtFile); err != nil { | ||||
| 		s.log.Error().Err(err).Msg("Failed to save certificate") | ||||
| 		return | ||||
| 	} | ||||
| 
 | ||||
| 	s.log.Info().Str("hostname", hostname).Msg("Saved certificate") | ||||
| } | ||||
|  | @ -0,0 +1,85 @@ | |||
| package websecure | ||||
| 
 | ||||
| import ( | ||||
| 	"crypto/ecdsa" | ||||
| 	"crypto/ed25519" | ||||
| 	"crypto/rand" | ||||
| 	"crypto/rsa" | ||||
| 	"crypto/tls" | ||||
| 	"crypto/x509" | ||||
| 	"encoding/pem" | ||||
| 	"fmt" | ||||
| 	"math/big" | ||||
| 	"os" | ||||
| ) | ||||
| 
 | ||||
| var serialNumberLimit = new(big.Int).Lsh(big.NewInt(1), 4096) | ||||
| 
 | ||||
| func withSecretFile(filename string, f func(*os.File) error) error { | ||||
| 	file, err := os.OpenFile(filename, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0600) | ||||
| 	if err != nil { | ||||
| 		return err | ||||
| 	} | ||||
| 	defer file.Close() | ||||
| 
 | ||||
| 	return f(file) | ||||
| } | ||||
| 
 | ||||
| func keyToFile(cert *tls.Certificate, filename string) error { | ||||
| 	var keyBlock pem.Block | ||||
| 	switch k := cert.PrivateKey.(type) { | ||||
| 	case *rsa.PrivateKey: | ||||
| 		keyBlock = pem.Block{ | ||||
| 			Type:  "RSA PRIVATE KEY", | ||||
| 			Bytes: x509.MarshalPKCS1PrivateKey(k), | ||||
| 		} | ||||
| 	case *ecdsa.PrivateKey: | ||||
| 		b, e := x509.MarshalECPrivateKey(k) | ||||
| 		if e != nil { | ||||
| 			return fmt.Errorf("failed to marshal EC private key: %v", e) | ||||
| 		} | ||||
| 		keyBlock = pem.Block{ | ||||
| 			Type:  "EC PRIVATE KEY", | ||||
| 			Bytes: b, | ||||
| 		} | ||||
| 	case ed25519.PrivateKey: | ||||
| 		keyBlock = pem.Block{ | ||||
| 			Type:  "ED25519 PRIVATE KEY", | ||||
| 			Bytes: k, | ||||
| 		} | ||||
| 	default: | ||||
| 		return fmt.Errorf("unknown private key type: %T", k) | ||||
| 	} | ||||
| 
 | ||||
| 	err := withSecretFile(filename, func(file *os.File) error { | ||||
| 		return pem.Encode(file, &keyBlock) | ||||
| 	}) | ||||
| 
 | ||||
| 	if err != nil { | ||||
| 		return fmt.Errorf("failed to save private key: %w", err) | ||||
| 	} | ||||
| 
 | ||||
| 	return nil | ||||
| } | ||||
| 
 | ||||
| func certToFile(cert *tls.Certificate, filename string) error { | ||||
| 	return withSecretFile(filename, func(file *os.File) error { | ||||
| 		for _, c := range cert.Certificate { | ||||
| 			block := pem.Block{ | ||||
| 				Type:  "CERTIFICATE", | ||||
| 				Bytes: c, | ||||
| 			} | ||||
| 
 | ||||
| 			err := pem.Encode(file, &block) | ||||
| 			if err != nil { | ||||
| 				return fmt.Errorf("failed to save certificate: %w", err) | ||||
| 			} | ||||
| 		} | ||||
| 
 | ||||
| 		return nil | ||||
| 	}) | ||||
| } | ||||
| 
 | ||||
| func generateSerialNumber() (*big.Int, error) { | ||||
| 	return rand.Int(rand.Reader, serialNumberLimit) | ||||
| } | ||||
							
								
								
									
										165
									
								
								jiggler.go
								
								
								
								
							
							
						
						
									
										165
									
								
								jiggler.go
								
								
								
								
							|  | @ -1,41 +1,160 @@ | |||
| package kvm | ||||
| 
 | ||||
| import ( | ||||
| 	"fmt" | ||||
| 	"math/rand" | ||||
| 	"time" | ||||
| 	_ "time/tzdata" | ||||
| 
 | ||||
| 	"github.com/go-co-op/gocron/v2" | ||||
| 	"github.com/jetkvm/kvm/internal/tzdata" | ||||
| ) | ||||
| 
 | ||||
| var lastUserInput = time.Now() | ||||
| 
 | ||||
| var jigglerEnabled = false | ||||
| 
 | ||||
| func rpcSetJigglerState(enabled bool) { | ||||
| 	jigglerEnabled = enabled | ||||
| type JigglerConfig struct { | ||||
| 	InactivityLimitSeconds int    `json:"inactivity_limit_seconds"` | ||||
| 	JitterPercentage       int    `json:"jitter_percentage"` | ||||
| 	ScheduleCronTab        string `json:"schedule_cron_tab"` | ||||
| 	Timezone               string `json:"timezone,omitempty"` | ||||
| } | ||||
| 
 | ||||
| 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 { | ||||
| 	return jigglerEnabled | ||||
| 	return config.JigglerEnabled | ||||
| } | ||||
| 
 | ||||
| func init() { | ||||
| 	ensureConfigLoaded() | ||||
| func rpcGetTimezones() []string { | ||||
| 	return tzdata.TimeZones | ||||
| } | ||||
| 
 | ||||
| 	go runJiggler() | ||||
| 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() { | ||||
| 	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() { | ||||
| 	for { | ||||
| 		if jigglerEnabled { | ||||
| 			if time.Since(lastUserInput) > 20*time.Second { | ||||
| 				//TODO: change to rel mouse
 | ||||
| 				err := rpcAbsMouseReport(1, 1, 0) | ||||
| 				if err != nil { | ||||
| 					logger.Warnf("Failed to jiggle mouse: %v", err) | ||||
| 				} | ||||
| 				err = rpcAbsMouseReport(0, 0, 0) | ||||
| 				if err != nil { | ||||
| 					logger.Warnf("Failed to reset mouse position: %v", err) | ||||
| 				} | ||||
| 	if config.JigglerEnabled { | ||||
| 		if config.JigglerConfig.JitterPercentage != 0 { | ||||
| 			jitter := calculateJitterDuration(jobDelta) | ||||
| 			time.Sleep(jitter) | ||||
| 		} | ||||
| 		inactivitySeconds := config.JigglerConfig.InactivityLimitSeconds | ||||
| 		timeSinceLastInput := time.Since(gadget.GetLastUserInputTime()) | ||||
| 		logger.Debug().Msgf("Time since last user input %v", timeSinceLastInput) | ||||
| 		if timeSinceLastInput > time.Duration(inactivitySeconds)*time.Second { | ||||
| 			logger.Debug().Msg("Jiggling mouse...") | ||||
| 			//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)) | ||||
| } | ||||
|  |  | |||
							
								
								
									
										595
									
								
								jsonrpc.go
								
								
								
								
							
							
						
						
									
										595
									
								
								jsonrpc.go
								
								
								
								
							|  | @ -1,6 +1,7 @@ | |||
| package kvm | ||||
| 
 | ||||
| import ( | ||||
| 	"bytes" | ||||
| 	"context" | ||||
| 	"encoding/json" | ||||
| 	"errors" | ||||
|  | @ -10,32 +11,40 @@ import ( | |||
| 	"path/filepath" | ||||
| 	"reflect" | ||||
| 	"strconv" | ||||
| 	"sync" | ||||
| 	"time" | ||||
| 
 | ||||
| 	"github.com/pion/webrtc/v4" | ||||
| 	"github.com/rs/zerolog" | ||||
| 	"go.bug.st/serial" | ||||
| 
 | ||||
| 	"github.com/jetkvm/kvm/internal/hidrpc" | ||||
| 	"github.com/jetkvm/kvm/internal/usbgadget" | ||||
| 	"github.com/jetkvm/kvm/internal/utils" | ||||
| ) | ||||
| 
 | ||||
| type JSONRPCRequest struct { | ||||
| 	JSONRPC string                 `json:"jsonrpc"` | ||||
| 	Method  string                 `json:"method"` | ||||
| 	Params  map[string]interface{} `json:"params,omitempty"` | ||||
| 	ID      interface{}            `json:"id,omitempty"` | ||||
| 	JSONRPC string         `json:"jsonrpc"` | ||||
| 	Method  string         `json:"method"` | ||||
| 	Params  map[string]any `json:"params,omitempty"` | ||||
| 	ID      any            `json:"id,omitempty"` | ||||
| } | ||||
| 
 | ||||
| type JSONRPCResponse struct { | ||||
| 	JSONRPC string      `json:"jsonrpc"` | ||||
| 	Result  interface{} `json:"result,omitempty"` | ||||
| 	Error   interface{} `json:"error,omitempty"` | ||||
| 	ID      interface{} `json:"id"` | ||||
| 	JSONRPC string `json:"jsonrpc"` | ||||
| 	Result  any    `json:"result,omitempty"` | ||||
| 	Error   any    `json:"error,omitempty"` | ||||
| 	ID      any    `json:"id"` | ||||
| } | ||||
| 
 | ||||
| type JSONRPCEvent struct { | ||||
| 	JSONRPC string      `json:"jsonrpc"` | ||||
| 	Method  string      `json:"method"` | ||||
| 	Params  interface{} `json:"params,omitempty"` | ||||
| 	JSONRPC string `json:"jsonrpc"` | ||||
| 	Method  string `json:"method"` | ||||
| 	Params  any    `json:"params,omitempty"` | ||||
| } | ||||
| 
 | ||||
| type DisplayRotationSettings struct { | ||||
| 	Rotation string `json:"rotation"` | ||||
| } | ||||
| 
 | ||||
| type BacklightSettings struct { | ||||
|  | @ -47,17 +56,17 @@ type BacklightSettings struct { | |||
| func writeJSONRPCResponse(response JSONRPCResponse, session *Session) { | ||||
| 	responseBytes, err := json.Marshal(response) | ||||
| 	if err != nil { | ||||
| 		logger.Warnf("Error marshalling JSONRPC response: %v", err) | ||||
| 		jsonRpcLogger.Warn().Err(err).Msg("Error marshalling JSONRPC response") | ||||
| 		return | ||||
| 	} | ||||
| 	err = session.RPCChannel.SendText(string(responseBytes)) | ||||
| 	if err != nil { | ||||
| 		logger.Warnf("Error sending JSONRPC response: %v", err) | ||||
| 		jsonRpcLogger.Warn().Err(err).Msg("Error sending JSONRPC response") | ||||
| 		return | ||||
| 	} | ||||
| } | ||||
| 
 | ||||
| func writeJSONRPCEvent(event string, params interface{}, session *Session) { | ||||
| func writeJSONRPCEvent(event string, params any, session *Session) { | ||||
| 	request := JSONRPCEvent{ | ||||
| 		JSONRPC: "2.0", | ||||
| 		Method:  event, | ||||
|  | @ -65,16 +74,24 @@ func writeJSONRPCEvent(event string, params interface{}, session *Session) { | |||
| 	} | ||||
| 	requestBytes, err := json.Marshal(request) | ||||
| 	if err != nil { | ||||
| 		logger.Warnf("Error marshalling JSONRPC event: %v", err) | ||||
| 		jsonRpcLogger.Warn().Err(err).Msg("Error marshalling JSONRPC event") | ||||
| 		return | ||||
| 	} | ||||
| 	if session == nil || session.RPCChannel == nil { | ||||
| 		logger.Info("RPC channel not available") | ||||
| 		jsonRpcLogger.Info().Msg("RPC channel not available") | ||||
| 		return | ||||
| 	} | ||||
| 	err = session.RPCChannel.SendText(string(requestBytes)) | ||||
| 
 | ||||
| 	requestString := string(requestBytes) | ||||
| 	scopedLogger := jsonRpcLogger.With(). | ||||
| 		Str("data", requestString). | ||||
| 		Logger() | ||||
| 
 | ||||
| 	scopedLogger.Trace().Msg("sending JSONRPC event") | ||||
| 
 | ||||
| 	err = session.RPCChannel.SendText(requestString) | ||||
| 	if err != nil { | ||||
| 		logger.Warnf("Error sending JSONRPC event: %v", err) | ||||
| 		scopedLogger.Warn().Err(err).Msg("error sending JSONRPC event") | ||||
| 		return | ||||
| 	} | ||||
| } | ||||
|  | @ -83,9 +100,14 @@ func onRPCMessage(message webrtc.DataChannelMessage, session *Session) { | |||
| 	var request JSONRPCRequest | ||||
| 	err := json.Unmarshal(message.Data, &request) | ||||
| 	if err != nil { | ||||
| 		jsonRpcLogger.Warn(). | ||||
| 			Str("data", string(message.Data)). | ||||
| 			Err(err). | ||||
| 			Msg("Error unmarshalling JSONRPC request") | ||||
| 
 | ||||
| 		errorResponse := JSONRPCResponse{ | ||||
| 			JSONRPC: "2.0", | ||||
| 			Error: map[string]interface{}{ | ||||
| 			Error: map[string]any{ | ||||
| 				"code":    -32700, | ||||
| 				"message": "Parse error", | ||||
| 			}, | ||||
|  | @ -95,12 +117,18 @@ func onRPCMessage(message webrtc.DataChannelMessage, session *Session) { | |||
| 		return | ||||
| 	} | ||||
| 
 | ||||
| 	//logger.Infof("Received RPC request: Method=%s, Params=%v, ID=%d", request.Method, request.Params, request.ID)
 | ||||
| 	scopedLogger := jsonRpcLogger.With(). | ||||
| 		Str("method", request.Method). | ||||
| 		Interface("params", request.Params). | ||||
| 		Interface("id", request.ID).Logger() | ||||
| 
 | ||||
| 	scopedLogger.Trace().Msg("Received RPC request") | ||||
| 
 | ||||
| 	handler, ok := rpcHandlers[request.Method] | ||||
| 	if !ok { | ||||
| 		errorResponse := JSONRPCResponse{ | ||||
| 			JSONRPC: "2.0", | ||||
| 			Error: map[string]interface{}{ | ||||
| 			Error: map[string]any{ | ||||
| 				"code":    -32601, | ||||
| 				"message": "Method not found", | ||||
| 			}, | ||||
|  | @ -110,11 +138,12 @@ func onRPCMessage(message webrtc.DataChannelMessage, session *Session) { | |||
| 		return | ||||
| 	} | ||||
| 
 | ||||
| 	result, err := callRPCHandler(handler, request.Params) | ||||
| 	result, err := callRPCHandler(scopedLogger, handler, request.Params) | ||||
| 	if err != nil { | ||||
| 		scopedLogger.Error().Err(err).Msg("Error calling RPC handler") | ||||
| 		errorResponse := JSONRPCResponse{ | ||||
| 			JSONRPC: "2.0", | ||||
| 			Error: map[string]interface{}{ | ||||
| 			Error: map[string]any{ | ||||
| 				"code":    -32603, | ||||
| 				"message": "Internal error", | ||||
| 				"data":    err.Error(), | ||||
|  | @ -125,6 +154,8 @@ func onRPCMessage(message webrtc.DataChannelMessage, session *Session) { | |||
| 		return | ||||
| 	} | ||||
| 
 | ||||
| 	scopedLogger.Trace().Interface("result", result).Msg("RPC handler returned") | ||||
| 
 | ||||
| 	response := JSONRPCResponse{ | ||||
| 		JSONRPC: "2.0", | ||||
| 		Result:  result, | ||||
|  | @ -141,6 +172,30 @@ func rpcGetDeviceID() (string, error) { | |||
| 	return GetDeviceID(), nil | ||||
| } | ||||
| 
 | ||||
| func rpcReboot(force bool) error { | ||||
| 	logger.Info().Msg("Got reboot request from JSONRPC, rebooting...") | ||||
| 
 | ||||
| 	args := []string{} | ||||
| 	if force { | ||||
| 		args = append(args, "-f") | ||||
| 	} | ||||
| 
 | ||||
| 	cmd := exec.Command("reboot", args...) | ||||
| 	err := cmd.Start() | ||||
| 	if err != nil { | ||||
| 		logger.Error().Err(err).Msg("failed to reboot") | ||||
| 		return fmt.Errorf("failed to reboot: %w", err) | ||||
| 	} | ||||
| 
 | ||||
| 	// If the reboot command is successful, exit the program after 5 seconds
 | ||||
| 	go func() { | ||||
| 		time.Sleep(5 * time.Second) | ||||
| 		os.Exit(0) | ||||
| 	}() | ||||
| 
 | ||||
| 	return nil | ||||
| } | ||||
| 
 | ||||
| var streamFactor = 1.0 | ||||
| 
 | ||||
| func rpcGetStreamQualityFactor() (float64, error) { | ||||
|  | @ -148,8 +203,8 @@ func rpcGetStreamQualityFactor() (float64, error) { | |||
| } | ||||
| 
 | ||||
| func rpcSetStreamQualityFactor(factor float64) error { | ||||
| 	logger.Infof("Setting stream quality factor to: %f", factor) | ||||
| 	var _, err = CallCtrlAction("set_video_quality_factor", map[string]interface{}{"quality_factor": factor}) | ||||
| 	logger.Info().Float64("factor", factor).Msg("Setting stream quality factor") | ||||
| 	var _, err = CallCtrlAction("set_video_quality_factor", map[string]any{"quality_factor": factor}) | ||||
| 	if err != nil { | ||||
| 		return err | ||||
| 	} | ||||
|  | @ -184,12 +239,12 @@ func rpcGetEDID() (string, error) { | |||
| 
 | ||||
| func rpcSetEDID(edid string) error { | ||||
| 	if edid == "" { | ||||
| 		logger.Info("Restoring EDID to default") | ||||
| 		logger.Info().Msg("Restoring EDID to default") | ||||
| 		edid = "00ffffffffffff0052620188008888881c150103800000780a0dc9a05747982712484c00000001010101010101010101010101010101023a801871382d40582c4500c48e2100001e011d007251d01e206e285500c48e2100001e000000fc00543734392d6648443732300a20000000fd00147801ff1d000a202020202020017b" | ||||
| 	} else { | ||||
| 		logger.Infof("Setting EDID to: %s", edid) | ||||
| 		logger.Info().Str("edid", edid).Msg("Setting EDID") | ||||
| 	} | ||||
| 	_, err := CallCtrlAction("set_edid", map[string]interface{}{"edid": edid}) | ||||
| 	_, err := CallCtrlAction("set_edid", map[string]any{"edid": edid}) | ||||
| 	if err != nil { | ||||
| 		return err | ||||
| 	} | ||||
|  | @ -215,24 +270,58 @@ func rpcSetDevChannelState(enabled bool) error { | |||
| func rpcGetUpdateStatus() (*UpdateStatus, error) { | ||||
| 	includePreRelease := config.IncludePreRelease | ||||
| 	updateStatus, err := GetUpdateStatus(context.Background(), GetDeviceID(), includePreRelease) | ||||
| 	// to ensure backwards compatibility,
 | ||||
| 	// if there's an error, we won't return an error, but we will set the error field
 | ||||
| 	if err != nil { | ||||
| 		return nil, fmt.Errorf("error checking for updates: %w", err) | ||||
| 		if updateStatus == nil { | ||||
| 			return nil, fmt.Errorf("error checking for updates: %w", err) | ||||
| 		} | ||||
| 		updateStatus.Error = err.Error() | ||||
| 	} | ||||
| 
 | ||||
| 	return updateStatus, nil | ||||
| } | ||||
| 
 | ||||
| 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 { | ||||
| 	includePreRelease := config.IncludePreRelease | ||||
| 	go func() { | ||||
| 		err := TryUpdate(context.Background(), GetDeviceID(), includePreRelease) | ||||
| 		if err != nil { | ||||
| 			logger.Warnf("failed to try update: %v", err) | ||||
| 			logger.Warn().Err(err).Msg("failed to try update") | ||||
| 		} | ||||
| 	}() | ||||
| 	return nil | ||||
| } | ||||
| 
 | ||||
| func rpcSetDisplayRotation(params DisplayRotationSettings) error { | ||||
| 	var err error | ||||
| 	_, err = lvDispSetRotation(params.Rotation) | ||||
| 	if err == nil { | ||||
| 		config.DisplayRotation = params.Rotation | ||||
| 		if err := SaveConfig(); err != nil { | ||||
| 			return fmt.Errorf("failed to save config: %w", err) | ||||
| 		} | ||||
| 	} | ||||
| 	return err | ||||
| } | ||||
| 
 | ||||
| func rpcGetDisplayRotation() (*DisplayRotationSettings, error) { | ||||
| 	return &DisplayRotationSettings{ | ||||
| 		Rotation: config.DisplayRotation, | ||||
| 	}, nil | ||||
| } | ||||
| 
 | ||||
| func rpcSetBacklightSettings(params BacklightSettings) error { | ||||
| 	blConfig := params | ||||
| 
 | ||||
|  | @ -257,7 +346,7 @@ func rpcSetBacklightSettings(params BacklightSettings) error { | |||
| 		return fmt.Errorf("failed to save config: %w", err) | ||||
| 	} | ||||
| 
 | ||||
| 	logger.Infof("rpc: display: settings applied, max_brightness: %d, dim after: %ds, off after: %ds", config.DisplayMaxBrightness, config.DisplayDimAfterSec, config.DisplayOffAfterSec) | ||||
| 	logger.Info().Int("max_brightness", config.DisplayMaxBrightness).Int("dim_after", config.DisplayDimAfterSec).Int("off_after", config.DisplayOffAfterSec).Msg("rpc: display: settings applied") | ||||
| 
 | ||||
| 	// If the device started up with auto-dim and/or auto-off set to zero, the display init
 | ||||
| 	// method will not have started the tickers. So in case that has changed, attempt to start the tickers now.
 | ||||
|  | @ -318,7 +407,7 @@ func rpcSetDevModeState(enabled bool) error { | |||
| 				return fmt.Errorf("failed to create devmode file: %w", err) | ||||
| 			} | ||||
| 		} else { | ||||
| 			logger.Debug("dev mode already enabled") | ||||
| 			logger.Debug().Msg("dev mode already enabled") | ||||
| 			return nil | ||||
| 		} | ||||
| 	} else { | ||||
|  | @ -327,7 +416,7 @@ func rpcSetDevModeState(enabled bool) error { | |||
| 				return fmt.Errorf("failed to remove devmode file: %w", err) | ||||
| 			} | ||||
| 		} else if os.IsNotExist(err) { | ||||
| 			logger.Debug("dev mode already disabled") | ||||
| 			logger.Debug().Msg("dev mode already disabled") | ||||
| 			return nil | ||||
| 		} else { | ||||
| 			return fmt.Errorf("error checking dev mode file: %w", err) | ||||
|  | @ -337,7 +426,7 @@ func rpcSetDevModeState(enabled bool) error { | |||
| 	cmd := exec.Command("dropbear.sh") | ||||
| 	output, err := cmd.CombinedOutput() | ||||
| 	if err != nil { | ||||
| 		logger.Warnf("Failed to start/stop SSH: %v, %v", err, output) | ||||
| 		logger.Warn().Err(err).Bytes("output", output).Msg("Failed to start/stop SSH") | ||||
| 		return fmt.Errorf("failed to start/stop SSH, you may need to reboot for changes to take effect") | ||||
| 	} | ||||
| 
 | ||||
|  | @ -355,27 +444,74 @@ func rpcGetSSHKeyState() (string, error) { | |||
| } | ||||
| 
 | ||||
| func rpcSetSSHKeyState(sshKey string) error { | ||||
| 	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 { | ||||
| 	if sshKey == "" { | ||||
| 		// Remove SSH key file if empty string is provided
 | ||||
| 		if err := os.Remove(sshKeyFile); err != nil && !os.IsNotExist(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 | ||||
| } | ||||
| 
 | ||||
| func callRPCHandler(handler RPCHandler, params map[string]interface{}) (interface{}, error) { | ||||
| func rpcGetTLSState() TLSState { | ||||
| 	return getTLSState() | ||||
| } | ||||
| 
 | ||||
| func rpcSetTLSState(state TLSState) error { | ||||
| 	err := setTLSState(state) | ||||
| 	if err != nil { | ||||
| 		return fmt.Errorf("failed to set TLS state: %w", err) | ||||
| 	} | ||||
| 
 | ||||
| 	if err := SaveConfig(); err != nil { | ||||
| 		return fmt.Errorf("failed to save config: %w", err) | ||||
| 	} | ||||
| 
 | ||||
| 	return nil | ||||
| } | ||||
| 
 | ||||
| type RPCHandler struct { | ||||
| 	Func   any | ||||
| 	Params []string | ||||
| } | ||||
| 
 | ||||
| // call the handler but recover from a panic to ensure our RPC thread doesn't collapse on malformed calls
 | ||||
| func callRPCHandler(logger zerolog.Logger, handler RPCHandler, params map[string]any) (result any, err error) { | ||||
| 	// Use defer to recover from a panic
 | ||||
| 	defer func() { | ||||
| 		if r := recover(); r != nil { | ||||
| 			// Convert the panic to an error
 | ||||
| 			if e, ok := r.(error); ok { | ||||
| 				err = e | ||||
| 			} else { | ||||
| 				err = fmt.Errorf("panic occurred: %v", r) | ||||
| 			} | ||||
| 		} | ||||
| 	}() | ||||
| 
 | ||||
| 	// Call the handler
 | ||||
| 	result, err = riskyCallRPCHandler(logger, handler, params) | ||||
| 	return result, err // do not combine these two lines into one, as it breaks the above defer function's setting of err
 | ||||
| } | ||||
| 
 | ||||
| func riskyCallRPCHandler(logger zerolog.Logger, handler RPCHandler, params map[string]any) (any, error) { | ||||
| 	handlerValue := reflect.ValueOf(handler.Func) | ||||
| 	handlerType := handlerValue.Type() | ||||
| 
 | ||||
|  | @ -384,20 +520,24 @@ func callRPCHandler(handler RPCHandler, params map[string]interface{}) (interfac | |||
| 	} | ||||
| 
 | ||||
| 	numParams := handlerType.NumIn() | ||||
| 	args := make([]reflect.Value, numParams) | ||||
| 	// Get the parameter names from the RPCHandler
 | ||||
| 	paramNames := handler.Params | ||||
| 	paramNames := handler.Params // Get the parameter names from the RPCHandler
 | ||||
| 
 | ||||
| 	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) | ||||
| 		paramName := paramNames[i] | ||||
| 		paramValue, ok := params[paramName] | ||||
| 		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) | ||||
|  | @ -414,7 +554,7 @@ func callRPCHandler(handler RPCHandler, params map[string]interface{}) (interfac | |||
| 						if elemValue.Kind() == reflect.Float64 && paramType.Elem().Kind() == reflect.Uint8 { | ||||
| 							intValue := int(elemValue.Float()) | ||||
| 							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)) | ||||
| 						} else { | ||||
|  | @ -430,12 +570,12 @@ func callRPCHandler(handler RPCHandler, params map[string]interface{}) (interfac | |||
| 			} else if paramType.Kind() == reflect.Struct && convertedValue.Kind() == reflect.Map { | ||||
| 				jsonData, err := json.Marshal(convertedValue.Interface()) | ||||
| 				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() | ||||
| 				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() | ||||
| 			} else { | ||||
|  | @ -446,6 +586,7 @@ func callRPCHandler(handler RPCHandler, params map[string]interface{}) (interfac | |||
| 		} | ||||
| 	} | ||||
| 
 | ||||
| 	logger.Trace().Msg("Calling RPC handler") | ||||
| 	results := handlerValue.Call(args) | ||||
| 
 | ||||
| 	if len(results) == 0 { | ||||
|  | @ -453,55 +594,62 @@ func callRPCHandler(handler RPCHandler, params map[string]interface{}) (interfac | |||
| 	} | ||||
| 
 | ||||
| 	if len(results) == 1 { | ||||
| 		if results[0].Type().Implements(reflect.TypeOf((*error)(nil)).Elem()) { | ||||
| 			if !results[0].IsNil() { | ||||
| 				return nil, results[0].Interface().(error) | ||||
| 		if ok, err := asError(results[0]); ok { | ||||
| 			return nil, err | ||||
| 		} | ||||
| 		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 | ||||
| 	} | ||||
| 
 | ||||
| 	if len(results) == 2 && results[1].Type().Implements(reflect.TypeOf((*error)(nil)).Elem()) { | ||||
| 		if !results[1].IsNil() { | ||||
| 			return nil, results[1].Interface().(error) | ||||
| 		} | ||||
| 		return results[0].Interface(), nil | ||||
| 	} | ||||
| 
 | ||||
| 	return nil, errors.New("unexpected return values from handler") | ||||
| 	return nil, fmt.Errorf("too many return values from handler: %d", len(results)) | ||||
| } | ||||
| 
 | ||||
| type RPCHandler struct { | ||||
| 	Func   interface{} | ||||
| 	Params []string | ||||
| 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) { | ||||
| 	logger.Infof("[jsonrpc.go:rpcSetMassStorageMode] Setting mass storage mode to: %s", mode) | ||||
| 	logger.Info().Str("mode", mode).Msg("Setting mass storage mode") | ||||
| 	var cdrom bool | ||||
| 	if mode == "cdrom" { | ||||
| 	switch mode { | ||||
| 	case "cdrom": | ||||
| 		cdrom = true | ||||
| 	} else if mode != "file" { | ||||
| 		logger.Infof("[jsonrpc.go:rpcSetMassStorageMode] Invalid mode provided: %s", mode) | ||||
| 	case "file": | ||||
| 		cdrom = false | ||||
| 	default: | ||||
| 		logger.Info().Str("mode", mode).Msg("Invalid mode provided") | ||||
| 		return "", fmt.Errorf("invalid mode: %s", mode) | ||||
| 	} | ||||
| 
 | ||||
| 	logger.Infof("[jsonrpc.go:rpcSetMassStorageMode] Setting mass storage mode to: %s", mode) | ||||
| 	logger.Info().Str("mode", mode).Msg("Setting mass storage mode") | ||||
| 
 | ||||
| 	err := setMassStorageMode(cdrom) | ||||
| 	if err != nil { | ||||
| 		return "", fmt.Errorf("failed to set mass storage mode: %w", err) | ||||
| 	} | ||||
| 
 | ||||
| 	logger.Infof("[jsonrpc.go:rpcSetMassStorageMode] Mass storage mode set to %s", mode) | ||||
| 	logger.Info().Str("mode", mode).Msg("Mass storage mode set") | ||||
| 
 | ||||
| 	// Get the updated mode after setting
 | ||||
| 	return rpcGetMassStorageMode() | ||||
| } | ||||
| 
 | ||||
| func rpcGetMassStorageMode() (string, error) { | ||||
| 	cdrom, err := getMassStorageMode() | ||||
| 	cdrom, err := getMassStorageCDROMEnabled() | ||||
| 	if err != nil { | ||||
| 		return "", fmt.Errorf("failed to get mass storage mode: %w", err) | ||||
| 	} | ||||
|  | @ -563,15 +711,16 @@ func rpcResetConfig() error { | |||
| 		return fmt.Errorf("failed to reset config: %w", err) | ||||
| 	} | ||||
| 
 | ||||
| 	logger.Info("Configuration reset to default") | ||||
| 	logger.Info().Msg("Configuration reset to default") | ||||
| 	return nil | ||||
| } | ||||
| 
 | ||||
| type DCPowerState struct { | ||||
| 	IsOn    bool    `json:"isOn"` | ||||
| 	Voltage float64 `json:"voltage"` | ||||
| 	Current float64 `json:"current"` | ||||
| 	Power   float64 `json:"power"` | ||||
| 	IsOn         bool    `json:"isOn"` | ||||
| 	Voltage      float64 `json:"voltage"` | ||||
| 	Current      float64 `json:"current"` | ||||
| 	Power        float64 `json:"power"` | ||||
| 	RestoreState int     `json:"restoreState"` | ||||
| } | ||||
| 
 | ||||
| func rpcGetDCPowerState() (DCPowerState, error) { | ||||
|  | @ -579,7 +728,7 @@ func rpcGetDCPowerState() (DCPowerState, error) { | |||
| } | ||||
| 
 | ||||
| func rpcSetDCPowerState(enabled bool) error { | ||||
| 	logger.Infof("[jsonrpc.go:rpcSetDCPowerState] Setting DC power state to: %v", enabled) | ||||
| 	logger.Info().Bool("enabled", enabled).Msg("Setting DC power state") | ||||
| 	err := setDCPowerState(enabled) | ||||
| 	if err != nil { | ||||
| 		return fmt.Errorf("failed to set DC power state: %w", err) | ||||
|  | @ -587,6 +736,15 @@ func rpcSetDCPowerState(enabled bool) error { | |||
| 	return nil | ||||
| } | ||||
| 
 | ||||
| func rpcSetDCRestoreState(state int) error { | ||||
| 	logger.Info().Int("state", state).Msg("Setting DC restore state") | ||||
| 	err := setDCRestoreState(state) | ||||
| 	if err != nil { | ||||
| 		return fmt.Errorf("failed to set DC restore state: %w", err) | ||||
| 	} | ||||
| 	return nil | ||||
| } | ||||
| 
 | ||||
| func rpcGetActiveExtension() (string, error) { | ||||
| 	return config.ActiveExtension, nil | ||||
| } | ||||
|  | @ -595,34 +753,36 @@ func rpcSetActiveExtension(extensionId string) error { | |||
| 	if config.ActiveExtension == extensionId { | ||||
| 		return nil | ||||
| 	} | ||||
| 	if config.ActiveExtension == "atx-power" { | ||||
| 	switch config.ActiveExtension { | ||||
| 	case "atx-power": | ||||
| 		_ = unmountATXControl() | ||||
| 	} else if config.ActiveExtension == "dc-power" { | ||||
| 	case "dc-power": | ||||
| 		_ = unmountDCControl() | ||||
| 	} | ||||
| 	config.ActiveExtension = extensionId | ||||
| 	if err := SaveConfig(); err != nil { | ||||
| 		return fmt.Errorf("failed to save config: %w", err) | ||||
| 	} | ||||
| 	if extensionId == "atx-power" { | ||||
| 	switch extensionId { | ||||
| 	case "atx-power": | ||||
| 		_ = mountATXControl() | ||||
| 	} else if extensionId == "dc-power" { | ||||
| 	case "dc-power": | ||||
| 		_ = mountDCControl() | ||||
| 	} | ||||
| 	return nil | ||||
| } | ||||
| 
 | ||||
| func rpcSetATXPowerAction(action string) error { | ||||
| 	logger.Debugf("[jsonrpc.go:rpcSetATXPowerAction] Executing ATX power action: %s", action) | ||||
| 	logger.Debug().Str("action", action).Msg("Executing ATX power action") | ||||
| 	switch action { | ||||
| 	case "power-short": | ||||
| 		logger.Debug("[jsonrpc.go:rpcSetATXPowerAction] Simulating short power button press") | ||||
| 		logger.Debug().Msg("Simulating short power button press") | ||||
| 		return pressATXPowerButton(200 * time.Millisecond) | ||||
| 	case "power-long": | ||||
| 		logger.Debug("[jsonrpc.go:rpcSetATXPowerAction] Simulating long power button press") | ||||
| 		logger.Debug().Msg("Simulating long power button press") | ||||
| 		return pressATXPowerButton(5 * time.Second) | ||||
| 	case "reset": | ||||
| 		logger.Debug("[jsonrpc.go:rpcSetATXPowerAction] Simulating reset button press") | ||||
| 		logger.Debug().Msg("Simulating reset button press") | ||||
| 		return pressATXResetButton(200 * time.Millisecond) | ||||
| 	default: | ||||
| 		return fmt.Errorf("invalid action: %s", action) | ||||
|  | @ -771,9 +931,14 @@ func rpcSetUsbDeviceState(device string, enabled bool) error { | |||
| } | ||||
| 
 | ||||
| func rpcSetCloudUrl(apiUrl string, appUrl string) error { | ||||
| 	currentCloudURL := config.CloudURL | ||||
| 	config.CloudURL = apiUrl | ||||
| 	config.CloudAppURL = appUrl | ||||
| 
 | ||||
| 	if currentCloudURL != apiUrl { | ||||
| 		disconnectCloud(fmt.Errorf("cloud url changed from %s to %s", currentCloudURL, apiUrl)) | ||||
| 	} | ||||
| 
 | ||||
| 	if err := SaveConfig(); err != nil { | ||||
| 		return fmt.Errorf("failed to save config: %w", err) | ||||
| 	} | ||||
|  | @ -781,23 +946,241 @@ func rpcSetCloudUrl(apiUrl string, appUrl string) error { | |||
| 	return nil | ||||
| } | ||||
| 
 | ||||
| var currentScrollSensitivity string = "default" | ||||
| 
 | ||||
| func rpcGetScrollSensitivity() (string, error) { | ||||
| 	return currentScrollSensitivity, nil | ||||
| func rpcGetKeyboardLayout() (string, error) { | ||||
| 	return config.KeyboardLayout, nil | ||||
| } | ||||
| 
 | ||||
| func rpcSetScrollSensitivity(sensitivity string) error { | ||||
| 	currentScrollSensitivity = sensitivity | ||||
| func rpcSetKeyboardLayout(layout string) error { | ||||
| 	config.KeyboardLayout = layout | ||||
| 	if err := SaveConfig(); err != nil { | ||||
| 		return fmt.Errorf("failed to save config: %w", err) | ||||
| 	} | ||||
| 	return nil | ||||
| } | ||||
| 
 | ||||
| func getKeyboardMacros() (any, error) { | ||||
| 	macros := make([]KeyboardMacro, len(config.KeyboardMacros)) | ||||
| 	copy(macros, config.KeyboardMacros) | ||||
| 
 | ||||
| 	return macros, nil | ||||
| } | ||||
| 
 | ||||
| type KeyboardMacrosParams struct { | ||||
| 	Macros []any `json:"macros"` | ||||
| } | ||||
| 
 | ||||
| func setKeyboardMacros(params KeyboardMacrosParams) (any, error) { | ||||
| 	if params.Macros == nil { | ||||
| 		return nil, fmt.Errorf("missing or invalid macros parameter") | ||||
| 	} | ||||
| 
 | ||||
| 	newMacros := make([]KeyboardMacro, 0, len(params.Macros)) | ||||
| 
 | ||||
| 	for i, item := range params.Macros { | ||||
| 		macroMap, ok := item.(map[string]any) | ||||
| 		if !ok { | ||||
| 			return nil, fmt.Errorf("invalid macro at index %d", i) | ||||
| 		} | ||||
| 
 | ||||
| 		id, _ := macroMap["id"].(string) | ||||
| 		if id == "" { | ||||
| 			id = fmt.Sprintf("macro-%d", time.Now().UnixNano()) | ||||
| 		} | ||||
| 
 | ||||
| 		name, _ := macroMap["name"].(string) | ||||
| 
 | ||||
| 		sortOrder := i + 1 | ||||
| 		if sortOrderFloat, ok := macroMap["sortOrder"].(float64); ok { | ||||
| 			sortOrder = int(sortOrderFloat) | ||||
| 		} | ||||
| 
 | ||||
| 		steps := []KeyboardMacroStep{} | ||||
| 		if stepsArray, ok := macroMap["steps"].([]any); ok { | ||||
| 			for _, stepItem := range stepsArray { | ||||
| 				stepMap, ok := stepItem.(map[string]any) | ||||
| 				if !ok { | ||||
| 					continue | ||||
| 				} | ||||
| 
 | ||||
| 				step := KeyboardMacroStep{} | ||||
| 
 | ||||
| 				if keysArray, ok := stepMap["keys"].([]any); ok { | ||||
| 					for _, k := range keysArray { | ||||
| 						if keyStr, ok := k.(string); ok { | ||||
| 							step.Keys = append(step.Keys, keyStr) | ||||
| 						} | ||||
| 					} | ||||
| 				} | ||||
| 
 | ||||
| 				if modsArray, ok := stepMap["modifiers"].([]any); ok { | ||||
| 					for _, m := range modsArray { | ||||
| 						if modStr, ok := m.(string); ok { | ||||
| 							step.Modifiers = append(step.Modifiers, modStr) | ||||
| 						} | ||||
| 					} | ||||
| 				} | ||||
| 
 | ||||
| 				if delay, ok := stepMap["delay"].(float64); ok { | ||||
| 					step.Delay = int(delay) | ||||
| 				} | ||||
| 
 | ||||
| 				steps = append(steps, step) | ||||
| 			} | ||||
| 		} | ||||
| 
 | ||||
| 		macro := KeyboardMacro{ | ||||
| 			ID:        id, | ||||
| 			Name:      name, | ||||
| 			Steps:     steps, | ||||
| 			SortOrder: sortOrder, | ||||
| 		} | ||||
| 
 | ||||
| 		if err := macro.Validate(); err != nil { | ||||
| 			return nil, fmt.Errorf("invalid macro at index %d: %w", i, err) | ||||
| 		} | ||||
| 
 | ||||
| 		newMacros = append(newMacros, macro) | ||||
| 	} | ||||
| 
 | ||||
| 	config.KeyboardMacros = newMacros | ||||
| 
 | ||||
| 	if err := SaveConfig(); err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
| 
 | ||||
| 	return nil, nil | ||||
| } | ||||
| 
 | ||||
| func rpcGetLocalLoopbackOnly() (bool, error) { | ||||
| 	return config.LocalLoopbackOnly, nil | ||||
| } | ||||
| 
 | ||||
| func rpcSetLocalLoopbackOnly(enabled bool) error { | ||||
| 	// Check if the setting is actually changing
 | ||||
| 	if config.LocalLoopbackOnly == enabled { | ||||
| 		return nil | ||||
| 	} | ||||
| 
 | ||||
| 	// Update the setting
 | ||||
| 	config.LocalLoopbackOnly = enabled | ||||
| 	if err := SaveConfig(); err != nil { | ||||
| 		return fmt.Errorf("failed to save config: %w", err) | ||||
| 	} | ||||
| 
 | ||||
| 	return nil | ||||
| } | ||||
| 
 | ||||
| var ( | ||||
| 	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{ | ||||
| 	"ping":                   {Func: rpcPing}, | ||||
| 	"reboot":                 {Func: rpcReboot, Params: []string{"force"}}, | ||||
| 	"getDeviceID":            {Func: rpcGetDeviceID}, | ||||
| 	"deregisterDevice":       {Func: rpcDeregisterDevice}, | ||||
| 	"getCloudState":          {Func: rpcGetCloudState}, | ||||
| 	"getNetworkState":        {Func: rpcGetNetworkState}, | ||||
| 	"getNetworkSettings":     {Func: rpcGetNetworkSettings}, | ||||
| 	"setNetworkSettings":     {Func: rpcSetNetworkSettings, Params: []string{"settings"}}, | ||||
| 	"renewDHCPLease":         {Func: rpcRenewDHCPLease}, | ||||
| 	"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"}}, | ||||
| 	"relMouseReport":         {Func: rpcRelMouseReport, Params: []string{"dx", "dy", "buttons"}}, | ||||
| 	"wheelReport":            {Func: rpcWheelReport, Params: []string{"wheelY"}}, | ||||
|  | @ -807,6 +1190,9 @@ var rpcHandlers = map[string]RPCHandler{ | |||
| 	"rpcMountBuiltInImage":   {Func: rpcMountBuiltInImage, Params: []string{"filename"}}, | ||||
| 	"setJigglerState":        {Func: rpcSetJigglerState, Params: []string{"enabled"}}, | ||||
| 	"getJigglerState":        {Func: rpcGetJigglerState}, | ||||
| 	"setJigglerConfig":       {Func: rpcSetJigglerConfig, Params: []string{"jigglerConfig"}}, | ||||
| 	"getJigglerConfig":       {Func: rpcGetJigglerConfig}, | ||||
| 	"getTimezones":           {Func: rpcGetTimezones}, | ||||
| 	"sendWOLMagicPacket":     {Func: rpcSendWOLMagicPacket, Params: []string{"macAddress"}}, | ||||
| 	"getStreamQualityFactor": {Func: rpcGetStreamQualityFactor}, | ||||
| 	"setStreamQualityFactor": {Func: rpcSetStreamQualityFactor, Params: []string{"factor"}}, | ||||
|  | @ -816,12 +1202,15 @@ var rpcHandlers = map[string]RPCHandler{ | |||
| 	"setEDID":                {Func: rpcSetEDID, Params: []string{"edid"}}, | ||||
| 	"getDevChannelState":     {Func: rpcGetDevChannelState}, | ||||
| 	"setDevChannelState":     {Func: rpcSetDevChannelState, Params: []string{"enabled"}}, | ||||
| 	"getLocalVersion":        {Func: rpcGetLocalVersion}, | ||||
| 	"getUpdateStatus":        {Func: rpcGetUpdateStatus}, | ||||
| 	"tryUpdate":              {Func: rpcTryUpdate}, | ||||
| 	"getDevModeState":        {Func: rpcGetDevModeState}, | ||||
| 	"setDevModeState":        {Func: rpcSetDevModeState, Params: []string{"enabled"}}, | ||||
| 	"getSSHKeyState":         {Func: rpcGetSSHKeyState}, | ||||
| 	"setSSHKeyState":         {Func: rpcSetSSHKeyState, Params: []string{"sshKey"}}, | ||||
| 	"getTLSState":            {Func: rpcGetTLSState}, | ||||
| 	"setTLSState":            {Func: rpcSetTLSState, Params: []string{"state"}}, | ||||
| 	"setMassStorageMode":     {Func: rpcSetMassStorageMode, Params: []string{"mode"}}, | ||||
| 	"getMassStorageMode":     {Func: rpcGetMassStorageMode}, | ||||
| 	"isUpdatePending":        {Func: rpcIsUpdatePending}, | ||||
|  | @ -833,7 +1222,6 @@ var rpcHandlers = map[string]RPCHandler{ | |||
| 	"getVirtualMediaState":   {Func: rpcGetVirtualMediaState}, | ||||
| 	"getStorageSpace":        {Func: rpcGetStorageSpace}, | ||||
| 	"mountWithHTTP":          {Func: rpcMountWithHTTP, Params: []string{"url", "mode"}}, | ||||
| 	"mountWithWebRTC":        {Func: rpcMountWithWebRTC, Params: []string{"filename", "size", "mode"}}, | ||||
| 	"mountWithStorage":       {Func: rpcMountWithStorage, Params: []string{"filename", "mode"}}, | ||||
| 	"listStorageFiles":       {Func: rpcListStorageFiles}, | ||||
| 	"deleteStorageFile":      {Func: rpcDeleteStorageFile, Params: []string{"filename"}}, | ||||
|  | @ -841,10 +1229,13 @@ var rpcHandlers = map[string]RPCHandler{ | |||
| 	"getWakeOnLanDevices":    {Func: rpcGetWakeOnLanDevices}, | ||||
| 	"setWakeOnLanDevices":    {Func: rpcSetWakeOnLanDevices, Params: []string{"params"}}, | ||||
| 	"resetConfig":            {Func: rpcResetConfig}, | ||||
| 	"setDisplayRotation":     {Func: rpcSetDisplayRotation, Params: []string{"params"}}, | ||||
| 	"getDisplayRotation":     {Func: rpcGetDisplayRotation}, | ||||
| 	"setBacklightSettings":   {Func: rpcSetBacklightSettings, Params: []string{"params"}}, | ||||
| 	"getBacklightSettings":   {Func: rpcGetBacklightSettings}, | ||||
| 	"getDCPowerState":        {Func: rpcGetDCPowerState}, | ||||
| 	"setDCPowerState":        {Func: rpcSetDCPowerState, Params: []string{"enabled"}}, | ||||
| 	"setDCRestoreState":      {Func: rpcSetDCRestoreState, Params: []string{"state"}}, | ||||
| 	"getActiveExtension":     {Func: rpcGetActiveExtension}, | ||||
| 	"setActiveExtension":     {Func: rpcSetActiveExtension, Params: []string{"extensionId"}}, | ||||
| 	"getATXState":            {Func: rpcGetATXState}, | ||||
|  | @ -855,6 +1246,10 @@ var rpcHandlers = map[string]RPCHandler{ | |||
| 	"setUsbDevices":          {Func: rpcSetUsbDevices, Params: []string{"devices"}}, | ||||
| 	"setUsbDeviceState":      {Func: rpcSetUsbDeviceState, Params: []string{"device", "enabled"}}, | ||||
| 	"setCloudUrl":            {Func: rpcSetCloudUrl, Params: []string{"apiUrl", "appUrl"}}, | ||||
| 	"getScrollSensitivity":   {Func: rpcGetScrollSensitivity}, | ||||
| 	"setScrollSensitivity":   {Func: rpcSetScrollSensitivity, Params: []string{"sensitivity"}}, | ||||
| 	"getKeyboardLayout":      {Func: rpcGetKeyboardLayout}, | ||||
| 	"setKeyboardLayout":      {Func: rpcSetKeyboardLayout, Params: []string{"layout"}}, | ||||
| 	"getKeyboardMacros":      {Func: getKeyboardMacros}, | ||||
| 	"setKeyboardMacros":      {Func: setKeyboardMacros, Params: []string{"params"}}, | ||||
| 	"getLocalLoopbackOnly":   {Func: rpcGetLocalLoopbackOnly}, | ||||
| 	"setLocalLoopbackOnly":   {Func: rpcSetLocalLoopbackOnly, Params: []string{"enabled"}}, | ||||
| } | ||||
|  |  | |||
							
								
								
									
										35
									
								
								log.go
								
								
								
								
							
							
						
						
									
										35
									
								
								log.go
								
								
								
								
							|  | @ -1,8 +1,33 @@ | |||
| package kvm | ||||
| 
 | ||||
| import "github.com/pion/logging" | ||||
| import ( | ||||
| 	"github.com/jetkvm/kvm/internal/logging" | ||||
| 	"github.com/rs/zerolog" | ||||
| ) | ||||
| 
 | ||||
| // we use logging framework from pion
 | ||||
| // ref: https://github.com/pion/webrtc/wiki/Debugging-WebRTC
 | ||||
| var logger = logging.NewDefaultLoggerFactory().NewLogger("jetkvm") | ||||
| var cloudLogger = logging.NewDefaultLoggerFactory().NewLogger("cloud") | ||||
| func ErrorfL(l *zerolog.Logger, format string, err error, args ...any) error { | ||||
| 	return logging.ErrorfL(l, format, err, args...) | ||||
| } | ||||
| 
 | ||||
| var ( | ||||
| 	logger          = logging.GetSubsystemLogger("jetkvm") | ||||
| 	networkLogger   = logging.GetSubsystemLogger("network") | ||||
| 	cloudLogger     = logging.GetSubsystemLogger("cloud") | ||||
| 	websocketLogger = logging.GetSubsystemLogger("websocket") | ||||
| 	webrtcLogger    = logging.GetSubsystemLogger("webrtc") | ||||
| 	nativeLogger    = logging.GetSubsystemLogger("native") | ||||
| 	nbdLogger       = logging.GetSubsystemLogger("nbd") | ||||
| 	timesyncLogger  = logging.GetSubsystemLogger("timesync") | ||||
| 	jsonRpcLogger   = logging.GetSubsystemLogger("jsonrpc") | ||||
| 	hidRPCLogger    = logging.GetSubsystemLogger("hidrpc") | ||||
| 	watchdogLogger  = logging.GetSubsystemLogger("watchdog") | ||||
| 	websecureLogger = logging.GetSubsystemLogger("websecure") | ||||
| 	otaLogger       = logging.GetSubsystemLogger("ota") | ||||
| 	serialLogger    = logging.GetSubsystemLogger("serial") | ||||
| 	terminalLogger  = logging.GetSubsystemLogger("terminal") | ||||
| 	displayLogger   = logging.GetSubsystemLogger("display") | ||||
| 	wolLogger       = logging.GetSubsystemLogger("wol") | ||||
| 	usbLogger       = logging.GetSubsystemLogger("usb") | ||||
| 	// external components
 | ||||
| 	ginLogger = logging.GetSubsystemLogger("gin") | ||||
| ) | ||||
|  |  | |||
							
								
								
									
										87
									
								
								main.go
								
								
								
								
							
							
						
						
									
										87
									
								
								main.go
								
								
								
								
							|  | @ -14,25 +14,55 @@ import ( | |||
| var appCtx context.Context | ||||
| 
 | ||||
| func Main() { | ||||
| 	LoadConfig() | ||||
| 
 | ||||
| 	var cancel context.CancelFunc | ||||
| 	appCtx, cancel = context.WithCancel(context.Background()) | ||||
| 	defer cancel() | ||||
| 	logger.Info("Starting JetKvm") | ||||
| 
 | ||||
| 	systemVersionLocal, appVersionLocal, err := GetLocalVersion() | ||||
| 	if err != nil { | ||||
| 		logger.Warn().Err(err).Msg("failed to get local version") | ||||
| 	} | ||||
| 
 | ||||
| 	logger.Info(). | ||||
| 		Interface("system_version", systemVersionLocal). | ||||
| 		Interface("app_version", appVersionLocal). | ||||
| 		Msg("starting JetKVM") | ||||
| 
 | ||||
| 	go runWatchdog() | ||||
| 	go confirmCurrentSystem() | ||||
| 
 | ||||
| 	http.DefaultClient.Timeout = 1 * time.Minute | ||||
| 	LoadConfig() | ||||
| 	logger.Debug("config loaded") | ||||
| 
 | ||||
| 	err := rootcerts.UpdateDefaultTransport() | ||||
| 	err = rootcerts.UpdateDefaultTransport() | ||||
| 	if err != nil { | ||||
| 		logger.Errorf("failed to load CA certs: %v", err) | ||||
| 		logger.Warn().Err(err).Msg("failed to load Root CA certificates") | ||||
| 	} | ||||
| 	logger.Info(). | ||||
| 		Int("ca_certs_loaded", len(rootcerts.Certs())). | ||||
| 		Msg("loaded Root CA certificates") | ||||
| 
 | ||||
| 	// Initialize network
 | ||||
| 	if err := initNetwork(); err != nil { | ||||
| 		logger.Error().Err(err).Msg("failed to initialize network") | ||||
| 		os.Exit(1) | ||||
| 	} | ||||
| 
 | ||||
| 	go TimeSyncLoop() | ||||
| 	// Initialize time sync
 | ||||
| 	initTimeSync() | ||||
| 	timeSync.Start() | ||||
| 
 | ||||
| 	// Initialize mDNS
 | ||||
| 	if err := initMdns(); err != nil { | ||||
| 		logger.Error().Err(err).Msg("failed to initialize mDNS") | ||||
| 		os.Exit(1) | ||||
| 	} | ||||
| 
 | ||||
| 	// Initialize native ctrl socket server
 | ||||
| 	StartNativeCtrlSocketServer() | ||||
| 
 | ||||
| 	// Initialize native video socket server
 | ||||
| 	StartNativeVideoSocketServer() | ||||
| 
 | ||||
| 	initPrometheus() | ||||
|  | @ -40,48 +70,71 @@ func Main() { | |||
| 	go func() { | ||||
| 		err = ExtractAndRunNativeBin() | ||||
| 		if err != nil { | ||||
| 			logger.Errorf("failed to extract and run native bin: %v", err) | ||||
| 			logger.Warn().Err(err).Msg("failed to extract and run native bin") | ||||
| 			//TODO: prepare an error message screen buffer to show on kvm screen
 | ||||
| 		} | ||||
| 	}() | ||||
| 
 | ||||
| 	// initialize usb gadget
 | ||||
| 	initUsbGadget() | ||||
| 	if err := setInitialVirtualMediaState(); err != nil { | ||||
| 		logger.Warn().Err(err).Msg("failed to set initial virtual media state") | ||||
| 	} | ||||
| 
 | ||||
| 	if err := initImagesFolder(); err != nil { | ||||
| 		logger.Warn().Err(err).Msg("failed to init images folder") | ||||
| 	} | ||||
| 	initJiggler() | ||||
| 
 | ||||
| 	// initialize display
 | ||||
| 	initDisplay() | ||||
| 
 | ||||
| 	go func() { | ||||
| 		time.Sleep(15 * time.Minute) | ||||
| 		for { | ||||
| 			logger.Debugf("UPDATING - Auto update enabled: %v", config.AutoUpdateEnabled) | ||||
| 			logger.Debug().Bool("auto_update_enabled", config.AutoUpdateEnabled).Msg("UPDATING") | ||||
| 			if !config.AutoUpdateEnabled { | ||||
| 				return | ||||
| 			} | ||||
| 
 | ||||
| 			if 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 { | ||||
| 				logger.Debugf("skipping update since a session is active") | ||||
| 				logger.Debug().Msg("skipping update since a session is active") | ||||
| 				time.Sleep(1 * time.Minute) | ||||
| 				continue | ||||
| 			} | ||||
| 
 | ||||
| 			includePreRelease := config.IncludePreRelease | ||||
| 			err = TryUpdate(context.Background(), GetDeviceID(), includePreRelease) | ||||
| 			if err != nil { | ||||
| 				logger.Errorf("failed to auto update: %v", err) | ||||
| 				logger.Warn().Err(err).Msg("failed to auto update") | ||||
| 			} | ||||
| 
 | ||||
| 			time.Sleep(1 * time.Hour) | ||||
| 		} | ||||
| 	}() | ||||
| 	//go RunFuseServer()
 | ||||
| 	go RunWebServer() | ||||
| 
 | ||||
| 	go RunWebSecureServer() | ||||
| 	// Web secure server is started only if TLS mode is enabled
 | ||||
| 	if config.TLSMode != "" { | ||||
| 		go RunWebSecureServer() | ||||
| 	} | ||||
| 	// If the cloud token isn't set, the client won't be started by default.
 | ||||
| 	// However, if the user adopts the device via the web interface, handleCloudRegister will start the client.
 | ||||
| 	if config.CloudToken != "" { | ||||
| 		go RunWebsocketClient() | ||||
| 		startWebSecureServer() | ||||
| 	} | ||||
| 
 | ||||
| 	// As websocket client already checks if the cloud token is set, we can start it here.
 | ||||
| 	go RunWebsocketClient() | ||||
| 
 | ||||
| 	initSerialPort() | ||||
| 	sigs := make(chan os.Signal, 1) | ||||
| 	signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM) | ||||
| 	<-sigs | ||||
| 	logger.Info("JetKVM Shutting Down") | ||||
| 	logger.Info().Msg("JetKVM Shutting Down") | ||||
| 	//if fuseServer != nil {
 | ||||
| 	//	err := setMassStorageImage(" ")
 | ||||
| 	//	if err != nil {
 | ||||
|  |  | |||
|  | @ -0,0 +1,26 @@ | |||
| package kvm | ||||
| 
 | ||||
| import ( | ||||
| 	"github.com/jetkvm/kvm/internal/mdns" | ||||
| ) | ||||
| 
 | ||||
| var mDNS *mdns.MDNS | ||||
| 
 | ||||
| func initMdns() error { | ||||
| 	m, err := mdns.NewMDNS(&mdns.MDNSOptions{ | ||||
| 		Logger: logger, | ||||
| 		LocalNames: []string{ | ||||
| 			networkState.GetHostname(), | ||||
| 			networkState.GetFQDN(), | ||||
| 		}, | ||||
| 		ListenOptions: config.NetworkConfig.GetMDNSMode(), | ||||
| 	}) | ||||
| 	if err != nil { | ||||
| 		return err | ||||
| 	} | ||||
| 
 | ||||
| 	// do not start the server yet, as we need to wait for the network state to be set
 | ||||
| 	mDNS = m | ||||
| 
 | ||||
| 	return nil | ||||
| } | ||||
							
								
								
									
										245
									
								
								native.go
								
								
								
								
							
							
						
						
									
										245
									
								
								native.go
								
								
								
								
							|  | @ -3,13 +3,14 @@ package kvm | |||
| import ( | ||||
| 	"bytes" | ||||
| 	"encoding/json" | ||||
| 	"errors" | ||||
| 	"fmt" | ||||
| 	"io" | ||||
| 	"net" | ||||
| 	"os" | ||||
| 	"os/exec" | ||||
| 	"strings" | ||||
| 	"sync" | ||||
| 	"syscall" | ||||
| 	"time" | ||||
| 
 | ||||
| 	"github.com/jetkvm/kvm/resource" | ||||
|  | @ -20,18 +21,18 @@ import ( | |||
| var ctrlSocketConn net.Conn | ||||
| 
 | ||||
| type CtrlAction struct { | ||||
| 	Action string                 `json:"action"` | ||||
| 	Seq    int32                  `json:"seq,omitempty"` | ||||
| 	Params map[string]interface{} `json:"params,omitempty"` | ||||
| 	Action string         `json:"action"` | ||||
| 	Seq    int32          `json:"seq,omitempty"` | ||||
| 	Params map[string]any `json:"params,omitempty"` | ||||
| } | ||||
| 
 | ||||
| type CtrlResponse struct { | ||||
| 	Seq    int32                  `json:"seq,omitempty"` | ||||
| 	Error  string                 `json:"error,omitempty"` | ||||
| 	Errno  int32                  `json:"errno,omitempty"` | ||||
| 	Result map[string]interface{} `json:"result,omitempty"` | ||||
| 	Event  string                 `json:"event,omitempty"` | ||||
| 	Data   json.RawMessage        `json:"data,omitempty"` | ||||
| 	Seq    int32           `json:"seq,omitempty"` | ||||
| 	Error  string          `json:"error,omitempty"` | ||||
| 	Errno  int32           `json:"errno,omitempty"` | ||||
| 	Result map[string]any  `json:"result,omitempty"` | ||||
| 	Event  string          `json:"event,omitempty"` | ||||
| 	Data   json.RawMessage `json:"data,omitempty"` | ||||
| } | ||||
| 
 | ||||
| type EventHandler func(event CtrlResponse) | ||||
|  | @ -42,7 +43,12 @@ var ongoingRequests = make(map[int32]chan *CtrlResponse) | |||
| 
 | ||||
| var lock = &sync.Mutex{} | ||||
| 
 | ||||
| func CallCtrlAction(action string, params map[string]interface{}) (*CtrlResponse, error) { | ||||
| var ( | ||||
| 	nativeCmd     *exec.Cmd | ||||
| 	nativeCmdLock = &sync.Mutex{} | ||||
| ) | ||||
| 
 | ||||
| func CallCtrlAction(action string, params map[string]any) (*CtrlResponse, error) { | ||||
| 	lock.Lock() | ||||
| 	defer lock.Unlock() | ||||
| 	ctrlAction := CtrlAction{ | ||||
|  | @ -61,25 +67,33 @@ func CallCtrlAction(action string, params map[string]interface{}) (*CtrlResponse | |||
| 		return nil, fmt.Errorf("error marshaling ctrl action: %w", err) | ||||
| 	} | ||||
| 
 | ||||
| 	logger.Infof("sending ctrl action: %s", string(jsonData)) | ||||
| 	scopedLogger := nativeLogger.With(). | ||||
| 		Str("action", ctrlAction.Action). | ||||
| 		Interface("params", ctrlAction.Params).Logger() | ||||
| 
 | ||||
| 	scopedLogger.Debug().Msg("sending ctrl action") | ||||
| 
 | ||||
| 	err = WriteCtrlMessage(jsonData) | ||||
| 	if err != nil { | ||||
| 		delete(ongoingRequests, ctrlAction.Seq) | ||||
| 		return nil, fmt.Errorf("error writing ctrl message: %w", err) | ||||
| 		return nil, ErrorfL(&scopedLogger, "error writing ctrl message", err) | ||||
| 	} | ||||
| 
 | ||||
| 	select { | ||||
| 	case response := <-responseChan: | ||||
| 		delete(ongoingRequests, seq) | ||||
| 		if response.Error != "" { | ||||
| 			return nil, fmt.Errorf("error native response: %s", response.Error) | ||||
| 			return nil, ErrorfL( | ||||
| 				&scopedLogger, | ||||
| 				"error native response: %s", | ||||
| 				errors.New(response.Error), | ||||
| 			) | ||||
| 		} | ||||
| 		return response, nil | ||||
| 	case <-time.After(5 * time.Second): | ||||
| 		close(responseChan) | ||||
| 		delete(ongoingRequests, seq) | ||||
| 		return nil, fmt.Errorf("timeout waiting for response") | ||||
| 		return nil, ErrorfL(&scopedLogger, "timeout waiting for response", nil) | ||||
| 	} | ||||
| } | ||||
| 
 | ||||
|  | @ -101,33 +115,47 @@ func waitCtrlClientConnected() { | |||
| } | ||||
| 
 | ||||
| func StartNativeSocketServer(socketPath string, handleClient func(net.Conn), isCtrl bool) net.Listener { | ||||
| 	scopedLogger := nativeLogger.With(). | ||||
| 		Str("socket_path", socketPath). | ||||
| 		Logger() | ||||
| 
 | ||||
| 	// Remove the socket file if it already exists
 | ||||
| 	if _, err := os.Stat(socketPath); err == nil { | ||||
| 		if err := os.Remove(socketPath); err != nil { | ||||
| 			logger.Errorf("Failed to remove existing socket file %s: %v", socketPath, err) | ||||
| 			scopedLogger.Warn().Err(err).Msg("failed to remove existing socket file") | ||||
| 			os.Exit(1) | ||||
| 		} | ||||
| 	} | ||||
| 
 | ||||
| 	listener, err := net.Listen("unixpacket", socketPath) | ||||
| 	if err != nil { | ||||
| 		logger.Errorf("Failed to start server on %s: %v", socketPath, err) | ||||
| 		scopedLogger.Warn().Err(err).Msg("failed to start server") | ||||
| 		os.Exit(1) | ||||
| 	} | ||||
| 
 | ||||
| 	logger.Infof("Server listening on %s", socketPath) | ||||
| 	scopedLogger.Info().Msg("server listening") | ||||
| 
 | ||||
| 	go func() { | ||||
| 		conn, err := listener.Accept() | ||||
| 		listener.Close() | ||||
| 		if err != nil { | ||||
| 			logger.Errorf("failed to accept sock: %v", err) | ||||
| 		for { | ||||
| 			conn, err := listener.Accept() | ||||
| 
 | ||||
| 			if err != nil { | ||||
| 				scopedLogger.Warn().Err(err).Msg("failed to accept socket") | ||||
| 				continue | ||||
| 			} | ||||
| 			if isCtrl { | ||||
| 				// check if the channel is closed
 | ||||
| 				select { | ||||
| 				case <-ctrlClientConnected: | ||||
| 					scopedLogger.Debug().Msg("ctrl client reconnected") | ||||
| 				default: | ||||
| 					close(ctrlClientConnected) | ||||
| 					scopedLogger.Debug().Msg("first native ctrl socket client connected") | ||||
| 				} | ||||
| 			} | ||||
| 
 | ||||
| 			go handleClient(conn) | ||||
| 		} | ||||
| 		if isCtrl { | ||||
| 			close(ctrlClientConnected) | ||||
| 			logger.Debug("first native ctrl socket client connected") | ||||
| 		} | ||||
| 		handleClient(conn) | ||||
| 	}() | ||||
| 
 | ||||
| 	return listener | ||||
|  | @ -135,20 +163,25 @@ func StartNativeSocketServer(socketPath string, handleClient func(net.Conn), isC | |||
| 
 | ||||
| func StartNativeCtrlSocketServer() { | ||||
| 	nativeCtrlSocketListener = StartNativeSocketServer("/var/run/jetkvm_ctrl.sock", handleCtrlClient, true) | ||||
| 	logger.Debug("native app ctrl sock started") | ||||
| 	nativeLogger.Debug().Msg("native app ctrl sock started") | ||||
| } | ||||
| 
 | ||||
| func StartNativeVideoSocketServer() { | ||||
| 	nativeVideoSocketListener = StartNativeSocketServer("/var/run/jetkvm_video.sock", handleVideoClient, false) | ||||
| 	logger.Debug("native app video sock started") | ||||
| 	nativeLogger.Debug().Msg("native app video sock started") | ||||
| } | ||||
| 
 | ||||
| func handleCtrlClient(conn net.Conn) { | ||||
| 	defer conn.Close() | ||||
| 
 | ||||
| 	logger.Debug("native socket client connected") | ||||
| 	scopedLogger := nativeLogger.With(). | ||||
| 		Str("addr", conn.RemoteAddr().String()). | ||||
| 		Str("type", "ctrl"). | ||||
| 		Logger() | ||||
| 
 | ||||
| 	scopedLogger.Info().Msg("native ctrl socket client connected") | ||||
| 	if ctrlSocketConn != nil { | ||||
| 		logger.Debugf("closing existing native socket connection") | ||||
| 		scopedLogger.Debug().Msg("closing existing native socket connection") | ||||
| 		ctrlSocketConn.Close() | ||||
| 	} | ||||
| 
 | ||||
|  | @ -161,17 +194,19 @@ func handleCtrlClient(conn net.Conn) { | |||
| 	for { | ||||
| 		n, err := conn.Read(readBuf) | ||||
| 		if err != nil { | ||||
| 			logger.Errorf("error reading from ctrl sock: %v", err) | ||||
| 			scopedLogger.Warn().Err(err).Msg("error reading from ctrl sock") | ||||
| 			break | ||||
| 		} | ||||
| 		readMsg := string(readBuf[:n]) | ||||
| 		logger.Tracef("ctrl sock msg: %v", readMsg) | ||||
| 
 | ||||
| 		ctrlResp := CtrlResponse{} | ||||
| 		err = json.Unmarshal([]byte(readMsg), &ctrlResp) | ||||
| 		if err != nil { | ||||
| 			logger.Warnf("error parsing ctrl sock msg: %v", err) | ||||
| 			scopedLogger.Warn().Err(err).Str("data", readMsg).Msg("error parsing ctrl sock msg") | ||||
| 			continue | ||||
| 		} | ||||
| 		scopedLogger.Trace().Interface("data", ctrlResp).Msg("ctrl sock msg") | ||||
| 
 | ||||
| 		if ctrlResp.Seq != 0 { | ||||
| 			responseChan, ok := ongoingRequests[ctrlResp.Seq] | ||||
| 			if ok { | ||||
|  | @ -184,20 +219,25 @@ func handleCtrlClient(conn net.Conn) { | |||
| 		} | ||||
| 	} | ||||
| 
 | ||||
| 	logger.Debug("ctrl sock disconnected") | ||||
| 	scopedLogger.Debug().Msg("ctrl sock disconnected") | ||||
| } | ||||
| 
 | ||||
| func handleVideoClient(conn net.Conn) { | ||||
| 	defer conn.Close() | ||||
| 
 | ||||
| 	logger.Infof("Native video socket client connected: %v", conn.RemoteAddr()) | ||||
| 	scopedLogger := nativeLogger.With(). | ||||
| 		Str("addr", conn.RemoteAddr().String()). | ||||
| 		Str("type", "video"). | ||||
| 		Logger() | ||||
| 
 | ||||
| 	scopedLogger.Info().Msg("native video socket client connected") | ||||
| 
 | ||||
| 	inboundPacket := make([]byte, maxFrameSize) | ||||
| 	lastFrame := time.Now() | ||||
| 	for { | ||||
| 		n, err := conn.Read(inboundPacket) | ||||
| 		if err != nil { | ||||
| 			logger.Warnf("error during read: %v", err) | ||||
| 			scopedLogger.Warn().Err(err).Msg("error during read") | ||||
| 			return | ||||
| 		} | ||||
| 		now := time.Now() | ||||
|  | @ -206,12 +246,64 @@ func handleVideoClient(conn net.Conn) { | |||
| 		if currentSession != nil { | ||||
| 			err := currentSession.VideoTrack.WriteSample(media.Sample{Data: inboundPacket[:n], Duration: sinceLastFrame}) | ||||
| 			if err != nil { | ||||
| 				logger.Warnf("error writing sample: %v", err) | ||||
| 				scopedLogger.Warn().Err(err).Msg("error writing sample") | ||||
| 			} | ||||
| 		} | ||||
| 	} | ||||
| } | ||||
| 
 | ||||
| func startNativeBinaryWithLock(binaryPath string) (*exec.Cmd, error) { | ||||
| 	nativeCmdLock.Lock() | ||||
| 	defer nativeCmdLock.Unlock() | ||||
| 
 | ||||
| 	cmd, err := startNativeBinary(binaryPath) | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
| 	nativeCmd = cmd | ||||
| 	return cmd, nil | ||||
| } | ||||
| 
 | ||||
| func restartNativeBinary(binaryPath string) error { | ||||
| 	time.Sleep(10 * time.Second) | ||||
| 	// restart the binary
 | ||||
| 	nativeLogger.Info().Msg("restarting jetkvm_native binary") | ||||
| 	cmd, err := startNativeBinary(binaryPath) | ||||
| 	if err != nil { | ||||
| 		nativeLogger.Warn().Err(err).Msg("failed to restart binary") | ||||
| 	} | ||||
| 	nativeCmd = cmd | ||||
| 
 | ||||
| 	// reset the display state
 | ||||
| 	time.Sleep(1 * time.Second) | ||||
| 	clearDisplayState() | ||||
| 	updateStaticContents() | ||||
| 	requestDisplayUpdate(true) | ||||
| 
 | ||||
| 	return err | ||||
| } | ||||
| 
 | ||||
| func superviseNativeBinary(binaryPath string) error { | ||||
| 	nativeCmdLock.Lock() | ||||
| 	defer nativeCmdLock.Unlock() | ||||
| 
 | ||||
| 	if nativeCmd == nil || nativeCmd.Process == nil { | ||||
| 		return restartNativeBinary(binaryPath) | ||||
| 	} | ||||
| 
 | ||||
| 	err := nativeCmd.Wait() | ||||
| 
 | ||||
| 	if err == nil { | ||||
| 		nativeLogger.Info().Err(err).Msg("jetkvm_native binary exited with no error") | ||||
| 	} else if exiterr, ok := err.(*exec.ExitError); ok { | ||||
| 		nativeLogger.Warn().Int("exit_code", exiterr.ExitCode()).Msg("jetkvm_native binary exited with error") | ||||
| 	} else { | ||||
| 		nativeLogger.Warn().Err(err).Msg("jetkvm_native binary exited with unknown error") | ||||
| 	} | ||||
| 
 | ||||
| 	return restartNativeBinary(binaryPath) | ||||
| } | ||||
| 
 | ||||
| func ExtractAndRunNativeBin() error { | ||||
| 	binaryPath := "/userdata/jetkvm/bin/jetkvm_native" | ||||
| 	if err := ensureBinaryUpdated(binaryPath); err != nil { | ||||
|  | @ -223,54 +315,74 @@ func ExtractAndRunNativeBin() error { | |||
| 		return fmt.Errorf("failed to make binary executable: %w", err) | ||||
| 	} | ||||
| 	// Run the binary in the background
 | ||||
| 	cmd := exec.Command(binaryPath) | ||||
| 
 | ||||
| 	// Redirect stdout and stderr to the current process
 | ||||
| 	cmd.Stdout = os.Stdout | ||||
| 	cmd.Stderr = os.Stderr | ||||
| 
 | ||||
| 	// Set the process group ID so we can kill the process and its children when this process exits
 | ||||
| 	cmd.SysProcAttr = &syscall.SysProcAttr{ | ||||
| 		Setpgid:   true, | ||||
| 		Pdeathsig: syscall.SIGKILL, | ||||
| 	} | ||||
| 
 | ||||
| 	// Start the command
 | ||||
| 	if err := cmd.Start(); err != nil { | ||||
| 	cmd, err := startNativeBinaryWithLock(binaryPath) | ||||
| 	if err != nil { | ||||
| 		return fmt.Errorf("failed to start binary: %w", err) | ||||
| 	} | ||||
| 
 | ||||
| 	//TODO: add auto restart
 | ||||
| 	// check if the binary is still running every 10 seconds
 | ||||
| 	go func() { | ||||
| 		for { | ||||
| 			select { | ||||
| 			case <-appCtx.Done(): | ||||
| 				nativeLogger.Info().Msg("stopping native binary supervisor") | ||||
| 				return | ||||
| 			default: | ||||
| 				err := superviseNativeBinary(binaryPath) | ||||
| 				if err != nil { | ||||
| 					nativeLogger.Warn().Err(err).Msg("failed to supervise native binary") | ||||
| 					time.Sleep(1 * time.Second) // Add a short delay to prevent rapid successive calls
 | ||||
| 				} | ||||
| 			} | ||||
| 		} | ||||
| 	}() | ||||
| 
 | ||||
| 	go func() { | ||||
| 		<-appCtx.Done() | ||||
| 		logger.Infof("killing process PID: %d", cmd.Process.Pid) | ||||
| 		nativeLogger.Info().Int("pid", cmd.Process.Pid).Msg("killing process") | ||||
| 		err := cmd.Process.Kill() | ||||
| 		if err != nil { | ||||
| 			logger.Errorf("failed to kill process: %v", err) | ||||
| 			nativeLogger.Warn().Err(err).Msg("failed to kill process") | ||||
| 			return | ||||
| 		} | ||||
| 	}() | ||||
| 
 | ||||
| 	logger.Infof("Binary started with PID: %d", cmd.Process.Pid) | ||||
| 	nativeLogger.Info().Int("pid", cmd.Process.Pid).Msg("jetkvm_native binary started") | ||||
| 
 | ||||
| 	return nil | ||||
| } | ||||
| 
 | ||||
| func shouldOverwrite(destPath string, srcHash []byte) bool { | ||||
| 	if srcHash == nil { | ||||
| 		logger.Debug("error reading embedded jetkvm_native.sha256, doing overwriting") | ||||
| 		nativeLogger.Debug().Msg("error reading embedded jetkvm_native.sha256, doing overwriting") | ||||
| 		return true | ||||
| 	} | ||||
| 
 | ||||
| 	dstHash, err := os.ReadFile(destPath + ".sha256") | ||||
| 	if err != nil { | ||||
| 		logger.Debug("error reading existing jetkvm_native.sha256, doing overwriting") | ||||
| 		nativeLogger.Debug().Msg("error reading existing jetkvm_native.sha256, doing overwriting") | ||||
| 		return true | ||||
| 	} | ||||
| 
 | ||||
| 	return !bytes.Equal(srcHash, dstHash) | ||||
| } | ||||
| 
 | ||||
| func getNativeSha256() ([]byte, error) { | ||||
| 	version, err := resource.ResourceFS.ReadFile("jetkvm_native.sha256") | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
| 	return version, nil | ||||
| } | ||||
| 
 | ||||
| func GetNativeVersion() (string, error) { | ||||
| 	version, err := getNativeSha256() | ||||
| 	if err != nil { | ||||
| 		return "", err | ||||
| 	} | ||||
| 	return strings.TrimSpace(string(version)), nil | ||||
| } | ||||
| 
 | ||||
| func ensureBinaryUpdated(destPath string) error { | ||||
| 	srcFile, err := resource.ResourceFS.Open("jetkvm_native") | ||||
| 	if err != nil { | ||||
|  | @ -278,15 +390,18 @@ func ensureBinaryUpdated(destPath string) error { | |||
| 	} | ||||
| 	defer srcFile.Close() | ||||
| 
 | ||||
| 	srcHash, err := resource.ResourceFS.ReadFile("jetkvm_native.sha256") | ||||
| 	srcHash, err := getNativeSha256() | ||||
| 	if err != nil { | ||||
| 		logger.Debug("error reading embedded jetkvm_native.sha256, proceeding with update") | ||||
| 		nativeLogger.Debug().Msg("error reading embedded jetkvm_native.sha256, proceeding with update") | ||||
| 		srcHash = nil | ||||
| 	} | ||||
| 
 | ||||
| 	_, err = os.Stat(destPath) | ||||
| 	if shouldOverwrite(destPath, srcHash) || err != nil { | ||||
| 		logger.Info("writing jetkvm_native") | ||||
| 		nativeLogger.Info(). | ||||
| 			Interface("hash", srcHash). | ||||
| 			Msg("writing jetkvm_native") | ||||
| 
 | ||||
| 		_ = os.Remove(destPath) | ||||
| 		destFile, err := os.OpenFile(destPath, os.O_CREATE|os.O_RDWR, 0755) | ||||
| 		if err != nil { | ||||
|  | @ -303,7 +418,7 @@ func ensureBinaryUpdated(destPath string) error { | |||
| 				return err | ||||
| 			} | ||||
| 		} | ||||
| 		logger.Info("jetkvm_native updated") | ||||
| 		nativeLogger.Info().Msg("jetkvm_native updated") | ||||
| 	} | ||||
| 
 | ||||
| 	return nil | ||||
|  | @ -313,10 +428,10 @@ func ensureBinaryUpdated(destPath string) error { | |||
| // Called after successful connection to jetkvm_native.
 | ||||
| func restoreHdmiEdid() { | ||||
| 	if config.EdidString != "" { | ||||
| 		logger.Infof("Restoring HDMI EDID to %v", config.EdidString) | ||||
| 		_, err := CallCtrlAction("set_edid", map[string]interface{}{"edid": config.EdidString}) | ||||
| 		nativeLogger.Info().Str("edid", config.EdidString).Msg("Restoring HDMI EDID") | ||||
| 		_, err := CallCtrlAction("set_edid", map[string]any{"edid": config.EdidString}) | ||||
| 		if err != nil { | ||||
| 			logger.Errorf("Failed to restore HDMI EDID: %v", err) | ||||
| 			nativeLogger.Warn().Err(err).Msg("Failed to restore HDMI EDID") | ||||
| 		} | ||||
| 	} | ||||
| } | ||||
|  |  | |||
|  | @ -0,0 +1,57 @@ | |||
| //go:build linux
 | ||||
| 
 | ||||
| package kvm | ||||
| 
 | ||||
| import ( | ||||
| 	"fmt" | ||||
| 	"os/exec" | ||||
| 	"sync" | ||||
| 	"syscall" | ||||
| 
 | ||||
| 	"github.com/rs/zerolog" | ||||
| ) | ||||
| 
 | ||||
| type nativeOutput struct { | ||||
| 	mu     *sync.Mutex | ||||
| 	logger *zerolog.Event | ||||
| } | ||||
| 
 | ||||
| func (w *nativeOutput) Write(p []byte) (n int, err error) { | ||||
| 	w.mu.Lock() | ||||
| 	defer w.mu.Unlock() | ||||
| 
 | ||||
| 	w.logger.Msg(string(p)) | ||||
| 	return len(p), nil | ||||
| } | ||||
| 
 | ||||
| func startNativeBinary(binaryPath string) (*exec.Cmd, error) { | ||||
| 	// Run the binary in the background
 | ||||
| 	cmd := exec.Command(binaryPath) | ||||
| 
 | ||||
| 	nativeOutputLock := sync.Mutex{} | ||||
| 	nativeStdout := &nativeOutput{ | ||||
| 		mu:     &nativeOutputLock, | ||||
| 		logger: nativeLogger.Info().Str("pipe", "stdout"), | ||||
| 	} | ||||
| 	nativeStderr := &nativeOutput{ | ||||
| 		mu:     &nativeOutputLock, | ||||
| 		logger: nativeLogger.Info().Str("pipe", "stderr"), | ||||
| 	} | ||||
| 
 | ||||
| 	// Redirect stdout and stderr to the current process
 | ||||
| 	cmd.Stdout = nativeStdout | ||||
| 	cmd.Stderr = nativeStderr | ||||
| 
 | ||||
| 	// Set the process group ID so we can kill the process and its children when this process exits
 | ||||
| 	cmd.SysProcAttr = &syscall.SysProcAttr{ | ||||
| 		Setpgid:   true, | ||||
| 		Pdeathsig: syscall.SIGKILL, | ||||
| 	} | ||||
| 
 | ||||
| 	// Start the command
 | ||||
| 	if err := cmd.Start(); err != nil { | ||||
| 		return nil, fmt.Errorf("failed to start binary: %w", err) | ||||
| 	} | ||||
| 
 | ||||
| 	return cmd, nil | ||||
| } | ||||
|  | @ -0,0 +1,12 @@ | |||
| //go:build !linux
 | ||||
| 
 | ||||
| package kvm | ||||
| 
 | ||||
| import ( | ||||
| 	"fmt" | ||||
| 	"os/exec" | ||||
| ) | ||||
| 
 | ||||
| func startNativeBinary(binaryPath string) (*exec.Cmd, error) { | ||||
| 	return nil, fmt.Errorf("not supported") | ||||
| } | ||||
							
								
								
									
										294
									
								
								network.go
								
								
								
								
							
							
						
						
									
										294
									
								
								network.go
								
								
								
								
							|  | @ -1,227 +1,125 @@ | |||
| package kvm | ||||
| 
 | ||||
| import ( | ||||
| 	"bytes" | ||||
| 	"fmt" | ||||
| 	"net" | ||||
| 	"os" | ||||
| 	"strings" | ||||
| 	"time" | ||||
| 
 | ||||
| 	"os/exec" | ||||
| 
 | ||||
| 	"github.com/hashicorp/go-envparse" | ||||
| 	"github.com/pion/mdns/v2" | ||||
| 	"golang.org/x/net/ipv4" | ||||
| 	"golang.org/x/net/ipv6" | ||||
| 
 | ||||
| 	"github.com/vishvananda/netlink" | ||||
| 	"github.com/vishvananda/netlink/nl" | ||||
| 	"github.com/jetkvm/kvm/internal/network" | ||||
| 	"github.com/jetkvm/kvm/internal/udhcpc" | ||||
| ) | ||||
| 
 | ||||
| var mDNSConn *mdns.Conn | ||||
| 
 | ||||
| var networkState NetworkState | ||||
| 
 | ||||
| type NetworkState struct { | ||||
| 	Up   bool | ||||
| 	IPv4 string | ||||
| 	IPv6 string | ||||
| 	MAC  string | ||||
| 
 | ||||
| 	checked bool | ||||
| } | ||||
| 
 | ||||
| type LocalIpInfo struct { | ||||
| 	IPv4 string | ||||
| 	IPv6 string | ||||
| 	MAC  string | ||||
| } | ||||
| 
 | ||||
| const ( | ||||
| 	NetIfName     = "eth0" | ||||
| 	DHCPLeaseFile = "/run/udhcpc.%s.info" | ||||
| 	NetIfName = "eth0" | ||||
| ) | ||||
| 
 | ||||
| // setDhcpClientState sends signals to udhcpc to change it's current mode
 | ||||
| // of operation. Setting active to true will force udhcpc to renew the DHCP lease.
 | ||||
| // Setting active to false will put udhcpc into idle mode.
 | ||||
| func setDhcpClientState(active bool) { | ||||
| 	var signal string | ||||
| 	if active { | ||||
| 		signal = "-SIGUSR1" | ||||
| 	} else { | ||||
| 		signal = "-SIGUSR2" | ||||
| var ( | ||||
| 	networkState *network.NetworkInterfaceState | ||||
| ) | ||||
| 
 | ||||
| func networkStateChanged(isOnline bool) { | ||||
| 	// do not block the main thread
 | ||||
| 	go waitCtrlAndRequestDisplayUpdate(true) | ||||
| 
 | ||||
| 	if timeSync != nil { | ||||
| 		if networkState != nil { | ||||
| 			timeSync.SetDhcpNtpAddresses(networkState.NtpAddressesString()) | ||||
| 		} | ||||
| 
 | ||||
| 		if err := timeSync.Sync(); err != nil { | ||||
| 			networkLogger.Error().Err(err).Msg("failed to sync time after network state change") | ||||
| 		} | ||||
| 	} | ||||
| 
 | ||||
| 	cmd := exec.Command("/usr/bin/killall", signal, "udhcpc") | ||||
| 	if err := cmd.Run(); err != nil { | ||||
| 		logger.Warnf("network: setDhcpClientState: failed to change udhcpc state: %s", err) | ||||
| 	// always restart mDNS when the network state changes
 | ||||
| 	if mDNS != nil { | ||||
| 		_ = mDNS.SetListenOptions(config.NetworkConfig.GetMDNSMode()) | ||||
| 		_ = mDNS.SetLocalNames([]string{ | ||||
| 			networkState.GetHostname(), | ||||
| 			networkState.GetFQDN(), | ||||
| 		}, true) | ||||
| 	} | ||||
| 
 | ||||
| 	// 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 checkNetworkState() { | ||||
| 	iface, err := netlink.LinkByName(NetIfName) | ||||
| 	if err != nil { | ||||
| 		logger.Warnf("failed to get [%s] interface: %v", NetIfName, err) | ||||
| 		return | ||||
| 	} | ||||
| func initNetwork() error { | ||||
| 	ensureConfigLoaded() | ||||
| 
 | ||||
| 	newState := NetworkState{ | ||||
| 		Up:  iface.Attrs().OperState == netlink.OperUp, | ||||
| 		MAC: iface.Attrs().HardwareAddr.String(), | ||||
| 	state, err := network.NewNetworkInterfaceState(&network.NetworkInterfaceOptions{ | ||||
| 		DefaultHostname: GetDefaultHostname(), | ||||
| 		InterfaceName:   NetIfName, | ||||
| 		NetworkConfig:   config.NetworkConfig, | ||||
| 		Logger:          networkLogger, | ||||
| 		OnStateChange: func(state *network.NetworkInterfaceState) { | ||||
| 			networkStateChanged(state.IsOnline()) | ||||
| 		}, | ||||
| 		OnInitialCheck: func(state *network.NetworkInterfaceState) { | ||||
| 			networkStateChanged(state.IsOnline()) | ||||
| 		}, | ||||
| 		OnDhcpLeaseChange: func(lease *udhcpc.Lease, state *network.NetworkInterfaceState) { | ||||
| 			networkStateChanged(state.IsOnline()) | ||||
| 
 | ||||
| 		checked: true, | ||||
| 	} | ||||
| 
 | ||||
| 	addrs, err := netlink.AddrList(iface, nl.FAMILY_ALL) | ||||
| 	if err != nil { | ||||
| 		logger.Warnf("failed to get addresses for [%s]: %v", NetIfName, err) | ||||
| 	} | ||||
| 
 | ||||
| 	// If the link is going down, put udhcpc into idle mode.
 | ||||
| 	// If the link is coming back up, activate udhcpc and force it to renew the lease.
 | ||||
| 	if newState.Up != networkState.Up { | ||||
| 		setDhcpClientState(newState.Up) | ||||
| 	} | ||||
| 
 | ||||
| 	for _, addr := range addrs { | ||||
| 		if addr.IP.To4() != nil { | ||||
| 			if !newState.Up && networkState.Up { | ||||
| 				// If the network is going down, remove all IPv4 addresses from the interface.
 | ||||
| 				logger.Infof("network: state transitioned to down, removing IPv4 address %s", addr.IP.String()) | ||||
| 				err := netlink.AddrDel(iface, &addr) | ||||
| 				if err != nil { | ||||
| 					logger.Warnf("network: failed to delete %s", addr.IP.String()) | ||||
| 				} | ||||
| 
 | ||||
| 				newState.IPv4 = "..." | ||||
| 			} else { | ||||
| 				newState.IPv4 = addr.IP.String() | ||||
| 			if currentSession == nil { | ||||
| 				return | ||||
| 			} | ||||
| 		} else if addr.IP.To16() != nil && newState.IPv6 == "" { | ||||
| 			newState.IPv6 = addr.IP.String() | ||||
| 		} | ||||
| 	} | ||||
| 
 | ||||
| 	if newState != networkState { | ||||
| 		logger.Info("network state changed") | ||||
| 		// restart MDNS
 | ||||
| 		_ = startMDNS() | ||||
| 		networkState = newState | ||||
| 		requestDisplayUpdate() | ||||
| 	} | ||||
| } | ||||
| 			writeJSONRPCEvent("networkState", networkState.RpcGetNetworkState(), currentSession) | ||||
| 		}, | ||||
| 		OnConfigChange: func(networkConfig *network.NetworkConfig) { | ||||
| 			config.NetworkConfig = networkConfig | ||||
| 			networkStateChanged(false) | ||||
| 
 | ||||
| func startMDNS() error { | ||||
| 	// If server was previously running, stop it
 | ||||
| 	if mDNSConn != nil { | ||||
| 		logger.Info("Stopping mDNS server") | ||||
| 		err := mDNSConn.Close() | ||||
| 		if err != nil { | ||||
| 			logger.Warnf("failed to stop mDNS server: %v", err) | ||||
| 		} | ||||
| 	} | ||||
| 
 | ||||
| 	// Start a new server
 | ||||
| 	logger.Info("Starting mDNS server on jetkvm.local") | ||||
| 	addr4, err := net.ResolveUDPAddr("udp4", mdns.DefaultAddressIPv4) | ||||
| 	if err != nil { | ||||
| 		return err | ||||
| 	} | ||||
| 
 | ||||
| 	addr6, err := net.ResolveUDPAddr("udp6", mdns.DefaultAddressIPv6) | ||||
| 	if err != nil { | ||||
| 		return err | ||||
| 	} | ||||
| 
 | ||||
| 	l4, err := net.ListenUDP("udp4", addr4) | ||||
| 	if err != nil { | ||||
| 		return err | ||||
| 	} | ||||
| 
 | ||||
| 	l6, err := net.ListenUDP("udp6", addr6) | ||||
| 	if err != nil { | ||||
| 		return err | ||||
| 	} | ||||
| 
 | ||||
| 	mDNSConn, err = mdns.Server(ipv4.NewPacketConn(l4), ipv6.NewPacketConn(l6), &mdns.Config{ | ||||
| 		LocalNames: []string{"jetkvm.local"}, //TODO: make it configurable
 | ||||
| 			if mDNS != nil { | ||||
| 				_ = mDNS.SetListenOptions(networkConfig.GetMDNSMode()) | ||||
| 				_ = mDNS.SetLocalNames([]string{ | ||||
| 					networkState.GetHostname(), | ||||
| 					networkState.GetFQDN(), | ||||
| 				}, true) | ||||
| 			} | ||||
| 		}, | ||||
| 	}) | ||||
| 	if err != nil { | ||||
| 		mDNSConn = nil | ||||
| 
 | ||||
| 	if state == nil { | ||||
| 		if err == nil { | ||||
| 			return fmt.Errorf("failed to create NetworkInterfaceState") | ||||
| 		} | ||||
| 		return err | ||||
| 	} | ||||
| 	//defer server.Close()
 | ||||
| 
 | ||||
| 	if err := state.Run(); err != nil { | ||||
| 		return err | ||||
| 	} | ||||
| 
 | ||||
| 	networkState = state | ||||
| 
 | ||||
| 	return nil | ||||
| } | ||||
| 
 | ||||
| func getNTPServersFromDHCPInfo() ([]string, error) { | ||||
| 	buf, err := os.ReadFile(fmt.Sprintf(DHCPLeaseFile, NetIfName)) | ||||
| 	if err != nil { | ||||
| 		// do not return error if file does not exist
 | ||||
| 		if os.IsNotExist(err) { | ||||
| 			return nil, nil | ||||
| 		} | ||||
| 		return nil, fmt.Errorf("failed to load udhcpc info: %w", err) | ||||
| 	} | ||||
| 
 | ||||
| 	// parse udhcpc info
 | ||||
| 	env, err := envparse.Parse(bytes.NewReader(buf)) | ||||
| 	if err != nil { | ||||
| 		return nil, fmt.Errorf("failed to parse udhcpc info: %w", err) | ||||
| 	} | ||||
| 
 | ||||
| 	val, ok := env["ntpsrv"] | ||||
| 	if !ok { | ||||
| 		return nil, nil | ||||
| 	} | ||||
| 
 | ||||
| 	var servers []string | ||||
| 
 | ||||
| 	for _, server := range strings.Fields(val) { | ||||
| 		if net.ParseIP(server) == nil { | ||||
| 			logger.Infof("invalid NTP server IP: %s, ignoring", server) | ||||
| 		} | ||||
| 		servers = append(servers, server) | ||||
| 	} | ||||
| 
 | ||||
| 	return servers, nil | ||||
| func rpcGetNetworkState() network.RpcNetworkState { | ||||
| 	return networkState.RpcGetNetworkState() | ||||
| } | ||||
| 
 | ||||
| func init() { | ||||
| 	ensureConfigLoaded() | ||||
| 
 | ||||
| 	updates := make(chan netlink.LinkUpdate) | ||||
| 	done := make(chan struct{}) | ||||
| 
 | ||||
| 	if err := netlink.LinkSubscribe(updates, done); err != nil { | ||||
| 		logger.Warnf("failed to subscribe to link updates: %v", err) | ||||
| 		return | ||||
| 	} | ||||
| 
 | ||||
| 	go func() { | ||||
| 		waitCtrlClientConnected() | ||||
| 		checkNetworkState() | ||||
| 		ticker := time.NewTicker(1 * time.Second) | ||||
| 		defer ticker.Stop() | ||||
| 
 | ||||
| 		for { | ||||
| 			select { | ||||
| 			case update := <-updates: | ||||
| 				if update.Link.Attrs().Name == NetIfName { | ||||
| 					logger.Infof("link update: %+v", update) | ||||
| 					checkNetworkState() | ||||
| 				} | ||||
| 			case <-ticker.C: | ||||
| 				checkNetworkState() | ||||
| 			case <-done: | ||||
| 				return | ||||
| 			} | ||||
| 		} | ||||
| 	}() | ||||
| 	err := startMDNS() | ||||
| 	if err != nil { | ||||
| 		logger.Warnf("failed to run mDNS: %v", err) | ||||
| 	} | ||||
| func rpcGetNetworkSettings() network.RpcNetworkSettings { | ||||
| 	return networkState.RpcGetNetworkSettings() | ||||
| } | ||||
| 
 | ||||
| func rpcSetNetworkSettings(settings network.RpcNetworkSettings) (*network.RpcNetworkSettings, error) { | ||||
| 	s := networkState.RpcSetNetworkSettings(settings) | ||||
| 	if s != nil { | ||||
| 		return nil, s | ||||
| 	} | ||||
| 
 | ||||
| 	if err := SaveConfig(); err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
| 
 | ||||
| 	return &network.RpcNetworkSettings{NetworkConfig: *config.NetworkConfig}, nil | ||||
| } | ||||
| 
 | ||||
| func rpcRenewDHCPLease() error { | ||||
| 	return networkState.RpcRenewDHCPLease() | ||||
| } | ||||
|  |  | |||
							
								
								
									
										140
									
								
								ntp.go
								
								
								
								
							
							
						
						
									
										140
									
								
								ntp.go
								
								
								
								
							|  | @ -1,140 +0,0 @@ | |||
| package kvm | ||||
| 
 | ||||
| import ( | ||||
| 	"errors" | ||||
| 	"fmt" | ||||
| 	"net/http" | ||||
| 	"os/exec" | ||||
| 	"time" | ||||
| 
 | ||||
| 	"github.com/beevik/ntp" | ||||
| ) | ||||
| 
 | ||||
| const ( | ||||
| 	timeSyncRetryStep     = 5 * time.Second | ||||
| 	timeSyncRetryMaxInt   = 1 * time.Minute | ||||
| 	timeSyncWaitNetChkInt = 100 * time.Millisecond | ||||
| 	timeSyncWaitNetUpInt  = 3 * time.Second | ||||
| 	timeSyncInterval      = 1 * time.Hour | ||||
| 	timeSyncTimeout       = 2 * time.Second | ||||
| ) | ||||
| 
 | ||||
| var ( | ||||
| 	timeSyncRetryInterval = 0 * time.Second | ||||
| 	defaultNTPServers     = []string{ | ||||
| 		"time.cloudflare.com", | ||||
| 		"time.apple.com", | ||||
| 	} | ||||
| ) | ||||
| 
 | ||||
| func TimeSyncLoop() { | ||||
| 	for { | ||||
| 		if !networkState.checked { | ||||
| 			time.Sleep(timeSyncWaitNetChkInt) | ||||
| 			continue | ||||
| 		} | ||||
| 
 | ||||
| 		if !networkState.Up { | ||||
| 			logger.Infof("Waiting for network to come up") | ||||
| 			time.Sleep(timeSyncWaitNetUpInt) | ||||
| 			continue | ||||
| 		} | ||||
| 
 | ||||
| 		logger.Infof("Syncing system time") | ||||
| 		start := time.Now() | ||||
| 		err := SyncSystemTime() | ||||
| 		if err != nil { | ||||
| 			logger.Warnf("Failed to sync system time: %v", err) | ||||
| 
 | ||||
| 			// retry after a delay
 | ||||
| 			timeSyncRetryInterval += timeSyncRetryStep | ||||
| 			time.Sleep(timeSyncRetryInterval) | ||||
| 			// reset the retry interval if it exceeds the max interval
 | ||||
| 			if timeSyncRetryInterval > timeSyncRetryMaxInt { | ||||
| 				timeSyncRetryInterval = 0 | ||||
| 			} | ||||
| 
 | ||||
| 			continue | ||||
| 		} | ||||
| 		logger.Infof("Time sync successful, now is: %v, time taken: %v", time.Now(), time.Since(start)) | ||||
| 		time.Sleep(timeSyncInterval) // after the first sync is done
 | ||||
| 	} | ||||
| } | ||||
| 
 | ||||
| func SyncSystemTime() (err error) { | ||||
| 	now, err := queryNetworkTime() | ||||
| 	if err != nil { | ||||
| 		return fmt.Errorf("failed to query network time: %w", err) | ||||
| 	} | ||||
| 	err = setSystemTime(*now) | ||||
| 	if err != nil { | ||||
| 		return fmt.Errorf("failed to set system time: %w", err) | ||||
| 	} | ||||
| 	return nil | ||||
| } | ||||
| 
 | ||||
| func queryNetworkTime() (*time.Time, error) { | ||||
| 	ntpServers, err := getNTPServersFromDHCPInfo() | ||||
| 	if err != nil { | ||||
| 		logger.Warnf("failed to get NTP servers from DHCP info: %v\n", err) | ||||
| 	} | ||||
| 
 | ||||
| 	if ntpServers == nil { | ||||
| 		ntpServers = defaultNTPServers | ||||
| 		logger.Infof("Using default NTP servers: %v\n", ntpServers) | ||||
| 	} else { | ||||
| 		logger.Infof("Using NTP servers from DHCP: %v\n", ntpServers) | ||||
| 	} | ||||
| 
 | ||||
| 	for _, server := range ntpServers { | ||||
| 		now, err := queryNtpServer(server, timeSyncTimeout) | ||||
| 		if err == nil { | ||||
| 			logger.Infof("NTP server [%s] returned time: %v\n", server, now) | ||||
| 			return now, nil | ||||
| 		} | ||||
| 	} | ||||
| 	httpUrls := []string{ | ||||
| 		"http://apple.com", | ||||
| 		"http://cloudflare.com", | ||||
| 	} | ||||
| 	for _, url := range httpUrls { | ||||
| 		now, err := queryHttpTime(url, timeSyncTimeout) | ||||
| 		if err == nil { | ||||
| 			return now, nil | ||||
| 		} | ||||
| 	} | ||||
| 	return nil, errors.New("failed to query network time") | ||||
| } | ||||
| 
 | ||||
| func queryNtpServer(server string, timeout time.Duration) (now *time.Time, err error) { | ||||
| 	resp, err := ntp.QueryWithOptions(server, ntp.QueryOptions{Timeout: timeout}) | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
| 	return &resp.Time, nil | ||||
| } | ||||
| 
 | ||||
| func queryHttpTime(url string, timeout time.Duration) (*time.Time, error) { | ||||
| 	client := http.Client{ | ||||
| 		Timeout: timeout, | ||||
| 	} | ||||
| 	resp, err := client.Head(url) | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
| 	dateStr := resp.Header.Get("Date") | ||||
| 	now, err := time.Parse(time.RFC1123, dateStr) | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
| 	return &now, nil | ||||
| } | ||||
| 
 | ||||
| func setSystemTime(now time.Time) error { | ||||
| 	nowStr := now.Format("2006-01-02 15:04:05") | ||||
| 	output, err := exec.Command("date", "-s", nowStr).CombinedOutput() | ||||
| 	if err != nil { | ||||
| 		return fmt.Errorf("failed to run date -s: %w, %s", err, string(output)) | ||||
| 	} | ||||
| 	return nil | ||||
| } | ||||
							
								
								
									
										141
									
								
								ota.go
								
								
								
								
							
							
						
						
									
										141
									
								
								ota.go
								
								
								
								
							|  | @ -4,6 +4,7 @@ import ( | |||
| 	"bytes" | ||||
| 	"context" | ||||
| 	"crypto/sha256" | ||||
| 	"crypto/tls" | ||||
| 	"encoding/hex" | ||||
| 	"encoding/json" | ||||
| 	"fmt" | ||||
|  | @ -16,6 +17,8 @@ import ( | |||
| 	"time" | ||||
| 
 | ||||
| 	"github.com/Masterminds/semver/v3" | ||||
| 	"github.com/gwatts/rootcerts" | ||||
| 	"github.com/rs/zerolog" | ||||
| ) | ||||
| 
 | ||||
| type UpdateMetadata struct { | ||||
|  | @ -38,12 +41,19 @@ type UpdateStatus struct { | |||
| 	Remote                *UpdateMetadata `json:"remote"` | ||||
| 	SystemUpdateAvailable bool            `json:"systemUpdateAvailable"` | ||||
| 	AppUpdateAvailable    bool            `json:"appUpdateAvailable"` | ||||
| 
 | ||||
| 	// for backwards compatibility
 | ||||
| 	Error string `json:"error,omitempty"` | ||||
| } | ||||
| 
 | ||||
| const UpdateMetadataUrl = "https://api.jetkvm.com/releases" | ||||
| 
 | ||||
| var builtAppVersion = "0.1.0+dev" | ||||
| 
 | ||||
| func GetBuiltAppVersion() string { | ||||
| 	return builtAppVersion | ||||
| } | ||||
| 
 | ||||
| func GetLocalVersion() (systemVersion *semver.Version, appVersion *semver.Version, err error) { | ||||
| 	appVersion, err = semver.NewVersion(builtAppVersion) | ||||
| 	if err != nil { | ||||
|  | @ -76,14 +86,21 @@ func fetchUpdateMetadata(ctx context.Context, deviceId string, includePreRelease | |||
| 	query.Set("prerelease", fmt.Sprintf("%v", includePreRelease)) | ||||
| 	updateUrl.RawQuery = query.Encode() | ||||
| 
 | ||||
| 	logger.Infof("Checking for updates at: %s", updateUrl) | ||||
| 	logger.Info().Str("url", updateUrl.String()).Msg("Checking for updates") | ||||
| 
 | ||||
| 	req, err := http.NewRequestWithContext(ctx, "GET", updateUrl.String(), nil) | ||||
| 	if err != nil { | ||||
| 		return nil, fmt.Errorf("error creating request: %w", err) | ||||
| 	} | ||||
| 
 | ||||
| 	resp, err := http.DefaultClient.Do(req) | ||||
| 	transport := http.DefaultTransport.(*http.Transport).Clone() | ||||
| 	transport.Proxy = config.NetworkConfig.GetTransportProxyFunc() | ||||
| 
 | ||||
| 	client := &http.Client{ | ||||
| 		Transport: transport, | ||||
| 	} | ||||
| 
 | ||||
| 	resp, err := client.Do(req) | ||||
| 	if err != nil { | ||||
| 		return nil, fmt.Errorf("error sending request: %w", err) | ||||
| 	} | ||||
|  | @ -126,7 +143,18 @@ func downloadFile(ctx context.Context, path string, url string, downloadProgress | |||
| 		return fmt.Errorf("error creating request: %w", err) | ||||
| 	} | ||||
| 
 | ||||
| 	resp, err := http.DefaultClient.Do(req) | ||||
| 	client := http.Client{ | ||||
| 		Timeout: 10 * time.Minute, | ||||
| 		Transport: &http.Transport{ | ||||
| 			Proxy:               config.NetworkConfig.GetTransportProxyFunc(), | ||||
| 			TLSHandshakeTimeout: 30 * time.Second, | ||||
| 			TLSClientConfig: &tls.Config{ | ||||
| 				RootCAs: rootcerts.ServerCertPool(), | ||||
| 			}, | ||||
| 		}, | ||||
| 	} | ||||
| 
 | ||||
| 	resp, err := client.Do(req) | ||||
| 	if err != nil { | ||||
| 		return fmt.Errorf("error downloading file: %w", err) | ||||
| 	} | ||||
|  | @ -185,7 +213,11 @@ func downloadFile(ctx context.Context, path string, url string, downloadProgress | |||
| 	return nil | ||||
| } | ||||
| 
 | ||||
| func verifyFile(path string, expectedHash string, verifyProgress *float32) error { | ||||
| func verifyFile(path string, expectedHash string, verifyProgress *float32, scopedLogger *zerolog.Logger) error { | ||||
| 	if scopedLogger == nil { | ||||
| 		scopedLogger = otaLogger | ||||
| 	} | ||||
| 
 | ||||
| 	unverifiedPath := path + ".unverified" | ||||
| 	fileToHash, err := os.Open(unverifiedPath) | ||||
| 	if err != nil { | ||||
|  | @ -229,7 +261,7 @@ func verifyFile(path string, expectedHash string, verifyProgress *float32) error | |||
| 	} | ||||
| 
 | ||||
| 	hashSum := hash.Sum(nil) | ||||
| 	logger.Infof("SHA256 hash of %s: %x", path, hashSum) | ||||
| 	scopedLogger.Info().Str("path", path).Str("hash", hex.EncodeToString(hashSum)).Msg("SHA256 hash of") | ||||
| 
 | ||||
| 	if hex.EncodeToString(hashSum) != expectedHash { | ||||
| 		return fmt.Errorf("hash mismatch: %x != %s", hashSum, expectedHash) | ||||
|  | @ -271,7 +303,7 @@ var otaState = OTAState{} | |||
| func triggerOTAStateUpdate() { | ||||
| 	go func() { | ||||
| 		if currentSession == nil { | ||||
| 			logger.Info("No active RPC session, skipping update state update") | ||||
| 			logger.Info().Msg("No active RPC session, skipping update state update") | ||||
| 			return | ||||
| 		} | ||||
| 		writeJSONRPCEvent("otaState", otaState, currentSession) | ||||
|  | @ -279,7 +311,12 @@ func triggerOTAStateUpdate() { | |||
| } | ||||
| 
 | ||||
| func TryUpdate(ctx context.Context, deviceId string, includePreRelease bool) error { | ||||
| 	logger.Info("Trying to update...") | ||||
| 	scopedLogger := otaLogger.With(). | ||||
| 		Str("deviceId", deviceId). | ||||
| 		Str("includePreRelease", fmt.Sprintf("%v", includePreRelease)). | ||||
| 		Logger() | ||||
| 
 | ||||
| 	scopedLogger.Info().Msg("Trying to update...") | ||||
| 	if otaState.Updating { | ||||
| 		return fmt.Errorf("update already in progress") | ||||
| 	} | ||||
|  | @ -297,6 +334,7 @@ func TryUpdate(ctx context.Context, deviceId string, includePreRelease bool) err | |||
| 	updateStatus, err := GetUpdateStatus(ctx, deviceId, includePreRelease) | ||||
| 	if err != nil { | ||||
| 		otaState.Error = fmt.Sprintf("Error checking for updates: %v", err) | ||||
| 		scopedLogger.Error().Err(err).Msg("Error checking for updates") | ||||
| 		return fmt.Errorf("error checking for updates: %w", err) | ||||
| 	} | ||||
| 
 | ||||
|  | @ -314,11 +352,15 @@ func TryUpdate(ctx context.Context, deviceId string, includePreRelease bool) err | |||
| 	rebootNeeded := false | ||||
| 
 | ||||
| 	if appUpdateAvailable { | ||||
| 		logger.Infof("App update available: %s -> %s", local.AppVersion, remote.AppVersion) | ||||
| 		scopedLogger.Info(). | ||||
| 			Str("local", local.AppVersion). | ||||
| 			Str("remote", remote.AppVersion). | ||||
| 			Msg("App update available") | ||||
| 
 | ||||
| 		err := downloadFile(ctx, "/userdata/jetkvm/jetkvm_app.update", remote.AppUrl, &otaState.AppDownloadProgress) | ||||
| 		if err != nil { | ||||
| 			otaState.Error = fmt.Sprintf("Error downloading app update: %v", err) | ||||
| 			scopedLogger.Error().Err(err).Msg("Error downloading app update") | ||||
| 			triggerOTAStateUpdate() | ||||
| 			return err | ||||
| 		} | ||||
|  | @ -327,9 +369,15 @@ func TryUpdate(ctx context.Context, deviceId string, includePreRelease bool) err | |||
| 		otaState.AppDownloadProgress = 1 | ||||
| 		triggerOTAStateUpdate() | ||||
| 
 | ||||
| 		err = verifyFile("/userdata/jetkvm/jetkvm_app.update", remote.AppHash, &otaState.AppVerificationProgress) | ||||
| 		err = verifyFile( | ||||
| 			"/userdata/jetkvm/jetkvm_app.update", | ||||
| 			remote.AppHash, | ||||
| 			&otaState.AppVerificationProgress, | ||||
| 			&scopedLogger, | ||||
| 		) | ||||
| 		if err != nil { | ||||
| 			otaState.Error = fmt.Sprintf("Error verifying app update hash: %v", err) | ||||
| 			scopedLogger.Error().Err(err).Msg("Error verifying app update hash") | ||||
| 			triggerOTAStateUpdate() | ||||
| 			return err | ||||
| 		} | ||||
|  | @ -340,17 +388,22 @@ func TryUpdate(ctx context.Context, deviceId string, includePreRelease bool) err | |||
| 		otaState.AppUpdateProgress = 1 | ||||
| 		triggerOTAStateUpdate() | ||||
| 
 | ||||
| 		logger.Info("App update downloaded") | ||||
| 		scopedLogger.Info().Msg("App update downloaded") | ||||
| 		rebootNeeded = true | ||||
| 	} else { | ||||
| 		logger.Info("App is up to date") | ||||
| 		scopedLogger.Info().Msg("App is up to date") | ||||
| 	} | ||||
| 
 | ||||
| 	if systemUpdateAvailable { | ||||
| 		logger.Infof("System update available: %s -> %s", local.SystemVersion, remote.SystemVersion) | ||||
| 		scopedLogger.Info(). | ||||
| 			Str("local", local.SystemVersion). | ||||
| 			Str("remote", remote.SystemVersion). | ||||
| 			Msg("System update available") | ||||
| 
 | ||||
| 		err := downloadFile(ctx, "/userdata/jetkvm/update_system.tar", remote.SystemUrl, &otaState.SystemDownloadProgress) | ||||
| 		if err != nil { | ||||
| 			otaState.Error = fmt.Sprintf("Error downloading system update: %v", err) | ||||
| 			scopedLogger.Error().Err(err).Msg("Error downloading system update") | ||||
| 			triggerOTAStateUpdate() | ||||
| 			return err | ||||
| 		} | ||||
|  | @ -359,18 +412,25 @@ func TryUpdate(ctx context.Context, deviceId string, includePreRelease bool) err | |||
| 		otaState.SystemDownloadProgress = 1 | ||||
| 		triggerOTAStateUpdate() | ||||
| 
 | ||||
| 		err = verifyFile("/userdata/jetkvm/update_system.tar", remote.SystemHash, &otaState.SystemVerificationProgress) | ||||
| 		err = verifyFile( | ||||
| 			"/userdata/jetkvm/update_system.tar", | ||||
| 			remote.SystemHash, | ||||
| 			&otaState.SystemVerificationProgress, | ||||
| 			&scopedLogger, | ||||
| 		) | ||||
| 		if err != nil { | ||||
| 			otaState.Error = fmt.Sprintf("Error verifying system update hash: %v", err) | ||||
| 			scopedLogger.Error().Err(err).Msg("Error verifying system update hash") | ||||
| 			triggerOTAStateUpdate() | ||||
| 			return err | ||||
| 		} | ||||
| 		logger.Info("System update downloaded") | ||||
| 		scopedLogger.Info().Msg("System update downloaded") | ||||
| 		verifyFinished := time.Now() | ||||
| 		otaState.SystemVerifiedAt = &verifyFinished | ||||
| 		otaState.SystemVerificationProgress = 1 | ||||
| 		triggerOTAStateUpdate() | ||||
| 
 | ||||
| 		scopedLogger.Info().Msg("Starting rk_ota command") | ||||
| 		cmd := exec.Command("rk_ota", "--misc=update", "--tar_path=/userdata/jetkvm/update_system.tar", "--save_dir=/userdata/jetkvm/ota_save", "--partition=all") | ||||
| 		var b bytes.Buffer | ||||
| 		cmd.Stdout = &b | ||||
|  | @ -378,6 +438,7 @@ func TryUpdate(ctx context.Context, deviceId string, includePreRelease bool) err | |||
| 		err = cmd.Start() | ||||
| 		if err != nil { | ||||
| 			otaState.Error = fmt.Sprintf("Error starting rk_ota command: %v", err) | ||||
| 			scopedLogger.Error().Err(err).Msg("Error starting rk_ota command") | ||||
| 			return fmt.Errorf("error starting rk_ota command: %w", err) | ||||
| 		} | ||||
| 		ctx, cancel := context.WithCancel(context.Background()) | ||||
|  | @ -409,25 +470,30 @@ func TryUpdate(ctx context.Context, deviceId string, includePreRelease bool) err | |||
| 		output := b.String() | ||||
| 		if err != nil { | ||||
| 			otaState.Error = fmt.Sprintf("Error executing rk_ota command: %v\nOutput: %s", err, output) | ||||
| 			scopedLogger.Error(). | ||||
| 				Err(err). | ||||
| 				Str("output", output). | ||||
| 				Int("exitCode", cmd.ProcessState.ExitCode()). | ||||
| 				Msg("Error executing rk_ota command") | ||||
| 			return fmt.Errorf("error executing rk_ota command: %w\nOutput: %s", err, output) | ||||
| 		} | ||||
| 
 | ||||
| 		logger.Infof("rk_ota success, output: %s", output) | ||||
| 		scopedLogger.Info().Str("output", output).Msg("rk_ota success") | ||||
| 		otaState.SystemUpdateProgress = 1 | ||||
| 		otaState.SystemUpdatedAt = &verifyFinished | ||||
| 		triggerOTAStateUpdate() | ||||
| 		rebootNeeded = true | ||||
| 	} else { | ||||
| 		logger.Info("System is up to date") | ||||
| 		scopedLogger.Info().Msg("System is up to date") | ||||
| 	} | ||||
| 
 | ||||
| 	if rebootNeeded { | ||||
| 		logger.Info("System Rebooting in 10s") | ||||
| 		scopedLogger.Info().Msg("System Rebooting in 10s") | ||||
| 		time.Sleep(10 * time.Second) | ||||
| 		cmd := exec.Command("reboot") | ||||
| 		err := cmd.Start() | ||||
| 		if err != nil { | ||||
| 			otaState.Error = fmt.Sprintf("Failed to start reboot: %v", err) | ||||
| 			scopedLogger.Error().Err(err).Msg("Failed to start reboot") | ||||
| 			return fmt.Errorf("failed to start reboot: %w", err) | ||||
| 		} else { | ||||
| 			os.Exit(0) | ||||
|  | @ -438,52 +504,47 @@ func TryUpdate(ctx context.Context, deviceId string, includePreRelease bool) err | |||
| } | ||||
| 
 | ||||
| func GetUpdateStatus(ctx context.Context, deviceId string, includePreRelease bool) (*UpdateStatus, error) { | ||||
| 	updateStatus := &UpdateStatus{} | ||||
| 
 | ||||
| 	// Get local versions
 | ||||
| 	systemVersionLocal, appVersionLocal, err := GetLocalVersion() | ||||
| 	if err != nil { | ||||
| 		return nil, fmt.Errorf("error getting local version: %w", err) | ||||
| 		return updateStatus, fmt.Errorf("error getting local version: %w", err) | ||||
| 	} | ||||
| 	updateStatus.Local = &LocalMetadata{ | ||||
| 		AppVersion:    appVersionLocal.String(), | ||||
| 		SystemVersion: systemVersionLocal.String(), | ||||
| 	} | ||||
| 
 | ||||
| 	// Get remote metadata
 | ||||
| 	remoteMetadata, err := fetchUpdateMetadata(ctx, deviceId, includePreRelease) | ||||
| 	if err != nil { | ||||
| 		return nil, fmt.Errorf("error checking for updates: %w", err) | ||||
| 	} | ||||
| 
 | ||||
| 	// Build local UpdateMetadata
 | ||||
| 	localMetadata := &LocalMetadata{ | ||||
| 		AppVersion:    appVersionLocal.String(), | ||||
| 		SystemVersion: systemVersionLocal.String(), | ||||
| 		return updateStatus, fmt.Errorf("error checking for updates: %w", err) | ||||
| 	} | ||||
| 	updateStatus.Remote = remoteMetadata | ||||
| 
 | ||||
| 	// Get remote versions
 | ||||
| 	systemVersionRemote, err := semver.NewVersion(remoteMetadata.SystemVersion) | ||||
| 	if err != nil { | ||||
| 		return nil, fmt.Errorf("error parsing remote system version: %w", err) | ||||
| 		return updateStatus, fmt.Errorf("error parsing remote system version: %w", err) | ||||
| 	} | ||||
| 	appVersionRemote, err := semver.NewVersion(remoteMetadata.AppVersion) | ||||
| 	if err != nil { | ||||
| 		return nil, fmt.Errorf("error parsing remote app version: %w, %s", err, remoteMetadata.AppVersion) | ||||
| 		return updateStatus, fmt.Errorf("error parsing remote app version: %w, %s", err, remoteMetadata.AppVersion) | ||||
| 	} | ||||
| 
 | ||||
| 	systemUpdateAvailable := systemVersionRemote.GreaterThan(systemVersionLocal) | ||||
| 	appUpdateAvailable := appVersionRemote.GreaterThan(appVersionLocal) | ||||
| 	updateStatus.SystemUpdateAvailable = systemVersionRemote.GreaterThan(systemVersionLocal) | ||||
| 	updateStatus.AppUpdateAvailable = appVersionRemote.GreaterThan(appVersionLocal) | ||||
| 
 | ||||
| 	// Handle pre-release updates
 | ||||
| 	isRemoteSystemPreRelease := systemVersionRemote.Prerelease() != "" | ||||
| 	isRemoteAppPreRelease := appVersionRemote.Prerelease() != "" | ||||
| 
 | ||||
| 	if isRemoteSystemPreRelease && !includePreRelease { | ||||
| 		systemUpdateAvailable = false | ||||
| 		updateStatus.SystemUpdateAvailable = false | ||||
| 	} | ||||
| 	if isRemoteAppPreRelease && !includePreRelease { | ||||
| 		appUpdateAvailable = false | ||||
| 	} | ||||
| 
 | ||||
| 	updateStatus := &UpdateStatus{ | ||||
| 		Local:                 localMetadata, | ||||
| 		Remote:                remoteMetadata, | ||||
| 		SystemUpdateAvailable: systemUpdateAvailable, | ||||
| 		AppUpdateAvailable:    appUpdateAvailable, | ||||
| 		updateStatus.AppUpdateAvailable = false | ||||
| 	} | ||||
| 
 | ||||
| 	return updateStatus, nil | ||||
|  | @ -497,6 +558,6 @@ func IsUpdatePending() bool { | |||
| func confirmCurrentSystem() { | ||||
| 	output, err := exec.Command("rk_ota", "--misc=now").CombinedOutput() | ||||
| 	if err != nil { | ||||
| 		logger.Warnf("failed to set current partition in A/B setup: %s", string(output)) | ||||
| 		logger.Warn().Str("output", string(output)).Msg("failed to set current partition in A/B setup") | ||||
| 	} | ||||
| } | ||||
|  |  | |||
|  | @ -1,15 +1,11 @@ | |||
| package kvm | ||||
| 
 | ||||
| import ( | ||||
| 	"net/http" | ||||
| 
 | ||||
| 	"github.com/prometheus/client_golang/prometheus" | ||||
| 	versioncollector "github.com/prometheus/client_golang/prometheus/collectors/version" | ||||
| 	"github.com/prometheus/common/version" | ||||
| ) | ||||
| 
 | ||||
| var promHandler http.Handler | ||||
| 
 | ||||
| func initPrometheus() { | ||||
| 	// A Prometheus metrics endpoint.
 | ||||
| 	version.Version = builtAppVersion | ||||
|  |  | |||
|  | @ -1,4 +1,4 @@ | |||
| #!/bin/bash | ||||
| #!/usr/bin/env bash | ||||
| 
 | ||||
| # Check if a commit message was provided | ||||
| if [ -z "$1" ]; then | ||||
|  | @ -26,7 +26,7 @@ git checkout -b release-temp | |||
| if git ls-remote --heads public main | grep -q 'refs/heads/main'; then | ||||
|     git reset --soft public/main | ||||
| else | ||||
|     git reset --soft $(git rev-list --max-parents=0 HEAD) | ||||
|     git reset --soft "$(git rev-list --max-parents=0 HEAD)" | ||||
| fi | ||||
| 
 | ||||
| # Merge changes from main | ||||
|  |  | |||
|  | @ -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.Debugf("reading from webrtc %v", string(jsonBytes)) | ||||
| 	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 | ||||
| } | ||||
Some files were not shown because too many files have changed in this diff Show More
		Loading…
	
		Reference in New Issue