// Copyright 2018-2026 The NATS Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // // Adapted from server/accounts.go in the NATS server Go source. using System.Collections.Concurrent; using System.Net; using System.Net.Http; namespace ZB.MOM.NatsNet.Server; // ============================================================================ // IAccountResolver // Mirrors Go AccountResolver interface (accounts.go ~line 4035). // ============================================================================ /// /// Resolves and stores account JWTs by account public key name. /// Mirrors Go AccountResolver interface. /// public interface IAccountResolver { /// /// Fetches the JWT for the named account. /// Throws when the account is not found. /// Mirrors Go AccountResolver.Fetch. /// Task FetchAsync(string name, CancellationToken ct = default); /// /// Stores the JWT for the named account. /// Read-only implementations throw . /// Mirrors Go AccountResolver.Store. /// Task StoreAsync(string name, string jwt, CancellationToken ct = default); /// Returns true when no writes are permitted. Mirrors Go IsReadOnly. bool IsReadOnly(); /// /// Starts any background processing needed by the resolver (system subscriptions, timers, etc.). /// The parameter accepts an object to avoid a circular assembly /// reference; implementations should cast it to the concrete server type as needed. /// Mirrors Go AccountResolver.Start. /// void Start(object server); /// Returns true when the resolver reacts to JWT update events. Mirrors Go IsTrackingUpdate. bool IsTrackingUpdate(); /// Reloads state from the backing store. Mirrors Go AccountResolver.Reload. void Reload(); /// Releases resources held by the resolver. Mirrors Go AccountResolver.Close. void Close(); } // ============================================================================ // ResolverDefaultsOps // Mirrors Go resolverDefaultsOpsImpl (accounts.go ~line 4046). // ============================================================================ /// /// Abstract base that provides sensible no-op / read-only defaults for /// so concrete implementations only need to override what they change. /// Mirrors Go resolverDefaultsOpsImpl. /// public abstract class ResolverDefaultsOps : IAccountResolver { /// public abstract Task FetchAsync(string name, CancellationToken ct = default); /// /// Default store implementation — always throws because the base defaults to read-only. /// Mirrors Go resolverDefaultsOpsImpl.Store. /// public virtual Task StoreAsync(string name, string jwt, CancellationToken ct = default) => throw new NotSupportedException("store operation not supported"); /// Default: the resolver is read-only. Mirrors Go resolverDefaultsOpsImpl.IsReadOnly. public virtual bool IsReadOnly() => true; /// Default: no-op start. Mirrors Go resolverDefaultsOpsImpl.Start. public virtual void Start(object server) { } /// Default: does not track updates. Mirrors Go resolverDefaultsOpsImpl.IsTrackingUpdate. public virtual bool IsTrackingUpdate() => false; /// Default: no-op reload. Mirrors Go resolverDefaultsOpsImpl.Reload. public virtual void Reload() { } /// Default: no-op close. Mirrors Go resolverDefaultsOpsImpl.Close. public virtual void Close() { } } // ============================================================================ // MemoryAccountResolver // Mirrors Go MemAccResolver (accounts.go ~line 4072). // ============================================================================ /// /// An in-memory account resolver backed by a . /// Primarily intended for testing. /// Mirrors Go MemAccResolver. /// public sealed class MemoryAccountResolver : ResolverDefaultsOps { private readonly ConcurrentDictionary _store = new(StringComparer.Ordinal); /// In-memory resolver is not read-only. public override bool IsReadOnly() => false; /// /// Returns the stored JWT for , or throws /// when the account is unknown. /// Mirrors Go MemAccResolver.Fetch. /// public override Task FetchAsync(string name, CancellationToken ct = default) { if (_store.TryGetValue(name, out var jwt)) { return Task.FromResult(jwt); } throw new InvalidOperationException($"Account not found: {name}"); } /// /// Stores for . /// Mirrors Go MemAccResolver.Store. /// public override Task StoreAsync(string name, string jwt, CancellationToken ct = default) { _store[name] = jwt; return Task.CompletedTask; } } // ============================================================================ // UrlAccountResolver // Mirrors Go URLAccResolver (accounts.go ~line 4097). // ============================================================================ /// /// An HTTP-based account resolver that fetches JWTs by appending the account public key /// to a configured base URL. /// Mirrors Go URLAccResolver. /// public sealed class UrlAccountResolver : ResolverDefaultsOps { // Mirrors Go DEFAULT_ACCOUNT_FETCH_TIMEOUT. private static readonly TimeSpan DefaultAccountFetchTimeout = TimeSpan.FromSeconds(2); private readonly string _url; private readonly HttpClient _httpClient; /// /// Creates a new URL resolver for the given . /// A trailing slash is appended when absent so that account names can be concatenated /// directly. An is configured with connection-pooling /// settings that amortise TLS handshakes across requests, mirroring Go's custom /// http.Transport. /// Mirrors Go NewURLAccResolver. /// public UrlAccountResolver(string url) { if (!url.EndsWith('/')) { url += "/"; } _url = url; // Mirror Go: MaxIdleConns=10, IdleConnTimeout=30s on a custom transport. var handler = new SocketsHttpHandler { MaxConnectionsPerServer = 10, PooledConnectionIdleTimeout = TimeSpan.FromSeconds(30), }; _httpClient = new HttpClient(handler) { Timeout = DefaultAccountFetchTimeout, }; } /// /// Issues an HTTP GET to the base URL with the account name appended, and returns /// the response body as the JWT string. /// Throws on a non-200 response. /// Mirrors Go URLAccResolver.Fetch. /// public override async Task FetchAsync(string name, CancellationToken ct = default) { var requestUrl = _url + name; HttpResponseMessage response; try { response = await _httpClient.GetAsync(requestUrl, ct).ConfigureAwait(false); } catch (Exception ex) { throw new InvalidOperationException($"could not fetch <\"{requestUrl}\">: {ex.Message}", ex); } using (response) { if (response.StatusCode != HttpStatusCode.OK) { throw new InvalidOperationException( $"could not fetch <\"{requestUrl}\">: {(int)response.StatusCode} {response.ReasonPhrase}"); } return await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false); } } } // ============================================================================ // DirResOption — functional option for DirAccountResolver // Mirrors Go DirResOption func type (accounts.go ~line 4552). // ============================================================================ /// /// A functional option that configures a instance. /// Mirrors Go DirResOption function type. /// public delegate void DirResOption(DirAccountResolver resolver); /// /// Factory methods for commonly used values. /// public static class DirResOptions { /// /// Returns an option that overrides the default fetch timeout. /// must be positive. /// Mirrors Go FetchTimeout option constructor. /// /// /// Thrown at application time when is not positive. /// public static DirResOption FetchTimeout(TimeSpan timeout) { if (timeout <= TimeSpan.Zero) { throw new ArgumentOutOfRangeException(nameof(timeout), $"Fetch timeout {timeout} is too small"); } return resolver => resolver.FetchTimeout = timeout; } } // ============================================================================ // DirAccountResolver (stub) // Mirrors Go DirAccResolver (accounts.go ~line 4143). // Full system-subscription wiring is deferred to session 12. // ============================================================================ /// /// A directory-backed account resolver that stores JWTs in a /// and synchronises with peers via NATS system subjects. /// /// The Start override that wires up system subscriptions and the periodic sync goroutine /// is a stub in this session; full implementation requires JetStream and system /// subscription support (session 12+). /// /// Mirrors Go DirAccResolver. /// public class DirAccountResolver : ResolverDefaultsOps, IDisposable { // Default fetch timeout — mirrors Go DEFAULT_ACCOUNT_FETCH_TIMEOUT (2 s). private static readonly TimeSpan DefaultFetchTimeout = TimeSpan.FromSeconds(2); // Default sync interval — mirrors Go's fallback of 1 minute. private static readonly TimeSpan DefaultSyncInterval = TimeSpan.FromMinutes(1); /// The underlying directory JWT store. Mirrors Go DirAccResolver.DirJWTStore. public DirJwtStore Store { get; } /// Reference to the running server, set during . Mirrors Go DirAccResolver.Server. public object? Server { get; protected set; } /// How often the resolver sends a sync (pack) request to peers. Mirrors Go DirAccResolver.syncInterval. public TimeSpan SyncInterval { get; protected set; } /// Maximum time to wait for a remote JWT fetch. Mirrors Go DirAccResolver.fetchTimeout. public TimeSpan FetchTimeout { get; set; } /// /// Creates a new directory account resolver. /// /// When is zero it is promoted to (unlimited). /// When is non-positive it defaults to one minute. /// /// Mirrors Go NewDirAccResolver. /// /// Directory path for the JWT store. /// Maximum number of JWTs the store may hold (0 = unlimited). /// How often to broadcast a sync/pack request to peers. /// Controls whether deletes are soft- or hard-deleted. /// Zero or more functional options to further configure this instance. public DirAccountResolver( string path, long limit, TimeSpan syncInterval, JwtDeleteType deleteType, params DirResOption[] opts) { if (limit == 0) { limit = long.MaxValue; } if (syncInterval <= TimeSpan.Zero) { syncInterval = DefaultSyncInterval; } Store = DirJwtStore.NewExpiringDirJwtStore( path, shard: false, create: true, deleteType, expireCheck: TimeSpan.Zero, limit, evictOnLimit: false, ttl: TimeSpan.Zero, changeNotification: null); SyncInterval = syncInterval; FetchTimeout = DefaultFetchTimeout; Apply(opts); } // Internal constructor used by CacheDirAccountResolver which supplies its own store. internal DirAccountResolver( DirJwtStore store, TimeSpan syncInterval, TimeSpan fetchTimeout) { Store = store; SyncInterval = syncInterval; FetchTimeout = fetchTimeout; } /// /// Applies a sequence of functional options to this resolver. /// Mirrors Go DirAccResolver.apply. /// protected void Apply(IEnumerable opts) { foreach (var opt in opts) { opt(this); } } // ------------------------------------------------------------------------- // IAccountResolver overrides // ------------------------------------------------------------------------- /// /// DirAccountResolver is not read-only. /// Mirrors Go: DirAccResolver does not override IsReadOnly, so it inherits false /// from the concrete behaviour (store is writable). /// public override bool IsReadOnly() => false; /// /// Tracks updates (reacts to JWT change events). /// Mirrors Go DirAccResolver.IsTrackingUpdate. /// public override bool IsTrackingUpdate() => true; /// /// Reloads state from the backing . /// Mirrors Go DirAccResolver.Reload. /// public override void Reload() => Store.Reload(); /// /// Fetches the JWT for from the local . /// Throws when the account is not found locally. /// /// Note: the Go implementation falls back to srv.fetch (a cluster-wide lookup) when /// the local store misses. That fallback requires system subscriptions and is deferred to /// session 12. For now this method only consults the local store. /// /// Mirrors Go DirAccResolver.Fetch (local path only). /// public override Task FetchAsync(string name, CancellationToken ct = default) { var theJwt = Store.LoadAcc(name); if (!string.IsNullOrEmpty(theJwt)) { return Task.FromResult(theJwt); } throw new InvalidOperationException($"Account not found: {name}"); } /// /// Stores under , keeping the newer JWT /// when a conflicting entry already exists. /// Mirrors Go DirAccResolver.Store (delegates to saveIfNewer). /// public override Task StoreAsync(string name, string jwt, CancellationToken ct = default) { // SaveAcc is equivalent to saveIfNewer in the DirJwtStore implementation. Store.SaveAcc(name, jwt); return Task.CompletedTask; } /// /// Starts background system subscriptions and the periodic sync timer. /// /// TODO (session 12): wire up system subscriptions for account JWT update/lookup/pack /// requests, cluster synchronisation, and the periodic pack broadcast goroutine. /// /// Mirrors Go DirAccResolver.Start. /// public override void Start(object server) { Server = server; // TODO (session 12): set up system subscriptions and periodic sync timer. } /// /// Stops background processing and closes the . /// Mirrors Go AccountResolver.Close (no explicit Go override; store is closed /// by the server shutdown path). /// public override void Close() => Store.Close(); /// public void Dispose() => Store.Dispose(); } // ============================================================================ // CacheDirAccountResolver (stub) // Mirrors Go CacheDirAccResolver (accounts.go ~line 4594). // ============================================================================ /// /// A caching variant of that uses a TTL-based expiring /// store so that fetched JWTs are automatically evicted after . /// /// The Start override that wires up system subscriptions is a stub in this session; /// full implementation requires system subscription support (session 12+). /// /// Mirrors Go CacheDirAccResolver. /// public sealed class CacheDirAccountResolver : DirAccountResolver { // Default cache limit — mirrors Go's fallback of 1 000 entries. private const long DefaultCacheLimit = 1_000; // Default fetch timeout — mirrors Go DEFAULT_ACCOUNT_FETCH_TIMEOUT (2 s). private static readonly TimeSpan DefaultFetchTimeout = TimeSpan.FromSeconds(2); /// The TTL applied to each cached JWT entry. Mirrors Go CacheDirAccResolver.ttl. public TimeSpan Ttl { get; } /// /// Creates a new caching directory account resolver. /// /// When is zero or negative it defaults to 1 000. /// /// Mirrors Go NewCacheDirAccResolver. /// /// Directory path for the JWT store. /// Maximum number of JWTs to cache (0 = 1 000). /// Time-to-live for each cached JWT. /// Zero or more functional options to further configure this instance. public CacheDirAccountResolver( string path, long limit, TimeSpan ttl, params DirResOption[] opts) : base( store: DirJwtStore.NewExpiringDirJwtStore( path, shard: false, create: true, JwtDeleteType.HardDelete, expireCheck: TimeSpan.Zero, limit: limit <= 0 ? DefaultCacheLimit : limit, evictOnLimit: true, ttl: ttl, changeNotification: null), syncInterval: TimeSpan.Zero, fetchTimeout: DefaultFetchTimeout) { Ttl = ttl; Apply(opts); } /// /// Starts background system subscriptions for cached JWT update notifications. /// /// TODO (session 12): wire up system subscriptions for account JWT update events /// (cache variant — does not include pack/list/delete handling). /// /// Mirrors Go CacheDirAccResolver.Start. /// public override void Start(object server) { Server = server; // TODO (session 12): set up system subscriptions for cache-update notifications. } }