Files
Deploymentcenter/public/api/openapi.php
T
Deploymentcenter BotandClaude Opus 5 c8f3e78635 feat(setup): Erstinstallation ueber den Update-Agent, Installationskonto, Downloads
Bisher gab es nur den Update-Weg: eine Anwendung musste bereits installiert und
eingerichtet sein, damit sich etwas aktualisieren liess. Die Erstinstallation
auf einem neuen System war Handarbeit - Paket kopieren, Konfiguration
abtippen, Token besorgen.

Setup-API (neu)
- POST /api/setup/v1/login tauscht Benutzername und Passwort gegen ein Token
  mit 30 Minuten Gueltigkeit und ausschliesslich setup:install. Es wird nicht
  mitgeschrieben und lebt im Installer nur im Speicher.
- GET /api/setup/v1/catalog zeigt nur, was zur Laufzeitkennung des anfragenden
  Systems passt. Ein Projekt mit ausschliesslich Windows-Paket taucht auf einem
  Linux-Rechner gar nicht erst auf.
- POST /api/setup/v1/token stellt das Dauertoken der Anwendung aus. Welche
  Rechte vergeben werden, entscheidet der Server; die Anfrage kann nur
  einschraenken. Sonst waere der Umweg ueber ein kurzlebiges Token wirkungslos.

Rollentrennung (Migration 012)
- dc_users bekommt role, disabled und last_login_at. Die Rolle "installer"
  darf sich ueber den Setup-Weg anmelden und nicht am WebUI. Die Zugangsdaten
  werden auf jedem Zielsystem eingetippt; mit einem Administratorkonto
  verteilte man damit den Zugang zu Tokens, Lizenzen und Monitoren auf jeden
  Rechner, auf dem je etwas installiert wurde.
- Auth::verifyCredentials() prueft sessionfrei, damit Setup- und WebUI-Login
  nicht zwei verschiedene Haertungsgrade haben (Drosselung, Timing-Angleichung,
  Rehash gelten fuer beide).
- Konten mit hinterlegtem TOTP-Geheimnis werden am Setup-Weg mit 501
  abgewiesen. Eine TOTP-Pruefung gibt es im Deploymentcenter noch nicht; sie
  stillschweigend zu uebergehen waere ein Rueckschritt.
- Benutzerverwaltung im WebUI - es gab bisher gar keine, nur den einen von
  install_db.php angelegten Admin. Das letzte aktive Administratorkonto laesst
  sich weder deaktivieren noch loeschen.

Installer
- update-agent --action install fuehrt durch Anmeldung, Auswahl,
  Zielverzeichnis, Installation und Einrichtung. Die Dateien kommen ueber
  denselben Pfad wie ein Update - mit Pruefsumme, Signatur, Staging und
  Rollback. Ein zweiter Download-Weg waere ein zweiter Ort fuer dieselben
  Fehler.
- --action configure holt die Einrichtung nachtraeglich.
- setup.json im Paket beschreibt die benoetigten Werte. Bewusst im Paket und
  nicht zentral: so ist sie mit der Anwendung versioniert.
- Gefragt wird nur, was uebrig bleibt: bereits gesetzt -> detect:... ->
  provision -> fragen. Platzhalter wie changeme oder <dein-wert> gelten dabei
  nicht als eingerichtet, sonst liefe die Anwendung mit der Vorlage los.
- SetupWriter erhaelt vorhandene Inhalte. Eine appsettings.json fuehrt neben
  den abgefragten Werten meist Logging und anderes; sie neu zu erzeugen waere
  bequemer und verloere das - bei einer Neuinstallation ohne Backup.
  int und bool landen als JSON-Typ, nicht als Zeichenkette.

Downloads
- scripts/build_installer.ps1 baut selbstenthaltende Einzeldateien fuer
  win-x64, linux-x64 und linux-arm64 (rund 34 MB, .NET-Laufzeit inbegriffen).
  Ohne NativeAOT und ohne Trimming: Spectre.Console loest ueber Reflexion auf
  und braeche sonst erst beim Anwender.
- scripts/upload_installer.py laedt sie nach /installer/. Getrennt von
  deploy.py, das client-dotnet bewusst ausklammert.
- Bereich "Installer" auf der UpdateService-Seite mit Groessen, Pruefsummen
  und den wget-Befehlen; die Angaben stammen aus installer.json statt aus fest
  eingetragenem Text.
- install.sh und install.ps1 laden, pruefen die Pruefsumme und legen ab -
  sie richten bewusst nichts selbst ein. Das Manifest wird BOM-frei
  geschrieben, sonst scheitert json_decode() daran.

Enthaelt ausserdem die bislang nicht committete Arbeit an den
RocketChat-Benachrichtigungen (Migrationen 010 und 011) sowie die Loesch- und
Editierfunktion des UpdateService; die betroffenen Dateien liessen sich nicht
getrennt stagen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 10:33:26 +02:00

916 lines
53 KiB
PHP

