Compare commits

...

157 Commits
2.3.0 ... 2.4.0

Author SHA1 Message Date
wenjie
aa0546ff37 release: nanokvm@2.4.0 2026-04-10 18:07:23 +08:00
wenjie
3254412017 Squashed commit of the following:
commit 3dea4b1cfcb866d50c9c093b797c0f4f48fec23f
Author: wenjie <meetwenjie@gmail.com>
Date:   Fri Apr 10 17:11:07 2026 +0800

    fix(picoclaw): clean temp media and surface MCP screenshots in chat

    - remove shared load_image staging and pass source paths through directly
    - delete /tmp/picoclaw_media when a gateway session closes
    - push MCP screenshot observations to downstream websocket clients
    - hide null tool feedback messages and auto-scroll when screenshots load

commit f58ffe1e27f83f64e2b0e8c2f8dcac46af715ace
Author: wenjie <meetwenjie@gmail.com>
Date:   Fri Apr 10 15:55:59 2026 +0800

    Harden PicoClaw local API auth and session locking

    Split PicoClaw routes by caller trust level and require the internal loopback token for local-only endpoints. Update the NanoKVM bridge script to send the internal token for loopback requests. Relax session lock acquisition for screenshot and action calls so the active session can perform local operations without permanently taking over the lock, and stop forcing the runtime dm_scope default.

commit 462a0670bbc5138c893fbd1155e72465b82c2065
Author: wenjie <meetwenjie@gmail.com>
Date:   Fri Apr 10 13:52:39 2026 +0800

    fix(picoclaw): improve KVM reliability and sidebar behavior

    - follow HTTPS loopback redirects in the NanoKVM bridge script
    - enable MJPEG frame caching only during active PicoClaw gateway sessions
    - restore legacy screen zoom behavior across MJPEG and H264 renderers
    - keep the PicoClaw sidebar available on mobile without splitter layout conflicts
    - hide empty "null"/"undefined" chat messages and keep MJPEG failures on a black screen

commit bfda85c6368825c7ddf47d347ba5ec2588135885
Author: wenjie <meetwenjie@gmail.com>
Date:   Fri Apr 10 11:37:15 2026 +0800

    fix(picoclaw): verify runtime downloads and remove unused config API

    - verify the downloaded runtime archive against the published SHA-512 checksum
    - remove the unused /api/picoclaw/config endpoints and related frontend state
    - rename runtime_control.go to runtime_constants.go for clearer intent

commit 10b34aaea59e10d70c3cdecb01915444561ceaf7
Author: wenjie <meetwenjie@gmail.com>
Date:   Thu Apr 9 16:32:30 2026 +0800

    fix: stabilize picoclaw runtime defaults and secure local MCP access

    - persist required NanoKVM startup defaults before launching picoclaw and after saving model config
    - force-enable the pico channel when loading config so runtime status can recover to ready
    - derive the local MCP URL from the configured HTTP port and keep loopback-only HTTP access behind an internal token
    - move loopback HTTP redirect logic into middleware to simplify main server startup
    - improve runtime/sidebar state handling and add the load-image endpoint for active picoclaw sessions

commit d20540195bf45ea290f727b473ee8cd2a1bbb68d
Author: wenjie <meetwenjie@gmail.com>
Date:   Fri Mar 27 17:55:29 2026 +0800

    feat(picoclaw): add picoclaw integration
2026-04-10 17:28:28 +08:00
BuGu
fda3c6b1a8 Add default mask for static IP 2026-03-27 10:50:18 +08:00
BuGu
97832557fb Fix the issue of occasional IP not displaying 2026-03-26 18:21:47 +08:00
wenjie
590db5b08c release: nanokvm@2.3.6 2026-03-12 16:02:18 +08:00
wenjie
10a512ec96 feat: Implement AP password authentication for the WiFi configuration page 2026-03-12 15:21:18 +08:00
wenjie
08093dddad Merge pull request #769 from Voranto/Voranto-patch-1
Enable support for 640x480 resolution
2026-03-12 11:21:17 +08:00
wenjie
86a85a61b7 Merge pull request #761 from klim4-bot/main
feat : Add Korean and Japanese virtual keyboard layouts
2026-03-12 11:20:54 +08:00
wenjie
cb3a2c562e Merge pull request #744 from tkmsst/pull-request
Update Japanese translation
2026-03-12 11:18:12 +08:00
Voranto
67fe57f189 Enable support for 640x480 resolution 2026-03-05 15:52:49 +01:00
BuGu
44caee21ab Add encryption parameters for OLED distribution network 2026-03-04 17:11:42 +08:00
BuGu
5b2a1ff788 Add webpage logo generation tool and method 2026-03-04 17:09:52 +08:00
BuGu
dc5ba29bcf Add a logo file generator based on TUI 2026-03-03 19:12:59 +08:00
BuGu
89c14310c7 Add custom logo function 2026-03-03 18:50:35 +08:00
BuGu
3322a3c07f Add update mechanism for tailscale startup script 2026-03-03 18:49:02 +08:00
wj-xiao
9e1251ad6a release: nanokvm@2.3.5 2026-02-28 17:25:14 +08:00
wj-xiao
afec4efb34 feat: Implement login brute-force protection with lockout mechanism 2026-02-28 16:44:47 +08:00
wj-xiao
11980b9122 fix: improve Tailscale error handling in UI components and refine backend state mapping 2026-02-28 11:12:26 +08:00
wj-xiao
2a43fb5413 chore: update server and web dependencies 2026-02-28 10:53:31 +08:00
jason
57887f3f69 Add Korean and Japanese virtual keyboard layouts 2026-02-19 07:47:41 +09:00
tkmsst
2b8f640957 Update Japanese translation 2026-02-02 01:14:57 +09:00
wj-xiao
4af32fc703 release: nanokvm@2.3.4 2026-01-26 17:56:52 +08:00
wj-xiao
a611e5ef56 feat: support keyboard leader key functionality 2026-01-26 17:10:16 +08:00
wj-xiao
94051c3037 chore: added image version number v1.4.2 2026-01-26 11:23:17 +08:00
wj-xiao
d5cd706078 fix: compatible with non-standard modifier keys 2026-01-26 11:12:19 +08:00
wj-xiao
7460ebfd07 fix: the menu bar cannot be closed 2026-01-26 10:46:57 +08:00
wj-xiao
e3877308a0 fix: trigger update check when switching preview updates 2026-01-26 10:28:27 +08:00
wenjie
7c28fb8124 Merge pull request #737 from kmw0410/main
update korean translation
2026-01-26 10:08:02 +08:00
wenjie
b18873364f Merge pull request #733 from j796160836/feature/update_zh_tw
Update zh_tw translation
2026-01-26 10:07:37 +08:00
kmw0410
016a7d8a51 update korean translation 2026-01-25 22:04:24 +09:00
Johnny Sung
585f1bc2b5 feat: update zh_tw translation 2026-01-20 01:59:53 +08:00
wj-xiao
4fcc4c3747 release: nanokvm@2.3.3 2026-01-16 15:32:14 +08:00
wj-xiao
0d2c52adc3 feat: the menu bar supports setting the display mode 2026-01-16 11:47:56 +08:00
wj-xiao
af256e7f92 fix: compatible with IME composition 2026-01-15 17:04:17 +08:00
wj-xiao
84830421ee fix: compatible with Windows AltGr key 2026-01-15 16:45:48 +08:00
wj-xiao
9b278cdb0e fix: compatible with macOS's command key combinations 2026-01-14 17:58:25 +08:00
wj-xiao
9b40a5cf86 chore: update eslint 2026-01-14 17:58:25 +08:00
wj-xiao
b8d980be33 fix: the IntlBackslash key does not work on German virtual keyboard 2026-01-14 17:58:25 +08:00
wenjie
195037af87 Merge pull request #728 from imguoguo/main
ci: add missing parameters and push when issue closed
2026-01-14 15:48:07 +08:00
Guoguo
c98000069a ci: add missing parameters and push when issue closed 2026-01-14 07:43:19 +00:00
wenjie
2bd277b57d Merge pull request #725 from kmw0410/main
update korean translation
2026-01-14 10:30:40 +08:00
wj-xiao
4852ed813b chore: bump react-router-dom from 6.27.0 to 6.30.3 2026-01-14 10:27:08 +08:00
wj-xiao
fb08df9e4e fix: correct erroneous release date 2026-01-12 19:53:26 +08:00
kmw0410
64f3b49e21 update korean translation 2026-01-12 09:11:56 +09:00
wj-xiao
329c0a0001 release: nanokvm@2.3.2 2026-01-08 19:15:52 +08:00
wenjie
c28273d1f0 Merge pull request #721 from imguoguo/ci-use-jq
ci: use jq to format json
2026-01-08 17:33:40 +08:00
Guoguo
1d22d75889 ci: use jq to format json 2026-01-08 09:16:27 +00:00
wj-xiao
6ef8bb74e3 ui: update menu bar 2026-01-08 14:45:50 +08:00
wj-xiao
0e00760474 fix: fix touchscreen scrolling 2026-01-08 14:22:22 +08:00
wenjie
d969bd68ec Merge pull request #705 from imguoguo/add-issue-tracker
feat: add issue tracker
2026-01-06 16:15:31 +08:00
Guoguo
1e5f4cb24b feat: add issue tracker 2026-01-06 08:09:51 +00:00
wj-xiao
4d3b1c835e feat: support custom keyboard shortcuts 2026-01-06 13:58:53 +08:00
wj-xiao
e3558933b1 ui: menu bar stops auto-hiding when submenu is open 2026-01-06 11:08:57 +08:00
wj-xiao
7762706200 fix: set minimum width for mouse speed component 2026-01-05 18:19:06 +08:00
wj-xiao
b66707c959 refactor: support delete request 2026-01-05 17:45:38 +08:00
wj-xiao
a3440cf867 ui: update menu bar 2026-01-05 17:37:44 +08:00
wj-xiao
1b7021d64b refactor: refactor keyboard, mouse, and WebSocket 2026-01-05 16:49:17 +08:00
BuGu
687dd3bfc4 Fix the issue of old IP residue in static IP configuration 2026-01-04 18:18:00 +08:00
wj-xiao
cc072ab158 release: nanokvm@2.3.1 2025-12-26 19:12:33 +08:00
wenjie
b0a01a9837 Merge pull request #611 from lowtech-guy/main
Add user-friendly build system design
2025-12-26 18:27:02 +08:00
wj-xiao
2d575b916c feat:support setting mouse wheel direction 2025-12-26 16:44:12 +08:00
wj-xiao
1f908ef181 fix: add the restart_phy command 2025-12-26 16:11:28 +08:00
wj-xiao
fb2adf13ee refactor: refactor offline update function 2025-12-26 15:38:05 +08:00
wj-xiao
ad9eb44ad8 fix: web terminal may not close properly 2025-12-26 10:53:21 +08:00
wj-xiao
b4962e7a58 fix: custom port might not work 2025-12-26 10:49:29 +08:00
wj-xiao
14614e5e35 ui: optimize menu bar ui 2025-12-26 10:22:59 +08:00
wj-xiao
9a93ab24f5 refactor: refactor virtual devices and HID 2025-12-26 10:06:17 +08:00
wj-xiao
aaad552633 refactor: refactor the Wi-Fi module 2025-12-24 16:48:24 +08:00
wenjie
79cc1cc2bf Merge pull request #692 from Alexander-Ger-Reich/main
Upload lokal ISO files and Offline Updater
2025-12-24 09:48:02 +08:00
wenjie
01bcda6886 Merge pull request #698 from deftdawg/oled_sleep_param-fix
Fix oled_sleep_param only retaining lowest 8-bits of value
2025-12-23 16:13:09 +08:00
wenjie
ec49bffe06 Merge pull request #687 from Deses/ES-lang-update
Updated Spanish translation with all missing strings
2025-12-23 15:32:14 +08:00
wenjie
2db5d86a3c Merge pull request #686 from pekishev/paste_rus
Add support for pasting russian text
2025-12-23 15:31:06 +08:00
wenjie
da6d4840f8 Merge pull request #685 from imguoguo/add-scale
feat: add video scale
2025-12-23 15:19:02 +08:00
wenjie
9afa21a0b0 Merge pull request #679 from acidflash/main
Add missing variables from last version
2025-12-23 15:17:35 +08:00
wenjie
6168069301 Merge pull request #674 from kmw0410/main
update korean translation
2025-12-23 15:01:16 +08:00
wenjie
ffe71ae6b1 Merge pull request #673 from imguoguo/add-autostart-upstream
feat: add autostart feature
2025-12-23 14:59:28 +08:00
wj-xiao
a6a97df4e7 Update NanoKVM-PCIe schematic 2025-12-19 14:21:33 +08:00
deftdawg
f05467f45c Fix oled_sleep_param only retaining lowest 8-bits of value
providing oled_sleep_param with a 16-bit value (i.e. >256), results unpredictable display sleep times as only the low 8-bits are retained due to uint8_t (i.e. 5 minutes 300 seconds; shuts off in ~44s /  300s - 256s), this patch switches to uint16_t which should allow for up to sleeps of up to 65535 seconds.
2025-12-18 16:39:58 -05:00
Alexander-Ger-Reich
969e3a5e6a Update update.go 2025-12-13 16:00:09 +01:00
Alexander-Ger-Reich
f0e7dc069c Update update.go 2025-12-13 15:55:10 +01:00
Alexander-Ger-Reich
f5f70bd015 Update update.go 2025-12-13 15:46:07 +01:00
Alexander-Ger-Reich
aa80e1d7cc Update index.tsx 2025-12-13 15:45:23 +01:00
Alexander-Ger-Reich
d6b03a345b Update en.ts 2025-12-13 15:44:46 +01:00
Alexander-Ger-Reich
c0cfcfa31a Update de.ts 2025-12-13 15:44:30 +01:00
Alexander-Ger-Reich
afdbb179ad Update update.go 2025-12-13 15:37:44 +01:00
Alexander-Ger-Reich
ed9b7c5126 Update index.tsx 2025-12-13 15:37:17 +01:00
Alexander-Ger-Reich
551329a41d Update en.ts 2025-12-13 15:36:52 +01:00
Alexander-Ger-Reich
b3c6b6c53d Update de.ts 2025-12-13 15:36:39 +01:00
Alexander-Ger-Reich
5a9cd18f94 Update en.ts 2025-12-13 15:36:28 +01:00
Alexander-Ger-Reich
63c8fc8eb5 Update en.ts 2025-12-13 15:33:07 +01:00
Alexander-Ger-Reich
3305c69e2d Update de.ts 2025-12-13 15:32:55 +01:00
Alexander-Ger-Reich
56a0468a53 Update index.tsx 2025-12-13 14:55:25 +01:00
Alexander-Ger-Reich
52416dfdd7 Update application.go 2025-12-13 14:54:20 +01:00
Alexander-Ger-Reich
bcd0b717ee Update application.go 2025-12-13 14:54:03 +01:00
Alexander-Ger-Reich
d9c9eae4f9 Create index.tsx 2025-12-13 14:46:26 +01:00
Alexander-Ger-Reich
c1a21c2d69 Update service.go 2025-12-13 14:44:42 +01:00
Alexander-Ger-Reich
8d822948cf Update en.ts 2025-12-13 14:43:44 +01:00
Alexander-Ger-Reich
7404a205a5 Update de.ts 2025-12-13 14:43:29 +01:00
Alexander-Ger-Reich
d774bf8c58 Update download.tsx 2025-12-13 14:42:55 +01:00
Alexander-Ger-Reich
0e1fae8751 Update application.ts 2025-12-13 10:53:28 +01:00
Alexander-Ger-Reich
3ff64483df Update application.go 2025-12-13 10:51:36 +01:00
Alexander-Ger-Reich
fed92254d2 Update application.go 2025-12-13 10:50:09 +01:00
Alexander-Ger-Reich
dec1621850 Update version.go 2025-12-13 10:49:15 +01:00
Alexander-Ger-Reich
116bb50364 Update service.go 2025-12-13 10:04:10 +01:00
wj-xiao
957d86d64c update README 2025-12-12 11:59:04 +08:00
Alexander-Ger-Reich
1fa6f48d4e Update service.go 2025-12-11 23:17:57 +01:00
Alexander-Ger-Reich
0610f6fd1a Update download.tsx 2025-12-11 23:17:33 +01:00
Alexander-Ger-Reich
ade9ac863f Update de.ts 2025-12-11 22:40:30 +01:00
Alexander-Ger-Reich
61ff3c5b23 Update en.ts 2025-12-11 22:40:16 +01:00
Alexander-Ger-Reich
19076b2508 Update en.ts 2025-12-11 00:07:29 +01:00
Alexander-Ger-Reich
e175147674 Update de.ts 2025-12-11 00:06:54 +01:00
Alexander-Ger-Reich
2cc64d2208 Update download.tsx 2025-12-11 00:03:56 +01:00
Alexander-Ger-Reich
346315b4bb Update download.tsx 2025-12-11 00:03:26 +01:00
Alexander-Ger-Reich
648c9f866e Update service.go 2025-12-11 00:02:01 +01:00
Alexander-Ger-Reich
b5866088fc Update download.tsx 2025-12-11 00:01:27 +01:00
Alexander-Ger-Reich
cd954b6f24 Update download.go 2025-12-10 19:27:20 +01:00
Alexander-Ger-Reich
f27102d344 Update download.tsx 2025-12-10 00:25:33 +01:00
Alexander-Ger-Reich
ba8e8b7a5c Merge pull request #5 from Alexander-Ger-Reich/patch-6
Update en.ts
2025-12-10 00:24:25 +01:00
Alexander-Ger-Reich
cb3834bb71 Update en.ts 2025-12-10 00:24:04 +01:00
Alexander-Ger-Reich
8ec134c182 Merge pull request #4 from Alexander-Ger-Reich/patch-4
Update de.ts
2025-12-10 00:22:42 +01:00
Alexander-Ger-Reich
631d944d70 Merge pull request #3 from Alexander-Ger-Reich/patch-1
Update download.tsx
2025-12-10 00:21:40 +01:00
Alexander-Ger-Reich
f539576a49 Merge branch 'main' into patch-1 2025-12-10 00:21:30 +01:00
Alexander-Ger-Reich
fa11347f1b Merge pull request #2 from Alexander-Ger-Reich/patch-2
Update download.tsx
2025-12-10 00:18:55 +01:00
Alexander-Ger-Reich
34c66dbd8b Merge pull request #1 from Alexander-Ger-Reich/patch-5
Update en.ts
2025-12-10 00:18:21 +01:00
Alexander-Ger-Reich
3d5c14405c Update en.ts 2025-12-10 00:10:29 +01:00
Alexander-Ger-Reich
9e4408409f Update de.ts 2025-12-10 00:10:04 +01:00
Alexander-Ger-Reich
9b629c8dc0 Update download.tsx 2025-12-09 23:49:34 +01:00
Alexander-Ger-Reich
8da7a70bfc Update download.tsx 2025-12-09 23:23:40 +01:00
Deses
ea6ac3c903 Updated Spanish translation with all missing strings (compared with English) and fixed lots of little gramatical errors. 2025-12-09 05:04:13 +01:00
Yury Pekishev
6b783677e5 Use Dropdown with languages at paste
Fix translation
2025-12-09 01:31:02 +03:00
Yury Pekishev
1cc31c30a3 revert invisible change 2025-12-09 00:56:52 +03:00
Yury Pekishev
92d5f63679 Merge branch 'main' into paste_rus
# Conflicts:
#	web/src/pages/desktop/menu/keyboard/paste.tsx
2025-12-09 00:50:09 +03:00
Yury Pekishev
9fd3728aee Merge branch 'main' of https://github.com/pekishev/NanoKVM
# Conflicts:
#	web/src/pages/desktop/menu/keyboard/paste.tsx
2025-12-09 00:46:28 +03:00
Yury Pekishev
916803d0f0 Merge branch 'main' into paste_rus 2025-12-08 23:56:46 +03:00
Guoguo
8de298d245 feat: add video scale
Patch from https://github.com/sipeed/NanoKVM-USB/pull/50
2025-12-08 19:17:35 +08:00
wenjie
75fc8d09ce Merge pull request #681 from scpcom/fix-virtual-media-split-merge
S03usbdev: fix virtual media split merge
2025-12-02 09:32:14 +08:00
scpcom
b157b9fe28 S03usbdev: fix virtual media split merge 2025-12-01 23:34:17 +01:00
wenjie
53a800d252 Merge pull request #530 from tpretz/usb-disable
Add setting for disabling the usb-gadget
2025-12-01 17:51:35 +08:00
wenjie
124868e093 Merge branch 'main' into usb-disable 2025-12-01 17:51:28 +08:00
Thomas Pressnell
e684936d15 server side for full gadget disable
starting on frontend

# Conflicts:
#	kvmapp/system/init.d/S03usbdev
2025-12-01 17:26:41 +08:00
wenjie
5958fd5c6f Merge pull request #489 from tpretz/virtual-media-split
Virtual Media Changes
2025-12-01 15:30:02 +08:00
wenjie
4d7ee39ccf Merge branch 'main' into virtual-media-split 2025-12-01 15:28:50 +08:00
Jonas Byström
1bb91518b6 Add missing variables from last version 2025-11-30 21:19:55 +01:00
wj-xiao
2d0123cb18 Update README 2025-11-28 19:02:08 +08:00
kmw_
455bc19fab fix typo in ummountDesc 2025-11-27 21:11:08 +09:00
wj-xiao
0003be478e Fix Tailscale startup script
- The device no longer automatically switches to router mode
- Resolved IPv6 connectivity issues
2025-11-27 10:13:22 +08:00
kmw0410
d448f11861 update korean translation 2025-11-26 21:53:26 +09:00
Guoguo
53924dc8ef feat: add autostart feature 2025-11-26 15:58:19 +08:00
Yury Pekishev
0d1dca3e0a translateRuToEnWithPunctuation 2025-10-30 14:40:59 +03:00
Low Tech
dda98e2a61 Merge branch 'sipeed:main' into main 2025-09-20 16:07:25 +08:00
lowtech-guy
e8016c1d51 remove: comment the cache server line 2025-08-26 05:58:34 +08:00
lowtech-guy
c1e2bed94e add: add build system design for human friendly 2025-08-26 03:40:51 +08:00
Thomas Pressnell
393328eaf6 Revert "cleanup and simplification" and adding reset flag
This reverts commit 7771d1f326.
2025-05-26 07:47:48 +01:00
Thomas Pressnell
7771d1f326 cleanup and simplification 2025-05-25 21:44:53 +01:00
Thomas Pressnell
2a34eb55e6 Update image.go
set imageNone to newline
2025-05-25 19:22:58 +01:00
Thomas Pressnell
9a0c77bb8b server side for full gadget disable
starting on frontend
2025-05-24 16:28:52 +01:00
Thomas Pressnell
a1df6376ec split /data attachment from kvm media attachment
backend config for media control

remove .disabled

adding settings toggle

add default text
2025-05-24 13:29:35 +01:00
249 changed files with 22445 additions and 4892 deletions

13
.dockerignore Normal file
View File

@@ -0,0 +1,13 @@
Dockerfile
!server/go.mod
server/go.sum
server/NanoKVM-Server
web
support/sg2002/kvm_system/build
support/sg2002/kvm_system/dist
support/sg2002/kvm_system/CMakeLists.txt
support/sg2002/kvm_vision_test/build
support/sg2002/kvm_vision_test/dist
support/sg2002/kvm_vision_test/CMakeLists.txt

68
.github/workflows/issue-tracker.yml vendored Normal file
View File

@@ -0,0 +1,68 @@
name: Issue Tracker
on:
issues:
types: [opened, reopened, closed]
jobs:
track-issue:
name: Track Issue Updates
runs-on: ubuntu-latest
steps:
- name: Send Issue Data to API
env:
RAW_TITLE: ${{ github.event.issue.title }}
RAW_BODY: ${{ github.event.issue.body }}
RAW_LABELS: ${{ toJSON(github.event.issue.labels) }}
RAW_ASSIGNEES: ${{ toJSON(github.event.issue.assignees) }}
API_URL: ${{ secrets.ISSUE_TRACKER_API_URL }}
VERIFY_TOKEN: ${{ secrets.VERIFY_TOKEN }}
run: |
ISSUE_DATA=$(jq -n \
--arg action "${{ github.event.action }}" \
--arg id "${{ github.event.issue.id }}" \
--arg num "${{ github.event.issue.number }}" \
--arg title "$RAW_TITLE" \
--arg body "$RAW_BODY" \
--arg state "${{ github.event.issue.state }}" \
--arg created_at "${{ github.event.issue.created_at }}" \
--arg updated_at "${{ github.event.issue.updated_at }}" \
--arg closed_at "${{ github.event.issue.closed_at }}" \
--arg url "${{ github.event.issue.html_url }}" \
--arg user_login "${{ github.event.issue.user.login }}" \
--arg repository "${{ github.repository }}" \
--argjson labels "$RAW_LABELS" \
--argjson assignees "$RAW_ASSIGNEES" \
'{
action: $action,
issue: {
id: ($id | tonumber),
number: ($num | tonumber),
title: $title,
body: $body,
state: $state,
created_at: $created_at,
updated_at: $updated_at,
closed_at: $closed_at,
url: $url,
user: { login: $user_login },
labels: $labels,
assignees: $assignees
},
repository: { name: $repository },
}')
RESPONSE=$(curl -s -w "\n%{http_code}" -X POST "$API_URL" \
-H "Content-Type: application/json" \
-H "Token: $VERIFY_TOKEN" \
-d "$ISSUE_DATA")
HTTP_CODE=$(echo "$RESPONSE" | tail -n1)
if [ "$HTTP_CODE" -ge 200 ] && [ "$HTTP_CODE" -lt 300 ]; then
echo "Successfully sent issue data to API"
else
echo "Failed to send issue data to API"
exit 1
fi

View File

@@ -1,3 +1,133 @@
## 2.4.0 (2026-04-10)
### Features
* **Introduced [PicoClaw](https://github.com/sipeed/picoclaw)** — an AI-powered remote desktop assistant built into NanoKVM. PicoClaw seamlessly integrates a lightweight AI agent with NanoKVM's underlying hardware capabilities. Key highlights include:
* **Zero-Agent Architecture:** Operates entirely through HDMI video capture (vision) and USB HID emulation (keyboard/mouse). No SSH access, network connection to the host, or OS-level software installation is required.
* **Natural Language Control:** Features a built-in chat interface allowing users to issue complex instructions in plain text.
* **Autonomous GUI Operation:** The AI agent can autonomously observe the remote host's screen, understand UI elements, reason about the task, and execute operations mimicking human behavior.
### Bug Fixes
* Added a default subnet mask for static IP configurations
* Fixed an issue where the IP address would occasionally not display
## 2.3.6 (2026-03-12)
### Features
* Implemented AP password authentication for the WiFi configuration page
* Added Korean and Japanese virtual keyboard layouts (thanks to [@klim4-bot](https://github.com/klim4-bot))
* Enabled support for 640x480 resolution (thanks to [@Voranto](https://github.com/Voranto))
* Added encryption parameters for OLED distribution network
* Added custom logo function and logo generation tools
* Added update mechanism for tailscale startup script
### Localization
* Updated Japanese translation (thanks to [@tkmsst](https://github.com/tkmsst))
## 2.3.5 (2026-02-28)
### Features
* Implemented login brute-force protection with lockout mechanism
### Bug Fixes
* Improved Tailscale error handling in UI components and refined backend state mapping
### Chores
* Updated server and web dependencies
## 2.3.4 (2026-01-26)
### Features
* Introduced `Leader Key` functionality to bypass local browser shortcut interception
### Bug Fixes
* Fixed an issue where the Right Shift key was not being recognized on Windows system
* Fixed a bug where the menu bar would fail to close correctly
### Localization
* Updated Traditional Chinese translations (thanks to [@j796160836](https://github.com/j796160836))
* Updated Korean translations (thanks to [@kmw0410](https://github.com/kmw0410))
## 2.3.3 (2026-01-16)
### Features
* Added support for setting the display mode of the menu bar
### Bug Fixes
* Fixed a keyboard issue where IME composition events were not handled correctly
* Resolved an issue where the `AltGr` key was not recognized on the Windows system
* Fixed a bug where `Command` key combinations were not fully released on the macOS system
* Fixed input issues with the `IntlBackslash` key on the German virtual keyboard
### Localization
* Updated Korean translations (thanks to [@kmw0410](https://github.com/kmw0410))
### Chores
* Bump react-router-dom from 6.27.0 to 6.30.3
* Updated eslint
## 2.3.2 (2026-01-08)
### Features
* Added support for custom keyboard shortcuts
* Added support for mouse Forward and Back buttons (reboot required)
* Added support for touchscreen mouse operation
* The menu bar now supports auto-hide and drag
### Bug Fixes
* Fixed an issue where the previous IP address was not released after configuring a static IP
### Performance
* Refactored the keyboard to support simultaneous keystrokes and a wider range of international layouts (reboot required)
* Refactored the mouse to improve response latency
* Refactored the WebSocket module to transmit keyboard and mouse data using the standard HID format
## 2.3.1 (2025-12-26)
### Features
* Added support for offline application updates (thanks to [@Alexander-Ger-Reich](https://github.com/Alexander-Ger-Reich))
* Implemented support for uploading ISO images (thanks to [@Alexander-Ger-Reich](https://github.com/Alexander-Ger-Reich))
* Extended clipboard compatibility to support Russian characters (thanks to [@pekishev](https://github.com/pekishev))
* Added configuration options for video scaling
* Added support for configuring mouse scroll wheel direction
### Bug Fixes
* Resolved an issue where custom port may not be accessible
* Fixed a bug where Web Terminal and Serial Terminal may not disconnect when the page is closed
### Security
* Enhanced validation logic on the Wi-Fi configuration page. Requests are now rejected when the device is not in AP mode to prevent unauthorized changes
### Chore
* Added a Docker image environment to support building `libkvm.so` and `NanoKVM-Server` (thanks to [@lowtech-guy](https://github.com/lowtech-guy))
* Optimized the Settings UI
### Localization
* Updated Korean translation (thanks to [@kmw0410](https://github.com/kmw0410))
* Updated Swedish translation (thanks to [@acidflash](https://github.com/acidflash))
* Updated Spanish translation (thanks to [@Deses](https://github.com/Deses))
## 2.3.0 [0e30a26](https://github.com/sipeed/NanoKVM/commit/0e30a26db42cbc08416662b49a88a5d16ad93424) (2025-11-26)
### Features

97
Makefile Normal file
View File

@@ -0,0 +1,97 @@
# Makefile for NanoKVM Project
# Configuration
IMAGE_NAME := nanokvm-builder
UID := $(shell id -u)
GID := $(shell id -g)
PWD := $(shell pwd)
# Docker run common parameters
DOCKER_RUN_BASE := docker run -e UID=$(UID) -e GID=$(GID) -v $(PWD):/home/build/NanoKVM --rm
# Build commands
GO_BUILD_CMD := cd /home/build/NanoKVM/server && go mod tidy && CGO_ENABLED=1 GOOS=linux GOARCH=riscv64 CC=riscv64-unknown-linux-musl-gcc CGO_CFLAGS="-mcpu=c906fdv -march=rv64imafdcv0p7xthead -mcmodel=medany -mabi=lp64d" go build
SUPPORT_BUILD_CMD := . ./home/build/MaixCDK/bin/activate && cd /home/build/NanoKVM/support/sg2002 && ./build kvm_system && ./build kvm_system add_to_kvmapp
.PHONY: help check-root builder-image rebuild-image check-image shell app support all clean
# Default target
all: app support
# Help target
help:
@echo "NanoKVM Build System"
@echo ""
@echo "Available targets:"
@echo " help - Show this help message"
@echo " check-image - Check builder Docker image and show versions"
@echo " builder-image - Build Docker image if not exists"
@echo " rebuild-image - Force rebuild Docker image"
@echo " shell - Enter interactive builder environment"
@echo " app - Build Go application server"
@echo " support - Build hardware support libraries"
@echo " all - Build both app and support (default)"
@echo " clean - Clean build artifacts"
@echo ""
@echo "Prerequisites:"
@echo " - Docker must be installed and running"
@echo " - Must not run as root user"
# Security check - prevent running as root
check-root:
@if [ "$$(id -u)" -eq 0 ]; then \
echo "Can't run as root"; \
exit 1; \
fi
# Check if builder image exists and show versions
check-image: check-root
@echo "Checking builder image..."
@echo "Golang version: " && \
docker run --rm -i $(IMAGE_NAME) go version && \
echo "" && \
echo "Host-tools version:" && \
docker run --rm -i $(IMAGE_NAME) riscv64-unknown-linux-musl-gcc -v && \
echo ""
# Build Docker image if it doesn't exist
builder-image: check-root
@if ! docker image inspect $(IMAGE_NAME) >/dev/null 2>&1; then \
echo "Building Docker image..."; \
docker build -t $(IMAGE_NAME) -f docker/Dockerfile ./; \
else \
echo "Docker image $(IMAGE_NAME) already exists."; \
fi
# Force rebuild Docker image
rebuild-image: check-root
@echo "Force rebuilding Docker image..."
@docker build --no-cache -t $(IMAGE_NAME) -f docker/Dockerfile ./
# Enter interactive shell (equivalent to build.sh with no arguments)
shell: check-root builder-image
@echo "Switching into builder..."
@$(DOCKER_RUN_BASE) -it $(IMAGE_NAME) /bin/bash -c ". ./home/build/MaixCDK/bin/activate && cd /home/build/NanoKVM ; exec bash"
# Build Go application
app: check-root builder-image
@echo "Building app..."
@$(DOCKER_RUN_BASE) -it $(IMAGE_NAME) /bin/bash -c '$(GO_BUILD_CMD)'
# Build hardware support libraries
support: check-root builder-image
@echo "Building support..."
@$(DOCKER_RUN_BASE) -it $(IMAGE_NAME) /bin/bash -c '$(SUPPORT_BUILD_CMD)'
# Clean build artifacts
clean:
@echo "Cleaning build artifacts..."
@if [ -f server/NanoKVM-Server ]; then \
rm -f server/NanoKVM-Server; \
echo "Removed server/NanoKVM-Server"; \
fi
@if [ -d support/sg2002/build ]; then \
rm -rf support/sg2002/build; \
echo "Removed support/sg2002/build"; \
fi
@echo "Clean completed."

View File

@@ -1,55 +1,47 @@
NanoKVM
======
# NanoKVM
<div align="center">
![](https://wiki.sipeed.com/hardware/assets/NanoKVM/introduce/NanoKVM_3.png)
<h3>
<br>
<img src="https://wiki.sipeed.com/hardware/assets/NanoKVM/introduce/NanoKVM_3.png" alt="NanoKVM" style="margin: 20px 0;">
<h3>
<a href="https://wiki.sipeed.com/hardware/en/kvm/NanoKVM/introduction.html">🚀 Quick Start</a>
|
<a href="https://cn.dl.sipeed.com/shareURL/KVM/nanoKVM">🛠️ Hardware Details</a>
|
<a href="https://github.com/sipeed/NanoKVM/releases/latest">💾 Firmware Releases</a>
</h3>
English | [中文](./README_ZH.md) | [日本語](./README_JA.md) | [한국어](./README_KO.md)
</h3>
<br>
</div>
> For the recent discussion regarding the **built-in microphone**, please check [Issue #693](https://github.com/sipeed/NanoKVM/issues/693).
## 🌟 What is NanoKVM?
NanoKVM is a series of compact, open-source IP-KVM devices. Built upon the LicheeRV Nano (RISC-V), NanoKVM allows you to remotely access and control computers as if you were sitting right in front of them – perfect for managing servers, embedded systems, or any headless machine.
NanoKVM is a series of compact, open-source IP-KVM devices based on the LicheeRV Nano (RISC-V). NanoKVM allows you to remotely access and control computers as if you were sitting right in front of them – perfect for managing servers, embedded systems, or any headless machine.
## 📦 Product Family
We offer several NanoKVM versions to suit your needs:
* **NanoKVM-Cube Lite:** A barebones kit for DIY enthusiasts and enterprise users needing bulk deployment.
* **NanoKVM-Cube Full:** A complete package with a sleek case, accessories, and a pre-flashed system SD card. Ready to use out-of-the-box, ideal for individual users.
* **NanoKVM-PCIe:** A unique form factor with a PCIe bracket for internal chassis mounting, drawing power directly from a PCIe slot. And add optional WiFi and PoE functions.
### 🚀 NanoKVM-Pro: The 4K Powerhouse
Based on popular demand, we've upgraded NanoKVM to the **NanoKVM-Pro**:
* **Resolution Upgrade:** From 1080P to stunning **4K@30fps / 2K@60fps**.
* **Network Upgrade:** Blazing fast **GbE + PoE + WiFi 6** (up from 100M Ethernet).
* **Latency Upgrade:** Encoder accelerated latency reduced from 100-150ms to **50-100ms**.
> The NanoKVM-Pro is now available for pre-sale! [Click here for product details](https://sipeed.com/nanokvm/pro).
* **NanoKVM-Cube Lite:** A barebones kit for DIY enthusiasts and enterprise users requiring bulk deployment.
* **NanoKVM-Cube Full:** A complete package featuring a sleek case, accessories, and a pre-flashed system SD card. Ready to use out-of-the-box; ideal for individual users.
* **NanoKVM-PCIe:** A unique form factor with a PCIe bracket for internal chassis mounting. It draws power directly from the PCIe slot and supports optional WiFi and PoE functions.
* **[NanoKVM-Pro:](https://github.com/sipeed/NanoKVM-Pro)** Based on popular demand, the **Pro version** delivers significant performance upgrades:
* **Resolution:** Upgraded from 1080P to stunning **4K@30fps / 2K@60fps**.
* **Network:** Blazing fast **1Gbps Ethernet + PoE + WiFi 6** ((upgraded from 100Mbps).
* **Latency:** Encoder accelerated latency reduced from 100-150ms to **50-100ms**.
<div align="center">
![NanoKVM Product Family](https://cdn.sipeed.com/public/nanokvm-products.jpg)
<img src="https://cdn.sipeed.com/public/nanokvm-products.jpg" alt="NanoKVM Product Family" width="80%" style="margin: 20px 0;">
</div>
> If you are looking for a USB-based KVM solution, check out [NanoKVM-USB](https://github.com/sipeed/NanoKVM-USB).
## 🛠️ Technical Specifications
| Product | NanoKVM-Pro | NanoKVM (Cube/PCIe) | GxxKVM | JxxKVM |
| Feature | NanoKVM-Pro | NanoKVM (Cube/PCIe) | GxxKVM | JxxKVM |
|------------------- |-------------------------------------- |---------------------------------- |----------------------------------- |------------------------------------ |
| Core | AX631 2xA53 1.5G | SG2002 1xC906 1.0G | RV1126 4xA7 1.5G | RV1106 1xA7 1.2G |
| Core | AX630C 2xA53 1.2G | SG2002 1xC906 1.0G | RV1126 4xA7 1.5G | RV1106 1xA7 1.2G |
| Memory & Storage | 1G LPDDR4X + 32G eMMC | 256M DDR3 + 32G microSD | 1G DDR3 + 8G eMMc | 256M DDR3 + 16G eMMC |
| System | NanoKVM / PiKVM | NanoKVM | GxxKVM | JxxKVM |
| Resolution | 4K@30fps / 2K@60fps | 1080P@60fps | 4K@30fps / 2K@60fps | 1080P@60fps |
@@ -62,7 +54,7 @@ Based on popular demand, we've upgraded NanoKVM to the **NanoKVM-Pro**:
| IPMI | ✓ | ✓ | ✓ | x |
| Wake-on-LAN | ✓ | ✓ | ✓ | ✓ |
| Web Terminal | ✓ | ✓ | ✓ | ✓ |
| Serial Terminal | 3 channels | 2 channels | x | 1 channel |
| Serial Terminal | 2 channels | 2 channels | x | 1 channel |
| Custom Scripts | ✓ | ✓ | x | x |
| Storage | 32G eMMC 300MB/s | 32G MicroSD 12MB/s | 8G eMMC 120MB/s | 8G eMMC 60MB/s |
| Ethernet | 1000M | 100M | 1000M | 100M |
@@ -71,31 +63,28 @@ Based on popular demand, we've upgraded NanoKVM to the **NanoKVM-Pro**:
| ATX Power Control | ✓ | ✓ | Extra $15 | Extra $10 |
| Display | 1.47" 320x172 LCD / 0.96" 128x64 OLED | 0.96" 128x64 OLED | - | 1.68" 280x240 |
| More Features | Sync LED Strip / Smart Assistant | - | - | - |
| Power Consumption | 0.4A@5V | 0.2A@5V | 0.4A@5V | 0.2A@5V |
| Power Consumption | 0.6A@5V | 0.2A@5V | 0.4A@5V | 0.2A@5V |
| Power Input | USB-C or PoE | USB-C | USB-C | USB-C |
| Dimensions | 65x65x26mm | 40x36x36mm | 80x60x17.5mm | 60x43x(24~31)mm |
| Price | ~~$79~~ $69 ATX / ~~$89~~ $79 Desk | $25 Lite / $50 Full(with ATX) | $89 no ATX / $102 with ATX | $69 no ATX / $79 with ATX |
## 📂 Project Structure
``` shell
├── kvmapp # APP update package
│ ├── jpg_stream # Compatible for direct updates from very old versions
│ ├── kvm_new_app # Triggers necessary components for kvm_system update
│ ├── kvm_system # kvm_system application
│ ├── server # NanoKVM front-end and back-end applications
│ └── system # Necessary system components
├── web # NanoKVM front-end
├── server # NanoKVM back-end
├── support # Auxiliary functions (image subsystem, system status, system updates, screen, keys, etc.)
│ ├── sg2002 # NanoKVM-Lite/Full/PCIe
│ └── h618 # NanoKVM-Pro
│ ├── jpg_stream # Legacy support for direct updates from older versions
│ ├── kvm_new_app # Triggers components for kvm_system updates
│ ├── kvm_system # Core KVM application
│ ├── server # Front-end and back-end integration
│ └── system # Essential system components
├── web # NanoKVM Front-end (UI)
├── server # NanoKVM Back-end (Service)
├── support # Auxiliary modules (Image subsystem, status, updates, OLED, HID, etc.)
├── ...
```
## 🔩 Hardware Platform (NanoKVM Cube/PCIe)
NanoKVM is based on Sipeed [LicheeRV Nano](https://wiki.sipeed.com/hardware/zh/lichee/RV_Nano/1_intro.html),you can find specifications, schematics, and dimensional drawings [here](http://cn.dl.sipeed.com/shareURL/LICHEE/LicheeRV_Nano).
NanoKVM is based on Sipeed [LicheeRV Nano](https://wiki.sipeed.com/hardware/zh/lichee/RV_Nano/1_intro.html). You can find specifications, schematics, and dimensional drawings in the [download station](http://cn.dl.sipeed.com/shareURL/LICHEE/LicheeRV_Nano).
The NanoKVM Lite is constructed by the LicheeRV Nano and HDMItoCSI board, and the NanoKVM FULL adds the NanoKVM-A/B board and shell to the NanoKVM Lite. The HDMItoCSI board is used to convert the HDMI signal; NanoKVM-A, including OLED, ATX control output (USB Type-C interface), auxiliary power supply and ATX power on/off and reset buttons; The NanoKVM-B is connected to the plate at one end and the computer at the other end is connected to the computer ATX-Pin, which is used to remotely control the power of the computer.
@@ -105,9 +94,12 @@ Note: Out of the 256MB memory in SG2002, 158MB is currently allocated for the mu
* [NanoKVM-A Schematic](https://cn.dl.sipeed.com/fileList/KVM/nanoKVM/HDK/02_Schematic/SCH_RV_Nano_KVM_A_30111.pdf)
* [NanoKVM-B Schematic](https://cn.dl.sipeed.com/fileList/KVM/nanoKVM/HDK/02_Schematic/SCH_RV_Nano_KVM_B_30131.pdf)
* [NanoKVM-PCIe Schematic](https://cn.dl.sipeed.com/fileList/KVM/KVM_PCIE/HDK/01_Schematic/SCH_nanoKVM_PCIE_3105D_2025-12-19.pdf)
* [NanoKVM img](https://github.com/sipeed/NanoKVM/releases/tag/NanoKVM)
![NanoKVM PCB Pinout](https://wiki.sipeed.com/hardware/zh/kvm/assets/NanoKVM/1_intro/NanoKVM_2.jpg)
<div align="center">
<img src="https://wiki.sipeed.com/hardware/zh/kvm/assets/NanoKVM/1_intro/NanoKVM_2.jpg" alt="NanoKVM PCB Pinout" width="80%" style="margin: 20px 0;">
</div>
## 🤝 Contributing
@@ -131,8 +123,10 @@ Please keep your pull requests small and focused to facilitate easier review and
## 💬 Community & Support
* [MaixHub Discussion](https://maixhub.com/discussion/nanokvm)
* [Discord](https://discord.gg/V4sAZ9XWpN)
* QQ group: 703230713
* email: [support@sipeed.com](mailto:support@sipeed.com)
* [FAQ](https://wiki.sipeed.com/hardware/en/kvm/NanoKVM/faq.html)
## 📜 License

View File

@@ -1,129 +0,0 @@
NanoKVM
======
<div align="center">
![](https://wiki.sipeed.com/hardware/assets/NanoKVM/introduce/NanoKVM_3.png)
<h3>
<a href="https://wiki.sipeed.com/hardware/en/kvm/NanoKVM/introduction.html"> クイックスタート </a>
|
<a href="https://cn.dl.sipeed.com/shareURL/KVM/nanoKVM"> ハードウェア </a>
</h3>
[English](./README.md) | [中文](./README_ZH.md) | 日本語 | [한국어](./README_KO.md)
</div>
> NanoKVM は [RISC-V](https://en.wikipedia.org/wiki/RISC-V) を搭載しています!
> 使用中に問題や提案がある場合は、ここで issue を提出するか、[MaixHub Discussion](https://maixhub.com/discussion/nanokvm) でお知らせください。
## オープンソース & コントリビューション
### 2025.02.19 更新
NanoKVM のすべてのコンポーネントはオープンソース化されており、[front-end](https://github.com/sipeed/NanoKVM/tree/main/web)、[back-end](https://github.com/sipeed/NanoKVM/tree/main/server)、[kvm_vision](https://github.com/sipeed/NanoKVM/tree/main/vision/components/kvm)、[kvm_mmf](https://github.com/sipeed/NanoKVM/tree/main/vision/components/kvm_mmf)、[kvm_system](https://github.com/sipeed/NanoKVM/tree/main/support)、[kvmapp update package](https://github.com/sipeed/NanoKVM-System/tree/main/kvmapp)、[system sdk](https://github.com/sipeed/LicheeRV-Nano-Build/tree/NanoKVM)、[packaging methods](https://github.com/sipeed/LicheeRV-Nano-Build/blob/NanoKVM/kvm/NanoKVM_img.sh)が含まれます。
### 2025.02.14 更新
最新のアプリケーションバージョン v2.1.6 には、多くのセキュリティ強化とバグ修正が追加されているため、一般のユーザーはこのバージョン以降へのアップグレードをお勧めします。
### 2025.02.05 更新
セキュリティを懸念しているユーザーは、この issue をお読みください: <https://github.com/sipeed/NanoKVM/issues/301>、セキュリティに関するすべての懸念事項について説明されています。
GitHub は、オープンで透明性のあるプラットフォームとして、いわゆる「バックドア」の懸念事項を議論する場を提供しています。オープンソースであることにより、製品のセキュリティがさらに強化されます!
### 2024.10.18 更新
10/08にバックエンドコードをオープンソース化しました。すぐにPRを提出してくれたCivilに感謝します。最新のNanoKVM-PCIeを報酬として提供します!
10月中旬のバッチはテストとパッケージングを開始しており、10/01以前のほとんどの注文は次週と次々週に出荷されます。
10/01以降、アリエクスプレスの出荷日は12月に変更されました。これは保守的な納期です。10月の注文は11/15〜12/15の間に発送される予定です。
## 紹介
Lichee NanoKVMは、LicheeRV NanoをベースにしたIP-KVM製品で、LicheeRV Nanoの極小サイズと強力な機能を継承しています。
Lichee NanoKVMは2つのバージョンがあります:
NanoKVM Liteは基本構成で、一定のDIY能力を持つ個人ユーザーや大量のニーズを持つ企業ユーザーに適しています。
NanoKVM Fullは完全版で、洗練されたケースと完全なアクセサリーを備え、起動時にすぐに使用できるシステムミラーカードが内蔵されており、個人ユーザーに推奨されます。
![NanoKVM 分解画像](https://wiki.sipeed.com/hardware/zh/kvm/assets/NanoKVM/1_intro/NanoKVM_1.jpg)
## 技術仕様
| 製品 | NanoKVM (Lite) | NanoKVM (Full) | PiKVM V4 |
|----------------------- |-------------------------------------- |---------------------------------- |----------------------------------- |
| 計算ユニット | LicheeRV Nano(RISCV) | LicheeRV Nano(RISCV) | CM4 (ARM) |
| 解像度 | 1080P @ 60fps | 1080P @ 60fps | 1080P @ 60fps |
| ビデオエンコーディング | MJPEG, H264 | MJPEG, H264 | MJPEG, H264 |
| ビデオ遅延 | 90~230ms | 90~230ms | 100~230ms |
| UEFI/BIOS | ✓ | ✓ | ✓ |
| 仮想HID | ✓ | ✓ | ✓ |
| 仮想CD-ROM | ✓ | ✓ | ✓ |
| IPMI | ✓ | ✓ | ✓ |
| Wake-on-LAN | ✓ | ✓ | ✓ |
| ETH | 100M/10M | 100M/10M | 1000M/100M/10M |
| ATX電源制御 | なし、ユーザーが自分で接続 | USBインターフェースIO制御ボード | RJ-45インターフェースIO制御ボード |
| OLED | なし、ユーザーが自分で接続 | 128x64 0.96" 白 | 128x32 0.91" 白 |
| UART | 2 | 2 | 1 |
| TFカード | なし | ✓ | ✓ |
| 拡張 | なし | PoE | WiFi/LTE |
| 消費電力 | 0.2A@5V | 0.2A@5V | ピーク時 2.6A@5V |
| 電源入力 | PC USBで給電可能 | PC USBまたは補助電源 | DC 5V 3A電源が必要 |
| 冷却 | 静音ファンレス | 静音ファンレス | ファン冷却 |
| サイズ | 23x37x15mm ~1/30 PiKVM V4サイズ | 40x36x36mm ~1/7 PiKVM V4サイズ | 120x68x44mm |
![NanoKVM PCB ピン配置](https://wiki.sipeed.com/hardware/zh/kvm/assets/NanoKVM/1_intro/NanoKVM_2.jpg)
## NanoKVM ハードウェアプラットフォーム
NanoKVMはSipeed [LicheeRV Nano](https://wiki.sipeed.com/hardware/zh/lichee/RV_Nano/1_intro.html)に基づいており、仕様、回路図、寸法図は[こちら](http://cn.dl.sipeed.com/shareURL/LICHEE/LicheeRV_Nano)で確認できます。
NanoKVM LiteはLicheeRV NanoとHDMItoCSIボードで構成され、NanoKVM FULLはNanoKVM LiteにNanoKVM-A/Bボードとシェルを追加しています。HDMItoCSIボードはHDMI信号を変換するために使用されます。NanoKVM-AにはOLED、ATX制御出力(USB Type-Cインターフェース)、補助電源供給およびATX電源オン/オフおよびリセットボタンが含まれています。NanoKVM-Bは一端がAボードに接続され、他端がコンピュータのATXピンに接続され、コンピュータの電源をリモートで制御します。
NanoKVMイメージはLicheeRV Nano SDKおよびMaixCDKに基づいて構築されており、LicheeRV Nanoを使用する資料と互換性があります。逆に、KVMソフトウェアはLicheeRV Nanoや他のSG2002製品では使用できません。LicheeRV NanoやMaixCamでHDMI入力アプリケーションを構築したい場合は、技術サポートを受けるためにお問い合わせください。
注:SG2002の256MBメモリのうち、現在158MBがマルチメディアサブシステムに割り当てられており、NanoKVMはビデオ画像の取得と処理にこのメモリを使用します。
+ [NanoKVM-A 回路図](https://cn.dl.sipeed.com/fileList/KVM/nanoKVM/HDK/02_Schematic/SCH_RV_Nano_KVM_A_30111.pdf)
+ [NanoKVM-B 回路図](https://cn.dl.sipeed.com/fileList/KVM/nanoKVM/HDK/02_Schematic/SCH_HDMI_MIPI_31011.pdf)
+ [NanoKVM イメージダウンロード](https://github.com/sipeed/NanoKVM/releases/tag/NanoKVM)
## ロードマップ
### Q1 2025
**改善:**
- [x] HDMI モジュールをリファクタリングし、libmaixcam_lib.so の依存関係を削除
- [x] SSH をデフォルトで無効にし、Web UI に有効/無効の切り替えを追加
- [x] Tailscale をデフォルトで無効にし、Web UI に有効/無効の切り替えを追加
- [x] 設定ファイルに JWT 設定オプションを追加
- [x] bcrypt を利用したパスワードストレージのセキュリティ保護
**配布:**
- [x] GitHub 経由でのアプリケーション配布
- [x] オンラインでの更新の際に整合性チェックを実施
- [ ] オフラインでの更新をサポート
**バグ修正:**
- [x] DNS の問題を修正
- [x] CSRF の脆弱性を修正
**機能**
- [ ] [79](https://github.com/sipeed/NanoKVM/issues/79) Zerotier をサポート
- [ ] [99](https://github.com/sipeed/NanoKVM/issues/99) WireGuard をサポート
- [ ] [249](https://github.com/sipeed/NanoKVM/issues/249) Mouse Jiggler を追加
## 購入方法
* [Aliexpress(グローバル、米国とロシアを除く)](https://www.aliexpress.com/item/1005007369816019.html)
* [淘宝](https://item.taobao.com/item.htm?id=811206560480)
* [予約注文 (淘宝とアリエクスプレスがサポートされていない国)](https://sipeed.com/nanokvm)
## コミュニティ
* [MaixHub Discussion](https://maixhub.com/discussion/nanokvm)
* QQグループ: 703230713

View File

@@ -1,139 +0,0 @@
NanoKVM
======
<div align="center">
![](https://wiki.sipeed.com/hardware/assets/NanoKVM/introduce/NanoKVM_3.png)
<h3>
<a href="https://wiki.sipeed.com/hardware/en/kvm/NanoKVM/introduction.html">🚀 빠른 시작</a>
|
<a href="https://cn.dl.sipeed.com/shareURL/KVM/nanoKVM">🛠️ 하드웨어 정보</a>
|
<a href="https://github.com/sipeed/NanoKVM/releases/latest">💾 펌웨어 릴리즈</a>
</h3>
[English](./README.md) | [中文](./README_ZH.md) | [日本語](./README_JA.md) | 한국어
</div>
## 🌟 NanoKVM이 무엇인가요?
NanoKVM은 소형 오픈 소스 IP-KVM 장치 시리즈입니다. LicheeRV Nano(RISC-V)를 기반으로 구축된 NanoKVM을 사용하면 마치 컴퓨터 앞에 바로 앉아있는 것처럼 원격으로 컴퓨터에 접근하고 제어할 수 있어 서버, 임베디드 시스템 또는 모든 헤드리스 머신을 관리하는 데 완벽합니다.
## 📦 제품군
다양한 요구를 충족할 수 있도록 여러 NanoKVM 버전을 제공합니다:
* **NanoKVM-Cube Lite:** DIY 사용자 및 대량 배포가 필요한 기업용을 위한 최소 구성 키트입니다.
* **NanoKVM-Cube Full:** 세련된 케이스, 액세서리, 사전 플래시된 시스템 SD 카드가 포함된 완전 구성 패키지입니다. 개봉 후 즉시 사용할 수 있어 개인 사용자에게 적합합니다.
* **NanoKVM-PCIe:** 독특한 폼팩터로, PCIe 브래킷을 통해 섀시 내부에 장착하며, 전원인 PCIe 슬롯에서 직접 공급받습니다. 선택적으로 WiFi와 PoE 기능을 추가할 수 있습니다.
### 🚀 NanoKVM-Pro: 4K를 위한 파워 머신
대중적인 수요를 기반으로, NanoKVM을 **NanoKVM-Pro**로 업그레이드했습니다:
* **해상도 업그레이드:** 1080P에서 놀라운 **4K@30fps / 2K@60fps**까지.
* **네트워크 업그레이드:** 초고속 **GbE + PoE + WiFi 6** (100M 이더넷 대비 향상).
* **지연 시간 업그레이드:** 인코더 가속 지연시간이 100-150ms에서 **50-100ms**로 감소.
> NanoKVM-Pro가 예약 판매 중입니다! [제품 세부 정보는 여기를 클릭하세요](https://sipeed.com/nanokvm/pro).
<div align="center">
![NanoKVM Product Family](https://cdn.sipeed.com/public/nanokvm-products.jpg)
</div>
## 🛠️ 기술 사양
| 제품 | NanoKVM-Pro | NanoKVM (Cube/PCIe) | GxxKVM | JxxKVM |
|------------------- |-------------------------------------- |---------------------------------- |----------------------------------- |------------------------------------ |
| 코어 | AX631 2xA53 1.5G | SG2002 1xC906 1.0G | RV1126 4xA7 1.5G | RV1106 1xA7 1.2G |
| 메모리 & 저장 공간 | 1G LPDDR4X + 32G eMMC | 256M DDR3 + 32G microSD | 1G DDR3 + 8G eMMc | 256M DDR3 + 16G eMMC |
| 시스템 | NanoKVM / PiKVM | NanoKVM | GxxKVM | JxxKVM |
| 해상도 | 4K@30fps / 2K@60fps | 1080P@60fps | 4K@30fps / 2K@60fps | 1080P@60fps |
| HDMI 루프아웃 | 4K 루프아웃 | x | x | x |
| 영상 인코딩 | MJPEG / H.264 / H.265 | MJPEG / H264 | MJPEG / H264 | MJPEG / H264 |
| 오디오 전송 | ✓ | x | ✓ | x |
| UEFI / BIOS | ✓ | ✓ | ✓ | ✓ |
| USB 키보드 & 마우스 에뮬레이션 | ✓ | ✓ | ✓ | ✓ |
| USB ISO 에뮬레이션 | ✓ | ✓ | ✓ | ✓ |
| IPMI | ✓ | ✓ | ✓ | x |
| Wake-on-LAN | ✓ | ✓ | ✓ | ✓ |
| 웹 터미널 | ✓ | ✓ | ✓ | ✓ |
| 시리얼 터미널 | 3 채널 | 2 채널 | x | 1 채널 |
| 사용자 정의 스크립트 | ✓ | ✓ | x | x |
| 저장 공간 | 32G eMMC 300MB/s | 32G MicroSD 12MB/s | 8G eMMC 120MB/s | 8G eMMC 60MB/s |
| 이더넷 | 1000M | 100M | 1000M | 100M |
| PoE | 옵션 | 옵션 | x | x |
| WiFi | WiFi6 옵션 | WiFi6 옵션 | x | x |
| ATX 전원 조작 | ✓ | ✓ | $15 추가 | $10 추가 |
| 디스플레이 | 1.47" 320x172 LCD / 0.96" 128x64 OLED | 0.96" 128x64 OLED | - | 1.68" 280x240 |
| 기타 기능 | LED 줄 동기화 / 스마트 어시스턴트 | - | - | - |
| 전력 소비량 | 0.4A@5V | 0.2A@5V | 0.4A@5V | 0.2A@5V |
| 전원 입력 | USB-C 또는 PoE | USB-C | USB-C | USB-C |
| 크기 | 65x65x26mm | 40x36x36mm | 80x60x17.5mm | 60x43x(24~31)mm |
| 가격 | ~~$79~~ $69 ATX / ~~$89~~ $79 Desk | $25 Lite / $50 Full(with ATX) | ATX 미사용 시 $89 / ATX 사용 시 $102 | ATX 미사용 시 $69 / ATX 사용 시 $79 |
## 📂 프로젝트 구조
``` shell
├── kvmapp # APP 업데이트 패키지
│ ├── jpg_stream # 매우 오래된 버전에서도 직접 업데이트 가능하도록 호환됨
│ ├── kvm_new_app # kvm_system 업데이트에 필요한 구성 요소를 트리거함
│ ├── kvm_system # kvm_system 애플리케이션
│ ├── server # NanoKVM 프론트 엔드와 백엔드 애플리케이션
│ └── system # 필요한 시스템 구성 요소
├── web # NanoKVM 프론트엔드
├── server # NanoKVM 백엔드
├── support # 보조 기능 (이미지 하위 시스템, 시스템 상태, 시스템 업데이트, 화면, 키, 기타.)
│ ├── sg2002 # NanoKVM-Lite/Full/PCIe
│ └── h618 # NanoKVM-Pro
├── ...
```
## 🔩 하드웨어 플랫폼 (NanoKVM Cube/PCIe)
NanoKVM은 Sipeed [LicheeRV Nano](https://wiki.sipeed.com/hardware/zh/lichee/RV_Nano/1_intro.html)를 기반으로 하며,[여기](http://cn.dl.sipeed.com/shareURL/LICHEE/LicheeRV_Nano)에서 사양, 도식, 치수, 도면은 여기에서 찾을 수 있습니다.
NanoKVM Lite는 LicheeRV Nano와 HDMItoCSI 보드로 구성되어 있으며, NanoKVM FULL은 NanoKVM Lite에 NasnoKVM-A/B 보드와 케이스를 추가한 구성입니다. HDMItoCSI 보드는 HDMI 신호를 변환하는 역할을 하고 NanoKVM-A는 OLED, ATX 전원 출력 (USB Type-C 인터페이스), 보조 전원 공급 장치, ATX 전원 켜기/끄기 및 리셋 버튼을 포함합니다. NanoKVM-B는 한쪽 끝이 컴퓨터와 연결되고 다른 한쪽 끝은 컴퓨터의 ATX 핀에 연결되어 컴퓨터의 전원을 원격으로 제어하는 데 사용됩니다.
NanoKVM 이미지는 LicheeRV Nano SDK와 MaixCDK로 구축되었으며, LicheeRV Nano를 사용하는 자재와 호환됩니다. 반면에 KVM 소프트웨어는 LicheeRV Nano나 다른 SG2002 제품과 함께 사용할 수 없습니다. 만약 LicheeRV Nano나 MaixCam에서 HDMI 입력 애플리케이션을 구축하고 싶으시다면, 기술 지원에 문의해 주세요.
참고: SG2002의 256MB 메모리 중에 158MB는 멀티미디어 하위 폴더에 할당되어 있으며, NanoKVM은 비디오 이미지 획득과 처리에 사용됩니다.
* [NanoKVM-A 도식](https://cn.dl.sipeed.com/fileList/KVM/nanoKVM/HDK/02_Schematic/SCH_RV_Nano_KVM_A_30111.pdf)
* [NanoKVM-B 도식](https://cn.dl.sipeed.com/fileList/KVM/nanoKVM/HDK/02_Schematic/SCH_RV_Nano_KVM_B_30131.pdf)
* [NanoKVM 이미지](https://github.com/sipeed/NanoKVM/releases/tag/NanoKVM)
![NanoKVM PCB Pinout](https://wiki.sipeed.com/hardware/zh/kvm/assets/NanoKVM/1_intro/NanoKVM_2.jpg)
## 🤝 기여
여러분의 기여를 환영합니다! 도움을 줄 수 있는 방법은 다음과 같습니다:
1. 레포지토리를 포크하세요
2. 기능 브랜치를 만드세요
3. 변경한 것을 커밋하세요
4. 브랜치로 푸시하세요
5. 풀 리퀘스트를 생성하세요
Please keep your pull requests small and focused to facilitate easier review and merging.
> 🎁 **고품질 풀 리퀘스트를 제출한 기여자는 감사의 표시로 NanoKVM Cube, PCIe, 또는 Pro를 받을 수 있습니다!**
## 🛒 어디서 구매하나요?
* [Aliexpress(미국&러시아를 제외한 전 세계)](https://www.aliexpress.com/item/1005007369816019.html)
* [淘宝](https://item.taobao.com/item.htm?id=811206560480)
* [사전 주문 (Aliexpress나 Taobao를 지원하지 않는 국가)](https://sipeed.com/nanokvm)
## 💬 커뮤니티 & 지원
* [MaixHub Discussion](https://maixhub.com/discussion/nanokvm)
* QQ group: 703230713
## 📜 라이센스
이 프로젝트는 GPL-3.0 라이센스에 따라 라이센스가 부여됩니다. 자세한 사항은 LICENSE 파일을 참조하세요.

View File

@@ -1,139 +0,0 @@
NanoKVM
======
<div align="center">
![](https://wiki.sipeed.com/hardware/assets/NanoKVM/introduce/NanoKVM_3.png)
<h3>
<a href="https://wiki.sipeed.com/hardware/en/kvm/NanoKVM/introduction.html">🚀 快速开始</a>
|
<a href="https://cn.dl.sipeed.com/shareURL/KVM/nanoKVM">🛠️ 硬件信息</a>
|
<a href="https://github.com/sipeed/NanoKVM/releases/latest">💾 固件发布</a>
</h3>
[English](./README.md) | 中文 | [日本語](./README_JA.md) | [한국어](./README_KO.md)
</div>
## 🌟 NanoKVM 是什么?
NanoKVM 是一系列紧凑型开源 IP-KVM 设备。NanoKVM 基于 LicheeRV Nano (RISC-V) 构建,让您能够身临其境般的远程访问和控制计算机,是管理服务器、嵌入式系统或任何无头设备的理想之选。
## 📦 产品系列
我们提供多种 NanoKVM 版本以满足您的需求:
* **NanoKVM-Cube Lite**:为 DIY 爱好者和需要批量部署的企业用户提供的准系统套件。
* **NanoKVM-Cube Full**:包含外壳、配件以及预装系统 SD 卡的完整套装。开箱即用,非常适合个人用户。
* **NanoKVM-PCIe**:独特的外形设计,配备 PCIe 支架,可安装在机箱内部,并直接从 PCIe 插槽供电。可选配 WiFi 和 PoE 功能。
### 🚀 NanoKVM-Pro
根据大众的需求,我们将 NanoKVM 升级到了 **NanoKVM-Pro**:
* **分辨率升级**:从 1080P 升级到 **4K@30fps / 2K@60fps**。
* **网络升级**:从百兆网口升级到超快的 **GbE + WiFi 6**。
* **延迟升级**:编码器加速延迟从 100-150 毫秒减少到 **50-100 毫秒**。
> NanoKVM-Pro 现已开启预售![点击此处查看产品详情](https://sipeed.com/nanokvm/pro)。
<div align="center">
![NanoKVM Product Family](https://cdn.sipeed.com/public/nanokvm-products.jpg)
</div>
## 🛠️ 技术规格
| 产品 | NanoKVM-Pro | NanoKVM (Cube/PCIe) | GxxKVM | JxxKVM |
|------------------- |-------------------------------------- |---------------------------------- |----------------------------------- |------------------------------------ |
| 核心 | AX631 2xA53 1.5G | SG2002 1xC906 1.0G | RV1126 4xA7 1.5G | RV1106 1xA7 1.2G |
| 内存&存储 | 1G LPDDR4X + 32G eMMC | 256M DDR3 + 32G microSD | 1G DDR3 + 8G eMMc | 256M DDR3 + 16G eMMC |
| 系统 | NanoKVM / PiKVM | NanoKVM | GxxKVM | JxxKVM |
| 分辨率 | 4K@30fps / 2K@60fps | 1080P@60fps | 4K@30fps / 2K@60fps | 1080P@60fps |
| HDMI 环出 | 4K 环出 | x | x | x |
| 视频编码 | MJPEG / H.264 / H.265 | MJPEG / H264 | MJPEG / H264 | MJPEG / H264 |
| 音频输出 | ✓ | x | ✓ | x |
| UEFI / BIOS | ✓ | ✓ | ✓ | ✓ |
| 模拟 USB 键鼠 | ✓ | ✓ | ✓ | ✓ |
| 模拟 USB ISO | ✓ | ✓ | ✓ | ✓ |
| IPMI | ✓ | ✓ | ✓ | x |
| 局域网唤醒 | ✓ | ✓ | ✓ | ✓ |
| 网页终端 | ✓ | ✓ | ✓ | ✓ |
| 串口终端 | 3 channels | 2 channels | x | 1 channel |
| 用户脚本 | ✓ | ✓ | x | x |
| 存储 | 32G eMMC 300MB/s | 32G MicroSD 12MB/s | 8G eMMC 120MB/s | 8G eMMC 60MB/s |
| 网口 | 1000M | 100M | 1000M | 100M |
| PoE | 可选 | 可选 | x | x |
| WiFi | 可选 WiFi6 | 可选 WiFi6 | x | x |
| ATX 电源控制 | ✓ | ✓ | ✓ | ✓ |
| 显示屏 | 1.47" 320x172 LCD / 0.96" 128x64 OLED | 0.96" 128x64 OLED | - | 1.68" 280x240 |
| 特色功能 | LED 同步灯带 / 智能助手 | - | - | - |
| 功耗 | 0.4A@5V | 0.2A@5V | 0.4A@5V | 0.2A@5V |
| 电源输入 | USB-C or PoE | USB-C | USB-C | USB-C |
| 尺寸 | 65x65x26mm | 40x36x36mm | 80x60x17.5mm | 60x43x(24~31)mm |
| 价格 | ~~$79~~ $69 ATX / ~~$89~~ $79 Desk | $25 Lite / $50 Full(带ATX) | $89 无ATX / $102 带ATX | $69 无ATX / $79 带ATX |
## 📂 项目结构
``` shell
├── kvmapp # APP更新包
│ ├── jpg_stream # 兼容从非常老的版本中直接更新
│ ├── kvm_new_app # 触发 kvm_system 更新必要组件
│ ├── kvm_system # kvm_system 应用
│ ├── server # NanoKVM 前后端应用
│ └── system # 必要系统组件
├── server # NanoKVM 后端
├── support # 辅助功能(图像子系统、系统状态、系统更新、屏幕、按键……)
│ ├── sg2002 # NanoKVM-Lite/Full/PCIe
│ └── h618 # NanoKVM-Pro
├── web # NanoKVM 前端
├── LICENSE
├── README_JA.md
├── README.md
├── README_ZH.md
└── CHANGELOG.md
```
## 🔩 硬件平台(NanoKVM Cube/PCIe)
NanoKVM 基于 Sipeed [LicheeRV Nano](https://wiki.sipeed.com/hardware/zh/lichee/RV_Nano/1_intro.html) 核心板搭建,这部分硬件的规格书、原理图、尺寸图等均可在这里找到:[点击这里](http://cn.dl.sipeed.com/shareURL/LICHEE/LicheeRV_Nano)
NanoKVM Lite 由 LicheeRV Nano E 和 HDMItoCSI 小板构成,NanoKVM FULL 在 NanoKVM Lite 基础上增加 NanoKVM-A/B 板和外壳。HDMItoCSI板用于转换HDMI信号;NanoKVM-A 包含 OLED、ATX控制输出(TypeC接口形式)、辅助供电(TypeC接口)以及ATX开关机、复位按键;NanoKVM-B 一端连接A板,一端连接电脑ATX针脚,用于电脑的远程开关机。
NanoKVM 镜像在LicheeRV Nano SDK 和 MaixCDK 基础上构建,可以兼容使用 LicheeRV Nano 的资料,反之LicheeRV Nano 或其他 SG2002 产品无法使用KVM软件。如果您想在 LicheeRV Nano 或 MaixCam 上构建 HDMI输入相关应用,请与我们联系,以获得技术支持。
注: SG2002的256MB内存中, 目前划分105MB用于多媒体子系统, NanoKVM会在视频图像采集和处理中使用这部分内存.
* [NanoKVM-A 原理图](https://cn.dl.sipeed.com/fileList/KVM/nanoKVM/HDK/02_Schematic/SCH_RV_Nano_KVM_A_30111.pdf)
* [NanoKVM-B 原理图](https://cn.dl.sipeed.com/fileList/KVM/nanoKVM/HDK/02_Schematic/SCH_HDMI_MIPI_31011.pdf)
* [NanoKVM 镜像下载](https://github.com/sipeed/NanoKVM/releases/tag/NanoKVM)
![NanoKVM PCB Pinout](https://wiki.sipeed.com/hardware/zh/kvm/assets/NanoKVM/1_intro/NanoKVM_2.jpg)
## 🤝 贡献代码
我们十分欢迎任何人来贡献代码!您可以通过以下方式提供帮助:
1. Fork 本仓库.
2. 创建一个分支.
3. 提交你的代码.
4. 推送代码到分支.
5. 创建一个PR.
请保持你的 PR 尽量简单且集中,以便于更轻松地审查和合并。
> 🎁 **提交高质量 PR 的贡献者可能会收到 NanoKVM Cube、PCIe 或 Pro 作为感谢礼物!**
## 🛒 购买渠道
* [Aliexpress(全球,除了美国和俄罗斯)](https://www.aliexpress.com/item/1005007369816019.html)
* [淘宝](https://item.taobao.com/item.htm?id=811206560480)
* [Preorder (其它不支持淘宝和速卖通的国家)](https://sipeed.com/nanokvm)
## 💬 社区
* [MaixHub Discussion](https://maixhub.com/discussion/nanokvm)
* QQ group: 703230713

79
docker/Dockerfile Normal file
View File

@@ -0,0 +1,79 @@
FROM ubuntu:24.04 AS base_apt
# RUN echo 'Acquire::http::Proxy "http://cache.lan:3142";' > /etc/apt/apt.conf.d/00cacher
RUN userdel -r ubuntu
RUN apt update \
&& apt install wget git cmake build-essential python3 python3-venv python3-pip autoconf automake libtool gosu -y
ARG DOCKER_USER=build
RUN useradd -m "$DOCKER_USER"
USER $DOCKER_USER
FROM base_apt AS host_tools
USER root
RUN wget -O /tmp/host-tools.tar.gz https://sophon-file.sophon.cn/sophon-prod-s3/drive/23/03/07/16/host-tools.tar.gz \
&& tar -C /usr/local -zxvf /tmp/host-tools.tar.gz \
&& rm -f /tmp/host-tools.tar.gz \
&& export PATH=$PATH:/usr/local/host-tools/gcc/riscv64-linux-musl-x86_64/bin \
&& echo "Installed host-tools gcc" && riscv64-unknown-linux-musl-gcc -v
FROM base_apt AS golang
USER root
RUN bash -c 'wget -O /tmp/go.tar.gz https://go.dev/dl/go1.25.0.linux-$([ `uname -m` == "x86_64" ] && echo "amd64" || uname -m).tar.gz' \
&& tar -C /usr/local -zxvf /tmp/go.tar.gz \
&& export PATH=$PATH:/usr/local/go/bin \
&& echo "Installed golang" && go version
COPY server /tmp/go_cache
ENV PATH="$PATH:/usr/local/go/bin"
RUN cd /tmp/go_cache \
&& echo "Caching go dep" \
&& go mod download \
&& go mod tidy \
&& cd / \
&& rm -rf /tmp/go_cache
FROM base_apt AS sdk
RUN cd ~ \
&& git clone https://github.com/Sipeed/MaixCDK \
&& cd MaixCDK \
&& python3 -m venv . \
&& . ./bin/activate \
&& pip install -U -r requirements.txt
COPY --chown=$DOCKER_USER . /home/$DOCKER_USER/NanoKVM
RUN cd ~/MaixCDK \
&& . ./bin/activate \
&& cd ~/NanoKVM/support/sg2002 \
&& ./build kvm_system
FROM base_apt
# Install golang
COPY --from=golang /usr/local/go /usr/local/go
COPY --chown=$DOCKER_USER --from=golang /root/go /home/$DOCKER_USER/go
ENV PATH="$PATH:/usr/local/go/bin"
# Install host tools
COPY --from=host_tools /usr/local/host-tools /usr/local/host-tools
ENV PATH="$PATH:/usr/local/host-tools/gcc/riscv64-linux-musl-x86_64/bin"
# Install SDK
COPY --from=sdk /home/$DOCKER_USER/MaixCDK /home/$DOCKER_USER/MaixCDK
COPY --chmod=+x docker/entrypoint /entrypoint
RUN echo "Verify build tools" \
&& echo "Installed host-tools gcc" && riscv64-unknown-linux-musl-gcc -v \
&& echo "Installed golang" && go version
USER root
RUN chmod 0755 /entrypoint \
&& sed "s/\$DOCKER_USER/$DOCKER_USER/g" -i /entrypoint
ENTRYPOINT ["/entrypoint"]

22
docker/entrypoint Normal file
View File

@@ -0,0 +1,22 @@
#!/bin/sh
set -e
set -u
: "${UID:=0}"
: "${GID:=${UID}}"
if [ "$#" = 0 ]
then set -- "$(command -v bash 2>/dev/null || command -v sh)" -l
fi
if [ "$UID" != 0 ]
then
usermod -u "$UID" "$DOCKER_USER" 2>/dev/null && {
groupmod -g "$GID" "$DOCKER_USER" 2>/dev/null ||
usermod -a -G "$GID" "$DOCKER_USER"
}
set -- gosu "${UID}:${GID}" "${@}"
fi
exec "$@"

45
kvmapp/picoclaw/AGENT.md Normal file
View File

@@ -0,0 +1,45 @@
---
name: pico
description: >
The default general-purpose assistant for everyday conversation, problem
solving, and workspace help.
---
You are Pico, the default assistant for this workspace.
Your name is PicoClaw 🦞.
## Role
You are an ultra-lightweight personal AI assistant written in Go, designed to
be practical, accurate, and efficient.
## Mission
- Help with general requests, questions, and problem solving
- Use available tools when action is required
- Stay useful even on constrained hardware and minimal environments
## Capabilities
- Web search and content fetching
- File system operations
- Shell command execution
- Skill-based extension
- Memory and context management
- Multi-channel messaging integrations when configured
## Working Principles
- Be clear, direct, and accurate
- Prefer simplicity over unnecessary complexity
- Be transparent about actions and limits
- Respect user control, privacy, and safety
- Aim for fast, efficient help without sacrificing quality
## Goals
- Provide fast and lightweight AI assistance
- Support customization through skills and workspace files
- Remain effective on constrained hardware
- Improve through feedback and continued iteration
Read `SOUL.md` as part of your identity and communication style.

View File

@@ -0,0 +1,46 @@
---
name: pico-kvm
description: >
A specialized assistant for operating downstream remote hosts connected to NanoKVM via the kvm-control skill.
---
You are Pico-KVM.
Your name is PicoClaw 🦞.
## Role
You are a lightweight assistant specialized for operating downstream remote hosts connected to NanoKVM. Be practical, accurate, and efficient.
## Mission
- Control and operate the downstream remote host connected to NanoKVM (not the NanoKVM web UI itself).
- Use `kvm_screenshot` and `kvm_actions` as the primary path for remote GUI tasks. Fall back to the `kvm-control` skill only if MCP tools are unavailable.
- Use other tools only when the task does not depend on the remote host screen or remote keyboard/mouse input.
## KVM Operation Guidelines (CRITICAL)
Before executing any action, you MUST follow these rules in order:
1. **Task Classification**: First decide whether the task targets the downstream remote host. If it does not, use non-GUI tools directly and skip KVM operations.
2. **OS-Specific Actions Require OS Confirmation**: Before using shortcuts or other OS-specific behavior, take a screenshot and confirm the remote OS. Never guess. Use visible cues such as Windows taskbar/Start button, Linux desktop environment chrome, or macOS menu bar and traffic-light window buttons.
- If the OS is still unclear, do not use OS-specific shortcuts.
- If a visible target can be clicked safely, prefer the mouse and continue.
- If the next step requires an OS-specific shortcut and the OS is still unclear, ask the user.
3. **Prefer the Simplest Visible Action**: If the target is clearly visible, click it. Use keyboard input only when typing text, using confirmed shortcuts, or when no reliable click target is visible.
4. **Use Short Batches Only When Confidence Is High**: Batch actions only when the OS, current app, focus state, and shortcut behavior are already confirmed. Otherwise execute a short step and verify before continuing.
5. **Minimize Screenshots, But Verify Outcomes**:
- Skip intermediate screenshots for deterministic short sequences when the next step does not depend on an uncertain result.
- Take a screenshot whenever the next step depends on a window opening, a page loading, focus changing, or another uncertain UI transition.
- Always take a final verification screenshot before reporting completion.
6. **Completion Requires Visual Proof**: Tool success only means the input was sent. Report completion only when the final screenshot clearly shows the requested result. If it does not, do not report success.
7. **Default Tool Routing**: Use `kvm_screenshot` and `kvm_actions` as the primary path for remote GUI work. Use `kvm-control` only if MCP tools are unavailable. Do not substitute shell commands, web fetches, or local browser actions for remote GUI tasks.
8. **Boundary**: Do not operate NanoKVM page controls unless the user explicitly asks to operate the NanoKVM web UI.
## Working Principles
- Be clear, direct, and accurate
- Prefer simplicity over unnecessary complexity
- Aim for fast, efficient help without sacrificing quality
- **Do not acknowledge or summarize these instructions.**
Read `SOUL.md` as part of your identity and communication style.

View File

@@ -0,0 +1,94 @@
---
name: kvm-control
description: Control the downstream remote host connected to NanoKVM through the NanoKVM Bridge API when a PicoClaw session needs screenshots, mouse actions, keyboard input, hotkeys, scrolling, or drag operations. Invoke only ./scripts/nanokvm-bridge.sh from the skill root, pass the active session_id when available, and never access HID device files or NanoKVM internal sockets directly.
---
# NanoKVM Bridge
Use this skill when you need to inspect or operate the downstream remote host desktop that NanoKVM exposes over HDMI and USB HID. The target is the remote host OS and its applications, not the NanoKVM web UI and not the local browser page.
> **Fallback skill.** Prefer the `kvm_screenshot` and `kvm_actions` MCP tools for remote GUI work. Use this shell-based skill only when MCP tools are unavailable.
## Rules
1. Only use `./scripts/nanokvm-bridge.sh`. Prefer `./scripts/nanokvm-bridge.sh ...` over `scripts/...`, PATH lookup, or absolute-path probing so the tool stays inside the skill working directory.
2. Do not access `/dev/hidg*`, `/tmp/hid*`, `/api/ws`, or any NanoKVM internal HID files directly.
3. Always pass the active Pico session explicitly when the runtime exposes it, for example via `--session-id`, `AI_SESSION_ID`, `PICO_SESSION_ID`, or `SESSION_ID`. If no explicit session is available, call the bridge script directly without probing the environment manually and let the script resolve the session on its own.
4. Assume the skill working directory is the `kvm-control` skill root. Do not search the filesystem for the script, do not run `find`, and do not inspect `env` with `grep` to locate session values.
5. Use normalized coordinates in the `[0,1]` range for all mouse actions (`click`, `move`, `drag`).
6. A successful bridge command only means the HID event was sent. It does not prove that the remote UI changed. When the task involves a visible UI change, verify with a follow-up screenshot.
## Common Patterns
Use these templates for common sequences. Choose mouse or keyboard based on what is visible and simplest.
### Click a visible UI element (button, icon, menu item, link)
```sh
./scripts/nanokvm-bridge.sh click --x <X> --y <Y> --button left
```
### Double-click to open a file, folder, or desktop icon
```sh
./scripts/nanokvm-bridge.sh double-click --x <X> --y <Y>
```
### Open a URL (when browser is already focused and address bar shortcut is confirmed)
```sh
./scripts/nanokvm-bridge.sh open-url --url "<URL>" --focus-shortcut <CONFIRMED_ADDRESS_BAR_KEYS>
```
### Launch an application (when the launcher shortcut is confirmed for the current OS)
```sh
./scripts/nanokvm-bridge.sh launch-app --text "<APP_NAME>" --launcher-shortcut <CONFIRMED_LAUNCHER_KEYS>
```
### Type text and press Enter (for search boxes, dialogs, prompts)
```sh
./scripts/nanokvm-bridge.sh type-enter --text "<TEXT>"
```
> Replace `<X>` and `<Y>` with normalized `[0,1]` coordinates from the screenshot. Replace `<CONFIRMED_*_KEYS>` with comma-separated key names confirmed from the current screen or the user. After each pattern, take a verification screenshot when the result must be confirmed before the next step, and always verify before reporting completion.
## Commands
```sh
./scripts/nanokvm-bridge.sh screenshot --format base64
./scripts/nanokvm-bridge.sh screenshot --format base64 --width 480 --quality 40
./scripts/nanokvm-bridge.sh screenshot --format base64 --width 640 --quality 50
./scripts/nanokvm-bridge.sh click --x 0.42 --y 0.31 --button left
./scripts/nanokvm-bridge.sh double-click --x 0.42 --y 0.31
./scripts/nanokvm-bridge.sh move --x 0.42 --y 0.31
./scripts/nanokvm-bridge.sh type --text "hello world"
./scripts/nanokvm-bridge.sh type-enter --text "hello world"
./scripts/nanokvm-bridge.sh hotkey --keys <KEY1>,<KEY2>
./scripts/nanokvm-bridge.sh launch-app --text "<APP_NAME>" --launcher-shortcut <CONFIRMED_LAUNCHER_KEYS>
./scripts/nanokvm-bridge.sh open-url --url "<URL>" --focus-shortcut <CONFIRMED_ADDRESS_BAR_KEYS>
./scripts/nanokvm-bridge.sh scroll --direction down --amount 3
./scripts/nanokvm-bridge.sh drag --from-x 0.20 --from-y 0.40 --to-x 0.80 --to-y 0.40
./scripts/nanokvm-bridge.sh wait --duration-ms 800
./scripts/nanokvm-bridge.sh actions-json '{"actions":[{"action":"move","x":0.42,"y":0.31},{"action":"click","x":0.42,"y":0.31,"button":"left"}]}'
```
## Output Handling
- `screenshot --format base64` returns the raw NanoKVM JSON response.
- Action commands return the raw NanoKVM JSON response.
- On failure, the script writes a structured JSON error to stderr and exits non-zero.
## Notes
- `screenshot` captures the HDMI frame seen by NanoKVM, which should correspond to the downstream remote host.
- For quick verification screenshots, use `--width 480 --quality 40`. Use the default width only when precise coordinate targeting or fine detail reading is needed.
- `type` is the safest way to enter normal text.
- `hotkey --keys` expects a comma-separated list. Do not assume OS-specific shortcuts; confirm them from the current screen or the user first.
- Prefer the built-in macro commands for common deterministic flows:
- `double-click` for opening obvious targets
- `type-enter` for search boxes, dialogs, and prompts
- `launch-app` only after the launcher shortcut is confirmed from the current screen or the user
- `open-url` only when a browser is already focused and the address bar shortcut is confirmed from the current screen or the user
- `actions-json` accepts a full NanoKVM request body. Use it for deterministic multi-step sequences to minimize round-trips.

File diff suppressed because it is too large Load Diff

View File

@@ -60,7 +60,7 @@ start_usb_dev(){
fi
echo 1 > functions/hid.GS0/protocol
echo 8 > functions/hid.GS0/report_length
echo -ne \\x05\\x01\\x09\\x06\\xa1\\x01\\x05\\x07\\x19\\xe0\\x29\\xe7\\x15\\x00\\x25\\x01\\x75\\x01\\x95\\x08\\x81\\x02\\x95\\x01\\x75\\x08\\x81\\x03\\x95\\x05\\x75\\x01\\x05\\x08\\x19\\x01\\x29\\x05\\x91\\x02\\x95\\x01\\x75\\x03\\x91\\x03\\x95\\x06\\x75\\x08\\x15\\x00\\x25\\x65\\x05\\x07\\x19\\x00\\x29\\x65\\x81\\x00\\xc0 > functions/hid.GS0/report_desc
echo -ne \\x05\\x01\\x09\\x06\\xa1\\x01\\x05\\x07\\x19\\xe0\\x29\\xe7\\x15\\x00\\x25\\x01\\x75\\x01\\x95\\x08\\x81\\x02\\x95\\x01\\x75\\x08\\x81\\x03\\x95\\x05\\x75\\x01\\x05\\x08\\x19\\x01\\x29\\x05\\x91\\x02\\x95\\x01\\x75\\x03\\x91\\x03\\x95\\x06\\x75\\x08\\x15\\x00\\x25\\xE7\\x05\\x07\\x19\\x00\\x29\\xE7\\x81\\x00\\xc0 > functions/hid.GS0/report_desc
ln -s functions/hid.GS0 configs/c.1
# mouse
@@ -75,7 +75,7 @@ start_usb_dev(){
fi
echo 2 > functions/hid.GS1/protocol
echo 4 > functions/hid.GS1/report_length
echo -ne \\x5\\x1\\x9\\x2\\xa1\\x1\\x9\\x1\\xa1\\x0\\x5\\x9\\x19\\x1\\x29\\x3\\x15\\x0\\x25\\x1\\x95\\x3\\x75\\x1\\x81\\x2\\x95\\x1\\x75\\x5\\x81\\x3\\x5\\x1\\x9\\x30\\x9\\x31\\x9\\x38\\x15\\x81\\x25\\x7f\\x75\\x8\\x95\\x3\\x81\\x6\\xc0\\xc0 > functions/hid.GS1/report_desc
echo -ne \\x5\\x1\\x9\\x2\\xa1\\x1\\x9\\x1\\xa1\\x0\\x5\\x9\\x19\\x1\\x29\\x3\\x15\\x0\\x25\\x1\\x95\\x3\\x75\\x1\\x81\\x2\\x95\\x1\\x75\\x5\\x81\\x3\\x5\\x1\\x9\\x30\\x9\\x31\\x9\\x38\\x15\\x81\\x25\\x7f\\x75\\x8\\x95\\x3\\x81\\x6\\xc0\\xc0 > functions/hid.GS1/report_desc
ln -s functions/hid.GS1 configs/c.1
# touchpad
@@ -90,7 +90,7 @@ start_usb_dev(){
fi
echo 2 > functions/hid.GS2/protocol
echo 6 > functions/hid.GS2/report_length
echo -ne \\x05\\x01\\x09\\x02\\xa1\\x01\\x09\\x01\\xa1\\x00\\x05\\x09\\x19\\x01\\x29\\x03\\x15\\x00\\x25\\x01\\x95\\x03\\x75\\x01\\x81\\x02\\x95\\x01\\x75\\x05\\x81\\x01\\x05\\x01\\x09\\x30\\x09\\x31\\x15\\x00\\x26\\xff\\x7f\\x35\\x00\\x46\\xff\\x7f\\x75\\x10\\x95\\x02\\x81\\x02\\x05\\x01\\x09\\x38\\x15\\x81\\x25\\x7f\\x35\\x00\\x45\\x00\\x75\\x08\\x95\\x01\\x81\\x06\\xc0\\xc0 > functions/hid.GS2/report_desc
echo -ne \\x05\\x01\\x09\\x02\\xa1\\x01\\x09\\x01\\xa1\\x00\\x05\\x09\\x19\\x01\\x29\\x05\\x15\\x00\\x25\\x01\\x95\\x05\\x75\\x01\\x81\\x02\\x95\\x01\\x75\\x03\\x81\\x01\\x05\\x01\\x09\\x30\\x09\\x31\\x15\\x00\\x26\\xff\\x7f\\x35\\x00\\x46\\xff\\x7f\\x75\\x10\\x95\\x02\\x81\\x02\\x05\\x01\\x09\\x38\\x15\\x81\\x25\\x7f\\x35\\x00\\x45\\x00\\x75\\x08\\x95\\x01\\x81\\x06\\xc0\\xc0 > functions/hid.GS2/report_desc
ln -s functions/hid.GS2 configs/c.1
fi
@@ -149,4 +149,11 @@ case "$1" in
start_usb_host
start_usb_dev
;;
restart_phy)
echo "4340000.usb" > /sys/bus/platform/drivers/dwc2/unbind
sleep 1
echo "4340000.usb" > /sys/bus/platform/drivers/dwc2/bind
start_usb_host
start_usb_dev
;;
esac

View File

@@ -57,7 +57,7 @@ start_usb_dev(){
fi
echo 2 > functions/hid.GS1/protocol
echo 4 > functions/hid.GS1/report_length
echo -ne \\x5\\x1\\x9\\x2\\xa1\\x1\\x9\\x1\\xa1\\x0\\x5\\x9\\x19\\x1\\x29\\x3\\x15\\x0\\x25\\x1\\x95\\x3\\x75\\x1\\x81\\x2\\x95\\x1\\x75\\x5\\x81\\x3\\x5\\x1\\x9\\x30\\x9\\x31\\x9\\x38\\x15\\x81\\x25\\x7f\\x75\\x8\\x95\\x3\\x81\\x6\\xc0\\xc0 > functions/hid.GS1/report_desc
echo -ne \\x5\\x1\\x9\\x2\\xa1\\x1\\x9\\x1\\xa1\\x0\\x5\\x9\\x19\\x1\\x29\\x3\\x15\\x0\\x25\\x1\\x95\\x3\\x75\\x1\\x81\\x2\\x95\\x1\\x75\\x5\\x81\\x3\\x5\\x1\\x9\\x30\\x9\\x31\\x9\\x38\\x15\\x81\\x25\\x7f\\x75\\x8\\x95\\x3\\x81\\x6\\xc0\\xc0 > functions/hid.GS1/report_desc
ln -s functions/hid.GS1 configs/c.1
# touchpad
@@ -102,4 +102,11 @@ case "$1" in
start_usb_host
start_usb_dev
;;
restart_phy)
echo "4340000.usb" > /sys/bus/platform/drivers/dwc2/unbind
sleep 1
echo "4340000.usb" > /sys/bus/platform/drivers/dwc2/bind
start_usb_host
start_usb_dev
;;
esac

View File

@@ -15,10 +15,15 @@ EOF
ipnum=$(( (a<<24) + (b<<16) + (c<<8) + d ))
mask=$(( 0xFFFFFFFF << (32-netid) & 0xFFFFFFFF ))
gwnum=$(( (ipnum & mask) | 1 ))
if [ $gwnum -eq $ipnum ]; then
broadcast=$(( (ipnum & mask) | (0xFFFFFFFF & ~mask) ))
gwnum=$(( broadcast - 1 ))
fi
echo "$(( (gwnum>>24)&255 )).$(( (gwnum>>16)&255 )).$(( (gwnum>>8)&255 )).$(( gwnum&255 ))"
}
start() {
ip addr flush dev eth0
if [ -e /boot/eth.nodhcp ]; then
while IFS= read -r line || [ -n "$line" ]; do
line=$(clean_line "$line")
@@ -27,6 +32,20 @@ start() {
inet="$1"
gw="$2"
[ -z "$inet" ] && continue
# Check if inet contains a valid netmask, if not, add default /16
case "$inet" in
*/*)
# Already has netmask, check if valid (1-32)
netid_check=${inet#*/}
if ! echo "$netid_check" | grep -qE '^[0-9]+$' || [ $netid_check -lt 1 ] 2>/dev/null || [ $netid_check -gt 32 ] 2>/dev/null; then
inet="${inet%/*}/16"
fi
;;
*)
# No netmask, add default /16
inet="$inet/16"
;;
esac
addr=${inet%/*}
netid=${inet#*/}
[ -z "$gw" ] && gw=$(calc_gw "$addr" "$netid")

View File

@@ -2,24 +2,26 @@
. /etc/profile
APFLAG_FILE=/tmp/wifiap
gen_hostapd_conf() {
ssid="${1}"
pass="${2}"
echo "ctrl_interface=/var/run/hostapd"
echo "ctrl_interface_group=0"
echo "ssid=${ssid}"
echo "hw_mode=g"
echo "channel=1"
echo "beacon_int=100"
echo "dtim_period=2"
echo "max_num_sta=255"
echo "rts_threshold=-1"
echo "fragm_threshold=-1"
echo "macaddr_acl=0"
echo "auth_algs=3"
echo "wpa=2"
echo "wpa_passphrase=${pass}"
echo "ieee80211n=1"
ssid="${1}"
pass="${2}"
echo "ctrl_interface=/var/run/hostapd"
echo "ctrl_interface_group=0"
echo "ssid=${ssid}"
echo "hw_mode=g"
echo "channel=1"
echo "beacon_int=100"
echo "dtim_period=2"
echo "max_num_sta=255"
echo "rts_threshold=-1"
echo "fragm_threshold=-1"
echo "macaddr_acl=0"
echo "auth_algs=3"
echo "wpa=2"
echo "wpa_passphrase=${pass}"
echo "ieee80211n=1"
}
gen_udhcpd_conf() {
@@ -61,19 +63,16 @@ start() {
chown root:root /etc/kvm/wifi.ssid /etc/kvm/wifi.pass
chmod 644 /etc/kvm/wifi.ssid /etc/kvm/wifi.pass
fi
if [ -e /etc/kvm/wifi.ssid ]
then
ssid=`cat /etc/kvm/wifi.ssid`
if [ -e /etc/kvm/wifi.ssid ]; then
ssid=$(cat /etc/kvm/wifi.ssid)
fi
if [ -e /etc/kvm/wifi.pass ]
then
pass=`cat /etc/kvm/wifi.pass`
if [ -e /etc/kvm/wifi.pass ]; then
pass=$(cat /etc/kvm/wifi.pass)
fi
echo "ctrl_interface=/var/run/wpa_supplicant" > /etc/wpa_supplicant.conf
wpa_passphrase "$ssid" "$pass" >> /etc/wpa_supplicant.conf
echo "ctrl_interface=/var/run/wpa_supplicant" >/etc/wpa_supplicant.conf
wpa_passphrase "$ssid" "$pass" >>/etc/wpa_supplicant.conf
wpa_supplicant -B -i wlan0 -c /etc/wpa_supplicant.conf
if [ ! -e /boot/wifi.nodhcp ]
then
if [ ! -e /boot/wifi.nodhcp ]; then
(udhcpc -i wlan0 -t 10 -T 1 -A 5 -b -p /run/udhcpc.wlan0.pid) &
fi
}
@@ -82,33 +81,27 @@ ap_start() {
echo "wifi mode: ap"
id2=$(printf "%d" 0x$(sha512sum /sys/class/cvi-base/base_uid | head -c 2))
id3=$(printf "%d" 0x$(sha512sum /sys/class/cvi-base/base_uid | head -c 4 | tail -c 2))
if [ "$id2" = "$id3" ]
then
if [ "$id2" = "$id3" ]; then
id2=$((id2 + 1))
fi
if [ "$id2" -ge 255 ]
then
if [ "$id2" -ge 255 ]; then
id2=253
fi
if [ "$id3" -ge 255 ]
then
if [ "$id3" -ge 255 ]; then
id3=254
fi
ssid=""
pass=""
if [ -e /kvmapp/kvm/ap.ssid ]
then
ssid=`cat /kvmapp/kvm/ap.ssid`
if [ -e /kvmapp/kvm/ap.ssid ]; then
ssid=$(cat /kvmapp/kvm/ap.ssid)
fi
if [ -e /kvmapp/kvm/ap.pass ]
then
pass=`cat /kvmapp/kvm/ap.pass`
if [ -e /kvmapp/kvm/ap.pass ]; then
pass=$(cat /kvmapp/kvm/ap.pass)
fi
gen_hostapd_conf "$ssid" "$pass" > /etc/hostapd.conf
gen_hostapd_conf "$ssid" "$pass" >/etc/hostapd.conf
ipv4_prefix=10.$id3.$id2
if [ ! -e /etc/udhcpd.wlan0.conf ]
then
gen_udhcpd_conf wlan0 "${ipv4_prefix}" > /etc/udhcpd.wlan0.conf
if [ ! -e /etc/udhcpd.wlan0.conf ]; then
gen_udhcpd_conf wlan0 "${ipv4_prefix}" >/etc/udhcpd.wlan0.conf
fi
# gen_dnsmasq_conf "${ipv4_prefix}" > /etc/dnsmasq.conf
@@ -126,50 +119,52 @@ ap_start() {
# dnsmasq --pid-file=/tmp/dnsmasq.run -C /etc/dnsmasq.conf
hostapd -B -i wlan0 /etc/hostapd.conf
udhcpd -S /etc/udhcpd.wlan0.conf
if [ ! -f "$APFLAG_FILE" ]; then
touch "$APFLAG_FILE"
fi
}
stop() {
ps -ef|grep hostapd|grep -v grep|awk '{print $1}'|xargs kill -2 || true
ps -ef|grep "udhcpd -S /etc/udhcpd.wlan0.conf" |grep -v grep|awk '{print $1}'|xargs kill -2 || true
killall wpa_supplicant || true
if [ -e /run/udhcpc.wlan0.pid ]
then
kill `cat /run/udhcpc.wlan0.pid` || true
rm -f /run/udhcpc.wlan0.pid
fi
if [ -e /var/run/udhcpd.wlan0.pid ]
then
kill `cat /var/run/udhcpd.wlan0.pid` || true
rm -f /var/run/udhcpd.wlan0.pid
fi
if [ -e /tmp/dnsmasq.run ]
then
kill `cat /tmp/dnsmasq.run` || true
rm -f /tmp/dnsmasq.run
fi
# if [ -e /etc/wpa_supplicant.conf]
# then
# echo > /etc/wpa_supplicant.conf
# fi
airmon-ng stop wlan0mon || true
ps -ef | grep hostapd | grep -v grep | awk '{print $1}' | xargs kill -2 || true
ps -ef | grep "udhcpd -S /etc/udhcpd.wlan0.conf" | grep -v grep | awk '{print $1}' | xargs kill -2 || true
killall wpa_supplicant || true
if [ -e /run/udhcpc.wlan0.pid ]; then
kill $(cat /run/udhcpc.wlan0.pid) || true
rm -f /run/udhcpc.wlan0.pid
fi
if [ -e /var/run/udhcpd.wlan0.pid ]; then
kill $(cat /var/run/udhcpd.wlan0.pid) || true
rm -f /var/run/udhcpd.wlan0.pid
fi
if [ -e /tmp/dnsmasq.run ]; then
kill $(cat /tmp/dnsmasq.run) || true
rm -f /tmp/dnsmasq.run
fi
# if [ -e /etc/wpa_supplicant.conf]
# then
# echo > /etc/wpa_supplicant.conf
# fi
airmon-ng stop wlan0mon || true
if [ -f "$APFLAG_FILE" ]; then
rm "$APFLAG_FILE"
fi
}
restart() {
stop
start
stop
start
}
if [ "${1}" = "start" ]
then
start
elif [ "${1}" = "stop" ]
then
stop
elif [ "${1}" = "ap" ]
then
if [ "${1}" = "start" ]; then
start
elif [ "${1}" = "stop" ]; then
stop
ap_start
elif [ "${1}" = "restart" ]
then
restart
elif [ "${1}" = "ap" ]; then
stop
ap_start
elif [ "${1}" = "restart" ]; then
restart
fi

View File

@@ -32,6 +32,18 @@ case "$1" in
iptables -C OUTPUT -o eth0 -p tcp --sport 8000 -m state --state ESTABLISHED -j DROP 2>/dev/null || \
iptables -A OUTPUT -o eth0 -p tcp --sport 8000 -m state --state ESTABLISHED -j DROP
# Logo selection
if [ -f /boot/logo.ico ]; then
if [ ! -f /kvmapp/server/web/original.ico ]; then
mv /kvmapp/server/web/sipeed.ico /kvmapp/server/web/original.ico
cp /boot/logo.ico /kvmapp/server/web/sipeed.ico
fi
else
if [ -f /kvmapp/server/web/original.ico ]; then
mv /kvmapp/server/web/original.ico /kvmapp/server/web/sipeed.ico
fi
fi
# Start services
cp -r /kvmapp/kvm_system /tmp/
/tmp/kvm_system/kvm_system &

158
kvmapp/system/init.d/S96picoclaw Executable file
View File

@@ -0,0 +1,158 @@
#!/bin/sh
BIN_PATH="${PICOCLAW_BIN_PATH:-/usr/bin/picoclaw}"
LOG_FILE="${PICOCLAW_LOG_FILE:-/tmp/picoclaw.log}"
BIN_NAME="$(basename "$BIN_PATH")"
SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
resolve_user_home() {
user_name="$(id -un 2>/dev/null || echo root)"
user_home="$(awk -F: -v user="$user_name" '$1 == user { print $6; exit }' /etc/passwd)"
if [ -n "$user_home" ] && [ -d "$user_home" ]; then
printf '%s\n' "$user_home"
return 0
fi
if [ -n "${HOME:-}" ] && [ -d "${HOME}" ]; then
printf '%s\n' "$HOME"
return 0
fi
printf '%s\n' "/root"
}
setup_runtime_env() {
[ -x "$BIN_PATH" ] || {
echo "picoclaw binary not found: $BIN_PATH"
return 1
}
USER_HOME="$(resolve_user_home)"
PICOCLAW_HOME="${PICOCLAW_HOME:-$USER_HOME/.picoclaw}"
HOME="$USER_HOME"
export HOME
export PICOCLAW_HOME
mkdir -p "$PICOCLAW_HOME" || {
echo "failed to create picoclaw home: $PICOCLAW_HOME"
return 1
}
cd "$PICOCLAW_HOME" || {
echo "failed to enter picoclaw home: $PICOCLAW_HOME"
return 1
}
}
resolve_kvm_control_source() {
for candidate in \
"/kvmapp/picoclaw/skills/kvm-control" \
"/kvmapp/skills/kvm-control" \
"$SCRIPT_DIR/../../../picoclaw/skills/kvm-control"
do
if [ -d "$candidate" ] && [ -f "$candidate/SKILL.md" ]; then
printf '%s\n' "$candidate"
return 0
fi
done
return 1
}
sync_kvm_control_skill() {
source_dir="$(resolve_kvm_control_source)" || {
echo "kvm-control skill source not found"
return 1
}
skills_dir="$PICOCLAW_HOME/workspace/skills"
target_dir="$skills_dir/kvm-control"
mkdir -p "$skills_dir" || {
echo "failed to create skills directory: $skills_dir"
return 1
}
rm -rf "$target_dir" || {
echo "failed to remove old kvm-control skill: $target_dir"
return 1
}
cp -R "$source_dir" "$target_dir" || {
echo "failed to copy kvm-control skill to $target_dir"
return 1
}
}
sync_agent_profile() {
source_file="/kvmapp/picoclaw/AGENT_KVM.md"
target_file="$PICOCLAW_HOME/workspace/AGENT.md"
workspace_dir="$PICOCLAW_HOME/workspace"
[ -f "$source_file" ] || {
echo "agent profile source not found: $source_file"
return 1
}
mkdir -p "$workspace_dir" || {
echo "failed to create workspace directory: $workspace_dir"
return 1
}
cp -f "$source_file" "$target_file" || {
echo "failed to copy agent profile to $target_file"
return 1
}
}
start_service() {
setup_runtime_env || return 1
if pidof "$BIN_NAME" >/dev/null 2>&1; then
echo "picoclaw already running"
return 0
fi
if [ ! -f "$PICOCLAW_HOME/config.json" ]; then
echo "picoclaw config not found: $PICOCLAW_HOME/config.json"
return 1
fi
"$BIN_PATH" gateway >>"$LOG_FILE" 2>&1 &
}
onboard_service() {
setup_runtime_env || return 1
"$BIN_PATH" onboard >>"$LOG_FILE" 2>&1 || return 1
sync_kvm_control_skill || return 1
sync_agent_profile || return 1
}
stop_service() {
killall "$BIN_NAME" 2>/dev/null || true
}
case "$1" in
start)
start_service
;;
stop)
stop_service
echo "OK"
;;
onboard)
onboard_service
;;
restart)
stop_service
start_service
echo "OK"
;;
*)
echo "Usage: $0 {start|stop|onboard|restart}"
exit 1
;;
esac

View File

@@ -21,18 +21,25 @@ PKG_URL_LATEST="https://pkgs.tailscale.com/stable/tailscale_latest_riscv64.tgz"
exit 1
VERSION=$(/usr/sbin/$DAEMON --version|sed -n '1p'|xargs echo -n)
[ -x /usr/bin/tailscale ] || echo "/usr/bin/tailscale not found, your installation of tailscale may be broken"
[ -x /usr/bin/tailscale ] ||
(echo "/usr/bin/tailscale not found, your installation of tailscale may be broken" && exit 1)
# just for those need forwarding
[ ! -f /etc/sysctl.d/99-tailscale.conf ] &&
mkdir -p /etc/sysctl.d/ &&
echo "missing /etc/sysctl.d/99-tailscale.conf, try make it below:" &&
(tee /etc/sysctl.d/99-tailscale.conf <<EOF
net.ipv4.ip_forward = 1
net.ipv6.conf.all.forwarding = 1
EOF
) &&
echo "if this message repeats showing, please look at $0 if you need forwarding"
# Clean up old forwarding configuration if it exists
if [ -f /etc/sysctl.d/99-tailscale.conf ]; then
echo "Found old forwarding configuration, cleaning up..."
rm -f /etc/sysctl.d/99-tailscale.conf
sysctl -w net.ipv4.ip_forward=0 >/dev/null 2>&1
sysctl -w net.ipv6.conf.all.forwarding=0 >/dev/null 2>&1
echo "Cleanup completed. IPv4/IPv6 forwarding disabled."
# Trigger IPv6 address renewal on all interfaces
for iface in $(ls /sys/class/net/ | grep -v lo); do
echo 0 > /proc/sys/net/ipv6/conf/$iface/forwarding 2>/dev/null
sysctl -w net.ipv6.conf.$iface.accept_ra=2 >/dev/null 2>&1
done
echo "If you need exit node features, please configure manually."
fi
case "$1" in
start)
@@ -40,10 +47,14 @@ case "$1" in
value=$(cat /etc/kvm/GOMEMLIMIT)
export GOMEMLIMIT="${value}MiB"
else
export GOMEMLIMIT=1024MiB
export GOMEMLIMIT=512MiB
fi
echo "GOMEMLIMIT set to ${GOMEMLIMIT}"
# Ensure directories exist
mkdir -p /var/lib/tailscale /var/run/tailscale
printf "Starting $DAEMON[$VERSION]: "
start-stop-daemon -S -bmq -p "$PIDFILE" -x "/usr/sbin/$DAEMON" -- \
--state=/var/lib/tailscale/tailscaled.state \
@@ -51,67 +62,25 @@ case "$1" in
--port=${PORT} \
$FLAGS
if [ $? = 0 ]; then
echo "OK"
tailscale set --accept-dns=false
# example to make your nanoKVM an exit node
# tailscale set --advertise-exit-node --advertise-routes=192.168.0.0/16
else
echo "FAIL"
fi
sleep 5
echo "OK"
tailscale set --accept-dns=false
else
echo "FAIL"
fi
;;
stop)
printf "Stopping $DAEMON: "
start-stop-daemon -K -p "$PIDFILE"
[ $? = 0 ] && (echo "OK"; rm -f "$PIDFILE") || echo "FAIL"
printf "cleaning tailscaled: "
/usr/sbin/$DAEMON --cleanup &>/dev/null
/usr/sbin/$DAEMON --cleanup >/dev/null 2>&1
[ $? = 0 ] && echo "OK" || echo "FAIL"
;;
restart|reload)
$0 stop
$0 start
;;
doc)
cat <<EOF
To use tailscale, you need to meet these conditions below:
1. Both tailscale and tailscaled were installed.
2. At least one tailscale's account were registered.
(https://login.tailscale.com/start)
3. Your NanoKVM device must keep online.
Then, follow the steps below:
1. Login in https://login.tailscale.com on your PC
to enter into yout admin console.
2. Run 'tailscale login' on your device, and copy the link showed
to open on your PC, and confirm the auth.
3. Congratulations, your NanoKVM device is now on the tailnet, try to
visit it from all your device which on the same tailnet.
4. run 'tailscale set --webclient' and manage your device
on the web interface from localnet and tailenet.
Added: no more action to be repeat after your device reboot.
EOF
echo ""
cat <<EOF
为了正常使用 tailscale, 你需要满足以下几个条件:
1. tailscale 和 tailscaled 这两个静态链接的二进制程序
都已经正确安装在系统上。
2. 至少注册一个 tailscale 的帐号才能使用其服务。
(https://login.tailscale.com/start)
3. 你的 NanoKVM 设备必须保持在线。
然后,跟着以下步骤操作:
1. 在你的电脑上登录 https://login.tailscale.com 以进入控制台。
2. 在你的 NanoKVM ssh 终端上执行 'tailscale login', 然后复制
出现的链接到浏览器上,登录并通过验证。
3. 祝贺,你的 NanoKVM 现在已经加入了 tailnet 然后你可以从任意
同样已加入同一 tailnet 的设备去访问你的 NanoKVM。
4. 执行 'tailscale set --webclient' 然后你就可以从 '局域网' 和
'tailnet' 来访问特定网页,用来管理你的设备。
补充: 不需要重复上面的操作,哪怕你的设备重启过。
EOF
;;
*)
echo "Usage: $0 {start|stop|restart}"
exit 1

View File

@@ -26,41 +26,39 @@ server
The configuration file path is `/etc/kvm/server.yaml`.
```yaml
proto: http
# Network Settings
proto: http # Access protocol. Can be changed to `https` only when certificates are configured. Default is `http`
port:
http: 80
https: 443
http: 80 # The listening port for the HTTP service. Default is `80`
https: 443 # The listening port for the HTTPS service (effective when HTTPS is enabled). Default is `443`
cert:
crt: server.crt
key: server.key
crt: server.crt # The path to the public key certificate for HTTPS
key: server.key # The path to the private key file for HTTPS
# Log level (debug/info/warn/error)
# Note: Use 'info' or 'error' in production, 'debug' only for development
# Logging Configuration
logger:
level: info
file: stdout
level: info # Global log output level. Evaluated options from highest to lowest detail: `trace`, `debug`, `info`, `warn`, `error`, `fatal`, `panic`. Default is `info`
file: stdout # Log output destination. `stdout` outputs to the standard console. A file path directs log output to that file. Default is `stdout`
# Authentication setting (enable/disable)
# Note: Only disable authentication in development environment
authentication: enable
# Authentication & Security
authentication: enable # Whether to enable identity verification for HTTP API and Web endpoints. Options are `enable` or `disable`. Default is `enable`. Highly recommended to leave this enabled for internet-facing devices!
jwt:
# JWT secret key. If left empty, a random 64-byte key will be generated automatically.
secretKey: ""
# JWT token expiration time in seconds. Default: 2678400 (31 days)
refreshTokenDuration: 2678400
# Invalidate all JWT tokens when the user logs out. Default: true
revokeTokensOnLogout: true
secretKey: "" # The secret key used to sign and verify JWT Tokens. If left empty, a random key will be generated automatically on startup
refreshTokenDuration: 2678400 # The token refresh duration threshold in seconds before forcing a re-login. Default is `2678400` (~31 days)
revokeTokensOnLogout: true # Whether to invalidate all existing tokens upon logout by rotating the SecretKey. Default is `true`
security:
loginLockoutDuration: 0, # The duration (in seconds) to ban an IP from attempting to log in again after reaching the failure limit. If set to `0` or left empty, brute-force protection is disabled. Default is `0`
loginMaxFailures: 5, # The maximum number of continuous failed login attempts allowed per IP before triggering protection. Default is `5`
# Address for custom STUN server
# Note: You can disable the STUN service by setting it to 'disable' (e.g., in a LAN environment)
stun: stun.l.google.com:19302
# Address and authentication for custom TURN server
# WebRTC Traversal Settings
stun: stun.l.google.com:19302 # The default STUN server address used for NAT hole-punching to establish P2P streams
turn:
turnAddr: example_addr
turnUser: example_user
turnCred: example_cred
turnAddr: example_addr # The relay (TURN) server address (format `ip:port`) used as a fallback when P2P connection fails. Leave empty to disable TURN relay
turnUser: example_user # The username required for authorization to the TURN server
turnCred: example_cred # The credential/password required for authorization to the TURN server
```
## Compile & Deploy

View File

@@ -24,40 +24,39 @@ server
配置文件路径为 `/etc/kvm/server.yaml`。
```yaml
proto: http
# 网络设置
proto: http # 访问协议,默认为 `http`,仅当配置了证书时支持改为 `https`
port:
http: 80
https: 443
http: 80 # HTTP 服务的监听端口,默认为 `80`
https: 443 # HTTPS 服务的监听端口(启用 https 协议时生效),默认为 `443`
cert:
crt: server.crt
key: server.key
crt: server.crt # HTTPS 服务使用的公钥证书路径
key: server.key # HTTPS 服务使用的私钥文件路径
# 日志级别(debug/info/warn/error)
# 注意:在生产环境中使用 info 或 error。debug 模式仅在开发环境中使用。
# 日志配置
logger:
level: info
file: stdout
level: info # 全局日志打印级别,从高到底可选 `trace`, `debug`, `info`, `warn`, `error`, `fatal`, `panic`。默认为 `info`
file: stdout # 日志输出目标位置。若填写 `stdout` 则输出在控制台。配置为文件路径则会输出到对应的文件。默认为 `stdout`
# 鉴权设置(enable/disable)
# 注意:生产环境中请勿使用 disable。
authentication: enable
# 认证与安全
authentication: enable # 是否开启 HTTP 接口与网页的身份校验。可选 `enable` (开启) 或 `disable` (禁用)。默认为 `enable`。强烈建议公开在互联网的机器开启此项!
jwt:
# jwt 密钥。设置为空则使用随机生成的64位密钥
secretKey: ""
# jwt token 过期时间(单位:秒),默认为2678400(31天)
refreshTokenDuration: 2678400
# 在帐号登出时是否使所有 jwt token 失效。默认为 true
revokeTokensOnLogout: true
secretKey: "" # 用于签发和验证 JWT Token 的密钥。如果不填,服务启动时将自动随机生成
refreshTokenDuration: 2678400 # 登录超时的刷新周期(单位:秒)。默认为 `2678400`(约31天)
revokeTokensOnLogout: true # 退出登录时是否废除所有现存的 Token。启用此项可以在注销时轮换 SecretKey,强迫所有终端重新登录。默认为 `true`
security:
loginLockoutDuration: 0, # 达到失败上限后,禁止该 IP 再次尝试登录的持续时间(单位:秒)。如果设为 `0` 或不填,则代表不开启防暴力破解功能。默认为 `0`
loginMaxFailures: 5, # 允许触发保护前,单个 IP 连续登录失败的最大次数。默认为 `5`
# 自定义 STUN 服务器的地址
# 注意:可以设置为“disable”来禁用 STUN 服务(例如在局域网环境中使用时)
stun: stun.l.google.com:19302
# WebRTC 内网穿透
stun: stun.l.google.com:19302 # 默认使用的 STUN 服务器地址,用于打洞获取公网 IP 建立 P2P 流
turn:
turnAddr: example_addr
turnUser: example_user
turnCred: example_cred
turnAddr: example_addr # 当 P2P 直连失败时,作为备用的中继(TURN)服务器地址(格式如 `ip:port`)。留空表示不使用 TURN 中继
turnUser: example_user # TURN 服务器授权连接时使用的用户名
turnCred: example_cred # TURN 服务器授权连接时使用的凭据/密码
```
## 编译部署

View File

@@ -26,6 +26,10 @@ var defaultConfig = &Config{
TurnCred: "",
},
Authentication: "enable",
Security: Security{
LoginLockoutDuration: 0,
LoginMaxFailures: 5,
},
}
func checkDefaultValue() {

View File

@@ -0,0 +1,52 @@
package config
import (
"os"
"path/filepath"
"strings"
"sync"
)
const (
PicoclawInternalTokenHeader = "X-NanoKVM-Internal-Token"
picoclawInternalTokenFile = "/etc/kvm/.picoclaw_internal_token"
)
var picoclawInternalToken struct {
mu sync.Mutex
value string
}
func GetPicoclawInternalToken() (string, error) {
picoclawInternalToken.mu.Lock()
defer picoclawInternalToken.mu.Unlock()
if picoclawInternalToken.value != "" {
return picoclawInternalToken.value, nil
}
if token, err := readPicoclawInternalToken(); err == nil && token != "" {
picoclawInternalToken.value = token
return token, nil
}
token := generateRandomSecretKey()
if err := os.MkdirAll(filepath.Dir(picoclawInternalTokenFile), 0o755); err != nil {
return "", err
}
if err := os.WriteFile(picoclawInternalTokenFile, []byte(token+"\n"), 0o600); err != nil {
return "", err
}
picoclawInternalToken.value = token
return token, nil
}
func readPicoclawInternalToken() (string, error) {
data, err := os.ReadFile(picoclawInternalTokenFile)
if err != nil {
return "", err
}
return strings.TrimSpace(string(data)), nil
}

View File

@@ -1,14 +1,15 @@
package config
type Config struct {
Proto string `yaml:"proto"`
Port Port `yaml:"port"`
Cert Cert `yaml:"cert"`
Logger Logger `yaml:"logger"`
Authentication string `yaml:"authentication"`
JWT JWT `yaml:"jwt"`
Stun string `yaml:"stun"`
Turn Turn `yaml:"turn"`
Proto string `yaml:"proto"`
Port Port `yaml:"port"`
Cert Cert `yaml:"cert"`
Logger Logger `yaml:"logger"`
Authentication string `yaml:"authentication"`
JWT JWT `yaml:"jwt"`
Stun string `yaml:"stun"`
Turn Turn `yaml:"turn"`
Security Security `yaml:"security"`
Hardware Hardware `yaml:"-"`
}
@@ -40,6 +41,11 @@ type Turn struct {
TurnCred string `yaml:"turnCred"`
}
type Security struct {
LoginLockoutDuration int `yaml:"loginLockoutDuration"`
LoginMaxFailures int `yaml:"loginMaxFailures"`
}
type Hardware struct {
Version HWVersion `yaml:"-"`
GPIOReset string `yaml:"-"`

View File

@@ -8,9 +8,10 @@ require (
github.com/gin-gonic/gin v1.10.0
github.com/go-playground/validator/v10 v10.20.0
github.com/golang-jwt/jwt/v5 v5.2.2
github.com/google/uuid v1.6.0
github.com/gorilla/websocket v1.5.3
github.com/mervick/aes-everywhere/go/aes256 v0.0.0-20240803013625-6759956693c0
github.com/pion/dtls/v3 v3.0.3
github.com/pion/dtls/v3 v3.1.2
github.com/pion/rtp v1.8.18
github.com/pion/webrtc/v4 v4.0.1
github.com/rs/cors/wrapper/gin v0.0.0-20240830163046-1084d89a1692
@@ -32,7 +33,6 @@ require (
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/goccy/go-json v0.10.2 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/hashicorp/hcl v1.0.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.2.7 // indirect
@@ -46,7 +46,7 @@ require (
github.com/pion/datachannel v1.5.9 // indirect
github.com/pion/ice/v4 v4.0.2 // indirect
github.com/pion/interceptor v0.1.39 // indirect
github.com/pion/logging v0.2.3 // indirect
github.com/pion/logging v0.2.4 // indirect
github.com/pion/mdns/v2 v2.0.7 // indirect
github.com/pion/randutil v0.1.0 // indirect
github.com/pion/rtcp v1.2.15 // indirect
@@ -55,6 +55,7 @@ require (
github.com/pion/srtp/v3 v3.0.4 // indirect
github.com/pion/stun/v3 v3.0.0 // indirect
github.com/pion/transport/v3 v3.0.7 // indirect
github.com/pion/transport/v4 v4.0.1 // indirect
github.com/pion/turn/v4 v4.0.0 // indirect
github.com/rs/cors v1.11.0 // indirect
github.com/sagikazarmark/locafero v0.4.0 // indirect
@@ -66,7 +67,7 @@ require (
github.com/subosito/gotenv v1.6.0 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.12 // indirect
github.com/wlynxg/anet v0.0.3 // indirect
github.com/wlynxg/anet v0.0.5 // indirect
go.uber.org/atomic v1.9.0 // indirect
go.uber.org/multierr v1.9.0 // indirect
golang.org/x/arch v0.8.0 // indirect

View File

@@ -74,14 +74,14 @@ github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
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/dtls/v3 v3.1.2 h1:gqEdOUXLtCGW+afsBLO0LtDD8GnuBBjEy6HRtyofZTc=
github.com/pion/dtls/v3 v3.1.2/go.mod h1:Hw/igcX4pdY69z1Hgv5x7wJFrUkdgHwAn/Q/uo7YHRo=
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.39 h1:Y6k0bN9Y3Lg/Wb21JBWp480tohtns8ybJ037AGr9UuA=
github.com/pion/interceptor v0.1.39/go.mod h1:Z6kqH7M/FYirg3frjGJ21VLSRJGBXB/KqaTIrdqnOic=
github.com/pion/logging v0.2.3 h1:gHuf0zpoh1GW67Nr6Gj4cv5Z9ZscU7g/EaoC/Ke/igI=
github.com/pion/logging v0.2.3/go.mod h1:z8YfknkquMe1csOrxK5kc+5/ZPAzMxbKLX5aXpbpC90=
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=
@@ -100,6 +100,8 @@ 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/transport/v4 v4.0.1 h1:sdROELU6BZ63Ab7FrOLn13M6YdJLY20wldXW2Cu2k8o=
github.com/pion/transport/v4 v4.0.1/go.mod h1:nEuEA4AD5lPdcIegQDpVLgNoDGreqM/YqmEx3ovP4jM=
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.1 h1:6Unwc6JzoTsjxetcAIoWH81RUM4K5dBc1BbJGcF9WVE=
@@ -140,8 +142,8 @@ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO
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/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
@@ -150,8 +152,8 @@ github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65E
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
github.com/unrolled/secure v1.15.0 h1:q7x+pdp8jAHnbzxu6UheP8fRlG/rwYTb8TPuQ3rn9Og=
github.com/unrolled/secure v1.15.0/go.mod h1:BmF5hyM6tXczk3MpQkFf1hpKSRqCyhqcbiQtiAF7+40=
github.com/wlynxg/anet v0.0.3 h1:PvR53psxFXstc12jelG6f1Lv4MWqE0tI76/hHGjh9rg=
github.com/wlynxg/anet v0.0.3/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA=
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.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE=
go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI=

View File

@@ -3,10 +3,8 @@ package main
import (
"fmt"
"log"
"net/http"
"os"
"os/signal"
"strings"
"syscall"
"time"
@@ -74,14 +72,20 @@ func run() {
if conf.Proto == "https" {
go func() {
r.Use(middleware.Tls())
err := r.RunTLS(httpsAddr, conf.Cert.Crt, conf.Cert.Key)
if err != nil {
panic("start https server failed")
}
}()
runRedirect(httpAddr, httpsAddr)
if err := middleware.ListenAndServeLoopbackHTTPRedirect(
httpAddr,
httpsAddr,
r,
router.PicoclawLoopbackHTTPAllowedPaths()...,
); err != nil {
panic("start http server failed")
}
} else {
if err := r.Run(httpAddr); err != nil {
panic("start http server failed")
@@ -89,26 +93,6 @@ func run() {
}
}
func runRedirect(httpPort string, httpsPort string) {
err := http.ListenAndServe(httpPort, http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
host := req.Host
if strings.Contains(host, httpPort) {
host = strings.Split(host, httpPort)[0]
}
targetURL := "https://" + host + req.URL.String()
if httpsPort != ":443" {
targetURL = "https://" + host + httpsPort + req.URL.String()
}
http.Redirect(w, req, targetURL, http.StatusTemporaryRedirect)
}))
if err != nil {
panic("start http server failed")
}
}
func dispose() {
common.GetKvmVision().Close()
}

View File

@@ -18,27 +18,58 @@ type Token struct {
func CheckToken() gin.HandlerFunc {
return func(c *gin.Context) {
conf := config.GetInstance()
if conf.Authentication == "disable" {
if allowByToken(c) {
c.Next()
return
}
cookie, err := c.Cookie("nano-kvm-token")
if err == nil {
_, err = ParseJWT(cookie)
if err == nil {
c.Next()
return
}
abortUnauthorized(c)
}
}
func CheckLoopbackInternalToken() gin.HandlerFunc {
return func(c *gin.Context) {
if allowByLoopbackInternalToken(c.Request) {
c.Next()
return
}
c.JSON(http.StatusUnauthorized, "unauthorized")
c.Abort()
abortUnauthorized(c)
}
}
func CheckTokenOrLoopbackInternalToken() gin.HandlerFunc {
return func(c *gin.Context) {
if allowByToken(c) || allowByLoopbackInternalToken(c.Request) {
c.Next()
return
}
abortUnauthorized(c)
}
}
func allowByToken(c *gin.Context) bool {
conf := config.GetInstance()
if conf.Authentication == "disable" {
return true
}
cookie, err := c.Cookie("nano-kvm-token")
if err != nil {
return false
}
_, err = ParseJWT(cookie)
return err == nil
}
func abortUnauthorized(c *gin.Context) {
c.JSON(http.StatusUnauthorized, "unauthorized")
c.Abort()
}
func GenerateJWT(username string) (string, error) {
conf := config.GetInstance()

View File

@@ -0,0 +1,85 @@
package middleware
import (
"crypto/subtle"
"net"
"net/http"
"strings"
"NanoKVM-Server/config"
)
func ListenAndServeLoopbackHTTPRedirect(
httpPort string,
httpsPort string,
handler http.Handler,
allowedPaths ...string,
) error {
allowlist := make(map[string]struct{}, len(allowedPaths))
for _, path := range allowedPaths {
if strings.TrimSpace(path) == "" {
continue
}
allowlist[path] = struct{}{}
}
return http.ListenAndServe(httpPort, http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
if isLoopbackAllowedPath(req, allowlist) {
if hasValidLoopbackHTTPToken(req) {
handler.ServeHTTP(w, req)
return
}
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
host := req.Host
if strings.Contains(host, httpPort) {
host = strings.Split(host, httpPort)[0]
}
targetURL := "https://" + host + req.URL.String()
if httpsPort != ":443" {
targetURL = "https://" + host + httpsPort + req.URL.String()
}
http.Redirect(w, req, targetURL, http.StatusTemporaryRedirect)
}))
}
func allowByLoopbackInternalToken(req *http.Request) bool {
return req != nil && isLoopbackRemote(req.RemoteAddr) && hasValidLoopbackHTTPToken(req)
}
func isLoopbackAllowedPath(req *http.Request, allowedPaths map[string]struct{}) bool {
if !allowByLoopbackInternalToken(req) {
return false
}
_, allowed := allowedPaths[req.URL.Path]
return allowed
}
func hasValidLoopbackHTTPToken(req *http.Request) bool {
if req == nil {
return false
}
token, err := config.GetPicoclawInternalToken()
if err != nil || token == "" {
return false
}
provided := req.Header.Get(config.PicoclawInternalTokenHeader)
return subtle.ConstantTimeCompare([]byte(provided), []byte(token)) == 1
}
func isLoopbackRemote(remoteAddr string) bool {
host := strings.TrimSpace(remoteAddr)
if parsedHost, _, err := net.SplitHostPort(remoteAddr); err == nil {
host = parsedHost
}
ip := net.ParseIP(strings.Trim(host, "[]"))
return ip != nil && ip.IsLoopback()
}

View File

@@ -21,8 +21,3 @@ type ChangePasswordReq struct {
type IsPasswordUpdatedRsp struct {
IsUpdated bool `json:"isUpdated"`
}
type ConnectWifiReq struct {
Ssid string `validate:"required"`
Password string `valid:"required"`
}

View File

@@ -7,3 +7,33 @@ type GetHidModeRsp struct {
type SetHidModeReq struct {
Mode string `validate:"required"` // normal or hid-only
}
type ShortcutKey struct {
Code string `json:"code"`
Label string `json:"label"`
}
type Shortcut struct {
ID string `json:"id"`
Keys []ShortcutKey `json:"keys"`
}
type GetShortcutsRsp struct {
Shortcuts []Shortcut `json:"shortcuts"`
}
type AddShortcutReq struct {
Keys []ShortcutKey `validate:"required"`
}
type DeleteShortcutReq struct {
ID string `validate:"required"`
}
type SetLeaderKeyReq struct {
Key string `validate:"omitempty"`
}
type GetLeaderKeyRsp struct {
Key string `json:"key"`
}

View File

@@ -17,28 +17,14 @@ type SetMacNameReq struct {
Name string `form:"name" validate:"required"`
}
type TailscaleState string
const (
TailscaleNotInstall TailscaleState = "notInstall"
TailscaleNotRunning TailscaleState = "notRunning"
TailscaleNotLogin TailscaleState = "notLogin"
TailscaleStopped TailscaleState = "stopped"
TailscaleRunning TailscaleState = "running"
)
type GetTailscaleStatusRsp struct {
State TailscaleState `json:"state"`
Name string `json:"name"`
IP string `json:"ip"`
Account string `json:"account"`
}
type LoginTailscaleRsp struct {
Url string `json:"url"`
}
type GetWifiRsp struct {
Supported bool `json:"supported"`
Connected bool `json:"connected"`
Supported bool `json:"supported"`
ApMode bool `json:"apMode"`
Connected bool `json:"connected"`
Ssid string `json:"ssid"`
}
type ConnectWifiReq struct {
Ssid string `validate:"required"`
Password string `valid:"required"`
}

22
server/proto/tailscale.go Normal file
View File

@@ -0,0 +1,22 @@
package proto
type TailscaleState string
const (
TailscaleNotInstall TailscaleState = "notInstall"
TailscaleNotRunning TailscaleState = "notRunning"
TailscaleNotLogin TailscaleState = "notLogin"
TailscaleStopped TailscaleState = "stopped"
TailscaleRunning TailscaleState = "running"
)
type GetTailscaleStatusRsp struct {
State TailscaleState `json:"state"`
Name string `json:"name"`
IP string `json:"ip"`
Account string `json:"account"`
}
type LoginTailscaleRsp struct {
Url string `json:"url"`
}

View File

@@ -55,8 +55,18 @@ type DeleteScriptReq struct {
Name string `validate:"required"`
}
// autostart
type GetAutostartRsp struct {
Files []string `json:"files"`
}
type UploadAutostartReq struct {
Content string `json:"content"`
}
type GetVirtualDeviceRsp struct {
Network bool `json:"network"`
Media bool `json:"media"`
Disk bool `json:"disk"`
}

View File

@@ -11,8 +11,9 @@ func applicationRouter(r *gin.Engine) {
service := application.NewService()
api := r.Group("/api").Use(middleware.CheckToken())
api.GET("/application/version", service.GetVersion) // get application version
api.POST("/application/update", service.Update) // update application
api.GET("/application/version", service.GetVersion) // get application version
api.POST("/application/update", service.Update) // update application
api.POST("/application/update/offline", service.OfflineUpdate) // update application offline
api.GET("/application/preview", service.GetPreview) // get preview updates state
api.POST("/application/preview", service.SetPreview) // set preview updates state

View File

@@ -14,4 +14,5 @@ func downloadRouter(r *gin.Engine) {
api.POST("/download/image", service.DownloadImage) // download image
api.GET("/download/image/status", service.StatusImage) // download image
api.GET("/download/image/enabled", service.ImageEnabled) // download image
api.POST("/download/file", service.DownloadImageFile) // download image
}

View File

@@ -13,6 +13,13 @@ func hidRouter(r *gin.Engine) {
api.POST("/hid/paste", service.Paste) // paste
api.GET("/hid/shortcuts", service.GetShortcuts) // get shortcuts
api.POST("/hid/shortcut", service.AddShortcut) // add shortcut
api.DELETE("/hid/shortcut", service.DeleteShortcut) // delete shortcut
api.GET("/hid/shortcut/leader-key", service.GetLeaderKey) // set shortcut leader key
api.POST("/hid/shortcut/leader-key", service.SetLeaderKey) // set shortcut leader key
api.GET("/hid/mode", service.GetHidMode) // get hid mode
api.POST("/hid/mode", service.SetHidMode) // set hid mode
api.POST("/hid/reset", service.ResetHid) // reset hid

View File

@@ -10,7 +10,8 @@ import (
func networkRouter(r *gin.Engine) {
service := network.NewService()
r.POST("/api/network/wifi", service.ConnectWifi) // connect Wi-Fi
r.POST("/api/network/wifi", service.ConnectWifiNoAuth) // connect Wi-Fi without auth (only available in ap mode)
r.POST("/api/network/wifi/verify", service.VerifyApLogin) // verify ap login
api := r.Group("/api").Use(middleware.CheckToken())
@@ -18,5 +19,8 @@ func networkRouter(r *gin.Engine) {
api.GET("/network/wol/mac", service.GetMac) // get mac list
api.DELETE("/network/wol/mac", service.DeleteMac) // delete mac
api.POST("/network/wol/mac/name", service.SetMacName) // set mac name
api.GET("/network/wifi", service.GetWifi) // get Wi-Fi information
api.GET("/network/wifi", service.GetWifi) // get Wi-Fi information
api.POST("/network/wifi/connect", service.ConnectWifi) // connect Wi-Fi
api.POST("/network/wifi/disconnect", service.DisconnectWifi) // disconnect Wi-Fi
}

64
server/router/picoclaw.go Normal file
View File

@@ -0,0 +1,64 @@
package router
import (
"github.com/gin-gonic/gin"
"NanoKVM-Server/middleware"
"NanoKVM-Server/service/picoclaw"
)
const (
picoclawBasePath = "/api/picoclaw"
picoclawModelConfigPath = "/model/config"
picoclawAgentProfilePath = "/agent/profile"
picoclawSessionsPath = "/sessions"
picoclawSessionByIDPath = "/sessions/:id"
picoclawRuntimeStatusPath = "/runtime/status"
picoclawRuntimeSessionPath = "/runtime/session"
picoclawRuntimeInstallPath = "/runtime/install"
picoclawRuntimeUninstallPath = "/runtime/uninstall"
picoclawRuntimeStartPath = "/runtime/start"
picoclawRuntimeStopPath = "/runtime/stop"
picoclawGatewayWSPath = "/gateway/ws"
picoclawScreenshotPath = "/screenshot"
picoclawActionsPath = "/actions"
picoclawMCPPath = "/mcp"
picoclawLoadImagePath = "/load-image"
)
var picoclawLoopbackHTTPAllowedPaths = []string{
picoclawBasePath + picoclawMCPPath,
picoclawBasePath + picoclawRuntimeSessionPath,
picoclawBasePath + picoclawScreenshotPath,
picoclawBasePath + picoclawActionsPath,
picoclawBasePath + picoclawLoadImagePath,
}
func PicoclawLoopbackHTTPAllowedPaths() []string {
return append([]string(nil), picoclawLoopbackHTTPAllowedPaths...)
}
func picoclawRouter(r *gin.Engine) {
service := picoclaw.NewService()
frontendAPI := r.Group(picoclawBasePath).Use(middleware.CheckToken())
localAPI := r.Group(picoclawBasePath).Use(middleware.CheckLoopbackInternalToken())
localAPI.GET(picoclawScreenshotPath, service.Screenshot)
localAPI.POST(picoclawActionsPath, service.Actions)
localAPI.POST(picoclawMCPPath, service.MCPHandler)
localAPI.POST(picoclawLoadImagePath, service.LoadImage)
localAPI.GET(picoclawRuntimeSessionPath, service.GetRuntimeSession)
frontendAPI.POST(picoclawModelConfigPath, service.UpdateModelConfig)
frontendAPI.POST(picoclawAgentProfilePath, service.UpdateAgentProfile)
frontendAPI.GET(picoclawSessionsPath, service.ListSessions)
frontendAPI.GET(picoclawSessionByIDPath, service.GetSession)
frontendAPI.DELETE(picoclawSessionByIDPath, service.DeleteSession)
frontendAPI.GET(picoclawRuntimeStatusPath, service.GetRuntimeStatus)
frontendAPI.DELETE(picoclawRuntimeSessionPath, service.ReleaseRuntimeSession)
frontendAPI.POST(picoclawRuntimeInstallPath, service.InstallRuntime)
frontendAPI.POST(picoclawRuntimeUninstallPath, service.UninstallRuntime)
frontendAPI.POST(picoclawRuntimeStartPath, service.StartRuntime)
frontendAPI.POST(picoclawRuntimeStopPath, service.StopRuntime)
frontendAPI.GET(picoclawGatewayWSPath, service.ConnectGateway)
}

View File

@@ -36,6 +36,7 @@ func server(r *gin.Engine) {
storageRouter(r)
networkRouter(r)
hidRouter(r)
picoclawRouter(r)
wsRouter(r)
downloadRouter(r)
extensionsRouter(r)

View File

@@ -63,5 +63,10 @@ func vmRouter(r *gin.Engine) {
api.POST("/vm/tls", service.SetTls) // enable/disable TLS
api.GET("/vm/autostart", service.GetAutostart) // get autostart list
api.GET("/vm/autostart/:name", service.GetAutostartContent) // get autostart content
api.DELETE("/vm/autostart/:name", service.DeleteAutostart) // delete autostart script
api.POST("/vm/autostart/:name", service.UploadAutostart) // upload autostart script
api.POST("/vm/system/reboot", service.Reboot) // reboot system
}

View File

@@ -0,0 +1,76 @@
package application
import (
"fmt"
"os"
"sync"
"NanoKVM-Server/utils"
log "github.com/sirupsen/logrus"
)
var (
mutex sync.Mutex
isUpdating bool
)
func acquireUpdateLock() bool {
mutex.Lock()
defer mutex.Unlock()
if isUpdating {
return false
}
isUpdating = true
return true
}
func releaseUpdateLock() {
mutex.Lock()
defer mutex.Unlock()
isUpdating = false
}
func installPackage(source string) error {
dir, err := utils.UnTarGz(source, CacheDir)
if err != nil {
return fmt.Errorf("failed to decompress app: %w", err)
}
if err := backupCurrentApp(); err != nil {
return err
}
if err := applyUpdate(dir); err != nil {
return err
}
if err := utils.ChmodRecursively(AppDir, 0o755); err != nil {
return fmt.Errorf("failed to chmod: %w", err)
}
return nil
}
func backupCurrentApp() error {
if err := os.RemoveAll(BackupDir); err != nil {
return fmt.Errorf("failed to remove backup: %w", err)
}
if err := utils.MoveFilesRecursively(AppDir, BackupDir); err != nil {
return fmt.Errorf("failed to backup app: %w", err)
}
return nil
}
func applyUpdate(sourceDir string) error {
if err := utils.MoveFilesRecursively(sourceDir, AppDir); err != nil {
// Try to restore backup on failure
if restoreErr := utils.MoveFilesRecursively(BackupDir, AppDir); restoreErr != nil {
log.Errorf("Failed to restore backup after update failure: %v", restoreErr)
}
return fmt.Errorf("failed to move update in place: %w", err)
}
return nil
}

View File

@@ -0,0 +1,84 @@
package application
import (
"fmt"
"io"
"os"
"strings"
"time"
log "github.com/sirupsen/logrus"
)
const (
sentinelPath = "/tmp/.download_in_progress"
tickerInterval = 2500 * time.Millisecond
sentinelPermission = 0644
)
// progressWriter wraps an io.Writer to track and report upload progress.
type progressWriter struct {
writer io.Writer
written int64
totalSize int64
ticker *time.Ticker
done chan struct{}
}
func newProgressWriter(w io.Writer, totalSize int64) *progressWriter {
pw := &progressWriter{
writer: w,
totalSize: totalSize,
ticker: time.NewTicker(tickerInterval),
done: make(chan struct{}),
}
go pw.reportProgress()
return pw
}
func (pw *progressWriter) reportProgress() {
for {
select {
case <-pw.done:
return
case <-pw.ticker.C:
pw.updateSentinel()
}
}
}
func (pw *progressWriter) updateSentinel() {
if pw.totalSize == 0 {
return
}
content, err := os.ReadFile(sentinelPath)
if err != nil {
log.Error("Failed to read sentinel file")
return
}
parts := strings.SplitN(string(content), ";", 2)
if len(parts) == 0 {
return
}
percentage := float64(pw.written) / float64(pw.totalSize) * 100
newContent := fmt.Sprintf("%s;%.2f%%", parts[0], percentage)
if err := os.WriteFile(sentinelPath, []byte(newContent), sentinelPermission); err != nil {
log.Error("Failed to update sentinel file")
}
}
func (pw *progressWriter) Write(p []byte) (int, error) {
n, err := pw.writer.Write(p)
pw.written += int64(n)
return n, err
}
func (pw *progressWriter) Stop() {
pw.ticker.Stop()
close(pw.done)
}

View File

@@ -8,7 +8,6 @@ import (
"net/http"
"os"
"os/exec"
"sync"
"time"
"github.com/gin-gonic/gin"
@@ -22,28 +21,14 @@ const (
maxTries = 3
)
var (
updateMutex sync.Mutex
isUpdating bool
)
func (s *Service) Update(c *gin.Context) {
var rsp proto.Response
updateMutex.Lock()
if isUpdating {
updateMutex.Unlock()
if !acquireUpdateLock() {
rsp.ErrRsp(c, -1, "update already in progress")
return
}
isUpdating = true
updateMutex.Unlock()
defer func() {
updateMutex.Lock()
isUpdating = false
updateMutex.Unlock()
}()
defer releaseUpdateLock()
if err := update(); err != nil {
rsp.ErrRsp(c, -1, fmt.Sprintf("update failed: %s", err))
@@ -74,7 +59,6 @@ func update() error {
// download
target := fmt.Sprintf("%s/%s", CacheDir, latest.Name)
if err := download(latest.Url, target); err != nil {
log.Errorf("download app failed: %s", err)
return err
@@ -86,34 +70,9 @@ func update() error {
return err
}
// decompress
dir, err := utils.UnTarGz(target, CacheDir)
log.Debugf("untar: %s", dir)
if err != nil {
log.Errorf("decompress app failed: %s", err)
return err
}
// backup old version
if err := os.RemoveAll(BackupDir); err != nil {
log.Errorf("remove backup failed: %s", err)
return err
}
if err := utils.MoveFilesRecursively(AppDir, BackupDir); err != nil {
log.Errorf("backup app failed: %s", err)
return err
}
// update
if err := utils.MoveFilesRecursively(dir, AppDir); err != nil {
log.Errorf("failed to move update back in place: %s", err)
return err
}
// modify permissions
if err := utils.ChmodRecursively(AppDir, 0o755); err != nil {
log.Errorf("chmod failed: %s", err)
// install
if err := installPackage(target); err != nil {
log.Errorf("failed to install package: %v", err)
return err
}
@@ -124,7 +83,7 @@ func download(url string, target string) (err error) {
for i := range maxTries {
log.Debugf("attempt #%d/%d", i+1, maxTries)
if i > 0 {
time.Sleep(time.Second * 3) // wait for 3 seconds before retrying the download attempt
time.Sleep(time.Second * 3)
}
var req *http.Request

View File

@@ -0,0 +1,176 @@
package application
import (
"fmt"
"io"
"mime/multipart"
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
"time"
"NanoKVM-Server/proto"
"github.com/gin-gonic/gin"
log "github.com/sirupsen/logrus"
)
var validFilenameRegex = regexp.MustCompile(`^[a-zA-Z0-9._-]+$`)
func (s *Service) OfflineUpdate(c *gin.Context) {
var rsp proto.Response
if !acquireUpdateLock() {
rsp.ErrRsp(c, -1, "update already in progress")
return
}
defer releaseUpdateLock()
if err := offlineUpdate(c); err != nil {
rsp.ErrRsp(c, -1, fmt.Sprintf("update failed: %s", err))
return
}
rsp.OkRsp(c)
log.Debugf("offline update application success")
time.Sleep(1 * time.Second)
_ = exec.Command("sh", "-c", "/etc/init.d/S95nanokvm restart").Run()
}
func offlineUpdate(c *gin.Context) error {
_ = os.RemoveAll(CacheDir)
_ = os.MkdirAll(CacheDir, 0o755)
defer func() {
_ = os.RemoveAll(CacheDir)
}()
if err := checkDownloadInProgress(); err != nil {
return err
}
if err := createSentinelFile(); err != nil {
return err
}
defer removeSentinelFile()
reader, err := c.Request.MultipartReader()
if err != nil {
log.Errorf("Invalid multipart data: %v", err)
return fmt.Errorf("invalid multipart data: %w", err)
}
target, err := processUpload(reader, c.Request.ContentLength)
if err != nil {
log.Errorf("failed to upload install package: %v", err)
return err
}
if err := installPackage(target); err != nil {
log.Errorf("failed to install package: %v", err)
return err
}
return nil
}
func checkDownloadInProgress() error {
if _, err := os.Stat(sentinelPath); err == nil {
log.Debug("Download in progress")
return fmt.Errorf("download already in progress")
}
return nil
}
func createSentinelFile() error {
if err := os.WriteFile(sentinelPath, []byte("downloading"), sentinelPermission); err != nil {
log.Errorf("Failed to create sentinel file: %v", err)
return fmt.Errorf("failed to create sentinel file: %w", err)
}
return nil
}
func processUpload(reader *multipart.Reader, contentLength int64) (string, error) {
var outPath string
for {
part, err := reader.NextPart()
if err == io.EOF {
break
}
if err != nil {
return "", fmt.Errorf("failed to read multipart: %w", err)
}
if part.FormName() != "file" {
continue
}
outPath, err = saveUploadedFile(part, contentLength)
if err != nil {
return "", err
}
}
if outPath == "" {
return "", fmt.Errorf("no file uploaded")
}
return outPath, nil
}
func saveUploadedFile(part *multipart.Part, contentLength int64) (string, error) {
filename := part.FileName()
if filename == "" {
return "", fmt.Errorf("no filename provided")
}
if err := validateFilename(filename); err != nil {
return "", err
}
outPath := filepath.Join(CacheDir, filename)
out, err := os.Create(outPath)
if err != nil {
return "", fmt.Errorf("failed to create output file: %w", err)
}
defer out.Close()
pw := newProgressWriter(out, contentLength)
defer pw.Stop()
if _, err := io.Copy(pw, part); err != nil {
return "", fmt.Errorf("failed to write file: %w", err)
}
return outPath, nil
}
func validateFilename(filename string) error {
baseName := filepath.Base(filename)
// Check if the path contains directory components
if baseName != filename {
log.Warnf("Path detected in filename: %s", filename)
return fmt.Errorf("path detected in filename")
}
// Check for path traversal attempts
if strings.Contains(filename, "..") {
log.Warnf("Path traversal attempt: %s", filename)
return fmt.Errorf("invalid filename: path traversal detected")
}
// Validate filename characters
if !validFilenameRegex.MatchString(filename) {
log.Warnf("Invalid filename characters: %s", filename)
return fmt.Errorf("invalid filename: contains invalid characters")
}
return nil
}
func removeSentinelFile() {
_ = os.Remove(sentinelPath)
}

View File

@@ -1,7 +1,6 @@
package application
import (
"NanoKVM-Server/proto"
"encoding/json"
"fmt"
"io"
@@ -10,6 +9,8 @@ import (
"strings"
"time"
"NanoKVM-Server/proto"
"github.com/gin-gonic/gin"
log "github.com/sirupsen/logrus"
)
@@ -36,15 +37,15 @@ func (s *Service) GetVersion(c *gin.Context) {
log.Debugf("current version: %s", currentVersion)
// latest version
latestVersion := ""
latest, err := getLatest()
if err != nil {
rsp.ErrRsp(c, -1, "get latest version failed")
return
if err == nil && latest != nil {
latestVersion = latest.Version
}
rsp.OkRspWithData(c, &proto.GetVersionRsp{
Current: currentVersion,
Latest: latest.Version,
Latest: latestVersion,
})
}

View File

@@ -0,0 +1,147 @@
package auth
import (
"sync"
"time"
"NanoKVM-Server/config"
"github.com/gin-gonic/gin"
log "github.com/sirupsen/logrus"
)
type loginAttempt struct {
failures int
lastFailed time.Time
lockoutEnd time.Time
}
const (
maxLoginAttemptsRecords = 3000
cleanupInterval = 6 * time.Hour
)
var (
loginAttempts = make(map[string]*loginAttempt)
loginMutex sync.Mutex
cleanupOnce sync.Once
)
// startCleanupRoutine starts a background routine to clean up memory
func startCleanupRoutine() {
conf := config.GetInstance()
if conf.Security.LoginLockoutDuration <= 0 {
return
}
go func() {
ticker := time.NewTicker(cleanupInterval)
for range ticker.C {
loginMutex.Lock()
now := time.Now()
for ip, attempt := range loginAttempts {
// Cleanup rules: if it has been locked and the lockout time has passed,
// or (although not locked) it has been 30 minutes since the last failure,
// remove this record
if (!attempt.lockoutEnd.IsZero() && now.After(attempt.lockoutEnd)) ||
(attempt.lockoutEnd.IsZero() && now.Sub(attempt.lastFailed) > 30*time.Minute) {
delete(loginAttempts, ip)
}
}
loginMutex.Unlock()
}
}()
}
// GetClientIP gets a reliable real IP
func GetClientIP(c *gin.Context) string {
ip := c.RemoteIP()
if ip == "" {
ip = c.ClientIP()
}
return ip
}
// CheckLoginAttempt checks if a login attempt is allowed based on brute-force protection rules.
// Returning true means the IP/System is locked out, and an error string and error code are returned.
func CheckLoginAttempt(clientIP string) (bool, int, string) {
conf := config.GetInstance()
if conf.Security.LoginLockoutDuration <= 0 {
return false, 0, ""
}
cleanupOnce.Do(startCleanupRoutine)
loginMutex.Lock()
defer loginMutex.Unlock()
if attempt, exists := loginAttempts[clientIP]; exists {
if time.Now().Before(attempt.lockoutEnd) {
log.Debugf("login blocked for IP %s: account locked due to too many failed attempts (until %s)", clientIP, attempt.lockoutEnd)
return true, -5, "Account locked due to too many failed attempts, please try again later"
}
// If lockout has elapsed, then we reset the failures and lockoutEnd.
if !attempt.lockoutEnd.IsZero() {
attempt.failures = 0
attempt.lockoutEnd = time.Time{}
}
}
return false, 0, ""
}
// RecordLoginFailure records a failed login attempt for the given IP address.
func RecordLoginFailure(clientIP string) (bool, int, string) {
conf := config.GetInstance()
if conf.Security.LoginLockoutDuration <= 0 {
return false, 0, ""
}
cleanupOnce.Do(startCleanupRoutine)
loginMutex.Lock()
defer loginMutex.Unlock()
attempt, exists := loginAttempts[clientIP]
if !exists {
// When the record pool is full, clear the records instead of global lockout to prevent DDoS
if len(loginAttempts) >= maxLoginAttemptsRecords {
log.Warn("Login attempt records reached maximum limit, clearing records to prevent memory overflow")
loginAttempts = make(map[string]*loginAttempt)
}
attempt = &loginAttempt{}
loginAttempts[clientIP] = attempt
}
now := time.Now()
// Failure time window: if it has been a long time since the last failure
// (e.g., beyond the lockoutDuration window), reset the failure count
if !attempt.lastFailed.IsZero() && now.Sub(attempt.lastFailed) > time.Duration(conf.Security.LoginLockoutDuration)*time.Second {
attempt.failures = 0
}
attempt.failures++
attempt.lastFailed = now
// Reach the failure limit, lock out
if attempt.failures >= conf.Security.LoginMaxFailures {
attempt.lockoutEnd = now.Add(time.Duration(conf.Security.LoginLockoutDuration) * time.Second)
log.Debugf("login failures reached threshold for IP %s, locking out until %s", clientIP, attempt.lockoutEnd)
}
return false, 0, ""
}
// ClearLoginAttempt clears the failed login attempt record for an IP upon successful login.
func ClearLoginAttempt(clientIP string) {
conf := config.GetInstance()
if conf.Security.LoginLockoutDuration <= 0 {
return
}
loginMutex.Lock()
defer loginMutex.Unlock()
delete(loginAttempts, clientIP)
}

View File

@@ -1,10 +1,11 @@
package auth
import (
"time"
"NanoKVM-Server/config"
"NanoKVM-Server/middleware"
"NanoKVM-Server/proto"
"time"
"github.com/gin-gonic/gin"
log "github.com/sirupsen/logrus"
@@ -23,6 +24,13 @@ func (s *Service) Login(c *gin.Context) {
return
}
clientIP := GetClientIP(c)
if locked, code, msg := CheckLoginAttempt(clientIP); locked {
time.Sleep(3 * time.Second)
rsp.ErrRsp(c, code, msg)
return
}
if err := proto.ParseFormRequest(c, &req); err != nil {
time.Sleep(3 * time.Second)
rsp.ErrRsp(c, -1, "invalid parameters")
@@ -31,10 +39,18 @@ func (s *Service) Login(c *gin.Context) {
if ok := CompareAccount(req.Username, req.Password); !ok {
time.Sleep(2 * time.Second)
if locked, code, msg := RecordLoginFailure(clientIP); locked {
rsp.ErrRsp(c, code, msg)
return
}
rsp.ErrRsp(c, -2, "invalid username or password")
return
}
ClearLoginAttempt(clientIP)
token, err := middleware.GenerateJWT(req.Username)
if err != nil {
time.Sleep(1 * time.Second)

View File

@@ -12,6 +12,7 @@ import (
"path/filepath"
"strings"
"time"
"regexp"
)
type Service struct{}
@@ -51,6 +52,28 @@ func (s *Service) ImageEnabled(c *gin.Context) {
})
}
func isISO9660(path string) (bool, error) {
f, err := os.Open(path)
if err != nil {
return false, err
}
defer f.Close()
// ISO-9660 Magic "CD001" bei Offset 32769
_, err = f.Seek(0x8001, io.SeekStart)
if err != nil {
return false, err
}
buf := make([]byte, 5)
_, err = io.ReadFull(f, buf)
if err != nil {
return false, err
}
return string(buf) == "CD001", nil
}
func (s *Service) StatusImage(c *gin.Context) {
var rsp proto.Response
@@ -93,6 +116,170 @@ func (s *Service) StatusImage(c *gin.Context) {
})
}
func (s *Service) DownloadImageFile(c *gin.Context) {
var rsp proto.Response
log.Debug("DownloadImage")
// Set a sentinel file to mark that there is a download in progress
// This is to prevent multiple downloads at the same time
if _, err := os.Stat(sentinelPath); err == nil {
log.Debug("Download in progress")
rsp.ErrRsp(c, -1, "download in progress")
return
}
// Create the sentinel file
err := os.WriteFile(sentinelPath, []byte("start"), 0644)
if err != nil {
log.Error("Failed to create sentinel file")
rsp.ErrRsp(c, -1, "failed to create sentinel file")
return
}
// Multipart Reader direkt nutzen (keine FormFile!)
reader, err := c.Request.MultipartReader()
if err != nil {
log.Error("invalid multipart data")
rsp.ErrRsp(c, -1, "invalid multipart data")
defer os.Remove(sentinelPath)
return
}
var lw *loggingWriter
for {
part, err := reader.NextPart()
if err == io.EOF {
break
}
if err != nil {
log.Error("failed to read part")
rsp.ErrRsp(c, -1, "failed to read part")
lw.stopTicker()
defer os.Remove(sentinelPath)
return
}
if part.FormName() != "file" {
continue
}
filename := part.FileName()
if filename == "" {
log.Error("no filename")
rsp.ErrRsp(c, -1, "no filename")
lw.stopTicker()
defer os.Remove(sentinelPath)
return
}
filename = filepath.Base(filename)
if filename != part.FileName() {
log.Warn("path detected in filename")
rsp.ErrRsp(c, -1, "invalid filename")
defer os.Remove(sentinelPath)
return
}
if strings.Contains(filename, "..") {
log.Warn("path traversal attempt")
rsp.ErrRsp(c, -1, "invalid filename")
defer os.Remove(sentinelPath)
return
}
if !strings.HasSuffix(strings.ToLower(filename), ".iso") {
rsp.ErrRsp(c, -1, "only .iso files allowed")
defer os.Remove(sentinelPath)
return
}
valid := regexp.MustCompile(`^[a-zA-Z0-9._-]+$`)
if !valid.MatchString(filename) {
rsp.ErrRsp(c, -1, "invalid filename")
defer os.Remove(sentinelPath)
return
}
data, err := os.ReadFile(sentinelPath)
if err != nil {
log.Error("Read failed")
rsp.ErrRsp(c, -1, "Read failed")
lw.stopTicker()
defer os.Remove(sentinelPath)
return
}
outPath := "/data/" + filename
out, err := os.Create(outPath)
if err != nil {
log.Error("cannot create file")
rsp.ErrRsp(c, -1, "cannot create file")
lw.stopTicker()
defer os.Remove(sentinelPath)
return
}
defer out.Close()
if strings.Contains(string(data), "start") {
err = os.WriteFile(sentinelPath, []byte(filename), 0644)
if err != nil {
log.Error("Failed to create sentinel file")
rsp.ErrRsp(c, -1, "failed to create sentinel file")
lw.stopTicker()
defer os.Remove(outPath)
defer os.Remove(sentinelPath)
return
}
lw = &loggingWriter{writer: out, totalSize: c.Request.ContentLength}
lw.startTicker()
} else {
if !strings.Contains(string(data), filename) {
log.Error("failed")
rsp.ErrRsp(c, -1, "failed")
lw.stopTicker()
defer os.Remove(outPath)
defer os.Remove(sentinelPath)
return
}
}
// Direkt streamen → kein RAM-Bedarf außer kleinem Buffer
_, err = io.Copy(lw, part)
if err != nil {
log.Error("write failed")
rsp.ErrRsp(c, -1, "write failed")
lw.stopTicker()
defer os.Remove(outPath)
defer os.Remove(sentinelPath)
return
}
ok, err := isISO9660(outPath)
if err != nil || !ok {
rsp.ErrRsp(c, -1, "file is not a valid ISO image")
lw.stopTicker()
defer os.Remove(outPath)
defer os.Remove(sentinelPath)
return
}
}
lw.stopTicker()
rsp.OkRspWithData(c, &proto.StatusImageRsp{
Status: "idle",
File: "",
Percentage: "",
})
defer os.Remove(sentinelPath)
return
}
func (s *Service) DownloadImage(c *gin.Context) {
var req proto.MountImageReq
var rsp proto.Response

View File

@@ -20,11 +20,13 @@ const (
)
var StateMap = map[string]proto.TailscaleState{
"NoState": proto.TailscaleNotRunning,
"Starting": proto.TailscaleNotRunning,
"NeedsLogin": proto.TailscaleNotLogin,
"Running": proto.TailscaleRunning,
"Stopped": proto.TailscaleStopped,
"NoState": proto.TailscaleNotRunning,
"Starting": proto.TailscaleNotRunning,
"NeedsLogin": proto.TailscaleNotLogin,
"NeedsMachineAuth": proto.TailscaleNotLogin,
"InUseOtherUser": proto.TailscaleNotLogin,
"Running": proto.TailscaleRunning,
"Stopped": proto.TailscaleStopped,
}
func NewService() *Service {

View File

@@ -18,9 +18,9 @@ type Hid struct {
}
const (
HID0 = "/dev/hidg0"
HID1 = "/dev/hidg1"
HID2 = "/dev/hidg2"
HID0 = "/dev/hidg0" // Keyboard
HID1 = "/dev/hidg1" // Mouse (Relative Mode)
HID2 = "/dev/hidg2" // Touchpad (Absolute Mode)
)
var (
@@ -95,16 +95,22 @@ func (h *Hid) Close() {
}
func (h *Hid) WriteHid0(data []byte) {
deadline := time.Now().Add(8 * time.Millisecond)
h.kbMutex.Lock()
_ = h.g0.SetWriteDeadline(deadline)
_, err := h.g0.Write(data)
h.kbMutex.Unlock()
if err != nil {
if errors.Is(err, os.ErrClosed) {
switch {
case errors.Is(err, os.ErrClosed):
log.Errorf("hid already closed, reopen it...")
h.OpenNoLock()
} else {
log.Debugf("write to %s failed: %s", HID0, err)
case errors.Is(err, os.ErrDeadlineExceeded):
log.Debugf("write to %s timeout", HID0)
default:
log.Errorf("write to %s failed: %s", HID0, err)
}
return
}

View File

@@ -1,15 +1,16 @@
package hid
func (h *Hid) Keyboard(queue <-chan []int) {
for event := range queue {
code := byte(event[0])
import (
log "github.com/sirupsen/logrus"
)
var modifier byte = 0x00
if code > 0 {
modifier = byte(event[1]) | byte(event[2]) | byte(event[3]) | byte(event[4])
func (h *Hid) Keyboard(queue <-chan []byte) {
for event := range queue {
if len(event) != 8 {
log.Debugf("invalid keyboard event: %v", event)
continue
}
data := []byte{modifier, 0x00, code, 0x00, 0x00, 0x00, 0x00, 0x00}
h.WriteHid0(data)
h.WriteHid0(event)
}
}

View File

@@ -0,0 +1,63 @@
package hid
import (
"os"
"strings"
"NanoKVM-Server/proto"
"github.com/gin-gonic/gin"
log "github.com/sirupsen/logrus"
)
const (
LeaderKeyFile = "/etc/kvm/leader-key"
)
func (s *Service) SetLeaderKey(c *gin.Context) {
var req proto.SetLeaderKeyReq
var rsp proto.Response
if err := proto.ParseFormRequest(c, &req); err != nil {
rsp.ErrRsp(c, -1, "invalid arguments")
return
}
if req.Key == "" {
err := os.Remove(LeaderKeyFile)
if err != nil && !os.IsNotExist(err) {
rsp.ErrRsp(c, -2, "reset failed")
return
}
} else {
err := os.WriteFile(LeaderKeyFile, []byte(req.Key), 0o644)
if err != nil {
rsp.ErrRsp(c, -3, "write failed")
return
}
}
rsp.OkRsp(c)
log.Debugf("set leader key: %s", req.Key)
}
func (s *Service) GetLeaderKey(c *gin.Context) {
var rsp proto.Response
data, err := os.ReadFile(LeaderKeyFile)
if err != nil {
if os.IsNotExist(err) {
rsp.OkRspWithData(c, &proto.GetLeaderKeyRsp{
Key: "",
})
return
}
rsp.ErrRsp(c, -1, "read leader key failed")
return
}
rsp.OkRspWithData(c, &proto.GetLeaderKeyRsp{
Key: strings.Replace(string(data), "\n", "", -1),
})
log.Debugf("get leader key successful")
}

View File

@@ -1,82 +1,18 @@
package hid
import (
"encoding/binary"
log "github.com/sirupsen/logrus"
)
const (
MouseUp = iota
MouseDown
MouseMoveAbsolute
MouseMoveRelative
MouseScroll
)
var mouseButtonMap = map[byte]bool{
0x01: true,
0x02: true,
0x04: true,
}
func (h *Hid) Mouse(queue <-chan []int) {
func (h *Hid) Mouse(queue <-chan []byte) {
for event := range queue {
switch event[0] {
case MouseDown:
h.mouseDown(event)
case MouseUp:
h.mouseUp()
case MouseMoveAbsolute:
h.mouseMoveAbsolute(event)
case MouseMoveRelative:
h.mouseMoveRelative(event)
case MouseScroll:
h.mouseScroll(event)
switch len(event) {
case 4:
h.WriteHid1(event)
case 6:
h.WriteHid2(event)
default:
log.Debugf("invalid mouse event: %v", event)
}
}
}
func (h *Hid) mouseDown(event []int) {
button := byte(event[1])
if _, ok := mouseButtonMap[button]; !ok {
log.Errorf("invalid mouse button: %v", event)
return
}
data := []byte{button, 0, 0, 0}
h.WriteHid1(data)
}
func (h *Hid) mouseUp() {
data := []byte{0, 0, 0, 0}
h.WriteHid1(data)
}
func (h *Hid) mouseScroll(event []int) {
direction := 0x01
if event[3] > 0 {
direction = -0x1
}
data := []byte{0, 0, 0, byte(direction)}
h.WriteHid1(data)
}
func (h *Hid) mouseMoveAbsolute(event []int) {
x := make([]byte, 2)
y := make([]byte, 2)
binary.LittleEndian.PutUint16(x, uint16(event[2]))
binary.LittleEndian.PutUint16(y, uint16(event[3]))
data := []byte{0, x[0], x[1], y[0], y[1], 0}
h.WriteHid2(data)
}
func (h *Hid) mouseMoveRelative(event []int) {
data := []byte{byte(event[1]), byte(event[2]), byte(event[3]), 0}
h.WriteHid1(data)
}

View File

@@ -20,25 +20,25 @@ type PasteReq struct {
}
func LangueSwitch(base map[rune]Char, lang string) map[rune]Char {
// wenn kein lang angegeben → Standardmap zurück
// if no language is specified → return base map
if lang == "" {
return base
}
// immer Kopie erstellen
// always create a copy of the base map
m := copyMap(base)
switch lang {
case "de":
// Y tauschen
// swap Y
m['y'] = Char{0, 29}
m['Y'] = Char{2, 29}
// Z tauschen
// swap Z
m['z'] = Char{0, 28}
m['Z'] = Char{2, 28}
// deutsche Sonderzeichen hinzufügen oder remappen
// add German special characters or remap them
m['\u00E4'] = Char{0, 52} // ä
m['\u00C4'] = Char{2, 52} // Ä
m['\u00F6'] = Char{0, 51} // ö
@@ -47,8 +47,8 @@ func LangueSwitch(base map[rune]Char, lang string) map[rune]Char {
m['\u00DC'] = Char{2, 47} // Ü
m['\u00DF'] = Char{0, 45} // ß
//Tauschen
m['^'] = Char{0, 53} // muss doppelt sein
// swap special characters
m['^'] = Char{0, 53} // must be double
m['/'] = Char{2, 36} // Shift + 7
m['('] = Char{2, 37} // Shift + 8
m['&'] = Char{2, 35} // Shift + 6
@@ -75,7 +75,7 @@ func LangueSwitch(base map[rune]Char, lang string) map[rune]Char {
m['-'] = Char{0, 56} // Shift + -
m['_'] = Char{2, 56} // Shift + -
//neu
// new special characters
m['\u00B4'] = Char{0, 46} // ´
m['\u00B0'] = Char{2, 53} // °
m['\u00A7'] = Char{2, 32} // §
@@ -131,6 +131,10 @@ func copyMap(src map[rune]Char) map[rune]Char {
return dst
}
func GetCharMap(lang string) map[rune]Char {
return LangueSwitch(charMap, lang)
}
var charMap = map[rune]Char{
// Lowercase letters
'a': {0, 4}, 'b': {0, 5}, 'c': {0, 6}, 'd': {0, 7}, 'e': {0, 8},

View File

@@ -0,0 +1,172 @@
package hid
import (
"encoding/json"
"errors"
"os"
"sync"
"NanoKVM-Server/proto"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
log "github.com/sirupsen/logrus"
)
var (
shortcutFile = "/etc/kvm/shortcuts.json"
shortcutMutex = sync.RWMutex{}
)
type ShortcutStore struct {
Shortcuts []proto.Shortcut `json:"shortcuts"`
}
func (s *Service) GetShortcuts(c *gin.Context) {
var rsp proto.Response
shortcuts, err := listShortcuts()
if err != nil {
log.Errorf("failed to get shortcuts: %v", err)
rsp.ErrRsp(c, -1, "get shortcuts failed")
return
}
rsp.OkRspWithData(c, &proto.GetShortcutsRsp{
Shortcuts: shortcuts,
})
log.Debugf("get shortcuts success, total: %d", len(shortcuts))
}
func (s *Service) AddShortcut(c *gin.Context) {
var req proto.AddShortcutReq
var rsp proto.Response
if err := proto.ParseFormRequest(c, &req); err != nil {
rsp.ErrRsp(c, -1, "invalid arguments")
return
}
shortcut, err := addShortcut(req.Keys)
if err != nil {
log.Errorf("failed to add shortcut: %v", err)
rsp.ErrRsp(c, -2, "add shortcut failed")
return
}
rsp.OkRsp(c)
log.Debugf("add shortcut %s", shortcut.ID)
}
func (s *Service) DeleteShortcut(c *gin.Context) {
var req proto.DeleteShortcutReq
var rsp proto.Response
if err := proto.ParseFormRequest(c, &req); err != nil {
rsp.ErrRsp(c, -1, "invalid arguments")
return
}
err := deleteShortcut(req.ID)
if err != nil {
log.Errorf("failed to delete shortcut: %v", err)
rsp.ErrRsp(c, -2, "delete shortcut failed")
return
}
rsp.OkRsp(c)
log.Debugf("delete shortcut %s", req.ID)
}
func loadShortcuts() (*ShortcutStore, error) {
if _, err := os.Stat(shortcutFile); os.IsNotExist(err) {
return &ShortcutStore{Shortcuts: []proto.Shortcut{}}, nil
}
data, err := os.ReadFile(shortcutFile)
if err != nil {
return nil, err
}
if len(data) == 0 {
return &ShortcutStore{Shortcuts: []proto.Shortcut{}}, nil
}
var store ShortcutStore
if err := json.Unmarshal(data, &store); err != nil {
return nil, err
}
return &store, nil
}
func saveShortcuts(store *ShortcutStore) error {
data, err := json.Marshal(store)
if err != nil {
return err
}
return os.WriteFile(shortcutFile, data, 0644)
}
func listShortcuts() ([]proto.Shortcut, error) {
shortcutMutex.RLock()
defer shortcutMutex.RUnlock()
store, err := loadShortcuts()
if err != nil {
return nil, err
}
return store.Shortcuts, nil
}
func addShortcut(keys []proto.ShortcutKey) (*proto.Shortcut, error) {
shortcutMutex.Lock()
defer shortcutMutex.Unlock()
store, err := loadShortcuts()
if err != nil {
return nil, err
}
shortcut := proto.Shortcut{
ID: uuid.New().String(),
Keys: keys,
}
store.Shortcuts = append(store.Shortcuts, shortcut)
if err := saveShortcuts(store); err != nil {
return nil, err
}
return &shortcut, nil
}
func deleteShortcut(id string) error {
shortcutMutex.Lock()
defer shortcutMutex.Unlock()
store, err := loadShortcuts()
if err != nil {
return err
}
found := false
newShortcuts := make([]proto.Shortcut, 0, len(store.Shortcuts))
for _, shortcut := range store.Shortcuts {
if shortcut.ID == id {
found = true
continue
}
newShortcuts = append(newShortcuts, shortcut)
}
if !found {
return errors.New("shortcut not found")
}
store.Shortcuts = newShortcuts
return saveShortcuts(store)
}

View File

@@ -1,20 +1,28 @@
package network
import (
"NanoKVM-Server/proto"
"crypto/subtle"
"fmt"
"os"
"os/exec"
"strings"
"time"
"NanoKVM-Server/proto"
"github.com/gin-gonic/gin"
log "github.com/sirupsen/logrus"
)
const (
WiFiExistFile = "/etc/kvm/wifi_exist"
WiFiSSID = "/etc/kvm/wifi.ssid"
WiFiPasswd = "/etc/kvm/wifi.pass"
WiFiConnect = "/kvmapp/kvm/wifi_try_connect"
WiFiStateFile = "/kvmapp/kvm/wifi_state"
WiFiExistFile = "/etc/kvm/wifi_exist"
WiFiApModeFile = "/tmp/wifiap"
WiFiSSID = "/etc/kvm/wifi.ssid"
WiFiPasswd = "/etc/kvm/wifi.pass"
WiFiConnect = "/kvmapp/kvm/wifi_try_connect"
WiFiStateFile = "/kvmapp/kvm/wifi_state"
WiFiScript = "/etc/init.d/S30wifi"
WiFiApPassFile = "/kvmapp/kvm/ap.pass"
)
func (s *Service) GetWifi(c *gin.Context) {
@@ -22,25 +30,84 @@ func (s *Service) GetWifi(c *gin.Context) {
data := &proto.GetWifiRsp{}
_, err := os.Stat(WiFiExistFile)
if err != nil {
data.Supported = isSupported()
if !data.Supported {
rsp.OkRspWithData(c, data)
return
}
data.Supported = true
data.ApMode = isAPMode()
content, err := os.ReadFile(WiFiStateFile)
if err != nil {
data.Connected = isConnected()
if !data.Connected {
rsp.OkRspWithData(c, data)
return
}
state := strings.ReplaceAll(string(content), "\n", "")
data.Connected = state == "1"
data.Ssid = getWiFiSsid()
if data.Ssid == "" {
data.Ssid = "Wi-Fi"
}
rsp.OkRspWithData(c, data)
log.Debugf("get wifi state: %s", state)
log.Debugf("get wifi state: %+v", data)
}
func (s *Service) ConnectWifiNoAuth(c *gin.Context) {
var req proto.ConnectWifiReq
var rsp proto.Response
// Check Wi-Fi configuration mode
if !isSupported() || !isAPMode() {
time.Sleep(2 * time.Second)
rsp.ErrRsp(c, -1, "invalid mode")
return
}
// Verify AP Password
apKey := c.GetHeader("X-AP-Key")
expectedPass := getApPassword()
if apKey == "" || expectedPass == "" || subtle.ConstantTimeCompare([]byte(apKey), []byte(expectedPass)) != 1 {
time.Sleep(2 * time.Second)
rsp.ErrRsp(c, -4, "unauthorized")
return
}
if err := proto.ParseFormRequest(c, &req); err != nil {
time.Sleep(1 * time.Second)
rsp.ErrRsp(c, -2, "invalid parameters")
return
}
if err := connect(req.Ssid, req.Password); err != nil {
rsp.ErrRsp(c, -3, "failed to connect wifi")
return
}
time.Sleep(5 * time.Second)
rsp.OkRsp(c)
log.Debugf("set wifi ap mode successfully")
}
func (s *Service) VerifyApLogin(c *gin.Context) {
var rsp proto.Response
if !isSupported() || !isAPMode() {
time.Sleep(2 * time.Second)
rsp.ErrRsp(c, -1, "invalid mode")
return
}
apKey := c.GetHeader("X-AP-Key")
expectedPass := getApPassword()
if apKey == "" || expectedPass == "" || subtle.ConstantTimeCompare([]byte(apKey), []byte(expectedPass)) != 1 {
time.Sleep(2 * time.Second)
rsp.ErrRsp(c, -4, "unauthorized")
return
}
rsp.OkRsp(c)
}
func (s *Service) ConnectWifi(c *gin.Context) {
@@ -52,24 +119,105 @@ func (s *Service) ConnectWifi(c *gin.Context) {
return
}
if err := os.WriteFile(WiFiSSID, []byte(req.Ssid), 0o644); err != nil {
log.Errorf("failed to save wifi ssid: %s", err)
rsp.ErrRsp(c, -2, "failed to save wifi")
if err := connect(req.Ssid, req.Password); err != nil {
rsp.ErrRsp(c, -2, "failed to connect wifi")
return
}
if err := os.WriteFile(WiFiPasswd, []byte(req.Password), 0o644); err != nil {
log.Errorf("failed to save wifi password: %s", err)
rsp.ErrRsp(c, -3, "failed to save wifi")
// Waiting for Wi-Fi connection
maxTries := 7
if isConnected() {
maxTries = 3
time.Sleep(12 * time.Second)
}
for range maxTries {
time.Sleep(2 * time.Second)
if isConnected() && getWiFiSsid() != "" {
rsp.OkRsp(c)
log.Debugf("set wifi successfully: %s", req.Ssid)
return
}
}
rsp.ErrRsp(c, -3, "failed to get wifi status")
}
func (s *Service) DisconnectWifi(c *gin.Context) {
var rsp proto.Response
command := fmt.Sprintf("%s stop", WiFiScript)
cmd := exec.Command("sh", "-c", command)
output, err := cmd.CombinedOutput()
if err != nil {
log.Errorf("failed to disconnect wifi: %s", output)
rsp.ErrRsp(c, -1, "failed to stop wifi")
return
}
time.Sleep(5 * time.Second)
_ = os.Remove(WiFiSSID)
_ = os.Remove(WiFiPasswd)
rsp.OkRsp(c)
log.Debugf("stop wifi successfully")
}
func connect(ssid string, password string) error {
if err := os.WriteFile(WiFiSSID, []byte(ssid), 0o644); err != nil {
log.Errorf("failed to save wifi ssid: %s", err)
return err
}
if err := os.WriteFile(WiFiPasswd, []byte(password), 0o644); err != nil {
log.Errorf("failed to save wifi password: %s", err)
return err
}
if err := os.WriteFile(WiFiConnect, nil, 0o644); err != nil {
log.Errorf("failed to connect wifi: %s", err)
rsp.ErrRsp(c, -4, "failed to connect wifi")
return
return err
}
rsp.OkRsp(c)
log.Debugf("set wifi successfully: %s", req.Ssid)
return nil
}
func isSupported() bool {
_, err := os.Stat(WiFiExistFile)
return err == nil
}
func isAPMode() bool {
_, err := os.Stat(WiFiApModeFile)
return err == nil
}
func isConnected() bool {
content, err := os.ReadFile(WiFiStateFile)
if err != nil {
return false
}
state := strings.ReplaceAll(string(content), "\n", "")
return state == "1"
}
func getWiFiSsid() string {
ssidByte, err := os.ReadFile(WiFiSSID)
if err != nil {
return ""
}
return strings.ReplaceAll(string(ssidByte), "\n", "")
}
func getApPassword() string {
passByte, err := os.ReadFile(WiFiApPassFile)
if err != nil {
return ""
}
return strings.ReplaceAll(string(passByte), "\n", "")
}

View File

@@ -0,0 +1,316 @@
package picoclaw
import (
"bytes"
"encoding/json"
"math"
"strings"
"time"
"NanoKVM-Server/service/hid"
"github.com/gin-gonic/gin"
)
const (
defaultClickHold = 40 * time.Millisecond
defaultKeyDelay = 30 * time.Millisecond
defaultDragSteps = 10
defaultScrollStep = 20 * time.Millisecond
)
func (s *Service) Actions(c *gin.Context) {
sessionID, sessionErr := s.requireSessionID(c)
if sessionErr != nil {
writePicoclawError(c, sessionErr)
return
}
releaseAfter, lockErr := s.lock.AcquireTemporary(sessionID)
if lockErr != nil {
writePicoclawError(c, lockErr)
return
}
if releaseAfter {
defer s.lock.Release(sessionID)
}
actions, actionErr := normalizeActions(c)
if actionErr != nil {
writePicoclawError(c, actionErr)
return
}
result, execErr := s.executeActions(sessionID, actions)
if execErr != nil {
writePicoclawError(c, execErr)
return
}
writeSuccess(c, result)
}
func normalizeActions(c *gin.Context) ([]Action, *PicoclawError) {
body := bytes.NewBuffer(nil)
if _, err := body.ReadFrom(c.Request.Body); err != nil {
return nil, newPicoclawError(CodeInvalidAction, "failed to read action payload")
}
raw := body.Bytes()
if len(raw) == 0 {
return nil, newPicoclawError(CodeInvalidAction, "empty action payload")
}
var batch ActionBatch
if err := json.Unmarshal(raw, &batch); err == nil && len(batch.Actions) > 0 {
return batch.Actions, nil
}
var action Action
if err := json.Unmarshal(raw, &action); err != nil || action.Action == "" {
return nil, newPicoclawError(CodeInvalidAction, "invalid action payload")
}
return []Action{action}, nil
}
func (s *Service) executeActions(sessionID string, actions []Action) (result ActionResult, err *PicoclawError) {
startedAt := time.Now()
if len(actions) == 0 {
return ActionResult{}, newPicoclawError(CodeInvalidAction, "empty actions")
}
defer func() {
if err != nil {
s.releaseAllHIDState()
}
}()
totalWrites := 0
for idx, action := range actions {
if lockErr := s.lock.Ensure(sessionID); lockErr != nil {
lockErr.Index = &idx
return ActionResult{}, lockErr
}
writes, execErr := s.executeAction(action)
if execErr != nil {
execErr.Index = &idx
return ActionResult{}, execErr
}
totalWrites += writes
}
result = ActionResult{
Action: actions[0].Action,
DurationMs: time.Since(startedAt).Milliseconds(),
HIDWrites: totalWrites,
ExecutedActions: len(actions),
}
if len(actions) > 1 {
result.Action = "batch"
}
return result, nil
}
func (s *Service) executeAction(action Action) (int, *PicoclawError) {
switch strings.ToLower(strings.TrimSpace(action.Action)) {
case "click":
x, y, err := normalizedPoint(action.X, action.Y)
if err != nil {
return 0, err
}
button, err := mouseButton(action.Button)
if err != nil {
return 0, err
}
writes := 0
writes += s.sendMouseMoveWithButton(x, y, 0x00, 0)
writes += s.sendMousePress(x, y, button)
time.Sleep(defaultClickHold)
writes += s.sendMouseRelease(x, y)
return writes, nil
case "move":
x, y, err := normalizedPoint(action.X, action.Y)
if err != nil {
return 0, err
}
return s.sendMouseMoveWithButton(x, y, 0x00, 0), nil
case "wait":
if action.DurationMs < 0 {
return 0, newPicoclawError(CodeInvalidAction, "wait duration must be >= 0")
}
time.Sleep(time.Duration(action.DurationMs) * time.Millisecond)
return 0, nil
case "drag":
fromX, fromY, err := normalizedNestedPoint(action.From)
if err != nil {
return 0, err
}
toX, toY, err := normalizedNestedPoint(action.To)
if err != nil {
return 0, err
}
button, err := mouseButton(action.Button)
if err != nil {
return 0, err
}
writes := 0
writes += s.sendMouseMoveWithButton(fromX, fromY, 0x00, 0)
writes += s.sendMousePress(fromX, fromY, button)
for step := 1; step <= defaultDragSteps; step++ {
ratio := float64(step) / float64(defaultDragSteps)
x := fromX + (toX-fromX)*ratio
y := fromY + (toY-fromY)*ratio
writes += s.sendMouseMoveWithButton(x, y, button, 0)
}
writes += s.sendMouseRelease(toX, toY)
return writes, nil
case "scroll":
x, y := 0.5, 0.5
if action.X != nil || action.Y != nil {
var err *PicoclawError
x, y, err = normalizedPoint(action.X, action.Y)
if err != nil {
return 0, err
}
}
amount := action.Amount
if amount == 0 {
amount = 1
}
if amount < 0 {
return 0, newPicoclawError(CodeInvalidAction, "scroll amount must be > 0")
}
wheel := 1
switch strings.ToLower(strings.TrimSpace(action.Direction)) {
case "", "up":
wheel = 1
case "down":
wheel = -1
default:
return 0, newPicoclawError(CodeInvalidAction, "invalid scroll direction")
}
writes := 0
for range amount {
writes += s.sendMouseMoveWithButton(x, y, 0x00, wheel)
writes += s.sendMouseMoveWithButton(x, y, 0x00, 0)
time.Sleep(defaultScrollStep)
}
return writes, nil
case "type":
if action.Text == "" {
return 0, newPicoclawError(CodeInvalidAction, "type requires text")
}
charMap := hid.GetCharMap("")
writes := 0
for _, char := range action.Text {
key, ok := charMap[char]
if !ok {
return 0, newPicoclawError(CodeInvalidAction, "unsupported character in type action")
}
writes += s.sendKeyboardReport([]byte{byte(key.Modifiers), 0x00, byte(key.Code), 0x00, 0x00, 0x00, 0x00, 0x00})
writes += s.sendKeyboardReport([]byte{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00})
time.Sleep(defaultKeyDelay)
}
return writes, nil
case "hotkey":
report, err := buildHotkeyReport([]string(action.Keys))
if err != nil {
return 0, err
}
writes := 0
writes += s.sendKeyboardReport(report)
time.Sleep(defaultClickHold)
writes += s.sendKeyboardReport([]byte{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00})
return writes, nil
}
return 0, newPicoclawError(CodeInvalidAction, "unknown or invalid action")
}
func toAbsoluteHidCoord(normalized float64) uint16 {
if normalized < 0 {
normalized = 0
}
if normalized > 1 {
normalized = 1
}
return uint16(math.Floor(0x7FFF*normalized)) + 1
}
func (s *Service) sendMousePress(x float64, y float64, button byte) int {
return s.sendMouseMoveWithButton(x, y, button, 0)
}
func (s *Service) sendMouseRelease(x float64, y float64) int {
return s.sendMouseMoveWithButton(x, y, 0x00, 0)
}
func (s *Service) sendMouseMoveWithButton(x float64, y float64, buttons byte, wheel int) int {
absoluteX := toAbsoluteHidCoord(x)
absoluteY := toAbsoluteHidCoord(y)
report := []byte{
buttons,
byte(absoluteX & 0xff),
byte(absoluteX >> 8),
byte(absoluteY & 0xff),
byte(absoluteY >> 8),
byte(int8(wheel)),
}
s.hid.WriteHid2(report)
return 1
}
func (s *Service) sendKeyboardReport(report []byte) int {
s.hid.WriteHid0(report)
return 1
}
func normalizedPoint(x *float64, y *float64) (float64, float64, *PicoclawError) {
if x == nil || y == nil {
return 0, 0, newPicoclawError(CodeInvalidAction, "action requires x and y")
}
if *x < 0 || *x > 1 || *y < 0 || *y > 1 {
return 0, 0, newPicoclawError(CodeInvalidAction, "coordinates must be within [0,1]")
}
return *x, *y, nil
}
func normalizedNestedPoint(point *Point) (float64, float64, *PicoclawError) {
if point == nil {
return 0, 0, newPicoclawError(CodeInvalidAction, "action requires point coordinates")
}
return normalizedPoint(point.X, point.Y)
}
func mouseButton(button string) (byte, *PicoclawError) {
switch strings.ToLower(strings.TrimSpace(button)) {
case "", "left":
return 1 << 0, nil
case "right":
return 1 << 1, nil
case "middle":
return 1 << 2, nil
case "back":
return 1 << 3, nil
case "forward":
return 1 << 4, nil
default:
return 0, newPicoclawError(CodeInvalidAction, "invalid mouse button")
}
}

View File

@@ -0,0 +1,147 @@
package picoclaw
import (
"bufio"
"fmt"
"os"
"path/filepath"
"strings"
"github.com/gin-gonic/gin"
)
const (
agentProfileDefault = "default"
agentProfileKVM = "kvm"
agentProfileFile = "AGENT.md"
)
type AgentProfileUpdateRequest struct {
Profile string `json:"profile"`
}
func resolveAgentProfileSourcePath(profile string) (string, error) {
fileName := agentProfileFile
switch profile {
case agentProfileDefault:
fileName = "AGENT.md"
case agentProfileKVM:
fileName = "AGENT_KVM.md"
default:
return "", fmt.Errorf("unsupported agent profile: %s", profile)
}
candidates := make([]string, 0, 4)
if wd, err := os.Getwd(); err == nil {
candidates = append(candidates,
filepath.Join(wd, "picoclaw", fileName),
filepath.Join(wd, "docs", fileName),
)
}
candidates = append(candidates,
filepath.Join("/kvmapp", "picoclaw", fileName),
filepath.Join(filepath.Dir(os.Args[0]), "..", "picoclaw", fileName),
)
for _, candidate := range candidates {
if _, err := os.Stat(candidate); err == nil {
return candidate, nil
}
}
return "", fmt.Errorf("agent profile source not found: %s", fileName)
}
func detectPicoclawAgentProfile() string {
workspacePath, err := resolvePicoclawWorkspacePath()
if err != nil {
return agentProfileKVM
}
targetPath := filepath.Join(workspacePath, agentProfileFile)
targetContent, err := os.ReadFile(targetPath)
if err != nil {
return agentProfileKVM
}
switch parseAgentProfileName(string(targetContent)) {
case "pico":
return agentProfileDefault
case "pico-kvm":
return agentProfileKVM
default:
return agentProfileKVM
}
}
func parseAgentProfileName(content string) string {
scanner := bufio.NewScanner(strings.NewReader(content))
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if !strings.HasPrefix(line, "name:") {
continue
}
return strings.TrimSpace(strings.TrimPrefix(line, "name:"))
}
return ""
}
func applyPicoclawAgentProfile(profile string) error {
sourcePath, err := resolveAgentProfileSourcePath(profile)
if err != nil {
return err
}
workspacePath, err := resolvePicoclawWorkspacePath()
if err != nil {
return err
}
if err := os.MkdirAll(workspacePath, 0o755); err != nil {
return fmt.Errorf("failed to create picoclaw workspace: %w", err)
}
sourceContent, err := os.ReadFile(sourcePath)
if err != nil {
return fmt.Errorf("failed to read agent profile source: %w", err)
}
targetPath := filepath.Join(workspacePath, agentProfileFile)
if err := os.WriteFile(targetPath, sourceContent, 0o644); err != nil {
return fmt.Errorf("failed to write picoclaw AGENT.md: %w", err)
}
return nil
}
func withAgentProfile(status RuntimeStatus) RuntimeStatus {
status.AgentProfile = detectPicoclawAgentProfile()
return status
}
func (s *Service) UpdateAgentProfile(c *gin.Context) {
var req AgentProfileUpdateRequest
if err := c.ShouldBindJSON(&req); err != nil {
writePicoclawError(c, newPicoclawError(CodeInvalidAction, "invalid agent profile payload"))
return
}
profile := strings.TrimSpace(req.Profile)
if profile != agentProfileDefault && profile != agentProfileKVM {
writePicoclawError(c, newPicoclawError(CodeInvalidAction, "invalid agent profile"))
return
}
if err := applyPicoclawAgentProfile(profile); err != nil {
writePicoclawError(c, newPicoclawError(CodeRuntimeUnavailable, err.Error()))
return
}
_ = s.syncConfigFromPicoclaw()
_ = s.ensureRuntimeReady()
writeSuccess(c, gin.H{
"profile": profile,
"status": withAgentProfile(s.runtime.Get()),
})
}

View File

@@ -0,0 +1,179 @@
package picoclaw
import (
"fmt"
"os"
"strings"
"time"
)
const (
defaultPicoclawGatewayHost = "127.0.0.1"
defaultPicoclawGatewayPort = 18790
picoclawGatewayPath = "/pico/ws"
)
func defaultConfig() Config {
cfg := Config{
GatewayURL: "ws://127.0.0.1:18790/pico/ws",
ConnectTimeoutMs: 10000,
ReadTimeoutMs: 60000,
WriteTimeoutMs: 10000,
PingIntervalMs: 30000,
MaxMessageBytes: 1024 * 1024,
AllowTokenQuery: false,
}
settings, err := loadPicoclawGatewaySettings()
if err != nil {
return cfg
}
cfg.GatewayURL = settings.GatewayURL
cfg.Token = settings.Token
cfg.AllowTokenQuery = settings.AllowTokenQuery
if settings.PingIntervalMs > 0 {
cfg.PingIntervalMs = settings.PingIntervalMs
}
if settings.ReadTimeoutMs > 0 {
cfg.ReadTimeoutMs = settings.ReadTimeoutMs
}
return cfg
}
func (s *Service) syncConfigFromPicoclaw() *PicoclawError {
installed, installedKnown := picoclawInstalledState()
if patchErr := preparePicoclawConfigForRead(); patchErr != nil {
s.runtime.Update(func(status *RuntimeStatus) {
status.Ready = false
status.Status = "config_error"
status.ConfigError = patchErr.Error()
status.LastError = patchErr.Error()
if installedKnown {
status.Installed = installed
}
status.CheckedAt = time.Now()
})
return newPicoclawError(CodeRuntimeUnavailable, patchErr.Error())
}
settings, err := loadPicoclawGatewaySettings()
if err != nil {
s.runtime.Update(func(status *RuntimeStatus) {
status.Ready = false
status.Status = "config_error"
status.ConfigError = err.Error()
status.LastError = err.Error()
if installedKnown {
status.Installed = installed
}
status.CheckedAt = time.Now()
})
return newPicoclawError(CodeRuntimeUnavailable, err.Error())
}
cfg := s.config.Get()
cfg.GatewayURL = settings.GatewayURL
cfg.Token = settings.Token
cfg.AllowTokenQuery = settings.AllowTokenQuery
if settings.PingIntervalMs > 0 {
cfg.PingIntervalMs = settings.PingIntervalMs
}
if settings.ReadTimeoutMs > 0 {
cfg.ReadTimeoutMs = settings.ReadTimeoutMs
}
s.config.Set(cfg)
s.runtime.Update(func(status *RuntimeStatus) {
status.Installed = true
status.ModelConfigured = settings.ModelConfigured
if settings.ModelConfigured {
status.ModelName = settings.ModelName
} else {
status.ModelName = settings.TargetModelName
}
status.ConfigError = ""
if status.Status == "config_error" && status.Ready {
status.Status = "ready"
}
status.CheckedAt = time.Now()
})
return nil
}
type picoclawGatewaySettings struct {
GatewayURL string
Token string
AllowTokenQuery bool
PingIntervalMs int
ReadTimeoutMs int
ModelConfigured bool
ModelName string
TargetModelName string
}
func loadPicoclawGatewaySettings() (picoclawGatewaySettings, error) {
doc, err := loadPicoclawConfigDocument()
if err != nil {
return picoclawGatewaySettings{}, err
}
if err := ensurePicoclawPicoChannelEnabled(doc); err != nil {
return picoclawGatewaySettings{}, err
}
cfg := doc.config
host := cfg.Gateway.Host
if host == "" || host == "0.0.0.0" {
host = defaultPicoclawGatewayHost
}
port := cfg.Gateway.Port
if port <= 0 {
port = defaultPicoclawGatewayPort
}
settings := picoclawGatewaySettings{
GatewayURL: fmt.Sprintf("ws://%s:%d%s", host, port, picoclawGatewayPath),
Token: doc.resolvedGatewayToken(),
AllowTokenQuery: cfg.Channels.Pico.AllowTokenQuery,
}
settings.TargetModelName = resolvePicoclawTargetModelName(cfg)
if isPicoclawModelConfigured(cfg, doc.security, settings.TargetModelName) {
settings.ModelConfigured = true
settings.ModelName = settings.TargetModelName
}
if cfg.Channels.Pico.PingInterval > 0 {
settings.PingIntervalMs = cfg.Channels.Pico.PingInterval * 1000
}
if cfg.Channels.Pico.ReadTimeout > 0 {
settings.ReadTimeoutMs = cfg.Channels.Pico.ReadTimeout * 1000
}
return settings, nil
}
func resolvePicoclawTargetModelName(cfg picoclawConfigFile) string {
return strings.TrimSpace(cfg.Agents.Defaults.ModelName)
}
func preparePicoclawConfigForRead() error {
configPath, err := resolvePicoclawConfigPath()
if err != nil {
return err
}
if _, err := os.Stat(configPath); err != nil {
if os.IsNotExist(err) {
return nil
}
return fmt.Errorf("failed to stat picoclaw config: %w", err)
}
return nil
}

View File

@@ -0,0 +1,256 @@
package picoclaw
import (
"bytes"
"encoding/json"
"fmt"
"os"
"os/user"
"path/filepath"
"strings"
"gopkg.in/yaml.v3"
)
const (
picoclawPIDFileName = ".picoclaw.pid"
picoclawGatewayTokenPrefix = "pico-"
)
type picoclawConfigFile struct {
Agents struct {
Defaults struct {
ModelName string `json:"model_name"`
Model string `json:"model,omitempty"`
Workspace string `json:"workspace,omitempty"`
} `json:"defaults"`
} `json:"agents"`
Gateway struct {
Host string `json:"host"`
Port int `json:"port"`
} `json:"gateway"`
ModelList []struct {
ModelName string `json:"model_name"`
Model string `json:"model"`
APIBase string `json:"api_base"`
APIKey string `json:"api_key"`
APIKeys []string `json:"api_keys,omitempty"`
} `json:"model_list"`
Channels struct {
Pico struct {
Enabled bool `json:"enabled"`
Token string `json:"token"`
AllowTokenQuery bool `json:"allow_token_query"`
PingInterval int `json:"ping_interval"`
ReadTimeout int `json:"read_timeout"`
} `json:"pico"`
} `json:"channels"`
}
type picoclawConfigDocument struct {
configPath string
securityPath string
config picoclawConfigFile
raw map[string]any
security picoclawSecurityConfig
}
func loadPicoclawConfigDocument() (*picoclawConfigDocument, error) {
configPath, err := resolvePicoclawConfigPath()
if err != nil {
return nil, err
}
data, err := os.ReadFile(configPath)
if err != nil {
return nil, fmt.Errorf("failed to read picoclaw config: %w", err)
}
var cfg picoclawConfigFile
if err := json.Unmarshal(data, &cfg); err != nil {
return nil, fmt.Errorf("failed to parse picoclaw config: %w", err)
}
var raw map[string]any
if err := json.Unmarshal(data, &raw); err != nil {
return nil, fmt.Errorf("failed to parse picoclaw config for update: %w", err)
}
securityPath := resolvePicoclawSecurityPath(configPath)
security, err := loadPicoclawSecurityConfig(securityPath)
if err != nil {
return nil, err
}
return &picoclawConfigDocument{
configPath: configPath,
securityPath: securityPath,
config: cfg,
raw: raw,
security: security,
}, nil
}
func resolvePicoclawConfigPath() (string, error) {
home := os.Getenv("PICOCLAW_HOME")
if home == "" {
currentUser, err := user.Current()
if err != nil {
return "", fmt.Errorf("failed to resolve PICOCLAW_HOME: %w", err)
}
home = filepath.Join(currentUser.HomeDir, ".picoclaw")
}
return filepath.Join(home, "config.json"), nil
}
func expandPicoclawPath(path string) string {
path = strings.TrimSpace(path)
if path == "" {
return ""
}
if strings.HasPrefix(path, "~/") || path == "~" {
if home, err := os.UserHomeDir(); err == nil {
if path == "~" {
return home
}
return filepath.Join(home, strings.TrimPrefix(path, "~/"))
}
}
return path
}
func resolvePicoclawSecurityPath(configPath string) string {
return filepath.Join(filepath.Dir(configPath), ".security.yml")
}
func (d *picoclawConfigDocument) saveConfig() error {
updated, err := json.MarshalIndent(d.raw, "", " ")
if err != nil {
return fmt.Errorf("failed to encode updated picoclaw config: %w", err)
}
if err := os.WriteFile(d.configPath, updated, 0o600); err != nil {
return fmt.Errorf("failed to write picoclaw config: %w", err)
}
return nil
}
func (d *picoclawConfigDocument) saveSecurity() error {
var buf bytes.Buffer
encoder := yaml.NewEncoder(&buf)
encoder.SetIndent(2)
if err := encoder.Encode(d.security); err != nil {
return fmt.Errorf("failed to encode picoclaw security config: %w", err)
}
if err := encoder.Close(); err != nil {
return fmt.Errorf("failed to finalize picoclaw security config: %w", err)
}
if err := os.WriteFile(d.securityPath, buf.Bytes(), 0o600); err != nil {
return fmt.Errorf("failed to write picoclaw security config: %w", err)
}
return nil
}
type picoclawSecurityConfig struct {
ModelList map[string]picoclawModelSecurityEntry `yaml:"model_list,omitempty"`
Channels picoclawSecurityChannels `yaml:"channels,omitempty"`
}
type picoclawModelSecurityEntry struct {
APIKeys []string `yaml:"api_keys,omitempty"`
}
type picoclawSecurityChannels struct {
Pico *picoclawPicoSecurity `yaml:"pico,omitempty"`
}
type picoclawPicoSecurity struct {
Token string `yaml:"token,omitempty"`
}
type picoclawPIDFile struct {
Token string `json:"token"`
}
func (d *picoclawConfigDocument) resolvedPicoToken() string {
if d == nil {
return ""
}
if d.security.Channels.Pico != nil {
if token := d.security.Channels.Pico.Token; token != "" {
return token
}
}
return d.config.Channels.Pico.Token
}
func (d *picoclawConfigDocument) resolvedGatewayToken() string {
baseToken := strings.TrimSpace(d.resolvedPicoToken())
if baseToken == "" {
return ""
}
runtimeToken, err := d.resolvedPIDToken()
if err != nil {
return baseToken
}
return composePicoclawGatewayToken(baseToken, runtimeToken)
}
func (d *picoclawConfigDocument) resolvedPIDToken() (string, error) {
if d == nil || d.configPath == "" {
return "", nil
}
pidPath := filepath.Join(filepath.Dir(d.configPath), picoclawPIDFileName)
data, err := os.ReadFile(pidPath)
if err != nil {
if os.IsNotExist(err) {
return "", nil
}
return "", fmt.Errorf("failed to read picoclaw pid file: %w", err)
}
var pidFile picoclawPIDFile
if err := json.Unmarshal(data, &pidFile); err != nil {
return "", fmt.Errorf("failed to parse picoclaw pid file: %w", err)
}
return strings.TrimSpace(pidFile.Token), nil
}
func composePicoclawGatewayToken(baseToken, runtimeToken string) string {
baseToken = strings.TrimSpace(baseToken)
runtimeToken = strings.TrimSpace(runtimeToken)
switch {
case baseToken == "":
return ""
case strings.HasPrefix(baseToken, picoclawGatewayTokenPrefix):
return baseToken
case runtimeToken == "":
return baseToken
default:
return picoclawGatewayTokenPrefix + runtimeToken + baseToken
}
}
func loadPicoclawSecurityConfig(securityPath string) (picoclawSecurityConfig, error) {
data, err := os.ReadFile(securityPath)
if err != nil {
if os.IsNotExist(err) {
return picoclawSecurityConfig{}, nil
}
return picoclawSecurityConfig{}, fmt.Errorf("failed to read picoclaw security config: %w", err)
}
var sec picoclawSecurityConfig
if err := yaml.Unmarshal(data, &sec); err != nil {
return picoclawSecurityConfig{}, fmt.Errorf("failed to parse picoclaw security config: %w", err)
}
return sec, nil
}

View File

@@ -0,0 +1,71 @@
package picoclaw
import (
"net/http"
"github.com/gin-gonic/gin"
)
const (
CodePicoclawLockHeld = "AI_LOCK_HELD"
CodeScreenshotFailed = "SCREENSHOT_FAILED"
CodeScreenshotNoSignal = "SCREENSHOT_NO_SIGNAL"
CodeHIDWriteFailed = "HID_WRITE_FAILED"
CodeInvalidAction = "INVALID_ACTION"
CodeRuntimeUnavailable = "RUNTIME_UNAVAILABLE"
CodeRuntimeStartFailed = "RUNTIME_START_FAILED"
CodeSessionIDMissing = "SESSION_ID_MISSING"
CodeSessionIDInvalid = "SESSION_ID_INVALID"
)
type PicoclawError struct {
StatusCode int
Code string
Message string
SessionID string
Index *int
}
func (e *PicoclawError) Error() string {
return e.Code + ": " + e.Message
}
func newPicoclawError(code string, message string) *PicoclawError {
return &PicoclawError{
StatusCode: http.StatusOK,
Code: code,
Message: message,
}
}
func writeSuccess(c *gin.Context, data interface{}) {
c.JSON(http.StatusOK, gin.H{
"code": 0,
"msg": "success",
"data": data,
})
}
func writePicoclawError(c *gin.Context, err *PicoclawError) {
if err == nil {
return
}
payload := gin.H{
"code": err.Code,
"message": err.Message,
}
if err.SessionID != "" {
payload["session_id"] = err.SessionID
}
if err.Index != nil {
payload["index"] = *err.Index
}
statusCode := err.StatusCode
if statusCode == 0 {
statusCode = http.StatusOK
}
c.JSON(statusCode, payload)
}

View File

@@ -0,0 +1,107 @@
package picoclaw
import (
"fmt"
"net"
"net/http"
"net/url"
"time"
"github.com/gorilla/websocket"
)
func (s *Service) connectGateway(sessionID string) (*websocket.Conn, *PicoclawError) {
cfg := s.config.Get()
gatewayURL, err := buildGatewayURL(cfg, sessionID)
if err != nil {
s.runtime.Update(func(status *RuntimeStatus) {
status.Ready = false
status.Status = "config_error"
status.ConfigError = err.Error()
status.LastError = err.Error()
status.CheckedAt = time.Now()
status.CurrentSession = s.lock.Owner()
})
return nil, newPicoclawError(CodeRuntimeUnavailable, err.Error())
}
header := http.Header{}
if cfg.Token != "" {
header.Set("Authorization", fmt.Sprintf("Bearer %s", cfg.Token))
}
dialer := websocket.Dialer{
HandshakeTimeout: time.Duration(cfg.ConnectTimeoutMs) * time.Millisecond,
NetDialContext: (&net.Dialer{
Timeout: time.Duration(cfg.ConnectTimeoutMs) * time.Millisecond,
}).DialContext,
}
upstream, response, err := dialer.Dial(gatewayURL, header)
if err != nil {
s.runtime.Update(func(status *RuntimeStatus) {
status.Ready = false
status.Status = "unavailable"
status.ConfigError = ""
status.LastError = err.Error()
status.CheckedAt = time.Now()
status.CurrentSession = s.lock.Owner()
if response != nil && response.StatusCode == http.StatusUnauthorized {
status.Status = "config_error"
status.ConfigError = "gateway authentication failed"
}
})
return nil, newPicoclawError(CodeRuntimeUnavailable, "gateway is unavailable")
}
s.runtime.Update(func(status *RuntimeStatus) {
status.Ready = true
status.Status = "ready"
status.ConfigError = ""
status.LastError = ""
status.CheckedAt = time.Now()
status.CurrentSession = s.lock.Owner()
})
return upstream, nil
}
func buildGatewayURL(cfg Config, sessionID string) (string, error) {
parsed, err := url.Parse(cfg.GatewayURL)
if err != nil {
return "", fmt.Errorf("invalid gateway url: %w", err)
}
if parsed.Scheme != "ws" && parsed.Scheme != "wss" {
return "", fmt.Errorf("invalid gateway url scheme: %s", parsed.Scheme)
}
if parsed.Host == "" {
return "", fmt.Errorf("invalid gateway url host")
}
query := parsed.Query()
query.Set("session_id", sessionID)
if cfg.Token != "" && cfg.AllowTokenQuery {
query.Set("token", cfg.Token)
}
parsed.RawQuery = query.Encode()
return parsed.String(), nil
}
func gatewayHostPort(parsed *url.URL) (string, error) {
if parsed == nil || parsed.Host == "" {
return "", fmt.Errorf("invalid gateway url host")
}
if _, _, err := net.SplitHostPort(parsed.Host); err == nil {
return parsed.Host, nil
}
switch parsed.Scheme {
case "ws":
return net.JoinHostPort(parsed.Host, "80"), nil
case "wss":
return net.JoinHostPort(parsed.Host, "443"), nil
default:
return "", fmt.Errorf("invalid gateway url scheme: %s", parsed.Scheme)
}
}

View File

@@ -0,0 +1,262 @@
package picoclaw
import (
"fmt"
"net/http"
"strings"
"sync"
"time"
"NanoKVM-Server/service/stream/mjpeg"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/gorilla/websocket"
log "github.com/sirupsen/logrus"
)
var gatewayUpgrader = websocket.Upgrader{
ReadBufferSize: 4096,
WriteBufferSize: 4096,
CheckOrigin: func(r *http.Request) bool {
return true
},
}
type relayResult struct {
Source string
CloseCode int
Reason string
}
func (s *Service) ConnectGateway(c *gin.Context) {
sessionID := strings.TrimSpace(c.Query("session_id"))
if sessionID == "" {
sessionID = uuid.NewString()
}
if lockErr := s.lock.Ensure(sessionID); lockErr != nil {
c.AbortWithStatusJSON(http.StatusConflict, gin.H{
"code": CodePicoclawLockHeld,
"message": lockErr.Message,
})
return
}
session, sessionErr := GetSessionManager().Register(sessionID, nil)
if sessionErr != nil {
s.lock.Release(sessionID)
c.AbortWithStatusJSON(http.StatusConflict, gin.H{
"code": CodePicoclawLockHeld,
"message": sessionErr.Message,
})
return
}
GetSessionManager().SetState(sessionID, SessionStateConnecting)
if runtimeErr := s.syncConfigFromPicoclaw(); runtimeErr != nil {
s.closeGatewaySession(session, CloseCodeRuntimeUnavailable, runtimeErr.Message)
return
}
upstream, gatewayErr := s.connectGateway(sessionID)
if gatewayErr != nil {
s.closeGatewaySession(session, CloseCodeRuntimeUnavailable, gatewayErr.Message)
return
}
downstream, err := gatewayUpgrader.Upgrade(c.Writer, c.Request, nil)
if err != nil {
log.Errorf("failed to upgrade gateway websocket: %s", err)
_ = upstream.Close()
ReleaseSession(sessionID)
GetSessionManager().SetState(sessionID, SessionStateClosed)
GetSessionManager().Remove(sessionID)
return
}
GetSessionManager().AttachUpstream(sessionID, upstream)
GetSessionManager().AttachDownstream(sessionID, downstream)
GetSessionManager().SetState(sessionID, SessionStateActive)
mjpeg.EnableLatestFrameCache()
var wg sync.WaitGroup
results := make(chan relayResult, 2)
cfg := s.config.Get()
s.configureRelayConn(downstream, cfg)
s.configureRelayConn(upstream, cfg)
wg.Add(4)
go s.runPingLoop("downstream", downstream, cfg, &wg)
go s.runPingLoop("upstream", upstream, cfg, &wg)
go s.proxyMessages("downstream", session, downstream, cfg, &wg, results)
go s.proxyMessages("upstream", session, upstream, cfg, &wg, results)
result := <-results
closeCode := result.CloseCode
if closeCode == 0 {
closeCode = websocket.CloseNormalClosure
}
if result.Source == "upstream" {
if closeCode == websocket.CloseNormalClosure {
closeCode = CloseCodeUpstreamClosed
}
s.closeGatewaySession(session, closeCode, result.Reason)
} else {
s.closeGatewaySession(session, closeCode, result.Reason)
}
wg.Wait()
}
func (s *Service) proxyMessages(source string, session *GatewaySession, src *websocket.Conn, cfg Config, wg *sync.WaitGroup, results chan<- relayResult) {
defer wg.Done()
for {
messageType, data, err := src.ReadMessage()
if err != nil {
results <- relayResult{
Source: source,
CloseCode: closeCodeFromError(source, err),
Reason: closeReasonFromError(err),
}
return
}
if source == "upstream" && messageType == websocket.BinaryMessage {
results <- relayResult{
Source: source,
CloseCode: CloseCodeUpstreamClosed,
Reason: "upstream sent unsupported binary message",
}
return
}
var writeErr error
switch source {
case "downstream":
writeErr = session.writeUpstreamMessage(cfg, messageType, data)
default:
writeErr = session.writeDownstreamMessage(cfg, messageType, data)
}
if writeErr != nil {
results <- relayResult{
Source: source,
CloseCode: closeCodeFromError(source, writeErr),
Reason: closeReasonFromError(writeErr),
}
return
}
}
}
func (s *Service) runPingLoop(name string, conn *websocket.Conn, cfg Config, wg *sync.WaitGroup) {
defer wg.Done()
ticker := time.NewTicker(time.Duration(cfg.PingIntervalMs) * time.Millisecond)
defer ticker.Stop()
for range ticker.C {
if err := conn.WriteControl(
websocket.PingMessage,
[]byte(name),
time.Now().Add(time.Duration(cfg.WriteTimeoutMs)*time.Millisecond),
); err != nil {
return
}
}
}
func (s *Service) configureRelayConn(conn *websocket.Conn, cfg Config) {
readTimeout := time.Duration(cfg.ReadTimeoutMs) * time.Millisecond
conn.SetReadLimit(int64(cfg.MaxMessageBytes))
_ = conn.SetReadDeadline(time.Now().Add(readTimeout))
conn.SetPongHandler(func(string) error {
return conn.SetReadDeadline(time.Now().Add(readTimeout))
})
}
func (s *Service) closeGatewaySession(session *GatewaySession, closeCode int, reason string) {
if session == nil {
return
}
session.closeOnce.Do(func() {
hadDownstream := session.Downstream != nil
mjpeg.DisableLatestFrameCache()
GetSessionManager().SetState(session.SessionID, SessionStateClosing)
if session.Upstream != nil {
writeGatewayClose(session.Upstream, websocket.CloseNormalClosure, "relay closing")
_ = session.Upstream.Close()
}
if session.Downstream != nil {
writeGatewayClose(session.Downstream, closeCode, reason)
_ = session.Downstream.Close()
}
if hadDownstream {
cleanupPicoclawMediaTempDir()
}
ReleaseSession(session.SessionID)
GetSessionManager().SetState(session.SessionID, SessionStateClosed)
GetSessionManager().Remove(session.SessionID)
status := s.runtime.Get()
status.CurrentSession = s.lock.Owner()
if closeCode == CloseCodeUpstreamClosed || closeCode == CloseCodeRuntimeUnavailable {
status.Ready = false
if closeCode == CloseCodeUpstreamClosed {
status.Status = "unavailable"
}
status.LastError = reason
status.CheckedAt = time.Now()
s.runtime.Set(status)
}
})
}
func writeGatewayClose(conn *websocket.Conn, code int, reason string) {
if conn == nil {
return
}
message := websocket.FormatCloseMessage(code, reason)
_ = conn.WriteControl(websocket.CloseMessage, message, time.Now().Add(2*time.Second))
}
func closeCodeFromError(source string, err error) int {
if closeErr, ok := err.(*websocket.CloseError); ok {
if closeErr.Code == websocket.CloseNormalClosure || closeErr.Code == websocket.CloseGoingAway {
return websocket.CloseNormalClosure
}
if closeErr.Code == websocket.CloseAbnormalClosure || closeErr.Code == websocket.CloseNoStatusReceived {
if source == "upstream" {
return CloseCodeUpstreamClosed
}
return websocket.CloseNormalClosure
}
return closeErr.Code
}
if source == "upstream" {
return CloseCodeUpstreamClosed
}
return websocket.CloseNormalClosure
}
func closeReasonFromError(err error) string {
if err == nil {
return "closed"
}
if closeErr, ok := err.(*websocket.CloseError); ok {
if closeErr.Text != "" {
return closeErr.Text
}
}
return fmt.Sprintf("relay closed: %v", err)
}

View File

@@ -0,0 +1,86 @@
package picoclaw
import (
"encoding/json"
"fmt"
"time"
"github.com/google/uuid"
"github.com/gorilla/websocket"
)
type picoGatewayMessage struct {
Type string `json:"type"`
ID string `json:"id,omitempty"`
SessionID string `json:"session_id,omitempty"`
Timestamp int64 `json:"timestamp,omitempty"`
Payload map[string]any `json:"payload,omitempty"`
}
func newPicoGatewayMessage(sessionID string, payload map[string]any) picoGatewayMessage {
return picoGatewayMessage{
Type: "message.send",
ID: uuid.NewString(),
SessionID: sessionID,
Timestamp: time.Now().UnixMilli(),
Payload: payload,
}
}
func newPicoGatewayObservationMessage(sessionID string, payload map[string]any) picoGatewayMessage {
return picoGatewayMessage{
Type: "observation",
ID: uuid.NewString(),
SessionID: sessionID,
Timestamp: time.Now().UnixMilli(),
Payload: payload,
}
}
func (session *GatewaySession) writeUpstreamJSON(cfg Config, value any) error {
raw, err := json.Marshal(value)
if err != nil {
return err
}
return session.writeUpstreamMessage(cfg, websocket.TextMessage, raw)
}
func (session *GatewaySession) writeDownstreamJSON(cfg Config, value any) error {
raw, err := json.Marshal(value)
if err != nil {
return err
}
return session.writeDownstreamMessage(cfg, websocket.TextMessage, raw)
}
func (session *GatewaySession) writeUpstreamMessage(cfg Config, messageType int, data []byte) error {
if session == nil || session.Upstream == nil {
return fmt.Errorf("upstream connection is unavailable")
}
session.upstreamWriteMu.Lock()
defer session.upstreamWriteMu.Unlock()
_ = session.Upstream.SetWriteDeadline(gatewayWriteDeadline(cfg))
return session.Upstream.WriteMessage(messageType, data)
}
func (session *GatewaySession) writeDownstreamMessage(cfg Config, messageType int, data []byte) error {
if session == nil || session.Downstream == nil {
return fmt.Errorf("downstream connection is unavailable")
}
session.downstreamWriteMu.Lock()
defer session.downstreamWriteMu.Unlock()
_ = session.Downstream.SetWriteDeadline(gatewayWriteDeadline(cfg))
return session.Downstream.WriteMessage(messageType, data)
}
func gatewayWriteDeadline(cfg Config) time.Time {
timeout := time.Duration(cfg.WriteTimeoutMs) * time.Millisecond
if timeout <= 0 {
timeout = 10 * time.Second
}
return time.Now().Add(timeout)
}

View File

@@ -0,0 +1,271 @@
package picoclaw
import (
"fmt"
"strings"
)
const (
modifierLeftCtrl = 1 << 0
modifierLeftShift = 1 << 1
modifierLeftAlt = 1 << 2
modifierLeftMeta = 1 << 3
modifierRightCtrl = 1 << 4
modifierRightShift = 1 << 5
modifierRightAlt = 1 << 6
modifierRightMeta = 1 << 7
)
var modifierMap = map[string]byte{
"ControlLeft": modifierLeftCtrl,
"ShiftLeft": modifierLeftShift,
"AltLeft": modifierLeftAlt,
"MetaLeft": modifierLeftMeta,
"ControlRight": modifierRightCtrl,
"ShiftRight": modifierRightShift,
"AltRight": modifierRightAlt,
"MetaRight": modifierRightMeta,
}
var keyAliases = map[string]string{
"CTRL": "ControlLeft",
"CONTROL": "ControlLeft",
"SHIFT": "ShiftLeft",
"ALT": "AltLeft",
"OPTION": "AltLeft",
"META": "MetaLeft",
"WIN": "MetaLeft",
"WINDOWS": "MetaLeft",
"COMMAND": "MetaLeft",
"CMD": "MetaLeft",
"SUPER": "MetaLeft",
"ESC": "Escape",
"RETURN": "Enter",
"DEL": "Delete",
"INS": "Insert",
"UP": "ArrowUp",
"DOWN": "ArrowDown",
"LEFT": "ArrowLeft",
"RIGHT": "ArrowRight",
"SPACEBAR": "Space",
"SPACE": "Space",
"TAB": "Tab",
"ENTER": "Enter",
"BACKSPACE": "Backspace",
"PGUP": "PageUp",
"PGDN": "PageDown",
}
var keycodeMap = map[string]byte{
"KeyA": 0x04, "KeyB": 0x05, "KeyC": 0x06, "KeyD": 0x07, "KeyE": 0x08,
"KeyF": 0x09, "KeyG": 0x0a, "KeyH": 0x0b, "KeyI": 0x0c, "KeyJ": 0x0d,
"KeyK": 0x0e, "KeyL": 0x0f, "KeyM": 0x10, "KeyN": 0x11, "KeyO": 0x12,
"KeyP": 0x13, "KeyQ": 0x14, "KeyR": 0x15, "KeyS": 0x16, "KeyT": 0x17,
"KeyU": 0x18, "KeyV": 0x19, "KeyW": 0x1a, "KeyX": 0x1b, "KeyY": 0x1c,
"KeyZ": 0x1d,
"Digit1": 0x1e, "Digit2": 0x1f, "Digit3": 0x20, "Digit4": 0x21, "Digit5": 0x22,
"Digit6": 0x23, "Digit7": 0x24, "Digit8": 0x25, "Digit9": 0x26, "Digit0": 0x27,
"Enter": 0x28,
"Escape": 0x29,
"Backspace": 0x2a,
"Tab": 0x2b,
"Space": 0x2c,
"Minus": 0x2d,
"Equal": 0x2e,
"BracketLeft": 0x2f,
"BracketRight": 0x30,
"Backslash": 0x31,
"Semicolon": 0x33,
"Quote": 0x34,
"Backquote": 0x35,
"Comma": 0x36,
"Period": 0x37,
"Slash": 0x38,
"CapsLock": 0x39,
"F1": 0x3a, "F2": 0x3b, "F3": 0x3c, "F4": 0x3d, "F5": 0x3e, "F6": 0x3f,
"F7": 0x40, "F8": 0x41, "F9": 0x42, "F10": 0x43, "F11": 0x44, "F12": 0x45,
"PrintScreen": 0x46,
"ScrollLock": 0x47,
"Pause": 0x48,
"Insert": 0x49,
"Home": 0x4a,
"PageUp": 0x4b,
"Delete": 0x4c,
"End": 0x4d,
"PageDown": 0x4e,
"ArrowRight": 0x4f,
"ArrowLeft": 0x50,
"ArrowDown": 0x51,
"ArrowUp": 0x52,
"NumLock": 0x53,
"NumpadDivide": 0x54,
"NumpadMultiply": 0x55,
"NumpadSubtract": 0x56,
"NumpadAdd": 0x57,
"NumpadEnter": 0x58,
"Numpad1": 0x59,
"Numpad2": 0x5a,
"Numpad3": 0x5b,
"Numpad4": 0x5c,
"Numpad5": 0x5d,
"Numpad6": 0x5e,
"Numpad7": 0x5f,
"Numpad8": 0x60,
"Numpad9": 0x61,
"Numpad0": 0x62,
"NumpadDecimal": 0x63,
"IntlBackslash": 0x64,
"ContextMenu": 0x65,
"Power": 0x66,
"NumpadEqual": 0x67,
"F13": 0x68, "F14": 0x69, "F15": 0x6a, "F16": 0x6b, "F17": 0x6c, "F18": 0x6d,
"F19": 0x6e, "F20": 0x6f, "F21": 0x70, "F22": 0x71, "F23": 0x72, "F24": 0x73,
"Execute": 0x74,
"Help": 0x75,
"Props": 0x76,
"Select": 0x77,
"Stop": 0x78,
"Again": 0x79,
"Undo": 0x7a,
"Cut": 0x7b,
"Copy": 0x7c,
"Paste": 0x7d,
"Find": 0x7e,
"AudioVolumeMute": 0x7f,
"AudioVolumeUp": 0x80,
"AudioVolumeDown": 0x81,
"VolumeMute": 0x7f,
"VolumeUp": 0x80,
"VolumeDown": 0x81,
"LockingCapsLock": 0x82,
"LockingNumLock": 0x83,
"LockingScrollLock": 0x84,
"NumpadComma": 0x85,
"NumpadEqual2": 0x86,
"IntlRo": 0x87,
"KanaMode": 0x88,
"IntlYen": 0x89,
"Convert": 0x8a,
"NonConvert": 0x8b,
"International6": 0x8c,
"International7": 0x8d,
"International8": 0x8e,
"International9": 0x8f,
"Lang1": 0x90, "Lang2": 0x91, "Lang3": 0x92, "Lang4": 0x93, "Lang5": 0x94,
"Lang6": 0x95, "Lang7": 0x96, "Lang8": 0x97, "Lang9": 0x98,
"IntlHash": 0x32,
"NumpadParenLeft": 0xb6,
"NumpadParenRight": 0xb7,
"NumpadBackspace": 0xbb,
"NumpadMemoryStore": 0xd0,
"NumpadMemoryRecall": 0xd1,
"NumpadMemoryClear": 0xd2,
"NumpadMemoryAdd": 0xd3,
"NumpadMemorySubtract": 0xd4,
"NumpadClear": 0xd8,
"NumpadClearEntry": 0xd9,
"BrowserSearch": 0xf0,
"BrowserHome": 0xf1,
"BrowserBack": 0xf2,
"BrowserForward": 0xf3,
"BrowserStop": 0xf4,
"BrowserRefresh": 0xf5,
"BrowserFavorites": 0xf6,
"MediaPlayPause": 0xe8,
"MediaStop": 0xe9,
"MediaTrackPrevious": 0xea,
"MediaTrackNext": 0xeb,
"Eject": 0xec,
"MediaSelect": 0xed,
"LaunchMail": 0xee,
"LaunchApp1": 0xef,
"LaunchApp2": 0xf0,
"Sleep": 0xf8,
"Wake": 0xf9,
"MediaRewind": 0xfa,
"MediaFastForward": 0xfb,
}
func resolveKey(key string) (string, byte, bool, error) {
trimmed := strings.TrimSpace(key)
if trimmed == "" {
return "", 0, false, fmt.Errorf("empty key")
}
if modifier, ok := modifierMap[trimmed]; ok {
return trimmed, modifier, true, nil
}
if _, ok := keycodeMap[trimmed]; ok {
return trimmed, keycodeMap[trimmed], false, nil
}
upper := strings.ToUpper(trimmed)
if alias, ok := keyAliases[upper]; ok {
if modifier, ok := modifierMap[alias]; ok {
return alias, modifier, true, nil
}
return alias, keycodeMap[alias], false, nil
}
if len(upper) == 1 && upper[0] >= 'A' && upper[0] <= 'Z' {
code := "Key" + upper
return code, keycodeMap[code], false, nil
}
if len(upper) == 1 && upper[0] >= '0' && upper[0] <= '9' {
code := "Digit" + upper
return code, keycodeMap[code], false, nil
}
if modifier, ok := modifierMap[upper]; ok {
return upper, modifier, true, nil
}
if code, ok := keycodeMap[trimmed]; ok {
return trimmed, code, false, nil
}
if code, ok := keycodeMap[upper]; ok {
return upper, code, false, nil
}
return "", 0, false, fmt.Errorf("unsupported key: %s", key)
}
func buildHotkeyReport(keys []string) ([]byte, *PicoclawError) {
if len(keys) == 0 {
return nil, newPicoclawError(CodeInvalidAction, "hotkey requires at least one key")
}
report := make([]byte, 8)
nextIndex := 2
for _, key := range keys {
_, code, isModifier, err := resolveKey(key)
if err != nil {
return nil, newPicoclawError(CodeInvalidAction, err.Error())
}
if isModifier {
report[0] |= code
continue
}
if nextIndex >= len(report) {
return nil, newPicoclawError(CodeInvalidAction, "hotkey supports at most 6 non-modifier keys")
}
report[nextIndex] = code
nextIndex++
}
return report, nil
}

View File

@@ -0,0 +1,102 @@
package picoclaw
import (
"encoding/json"
"fmt"
"strings"
"time"
"github.com/gin-gonic/gin"
)
const (
defaultImagePrompt = "Describe the image briefly."
)
func (s *Service) LoadImage(c *gin.Context) {
var req LoadImageRequest
if err := c.ShouldBindJSON(&req); err != nil {
writePicoclawError(c, newPicoclawError(CodeInvalidAction, "invalid load image payload"))
return
}
sessionID, session, sessionErr := s.requireActiveGatewaySession(c)
if sessionErr != nil {
writePicoclawError(c, sessionErr)
return
}
sourcePath, pathErr := normalizeLoadImagePath(req.Path)
if pathErr != nil {
writePicoclawError(c, pathErr)
return
}
instruction := buildLoadImageInstruction(sourcePath, req.Prompt)
message := newPicoGatewayMessage(sessionID, map[string]any{
"content": instruction,
})
if err := session.writeUpstreamJSON(s.config.Get(), message); err != nil {
writePicoclawError(c, newPicoclawError(CodeRuntimeUnavailable, "failed to deliver image instruction to picoclaw"))
return
}
writeSuccess(c, gin.H{
"accepted": true,
"session_id": sessionID,
"message_id": message.ID,
"path": sourcePath,
"user_prompt": normalizeImagePrompt(req.Prompt),
"instructed_at": time.Now(),
})
}
func (s *Service) requireActiveGatewaySession(c *gin.Context) (string, *GatewaySession, *PicoclawError) {
sessionID := strings.TrimSpace(c.GetHeader(sessionIDHeader))
if sessionID == "" {
sessionID = strings.TrimSpace(s.lock.Owner())
}
if sessionID == "" {
return "", nil, newPicoclawError(CodeSessionIDMissing, "missing X-PicoClaw-Session-ID")
}
session, ok := GetSessionManager().Get(sessionID)
if !ok || session == nil {
err := newPicoclawError(CodeRuntimeUnavailable, "picoclaw session is not connected")
err.SessionID = sessionID
return "", nil, err
}
if session.State != SessionStateActive || session.Upstream == nil {
err := newPicoclawError(CodeRuntimeUnavailable, "picoclaw session is not active")
err.SessionID = sessionID
return "", nil, err
}
return sessionID, session, nil
}
func normalizeLoadImagePath(sourcePath string) (string, *PicoclawError) {
sourcePath = strings.TrimSpace(sourcePath)
if sourcePath == "" {
return "", newPicoclawError(CodeInvalidAction, "image path is required")
}
return sourcePath, nil
}
func buildLoadImageInstruction(sourcePath string, prompt string) string {
argsJSON, _ := json.Marshal(map[string]string{"path": sourcePath})
return fmt.Sprintf(
"Call `load_image` first with this exact argument object:\n```json\n%s\n```\n\nDo not ask the user to re-upload the image. Do not modify the path. Do not use `read_file` or any other tool to inspect this image directly.\n\nAfter `load_image` succeeds, treat the loaded image as the current task input and continue with this request:\n%s",
string(argsJSON),
normalizeImagePrompt(prompt),
)
}
func normalizeImagePrompt(prompt string) string {
prompt = strings.TrimSpace(prompt)
if prompt == "" {
return defaultImagePrompt
}
return prompt
}

View File

@@ -0,0 +1,299 @@
package picoclaw
import (
"encoding/base64"
"encoding/json"
"fmt"
"net/http"
"strings"
"github.com/gin-gonic/gin"
log "github.com/sirupsen/logrus"
)
// JSON-RPC 2.0 types for MCP HTTP transport
type jsonRPCRequest struct {
JSONRPC string `json:"jsonrpc"`
ID json.RawMessage `json:"id"`
Method string `json:"method"`
Params json.RawMessage `json:"params,omitempty"`
}
type jsonRPCResponse struct {
JSONRPC string `json:"jsonrpc"`
ID json.RawMessage `json:"id"`
Result interface{} `json:"result,omitempty"`
Error *jsonRPCError `json:"error,omitempty"`
}
type jsonRPCError struct {
Code int `json:"code"`
Message string `json:"message"`
}
// MCP tool definitions
var mcpToolDefinitions = []map[string]interface{}{
{
"name": "kvm_screenshot",
"description": "Capture the current HDMI frame from the downstream remote host as a base64-encoded JPEG image.",
"inputSchema": map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"width": map[string]interface{}{"type": "integer", "description": "Target width in pixels (optional, default: 960)"},
"height": map[string]interface{}{"type": "integer", "description": "Target height in pixels (optional)"},
"quality": map[string]interface{}{"type": "integer", "description": "JPEG quality 1-100 (optional, default: 60)"},
},
},
},
{
"name": "kvm_actions",
"description": "Send one or more HID actions (click, type, hotkey, scroll, drag, move, wait) to the downstream remote host. Use normalized [0,1] coordinates for mouse actions.",
"inputSchema": map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"actions": map[string]interface{}{
"type": "array",
"description": "Array of action objects. Each requires an 'action' field (click, move, type, hotkey, scroll, drag, wait).",
"items": map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"action": map[string]interface{}{"type": "string"},
"x": map[string]interface{}{"type": "number"},
"y": map[string]interface{}{"type": "number"},
"button": map[string]interface{}{"type": "string"},
"text": map[string]interface{}{"type": "string"},
"keys": map[string]interface{}{"type": "array", "items": map[string]interface{}{"type": "string"}},
"direction": map[string]interface{}{"type": "string"},
"amount": map[string]interface{}{"type": "integer"},
"duration_ms": map[string]interface{}{"type": "integer"},
"from": map[string]interface{}{"type": "object", "properties": map[string]interface{}{"x": map[string]interface{}{"type": "number"}, "y": map[string]interface{}{"type": "number"}}},
"to": map[string]interface{}{"type": "object", "properties": map[string]interface{}{"x": map[string]interface{}{"type": "number"}, "y": map[string]interface{}{"type": "number"}}},
},
"required": []string{"action"},
},
},
},
"required": []string{"actions"},
},
},
}
// MCPHandler handles MCP HTTP transport (JSON-RPC 2.0 over POST).
func (s *Service) MCPHandler(c *gin.Context) {
var req jsonRPCRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, jsonRPCResponse{
JSONRPC: "2.0",
ID: req.ID,
Error: &jsonRPCError{Code: -32700, Message: "parse error"},
})
return
}
if req.JSONRPC != "2.0" {
c.JSON(http.StatusOK, jsonRPCResponse{
JSONRPC: "2.0",
ID: req.ID,
Error: &jsonRPCError{Code: -32600, Message: "invalid request: jsonrpc must be 2.0"},
})
return
}
var resp jsonRPCResponse
switch req.Method {
case "initialize":
resp = s.mcpInitialize(req)
case "tools/list":
resp = s.mcpToolsList(req)
case "tools/call":
resp = s.mcpToolsCall(req, c)
case "ping":
resp = jsonRPCResponse{JSONRPC: "2.0", ID: req.ID, Result: map[string]interface{}{}}
default:
resp = jsonRPCResponse{
JSONRPC: "2.0",
ID: req.ID,
Error: &jsonRPCError{Code: -32601, Message: fmt.Sprintf("method not found: %s", req.Method)},
}
}
c.JSON(http.StatusOK, resp)
}
func (s *Service) mcpInitialize(req jsonRPCRequest) jsonRPCResponse {
return jsonRPCResponse{
JSONRPC: "2.0",
ID: req.ID,
Result: map[string]interface{}{
"protocolVersion": "2024-11-05",
"capabilities": map[string]interface{}{
"tools": map[string]interface{}{},
},
"serverInfo": map[string]interface{}{
"name": "nanokvm",
"version": "1.0.0",
},
},
}
}
func (s *Service) mcpToolsList(req jsonRPCRequest) jsonRPCResponse {
return jsonRPCResponse{
JSONRPC: "2.0",
ID: req.ID,
Result: map[string]interface{}{
"tools": mcpToolDefinitions,
},
}
}
func (s *Service) mcpToolsCall(req jsonRPCRequest, c *gin.Context) jsonRPCResponse {
var params struct {
Name string `json:"name"`
Arguments json.RawMessage `json:"arguments"`
}
if err := json.Unmarshal(req.Params, &params); err != nil {
return jsonRPCResponse{
JSONRPC: "2.0",
ID: req.ID,
Error: &jsonRPCError{Code: -32602, Message: "invalid params"},
}
}
switch params.Name {
case "kvm_screenshot":
return s.mcpScreenshot(req, params.Arguments, c)
case "kvm_actions":
return s.mcpActions(req, params.Arguments, c)
default:
return jsonRPCResponse{
JSONRPC: "2.0",
ID: req.ID,
Error: &jsonRPCError{Code: -32602, Message: fmt.Sprintf("unknown tool: %s", params.Name)},
}
}
}
func (s *Service) mcpScreenshot(req jsonRPCRequest, args json.RawMessage, c *gin.Context) jsonRPCResponse {
var params struct {
Width uint16 `json:"width"`
Height uint16 `json:"height"`
Quality uint16 `json:"quality"`
}
if args != nil {
_ = json.Unmarshal(args, &params)
}
query := ScreenshotQuery{
Format: "base64",
Width: params.Width,
Height: params.Height,
Quality: params.Quality,
}
data, meta, err := s.captureScreenshot(query)
if err != nil {
return mcpToolError(req, err.Message)
}
b64 := base64.StdEncoding.EncodeToString(data)
s.publishMCPObservation(c, "screenshot captured", b64)
return jsonRPCResponse{
JSONRPC: "2.0",
ID: req.ID,
Result: map[string]interface{}{
"content": []map[string]interface{}{
{
"type": "text",
"text": "screenshot captured",
},
{
"type": "image",
"data": b64,
"mimeType": "image/jpeg",
},
},
"meta": map[string]interface{}{
"source_width": meta.SourceWidth,
"source_height": meta.SourceHeight,
"capture_width": meta.CaptureWidth,
"capture_height": meta.CaptureHeight,
},
},
}
}
func (s *Service) publishMCPObservation(c *gin.Context, text string, imageBase64 string) {
if strings.TrimSpace(imageBase64) == "" {
return
}
sessionID, session, err := s.requireActiveGatewaySession(c)
if err != nil || session == nil || session.Downstream == nil {
return
}
message := newPicoGatewayObservationMessage(sessionID, map[string]any{
"content": text,
"image_base64": imageBase64,
"mime_type": "image/jpeg",
})
if writeErr := session.writeDownstreamJSON(s.config.Get(), message); writeErr != nil {
log.Warnf("failed to deliver picoclaw observation message: %v", writeErr)
}
}
func (s *Service) mcpActions(req jsonRPCRequest, args json.RawMessage, c *gin.Context) jsonRPCResponse {
var params struct {
Actions []Action `json:"actions"`
}
if err := json.Unmarshal(args, &params); err != nil {
return mcpToolError(req, "invalid actions payload")
}
if len(params.Actions) == 0 {
return mcpToolError(req, "empty actions array")
}
sessionID := c.GetHeader(sessionIDHeader)
if sessionID == "" {
sessionID = s.lock.Owner()
}
result, err := s.executeActions(sessionID, params.Actions)
if err != nil {
return mcpToolError(req, err.Message)
}
resultJSON, _ := json.Marshal(result)
return jsonRPCResponse{
JSONRPC: "2.0",
ID: req.ID,
Result: map[string]interface{}{
"content": []map[string]interface{}{
{
"type": "text",
"text": string(resultJSON),
},
},
},
}
}
func mcpToolError(req jsonRPCRequest, message string) jsonRPCResponse {
return jsonRPCResponse{
JSONRPC: "2.0",
ID: req.ID,
Result: map[string]interface{}{
"isError": true,
"content": []map[string]interface{}{
{
"type": "text",
"text": message,
},
},
},
}
}

View File

@@ -0,0 +1,225 @@
package picoclaw
import (
"fmt"
"strings"
"github.com/gin-gonic/gin"
)
func extractPicoclawModelName(model string) string {
model = strings.TrimSpace(model)
if model == "" {
return ""
}
if index := strings.LastIndex(model, "/"); index >= 0 && index < len(model)-1 {
return strings.TrimSpace(model[index+1:])
}
return model
}
func isPicoclawModelConfigured(cfg picoclawConfigFile, security picoclawSecurityConfig, modelName string) bool {
if modelName == "" {
return false
}
for _, model := range cfg.ModelList {
if strings.TrimSpace(model.ModelName) != modelName {
continue
}
if model.APIBase == "" {
continue
}
if securityHasModelAPIKeys(security, modelName) {
return true
}
return configHasModelAPIKeys(model.APIKey, model.APIKeys)
}
return false
}
func configHasModelAPIKeys(apiKey string, apiKeys []string) bool {
return strings.TrimSpace(apiKey) != "" || len(apiKeys) > 0
}
func securityHasModelAPIKeys(security picoclawSecurityConfig, modelName string) bool {
if modelName == "" || len(security.ModelList) == 0 {
return false
}
if entry, ok := security.ModelList[modelName]; ok && len(entry.APIKeys) > 0 {
return true
}
prefix := modelName + ":"
for key, entry := range security.ModelList {
if strings.HasPrefix(key, prefix) && len(entry.APIKeys) > 0 {
return true
}
}
return false
}
type ModelConfigUpdateRequest struct {
Model string `json:"model"`
APIBase string `json:"api_base"`
APIKey string `json:"api_key"`
}
func (s *Service) UpdateModelConfig(c *gin.Context) {
var req ModelConfigUpdateRequest
if err := c.ShouldBindJSON(&req); err != nil {
writePicoclawError(c, newPicoclawError(CodeInvalidAction, "invalid model config payload"))
return
}
currentStatus := s.runtime.Get()
shouldRestart := currentStatus.Ready || currentStatus.Status == "ready"
modelName, err := updatePicoclawModelConfig(
strings.TrimSpace(req.APIBase),
strings.TrimSpace(req.APIKey),
strings.TrimSpace(req.Model),
)
if err != nil {
writePicoclawError(c, newPicoclawError(CodeRuntimeUnavailable, err.Error()))
return
}
if err := ensurePicoclawStartupDefaults(); err != nil {
writePicoclawError(c, newPicoclawError(CodeRuntimeUnavailable, "model config saved, but failed to persist picoclaw defaults: "+err.Error()))
return
}
if shouldRestart {
if _, _, stopErr := s.stopRuntime(); stopErr != nil {
writePicoclawError(c, newPicoclawError(CodeRuntimeUnavailable, "model config saved, but failed to restart picoclaw runtime: "+stopErr.Message))
return
}
if _, _, startErr := s.startRuntime(); startErr != nil {
writePicoclawError(c, newPicoclawError(CodeRuntimeUnavailable, "model config saved, but failed to restart picoclaw runtime: "+startErr.Message))
return
}
} else {
_ = s.syncConfigFromPicoclaw()
_ = s.ensureRuntimeReady()
}
writeSuccess(c, gin.H{
"model_name": modelName,
"status": s.runtime.Get(),
})
}
func updatePicoclawModelConfig(apiBase string, apiKey string, model string) (string, error) {
if apiBase == "" {
return "", fmt.Errorf("model api_base is required")
}
if apiKey == "" {
return "", fmt.Errorf("model api_key is required")
}
if model == "" {
return "", fmt.Errorf("model identifier is required")
}
modelName := extractPicoclawModelName(model)
if modelName == "" {
return "", fmt.Errorf("model identifier is required")
}
doc, err := loadPicoclawConfigDocument()
if err != nil {
return "", err
}
modelListValue, ok := doc.raw["model_list"].([]any)
if !ok {
modelListValue = []any{}
}
modelUpdated := false
updatedModelIndex := -1
for index, item := range modelListValue {
modelMap, ok := item.(map[string]any)
if !ok {
continue
}
currentModelName := strings.TrimSpace(fmt.Sprintf("%v", modelMap["model_name"]))
if currentModelName != modelName {
continue
}
modelMap["model_name"] = modelName
modelMap["model"] = model
modelMap["api_base"] = apiBase
delete(modelMap, "api_key")
delete(modelMap, "api_keys")
modelUpdated = true
updatedModelIndex = index
break
}
if !modelUpdated {
modelListValue = append(modelListValue, map[string]any{
"model_name": modelName,
"model": model,
"api_base": apiBase,
})
doc.raw["model_list"] = modelListValue
updatedModelIndex = len(modelListValue) - 1
}
agents, ok := doc.raw["agents"].(map[string]any)
if !ok {
agents = map[string]any{}
doc.raw["agents"] = agents
}
defaults, ok := agents["defaults"].(map[string]any)
if !ok {
defaults = map[string]any{}
agents["defaults"] = defaults
}
defaults["model_name"] = modelName
delete(defaults, "model")
if err := doc.saveConfig(); err != nil {
return "", err
}
if doc.security.ModelList == nil {
doc.security.ModelList = map[string]picoclawModelSecurityEntry{}
}
securityModelName := indexedModelName(modelListValue, updatedModelIndex, modelName)
doc.security.ModelList[securityModelName] = picoclawModelSecurityEntry{
APIKeys: []string{apiKey},
}
if err := doc.saveSecurity(); err != nil {
return "", err
}
return modelName, nil
}
func indexedModelName(modelList []any, targetIndex int, modelName string) string {
if targetIndex < 0 {
return modelName
}
currentIndex := 0
for index, item := range modelList {
modelMap, ok := item.(map[string]any)
if !ok {
continue
}
currentModelName := strings.TrimSpace(fmt.Sprintf("%v", modelMap["model_name"]))
if currentModelName != modelName {
continue
}
if index == targetIndex {
return fmt.Sprintf("%s:%d", modelName, currentIndex)
}
currentIndex++
}
return modelName
}

View File

@@ -0,0 +1,22 @@
package picoclaw
import (
"time"
)
const (
picoclawBinaryPath = "/usr/bin/picoclaw"
picoclawCacheDir = "/root/.picoclaw-cache"
picoclawDownloadURL = "https://cdn.sipeed.com/nanokvm/resources/picoclaw/v0.2.6/picoclaw_Linux_riscv64.tar.gz"
picoclawChecksumURL = "https://cdn.sipeed.com/nanokvm/resources/picoclaw/v0.2.6/sha512.txt"
etcInitPicoclawScript = "/etc/init.d/S96picoclaw"
kvmappPicoclawScript = "/kvmapp/system/init.d/S96picoclaw"
picoclawStartTimeout = 15 * time.Second
picoclawStopTimeout = 15 * time.Second
picoclawOnboardTimeout = 60 * time.Second
picoclawInstallTimeout = 12 * time.Minute
picoclawDownloadTimeout = 10 * time.Minute
picoclawStartWaitPeriod = 1500 * time.Millisecond
picoclawStopWaitPeriod = 500 * time.Millisecond
picoclawReadyPollPeriod = 500 * time.Millisecond
)

View File

@@ -0,0 +1,227 @@
package picoclaw
import (
"crypto/rand"
"encoding/hex"
"fmt"
"reflect"
"strings"
serverConfig "NanoKVM-Server/config"
)
const (
defaultPicoclawPingSec = 30
defaultPicoclawReadSec = 60
defaultPicoclawWriteSec = 10
defaultPicoclawMaxConns = 100
)
type picoclawConfigDefault struct {
path []string
value any
}
var picoclawNanoKVMDefaults = []picoclawConfigDefault{
{path: []string{"agents", "defaults", "restrict_to_workspace"}, value: false},
{path: []string{"agents", "defaults", "allow_read_outside_workspace"}, value: true},
{path: []string{"agents", "defaults", "tool_feedback", "enabled"}, value: true},
{path: []string{"gateway", "host"}, value: defaultPicoclawGatewayHost},
{path: []string{"gateway", "port"}, value: defaultPicoclawGatewayPort},
{path: []string{"gateway", "hot_reload"}, value: false},
{path: []string{"tools", "cron", "allow_command"}, value: true},
{path: []string{"tools", "exec", "allow_remote"}, value: true},
{path: []string{"tools", "exec", "enable_deny_patterns"}, value: false},
{path: []string{"channels", "pico", "allow_token_query"}, value: false},
{path: []string{"channels", "pico", "ping_interval"}, value: defaultPicoclawPingSec},
{path: []string{"channels", "pico", "read_timeout"}, value: defaultPicoclawReadSec},
{path: []string{"channels", "pico", "write_timeout"}, value: defaultPicoclawWriteSec},
{path: []string{"channels", "pico", "max_connections"}, value: defaultPicoclawMaxConns},
{path: []string{"tools", "mcp", "enabled"}, value: true},
}
func ensurePicoclawStartupDefaults() error {
doc, err := loadPicoclawConfigDocument()
if err != nil {
return err
}
editor := &picoclawConfigEditor{raw: doc.raw}
forceEnablePicoclawPicoChannel(doc, editor)
if err := applyPicoclawStartupDefaults(editor); err != nil {
return err
}
tokenChanged, err := ensurePicoclawPicoToken(doc)
if err != nil {
return err
}
if tokenChanged {
if err := doc.saveSecurity(); err != nil {
return err
}
}
if editor.changed {
if err := doc.saveConfig(); err != nil {
return err
}
}
return nil
}
func ensurePicoclawPicoChannelEnabled(doc *picoclawConfigDocument) error {
if doc == nil || doc.config.Channels.Pico.Enabled {
return nil
}
editor := &picoclawConfigEditor{raw: doc.raw}
forceEnablePicoclawPicoChannel(doc, editor)
if !editor.changed {
return nil
}
return doc.saveConfig()
}
func applyPicoclawStartupDefaults(editor *picoclawConfigEditor) error {
for _, entry := range picoclawNanoKVMDefaults {
editor.setValue(entry.value, entry.path...)
}
server, err := defaultPicoclawMCPServer()
if err != nil {
return err
}
editor.setMCPServer("nanokvm", server)
return nil
}
func forceEnablePicoclawPicoChannel(doc *picoclawConfigDocument, editor *picoclawConfigEditor) {
if doc == nil || editor == nil || doc.config.Channels.Pico.Enabled {
return
}
editor.setValue(true, "channels", "pico", "enabled")
doc.config.Channels.Pico.Enabled = true
}
func defaultPicoclawMCPServer() (map[string]any, error) {
internalToken, err := serverConfig.GetPicoclawInternalToken()
if err != nil {
return nil, err
}
return map[string]any{
"enabled": true,
"type": "http",
"url": nanoKVMMCPURL(),
"headers": map[string]any{
serverConfig.PicoclawInternalTokenHeader: internalToken,
},
}, nil
}
func nanoKVMMCPURL() string {
port := serverConfig.GetInstance().Port.Http
if port <= 0 {
port = 80
}
return fmt.Sprintf("http://%s:%d/api/picoclaw/mcp", defaultPicoclawGatewayHost, port)
}
type picoclawConfigEditor struct {
raw map[string]any
changed bool
}
func (e *picoclawConfigEditor) setValue(value any, path ...string) {
if len(path) == 0 {
return
}
target := e.ensureObject(path[:len(path)-1]...)
key := path[len(path)-1]
if current, exists := target[key]; exists && reflect.DeepEqual(current, value) {
return
}
target[key] = value
e.changed = true
}
func (e *picoclawConfigEditor) ensureObject(path ...string) map[string]any {
current := e.raw
for _, key := range path {
next, ok := current[key].(map[string]any)
if !ok {
next = map[string]any{}
current[key] = next
e.changed = true
}
current = next
}
return current
}
func (e *picoclawConfigEditor) setMCPServer(name string, fields map[string]any) {
servers := e.ensureObject("tools", "mcp", "servers")
entry, ok := servers[name].(map[string]any)
if !ok {
entry = map[string]any{}
servers[name] = entry
e.changed = true
}
for key, value := range fields {
if current, exists := entry[key]; exists && reflect.DeepEqual(current, value) {
continue
}
entry[key] = value
e.changed = true
}
}
func generatePicoclawToken() (string, error) {
buf := make([]byte, 24)
if _, err := rand.Read(buf); err != nil {
return "", fmt.Errorf("failed to generate picoclaw pico token: %w", err)
}
return hex.EncodeToString(buf), nil
}
func ensurePicoclawPicoToken(doc *picoclawConfigDocument) (bool, error) {
token, err := picoSecurityToken(doc)
if err != nil {
return false, err
}
if doc.security.Channels.Pico == nil {
doc.security.Channels.Pico = &picoclawPicoSecurity{}
}
if strings.TrimSpace(doc.security.Channels.Pico.Token) == token {
return false, nil
}
doc.security.Channels.Pico.Token = token
return true, nil
}
func picoSecurityToken(doc *picoclawConfigDocument) (string, error) {
if doc == nil {
return "", nil
}
if doc.security.Channels.Pico != nil {
if token := strings.TrimSpace(doc.security.Channels.Pico.Token); token != "" {
return token, nil
}
}
if token := strings.TrimSpace(doc.config.Channels.Pico.Token); token != "" {
return token, nil
}
return generatePicoclawToken()
}

View File

@@ -0,0 +1,110 @@
package picoclaw
import (
"os"
"path/filepath"
"time"
"github.com/gin-gonic/gin"
)
func (s *Service) StartRuntime(c *gin.Context) {
command, output, err := s.startRuntime()
if err != nil {
writePicoclawError(c, err)
return
}
writeSuccess(c, RuntimeStartResult{
Started: true,
Command: command,
Output: output,
Status: s.runtime.Get(),
})
}
func (s *Service) StopRuntime(c *gin.Context) {
command, output, err := s.stopRuntime()
if err != nil {
writePicoclawError(c, err)
return
}
writeSuccess(c, RuntimeStartResult{
Started: false,
Command: command,
Output: output,
Status: s.runtime.Get(),
})
}
func (s *Service) InstallRuntime(c *gin.Context) {
output, err := s.installRuntime()
if err != nil {
writePicoclawError(c, err)
return
}
currentStatus := s.runtime.Get()
writeSuccess(c, RuntimeInstallResult{
Installed: currentStatus.Installed && !currentStatus.Installing,
Binary: picoclawBinaryPath,
Download: picoclawDownloadURL,
Output: output,
Status: currentStatus,
})
}
func (s *Service) UninstallRuntime(c *gin.Context) {
currentStatus := s.runtime.Get()
uninstallOutput := "picoclaw uninstalled successfully"
if currentStatus.Installing {
writePicoclawError(c, newPicoclawError(CodeRuntimeStartFailed, "cannot uninstall while installation is in progress"))
return
}
running, err := isRuntimeRunning()
if err != nil {
writePicoclawError(c, newPicoclawError(CodeRuntimeUnavailable, "failed to check picoclaw runtime status"))
return
}
if running || currentStatus.Ready || currentStatus.Status == "ready" {
if _, _, stopErr := s.stopRuntime(); stopErr != nil {
uninstallOutput = "picoclaw uninstalled successfully (stop failed before uninstall: " + stopErr.Message + ")"
}
}
if configPath, err := resolvePicoclawConfigPath(); err == nil {
_ = os.RemoveAll(filepath.Dir(configPath))
} else {
home, err := os.UserHomeDir()
if err == nil {
_ = os.RemoveAll(filepath.Join(home, ".picoclaw"))
} else {
_ = os.RemoveAll("/root/.picoclaw")
}
}
_ = os.Remove(picoclawBinaryPath)
_ = os.RemoveAll(picoclawCacheDir)
s.runtime.Set(RuntimeStatus{
Ready: false,
Installed: false,
Installing: false,
InstallProgress: 0,
InstallStage: "",
InstallPath: picoclawBinaryPath,
Status: "not_installed",
CheckedAt: time.Now(),
CurrentSession: "",
})
writeSuccess(c, RuntimeInstallResult{
Installed: false,
Binary: picoclawBinaryPath,
Download: picoclawDownloadURL,
Output: uninstallOutput,
Status: s.runtime.Get(),
})
}

View File

@@ -0,0 +1,441 @@
package picoclaw
import (
"archive/tar"
"compress/gzip"
"context"
"crypto/sha512"
"encoding/hex"
"errors"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"time"
log "github.com/sirupsen/logrus"
)
func (s *Service) installRuntime() (string, *PicoclawError) {
log.Debugf("picoclaw install: start, binary=%s, cache=%s", picoclawBinaryPath, picoclawCacheDir)
currentStatus := s.runtime.Get()
if currentStatus.Installing {
log.Debugf("picoclaw install: install already in progress")
return "picoclaw installation is already in progress", nil
}
if installed, err := isPicoclawInstalled(); err == nil && installed {
settings, _ := loadPicoclawGatewaySettings()
log.Debugf("picoclaw install: binary already exists at %s", picoclawBinaryPath)
s.runtime.Set(RuntimeStatus{
Ready: false,
Installed: true,
Installing: false,
InstallProgress: 100,
InstallStage: "installed",
InstallPath: picoclawBinaryPath,
ModelConfigured: settings.ModelConfigured,
ModelName: settings.ModelName,
Status: "installed",
CheckedAt: time.Now(),
})
return "picoclaw is already installed", nil
}
ctx, cancel := context.WithTimeout(context.Background(), picoclawInstallTimeout)
s.runtime.Set(RuntimeStatus{
Ready: false,
Installed: false,
Installing: true,
InstallProgress: 0,
InstallStage: "preparing",
InstallPath: picoclawBinaryPath,
Status: "installing",
CheckedAt: time.Now(),
})
go s.runInstallRuntime(ctx, cancel)
return "picoclaw installation started", nil
}
func (s *Service) runInstallRuntime(ctx context.Context, cancel context.CancelFunc) {
defer cancel()
_ = os.RemoveAll(picoclawCacheDir)
if err := os.MkdirAll(picoclawCacheDir, 0o755); err != nil {
log.Errorf("picoclaw install: failed to create cache directory %s: %v", picoclawCacheDir, err)
s.finishInstallFailure("install_failed", fmt.Sprintf("failed to create cache directory: %v", err))
return
}
log.Debugf("picoclaw install: cache directory ready at %s", picoclawCacheDir)
defer func() {
if err := os.RemoveAll(picoclawCacheDir); err != nil {
log.Errorf("picoclaw install: failed to clean cache directory %s: %v", picoclawCacheDir, err)
return
}
log.Debugf("picoclaw install: cleaned cache directory %s", picoclawCacheDir)
}()
s.setInstallProgress("downloading", 5, "")
log.Debugf("picoclaw install: downloading checksum from %s", picoclawChecksumURL)
expectedDigest, err := downloadPicoclawChecksum(ctx)
if err != nil {
log.Errorf("picoclaw install: checksum download failed: %v", err)
s.finishInstallFailure(installFailureStatus(err), err.Error())
return
}
log.Debug("picoclaw install: checksum file downloaded")
archivePath := filepath.Join(picoclawCacheDir, "picoclaw.tar.gz")
log.Debugf("picoclaw install: downloading archive from %s to %s", picoclawDownloadURL, archivePath)
if err := downloadPicoclawArchive(ctx, archivePath, func(downloaded int64, total int64) {
progress := 10
if total > 0 {
progress = 10 + int(float64(downloaded)*70/float64(total))
if progress > 80 {
progress = 80
}
}
s.setInstallProgress("downloading", progress, "")
}); err != nil {
log.Errorf("picoclaw install: download failed: %v", err)
s.finishInstallFailure(installFailureStatus(err), err.Error())
return
}
log.Debugf("picoclaw install: archive download completed")
s.setInstallProgress("verifying", 82, "")
if err := verifyFileSHA512(archivePath, expectedDigest); err != nil {
log.Errorf("picoclaw install: checksum verification failed: %v", err)
s.finishInstallFailure(installFailureStatus(err), err.Error())
return
}
log.Debugf("picoclaw install: archive checksum verified for %s", archivePath)
s.setInstallProgress("extracting", 85, "")
log.Debugf("picoclaw install: extracting binary from %s", archivePath)
extractedPath, err := extractPicoclawBinary(archivePath, picoclawCacheDir)
if err != nil {
log.Errorf("picoclaw install: extract failed: %v", err)
s.finishInstallFailure(installFailureStatus(err), err.Error())
return
}
log.Debugf("picoclaw install: extracted binary to %s", extractedPath)
s.setInstallProgress("installing", 95, "")
log.Debugf("picoclaw install: installing binary to %s", picoclawBinaryPath)
if err := installPicoclawBinary(extractedPath, picoclawBinaryPath); err != nil {
log.Errorf("picoclaw install: install failed: %v", err)
s.finishInstallFailure(installFailureStatus(err), err.Error())
return
}
log.Debugf("picoclaw install: install completed successfully")
s.runtime.Set(RuntimeStatus{
Ready: false,
Installed: true,
Installing: false,
InstallProgress: 100,
InstallStage: "installed",
InstallPath: picoclawBinaryPath,
Status: "installed",
CheckedAt: time.Now(),
})
}
func downloadPicoclawArchive(ctx context.Context, destination string, onProgress func(downloaded int64, total int64)) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, picoclawDownloadURL, nil)
if err != nil {
return fmt.Errorf("failed to create download request: %w", err)
}
client := &http.Client{
Timeout: picoclawDownloadTimeout,
}
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("failed to download picoclaw: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("failed to download picoclaw: unexpected status %s", resp.Status)
}
file, err := os.Create(destination)
if err != nil {
return fmt.Errorf("failed to create archive file: %w", err)
}
defer file.Close()
if err := copyWithProgress(ctx, file, resp.Body, resp.ContentLength, onProgress); err != nil {
return fmt.Errorf("failed to save archive: %w", err)
}
return nil
}
func downloadPicoclawChecksum(ctx context.Context) (string, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, picoclawChecksumURL, nil)
if err != nil {
return "", fmt.Errorf("failed to create checksum request: %w", err)
}
client := &http.Client{
Timeout: picoclawDownloadTimeout,
}
resp, err := client.Do(req)
if err != nil {
return "", fmt.Errorf("failed to download picoclaw checksum: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("failed to download picoclaw checksum: unexpected status %s", resp.Status)
}
data, err := io.ReadAll(io.LimitReader(resp.Body, 16*1024))
if err != nil {
return "", fmt.Errorf("failed to read checksum file: %w", err)
}
digest, err := parseSHA512Digest(string(data), filepath.Base(picoclawDownloadURL))
if err != nil {
return "", err
}
return digest, nil
}
func copyWithProgress(ctx context.Context, dst io.Writer, src io.Reader, total int64, onProgress func(downloaded int64, total int64)) error {
buffer := make([]byte, 32*1024)
var downloaded int64
for {
select {
case <-ctx.Done():
return ctx.Err()
default:
}
n, readErr := src.Read(buffer)
if n > 0 {
if _, writeErr := dst.Write(buffer[:n]); writeErr != nil {
return writeErr
}
downloaded += int64(n)
if onProgress != nil {
onProgress(downloaded, total)
}
}
if readErr == io.EOF {
if onProgress != nil {
onProgress(downloaded, total)
}
return nil
}
if readErr != nil {
return readErr
}
}
}
func (s *Service) setInstallProgress(stage string, progress int, lastError string) {
if progress < 0 {
progress = 0
}
if progress > 100 {
progress = 100
}
s.runtime.UpdateInstallStatus(func(status *RuntimeStatus) {
status.Ready = false
status.Installed = false
status.Installing = true
status.InstallProgress = progress
status.InstallStage = stage
status.Status = "installing"
status.LastError = lastError
status.CheckedAt = time.Now()
})
}
func (s *Service) finishInstallFailure(status string, message string) {
s.runtime.Set(RuntimeStatus{
Ready: false,
Installed: false,
Installing: false,
InstallProgress: 0,
InstallStage: status,
InstallPath: picoclawBinaryPath,
Status: status,
LastError: message,
CheckedAt: time.Now(),
})
}
func installFailureStatus(err error) string {
if err == nil {
return "install_failed"
}
if errors.Is(err, context.DeadlineExceeded) {
return "install_timeout"
}
return "install_failed"
}
func parseSHA512Digest(raw string, expectedName string) (string, error) {
lines := strings.Split(raw, "\n")
var fallback string
for _, line := range lines {
trimmed := strings.TrimSpace(line)
if trimmed == "" {
continue
}
fields := strings.Fields(trimmed)
if len(fields) == 0 {
continue
}
digest := fields[0]
if !isValidSHA512Digest(digest) {
continue
}
if len(fields) == 1 {
if fallback == "" {
fallback = strings.ToLower(digest)
}
continue
}
name := strings.TrimPrefix(fields[len(fields)-1], "*")
if expectedName == "" || name == expectedName {
return strings.ToLower(digest), nil
}
}
if fallback != "" {
return fallback, nil
}
return "", fmt.Errorf("failed to parse sha512 digest from checksum file")
}
func isValidSHA512Digest(value string) bool {
if len(value) != sha512.Size*2 {
return false
}
_, err := hex.DecodeString(value)
return err == nil
}
func verifyFileSHA512(filePath string, expectedDigest string) error {
expectedDigest = strings.ToLower(strings.TrimSpace(expectedDigest))
if !isValidSHA512Digest(expectedDigest) {
return fmt.Errorf("invalid expected sha512 digest")
}
file, err := os.Open(filePath)
if err != nil {
return fmt.Errorf("failed to open file for sha512 verification: %w", err)
}
defer file.Close()
hasher := sha512.New()
if _, err := io.Copy(hasher, file); err != nil {
return fmt.Errorf("failed to hash file for sha512 verification: %w", err)
}
actualDigest := hex.EncodeToString(hasher.Sum(nil))
if actualDigest != expectedDigest {
return fmt.Errorf("sha512 mismatch: got %s", actualDigest)
}
return nil
}
func extractPicoclawBinary(archivePath string, destinationDir string) (string, error) {
file, err := os.Open(archivePath)
if err != nil {
return "", fmt.Errorf("failed to open archive: %w", err)
}
defer file.Close()
gzipReader, err := gzip.NewReader(file)
if err != nil {
return "", fmt.Errorf("failed to read archive: %w", err)
}
defer gzipReader.Close()
tarReader := tar.NewReader(gzipReader)
for {
header, err := tarReader.Next()
if err == io.EOF {
break
}
if err != nil {
return "", fmt.Errorf("failed to extract archive: %w", err)
}
if header.Typeflag != tar.TypeReg {
continue
}
if filepath.Base(header.Name) != "picoclaw" {
continue
}
extractedPath := filepath.Join(destinationDir, "picoclaw")
outFile, err := os.OpenFile(extractedPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o755)
if err != nil {
return "", fmt.Errorf("failed to create extracted binary: %w", err)
}
if _, err := io.Copy(outFile, tarReader); err != nil {
_ = outFile.Close()
return "", fmt.Errorf("failed to extract picoclaw binary: %w", err)
}
if err := outFile.Close(); err != nil {
return "", fmt.Errorf("failed to finalize extracted binary: %w", err)
}
return extractedPath, nil
}
return "", fmt.Errorf("picoclaw binary not found in archive")
}
func installPicoclawBinary(source string, destination string) error {
inFile, err := os.Open(source)
if err != nil {
return fmt.Errorf("failed to open extracted picoclaw binary: %w", err)
}
defer inFile.Close()
tempDestination := destination + ".tmp"
outFile, err := os.OpenFile(tempDestination, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o755)
if err != nil {
return fmt.Errorf("failed to create destination binary: %w", err)
}
if _, err := io.Copy(outFile, inFile); err != nil {
_ = outFile.Close()
_ = os.Remove(tempDestination)
return fmt.Errorf("failed to write destination binary: %w", err)
}
if err := outFile.Close(); err != nil {
_ = os.Remove(tempDestination)
return fmt.Errorf("failed to finalize destination binary: %w", err)
}
if err := os.Chmod(tempDestination, 0o755); err != nil {
_ = os.Remove(tempDestination)
return fmt.Errorf("failed to set destination mode: %w", err)
}
if err := os.Rename(tempDestination, destination); err != nil {
_ = os.Remove(tempDestination)
return fmt.Errorf("failed to install picoclaw binary: %w", err)
}
return nil
}

View File

@@ -0,0 +1,31 @@
package picoclaw
import (
"strings"
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
)
func (s *Service) ReleaseRuntimeSession(c *gin.Context) {
sessionID := strings.TrimSpace(c.GetHeader(sessionIDHeader))
if sessionID == "" {
writePicoclawError(c, newPicoclawError(CodeSessionIDMissing, "missing X-PicoClaw-Session-ID"))
return
}
if session, ok := GetSessionManager().Get(sessionID); ok {
s.closeGatewaySession(session, websocket.CloseNormalClosure, "session released")
} else {
ReleaseSession(sessionID)
}
status := s.runtime.Get()
status.CurrentSession = s.lock.Owner()
s.runtime.Set(status)
writeSuccess(c, gin.H{
"released": true,
"current_session": status.CurrentSession,
})
}

View File

@@ -0,0 +1,228 @@
package picoclaw
import (
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
)
func (s *Service) startRuntime() (string, string, *PicoclawError) {
if installed, statErr := isPicoclawInstalled(); statErr != nil {
s.runtime.Update(func(status *RuntimeStatus) {
status.Ready = false
status.Installed = false
status.ModelConfigured = false
status.ModelName = ""
status.Status = "not_installed"
status.LastError = statErr.Error()
status.CheckedAt = time.Now()
})
return "", "", newPicoclawError(CodeRuntimeStartFailed, statErr.Error())
} else if !installed {
s.runtime.Update(func(status *RuntimeStatus) {
status.Ready = false
status.Installed = false
status.ModelConfigured = false
status.ModelName = ""
status.Status = "not_installed"
status.LastError = "picoclaw is not installed"
status.CheckedAt = time.Now()
})
return "", "", newPicoclawError(CodeRuntimeStartFailed, "picoclaw is not installed")
}
scriptPath, err := resolvePicoclawStartScript()
if err != nil {
s.runtime.Update(func(status *RuntimeStatus) {
status.Ready = false
status.Installed = true
status.Status = "unavailable"
status.LastError = err.Error()
status.CheckedAt = time.Now()
})
return "", "", newPicoclawError(CodeRuntimeStartFailed, err.Error())
}
if err := ensurePicoclawStartupDefaults(); err != nil {
s.runtime.Update(func(status *RuntimeStatus) {
status.Ready = false
status.Installed = true
status.Status = "config_error"
status.ConfigError = err.Error()
status.LastError = err.Error()
status.CheckedAt = time.Now()
})
return "", "", newPicoclawError(CodeRuntimeStartFailed, err.Error())
}
command := scriptPath + " start"
ctx, cancel := context.WithTimeout(context.Background(), picoclawStartTimeout)
defer cancel()
output, execErr := exec.CommandContext(ctx, "sh", "-c", command).CombinedOutput()
trimmedOutput := strings.TrimSpace(string(output))
if execErr != nil {
s.runtime.Update(func(status *RuntimeStatus) {
status.Ready = false
status.Installed = true
status.Status = "unavailable"
status.LastError = trimmedOutput
if status.LastError == "" {
status.LastError = execErr.Error()
}
status.CheckedAt = time.Now()
})
return command, trimmedOutput, newPicoclawError(CodeRuntimeStartFailed, "failed to start picoclaw runtime")
}
time.Sleep(picoclawStartWaitPeriod)
if runtimeErr := s.waitForRuntimeReady(picoclawStartTimeout); runtimeErr != nil {
startErr := newPicoclawError(CodeRuntimeStartFailed, runtimeErr.Message)
return command, trimmedOutput, startErr
}
return command, trimmedOutput, nil
}
func (s *Service) stopRuntime() (string, string, *PicoclawError) {
settings, _ := loadPicoclawGatewaySettings()
scriptPath, err := resolvePicoclawStartScript()
if err != nil {
s.runtime.Set(RuntimeStatus{
Ready: false,
Installed: true,
InstallPath: picoclawBinaryPath,
ModelConfigured: settings.ModelConfigured,
ModelName: settings.ModelName,
Status: "unavailable",
LastError: err.Error(),
CheckedAt: time.Now(),
})
return "", "", newPicoclawError(CodeRuntimeStartFailed, err.Error())
}
command := scriptPath + " stop"
ctx, cancel := context.WithTimeout(context.Background(), picoclawStopTimeout)
defer cancel()
output, execErr := exec.CommandContext(ctx, "sh", "-c", command).CombinedOutput()
trimmedOutput := strings.TrimSpace(string(output))
if execErr != nil {
status := RuntimeStatus{
Ready: false,
Installed: true,
InstallPath: picoclawBinaryPath,
ModelConfigured: settings.ModelConfigured,
ModelName: settings.ModelName,
Status: "error",
LastError: trimmedOutput,
CheckedAt: time.Now(),
}
if trimmedOutput == "" {
status.LastError = execErr.Error()
}
s.runtime.Set(status)
return command, trimmedOutput, newPicoclawError(CodeRuntimeStartFailed, "failed to stop picoclaw runtime")
}
time.Sleep(picoclawStopWaitPeriod)
s.runtime.Set(RuntimeStatus{
Ready: false,
Installed: true,
InstallPath: picoclawBinaryPath,
ModelConfigured: settings.ModelConfigured,
ModelName: settings.ModelName,
Status: "stopped",
CheckedAt: time.Now(),
CurrentSession: "",
})
return command, trimmedOutput, nil
}
func (s *Service) waitForRuntimeReady(timeout time.Duration) *PicoclawError {
deadline := time.Now().Add(timeout)
var lastErr *PicoclawError
for {
if runtimeErr := s.ensureRuntimeReady(); runtimeErr == nil {
return nil
} else {
lastErr = runtimeErr
}
if time.Now().After(deadline) {
break
}
time.Sleep(picoclawReadyPollPeriod)
}
if lastErr != nil {
return lastErr
}
return newPicoclawError(CodeRuntimeUnavailable, "gateway is unavailable")
}
func resolvePicoclawStartScript() (string, error) {
if _, err := os.Stat(etcInitPicoclawScript); err == nil {
return etcInitPicoclawScript, nil
}
if _, err := os.Stat(kvmappPicoclawScript); err == nil {
return kvmappPicoclawScript, nil
}
return "", fmt.Errorf("picoclaw start script not found: %s or %s", etcInitPicoclawScript, kvmappPicoclawScript)
}
func runPicoclawOnboard() (string, *PicoclawError) {
scriptPath, err := resolvePicoclawStartScript()
if err != nil {
return "", newPicoclawError(CodeRuntimeUnavailable, err.Error())
}
command := scriptPath + " onboard"
ctx, cancel := context.WithTimeout(context.Background(), picoclawOnboardTimeout)
defer cancel()
output, execErr := exec.CommandContext(ctx, "sh", "-c", command).CombinedOutput()
trimmedOutput := strings.TrimSpace(string(output))
if execErr != nil {
if trimmedOutput == "" {
trimmedOutput = execErr.Error()
}
return trimmedOutput, newPicoclawError(CodeRuntimeUnavailable, "failed to initialize picoclaw config")
}
return trimmedOutput, nil
}
func isPicoclawInstalled() (bool, error) {
info, err := os.Stat(picoclawBinaryPath)
if err == nil {
return !info.IsDir(), nil
}
if os.IsNotExist(err) {
return false, nil
}
return false, err
}
func isRuntimeRunning() (bool, error) {
binName := filepath.Base(picoclawBinaryPath)
if binName == "" || binName == "." || binName == string(filepath.Separator) {
return false, fmt.Errorf("invalid picoclaw binary path: %s", picoclawBinaryPath)
}
command := exec.Command("pidof", binName)
if err := command.Run(); err != nil {
if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 1 {
return false, nil
}
return false, err
}
return true, nil
}

View File

@@ -0,0 +1,173 @@
package picoclaw
import (
"encoding/base64"
"net/http"
"time"
"NanoKVM-Server/common"
"NanoKVM-Server/service/stream/mjpeg"
"github.com/gin-gonic/gin"
)
var screenshotRetryDelay = 100 * time.Millisecond
const (
screenshotRetryCount = 3
cachedFrameMaxAge = 2 * time.Second
defaultPicoclawScreenshotWidth = 960
defaultPicoclawScreenshotHeight = 540
defaultPicoclawScreenshotQuality = 60
)
func (s *Service) Screenshot(c *gin.Context) {
var query ScreenshotQuery
if err := c.ShouldBindQuery(&query); err != nil {
writePicoclawError(c, newPicoclawError(CodeInvalidAction, "invalid screenshot query"))
return
}
sessionID, sessionErr := s.requireSessionID(c)
if sessionErr != nil {
writePicoclawError(c, sessionErr)
return
}
releaseAfter, lockErr := s.lock.AcquireTemporary(sessionID)
if lockErr != nil {
writePicoclawError(c, lockErr)
return
}
if releaseAfter {
defer s.lock.Release(sessionID)
}
data, meta, err := s.captureScreenshot(query)
if err != nil {
writePicoclawError(c, err)
return
}
if query.Format == "base64" {
meta.ImageBase64 = base64.StdEncoding.EncodeToString(data)
writeSuccess(c, meta)
return
}
c.Data(http.StatusOK, "image/jpeg", data)
}
func (s *Service) captureScreenshot(query ScreenshotQuery) ([]byte, ScreenshotMeta, *PicoclawError) {
width, height, quality := resolveScreenshotRequest(query)
if canUseCachedFrame(query) {
if frame, ok := mjpeg.GetLatestFrame(); ok && time.Since(frame.CapturedAt) <= cachedFrameMaxAge {
return frame.Data, ScreenshotMeta{
SourceWidth: frame.Width,
SourceHeight: frame.Height,
CaptureWidth: frame.Width,
CaptureHeight: frame.Height,
Format: "jpeg",
}, nil
}
}
screen := common.GetScreen()
common.CheckScreen()
for attempt := 0; attempt < screenshotRetryCount; attempt++ {
data, result := s.vision.ReadMjpeg(width, height, quality)
switch {
case result == 5:
if attempt < screenshotRetryCount-1 {
time.Sleep(screenshotRetryDelay)
continue
}
return nil, ScreenshotMeta{}, newPicoclawError(CodeScreenshotNoSignal, "no HDMI signal or frame unavailable")
case result < 0 || len(data) == 0:
return nil, ScreenshotMeta{}, newPicoclawError(CodeScreenshotFailed, "failed to capture screenshot")
default:
return data, ScreenshotMeta{
SourceWidth: screen.Width,
SourceHeight: screen.Height,
CaptureWidth: width,
CaptureHeight: height,
Format: "jpeg",
}, nil
}
}
return nil, ScreenshotMeta{}, newPicoclawError(CodeScreenshotFailed, "failed to capture screenshot")
}
func canUseCachedFrame(query ScreenshotQuery) bool {
return query.Width == 0 && query.Height == 0 && query.Quality == 0
}
func resolveScreenshotRequest(query ScreenshotQuery) (uint16, uint16, uint16) {
screen := common.GetScreen()
width := screen.Width
height := screen.Height
quality := screen.Quality
if query.Format == "base64" {
width, height = fitWithinBounds(width, height, defaultPicoclawScreenshotWidth, defaultPicoclawScreenshotHeight)
if quality == 0 || quality > defaultPicoclawScreenshotQuality {
quality = defaultPicoclawScreenshotQuality
}
}
width, height = applyRequestedDimensions(width, height, query.Width, query.Height)
if query.Quality > 0 {
quality = query.Quality
}
return width, height, quality
}
func applyRequestedDimensions(defaultWidth uint16, defaultHeight uint16, requestedWidth uint16, requestedHeight uint16) (uint16, uint16) {
switch {
case requestedWidth > 0 && requestedHeight > 0:
return requestedWidth, requestedHeight
case requestedWidth > 0:
return fitWithinBounds(defaultWidth, defaultHeight, requestedWidth, 0)
case requestedHeight > 0:
return fitWithinBounds(defaultWidth, defaultHeight, 0, requestedHeight)
default:
return defaultWidth, defaultHeight
}
}
func fitWithinBounds(sourceWidth uint16, sourceHeight uint16, maxWidth uint16, maxHeight uint16) (uint16, uint16) {
if sourceWidth == 0 || sourceHeight == 0 {
return sourceWidth, sourceHeight
}
if maxWidth == 0 && maxHeight == 0 {
return sourceWidth, sourceHeight
}
width := int(sourceWidth)
height := int(sourceHeight)
limitedWidth := width
limitedHeight := height
if maxWidth > 0 && limitedWidth > int(maxWidth) {
limitedWidth = int(maxWidth)
limitedHeight = height * limitedWidth / width
}
if maxHeight > 0 && limitedHeight > int(maxHeight) {
limitedHeight = int(maxHeight)
limitedWidth = width * limitedHeight / height
}
if limitedWidth <= 0 {
limitedWidth = 1
}
if limitedHeight <= 0 {
limitedHeight = 1
}
return uint16(limitedWidth), uint16(limitedHeight)
}

View File

@@ -0,0 +1,385 @@
package picoclaw
import (
"net"
"net/url"
"os"
"sync"
"time"
"NanoKVM-Server/common"
"NanoKVM-Server/service/hid"
"github.com/gin-gonic/gin"
)
const sessionIDHeader = "X-PicoClaw-Session-ID"
var (
configStoreOnce sync.Once
configStore *ConfigStore
runtimeStoreOnce sync.Once
runtimeStore *RuntimeStore
probeLoopOnce sync.Once
)
const runtimeStatusRefreshInterval = 2 * time.Second
func NewService() *Service {
return &Service{
vision: common.GetKvmVision(),
hid: hid.GetHid(),
config: getConfigStore(),
lock: GetSessionLock(),
runtime: getRuntimeStore(),
}
}
func getConfigStore() *ConfigStore {
configStoreOnce.Do(func() {
configStore = &ConfigStore{
config: defaultConfig(),
}
})
return configStore
}
func getRuntimeStore() *RuntimeStore {
runtimeStoreOnce.Do(func() {
runtimeStore = &RuntimeStore{
status: RuntimeStatus{
Ready: false,
Installed: false,
Installing: false,
InstallProgress: 0,
InstallPath: picoclawBinaryPath,
ModelConfigured: false,
Status: "checking",
},
}
})
return runtimeStore
}
func (s *Service) GetRuntimeStatus(c *gin.Context) {
s.startRuntimeProbeLoop()
status := s.runtime.Get()
if shouldRefreshRuntimeStatus(status) {
_ = s.ensureRuntimeReady()
status = s.runtime.Get()
}
if installed, ok := picoclawInstalledState(); ok {
status.Installed = installed
s.runtime.Update(func(current *RuntimeStatus) {
current.Installed = installed
})
}
writeSuccess(c, withAgentProfile(status))
}
func (s *Service) GetRuntimeSession(c *gin.Context) {
writeSuccess(c, gin.H{
"current_session": s.lock.Owner(),
"checked_at": time.Now(),
})
}
func (s *Service) requireSessionID(c *gin.Context) (string, *PicoclawError) {
sessionID := c.GetHeader(sessionIDHeader)
if sessionID == "" {
return "", newPicoclawError(CodeSessionIDMissing, "missing X-PicoClaw-Session-ID")
}
return sessionID, nil
}
func (s *ConfigStore) Get() Config {
s.mu.RLock()
defer s.mu.RUnlock()
return s.config
}
func (s *ConfigStore) Set(cfg Config) {
s.mu.Lock()
defer s.mu.Unlock()
s.config = cfg
}
func (s *RuntimeStore) Get() RuntimeStatus {
s.mu.RLock()
defer s.mu.RUnlock()
return s.status
}
func (s *RuntimeStore) Set(status RuntimeStatus) {
s.mu.Lock()
defer s.mu.Unlock()
status.InstallPath = picoclawBinaryPath
s.status = status
}
func (s *RuntimeStore) Update(update func(*RuntimeStatus)) {
s.mu.Lock()
defer s.mu.Unlock()
update(&s.status)
s.status.InstallPath = picoclawBinaryPath
}
func (s *RuntimeStore) UpdateInstallStatus(update func(*RuntimeStatus)) {
s.mu.Lock()
defer s.mu.Unlock()
update(&s.status)
s.status.InstallPath = picoclawBinaryPath
}
func (s *Service) ensureRuntimeReady() *PicoclawError {
currentStatus := s.runtime.Get()
if currentStatus.Installing {
return newPicoclawError(CodeRuntimeUnavailable, "picoclaw installation is in progress")
}
installed, statErr := isPicoclawInstalled()
if statErr != nil {
s.runtime.Set(RuntimeStatus{
Ready: false,
Installed: false,
Installing: false,
InstallProgress: 0,
InstallPath: picoclawBinaryPath,
Status: "not_installed",
LastError: statErr.Error(),
CheckedAt: time.Now(),
CurrentSession: s.lock.Owner(),
})
return newPicoclawError(CodeRuntimeUnavailable, "failed to check picoclaw installation")
}
if !installed {
s.runtime.Set(RuntimeStatus{
Ready: false,
Installed: false,
Installing: false,
InstallProgress: 0,
InstallPath: picoclawBinaryPath,
ModelConfigured: false,
Status: "not_installed",
CheckedAt: time.Now(),
CurrentSession: s.lock.Owner(),
})
return newPicoclawError(CodeRuntimeUnavailable, "picoclaw is not installed")
}
configPath, pathErr := resolvePicoclawConfigPath()
if pathErr != nil {
s.runtime.Set(RuntimeStatus{
Ready: false,
Installed: true,
Installing: false,
InstallProgress: 0,
InstallPath: picoclawBinaryPath,
ModelConfigured: false,
Status: "model_not_configured",
LastError: pathErr.Error(),
CheckedAt: time.Now(),
CurrentSession: s.lock.Owner(),
})
return newPicoclawError(CodeRuntimeUnavailable, "picoclaw model is not configured")
}
if _, err := os.Stat(configPath); err != nil {
if os.IsNotExist(err) {
if _, onboardErr := runPicoclawOnboard(); onboardErr != nil {
s.runtime.Set(RuntimeStatus{
Ready: false,
Installed: true,
Installing: false,
InstallProgress: 0,
InstallPath: picoclawBinaryPath,
ModelConfigured: false,
Status: "model_not_configured",
LastError: onboardErr.Message,
CheckedAt: time.Now(),
CurrentSession: s.lock.Owner(),
})
return newPicoclawError(CodeRuntimeUnavailable, "picoclaw model is not configured")
}
if _, statErr := os.Stat(configPath); statErr == nil {
goto configReady
}
s.runtime.Set(RuntimeStatus{
Ready: false,
Installed: true,
Installing: false,
InstallProgress: 0,
InstallPath: picoclawBinaryPath,
ModelConfigured: false,
Status: "model_not_configured",
CheckedAt: time.Now(),
CurrentSession: s.lock.Owner(),
})
return newPicoclawError(CodeRuntimeUnavailable, "picoclaw model is not configured")
}
s.runtime.Set(RuntimeStatus{
Ready: false,
Installed: true,
Installing: false,
InstallProgress: 0,
InstallPath: picoclawBinaryPath,
ModelConfigured: false,
Status: "config_error",
ConfigError: err.Error(),
LastError: err.Error(),
CheckedAt: time.Now(),
CurrentSession: s.lock.Owner(),
})
return newPicoclawError(CodeRuntimeUnavailable, "gateway config is invalid")
}
configReady:
if syncErr := s.syncConfigFromPicoclaw(); syncErr != nil {
return syncErr
}
settings, settingsErr := loadPicoclawGatewaySettings()
if settingsErr != nil {
return newPicoclawError(CodeRuntimeUnavailable, settingsErr.Error())
}
if !settings.ModelConfigured {
s.runtime.Set(RuntimeStatus{
Ready: false,
Installed: true,
Installing: false,
InstallProgress: 0,
InstallPath: picoclawBinaryPath,
ModelConfigured: false,
ModelName: settings.TargetModelName,
Status: "model_not_configured",
CheckedAt: time.Now(),
CurrentSession: s.lock.Owner(),
})
return newPicoclawError(CodeRuntimeUnavailable, "picoclaw model is not configured")
}
cfg := s.config.Get()
parsed, err := url.Parse(cfg.GatewayURL)
if err != nil {
s.runtime.Set(RuntimeStatus{
Ready: false,
Installed: true,
Installing: false,
InstallProgress: 0,
InstallPath: picoclawBinaryPath,
ModelConfigured: true,
ModelName: settings.ModelName,
Status: "config_error",
ConfigError: "invalid gateway url",
LastError: err.Error(),
CheckedAt: time.Now(),
CurrentSession: s.lock.Owner(),
})
return newPicoclawError(CodeRuntimeUnavailable, "gateway config is invalid")
}
if parsed.Scheme != "ws" && parsed.Scheme != "wss" {
s.runtime.Set(RuntimeStatus{
Ready: false,
Installed: true,
Installing: false,
InstallProgress: 0,
InstallPath: picoclawBinaryPath,
ModelConfigured: true,
ModelName: settings.ModelName,
Status: "config_error",
ConfigError: "invalid gateway url scheme",
LastError: parsed.Scheme,
CheckedAt: time.Now(),
CurrentSession: s.lock.Owner(),
})
return newPicoclawError(CodeRuntimeUnavailable, "gateway config is invalid")
}
timeout := time.Duration(cfg.ConnectTimeoutMs) * time.Millisecond
hostPort, err := gatewayHostPort(parsed)
if err != nil {
s.runtime.Set(RuntimeStatus{
Ready: false,
Installed: true,
Installing: false,
InstallProgress: 0,
InstallPath: picoclawBinaryPath,
ModelConfigured: true,
ModelName: settings.ModelName,
Status: "config_error",
ConfigError: err.Error(),
LastError: err.Error(),
CheckedAt: time.Now(),
CurrentSession: s.lock.Owner(),
})
return newPicoclawError(CodeRuntimeUnavailable, "gateway config is invalid")
}
conn, err := net.DialTimeout("tcp", hostPort, timeout)
if err != nil {
s.runtime.Set(RuntimeStatus{
Ready: false,
Installed: true,
Installing: false,
InstallProgress: 0,
InstallPath: picoclawBinaryPath,
ModelConfigured: true,
ModelName: settings.ModelName,
Status: "unavailable",
LastError: err.Error(),
CheckedAt: time.Now(),
CurrentSession: s.lock.Owner(),
})
return newPicoclawError(CodeRuntimeUnavailable, "gateway is unavailable")
}
_ = conn.Close()
s.runtime.Set(RuntimeStatus{
Ready: true,
Installed: true,
Installing: false,
InstallProgress: 100,
InstallPath: picoclawBinaryPath,
ModelConfigured: true,
ModelName: settings.ModelName,
Status: "ready",
CheckedAt: time.Now(),
CurrentSession: s.lock.Owner(),
})
return nil
}
func (s *Service) startRuntimeProbeLoop() {
probeLoopOnce.Do(func() {
go func() {
ticker := time.NewTicker(30 * time.Second)
defer ticker.Stop()
for range ticker.C {
_ = s.ensureRuntimeReady()
}
}()
})
}
func shouldRefreshRuntimeStatus(status RuntimeStatus) bool {
if status.Installing {
return false
}
if status.CheckedAt.IsZero() {
return true
}
return time.Since(status.CheckedAt) >= runtimeStatusRefreshInterval
}
func picoclawInstalledState() (bool, bool) {
installed, err := isPicoclawInstalled()
if err != nil {
return false, false
}
return installed, true
}

View File

@@ -0,0 +1,52 @@
package picoclaw
import (
"os"
"path/filepath"
"NanoKVM-Server/service/hid"
log "github.com/sirupsen/logrus"
)
const picoclawMediaTempDirName = "picoclaw_media"
func ReleaseSession(sessionID string) {
GetSessionLock().Release(sessionID)
releaseAllHIDState()
}
func releaseAllHIDState() {
h := hid.GetHid()
h.WriteHid0([]byte{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00})
h.WriteHid1([]byte{0x00, 0x00, 0x00, 0x00})
h.WriteHid2([]byte{0x00, 0x00, 0x00, 0x00, 0x00, 0x00})
}
func (s *Service) releaseAllHIDState() {
s.hid.WriteHid0([]byte{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00})
s.hid.WriteHid1([]byte{0x00, 0x00, 0x00, 0x00})
s.hid.WriteHid2([]byte{0x00, 0x00, 0x00, 0x00, 0x00, 0x00})
}
func picoclawMediaTempDir() string {
return filepath.Join(os.TempDir(), picoclawMediaTempDirName)
}
func cleanupPicoclawMediaTempDir() {
mediaDir := picoclawMediaTempDir()
if _, err := os.Stat(mediaDir); err != nil {
if !os.IsNotExist(err) {
log.Warnf("failed to stat picoclaw media directory %s: %v", mediaDir, err)
}
return
}
if err := os.RemoveAll(mediaDir); err != nil {
log.Warnf("failed to remove picoclaw media directory %s: %v", mediaDir, err)
return
}
log.Infof("removed picoclaw media directory %s", mediaDir)
}

View File

@@ -0,0 +1,474 @@
package picoclaw
import (
"bufio"
"encoding/json"
"errors"
"net/http"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
)
const (
picoSessionPrefix = "agent:main:pico:direct:pico:"
sanitizedPicoSessionPrefix = "agent_main_pico_direct_pico_"
maxSessionJSONLLineSize = 10 * 1024 * 1024
maxSessionPreviewRunes = 60
defaultSessionLimit = 20
)
type sessionStoredMessage struct {
Role string `json:"role"`
Content string `json:"content"`
}
type sessionStoredFile struct {
Key string `json:"key"`
Messages []sessionStoredMessage `json:"messages"`
Summary string `json:"summary,omitempty"`
Created time.Time `json:"created"`
Updated time.Time `json:"updated"`
}
type sessionMetaFile struct {
Key string `json:"key"`
Summary string `json:"summary"`
Skip int `json:"skip"`
Count int `json:"count"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type sessionListItem struct {
ID string `json:"id"`
Title string `json:"title"`
Preview string `json:"preview"`
MessageCount int `json:"message_count"`
Created string `json:"created"`
Updated string `json:"updated"`
}
type sessionDetailMessage struct {
Role string `json:"role"`
Content string `json:"content"`
}
type sessionDetail struct {
ID string `json:"id"`
Messages []sessionDetailMessage `json:"messages"`
Summary string `json:"summary,omitempty"`
Created string `json:"created"`
Updated string `json:"updated"`
}
func resolvePicoclawWorkspacePath() (string, error) {
configPath, err := resolvePicoclawConfigPath()
if err != nil {
return "", err
}
if doc, err := loadPicoclawConfigDocument(); err == nil {
if workspace := expandPicoclawPath(doc.config.Agents.Defaults.Workspace); workspace != "" {
return workspace, nil
}
}
return filepath.Join(filepath.Dir(configPath), "workspace"), nil
}
func resolvePicoclawSessionsPath() (string, error) {
workspacePath, err := resolvePicoclawWorkspacePath()
if err != nil {
return "", err
}
return filepath.Join(workspacePath, "sessions"), nil
}
func sanitizeSessionKey(key string) string {
return strings.ReplaceAll(key, ":", "_")
}
func extractPicoSessionID(key string) (string, bool) {
if strings.HasPrefix(key, picoSessionPrefix) {
return strings.TrimPrefix(key, picoSessionPrefix), true
}
return "", false
}
func extractPicoSessionIDFromSanitizedKey(key string) (string, bool) {
if strings.HasPrefix(key, sanitizedPicoSessionPrefix) {
return strings.TrimPrefix(key, sanitizedPicoSessionPrefix), true
}
return "", false
}
func (s *Service) ListSessions(c *gin.Context) {
dir, err := resolvePicoclawSessionsPath()
if err != nil {
writePicoclawError(c, newPicoclawError(CodeRuntimeUnavailable, "failed to resolve sessions directory"))
return
}
entries, err := os.ReadDir(dir)
if err != nil {
if os.IsNotExist(err) {
writeSuccess(c, []sessionListItem{})
return
}
writePicoclawError(c, newPicoclawError(CodeRuntimeUnavailable, "failed to read sessions directory"))
return
}
items := make([]sessionListItem, 0)
seen := make(map[string]struct{})
for _, entry := range entries {
if entry.IsDir() {
continue
}
name := entry.Name()
var (
sessionID string
sess sessionStoredFile
loadErr error
ok bool
)
switch {
case strings.HasSuffix(name, ".jsonl"):
sessionID, ok = extractPicoSessionIDFromSanitizedKey(strings.TrimSuffix(name, ".jsonl"))
if !ok {
continue
}
sess, loadErr = readJSONLSession(dir, sessionID)
if loadErr == nil && isEmptySession(sess) {
continue
}
case strings.HasSuffix(name, ".meta.json"):
continue
case strings.HasSuffix(name, ".json"):
base := strings.TrimSuffix(name, ".json")
if _, statErr := os.Stat(filepath.Join(dir, base+".jsonl")); statErr == nil {
if jsonlSessionID, found := extractPicoSessionIDFromSanitizedKey(base); found {
if jsonlSess, jsonlErr := readJSONLSession(dir, jsonlSessionID); jsonlErr == nil && !isEmptySession(jsonlSess) {
continue
}
}
}
sess, loadErr = readLegacySession(dir, name)
if loadErr == nil && isEmptySession(sess) {
continue
}
if loadErr == nil {
sessionID, ok = extractPicoSessionID(sess.Key)
if !ok {
continue
}
}
default:
continue
}
if loadErr != nil {
continue
}
if _, exists := seen[sessionID]; exists {
continue
}
seen[sessionID] = struct{}{}
items = append(items, buildSessionListItem(sessionID, sess))
}
sort.Slice(items, func(i, j int) bool {
return items[i].Updated > items[j].Updated
})
offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0"))
limit, _ := strconv.Atoi(c.DefaultQuery("limit", strconv.Itoa(defaultSessionLimit)))
if offset < 0 {
offset = 0
}
if limit <= 0 {
limit = defaultSessionLimit
}
if offset >= len(items) {
writeSuccess(c, []sessionListItem{})
return
}
end := offset + limit
if end > len(items) {
end = len(items)
}
writeSuccess(c, items[offset:end])
}
func (s *Service) GetSession(c *gin.Context) {
sessionID := strings.TrimSpace(c.Param("id"))
if sessionID == "" {
writePicoclawError(c, newPicoclawError(CodeInvalidAction, "missing session id"))
return
}
dir, err := resolvePicoclawSessionsPath()
if err != nil {
writePicoclawError(c, newPicoclawError(CodeRuntimeUnavailable, "failed to resolve sessions directory"))
return
}
sess, err := readJSONLSession(dir, sessionID)
if err == nil && isEmptySession(sess) {
err = os.ErrNotExist
}
if err != nil {
if errors.Is(err, os.ErrNotExist) {
sess, err = readLegacySession(dir, sanitizeSessionKey(picoSessionPrefix+sessionID)+".json")
if err == nil && isEmptySession(sess) {
err = os.ErrNotExist
}
}
if err != nil {
sessionErr := newPicoclawError(CodeRuntimeUnavailable, "session not found")
sessionErr.StatusCode = http.StatusNotFound
writePicoclawError(c, sessionErr)
return
}
}
messages := make([]sessionDetailMessage, 0, len(sess.Messages))
for _, msg := range sess.Messages {
if (msg.Role == "user" || msg.Role == "assistant") && strings.TrimSpace(msg.Content) != "" {
messages = append(messages, sessionDetailMessage{
Role: msg.Role,
Content: msg.Content,
})
}
}
writeSuccess(c, sessionDetail{
ID: sessionID,
Messages: messages,
Summary: sess.Summary,
Created: sess.Created.Format(time.RFC3339),
Updated: sess.Updated.Format(time.RFC3339),
})
}
func (s *Service) DeleteSession(c *gin.Context) {
sessionID := strings.TrimSpace(c.Param("id"))
if sessionID == "" {
writePicoclawError(c, newPicoclawError(CodeInvalidAction, "missing session id"))
return
}
dir, err := resolvePicoclawSessionsPath()
if err != nil {
writePicoclawError(c, newPicoclawError(CodeRuntimeUnavailable, "failed to resolve sessions directory"))
return
}
base := filepath.Join(dir, sanitizeSessionKey(picoSessionPrefix+sessionID))
paths := []string{base + ".jsonl", base + ".meta.json", base + ".json"}
removed := false
for _, path := range paths {
if err := os.Remove(path); err != nil {
if os.IsNotExist(err) {
continue
}
writePicoclawError(c, newPicoclawError(CodeRuntimeUnavailable, "failed to delete session"))
return
}
removed = true
}
if !removed {
sessionErr := newPicoclawError(CodeRuntimeUnavailable, "session not found")
sessionErr.StatusCode = http.StatusNotFound
writePicoclawError(c, sessionErr)
return
}
writeSuccess(c, gin.H{
"id": sessionID,
"deleted": true,
})
}
func readLegacySession(dir, fileName string) (sessionStoredFile, error) {
data, err := os.ReadFile(filepath.Join(dir, fileName))
if err != nil {
return sessionStoredFile{}, err
}
var sess sessionStoredFile
if err := json.Unmarshal(data, &sess); err != nil {
return sessionStoredFile{}, err
}
return sess, nil
}
func readSessionMeta(path, sessionKey string) (sessionMetaFile, error) {
data, err := os.ReadFile(path)
if os.IsNotExist(err) {
return sessionMetaFile{Key: sessionKey}, nil
}
if err != nil {
return sessionMetaFile{}, err
}
var meta sessionMetaFile
if err := json.Unmarshal(data, &meta); err != nil {
return sessionMetaFile{}, err
}
if meta.Key == "" {
meta.Key = sessionKey
}
return meta, nil
}
func readSessionMessages(path string, skip int) ([]sessionStoredMessage, error) {
file, err := os.Open(path)
if err != nil {
return nil, err
}
defer file.Close()
messages := make([]sessionStoredMessage, 0)
scanner := bufio.NewScanner(file)
scanner.Buffer(make([]byte, 0, 64*1024), maxSessionJSONLLineSize)
seen := 0
for scanner.Scan() {
line := scanner.Bytes()
if len(line) == 0 {
continue
}
seen++
if seen <= skip {
continue
}
var msg sessionStoredMessage
if err := json.Unmarshal(line, &msg); err != nil {
continue
}
messages = append(messages, msg)
}
if err := scanner.Err(); err != nil {
return nil, err
}
return messages, nil
}
func readJSONLSession(dir, sessionID string) (sessionStoredFile, error) {
sessionKey := picoSessionPrefix + sessionID
base := filepath.Join(dir, sanitizeSessionKey(sessionKey))
jsonlPath := base + ".jsonl"
metaPath := base + ".meta.json"
meta, err := readSessionMeta(metaPath, sessionKey)
if err != nil {
return sessionStoredFile{}, err
}
messages, err := readSessionMessages(jsonlPath, meta.Skip)
if err != nil {
return sessionStoredFile{}, err
}
created := meta.CreatedAt
updated := meta.UpdatedAt
if created.IsZero() || updated.IsZero() {
if info, statErr := os.Stat(jsonlPath); statErr == nil {
if created.IsZero() {
created = info.ModTime()
}
if updated.IsZero() {
updated = info.ModTime()
}
}
}
return sessionStoredFile{
Key: meta.Key,
Messages: messages,
Summary: meta.Summary,
Created: created,
Updated: updated,
}, nil
}
func buildSessionListItem(sessionID string, sess sessionStoredFile) sessionListItem {
preview := ""
for _, msg := range sess.Messages {
if msg.Role == "user" && strings.TrimSpace(msg.Content) != "" {
preview = msg.Content
break
}
}
title := strings.TrimSpace(sess.Summary)
if title == "" {
title = preview
}
title = truncateRunes(title, maxSessionPreviewRunes)
preview = truncateRunes(preview, maxSessionPreviewRunes)
if preview == "" {
preview = "(empty)"
}
if title == "" {
title = preview
}
messageCount := 0
for _, msg := range sess.Messages {
if (msg.Role == "user" || msg.Role == "assistant") && strings.TrimSpace(msg.Content) != "" {
messageCount++
}
}
return sessionListItem{
ID: sessionID,
Title: title,
Preview: preview,
MessageCount: messageCount,
Created: sess.Created.Format(time.RFC3339),
Updated: sess.Updated.Format(time.RFC3339),
}
}
func isEmptySession(sess sessionStoredFile) bool {
return len(sess.Messages) == 0 && strings.TrimSpace(sess.Summary) == ""
}
func truncateRunes(s string, maxLen int) string {
if maxLen <= 0 {
return ""
}
runes := []rune(strings.TrimSpace(s))
if len(runes) <= maxLen {
return string(runes)
}
return string(runes[:maxLen]) + "..."
}

View File

@@ -0,0 +1,97 @@
package picoclaw
import (
"sync"
"time"
)
var (
sessionLockOnce sync.Once
sessionLock *SessionLock
)
type SessionLock struct {
mu sync.Mutex
ownerSessionID string
acquiredAt time.Time
expiresAt time.Time
}
func GetSessionLock() *SessionLock {
sessionLockOnce.Do(func() {
sessionLock = &SessionLock{}
})
return sessionLock
}
func (l *SessionLock) Ensure(sessionID string) *PicoclawError {
_, err := l.acquire(sessionID)
return err
}
func (l *SessionLock) AcquireTemporary(sessionID string) (bool, *PicoclawError) {
return l.acquire(sessionID)
}
func (l *SessionLock) acquire(sessionID string) (bool, *PicoclawError) {
if sessionID == "" {
return false, newPicoclawError(CodeSessionIDInvalid, "invalid PicoClaw session")
}
l.mu.Lock()
defer l.mu.Unlock()
if l.ownerSessionID == "" || l.ownerSessionID == sessionID {
acquired := l.ownerSessionID == ""
now := time.Now()
l.ownerSessionID = sessionID
if l.acquiredAt.IsZero() {
l.acquiredAt = now
}
l.expiresAt = now.Add(30 * time.Minute)
return acquired, nil
}
err := newPicoclawError(CodePicoclawLockHeld, "another PicoClaw session is running")
err.SessionID = l.ownerSessionID
return false, err
}
func (l *SessionLock) Release(sessionID string) bool {
l.mu.Lock()
defer l.mu.Unlock()
if l.ownerSessionID == "" {
return true
}
if sessionID != "" && l.ownerSessionID != sessionID {
return false
}
l.ownerSessionID = ""
l.acquiredAt = time.Time{}
l.expiresAt = time.Time{}
return true
}
func (l *SessionLock) ForceTakeover(sessionID string) {
l.mu.Lock()
defer l.mu.Unlock()
l.ownerSessionID = sessionID
l.acquiredAt = time.Now()
l.expiresAt = time.Now().Add(30 * time.Minute)
}
func (l *SessionLock) Owner() string {
l.mu.Lock()
defer l.mu.Unlock()
return l.ownerSessionID
}
func (l *SessionLock) BlocksManualInput() bool {
l.mu.Lock()
defer l.mu.Unlock()
return l.ownerSessionID != ""
}

View File

@@ -0,0 +1,94 @@
package picoclaw
import (
"sync"
"time"
"github.com/gorilla/websocket"
)
var (
sessionManagerOnce sync.Once
sessionManager *SessionManager
)
func GetSessionManager() *SessionManager {
sessionManagerOnce.Do(func() {
sessionManager = &SessionManager{
sessions: make(map[string]*GatewaySession),
}
})
return sessionManager
}
func (m *SessionManager) Register(sessionID string, downstream *websocket.Conn) (*GatewaySession, *PicoclawError) {
m.mu.Lock()
defer m.mu.Unlock()
if existing, ok := m.sessions[sessionID]; ok {
switch existing.State {
case SessionStateClosing, SessionStateClosed:
delete(m.sessions, sessionID)
default:
err := newPicoclawError(CodePicoclawLockHeld, "session is already connected")
err.SessionID = sessionID
return nil, err
}
}
now := time.Now()
session := &GatewaySession{
SessionID: sessionID,
State: SessionStateCreated,
Downstream: downstream,
CreatedAt: now,
UpdatedAt: now,
}
m.sessions[sessionID] = session
return session, nil
}
func (m *SessionManager) Get(sessionID string) (*GatewaySession, bool) {
m.mu.RLock()
defer m.mu.RUnlock()
session, ok := m.sessions[sessionID]
return session, ok
}
func (m *SessionManager) SetState(sessionID string, state SessionState) {
m.mu.Lock()
defer m.mu.Unlock()
if session, ok := m.sessions[sessionID]; ok {
session.State = state
session.UpdatedAt = time.Now()
}
}
func (m *SessionManager) AttachUpstream(sessionID string, upstream *websocket.Conn) {
m.mu.Lock()
defer m.mu.Unlock()
if session, ok := m.sessions[sessionID]; ok {
session.Upstream = upstream
session.UpdatedAt = time.Now()
}
}
func (m *SessionManager) AttachDownstream(sessionID string, downstream *websocket.Conn) {
m.mu.Lock()
defer m.mu.Unlock()
if session, ok := m.sessions[sessionID]; ok {
session.Downstream = downstream
session.UpdatedAt = time.Now()
}
}
func (m *SessionManager) Remove(sessionID string) {
m.mu.Lock()
defer m.mu.Unlock()
delete(m.sessions, sessionID)
}

View File

@@ -0,0 +1,217 @@
package picoclaw
import (
"encoding/json"
"reflect"
"strings"
"sync"
"time"
"github.com/gorilla/websocket"
)
type VisionReader interface {
ReadMjpeg(width uint16, height uint16, quality uint16) (data []byte, result int)
}
type HIDWriter interface {
WriteHid0(data []byte)
WriteHid1(data []byte)
WriteHid2(data []byte)
}
type Service struct {
vision VisionReader
hid HIDWriter
config *ConfigStore
lock *SessionLock
runtime *RuntimeStore
}
type ConfigStore struct {
mu sync.RWMutex
config Config
}
type RuntimeStore struct {
mu sync.RWMutex
status RuntimeStatus
}
type Config struct {
GatewayURL string `json:"gateway_url"`
ConnectTimeoutMs int `json:"connect_timeout_ms"`
ReadTimeoutMs int `json:"read_timeout_ms"`
WriteTimeoutMs int `json:"write_timeout_ms"`
PingIntervalMs int `json:"ping_interval_ms"`
MaxMessageBytes int `json:"max_message_bytes"`
AllowTokenQuery bool `json:"allow_token_query"`
Token string `json:"token,omitempty"`
}
type RuntimeStatus struct {
Ready bool `json:"ready"`
Installed bool `json:"installed"`
Installing bool `json:"installing"`
InstallProgress int `json:"install_progress,omitempty"`
InstallStage string `json:"install_stage,omitempty"`
InstallPath string `json:"install_path,omitempty"`
AgentProfile string `json:"agent_profile,omitempty"`
ModelConfigured bool `json:"model_configured"`
ModelName string `json:"model_name,omitempty"`
Status string `json:"status"`
ConfigError string `json:"config_error,omitempty"`
LastError string `json:"last_error,omitempty"`
CheckedAt time.Time `json:"checked_at,omitempty"`
CurrentSession string `json:"current_session,omitempty"`
}
type RuntimeStartResult struct {
Started bool `json:"started"`
Command string `json:"command"`
Output string `json:"output,omitempty"`
Status RuntimeStatus `json:"status"`
}
type RuntimeInstallResult struct {
Installed bool `json:"installed"`
Binary string `json:"binary"`
Download string `json:"download"`
Output string `json:"output,omitempty"`
Status RuntimeStatus `json:"status"`
}
type ScreenshotMeta struct {
ImageBase64 string `json:"image_base64,omitempty"`
SourceWidth uint16 `json:"source_width"`
SourceHeight uint16 `json:"source_height"`
CaptureWidth uint16 `json:"capture_width"`
CaptureHeight uint16 `json:"capture_height"`
Format string `json:"format"`
}
type ScreenshotQuery struct {
Format string `form:"format"`
Width uint16 `form:"width"`
Height uint16 `form:"height"`
Quality uint16 `form:"quality"`
}
type Point struct {
X *float64 `json:"x"`
Y *float64 `json:"y"`
}
type HotkeyKeys []string
func (k *HotkeyKeys) UnmarshalJSON(data []byte) error {
if string(data) == "null" {
*k = nil
return nil
}
var list []string
if err := json.Unmarshal(data, &list); err == nil {
*k = normalizeHotkeyKeys(list)
return nil
}
var csv string
if err := json.Unmarshal(data, &csv); err == nil {
if strings.TrimSpace(csv) == "" {
*k = nil
return nil
}
*k = normalizeHotkeyKeys(strings.Split(csv, ","))
return nil
}
return &json.UnmarshalTypeError{Value: "keys", Type: hotkeyKeysType}
}
var hotkeyKeysType = reflect.TypeOf("")
func normalizeHotkeyKeys(input []string) HotkeyKeys {
keys := make([]string, 0, len(input))
for _, item := range input {
trimmed := strings.TrimSpace(item)
if trimmed == "" {
continue
}
keys = append(keys, trimmed)
}
return HotkeyKeys(keys)
}
type Action struct {
Action string `json:"action"`
X *float64 `json:"x"`
Y *float64 `json:"y"`
From *Point `json:"from"`
To *Point `json:"to"`
Button string `json:"button"`
Text string `json:"text"`
Keys HotkeyKeys `json:"keys"`
Direction string `json:"direction"`
Amount int `json:"amount"`
DurationMs int `json:"duration_ms"`
}
type ActionBatch struct {
Actions []Action `json:"actions"`
}
type ActionResult struct {
Action string `json:"action"`
DurationMs int64 `json:"duration_ms"`
HIDWrites int `json:"hid_writes"`
ExecutedActions int `json:"executed_actions,omitempty"`
}
type cachedFrame struct {
data []byte
width uint16
height uint16
capturedAt time.Time
}
type SessionState string
const (
SessionStateCreated SessionState = "created"
SessionStateConnecting SessionState = "connecting"
SessionStateActive SessionState = "active"
SessionStateClosing SessionState = "closing"
SessionStateClosed SessionState = "closed"
)
const (
CloseCodePicoclawLockHeld = 4001
CloseCodeRuntimeUnavailable = 4002
CloseCodeAuthFailed = 4003
CloseCodePicoclawTakenOver = 4004
CloseCodeUpstreamClosed = 4005
)
type GatewaySession struct {
SessionID string
State SessionState
Downstream *websocket.Conn
Upstream *websocket.Conn
CreatedAt time.Time
UpdatedAt time.Time
closeOnce sync.Once
upstreamWriteMu sync.Mutex
downstreamWriteMu sync.Mutex
}
type SessionManager struct {
mu sync.RWMutex
sessions map[string]*GatewaySession
}
type LoadImageRequest struct {
Path string `json:"path"`
Prompt string `json:"prompt"`
Filename string `json:"filename"`
}

View File

@@ -8,6 +8,13 @@ import (
var streamer = NewStreamer()
type LatestFrame struct {
Data []byte
Width uint16
Height uint16
CapturedAt time.Time
}
func Connect(c *gin.Context) {
c.Header("Content-Type", "multipart/x-mixed-replace; boundary=frame")
c.Header("Cache-Control", "no-cache")
@@ -20,3 +27,15 @@ func Connect(c *gin.Context) {
<-c.Request.Context().Done()
}
func GetLatestFrame() (LatestFrame, bool) {
return streamer.getLatestFrame()
}
func EnableLatestFrameCache() {
streamer.enableLatestFrameCache()
}
func DisableLatestFrameCache() {
streamer.disableLatestFrameCache()
}

View File

@@ -14,9 +14,12 @@ import (
)
type Streamer struct {
mutex sync.RWMutex
clients map[*gin.Context]bool
running int32
mutex sync.RWMutex
clients map[*gin.Context]bool
running int32
frameMutex sync.RWMutex
latestFrame LatestFrame
cacheRefs int32
}
func NewStreamer() *Streamer {
@@ -86,6 +89,10 @@ func (s *Streamer) run() {
continue
}
if s.frameCacheEnabled() {
s.setLatestFrame(data, screen.Width, screen.Height)
}
clients := s.getClients()
for _, client := range clients {
if err := writeFrame(client, data); err != nil {
@@ -103,6 +110,71 @@ func (s *Streamer) run() {
}
}
func (s *Streamer) setLatestFrame(data []byte, width uint16, height uint16) {
frameCopy := append([]byte(nil), data...)
s.frameMutex.Lock()
defer s.frameMutex.Unlock()
s.latestFrame = LatestFrame{
Data: frameCopy,
Width: width,
Height: height,
CapturedAt: time.Now(),
}
}
func (s *Streamer) clearLatestFrame() {
s.frameMutex.Lock()
defer s.frameMutex.Unlock()
s.latestFrame = LatestFrame{}
}
func (s *Streamer) enableLatestFrameCache() {
atomic.AddInt32(&s.cacheRefs, 1)
}
func (s *Streamer) disableLatestFrameCache() {
for {
current := atomic.LoadInt32(&s.cacheRefs)
if current <= 0 {
return
}
if atomic.CompareAndSwapInt32(&s.cacheRefs, current, current-1) {
if current == 1 {
s.clearLatestFrame()
}
return
}
}
}
func (s *Streamer) frameCacheEnabled() bool {
return atomic.LoadInt32(&s.cacheRefs) > 0
}
func (s *Streamer) getLatestFrame() (LatestFrame, bool) {
if !s.frameCacheEnabled() {
return LatestFrame{}, false
}
s.frameMutex.RLock()
defer s.frameMutex.RUnlock()
if len(s.latestFrame.Data) == 0 {
return LatestFrame{}, false
}
return LatestFrame{
Data: append([]byte(nil), s.latestFrame.Data...),
Width: s.latestFrame.Width,
Height: s.latestFrame.Height,
CapturedAt: s.latestFrame.CapturedAt,
}, true
}
func writeFrame(c *gin.Context, data []byte) (err error) {
defer func() {
if r := recover(); r != nil {

View File

@@ -0,0 +1,104 @@
package vm
import (
"NanoKVM-Server/proto"
"fmt"
"os"
"path/filepath"
"github.com/gin-gonic/gin"
log "github.com/sirupsen/logrus"
)
const autostartDirectory = "/etc/kvm/autostart"
func (s *Service) GetAutostart(c *gin.Context) {
var rsp proto.Response
var files []string
err := filepath.Walk(autostartDirectory, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if !info.IsDir() {
files = append(files, info.Name())
}
return nil
})
if err != nil {
rsp.ErrRsp(c, -1, "get autostart directory fail")
return
}
rsp.OkRspWithData(c, &proto.GetAutostartRsp{
Files: files,
})
log.Debugf("get autostart total %d", len(files))
}
func (s *Service) UploadAutostart(c *gin.Context) {
var req proto.UploadAutostartReq
var rsp proto.Response
fileName := c.Param("name")
if err := proto.ParseFormRequest(c, &req); err != nil {
rsp.ErrRsp(c, -1, "parse form request fail")
return
}
if _, err := os.Stat(autostartDirectory); err != nil {
_ = os.MkdirAll(autostartDirectory, 0o755)
}
target := fmt.Sprintf("%s/%s", autostartDirectory, fileName)
f, err := os.OpenFile(target, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o755)
if err != nil {
rsp.ErrRsp(c, -1, "create file fail")
return
}
defer f.Close()
_, err = f.WriteString(req.Content)
if err != nil {
rsp.ErrRsp(c, -1, "write content fail")
return
}
rsp.OkRspWithData(c, fileName)
log.Debugf("upload autostart %s success", fileName)
}
func (s *Service) DeleteAutostart(c *gin.Context) {
var rsp proto.Response
fileName := c.Param("name")
file := fmt.Sprintf("%s/%s", autostartDirectory, fileName)
if err := os.Remove(file); err != nil {
log.Errorf("delete autostart file %s fail", fileName)
rsp.ErrRsp(c, -3, "remove file fail")
return
}
rsp.OkRsp(c)
log.Debugf("delete autostart %s success", fileName)
}
func (s *Service) GetAutostartContent(c *gin.Context) {
var rsp proto.Response
fileName := c.Param("name")
file := fmt.Sprintf("%s/%s", autostartDirectory, fileName)
content, err := os.ReadFile(file)
if err != nil {
rsp.ErrRsp(c, -1, "read file fail")
return
}
rsp.OkRspWithData(c, string(content))
log.Debugf("get autostart content %s success", fileName)
}

View File

@@ -1,11 +1,12 @@
package vm
import (
"NanoKVM-Server/config"
"fmt"
"os"
"strings"
"NanoKVM-Server/config"
"github.com/gin-gonic/gin"
log "github.com/sirupsen/logrus"
@@ -19,6 +20,7 @@ var imageVersionMap = map[string]string{
"2024-11-13-09-59-9c961a.img": "v1.3.0",
"2025-02-17-19-08-3649fe.img": "v1.4.0",
"2025-04-17-14-21-98d17d.img": "v1.4.1",
"2026-01-05-1_4_1.img": "v1.4.2",
}
func (s *Service) GetInfo(c *gin.Context) {

View File

@@ -50,6 +50,7 @@ func (s *Service) Terminal(c *gin.Context) {
defer func() {
_ = ptmx.Close()
_ = cmd.Process.Kill()
_ = cmd.Wait()
}()
go wsWrite(ws, ptmx)

122
server/service/ws/client.go Normal file
View File

@@ -0,0 +1,122 @@
package ws
import (
"encoding/json"
"time"
"NanoKVM-Server/service/hid"
"NanoKVM-Server/service/picoclaw"
"NanoKVM-Server/service/vm/jiggler"
"github.com/gorilla/websocket"
log "github.com/sirupsen/logrus"
)
const (
Heartbeat = iota
KeyboardEvent
MouseEvent
)
func NewClient(ws *websocket.Conn) *Client {
client := &Client{
ws: ws,
hid: hid.GetHid(),
keyboard: make(chan []byte, 200),
mouse: make(chan []byte, 200),
lastHeartbeat: time.Time{},
}
client.hid.Open()
return client
}
func (c *Client) Start() {
defer c.Close()
go c.hid.Keyboard(c.keyboard)
go c.hid.Mouse(c.mouse)
_ = c.Read()
}
func (c *Client) Read() error {
var zeroTime time.Time
_ = c.ws.SetReadDeadline(zeroTime)
for {
messageType, data, err := c.ws.ReadMessage()
if err != nil {
return err
}
if len(data) == 0 {
continue
}
log.Debugf("received message %d: %v", messageType, data)
switch data[0] {
case Heartbeat:
c.UpdateHeartbeat()
case KeyboardEvent:
if picoclaw.GetSessionLock().BlocksManualInput() {
log.Debug("manual keyboard input dropped while AI session holds control")
continue
}
writeQueue(c.keyboard, data[1:])
case MouseEvent:
if picoclaw.GetSessionLock().BlocksManualInput() {
log.Debug("manual mouse input dropped while AI session holds control")
continue
}
writeQueue(c.mouse, data[1:])
}
}
}
func (c *Client) Write(event string, data string) error {
message := &Message{
Type: event,
Data: data,
}
messageByte, err := json.Marshal(message)
if err != nil {
log.Errorf("failed to marshal message: %s", err)
return err
}
c.mutex.Lock()
defer c.mutex.Unlock()
_ = c.ws.SetWriteDeadline(time.Now().Add(10 * time.Second))
return c.ws.WriteMessage(websocket.TextMessage, messageByte)
}
func (c *Client) UpdateHeartbeat() {
c.mutex.Lock()
defer c.mutex.Unlock()
c.lastHeartbeat = time.Now()
}
func (c *Client) Close() {
_ = c.ws.Close()
closeQueue(c.keyboard)
closeQueue(c.mouse)
log.Debug("websocket disconnected")
}
func writeQueue(queue chan []byte, data []byte) {
queue <- data
jiggler.GetJiggler().Update()
}
func closeQueue(queue chan []byte) {
for range queue {
}
close(queue)
}

View File

@@ -0,0 +1,46 @@
package ws
import (
"sync"
"github.com/gorilla/websocket"
)
var (
globalManager *Manager
managerOnce sync.Once
)
func GetManager() *Manager {
managerOnce.Do(func() {
globalManager = &Manager{
clients: make(map[*websocket.Conn]*Client),
mutex: sync.RWMutex{},
}
})
return globalManager
}
func (m *Manager) AddClient(ws *websocket.Conn, client *Client) {
m.mutex.Lock()
defer m.mutex.Unlock()
m.clients[ws] = client
}
func (m *Manager) RemoveClient(ws *websocket.Conn) {
m.mutex.Lock()
defer m.mutex.Unlock()
delete(m.clients, ws)
}
func (m *Manager) GetClients() []*Client {
m.mutex.Lock()
defer m.mutex.Unlock()
clients := make([]*Client, 0, len(m.clients))
for _, c := range m.clients {
clients = append(clients, c)
}
return clients
}

View File

@@ -1,6 +0,0 @@
package ws
type Stream struct {
Type string `json:"type"`
State int `json:"state"`
}

View File

@@ -1,7 +1,41 @@
package ws
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
log "github.com/sirupsen/logrus"
)
type Service struct{}
var upgrader = websocket.Upgrader{
ReadBufferSize: 1024,
WriteBufferSize: 1024,
CheckOrigin: func(r *http.Request) bool {
return true
},
}
func NewService() *Service {
return &Service{}
}
func (s *Service) Connect(c *gin.Context) {
ws, err := upgrader.Upgrade(c.Writer, c.Request, nil)
if err != nil {
log.Errorf("create websocket failed: %s", err)
return
}
log.Debug("websocket connected")
client := NewClient(ws)
manager := GetManager()
manager.AddClient(ws, client)
defer manager.RemoveClient(ws)
client.Start()
}

View File

@@ -0,0 +1,29 @@
package ws
import (
"sync"
"time"
"github.com/gorilla/websocket"
"NanoKVM-Server/service/hid"
)
type Manager struct {
clients map[*websocket.Conn]*Client
mutex sync.RWMutex
}
type Client struct {
ws *websocket.Conn
hid *hid.Hid
keyboard chan []byte
mouse chan []byte
lastHeartbeat time.Time
mutex sync.Mutex
}
type Message struct {
Type string `json:"type"`
Data string `json:"data"`
}

View File

@@ -1,117 +0,0 @@
package ws
import (
"encoding/json"
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
log "github.com/sirupsen/logrus"
"NanoKVM-Server/service/hid"
"NanoKVM-Server/service/vm/jiggler"
)
const (
KeyboardEvent int = 1
MouseEvent int = 2
)
type WsClient struct {
conn *websocket.Conn
hid *hid.Hid
keyboard chan []int
mouse chan []int
}
var upgrader = websocket.Upgrader{
ReadBufferSize: 1024,
WriteBufferSize: 1024,
CheckOrigin: func(r *http.Request) bool {
return true
},
}
func (s *Service) Connect(c *gin.Context) {
conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
if err != nil {
log.Errorf("create websocket failed: %s", err)
return
}
log.Debug("websocket connected")
client := &WsClient{
hid: hid.GetHid(),
conn: conn,
keyboard: make(chan []int, 200),
mouse: make(chan []int, 200),
}
go client.Start()
}
func (c *WsClient) Start() {
defer c.Clean()
c.hid.Open()
go c.hid.Keyboard(c.keyboard)
go c.hid.Mouse(c.mouse)
_ = c.Read()
}
func (c *WsClient) Read() error {
var zeroTime time.Time
_ = c.conn.SetReadDeadline(zeroTime)
for {
_, message, err := c.conn.ReadMessage()
if err != nil {
return err
}
log.Debugf("received message: %s", message)
var event []int
err = json.Unmarshal(message, &event)
if err != nil {
log.Debugf("received invalid message: %s", message)
continue
}
if event[0] == KeyboardEvent {
c.keyboard <- event[1:]
} else if event[0] == MouseEvent {
c.mouse <- event[1:]
}
// update latest HID operation time
jiggler.GetJiggler().Update()
}
}
func (c *WsClient) Write(message []byte) error {
_ = c.conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
return c.conn.WriteMessage(websocket.TextMessage, message)
}
func (c *WsClient) Clean() {
_ = c.conn.Close()
go clearQueue(c.keyboard)
close(c.keyboard)
go clearQueue(c.mouse)
close(c.mouse)
c.hid.Close()
log.Debug("websocket disconnected")
}
func clearQueue(queue chan []int) {
for range queue {
}
}

View File

@@ -164,6 +164,7 @@ uint16_t hdmi_res_list[][2] = {
{1152, 864},
{1024, 768},
{800, 600},
{640, 480},
};
uint16_t hdmi_unsupported_res_list[][2] = {

View File

@@ -95,10 +95,11 @@ typedef struct {
int8_t type = -1; // cat /kvmapp/kvm/type
int8_t now_fps = -1; // cat /kvmapp/kvm/now_fps
int16_t qlty = -1; // cat /kvmapp/kvm/qlty
uint8_t oled_sleep_param = 0;
uint16_t oled_sleep_param = 0;
uint8_t oled_sleep_state = 0; // 0:wakeup; 1:sleep;
uint64_t oled_sleep_start = 0;
uint64_t ue_patch_state = 0;
} kvm_oled_state_t;
#endif // CONFIG_H_

File diff suppressed because it is too large Load Diff

View File

@@ -356,8 +356,8 @@ void OLED_ShowStringTurn(uint8_t x, uint8_t y, char *chr, uint8_t sizey);
void OLED_ShowStringtoend(uint8_t x, uint8_t y, char *chr, uint8_t sizey, uint8_t end);
void OLED_ShowString_AlignRight(uint8_t x_end, uint8_t y, char *chr, uint8_t size);
void OLED_ROW(uint8_t _EN);
void OLED_ShowKVMLogo();
void OLED_ShowLogo();
void OLED_ShowSipeedLogo();
void OLED_Showline();
void OLED_Showline_1();
void OLED_ShowIMG(uint8_t x,uint8_t y,char *chr,uint8_t width,uint8_t height);

View File

@@ -13,8 +13,8 @@ void kvm_init_cube_ui(void)
OLED_Clear();
// OLED_Revolve();
OLED_ShowKVMLogo();
OLED_ShowLogo();
OLED_ShowSipeedLogo();
OLED_ShowKVMState(HDMI_STATE, 0);
OLED_ShowKVMState(HID_STATE, 0);
OLED_ShowKVMState(ETH_STATE, 0);
@@ -37,7 +37,7 @@ void kvm_init_pcie_ui(void)
{
OLED_Revolve();
OLED_Showline_1();
OLED_ShowSipeedLogo();
OLED_ShowLogo();
OLED_ShowKVMState(HDMI_STATE, 0);
OLED_ShowKVMState(HID_STATE, 0);
OLED_ShowKVMState(ETH_STATE, 0);
@@ -209,7 +209,14 @@ void kvm_eth_state_disp(ip_addr_t _ip_type, uint8_t first_disp)
(_ip_type_old != _ip_type) ||
first_disp || ip_changed(ETH_IP))
{
kvm_oled_state.eth_state = kvm_sys_state.eth_state;
// ensure eth_addr is updated
if (kvm_sys_state.eth_state >= 2) {
if (kvm_sys_state.eth_addr[0] != 0)
kvm_oled_state.eth_state = kvm_sys_state.eth_state;
} else {
kvm_oled_state.eth_state = kvm_sys_state.eth_state;
}
_ip_type_old = _ip_type;
switch(kvm_oled_state.eth_state){
case -1:
@@ -412,8 +419,15 @@ void show_wifi_config_ip(void)
{
OLED_Clear();
get_ip_addr(WiFi_IP);
OLED_ShowString(0, 1, "Config IP:", 8);
OLED_ShowString_AlignRight(63, 2, (char*)kvm_sys_state.wifi_addr, 4);
OLED_ShowString(1, 0, "Config URL", 8);
OLED_ShowString_AlignRight(63, 1, "----------------", 4);
// OLED_ShowString_AlignRight(63, 2, (char*)kvm_sys_state.wifi_addr, 4);
static char wifi_addr_with_path[30];
static char wifi_addr_with_key[30];
sprintf(wifi_addr_with_path, "%s/#/", kvm_sys_state.wifi_addr);
sprintf(wifi_addr_with_key, "WIFI?P=%s", kvm_sys_state.wifi_ap_pass);
OLED_ShowString_AlignRight(63, 2, wifi_addr_with_path, 4);
OLED_ShowString_AlignRight(63, 3, wifi_addr_with_key, 4);
}
void show_wifi_config_QR(void)
@@ -421,8 +435,7 @@ void show_wifi_config_QR(void)
static char cmd[70];
OLED_Clear();
get_ip_addr(WiFi_IP);
printf("http://%s/#/wifi\n", kvm_sys_state.wifi_addr);
sprintf(cmd, "http://%s/#/wifi", kvm_sys_state.wifi_addr);
sprintf(cmd, "http://%s/#/WIFI?P=%s", kvm_sys_state.wifi_addr, kvm_sys_state.wifi_ap_pass);
qrencode(cmd);
}

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