Skip to content

Commit f6f01f2

Browse files
committed
fix: review changes
Signed-off-by: llogen <christoph.lange@blindspot.software>
1 parent 30b00e0 commit f6f01f2

14 files changed

Lines changed: 532 additions & 271 deletions

pkg/module/pikvm/README.md

Lines changed: 35 additions & 105 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,22 @@
22

33
This module provides comprehensive control of a DUT via a PiKVM device. It offers power management through ATX control, keyboard input simulation, and virtual media mounting capabilities.
44

5+
PiKVM documentation: https://docs.pikvm.org/
6+
7+
Compatibility: This module uses PiKVM's HTTP API endpoints (e.g. `/api/atx/*`, `/api/hid/*`, `/api/msd/*`, `/api/streamer/snapshot`). It is intended for PiKVM (kvmd/PiKVM OS) systems, and should work with other devices only if they expose a compatible API.
8+
59
## Features
610

711
### Power Management
812
Control the DUT's power state via ATX power and reset buttons:
913

14+
- Power commands use PiKVM's ATX control API:
15+
- `on` is idempotent (does nothing if already powered on)
16+
- `off` performs a graceful shutdown via power button press
17+
- `force-off` performs a hard power off via long press (5+ seconds)
18+
- `reset` triggers an ATX reset button press
19+
- `force-reset` triggers a hardware hot reset
20+
1021
```
1122
COMMANDS:
1223
on Power on (does nothing if already on)
@@ -20,16 +31,26 @@ COMMANDS:
2031
### Keyboard Control
2132
Send keyboard input to the DUT:
2233

34+
See [pikvm-key-strings.md](./pikvm-key-strings.md) for the full list of supported key strings.
35+
2336
```
2437
COMMANDS:
2538
type <text> Type a text string
2639
key <keyname> Send a single key (e.g., Enter, Escape, F12)
2740
key-combo <keys> Send key combination (e.g., Ctrl+Alt+Delete)
41+
42+
FLAGS (must come before the action):
43+
--delay <duration> Delay between key events for key-combo (default: 500ms)
2844
```
2945

3046
### Virtual Media
3147
Mount ISO images or disk images as virtual USB devices:
3248

49+
- `mount` uploads images to PiKVM's storage (with automatic space management)
50+
- `mount-url` instructs PiKVM to download the image directly from the URL
51+
- Old images are automatically deleted when storage space is needed
52+
- SHA256 hashing prevents duplicate uploads
53+
3354
```
3455
COMMANDS:
3556
mount <path> Mount an image file from the agent's filesystem
@@ -38,130 +59,39 @@ COMMANDS:
3859
media-status Show mounted media information
3960
```
4061

62+
### Screenshot
63+
Capture the current display output from PiKVM's video stream.
64+
4165
## Configuration Options
4266

43-
| Option | Type | Default | Description |
44-
| -------- | ------ | ------- | -------------------------------------------------------------------- |
45-
| host | string | - | Address of the PiKVM device (e.g., "192.168.1.100") |
46-
| user | string | admin | Username for authentication |
47-
| password | string | - | Password for authentication |
48-
| timeout | string | 10s | Timeout for HTTP requests (e.g., "10s", "30s") |
49-
| command | string | - | **Required**: Command type ("power", "keyboard", "media", "screenshot") |
67+
| Option | Type | Default | Description |
68+
| -------- | ------ | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
69+
| host | string | - | Address of the PiKVM device (e.g., "192.168.1.100") Defaults to HTTPS if no scheme is specified, HTTP can be used by explicitly specifying `http://` |
70+
| user | string | admin | Username for authentication |
71+
| password | string | - | Password for authentication |
72+
| timeout | string | 10s | Timeout for HTTP requests (e.g., "10s", "30s") |
73+
| mode | string | - | **Required**: Mode ("power", "keyboard", "media", "screenshot") |
5074

5175
⚠️ **Security Warning**: Passwords are stored in plaintext in the configuration file. This should only be used in trusted environments.
5276

