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
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -345,6 +345,7 @@ Opt-in (`Llm:Enabled=false` default), OpenAI-kompatibler Endpunkt, Rate-Limit 20
- **Globaler AI-Chat / Wissens-Assistent** (`POST /api/ai/knowledge/ask`, SSE; `GET /api/ai/knowledge/capabilities`) — seitenweiter read-only Q&A in `/ai-chat`, canvas-frei. Vier admin-toggelbare Wissensquellen (Sektion `AiKnowledge`, hot-reloadbar, alle `false`-default außer Docs/Operational): **Docs** (`DocsEnabled`), **Operational** (`OperationalEnabled`, RBAC-folder-gescoped — liefert nur die Workflow-spezifische **Definition** (`get_workflow_definition`, secret-redigiert), **statische Analyse** (`analyze_workflow`) und **Cron-Voraussage** (`get_next_scheduled_fires`); reine Listen wie "welche Workflows/Läufe/Maschinen gibt es" werden über die DB-Quelle per text2sql beantwortet), **Source-Code** (`SourceCodeEnabled`, Admin/Op), **DB / text2sql** (`DbEnabled`, Admin/Op). DB-Tools (`list_db_tables`/`get_db_table`/`execute_readonly_sql`) über `ISqlKnowledgeReader`: Schema inkl. Provider/FKs ohne Secret-Spalten; zentraler Executor-Guard (64 KiB, Single-Statement, Read-only-Whitelist + Dangerous-Token/Routine-Block), geschützte Spaltenreferenzen vor Ausführung abgelehnt, Result-Masking + `IAuditDetailsRedactor`, Row-Cap 200, valides Truncation-JSON. DB-Tools Strict mit Best-Effort-Fallback; Audit nur Query-Anzahl/Fingerprint. Sources sind nur sichtbar, wenn das aktive Profil `EnableToolCalling` gesetzt hat.
- **`llmQuery`-Activity:** Engine-lokal, Prompt→Text; per-Node-Overrides `baseUrl`/`model`/`apiKey`/`maxTokens`/`temperature`/`timeoutSeconds`/`jsonMode`, **gated durch `Llm:Enabled`** (zentraler Kill-Switch). Teilt Transport + SSRF-Guard via `ILlmClientFactory`; einziger BaseUrl-Validierungspunkt ist `LlmEndpointGuard`.
- **Zwei Wire-Dialekte, kein Config-Key:** `LlmEndpointGuard.ResolveEndpoint` leitet aus dem `BaseUrl`-Pfad ab, wohin gepostet wird und wer antwortet — `…/responses` → `OpenAiResponsesLlmClient` (OpenAI Responses API), sonst `OpenAiCompatibleLlmClient`; endet der Pfad schon auf `/chat/completions`, wird **nichts** mehr angehängt. Gemeinsames HTTP-Plumbing in `LlmHttpTransport`. Die vier Quirk-Fallbacks (`max_tokens`→`max_completion_tokens`, `stream_options`, `response_format`, `strict`) sind Chat-Completions-only und im Responses-Client bewusst nicht vorhanden; dieser sendet immer `store: false`.
- **Erreichbarkeit ≠ Antwortzeit:** `TimeoutSeconds` ist reines **Antwort**-Budget. Der Verbindungsaufbau hat eigene Konstanten in `LlmConnectGuard` — `ConnectPhaseTimeout` (15 s, DNS+TCP im ConnectCallback) und `HandshakeTimeout` (30 s, als `SocketsHttpHandler.ConnectTimeout`, die einzige Stelle die den TLS-Handshake binden kann). Die Ordnung `HandshakeTimeout > ConnectPhaseTimeout` ist tragend (per Test gepinnt): nur deshalb darf ein gefeuertes `ConnectTimeout` als TLS-Stufe gelesen werden. Fehler nennen die Stufe (`LLM endpoint DNS:|TCP:|TLS:`); Debug-Logging der aufgelösten Adressen unter `NodePilot.Ai.LlmConnect`. Details: `docs/claude-reference.md`.
- **Hardening:** SSRF-Block (Cloud-Metadata), Proxy nur nach Opt-in (`Llm:Proxy:Mode`, default `Off`), Klartext-ApiKey-/Proxy-Passwort-Warning, Prompt-Injection-Mitigation (Schema-only, User-reviewed Insert). Drift-Schutz: `PromptCatalogDriftTest.cs`. Audit: `AI_*`-Codes.

