Add live TLS certificate probing and improve restart error handling.

Configure CertificateCheckUrl per container for curl-like TLS checks, classify Sonic permission errors, and extend setup wizard for container management.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-03 13:16:21 +02:00
co-authored by Cursor
parent d20e0df618
commit 58b7826162
1430 changed files with 358394 additions and 71 deletions
@@ -427,6 +427,7 @@ BEGIN
[SonicConnectionId] [int] NOT NULL,
[ContainerName] [nvarchar](250) NOT NULL,
[ContainerDisplayName] [nvarchar](250) NULL,
[CertificateCheckUrl] [nvarchar](500) NULL,
[RestartTimeoutSeconds] [int] NOT NULL,
[IsActive] [bit] NOT NULL,
[CreationDateTime] [datetime2](3) NOT NULL,
@@ -0,0 +1,18 @@
/*
DATABASE: [EsbZertifikatManager]
OBJECTNAME: SonicContainer.CertificateCheckUrl
ACTION: ALTER
IDX: 005
DATE: 2026-07-31
USER: CUR
COMMENT: URL/Host:Port fuer TLS-Zertifikatspruefung (curl-aehnlich) am Container.
*/
USE [EsbZertifikatManager];
GO
IF COL_LENGTH(N'dbo.SonicContainer', N'CertificateCheckUrl') IS NULL
BEGIN
ALTER TABLE [dbo].[SonicContainer]
ADD [CertificateCheckUrl] [nvarchar](500) NULL;
END;
GO
@@ -46,6 +46,16 @@ Wenn der alte Platzhalter noch in SQL liegt:
Zusätzlich: in `appsettings.json` ist `"Demo": { "Enabled": true, ... }`
die App zeigt den Demo-Pfad auch ohne SQL-Update in der Zielliste (Kopieren testen).
## TLS-Prüf-URL am Container (bestehende DB)
Für Live-Zertifikatsprüfung (curl-ähnlich) die Spalte nachziehen:
```text
05_AddCertificateCheckUrl.sql
```
Danach in Einstellungen → Container die Prüf-URL setzen, z. B. `https://server:8443`.
## Always Encrypted
| Objekt | Name |
@@ -0,0 +1,437 @@
# Dateiübersicht nach Ordnerstruktur
Was **jede einzelne Datei** macht inkl. Sonic Management API.
Projektroot der App: `ZA.CoreService.ESBCertificateManager/`
---
## 0. Gesamtbild (wer ruft wen)
```text
Program.cs
└─ Form1.cs (UI)
├─ Zertifikat lesen → CertificateInfo
├─ Ziele ← SonicContainerDiscovery (KnownContainers / Live)
└─ Neustart
└─ DeploymentOrchestrator
└─ RestartExecutor
└─ SonicManagementClient
└─ SonicMfApiExecutor
└─ java + Tools/SonicMfContainerTool
└─ Sonic JARs (externe API)
└─ Domain Manager tcp://…:13070
```
Konfiguration: `appsettings.json``AppSettingsLoader``AppSettings` / `SonicConnection`.
---
## 1. Projektroot (`ZA.CoreService.ESBCertificateManager/`)
### `Program.cs`
Einstiegspunkt der WinForms-App.
`ApplicationConfiguration.Initialize()` + `Application.Run(new Form1())`.
Keine Business-Logik.
### `Form1.cs`
**Gesamte Benutzeroberfläche** (Dark-Design, Sidebar, Zertifikatskarte, Grid, Buttons).
Wichtige Aufgaben:
| Bereich | Was passiert |
|---------|----------------|
| Zertifikat | Dateidialog → `X509Certificate2` → Subject/Issuer/Fingerprint/Gültigkeit anzeigen |
| Ziele | Grid aus `KnownContainers`; Button „Container laden“ → Live-Discovery |
| Prüfung | `ValidateRestartOnly` (ContainerName + SonicConnectionName) |
| Neustart | Bestätigung → `DeploymentOrchestrator.RestartOnlyAsync` → Ergebnisdialog |
| Status | Statuszeile, Fortschritt im Grid, SonicHome/Java-Anzeige |
Deploy-Button ist vorhanden im Layout, aber **ausgeblendet/deaktiviert**.
### `Form1.Designer.cs`
WinForms-Designer-Stub. `InitializeComponent()` ist praktisch leer das Layout wird in `Form1.cs` per Code gebaut.
### `Form1.resx`
Ressourcen-Datei zum Formular (Standard WinForms). Kaum eigene Inhalte.
### `appsettings.json`
**Laufzeitkonfiguration** die „Online“-Quelle für Sonic:
- `LogDirectory` wohin Logs geschrieben werden
- `SonicConnections[]` Domain, URL, User/Pass, Java, Lib-Pfad, **KnownContainers**
Beispiel-Verbindung `DE-Test` → Domain `proalpha-test`, Container `ct-ZADBService`.
### `ZA.CoreService.ESBCertificateManager.csproj`
Projektdatei: .NET 8 WinForms, NuGet (Configuration), welche Dateien nach `bin` kopiert werden (`appsettings`, `Tools`, `Assets`, `Docs`).
### `ZA.CoreService.ESBCertificateManager.csproj.user`
Lokale Visual-Studio-Benutzereinstellungen (Debugger usw.). Nicht fachlich relevant.
---
## 2. Ordner `Configuration/`
### `AppSettingsLoader.cs`
Liest `appsettings.json` über `Microsoft.Extensions.Configuration` und bindet sie an `AppSettings`.
Sucht zuerst im Output-Verzeichnis (`AppContext.BaseDirectory`), sonst im Arbeitsverzeichnis.
---
## 3. Ordner `Models/` (Datenstrukturen)
### `AppSettings.cs`
Root-Settings-Objekt:
- `LogDirectory`
- `List<SonicConnection> SonicConnections`
### `SonicConnection.cs`
Eine Sonic-/SMC-Verbindung:
| Property | Bedeutung |
|----------|-----------|
| `Name` | Alias, z. B. `DE-Test` |
| `DomainName` | z. B. `proalpha-test` |
| `ConnectionUrl` | `tcp://host:port` wie SMC |
| `Username` / `Password` | SMC-Login |
| `SonicHome` / `JavaHome` / `JavaPath` | Pfade |
| `MfClientLibPath` | Ordner mit Client-JARs |
| `KnownContainers` | Containerliste für die UI |
| `TimeoutSeconds` / `PostRestartDelaySeconds` | Timeouts |
| `ManagementModeDisplay` | Anzeigetext „MfApi …“ |
Enthält noch ältere Hilfsfelder/Enums (WinRM etc.) aus der Historie der aktive Client nutzt nur den MfApi-Pfad.
### `DeploymentTarget.cs`
Ein **Ziel** im Grid (ein Container zum Neustarten):
- `Id`, `Name`, `Environment`, `IsActive`
- `ContainerName` z. B. `ct-ZADBService`
- `SonicConnectionName` Verweis auf `SonicConnection.Name`
- `RestartType` für Neustart typischerweise `SonicContainer`
- Weitere Felder (TargetDirectory, Tls*, Xapi*) können noch im Model stehen, werden für den aktuellen Neustart-Pfad nicht mehr aktiv genutzt
### `CertificateInfo.cs`
Ergebnis der lokalen Zertifikatsprüfung:
- Subject, Issuer
- FingerprintSha256
- ValidFrom / ValidUntil
- IsCurrentlyValid
### `DeploymentRunResult.cs`
Ergebnisstrukturen nach einem Lauf:
- `DeploymentRunResult` Gesamtlauf (Liste Zielergebnisse, Zeiten, OverallSuccess)
- `TargetStepResult` ein Ziel (Success, StatusText, Detail, …)
- `ValidationIssue` / `PreflightValidationResult` Vorabprüfung
- `TargetProgressUpdate` Fortschritt für die UI
---
## 4. Ordner `Services/` (Geschäftslogik)
### `DeploymentOrchestrator.cs`
Orchestriert **nur Neustart** (kein Deploy/TLS):
1. `ValidateRestartOnly` → Preflight
2. `RestartOnlyAsync` → für jedes Ziel `RestartExecutor`
3. Schreibt Log über `RunLogger`
4. Meldet Progress an die UI
### `RestartExecutor.cs`
Mappt ein `DeploymentTarget` auf die passende `SonicConnection` und ruft
`SonicManagementClient.RestartContainerAsync(containerName)` auf.
### `PreflightValidator.cs`
Prüft vor dem Neustart:
- mindestens ein Ziel ausgewählt
- `ContainerName` gesetzt
- `SonicConnectionName` gesetzt
### `SonicManagementClient.cs`
**Dünne Fassade** zur MfApi (kein WinRM/HTTP/XApi mehr):
| Methode | Zweck |
|---------|--------|
| `CheckConnectionAsync` | Ping / Soft-Fallback KnownContainers+TCP |
| `GetContainersAsync` | Liste von Containern (API oder KnownContainers) |
| `RestartContainerAsync` | Neustart + Wartezeit `PostRestartDelaySeconds` |
### `SonicMfApiExecutor.cs`
**Brücke C# → Java.** Das ist der technische Kern „unsere Seite der API“:
1. Java 8+ finden (`JavaPath` / Suche)
2. Classpath aus allen JARs unter `MfClientLibPath` / `SonicHome\lib` bauen
3. `Tools\SonicMfContainerTool.class` laden (Version prüfen)
4. Prozess starten: `java -cp … SonicMfContainerTool ping|list|restart …`
5. Passwort über Env `ESB_SONIC_PASSWORD`
6. Erfolg bei Neustart nur wenn Output `OK:RestartInvoked` enthält
Öffentliche Methoden:
- `TestConnectionAsync` → Tool-Befehl `ping`
- `ListContainersAsync``list`
- `RestartAsync``restart`
- `ResolveRuntimePaths` → für Statuszeile in der UI
### `SonicContainerDiscovery.cs`
Baut die Zielliste:
- `BuildTargetsFromConfig()` aus `KnownContainers` in appsettings (Start der App)
- `DiscoverAsync()` live per `SonicManagementClient` listen + mit KnownContainers mergen
### `RunLogger.cs`
Schreibt Textdateien unter `LogDirectory` (ein Log pro Lauf). Redaktiert ggf. secrets-ähnliche Strings.
### `PathResolver.cs`
Löst relative Pfade gegen `AppContext.BaseDirectory` auf (z. B. für Logs).
---
## 5. Ordner `Tools/` (Java + Sonic-API)
Hier liegt der **eigentliche Aufruf der Sonic Management Application API**.
### `SonicMfContainerTool.java`
Kleines Java-Hilfsprogramm (Reflection, kompilierbar ohne Sonic-JARs auf dem Compile-Classpath).
**Befehle:**
| Befehl | Bedeutung |
|--------|-----------|
| `ping` | Connect + Agenten zählen |
| `list` | Containernamen der Domain ausgeben |
| `restart` | Lifecycle auf einem Container |
**Connect (API-Login):**
```text
Hashtable: ConnectionURLs, DefaultUser, DefaultPassword
→ new JMSConnectorAddress(env)
→ new JMSConnectorClient().connect(address, timeout)
```
Klassen aus Sonic-JARs:
- `com.sonicsw.mf.jmx.client.JMSConnectorAddress`
- `com.sonicsw.mf.jmx.client.JMSConnectorClient`
**Neustart Ziel:**
```text
ObjectName = {Domain}.{Container}:ID=AGENT
Beispiel: proalpha-test.ct-ZADBService:ID=AGENT
```
**Neustart Wege (nacheinander):**
1. **MBean.invoke** `"stop"` / `"restart"` / `"shutdown"` ← bevorzugt remote
2. **IAgentProxy** über `MFProxyFactory.createAgentProxy` (oft *unbounded*-Fehler)
3. **DomainManager**-Operationen wie `restartContainer` / `stopContainer`
Erfolg:
```text
OK:RestartInvoked method=…
INFO:ToolVersion=…
```
### `SonicMfContainerTool.class` / `SonicMfContainerTool$Args.class`
Vorkompilierter Java-8-Bytecode (major 52), den die App zur Laufzeit nutzt (ohne `javac` auf dem Zielrechner).
### `SonicMfContainerTool.jar`
Optional verpackte Variante des Tools (Classpath-Alternative).
### `verify-mfapi-classpath.bat`
Hilfsskript: prüft manuell, ob `JMSConnectorAddress` mit den JARs unter `C:\DEV\MQ10.0\lib` ladbar ist.
---
## 6. Ordner `Docs/` (Dokumentation)
### `Komplette-Erklaerung.md`
Schritt-für-Schritt: Zertifikat, Preflight, C#-Kette, Java-API, Fehlerbilder, Präsentationsskript.
### `Neustart-und-Management-API.md`
Kompaktere API-/Neustart-Erklärung inkl. „Was ist unnötig in der großen Sonic-API?“.
### `Dateiuebersicht-nach-Ordner.md`
Diese Datei Katalog aller Projektdateien.
---
## 7. Ordner `Assets/`
### `logo.png`
UI-/Branding-Grafik.
### `TestCertificates/esb-test-cert.cer`
Beispiel-Zertifikat zum lokalen Test der Dateierkennung (kein Sonic-Deploy).
---
## 8. Ordner `Demo/`
### `Demo/Central/esb-cert.cer`
Weiteres Beispielzertifikat. Nicht Teil der Online-Neustart-Logik.
---
## 9. Sibling-Ordner `doc-10.0.10/` (neben der App, im Repo)
Produkt-Dokumentation Aurea CX Messenger / Sonic (nicht App-Code).
### `CXMessenger_2017_R3.htm`
Index der offiziellen Doku. Wichtiger Link:
- **Management Application API Reference** → `Docs2017/api/mgmt_api/`
Das ist die API-Familie, die SMC und unser Java-Tool nutzen.
### `Docs2017/api/analytics-offloader/…`
Javadoc-Stub für Analytics Offload **nicht** der Neustart-Pfad.
### PDFs / vollständige `mgmt_api`
Im Index verlinkt; können im Checkout fehlen. Für Reviews idealerweise nachziehen.
---
## 10. Die Sonic Management Application API detailliert
### 10.1 Was „API“ hier bedeutet
Es ist **keine REST-URL der WinForms-App**.
Es ist die **Java Management Application API** von Sonic/Aurea:
- Transport: JMS zum Domain Manager (`tcp://host:port`)
- Steuerung: JMX-ähnliche ObjectNames + Operationen
- Authentifizierung: SMC-User/Pass
Dieselbe Schicht wie der Restart in der Sonic Management Console.
### 10.2 Welche Sonic-Klassen wir wirklich brauchen
| Klasse | Aufgabe |
|--------|---------|
| `JMSConnectorAddress` | Verbindungsparameter kapseln |
| `JMSConnectorClient` | Verbinden / disconnect / invoke / queryNames |
| `MFProxyFactory` | `createAgentProxy` |
| `IAgentProxy` | typisierte Methoden stop/restart/… (remote oft eingeschränkt) |
| ObjectName `domain.container:ID=AGENT` | Ziel-Agent |
Benötigte JARs typischerweise unter `C:\DEV\MQ10.0\lib`:
- `mgmt_client.jar`
- `mfcontext.jar`
- `sonic_Client.jar`
(+ weitere Abhängigkeiten im selben Ordner)
### 10.3 Warum die Sonic-API „viele unnötige Funktionen“ hat
Die Produkt-API deckt ab:
- Messaging (Queues, Topics, Durable Subs)
- Broker-Admin
- ESB
- Metrics / Notifications
- Config / Directory
**Für unseren Use-Case reichen 3 Schritte:** Connect → Agent finden → Stop/Restart.
Der Rest der JARs/Javadoc ist Produktumfang, nicht App-Feature.
### 10.4 „Unbounded client connector“
Remote-Clients (SMC, unser Tool) sind oft **unbounded**.
Dann schlägt `IAgentProxy.restart()` fehl mit:
```text
Operation unsupported for unbounded client connector
```
Deshalb bevorzugt das Tool **MBean.invoke("stop")**.
Operativ entspricht das oft SMC-Restart: Stop + Auto-Relaunch durch Launch Daemon.
### 10.5 Sequenz eines Neustarts (API-Ebene)
```text
1. C#: RestartExecutor → SonicManagementClient → SonicMfApiExecutor
2. C#: startet java -cp Tools;lib\*.jar SonicMfContainerTool restart …
3. Java: JMSConnectorClient.connect(url, user, pass)
4. Java: ObjectName = proalpha-test.ct-ZADBService:ID=AGENT
5. Java: connector.invoke(on, "stop", …) // oder Fallback
6. Java: OK:RestartInvoked
7. C#: PostRestartDelaySeconds warten
8. UI: Erfolg/Fehler + Log
```
---
## 11. Zertifikatsprüfung welche Dateien
| Datei | Rolle |
|-------|--------|
| `Form1.cs` | Dateiauswahl, PFX-Passwort, Anzeige |
| `Models/CertificateInfo.cs` | Ergebnisobjekt |
| `Assets/...` / `Demo/...` | optionale Testdateien |
Ablauf:
1. Datei wählen (`.cer/.crt/.pem/.pfx`)
2. Als `X509Certificate2` laden
3. Subject/Issuer/Fingerprint/Gültigkeit berechnen
4. Anzeigen **lokal verifiziert**
Nicht enthalten: Kopieren auf ESB, TLS-Probe gegen Live-Host, CA-Kettenprüfung.
---
## 12. Was absichtlich fehlt (gelöscht / deaktiviert)
| Früher | Status |
|--------|--------|
| `Data/targets.sample.json` + JSON-Repository | entfernt Ziele aus KnownContainers |
| SQL-Repos / Schema | entfernt |
| WinRM / LocalCmd / stopcontainer.bat-Executor | entfernt |
| CertificateDeployer / TlsCertificateProbe | entfernt |
| XApi-Import | entfernt |
| Deploy-Button | UI ausgeblendet |
---
## 13. Schnell-Tabelle „Datei → Ein Satz“
| Datei | Ein Satz |
|-------|----------|
| `Program.cs` | Startet die App |
| `Form1.cs` | UI + Zertifikat + Neustart-Klick |
| `Form1.Designer.cs` | Leerer Designer-Stub |
| `Form1.resx` | Formular-Ressourcen |
| `appsettings.json` | Sonic-Verbindung + KnownContainers + Java/Libs |
| `*.csproj` | Build/Copy-Regeln |
| `Configuration/AppSettingsLoader.cs` | JSON → Settings-Objekt |
| `Models/AppSettings.cs` | Settings-Root |
| `Models/SonicConnection.cs` | Eine SMC-/Domain-Verbindung |
| `Models/DeploymentTarget.cs` | Ein Container-Ziel im Grid |
| `Models/CertificateInfo.cs` | Lokales Zertifikats-Ergebnis |
| `Models/DeploymentRunResult.cs` | Lauf-/Validierungs-DTOs |
| `Services/DeploymentOrchestrator.cs` | Neustart über alle Ziele |
| `Services/RestartExecutor.cs` | Ziel → SonicClient |
| `Services/PreflightValidator.cs` | Pflichtfelder prüfen |
| `Services/SonicManagementClient.cs` | Fassade ping/list/restart |
| `Services/SonicMfApiExecutor.cs` | Java-Prozess + Classpath |
| `Services/SonicContainerDiscovery.cs` | KnownContainers + Live-Liste |
| `Services/RunLogger.cs` | Datei-Logs |
| `Services/PathResolver.cs` | Relative Pfade |
| `Tools/SonicMfContainerTool.java` | Sonic Management API Aufrufe |
| `Tools/*.class` / `.jar` | Vorkompiliertes Tool |
| `Tools/verify-mfapi-classpath.bat` | Classpath-Selbsttest |
| `Docs/*.md` | Erklärungen |
| `Assets/*` | Logo / Testzertifikat |
| `Demo/*` | Demo-Zertifikat |
| `doc-10.0.10/*` | Offizielle Sonic/CX-Messenger-Doku |
---
*Ende der Dateiübersicht. Für Ablauf-Details siehe `Komplette-Erklaerung.md`.*
@@ -0,0 +1,507 @@
# MfApi-Restart vollständige Erklärung
Stand: Tool-Version `2026-07-24d-restart-only`
Zweck dieses Dokuments: erklären, **wie die Sonic Management Application API (MfApi) in dieser App genutzt wird**, Schicht für Schicht von Button-Klick bis MBean-Aufruf.
---
## 1. Was ist „die API“ hier?
Es gibt **keine eigene REST-API** der WinForms-App.
Gemeint ist die **Sonic / Aurea MF Management Application API** (wie in der Sonic Management Console, SMC):
| Begriff | Bedeutung |
|---------|-----------|
| **MfApi** | Management Framework API über JMS/JMX-Client |
| **Domain Manager** | zentrale Verwaltung der Sonic-Domain |
| **Container / Agent** | laufende ESB-Instanz, z.B. `ct-ZADBService` |
| **SMC** | Sonic Management Console (UI mit denselben Credentials/URL) |
| **MBean** | verwaltbares Objekt im Domain Manager (`ObjectName`) |
Die App startet lokal einen **Java-Prozess**, der die offiziellen Sonic-Client-JARs lädt und denselben Neustart-Intent ausführt wie **Restart** in der SMC.
### Warum Java und nicht reines .NET?
Die Sonic-Client-Bibliotheken (`mgmt_client.jar`, `mfcontext.jar`, `sonic_Client.jar`, …) sind **Java**.
C# orchestriert nur: Konfiguration lesen → `java.exe` starten → stdout auswerten.
---
## 2. Was die App bewusst *nicht* mehr macht
Nach dem Slimming ist die API-Schicht **nur Restart**:
| Entfernt | Früher |
|----------|--------|
| `ping` | Verbindungstest über Agent-Query |
| `list` | Live-Containerliste aus dem Domain Manager |
| IAgentProxy-Fallback | oft „Operation unsupported for unbounded client connector“ |
| DomainManager-Scan | `restartContainer` / Manager-MBeans |
| WinRM / `stopcontainer.bat` | lokaler Script-Pfad |
| HTTP-REST-Pfade | ungenutzt |
Container kommen ausschließlich aus **`appsettings.json``KnownContainers`**.
---
## 3. Architektur-Überblick
```text
┌─────────────────────────────────────────────────────────────────┐
│ WinForms UI (Form1) │
│ Button „ESB neu starten“ │
└────────────────────────────┬────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ DeploymentOrchestrator │
│ Preflight → Schleife über Ziele → Logging / Progress │
└────────────────────────────┬────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ RestartExecutor │
│ Ziel → SonicConnection (Name-Match) │
└────────────────────────────┬────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ SonicManagementClient │
│ Restart + PostRestartDelay │
└────────────────────────────┬────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ SonicMfApiExecutor (.NET) │
│ Java finden, Classpath bauen, Process starten, Output prüfen │
└────────────────────────────┬────────────────────────────────────┘
│ Process: java -cp … SonicMfContainerTool restart …
┌─────────────────────────────────────────────────────────────────┐
│ SonicMfContainerTool (Java) │
│ connect → MBean.invoke(stop|restart) → OK:RestartInvoked │
└────────────────────────────┬────────────────────────────────────┘
│ tcp://…:13070 (JMS Management)
┌─────────────────────────────────────────────────────────────────┐
│ Sonic Domain Manager / Broker │
│ ObjectName: proalpha-test.ct-ZADBService:ID=AGENT │
│ Launch Daemon startet Container nach stop ggf. neu │
└─────────────────────────────────────────────────────────────────┘
```
---
## 4. Konfiguration (`appsettings.json`)
Beispiel (aktuell):
```json
{
"LogDirectory": "Logs",
"SonicConnections": [
{
"Name": "DE-Test",
"DomainName": "proalpha-test",
"ConnectionUrl": "tcp://dekun-painwbdet:13070",
"Username": "Administrator",
"Password": "Administrator",
"SonicHome": "C:\\DEV\\MQ10.0",
"JavaHome": "C:\\Program Files (x86)\\Java\\jre1.8.0_501",
"JavaPath": "C:\\Program Files (x86)\\Java\\jre1.8.0_501\\bin\\java.exe",
"MfClientLibPath": "C:\\DEV\\MQ10.0\\lib",
"KnownContainers": [ "ct-ZADBService" ],
"TimeoutSeconds": 120,
"PostRestartDelaySeconds": 20
}
]
}
```
### Felder erklärt
| Feld | Rolle |
|------|--------|
| **Name** | Alias der Verbindung. `DeploymentTarget.SonicConnectionName` muss dazu passen (hier `DE-Test`). |
| **DomainName** | Sonic-Domain (`proalpha-test`). Teil des JMX-`ObjectName`. |
| **ConnectionUrl** | Dieselbe TCP-URL wie in der SMC (`tcp://host:port`). |
| **Username / Password** | SMC-Login (nicht Windows/WinRM). Passwort geht als Env `ESB_SONIC_PASSWORD` an Java. |
| **SonicHome** | Installationsroot; Fallback für `lib\*.jar`, wenn `MfClientLibPath` leer/unbrauchbar. |
| **JavaPath** | Bevorzugter Pfad zu `java.exe` (**Java 8+**, Class major ≥ 52). |
| **JavaHome** | Alternativ `JavaHome\bin\java.exe`. |
| **MfClientLibPath** | Ordner mit Client-JARs (`mgmt_client.jar`, `mfcontext.jar`, …). |
| **KnownContainers** | Liste der Container-**Kurznamen** für die Grid-Ziele. |
| **TimeoutSeconds** | Timeout für Java-Prozess und Tool-Connect (5600 s, Clamp in Code). |
| **PostRestartDelaySeconds** | Wartezeit **nach** erfolgreichem `OK:RestartInvoked` (0120 s), bevor UI „fertig“ meldet. |
### Wichtige Namensunterscheidung
| Name | Beispiel | Ist |
|------|----------|-----|
| Verbindungs-Alias | `DE-Test` | Eintrag in `SonicConnections[].Name` |
| Domain | `proalpha-test` | Sonic-Domain |
| Container | `ct-ZADBService` | Agent / Container in SMC |
Häufiger Fehler: Alias `DE-Test` als Containername verwenden. Der ObjectName wäre dann falsch.
Korrekt:
```text
proalpha-test.ct-ZADBService:ID=AGENT
```
---
## 5. Ziele (DeploymentTarget)
Beim Start baut `SonicContainerDiscovery.BuildTargetsFromConfig()` aus jeder Connection und jedem `KnownContainers`-Eintrag ein Ziel:
| Property | Quelle / Wert |
|----------|----------------|
| `Name` | `"DE-Test / ct-ZADBService"` |
| `Environment` | `DomainName` |
| `ContainerName` | `ct-ZADBService` |
| `SonicConnectionName` | `DE-Test` |
| `RestartType` | `SonicContainer` |
Es gibt **keinen Live-Query** mehr („Container laden“ wurde entfernt).
---
## 6. Aufrufkette jede Methode
### 6.1 UI `Form1`
| Schritt | Methode / Ereignis | Aufgabe |
|---------|-------------------|---------|
| 1 | `btnRestartOnly.Click` | Startet `RunRestartOnlyAsync()` |
| 2 | `GetSelectedTargets()` | Angehakte Grid-Zeilen |
| 3 | `_orchestrator.ValidateRestartOnly(...)` | Vorabprüfung |
| 4 | MessageBox-Bestätigung | Nutzer bestätigt Neustart |
| 5 | `_orchestrator.RestartOnlyAsync(...)` | eigentlicher Lauf |
| 6 | Grid / Statuszeile | Ergebnis anzeigen (kein Erfolgs-Popup) |
### 6.2 `PreflightValidator.ValidateRestartOnly`
Prüft grob:
- mindestens ein Ziel gewählt
- `ContainerName` gesetzt
- `SonicConnectionName` gesetzt
Fehler → Abbruch **vor** Java.
### 6.3 `DeploymentOrchestrator`
| Methode | Aufgabe |
|---------|---------|
| `ValidateRestartOnly` | reicht an Preflight durch |
| `RestartOnlyAsync` | Log öffnen, alle gewählten Ziele nacheinander |
| `RestartTargetOnlyAsync` | Progress „Neustart…“ → Executor → `TargetStepResult` |
Kein Deploy, kein TLS, kein SQL.
### 6.4 `RestartExecutor.ExecuteAsync`
1. `ContainerName` prüfen
2. `SonicConnection` per `Name == target.SonicConnectionName` finden
3. `new SonicManagementClient(connection)`
4. `RestartContainerAsync(target.ContainerName)`
### 6.5 `SonicManagementClient.RestartContainerAsync`
1. `_mfApi.RestartAsync(containerName)`
2. Bei Erfolg: `Task.Delay(PostRestartDelaySeconds)`
3. Status/Detail-String für UI/Log zurückgeben
Die Verzögerung gibt dem Launch Daemon Zeit, den Container wieder hochzufahren. Sie ist **kein** aktiver SMC-Status-Poll.
### 6.6 `SonicMfApiExecutor` die C#-Brücke
#### `PrepareTool()`
1. **Java** über `ResolveJavaExe()`:
- `JavaPath`
- `JavaHome\bin\java.exe`
- `JAVA_HOME` / `JRE_HOME`
- `java.exe` aus PATH
2. **Classpath** über `ResolveSonicClasspath()`:
- alle `*.jar` aus `MfClientLibPath`, sonst `SonicHome\lib` / `SonicHome`
- bevorzugte Reihenfolge: `mgmt_client.jar`, `mfcontext.jar`, `sonic_Client.jar`, `sonic_Crypto.jar`, `mf_common.jar`, dann Rest
3. **Tool-Verzeichnis** mit `SonicMfContainerTool.class` (`Tools\` neben der EXE)
Ergebnis-Classpath grob:
```text
<App>\Tools;<MfClientLibPath>\mgmt_client.jar;...;<weitere jars>
```
#### `RestartAsync(containerName)`
Startet:
```text
java -cp "<Tools>;<jars>" SonicMfContainerTool restart
--domain proalpha-test
--url tcp://dekun-painwbdet:13070
--user Administrator
--container ct-ZADBService
--timeout 120
```
- Argumente über `ProcessStartInfo.ArgumentList` (keine manuellen Quotes → sonst Classpath-Bugs)
- Passwort: Umgebungsvariable `ESB_SONIC_PASSWORD`
- stdout/stderr lesen, auf Timeout killen
#### Erfolgskriterium in C#
Alles muss gelten:
1. `ExitCode == 0`
2. Ausgabe enthält `OK:RestartInvoked`
3. Ausgabe enthält **kein** `ERROR:`
Sonst Fehlertext (+ Classpath-Hinweis bei `ClassNotFoundException` / `JMSConnectorAddress`).
---
## 7. Java-Tool der eigentliche API-Call
Datei: `Tools/SonicMfContainerTool.java`
Bytecode: `Tools/SonicMfContainerTool.class` (Java 8 / major 52)
Version-Stamp: `TOOL_VERSION = "2026-07-24d-restart-only"` (erste INFO-Zeile)
### 7.1 `main`
1. `INFO:ToolVersion=…` ausgeben
2. Args parsen nur Befehl `restart` erlaubt
3. Pflicht: `--domain`, `--url`, `--user`, `--container`
4. Passwort: `--password` oder Env `ESB_SONIC_PASSWORD`
5. `connect(...)`
6. `restart(...)`
7. `disconnect` im `finally`
### 7.2 `connect` Einloggen wie SMC
```text
Hashtable env:
ConnectionURLs = tcp://…
DefaultUser = Administrator
DefaultPassword = …
JMSConnectorAddress(env)
JMSConnectorClient()
optional: setTimeout / setRequestTimeout
client.connect(address [, timeout])
```
Klassen (aus den JARs, per Reflection):
- `com.sonicsw.mf.jmx.client.JMSConnectorAddress`
- `com.sonicsw.mf.jmx.client.JMSConnectorClient`
Reflection statt direkter Compile-Abhängigkeit: das Tool kompiliert ohne Sonic-JARs auf dem Build-Rechner; zur Laufzeit müssen die JARs im Classpath liegen.
### 7.3 `restart` ObjectName und Lifecycle
1. Kurzname bilden (`proalpha-test.ct-ZADBService``ct-ZADBService`)
2. ObjectName:
```text
<DomainName>.<ContainerKurzname>:ID=AGENT
→ proalpha-test.ct-ZADBService:ID=AGENT
```
3. Operationen der Reihe nach:
| Versuch | Operation | Bedeutung |
|---------|-----------|-----------|
| 1 | `stop` | Agent stoppen (SMC macht oft faktisch Stop; Daemon startet neu) |
| 2 | `restart` | falls vorhanden und erlaubt |
4. Aufruf:
```text
connector.invoke(objectName, op, new Object[0], new String[0])
```
Das ist der JMX/`MBeanServerConnection.invoke`-Weg über den Sonic-Management-Connector.
### 7.4 Side-Effect bei `stop`
Wenn `stop` eine Exception wirft, deren Message auf Verbindungsabbruch hindeutet (`disconnect`, `closed`, `not connected`, `connection lost`), wertet das Tool das als **Erfolg**:
```text
OK:RestartInvoked method=MBean.stop(side-effect) …
```
Grund: der Agent geht runter und reißt oft die Management-Session das ist erwartbar, kein „echter“ Fehlschlag.
### 7.5 Erfolgszeile
```text
OK:RestartInvoked method=MBean.stop container=ct-ZADBService domain=proalpha-test tool=2026-07-24d-restart-only
```
C# sucht genau nach `OK:RestartInvoked`.
### 7.6 Ausgabe-Konventionen
| Präfix | Bedeutung |
|---------|-----------|
| `INFO:` | Fortschritt / ObjectName / Trying … |
| `WARN:` | fehlgeschlagener Versuch, Weiterversuch |
| `ERROR:` | fatal, ExitCode ≠ 0 |
| `OK:RestartInvoked` | Erfolg |
---
## 8. Sequenz (zeitlich)
```mermaid
sequenceDiagram
participant UI as Form1
participant Orch as DeploymentOrchestrator
participant RE as RestartExecutor
participant MC as SonicManagementClient
participant EX as SonicMfApiExecutor
participant JV as SonicMfContainerTool
participant DM as Sonic Domain Manager
UI->>Orch: RestartOnlyAsync(selected)
Orch->>RE: ExecuteAsync(target)
RE->>MC: RestartContainerAsync(ct-ZADBService)
MC->>EX: RestartAsync(...)
EX->>EX: PrepareTool (java + jars + class)
EX->>JV: Process start restart …
JV->>DM: JMSConnectorClient.connect
JV->>DM: invoke(…:ID=AGENT, "stop")
DM-->>JV: ok / disconnect side-effect
JV-->>EX: OK:RestartInvoked
EX-->>MC: Success=true
MC->>MC: Delay PostRestartDelaySeconds
MC-->>UI: Status + Detail
```
---
## 9. Dateien und Verantwortlichkeiten
| Datei | Rolle |
|-------|--------|
| `Form1.cs` | UI, Auswahl, Bestätigung, Status |
| `Services/DeploymentOrchestrator.cs` | Lauf orchestrieren, Log |
| `Services/PreflightValidator.cs` | Vorabprüfung |
| `Services/RestartExecutor.cs` | Ziel → Connection |
| `Services/SonicManagementClient.cs` | Restart + Delay |
| `Services/SonicMfApiExecutor.cs` | Java-Prozess, Classpath, Erfolgsauswertung |
| `Services/SonicContainerDiscovery.cs` | KnownContainers → Grid-Ziele |
| `Models/SonicConnection.cs` | Konfigurationsmodell |
| `Models/DeploymentTarget.cs` | Zielzeile |
| `Tools/SonicMfContainerTool.java` | API-Client (Quellcode) |
| `Tools/SonicMfContainerTool.class` | ausgeliefert / Java-8-Bytecode |
| `appsettings.json` | Verbindungen + Containerliste |
---
## 10. Laufzeit-Voraussetzungen
1. **Java 8+** erreichbar (`JavaPath` empfohlen)
2. **`MfClientLibPath`** zeigt auf Ordner mit Sonic-Client-JARs
3. **`Tools\SonicMfContainerTool.class`** liegt neben der gebauten App
4. Netzwerk zu `ConnectionUrl` (Port z.B. 13070)
5. Gültige SMC-Credentials
6. Containername stimmt mit SMC überein (`ct-ZADBService`)
7. Domain Manager / Launch Daemon müssen Container nach Stop wieder starten können
### Tool neu kompilieren
```text
javac --release 8 -encoding UTF-8 Tools\SonicMfContainerTool.java
```
(oder `-source 1.8 -target 1.8`)
Danach App neu bauen, damit `.class` nach `bin\…\Tools\` kopiert wird (csproj Content-Copy).
---
## 11. Typische Fehler und Ursache
| Symptom | Ursache | Maßnahme |
|---------|---------|----------|
| `ClassNotFoundException: JMSConnectorAddress` | Classpath ohne MF-JARs / falsche Quotes | `MfClientLibPath` prüfen; `ArgumentList` nicht manuell quoten |
| `UnsupportedClassVersionError` | Runtime zu alt oder `.class` zu neu | Java 8+ Runtime; Tool mit `--release 8` bauen |
| `Java nicht gefunden` | `JavaPath` falsch | appsettings korrigieren |
| `Tools\SonicMfContainerTool.class nicht gefunden` | Build/Copy fehlt | Projekt neu bauen |
| `Neustart fehlgeschlagen` + Attempts | MBean `stop`/`restart` abgelehnt | ObjectName/Rechte/Containerstatus in SMC prüfen |
| Timeout | Domain Manager hängt / Netz | `TimeoutSeconds`, Firewall, Broker |
| Falscher Container | Alias statt Kurzname | `KnownContainers`: `ct-ZADBService` |
| Kein `ToolVersion=` in Output | alte/falsche `.class` | Tools neu kompilieren, Output-Ordner prüfen |
---
## 12. Verhältnis zur SMC
| SMC | Diese App |
|-----|-----------|
| Login mit User/Pass auf Domain-URL | dieselben Werte in appsettings |
| Container im Baum wählen | `KnownContainers` + Grid |
| Rechtsklick → Restart | `MBean.invoke("stop"|"restart")` auf `…:ID=AGENT` |
| UI zeigt Online/Offline | App wartet nur `PostRestartDelaySeconds`, pollt Status nicht |
Operativ oft: **Stop am Agent-MBean**; der **Launch Daemon** bringt den Container wieder hoch analog zu vielen SMC-Restart-Verhalten bei remote „unbounded“ Clients.
Früher getestet: `IAgentProxy.restart()` schlägt remote häufig mit
`Operation unsupported for unbounded client connector` fehl deshalb der direkte MBean-Weg.
---
## 13. Sicherheitshinweise
- Passwort steht in `appsettings.json` (Klartext) und kurzzeitig in der Prozess-Umgebung `ESB_SONIC_PASSWORD`.
- Nicht in öffentliche Repos committen bzw. Secrets auslagern.
- Logs (`LogDirectory`) können Tool-Output enthalten ggf. sensible Zeilen beachten.
---
## 14. Manueller Test (ohne UI)
Mit denselben Werten wie in appsettings:
```text
set ESB_SONIC_PASSWORD=Administrator
java -cp "Tools;C:\DEV\MQ10.0\lib\*" SonicMfContainerTool restart ^
--domain proalpha-test ^
--url tcp://dekun-painwbdet:13070 ^
--user Administrator ^
--container ct-ZADBService ^
--timeout 120
```
Erwartet u.a.:
```text
INFO:ToolVersion=2026-07-24d-restart-only
INFO:ObjectName=proalpha-test.ct-ZADBService:ID=AGENT
INFO:Trying MBean.invoke(stop)
OK:RestartInvoked method=MBean.stop container=ct-ZADBService domain=proalpha-test tool=2026-07-24d-restart-only
```
(Unter Windows Classpath ggf. mit `;` und expliziten JAR-Namen statt `*`, je nach Shell.)
---
## 15. Kurzfassung
1. UI wählt Ziel aus `KnownContainers`.
2. C# findet die passenden Sonic-Verbindungsdaten.
3. C# startet Java mit MF-Client-JARs + `SonicMfContainerTool`.
4. Java verbindet sich wie die SMC an den Domain Manager.
5. Java ruft auf dem Agent-MBean `stop` (sonst `restart`) auf.
6. Bei `OK:RestartInvoked` wartet C# noch `PostRestartDelaySeconds` und meldet Erfolg.
**Der einzige echte Sonic-API-Call der App ist:**
`JMSConnectorClient.invoke(<domain>.<container>:ID=AGENT, "stop"|"restart", …)`.
+91 -11
View File
@@ -1651,7 +1651,8 @@ namespace ZA.CoreService.ESBCertificateManager
private async Task ProbeTargetCertificatesAsync(
IReadOnlySet<int>? preferLoadedCertificateForTargetIds = null)
{
CertificateProbeService probe = new();
CertificateProbeService fileProbe = new();
TlsEndpointProbeService tlsProbe = new();
string? password = _loadedCertificatePassword;
Func<string?>? passwordPrompt = password is null
? null
@@ -1664,8 +1665,37 @@ namespace ZA.CoreService.ESBCertificateManager
continue;
}
// Bevorzugt Live-TLS-Probe (curl-ähnlich), wenn URL am Container gesetzt.
if (!string.IsNullOrWhiteSpace(target.TlsHost)
&& target.TlsPort is int tlsPort)
{
string checkUrl =
string.IsNullOrWhiteSpace(target.TlsServerName)
? $"{target.TlsHost}:{tlsPort}"
: $"https://{target.TlsServerName}:{tlsPort}";
CertificateProbeResult tlsResult = await Task.Run(
() => tlsProbe.Probe(checkUrl));
if (IsDisposed || !dgvTargets.Columns.Contains("Expiry"))
{
return;
}
if (tlsResult.Success && tlsResult.Certificate is not null)
{
row.Cells["Expiry"].Value =
"TLS · "
+ FormatCertificateExpiryCell(tlsResult.Certificate);
continue;
}
row.Cells["Expiry"].Value = "TLS · nicht erreichbar";
continue;
}
CertificateProbeResult result = await Task.Run(
() => probe.Probe(target.FullTargetPath, passwordPrompt));
() => fileProbe.Probe(target.FullTargetPath, passwordPrompt));
if (IsDisposed || !dgvTargets.Columns.Contains("Expiry"))
{
@@ -1697,6 +1727,13 @@ namespace ZA.CoreService.ESBCertificateManager
continue;
}
if (result.AccessDenied)
{
row.Cells["Expiry"].Value =
CertificateProbeResult.AccessDeniedShortText;
continue;
}
row.Cells["Expiry"].Value = result.Success
? "Datei fehlt"
: "nicht erreichbar";
@@ -1940,13 +1977,27 @@ namespace ZA.CoreService.ESBCertificateManager
runResult.TargetResults
.Where(result => !result.Success)
.Select(result =>
$"{result.TargetName}: {result.StatusText}"
$"{result.TargetName}"
+ Environment.NewLine
+ (result.Detail ?? string.Empty)));
+ SonicErrorClassifier.FormatRestartFailure(
result.StatusText,
result.Detail)));
bool permissionIssue =
SonicErrorClassifier.LooksLikeMissingPermission(details);
if (permissionIssue)
{
SetStatus(
"Neustart nicht möglich fehlende Benutzerrechte.",
isError: true);
}
CopyableErrorDialog.Show(
this,
"Neustart fehlgeschlagen (MfApi)",
permissionIssue
? "Keine Rechte für den Neustart"
: "Neustart fehlgeschlagen (MfApi)",
string.IsNullOrWhiteSpace(details)
? "Neustart fehlgeschlagen (keine Details)."
: details);
@@ -1958,11 +2009,26 @@ namespace ZA.CoreService.ESBCertificateManager
}
catch (Exception ex)
{
SetStatus($"Neustart fehlgeschlagen: {ex.Message}", isError: true);
string formatted =
SonicErrorClassifier.FormatRestartFailure(
"Neustart fehlgeschlagen",
ex.ToString());
bool permissionIssue =
SonicErrorClassifier.LooksLikeMissingPermission(formatted);
SetStatus(
permissionIssue
? "Neustart nicht möglich fehlende Benutzerrechte."
: $"Neustart fehlgeschlagen: {ex.Message}",
isError: true);
CopyableErrorDialog.Show(
this,
"Neustart fehlgeschlagen (MfApi)",
ex.ToString());
permissionIssue
? "Keine Rechte für den Neustart"
: "Neustart fehlgeschlagen (MfApi)",
formatted);
}
finally
{
@@ -2078,13 +2144,27 @@ namespace ZA.CoreService.ESBCertificateManager
runResult.TargetResults
.Where(result => !result.Success)
.Select(result =>
$"{result.TargetName}: {result.StatusText}"
$"{result.TargetName}"
+ Environment.NewLine
+ (result.Detail ?? string.Empty)));
+ SonicErrorClassifier.FormatRestartFailure(
result.StatusText,
result.Detail)));
bool permissionIssue =
SonicErrorClassifier.LooksLikeMissingPermission(details);
if (permissionIssue)
{
SetStatus(
"Austausch/Neustart nicht möglich fehlende Benutzerrechte.",
isError: true);
}
CopyableErrorDialog.Show(
this,
"Austausch fehlgeschlagen",
permissionIssue
? "Keine Rechte für den Neustart"
: "Austausch fehlgeschlagen",
details);
await ProbeTargetCertificatesAsync(exchangedTargetIds);
@@ -26,6 +26,11 @@ public sealed class ContainerOption
public string? ContainerDisplayName { get; init; }
/// <summary>
/// Endpoint für TLS-Zertifikatsprüfung, z. B. https://host:8443 oder host:8443.
/// </summary>
public string? CertificateCheckUrl { get; init; }
public required string CompanyCode { get; init; }
public required string ConnectionName { get; init; }
@@ -34,7 +34,20 @@ public sealed class CertificateProbeService
+ string.Join(", ", SupportedExtensions));
}
if (!File.Exists(normalized))
PathReachability reachability = AssessPathReachability(normalized);
if (reachability == PathReachability.AccessDenied)
{
return CertificateProbeResult.ForAccessDenied(normalized);
}
if (reachability == PathReachability.Unreachable)
{
return CertificateProbeResult.Failed(
"Pfad nicht erreichbar.");
}
if (reachability == PathReachability.FileMissing)
{
return CertificateProbeResult.Missing(
normalized,
@@ -72,6 +85,14 @@ public sealed class CertificateProbeService
"Das Kennwort ist falsch oder die Datei ist beschädigt.");
}
}
catch (UnauthorizedAccessException)
{
return CertificateProbeResult.ForAccessDenied(normalized);
}
catch (IOException ex) when (IsAccessOrLogonFailure(ex))
{
return CertificateProbeResult.ForAccessDenied(normalized);
}
catch (Exception ex)
{
return CertificateProbeResult.Failed(
@@ -151,6 +172,154 @@ public sealed class CertificateProbeService
|| extension.Equals(".p12", StringComparison.OrdinalIgnoreCase);
}
private enum PathReachability
{
FileExists,
FileMissing,
AccessDenied,
Unreachable
}
/// <summary>
/// Unterscheidet „Datei fehlt“ von „Share/Pfad ohne Admin-Konto nicht erreichbar“.
/// File.Exists liefert bei fehlenden Credentials oft nur false.
/// </summary>
private static PathReachability AssessPathReachability(string filePath)
{
try
{
if (File.Exists(filePath))
{
return PathReachability.FileExists;
}
}
catch (UnauthorizedAccessException)
{
return PathReachability.AccessDenied;
}
catch (IOException ex) when (IsAccessOrLogonFailure(ex))
{
return PathReachability.AccessDenied;
}
catch (IOException)
{
return PathReachability.Unreachable;
}
string? directory = Path.GetDirectoryName(filePath);
if (string.IsNullOrWhiteSpace(directory))
{
return PathReachability.Unreachable;
}
return AssessDirectoryReachability(directory);
}
private static PathReachability AssessDirectoryReachability(string directory)
{
string current = directory.TrimEnd('\\', '/');
while (!string.IsNullOrWhiteSpace(current))
{
try
{
if (Directory.Exists(current))
{
// Exists kann bei UNC ohne Rechte „lügen“ Auflisten erzwingen.
_ = Directory.EnumerateFileSystemEntries(current)
.Any();
return PathReachability.FileMissing;
}
if (current.StartsWith(@"\\", StringComparison.Ordinal))
{
// Exists=false: echter Zugriffsfehler oder Ordner fehlt.
_ = Directory.GetFileSystemEntries(current);
return PathReachability.FileMissing;
}
// Lokaler Ordner fehlt → für Erst-Deployment als „Datei fehlt“ werten.
return PathReachability.FileMissing;
}
catch (UnauthorizedAccessException)
{
return PathReachability.AccessDenied;
}
catch (IOException ex) when (IsAccessOrLogonFailure(ex))
{
return PathReachability.AccessDenied;
}
catch (DirectoryNotFoundException)
{
string? parent = GetParentPath(current);
if (parent is null || parent == current)
{
return current.StartsWith(@"\\", StringComparison.Ordinal)
? PathReachability.Unreachable
: PathReachability.FileMissing;
}
current = parent;
continue;
}
catch (IOException)
{
return PathReachability.Unreachable;
}
}
return PathReachability.Unreachable;
}
private static string? GetParentPath(string path)
{
string trimmed = path.TrimEnd('\\', '/');
if (trimmed.StartsWith(@"\\", StringComparison.Ordinal))
{
// \\server\share → Stop; darunter weiter nach oben.
string withoutPrefix = trimmed[2..];
int slash = withoutPrefix.IndexOfAny(['\\', '/']);
if (slash < 0)
{
return null;
}
int second = withoutPrefix.IndexOfAny(['\\', '/'], slash + 1);
if (second < 0)
{
// Bereits Share-Root \\server\share
return null;
}
}
string? parent = Path.GetDirectoryName(trimmed);
return string.IsNullOrWhiteSpace(parent) ? null : parent;
}
private static bool IsAccessOrLogonFailure(IOException ex)
{
// HRESULT-Lower-Word = Win32-Fehlercode
int win32 = ex.HResult & 0xFFFF;
return win32 is
5 or // ERROR_ACCESS_DENIED
53 or // ERROR_BAD_NETPATH
67 or // ERROR_BAD_NET_NAME
86 or // ERROR_INVALID_PASSWORD
1326 or // ERROR_LOGON_FAILURE
59 or // ERROR_UNEXP_NET_ERR
64 or // ERROR_NETNAME_DELETED
1219 or // ERROR_SESSION_CREDENTIAL_CONFLICT
1240 or // ERROR_LOGIN_WKSTA_RESTRICTION
1245 or // ERROR_ACCOUNT_RESTRICTION
1396; // ERROR_WRONG_TARGET_NAME
}
private static string? NormalizePath(string? path)
{
if (string.IsNullOrWhiteSpace(path))
@@ -233,10 +402,14 @@ public sealed class CertificateProbeService
public sealed class CertificateProbeResult
{
public const string AccessDeniedShortText = "Admin nötig";
public bool Success { get; init; }
public bool FileExists { get; init; }
public bool AccessDenied { get; init; }
public string Path { get; init; } = string.Empty;
public string Message { get; init; } = string.Empty;
@@ -289,6 +462,19 @@ public sealed class CertificateProbeResult
};
}
public static CertificateProbeResult ForAccessDenied(string path)
{
return new CertificateProbeResult
{
Success = false,
FileExists = false,
AccessDenied = true,
Path = path,
Message =
"Kein Zugriff auf den Pfad Admin-Konto nötig."
};
}
public static CertificateProbeResult PasswordRequired(string path)
{
return new CertificateProbeResult
@@ -43,6 +43,7 @@ public sealed class DeploymentTargetRepository
container.[SonicContainerId],
container.[ContainerName],
container.[ContainerDisplayName],
container.[CertificateCheckUrl],
container.[RestartTimeoutSeconds],
connection.[ConnectionName],
@@ -119,6 +120,9 @@ public sealed class DeploymentTargetRepository
int containerDisplayNameOrdinal =
reader.GetOrdinal("ContainerDisplayName");
int certificateCheckUrlOrdinal =
reader.GetOrdinal("CertificateCheckUrl");
int restartTimeoutOrdinal =
reader.GetOrdinal("RestartTimeoutSeconds");
@@ -144,6 +148,11 @@ public sealed class DeploymentTargetRepository
reader,
containerDisplayNameOrdinal);
string? certificateCheckUrl =
ReadNullableString(
reader,
certificateCheckUrlOrdinal);
string companyCode =
reader.GetString(companyCodeOrdinal);
@@ -152,6 +161,22 @@ public sealed class DeploymentTargetRepository
? containerName
: containerDisplayName;
string tlsHost = string.Empty;
int? tlsPort = null;
string tlsServerName = string.Empty;
if (TlsEndpointProbeService.TryParseEndpoint(
certificateCheckUrl,
out string host,
out int port,
out string serverName,
out _))
{
tlsHost = host;
tlsPort = port;
tlsServerName = serverName;
}
targets.Add(new DeploymentTarget
{
Id = reader.GetInt32(targetIdOrdinal),
@@ -188,11 +213,11 @@ public sealed class DeploymentTargetRepository
RestartTimeoutSeconds =
reader.GetInt32(restartTimeoutOrdinal),
TlsHost = string.Empty,
TlsHost = tlsHost,
TlsPort = null,
TlsPort = tlsPort,
TlsServerName = string.Empty,
TlsServerName = tlsServerName,
ExpectedFingerprint = null,
@@ -0,0 +1,75 @@
namespace ZA.CoreService.ESBCertificateManager.Services;
/// <summary>
/// Erkennt typische Sonic-/MfApi-Fehlermeldungen und liefert
/// verständliche Hinweise für die UI.
/// </summary>
public static class SonicErrorClassifier
{
private static readonly string[] PermissionMarkers =
[
"ManagePermissionDenied",
"ConfigurePermissionDenied",
"ManagementPermissionDenied",
"PermissionDenied",
"MFSecurityException",
"SecurityException",
"Access is denied",
"Access denied",
"not authorized",
"nicht autorisiert",
"permission denied",
"keine Berechtigung",
"Insufficient privileges",
"Unauthorized"
];
public static bool LooksLikeMissingPermission(string? text)
{
if (string.IsNullOrWhiteSpace(text))
{
return false;
}
foreach (string marker in PermissionMarkers)
{
if (text.Contains(marker, StringComparison.OrdinalIgnoreCase))
{
return true;
}
}
return false;
}
public static string PermissionDeniedUserMessage =>
"Keine Rechte für den Neustart.\n\n"
+ "Der hinterlegte Sonic-Benutzer darf diesen Container "
+ "nicht neu starten (Manage-/Restart-Recht fehlt).\n\n"
+ "Bitte in der Sonic Management Console die Rechte prüfen "
+ "oder ein Profil mit ausreichenden Rechten verwenden.";
public static string FormatRestartFailure(
string? status,
string? detail)
{
string combined = string.Join(
Environment.NewLine,
new[] { status, detail }
.Where(value => !string.IsNullOrWhiteSpace(value)));
if (LooksLikeMissingPermission(combined))
{
return PermissionDeniedUserMessage
+ Environment.NewLine
+ Environment.NewLine
+ "--- Technische Details ---"
+ Environment.NewLine
+ combined;
}
return string.IsNullOrWhiteSpace(combined)
? "Neustart fehlgeschlagen (keine Details)."
: combined;
}
}
@@ -34,9 +34,21 @@ public sealed class SonicManagementClient : IDisposable
$"Domain={_connection.DomainName}; URL={_connection.ConnectionUrl}\n{output}");
}
return (false, "Neustart fehlgeschlagen (MfApi)",
$"Container '{containerName}', Domain '{_connection.DomainName}', URL {_connection.ConnectionUrl}\n" +
$"Fehler: {error}\n\n{output}");
string technical =
$"Container '{containerName}', Domain '{_connection.DomainName}', URL {_connection.ConnectionUrl}\n"
+ $"Fehler: {error}\n\n{output}";
if (SonicErrorClassifier.LooksLikeMissingPermission(technical))
{
return (
false,
"Keine Rechte für den Neustart",
SonicErrorClassifier.FormatRestartFailure(
"Keine Rechte für den Neustart",
technical));
}
return (false, "Neustart fehlgeschlagen (MfApi)", technical);
}
public void Dispose()
@@ -380,6 +380,228 @@ public sealed class SonicSetupRepository
}
}
public async Task UpdateCredentialAsync(
int sonicCredentialId,
int sonicConnectionId,
string credentialName,
string userName,
string secret,
bool isDefault,
CancellationToken cancellationToken = default)
{
if (sonicCredentialId <= 0)
{
throw new ArgumentOutOfRangeException(
nameof(sonicCredentialId));
}
if (sonicConnectionId <= 0)
{
throw new ArgumentOutOfRangeException(
nameof(sonicConnectionId));
}
if (string.IsNullOrWhiteSpace(credentialName))
{
throw new ArgumentException(
"Der Profilname fehlt.",
nameof(credentialName));
}
if (string.IsNullOrWhiteSpace(userName))
{
throw new ArgumentException(
"Der Sonic-Benutzername fehlt.",
nameof(userName));
}
if (string.IsNullOrEmpty(secret))
{
throw new ArgumentException(
"Das Sonic-Kennwort fehlt.",
nameof(secret));
}
string normalizedCredentialName =
credentialName.Trim();
string normalizedUserName =
userName.Trim();
if (normalizedCredentialName.Length > 150)
{
throw new ArgumentException(
"Der Profilname darf maximal 150 Zeichen enthalten.",
nameof(credentialName));
}
if (normalizedUserName.Length > 256)
{
throw new ArgumentException(
"Der Benutzername darf maximal 256 Zeichen enthalten.",
nameof(userName));
}
if (secret.Length > 512)
{
throw new ArgumentException(
"Das Kennwort darf maximal 512 Zeichen enthalten.",
nameof(secret));
}
await using SqlConnection connection =
new(_connectionString);
await connection.OpenAsync(cancellationToken);
await using SqlTransaction transaction =
(SqlTransaction)
await connection.BeginTransactionAsync(
cancellationToken);
try
{
if (isDefault)
{
const string clearDefaultSql = """
UPDATE [dbo].[SonicCredential]
SET
[IsDefault] = 0,
[ModifiedDateTime] = SYSUTCDATETIME(),
[ModifiedBy] = SUSER_SNAME()
WHERE [SonicConnectionId] =
@SonicConnectionId
AND [IsDefault] = 1
AND [SonicCredentialId] <>
@SonicCredentialId;
""";
await using SqlCommand clearDefaultCommand =
new(
clearDefaultSql,
connection,
transaction);
clearDefaultCommand.Parameters.Add(
new SqlParameter(
"@SonicConnectionId",
SqlDbType.Int)
{
Value = sonicConnectionId
});
clearDefaultCommand.Parameters.Add(
new SqlParameter(
"@SonicCredentialId",
SqlDbType.Int)
{
Value = sonicCredentialId
});
await clearDefaultCommand.ExecuteNonQueryAsync(
cancellationToken);
}
const string updateSql = """
UPDATE [dbo].[SonicCredential]
SET
[CredentialName] = @CredentialName,
[CredentialUserName] = @CredentialUserName,
[CredentialSecret] = @CredentialSecret,
[IsDefault] = @IsDefault,
[ModifiedDateTime] = SYSUTCDATETIME(),
[ModifiedBy] = SUSER_SNAME()
WHERE [SonicCredentialId] = @SonicCredentialId
AND [SonicConnectionId] = @SonicConnectionId
AND [IsActive] = 1;
""";
await using SqlCommand updateCommand =
new(
updateSql,
connection,
transaction);
updateCommand.Parameters.Add(
new SqlParameter(
"@SonicCredentialId",
SqlDbType.Int)
{
Value = sonicCredentialId
});
updateCommand.Parameters.Add(
new SqlParameter(
"@SonicConnectionId",
SqlDbType.Int)
{
Value = sonicConnectionId
});
updateCommand.Parameters.Add(
new SqlParameter(
"@CredentialName",
SqlDbType.NVarChar,
150)
{
Value = normalizedCredentialName
});
updateCommand.Parameters.Add(
new SqlParameter(
"@CredentialUserName",
SqlDbType.NVarChar,
256)
{
Value = normalizedUserName
});
updateCommand.Parameters.Add(
new SqlParameter(
"@CredentialSecret",
SqlDbType.NVarChar,
512)
{
Value = secret
});
updateCommand.Parameters.Add(
new SqlParameter(
"@IsDefault",
SqlDbType.Bit)
{
Value = isDefault
});
int affected =
await updateCommand.ExecuteNonQueryAsync(
cancellationToken);
if (affected == 0)
{
throw new InvalidOperationException(
$"Benutzerprofil Id {sonicCredentialId} "
+ "wurde nicht gefunden oder ist inaktiv.");
}
await transaction.CommitAsync(cancellationToken);
}
catch
{
try
{
await transaction.RollbackAsync(
CancellationToken.None);
}
catch
{
// Die ursprüngliche Exception soll erhalten bleiben.
}
throw;
}
}
public async Task<IReadOnlyList<SonicCredentialProfile>>
GetCredentialsAsync(
int sonicConnectionId,
@@ -663,6 +885,7 @@ public sealed class SonicSetupRepository
container.[CompanyId],
container.[ContainerName],
container.[ContainerDisplayName],
container.[CertificateCheckUrl],
container.[RestartTimeoutSeconds],
company.[CompanyCode],
connection.[ConnectionName]
@@ -729,6 +952,9 @@ public sealed class SonicSetupRepository
int displayOrdinal =
reader.GetOrdinal("ContainerDisplayName");
int checkUrlOrdinal =
reader.GetOrdinal("CertificateCheckUrl");
containers.Add(
new ContainerOption
{
@@ -753,6 +979,11 @@ public sealed class SonicSetupRepository
? null
: reader.GetString(displayOrdinal),
CertificateCheckUrl =
reader.IsDBNull(checkUrlOrdinal)
? null
: reader.GetString(checkUrlOrdinal),
RestartTimeoutSeconds =
reader.GetInt32(
reader.GetOrdinal("RestartTimeoutSeconds")),
@@ -1057,6 +1288,7 @@ public sealed class SonicSetupRepository
string containerName,
string? containerDisplayName,
int restartTimeoutSeconds,
string? certificateCheckUrl = null,
CancellationToken cancellationToken = default)
{
if (companyId <= 0)
@@ -1093,6 +1325,9 @@ public sealed class SonicSetupRepository
"Timeout muss zwischen 10 und 3600 Sekunden liegen.");
}
string? checkUrl =
TlsEndpointProbeService.NormalizeCheckUrl(certificateCheckUrl);
const string sql = """
INSERT INTO [dbo].[SonicContainer]
(
@@ -1100,6 +1335,7 @@ public sealed class SonicSetupRepository
[SonicConnectionId],
[ContainerName],
[ContainerDisplayName],
[CertificateCheckUrl],
[RestartTimeoutSeconds],
[IsActive],
[CreationDateTime],
@@ -1112,6 +1348,7 @@ public sealed class SonicSetupRepository
@SonicConnectionId,
@ContainerName,
@ContainerDisplayName,
@CertificateCheckUrl,
@RestartTimeoutSeconds,
1,
SYSUTCDATETIME(),
@@ -1159,6 +1396,17 @@ public sealed class SonicSetupRepository
: display
});
command.Parameters.Add(
new SqlParameter(
"@CertificateCheckUrl",
SqlDbType.NVarChar,
500)
{
Value = checkUrl is null
? DBNull.Value
: checkUrl
});
command.Parameters.Add(
new SqlParameter(
"@RestartTimeoutSeconds",
@@ -1457,6 +1705,7 @@ public sealed class SonicSetupRepository
string containerName,
string? containerDisplayName,
int restartTimeoutSeconds,
string? certificateCheckUrl = null,
CancellationToken cancellationToken = default)
{
if (sonicContainerId <= 0)
@@ -1499,6 +1748,9 @@ public sealed class SonicSetupRepository
"Timeout muss zwischen 10 und 3600 Sekunden liegen.");
}
string? checkUrl =
TlsEndpointProbeService.NormalizeCheckUrl(certificateCheckUrl);
const string sql = """
UPDATE [dbo].[SonicContainer]
SET
@@ -1506,6 +1758,7 @@ public sealed class SonicSetupRepository
[SonicConnectionId] = @SonicConnectionId,
[ContainerName] = @ContainerName,
[ContainerDisplayName] = @ContainerDisplayName,
[CertificateCheckUrl] = @CertificateCheckUrl,
[RestartTimeoutSeconds] = @RestartTimeoutSeconds,
[ModifiedDateTime] = SYSUTCDATETIME(),
[ModifiedBy] = SUSER_SNAME()
@@ -1559,6 +1812,17 @@ public sealed class SonicSetupRepository
: display
});
command.Parameters.Add(
new SqlParameter(
"@CertificateCheckUrl",
SqlDbType.NVarChar,
500)
{
Value = checkUrl is null
? DBNull.Value
: checkUrl
});
command.Parameters.Add(
new SqlParameter(
"@RestartTimeoutSeconds",
@@ -0,0 +1,235 @@
using System.Net.Security;
using System.Net.Sockets;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using System.Text.RegularExpressions;
using ZA.CoreService.ESBCertificateManager.Models;
namespace ZA.CoreService.ESBCertificateManager.Services;
/// <summary>
/// Liest das präsentierte TLS-Zertifikat eines Endpoints
/// (entspricht grob <c>curl -v https://host:port</c>).
/// </summary>
public sealed class TlsEndpointProbeService
{
private static readonly Regex HostPortRegex = new(
@"^(?<host>[^:/]+)(:(?<port>\d{1,5}))?$",
RegexOptions.Compiled | RegexOptions.CultureInvariant);
public CertificateProbeResult Probe(
string? checkUrl,
int timeoutMilliseconds = 8000)
{
if (!TryParseEndpoint(
checkUrl,
out string host,
out int port,
out string serverName,
out string? parseError))
{
return CertificateProbeResult.Failed(
parseError ?? "Ungültige Prüf-URL.");
}
try
{
using CancellationTokenSource cts = new(timeoutMilliseconds);
using TcpClient client = new();
using (cts.Token.Register(
() =>
{
try
{
client.Close();
}
catch
{
// Timeout beendet die Verbindung.
}
}))
{
client.Connect(host, port);
}
using SslStream ssl = new(
client.GetStream(),
leaveInnerStreamOpen: false,
userCertificateValidationCallback:
static (_, _, _, _) => true);
ssl.AuthenticateAsClient(serverName);
if (ssl.RemoteCertificate is null)
{
return CertificateProbeResult.Failed(
$"Kein Zertifikat von {host}:{port} erhalten.");
}
using X509Certificate2 certificate = new(ssl.RemoteCertificate);
CertificateInfo info = ToCertificateInfo(certificate);
return CertificateProbeResult.Found(
$"{host}:{port}",
info);
}
catch (Exception ex)
{
return CertificateProbeResult.Failed(
$"TLS-Prüfung {host}:{port} fehlgeschlagen: {ex.Message}");
}
}
public static bool TryParseEndpoint(
string? value,
out string host,
out int port,
out string serverName,
out string? error)
{
host = string.Empty;
port = 443;
serverName = string.Empty;
error = null;
if (string.IsNullOrWhiteSpace(value))
{
error = "Keine Prüf-URL hinterlegt.";
return false;
}
string trimmed = value.Trim();
if (Uri.TryCreate(trimmed, UriKind.Absolute, out Uri? uri)
&& (uri.Scheme.Equals("https", StringComparison.OrdinalIgnoreCase)
|| uri.Scheme.Equals("http", StringComparison.OrdinalIgnoreCase)
|| uri.Scheme.Equals("ssl", StringComparison.OrdinalIgnoreCase)
|| uri.Scheme.Equals("tcp", StringComparison.OrdinalIgnoreCase)))
{
if (string.IsNullOrWhiteSpace(uri.Host))
{
error = "Host in der Prüf-URL fehlt.";
return false;
}
host = uri.Host;
serverName = uri.Host;
port = uri.IsDefaultPort
? (uri.Scheme.Equals("http", StringComparison.OrdinalIgnoreCase)
? 80
: 443)
: uri.Port;
if (port is < 1 or > 65535)
{
error = "Port muss zwischen 1 und 65535 liegen.";
return false;
}
return true;
}
Match match = HostPortRegex.Match(trimmed);
if (!match.Success)
{
error =
"Prüf-URL ungültig. Beispiele:\n"
+ "https://server:8443\n"
+ "server:8443\n"
+ "server";
return false;
}
host = match.Groups["host"].Value;
serverName = host;
if (match.Groups["port"].Success)
{
if (!int.TryParse(match.Groups["port"].Value, out port)
|| port is < 1 or > 65535)
{
error = "Port muss zwischen 1 und 65535 liegen.";
return false;
}
}
return !string.IsNullOrWhiteSpace(host);
}
public static string? NormalizeCheckUrl(string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
return null;
}
string trimmed = value.Trim();
if (!TryParseEndpoint(
trimmed,
out _,
out _,
out _,
out string? error))
{
throw new ArgumentException(
error ?? "Ungültige Prüf-URL.",
nameof(value));
}
if (trimmed.Length > 500)
{
throw new ArgumentException(
"Die Prüf-URL darf maximal 500 Zeichen enthalten.",
nameof(value));
}
return trimmed;
}
private static CertificateInfo ToCertificateInfo(
X509Certificate2 certificate)
{
string subject = certificate.GetNameInfo(
X509NameType.SimpleName,
forIssuer: false);
string issuer = certificate.GetNameInfo(
X509NameType.SimpleName,
forIssuer: true);
if (string.IsNullOrWhiteSpace(subject))
{
subject = certificate.Subject;
}
if (string.IsNullOrWhiteSpace(issuer))
{
issuer = certificate.Issuer;
}
string fingerprint = certificate.GetCertHashString(
HashAlgorithmName.SHA256);
fingerprint = string.Join(
":",
Enumerable.Range(0, fingerprint.Length / 2)
.Select(index => fingerprint.Substring(index * 2, 2)));
DateTimeOffset validFrom = new(certificate.NotBefore);
DateTimeOffset validUntil = new(certificate.NotAfter);
DateTimeOffset now = DateTimeOffset.Now;
return new CertificateInfo
{
Subject = subject,
Issuer = issuer,
FingerprintSha256 = fingerprint,
ValidFrom = validFrom,
ValidUntil = validUntil,
IsCurrentlyValid = now >= validFrom && now <= validUntil
};
}
}
@@ -1,4 +1,5 @@
using ZA.CoreService.ESBCertificateManager.Models;
using ZA.CoreService.ESBCertificateManager.Services;
namespace ZA.CoreService.ESBCertificateManager.Setup;
@@ -11,8 +12,10 @@ public sealed class AddContainerForm : Form
private readonly ComboBox _cmbSystem;
private readonly TextBox _txtName;
private readonly TextBox _txtDisplay;
private readonly TextBox _txtCheckUrl;
private readonly TextBox _txtTimeout;
private readonly Button _btnSave;
private readonly Button _btnProbeUrl;
private bool _running;
public int? CreatedSonicContainerId { get; private set; }
@@ -36,7 +39,7 @@ public sealed class AddContainerForm : Form
MaximizeBox = false;
MinimizeBox = false;
ShowInTaskbar = false;
ClientSize = new Size(540, 470);
ClientSize = new Size(540, 560);
BackColor = SettingsUi.Background;
ForeColor = SettingsUi.Text;
Font = new Font("Segoe UI", 10f);
@@ -55,7 +58,7 @@ public sealed class AddContainerForm : Form
Label subtitle = new()
{
Text =
"Container einer Company und einem Sonic-System zuordnen.",
"Container zuordnen und optional TLS-Prüf-URL hinterlegen.",
Location = new Point(30, 58),
AutoSize = true,
ForeColor = SettingsUi.Muted
@@ -63,13 +66,14 @@ public sealed class AddContainerForm : Form
Panel card = SettingsUi.CreateCard();
card.Location = new Point(28, 95);
card.Size = new Size(484, 290);
card.Size = new Size(484, 370);
card.Paint += SettingsUi.PaintCardBorder;
_cmbCompany = SettingsUi.CreateComboBox();
_cmbSystem = SettingsUi.CreateComboBox();
_txtName = SettingsUi.CreateTextBox();
_txtDisplay = SettingsUi.CreateTextBox();
_txtCheckUrl = SettingsUi.CreateTextBox();
_txtTimeout = SettingsUi.CreateTextBox();
_txtTimeout.Text = "180";
@@ -77,6 +81,7 @@ public sealed class AddContainerForm : Form
{
_txtName.Text = _existing.ContainerName;
_txtDisplay.Text = _existing.ContainerDisplayName ?? string.Empty;
_txtCheckUrl.Text = _existing.CertificateCheckUrl ?? string.Empty;
_txtTimeout.Text = _existing.RestartTimeoutSeconds.ToString();
}
@@ -85,15 +90,39 @@ public sealed class AddContainerForm : Form
AddField(card, "Container-Name", _txtName, 18, 138, 440);
AddField(card, "Anzeigename (optional)", _txtDisplay, 18, 198, 280);
AddField(card, "Timeout (s)", _txtTimeout, 318, 198, 140);
AddField(
card,
"Zertifikat prüfen (URL / Host:Port)",
_txtCheckUrl,
18,
258,
440);
Label urlHint = new()
{
Text = "z. B. https://server:8443 oder server:8443",
Location = new Point(18, 312),
AutoSize = true,
ForeColor = SettingsUi.Muted,
Font = new Font("Segoe UI", 8.5f)
};
_btnProbeUrl = SettingsUi.CreateGhostButton("URL prüfen");
_btnProbeUrl.Location = new Point(318, 318);
_btnProbeUrl.Size = new Size(140, 30);
_btnProbeUrl.Click += (_, _) => ProbeUrl();
card.Controls.Add(urlHint);
card.Controls.Add(_btnProbeUrl);
_btnSave = SettingsUi.CreatePrimaryButton(
IsEditMode ? "Änderungen speichern" : "Container anlegen");
_btnSave.Location = new Point(292, 405);
_btnSave.Location = new Point(292, 490);
_btnSave.Size = new Size(220, 40);
_btnSave.Click += async (_, _) => await SaveAsync();
Button cancel = SettingsUi.CreateGhostButton("Abbrechen");
cancel.Location = new Point(170, 405);
cancel.Location = new Point(170, 490);
cancel.Size = new Size(110, 40);
cancel.Click += (_, _) =>
{
@@ -110,6 +139,53 @@ public sealed class AddContainerForm : Form
Shown += async (_, _) => await LoadLookupsAsync();
}
private void ProbeUrl()
{
string url = _txtCheckUrl.Text.Trim();
if (string.IsNullOrWhiteSpace(url))
{
MessageBox.Show(
this,
"Bitte zuerst eine Prüf-URL eintragen.",
"URL prüfen",
MessageBoxButtons.OK,
MessageBoxIcon.Information);
return;
}
TlsEndpointProbeService probe = new();
CertificateProbeResult result = probe.Probe(url);
if (result.Success && result.Certificate is not null)
{
string expiry = result.Certificate.ValidUntil
.ToLocalTime()
.ToString("dd.MM.yyyy HH:mm");
MessageBox.Show(
this,
$"TLS-Zertifikat erreichbar.\n\n"
+ $"Subject: {result.Certificate.Subject}\n"
+ $"Aussteller: {result.Certificate.Issuer}\n"
+ $"Gültig bis: {expiry}\n"
+ $"Status: {(result.Certificate.IsCurrentlyValid ? "gültig" : "abgelaufen")}",
"URL prüfen",
MessageBoxButtons.OK,
result.Certificate.IsCurrentlyValid
? MessageBoxIcon.Information
: MessageBoxIcon.Warning);
return;
}
MessageBox.Show(
this,
result.Message,
"URL prüfen",
MessageBoxButtons.OK,
MessageBoxIcon.Warning);
}
private async Task LoadLookupsAsync()
{
try
@@ -211,6 +287,29 @@ public sealed class AddContainerForm : Form
return;
}
string? checkUrl = _txtCheckUrl.Text.Trim();
if (string.IsNullOrWhiteSpace(checkUrl))
{
checkUrl = null;
}
else if (!TlsEndpointProbeService.TryParseEndpoint(
checkUrl,
out _,
out _,
out _,
out string? parseError))
{
MessageBox.Show(
this,
parseError ?? "Ungültige Prüf-URL.",
"Container",
MessageBoxButtons.OK,
MessageBoxIcon.Warning);
_txtCheckUrl.Focus();
return;
}
_running = true;
_btnSave.Enabled = false;
Cursor = Cursors.WaitCursor;
@@ -225,7 +324,8 @@ public sealed class AddContainerForm : Form
system.SonicConnectionId,
_txtName.Text,
_txtDisplay.Text,
timeout);
timeout,
checkUrl);
CreatedSonicContainerId = _existing.SonicContainerId;
}
@@ -237,7 +337,8 @@ public sealed class AddContainerForm : Form
system.SonicConnectionId,
_txtName.Text,
_txtDisplay.Text,
timeout);
timeout,
checkUrl);
}
DialogResult = DialogResult.OK;
@@ -8,6 +8,7 @@ public sealed class AddCredentialProfileForm : Form
{
private readonly SetupCoordinator _coordinator;
private readonly SonicSystemOption _system;
private readonly SonicCredentialProfile? _existing;
private readonly TextBox _txtProfileName;
private readonly TextBox _txtUserName;
@@ -16,6 +17,7 @@ public sealed class AddCredentialProfileForm : Form
private readonly CheckBox _chkDefault;
private readonly Button _btnSave;
private readonly Button _btnCancel;
private readonly Label _lblPasswordHint;
private bool _operationRunning;
@@ -25,9 +27,12 @@ public sealed class AddCredentialProfileForm : Form
private set;
}
public bool IsEditMode => _existing is not null;
public AddCredentialProfileForm(
SetupCoordinator coordinator,
SonicSystemOption system)
SonicSystemOption system,
SonicCredentialProfile? existing = null)
{
_coordinator =
coordinator
@@ -39,13 +44,17 @@ public sealed class AddCredentialProfileForm : Form
?? throw new ArgumentNullException(
nameof(system));
Text = "Benutzerprofil hinzufügen";
_existing = existing;
Text = IsEditMode
? "Benutzerprofil bearbeiten"
: "Benutzerprofil hinzufügen";
StartPosition =
FormStartPosition.CenterParent;
ClientSize =
new Size(560, 525);
new Size(560, 545);
FormBorderStyle =
FormBorderStyle.FixedDialog;
@@ -65,8 +74,9 @@ public sealed class AddCredentialProfileForm : Form
Label title = new()
{
Text =
$"Profil für {_system.ConnectionName}",
Text = IsEditMode
? $"Profil bearbeiten · {_system.ConnectionName}"
: $"Profil für {_system.ConnectionName}",
Location =
new Point(28, 22),
@@ -112,7 +122,7 @@ public sealed class AddCredentialProfileForm : Form
CreateTextBox(185);
_txtProfileName.Text =
"Administrator";
_existing?.CredentialName ?? "Administrator";
Label userLabel =
CreateLabel(
@@ -123,7 +133,7 @@ public sealed class AddCredentialProfileForm : Form
CreateTextBox(250);
_txtUserName.Text =
"Administrator";
_existing?.UserName ?? "Administrator";
Label passwordLabel =
CreateLabel(
@@ -136,13 +146,25 @@ public sealed class AddCredentialProfileForm : Form
_txtPassword.UseSystemPasswordChar =
true;
_lblPasswordHint = new Label
{
Text = IsEditMode
? "Leer lassen = bisheriges Kennwort behalten"
: string.Empty,
Location = new Point(30, 343),
AutoSize = true,
ForeColor = Color.FromArgb(169, 184, 200),
Font = new Font("Segoe UI", 8.5f)
};
Label repeatLabel =
CreateLabel(
"Kennwort wiederholen",
355);
365);
_txtPasswordRepeat =
CreateTextBox(380);
CreateTextBox(390);
_txtPasswordRepeat.UseSystemPasswordChar =
true;
@@ -153,10 +175,10 @@ public sealed class AddCredentialProfileForm : Form
"Als Standardprofil verwenden",
Location =
new Point(30, 425),
new Point(30, 435),
AutoSize = true,
Checked = true,
Checked = _existing?.IsDefault ?? true,
ForeColor =
Color.FromArgb(241, 245, 249)
@@ -167,7 +189,7 @@ public sealed class AddCredentialProfileForm : Form
Text = "Abbrechen",
Location =
new Point(125, 465),
new Point(125, 480),
Size =
new Size(110, 38),
@@ -189,11 +211,12 @@ public sealed class AddCredentialProfileForm : Form
_btnSave = new Button
{
Text =
"Verbindung prüfen und hinzufügen",
Text = IsEditMode
? "Prüfen und speichern"
: "Verbindung prüfen und hinzufügen",
Location =
new Point(245, 465),
new Point(245, 480),
Size =
new Size(280, 38),
@@ -228,6 +251,7 @@ public sealed class AddCredentialProfileForm : Form
Controls.Add(_txtUserName);
Controls.Add(passwordLabel);
Controls.Add(_txtPassword);
Controls.Add(_lblPasswordHint);
Controls.Add(repeatLabel);
Controls.Add(_txtPasswordRepeat);
Controls.Add(_chkDefault);
@@ -328,7 +352,7 @@ public sealed class AddCredentialProfileForm : Form
return;
}
if (string.IsNullOrEmpty(password))
if (!IsEditMode && string.IsNullOrEmpty(password))
{
ShowWarning("Kennwort fehlt.");
@@ -344,7 +368,8 @@ public sealed class AddCredentialProfileForm : Form
return;
}
if (!string.Equals(
if (!string.IsNullOrEmpty(password)
&& !string.Equals(
password,
passwordRepeat,
StringComparison.Ordinal))
@@ -372,13 +397,27 @@ public sealed class AddCredentialProfileForm : Form
try
{
CreatedProfile =
await _coordinator.AddCredentialAsync(
_system,
profileName,
userName,
password,
_chkDefault.Checked);
if (IsEditMode && _existing is not null)
{
CreatedProfile =
await _coordinator.UpdateCredentialAsync(
_system,
_existing,
profileName,
userName,
password,
_chkDefault.Checked);
}
else
{
CreatedProfile =
await _coordinator.TestAndAddCredentialAsync(
_system,
profileName,
userName,
password,
_chkDefault.Checked);
}
DialogResult = DialogResult.OK;
Close();
@@ -391,7 +430,11 @@ public sealed class AddCredentialProfileForm : Form
{
MessageBox.Show(
this,
"Profil konnte nicht angelegt werden.",
string.IsNullOrWhiteSpace(exception.Message)
? (IsEditMode
? "Profil konnte nicht aktualisiert werden."
: "Profil konnte nicht angelegt werden.")
: exception.Message,
"Benutzerprofil",
MessageBoxButtons.OK,
MessageBoxIcon.Error);
@@ -423,7 +466,9 @@ public sealed class AddCredentialProfileForm : Form
_btnSave.Text =
isRunning
? "Verbindung wird geprüft ..."
: "Verbindung prüfen und hinzufügen";
: IsEditMode
? "Prüfen und speichern"
: "Verbindung prüfen und hinzufügen";
}
private void ShowWarning(string message)
@@ -435,4 +480,4 @@ public sealed class AddCredentialProfileForm : Form
MessageBoxButtons.OK,
MessageBoxIcon.Warning);
}
}
}
@@ -207,7 +207,10 @@ public sealed class AddTargetPathForm : Form
if (!_lastProbe.Success)
{
_lblProbeResult.ForeColor = Color.FromArgb(220, 100, 100);
_lblProbeResult.Text = _lastProbe.Message;
_lblProbeResult.Text = _lastProbe.AccessDenied
? CertificateProbeResult.AccessDeniedShortText
+ " Anmeldung am Freigabe-Pfad erforderlich."
: _lastProbe.Message;
return;
}
@@ -160,6 +160,78 @@ public sealed class SetupCoordinator
return createdProfile;
}
public async Task<SonicCredentialProfile> UpdateCredentialAsync(
SonicSystemOption system,
SonicCredentialProfile existing,
string credentialName,
string userName,
string secret,
bool isDefault,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(system);
ArgumentNullException.ThrowIfNull(existing);
if (system.SonicConnectionId <= 0
|| existing.SonicCredentialId <= 0)
{
throw new ArgumentException(
"Das ausgewählte Profil oder System ist ungültig.");
}
string passwordToStore =
string.IsNullOrEmpty(secret)
? existing.Secret
: secret;
CredentialTestResult testResult =
await _credentialTester.TestAsync(
system,
userName,
passwordToStore,
system.ValidationContainerName,
cancellationToken);
if (!testResult.Success)
{
throw new InvalidOperationException(
"Das Benutzerprofil wurde nicht gespeichert."
+ Environment.NewLine
+ Environment.NewLine
+ testResult.Message);
}
await _repository.UpdateCredentialAsync(
existing.SonicCredentialId,
system.SonicConnectionId,
credentialName,
userName,
passwordToStore,
isDefault,
cancellationToken);
IReadOnlyList<SonicCredentialProfile> profiles =
await _repository.GetCredentialsAsync(
system.SonicConnectionId,
cancellationToken);
SonicCredentialProfile? updated =
profiles.FirstOrDefault(
profile =>
profile.SonicCredentialId
== existing.SonicCredentialId);
if (updated is null)
{
throw new InvalidOperationException(
"Das Benutzerprofil wurde gespeichert, "
+ "konnte anschließend aber nicht geladen werden.");
}
return updated;
}
public async Task<SetupCheckResult> CheckAsync(
CancellationToken cancellationToken = default)
{
@@ -396,6 +468,7 @@ public sealed class SetupCoordinator
string containerName,
string? containerDisplayName,
int restartTimeoutSeconds,
string? certificateCheckUrl = null,
CancellationToken cancellationToken = default)
{
int id = await _setupRepository.AddSonicContainerAsync(
@@ -404,6 +477,7 @@ public sealed class SetupCoordinator
containerName,
containerDisplayName,
restartTimeoutSeconds,
certificateCheckUrl,
cancellationToken);
await EnsureExistsAsync(
@@ -520,6 +594,7 @@ public sealed class SetupCoordinator
string containerName,
string? containerDisplayName,
int restartTimeoutSeconds,
string? certificateCheckUrl = null,
CancellationToken cancellationToken = default)
{
await _setupRepository.UpdateSonicContainerAsync(
@@ -529,6 +604,7 @@ public sealed class SetupCoordinator
containerName,
containerDisplayName,
restartTimeoutSeconds,
certificateCheckUrl,
cancellationToken);
await EnsureExistsAsync(
@@ -36,6 +36,7 @@ public sealed class SetupWizardForm : Form
private Button _btnSaveSelection = null!;
private Button _btnFinish = null!;
private Button _btnAddCredential = null!;
private Button _btnEditCredential = null!;
private IReadOnlyList<SonicSystemOption> _systems = [];
private IReadOnlyList<SonicCredentialProfile> _profiles = [];
@@ -385,11 +386,12 @@ public sealed class SetupWizardForm : Form
_lvContainers = SettingsUi.CreateListView();
_lvContainers.Dock = DockStyle.Fill;
_lvContainers.Columns.Add("Company", 80);
_lvContainers.Columns.Add("Container", 180);
_lvContainers.Columns.Add("Anzeige", 160);
_lvContainers.Columns.Add("System", 160);
_lvContainers.Columns.Add("Timeout", 90);
_lvContainers.Columns.Add("Company", 70);
_lvContainers.Columns.Add("Container", 150);
_lvContainers.Columns.Add("Anzeige", 120);
_lvContainers.Columns.Add("System", 120);
_lvContainers.Columns.Add("Prüf-URL", 180);
_lvContainers.Columns.Add("Timeout", 80);
_lvContainers.DoubleClick += async (_, _) => await EditSelectedContainerAsync();
FinishPanelLayout(_panelContainers, _lvContainers, toolbar);
@@ -484,6 +486,12 @@ public sealed class SetupWizardForm : Form
_btnAddCredential.Click += async (_, _) =>
await AddCredentialAsync();
_btnEditCredential = SettingsUi.CreateGhostButton("Profil bearbeiten");
_btnEditCredential.Location = new Point(550, 122);
_btnEditCredential.Size = new Size(160, 34);
_btnEditCredential.Click += async (_, _) =>
await EditCredentialAsync();
_lblSystemDetails = new Label
{
Location = new Point(24, 180),
@@ -502,6 +510,7 @@ public sealed class SetupWizardForm : Form
card.Controls.Add(credentialLabel);
card.Controls.Add(_cmbCredential);
card.Controls.Add(_btnAddCredential);
card.Controls.Add(_btnEditCredential);
card.Controls.Add(_lblSystemDetails);
card.Controls.Add(_btnSaveSelection);
@@ -625,6 +634,10 @@ public sealed class SetupWizardForm : Form
item.SubItems.Add(container.ContainerName);
item.SubItems.Add(container.ContainerDisplayName ?? "—");
item.SubItems.Add(container.ConnectionName);
item.SubItems.Add(
string.IsNullOrWhiteSpace(container.CertificateCheckUrl)
? "—"
: container.CertificateCheckUrl);
item.SubItems.Add(container.RestartTimeoutSeconds + " s");
item.Tag = container;
_lvContainers.Items.Add(item);
@@ -722,7 +735,12 @@ public sealed class SetupWizardForm : Form
return "Kennwort nötig";
}
return result.Success ? "fehlt" : "n/a";
if (result.AccessDenied)
{
return CertificateProbeResult.AccessDeniedShortText;
}
return result.Success ? "fehlt" : "nicht erreichbar";
}
private static string? PromptCertificatePassword(IWin32Window owner)
@@ -1228,19 +1246,9 @@ public sealed class SetupWizardForm : Form
}
await SystemChangedAsync();
SelectCredential(dialog.CreatedProfile.SonicCredentialId);
for (int index = 0; index < _cmbCredential.Items.Count; index++)
{
if (_cmbCredential.Items[index] is SonicCredentialProfile profile
&& profile.SonicCredentialId
== dialog.CreatedProfile.SonicCredentialId)
{
_cmbCredential.SelectedIndex = index;
break;
}
}
MessageBox.Show(
MessageBox.Show(
this,
"Profil geladen.",
"Benutzerprofil",
@@ -1248,6 +1256,53 @@ public sealed class SetupWizardForm : Form
MessageBoxIcon.Information);
}
private async Task EditCredentialAsync()
{
if (_cmbSystem.SelectedItem is not SonicSystemOption system)
{
ShowWarning("Bitte System wählen.");
return;
}
if (_cmbCredential.SelectedItem is not SonicCredentialProfile profile)
{
ShowWarning("Bitte Profil wählen.");
return;
}
using AddCredentialProfileForm dialog =
new(_coordinator, system, profile);
if (dialog.ShowDialog(this) != DialogResult.OK
|| dialog.CreatedProfile is null)
{
return;
}
await SystemChangedAsync();
SelectCredential(dialog.CreatedProfile.SonicCredentialId);
MessageBox.Show(
this,
"Profil aktualisiert.",
"Benutzerprofil",
MessageBoxButtons.OK,
MessageBoxIcon.Information);
}
private void SelectCredential(int sonicCredentialId)
{
for (int index = 0; index < _cmbCredential.Items.Count; index++)
{
if (_cmbCredential.Items[index] is SonicCredentialProfile profile
&& profile.SonicCredentialId == sonicCredentialId)
{
_cmbCredential.SelectedIndex = index;
break;
}
}
}
private async Task SystemChangedAsync()
{
if (_cmbSystem.SelectedItem is not SonicSystemOption system)
@@ -1275,9 +1330,12 @@ public sealed class SetupWizardForm : Form
Environment.NewLine
+ "Noch kein Benutzerprofil — bitte hinzufügen.";
_btnSaveSelection.Enabled = false;
_btnEditCredential.Enabled = false;
return;
}
_btnEditCredential.Enabled = true;
_btnSaveSelection.Enabled = true;
LocalSetupSelection? selection = _coordinator.LoadSelection();