From 2b5b97c869691c05d884aa5df9490a38f11d7394 Mon Sep 17 00:00:00 2001 From: Johannes Rest Date: Mon, 27 Jul 2026 14:46:02 +0200 Subject: [PATCH] Initial commit: BizTalk application catalog --- .editorconfig | 12 + .gitea/workflows/build.yml | 26 + .gitignore | 10 + BizTalkApplicationCatalog.sln | 24 + Dokumentation.md | 220 +++++ Readme.md | 210 +++++ deployment/run-inventory.cmd | 51 ++ scripts/build-release.cmd | 43 + scripts/package-release.cmd | 24 + scripts/package-source.cmd | 31 + src/BizTalkApplicationCatalog/App.config | 10 + .../BizTalkApplicationCatalog.csproj | 64 ++ .../Collectors/BizTalkWmiCollector.cs | 421 ++++++++++ .../Collectors/SystemCollector.cs | 223 +++++ .../Configuration/CommandLineOptions.cs | 115 +++ .../Infrastructure/ConsoleFileLogger.cs | 41 + .../Infrastructure/SafeCollector.cs | 61 ++ .../Infrastructure/SelfTestRunner.cs | 190 +++++ .../Infrastructure/SensitiveDataSanitizer.cs | 39 + .../Models/InventoryModels.cs | 138 +++ src/BizTalkApplicationCatalog/Program.cs | 194 +++++ .../Properties/AssemblyInfo.cs | 14 + .../Reporting/XlsxReportWriter.cs | 793 ++++++++++++++++++ .../BizTalkApplicationCatalog.Tests.csproj | 49 ++ .../Program.cs | 23 + 25 files changed, 3026 insertions(+) create mode 100644 .editorconfig create mode 100644 .gitea/workflows/build.yml create mode 100644 .gitignore create mode 100644 BizTalkApplicationCatalog.sln create mode 100644 Dokumentation.md create mode 100644 Readme.md create mode 100644 deployment/run-inventory.cmd create mode 100644 scripts/build-release.cmd create mode 100644 scripts/package-release.cmd create mode 100644 scripts/package-source.cmd create mode 100644 src/BizTalkApplicationCatalog/App.config create mode 100644 src/BizTalkApplicationCatalog/BizTalkApplicationCatalog.csproj create mode 100644 src/BizTalkApplicationCatalog/Collectors/BizTalkWmiCollector.cs create mode 100644 src/BizTalkApplicationCatalog/Collectors/SystemCollector.cs create mode 100644 src/BizTalkApplicationCatalog/Configuration/CommandLineOptions.cs create mode 100644 src/BizTalkApplicationCatalog/Infrastructure/ConsoleFileLogger.cs create mode 100644 src/BizTalkApplicationCatalog/Infrastructure/SafeCollector.cs create mode 100644 src/BizTalkApplicationCatalog/Infrastructure/SelfTestRunner.cs create mode 100644 src/BizTalkApplicationCatalog/Infrastructure/SensitiveDataSanitizer.cs create mode 100644 src/BizTalkApplicationCatalog/Models/InventoryModels.cs create mode 100644 src/BizTalkApplicationCatalog/Program.cs create mode 100644 src/BizTalkApplicationCatalog/Properties/AssemblyInfo.cs create mode 100644 src/BizTalkApplicationCatalog/Reporting/XlsxReportWriter.cs create mode 100644 tests/BizTalkApplicationCatalog.Tests/BizTalkApplicationCatalog.Tests.csproj create mode 100644 tests/BizTalkApplicationCatalog.Tests/Program.cs diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..5701936 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,12 @@ +root = true + +[*] +charset = utf-8 +end_of_line = crlf +insert_final_newline = true +indent_style = space +indent_size = 4 + +[*.md] +end_of_line = lf +trim_trailing_whitespace = false diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml new file mode 100644 index 0000000..7978208 --- /dev/null +++ b/.gitea/workflows/build.yml @@ -0,0 +1,26 @@ +name: Build und Test + +on: + push: + pull_request: + +jobs: + build: + runs-on: windows + steps: + - name: Repository auschecken + uses: actions/checkout@v4 + + - name: Release bauen und Tests ausführen + shell: cmd + run: scripts\build-release.cmd + + - name: Deployment-Ordner erzeugen + shell: cmd + run: scripts\package-release.cmd + + - name: Deployment-Artefakt bereitstellen + uses: actions/upload-artifact@v4 + with: + name: BizTalkApplicationCatalog-deploy + path: artifacts\BizTalkApplicationCatalog-deploy diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f4ec5a9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +bin/ +obj/ +artifacts/ +.vs/ +*.user +*.suo +*.log +*.xlsx +*.zip +*.zip.txt diff --git a/BizTalkApplicationCatalog.sln b/BizTalkApplicationCatalog.sln new file mode 100644 index 0000000..bef624c --- /dev/null +++ b/BizTalkApplicationCatalog.sln @@ -0,0 +1,24 @@ +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 16 +VisualStudioVersion = 16.0.31729.503 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BizTalkApplicationCatalog", "src\BizTalkApplicationCatalog\BizTalkApplicationCatalog.csproj", "{41CB5701-3FBC-49F4-856A-6AE930B8513D}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BizTalkApplicationCatalog.Tests", "tests\BizTalkApplicationCatalog.Tests\BizTalkApplicationCatalog.Tests.csproj", "{6B7CE874-8054-46F5-927C-E9B26927F187}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {41CB5701-3FBC-49F4-856A-6AE930B8513D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {41CB5701-3FBC-49F4-856A-6AE930B8513D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {41CB5701-3FBC-49F4-856A-6AE930B8513D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {41CB5701-3FBC-49F4-856A-6AE930B8513D}.Release|Any CPU.Build.0 = Release|Any CPU + {6B7CE874-8054-46F5-927C-E9B26927F187}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {6B7CE874-8054-46F5-927C-E9B26927F187}.Debug|Any CPU.Build.0 = Debug|Any CPU + {6B7CE874-8054-46F5-927C-E9B26927F187}.Release|Any CPU.ActiveCfg = Release|Any CPU + {6B7CE874-8054-46F5-927C-E9B26927F187}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection +EndGlobal diff --git a/Dokumentation.md b/Dokumentation.md new file mode 100644 index 0000000..00623f5 --- /dev/null +++ b/Dokumentation.md @@ -0,0 +1,220 @@ +# Technische Dokumentation: BEW BizTalk Application Catalog + +## 1. Zielbild + +Das Werkzeug erstellt je Umgebung eine eigenständige Microsoft-Excel-Arbeitsmappe mit der vollständigen Liste der auf dem lokalen BizTalk Server 2020 installierten Anwendungen und den wichtigsten zugeordneten Kernparametern. + +```text +ACC + +-- 1 BizTalk Server 2020 / Windows Server 2019 <-- Tool lokal ausführen + +-- 1 SQL Server für die BizTalk-Datenbanken + +PROD + +-- 1 BizTalk Server 2020 / Windows Server 2019 <-- Tool lokal ausführen + +-- 1 SQL Server für die BizTalk-Datenbanken +``` + +ACC und PROD werden getrennt inventarisiert. Da es pro Umgebung nur einen BizTalk-Knoten gibt, ist kein Node-Abgleich erforderlich. + +## 2. Architektur + +```text +Administrative cmd.exe + | + +-- run-inventory.cmd ACC|PROD + | + +-- BizTalkApplicationCatalog.exe + | + +-- SystemCollector + | +-- Win32_OperatingSystem + | +-- lokale BizTalk Registry + | +-- MSBTS_GroupSetting + | + +-- BizTalkWmiCollector + | +-- vollständige Anwendungsliste + | +-- Artefaktdetails + | +-- Hosts und Handler + | +-- Abdeckungsstatus pro Klasse + | + +-- XlsxReportWriter + +-- Office Open XML / ZIP + +-- Inline Strings, keine Shared-String-Abhängigkeit + +-- Filter, Freeze Panes, Styles + +-- atomarer Dateiaustausch +``` + +Die Anwendung referenziert keine `Microsoft.BizTalk.*`-Assembly. Die einzige BizTalk-Schnittstelle ist der lokal installierte WMI-Provider. Dadurch besteht das Deployment nur aus EXE, Konfiguration, Startskript und Dokumentation. + +## 3. Toolchain und Kompatibilität + +Die Build-Baseline ist absichtlich auf die BizTalk-2020-Umgebung ausgerichtet: + +- Visual Studio 2019 / Solution Version 16 +- MSBuild 16.x, insbesondere 16.11 +- klassisches MSBuild-Projektformat mit `ToolsVersion="15.0"` +- .NET Framework 4.7.2 +- C# 7.3 +- keine SDK-Style-Projekte +- kein `PackageReference` +- keine `global.json` +- keine NuGet-Abhängigkeit + +Damit wird nicht das installierte .NET SDK 8 oder neuer ausgewählt. Der bekannte Fehler „.NET SDK 8.x requires at least MSBuild 17.8.3“ kann bei dieser Solution nicht durch eine SDK-Auswahl entstehen. + +## 4. Datenquellen + +### 4.1 Pflichtquelle + +`MSBTS_Application` liefert die Primärliste aller Anwendungen. Liefert diese Klasse keine Datensätze oder kann sie nicht gelesen werden, gilt der Pflichtabschnitt als fehlgeschlagen und der Prozess endet nach der dennoch versuchten XLSX-Erzeugung mit Exitcode `1`. + +Aufgenommene Parameter: + +- `Name` beziehungsweise `ApplicationName` +- `Description` +- `Status` +- `IsDefault`, soweit vom Provider geliefert + +### 4.2 Optionale Artefaktklassen + +| WMI-Klasse | Berichtstyp | +| --- | --- | +| `MSBTS_Orchestration` | Orchestrierung | +| `MSBTS_SendPort` | Send Port | +| `MSBTS_SendPortGroup` | Send Port Group | +| `MSBTS_ReceivePort` | Receive Port | +| `MSBTS_ReceiveLocation` | Receive Location | +| `MSBTS_Assembly` | Assembly | +| `MSBTS_Schema` | Schema | +| `MSBTS_Map` | Map | +| `MSBTS_Pipeline` | Pipeline | + +Die WMI-Klassen können abhängig von Installation, Providerstand und Berechtigung unterschiedliche Properties anbieten. Deshalb fragt der Collector `SELECT *` ab und liest bekannte Properties defensiv. Eine nicht vorhandene Property wird leer gelassen und beendet die Klasse nicht. + +Receive Ports werden vor Receive Locations gelesen. Fehlt an einer Receive Location die direkte `ApplicationName`, wird die Anwendung über `ReceivePortName` bestmöglich aufgelöst. + +### 4.3 Hosts und Handler + +- `MSBTS_HostSetting` +- `MSBTS_HostInstance` +- `MSBTS_ReceiveHandler` +- `MSBTS_SendHandler2` +- Fallback `MSBTS_SendHandler` + +Erfasst werden Name, Server, Status, Hosttyp, Windows-Gruppe, 32-Bit-Kennzeichen, Trusted-Kennzeichen und Adaptername, soweit vorhanden. + +### 4.4 Systeminformationen + +- `Win32_OperatingSystem` +- `HKLM\SOFTWARE\Microsoft\BizTalk Server\3.0` +- `HKLM\SOFTWARE\Microsoft\BizTalk Server\3.0\Administration` +- `MSBTS_GroupSetting` + +Registry-Werte werden in 64- und 32-Bit-Ansicht gelesen. Kommandozeilenwerte für Management Server und Datenbank überschreiben ermittelte Werte. + +## 5. Abdeckungsmodell + +Jede abgefragte BizTalk-WMI-Klasse erzeugt einen Datensatz im Blatt `Abdeckung`: + +| Status | Bedeutung | +| --- | --- | +| `Vollständig` | Abfrage erfolgreich; die angegebene Zeilenzahl ist technisch belegt | +| `Begrenzt` | Abfrage erfolgreich, aber das konfigurierte Zeilenlimit wurde erreicht | +| `Nicht verfügbar` | Klasse oder Berechtigung nicht verfügbar; eine Null ist nicht fachlich bestätigt | +| `Fehler` | Pflichtquelle lieferte kein verwertbares Ergebnis | + +Dieses Modell verhindert die irreführende Interpretation eines fehlenden WMI-Ergebnisses als „keine Artefakte vorhanden“. + +`MaxRowsPerArtifactType` begrenzt optional die Details pro Artefakttyp. Der Standard ist `10000`. Die Anwendungsliste aus `MSBTS_Application` wird nicht begrenzt. + +## 6. Excel-Erzeugung + +Die `.xlsx`-Datei ist ein OPC-/ZIP-Paket mit folgenden Kernteilen: + +```text +[Content_Types].xml +_rels/.rels +docProps/core.xml +docProps/app.xml +xl/workbook.xml +xl/_rels/workbook.xml.rels +xl/styles.xml +xl/worksheets/sheet1.xml ... sheet10.xml +``` + +Zelltexte werden als `inlineStr` geschrieben. Dadurch wird keine Shared-String-Tabelle im Arbeitsspeicher aufgebaut. Zahlen werden als numerische Zellen gespeichert und können in Excel direkt summiert oder gefiltert werden. + +Der Writer: + +1. erzeugt eine eindeutige temporäre Datei im Zielordner, +2. schreibt und schließt alle XML-/ZIP-Teile, +3. entfernt eine gegebenenfalls vorhandene Zieldatei gleichen Namens, +4. verschiebt die vollständige temporäre Datei atomar auf den Zielnamen, +5. löscht temporäre Reste auch im Fehlerfall. + +Ein einzelner Zelltext wird auf das Excel-Limit von 32.767 Zeichen begrenzt. Nicht XML-konforme Steuerzeichen werden entfernt. + +## 7. Resilienz + +- `SafeCollector` isoliert voneinander unabhängige Abschnitte. +- Jeder Abschnitt schreibt Start, Ende, Dauer und Fehler in Konsole und Log. +- Optionale WMI-Klassen werden einzeln behandelt. +- Timeout und maximale Detailzeilen sind in `App.config` konfigurierbar. +- Die XLSX wird auch bei optionalen Lücken erstellt. +- Die vollständige Anwendungsliste ist ein Pflichtabschnitt. +- Findings erklären Lücken und nennen eine konkrete Prüfung. + +## 8. Datenschutz und Secrets + +Das Inventar soll keine Credentials enthalten. Bekannte Kennwort-, Secret- und Tokenmuster in Text- und Adresswerten werden durch `[REDACTED]` ersetzt. Das ersetzt keine Schutzklassifizierung: interne Adressen, Servernamen, Hostgruppen und Anwendungsbezeichnungen bleiben sensible Betriebsinformationen. + +Es werden keine Kennwörter abgefragt, keine Bindings exportiert und keine aktiven Endpunktverbindungen getestet. + +## 9. Build, Test und Paketierung + +Windows-Build: + +```cmd +scripts\build-release.cmd +``` + +Der Build: + +1. findet MSBuild über `vswhere.exe` oder `PATH`, +2. baut `Release|Any CPU`, +3. führt das Testprogramm aus, +4. führt den Self-Test der eigentlichen EXE aus. + +Der Self-Test benötigt kein BizTalk. Er prüft: + +- Secret-Redaktion +- Erstellung aller zehn Tabellenblätter +- Vorhandensein der erforderlichen OPC-/XLSX-Teile +- XML-Wohlgeformtheit aller Paketbestandteile +- erwartete Blattnamen +- Abwesenheit des Testkennworts + +Deployment: + +```cmd +scripts\package-release.cmd +``` + +Quell-ZIP und certutil-dekodierbarer Text: + +```cmd +scripts\package-source.cmd +``` + +## 10. Validierung in ACC und PROD + +Je Umgebung: + +1. EXE mit `--self-test` starten. +2. Inventar in einer administrativen `cmd.exe` ausführen. +3. Exitcode und Log prüfen. +4. XLSX öffnen und Blatt `Abdeckung` prüfen. +5. Zahl der Anwendungen mit der BizTalk Administration Console vergleichen. +6. Stichproben für Ports, Orchestrierungen und Assemblies durchführen. +7. XLSX und Log im geschützten Umgebungsordner archivieren. + +ACC- und PROD-Dateien dürfen nicht zusammengeführt werden, ohne die Spalten `Umgebung` und `Server` beizubehalten. diff --git a/Readme.md b/Readme.md new file mode 100644 index 0000000..3189884 --- /dev/null +++ b/Readme.md @@ -0,0 +1,210 @@ +# BEW BizTalk Application Catalog + +`BEW BizTalk Application Catalog` inventarisiert die auf einem BizTalk Server 2020 installierten Anwendungen und erzeugt eine kompakte, filterbare Microsoft-Excel-Arbeitsmappe (`.xlsx`). + +Das Tool wird lokal einmal auf dem BizTalk-Server in `ACC` und einmal auf dem BizTalk-Server in `PROD` ausgeführt. Beide Umgebungen bestehen jeweils aus: + +- einem BizTalk Server 2020 auf Windows Server 2019 +- einem separaten SQL Server für die BizTalk-Datenbanken + +Microsoft Excel, Microsoft Office, PowerShell, Internetzugriff und ein .NET SDK werden auf dem Zielserver nicht benötigt. + +## Ergebnis + +Die erzeugte Arbeitsmappe enthält zehn Tabellenblätter: + +| Blatt | Inhalt | +| --- | --- | +| `Übersicht` | Umgebung, Server, BizTalk-/Windows-Metadaten, Gesamtzahlen und Laufstatus | +| `Anwendungen` | vollständige, kompakte Anwendungsliste mit Status, Beschreibung und Artefaktzahlen | +| `Artefakte` | konsolidierte technische Detailansicht | +| `Ports` | Send Ports, Send Port Groups, Receive Ports und Receive Locations | +| `Orchestrierungen` | Orchestrierungen, Status und Hostzuordnung | +| `Schemas-Maps-Pipelines` | Schemas, Maps und Pipelines | +| `Assemblies` | bereitgestellte BizTalk-Assemblies | +| `Hosts-Handler` | Hosts, Hostinstanzen sowie Receive-/Send-Handler | +| `Abdeckung` | Erfolg, Zeilenzahl und mögliche Lücken jeder WMI-Datenquelle | +| `Findings` | Warnungen, Fehler und empfohlene Prüfungen | + +Alle Detailblätter besitzen Filter, fixierte Kopfzeilen und angepasste Spaltenbreiten. + +## Kernparameter je Anwendung + +Das Blatt `Anwendungen` enthält unter anderem: + +- Umgebung und ausführenden BizTalk Server +- Anwendungsname, Beschreibung, Status und Standardanwendungskennzeichen +- Gesamtzahl zugeordneter Artefakte +- Anzahl Orchestrierungen +- Anzahl Send Ports und Send Port Groups +- Anzahl Receive Ports und Receive Locations +- Anzahl Assemblies, Schemas, Maps und Pipelines +- erkannte Hosts beziehungsweise Handler +- verwendete Adapter +- expliziten Status der Detailabdeckung + +Die vollständige Anwendungsliste stammt aus `MSBTS_Application`. Eine nicht verfügbare optionale Artefaktklasse wird nicht stillschweigend als fachliche Null interpretiert, sondern im Blatt `Abdeckung` ausgewiesen. + +## Sicherheits- und Betriebsprinzip + +- ausschließlich lokale, lesende WMI- und Registry-Abfragen +- keine Änderungen an Anwendungen, Ports, Hosts oder Runtime-Instanzen +- keine direkte SQL-Abfrage und keine Datenbankänderung +- keine Office-Automation und keine COM-Interop +- zusätzliche Redigierung versehentlich gelieferter Kennwort-/Tokenwerte +- atomare XLSX-Erzeugung über eine temporäre Datei +- Fehler optionaler WMI-Klassen stoppen die übrige Erfassung nicht +- identische Fortschrittsausgabe auf Konsole und in der Logdatei + +Die XLSX- und Logdatei enthält interne Server-, Anwendungs-, Host- und Endpunktinformationen und muss entsprechend geschützt abgelegt werden. + +## Voraussetzungen auf ACC und PROD + +- lokale Ausführung auf dem jeweiligen BizTalk Server 2020 +- Windows Server 2019 +- administrative `cmd.exe` empfohlen +- Konto mit Leserechten auf `root\MicrosoftBizTalkServer`, regulär ein BizTalk-Administratoren- oder geeignetes Operatorenkonto +- .NET Framework 4.7.2 oder höher +- funktionierender lokaler BizTalk-WMI-Provider + +Microsoft Office ist ausdrücklich keine Voraussetzung. + +## Schnellstart + +1. Deployment-Ordner auf den BizTalk Server kopieren. +2. Administrative `cmd.exe` öffnen. +3. Self-Test ohne BizTalk-Zugriff ausführen. +4. Inventar für die jeweilige Umgebung erzeugen. + +Self-Test: + +```cmd +BizTalkApplicationCatalog.exe --self-test +``` + +ACC: + +```cmd +run-inventory.cmd ACC C:\BizTalk-Doku\ACC +``` + +PROD: + +```cmd +run-inventory.cmd PROD C:\BizTalk-Doku\PROD +``` + +Direkter Aufruf: + +```cmd +BizTalkApplicationCatalog.exe ^ + --environment ACC ^ + --output C:\BizTalk-Doku\ACC +``` + +## Konsolenausgabe + +Das Tool zeigt laufend, was es gerade erfasst und wann die Excel-Datei geschrieben wurde: + +```text +[INFO] Starte Abschnitt: Anwendungen und Artefakte +[INFO] 34 installierte BizTalk-Anwendung(en) gefunden. +[INFO] Send Port: 87 Datensatz/Datensätze. +[INFO] Erzeuge Microsoft-Excel-Datei: C:\BizTalk-Doku\ACC\... +[INFO] Excel-Datei erfolgreich erzeugt: C:\BizTalk-Doku\ACC\... +``` + +## Ausgabedateien + +```text +BizTalk-Anwendungsinventar-ACC-BIZTALKSERVER-20260727-150000.xlsx +BizTalk-Anwendungsinventar-ACC-BIZTALKSERVER-20260727-150000.log +``` + +## Optionen und Exitcodes + +| Option | Bedeutung | +| --- | --- | +| `--environment NAME` | Umgebung, regulär `ACC` oder `PROD` | +| `--output PFAD` | Zielordner für XLSX und Log | +| `--management-server NAME` | optionaler Management-SQL-Server-Hinweis | +| `--management-database NAME` | optionale Management-Datenbank; Fallback `BizTalkMgmtDb` | +| `--self-test` | prüft XLSX-Struktur, Tabellen und Secret-Redaktion | +| `--help` | zeigt die Hilfe | + +| Exitcode | Bedeutung | +| ---: | --- | +| `0` | vollständige Anwendungserfassung und XLSX erfolgreich | +| `1` | XLSX erzeugt, aber ein Pflichtabschnitt war unvollständig | +| `2` | Aufruf-, Ausgabe- oder Berichtserzeugungsfehler | + +## Build mit Visual Studio 2019 + +Die Solution vermeidet bewusst den beim früheren IIS Inventory aufgetretenen .NET-SDK-/MSBuild-Konflikt: + +| Komponente | Vorgabe | +| --- | --- | +| Ziel | .NET Framework 4.7.2 | +| Sprache | C# 7.3 | +| Solution | Visual Studio 2019, Formatversion 16 | +| Projektformat | klassisches MSBuild mit `ToolsVersion="15.0"` | +| MSBuild | 16.x, einschließlich 16.11 | +| NuGet | keine Pakete und kein Restore | +| .NET SDK / `global.json` | nicht erforderlich | + +Voraussetzung auf dem Buildrechner ist das `.NET Framework 4.7.2 Developer/Targeting Pack`. + +```cmd +scripts\build-release.cmd +scripts\package-release.cmd +``` + +Das Deployment liegt anschließend unter: + +```text +artifacts\BizTalkApplicationCatalog-deploy\ +``` + +## Quellpaket und Base64-Text + +Für eine dateibasierte Übergabe: + +```cmd +scripts\package-source.cmd +``` + +Erzeugt werden: + +```text +artifacts\BizTalkApplicationCatalog-source.zip +artifacts\BizTalkApplicationCatalog-source.zip.txt +``` + +Dekodieren unter Windows: + +```cmd +certutil -decode BizTalkApplicationCatalog-source.zip.txt BizTalkApplicationCatalog-source.zip +``` + +## Gitea + +Das Repository enthält `.gitea/workflows/build.yml`. Ein Windows-Runner mit Visual Studio 2019 Build Tools und .NET Framework 4.7.2 Developer Pack baut die Solution, führt Tests und Self-Test aus und stellt den Deployment-Ordner als Artefakt bereit. + +## Troubleshooting + +### Solution lässt sich in VS 2019 nicht öffnen + +Prüfen, dass wirklich diese klassische Solution geöffnet wird und das .NET Framework 4.7.2 Developer Pack installiert ist. Das Repository enthält weder SDK-Style-Projekte noch `global.json` oder `PackageReference`. + +### Keine Anwendungen + +- Tool lokal auf dem BizTalk Server ausführen +- Konto und WMI-Leserechte prüfen +- Namespace `root\MicrosoftBizTalkServer` prüfen +- BizTalk Administration Console auf demselben Konto testen + +### Einzelne Zählwerte sind 0 + +Zuerst das Blatt `Abdeckung` prüfen. Nur bei Status `Vollständig` ist die Null durch eine erfolgreich gelesene WMI-Klasse belegt. Bei `Nicht verfügbar` oder `Begrenzt` muss die Ursache geprüft werden. + +Weitere technische Details stehen in [Dokumentation.md](Dokumentation.md). diff --git a/deployment/run-inventory.cmd b/deployment/run-inventory.cmd new file mode 100644 index 0000000..a902161 --- /dev/null +++ b/deployment/run-inventory.cmd @@ -0,0 +1,51 @@ +@echo off +setlocal EnableExtensions + +set "APPDIR=%~dp0" +set "EXE=%APPDIR%BizTalkApplicationCatalog.exe" + +if not exist "%EXE%" ( + echo FEHLER: Anwendung fehlt: "%EXE%" + exit /b 2 +) + +if "%~1"=="" goto :usage +set "ENVIRONMENT=%~1" + +if "%~2"=="" ( + set "OUTPUT=%CD%\BizTalk-Anwendungsinventar\%ENVIRONMENT%" +) else ( + set "OUTPUT=%~2" +) + +echo ============================================================ +echo BEW BizTalk Application Catalog +echo Umgebung: %ENVIRONMENT% +echo Server: %COMPUTERNAME% +echo Ausgabe: %OUTPUT% +echo Modus: ausschliesslich lesend +echo ============================================================ +echo. + +"%EXE%" --environment "%ENVIRONMENT%" --output "%OUTPUT%" +set "EXITCODE=%ERRORLEVEL%" + +echo. +if "%EXITCODE%"=="0" ( + echo Inventar erfolgreich erstellt. +) else if "%EXITCODE%"=="1" ( + echo Inventar mit Teilfehler erstellt. XLSX, Abdeckung, Findings und Log pruefen. +) else ( + echo Inventar konnte nicht erstellt werden. +) +echo ExitCode: %EXITCODE% +exit /b %EXITCODE% + +:usage +echo Aufruf: +echo run-inventory.cmd UMGEBUNG [AUSGABEORDNER] +echo. +echo Beispiele: +echo run-inventory.cmd ACC C:\BizTalk-Doku\ACC +echo run-inventory.cmd PROD C:\BizTalk-Doku\PROD +exit /b 2 diff --git a/scripts/build-release.cmd b/scripts/build-release.cmd new file mode 100644 index 0000000..2d4c987 --- /dev/null +++ b/scripts/build-release.cmd @@ -0,0 +1,43 @@ +@echo off +setlocal EnableExtensions + +set "ROOT=%~dp0.." +set "SOLUTION=%ROOT%\BizTalkApplicationCatalog.sln" + +if not defined MSBUILD ( + set "VSWHERE=%ProgramFiles(x86)%\Microsoft Visual Studio\Installer\vswhere.exe" + if exist "%VSWHERE%" ( + for /f "usebackq tokens=*" %%i in (`"%VSWHERE%" -latest -requires Microsoft.Component.MSBuild -find MSBuild\**\Bin\MSBuild.exe`) do ( + set "MSBUILD=%%i" + goto :found_msbuild + ) + ) +) + +:found_msbuild +if not defined MSBUILD ( + for /f "tokens=*" %%i in ('where msbuild 2^>nul') do ( + set "MSBUILD=%%i" + goto :found_msbuild_path + ) +) + +:found_msbuild_path +if not defined MSBUILD ( + echo FEHLER: MSBuild fehlt. Visual Studio 2019 Build Tools und .NET Framework 4.7.2 Developer Pack installieren. + exit /b 2 +) + +echo Verwende MSBuild: %MSBUILD% +"%MSBUILD%" -version +echo Baue klassisches .NET-Framework-4.7.2-Projekt ohne .NET-SDK und ohne NuGet-Restore ... +"%MSBUILD%" "%SOLUTION%" /m /t:Rebuild /p:Configuration=Release /p:Platform="Any CPU" +if errorlevel 1 exit /b %ERRORLEVEL% + +echo Fuehre automatisierte Tests aus ... +"%ROOT%\tests\BizTalkApplicationCatalog.Tests\bin\Release\BizTalkApplicationCatalog.Tests.exe" +if errorlevel 1 exit /b %ERRORLEVEL% + +echo Fuehre Self-Test der Anwendung aus ... +"%ROOT%\src\BizTalkApplicationCatalog\bin\Release\BizTalkApplicationCatalog.exe" --self-test +exit /b %ERRORLEVEL% diff --git a/scripts/package-release.cmd b/scripts/package-release.cmd new file mode 100644 index 0000000..1281aaf --- /dev/null +++ b/scripts/package-release.cmd @@ -0,0 +1,24 @@ +@echo off +setlocal EnableExtensions + +set "ROOT=%~dp0.." +set "BIN=%ROOT%\src\BizTalkApplicationCatalog\bin\Release" +set "ARTIFACTS=%ROOT%\artifacts" +set "DEPLOY=%ARTIFACTS%\BizTalkApplicationCatalog-deploy" + +call "%ROOT%\scripts\build-release.cmd" +if errorlevel 1 exit /b %ERRORLEVEL% + +if exist "%DEPLOY%" rmdir /s /q "%DEPLOY%" +mkdir "%DEPLOY%" + +copy "%BIN%\BizTalkApplicationCatalog.exe" "%DEPLOY%\" >nul +copy "%BIN%\BizTalkApplicationCatalog.exe.config" "%DEPLOY%\" >nul +if exist "%BIN%\BizTalkApplicationCatalog.pdb" copy "%BIN%\BizTalkApplicationCatalog.pdb" "%DEPLOY%\" >nul +copy "%ROOT%\deployment\run-inventory.cmd" "%DEPLOY%\" >nul +copy "%ROOT%\Readme.md" "%DEPLOY%\" >nul +copy "%ROOT%\Dokumentation.md" "%DEPLOY%\" >nul + +echo Deployment-Ordner erstellt: +echo %DEPLOY% +exit /b 0 diff --git a/scripts/package-source.cmd b/scripts/package-source.cmd new file mode 100644 index 0000000..0cff910 --- /dev/null +++ b/scripts/package-source.cmd @@ -0,0 +1,31 @@ +@echo off +setlocal EnableExtensions + +set "ROOT=%~dp0.." +set "ARTIFACTS=%ROOT%\artifacts" +set "ZIP=%ARTIFACTS%\BizTalkApplicationCatalog-source.zip" +set "TEXT=%ZIP%.txt" + +if not exist "%ARTIFACTS%" mkdir "%ARTIFACTS%" +if exist "%ZIP%" del /q "%ZIP%" +if exist "%TEXT%" del /q "%TEXT%" + +where tar >nul 2>nul +if errorlevel 1 ( + echo FEHLER: tar.exe wird fuer das Quellarchiv benoetigt. + exit /b 2 +) + +pushd "%ROOT%" +tar.exe -a -c -f "%ZIP%" --exclude=.git --exclude=bin --exclude=obj --exclude=artifacts . +set "TAR_EXIT=%ERRORLEVEL%" +popd +if not "%TAR_EXIT%"=="0" exit /b %TAR_EXIT% + +certutil -f -encode "%ZIP%" "%TEXT%" >nul +if errorlevel 1 exit /b %ERRORLEVEL% + +echo Quellarchiv: %ZIP% +echo Base64-Text: %TEXT% +echo Dekodieren: certutil -decode BizTalkApplicationCatalog-source.zip.txt BizTalkApplicationCatalog-source.zip +exit /b 0 diff --git a/src/BizTalkApplicationCatalog/App.config b/src/BizTalkApplicationCatalog/App.config new file mode 100644 index 0000000..06ed7ff --- /dev/null +++ b/src/BizTalkApplicationCatalog/App.config @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/src/BizTalkApplicationCatalog/BizTalkApplicationCatalog.csproj b/src/BizTalkApplicationCatalog/BizTalkApplicationCatalog.csproj new file mode 100644 index 0000000..185009d --- /dev/null +++ b/src/BizTalkApplicationCatalog/BizTalkApplicationCatalog.csproj @@ -0,0 +1,64 @@ + + + + Debug + AnyCPU + {41CB5701-3FBC-49F4-856A-6AE930B8513D} + Exe + BizTalkApplicationCatalog + BizTalkApplicationCatalog + v4.7.2 + + 512 + 7.3 + true + true + true + + + true + full + false + bin\Debug\ + DEBUG;TRACE + 4 + AnyCPU + false + + + pdbonly + true + bin\Release\ + TRACE + 4 + AnyCPU + false + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/BizTalkApplicationCatalog/Collectors/BizTalkWmiCollector.cs b/src/BizTalkApplicationCatalog/Collectors/BizTalkWmiCollector.cs new file mode 100644 index 0000000..1a84703 --- /dev/null +++ b/src/BizTalkApplicationCatalog/Collectors/BizTalkWmiCollector.cs @@ -0,0 +1,421 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Management; +using System.Runtime.InteropServices; +using BizTalkApplicationCatalog.Infrastructure; +using BizTalkApplicationCatalog.Models; + +namespace BizTalkApplicationCatalog.Collectors +{ + /// + /// Liest die installierten Anwendungen und zugehörigen Artefakte über den lokalen BizTalk-WMI-Provider. + /// + internal sealed class BizTalkWmiCollector + { + private readonly InventoryDocument document; + private readonly ConsoleFileLogger logger; + private readonly ManagementScope scope; + private readonly TimeSpan timeout; + private readonly int maxRowsPerType; + private readonly Dictionary receivePortApplications = + new Dictionary(StringComparer.OrdinalIgnoreCase); + + public BizTalkWmiCollector( + InventoryDocument document, + ConsoleFileLogger logger, + int timeoutSeconds, + int maxRowsPerType) + { + this.document = document; + this.logger = logger; + timeout = TimeSpan.FromSeconds(Math.Max(5, timeoutSeconds)); + this.maxRowsPerType = Math.Max(100, maxRowsPerType); + scope = new ManagementScope( + @"\\" + Environment.MachineName + @"\root\MicrosoftBizTalkServer"); + scope.Options.Timeout = timeout; + } + + /// + /// Stellt zuerst die vollständige Primärliste her und ergänzt anschließend optionale Details. + /// + public void Collect() + { + scope.Connect(); + CollectApplications(); + + // Receive Ports werden vor Receive Locations gelesen, damit deren Anwendung + // auch dann aufgelöst werden kann, wenn die Location sie nicht direkt liefert. + foreach (var descriptor in ArtifactDescriptors()) + { + CollectOptionalArtifacts(descriptor); + } + + CollectOptionalHosts("MSBTS_HostSetting", "Host"); + CollectOptionalHosts("MSBTS_HostInstance", "Hostinstanz"); + CollectOptionalHosts("MSBTS_ReceiveHandler", "Receive Handler"); + if (!CollectOptionalHosts("MSBTS_SendHandler2", "Send Handler")) + { + CollectOptionalHosts("MSBTS_SendHandler", "Send Handler"); + } + + document.Applications.Sort((left, right) => + string.Compare(left.Name, right.Name, StringComparison.OrdinalIgnoreCase)); + document.Artifacts.Sort(CompareArtifacts); + document.Hosts.Sort((left, right) => + { + var category = string.Compare(left.Category, right.Category, StringComparison.OrdinalIgnoreCase); + return category != 0 + ? category + : string.Compare(left.Name, right.Name, StringComparison.OrdinalIgnoreCase); + }); + } + + private void CollectApplications() + { + var count = 0; + foreach (var row in Query("SELECT * FROM MSBTS_Application")) + { + using (row) + { + var name = First(row, "Name", "ApplicationName"); + if (string.IsNullOrWhiteSpace(name)) continue; + document.Applications.Add(new ApplicationRecord + { + Name = name, + Description = First(row, "Description"), + Status = FormatStatus(First(row, "Status")), + IsDefault = FormatBoolean(First(row, "IsDefault")), + Source = "MSBTS_Application" + }); + count++; + } + } + + document.Coverage.Add(new CoverageRecord + { + DataSource = "MSBTS_Application", + Status = count > 0 ? "Vollständig" : "Fehler", + RowCount = count, + Required = "Ja", + Message = count > 0 + ? "Primärquelle der vollständigen Anwendungsliste." + : "Keine Anwendung geliefert." + }); + if (count == 0) + { + throw new InvalidOperationException( + "MSBTS_Application lieferte keine Anwendungen. Zielserver und Berechtigung prüfen."); + } + + logger.Info(count + " installierte BizTalk-Anwendung(en) gefunden."); + } + + private void CollectOptionalArtifacts(ArtifactDescriptor descriptor) + { + try + { + var count = 0; + var truncated = false; + foreach (var row in Query("SELECT * FROM " + descriptor.ClassName)) + { + using (row) + { + if (count >= maxRowsPerType) + { + truncated = true; + break; + } + + var record = CreateArtifact(row, descriptor.DisplayName); + if (string.IsNullOrWhiteSpace(record.Name)) continue; + if (record.Type == "Receive Port") + { + receivePortApplications[record.Name] = record.ApplicationName; + } + if (record.Type == "Receive Location" + && string.IsNullOrWhiteSpace(record.ApplicationName) + && receivePortApplications.ContainsKey(record.ParentName)) + { + record.ApplicationName = receivePortApplications[record.ParentName]; + } + document.Artifacts.Add(record); + count++; + } + } + + document.Coverage.Add(new CoverageRecord + { + DataSource = descriptor.ClassName, + Status = truncated ? "Begrenzt" : "Vollständig", + RowCount = count, + Required = "Nein", + Message = truncated + ? "Detailzeilen auf " + maxRowsPerType + " begrenzt." + : "WMI-Klasse erfolgreich gelesen." + }); + logger.Info(descriptor.DisplayName + ": " + count + " Datensatz/Datensätze."); + if (truncated) + { + AddFinding( + "Warnung", + descriptor.DisplayName, + "Detailzeilen wurden bei " + maxRowsPerType + " Einträgen begrenzt.", + "MaxRowsPerArtifactType kontrolliert erhöhen und Inventar erneut ausführen."); + } + } + catch (Exception exception) when (IsRecoverableWmiException(exception)) + { + document.Coverage.Add(new CoverageRecord + { + DataSource = descriptor.ClassName, + Status = "Nicht verfügbar", + RowCount = 0, + Required = "Nein", + Message = exception.Message + }); + AddFinding( + "Hinweis", + descriptor.DisplayName, + "Optionale WMI-Klasse konnte nicht gelesen werden: " + exception.Message, + "Berechtigung und Verfügbarkeit der WMI-Klasse prüfen; Wert 0 nicht als fachlich bestätigt werten."); + logger.Warning(descriptor.ClassName + " nicht verfügbar: " + exception.Message); + } + } + + private bool CollectOptionalHosts(string className, string category) + { + try + { + var count = 0; + foreach (var row in Query("SELECT * FROM " + className)) + { + using (row) + { + var name = First(row, "Name", "HostName", "AdapterName", "RunningServer"); + if (string.IsNullOrWhiteSpace(name)) continue; + document.Hosts.Add(new HostRecord + { + Category = category, + Name = name, + Server = First(row, "RunningServer", "ServerName"), + Status = FormatStatus(First(row, "ServiceState", "Status")), + Type = First(row, "HostType"), + WindowsGroup = First(row, "NTGroupName"), + Is32BitOnly = FormatBoolean(First(row, "IsHost32BitOnly")), + Trusted = FormatBoolean(First(row, "AuthTrusted")), + AdapterName = First(row, "AdapterName") + }); + count++; + } + } + document.Coverage.Add(new CoverageRecord + { + DataSource = className, + Status = "Vollständig", + RowCount = count, + Required = "Nein", + Message = "WMI-Klasse erfolgreich gelesen." + }); + logger.Info(category + ": " + count + " Datensatz/Datensätze."); + return true; + } + catch (Exception exception) when (IsRecoverableWmiException(exception)) + { + document.Coverage.Add(new CoverageRecord + { + DataSource = className, + Status = "Nicht verfügbar", + RowCount = 0, + Required = "Nein", + Message = exception.Message + }); + logger.Warning(className + " nicht verfügbar: " + exception.Message); + return false; + } + } + + private ArtifactRecord CreateArtifact(ManagementBaseObject row, string type) + { + var record = new ArtifactRecord + { + Type = type, + ApplicationName = First(row, "ApplicationName", "Application"), + Name = First( + row, + "Name", + "AssemblyName", + "FullName", + "ReceivePortName", + "OrchestrationName"), + Status = FormatStatus(First(row, "Status", "ServiceStatus", "IsDisabled")), + HostName = First(row, "HostName", "SendHandler", "ReceiveHandler"), + AdapterName = First( + row, + "PTTransportType", + "AdapterName", + "TransportType"), + ParentName = First(row, "ReceivePortName", "SendPortGroupName"), + Address = SensitiveDataSanitizer.Sanitize(First( + row, + "PTAddress", + "InboundTransportURL", + "Address")) + }; + + AddProperty(row, record, "Description"); + AddProperty(row, record, "IsTwoWay"); + AddProperty(row, record, "IsDynamic"); + AddProperty(row, record, "IsDisabled"); + AddProperty(row, record, "ReceivePipeline"); + AddProperty(row, record, "SendPipeline"); + AddProperty(row, record, "STTransportType"); + AddProperty(row, record, "STAddress"); + AddProperty(row, record, "AssemblyName"); + AddProperty(row, record, "FullName"); + AddProperty(row, record, "TargetNameSpace"); + AddProperty(row, record, "RootName"); + AddProperty(row, record, "Tracking"); + return record; + } + + private static IEnumerable ArtifactDescriptors() + { + return new[] + { + new ArtifactDescriptor("MSBTS_Orchestration", "Orchestrierung"), + new ArtifactDescriptor("MSBTS_SendPort", "Send Port"), + new ArtifactDescriptor("MSBTS_SendPortGroup", "Send Port Group"), + new ArtifactDescriptor("MSBTS_ReceivePort", "Receive Port"), + new ArtifactDescriptor("MSBTS_ReceiveLocation", "Receive Location"), + new ArtifactDescriptor("MSBTS_Assembly", "Assembly"), + new ArtifactDescriptor("MSBTS_Schema", "Schema"), + new ArtifactDescriptor("MSBTS_Map", "Map"), + new ArtifactDescriptor("MSBTS_Pipeline", "Pipeline") + }; + } + + private List Query(string query) + { + var options = new EnumerationOptions + { + ReturnImmediately = false, + Rewindable = false, + Timeout = timeout + }; + using (var searcher = new ManagementObjectSearcher(scope, new ObjectQuery(query), options)) + { + return searcher.Get().Cast().ToList(); + } + } + + private void AddFinding(string severity, string area, string message, string action) + { + document.Findings.Add(new Finding + { + Severity = severity, + Area = area, + Message = message, + RecommendedAction = action + }); + } + + private static void AddProperty( + ManagementBaseObject row, + ArtifactRecord target, + string propertyName) + { + var value = First(row, propertyName); + if (!string.IsNullOrWhiteSpace(value)) + { + target.Properties.Add(new NameValueRecord( + propertyName, + SensitiveDataSanitizer.RedactProperty(propertyName, value), + "WMI")); + } + } + + private static string First(ManagementBaseObject row, params string[] names) + { + foreach (var name in names) + { + try + { + var value = row[name]; + if (value == null) continue; + var text = ConvertValue(value); + if (!string.IsNullOrWhiteSpace(text)) return text; + } + catch (Exception exception) when (IsRecoverableWmiException(exception)) + { + // Die Eigenschaft ist in dieser BizTalk-Version/Klasse nicht vorhanden. + } + } + return string.Empty; + } + + private static string ConvertValue(object value) + { + var array = value as Array; + if (array != null && !(value is byte[])) + { + return string.Join(", ", array.Cast().Select(item => + Convert.ToString(item, CultureInfo.InvariantCulture))); + } + return Convert.ToString(value, CultureInfo.InvariantCulture) ?? string.Empty; + } + + private static bool IsRecoverableWmiException(Exception exception) + { + return exception is ManagementException + || exception is COMException + || exception is UnauthorizedAccessException; + } + + private static string FormatStatus(string value) + { + if (string.IsNullOrWhiteSpace(value)) return "Unbekannt"; + switch (value.Trim()) + { + case "1": return "Gestoppt (1)"; + case "2": return "Gestartet (2)"; + case "3": return "Teilweise gestartet (3)"; + case "True": return "Ja"; + case "False": return "Nein"; + default: return value; + } + } + + private static string FormatBoolean(string value) + { + if (string.IsNullOrWhiteSpace(value)) return "Unbekannt"; + if (value.Equals("True", StringComparison.OrdinalIgnoreCase) || value == "1") return "Ja"; + if (value.Equals("False", StringComparison.OrdinalIgnoreCase) || value == "0") return "Nein"; + return value; + } + + private static int CompareArtifacts(ArtifactRecord left, ArtifactRecord right) + { + var application = string.Compare( + left.ApplicationName, right.ApplicationName, StringComparison.OrdinalIgnoreCase); + if (application != 0) return application; + var type = string.Compare(left.Type, right.Type, StringComparison.OrdinalIgnoreCase); + return type != 0 + ? type + : string.Compare(left.Name, right.Name, StringComparison.OrdinalIgnoreCase); + } + + private sealed class ArtifactDescriptor + { + public ArtifactDescriptor(string className, string displayName) + { + ClassName = className; + DisplayName = displayName; + } + + public string ClassName { get; private set; } + public string DisplayName { get; private set; } + } + } +} diff --git a/src/BizTalkApplicationCatalog/Collectors/SystemCollector.cs b/src/BizTalkApplicationCatalog/Collectors/SystemCollector.cs new file mode 100644 index 0000000..9fde298 --- /dev/null +++ b/src/BizTalkApplicationCatalog/Collectors/SystemCollector.cs @@ -0,0 +1,223 @@ +using System; +using System.Globalization; +using System.Management; +using System.Reflection; +using Microsoft.Win32; +using BizTalkApplicationCatalog.Configuration; +using BizTalkApplicationCatalog.Models; + +namespace BizTalkApplicationCatalog.Collectors +{ + /// + /// Erfasst lokale Windows-, BizTalk- und Gruppenmetadaten ausschließlich lesend. + /// + internal sealed class SystemCollector + { + private const string ProductKey = @"SOFTWARE\Microsoft\BizTalk Server\3.0"; + private const string AdministrationKey = ProductKey + @"\Administration"; + private readonly CommandLineOptions options; + + public SystemCollector(CommandLineOptions options) + { + this.options = options; + } + + public void Collect(InventoryDocument document) + { + document.ComputerName = Environment.MachineName; + document.ToolVersion = Assembly.GetExecutingAssembly().GetName().Version.ToString(); + Add(document, "Ausführender Benutzer", Environment.UserDomainName + "\\" + Environment.UserName, "Prozess"); + Add(document, "64-Bit-Betriebssystem", Environment.Is64BitOperatingSystem ? "Ja" : "Nein", "Prozess"); + Add(document, "64-Bit-Prozess", Environment.Is64BitProcess ? "Ja" : "Nein", "Prozess"); + Add(document, ".NET Runtime", Environment.Version.ToString(), "Prozess"); + + CollectOperatingSystem(document); + CollectRegistry(document); + CollectGroupSetting(document); + + if (!string.IsNullOrWhiteSpace(options.ManagementServer)) + { + document.ManagementServer = options.ManagementServer; + } + if (!string.IsNullOrWhiteSpace(options.ManagementDatabase)) + { + document.ManagementDatabase = options.ManagementDatabase; + } + if (string.IsNullOrWhiteSpace(document.ManagementDatabase)) + { + document.ManagementDatabase = "BizTalkMgmtDb"; + } + + Add(document, "BizTalk Management SQL Server", Unknown(document.ManagementServer), "Ermittelt/Parameter"); + Add(document, "BizTalk Management Database", document.ManagementDatabase, "Ermittelt/Parameter"); + + if (document.EnvironmentName != "ACC" && document.EnvironmentName != "PROD") + { + document.Findings.Add(new Finding + { + Severity = "Warnung", + Area = "Aufruf", + Message = "Die Umgebung ist weder ACC noch PROD: " + document.EnvironmentName, + RecommendedAction = "Umgebung und Zielserver vor der Ablage bestätigen." + }); + } + } + + private static void CollectOperatingSystem(InventoryDocument document) + { + using (var searcher = new ManagementObjectSearcher( + "root\\cimv2", + "SELECT Caption,Version,BuildNumber,OSArchitecture,LastBootUpTime FROM Win32_OperatingSystem")) + { + foreach (ManagementObject row in searcher.Get()) + { + using (row) + { + Add(document, "Betriebssystem", Value(row, "Caption"), "Win32_OperatingSystem"); + Add(document, "Windows-Version", Value(row, "Version"), "Win32_OperatingSystem"); + Add(document, "Windows-Build", Value(row, "BuildNumber"), "Win32_OperatingSystem"); + Add(document, "Architektur", Value(row, "OSArchitecture"), "Win32_OperatingSystem"); + Add(document, "Letzter Systemstart", WmiDate(Value(row, "LastBootUpTime")), "Win32_OperatingSystem"); + break; + } + } + } + } + + private static void CollectRegistry(InventoryDocument document) + { + foreach (var view in new[] { RegistryView.Registry64, RegistryView.Registry32 }) + { + using (var baseKey = RegistryKey.OpenBaseKey(RegistryHive.LocalMachine, view)) + using (var product = baseKey.OpenSubKey(ProductKey, false)) + { + if (product != null) + { + AddRegistry(document, product, "ProductName", "BizTalk Produktname", view); + AddRegistry(document, product, "ProductVersion", "BizTalk Produktversion", view); + AddRegistry(document, product, "Edition", "BizTalk Edition", view); + } + } + + using (var baseKey = RegistryKey.OpenBaseKey(RegistryHive.LocalMachine, view)) + using (var administration = baseKey.OpenSubKey(AdministrationKey, false)) + { + if (administration == null) + { + continue; + } + if (string.IsNullOrWhiteSpace(document.ManagementServer)) + { + document.ManagementServer = FirstRegistry( + administration, "MgmtDBServer", "ManagementDBServer"); + } + if (string.IsNullOrWhiteSpace(document.ManagementDatabase)) + { + document.ManagementDatabase = FirstRegistry( + administration, "MgmtDBName", "ManagementDBName"); + } + } + } + } + + private static void CollectGroupSetting(InventoryDocument document) + { + try + { + using (var searcher = new ManagementObjectSearcher( + @"root\MicrosoftBizTalkServer", "SELECT * FROM MSBTS_GroupSetting")) + { + foreach (ManagementObject row in searcher.Get()) + { + using (row) + { + var server = Value(row, "MgmtDbServerName"); + var database = Value(row, "MgmtDbName"); + if (!string.IsNullOrWhiteSpace(server)) document.ManagementServer = server; + if (!string.IsNullOrWhiteSpace(database)) document.ManagementDatabase = database; + Add(document, "BizTalk Gruppenname", Value(row, "Name"), "MSBTS_GroupSetting"); + Add(document, "BizTalk Administratorengruppe", Value(row, "BizTalkAdministratorGroup"), "MSBTS_GroupSetting"); + Add(document, "BizTalk Operatorengruppe", Value(row, "BizTalkOperatorGroup"), "MSBTS_GroupSetting"); + Add(document, "Enterprise SSO Server", Value(row, "SSOServerName"), "MSBTS_GroupSetting"); + break; + } + } + } + } + catch (ManagementException exception) + { + document.Findings.Add(new Finding + { + Severity = "Hinweis", + Area = "MSBTS_GroupSetting", + Message = "Gruppenmetadaten konnten nicht vollständig gelesen werden: " + exception.Message, + RecommendedAction = "WMI-Berechtigung prüfen; die Anwendungserfassung läuft unabhängig weiter." + }); + } + } + + private static void AddRegistry( + InventoryDocument document, + RegistryKey key, + string valueName, + string displayName, + RegistryView view) + { + var value = Convert.ToString(key.GetValue(valueName), CultureInfo.InvariantCulture); + if (!string.IsNullOrWhiteSpace(value) + && !document.SystemProperties.Exists(item => item.Name == displayName)) + { + Add(document, displayName, value, "Registry " + view); + } + } + + private static string FirstRegistry(RegistryKey key, params string[] names) + { + foreach (var name in names) + { + var value = Convert.ToString(key.GetValue(name), CultureInfo.InvariantCulture); + if (!string.IsNullOrWhiteSpace(value)) return value.Trim(); + } + return string.Empty; + } + + private static string Value(ManagementBaseObject row, string property) + { + try + { + return Convert.ToString(row[property], CultureInfo.InvariantCulture) ?? string.Empty; + } + catch (ManagementException) + { + return string.Empty; + } + } + + private static void Add(InventoryDocument document, string name, string value, string source) + { + if (!string.IsNullOrWhiteSpace(value)) + { + document.SystemProperties.Add(new NameValueRecord(name, value, source)); + } + } + + private static string WmiDate(string value) + { + if (string.IsNullOrWhiteSpace(value)) return "Unbekannt"; + try + { + return ManagementDateTimeConverter.ToDateTime(value) + .ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture); + } + catch (ArgumentOutOfRangeException) + { + return value; + } + } + + private static string Unknown(string value) + { + return string.IsNullOrWhiteSpace(value) ? "Nicht ermittelt" : value; + } + } +} diff --git a/src/BizTalkApplicationCatalog/Configuration/CommandLineOptions.cs b/src/BizTalkApplicationCatalog/Configuration/CommandLineOptions.cs new file mode 100644 index 0000000..2277c70 --- /dev/null +++ b/src/BizTalkApplicationCatalog/Configuration/CommandLineOptions.cs @@ -0,0 +1,115 @@ +using System; +using System.IO; +using System.Text.RegularExpressions; + +namespace BizTalkApplicationCatalog.Configuration +{ + /// + /// Validiert die Kommandozeile und stellt ausschließlich normalisierte Werte bereit. + /// + internal sealed class CommandLineOptions + { + private static readonly Regex UnsafeEnvironment = + new Regex("[^A-Z0-9_-]+", RegexOptions.Compiled | RegexOptions.CultureInvariant); + + public string EnvironmentName { get; private set; } + public string OutputDirectory { get; private set; } + public string ManagementServer { get; private set; } + public string ManagementDatabase { get; private set; } + public bool SelfTest { get; private set; } + public bool ShowHelp { get; private set; } + + /// + /// Parst die Argumente. Unbekannte oder unvollständige Optionen werden abgelehnt. + /// + public static CommandLineOptions Parse(string[] args) + { + var result = new CommandLineOptions(); + for (var index = 0; index < args.Length; index++) + { + var argument = args[index]; + switch (argument.ToLowerInvariant()) + { + case "--environment": + result.EnvironmentName = Value(args, ref index, argument); + break; + case "--output": + result.OutputDirectory = Value(args, ref index, argument); + break; + case "--management-server": + result.ManagementServer = Value(args, ref index, argument); + break; + case "--management-database": + result.ManagementDatabase = Value(args, ref index, argument); + break; + case "--self-test": + result.SelfTest = true; + break; + case "--help": + case "-h": + case "/?": + result.ShowHelp = true; + break; + default: + throw new ArgumentException("Unbekannte Option: " + argument); + } + } + + if (result.SelfTest || result.ShowHelp) + { + return result; + } + + if (string.IsNullOrWhiteSpace(result.EnvironmentName)) + { + throw new ArgumentException("--environment fehlt."); + } + + result.EnvironmentName = UnsafeEnvironment.Replace( + result.EnvironmentName.Trim().ToUpperInvariant(), "-").Trim('-'); + if (result.EnvironmentName.Length == 0) + { + throw new ArgumentException("--environment enthält keinen gültigen Namen."); + } + + if (string.IsNullOrWhiteSpace(result.OutputDirectory)) + { + result.OutputDirectory = Path.Combine( + Environment.CurrentDirectory, "BizTalk-Anwendungsinventar", result.EnvironmentName); + } + + result.OutputDirectory = Path.GetFullPath( + Environment.ExpandEnvironmentVariables(result.OutputDirectory)); + return result; + } + + public static string Usage() + { + return string.Join(Environment.NewLine, new[] + { + "BEW BizTalk Application Catalog", + string.Empty, + "Aufruf:", + " BizTalkApplicationCatalog.exe --environment ACC [--output PFAD]", + " BizTalkApplicationCatalog.exe --environment PROD [--output PFAD]", + string.Empty, + "Optionen:", + " --management-server NAME Optionaler SQL-Server-Hinweis.", + " --management-database NAME Optionale Management-Datenbank.", + " --self-test Prüft XLSX-Struktur und Kernlogik ohne BizTalk.", + " --help Diese Hilfe." + }); + } + + private static string Value(string[] args, ref int index, string option) + { + index++; + if (index >= args.Length || args[index].StartsWith("--", StringComparison.Ordinal)) + { + throw new ArgumentException("Wert für " + option + " fehlt."); + } + + return args[index]; + } + } +} diff --git a/src/BizTalkApplicationCatalog/Infrastructure/ConsoleFileLogger.cs b/src/BizTalkApplicationCatalog/Infrastructure/ConsoleFileLogger.cs new file mode 100644 index 0000000..92daaf2 --- /dev/null +++ b/src/BizTalkApplicationCatalog/Infrastructure/ConsoleFileLogger.cs @@ -0,0 +1,41 @@ +using System; +using System.Globalization; +using System.IO; +using System.Text; + +namespace BizTalkApplicationCatalog.Infrastructure +{ + /// + /// Spiegelt alle Fortschrittsmeldungen zeitgleich auf die Konsole und in eine UTF-8-Logdatei. + /// + internal sealed class ConsoleFileLogger : IDisposable + { + private readonly object sync = new object(); + private readonly StreamWriter writer; + + public ConsoleFileLogger(string path) + { + writer = new StreamWriter(path, false, new UTF8Encoding(false)) { AutoFlush = true }; + } + + public void Info(string message) { Write("INFO", message); } + public void Warning(string message) { Write("WARNUNG", message); } + public void Error(string message) { Write("FEHLER", message); } + public void Dispose() { writer.Dispose(); } + + private void Write(string level, string message) + { + var line = string.Format( + CultureInfo.InvariantCulture, + "{0:yyyy-MM-dd HH:mm:ss.fff} [{1}] {2}", + DateTime.Now, + level, + message ?? string.Empty); + lock (sync) + { + Console.WriteLine(line); + writer.WriteLine(line); + } + } + } +} diff --git a/src/BizTalkApplicationCatalog/Infrastructure/SafeCollector.cs b/src/BizTalkApplicationCatalog/Infrastructure/SafeCollector.cs new file mode 100644 index 0000000..c17cc82 --- /dev/null +++ b/src/BizTalkApplicationCatalog/Infrastructure/SafeCollector.cs @@ -0,0 +1,61 @@ +using System; +using System.Diagnostics; +using BizTalkApplicationCatalog.Models; + +namespace BizTalkApplicationCatalog.Infrastructure +{ + /// + /// Isoliert Erfassungsfehler, protokolliert sie und lässt unabhängige Abschnitte weiterlaufen. + /// + internal sealed class SafeCollector + { + private readonly InventoryDocument document; + private readonly ConsoleFileLogger logger; + + public SafeCollector(InventoryDocument document, ConsoleFileLogger logger) + { + this.document = document; + this.logger = logger; + } + + public void Execute(string name, bool required, Action action) + { + var timer = Stopwatch.StartNew(); + logger.Info("Starte Abschnitt: " + name); + try + { + action(); + timer.Stop(); + document.SectionStatuses.Add(new SectionStatus + { + Name = name, + Status = "Erfolgreich", + Message = "Abschnitt vollständig ausgeführt.", + Required = required, + DurationMilliseconds = timer.ElapsedMilliseconds + }); + logger.Info("Abschnitt abgeschlossen: " + name + " (" + timer.ElapsedMilliseconds + " ms)"); + } + catch (Exception exception) + { + timer.Stop(); + document.SectionStatuses.Add(new SectionStatus + { + Name = name, + Status = required ? "Fehler" : "Teilweise", + Message = exception.GetType().Name + ": " + exception.Message, + Required = required, + DurationMilliseconds = timer.ElapsedMilliseconds + }); + document.Findings.Add(new Finding + { + Severity = required ? "Fehler" : "Warnung", + Area = name, + Message = "Datenerfassung fehlgeschlagen: " + exception.Message, + RecommendedAction = "Logdatei, WMI-Provider und Leseberechtigungen prüfen." + }); + logger.Error(name + ": " + exception.Message); + } + } + } +} diff --git a/src/BizTalkApplicationCatalog/Infrastructure/SelfTestRunner.cs b/src/BizTalkApplicationCatalog/Infrastructure/SelfTestRunner.cs new file mode 100644 index 0000000..eca224a --- /dev/null +++ b/src/BizTalkApplicationCatalog/Infrastructure/SelfTestRunner.cs @@ -0,0 +1,190 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.IO.Compression; +using System.Linq; +using System.Text; +using System.Xml.Linq; +using BizTalkApplicationCatalog.Models; +using BizTalkApplicationCatalog.Reporting; + +namespace BizTalkApplicationCatalog.Infrastructure +{ + /// + /// Prüft die zentrale Berichtslogik ohne Zugriff auf Windows- oder BizTalk-WMI. + /// + internal static class SelfTestRunner + { + public static void Run(string outputPath = null) + { + Assert(SensitiveDataSanitizer.Sanitize( + "https://host/path?password=secret&client=100") + == "https://host/path?password=[REDACTED]&client=100", + "Secret-Redaktion ist fehlerhaft."); + + var keepOutput = !string.IsNullOrWhiteSpace(outputPath); + var directory = keepOutput + ? Path.GetDirectoryName(Path.GetFullPath(outputPath)) + : Path.Combine( + Path.GetTempPath(), + "BizTalkApplicationCatalogTests-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(directory); + var path = keepOutput ? Path.GetFullPath(outputPath) : Path.Combine(directory, "test.xlsx"); + try + { + var document = SampleDocument(); + new XlsxReportWriter().Write(document, path); + Assert(File.Exists(path), "XLSX-Datei wurde nicht erzeugt."); + + using (var archive = ZipFile.OpenRead(path)) + { + var required = new[] + { + "[Content_Types].xml", + "_rels/.rels", + "docProps/core.xml", + "docProps/app.xml", + "xl/workbook.xml", + "xl/_rels/workbook.xml.rels", + "xl/styles.xml" + }; + foreach (var name in required) + { + Assert(archive.GetEntry(name) != null, "XLSX-Part fehlt: " + name); + } + + for (var index = 1; index <= 10; index++) + { + Assert( + archive.GetEntry("xl/worksheets/sheet" + index + ".xml") != null, + "Arbeitsblatt fehlt: " + index); + } + + foreach (var entry in archive.Entries.Where(item => + item.FullName.EndsWith(".xml", StringComparison.OrdinalIgnoreCase) + || item.FullName.EndsWith(".rels", StringComparison.OrdinalIgnoreCase))) + { + using (var stream = entry.Open()) + { + XDocument.Load(stream); + } + } + + var workbook = LoadXml(archive, "xl/workbook.xml"); + XNamespace spreadsheet = + "http://schemas.openxmlformats.org/spreadsheetml/2006/main"; + var names = workbook.Descendants(spreadsheet + "sheet") + .Select(item => (string)item.Attribute("name")).ToList(); + Assert(names.Contains("Anwendungen"), "Anwendungsblatt fehlt."); + Assert(names.Contains("Abdeckung"), "Abdeckungsblatt fehlt."); + + var contentTypes = LoadXml(archive, "[Content_Types].xml"); + XNamespace contentTypeNamespace = + "http://schemas.openxmlformats.org/package/2006/content-types"; + Assert( + contentTypes.Root.Elements(contentTypeNamespace + "Override").Count() == 14, + "Content-Type-Overrides sind unvollständig oder im falschen Namespace."); + + var relationships = LoadXml(archive, "_rels/.rels"); + XNamespace relationshipNamespace = + "http://schemas.openxmlformats.org/package/2006/relationships"; + Assert( + relationships.Root.Elements(relationshipNamespace + "Relationship").Count() == 3, + "Paketbeziehungen sind unvollständig oder im falschen Namespace."); + + var combinedText = new StringBuilder(); + foreach (var entry in archive.Entries.Where(item => + item.FullName.StartsWith("xl/worksheets/", StringComparison.Ordinal))) + { + using (var reader = new StreamReader(entry.Open(), Encoding.UTF8)) + { + combinedText.Append(reader.ReadToEnd()); + } + } + Assert(!combinedText.ToString().Contains("supersecret"), "XLSX enthält Testkennwort."); + Assert(combinedText.ToString().Contains("[REDACTED]"), "Redaktionsmarker fehlt."); + } + } + finally + { + try + { + if (!keepOutput && Directory.Exists(directory)) Directory.Delete(directory, true); + } + catch (IOException) + { + } + catch (UnauthorizedAccessException) + { + } + } + } + + private static InventoryDocument SampleDocument() + { + var document = new InventoryDocument + { + EnvironmentName = "ACC", + ComputerName = "BIZTALK-ACC", + ToolVersion = "1.0.0.0", + ManagementServer = "SQL-ACC", + ManagementDatabase = "BizTalkMgmtDb", + StartedUtc = DateTime.UtcNow.AddSeconds(-1), + CompletedUtc = DateTime.UtcNow + }; + document.SystemProperties.Add(new NameValueRecord( + "Betriebssystem", "Windows Server 2019", "Self-Test")); + document.Applications.Add(new ApplicationRecord + { + Name = "OrderProcessing", + Description = "Aufträge & Sonderzeichen ", + Status = "Gestartet (2)", + IsDefault = "Nein", + Source = "Self-Test" + }); + var port = new ArtifactRecord + { + Type = "Send Port", + ApplicationName = "OrderProcessing", + Name = "Send_Orders", + Status = "Gestartet (2)", + HostName = "SendHost", + AdapterName = "WCF-Custom", + Address = SensitiveDataSanitizer.Sanitize( + "https://service/orders?password=supersecret") + }; + port.Properties.Add(new NameValueRecord("IsTwoWay", "True", "Self-Test")); + document.Artifacts.Add(port); + document.Coverage.Add(new CoverageRecord + { + DataSource = "MSBTS_Application", + Status = "Vollständig", + RowCount = 1, + Required = "Ja", + Message = "Self-Test" + }); + document.SectionStatuses.Add(new SectionStatus + { + Name = "Self-Test", + Status = "Erfolgreich", + Message = "OK", + Required = true, + DurationMilliseconds = 1 + }); + return document; + } + + private static XDocument LoadXml(ZipArchive archive, string name) + { + using (var stream = archive.GetEntry(name).Open()) + { + return XDocument.Load(stream); + } + } + + private static void Assert(bool condition, string message) + { + if (!condition) throw new InvalidOperationException(message); + } + } +} diff --git a/src/BizTalkApplicationCatalog/Infrastructure/SensitiveDataSanitizer.cs b/src/BizTalkApplicationCatalog/Infrastructure/SensitiveDataSanitizer.cs new file mode 100644 index 0000000..f7ee74f --- /dev/null +++ b/src/BizTalkApplicationCatalog/Infrastructure/SensitiveDataSanitizer.cs @@ -0,0 +1,39 @@ +using System; +using System.Text.RegularExpressions; + +namespace BizTalkApplicationCatalog.Infrastructure +{ + /// + /// Verhindert, dass versehentlich Kennwörter oder Token aus WMI-Textwerten in den Bericht gelangen. + /// + internal static class SensitiveDataSanitizer + { + private static readonly Regex NamedSecret = new Regex( + @"(?i)(password|passwd|pwd|secret|token|clientsecret|accesskey)\s*([=:])\s*([^;&\s""']+)", + RegexOptions.Compiled | RegexOptions.CultureInvariant); + + public static string Sanitize(string value) + { + if (string.IsNullOrEmpty(value)) + { + return string.Empty; + } + + return NamedSecret.Replace(value, match => + match.Groups[1].Value + match.Groups[2].Value + "[REDACTED]"); + } + + public static string RedactProperty(string name, string value) + { + if (!string.IsNullOrWhiteSpace(name) + && (name.IndexOf("password", StringComparison.OrdinalIgnoreCase) >= 0 + || name.IndexOf("secret", StringComparison.OrdinalIgnoreCase) >= 0 + || name.IndexOf("token", StringComparison.OrdinalIgnoreCase) >= 0)) + { + return "[REDACTED]"; + } + + return Sanitize(value); + } + } +} diff --git a/src/BizTalkApplicationCatalog/Models/InventoryModels.cs b/src/BizTalkApplicationCatalog/Models/InventoryModels.cs new file mode 100644 index 0000000..9817b76 --- /dev/null +++ b/src/BizTalkApplicationCatalog/Models/InventoryModels.cs @@ -0,0 +1,138 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace BizTalkApplicationCatalog.Models +{ + /// + /// Enthält die vollständig normalisierten Ergebnisse eines Inventarlaufs. + /// + internal sealed class InventoryDocument + { + public InventoryDocument() + { + SystemProperties = new List(); + Applications = new List(); + Artifacts = new List(); + Hosts = new List(); + Coverage = new List(); + Findings = new List(); + SectionStatuses = new List(); + } + + public string EnvironmentName { get; set; } + public string ComputerName { get; set; } + public string ToolVersion { get; set; } + public string ManagementServer { get; set; } + public string ManagementDatabase { get; set; } + public DateTime StartedUtc { get; set; } + public DateTime CompletedUtc { get; set; } + public List SystemProperties { get; private set; } + public List Applications { get; private set; } + public List Artifacts { get; private set; } + public List Hosts { get; private set; } + public List Coverage { get; private set; } + public List Findings { get; private set; } + public List SectionStatuses { get; private set; } + + public bool HasRequiredFailure + { + get + { + return SectionStatuses.Any(item => item.Required && item.Status != "Erfolgreich") + || Findings.Any(item => item.Severity == "Fehler"); + } + } + } + + internal sealed class ApplicationRecord + { + public string Name { get; set; } + public string Description { get; set; } + public string Status { get; set; } + public string IsDefault { get; set; } + public string Source { get; set; } + } + + internal sealed class ArtifactRecord + { + public ArtifactRecord() + { + Properties = new List(); + } + + public string Type { get; set; } + public string ApplicationName { get; set; } + public string Name { get; set; } + public string Status { get; set; } + public string HostName { get; set; } + public string AdapterName { get; set; } + public string ParentName { get; set; } + public string Address { get; set; } + public List Properties { get; private set; } + + public string Property(string name) + { + var match = Properties.FirstOrDefault(item => + string.Equals(item.Name, name, StringComparison.OrdinalIgnoreCase)); + return match == null ? string.Empty : match.Value; + } + } + + internal sealed class HostRecord + { + public string Category { get; set; } + public string Name { get; set; } + public string Server { get; set; } + public string Status { get; set; } + public string Type { get; set; } + public string WindowsGroup { get; set; } + public string Is32BitOnly { get; set; } + public string Trusted { get; set; } + public string AdapterName { get; set; } + } + + internal sealed class CoverageRecord + { + public string DataSource { get; set; } + public string Status { get; set; } + public int RowCount { get; set; } + public string Required { get; set; } + public string Message { get; set; } + } + + internal sealed class Finding + { + public string Severity { get; set; } + public string Area { get; set; } + public string Message { get; set; } + public string RecommendedAction { get; set; } + } + + internal sealed class SectionStatus + { + public string Name { get; set; } + public string Status { get; set; } + public string Message { get; set; } + public bool Required { get; set; } + public long DurationMilliseconds { get; set; } + } + + internal sealed class NameValueRecord + { + public NameValueRecord() + { + } + + public NameValueRecord(string name, string value, string source = "") + { + Name = name ?? string.Empty; + Value = value ?? string.Empty; + Source = source ?? string.Empty; + } + + public string Name { get; set; } + public string Value { get; set; } + public string Source { get; set; } + } +} diff --git a/src/BizTalkApplicationCatalog/Program.cs b/src/BizTalkApplicationCatalog/Program.cs new file mode 100644 index 0000000..514e78c --- /dev/null +++ b/src/BizTalkApplicationCatalog/Program.cs @@ -0,0 +1,194 @@ +using System; +using System.Configuration; +using System.Globalization; +using System.IO; +using System.Linq; +using BizTalkApplicationCatalog.Collectors; +using BizTalkApplicationCatalog.Configuration; +using BizTalkApplicationCatalog.Infrastructure; +using BizTalkApplicationCatalog.Models; +using BizTalkApplicationCatalog.Reporting; + +namespace BizTalkApplicationCatalog +{ + /// + /// Orchestriert die read-only Erfassung, die Fortschrittsanzeige und den Excel-Export. + /// + internal static class Program + { + private static int Main(string[] args) + { + CommandLineOptions options; + try + { + options = CommandLineOptions.Parse(args); + } + catch (Exception exception) + { + Console.Error.WriteLine("FEHLER: " + exception.Message); + Console.Error.WriteLine(); + Console.Error.WriteLine(CommandLineOptions.Usage()); + return 2; + } + + if (options.ShowHelp) + { + Console.WriteLine(CommandLineOptions.Usage()); + return 0; + } + if (options.SelfTest) + { + return RunSelfTest(); + } + + try + { + Directory.CreateDirectory(options.OutputDirectory); + } + catch (Exception exception) when ( + exception is IOException + || exception is UnauthorizedAccessException + || exception is ArgumentException) + { + Console.Error.WriteLine("FEHLER: Ausgabeordner kann nicht erstellt werden: " + exception.Message); + return 2; + } + + var timestamp = DateTime.Now.ToString("yyyyMMdd-HHmmss", CultureInfo.InvariantCulture); + var safeMachine = FileName(Environment.MachineName); + var baseName = "BizTalk-Anwendungsinventar-" + + options.EnvironmentName + "-" + safeMachine + "-" + timestamp; + var logPath = Path.Combine(options.OutputDirectory, baseName + ".log"); + var reportPath = Path.Combine(options.OutputDirectory, baseName + ".xlsx"); + + using (var logger = new ConsoleFileLogger(logPath)) + { + var document = new InventoryDocument + { + EnvironmentName = options.EnvironmentName, + ComputerName = Environment.MachineName, + StartedUtc = DateTime.UtcNow + }; + + logger.Info("BEW BizTalk Application Catalog startet."); + logger.Info("Umgebung: " + options.EnvironmentName); + logger.Info("Server: " + Environment.MachineName); + logger.Info("Ausgabeordner: " + options.OutputDirectory); + logger.Info("Modus: ausschließlich lesend; Excel wird ohne Office erzeugt."); + + var safe = new SafeCollector(document, logger); + safe.Execute( + "System und BizTalk-Gruppe", + false, + () => new SystemCollector(options).Collect(document)); + safe.Execute( + "Anwendungen und Artefakte", + true, + () => new BizTalkWmiCollector( + document, + logger, + ReadInt("WmiTimeoutSeconds", 30), + ReadInt("MaxRowsPerArtifactType", 10000)).Collect()); + + Evaluate(document); + document.CompletedUtc = DateTime.UtcNow; + try + { + logger.Info("Erzeuge Microsoft-Excel-Datei: " + reportPath); + new XlsxReportWriter().Write(document, reportPath); + logger.Info("Excel-Datei erfolgreich erzeugt: " + reportPath); + logger.Info("Logdatei: " + logPath); + } + catch (Exception exception) + { + logger.Error("Excel-Datei konnte nicht erzeugt werden: " + exception); + return 2; + } + + logger.Info("Anwendungen: " + document.Applications.Count); + logger.Info("Artefakte: " + document.Artifacts.Count); + logger.Info("Findings: " + document.Findings.Count); + if (document.HasRequiredFailure) + { + logger.Warning("Inventar wurde mit einem Fehler in einem Pflichtabschnitt erzeugt."); + return 1; + } + + logger.Info("Inventarisierung erfolgreich abgeschlossen."); + return 0; + } + } + + private static void Evaluate(InventoryDocument document) + { + if (document.Applications.Count == 0) + { + document.Findings.Add(new Finding + { + Severity = "Fehler", + Area = "Anwendungen", + Message = "Keine installierte BizTalk-Anwendung ermittelt.", + RecommendedAction = "Lokal auf dem BizTalk Server mit ausreichenden WMI-Leserechten ausführen." + }); + } + + var withoutApplication = document.Artifacts.Count(item => + string.IsNullOrWhiteSpace(item.ApplicationName)); + if (withoutApplication > 0) + { + document.Findings.Add(new Finding + { + Severity = "Warnung", + Area = "Artefaktzuordnung", + Message = withoutApplication + " Artefakt(e) besitzen keine von WMI gelieferte Anwendungszuordnung.", + RecommendedAction = "Artefakte im Blatt 'Artefakte' prüfen und bei Bedarf mit der BizTalk Administration Console abgleichen." + }); + } + + if (document.Artifacts.Count == 0) + { + document.Findings.Add(new Finding + { + Severity = "Warnung", + Area = "Artefakte", + Message = "Keine Artefaktdetails wurden ermittelt; die Anwendungsliste kann dennoch vollständig sein.", + RecommendedAction = "Blatt 'Abdeckung' sowie WMI-Klassen und Berechtigungen prüfen." + }); + } + } + + private static int RunSelfTest() + { + try + { + SelfTestRunner.Run(); + Console.WriteLine("Self-Test erfolgreich: XLSX-Paket, Tabellen und Secret-Redaktion sind gültig."); + return 0; + } + catch (Exception exception) + { + Console.Error.WriteLine("SELF-TEST FEHLGESCHLAGEN: " + exception); + return 1; + } + } + + private static int ReadInt(string key, int fallback) + { + int value; + return int.TryParse( + ConfigurationManager.AppSettings[key], + NumberStyles.Integer, + CultureInfo.InvariantCulture, + out value) + ? value + : fallback; + } + + private static string FileName(string value) + { + var invalid = Path.GetInvalidFileNameChars(); + return new string((value ?? "SERVER").Select(character => + invalid.Contains(character) ? '-' : character).ToArray()); + } + } +} diff --git a/src/BizTalkApplicationCatalog/Properties/AssemblyInfo.cs b/src/BizTalkApplicationCatalog/Properties/AssemblyInfo.cs new file mode 100644 index 0000000..17c81b9 --- /dev/null +++ b/src/BizTalkApplicationCatalog/Properties/AssemblyInfo.cs @@ -0,0 +1,14 @@ +using System.Reflection; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; + +[assembly: AssemblyTitle("BEW BizTalk Application Catalog")] +[assembly: AssemblyDescription("Read-only BizTalk 2020 application inventory with Microsoft Excel output")] +[assembly: AssemblyCompany("JR IT Services")] +[assembly: AssemblyProduct("BEW BizTalk Application Catalog")] +[assembly: AssemblyCopyright("Copyright © 2026")] +[assembly: ComVisible(false)] +[assembly: Guid("41cb5701-3fbc-49f4-856a-6ae930b8513d")] +[assembly: InternalsVisibleTo("BizTalkApplicationCatalog.Tests")] +[assembly: AssemblyVersion("1.0.0.0")] +[assembly: AssemblyFileVersion("1.0.0.0")] diff --git a/src/BizTalkApplicationCatalog/Reporting/XlsxReportWriter.cs b/src/BizTalkApplicationCatalog/Reporting/XlsxReportWriter.cs new file mode 100644 index 0000000..3cbeae8 --- /dev/null +++ b/src/BizTalkApplicationCatalog/Reporting/XlsxReportWriter.cs @@ -0,0 +1,793 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.IO.Compression; +using System.Linq; +using System.Text; +using System.Xml; +using BizTalkApplicationCatalog.Infrastructure; +using BizTalkApplicationCatalog.Models; + +namespace BizTalkApplicationCatalog.Reporting +{ + /// + /// Erzeugt eine filterbare Microsoft-Excel-Arbeitsmappe direkt als Office Open XML. + /// Excel oder eine Office-Interop-Installation werden nicht benötigt. + /// + internal sealed class XlsxReportWriter + { + private const string SpreadsheetNamespace = + "http://schemas.openxmlformats.org/spreadsheetml/2006/main"; + private const string RelationshipsNamespace = + "http://schemas.openxmlformats.org/officeDocument/2006/relationships"; + + /// + /// Schreibt atomar: Erst nach erfolgreichem Abschluss ersetzt die temporäre Datei das Ziel. + /// + public void Write(InventoryDocument document, string outputPath) + { + if (document == null) throw new ArgumentNullException("document"); + if (string.IsNullOrWhiteSpace(outputPath)) throw new ArgumentException("Ausgabepfad fehlt."); + + var fullPath = Path.GetFullPath(outputPath); + var directory = Path.GetDirectoryName(fullPath); + if (!string.IsNullOrWhiteSpace(directory)) Directory.CreateDirectory(directory); + var temporaryPath = fullPath + "." + Guid.NewGuid().ToString("N") + ".tmp"; + + try + { + var sheets = BuildSheets(document); + using (var archive = ZipFile.Open(temporaryPath, ZipArchiveMode.Create)) + { + WriteContentTypes(archive, sheets.Count); + WritePackageRelationships(archive); + WriteCoreProperties(archive, document); + WriteApplicationProperties(archive); + WriteWorkbook(archive, sheets); + WriteWorkbookRelationships(archive, sheets.Count); + WriteStyles(archive); + for (var index = 0; index < sheets.Count; index++) + { + WriteWorksheet(archive, index + 1, sheets[index]); + } + } + + if (File.Exists(fullPath)) File.Delete(fullPath); + File.Move(temporaryPath, fullPath); + } + finally + { + if (File.Exists(temporaryPath)) File.Delete(temporaryPath); + } + } + + internal static List BuildSheets(InventoryDocument document) + { + return new List + { + BuildOverview(document), + BuildApplications(document), + BuildArtifacts("Artefakte", document.Artifacts), + BuildArtifacts("Ports", document.Artifacts.Where(item => + item.Type == "Send Port" + || item.Type == "Send Port Group" + || item.Type == "Receive Port" + || item.Type == "Receive Location")), + BuildArtifacts("Orchestrierungen", document.Artifacts.Where(item => + item.Type == "Orchestrierung")), + BuildArtifacts("Schemas-Maps-Pipelines", document.Artifacts.Where(item => + item.Type == "Schema" || item.Type == "Map" || item.Type == "Pipeline")), + BuildArtifacts("Assemblies", document.Artifacts.Where(item => + item.Type == "Assembly")), + BuildHosts(document), + BuildCoverage(document), + BuildFindings(document) + }; + } + + private static SheetDefinition BuildOverview(InventoryDocument document) + { + var rows = new List + { + new object[] { "BEW BizTalk Application Catalog", "" }, + new object[] { "Umgebung", document.EnvironmentName }, + new object[] { "BizTalk Server", document.ComputerName }, + new object[] { "Erzeugt (lokal)", document.CompletedUtc.ToLocalTime().ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture) }, + new object[] { "Toolversion", document.ToolVersion }, + new object[] { "Management SQL Server", Empty(document.ManagementServer) }, + new object[] { "Management Database", Empty(document.ManagementDatabase) }, + new object[] { "Anwendungen gesamt", document.Applications.Count }, + new object[] { "Artefakte gesamt", document.Artifacts.Count }, + new object[] { "Findings", document.Findings.Count }, + new object[] { "", "" }, + new object[] { "Artefakttyp", "Anzahl" } + }; + + foreach (var group in document.Artifacts + .GroupBy(item => item.Type) + .OrderBy(item => item.Key, StringComparer.OrdinalIgnoreCase)) + { + rows.Add(new object[] { group.Key, group.Count() }); + } + + rows.Add(new object[] { "", "" }); + rows.Add(new object[] { "Systemparameter", "Wert", "Quelle" }); + foreach (var property in document.SystemProperties) + { + rows.Add(new object[] { property.Name, property.Value, property.Source }); + } + + rows.Add(new object[] { "", "" }); + rows.Add(new object[] { "Erfassungsabschnitt", "Status", "Pflicht", "Dauer (ms)", "Meldung" }); + foreach (var section in document.SectionStatuses) + { + rows.Add(new object[] + { + section.Name, + section.Status, + section.Required ? "Ja" : "Nein", + section.DurationMilliseconds, + section.Message + }); + } + + return new SheetDefinition("Übersicht", rows, false, 0); + } + + private static SheetDefinition BuildApplications(InventoryDocument document) + { + var headers = new object[] + { + "Umgebung", "Server", "Anwendung", "Status", "Standard", "Beschreibung", + "Artefakte gesamt", "Orchestrierungen", "Send Ports", "Send Port Groups", + "Receive Ports", "Receive Locations", "Assemblies", "Schemas", "Maps", + "Pipelines", "Hosts", "Adapter", "Detailabdeckung" + }; + var rows = new List { headers }; + var coverage = ArtifactCoverage(document); + + foreach (var application in document.Applications) + { + var artifacts = document.Artifacts.Where(item => + string.Equals(item.ApplicationName, application.Name, StringComparison.OrdinalIgnoreCase)).ToList(); + rows.Add(new object[] + { + document.EnvironmentName, + document.ComputerName, + application.Name, + application.Status, + application.IsDefault, + application.Description, + artifacts.Count, + Count(artifacts, "Orchestrierung"), + Count(artifacts, "Send Port"), + Count(artifacts, "Send Port Group"), + Count(artifacts, "Receive Port"), + Count(artifacts, "Receive Location"), + Count(artifacts, "Assembly"), + Count(artifacts, "Schema"), + Count(artifacts, "Map"), + Count(artifacts, "Pipeline"), + JoinDistinct(artifacts.Select(item => item.HostName)), + JoinDistinct(artifacts.Select(item => item.AdapterName)), + coverage + }); + } + + return new SheetDefinition("Anwendungen", rows, true, 1); + } + + private static SheetDefinition BuildArtifacts( + string sheetName, + IEnumerable source) + { + var rows = new List + { + new object[] + { + "Anwendung", "Typ", "Name", "Status", "Host/Handler", "Adapter", + "Übergeordnet", "Adresse", "Beschreibung", "Two-Way", "Dynamisch", + "Deaktiviert", "Receive Pipeline", "Send Pipeline", "Secondary Adapter", + "Secondary Adresse", "Assembly/FullName", "Namespace", "Root", "Tracking" + } + }; + foreach (var item in source) + { + rows.Add(new object[] + { + item.ApplicationName, + item.Type, + item.Name, + item.Status, + item.HostName, + item.AdapterName, + item.ParentName, + item.Address, + item.Property("Description"), + item.Property("IsTwoWay"), + item.Property("IsDynamic"), + item.Property("IsDisabled"), + item.Property("ReceivePipeline"), + item.Property("SendPipeline"), + item.Property("STTransportType"), + SensitiveDataSanitizer.Sanitize(item.Property("STAddress")), + FirstNonEmpty(item.Property("AssemblyName"), item.Property("FullName")), + item.Property("TargetNameSpace"), + item.Property("RootName"), + item.Property("Tracking") + }); + } + return new SheetDefinition(sheetName, rows, true, 1); + } + + private static SheetDefinition BuildHosts(InventoryDocument document) + { + var rows = new List + { + new object[] + { + "Kategorie", "Name", "Server", "Status", "Typ", "Windows-Gruppe", + "Nur 32 Bit", "Vertrauenswürdig", "Adapter" + } + }; + foreach (var item in document.Hosts) + { + rows.Add(new object[] + { + item.Category, item.Name, item.Server, item.Status, item.Type, + item.WindowsGroup, item.Is32BitOnly, item.Trusted, item.AdapterName + }); + } + return new SheetDefinition("Hosts-Handler", rows, true, 1); + } + + private static SheetDefinition BuildCoverage(InventoryDocument document) + { + var rows = new List + { + new object[] { "Datenquelle", "Status", "Zeilen", "Pflicht", "Meldung" } + }; + foreach (var item in document.Coverage) + { + rows.Add(new object[] + { + item.DataSource, item.Status, item.RowCount, item.Required, item.Message + }); + } + return new SheetDefinition("Abdeckung", rows, true, 1); + } + + private static SheetDefinition BuildFindings(InventoryDocument document) + { + var rows = new List + { + new object[] { "Schweregrad", "Bereich", "Feststellung", "Empfohlene Aktion" } + }; + foreach (var item in document.Findings) + { + rows.Add(new object[] + { + item.Severity, item.Area, item.Message, item.RecommendedAction + }); + } + if (document.Findings.Count == 0) + { + rows.Add(new object[] { "Information", "Gesamt", "Keine Findings.", "" }); + } + return new SheetDefinition("Findings", rows, true, 1); + } + + private static string ArtifactCoverage(InventoryDocument document) + { + var incomplete = document.Coverage + .Where(item => item.DataSource.StartsWith("MSBTS_", StringComparison.Ordinal) + && item.DataSource != "MSBTS_Application" + && item.Status != "Vollständig") + .Select(item => item.DataSource + ": " + item.Status) + .ToList(); + return incomplete.Count == 0 + ? "Vollständig" + : "Teilweise – siehe Abdeckung: " + string.Join(", ", incomplete); + } + + private static int Count(IEnumerable artifacts, string type) + { + return artifacts.Count(item => item.Type == type); + } + + private static string JoinDistinct(IEnumerable values) + { + return string.Join( + ", ", + values.Where(item => !string.IsNullOrWhiteSpace(item)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .OrderBy(item => item, StringComparer.OrdinalIgnoreCase)); + } + + private static string FirstNonEmpty(params string[] values) + { + return values.FirstOrDefault(item => !string.IsNullOrWhiteSpace(item)) ?? string.Empty; + } + + private static string Empty(string value) + { + return string.IsNullOrWhiteSpace(value) ? "Nicht ermittelt" : value; + } + + private static void WriteWorksheet(ZipArchive archive, int sheetNumber, SheetDefinition sheet) + { + using (var writer = CreateXmlWriter(archive, "xl/worksheets/sheet" + sheetNumber + ".xml")) + { + writer.WriteStartDocument(); + writer.WriteStartElement("worksheet", SpreadsheetNamespace); + writer.WriteAttributeString("xmlns", "r", null, RelationshipsNamespace); + WriteSheetViews(writer, sheet.FreezeRows); + WriteColumns(writer, sheet); + writer.WriteStartElement("sheetData", SpreadsheetNamespace); + + for (var rowIndex = 0; rowIndex < sheet.Rows.Count; rowIndex++) + { + writer.WriteStartElement("row", SpreadsheetNamespace); + writer.WriteAttributeString("r", (rowIndex + 1).ToString(CultureInfo.InvariantCulture)); + for (var columnIndex = 0; columnIndex < sheet.Rows[rowIndex].Length; columnIndex++) + { + var style = RowStyle(sheet, rowIndex); + WriteCell( + writer, + columnIndex + 1, + rowIndex + 1, + sheet.Rows[rowIndex][columnIndex], + style); + } + writer.WriteEndElement(); + } + writer.WriteEndElement(); + + if (sheet.AutoFilter && sheet.Rows.Count > 0 && sheet.MaximumColumns > 0) + { + writer.WriteStartElement("autoFilter", SpreadsheetNamespace); + writer.WriteAttributeString( + "ref", + "A1:" + ColumnName(sheet.MaximumColumns) + sheet.Rows.Count); + writer.WriteEndElement(); + } + + writer.WriteStartElement("pageMargins", SpreadsheetNamespace); + writer.WriteAttributeString("left", "0.25"); + writer.WriteAttributeString("right", "0.25"); + writer.WriteAttributeString("top", "0.5"); + writer.WriteAttributeString("bottom", "0.5"); + writer.WriteAttributeString("header", "0.2"); + writer.WriteAttributeString("footer", "0.2"); + writer.WriteEndElement(); + writer.WriteEndElement(); + writer.WriteEndDocument(); + } + } + + private static int RowStyle(SheetDefinition sheet, int rowIndex) + { + if (sheet.Name == "Übersicht") + { + if (rowIndex == 0) return 2; + var first = Convert.ToString(sheet.Rows[rowIndex].FirstOrDefault(), CultureInfo.InvariantCulture); + if (first == "Artefakttyp" + || first == "Systemparameter" + || first == "Erfassungsabschnitt") return 1; + return 0; + } + return rowIndex == 0 ? 1 : 0; + } + + private static void WriteSheetViews(XmlWriter writer, int freezeRows) + { + writer.WriteStartElement("sheetViews", SpreadsheetNamespace); + writer.WriteStartElement("sheetView", SpreadsheetNamespace); + writer.WriteAttributeString("workbookViewId", "0"); + if (freezeRows > 0) + { + writer.WriteStartElement("pane", SpreadsheetNamespace); + writer.WriteAttributeString("ySplit", freezeRows.ToString(CultureInfo.InvariantCulture)); + writer.WriteAttributeString("topLeftCell", "A" + (freezeRows + 1)); + writer.WriteAttributeString("activePane", "bottomLeft"); + writer.WriteAttributeString("state", "frozen"); + writer.WriteEndElement(); + } + writer.WriteEndElement(); + writer.WriteEndElement(); + } + + private static void WriteColumns(XmlWriter writer, SheetDefinition sheet) + { + writer.WriteStartElement("cols", SpreadsheetNamespace); + for (var columnIndex = 0; columnIndex < sheet.MaximumColumns; columnIndex++) + { + var width = 10; + foreach (var row in sheet.Rows) + { + if (columnIndex >= row.Length) continue; + var length = Convert.ToString(row[columnIndex], CultureInfo.InvariantCulture).Length + 2; + width = Math.Max(width, Math.Min(60, length)); + } + writer.WriteStartElement("col", SpreadsheetNamespace); + writer.WriteAttributeString("min", (columnIndex + 1).ToString(CultureInfo.InvariantCulture)); + writer.WriteAttributeString("max", (columnIndex + 1).ToString(CultureInfo.InvariantCulture)); + writer.WriteAttributeString("width", width.ToString(CultureInfo.InvariantCulture)); + writer.WriteAttributeString("customWidth", "1"); + writer.WriteEndElement(); + } + writer.WriteEndElement(); + } + + private static void WriteCell( + XmlWriter writer, + int column, + int row, + object value, + int style) + { + writer.WriteStartElement("c", SpreadsheetNamespace); + writer.WriteAttributeString("r", ColumnName(column) + row); + if (style > 0) writer.WriteAttributeString("s", style.ToString(CultureInfo.InvariantCulture)); + if (IsNumber(value)) + { + writer.WriteStartElement("v", SpreadsheetNamespace); + writer.WriteString(Convert.ToString(value, CultureInfo.InvariantCulture)); + writer.WriteEndElement(); + } + else + { + writer.WriteAttributeString("t", "inlineStr"); + writer.WriteStartElement("is", SpreadsheetNamespace); + writer.WriteStartElement("t", SpreadsheetNamespace); + writer.WriteAttributeString("xml", "space", null, "preserve"); + writer.WriteString(ExcelText(Convert.ToString(value, CultureInfo.InvariantCulture))); + writer.WriteEndElement(); + writer.WriteEndElement(); + } + writer.WriteEndElement(); + } + + private static bool IsNumber(object value) + { + return value is byte || value is short || value is int || value is long + || value is float || value is double || value is decimal; + } + + private static string ExcelText(string value) + { + if (string.IsNullOrEmpty(value)) return string.Empty; + var builder = new StringBuilder(Math.Min(value.Length, 32767)); + foreach (var character in value) + { + if (builder.Length >= 32767) break; + if (XmlConvert.IsXmlChar(character)) builder.Append(character); + } + return builder.ToString(); + } + + private static string ColumnName(int number) + { + var result = string.Empty; + while (number > 0) + { + number--; + result = (char)('A' + (number % 26)) + result; + number /= 26; + } + return result; + } + + private static void WriteContentTypes(ZipArchive archive, int sheetCount) + { + using (var writer = CreateXmlWriter(archive, "[Content_Types].xml")) + { + writer.WriteStartDocument(); + writer.WriteStartElement("Types", "http://schemas.openxmlformats.org/package/2006/content-types"); + WriteDefault(writer, "rels", "application/vnd.openxmlformats-package.relationships+xml"); + WriteDefault(writer, "xml", "application/xml"); + WriteOverride(writer, "/xl/workbook.xml", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"); + WriteOverride(writer, "/xl/styles.xml", "application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"); + WriteOverride(writer, "/docProps/core.xml", "application/vnd.openxmlformats-package.core-properties+xml"); + WriteOverride(writer, "/docProps/app.xml", "application/vnd.openxmlformats-officedocument.extended-properties+xml"); + for (var index = 1; index <= sheetCount; index++) + { + WriteOverride( + writer, + "/xl/worksheets/sheet" + index + ".xml", + "application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"); + } + writer.WriteEndElement(); + writer.WriteEndDocument(); + } + } + + private static void WriteDefault(XmlWriter writer, string extension, string type) + { + writer.WriteStartElement("Default"); + writer.WriteAttributeString("Extension", extension); + writer.WriteAttributeString("ContentType", type); + writer.WriteEndElement(); + } + + private static void WriteOverride(XmlWriter writer, string partName, string type) + { + writer.WriteStartElement("Override"); + writer.WriteAttributeString("PartName", partName); + writer.WriteAttributeString("ContentType", type); + writer.WriteEndElement(); + } + + private static void WritePackageRelationships(ZipArchive archive) + { + using (var writer = CreateXmlWriter(archive, "_rels/.rels")) + { + writer.WriteStartDocument(); + writer.WriteStartElement("Relationships", "http://schemas.openxmlformats.org/package/2006/relationships"); + WriteRelationship(writer, "rId1", "http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument", "xl/workbook.xml"); + WriteRelationship(writer, "rId2", "http://schemas.openxmlformats.org/package/2006/relationships/metadata/core-properties", "docProps/core.xml"); + WriteRelationship(writer, "rId3", "http://schemas.openxmlformats.org/officeDocument/2006/relationships/extended-properties", "docProps/app.xml"); + writer.WriteEndElement(); + writer.WriteEndDocument(); + } + } + + private static void WriteWorkbook(ZipArchive archive, List sheets) + { + using (var writer = CreateXmlWriter(archive, "xl/workbook.xml")) + { + writer.WriteStartDocument(); + writer.WriteStartElement("workbook", SpreadsheetNamespace); + writer.WriteAttributeString("xmlns", "r", null, RelationshipsNamespace); + writer.WriteStartElement("bookViews", SpreadsheetNamespace); + writer.WriteStartElement("workbookView", SpreadsheetNamespace); + writer.WriteAttributeString("activeTab", "0"); + writer.WriteEndElement(); + writer.WriteEndElement(); + writer.WriteStartElement("sheets", SpreadsheetNamespace); + for (var index = 0; index < sheets.Count; index++) + { + writer.WriteStartElement("sheet", SpreadsheetNamespace); + writer.WriteAttributeString("name", sheets[index].Name); + writer.WriteAttributeString("sheetId", (index + 1).ToString(CultureInfo.InvariantCulture)); + writer.WriteAttributeString("r", "id", RelationshipsNamespace, "rId" + (index + 1)); + writer.WriteEndElement(); + } + writer.WriteEndElement(); + writer.WriteEndElement(); + writer.WriteEndDocument(); + } + } + + private static void WriteWorkbookRelationships(ZipArchive archive, int sheetCount) + { + using (var writer = CreateXmlWriter(archive, "xl/_rels/workbook.xml.rels")) + { + writer.WriteStartDocument(); + writer.WriteStartElement("Relationships", "http://schemas.openxmlformats.org/package/2006/relationships"); + for (var index = 1; index <= sheetCount; index++) + { + WriteRelationship( + writer, + "rId" + index, + "http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet", + "worksheets/sheet" + index + ".xml"); + } + WriteRelationship( + writer, + "rId" + (sheetCount + 1), + "http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles", + "styles.xml"); + writer.WriteEndElement(); + writer.WriteEndDocument(); + } + } + + private static void WriteRelationship(XmlWriter writer, string id, string type, string target) + { + writer.WriteStartElement("Relationship"); + writer.WriteAttributeString("Id", id); + writer.WriteAttributeString("Type", type); + writer.WriteAttributeString("Target", target); + writer.WriteEndElement(); + } + + private static void WriteStyles(ZipArchive archive) + { + using (var writer = CreateXmlWriter(archive, "xl/styles.xml")) + { + writer.WriteStartDocument(); + writer.WriteStartElement("styleSheet", SpreadsheetNamespace); + writer.WriteStartElement("fonts", SpreadsheetNamespace); + writer.WriteAttributeString("count", "3"); + WriteFont(writer, false, "000000", 10); + WriteFont(writer, true, "FFFFFF", 10); + WriteFont(writer, true, "FFFFFF", 16); + writer.WriteEndElement(); + writer.WriteStartElement("fills", SpreadsheetNamespace); + writer.WriteAttributeString("count", "4"); + WritePatternFill(writer, "none", null); + WritePatternFill(writer, "gray125", null); + WritePatternFill(writer, "solid", "1F4E78"); + WritePatternFill(writer, "solid", "2F75B5"); + writer.WriteEndElement(); + writer.WriteStartElement("borders", SpreadsheetNamespace); + writer.WriteAttributeString("count", "2"); + WriteBorder(writer, false); + WriteBorder(writer, true); + writer.WriteEndElement(); + writer.WriteStartElement("cellStyleXfs", SpreadsheetNamespace); + writer.WriteAttributeString("count", "1"); + WriteXf(writer, 0, 0, 0, false); + writer.WriteEndElement(); + writer.WriteStartElement("cellXfs", SpreadsheetNamespace); + writer.WriteAttributeString("count", "3"); + WriteXf(writer, 0, 0, 0, true); + WriteXf(writer, 1, 2, 1, true); + WriteXf(writer, 2, 3, 1, true); + writer.WriteEndElement(); + writer.WriteStartElement("cellStyles", SpreadsheetNamespace); + writer.WriteAttributeString("count", "1"); + writer.WriteStartElement("cellStyle", SpreadsheetNamespace); + writer.WriteAttributeString("name", "Normal"); + writer.WriteAttributeString("xfId", "0"); + writer.WriteAttributeString("builtinId", "0"); + writer.WriteEndElement(); + writer.WriteEndElement(); + writer.WriteEndElement(); + writer.WriteEndDocument(); + } + } + + private static void WriteFont(XmlWriter writer, bool bold, string color, int size) + { + writer.WriteStartElement("font", SpreadsheetNamespace); + if (bold) writer.WriteElementString("b", SpreadsheetNamespace, string.Empty); + writer.WriteStartElement("sz", SpreadsheetNamespace); + writer.WriteAttributeString("val", size.ToString(CultureInfo.InvariantCulture)); + writer.WriteEndElement(); + writer.WriteStartElement("color", SpreadsheetNamespace); + writer.WriteAttributeString("rgb", "FF" + color); + writer.WriteEndElement(); + writer.WriteStartElement("name", SpreadsheetNamespace); + writer.WriteAttributeString("val", "Calibri"); + writer.WriteEndElement(); + writer.WriteEndElement(); + } + + private static void WritePatternFill(XmlWriter writer, string pattern, string color) + { + writer.WriteStartElement("fill", SpreadsheetNamespace); + writer.WriteStartElement("patternFill", SpreadsheetNamespace); + writer.WriteAttributeString("patternType", pattern); + if (color != null) + { + writer.WriteStartElement("fgColor", SpreadsheetNamespace); + writer.WriteAttributeString("rgb", "FF" + color); + writer.WriteEndElement(); + writer.WriteStartElement("bgColor", SpreadsheetNamespace); + writer.WriteAttributeString("indexed", "64"); + writer.WriteEndElement(); + } + writer.WriteEndElement(); + writer.WriteEndElement(); + } + + private static void WriteBorder(XmlWriter writer, bool thin) + { + writer.WriteStartElement("border", SpreadsheetNamespace); + foreach (var side in new[] { "left", "right", "top", "bottom" }) + { + writer.WriteStartElement(side, SpreadsheetNamespace); + if (thin) + { + writer.WriteAttributeString("style", "thin"); + writer.WriteStartElement("color", SpreadsheetNamespace); + writer.WriteAttributeString("rgb", "FFD9E2F3"); + writer.WriteEndElement(); + } + writer.WriteEndElement(); + } + writer.WriteElementString("diagonal", SpreadsheetNamespace, string.Empty); + writer.WriteEndElement(); + } + + private static void WriteXf( + XmlWriter writer, + int fontId, + int fillId, + int borderId, + bool alignment) + { + writer.WriteStartElement("xf", SpreadsheetNamespace); + writer.WriteAttributeString("numFmtId", "0"); + writer.WriteAttributeString("fontId", fontId.ToString(CultureInfo.InvariantCulture)); + writer.WriteAttributeString("fillId", fillId.ToString(CultureInfo.InvariantCulture)); + writer.WriteAttributeString("borderId", borderId.ToString(CultureInfo.InvariantCulture)); + writer.WriteAttributeString("xfId", "0"); + if (fontId > 0) writer.WriteAttributeString("applyFont", "1"); + if (fillId > 0) writer.WriteAttributeString("applyFill", "1"); + if (borderId > 0) writer.WriteAttributeString("applyBorder", "1"); + if (alignment) + { + writer.WriteAttributeString("applyAlignment", "1"); + writer.WriteStartElement("alignment", SpreadsheetNamespace); + writer.WriteAttributeString("vertical", "top"); + writer.WriteAttributeString("wrapText", "1"); + writer.WriteEndElement(); + } + writer.WriteEndElement(); + } + + private static void WriteCoreProperties(ZipArchive archive, InventoryDocument document) + { + using (var writer = CreateXmlWriter(archive, "docProps/core.xml")) + { + writer.WriteStartDocument(); + writer.WriteStartElement("cp", "coreProperties", "http://schemas.openxmlformats.org/package/2006/metadata/core-properties"); + writer.WriteAttributeString("xmlns", "dc", null, "http://purl.org/dc/elements/1.1/"); + writer.WriteAttributeString("xmlns", "dcterms", null, "http://purl.org/dc/terms/"); + writer.WriteAttributeString("xmlns", "xsi", null, "http://www.w3.org/2001/XMLSchema-instance"); + writer.WriteElementString("dc", "title", "http://purl.org/dc/elements/1.1/", "BizTalk-Anwendungsinventar " + document.EnvironmentName); + writer.WriteElementString("dc", "creator", "http://purl.org/dc/elements/1.1/", "BEW BizTalk Application Catalog"); + writer.WriteElementString("cp", "lastModifiedBy", "http://schemas.openxmlformats.org/package/2006/metadata/core-properties", "BEW BizTalk Application Catalog"); + writer.WriteStartElement("dcterms", "created", "http://purl.org/dc/terms/"); + writer.WriteAttributeString("xsi", "type", "http://www.w3.org/2001/XMLSchema-instance", "dcterms:W3CDTF"); + writer.WriteString(document.CompletedUtc.ToString("yyyy-MM-ddTHH:mm:ssZ", CultureInfo.InvariantCulture)); + writer.WriteEndElement(); + writer.WriteEndElement(); + writer.WriteEndDocument(); + } + } + + private static void WriteApplicationProperties(ZipArchive archive) + { + using (var writer = CreateXmlWriter(archive, "docProps/app.xml")) + { + writer.WriteStartDocument(); + writer.WriteStartElement("Properties", "http://schemas.openxmlformats.org/officeDocument/2006/extended-properties"); + writer.WriteAttributeString("xmlns", "vt", null, "http://schemas.openxmlformats.org/officeDocument/2006/docPropsVTypes"); + writer.WriteElementString("Application", "BEW BizTalk Application Catalog"); + writer.WriteElementString("AppVersion", "1.0"); + writer.WriteEndElement(); + writer.WriteEndDocument(); + } + } + + private static XmlWriter CreateXmlWriter(ZipArchive archive, string path) + { + var entry = archive.CreateEntry(path, CompressionLevel.Optimal); + return XmlWriter.Create(entry.Open(), new XmlWriterSettings + { + Encoding = new UTF8Encoding(false), + Indent = false, + CloseOutput = true + }); + } + + internal sealed class SheetDefinition + { + public SheetDefinition( + string name, + List rows, + bool autoFilter, + int freezeRows) + { + Name = name; + Rows = rows; + AutoFilter = autoFilter; + FreezeRows = freezeRows; + } + + public string Name { get; private set; } + public List Rows { get; private set; } + public bool AutoFilter { get; private set; } + public int FreezeRows { get; private set; } + public int MaximumColumns + { + get { return Rows.Count == 0 ? 0 : Rows.Max(item => item.Length); } + } + } + } +} diff --git a/tests/BizTalkApplicationCatalog.Tests/BizTalkApplicationCatalog.Tests.csproj b/tests/BizTalkApplicationCatalog.Tests/BizTalkApplicationCatalog.Tests.csproj new file mode 100644 index 0000000..0f1a063 --- /dev/null +++ b/tests/BizTalkApplicationCatalog.Tests/BizTalkApplicationCatalog.Tests.csproj @@ -0,0 +1,49 @@ + + + + Debug + AnyCPU + {6B7CE874-8054-46F5-927C-E9B26927F187} + Exe + BizTalkApplicationCatalog.Tests + BizTalkApplicationCatalog.Tests + v4.7.2 + + 512 + 7.3 + true + + + true + full + false + bin\Debug\ + DEBUG;TRACE + 4 + AnyCPU + false + + + pdbonly + true + bin\Release\ + TRACE + 4 + AnyCPU + false + + + + + + + + + + {41CB5701-3FBC-49F4-856A-6AE930B8513D} + BizTalkApplicationCatalog + True + + + + diff --git a/tests/BizTalkApplicationCatalog.Tests/Program.cs b/tests/BizTalkApplicationCatalog.Tests/Program.cs new file mode 100644 index 0000000..985fdd9 --- /dev/null +++ b/tests/BizTalkApplicationCatalog.Tests/Program.cs @@ -0,0 +1,23 @@ +using System; +using BizTalkApplicationCatalog.Infrastructure; + +namespace BizTalkApplicationCatalog.Tests +{ + internal static class Program + { + private static int Main(string[] args) + { + try + { + SelfTestRunner.Run(args.Length > 0 ? args[0] : null); + Console.WriteLine("Alle Tests erfolgreich."); + return 0; + } + catch (Exception exception) + { + Console.Error.WriteLine("TEST FEHLGESCHLAGEN: " + exception); + return 1; + } + } + } +}