Mert Özen Her Satırda Daha İleri
DevOps SSL/TLS Certificate'ları ve HTTPS — #04

.NET'te ve Kestrel'de Certificate: dev-certs, PEM/PFX Yükleme, SNI, Yenilemede Reload ve HttpClient Doğrulaması

Mert Özen 14 Kas 2026 15 dk 5 görüntülenme
.NET'te ve Kestrel'de Certificate: dev-certs, PEM/PFX Yükleme, SNI, Yenilemede Reload ve HttpClient Doğrulaması

Kestrel, configuration'daki PEM veya PFX certificate'ını restart'sız yeniden yükler ama yalnızca Path'i izler, yaklaşık beş saniyede fark eder ve endpoint'i yeniden başlatır; root olmayan kullanıcıyla Certbot'un dosyalarını okuyamaz, bu yüzden bir deploy hook gerekir. dev-certs'in Linux'taki SSL_CERT_DIR ayarını, ServerCertificateSelector ile SNI'ı ve HttpClient'ta doğrulamayı kapatmadan CustomRootTrust ile internal CA'ya güvenmeyi gerçek koşularla görüyoruz.

Sürüm notu: Bu yazıdaki örnekler .NET 10 (SDK 10.0.404, ASP.NET Core runtime 10.0.15), Certbot 5.10.0, Let's Encrypt'in test sunucusu Pebble ve OpenSSL 3 ile Docker 29 üzerinde, Ubuntu 24.04 tabanlı resmi .NET image'larında denenmiştir; Windows'a özgü davranışlar bu ortamda çalıştırılmamış, dokümantasyondan aktarılmıştır.

Önce Tahmin Et

Bookstore.Api bir Linux sunucusunda, önünde reverse proxy olmadan, TLS'i Kestrel'de sonlandırarak çalışıyor. Certificate'ı aynı makinedeki Certbot alıyor. .NET 8'den beri resmi container image'ları uygulamayı root olmayan app kullanıcısıyla çalıştırıyor ve ekip de bu kuralı sunucuda uyguluyor. appsettings.Production.json'da Kestrel'e Certbot'un verdiği yol yazılmış: /etc/letsencrypt/live/api.bookstore.example/fullchain.pem.

İki soru. Birincisi: uygulama ayağa kalkar mı? İkincisi: kalktığını varsayalım; Certbot certificate'ı yenilediğinde Kestrel'i restart etmek gerekir mi, gerekmiyorsa yeni certificate ne kadar sürede sunulur ve bu sırada açık bağlantılara ne olur? İkisini de gerçek koşuyla göreceğiz.

Geliştirme: dotnet dev-certs

Geliştirme makinesinde Kestrel'in HTTPS'i (HTTP Secure) için SDK'nın ürettiği bir development certificate'ı kullanılır: CN=localhost, kendi kendini imzalamış, bir yıllık. Windows'ta dotnet dev-certs https --trust certificate'ı kullanıcının Root store'una ekler ve bir onay penceresi açar; macOS'te ise Keychain'e ekler. Linux'ta durum dağıtıma göre değişir ve Microsoft resmi olarak yalnızca Ubuntu ile Fedora'yı destekliyor. Ubuntu 24.04 tabanlı SDK image'ında denedim:

$ dotnet dev-certs https --check
No valid certificate found.
$ dotnet dev-certs https --trust
Trusting the HTTPS development certificate was requested. Trust is per-user and may require additional configuration. See https://aka.ms/dev-certs-trust for more information.
[110] For OpenSSL trust to take effect, '$HOME/.aspnet/dev-certs/trust' must be listed in the SSL_CERT_DIR environment variable. For example, `export SSL_CERT_DIR="$HOME/.aspnet/dev-certs/trust:/usr/lib/ssl/certs"`. See https://aka.ms/dev-certs-trust for more information.
There was an error trusting the HTTPS developer certificate. It will be trusted by some clients but not by others.
$ ls ~/.aspnet/dev-certs/trust
aspnetcore-localhost-85133820C795076E59C2B7F4FE25D736E5900635.pem
ce275665.0

"error" kelimesi yanıltıcı. Araç certificate'ı üretmiş ve OpenSSL'in okuyabileceği hash'li bir klasöre koymuş, ama OpenSSL tabanlı istemcilerin (curl, Linux'taki .NET) o klasöre bakması için SSL_CERT_DIR'i senin ayarlaman gerekiyor. Tarayıcı tarafı için ise certutil aracı gerekir. Ayarlayınca kontrol geçiyor:

