Skip to content

Commit 00038f2

Browse files
committed
feat: server-side restriction system driven by LicenseHub heartbeat response
The Hub can now return a ServerRestrictions object on each heartbeat response. Restrictions are non-blocking but create operational friction to enforce ToS/license compliance. Implemented restrictions: - OperationDelayMs: artificial delay injected before every gRPC and REST call. Applied by RestrictionInterceptor (gRPC) and RestrictionMiddleware (REST). Even 30-50ms is severe at production throughput. - DisableQueryCache: bypasses the query result cache completely, forcing every read to hit the storage engine (higher latency + CPU usage). Applied in QueryCacheService.Enabled. - WarnBannerMessage: displays a persistent warning banner in the Blazor Studio UI so the server administrator sees a compliance/license message. - QueryResultLimit: defined in RestrictionSnapshot for future wiring into the query execution path (QueryDescriptorExecutor). Hub side: HeartbeatEndpoints always returns Restrictions with all defaults (zero delay, no cache bypass, no banner) — no restrictive logic applied yet. Future: analyze instance telemetry and selectively apply restrictions. Components: - RestrictionService: singleton holding the current RestrictionSnapshot - RestrictionInterceptor: gRPC server interceptor (runs before TelemetryInterceptor) - RestrictionMiddleware: ASP.NET middleware for REST/Studio delay - HeartbeatWorker: parses Restrictions from heartbeat response and calls RestrictionService.Update(); logs a warning when any restriction is active
1 parent f45e0f4 commit 00038f2

8 files changed

Lines changed: 214 additions & 3 deletions

File tree

src/BLite.Server/Caching/QueryCacheService.cs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
// CancellationChangeToken-based invalidation.
66

77
using System.Collections.Concurrent;
8+
using BLite.Server.License;
89
using Microsoft.Extensions.Caching.Memory;
910
using Microsoft.Extensions.Options;
1011
using Microsoft.Extensions.Primitives;
@@ -16,15 +17,16 @@ namespace BLite.Server.Caching;
1617
/// </summary>
1718
public sealed class QueryCacheService(
1819
IMemoryCache cache,
19-
IOptions<QueryCacheOptions> opts)
20+
IOptions<QueryCacheOptions> opts,
21+
RestrictionService restrictions)
2022
{
2123
private readonly QueryCacheOptions _opts = opts.Value;
2224

2325
// One CTS per canonical (dbId, collection) key.
2426
// Cancelling it expires all cache entries tagged with that collection.
2527
private readonly ConcurrentDictionary<string, CancellationTokenSource> _tokens = new();
2628

27-
public bool Enabled => _opts.Enabled;
29+
public bool Enabled => _opts.Enabled && !restrictions.Current.DisableQueryCache;
2830

2931
// ── Read ──────────────────────────────────────────────────────────────────
3032

src/BLite.Server/Components/Layout/StudioLayout.razor

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
@inject StudioService Studio
33
@inject NavigationManager Nav
44
@inject AuthenticationStateProvider AuthStateProvider
5+
@inject BLite.Server.License.RestrictionService Restrictions
56
@using Microsoft.AspNetCore.Components.Authorization
67

78
@if (Studio.IsSetupComplete && _isAuthenticated)
@@ -32,6 +33,13 @@
3233
</div>
3334
</nav>
3435
<main class="studio-main">
36+
@if (Restrictions.Current.WarnBannerMessage is { } msg)
37+
{
38+
<div class="restriction-banner">
39+
<span class="restriction-banner-icon">⚠️</span>
40+
@msg
41+
</div>
42+
}
3543
@Body
3644
</main>
3745
</div>

src/BLite.Server/License/HeartbeatWorker.cs

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33

44
using System.Net.Http.Json;
55
using System.Runtime.InteropServices;
6+
using System.Text.Json;
7+
using System.Text.Json.Serialization;
68

79
namespace BLite.Server.License;
810

@@ -15,10 +17,13 @@ public sealed class HeartbeatWorker : BackgroundService
1517
// any restriction on server functionality — the heartbeat is telemetry only.
1618
private static readonly TimeSpan Interval = TimeSpan.FromMinutes(60);
1719

20+
private static readonly JsonSerializerOptions JsonOpts = new(JsonSerializerDefaults.Web);
21+
1822
private readonly ILogger<HeartbeatWorker> _log;
1923
private readonly LicenseManager _license;
2024
private readonly InstanceIdProvider _instance;
2125
private readonly IHttpClientFactory _httpFactory;
26+
private readonly RestrictionService _restrictions;
2227
private readonly string _hubUrl;
2328
private readonly string _licenseFilePath;
2429
private readonly DateTime _startedAt = DateTime.UtcNow;
@@ -28,12 +33,14 @@ public HeartbeatWorker(
2833
LicenseManager license,
2934
InstanceIdProvider instance,
3035
IHttpClientFactory httpFactory,
36+
RestrictionService restrictions,
3137
ILogger<HeartbeatWorker> log)
3238
{
3339
_log = log;
3440
_license = license;
3541
_instance = instance;
3642
_httpFactory = httpFactory;
43+
_restrictions = restrictions;
3744
_hubUrl = cfg.GetValue<string>("License:HubUrl") ?? "https://licensehub.blitedb.com";
3845
_licenseFilePath = cfg.GetValue<string>("License:FilePath") ?? string.Empty;
3946
}
@@ -75,9 +82,34 @@ private async Task SendHeartbeatAsync(CancellationToken ct)
7582
payload, ct);
7683

