module_fn('pi_control', 'table', $name); $notice = null; $error = null; $terminalNotice = null; $terminalError = null; $terminalUrl = null; $terminalToken = null; $terminalHostLabel = null; $settings = modules()->settings('pi_control'); $assets = app()->assets(); if ($assets) { $assets->addStyle('/module/pi_control/asset?file=pi_control.css'); $assets->addScript('/module/pi_control/asset?file=console.js', 'footer', true); } $ttydUrl = trim((string)($settings['ttyd_url'] ?? '/ttyd')); $defaultProvider = 'ttyd'; $defaultTimeout = (int)($settings['exec_default_timeout'] ?? (getenv('PI_CONTROL_EXEC_DEFAULT_TIMEOUT') !== false ? (int)getenv('PI_CONTROL_EXEC_DEFAULT_TIMEOUT') : 300)); $defaultTimeout = $defaultTimeout > 0 ? $defaultTimeout : 300; $queueName = (string)($settings['redis']['queue'] ?? ($settings['redis.queue'] ?? (getenv('PI_CONTROL_REDIS_QUEUE') ?: 'pi_control:queue'))); $tokenTtl = (int)($settings['terminal_token_ttl'] ?? 10); $tokenTtl = $tokenTtl > 0 ? $tokenTtl : 10; $hosts = $pdo->query('SELECT * FROM ' . $table('hosts') . ' ORDER BY name ASC')->fetchAll(PDO::FETCH_ASSOC); $commands = $pdo->query('SELECT * FROM ' . $table('commands') . ' ORDER BY label ASC')->fetchAll(PDO::FETCH_ASSOC); $renderRuns = function (array $runs): string { ob_start(); if (!$runs) { echo 'Noch keine Runs.'; return ob_get_clean(); } foreach ($runs as $r) { $out = (string)($r['output'] ?? ''); $err = (string)($r['error'] ?? ''); $snippet = $out !== '' ? $out : $err; if (strlen($snippet) > 140) { $snippet = substr($snippet, 0, 140) . '…'; } echo ''; echo '' . e((string)$r['id']) . ''; echo '' . e((string)($r['status'] ?? '')) . ''; echo '' . e((string)($r['host_name'] ?? $r['host_addr'] ?? '')) . ''; echo '' . e((string)($r['command_text'] ?? '')) . ''; echo '' . e((string)($r['created_by'] ?? '')) . ''; echo '' . e($snippet) . ''; echo '' . (!empty($r['timeout_sec']) ? e((string)$r['timeout_sec']) . 's' : 'default') . ''; $status = (string)($r['status'] ?? ''); echo ''; if ($status === 'queued') { echo ''; echo ''; } elseif ($status === 'running') { echo 'läuft'; } else { echo ''; } echo ''; echo ''; } return ob_get_clean(); }; if (isset($_GET['queue_json'])) { $runs = $pdo->query( 'SELECT r.*, h.name AS host_name, h.host AS host_addr FROM ' . $table('runs') . ' r LEFT JOIN ' . $table('hosts') . ' h ON h.id = r.host_id ORDER BY r.id DESC LIMIT 20' )->fetchAll(PDO::FETCH_ASSOC); $count = (int)$pdo->query( "SELECT COUNT(*) FROM " . $table('runs') . " WHERE status IN ('queued','running','cancel_requested')" )->fetchColumn(); header('Content-Type: application/json; charset=utf-8'); echo json_encode([ 'ok' => true, 'next' => 10, 'count' => $count, 'html' => $renderRuns($runs), ]); exit; } if (isset($_GET['queue_action_json'])) { $runId = (int)($_POST['run_id'] ?? 0); $action = (string)($_POST['action'] ?? ''); $error = null; if ($runId <= 0 || !in_array($action, ['cancel', 'delete'], true)) { $error = 'Ungültige Anfrage.'; } else { $stmt = $pdo->prepare('SELECT * FROM ' . $table('runs') . ' WHERE id = :id LIMIT 1'); $stmt->execute(['id' => $runId]); $run = $stmt->fetch(PDO::FETCH_ASSOC); if (!$run) { $error = 'Run nicht gefunden.'; } else { $status = (string)($run['status'] ?? ''); $driver = (string)$pdo->getAttribute(PDO::ATTR_DRIVER_NAME); $nowExpr = $driver === 'pgsql' ? 'NOW()' : "DATETIME('now')"; if ($action === 'cancel') { if ($status !== 'queued') { $error = 'Nur queued Runs können gestoppt werden.'; } else { $pdo->exec('UPDATE ' . $table('runs') . ' SET status = \'canceled\', finished_at = ' . $nowExpr . ' WHERE id = ' . (int)$runId); try { $redis = module_fn('pi_control', 'redis'); $payload = json_encode(['run_id' => $runId]); $redis->command(['LREM', $queueName, '0', $payload]); } catch (\Throwable $e) { // ignore redis cleanup errors } } } elseif ($action === 'delete') { if ($status === 'running') { $error = 'Laufende Runs können nicht gelöscht werden.'; } else { $pdo->prepare('DELETE FROM ' . $table('runs') . ' WHERE id = :id')->execute(['id' => $runId]); try { $redis = module_fn('pi_control', 'redis'); $payload = json_encode(['run_id' => $runId]); $redis->command(['LREM', $queueName, '0', $payload]); } catch (\Throwable $e) { // ignore redis cleanup errors } } } } } header('Content-Type: application/json; charset=utf-8'); echo json_encode([ 'ok' => $error === null, 'error' => $error, ]); exit; } if (isset($_GET['open_console_json'])) { $hostId = (int)($_POST['terminal_host_id'] ?? 0); $presetId = (int)($_POST['terminal_preset_id'] ?? 0); $rawCommand = trim((string)($_POST['terminal_command_text'] ?? '')); $terminalError = null; $terminalUrl = null; $terminalToken = null; $terminalHostLabel = null; if ($hostId <= 0) { $terminalError = 'Bitte einen Host wählen.'; } elseif ($ttydUrl === '') { $terminalError = 'ttyd URL fehlt. Bitte im Setup setzen.'; } else { $presetCommand = ''; if ($presetId > 0) { foreach ($commands as $c) { if ((int)$c['id'] === $presetId) { if (!auth_is_admin() && !empty($c['admin_only'])) { $terminalError = 'Diese Vorlage ist nur für Admins.'; } else { $presetCommand = (string)$c['command']; } break; } } } if (!$terminalError) { $driver = (string)$pdo->getAttribute(PDO::ATTR_DRIVER_NAME); $expiresSql = $driver === 'pgsql' ? "NOW() + INTERVAL '{$tokenTtl} minutes'" : "DATETIME('now', '+{$tokenTtl} minutes')"; $token = bin2hex(random_bytes(24)); $terminalToken = $token; $stmt = $pdo->prepare( 'INSERT INTO ' . $table('sessions') . ' (token, host_id, provider, command_text, created_by, expires_at) VALUES (:token, :host_id, :provider, :command_text, :created_by, ' . $expiresSql . ')' ); $commandToRun = $presetCommand !== '' ? $presetCommand : $rawCommand; $stmt->execute([ 'token' => $token, 'host_id' => $hostId, 'provider' => 'ttyd', 'command_text' => $commandToRun !== '' ? $commandToRun : null, 'created_by' => auth_display_name() ?: null, ]); $sep = str_contains($ttydUrl, '?') ? '&' : '?'; $terminalUrl = rtrim($ttydUrl, '/') . '/' . $sep . 'arg=' . rawurlencode($token); foreach ($hosts as $h) { if ((int)$h['id'] === $hostId) { $terminalHostLabel = (string)($h['name'] ?? $h['host']); break; } } } } header('Content-Type: application/json; charset=utf-8'); echo json_encode([ 'ok' => $terminalError === null, 'error' => $terminalError, 'url' => $terminalUrl, 'token' => $terminalToken, 'host' => $terminalHostLabel, ]); exit; } if (isset($_GET['run_command_json'])) { $hostId = (int)($_POST['terminal_host_id'] ?? 0); $commandId = (int)($_POST['terminal_preset_id'] ?? 0); $rawCommand = trim((string)($_POST['terminal_command_text'] ?? '')); $timeoutSec = $defaultTimeout; $error = null; $notice = null; if ($hostId <= 0) { $error = 'Bitte einen Host wählen.'; } elseif ($commandId <= 0 && $rawCommand === '') { $error = 'Bitte einen Befehl wählen oder einen Befehl eingeben.'; } else { $selectedCommand = ''; if ($commandId > 0) { foreach ($commands as $c) { if ((int)$c['id'] === $commandId) { if (!auth_is_admin() && !empty($c['admin_only'])) { $error = 'Dieser Befehl ist nur für Admins.'; } else { $selectedCommand = (string)$c['command']; if (!empty($c['timeout_sec'])) { $timeoutSec = (int)$c['timeout_sec']; } } break; } } } if (!$error) { $commandText = $selectedCommand !== '' ? $selectedCommand : $rawCommand; $driver = (string)$pdo->getAttribute(PDO::ATTR_DRIVER_NAME); if ($driver === 'pgsql') { $stmt = $pdo->prepare( 'INSERT INTO ' . $table('runs') . ' (host_id, command_id, command_text, status, timeout_sec, created_by) VALUES (:host_id, :command_id, :command_text, :status, :timeout_sec, :created_by) RETURNING id' ); $stmt->execute([ 'host_id' => $hostId, 'command_id' => $commandId > 0 ? $commandId : null, 'command_text' => $commandText, 'status' => 'queued', 'timeout_sec' => $timeoutSec, 'created_by' => auth_display_name() ?: null, ]); $runId = (int)$stmt->fetchColumn(); } else { $stmt = $pdo->prepare( 'INSERT INTO ' . $table('runs') . ' (host_id, command_id, command_text, status, timeout_sec, created_by) VALUES (:host_id, :command_id, :command_text, :status, :timeout_sec, :created_by)' ); $stmt->execute([ 'host_id' => $hostId, 'command_id' => $commandId > 0 ? $commandId : null, 'command_text' => $commandText, 'status' => 'queued', 'timeout_sec' => $timeoutSec, 'created_by' => auth_display_name() ?: null, ]); $runId = (int)$pdo->lastInsertId(); } try { $redis = module_fn('pi_control', 'redis'); $payload = json_encode(['run_id' => $runId], JSON_THROW_ON_ERROR); $redis->command(['RPUSH', $queueName, $payload]); $notice = 'Befehl wurde in die Queue gestellt.'; } catch (\Throwable $e) { $pdo->exec('UPDATE ' . $table('runs') . ' SET status = \'queue_error\' WHERE id = ' . (int)$runId); $notice = 'Befehl gespeichert, aber Queue nicht erreichbar.'; } } } header('Content-Type: application/json; charset=utf-8'); echo json_encode([ 'ok' => $error === null, 'error' => $error, 'notice' => $notice, ]); exit; } if (isset($_GET['send_active_json'])) { $hostId = (int)($_POST['terminal_host_id'] ?? 0); $commandId = (int)($_POST['terminal_preset_id'] ?? 0); $rawCommand = trim((string)($_POST['terminal_command_text'] ?? '')); $error = null; $notice = null; if ($hostId <= 0) { $error = 'Bitte einen Host wählen.'; } elseif ($commandId <= 0 && $rawCommand === '') { $error = 'Bitte einen Befehl wählen oder einen Befehl eingeben.'; } else { $selectedCommand = ''; if ($commandId > 0) { foreach ($commands as $c) { if ((int)$c['id'] === $commandId) { if (!auth_is_admin() && !empty($c['admin_only'])) { $error = 'Dieser Befehl ist nur für Admins.'; } else { $selectedCommand = (string)$c['command']; } break; } } } if (!$error) { $commandText = $selectedCommand !== '' ? $selectedCommand : $rawCommand; $stmt = $pdo->prepare('SELECT * FROM ' . $table('hosts') . ' WHERE id = :id LIMIT 1'); $stmt->execute(['id' => $hostId]); $host = $stmt->fetch(PDO::FETCH_ASSOC); if (!$host) { $error = 'Host nicht gefunden.'; } else { $settings = modules()->settings('pi_control'); $strictHostKey = !empty($settings['terminal_strict_hostkey']) || getenv('PI_CONTROL_STRICT_HOSTKEY') === '1'; [$ok, $sendError] = sendToActiveConsole($host, $commandText, $strictHostKey); if ($ok) { $notice = 'Befehl wurde in der bestehenden Konsole ausgeführt.'; } else { $error = $sendError ?: 'Bestehende Konsole nicht verfügbar.'; } } } } header('Content-Type: application/json; charset=utf-8'); echo json_encode([ 'ok' => $error === null, 'error' => $error, 'notice' => $notice, ]); exit; } // Form submits are handled via AJAX to avoid reloads. $runs = $pdo->query( 'SELECT r.*, h.name AS host_name, h.host AS host_addr FROM ' . $table('runs') . ' r LEFT JOIN ' . $table('hosts') . ' h ON h.id = r.host_id ORDER BY r.id DESC LIMIT 20' )->fetchAll(PDO::FETCH_ASSOC); function sendToActiveConsole(array $host, string $command, bool $strictHostKey): array { $hostAddr = (string)($host['host'] ?? ''); $user = (string)($host['username'] ?? ''); $port = (int)($host['port'] ?? 22); $authType = (string)($host['auth_type'] ?? 'key'); $keyPath = (string)($host['key_path'] ?? ''); $password = (string)($host['password'] ?? ''); if ($hostAddr === '' || $user === '') { return [false, 'Hostdaten unvollständig.']; } $opts = $strictHostKey ? '-o StrictHostKeyChecking=accept-new -o UserKnownHostsFile=/root/.ssh/known_hosts' : '-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null'; $target = escapeshellarg($user . '@' . $hostAddr); $cmdB64 = base64_encode($command); $remote = 'CMD_B64="$0"; CMD="$(printf "%s" "$CMD_B64" | base64 -d)"; ' . 'command -v tmux >/dev/null 2>&1 || exit 2; ' . 'tmux has-session -t nexus 2>/dev/null || exit 3; ' . 'tmux send-keys -t nexus "$CMD" C-m'; $cmd = 'ssh ' . $opts . ' -p ' . (int)$port . ' '; if ($authType === 'key' && $keyPath !== '') { $cmd .= '-i ' . escapeshellarg($keyPath) . ' '; } $cmd .= $target . ' -- /bin/bash -lc ' . escapeshellarg($remote) . ' ' . escapeshellarg($cmdB64); if ($authType === 'pass' && $password !== '') { $cmd = 'sshpass -p ' . escapeshellarg($password) . ' ' . $cmd; } $descriptors = [ 1 => ['pipe', 'w'], 2 => ['pipe', 'w'], ]; $process = proc_open($cmd, $descriptors, $pipes); if (!is_resource($process)) { return [false, 'proc_open failed']; } $out = stream_get_contents($pipes[1]); $err = stream_get_contents($pipes[2]); $status = proc_get_status($process); $exitCode = (int)($status['exitcode'] ?? 1); proc_close($process); if ($exitCode === 0) { return [true, null]; } if ($exitCode === 2) { return [false, 'tmux ist auf dem Host nicht installiert.']; } if ($exitCode === 3) { return [false, 'Keine aktive Konsole gefunden.']; } $msg = trim($err !== '' ? $err : $out); return [false, $msg !== '' ? $msg : 'Befehl konnte nicht gesendet werden.']; } ?>
Pi Control

Konsole

Wähle einen Host und führe einen Befehl aus.

Live-Konsole
Token:
Konsole Tabs

Mehrere Konsolen bleiben hier parallel offen.