query('SHOW TABLES')->fetchAll(PDO::FETCH_COLUMN) ?: []; if ($tables === []) { return []; } // Fremdschluessel vorruebergehend aus: sonst haengt die Reihenfolge des // Loeschens von der Reihenfolge der Tabellen ab, und die ist nicht stabil. $db->exec('SET FOREIGN_KEY_CHECKS = 0'); try { foreach ($tables as $table) { $db->exec('DROP TABLE IF EXISTS `' . str_replace('`', '', (string)$table) . '`'); } } finally { $db->exec('SET FOREIGN_KEY_CHECKS = 1'); } return array_map('strval', $tables); }; /** Zustand der Instanz - der Blick, den ein Testlauf vor und nach sich wirft. */ $status = static function () use ($db, $dbName): array { $tables = $db->query('SHOW TABLES')->fetchAll(PDO::FETCH_COLUMN) ?: []; $counts = []; foreach ($tables as $table) { $name = (string)$table; try { $counts[$name] = (int)$db->query('SELECT COUNT(*) FROM `' . str_replace('`', '', $name) . '`')->fetchColumn(); } catch (Throwable $e) { $counts[$name] = null; } } $migrations = []; if (in_array('dc_migrations', array_map('strval', $tables), true)) { $migrations = $db->query('SELECT version FROM dc_migrations ORDER BY version') ->fetchAll(PDO::FETCH_COLUMN) ?: []; } return [ 'database' => $dbName, 'php' => PHP_VERSION, 'app_version' => Config::get('app.version'), 'debug' => Config::isDebug(), 'tables' => count($tables), 'rows' => $counts, 'migrations' => array_map('strval', $migrations), 'log_dir' => is_writable(DC_VAR . '/log'), 'signing_key' => is_readable((string)Config::get('security.release_private_key', '')), ]; }; switch ($action) { // ------------------------------------------------------------------ case 'status': Http::ok($status()); break; // ------------------------------------------------------------------ case 'wipe': $requireConfirmation(); Http::ok(['dropped' => $wipe(), 'state' => $status()]); break; // ------------------------------------------------------------------ case 'migrate': $result = Migrator::migrate($db); if ($result['failed'] !== null) { Http::fail(500, 'migration_failed', 'Migration abgebrochen.', null, $result); } Http::ok(['migration' => $result, 'state' => $status()]); break; // ------------------------------------------------------------------ // Ausgangszustand herstellen: leere Datenbank, frisches Schema, ein // Administrator und ein Master-Token mit allen Rechten. Beides gibt der // Aufruf zurueck - der Testlauf soll sich seine Zugaenge selbst holen // koennen und nicht darauf warten, dass jemand im WebUI etwas anklickt. // ------------------------------------------------------------------ case 'reset': $requireConfirmation(); $dropped = $wipe(); $result = Migrator::migrate($db); if ($result['failed'] !== null) { Http::fail(500, 'migration_failed', 'Migration nach dem Zuruecksetzen abgebrochen.', null, $result); } $adminUser = (string)(Http::str('admin_user') ?? 'admin'); $adminPass = (string)(Http::str('admin_pass') ?? 'test02763!'); $db->prepare('DELETE FROM dc_users WHERE username = :u')->execute([':u' => $adminUser]); $db->prepare(' INSERT INTO dc_users (username, password_hash, created_at) VALUES (:u, :h, UTC_TIMESTAMP()) ')->execute([':u' => $adminUser, ':h' => password_hash($adminPass, PASSWORD_DEFAULT)]); // Rolle setzen, falls die Spalte existiert (Migration 012). try { $db->prepare('UPDATE dc_users SET role = "admin" WHERE username = :u')->execute([':u' => $adminUser]); } catch (Throwable $e) { // Ohne Rollenspalte ist jeder Benutzer Administrator - nichts zu tun. } $manager = new TokenManager($db); $master = $manager->createMasterToken( 'Testlauf (automatisch)', null, null, 'custom', 'testkit', ['*'], 'all' ); Http::ok([ 'dropped' => $dropped, 'migration' => $result, 'admin' => ['username' => $adminUser, 'password' => $adminPass], 'master' => ['token_id' => $master['token_id'], 'raw_token' => $master['raw_token']], 'state' => $status(), ]); break; // ------------------------------------------------------------------ // Werkbank-Token (owner_type = dev_agent), an ein Projekt gebunden. // // Es gibt keine API, so eines anzulegen - in der Produktion geschieht das // von Hand in der Token-Verwaltung, und das ist auch richtig so. Fuer den // Testlauf fehlte damit aber ausgerechnet der Tokentyp, den der // MCP-Adapter und dc-testkit benutzen. Die Folge war ein Fehler, den // niemand fand: Beim Lesen laesst projectFilter() ein Werkbank-Token // bewusst durch, also war der Projektbezug leer - und // roadmap.php?action=render lehnte mit "missing_project" ab, obwohl das // Token sehr wohl an ein Projekt gebunden ist. // // Ein Tokentyp, den kein Testfall benutzt, ist ein Tokentyp, dessen // Sonderwege niemand prueft. // ------------------------------------------------------------------ case 'workbench_token': { $slug = (string)(Http::str('project_slug') ?? ''); if ($slug === '') { Http::fail(400, 'missing_project', 'project_slug wird gebraucht.'); } // owner_type und scopes sind seit 2.8.1 waehlbar. // // Grund: Zwei Befunde lassen sich nur mit einem projektgebundenen // Token ueberhaupt ausloesen, und zwar mit VERSCHIEDENEN Sorten davon. // // * #404 (Monitor ohne Projekt) braucht ein gebundenes Token mit // watchdog:ping. Die feste Scope-Liste hier hatte nur // watchdog:read - pingen konnte das Fixture also nicht. // * #131 (Leseweg verengt still) tritt NUR bei einem gebundenen // NICHT-Werkbank-Token auf: Ein dev_agent faellt beim Lesen // absichtlich nicht unter die Schranke, wird also nie verengt. // // Beides war mit dem alten, fest verdrahteten Fixture nicht // darstellbar - und beides ist deshalb jahrelang durchgerutscht. $ownerType = (string)(Http::str('owner_type') ?? 'dev_agent'); $scopes = Http::input('scopes'); if (!is_array($scopes) || $scopes === []) { $scopes = ['bugtracker:manage', 'supervisor:manage', 'watchdog:read', 'license:dev']; } $manager = new TokenManager($db); $token = $manager->createMasterToken( (string)(Http::str('name') ?? 'Werkbank (Testlauf)'), $slug, null, $ownerType, 'testkit', array_values(array_map('strval', $scopes)), 'all' ); Http::ok([ 'token_id' => $token['token_id'], 'raw_token' => $token['raw_token'], 'project_slug' => $slug, 'owner_type' => $ownerType, 'scopes' => $scopes, ]); break; } // ------------------------------------------------------------------ // Master-Token nachtraeglich aendern. // // Der Weg dorthin ist die Oberflaeche - eine API gibt es dafuer bewusst // nicht, denn ein Token soll seine eigenen Rechte nicht erweitern koennen. // Damit waere die Aenderung aber ungeprueft geblieben, und gerade hier // zaehlt eine Zusage: dass der Tokenwert unveraendert bleibt. Sonst // muessten alle Maschinen neu bestueckt werden, und das Versprechen der // Funktion waere gebrochen. // ------------------------------------------------------------------ case 'update_master_token': { $tokenId = (string)(Http::str('token_id') ?? ''); if ($tokenId === '') { Http::fail(400, 'missing_token_id', 'token_id wird gebraucht.'); } $felder = Http::body(); unset($felder['action'], $felder['token_id']); try { $ok = (new TokenManager($db))->updateMasterToken($tokenId, $felder); } catch (\InvalidArgumentException $e) { Http::fail(400, 'invalid_update', $e->getMessage()); } if (!$ok) { Http::fail(404, 'not_found', sprintf('Master-Token "%s" existiert nicht.', $tokenId)); } $stmt = $db->prepare('SELECT token_id, name, project_slug, owner_type, owner_identity, scopes, environment, expires_at, type FROM dc_tokens WHERE token_id = :id'); $stmt->execute([':id' => $tokenId]); Http::ok(['token' => $stmt->fetch() ?: null]); break; } // ------------------------------------------------------------------ // Signierschluessel fuer den Updateservice. Er wird auf der Testinstanz // erzeugt und nie von der Produktion kopiert - ein Testlauf darf mit dem // Schluessel, mit dem echte Releases signiert werden, nichts zu tun haben. // ------------------------------------------------------------------ case 'genkey': $target = (string)Config::get('security.release_private_key', DC_ROOT . '/config/release-signing.pem'); if (is_readable($target) && (string)(Http::str('force') ?? '') !== '1') { Http::ok(['created' => false, 'path' => basename($target), 'message' => 'Schluessel vorhanden.']); } $res = openssl_pkey_new([ 'private_key_bits' => 4096, 'private_key_type' => OPENSSL_KEYTYPE_RSA, ]); if ($res === false) { Http::fail(500, 'keygen_failed', 'Schluesselerzeugung fehlgeschlagen: ' . openssl_error_string()); } $pem = ''; openssl_pkey_export($res, $pem); if (file_put_contents($target, $pem) === false) { Http::fail(500, 'keygen_failed', 'Schluessel konnte nicht geschrieben werden.'); } @chmod($target, 0600); $details = openssl_pkey_get_details($res); Http::ok([ 'created' => true, 'path' => basename($target), 'bits' => $details['bits'] ?? null, 'public' => $details['key'] ?? null, ]); break; // ------------------------------------------------------------------ // Protokoll lesen. Ohne diese Aktion endet jeder fehlgeschlagene Testlauf // bei "500" und dem Ratespiel, was dahinter stand - die Logdatei liegt // unter var/log/ und ist per .htaccess zu Recht gesperrt. // ------------------------------------------------------------------ case 'log': $lines = max(1, min(500, (int)(Http::str('lines') ?? 80))); $dir = DC_VAR . '/log'; $files = glob($dir . '/*.log') ?: []; usort($files, static fn($a, $b) => filemtime($b) <=> filemtime($a)); $out = []; foreach (array_slice($files, 0, 3) as $file) { $content = file($file, FILE_IGNORE_NEW_LINES) ?: []; $out[basename($file)] = array_slice($content, -$lines); } Http::ok(['log' => $out]); break; // ------------------------------------------------------------------ case 'clearlog': foreach (glob(DC_VAR . '/log/*.log') ?: [] as $file) { file_put_contents($file, ''); } Http::ok(['cleared' => true]); break; // ------------------------------------------------------------------ default: Http::fail(400, 'unknown_action', sprintf('Unbekannte Aktion "%s".', $action), null, [ 'actions' => ['status', 'wipe', 'migrate', 'reset', 'workbench_token', 'update_master_token', 'genkey', 'log', 'clearlog'], ]); }