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:
Deploymentcenter Bot
2026-08-07 16:17:36 +02:00
co-authored by Claude Opus 5
parent a21536f495
commit e7fbc85db4
59 changed files with 8506 additions and 2410 deletions
+302 -197
View File
@@ -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;
}
+43 -30
View File
@@ -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),
]);
+77 -65
View File
@@ -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);
+118
View File
@@ -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);
+56 -57
View File
@@ -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',
};
}
+308
View File
@@ -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);
+64 -50
View File
@@ -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);
+171 -74
View File
@@ -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',
};
}
+188 -92
View File
@@ -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
View File
@@ -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.
+8 -2
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+136 -100
View File
@@ -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
View File
@@ -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
View File
@@ -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;