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
+8
View File
@@ -5,3 +5,11 @@ scratch/
bin/ bin/
obj/ obj/
*.user *.user
# --- Secrets: NIEMALS committen ---
config/config.php
config/.htpasswd
scripts/deploy_config.json
Serverdaten.txt
*.local.php
.env
+70 -23
View File
@@ -1,36 +1,83 @@
# Deploymentcenter - Front-Controller-Routing
#
# Reihenfolge ist bewusst gewaehlt: erst die Routen (jeweils mit [L]), danach
# die Sperrregeln fuer alles, was nicht geroutet wurde. Zusaetzlich liegt in
# config/, src/ und sql/ jeweils eine eigene .htaccess als zweite
# Verteidigungslinie, falls mod_rewrite oder AllowOverride ausfaellt.
Options -Indexes
<IfModule mod_rewrite.c> <IfModule mod_rewrite.c>
RewriteEngine On RewriteEngine On
# Route /assets/ requests to public/assets/ # Bereits umgeschriebene Anfragen nicht erneut anfassen.
RewriteRule ^public/ - [L]
# ------------------------------------------------------------------
# 1. Statische Assets
# ------------------------------------------------------------------
RewriteRule ^assets/(.*)$ public/assets/$1 [L,QSA] RewriteRule ^assets/(.*)$ public/assets/$1 [L,QSA]
# Module API routing # ------------------------------------------------------------------
RewriteRule ^api/license/v1(?:/(.*))?$ public/api/license/v1/index.php [L,QSA] # 2. API
RewriteRule ^api/watchdog/v1(?:/(.*))?$ public/api/watchdog/v1/index.php [L,QSA] # ------------------------------------------------------------------
RewriteRule ^api/updateservice/v1(?:/(.*))?$ public/api/updateservice/v1/index.php [L,QSA] RewriteRule ^api/health/?$ public/api/health.php [L,QSA]
RewriteRule ^api/tokens/v1/provision public/api/tokens/v1/provision.php [L,QSA] RewriteRule ^api/openapi(?:\.json)?/?$ public/api/openapi.php [L,QSA]
RewriteRule ^api/bugtracker/v1/report public/api/bugtracker/v1/report.php [L,QSA]
RewriteRule ^api/bugtracker/v1/projects public/api/bugtracker/v1/projects.php [L,QSA] RewriteRule ^api/bugtracker/v1/report/?$ public/api/bugtracker/v1/report.php [L,QSA]
RewriteRule ^api/bugtracker/v1/projects/?$ public/api/bugtracker/v1/projects.php [L,QSA]
RewriteRule ^api/bugtracker/v1/manage(?:/(.*))?$ public/api/bugtracker/v1/manage/index.php [L,QSA] RewriteRule ^api/bugtracker/v1/manage(?:/(.*))?$ public/api/bugtracker/v1/manage/index.php [L,QSA]
# Documentation routing RewriteRule ^api/license/v1(?:/(.*))?$ public/api/license/v1/index.php [L,QSA]
RewriteRule ^docs/(.*)$ public/docs/$1 [L,QSA] RewriteRule ^api/watchdog/v1(?:/(.*))?$ public/api/watchdog/v1/index.php [L,QSA]
RewriteRule ^docs/?$ public/docs/index.php [L,QSA] RewriteRule ^api/updateservice/v1(?:/(.*))?$ public/api/updateservice/v1/index.php [L,QSA]
RewriteRule ^api/tokens/v1/provision/?$ public/api/tokens/v1/provision.php [L,QSA]
# Fallback for static assets in /api/ # Fallback fuer direkt adressierte Dateien unterhalb von /api/
RewriteRule ^api/(.*)$ public/api/$1 [L,QSA] RewriteRule ^api/(.*)$ public/api/$1 [L,QSA]
# Route /install_db.php # ------------------------------------------------------------------
# 3. Oeffentliche Dokumentation (public/docs/)
# Muss vor der Sperrregel stehen, sonst wuerde ^docs/ blockiert.
# ------------------------------------------------------------------
RewriteRule ^docs/?$ public/docs/index.php [L,QSA]
RewriteRule ^docs/(.+)$ public/docs/$1 [L,QSA]
# ------------------------------------------------------------------
# 4. WebUI
# ------------------------------------------------------------------
RewriteRule ^install_db\.php$ public/install_db.php [L,QSA] RewriteRule ^install_db\.php$ public/install_db.php [L,QSA]
RewriteRule ^login\.php$ public/login.php [L,QSA]
RewriteRule ^logout\.php$ public/logout.php [L,QSA]
RewriteRule ^index\.php$ public/index.php [L,QSA]
RewriteRule ^$ public/index.php [L,QSA]
# Route /login.php & /logout.php # ------------------------------------------------------------------
RewriteRule ^login\.php$ public/login.php [L,QSA] # 5. Alles Uebrige sperren: Anwendungscode, Konfiguration, Skripte
RewriteRule ^logout\.php$ public/logout.php [L,QSA] # ------------------------------------------------------------------
RewriteRule ^(config|src|sql|scripts|var|client-dotnet)(/|$) - [F,L]
# Route root requests to public/index.php RewriteRule ^Serverdaten\.txt$ - [F,L]
RewriteRule ^$ public/index.php [L]
RewriteRule ^index\.php$ public/index.php [L]
# Block direct access to sensitive folders
RewriteRule ^(config|src|sql|scripts)/ - [F,L]
</IfModule> </IfModule>
# ----------------------------------------------------------------------
# Sicherheits-Header
# ----------------------------------------------------------------------
<IfModule mod_headers.c>
Header always set X-Content-Type-Options "nosniff"
Header always set X-Frame-Options "SAMEORIGIN"
Header always set Referrer-Policy "strict-origin-when-cross-origin"
Header always set Permissions-Policy "geolocation=(), microphone=(), camera=()"
</IfModule>
# ----------------------------------------------------------------------
# Dateischutz auch ohne mod_rewrite
# ----------------------------------------------------------------------
<FilesMatch "^(Serverdaten\.txt|\.env.*|composer\.(json|lock)|deploy_config\.json)$">
<IfModule mod_authz_core.c>
Require all denied
</IfModule>
<IfModule !mod_authz_core.c>
Order allow,deny
Deny from all
</IfModule>
</FilesMatch>
-6
View File
@@ -1,6 +0,0 @@
url dc.mhdf.de htaccess: user: deploy pw: deploy02763!
MySQL Connectionstring: mysql -D bergisnu_db0 -u bergisnu_0 -p'r4[V?:)C~+Sh' -h lznk.your-database.de
FTP Zugangsdaten: server: www531.your-server.de user: bergisnu_4 pw: o2#M*NN^5EsT
Git http://192.168.178.10:8418/Richard/Deploymentcenter.git Token: eb42957585f9c6d41b79cee07b9a5ca8dbaf0179
+11
View File
@@ -0,0 +1,11 @@
# Zweite Verteidigungslinie: kein Direktzugriff auf Konfiguration/Secrets,
# auch wenn mod_rewrite oder AllowOverride im Root ausfaellt.
<IfModule mod_authz_core.c>
Require all denied
</IfModule>
<IfModule !mod_authz_core.c>
Order allow,deny
Deny from all
</IfModule>
-1
View File
@@ -1 +0,0 @@
deploy:$apr1$c815$WbSj8VpE1zP2Y.0Z7G3h/1
+36
View File
@@ -0,0 +1,36 @@
# config/
Dieses Verzeichnis enthält Zugangsdaten und wird **nicht** versioniert.
| Datei | Zweck | In Git? |
|---|---|---|
| `config.example.php` | Vorlage mit allen Schlüsseln | ja |
| `config.php` | Aktive Konfiguration dieser Instanz | **nein** |
| `.htpasswd` | Apache Basic-Auth vor der Plattform | **nein** |
## Einrichtung
```bash
cp config/config.example.php config/config.php
```
Danach `db.*` sowie die drei Schlüssel unter `security` ausfüllen. Schlüssel erzeugen:
```bash
openssl rand -hex 32
```
## Umgebungsvariablen
Jeder Wert lässt sich über eine Umgebungsvariable überschreiben; diese hat Vorrang
vor dem Wert in `config.php`:
`DC_APP_URL`, `DC_DEBUG`, `DC_DB_HOST`, `DC_DB_NAME`, `DC_DB_USER`, `DC_DB_PASS`,
`DC_SHARED_KEY`, `DC_WEBHOOK_KEY`, `DC_LICENSE_SIGNING_KEY`
## Schutz im Webroot
`/.htaccess` blockt Zugriffe auf `config/`, `src/`, `sql/` und `scripts/`.
Zusätzlich liegt in diesem Verzeichnis eine eigene `.htaccess` als zweite
Verteidigungslinie, falls `mod_rewrite` oder `AllowOverride` einmal ausfallen.
Ideal wäre, `config/` ganz aus dem Webroot zu verschieben.
+61
View File
@@ -0,0 +1,61 @@
<?php
/**
* Deploymentcenter Configuration VORLAGE
*
* Diese Datei nach config/config.php kopieren und ausfüllen.
* config/config.php ist per .gitignore vom Repository ausgeschlossen.
*
* Jeder Wert kann alternativ über eine Umgebungsvariable gesetzt werden
* (siehe dc_env() unten). Die Umgebungsvariable hat immer Vorrang.
*/
if (!function_exists('dc_env')) {
/**
* Liest eine Umgebungsvariable, fällt sonst auf den Standardwert zurück.
*/
function dc_env(string $key, $default = null)
{
$val = getenv($key);
if ($val === false || $val === '') {
return $default;
}
return $val;
}
}
return [
'app' => [
'name' => 'Deploymentcenter',
'version' => '2.0.0',
'url' => dc_env('DC_APP_URL', 'https://dc.example.com'),
'timezone' => 'Europe/Berlin',
// Bei true werden Exception-Texte in API-Antworten ausgegeben.
// Auf Produktivsystemen zwingend false lassen.
'debug' => (bool)dc_env('DC_DEBUG', false),
],
'db' => [
'host' => dc_env('DC_DB_HOST', 'localhost'),
'dbname' => dc_env('DC_DB_NAME', 'deploymentcenter'),
'username' => dc_env('DC_DB_USER', 'root'),
'password' => dc_env('DC_DB_PASS', ''),
'charset' => 'utf8mb4',
],
'security' => [
// Master-Key für Server-zu-Server-Aufrufe (Evaluator-Cron, Deactivate, Migration).
// Mit `openssl rand -hex 32` erzeugen.
'shared_key' => dc_env('DC_SHARED_KEY', ''),
'session_name' => 'DC_SESSION_ID',
// Signaturschlüssel für ausgehende Webhooks (HMAC-SHA256).
'webhook_key' => dc_env('DC_WEBHOOK_KEY', ''),
// Signaturschlüssel für Offline-Lizenzdateien (.lic).
'license_key' => dc_env('DC_LICENSE_SIGNING_KEY', ''),
],
'bugtracker' => [
// Projekt-Slug, unter dem das Deploymentcenter eigene Fehler meldet.
'self_project' => 'deploymentcenter',
// Reports pro IP und Minute am öffentlichen Ingest-Endpunkt.
'report_rate' => 60,
// Wie lange ein Agent ein Item exklusiv beansprucht (Minuten).
'lease_minutes' => 30,
],
];
-22
View File
@@ -1,22 +0,0 @@
<?php
// Deploymentcenter Configuration
return [
'app' => [
'name' => 'Deploymentcenter',
'version' => '1.0.0',
'url' => 'https://dc.mhdf.de',
'timezone' => 'Europe/Berlin',
],
'db' => [
'host' => 'lznk.your-database.de',
'dbname' => 'bergisnu_db0',
'username' => 'bergisnu_0',
'password' => 'r4[V?:)C~+Sh',
'charset' => 'utf8mb4',
],
'security' => [
'shared_key' => 'DC_MASTER_SECURE_TOKEN_2026_x98f',
'session_name' => 'DC_SESSION_ID',
]
];
+101 -26
View File
@@ -1,42 +1,117 @@
# 📋 Minimal Agent Prompt Template (For CLAUDE.md / AGENTS.md / CursorRules) # Agent-Prompt-Vorlage
Copy & paste the following snippet into your agent system prompts or repository `CLAUDE.md` / `.cursorrules`: Diesen Abschnitt in `CLAUDE.md`, `AGENTS.md` oder `.cursorrules` des jeweiligen
Projekts einfügen.
---
```markdown ```markdown
## 🐛 Central Bugtracker & Feature Logging Directive ## Zentraler Bugtracker — Deployment Center
As an AI developer agent, you MUST track all unhandled errors, stack traces, planned features, and refactoring ideas in the central **Deployment Center Bugtracker**. Erfasse unbehandelte Fehler, geplante Verbesserungen und Ideen im zentralen
Deployment Center. Basis-URL: `https://dc.mhdf.de`
### 1. Monitored Projects & Discovery ### Zugang
Before reporting, check monitored projects via API or use the known project slug: Token steht in der Umgebungsvariable `DC_TOKEN`.
- **Projects Discovery API**: `GET https://dc.mhdf.de/api/bugtracker/v1/projects.php` Header: `Authorization: Bearer $DC_TOKEN`
- **Known Slugs**: `deploymentcenter`, `myapp`, `polytrader`, `predictalytics`.
*Note: If you discover a bug or issue in the Deployment Center code itself while working elsewhere, report it under `project_slug: "deploymentcenter"`.*
### 2. Reporting Bugs & Ideas Die vollständige Schnittstellenbeschreibung liegt maschinenlesbar unter
Submit reports via HTTP POST to: `https://dc.mhdf.de/api/bugtracker/v1/report.php` `GET /api/openapi.json`, das Handbuch unter `/docs/`.
Header: `Authorization: Bearer <YOUR_AGENT_TOKEN>` (or `X-Agent-Token: <YOUR_AGENT_TOKEN>`)
### Projekt bestimmen
`GET /api/bugtracker/v1/projects` liefert alle Slugs.
Bekannt: `deploymentcenter`, `myapp`, `polytrader`, `predictalytics`.
Fällt dir ein Fehler im Deployment Center selbst auf, melde ihn unter
`deploymentcenter`.
### Etwas melden
`POST /api/bugtracker/v1/report`
```json ```json
{ {
"project_slug": "deploymentcenter", "project_slug": "myapp",
"type": "bug", "type": "bug",
"title": "Short descriptive title of the error or feature", "title": "Kurze, aussagekräftige Zusammenfassung",
"description": "Condition or context under which it occurred", "description": "Unter welchen Bedingungen tritt es auf?",
"error_message": "Exact exception message", "error_message": "Exakte Fehlermeldung",
"stack_trace": "Complete stack trace snippet", "stack_trace": "Vollständiger Stacktrace",
"severity": "high", "severity": "high",
"push_id": "push_wf_8912", "environment": "production",
"created_by": "agent:your-name" "build_version": "v1.4.2",
"repo_url": "https://git.example.com/me/myapp.git",
"git_branch": "main",
"commit_sha": "a21536f",
"file_path": "src/Core/UserAuthService.cs",
"line_no": 42,
"client_ref": "eindeutige-id-dieses-laufs"
} }
``` ```
- **Severities**: **Setze immer `client_ref`** — ein wiederholter Aufruf mit demselben Wert legt
- `idea`: 💡 Quick reminder / thought for later kein Duplikat an. Gib nach Möglichkeit `file_path` und `line_no` an; das spart
- `wishlist`: ⭐ Backlog feature request dem nächsten Agenten das Parsen des Stacktrace.
- `low` / `medium` / `high` / `critical`: Standard bug severities
Schweregrade: `idea` (Gedanke für später), `wishlist` (Backlog),
`low`, `medium`, `high`, `critical`.
### Arbeit übernehmen
Bevor du an einem Item arbeitest, übernimm es — sonst arbeiten zwei Agenten
parallel am selben Problem:
### 3. Full API Documentation On-Demand ```
If you need complete API details, token provisioning instructions, or cURL/Python examples, read: POST /api/bugtracker/v1/manage?action=next
📄 **Documentation URL**: `https://dc.mhdf.de/docs/bugtracker.md` Body: {"project_slug": "myapp", "limit": 1}
```
Antwortet der Server mit `409 already_claimed`, nimm das nächste Item.
### Fortschritt festhalten
```
POST /api/bugtracker/v1/manage?action=comment&id=<ID>
Body: {"comment": "Was du herausgefunden hast", "action_taken": "investigated"}
```
`action_taken`: `investigated`, `fix_proposed`, `pr_opened`, `needs_human`,
`blocked`.
### Abschließen
```
POST /api/bugtracker/v1/manage?action=resolve&id=<ID>
Body: {"resolved_in_build": "v1.4.3", "resolution_notes": "Was geändert wurde"}
```
Kommst du nicht weiter, gib das Item zurück statt es blockieren zu lassen:
```
POST /api/bugtracker/v1/manage?action=release&id=<ID>
Body: {"note": "Grund"}
```
### Release melden
Nach einem Release schließen sich Items mit passendem `resolved_in_build`
automatisch:
```
POST /api/updateservice/v1/publish
Body: {"product_slug": "myapp", "version": "1.4.3",
"download_url": "...", "sha256_hash": "...", "git_commit": "..."}
```
### Fehlerbehandlung
Antworten haben die Form `{"status":"error","error":{"code":"…"}}`.
Reagiere auf `code`, nicht auf den Text:
- `401 unauthorized` — Token prüfen, nicht wiederholen
- `409 already_claimed` — nächstes Item nehmen
- `429 rate_limited` — Intervall verdoppeln, später erneut
```
---
## Kurzfassung für knappe Prompt-Budgets
```markdown
Melde Fehler und Ideen an https://dc.mhdf.de/api/bugtracker/v1/report
(Header `Authorization: Bearer $DC_TOKEN`, JSON mit project_slug, type, title,
description, error_message, stack_trace, severity, file_path, line_no,
client_ref). Vor der Arbeit an einem Item: POST .../manage?action=next zum
Übernehmen. Danach ?action=resolve mit resolved_in_build.
Vollständige Beschreibung: https://dc.mhdf.de/api/openapi.json
``` ```
+8
View File
@@ -1,5 +1,13 @@
# 🤖 AI Agent Integration Guide: Deployment Center Bugtracker & Provisioning API # 🤖 AI Agent Integration Guide: Deployment Center Bugtracker & Provisioning API
> **⚠️ Geändert in Version 2.0** — `POST /api/bugtracker/v1/report` und
> `GET /api/bugtracker/v1/projects` verlangen jetzt zwingend ein Token mit dem
> passenden Scope; Aufrufe ohne Token liefern `401 unauthorized`. Das
> Antwortformat wurde vereinheitlicht. Die aktuelle, vollständige Beschreibung
> steht im **[Agenten-Handbuch](../public/docs/bugtracker.md)** und unter
> `/api/openapi.json`. Umstellungsschritte: **[UPGRADE.md](./UPGRADE.md)**.
This guide defines the standardized protocol and API specifications for autonomous AI Developer Agents interacting with the **Deployment Center Bugtracker & Token Provisioning System**. This guide defines the standardized protocol and API specifications for autonomous AI Developer Agents interacting with the **Deployment Center Bugtracker & Token Provisioning System**.
--- ---
+8
View File
@@ -1,5 +1,13 @@
# Deploymentcenter — Lizenzsystem Integration für KI-Agenten # Deploymentcenter — Lizenzsystem Integration für KI-Agenten
> **⚠️ Geändert in Version 2.0** — `/api/license/v1/validate` bleibt unverändert
> und ohne Token erreichbar. `/api/license/v1/deactivate` verlangt weiterhin
> Authentifizierung, allerdings mit dem **neu erzeugten** `shared_key`: der alte
> Wert lag im Repository und wurde ersetzt. Die Signatur der Offline-Lizenzdateien
> ist jetzt ein ehrlich benanntes HMAC-SHA256 statt der irreführenden Bezeichnung
> `ED25519_SIG_`. Umstellungsschritte: **[UPGRADE.md](./UPGRADE.md)**.
> **Zielgruppe**: KI-Agenten & Softwareentwickler > **Zielgruppe**: KI-Agenten & Softwareentwickler
> **Gültig ab**: Hardware-ID v2 Specification (August 2026) > **Gültig ab**: Hardware-ID v2 Specification (August 2026)
> **Plattformen**: Windows, Linux (inkl. systemd Services & Docker-Container), macOS > **Plattformen**: Windows, Linux (inkl. systemd Services & Docker-Container), macOS
+82 -10
View File
@@ -1,18 +1,90 @@
# Deploymentcenter — AI Agent Integration Guides # Deploymentcenter — Entwickler- und Agenten-Dokumentation
Willkommen in der Entwickler- und Agenten-Dokumentation von **Deploymentcenter**. Zentrale Plattform für Lizenzverwaltung, Software-Updates, Infrastruktur-
Monitoring und einen Bugtracker, den Coding-Agenten selbständig bedienen.
Diese Anleitungen sind speziell dafür strukturiert, KI-Agenten und Entwicklern klare, praxiserprobte Vorgaben zur Integration unserer zentralen Dienste bereitzustellen: ---
- **[Lizenzsystem-Integration (Hardware-ID v2)](./LICENSE_INTEGRATION_GUIDE.md)**: Hardware-Anbindung, Lizenzschlüssel-Validierung, verschlüsselter Offline-Cache (`LLS2`), CLI-Befehle und Multi-Plattform-Betrieb (Windows & Linux / Docker). ## Zuerst lesen
- **[Watchdog-Integration (Heartbeat & Telemetrie)](./WATCHDOG_INTEGRATION_GUIDE.md)**: Überwachung von Anwendungen, Diensten und Infrastruktur-Knoten via Ping-API, Agent-Tokens und automatisiertem Heartbeat.
| Dokument | Wofür |
|---|---|
| **[UPGRADE.md](./UPGRADE.md)** | **Ablaufplan für die Umstellung auf 2.0.** Enthält Pflichtschritte: Zugangsdaten wechseln, Migration, Evaluator-Cron. |
| [Agent-Prompt-Vorlage](./AGENT_PROMPT_TEMPLATE.md) | Textbaustein für `CLAUDE.md` / `AGENTS.md` eines Projekts |
| [Agenten-Handbuch](../public/docs/bugtracker.md) | Vollständige Beschreibung des Bugtracker-Workflows, öffentlich unter `/docs/` |
## Modul-Handbücher
- **[Lizenzsystem (Hardware-ID v2)](./LICENSE_INTEGRATION_GUIDE.md)** — Hardware-Anbindung, Schlüsselvalidierung, Offline-Cache, CLI, Windows und Linux/Docker
- **[Watchdog (Heartbeat & Telemetrie)](./WATCHDOG_INTEGRATION_GUIDE.md)** — Überwachung von Anwendungen, Diensten und Infrastruktur
- **[UpdateService](./UPDATESERVICE_INTEGRATION_GUIDE.md)** — Release-Verteilung und Update-Prüfung
- **[Bugtracker](./BUGTRACKER_INTEGRATION_GUIDE.md)** — Anbindung aus Anwendungen heraus
--- ---
## Modulübersicht ## Modulübersicht
| Modul | Hauptaufgabe | Endpunkte | .NET Client SDK | | Modul | Aufgabe | Endpunkte | Authentifizierung |
| :--- | :--- | :--- | :--- | |---|---|---|---|
| **Lizenzen** | Lizenzprüfung, Hardware-ID v2, Offline-Cache | `/api/license/v1/validate`<br>`/api/license/v1/deactivate` | `Deploymentcenter.Client` (`LicenseClient`, `HardwareId`) | | **Bugtracker** | Fehler, Feature Requests und Ideen; Agenten-Workflow mit Claim/Lease | `/api/bugtracker/v1/report`<br>`/api/bugtracker/v1/projects`<br>`/api/bugtracker/v1/manage` | Token mit `bugtracker:*` |
| **Watchdog** | Heartbeat-Monitoring, Status & Alerting | `/api/watchdog/v1/ping` | `HttpClient` + Header `X-Agent-Token` | | **UpdateService** | Release-Verteilung, semantischer Versionsvergleich | `/api/updateservice/v1/check`<br>`/api/updateservice/v1/publish` | Lesen offen, Publish braucht `updateservice:publish` |
| **UpdateService** | Automatic Software Release Checks | `/api/updateservice/v1/check` | `HttpClient` GET Request | | **Watchdog** | Heartbeat-Monitoring, Zustandsbewertung, Alarmierung | `/api/watchdog/v1/ping`<br>`/api/watchdog/v1/evaluate` | Token mit `watchdog:ping` |
| **Lizenzen** | Lizenzprüfung, Hardware-ID v2, Offline-Cache | `/api/license/v1/validate`<br>`/api/license/v1/deactivate` | Validierung offen, Deaktivierung authentifiziert |
| **Tokens** | Selbst-Provisionierung von Sub-Tokens | `/api/tokens/v1/provision` | Master-Token |
| **System** | Verfügbarkeit, Schema-Status, Schnittstellenbeschreibung | `/api/health`<br>`/api/openapi.json` | Health optional, OpenAPI offen |
---
## Schnittstelle maschinenlesbar
```
GET https://dc.mhdf.de/api/openapi.json
```
Ein Agent kann sich daran selbst orientieren — der früher fest im WebUI
hinterlegte Textblock entfällt damit.
---
## Antwortformat
Alle JSON-Endpunkte antworten einheitlich:
```json
{ "status": "success", "…": "…" }
```
```json
{ "status": "error", "error": { "code": "already_claimed", "message": "…" } }
```
Der `code` ist stabil und für Programme gedacht; die `message` richtet sich an
Menschen und kann sich ändern.
---
## Betrieb
| Aufgabe | Befehl |
|---|---|
| Deployment | `python scripts/deploy.py` |
| Migration | `php public/install_db.php` oder WebUI → System → DB-Migration |
| Evaluator (Cron, minütlich) | `curl -fsS -H "Authorization: Bearer <SHARED_KEY>" https://dc.mhdf.de/api/watchdog/v1/evaluate` |
| Zustand prüfen | `curl https://dc.mhdf.de/api/health` |
| Logs | `var/log/dc-<datum>.log` auf dem Server |
## Aufbau
```
config/ Zugangsdaten (nicht versioniert), Vorlage in config.example.php
src/ Anwendungscode, PSR-4 unter dem Namensraum Deploymentcenter\
Core/ Bootstrap, Konfiguration, DB, Auth, CSRF, HTTP, Tokens, Migrator
Modules/ Bugtracker, License, UpdateService, Watchdog, Notify
public/ Webroot-Inhalte: WebUI, API-Endpunkte, öffentliche Dokumentation
sql/ Schema und Migrationen (fortlaufend nummeriert)
var/log/ Laufzeitprotokolle
scripts/ Deployment
```
Neue Klassen werden automatisch geladen, sobald sie dem Namensraum-Pfad
entsprechen — eine `require`-Zeile ist nicht mehr nötig.
+7
View File
@@ -1,5 +1,12 @@
# Deploymentcenter — UpdateService Integration & Deployment Guide # Deploymentcenter — UpdateService Integration & Deployment Guide
> **⚠️ Geändert in Version 2.0** — Das Veröffentlichen eines Releases läuft jetzt
> über `POST /api/updateservice/v1/publish` und verlangt ein Token mit dem Scope
> `updateservice:publish` (zuvor völlig ungeschützt). Der Versionsvergleich folgt
> jetzt der semantischen Versionsordnung, `1.10.0` gilt also korrekt als neuer
> als `1.9.0`. Umstellungsschritte: **[UPGRADE.md](./UPGRADE.md)**.
Das **UpdateService-Modul** des Deploymentcenters bietet ein unternehmensweites, leichtgewichtiges Update-, Rollback- und Reparatur-Schema auf Basis eines LEMP-Stacks (Nginx Static Files + PHP API). Das **UpdateService-Modul** des Deploymentcenters bietet ein unternehmensweites, leichtgewichtiges Update-, Rollback- und Reparatur-Schema auf Basis eines LEMP-Stacks (Nginx Static Files + PHP API).
--- ---
+196
View File
@@ -0,0 +1,196 @@
# Umstellung auf Version 2.0 — Ablaufplan
Diese Fassung enthält Sicherheitskorrekturen, die das Verhalten der
Schnittstellen ändern. Bitte in dieser Reihenfolge vorgehen.
---
## 1. Vor dem Deployment: Zugangsdaten wechseln
`Serverdaten.txt`, `config/config.php`, `config/.htpasswd` und
`scripts/deploy_config.json` lagen im Git-Repository. Sie sind jetzt per
`.gitignore` ausgeschlossen und aus dem Index entfernt — **die Git-Historie
enthält sie aber weiterhin**. Alle betroffenen Zugangsdaten sind daher als
kompromittiert zu behandeln:
- [ ] MySQL-Passwort ändern, danach in `config/config.php` eintragen
- [ ] FTP-Passwort ändern, danach in `scripts/deploy_config.json` eintragen
- [ ] `.htpasswd`-Passwort für `deploy` neu setzen
- [ ] Git-Token `eb429575…` widerrufen und neu ausstellen
- [ ] Admin-Passwort im WebUI ändern (die alte Fassung setzte es bei jedem
Aufruf von `install_db.php` auf `Admin1337!` zurück — jeder im Internet
konnte das auslösen)
Wenn die Historie bereinigt werden soll, geht das mit
[`git filter-repo`](https://github.com/newren/git-filter-repo). Das schreibt
alle Commit-Hashes um; bei einem Repository mit mehreren Nutzern vorher abstimmen.
---
## 2. Konfiguration ergänzen
`config/config.php` braucht drei neue Schlüssel unter `security`. Die
mitgelieferte Datei enthält bereits erzeugte Werte; für eine neue Installation:
```bash
cp config/config.example.php config/config.php
openssl rand -hex 32 # je einmal für shared_key, webhook_key, license_key
```
| Schlüssel | Zweck |
|---|---|
| `security.shared_key` | Server-zu-Server-Aufrufe: Evaluator-Cron, Migration, Deaktivierung |
| `security.webhook_key` | HMAC-Signatur ausgehender Webhooks |
| `security.license_key` | Signatur der Offline-Lizenzdateien (`.lic`) |
| `app.debug` | Auf Produktivsystemen `false` — steuert, ob Exception-Texte ausgeliefert werden |
> Der bisherige `shared_key` (`DC_MASTER_SECURE_TOKEN_2026_x98f`) stand im
> Repository und wurde ersetzt. Wer ihn irgendwo eingetragen hat — etwa für
> `/api/license/v1/deactivate` — muss den neuen Wert nachziehen.
---
## 3. Deployment
```bash
python scripts/deploy.py
```
Das Skript überträgt unter anderem die neuen Verzeichnisse `var/` (Logs) und
die zusätzlichen `.htaccess`-Dateien in `config/`, `src/` und `sql/`.
---
## 4. Migration ausführen
Im WebUI anmelden, dann **System → DB-Migration → Migration jetzt ausführen**.
Alternativ über die Kommandozeile:
```bash
php public/install_db.php
```
Oder mit dem Shared Key:
```bash
curl -H "Authorization: Bearer <SHARED_KEY>" https://dc.mhdf.de/install_db.php
```
Die Migration ist additiv und legt an bzw. korrigiert:
- `dc_migrations` — vermerkt angewendete Versionen, damit nichts doppelt läuft
- `dc_login_attempts` — Drosselung fehlgeschlagener Anmeldungen
- `dc_webhooks` — ausgehende Benachrichtigungen
- Bugtracker: Claim/Lease, `client_ref`, `dedup_key`, Code-Kontextfelder, Indizes
- Watchdog: `last_state_change_utc`, `down_since_utc`, Zustand `unknown`
- **Reparatur von Migration 005** — deren Spalten (`push_id`, `target_agent`,
`tags`) fehlten bisher auf Datenbanken, die aus Migration 004 stammen. Die
alte Fassung nutzte dynamisches SQL, dessen Semikolons in String-Literalen
vom damaligen Installer als Statement-Ende gelesen wurden; die Fehler wurden
stillschweigend verschluckt.
- **Reparatur der Token-Hashes** — die Validierung vergleicht jetzt nur noch
den SHA-256-Hash. Die geseedeten Beispiel-Tokens trugen Hashes, die nicht zu
ihrem Klartext passten; sie werden korrigiert, damit bestehende Tokens
weiterhin funktionieren.
---
## 5. Cron für den Watchdog-Evaluator einrichten
**Ohne diesen Schritt sind die Monitor-Zustände wertlos.** Der Evaluator fehlte
bisher vollständig — der Zustand änderte sich nur beim Eintreffen eines
Heartbeats, ein ausgefallener Server blieb dauerhaft grün.
```bash
* * * * * curl -fsS -H "Authorization: Bearer <SHARED_KEY>" https://dc.mhdf.de/api/watchdog/v1/evaluate > /dev/null
```
Solange der Job fehlt, zeigt das WebUI oben einen Warnhinweis mit einer
Schaltfläche für einen einmaligen Lauf.
---
## 6. Agenten-Tokens ausstellen
Die Ingest-Endpunkte verlangen jetzt zwingend ein Token.
1. WebUI → **Token-Verwaltung → Master-Token erstellen**
2. Scopes wählen (für einen Coding-Agenten: `bugtracker:report`,
`bugtracker:read`, `bugtracker:manage`)
3. Master-Token einmalig kopieren und auf dem Agenten-Rechner als `DC_TOKEN`
hinterlegen — oder den Agenten per `/api/tokens/v1/provision` ein eigenes
Sub-Token ziehen lassen
---
## 7. Bestehende Integrationen anpassen
| Betroffen | Was zu tun ist |
|---|---|
| Aufrufe von `/api/bugtracker/v1/report` ohne Token | Token-Header ergänzen |
| Aufrufe von `/api/bugtracker/v1/projects` ohne Token | Token-Header ergänzen |
| Skripte, die Releases veröffentlichen | Token mit `updateservice:publish` ergänzen |
| Auswertung der Antworten | Neues Format: `{"status":"success",…}` bzw. `{"status":"error","error":{"code":…}}` |
| Watchdog-Agenten mit `wd_live_…`-Token | Laufen unverändert weiter |
| Clients, die `/api/updateservice/v1/check` aufrufen | Unverändert, weiterhin ohne Token |
| Clients, die `/api/license/v1/validate` aufrufen | Unverändert, weiterhin ohne Token |
---
## 8. Zeitzonen
Datenbankzeitstempel liegen jetzt durchgängig in **UTC**; das WebUI rechnet für
die Anzeige in `app.timezone` (Europe/Berlin) um. Vorhandene Datensätze wurden
in Serverzeit geschrieben und erscheinen daher einmalig um den Zeitzonenversatz
verschoben. Für Monitoring-Daten ist das ohne Bedeutung, für den Audit-Log
gegebenenfalls beachten.
---
## 9. Prüfen, ob alles läuft
```bash
curl https://dc.mhdf.de/api/health -H "Authorization: Bearer <SHARED_KEY>"
```
Erwartet wird `"healthy": true`, eine leere `schema.pending`-Liste und ein
`checks.evaluator.ok` von `true`.
Zusätzlich stichprobenartig im WebUI prüfen:
- [ ] Anmeldung funktioniert
- [ ] Projekt anlegen und wieder löschen
- [ ] Monitor bearbeiten — das Feld **Betriebssystem** bleibt nach dem
Speichern erhalten (wurde zuvor bei jedem Speichern geleert)
- [ ] Bugtracker: „🔍 Details" öffnet den Dialog, „✔" öffnet den
Lösen-Dialog (dessen HTML fehlte bisher komplett)
- [ ] Token widerrufen und löschen (warf zuvor `HY093`)
- [ ] Lizenz-Aktivierung freigeben (warf zuvor `Class "Audit" not found`)
- [ ] Nach dem Speichern F5 drücken — es entsteht kein zweiter Eintrag mehr
---
## 10. Optional: Webhooks
Ereignisgesteuerte Benachrichtigung statt Polling. Ziel direkt in der Datenbank
eintragen:
```sql
INSERT INTO dc_webhooks (name, url, project_slug, events, secret, enabled)
VALUES ('Telegram Alarm', 'https://n8n.example.com/webhook/dc',
NULL, 'bug.critical,monitor.down', 'geheimnis', 1);
```
Verfügbare Ereignisse: `bug.created`, `bug.critical`, `bug.resolved`,
`feature.created`, `monitor.down`, `monitor.recovered`, `release.published`,
oder `*` für alle.
Jede Zustellung trägt eine Signatur:
```
X-DC-Timestamp: 1754563200
X-DC-Signature: sha256=<hex(hmac_sha256(secret, timestamp + "." + body))>
```
Nach 20 Fehlversuchen in Folge deaktiviert sich ein Webhook selbst.
+9
View File
@@ -1,5 +1,14 @@
# Deploymentcenter — Watchdog Integration für KI-Agenten # Deploymentcenter — Watchdog Integration für KI-Agenten
> **⚠️ Geändert in Version 2.0** — Neu ist der Evaluator unter
> `GET /api/watchdog/v1/evaluate`, der per Cron minütlich laufen muss. Ohne ihn
> ändert sich der Zustand eines Monitors nur beim Eintreffen eines Heartbeats,
> ein ausgefallenes System bliebe dauerhaft `up`. `expected_interval_sec`,
> `is_muted` und `suppress_until_utc` werden jetzt ausgewertet. Neben den
> bisherigen `wd_live_`-Tokens werden auch zentrale Tokens mit dem Scope
> `watchdog:ping` akzeptiert. Umstellungsschritte: **[UPGRADE.md](./UPGRADE.md)**.
> **Zielgruppe**: KI-Agenten & Softwareentwickler > **Zielgruppe**: KI-Agenten & Softwareentwickler
> **Zweck**: Einbindung von Heartbeat-Monitoring, Statusmeldungen und Telemetrie in Anwendungen & Serverdienste. > **Zweck**: Einbindung von Heartbeat-Monitoring, Statusmeldungen und Telemetrie in Anwendungen & Serverdienste.
+302 -197
View File
@@ -1,229 +1,334 @@
<?php <?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); declare(strict_types=1);
require_once __DIR__ . '/../../../../../src/Core/Auth.php'; require_once __DIR__ . '/../../../../../src/bootstrap.php';
require_once __DIR__ . '/../../../../../src/Core/Db.php';
require_once __DIR__ . '/../../../../../src/Core/TokenManager.php';
require_once __DIR__ . '/../../../../../src/Modules/Bugtracker/BugRepo.php';
use Deploymentcenter\Core\Auth; use Deploymentcenter\Core\ApiAuth;
use Deploymentcenter\Core\Db; use Deploymentcenter\Core\Db;
use Deploymentcenter\Core\TokenManager; use Deploymentcenter\Core\Http;
use Deploymentcenter\Modules\Bugtracker\BugRepo; use Deploymentcenter\Modules\Bugtracker\BugRepo;
header('Content-Type: application/json; charset=utf-8'); Http::beginJson(['GET', 'POST', 'OPTIONS'], true);
try { const READ_ACTIONS = ['list', 'get', 'stats', 'projects'];
$config = require __DIR__ . '/../../../../../config/config.php'; const WRITE_ACTIONS = ['claim', 'next', 'release', 'comment', 'status', 'update', 'resolve', 'bulk_update'];
$db = Db::connect($config['db']);
// Authenticate Request (Session OR Token) $db = Db::init();
$isAuthenticated = false; $repo = new BugRepo($db);
$authorName = 'admin';
if (Auth::isLoggedIn()) { $action = resolveAction();
$isAuthenticated = true; $method = Http::method();
$authorName = $_SESSION['dc_username'] ?? 'admin';
} else { if (!in_array($action, READ_ACTIONS, true) && !in_array($action, WRITE_ACTIONS, true)) {
$headers = getallheaders(); Http::fail(404, 'unknown_action', sprintf('Unbekannte Aktion "%s".', $action), null, [
$token = $headers['X-Agent-Token'] ?? $headers['x-agent-token'] ?? null; 'available' => array_merge(READ_ACTIONS, WRITE_ACTIONS),
if (!$token && !empty($headers['Authorization'])) { ]);
if (preg_match('/Bearer\s+(.+)/i', $headers['Authorization'], $matches)) { }
$token = trim($matches[1]);
} $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) { $meta = Http::input('meta');
$tokenMgr = new TokenManager($db); $created = $repo->addComment(
$tokenInfo = $tokenMgr->validateToken($token, 'bugtracker:manage'); $itemId,
if ($tokenInfo) { $author,
$isAuthenticated = true; $comment,
$authorName = 'agent:' . ($tokenInfo['name'] ?? $tokenInfo['token_id']); 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) { return resolveItemId() > 0 ? 'get' : 'list';
http_response_code(401); }
echo json_encode(['status' => 'error', 'message' => 'Unauthorized: Valid Session or Bearer Token with scope bugtracker:manage required']);
exit; /** 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); if (Http::method() === 'POST') {
$body = Http::body();
$uri = $_SERVER['REQUEST_URI']; if (isset($body['id']) && is_numeric($body['id'])) {
$method = $_SERVER['REQUEST_METHOD']; return (int)$body['id'];
}
$rawInput = file_get_contents('php://input'); if (isset($body['item_id']) && is_numeric($body['item_id'])) {
$input = json_decode($rawInput, true) ?: $_POST; return (int)$body['item_id'];
}
// 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];
} }
// Sub-actions if (preg_match('#/items/(\d+)#', Http::path(), $m) === 1) {
if ($action === 'projects' || str_contains($path, '/projects')) { return (int)$m[1];
echo json_encode(['status' => 'success', 'projects' => $repo->getProjects()], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
exit;
} }
if ($action === 'stats' || str_ends_with($path, '/stats')) { return 0;
echo json_encode(['status' => 'success', 'stats' => $repo->getStats()], JSON_PRETTY_PRINT); }
exit;
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')) { $item = $repo->getItemDetails($itemId);
if ($method !== 'POST') { if ($item === null) {
http_response_code(405); Http::fail(404, 'not_found', sprintf('Item #%d existiert nicht.', $itemId));
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;
} }
if ($action === 'comment' || str_contains($path, '/comments')) { ApiAuth::enforceProject($context, (string)$item['project_slug']);
if ($method !== 'POST') { }
http_response_code(405);
echo json_encode(['status' => 'error', 'message' => 'POST required for comment']);
exit;
}
if (!$itemId) { /**
http_response_code(400); * Sammelt Filter aus Query und Body.
echo json_encode(['status' => 'error', 'message' => 'Missing item ID']); *
exit; * @return array<string,mixed>
} */
function collectFilters(?string $boundProject): array
$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
$filters = [ $filters = [
'project_slug' => $_GET['project_slug'] ?? $_GET['project'] ?? 'all', 'project_slug' => Http::str('project_slug') ?? Http::str('project') ?? 'all',
'environment' => $_GET['environment'] ?? $_GET['env'] ?? 'all', 'environment' => Http::str('environment') ?? Http::str('env') ?? 'all',
'type' => $_GET['type'] ?? 'all', 'type' => Http::str('type') ?? 'all',
'status' => $_GET['status'] ?? 'all', 'status' => Http::str('status') ?? 'all',
'severity' => $_GET['severity'] ?? 'all', 'severity' => Http::str('severity') ?? 'all',
'push_id' => $_GET['push_id'] ?? '', 'push_id' => Http::str('push_id') ?? '',
'target_agent' => $_GET['target_agent'] ?? $_GET['agent'] ?? '', 'target_agent' => Http::str('target_agent') ?? Http::str('agent') ?? '',
'search' => $_GET['search'] ?? $_GET['q'] ?? '', '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); if (Http::input('unclaimed_only') !== null) {
echo json_encode([ $filters['unclaimed_only'] = filter_var(Http::input('unclaimed_only'), FILTER_VALIDATE_BOOLEAN);
'status' => 'success', }
'count' => count($items),
'filters' => $filters,
'items' => $items,
], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
} catch (Throwable $t) { // Ein projektgebundenes Token kann den Projektfilter nicht umgehen.
http_response_code(500); if ($boundProject !== null) {
echo json_encode(['status' => 'error', 'message' => 'Manage API Error: ' . $t->getMessage()]); $filters['project_slug'] = $boundProject;
}
return $filters;
} }
+43 -30
View File
@@ -1,44 +1,57 @@
<?php <?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); declare(strict_types=1);
require_once __DIR__ . '/../../../../src/Core/Db.php'; require_once __DIR__ . '/../../../../src/bootstrap.php';
require_once __DIR__ . '/../../../../src/Modules/Bugtracker/BugRepo.php';
use Deploymentcenter\Core\ApiAuth;
use Deploymentcenter\Core\Db; use Deploymentcenter\Core\Db;
use Deploymentcenter\Core\Http;
use Deploymentcenter\Modules\Bugtracker\BugRepo; use Deploymentcenter\Modules\Bugtracker\BugRepo;
header('Content-Type: application/json; charset=utf-8'); Http::beginJson(['GET', 'OPTIONS'], true);
header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Headers: Content-Type, Authorization, X-Agent-Token');
header('Access-Control-Allow-Methods: GET, OPTIONS');
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') { if (Http::method() !== 'GET') {
http_response_code(200); Http::fail(405, 'method_not_allowed', 'Dieser Endpunkt erwartet GET.');
exit;
} }
try { $db = Db::init();
$config = require __DIR__ . '/../../../../config/config.php'; $context = ApiAuth::requireScope($db, 'bugtracker:read');
$db = Db::connect($config['db']);
$repo = new BugRepo($db); $repo = new BugRepo($db);
$projects = $repo->getProjects(); $projects = $repo->getProjects();
echo json_encode([ // Ein projektgebundenes Token sieht nur sein eigenes Projekt.
'status' => 'success', $bound = ApiAuth::projectFilter($context);
'count' => count($projects), if ($bound !== null) {
'projects' => array_map(function($p) { $projects = array_values(array_filter(
return [ $projects,
'id' => (int)$p['id'], static fn(array $p): bool => (string)$p['slug'] === $bound
'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()]);
} }
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 <?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); declare(strict_types=1);
require_once __DIR__ . '/../../../../src/Core/Db.php'; require_once __DIR__ . '/../../../../src/bootstrap.php';
require_once __DIR__ . '/../../../../src/Core/TokenManager.php';
require_once __DIR__ . '/../../../../src/Modules/Bugtracker/BugRepo.php';
use Deploymentcenter\Core\ApiAuth;
use Deploymentcenter\Core\Config;
use Deploymentcenter\Core\Db; use Deploymentcenter\Core\Db;
use Deploymentcenter\Core\TokenManager; use Deploymentcenter\Core\Http;
use Deploymentcenter\Modules\Bugtracker\BugRepo; 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 if (Http::method() !== 'POST') {
header('Access-Control-Allow-Origin: *'); Http::fail(405, 'method_not_allowed', 'Dieser Endpunkt erwartet POST.');
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 ($_SERVER['REQUEST_METHOD'] !== 'POST') { $db = Db::init();
http_response_code(405);
echo json_encode(['status' => 'error', 'message' => 'Method Not Allowed']); // Drosselung, damit ein Agent in einer Fehlerschleife den Tracker nicht flutet.
exit; $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 = Http::body();
$data = json_decode($rawInput, true) ?: $_POST; if ($data === []) {
Http::fail(400, 'empty_body', 'Der Request-Body ist leer.');
if (empty($data)) {
http_response_code(400);
echo json_encode(['status' => 'error', 'message' => 'Empty request body or invalid JSON']);
exit;
} }
try { $environment = is_string($data['environment'] ?? null) ? $data['environment'] : null;
$config = require __DIR__ . '/../../../../config/config.php';
$db = Db::connect($config['db']);
// Optional Token Verification (if provided) // Session ist hier bewusst nicht erlaubt: dieser Endpunkt ist die
$headers = getallheaders(); // Maschinenschnittstelle. Das WebUI legt Items ueber index.php an.
$token = $headers['X-Agent-Token'] ?? $headers['x-agent-token'] ?? null; $context = ApiAuth::requireScope($db, 'bugtracker:report', $environment, false);
if (!$token && !empty($headers['Authorization'])) {
if (preg_match('/Bearer\s+(.+)/i', $headers['Authorization'], $matches)) { $projectSlug = is_string($data['project_slug'] ?? null) ? trim($data['project_slug']) : null;
$token = trim($matches[1]); 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 <?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); declare(strict_types=1);
header('Content-Type: application/json; charset=utf-8'); require_once __DIR__ . '/../../../../src/bootstrap.php';
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';
use Deploymentcenter\Core\ApiAuth;
use Deploymentcenter\Core\Db; use Deploymentcenter\Core\Db;
use Deploymentcenter\Core\Auth; use Deploymentcenter\Core\Http;
use Deploymentcenter\Modules\License\LicenseService; use Deploymentcenter\Modules\License\LicenseService;
use Deploymentcenter\Modules\License\RateLimiter; use Deploymentcenter\Modules\License\RateLimiter;
function sendResponse(array $data, int $statusCode = 200): void { Http::beginJson(['GET', 'POST', 'OPTIONS'], true);
http_response_code($statusCode);
echo json_encode($data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT); $db = Db::init();
exit;
$ip = Http::clientIp();
$limiter = new RateLimiter($db, 120, 60, 'license');
if (!$limiter->check($ip)) {
Http::fail(429, 'rate_limited', 'Zu viele Anfragen.');
} }
try { $service = new LicenseService($db);
$config = require __DIR__ . '/../../../../config/config.php'; $action = resolveLicenseAction();
$pdo = Db::init($config);
$limiter = new RateLimiter($pdo, 120, 60); switch ($action) {
$ip = $_SERVER['REMOTE_ADDR'] ?? '127.0.0.1';
if (!$limiter->check($ip)) {
sendResponse(['error' => 'Too Many Requests', 'message' => 'Rate limit exceeded.'], 429);
}
$uri = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH); case 'validate':
$method = strtoupper($_SERVER['REQUEST_METHOD'] ?? 'GET'); if (Http::method() !== 'POST') {
Http::fail(405, 'method_not_allowed', 'Diese Aktion erwartet POST.');
$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);
} }
// 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'] ?? ''; case 'deactivate':
$isAuthenticated = ($authHeader && hash_equals($sharedKey, $authHeader)) || Auth::isLoggedIn(); if (Http::method() !== 'POST') {
Http::fail(405, 'method_not_allowed', 'Diese Aktion erwartet POST.');
if (!$isAuthenticated) {
sendResponse([
'error' => 'Unauthorized',
'message' => 'Authentication required for license deactivation. Pass Bearer token or master key.'
], 401);
} }
ApiAuth::requireScope($db, 'license:deactivate');
Http::ok(['result' => $service->deactivate(Http::body(), $ip)]);
$res = $licenseService->deactivate($inputData, $ip); case 'status':
sendResponse($res); Http::ok(['module' => 'license', 'version' => '2.0']);
}
// Status Endpoint default:
if (str_ends_with($uri, '/status') && $method === 'GET') { Http::fail(404, 'unknown_action', 'Endpunkt nicht gefunden.', null, [
sendResponse(['status' => 'ok', 'module' => 'Lizenzen', 'version' => '1.0']); '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) { return match ($last) {
sendResponse(['error' => 'Server Error', 'message' => $t->getMessage()], 500); '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 <?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); declare(strict_types=1);
require_once __DIR__ . '/../../../../src/Core/Db.php'; require_once __DIR__ . '/../../../../src/bootstrap.php';
require_once __DIR__ . '/../../../../src/Core/TokenManager.php';
use Deploymentcenter\Core\Db; use Deploymentcenter\Core\Db;
use Deploymentcenter\Core\Http;
use Deploymentcenter\Core\Logger;
use Deploymentcenter\Core\TokenManager; 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') { if (Http::method() !== 'POST') {
http_response_code(405); Http::fail(405, 'method_not_allowed', 'Dieser Endpunkt erwartet POST.');
echo json_encode(['status' => 'error', 'message' => 'Method Not Allowed']);
exit;
} }
// Extract Master Token from Headers $db = Db::init();
$headers = getallheaders();
$masterToken = $headers['X-Master-Token'] ?? $headers['x-master-token'] ?? null;
if (!$masterToken && !empty($headers['Authorization'])) { // Provisionierung ist selten - eine enge Drosselung verhindert das
if (preg_match('/Bearer\s+(.+)/i', $headers['Authorization'], $matches)) { // Durchprobieren von Master-Tokens.
$masterToken = trim($matches[1]); $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'); $masterToken = Http::bearerToken();
$data = json_decode($rawInput, true) ?: $_POST; if ($masterToken === null) {
Http::fail(
if (!$masterToken && !empty($data['master_token'])) { 401,
$masterToken = trim($data['master_token']); 'missing_master_token',
'Master-Token fehlt. Erwartet im Header X-Master-Token oder als Authorization: Bearer <token>.'
);
} }
if (!$masterToken) { $data = Http::body();
http_response_code(401);
echo json_encode(['status' => 'error', 'message' => 'Missing Master Token in X-Master-Token header or Authorization Bearer header']); $name = Http::str('client_name') ?? Http::str('name') ?? 'Auto-provisioniertes Sub-Token';
exit; $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 { try {
$config = require __DIR__ . '/../../../../config/config.php'; $subToken = $manager->provisionSubToken(
$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(
$masterToken, $masterToken,
$name, $name,
$instanceIdentity, $instanceIdentity,
$requestedScopes, $requestedScopes,
$environment $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) { } catch (InvalidArgumentException $e) {
http_response_code(400); Logger::warning('Provisionierung abgelehnt', [
echo json_encode(['status' => 'error', 'message' => $e->getMessage()]); 'ip' => Http::clientIp(),
} catch (Throwable $t) { 'reason' => $e->getMessage(),
http_response_code(500); ]);
echo json_encode(['status' => 'error', 'message' => 'Internal server error: ' . $t->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 <?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); declare(strict_types=1);
header('Content-Type: application/json; charset=utf-8'); require_once __DIR__ . '/../../../../src/bootstrap.php';
require_once __DIR__ . '/../../../../src/Core/Db.php';
require_once __DIR__ . '/../../../../src/Modules/UpdateService/UpdateManager.php';
use Deploymentcenter\Core\ApiAuth;
use Deploymentcenter\Core\Db; use Deploymentcenter\Core\Db;
use Deploymentcenter\Core\Http;
use Deploymentcenter\Modules\License\RateLimiter;
use Deploymentcenter\Modules\UpdateService\UpdateManager; use Deploymentcenter\Modules\UpdateService\UpdateManager;
use Deploymentcenter\Modules\UpdateService\Version;
function sendResponse(array $data, int $statusCode = 200): void { Http::beginJson(['GET', 'POST', 'OPTIONS'], true);
http_response_code($statusCode);
echo json_encode($data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT); $db = Db::init();
exit;
$limiter = new RateLimiter($db, 240, 60, 'updateservice');
if (!$limiter->check(Http::clientIp())) {
Http::fail(429, 'rate_limited', 'Zu viele Anfragen.');
} }
try { $manager = new UpdateManager($db);
$config = require __DIR__ . '/../../../../config/config.php'; $action = resolveUpdateAction();
$pdo = Db::init($config);
$uri = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH); switch ($action) {
$method = strtoupper($_SERVER['REQUEST_METHOD'] ?? 'GET');
$updateMgr = new UpdateManager($pdo); case 'check':
$product = Http::str('product') ?? Http::str('product_slug');
// Read JSON body for POST requests if available if ($product === null) {
$inputData = []; Http::fail(400, 'missing_product', 'Der Parameter "product" wird benoetigt.');
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);
} }
$ok = $updateMgr->addRelease($product, $version, $channel, $notes, $url, $hash, $gitCommit, $sizeBytes, null, $isCritical); $current = Http::str('version') ?? Http::str('current_version') ?? '0.0.0';
if ($ok) { $channel = Http::str('channel') ?? 'prod';
sendResponse(['status' => 'success', 'message' => "Release v{$version} published for {$product} ({$channel})."]);
} else {
sendResponse(['error' => 'Database Error', 'message' => 'Failed to store release.'], 500);
}
}
if (str_ends_with($uri, '/check') && ($method === 'GET' || $method === 'POST')) { $latest = $manager->checkUpdate($product, $current, $channel);
$product = $_REQUEST['product'] ?? $_REQUEST['product_slug'] ?? '';
$version = $_REQUEST['version'] ?? $_REQUEST['current_version'] ?? '0.0.0';
$channel = $_REQUEST['channel'] ?? 'prod';
if (empty($product)) { if ($latest === null) {
sendResponse(['error' => 'Bad Request', 'message' => 'Parameter "product" is required.'], 400); $installed = $manager->latestRelease($product, $channel);
} Http::ok([
$latest = $updateMgr->checkUpdate($product, $version, $channel);
if ($latest) {
sendResponse([
'update_available' => true,
'latest_release' => $latest
]);
} else {
sendResponse([
'update_available' => false, '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') { Http::ok([
$product = $_GET['product'] ?? null; 'update_available' => true,
$channel = $_GET['channel'] ?? null; 'current_version' => $current,
$releases = $updateMgr->getReleases($product, $channel); 'latest_version' => $latest['version'],
sendResponse(['count' => count($releases), 'releases' => $releases]); '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) { $release = $manager->latestRelease($product, Http::str('channel') ?? 'prod');
sendResponse(['error' => 'Server Error', 'message' => $t->getMessage()], 500); 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 <?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); declare(strict_types=1);
header('Content-Type: application/json; charset=utf-8'); require_once __DIR__ . '/../../../../src/bootstrap.php';
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';
use Deploymentcenter\Core\ApiAuth;
use Deploymentcenter\Core\Db; 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\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::beginJson(['GET', 'POST', 'OPTIONS'], true);
http_response_code($statusCode);
echo json_encode($data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
exit;
}
try { $db = Db::init();
$config = require __DIR__ . '/../../../../config/config.php';
$pdo = Db::init($config);
$uri = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH); $monitorRepo = new MonitorRepo($db);
$method = strtoupper($_SERVER['REQUEST_METHOD'] ?? 'GET'); $eventLog = new EventLog($db);
$authHeader = $_SERVER['HTTP_X_WATCHDOG_KEY'] ?? $_SERVER['HTTP_AUTHORIZATION'] ?? $_SERVER['HTTP_X_AGENT_TOKEN'] ?? null; $action = resolveWatchdogAction();
if ($authHeader && str_starts_with($authHeader, 'Bearer ')) {
$authHeader = substr($authHeader, 7);
}
$sharedKey = $config['security']['shared_key'] ?? ''; switch ($action) {
$isAdminAuth = ($authHeader && hash_equals($sharedKey, $authHeader));
$tokenManager = new TokenManager($pdo); case 'ping':
$monitorRepo = new MonitorRepo($pdo); requirePost();
$eventLog = new EventLog($pdo);
$verifyToken = function(string $source) use ($isAdminAuth, $authHeader, $tokenManager) { $source = Http::str('source');
if ($isAdminAuth) return true; if ($source === null) {
if (empty($authHeader)) { Http::fail(400, 'missing_source', 'Das Feld "source" wird benoetigt.');
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);
} }
$verifyToken($source); authorizeSource($db, $source);
$monitor = $monitorRepo->upsertHeartbeat($source, $instance, $type, $interval, $metrics, $status, $message, $groupKey, $os); $monitor = $monitorRepo->upsertHeartbeat(
sendResponse([ $source,
'status' => 'success', Http::str('instance') ?? 'default',
'message' => 'Heartbeat received', 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' => [ 'monitor' => [
'source' => $monitor['source'], 'source' => $monitor['source'],
'instance' => $monitor['instance'], 'instance' => $monitor['instance'],
'state' => $monitor['state'], 'state' => $monitor['state'],
'last_status' => $monitor['last_status'], 'last_status' => $monitor['last_status'],
'last_seen_utc' => $monitor['last_seen_utc'], 'last_seen_utc' => $monitor['last_seen_utc'],
] 'state_changed' => (bool)($monitor['_state_changed'] ?? false),
],
]); ]);
}
// Log Event case 'event':
if (str_ends_with($uri, '/event') && $method === 'POST') { requirePost();
$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;
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);
$eventId = $eventLog->logEvent($source, $instance, $kind, null, null, $severity, $message, $meta);
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 Http::ok(['event_id' => $eventId], 201);
if (str_ends_with($uri, '/status') && $method === 'GET') {
case 'status':
ApiAuth::requireScope($db, 'watchdog:read');
$monitors = $monitorRepo->getAllMonitors(); $monitors = $monitorRepo->getAllMonitors();
sendResponse(['count' => count($monitors), 'monitors' => $monitors]); Http::ok(['count' => count($monitors), 'monitors' => $monitors]);
}
// Events auflisten case 'events':
if (str_ends_with($uri, '/events') && $method === 'GET') { ApiAuth::requireScope($db, 'watchdog:read');
$limit = (int)($_GET['limit'] ?? 50); $events = $eventLog->getRecentEvents(
$events = $eventLog->getRecentEvents($limit); Http::int('limit', 50),
sendResponse(['count' => count($events), 'events' => $events]); 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) { $result = Evaluator::run($db);
sendResponse(['error' => 'Server Error', 'message' => $t->getMessage()], 500); 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: | Änderung | Auswirkung |
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. | `POST /api/bugtracker/v1/report` verlangt jetzt zwingend ein Token | Aufrufe ohne Token liefern `401 unauthorized` |
3. **Feature & Idea Backlog**: Submit roadmap ideas (`severity: "idea"`) or backlog items (`severity: "wishlist"`). | `GET /api/bugtracker/v1/projects` verlangt jetzt ein Token | dito |
4. **Active Workflow Management**: Fetch active bugs assigned to your agent ID, update status (`in_progress`, `resolved`), and append diagnostic comments. | `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` ```
Authorization: Bearer dc_sub_xxxxxxxxxxxx
Header: `Authorization: Bearer <MASTER_TOKEN>` X-Agent-Token: dc_sub_xxxxxxxxxxxx
#### 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
}
``` ```
#### Response: ### Token-Hierarchie
```json
{ * **Master-Token** (`dc_master_…`) — wird im WebUI unter *Token-Verwaltung* erzeugt.
"status": "success", Langlebig, gehört auf den Rechner bzw. in die CI, nicht in ein Repository.
"token_id": "tok_s_8912ab", * **Sub-Token** (`dc_sub_…`) — erzeugt sich ein Agent selbst aus dem Master-Token.
"raw_token": "dc_sub_myapp_refactor_agent_991", Rechte lassen sich dabei nur **einschränken**, nie erweitern.
"scopes": ["bugtracker:report", "bugtracker:manage"],
"environment": "development", ### Sub-Token anfordern
"expires_at": "2026-08-07 21:00:00"
} ```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 ```json
{ {
"status": "success", "status": "success",
"count": 4, "count": 4,
"projects": [ "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", "slug": "deploymentcenter",
"name": "Deployment Center", "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>` "repo_url": "https://git.example.com/me/myapp.git",
"git_branch": "main",
### A. Reporting an Unhandled Exception / Bug "commit_sha": "a21536f",
```json "file_path": "src/Core/UserAuthService.cs",
{ "line_no": 42
"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"
}
``` ```
### B. Submitting a Feature Request or Quick Reminder Idea (`severity: "idea"`) ### Felder
```json
{ | Feld | Pflicht | Bedeutung |
"project_slug": "myapp", |---|---|---|
"type": "feature_request", | `title` | ja | Kurze Beschreibung, max. 255 Zeichen |
"title": "Automatische Datenbank-Backups vor FTP Deployments", | `project_slug` | empfohlen | Aus der Projektliste; Vorgabe `default` |
"description": "Gedanke für später: Vor jedem FTP-Deployment automatisch mysqldump ausführen und im Server-Archiv ablegen.", | `type` | nein | `bug` (Vorgabe) oder `feature_request` |
"build_version": "v1.6.0-roadmap", | `severity` | nein | `idea`, `wishlist`, `low`, `medium` (Vorgabe), `high`, `critical` |
"environment": "development", | `environment` | nein | `production` (Vorgabe), `development`, `staging`, `testing` |
"severity": "idea", | `client_ref` | empfohlen | Idempotenz-Schlüssel, alternativ Header `Idempotency-Key` |
"push_id": "push_wf_9910", | `repo_url`, `git_branch`, `commit_sha`, `file_path`, `line_no` | empfohlen | Code-Kontext — spart dem nächsten Agenten das Parsen des Stacktrace |
"target_agent": "agent:db-optimizer", | `context` | nein | Beliebiges JSON-Objekt für Zusatzinformationen |
"tags": "database, automation, backup", | `push_id`, `target_agent`, `tags` | nein | Workflow-Zuordnung |
"created_by": "agent:planner"
} `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 ```json
{ {
"status": "success", "status": "success",
"item_id": 4, "item_id": 42,
"is_new": true, "is_new": true,
"idempotent_hit": false,
"occurrence_count": 1, "occurrence_count": 1,
"error_hash": "e2c918a514d89a42f", "dedup_key": "e2c918a514d89a42f...",
"type": "bug", "item_status": "open",
"environment": "development", "regression_of": null,
"push_id": "push_wf_8912", "message": "Bug erfasst."
"message": "New bug reported successfully."
} }
``` ```
--- ---
## 📌 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 Ein Aufruf, der die nächsten offenen Items liefert **und** exklusiv für dich
```http reserviert — damit arbeiten nicht zwei Agenten am selben Bug:
GET /api/bugtracker/v1/manage/index.php?project_slug=myapp&status=open&agent=agent:code-fixer-01
```
### 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 ```bash
curl -X POST "https://dc.mhdf.de/api/bugtracker/v1/report.php" \ curl -X POST "https://dc.mhdf.de/api/bugtracker/v1/manage?action=next" \
-H "Authorization: Bearer dc_sub_myapp_agent_live_001" \ -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" \ -H "Content-Type: application/json" \
-d '{ -d '{
"project_slug": "myapp", "comment": "Ursache gefunden: Session wird vor dem Redirect nicht initialisiert.",
"type": "feature_request", "action_taken": "investigated"
"title": "Erweiterte Filterung im WebUI Dashboard", }'
"severity": "idea", ```
"push_id": "push_task_1029",
"tags": "ui, dashboard", Empfohlene Werte für `action_taken`: `investigated`, `fix_proposed`,
"created_by": "agent:dev-assistant" `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. ```bash
2. **Use `severity: "idea"` for thoughts**: When noticing potential refactorings or future improvements during coding, log them immediately as ideas. curl "https://dc.mhdf.de/api/bugtracker/v1/manage?action=list&project_slug=myapp&status=open,in_progress&order=severity&limit=20" \
3. **Comment before resolving**: Before calling `action=resolve`, write a diagnostic comment explaining **why** and **how** the fix was performed. -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"> <div class="header-bar">
<h1>🚀 Deployment Center API & Agent Documentation</h1> <h1>🚀 Deployment Center API & Agent Documentation</h1>
<div> <div>
<a href="bugtracker.md" target="_blank" class="btn">📄 Raw Markdown (.md)</a> <a href="bugtracker.md" target="_blank" rel="noopener" class="btn">📄 Rohtext (.md)</a>
<a href="../api/bugtracker/v1/projects.php" target="_blank" class="btn">📂 Projects API</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> </div>
<div id="docContent">Loading documentation...</div> <div id="docContent">Loading documentation...</div>
+1609 -734
View File
File diff suppressed because it is too large Load Diff
+136 -100
View File
@@ -1,117 +1,153 @@
<?php <?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); declare(strict_types=1);
error_reporting(E_ALL); require_once __DIR__ . '/../src/bootstrap.php';
ini_set('display_errors', '1');
header('Content-Type: application/json; charset=utf-8');
try { use Deploymentcenter\Core\ApiAuth;
$config = require __DIR__ . '/../config/config.php'; use Deploymentcenter\Core\Config;
$dbCfg = $config['db']; 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']); $isCli = PHP_SAPI === 'cli';
$pdo = new PDO($dsn, $dbCfg['username'], $dbCfg['password'], [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]);
$sqlFile = __DIR__ . '/../sql/schema.sql'; if (!$isCli) {
if (!file_exists($sqlFile)) { Http::beginJson(['GET', 'POST', 'OPTIONS']);
echo json_encode(['status' => 'error', 'message' => 'schema.sql file not found']); }
exit;
} $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); if ($result['failed'] !== null) {
Logger::error('Migration abgebrochen', $result['failed']);
// 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);
// Split queries by semicolon $payload = [
$queries = array_filter(array_map('trim', explode(';', $cleanSql))); '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; if ($isCli) {
foreach ($queries as $q) { fwrite(STDERR, "Migration fehlgeschlagen:\n" . json_encode($payload, JSON_PRETTY_PRINT) . "\n");
if (!empty($q)) { exit(1);
$pdo->exec($q);
$executed++;
}
} }
// Execute migration files in sql/migrations/ Http::fail(500, 'migration_failed', 'Die Migration wurde abgebrochen.', null, $payload);
$migrationDir = __DIR__ . '/../sql/migrations'; }
if (is_dir($migrationDir)) {
$files = glob($migrationDir . '/*.sql'); // --- Administrator nur anlegen, wenn noch keiner existiert ---
sort($files); $adminNotice = null;
foreach ($files as $mFile) { $userCount = (int)$db->query('SELECT COUNT(*) FROM dc_users')->fetchColumn();
$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
}
}
}
}
}
// Create / update Admin user: admin / Admin1337! if ($userCount === 0) {
$adminUsername = 'admin'; $username = 'admin';
$adminPassword = 'Admin1337!'; $password = generateInitialPassword();
$passwordHash = password_hash($adminPassword, PASSWORD_ARGON2ID);
$stmt = $pdo->prepare(' $stmt = $db->prepare('
INSERT INTO dc_users (username, password_hash, created_at) INSERT INTO dc_users (username, password_hash, created_at)
VALUES (:u, :p, NOW()) VALUES (:username, :hash, UTC_TIMESTAMP())
ON DUPLICATE KEY UPDATE password_hash = VALUES(password_hash)
'); ');
$stmt->execute([':u' => $adminUsername, ':p' => $passwordHash]); $stmt->execute([':username' => $username, ':hash' => password_hash($password, PASSWORD_DEFAULT)]);
// Seed default endpoints setting in dc_settings Logger::info('Initialer Administrator angelegt', ['username' => $username]);
$endpoints = json_encode([
'validate' => '/api/license/v1/validate', $adminNotice = [
'deactivate' => '/api/license/v1/deactivate', 'username' => $username,
]); 'password' => $password,
$stmtSet = $pdo->prepare('INSERT INTO dc_settings (skey, svalue) VALUES ("endpoints", :v) ON DUPLICATE KEY UPDATE svalue = VALUES(svalue)'); 'warning' => 'Dieses Passwort wird nur ein einziges Mal angezeigt. Bitte sofort notieren und nach der ersten Anmeldung aendern.',
$stmtSet->execute([':v' => $endpoints]); ];
} else {
// Query created tables to verify $adminNotice = [
$tables = $pdo->query('SHOW TABLES')->fetchAll(PDO::FETCH_COLUMN); 'message' => sprintf(
'%d Benutzerkonto(en) vorhanden - es wurde keines angelegt und keines veraendert.',
echo json_encode([ $userCount
'status' => 'success', ),
'message' => "Successfully executed {$executed} SQL statements!", ];
'created_user' => 'admin', }
'tables_in_db' => $tables,
'timestamp' => date('Y-m-d H:i:s') // --- Standard-Endpunkte hinterlegen ---
], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); $endpoints = json_encode([
'validate' => '/api/license/v1/validate',
} catch (Throwable $t) { 'deactivate' => '/api/license/v1/deactivate',
http_response_code(500); ], JSON_UNESCAPED_SLASHES);
echo json_encode([
'status' => 'error', $db->prepare('
'message' => $t->getMessage(), INSERT INTO dc_settings (skey, svalue) VALUES ("endpoints", :value)
'file' => $t->getFile(), ON DUPLICATE KEY UPDATE svalue = VALUES(svalue)
'line' => $t->getLine() ')->execute([':value' => $endpoints]);
], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
$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); declare(strict_types=1);
require_once __DIR__ . '/../src/Core/Db.php'; require_once __DIR__ . '/../src/bootstrap.php';
require_once __DIR__ . '/../src/Core/Auth.php';
use Deploymentcenter\Core\Db;
use Deploymentcenter\Core\Auth; use Deploymentcenter\Core\Auth;
use Deploymentcenter\Core\Csrf;
use Deploymentcenter\Core\Db;
use Deploymentcenter\Core\Http;
use Deploymentcenter\Core\Logger;
Auth::startSession(); Auth::startSession();
@@ -18,25 +20,38 @@ if (Auth::isLoggedIn()) {
$error = null; $error = null;
if ($_SERVER['REQUEST_METHOD'] === 'POST') { if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$username = trim($_POST['username'] ?? ''); $username = trim((string)($_POST['username'] ?? ''));
$password = trim($_POST['password'] ?? ''); $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 { try {
$config = require __DIR__ . '/../config/config.php'; $pdo = Db::init();
$pdo = Db::init($config); $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'); header('Location: /index.php');
exit; exit;
} else { } 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) { } 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; ?> <?php endif; ?>
<form method="POST" action="login.php"> <form method="POST" action="login.php">
<?= Csrf::field() ?>
<div class="form-group"> <div class="form-group">
<label for="username" class="form-label">Benutzername</label> <label for="username" class="form-label">Benutzername</label>
<input type="text" id="username" name="username" class="form-control" required autofocus placeholder="z. B. admin"> <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); declare(strict_types=1);
require_once __DIR__ . '/../src/Core/Auth.php'; require_once __DIR__ . '/../src/bootstrap.php';
use Deploymentcenter\Core\Auth; use Deploymentcenter\Core\Auth;
Auth::logout(); Auth::logout();
header('Location: /login.php'); header('Location: /login.php');
exit; exit;
+98 -33
View File
@@ -19,7 +19,12 @@ IGNORE_PATTERNS = {
'Serverdaten.txt', 'Serverdaten.txt',
'Serverdaten.txt.bak', 'Serverdaten.txt.bak',
'bin', 'bin',
'obj' 'obj',
# Laufzeitdaten gehoeren dem Server, nicht dem Arbeitsplatz. var/.htaccess
# wird dennoch uebertragen, damit das Verzeichnis existiert und gesperrt ist.
'log',
'__pycache__',
'client-dotnet',
} }
def load_config(): def load_config():
@@ -76,6 +81,70 @@ def should_ignore(rel_path):
return True return True
return False return False
def collect_changes(cache):
"""Ermittelt alle Dateien, die sich seit dem letzten Deployment geaendert haben."""
files_to_upload = []
for root, dirs, files in os.walk(PROJECT_DIR):
rel_dir = os.path.relpath(root, PROJECT_DIR)
if rel_dir == '.':
rel_dir = ''
dirs[:] = [d for d in dirs if not should_ignore(os.path.join(rel_dir, d))]
for f in files:
rel_file = os.path.normpath(os.path.join(rel_dir, f)).replace('\\', '/')
if should_ignore(rel_file):
continue
full_path = os.path.join(root, f)
file_hash = compute_hash(full_path)
if cache.get(rel_file) != file_hash:
files_to_upload.append((rel_file, full_path, file_hash))
files_to_upload.sort(key=upload_priority)
return files_to_upload
def upload_priority(entry):
"""
Reihenfolge des Uploads.
Abhaengigkeiten zuerst: waere public/index.php schon oben, waehrend
src/bootstrap.php noch fehlt, liefe die Seite in der Zwischenzeit in einen
Fehler. Die .htaccess im Wurzelverzeichnis kommt zuletzt, damit neue Routen
erst greifen, wenn ihre Zielskripte vorhanden sind.
"""
rel_file = entry[0]
if rel_file == '.htaccess':
rank = 4
elif rel_file.startswith('public/'):
rank = 3
elif rel_file.startswith(('src/', 'config/', 'sql/', 'var/')):
rank = 1
else:
rank = 2
return (rank, rel_file)
def dry_run():
"""Zeigt an, was uebertragen wuerde, ohne eine Verbindung aufzubauen."""
cache = load_cache()
changes = collect_changes(cache)
if not changes:
print("Keine Aenderungen - der Server ist auf dem aktuellen Stand.")
return
total = sum(os.path.getsize(p) for _, p, _ in changes)
print(f"{len(changes)} Datei(en) wuerden uebertragen ({total / 1024:.1f} KB):\n")
for rel_file, full_path, _ in changes:
marker = 'NEU' if rel_file not in cache else ' '
print(f" {marker} {rel_file} ({os.path.getsize(full_path) / 1024:.1f} KB)")
def deploy(): def deploy():
config = load_config() config = load_config()
cache = load_cache() cache = load_cache()
@@ -97,24 +166,7 @@ def deploy():
print(f"FTP Connection failed: {e}") print(f"FTP Connection failed: {e}")
sys.exit(1) sys.exit(1)
files_to_upload = [] files_to_upload = collect_changes(cache)
for root, dirs, files in os.walk(PROJECT_DIR):
rel_dir = os.path.relpath(root, PROJECT_DIR)
if rel_dir == '.':
rel_dir = ''
dirs[:] = [d for d in dirs if not should_ignore(os.path.join(rel_dir, d))]
for f in files:
rel_file = os.path.normpath(os.path.join(rel_dir, f)).replace('\\', '/')
if should_ignore(rel_file):
continue
full_path = os.path.join(root, f)
file_hash = compute_hash(full_path)
if cache.get(rel_file) != file_hash:
files_to_upload.append((rel_file, full_path, file_hash))
if not files_to_upload: if not files_to_upload:
print("No changed files to upload. Remote is up to date!") print("No changed files to upload. Remote is up to date!")
@@ -125,21 +177,34 @@ def deploy():
for rel_file, full_path, file_hash in files_to_upload: for rel_file, full_path, file_hash in files_to_upload:
print(f" -> {rel_file}") print(f" -> {rel_file}")
for rel_file, full_path, file_hash in files_to_upload: uploaded = 0
remote_file_path = f"/{rel_file}" try:
remote_dir = os.path.dirname(remote_file_path).replace('\\', '/') for rel_file, full_path, file_hash in files_to_upload:
if remote_dir and remote_dir != '/': remote_file_path = f"/{rel_file}"
ensure_remote_dir(ftp, remote_dir) remote_dir = os.path.dirname(remote_file_path).replace('\\', '/')
if remote_dir and remote_dir != '/':
ensure_remote_dir(ftp, remote_dir)
ftp.cwd('/') ftp.cwd('/')
print(f"Uploading {rel_file} ...") print(f"Uploading {rel_file} ...")
with open(full_path, 'rb') as f: with open(full_path, 'rb') as f:
ftp.storbinary(f"STOR {remote_file_path}", f) ftp.storbinary(f"STOR {remote_file_path}", f)
new_cache[rel_file] = file_hash
save_cache(new_cache) new_cache[rel_file] = file_hash
ftp.quit() uploaded += 1
print("Deployment completed successfully!") finally:
# Der Cache wird auch bei einem Abbruch geschrieben. Sonst beginnt der
# naechste Lauf wieder bei null und ueberträgt alles erneut.
save_cache(new_cache)
try:
ftp.quit()
except Exception:
pass
print(f"\nDeployment abgeschlossen: {uploaded} von {len(files_to_upload)} Datei(en) uebertragen.")
if __name__ == '__main__': if __name__ == '__main__':
deploy() if '--dry-run' in sys.argv or '-n' in sys.argv:
dry_run()
else:
deploy()
+8
View File
@@ -0,0 +1,8 @@
{
"_comment": "Kopie als deploy_config.json anlegen und ausfuellen. deploy_config.json ist per .gitignore ausgeschlossen.",
"host": "ftp.example.com",
"port": 21,
"user": "ftp-user",
"pass": "ftp-password",
"secure": true
}
-7
View File
@@ -1,7 +0,0 @@
{
"host": "www531.your-server.de",
"port": 21,
"user": "bergisnu_4",
"pass": "o2#M*NN^5EsT",
"secure": true
}
+10
View File
@@ -0,0 +1,10 @@
# Kein Direktzugriff auf Schema- und Migrationsdateien.
<IfModule mod_authz_core.c>
Require all denied
</IfModule>
<IfModule !mod_authz_core.c>
Order allow,deny
Deny from all
</IfModule>
@@ -1,60 +1,33 @@
-- Migration 005: Bugtracker Push ID, Target Agent, Tags, and Extended Severities (idea, wishlist) -- Migration 005: Bugtracker Push-ID, Ziel-Agent, Tags und erweiterte Schweregrade
-- Non-destructive migration --
-- HINWEIS ZUR UEBERARBEITUNG:
-- Die urspruengliche Fassung nutzte PREPARE/EXECUTE mit dynamischem SQL, um
-- Spalten bedingt anzulegen. Die darin enthaltenen Semikolons steckten in
-- String-Literalen und wurden vom damaligen explode(';')-Installer als
-- Statement-Ende missverstanden. Ergebnis: die Migration schlug still fehl,
-- die Spalten push_id/target_agent/tags fehlten und jeder Bug-Report lief in
-- "Unknown column 'push_id'".
--
-- Jetzt plain SQL. Der Migrator toleriert gezielt "Spalte existiert bereits"
-- (Fehlercode 1060) und "Index existiert bereits" (1061), sodass diese
-- Migration auch auf einer bereits teilweise migrierten Datenbank durchlaeuft.
SET FOREIGN_KEY_CHECKS = 0;
-- 1. Extend ENUM severity column if needed or modify column definition
ALTER TABLE bugtracker_items ALTER TABLE bugtracker_items
MODIFY COLUMN severity ENUM('idea', 'wishlist', 'low', 'medium', 'high', 'critical') NOT NULL DEFAULT 'medium'; MODIFY COLUMN severity ENUM('idea', 'wishlist', 'low', 'medium', 'high', 'critical') NOT NULL DEFAULT 'medium';
-- 2. Add push_id column if not exists ALTER TABLE bugtracker_items ADD COLUMN push_id VARCHAR(128) NULL AFTER occurrence_count;
SET @exist_push_id := ( ALTER TABLE bugtracker_items ADD COLUMN target_agent VARCHAR(100) NULL AFTER push_id;
SELECT COUNT(*) ALTER TABLE bugtracker_items ADD COLUMN tags VARCHAR(255) NULL AFTER target_agent;
FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'bugtracker_items'
AND COLUMN_NAME = 'push_id'
);
SET @sql_push_id := IF(@exist_push_id = 0, 'ALTER TABLE bugtracker_items ADD COLUMN push_id VARCHAR(128) NULL AFTER occurrence_count, ADD KEY ix_bt_push_id (push_id);', 'SELECT 1;');
PREPARE stmt FROM @sql_push_id;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-- 3. Add target_agent column if not exists ALTER TABLE bugtracker_items ADD KEY ix_bt_push_id (push_id);
SET @exist_target_agent := (
SELECT COUNT(*)
FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'bugtracker_items'
AND COLUMN_NAME = 'target_agent'
);
SET @sql_target_agent := IF(@exist_target_agent = 0, 'ALTER TABLE bugtracker_items ADD COLUMN target_agent VARCHAR(100) NULL AFTER push_id;', 'SELECT 1;');
PREPARE stmt FROM @sql_target_agent;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-- 4. Add tags column if not exists -- Projekt fuer das Deploymentcenter selbst. Kein Demo-Datensatz, sondern das
SET @exist_tags := ( -- Projekt, unter dem die Plattform ihre eigenen Fehler meldet.
SELECT COUNT(*) INSERT INTO dc_projects (slug, name, notes, default_cache_ttl_hours) VALUES
FROM INFORMATION_SCHEMA.COLUMNS ('deploymentcenter', 'Deployment Center', 'Zentrale Verwaltungs- & Update-Plattform', 168)
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'bugtracker_items'
AND COLUMN_NAME = 'tags'
);
SET @sql_tags := IF(@exist_tags = 0, 'ALTER TABLE bugtracker_items ADD COLUMN tags VARCHAR(255) NULL AFTER target_agent;', 'SELECT 1;');
PREPARE stmt FROM @sql_tags;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET FOREIGN_KEY_CHECKS = 1;
-- Seed Deployment Center Project
INSERT INTO dc_projects (id, slug, name, notes, default_cache_ttl_hours) VALUES
(4, 'deploymentcenter', 'Deployment Center', 'Zentrale Verwaltungs- & Update-Plattform', 168)
ON DUPLICATE KEY UPDATE name = VALUES(name); ON DUPLICATE KEY UPDATE name = VALUES(name);
-- Seed Sample Feature Idea -- Der frueher hier stehende Beispiel-Eintrag (bugtracker_items id 4) ist
INSERT IGNORE INTO bugtracker_items ( -- entfallen. Er wurde mit fester ID eingefuegt und waere dadurch bei jedem
id, project_slug, type, title, description, error_message, stack_trace, error_hash, build_version, environment, severity, status, occurrence_count, push_id, target_agent, tags, created_by -- Migrationslauf auf einer produktiv genutzten Datenbank wieder aufgetaucht,
) VALUES -- nachdem man ihn geloescht hat.
(4, 'myapp', 'feature_request', 'Automatische Datenbank-Backups vor Deployments', 'Idee für später: Vor jedem FTP-Deployment automatisch einen Mysqldump ausführen und im Server-Archiv ablegen.', NULL, NULL, NULL, 'v1.6.0-idea', 'development', 'idea', 'open', 1, 'push_notify_99182', 'agent:dev-monitor-01', 'database,automation', 'user:richard');
+123
View File
@@ -0,0 +1,123 @@
-- Migration 006: Agenten-Workflow, Webhooks, Login-Drosselung, Reparaturen
--
-- Additive Migration. Der Migrator toleriert 1050/1060/1061/1062 ("existiert
-- bereits"), sodass sie auf einer teilweise migrierten Datenbank durchlaeuft.
-- ---------------------------------------------------------------------------
-- 1. Anmeldeversuche (Brute-Force-Drosselung fuer login.php)
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS dc_login_attempts (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
ip VARBINARY(16) NOT NULL,
username VARCHAR(64) NULL,
success TINYINT(1) NOT NULL DEFAULT 0,
attempted_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
KEY ix_login_ip_time (ip, attempted_at),
KEY ix_login_time (attempted_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- ---------------------------------------------------------------------------
-- 2. Bugtracker: Agenten-Workflow
-- ---------------------------------------------------------------------------
-- Exklusive Bearbeitung (Claim/Lease): verhindert, dass zwei Agenten
-- gleichzeitig am selben Item arbeiten.
ALTER TABLE bugtracker_items ADD COLUMN claimed_by VARCHAR(100) NULL AFTER target_agent;
ALTER TABLE bugtracker_items ADD COLUMN claimed_at DATETIME NULL AFTER claimed_by;
ALTER TABLE bugtracker_items ADD COLUMN lease_until DATETIME NULL AFTER claimed_at;
-- Idempotenz: ein Agent, der wegen Timeout erneut sendet, erzeugt kein Duplikat.
ALTER TABLE bugtracker_items ADD COLUMN client_ref VARCHAR(128) NULL AFTER tags;
-- Dedup-Schluessel auch fuer Feature Requests und Ideen (nicht nur Bugs).
ALTER TABLE bugtracker_items ADD COLUMN dedup_key VARCHAR(64) NULL AFTER error_hash;
-- Strukturierter Code-Kontext: ein Agent kann direkt an die Stelle springen,
-- statt den Stacktrace parsen zu muessen.
ALTER TABLE bugtracker_items ADD COLUMN repo_url VARCHAR(255) NULL AFTER build_version;
ALTER TABLE bugtracker_items ADD COLUMN git_branch VARCHAR(120) NULL AFTER repo_url;
ALTER TABLE bugtracker_items ADD COLUMN commit_sha VARCHAR(64) NULL AFTER git_branch;
ALTER TABLE bugtracker_items ADD COLUMN file_path VARCHAR(400) NULL AFTER commit_sha;
ALTER TABLE bugtracker_items ADD COLUMN line_no INT NULL AFTER file_path;
ALTER TABLE bugtracker_items ADD COLUMN context_json JSON NULL AFTER line_no;
-- Verknuepfung zum Release, das den Fehler behoben hat.
ALTER TABLE bugtracker_items ADD COLUMN resolved_release_id INT NULL AFTER resolved_in_build;
-- Wiederauftreten nach Behebung: Verweis auf das urspruengliche Item.
ALTER TABLE bugtracker_items ADD COLUMN regression_of BIGINT NULL AFTER resolved_release_id;
ALTER TABLE bugtracker_items ADD UNIQUE KEY uq_bt_client_ref (project_slug, client_ref);
ALTER TABLE bugtracker_items ADD KEY ix_bt_dedup (dedup_key);
ALTER TABLE bugtracker_items ADD KEY ix_bt_updated (updated_at);
ALTER TABLE bugtracker_items ADD KEY ix_bt_status (status, severity);
ALTER TABLE bugtracker_items ADD KEY ix_bt_agent (target_agent, status);
ALTER TABLE bugtracker_items ADD KEY ix_bt_lease (lease_until);
-- ---------------------------------------------------------------------------
-- 3. Projekte: Repository-Bezug fuer Agenten
-- ---------------------------------------------------------------------------
ALTER TABLE dc_projects ADD COLUMN repo_url VARCHAR(255) NULL AFTER notes;
ALTER TABLE dc_projects ADD COLUMN default_agent VARCHAR(100) NULL AFTER repo_url;
ALTER TABLE dc_projects ADD COLUMN is_active TINYINT(1) NOT NULL DEFAULT 1 AFTER default_agent;
-- ---------------------------------------------------------------------------
-- 4. Webhooks (ausgehende Benachrichtigungen)
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS dc_webhooks (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(100) NOT NULL,
url VARCHAR(500) NOT NULL,
project_slug VARCHAR(64) NULL,
events VARCHAR(500) NOT NULL DEFAULT 'bug.created,bug.critical,monitor.down',
secret VARCHAR(128) NULL,
enabled TINYINT(1) NOT NULL DEFAULT 1,
last_status VARCHAR(50) NULL,
last_error TEXT NULL,
last_fired_at DATETIME NULL,
failure_count INT NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
KEY ix_webhook_project (project_slug, enabled)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- ---------------------------------------------------------------------------
-- 5. Watchdog: Zustandswechsel nachvollziehbar machen
-- ---------------------------------------------------------------------------
ALTER TABLE watchdog_monitors ADD COLUMN last_state_change_utc DATETIME NULL AFTER state;
ALTER TABLE watchdog_monitors ADD COLUMN down_since_utc DATETIME NULL AFTER last_state_change_utc;
-- 'unknown' als Anfangszustand, bevor je ein Heartbeat eintraf.
ALTER TABLE watchdog_monitors
MODIFY COLUMN state ENUM('up','warning','down','error','stopped','maintenance','unknown')
NOT NULL DEFAULT 'unknown';
-- Der Evaluator laeuft als eigener Cron-Job.
INSERT INTO watchdog_cron_jobs (name, interval_sec, enabled) VALUES
('evaluator', 60, 1)
ON DUPLICATE KEY UPDATE interval_sec = VALUES(interval_sec);
-- ---------------------------------------------------------------------------
-- 6. Reparatur: Token-Hashes
-- ---------------------------------------------------------------------------
-- Die Validierung vergleicht ab sofort ausschliesslich den SHA-256-Hash und
-- nicht mehr zusaetzlich den Klartext. Die geseedeten Beispiel-Tokens trugen
-- Hashes, die nicht zu ihrem raw_token passten - diese werden hier korrigiert,
-- damit bestehende Tokens weiter funktionieren.
UPDATE dc_tokens
SET token_hash = SHA2(raw_token, 256)
WHERE raw_token IS NOT NULL
AND raw_token <> ''
AND token_hash <> SHA2(raw_token, 256);
UPDATE watchdog_agent_tokens
SET token_hash = SHA2(raw_token, 256)
WHERE raw_token IS NOT NULL
AND raw_token <> ''
AND token_hash <> SHA2(raw_token, 256);
-- ---------------------------------------------------------------------------
-- 7. Konsistenz: resolved_at fuer bereits geloeste Items nachtragen
-- ---------------------------------------------------------------------------
UPDATE bugtracker_items
SET resolved_at = COALESCE(updated_at, last_seen_at, created_at)
WHERE status = 'resolved' AND resolved_at IS NULL;
+10
View File
@@ -0,0 +1,10 @@
# Kein Direktzugriff auf Anwendungscode.
<IfModule mod_authz_core.c>
Require all denied
</IfModule>
<IfModule !mod_authz_core.c>
Order allow,deny
Deny from all
</IfModule>
+160
View File
@@ -0,0 +1,160 @@
<?php
declare(strict_types=1);
namespace Deploymentcenter\Core;
use PDO;
/**
* Einheitliche Authentifizierung fuer alle API-Endpunkte.
*
* Drei akzeptierte Wege, in dieser Reihenfolge geprueft:
* 1. Shared Key aus der Konfiguration (Server-zu-Server, z. B. Cron)
* 2. Aktive WebUI-Session (nur wenn ausdruecklich erlaubt)
* 3. Master-/Sub-Token aus dc_tokens mit passendem Scope
*
* Frueher hatte jeder Endpunkt seine eigene Variante davon - mit jeweils
* leicht abweichendem Verhalten. Diese Klasse ersetzt alle.
*/
final class ApiAuth
{
/**
* Erzwingt Authentifizierung mit einem bestimmten Scope.
* Bricht die Anfrage bei Fehlschlag mit 401 ab.
*
* @return array{method:string,actor:string,token:?array,project_slug:?string,environment:?string}
*/
public static function requireScope(
PDO $db,
string $scope,
?string $environment = null,
bool $allowSession = true
): array {
$context = self::resolve($db, $scope, $environment, $allowSession);
if ($context === null) {
Http::fail(
401,
'unauthorized',
sprintf('Authentifizierung erforderlich. Erwartet wird ein Token mit dem Recht "%s".', $scope),
null,
['required_scope' => $scope]
);
}
return $context;
}
/**
* Wie requireScope(), bricht aber nicht ab, sondern liefert null.
*
* @return array{method:string,actor:string,token:?array,project_slug:?string,environment:?string}|null
*/
public static function resolve(
PDO $db,
string $scope,
?string $environment = null,
bool $allowSession = true
): ?array {
$presented = Http::bearerToken();
// 1. Shared Key
$sharedKey = (string)Config::get('security.shared_key', '');
if ($presented !== null && $sharedKey !== '' && hash_equals($sharedKey, $presented)) {
return [
'method' => 'shared_key',
'actor' => 'system:shared-key',
'token' => null,
'project_slug' => null,
'environment' => null,
];
}
// 2. WebUI-Session
if ($allowSession && Auth::isLoggedIn()) {
return [
'method' => 'session',
'actor' => Auth::username(),
'token' => null,
'project_slug' => null,
'environment' => null,
];
}
// 3. Agenten-Token
if ($presented !== null) {
$manager = new TokenManager($db);
$token = $manager->validateToken($presented, $scope, $environment);
if (is_array($token)) {
$name = isset($token['name']) && $token['name'] !== ''
? (string)$token['name']
: (string)$token['token_id'];
return [
'method' => 'token',
'actor' => 'agent:' . $name,
'token' => $token,
'project_slug' => self::stringOrNull($token['project_slug'] ?? null),
'environment' => self::stringOrNull($token['environment'] ?? null),
];
}
Logger::warning('Token abgelehnt', [
'scope' => $scope,
'ip' => Http::clientIp(),
'path' => Http::path(),
]);
}
return null;
}
/**
* Stellt sicher, dass ein projektgebundenes Token nur auf sein eigenes
* Projekt zugreift. Bricht sonst mit 403 ab.
*
* @param array{project_slug:?string,method:string} $context
*/
public static function enforceProject(array $context, ?string $requestedSlug): void
{
$bound = $context['project_slug'] ?? null;
if ($bound === null || $bound === '') {
return; // Token ist nicht projektgebunden
}
if ($requestedSlug === null || $requestedSlug === '' || $requestedSlug === $bound) {
return;
}
Http::fail(
403,
'project_forbidden',
sprintf('Dieses Token ist an das Projekt "%s" gebunden und darf nicht auf "%s" zugreifen.', $bound, $requestedSlug),
null,
['bound_project' => $bound]
);
}
/**
* Liefert den Projekt-Slug, auf den eine Anfrage eingeschraenkt werden muss,
* oder null bei uneingeschraenktem Zugriff.
*
* @param array{project_slug:?string} $context
*/
public static function projectFilter(array $context): ?string
{
$bound = $context['project_slug'] ?? null;
return is_string($bound) && $bound !== '' ? $bound : null;
}
private static function stringOrNull($value): ?string
{
if (!is_string($value)) {
return null;
}
return $value === '' ? null : $value;
}
}
+217 -19
View File
@@ -1,18 +1,55 @@
<?php <?php
declare(strict_types=1);
namespace Deploymentcenter\Core; namespace Deploymentcenter\Core;
use PDO; use PDO;
class Auth /**
* Session- und Anmeldeverwaltung fuer das WebUI.
*
* Haerteung gegenueber der Erstfassung:
* - Session-Cookie mit HttpOnly, SameSite=Lax und Secure (bei HTTPS)
* - session_regenerate_id() nach erfolgreicher Anmeldung (Session Fixation)
* - Leerlauf- und Absolut-Timeout
* - Anmeldeversuche werden gezaehlt und pro IP gedrosselt
*/
final class Auth
{ {
/** Leerlauf-Timeout in Sekunden (2 Stunden). */
private const IDLE_TIMEOUT = 7200;
/** Absolutes Session-Maximum in Sekunden (12 Stunden). */
private const ABSOLUTE_TIMEOUT = 43200;
/** Fehlversuche pro IP, bevor gesperrt wird. */
private const MAX_ATTEMPTS = 10;
/** Sperrfenster in Sekunden. */
private const LOCKOUT_WINDOW = 900;
public static function startSession(): void public static function startSession(): void
{ {
if (session_status() === PHP_SESSION_NONE) { if (session_status() !== PHP_SESSION_NONE) {
$config = require __DIR__ . '/../../config/config.php'; return;
session_name($config['security']['session_name'] ?? 'DC_SESSION_ID');
session_start();
} }
$secure = !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off';
session_set_cookie_params([
'lifetime' => 0,
'path' => '/',
'domain' => '',
'secure' => $secure,
'httponly' => true,
'samesite' => 'Lax',
]);
session_name((string)Config::get('security.session_name', 'DC_SESSION_ID'));
session_start();
self::enforceTimeouts();
} }
public static function isLoggedIn(): bool public static function isLoggedIn(): bool
@@ -23,39 +60,200 @@ class Auth
public static function requireLogin(): void public static function requireLogin(): void
{ {
if (!self::isLoggedIn()) { if (self::isLoggedIn()) {
header('Location: /login.php'); return;
exit;
} }
if (!headers_sent()) {
header('Location: /login.php');
}
exit;
} }
public static function username(): string
{
self::startSession();
$name = $_SESSION['dc_username'] ?? null;
return is_string($name) && $name !== '' ? $name : 'admin';
}
public static function userId(): int
{
self::startSession();
return (int)($_SESSION['dc_user_id'] ?? 0);
}
/**
* Prueft die Zugangsdaten und startet bei Erfolg eine frische Session.
*/
public static function login(PDO $db, string $username, string $password): bool public static function login(PDO $db, string $username, string $password): bool
{ {
self::startSession(); self::startSession();
$stmt = $db->prepare('SELECT id, username, password_hash FROM dc_users WHERE username = :u');
$ip = Http::clientIp();
if (self::isLockedOut($db, $ip)) {
Logger::warning('Anmeldung gesperrt (zu viele Fehlversuche)', ['ip' => $ip, 'username' => $username]);
return false;
}
$stmt = $db->prepare('SELECT id, username, password_hash FROM dc_users WHERE username = :u LIMIT 1');
$stmt->execute([':u' => $username]); $stmt->execute([':u' => $username]);
$user = $stmt->fetch(); $user = $stmt->fetch();
if ($user && password_verify($password, $user['password_hash'])) { $found = is_array($user) && isset($user['password_hash']);
$_SESSION['dc_user_id'] = $user['id']; // Auch ohne Treffer wird ein Hash berechnet, damit die Antwortzeit
$_SESSION['dc_username'] = $user['username']; // keinen Rueckschluss auf die Existenz des Kontos erlaubt.
return true; $hash = $found ? (string)$user['password_hash'] : self::dummyHash();
$verified = password_verify($password, $hash);
if (!$verified || !$found) {
self::recordAttempt($db, $ip, $username, false);
return false;
} }
return false; // Passwort-Hash bei Bedarf auf das aktuelle Verfahren heben.
if (password_needs_rehash($hash, PASSWORD_DEFAULT)) {
$upd = $db->prepare('UPDATE dc_users SET password_hash = :h WHERE id = :id');
$upd->execute([':h' => password_hash($password, PASSWORD_DEFAULT), ':id' => $user['id']]);
}
session_regenerate_id(true);
$_SESSION['dc_user_id'] = (int)$user['id'];
$_SESSION['dc_username'] = (string)$user['username'];
$_SESSION['dc_login_at'] = time();
$_SESSION['dc_last_seen'] = time();
self::recordAttempt($db, $ip, $username, true);
Logger::info('Anmeldung erfolgreich', ['username' => $user['username'], 'ip' => $ip]);
return true;
} }
public static function logout(): void public static function logout(): void
{ {
self::startSession(); self::startSession();
$_SESSION = []; $_SESSION = [];
if (ini_get("session.use_cookies")) {
if (ini_get('session.use_cookies')) {
$params = session_get_cookie_params(); $params = session_get_cookie_params();
setcookie(session_name(), '', time() - 42000, setcookie(session_name(), '', [
$params["path"], $params["domain"], 'expires' => time() - 42000,
$params["secure"], $params["httponly"] 'path' => $params['path'],
); 'domain' => $params['domain'],
'secure' => $params['secure'],
'httponly' => $params['httponly'],
'samesite' => 'Lax',
]);
} }
session_destroy(); session_destroy();
} }
/**
* Verbleibende Sperrzeit in Sekunden, oder 0 wenn nicht gesperrt.
*/
public static function lockoutSeconds(PDO $db, string $ip): int
{
try {
$stmt = $db->prepare('
SELECT COUNT(*) AS failures, MAX(attempted_at) AS last_attempt
FROM dc_login_attempts
WHERE ip = :ip
AND success = 0
AND attempted_at > (UTC_TIMESTAMP() - INTERVAL ' . self::LOCKOUT_WINDOW . ' SECOND)
');
$stmt->execute([':ip' => self::packIp($ip)]);
$row = $stmt->fetch();
if (!is_array($row) || (int)$row['failures'] < self::MAX_ATTEMPTS) {
return 0;
}
$last = isset($row['last_attempt']) ? strtotime((string)$row['last_attempt'] . ' UTC') : false;
if ($last === false) {
return self::LOCKOUT_WINDOW;
}
$remaining = self::LOCKOUT_WINDOW - (time() - $last);
return $remaining > 0 ? $remaining : 0;
} catch (\Throwable $e) {
// Fehlt die Tabelle (Migration noch nicht gelaufen), darf die
// Anmeldung nicht blockiert werden.
Logger::warning('Lockout-Pruefung nicht moeglich', ['error' => $e->getMessage()]);
return 0;
}
}
// ------------------------------------------------------------------
private static function isLockedOut(PDO $db, string $ip): bool
{
return self::lockoutSeconds($db, $ip) > 0;
}
private static function recordAttempt(PDO $db, string $ip, string $username, bool $success): void
{
try {
$stmt = $db->prepare('
INSERT INTO dc_login_attempts (ip, username, success, attempted_at)
VALUES (:ip, :username, :success, UTC_TIMESTAMP())
');
$stmt->execute([
':ip' => self::packIp($ip),
':username' => mb_substr($username, 0, 64),
':success' => $success ? 1 : 0,
]);
// Bei Erfolg die Fehlversuche dieser IP zuruecksetzen.
if ($success) {
$del = $db->prepare('DELETE FROM dc_login_attempts WHERE ip = :ip AND success = 0');
$del->execute([':ip' => self::packIp($ip)]);
}
} catch (\Throwable $e) {
Logger::warning('Anmeldeversuch nicht protokolliert', ['error' => $e->getMessage()]);
}
}
/** Gueltiger Hash gegen einen Zufallswert, nur fuer Timing-Angleichung. */
private static function dummyHash(): string
{
static $hash = null;
if ($hash === null) {
$hash = password_hash(bin2hex(random_bytes(16)), PASSWORD_DEFAULT);
}
return $hash;
}
private static function packIp(string $ip): string
{
$packed = @inet_pton($ip);
return $packed === false ? str_repeat("\0", 16) : $packed;
}
private static function enforceTimeouts(): void
{
if (empty($_SESSION['dc_user_id'])) {
return;
}
$now = time();
$loginAt = (int)($_SESSION['dc_login_at'] ?? $now);
$lastSeen = (int)($_SESSION['dc_last_seen'] ?? $now);
$expired = ($now - $lastSeen) > self::IDLE_TIMEOUT
|| ($now - $loginAt) > self::ABSOLUTE_TIMEOUT;
if ($expired) {
$_SESSION = [];
session_destroy();
session_start();
return;
}
$_SESSION['dc_last_seen'] = $now;
}
} }
+55
View File
@@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
namespace Deploymentcenter\Core;
/**
* Zugriff auf die geladene Konfiguration ueber Punktpfade, z. B. Config::get('db.host').
*/
final class Config
{
/** @var array<string,mixed> */
private static array $data = [];
/** @param array<string,mixed> $data */
public static function load(array $data): void
{
self::$data = $data;
}
/** @return array<string,mixed> */
public static function all(): array
{
return self::$data;
}
/**
* @param string $path Punktseparierter Pfad, z. B. "security.shared_key"
* @param mixed $default
* @return mixed
*/
public static function get(string $path, $default = null)
{
$current = self::$data;
foreach (explode('.', $path) as $segment) {
if (!is_array($current) || !array_key_exists($segment, $current)) {
return $default;
}
$current = $current[$segment];
}
return $current;
}
/** Liefert die Datenbank-Zugangsdaten als Array. */
public static function db(): array
{
$db = self::get('db', []);
return is_array($db) ? $db : [];
}
public static function isDebug(): bool
{
return (bool)self::get('app.debug', false);
}
}
+72
View File
@@ -0,0 +1,72 @@
<?php
declare(strict_types=1);
namespace Deploymentcenter\Core;
/**
* CSRF-Schutz fuer alle zustandsaendernden Formulare im WebUI.
*
* Das Token haengt an der Session und ist fuer deren Lebensdauer stabil.
* API-Endpunkte, die per Bearer-Token authentifizieren, brauchen keinen
* CSRF-Schutz - dort gibt es keinen Cookie, der automatisch mitgeschickt wird.
*/
final class Csrf
{
private const SESSION_KEY = 'dc_csrf_token';
public static function token(): string
{
Auth::startSession();
if (empty($_SESSION[self::SESSION_KEY]) || !is_string($_SESSION[self::SESSION_KEY])) {
$_SESSION[self::SESSION_KEY] = bin2hex(random_bytes(32));
}
return $_SESSION[self::SESSION_KEY];
}
/** Fertiges verstecktes Formularfeld. */
public static function field(): string
{
return '<input type="hidden" name="csrf_token" value="'
. htmlspecialchars(self::token(), ENT_QUOTES, 'UTF-8') . '">';
}
/** Prueft ein uebergebenes Token gegen die Session. */
public static function isValid(?string $candidate): bool
{
Auth::startSession();
$expected = $_SESSION[self::SESSION_KEY] ?? null;
if (!is_string($expected) || $expected === '' || !is_string($candidate) || $candidate === '') {
return false;
}
return hash_equals($expected, $candidate);
}
/**
* Erzwingt ein gueltiges CSRF-Token bei POST-Anfragen.
* Bricht die Anfrage mit 419 ab, wenn es fehlt oder falsch ist.
*/
public static function requireValid(): void
{
if (strtoupper((string)($_SERVER['REQUEST_METHOD'] ?? 'GET')) !== 'POST') {
return;
}
$candidate = $_POST['csrf_token'] ?? Http::header('x-csrf-token');
if (is_array($candidate)) {
$candidate = null;
}
if (!self::isValid(is_string($candidate) ? $candidate : null)) {
Logger::warning('CSRF-Pruefung fehlgeschlagen', [
'ip' => Http::clientIp(),
'path' => Http::path(),
]);
Http::fail(419, 'csrf_invalid', 'Sicherheits-Token abgelaufen oder ungueltig. Bitte Seite neu laden.');
}
}
}
+89 -19
View File
@@ -1,46 +1,116 @@
<?php <?php
declare(strict_types=1);
namespace Deploymentcenter\Core; namespace Deploymentcenter\Core;
use PDO; use PDO;
use PDOException; use PDOException;
use RuntimeException;
class Db /**
* Zentrale PDO-Verbindung.
*
* Die Session-Zeitzone wird fest auf UTC gesetzt. Damit liefert NOW() echte
* UTC-Werte - passend zu den Spalten, die auf _utc enden - und alle
* Zeitvergleiche (Watchdog-Evaluator, Lease-Ablauf) rechnen auf derselben
* Basis. Die Darstellung im WebUI erfolgt in der App-Zeitzone.
*/
final class Db
{ {
private static ?PDO $instance = null; private static ?PDO $instance = null;
public static function init(array $config): PDO public static function init(array $config = []): PDO
{ {
if (self::$instance === null) { if (self::$instance !== null) {
$dbCfg = isset($config['db']) ? $config['db'] : $config; return self::$instance;
$dsn = sprintf('mysql:host=%s;dbname=%s;charset=%s', $dbCfg['host'], $dbCfg['dbname'], $dbCfg['charset'] ?? 'utf8mb4'); }
$options = [ if ($config === []) {
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, $dbConfig = Config::db();
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, } elseif (isset($config['db']) && is_array($config['db'])) {
PDO::ATTR_EMULATE_PREPARES => false, $dbConfig = $config['db'];
]; } else {
$dbConfig = $config;
}
try { foreach (['host', 'dbname', 'username'] as $required) {
self::$instance = new PDO($dsn, $dbCfg['username'], $dbCfg['password'], $options); if (!isset($dbConfig[$required]) || $dbConfig[$required] === '') {
} catch (PDOException $e) { throw new RuntimeException('Datenbank-Konfiguration unvollstaendig: ' . $required . ' fehlt.');
throw new \Exception('Database connection failed: ' . $e->getMessage());
} }
} }
$dsn = sprintf(
'mysql:host=%s;dbname=%s;charset=%s',
$dbConfig['host'],
$dbConfig['dbname'],
$dbConfig['charset'] ?? 'utf8mb4'
);
$options = [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
PDO::ATTR_STRINGIFY_FETCHES => false,
];
try {
$pdo = new PDO($dsn, (string)$dbConfig['username'], (string)($dbConfig['password'] ?? ''), $options);
$pdo->exec("SET time_zone = '+00:00'");
} catch (PDOException $e) {
// Die Originalmeldung kann Host und Benutzername enthalten und
// gehoert deshalb ins Log, nicht in die Exception-Kette nach aussen.
Logger::error('Datenbankverbindung fehlgeschlagen', ['error' => $e->getMessage()]);
throw new RuntimeException('Datenbankverbindung fehlgeschlagen.', 0, $e);
}
self::$instance = $pdo;
return self::$instance; return self::$instance;
} }
public static function connect(array $config): PDO /** Alias fuer init(); historisch an mehreren Stellen verwendet. */
public static function connect(array $config = []): PDO
{ {
return self::init($config); return self::init($config);
} }
public static function getInstance(): PDO public static function getInstance(): PDO
{ {
if (self::$instance === null) { return self::$instance ?? self::init();
$config = require __DIR__ . '/../../config/config.php'; }
return self::init($config);
/**
* Fuehrt einen Callback in einer Transaktion aus.
* Verschachtelte Aufrufe laufen in der bereits offenen Transaktion mit.
*
* @template T
* @param callable(PDO):T $callback
* @return T
*/
public static function transaction(callable $callback)
{
$pdo = self::getInstance();
if ($pdo->inTransaction()) {
return $callback($pdo);
} }
return self::$instance;
$pdo->beginTransaction();
try {
$result = $callback($pdo);
$pdo->commit();
return $result;
} catch (\Throwable $e) {
if ($pdo->inTransaction()) {
$pdo->rollBack();
}
throw $e;
}
}
/** Aktueller UTC-Zeitstempel im MySQL-DATETIME-Format. */
public static function nowUtc(): string
{
return gmdate('Y-m-d H:i:s');
} }
} }
+90
View File
@@ -0,0 +1,90 @@
<?php
declare(strict_types=1);
namespace Deploymentcenter\Core;
use Deploymentcenter\Modules\Bugtracker\BugRepo;
/**
* Meldet unbehandelte Fehler des Deploymentcenters in seinen eigenen Bugtracker.
*
* Damit taucht ein 500er kuenftig selbst im Dashboard auf, statt gesucht werden
* zu muessen. Der Reporter ist bewusst extrem defensiv: schlaegt er fehl,
* darf das die urspruengliche Fehlerbehandlung nicht stoeren.
*/
final class ErrorReporter
{
private static bool $reported = false;
public static function report(\Throwable $e): void
{
self::submit(
self::titleFor($e->getMessage(), basename($e->getFile()), $e->getLine()),
$e->getMessage(),
$e->getFile() . ':' . $e->getLine() . "\n" . $e->getTraceAsString(),
get_class($e)
);
}
/** @param array{type:int,message:string,file:string,line:int} $err */
public static function reportFatal(array $err): void
{
self::submit(
self::titleFor($err['message'], basename($err['file']), $err['line']),
$err['message'],
$err['file'] . ':' . $err['line'],
'FatalError'
);
}
private static function submit(string $title, string $message, string $trace, string $class): void
{
// Nur ein Report pro Request, und niemals rekursiv.
if (self::$reported) {
return;
}
self::$reported = true;
try {
$slug = (string)Config::get('bugtracker.self_project', 'deploymentcenter');
if ($slug === '') {
return;
}
$db = Db::getInstance();
$repo = new BugRepo($db);
$repo->reportItem([
'project_slug' => $slug,
'type' => 'bug',
'title' => $title,
'description' => sprintf(
"Automatisch erfasster Laufzeitfehler.\n\nPfad: %s\nMethode: %s\nException: %s",
Http::path(),
Http::method(),
$class
),
'error_message' => $message,
'stack_trace' => $trace,
'environment' => Config::isDebug() ? 'development' : 'production',
'severity' => 'high',
'build_version' => (string)Config::get('app.version', 'unknown'),
'created_by' => 'system:self-report',
'tags' => 'selfreport,runtime',
]);
} catch (\Throwable $ignored) {
// Selbstmeldung ist bestenfalls hilfreich, niemals kritisch.
@error_log('ErrorReporter fehlgeschlagen: ' . $ignored->getMessage());
}
}
private static function titleFor(string $message, string $file, int $line): string
{
$short = trim(preg_replace('/\s+/', ' ', $message) ?? $message);
if (mb_strlen($short) > 150) {
$short = mb_substr($short, 0, 147) . '...';
}
return sprintf('%s (%s:%d)', $short, $file, $line);
}
}
+310
View File
@@ -0,0 +1,310 @@
<?php
declare(strict_types=1);
namespace Deploymentcenter\Core;
/**
* Einheitliche HTTP-Ein- und Ausgabe fuer alle API-Endpunkte.
*
* Alle Antworten folgen der Form:
* Erfolg: { "status": "success", ...Daten }
* Fehler: { "status": "error", "error": { "code": "...", "message": "..." } }
*
* Der Fehlercode ist stabil und maschinenlesbar - Agenten sollen darauf
* reagieren, nicht auf den Klartext der Nachricht.
*/
final class Http
{
private static bool $jsonMode = false;
private static ?array $headerCache = null;
private static ?array $bodyCache = null;
// ------------------------------------------------------------------
// Ausgabe
// ------------------------------------------------------------------
/**
* Startet eine JSON-Antwort und setzt Sicherheits- sowie CORS-Header.
*
* @param string[] $methods Erlaubte HTTP-Methoden fuer CORS.
* @param bool $publicCors true = beliebige Herkunft (nur fuer reine
* Token-Endpunkte ohne Cookie-Auth).
*/
public static function beginJson(array $methods = ['GET', 'POST', 'OPTIONS'], bool $publicCors = false): void
{
self::$jsonMode = true;
if (headers_sent()) {
return;
}
header('Content-Type: application/json; charset=utf-8');
header('X-Content-Type-Options: nosniff');
header('Cache-Control: no-store');
if ($publicCors) {
// Bewusst ohne Access-Control-Allow-Credentials: diese Endpunkte
// authentifizieren ausschliesslich per Token-Header, niemals per Cookie.
header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Headers: Content-Type, Authorization, X-Agent-Token, X-Master-Token, Idempotency-Key');
header('Access-Control-Allow-Methods: ' . implode(', ', $methods));
header('Access-Control-Max-Age: 600');
}
if (self::method() === 'OPTIONS') {
http_response_code(204);
exit;
}
}
/** Sendet eine Erfolgsantwort und beendet die Anfrage. */
public static function ok(array $data = [], int $status = 200): void
{
self::send(array_merge(['status' => 'success'], $data), $status);
}
/**
* Sendet eine Fehlerantwort und beendet die Anfrage.
*
* Der Exception-Text wird nur bei app.debug = true ausgeliefert;
* andernfalls landet er ausschliesslich im Log.
*/
public static function fail(
int $status,
string $code,
string $message,
?\Throwable $e = null,
array $extra = []
): void {
if ($e !== null) {
Logger::exception($e);
}
$error = ['code' => $code, 'message' => $message];
if ($e !== null && Config::isDebug()) {
$error['debug'] = [
'exception' => get_class($e),
'message' => $e->getMessage(),
'file' => $e->getFile(),
'line' => $e->getLine(),
];
}
if ($extra !== []) {
$error = array_merge($error, $extra);
}
if (!self::$jsonMode) {
self::sendHtmlError($status, $message);
}
self::send(['status' => 'error', 'error' => $error], $status);
}
private static function send(array $payload, int $status): void
{
if (!headers_sent()) {
http_response_code($status);
header('Content-Type: application/json; charset=utf-8');
}
$json = json_encode(
$payload,
JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT | JSON_INVALID_UTF8_SUBSTITUTE
);
echo $json === false
? '{"status":"error","error":{"code":"encoding_error","message":"Antwort nicht kodierbar."}}'
: $json;
exit;
}
private static function sendHtmlError(int $status, string $message): void
{
if (!headers_sent()) {
http_response_code($status);
header('Content-Type: text/html; charset=utf-8');
}
echo '<!doctype html><meta charset="utf-8"><title>Fehler ' . $status . '</title>'
. '<body style="font-family:system-ui,sans-serif;background:#0a0d14;color:#EDEFF5;padding:3rem;">'
. '<h1 style="font-size:1.4rem;">Fehler ' . $status . '</h1>'
. '<p style="color:#8B93A7;">' . htmlspecialchars($message, ENT_QUOTES, 'UTF-8') . '</p>'
. '<p style="color:#8B93A7;font-size:.85rem;">Details stehen im Server-Log unter var/log/.</p>'
. '</body>';
exit;
}
// ------------------------------------------------------------------
// Eingabe
// ------------------------------------------------------------------
public static function method(): string
{
return strtoupper((string)($_SERVER['REQUEST_METHOD'] ?? 'GET'));
}
public static function path(): string
{
$uri = (string)($_SERVER['REQUEST_URI'] ?? '/');
$path = parse_url($uri, PHP_URL_PATH);
return is_string($path) ? $path : '/';
}
/**
* Alle Request-Header mit kleingeschriebenen Namen.
*
* Bewusst aus $_SERVER aufgebaut statt ueber getallheaders(): letzteres
* existiert nicht in jeder SAPI und liefert die Schreibweise des Clients.
*
* @return array<string,string>
*/
public static function headers(): array
{
if (self::$headerCache !== null) {
return self::$headerCache;
}
$headers = [];
foreach ($_SERVER as $key => $value) {
if (!is_string($key) || !is_scalar($value)) {
continue;
}
if (strncmp($key, 'HTTP_', 5) === 0) {
$name = strtolower(str_replace('_', '-', substr($key, 5)));
$headers[$name] = (string)$value;
}
}
// Diese beiden kommen ohne HTTP_-Praefix an.
foreach (['CONTENT_TYPE' => 'content-type', 'CONTENT_LENGTH' => 'content-length'] as $src => $dst) {
if (isset($_SERVER[$src]) && is_scalar($_SERVER[$src])) {
$headers[$dst] = (string)$_SERVER[$src];
}
}
self::$headerCache = $headers;
return $headers;
}
public static function header(string $name): ?string
{
$headers = self::headers();
$key = strtolower($name);
return isset($headers[$key]) && $headers[$key] !== '' ? $headers[$key] : null;
}
/**
* Ermittelt das Agenten-Token aus X-Agent-Token, X-Master-Token
* oder einem Authorization-Bearer-Header.
*/
public static function bearerToken(): ?string
{
foreach (['x-agent-token', 'x-master-token', 'x-license-key', 'x-watchdog-key'] as $name) {
$value = self::header($name);
if ($value !== null) {
return trim($value);
}
}
$auth = self::header('authorization');
if ($auth !== null && preg_match('/^\s*Bearer\s+(\S+)/i', $auth, $m) === 1) {
return trim($m[1]);
}
return null;
}
/**
* Request-Body als Array.
*
* JSON hat Vorrang. Ist der Body leer, wird auf $_POST zurueckgefallen.
* Ist der Body vorhanden, aber kein gueltiges JSON, wird abgebrochen -
* ein stiller Fallback wuerde nur schwer auffindbare Fehler erzeugen.
*/
public static function body(): array
{
if (self::$bodyCache !== null) {
return self::$bodyCache;
}
$raw = file_get_contents('php://input');
if ($raw === false || trim($raw) === '') {
self::$bodyCache = is_array($_POST) ? $_POST : [];
return self::$bodyCache;
}
$contentType = strtolower((string)self::header('content-type'));
if (
str_contains($contentType, 'application/x-www-form-urlencoded')
|| str_contains($contentType, 'multipart/form-data')
) {
self::$bodyCache = is_array($_POST) ? $_POST : [];
return self::$bodyCache;
}
$decoded = json_decode($raw, true);
if (!is_array($decoded)) {
self::fail(400, 'invalid_json', 'Request-Body ist kein gueltiges JSON-Objekt.');
}
self::$bodyCache = $decoded;
return self::$bodyCache;
}
/**
* Liest einen Wert aus Body oder Query-String (Body hat Vorrang).
*
* @param mixed $default
* @return mixed
*/
public static function input(string $key, $default = null)
{
$body = self::body();
if (array_key_exists($key, $body)) {
return $body[$key];
}
if (isset($_GET[$key])) {
return $_GET[$key];
}
return $default;
}
/** Getrimmter String-Wert aus Body oder Query, oder null wenn leer. */
public static function str(string $key, ?string $default = null): ?string
{
$value = self::input($key, null);
if ($value === null || is_array($value)) {
return $default;
}
$value = trim((string)$value);
return $value === '' ? $default : $value;
}
public static function int(string $key, int $default = 0): int
{
$value = self::input($key, null);
if ($value === null || is_array($value)) {
return $default;
}
return (int)$value;
}
/** IP des Clients; Proxy-Header werden bewusst ignoriert (faelschbar). */
public static function clientIp(): string
{
$ip = $_SERVER['REMOTE_ADDR'] ?? '127.0.0.1';
return is_string($ip) && $ip !== '' ? $ip : '127.0.0.1';
}
/** Basis-URL der Installation, z. B. https://dc.mhdf.de */
public static function baseUrl(): string
{
$configured = (string)Config::get('app.url', '');
if ($configured !== '') {
return rtrim($configured, '/');
}
$https = !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off';
$host = (string)($_SERVER['HTTP_HOST'] ?? 'localhost');
return ($https ? 'https' : 'http') . '://' . $host;
}
}
+93
View File
@@ -0,0 +1,93 @@
<?php
declare(strict_types=1);
namespace Deploymentcenter\Core;
/**
* Minimaler Datei-Logger. Schlaegt das Schreiben fehl, wird still auf
* error_log() ausgewichen - Logging darf niemals die Anwendung stoppen.
*/
final class Logger
{
private static ?string $resolvedDir = null;
public static function error(string $message, array $context = []): void
{
self::write('ERROR', $message, $context);
}
public static function warning(string $message, array $context = []): void
{
self::write('WARN', $message, $context);
}
public static function info(string $message, array $context = []): void
{
self::write('INFO', $message, $context);
}
public static function exception(\Throwable $e): void
{
self::write('ERROR', sprintf(
'%s: %s in %s:%d',
get_class($e),
$e->getMessage(),
$e->getFile(),
$e->getLine()
), ['trace' => $e->getTraceAsString()]);
}
private static function write(string $level, string $message, array $context = []): void
{
$line = sprintf(
"[%s] %s %s%s\n",
gmdate('Y-m-d H:i:s'),
$level,
$message,
$context !== [] ? ' ' . self::encodeContext($context) : ''
);
$dir = self::dir();
if ($dir !== null) {
$file = $dir . '/dc-' . gmdate('Y-m-d') . '.log';
if (@file_put_contents($file, $line, FILE_APPEND | LOCK_EX) !== false) {
return;
}
}
@error_log(rtrim($line));
}
private static function encodeContext(array $context): string
{
$json = json_encode($context, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_SUBSTITUTE);
return $json === false ? '{}' : $json;
}
private static function dir(): ?string
{
if (self::$resolvedDir !== null) {
return self::$resolvedDir === '' ? null : self::$resolvedDir;
}
$dir = defined('DC_VAR') ? DC_VAR . '/log' : null;
if ($dir === null) {
self::$resolvedDir = '';
return null;
}
if (!is_dir($dir) && !@mkdir($dir, 0775, true) && !is_dir($dir)) {
self::$resolvedDir = '';
return null;
}
if (!is_writable($dir)) {
self::$resolvedDir = '';
return null;
}
self::$resolvedDir = $dir;
return $dir;
}
}
+427
View File
@@ -0,0 +1,427 @@
<?php
declare(strict_types=1);
namespace Deploymentcenter\Core;
use PDO;
use PDOException;
/**
* Migrations-Runner.
*
* Ersetzt das frueher genutzte explode(';') - das zerlegte jede Migration,
* die ein Semikolon innerhalb eines String-Literals enthielt, in Fragmente
* und liess sie (durch verschlucktes catch) still fehlschlagen.
*
* Angewendete Migrationen werden in dc_migrations vermerkt und nicht erneut
* ausgefuehrt. Fehler brechen den Lauf ab, statt ignoriert zu werden.
*/
final class Migrator
{
/**
* MySQL-Fehlercodes, die bei additiven Migrationen unkritisch sind:
* Objekt existiert bereits. Alles andere ist ein echter Fehler.
*/
private const TOLERATED_ERRORS = [
1022, // Duplicate key
1050, // Table already exists
1060, // Duplicate column name
1061, // Duplicate key name
1062, // Duplicate entry for key
1091, // Can't DROP; check that column/key exists
1826, // Duplicate foreign key constraint name
];
/**
* Migrationen, die auf einer bereits bestehenden Installation nur vermerkt,
* aber nicht ausgefuehrt werden.
*
* Grund: schema.sql und Migration 004 legen nicht nur Tabellen an, sondern
* spielen auch Beispieldaten ein (Demo-Lizenzen, Demo-Monitore, Demo-Bugs).
* Auf einer produktiv genutzten Datenbank wuerden diese Zeilen dadurch
* wieder auftauchen, nachdem sie geloescht wurden - die Seeds arbeiten mit
* festen IDs und ON DUPLICATE KEY UPDATE. Die Struktur, die sie erzeugen,
* ist auf einer bestehenden Installation ohnehin vorhanden.
*/
private const BASELINE_ONLY = [
'000_schema',
'004_unified_tokens_and_bugtracker',
'v2_hardware_id',
];
/**
* Fuehrt Schema und ausstehende Migrationen aus.
*
* @return array{applied:list<array<string,mixed>>,skipped:list<string>,baselined:list<string>,failed:?array<string,mixed>}
*/
public static function migrate(PDO $db): array
{
self::ensureMigrationsTable($db);
$applied = self::appliedVersions($db);
$result = ['applied' => [], 'skipped' => [], 'baselined' => [], 'failed' => null];
// Bestehende Installation ohne Migrationsvermerk: Strukturmigrationen
// als Ausgangsstand vermerken, statt sie samt Beispieldaten auszufuehren.
if ($applied === [] && self::isExistingInstall($db)) {
$files = self::discoverFiles();
foreach (self::BASELINE_ONLY as $version) {
if (isset($files[$version])) {
self::recordApplied($db, $version, hash('sha256', 'baseline'), 0, 0);
$result['baselined'][] = $version;
}
}
Logger::info('Bestehende Installation als Ausgangsstand vermerkt', [
'versions' => $result['baselined'],
]);
$applied = self::appliedVersions($db);
}
foreach (self::discoverFiles() as $version => $path) {
if (isset($applied[$version])) {
$result['skipped'][] = $version;
continue;
}
$sql = @file_get_contents($path);
if ($sql === false) {
$result['failed'] = ['version' => $version, 'error' => 'Datei nicht lesbar: ' . $path];
return $result;
}
$statements = self::splitStatements($sql);
$executed = 0;
$tolerated = 0;
$started = microtime(true);
foreach ($statements as $index => $statement) {
try {
$db->exec($statement);
$executed++;
} catch (PDOException $e) {
$code = self::driverErrorCode($e);
if ($code !== null && in_array($code, self::TOLERATED_ERRORS, true)) {
$tolerated++;
continue;
}
Logger::error('Migration fehlgeschlagen', [
'version' => $version,
'statement' => $index + 1,
'sql' => mb_substr($statement, 0, 400),
'error' => $e->getMessage(),
]);
$result['failed'] = [
'version' => $version,
'statement' => $index + 1,
'error' => $e->getMessage(),
'sql' => mb_substr($statement, 0, 400),
];
return $result;
}
}
$durationMs = (int)round((microtime(true) - $started) * 1000);
self::recordApplied($db, $version, hash('sha256', $sql), $executed, $durationMs);
$result['applied'][] = [
'version' => $version,
'statements' => $executed,
'tolerated' => $tolerated,
'duration_ms' => $durationMs,
];
}
return $result;
}
/**
* Aktuell in der Datenbank vermerkte Migrationsversionen.
*
* Legt die Tabelle bewusst NICHT an - die Methode wird bei jedem
* Seitenaufruf des Dashboards ausgefuehrt und soll dabei kein DDL absetzen.
* Fehlt die Tabelle, gelten schlicht alle Migrationen als ausstehend.
*/
public static function status(PDO $db): array
{
$all = array_keys(self::discoverFiles());
if (!self::migrationsTableExists($db)) {
return ['applied' => [], 'pending' => $all];
}
$applied = self::appliedVersions($db);
return [
'applied' => array_values($applied),
'pending' => array_values(array_diff($all, array_keys($applied))),
];
}
/**
* Erkennt eine bereits produktiv genutzte Datenbank.
*
* Kriterium: die Kerntabelle dc_projects existiert und enthaelt Zeilen.
* Bei einer leeren Datenbank trifft das nicht zu, dort laeuft schema.sql
* regulaer durch und legt auch die Beispieldaten an.
*/
private static function isExistingInstall(PDO $db): bool
{
try {
$stmt = $db->query("SHOW TABLES LIKE 'dc_projects'");
if ($stmt === false || $stmt->fetchColumn() === false) {
return false;
}
return (int)$db->query('SELECT COUNT(*) FROM dc_projects')->fetchColumn() > 0;
} catch (\Throwable $e) {
return false;
}
}
private static ?bool $tableExistsCache = null;
private static function migrationsTableExists(PDO $db): bool
{
if (self::$tableExistsCache !== null) {
return self::$tableExistsCache;
}
try {
$stmt = $db->query("SHOW TABLES LIKE 'dc_migrations'");
self::$tableExistsCache = $stmt !== false && $stmt->fetchColumn() !== false;
} catch (\Throwable $e) {
self::$tableExistsCache = false;
}
return self::$tableExistsCache;
}
// ------------------------------------------------------------------
private static function ensureMigrationsTable(PDO $db): void
{
// Der Existenz-Cache in migrationsTableExists() waere sonst veraltet,
// wenn status() vor migrate() im selben Request lief.
self::$tableExistsCache = true;
$db->exec('
CREATE TABLE IF NOT EXISTS dc_migrations (
version VARCHAR(190) NOT NULL PRIMARY KEY,
checksum CHAR(64) NOT NULL,
statements INT NOT NULL DEFAULT 0,
duration_ms INT NOT NULL DEFAULT 0,
applied_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
');
}
/** @return array<string,array<string,mixed>> */
private static function appliedVersions(PDO $db): array
{
$rows = $db->query('SELECT version, checksum, applied_at FROM dc_migrations')->fetchAll();
$out = [];
foreach ($rows ?: [] as $row) {
$out[(string)$row['version']] = $row;
}
return $out;
}
private static function recordApplied(
PDO $db,
string $version,
string $checksum,
int $statements,
int $durationMs
): void {
$stmt = $db->prepare('
INSERT INTO dc_migrations (version, checksum, statements, duration_ms, applied_at)
VALUES (:version, :checksum, :statements, :duration, NOW())
ON DUPLICATE KEY UPDATE
checksum = VALUES(checksum),
statements = VALUES(statements),
duration_ms = VALUES(duration_ms),
applied_at = VALUES(applied_at)
');
$stmt->execute([
':version' => $version,
':checksum' => $checksum,
':statements' => $statements,
':duration' => $durationMs,
]);
}
/**
* Basis-Schema plus alle Migrationen, in Ausfuehrungsreihenfolge.
*
* @return array<string,string> version => absoluter Pfad
*/
private static function discoverFiles(): array
{
$files = [];
$schema = DC_ROOT . '/sql/schema.sql';
if (is_file($schema)) {
$files['000_schema'] = $schema;
}
$dir = DC_ROOT . '/sql/migrations';
if (is_dir($dir)) {
$found = glob($dir . '/*.sql') ?: [];
sort($found, SORT_NATURAL);
foreach ($found as $path) {
$files[basename($path, '.sql')] = $path;
}
}
return $files;
}
private static function driverErrorCode(PDOException $e): ?int
{
$info = $e->errorInfo;
if (is_array($info) && isset($info[1]) && is_numeric($info[1])) {
return (int)$info[1];
}
return null;
}
/**
* Zerlegt ein SQL-Skript in einzelne Statements.
*
* Beachtet String-Literale ('...', "..."), Backtick-Bezeichner sowie
* Zeilen- und Blockkommentare, damit Semikolons darin nicht als
* Statement-Ende missverstanden werden.
*
* @return list<string>
*/
public static function splitStatements(string $sql): array
{
$statements = [];
$buffer = '';
$length = strlen($sql);
$i = 0;
$inSingle = false;
$inDouble = false;
$inBacktick = false;
$inLineComment = false;
$inBlockComment = false;
while ($i < $length) {
$char = $sql[$i];
$next = ($i + 1 < $length) ? $sql[$i + 1] : '';
if ($inLineComment) {
if ($char === "\n") {
$inLineComment = false;
$buffer .= $char;
}
$i++;
continue;
}
if ($inBlockComment) {
if ($char === '*' && $next === '/') {
$inBlockComment = false;
$i += 2;
continue;
}
$i++;
continue;
}
if ($inSingle || $inDouble || $inBacktick) {
$buffer .= $char;
// Backslash-Escape innerhalb von Strings (nicht in Backticks).
if ($char === '\\' && !$inBacktick && $next !== '') {
$buffer .= $next;
$i += 2;
continue;
}
$quote = $inSingle ? "'" : ($inDouble ? '"' : '`');
if ($char === $quote) {
if ($next === $quote) {
// Verdoppeltes Anfuehrungszeichen = Escape, bleibt im String.
$buffer .= $next;
$i += 2;
continue;
}
$inSingle = $inDouble = $inBacktick = false;
}
$i++;
continue;
}
// Ausserhalb von Strings und Kommentaren
if ($char === '-' && $next === '-') {
$after = ($i + 2 < $length) ? $sql[$i + 2] : "\n";
if ($after === ' ' || $after === "\t" || $after === "\n" || $after === "\r") {
$inLineComment = true;
$i += 2;
continue;
}
}
if ($char === '#') {
$inLineComment = true;
$i++;
continue;
}
if ($char === '/' && $next === '*') {
$inBlockComment = true;
$i += 2;
continue;
}
if ($char === "'") {
$inSingle = true;
$buffer .= $char;
$i++;
continue;
}
if ($char === '"') {
$inDouble = true;
$buffer .= $char;
$i++;
continue;
}
if ($char === '`') {
$inBacktick = true;
$buffer .= $char;
$i++;
continue;
}
if ($char === ';') {
$trimmed = trim($buffer);
if ($trimmed !== '') {
$statements[] = $trimmed;
}
$buffer = '';
$i++;
continue;
}
$buffer .= $char;
$i++;
}
$trimmed = trim($buffer);
if ($trimmed !== '') {
$statements[] = $trimmed;
}
return $statements;
}
}
+337 -130
View File
@@ -1,21 +1,60 @@
<?php <?php
declare(strict_types=1);
namespace Deploymentcenter\Core; namespace Deploymentcenter\Core;
use InvalidArgumentException;
use PDO; use PDO;
class TokenManager /**
* Verwaltung der Master-/Sub-Token-Hierarchie in dc_tokens.
*
* Korrekturen gegenueber der Erstfassung:
* - revokeToken()/deleteToken() nutzen getrennte Platzhalter. Derselbe
* benannte Parameter zweimal im Statement ist bei ATTR_EMULATE_PREPARES=false
* nicht zulaessig und warf HY093.
* - validateToken() vergleicht ausschliesslich den SHA-256-Hash, nicht mehr
* zusaetzlich den Klartext.
* - expires_at wird ausgewertet; die Spalte existierte, wurde aber ignoriert.
* - Scopes unterstuetzen Praefix-Wildcards (bugtracker:* deckt bugtracker:report ab).
*
* Hinweis: raw_token wird weiterhin gespeichert, damit das WebUI Tokens
* nachtraeglich anzeigen und kopieren kann. Das ist eine bewusste Abwaegung
* fuer ein Ein-Administrator-Werkzeug hinter Login. Wer das nicht moechte,
* setzt store_raw_tokens = false; dann ist das Token nur einmalig bei der
* Erstellung sichtbar.
*/
final class TokenManager
{ {
public const OWNER_TYPES = ['license', 'project', 'host', 'dev_agent', 'custom'];
public const ENVIRONMENTS = ['production', 'development', 'all'];
public const KNOWN_SCOPES = [
'*',
'bugtracker:report',
'bugtracker:read',
'bugtracker:manage',
'watchdog:ping',
'watchdog:read',
'updateservice:read',
'updateservice:publish',
'tokens:provision',
];
private PDO $db; private PDO $db;
private bool $storeRaw;
public function __construct(PDO $db) public function __construct(PDO $db)
{ {
$this->db = $db; $this->db = $db;
$this->storeRaw = (bool)Config::get('security.store_raw_tokens', true);
} }
/** // ------------------------------------------------------------------
* Create a new Master Token. // Erstellung
*/ // ------------------------------------------------------------------
public function createMasterToken( public function createMasterToken(
string $name, string $name,
?string $projectSlug = null, ?string $projectSlug = null,
@@ -23,47 +62,58 @@ class TokenManager
string $ownerType = 'custom', string $ownerType = 'custom',
?string $ownerIdentity = null, ?string $ownerIdentity = null,
array $scopes = ['*'], array $scopes = ['*'],
string $environment = 'all' string $environment = 'all',
?string $expiresAt = null
): array { ): array {
$tokenId = 'tok_m_' . bin2hex(random_bytes(8)); $name = trim($name);
$rawToken = 'dc_master_' . bin2hex(random_bytes(20)); if ($name === '') {
$tokenHash = hash('sha256', $rawToken); throw new InvalidArgumentException('Token-Bezeichnung darf nicht leer sein.');
}
$tokenId = 'tok_m_' . bin2hex(random_bytes(8));
$rawToken = 'dc_master_' . bin2hex(random_bytes(24));
$stmt = $this->db->prepare(' $stmt = $this->db->prepare('
INSERT INTO dc_tokens ( INSERT INTO dc_tokens (
token_id, parent_token_id, token_hash, raw_token, name, token_id, parent_token_id, token_hash, raw_token, name,
project_slug, license_key, owner_type, owner_identity, project_slug, license_key, owner_type, owner_identity,
type, scopes, environment, created_at type, scopes, environment, expires_at, created_at
) VALUES ( ) VALUES (
:id, NULL, :hash, :raw, :name, :id, NULL, :hash, :raw, :name,
:proj, :lic, :type, :identity, :proj, :lic, :owner_type, :identity,
"master", :scopes, :env, NOW() "master", :scopes, :env, :expires, UTC_TIMESTAMP()
) )
'); ');
$stmt->execute([ $stmt->execute([
':id' => $tokenId, ':id' => $tokenId,
':hash' => $tokenHash, ':hash' => hash('sha256', $rawToken),
':raw' => $rawToken, ':raw' => $this->storeRaw ? $rawToken : null,
':name' => $name, ':name' => $name,
':proj' => !empty($projectSlug) ? $projectSlug : null, ':proj' => self::nullIfEmpty($projectSlug),
':lic' => !empty($licenseKey) ? $licenseKey : null, ':lic' => self::nullIfEmpty($licenseKey),
':type' => in_array($ownerType, ['license', 'project', 'host', 'dev_agent', 'custom']) ? $ownerType : 'custom', ':owner_type' => in_array($ownerType, self::OWNER_TYPES, true) ? $ownerType : 'custom',
':identity' => !empty($ownerIdentity) ? $ownerIdentity : null, ':identity' => self::nullIfEmpty($ownerIdentity),
':scopes' => json_encode(!empty($scopes) ? $scopes : ['*']), ':scopes' => json_encode(self::normalizeScopes($scopes, ['*'])),
':env' => in_array($environment, ['production', 'development', 'all']) ? $environment : 'all', ':env' => in_array($environment, self::ENVIRONMENTS, true) ? $environment : 'all',
':expires' => self::nullIfEmpty($expiresAt),
]); ]);
Logger::info('Master-Token erstellt', ['token_id' => $tokenId, 'name' => $name]);
return [ return [
'token_id' => $tokenId, 'token_id' => $tokenId,
'raw_token' => $rawToken, 'raw_token' => $rawToken,
'name' => $name, 'name' => $name,
'type' => 'master', 'type' => 'master',
'scopes' => self::normalizeScopes($scopes, ['*']),
'environment' => $environment,
]; ];
} }
/** /**
* Provision a Sub-Token using a Master-Token. * Erzeugt ein Sub-Token aus einem gueltigen Master-Token.
* Rechte und Umgebung koennen dabei nur eingeschraenkt, nie erweitert werden.
*/ */
public function provisionSubToken( public function provisionSubToken(
string $rawMasterToken, string $rawMasterToken,
@@ -72,156 +122,201 @@ class TokenManager
array $requestedScopes = [], array $requestedScopes = [],
string $environment = 'all' string $environment = 'all'
): array { ): array {
$masterHash = hash('sha256', $rawMasterToken); $master = $this->findByRawToken($rawMasterToken);
if ($master === null || $master['type'] !== 'master' || (int)$master['revoked'] === 1) {
throw new InvalidArgumentException('Ungueltiges oder widerrufenes Master-Token.');
}
if (self::isExpired($master)) {
throw new InvalidArgumentException('Master-Token ist abgelaufen.');
}
$masterScopes = self::decodeScopes($master['scopes']);
$requested = self::normalizeScopes($requestedScopes, []);
if ($requested === []) {
$effectiveScopes = $masterScopes;
} elseif (in_array('*', $masterScopes, true)) {
$effectiveScopes = $requested;
} else {
// Nur Rechte durchreichen, die das Master-Token tatsaechlich besitzt.
$effectiveScopes = array_values(array_filter(
$requested,
static fn(string $scope): bool => self::scopeSatisfied($masterScopes, $scope)
));
}
if ($effectiveScopes === []) {
throw new InvalidArgumentException('Die angeforderten Rechte deckt dieses Master-Token nicht ab.');
}
// Ist das Master-Token auf eine Umgebung festgelegt, gilt diese zwingend.
$effectiveEnv = $master['environment'] !== 'all'
? (string)$master['environment']
: (in_array($environment, self::ENVIRONMENTS, true) ? $environment : 'all');
$subTokenId = 'tok_s_' . bin2hex(random_bytes(8));
$rawSubToken = 'dc_sub_' . bin2hex(random_bytes(24));
$stmt = $this->db->prepare(' $stmt = $this->db->prepare('
SELECT * FROM dc_tokens
WHERE (token_hash = :hash OR raw_token = :raw)
AND type = "master"
AND revoked = 0
');
$stmt->execute([':hash' => $masterHash, ':raw' => $rawMasterToken]);
$master = $stmt->fetch();
if (!$master) {
throw new \InvalidArgumentException('Invalid or revoked Master Token.');
}
$masterScopes = json_decode($master['scopes'], true) ?: ['*'];
// Determine effective scopes
$effectiveScopes = [];
if (in_array('*', $masterScopes)) {
$effectiveScopes = !empty($requestedScopes) ? $requestedScopes : ['*'];
} else {
if (empty($requestedScopes)) {
$effectiveScopes = $masterScopes;
} else {
$effectiveScopes = array_intersect($requestedScopes, $masterScopes);
}
}
if (empty($effectiveScopes)) {
throw new \InvalidArgumentException('Requested scopes are not allowed by this Master Token.');
}
// Determine effective environment
$effectiveEnv = $environment;
if ($master['environment'] !== 'all') {
$effectiveEnv = $master['environment'];
}
$subTokenId = 'tok_s_' . bin2hex(random_bytes(8));
$rawSubToken = 'dc_sub_' . bin2hex(random_bytes(20));
$subHash = hash('sha256', $rawSubToken);
$ins = $this->db->prepare('
INSERT INTO dc_tokens ( INSERT INTO dc_tokens (
token_id, parent_token_id, token_hash, raw_token, name, token_id, parent_token_id, token_hash, raw_token, name,
project_slug, license_key, owner_type, owner_identity, project_slug, license_key, owner_type, owner_identity,
type, scopes, environment, created_at type, scopes, environment, expires_at, created_at
) VALUES ( ) VALUES (
:id, :parent_id, :hash, :raw, :name, :id, :parent_id, :hash, :raw, :name,
:proj, :lic, :owner_type, :identity, :proj, :lic, :owner_type, :identity,
"sub", :scopes, :env, NOW() "sub", :scopes, :env, :expires, UTC_TIMESTAMP()
) )
'); ');
$ins->execute([ $stmt->execute([
':id' => $subTokenId, ':id' => $subTokenId,
':parent_id' => $master['token_id'], ':parent_id' => $master['token_id'],
':hash' => $subHash, ':hash' => hash('sha256', $rawSubToken),
':raw' => $rawSubToken, ':raw' => $this->storeRaw ? $rawSubToken : null,
':name' => $name, ':name' => trim($name) !== '' ? trim($name) : 'Auto-Provisioned Sub-Token',
':proj' => $master['project_slug'], ':proj' => $master['project_slug'],
':lic' => $master['license_key'], ':lic' => $master['license_key'],
':owner_type'=> $master['owner_type'], ':owner_type' => $master['owner_type'],
':identity' => !empty($instanceIdentity) ? $instanceIdentity : $master['owner_identity'], ':identity' => self::nullIfEmpty($instanceIdentity) ?? $master['owner_identity'],
':scopes' => json_encode(array_values($effectiveScopes)), ':scopes' => json_encode($effectiveScopes),
':env' => $effectiveEnv, ':env' => $effectiveEnv,
// Ein Sub-Token ueberlebt sein Master-Token nicht.
':expires' => $master['expires_at'],
]);
Logger::info('Sub-Token provisioniert', [
'token_id' => $subTokenId,
'parent' => $master['token_id'],
]); ]);
return [ return [
'token_id' => $subTokenId, 'token_id' => $subTokenId,
'raw_token' => $rawSubToken, 'raw_token' => $rawSubToken,
'name' => $name, 'name' => $name,
'scopes' => array_values($effectiveScopes), 'scopes' => $effectiveScopes,
'environment'=> $effectiveEnv, 'environment' => $effectiveEnv,
'type' => 'sub', 'expires_at' => $master['expires_at'],
'type' => 'sub',
]; ];
} }
// ------------------------------------------------------------------
// Validierung
// ------------------------------------------------------------------
/** /**
* Validate any Token (Master or Sub) and check cascading revocation of parent tokens. * Prueft ein Token auf Gueltigkeit, Rechte und Umgebung.
*
* @return array<string,mixed>|null Der Token-Datensatz oder null.
*/ */
public function validateToken(string $rawToken, ?string $requiredScope = null, ?string $environment = null): ?array public function validateToken(string $rawToken, ?string $requiredScope = null, ?string $environment = null): ?array
{ {
$hash = hash('sha256', $rawToken); $token = $this->findByRawToken($rawToken);
$stmt = $this->db->prepare(' if ($token === null || (int)$token['revoked'] === 1) {
SELECT t.*, p.revoked as parent_revoked
FROM dc_tokens t
LEFT JOIN dc_tokens p ON t.parent_token_id = p.token_id
WHERE (t.token_hash = :hash OR t.raw_token = :raw)
AND t.revoked = 0
');
$stmt->execute([':hash' => $hash, ':raw' => $rawToken]);
$token = $stmt->fetch();
if (!$token) {
return null; return null;
} }
// Cascading Revocation Check // Kaskadierende Sperre: ein widerrufenes Master-Token entwertet seine Kinder.
if ($token['type'] === 'sub' && !empty($token['parent_token_id']) && (int)$token['parent_revoked'] === 1) { if ($token['type'] === 'sub' && (int)($token['parent_revoked'] ?? 0) === 1) {
return null; return null;
} }
// Scope Check if (self::isExpired($token)) {
if ($requiredScope !== null) { return null;
$scopes = json_decode($token['scopes'], true) ?: []; }
if (!in_array('*', $scopes) && !in_array($requiredScope, $scopes)) {
return null; if ($requiredScope !== null && !self::scopeSatisfied(self::decodeScopes($token['scopes']), $requiredScope)) {
} return null;
} }
// Environment Check
if ($environment !== null && $token['environment'] !== 'all' && $token['environment'] !== $environment) { if ($environment !== null && $token['environment'] !== 'all' && $token['environment'] !== $environment) {
return null; return null;
} }
// Update Last Used Timestamp $this->touch((string)$token['token_id']);
$upd = $this->db->prepare('UPDATE dc_tokens SET last_used_at = NOW() WHERE id = :id');
$upd->execute([':id' => $token['id']]);
return $token; return $token;
} }
/** /** Sucht ein Token ausschliesslich ueber den Hash des Klartextwerts. */
* Revoke a Token (Master or Sub). If Master, cascade revokes all child Sub-Tokens via DB foreign key or query. public function findByRawToken(string $rawToken): ?array
*/
public function revokeToken(string $tokenId): bool
{ {
$stmt = $this->db->prepare('UPDATE dc_tokens SET revoked = 1 WHERE token_id = :id OR parent_token_id = :id'); $rawToken = trim($rawToken);
return $stmt->execute([':id' => $tokenId]); if ($rawToken === '') {
return null;
}
$stmt = $this->db->prepare('
SELECT t.*, COALESCE(p.revoked, 0) AS parent_revoked
FROM dc_tokens t
LEFT JOIN dc_tokens p ON t.parent_token_id = p.token_id
WHERE t.token_hash = :hash
LIMIT 1
');
$stmt->execute([':hash' => hash('sha256', $rawToken)]);
$row = $stmt->fetch();
return is_array($row) ? $row : null;
} }
/** private function touch(string $tokenId): void
* Permanently delete a Token (Master or Sub). If Master, child Sub-Tokens are deleted via cascade.
*/
public function deleteToken(string $tokenId): bool
{ {
$stmt = $this->db->prepare('DELETE FROM dc_tokens WHERE token_id = :id OR parent_token_id = :id'); try {
return $stmt->execute([':id' => $tokenId]); $stmt = $this->db->prepare('UPDATE dc_tokens SET last_used_at = UTC_TIMESTAMP() WHERE token_id = :id');
$stmt->execute([':id' => $tokenId]);
} catch (\Throwable $e) {
// Die Nutzungsstatistik darf keinen Request scheitern lassen.
Logger::warning('last_used_at nicht aktualisiert', ['token_id' => $tokenId]);
}
} }
// ------------------------------------------------------------------
// Verwaltung
// ------------------------------------------------------------------
/** /**
* Get all Master Tokens with child count. * Widerruft ein Token und alle davon abgeleiteten Sub-Tokens.
* Getrennte Platzhalter, da derselbe Parametername sonst HY093 ausloest.
*/ */
public function revokeToken(string $tokenId): int
{
$stmt = $this->db->prepare('
UPDATE dc_tokens
SET revoked = 1
WHERE token_id = :token_id OR parent_token_id = :parent_id
');
$stmt->execute([':token_id' => $tokenId, ':parent_id' => $tokenId]);
$count = $stmt->rowCount();
Logger::info('Token widerrufen', ['token_id' => $tokenId, 'affected' => $count]);
return $count;
}
/** Loescht ein Token samt Sub-Tokens dauerhaft. */
public function deleteToken(string $tokenId): int
{
$stmt = $this->db->prepare('
DELETE FROM dc_tokens
WHERE token_id = :token_id OR parent_token_id = :parent_id
');
$stmt->execute([':token_id' => $tokenId, ':parent_id' => $tokenId]);
$count = $stmt->rowCount();
Logger::info('Token geloescht', ['token_id' => $tokenId, 'affected' => $count]);
return $count;
}
/** @return list<array<string,mixed>> */
public function getAllMasterTokens(): array public function getAllMasterTokens(): array
{ {
$stmt = $this->db->query(' $stmt = $this->db->query('
SELECT m.*, COUNT(s.id) as sub_token_count SELECT m.*, COUNT(s.id) AS sub_token_count
FROM dc_tokens m FROM dc_tokens m
LEFT JOIN dc_tokens s ON m.token_id = s.parent_token_id LEFT JOIN dc_tokens s ON m.token_id = s.parent_token_id
WHERE m.type = "master" WHERE m.type = "master"
@@ -231,12 +326,124 @@ class TokenManager
return $stmt->fetchAll() ?: []; return $stmt->fetchAll() ?: [];
} }
/** /** @return list<array<string,mixed>> */
* Get all Tokens (Master & Sub).
*/
public function getAllTokens(): array public function getAllTokens(): array
{ {
$stmt = $this->db->query('SELECT * FROM dc_tokens ORDER BY created_at DESC'); $stmt = $this->db->query('SELECT * FROM dc_tokens ORDER BY created_at DESC');
return $stmt->fetchAll() ?: []; return $stmt->fetchAll() ?: [];
} }
// ------------------------------------------------------------------
// Hilfsfunktionen
// ------------------------------------------------------------------
/**
* Rechte, die andere Rechte einschliessen.
*
* Wer Items bearbeiten darf, muss sie auch lesen koennen - sonst ist das
* Recht wertlos. Ohne diese Zuordnung braeuchte jedes Token beide Eintraege
* einzeln, und ein im WebUI mit "Bugtracker Manage" erzeugtes Token
* scheiterte an jeder Abfrage.
*
* @var array<string,list<string>>
*/
private const IMPLIED_SCOPES = [
'bugtracker:manage' => ['bugtracker:read', 'bugtracker:report'],
'bugtracker:report' => ['bugtracker:read'],
'updateservice:publish' => ['updateservice:read'],
'watchdog:evaluate' => ['watchdog:read'],
'watchdog:ping' => ['watchdog:read'],
];
/**
* Prueft, ob eine Scope-Liste ein konkretes Recht abdeckt.
*
* "*" deckt alles ab, "bugtracker:*" alle bugtracker-Rechte, und
* uebergeordnete Rechte schliessen die jeweils schwaecheren ein.
*
* @param list<string> $granted
*/
public static function scopeSatisfied(array $granted, string $required): bool
{
foreach ($granted as $scope) {
if ($scope === '*' || $scope === $required) {
return true;
}
// Praefix-Wildcard: "bugtracker:*" deckt "bugtracker:read" ab
if (str_ends_with($scope, ':*')) {
$prefix = substr($scope, 0, -1);
if (str_starts_with($required, $prefix)) {
return true;
}
}
if (in_array($required, self::IMPLIED_SCOPES[$scope] ?? [], true)) {
return true;
}
}
return false;
}
/**
* @param mixed $raw
* @return list<string>
*/
public static function decodeScopes($raw): array
{
if (is_array($raw)) {
return self::normalizeScopes($raw, ['*']);
}
if (!is_string($raw) || $raw === '') {
return ['*'];
}
$decoded = json_decode($raw, true);
return is_array($decoded) ? self::normalizeScopes($decoded, ['*']) : ['*'];
}
/**
* @param mixed $scopes
* @param list<string> $fallback
* @return list<string>
*/
private static function normalizeScopes($scopes, array $fallback): array
{
if (!is_array($scopes)) {
return $fallback;
}
$clean = [];
foreach ($scopes as $scope) {
if (!is_string($scope)) {
continue;
}
$scope = trim($scope);
if ($scope !== '' && !in_array($scope, $clean, true)) {
$clean[] = $scope;
}
}
return $clean === [] ? $fallback : $clean;
}
/** @param array<string,mixed> $token */
public static function isExpired(array $token): bool
{
$expires = $token['expires_at'] ?? null;
if ($expires === null || $expires === '') {
return false;
}
$ts = strtotime((string)$expires . ' UTC');
return $ts !== false && $ts < time();
}
private static function nullIfEmpty(?string $value): ?string
{
if ($value === null) {
return null;
}
$value = trim($value);
return $value === '' ? null : $value;
}
} }
File diff suppressed because it is too large Load Diff
+64 -28
View File
@@ -1,59 +1,95 @@
<?php <?php
declare(strict_types=1);
namespace Deploymentcenter\Modules\License; namespace Deploymentcenter\Modules\License;
use Deploymentcenter\Core\Logger;
use PDO; use PDO;
class RateLimiter /**
* Einfache Zaehler-Drosselung pro IP und Zeitfenster.
*
* Korrekturen gegenueber der Erstfassung:
* - beginTransaction() wurde ungeprueft aufgerufen; lief bereits eine
* Transaktion, warf das. Im catch-Zweig folgte rollBack(), das ohne aktive
* Transaktion selbst wirft - eine Exception aus dem Exception-Handler.
* - Der Zaehler laeuft jetzt ohne explizite Transaktion ueber ein atomares
* INSERT ... ON DUPLICATE KEY UPDATE. Das ist kuerzer, schneller und
* braucht keine Sperren.
* - Wird jetzt auch vom Bugtracker-Ingest genutzt, nicht nur vom Lizenzmodul.
*/
final class RateLimiter
{ {
private PDO $db; private PDO $db;
private int $limit; private int $limit;
private int $windowSeconds; private int $windowSeconds;
private string $bucket;
public function __construct(PDO $db, int $limit = 60, int $windowSeconds = 60) public function __construct(PDO $db, int $limit = 60, int $windowSeconds = 60, string $bucket = 'default')
{ {
$this->db = $db; $this->db = $db;
$this->limit = $limit; $this->limit = max(1, $limit);
$this->windowSeconds = $windowSeconds; $this->windowSeconds = max(1, $windowSeconds);
$this->bucket = $bucket;
} }
/**
* Zaehlt einen Zugriff und meldet, ob er erlaubt ist.
*
* Faellt die Pruefung selbst aus (z. B. weil die Tabelle fehlt), wird der
* Zugriff durchgelassen - eine kaputte Drosselung darf den Dienst nicht
* lahmlegen.
*/
public function check(string $ip): bool public function check(string $ip): bool
{ {
$packedIp = inet_pton($ip); $packed = @inet_pton($ip);
if ($packedIp === false) { if ($packed === false) {
return true; return true;
} }
// Fensteranfang auf ein festes Raster runden, damit alle Anfragen
// desselben Intervalls auf dieselbe Zeile treffen.
$now = time(); $now = time();
$windowStart = date('Y-m-d H:i:s', $now - ($now % $this->windowSeconds)); $windowStart = gmdate('Y-m-d H:i:s', $now - ($now % $this->windowSeconds));
$this->db->beginTransaction(); // Der Bucket unterscheidet Endpunkte mit eigenen Limits.
$key = $this->bucket === 'default' ? $packed : substr(hash('sha256', $this->bucket . $ip, true), 0, 16);
try { try {
$stmt = $this->db->prepare('SELECT request_count FROM license_api_rate_limit WHERE ip = :ip AND window_start = :ws FOR UPDATE'); $stmt = $this->db->prepare('
$stmt->execute([':ip' => $packedIp, ':ws' => $windowStart]); INSERT INTO license_api_rate_limit (ip, window_start, request_count)
$count = $stmt->fetchColumn(); VALUES (:ip, :window_start, 1)
ON DUPLICATE KEY UPDATE request_count = request_count + 1
');
$stmt->execute([':ip' => $key, ':window_start' => $windowStart]);
if ($count === false) { $read = $this->db->prepare('
$ins = $this->db->prepare('INSERT INTO license_api_rate_limit (ip, window_start, request_count) VALUES (:ip, :ws, 1)'); SELECT request_count FROM license_api_rate_limit
$ins->execute([':ip' => $packedIp, ':ws' => $windowStart]); WHERE ip = :ip AND window_start = :window_start
$this->db->commit(); ');
return true; $read->execute([':ip' => $key, ':window_start' => $windowStart]);
} $count = (int)$read->fetchColumn();
if ((int)$count >= $this->limit) {
$this->db->commit();
return false;
}
$upd = $this->db->prepare('UPDATE license_api_rate_limit SET request_count = request_count + 1 WHERE ip = :ip AND window_start = :ws');
$upd->execute([':ip' => $packedIp, ':ws' => $windowStart]);
$this->db->commit();
return true;
return $count <= $this->limit;
} catch (\Throwable $e) { } catch (\Throwable $e) {
$this->db->rollBack(); Logger::warning('Rate-Limit-Pruefung nicht moeglich', ['error' => $e->getMessage()]);
return true; return true;
} }
} }
/** Entfernt Zeilen aelterer Zeitfenster. */
public function purge(int $olderThanSeconds = 3600): int
{
try {
$seconds = max(60, $olderThanSeconds);
$stmt = $this->db->prepare(
'DELETE FROM license_api_rate_limit WHERE window_start < (UTC_TIMESTAMP() - INTERVAL ' . $seconds . ' SECOND)'
);
$stmt->execute();
return $stmt->rowCount();
} catch (\Throwable $e) {
return 0;
}
}
} }
+214
View File
@@ -0,0 +1,214 @@
<?php
declare(strict_types=1);
namespace Deploymentcenter\Modules\Notify;
use Deploymentcenter\Core\Config;
use Deploymentcenter\Core\Logger;
use PDO;
/**
* Ausgehende Webhooks.
*
* Ereignisgesteuerte Benachrichtigung statt Polling: Agenten und Chat-Kanaele
* (Telegram, Matrix, n8n, ...) koennen sich registrieren und werden bei neuen
* oder kritischen Items sowie bei Watchdog-Zustandswechseln informiert.
*
* Jede Zustellung traegt eine HMAC-SHA256-Signatur im Header X-DC-Signature,
* damit der Empfaenger die Echtheit pruefen kann:
* signature = hex(hmac_sha256(secret, timestamp . '.' . body))
* Der Header X-DC-Timestamp enthaelt den zugehoerigen Unix-Zeitstempel.
*/
final class WebhookDispatcher
{
/** Bekannte Ereignisnamen. */
public const EVENTS = [
'bug.created',
'bug.critical',
'bug.resolved',
'feature.created',
'monitor.down',
'monitor.recovered',
'release.published',
];
private const TIMEOUT_SECONDS = 4;
private const MAX_TARGETS = 10;
/** Verhindert, dass eine Kette von Ereignissen einen Request blockiert. */
private static int $dispatchedThisRequest = 0;
public static function dispatch(PDO $db, string $event, array $payload): void
{
if (self::$dispatchedThisRequest >= self::MAX_TARGETS) {
return;
}
$targets = self::targetsFor($db, $event, $payload['project_slug'] ?? null);
if ($targets === []) {
return;
}
$body = [
'event' => $event,
'timestamp' => gmdate('c'),
'source' => (string)Config::get('app.url', ''),
'data' => $payload,
];
$json = json_encode($body, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
if ($json === false) {
Logger::warning('Webhook-Payload nicht kodierbar', ['event' => $event]);
return;
}
foreach ($targets as $target) {
if (self::$dispatchedThisRequest >= self::MAX_TARGETS) {
break;
}
self::$dispatchedThisRequest++;
self::deliver($db, $target, $json);
}
}
/**
* @return list<array<string,mixed>>
*/
private static function targetsFor(PDO $db, string $event, $projectSlug): array
{
try {
$stmt = $db->prepare('
SELECT id, name, url, secret, events, project_slug
FROM dc_webhooks
WHERE enabled = 1
AND (project_slug IS NULL OR project_slug = :slug)
LIMIT 25
');
$stmt->execute([':slug' => is_string($projectSlug) ? $projectSlug : '']);
$rows = $stmt->fetchAll() ?: [];
} catch (\Throwable $e) {
// Tabelle fehlt (Migration noch nicht gelaufen) - kein Grund zu scheitern.
return [];
}
$matching = [];
foreach ($rows as $row) {
$subscribed = array_map('trim', explode(',', (string)$row['events']));
if (in_array($event, $subscribed, true) || in_array('*', $subscribed, true)) {
$matching[] = $row;
}
}
return $matching;
}
private static function deliver(PDO $db, array $target, string $json): void
{
$secret = (string)($target['secret'] ?? '');
if ($secret === '') {
$secret = (string)Config::get('security.webhook_key', '');
}
$timestamp = (string)time();
$signature = $secret !== ''
? hash_hmac('sha256', $timestamp . '.' . $json, $secret)
: '';
$headers = [
'Content-Type: application/json',
'User-Agent: Deploymentcenter-Webhook/1.0',
'X-DC-Timestamp: ' . $timestamp,
];
if ($signature !== '') {
$headers[] = 'X-DC-Signature: sha256=' . $signature;
}
[$ok, $status, $error] = self::post((string)$target['url'], $json, $headers);
self::recordResult($db, (int)$target['id'], $ok, $status, $error);
}
/**
* @param list<string> $headers
* @return array{0:bool,1:int,2:?string}
*/
private static function post(string $url, string $json, array $headers): array
{
if (!preg_match('#^https?://#i', $url)) {
return [false, 0, 'Ungueltige URL'];
}
if (function_exists('curl_init')) {
$ch = curl_init($url);
if ($ch === false) {
return [false, 0, 'curl_init fehlgeschlagen'];
}
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $json,
CURLOPT_HTTPHEADER => $headers,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => self::TIMEOUT_SECONDS,
CURLOPT_CONNECTTIMEOUT => 2,
CURLOPT_FOLLOWLOCATION => false,
CURLOPT_SSL_VERIFYPEER => true,
CURLOPT_SSL_VERIFYHOST => 2,
]);
$response = curl_exec($ch);
$status = (int)curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
$error = $response === false ? curl_error($ch) : null;
curl_close($ch);
return [$status >= 200 && $status < 300, $status, $error];
}
$context = stream_context_create([
'http' => [
'method' => 'POST',
'header' => implode("\r\n", $headers),
'content' => $json,
'timeout' => self::TIMEOUT_SECONDS,
'ignore_errors' => true,
],
]);
$result = @file_get_contents($url, false, $context);
$status = 0;
if (isset($http_response_header[0]) && preg_match('#\s(\d{3})\s#', $http_response_header[0], $m) === 1) {
$status = (int)$m[1];
}
return [
$result !== false && $status >= 200 && $status < 300,
$status,
$result === false ? 'Anfrage fehlgeschlagen' : null,
];
}
private static function recordResult(PDO $db, int $webhookId, bool $ok, int $status, ?string $error): void
{
try {
$stmt = $db->prepare('
UPDATE dc_webhooks
SET last_status = :status,
last_error = :error,
last_fired_at = UTC_TIMESTAMP(),
failure_count = IF(:ok = 1, 0, failure_count + 1),
enabled = IF(:ok2 = 1, enabled, IF(failure_count + 1 >= 20, 0, enabled))
WHERE id = :id
');
$stmt->execute([
':status' => $ok ? 'ok (' . $status . ')' : 'failed (' . $status . ')',
':error' => $error !== null ? mb_substr($error, 0, 500) : null,
':ok' => $ok ? 1 : 0,
':ok2' => $ok ? 1 : 0,
':id' => $webhookId,
]);
} catch (\Throwable $e) {
Logger::warning('Webhook-Ergebnis nicht gespeichert', ['id' => $webhookId]);
}
}
}
+163 -31
View File
@@ -1,10 +1,22 @@
<?php <?php
declare(strict_types=1);
namespace Deploymentcenter\Modules\UpdateService; namespace Deploymentcenter\Modules\UpdateService;
use Deploymentcenter\Core\Logger;
use Deploymentcenter\Modules\Bugtracker\BugRepo;
use Deploymentcenter\Modules\Notify\WebhookDispatcher;
use PDO; use PDO;
class UpdateManager /**
* Release-Verwaltung des UpdateService.
*
* Der Versionsvergleich findet jetzt in PHP ueber Version::compare() statt.
* Zuvor verglich SQL lexikografisch ("1.9.0" > "1.10.0"), was Clients ein
* Downgrade als Update anbot.
*/
final class UpdateManager
{ {
private PDO $db; private PDO $db;
@@ -13,19 +25,48 @@ class UpdateManager
$this->db = $db; $this->db = $db;
} }
/**
* Ermittelt das neueste Release, das echt neuer ist als die uebergebene Version.
*
* @return array<string,mixed>|null
*/
public function checkUpdate(string $productSlug, string $currentVersion, string $channel = 'prod'): ?array public function checkUpdate(string $productSlug, string $currentVersion, string $channel = 'prod'): ?array
{ {
$stmt = $this->db->prepare(' $stmt = $this->db->prepare('
SELECT * FROM updateservice_releases SELECT * FROM updateservice_releases
WHERE product_slug = :slug AND channel = :channel AND version > :ver WHERE product_slug = :slug AND channel = :channel
ORDER BY created_at DESC LIMIT 1
'); ');
$stmt->execute([':slug' => $productSlug, ':channel' => $channel, ':ver' => $currentVersion]); $stmt->execute([':slug' => $productSlug, ':channel' => $channel]);
$latest = $stmt->fetch(); $releases = $stmt->fetchAll() ?: [];
return $latest ?: null; if ($releases === []) {
return null;
}
$latest = Version::highest($releases);
if ($latest === null) {
return null;
}
return Version::isNewer((string)$latest['version'], $currentVersion) ? $latest : null;
} }
/** Hoechstes Release eines Kanals, unabhaengig von der Client-Version. */
public function latestRelease(string $productSlug, string $channel = 'prod'): ?array
{
$stmt = $this->db->prepare('
SELECT * FROM updateservice_releases
WHERE product_slug = :slug AND channel = :channel
');
$stmt->execute([':slug' => $productSlug, ':channel' => $channel]);
return Version::highest($stmt->fetchAll() ?: []);
}
/**
* Legt ein Release an oder aktualisiert es.
*
* @return array{id:int,created:bool,auto_resolved:int}
*/
public function addRelease( public function addRelease(
string $productSlug, string $productSlug,
string $version, string $version,
@@ -36,51 +77,142 @@ class UpdateManager
?string $gitCommit = null, ?string $gitCommit = null,
int $sizeBytes = 0, int $sizeBytes = 0,
?string $manifestJson = null, ?string $manifestJson = null,
bool $isCritical = false bool $isCritical = false,
): bool { string $author = 'admin'
): array {
$existing = $this->findRelease($productSlug, $version, $channel);
$stmt = $this->db->prepare(' $stmt = $this->db->prepare('
INSERT INTO updateservice_releases ( INSERT INTO updateservice_releases (
product_slug, version, channel, release_notes, download_url, sha256_hash, git_commit, size_bytes, manifest_json, is_critical product_slug, version, channel, release_notes, download_url,
sha256_hash, git_commit, size_bytes, manifest_json, is_critical
) VALUES ( ) VALUES (
:slug, :version, :channel, :notes, :url, :hash, :git, :size, :manifest, :critical :slug, :version, :channel, :notes, :url,
:hash, :git, :size, :manifest, :critical
) ON DUPLICATE KEY UPDATE ) ON DUPLICATE KEY UPDATE
release_notes = VALUES(release_notes), release_notes = VALUES(release_notes),
download_url = VALUES(download_url), download_url = VALUES(download_url),
sha256_hash = VALUES(sha256_hash), sha256_hash = VALUES(sha256_hash),
git_commit = VALUES(git_commit), git_commit = VALUES(git_commit),
size_bytes = VALUES(size_bytes), size_bytes = VALUES(size_bytes),
manifest_json = VALUES(manifest_json), manifest_json = VALUES(manifest_json),
is_critical = VALUES(is_critical) is_critical = VALUES(is_critical)
'); ');
return $stmt->execute([ $stmt->execute([
':slug' => $productSlug, ':slug' => $productSlug,
':version' => $version, ':version' => $version,
':channel' => $channel, ':channel' => $channel,
':notes' => $releaseNotes, ':notes' => $releaseNotes,
':url' => $downloadUrl, ':url' => $downloadUrl,
':hash' => $sha256Hash, ':hash' => $sha256Hash !== null && $sha256Hash !== '' ? $sha256Hash : null,
':git' => $gitCommit, ':git' => $gitCommit !== null && $gitCommit !== '' ? $gitCommit : null,
':size' => $sizeBytes, ':size' => $sizeBytes,
':manifest' => $manifestJson, ':manifest' => $manifestJson,
':critical' => $isCritical ? 1 : 0, ':critical' => $isCritical ? 1 : 0,
]); ]);
$release = $this->findRelease($productSlug, $version, $channel);
$releaseId = $release !== null ? (int)$release['id'] : 0;
// Bugtracker-Items, die fuer genau diesen Build vorgemerkt sind,
// schliessen sich mit der Veroeffentlichung selbst.
$autoResolved = 0;
try {
$bugRepo = new BugRepo($this->db);
$autoResolved = $bugRepo->resolveByBuild($productSlug, $version, $releaseId, $author);
} catch (\Throwable $e) {
Logger::warning('Auto-Resolve beim Release fehlgeschlagen', ['error' => $e->getMessage()]);
}
try {
WebhookDispatcher::dispatch($this->db, 'release.published', [
'project_slug' => $productSlug,
'version' => $version,
'channel' => $channel,
'is_critical' => $isCritical,
'download_url' => $downloadUrl,
'auto_resolved' => $autoResolved,
]);
} catch (\Throwable $e) {
Logger::warning('Release-Webhook fehlgeschlagen', ['error' => $e->getMessage()]);
}
Logger::info('Release veroeffentlicht', [
'product' => $productSlug,
'version' => $version,
'channel' => $channel,
'author' => $author,
]);
return [
'id' => $releaseId,
'created' => $existing === null,
'auto_resolved' => $autoResolved,
];
} }
public function getReleases(?string $productSlug = null, ?string $channel = null): array public function findRelease(string $productSlug, string $version, string $channel): ?array
{ {
if ($productSlug && $channel) { $stmt = $this->db->prepare('
$stmt = $this->db->prepare('SELECT * FROM updateservice_releases WHERE product_slug = :slug AND channel = :channel ORDER BY created_at DESC'); SELECT * FROM updateservice_releases
$stmt->execute([':slug' => $productSlug, ':channel' => $channel]); WHERE product_slug = :slug AND version = :version AND channel = :channel
} elseif ($productSlug) { LIMIT 1
$stmt = $this->db->prepare('SELECT * FROM updateservice_releases WHERE product_slug = :slug ORDER BY created_at DESC'); ');
$stmt->execute([':slug' => $productSlug]); $stmt->execute([':slug' => $productSlug, ':version' => $version, ':channel' => $channel]);
} elseif ($channel) { $row = $stmt->fetch();
$stmt = $this->db->prepare('SELECT * FROM updateservice_releases WHERE channel = :channel ORDER BY created_at DESC'); return is_array($row) ? $row : null;
$stmt->execute([':channel' => $channel]); }
} else {
$stmt = $this->db->query('SELECT * FROM updateservice_releases ORDER BY created_at DESC'); public function deleteRelease(int $id): bool
{
$stmt = $this->db->prepare('DELETE FROM updateservice_releases WHERE id = :id');
$stmt->execute([':id' => $id]);
return $stmt->rowCount() > 0;
}
/**
* Releases, nach Version absteigend sortiert.
*
* @return list<array<string,mixed>>
*/
public function getReleases(?string $productSlug = null, ?string $channel = null, int $limit = 200): array
{
$where = [];
$params = [];
if ($productSlug !== null && $productSlug !== '') {
$where[] = 'product_slug = :slug';
$params[':slug'] = $productSlug;
} }
return $stmt->fetchAll() ?: []; if ($channel !== null && $channel !== '') {
$where[] = 'channel = :channel';
$params[':channel'] = $channel;
}
$sql = 'SELECT * FROM updateservice_releases';
if ($where !== []) {
$sql .= ' WHERE ' . implode(' AND ', $where);
}
$sql .= ' ORDER BY product_slug ASC, channel ASC, created_at DESC LIMIT ' . max(1, min($limit, 1000));
$stmt = $this->db->prepare($sql);
$stmt->execute($params);
$releases = $stmt->fetchAll() ?: [];
// Innerhalb einer Produkt/Kanal-Gruppe nach echter Versionsordnung sortieren.
usort($releases, static function (array $a, array $b): int {
$bySlug = strcmp((string)$a['product_slug'], (string)$b['product_slug']);
if ($bySlug !== 0) {
return $bySlug;
}
$byChannel = strcmp((string)$a['channel'], (string)$b['channel']);
if ($byChannel !== 0) {
return $byChannel;
}
return Version::compare((string)$b['version'], (string)$a['version']);
});
return $releases;
} }
} }
+156
View File
@@ -0,0 +1,156 @@
<?php
declare(strict_types=1);
namespace Deploymentcenter\Modules\UpdateService;
/**
* Semantischer Versionsvergleich.
*
* Der frueher genutzte SQL-Ausdruck "version > :ver" verglich lexikografisch.
* Damit galt '1.9.0' als neuer als '1.10.0' und Clients bekamen ein Downgrade
* als Update angeboten. Der .NET-Client verglich bereits korrekt - Server und
* Client waren sich also uneinig.
*
* Unterstuetzt: "1.2.3", "v1.2.3", "1.2.3-beta.1", "1.2.3+build.5", "1.2".
*/
final class Version
{
/**
* @return int -1 wenn $a < $b, 0 bei Gleichstand, 1 wenn $a > $b
*/
public static function compare(string $a, string $b): int
{
[$coreA, $preA] = self::parse($a);
[$coreB, $preB] = self::parse($b);
$length = max(count($coreA), count($coreB));
for ($i = 0; $i < $length; $i++) {
$partA = $coreA[$i] ?? 0;
$partB = $coreB[$i] ?? 0;
if ($partA !== $partB) {
return $partA <=> $partB;
}
}
// Eine Version ohne Vorabkennung ist hoeher als dieselbe mit
// (1.0.0 > 1.0.0-rc.1), so verlangt es die Semver-Spezifikation.
if ($preA === [] && $preB === []) {
return 0;
}
if ($preA === []) {
return 1;
}
if ($preB === []) {
return -1;
}
return self::comparePrerelease($preA, $preB);
}
public static function isNewer(string $candidate, string $current): bool
{
return self::compare($candidate, $current) > 0;
}
/**
* Waehlt die hoechste Version aus einer Liste von Release-Datensaetzen.
*
* @param list<array<string,mixed>> $releases
* @return array<string,mixed>|null
*/
public static function highest(array $releases, string $versionKey = 'version'): ?array
{
$best = null;
foreach ($releases as $release) {
if (!isset($release[$versionKey]) || !is_string($release[$versionKey])) {
continue;
}
if ($best === null || self::compare($release[$versionKey], (string)$best[$versionKey]) > 0) {
$best = $release;
}
}
return $best;
}
/**
* Zerlegt eine Version in numerischen Kern und Vorabkennung.
*
* @return array{0:list<int>,1:list<string>}
*/
private static function parse(string $version): array
{
$version = trim($version);
$version = ltrim($version, 'vV');
// Build-Metadaten sind fuer die Rangfolge irrelevant.
$plus = strpos($version, '+');
if ($plus !== false) {
$version = substr($version, 0, $plus);
}
$prerelease = [];
$dash = strpos($version, '-');
if ($dash !== false) {
$preString = substr($version, $dash + 1);
$version = substr($version, 0, $dash);
$prerelease = $preString === '' ? [] : explode('.', $preString);
}
$core = [];
foreach (explode('.', $version) as $part) {
$core[] = (int)preg_replace('/\D/', '', $part);
}
if ($core === []) {
$core = [0];
}
return [$core, $prerelease];
}
/**
* @param list<string> $a
* @param list<string> $b
*/
private static function comparePrerelease(array $a, array $b): int
{
$length = max(count($a), count($b));
for ($i = 0; $i < $length; $i++) {
// Weniger Bestandteile = niedrigere Rangfolge (rc < rc.1)
if (!isset($a[$i])) {
return -1;
}
if (!isset($b[$i])) {
return 1;
}
$partA = $a[$i];
$partB = $b[$i];
$numericA = ctype_digit($partA);
$numericB = ctype_digit($partB);
if ($numericA && $numericB) {
$cmp = (int)$partA <=> (int)$partB;
if ($cmp !== 0) {
return $cmp;
}
continue;
}
// Rein numerische Bestandteile rangieren unter alphanumerischen.
if ($numericA !== $numericB) {
return $numericA ? -1 : 1;
}
$cmp = strcmp($partA, $partB);
if ($cmp !== 0) {
return $cmp > 0 ? 1 : -1;
}
}
return 0;
}
}
+261
View File
@@ -0,0 +1,261 @@
<?php
declare(strict_types=1);
namespace Deploymentcenter\Modules\Watchdog;
use Deploymentcenter\Core\Logger;
use Deploymentcenter\Modules\Bugtracker\BugRepo;
use Deploymentcenter\Modules\Notify\WebhookDispatcher;
use PDO;
/**
* Watchdog-Evaluator.
*
* Diese Komponente fehlte bislang vollstaendig. Der Zustand eines Monitors
* aenderte sich ausschliesslich beim Eintreffen eines Heartbeats - ein
* ausgefallener Server blieb im Dashboard damit fuer immer gruen. Die Spalten
* expected_interval_sec, is_muted, suppress_until_utc und expect_running waren
* angelegt, wurden aber von keiner Zeile Code ausgewertet.
*
* Der Evaluator vergleicht last_seen_utc mit dem erwarteten Intervall und
* stuft Monitore entsprechend auf warning bzw. down. Zustandswechsel landen
* im Event-Log und loesen Webhooks aus.
*
* Aufruf per Cron (empfohlen minuetlich):
* curl -H "Authorization: Bearer <SHARED_KEY>" https://dc.example.com/api/watchdog/v1/evaluate
*/
final class Evaluator
{
/** Ab dem Wievielfachen des Intervalls gilt ein Monitor als auffaellig. */
private const WARNING_FACTOR = 2.0;
/** Ab dem Wievielfachen des Intervalls gilt ein Monitor als ausgefallen. */
private const DOWN_FACTOR = 4.0;
/** Kulanz fuer neu angelegte Monitore, die noch nie gemeldet haben. */
private const FIRST_CONTACT_GRACE = 3;
/**
* Fuehrt einen Evaluationslauf durch.
*
* @return array<string,mixed>
*/
public static function run(PDO $db): array
{
$started = microtime(true);
$monitorRepo = new MonitorRepo($db);
$eventLog = new EventLog($db);
$candidates = $monitorRepo->getEvaluationCandidates();
$changes = [];
$checked = 0;
foreach ($candidates as $monitor) {
$checked++;
$current = (string)$monitor['state'];
$target = self::desiredState($monitor);
if ($target === null || $target === $current) {
continue;
}
$reason = self::reasonFor($monitor, $target);
$monitorRepo->setState((int)$monitor['id'], $target, $reason);
$eventLog->logEvent(
(string)$monitor['source'],
(string)$monitor['instance'],
self::eventKindFor($current, $target),
$current,
$target,
$target === 'down' ? 'alarm' : ($target === 'warning' ? 'warning' : 'info'),
$reason
);
$changes[] = [
'source' => $monitor['source'],
'from' => $current,
'to' => $target,
'reason' => $reason,
];
// Stummgeschaltete Monitore erscheinen im Dashboard, loesen aber
// keine Benachrichtigung aus.
if (empty($monitor['is_muted'])) {
self::notify($db, $monitor, $current, $target, $reason);
}
}
// Abgelaufene Bugtracker-Leases freigeben, damit haengengebliebene
// Agenten kein Item dauerhaft blockieren.
$releasedLeases = 0;
try {
$releasedLeases = (new BugRepo($db))->expireStaleLeases();
} catch (\Throwable $e) {
Logger::warning('Lease-Bereinigung fehlgeschlagen', ['error' => $e->getMessage()]);
}
$durationMs = (int)round((microtime(true) - $started) * 1000);
self::recordRun($db, $durationMs, count($changes));
if ($changes !== []) {
Logger::info('Watchdog-Evaluator: Zustandswechsel', ['changes' => $changes]);
}
return [
'checked' => $checked,
'changed' => count($changes),
'changes' => $changes,
'released_leases' => $releasedLeases,
'duration_ms' => $durationMs,
];
}
/**
* Ermittelt den Zustand, den ein Monitor haben sollte.
* null bedeutet: keine Aenderung noetig.
*
* @param array<string,mixed> $monitor
*/
private static function desiredState(array $monitor): ?string
{
// Bewusst gestoppte Dienste werden nicht als Ausfall gewertet.
if ((int)($monitor['expect_running'] ?? 1) === 0) {
return null;
}
if ((string)$monitor['state'] === 'stopped') {
return null;
}
$interval = max(10, (int)($monitor['expected_interval_sec'] ?? 60));
$lastSeen = $monitor['last_seen_utc'] ?? null;
if ($lastSeen === null || $lastSeen === '') {
// Noch nie ein Heartbeat: erst nach einer Kulanzfrist als down werten.
$created = $monitor['created_utc'] ?? null;
$createdTs = is_string($created) ? strtotime($created . ' UTC') : false;
if ($createdTs === false) {
return 'unknown';
}
$age = time() - $createdTs;
return $age > ($interval * self::FIRST_CONTACT_GRACE) ? 'down' : 'unknown';
}
$lastSeenTs = strtotime((string)$lastSeen . ' UTC');
if ($lastSeenTs === false) {
return null;
}
$age = time() - $lastSeenTs;
if ($age > $interval * self::DOWN_FACTOR) {
return 'down';
}
if ($age > $interval * self::WARNING_FACTOR) {
return 'warning';
}
// Innerhalb des Intervalls: der Heartbeat selbst bestimmt den Zustand.
// Ein zuvor als down/warning markierter Monitor, der wieder meldet,
// wird bereits durch upsertHeartbeat() auf up gesetzt.
return null;
}
/** @param array<string,mixed> $monitor */
private static function reasonFor(array $monitor, string $target): string
{
$interval = max(10, (int)($monitor['expected_interval_sec'] ?? 60));
$lastSeen = $monitor['last_seen_utc'] ?? null;
if ($lastSeen === null || $lastSeen === '') {
return sprintf('Seit Anlage kein Heartbeat empfangen (erwartet alle %ds).', $interval);
}
$lastSeenTs = strtotime((string)$lastSeen . ' UTC');
$age = $lastSeenTs !== false ? time() - $lastSeenTs : 0;
return sprintf(
'Letzter Heartbeat vor %s (erwartet alle %ds) -> %s.',
self::humanDuration($age),
$interval,
$target
);
}
private static function eventKindFor(string $from, string $to): string
{
if ($to === 'down') {
return 'crash_suspected';
}
if ($to === 'warning') {
return 'warning_raised';
}
if ($from === 'down' || $from === 'warning') {
return 'recovered';
}
return 'warning_cleared';
}
/** @param array<string,mixed> $monitor */
private static function notify(PDO $db, array $monitor, string $from, string $to, string $reason): void
{
$event = $to === 'down' ? 'monitor.down' : ($to === 'up' ? 'monitor.recovered' : null);
if ($event === null) {
return;
}
try {
WebhookDispatcher::dispatch($db, $event, [
'source' => $monitor['source'],
'instance' => $monitor['instance'],
'group' => $monitor['group_key'] ?? null,
'from_state' => $from,
'to_state' => $to,
'reason' => $reason,
]);
} catch (\Throwable $e) {
Logger::warning('Monitor-Webhook fehlgeschlagen', ['error' => $e->getMessage()]);
}
}
private static function recordRun(PDO $db, int $durationMs, int $changes): void
{
try {
$stmt = $db->prepare('
INSERT INTO watchdog_cron_jobs (name, interval_sec, last_run_utc, running, last_status, last_duration_ms, enabled)
VALUES ("evaluator", 60, UTC_TIMESTAMP(), 0, :status, :duration, 1)
ON DUPLICATE KEY UPDATE
last_run_utc = UTC_TIMESTAMP(),
running = 0,
last_status = VALUES(last_status),
last_duration_ms = VALUES(last_duration_ms)
');
$stmt->execute([
':status' => $changes > 0 ? 'ok (' . $changes . ' Wechsel)' : 'ok',
':duration' => $durationMs,
]);
} catch (\Throwable $e) {
Logger::warning('Evaluator-Lauf nicht protokolliert', ['error' => $e->getMessage()]);
}
}
private static function humanDuration(int $seconds): string
{
if ($seconds < 60) {
return $seconds . ' s';
}
if ($seconds < 3600) {
return intdiv($seconds, 60) . ' min';
}
if ($seconds < 86400) {
return intdiv($seconds, 3600) . ' h';
}
return intdiv($seconds, 86400) . ' Tage';
}
}
+48 -15
View File
@@ -1,11 +1,24 @@
<?php <?php
declare(strict_types=1);
namespace Deploymentcenter\Modules\Watchdog; namespace Deploymentcenter\Modules\Watchdog;
use PDO; use PDO;
class EventLog /**
* Chronologisches Ereignisprotokoll des Watchdog-Moduls.
*/
final class EventLog
{ {
public const KINDS = [
'started', 'stopped_graceful', 'crash_suspected', 'hard_error',
'recovered', 'warning_raised', 'warning_cleared',
'maintenance_start', 'maintenance_end', 'watchdog_started',
];
public const SEVERITIES = ['info', 'warning', 'alarm'];
private PDO $db; private PDO $db;
public function __construct(PDO $db) public function __construct(PDO $db)
@@ -23,25 +36,25 @@ class EventLog
?string $message = null, ?string $message = null,
$meta = null $meta = null
): int { ): int {
$metaJson = is_array($meta) || is_object($meta) ? json_encode($meta, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) : null; $metaJson = (is_array($meta) || is_object($meta))
$nowUtc = date('Y-m-d H:i:s'); ? json_encode($meta, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)
: null;
$stmt = $this->db->prepare(' $stmt = $this->db->prepare('
INSERT INTO watchdog_event_log ( INSERT INTO watchdog_event_log (
source, instance, kind, from_state, to_state, severity, at_utc, message, meta_json source, instance, kind, from_state, to_state, severity, at_utc, message, meta_json
) VALUES ( ) VALUES (
:source, :instance, :kind, :from_state, :to_state, :severity, :now, :message, :meta :source, :instance, :kind, :from_state, :to_state, :severity, UTC_TIMESTAMP(), :message, :meta
) )
'); ');
$stmt->execute([ $stmt->execute([
':source' => $source, ':source' => mb_substr($source, 0, 100),
':instance' => $instance, ':instance' => mb_substr($instance, 0, 100),
':kind' => $kind, ':kind' => in_array($kind, self::KINDS, true) ? $kind : 'hard_error',
':from_state' => $fromState, ':from_state' => $fromState,
':to_state' => $toState, ':to_state' => $toState,
':severity' => $severity, ':severity' => in_array($severity, self::SEVERITIES, true) ? $severity : 'info',
':now' => $nowUtc,
':message' => $message, ':message' => $message,
':meta' => $metaJson, ':meta' => $metaJson,
]); ]);
@@ -49,29 +62,49 @@ class EventLog
return (int)$this->db->lastInsertId(); return (int)$this->db->lastInsertId();
} }
public function getRecentEvents(int $limit = 50, ?string $source = null, ?string $instance = null): array /**
* @return list<array<string,mixed>>
*/
public function getRecentEvents(int $limit = 50, ?string $source = null, ?string $instance = null, ?string $severity = null): array
{ {
$sql = 'SELECT * FROM watchdog_event_log';
$where = []; $where = [];
$params = []; $params = [];
if ($source !== null) { if ($source !== null && $source !== '') {
$where[] = 'source = :source'; $where[] = 'source = :source';
$params[':source'] = $source; $params[':source'] = $source;
} }
if ($instance !== null) { if ($instance !== null && $instance !== '') {
$where[] = 'instance = :instance'; $where[] = 'instance = :instance';
$params[':instance'] = $instance; $params[':instance'] = $instance;
} }
if ($severity !== null && in_array($severity, self::SEVERITIES, true)) {
$where[] = 'severity = :severity';
$params[':severity'] = $severity;
}
if (!empty($where)) { $sql = 'SELECT * FROM watchdog_event_log';
if ($where !== []) {
$sql .= ' WHERE ' . implode(' AND ', $where); $sql .= ' WHERE ' . implode(' AND ', $where);
} }
$sql .= ' ORDER BY at_utc DESC LIMIT ' . (int)$limit; // Limit wird als Integer interpoliert; der Wert ist durch max/min begrenzt.
$sql .= ' ORDER BY at_utc DESC, id DESC LIMIT ' . max(1, min($limit, 1000));
$stmt = $this->db->prepare($sql); $stmt = $this->db->prepare($sql);
$stmt->execute($params); $stmt->execute($params);
return $stmt->fetchAll() ?: []; return $stmt->fetchAll() ?: [];
} }
/** Loescht Eintraege, die aelter als die angegebene Anzahl Tage sind. */
public function purgeOlderThan(int $days): int
{
$days = max(1, min($days, 3650));
$stmt = $this->db->prepare(
'DELETE FROM watchdog_event_log WHERE at_utc < (UTC_TIMESTAMP() - INTERVAL ' . $days . ' DAY)'
);
$stmt->execute();
return $stmt->rowCount();
}
} }
+355 -86
View File
@@ -1,11 +1,49 @@
<?php <?php
declare(strict_types=1);
namespace Deploymentcenter\Modules\Watchdog; namespace Deploymentcenter\Modules\Watchdog;
use Deploymentcenter\Core\Logger;
use PDO; use PDO;
use RuntimeException;
class MonitorRepo /**
* Monitore des Watchdog-Moduls.
*
* Korrekturen gegenueber der Erstfassung:
* - updateMonitor() nimmt jetzt ein Feld-Array entgegen und aktualisiert nur
* die tatsaechlich uebergebenen Spalten. Zuvor wurden alle Spalten fest
* geschrieben; fehlte ein Formularfeld (z. B. "os", das es im Bearbeiten-
* Dialog gar nicht gab), wurde die Spalte bei jedem Speichern auf NULL
* gesetzt - und damit auch die automatische Icon-Erkennung zerstoert.
* - Das Umbenennen einer Source laeuft in einer Transaktion. Vorher wurden
* erst Kinder und Tokens umgehaengt und danach umbenannt; scheiterte das
* Umbenennen am Unique-Key, zeigten die Kinder auf einen Parent, den es
* nicht mehr gab.
* - Beim Umbenennen wird auch das Event-Log mitgezogen, damit die Historie
* nicht abreisst.
*/
final class MonitorRepo
{ {
public const STATES = ['up', 'warning', 'down', 'error', 'stopped', 'maintenance', 'unknown'];
public const TYPES = ['heartbeat', 'host', 'hypervisor_node', 'guest'];
/** Spalten, die ueber updateMonitor() gesetzt werden duerfen. */
private const UPDATABLE = [
'source' => 'source',
'type' => 'type',
'group_key' => 'group_key',
'parent_source' => 'parent_source',
'os' => 'os',
'notes' => 'notes',
'url' => 'url',
'icon' => 'icon',
'expected_interval_sec' => 'expected_interval_sec',
'is_muted' => 'is_muted',
'expect_running' => 'expect_running',
];
private PDO $db; private PDO $db;
public function __construct(PDO $db) public function __construct(PDO $db)
@@ -13,20 +51,27 @@ class MonitorRepo
$this->db = $db; $this->db = $db;
} }
/** @return list<array<string,mixed>> */
public function getAllMonitors(): array public function getAllMonitors(): array
{ {
$stmt = $this->db->query('SELECT * FROM watchdog_monitors ORDER BY group_key ASC, source ASC'); $stmt = $this->db->query('
SELECT * FROM watchdog_monitors
ORDER BY COALESCE(group_key, "zzz") ASC, source ASC
');
return $stmt->fetchAll() ?: []; return $stmt->fetchAll() ?: [];
} }
public function getMonitor(string $source, string $instance = 'default'): ?array public function getMonitor(string $source, string $instance = 'default'): ?array
{ {
$stmt = $this->db->prepare('SELECT * FROM watchdog_monitors WHERE source = :s AND instance = :i'); $stmt = $this->db->prepare('SELECT * FROM watchdog_monitors WHERE source = :source AND instance = :instance');
$stmt->execute([':s' => $source, ':i' => $instance]); $stmt->execute([':source' => $source, ':instance' => $instance]);
$row = $stmt->fetch(); $row = $stmt->fetch();
return $row ?: null; return is_array($row) ? $row : null;
} }
/**
* Nimmt einen Heartbeat entgegen und legt den Monitor bei Bedarf an.
*/
public function upsertHeartbeat( public function upsertHeartbeat(
string $source, string $source,
string $instance, string $instance,
@@ -38,27 +83,47 @@ class MonitorRepo
?string $groupKey = null, ?string $groupKey = null,
?string $os = null ?string $os = null
): array { ): array {
$metricsJson = is_array($metrics) || is_object($metrics) ? json_encode($metrics, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) : null; $metricsJson = (is_array($metrics) || is_object($metrics))
$state = ($status === 'ok') ? 'up' : (($status === 'warning') ? 'warning' : 'down'); ? json_encode($metrics, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)
: null;
$state = match ($status) {
'ok' => 'up',
'warning' => 'warning',
default => 'down',
};
$previous = $this->getMonitor($source, $instance);
$previousState = $previous !== null ? (string)$previous['state'] : null;
$type = in_array($type, self::TYPES, true) ? $type : 'heartbeat';
$intervalSec = max(10, min($intervalSec, 86400));
$stmt = $this->db->prepare(' $stmt = $this->db->prepare('
INSERT INTO watchdog_monitors ( INSERT INTO watchdog_monitors (
source, instance, type, state, expected_interval_sec, last_seen_utc, source, instance, type, state, last_state_change_utc, expected_interval_sec,
last_status, last_message, metrics_json, group_key, os, created_utc, updated_utc last_seen_utc, last_status, last_message, metrics_json, group_key, os,
created_utc, updated_utc
) VALUES ( ) VALUES (
:source, :instance, :type, :state, :interval, NOW(), :source, :instance, :type, :state, UTC_TIMESTAMP(), :interval,
:last_status, :message, :metrics, :group_key, :os, NOW(), NOW() UTC_TIMESTAMP(), :last_status, :message, :metrics, :group_key, :os,
UTC_TIMESTAMP(), UTC_TIMESTAMP()
) )
ON DUPLICATE KEY UPDATE ON DUPLICATE KEY UPDATE
state = VALUES(state), -- Reihenfolge ist relevant: MySQL wertet die Zuweisungen von
-- links nach rechts aus. last_state_change_utc muss den alten
-- Wert von state sehen, also vor dessen Zuweisung stehen.
last_state_change_utc = IF(state <> VALUES(state), UTC_TIMESTAMP(), last_state_change_utc),
state = VALUES(state),
down_since_utc = IF(VALUES(state) = "up", NULL, down_since_utc),
expected_interval_sec = VALUES(expected_interval_sec), expected_interval_sec = VALUES(expected_interval_sec),
last_seen_utc = VALUES(last_seen_utc), last_seen_utc = VALUES(last_seen_utc),
last_status = VALUES(last_status), last_status = VALUES(last_status),
last_message = VALUES(last_message), last_message = VALUES(last_message),
metrics_json = VALUES(metrics_json), metrics_json = VALUES(metrics_json),
group_key = COALESCE(VALUES(group_key), group_key), group_key = COALESCE(VALUES(group_key), group_key),
os = COALESCE(VALUES(os), os), os = COALESCE(VALUES(os), os),
updated_utc = VALUES(updated_utc) updated_utc = VALUES(updated_utc)
'); ');
$stmt->execute([ $stmt->execute([
@@ -67,101 +132,305 @@ class MonitorRepo
':type' => $type, ':type' => $type,
':state' => $state, ':state' => $state,
':interval' => $intervalSec, ':interval' => $intervalSec,
':last_status' => $status, ':last_status' => in_array($status, ['ok', 'warning', 'error'], true) ? $status : 'error',
':message' => $message, ':message' => $message,
':metrics' => $metricsJson, ':metrics' => $metricsJson,
':group_key' => $groupKey, ':group_key' => $groupKey,
':os' => $os, ':os' => $os,
]); ]);
return $this->getMonitor($source, $instance); $monitor = $this->getMonitor($source, $instance);
} if ($monitor === null) {
throw new RuntimeException('Monitor konnte nicht gespeichert werden: ' . $source);
public function updateMonitor(
string $oldSource,
string $newSource,
string $instance,
?string $type,
?string $groupKey,
?string $parentSource,
?string $os,
?string $notes,
?string $url,
?int $intervalSec,
?string $icon = null,
bool $isMuted = false
): bool {
// Cascade source renaming to children & agent tokens
if ($oldSource !== $newSource) {
$this->db->prepare('UPDATE watchdog_monitors SET parent_source = :new WHERE parent_source = :old')
->execute([':new' => $newSource, ':old' => $oldSource]);
$this->db->prepare('UPDATE watchdog_agent_tokens SET monitor_source = :new WHERE monitor_source = :old')
->execute([':new' => $newSource, ':old' => $oldSource]);
} }
$monitor['_previous_state'] = $previousState;
$monitor['_state_changed'] = $previousState !== null && $previousState !== $state;
return $monitor;
}
/**
* Legt einen Monitor manuell an (ohne Heartbeat).
*
* @param array<string,mixed> $fields
*/
public function createMonitor(string $source, array $fields = []): array
{
$stmt = $this->db->prepare(' $stmt = $this->db->prepare('
UPDATE watchdog_monitors SET INSERT INTO watchdog_monitors (
source = :new_source, source, instance, type, state, expected_interval_sec,
type = COALESCE(:type, type), group_key, parent_source, os, created_utc, updated_utc
group_key = :group_key, ) VALUES (
parent_source = :parent_source, :source, "default", :type, "unknown", :interval,
os = :os, :group_key, :parent_source, :os, UTC_TIMESTAMP(), UTC_TIMESTAMP()
notes = :notes, )
url = :url, ON DUPLICATE KEY UPDATE
expected_interval_sec = COALESCE(:interval, expected_interval_sec), expected_interval_sec = VALUES(expected_interval_sec),
icon = COALESCE(:icon, icon), group_key = VALUES(group_key),
is_muted = :is_muted, parent_source = VALUES(parent_source),
updated_utc = NOW() os = COALESCE(VALUES(os), os),
WHERE source = :old_source AND instance = :instance updated_utc = UTC_TIMESTAMP()
'); ');
return $stmt->execute([ $type = (string)($fields['type'] ?? 'heartbeat');
':new_source' => $newSource,
':type' => !empty($type) ? $type : null, $stmt->execute([
':group_key' => !empty($groupKey) ? $groupKey : null, ':source' => $source,
':parent_source' => !empty($parentSource) ? $parentSource : null, ':type' => in_array($type, self::TYPES, true) ? $type : 'heartbeat',
':os' => !empty($os) ? $os : null, ':interval' => max(10, min((int)($fields['expected_interval_sec'] ?? 60), 86400)),
':notes' => !empty($notes) ? $notes : null, ':group_key' => self::nullIfEmpty($fields['group_key'] ?? null),
':url' => !empty($url) ? $url : null, ':parent_source' => self::nullIfEmpty($fields['parent_source'] ?? null),
':interval' => $intervalSec, ':os' => self::nullIfEmpty($fields['os'] ?? null),
':icon' => !empty($icon) ? $icon : null,
':is_muted' => $isMuted ? 1 : 0,
':old_source' => $oldSource,
':instance' => $instance,
]); ]);
return $this->getMonitor($source) ?? [];
}
/**
* Aktualisiert einen Monitor. Es werden ausschliesslich die in $fields
* enthaltenen Spalten geschrieben - alles andere bleibt unangetastet.
*
* @param array<string,mixed> $fields
*/
public function updateMonitor(string $oldSource, string $instance, array $fields): bool
{
$monitor = $this->getMonitor($oldSource, $instance);
if ($monitor === null) {
return false;
}
$newSource = isset($fields['source']) ? trim((string)$fields['source']) : $oldSource;
if ($newSource === '') {
$newSource = $oldSource;
}
$renaming = $newSource !== $oldSource;
if ($renaming) {
$conflict = $this->getMonitor($newSource, $instance);
if ($conflict !== null) {
throw new RuntimeException(
sprintf('Ein Monitor namens "%s" existiert bereits.', $newSource)
);
}
}
$set = [];
$params = [':old_source' => $oldSource, ':instance' => $instance];
foreach (self::UPDATABLE as $key => $column) {
if (!array_key_exists($key, $fields)) {
continue;
}
$value = $fields[$key];
if ($column === 'expected_interval_sec') {
$value = max(10, min((int)$value, 86400));
} elseif ($column === 'is_muted' || $column === 'expect_running') {
$value = !empty($value) ? 1 : 0;
} elseif ($column === 'type') {
if (!in_array((string)$value, self::TYPES, true)) {
continue;
}
} elseif ($column === 'source') {
$value = $newSource;
} else {
$value = self::nullIfEmpty($value);
}
$set[] = $column . ' = :f_' . $key;
$params[':f_' . $key] = $value;
}
if ($set === []) {
return true;
}
$set[] = 'updated_utc = UTC_TIMESTAMP()';
// Umbenennung und alle abhaengigen Aktualisierungen als eine Einheit.
$ownTransaction = !$this->db->inTransaction();
if ($ownTransaction) {
$this->db->beginTransaction();
}
try {
$stmt = $this->db->prepare(
'UPDATE watchdog_monitors SET ' . implode(', ', $set)
. ' WHERE source = :old_source AND instance = :instance'
);
$stmt->execute($params);
if ($renaming) {
$this->cascadeRename($oldSource, $newSource);
}
if ($ownTransaction) {
$this->db->commit();
}
} catch (\Throwable $e) {
if ($ownTransaction && $this->db->inTransaction()) {
$this->db->rollBack();
}
throw $e;
}
return true;
}
/** Zieht Kinder, Agent-Tokens und Event-Log auf den neuen Namen um. */
private function cascadeRename(string $oldSource, string $newSource): void
{
$updates = [
'UPDATE watchdog_monitors SET parent_source = :new WHERE parent_source = :old',
'UPDATE watchdog_agent_tokens SET monitor_source = :new WHERE monitor_source = :old',
'UPDATE watchdog_event_log SET source = :new WHERE source = :old',
];
foreach ($updates as $sql) {
$this->db->prepare($sql)->execute([':new' => $newSource, ':old' => $oldSource]);
}
Logger::info('Monitor umbenannt', ['from' => $oldSource, 'to' => $newSource]);
} }
public function setParentSource(string $source, ?string $parentSource, string $instance = 'default'): bool public function setParentSource(string $source, ?string $parentSource, string $instance = 'default'): bool
{ {
$stmt = $this->db->prepare('UPDATE watchdog_monitors SET parent_source = :parent, updated_utc = NOW() WHERE source = :s AND instance = :i'); // Ein Monitor darf nicht sein eigener Parent sein.
return $stmt->execute([':parent' => !empty($parentSource) ? $parentSource : null, ':s' => $source, ':i' => $instance]); if ($parentSource !== null && trim($parentSource) === $source) {
$parentSource = null;
}
$stmt = $this->db->prepare('
UPDATE watchdog_monitors
SET parent_source = :parent, updated_utc = UTC_TIMESTAMP()
WHERE source = :source AND instance = :instance
');
$stmt->execute([
':parent' => self::nullIfEmpty($parentSource),
':source' => $source,
':instance' => $instance,
]);
return $stmt->rowCount() > 0;
} }
public function updateState(int $id, string $state, ?string $reason = null): bool /**
* Setzt den Zustand eines Monitors und vermerkt den Wechselzeitpunkt.
*/
public function setState(int $id, string $state, ?string $reason = null): bool
{ {
$stmt = $this->db->prepare('UPDATE watchdog_monitors SET state = :state, metric_state_reason = :reason, updated_utc = NOW() WHERE id = :id'); if (!in_array($state, self::STATES, true)) {
return $stmt->execute([':state' => $state, ':reason' => $reason, ':id' => $id]); return false;
}
$isDown = in_array($state, ['down', 'error'], true);
$isUp = $state === 'up';
// Reihenfolge beachten: last_state_change_utc und down_since_utc muessen
// den alten Wert von state sehen, stehen daher vor dessen Zuweisung.
$stmt = $this->db->prepare('
UPDATE watchdog_monitors
SET last_state_change_utc = IF(state <> :state_cmp, UTC_TIMESTAMP(), last_state_change_utc),
down_since_utc = CASE
WHEN :is_down = 1 AND down_since_utc IS NULL THEN UTC_TIMESTAMP()
WHEN :is_up = 1 THEN NULL
ELSE down_since_utc
END,
state = :state,
metric_state_reason = :reason,
updated_utc = UTC_TIMESTAMP()
WHERE id = :id
');
$stmt->execute([
':state_cmp' => $state,
':is_down' => $isDown ? 1 : 0,
':is_up' => $isUp ? 1 : 0,
':state' => $state,
':reason' => $reason,
':id' => $id,
]);
return $stmt->rowCount() > 0;
} }
public function setMaintenance(string $source, string $instance, ?string $untilUtc): bool public function setMaintenance(string $source, string $instance, ?string $untilUtc): bool
{ {
$state = $untilUtc ? 'maintenance' : 'up'; $stmt = $this->db->prepare('
$stmt = $this->db->prepare('UPDATE watchdog_monitors SET state = :state, suppress_until_utc = :until, updated_utc = NOW() WHERE source = :s AND instance = :i'); UPDATE watchdog_monitors
return $stmt->execute([':state' => $state, ':until' => $untilUtc, ':s' => $source, ':i' => $instance]); SET state = :state,
suppress_until_utc = :until,
updated_utc = UTC_TIMESTAMP()
WHERE source = :source AND instance = :instance
');
$stmt->execute([
':state' => $untilUtc !== null ? 'maintenance' : 'unknown',
':until' => $untilUtc,
':source' => $source,
':instance' => $instance,
]);
return $stmt->rowCount() > 0;
} }
public function deleteMonitor(string $source, string $instance = 'default'): bool public function deleteMonitor(string $source, string $instance = 'default'): bool
{ {
// 1. Unlink any children $ownTransaction = !$this->db->inTransaction();
$this->db->prepare('UPDATE watchdog_monitors SET parent_source = NULL WHERE parent_source = :s') if ($ownTransaction) {
->execute([':s' => $source]); $this->db->beginTransaction();
}
// 2. Revoke agent tokens for this monitor try {
$this->db->prepare('UPDATE watchdog_agent_tokens SET revoked = 1 WHERE monitor_source = :s') // Kinder auf Top-Level heben, damit keine Waisen entstehen.
->execute([':s' => $source]); $this->db->prepare('UPDATE watchdog_monitors SET parent_source = NULL WHERE parent_source = :source')
->execute([':source' => $source]);
// 3. Delete monitor // Zugehoerige Agent-Tokens entwerten.
$stmt = $this->db->prepare('DELETE FROM watchdog_monitors WHERE source = :s AND instance = :i'); $this->db->prepare('UPDATE watchdog_agent_tokens SET revoked = 1 WHERE monitor_source = :source')
return $stmt->execute([':s' => $source, ':i' => $instance]); ->execute([':source' => $source]);
$stmt = $this->db->prepare('DELETE FROM watchdog_monitors WHERE source = :source AND instance = :instance');
$stmt->execute([':source' => $source, ':instance' => $instance]);
$deleted = $stmt->rowCount() > 0;
if ($ownTransaction) {
$this->db->commit();
}
return $deleted;
} catch (\Throwable $e) {
if ($ownTransaction && $this->db->inTransaction()) {
$this->db->rollBack();
}
throw $e;
}
}
/**
* Monitore, die der Evaluator pruefen muss.
*
* @return list<array<string,mixed>>
*/
public function getEvaluationCandidates(): array
{
$stmt = $this->db->query('
SELECT * FROM watchdog_monitors
WHERE state <> "maintenance"
AND (suppress_until_utc IS NULL OR suppress_until_utc < UTC_TIMESTAMP())
');
return $stmt->fetchAll() ?: [];
}
/** @param mixed $value */
private static function nullIfEmpty($value): ?string
{
if ($value === null || is_array($value) || is_object($value)) {
return null;
}
$value = trim((string)$value);
return $value === '' ? null : $value;
} }
} }
+56 -22
View File
@@ -1,10 +1,24 @@
<?php <?php
declare(strict_types=1);
namespace Deploymentcenter\Modules\Watchdog; namespace Deploymentcenter\Modules\Watchdog;
use Deploymentcenter\Core\Logger;
use PDO; use PDO;
class TokenManager /**
* Alt-Tokens des Watchdog-Moduls (Tabelle watchdog_agent_tokens).
*
* Diese Tabelle existiert parallel zur zentralen dc_tokens-Hierarchie. Neue
* Integrationen sollten Master-/Sub-Tokens mit dem Scope "watchdog:ping"
* verwenden; der Watchdog-Endpunkt akzeptiert beide. Diese Klasse bleibt
* bestehen, damit bereits ausgerollte Agenten weiterlaufen.
*
* Die Validierung vergleicht nur noch den SHA-256-Hash, nicht mehr zusaetzlich
* den Klartext.
*/
final class TokenManager
{ {
private PDO $db; private PDO $db;
@@ -13,57 +27,77 @@ class TokenManager
$this->db = $db; $this->db = $db;
} }
public function createToken(string $source, ?string $name = null, string $notes = ''): array public function createToken(string $source, ?string $name = null): array
{ {
$tokenId = 'tok_' . bin2hex(random_bytes(8)); $tokenId = 'tok_' . bin2hex(random_bytes(8));
$rawToken = 'wd_live_' . bin2hex(random_bytes(18)); $rawToken = 'wd_live_' . bin2hex(random_bytes(24));
$tokenHash = hash('sha256', $rawToken);
$tokenName = !empty($name) ? $name : ("Token for " . ($source ?: 'General'));
$stmt = $this->db->prepare(' $stmt = $this->db->prepare('
INSERT INTO watchdog_agent_tokens ( INSERT INTO watchdog_agent_tokens (
token_id, token_hash, raw_token, name, monitor_source, created_at_utc token_id, token_hash, raw_token, name, monitor_source, created_at_utc
) VALUES ( ) VALUES (
:id, :hash, :raw, :name, :source, NOW() :id, :hash, :raw, :name, :source, UTC_TIMESTAMP()
) )
'); ');
$stmt->execute([ $stmt->execute([
':id' => $tokenId, ':id' => $tokenId,
':hash' => $tokenHash, ':hash' => hash('sha256', $rawToken),
':raw' => $rawToken, ':raw' => $rawToken,
':name' => $tokenName, ':name' => $name !== null && trim($name) !== '' ? trim($name) : ('Token fuer ' . ($source !== '' ? $source : 'Allgemein')),
':source' => !empty($source) ? $source : null, ':source' => $source !== '' ? $source : null,
]); ]);
return [ return ['token_id' => $tokenId, 'raw_token' => $rawToken];
'token_id' => $tokenId,
'raw_token' => $rawToken,
];
} }
/**
* Prueft ein Alt-Token. Ist es an eine Source gebunden, darf es nur
* fuer genau diese verwendet werden.
*/
public function validateToken(string $rawToken, string $targetSource): bool public function validateToken(string $rawToken, string $targetSource): bool
{ {
$hash = hash('sha256', $rawToken); $rawToken = trim($rawToken);
$stmt = $this->db->prepare('SELECT * FROM watchdog_agent_tokens WHERE (token_hash = :hash OR raw_token = :raw) AND revoked = 0'); if ($rawToken === '') {
$stmt->execute([':hash' => $hash, ':raw' => $rawToken]); return false;
}
$stmt = $this->db->prepare('
SELECT token_id, monitor_source
FROM watchdog_agent_tokens
WHERE token_hash = :hash AND revoked = 0
LIMIT 1
');
$stmt->execute([':hash' => hash('sha256', $rawToken)]);
$row = $stmt->fetch(); $row = $stmt->fetch();
if (!$row) { if (!is_array($row)) {
return false; return false;
} }
if (!empty($row['monitor_source']) && $row['monitor_source'] !== $targetSource) { $boundSource = $row['monitor_source'] ?? null;
if (is_string($boundSource) && $boundSource !== '' && $boundSource !== $targetSource) {
return false; return false;
} }
$upd = $this->db->prepare('UPDATE watchdog_agent_tokens SET last_used_at_utc = NOW() WHERE token_id = :id'); try {
$upd->execute([':id' => $row['token_id']]); $upd = $this->db->prepare('UPDATE watchdog_agent_tokens SET last_used_at_utc = UTC_TIMESTAMP() WHERE token_id = :id');
$upd->execute([':id' => $row['token_id']]);
} catch (\Throwable $e) {
Logger::warning('last_used_at_utc nicht aktualisiert', ['token_id' => $row['token_id']]);
}
return true; return true;
} }
public function revokeToken(string $tokenId): bool
{
$stmt = $this->db->prepare('UPDATE watchdog_agent_tokens SET revoked = 1 WHERE token_id = :id');
$stmt->execute([':id' => $tokenId]);
return $stmt->rowCount() > 0;
}
/** @return list<array<string,mixed>> */
public function getAllTokens(): array public function getAllTokens(): array
{ {
$stmt = $this->db->query('SELECT * FROM watchdog_agent_tokens ORDER BY created_at_utc DESC'); $stmt = $this->db->query('SELECT * FROM watchdog_agent_tokens ORDER BY created_at_utc DESC');
+88
View File
@@ -0,0 +1,88 @@
<?php
/**
* Deploymentcenter Bootstrap
*
* Einziger Einstiegspunkt fuer Autoloading, Konfiguration und Fehlerbehandlung.
* Jede Datei unter public/ bindet ausschliesslich diese Datei ein.
*/
declare(strict_types=1);
if (defined('DC_BOOTSTRAPPED')) {
return;
}
define('DC_BOOTSTRAPPED', true);
define('DC_ROOT', dirname(__DIR__));
define('DC_SRC', DC_ROOT . '/src');
define('DC_VAR', DC_ROOT . '/var');
// --- PSR-4 Autoloader: Deploymentcenter\Foo\Bar -> src/Foo/Bar.php ---
spl_autoload_register(static function (string $class): void {
$prefix = 'Deploymentcenter\\';
$len = strlen($prefix);
if (strncmp($class, $prefix, $len) !== 0) {
return;
}
$relative = substr($class, $len);
$path = DC_SRC . '/' . str_replace('\\', '/', $relative) . '.php';
if (is_file($path)) {
require_once $path;
}
});
// --- Konfiguration laden ---
$dcConfigFile = DC_ROOT . '/config/config.php';
if (!is_file($dcConfigFile)) {
http_response_code(500);
header('Content-Type: text/plain; charset=utf-8');
echo "Konfiguration fehlt.\n\n"
. "Bitte config/config.example.php nach config/config.php kopieren und ausfuellen.\n";
exit(1);
}
/** @var array $dcConfig */
$dcConfig = require $dcConfigFile;
if (!is_array($dcConfig)) {
http_response_code(500);
header('Content-Type: text/plain; charset=utf-8');
echo "config/config.php muss ein Array zurueckgeben.\n";
exit(1);
}
Deploymentcenter\Core\Config::load($dcConfig);
date_default_timezone_set((string)Deploymentcenter\Core\Config::get('app.timezone', 'UTC'));
// --- Fehleranzeige: niemals an den Client, immer ins Log ---
$dcDebug = (bool)Deploymentcenter\Core\Config::get('app.debug', false);
ini_set('display_errors', $dcDebug ? '1' : '0');
ini_set('log_errors', '1');
error_reporting(E_ALL);
set_exception_handler(static function (\Throwable $e): void {
// Http::fail() protokolliert die Exception bereits - hier nur die
// Selbstmeldung in den eigenen Bugtracker anstossen.
Deploymentcenter\Core\ErrorReporter::report($e);
if (!headers_sent()) {
http_response_code(500);
}
Deploymentcenter\Core\Http::fail(500, 'internal_error', 'Interner Serverfehler.', $e);
});
register_shutdown_function(static function (): void {
$err = error_get_last();
if ($err === null) {
return;
}
if (!in_array($err['type'], [E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR, E_USER_ERROR], true)) {
return;
}
Deploymentcenter\Core\Logger::error(
sprintf('Fatal: %s in %s:%d', $err['message'], $err['file'], $err['line'])
);
Deploymentcenter\Core\ErrorReporter::reportFatal($err);
});
unset($dcConfigFile, $dcConfig, $dcDebug);
+10
View File
@@ -0,0 +1,10 @@
# Logs und Laufzeitdaten - kein Webzugriff.
<IfModule mod_authz_core.c>
Require all denied
</IfModule>
<IfModule !mod_authz_core.c>
Order allow,deny
Deny from all
</IfModule>
View File