Part 34: TLS — Init, Install, Trust
"Self-signed certs are fine. Self-signed certs your browser doesn't trust are not."
Why
DevLab serves everything over HTTPS. That requires certificates. There are three ways to get them:
- Let's Encrypt — free, trusted by every browser, requires public DNS resolvable by LE's challenge servers. Disqualified: a homelab has no public DNS.
- A real DV cert from a CA — works, costs money, requires manual renewal. Disqualified: cost and friction.
- A self-signed CA + a wildcard cert for
*.frenchexdev.lab— free, fast, works offline, zero renewal friction. Selected.
The catch with option 3 is that browsers do not trust the CA by default. You can ignore the warning, or you can install the CA into the OS trust store and the browser trust store. HomeLab provides a third option that is both: homelab tls init generates the CA, homelab tls install distributes it to every place that needs to trust it, and homelab tls trust adds it to the OS store on macOS, Linux, and Windows.
The thesis of this part is: TLS is a three-verb story. Init creates. Install distributes. Trust enrolls. The user runs all three once, and DevLab is HTTPS-clean for the next year.
The shape
public interface ITlsCertificateProvider
{
string Name { get; }
Task<Result<TlsCertificateBundle>> GenerateCaAsync(string caName, CancellationToken ct);
Task<Result<TlsCertificateBundle>> GenerateCertAsync(TlsCertificateBundle ca, string commonName, string[] sans, CancellationToken ct);
}
public sealed record TlsCertificateBundle(
byte[] Certificate,
byte[] PrivateKey,
string? CertPath,
string? KeyPath);public interface ITlsCertificateProvider
{
string Name { get; }
Task<Result<TlsCertificateBundle>> GenerateCaAsync(string caName, CancellationToken ct);
Task<Result<TlsCertificateBundle>> GenerateCertAsync(TlsCertificateBundle ca, string commonName, string[] sans, CancellationToken ct);
}
public sealed record TlsCertificateBundle(
byte[] Certificate,
byte[] PrivateKey,
string? CertPath,
string? KeyPath);Two providers ship with HomeLab:
NativeTlsProvider — pure C#
[Injectable(ServiceLifetime.Singleton)]
public sealed class NativeTlsProvider : ITlsCertificateProvider
{
public string Name => "native";
public Task<Result<TlsCertificateBundle>> GenerateCaAsync(string caName, CancellationToken ct)
{
try
{
using var rsa = RSA.Create(2048);
var req = new CertificateRequest(
subjectName: $"CN={caName}",
key: rsa,
hashAlgorithm: HashAlgorithmName.SHA256,
padding: RSASignaturePadding.Pkcs1);
req.CertificateExtensions.Add(new X509BasicConstraintsExtension(certificateAuthority: true, hasPathLengthConstraint: false, pathLengthConstraint: 0, critical: true));
req.CertificateExtensions.Add(new X509KeyUsageExtension(X509KeyUsageFlags.KeyCertSign | X509KeyUsageFlags.CrlSign, critical: true));
req.CertificateExtensions.Add(new X509SubjectKeyIdentifierExtension(req.PublicKey, critical: false));
var notBefore = DateTimeOffset.UtcNow.AddDays(-1);
var notAfter = DateTimeOffset.UtcNow.AddYears(10);
using var cert = req.CreateSelfSigned(notBefore, notAfter);
return Task.FromResult(Result.Success(new TlsCertificateBundle(
Certificate: cert.Export(X509ContentType.Cert),
PrivateKey: rsa.ExportPkcs8PrivateKey(),
CertPath: null,
KeyPath: null)));
}
catch (Exception ex)
{
return Task.FromResult(Result.Failure<TlsCertificateBundle>($"CA generation failed: {ex.Message}"));
}
}
public Task<Result<TlsCertificateBundle>> GenerateCertAsync(TlsCertificateBundle ca, string commonName, string[] sans, CancellationToken ct)
{
try
{
using var caCert = X509Certificate2.CreateFromPem(
Encoding.UTF8.GetString(ca.Certificate),
Encoding.UTF8.GetString(ca.PrivateKey));
using var rsa = RSA.Create(2048);
var req = new CertificateRequest($"CN={commonName}", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
req.CertificateExtensions.Add(new X509KeyUsageExtension(X509KeyUsageFlags.DigitalSignature | X509KeyUsageFlags.KeyEncipherment, critical: true));
req.CertificateExtensions.Add(new X509EnhancedKeyUsageExtension(new OidCollection { new("1.3.6.1.5.5.7.3.1") }, critical: false));
var sanBuilder = new SubjectAlternativeNameBuilder();
foreach (var san in sans)
{
if (san.StartsWith("*."))
{
sanBuilder.AddDnsName(san);
}
else
{
sanBuilder.AddDnsName(san);
}
}
req.CertificateExtensions.Add(sanBuilder.Build());
var notBefore = DateTimeOffset.UtcNow.AddDays(-1);
var notAfter = DateTimeOffset.UtcNow.AddYears(2);
var serialNumber = RandomNumberGenerator.GetBytes(16);
using var leaf = req.Create(caCert, notBefore, notAfter, serialNumber);
return Task.FromResult(Result.Success(new TlsCertificateBundle(
Certificate: leaf.Export(X509ContentType.Cert),
PrivateKey: rsa.ExportPkcs8PrivateKey(),
CertPath: null,
KeyPath: null)));
}
catch (Exception ex)
{
return Task.FromResult(Result.Failure<TlsCertificateBundle>($"Cert generation failed: {ex.Message}"));
}
}
}[Injectable(ServiceLifetime.Singleton)]
public sealed class NativeTlsProvider : ITlsCertificateProvider
{
public string Name => "native";
public Task<Result<TlsCertificateBundle>> GenerateCaAsync(string caName, CancellationToken ct)
{
try
{
using var rsa = RSA.Create(2048);
var req = new CertificateRequest(
subjectName: $"CN={caName}",
key: rsa,
hashAlgorithm: HashAlgorithmName.SHA256,
padding: RSASignaturePadding.Pkcs1);
req.CertificateExtensions.Add(new X509BasicConstraintsExtension(certificateAuthority: true, hasPathLengthConstraint: false, pathLengthConstraint: 0, critical: true));
req.CertificateExtensions.Add(new X509KeyUsageExtension(X509KeyUsageFlags.KeyCertSign | X509KeyUsageFlags.CrlSign, critical: true));
req.CertificateExtensions.Add(new X509SubjectKeyIdentifierExtension(req.PublicKey, critical: false));
var notBefore = DateTimeOffset.UtcNow.AddDays(-1);
var notAfter = DateTimeOffset.UtcNow.AddYears(10);
using var cert = req.CreateSelfSigned(notBefore, notAfter);
return Task.FromResult(Result.Success(new TlsCertificateBundle(
Certificate: cert.Export(X509ContentType.Cert),
PrivateKey: rsa.ExportPkcs8PrivateKey(),
CertPath: null,
KeyPath: null)));
}
catch (Exception ex)
{
return Task.FromResult(Result.Failure<TlsCertificateBundle>($"CA generation failed: {ex.Message}"));
}
}
public Task<Result<TlsCertificateBundle>> GenerateCertAsync(TlsCertificateBundle ca, string commonName, string[] sans, CancellationToken ct)
{
try
{
using var caCert = X509Certificate2.CreateFromPem(
Encoding.UTF8.GetString(ca.Certificate),
Encoding.UTF8.GetString(ca.PrivateKey));
using var rsa = RSA.Create(2048);
var req = new CertificateRequest($"CN={commonName}", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
req.CertificateExtensions.Add(new X509KeyUsageExtension(X509KeyUsageFlags.DigitalSignature | X509KeyUsageFlags.KeyEncipherment, critical: true));
req.CertificateExtensions.Add(new X509EnhancedKeyUsageExtension(new OidCollection { new("1.3.6.1.5.5.7.3.1") }, critical: false));
var sanBuilder = new SubjectAlternativeNameBuilder();
foreach (var san in sans)
{
if (san.StartsWith("*."))
{
sanBuilder.AddDnsName(san);
}
else
{
sanBuilder.AddDnsName(san);
}
}
req.CertificateExtensions.Add(sanBuilder.Build());
var notBefore = DateTimeOffset.UtcNow.AddDays(-1);
var notAfter = DateTimeOffset.UtcNow.AddYears(2);
var serialNumber = RandomNumberGenerator.GetBytes(16);
using var leaf = req.Create(caCert, notBefore, notAfter, serialNumber);
return Task.FromResult(Result.Success(new TlsCertificateBundle(
Certificate: leaf.Export(X509ContentType.Cert),
PrivateKey: rsa.ExportPkcs8PrivateKey(),
CertPath: null,
KeyPath: null)));
}
catch (Exception ex)
{
return Task.FromResult(Result.Failure<TlsCertificateBundle>($"Cert generation failed: {ex.Message}"));
}
}
}Pure System.Security.Cryptography. Zero external dependencies. Produces a 10-year CA and a 2-year wildcard cert. Both signed correctly, both with the right extensions, both ready to be served by Traefik.
MkcertProvider — wraps the mkcert binary
[BinaryWrapper("mkcert", HelpCommand = "-help")]
public partial class MkcertClient
{
[Command("")]
public partial Task<Result<MkcertOutput>> CreateAsync(
[PositionalArgument(IsList = true)] IReadOnlyList<string> hosts,
[Flag("-cert-file")] string? certFile = null,
[Flag("-key-file")] string? keyFile = null,
CancellationToken ct = default);
[Command("-install")]
public partial Task<Result<MkcertInstallOutput>> InstallAsync(CancellationToken ct = default);
[Command("-CAROOT")]
public partial Task<Result<MkcertCarootOutput>> CarootAsync(CancellationToken ct = default);
}
[Injectable(ServiceLifetime.Singleton)]
public sealed class MkcertTlsProvider : ITlsCertificateProvider
{
private readonly MkcertClient _mkcert;
public string Name => "mkcert";
public async Task<Result<TlsCertificateBundle>> GenerateCaAsync(string caName, CancellationToken ct)
{
// mkcert manages its own CA in ~/.local/share/mkcert
// We just call -install (idempotent) and read the CAROOT files
var install = await _mkcert.InstallAsync(ct);
if (install.IsFailure) return install.Map<TlsCertificateBundle>();
var caroot = await _mkcert.CarootAsync(ct);
if (caroot.IsFailure) return caroot.Map<TlsCertificateBundle>();
var caCertBytes = await File.ReadAllBytesAsync(Path.Combine(caroot.Value.Path, "rootCA.pem"), ct);
var caKeyBytes = await File.ReadAllBytesAsync(Path.Combine(caroot.Value.Path, "rootCA-key.pem"), ct);
return Result.Success(new TlsCertificateBundle(caCertBytes, caKeyBytes, /* paths */));
}
public async Task<Result<TlsCertificateBundle>> GenerateCertAsync(TlsCertificateBundle ca, string commonName, string[] sans, CancellationToken ct)
{
var tempDir = Path.GetTempPath();
var certFile = Path.Combine(tempDir, $"{commonName}.crt");
var keyFile = Path.Combine(tempDir, $"{commonName}.key");
var hosts = sans.Concat(new[] { commonName }).Distinct().ToArray();
var result = await _mkcert.CreateAsync(hosts, certFile, keyFile, ct);
if (result.IsFailure) return result.Map<TlsCertificateBundle>();
var certBytes = await File.ReadAllBytesAsync(certFile, ct);
var keyBytes = await File.ReadAllBytesAsync(keyFile, ct);
return Result.Success(new TlsCertificateBundle(certBytes, keyBytes, certFile, keyFile));
}
}[BinaryWrapper("mkcert", HelpCommand = "-help")]
public partial class MkcertClient
{
[Command("")]
public partial Task<Result<MkcertOutput>> CreateAsync(
[PositionalArgument(IsList = true)] IReadOnlyList<string> hosts,
[Flag("-cert-file")] string? certFile = null,
[Flag("-key-file")] string? keyFile = null,
CancellationToken ct = default);
[Command("-install")]
public partial Task<Result<MkcertInstallOutput>> InstallAsync(CancellationToken ct = default);
[Command("-CAROOT")]
public partial Task<Result<MkcertCarootOutput>> CarootAsync(CancellationToken ct = default);
}
[Injectable(ServiceLifetime.Singleton)]
public sealed class MkcertTlsProvider : ITlsCertificateProvider
{
private readonly MkcertClient _mkcert;
public string Name => "mkcert";
public async Task<Result<TlsCertificateBundle>> GenerateCaAsync(string caName, CancellationToken ct)
{
// mkcert manages its own CA in ~/.local/share/mkcert
// We just call -install (idempotent) and read the CAROOT files
var install = await _mkcert.InstallAsync(ct);
if (install.IsFailure) return install.Map<TlsCertificateBundle>();
var caroot = await _mkcert.CarootAsync(ct);
if (caroot.IsFailure) return caroot.Map<TlsCertificateBundle>();
var caCertBytes = await File.ReadAllBytesAsync(Path.Combine(caroot.Value.Path, "rootCA.pem"), ct);
var caKeyBytes = await File.ReadAllBytesAsync(Path.Combine(caroot.Value.Path, "rootCA-key.pem"), ct);
return Result.Success(new TlsCertificateBundle(caCertBytes, caKeyBytes, /* paths */));
}
public async Task<Result<TlsCertificateBundle>> GenerateCertAsync(TlsCertificateBundle ca, string commonName, string[] sans, CancellationToken ct)
{
var tempDir = Path.GetTempPath();
var certFile = Path.Combine(tempDir, $"{commonName}.crt");
var keyFile = Path.Combine(tempDir, $"{commonName}.key");
var hosts = sans.Concat(new[] { commonName }).Distinct().ToArray();
var result = await _mkcert.CreateAsync(hosts, certFile, keyFile, ct);
if (result.IsFailure) return result.Map<TlsCertificateBundle>();
var certBytes = await File.ReadAllBytesAsync(certFile, ct);
var keyBytes = await File.ReadAllBytesAsync(keyFile, ct);
return Result.Success(new TlsCertificateBundle(certBytes, keyBytes, certFile, keyFile));
}
}mkcert is the popular tool for local cert generation. It comes with one significant advantage: its -install command adds the CA to the OS trust store automatically — including the macOS keychain, the Linux NSS store (Firefox), and the Windows certificate store. That alone is worth using.
The user picks the provider via tls.provider: native | mkcert in the config. We default to native because it has zero external dependencies; we recommend mkcert for users who want easier trust enrollment.
The three verbs
[Injectable(ServiceLifetime.Singleton)]
public sealed class TlsInitRequestHandler : IRequestHandler<TlsInitRequest, Result<TlsInitResponse>>
{
public async Task<Result<TlsInitResponse>> HandleAsync(TlsInitRequest req, CancellationToken ct)
{
var provider = _providers.SingleOrDefault(p => p.Name == req.Provider)
?? return Result.Failure<TlsInitResponse>($"unknown provider: {req.Provider}");
var ca = await provider.GenerateCaAsync(req.CaName, ct);
if (ca.IsFailure) return ca.Map<TlsInitResponse>();
var sans = new[] { req.Domain, $"*.{req.Domain}" };
var cert = await provider.GenerateCertAsync(ca.Value, req.Domain, sans, ct);
if (cert.IsFailure) return cert.Map<TlsInitResponse>();
var caPath = Path.Combine(req.OutputDir.FullName, "ca.crt");
var caKeyPath = Path.Combine(req.OutputDir.FullName, "ca.key");
var certPath = Path.Combine(req.OutputDir.FullName, "wildcard.crt");
var keyPath = Path.Combine(req.OutputDir.FullName, "wildcard.key");
Directory.CreateDirectory(req.OutputDir.FullName);
await File.WriteAllBytesAsync(caPath, ca.Value.Certificate, ct);
await File.WriteAllBytesAsync(caKeyPath, ca.Value.PrivateKey, ct);
await File.WriteAllBytesAsync(certPath, cert.Value.Certificate, ct);
await File.WriteAllBytesAsync(keyPath, cert.Value.PrivateKey, ct);
await _events.PublishAsync(new TlsCaGenerated(req.CaName, caPath, _clock.UtcNow), ct);
await _events.PublishAsync(new TlsCertIssued(req.Domain, certPath, keyPath, _clock.UtcNow.AddYears(2), _clock.UtcNow), ct);
return Result.Success(new TlsInitResponse(caPath, certPath, keyPath));
}
}[Injectable(ServiceLifetime.Singleton)]
public sealed class TlsInitRequestHandler : IRequestHandler<TlsInitRequest, Result<TlsInitResponse>>
{
public async Task<Result<TlsInitResponse>> HandleAsync(TlsInitRequest req, CancellationToken ct)
{
var provider = _providers.SingleOrDefault(p => p.Name == req.Provider)
?? return Result.Failure<TlsInitResponse>($"unknown provider: {req.Provider}");
var ca = await provider.GenerateCaAsync(req.CaName, ct);
if (ca.IsFailure) return ca.Map<TlsInitResponse>();
var sans = new[] { req.Domain, $"*.{req.Domain}" };
var cert = await provider.GenerateCertAsync(ca.Value, req.Domain, sans, ct);
if (cert.IsFailure) return cert.Map<TlsInitResponse>();
var caPath = Path.Combine(req.OutputDir.FullName, "ca.crt");
var caKeyPath = Path.Combine(req.OutputDir.FullName, "ca.key");
var certPath = Path.Combine(req.OutputDir.FullName, "wildcard.crt");
var keyPath = Path.Combine(req.OutputDir.FullName, "wildcard.key");
Directory.CreateDirectory(req.OutputDir.FullName);
await File.WriteAllBytesAsync(caPath, ca.Value.Certificate, ct);
await File.WriteAllBytesAsync(caKeyPath, ca.Value.PrivateKey, ct);
await File.WriteAllBytesAsync(certPath, cert.Value.Certificate, ct);
await File.WriteAllBytesAsync(keyPath, cert.Value.PrivateKey, ct);
await _events.PublishAsync(new TlsCaGenerated(req.CaName, caPath, _clock.UtcNow), ct);
await _events.PublishAsync(new TlsCertIssued(req.Domain, certPath, keyPath, _clock.UtcNow.AddYears(2), _clock.UtcNow), ct);
return Result.Success(new TlsInitResponse(caPath, certPath, keyPath));
}
}tls install copies the cert and key into the location Traefik mounts (the Vagrant synced folder data/certs/), so the next compose deploy picks them up.
tls trust enrols the CA into the OS trust store. On macOS:
private async Task<Result> TrustOnMacOsAsync(string caPath, CancellationToken ct)
{
return await _security.RunAsync(new[] { "add-trusted-cert", "-d", "-r", "trustRoot", "-k", "/Library/Keychains/System.keychain", caPath }, ct);
}private async Task<Result> TrustOnMacOsAsync(string caPath, CancellationToken ct)
{
return await _security.RunAsync(new[] { "add-trusted-cert", "-d", "-r", "trustRoot", "-k", "/Library/Keychains/System.keychain", caPath }, ct);
}On Linux (Debian/Ubuntu):
private async Task<Result> TrustOnLinuxAsync(string caPath, CancellationToken ct)
{
var dest = "/usr/local/share/ca-certificates/devlab-ca.crt";
await File.CopyAsync(caPath, dest);
return await _shell.RunAsync("update-ca-certificates", ct);
}private async Task<Result> TrustOnLinuxAsync(string caPath, CancellationToken ct)
{
var dest = "/usr/local/share/ca-certificates/devlab-ca.crt";
await File.CopyAsync(caPath, dest);
return await _shell.RunAsync("update-ca-certificates", ct);
}On Windows:
private async Task<Result> TrustOnWindowsAsync(string caPath, CancellationToken ct)
{
return await _certutil.RunAsync(new[] { "-addstore", "-f", "ROOT", caPath }, ct);
}private async Task<Result> TrustOnWindowsAsync(string caPath, CancellationToken ct)
{
return await _certutil.RunAsync(new[] { "-addstore", "-f", "ROOT", caPath }, ct);
}The platform-specific paths are encapsulated. The user runs homelab tls trust and it Just Works on whichever OS they are on.
The test
public sealed class NativeTlsProviderTests
{
[Fact]
public async Task ca_generation_produces_valid_certificate()
{
var provider = new NativeTlsProvider();
var result = await provider.GenerateCaAsync("Test CA", CancellationToken.None);
result.IsSuccess.Should().BeTrue();
var cert = X509Certificate2.CreateFromPem(Encoding.UTF8.GetString(result.Value.Certificate));
cert.Subject.Should().Contain("Test CA");
cert.HasPrivateKey.Should().BeFalse(); // private key is in the Bundle, not in the cert export
var bc = cert.Extensions.OfType<X509BasicConstraintsExtension>().Single();
bc.CertificateAuthority.Should().BeTrue();
}
[Fact]
public async Task wildcard_cert_has_correct_san_list()
{
var provider = new NativeTlsProvider();
var ca = await provider.GenerateCaAsync("Test CA", CancellationToken.None);
var cert = await provider.GenerateCertAsync(ca.Value, "frenchexdev.lab",
new[] { "frenchexdev.lab", "*.frenchexdev.lab" },
CancellationToken.None);
cert.IsSuccess.Should().BeTrue();
var x509 = X509Certificate2.CreateFromPem(Encoding.UTF8.GetString(cert.Value.Certificate));
var san = x509.Extensions.OfType<X509SubjectAlternativeNameExtension>().Single();
san.EnumerateDnsNames().Should().Contain("frenchexdev.lab");
san.EnumerateDnsNames().Should().Contain("*.frenchexdev.lab");
}
[Fact]
public async Task wildcard_cert_chains_to_the_ca()
{
var provider = new NativeTlsProvider();
var ca = await provider.GenerateCaAsync("Test CA", CancellationToken.None);
var cert = await provider.GenerateCertAsync(ca.Value, "test.lab", new[] { "test.lab" }, CancellationToken.None);
var caCert = X509Certificate2.CreateFromPem(Encoding.UTF8.GetString(ca.Value.Certificate));
var leaf = X509Certificate2.CreateFromPem(Encoding.UTF8.GetString(cert.Value.Certificate));
using var chain = new X509Chain();
chain.ChainPolicy.ExtraStore.Add(caCert);
chain.ChainPolicy.RevocationMode = X509RevocationMode.NoCheck;
chain.ChainPolicy.VerificationFlags = X509VerificationFlags.AllowUnknownCertificateAuthority;
chain.Build(leaf).Should().BeTrue();
}
}public sealed class NativeTlsProviderTests
{
[Fact]
public async Task ca_generation_produces_valid_certificate()
{
var provider = new NativeTlsProvider();
var result = await provider.GenerateCaAsync("Test CA", CancellationToken.None);
result.IsSuccess.Should().BeTrue();
var cert = X509Certificate2.CreateFromPem(Encoding.UTF8.GetString(result.Value.Certificate));
cert.Subject.Should().Contain("Test CA");
cert.HasPrivateKey.Should().BeFalse(); // private key is in the Bundle, not in the cert export
var bc = cert.Extensions.OfType<X509BasicConstraintsExtension>().Single();
bc.CertificateAuthority.Should().BeTrue();
}
[Fact]
public async Task wildcard_cert_has_correct_san_list()
{
var provider = new NativeTlsProvider();
var ca = await provider.GenerateCaAsync("Test CA", CancellationToken.None);
var cert = await provider.GenerateCertAsync(ca.Value, "frenchexdev.lab",
new[] { "frenchexdev.lab", "*.frenchexdev.lab" },
CancellationToken.None);
cert.IsSuccess.Should().BeTrue();
var x509 = X509Certificate2.CreateFromPem(Encoding.UTF8.GetString(cert.Value.Certificate));
var san = x509.Extensions.OfType<X509SubjectAlternativeNameExtension>().Single();
san.EnumerateDnsNames().Should().Contain("frenchexdev.lab");
san.EnumerateDnsNames().Should().Contain("*.frenchexdev.lab");
}
[Fact]
public async Task wildcard_cert_chains_to_the_ca()
{
var provider = new NativeTlsProvider();
var ca = await provider.GenerateCaAsync("Test CA", CancellationToken.None);
var cert = await provider.GenerateCertAsync(ca.Value, "test.lab", new[] { "test.lab" }, CancellationToken.None);
var caCert = X509Certificate2.CreateFromPem(Encoding.UTF8.GetString(ca.Value.Certificate));
var leaf = X509Certificate2.CreateFromPem(Encoding.UTF8.GetString(cert.Value.Certificate));
using var chain = new X509Chain();
chain.ChainPolicy.ExtraStore.Add(caCert);
chain.ChainPolicy.RevocationMode = X509RevocationMode.NoCheck;
chain.ChainPolicy.VerificationFlags = X509VerificationFlags.AllowUnknownCertificateAuthority;
chain.Build(leaf).Should().BeTrue();
}
}What this gives you that bash doesn't
A bash script that generates a CA and a wildcard cert is openssl genrsa, openssl req -new, openssl x509 -req, three config files, two subjectAltName lines that are easy to get wrong, and a comment that says # I never remember the syntax. The script then sudo cps the CA into /usr/local/share/ca-certificates/ and runs update-ca-certificates, which works on Debian and breaks on Alpine and breaks differently on macOS.
A typed two-provider TLS layer with three verbs gives you, for the same surface area:
- A native pure-C# CA + cert generator with no external dependencies
- A mkcert wrapper for users who want browser trust for free
- Three CLI verbs (
init,install,trust) that handle generation, distribution, enrollment - Cross-platform OS trust enrollment via small platform-specific helpers
- Tests that exercise the cert generation and chain validity
The bargain pays back the first time you bring up DevLab on a fresh machine, run homelab tls init && homelab tls install && homelab tls trust, and load https://gitlab.frenchexdev.lab in a browser that trusts the cert without any warnings.