53-
## API Endpoints Used
54-
55-
This module interacts with the following PiKVM API endpoints:
56-
57-
- `/api/atx` - Get ATX power status
58-
- `/api/atx/power` - Intelligent power management (on/off/off_hard/reset_hard)
59-
- `/api/atx/click` - ATX button control (reset)
60-
- `/api/hid/print` - Type text input
61-
- `/api/hid/events/send_key` - Send keyboard keys and combinations
62-
- `/api/msd` - Mass Storage Device (virtual media) status
63-
- `/api/msd/write` - Upload images to PiKVM storage
64-
- `/api/msd/write_remote` - Download images from URL to PiKVM
65-
- `/api/msd/set_params` - Configure virtual media parameters
66-
- `/api/msd/set_connected` - Mount/unmount media
67-
- `/api/msd/remove` - Delete images from storage
68-
- `/api/streamer/snapshot` - Capture screenshot
69-
7077
## Usage Examples
7178

7279
See [pikvm-example-cfg.yml](./pikvm-example-cfg.yml) for comprehensive configuration examples.
7380

7481
### Basic Power Control
75-
76-
```yaml
77-
version: 0
78-
devices:
79-
my-server:
80-
desc: "Server controlled via PiKVM"
81-
cmds:
82-
power:
83-
desc: "Power management: on|off|force-off|reset|force-reset|status"
84-
modules:
85-
- module: pikvm
86-
main: true
87-
options:
88-
host: https://pikvm.local
89-
user: admin
90-
password: admin
91-
command: power
92-
```
82+
See [pikvm-example-power.yml](./pikvm-example-power.yml).
9383

9484
### Boot Menu Access
95-
96-
```yaml
97-
keyboard:
98-
desc: "Keyboard control: type <text>|key <keyname>|key-combo <combo>"
99-
modules:
100-
- module: pikvm
101-
main: true
102-
options:
103-
host: https://pikvm.local
104-
user: admin
105-
password: admin
106-
command: keyboard
107-
108-
# Usage: dutctl my-server keyboard key F12
109-
```
85+
See [pikvm-example-keyboard.yml](./pikvm-example-keyboard.yml).
11086

11187
### ISO Mounting
112-
113-
```yaml
114-
media:
115-
desc: "Virtual media control: mount <path>|mount-url <url>|unmount|media-status"
116-
modules:
117-
- module: pikvm
118-
main: true
119-
options:
120-
host: https://pikvm.local
121-
user: admin
122-
password: admin
123-
command: media
124-
125-
# Usage: dutctl my-server media mount-url https://releases.ubuntu.com/22.04/ubuntu-22.04-live-server-amd64.iso
126-
```
88+
See [pikvm-example-media.yml](./pikvm-example-media.yml).
12789

12890
### Screenshot Capture
129-
130-
```yaml
131-
screenshot:
132-
desc: "Capture a screenshot from PiKVM"
133-
modules:
134-
- module: pikvm
135-
main: true
136-
options:
137-
host: https://pikvm.local
138-
user: admin
139-
password: admin
140-
command: screenshot
141-
142-
# Usage: dutctl my-server screenshot
143-
# The screenshot will be saved to the current directory
144-
```
91+
See [pikvm-example-screenshot.yml](./pikvm-example-screenshot.yml).
14592

14693
## Requirements
14794

14895
- PiKVM device with API access enabled
14996
- Network connectivity between dutagent and PiKVM
15097
- Valid authentication credentials
151-
152-
## Notes
153-
154-
- The module defaults to HTTPS if no scheme is specified in the host
155-
- HTTP can be used by explicitly specifying `http://` in the host
156-
- Power commands use intelligent API:
157-
- `on` - Does nothing if already powered on (idempotent)
158-
- `off` - Graceful shutdown via power button press
159-
- `force-off` - Hard power off via long press (5+ seconds)
160-
- `reset` - Reset button press
161-
- `force-reset` - Hardware hot reset
162-
- Virtual media:
163-
- `mount` uploads images to PiKVM's storage (with automatic space management)
164-
- `mount-url` instructs PiKVM to download the image directly from the URL
165-
- Old images are automatically deleted when storage space is needed
166-
- SHA256 hashing prevents duplicate uploads
167-
- Screenshot functionality captures the current display output from PiKVM's video stream