$ export SSL_CERT_DIR="$HOME/.aspnet/dev-certs/trust:/usr/lib/ssl/certs"
$ dotnet dev-certs https --check --trust
A trusted certificate was found: 7FB230F0FBB47BE0A357A82BD884ED8BA49AADA0 - CN=localhost - Valid from 2026-11-13 21:58:57Z to 2027-11-13 21:58:57Z - IsHttpsDevelopmentCertificate: true - IsExportable: true

Uygulamayı geliştirme sırasında container'da çalıştırıyorsan, dev certificate container'da yoktur. Resmi SDK image'ı DOTNET_GENERATE_ASPNET_CERTIFICATE=false ile geliyor, runtime image'ında ise SDK hiç yok. Doğru yol, certificate'ı host'ta dışa aktarıp container'a read-only mount etmek:

$ dotnet dev-certs https -ep ./devcert/bookstore-dev.pem --format Pem --no-password
The HTTPS developer certificate was generated successfully.
$ docker run -d --name tlsseries-devcert -e ASPNETCORE_HTTPS_PORTS=8443 \
    -e Kestrel__Certificates__Default__Path=/https/bookstore-dev.pem \
    -e Kestrel__Certificates__Default__KeyPath=/https/bookstore-dev.key \
    -v ./devcert:/https:ro -v ./app/Bookstore.Api:/app:ro -w /app \
    mcr.microsoft.com/dotnet/aspnet:10.0 dotnet Bookstore.Api.dll
$ docker run --rm --network container:tlsseries-devcert -v ./devcert:/https:ro mcr.microsoft.com/dotnet/sdk:10.0 \
    sh -c 'curl -sS --cacert /https/bookstore-dev.pem https://localhost:8443/api/health; curl -sS https://localhost:8443/api/health 2>&1 | head -1'
ok from localhost
curl: (60) SSL certificate problem: self-signed certificate

Dışa aktarılan certificate'ın SAN (sertifikanın geçerli olduğu alan adları listesi) listesinde localhost'un yanında host.docker.internal ve *.dev.localhost gibi isimler de var. openssl x509 -ext subjectAltName ile bakabilirsin. Microsoft'un uyarısı açık: dağıtılacak bir image'ın içinde development certificate üretme. Dev cert'in private key'i her geliştiricinin makinesinde ayrı ve yalnızca orada geçerli olmalı.

Kestrel'e Certificate Vermek: PEM, PFX ve Store

