Intro

When dealing with TLS and containers, a common pattern is to terminate the TLS connection in a load balancer and then speak http/1 between the loadbalancer and the container. It has a number of benefits, mainly that you don't need to deal with certificates in your container.

But sometimes you need to speak http/2 "all the way" to the container (when using grpc for example).

I'll show you my setup. I use Traefik in my Kubernetes cluster but that's not important, it will only passthrough the request to the ASP.NET container anyway, you can use whatever load balancer you want.

My setup

ASP.NET Core

I have a simple ASP.NET Core application running ASP.NET Core 8 called JOS.Echo. It's configured to listen on port 443.

Dockerfile

FROM mcr.microsoft.com/dotnet/sdk:8.0 as builder
# https://learn.microsoft.com/en-us/nuget/reference/cli-reference/cli-ref-environment-variables
ARG informationalVersion
ARG version
ENV NUGET_XMLDOC_MODE=none
COPY . app/
WORKDIR /app

RUN dotnet build -c Release /p:AssemblyVersion=$version /p:InformationalVersion=$informationalVersion
RUN dotnet test -c Release --no-build
RUN dotnet publish src/JOS.Echo -c Release -o published --no-build

FROM mcr.microsoft.com/dotnet/aspnet:8.0

COPY --from=builder /app/published/ /app/
WORKDIR /app

RUN apt-get update && apt-get -y install libcap2-bin
RUN setcap 'cap_net_bind_service=+ep' /usr/share/dotnet/dotnet

USER 1000

ENV ASPNETCORE_URLS https://+:443
EXPOSE 443/tcp

ENTRYPOINT ["dotnet", "JOS.Echo.dll"]

Since I wanted the application to listen on port 443 inside the container, I had to use setcap because I don't want to run the container as root. If you run the container as a non-root user, you are not allowed to bind to a port lower than 1024. setcap enables the dotnet binary to bind to a port lower than 1024 as a non-root user, 443 in this case.

Traefik

IngressRoute

apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
  name: jos-echo-ingress-route
  annotations:
    kubernetes.io/ingress.class: traefik-internal
spec:
  entryPoints:
    - websecure
  routes:
    - match: Host(`echo.local.josef.guru`)
      kind: Rule
      services:
        - name: jos-echo-service
          port: 443

Kubernetes

Vanilla Kuberentes. I use cert-manager for dealing with certificates. More on that later.
Service

apiVersion: v1
kind: Service
metadata:
  name: jos-echo-service
spec:
  ports:
    - name: http
      port: 443
      targetPort: http-web-svc
  selector:
    app: jos-echo

Deployment

apiVersion: apps/v1
kind: Deployment
metadata:
  name: jos-echo-deployment
spec:
  replicas: 3
  selector:
    matchLabels:
      app: jos-echo
  template:
    metadata:
      labels:
        app: jos-echo
    spec:
      imagePullSecrets:
        - name: dockerconfig-ghcr.io
      containers:
        - name: jos-echo-container
          image: ghcr.io/joseftw/jos-echo:1.0.30-ci.g83055582fe
          env:
            - name: "Server__Port"
              value: "443"
            - name: "TLS__Certificate__CertificateFile"
              value: "/certs/tls.crt"
            - name: "TLS__Certificate__KeyFile"
              value: "/certs/tls.key"
          volumeMounts:
            - name: certs-volume
              mountPath: /certs
              readOnly: true
          ports:
            - containerPort: 443
              name: http-web-svc
      volumes:
        - name: certs-volume
          secret:
            secretName: local-josef-guru-tls

Kubernetes

Since http/2 requires the use of https, I thought it made sense to quickly show how I handle certificates in my cluster.
I'm using cert-manager.
My local domain is local.josef.guru so I've created a wildcard certificate with cert-manager.
Two custom resources are needed, a ClusterIssuer and a Certificate.

apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
  name: letsencrypt-production
spec:
  acme:
    server: https://acme-v02.api.letsencrypt.org/directory
    email: josef@josef.guru
    privateKeySecretRef:
      name: letsencrypt-production
    solvers:
      - dns01:
          cloudflare:
            email: josef@josef.guru
            apiTokenSecretRef:
              name: cloudflare-token-secret
              key: cloudflare-token
        selector:
          dnsZones:
            - "josef.guru"
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
  name: local-josef-guru
  namespace: default
spec:
  secretName: local-josef-guru-tls
  issuerRef:
    name: letsencrypt-production
    kind: ClusterIssuer
  commonName: "*.local.josef.guru"
  dnsNames:
    - "local.josef.guru"
    - "*.local.josef.guru"
  secretTemplate:
    annotations:
      reflector.v1.k8s.emberstack.com/reflection-allowed: "true"
      reflector.v1.k8s.emberstack.com/reflection-allowed-namespaces: ""
      reflector.v1.k8s.emberstack.com/reflection-auto-enabled: "true"
      reflector.v1.k8s.emberstack.com/reflection-auto-namespaces: ""

