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