## Workflow Import/Export
Expand Down
35 changes: 34 additions & 1 deletion docs/ai-features.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ Neu-Eintippen.
| `ApiKey` | `null` | OpenAI-Cloud verlangt einen Key; lokale Endpoints meist nicht. **Empfohlener Weg: Env-Var `Llm__Profiles__<id>__ApiKey`** — Klartext in der Settings-Datei löst eine Startup-Hardening-Warnung aus. |
| `Model` | `gpt-4o-mini` | Wird für Script-, Workflow-Generierung und beide Chats verwendet. |
| `MaxTokens` | `4096` | Cap der LLM-Antwort. Reicht für ein typisches Script und einen mittelgroßen Workflow. Bei großen Modellen (32k+ Context) gerne erhöhen. |
| `TimeoutSeconds` | `90` | HTTP-Timeout. Großzügig für lokale Modelle, klein genug um nicht ewig zu hängen. |
| `TimeoutSeconds` | `90` | **Antwort**-Budget: wie lange das Modell denken darf. Deckt ausdrücklich **nicht** den Verbindungsaufbau — der hat seine eigenen, kurzen Fristen (siehe unten). Großzügig setzen ist deshalb gefahrlos: ein unerreichbarer Endpunkt scheitert trotzdem in Sekunden. |
| `EnableToolCalling` | `false` | Opt-in. Lässt die Chat-Assistenten read-only Analyse-Tools per OpenAI-Function-Calling callen (`tool_choice: auto`). Braucht ein Modell, das Function-Calling zuverlässig kann — viele kleine lokale Modelle nicht. **Pro Profil**, weil das eine Eigenschaft des Modells ist, nicht der Installation: beim Umschalten auf ein kleines lokales Modell wandert die Fähigkeit mit. |
| `ToolCallMaxDepth` | `6` | Max LLM-Runden mit Tool-Calls pro Chat-Turn (Loop-Guard, gültig 1–10). Lässt bei text2sql nach Schema-Discovery noch Raum für SQL-Korrekturen. In der letzten erlaubten Runde sendet der Server **keine** `tools` → erzwingt eine Text-Antwort. |

