diff --git a/examples/ServerManager/Services/HetznerService.php b/examples/ServerManager/Services/HetznerService.php index 203fc52..9591b5b 100644 --- a/examples/ServerManager/Services/HetznerService.php +++ b/examples/ServerManager/Services/HetznerService.php @@ -36,6 +36,7 @@ class HetznerService 'root' => 'unbekannt', 'data' => 'unbekannt', 'last_backup' => 'unbekannt', + 'ssh_keys' => [], ]; } @@ -71,6 +72,7 @@ class HetznerService 'root' => '35%', 'data' => '42%', 'last_backup' => 'unbekannt', + 'ssh_keys' => [], ]; } return $testData; diff --git a/examples/ServerManager/UI/ServerListTab.php b/examples/ServerManager/UI/ServerListTab.php index 6d32a23..183f057 100644 --- a/examples/ServerManager/UI/ServerListTab.php +++ b/examples/ServerManager/UI/ServerListTab.php @@ -20,7 +20,7 @@ use ServerManager\UI\LoadingIndicator; class ServerListTab { private Settings $settings; - private null|KanbanTab $kanbanTab; + private ?KanbanTab $kanbanTab; private Container $tab; private VirtualListView $table; private TextInput $searchInput; @@ -30,7 +30,7 @@ class ServerListTab private Button $sshTerminalButton; public array $currentServerData = []; - public null|array $selectedServer = null; + public ?array $selectedServer = null; private Label $detailId; private Label $detailName; @@ -38,12 +38,14 @@ class ServerListTab private Label $detailType; private Label $detailIpv4; private Container $detailDomainsContainer; + private Container $detailSshKeysContainer; private LoadingIndicator $loadingIndicator; private TextInput $serverApiKeyInput; + private TextInput $borgmaticPassphraseInput; private Container $todoListContainer; private TextInput $todoInput; private array $currentServerTodos = []; - private null|int $currentServerId = null; + private ?int $currentServerId = null; public function __construct( string &$apiKey, @@ -51,7 +53,7 @@ class ServerListTab TabContainer $tabContainer, Label $statusLabel, Settings $settings, - null|KanbanTab $kanbanTab = null, + ?KanbanTab $kanbanTab = null, ) { $this->settings = $settings; $this->kanbanTab = $kanbanTab; @@ -79,6 +81,17 @@ class ServerListTab $this->refreshButton->setIcon($refreshIcon); $headerRow->addComponent($this->refreshButton); + // Button to refresh selected servers only + $refreshSelectedButton = new Button( + 'Ausgewählte aktualisieren', + 'px-3 shadow-lg/50 py-2 bg-green-600 rounded hover:bg-green-700', + null, + 'text-white', + ); + $refreshSelectedIcon = new Icon(IconName::sync, 16, 'text-white'); + $refreshSelectedButton->setIcon($refreshSelectedIcon); + $headerRow->addComponent($refreshSelectedButton); + // Bulk action buttons for selected servers $rebootButton = new Button( 'Reboot ausgewählte', @@ -150,6 +163,12 @@ class ServerListTab 'render' => [$this, 'renderOsVersionCell'], ], ['key' => 'release', 'title' => 'Release', 'width' => 140], + [ + 'key' => 'ssh_keys_count', + 'title' => 'SSH Keys', + 'width' => 80, + 'render' => [$this, 'renderSshKeysCell'], + ], [ 'key' => 'root', 'title' => 'Root', @@ -189,7 +208,7 @@ class ServerListTab $style = 'px-4 py-2 text-black font-bold border-r border-gray-300'; if ($width) { - $style .= ' w-' . ((int) ($width / 4)); + $style .= ' w-' . (int) ($width / 4); } else { $style .= ' flex-1'; } @@ -239,6 +258,11 @@ class ServerListTab $this->detailDomainsContainer = new Container('flex flex-col gap-1'); $detailPanel->addComponent($this->detailDomainsContainer); + // SSH Keys list + $detailPanel->addComponent(new Label('SSH Keys:', 'text-xs text-gray-500 mt-2')); + $this->detailSshKeysContainer = new Container('flex flex-col gap-1'); + $detailPanel->addComponent($this->detailSshKeysContainer); + // SFTP Manager Button (handler will be set by SftpManagerTab) $this->sftpButton = new Button( 'SFTP Manager öffnen', @@ -312,6 +336,13 @@ class ServerListTab ); $detailPanel->addComponent($this->serverApiKeyInput); + $detailPanel->addComponent(new Label('Borgmatic Passphrase:', 'text-xs text-gray-500 mt-2')); + $this->borgmaticPassphraseInput = new TextInput( + 'Borgmatic Passphrase', + 'w-full border border-gray-300 rounded px-3 py-2 bg-white text-black', + ); + $detailPanel->addComponent($this->borgmaticPassphraseInput); + $saveServerSettingsButton = new Button( 'Server-Einstellungen speichern', 'mt-1 px-3 py-2 bg-green-600 rounded hover:bg-green-700', @@ -341,6 +372,7 @@ class ServerListTab $this->setupEventHandlers( $currentApiKey, $currentPrivateKeyPath, + $refreshSelectedButton, $rebootButton, $updateButton, $saveServerSettingsButton, @@ -351,6 +383,7 @@ class ServerListTab private function setupEventHandlers( string &$currentApiKey, string &$currentPrivateKeyPath, + Button $refreshSelectedButton, Button $rebootButton, Button $updateButton, Button $saveServerSettingsButton, @@ -370,16 +403,22 @@ class ServerListTab // Load per-server settings (API key, todos) $serverId = $row['id'] ?? null; - $serverListTab->currentServerId = is_numeric($serverId) ? ((int) $serverId) : null; + $serverListTab->currentServerId = is_numeric($serverId) ? (int) $serverId : null; if ($serverListTab->currentServerId !== null) { $settingsKeyBase = 'servers.' . $serverListTab->currentServerId; $apiKey = $serverListTab->settings->get($settingsKeyBase . '.api_key', ''); + $borgmaticPassphrase = $serverListTab->settings->get( + $settingsKeyBase . '.borgmatic_passphrase', + '', + ); $serverListTab->serverApiKeyInput->setValue($apiKey); + $serverListTab->borgmaticPassphraseInput->setValue($borgmaticPassphrase); $serverListTab->loadServerTasks(); } else { $serverListTab->serverApiKeyInput->setValue(''); + $serverListTab->borgmaticPassphraseInput->setValue(''); $serverListTab->currentServerTodos = []; $serverListTab->renderTodoList(); } @@ -387,6 +426,9 @@ class ServerListTab $domains = $row['domains'] ?? []; $serverListTab->updateDomainDetails(is_array($domains) ? $domains : []); + $sshKeys = $row['ssh_keys'] ?? []; + $serverListTab->updateSshKeysDetails(is_array($sshKeys) ? $sshKeys : []); + $serverListTab->selectedServer = $row; } }); @@ -396,17 +438,39 @@ class ServerListTab $searchTerm = strtolower(trim($value)); if (empty($searchTerm)) { - $serverListTab->table->setData($serverListTab->currentServerData); + $serverListTab->table->updateData($serverListTab->currentServerData); } else { $filteredData = array_filter($serverListTab->currentServerData, function ($row) use ($searchTerm) { - return ( - str_contains(strtolower($row['name']), $searchTerm) || - count(array_filter($row['domains'], function ($item) use ($searchTerm) { + // Search in server name + if (str_contains(strtolower($row['name']), $searchTerm)) { + return true; + } + + // Search in domains + $domains = $row['domains'] ?? []; + if ( + is_array($domains) + && count(array_filter($domains, function ($item) use ($searchTerm) { return str_contains(strtolower($item), $searchTerm); })) - ); + ) { + return true; + } + + // Search in SSH keys + $sshKeys = $row['ssh_keys'] ?? []; + if ( + is_array($sshKeys) + && count(array_filter($sshKeys, function ($item) use ($searchTerm) { + return str_contains(strtolower($item), $searchTerm); + })) + ) { + return true; + } + + return false; }); - $serverListTab->table->setData(array_values($filteredData)); + $serverListTab->table->updateData(array_values($filteredData)); } }); @@ -464,9 +528,10 @@ class ServerListTab 'root' => 'unbekannt', 'data' => 'unbekannt', 'last_backup' => 'unbekannt', + 'ssh_keys' => [], ], $row), $result['servers']); - $serverListTab->table->setData($serverListTab->currentServerData); + $serverListTab->table->updateData($serverListTab->currentServerData); $serverListTab->statusLabel->setText('Server geladen: ' . $result['count'] . ' gefunden'); // Kanban-Board aktualisieren, damit Servernamen in den Karten up-to-date sind @@ -485,6 +550,16 @@ class ServerListTab // Danach: pro Server asynchron Docker-Infos und Systemstatus nachladen foreach ($serverListTab->currentServerData as $index => $row) { $ip = $row['ipv4'] ?? ''; + $serverId = $row['id'] ?? null; + + // Read saved borgmatic passphrase before async task + $savedBorgmaticPassphrase = null; + if ($serverId !== null) { + $savedBorgmaticPassphrase = $serverListTab->settings->get( + 'servers.' . $serverId . '.borgmatic_passphrase', + null + ); + } if (empty($ip) || empty($currentPrivateKeyPath) || !file_exists($currentPrivateKeyPath)) { $serverListTab->currentServerData[$index]['docker_error'] = 'Kein gültiger Private-Key oder IP'; @@ -494,9 +569,11 @@ class ServerListTab } $dockerTask = TaskManager::getInstance()->runAsync(function () use ( - $ip, - $currentPrivateKeyPath, - $index, + &$ip, + &$currentPrivateKeyPath, + &$index, + &$serverId, + &$savedBorgmaticPassphrase, ) { try { $ssh = new \phpseclib3\Net\SSH2($ip); @@ -510,6 +587,9 @@ class ServerListTab 'docker' => 'fetch usage', 'docker_error' => 'SSH Login fehlgeschlagen', 'docker_status' => 'error', + 'ssh_keys' => [], + 'borgmatic_passphrase' => null, + 'server_id' => $serverId, ]; } @@ -523,7 +603,7 @@ class ServerListTab $updatesOutput = trim($ssh->exec( 'apt-get -s upgrade 2>/dev/null | grep -c "^Inst " || echo 0', )); - $updatesCount = is_numeric($updatesOutput) ? ((int) $updatesOutput) : 0; + $updatesCount = is_numeric($updatesOutput) ? (int) $updatesOutput : 0; // Read Ubuntu version (PRETTY_NAME from /etc/os-release) $osOutput = trim($ssh->exec( @@ -531,6 +611,103 @@ class ServerListTab )); $osVersion = $osOutput !== '' ? $osOutput : 'unbekannt'; + // Read authorized SSH keys + $sshKeysOutput = trim($ssh->exec( + 'cat ~/.ssh/authorized_keys 2>/dev/null || echo ""', + )); + $sshKeys = []; + if ($sshKeysOutput !== '') { + $lines = preg_split('/\r\n|\r|\n/', $sshKeysOutput); + foreach ($lines as $line) { + $line = trim($line); + // Skip empty lines and comments + if ($line !== '' && !str_starts_with($line, '#')) { + $sshKeys[] = $line; + } + } + } + + // Read borgmatic passphrase (only if not already saved locally) + $borgmaticPassphrase = null; + if ($serverId !== null) { + $logFile = '/tmp/borgmatic_debug.log'; + file_put_contents( + $logFile, + '[' . date('Y-m-d H:i:s') . "] Server ID: {$serverId}\n", + FILE_APPEND, + ); + file_put_contents( + $logFile, + 'Saved passphrase: ' . ($savedBorgmaticPassphrase ?? 'NULL') . "\n", + FILE_APPEND, + ); + + if ($savedBorgmaticPassphrase === null || $savedBorgmaticPassphrase === '') { + // Read from server + $borgmaticConfigOutput = trim($ssh->exec( + 'cat /etc/borgmatic/config.yaml 2>/dev/null || echo ""', + )); + + file_put_contents( + $logFile, + 'Config length: ' . strlen($borgmaticConfigOutput) . " bytes\n", + FILE_APPEND, + ); + + if ($borgmaticConfigOutput !== '') { + // Extract passphrase from YAML (with leading whitespace) + if (preg_match( + '/^\s*encryption_passphrase:\s*["\']([^"\']+)["\']/m', + $borgmaticConfigOutput, + $matches, + )) { + $borgmaticPassphrase = $matches[1]; + file_put_contents( + $logFile, + "Found passphrase with quotes: {$borgmaticPassphrase}\n", + FILE_APPEND, + ); + } elseif (preg_match( + '/^\s*encryption_passphrase:\s*([^\s]+)/m', + $borgmaticConfigOutput, + $matches, + )) { + $borgmaticPassphrase = trim($matches[1]); + file_put_contents( + $logFile, + "Found passphrase without quotes: {$borgmaticPassphrase}\n", + FILE_APPEND, + ); + } else { + file_put_contents($logFile, "No passphrase found!\n", FILE_APPEND); + file_put_contents( + $logFile, + "First 300 chars:\n" + . substr($borgmaticConfigOutput, 0, 300) + . "\n", + FILE_APPEND, + ); + } + + // Passphrase will be saved in onComplete handler + } else { + file_put_contents( + $logFile, + "Config file empty or not found\n", + FILE_APPEND, + ); + } + } else { + $borgmaticPassphrase = $savedBorgmaticPassphrase; + file_put_contents( + $logFile, + "Using saved passphrase: {$borgmaticPassphrase}\n", + FILE_APPEND, + ); + } + file_put_contents($logFile, "---\n", FILE_APPEND); + } + // Last backup from borgmatic $lastBackup = 'unbekannt'; $borgmaticOutput = trim($ssh->exec( @@ -539,23 +716,23 @@ class ServerListTab if ($borgmaticOutput !== '') { $borgmaticData = json_decode($borgmaticOutput, true); if ( - json_last_error() === JSON_ERROR_NONE && - is_array($borgmaticData) && - !empty($borgmaticData) + json_last_error() === JSON_ERROR_NONE + && is_array($borgmaticData) + && !empty($borgmaticData) ) { // borgmatic list --json gibt ein Array von Repositories zurück $firstRepo = reset($borgmaticData); if ( - is_array($firstRepo) && - isset($firstRepo['archives']) && - is_array($firstRepo['archives']) && - !empty($firstRepo['archives']) + is_array($firstRepo) + && isset($firstRepo['archives']) + && is_array($firstRepo['archives']) + && !empty($firstRepo['archives']) ) { $lastArchive = reset($firstRepo['archives']); if ( - is_array($lastArchive) && - isset($lastArchive['time']) && - is_string($lastArchive['time']) + is_array($lastArchive) + && isset($lastArchive['time']) + && is_string($lastArchive['time']) ) { try { $backupTime = new \DateTime($lastArchive['time']); @@ -597,7 +774,7 @@ class ServerListTab $runningOutput = trim($ssh->exec( "docker ps --filter status=running --format '{{.ID}}' | wc -l", )); - $runningCount = is_numeric($runningOutput) ? ((int) $runningOutput) : null; + $runningCount = is_numeric($runningOutput) ? (int) $runningOutput : null; $output = $ssh->exec('docker inspect psc-web-1'); if (empty($output)) { @@ -613,6 +790,9 @@ class ServerListTab 'root' => $rootUsage, 'data' => $dataUsage, 'last_backup' => $lastBackup, + 'ssh_keys' => $sshKeys, + 'borgmatic_passphrase' => $borgmaticPassphrase, + 'server_id' => $serverId, ]; } @@ -630,6 +810,9 @@ class ServerListTab 'root' => $rootUsage, 'data' => $dataUsage, 'last_backup' => $lastBackup, + 'ssh_keys' => $sshKeys, + 'borgmatic_passphrase' => $borgmaticPassphrase, + 'server_id' => $serverId, ]; } @@ -655,8 +838,7 @@ class ServerListTab if ($releaseDomain !== '') { $url = $releaseDomain; if ( - !str_starts_with($url, 'http://') && - !str_starts_with($url, 'https://') + !str_starts_with($url, 'http://') && !str_starts_with($url, 'https://') ) { $url = 'https://' . $url; } @@ -678,8 +860,7 @@ class ServerListTab if (isset($data['version']) && is_string($data['version'])) { $releaseValue = trim($data['version']); } elseif ( - isset($data['release']) && - is_string($data['release']) + isset($data['release']) && is_string($data['release']) ) { $releaseValue = trim($data['release']); } @@ -690,9 +871,9 @@ class ServerListTab } if ( - $releaseValue !== null && - $dateValue !== null && - $dateValue !== '' + $releaseValue !== null + && $dateValue !== null + && $dateValue !== '' ) { $release = $releaseValue . ' (' . $dateValue . ')'; } elseif ($releaseValue !== null) { @@ -728,6 +909,9 @@ class ServerListTab 'domains' => $domains, 'release' => $release, 'last_backup' => $lastBackup, + 'ssh_keys' => $sshKeys, + 'borgmatic_passphrase' => $borgmaticPassphrase, + 'server_id' => $serverId, ]; } catch (\Throwable $e) { return [ @@ -742,6 +926,9 @@ class ServerListTab 'root' => 'unbekannt', 'data' => 'unbekannt', 'last_backup' => 'unbekannt', + 'ssh_keys' => [], + 'borgmatic_passphrase' => null, + 'server_id' => $serverId, ]; } }); @@ -758,24 +945,38 @@ class ServerListTab if (array_key_exists('docker', $dockerResult)) { $serverListTab->currentServerData[$i]['docker'] = $dockerResult['docker']; - $searchTerm = $serverListTab->searchInput->getValue(); + $searchTerm = strtolower(trim($serverListTab->searchInput->getValue())); if (empty($searchTerm)) { - $serverListTab->table->setData($serverListTab->currentServerData); + $serverListTab->table->updateData($serverListTab->currentServerData); } else { $filteredData = array_filter( $serverListTab->currentServerData, function ($row) use ($searchTerm) { - return ( - str_contains(strtolower($row['name']), $searchTerm) || - count(array_filter($row['domains'], function ($item) use ( - $searchTerm, - ) { + if (str_contains(strtolower($row['name']), $searchTerm)) { + return true; + } + $domains = $row['domains'] ?? []; + if ( + is_array($domains) + && count(array_filter($domains, function ($item) use ($searchTerm) { return str_contains(strtolower($item), $searchTerm); })) - ); + ) { + return true; + } + $sshKeys = $row['ssh_keys'] ?? []; + if ( + is_array($sshKeys) + && count(array_filter($sshKeys, function ($item) use ($searchTerm) { + return str_contains(strtolower($item), $searchTerm); + })) + ) { + return true; + } + return false; }, ); - $serverListTab->table->setData(array_values($filteredData)); + $serverListTab->table->updateData(array_values($filteredData)); } } @@ -802,7 +1003,7 @@ class ServerListTab if (array_key_exists('updates', $dockerResult)) { $updates = (int) ($dockerResult['updates'] ?? 0); $serverListTab->currentServerData[$i]['updates_available'] = $updates > 0 - ? ('ja (' . $updates . ')') + ? 'ja (' . $updates . ')' : 'nein'; } @@ -857,12 +1058,35 @@ class ServerListTab ? $lastBackup : 'unbekannt'; } + + if (array_key_exists('ssh_keys', $dockerResult)) { + $sshKeys = $dockerResult['ssh_keys']; + if (!is_array($sshKeys)) { + $sshKeys = []; + } + $serverListTab->currentServerData[$i]['ssh_keys'] = $sshKeys; + } + + // Save borgmatic passphrase if found and new + if (array_key_exists('borgmatic_passphrase', $dockerResult) + && array_key_exists('server_id', $dockerResult) + && $dockerResult['borgmatic_passphrase'] !== null + && $dockerResult['borgmatic_passphrase'] !== '' + && $dockerResult['server_id'] !== null) { + $serverId = $dockerResult['server_id']; + $passphrase = $dockerResult['borgmatic_passphrase']; + $serverListTab->settings->set( + 'servers.' . $serverId . '.borgmatic_passphrase', + $passphrase + ); + $serverListTab->settings->save(); + } }); $dockerTask->onError(function ($error) use (&$serverListTab, $index) { $errorMsg = is_object($error) && method_exists($error, 'getMessage') ? $error->getMessage() - : ((string) $error); + : (string) $error; if (isset($serverListTab->currentServerData[$index])) { $serverListTab->currentServerData[$index]['docker_error'] = 'Async Fehler: ' . $errorMsg; @@ -879,7 +1103,7 @@ class ServerListTab $task->onError(function ($error) use (&$serverListTab) { $errorMsg = is_object($error) && method_exists($error, 'getMessage') ? $error->getMessage() - : ((string) $error); + : (string) $error; $serverListTab->statusLabel->setText('Async Fehler: ' . $errorMsg); echo "Async error: {$errorMsg}\n"; @@ -887,6 +1111,578 @@ class ServerListTab }); }); + // Refresh selected servers only + $refreshSelectedButton->setOnClick(function () use (&$currentApiKey, $serverListTab, &$currentPrivateKeyPath) { + $selected = []; + foreach ($serverListTab->currentServerData as $index => $row) { + if (!empty($row['selected'])) { + $selected[] = ['index' => $index, 'row' => $row]; + } + } + + if (empty($selected)) { + $serverListTab->statusLabel->setText('Keine Server ausgewählt für Aktualisierung'); + return; + } + + if (empty($currentPrivateKeyPath) || !file_exists($currentPrivateKeyPath)) { + $serverListTab->statusLabel->setText('Private Key Pfad nicht konfiguriert'); + return; + } + + $serverListTab->loadingIndicator->setLoading(true); + $serverListTab->statusLabel->setText('Aktualisiere ' . count($selected) . ' ausgewählte Server...'); + + foreach ($selected as $item) { + $index = $item['index']; + $row = $item['row']; + $ip = $row['ipv4'] ?? ''; + $serverId = $row['id'] ?? null; + + // Read saved borgmatic passphrase before async task + $savedBorgmaticPassphrase = null; + if ($serverId !== null) { + $savedBorgmaticPassphrase = $serverListTab->settings->get( + 'servers.' . $serverId . '.borgmatic_passphrase', + null + ); + } + + if (empty($ip)) { + continue; + } + + // Mark as pending + $serverListTab->currentServerData[$index]['docker_status'] = 'pending'; + $serverListTab->currentServerData[$index]['docker_running'] = 'pending'; + + $dockerTask = TaskManager::getInstance()->runAsync(function () use ( + &$ip, + &$currentPrivateKeyPath, + &$index, + &$serverId, + &$savedBorgmaticPassphrase, + ) { + try { + $ssh = new \phpseclib3\Net\SSH2($ip); + $key = \phpseclib3\Crypt\PublicKeyLoader::loadPrivateKey(file_get_contents( + $currentPrivateKeyPath, + )); + + if (!$ssh->login('root', $key)) { + return [ + 'index' => $index, + 'docker' => 'fetch usage', + 'docker_error' => 'SSH Login fehlgeschlagen', + 'docker_status' => 'error', + 'ssh_keys' => [], + 'borgmatic_passphrase' => null, + 'server_id' => $serverId, + ]; + } + + // Check if reboot is required (Ubuntu) + $rebootOutput = trim($ssh->exec('[ -f /var/run/reboot-required ] && echo yes || echo no')); + $needsReboot = $rebootOutput === 'yes'; + + // Check number of available package updates (Ubuntu) + $updatesOutput = trim($ssh->exec( + 'apt-get -s upgrade 2>/dev/null | grep -c "^Inst " || echo 0', + )); + $updatesCount = is_numeric($updatesOutput) ? (int) $updatesOutput : 0; + + // Read Ubuntu version (PRETTY_NAME from /etc/os-release) + $osOutput = trim($ssh->exec( + 'grep "^PRETTY_NAME=" /etc/os-release 2>/dev/null | cut -d= -f2 | tr -d \'"\'', + )); + $osVersion = $osOutput !== '' ? $osOutput : 'unbekannt'; + + // Read authorized SSH keys + $sshKeysOutput = trim($ssh->exec('cat ~/.ssh/authorized_keys 2>/dev/null || echo ""')); + $sshKeys = []; + if ($sshKeysOutput !== '') { + $lines = preg_split('/\r\n|\r|\n/', $sshKeysOutput); + foreach ($lines as $line) { + $line = trim($line); + // Skip empty lines and comments + if ($line !== '' && !str_starts_with($line, '#')) { + $sshKeys[] = $line; + } + } + } + + // Read borgmatic passphrase (only if not already saved locally) + $borgmaticPassphrase = null; + if ($serverId !== null) { + $logFile = '/tmp/borgmatic_debug.log'; + file_put_contents( + $logFile, + '[' . date('Y-m-d H:i:s') . "] Server ID: {$serverId}\n", + FILE_APPEND, + ); + file_put_contents( + $logFile, + 'Saved passphrase: ' . ($savedBorgmaticPassphrase ?? 'NULL') . "\n", + FILE_APPEND, + ); + + if ($savedBorgmaticPassphrase === null || $savedBorgmaticPassphrase === '') { + // Read from server + $borgmaticConfigOutput = trim($ssh->exec( + 'cat /etc/borgmatic/config.yaml 2>/dev/null || echo ""', + )); + + file_put_contents( + $logFile, + 'Config length: ' . strlen($borgmaticConfigOutput) . " bytes\n", + FILE_APPEND, + ); + + if ($borgmaticConfigOutput !== '') { + // Extract passphrase from YAML (with leading whitespace) + if (preg_match( + '/^\s*encryption_passphrase:\s*["\']([^"\']+)["\']/m', + $borgmaticConfigOutput, + $matches, + )) { + $borgmaticPassphrase = $matches[1]; + file_put_contents( + $logFile, + "Found passphrase with quotes: {$borgmaticPassphrase}\n", + FILE_APPEND, + ); + } elseif (preg_match( + '/^\s*encryption_passphrase:\s*([^\s]+)/m', + $borgmaticConfigOutput, + $matches, + )) { + $borgmaticPassphrase = trim($matches[1]); + file_put_contents( + $logFile, + "Found passphrase without quotes: {$borgmaticPassphrase}\n", + FILE_APPEND, + ); + } else { + file_put_contents($logFile, "No passphrase found!\n", FILE_APPEND); + file_put_contents( + $logFile, + "First 300 chars:\n" . substr($borgmaticConfigOutput, 0, 300) . "\n", + FILE_APPEND, + ); + } + + // Passphrase will be saved in onComplete handler + } else { + file_put_contents($logFile, "Config file empty or not found\n", FILE_APPEND); + } + } else { + $borgmaticPassphrase = $savedBorgmaticPassphrase; + file_put_contents( + $logFile, + "Using saved passphrase: {$borgmaticPassphrase}\n", + FILE_APPEND, + ); + } + file_put_contents($logFile, "---\n", FILE_APPEND); + } + + // Last backup from borgmatic + $lastBackup = 'unbekannt'; + $borgmaticOutput = trim($ssh->exec('borgmatic list --last 1 --json 2>/dev/null || echo ""')); + if ($borgmaticOutput !== '') { + $borgmaticData = json_decode($borgmaticOutput, true); + if ( + json_last_error() === JSON_ERROR_NONE + && is_array($borgmaticData) + && !empty($borgmaticData) + ) { + // borgmatic list --json gibt ein Array von Repositories zurück + $firstRepo = reset($borgmaticData); + if ( + is_array($firstRepo) + && isset($firstRepo['archives']) + && is_array($firstRepo['archives']) + && !empty($firstRepo['archives']) + ) { + $lastArchive = reset($firstRepo['archives']); + if ( + is_array($lastArchive) + && isset($lastArchive['time']) + && is_string($lastArchive['time']) + ) { + try { + $backupTime = new \DateTime($lastArchive['time']); + $lastBackup = $backupTime->format('d.m.Y H:i'); + } catch (\Exception $e) { + $lastBackup = $lastArchive['time']; + } + } + } + } + } + + // Disk usage for / and /data + $rootUsage = 'unbekannt'; + $dfRoot = trim($ssh->exec('df -P / 2>/dev/null')); + if ($dfRoot !== '') { + $lines = preg_split('/\r\n|\r|\n/', $dfRoot); + if (count($lines) >= 2) { + $parts = preg_split('/\s+/', trim($lines[1])); + if (isset($parts[4]) && $parts[4] !== '') { + $rootUsage = $parts[4]; + } + } + } + + $dataUsage = 'unbekannt'; + $dfData = trim($ssh->exec('df -P /data 2>/dev/null')); + if ($dfData !== '') { + $lines = preg_split('/\r\n|\r|\n/', $dfData); + if (count($lines) >= 2) { + $parts = preg_split('/\s+/', trim($lines[1])); + if (isset($parts[4]) && $parts[4] !== '') { + $dataUsage = $parts[4]; + } + } + } + + // Docker status for main application container + $runningOutput = trim($ssh->exec( + "docker ps --filter status=running --format '{{.ID}}' | wc -l", + )); + $runningCount = is_numeric($runningOutput) ? (int) $runningOutput : null; + + $output = $ssh->exec('docker inspect psc-web-1'); + if (empty($output)) { + return [ + 'index' => $index, + 'docker' => null, + 'docker_error' => 'Leere Docker-Antwort', + 'docker_status' => 'error', + 'docker_running' => $runningCount, + 'needs_reboot' => $needsReboot, + 'updates' => $updatesCount, + 'os_version' => $osVersion, + 'root' => $rootUsage, + 'data' => $dataUsage, + 'last_backup' => $lastBackup, + 'ssh_keys' => $sshKeys, + 'borgmatic_passphrase' => $borgmaticPassphrase, + 'server_id' => $serverId, + ]; + } + + $json = json_decode($output, true); + if (json_last_error() !== JSON_ERROR_NONE) { + return [ + 'index' => $index, + 'docker' => null, + 'docker_error' => 'Ungültige Docker-JSON-Antwort', + 'docker_status' => 'error', + 'docker_running' => $runningCount, + 'needs_reboot' => $needsReboot, + 'updates' => $updatesCount, + 'os_version' => $osVersion, + 'root' => $rootUsage, + 'data' => $dataUsage, + 'last_backup' => $lastBackup, + 'ssh_keys' => $sshKeys, + 'borgmatic_passphrase' => $borgmaticPassphrase, + 'server_id' => $serverId, + ]; + } + + $domains = []; + if (isset($json[0]['Config']['Env']) && is_array($json[0]['Config']['Env'])) { + $hosts = array_filter($json[0]['Config']['Env'], static function ($item) { + return is_string($item) && str_starts_with($item, 'LETSENCRYPT_HOST'); + }); + if (!empty($hosts)) { + $hostEntry = array_first($hosts); + $hostString = substr((string) $hostEntry, strlen('LETSENCRYPT_HOST=')); + $domains = array_values(array_filter( + array_map('trim', explode(',', $hostString)), + static fn($d) => $d !== '', + )); + } + } + + $release = 'unbekannt'; + if (!empty($domains)) { + $releaseDomain = $domains[0]; + $releaseDomain = trim($releaseDomain); + if ($releaseDomain !== '') { + $url = $releaseDomain; + if (!str_starts_with($url, 'http://') && !str_starts_with($url, 'https://')) { + $url = 'https://' . $url; + } + $url = rtrim($url, '/') . '/apps/api/system/version'; + + try { + $context = stream_context_create([ + 'http' => [ + 'method' => 'GET', + 'timeout' => 5, + ], + ]); + + $response = file_get_contents($url, false, $context); + if ($response !== false) { + $data = json_decode($response, true); + if (json_last_error() === JSON_ERROR_NONE && is_array($data)) { + $releaseValue = null; + if (isset($data['version']) && is_string($data['version'])) { + $releaseValue = trim($data['version']); + } elseif (isset($data['release']) && is_string($data['release'])) { + $releaseValue = trim($data['release']); + } + + $dateValue = null; + if (isset($data['datum']) && is_string($data['datum'])) { + $dateValue = trim($data['datum']); + } + + if ($releaseValue !== null && $dateValue !== null && $dateValue !== '') { + $release = $releaseValue . ' (' . $dateValue . ')'; + } elseif ($releaseValue !== null) { + $release = $releaseValue; + } elseif ($dateValue !== null && $dateValue !== '') { + $release = $dateValue; + } else { + $release = 'unbekannt'; + } + } else { + $release = 'Ungültig'; + } + } else { + $release = 'Fehler'; + } + } catch (\Throwable) { + $release = 'Fehler'; + } + } + } + + return [ + 'index' => $index, + 'docker' => $json, + 'docker_error' => null, + 'docker_status' => 'ok', + 'docker_running' => $runningCount, + 'needs_reboot' => $needsReboot, + 'updates' => $updatesCount, + 'os_version' => $osVersion, + 'root' => $rootUsage, + 'data' => $dataUsage, + 'domains' => $domains, + 'release' => $release, + 'last_backup' => $lastBackup, + 'ssh_keys' => $sshKeys, + 'borgmatic_passphrase' => $borgmaticPassphrase, + 'server_id' => $serverId, + ]; + } catch (\Throwable $e) { + return [ + 'index' => $index, + 'docker' => null, + 'docker_error' => 'SSH-Fehler: ' . $e->getMessage(), + 'docker_status' => 'error', + 'docker_running' => null, + 'needs_reboot' => null, + 'updates' => null, + 'os_version' => null, + 'root' => 'unbekannt', + 'data' => 'unbekannt', + 'last_backup' => 'unbekannt', + 'ssh_keys' => [], + 'borgmatic_passphrase' => null, + 'server_id' => $serverId, + ]; + } + }); + + $dockerTask->onComplete(function ($dockerResult) use (&$serverListTab, &$selected) { + if (!is_array($dockerResult) || !isset($dockerResult['index'])) { + return; + } + + $i = $dockerResult['index']; + if (!isset($serverListTab->currentServerData[$i])) { + return; + } + + if (array_key_exists('docker', $dockerResult)) { + $serverListTab->currentServerData[$i]['docker'] = $dockerResult['docker']; + } + + if (array_key_exists('domains', $dockerResult)) { + $domains = $dockerResult['domains']; + if (!is_array($domains)) { + $domains = []; + } + $serverListTab->currentServerData[$i]['domains'] = $domains; + } + + // Map system status into human-readable table fields + if (array_key_exists('needs_reboot', $dockerResult)) { + $needsReboot = $dockerResult['needs_reboot']; + if ($needsReboot === null) { + $serverListTab->currentServerData[$i]['needs_reboot'] = 'unbekannt'; + } else { + $serverListTab->currentServerData[$i]['needs_reboot'] = $needsReboot ? 'ja' : 'nein'; + } + } + + if (array_key_exists('updates', $dockerResult)) { + $updates = (int) ($dockerResult['updates'] ?? 0); + $serverListTab->currentServerData[$i]['updates_available'] = $updates > 0 + ? 'ja (' . $updates . ')' + : 'nein'; + } + + if (array_key_exists('os_version', $dockerResult)) { + $osVersion = $dockerResult['os_version']; + $serverListTab->currentServerData[$i]['os_version'] = $osVersion !== null + ? $osVersion + : 'unbekannt'; + } + + if (array_key_exists('root', $dockerResult)) { + $rootUsage = $dockerResult['root']; + $serverListTab->currentServerData[$i]['root'] = $rootUsage !== null ? $rootUsage : 'unbekannt'; + } + + if (array_key_exists('data', $dockerResult)) { + $dataUsage = $dockerResult['data']; + $serverListTab->currentServerData[$i]['data'] = $dataUsage !== null ? $dataUsage : 'unbekannt'; + } + + if (array_key_exists('release', $dockerResult)) { + $release = $dockerResult['release']; + $serverListTab->currentServerData[$i]['release'] = $release !== null ? $release : 'unbekannt'; + } + + if (array_key_exists('docker_error', $dockerResult)) { + $serverListTab->currentServerData[$i]['docker_error'] = $dockerResult['docker_error']; + } + + if (array_key_exists('docker_status', $dockerResult)) { + $serverListTab->currentServerData[$i]['docker_status'] = $dockerResult['docker_status']; + } + + if (array_key_exists('docker_running', $dockerResult)) { + $serverListTab->currentServerData[$i]['docker_running'] = $dockerResult['docker_running'] + !== null + ? $dockerResult['docker_running'] + : 'unbekannt'; + } + + if (array_key_exists('last_backup', $dockerResult)) { + $lastBackup = $dockerResult['last_backup']; + $serverListTab->currentServerData[$i]['last_backup'] = $lastBackup !== null + ? $lastBackup + : 'unbekannt'; + } + + if (array_key_exists('ssh_keys', $dockerResult)) { + $sshKeys = $dockerResult['ssh_keys']; + if (!is_array($sshKeys)) { + $sshKeys = []; + } + $serverListTab->currentServerData[$i]['ssh_keys'] = $sshKeys; + } + + // Save borgmatic passphrase if found and new + if (array_key_exists('borgmatic_passphrase', $dockerResult) + && array_key_exists('server_id', $dockerResult) + && $dockerResult['borgmatic_passphrase'] !== null + && $dockerResult['borgmatic_passphrase'] !== '' + && $dockerResult['server_id'] !== null) { + $serverId = $dockerResult['server_id']; + $passphrase = $dockerResult['borgmatic_passphrase']; + $serverListTab->settings->set( + 'servers.' . $serverId . '.borgmatic_passphrase', + $passphrase + ); + $serverListTab->settings->save(); + } + + // Update table display + $searchTerm = strtolower(trim($serverListTab->searchInput->getValue())); + if (empty($searchTerm)) { + $serverListTab->table->updateData($serverListTab->currentServerData); + } else { + $filteredData = array_filter($serverListTab->currentServerData, function ($row) use ( + $searchTerm, + ) { + if (str_contains(strtolower($row['name']), $searchTerm)) { + return true; + } + $domains = $row['domains'] ?? []; + if ( + is_array($domains) + && count(array_filter($domains, function ($item) use ($searchTerm) { + return str_contains(strtolower($item), $searchTerm); + })) + ) { + return true; + } + $sshKeys = $row['ssh_keys'] ?? []; + if ( + is_array($sshKeys) + && count(array_filter($sshKeys, function ($item) use ($searchTerm) { + return str_contains(strtolower($item), $searchTerm); + })) + ) { + return true; + } + return false; + }); + $serverListTab->table->updateData(array_values($filteredData)); + } + + // Check if all selected servers are done + $allDone = true; + foreach ($selected as $item) { + $idx = $item['index']; + $status = $serverListTab->currentServerData[$idx]['docker_status'] ?? 'pending'; + if ($status === 'pending') { + $allDone = false; + break; + } + } + + if ($allDone) { + $serverListTab->loadingIndicator->setLoading(false); + $serverListTab->statusLabel->setText( + 'Aktualisierung abgeschlossen für ' . count($selected) . ' Server', + ); + if (function_exists('desktop_notify')) { + desktop_notify( + 'Server aktualisiert', + 'Die ausgewählten Server wurden erfolgreich aktualisiert.', + ['timeout' => 4000, 'urgency' => 'normal'], + ); + } + } + }); + + $dockerTask->onError(function ($error) use (&$serverListTab, $index) { + $errorMsg = is_object($error) && method_exists($error, 'getMessage') + ? $error->getMessage() + : (string) $error; + if (isset($serverListTab->currentServerData[$index])) { + $serverListTab->currentServerData[$index]['docker_error'] = 'Async Fehler: ' . $errorMsg; + $serverListTab->currentServerData[$index]['docker_running'] = 'error'; + $serverListTab->currentServerData[$index]['docker_status'] = 'error'; + } + }); + } + + // Update table to show pending status + $serverListTab->table->updateData($serverListTab->currentServerData); + }); + // Reboot selected servers $rebootButton->setOnClick(function () use (&$currentPrivateKeyPath, $serverListTab) { $selected = []; @@ -1017,15 +1813,17 @@ class ServerListTab } // Run update & upgrade non-interactively and then output reboot + update status in one go - $statusOutput = trim($ssh->exec('apt-get update >/dev/null 2>&1 && ' . - 'DEBIAN_FRONTEND=noninteractive apt-get -y upgrade >/dev/null 2>&1; ' . - 'reboot_flag=$([ -f /var/run/reboot-required ] && echo yes || echo no); ' . - 'updates=$(apt-get -s upgrade 2>/dev/null | grep -c "^Inst " || echo 0); ' . - 'echo "$reboot_flag $updates"')); + $statusOutput = trim($ssh->exec( + 'apt-get update >/dev/null 2>&1 && ' + . 'DEBIAN_FRONTEND=noninteractive apt-get -y upgrade >/dev/null 2>&1; ' + . 'reboot_flag=$([ -f /var/run/reboot-required ] && echo yes || echo no); ' + . 'updates=$(apt-get -s upgrade 2>/dev/null | grep -c "^Inst " || echo 0); ' + . 'echo "$reboot_flag $updates"', + )); $parts = preg_split('/\s+/', $statusOutput); $rebootFlag = $parts[0] ?? 'no'; - $updatesCount = isset($parts[1]) && is_numeric($parts[1]) ? ((int) $parts[1]) : 0; + $updatesCount = isset($parts[1]) && is_numeric($parts[1]) ? (int) $parts[1] : 0; $needsReboot = $rebootFlag === 'yes'; return [ @@ -1053,7 +1851,7 @@ class ServerListTab if (isset($result['updates'])) { $updates = (int) $result['updates']; $serverListTab->currentServerData[$index]['updates_available'] = $updates > 0 - ? ('ja (' . $updates . ')') + ? 'ja (' . $updates . ')' : 'nein'; } @@ -1061,21 +1859,37 @@ class ServerListTab $needsReboot = (bool) $result['needs_reboot']; $serverListTab->currentServerData[$index]['needs_reboot'] = $needsReboot ? 'ja' : 'nein'; } - $searchTerm = $serverListTab->searchInput->getValue(); + $searchTerm = strtolower(trim($serverListTab->searchInput->getValue())); if (empty($searchTerm)) { - $serverListTab->table->setData($serverListTab->currentServerData); + $serverListTab->table->updateData($serverListTab->currentServerData); } else { $filteredData = array_filter($serverListTab->currentServerData, function ($row) use ( $searchTerm, ) { - return ( - str_contains(strtolower($row['name']), $searchTerm) || - count(array_filter($row['domains'], function ($item) use ($searchTerm) { + if (str_contains(strtolower($row['name']), $searchTerm)) { + return true; + } + $domains = $row['domains'] ?? []; + if ( + is_array($domains) + && count(array_filter($domains, function ($item) use ($searchTerm) { return str_contains(strtolower($item), $searchTerm); })) - ); + ) { + return true; + } + $sshKeys = $row['ssh_keys'] ?? []; + if ( + is_array($sshKeys) + && count(array_filter($sshKeys, function ($item) use ($searchTerm) { + return str_contains(strtolower($item), $searchTerm); + })) + ) { + return true; + } + return false; }); - $serverListTab->table->setData(array_values($filteredData)); + $serverListTab->table->updateData(array_values($filteredData)); } } @@ -1100,7 +1914,7 @@ class ServerListTab } }); - // Save per-server settings (API key + todos) + // Save per-server settings (API key, borgmatic passphrase + todos) $saveServerSettingsButton->setOnClick(function () use ($serverListTab) { if ($serverListTab->currentServerId === null) { $serverListTab->statusLabel->setText('Kein Server für das Speichern ausgewählt'); @@ -1109,12 +1923,14 @@ class ServerListTab $settingsKeyBase = 'servers.' . $serverListTab->currentServerId; $apiKey = trim($serverListTab->serverApiKeyInput->getValue()); + $borgmaticPassphrase = trim($serverListTab->borgmaticPassphraseInput->getValue()); $serverListTab->settings->set($settingsKeyBase . '.api_key', $apiKey); + $serverListTab->settings->set($settingsKeyBase . '.borgmatic_passphrase', $borgmaticPassphrase); $serverListTab->settings->save(); - $serverListTab->statusLabel->setText('Server-Einstellungen gespeichert für Server #' . - $serverListTab->currentServerId); + $serverListTab->statusLabel->setText('Server-Einstellungen gespeichert für Server #' + . $serverListTab->currentServerId); }); // Add TODO for current server @@ -1258,7 +2074,7 @@ class ServerListTab $intServerId = (int) $serverId; $doneBoard = (string) $this->settings->get('kanban.done_board', 'fertig'); foreach ($tasks as $task) { - if (((int) ($task['server_id'] ?? 0)) === $intServerId) { + if ((int) ($task['server_id'] ?? 0) === $intServerId) { $taskBoard = (string) ($task['board'] ?? 'neu'); if ($taskBoard !== $doneBoard) { $hasOpenTasks = true; @@ -1282,7 +2098,7 @@ class ServerListTab $hasError = strtolower(trim($status)) === 'error' || !empty($rowData['docker_error'] ?? null); $baseStyle = 'px-4 text-sm '; - $style = $hasError ? ($baseStyle . 'text-red-600') : ($baseStyle . 'text-gray-800'); + $style = $hasError ? $baseStyle . 'text-red-600' : $baseStyle . 'text-gray-800'; return new Label($status, $style); } @@ -1356,7 +2172,7 @@ class ServerListTab if (str_ends_with($numeric, '%')) { $numeric = substr($numeric, 0, -1); } - if (is_numeric($numeric) && ((int) $numeric) >= 90) { + if (is_numeric($numeric) && (int) $numeric >= 90) { $style = $baseStyle . 'text-red-600'; } } @@ -1399,6 +2215,22 @@ class ServerListTab return new Label($value, $style); } + public function renderSshKeysCell(array $rowData, int $rowIndex): Label + { + $sshKeys = $rowData['ssh_keys'] ?? []; + $count = is_array($sshKeys) ? count($sshKeys) : 0; + + $baseStyle = 'px-4 text-sm text-center '; + + if ($count === 0) { + $style = $baseStyle . 'text-gray-500 italic'; + return new Label('0', $style); + } + + $style = $baseStyle . 'text-blue-600 font-semibold'; + return new Label((string) $count, $style); + } + private function updateDomainDetails(array $domains): void { $this->detailDomainsContainer->clearChildren(); @@ -1434,6 +2266,40 @@ class ServerListTab } } + private function updateSshKeysDetails(array $sshKeys): void + { + $this->detailSshKeysContainer->clearChildren(); + + if (empty($sshKeys)) { + $this->detailSshKeysContainer->addComponent(new Label( + 'Keine SSH Keys gefunden', + 'text-xs text-gray-500 italic', + )); + return; + } + + foreach ($sshKeys as $key) { + if (!is_string($key) || trim($key) === '') { + continue; + } + + $key = trim($key); + // Extract key type and comment/fingerprint for display + $parts = explode(' ', $key, 3); + $keyType = $parts[0] ?? 'unknown'; + $keyFingerprint = isset($parts[1]) ? substr($parts[1], 0, 20) . '...' : ''; + $keyComment = $parts[2] ?? ''; + + $displayText = $keyType . ' ' . $keyFingerprint; + if ($keyComment !== '') { + $displayText .= ' (' . $keyComment . ')'; + } + + $keyLabel = new Label($displayText, 'text-xs text-gray-700 font-mono break-all'); + $this->detailSshKeysContainer->addComponent($keyLabel); + } + } + private function renderTodoList(): void { $this->todoListContainer->clearChildren(); @@ -1500,7 +2366,7 @@ class ServerListTab foreach ($serverListTab->currentServerData as $index => $row) { if (($row['id'] ?? null) === $rowId) { $serverListTab->currentServerData[$index]['selected'] = $checked; - $serverListTab->table->setData($serverListTab->currentServerData); + $serverListTab->table->updateData($serverListTab->currentServerData); break; } } @@ -1524,7 +2390,7 @@ class ServerListTab } $this->currentServerTodos = array_values(array_filter($tasks, function ($task) { - return ((int) ($task['server_id'] ?? 0)) === ((int) $this->currentServerId); + return (int) ($task['server_id'] ?? 0) === (int) $this->currentServerId; })); $this->renderTodoList(); diff --git a/src/Ui/Widget/VirtualListView.php b/src/Ui/Widget/VirtualListView.php index 86d8b57..ba4ea14 100644 --- a/src/Ui/Widget/VirtualListView.php +++ b/src/Ui/Widget/VirtualListView.php @@ -41,6 +41,16 @@ class VirtualListView extends Container $this->markDirty(true); } + /** + * Update data without resetting scroll position + * Useful for updating row data while preserving user's scroll position + */ + public function updateData(array $rows): void + { + $this->rows = array_values($rows); + $this->markDirty(true); + } + public function setOnRowSelect(callable $callback): void { $this->onRowSelect = $callback;