Sammelcommit fuer den ueber mehrere Sitzungen gewachsenen, bisher nicht
eingecheckten Stand. Getrennte Commits waeren ehrlicher gewesen, sind es
nachtraeglich aber nicht mehr: BugRepo, changelog.json und die Doku tragen
Aenderungen aus allen Straengen gleichzeitig, und keine Zwischenstufe waere
lauffaehig.
ROADMAP IM DEPLOYMENTCENTER (2.7.0)
Planungsstand, Reihenfolge und Zustaendigkeit lagen in handgepflegten
Markdown-Dateien neben einem Bugtracker, der genau diese drei Dinge bereits
fuehrt. Zwei Pfleger fuer dasselbe Feld - Drift war eine Frage der Zeit.
Ab jetzt: Zustand im DC, Begruendung in der Datei. Kein Feld hat zwei Pfleger.
- Migration 016: epic, roadmap_rank, target_build, defer_trigger,
doc_anchor, Status "deferred", Tabelle roadmap_epics.
KEINE Roadmap-Tabelle - ein Eintrag ist ein bugtracker_item.
- Zugehoerigkeit ist ein Feld: Ein Item steht auf der Roadmap, wenn es ein
epic hat. Ein zweites Kennzeichen daneben koennte auseinanderlaufen.
- Kennung ist die Item-ID. Handvergebene Kuerzel ("A12") erzeugen die
schlimmste Fehlerart: Beide Seiten stimmig, Verschiedenes gemeint, weil
die Kennung nach einer Loeschung neu vergeben wurde.
- Zurueckstellen verlangt defer_trigger. Ohne benannten Anlass ist ein
Vorhaben nicht zurueckgestellt, sondern vergessen. Die Regel sitzt im
Repository, damit sie auf allen drei Schreibwegen gleich greift.
- Modules/Bugtracker/Roadmap.php, /api/bugtracker/v1/roadmap.php,
WebUI-Modul mit Filtern, cli/roadmap.php --write/--check,
dc_roadmap und dc_roadmap_set im MCP-Adapter.
- cli/roadmap.php --write ERZEUGT den Zustandsteil der Markdown-Datei;
--check prueft nur die Prosa. Was erzeugt wird, kann nicht driften.
- docs/ROADMAP_MIGRATION.md: Uebernahme fuer die anderen Projekte.
TESTINSTANZ UND TESTTOR
- dc-dev.mhdf.de: zweite Installation, eigene DB, eigene Schluessel,
Basic-Auth davor, Rocket.Chat aus.
- 124 Testfaelle in tests/e2e gegen API, Rechte, Migrationen, Sperre.
- scripts/deploy.py bringt vor jedem Upload erst den anstehenden Stand auf
die Testinstanz und laesst dann den Testlauf laufen. Erst bei
Rueckgabewert 0 geht etwas auf die Produktion (--ohne-test als Notausgang).
Andersherum prueft der Lauf aelteren Code als den, der gleich hochgeht.
- tests/ ist aus dem Produktiv-Deploy ausgeschlossen; testctl.php kann
nicht versehentlich auf dc.mhdf.de landen.
- Ein Lauf mit mehr uebersprungenen als geprueften Faellen endet mit
Rueckgabewert 2 statt 0. Nichts ist kaputt, geprueft wurde es aber auch
nicht - der Unterschied darf nicht am Rueckgabewert verschwinden.
SUPERVISOR, PROJEKTLANDSCHAFT, NACHPRUEFUNG (2.6.0/2.6.1)
- Migration 013: projektuebergreifend melden (dev_agent, evidence,
triage_external, affects).
- Migration 014: supervisor_findings, Katalog, fuenf serverseitige
Detektoren im Tick.
- Migration 015: Nachpruefung als Arbeitsschlange statt Benachrichtigung.
- DEV-Lizenzschluessel zur Selbstbedienung, dc-testkit fuer die Test-VM.
- MCP-Adapter: 15 Werkzeuge, ohne Fremdabhaengigkeiten, mit Abgrenzung
von Fremddaten gegen Prompt-Injektion.
BEHOBEN
- observe und status im Supervisor legten den Zustand des Befundes als
"status" in die Antwort. Http::ok() merged ['status' => 'success']
darunter, also gewann der Befundzustand - eine angenommene Meldung kam
als status="open" an. Heisst jetzt finding_status. War Item #59.
Migrationen 013-016 sind auf dem Produktivserver noch NICHT ausgefuehrt.
Ohne sie bricht das WebUI, weil die Roadmap-Ansicht bei jedem Seitenaufruf
gegen bugtracker_items.epic abfragt.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
192 lines
7.6 KiB
PHP
192 lines
7.6 KiB
PHP
<?php
|
|
|
|
/**
|
|
* Lizenz-API
|
|
*
|
|
* POST /api/license/v1/validate Lizenz und Hardware pruefen (oeffentlich)
|
|
* POST /api/license/v1/deactivate Aktivierung freigeben (authentifiziert)
|
|
* POST /api/license/v1/dev-key Entwicklungsschluessel abholen (license:dev)
|
|
* GET /api/license/v1/status Verfuegbarkeitspruefung
|
|
*
|
|
* ACHTUNG, ZWEI ANTWORTFORMATE IN EINER DATEI: validate und deactivate
|
|
* antworten ohne den status/error-Umschlag der uebrigen API - das ist ein
|
|
* ausgerollter Vertrag, siehe die Begruendung weiter unten. dev-key ist neu und
|
|
* folgt deshalb dem Hausformat (status: success). Wer hier etwas ergaenzt,
|
|
* nimmt das Hausformat; der Sonderfall gilt nur fuer die beiden alten Wege.
|
|
*/
|
|
|
|
declare(strict_types=1);
|
|
|
|
require_once __DIR__ . '/../../../../src/bootstrap.php';
|
|
|
|
use Deploymentcenter\Core\ApiAuth;
|
|
use Deploymentcenter\Core\Db;
|
|
use Deploymentcenter\Core\Http;
|
|
use Deploymentcenter\Modules\License\DevKeys;
|
|
use Deploymentcenter\Modules\License\LicenseService;
|
|
use Deploymentcenter\Modules\License\RateLimiter;
|
|
|
|
Http::beginJson(['GET', 'POST', 'OPTIONS'], true);
|
|
|
|
$db = Db::init();
|
|
|
|
$ip = Http::clientIp();
|
|
$limiter = new RateLimiter($db, 120, 60, 'license');
|
|
if (!$limiter->check($ip)) {
|
|
Http::fail(429, 'rate_limited', 'Zu viele Anfragen.');
|
|
}
|
|
|
|
$service = new LicenseService($db);
|
|
$action = resolveLicenseAction();
|
|
|
|
switch ($action) {
|
|
|
|
// WICHTIG: Die Lizenz-Endpunkte antworten bewusst OHNE den
|
|
// status/error-Umschlag der uebrigen API. Ihr Format ist ein bereits
|
|
// ausgerollter Vertrag: das Feld "status" auf oberster Ebene traegt den
|
|
// Lizenzzustand (valid, revoked, expired, not_found, activation_limit).
|
|
// Ein Umschlag mit status=success wuerde von jedem bestehenden Client als
|
|
// "nicht valid" interpretiert - saemtliche Lizenzen gaelten als ungueltig.
|
|
|
|
case 'validate':
|
|
if (Http::method() !== 'POST') {
|
|
Http::fail(405, 'method_not_allowed', 'Diese Aktion erwartet POST.');
|
|
}
|
|
// Bewusst oeffentlich: Client-Anwendungen pruefen hier ihre Lizenz.
|
|
// Die Antwort verraet nichts ueber fremde Lizenzen.
|
|
Http::raw($service->validate(Http::body(), $ip));
|
|
|
|
case 'deactivate':
|
|
if (Http::method() !== 'POST') {
|
|
Http::fail(405, 'method_not_allowed', 'Diese Aktion erwartet POST.');
|
|
}
|
|
ApiAuth::requireScope($db, 'license:deactivate');
|
|
Http::raw($service->deactivate(Http::body(), $ip));
|
|
|
|
// ------------------------------------------------------------------
|
|
// Entwicklungsschluessel zur Selbstbedienung
|
|
//
|
|
// Damit ein Agent auf der Testmaschine ueberhaupt etwas pruefen kann,
|
|
// braucht er einen gueltigen Schluessel - fuer die Lizenzpruefung selbst
|
|
// und seit 2.4 auch fuer den Zugang zur Release-Ablage. Ihn von Hand
|
|
// auszustellen und weiterzureichen war der einzige Weg; das Ergebnis waren
|
|
// unbefristete Schluessel in Chatverlaeufen. Herleitung in DevKeys.
|
|
//
|
|
// Antwortet im Hausformat, nicht im Sonderformat der Lizenzpruefung.
|
|
// ------------------------------------------------------------------
|
|
case 'dev-key':
|
|
if (Http::method() !== 'POST') {
|
|
Http::fail(405, 'method_not_allowed', 'Diese Aktion erwartet POST.');
|
|
}
|
|
|
|
// Enger gedrosselt als der Rest des Moduls: Ein Schluessel wird einmal
|
|
// je Testlauf geholt, nicht im Sekundentakt. Wer haeufiger anfragt, hat
|
|
// eine Schleife gebaut - und legt sonst Zeilen an, bis die Grenze aus
|
|
// DevKeys::MAX_PER_PROJECT greift.
|
|
$devLimiter = new RateLimiter($db, 10, 60, 'license_dev');
|
|
if (!$devLimiter->check($ip)) {
|
|
Http::fail(429, 'rate_limited', 'Zu viele Anfragen nach Entwicklungsschluesseln.');
|
|
}
|
|
|
|
// allowSession bewusst aus: Dieser Weg ist fuer Werkbank-Token auf
|
|
// Testmaschinen, nicht fuer einen angemeldeten Browser. Wer im WebUI
|
|
// sitzt, legt Lizenzen dort an.
|
|
$context = ApiAuth::requireScope($db, 'license:dev', null, false);
|
|
|
|
$slug = Http::str('project_slug') ?? Http::str('product');
|
|
if ($slug === null) {
|
|
Http::fail(400, 'missing_project', 'Es fehlt project_slug.');
|
|
}
|
|
|
|
// allowWorkbench bleibt aus. Ein Werkbank-Token darf projektuebergreifend
|
|
// melden - aber ein Lizenzschluessel ist etwas anderes als eine Meldung:
|
|
// Er oeffnet die Release-Ablage eines Produkts. Wer fuer ein fremdes
|
|
// Projekt testet, braucht dafuer dessen Token.
|
|
ApiAuth::enforceProject($context, $slug);
|
|
|
|
$identity = Http::str('instance_id')
|
|
?? Http::str('hostname')
|
|
?? ($context['token']['owner_identity'] ?? null);
|
|
|
|
if ($identity === null || $identity === '') {
|
|
Http::fail(
|
|
400,
|
|
'missing_identity',
|
|
'Es fehlt instance_id - die Kennung der Maschine, die den Schluessel bekommt. '
|
|
. 'Ohne sie liesse sich ein wiederholter Aufruf nicht demselben Schluessel zuordnen.'
|
|
);
|
|
}
|
|
|
|
$devAction = strtolower(Http::str('action_mode') ?? 'issue');
|
|
|
|
try {
|
|
if ($devAction === 'revoke') {
|
|
$removed = DevKeys::revoke($db, $slug, (string)$identity, $context['actor']);
|
|
|
|
Http::ok([
|
|
'project_slug' => $slug,
|
|
'revoked' => $removed,
|
|
'message' => $removed
|
|
? 'Entwicklungsschluessel zurueckgegeben.'
|
|
: 'Fuer diese Maschine war kein Entwicklungsschluessel vergeben.',
|
|
]);
|
|
}
|
|
|
|
$result = DevKeys::issue(
|
|
$db,
|
|
$slug,
|
|
(string)$identity,
|
|
Http::input('ttl_days') !== null ? Http::int('ttl_days') : null,
|
|
$context['actor']
|
|
);
|
|
} catch (RuntimeException $e) {
|
|
Http::fail(409, 'dev_key_denied', $e->getMessage());
|
|
}
|
|
|
|
Http::ok([
|
|
'license_key' => $result['license_key'],
|
|
'project_slug' => $result['project_slug'],
|
|
'instance_id' => $result['identity'],
|
|
'expires_at' => $result['expires_at'],
|
|
'max_activations' => $result['max_activations'],
|
|
'activations_used' => $result['activations_used'],
|
|
'created' => $result['created'],
|
|
'renewed' => $result['renewed'],
|
|
'message' => $result['created']
|
|
? 'Entwicklungsschluessel ausgestellt. Er ist befristet und gilt nur fuer diese Maschine.'
|
|
: 'Bestehender Entwicklungsschluessel verlaengert.',
|
|
], $result['created'] ? 201 : 200);
|
|
|
|
case 'status':
|
|
Http::raw(['status' => 'ok', 'module' => 'license', 'version' => '2.0']);
|
|
|
|
default:
|
|
Http::fail(404, 'unknown_action', 'Endpunkt nicht gefunden.', null, [
|
|
'available' => ['validate', 'deactivate', 'dev-key', 'status'],
|
|
]);
|
|
}
|
|
|
|
function resolveLicenseAction(): string
|
|
{
|
|
$explicit = Http::str('action');
|
|
if ($explicit !== null) {
|
|
return strtolower($explicit);
|
|
}
|
|
|
|
$segments = array_values(array_filter(
|
|
explode('/', trim(Http::path(), '/')),
|
|
static fn(string $s): bool => $s !== ''
|
|
));
|
|
|
|
$last = strtolower((string)end($segments));
|
|
|
|
return match ($last) {
|
|
'validate', 'deactivate', 'status', 'dev-key' => $last,
|
|
// Ein Tippfehler bei der Selbstbedienung soll nicht stillschweigend zur
|
|
// Verfuegbarkeitspruefung werden - sonst sucht jemand lange nach dem
|
|
// Grund, warum "kein Schluessel kommt, aber alles ok meldet".
|
|
'devkey', 'dev_key' => 'dev-key',
|
|
default => 'status',
|
|
};
|
|
}
|