Expand Down Expand Up @@ -136,6 +136,39 @@ genau deshalb bleibt die Sektion hot-reloadable, wo `RestApi` (Proxy fest im Han
Eine Ausnahme bleibt `Mode: System`: Änderungen an den **Windows-Proxy-Einstellungen** selbst
greifen erst nach einem Dienst-Neustart, weil .NET die Systemkonfiguration prozessweit cacht.

### „Endpunkt nicht erreichbar" — welche Stufe gescheitert ist

Den Endpunkt zu *erreichen* hat eigene Fristen, unabhängig von `TimeoutSeconds`. Das ist kein
Detail, sondern der Grund, warum die Fehlermeldung überhaupt etwas aussagt: vorher deckte ein
einziges Budget DNS, TCP, TLS **und** die Modellantwort ab, und alle vier endeten in derselben
Zeile „did not respond within {TimeoutSeconds}s". Bei einem Profil mit 360 s hieß das sechs
Minuten Stille und danach ein Satz, der auf das Modell zeigte, während in Wahrheit eine Firewall
oder ein nicht vertrautes Zertifikat im Weg stand.

| Stufe | Frist | Meldung beginnt mit | Typische Ursache |
|---|---|---|---|
| Namensauflösung | 15 s | `LLM endpoint DNS:` | Name falsch, falsches DNS-Suffix, Resolver antwortet nicht |
| TCP-Verbindung | 15 s | `LLM endpoint TCP:` | **verworfen** = Firewall/Netzsegment; **abgelehnt** = Host da, Port/Dienst falsch |
| TLS-Handshake | 30 s | `LLM endpoint TLS:` | Client-Zertifikat verlangt, SNI-Mismatch, Middlebox, die annimmt und nicht verhandelt |
| Zertifikatsprüfung | — | `LLM endpoint TLS:` … `certificate` | Interne CA fehlt im **Maschinen**-Store des NodePilot-Hosts |
| Modellantwort | `TimeoutSeconds` | `accepted the request but sent no answer` | Modell rechnet noch — hier ist Hochsetzen die richtige Antwort |

Die Fristen der ersten drei Stufen sind Konstanten, keine Konfigurationsschlüssel: 15 s liegen weit
jenseits jeder gesunden Auflösung oder eines TCP-Handshakes (Windows gibt bei unbeantwortetem SYN
nach ~21 s von selbst auf). Ein Wert, den man hochsetzen müsste, bedeutet ein kaputtes Netz — und
genau das sagt die Meldung jetzt.

**Zertifikate:** NodePilot prüft gegen den Trust-Store der **Maschine**, nicht den des angemeldeten
Benutzers. Ein Zertifikat, das im Browser auf dem Arbeitsplatz akzeptiert wird, ist damit für den
Dienst noch lange nicht vertrauenswürdig — die ausstellende CA muss unter
`LocalMachine\Root` des NodePilot-Hosts liegen.

**Mehr Details im Log:** Die aufgelösten Adressen und die Dauer je Stufe stehen auf `Debug` unter
der Kategorie `NodePilot.Ai.LlmConnect` — gezielt einschaltbar über
`Serilog:MinimumLevel:Override`, ohne den ganzen KI-Stack gesprächig zu machen. Das ist die Zeile,
die den Fall „funktioniert von meinem Rechner, nicht vom Dienst" auflöst: ein veralteter
AAAA-Eintrag und ein anderes DNS-Suffix sehen von außen identisch aus.

### Wire-Dialekt (aus der `BaseUrl` abgeleitet)

OpenAI betreibt zwei Request-Formate nebeneinander: das klassische **Chat Completions**
Expand Down
1 change: 1 addition & 0 deletions docs/claude-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -365,6 +365,7 @@ Background-Service-, Konfigurations- und Observability-Vertrag stehen vollständ
**Hardening**:
- SSRF-Block für Cloud-Metadata-IPs in **jeder** `Llm:Profiles:<id>:BaseUrl` (nicht nur der aktiven — Profilwechsel ist ein restart-freier Save). Eine geteilte Regel für Boot *und* Save-Simulation: [LlmProfileValidation.cs](src/NodePilot.Ai/LlmProfileValidation.cs), aufgerufen von `AddNodePilotAi` und `LlmConfigBootValidator`. Einziger BaseUrl-Validierungspunkt bleibt [LlmEndpointGuard.cs](src/NodePilot.Ai/LlmEndpointGuard.cs) (`NormalizeAndValidateBaseUrl`/`IsCloudMetadataEndpoint`), plus Connect-Zeit-Guard `LlmConnectGuard` in [LlmServiceCollectionExtensions.cs](src/NodePilot.Ai/LlmServiceCollectionExtensions.cs). `Enabled=true` ohne auflösbares Profil ist bewusst nur eine **Warning** — KI ist opt-in und darf den Boot nicht blockieren.
- Eigener `SocketsHttpHandler` (NICHT der `RestApiHttpClientProvider` — der hat SSRF-Guards die `127.0.0.1:11434` blocken würden). Proxy-Verhalten kommt aus `Llm:Proxy:*` über `LlmConfiguredProxy`, default `Off` = Direktverbindung.
- **Erreichbarkeit ist von der Antwortzeit getrennt.** `TimeoutSeconds` ist reines Antwort-Budget; der Verbindungsaufbau hat eigene Konstanten in `LlmConnectGuard`: `ConnectPhaseTimeout` (15 s, deckt DNS + TCP im ConnectCallback) und `HandshakeTimeout` (30 s, als `SocketsHttpHandler.ConnectTimeout` — die einzige Stelle, die den **TLS-Handshake** binden kann, weil der Callback nur den rohen Transport-Stream zurückgibt). Die Ordnung `HandshakeTimeout > ConnectPhaseTimeout` ist **tragend**: nur weil DNS und TCP immer an ihrer eigenen Frist scheitern, darf `LlmHttpTransport.DescribeUnreachable` aus einem gefeuerten `ConnectTimeout` auf die TLS-Stufe schließen. Ein Test pinnt die Ordnung. Meldungspräfixe: `LLM endpoint DNS:` / `TCP:` / `TLS:`; die Modell-Stufe sagt „accepted the request but sent no answer". Debug-Logging der aufgelösten Adressen unter der Kategorie `NodePilot.Ai.LlmConnect`.
- Klartext-ApiKey je Profil löst Startup-Hardening-Warning aus, analog `Smtp:Password` ([SecurityHardeningWarnings.cs](src/NodePilot.Api/Hosting/SecurityHardeningWarnings.cs))
- `SettingsSchema.IsUnchangedSecretValue` behandelt `__unchanged__` **und** die Anzeige-Maske `"********"` als „unverändert" — vorher hätte ein Client, der die GET-Antwort zurück-PUTet, die Maske als neuen Key verschlüsselt und den echten still zerstört (gilt jetzt für alle Sektionen, auch `Smtp:Password`).

Expand Down
51 changes: 48 additions & 3 deletions src/NodePilot.Ai/LlmHttpTransport.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Runtime.CompilerServices;
using System.Security.Authentication;
using System.Text.Json;
using Microsoft.Extensions.Logging;

Expand Down Expand Up @@ -81,13 +82,16 @@ public async Task<HttpResponseMessage> SendAsync(
}
catch (OperationCanceledException) when (!caller.IsCancellationRequested)
{
// Reaching the endpoint has its own, much shorter budget (LlmConnectGuard), so by the
// time this fires the request was on the wire and the model is simply still thinking.
throw new LlmException(LlmErrorKind.Timeout,
$"LLM endpoint did not respond within {_config.TimeoutSeconds}s ({_config.Endpoint.PostUrl}).");
$"LLM endpoint accepted the request but sent no answer within {_config.TimeoutSeconds}s "
+ $"({_config.Endpoint.PostUrl}). The connection itself was fine — raise the profile's "
+ "timeout if the model needs longer, or pick a faster model.");
}
catch (HttpRequestException ex)
{
throw new LlmException(LlmErrorKind.Unreachable,
$"LLM endpoint unreachable ({_config.Endpoint.PostUrl}): {ex.Message}", inner: ex);
throw new LlmException(LlmErrorKind.Unreachable, DescribeUnreachable(ex), inner: ex);
}

