Files
nexus/modules/pi_control/pages/hosts.php
2026-03-09 01:08:02 +01:00

472 lines
20 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);
$assets = app()->assets();
if ($assets) {
$assets->addStyle('/module/pi_control/asset?file=pi_control.css');
$assets->addScript('/module/pi_control/asset?file=hosts.js', 'footer', true);
}
$notice = null;
$error = null;
if (isset($_GET['status_json'])) {
require_admin();
$id = (int)($_GET['id'] ?? 0);
$stmt = $pdo->prepare('SELECT * FROM ' . $table('hosts') . ' WHERE id = :id LIMIT 1');
$stmt->execute(['id' => $id]);
$host = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$host) {
header('Content-Type: application/json; charset=utf-8');
echo json_encode(['ok' => false, 'error' => 'not_found']);
exit;
}
$settings = modules()->settings('pi_control');
$strictHostKey = !empty($settings['terminal_strict_hostkey']);
$reachable = hostReachable((string)($host['host'] ?? ''), (int)($host['port'] ?? 22));
$authOk = $reachable ? hostAuthOk($host, $strictHostKey) : false;
$status = !$reachable ? 'down' : ($authOk ? 'ok' : 'auth');
header('Content-Type: application/json; charset=utf-8');
echo json_encode(['ok' => true, 'status' => $status]);
exit;
}
if (isset($_GET['update_json'])) {
require_admin();
$id = (int)($_GET['id'] ?? 0);
$stmt = $pdo->prepare('SELECT * FROM ' . $table('hosts') . ' WHERE id = :id LIMIT 1');
$stmt->execute(['id' => $id]);
$host = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$host) {
header('Content-Type: application/json; charset=utf-8');
echo json_encode(['ok' => false, 'error' => 'not_found']);
exit;
}
$settings = modules()->settings('pi_control');
$strictHostKey = !empty($settings['terminal_strict_hostkey']);
$updateCmd = <<<'SH'
if ! command -v apt-get >/dev/null 2>&1; then echo "__ERR__NO_APT"; exit 2; fi;
if sudo -n apt update -qq >/dev/null 2>&1; then echo "__APT_UPDATE__=1"; else echo "__APT_UPDATE__=0"; fi;
count=$(apt-get -s dist-upgrade 2>/dev/null | grep -c "^Inst ");
echo "__COUNT__=$count"
SH;
$upgradeCmd = <<<'SH'
id="$(. /etc/os-release 2>/dev/null && echo "${ID:-}")";
current="$(. /etc/os-release 2>/dev/null && echo "${VERSION_CODENAME:-}")";
if [ "$id" != "debian" ] || [ -z "$current" ]; then echo "__UPGRADE__=0"; exit 0; fi;
latest="$( (command -v curl >/dev/null 2>&1 && curl -fsSL https://deb.debian.org/debian/dists/stable/Release) || (command -v wget >/dev/null 2>&1 && wget -qO- https://deb.debian.org/debian/dists/stable/Release) )";
latest="$(printf "%s" "$latest" | awk -F": " "/^Codename:/{print $2}")";
if [ -z "$latest" ]; then echo "__UPGRADE__=0"; echo "__RAW__=NO_FETCH"; exit 0; fi;
if [ "$current" != "$latest" ]; then echo "__UPGRADE__=1"; else echo "__UPGRADE__=0"; fi
SH;
[$updExit, $updOut, $updErr] = runSshCommandCapture($host, $updateCmd, $strictHostKey, 20);
$updOutStr = (string)$updOut;
$updErrStr = (string)$updErr;
$updateCount = null;
$updatePreview = '';
$updateErr = str_contains($updOutStr, '__ERR__NO_APT');
if (preg_match('/^__COUNT__=(\d+)$/m', $updOutStr, $m)) {
$updateCount = (int)$m[1];
}
$updatePreview = trim($updOutStr);
if (strlen($updatePreview) > 1200) {
$updatePreview = substr($updatePreview, 0, 1200);
}
[$upgExit, $upgOut, $upgErr] = runSshCommandCapture($host, $upgradeCmd, $strictHostKey, 25);
$upgOutStr = (string)$upgOut;
$upgErrStr = (string)$upgErr;
$upgradeAvailable = null;
$upgradeErr = str_contains($upgOutStr, '__ERR__');
if ($upgExit === 0 && !$upgradeErr) {
if (preg_match('/^__UPGRADE__=(0|1)$/m', $upgOutStr, $m)) {
$upgradeAvailable = $m[1] === '1';
}
}
$driver = (string)$pdo->getAttribute(PDO::ATTR_DRIVER_NAME);
$nowExpr = $driver === 'pgsql' ? 'NOW()' : "DATETIME('now')";
$updCountVal = $updateCount;
$updErrVal = (!$updateErr && $updateCount !== null) ? null : trim($updErrStr ?: $updOutStr);
$upgAvailVal = $upgradeAvailable;
$upgErrVal = $upgExit === 0 && !$upgradeErr ? null : trim($upgErrStr ?: $upgOutStr);
$stmt = $pdo->prepare(
'UPDATE ' . $table('hosts') . ' SET update_checked_at = ' . $nowExpr . ',
update_count = :update_count,
update_preview = :update_preview,
update_error = :update_error,
upgrade_available = :upgrade_available,
upgrade_raw = :upgrade_raw,
upgrade_error = :upgrade_error
WHERE id = :id'
);
$stmt->execute([
'update_count' => $updCountVal,
'update_preview' => $updatePreview !== '' ? $updatePreview : null,
'update_error' => $updErrVal,
'upgrade_available' => $upgAvailVal === null ? null : ($upgAvailVal ? 1 : 0),
'upgrade_raw' => $upgExit === 0 ? trim($upgOutStr) : null,
'upgrade_error' => $upgErrVal,
'id' => $id,
]);
header('Content-Type: application/json; charset=utf-8');
echo json_encode([
'ok' => true,
'updates' => [
'count' => $updateCount,
'preview' => $updatePreview,
'raw' => $updatePreview,
'error' => (!$updateErr && $updateCount !== null) ? '' : trim($updErrStr ?: $updOutStr),
],
'os' => [
'available' => $upgradeAvailable,
'raw' => $upgExit === 0 ? trim($upgOutStr) : '',
'error' => $upgExit === 0 && !$upgradeErr ? '' : trim($upgErrStr ?: $upgOutStr),
],
'checked_at' => date('c'),
]);
exit;
}
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
require_admin();
$deleteId = (int)($_POST['delete_id'] ?? 0);
$editId = (int)($_POST['id'] ?? 0);
$name = trim((string)($_POST['name'] ?? ''));
$host = trim((string)($_POST['host'] ?? ''));
$port = (int)($_POST['port'] ?? 22);
$username = trim((string)($_POST['username'] ?? ''));
$authType = trim((string)($_POST['auth_type'] ?? 'key'));
$keyPath = trim((string)($_POST['key_path'] ?? ''));
$password = trim((string)($_POST['password'] ?? ''));
$imageUrl = trim((string)($_POST['image_url'] ?? ''));
if ($deleteId > 0) {
$stmt = $pdo->prepare('DELETE FROM ' . $table('hosts') . ' WHERE id = :id');
$stmt->execute(['id' => $deleteId]);
$notice = 'Host gelöscht.';
} else {
if ($name === '' || $host === '' || $username === '') {
$error = 'Bitte Name, Host und Benutzer angeben.';
} else {
if ($editId > 0) {
$stmt = $pdo->prepare('SELECT * FROM ' . $table('hosts') . ' WHERE id = :id LIMIT 1');
$stmt->execute(['id' => $editId]);
$existing = $stmt->fetch(PDO::FETCH_ASSOC) ?: [];
$passwordToStore = $password !== '' ? $password : ($existing['password'] ?? null);
$stmt = $pdo->prepare(
'UPDATE ' . $table('hosts') . ' SET name = :name, host = :host, port = :port, username = :username, auth_type = :auth_type, key_path = :key_path, password = :password, image_url = :image_url WHERE id = :id'
);
$stmt->execute([
'id' => $editId,
'name' => $name,
'host' => $host,
'port' => $port > 0 ? $port : 22,
'username' => $username,
'auth_type' => $authType !== '' ? $authType : 'key',
'key_path' => $keyPath !== '' ? $keyPath : null,
'password' => $passwordToStore,
'image_url' => $imageUrl !== '' ? $imageUrl : null,
]);
$notice = 'Host aktualisiert.';
} else {
$stmt = $pdo->prepare(
'INSERT INTO ' . $table('hosts') . ' (name, host, port, username, auth_type, key_path, password, image_url) VALUES (:name, :host, :port, :username, :auth_type, :key_path, :password, :image_url)'
);
$stmt->execute([
'name' => $name,
'host' => $host,
'port' => $port > 0 ? $port : 22,
'username' => $username,
'auth_type' => $authType !== '' ? $authType : 'key',
'key_path' => $keyPath !== '' ? $keyPath : null,
'password' => $password !== '' ? $password : null,
'image_url' => $imageUrl !== '' ? $imageUrl : null,
]);
$notice = 'Host gespeichert.';
}
}
}
}
$hosts = $pdo->query('SELECT * FROM ' . $table('hosts') . ' ORDER BY id DESC')->fetchAll(PDO::FETCH_ASSOC);
$settings = modules()->settings('pi_control');
$strictHostKey = !empty($settings['terminal_strict_hostkey']);
function hostReachable(string $host, int $port): bool
{
$errno = 0;
$errstr = '';
$fp = @fsockopen($host, $port, $errno, $errstr, 1.2);
if ($fp) {
fclose($fp);
return true;
}
return false;
}
function runSshCommand(string $cmd, int $timeoutSec): int
{
$descriptors = [
1 => ['pipe', 'w'],
2 => ['pipe', 'w'],
];
$process = proc_open($cmd, $descriptors, $pipes);
if (!is_resource($process)) {
return 255;
}
stream_set_blocking($pipes[1], false);
stream_set_blocking($pipes[2], false);
$start = time();
while (true) {
$status = proc_get_status($process);
if (!$status['running']) {
$code = (int)$status['exitcode'];
proc_close($process);
return $code;
}
if (time() - $start > $timeoutSec) {
proc_terminate($process, 9);
proc_close($process);
return 124;
}
usleep(100000);
}
}
function runSshCommandCapture(array $host, string $command, bool $strictHostKey, int $timeoutSec): 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'] ?? '');
$opts = $strictHostKey
? '-o StrictHostKeyChecking=accept-new -o UserKnownHostsFile=/root/.ssh/known_hosts'
: '-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null';
$opts .= ' -o ConnectTimeout=6 -o NumberOfPasswordPrompts=1 -o LogLevel=ERROR -o RequestTTY=no';
$target = escapeshellarg($user . '@' . $hostAddr);
$remote = '/bin/sh -c ' . escapeshellarg($command);
$cmd = 'ssh -T ' . $opts . ' -p ' . (int)$port . ' ';
if ($authType === 'key' && $keyPath !== '') {
$cmd .= '-i ' . escapeshellarg($keyPath) . ' -o BatchMode=yes ';
} elseif ($authType === 'key') {
$cmd .= '-o BatchMode=yes ';
}
$cmd .= $target . ' -- ' . $remote;
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 [255, '', 'proc_open failed'];
}
stream_set_blocking($pipes[1], false);
stream_set_blocking($pipes[2], false);
$out = '';
$err = '';
$start = time();
while (true) {
$status = proc_get_status($process);
$out .= stream_get_contents($pipes[1]);
$err .= stream_get_contents($pipes[2]);
if (!$status['running']) {
$exit = (int)$status['exitcode'];
proc_close($process);
return [$exit, $out, $err];
}
if (time() - $start > $timeoutSec) {
proc_terminate($process, 9);
proc_close($process);
return [124, $out, $err];
}
usleep(100000);
}
}
function hostAuthOk(array $host, bool $strictHostKey): bool
{
$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;
}
$opts = $strictHostKey
? '-o StrictHostKeyChecking=accept-new -o UserKnownHostsFile=/root/.ssh/known_hosts'
: '-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null';
$opts .= ' -o ConnectTimeout=2 -o NumberOfPasswordPrompts=1';
$target = escapeshellarg($user . '@' . $hostAddr);
$cmd = 'ssh ' . $opts . ' -p ' . (int)$port . ' ';
if ($authType === 'key' && $keyPath !== '') {
$cmd .= '-i ' . escapeshellarg($keyPath) . ' -o BatchMode=yes ';
} elseif ($authType === 'key') {
$cmd .= '-o BatchMode=yes ';
}
$cmd .= $target . ' -- true';
if ($authType === 'pass') {
if ($password === '') {
return false;
}
$cmd = 'sshpass -p ' . escapeshellarg($password) . ' ' . $cmd;
}
$exitCode = runSshCommand($cmd, 3);
return $exitCode === 0;
}
?>
<div class="card">
<div class="pill">Pi Control</div>
<div style="display:flex; align-items:center; justify-content:space-between; gap:12px; flex-wrap:wrap; margin-top:.75rem;">
<h1 style="margin:0;">Hosts</h1>
<div style="display:flex; gap:10px; flex-wrap:wrap;">
<button class="nav-link" type="button" data-host-check-all>Alle Hosts prüfen</button>
<button class="cta-button" type="button" data-host-new>+ Neuer Host</button>
</div>
</div>
<p class="muted">Verwalte die Raspberry Pis, die du steuern möchtest.</p>
<?php if ($error): ?>
<div class="card notice-card" style="margin-top:1rem; border-color:#ffb4a8; background:#fff5f3; color:#7a2114;">
<?= e($error) ?>
</div>
<?php elseif ($notice): ?>
<div class="card notice-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>Registrierte Hosts</strong>
<?php if (!$hosts): ?>
<div class="muted" style="margin-top:.75rem;">Keine Hosts vorhanden.</div>
<?php else: ?>
<div class="host-grid" style="margin-top:.75rem;">
<?php foreach ($hosts as $h): ?>
<div class="host-card"
data-host-id="<?= e((string)$h['id']) ?>"
data-name="<?= e((string)($h['name'] ?? '')) ?>"
data-host="<?= e((string)($h['host'] ?? '')) ?>"
data-port="<?= e((string)($h['port'] ?? 22)) ?>"
data-username="<?= e((string)($h['username'] ?? '')) ?>"
data-auth="<?= e((string)($h['auth_type'] ?? 'key')) ?>"
data-key-path="<?= e((string)($h['key_path'] ?? '')) ?>"
data-image-url="<?= e((string)($h['image_url'] ?? '')) ?>"
data-update-checked="<?= e((string)($h['update_checked_at'] ?? '')) ?>"
data-update-count="<?= e((string)($h['update_count'] ?? '')) ?>"
data-update-error="<?= e((string)($h['update_error'] ?? '')) ?>"
data-update-preview="<?= e((string)($h['update_preview'] ?? '')) ?>"
data-upgrade-available="<?= e((string)($h['upgrade_available'] ?? '')) ?>"
data-upgrade-raw="<?= e((string)($h['upgrade_raw'] ?? '')) ?>"
data-upgrade-error="<?= e((string)($h['upgrade_error'] ?? '')) ?>">
<div class="host-card-image"<?= !empty($h['image_url']) ? ' style="background-image:url(' . e((string)$h['image_url']) . ')"' : '' ?>>
<div class="host-card-overlay"></div>
</div>
<div class="host-card-body">
<div class="host-card-header">
<div class="host-card-title">
<span class="status-dot status-auth" data-host-status></span>
<strong><?= e((string)($h['name'] ?? '')) ?></strong>
</div>
<details class="action-menu">
<summary>☰</summary>
<div class="action-menu-panel">
<button class="nav-link" type="button" data-host-edit>Bearbeiten</button>
<form method="post" onsubmit="return confirm('Host wirklich löschen?')">
<input type="hidden" name="delete_id" value="<?= e((string)$h['id']) ?>">
<button class="nav-link" type="submit">Löschen</button>
</form>
</div>
</details>
</div>
<div class="muted"><?= e((string)($h['host'] ?? '')) ?>:<?= e((string)($h['port'] ?? 22)) ?></div>
<div class="muted"><?= e((string)($h['username'] ?? '')) ?> · <?= e((string)($h['auth_type'] ?? '')) ?></div>
<div class="host-update-row">
<span class="update-badge" data-update-badge>Updates: </span>
<span class="update-badge" data-upgrade-badge>OS: </span>
<span class="muted" data-update-time>Nie geprüft</span>
</div>
<pre class="host-debug" data-update-debug>Update Debug: </pre>
<pre class="host-debug" data-upgrade-debug>Upgrade Debug: </pre>
<div style="display:flex; gap:8px; flex-wrap:wrap;">
<button class="nav-link" type="button" data-host-check>Updates prüfen</button>
</div>
</div>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
</div>
</div>
</div>
<div class="modal" data-host-modal aria-hidden="true">
<div class="modal-card">
<div class="modal-header">
<strong data-host-modal-title>Neuer Host</strong>
<button class="icon-button" type="button" data-host-close>×</button>
</div>
<form method="post" class="form-grid" style="margin-top:.75rem;" data-host-form>
<input type="hidden" name="id" value="">
<label class="form-field">
<span class="muted">Name</span>
<input type="text" name="name" placeholder="z.B. Wohnzimmer-Pi" required title="Eindeutiger Anzeigename für diesen Pi.">
</label>
<label class="form-field">
<span class="muted">Host / IP</span>
<input type="text" name="host" placeholder="z.B. 192.168.178.14 oder pi.local" required title="Hostname oder IP im Heimnetzwerk.">
</label>
<label class="form-field">
<span class="muted">SSH Port</span>
<input type="number" name="port" placeholder="22" value="22" title="Standard ist 22.">
</label>
<label class="form-field">
<span class="muted">SSH Benutzer</span>
<input type="text" name="username" placeholder="z.B. pi" required title="Benutzername auf dem Pi.">
</label>
<label class="form-field">
<span class="muted">Auth-Typ</span>
<select name="auth_type" title="key = SSH-Key, password = Passwort">
<option value="key">key (SSH-Key)</option>
<option value="pass">pass (Passwort)</option>
</select>
</label>
<label class="form-field">
<span class="muted">Key-Pfad (optional)</span>
<input type="text" name="key_path" placeholder="/home/app/.ssh/id_rsa" title="Pfad zum Private-Key im Webserver-Container.">
</label>
<label class="form-field">
<span class="muted">Passwort (optional)</span>
<input type="password" name="password" placeholder="SSH-Passwort" title="Nur nutzen, wenn Auth-Typ = pass.">
</label>
<label class="form-field">
<span class="muted">Bild-URL (optional)</span>
<input type="text" name="image_url" placeholder="https://..." title="Optionales Bild für die Host-Karte.">
</label>
<div style="display:flex; gap:10px; flex-wrap:wrap;">
<button class="cta-button" type="submit" data-host-submit>Speichern</button>
<button class="nav-link" type="button" data-host-cancel>Zurücksetzen</button>
</div>
</form>
</div>
</div>