pkg/module/pikvm/keyboard.go

Lines changed: 70 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -16,30 +16,84 @@ import (
1616
"github.com/BlindspotSoftware/dutctl/pkg/module"
1717
)
1818

19+
// Keyboard command constants.
20+
const (
21+
typeCmd = "type"
22+
key = "key"
23+
keyCombo = "key-combo"
24+
)
25+
1926
const (
20-
keyDelay = 500 * time.Millisecond
2127
maxTextLength = 1024
2228
minComboKeys = 2
2329
invalidCharNul = "\x00"
2430
invalidCharNewline = "\n"
2531
invalidCharReturn = "\r"
32+
defaultKeyDelay = 500 * time.Millisecond
2633
)
2734

35+
func parseKeyboardFlags(args []string) (delay time.Duration, remaining []string, err error) {
36+
delay = defaultKeyDelay
37+
38+
for len(args) > 0 {
39+
arg := args[0]
40+
if !strings.HasPrefix(arg, "--") {
41+
break
42+
}
43+
44+
if strings.HasPrefix(arg, "--delay=") {
45+
value := strings.TrimPrefix(arg, "--delay=")
46+
parsed, parseErr := time.ParseDuration(value)
47+
if parseErr != nil || parsed <= 0 {
48+
return 0, nil, fmt.Errorf("invalid --delay value %q", value)
49+
}
50+
delay = parsed
51+
args = args[1:]
52+
continue
53+
}
54+
55+
if arg == "--delay" {
56+
if len(args) < 2 {
57+
return 0, nil, fmt.Errorf("--delay requires a duration value")
58+
}
59+
value := args[1]
60+
parsed, parseErr := time.ParseDuration(value)
61+
if parseErr != nil || parsed <= 0 {
62+
return 0, nil, fmt.Errorf("invalid --delay value %q", value)
63+
}
64+
delay = parsed
65+
args = args[2:]
66+
continue
67+
}
68+
69+
return 0, nil, fmt.Errorf("unknown flag %q", arg)
70+
}
71+
72+
return delay, args, nil
73+
}
74+
2875
// handleKeyboardCommandRouter routes keyboard commands based on the first argument.
2976
func (p *PiKVM) handleKeyboardCommandRouter(ctx context.Context, s module.Session, args []string) error {
30-
if len(args) == 0 {
77+
delay, remaining, err := parseKeyboardFlags(args)
78+
if err != nil {
79+
s.Printf("Error: %v\n", err)
80+
s.Println("Usage: keyboard [--delay <duration>] <action> ...")
81+
return nil
82+
}
83+
84+
if len(remaining) == 0 {
3185
s.Println("Keyboard command requires an action: type|key|key-combo")
3286

3387
return nil
3488
}
3589

36-
command := strings.ToLower(args[0])
90+
command := strings.ToLower(remaining[0])
3791

38-
return p.handleKeyboardCommand(ctx, s, command, args)
92+
return p.handleKeyboardCommand(ctx, s, command, remaining, delay)
3993
}
4094

4195
// handleKeyboardCommand dispatches keyboard-related commands.
42-
func (p *PiKVM) handleKeyboardCommand(ctx context.Context, s module.Session, command string, args []string) error {
96+
func (p *PiKVM) handleKeyboardCommand(ctx context.Context, s module.Session, command string, args []string, delay time.Duration) error {
4397
switch command {
4498
case typeCmd:
4599
if len(args) < minArgsRequired {
@@ -64,7 +118,7 @@ func (p *PiKVM) handleKeyboardCommand(ctx context.Context, s module.Session, com
64118
return nil
65119
}
66120

67-
return p.handleCombo(ctx, s, args[1])
121+
return p.handleCombo(ctx, s, args[1], delay)
68122
default:
69123
return fmt.Errorf("unknown keyboard action: %s (must be: type, key, key-combo)", command)
70124
}
@@ -77,10 +131,8 @@ func (p *PiKVM) handleType(ctx context.Context, s module.Session, text string) e
77131
}
78132

79133
// Check for invalid characters
80-
for _, c := range []string{invalidCharNul, invalidCharNewline, invalidCharReturn} {
81-
if strings.Contains(text, c) {
82-
return fmt.Errorf("text contains invalid character: %q", c)
83-
}
134+
if idx := strings.IndexAny(text, invalidCharNul+invalidCharNewline+invalidCharReturn); idx != -1 {
135+
return fmt.Errorf("text contains invalid character: %q", rune(text[idx]))
84136
}
85137

86138
// Limit text length
@@ -91,7 +143,7 @@ func (p *PiKVM) handleType(ctx context.Context, s module.Session, text string) e
91143
s.Printf("Typing text: %s\n", text)
92144

93145
// Use the /api/hid/print endpoint for text input
94-
resp, err := p.doRequest(ctx, http.MethodPost, "/api/hid/print?slow=true", bytes.NewBufferString(text), "text/plain")
146+
resp, err := p.doRequest(ctx, http.MethodPost, "/api/hid/print?slow=true", bytes.NewBufferString(text), "text/plain", requestOptions{})
95147
if err != nil {
96148
return fmt.Errorf("failed to type text: %v", err)
97149
}
@@ -122,7 +174,7 @@ func (p *PiKVM) handleKey(ctx context.Context, s module.Session, keyName string)
122174
return nil
123175
}
124176

125-
func (p *PiKVM) handleCombo(ctx context.Context, s module.Session, comboStr string) error {
177+
func (p *PiKVM) handleCombo(ctx context.Context, s module.Session, comboStr string, delay time.Duration) error {
126178
// Parse combo like "Ctrl+Alt+Delete" into array of keys
127179
keys := strings.Split(comboStr, "+")
128180
for idx := range keys {
@@ -145,7 +197,7 @@ func (p *PiKVM) handleCombo(ctx context.Context, s module.Session, comboStr stri
145197
}
146198

147199
// Execute the key combination
148-
err = p.executeKeyCombo(ctx, mappedModifiers, mainKeyMapped)
200+
err = p.executeKeyCombo(ctx, mappedModifiers, mainKeyMapped, delay)
149201
if err != nil {
150202
return err
151203
}
@@ -187,15 +239,15 @@ func (p *PiKVM) validateAndMapComboKeys(modifiers []string, mainKey string) (str
187239
}
188240

189241
// executeKeyCombo executes a key combination by pressing modifiers, main key, and releasing modifiers.
190-
func (p *PiKVM) executeKeyCombo(ctx context.Context, modifiers []string, mainKey string) error {
242+
func (p *PiKVM) executeKeyCombo(ctx context.Context, modifiers []string, mainKey string, delay time.Duration) error {
191243
// Press down all modifier keys
192244
for _, modifier := range modifiers {
193245
err := p.sendKeyRequest(ctx, fmt.Sprintf("/api/hid/events/send_key?key=%s&state=1", url.QueryEscape(modifier)))
194246
if err != nil {
195247
return err
196248
}
197249

198-
time.Sleep(keyDelay)
250+
time.Sleep(delay)
199251
}
200252

201253
// Press and release the main key
@@ -204,7 +256,7 @@ func (p *PiKVM) executeKeyCombo(ctx context.Context, modifiers []string, mainKey
204256
return err
205257
}
206258

207-
time.Sleep(keyDelay)
259+
time.Sleep(delay)
208260

209261
// Release all modifier keys in reverse order
210262
for idx := len(modifiers) - 1; idx >= 0; idx-- {
@@ -213,15 +265,15 @@ func (p *PiKVM) executeKeyCombo(ctx context.Context, modifiers []string, mainKey
213265
return err
214266
}
215267

216-
time.Sleep(keyDelay)
268+
time.Sleep(delay)
217269
}
218270

219271
return nil
220272
}
221273

222274
// sendKeyRequest sends a key request to PiKVM.
223275
func (p *PiKVM) sendKeyRequest(ctx context.Context, urlPath string) error {
224-
resp, err := p.doRequest(ctx, http.MethodPost, urlPath, nil, "")
276+
resp, err := p.doRequest(ctx, http.MethodPost, urlPath, nil, "", requestOptions{})
225277
if err != nil {
226278
return err
227279
}

0 commit comments

Comments
 (0)