@geekbeer/minion 1.5.0 → 1.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/minion-cli.sh +108 -23
- package/package.json +1 -1
- package/routes/commands.js +1 -50
- package/routes/index.js +0 -1
- package/routes/terminal.js +79 -13
- package/server.js +3 -72
package/minion-cli.sh
CHANGED
|
@@ -4,16 +4,17 @@
|
|
|
4
4
|
# CLI tool for interacting with and setting up the minion agent
|
|
5
5
|
#
|
|
6
6
|
# Usage:
|
|
7
|
-
# minion-cli setup [options]
|
|
8
|
-
# minion-cli start
|
|
9
|
-
# minion-cli stop
|
|
10
|
-
# minion-cli restart
|
|
7
|
+
# sudo minion-cli setup [options] # Set up minion agent service (root)
|
|
8
|
+
# sudo minion-cli start # Start agent service (root)
|
|
9
|
+
# sudo minion-cli stop # Stop agent service (root)
|
|
10
|
+
# sudo minion-cli restart # Restart agent service (root)
|
|
11
11
|
# minion-cli status # Get current status
|
|
12
12
|
# minion-cli health # Health check
|
|
13
13
|
# minion-cli set-status busy "Running X" # Set status and task
|
|
14
14
|
# minion-cli set-status online # Set status only
|
|
15
15
|
#
|
|
16
16
|
# Setup options:
|
|
17
|
+
# --user <USERNAME> Target user for the agent (required when running as root)
|
|
17
18
|
# --hq-url <URL> HQ server URL (optional, omit for standalone mode)
|
|
18
19
|
# --minion-id <UUID> Minion ID (optional)
|
|
19
20
|
# --api-token <TOKEN> API token (optional)
|
|
@@ -24,13 +25,10 @@ set -euo pipefail
|
|
|
24
25
|
# Ensure HOME is set (cloud-init may not set it)
|
|
25
26
|
export HOME="${HOME:-$(getent passwd "$(whoami)" | cut -d: -f6)}"
|
|
26
27
|
|
|
27
|
-
# Detect target user
|
|
28
|
-
#
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
TARGET_HOME=$(getent passwd "$TARGET_USER" | cut -d: -f6)
|
|
32
|
-
RUN_AS="sudo -u $TARGET_USER"
|
|
33
|
-
elif [ "$(id -u)" -eq 0 ]; then
|
|
28
|
+
# Detect target user
|
|
29
|
+
# Resolved after argument parsing in do_setup (--user flag may override)
|
|
30
|
+
# For non-setup commands, use current user
|
|
31
|
+
if [ "$(id -u)" -eq 0 ]; then
|
|
34
32
|
TARGET_USER="root"
|
|
35
33
|
TARGET_HOME="$HOME"
|
|
36
34
|
RUN_AS=""
|
|
@@ -50,8 +48,16 @@ if [ "$(id -u)" -ne 0 ] && command -v sudo &>/dev/null; then
|
|
|
50
48
|
SUDO="sudo"
|
|
51
49
|
fi
|
|
52
50
|
|
|
51
|
+
# Require root for service management commands
|
|
52
|
+
require_root() {
|
|
53
|
+
if [ "$(id -u)" -ne 0 ]; then
|
|
54
|
+
echo "Error: 'minion-cli $1' requires root privileges."
|
|
55
|
+
echo "Usage: sudo minion-cli $1"
|
|
56
|
+
exit 1
|
|
57
|
+
fi
|
|
58
|
+
}
|
|
59
|
+
|
|
53
60
|
# Detect process manager: systemd or supervisord
|
|
54
|
-
# Detect process manager
|
|
55
61
|
# Check if systemd is actually running (not just installed)
|
|
56
62
|
# /run/systemd/system exists only when systemd is the init system
|
|
57
63
|
SERVICE_NAME="minion-agent"
|
|
@@ -68,10 +74,10 @@ svc_control() {
|
|
|
68
74
|
local action="$1"
|
|
69
75
|
case "$PROC_MGR" in
|
|
70
76
|
systemd)
|
|
71
|
-
|
|
77
|
+
systemctl "$action" "$SERVICE_NAME"
|
|
72
78
|
;;
|
|
73
79
|
supervisord)
|
|
74
|
-
|
|
80
|
+
supervisorctl "$action" "$SERVICE_NAME"
|
|
75
81
|
;;
|
|
76
82
|
*)
|
|
77
83
|
echo "Error: No supported process manager found (systemd or supervisord)"
|
|
@@ -89,12 +95,17 @@ do_setup() {
|
|
|
89
95
|
local HQ_URL=""
|
|
90
96
|
local MINION_ID=""
|
|
91
97
|
local API_TOKEN=""
|
|
98
|
+
local CLI_USER=""
|
|
92
99
|
local SETUP_TUNNEL=false
|
|
93
100
|
local ARGS_PROVIDED=false
|
|
94
101
|
|
|
95
102
|
# Parse arguments
|
|
96
103
|
while [[ $# -gt 0 ]]; do
|
|
97
104
|
case "$1" in
|
|
105
|
+
--user)
|
|
106
|
+
CLI_USER="$2"
|
|
107
|
+
shift 2
|
|
108
|
+
;;
|
|
98
109
|
--hq-url)
|
|
99
110
|
HQ_URL="$2"
|
|
100
111
|
ARGS_PROVIDED=true
|
|
@@ -120,12 +131,47 @@ do_setup() {
|
|
|
120
131
|
;;
|
|
121
132
|
*)
|
|
122
133
|
echo "Unknown option: $1"
|
|
123
|
-
echo "Usage: minion-cli setup [--hq-url <URL>] [--minion-id <UUID>] [--api-token <TOKEN>] [--setup-tunnel]"
|
|
134
|
+
echo "Usage: minion-cli setup --user <USERNAME> [--hq-url <URL>] [--minion-id <UUID>] [--api-token <TOKEN>] [--setup-tunnel]"
|
|
124
135
|
exit 1
|
|
125
136
|
;;
|
|
126
137
|
esac
|
|
127
138
|
done
|
|
128
139
|
|
|
140
|
+
# Resolve target user for setup
|
|
141
|
+
if [ -n "$CLI_USER" ]; then
|
|
142
|
+
# Explicit --user flag provided
|
|
143
|
+
TARGET_USER="$CLI_USER"
|
|
144
|
+
TARGET_HOME=$(getent passwd "$TARGET_USER" | cut -d: -f6)
|
|
145
|
+
if [ -z "$TARGET_HOME" ]; then
|
|
146
|
+
echo "ERROR: User '$TARGET_USER' does not exist on this system."
|
|
147
|
+
echo "Create the user first: sudo useradd -m -s /bin/bash $TARGET_USER"
|
|
148
|
+
exit 1
|
|
149
|
+
fi
|
|
150
|
+
if [ "$(id -u)" -eq 0 ] && [ "$TARGET_USER" != "root" ]; then
|
|
151
|
+
RUN_AS="sudo -u $TARGET_USER"
|
|
152
|
+
fi
|
|
153
|
+
elif [ "$(id -u)" -eq 0 ]; then
|
|
154
|
+
# Running as root without --user
|
|
155
|
+
if [ "$PROC_MGR" = "supervisord" ]; then
|
|
156
|
+
# Container environment (supervisord) - allow root for backward compatibility
|
|
157
|
+
echo " NOTE: Running as root in container environment (supervisord detected)"
|
|
158
|
+
TARGET_USER="root"
|
|
159
|
+
TARGET_HOME="$HOME"
|
|
160
|
+
RUN_AS=""
|
|
161
|
+
else
|
|
162
|
+
echo "ERROR: Running as root without --user flag is not supported."
|
|
163
|
+
echo ""
|
|
164
|
+
echo "Usage: sudo minion-cli setup --user <USERNAME> [--hq-url <URL>] ..."
|
|
165
|
+
echo ""
|
|
166
|
+
echo "The --user flag specifies which system user the minion agent will run as."
|
|
167
|
+
echo "This user must already exist. Example:"
|
|
168
|
+
echo " sudo useradd -m -s /bin/bash minion"
|
|
169
|
+
echo " sudo minion-cli setup --user minion --hq-url https://..."
|
|
170
|
+
exit 1
|
|
171
|
+
fi
|
|
172
|
+
fi
|
|
173
|
+
# Non-root case: TARGET_USER is already set to $(whoami)
|
|
174
|
+
|
|
129
175
|
# Preserve existing .env values if no arguments were provided (redeploy scenario)
|
|
130
176
|
if [ "$ARGS_PROVIDED" = false ] && [ -f /opt/minion-agent/.env ]; then
|
|
131
177
|
echo "Reading existing .env values (redeploy mode)..."
|
|
@@ -142,6 +188,7 @@ do_setup() {
|
|
|
142
188
|
echo "========================================="
|
|
143
189
|
echo " @geekbeer/minion Setup"
|
|
144
190
|
echo "========================================="
|
|
191
|
+
echo "User: $TARGET_USER ($TARGET_HOME)"
|
|
145
192
|
|
|
146
193
|
if [ -n "$HQ_URL" ]; then
|
|
147
194
|
echo "Mode: Connected to HQ ($HQ_URL)"
|
|
@@ -202,6 +249,7 @@ do_setup() {
|
|
|
202
249
|
# Step 3: Create config directory
|
|
203
250
|
echo "[3/${TOTAL_STEPS}] Creating config directory..."
|
|
204
251
|
$SUDO mkdir -p /opt/minion-agent
|
|
252
|
+
$SUDO chown "${TARGET_USER}:${TARGET_USER}" /opt/minion-agent 2>/dev/null || true
|
|
205
253
|
echo " -> /opt/minion-agent/ created"
|
|
206
254
|
|
|
207
255
|
# Step 4: Generate .env file
|
|
@@ -222,6 +270,7 @@ do_setup() {
|
|
|
222
270
|
|
|
223
271
|
ENV_CONTENT+="AGENT_PORT=3001\n"
|
|
224
272
|
ENV_CONTENT+="HEARTBEAT_INTERVAL=30\n"
|
|
273
|
+
ENV_CONTENT+="MINION_USER=${TARGET_USER}\n"
|
|
225
274
|
|
|
226
275
|
echo -e "$ENV_CONTENT" | $SUDO tee /opt/minion-agent/.env > /dev/null
|
|
227
276
|
echo " -> /opt/minion-agent/.env generated"
|
|
@@ -240,19 +289,39 @@ do_setup() {
|
|
|
240
289
|
|
|
241
290
|
case "$PROC_MGR" in
|
|
242
291
|
systemd)
|
|
292
|
+
# Create tmux-init service (ensures 'main' session exists)
|
|
293
|
+
$SUDO tee /etc/systemd/system/tmux-init.service > /dev/null <<TMUXEOF
|
|
294
|
+
[Unit]
|
|
295
|
+
Description=tmux session initialization (main)
|
|
296
|
+
After=network.target
|
|
297
|
+
|
|
298
|
+
[Service]
|
|
299
|
+
Type=oneshot
|
|
300
|
+
User=${TARGET_USER}
|
|
301
|
+
RemainAfterExit=yes
|
|
302
|
+
ExecStart=/bin/bash -c 'tmux has-session -t main 2>/dev/null || tmux new-session -d -s main; tmux set-option -g mouse on'
|
|
303
|
+
|
|
304
|
+
[Install]
|
|
305
|
+
WantedBy=multi-user.target
|
|
306
|
+
TMUXEOF
|
|
307
|
+
echo " -> /etc/systemd/system/tmux-init.service created"
|
|
308
|
+
|
|
309
|
+
# Create minion-agent service
|
|
243
310
|
$SUDO tee /etc/systemd/system/minion-agent.service > /dev/null <<SVCEOF
|
|
244
311
|
[Unit]
|
|
245
312
|
Description=Minion Agent API (@geekbeer/minion)
|
|
246
|
-
After=network.target
|
|
313
|
+
After=network.target tmux-init.service
|
|
314
|
+
Wants=tmux-init.service
|
|
247
315
|
|
|
248
316
|
[Service]
|
|
249
317
|
Type=simple
|
|
250
|
-
User
|
|
318
|
+
User=${TARGET_USER}
|
|
251
319
|
WorkingDirectory=/opt/minion-agent
|
|
252
320
|
ExecStart=/usr/bin/node ${SERVER_PATH}
|
|
253
321
|
Restart=always
|
|
254
322
|
RestartSec=10
|
|
255
323
|
EnvironmentFile=/opt/minion-agent/.env
|
|
324
|
+
Environment=MINION_USER=${TARGET_USER}
|
|
256
325
|
|
|
257
326
|
[Install]
|
|
258
327
|
WantedBy=multi-user.target
|
|
@@ -268,12 +337,20 @@ SVCEOF
|
|
|
268
337
|
[[ -z "$key" || "$key" == \#* ]] && continue
|
|
269
338
|
ENV_PAIRS+=("${key}=\"${value}\"")
|
|
270
339
|
done < /opt/minion-agent/.env
|
|
340
|
+
|
|
271
341
|
ENV_LINE+="$(IFS=,; echo "${ENV_PAIRS[*]}")"
|
|
272
342
|
|
|
343
|
+
# Build user line (omit for root)
|
|
344
|
+
local USER_LINE=""
|
|
345
|
+
if [ "$TARGET_USER" != "root" ]; then
|
|
346
|
+
USER_LINE="user=${TARGET_USER}"
|
|
347
|
+
fi
|
|
348
|
+
|
|
273
349
|
$SUDO tee /etc/supervisor/conf.d/minion-agent.conf > /dev/null <<SUPEOF
|
|
274
350
|
[program:minion-agent]
|
|
275
351
|
command=/usr/bin/node ${SERVER_PATH}
|
|
276
352
|
directory=/opt/minion-agent
|
|
353
|
+
${USER_LINE}
|
|
277
354
|
${ENV_LINE}
|
|
278
355
|
autorestart=true
|
|
279
356
|
priority=500
|
|
@@ -295,6 +372,8 @@ SUPEOF
|
|
|
295
372
|
case "$PROC_MGR" in
|
|
296
373
|
systemd)
|
|
297
374
|
$SUDO systemctl daemon-reload
|
|
375
|
+
$SUDO systemctl enable tmux-init
|
|
376
|
+
$SUDO systemctl start tmux-init
|
|
298
377
|
$SUDO systemctl enable minion-agent
|
|
299
378
|
$SUDO systemctl start minion-agent
|
|
300
379
|
;;
|
|
@@ -438,11 +517,13 @@ SUPEOF
|
|
|
438
517
|
echo " Setup Complete!"
|
|
439
518
|
echo "========================================="
|
|
440
519
|
echo ""
|
|
520
|
+
echo "Agent user: $TARGET_USER"
|
|
521
|
+
echo ""
|
|
441
522
|
echo "Useful commands:"
|
|
442
523
|
echo " minion-cli status # Agent status"
|
|
443
524
|
echo " minion-cli health # Health check"
|
|
444
|
-
echo " minion-cli restart
|
|
445
|
-
echo " minion-cli stop
|
|
525
|
+
echo " sudo minion-cli restart # Restart agent"
|
|
526
|
+
echo " sudo minion-cli stop # Stop agent"
|
|
446
527
|
if [ "$PROC_MGR" = "systemd" ]; then
|
|
447
528
|
echo " journalctl -u minion-agent -f # View logs"
|
|
448
529
|
else
|
|
@@ -487,16 +568,19 @@ case "${1:-}" in
|
|
|
487
568
|
;;
|
|
488
569
|
|
|
489
570
|
start)
|
|
571
|
+
require_root start
|
|
490
572
|
svc_control start
|
|
491
573
|
echo "minion-agent started ($PROC_MGR)"
|
|
492
574
|
;;
|
|
493
575
|
|
|
494
576
|
stop)
|
|
577
|
+
require_root stop
|
|
495
578
|
svc_control stop
|
|
496
579
|
echo "minion-agent stopped ($PROC_MGR)"
|
|
497
580
|
;;
|
|
498
581
|
|
|
499
582
|
restart)
|
|
583
|
+
require_root restart
|
|
500
584
|
svc_control restart
|
|
501
585
|
echo "minion-agent restarted ($PROC_MGR)"
|
|
502
586
|
;;
|
|
@@ -505,16 +589,17 @@ case "${1:-}" in
|
|
|
505
589
|
echo "Minion Agent CLI (@geekbeer/minion) v${CLI_VERSION}"
|
|
506
590
|
echo ""
|
|
507
591
|
echo "Usage:"
|
|
508
|
-
echo " minion-cli setup [options]
|
|
509
|
-
echo " minion-cli start
|
|
510
|
-
echo " minion-cli stop
|
|
511
|
-
echo " minion-cli restart
|
|
592
|
+
echo " sudo minion-cli setup [options] # Set up agent service (root)"
|
|
593
|
+
echo " sudo minion-cli start # Start agent service (root)"
|
|
594
|
+
echo " sudo minion-cli stop # Stop agent service (root)"
|
|
595
|
+
echo " sudo minion-cli restart # Restart agent service (root)"
|
|
512
596
|
echo " minion-cli status # Get current status"
|
|
513
597
|
echo " minion-cli health # Health check"
|
|
514
598
|
echo " minion-cli set-status <status> [task] # Set status and optional task"
|
|
515
599
|
echo " minion-cli --version # Show version"
|
|
516
600
|
echo ""
|
|
517
601
|
echo "Setup options:"
|
|
602
|
+
echo " --user <USERNAME> Target user for the agent (required when running as root)"
|
|
518
603
|
echo " --hq-url <URL> HQ server URL (optional)"
|
|
519
604
|
echo " --minion-id <UUID> Minion ID (optional)"
|
|
520
605
|
echo " --api-token <TOKEN> API token (optional)"
|
package/package.json
CHANGED
package/routes/commands.js
CHANGED
|
@@ -4,7 +4,6 @@
|
|
|
4
4
|
* Endpoints:
|
|
5
5
|
* - GET /api/commands - List available commands
|
|
6
6
|
* - POST /api/command - Execute a whitelisted command
|
|
7
|
-
* - POST /api/vnc-password - Change VNC password
|
|
8
7
|
*/
|
|
9
8
|
|
|
10
9
|
const { exec } = require('child_process')
|
|
@@ -12,7 +11,7 @@ const { promisify } = require('util')
|
|
|
12
11
|
const execAsync = promisify(exec)
|
|
13
12
|
|
|
14
13
|
const { verifyToken } = require('../lib/auth')
|
|
15
|
-
const {
|
|
14
|
+
const { detectProcessManager, buildAllowedCommands } = require('../lib/process-manager')
|
|
16
15
|
|
|
17
16
|
const PROC_MGR = detectProcessManager()
|
|
18
17
|
const ALLOWED_COMMANDS = buildAllowedCommands(PROC_MGR)
|
|
@@ -109,54 +108,6 @@ async function commandRoutes(fastify) {
|
|
|
109
108
|
}
|
|
110
109
|
})
|
|
111
110
|
|
|
112
|
-
// Change VNC password
|
|
113
|
-
fastify.post('/api/vnc-password', async (request, reply) => {
|
|
114
|
-
if (!verifyToken(request)) {
|
|
115
|
-
reply.code(401)
|
|
116
|
-
return { success: false, error: 'Unauthorized' }
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
const { password } = request.body || {}
|
|
120
|
-
|
|
121
|
-
if (!password || password.length < 1) {
|
|
122
|
-
reply.code(400)
|
|
123
|
-
return { success: false, error: 'password is required' }
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
console.log('[VNC] Changing VNC password')
|
|
127
|
-
|
|
128
|
-
try {
|
|
129
|
-
// Escape single quotes in password for shell safety
|
|
130
|
-
const escapedPassword = password.replace(/'/g, "'\\''")
|
|
131
|
-
|
|
132
|
-
// Use x11vnc to store the new password
|
|
133
|
-
await execAsync(
|
|
134
|
-
`x11vnc -storepasswd '${escapedPassword}' ~/.vnc/passwd`,
|
|
135
|
-
{ timeout: 10000 }
|
|
136
|
-
)
|
|
137
|
-
|
|
138
|
-
// Restart x11vnc to apply the new password
|
|
139
|
-
if (PROC_MGR === 'systemd') {
|
|
140
|
-
await execAsync(`${SUDO}systemctl restart x11vnc`, { timeout: 10000 })
|
|
141
|
-
} else if (PROC_MGR === 'supervisord') {
|
|
142
|
-
await execAsync(`${SUDO}supervisorctl restart x11vnc`, { timeout: 10000 })
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
console.log('[VNC] Password changed successfully')
|
|
146
|
-
|
|
147
|
-
return {
|
|
148
|
-
success: true,
|
|
149
|
-
message: 'VNC password changed successfully',
|
|
150
|
-
}
|
|
151
|
-
} catch (error) {
|
|
152
|
-
console.error(`[VNC] Failed to change password: ${error.message}`)
|
|
153
|
-
reply.code(500)
|
|
154
|
-
return {
|
|
155
|
-
success: false,
|
|
156
|
-
error: error.message,
|
|
157
|
-
}
|
|
158
|
-
}
|
|
159
|
-
})
|
|
160
111
|
}
|
|
161
112
|
|
|
162
113
|
/**
|
package/routes/index.js
CHANGED
|
@@ -13,7 +13,6 @@
|
|
|
13
13
|
* Commands (routes/commands.js)
|
|
14
14
|
* GET /api/commands - List available commands (auth required)
|
|
15
15
|
* POST /api/command - Execute a whitelisted command (auth required)
|
|
16
|
-
* POST /api/vnc-password - Change VNC password (auth required)
|
|
17
16
|
*
|
|
18
17
|
* Skills (routes/skills.js)
|
|
19
18
|
* GET /api/list-skills - List deployed skills (auth required)
|
package/routes/terminal.js
CHANGED
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
|
|
17
17
|
const { exec, spawn } = require('child_process')
|
|
18
18
|
const { promisify } = require('util')
|
|
19
|
+
const net = require('net')
|
|
19
20
|
const os = require('os')
|
|
20
21
|
const execAsync = promisify(exec)
|
|
21
22
|
|
|
@@ -38,16 +39,35 @@ const ttydProcesses = new Map()
|
|
|
38
39
|
const TTYD_BASE_PORT = 7682
|
|
39
40
|
|
|
40
41
|
/**
|
|
41
|
-
*
|
|
42
|
-
* @
|
|
42
|
+
* Check if a port is available by attempting to bind to it
|
|
43
|
+
* @param {number} port
|
|
44
|
+
* @returns {Promise<boolean>}
|
|
45
|
+
*/
|
|
46
|
+
function isPortAvailable(port) {
|
|
47
|
+
return new Promise((resolve) => {
|
|
48
|
+
const server = net.createServer()
|
|
49
|
+
server.once('error', () => resolve(false))
|
|
50
|
+
server.once('listening', () => {
|
|
51
|
+
server.close(() => resolve(true))
|
|
52
|
+
})
|
|
53
|
+
server.listen(port, '0.0.0.0')
|
|
54
|
+
})
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Find an available port for ttyd (checks OS-level availability)
|
|
59
|
+
* @returns {Promise<number>} Available port
|
|
43
60
|
*/
|
|
44
|
-
function findAvailablePort() {
|
|
61
|
+
async function findAvailablePort() {
|
|
45
62
|
const usedPorts = new Set([...ttydProcesses.values()].map(p => p.port))
|
|
46
63
|
let port = TTYD_BASE_PORT
|
|
47
|
-
while (
|
|
64
|
+
while (port < TTYD_BASE_PORT + 100) {
|
|
65
|
+
if (!usedPorts.has(port) && await isPortAvailable(port)) {
|
|
66
|
+
return port
|
|
67
|
+
}
|
|
48
68
|
port++
|
|
49
69
|
}
|
|
50
|
-
|
|
70
|
+
throw new Error('No available port found for ttyd')
|
|
51
71
|
}
|
|
52
72
|
|
|
53
73
|
/**
|
|
@@ -81,22 +101,34 @@ async function startTtydForSession(session) {
|
|
|
81
101
|
throw new Error('ttyd is not installed')
|
|
82
102
|
}
|
|
83
103
|
|
|
84
|
-
// Verify tmux session exists
|
|
104
|
+
// Verify tmux session exists; auto-create if missing
|
|
85
105
|
try {
|
|
86
106
|
await execAsync(`tmux has-session -t "${session}"`, {
|
|
87
107
|
timeout: 5000,
|
|
88
108
|
env: { ...process.env, HOME: homeDir },
|
|
89
109
|
})
|
|
90
110
|
} catch {
|
|
91
|
-
|
|
111
|
+
console.log(`[Terminal] Session '${session}' not found, auto-creating...`)
|
|
112
|
+
try {
|
|
113
|
+
await execAsync(`tmux new-session -d -s "${session}" -c "${homeDir}"`, {
|
|
114
|
+
timeout: 5000,
|
|
115
|
+
env: { ...process.env, HOME: homeDir },
|
|
116
|
+
})
|
|
117
|
+
console.log(`[Terminal] Auto-created session '${session}'`)
|
|
118
|
+
} catch (createErr) {
|
|
119
|
+
throw new Error(
|
|
120
|
+
`tmux session '${session}' not found and auto-creation failed: ${createErr.message}`
|
|
121
|
+
)
|
|
122
|
+
}
|
|
92
123
|
}
|
|
93
124
|
|
|
94
|
-
const port = findAvailablePort()
|
|
125
|
+
const port = await findAvailablePort()
|
|
95
126
|
|
|
96
127
|
const proc = spawn('ttyd', [
|
|
97
128
|
'-p', port.toString(),
|
|
98
129
|
'-t', 'fontSize=14',
|
|
99
130
|
'-t', 'theme={"background": "#1a1a2e", "foreground": "#eee"}',
|
|
131
|
+
'--',
|
|
100
132
|
'tmux', 'attach', '-t', session,
|
|
101
133
|
], {
|
|
102
134
|
env: { ...process.env, HOME: homeDir },
|
|
@@ -104,13 +136,18 @@ async function startTtydForSession(session) {
|
|
|
104
136
|
detached: false,
|
|
105
137
|
})
|
|
106
138
|
|
|
139
|
+
// Track early exit to detect startup failures
|
|
140
|
+
let earlyExit = null
|
|
141
|
+
|
|
107
142
|
proc.on('error', (err) => {
|
|
108
143
|
console.error(`[Terminal] ttyd error for session '${session}': ${err.message}`)
|
|
144
|
+
earlyExit = { code: null, error: err.message }
|
|
109
145
|
ttydProcesses.delete(session)
|
|
110
146
|
})
|
|
111
147
|
|
|
112
|
-
proc.on('exit', (code) => {
|
|
113
|
-
console.log(`[Terminal] ttyd exited for session '${session}' with code ${code}`)
|
|
148
|
+
proc.on('exit', (code, signal) => {
|
|
149
|
+
console.log(`[Terminal] ttyd exited for session '${session}' with code ${code}, signal ${signal}`)
|
|
150
|
+
earlyExit = { code, signal }
|
|
114
151
|
ttydProcesses.delete(session)
|
|
115
152
|
})
|
|
116
153
|
|
|
@@ -126,8 +163,17 @@ async function startTtydForSession(session) {
|
|
|
126
163
|
|
|
127
164
|
console.log(`[Terminal] Started ttyd for session '${session}' on port ${port}`)
|
|
128
165
|
|
|
129
|
-
// Wait for ttyd to start listening
|
|
130
|
-
|
|
166
|
+
// Wait for ttyd to start listening, checking for early exit
|
|
167
|
+
const maxWait = 500
|
|
168
|
+
const checkInterval = 50
|
|
169
|
+
for (let waited = 0; waited < maxWait; waited += checkInterval) {
|
|
170
|
+
await new Promise(resolve => setTimeout(resolve, checkInterval))
|
|
171
|
+
if (earlyExit) {
|
|
172
|
+
throw new Error(
|
|
173
|
+
`ttyd exited immediately for session '${session}' (code=${earlyExit.code}, signal=${earlyExit.signal || 'none'})`
|
|
174
|
+
)
|
|
175
|
+
}
|
|
176
|
+
}
|
|
131
177
|
|
|
132
178
|
return { port, alreadyRunning: false }
|
|
133
179
|
}
|
|
@@ -388,7 +434,7 @@ async function terminalRoutes(fastify) {
|
|
|
388
434
|
// Create new detached session
|
|
389
435
|
// -d: detached, -s: session name
|
|
390
436
|
// Optionally run a command in the new session
|
|
391
|
-
let createCommand = `tmux new-session -d -s "${sessionName}"`
|
|
437
|
+
let createCommand = `tmux new-session -d -s "${sessionName}" -c "${homeDir}"`
|
|
392
438
|
if (command) {
|
|
393
439
|
// Escape command for shell safety
|
|
394
440
|
const escapedCommand = command.replace(/'/g, "'\\''")
|
|
@@ -634,9 +680,29 @@ function cleanupTtyd() {
|
|
|
634
680
|
ttydProcesses.clear()
|
|
635
681
|
}
|
|
636
682
|
|
|
683
|
+
/**
|
|
684
|
+
* Kill any stale ttyd processes left over from a previous agent run.
|
|
685
|
+
* Call this on server startup before accepting connections.
|
|
686
|
+
*/
|
|
687
|
+
async function killStaleTtydProcesses() {
|
|
688
|
+
try {
|
|
689
|
+
const { stdout } = await execAsync("pgrep -f '^ttyd ' || true", { timeout: 5000 })
|
|
690
|
+
const pids = stdout.trim().split('\n').filter(Boolean)
|
|
691
|
+
if (pids.length > 0) {
|
|
692
|
+
console.log(`[Terminal] Found ${pids.length} stale ttyd process(es), killing: ${pids.join(', ')}`)
|
|
693
|
+
await execAsync(`kill ${pids.join(' ')}`, { timeout: 5000 }).catch(() => {})
|
|
694
|
+
// Wait briefly for processes to exit and release ports
|
|
695
|
+
await new Promise(resolve => setTimeout(resolve, 300))
|
|
696
|
+
}
|
|
697
|
+
} catch {
|
|
698
|
+
// Ignore errors (pgrep returns non-zero when no matches)
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
|
|
637
702
|
module.exports = {
|
|
638
703
|
terminalRoutes,
|
|
639
704
|
cleanupTtyd,
|
|
705
|
+
killStaleTtydProcesses,
|
|
640
706
|
ttydProcesses,
|
|
641
707
|
startTtydForSession,
|
|
642
708
|
}
|
package/server.js
CHANGED
|
@@ -5,20 +5,13 @@
|
|
|
5
5
|
* See routes/index.js for API documentation.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
const { promisify } = require('util')
|
|
9
|
-
const { exec } = require('child_process')
|
|
10
|
-
const path = require('path')
|
|
11
|
-
const os = require('os')
|
|
12
|
-
const execAsync = promisify(exec)
|
|
13
|
-
|
|
14
8
|
const fastify = require('fastify')({ logger: true })
|
|
15
9
|
const { config, validate, isHqConfigured } = require('./config')
|
|
16
10
|
const workflowRunner = require('./workflow-runner')
|
|
17
11
|
const workflowStore = require('./workflow-store')
|
|
18
12
|
|
|
19
13
|
const { registerRoutes, setOffline, getProcessManager, getAllowedCommands } = require('./routes')
|
|
20
|
-
const {
|
|
21
|
-
const { cleanupTtyd } = require('./routes/terminal')
|
|
14
|
+
const { cleanupTtyd, killStaleTtydProcesses } = require('./routes/terminal')
|
|
22
15
|
const { startTerminalProxy, stopTerminalProxy } = require('./terminal-proxy')
|
|
23
16
|
|
|
24
17
|
// Validate configuration before starting
|
|
@@ -49,64 +42,6 @@ async function shutdown(signal) {
|
|
|
49
42
|
process.on('SIGTERM', () => shutdown('SIGTERM'))
|
|
50
43
|
process.on('SIGINT', () => shutdown('SIGINT'))
|
|
51
44
|
|
|
52
|
-
// Sync VNC password from HQ
|
|
53
|
-
async function syncVncPassword() {
|
|
54
|
-
console.log('[VNC] Syncing VNC password from HQ...')
|
|
55
|
-
|
|
56
|
-
try {
|
|
57
|
-
const url = `${config.HQ_URL}/api/minion/vnc-password`
|
|
58
|
-
const response = await fetch(url, {
|
|
59
|
-
method: 'GET',
|
|
60
|
-
headers: {
|
|
61
|
-
'Authorization': `Bearer ${config.API_TOKEN}`,
|
|
62
|
-
},
|
|
63
|
-
})
|
|
64
|
-
|
|
65
|
-
if (!response.ok) {
|
|
66
|
-
const errorText = await response.text()
|
|
67
|
-
console.error(`[VNC] Failed to get VNC password from HQ: ${response.status} - ${errorText}`)
|
|
68
|
-
return false
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
const data = await response.json()
|
|
72
|
-
const vncPassword = data.vnc_password
|
|
73
|
-
|
|
74
|
-
if (!vncPassword) {
|
|
75
|
-
console.log('[VNC] No VNC password set in HQ, using default')
|
|
76
|
-
return true
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
console.log('[VNC] Got VNC password from HQ, applying...')
|
|
80
|
-
|
|
81
|
-
// Escape single quotes for shell safety
|
|
82
|
-
const escapedPassword = vncPassword.replace(/'/g, "'\\''")
|
|
83
|
-
|
|
84
|
-
// Ensure .vnc directory exists and set password
|
|
85
|
-
const homeDir = os.homedir()
|
|
86
|
-
const vncDir = path.join(homeDir, '.vnc')
|
|
87
|
-
const vncPasswdPath = path.join(vncDir, 'passwd')
|
|
88
|
-
await execAsync(`mkdir -p ${vncDir}`, { timeout: 5000 })
|
|
89
|
-
await execAsync(
|
|
90
|
-
`x11vnc -storepasswd '${escapedPassword}' ${vncPasswdPath}`,
|
|
91
|
-
{ timeout: 10000 }
|
|
92
|
-
)
|
|
93
|
-
await execAsync(`chmod 600 ${vncPasswdPath}`, { timeout: 5000 })
|
|
94
|
-
|
|
95
|
-
// Restart x11vnc to apply the new password
|
|
96
|
-
if (PROC_MGR === 'systemd') {
|
|
97
|
-
await execAsync(`${SUDO}systemctl restart x11vnc`, { timeout: 10000 })
|
|
98
|
-
} else if (PROC_MGR === 'supervisord') {
|
|
99
|
-
await execAsync(`${SUDO}supervisorctl restart x11vnc`, { timeout: 10000 })
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
console.log('[VNC] VNC password synced successfully')
|
|
103
|
-
return true
|
|
104
|
-
} catch (error) {
|
|
105
|
-
console.error(`[VNC] Failed to sync VNC password: ${error.message}`)
|
|
106
|
-
return false
|
|
107
|
-
}
|
|
108
|
-
}
|
|
109
|
-
|
|
110
45
|
// Start server
|
|
111
46
|
async function start() {
|
|
112
47
|
try {
|
|
@@ -118,7 +53,8 @@ async function start() {
|
|
|
118
53
|
|
|
119
54
|
console.log(`[Server] Minion agent listening on port ${config.AGENT_PORT}`)
|
|
120
55
|
|
|
121
|
-
//
|
|
56
|
+
// Kill any stale ttyd processes from a previous run, then start terminal proxy
|
|
57
|
+
await killStaleTtydProcesses()
|
|
122
58
|
try {
|
|
123
59
|
await startTerminalProxy()
|
|
124
60
|
} catch (err) {
|
|
@@ -139,11 +75,6 @@ async function start() {
|
|
|
139
75
|
|
|
140
76
|
if (isHqConfigured()) {
|
|
141
77
|
console.log(`[Server] HQ URL: ${config.HQ_URL}`)
|
|
142
|
-
|
|
143
|
-
// Sync VNC password from HQ (non-blocking, run in background)
|
|
144
|
-
syncVncPassword().catch(err => {
|
|
145
|
-
console.error('[VNC] Background sync failed:', err.message)
|
|
146
|
-
})
|
|
147
78
|
} else {
|
|
148
79
|
console.log('[Server] Running in standalone mode (no HQ connection)')
|
|
149
80
|
}
|