feat(updateservice): Plattform-Dimension, signierte Releases, Update mit Rollback
Behebt eine Reihe zusammenhaengender Fehler im Update-Weg, die zusammen verhindert haben, fuer mehr als eine Plattform auszuliefern - und die im Fehlerfall halb aktualisierte Installationen hinterliessen. Server - Migration 009: Spalte platform samt neuem Unique-Key. Zuvor verdraengte das zuletzt veroeffentlichte Paket alle anderen Plattformen derselben Version, weil ON DUPLICATE KEY auf (slug, version, channel) griff. Ein Linux-System zog sich damit das Windows-Paket. - Aufloesungsregel: je Version das plattformgenaue Paket, sonst das plattformunabhaengige. Ein Client ohne Plattformangabe sieht ausschliesslich 'any' - lieber kein Update als das falsche. - manifest_json wird endlich befuellt; die Spalte blieb bisher immer leer, wodurch die API nie der Rueckfall sein konnte, als der sie gedacht war. - Releases werden serverseitig mit RSA-SHA256 signiert, neuer Endpunkt /api/updateservice/v1/pubkey. Bewusst kein HMAC: der Pruefende laeuft auf fremden Systemen und darf den Signierschluessel nicht besitzen. Packager - Bricht ab, statt die Versionshistorie zu verlieren. Schlug das Lesen der bestehenden latest.json fehl, ersetzte ein leeres catch die komplette Historie durch einen einzigen Eintrag - ohne jede Meldung. - Echte Glob-Muster. Zuvor trafen "logs/**" und "scratch/**" aus der mitgelieferten Beispielkonfiguration nie zu. - preservePatterns: Konfigurationsvorlagen werden ausgeliefert, ersetzen am Ziel aber keine vorhandene Datei. Eine settings.json mit Zugangsdaten ueberschrieb bisher beim Update die Konfiguration jedes Zielsystems. - Warnt vor Dateien, die nach Zugangsdaten aussehen und auf keiner Liste stehen. - Prueft --version gegen die Hauptassembly. Eine Abweichung fuehrte zu einer Endlosschleife: Clients aktualisieren, melden weiter die alte Version, halten das Release erneut fuer neu. - --platform mit Ableitung aus dem Publish-Pfad. Agent - Anwenden mit Plan, Backup und vollstaendigem Rollback. Die Stelle war als "Atomic Replace with Backup" kommentiert und war eine Kopierschleife. - Verwaiste Dateien werden entfernt, aber nur solche aus dem Manifest der Vorversion. Was nicht aus einem Release stammt, bleibt liegen. - Das laufende Agent-Binary wird zur Seite gelegt statt ueberschrieben. - API-Rueckfall in FetchManifestAsync; bisher nur im SDK vorhanden, weshalb die Anwendung "Update verfuegbar" und der Agent "kein Release" sagen konnte. - Installierte Version aus --current-version oder manifest.json statt des Textes "Unbekannt", der als 0 gelesen wurde und jede Version neuer erscheinen liess. Reparatur funktioniert damit auch ohne manifest.json. - Setzt das Ausfuehrungsbit fuer Linux-Pakete, die unter Windows gebaut wurden. SDK - ResolveAgentPath() liefert den plattformrichtigen Namen; ein fest verdrahtetes "update-agent.exe" wird unter Linux nie gefunden. - LaunchUpdateAgent uebergibt jetzt --restart (wurde nie uebergeben, die Anwendung blieb nach dem Update zu), --wait-for-pid (kein Wettlauf mehr mit dem Herunterfahren) und --platform. Enthaelt ausserdem die bislang nicht committete Arbeit an Watchdog, Lizenz- Client und cli/tick.php samt Migration 008; die betroffenen Dateien liessen sich nicht getrennt stagen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
5f9b0c5596
commit
2388b5abe1
+399
-4
@@ -15,6 +15,7 @@ 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);
|
||||
@@ -32,7 +33,11 @@ $spec = [
|
||||
'title' => 'Deploymentcenter API',
|
||||
'version' => (string)Config::get('app.version', '2.0.0'),
|
||||
'description' =>
|
||||
"Zentrale Schnittstelle fuer Bugtracker, UpdateService, Watchdog und Token-Provisionierung.\n\n"
|
||||
"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"
|
||||
@@ -115,6 +120,94 @@ $spec = [
|
||||
'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' => []]],
|
||||
@@ -261,13 +354,125 @@ $spec = [
|
||||
'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,
|
||||
],
|
||||
'responses' => ['200' => ['description' => 'Vergleich nach semantischer Versionsordnung']],
|
||||
],
|
||||
],
|
||||
|
||||
@@ -275,7 +480,11 @@ $spec = [
|
||||
'post' => [
|
||||
'tags' => ['UpdateService'],
|
||||
'summary' => 'Release veroeffentlichen',
|
||||
'description' => 'Scope updateservice:publish. Schliesst automatisch alle Bugtracker-Items, deren resolved_in_build dieser Version entspricht.',
|
||||
'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' => [
|
||||
@@ -285,12 +494,20 @@ $spec = [
|
||||
'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.',
|
||||
],
|
||||
],
|
||||
]]],
|
||||
],
|
||||
@@ -309,8 +526,23 @@ $spec = [
|
||||
'type' => 'object',
|
||||
'required' => ['source'],
|
||||
'properties' => [
|
||||
'source' => ['type' => 'string'],
|
||||
'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'],
|
||||
@@ -331,6 +563,73 @@ $spec = [
|
||||
],
|
||||
],
|
||||
|
||||
'/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'],
|
||||
@@ -340,6 +639,102 @@ $spec = [
|
||||
],
|
||||
],
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 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'],
|
||||
|
||||
@@ -3,9 +3,10 @@
|
||||
/**
|
||||
* UpdateService API
|
||||
*
|
||||
* GET /api/updateservice/v1/check?product=myapp&version=1.0.0&channel=prod
|
||||
* GET /api/updateservice/v1/latest?product=myapp&channel=prod
|
||||
* GET /api/updateservice/v1/check?product=myapp&version=1.0.0&channel=prod&platform=win-x64
|
||||
* GET /api/updateservice/v1/latest?product=myapp&channel=prod&platform=win-x64
|
||||
* GET /api/updateservice/v1/releases?product=myapp
|
||||
* GET /api/updateservice/v1/pubkey
|
||||
* POST /api/updateservice/v1/publish (Scope updateservice:publish)
|
||||
*
|
||||
* SICHERHEITSAENDERUNG: Das Veroeffentlichen eines Releases war vollstaendig
|
||||
@@ -13,6 +14,11 @@
|
||||
* Releases ueberschreiben und damit allen Clients ein beliebiges Paket
|
||||
* unterschieben. Publish verlangt jetzt ein Token mit "updateservice:publish".
|
||||
*
|
||||
* PLATTFORM: Releases tragen seit Migration 009 eine Laufzeitkennung. Ein
|
||||
* Client, der "platform" mitschickt, bekommt nur Pakete seiner Plattform oder
|
||||
* plattformunabhaengige ('any'). Ein Client ohne Angabe sieht ausschliesslich
|
||||
* 'any' - lieber kein Update als das Paket einer fremden Plattform.
|
||||
*
|
||||
* Die Lese-Endpunkte bleiben ohne Token erreichbar, damit bereits ausgerollte
|
||||
* Client-Anwendungen weiter nach Updates suchen koennen. Sie liefern nur
|
||||
* Release-Metadaten, die ueber die Download-URL ohnehin oeffentlich sind.
|
||||
@@ -26,6 +32,7 @@ use Deploymentcenter\Core\ApiAuth;
|
||||
use Deploymentcenter\Core\Db;
|
||||
use Deploymentcenter\Core\Http;
|
||||
use Deploymentcenter\Modules\License\RateLimiter;
|
||||
use Deploymentcenter\Modules\UpdateService\ReleaseSigner;
|
||||
use Deploymentcenter\Modules\UpdateService\UpdateManager;
|
||||
use Deploymentcenter\Modules\UpdateService\Version;
|
||||
|
||||
@@ -49,17 +56,19 @@ switch ($action) {
|
||||
Http::fail(400, 'missing_product', 'Der Parameter "product" wird benoetigt.');
|
||||
}
|
||||
|
||||
$current = Http::str('version') ?? Http::str('current_version') ?? '0.0.0';
|
||||
$channel = Http::str('channel') ?? 'prod';
|
||||
$current = Http::str('version') ?? Http::str('current_version') ?? '0.0.0';
|
||||
$channel = Http::str('channel') ?? 'prod';
|
||||
$platform = Http::str('platform') ?? Http::str('rid');
|
||||
|
||||
$latest = $manager->checkUpdate($product, $current, $channel);
|
||||
$latest = $manager->checkUpdate($product, $current, $channel, $platform);
|
||||
|
||||
if ($latest === null) {
|
||||
$installed = $manager->latestRelease($product, $channel);
|
||||
$installed = $manager->latestRelease($product, $channel, $platform);
|
||||
Http::ok([
|
||||
'update_available' => false,
|
||||
'current_version' => $current,
|
||||
'latest_version' => $installed !== null ? $installed['version'] : $current,
|
||||
'platform' => UpdateManager::normalizePlatform($platform),
|
||||
'message' => 'Anwendung ist aktuell.',
|
||||
]);
|
||||
}
|
||||
@@ -68,6 +77,7 @@ switch ($action) {
|
||||
'update_available' => true,
|
||||
'current_version' => $current,
|
||||
'latest_version' => $latest['version'],
|
||||
'platform' => $latest['platform'] ?? UpdateManager::PLATFORM_ANY,
|
||||
'is_critical' => (bool)$latest['is_critical'],
|
||||
'latest_release' => $latest,
|
||||
]);
|
||||
@@ -78,7 +88,11 @@ switch ($action) {
|
||||
Http::fail(400, 'missing_product', 'Der Parameter "product" wird benoetigt.');
|
||||
}
|
||||
|
||||
$release = $manager->latestRelease($product, Http::str('channel') ?? 'prod');
|
||||
$release = $manager->latestRelease(
|
||||
$product,
|
||||
Http::str('channel') ?? 'prod',
|
||||
Http::str('platform') ?? Http::str('rid')
|
||||
);
|
||||
if ($release === null) {
|
||||
Http::fail(404, 'no_release', sprintf('Fuer "%s" ist kein Release hinterlegt.', $product));
|
||||
}
|
||||
@@ -89,10 +103,33 @@ switch ($action) {
|
||||
$releases = $manager->getReleases(
|
||||
Http::str('product') ?? Http::str('product_slug'),
|
||||
Http::str('channel'),
|
||||
Http::int('limit', 200)
|
||||
Http::int('limit', 200),
|
||||
Http::str('platform') ?? Http::str('rid')
|
||||
);
|
||||
Http::ok(['count' => count($releases), 'releases' => $releases]);
|
||||
|
||||
case 'pubkey':
|
||||
// Oeffentlicher Schluessel zum Pruefen der Release-Signaturen.
|
||||
// Bewusst ohne Token: er ist oeffentlich, und der Agent braucht ihn,
|
||||
// bevor er irgendetwas anderes vertrauen kann.
|
||||
$pub = ReleaseSigner::publicKeyPem();
|
||||
|
||||
if ($pub === null) {
|
||||
Http::fail(
|
||||
404,
|
||||
'signing_disabled',
|
||||
'Auf diesem Deploymentcenter ist kein Signierschluessel hinterlegt '
|
||||
. '(security.release_private_key). Releases werden unsigniert ausgeliefert.'
|
||||
);
|
||||
}
|
||||
|
||||
Http::ok([
|
||||
'algorithm' => 'RSA-SHA256',
|
||||
'format' => 'canonical-line-v1',
|
||||
'public_key' => $pub,
|
||||
'fingerprint' => ReleaseSigner::publicKeyFingerprint(),
|
||||
]);
|
||||
|
||||
case 'publish':
|
||||
if (Http::method() !== 'POST') {
|
||||
Http::fail(405, 'method_not_allowed', 'Das Veroeffentlichen erwartet POST.');
|
||||
@@ -131,30 +168,76 @@ switch ($action) {
|
||||
Http::fail(400, 'invalid_hash', 'sha256_hash muss 64 Hexadezimalzeichen enthalten.');
|
||||
}
|
||||
|
||||
$channel = Http::str('channel') ?? 'prod';
|
||||
$platform = UpdateManager::normalizePlatform(Http::str('platform') ?? Http::str('rid'));
|
||||
$size = Http::int('size_bytes', 0);
|
||||
|
||||
// Das Dateimanifest wandert mit in die Datenbank. Bisher blieb die
|
||||
// Spalte manifest_json immer leer - damit konnte die API kein
|
||||
// vollwertiger Rueckfall fuer den Agenten sein, wenn die statische
|
||||
// latest.json fehlt.
|
||||
$manifestJson = null;
|
||||
$manifestRaw = Http::input('manifest_json', null);
|
||||
|
||||
if (is_array($manifestRaw)) {
|
||||
$encoded = json_encode($manifestRaw, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
||||
$manifestJson = $encoded === false ? null : $encoded;
|
||||
} elseif (is_string($manifestRaw) && trim($manifestRaw) !== '') {
|
||||
if (json_decode($manifestRaw) === null && json_last_error() !== JSON_ERROR_NONE) {
|
||||
Http::fail(400, 'invalid_manifest', 'manifest_json ist kein gueltiges JSON.');
|
||||
}
|
||||
$manifestJson = $manifestRaw;
|
||||
}
|
||||
|
||||
if ($manifestJson !== null && strlen($manifestJson) > 4 * 1024 * 1024) {
|
||||
Http::fail(400, 'manifest_too_large', 'manifest_json ueberschreitet 4 MB.');
|
||||
}
|
||||
|
||||
// Signiert wird serverseitig. Der Packager laeuft auf Entwickler-
|
||||
// rechnern; ein dort hinterlegter Signierschluessel waere so gut
|
||||
// geschuetzt wie das schwaechste dieser Systeme.
|
||||
$signature = ReleaseSigner::sign(ReleaseSigner::canonical(
|
||||
$product,
|
||||
$version,
|
||||
$channel,
|
||||
$platform,
|
||||
$hash,
|
||||
$url,
|
||||
$size
|
||||
));
|
||||
|
||||
$result = $manager->addRelease(
|
||||
$product,
|
||||
$version,
|
||||
Http::str('channel') ?? 'prod',
|
||||
$channel,
|
||||
Http::str('release_notes'),
|
||||
$url,
|
||||
$hash,
|
||||
Http::str('git_commit'),
|
||||
Http::int('size_bytes', 0),
|
||||
null,
|
||||
$size,
|
||||
$manifestJson,
|
||||
filter_var(Http::input('is_critical', false), FILTER_VALIDATE_BOOLEAN),
|
||||
$context['actor']
|
||||
$context['actor'],
|
||||
$platform,
|
||||
$signature
|
||||
);
|
||||
|
||||
Http::ok([
|
||||
'release_id' => $result['id'],
|
||||
'created' => $result['created'],
|
||||
'platform' => $platform,
|
||||
'signed' => $signature !== null,
|
||||
'auto_resolved' => $result['auto_resolved'],
|
||||
'message' => sprintf(
|
||||
'Release %s (%s) fuer "%s" %s.%s',
|
||||
'Release %s (%s, %s) fuer "%s" %s.%s%s',
|
||||
$version,
|
||||
Http::str('channel') ?? 'prod',
|
||||
$channel,
|
||||
$platform,
|
||||
$product,
|
||||
$result['created'] ? 'veroeffentlicht' : 'aktualisiert',
|
||||
$signature === null
|
||||
? ' Hinweis: unsigniert, kein Signierschluessel hinterlegt.'
|
||||
: '',
|
||||
$result['auto_resolved'] > 0
|
||||
? sprintf(' %d Bugtracker-Item(s) automatisch geschlossen.', $result['auto_resolved'])
|
||||
: ''
|
||||
@@ -163,7 +246,7 @@ switch ($action) {
|
||||
|
||||
default:
|
||||
Http::fail(404, 'unknown_action', 'Endpunkt nicht gefunden.', null, [
|
||||
'available' => ['check', 'latest', 'releases', 'publish'],
|
||||
'available' => ['check', 'latest', 'releases', 'pubkey', 'publish'],
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -187,7 +270,7 @@ function resolveUpdateAction(): string
|
||||
$last = strtolower((string)end($segments));
|
||||
|
||||
return match ($last) {
|
||||
'check', 'latest', 'releases', 'publish' => $last,
|
||||
'check', 'latest', 'releases', 'pubkey', 'publish' => $last,
|
||||
'publish_release' => 'publish',
|
||||
default => 'check',
|
||||
};
|
||||
|
||||
@@ -14,7 +14,11 @@
|
||||
* blieb ein ausgefallener Server dauerhaft gruen, weil der Zustand sich nur
|
||||
* beim Eintreffen eines Heartbeats aenderte.
|
||||
*
|
||||
* Cron-Eintrag (minuetlich):
|
||||
* Cron-Eintrag (minuetlich). Bevorzugt intern, ohne HTTP und ohne Schluessel
|
||||
* im Crontab:
|
||||
* * * * * * /usr/bin/php /pfad/zum/deploymentcenter/cli/tick.php --quiet
|
||||
*
|
||||
* Ueber die Schnittstelle, wenn der Cron woanders laeuft:
|
||||
* * * * * * curl -fsS -H "Authorization: Bearer <SHARED_KEY>" \
|
||||
* https://dc.example.com/api/watchdog/v1/evaluate > /dev/null
|
||||
*
|
||||
@@ -71,7 +75,10 @@ switch ($action) {
|
||||
Http::str('group') ?? Http::str('group_key'),
|
||||
Http::str('os'),
|
||||
// Gesundheitszustand, den die Anwendung selbst ermittelt hat.
|
||||
Http::input('checks')
|
||||
Http::input('checks'),
|
||||
// Welche Version laeuft hier? Optional - bestehende Agenten
|
||||
// schicken das Feld nicht und laufen unveraendert weiter.
|
||||
Http::str('version') ?? Http::str('app_version')
|
||||
);
|
||||
|
||||
// Numerische Werte in den Verlauf uebernehmen, damit sich Trends
|
||||
@@ -102,6 +109,7 @@ switch ($action) {
|
||||
'state' => $monitor['state'],
|
||||
'last_status' => $monitor['last_status'],
|
||||
'last_seen_utc' => $monitor['last_seen_utc'],
|
||||
'app_version' => $monitor['app_version'] ?? null,
|
||||
'state_changed' => (bool)($monitor['_state_changed'] ?? false),
|
||||
'failing_checks' => $monitor['_failing_checks'] ?? [],
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user