Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 29 additions & 3 deletions frameworks/aspnet-minimal/Program.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using System.Security.Cryptography.X509Certificates;

using HttpArena;
using HttpArena.Services;
using HttpArena.Types;

Expand All @@ -23,6 +24,14 @@
var keyPath = Environment.GetEnvironmentVariable("TLS_KEY") ?? "/certs/server.key";
var hasCert = File.Exists(certPath) && File.Exists(keyPath);

// The opt-in tls_check gets its own listener on :9000 and its own pair at
// /certs-tls. It rotates certificates under a running server, and pointing it
// at /certs would move the ground under json-tls, static-tls and the h2
// profiles in the same validation run.
var tlsCheckCert = "/certs-tls/server.crt";
var tlsCheckKey = "/certs-tls/server.key";
var hasTlsCheck = File.Exists(tlsCheckCert) && File.Exists(tlsCheckKey);

builder.WebHost.ConfigureKestrel(options =>
{
options.Limits.Http2.MaxStreamsPerConnection = 256;
Expand All @@ -45,12 +54,15 @@

if (hasCert)
{
var cert = X509Certificate2.CreateFromPemFile(certPath, keyPath);
// Re-read when the files change, so a rotation lands without a restart.
// The selector below runs per handshake, which is what makes that
// visible to the next connection rather than the next process.
var cert = new RotatingCertificate(certPath, keyPath);

options.ListenAnyIP(8443, lo =>
{
lo.Protocols = HttpProtocols.Http1AndHttp2AndHttp3;
lo.UseHttps(cert);
lo.UseHttps(https => https.ServerCertificateSelector = (_, _) => cert.Current);
});

// HTTP/1.1-only TLS listener for the json-tls profile. Kestrel
Expand All @@ -59,7 +71,21 @@
options.ListenAnyIP(8081, lo =>
{
lo.Protocols = HttpProtocols.Http1;
lo.UseHttps(cert);
lo.UseHttps(https => https.ServerCertificateSelector = (_, _) => cert.Current);
});
}

if (hasTlsCheck)
{
// Same rotating handle, a different pair. The selector runs per
// handshake, which is what lets a replaced file reach the next
// connection instead of the next process.
var checkCert = new RotatingCertificate(tlsCheckCert, tlsCheckKey);

options.ListenAnyIP(9000, lo =>
{
lo.Protocols = HttpProtocols.Http1;
lo.UseHttps(https => https.ServerCertificateSelector = (_, _) => checkCert.Current);
});
}
});
Expand Down
99 changes: 99 additions & 0 deletions frameworks/aspnet-minimal/RotatingCertificate.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
using System.Security.Cryptography.X509Certificates;

namespace HttpArena;

/// <summary>
/// The server certificate, re-read from disk when the file underneath it
/// changes.
///
/// Kestrel's ServerCertificateSelector runs per handshake, so this is where a
/// rotation becomes visible without a restart: the selector asks for Current,
/// and Current notices the PEM was replaced. That is what the opt-in `tls`
/// profile checks -- a certificate is renewed roughly every 60 days in
/// production, and a server that needs a restart to pick one up is a weaker
/// server.
///
/// The mtime check is throttled rather than run on every handshake. A stat is
/// cheap next to a TLS handshake, but not next to a resumed one, and a second
/// of staleness costs nothing when the thing being tracked changes every two
/// months.
/// </summary>
internal sealed class RotatingCertificate : IDisposable
{
private const int CheckIntervalMs = 1000;

private readonly string _certPath;
private readonly string _keyPath;
private readonly object _gate = new();

private X509Certificate2 _current;
private DateTime _loadedStamp;
private long _lastCheck;

public RotatingCertificate(string certPath, string keyPath)
{
_certPath = certPath;
_keyPath = keyPath;
_current = Load(certPath, keyPath);
_loadedStamp = Stamp(certPath, keyPath);
_lastCheck = Environment.TickCount64;
}

public X509Certificate2 Current
{
get
{
var now = Environment.TickCount64;
if (now - Interlocked.Read(ref _lastCheck) >= CheckIntervalMs)
{
Interlocked.Exchange(ref _lastCheck, now);
ReloadIfChanged();
}
return Volatile.Read(ref _current);
}
}

private void ReloadIfChanged()
{
try
{
var stamp = Stamp(_certPath, _keyPath);
if (stamp == _loadedStamp) return;

lock (_gate)
{
if (stamp == _loadedStamp) return;
// A rotation is two files. Loading between the two writes gives
// a mismatched pair, so a failure here is left for the next
// check rather than thrown at a handshake in progress.
var fresh = Load(_certPath, _keyPath);
var previous = _current;
Volatile.Write(ref _current, fresh);
_loadedStamp = stamp;
previous.Dispose();
}
}
catch
{
// Keep serving the certificate that works.
}
}

private static DateTime Stamp(string certPath, string keyPath)
{
var c = File.GetLastWriteTimeUtc(certPath);
var k = File.GetLastWriteTimeUtc(keyPath);
return c > k ? c : k;
}

private static X509Certificate2 Load(string certPath, string keyPath)
{
// CreateFromPemFile hands back a certificate whose key is ephemeral,
// which SslStream will not use on every platform; the PKCS12 round trip
// gives it one it will.
using var pem = X509Certificate2.CreateFromPemFile(certPath, keyPath);
return X509CertificateLoader.LoadPkcs12(pem.Export(X509ContentType.Pkcs12), null);
}

public void Dispose() => _current.Dispose();
}
3 changes: 2 additions & 1 deletion frameworks/aspnet-minimal/meta.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
"description": "Minimal ASP.NET Core server using .NET 10 with Kestrel and minimal API routing.",
"repo": "https://github.com/dotnet/aspnetcore",
"enabled": true,
"tls_check": true,
"tests": [
"baseline",
"pipelined",
Expand All @@ -36,4 +37,4 @@
"static-h3"
],
"maintainers": []
}
}
17 changes: 16 additions & 1 deletion scripts/gen_leaderboard_data.py
Original file line number Diff line number Diff line change
Expand Up @@ -3443,6 +3443,19 @@ def main():
langcolors = load("langcolors.json") or {}
current = load("current.json") or {}

# tls_check verdicts, written by validate.sh when an entry opts in. Keyed by directory name, which is what validate.sh is given;
# meta is keyed by display name, so the mapping goes through "dir".
tls_check = {}
tls_dir = ROOT / "site" / "data" / "tls"
if tls_dir.is_dir():
for f in sorted(tls_dir.glob("*.json")):
try:
v = json.loads(f.read_text())
except Exception:
continue
if v.get("check"):
tls_check[f.stem] = v["check"]

meta = {n: {"type": m.get("type", "emerging"),
"mode": m.get("mode", "standard"),
"language": m.get("language", ""),
Expand All @@ -3454,7 +3467,9 @@ def main():
# Only ever set when the probes ran and were clean. Absent
# means unverified, which the board renders as no shield
# rather than as a failure.
} for n, m in frameworks.items()}
# "pass" only when the opt-in section ran and its own checks
# were clean. Absent for every entry that did not opt in.
"tlsCheck": tls_check.get(m.get("dir", ""))} for n, m in frameworks.items()}

docs_tree, docs_content = build_docs()

Expand Down
Loading
Loading