Schneespur — Open-source winter service documentation software (PWA + Admin). GPS tracking via OwnTracks, weather data, photo evidence, and legally compliant service records for winter maintenance operators. License: AGPL-3.0-or-later
844 lines
28 KiB
PHP
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);
|
|
}
|
|
}
|