Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,7 @@ coverage-report/
**/test-migrate.db
**/test-migrate.db-*

# dev-reset.ps1 transient logs (build/test/start output, kept locally for debugging)
# dev-reset.ps1 transient logs (build/start output, kept locally for debugging)
.dev-logs/

# Local artefact + backup folders produced by Build-Artifact.ps1 / manual zips
Expand Down
4 changes: 2 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -357,7 +357,7 @@ Initial-Admin: erster Login bei leerer DB (One-Shot-Token `admin-setup.token`).

- **Session:** absolute Lebensdauer **8h** (`Authentication:SessionAbsoluteLifetimeHours`, default 8; `AuthController.TokenLifetime`). Refresh verlängert die absolute Grenze **nicht**. `jti`-Revocation. Key aus `Jwt:Key` oder auto-generiertes `jwt-secret.key`.
- **Auth-Pfade:** Local-BCrypt (`Authentication:LocalLoginMode`, Produktionsdefault **`BreakGlassOnly`** — nur explizit markierte Notfallkonten; `Enabled`/`Disabled` möglich) + LDAP (`Authentication:Ldap:Enabled`) + Windows-Negotiate (`Authentication:Windows:Enabled`) + OIDC (`Authentication:Oidc:Enabled`, release-gated, + SCIM-Controller). Alle konvergieren auf JWT-Cookie + CSRF-Token. Siehe `docs/ldap-windows-sso.md`.
- **External Trigger:** nur aktiv wenn `ExternalTrigger:ApiKey` gesetzt.
- **External Trigger:** `X-Api-Key` wird bevorzugt gegen SHA-256-Hashes unter `ExternalTrigger:Keys:<id>` geprüft; jeder Eintrag hat eine GUID-only `AllowedWorkflowIds`-Liste. Die komplette `Keys`-Map kommt atomar aus dem höchstprioren Provider, der sie deklariert (`Keys: {}` widerruft alle niedrigeren Keys); auch Scope-Arrays sind provider-atomar (`[]` = deny-all). Zusätzlich braucht der Workflow einen aktiven `manualTrigger`. Legacy-`ApiKey` ist ohne eigene `AllowedWorkflowIds`-Liste inert. Idempotency wird per kanonischer Integration-ID + Key-Fingerprint + Workflow domain-separiert; die DB speichert nur den Digest.
- **Rate-Limiting:** login 50/Min, refresh 20/Min, webhook 60/Min, trigger 30/Min, ai-generate 20/Min, audit 60/Min, backup 10/Min (per-IP, Sliding-Window).
- **Output-Redaction:** `OutputRedactor` maskiert Secrets. Immer aktiv. Custom-Patterns via `Logging:Redaction:Patterns`.
- **Localhost-Bypass:** ohne Credentials läuft in-process. **Produkt-Feature, kein Guard einziehen.**
Expand Down Expand Up @@ -410,7 +410,7 @@ Getrennt vom Workflow-Export: voller DR-Snapshot der Konfiguration (Workflows+Fo
- **Kein Root (trigger-los oder nur Zyklen):** Nodes vorhanden, aber kein (aktiver) Trigger → 0 Roots → Execution `Failed` (ErrorMessage nennt den fehlenden Trigger/Start). **Leerer** Workflow (0 Nodes) → läuft mit 0 Steps durch (`Succeeded`).
- **`POST /execute`:** asynchron, 202 + ExecutionId. Fortschritt via SignalR.
- **Workflow-Version-History:** `Update`/`Rollback` snapshotten vorherige Definition.
- **Idempotency-Keys:** `POST /api/trigger/{name}` akzeptiert `Idempotency-Key`-Header.
- **Idempotency-Keys:** `POST /api/trigger/{name}` akzeptiert `Idempotency-Key`-Header; Replay/Reservation gilt nur innerhalb desselben authentifizierten External-Trigger-Key-Principals und Workflows.
- **Node-Level `disabled`:** `data.disabled: true` → Node wird `Skipped`, Downstream ohne andere Quellen auch.
- **Step-Debugger:** `POST /execute` mit `debug: true` → Breakpoints, SignalR `StepPaused`, Resume via `POST /executions/{id}/resume`.

Expand Down
23 changes: 17 additions & 6 deletions E2ETests.md
Original file line number Diff line number Diff line change
Expand Up @@ -2234,21 +2234,32 @@ Erstelle folgende Edges mit Comparison-Bedingungen:
### Test 23.1 — External Trigger via API-Key

**Schritte:**
1. Config: `ExternalTrigger:ApiKey: "my-api-key-xyz"`
2. Call:
1. Einen aktivierten Workflow mit aktivem `manualTrigger` anlegen und seine GUID notieren.
2. Einen zufälligen Schlüssel mit mindestens 32 UTF-8-Bytes erzeugen, seinen SHA-256-Hash als Base64 berechnen und konfigurieren:
```yaml
ExternalTrigger:
Keys:
e2e:
KeyHash: "<SHA-256-Hash als Base64>"
AllowedWorkflowIds:
- "<Workflow-GUID>"
```
3. Call:
```bash
curl -X POST http://localhost:5000/api/trigger/MyWorkflow \
-H "X-Api-Key: my-api-key-xyz" \
-H "X-Api-Key: <Klartextschlüssel>" \
-H "Content-Type: application/json" \
-d '{"parameters": {"env": "prod"}}'
```
3. Response: 202 Accepted + ExecutionId
4. Response: 202 Accepted + ExecutionId

**Prüfpunkte:**
- [ ] Ohne Config → 503
- [ ] Ohne Config → 401
- [ ] Ohne API-Key → 401
- [ ] Falscher Key → 401
- [ ] Korrekter Key → 202
- [ ] Korrekter, für die Workflow-GUID freigegebener Key → 202
- [ ] Korrekter Key für eine andere Workflow-GUID → uniforme 404
- [ ] Workflow ohne aktiven `manualTrigger` → uniforme 404
- [ ] Execution wird erstellt

**Erwartung:** External Trigger ist sicher
Expand Down
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1165,7 +1165,9 @@ All settings live in [`src/NodePilot.Api/appsettings.json`](src/NodePilot.Api/ap
| `Jwt:Issuer` / `Jwt:Audience` | `NodePilot` | Token validation — change for production |
| `Remote:Provider` | `winrm` | `winrm` or `noop` (load-test stub) |
| `Smtp:Host` / `Port` / `From` | `localhost:25` | SMTP for `emailNotification` |
| `ExternalTrigger:ApiKey` | *(unset)* | API key for `POST /api/trigger/{name}`; endpoint returns 401 if unset |
| `ExternalTrigger:Keys:<id>:KeyHash` | *(unset)* | Base64-encoded SHA-256 hash; the highest provider declaring `Keys` owns the complete map, and `Keys: {}` revokes all lower-provider keys |
| `ExternalTrigger:Keys:<id>:AllowedWorkflowIds` | `[]` | Provider-atomic GUID-only workflow scope; a higher list replaces all lower indices and empty is deny-all |
| `ExternalTrigger:ApiKey` / `AllowedWorkflowIds` | *(unset)* / `[]` | Transitional legacy key and its mandatory GUID scope; without the scope it authorizes nothing |

### Logging & Observability

Expand Down Expand Up @@ -1349,7 +1351,7 @@ The full OpenAPI spec is served at `GET /openapi/v1.json`; Swagger UI at `GET /s
| AI | `POST /api/ai/generate-script` + `/api/ai/chat` *(SSE streaming)*, `POST /api/ai/generate-workflow` *(JSON)*, `POST /api/ai/chat/applied` + `GET /api/ai/chat/activity/{workflowId}` *(Admin/Operator, folder-RBAC)* — `generate-*` Admin/Operator, `chat` all roles (edits Admin/Operator only); opt-in, rate-limited |
| Auth | `POST /api/auth/login`, `POST /api/auth/logout`, `POST /api/auth/refresh`, `GET /api/auth/me` |
| Audit | `GET /api/audit` *(Admin only, max 500 entries)* |
| External trigger | `POST /api/trigger/{workflowNameOrId}` *(`X-Api-Key` header, optional `Idempotency-Key`)* |
| External trigger | `POST /api/trigger/{workflowNameOrId}` *(`X-Api-Key` scoped to the workflow GUID; workflow needs an enabled `manualTrigger`; optional `Idempotency-Key`, isolated per authenticated key principal)* |
| Webhooks | `POST /api/webhooks/{workflow}/{path}` *(secret via `X-Webhook-Secret` or versioned NodePilot HMAC v2 over freshness metadata + method + path + canonical query + body)* |
| Observability | `GET /api/observability/config\|query\|query_range\|summary` |
| Health | `GET /healthz/live`, `GET /healthz/ready`, `GET /healthz/database` *(anonymous)* |
Expand Down
7 changes: 5 additions & 2 deletions deploy/Install-NodePilot.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,9 @@
Windows Service display name. Default: NodePilot Orchestrator.

.PARAMETER ExternalTriggerApiKey
Pre-shared key for POST /api/trigger/{workflow}. Auto-generated (48 random bytes base64) if omitted.
Transitional legacy key for POST /api/trigger/{workflow}. Auto-generated (48 random bytes
base64) if omitted. It authorizes nothing until workflow GUIDs are explicitly configured in
ExternalTrigger:AllowedWorkflowIds; new integrations should use hashed ExternalTrigger:Keys entries.

.PARAMETER JwtIssuer
JWT issuer claim. Default: nodepilot:prod:<machine-name>.
Expand Down Expand Up @@ -1709,8 +1711,9 @@ Write-Host " Health : https://$PublicHostname/healthz/ready" -Foreground
Write-Host " Logs : $DataPath\logs" -ForegroundColor Gray
Write-Host " Install log : $reportPath" -ForegroundColor Gray
Write-Host ""
Write-Host " External-Trigger API key (store it now; it won't be shown again):" -ForegroundColor Yellow
Write-Host " External-Trigger API key (legacy; store it now, it won't be shown again):" -ForegroundColor Yellow
Write-Host " $ExternalTriggerApiKey" -ForegroundColor Yellow
Write-Host " Deny-all until workflow GUIDs are added to ExternalTrigger:AllowedWorkflowIds." -ForegroundColor Yellow
Write-Host ""
if ($tokenContent) {
Write-Host " FIRST-LOGIN ADMIN BOOTSTRAP" -ForegroundColor Yellow
Expand Down
8 changes: 4 additions & 4 deletions deploy/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -358,7 +358,7 @@ Der Installer macht alles Weitere:
8. Dienst per `Win32_Service.Create` anlegen — gMSA (leeres Passwort + `sc.exe managedaccount` + „Log on as a service"-Grant) oder `LocalSystem` (keine dieser drei Schritte nötig), Recovery-Actions, `ASPNETCORE_ENVIRONMENT=Production`
9. Dienst starten, `https://localhost/healthz/ready` pollen
10. Installations-Marker `HKLM\SOFTWARE\NodePilot\Server` schreiben (`InstallPath`, `DataPath`, `ServiceName`, `Version`, `DbProvider`, `HttpsPort`) — nur auf dem Erfolgspfad, damit ein zurückgerollter Lauf keinen Marker hinterlässt
11. Admin-Bootstrap-Token + External-Trigger-API-Key auf der Konsole ausgeben
11. Admin-Bootstrap-Token + Legacy-External-Trigger-API-Key auf der Konsole ausgeben. Der Key ist zunächst deny-all und wird erst zusammen mit expliziten Workflow-GUIDs unter `ExternalTrigger:AllowedWorkflowIds` wirksam.

Schritt 1 kommt aus [`Preflight.ps1`](Preflight.ps1) und ist bewusst als eigene Datei ausgelagert:
die Checks sammeln nur (`Invoke-NodePilotPreflight`), das Abbrechen ist ein zweiter Schritt
Expand All @@ -376,7 +376,7 @@ Nach erfolgreichem Install steht in der Konsole:

- URL: `https://<public-hostname>/`
- **Admin-Setup-Token** (aus `C:\ProgramData\NodePilot\admin-setup.token`) → im Browser anmelden: beim ersten Versuch blendet die Login-Seite ein **„Setup-Token"-Feld** ein, Token dort einfügen, erneut anmelden → Admin-User wird erstellt, Token-Datei gelöscht, Bootstrap-Fenster schließt. Kann der Installer das Token nicht anzeigen (die Datei ist per Owner-only-ACL auf das **Dienstkonto** beschränkt — auch für Admins by design nicht direkt lesbar), per Backup-Semantik lesen statt die ACL anzufassen: `robocopy C:\ProgramData\NodePilot $env:TEMP admin-setup.token /B`, dann `Get-Content "$env:TEMP\admin-setup.token"` (Temp-Kopie danach löschen). ACL-Änderung nur mit Bedacht: Der Server validiert die Datei fail-closed; im Trusted-Set sind nur Dienstkonto, SYSTEM und die **Administrators-Gruppe** — `takeown /a` + Gruppen-Grant übersteht das, Ownership auf den persönlichen Admin-User invalidiert die Datei.
- **External-Trigger API Key** — einmalig sichern, wird nicht erneut angezeigt.
- **Legacy-External-Trigger API Key** — einmalig sichern, wird nicht erneut angezeigt. Er autorisiert zunächst keinen Workflow; für neue Integrationen werden gehashte, GUID-gescopte Einträge unter `ExternalTrigger:Keys` empfohlen.

### Parameter-Übersicht

Expand Down Expand Up @@ -404,7 +404,7 @@ Nach erfolgreichem Install steht in der Konsole:
| `-DataPath` | | `C:\ProgramData\NodePilot` |
| `-ServiceName` | | `NodePilot` |
| `-ServiceDisplayName` | | `NodePilot Orchestrator` |
| `-ExternalTriggerApiKey` | | auto-generiert (48 bytes base64) |
| `-ExternalTriggerApiKey` | | auto-generierter Legacy-Key (48 Bytes, Base64); mit leerer `AllowedWorkflowIds`-Liste zunächst deny-all |
| `-JwtIssuer` | | `nodepilot:prod:<machine>` |
| `-JwtAudience` | | `nodepilot:prod:<machine>` |
| `-AllowedHosts` | | PublicHostname. `localhost` wird immer angehängt — die Health-Probe des Installers geht an `https://localhost:<port>/healthz/ready`, und `UseHostFiltering` würde sie sonst mit 400 abweisen und eine fertige Installation zurückrollen |
Expand Down Expand Up @@ -475,7 +475,7 @@ Erhält `appsettings.Production.json`, die DB (SQL Server oder Postgres) und den

- **`-HttpsPort` muss nicht wiederholt werden**: die Health-Probe übernimmt `Kestrel:Https:HttpsPort` aus der installierten Config (explizites `-HttpsPort` gewinnt weiterhin). Ohne diese Ableitung probte ein Update einer 8443-Installation gegen 443 und rollte ein gesundes Upgrade zurück.
- **Prozess-Guard vor dem Swap:** Ein gestoppter Dienst genügt nicht — ein verwaister Worker hält seine DLLs als Image gemappt, Windows meldet das als schlichtes „Access denied" mitten im Wipe. Der SCM meldet aber `SERVICE_STOPPED`, **bevor** der Prozess wirklich beendet ist (Host-Shutdown, Log-Flush). Deshalb wird nach dem Stopp bis zu 30 s gewartet, danach werden verbliebene Prozesse aus dem Install-Verzeichnis beendet — es sind NodePilot-Binaries, deren Dateien ohnehin gleich ersetzt werden. Erst wenn auch das nicht greift, bricht der Updater **vor der ersten Löschung** mit PID + Namen ab. Bis 2026-08-03 fehlte das Warten und der Lauf scheiterte an genau dem Prozess, den er selbst gestoppt hatte.
- Beim Swap fällt `appsettings.Production.json` bewusst **zuletzt**, damit ein Abbruch die Config nicht mit ins Grab nimmt (sie steht per Design nicht im Backup). Fehlt sie doch einmal, lehnt der Updater ab — dann `Install-NodePilot.ps1` fahren, das die Config aus seinen Parametern neu rendert (DB, DataPath und Konten bleiben; nur der External-Trigger-API-Key wird neu erzeugt).
- Beim Swap fällt `appsettings.Production.json` bewusst **zuletzt**, damit ein Abbruch die Config nicht mit ins Grab nimmt (sie steht per Design nicht im Backup). Fehlt sie doch einmal, lehnt der Updater ab — dann `Install-NodePilot.ps1` fahren, das die Config aus seinen Parametern neu rendert (DB, DataPath und Konten bleiben; der neu erzeugte Legacy-External-Trigger-Key bleibt bis zur erneuten GUID-Freigabe deny-all).
- **Ein erfolgreicher Update lässt den Dienst LAUFEN**, egal ob er vorher gestoppt war. Nur ein fehlgeschlagener Update stellt den Ausgangszustand wieder her (ein Rollback startet nichts, was vorher bewusst gestoppt war).

## Uninstall
Expand Down
2 changes: 1 addition & 1 deletion deploy/desktop/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ service-environment value.
The practical rule: **anything NodePilot initiates works, anything that must reach in does not.**
Schedule/file-watcher/database/event-log triggers and all outbound automation (WinRM, `restApi`,
`sql`, SMTP, alerting webhooks) are unaffected; inbound webhooks and the external trigger API
(also disabled via an empty `ExternalTrigger:ApiKey`) are unusable.
(also disabled because no scoped external-trigger key is configured) are unusable.
- **API runs as LocalSystem** (zero-config). Consequence: loopback `runScript` activities run with
**SYSTEM** rights. This is an explicit v1 decision for a single-user local orchestrator.
- **Postgres runs as NetworkService**, bound to 127.0.0.1 only.
Expand Down
2 changes: 1 addition & 1 deletion deploy/desktop/appsettings.Desktop.json.template
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@
"SqlActivity": { "RequireConnectionRef": true },
"Trigger": { "Database": { "RequireConnectionRef": true } },
"StartProgram": { "DisallowShellExecute": true },
"ExternalTrigger": { "ApiKey": "" },
"ExternalTrigger": { "ApiKey": "", "AllowedWorkflowIds": [], "Keys": {} },
"Retention": {
"Executions": { "Enabled": true, "MaxAgeDays": 30, "IntervalMinutes": 60, "BatchSize": 500, "ArchivePath": "{{DATA_PATH_ESCAPED}}\\archive\\executions" },
"AuditLog": { "Enabled": true, "MaxAgeDays": 365, "IntervalMinutes": 720, "BatchSize": 1000, "ArchivePath": "{{DATA_PATH_ESCAPED}}\\archive\\audit", "VerifyIntervalMinutes": 1440, "VerifyMaxFilesPerPass": 500 },
Expand Down
4 changes: 3 additions & 1 deletion deploy/templates/appsettings.Production.json.template
Original file line number Diff line number Diff line change
Expand Up @@ -197,7 +197,9 @@
"DisallowShellExecute": true
},
"ExternalTrigger": {
"ApiKey": "{{EXTERNAL_TRIGGER_API_KEY}}"
"ApiKey": "{{EXTERNAL_TRIGGER_API_KEY}}",
"AllowedWorkflowIds": [],
"Keys": {}
},
"Retention": {
"Executions": { "Enabled": true, "MaxAgeDays": 30, "IntervalMinutes": 60, "BatchSize": 500, "ArchivePath": "{{DATA_PATH_ESCAPED}}\\archive\\executions" },
Expand Down
4 changes: 4 additions & 0 deletions docs/ai-features.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,10 @@ Neu-Eintippen.
}
```

`BaseUrl` muss HTTPS verwenden. HTTP ist nur für exakte lokale Loopback-Ziele wie
`http://localhost:11434/v1`, `127.0.0.0/8` oder `::1` erlaubt; solche Ziele umgehen immer den
ausgehenden Proxy, damit Prompt und API-Key den Host nicht im Klartext verlassen.

**Section-Root:**

| Key | Default | Erklärung |
Expand Down
Loading
Loading