Production'da certificate'ı configuration'dan vermek en esnek yol. Web Server'lar ve Hosting serisinin "Kestrel'i Tanımak" yazısında Path/KeyPath ve SNI (TLS handshake'inde istenen host adının bildirilmesi) JSON'unun (JavaScript Object Notation) temelini gördük. Bookstore.Api'nin bu yazıdaki config'i, public certificate'ın yanında iki internal certificate da sunuyor:

{
  "Logging": {
    "LogLevel": {
      "Microsoft.AspNetCore.Server.Kestrel": "Debug"
    }
  },
  "Kestrel": {
    "Endpoints": {
      "Https": {
        "Url": "https://*:8443",
        "Certificate": {
          "Path": "/etc/bookstore/tls/fullchain.pem",
          "KeyPath": "/etc/bookstore/tls/privkey.pem"
        },
        "Sni": {
          "legacy.bookstore.internal": {
            "Certificate": {
              "Path": "/etc/bookstore/internal/legacy.fullchain.pem",
              "KeyPath": "/etc/bookstore/internal/legacy.key"
            }
          },
          "*.bookstore.internal": {
            "Certificate": {
              "Path": "/etc/bookstore/internal/partner-api.fullchain.pem",
              "KeyPath": "/etc/bookstore/internal/partner-api.key"
            }
          },
          "*": {
            "Certificate": {
              "Path": "/etc/bookstore/tls/fullchain.pem",
              "KeyPath": "/etc/bookstore/tls/privkey.pem"
            }
          }
        }
      }
    }
  }
}

SNI eşleşmesinde tam isim wildcard'dan önce gelir; birden fazla wildcard eşleşirse en uzunu seçilir. * ise geri kalan her şeyi ve SNI göndermeyen istemcileri karşılar. Bu girdiyi koymazsan, tanımadığı bir isimle gelen bağlantıyı Kestrel reddeder.

Üç kaynak türü var. Path ile KeyPath PEM (sertifika ve anahtarlar için Base64 metin formatı) dosyaları içindir ve Certbot'la en doğal eşleşme budur. Path ile Password PFX (PKCS #12) içindir; Windows'ta ve Azure Key Vault'tan indirilen certificate'larda sık görülür. Subject ile Store ise Windows certificate store'undan okur. PFX parolasını appsettings.json'a yazma. Kestrel__Certificates__Default__Password environment variable'ı veya Key Vault configuration provider'ı ile ver; parolanın kendisini yönetmek Configuration ve Secret Management serisinin konusu. Certificate'ı Windows store'undan okumak, dosya izinleriyle uğraşmamanın ve key'i dışa aktarılamaz (non-exportable) tutmanın yolu; ama bu durumda yenilemeyi de store'a göre kurman gerekir.

Certbot ile Kestrel: İzinler ve Deploy Hook

Önce Tahmin Et'in ilk sorusu. Certificate'ı Certbot'un standalone modu ile aldım; standalone, challenge'ı 80 portunda kendi açtığı geçici bir sunucuyla çözüyor. Ardından Kestrel'i app kullanıcısıyla, yolları doğrudan live/'a çevirerek başlattım:

$ certbot certonly --standalone -d api.bookstore.example --preferred-profile shortlived \
    --server https://pebble:14000/dir -m ops@bookstore.example --agree-tos --no-eff-email -n \
    --deploy-hook /usr/local/bin/bookstore-cert-deploy.sh
Requesting a certificate for api.bookstore.example
This certificate expires on 2026-11-19.
$ ls -ld /etc/letsencrypt/archive /etc/letsencrypt/live | awk '{print $1,$3,$4,$9}'
drwx------ root root /etc/letsencrypt/archive
drwx------ root root /etc/letsencrypt/live
$ dotnet Bookstore.Api.dll
fail: Microsoft.Extensions.Hosting.Internal.Host[11]
      Hosting failed to start
      System.UnauthorizedAccessException: Access to the path '/etc/letsencrypt/live/api.bookstore.example/fullchain.pem' is denied.
       ---> System.IO.IOException: Permission denied

Uygulama ayağa kalkmıyor. Certbot live/ ve archive/ klasörlerini yalnızca root'un girebileceği şekilde (0700), private key'i de 0600 ile oluşturuyor. Bu, private key'in tam olması gereken hali. Kestrel'i root çalıştırarak bunu "çözmek" en kötü seçenek. Klasör izinlerini gevşetmek de bütün certificate'ları bütün kullanıcılara açar. Doğru yol, yenilemede çalışan bir deploy hook'un yalnızca bu uygulamanın certificate'ını, yalnızca uygulamanın grubunun okuyabileceği bir yere kopyalaması:

#!/bin/sh
# Certbot deploy hook: yenilenen certificate'ı Kestrel'in (app kullanıcısı) okuyabileceği yere kopyalar
set -e
dest=/etc/bookstore/tls
install -o root -g app -m 0640 "$RENEWED_LINEAGE/privkey.pem" "$dest/privkey.pem"
install -o root -g app -m 0644 "$RENEWED_LINEAGE/fullchain.pem" "$dest/fullchain.pem.new"
mv "$dest/fullchain.pem.new" "$dest/fullchain.pem"
$ ls -l /etc/bookstore/tls | awk 'NR>1{print $1,$3,$4,$9}'
-rw-r--r-- root app fullchain.pem
-rw-r----- root app privkey.pem

Sıra bilinçli: önce key, en son certificate. Certificate de önce geçici bir dosyaya yazılıp mv ile tek adımda yerine konuyor. Sebebini birazdan Kestrel'in nasıl izlediğine bakınca göreceğiz. /etc/bookstore/tls klasörü de root:app sahipliğinde ve 0750; yani app dışındaki kullanıcılar içine giremiyor. Hook, Certbot'un renewal config'ine kaydedildiği için her yenilemede kendiliğinden çalışıyor.

Yenilemede Restart'sız Reload

Kestrel bu config ile başlayınca Debug log'unda hangi dosyaları izlediğini yazıyor:

dbug: Microsoft.AspNetCore.Server.Kestrel.Core.Internal.CertificatePathWatcher[4]
      Created directory watcher for '/etc/bookstore/tls'.
dbug: Microsoft.AspNetCore.Server.Kestrel.Core.Internal.CertificatePathWatcher[5]
      Created file watcher for '/etc/bookstore/tls/fullchain.pem'.
dbug: Microsoft.AspNetCore.Server.Kestrel.Core.Internal.CertificatePathWatcher[4]
      Created directory watcher for '/etc/bookstore/internal'.
dbug: Microsoft.AspNetCore.Server.Kestrel.Core.Internal.CertificatePathWatcher[5]
      Created file watcher for '/etc/bookstore/internal/partner-api.fullchain.pem'.
dbug: Microsoft.AspNetCore.Server.Kestrel.Core.Internal.CertificatePathWatcher[5]
      Created file watcher for '/etc/bookstore/internal/legacy.fullchain.pem'.
info: Microsoft.Hosting.Lifetime[14]
      Now listening on: https://[::]:8443

Dikkat: privkey.pem için bir watcher yok. ASP.NET Core kaynak kodundaki CertificatePathWatcher yalnızca Path'i izliyor; KeyPath değiştiğinde tek başına reload olmuyor. Ayrıca kod polling'i bilerek zorluyor. Kaynaktaki yorum, polling'in hem symlink'leri hem de hedeflerini izlediğini, normal watcher'ın ise yalnızca symlink'in kendisini gördüğünü söylüyor. Yani Certbot'un live/ symlink'lerini doğrudan göstersen de değişiklik yakalanırdı; bizi oradan uzaklaştıran tek şey izinler. Deploy hook'taki sıralama buradan geliyor: certificate en son değişsin ki Kestrel tetiklendiğinde yeni key zaten yerinde olsun.

Şimdi ikinci soru. Certbot'a yenilemeyi zorlattım ve saniye saniye sunulan seri numarasına baktım:

$ certbot renew --force-renewal > /tmp/renew.log 2>&1; date -u +"renewed at %H:%M:%S"
$ for i in $(seq 1 12); do printf "%s " "$(date -u +%H:%M:%S)"
    echo | openssl s_client -connect api.bookstore.example:8443 -servername api.bookstore.example 2>/dev/null \
      | openssl x509 -noout -serial; sleep 1; done
renewed at 22:05:11
22:05:11 serial=26100F532DAF9882
22:05:12 serial=26100F532DAF9882
22:05:13 serial=26100F532DAF9882
22:05:14 serial=26100F532DAF9882
22:05:15 serial=26100F532DAF9882
22:05:16 serial=7FC6AF2F987DCAD7
22:05:17 serial=7FC6AF2F987DCAD7
22:05:18 serial=7FC6AF2F987DCAD7
22:05:19 serial=7FC6AF2F987DCAD7
22:05:20 serial=7FC6AF2F987DCAD7
22:05:21 serial=7FC6AF2F987DCAD7
22:05:22 serial=7FC6AF2F987DCAD7

Restart yok, yeni certificate yaklaşık beş saniye sonra sunuluyor; gecikme polling aralığından geliyor. Ama log, reload'un sanıldığı kadar sessiz olmadığını gösteriyor:

$ grep -A1 "Kestrel\[0\]" /tmp/api.log
dbug: Microsoft.AspNetCore.Server.Kestrel[0]
      Config reload token fired. Checking for changes...
info: Microsoft.AspNetCore.Server.Kestrel[0]
      Config changed. Stopping the following endpoints: 'https://*:8443'
info: Microsoft.AspNetCore.Server.Kestrel[0]
      Config changed. Starting the following endpoints: 'https://*:8443'

Kestrel certificate değişikliğini bir configuration değişikliği gibi ele alıyor ve endpoint'i durdurup yeniden başlatıyor. Microsoft'un endpoint dokümanına göre değişen endpoint'teki istekler bitmek için beş saniye alıyor ve bu sırada bağlanmaya çalışan istemciler reddedilebiliyor. Önce Tahmin Et'in cevabı: restart gerekmez, ama yenileme anında kısa bir kesinti penceresi oluşabilir. Tek instance'ta bu birkaç bağlantı hatası demek. Kestrel'i doğrudan internete açıyorsan, birden fazla instance'ı aynı anda yenilememek bu yüzden önemli.

Kodda SNI: ServerCertificateSelector

Certificate'lar configuration'da yazamayacağın kadar dinamikse, örneğin her tenant'ın kendi domain'i varsa veya certificate'lar bir veritabanından ya da Key Vault'tan geliyorsa, seçimi kodda yaparsın. ServerCertificateSelector her bağlantıda istemcinin SNI'da gönderdiği isimle çağrılır:

using Bookstore.Api;

WebApplicationBuilder builder = WebApplication.CreateBuilder(args);

if (builder.Configuration.GetValue<bool>("Tls:UseSelector"))
{
    string certRoot = builder.Configuration["Tls:CertificateRoot"]!;
    CertificateCache cache = new(certRoot, TimeSpan.FromMinutes(1));

    builder.WebHost.ConfigureKestrel(kestrel =>
        kestrel.ListenAnyIP(9443, listen => listen.UseHttps(https =>
            https.ServerCertificateSelector = (_, hostName) =>
                cache.Get(hostName is not null && cache.Has(hostName) ? hostName : "api.bookstore.example"))));
}

WebApplication app = builder.Build();

app.MapGet("/api/health", (HttpContext context) => $"ok from {context.Request.Host.Host}\n");

app.Run();
using System.Collections.Concurrent;
using System.Security.Cryptography.X509Certificates;

namespace Bookstore.Api;

public sealed class CertificateCache(string root, TimeSpan maxAge)
{
    private readonly ConcurrentDictionary<string, (X509Certificate2 Certificate, DateTimeOffset LoadedAt)> _entries = new();

    public bool Has(string hostName) => Directory.Exists(Path.Combine(root, hostName));

    public X509Certificate2 Get(string hostName)
    {
        (X509Certificate2 Certificate, DateTimeOffset LoadedAt) entry = _entries.AddOrUpdate(
            hostName,
            Load,
            (name, existing) => DateTimeOffset.UtcNow - existing.LoadedAt < maxAge ? existing : Load(name));
        return entry.Certificate;
    }

    private (X509Certificate2, DateTimeOffset) Load(string hostName)
    {
        string dir = Path.Combine(root, hostName);
        X509Certificate2 certificate = X509Certificate2.CreateFromPemFile(
            Path.Combine(dir, "fullchain.pem"), Path.Combine(dir, "privkey.pem"));

        if (OperatingSystem.IsWindows())
        {
            // Windows'ta SslStream, PEM'den yüklenen ephemeral key'i kullanamaz; PKCS#12 üzerinden yeniden yükle
            certificate = X509CertificateLoader.LoadPkcs12(certificate.Export(X509ContentType.Pkcs12), null);
        }

        return (certificate, DateTimeOffset.UtcNow);
    }
}
$ for h in api.bookstore.example partner-api.bookstore.internal unknown.bookstore.example; do
    printf "%-32s " $h
    echo | openssl s_client -connect 127.0.0.1:9443 -servername $h 2>/dev/null \
      | openssl x509 -noout -ext subjectAltName | tail -1
  done
api.bookstore.example            DNS:api.bookstore.example
partner-api.bookstore.internal   DNS:partner-api.bookstore.internal
unknown.bookstore.example        DNS:api.bookstore.example

Kodla seçim yapınca iki sorumluluk sana geçiyor. Birincisi reload: Kestrel'in dosya izleyicisi yalnızca configuration'daki certificate'lar için çalışıyor, selector'da yenilenen dosyayı yeniden okumak senin işin. Buradaki cache bunu bir dakikalık yaşla basitçe yapıyor. İkincisi bilinmeyen isim: selector exception fırlatırsa handshake düşer, bu yüzden varsayılan bir certificate'a düşmek gerekir. Bir de Windows ayrıntısı var: CreateFromPemFile'ın döndürdüğü key ephemeral ve Windows'taki SslStream onu sunucu tarafında kullanamıyor; Microsoft'un SslStream sorun giderme sayfası "No credentials are available in the security package" hatasını bu durumla ilişkilendiriyor. Kodda bunun için PKCS#12'ye çevirip yeniden yükleyen bir dal var. Linux'ta bu dal çalışmadı; Windows'ta denemedim. Kestrel'in configuration yükleyicisi bu dönüşümü PEM dosyaları için kendisi yapıyor.

TLS Kestrel'de mi, Reverse Proxy'de mi?

Bu yazıdaki senaryo, TLS'in Kestrel'de bittiği durumdu. Çoğu production kurulumunda ise TLS önde bir Nginx, YARP (Microsoft'un .NET tabanlı reverse proxy kütüphanesi), bulut load balancer'ı veya Kubernetes ingress/gateway'inde biter ve Kestrel arkada düz HTTP konuşur. Karar için kısa bir ölçü: aynı makinede birden fazla uygulama varsa, certificate'ları tek yerden yönetmek istiyorsan ya da HTTP/3 ve rate limiting gibi işleri proxy'de topluyorsan, TLS proxy'de bitsin. Tek uygulama ve basit bir kurulumsa Kestrel'in kendisi yeterli. Proxy arkasında Kestrel'in isteğin aslında HTTPS geldiğini bilmesi için Forwarded Headers gerekir; bu, Web Server'lar ve Hosting serisinin üçüncü yazısının konusu. Proxy ile Kestrel arasının da şifreli olması gerekiyorsa (sıfır güven ağı, regülasyon), arka tarafta internal bir CA'nın (sertifika veren güvenilir kuruluş) certificate'ını kullanırsın ve aşağıdaki HttpClient ayarlarının proxy'deki karşılıklarını yaparsın.

HttpClient: Doğrulamayı Kapatmadan Internal CA'ya Güvenmek

Şimdi istemci tarafı. Bookstore'un sipariş servisi hem public certificate'lı api.bookstore.example'a, hem de internal CA'nın certificate'ını kullanan partner API'sine bağlanıyor. Bir de tehlikeli seçeneği gösteren dört mod yazdım:

using System.Net.Security;
using System.Security.Cryptography.X509Certificates;

string mode = args[0];
string url = args[1];

HttpMessageHandler handler = mode switch
{
    "default" => new SocketsHttpHandler(),
    "internal-ca" => CreateInternalCaHandler("/certs/internal-root.crt"),
    "issuer-rule" => CreateIssuerRuleHandler("/certs/internal-root.crt", "CN=Bookstore Test Issuing CA 1, O=Bookstore Test, C=TR"),
    "dangerous" => new HttpClientHandler
    {
        ServerCertificateCustomValidationCallback = HttpClientHandler.DangerousAcceptAnyServerCertificateValidator
    },
    _ => throw new ArgumentException($"unknown mode {mode}")
};

using HttpClient client = new(handler);
try
{
    string body = await client.GetStringAsync(url);
    Console.WriteLine($"[{mode}] OK: {body.Trim()}");
}
catch (HttpRequestException ex)
{
    Console.WriteLine($"[{mode}] {ex.InnerException?.Message ?? ex.Message}");
}

static SocketsHttpHandler CreateInternalCaHandler(string rootPath)
{
    X509Certificate2 root = X509CertificateLoader.LoadCertificateFromFile(rootPath);
    return new SocketsHttpHandler
    {
        SslOptions = new SslClientAuthenticationOptions
        {
            CertificateChainPolicy = new X509ChainPolicy
            {
                // yalnızca bu root'a güven; sistem trust store'u devre dışı
                TrustMode = X509ChainTrustMode.CustomRootTrust,
                CustomTrustStore = { root },
                RevocationMode = X509RevocationMode.NoCheck
            }
        }
    };
}

static SocketsHttpHandler CreateIssuerRuleHandler(string rootPath, string expectedIssuer)
{
    SocketsHttpHandler handler = CreateInternalCaHandler(rootPath);
    // önce normal doğrulama geçmeli, sonra ek kural: yalnızca beklenen intermediate
    handler.SslOptions.RemoteCertificateValidationCallback = (_, certificate, chain, errors) =>
        errors == SslPolicyErrors.None
        && chain?.ChainElements.Count > 1
        && chain.ChainElements[1].Certificate.Subject == expectedIssuer;
    return handler;
}

Laboratuvarda Pebble'ın root'unu container'ın sistem trust store'una ekledim; Let's Encrypt'in root'unun gerçek sistemlerde zaten orada olmasının karşılığı. Internal root ise yalnızca /certs/internal-root.crt dosyasında:

$ dotnet Bookstore.PartnerClient.dll default https://api.bookstore.example:8443/api/health
[default] OK: ok from api.bookstore.example
$ dotnet Bookstore.PartnerClient.dll default https://partner-api.bookstore.internal:8443/api/health
[default] The remote certificate is invalid because of errors in the certificate chain: PartialChain
$ dotnet Bookstore.PartnerClient.dll internal-ca https://partner-api.bookstore.internal:8443/api/health
[internal-ca] OK: ok from partner-api.bookstore.internal
$ dotnet Bookstore.PartnerClient.dll internal-ca https://api.bookstore.example:8443/api/health
[internal-ca] The remote certificate is invalid because of errors in the certificate chain: PartialChain
$ dotnet Bookstore.PartnerClient.dll internal-ca https://orders.bookstore.internal:8443/api/health
[internal-ca] The remote certificate is invalid according to the validation procedure: RemoteCertificateNameMismatch
$ dotnet Bookstore.PartnerClient.dll internal-ca https://legacy.bookstore.internal:8443/api/health
[internal-ca] OK: ok from legacy.bookstore.internal
$ dotnet Bookstore.PartnerClient.dll issuer-rule https://partner-api.bookstore.internal:8443/api/health
[issuer-rule] OK: ok from partner-api.bookstore.internal
$ dotnet Bookstore.PartnerClient.dll issuer-rule https://legacy.bookstore.internal:8443/api/health
[issuer-rule] The remote certificate was rejected by the provided RemoteCertificateValidationCallback.
$ dotnet Bookstore.PartnerClient.dll dangerous https://orders.bookstore.internal:8443/api/health
[dangerous] OK: ok from orders.bookstore.internal

Satır satır okuyalım. Varsayılan istemci public certificate'a güveniyor, internal olana güvenmiyor. internal-ca modu internal root'a güveniyor ama sadece ona. Public certificate'ı reddetmesi bir hata değil, tam istenen izolasyon: bu HttpClient yalnızca partner'a konuşacak. İsim kontrolü de çalışmaya devam ediyor; orders.bookstore.internal'a partner'ın certificate'ı sunulunca RemoteCertificateNameMismatch alıyoruz. issuer-rule modu, aynı root'a bağlı ama başka bir intermediate'ın (Issuing CA 2) verdiği legacy certificate'ını reddediyor. Callback'in doğru kullanımı bu: normal doğrulamanın sonucunu (errors == SslPolicyErrors.None) koru ve üstüne dar bir kural ekle. HttpClientHandler.ServerCertificateCustomValidationCallback da aynı katmanın HttpClientHandler üzerinden görünen hali.

Son satır ise sorunun kendisi. DangerousAcceptAnyServerCertificateValidator yanlış isimli certificate'ı da kabul etti ve aynı şekilde süresi dolmuş, self-signed ya da saldırganın ürettiği bir certificate'ı da kabul eder. Bu, araya giren birinin trafiği okumasına ve değiştirmesine izin vermek demek. Bu satır production'a genelde "lokalde internal servisin certificate'ı çalışmıyordu" diye girer ve bir #if DEBUG ya da environment kontrolüyle unutulur. Internal CA'ya güvenmenin doğru yolu yukarıdaki CustomRootTrust. Sistem genelinde güvenmen gerekiyorsa (aynı makinedeki bütün araçlar için) root'u işletim sisteminin store'una ekle: Linux'ta /usr/local/share/ca-certificates ve update-ca-certificates, Windows'ta Group Policy veya certlm.msc ile LocalMachine\Root.

Üretimde Dikkat

Yenilemeyi dışarıdan doğrula. Kestrel'in reload'u, Certbot'un hook'u ve dosya izinleri ayrı ayrı çalışabilir ama zincir bir yerden kopabilir. Sunulan certificate'ın seri numarasını ve notAfter'ını dışarıdan ölçen bir kontrol olmalı; serinin son yazısında bunu alarm haline getireceğiz.

Instance'ları aynı anda yenileme. Reload sırasında endpoint yeniden başladığı için, load balancer arkasındaki instance'lar aynı certificate dosyasını paylaşıyorsa (ortak bir volume gibi) hepsi aynı saniyede kısa bir kesinti yaşar. Yenilemeyi tek bir yerde yap ve dağıtımı kademeli tut, ya da TLS'i proxy'de bitir.

Container'da certificate'ı secret olarak mount et. Kubernetes'te Secret volume'ü, Docker'da read-only bind mount. Kubernetes Secret volume'leri güncellemeyi bir symlink'i değiştirerek yapıyor. Kestrel'in izleyicisi polling ile symlink hedeflerine de baktığı için bunu yakalaması beklenir, ama ben Kubernetes'te denemedim; ilk yenilemede dışarıdan doğrula. Secret'ı subPath ile mount edersen ise güncelleme container'a hiç gelmez. Kubernetes'te certificate otomasyonu (cert-manager) bir sonraki yazının konusu.

Internal CA root'unu konfigürasyon olarak dağıt. Her servisin HttpClient'ı internal root'u aynı yerden okusun; root değiştiğinde (ki internal CA'larda bu planlı bir iştir) tek bir dosyayı güncellemek yetsin.

Sık Düşülen Tuzaklar

Obsolete constructor'lar: .NET 9'dan beri X509Certificate2'nin dosya veya byte alan constructor'ları obsolete. Derleyici şöyle uyarıyor:

$ dotnet build -v q -nologo 2>&1 | grep SYSLIB0057 | sort -u
/src/Program.cs(3,27): warning SYSLIB0057: 'X509Certificate2.X509Certificate2(string, string?)' is obsolete: 'Loading certificate data through the constructor or Import is obsolete. Use X509CertificateLoader instead to load certificates.' (https://aka.ms/dotnet-warnings/SYSLIB0057) [/src/Bookstore.CertLoad.csproj]

Eski constructor içeriğe bakıp formatı tahmin ediyordu. Bir PFX beklerken başka bir format kabul etmesi, beklenmedik davranışlara ve güvenlik açıklarına kapı açıyordu. X509CertificateLoader.LoadPkcs12FromFile veya LoadCertificateFromFile kullan; PEM certificate ile key'i birlikte yüklemek için X509Certificate2.CreateFromPemFile hâlâ geçerli.

Linux'ta private key izinleri: Yukarıda gördüğümüz UnauthorizedAccessException'ı çözmek için Kestrel'i root çalıştırmak ya da chmod -R 755 /etc/letsencrypt yapmak yaygın ama yanlış. Uygulamaya yalnızca kendi certificate'ını, yalnızca okuma yetkisiyle ver.

Certificate'ı image'a gömmek: Dockerfile'da COPY certs/ /app/certs/ yazınca private key image'ın bir katmanına girer. Registry'ye erişimi olan herkes onu çıkarabilir, sonraki bir katmanda silsen bile. Kısa ömürlü certificate'larla image'ı her yenilemede yeniden build etmek de anlamsız. Certificate runtime'da mount edilir, image'da durmaz.

Sadece key'i değiştirmek: Kestrel KeyPath'i izlemiyor. Key'i yeni bir dosyayla değiştirip certificate'a dokunmayan bir script, reload tetiklemez. Restart'a kadar eski key ile çalışılır, restart'ta da certificate ile key uyuşmadığı için uygulama başlamaz.

Store tabanlı config'i çalışırken değiştirmek: .NET 10'un release candidate döneminde açılmış ve bu yazı yazılırken açık olan bir GitHub issue'su (dotnet/aspnetcore #64307), uygulama çalışırken appsettings.json'daki Subject/Store tabanlı certificate ayarlarını değiştirmenin CertificatePathWatcher'da ArgumentNullException ile reload'u bozduğunu bildiriyor. Ben Linux'ta bu senaryoyu denemedim; store kullanıyorsan certificate ayarını değiştirdikten sonra restart planla.

Küçük Bir Deneme

★☆☆ Kendi makinende dotnet dev-certs https --check --trust çalıştır. Certificate'ın ne zaman bittiğini bul, --export-path ile PEM olarak dışa aktar ve openssl x509 -ext subjectAltName ile hangi isimleri kapsadığına bak.

★★☆ Bu yazıdaki SNI config'ine *.internal.bookstore.example için bir girdi ekle ve a.internal.bookstore.example, internal.bookstore.example ve SNI'sız (openssl s_client'a -noservername) bağlantılarda hangi certificate'ın sunulduğunu tahmin et, sonra dene.

★★★ Yenileme sırasında Kestrel'e saniyede 50 istek gönderen bir yük üret (örneğin curl döngüsü veya ilerideki Performance ve Load Testing serisindeki araçlardan biri). Endpoint yeniden başlarken kaç istek hata alıyor? Aynı denemeyi TLS'i önde bir Nginx'te bitirip nginx -s reload ile tekrarla ve sonuçları karşılaştır.

Bu yazıda certificate'ı kendi elimizle Kestrel'e taşıdık. Bir sonraki yazıda bu işi platformun üstlendiği yerlere bakıyoruz: Caddy'nin sıfır config'li otomatik HTTPS'i, Azure App Service'in managed certificate'ları, Key Vault, Front Door ve Kubernetes'te cert-manager.