Improve certificate path probing with demo UNC target and remove Sonic container scan.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-29 09:14:25 +02:00
co-authored by Cursor
parent f7e9bd43e2
commit f584fd68fe
16 changed files with 1026 additions and 999 deletions
@@ -0,0 +1,302 @@
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using ZA.CoreService.ESBCertificateManager.Models;
namespace ZA.CoreService.ESBCertificateManager.Services;
public sealed class CertificateProbeService
{
public static readonly string[] SupportedExtensions =
[
".cer",
".crt",
".pem",
".pfx",
".p12"
];
public CertificateProbeResult Probe(
string certificatePath,
Func<string?>? passwordPrompt = null)
{
string? normalized = NormalizePath(certificatePath);
if (normalized is null)
{
return CertificateProbeResult.Failed(
"Kein gültiger Zertifikatspfad angegeben.");
}
if (!IsSupportedExtension(normalized))
{
return CertificateProbeResult.Failed(
"Nicht unterstütztes Format. Erlaubt: "
+ string.Join(", ", SupportedExtensions));
}
if (!File.Exists(normalized))
{
return CertificateProbeResult.Missing(
normalized,
"Am Zielpfad liegt noch keine Zertifikatsdatei.");
}
try
{
CertificateInfo info = ReadCertificate(normalized, password: null);
return CertificateProbeResult.Found(normalized, info);
}
catch (CryptographicException) when (IsPasswordProtected(normalized))
{
if (passwordPrompt is null)
{
return CertificateProbeResult.PasswordRequired(normalized);
}
string? password = passwordPrompt.Invoke();
if (password is null)
{
return CertificateProbeResult.Failed(
"Zum Lesen der PFX/P12-Datei wird ein Kennwort benötigt.");
}
try
{
CertificateInfo info = ReadCertificate(normalized, password);
return CertificateProbeResult.Found(normalized, info);
}
catch (CryptographicException)
{
return CertificateProbeResult.Failed(
"Das Kennwort ist falsch oder die Datei ist beschädigt.");
}
}
catch (Exception ex)
{
return CertificateProbeResult.Failed(
$"Zertifikat konnte nicht gelesen werden: {ex.Message}");
}
}
public static bool TrySplitTargetPath(
string fullPath,
out string directory,
out string fileName,
out string? error)
{
directory = string.Empty;
fileName = string.Empty;
error = null;
string? normalized = NormalizePath(fullPath);
if (normalized is null)
{
error = "Bitte einen vollständigen Pfad zur Zertifikatsdatei angeben.";
return false;
}
if (!IsSupportedExtension(normalized))
{
error =
"Dateiendung nicht unterstützt. Erlaubt: "
+ string.Join(", ", SupportedExtensions);
return false;
}
string? dir = Path.GetDirectoryName(normalized);
string file = Path.GetFileName(normalized);
if (string.IsNullOrWhiteSpace(dir) || string.IsNullOrWhiteSpace(file))
{
error =
"Pfad muss Verzeichnis und Dateiname enthalten, z. B.\n"
+ @"\\server\e$\...\certs\server.p12";
return false;
}
if (Path.GetFileName(file) != file)
{
error = "Dateiname darf keinen weiteren Verzeichnisanteil enthalten.";
return false;
}
directory = dir;
fileName = file;
return true;
}
public static string CombineTargetPath(
string directory,
string fileName)
{
return Path.Combine(
directory.Trim().TrimEnd('\\', '/'),
fileName.Trim());
}
public static bool IsSupportedExtension(string path)
{
string extension = Path.GetExtension(path);
return SupportedExtensions.Contains(
extension,
StringComparer.OrdinalIgnoreCase);
}
private static bool IsPasswordProtected(string path)
{
string extension = Path.GetExtension(path);
return extension.Equals(".pfx", StringComparison.OrdinalIgnoreCase)
|| extension.Equals(".p12", StringComparison.OrdinalIgnoreCase);
}
private static string? NormalizePath(string? path)
{
if (string.IsNullOrWhiteSpace(path))
{
return null;
}
string trimmed = path.Trim().Trim('"');
if (trimmed.Length == 0)
{
return null;
}
// UNC und absolute lokale Pfade akzeptieren
if (!Path.IsPathRooted(trimmed)
&& !trimmed.StartsWith(@"\\", StringComparison.Ordinal))
{
return null;
}
return trimmed;
}
private static CertificateInfo ReadCertificate(
string certificatePath,
string? password)
{
string extension = Path.GetExtension(certificatePath);
using X509Certificate2 certificate =
IsPasswordProtected(certificatePath)
? new X509Certificate2(
certificatePath,
password,
X509KeyStorageFlags.EphemeralKeySet)
: new X509Certificate2(certificatePath);
string subject = certificate.GetNameInfo(
X509NameType.SimpleName,
forIssuer: false);
string issuer = certificate.GetNameInfo(
X509NameType.SimpleName,
forIssuer: true);
if (string.IsNullOrWhiteSpace(subject))
{
subject = certificate.Subject;
}
if (string.IsNullOrWhiteSpace(issuer))
{
issuer = certificate.Issuer;
}
string fingerprint = certificate.GetCertHashString(
HashAlgorithmName.SHA256);
fingerprint = string.Join(
":",
Enumerable.Range(0, fingerprint.Length / 2)
.Select(index => fingerprint.Substring(index * 2, 2)));
DateTimeOffset validFrom = new(certificate.NotBefore);
DateTimeOffset validUntil = new(certificate.NotAfter);
DateTimeOffset now = DateTimeOffset.Now;
return new CertificateInfo
{
Subject = subject,
Issuer = issuer,
FingerprintSha256 = fingerprint,
ValidFrom = validFrom,
ValidUntil = validUntil,
IsCurrentlyValid = now >= validFrom && now <= validUntil
};
}
}
public sealed class CertificateProbeResult
{
public bool Success { get; init; }
public bool FileExists { get; init; }
public string Path { get; init; } = string.Empty;
public string Message { get; init; } = string.Empty;
public CertificateInfo? Certificate { get; init; }
public static CertificateProbeResult Found(
string path,
CertificateInfo certificate)
{
string expiry = certificate.ValidUntil.ToLocalTime()
.ToString("dd.MM.yyyy HH:mm");
string validity = certificate.IsCurrentlyValid
? "gültig"
: "abgelaufen / ungültig";
return new CertificateProbeResult
{
Success = true,
FileExists = true,
Path = path,
Certificate = certificate,
Message =
$"Zertifikat gefunden · Subject: {certificate.Subject} · "
+ $"Ablauf: {expiry} ({validity})"
};
}
public static CertificateProbeResult Missing(
string path,
string message)
{
return new CertificateProbeResult
{
Success = true,
FileExists = false,
Path = path,
Message = message
};
}
public static CertificateProbeResult Failed(string message)
{
return new CertificateProbeResult
{
Success = false,
FileExists = false,
Message = message
};
}
public static CertificateProbeResult PasswordRequired(string path)
{
return new CertificateProbeResult
{
Success = false,
FileExists = true,
Path = path,
Message = "PFX/P12 vorhanden Kennwort nötig zum Lesen des Ablaufs"
};
}
}
@@ -8,7 +8,8 @@ public sealed class LocalCertificateDeployer
".cer",
".crt",
".pem",
".pfx"
".pfx",
".p12"
};
public async Task<string> DeployAsync(
@@ -1,303 +0,0 @@
using System.Diagnostics;
using System.Text;
using ZA.CoreService.ESBCertificateManager.Models;
namespace ZA.CoreService.ESBCertificateManager.Services;
public sealed class SonicContainerScanner
{
private readonly RuntimeSettings _runtimeSettings;
public SonicContainerScanner(RuntimeSettings runtimeSettings)
{
_runtimeSettings = runtimeSettings
?? throw new ArgumentNullException(nameof(runtimeSettings));
}
public async Task<ContainerScanResult> ScanAsync(
SonicSystemOption system,
string userName,
string secret,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(system);
if (string.IsNullOrWhiteSpace(userName))
{
return ContainerScanResult.Failed(
"Der Sonic-Benutzername fehlt.");
}
if (string.IsNullOrEmpty(secret))
{
return ContainerScanResult.Failed(
"Das Sonic-Kennwort fehlt.");
}
string javaPath =
PathResolver.ResolvePath(
_runtimeSettings.JavaExecutablePath);
string libraryPath =
PathResolver.ResolvePath(
_runtimeSettings.SonicClientLibraryPath);
string? toolsPath = ResolveToolsDirectory();
if (!File.Exists(javaPath))
{
return ContainerScanResult.Failed(
$"Java wurde nicht gefunden: {javaPath}");
}
if (!Directory.Exists(libraryPath))
{
return ContainerScanResult.Failed(
$"Der Sonic-JAR-Ordner wurde nicht gefunden: {libraryPath}");
}
if (toolsPath is null)
{
return ContainerScanResult.Failed(
"SonicMfContainerTool.class wurde nicht gefunden.");
}
string[] jars =
Directory.GetFiles(
libraryPath,
"*.jar",
SearchOption.TopDirectoryOnly);
if (jars.Length == 0)
{
return ContainerScanResult.Failed(
"Im Sonic-Client-Ordner wurden keine JAR-Dateien gefunden.");
}
string classpath =
toolsPath
+ Path.PathSeparator
+ string.Join(
Path.PathSeparator,
jars.OrderBy(
Path.GetFileName,
StringComparer.OrdinalIgnoreCase));
ProcessStartInfo startInfo = new()
{
FileName = javaPath,
WorkingDirectory = toolsPath,
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
StandardOutputEncoding = Encoding.UTF8,
StandardErrorEncoding = Encoding.UTF8,
CreateNoWindow = true
};
startInfo.ArgumentList.Add("-cp");
startInfo.ArgumentList.Add(classpath);
startInfo.ArgumentList.Add("SonicMfContainerTool");
startInfo.ArgumentList.Add("list");
startInfo.ArgumentList.Add("--domain");
startInfo.ArgumentList.Add(system.DomainName);
startInfo.ArgumentList.Add("--url");
startInfo.ArgumentList.Add(system.ConnectionUrl);
startInfo.ArgumentList.Add("--user");
startInfo.ArgumentList.Add(userName.Trim());
startInfo.ArgumentList.Add("--timeout");
startInfo.ArgumentList.Add("45");
startInfo.Environment["ESB_SONIC_PASSWORD"] = secret;
using Process process = new()
{
StartInfo = startInfo
};
try
{
if (!process.Start())
{
return ContainerScanResult.Failed(
"Der Java-Prozess konnte nicht gestartet werden.");
}
}
catch (Exception ex)
{
return ContainerScanResult.Failed(
$"Java konnte nicht gestartet werden: {ex.Message}");
}
Task<string> standardOutputTask =
process.StandardOutput.ReadToEndAsync();
Task<string> standardErrorTask =
process.StandardError.ReadToEndAsync();
using CancellationTokenSource timeoutSource =
CancellationTokenSource.CreateLinkedTokenSource(
cancellationToken);
timeoutSource.CancelAfter(TimeSpan.FromSeconds(50));
try
{
await process.WaitForExitAsync(timeoutSource.Token);
}
catch (OperationCanceledException)
when (!cancellationToken.IsCancellationRequested)
{
TryKill(process);
return ContainerScanResult.Failed(
"Der Container-Scan hat das Zeitlimit überschritten.");
}
catch (OperationCanceledException)
{
TryKill(process);
throw;
}
string standardOutput =
(await standardOutputTask).Trim();
string standardError =
(await standardErrorTask).Trim();
string combined =
string.Join(
Environment.NewLine,
new[] { standardOutput, standardError }
.Where(value => !string.IsNullOrWhiteSpace(value)));
bool success =
process.ExitCode == 0
&& combined.Contains(
"OK:ContainerListEnd",
StringComparison.OrdinalIgnoreCase)
&& !combined.Contains(
"ERROR:",
StringComparison.OrdinalIgnoreCase);
if (!success)
{
return ContainerScanResult.Failed(
ExtractError(combined)
?? $"Container-Scan fehlgeschlagen, ExitCode={process.ExitCode}.");
}
List<string> containers = [];
foreach (string line in standardOutput.Split(
['\r', '\n'],
StringSplitOptions.RemoveEmptyEntries))
{
const string prefix = "CONTAINER=";
if (!line.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
{
continue;
}
string name = line[prefix.Length..].Trim();
if (name.Length > 0
&& !containers.Contains(name, StringComparer.OrdinalIgnoreCase))
{
containers.Add(name);
}
}
containers.Sort(StringComparer.OrdinalIgnoreCase);
return ContainerScanResult.Successful(containers);
}
private static string? ResolveToolsDirectory()
{
string baseDirectory = AppContext.BaseDirectory;
string[] candidates =
[
Path.Combine(baseDirectory, "Tools"),
Path.Combine(baseDirectory, "..", "..", "..", "Tools"),
Path.Combine(
Directory.GetCurrentDirectory(),
"Tools")
];
foreach (string candidate in candidates)
{
string fullPath = Path.GetFullPath(candidate);
string classFile = Path.Combine(
fullPath,
"SonicMfContainerTool.class");
if (File.Exists(classFile))
{
return fullPath;
}
}
return null;
}
private static string? ExtractError(string output)
{
foreach (string line in output.Split(
['\r', '\n'],
StringSplitOptions.RemoveEmptyEntries))
{
if (line.StartsWith("ERROR:", StringComparison.OrdinalIgnoreCase))
{
return line["ERROR:".Length..].Trim();
}
}
return string.IsNullOrWhiteSpace(output) ? null : output;
}
private static void TryKill(Process process)
{
try
{
if (!process.HasExited)
{
process.Kill(entireProcessTree: true);
}
}
catch
{
}
}
}
public sealed class ContainerScanResult
{
public bool Success { get; init; }
public string Message { get; init; } = string.Empty;
public IReadOnlyList<string> Containers { get; init; } = [];
public static ContainerScanResult Successful(
IReadOnlyList<string> containers)
{
return new ContainerScanResult
{
Success = true,
Message = $"{containers.Count} Container gefunden.",
Containers = containers
};
}
public static ContainerScanResult Failed(string message)
{
return new ContainerScanResult
{
Success = false,
Message = message
};
}
}