schneespur/release/schneespur-1.0.2/app/Services/SchneespurUpdater.php
Michael 7288b93500 Release v1.0.2: diagnostic infrastructure core
Add neutral diagnostic framework for future reporting modules:
- DiagnosticReporterInterface, Registry, Manager, PayloadSanitizer
- Laravel exception hook in bootstrap/app.php
- Module permission declarations (requires_permissions in module.json)
- Core diagnostic report points (module boot/install/update failures)
- Module documentation update (moduldoku.md)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-18 16:54:11 +00:00

844 lines
28 KiB
PHP

<?php
namespace App\Services;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use RuntimeException;
use ZipArchive;
class SchneespurUpdater
{
private string $rootPubkeyRaw;
private string $baseUrl;
private string $slug;
private string $statePath;
private string $stagingDir;
public function __construct()
{
$b64 = config('schneespur_update.root_pubkey_b64');
if (! function_exists('sodium_crypto_sign_verify_detached')) {
throw new RuntimeException('ext-sodium is required for update verification');
}
$this->rootPubkeyRaw = base64_decode($b64, true);
if (strlen($this->rootPubkeyRaw) !== SODIUM_CRYPTO_SIGN_PUBLICKEYBYTES) {
throw new RuntimeException('Configured root_pubkey_b64 has wrong length');
}
$this->baseUrl = rtrim(config('schneespur_update.base_url'), '/');
$this->slug = config('schneespur_update.slug');
$this->statePath = config('schneespur_update.state_path');
$this->stagingDir = config('schneespur_update.staging_dir');
}
// ── Trust & Manifest (unchanged logic) ──────────────
public function refreshTrust(): void
{
$state = $this->loadState();
$r = Http::acceptJson()->timeout(15)
->get("{$this->baseUrl}/api/signing/trust");
if ($r->status() === 404) {
throw new RuntimeException(
'Server liefert noch keine signed trust.json — '
. 'Operator muss zuerst trust-tool sign --initial laufen lassen.'
);
}
if ($r->failed()) {
throw new RuntimeException("HTTP {$r->status()} bei trust-fetch");
}
$body = $r->json();
$trust = $body['trust'] ?? null;
$sigB64 = $body['signature'] ?? null;
if (! is_array($trust) || ! is_string($sigB64)) {
throw new RuntimeException('Trust-Response hat unerwartete Form');
}
$sigRaw = base64_decode($sigB64, true);
if ($sigRaw === false || strlen($sigRaw) !== SODIUM_CRYPTO_SIGN_BYTES) {
throw new RuntimeException('Trust-Signature-Base64 ungültig');
}
$canonical = self::canonicalJson($trust);
if (! sodium_crypto_sign_verify_detached($sigRaw, $canonical, $this->rootPubkeyRaw)) {
throw new RuntimeException(
'Trust-Signatur ungültig — Root-Mismatch oder MITM'
);
}
$newVersion = (int) ($trust['trust_version'] ?? 0);
$localVersion = (int) ($state['trust_version'] ?? 0);
if ($newVersion < $localVersion) {
throw new RuntimeException(
"Trust-Rollback-Versuch: server={$newVersion} < local={$localVersion}"
);
}
$expires = strtotime((string) ($trust['expires_at'] ?? ''));
if ($expires === false || $expires <= time()) {
throw new RuntimeException(
'Trust-Liste ist abgelaufen — Operator muss neu signieren'
);
}
$state['trust_version'] = $newVersion;
$state['valid_keys'] = $trust['valid_keys'];
$state['revoked_keys'] = $trust['revoked_keys'];
$state['trust_expires_at'] = (string) ($trust['expires_at'] ?? '');
$this->writeState($state);
}
public function checkForUpdate(): ?array
{
$this->refreshTrust();
$state = $this->loadState();
$r = Http::acceptJson()->timeout(15)
->get("{$this->baseUrl}/api/projects/{$this->slug}/manifest");
if ($r->status() === 404) {
Log::info('schneespur-update: kein signiertes Release verfügbar');
$this->writeLastCheck($state, false);
return null;
}
if ($r->failed()) {
throw new RuntimeException("HTTP {$r->status()} beim manifest-fetch");
}
$body = $r->json();
$manifest = $body['manifest'] ?? null;
$signatureB64 = $body['signature'] ?? null;
if (! is_array($manifest) || ! is_string($signatureB64)) {
throw new RuntimeException('Manifest-Response hat unerwartete Form');
}
$keyId = $manifest['key_id'] ?? null;
foreach ($state['revoked_keys'] ?? [] as $rk) {
if (($rk['key_id'] ?? null) === $keyId) {
throw new RuntimeException(
"Signing-Key {$keyId} wurde widerrufen, reason=" . ($rk['reason'] ?? 'unknown')
);
}
}
$match = null;
foreach ($state['valid_keys'] ?? [] as $vk) {
if (($vk['key_id'] ?? null) === $keyId) {
$match = $vk;
break;
}
}
if ($match === null) {
throw new RuntimeException(
'Unbekannter Signing-Key — Trust-Layer-Mismatch'
);
}
$pubRaw = base64_decode($match['pubkey_b64'], true);
if ($pubRaw === false || strlen($pubRaw) !== SODIUM_CRYPTO_SIGN_PUBLICKEYBYTES) {
throw new RuntimeException('valid_keys-Pubkey-Format ungültig');
}
if (($manifest['project'] ?? null) !== $this->slug) {
throw new RuntimeException("Falsches Manifest: project={$manifest['project']}");
}
if (($manifest['manifest_schema_version'] ?? null) !== 2) {
throw new RuntimeException(
'Manifest-Schema-Version ungültig — erwartet 2, '
. 'bekam ' . var_export($manifest['manifest_schema_version'] ?? null, true)
);
}
$sigRaw = base64_decode($signatureB64, true);
if ($sigRaw === false || strlen($sigRaw) !== SODIUM_CRYPTO_SIGN_BYTES) {
throw new RuntimeException('Signature-Base64 ungültig');
}
$canonical = self::canonicalJson($manifest);
if (! sodium_crypto_sign_verify_detached($sigRaw, $canonical, $pubRaw)) {
throw new RuntimeException('Signatur ungültig — MITM oder beschädigt');
}
$newCounter = (int) $manifest['counter'];
if ($newCounter <= (int) ($state['last_counter'] ?? 0)) {
throw new RuntimeException(
"Rollback-Versuch: counter={$newCounter} <= zuletzt={$state['last_counter']}"
);
}
if ($manifest['version'] === ($state['current_version'] ?? '')) {
$this->writeLastCheck($state, false);
return null;
}
$this->writeLastCheck($state, true, $manifest);
return $manifest;
}
// ── Download & Verify ──────────────────────────
public function downloadAndVerifyZip(array $manifest): string
{
$this->logPhase('download', 'start', ['version' => $manifest['version']]);
$url = $this->baseUrl . $manifest['url'];
$tmp = tempnam(sys_get_temp_dir(), 'schneespur-');
$r = Http::timeout(120)->withOptions(['sink' => $tmp])->get($url);
if ($r->failed()) {
$this->safeUnlink($tmp);
$this->logPhase('download', 'failed', ['http_status' => $r->status()]);
throw new RuntimeException("ZIP-Download HTTP {$r->status()}");
}
$this->logPhase('download', 'complete', ['path' => $tmp]);
$this->logPhase('verify', 'start');
clearstatcache(true, $tmp);
$actualSize = filesize($tmp);
if ($actualSize !== (int) $manifest['size_bytes']) {
$this->safeUnlink($tmp);
$this->logPhase('verify', 'failed', ['reason' => 'size_mismatch']);
throw new RuntimeException(
"Grösse stimmt nicht: {$actualSize} vs signiert {$manifest['size_bytes']}"
);
}
$actualSha = hash_file('sha256', $tmp);
if (! hash_equals($manifest['sha256'], $actualSha)) {
$this->safeUnlink($tmp);
$this->logPhase('verify', 'failed', ['reason' => 'sha256_mismatch']);
throw new RuntimeException(
"sha256 stimmt nicht: {$actualSha} vs signiert {$manifest['sha256']}"
);
}
$this->logPhase('verify', 'complete');
return $tmp;
}
// ── Preflight ──────────────────────────────────
public function canInstall(): array
{
$checks = [];
$checks['sodium'] = function_exists('sodium_crypto_sign_verify_detached');
$checks['zip'] = class_exists(ZipArchive::class);
$baseDir = base_path();
$checks['writable'] = is_writable($baseDir);
$stagingParent = dirname($this->stagingDir);
try {
$this->ensureDirectory($stagingParent);
} catch (RuntimeException) {
$checks['disk_space'] = false;
return $checks;
}
$freeSpace = disk_free_space($stagingParent);
$checks['disk_space'] = $freeSpace !== false && $freeSpace > 100 * 1024 * 1024;
return $checks;
}
// ── Extract with ZIP Safety ──────────────────────
public function extractAndStage(string $zipPath): string
{
if (! class_exists(ZipArchive::class)) {
throw new RuntimeException('ext-zip is required for update installation');
}
$this->logPhase('extract', 'start');
$this->cleanStaging();
$this->ensureDirectory($this->stagingDir);
$zip = new ZipArchive;
$result = $zip->open($zipPath);
if ($result !== true) {
throw new RuntimeException("ZIP konnte nicht geöffnet werden: error code {$result}");
}
$this->validateZipEntries($zip);
$zip->extractTo($this->stagingDir);
$zip->close();
$this->logPhase('extract', 'complete', ['files' => $this->countFiles($this->stagingDir)]);
return $this->stagingDir;
}
private function validateZipEntries(ZipArchive $zip): void
{
$resolvedStaging = realpath($this->stagingDir) ?: $this->stagingDir;
for ($i = 0; $i < $zip->numFiles; $i++) {
$stat = $zip->statIndex($i);
if ($stat === false) {
throw new RuntimeException("ZIP-Eintrag #{$i} konnte nicht gelesen werden");
}
$name = $stat['name'];
if (str_contains($name, '..')) {
throw new RuntimeException(
"ZIP-Path-Traversal blockiert: '{$name}' enthält '..'"
);
}
if (str_starts_with($name, '/') || preg_match('/^[A-Za-z]:/', $name)) {
throw new RuntimeException(
"ZIP-absoluter-Pfad blockiert: '{$name}'"
);
}
$resolved = realpath($this->stagingDir . '/' . dirname($name));
if ($resolved !== false && ! str_starts_with($resolved, $resolvedStaging)) {
throw new RuntimeException(
"ZIP-Eintrag verlässt Staging-Verzeichnis: '{$name}'"
);
}
if (function_exists('posix_getpwuid')) {
$externalAttr = $zip->getExternalAttributesIndex($i, $opsys, $attr);
if ($externalAttr && $opsys === ZipArchive::OPSYS_UNIX) {
$fileType = ($attr >> 16) & 0xF000;
if ($fileType === 0xA000) {
throw new RuntimeException(
"ZIP-Symlink blockiert: '{$name}'"
);
}
}
}
}
}
// ── Install (atomic, with backup + rollback) ────────
public function install(string $zipPath, array $manifest): void
{
$this->logPhase('install', 'start', ['version' => $manifest['version']]);
$preflight = $this->canInstall();
if (in_array(false, $preflight, true)) {
$failed = array_keys(array_filter($preflight, fn ($v) => ! $v));
throw new RuntimeException('Preflight fehlgeschlagen: ' . implode(', ', $failed));
}
$backupDir = $this->createPreUpdateBackup();
$this->logPhase('backup', 'complete', ['path' => $backupDir]);
$secret = bin2hex(random_bytes(16));
Artisan::call('down', ['--secret' => $secret]);
$this->logPhase('maintenance', 'enabled');
try {
$this->extractAndStage($zipPath);
$this->logPhase('copy', 'start');
$this->copyFiles();
$this->logPhase('copy', 'complete');
$this->logPhase('migrate', 'start');
Artisan::call('migrate', ['--force' => true]);
$this->logPhase('migrate', 'complete');
Artisan::call('config:cache');
Artisan::call('route:cache');
Artisan::call('view:cache');
$this->logPhase('cache', 'complete');
$this->commitState($manifest);
$this->logPhase('state', 'committed', ['version' => $manifest['version']]);
} catch (\Throwable $e) {
$this->logPhase('install', 'failed', [
'error' => $e->getMessage(),
'backup_dir' => $backupDir,
]);
$this->writeRecoveryInfo($manifest, $backupDir, $e->getMessage());
throw new RuntimeException(
__('update.install_failed', ['error' => $e->getMessage()])
. ' — Recovery: php artisan schneespur:update-recover'
);
} finally {
$this->cleanStaging();
}
Artisan::call('up');
$this->logPhase('maintenance', 'disabled');
$this->logPhase('install', 'complete', ['version' => $manifest['version']]);
$this->cleanOldBackups(2);
}
// ── File Copy (no silent errors) ────────────────────
private function copyFiles(): void
{
$source = $this->stagingDir;
$target = base_path();
$skip = ['.env', 'storage', 'bootstrap/cache'];
$iterator = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($source, \FilesystemIterator::SKIP_DOTS),
\RecursiveIteratorIterator::SELF_FIRST
);
$copied = 0;
$dirs = 0;
foreach ($iterator as $item) {
$relative = substr($item->getPathname(), strlen($source) + 1);
foreach ($skip as $prefix) {
if ($relative === $prefix || str_starts_with($relative, $prefix . '/') || str_starts_with($relative, $prefix . DIRECTORY_SEPARATOR)) {
continue 2;
}
}
$dest = $target . '/' . $relative;
if ($item->isDir()) {
if (! is_dir($dest)) {
$this->ensureDirectory($dest);
$dirs++;
}
} else {
$destDir = dirname($dest);
$this->ensureDirectory($destDir);
if (! copy($item->getPathname(), $dest)) {
throw new RuntimeException("Datei konnte nicht kopiert werden: {$relative}");
}
$copied++;
}
}
$this->logPhase('copy', 'stats', ['files' => $copied, 'dirs' => $dirs]);
}
// ── Pre-Update Backup ──────────────────────────────
private function createPreUpdateBackup(): string
{
$backupBase = config('schneespur_update.backup_dir', storage_path('app/schneespur_backups'));
$state = $this->loadState();
$version = $state['current_version'] ?: 'unknown';
$backupDir = $backupBase . '/' . $version . '_' . date('Ymd_His');
$this->ensureDirectory($backupDir, 0755);
$criticalFiles = [
'composer.json',
'composer.lock',
'artisan',
'bootstrap/app.php',
'bootstrap/providers.php',
'config/app.php',
];
$basePath = base_path();
foreach ($criticalFiles as $file) {
$src = $basePath . '/' . $file;
if (! is_file($src)) {
continue;
}
$dst = $backupDir . '/' . $file;
$dstDir = dirname($dst);
$this->ensureDirectory($dstDir);
if (! copy($src, $dst)) {
throw new RuntimeException("Backup-Kopie fehlgeschlagen: {$file}");
}
}
$stateBackup = $backupDir . '/schneespur_update_state.json';
if (is_file($this->statePath)) {
if (! copy($this->statePath, $stateBackup)) {
throw new RuntimeException('Backup der State-Datei fehlgeschlagen');
}
}
return $backupDir;
}
private function cleanOldBackups(int $keep): void
{
$backupBase = config('schneespur_update.backup_dir', storage_path('app/schneespur_backups'));
if (! is_dir($backupBase)) {
return;
}
$dirs = [];
foreach (new \DirectoryIterator($backupBase) as $entry) {
if ($entry->isDot() || ! $entry->isDir()) {
continue;
}
$dirs[$entry->getPathname()] = $entry->getMTime();
}
arsort($dirs);
$toDelete = array_slice(array_keys($dirs), $keep);
foreach ($toDelete as $dir) {
$this->recursiveDelete($dir);
}
}
// ── Recovery ────────────────────────────────────────
private function writeRecoveryInfo(array $manifest, string $backupDir, string $error): void
{
$info = [
'failed_at' => now()->toIso8601String(),
'target_version' => $manifest['version'],
'error' => $error,
'backup_dir' => $backupDir,
'maintenance_mode' => true,
'recovery_steps' => [
'1. php artisan schneespur:update-recover',
'2. Or manually: php artisan up',
'3. Check logs: storage/logs/schneespur-update.log',
],
];
$path = storage_path('app/schneespur_update_recovery.json');
$this->atomicJsonWrite($path, $info);
}
public function getRecoveryInfo(): ?array
{
$path = storage_path('app/schneespur_update_recovery.json');
if (! is_file($path)) {
return null;
}
$raw = file_get_contents($path);
if ($raw === false) {
return null;
}
$data = json_decode($raw, true);
return is_array($data) ? $data : null;
}
public function clearRecoveryInfo(): void
{
$path = storage_path('app/schneespur_update_recovery.json');
if (is_file($path)) {
$this->safeUnlink($path);
}
}
public function restoreFromBackup(string $backupDir): bool
{
if (! is_dir($backupDir)) {
$this->logPhase('recovery', 'failed', ['reason' => 'backup_dir_missing', 'path' => $backupDir]);
return false;
}
$this->logPhase('recovery', 'start', ['backup' => $backupDir]);
$basePath = base_path();
$iterator = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($backupDir, \FilesystemIterator::SKIP_DOTS),
\RecursiveIteratorIterator::SELF_FIRST
);
$restored = 0;
foreach ($iterator as $item) {
if ($item->isDir()) {
continue;
}
$relative = substr($item->getPathname(), strlen($backupDir) + 1);
if ($relative === 'schneespur_update_state.json') {
if (! copy($item->getPathname(), $this->statePath)) {
$this->logPhase('recovery', 'warning', ['file' => 'state', 'action' => 'copy_failed']);
}
$restored++;
continue;
}
$dest = $basePath . '/' . $relative;
$destDir = dirname($dest);
try {
$this->ensureDirectory($destDir);
} catch (RuntimeException) {
continue;
}
if (copy($item->getPathname(), $dest)) {
$restored++;
}
}
$this->logPhase('recovery', 'complete', ['files_restored' => $restored]);
return $restored > 0;
}
// ── Post-Install Integrity Check ────────────────────
public function verifyInstallation(array $manifest): array
{
$result = [
'ok' => true,
'checks' => [],
'warnings' => [],
];
$criticalPaths = [
'artisan',
'bootstrap/app.php',
'public/index.php',
'vendor/autoload.php',
];
foreach ($criticalPaths as $path) {
$full = base_path($path);
if (! is_file($full)) {
$result['ok'] = false;
$result['checks'][$path] = 'missing';
} else {
$result['checks'][$path] = 'ok';
}
}
$state = $this->loadState();
if ($state['current_version'] !== $manifest['version']) {
$result['ok'] = false;
$result['warnings'][] = 'State-Version stimmt nicht: '
. $state['current_version'] . ' vs ' . $manifest['version'];
}
return $result;
}
// ── State Persistence (atomic) ─────────────────────
public function commitState(array $manifest): void
{
$state = $this->loadState();
$state['last_counter'] = (int) $manifest['counter'];
$state['current_version'] = $manifest['version'];
$state['updated_at'] = now()->toIso8601String();
$this->writeState($state);
}
public function getState(): array
{
return $this->loadState();
}
public function loadState(): array
{
if (! is_file($this->statePath)) {
return [
'last_counter' => 0,
'current_version' => '',
'trust_version' => 0,
'valid_keys' => [],
'revoked_keys' => [],
'trust_expires_at' => '',
'last_check' => null,
];
}
$raw = file_get_contents($this->statePath);
if ($raw === false) {
throw new RuntimeException("State-File konnte nicht gelesen werden: {$this->statePath}");
}
$parsed = json_decode($raw, true);
if (! is_array($parsed)) {
throw new RuntimeException("State-File korrupt: {$this->statePath}");
}
return [
'last_counter' => (int) ($parsed['last_counter'] ?? 0),
'current_version' => (string) ($parsed['current_version'] ?? ''),
'trust_version' => (int) ($parsed['trust_version'] ?? 0),
'valid_keys' => $parsed['valid_keys'] ?? [],
'revoked_keys' => $parsed['revoked_keys'] ?? [],
'trust_expires_at' => (string) ($parsed['trust_expires_at'] ?? ''),
'last_check' => $parsed['last_check'] ?? null,
'updated_at' => (string) ($parsed['updated_at'] ?? ''),
];
}
private function writeLastCheck(array &$state, bool $hasUpdate, ?array $manifest = null): void
{
$state['last_check'] = [
'checked_at' => now()->toIso8601String(),
'has_update' => $hasUpdate,
'latest_version' => $manifest['version'] ?? null,
'changelog' => $manifest['changelog'] ?? null,
'name' => $manifest['name'] ?? null,
'description' => $manifest['description'] ?? null,
];
$this->writeState($state);
}
private function writeState(array $state): void
{
$this->atomicJsonWrite($this->statePath, $state);
}
private function atomicJsonWrite(string $path, array $data): void
{
$payload = json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
if ($payload === false) {
throw new RuntimeException('JSON-Encode fehlgeschlagen: ' . json_last_error_msg());
}
$dir = dirname($path);
$this->ensureDirectory($dir, 0700);
$tmp = $path . '.tmp.' . getmypid();
if (file_put_contents($tmp, $payload, LOCK_EX) === false) {
throw new RuntimeException("Temporäre Datei konnte nicht geschrieben werden: {$tmp}");
}
if (! rename($tmp, $path)) {
$this->safeUnlink($tmp);
throw new RuntimeException("Atomarer Swap fehlgeschlagen: {$tmp}{$path}");
}
}
// ── Staging Cleanup ─────────────────────────────────
private function cleanStaging(): void
{
if (! is_dir($this->stagingDir)) {
return;
}
$this->recursiveDelete($this->stagingDir);
}
private function recursiveDelete(string $dir): void
{
if (! is_dir($dir)) {
return;
}
$iterator = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS),
\RecursiveIteratorIterator::CHILD_FIRST
);
foreach ($iterator as $item) {
if ($item->isDir()) {
if (! rmdir($item->getPathname())) {
Log::warning('schneespur-update: rmdir fehlgeschlagen', ['path' => $item->getPathname()]);
}
} else {
$this->safeUnlink($item->getPathname());
}
}
if (! rmdir($dir)) {
Log::warning('schneespur-update: rmdir fehlgeschlagen', ['path' => $dir]);
}
}
// ── Helpers ──────────────────────────────────────────
private function ensureDirectory(string $path, int $mode = 0755): void
{
if (is_dir($path)) {
return;
}
if (! mkdir($path, $mode, true) && ! is_dir($path)) {
throw new RuntimeException("Verzeichnis konnte nicht erstellt werden: {$path}");
}
}
private function safeUnlink(string $path): void
{
if (is_file($path) && ! unlink($path)) {
Log::warning('schneespur-update: unlink fehlgeschlagen', ['path' => $path]);
}
}
private function countFiles(string $dir): int
{
$count = 0;
$iterator = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS)
);
foreach ($iterator as $_) {
$count++;
}
return $count;
}
private function logPhase(string $phase, string $status, array $context = []): void
{
$context['phase'] = $phase;
$context['status'] = $status;
Log::channel('single')->info("schneespur-update: [{$phase}] {$status}", $context);
}
// ── Canonical JSON (unchanged) ──────────────────────
public static function canonicalJson(array $d): string
{
self::sortRecursive($d);
$j = json_encode($d, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
if ($j === false) {
throw new RuntimeException('json_encode failed: ' . json_last_error_msg());
}
return $j;
}
private static function sortRecursive(array &$arr): void
{
foreach ($arr as &$v) {
if (is_array($v) && self::isAssoc($v)) {
self::sortRecursive($v);
}
}
unset($v);
if (self::isAssoc($arr)) {
ksort($arr, SORT_STRING);
}
}
private static function isAssoc(array $arr): bool
{
if ($arr === []) {
return false;
}
return array_keys($arr) !== range(0, count($arr) - 1);
}
}