diff --git a/.htaccess b/.htaccess index 7ce8de3..ad6cf1e 100644 --- a/.htaccess +++ b/.htaccess @@ -28,11 +28,13 @@ Options -Indexes RewriteRule ^api/bugtracker/v1/report/?$ public/api/bugtracker/v1/report.php [L,QSA] RewriteRule ^api/bugtracker/v1/projects/?$ public/api/bugtracker/v1/projects.php [L,QSA] RewriteRule ^api/bugtracker/v1/manage(?:/(.*))?$ public/api/bugtracker/v1/manage/index.php [L,QSA] + RewriteRule ^api/bugtracker/v1/roadmap/?$ public/api/bugtracker/v1/roadmap.php [L,QSA] RewriteRule ^api/license/v1(?:/(.*))?$ public/api/license/v1/index.php [L,QSA] RewriteRule ^api/watchdog/v1(?:/(.*))?$ public/api/watchdog/v1/index.php [L,QSA] RewriteRule ^api/updateservice/v1(?:/(.*))?$ public/api/updateservice/v1/index.php [L,QSA] RewriteRule ^api/tokens/v1/provision/?$ public/api/tokens/v1/provision.php [L,QSA] + RewriteRule ^api/tokens/v1/whoami/?$ public/api/tokens/v1/whoami.php [L,QSA] RewriteRule ^api/setup/v1(?:/(.*))?$ public/api/setup/v1/index.php [L,QSA] RewriteRule ^api/supervisor/v1(?:/(.*))?$ public/api/supervisor/v1/index.php [L,QSA] @@ -46,6 +48,18 @@ Options -Indexes RewriteRule ^docs/?$ public/docs/index.php [L,QSA] RewriteRule ^docs/(.+)$ public/docs/$1 [L,QSA] + # ------------------------------------------------------------------ + # 3b. Werkzeugkasten fuer die Test-VM (public/testkit/) + # + # TESTUMGEBUNG.md nennt als Bezugsweg + # curl -fsSL https://dc.mhdf.de/testkit/dc-testkit.sh + # Ohne diese Regel lief der Aufruf in ein 404: ausgeliefert war die + # Datei nur unter /public/testkit/. Der dokumentierte Weg hat also + # nie funktioniert - was als "Server ist noch zu alt" gedeutet + # wurde und in den Behelf per scp fuehrte. + # ------------------------------------------------------------------ + RewriteRule ^testkit/(.+)$ public/testkit/$1 [L,QSA] + # ------------------------------------------------------------------ # 4. WebUI # ------------------------------------------------------------------ diff --git a/config/config.example.php b/config/config.example.php index 792a47f..ebbfa20 100644 --- a/config/config.example.php +++ b/config/config.example.php @@ -26,6 +26,10 @@ if (!function_exists('dc_env')) { return [ 'app' => [ 'name' => 'Deploymentcenter', + // Rueckfallebene. Massgeblich ist "current" aus + // public/docs/changelog.json - src/bootstrap.php setzt den Wert von + // dort. Hier wurde der Wert nicht nachgezogen - /api/health meldete + // deshalb bis 2.7.0 unveraendert 2.0.0. 'version' => '2.0.0', 'url' => dc_env('DC_APP_URL', 'https://dc.example.com'), 'timezone' => 'Europe/Berlin', diff --git a/docs/TESTUMGEBUNG.md b/docs/TESTUMGEBUNG.md index 1919e42..62ea001 100644 --- a/docs/TESTUMGEBUNG.md +++ b/docs/TESTUMGEBUNG.md @@ -126,12 +126,16 @@ chmod +x ~/bin/dc-testkit ``` > ⚠️ **Beides — dieser Download und `dc-testkit license` — setzt Deploymentcenter 2.6.1 voraus.** -> Solange die Fassung auf dem Server älter ist, gibt es die Datei unter `/testkit/` nicht, und der -> Endpunkt `/api/license/v1/dev-key` fehlt. Der fehlende Endpunkt fällt dabei **nicht** mit 404 -> auf: Die Lizenz-API antwortet bei unbekannten Pfadsegmenten mit ihrer Verfügbarkeitsprüfung, also -> mit HTTP 200. Das Testkit erkennt das und sagt es; wer von Hand `curl`t, sucht sonst lange. +> Ist die Fassung auf dem Server älter, fehlt der Endpunkt `/api/license/v1/dev-key`. Er fällt +> dabei **nicht** mit 404 auf: Die Lizenz-API antwortet bei unbekannten Pfadsegmenten mit ihrer +> Verfügbarkeitsprüfung, also mit HTTP 200. Das Testkit erkennt das und sagt es; wer von Hand +> `curl`t, sucht sonst lange. Seit 2.7.1 vergleicht `dc-testkit doctor` die Fassung von sich aus, +> statt sie nur anzuzeigen. > -> Bis dahin geht es auch per `scp` aus dem Repo: +> **Der Download unter `/testkit/` funktionierte bis 2.7.1 gar nicht** — und zwar unabhängig von +> der Fassung: Im Webroot lag die Datei nur unter `/public/testkit/`, die Route auf `/testkit/` +> fehlte in der `.htaccess`. Wer vor 2.7.1 ein 404 sah, hat deshalb nicht unbedingt einen zu alten +> Server vor sich. Ausweichweg für alte Stände: > `scp public/testkit/dc-testkit.sh testvm:~/bin/dc-testkit` Dann je Projekt eine Konfiguration — **das Token gehört in die Datei, nicht in die Befehlszeile**, @@ -397,6 +401,12 @@ am selben Problem. Antwortet der Server mit `409 already_claimed`, nimm das näc ### 4.6 Befunde, die ein anderes Projekt betreffen +> ⚠️ **Alles in diesem Abschnitt setzt Deploymentcenter 2.6.0 voraus** (`triage_external`, +> Deckelung, `reported_from`, die Ablehnung von Laufzeit-Token). Ein älterer Server nimmt eine +> Fremdmeldung an, legt sie aber als gewöhnliches Item mit `status=open` und ohne `reported_from` +> ab — ohne das zu sagen. Wer das beobachtet, hat nicht drei Fehler vor sich, sondern einen zu +> alten Server; `dc-testkit doctor` sagt es seit 2.7.1 von sich aus. + **Das ist der wichtigste Abschnitt dieser Datei.** Ein Agent auf der Test-VM stolpert zwangsläufig über Dinge, die nicht seinem Projekt gehören: Der PolyTrader-Agent findet einen Fehler im `update-agent`. Ihm fällt auf, dass diese Anleitung an einer Stelle lügt. Er hätte diff --git a/public/api/health.php b/public/api/health.php index 9d7c6ee..8a1faea 100644 --- a/public/api/health.php +++ b/public/api/health.php @@ -47,6 +47,34 @@ try { $logDir = DC_VAR . '/log'; $checks['log_writable'] = ['ok' => is_dir($logDir) ? is_writable($logDir) : is_writable(DC_ROOT)]; +// --- Schema vorhanden? --- +// Dass die Verbindung steht, heisst noch nicht, dass die Migrationen gelaufen +// sind: SELECT 1 gelingt auch auf einer leeren Datenbank. Vor der ersten +// Migration gibt es dc_tokens nicht - und daran starb die Rechtepruefung +// weiter unten mit einer PDOException. Ausgerechnet der Endpunkt, der ueber +// kaputte Zustaende Auskunft geben soll, antwortete dann mit HTTP 500 statt +// mit healthy=false und einer Begruendung. Betroffen war jede Neuinstallation +// zwischen Deployment und erster Migration. +$schemaReady = false; +if ($db !== null) { + try { + $schemaReady = $db->query("SHOW TABLES LIKE 'dc_tokens'")->fetchColumn() !== false; + } catch (Throwable $e) { + Logger::error('Health-Check: Schemapruefung fehlgeschlagen', ['error' => $e->getMessage()]); + } + + $checks['schema'] = $schemaReady + ? ['ok' => true] + : [ + 'ok' => false, + 'message' => 'Datenbankschema fehlt - die Migrationen wurden noch nicht ausgefuehrt (install_db.php aufrufen).', + ]; + + if (!$schemaReady) { + $healthy = false; + } +} + $response = [ 'healthy' => $healthy, 'app' => Config::get('app.name', 'Deploymentcenter'), @@ -56,7 +84,26 @@ $response = [ ]; // --- Detailinformationen nur fuer Authentifizierte --- -if ($db !== null && ApiAuth::resolve($db, 'bugtracker:read') !== null) { +// Die Rechtepruefung selbst bleibt zusaetzlich abgesichert: ein halb +// migriertes Schema besteht die Pruefung oben, kann hier aber trotzdem ueber +// eine fehlende Spalte stolpern. Ein Fehlschlag wird wie "nicht +// authentifiziert" behandelt - dann kommt die Kurzfassung heraus, und die +// Antwort bleibt auswertbar. +$authenticated = false; +if ($db !== null && $schemaReady) { + try { + $authenticated = ApiAuth::resolve($db, 'bugtracker:read') !== null; + } catch (Throwable $e) { + Logger::warning('Health-Check: Rechtepruefung nicht moeglich', ['error' => $e->getMessage()]); + $response['checks']['schema'] = [ + 'ok' => false, + 'message' => 'Datenbankschema unvollstaendig - die Rechtepruefung war nicht moeglich.', + ]; + $response['healthy'] = false; + } +} + +if ($authenticated) { try { $status = Migrator::status($db); $response['schema'] = [ diff --git a/public/api/openapi.php b/public/api/openapi.php index 2983715..713629c 100644 --- a/public/api/openapi.php +++ b/public/api/openapi.php @@ -406,7 +406,7 @@ $spec = [ ], ], - '/api/bugtracker/v1/roadmap.php' => [ + '/api/bugtracker/v1/roadmap' => [ 'get' => [ 'tags' => ['Bugtracker'], 'summary' => 'Roadmap lesen', @@ -1229,6 +1229,47 @@ $spec = [ 'responses' => ['201' => ['description' => 'Sub-Token erstellt'], '403' => $errorResponse], ], ], + + '/api/tokens/v1/whoami' => [ + 'get' => [ + 'tags' => ['Tokens'], + 'summary' => 'Auskunft ueber das eigene Token', + 'description' => + "Meldet Besitzerart, Projektbindung, Rechte, Umgebung und Frist des uebergebenen Tokens.\n\n" + . "Gedacht fuer Werkzeuge, die ihre Voraussetzungen pruefen wollen, BEVOR sie etwas tun. " + . "Ohne diesen Endpunkt liesse sich ein Recht nur durch Ausprobieren feststellen - und das " + . "hat Nebenwirkungen: Ein Test auf `bugtracker:manage` uebernaehme ein Item, einer auf " + . "`license:dev` stellte einen Schluessel aus.\n\n" + . "`is_workbench` ist der Wert, der ueber Fremdmeldungen entscheidet: nur ein Token der " + . "Besitzerart `dev_agent` darf in ein fremdes Projekt melden.\n\n" + . "Der Klartextwert des Tokens wird nicht ausgeliefert. Ein widerrufenes oder abgelaufenes " + . "Token bekommt `401`.", + 'responses' => [ + '200' => [ + 'description' => 'Angaben zum Token', + 'content' => ['application/json' => ['schema' => [ + 'type' => 'object', + 'properties' => [ + 'token_id' => ['type' => 'string'], + 'name' => ['type' => 'string'], + 'type' => ['type' => 'string', 'enum' => ['master', 'sub']], + 'owner_type' => ['type' => 'string', 'enum' => TokenManager::OWNER_TYPES], + 'owner_identity' => ['type' => 'string', 'nullable' => true], + 'is_workbench' => ['type' => 'boolean', 'description' => 'owner_type = dev_agent'], + 'project_slug' => ['type' => 'string', 'nullable' => true], + 'environment' => ['type' => 'string', 'enum' => TokenManager::ENVIRONMENTS], + 'scopes' => ['type' => 'array', 'items' => ['type' => 'string']], + 'created_at' => ['type' => 'string', 'format' => 'date-time', 'nullable' => true], + 'last_used_at' => ['type' => 'string', 'format' => 'date-time', 'nullable' => true, 'description' => 'Stand VOR diesem Aufruf.'], + 'expires_at' => ['type' => 'string', 'format' => 'date-time', 'nullable' => true], + 'parent_token_id' => ['type' => 'string', 'nullable' => true], + ], + ]]], + ], + '401' => $errorResponse, + ], + ], + ], ], ]; diff --git a/public/api/tokens/v1/whoami.php b/public/api/tokens/v1/whoami.php new file mode 100644 index 0000000..36bf2e5 --- /dev/null +++ b/public/api/tokens/v1/whoami.php @@ -0,0 +1,68 @@ + oder X-Agent-Token.'); +} + +$db = Db::init(); +$manager = new TokenManager($db); + +// Ohne geforderten Scope: die Frage lautet "wer bin ich", nicht "darf ich +// etwas". Widerruf, kaskadierender Widerruf und Ablauf werden trotzdem +// geprueft - ein entwertetes Token bekommt hier keine Auskunft. +$token = $manager->validateToken($presented, null); + +if ($token === null) { + Http::fail(401, 'invalid_token', 'Token unbekannt, widerrufen oder abgelaufen.'); +} + +$scopes = TokenManager::decodeScopes($token['scopes']); +$isWorkbench = ($token['owner_type'] ?? null) === 'dev_agent'; + +Http::ok([ + 'token_id' => (string)$token['token_id'], + 'name' => (string)$token['name'], + 'type' => (string)$token['type'], + 'owner_type' => (string)$token['owner_type'], + 'owner_identity' => $token['owner_identity'] !== null ? (string)$token['owner_identity'] : null, + // Werkbank-Token duerfen in fremde Projekte melden, Laufzeit-Token nicht. + // Der Unterschied entscheidet ueber Fremdmeldungen und ist deshalb der + // erste Wert, den ein Werkzeug pruefen sollte. + 'is_workbench' => $isWorkbench, + 'project_slug' => $token['project_slug'] !== null ? (string)$token['project_slug'] : null, + 'environment' => (string)$token['environment'], + 'scopes' => $scopes, + 'created_at' => $token['created_at'] !== null ? (string)$token['created_at'] : null, + 'last_used_at' => $token['last_used_at'] !== null ? (string)$token['last_used_at'] : null, + 'expires_at' => $token['expires_at'] !== null ? (string)$token['expires_at'] : null, + 'parent_token_id' => $token['parent_token_id'] !== null ? (string)$token['parent_token_id'] : null, +]); diff --git a/public/docs/bugtracker.md b/public/docs/bugtracker.md index 073a49d..2bcdc8d 100644 --- a/public/docs/bugtracker.md +++ b/public/docs/bugtracker.md @@ -70,6 +70,38 @@ Das zurückgegebene `sub_token` wird **nur einmal** ausgeliefert. Ist ein Token an ein Projekt gebunden, greifen alle Aufrufe automatisch nur auf dieses Projekt zu — ein Zugriff auf ein anderes liefert `403 project_forbidden`. +### Was kann mein Token? (ab 2.7.1) + +```bash +curl https://dc.mhdf.de/api/tokens/v1/whoami \ + -H "Authorization: Bearer $DC_TOKEN" +``` + +```json +{ + "status": "success", + "name": "Werkbank polytrader", + "type": "master", + "owner_type": "dev_agent", + "is_workbench": true, + "project_slug": "polytrader", + "environment": "all", + "scopes": ["bugtracker:manage", "license:dev"], + "last_used_at": "2026-08-30 09:12:44", + "expires_at": null +} +``` + +Gedacht für Werkzeuge, die ihre Voraussetzungen prüfen wollen, **bevor** sie +etwas tun. Ohne diese Auskunft ließe sich ein Recht nur durch Ausprobieren +feststellen — und das hat Nebenwirkungen: eine Probe auf `bugtracker:manage` +übernähme ein Item, eine auf `license:dev` stellte einen Schlüssel aus. + +`is_workbench` ist der Wert, der über Fremdmeldungen entscheidet: nur ein Token +der Besitzerart `dev_agent` darf in ein fremdes Projekt melden. Der +Klartextwert des Tokens wird nicht ausgeliefert; ein widerrufenes oder +abgelaufenes Token bekommt `401`. + --- ## 2. Projekte finden diff --git a/public/docs/changelog.json b/public/docs/changelog.json index 5f014a2..594b449 100644 --- a/public/docs/changelog.json +++ b/public/docs/changelog.json @@ -1,8 +1,58 @@ { "_comment": "Einzige Quelle der Wahrheit fuer den Changelog. Wird von /docs/changelog.php gerendert und von GET /api/updateservice/v1/changelog?since=X ausgeliefert. Neue Eintraege oben einfuegen.", "schema": 1, - "current": "2.7.0", + "current": "2.7.1", "versions": [ + { + "version": "2.7.1", + "date": "2026-08-30", + "summary": "Aufraeumen nach dem ersten Durchlauf der Testinstanz: Was dort auffiel, war fast durchweg dieselbe Sorte Fehler - eine Auskunft, die nicht stimmte. Der Server nannte eine falsche Fassung, doctor meldete Bereitschaft ohne zu pruefen, /api/health starb an dem Zustand, ueber den es Auskunft geben sollte, und das WebUI zeigte nach jedem Filter eine andere Ansicht als die verlangte.", + "actionRequired": false, + "changes": [ + { + "area": "server", + "breaking": false, + "title": "Die gemeldete Fassung kommt aus dem Changelog statt aus der Konfiguration", + "text": "config/config.php fuehrte 'version' als Zeichenkette, und die stand seit jeher auf 2.0.0 - waehrend 2.7.0 lief. Das war keine Schoenheitsfrage: /api/health meldete 2.0.0, selbst gemeldete Fehler trugen eine falsche build_version, und jede Pruefung gegen eine Mindestfassung schlug auf einem aktuellen Server fehl. Genau daraus entstand der Eindruck, die Testinstanz laufe auf veralteter Software. src/bootstrap.php liest die Fassung jetzt aus 'current' in public/docs/changelog.json - der Datei, die bei jedem Release ohnehin gepflegt wird. Der Wert in der Konfiguration bleibt Rueckfallebene." + }, + { + "area": "api", + "breaking": false, + "title": "/api/health ueberlebt eine leere Datenbank", + "text": "Auf einer frisch aufgesetzten Instanz - Anwendung deployt, Migrationen noch nicht gelaufen - endete GET /api/health mit HTTP 500: die Rechtepruefung griff auf dc_tokens zu, und diese Tabelle gab es noch nicht. Ausgerechnet der Endpunkt, der ueber kaputte Zustaende Auskunft geben soll, fiel bei einem kaputten Zustand aus. Neu ist eine eigene Pruefung 'schema': fehlt das Schema, kommt healthy=false mit dem Hinweis auf install_db.php statt eines Serverfehlers. Die Rechtepruefung selbst ist zusaetzlich abgesichert, damit auch ein halb migrierter Stand eine auswertbare Antwort liefert." + }, + { + "area": "api", + "breaking": false, + "title": "Neu: GET /api/tokens/v1/whoami - Auskunft ueber das eigene Token", + "text": "Meldet Besitzerart, Projektbindung, Rechte, Umgebung und Frist des uebergebenen Tokens; der Klartextwert wird nicht ausgeliefert, ein widerrufenes oder abgelaufenes Token bekommt 401. Bis hierher liess sich ein Recht nur durch Ausprobieren feststellen - und das hat Nebenwirkungen: eine Probe auf bugtracker:manage uebernaehme ein Item, eine auf license:dev stellte einen Schluessel aus. Deshalb pruefte kein Werkzeug seine Voraussetzungen, sondern lief hinein. 'is_workbench' ist der Wert, der ueber Fremdmeldungen entscheidet." + }, + { + "area": "tooling", + "breaking": false, + "title": "dc-testkit doctor prueft Mindestfassung und Rechte, statt sie nur anzuzeigen", + "text": "doctor meldete 'Bereit.', obwohl der Server die benoetigten Endpunkte nicht hatte und das Token die noetigen Rechte nicht trug - der Lauf scheiterte dann beim ersten Schritt, der beides wirklich brauchte. Die Fassung stand dabei schon in der Ausgabe, verglichen wurde sie nur nie. Jetzt: Vergleich gegen DC_MIN_SERVER (2.6.1) nach semantischer Ordnung, und ueber /api/tokens/v1/whoami die Pruefung auf Besitzerart dev_agent sowie auf bugtracker:manage, license:dev und updateservice:read - fehlende Rechte werden einzeln benannt. Gehoert das Token zu einem anderen Projekt als dem bearbeiteten, sagt doctor das ebenfalls. Gegen einen aelteren Server faellt er auf die grobe Pruefung zurueck und weist ausdruecklich darauf hin, dass die Rechte ungeprueft blieben." + }, + { + "area": "betrieb", + "breaking": false, + "title": "dc-testkit.sh: erreichbar unter /testkit/ und mit Unix-Zeilenenden", + "text": "Zwei Fehler auf demselben Weg. Erstens fehlte die Route: TESTUMGEBUNG.md nennt 'curl https://dc.mhdf.de/testkit/dc-testkit.sh', ausgeliefert war die Datei aber nur unter /public/testkit/ - der dokumentierte Bezugsweg hat nie funktioniert und wurde als 'Server noch zu alt' gedeutet. Zweitens die Zeilenenden: .gitattributes fuehrt *.sh als eol=lf, normalisiert wird aber erst beim Einchecken - hochgeladen wird der Arbeitsbaum. Von einem Windows-Rechner aus landete das Skript deshalb mit CRLF im Webroot und scheiterte auf Linux mit 'env: bash\\r: No such file or directory'. scripts/deploy.py und scripts/deploy_dev.py entfernen das CR bei .sh und .bash jetzt beim Hochladen, unabhaengig vom Zustand der Arbeitskopie." + }, + { + "area": "webui", + "breaking": false, + "title": "Filter und Formulare werfen nicht mehr aufs Dashboard zurueck", + "text": "Welche Ansicht sichtbar ist, hing allein am Anker der URL. Fuer POST-Formulare gab es dafuer laengst eine Loesung (_return und dc_redirect), die Filter fielen aber durch beide Netze: Es sind GET-Formulare, und ein GET-Formular verwirft beim Absenden den Anker seiner action; ausserdem loesten sie ueber form.submit() aus, was gar kein submit-Ereignis erzeugt. Nach jeder Filteraenderung stand man wieder im Dashboard und musste zurueckklicken - beim Filtern nach mehreren Parametern jedes Mal. Jetzt haengt der zentrale Zuhoerer die offene Ansicht bei GET als Parameter 'view' an den Query-String, ausgeloest wird ueber requestSubmit(), und beim Laden wird die Ansicht aus 'view' wiederhergestellt, wenn kein Anker da ist." + }, + { + "area": "api", + "breaking": false, + "title": "/api/bugtracker/v1/roadmap ist jetzt auch ohne .php erreichbar", + "text": "Jeder andere Endpunkt kommt ohne Dateiendung aus, dieser nicht: Die Route fehlte in der .htaccess, der Aufruf lief in ein 404, und die OpenAPI-Beschreibung fuehrte deshalb als einzigen Pfad einen mit .php. Wer dem Muster der uebrigen API folgte, lief auf. Die Route ist nachgetragen, die Beschreibung nennt jetzt den Pfad ohne Endung. Der alte Pfad mit .php funktioniert unveraendert weiter - er faellt auf die allgemeine Regel fuer /api/ zurueck." + } + ] + }, { "version": "2.7.0", "date": "2026-08-29", diff --git a/public/index.php b/public/index.php index 21f90a5..34e0aeb 100644 --- a/public/index.php +++ b/public/index.php @@ -4018,7 +4018,7 @@ chmod +x update-agent
- @@ -4641,7 +4641,7 @@ chmod +x update-agent und ging bei jedem Auto-Refresh verloren. --> -