fix(security, core): Auth-Pflicht für Ingest-APIs, 500er-Ursachen beheben, Agenten-Workflow
Sicherheit
- install_db.php war ohne Authentifizierung erreichbar und setzte bei jedem
Aufruf das Admin-Passwort auf einen fest im Code stehenden Wert zurück.
Jetzt Auth-Pflicht; ein Konto wird nur bei leerer Benutzertabelle angelegt.
- Stored XSS im Bugtracker-Detail-Modal: Titel, Beschreibung, Fehlermeldung,
Stacktrace und Kommentare gingen ungefiltert durch innerHTML.
- report.php, projects.php und das Veröffentlichen von Releases verlangen jetzt
zwingend ein Token. Publish war zuvor völlig ungeschützt.
- CSRF-Token in allen Formularen, Session-Regenerierung nach Login,
Drosselung fehlgeschlagener Anmeldeversuche.
- Zugangsdaten aus der Versionskontrolle entfernt (Serverdaten.txt,
config.php, .htpasswd, deploy_config.json). Historie enthält sie weiterhin,
Rotation erforderlich (siehe docs/UPGRADE.md).
- Token-Validierung nur noch über SHA-256-Hash; expires_at wird ausgewertet.
Behobene 500er
- Audit::log() war in index.php weder eingebunden noch importiert. Jeder
Klick auf "Aktivierung freigeben" endete in einem Fatal Error.
- Derselbe benannte PDO-Platzhalter mehrfach je Statement (:id in
revokeToken/deleteToken, :q siebenfach in der Volltextsuche). Bei
EMULATE_PREPARES=false ist das nicht zulässig und warf HY093.
- Migration 005 nutzte dynamisches SQL, dessen Semikolons in String-Literalen
vom alten explode(';')-Installer als Statement-Ende gelesen wurden. Sie
schlug still fehl, wodurch push_id/target_agent/tags dauerhaft fehlten.
- Monitor-Umbenennung ohne Transaktion, verschachtelte Transaktionen im
RateLimiter.
Funktionale Korrekturen
- Der Watchdog-Evaluator fehlte vollständig: Monitor-Zustände änderten sich nur
beim Eintreffen eines Heartbeats, ein ausgefallenes System blieb dauerhaft
"up". Erster Lauf auf dem Produktivsystem: 7 von 10 Monitoren waren
tatsächlich seit über einem Tag nicht erreichbar.
- Das Feld "os" fehlte im Monitor-Dialog, wurde aber gespeichert und löschte
damit bei jedem Speichern das Betriebssystem.
- Der Resolve-Dialog existierte im HTML nicht; der Button war funktionslos.
- Versionsvergleich erfolgte lexikografisch, wodurch 1.9.0 als neuer galt
als 1.10.0.
- Schreiboperationen meldeten Erfolg auch für nicht existierende IDs.
- Post/Redirect/Get gegen doppelte Einträge beim Neuladen.
Neue Struktur
- src/bootstrap.php mit PSR-4-Autoloader ersetzt die require-Ketten.
- Core: Config, Http, Csrf, ApiAuth, Logger, Migrator, ErrorReporter.
- Migrator mit zeichenweisem SQL-Parser, dc_migrations und Baseline-Verfahren,
damit bestehende Installationen keine Beispieldaten zurückbekommen.
Agenten-Workflow
- Claim/Lease: Items werden exklusiv übernommen, damit nicht zwei Agenten am
selben Problem arbeiten. action=next holt und reserviert in einem Zug.
- Idempotenz über client_ref, Deduplizierung auch für Feature Requests,
Erkennung von Regressionen, automatische Eskalation des Schweregrads.
- Strukturierter Code-Kontext (repo_url, commit_sha, file_path, line_no).
- Delta-Abfragen über updated_since, Pagination, Bulk-Update.
- Beim Veröffentlichen eines Releases schließen sich Items mit passendem
resolved_in_build selbst.
- Ausgehende Webhooks mit HMAC-Signatur, /api/health, /api/openapi.json.
- Unbehandelte Fehler meldet die Plattform in ihren eigenen Bugtracker.
WebUI
- Serverseitige Filterung mit Pagination statt Rendern aller Datensätze.
- Migrations-Schranke, Evaluator-Warnung, Übersicht aktiver Agenten.
Zeitstempel liegen in der Datenbank durchgängig in UTC und werden für die
Anzeige in die App-Zeitzone umgerechnet.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
a21536f495
commit
e7fbc85db4
@@ -1,229 +1,334 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Bugtracker Management-API - die Schnittstelle fuer Coding-Agenten.
|
||||
*
|
||||
* Basis: /api/bugtracker/v1/manage
|
||||
*
|
||||
* Lesen (Scope bugtracker:read):
|
||||
* GET ?action=list Gefilterte Liste mit Pagination und Delta-Abfrage
|
||||
* GET ?action=get&id=42 Einzelnes Item samt Kommentar-Historie
|
||||
* GET ?action=stats Kennzahlen
|
||||
* GET ?action=projects Projektliste
|
||||
*
|
||||
* Schreiben (Scope bugtracker:manage):
|
||||
* POST ?action=claim&id=42 Item exklusiv uebernehmen
|
||||
* POST ?action=next Naechste offene Items holen und uebernehmen
|
||||
* POST ?action=release&id=42 Item wieder freigeben
|
||||
* POST ?action=comment&id=42 Kommentar / Ermittlungsschritt anhaengen
|
||||
* POST ?action=status&id=42 Status setzen
|
||||
* POST ?action=update&id=42 Mehrere Felder aendern
|
||||
* POST ?action=resolve&id=42 Als geloest markieren
|
||||
* POST ?action=bulk_update Mehrere Items auf einmal aendern
|
||||
*
|
||||
* ROUTING-AENDERUNG: Die Zuordnung erfolgt jetzt ueber eine feste Aktionsliste.
|
||||
* Zuvor wurde per str_contains() im Pfad gesucht, wodurch jede URL, die
|
||||
* zufaellig "/status" oder "/update" enthielt, die Route uebernahm.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/../../../../../src/Core/Auth.php';
|
||||
require_once __DIR__ . '/../../../../../src/Core/Db.php';
|
||||
require_once __DIR__ . '/../../../../../src/Core/TokenManager.php';
|
||||
require_once __DIR__ . '/../../../../../src/Modules/Bugtracker/BugRepo.php';
|
||||
require_once __DIR__ . '/../../../../../src/bootstrap.php';
|
||||
|
||||
use Deploymentcenter\Core\Auth;
|
||||
use Deploymentcenter\Core\ApiAuth;
|
||||
use Deploymentcenter\Core\Db;
|
||||
use Deploymentcenter\Core\TokenManager;
|
||||
use Deploymentcenter\Core\Http;
|
||||
use Deploymentcenter\Modules\Bugtracker\BugRepo;
|
||||
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
Http::beginJson(['GET', 'POST', 'OPTIONS'], true);
|
||||
|
||||
try {
|
||||
$config = require __DIR__ . '/../../../../../config/config.php';
|
||||
$db = Db::connect($config['db']);
|
||||
const READ_ACTIONS = ['list', 'get', 'stats', 'projects'];
|
||||
const WRITE_ACTIONS = ['claim', 'next', 'release', 'comment', 'status', 'update', 'resolve', 'bulk_update'];
|
||||
|
||||
// Authenticate Request (Session OR Token)
|
||||
$isAuthenticated = false;
|
||||
$authorName = 'admin';
|
||||
$db = Db::init();
|
||||
$repo = new BugRepo($db);
|
||||
|
||||
if (Auth::isLoggedIn()) {
|
||||
$isAuthenticated = true;
|
||||
$authorName = $_SESSION['dc_username'] ?? 'admin';
|
||||
} else {
|
||||
$headers = getallheaders();
|
||||
$token = $headers['X-Agent-Token'] ?? $headers['x-agent-token'] ?? null;
|
||||
if (!$token && !empty($headers['Authorization'])) {
|
||||
if (preg_match('/Bearer\s+(.+)/i', $headers['Authorization'], $matches)) {
|
||||
$token = trim($matches[1]);
|
||||
}
|
||||
$action = resolveAction();
|
||||
$method = Http::method();
|
||||
|
||||
if (!in_array($action, READ_ACTIONS, true) && !in_array($action, WRITE_ACTIONS, true)) {
|
||||
Http::fail(404, 'unknown_action', sprintf('Unbekannte Aktion "%s".', $action), null, [
|
||||
'available' => array_merge(READ_ACTIONS, WRITE_ACTIONS),
|
||||
]);
|
||||
}
|
||||
|
||||
$isWrite = in_array($action, WRITE_ACTIONS, true);
|
||||
|
||||
if ($isWrite && $method !== 'POST') {
|
||||
Http::fail(405, 'method_not_allowed', sprintf('Die Aktion "%s" erwartet POST.', $action));
|
||||
}
|
||||
|
||||
$context = ApiAuth::requireScope($db, $isWrite ? 'bugtracker:manage' : 'bugtracker:read');
|
||||
$author = $context['actor'];
|
||||
$boundProject = ApiAuth::projectFilter($context);
|
||||
|
||||
$itemId = resolveItemId();
|
||||
|
||||
switch ($action) {
|
||||
|
||||
// ---------------------------------------------------------------- lesen
|
||||
case 'projects':
|
||||
Http::ok(['projects' => $repo->getProjects()]);
|
||||
// no break - Http::ok beendet die Anfrage
|
||||
|
||||
case 'stats':
|
||||
$slug = Http::str('project_slug') ?? $boundProject;
|
||||
Http::ok(['stats' => $repo->getStats($slug)]);
|
||||
|
||||
case 'get':
|
||||
requireItemId($itemId);
|
||||
$item = $repo->getItemDetails($itemId);
|
||||
if ($item === null) {
|
||||
Http::fail(404, 'not_found', sprintf('Item #%d existiert nicht.', $itemId));
|
||||
}
|
||||
ApiAuth::enforceProject($context, (string)$item['project_slug']);
|
||||
Http::ok(['item' => $item]);
|
||||
|
||||
case 'list':
|
||||
$filters = collectFilters($boundProject);
|
||||
$result = $repo->getItems($filters);
|
||||
Http::ok([
|
||||
'count' => count($result['items']),
|
||||
'total' => $result['total'],
|
||||
'limit' => $result['limit'],
|
||||
'offset' => $result['offset'],
|
||||
'has_more' => $result['has_more'],
|
||||
'filters' => $filters,
|
||||
'items' => $result['items'],
|
||||
]);
|
||||
|
||||
// -------------------------------------------------------------- schreiben
|
||||
case 'claim':
|
||||
requireItemId($itemId);
|
||||
assertProject($repo, $context, $itemId);
|
||||
|
||||
$claimed = $repo->claimItem($itemId, $author, Http::int('lease_minutes', 0) ?: null);
|
||||
if ($claimed === null) {
|
||||
Http::fail(409, 'already_claimed', sprintf(
|
||||
'Item #%d ist bereits vergeben oder nicht mehr offen.',
|
||||
$itemId
|
||||
));
|
||||
}
|
||||
Http::ok(['item' => $claimed, 'message' => sprintf('Item #%d uebernommen.', $itemId)]);
|
||||
|
||||
case 'next':
|
||||
$filters = collectFilters($boundProject);
|
||||
$limit = Http::int('limit', 1);
|
||||
$claimedItems = $repo->claimNext($author, $filters, $limit);
|
||||
Http::ok([
|
||||
'count' => count($claimedItems),
|
||||
'items' => $claimedItems,
|
||||
'message' => $claimedItems === []
|
||||
? 'Aktuell keine offenen Items verfuegbar.'
|
||||
: sprintf('%d Item(s) uebernommen.', count($claimedItems)),
|
||||
]);
|
||||
|
||||
case 'release':
|
||||
requireItemId($itemId);
|
||||
assertProject($repo, $context, $itemId);
|
||||
|
||||
if (!$repo->releaseItem($itemId, $author, Http::str('note'))) {
|
||||
Http::fail(409, 'not_claimed', sprintf(
|
||||
'Item #%d ist nicht von "%s" beansprucht.',
|
||||
$itemId,
|
||||
$author
|
||||
));
|
||||
}
|
||||
Http::ok(['message' => sprintf('Item #%d freigegeben.', $itemId)]);
|
||||
|
||||
case 'comment':
|
||||
requireItemId($itemId);
|
||||
assertProject($repo, $context, $itemId);
|
||||
|
||||
$comment = Http::str('comment');
|
||||
if ($comment === null) {
|
||||
Http::fail(400, 'missing_comment', 'Das Feld "comment" darf nicht leer sein.');
|
||||
}
|
||||
|
||||
if ($token) {
|
||||
$tokenMgr = new TokenManager($db);
|
||||
$tokenInfo = $tokenMgr->validateToken($token, 'bugtracker:manage');
|
||||
if ($tokenInfo) {
|
||||
$isAuthenticated = true;
|
||||
$authorName = 'agent:' . ($tokenInfo['name'] ?? $tokenInfo['token_id']);
|
||||
}
|
||||
$meta = Http::input('meta');
|
||||
$created = $repo->addComment(
|
||||
$itemId,
|
||||
$author,
|
||||
$comment,
|
||||
Http::str('action_taken') ?? 'commented',
|
||||
is_array($meta) ? $meta : null
|
||||
);
|
||||
Http::ok(['comment' => $created], 201);
|
||||
|
||||
case 'status':
|
||||
requireItemId($itemId);
|
||||
assertProject($repo, $context, $itemId);
|
||||
|
||||
$status = Http::str('status');
|
||||
if ($status === null) {
|
||||
Http::fail(400, 'missing_status', 'Das Feld "status" fehlt.', null, [
|
||||
'allowed' => BugRepo::STATUSES,
|
||||
]);
|
||||
}
|
||||
|
||||
if (!$repo->updateStatus($itemId, $status, Http::str('notes'), $author)) {
|
||||
Http::fail(400, 'invalid_status', sprintf(
|
||||
'Status "%s" ist unbekannt oder Item #%d existiert nicht.',
|
||||
$status,
|
||||
$itemId
|
||||
), null, ['allowed' => BugRepo::STATUSES]);
|
||||
}
|
||||
|
||||
Http::ok(['message' => sprintf('Status von #%d auf "%s" gesetzt.', $itemId, $status)]);
|
||||
|
||||
case 'update':
|
||||
requireItemId($itemId);
|
||||
assertProject($repo, $context, $itemId);
|
||||
|
||||
if (!$repo->updateItemDetails($itemId, Http::body(), $author)) {
|
||||
Http::fail(404, 'not_found', sprintf('Item #%d existiert nicht.', $itemId));
|
||||
}
|
||||
Http::ok(['message' => sprintf('Item #%d aktualisiert.', $itemId)]);
|
||||
|
||||
case 'resolve':
|
||||
requireItemId($itemId);
|
||||
assertProject($repo, $context, $itemId);
|
||||
|
||||
$build = Http::str('resolved_in_build');
|
||||
if ($build === null) {
|
||||
Http::fail(400, 'missing_build', 'Das Feld "resolved_in_build" wird benoetigt.');
|
||||
}
|
||||
|
||||
if (!$repo->resolveItem($itemId, $build, Http::str('resolution_notes'), $author)) {
|
||||
Http::fail(404, 'not_found', sprintf('Item #%d existiert nicht.', $itemId));
|
||||
}
|
||||
Http::ok(['message' => sprintf('Item #%d in Build "%s" geloest.', $itemId, $build)]);
|
||||
|
||||
case 'bulk_update':
|
||||
$ids = Http::input('ids');
|
||||
if (!is_array($ids) || $ids === []) {
|
||||
Http::fail(400, 'missing_ids', 'Das Feld "ids" muss eine nicht leere Liste sein.');
|
||||
}
|
||||
if (count($ids) > 200) {
|
||||
Http::fail(400, 'too_many_ids', 'Maximal 200 Items pro Aufruf.');
|
||||
}
|
||||
|
||||
$updates = Http::input('updates');
|
||||
if (!is_array($updates) || $updates === []) {
|
||||
Http::fail(400, 'missing_updates', 'Das Feld "updates" muss die zu setzenden Felder enthalten.');
|
||||
}
|
||||
|
||||
$result = $repo->bulkUpdate($ids, $updates, $author);
|
||||
Http::ok([
|
||||
'updated' => $result['updated'],
|
||||
'failed' => $result['failed'],
|
||||
'message' => sprintf('%d Item(s) aktualisiert.', $result['updated']),
|
||||
]);
|
||||
}
|
||||
|
||||
// ======================================================================
|
||||
// Hilfsfunktionen
|
||||
// ======================================================================
|
||||
|
||||
/**
|
||||
* Ermittelt die Aktion aus ?action= oder aus dem letzten Pfadsegment.
|
||||
* Ohne Angabe: "get" bei vorhandener ID, sonst "list".
|
||||
*/
|
||||
function resolveAction(): string
|
||||
{
|
||||
$explicit = $_GET['action'] ?? null;
|
||||
if (is_string($explicit) && $explicit !== '') {
|
||||
return strtolower(trim($explicit));
|
||||
}
|
||||
|
||||
// Pfadform: /manage/items/42/comment -> "comment"
|
||||
$path = trim(Http::path(), '/');
|
||||
$segments = array_values(array_filter(explode('/', $path), static fn(string $s): bool => $s !== ''));
|
||||
|
||||
$last = end($segments);
|
||||
if (is_string($last)) {
|
||||
$candidate = strtolower($last);
|
||||
if (in_array($candidate, READ_ACTIONS, true) || in_array($candidate, WRITE_ACTIONS, true)) {
|
||||
return $candidate;
|
||||
}
|
||||
}
|
||||
|
||||
if (!$isAuthenticated) {
|
||||
http_response_code(401);
|
||||
echo json_encode(['status' => 'error', 'message' => 'Unauthorized: Valid Session or Bearer Token with scope bugtracker:manage required']);
|
||||
exit;
|
||||
return resolveItemId() > 0 ? 'get' : 'list';
|
||||
}
|
||||
|
||||
/** Item-ID aus Query, Body oder Pfad (/items/42). */
|
||||
function resolveItemId(): int
|
||||
{
|
||||
$fromRequest = $_GET['id'] ?? null;
|
||||
if (is_numeric($fromRequest)) {
|
||||
return (int)$fromRequest;
|
||||
}
|
||||
|
||||
$repo = new BugRepo($db);
|
||||
|
||||
$uri = $_SERVER['REQUEST_URI'];
|
||||
$method = $_SERVER['REQUEST_METHOD'];
|
||||
|
||||
$rawInput = file_get_contents('php://input');
|
||||
$input = json_decode($rawInput, true) ?: $_POST;
|
||||
|
||||
// Parse sub-route if any
|
||||
$path = parse_url($uri, PHP_URL_PATH);
|
||||
$action = $_GET['action'] ?? null;
|
||||
|
||||
// Handle Item Detail/Comment/Resolve via ID in URL or query params
|
||||
$itemId = isset($_GET['id']) ? (int)$_GET['id'] : 0;
|
||||
if (!$itemId && preg_match('/\/manage\/items\/(\d+)/', $path, $m)) {
|
||||
$itemId = (int)$m[1];
|
||||
if (Http::method() === 'POST') {
|
||||
$body = Http::body();
|
||||
if (isset($body['id']) && is_numeric($body['id'])) {
|
||||
return (int)$body['id'];
|
||||
}
|
||||
if (isset($body['item_id']) && is_numeric($body['item_id'])) {
|
||||
return (int)$body['item_id'];
|
||||
}
|
||||
}
|
||||
|
||||
// Sub-actions
|
||||
if ($action === 'projects' || str_contains($path, '/projects')) {
|
||||
echo json_encode(['status' => 'success', 'projects' => $repo->getProjects()], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
|
||||
exit;
|
||||
if (preg_match('#/items/(\d+)#', Http::path(), $m) === 1) {
|
||||
return (int)$m[1];
|
||||
}
|
||||
|
||||
if ($action === 'stats' || str_ends_with($path, '/stats')) {
|
||||
echo json_encode(['status' => 'success', 'stats' => $repo->getStats()], JSON_PRETTY_PRINT);
|
||||
exit;
|
||||
return 0;
|
||||
}
|
||||
|
||||
function requireItemId(int $itemId): void
|
||||
{
|
||||
if ($itemId <= 0) {
|
||||
Http::fail(400, 'missing_id', 'Es wurde keine Item-ID uebergeben (?id=... oder /items/<id>).');
|
||||
}
|
||||
}
|
||||
|
||||
/** Stellt sicher, dass ein projektgebundenes Token das Item anfassen darf. */
|
||||
function assertProject(BugRepo $repo, array $context, int $itemId): void
|
||||
{
|
||||
if (ApiAuth::projectFilter($context) === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($action === 'resolve' || str_contains($path, '/resolve')) {
|
||||
if ($method !== 'POST') {
|
||||
http_response_code(405);
|
||||
echo json_encode(['status' => 'error', 'message' => 'POST required for resolve']);
|
||||
exit;
|
||||
}
|
||||
|
||||
if (!$itemId) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['status' => 'error', 'message' => 'Missing item ID']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$build = !empty($input['resolved_in_build']) ? trim($input['resolved_in_build']) : 'v1.0.0';
|
||||
$notes = !empty($input['resolution_notes']) ? trim($input['resolution_notes']) : null;
|
||||
$author = !empty($input['author']) ? trim($input['author']) : $authorName;
|
||||
|
||||
$ok = $repo->resolveItem($itemId, $build, $notes, $author);
|
||||
if ($ok) {
|
||||
echo json_encode(['status' => 'success', 'message' => "Item #{$itemId} resolved in build {$build}"]);
|
||||
} else {
|
||||
http_response_code(400);
|
||||
echo json_encode(['status' => 'error', 'message' => 'Failed to resolve item']);
|
||||
}
|
||||
exit;
|
||||
$item = $repo->getItemDetails($itemId);
|
||||
if ($item === null) {
|
||||
Http::fail(404, 'not_found', sprintf('Item #%d existiert nicht.', $itemId));
|
||||
}
|
||||
|
||||
if ($action === 'comment' || str_contains($path, '/comments')) {
|
||||
if ($method !== 'POST') {
|
||||
http_response_code(405);
|
||||
echo json_encode(['status' => 'error', 'message' => 'POST required for comment']);
|
||||
exit;
|
||||
}
|
||||
ApiAuth::enforceProject($context, (string)$item['project_slug']);
|
||||
}
|
||||
|
||||
if (!$itemId) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['status' => 'error', 'message' => 'Missing item ID']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$comment = !empty($input['comment']) ? trim($input['comment']) : '';
|
||||
if (empty($comment)) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['status' => 'error', 'message' => 'Comment cannot be empty']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$author = !empty($input['author']) ? trim($input['author']) : $authorName;
|
||||
$actionTaken = !empty($input['action_taken']) ? trim($input['action_taken']) : 'commented';
|
||||
$meta = isset($input['meta']) && is_array($input['meta']) ? $input['meta'] : null;
|
||||
|
||||
$comm = $repo->addComment($itemId, $author, $comment, $actionTaken, $meta);
|
||||
echo json_encode(['status' => 'success', 'comment' => $comm]);
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($action === 'status' || str_contains($path, '/status')) {
|
||||
if ($method !== 'POST') {
|
||||
http_response_code(405);
|
||||
echo json_encode(['status' => 'error', 'message' => 'POST required for status change']);
|
||||
exit;
|
||||
}
|
||||
|
||||
if (!$itemId) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['status' => 'error', 'message' => 'Missing item ID']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$status = !empty($input['status']) ? trim($input['status']) : 'open';
|
||||
$notes = !empty($input['notes']) ? trim($input['notes']) : null;
|
||||
$author = !empty($input['author']) ? trim($input['author']) : $authorName;
|
||||
|
||||
$ok = $repo->updateStatus($itemId, $status, $notes, $author);
|
||||
if ($ok) {
|
||||
echo json_encode(['status' => 'success', 'message' => "Status for #{$itemId} updated to {$status}"]);
|
||||
} else {
|
||||
http_response_code(400);
|
||||
echo json_encode(['status' => 'error', 'message' => 'Invalid status']);
|
||||
}
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($action === 'update' || str_contains($path, '/update')) {
|
||||
if ($method !== 'POST') {
|
||||
http_response_code(405);
|
||||
echo json_encode(['status' => 'error', 'message' => 'POST required for update']);
|
||||
exit;
|
||||
}
|
||||
|
||||
if (!$itemId && !empty($input['id'])) {
|
||||
$itemId = (int)$input['id'];
|
||||
}
|
||||
|
||||
if (!$itemId) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['status' => 'error', 'message' => 'Missing item ID']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$author = !empty($input['author']) ? trim($input['author']) : $authorName;
|
||||
$ok = $repo->updateItemDetails($itemId, $input, $author);
|
||||
|
||||
if ($ok) {
|
||||
echo json_encode(['status' => 'success', 'message' => "Item #{$itemId} updated successfully"]);
|
||||
} else {
|
||||
http_response_code(400);
|
||||
echo json_encode(['status' => 'error', 'message' => 'Failed to update item']);
|
||||
}
|
||||
exit;
|
||||
}
|
||||
|
||||
// Detail View of a single item
|
||||
if ($itemId > 0 && $method === 'GET') {
|
||||
$details = $repo->getItemDetails($itemId);
|
||||
if (!$details) {
|
||||
http_response_code(404);
|
||||
echo json_encode(['status' => 'error', 'message' => 'Item not found']);
|
||||
exit;
|
||||
}
|
||||
echo json_encode(['status' => 'success', 'item' => $details], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Default: List Items
|
||||
/**
|
||||
* Sammelt Filter aus Query und Body.
|
||||
*
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
function collectFilters(?string $boundProject): array
|
||||
{
|
||||
$filters = [
|
||||
'project_slug' => $_GET['project_slug'] ?? $_GET['project'] ?? 'all',
|
||||
'environment' => $_GET['environment'] ?? $_GET['env'] ?? 'all',
|
||||
'type' => $_GET['type'] ?? 'all',
|
||||
'status' => $_GET['status'] ?? 'all',
|
||||
'severity' => $_GET['severity'] ?? 'all',
|
||||
'push_id' => $_GET['push_id'] ?? '',
|
||||
'target_agent' => $_GET['target_agent'] ?? $_GET['agent'] ?? '',
|
||||
'search' => $_GET['search'] ?? $_GET['q'] ?? '',
|
||||
'project_slug' => Http::str('project_slug') ?? Http::str('project') ?? 'all',
|
||||
'environment' => Http::str('environment') ?? Http::str('env') ?? 'all',
|
||||
'type' => Http::str('type') ?? 'all',
|
||||
'status' => Http::str('status') ?? 'all',
|
||||
'severity' => Http::str('severity') ?? 'all',
|
||||
'push_id' => Http::str('push_id') ?? '',
|
||||
'target_agent' => Http::str('target_agent') ?? Http::str('agent') ?? '',
|
||||
'claimed_by' => Http::str('claimed_by') ?? '',
|
||||
'search' => Http::str('search') ?? Http::str('q') ?? '',
|
||||
'updated_since' => Http::str('updated_since') ?? '',
|
||||
'order' => Http::str('order') ?? 'newest',
|
||||
'limit' => Http::int('limit', 100),
|
||||
'offset' => Http::int('offset', 0),
|
||||
];
|
||||
|
||||
$items = $repo->getItems($filters);
|
||||
echo json_encode([
|
||||
'status' => 'success',
|
||||
'count' => count($items),
|
||||
'filters' => $filters,
|
||||
'items' => $items,
|
||||
], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
|
||||
if (Http::input('unclaimed_only') !== null) {
|
||||
$filters['unclaimed_only'] = filter_var(Http::input('unclaimed_only'), FILTER_VALIDATE_BOOLEAN);
|
||||
}
|
||||
|
||||
} catch (Throwable $t) {
|
||||
http_response_code(500);
|
||||
echo json_encode(['status' => 'error', 'message' => 'Manage API Error: ' . $t->getMessage()]);
|
||||
// Ein projektgebundenes Token kann den Projektfilter nicht umgehen.
|
||||
if ($boundProject !== null) {
|
||||
$filters['project_slug'] = $boundProject;
|
||||
}
|
||||
|
||||
return $filters;
|
||||
}
|
||||
|
||||
@@ -1,44 +1,57 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* GET /api/bugtracker/v1/projects
|
||||
*
|
||||
* Projekt-Discovery fuer Agenten: welche Projekte gibt es, wie heissen ihre
|
||||
* Slugs, wo liegt das Repository und wie viele Items sind offen.
|
||||
*
|
||||
* SICHERHEITSAENDERUNG: verlangt jetzt ein Token mit "bugtracker:read"
|
||||
* (oder hoeher). Zuvor war die Projektliste oeffentlich abrufbar.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/../../../../src/Core/Db.php';
|
||||
require_once __DIR__ . '/../../../../src/Modules/Bugtracker/BugRepo.php';
|
||||
require_once __DIR__ . '/../../../../src/bootstrap.php';
|
||||
|
||||
use Deploymentcenter\Core\ApiAuth;
|
||||
use Deploymentcenter\Core\Db;
|
||||
use Deploymentcenter\Core\Http;
|
||||
use Deploymentcenter\Modules\Bugtracker\BugRepo;
|
||||
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
header('Access-Control-Allow-Origin: *');
|
||||
header('Access-Control-Allow-Headers: Content-Type, Authorization, X-Agent-Token');
|
||||
header('Access-Control-Allow-Methods: GET, OPTIONS');
|
||||
Http::beginJson(['GET', 'OPTIONS'], true);
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
|
||||
http_response_code(200);
|
||||
exit;
|
||||
if (Http::method() !== 'GET') {
|
||||
Http::fail(405, 'method_not_allowed', 'Dieser Endpunkt erwartet GET.');
|
||||
}
|
||||
|
||||
try {
|
||||
$config = require __DIR__ . '/../../../../config/config.php';
|
||||
$db = Db::connect($config['db']);
|
||||
$db = Db::init();
|
||||
$context = ApiAuth::requireScope($db, 'bugtracker:read');
|
||||
|
||||
$repo = new BugRepo($db);
|
||||
$projects = $repo->getProjects();
|
||||
$repo = new BugRepo($db);
|
||||
$projects = $repo->getProjects();
|
||||
|
||||
echo json_encode([
|
||||
'status' => 'success',
|
||||
'count' => count($projects),
|
||||
'projects' => array_map(function($p) {
|
||||
return [
|
||||
'id' => (int)$p['id'],
|
||||
'slug' => $p['slug'],
|
||||
'name' => $p['name'],
|
||||
'notes' => $p['notes'] ?? null,
|
||||
];
|
||||
}, $projects),
|
||||
], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
|
||||
|
||||
} catch (Throwable $t) {
|
||||
http_response_code(500);
|
||||
echo json_encode(['status' => 'error', 'message' => 'Failed to fetch projects: ' . $t->getMessage()]);
|
||||
// Ein projektgebundenes Token sieht nur sein eigenes Projekt.
|
||||
$bound = ApiAuth::projectFilter($context);
|
||||
if ($bound !== null) {
|
||||
$projects = array_values(array_filter(
|
||||
$projects,
|
||||
static fn(array $p): bool => (string)$p['slug'] === $bound
|
||||
));
|
||||
}
|
||||
|
||||
Http::ok([
|
||||
'count' => count($projects),
|
||||
'projects' => array_map(static function (array $p): array {
|
||||
return [
|
||||
'id' => (int)$p['id'],
|
||||
'slug' => (string)$p['slug'],
|
||||
'name' => (string)$p['name'],
|
||||
'notes' => $p['notes'] ?? null,
|
||||
'repo_url' => $p['repo_url'] ?? null,
|
||||
'default_agent' => $p['default_agent'] ?? null,
|
||||
'open_items' => (int)($p['open_items'] ?? 0),
|
||||
'critical_items' => (int)($p['critical_items'] ?? 0),
|
||||
];
|
||||
}, $projects),
|
||||
]);
|
||||
|
||||
@@ -1,83 +1,95 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* POST /api/bugtracker/v1/report
|
||||
*
|
||||
* Nimmt Bugs, Feature Requests und Ideen von Agenten und Client-Anwendungen
|
||||
* entgegen.
|
||||
*
|
||||
* SICHERHEITSAENDERUNG: Dieser Endpunkt verlangt jetzt zwingend ein Token mit
|
||||
* dem Recht "bugtracker:report". Zuvor wurde ein Token nur geprueft, wenn eines
|
||||
* mitgeschickt wurde - damit konnte jeder im Internet Eintraege anlegen
|
||||
* (und ueber die Detailansicht Skripte in die Admin-Session einschleusen).
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/../../../../src/Core/Db.php';
|
||||
require_once __DIR__ . '/../../../../src/Core/TokenManager.php';
|
||||
require_once __DIR__ . '/../../../../src/Modules/Bugtracker/BugRepo.php';
|
||||
require_once __DIR__ . '/../../../../src/bootstrap.php';
|
||||
|
||||
use Deploymentcenter\Core\ApiAuth;
|
||||
use Deploymentcenter\Core\Config;
|
||||
use Deploymentcenter\Core\Db;
|
||||
use Deploymentcenter\Core\TokenManager;
|
||||
use Deploymentcenter\Core\Http;
|
||||
use Deploymentcenter\Modules\Bugtracker\BugRepo;
|
||||
use Deploymentcenter\Modules\License\RateLimiter;
|
||||
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
Http::beginJson(['POST', 'OPTIONS'], true);
|
||||
|
||||
// Allow CORS for public ingest
|
||||
header('Access-Control-Allow-Origin: *');
|
||||
header('Access-Control-Allow-Headers: Content-Type, Authorization, X-Agent-Token');
|
||||
header('Access-Control-Allow-Methods: POST, OPTIONS');
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
|
||||
http_response_code(200);
|
||||
exit;
|
||||
if (Http::method() !== 'POST') {
|
||||
Http::fail(405, 'method_not_allowed', 'Dieser Endpunkt erwartet POST.');
|
||||
}
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
http_response_code(405);
|
||||
echo json_encode(['status' => 'error', 'message' => 'Method Not Allowed']);
|
||||
exit;
|
||||
$db = Db::init();
|
||||
|
||||
// Drosselung, damit ein Agent in einer Fehlerschleife den Tracker nicht flutet.
|
||||
$limiter = new RateLimiter($db, (int)Config::get('bugtracker.report_rate', 60), 60, 'bt_report');
|
||||
if (!$limiter->check(Http::clientIp())) {
|
||||
Http::fail(429, 'rate_limited', 'Zu viele Reports. Bitte Sendefrequenz reduzieren.');
|
||||
}
|
||||
|
||||
$rawInput = file_get_contents('php://input');
|
||||
$data = json_decode($rawInput, true) ?: $_POST;
|
||||
|
||||
if (empty($data)) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['status' => 'error', 'message' => 'Empty request body or invalid JSON']);
|
||||
exit;
|
||||
$data = Http::body();
|
||||
if ($data === []) {
|
||||
Http::fail(400, 'empty_body', 'Der Request-Body ist leer.');
|
||||
}
|
||||
|
||||
try {
|
||||
$config = require __DIR__ . '/../../../../config/config.php';
|
||||
$db = Db::connect($config['db']);
|
||||
$environment = is_string($data['environment'] ?? null) ? $data['environment'] : null;
|
||||
|
||||
// Optional Token Verification (if provided)
|
||||
$headers = getallheaders();
|
||||
$token = $headers['X-Agent-Token'] ?? $headers['x-agent-token'] ?? null;
|
||||
if (!$token && !empty($headers['Authorization'])) {
|
||||
if (preg_match('/Bearer\s+(.+)/i', $headers['Authorization'], $matches)) {
|
||||
$token = trim($matches[1]);
|
||||
}
|
||||
// Session ist hier bewusst nicht erlaubt: dieser Endpunkt ist die
|
||||
// Maschinenschnittstelle. Das WebUI legt Items ueber index.php an.
|
||||
$context = ApiAuth::requireScope($db, 'bugtracker:report', $environment, false);
|
||||
|
||||
$projectSlug = is_string($data['project_slug'] ?? null) ? trim($data['project_slug']) : null;
|
||||
ApiAuth::enforceProject($context, $projectSlug);
|
||||
|
||||
// Ein projektgebundenes Token schreibt immer in sein eigenes Projekt.
|
||||
$boundProject = ApiAuth::projectFilter($context);
|
||||
if ($boundProject !== null) {
|
||||
$data['project_slug'] = $boundProject;
|
||||
}
|
||||
|
||||
// Der Absender wird aus dem Token abgeleitet und kann nicht frei gewaehlt
|
||||
// werden - sonst koennte sich ein Agent als ein anderer ausgeben.
|
||||
$data['created_by'] = $context['actor'];
|
||||
|
||||
// Idempotenz-Schluessel darf auch als Header kommen.
|
||||
if (empty($data['client_ref'])) {
|
||||
$headerRef = Http::header('idempotency-key');
|
||||
if ($headerRef !== null) {
|
||||
$data['client_ref'] = $headerRef;
|
||||
}
|
||||
|
||||
if ($token) {
|
||||
$tokenMgr = new TokenManager($db);
|
||||
$valid = $tokenMgr->validateToken($token, 'bugtracker:report', $data['environment'] ?? null);
|
||||
if (!$valid) {
|
||||
http_response_code(401);
|
||||
echo json_encode(['status' => 'error', 'message' => 'Invalid, revoked or unauthorized Token for bugtracker:report']);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
$repo = new BugRepo($db);
|
||||
$result = $repo->reportItem($data);
|
||||
|
||||
echo json_encode([
|
||||
'status' => 'success',
|
||||
'item_id' => $result['id'],
|
||||
'is_new' => $result['is_new'],
|
||||
'occurrence_count' => $result['occurrence_count'],
|
||||
'error_hash' => $result['error_hash'],
|
||||
'type' => $result['type'],
|
||||
'environment' => $result['environment'],
|
||||
'push_id' => $result['push_id'] ?? null,
|
||||
'message' => $result['is_new']
|
||||
? ($result['type'] === 'bug' ? 'New bug reported successfully.' : 'New feature request / idea submitted.')
|
||||
: 'Recurring bug count updated.',
|
||||
], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
|
||||
|
||||
} catch (Throwable $t) {
|
||||
http_response_code(500);
|
||||
echo json_encode(['status' => 'error', 'message' => 'Failed to log report: ' . $t->getMessage()]);
|
||||
}
|
||||
|
||||
$repo = new BugRepo($db);
|
||||
$result = $repo->reportItem($data);
|
||||
|
||||
$message = $result['idempotent_hit']
|
||||
? 'Bereits erfasst (identische client_ref) - kein Duplikat angelegt.'
|
||||
: ($result['is_new']
|
||||
? ($result['type'] === 'bug' ? 'Bug erfasst.' : 'Feature Request erfasst.')
|
||||
: 'Wiederkehrendes Vorkommnis - Zaehler erhoeht.');
|
||||
|
||||
Http::ok([
|
||||
'item_id' => $result['id'],
|
||||
'is_new' => $result['is_new'],
|
||||
'idempotent_hit' => $result['idempotent_hit'],
|
||||
'occurrence_count' => $result['occurrence_count'],
|
||||
'dedup_key' => $result['dedup_key'],
|
||||
'error_hash' => $result['error_hash'],
|
||||
'type' => $result['type'],
|
||||
'item_status' => $result['status'],
|
||||
'environment' => $result['environment'],
|
||||
'push_id' => $result['push_id'] ?? null,
|
||||
'regression_of' => $result['regression_of'] ?? null,
|
||||
'url' => Http::baseUrl() . '/index.php#tab-bugtracker',
|
||||
'message' => $message,
|
||||
], $result['is_new'] ? 201 : 200);
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* GET /api/health
|
||||
*
|
||||
* Verfuegbarkeitspruefung fuer Monitoring und Agenten.
|
||||
*
|
||||
* Ohne Authentifizierung wird nur der Gesamtzustand gemeldet. Mit gueltigem
|
||||
* Token, Shared Key oder angemeldeter Sitzung kommen Schema-Version,
|
||||
* ausstehende Migrationen und Kennzahlen dazu.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/../../src/bootstrap.php';
|
||||
|
||||
use Deploymentcenter\Core\ApiAuth;
|
||||
use Deploymentcenter\Core\Config;
|
||||
use Deploymentcenter\Core\Db;
|
||||
use Deploymentcenter\Core\Http;
|
||||
use Deploymentcenter\Core\Logger;
|
||||
use Deploymentcenter\Core\Migrator;
|
||||
use Deploymentcenter\Modules\Bugtracker\BugRepo;
|
||||
|
||||
Http::beginJson(['GET', 'OPTIONS'], true);
|
||||
|
||||
$checks = [];
|
||||
$healthy = true;
|
||||
|
||||
// --- Datenbank ---
|
||||
$db = null;
|
||||
try {
|
||||
$started = microtime(true);
|
||||
$db = Db::init();
|
||||
$db->query('SELECT 1')->fetchColumn();
|
||||
$checks['database'] = [
|
||||
'ok' => true,
|
||||
'latency_ms' => (int)round((microtime(true) - $started) * 1000),
|
||||
];
|
||||
} catch (Throwable $e) {
|
||||
Logger::error('Health-Check: Datenbank nicht erreichbar', ['error' => $e->getMessage()]);
|
||||
$checks['database'] = ['ok' => false, 'error' => 'nicht erreichbar'];
|
||||
$healthy = false;
|
||||
}
|
||||
|
||||
// --- Schreibbarkeit des Log-Verzeichnisses ---
|
||||
$logDir = DC_VAR . '/log';
|
||||
$checks['log_writable'] = ['ok' => is_dir($logDir) ? is_writable($logDir) : is_writable(DC_ROOT)];
|
||||
|
||||
$response = [
|
||||
'healthy' => $healthy,
|
||||
'app' => Config::get('app.name', 'Deploymentcenter'),
|
||||
'version' => Config::get('app.version', 'unknown'),
|
||||
'time_utc' => gmdate('c'),
|
||||
'checks' => $checks,
|
||||
];
|
||||
|
||||
// --- Detailinformationen nur fuer Authentifizierte ---
|
||||
if ($db !== null && ApiAuth::resolve($db, 'bugtracker:read') !== null) {
|
||||
try {
|
||||
$status = Migrator::status($db);
|
||||
$response['schema'] = [
|
||||
'applied_count' => count($status['applied']),
|
||||
'pending' => $status['pending'],
|
||||
];
|
||||
|
||||
if ($status['pending'] !== []) {
|
||||
$response['healthy'] = false;
|
||||
$response['checks']['migrations'] = [
|
||||
'ok' => false,
|
||||
'message' => 'Ausstehende Migrationen: ' . implode(', ', $status['pending']),
|
||||
];
|
||||
} else {
|
||||
$response['checks']['migrations'] = ['ok' => true];
|
||||
}
|
||||
|
||||
$repo = new BugRepo($db);
|
||||
$response['bugtracker'] = $repo->getStats();
|
||||
|
||||
$monitors = $db->query('
|
||||
SELECT state, COUNT(*) AS total
|
||||
FROM watchdog_monitors
|
||||
GROUP BY state
|
||||
')->fetchAll() ?: [];
|
||||
|
||||
$byState = [];
|
||||
foreach ($monitors as $row) {
|
||||
$byState[(string)$row['state']] = (int)$row['total'];
|
||||
}
|
||||
$response['watchdog'] = ['monitors_by_state' => $byState];
|
||||
|
||||
$lastRun = $db->query("
|
||||
SELECT last_run_utc FROM watchdog_cron_jobs WHERE name = 'evaluator'
|
||||
")->fetchColumn();
|
||||
|
||||
$response['watchdog']['evaluator_last_run_utc'] = $lastRun !== false ? $lastRun : null;
|
||||
|
||||
// Laeuft der Evaluator nicht, sind alle Monitor-Zustaende wertlos.
|
||||
if ($lastRun === false || $lastRun === null) {
|
||||
$response['checks']['evaluator'] = [
|
||||
'ok' => false,
|
||||
'message' => 'Der Evaluator lief noch nie. Cron-Job auf /api/watchdog/v1/evaluate einrichten.',
|
||||
];
|
||||
} else {
|
||||
$age = time() - (int)strtotime((string)$lastRun . ' UTC');
|
||||
$response['checks']['evaluator'] = [
|
||||
'ok' => $age < 900,
|
||||
'age_seconds' => $age,
|
||||
'message' => $age < 900 ? null : 'Letzter Lauf liegt zu lange zurueck.',
|
||||
];
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
Logger::warning('Health-Check: Detailabfrage fehlgeschlagen', ['error' => $e->getMessage()]);
|
||||
$response['detail_error'] = 'Detailinformationen konnten nicht ermittelt werden.';
|
||||
}
|
||||
}
|
||||
|
||||
Http::ok($response, $response['healthy'] ? 200 : 503);
|
||||
@@ -1,79 +1,78 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Lizenz-API
|
||||
*
|
||||
* POST /api/license/v1/validate Lizenz und Hardware pruefen (oeffentlich)
|
||||
* POST /api/license/v1/deactivate Aktivierung freigeben (authentifiziert)
|
||||
* GET /api/license/v1/status Verfuegbarkeitspruefung
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
require_once __DIR__ . '/../../../../src/Core/Db.php';
|
||||
require_once __DIR__ . '/../../../../src/Core/Auth.php';
|
||||
require_once __DIR__ . '/../../../../src/Modules/License/Audit.php';
|
||||
require_once __DIR__ . '/../../../../src/Modules/License/KeyGen.php';
|
||||
require_once __DIR__ . '/../../../../src/Modules/License/RateLimiter.php';
|
||||
require_once __DIR__ . '/../../../../src/Modules/License/LicenseService.php';
|
||||
require_once __DIR__ . '/../../../../src/bootstrap.php';
|
||||
|
||||
use Deploymentcenter\Core\ApiAuth;
|
||||
use Deploymentcenter\Core\Db;
|
||||
use Deploymentcenter\Core\Auth;
|
||||
use Deploymentcenter\Core\Http;
|
||||
use Deploymentcenter\Modules\License\LicenseService;
|
||||
use Deploymentcenter\Modules\License\RateLimiter;
|
||||
|
||||
function sendResponse(array $data, int $statusCode = 200): void {
|
||||
http_response_code($statusCode);
|
||||
echo json_encode($data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
|
||||
exit;
|
||||
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.');
|
||||
}
|
||||
|
||||
try {
|
||||
$config = require __DIR__ . '/../../../../config/config.php';
|
||||
$pdo = Db::init($config);
|
||||
$service = new LicenseService($db);
|
||||
$action = resolveLicenseAction();
|
||||
|
||||
$limiter = new RateLimiter($pdo, 120, 60);
|
||||
$ip = $_SERVER['REMOTE_ADDR'] ?? '127.0.0.1';
|
||||
if (!$limiter->check($ip)) {
|
||||
sendResponse(['error' => 'Too Many Requests', 'message' => 'Rate limit exceeded.'], 429);
|
||||
}
|
||||
switch ($action) {
|
||||
|
||||
$uri = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH);
|
||||
$method = strtoupper($_SERVER['REQUEST_METHOD'] ?? 'GET');
|
||||
|
||||
$rawInput = file_get_contents('php://input');
|
||||
$inputData = !empty($rawInput) ? (json_decode($rawInput, true) ?? []) : $_POST;
|
||||
|
||||
$licenseService = new LicenseService($pdo);
|
||||
|
||||
// Validate Endpoint (Public API for clients)
|
||||
if (str_ends_with($uri, '/validate') && $method === 'POST') {
|
||||
$res = $licenseService->validate($inputData, $ip);
|
||||
sendResponse($res);
|
||||
}
|
||||
|
||||
// Deactivate Endpoint (AUTHENTICATED ONLY - Security Protection)
|
||||
if (str_ends_with($uri, '/deactivate') && $method === 'POST') {
|
||||
$authHeader = $_SERVER['HTTP_X_WATCHDOG_KEY'] ?? $_SERVER['HTTP_AUTHORIZATION'] ?? $_SERVER['HTTP_X_LICENSE_KEY'] ?? null;
|
||||
if ($authHeader && str_starts_with($authHeader, 'Bearer ')) {
|
||||
$authHeader = substr($authHeader, 7);
|
||||
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::ok(['result' => $service->validate(Http::body(), $ip)]);
|
||||
|
||||
$sharedKey = $config['security']['shared_key'] ?? '';
|
||||
$isAuthenticated = ($authHeader && hash_equals($sharedKey, $authHeader)) || Auth::isLoggedIn();
|
||||
|
||||
if (!$isAuthenticated) {
|
||||
sendResponse([
|
||||
'error' => 'Unauthorized',
|
||||
'message' => 'Authentication required for license deactivation. Pass Bearer token or master key.'
|
||||
], 401);
|
||||
case 'deactivate':
|
||||
if (Http::method() !== 'POST') {
|
||||
Http::fail(405, 'method_not_allowed', 'Diese Aktion erwartet POST.');
|
||||
}
|
||||
ApiAuth::requireScope($db, 'license:deactivate');
|
||||
Http::ok(['result' => $service->deactivate(Http::body(), $ip)]);
|
||||
|
||||
$res = $licenseService->deactivate($inputData, $ip);
|
||||
sendResponse($res);
|
||||
}
|
||||
case 'status':
|
||||
Http::ok(['module' => 'license', 'version' => '2.0']);
|
||||
|
||||
// Status Endpoint
|
||||
if (str_ends_with($uri, '/status') && $method === 'GET') {
|
||||
sendResponse(['status' => 'ok', 'module' => 'Lizenzen', 'version' => '1.0']);
|
||||
default:
|
||||
Http::fail(404, 'unknown_action', 'Endpunkt nicht gefunden.', null, [
|
||||
'available' => ['validate', 'deactivate', '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 !== ''
|
||||
));
|
||||
|
||||
sendResponse(['error' => 'Not Found', 'message' => 'Endpoint not found'], 404);
|
||||
$last = strtolower((string)end($segments));
|
||||
|
||||
} catch (Throwable $t) {
|
||||
sendResponse(['error' => 'Server Error', 'message' => $t->getMessage()], 500);
|
||||
return match ($last) {
|
||||
'validate', 'deactivate', 'status' => $last,
|
||||
default => 'status',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,308 @@
|
||||
<?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\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 und Token-Provisionierung.\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'],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
'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/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',
|
||||
'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']],
|
||||
],
|
||||
'responses' => ['200' => ['description' => 'Vergleich nach semantischer Versionsordnung']],
|
||||
],
|
||||
],
|
||||
|
||||
'/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.',
|
||||
'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'],
|
||||
'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'],
|
||||
],
|
||||
]]],
|
||||
],
|
||||
'responses' => ['201' => ['description' => 'Angelegt'], '200' => ['description' => 'Aktualisiert'], '401' => $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'],
|
||||
'instance' => ['type' => 'string', 'default' => 'default'],
|
||||
'status' => ['type' => 'string', 'enum' => ['ok', 'warning', 'error']],
|
||||
'interval' => ['type' => 'integer', 'description' => 'Erwarteter Abstand in Sekunden; danach gilt der Monitor als auffaellig'],
|
||||
'message' => ['type' => 'string'],
|
||||
'metrics' => ['type' => 'object'],
|
||||
'os' => ['type' => 'string'],
|
||||
],
|
||||
]]],
|
||||
],
|
||||
'responses' => ['200' => ['description' => 'Empfangen'], '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],
|
||||
],
|
||||
],
|
||||
|
||||
'/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);
|
||||
@@ -1,77 +1,91 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* POST /api/tokens/v1/provision
|
||||
*
|
||||
* Selbst-Provisionierung: Eine Client-Anwendung oder ein Agent tauscht ein
|
||||
* langlebiges Master-Token gegen ein eigenes Sub-Token ein. Rechte und
|
||||
* Umgebung koennen dabei nur eingeschraenkt, niemals erweitert werden.
|
||||
*
|
||||
* Das Master-Token wird per X-Master-Token oder Authorization: Bearer
|
||||
* uebergeben. Die Uebergabe im Request-Body wird nicht mehr akzeptiert -
|
||||
* Bodies landen haeufiger in Logs und Fehlermeldungen als Header.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/../../../../src/Core/Db.php';
|
||||
require_once __DIR__ . '/../../../../src/Core/TokenManager.php';
|
||||
require_once __DIR__ . '/../../../../src/bootstrap.php';
|
||||
|
||||
use Deploymentcenter\Core\Db;
|
||||
use Deploymentcenter\Core\Http;
|
||||
use Deploymentcenter\Core\Logger;
|
||||
use Deploymentcenter\Core\TokenManager;
|
||||
use Deploymentcenter\Modules\License\RateLimiter;
|
||||
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
Http::beginJson(['POST', 'OPTIONS'], true);
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
http_response_code(405);
|
||||
echo json_encode(['status' => 'error', 'message' => 'Method Not Allowed']);
|
||||
exit;
|
||||
if (Http::method() !== 'POST') {
|
||||
Http::fail(405, 'method_not_allowed', 'Dieser Endpunkt erwartet POST.');
|
||||
}
|
||||
|
||||
// Extract Master Token from Headers
|
||||
$headers = getallheaders();
|
||||
$masterToken = $headers['X-Master-Token'] ?? $headers['x-master-token'] ?? null;
|
||||
$db = Db::init();
|
||||
|
||||
if (!$masterToken && !empty($headers['Authorization'])) {
|
||||
if (preg_match('/Bearer\s+(.+)/i', $headers['Authorization'], $matches)) {
|
||||
$masterToken = trim($matches[1]);
|
||||
}
|
||||
// Provisionierung ist selten - eine enge Drosselung verhindert das
|
||||
// Durchprobieren von Master-Tokens.
|
||||
$limiter = new RateLimiter($db, 20, 60, 'token_provision');
|
||||
if (!$limiter->check(Http::clientIp())) {
|
||||
Http::fail(429, 'rate_limited', 'Zu viele Provisionierungsversuche.');
|
||||
}
|
||||
|
||||
$rawInput = file_get_contents('php://input');
|
||||
$data = json_decode($rawInput, true) ?: $_POST;
|
||||
|
||||
if (!$masterToken && !empty($data['master_token'])) {
|
||||
$masterToken = trim($data['master_token']);
|
||||
$masterToken = Http::bearerToken();
|
||||
if ($masterToken === null) {
|
||||
Http::fail(
|
||||
401,
|
||||
'missing_master_token',
|
||||
'Master-Token fehlt. Erwartet im Header X-Master-Token oder als Authorization: Bearer <token>.'
|
||||
);
|
||||
}
|
||||
|
||||
if (!$masterToken) {
|
||||
http_response_code(401);
|
||||
echo json_encode(['status' => 'error', 'message' => 'Missing Master Token in X-Master-Token header or Authorization Bearer header']);
|
||||
exit;
|
||||
$data = Http::body();
|
||||
|
||||
$name = Http::str('client_name') ?? Http::str('name') ?? 'Auto-provisioniertes Sub-Token';
|
||||
$instanceIdentity = Http::str('instance_id') ?? Http::str('hostname');
|
||||
$environment = Http::str('environment') ?? 'all';
|
||||
|
||||
$requestedScopes = $data['scopes'] ?? [];
|
||||
if (is_string($requestedScopes)) {
|
||||
$requestedScopes = array_map('trim', explode(',', $requestedScopes));
|
||||
}
|
||||
if (!is_array($requestedScopes)) {
|
||||
$requestedScopes = [];
|
||||
}
|
||||
|
||||
$manager = new TokenManager($db);
|
||||
|
||||
try {
|
||||
$config = require __DIR__ . '/../../../../config/config.php';
|
||||
$db = Db::connect($config['db']);
|
||||
$tokenMgr = new TokenManager($db);
|
||||
|
||||
$name = !empty($data['client_name']) ? trim($data['client_name']) : (!empty($data['name']) ? trim($data['name']) : 'Auto-Provisioned Agent Sub-Token');
|
||||
$instanceIdentity = !empty($data['instance_id']) ? trim($data['instance_id']) : (!empty($data['hostname']) ? trim($data['hostname']) : null);
|
||||
$requestedScopes = isset($data['scopes']) && is_array($data['scopes']) ? $data['scopes'] : [];
|
||||
$environment = !empty($data['environment']) ? trim($data['environment']) : 'all';
|
||||
|
||||
$subTokenData = $tokenMgr->provisionSubToken(
|
||||
$subToken = $manager->provisionSubToken(
|
||||
$masterToken,
|
||||
$name,
|
||||
$instanceIdentity,
|
||||
$requestedScopes,
|
||||
$environment
|
||||
);
|
||||
|
||||
echo json_encode([
|
||||
'status' => 'success',
|
||||
'sub_token' => $subTokenData['raw_token'],
|
||||
'token_id' => $subTokenData['token_id'],
|
||||
'name' => $subTokenData['name'],
|
||||
'scopes' => $subTokenData['scopes'],
|
||||
'environment' => $subTokenData['environment'],
|
||||
'type' => 'sub',
|
||||
'created_at' => date('Y-m-d H:i:s'),
|
||||
], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
|
||||
|
||||
} catch (InvalidArgumentException $e) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['status' => 'error', 'message' => $e->getMessage()]);
|
||||
} catch (Throwable $t) {
|
||||
http_response_code(500);
|
||||
echo json_encode(['status' => 'error', 'message' => 'Internal server error: ' . $t->getMessage()]);
|
||||
Logger::warning('Provisionierung abgelehnt', [
|
||||
'ip' => Http::clientIp(),
|
||||
'reason' => $e->getMessage(),
|
||||
]);
|
||||
Http::fail(403, 'provision_denied', $e->getMessage());
|
||||
}
|
||||
|
||||
Http::ok([
|
||||
'sub_token' => $subToken['raw_token'],
|
||||
'token_id' => $subToken['token_id'],
|
||||
'name' => $subToken['name'],
|
||||
'scopes' => $subToken['scopes'],
|
||||
'environment' => $subToken['environment'],
|
||||
'expires_at' => $subToken['expires_at'],
|
||||
'type' => 'sub',
|
||||
'created_at' => gmdate('Y-m-d H:i:s'),
|
||||
'message' => 'Sub-Token erstellt. Der Wert wird nur einmal ausgeliefert - bitte sicher speichern.',
|
||||
], 201);
|
||||
|
||||
@@ -1,97 +1,194 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 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/releases?product=myapp
|
||||
* POST /api/updateservice/v1/publish (Scope updateservice:publish)
|
||||
*
|
||||
* SICHERHEITSAENDERUNG: Das Veroeffentlichen eines Releases war vollstaendig
|
||||
* ungeschuetzt. Jeder konnte download_url und sha256_hash eines bestehenden
|
||||
* Releases ueberschreiben und damit allen Clients ein beliebiges Paket
|
||||
* unterschieben. Publish verlangt jetzt ein Token mit "updateservice:publish".
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
require_once __DIR__ . '/../../../../src/Core/Db.php';
|
||||
require_once __DIR__ . '/../../../../src/Modules/UpdateService/UpdateManager.php';
|
||||
require_once __DIR__ . '/../../../../src/bootstrap.php';
|
||||
|
||||
use Deploymentcenter\Core\ApiAuth;
|
||||
use Deploymentcenter\Core\Db;
|
||||
use Deploymentcenter\Core\Http;
|
||||
use Deploymentcenter\Modules\License\RateLimiter;
|
||||
use Deploymentcenter\Modules\UpdateService\UpdateManager;
|
||||
use Deploymentcenter\Modules\UpdateService\Version;
|
||||
|
||||
function sendResponse(array $data, int $statusCode = 200): void {
|
||||
http_response_code($statusCode);
|
||||
echo json_encode($data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
|
||||
exit;
|
||||
Http::beginJson(['GET', 'POST', 'OPTIONS'], true);
|
||||
|
||||
$db = Db::init();
|
||||
|
||||
$limiter = new RateLimiter($db, 240, 60, 'updateservice');
|
||||
if (!$limiter->check(Http::clientIp())) {
|
||||
Http::fail(429, 'rate_limited', 'Zu viele Anfragen.');
|
||||
}
|
||||
|
||||
try {
|
||||
$config = require __DIR__ . '/../../../../config/config.php';
|
||||
$pdo = Db::init($config);
|
||||
$manager = new UpdateManager($db);
|
||||
$action = resolveUpdateAction();
|
||||
|
||||
$uri = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH);
|
||||
$method = strtoupper($_SERVER['REQUEST_METHOD'] ?? 'GET');
|
||||
switch ($action) {
|
||||
|
||||
$updateMgr = new UpdateManager($pdo);
|
||||
|
||||
// Read JSON body for POST requests if available
|
||||
$inputData = [];
|
||||
if ($method === 'POST') {
|
||||
$raw = file_get_contents('php://input');
|
||||
if (!empty($raw)) {
|
||||
$inputData = json_decode($raw, true) ?? [];
|
||||
}
|
||||
}
|
||||
|
||||
$action = $_REQUEST['action'] ?? $inputData['action'] ?? '';
|
||||
|
||||
// Action: Publish Release (from Packager CLI)
|
||||
if ($action === 'publish_release' && $method === 'POST') {
|
||||
$product = $inputData['product_slug'] ?? $_POST['product_slug'] ?? '';
|
||||
$version = $inputData['version'] ?? $_POST['version'] ?? '';
|
||||
$channel = $inputData['channel'] ?? $_POST['channel'] ?? 'prod';
|
||||
$url = $inputData['download_url'] ?? $_POST['download_url'] ?? '';
|
||||
$hash = $inputData['sha256_hash'] ?? $_POST['sha256_hash'] ?? null;
|
||||
$gitCommit = $inputData['git_commit'] ?? $_POST['git_commit'] ?? null;
|
||||
$sizeBytes = (int)($inputData['size_bytes'] ?? $_POST['size_bytes'] ?? 0);
|
||||
$notes = $inputData['release_notes'] ?? $_POST['release_notes'] ?? null;
|
||||
$isCritical= !empty($inputData['is_critical']) || !empty($_POST['is_critical']);
|
||||
|
||||
if (empty($product) || empty($version) || empty($url)) {
|
||||
sendResponse(['error' => 'Bad Request', 'message' => 'Missing required fields: product_slug, version, download_url'], 400);
|
||||
case 'check':
|
||||
$product = Http::str('product') ?? Http::str('product_slug');
|
||||
if ($product === null) {
|
||||
Http::fail(400, 'missing_product', 'Der Parameter "product" wird benoetigt.');
|
||||
}
|
||||
|
||||
$ok = $updateMgr->addRelease($product, $version, $channel, $notes, $url, $hash, $gitCommit, $sizeBytes, null, $isCritical);
|
||||
if ($ok) {
|
||||
sendResponse(['status' => 'success', 'message' => "Release v{$version} published for {$product} ({$channel})."]);
|
||||
} else {
|
||||
sendResponse(['error' => 'Database Error', 'message' => 'Failed to store release.'], 500);
|
||||
}
|
||||
}
|
||||
$current = Http::str('version') ?? Http::str('current_version') ?? '0.0.0';
|
||||
$channel = Http::str('channel') ?? 'prod';
|
||||
|
||||
if (str_ends_with($uri, '/check') && ($method === 'GET' || $method === 'POST')) {
|
||||
$product = $_REQUEST['product'] ?? $_REQUEST['product_slug'] ?? '';
|
||||
$version = $_REQUEST['version'] ?? $_REQUEST['current_version'] ?? '0.0.0';
|
||||
$channel = $_REQUEST['channel'] ?? 'prod';
|
||||
$latest = $manager->checkUpdate($product, $current, $channel);
|
||||
|
||||
if (empty($product)) {
|
||||
sendResponse(['error' => 'Bad Request', 'message' => 'Parameter "product" is required.'], 400);
|
||||
}
|
||||
|
||||
$latest = $updateMgr->checkUpdate($product, $version, $channel);
|
||||
if ($latest) {
|
||||
sendResponse([
|
||||
'update_available' => true,
|
||||
'latest_release' => $latest
|
||||
]);
|
||||
} else {
|
||||
sendResponse([
|
||||
if ($latest === null) {
|
||||
$installed = $manager->latestRelease($product, $channel);
|
||||
Http::ok([
|
||||
'update_available' => false,
|
||||
'message' => 'Application is up to date.'
|
||||
'current_version' => $current,
|
||||
'latest_version' => $installed !== null ? $installed['version'] : $current,
|
||||
'message' => 'Anwendung ist aktuell.',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
if (str_ends_with($uri, '/releases') && $method === 'GET') {
|
||||
$product = $_GET['product'] ?? null;
|
||||
$channel = $_GET['channel'] ?? null;
|
||||
$releases = $updateMgr->getReleases($product, $channel);
|
||||
sendResponse(['count' => count($releases), 'releases' => $releases]);
|
||||
}
|
||||
Http::ok([
|
||||
'update_available' => true,
|
||||
'current_version' => $current,
|
||||
'latest_version' => $latest['version'],
|
||||
'is_critical' => (bool)$latest['is_critical'],
|
||||
'latest_release' => $latest,
|
||||
]);
|
||||
|
||||
sendResponse(['error' => 'Not Found', 'message' => 'Endpoint not found'], 404);
|
||||
case 'latest':
|
||||
$product = Http::str('product') ?? Http::str('product_slug');
|
||||
if ($product === null) {
|
||||
Http::fail(400, 'missing_product', 'Der Parameter "product" wird benoetigt.');
|
||||
}
|
||||
|
||||
} catch (Throwable $t) {
|
||||
sendResponse(['error' => 'Server Error', 'message' => $t->getMessage()], 500);
|
||||
$release = $manager->latestRelease($product, Http::str('channel') ?? 'prod');
|
||||
if ($release === null) {
|
||||
Http::fail(404, 'no_release', sprintf('Fuer "%s" ist kein Release hinterlegt.', $product));
|
||||
}
|
||||
|
||||
Http::ok(['release' => $release]);
|
||||
|
||||
case 'releases':
|
||||
$releases = $manager->getReleases(
|
||||
Http::str('product') ?? Http::str('product_slug'),
|
||||
Http::str('channel'),
|
||||
Http::int('limit', 200)
|
||||
);
|
||||
Http::ok(['count' => count($releases), 'releases' => $releases]);
|
||||
|
||||
case 'publish':
|
||||
if (Http::method() !== 'POST') {
|
||||
Http::fail(405, 'method_not_allowed', 'Das Veroeffentlichen erwartet POST.');
|
||||
}
|
||||
|
||||
$context = ApiAuth::requireScope($db, 'updateservice:publish');
|
||||
|
||||
$product = Http::str('product_slug') ?? Http::str('product');
|
||||
$version = Http::str('version');
|
||||
$url = Http::str('download_url');
|
||||
|
||||
$missing = [];
|
||||
if ($product === null) { $missing[] = 'product_slug'; }
|
||||
if ($version === null) { $missing[] = 'version'; }
|
||||
if ($url === null) { $missing[] = 'download_url'; }
|
||||
|
||||
if ($missing !== []) {
|
||||
Http::fail(400, 'missing_fields', 'Pflichtfelder fehlen: ' . implode(', ', $missing), null, [
|
||||
'missing' => $missing,
|
||||
]);
|
||||
}
|
||||
|
||||
ApiAuth::enforceProject($context, $product);
|
||||
|
||||
// Eine Version ohne Ziffern wuerde beim Vergleich als 0.0.0 gelten und
|
||||
// die Rangfolge aller Releases dieses Produkts durcheinanderbringen.
|
||||
if (preg_match('/^v?\d+(\.\d+)*([-+].*)?$/i', $version) !== 1) {
|
||||
Http::fail(400, 'invalid_version', sprintf(
|
||||
'Version "%s" ist nicht interpretierbar. Erwartet wird eine Form wie 1.4.3, v1.4.3 oder 1.4.3-beta.1.',
|
||||
$version
|
||||
));
|
||||
}
|
||||
|
||||
$hash = Http::str('sha256_hash');
|
||||
if ($hash !== null && preg_match('/^[0-9a-f]{64}$/i', $hash) !== 1) {
|
||||
Http::fail(400, 'invalid_hash', 'sha256_hash muss 64 Hexadezimalzeichen enthalten.');
|
||||
}
|
||||
|
||||
$result = $manager->addRelease(
|
||||
$product,
|
||||
$version,
|
||||
Http::str('channel') ?? 'prod',
|
||||
Http::str('release_notes'),
|
||||
$url,
|
||||
$hash,
|
||||
Http::str('git_commit'),
|
||||
Http::int('size_bytes', 0),
|
||||
null,
|
||||
filter_var(Http::input('is_critical', false), FILTER_VALIDATE_BOOLEAN),
|
||||
$context['actor']
|
||||
);
|
||||
|
||||
Http::ok([
|
||||
'release_id' => $result['id'],
|
||||
'created' => $result['created'],
|
||||
'auto_resolved' => $result['auto_resolved'],
|
||||
'message' => sprintf(
|
||||
'Release %s (%s) fuer "%s" %s.%s',
|
||||
$version,
|
||||
Http::str('channel') ?? 'prod',
|
||||
$product,
|
||||
$result['created'] ? 'veroeffentlicht' : 'aktualisiert',
|
||||
$result['auto_resolved'] > 0
|
||||
? sprintf(' %d Bugtracker-Item(s) automatisch geschlossen.', $result['auto_resolved'])
|
||||
: ''
|
||||
),
|
||||
], $result['created'] ? 201 : 200);
|
||||
|
||||
default:
|
||||
Http::fail(404, 'unknown_action', 'Endpunkt nicht gefunden.', null, [
|
||||
'available' => ['check', 'latest', 'releases', 'publish'],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Bestimmt die Aktion aus dem Pfadsegment oder ?action=.
|
||||
* Feste Liste statt Teilstring-Suche.
|
||||
*/
|
||||
function resolveUpdateAction(): string
|
||||
{
|
||||
$explicit = Http::str('action');
|
||||
if ($explicit !== null) {
|
||||
// Altes Feld hiess publish_release
|
||||
return $explicit === 'publish_release' ? 'publish' : 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) {
|
||||
'check', 'latest', 'releases', 'publish' => $last,
|
||||
'publish_release' => 'publish',
|
||||
default => 'check',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,122 +1,218 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Watchdog API
|
||||
*
|
||||
* POST /api/watchdog/v1/ping Heartbeat (Scope watchdog:ping)
|
||||
* POST /api/watchdog/v1/event Ereignis protokollieren
|
||||
* GET /api/watchdog/v1/status Alle Monitore (Scope watchdog:read)
|
||||
* GET /api/watchdog/v1/events Ereignisprotokoll
|
||||
* GET /api/watchdog/v1/evaluate Evaluationslauf (Shared Key oder Session)
|
||||
*
|
||||
* Der Evaluate-Endpunkt ist neu und die eigentliche Ergaenzung: er stuft
|
||||
* Monitore anhand ihres erwarteten Intervalls auf warning bzw. down. Ohne ihn
|
||||
* blieb ein ausgefallener Server dauerhaft gruen, weil der Zustand sich nur
|
||||
* beim Eintreffen eines Heartbeats aenderte.
|
||||
*
|
||||
* Cron-Eintrag (minuetlich):
|
||||
* * * * * * curl -fsS -H "Authorization: Bearer <SHARED_KEY>" \
|
||||
* https://dc.example.com/api/watchdog/v1/evaluate > /dev/null
|
||||
*
|
||||
* Authentifizierung fuer Agenten: sowohl die zentralen Master-/Sub-Tokens
|
||||
* (dc_tokens, Scope watchdog:ping) als auch die aelteren Agent-Tokens aus
|
||||
* watchdog_agent_tokens werden akzeptiert.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
require_once __DIR__ . '/../../../../src/Core/Db.php';
|
||||
require_once __DIR__ . '/../../../../src/Modules/Watchdog/MonitorRepo.php';
|
||||
require_once __DIR__ . '/../../../../src/Modules/Watchdog/EventLog.php';
|
||||
require_once __DIR__ . '/../../../../src/Modules/Watchdog/TokenManager.php';
|
||||
require_once __DIR__ . '/../../../../src/bootstrap.php';
|
||||
|
||||
use Deploymentcenter\Core\ApiAuth;
|
||||
use Deploymentcenter\Core\Db;
|
||||
use Deploymentcenter\Modules\Watchdog\MonitorRepo;
|
||||
use Deploymentcenter\Core\Http;
|
||||
use Deploymentcenter\Modules\Watchdog\Evaluator;
|
||||
use Deploymentcenter\Modules\Watchdog\EventLog;
|
||||
use Deploymentcenter\Modules\Watchdog\TokenManager;
|
||||
use Deploymentcenter\Modules\Watchdog\MonitorRepo;
|
||||
use Deploymentcenter\Modules\Watchdog\TokenManager as LegacyTokenManager;
|
||||
|
||||
function sendResponse(array $data, int $statusCode = 200): void {
|
||||
http_response_code($statusCode);
|
||||
echo json_encode($data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
|
||||
exit;
|
||||
}
|
||||
Http::beginJson(['GET', 'POST', 'OPTIONS'], true);
|
||||
|
||||
try {
|
||||
$config = require __DIR__ . '/../../../../config/config.php';
|
||||
$pdo = Db::init($config);
|
||||
$db = Db::init();
|
||||
|
||||
$uri = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH);
|
||||
$method = strtoupper($_SERVER['REQUEST_METHOD'] ?? 'GET');
|
||||
$monitorRepo = new MonitorRepo($db);
|
||||
$eventLog = new EventLog($db);
|
||||
|
||||
$authHeader = $_SERVER['HTTP_X_WATCHDOG_KEY'] ?? $_SERVER['HTTP_AUTHORIZATION'] ?? $_SERVER['HTTP_X_AGENT_TOKEN'] ?? null;
|
||||
if ($authHeader && str_starts_with($authHeader, 'Bearer ')) {
|
||||
$authHeader = substr($authHeader, 7);
|
||||
}
|
||||
$action = resolveWatchdogAction();
|
||||
|
||||
$sharedKey = $config['security']['shared_key'] ?? '';
|
||||
$isAdminAuth = ($authHeader && hash_equals($sharedKey, $authHeader));
|
||||
switch ($action) {
|
||||
|
||||
$tokenManager = new TokenManager($pdo);
|
||||
$monitorRepo = new MonitorRepo($pdo);
|
||||
$eventLog = new EventLog($pdo);
|
||||
case 'ping':
|
||||
requirePost();
|
||||
|
||||
$verifyToken = function(string $source) use ($isAdminAuth, $authHeader, $tokenManager) {
|
||||
if ($isAdminAuth) return true;
|
||||
if (empty($authHeader)) {
|
||||
sendResponse(['error' => 'Unauthorized', 'message' => 'Missing authorization token.'], 401);
|
||||
}
|
||||
if (!$tokenManager->validateToken($authHeader, $source)) {
|
||||
sendResponse(['error' => 'Forbidden', 'message' => "Token unauthorized for source '{$source}'."], 403);
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
$rawInput = file_get_contents('php://input');
|
||||
$inputData = !empty($rawInput) ? (json_decode($rawInput, true) ?? []) : $_POST;
|
||||
|
||||
// Heartbeat / Ping
|
||||
if ((str_ends_with($uri, '/ping') || str_ends_with($uri, '/heartbeat')) && $method === 'POST') {
|
||||
$source = trim($inputData['source'] ?? '');
|
||||
$instance = trim($inputData['instance'] ?? 'default');
|
||||
$type = trim($inputData['type'] ?? 'heartbeat');
|
||||
$interval = (int)($inputData['interval'] ?? $inputData['expected_interval_sec'] ?? 60);
|
||||
$metrics = $inputData['metrics'] ?? null;
|
||||
$status = strtolower(trim($inputData['status'] ?? 'ok'));
|
||||
$message = $inputData['message'] ?? $inputData['reason'] ?? null;
|
||||
$groupKey = $inputData['group'] ?? $inputData['group_key'] ?? null;
|
||||
$os = $inputData['os'] ?? null;
|
||||
|
||||
if (empty($source)) {
|
||||
sendResponse(['error' => 'Bad Request', 'message' => 'Field "source" is required.'], 400);
|
||||
$source = Http::str('source');
|
||||
if ($source === null) {
|
||||
Http::fail(400, 'missing_source', 'Das Feld "source" wird benoetigt.');
|
||||
}
|
||||
|
||||
$verifyToken($source);
|
||||
authorizeSource($db, $source);
|
||||
|
||||
$monitor = $monitorRepo->upsertHeartbeat($source, $instance, $type, $interval, $metrics, $status, $message, $groupKey, $os);
|
||||
sendResponse([
|
||||
'status' => 'success',
|
||||
'message' => 'Heartbeat received',
|
||||
$monitor = $monitorRepo->upsertHeartbeat(
|
||||
$source,
|
||||
Http::str('instance') ?? 'default',
|
||||
Http::str('type') ?? 'heartbeat',
|
||||
Http::int('interval', 0) ?: Http::int('expected_interval_sec', 60),
|
||||
Http::input('metrics'),
|
||||
strtolower(Http::str('status') ?? 'ok'),
|
||||
Http::str('message') ?? Http::str('reason'),
|
||||
Http::str('group') ?? Http::str('group_key'),
|
||||
Http::str('os')
|
||||
);
|
||||
|
||||
// Zustandswechsel im Ereignisprotokoll festhalten.
|
||||
if (!empty($monitor['_state_changed'])) {
|
||||
$previous = (string)$monitor['_previous_state'];
|
||||
$current = (string)$monitor['state'];
|
||||
|
||||
$eventLog->logEvent(
|
||||
$source,
|
||||
(string)$monitor['instance'],
|
||||
$current === 'up' ? 'recovered' : ($current === 'warning' ? 'warning_raised' : 'hard_error'),
|
||||
$previous,
|
||||
$current,
|
||||
$current === 'up' ? 'info' : ($current === 'warning' ? 'warning' : 'alarm'),
|
||||
'Zustandswechsel durch Heartbeat.'
|
||||
);
|
||||
}
|
||||
|
||||
Http::ok([
|
||||
'message' => 'Heartbeat empfangen.',
|
||||
'monitor' => [
|
||||
'source' => $monitor['source'],
|
||||
'instance' => $monitor['instance'],
|
||||
'state' => $monitor['state'],
|
||||
'last_status' => $monitor['last_status'],
|
||||
'source' => $monitor['source'],
|
||||
'instance' => $monitor['instance'],
|
||||
'state' => $monitor['state'],
|
||||
'last_status' => $monitor['last_status'],
|
||||
'last_seen_utc' => $monitor['last_seen_utc'],
|
||||
]
|
||||
'state_changed' => (bool)($monitor['_state_changed'] ?? false),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
// Log Event
|
||||
if (str_ends_with($uri, '/event') && $method === 'POST') {
|
||||
$source = trim($inputData['source'] ?? '');
|
||||
$instance = trim($inputData['instance'] ?? 'default');
|
||||
$kind = trim($inputData['kind'] ?? 'started');
|
||||
$severity = trim($inputData['severity'] ?? 'info');
|
||||
$message = $inputData['message'] ?? null;
|
||||
$meta = $inputData['meta'] ?? null;
|
||||
case 'event':
|
||||
requirePost();
|
||||
|
||||
if (empty($source)) sendResponse(['error' => 'Bad Request', 'message' => 'Field "source" is required.'], 400);
|
||||
$source = Http::str('source');
|
||||
if ($source === null) {
|
||||
Http::fail(400, 'missing_source', 'Das Feld "source" wird benoetigt.');
|
||||
}
|
||||
|
||||
$verifyToken($source);
|
||||
$eventId = $eventLog->logEvent($source, $instance, $kind, null, null, $severity, $message, $meta);
|
||||
authorizeSource($db, $source);
|
||||
|
||||
sendResponse(['status' => 'success', 'event_id' => $eventId]);
|
||||
}
|
||||
$meta = Http::input('meta');
|
||||
$eventId = $eventLog->logEvent(
|
||||
$source,
|
||||
Http::str('instance') ?? 'default',
|
||||
Http::str('kind') ?? 'started',
|
||||
Http::str('from_state'),
|
||||
Http::str('to_state'),
|
||||
Http::str('severity') ?? 'info',
|
||||
Http::str('message'),
|
||||
is_array($meta) ? $meta : null
|
||||
);
|
||||
|
||||
// Status / Monitore auflisten
|
||||
if (str_ends_with($uri, '/status') && $method === 'GET') {
|
||||
Http::ok(['event_id' => $eventId], 201);
|
||||
|
||||
case 'status':
|
||||
ApiAuth::requireScope($db, 'watchdog:read');
|
||||
$monitors = $monitorRepo->getAllMonitors();
|
||||
sendResponse(['count' => count($monitors), 'monitors' => $monitors]);
|
||||
}
|
||||
Http::ok(['count' => count($monitors), 'monitors' => $monitors]);
|
||||
|
||||
// Events auflisten
|
||||
if (str_ends_with($uri, '/events') && $method === 'GET') {
|
||||
$limit = (int)($_GET['limit'] ?? 50);
|
||||
$events = $eventLog->getRecentEvents($limit);
|
||||
sendResponse(['count' => count($events), 'events' => $events]);
|
||||
}
|
||||
case 'events':
|
||||
ApiAuth::requireScope($db, 'watchdog:read');
|
||||
$events = $eventLog->getRecentEvents(
|
||||
Http::int('limit', 50),
|
||||
Http::str('source'),
|
||||
Http::str('instance'),
|
||||
Http::str('severity')
|
||||
);
|
||||
Http::ok(['count' => count($events), 'events' => $events]);
|
||||
|
||||
sendResponse(['error' => 'Not Found', 'message' => 'Endpoint not found'], 404);
|
||||
case 'evaluate':
|
||||
// Bewusst nur fuer Shared Key oder eine angemeldete Sitzung -
|
||||
// ein Agenten-Token soll den Zustand aller Monitore nicht umschreiben.
|
||||
ApiAuth::requireScope($db, 'watchdog:evaluate');
|
||||
|
||||
} catch (Throwable $t) {
|
||||
sendResponse(['error' => 'Server Error', 'message' => $t->getMessage()], 500);
|
||||
$result = Evaluator::run($db);
|
||||
Http::ok($result + ['message' => sprintf(
|
||||
'%d Monitor(e) geprueft, %d Zustandswechsel.',
|
||||
$result['checked'],
|
||||
$result['changed']
|
||||
)]);
|
||||
|
||||
default:
|
||||
Http::fail(404, 'unknown_action', 'Endpunkt nicht gefunden.', null, [
|
||||
'available' => ['ping', 'event', 'status', 'events', 'evaluate'],
|
||||
]);
|
||||
}
|
||||
|
||||
// ======================================================================
|
||||
|
||||
function requirePost(): void
|
||||
{
|
||||
if (Http::method() !== 'POST') {
|
||||
Http::fail(405, 'method_not_allowed', 'Diese Aktion erwartet POST.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Prueft die Berechtigung, fuer eine bestimmte Source zu melden.
|
||||
*
|
||||
* Akzeptiert zentrale Tokens (dc_tokens, Scope watchdog:ping) und die
|
||||
* aelteren, an eine Source gebundenen Agent-Tokens.
|
||||
*/
|
||||
function authorizeSource(PDO $db, string $source): void
|
||||
{
|
||||
// Zentrale Token-Hierarchie, Shared Key oder Session
|
||||
if (ApiAuth::resolve($db, 'watchdog:ping', null, true) !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Alt-Tokens aus watchdog_agent_tokens
|
||||
$presented = Http::bearerToken();
|
||||
if ($presented !== null) {
|
||||
$legacy = new LegacyTokenManager($db);
|
||||
if ($legacy->validateToken($presented, $source)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
Http::fail(
|
||||
401,
|
||||
'unauthorized',
|
||||
sprintf('Kein gueltiges Token fuer die Source "%s".', $source),
|
||||
null,
|
||||
['required_scope' => 'watchdog:ping']
|
||||
);
|
||||
}
|
||||
|
||||
function resolveWatchdogAction(): 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) {
|
||||
'ping', 'heartbeat' => 'ping',
|
||||
'event' => 'event',
|
||||
'events' => 'events',
|
||||
'status' => 'status',
|
||||
'evaluate' => 'evaluate',
|
||||
default => 'status',
|
||||
};
|
||||
}
|
||||
|
||||
+343
-196
@@ -1,258 +1,405 @@
|
||||
# 🤖 AI Agent Integration Guide: Deployment Center Bugtracker & Provisioning API
|
||||
# Deployment Center — Agenten-Handbuch
|
||||
|
||||
This guide defines the standardized protocol and API specifications for autonomous AI Developer Agents interacting with the **Deployment Center Bugtracker & Token Provisioning System**.
|
||||
Diese Seite beschreibt, wie ein Coding-Agent den Bugtracker und den
|
||||
UpdateService des Deployment Centers benutzt.
|
||||
|
||||
**Maschinenlesbare Fassung:** `GET /api/openapi.json`
|
||||
|
||||
---
|
||||
|
||||
## 📌 Executive Overview for AI Agents
|
||||
## 0. Was sich geändert hat
|
||||
|
||||
As an autonomous AI agent working on software repositories, you MUST actively track all runtime errors, stack traces, planned features, and backlog items in the central Deployment Center.
|
||||
Wer eine ältere Integration betreibt, muss zwei Dinge anpassen:
|
||||
|
||||
### Core Capabilities:
|
||||
1. **Sub-Token Auto-Provisioning**: Generate restricted sub-tokens for scoped agent tasks.
|
||||
2. **Automated Error Ingestion**: Report unhandled exceptions with stack traces & automatic SHA-256 deduplication.
|
||||
3. **Feature & Idea Backlog**: Submit roadmap ideas (`severity: "idea"`) or backlog items (`severity: "wishlist"`).
|
||||
4. **Active Workflow Management**: Fetch active bugs assigned to your agent ID, update status (`in_progress`, `resolved`), and append diagnostic comments.
|
||||
| Änderung | Auswirkung |
|
||||
|---|---|
|
||||
| `POST /api/bugtracker/v1/report` verlangt jetzt zwingend ein Token | Aufrufe ohne Token liefern `401 unauthorized` |
|
||||
| `GET /api/bugtracker/v1/projects` verlangt jetzt ein Token | dito |
|
||||
| `POST` auf UpdateService-Publish verlangt `updateservice:publish` | Aufrufe ohne Token liefern `401` |
|
||||
| Antwortformat vereinheitlicht | Erfolg: `{"status":"success",...}`, Fehler: `{"status":"error","error":{"code":"…","message":"…"}}` |
|
||||
|
||||
Der Feldname `error_hash` bleibt erhalten; zusätzlich gibt es `dedup_key`.
|
||||
|
||||
---
|
||||
|
||||
## 🔑 1. Token Provisioning API
|
||||
## 1. Authentifizierung
|
||||
|
||||
Agents authenticate using a **Master Token** or auto-provisioned **Sub-Token**.
|
||||
Alle Endpunkte akzeptieren das Token in einem dieser Header:
|
||||
|
||||
### Endpoint: `POST /api/tokens/v1/provision`
|
||||
|
||||
Header: `Authorization: Bearer <MASTER_TOKEN>`
|
||||
|
||||
#### Request Payload:
|
||||
```json
|
||||
{
|
||||
"parent_token": "dc_master_myapp_dev_agent_001",
|
||||
"name": "Codebase Refactoring Agent Token",
|
||||
"environment": "development",
|
||||
"scopes": ["bugtracker:report", "bugtracker:manage"],
|
||||
"expires_in_hours": 24
|
||||
}
|
||||
```
|
||||
Authorization: Bearer dc_sub_xxxxxxxxxxxx
|
||||
X-Agent-Token: dc_sub_xxxxxxxxxxxx
|
||||
```
|
||||
|
||||
#### Response:
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"token_id": "tok_s_8912ab",
|
||||
"raw_token": "dc_sub_myapp_refactor_agent_991",
|
||||
"scopes": ["bugtracker:report", "bugtracker:manage"],
|
||||
"environment": "development",
|
||||
"expires_at": "2026-08-07 21:00:00"
|
||||
}
|
||||
### Token-Hierarchie
|
||||
|
||||
* **Master-Token** (`dc_master_…`) — wird im WebUI unter *Token-Verwaltung* erzeugt.
|
||||
Langlebig, gehört auf den Rechner bzw. in die CI, nicht in ein Repository.
|
||||
* **Sub-Token** (`dc_sub_…`) — erzeugt sich ein Agent selbst aus dem Master-Token.
|
||||
Rechte lassen sich dabei nur **einschränken**, nie erweitern.
|
||||
|
||||
### Sub-Token anfordern
|
||||
|
||||
```bash
|
||||
curl -X POST https://dc.mhdf.de/api/tokens/v1/provision \
|
||||
-H "X-Master-Token: dc_master_xxxxxxxx" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"client_name": "claude-code auf DEV-WORKSTATION-01",
|
||||
"instance_id": "DEV-WORKSTATION-01",
|
||||
"scopes": ["bugtracker:report", "bugtracker:read", "bugtracker:manage"],
|
||||
"environment": "development"
|
||||
}'
|
||||
```
|
||||
|
||||
Das zurückgegebene `sub_token` wird **nur einmal** ausgeliefert.
|
||||
|
||||
### Rechte (Scopes)
|
||||
|
||||
| Scope | Erlaubt |
|
||||
|---|---|
|
||||
| `bugtracker:report` | Bugs, Feature Requests und Ideen melden |
|
||||
| `bugtracker:read` | Items und Projekte lesen |
|
||||
| `bugtracker:manage` | Übernehmen, kommentieren, Status setzen, schließen |
|
||||
| `watchdog:ping` | Heartbeats senden |
|
||||
| `updateservice:read` | Auf Updates prüfen |
|
||||
| `updateservice:publish` | Releases veröffentlichen |
|
||||
| `bugtracker:*` | alle Bugtracker-Rechte |
|
||||
| `*` | alles |
|
||||
|
||||
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`.
|
||||
|
||||
---
|
||||
|
||||
## 📂 1.5. Discovering Monitored Projects API
|
||||
## 2. Projekte finden
|
||||
|
||||
Before reporting a bug or feature request, an agent can dynamically query all registered projects monitored by the Deployment Center.
|
||||
```bash
|
||||
curl https://dc.mhdf.de/api/bugtracker/v1/projects \
|
||||
-H "Authorization: Bearer $DC_TOKEN"
|
||||
```
|
||||
|
||||
### Endpoint: `GET /api/bugtracker/v1/projects.php`
|
||||
|
||||
#### Response:
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"count": 4,
|
||||
"projects": [
|
||||
{
|
||||
"id": 1,
|
||||
"slug": "myapp",
|
||||
"name": "My Application Deluxe",
|
||||
"notes": "Hauptanwendung für Desktop und Server"
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"slug": "polytrader",
|
||||
"name": "PolyTrader Suite Pro",
|
||||
"notes": "Trading- und Handelssystem Client"
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"slug": "predictalytics",
|
||||
"name": "Predictalytics Engine",
|
||||
"notes": "Datenanalyse und Vorhersage Dienst"
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"slug": "deploymentcenter",
|
||||
"name": "Deployment Center",
|
||||
"notes": "Zentrale Verwaltungs- & Update-Plattform"
|
||||
"repo_url": "https://git.example.com/Richard/Deploymentcenter.git",
|
||||
"default_agent": null,
|
||||
"open_items": 3,
|
||||
"critical_items": 0
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
If an agent discovers an issue or refactoring opportunity in any monitored system (including `deploymentcenter` itself or external dependencies), it can fetch this project list and map the issue to the appropriate `project_slug`.
|
||||
> Findest du einen Fehler im Deployment Center selbst, melde ihn unter
|
||||
> `project_slug: "deploymentcenter"`.
|
||||
|
||||
---
|
||||
|
||||
## 🐛 2. Reporting Bugs, Features & Ideas
|
||||
## 3. Etwas melden
|
||||
|
||||
### Endpoint: `POST /api/bugtracker/v1/report.php`
|
||||
```bash
|
||||
curl -X POST https://dc.mhdf.de/api/bugtracker/v1/report \
|
||||
-H "Authorization: Bearer $DC_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Idempotency-Key: run-2026-08-07-42" \
|
||||
-d '{
|
||||
"project_slug": "myapp",
|
||||
"type": "bug",
|
||||
"title": "NullReferenceException in UserAuthService",
|
||||
"description": "Tritt beim Login ohne gesetzte Session auf.",
|
||||
"error_message": "Object reference not set to an instance of an object.",
|
||||
"stack_trace": "at MyApp.Core.UserAuthService.ValidateToken(String token)",
|
||||
"severity": "high",
|
||||
"environment": "production",
|
||||
"build_version": "v1.4.2",
|
||||
|
||||
Header: `Authorization: Bearer <AGENT_TOKEN>`
|
||||
|
||||
### A. Reporting an Unhandled Exception / Bug
|
||||
```json
|
||||
{
|
||||
"project_slug": "myapp",
|
||||
"type": "bug",
|
||||
"title": "NullReferenceException in UserAuthService.cs line 42",
|
||||
"description": "Triggered when user logs in without an active session object.",
|
||||
"error_message": "NullReferenceException: Object reference not set to an instance of an object.",
|
||||
"stack_trace": "at MyApp.Core.UserAuthService.ValidateToken(String token) in UserAuthService.cs:line 42\nat MyApp.Controllers.AuthController.Login() in AuthController.cs:line 18",
|
||||
"build_version": "v1.4.2-dev",
|
||||
"environment": "development",
|
||||
"severity": "high",
|
||||
"push_id": "push_wf_8912",
|
||||
"target_agent": "agent:code-fixer-01",
|
||||
"tags": "auth, security, csharp",
|
||||
"created_by": "agent:watchdog-monitor"
|
||||
}
|
||||
"repo_url": "https://git.example.com/me/myapp.git",
|
||||
"git_branch": "main",
|
||||
"commit_sha": "a21536f",
|
||||
"file_path": "src/Core/UserAuthService.cs",
|
||||
"line_no": 42
|
||||
}'
|
||||
```
|
||||
|
||||
### B. Submitting a Feature Request or Quick Reminder Idea (`severity: "idea"`)
|
||||
```json
|
||||
{
|
||||
"project_slug": "myapp",
|
||||
"type": "feature_request",
|
||||
"title": "Automatische Datenbank-Backups vor FTP Deployments",
|
||||
"description": "Gedanke für später: Vor jedem FTP-Deployment automatisch mysqldump ausführen und im Server-Archiv ablegen.",
|
||||
"build_version": "v1.6.0-roadmap",
|
||||
"environment": "development",
|
||||
"severity": "idea",
|
||||
"push_id": "push_wf_9910",
|
||||
"target_agent": "agent:db-optimizer",
|
||||
"tags": "database, automation, backup",
|
||||
"created_by": "agent:planner"
|
||||
}
|
||||
```
|
||||
### Felder
|
||||
|
||||
| Feld | Pflicht | Bedeutung |
|
||||
|---|---|---|
|
||||
| `title` | ja | Kurze Beschreibung, max. 255 Zeichen |
|
||||
| `project_slug` | empfohlen | Aus der Projektliste; Vorgabe `default` |
|
||||
| `type` | nein | `bug` (Vorgabe) oder `feature_request` |
|
||||
| `severity` | nein | `idea`, `wishlist`, `low`, `medium` (Vorgabe), `high`, `critical` |
|
||||
| `environment` | nein | `production` (Vorgabe), `development`, `staging`, `testing` |
|
||||
| `client_ref` | empfohlen | Idempotenz-Schlüssel, alternativ Header `Idempotency-Key` |
|
||||
| `repo_url`, `git_branch`, `commit_sha`, `file_path`, `line_no` | empfohlen | Code-Kontext — spart dem nächsten Agenten das Parsen des Stacktrace |
|
||||
| `context` | nein | Beliebiges JSON-Objekt für Zusatzinformationen |
|
||||
| `push_id`, `target_agent`, `tags` | nein | Workflow-Zuordnung |
|
||||
|
||||
`created_by` wird aus dem Token abgeleitet und kann nicht gesetzt werden.
|
||||
|
||||
### Was der Server daraus macht
|
||||
|
||||
* **Deduplizierung** — gleiche Fehler werden zusammengefasst und
|
||||
`occurrence_count` erhöht. Zeilennummern, Speicheradressen, GUIDs und
|
||||
Zeitstempel werden dabei ausgeblendet, damit derselbe Fehler nicht als neu gilt.
|
||||
Feature Requests und Ideen werden über den Titel dedupliziert.
|
||||
* **Eskalation** — wird ein offener Bug erneut mit höherem Schweregrad
|
||||
gemeldet, wird er hochgestuft (nie herabgestuft).
|
||||
* **Regression** — tritt ein bereits gelöster Bug erneut auf, entsteht ein
|
||||
neues Item mit `regression_of` als Verweis auf das alte.
|
||||
* **Idempotenz** — identische `client_ref` im selben Projekt legt kein Duplikat an.
|
||||
|
||||
### Antwort
|
||||
|
||||
#### Response:
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"item_id": 4,
|
||||
"item_id": 42,
|
||||
"is_new": true,
|
||||
"idempotent_hit": false,
|
||||
"occurrence_count": 1,
|
||||
"error_hash": "e2c918a514d89a42f",
|
||||
"type": "bug",
|
||||
"environment": "development",
|
||||
"push_id": "push_wf_8912",
|
||||
"message": "New bug reported successfully."
|
||||
"dedup_key": "e2c918a514d89a42f...",
|
||||
"item_status": "open",
|
||||
"regression_of": null,
|
||||
"message": "Bug erfasst."
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📌 3. Managing Items (Fetching, Updating & Commenting)
|
||||
## 4. Die Agenten-Schleife
|
||||
|
||||
### Base Endpoint: `/api/bugtracker/v1/manage/index.php`
|
||||
Basis: `https://dc.mhdf.de/api/bugtracker/v1/manage`
|
||||
|
||||
Header: `Authorization: Bearer <AGENT_TOKEN>`
|
||||
### 4.1 Arbeit holen und übernehmen
|
||||
|
||||
### A. Fetching Open Items Assigned to an Agent
|
||||
```http
|
||||
GET /api/bugtracker/v1/manage/index.php?project_slug=myapp&status=open&agent=agent:code-fixer-01
|
||||
```
|
||||
Ein Aufruf, der die nächsten offenen Items liefert **und** exklusiv für dich
|
||||
reserviert — damit arbeiten nicht zwei Agenten am selben Bug:
|
||||
|
||||
### B. Updating Status & Details (`POST ?action=update`)
|
||||
```json
|
||||
{
|
||||
"id": 4,
|
||||
"status": "in_progress",
|
||||
"severity": "high",
|
||||
"push_id": "push_wf_8912",
|
||||
"target_agent": "agent:code-fixer-01",
|
||||
"tags": "auth, fixed_pending_test",
|
||||
"author": "agent:code-fixer-01"
|
||||
}
|
||||
```
|
||||
|
||||
### C. Appending Diagnostic Timeline Comments (`POST ?action=comment`)
|
||||
```json
|
||||
{
|
||||
"id": 4,
|
||||
"comment": "Ursache identifiziert: $_SESSION['user'] war Null in line 42. Null-Check und Safe Navigation Operator wurden hinzugefügt.",
|
||||
"action_taken": "code_patched",
|
||||
"author": "agent:code-fixer-01"
|
||||
}
|
||||
```
|
||||
|
||||
### D. Marking as Resolved (`POST ?action=resolve`)
|
||||
```json
|
||||
{
|
||||
"id": 4,
|
||||
"resolved_in_build": "v1.4.3-dev",
|
||||
"resolution_notes": "Unit tests hinzugefügt und Null-Check in ValidateToken() integriert.",
|
||||
"author": "agent:code-fixer-01"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 💻 4. Code Implementation Examples for Agents
|
||||
|
||||
### Python Example: Automatic Error Reporter Decorator
|
||||
```python
|
||||
import requests
|
||||
import traceback
|
||||
import sys
|
||||
|
||||
DC_API_URL = "https://dc.mhdf.de/api/bugtracker/v1/report.php"
|
||||
AGENT_TOKEN = "dc_sub_myapp_agent_live_001"
|
||||
|
||||
def report_exception_to_dc(project_slug: str, exc: Exception, env: str = "production", push_id: str = None):
|
||||
payload = {
|
||||
"project_slug": project_slug,
|
||||
"type": "bug",
|
||||
"title": f"{type(exc).__name__}: {str(exc)}",
|
||||
"error_message": str(exc),
|
||||
"stack_trace": traceback.format_exc(),
|
||||
"build_version": "v1.4.2",
|
||||
"environment": env,
|
||||
"severity": "high",
|
||||
"push_id": push_id,
|
||||
"created_by": "agent:python-runner"
|
||||
}
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": f"Bearer {AGENT_TOKEN}"
|
||||
}
|
||||
try:
|
||||
r = requests.post(DC_API_URL, json=payload, headers=headers, timeout=5)
|
||||
return r.json()
|
||||
except Exception as e:
|
||||
print(f"Failed to report to Deployment Center: {e}", file=sys.stderr)
|
||||
```
|
||||
|
||||
### cURL Example: Submit Feature Request / Idea
|
||||
```bash
|
||||
curl -X POST "https://dc.mhdf.de/api/bugtracker/v1/report.php" \
|
||||
-H "Authorization: Bearer dc_sub_myapp_agent_live_001" \
|
||||
curl -X POST "https://dc.mhdf.de/api/bugtracker/v1/manage?action=next" \
|
||||
-H "Authorization: Bearer $DC_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"project_slug": "myapp", "limit": 1, "severity": "critical,high"}'
|
||||
```
|
||||
|
||||
Die Reservierung (Lease) läuft nach 30 Minuten automatisch ab. Brauchst du
|
||||
länger, erneuere sie mit `action=claim` auf dieselbe ID.
|
||||
|
||||
### 4.2 Zwischenstand dokumentieren
|
||||
|
||||
```bash
|
||||
curl -X POST "https://dc.mhdf.de/api/bugtracker/v1/manage?action=comment&id=42" \
|
||||
-H "Authorization: Bearer $DC_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"project_slug": "myapp",
|
||||
"type": "feature_request",
|
||||
"title": "Erweiterte Filterung im WebUI Dashboard",
|
||||
"severity": "idea",
|
||||
"push_id": "push_task_1029",
|
||||
"tags": "ui, dashboard",
|
||||
"created_by": "agent:dev-assistant"
|
||||
}'
|
||||
"comment": "Ursache gefunden: Session wird vor dem Redirect nicht initialisiert.",
|
||||
"action_taken": "investigated"
|
||||
}'
|
||||
```
|
||||
|
||||
Empfohlene Werte für `action_taken`: `investigated`, `fix_proposed`,
|
||||
`pr_opened`, `needs_human`, `blocked`, `commented`.
|
||||
|
||||
### 4.3 Abschließen
|
||||
|
||||
```bash
|
||||
curl -X POST "https://dc.mhdf.de/api/bugtracker/v1/manage?action=resolve&id=42" \
|
||||
-H "Authorization: Bearer $DC_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"resolved_in_build": "v1.4.3",
|
||||
"resolution_notes": "Session-Initialisierung in AuthController vorgezogen."
|
||||
}'
|
||||
```
|
||||
|
||||
### 4.4 Wieder freigeben
|
||||
|
||||
Kommst du nicht weiter, gib das Item zurück, statt den Lease verfallen zu lassen:
|
||||
|
||||
```bash
|
||||
curl -X POST "https://dc.mhdf.de/api/bugtracker/v1/manage?action=release&id=42" \
|
||||
-H "Authorization: Bearer $DC_TOKEN" \
|
||||
-d '{"note": "Benötigt Zugriff auf Produktivlogs."}'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Best Practices for Developer Agents
|
||||
## 5. Lesen und Filtern
|
||||
|
||||
1. **Always set `push_id`**: When executing automated pipelines, pass a `push_id` so all updates can be traced back to the specific execution run.
|
||||
2. **Use `severity: "idea"` for thoughts**: When noticing potential refactorings or future improvements during coding, log them immediately as ideas.
|
||||
3. **Comment before resolving**: Before calling `action=resolve`, write a diagnostic comment explaining **why** and **how** the fix was performed.
|
||||
```bash
|
||||
curl "https://dc.mhdf.de/api/bugtracker/v1/manage?action=list&project_slug=myapp&status=open,in_progress&order=severity&limit=20" \
|
||||
-H "Authorization: Bearer $DC_TOKEN"
|
||||
```
|
||||
|
||||
| Parameter | Bedeutung |
|
||||
|---|---|
|
||||
| `status`, `severity` | Mehrere Werte kommagetrennt |
|
||||
| `type`, `environment`, `project_slug` | Einzelwert oder `all` |
|
||||
| `target_agent`, `claimed_by`, `push_id` | Exakte Übereinstimmung |
|
||||
| `search` | Volltext über Titel, Beschreibung, Fehlermeldung, Tags, Dateipfad |
|
||||
| `unclaimed_only` | `true` — nur Items, die kein Agent bearbeitet |
|
||||
| `updated_since` | ISO-8601 — **Delta-Abfrage für effizientes Polling** |
|
||||
| `order` | `newest`, `oldest`, `updated`, `severity`, `occurrences` |
|
||||
| `limit`, `offset` | Pagination, max. 500 pro Seite |
|
||||
|
||||
Die Antwort enthält `total`, `limit`, `offset` und `has_more`.
|
||||
|
||||
### Polling-Muster
|
||||
|
||||
```bash
|
||||
# Nur was sich seit dem letzten Durchlauf geändert hat
|
||||
curl "…/manage?action=list&updated_since=2026-08-07T09:00:00Z&order=updated" \
|
||||
-H "Authorization: Bearer $DC_TOKEN"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Release veröffentlichen und Items automatisch schließen
|
||||
|
||||
Der Kreis schließt sich hier: Items, deren `resolved_in_build` der
|
||||
veröffentlichten Version entspricht, werden beim Publish automatisch geschlossen.
|
||||
|
||||
```bash
|
||||
curl -X POST https://dc.mhdf.de/api/updateservice/v1/publish \
|
||||
-H "Authorization: Bearer $DC_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"product_slug": "myapp",
|
||||
"version": "1.4.3",
|
||||
"channel": "prod",
|
||||
"download_url": "https://dc.mhdf.de/downloads/myapp-1.4.3.zip",
|
||||
"sha256_hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
||||
"git_commit": "a21536f",
|
||||
"release_notes": "Behebt den Login-Fehler."
|
||||
}'
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"release_id": 12,
|
||||
"created": true,
|
||||
"auto_resolved": 3,
|
||||
"message": "Release 1.4.3 (prod) für \"myapp\" veröffentlicht. 3 Bugtracker-Item(s) automatisch geschlossen."
|
||||
}
|
||||
```
|
||||
|
||||
Der Versionsvergleich folgt der semantischen Versionsordnung — `1.10.0` gilt
|
||||
korrekt als neuer als `1.9.0`.
|
||||
|
||||
---
|
||||
|
||||
## 7. Fehlerbehandlung
|
||||
|
||||
Fehler tragen einen stabilen, maschinenlesbaren Code. Reagiere auf `code`,
|
||||
nicht auf `message`:
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "error",
|
||||
"error": { "code": "already_claimed", "message": "Item #42 ist bereits vergeben." }
|
||||
}
|
||||
```
|
||||
|
||||
| Code | HTTP | Bedeutung und Reaktion |
|
||||
|---|---|---|
|
||||
| `unauthorized` | 401 | Token fehlt, ist abgelaufen oder hat den Scope nicht |
|
||||
| `project_forbidden` | 403 | Token ist an ein anderes Projekt gebunden |
|
||||
| `already_claimed` | 409 | Anderer Agent arbeitet daran — nächstes Item nehmen |
|
||||
| `not_claimed` | 409 | Freigabe eines Items, das dir nicht gehört |
|
||||
| `not_found` | 404 | Item existiert nicht |
|
||||
| `rate_limited` | 429 | Sendefrequenz senken, später erneut |
|
||||
| `invalid_json` | 400 | Request-Body ist kein gültiges JSON |
|
||||
| `missing_id`, `missing_status`, `missing_build` | 400 | Pflichtfeld fehlt |
|
||||
| `invalid_status`, `invalid_version`, `invalid_hash` | 400 | Wert nicht zulässig |
|
||||
| `internal_error` | 500 | Serverfehler — wird automatisch selbst im Bugtracker erfasst |
|
||||
|
||||
**Rate-Limit:** 60 Reports pro Minute und IP. Bei `429` das Intervall verdoppeln.
|
||||
|
||||
---
|
||||
|
||||
## 8. Vollständige Beispielschleife (Python)
|
||||
|
||||
```python
|
||||
import os, requests
|
||||
|
||||
BASE = "https://dc.mhdf.de/api/bugtracker/v1/manage"
|
||||
HEAD = {"Authorization": f"Bearer {os.environ['DC_TOKEN']}",
|
||||
"Content-Type": "application/json"}
|
||||
|
||||
|
||||
def next_item(project):
|
||||
r = requests.post(f"{BASE}?action=next", headers=HEAD,
|
||||
json={"project_slug": project, "limit": 1})
|
||||
r.raise_for_status()
|
||||
items = r.json().get("items", [])
|
||||
return items[0] if items else None
|
||||
|
||||
|
||||
def comment(item_id, text, action="investigated"):
|
||||
requests.post(f"{BASE}?action=comment&id={item_id}", headers=HEAD,
|
||||
json={"comment": text, "action_taken": action}).raise_for_status()
|
||||
|
||||
|
||||
def resolve(item_id, build, notes):
|
||||
requests.post(f"{BASE}?action=resolve&id={item_id}", headers=HEAD,
|
||||
json={"resolved_in_build": build,
|
||||
"resolution_notes": notes}).raise_for_status()
|
||||
|
||||
|
||||
def release(item_id, reason):
|
||||
requests.post(f"{BASE}?action=release&id={item_id}", headers=HEAD,
|
||||
json={"note": reason}).raise_for_status()
|
||||
|
||||
|
||||
item = next_item("myapp")
|
||||
if item is None:
|
||||
print("Nichts zu tun.")
|
||||
else:
|
||||
print(f"#{item['id']}: {item['title']}")
|
||||
if item.get("file_path"):
|
||||
print(f" -> {item['file_path']}:{item.get('line_no', '?')}")
|
||||
|
||||
comment(item["id"], "Analyse gestartet.")
|
||||
try:
|
||||
# ... hier die eigentliche Arbeit ...
|
||||
resolve(item["id"], "v1.4.3", "Fix in AuthController.")
|
||||
except Exception as exc:
|
||||
release(item["id"], f"Abbruch: {exc}")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. Watchdog-Heartbeat
|
||||
|
||||
Läuft dein Agent als Dienst, melde dich regelmäßig:
|
||||
|
||||
```bash
|
||||
curl -X POST https://dc.mhdf.de/api/watchdog/v1/ping \
|
||||
-H "Authorization: Bearer $DC_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"source": "agent-worker-01", "status": "ok", "interval": 60,
|
||||
"message": "Verarbeite Warteschlange", "metrics": {"queue": 3}}'
|
||||
```
|
||||
|
||||
`interval` ist der erwartete Abstand in Sekunden. Bleibt der Heartbeat aus,
|
||||
stuft der Evaluator den Monitor nach dem Doppelten auf `warning` und nach dem
|
||||
Vierfachen auf `down`.
|
||||
|
||||
---
|
||||
|
||||
## 10. Verfügbarkeit prüfen
|
||||
|
||||
```bash
|
||||
curl https://dc.mhdf.de/api/health -H "Authorization: Bearer $DC_TOKEN"
|
||||
```
|
||||
|
||||
Meldet Datenbankzustand, ausstehende Migrationen, Bugtracker-Kennzahlen und
|
||||
wann der Watchdog-Evaluator zuletzt lief.
|
||||
|
||||
@@ -141,8 +141,14 @@ if (isset($_GET['raw']) || (isset($_SERVER['HTTP_ACCEPT']) && str_contains($_SER
|
||||
<div class="header-bar">
|
||||
<h1>🚀 Deployment Center API & Agent Documentation</h1>
|
||||
<div>
|
||||
<a href="bugtracker.md" target="_blank" class="btn">📄 Raw Markdown (.md)</a>
|
||||
<a href="../api/bugtracker/v1/projects.php" target="_blank" class="btn">📂 Projects API</a>
|
||||
<a href="bugtracker.md" target="_blank" rel="noopener" class="btn">📄 Rohtext (.md)</a>
|
||||
<!--
|
||||
Der frühere Link auf die Projects-API ist entfallen: sie
|
||||
verlangt jetzt ein Token und liefert im Browser nur noch 401.
|
||||
Stattdessen die maschinenlesbare Schnittstellenbeschreibung,
|
||||
die ohne Token abrufbar ist.
|
||||
-->
|
||||
<a href="../api/openapi.php" target="_blank" rel="noopener" class="btn">🔌 OpenAPI (JSON)</a>
|
||||
</div>
|
||||
</div>
|
||||
<div id="docContent">Loading documentation...</div>
|
||||
|
||||
+1611
-736
File diff suppressed because it is too large
Load Diff
+136
-100
@@ -1,117 +1,153 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Datenbank-Migration.
|
||||
*
|
||||
* SICHERHEITSAENDERUNG - das hier war die gravierendste Luecke des Projekts:
|
||||
* Diese Datei war ohne jede Authentifizierung erreichbar und setzte bei jedem
|
||||
* Aufruf das Admin-Passwort auf einen fest im Code stehenden Wert zurueck.
|
||||
* Ein einziger Aufruf von aussen genuegte, um die Plattform zu uebernehmen.
|
||||
*
|
||||
* Jetzt gilt:
|
||||
* - Zugriff nur mit angemeldeter Sitzung oder Shared Key
|
||||
* - Kein Zuruecksetzen bestehender Passwoerter. Ein Administrator wird nur
|
||||
* angelegt, wenn ueberhaupt noch keiner existiert; das Passwort wird dann
|
||||
* zufaellig erzeugt und genau einmal angezeigt.
|
||||
* - Fehler werden nicht mehr mit Dateipfad und Zeilennummer ausgeliefert
|
||||
* - Migrationen laufen ueber den Migrator, der Statements korrekt zerlegt
|
||||
* und angewendete Versionen in dc_migrations vermerkt
|
||||
*
|
||||
* Aufruf per CLI ist ebenfalls moeglich:
|
||||
* php public/install_db.php
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
error_reporting(E_ALL);
|
||||
ini_set('display_errors', '1');
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
require_once __DIR__ . '/../src/bootstrap.php';
|
||||
|
||||
try {
|
||||
$config = require __DIR__ . '/../config/config.php';
|
||||
$dbCfg = $config['db'];
|
||||
use Deploymentcenter\Core\ApiAuth;
|
||||
use Deploymentcenter\Core\Config;
|
||||
use Deploymentcenter\Core\Db;
|
||||
use Deploymentcenter\Core\Http;
|
||||
use Deploymentcenter\Core\Logger;
|
||||
use Deploymentcenter\Core\Migrator;
|
||||
|
||||
$dsn = sprintf('mysql:host=%s;dbname=%s;charset=%s', $dbCfg['host'], $dbCfg['dbname'], $dbCfg['charset']);
|
||||
$pdo = new PDO($dsn, $dbCfg['username'], $dbCfg['password'], [
|
||||
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||||
]);
|
||||
$isCli = PHP_SAPI === 'cli';
|
||||
|
||||
$sqlFile = __DIR__ . '/../sql/schema.sql';
|
||||
if (!file_exists($sqlFile)) {
|
||||
echo json_encode(['status' => 'error', 'message' => 'schema.sql file not found']);
|
||||
exit;
|
||||
}
|
||||
if (!$isCli) {
|
||||
Http::beginJson(['GET', 'POST', 'OPTIONS']);
|
||||
}
|
||||
|
||||
$db = Db::init();
|
||||
|
||||
// --- Zugriffsschutz ---
|
||||
if (!$isCli && ApiAuth::resolve($db, 'system:migrate') === null) {
|
||||
Logger::warning('Migrationsversuch ohne Berechtigung', ['ip' => Http::clientIp()]);
|
||||
Http::fail(
|
||||
401,
|
||||
'unauthorized',
|
||||
'Migration nur fuer angemeldete Administratoren oder mit gueltigem Shared Key.'
|
||||
);
|
||||
}
|
||||
|
||||
// --- Migrationen ausfuehren ---
|
||||
$result = Migrator::migrate($db);
|
||||
|
||||
$rawSql = file_get_contents($sqlFile);
|
||||
|
||||
// Remove comments
|
||||
$lines = explode("\n", $rawSql);
|
||||
$cleanLines = [];
|
||||
foreach ($lines as $line) {
|
||||
$trimmed = trim($line);
|
||||
if (str_starts_with($trimmed, '--') || str_starts_with($trimmed, '#')) {
|
||||
continue;
|
||||
}
|
||||
$cleanLines[] = $line;
|
||||
}
|
||||
$cleanSql = implode("\n", $cleanLines);
|
||||
if ($result['failed'] !== null) {
|
||||
Logger::error('Migration abgebrochen', $result['failed']);
|
||||
|
||||
// Split queries by semicolon
|
||||
$queries = array_filter(array_map('trim', explode(';', $cleanSql)));
|
||||
$payload = [
|
||||
'applied' => $result['applied'],
|
||||
'skipped' => $result['skipped'],
|
||||
'failed' => Config::isDebug()
|
||||
? $result['failed']
|
||||
: ['version' => $result['failed']['version'], 'message' => 'Details stehen im Log unter var/log/.'],
|
||||
];
|
||||
|
||||
$executed = 0;
|
||||
foreach ($queries as $q) {
|
||||
if (!empty($q)) {
|
||||
$pdo->exec($q);
|
||||
$executed++;
|
||||
}
|
||||
if ($isCli) {
|
||||
fwrite(STDERR, "Migration fehlgeschlagen:\n" . json_encode($payload, JSON_PRETTY_PRINT) . "\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
// Execute migration files in sql/migrations/
|
||||
$migrationDir = __DIR__ . '/../sql/migrations';
|
||||
if (is_dir($migrationDir)) {
|
||||
$files = glob($migrationDir . '/*.sql');
|
||||
sort($files);
|
||||
foreach ($files as $mFile) {
|
||||
$mSql = file_get_contents($mFile);
|
||||
$mLines = explode("\n", $mSql);
|
||||
$mClean = [];
|
||||
foreach ($mLines as $l) {
|
||||
$t = trim($l);
|
||||
if (str_starts_with($t, '--') || str_starts_with($t, '#')) continue;
|
||||
$mClean[] = $l;
|
||||
}
|
||||
$mQueries = array_filter(array_map('trim', explode(';', implode("\n", $mClean))));
|
||||
foreach ($mQueries as $mq) {
|
||||
if (!empty($mq)) {
|
||||
try {
|
||||
$pdo->exec($mq);
|
||||
$executed++;
|
||||
} catch (Throwable $e) {
|
||||
// Ignore harmless duplicate column / migration errors
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Http::fail(500, 'migration_failed', 'Die Migration wurde abgebrochen.', null, $payload);
|
||||
}
|
||||
|
||||
// --- Administrator nur anlegen, wenn noch keiner existiert ---
|
||||
$adminNotice = null;
|
||||
$userCount = (int)$db->query('SELECT COUNT(*) FROM dc_users')->fetchColumn();
|
||||
|
||||
// Create / update Admin user: admin / Admin1337!
|
||||
$adminUsername = 'admin';
|
||||
$adminPassword = 'Admin1337!';
|
||||
$passwordHash = password_hash($adminPassword, PASSWORD_ARGON2ID);
|
||||
if ($userCount === 0) {
|
||||
$username = 'admin';
|
||||
$password = generateInitialPassword();
|
||||
|
||||
$stmt = $pdo->prepare('
|
||||
$stmt = $db->prepare('
|
||||
INSERT INTO dc_users (username, password_hash, created_at)
|
||||
VALUES (:u, :p, NOW())
|
||||
ON DUPLICATE KEY UPDATE password_hash = VALUES(password_hash)
|
||||
VALUES (:username, :hash, UTC_TIMESTAMP())
|
||||
');
|
||||
$stmt->execute([':u' => $adminUsername, ':p' => $passwordHash]);
|
||||
|
||||
// Seed default endpoints setting in dc_settings
|
||||
$endpoints = json_encode([
|
||||
'validate' => '/api/license/v1/validate',
|
||||
'deactivate' => '/api/license/v1/deactivate',
|
||||
]);
|
||||
$stmtSet = $pdo->prepare('INSERT INTO dc_settings (skey, svalue) VALUES ("endpoints", :v) ON DUPLICATE KEY UPDATE svalue = VALUES(svalue)');
|
||||
$stmtSet->execute([':v' => $endpoints]);
|
||||
|
||||
// Query created tables to verify
|
||||
$tables = $pdo->query('SHOW TABLES')->fetchAll(PDO::FETCH_COLUMN);
|
||||
|
||||
echo json_encode([
|
||||
'status' => 'success',
|
||||
'message' => "Successfully executed {$executed} SQL statements!",
|
||||
'created_user' => 'admin',
|
||||
'tables_in_db' => $tables,
|
||||
'timestamp' => date('Y-m-d H:i:s')
|
||||
], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
|
||||
|
||||
} catch (Throwable $t) {
|
||||
http_response_code(500);
|
||||
echo json_encode([
|
||||
'status' => 'error',
|
||||
'message' => $t->getMessage(),
|
||||
'file' => $t->getFile(),
|
||||
'line' => $t->getLine()
|
||||
], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
|
||||
$stmt->execute([':username' => $username, ':hash' => password_hash($password, PASSWORD_DEFAULT)]);
|
||||
|
||||
Logger::info('Initialer Administrator angelegt', ['username' => $username]);
|
||||
|
||||
$adminNotice = [
|
||||
'username' => $username,
|
||||
'password' => $password,
|
||||
'warning' => 'Dieses Passwort wird nur ein einziges Mal angezeigt. Bitte sofort notieren und nach der ersten Anmeldung aendern.',
|
||||
];
|
||||
} else {
|
||||
$adminNotice = [
|
||||
'message' => sprintf(
|
||||
'%d Benutzerkonto(en) vorhanden - es wurde keines angelegt und keines veraendert.',
|
||||
$userCount
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
// --- Standard-Endpunkte hinterlegen ---
|
||||
$endpoints = json_encode([
|
||||
'validate' => '/api/license/v1/validate',
|
||||
'deactivate' => '/api/license/v1/deactivate',
|
||||
], JSON_UNESCAPED_SLASHES);
|
||||
|
||||
$db->prepare('
|
||||
INSERT INTO dc_settings (skey, svalue) VALUES ("endpoints", :value)
|
||||
ON DUPLICATE KEY UPDATE svalue = VALUES(svalue)
|
||||
')->execute([':value' => $endpoints]);
|
||||
|
||||
$tables = $db->query('SHOW TABLES')->fetchAll(PDO::FETCH_COLUMN) ?: [];
|
||||
|
||||
$response = [
|
||||
'applied' => $result['applied'],
|
||||
'skipped' => $result['skipped'],
|
||||
'tables' => $tables,
|
||||
'admin' => $adminNotice,
|
||||
'next_steps' => [
|
||||
'Cron einrichten: * * * * * curl -fsS -H "Authorization: Bearer <SHARED_KEY>" '
|
||||
. Http::baseUrl() . '/api/watchdog/v1/evaluate > /dev/null',
|
||||
'Master-Token im WebUI unter "Token-Verwaltung" erzeugen und an die Agenten verteilen.',
|
||||
],
|
||||
'timestamp_utc' => gmdate('c'),
|
||||
];
|
||||
|
||||
if ($isCli) {
|
||||
echo json_encode($response, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE), "\n";
|
||||
exit(0);
|
||||
}
|
||||
|
||||
Http::ok($response);
|
||||
|
||||
/**
|
||||
* Erzeugt ein gut lesbares, ausreichend starkes Initialpasswort.
|
||||
* Zeichen, die sich leicht verwechseln lassen, sind ausgeschlossen.
|
||||
*/
|
||||
function generateInitialPassword(int $length = 20): string
|
||||
{
|
||||
$alphabet = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789';
|
||||
$max = strlen($alphabet) - 1;
|
||||
|
||||
$password = '';
|
||||
for ($i = 0; $i < $length; $i++) {
|
||||
$password .= $alphabet[random_int(0, $max)];
|
||||
}
|
||||
|
||||
return $password;
|
||||
}
|
||||
|
||||
+29
-13
@@ -2,11 +2,13 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/../src/Core/Db.php';
|
||||
require_once __DIR__ . '/../src/Core/Auth.php';
|
||||
require_once __DIR__ . '/../src/bootstrap.php';
|
||||
|
||||
use Deploymentcenter\Core\Db;
|
||||
use Deploymentcenter\Core\Auth;
|
||||
use Deploymentcenter\Core\Csrf;
|
||||
use Deploymentcenter\Core\Db;
|
||||
use Deploymentcenter\Core\Http;
|
||||
use Deploymentcenter\Core\Logger;
|
||||
|
||||
Auth::startSession();
|
||||
|
||||
@@ -18,25 +20,38 @@ if (Auth::isLoggedIn()) {
|
||||
$error = null;
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$username = trim($_POST['username'] ?? '');
|
||||
$password = trim($_POST['password'] ?? '');
|
||||
$username = trim((string)($_POST['username'] ?? ''));
|
||||
$password = (string)($_POST['password'] ?? '');
|
||||
|
||||
if (!empty($username) && !empty($password)) {
|
||||
// CSRF-Schutz auch am Login: verhindert erzwungene Fremdanmeldungen.
|
||||
if (!Csrf::isValid(is_string($_POST['csrf_token'] ?? null) ? $_POST['csrf_token'] : null)) {
|
||||
$error = 'Sitzung abgelaufen. Bitte erneut versuchen.';
|
||||
} elseif ($username === '' || $password === '') {
|
||||
$error = 'Bitte fuellen Sie alle Felder aus.';
|
||||
} else {
|
||||
try {
|
||||
$config = require __DIR__ . '/../config/config.php';
|
||||
$pdo = Db::init($config);
|
||||
$pdo = Db::init();
|
||||
$lockoutSeconds = Auth::lockoutSeconds($pdo, Http::clientIp());
|
||||
|
||||
if (Auth::login($pdo, $username, $password)) {
|
||||
if ($lockoutSeconds > 0) {
|
||||
$error = sprintf(
|
||||
'Zu viele fehlgeschlagene Anmeldeversuche. Bitte in %d Minute(n) erneut versuchen.',
|
||||
(int)ceil($lockoutSeconds / 60)
|
||||
);
|
||||
} elseif (Auth::login($pdo, $username, $password)) {
|
||||
header('Location: /index.php');
|
||||
exit;
|
||||
} else {
|
||||
$error = 'Ungültige Anmeldedaten. Bitte überprüfen Sie Benutzername und Passwort.';
|
||||
// Bewusst dieselbe Meldung fuer falschen Benutzer und falsches
|
||||
// Passwort - sonst laesst sich herausfinden, welche Konten es gibt.
|
||||
$error = 'Ungueltige Anmeldedaten.';
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$error = 'Datenbankverbindung fehlgeschlagen: ' . $e->getMessage();
|
||||
// Die urspruengliche Fassung gab hier die rohe PDO-Meldung samt
|
||||
// Hostname und Benutzernamen an jeden anonymen Besucher aus.
|
||||
Logger::error('Anmeldung fehlgeschlagen (technischer Fehler)', ['error' => $e->getMessage()]);
|
||||
$error = 'Anmeldung derzeit nicht moeglich. Bitte spaeter erneut versuchen.';
|
||||
}
|
||||
} else {
|
||||
$error = 'Bitte füllen Sie alle Felder aus.';
|
||||
}
|
||||
}
|
||||
?>
|
||||
@@ -228,6 +243,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
<?php endif; ?>
|
||||
|
||||
<form method="POST" action="login.php">
|
||||
<?= Csrf::field() ?>
|
||||
<div class="form-group">
|
||||
<label for="username" class="form-label">Benutzername</label>
|
||||
<input type="text" id="username" name="username" class="form-control" required autofocus placeholder="z. B. admin">
|
||||
|
||||
+3
-1
@@ -2,9 +2,11 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/../src/Core/Auth.php';
|
||||
require_once __DIR__ . '/../src/bootstrap.php';
|
||||
|
||||
use Deploymentcenter\Core\Auth;
|
||||
|
||||
Auth::logout();
|
||||
|
||||
header('Location: /login.php');
|
||||
exit;
|
||||
|
||||
Reference in New Issue
Block a user