From 8ee9b364ee2d889ae3d540c70adf580f055dc769 Mon Sep 17 00:00:00 2001 From: Lars Gebhardt-Kusche Date: Thu, 5 Mar 2026 02:10:21 +0100 Subject: [PATCH] tty --- modules/pi_control/bootstrap.php | 21 +++ modules/pi_control/module.json | 7 +- modules/pi_control/pages/console.php | 192 ++++++++++++++++----- modules/pi_control/pages/terminal_info.php | 74 ++++++++ public/index.php | 1 + tools/pi_control/terminal_entry.sh | 50 ++++++ tools/pi_control/ttyd/Dockerfile | 17 ++ 7 files changed, 315 insertions(+), 47 deletions(-) create mode 100644 modules/pi_control/pages/terminal_info.php create mode 100644 tools/pi_control/terminal_entry.sh create mode 100644 tools/pi_control/ttyd/Dockerfile diff --git a/modules/pi_control/bootstrap.php b/modules/pi_control/bootstrap.php index abb9804..08c08e1 100644 --- a/modules/pi_control/bootstrap.php +++ b/modules/pi_control/bootstrap.php @@ -41,6 +41,7 @@ $mm->registerFunction($moduleName, 'ensure_schema', function () use ($moduleName $hostTable = $table('hosts'); $cmdTable = $table('commands'); $runTable = $table('runs'); + $sessionTable = $table('sessions'); if ($driver === 'pgsql') { $pdo->exec("CREATE TABLE IF NOT EXISTS {$hostTable} ( @@ -71,6 +72,16 @@ $mm->registerFunction($moduleName, 'ensure_schema', function () use ($moduleName created_by VARCHAR(120) NULL, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP )"); + $pdo->exec("CREATE TABLE IF NOT EXISTS {$sessionTable} ( + id SERIAL PRIMARY KEY, + token VARCHAR(64) NOT NULL UNIQUE, + host_id INTEGER NOT NULL, + provider VARCHAR(20) NOT NULL DEFAULT 'ttyd', + created_by VARCHAR(120) NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + expires_at TIMESTAMP NOT NULL, + last_used_at TIMESTAMP NULL + )"); } else { $pdo->exec("CREATE TABLE IF NOT EXISTS {$hostTable} ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -100,6 +111,16 @@ $mm->registerFunction($moduleName, 'ensure_schema', function () use ($moduleName created_by VARCHAR(120) NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP )"); + $pdo->exec("CREATE TABLE IF NOT EXISTS {$sessionTable} ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + token VARCHAR(64) NOT NULL UNIQUE, + host_id INTEGER NOT NULL, + provider VARCHAR(20) NOT NULL DEFAULT 'ttyd', + created_by VARCHAR(120) NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + expires_at DATETIME NOT NULL, + last_used_at DATETIME NULL + )"); } // Seed default commands (only when empty) diff --git a/modules/pi_control/module.json b/modules/pi_control/module.json index 2de09be..6e2a378 100644 --- a/modules/pi_control/module.json +++ b/modules/pi_control/module.json @@ -30,7 +30,12 @@ { "name": "db.dbname", "label": "DB Name", "type": "text", "required": false }, { "name": "db.schema", "label": "DB Schema", "type": "text", "required": false }, { "name": "db.user", "label": "DB User", "type": "text", "required": false }, - { "name": "db.password", "label": "DB Passwort", "type": "password", "required": false } + { "name": "db.password", "label": "DB Passwort", "type": "password", "required": false }, + { "name": "ttyd_url", "label": "ttyd URL", "type": "text", "required": false, "help": "z.B. https://staging.nexus.int.kusche.berlin/ttyd" }, + { "name": "wetty_url", "label": "WeTTY URL", "type": "text", "required": false, "help": "z.B. https://staging.nexus.int.kusche.berlin/wetty" }, + { "name": "terminal_default_provider", "label": "Standard-Konsole", "type": "text", "required": false, "help": "ttyd oder wetty" }, + { "name": "terminal_token_ttl", "label": "Token TTL (Minuten)", "type": "number", "required": false, "help": "Gültigkeit der Konsole-Token, z.B. 10" }, + { "name": "terminal_shared_secret", "label": "Terminal Shared Secret", "type": "password", "required": false, "help": "Zusätzliche Absicherung für terminal_info (Header X-Terminal-Secret)" } ] }, "db_defaults": { diff --git a/modules/pi_control/pages/console.php b/modules/pi_control/pages/console.php index 80fbc6f..23db98a 100644 --- a/modules/pi_control/pages/console.php +++ b/modules/pi_control/pages/console.php @@ -5,48 +5,95 @@ $table = fn(string $name) => module_fn('pi_control', 'table', $name); $notice = null; $error = null; +$terminalNotice = null; +$terminalError = null; +$terminalUrl = null; + +$settings = modules()->settings('pi_control'); +$ttydUrl = trim((string)($settings['ttyd_url'] ?? '/ttyd')); +$wettyUrl = trim((string)($settings['wetty_url'] ?? '/wetty')); +$defaultProvider = (string)($settings['terminal_default_provider'] ?? 'ttyd'); +$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); if ($_SERVER['REQUEST_METHOD'] === 'POST') { - $hostId = (int)($_POST['host_id'] ?? 0); - $commandId = (int)($_POST['command_id'] ?? 0); - $rawCommand = trim((string)($_POST['command_text'] ?? '')); + $action = (string)($_POST['action'] ?? ''); - 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 ($action === 'open_console') { + $hostId = (int)($_POST['terminal_host_id'] ?? 0); + $provider = (string)($_POST['terminal_provider'] ?? $defaultProvider); + $provider = in_array($provider, ['ttyd', 'wetty'], true) ? $provider : 'ttyd'; - if (!$error) { - $commandText = $selectedCommand !== '' ? $selectedCommand : $rawCommand; + if ($hostId <= 0) { + $terminalError = 'Bitte einen Host wählen.'; + } else { + $driver = (string)$pdo->getAttribute(PDO::ATTR_DRIVER_NAME); + $expiresSql = $driver === 'pgsql' + ? "NOW() + INTERVAL '{$tokenTtl} minutes'" + : "DATETIME('now', '+{$tokenTtl} minutes')"; + + $token = bin2hex(random_bytes(24)); $stmt = $pdo->prepare( - 'INSERT INTO ' . $table('runs') . ' (host_id, command_id, command_text, status, created_by) VALUES (:host_id, :command_id, :command_text, :status, :created_by)' + 'INSERT INTO ' . $table('sessions') . ' (token, host_id, provider, created_by, expires_at) + VALUES (:token, :host_id, :provider, :created_by, ' . $expiresSql . ')' ); $stmt->execute([ + 'token' => $token, 'host_id' => $hostId, - 'command_id' => $commandId > 0 ? $commandId : null, - 'command_text' => $commandText, - 'status' => 'pending', + 'provider' => $provider, 'created_by' => auth_display_name() ?: null, ]); - $notice = 'Befehl wurde erfasst. (Execution-Backend folgt)'; + if ($provider === 'ttyd') { + $sep = str_contains($ttydUrl, '?') ? '&' : '?'; + $terminalUrl = rtrim($ttydUrl, '/') . '/' . $sep . 'arg=' . rawurlencode($token); + } else { + $terminalUrl = $wettyUrl; + $terminalNotice = 'WeTTY nutzt den Standard-Host aus der Container-Konfiguration. Für Host-Auswahl bitte ttyd nutzen.'; + } + } + } else { + $hostId = (int)($_POST['host_id'] ?? 0); + $commandId = (int)($_POST['command_id'] ?? 0); + $rawCommand = trim((string)($_POST['command_text'] ?? '')); + + 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( + 'INSERT INTO ' . $table('runs') . ' (host_id, command_id, command_text, status, created_by) VALUES (:host_id, :command_id, :command_text, :status, :created_by)' + ); + $stmt->execute([ + 'host_id' => $hostId, + 'command_id' => $commandId > 0 ? $commandId : null, + 'command_text' => $commandText, + 'status' => 'pending', + 'created_by' => auth_display_name() ?: null, + ]); + + $notice = 'Befehl wurde erfasst. (Execution-Backend folgt)'; + } } } } @@ -69,31 +116,84 @@ $runs = $pdo->query('SELECT * FROM ' . $table('runs') . ' ORDER BY id DESC LIMIT
-
- Ausführen -
- +
+ Live-Konsole + +
+ +
+ +
+ +
+ + + + + + + - + +
+
+ Aktive Konsole + In neuem Tab öffnen +
+
+ +
+
+ +
- +
+ Befehl ausführen +
+ + + +

Hinweis: Execution-Backend wird im nächsten Schritt ergänzt.

-
+
Letzte Runs
diff --git a/modules/pi_control/pages/terminal_info.php b/modules/pi_control/pages/terminal_info.php new file mode 100644 index 0000000..87e8c65 --- /dev/null +++ b/modules/pi_control/pages/terminal_info.php @@ -0,0 +1,74 @@ + module_fn('pi_control', 'table', $name); + +header('Content-Type: application/json; charset=utf-8'); + +$settings = modules()->settings('pi_control'); +$sharedSecret = trim((string)($settings['terminal_shared_secret'] ?? '')); +if ($sharedSecret !== '') { + $provided = trim((string)($_SERVER['HTTP_X_TERMINAL_SECRET'] ?? '')); + if (!hash_equals($sharedSecret, $provided)) { + http_response_code(401); + echo json_encode(['ok' => false, 'error' => 'unauthorized']); + exit; + } +} + +$token = ''; +if (!empty($_GET['token'])) { + $token = trim((string)$_GET['token']); +} elseif (!empty($_SERVER['HTTP_X_TERMINAL_TOKEN'])) { + $token = trim((string)$_SERVER['HTTP_X_TERMINAL_TOKEN']); +} + +if ($token === '') { + http_response_code(400); + echo json_encode(['ok' => false, 'error' => 'missing_token']); + exit; +} + +$driver = (string)$pdo->getAttribute(PDO::ATTR_DRIVER_NAME); +$nowSql = $driver === 'pgsql' ? 'NOW()' : "DATETIME('now')"; + +$sessionStmt = $pdo->prepare( + 'SELECT * FROM ' . $table('sessions') . ' WHERE token = :token AND expires_at > ' . $nowSql . ' LIMIT 1' +); +$sessionStmt->execute(['token' => $token]); +$session = $sessionStmt->fetch(PDO::FETCH_ASSOC); + +if (!$session) { + http_response_code(404); + echo json_encode(['ok' => false, 'error' => 'invalid_or_expired']); + exit; +} + +$hostStmt = $pdo->prepare('SELECT * FROM ' . $table('hosts') . ' WHERE id = :id LIMIT 1'); +$hostStmt->execute(['id' => (int)$session['host_id']]); +$host = $hostStmt->fetch(PDO::FETCH_ASSOC); + +if (!$host) { + http_response_code(404); + echo json_encode(['ok' => false, 'error' => 'host_not_found']); + exit; +} + +$pdo->prepare('UPDATE ' . $table('sessions') . ' SET last_used_at = ' . $nowSql . ' WHERE id = :id') + ->execute(['id' => (int)$session['id']]); + +echo json_encode([ + 'ok' => true, + 'host' => [ + 'name' => (string)($host['name'] ?? ''), + 'host' => (string)($host['host'] ?? ''), + 'port' => (int)($host['port'] ?? 22), + 'username' => (string)($host['username'] ?? ''), + 'auth_type' => (string)($host['auth_type'] ?? 'key'), + 'key_path' => (string)($host['key_path'] ?? ''), + 'password' => (string)($host['password'] ?? ''), + ], +]); +exit; diff --git a/public/index.php b/public/index.php index a250507..3a4df30 100755 --- a/public/index.php +++ b/public/index.php @@ -27,6 +27,7 @@ $publicPaths = [ 'auth/login', 'auth/callback', 'auth/logout', + 'module/pi_control/terminal_info', ]; if (defined('APP_AUTH_ENABLED') && APP_AUTH_ENABLED && !in_array($uriPath, $publicPaths, true)) { $user = auth_user(); diff --git a/tools/pi_control/terminal_entry.sh b/tools/pi_control/terminal_entry.sh new file mode 100644 index 0000000..dc6c675 --- /dev/null +++ b/tools/pi_control/terminal_entry.sh @@ -0,0 +1,50 @@ +#!/usr/bin/env bash +set -euo pipefail + +TOKEN="${1:-}" +if [[ -z "${TOKEN}" ]]; then + echo "Missing token." + exit 1 +fi + +API_BASE="${PI_CONTROL_API_URL:-http://gui_nexus}" +API_BASE="${API_BASE%/}" +INFO_URL="${API_BASE}/module/pi_control/terminal_info?token=${TOKEN}" + +AUTH_HEADER=() +if [[ -n "${STAGING_AUTH_USER:-}" && -n "${STAGING_AUTH_PASS:-}" ]]; then + BASIC="$(printf "%s:%s" "${STAGING_AUTH_USER}" "${STAGING_AUTH_PASS}" | base64)" + AUTH_HEADER=(-H "Authorization: Basic ${BASIC}") +fi +if [[ -n "${PI_CONTROL_SHARED_SECRET:-}" ]]; then + AUTH_HEADER+=(-H "X-Terminal-Secret: ${PI_CONTROL_SHARED_SECRET}") +fi + +JSON="$(curl -sS "${AUTH_HEADER[@]}" "${INFO_URL}")" +OK="$(echo "${JSON}" | jq -r '.ok')" +if [[ "${OK}" != "true" ]]; then + echo "Invalid or expired token." + exit 1 +fi + +HOST="$(echo "${JSON}" | jq -r '.host.host')" +PORT="$(echo "${JSON}" | jq -r '.host.port')" +USER="$(echo "${JSON}" | jq -r '.host.username')" +AUTH_TYPE="$(echo "${JSON}" | jq -r '.host.auth_type')" +KEY_PATH="$(echo "${JSON}" | jq -r '.host.key_path')" +PASSWORD="$(echo "${JSON}" | jq -r '.host.password')" + +if [[ -z "${HOST}" || -z "${USER}" ]]; then + echo "Host data incomplete." + exit 1 +fi + +SSH_OPTS=(-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null) + +if [[ "${AUTH_TYPE}" == "key" && -n "${KEY_PATH}" ]]; then + exec ssh "${SSH_OPTS[@]}" -i "${KEY_PATH}" -p "${PORT:-22}" "${USER}@${HOST}" +elif [[ "${AUTH_TYPE}" == "pass" && -n "${PASSWORD}" ]]; then + exec sshpass -p "${PASSWORD}" ssh "${SSH_OPTS[@]}" -p "${PORT:-22}" "${USER}@${HOST}" +else + exec ssh "${SSH_OPTS[@]}" -p "${PORT:-22}" "${USER}@${HOST}" +fi diff --git a/tools/pi_control/ttyd/Dockerfile b/tools/pi_control/ttyd/Dockerfile new file mode 100644 index 0000000..0593b5a --- /dev/null +++ b/tools/pi_control/ttyd/Dockerfile @@ -0,0 +1,17 @@ +FROM alpine:3.20 + +RUN apk add --no-cache \ + ttyd \ + bash \ + openssh-client \ + curl \ + jq \ + sshpass + +WORKDIR /app +COPY tools/pi_control/terminal_entry.sh /app/tools/pi_control/terminal_entry.sh +RUN chmod +x /app/tools/pi_control/terminal_entry.sh + +EXPOSE 7681 + +ENTRYPOINT ["ttyd", "--url-arg", "--base-path", "/ttyd", "/app/tools/pi_control/terminal_entry.sh"]