Cert-manager will create a secret named local-josef-guru-tls that contains the certificate. As you can see, the certificate will be created in the default namespace, so I'm using reflector to make it possible to access the secret from other namespaces.

If you want to know more about cert-manager I highly recommend this video by Techo Tim.

Traefik

Since I don't want to terminate the tls connection in Traefik, I don't need to do any additional configuration of Traefik, it will happily forward the request to my container without intervention.

JOS.Echo - The ASP.NET Core portion

"Mount" the certificate

In the deployment.yml file, the following can be found:

....
containers:
  ....
  volumeMounts:
    - name: certs-volume
      mountPath: /certs
      readOnly: true
volumes:
  - name: certs-volume
    secret:
      secretName: local-josef-guru-tls

Here, I'm mounting the contents of the local-josef-guru-tls secret (the certificate) to /certs. This will enable us to access the certificate inside our container.

One nice thing with doing it this way is that if the certificate gets updated, it will be reflected immediately inside the container.

Since the certificate can be updated while the container is running, I don't want to cache it at startup. But at the same time, I don't want to read the certificate from disk on every request either.

That's why I'm making use of ServerCertificateSelector. This code will run on every request.

public class CachingMountedCertificateReader
{
    private static readonly SemaphoreSlim Semaphore;
    private readonly MountedCertificateReader _certificateReader;
    private static DateTime? LastRefreshed;
    private static X509Certificate2? _certificate;

    static CachingMountedCertificateReader()
    {
        Semaphore = new SemaphoreSlim(1, 1);
    }

    public CachingMountedCertificateReader(MountedCertificateReader certificateReader)
    {
        _certificateReader = certificateReader ?? throw new ArgumentNullException(nameof(certificateReader));
    }

    public X509Certificate2 Read()
    {
        if (!NeedsRefresh(_certificate))
        {
            return _certificate!;
        }

        Semaphore.Wait();

        if (!NeedsRefresh(_certificate))
        {
            Semaphore.Release(1);
            return _certificate!;
        }

        _certificate = _certificateReader.Read();
        LastRefreshed = DateTime.UtcNow;
        Semaphore.Release(1);
        return _certificate;
    }

    private static bool NeedsRefresh(X509Certificate2? certificate)
    {
        return certificate is null || certificate.NotAfter <= DateTime.UtcNow || (DateTime.UtcNow - LastRefreshed!.Value).TotalMinutes >= 60;
    }
}
public class MountedCertificateReader
{
    private readonly TlsConfiguration _tlsConfiguration;

    public MountedCertificateReader(TlsConfiguration tlsConfiguration)
    {
        _tlsConfiguration = tlsConfiguration ?? throw new ArgumentNullException(nameof(tlsConfiguration));
    }

    public X509Certificate2 Read()
    {
        if (!_tlsConfiguration.HasCertificate)
        {
            throw new Exception("No certificate has been configured");
        }

        var certificate = _tlsConfiguration.Certificate!;
        if (!certificate.HasKeyFile)
        {
            return X509Certificate2.CreateFromPemFile(certificate.CertificateFile);
        }

        if (certificate.HasPassword)
        {
            return X509Certificate2.CreateFromEncryptedPemFile(
                certificate.CertificateFile,
                certificate.Password,
                certificate.KeyFile);
        }

        return X509Certificate2.CreateFromPemFile(
            certificate.CertificateFile,
            certificate.KeyFile);
    }
}

It works like this:

  1. Check if the certificate needs refresh (refresh in this case just means "read from disk")
  2. If it doesn't, return the cached certificate.
  3. If it does -> read the certificate from disk and cache it.

Putting it all together

To enable Kestrel to use http/2, you'll need to do the following:

builder.WebHost.ConfigureKestrel((_, options) =>
{
    options.ListenAnyIP(serverConfiguration.Port, listenOptions =>
    {
        if (tlsConfiguration.HasCertificate)
        {
            listenOptions.Protocols = HttpProtocols.Http2 | HttpProtocols.Http3;
            listenOptions.UseHttps(httpsOptions =>
            {
                httpsOptions.ServerCertificateSelector = (_, _) => certificateReader.Read();
            });
        }
        else
        {
            listenOptions.Protocols = HttpProtocols.Http1;
        }
    });
});

As you can see, if a certificate is supplied, Kestrel will enable http2 AND http3 (http3 will be a separate post in the future).

I've also created a simple endpoint that just echoes the request information back in the response body.

A call to / would return a bunch of details regarding the request, but the only thing we care about in this post is the following:

{
    ...
    "request": {
        "protocol": "HTTP/2"
    }
    ...
}

This means that http/2 works "all the way" from the users browser -> Traefik -> Kestrel.

All code for JOS.Echo can be found on GitHub.