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:
@@ -34,7 +34,20 @@ public sealed class CertificateProbeService
|
||||
+ string.Join(", ", SupportedExtensions));
|
||||
}
|
||||
|
||||
if (!File.Exists(normalized))
|
||||
PathReachability reachability = AssessPathReachability(normalized);
|
||||
|
||||
if (reachability == PathReachability.AccessDenied)
|
||||
{
|
||||
return CertificateProbeResult.ForAccessDenied(normalized);
|
||||
}
|
||||
|
||||
if (reachability == PathReachability.Unreachable)
|
||||
{
|
||||
return CertificateProbeResult.Failed(
|
||||
"Pfad nicht erreichbar.");
|
||||
}
|
||||
|
||||
if (reachability == PathReachability.FileMissing)
|
||||
{
|
||||
return CertificateProbeResult.Missing(
|
||||
normalized,
|
||||
@@ -72,6 +85,14 @@ public sealed class CertificateProbeService
|
||||
"Das Kennwort ist falsch oder die Datei ist beschädigt.");
|
||||
}
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
return CertificateProbeResult.ForAccessDenied(normalized);
|
||||
}
|
||||
catch (IOException ex) when (IsAccessOrLogonFailure(ex))
|
||||
{
|
||||
return CertificateProbeResult.ForAccessDenied(normalized);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return CertificateProbeResult.Failed(
|
||||
@@ -151,6 +172,154 @@ public sealed class CertificateProbeService
|
||||
|| extension.Equals(".p12", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private enum PathReachability
|
||||
{
|
||||
FileExists,
|
||||
FileMissing,
|
||||
AccessDenied,
|
||||
Unreachable
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Unterscheidet „Datei fehlt“ von „Share/Pfad ohne Admin-Konto nicht erreichbar“.
|
||||
/// File.Exists liefert bei fehlenden Credentials oft nur false.
|
||||
/// </summary>
|
||||
private static PathReachability AssessPathReachability(string filePath)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (File.Exists(filePath))
|
||||
{
|
||||
return PathReachability.FileExists;
|
||||
}
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
return PathReachability.AccessDenied;
|
||||
}
|
||||
catch (IOException ex) when (IsAccessOrLogonFailure(ex))
|
||||
{
|
||||
return PathReachability.AccessDenied;
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
return PathReachability.Unreachable;
|
||||
}
|
||||
|
||||
string? directory = Path.GetDirectoryName(filePath);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(directory))
|
||||
{
|
||||
return PathReachability.Unreachable;
|
||||
}
|
||||
|
||||
return AssessDirectoryReachability(directory);
|
||||
}
|
||||
|
||||
private static PathReachability AssessDirectoryReachability(string directory)
|
||||
{
|
||||
string current = directory.TrimEnd('\\', '/');
|
||||
|
||||
while (!string.IsNullOrWhiteSpace(current))
|
||||
{
|
||||
try
|
||||
{
|
||||
if (Directory.Exists(current))
|
||||
{
|
||||
// Exists kann bei UNC ohne Rechte „lügen“ – Auflisten erzwingen.
|
||||
_ = Directory.EnumerateFileSystemEntries(current)
|
||||
.Any();
|
||||
return PathReachability.FileMissing;
|
||||
}
|
||||
|
||||
if (current.StartsWith(@"\\", StringComparison.Ordinal))
|
||||
{
|
||||
// Exists=false: echter Zugriffsfehler oder Ordner fehlt.
|
||||
_ = Directory.GetFileSystemEntries(current);
|
||||
return PathReachability.FileMissing;
|
||||
}
|
||||
|
||||
// Lokaler Ordner fehlt → für Erst-Deployment als „Datei fehlt“ werten.
|
||||
return PathReachability.FileMissing;
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
return PathReachability.AccessDenied;
|
||||
}
|
||||
catch (IOException ex) when (IsAccessOrLogonFailure(ex))
|
||||
{
|
||||
return PathReachability.AccessDenied;
|
||||
}
|
||||
catch (DirectoryNotFoundException)
|
||||
{
|
||||
string? parent = GetParentPath(current);
|
||||
|
||||
if (parent is null || parent == current)
|
||||
{
|
||||
return current.StartsWith(@"\\", StringComparison.Ordinal)
|
||||
? PathReachability.Unreachable
|
||||
: PathReachability.FileMissing;
|
||||
}
|
||||
|
||||
current = parent;
|
||||
continue;
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
return PathReachability.Unreachable;
|
||||
}
|
||||
}
|
||||
|
||||
return PathReachability.Unreachable;
|
||||
}
|
||||
|
||||
private static string? GetParentPath(string path)
|
||||
{
|
||||
string trimmed = path.TrimEnd('\\', '/');
|
||||
|
||||
if (trimmed.StartsWith(@"\\", StringComparison.Ordinal))
|
||||
{
|
||||
// \\server\share → Stop; darunter weiter nach oben.
|
||||
string withoutPrefix = trimmed[2..];
|
||||
int slash = withoutPrefix.IndexOfAny(['\\', '/']);
|
||||
|
||||
if (slash < 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
int second = withoutPrefix.IndexOfAny(['\\', '/'], slash + 1);
|
||||
|
||||
if (second < 0)
|
||||
{
|
||||
// Bereits Share-Root \\server\share
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
string? parent = Path.GetDirectoryName(trimmed);
|
||||
return string.IsNullOrWhiteSpace(parent) ? null : parent;
|
||||
}
|
||||
|
||||
private static bool IsAccessOrLogonFailure(IOException ex)
|
||||
{
|
||||
// HRESULT-Lower-Word = Win32-Fehlercode
|
||||
int win32 = ex.HResult & 0xFFFF;
|
||||
|
||||
return win32 is
|
||||
5 or // ERROR_ACCESS_DENIED
|
||||
53 or // ERROR_BAD_NETPATH
|
||||
67 or // ERROR_BAD_NET_NAME
|
||||
86 or // ERROR_INVALID_PASSWORD
|
||||
1326 or // ERROR_LOGON_FAILURE
|
||||
59 or // ERROR_UNEXP_NET_ERR
|
||||
64 or // ERROR_NETNAME_DELETED
|
||||
1219 or // ERROR_SESSION_CREDENTIAL_CONFLICT
|
||||
1240 or // ERROR_LOGIN_WKSTA_RESTRICTION
|
||||
1245 or // ERROR_ACCOUNT_RESTRICTION
|
||||
1396; // ERROR_WRONG_TARGET_NAME
|
||||
}
|
||||
|
||||
private static string? NormalizePath(string? path)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path))
|
||||
@@ -233,10 +402,14 @@ public sealed class CertificateProbeService
|
||||
|
||||
public sealed class CertificateProbeResult
|
||||
{
|
||||
public const string AccessDeniedShortText = "Admin nötig";
|
||||
|
||||
public bool Success { get; init; }
|
||||
|
||||
public bool FileExists { get; init; }
|
||||
|
||||
public bool AccessDenied { get; init; }
|
||||
|
||||
public string Path { get; init; } = string.Empty;
|
||||
|
||||
public string Message { get; init; } = string.Empty;
|
||||
@@ -289,6 +462,19 @@ public sealed class CertificateProbeResult
|
||||
};
|
||||
}
|
||||
|
||||
public static CertificateProbeResult ForAccessDenied(string path)
|
||||
{
|
||||
return new CertificateProbeResult
|
||||
{
|
||||
Success = false,
|
||||
FileExists = false,
|
||||
AccessDenied = true,
|
||||
Path = path,
|
||||
Message =
|
||||
"Kein Zugriff auf den Pfad – Admin-Konto nötig."
|
||||
};
|
||||
}
|
||||
|
||||
public static CertificateProbeResult PasswordRequired(string path)
|
||||
{
|
||||
return new CertificateProbeResult
|
||||
|
||||
@@ -43,6 +43,7 @@ public sealed class DeploymentTargetRepository
|
||||
container.[SonicContainerId],
|
||||
container.[ContainerName],
|
||||
container.[ContainerDisplayName],
|
||||
container.[CertificateCheckUrl],
|
||||
container.[RestartTimeoutSeconds],
|
||||
|
||||
connection.[ConnectionName],
|
||||
@@ -119,6 +120,9 @@ public sealed class DeploymentTargetRepository
|
||||
int containerDisplayNameOrdinal =
|
||||
reader.GetOrdinal("ContainerDisplayName");
|
||||
|
||||
int certificateCheckUrlOrdinal =
|
||||
reader.GetOrdinal("CertificateCheckUrl");
|
||||
|
||||
int restartTimeoutOrdinal =
|
||||
reader.GetOrdinal("RestartTimeoutSeconds");
|
||||
|
||||
@@ -144,6 +148,11 @@ public sealed class DeploymentTargetRepository
|
||||
reader,
|
||||
containerDisplayNameOrdinal);
|
||||
|
||||
string? certificateCheckUrl =
|
||||
ReadNullableString(
|
||||
reader,
|
||||
certificateCheckUrlOrdinal);
|
||||
|
||||
string companyCode =
|
||||
reader.GetString(companyCodeOrdinal);
|
||||
|
||||
@@ -152,6 +161,22 @@ public sealed class DeploymentTargetRepository
|
||||
? containerName
|
||||
: containerDisplayName;
|
||||
|
||||
string tlsHost = string.Empty;
|
||||
int? tlsPort = null;
|
||||
string tlsServerName = string.Empty;
|
||||
|
||||
if (TlsEndpointProbeService.TryParseEndpoint(
|
||||
certificateCheckUrl,
|
||||
out string host,
|
||||
out int port,
|
||||
out string serverName,
|
||||
out _))
|
||||
{
|
||||
tlsHost = host;
|
||||
tlsPort = port;
|
||||
tlsServerName = serverName;
|
||||
}
|
||||
|
||||
targets.Add(new DeploymentTarget
|
||||
{
|
||||
Id = reader.GetInt32(targetIdOrdinal),
|
||||
@@ -188,11 +213,11 @@ public sealed class DeploymentTargetRepository
|
||||
RestartTimeoutSeconds =
|
||||
reader.GetInt32(restartTimeoutOrdinal),
|
||||
|
||||
TlsHost = string.Empty,
|
||||
TlsHost = tlsHost,
|
||||
|
||||
TlsPort = null,
|
||||
TlsPort = tlsPort,
|
||||
|
||||
TlsServerName = string.Empty,
|
||||
TlsServerName = tlsServerName,
|
||||
|
||||
ExpectedFingerprint = null,
|
||||
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
namespace ZA.CoreService.ESBCertificateManager.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Erkennt typische Sonic-/MfApi-Fehlermeldungen und liefert
|
||||
/// verständliche Hinweise für die UI.
|
||||
/// </summary>
|
||||
public static class SonicErrorClassifier
|
||||
{
|
||||
private static readonly string[] PermissionMarkers =
|
||||
[
|
||||
"ManagePermissionDenied",
|
||||
"ConfigurePermissionDenied",
|
||||
"ManagementPermissionDenied",
|
||||
"PermissionDenied",
|
||||
"MFSecurityException",
|
||||
"SecurityException",
|
||||
"Access is denied",
|
||||
"Access denied",
|
||||
"not authorized",
|
||||
"nicht autorisiert",
|
||||
"permission denied",
|
||||
"keine Berechtigung",
|
||||
"Insufficient privileges",
|
||||
"Unauthorized"
|
||||
];
|
||||
|
||||
public static bool LooksLikeMissingPermission(string? text)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(text))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach (string marker in PermissionMarkers)
|
||||
{
|
||||
if (text.Contains(marker, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public static string PermissionDeniedUserMessage =>
|
||||
"Keine Rechte für den Neustart.\n\n"
|
||||
+ "Der hinterlegte Sonic-Benutzer darf diesen Container "
|
||||
+ "nicht neu starten (Manage-/Restart-Recht fehlt).\n\n"
|
||||
+ "Bitte in der Sonic Management Console die Rechte prüfen "
|
||||
+ "oder ein Profil mit ausreichenden Rechten verwenden.";
|
||||
|
||||
public static string FormatRestartFailure(
|
||||
string? status,
|
||||
string? detail)
|
||||
{
|
||||
string combined = string.Join(
|
||||
Environment.NewLine,
|
||||
new[] { status, detail }
|
||||
.Where(value => !string.IsNullOrWhiteSpace(value)));
|
||||
|
||||
if (LooksLikeMissingPermission(combined))
|
||||
{
|
||||
return PermissionDeniedUserMessage
|
||||
+ Environment.NewLine
|
||||
+ Environment.NewLine
|
||||
+ "--- Technische Details ---"
|
||||
+ Environment.NewLine
|
||||
+ combined;
|
||||
}
|
||||
|
||||
return string.IsNullOrWhiteSpace(combined)
|
||||
? "Neustart fehlgeschlagen (keine Details)."
|
||||
: combined;
|
||||
}
|
||||
}
|
||||
@@ -34,9 +34,21 @@ public sealed class SonicManagementClient : IDisposable
|
||||
$"Domain={_connection.DomainName}; URL={_connection.ConnectionUrl}\n{output}");
|
||||
}
|
||||
|
||||
return (false, "Neustart fehlgeschlagen (MfApi)",
|
||||
$"Container '{containerName}', Domain '{_connection.DomainName}', URL {_connection.ConnectionUrl}\n" +
|
||||
$"Fehler: {error}\n\n{output}");
|
||||
string technical =
|
||||
$"Container '{containerName}', Domain '{_connection.DomainName}', URL {_connection.ConnectionUrl}\n"
|
||||
+ $"Fehler: {error}\n\n{output}";
|
||||
|
||||
if (SonicErrorClassifier.LooksLikeMissingPermission(technical))
|
||||
{
|
||||
return (
|
||||
false,
|
||||
"Keine Rechte für den Neustart",
|
||||
SonicErrorClassifier.FormatRestartFailure(
|
||||
"Keine Rechte für den Neustart",
|
||||
technical));
|
||||
}
|
||||
|
||||
return (false, "Neustart fehlgeschlagen (MfApi)", technical);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
|
||||
@@ -380,6 +380,228 @@ public sealed class SonicSetupRepository
|
||||
}
|
||||
}
|
||||
|
||||
public async Task UpdateCredentialAsync(
|
||||
int sonicCredentialId,
|
||||
int sonicConnectionId,
|
||||
string credentialName,
|
||||
string userName,
|
||||
string secret,
|
||||
bool isDefault,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (sonicCredentialId <= 0)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(
|
||||
nameof(sonicCredentialId));
|
||||
}
|
||||
|
||||
if (sonicConnectionId <= 0)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(
|
||||
nameof(sonicConnectionId));
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(credentialName))
|
||||
{
|
||||
throw new ArgumentException(
|
||||
"Der Profilname fehlt.",
|
||||
nameof(credentialName));
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(userName))
|
||||
{
|
||||
throw new ArgumentException(
|
||||
"Der Sonic-Benutzername fehlt.",
|
||||
nameof(userName));
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(secret))
|
||||
{
|
||||
throw new ArgumentException(
|
||||
"Das Sonic-Kennwort fehlt.",
|
||||
nameof(secret));
|
||||
}
|
||||
|
||||
string normalizedCredentialName =
|
||||
credentialName.Trim();
|
||||
|
||||
string normalizedUserName =
|
||||
userName.Trim();
|
||||
|
||||
if (normalizedCredentialName.Length > 150)
|
||||
{
|
||||
throw new ArgumentException(
|
||||
"Der Profilname darf maximal 150 Zeichen enthalten.",
|
||||
nameof(credentialName));
|
||||
}
|
||||
|
||||
if (normalizedUserName.Length > 256)
|
||||
{
|
||||
throw new ArgumentException(
|
||||
"Der Benutzername darf maximal 256 Zeichen enthalten.",
|
||||
nameof(userName));
|
||||
}
|
||||
|
||||
if (secret.Length > 512)
|
||||
{
|
||||
throw new ArgumentException(
|
||||
"Das Kennwort darf maximal 512 Zeichen enthalten.",
|
||||
nameof(secret));
|
||||
}
|
||||
|
||||
await using SqlConnection connection =
|
||||
new(_connectionString);
|
||||
|
||||
await connection.OpenAsync(cancellationToken);
|
||||
|
||||
await using SqlTransaction transaction =
|
||||
(SqlTransaction)
|
||||
await connection.BeginTransactionAsync(
|
||||
cancellationToken);
|
||||
|
||||
try
|
||||
{
|
||||
if (isDefault)
|
||||
{
|
||||
const string clearDefaultSql = """
|
||||
UPDATE [dbo].[SonicCredential]
|
||||
SET
|
||||
[IsDefault] = 0,
|
||||
[ModifiedDateTime] = SYSUTCDATETIME(),
|
||||
[ModifiedBy] = SUSER_SNAME()
|
||||
WHERE [SonicConnectionId] =
|
||||
@SonicConnectionId
|
||||
AND [IsDefault] = 1
|
||||
AND [SonicCredentialId] <>
|
||||
@SonicCredentialId;
|
||||
""";
|
||||
|
||||
await using SqlCommand clearDefaultCommand =
|
||||
new(
|
||||
clearDefaultSql,
|
||||
connection,
|
||||
transaction);
|
||||
|
||||
clearDefaultCommand.Parameters.Add(
|
||||
new SqlParameter(
|
||||
"@SonicConnectionId",
|
||||
SqlDbType.Int)
|
||||
{
|
||||
Value = sonicConnectionId
|
||||
});
|
||||
|
||||
clearDefaultCommand.Parameters.Add(
|
||||
new SqlParameter(
|
||||
"@SonicCredentialId",
|
||||
SqlDbType.Int)
|
||||
{
|
||||
Value = sonicCredentialId
|
||||
});
|
||||
|
||||
await clearDefaultCommand.ExecuteNonQueryAsync(
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
const string updateSql = """
|
||||
UPDATE [dbo].[SonicCredential]
|
||||
SET
|
||||
[CredentialName] = @CredentialName,
|
||||
[CredentialUserName] = @CredentialUserName,
|
||||
[CredentialSecret] = @CredentialSecret,
|
||||
[IsDefault] = @IsDefault,
|
||||
[ModifiedDateTime] = SYSUTCDATETIME(),
|
||||
[ModifiedBy] = SUSER_SNAME()
|
||||
WHERE [SonicCredentialId] = @SonicCredentialId
|
||||
AND [SonicConnectionId] = @SonicConnectionId
|
||||
AND [IsActive] = 1;
|
||||
""";
|
||||
|
||||
await using SqlCommand updateCommand =
|
||||
new(
|
||||
updateSql,
|
||||
connection,
|
||||
transaction);
|
||||
|
||||
updateCommand.Parameters.Add(
|
||||
new SqlParameter(
|
||||
"@SonicCredentialId",
|
||||
SqlDbType.Int)
|
||||
{
|
||||
Value = sonicCredentialId
|
||||
});
|
||||
|
||||
updateCommand.Parameters.Add(
|
||||
new SqlParameter(
|
||||
"@SonicConnectionId",
|
||||
SqlDbType.Int)
|
||||
{
|
||||
Value = sonicConnectionId
|
||||
});
|
||||
|
||||
updateCommand.Parameters.Add(
|
||||
new SqlParameter(
|
||||
"@CredentialName",
|
||||
SqlDbType.NVarChar,
|
||||
150)
|
||||
{
|
||||
Value = normalizedCredentialName
|
||||
});
|
||||
|
||||
updateCommand.Parameters.Add(
|
||||
new SqlParameter(
|
||||
"@CredentialUserName",
|
||||
SqlDbType.NVarChar,
|
||||
256)
|
||||
{
|
||||
Value = normalizedUserName
|
||||
});
|
||||
|
||||
updateCommand.Parameters.Add(
|
||||
new SqlParameter(
|
||||
"@CredentialSecret",
|
||||
SqlDbType.NVarChar,
|
||||
512)
|
||||
{
|
||||
Value = secret
|
||||
});
|
||||
|
||||
updateCommand.Parameters.Add(
|
||||
new SqlParameter(
|
||||
"@IsDefault",
|
||||
SqlDbType.Bit)
|
||||
{
|
||||
Value = isDefault
|
||||
});
|
||||
|
||||
int affected =
|
||||
await updateCommand.ExecuteNonQueryAsync(
|
||||
cancellationToken);
|
||||
|
||||
if (affected == 0)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Benutzerprofil Id {sonicCredentialId} "
|
||||
+ "wurde nicht gefunden oder ist inaktiv.");
|
||||
}
|
||||
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
}
|
||||
catch
|
||||
{
|
||||
try
|
||||
{
|
||||
await transaction.RollbackAsync(
|
||||
CancellationToken.None);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Die ursprüngliche Exception soll erhalten bleiben.
|
||||
}
|
||||
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<SonicCredentialProfile>>
|
||||
GetCredentialsAsync(
|
||||
int sonicConnectionId,
|
||||
@@ -663,6 +885,7 @@ public sealed class SonicSetupRepository
|
||||
container.[CompanyId],
|
||||
container.[ContainerName],
|
||||
container.[ContainerDisplayName],
|
||||
container.[CertificateCheckUrl],
|
||||
container.[RestartTimeoutSeconds],
|
||||
company.[CompanyCode],
|
||||
connection.[ConnectionName]
|
||||
@@ -729,6 +952,9 @@ public sealed class SonicSetupRepository
|
||||
int displayOrdinal =
|
||||
reader.GetOrdinal("ContainerDisplayName");
|
||||
|
||||
int checkUrlOrdinal =
|
||||
reader.GetOrdinal("CertificateCheckUrl");
|
||||
|
||||
containers.Add(
|
||||
new ContainerOption
|
||||
{
|
||||
@@ -753,6 +979,11 @@ public sealed class SonicSetupRepository
|
||||
? null
|
||||
: reader.GetString(displayOrdinal),
|
||||
|
||||
CertificateCheckUrl =
|
||||
reader.IsDBNull(checkUrlOrdinal)
|
||||
? null
|
||||
: reader.GetString(checkUrlOrdinal),
|
||||
|
||||
RestartTimeoutSeconds =
|
||||
reader.GetInt32(
|
||||
reader.GetOrdinal("RestartTimeoutSeconds")),
|
||||
@@ -1057,6 +1288,7 @@ public sealed class SonicSetupRepository
|
||||
string containerName,
|
||||
string? containerDisplayName,
|
||||
int restartTimeoutSeconds,
|
||||
string? certificateCheckUrl = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (companyId <= 0)
|
||||
@@ -1093,6 +1325,9 @@ public sealed class SonicSetupRepository
|
||||
"Timeout muss zwischen 10 und 3600 Sekunden liegen.");
|
||||
}
|
||||
|
||||
string? checkUrl =
|
||||
TlsEndpointProbeService.NormalizeCheckUrl(certificateCheckUrl);
|
||||
|
||||
const string sql = """
|
||||
INSERT INTO [dbo].[SonicContainer]
|
||||
(
|
||||
@@ -1100,6 +1335,7 @@ public sealed class SonicSetupRepository
|
||||
[SonicConnectionId],
|
||||
[ContainerName],
|
||||
[ContainerDisplayName],
|
||||
[CertificateCheckUrl],
|
||||
[RestartTimeoutSeconds],
|
||||
[IsActive],
|
||||
[CreationDateTime],
|
||||
@@ -1112,6 +1348,7 @@ public sealed class SonicSetupRepository
|
||||
@SonicConnectionId,
|
||||
@ContainerName,
|
||||
@ContainerDisplayName,
|
||||
@CertificateCheckUrl,
|
||||
@RestartTimeoutSeconds,
|
||||
1,
|
||||
SYSUTCDATETIME(),
|
||||
@@ -1159,6 +1396,17 @@ public sealed class SonicSetupRepository
|
||||
: display
|
||||
});
|
||||
|
||||
command.Parameters.Add(
|
||||
new SqlParameter(
|
||||
"@CertificateCheckUrl",
|
||||
SqlDbType.NVarChar,
|
||||
500)
|
||||
{
|
||||
Value = checkUrl is null
|
||||
? DBNull.Value
|
||||
: checkUrl
|
||||
});
|
||||
|
||||
command.Parameters.Add(
|
||||
new SqlParameter(
|
||||
"@RestartTimeoutSeconds",
|
||||
@@ -1457,6 +1705,7 @@ public sealed class SonicSetupRepository
|
||||
string containerName,
|
||||
string? containerDisplayName,
|
||||
int restartTimeoutSeconds,
|
||||
string? certificateCheckUrl = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (sonicContainerId <= 0)
|
||||
@@ -1499,6 +1748,9 @@ public sealed class SonicSetupRepository
|
||||
"Timeout muss zwischen 10 und 3600 Sekunden liegen.");
|
||||
}
|
||||
|
||||
string? checkUrl =
|
||||
TlsEndpointProbeService.NormalizeCheckUrl(certificateCheckUrl);
|
||||
|
||||
const string sql = """
|
||||
UPDATE [dbo].[SonicContainer]
|
||||
SET
|
||||
@@ -1506,6 +1758,7 @@ public sealed class SonicSetupRepository
|
||||
[SonicConnectionId] = @SonicConnectionId,
|
||||
[ContainerName] = @ContainerName,
|
||||
[ContainerDisplayName] = @ContainerDisplayName,
|
||||
[CertificateCheckUrl] = @CertificateCheckUrl,
|
||||
[RestartTimeoutSeconds] = @RestartTimeoutSeconds,
|
||||
[ModifiedDateTime] = SYSUTCDATETIME(),
|
||||
[ModifiedBy] = SUSER_SNAME()
|
||||
@@ -1559,6 +1812,17 @@ public sealed class SonicSetupRepository
|
||||
: display
|
||||
});
|
||||
|
||||
command.Parameters.Add(
|
||||
new SqlParameter(
|
||||
"@CertificateCheckUrl",
|
||||
SqlDbType.NVarChar,
|
||||
500)
|
||||
{
|
||||
Value = checkUrl is null
|
||||
? DBNull.Value
|
||||
: checkUrl
|
||||
});
|
||||
|
||||
command.Parameters.Add(
|
||||
new SqlParameter(
|
||||
"@RestartTimeoutSeconds",
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
using System.Net.Security;
|
||||
using System.Net.Sockets;
|
||||
using System.Security.Cryptography;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using System.Text.RegularExpressions;
|
||||
using ZA.CoreService.ESBCertificateManager.Models;
|
||||
|
||||
namespace ZA.CoreService.ESBCertificateManager.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Liest das präsentierte TLS-Zertifikat eines Endpoints
|
||||
/// (entspricht grob <c>curl -v https://host:port</c>).
|
||||
/// </summary>
|
||||
public sealed class TlsEndpointProbeService
|
||||
{
|
||||
private static readonly Regex HostPortRegex = new(
|
||||
@"^(?<host>[^:/]+)(:(?<port>\d{1,5}))?$",
|
||||
RegexOptions.Compiled | RegexOptions.CultureInvariant);
|
||||
|
||||
public CertificateProbeResult Probe(
|
||||
string? checkUrl,
|
||||
int timeoutMilliseconds = 8000)
|
||||
{
|
||||
if (!TryParseEndpoint(
|
||||
checkUrl,
|
||||
out string host,
|
||||
out int port,
|
||||
out string serverName,
|
||||
out string? parseError))
|
||||
{
|
||||
return CertificateProbeResult.Failed(
|
||||
parseError ?? "Ungültige Prüf-URL.");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using CancellationTokenSource cts = new(timeoutMilliseconds);
|
||||
using TcpClient client = new();
|
||||
|
||||
using (cts.Token.Register(
|
||||
() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
client.Close();
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Timeout beendet die Verbindung.
|
||||
}
|
||||
}))
|
||||
{
|
||||
client.Connect(host, port);
|
||||
}
|
||||
|
||||
using SslStream ssl = new(
|
||||
client.GetStream(),
|
||||
leaveInnerStreamOpen: false,
|
||||
userCertificateValidationCallback:
|
||||
static (_, _, _, _) => true);
|
||||
|
||||
ssl.AuthenticateAsClient(serverName);
|
||||
|
||||
if (ssl.RemoteCertificate is null)
|
||||
{
|
||||
return CertificateProbeResult.Failed(
|
||||
$"Kein Zertifikat von {host}:{port} erhalten.");
|
||||
}
|
||||
|
||||
using X509Certificate2 certificate = new(ssl.RemoteCertificate);
|
||||
CertificateInfo info = ToCertificateInfo(certificate);
|
||||
|
||||
return CertificateProbeResult.Found(
|
||||
$"{host}:{port}",
|
||||
info);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return CertificateProbeResult.Failed(
|
||||
$"TLS-Prüfung {host}:{port} fehlgeschlagen: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
public static bool TryParseEndpoint(
|
||||
string? value,
|
||||
out string host,
|
||||
out int port,
|
||||
out string serverName,
|
||||
out string? error)
|
||||
{
|
||||
host = string.Empty;
|
||||
port = 443;
|
||||
serverName = string.Empty;
|
||||
error = null;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
error = "Keine Prüf-URL hinterlegt.";
|
||||
return false;
|
||||
}
|
||||
|
||||
string trimmed = value.Trim();
|
||||
|
||||
if (Uri.TryCreate(trimmed, UriKind.Absolute, out Uri? uri)
|
||||
&& (uri.Scheme.Equals("https", StringComparison.OrdinalIgnoreCase)
|
||||
|| uri.Scheme.Equals("http", StringComparison.OrdinalIgnoreCase)
|
||||
|| uri.Scheme.Equals("ssl", StringComparison.OrdinalIgnoreCase)
|
||||
|| uri.Scheme.Equals("tcp", StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(uri.Host))
|
||||
{
|
||||
error = "Host in der Prüf-URL fehlt.";
|
||||
return false;
|
||||
}
|
||||
|
||||
host = uri.Host;
|
||||
serverName = uri.Host;
|
||||
port = uri.IsDefaultPort
|
||||
? (uri.Scheme.Equals("http", StringComparison.OrdinalIgnoreCase)
|
||||
? 80
|
||||
: 443)
|
||||
: uri.Port;
|
||||
|
||||
if (port is < 1 or > 65535)
|
||||
{
|
||||
error = "Port muss zwischen 1 und 65535 liegen.";
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
Match match = HostPortRegex.Match(trimmed);
|
||||
|
||||
if (!match.Success)
|
||||
{
|
||||
error =
|
||||
"Prüf-URL ungültig. Beispiele:\n"
|
||||
+ "https://server:8443\n"
|
||||
+ "server:8443\n"
|
||||
+ "server";
|
||||
return false;
|
||||
}
|
||||
|
||||
host = match.Groups["host"].Value;
|
||||
serverName = host;
|
||||
|
||||
if (match.Groups["port"].Success)
|
||||
{
|
||||
if (!int.TryParse(match.Groups["port"].Value, out port)
|
||||
|| port is < 1 or > 65535)
|
||||
{
|
||||
error = "Port muss zwischen 1 und 65535 liegen.";
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return !string.IsNullOrWhiteSpace(host);
|
||||
}
|
||||
|
||||
public static string? NormalizeCheckUrl(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
string trimmed = value.Trim();
|
||||
|
||||
if (!TryParseEndpoint(
|
||||
trimmed,
|
||||
out _,
|
||||
out _,
|
||||
out _,
|
||||
out string? error))
|
||||
{
|
||||
throw new ArgumentException(
|
||||
error ?? "Ungültige Prüf-URL.",
|
||||
nameof(value));
|
||||
}
|
||||
|
||||
if (trimmed.Length > 500)
|
||||
{
|
||||
throw new ArgumentException(
|
||||
"Die Prüf-URL darf maximal 500 Zeichen enthalten.",
|
||||
nameof(value));
|
||||
}
|
||||
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
private static CertificateInfo ToCertificateInfo(
|
||||
X509Certificate2 certificate)
|
||||
{
|
||||
string subject = certificate.GetNameInfo(
|
||||
X509NameType.SimpleName,
|
||||
forIssuer: false);
|
||||
|
||||
string issuer = certificate.GetNameInfo(
|
||||
X509NameType.SimpleName,
|
||||
forIssuer: true);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(subject))
|
||||
{
|
||||
subject = certificate.Subject;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(issuer))
|
||||
{
|
||||
issuer = certificate.Issuer;
|
||||
}
|
||||
|
||||
string fingerprint = certificate.GetCertHashString(
|
||||
HashAlgorithmName.SHA256);
|
||||
|
||||
fingerprint = string.Join(
|
||||
":",
|
||||
Enumerable.Range(0, fingerprint.Length / 2)
|
||||
.Select(index => fingerprint.Substring(index * 2, 2)));
|
||||
|
||||
DateTimeOffset validFrom = new(certificate.NotBefore);
|
||||
DateTimeOffset validUntil = new(certificate.NotAfter);
|
||||
DateTimeOffset now = DateTimeOffset.Now;
|
||||
|
||||
return new CertificateInfo
|
||||
{
|
||||
Subject = subject,
|
||||
Issuer = issuer,
|
||||
FingerprintSha256 = fingerprint,
|
||||
ValidFrom = validFrom,
|
||||
ValidUntil = validUntil,
|
||||
IsCurrentlyValid = now >= validFrom && now <= validUntil
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user