Files
nexus/modules/pi_control/pages/console.php
2026-03-07 23:08:05 +01:00

530 lines
21 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
$pdo = module_fn('pi_control', 'pdo');
module_fn('pi_control', 'ensure_schema');
$table = fn(string $name) => 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 '<tr><td colspan="8" class="muted">Noch keine Runs.</td></tr>';
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 '<tr>';
echo '<td>' . e((string)$r['id']) . '</td>';
echo '<td>' . e((string)($r['status'] ?? '')) . '</td>';
echo '<td>' . e((string)($r['host_name'] ?? $r['host_addr'] ?? '')) . '</td>';
echo '<td class="muted" style="max-width:360px;"><code>' . e((string)($r['command_text'] ?? '')) . '</code></td>';
echo '<td>' . e((string)($r['created_by'] ?? '')) . '</td>';
echo '<td class="muted" style="max-width:240px;"><code>' . e($snippet) . '</code></td>';
echo '<td>' . (!empty($r['timeout_sec']) ? e((string)$r['timeout_sec']) . 's' : 'default') . '</td>';
$status = (string)($r['status'] ?? '');
echo '<td>';
if ($status === 'queued') {
echo '<button class="nav-link" type="button" data-queue-action="cancel" data-run-id="' . e((string)$r['id']) . '">Stoppen</button>';
echo '<button class="nav-link" type="button" data-queue-action="delete" data-run-id="' . e((string)$r['id']) . '">Löschen</button>';
} elseif ($status === 'running') {
echo '<span class="muted">läuft</span>';
} else {
echo '<button class="nav-link" type="button" data-queue-action="delete" data-run-id="' . e((string)$r['id']) . '">Löschen</button>';
}
echo '</td>';
echo '</tr>';
}
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.'];
}
?>
<div class="card">
<div class="pill">Pi Control</div>
<h1 style="margin-top:.75rem;">Konsole</h1>
<p class="muted">Wähle einen Host und führe einen Befehl aus.</p>
<?php if ($error): ?>
<div class="card" style="margin-top:1rem; border-color:#ffb4a8; background:#fff5f3; color:#7a2114;">
<?= e($error) ?>
</div>
<?php elseif ($notice): ?>
<div class="card" style="margin-top:1rem; border-color:var(--accent-2);">
<?= e($notice) ?>
</div>
<?php endif; ?>
<div class="grid" style="margin-top:1rem;">
<div class="card form-card" style="background:var(--panel-2);">
<strong>Live-Konsole</strong>
<div class="card" style="margin-top:1rem; border-color:#ffb4a8; background:#fff5f3; color:#7a2114; display:none;" data-console-error></div>
<div class="card" style="margin-top:1rem; border-color:var(--accent-2); display:none;" data-console-notice></div>
<form method="post" class="form-grid" style="margin-top:.75rem;" data-console-form>
<label class="form-field">
<span class="muted">Host</span>
<select name="terminal_host_id" required title="Pi auswählen, zu dem die Konsole verbindet.">
<option value="">Host wählen</option>
<?php foreach ($hosts as $h): ?>
<option value="<?= e((string)$h['id']) ?>"><?= e($h['name'] ?? $h['host']) ?></option>
<?php endforeach; ?>
</select>
</label>
<label class="form-field">
<span class="muted">Vorlage</span>
<select name="terminal_preset_id" title="Optional: Vorlage direkt in der Konsole ausführen.">
<option value="">Vorlage auswählen (optional)</option>
<?php foreach ($commands as $c): ?>
<?php if (!auth_is_admin() && !empty($c['admin_only'])) { continue; } ?>
<option value="<?= e((string)$c['id']) ?>" data-command="<?= e((string)($c['command'] ?? '')) ?>">
<?= e($c['label'] ?? '') ?>
</option>
<?php endforeach; ?>
</select>
</label>
<label class="form-field">
<span class="muted">Direkter Befehl</span>
<textarea name="terminal_command_text" rows="2" placeholder="Optional: Befehl direkt ausführen"></textarea>
</label>
<div style="display:flex; gap:10px; flex-wrap:wrap;">
<button class="cta-button" type="button" data-open-console>Neue Konsole öffnen</button>
<button class="nav-link" type="button" data-run-command>Im Hintergrund ausführen</button>
<button class="icon-button queue-button" type="button" data-queue-button>
Queue
<span class="queue-badge" data-queue-count>0</span>
</button>
</div>
</form>
<div class="muted" style="margin-top:.5rem;">
Token: <code data-console-token></code>
</div>
<?php if ($terminalUrl): ?>
<div class="console-launch"
data-url="<?= e($terminalUrl) ?>"
data-host="<?= e($terminalHostLabel ?: 'Konsole') ?>"
data-token="<?= e($terminalToken ?: '') ?>"></div>
<?php endif; ?>
</div>
<div class="card form-card" style="background:var(--panel-2);">
<strong>Konsole Tabs</strong>
<div class="console-tabs" data-console-tabs>
<div class="console-tab-bar" data-console-tab-bar></div>
<div class="console-tab-panels" data-console-tab-panels></div>
</div>
<p class="muted" style="margin-top:.5rem;">Mehrere Konsolen bleiben hier parallel offen.</p>
</div>
<div class="modal" data-queue-modal aria-hidden="true">
<div class="modal-card">
<div class="modal-header">
<strong>Queue</strong>
<div class="modal-actions">
<span class="muted">Update in <span data-queue-countdown>10</span>s</span>
<button class="icon-button" type="button" data-queue-refresh title="Jetzt aktualisieren">↻</button>
<button class="icon-button" type="button" data-queue-close title="Schließen">×</button>
</div>
</div>
<div style="margin-top:.75rem; overflow:auto;">
<table class="table" style="min-width:720px;">
<thead>
<tr>
<th>ID</th>
<th>Status</th>
<th>Host</th>
<th>Command</th>
<th>Von</th>
<th>Output</th>
<th>Timeout</th>
<th>Aktion</th>
</tr>
</thead>
<tbody data-queue-body>
<?= $renderRuns($runs) ?>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>