feat(batch10): task4 wire ocsp cache load save and server lifecycle

This commit is contained in:
Joseph Doherty
2026-02-28 13:05:45 -05:00
parent 98cf350383
commit f5a13bedff
10 changed files with 674 additions and 14 deletions

View File

@@ -3,6 +3,7 @@
using System.Globalization;
using System.Formats.Asn1;
using System.Text.RegularExpressions;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using System.Text.Json;
@@ -24,6 +25,8 @@ internal static class OcspHandler
internal const string OcspResponseCacheTypeLocal = "local";
internal static readonly TimeSpan OcspResponseCacheMinimumSaveInterval = TimeSpan.FromSeconds(1);
internal static readonly TimeSpan OcspResponseCacheDefaultSaveInterval = TimeSpan.FromMinutes(5);
internal const string OcspResponseCacheDefaultFilename = "cache.json";
internal const string OcspResponseCacheDefaultTempFilePrefix = "ocsprc-";
internal static OcspResponseCacheConfig NewOCSPResponseCacheConfig() =>
new()
@@ -34,6 +37,82 @@ internal static class OcspHandler
SaveInterval = OcspResponseCacheDefaultSaveInterval.TotalSeconds,
};
internal static (OcspResponseCacheConfig? config, Exception? error) ParseOCSPResponseCache(object? value)
{
if (value is not IDictionary<string, object?> map)
{
return (null, new InvalidOperationException(
string.Format(CultureInfo.InvariantCulture, OcspMessages.ErrIllegalCacheOptsConfig, value ?? "null")));
}
var config = NewOCSPResponseCacheConfig();
foreach (var (key, raw) in map)
{
switch (key.ToLowerInvariant())
{
case "type":
if (raw is not string cacheType)
{
return (null, new InvalidOperationException(
string.Format(CultureInfo.InvariantCulture, OcspMessages.ErrParsingCacheOptFieldGeneric, key)));
}
var normalizedType = cacheType.ToLowerInvariant();
if (normalizedType != OcspResponseCacheTypeLocal && normalizedType != OcspResponseCacheTypeNone)
{
return (null, new InvalidOperationException(
string.Format(CultureInfo.InvariantCulture, OcspMessages.ErrUnknownCacheType, cacheType)));
}
config.Type = normalizedType;
break;
case "local_store":
if (raw is not string store)
{
return (null, new InvalidOperationException(
string.Format(CultureInfo.InvariantCulture, OcspMessages.ErrParsingCacheOptFieldGeneric, key)));
}
config.LocalStore = store;
break;
case "preserve_revoked":
if (raw is not bool preserveRevoked)
{
return (null, new InvalidOperationException(
string.Format(CultureInfo.InvariantCulture, OcspMessages.ErrParsingCacheOptFieldGeneric, key)));
}
config.PreserveRevoked = preserveRevoked;
break;
case "save_interval":
var (seconds, parseError) = ParseCacheSaveIntervalSeconds(raw);
if (parseError != null)
{
return (null, parseError);
}
var parsedDuration = TimeSpan.FromSeconds(seconds);
if (parsedDuration < OcspResponseCacheMinimumSaveInterval)
{
parsedDuration = OcspResponseCacheMinimumSaveInterval;
}
config.SaveInterval = parsedDuration.TotalSeconds;
break;
default:
return (null, new InvalidOperationException(
string.Format(CultureInfo.InvariantCulture, OcspMessages.ErrParsingCacheOptFieldGeneric, key)));
}
}
return (config, null);
}
internal static (List<X509Certificate2>? certificates, Exception? error) ParseCertPEM(string name)
{
try
@@ -395,6 +474,53 @@ internal static class OcspHandler
string.Format(CultureInfo.InvariantCulture, OcspMessages.ErrParsingPeerOptFieldTypeConversion, "unexpected type")));
}
private static (double seconds, Exception? error) ParseCacheSaveIntervalSeconds(object? value)
{
return value switch
{
int i => (i, null),
long l => (l, null),
float f => (f, null),
double d => (d, null),
string s => ParseCacheDurationSeconds(s),
_ => (0, new InvalidOperationException(
string.Format(CultureInfo.InvariantCulture, OcspMessages.ErrParsingCacheOptFieldTypeConversion, "unexpected type"))),
};
}
private static (double seconds, Exception? error) ParseCacheDurationSeconds(string value)
{
if (TimeSpan.TryParse(value, CultureInfo.InvariantCulture, out var parsed))
{
return (parsed.TotalSeconds, null);
}
var match = Regex.Match(value.Trim(), "^([0-9]*\\.?[0-9]+)\\s*(ms|s|m|h)$", RegexOptions.IgnoreCase);
if (!match.Success)
{
return (0, new InvalidOperationException(
string.Format(CultureInfo.InvariantCulture, OcspMessages.ErrParsingCacheOptFieldTypeConversion, "unexpected type")));
}
if (!double.TryParse(match.Groups[1].Value, NumberStyles.Float, CultureInfo.InvariantCulture, out var amount))
{
return (0, new InvalidOperationException(
string.Format(CultureInfo.InvariantCulture, OcspMessages.ErrParsingCacheOptFieldTypeConversion, "unexpected type")));
}
var unit = match.Groups[2].Value.ToLowerInvariant();
var seconds = unit switch
{
"ms" => amount / 1000.0,
"s" => amount,
"m" => amount * 60.0,
"h" => amount * 3600.0,
_ => 0,
};
return (seconds, null);
}
private sealed class SerializedOcspResponse
{
public int Status { get; set; }

View File

@@ -19,6 +19,7 @@ using System.Security.Cryptography;
using System.IO.Compression;
using System.Net.Http;
using System.Text;
using System.Text.Json;
using ZB.MOM.NatsNet.Server.Auth.CertificateIdentityProvider;
namespace ZB.MOM.NatsNet.Server.Auth.Ocsp;
@@ -752,14 +753,25 @@ internal sealed class LocalDirCache : IOcspResponseCache
public void Start(NatsServer? server = null)
{
_ = server;
if (server != null)
{
LoadCache(server);
}
InitStats();
_mu.EnterWriteLock();
try
{
_online = true;
_timer ??= new Timer(_ => { }, null, _saveInterval, _saveInterval);
if (server != null)
{
_timer ??= new Timer(_ => SaveCache(server), null, _saveInterval, _saveInterval);
}
else
{
_timer ??= new Timer(_ => { }, null, _saveInterval, _saveInterval);
}
}
finally
{
@@ -769,16 +781,10 @@ internal sealed class LocalDirCache : IOcspResponseCache
public void Stop(NatsServer? server = null)
{
_ = server;
_mu.EnterWriteLock();
try
{
_online = false;
if (_dirty)
{
_dirty = false;
}
_timer?.Dispose();
_timer = null;
}
@@ -786,6 +792,138 @@ internal sealed class LocalDirCache : IOcspResponseCache
{
_mu.ExitWriteLock();
}
if (server != null)
{
SaveCache(server);
}
}
internal void LoadCache(NatsServer server)
{
var storePath = CacheStorePath();
Dictionary<string, OcspResponseCacheItem>? loaded;
try
{
var bytes = File.ReadAllBytes(storePath);
loaded = JsonSerializer.Deserialize<Dictionary<string, OcspResponseCacheItem>>(bytes);
}
catch (FileNotFoundException)
{
loaded = null;
}
catch (DirectoryNotFoundException)
{
loaded = null;
}
catch (Exception ex)
{
server.Warnf(OcspMessages.ErrLoadCacheFail, ex);
_mu.EnterWriteLock();
try
{
_cache.Clear();
_dirty = true;
}
finally
{
_mu.ExitWriteLock();
}
return;
}
_mu.EnterWriteLock();
try
{
_cache.Clear();
if (loaded != null)
{
foreach (var (key, item) in loaded)
{
_cache[key] = item;
}
}
_dirty = false;
}
finally
{
_mu.ExitWriteLock();
}
}
internal void SaveCache(NatsServer server)
{
bool dirty;
_mu.EnterReadLock();
try
{
dirty = _dirty;
}
finally
{
_mu.ExitReadLock();
}
if (!dirty)
{
return;
}
var storePath = CacheStorePath();
var directory = Path.GetDirectoryName(storePath);
if (string.IsNullOrEmpty(directory))
{
server.Errorf(OcspMessages.ErrSaveCacheFail, "cache directory path is invalid");
return;
}
try
{
Directory.CreateDirectory(directory);
}
catch (Exception ex)
{
server.Errorf(OcspMessages.ErrSaveCacheFail, ex);
return;
}
var tempPath = Path.Combine(directory, $"{OcspHandler.OcspResponseCacheDefaultTempFilePrefix}{Path.GetRandomFileName()}");
try
{
_mu.EnterWriteLock();
try
{
var payload = JsonSerializer.SerializeToUtf8Bytes(_cache, new JsonSerializerOptions { WriteIndented = true });
File.WriteAllBytes(tempPath, payload);
File.Move(tempPath, storePath, overwrite: true);
_dirty = false;
}
finally
{
_mu.ExitWriteLock();
}
}
catch (Exception ex)
{
server.Errorf(OcspMessages.ErrSaveCacheFail, ex);
}
finally
{
try
{
if (File.Exists(tempPath))
{
File.Delete(tempPath);
}
}
catch
{
}
}
}
public bool Online()
@@ -925,6 +1063,15 @@ internal sealed class LocalDirCache : IOcspResponseCache
var file = Convert.ToHexString(hash).ToLowerInvariant();
return Path.Combine(_config.LocalStore, $"{file}.ocsp");
}
private string CacheStorePath()
{
var directory = string.IsNullOrEmpty(_config.LocalStore)
? OcspHandler.OcspResponseCacheDefaultDir
: _config.LocalStore;
return Path.Combine(directory, OcspHandler.OcspResponseCacheDefaultFilename);
}
}
/// <summary>

View File

@@ -418,6 +418,8 @@ public sealed partial class NatsServer
return (null, ocspError);
}
s.InitOCSPResponseCache();
// Gateway (stub — session 16).
// s.NewGateway(opts) — deferred
@@ -995,6 +997,8 @@ public sealed partial class NatsServer
SetDefaultSystemAccount();
}
StartOCSPResponseCache();
// Signal startup complete.
_startupComplete.TrySetResult();