7784
if (!resp.IsSuccessStatusCode)
85+
{
7886
_log.LogWarning("Heartbeat returned {Status}.", (int)resp.StatusCode);
87+
return;
88+
}
89+
90+
var body = await resp.Content.ReadFromJsonAsync<HeartbeatResponseDto>(JsonOpts, ct);
91+
if (body?.Restrictions is { } r)
92+
{
93+
var snapshot = new RestrictionSnapshot
94+
{
95+
OperationDelayMs = Math.Max(0, r.OperationDelayMs),
96+
QueryResultLimit = Math.Max(0, r.QueryResultLimit),
97+
DisableQueryCache = r.DisableQueryCache,
98+
WarnBannerMessage = r.WarnBannerMessage,
99+
};
100+
_restrictions.Update(snapshot);
101+
if (snapshot.HasAny)
102+
_log.LogWarning("Restrictions active: delay={Delay}ms, resultLimit={Limit}, cacheOff={CacheOff}, banner={Banner}",
103+
snapshot.OperationDelayMs, snapshot.QueryResultLimit,
104+
snapshot.DisableQueryCache, snapshot.WarnBannerMessage is not null);
105+
}
79106
else
80-
_log.LogDebug("Heartbeat sent for instance {Id}.", _instance.InstanceId[..8]);
107+
{
108+
// Hub returned no restrictions — clear any previously active ones
109+
_restrictions.Update(RestrictionSnapshot.None);
110+
}
111+
112+
_log.LogDebug("Heartbeat sent for instance {Id}.", _instance.InstanceId[..8]);
81113
}
82114
catch (OperationCanceledException) { /* shutting down */ }
83115
catch (Exception ex)
@@ -89,6 +121,22 @@ private async Task SendHeartbeatAsync(CancellationToken ct)
89121
private static string GetVersion()
90122
=> typeof(HeartbeatWorker).Assembly.GetName().Version?.ToString() ?? "0.0.0";
91123