if (!resp.IsSuccessStatusCode)
Expand All @@ -96,6 +100,47 @@ public async Task<HttpResponseMessage> SendAsync(
return resp;
}

/// <summary>
/// Turns a transport failure into a message that names <b>which stage</b> failed.
///
/// <para>Every one of these used to arrive as the same "did not respond" sentence, which is
/// why an unreachable endpoint and a slow model were indistinguishable from the UI. The stages
/// are separable because each has its own deadline: DNS and TCP fail inside
/// <c>LlmConnectGuard</c> with a message that already names them, certificate validation
/// raises <see cref="AuthenticationException"/>, and the handler's <c>ConnectTimeout</c> can
/// only fire <i>after</i> those two have passed — so it means the TLS handshake stalled.</para>
/// </summary>
internal string DescribeUnreachable(Exception ex)
{
var url = _config.Endpoint.PostUrl;

Exception innermost = ex;
while (innermost.InnerException is not null) innermost = innermost.InnerException;

if (innermost is AuthenticationException auth)
{
return $"LLM endpoint TLS ({url}): the server's certificate was rejected — {auth.Message} "
+ "Import the issuing CA into the machine's Trusted Root store on the NodePilot host; "
+ "a certificate that a browser accepts on a workstation is not automatically trusted by the service account.";
}

// TimeoutException here is SocketsHttpHandler.ConnectTimeout. DNS and TCP carry shorter
// deadlines of their own, so they can never be what expired.
if (innermost is TimeoutException)
{
return $"LLM endpoint TLS ({url}): the TCP connection was established but the TLS handshake did not "
+ $"complete within {LlmConnectGuard.HandshakeTimeout.TotalSeconds:0}s. Typical causes are an endpoint "
+ "demanding a client certificate, an SNI mismatch, or a middlebox that accepts the connection and "
+ "never negotiates.";
}

// Anything from the connect guard already names its own stage; don't wrap it in a second
// sentence that says less.
return innermost is IOException io && io.Message.StartsWith("LLM endpoint ", StringComparison.Ordinal)
? io.Message
: $"LLM endpoint unreachable ({url}): {innermost.Message}";
}

/// <summary>
/// Reads an error response's body excerpt (through the same byte cap as the success path) and
/// throws the matching <see cref="LlmException"/>. Disposes <paramref name="resp"/>.
Expand Down
Loading
Loading