Certes
ライブラリ
Certes
概要
CertesはACME(Automated Certificate Management Environment)プロトコルの.NET Core実装で、Let's Encryptを始めとする証明書機関からSSL証明書を自動取得・管理するライブラリです。証明書の有効性検証、ドメイン所有権の確認、証明書の自動更新機能を提供します。DNS-01およびHTTP-01チャレンジをサポートし、ワイルドカード証明書の取得も可能です。.NET Coreの非同期パターンを活用した効率的な証明書管理により、Webアプリケーションのセキュリティを強化します。
詳細
Certesは、ACMEプロトコルv2に準拠したクライアント実装で、Let's Encryptをはじめとする証明書機関との通信を自動化します。証明書の取得プロセスにおいて、ドメイン所有権の検証、証明書署名要求(CSR)の生成、証明書の取得と保存を一貫して処理します。バリデーション機能として、証明書の有効期限チェック、ドメイン名の検証、証明書チェーンの検証を提供します。90日間の証明書有効期限に対応した自動更新機能により、証明書の期限切れを防止できます。
主な特徴
- ACME v2対応: Let's Encryptの最新プロトコルに完全対応
- ワイルドカード証明書: DNS-01チャレンジによるワイルドカード証明書取得
- 証明書検証: 有効期限、ドメイン名、証明書チェーンの包括的な検証
- 自動更新: 証明書の自動更新とローテーション機能
- 非同期処理: .NET Coreの非同期パターンを活用
- 複数プロバイダー: Let's Encrypt以外のACME対応CAもサポート
メリット・デメリット
メリット
- Let's Encryptとの完全な統合により無料SSL証明書を自動取得
- ワイルドカード証明書の取得が可能
- 証明書の有効期限管理と自動更新
- .NET Coreエコシステムとの親和性
- 包括的な証明書検証機能
- 本番環境での豊富な運用実績
デメリット
- ACME対応の証明書機関に限定される
- DNS-01チャレンジには外部DNSプロバイダーとの連携が必要
- 証明書の有効期限が90日と短い
- 初期設定とドメイン検証の複雑さ
- 高頻度な証明書更新によるシステム負荷
- エラーハンドリングが複雑
参考ページ
書き方の例
インストールと基本セットアップ
# NuGetパッケージのインストール
dotnet add package Certes
# PackageManagerコンソールを使用する場合
Install-Package Certes
# .NET CLI を使用してプロジェクトを作成
dotnet new console -n CertesExample
cd CertesExample
dotnet add package Certes
基本的な証明書取得とバリデーション
using Certes;
using Certes.Acme;
using Certes.Acme.Resource;
using System;
using System.IO;
using System.Threading.Tasks;
class Program
{
private static readonly string StagingDirectoryUri = WellKnownServers.LetsEncryptStagingV2;
private static readonly string ProductionDirectoryUri = WellKnownServers.LetsEncryptV2;
static async Task Main(string[] args)
{
try
{
await BasicCertificateExample();
await CertificateValidationExample();
}
catch (Exception ex)
{
Console.WriteLine($"エラー: {ex.Message}");
}
}
// 基本的な証明書取得
private static async Task BasicCertificateExample()
{
Console.WriteLine("=== 基本的な証明書取得 ===");
// ACMEクライアントの作成(テスト環境を使用)
var acme = new AcmeContext(StagingDirectoryUri);
// アカウントの作成
var account = await acme.NewAccount("[email protected]", true);
Console.WriteLine($"アカウント作成成功: {account.Location}");
// アカウントキーの保存(実際の実装では安全な場所に保存)
var accountKey = acme.AccountKey.ToPem();
await File.WriteAllTextAsync("account.key", accountKey);
Console.WriteLine("アカウントキーを保存しました");
// 証明書の注文作成
var order = await acme.NewOrder(new[] { "test.example.com" });
Console.WriteLine($"注文作成成功: {order.Location}");
// 認証の取得
var authz = (await order.Authorizations()).First();
Console.WriteLine($"認証が必要なドメイン: {authz.Identifier}");
// HTTPチャレンジの取得
var httpChallenge = await authz.Http();
if (httpChallenge != null)
{
Console.WriteLine($"HTTPチャレンジトークン: {httpChallenge.Token}");
Console.WriteLine($"認証キー: {httpChallenge.KeyAuthz}");
Console.WriteLine($"チャレンジURL: http://{authz.Identifier}/.well-known/acme-challenge/{httpChallenge.Token}");
Console.WriteLine("上記URLでキー認証を公開してください");
}
// 実際の証明書取得は、チャレンジを完了した後に実行
// await httpChallenge.Validate();
// var privateKey = KeyFactory.NewKey(KeyAlgorithm.ES256);
// var cert = await order.Generate(new CsrInfo { CommonName = "test.example.com" }, privateKey);
}
// 証明書の検証
private static async Task CertificateValidationExample()
{
Console.WriteLine("\n=== 証明書の検証 ===");
// 保存されたアカウントキーの読み込み
if (File.Exists("account.key"))
{
var accountKeyPem = await File.ReadAllTextAsync("account.key");
var accountKey = KeyFactory.FromPem(accountKeyPem);
var acme = new AcmeContext(StagingDirectoryUri, accountKey);
// アカウント情報の取得
var account = await acme.Account();
Console.WriteLine($"アカウント状態: {account.Status}");
Console.WriteLine($"連絡先: {string.Join(", ", account.Contact ?? new string[0])}");
// アカウントが有効かどうかの検証
if (account.Status == AccountStatus.Valid)
{
Console.WriteLine("✓ アカウントは有効です");
}
else
{
Console.WriteLine($"✗ アカウントの状態が無効です: {account.Status}");
}
}
else
{
Console.WriteLine("アカウントキーが見つかりません");
}
}
}
高度な証明書管理とバリデーション
using Certes;
using Certes.Acme;
using Certes.Acme.Resource;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Security.Cryptography.X509Certificates;
using System.Threading.Tasks;
// 証明書管理クラス
public class CertificateManager
{
private readonly AcmeContext _acmeContext;
private readonly string _accountKeyPath;
private readonly string _certificateStorePath;
private readonly bool _useProduction;
public CertificateManager(string accountKeyPath, string certificateStorePath, bool useProduction = false)
{
_accountKeyPath = accountKeyPath;
_certificateStorePath = certificateStorePath;
_useProduction = useProduction;
var directoryUri = useProduction ? WellKnownServers.LetsEncryptV2 : WellKnownServers.LetsEncryptStagingV2;
_acmeContext = new AcmeContext(directoryUri);
}
// アカウントの初期化
public async Task<bool> InitializeAccountAsync(string email)
{
try
{
IAccountContext account;
if (File.Exists(_accountKeyPath))
{
// 既存のアカウントキーを読み込み
var accountKeyPem = await File.ReadAllTextAsync(_accountKeyPath);
var accountKey = KeyFactory.FromPem(accountKeyPem);
_acmeContext.AccountKey = accountKey;
account = await _acmeContext.Account();
Console.WriteLine($"既存のアカウントを読み込みました: {account.Location}");
}
else
{
// 新しいアカウントを作成
account = await _acmeContext.NewAccount(email, true);
// アカウントキーを保存
var accountKeyPem = _acmeContext.AccountKey.ToPem();
await File.WriteAllTextAsync(_accountKeyPath, accountKeyPem);
Console.WriteLine($"新しいアカウントを作成しました: {account.Location}");
}
return account.Resource.Status == AccountStatus.Valid;
}
catch (Exception ex)
{
Console.WriteLine($"アカウント初期化エラー: {ex.Message}");
return false;
}
}
// 証明書の取得
public async Task<CertificateResult> ObtainCertificateAsync(string[] domains, CertificateType certificateType = CertificateType.RSA)
{
try
{
// 証明書の注文を作成
var order = await _acmeContext.NewOrder(domains);
Console.WriteLine($"証明書注文を作成しました: {order.Location}");
// 各ドメインの認証を処理
var authorizations = await order.Authorizations();
var challenges = new List<ChallengeInfo>();
foreach (var authz in authorizations)
{
var domain = authz.Identifier;
Console.WriteLine($"ドメイン {domain} の認証を開始します");
// DNSチャレンジを優先(ワイルドカード証明書の場合)
var dnsChallenge = await authz.Dns();
if (dnsChallenge != null)
{
var dnsTxt = _acmeContext.AccountKey.DnsTxt(dnsChallenge.Token);
challenges.Add(new ChallengeInfo
{
Domain = domain,
Type = ChallengeType.Dns01,
Token = dnsChallenge.Token,
KeyAuthorization = dnsTxt,
Challenge = dnsChallenge
});
Console.WriteLine($"DNS TXTレコードを設定してください:");
Console.WriteLine($" ホスト: _acme-challenge.{domain}");
Console.WriteLine($" 値: {dnsTxt}");
}
else
{
// HTTPチャレンジを使用
var httpChallenge = await authz.Http();
if (httpChallenge != null)
{
challenges.Add(new ChallengeInfo
{
Domain = domain,
Type = ChallengeType.Http01,
Token = httpChallenge.Token,
KeyAuthorization = httpChallenge.KeyAuthz,
Challenge = httpChallenge
});
Console.WriteLine($"HTTPチャレンジファイルを設置してください:");
Console.WriteLine($" URL: http://{domain}/.well-known/acme-challenge/{httpChallenge.Token}");
Console.WriteLine($" 内容: {httpChallenge.KeyAuthz}");
}
}
}
// ユーザーの確認を待つ
Console.WriteLine("チャレンジの設定が完了したら、Enterキーを押してください...");
Console.ReadLine();
// チャレンジを検証
foreach (var challengeInfo in challenges)
{
Console.WriteLine($"ドメイン {challengeInfo.Domain} のチャレンジを検証中...");
var challenge = await challengeInfo.Challenge.Validate();
// 検証結果を待つ
while (challenge.Status == ChallengeStatus.Pending || challenge.Status == ChallengeStatus.Processing)
{
await Task.Delay(2000);
challenge = await challengeInfo.Challenge.Resource();
}
if (challenge.Status == ChallengeStatus.Valid)
{
Console.WriteLine($"✓ ドメイン {challengeInfo.Domain} の検証成功");
}
else
{
Console.WriteLine($"✗ ドメイン {challengeInfo.Domain} の検証失敗: {challenge.Status}");
if (challenge.Error != null)
{
Console.WriteLine($" エラー: {challenge.Error.Detail}");
}
return new CertificateResult
{
Success = false,
ErrorMessage = $"ドメイン {challengeInfo.Domain} の検証に失敗しました"
};
}
}
// 証明書の生成
var privateKey = certificateType == CertificateType.RSA
? KeyFactory.NewKey(KeyAlgorithm.RS256)
: KeyFactory.NewKey(KeyAlgorithm.ES256);
var csrInfo = new CsrInfo
{
CommonName = domains.First(),
CountryName = "JP",
State = "Tokyo",
Locality = "Tokyo",
Organization = "Example Organization",
OrganizationUnit = "IT Department"
};
var cert = await order.Generate(csrInfo, privateKey);
// 証明書を保存
var certificatePath = Path.Combine(_certificateStorePath, $"{domains.First()}.crt");
var privateKeyPath = Path.Combine(_certificateStorePath, $"{domains.First()}.key");
await File.WriteAllTextAsync(certificatePath, cert.ToPem());
await File.WriteAllTextAsync(privateKeyPath, privateKey.ToPem());
Console.WriteLine($"証明書を保存しました: {certificatePath}");
Console.WriteLine($"秘密鍵を保存しました: {privateKeyPath}");
return new CertificateResult
{
Success = true,
CertificatePath = certificatePath,
PrivateKeyPath = privateKeyPath,
Certificate = cert,
PrivateKey = privateKey
};
}
catch (Exception ex)
{
Console.WriteLine($"証明書取得エラー: {ex.Message}");
return new CertificateResult
{
Success = false,
ErrorMessage = ex.Message
};
}
}
// 証明書の検証
public async Task<CertificateValidationResult> ValidateCertificateAsync(string certificatePath)
{
try
{
if (!File.Exists(certificatePath))
{
return new CertificateValidationResult
{
IsValid = false,
Errors = new[] { "証明書ファイルが見つかりません" }
};
}
var certificatePem = await File.ReadAllTextAsync(certificatePath);
var certificate = new X509Certificate2(System.Text.Encoding.UTF8.GetBytes(certificatePem));
var validationResult = new CertificateValidationResult
{
IsValid = true,
Certificate = certificate,
Subject = certificate.Subject,
Issuer = certificate.Issuer,
NotBefore = certificate.NotBefore,
NotAfter = certificate.NotAfter,
SerialNumber = certificate.SerialNumber,
Thumbprint = certificate.Thumbprint
};
var errors = new List<string>();
// 有効期限の確認
if (certificate.NotAfter < DateTime.Now)
{
errors.Add("証明書の有効期限が切れています");
validationResult.IsValid = false;
}
else if (certificate.NotAfter < DateTime.Now.AddDays(30))
{
errors.Add("証明書の有効期限が30日以内に切れます");
validationResult.HasWarnings = true;
}
// 発行日の確認
if (certificate.NotBefore > DateTime.Now)
{
errors.Add("証明書はまだ有効になっていません");
validationResult.IsValid = false;
}
// 証明書チェーンの検証
var chain = new X509Chain();
chain.ChainPolicy.RevocationMode = X509RevocationMode.Online;
chain.ChainPolicy.RevocationFlag = X509RevocationFlag.ExcludeRoot;
bool chainIsValid = chain.Build(certificate);
if (!chainIsValid)
{
var chainErrors = chain.ChainStatus.Select(status => status.StatusInformation).ToArray();
errors.AddRange(chainErrors);
validationResult.IsValid = false;
}
validationResult.Errors = errors.ToArray();
return validationResult;
}
catch (Exception ex)
{
return new CertificateValidationResult
{
IsValid = false,
Errors = new[] { $"証明書の検証中にエラーが発生しました: {ex.Message}" }
};
}
}
// 証明書の更新が必要かどうかを確認
public async Task<bool> NeedsRenewalAsync(string certificatePath, int daysBeforeExpiry = 30)
{
try
{
var validationResult = await ValidateCertificateAsync(certificatePath);
if (!validationResult.IsValid)
{
return true; // 無効な証明書は更新が必要
}
if (validationResult.Certificate == null)
{
return true;
}
var expiryDate = validationResult.Certificate.NotAfter;
var renewalDate = expiryDate.AddDays(-daysBeforeExpiry);
return DateTime.Now >= renewalDate;
}
catch (Exception ex)
{
Console.WriteLine($"証明書更新判定エラー: {ex.Message}");
return true; // エラーが発生した場合は更新を試行
}
}
}
// 補助クラス
public class ChallengeInfo
{
public string Domain { get; set; }
public ChallengeType Type { get; set; }
public string Token { get; set; }
public string KeyAuthorization { get; set; }
public IChallengeContext Challenge { get; set; }
}
public enum ChallengeType
{
Http01,
Dns01
}
public enum CertificateType
{
RSA,
ECDSA
}
public class CertificateResult
{
public bool Success { get; set; }
public string ErrorMessage { get; set; }
public string CertificatePath { get; set; }
public string PrivateKeyPath { get; set; }
public CertificateChain Certificate { get; set; }
public IKey PrivateKey { get; set; }
}
public class CertificateValidationResult
{
public bool IsValid { get; set; }
public bool HasWarnings { get; set; }
public string[] Errors { get; set; }
public X509Certificate2 Certificate { get; set; }
public string Subject { get; set; }
public string Issuer { get; set; }
public DateTime NotBefore { get; set; }
public DateTime NotAfter { get; set; }
public string SerialNumber { get; set; }
public string Thumbprint { get; set; }
}
// 使用例
class Program
{
static async Task Main(string[] args)
{
var manager = new CertificateManager(
accountKeyPath: "account.key",
certificateStorePath: "./certificates",
useProduction: false // テスト環境を使用
);
// アカウントの初期化
bool accountInitialized = await manager.InitializeAccountAsync("[email protected]");
if (!accountInitialized)
{
Console.WriteLine("アカウントの初期化に失敗しました");
return;
}
// 証明書の取得
var domains = new[] { "test.example.com", "www.test.example.com" };
var result = await manager.ObtainCertificateAsync(domains);
if (result.Success)
{
Console.WriteLine("証明書の取得に成功しました");
// 証明書の検証
var validationResult = await manager.ValidateCertificateAsync(result.CertificatePath);
if (validationResult.IsValid)
{
Console.WriteLine("✓ 証明書は有効です");
Console.WriteLine($" 発行者: {validationResult.Issuer}");
Console.WriteLine($" 有効期限: {validationResult.NotAfter:yyyy-MM-dd HH:mm:ss}");
}
else
{
Console.WriteLine("✗ 証明書に問題があります:");
foreach (var error in validationResult.Errors)
{
Console.WriteLine($" - {error}");
}
}
}
else
{
Console.WriteLine($"証明書の取得に失敗しました: {result.ErrorMessage}");
}
}
}
証明書の自動更新とモニタリング
using Certes;
using System;
using System.Collections.Generic;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using System.Timers;
// 証明書自動更新サービス
public class CertificateRenewalService
{
private readonly CertificateManager _certificateManager;
private readonly System.Timers.Timer _renewalTimer;
private readonly List<CertificateConfig> _certificates;
private readonly string _configPath;
private readonly CancellationTokenSource _cancellationTokenSource;
public event EventHandler<CertificateRenewalEventArgs> CertificateRenewed;
public event EventHandler<CertificateRenewalEventArgs> CertificateRenewalFailed;
public CertificateRenewalService(CertificateManager certificateManager, string configPath)
{
_certificateManager = certificateManager;
_configPath = configPath;
_certificates = new List<CertificateConfig>();
_cancellationTokenSource = new CancellationTokenSource();
// 毎日午前2時に更新チェックを実行
_renewalTimer = new System.Timers.Timer(TimeSpan.FromHours(24).TotalMilliseconds);
_renewalTimer.Elapsed += async (sender, e) => await CheckAndRenewCertificatesAsync();
_renewalTimer.AutoReset = true;
}
// 証明書設定の追加
public void AddCertificateConfig(CertificateConfig config)
{
_certificates.Add(config);
}
// 証明書設定の読み込み
public async Task LoadCertificateConfigsAsync()
{
try
{
if (File.Exists(_configPath))
{
var configJson = await File.ReadAllTextAsync(_configPath);
var configs = System.Text.Json.JsonSerializer.Deserialize<List<CertificateConfig>>(configJson);
_certificates.AddRange(configs);
Console.WriteLine($"証明書設定を読み込みました: {_certificates.Count} 個");
}
}
catch (Exception ex)
{
Console.WriteLine($"証明書設定の読み込みエラー: {ex.Message}");
}
}
// 証明書設定の保存
public async Task SaveCertificateConfigsAsync()
{
try
{
var configJson = System.Text.Json.JsonSerializer.Serialize(_certificates, new System.Text.Json.JsonSerializerOptions
{
WriteIndented = true
});
await File.WriteAllTextAsync(_configPath, configJson);
Console.WriteLine("証明書設定を保存しました");
}
catch (Exception ex)
{
Console.WriteLine($"証明書設定の保存エラー: {ex.Message}");
}
}
// 自動更新サービスの開始
public async Task StartAsync()
{
await LoadCertificateConfigsAsync();
// 初回チェック
await CheckAndRenewCertificatesAsync();
// 定期チェック開始
_renewalTimer.Start();
Console.WriteLine("証明書自動更新サービスを開始しました");
}
// 自動更新サービスの停止
public async Task StopAsync()
{
_renewalTimer.Stop();
_cancellationTokenSource.Cancel();
await SaveCertificateConfigsAsync();
Console.WriteLine("証明書自動更新サービスを停止しました");
}
// 証明書の更新チェックと実行
private async Task CheckAndRenewCertificatesAsync()
{
Console.WriteLine($"証明書更新チェック開始: {DateTime.Now:yyyy-MM-dd HH:mm:ss}");
foreach (var config in _certificates)
{
try
{
if (_cancellationTokenSource.Token.IsCancellationRequested)
{
break;
}
Console.WriteLine($"証明書チェック中: {config.Name}");
bool needsRenewal = await _certificateManager.NeedsRenewalAsync(
config.CertificatePath,
config.RenewalDays
);
if (needsRenewal)
{
Console.WriteLine($"証明書更新が必要です: {config.Name}");
var result = await _certificateManager.ObtainCertificateAsync(
config.Domains,
config.CertificateType
);
if (result.Success)
{
// 設定を更新
config.CertificatePath = result.CertificatePath;
config.PrivateKeyPath = result.PrivateKeyPath;
config.LastRenewalDate = DateTime.Now;
Console.WriteLine($"✓ 証明書更新成功: {config.Name}");
// イベント発火
CertificateRenewed?.Invoke(this, new CertificateRenewalEventArgs
{
Config = config,
Result = result,
Success = true
});
// Webhook通知(オプション)
if (!string.IsNullOrEmpty(config.WebhookUrl))
{
await SendWebhookNotificationAsync(config, result, true);
}
}
else
{
Console.WriteLine($"✗ 証明書更新失敗: {config.Name} - {result.ErrorMessage}");
// イベント発火
CertificateRenewalFailed?.Invoke(this, new CertificateRenewalEventArgs
{
Config = config,
Result = result,
Success = false,
ErrorMessage = result.ErrorMessage
});
// Webhook通知(オプション)
if (!string.IsNullOrEmpty(config.WebhookUrl))
{
await SendWebhookNotificationAsync(config, result, false);
}
}
}
else
{
Console.WriteLine($"証明書更新は不要です: {config.Name}");
}
}
catch (Exception ex)
{
Console.WriteLine($"証明書チェックエラー: {config.Name} - {ex.Message}");
}
}
// 設定を保存
await SaveCertificateConfigsAsync();
Console.WriteLine($"証明書更新チェック完了: {DateTime.Now:yyyy-MM-dd HH:mm:ss}");
}
// Webhook通知の送信
private async Task SendWebhookNotificationAsync(CertificateConfig config, CertificateResult result, bool success)
{
try
{
using var httpClient = new System.Net.Http.HttpClient();
var notification = new
{
certificate_name = config.Name,
domains = config.Domains,
success = success,
timestamp = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss"),
message = success ? "証明書の更新が完了しました" : $"証明書の更新に失敗しました: {result.ErrorMessage}"
};
var json = System.Text.Json.JsonSerializer.Serialize(notification);
var content = new System.Net.Http.StringContent(json, System.Text.Encoding.UTF8, "application/json");
var response = await httpClient.PostAsync(config.WebhookUrl, content);
if (response.IsSuccessStatusCode)
{
Console.WriteLine($"Webhook通知送信成功: {config.Name}");
}
else
{
Console.WriteLine($"Webhook通知送信失敗: {config.Name} - {response.StatusCode}");
}
}
catch (Exception ex)
{
Console.WriteLine($"Webhook通知エラー: {config.Name} - {ex.Message}");
}
}
}
// 証明書設定クラス
public class CertificateConfig
{
public string Name { get; set; }
public string[] Domains { get; set; }
public CertificateType CertificateType { get; set; }
public string CertificatePath { get; set; }
public string PrivateKeyPath { get; set; }
public int RenewalDays { get; set; } = 30;
public string WebhookUrl { get; set; }
public DateTime LastRenewalDate { get; set; }
}
// 証明書更新イベント引数
public class CertificateRenewalEventArgs : EventArgs
{
public CertificateConfig Config { get; set; }
public CertificateResult Result { get; set; }
public bool Success { get; set; }
public string ErrorMessage { get; set; }
}
// 使用例
class Program
{
static async Task Main(string[] args)
{
var certificateManager = new CertificateManager(
accountKeyPath: "account.key",
certificateStorePath: "./certificates",
useProduction: false
);
var renewalService = new CertificateRenewalService(
certificateManager,
configPath: "certificate_configs.json"
);
// イベントハンドラーの設定
renewalService.CertificateRenewed += (sender, e) =>
{
Console.WriteLine($"証明書更新成功通知: {e.Config.Name}");
};
renewalService.CertificateRenewalFailed += (sender, e) =>
{
Console.WriteLine($"証明書更新失敗通知: {e.Config.Name} - {e.ErrorMessage}");
};
// 証明書設定の追加
renewalService.AddCertificateConfig(new CertificateConfig
{
Name = "example.com",
Domains = new[] { "example.com", "www.example.com" },
CertificateType = CertificateType.RSA,
RenewalDays = 30,
WebhookUrl = "https://hooks.example.com/certificate-renewal"
});
// アカウントの初期化
bool accountInitialized = await certificateManager.InitializeAccountAsync("[email protected]");
if (!accountInitialized)
{
Console.WriteLine("アカウントの初期化に失敗しました");
return;
}
// 自動更新サービスの開始
await renewalService.StartAsync();
// サービスの実行を維持
Console.WriteLine("証明書自動更新サービスが実行中です。終了するには任意のキーを押してください...");
Console.ReadKey();
// サービスの停止
await renewalService.StopAsync();
}
}