View File

@@ -155,7 +155,7 @@ public sealed partial class NatsServer
_mu.ExitWriteLock();
}
if (_ocsprc != null) { /* stub — stop OCSP cache in session 23 */ }
StopOCSPResponseCache();
DisposeSignalHandlers();
@@ -860,7 +860,10 @@ public sealed partial class NatsServer
}
/// <summary>Stub — Raft leader transfer (session 20). Returns false (no leaders to transfer).</summary>
private bool TransferRaftLeaders() => false;
private bool TransferRaftLeaders()
{
return false;
}
/// <summary>Stub — LDM shutdown event (session 12).</summary>
private void SendLDMShutdownEventLocked()

View File

@@ -0,0 +1,128 @@
// Copyright 2023-2026 The NATS Authors
// Licensed under the Apache License, Version 2.0
using ZB.MOM.NatsNet.Server.Auth.CertificateIdentityProvider;
using ZB.MOM.NatsNet.Server.Auth.Ocsp;
namespace ZB.MOM.NatsNet.Server;
public sealed partial class NatsServer
{
internal void InitOCSPResponseCache()
{
_mu.EnterReadLock();
try
{
if (!_ocspPeerVerify)
{
return;
}
}
finally
{
_mu.ExitReadLock();
}
var opts = GetOpts();
opts.OcspCacheConfig ??= OcspHandler.NewOCSPResponseCacheConfig();
var config = opts.OcspCacheConfig;
IOcspResponseCache cache;
var cacheType = (config.Type ?? string.Empty).Trim().ToLowerInvariant();
switch (cacheType)
{
case "":
case OcspHandler.OcspResponseCacheTypeLocal:
config.Type = OcspHandler.OcspResponseCacheTypeLocal;
cache = new LocalDirCache(config);
break;
case OcspHandler.OcspResponseCacheTypeNone:
cache = new NoOpCache(config);
break;
default:
Fatalf(OcspMessages.ErrBadCacheTypeConfig, config.Type);
return;
}
_mu.EnterWriteLock();
try
{
_ocsprc = cache;
}
finally
{
_mu.ExitWriteLock();
}
}
internal void StartOCSPResponseCache()
{
IOcspResponseCache? cache;
_mu.EnterReadLock();
try
{
if (!_ocspPeerVerify)
{
return;
}
cache = _ocsprc;
}
finally
{
_mu.ExitReadLock();
}
if (cache == null)
{
return;
}
switch (cache)
{
case NoOpCache noOpCache:
noOpCache.Start(this);
Noticef("OCSP peer cache online [{0}]", noOpCache.Type());
break;
case LocalDirCache localDirCache:
localDirCache.Start(this);
Noticef("OCSP peer cache online [{0}]", localDirCache.Type());
break;
}
}
internal void StopOCSPResponseCache()
{
IOcspResponseCache? cache;
_mu.EnterReadLock();
try
{
cache = _ocsprc;
}
finally
{
_mu.ExitReadLock();
}
if (cache == null)
{
return;
}
switch (cache)
{
case NoOpCache noOpCache:
noOpCache.Stop(this);
break;
case LocalDirCache localDirCache:
localDirCache.Stop(this);
break;
}
}
}

