Add live TLS certificate probing and improve restart error handling.

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

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-03 13:16:21 +02:00
co-authored by Cursor
parent d20e0df618
commit 58b7826162
1430 changed files with 358394 additions and 71 deletions
@@ -0,0 +1,235 @@
using System.Net.Security;
using System.Net.Sockets;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using System.Text.RegularExpressions;
using ZA.CoreService.ESBCertificateManager.Models;
namespace ZA.CoreService.ESBCertificateManager.Services;
/// <summary>
/// Liest das präsentierte TLS-Zertifikat eines Endpoints
/// (entspricht grob <c>curl -v https://host:port</c>).
/// </summary>
public sealed class TlsEndpointProbeService
{
private static readonly Regex HostPortRegex = new(
@"^(?<host>[^:/]+)(:(?<port>\d{1,5}))?$",
RegexOptions.Compiled | RegexOptions.CultureInvariant);
public CertificateProbeResult Probe(
string? checkUrl,
int timeoutMilliseconds = 8000)
{
if (!TryParseEndpoint(
checkUrl,
out string host,
out int port,
out string serverName,
out string? parseError))
{
return CertificateProbeResult.Failed(
parseError ?? "Ungültige Prüf-URL.");
}
try
{
using CancellationTokenSource cts = new(timeoutMilliseconds);
using TcpClient client = new();
using (cts.Token.Register(
() =>
{
try
{
client.Close();
}
catch
{
// Timeout beendet die Verbindung.
}
}))
{
client.Connect(host, port);
}
using SslStream ssl = new(
client.GetStream(),
leaveInnerStreamOpen: false,
userCertificateValidationCallback:
static (_, _, _, _) => true);
ssl.AuthenticateAsClient(serverName);
if (ssl.RemoteCertificate is null)
{
return CertificateProbeResult.Failed(
$"Kein Zertifikat von {host}:{port} erhalten.");
}
using X509Certificate2 certificate = new(ssl.RemoteCertificate);
CertificateInfo info = ToCertificateInfo(certificate);
return CertificateProbeResult.Found(
$"{host}:{port}",
info);
}
catch (Exception ex)
{
return CertificateProbeResult.Failed(
$"TLS-Prüfung {host}:{port} fehlgeschlagen: {ex.Message}");
}
}
public static bool TryParseEndpoint(
string? value,
out string host,
out int port,
out string serverName,
out string? error)
{
host = string.Empty;
port = 443;
serverName = string.Empty;
error = null;
if (string.IsNullOrWhiteSpace(value))
{
error = "Keine Prüf-URL hinterlegt.";
return false;
}
string trimmed = value.Trim();
if (Uri.TryCreate(trimmed, UriKind.Absolute, out Uri? uri)
&& (uri.Scheme.Equals("https", StringComparison.OrdinalIgnoreCase)
|| uri.Scheme.Equals("http", StringComparison.OrdinalIgnoreCase)
|| uri.Scheme.Equals("ssl", StringComparison.OrdinalIgnoreCase)
|| uri.Scheme.Equals("tcp", StringComparison.OrdinalIgnoreCase)))
{
if (string.IsNullOrWhiteSpace(uri.Host))
{
error = "Host in der Prüf-URL fehlt.";
return false;
}
host = uri.Host;
serverName = uri.Host;
port = uri.IsDefaultPort
? (uri.Scheme.Equals("http", StringComparison.OrdinalIgnoreCase)
? 80
: 443)
: uri.Port;
if (port is < 1 or > 65535)
{
error = "Port muss zwischen 1 und 65535 liegen.";
return false;
}
return true;
}
Match match = HostPortRegex.Match(trimmed);
if (!match.Success)
{
error =
"Prüf-URL ungültig. Beispiele:\n"
+ "https://server:8443\n"
+ "server:8443\n"
+ "server";
return false;
}
host = match.Groups["host"].Value;
serverName = host;
if (match.Groups["port"].Success)
{
if (!int.TryParse(match.Groups["port"].Value, out port)
|| port is < 1 or > 65535)
{
error = "Port muss zwischen 1 und 65535 liegen.";
return false;
}
}
return !string.IsNullOrWhiteSpace(host);
}
public static string? NormalizeCheckUrl(string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
return null;
}
string trimmed = value.Trim();
if (!TryParseEndpoint(
trimmed,
out _,
out _,
out _,
out string? error))
{
throw new ArgumentException(
error ?? "Ungültige Prüf-URL.",
nameof(value));
}
if (trimmed.Length > 500)
{
throw new ArgumentException(
"Die Prüf-URL darf maximal 500 Zeichen enthalten.",
nameof(value));
}
return trimmed;
}
private static CertificateInfo ToCertificateInfo(
X509Certificate2 certificate)
{
string subject = certificate.GetNameInfo(
X509NameType.SimpleName,
forIssuer: false);
string issuer = certificate.GetNameInfo(
X509NameType.SimpleName,
forIssuer: true);
if (string.IsNullOrWhiteSpace(subject))
{
subject = certificate.Subject;
}
if (string.IsNullOrWhiteSpace(issuer))
{
issuer = certificate.Issuer;
}
string fingerprint = certificate.GetCertHashString(
HashAlgorithmName.SHA256);
fingerprint = string.Join(
":",
Enumerable.Range(0, fingerprint.Length / 2)
.Select(index => fingerprint.Substring(index * 2, 2)));
DateTimeOffset validFrom = new(certificate.NotBefore);
DateTimeOffset validUntil = new(certificate.NotAfter);
DateTimeOffset now = DateTimeOffset.Now;
return new CertificateInfo
{
Subject = subject,
Issuer = issuer,
FingerprintSha256 = fingerprint,
ValidFrom = validFrom,
ValidUntil = validUntil,
IsCurrentlyValid = now >= validFrom && now <= validUntil
};
}
}