124+
// Minimal DTO to deserialise only what we need from the heartbeat response
125+
private sealed class HeartbeatResponseDto
126+
{
127+
public string? LicenseStatus { get; set; }
128+
public string? Message { get; set; }
129+
public RestrictionsDto? Restrictions { get; set; }
130+
}
131+
132+
private sealed class RestrictionsDto
133+
{
134+
public int OperationDelayMs { get; set; } = 0;
135+
public int QueryResultLimit { get; set; } = 0;
136+
public bool DisableQueryCache { get; set; } = false;
137+
public string? WarnBannerMessage { get; set; } = null;
138+
}
139+
92140
private sealed record HeartbeatPayload(
93141
string InstanceId,
94142
string LicenseJwt,
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
// BLite.Server — gRPC interceptor that applies active server-side restrictions
2+
// Copyright (C) 2026 Luca Fabbri — AGPL-3.0
3+
//
4+
// Restrictions (delay, etc.) are set remotely by LicenseHub and stored in
5+
// RestrictionService. This interceptor runs before TelemetryInterceptor.
6+
7+
using Grpc.Core;
8+
using Grpc.Core.Interceptors;
9+
10+
namespace BLite.Server.License;
11+
12+
/// <summary>
13+
/// gRPC server interceptor that enforces the operational restrictions received
14+
/// from LicenseHub (delay per call, etc.).
15+
/// </summary>
16+
public sealed class RestrictionInterceptor(RestrictionService restrictions) : Interceptor
17+
{
18+
public override async Task<TResponse> UnaryServerHandler<TRequest, TResponse>(
19+
TRequest request,
20+
ServerCallContext context,
21+
UnaryServerMethod<TRequest, TResponse> continuation)
22+
{
23+
await ApplyDelayAsync(context.CancellationToken);
24+
return await continuation(request, context);
25+
}
26+
27+
public override async Task ServerStreamingServerHandler<TRequest, TResponse>(
28+
TRequest request,
29+
IServerStreamWriter<TResponse> responseStream,
30+
ServerCallContext context,
31+
ServerStreamingServerMethod<TRequest, TResponse> continuation)
32+
{
33+
await ApplyDelayAsync(context.CancellationToken);
34+
await continuation(request, responseStream, context);
35+
}
36+
37+
private Task ApplyDelayAsync(CancellationToken ct)
38+
{
39+
var delay = restrictions.Current.OperationDelayMs;
40+
return delay > 0 ? Task.Delay(delay, ct) : Task.CompletedTask;
41+
}
42+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
// BLite.Server — ASP.NET Core middleware that applies active restrictions to REST calls
2+
// Copyright (C) 2026 Luca Fabbri — AGPL-3.0
3+
4+
namespace BLite.Server.License;
5+
6+
/// <summary>
7+
/// Injects a per-request delay on all REST/API paths when an OperationDelayMs
8+
/// restriction is active. Must be registered before authentication middleware
9+
/// so it covers every request on the REST port.
10+
/// </summary>
11+
public sealed class RestrictionMiddleware(RequestDelegate next, RestrictionService restrictions)
12+
{
13+
public async Task InvokeAsync(HttpContext context)
14+
{
15+
var delay = restrictions.Current.OperationDelayMs;
16+
if (delay > 0)
17+
await Task.Delay(delay, context.RequestAborted);
18+
19+
await next(context);
20+
}
21+
}
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
// BLite.Server — singleton that holds the current server-side restrictions
2+
// Copyright (C) 2026 Luca Fabbri — AGPL-3.0
3+
//
4+
// Restrictions are set by the LicenseHub heartbeat response and applied
5+
// across all gRPC calls, REST calls, query cache, and the Studio UI.
6+
// All fields default to zero / false (no restrictions active).
7+
8+
namespace BLite.Server.License;
9+
10+
/// <summary>
11+
/// Thread-safe holder for the operational restrictions received from LicenseHub.
12+
/// Updated by <see cref="HeartbeatWorker"/> after every successful heartbeat.
13+
/// </summary>
14+
public sealed class RestrictionService
15+
{
16+
// Volatile read is safe for reading a reference type on all current .NET
17+
// memory models; writes go through Interlocked.Exchange for atomicity.
18+
private volatile RestrictionSnapshot _current = RestrictionSnapshot.None;
19+
20+
public RestrictionSnapshot Current => _current;
21+
22+
/// <summary>Atomically replaces the active restriction set.</summary>
23+
public void Update(RestrictionSnapshot snapshot) =>
24+
Interlocked.Exchange(ref _current, snapshot);
25+
}
26+
27+
/// <summary>
28+
/// Immutable snapshot of the restrictions in force at a given point in time.
29+
/// </summary>
30+
public sealed class RestrictionSnapshot
31+
{
32+
public static readonly RestrictionSnapshot None = new();
33+
34+
/// <summary>
35+
/// Artificial delay added to every gRPC and REST API call (milliseconds).
36+
/// 0 = no delay. Even a small value (30–50 ms) is severe at production throughput.
37+
/// </summary>
38+
public int OperationDelayMs { get; init; } = 0;
39+
40+
/// <summary>
41+
/// Hard cap on the number of documents any query may return.
42+
/// 0 = no cap. Overrides any higher limit set by the caller.
43+
/// </summary>
44+
public int QueryResultLimit { get; init; } = 0;
45+
46+
/// <summary>
47+
/// When true the query result cache is fully bypassed.
48+
/// Forces every read to hit the storage engine, increasing latency and CPU usage.
49+
/// </summary>
50+
public bool DisableQueryCache { get; init; } = false;
51+
52+
/// <summary>
53+
/// Non-null = a warning banner displayed in the Blazor Studio UI to alert
54+
/// the administrator about a license or compliance issue.
55+
/// </summary>
56+
public string? WarnBannerMessage { get; init; } = null;
57+
58+
public bool HasAny =>
59+
OperationDelayMs > 0 ||
60+
QueryResultLimit > 0 ||
61+
DisableQueryCache ||
62+
WarnBannerMessage is not null;
63+
}

src/BLite.Server/Program.cs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
using BLite.Server.Services;
1414
using BLite.Server.Caching;
1515
using BLite.Server.Studio;
16+
using BLite.Server.License;
1617
using BLite.Server.Telemetry;
1718
using BLite.Server.Transactions;
1819
using Microsoft.AspNetCore.Authentication;
@@ -79,6 +80,7 @@ static string ResolveAppPath(string path) =>
7980
builder.Services.AddHttpClient("heartbeat");
8081
builder.Services.AddSingleton<BLite.Server.License.InstanceIdProvider>();
8182
builder.Services.AddSingleton<BLite.Server.License.LicenseManager>();
83+
builder.Services.AddSingleton<RestrictionService>();
8284
builder.Services.AddHostedService<BLite.Server.License.HeartbeatWorker>();
8385

8486
// Embedding service
@@ -174,6 +176,7 @@ static string ResolveAppPath(string path) =>
174176
// gRPC
175177
builder.Services.AddGrpc(options =>
176178
{
179+
options.Interceptors.Add<RestrictionInterceptor>(); // must run first — applies delay
177180
options.Interceptors.Add<TelemetryInterceptor>();
178181
options.EnableDetailedErrors = builder.Environment.IsDevelopment();
179182
options.MaxReceiveMessageSize = 16 * 1024 * 1024; // 16 MB
@@ -294,6 +297,11 @@ static string ResolveAppPath(string path) =>
294297
ctx => ctx.Request.ContentType?.StartsWith("application/grpc") == true,
295298
branch => branch.UseMiddleware<ApiKeyMiddleware>());
296299

300+
// REST operation delay restriction (applied to all non-gRPC paths)
301+
app.UseWhen(
302+
ctx => ctx.Request.ContentType?.StartsWith("application/grpc") != true,
303+
branch => branch.UseMiddleware<RestrictionMiddleware>());
304+
297305
// Cookie auth for Studio UI
298306
if (studioEnabled)
299307
app.UseAuthentication();

src/BLite.Server/wwwroot/css/studio.css

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -373,6 +373,25 @@ select.input {
373373
color: var(--red);
374374
}
375375

376+
/* ── Restriction warning banner ─────────────────────────────────────────────── */
377+
378+
.restriction-banner {
379+
display: flex;
380+
align-items: center;
381+
gap: 10px;
382+
padding: 10px 16px;
383+
background: rgba(210,153,34,.12);
384+
border-bottom: 1px solid rgba(210,153,34,.4);
385+
color: var(--orange);
386+
font-size: 13px;
387+
font-family: var(--sans);
388+
}
389+
390+
.restriction-banner-icon {
391+
font-size: 16px;
392+
flex-shrink: 0;
393+
}
394+
376395
/* ── Collapsible panels (details/summary) ───────────────────────────────────── */
377396

378397
.panel {

0 commit comments

Comments
 (0)