View File

@@ -15,4 +15,40 @@ public sealed class OcspResponseCacheParserTests
config.PreserveRevoked.ShouldBeFalse();
config.SaveInterval.ShouldBe(TimeSpan.FromMinutes(5).TotalSeconds);
}
[Fact]
public void ParseOCSPResponseCache_StringDurationBelowMinimum_ClampsToOneSecond()
{
var input = new Dictionary<string, object?>
{
["type"] = "local",
["save_interval"] = "500ms",
};
var (config, error) = OcspHandler.ParseOCSPResponseCache(input);
error.ShouldBeNull();
config.ShouldNotBeNull();
config.SaveInterval.ShouldBe(1);
}
[Fact]
public void ParseOCSPResponseCache_NoneType_AcceptsConfiguration()
{
var input = new Dictionary<string, object?>
{
["type"] = "none",
["local_store"] = "_rc_",
["preserve_revoked"] = true,
["save_interval"] = 25.0,
};
var (config, error) = OcspHandler.ParseOCSPResponseCache(input);
error.ShouldBeNull();
config.ShouldNotBeNull();
config.Type.ShouldBe("none");
config.PreserveRevoked.ShouldBeTrue();
config.SaveInterval.ShouldBe(25.0);
}
}

View File

@@ -2,6 +2,7 @@
// Licensed under the Apache License, Version 2.0
using Shouldly;
using System.Text.Json;
using ZB.MOM.NatsNet.Server.Auth.CertificateIdentityProvider;
using ZB.MOM.NatsNet.Server.Auth.Ocsp;
@@ -92,6 +93,84 @@ public sealed class OcspResponseCacheTests
decompressed.ShouldBe(payload);
}
[Fact]
public void LocalDirCache_LoadCache_MissingFile_LeavesCacheEmpty()
{
var dir = CreateTempDir();
try
{
var cache = new LocalDirCache(dir);
var server = NewServer(new ServerOptions { NoSystemAccount = true });
cache.LoadCache(server);
cache.Start();
var stats = cache.Stats();
stats.ShouldNotBeNull();
stats.Responses.ShouldBe(0);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void LocalDirCache_LoadCache_ValidFile_PopulatesCache()
{
var dir = CreateTempDir();
try
{
var cache = new LocalDirCache(dir);
var cacheFile = Path.Combine(dir, "cache.json");
var seed = new Dictionary<string, OcspResponseCacheItem>
{
["k1"] = new()
{
Subject = "subj",
CachedAt = DateTime.UtcNow,
RespStatus = OcspStatusAssertion.Good,
RespExpires = DateTime.UtcNow.AddMinutes(5),
Resp = cache.Compress([1, 2, 3]).compressed!,
},
};
File.WriteAllBytes(cacheFile, JsonSerializer.SerializeToUtf8Bytes(seed));
var server = NewServer(new ServerOptions { NoSystemAccount = true });
cache.LoadCache(server);
cache.Start();
cache.Get("k1").ShouldBe([1, 2, 3]);
cache.Stats()!.Responses.ShouldBe(1);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void LocalDirCache_SaveCache_DirtyWritesCacheFile()
{
var dir = CreateTempDir();
try
{
var cache = new LocalDirCache(dir);
var server = NewServer(new ServerOptions { NoSystemAccount = true });
cache.Start();
cache.Put("k1", CreateResponse(OcspStatusAssertion.Good, [4, 5, 6]), "subj");
cache.SaveCache(server);
File.Exists(Path.Combine(dir, "cache.json")).ShouldBeTrue();
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void NoOpCache_LifecycleAndStats_ShouldNoOpSafely()
{
@@ -122,4 +201,19 @@ public sealed class OcspResponseCacheTests
NextUpdate = DateTime.UtcNow.AddMinutes(10),
Raw = raw,
};
private static NatsServer NewServer(ServerOptions options)
{
var (server, err) = NatsServer.NewServer(options);
err.ShouldBeNull();
server.ShouldNotBeNull();
return server!;
}
private static string CreateTempDir()
{
var dir = Path.Combine(Path.GetTempPath(), "ocsp-cache-" + Path.GetRandomFileName());
Directory.CreateDirectory(dir);
return dir;
}
}

View File

@@ -0,0 +1,122 @@
using System.Reflection;
using Shouldly;
using ZB.MOM.NatsNet.Server.Auth.Ocsp;
namespace ZB.MOM.NatsNet.Server.Tests;
public sealed class NatsServerOcspCacheTests
{
[Fact]
public void InitOCSPResponseCache_LocalType_CreatesLocalDirCache()
{
var dir = CreateTempDir();
try
{
var server = NewServer(new ServerOptions
{
NoSystemAccount = true,
OcspCacheConfig = new OcspResponseCacheConfig
{
Type = "local",
LocalStore = dir,
SaveInterval = 5,
},
});
SetPrivateField(server, "_ocspPeerVerify", true);
server.InitOCSPResponseCache();
GetOcspCache(server).ShouldBeOfType<LocalDirCache>();
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public void InitOCSPResponseCache_NoneType_CreatesNoOpCache()
{
var server = NewServer(new ServerOptions
{
NoSystemAccount = true,
OcspCacheConfig = new OcspResponseCacheConfig
{
Type = "none",
LocalStore = "_rc_",
SaveInterval = 5,
},
});
SetPrivateField(server, "_ocspPeerVerify", true);
server.InitOCSPResponseCache();
GetOcspCache(server).ShouldBeOfType<NoOpCache>();
}
[Fact]
public void StartStopOCSPResponseCache_WhenInitialized_TogglesOnlineState()
{
var dir = CreateTempDir();
try
{
var server = NewServer(new ServerOptions
{
NoSystemAccount = true,
OcspCacheConfig = new OcspResponseCacheConfig
{
Type = "local",
LocalStore = dir,
SaveInterval = 5,
},
});
SetPrivateField(server, "_ocspPeerVerify", true);
server.InitOCSPResponseCache();
var cache = GetOcspCache(server).ShouldBeOfType<LocalDirCache>();
server.StartOCSPResponseCache();
cache.Online().ShouldBeTrue();
server.StopOCSPResponseCache();
cache.Online().ShouldBeFalse();
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
private static NatsServer NewServer(ServerOptions options)
{
var (server, err) = NatsServer.NewServer(options);
err.ShouldBeNull();
server.ShouldNotBeNull();
return server!;
}
private static IOcspResponseCache GetOcspCache(NatsServer server)
{
var field = typeof(NatsServer).GetField("_ocsprc", BindingFlags.Instance | BindingFlags.NonPublic);
field.ShouldNotBeNull();
var value = field!.GetValue(server);
value.ShouldNotBeNull();
return (IOcspResponseCache)value!;
}
private static void SetPrivateField(NatsServer server, string fieldName, object value)
{
var field = typeof(NatsServer).GetField(fieldName, BindingFlags.Instance | BindingFlags.NonPublic);
field.ShouldNotBeNull();
field!.SetValue(server, value);
}
private static string CreateTempDir()
{
var dir = Path.Combine(Path.GetTempPath(), "nats-ocsp-cache-" + Path.GetRandomFileName());
Directory.CreateDirectory(dir);
return dir;
}
}

Binary file not shown.

View File

@@ -1,6 +1,6 @@
# NATS .NET Porting Status Report
Generated: 2026-02-28 17:59:26 UTC
Generated: 2026-02-28 18:05:45 UTC
## Modules (12 total)
@@ -13,10 +13,10 @@ Generated: 2026-02-28 17:59:26 UTC
| Status | Count |
|--------|-------|
| complete | 14 |
| deferred | 1955 |
| deferred | 1949 |
| n_a | 24 |
| stub | 1 |
| verified | 1679 |
| verified | 1685 |
## Unit Tests (3257 total)
@@ -35,4 +35,4 @@ Generated: 2026-02-28 17:59:26 UTC
## Overall Progress
**3030/6942 items complete (43.6%)**
**3036/6942 items complete (43.7%)**