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>
236 lines
6.7 KiB
C#
236 lines
6.7 KiB
C#
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
|
|
};
|
|
}
|
|
}
|