<?php
/**
* GET /api/openapi.json
*
* Maschinenlesbare Beschreibung der Schnittstelle. Ersetzt den frueher fest
* im WebUI hinterlegten Textblock: ein Agent kann sich hier selbst orientieren,
* ohne dass eine Prompt-Vorlage gepflegt werden muss.
*/
declare(strict_types=1);
require_once __DIR__ . '/../../src/bootstrap.php';
use Deploymentcenter\Core\Config;
use Deploymentcenter\Core\Http;
use Deploymentcenter\Modules\Bugtracker\BugRepo;
use Deploymentcenter\Modules\Watchdog\EventLog;
use Deploymentcenter\Core\TokenManager;
Http::beginJson(['GET', 'OPTIONS'], true);
$baseUrl = Http::baseUrl();
$errorResponse = [
'description' => 'Fehler',
'content' => ['application/json' => ['schema' => ['$ref' => '#/components/schemas/Error']]],
];
$spec = [
'openapi' => '3.0.3',
'info' => [
'title' => 'Deploymentcenter API',
'version' => (string)Config::get('app.version', '2.0.0'),
'description' =>
"Zentrale Schnittstelle fuer Bugtracker, UpdateService, Watchdog, Lizenzen und Token-Provisionierung.\n\n"
. "**Zwei Antwortformate.** Der Regelfall ist der Umschlag `{\"status\":\"success\",...}` bzw.\n"
. "`{\"status\":\"error\",\"error\":{\"code\":...}}`. Die Endpunkte unter `/api/license/v1/`\n"
. "antworten davon abweichend ohne Umschlag - dort ist `status` der Lizenzzustand.\n"
. "Siehe das Schema `LicenseResult`.\n\n"
. "Authentifizierung ueber `Authorization: Bearer <token>` oder `X-Agent-Token`.\n"
. "Tokens werden im WebUI erzeugt (Master-Token) und koennen sich per\n"
. "`/api/tokens/v1/provision` selbst in Sub-Tokens aufteilen.\n\n"
. "Typischer Agenten-Ablauf:\n"
. "1. `POST /api/bugtracker/v1/manage?action=next` - naechstes Item holen und uebernehmen\n"
. "2. Arbeiten, Zwischenstand per `?action=comment` dokumentieren\n"
. "3. `?action=resolve` mit `resolved_in_build`\n"
. "4. Beim Release meldet `POST /api/updateservice/v1/publish` den Build; passende Items schliessen sich selbst.",
],
'servers' => [['url' => $baseUrl]],
'components' => [
'securitySchemes' => [
'bearerAuth' => ['type' => 'http', 'scheme' => 'bearer'],
'agentToken' => ['type' => 'apiKey', 'in' => 'header', 'name' => 'X-Agent-Token'],
],
'schemas' => [
'Error' => [
'type' => 'object',
'properties' => [
'status' => ['type' => 'string', 'enum' => ['error']],
'error' => [
'type' => 'object',
'properties' => [
'code' => ['type' => 'string', 'description' => 'Stabiler, maschinenlesbarer Fehlercode'],
'message' => ['type' => 'string'],
],
],
],
],
'BugtrackerItem' => [
'type' => 'object',
'properties' => [
'id' => ['type' => 'integer'],
'project_slug' => ['type' => 'string'],
'type' => ['type' => 'string', 'enum' => BugRepo::TYPES],
'title' => ['type' => 'string'],
'description' => ['type' => 'string', 'nullable' => true],
'error_message' => ['type' => 'string', 'nullable' => true],
'stack_trace' => ['type' => 'string', 'nullable' => true],
'environment' => ['type' => 'string', 'enum' => BugRepo::ENVIRONMENTS],
'severity' => ['type' => 'string', 'enum' => BugRepo::SEVERITIES],
'status' => ['type' => 'string', 'enum' => BugRepo::STATUSES],
'occurrence_count' => ['type' => 'integer'],
'claimed_by' => ['type' => 'string', 'nullable' => true],
'lease_until' => ['type' => 'string', 'format' => 'date-time', 'nullable' => true],
'repo_url' => ['type' => 'string', 'nullable' => true],
'git_branch' => ['type' => 'string', 'nullable' => true],
'commit_sha' => ['type' => 'string', 'nullable' => true],
'file_path' => ['type' => 'string', 'nullable' => true],
'line_no' => ['type' => 'integer', 'nullable' => true],
'resolved_in_build' => ['type' => 'string', 'nullable' => true],
'updated_at' => ['type' => 'string', 'format' => 'date-time'],
],
],
'ReportRequest' => [
'type' => 'object',
'required' => ['title'],
'properties' => [
'project_slug' => ['type' => 'string', 'example' => 'deploymentcenter'],
'type' => ['type' => 'string', 'enum' => BugRepo::TYPES, 'default' => 'bug'],
'title' => ['type' => 'string', 'maxLength' => 255],
'description' => ['type' => 'string'],
'error_message' => ['type' => 'string'],
'stack_trace' => ['type' => 'string'],
'severity' => ['type' => 'string', 'enum' => BugRepo::SEVERITIES],
'environment' => ['type' => 'string', 'enum' => BugRepo::ENVIRONMENTS],
'build_version' => ['type' => 'string'],
'push_id' => ['type' => 'string'],
'target_agent' => ['type' => 'string'],
'tags' => ['type' => 'string', 'description' => 'Kommagetrennt'],
'client_ref' => [
'type' => 'string',
'description' => 'Idempotenz-Schluessel. Ein erneuter Aufruf mit demselben Wert legt kein Duplikat an. Alternativ als Header Idempotency-Key.',
],
'repo_url' => ['type' => 'string'],
'git_branch' => ['type' => 'string'],
'commit_sha' => ['type' => 'string'],
'file_path' => ['type' => 'string'],
'line_no' => ['type' => 'integer'],
'context' => ['type' => 'object', 'description' => 'Beliebiger strukturierter Zusatzkontext'],
],
],
'Release' => [
'type' => 'object',
'description' =>
'Release-Zeile aus updateservice_releases. Die Feldnamen sind die der Datenbank '
. '(snake_case) und unterscheiden sich von der statischen latest.json, die der '
. 'Packager in camelCase erzeugt.',
'properties' => [
'id' => ['type' => 'integer'],
'product_slug' => ['type' => 'string'],
'version' => ['type' => 'string', 'example' => '1.4.3'],
'channel' => ['type' => 'string', 'default' => 'prod'],
'platform' => [
'type' => 'string',
'default' => 'any',
'example' => 'win-x64',
'description' =>
'Laufzeitkennung (.NET-RID) oder "any" fuer plattformunabhaengige Releases. '
. 'Ein Client, der "platform" mitschickt, bekommt nur Pakete seiner Plattform '
. 'oder "any"; ohne Angabe ausschliesslich "any".',
],
'release_notes' => ['type' => 'string', 'nullable' => true, 'description' => 'Entspricht "changelog" in der latest.json.'],
'download_url' => ['type' => 'string', 'description' => 'Entspricht "packageUrl" in der latest.json.'],
'sha256_hash' => ['type' => 'string', 'nullable' => true, 'description' => 'Entspricht "sha256" in der latest.json.'],
'git_commit' => ['type' => 'string', 'nullable' => true],
'size_bytes' => ['type' => 'integer'],
'manifest_signature' => [
'type' => 'string',
'nullable' => true,
'description' =>
'RSA-SHA256 ueber die kanonische Release-Zeile, base64. Pruefbar mit dem '
. 'Schluessel aus /api/updateservice/v1/pubkey. Null, wenn auf dem Server '
. 'kein Signierschluessel hinterlegt ist.',
],
'is_critical' => ['type' => 'boolean', 'description' => 'Kommt als TINYINT(1), also 0 oder 1.'],
'created_at' => ['type' => 'string', 'format' => 'date-time'],
],
],
'Monitor' => [
'type' => 'object',
'properties' => [
'source' => ['type' => 'string'],
'instance' => ['type' => 'string'],
'type' => ['type' => 'string', 'enum' => ['heartbeat', 'host', 'hypervisor_node', 'guest']],
'state' => ['type' => 'string', 'enum' => ['up', 'warning', 'down', 'error', 'stopped', 'maintenance']],
'expected_interval_sec' => ['type' => 'integer'],
'last_seen_utc' => ['type' => 'string', 'format' => 'date-time', 'nullable' => true],
'last_status' => ['type' => 'string', 'enum' => ['ok', 'warning', 'error'], 'nullable' => true],
'last_message' => ['type' => 'string', 'nullable' => true],
'app_version' => ['type' => 'string', 'nullable' => true, 'description' => 'Vom Heartbeat gemeldete Anwendungsversion.'],
'group_key' => ['type' => 'string', 'nullable' => true],
'parent_source' => ['type' => 'string', 'nullable' => true],
'os' => ['type' => 'string', 'nullable' => true],
'metrics_json' => ['type' => 'object', 'nullable' => true],
'failing_checks' => ['type' => 'string', 'nullable' => true],
'is_muted' => ['type' => 'boolean'],
],
],
'LicenseResult' => [
'type' => 'object',
'description' =>
'ACHTUNG: ohne den status/error-Umschlag der uebrigen API. Das Feld "status" '
. 'traegt hier den Lizenzzustand, nicht "success".',
'properties' => [
'type' => ['type' => 'string', 'enum' => ['validation_result']],
'status' => [
'type' => 'string',
'enum' => ['valid', 'not_found', 'revoked', 'suspended', 'expired', 'activation_limit'],
'description' =>
'valid = gueltig und aktiviert; not_found = Projekt oder Schluessel unbekannt; '
. 'revoked = widerrufen oder diese Hardware gesperrt; suspended = voruebergehend '
. 'ausgesetzt; expired = Ablaufdatum ueberschritten; activation_limit = maximale '
. 'Anzahl Aktivierungen erreicht.',
],
'issued_at' => ['type' => 'integer', 'description' => 'Unix-Zeit der Ausstellung.'],
'expires_at' => ['type' => 'integer', 'nullable' => true, 'description' => 'Unix-Zeit des Lizenzablaufs, null bei unbefristet.'],
'cache_ttl_hours' => [
'type' => 'integer',
'example' => 168,
'description' => 'Laenge der Offline-Gnadenfrist in Stunden. Auswerten - nicht durch expires_at ersetzen.',
],
'product' => ['type' => 'string'],
'license_key' => ['type' => 'string'],
'hardware_id' => ['type' => 'string'],
'nonce' => ['type' => 'string', 'description' => 'Der uebergebene Wert, unveraendert.'],
'endpoints' => ['type' => 'object'],
'message' => ['type' => 'string', 'nullable' => true],
],
],
],
],
'security' => [['bearerAuth' => []], ['agentToken' => []]],
'paths' => [
'/api/health' => [
'get' => [
'tags' => ['System'],
'summary' => 'Verfuegbarkeit und Schema-Status',
'security' => [],
'responses' => ['200' => ['description' => 'Zustand'], '503' => ['description' => 'Nicht bereit']],
],
],
'/api/bugtracker/v1/report' => [
'post' => [
'tags' => ['Bugtracker'],
'summary' => 'Bug, Feature Request oder Idee melden',
'description' => 'Benoetigt den Scope bugtracker:report. Gleiche Fehler werden automatisch zusammengefasst und hochgezaehlt.',
'requestBody' => [
'required' => true,
'content' => ['application/json' => ['schema' => ['$ref' => '#/components/schemas/ReportRequest']]],
],
'responses' => [
'201' => ['description' => 'Neu angelegt'],
'200' => ['description' => 'Bestehendes Item aktualisiert (Duplikat oder Idempotenz-Treffer)'],
'401' => $errorResponse,
'429' => $errorResponse,
],
],
],
'/api/errors/v1/report' => [
'post' => [
'tags' => ['Fehler'],
'summary' => 'Laufzeitfehler melden',
'description' =>
"Schlanker Eingang fuer den globalen Exception-Handler. Titel und Dringlichkeit "
. "leitet der Server ab.\n\n"
. "Gleiche Fehler werden zu einer Gruppe zusammengefasst; veraenderliche Anteile "
. "(Werte in Anfuehrungszeichen mit Ziffern, Adressen, GUIDs, Zeitstempel) werden "
. "dabei ausgeblendet.\n\n"
. "Greift eine Ignore-Regel, kommt `\"ignored\": true` zurueck: der Fehler wird "
. "gezaehlt, aber nicht gemeldet. Ueberschreitet er die hinterlegte Alarmschwelle, "
. "meldet die Antwort `\"rate_alerted\": true`.",
'requestBody' => [
'required' => true,
'content' => ['application/json' => ['schema' => [
'type' => 'object',
'properties' => [
'project_slug' => ['type' => 'string'],
'exception' => ['type' => 'string', 'example' => 'PDOException'],
'message' => ['type' => 'string'],
'stack_trace' => ['type' => 'string'],
'level' => ['type' => 'string', 'enum' => BugRepo::ERROR_LEVELS, 'default' => 'error'],
'build' => ['type' => 'string'],
'environment' => ['type' => 'string', 'enum' => BugRepo::ENVIRONMENTS],
'file' => ['type' => 'string'],
'line' => ['type' => 'integer'],
'client_ref' => ['type' => 'string'],
'context' => ['type' => 'object'],
],
]]],
],
'responses' => [
'201' => ['description' => 'Neue Fehlergruppe angelegt'],
'200' => ['description' => 'Bestehende Gruppe hochgezaehlt'],
'401' => $errorResponse,
'429' => $errorResponse,
],
],
],
'/api/watchdog/v1/metrics' => [
'get' => [
'tags' => ['Watchdog'],
'summary' => 'Metrik-Verlauf abrufen',
'description' => 'Ohne "metric" die verfuegbaren Namen, mit "metric" den verdichteten Verlauf samt Abweichung vom eigenen Durchschnitt.',
'parameters' => [
['name' => 'source', 'in' => 'query', 'required' => true, 'schema' => ['type' => 'string']],
['name' => 'metric', 'in' => 'query', 'schema' => ['type' => 'string']],
['name' => 'hours', 'in' => 'query', 'schema' => ['type' => 'integer', 'default' => 24]],
['name' => 'bucket', 'in' => 'query', 'schema' => ['type' => 'integer', 'default' => 15], 'description' => 'Fenstergroesse in Minuten'],
],
'responses' => ['200' => ['description' => 'Verlauf'], '401' => $errorResponse],
],
],
'/api/bugtracker/v1/projects' => [
'get' => [
'tags' => ['Bugtracker'],
'summary' => 'Projekte auflisten (Discovery)',
'description' => 'Benoetigt den Scope bugtracker:read.',
'responses' => ['200' => ['description' => 'Projektliste'], '401' => $errorResponse],
],
],
'/api/bugtracker/v1/manage' => [
'get' => [
'tags' => ['Bugtracker'],
'summary' => 'Items lesen',
'description' => 'Scope bugtracker:read. action=list|get|stats|projects.',
'parameters' => [
['name' => 'action', 'in' => 'query', 'schema' => ['type' => 'string', 'enum' => ['list', 'get', 'stats', 'projects'], 'default' => 'list']],
['name' => 'id', 'in' => 'query', 'schema' => ['type' => 'integer'], 'description' => 'Pflicht bei action=get'],
['name' => 'project_slug', 'in' => 'query', 'schema' => ['type' => 'string']],
['name' => 'status', 'in' => 'query', 'schema' => ['type' => 'string'], 'description' => 'Mehrere kommagetrennt, z. B. open,in_progress'],
['name' => 'severity', 'in' => 'query', 'schema' => ['type' => 'string'], 'description' => 'Mehrere kommagetrennt'],
['name' => 'target_agent', 'in' => 'query', 'schema' => ['type' => 'string']],
['name' => 'unclaimed_only', 'in' => 'query', 'schema' => ['type' => 'boolean']],
['name' => 'updated_since', 'in' => 'query', 'schema' => ['type' => 'string', 'format' => 'date-time'], 'description' => 'Delta-Abfrage fuer Polling'],
['name' => 'order', 'in' => 'query', 'schema' => ['type' => 'string', 'enum' => ['newest', 'oldest', 'updated', 'severity', 'occurrences']]],
['name' => 'limit', 'in' => 'query', 'schema' => ['type' => 'integer', 'default' => 100, 'maximum' => 500]],
['name' => 'offset', 'in' => 'query', 'schema' => ['type' => 'integer', 'default' => 0]],
],
'responses' => ['200' => ['description' => 'Trefferliste mit total/has_more'], '401' => $errorResponse],
],
'post' => [
'tags' => ['Bugtracker'],
'summary' => 'Items veraendern',
'description' =>
"Scope bugtracker:manage.\n\n"
. "- `action=next` holt die naechsten offenen Items und uebernimmt sie exklusiv\n"
. "- `action=claim&id=` uebernimmt ein bestimmtes Item (409 wenn bereits vergeben)\n"
. "- `action=release&id=` gibt es wieder frei\n"
. "- `action=comment&id=` haengt einen Ermittlungsschritt an\n"
. "- `action=status&id=` setzt den Status\n"
. "- `action=update&id=` aendert mehrere Felder\n"
. "- `action=resolve&id=` schliesst mit resolved_in_build\n"
. "- `action=bulk_update` aendert mehrere Items (ids[] + updates{})",
'parameters' => [
['name' => 'action', 'in' => 'query', 'required' => true, 'schema' => ['type' => 'string', 'enum' => ['claim', 'next', 'release', 'comment', 'status', 'update', 'resolve', 'bulk_update']]],
['name' => 'id', 'in' => 'query', 'schema' => ['type' => 'integer']],
],
'responses' => [
'200' => ['description' => 'Erfolg'],
'409' => ['description' => 'Item bereits von einem anderen Agenten uebernommen'],
'401' => $errorResponse,
],
],
],
'/api/updateservice/v1/check' => [
'get' => [
'tags' => ['UpdateService'],
'summary' => 'Auf Update pruefen',
'description' =>
"Ohne Token erreichbar.\n\n"
. "**Achtung, zwei verschiedene Formate:** diese Antwort ist in snake_case und "
. "reicht unter `latest_release` die Datenbankzeile durch (`download_url`, "
. "`sha256_hash`, `release_notes`, `size_bytes`). Die statische "
. "`/releases/{product}/{channel}/latest.json`, die der Packager erzeugt, "
. "verwendet dagegen camelCase (`packageUrl`, `sha256`, `changelog`, `isCritical`). "
. "Wer beide Quellen unterstuetzt, braucht zwei Modelle.\n\n"
. "`is_critical` steht auf oberster Ebene, nicht im Release-Objekt.\n\n"
. "**Plattform:** ohne `platform` werden ausschliesslich Releases mit `platform=any` "
. "beruecksichtigt. Das ist Absicht - ein Client, der seine Laufzeitkennung nicht "
. "nennt, soll lieber kein Update bekommen als das Paket einer fremden Plattform.",
'security' => [],
'parameters' => [
['name' => 'product', 'in' => 'query', 'required' => true, 'schema' => ['type' => 'string']],
['name' => 'version', 'in' => 'query', 'required' => true, 'schema' => ['type' => 'string']],
['name' => 'channel', 'in' => 'query', 'schema' => ['type' => 'string', 'default' => 'prod']],
['name' => 'platform', 'in' => 'query', 'schema' => ['type' => 'string', 'example' => 'win-x64'],
'description' => '.NET-RID des anfragenden Systems. Alias: rid.'],
],
'responses' => [
'200' => [
'description' => 'Vergleich nach semantischer Versionsordnung',
'content' => ['application/json' => ['schema' => [
'type' => 'object',
'properties' => [
'update_available' => ['type' => 'boolean'],
'current_version' => ['type' => 'string'],
'latest_version' => ['type' => 'string'],
'is_critical' => ['type' => 'boolean', 'description' => 'Nur vorhanden, wenn ein Update ansteht.'],
'latest_release' => ['$ref' => '#/components/schemas/Release'],
'message' => ['type' => 'string'],
],
]]],
],
],
],
],
'/api/updateservice/v1/latest' => [
'get' => [
'tags' => ['UpdateService'],
'summary' => 'Hoechstes Release eines Kanals',
'description' => 'Ohne Token erreichbar. Unabhaengig von der Version des Aufrufers.',
'security' => [],
'parameters' => [
['name' => 'product', 'in' => 'query', 'required' => true, 'schema' => ['type' => 'string']],
['name' => 'channel', 'in' => 'query', 'schema' => ['type' => 'string', 'default' => 'prod']],
['name' => 'platform', 'in' => 'query', 'schema' => ['type' => 'string', 'example' => 'win-x64']],
],
'responses' => [
'200' => [
'description' => 'Release',
'content' => ['application/json' => ['schema' => [
'type' => 'object',
'properties' => ['release' => ['$ref' => '#/components/schemas/Release']],
]]],
],
'404' => $errorResponse,
],
],
],
'/api/updateservice/v1/releases' => [
'get' => [
'tags' => ['UpdateService'],
'summary' => 'Releases auflisten',
'description' => 'Ohne Token erreichbar. Ohne "product" alle Produkte.',
'security' => [],
'parameters' => [
['name' => 'product', 'in' => 'query', 'schema' => ['type' => 'string']],
['name' => 'channel', 'in' => 'query', 'schema' => ['type' => 'string']],
['name' => 'platform', 'in' => 'query', 'schema' => ['type' => 'string'],
'description' => 'Filtert exakt auf diese Plattform - dieser Endpunkt listet den Bestand, er waehlt kein Paket aus.'],
['name' => 'limit', 'in' => 'query', 'schema' => ['type' => 'integer', 'default' => 200]],
],
'responses' => [
'200' => [
'description' => 'Liste',
'content' => ['application/json' => ['schema' => [
'type' => 'object',
'properties' => [
'count' => ['type' => 'integer'],
'releases' => ['type' => 'array', 'items' => ['$ref' => '#/components/schemas/Release']],
],
]]],
],
],
],
],
'/api/updateservice/v1/pubkey' => [
'get' => [
'tags' => ['UpdateService'],
'summary' => 'Oeffentlichen Schluessel fuer Release-Signaturen holen',
'description' =>
"Ohne Token erreichbar - der Schluessel ist oeffentlich, und ein Agent braucht ihn, "
. "bevor er irgendetwas anderes pruefen kann.\n\n"
. "Signiert wird die kanonische Release-Zeile (`canonical-line-v1`): die Felder "
. "`dc-release-v1`, product_slug, version, channel, platform, sha256_hash (klein "
. "geschrieben), download_url und size_bytes, verbunden mit \\n. Geprueft wird "
. "`manifest_signature` (base64) mit RSA-SHA256.\n\n"
. "404 bedeutet, dass auf diesem Server kein Signierschluessel hinterlegt ist.",
'security' => [],
'responses' => [
'200' => [
'description' => 'Oeffentlicher Schluessel im PEM-Format',
'content' => ['application/json' => ['schema' => [
'type' => 'object',
'properties' => [
'algorithm' => ['type' => 'string', 'example' => 'RSA-SHA256'],
'format' => ['type' => 'string', 'example' => 'canonical-line-v1'],
'public_key' => ['type' => 'string', 'description' => 'PEM'],
'fingerprint' => ['type' => 'string', 'description' => 'SHA256 ueber das PEM, zum Wiedererkennen.'],
],
]]],
],
'404' => $errorResponse,
],
],
],
'/api/updateservice/v1/publish' => [
'post' => [
'tags' => ['UpdateService'],
'summary' => 'Release veroeffentlichen',
'description' =>
"Scope updateservice:publish. Schliesst automatisch alle Bugtracker-Items, deren "
. "resolved_in_build dieser Version entspricht.\n\n"
. "Die Signatur wird serverseitig gebildet und ist nicht Teil des Aufrufs - der "
. "Packager laeuft auf Entwicklerrechnern und darf den Signierschluessel nicht besitzen.",
'requestBody' => [
'required' => true,
'content' => ['application/json' => ['schema' => [
'type' => 'object',
'required' => ['product_slug', 'version', 'download_url'],
'properties' => [
'product_slug' => ['type' => 'string'],
'version' => ['type' => 'string', 'example' => '1.4.3'],
'channel' => ['type' => 'string', 'default' => 'prod'],
'platform' => ['type' => 'string', 'default' => 'any', 'example' => 'win-x64'],
'download_url' => ['type' => 'string'],
'sha256_hash' => ['type' => 'string', 'pattern' => '^[0-9a-fA-F]{64}$'],
'git_commit' => ['type' => 'string'],
'size_bytes' => ['type' => 'integer'],
'release_notes' => ['type' => 'string'],
'is_critical' => ['type' => 'boolean'],
'manifest_json' => [
'type' => 'object',
'description' =>
'Dateimanifest des Pakets (Pfade, Groessen, SHA256, preserve-Liste). '
. 'Damit kann die API als vollwertiger Rueckfall dienen, wenn die '
. 'statische latest.json fehlt. Maximal 4 MB.',
],
],
]]],
],
'responses' => ['201' => ['description' => 'Angelegt'], '200' => ['description' => 'Aktualisiert'], '401' => $errorResponse],
],
],
'/api/setup/v1/login' => [
'post' => [
'tags' => ['Setup'],
'summary' => 'Anmelden und ein kurzlebiges Setup-Token holen',
'description' =>
"Ohne Token erreichbar - hier entsteht das Token erst.\n\n"
. "Gedacht fuer Konten der Rolle `installer`, die sich ihrerseits **nicht** am "
. "WebUI anmelden koennen. Die Zugangsdaten werden auf jedem Zielsystem "
. "eingegeben; mit einem Administratorkonto verteilte man damit den Zugang zur "
. "gesamten Verwaltung.\n\n"
. "Das Token laeuft nach 30 Minuten ab, traegt nur `setup:install` und wird "
. "nicht mitgeschrieben - es ist nach der Ausgabe nirgends mehr abrufbar.\n\n"
. "Konten mit hinterlegtem TOTP-Geheimnis werden mit 501 abgewiesen: eine "
. "TOTP-Pruefung gibt es noch nicht, und sie stillschweigend zu uebergehen "
. "waere ein Rueckschritt.",
'security' => [],
'requestBody' => [
'required' => true,
'content' => ['application/json' => ['schema' => [
'type' => 'object',
'required' => ['username', 'password'],
'properties' => [
'username' => ['type' => 'string'],
'password' => ['type' => 'string', 'format' => 'password'],
'hostname' => ['type' => 'string', 'description' => 'Rechnername, erscheint im Token-Namen und im Protokoll.'],
],
]]],
],
'responses' => [
'201' => [
'description' => 'Angemeldet',
'content' => ['application/json' => ['schema' => [
'type' => 'object',
'properties' => [
'setup_token' => ['type' => 'string'],
'expires_at' => ['type' => 'string', 'format' => 'date-time'],
'expires_in' => ['type' => 'integer', 'example' => 1800],
'scopes' => ['type' => 'array', 'items' => ['type' => 'string']],
'role' => ['type' => 'string', 'enum' => ['admin', 'installer']],
],
]]],
],
'401' => $errorResponse,
'429' => $errorResponse,
'501' => $errorResponse,
],
],
],
'/api/setup/v1/catalog' => [
'get' => [
'tags' => ['Setup'],
'summary' => 'Installierbare Anwendungen fuer dieses System',
'description' =>
"Scope `setup:catalog`. Zeigt ausschliesslich, was zur angefragten "
. "Laufzeitkennung passt - ein Projekt, von dem es nur ein Windows-Paket gibt, "
. "taucht auf einem Linux-Rechner gar nicht erst auf.\n\n"
. "Eine WebUI-Session wird hier bewusst **nicht** akzeptiert.",
'parameters' => [
['name' => 'platform', 'in' => 'query', 'schema' => ['type' => 'string', 'example' => 'win-x64'],
'description' => 'Laufzeitkennung des Zielsystems. Ohne Angabe gelten nur Releases mit platform=any.'],
],
'responses' => [
'200' => [
'description' => 'Katalog',
'content' => ['application/json' => ['schema' => [
'type' => 'object',
'properties' => [
'platform' => ['type' => 'string'],
'count' => ['type' => 'integer'],
'projects' => [
'type' => 'array',
'items' => [
'type' => 'object',
'properties' => [
'slug' => ['type' => 'string'],
'name' => ['type' => 'string'],
'notes' => ['type' => 'string', 'nullable' => true],
'channels' => [
'type' => 'array',
'items' => [
'type' => 'object',
'properties' => [
'channel' => ['type' => 'string'],
'version' => ['type' => 'string'],
'platform' => ['type' => 'string'],
'size_bytes' => ['type' => 'integer'],
'is_critical' => ['type' => 'boolean'],
'signed' => ['type' => 'boolean'],
'download_url' => ['type' => 'string'],
],
],
],
],
],
],
],
]]],
],
'401' => $errorResponse,
],
],
],
'/api/setup/v1/token' => [
'post' => [
'tags' => ['Setup'],
'summary' => 'Dauertoken fuer die eingerichtete Anwendung',
'description' =>
"Scope `setup:install`. Stellt das Token aus, mit dem die frisch installierte "
. "Anwendung spaeter Heartbeats sendet und Fehler meldet.\n\n"
. "**Welche Rechte vergeben werden, entscheidet der Server.** Die angefragten "
. "`scopes` koennen nur einschraenken; erlaubt sind ausschliesslich "
. "`watchdog:ping`, `watchdog:read`, `bugtracker:report` und "
. "`updateservice:read`. Andernfalls waere der Umweg ueber ein kurzlebiges "
. "Setup-Token wirkungslos - wer einmal installieren darf, stellte sich sonst "
. "ein Token mit allen Rechten aus und behielte es.",
'requestBody' => [
'required' => true,
'content' => ['application/json' => ['schema' => [
'type' => 'object',
'required' => ['project'],
'properties' => [
'project' => ['type' => 'string'],
'scopes' => ['type' => 'array', 'items' => ['type' => 'string']],
'hostname' => ['type' => 'string'],
],
]]],
],
'responses' => [
'201' => [
'description' => 'Token ausgestellt - wird nur einmal ausgeliefert',
'content' => ['application/json' => ['schema' => [
'type' => 'object',
'properties' => [
'token' => ['type' => 'string'],
'project' => ['type' => 'string'],
'scopes' => ['type' => 'array', 'items' => ['type' => 'string']],
],
]]],
],
'401' => $errorResponse,
'403' => $errorResponse,
'404' => $errorResponse,
],
],
],
'/api/watchdog/v1/ping' => [
'post' => [
'tags' => ['Watchdog'],
'summary' => 'Heartbeat senden',
'description' => 'Scope watchdog:ping. Alternativ ein Agent-Token aus watchdog_agent_tokens.',
'requestBody' => [
'required' => true,
'content' => ['application/json' => ['schema' => [
'type' => 'object',
'required' => ['source'],
'properties' => [
'source' => ['type' => 'string', 'description' => 'Eindeutiger Name des Dienstes oder Hosts.'],
'instance' => ['type' => 'string', 'default' => 'default'],
'type' => [
'type' => 'string',
'enum' => ['heartbeat', 'host', 'hypervisor_node', 'guest'],
'default' => 'heartbeat',
'description' => 'Bestimmt die Einordnung in der Hierarchie.',
],
'group' => [
'type' => 'string',
'description' => 'Gruppierung im Dashboard, z. B. "Applications". Alias: group_key.',
],
'version' => [
'type' => 'string',
'description' => 'Version der laufenden Anwendung, erscheint in der Monitorliste. Alias: app_version. Optional - bestehende Agenten senden es nicht.',
'example' => '1.4.3',
],
'status' => [
'type' => 'string',
'enum' => ['ok', 'warning', 'error', 'stopped', 'maintenance'],
'description' => 'stopped und maintenance sind angekuendigte Zustaende - der Evaluator meldet dafuer keinen Ausfall.',
],
'interval' => ['type' => 'integer', 'description' => 'Erwarteter Abstand in Sekunden. Nach dem Doppelten gilt der Monitor als auffaellig, nach dem Vierfachen als ausgefallen.'],
'message' => ['type' => 'string'],
'checks' => [
'type' => 'object',
'description' => 'Selbst ermittelter Gesundheitszustand, z. B. {"db":{"ok":true},"feed":{"ok":false,"message":"..."}}. Die Namen werden nicht interpretiert; eine fehlgeschlagene Pruefung stuft einen als ok gemeldeten Heartbeat auf warning herab.',
],
'metrics' => ['type' => 'object', 'description' => 'Numerische Werte; landen im Verlauf und sind ueber /metrics abrufbar.'],
'os' => ['type' => 'string'],
],
]]],
],
'responses' => ['200' => ['description' => 'Empfangen'], '401' => $errorResponse],
],
],
'/api/watchdog/v1/event' => [
'post' => [
'tags' => ['Watchdog'],
'summary' => 'Einzelnes Ereignis protokollieren',
'description' =>
"Scope watchdog:ping, alternativ ein Agent-Token. Fuer einmalige Vorkommnisse "
. "statt zyklischer Meldungen - etwa ein angekuendigtes Herunterfahren oder eine "
. "wiederhergestellte Verbindung.\n\n"
. "Zustandswechsel durch Heartbeats werden ohnehin selbst protokolliert; dieser "
. "Endpunkt ist fuer alles, was dazwischen passiert.",
'requestBody' => [
'required' => true,
'content' => ['application/json' => ['schema' => [
'type' => 'object',
'required' => ['source'],
'properties' => [
'source' => ['type' => 'string'],
'instance' => ['type' => 'string', 'default' => 'default'],
'kind' => ['type' => 'string', 'enum' => EventLog::KINDS, 'default' => 'started'],
'severity' => ['type' => 'string', 'enum' => EventLog::SEVERITIES, 'default' => 'info'],
'message' => ['type' => 'string'],
'from_state' => ['type' => 'string'],
'to_state' => ['type' => 'string'],
'meta' => ['type' => 'object', 'description' => 'Beliebiger strukturierter Zusatzkontext.'],
],
]]],
],
'responses' => ['201' => ['description' => 'Protokolliert'], '401' => $errorResponse],
],
],
'/api/watchdog/v1/status' => [
'get' => [
'tags' => ['Watchdog'],
'summary' => 'Alle Monitore abrufen',
'description' => 'Scope watchdog:read. Liefert jede Zeile aus watchdog_monitors, einschliesslich app_version und group_key.',
'responses' => [
'200' => [
'description' => 'Monitorliste',
'content' => ['application/json' => ['schema' => [
'type' => 'object',
'properties' => [
'count' => ['type' => 'integer'],
'monitors' => ['type' => 'array', 'items' => ['$ref' => '#/components/schemas/Monitor']],
],
]]],
],
'401' => $errorResponse,
],
],
],
'/api/watchdog/v1/events' => [
'get' => [
'tags' => ['Watchdog'],
'summary' => 'Ereignisprotokoll lesen',
'description' => 'Scope watchdog:read.',
'parameters' => [
['name' => 'limit', 'in' => 'query', 'schema' => ['type' => 'integer', 'default' => 50]],
['name' => 'source', 'in' => 'query', 'schema' => ['type' => 'string']],
['name' => 'instance', 'in' => 'query', 'schema' => ['type' => 'string']],
['name' => 'severity', 'in' => 'query', 'schema' => ['type' => 'string', 'enum' => EventLog::SEVERITIES]],
],
'responses' => ['200' => ['description' => 'Ereignisliste'], '401' => $errorResponse],
],
],
'/api/watchdog/v1/evaluate' => [
'get' => [
'tags' => ['Watchdog'],
'summary' => 'Evaluationslauf ausloesen',
'description' => 'Nur mit Shared Key oder angemeldeter Sitzung. Per Cron minuetlich aufrufen, sonst bleiben ausgefallene Monitore gruen.',
'responses' => ['200' => ['description' => 'Ergebnis des Laufs'], '401' => $errorResponse],
],
],
// ------------------------------------------------------------------
// Lizenzmodul
//
// Fehlte hier bisher vollstaendig, obwohl README und Prompt-Vorlage
// Agenten ausdruecklich auf diese Beschreibung verweisen. Wer sich
// daran orientierte, konnte das Lizenzsystem nicht anbinden - und
// kannte insbesondere dessen abweichendes Antwortformat nicht.
// ------------------------------------------------------------------
'/api/license/v1/validate' => [
'post' => [
'tags' => ['Lizenz'],
'summary' => 'Lizenz und Hardware pruefen',
'description' =>
"Bewusst ohne Token erreichbar: ausgelieferte Anwendungen pruefen hier ihre Lizenz.\n\n"
. "**Abweichendes Antwortformat.** Diese Antwort traegt NICHT den "
. "`status`/`error`-Umschlag der uebrigen API. `status` auf oberster Ebene ist der "
. "Lizenzzustand (`valid`, `not_found`, `revoked`, `suspended`, `expired`, "
. "`activation_limit`), nicht `success`. Das ist ein bereits ausgerollter Vertrag - "
. "ein Umschlag mit `status: \"success\"` wuerde von jedem bestehenden Client als "
. "\"nicht valid\" gelesen.\n\n"
. "**Nur ein gelesenes Urteil ist ein Urteil.** Ein HTTP-Fehler (429 durch "
. "Drosselung, 5xx) ist keine Aussage ueber die Lizenz. Clients muessen in diesem "
. "Fall ihren lokalen Cache verwenden und es spaeter erneut versuchen, statt die "
. "Anwendung zu blockieren.\n\n"
. "`cache_ttl_hours` bestimmt, wie lange ohne Serverkontakt weitergearbeitet "
. "werden darf. Der Wert stammt aus `dc_projects.default_cache_ttl_hours` und ist "
. "auszuwerten - nicht das Ablaufdatum der Lizenz dafuer heranzuziehen.\n\n"
. "Begrenzt auf 120 Anfragen pro Minute und IP.",
'security' => [],
'requestBody' => [
'required' => true,
'content' => ['application/json' => ['schema' => [
'type' => 'object',
'required' => ['product', 'license_key', 'hardware_id'],
'properties' => [
'product' => ['type' => 'string', 'description' => 'Slug aus dc_projects.'],
'license_key' => ['type' => 'string', 'example' => 'XXXXX-XXXXX-XXXXX-XXXXX-XXXXX'],
'hardware_id' => ['type' => 'string', 'example' => '2:win:a765bd47...', 'description' => 'Hardware-ID v2, Format 2:<plattform>:<64 Hex>.'],
'legacy_hardware_id' => ['type' => 'string', 'description' => 'Hardware-ID v1. Mitschicken, damit eine bestehende Aktivierung ohne zusaetzlichen Platz auf v2 umzieht.'],
'hwid_version' => ['type' => 'integer', 'default' => 2],
'hwid_source' => ['type' => 'string', 'example' => 'machine-guid'],
'platform' => ['type' => 'string', 'example' => 'win'],
'hostname' => ['type' => 'string'],
'app_version' => ['type' => 'string', 'example' => '1.4.3', 'description' => 'Version der Anwendung; erscheint in der Aktivierungsliste.'],
'nonce' => ['type' => 'string', 'description' => 'Zufallswert, wird unveraendert zurueckgegeben.'],
],
]]],
],
'responses' => [
'200' => [
'description' => 'Lizenzurteil (auch bei ungueltiger Lizenz)',
'content' => ['application/json' => ['schema' => ['$ref' => '#/components/schemas/LicenseResult']]],
],
'429' => $errorResponse,
],
],
],
'/api/license/v1/deactivate' => [
'post' => [
'tags' => ['Lizenz'],
'summary' => 'Aktivierung freigeben',
'description' =>
"Scope license:deactivate, alternativ der `shared_key` aus config/config.php als "
. "`Authorization: Bearer`. Geschuetzt, weil sonst jeder fremde Installationen "
. "abmelden koennte.\n\n"
. "Der Schluessel gehoert auf den Administrationsrechner, nicht in die "
. "ausgelieferte Anwendung. Fuer Einzelfaelle genuegt der Knopf **Freigeben** in "
. "der Hardware-Liste des WebUI.",
'requestBody' => [
'required' => true,
'content' => ['application/json' => ['schema' => [
'type' => 'object',
'required' => ['product', 'license_key', 'hardware_id'],
'properties' => [
'product' => ['type' => 'string'],
'license_key' => ['type' => 'string'],
'hardware_id' => ['type' => 'string'],
'nonce' => ['type' => 'string'],
],
]]],
],
'responses' => ['200' => ['description' => 'Freigegeben'], '401' => $errorResponse],
],
],
'/api/license/v1/status' => [
'get' => [
'tags' => ['Lizenz'],
'summary' => 'Verfuegbarkeitspruefung des Moduls',
'security' => [],
'responses' => ['200' => ['description' => 'Modulzustand']],
],
],
'/api/tokens/v1/provision' => [
'post' => [
'tags' => ['Tokens'],
'summary' => 'Sub-Token aus Master-Token erzeugen',
'description' => 'Master-Token im Header X-Master-Token. Rechte koennen nur eingeschraenkt, nicht erweitert werden.',
'requestBody' => [
'content' => ['application/json' => ['schema' => [
'type' => 'object',
'properties' => [
'client_name' => ['type' => 'string'],
'instance_id' => ['type' => 'string'],
'scopes' => ['type' => 'array', 'items' => ['type' => 'string', 'enum' => TokenManager::KNOWN_SCOPES]],
'environment' => ['type' => 'string', 'enum' => TokenManager::ENVIRONMENTS],
],
]]],
],
'responses' => ['201' => ['description' => 'Sub-Token erstellt'], '403' => $errorResponse],
],
],
],
];
// Direkte Ausgabe statt Http::ok(), damit die Spezifikation nicht in einen
// status-Umschlag verpackt wird.
if (!headers_sent()) {
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: public, max-age=300');
}
echo json_encode($spec, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);