Skip to content

Commit 17318aa

Browse files
committed
feat: automatic local restrictions for AGPL §13 and Hub unreachability
Two new local restriction policies applied independently of Hub responses: 1. AGPL-3.0 §13 violation (License:SourceUrl not configured) - Detected at startup in Program.cs - Applies SEVERE restrictions immediately (200ms delay, query limit 100, cache off) - LogCritical to make the issue clearly visible in logs 2. LicenseHub unreachable (applies only to unlicensed instances) - Tracked via HeartbeatWorker._lastSuccessfulHeartbeat - Days counted from last successful heartbeat (or from startup if never succeeded) - 7 consecutive days → MEDIUM-SEVERE (50ms delay, cache off, banner) - 30 consecutive days → SEVERE (200ms delay, query limit 100, cache off, banner) - Licensed instances (LicenseLoadResult.Ok) are exempt — heartbeat is telemetry only Restriction presets defined as static fields in RestrictionService: - RestrictionService.MediumSevere - RestrictionService.Severe - RestrictionService.FromMissedHeartbeatDays(int days) helper
1 parent 00038f2 commit 17318aa

3 files changed

Lines changed: 112 additions & 4 deletions

File tree

src/BLite.Server/License/HeartbeatWorker.cs

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,10 @@ public sealed class HeartbeatWorker : BackgroundService
2828
private readonly string _licenseFilePath;
2929
private readonly DateTime _startedAt = DateTime.UtcNow;
3030

31+
// Tracks the last time a heartbeat response was successfully received.
32+
// Null = never succeeded since startup.
33+
private DateTime? _lastSuccessfulHeartbeat;
34+
3135
public HeartbeatWorker(
3236
IConfiguration cfg,
3337
LicenseManager license,
@@ -60,7 +64,6 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
6064

6165
private async Task SendHeartbeatAsync(CancellationToken ct)
6266
{
63-
6467
try
6568
{
6669
var jwt = !string.IsNullOrEmpty(_licenseFilePath) && File.Exists(_licenseFilePath)
@@ -84,9 +87,12 @@ private async Task SendHeartbeatAsync(CancellationToken ct)
8487
if (!resp.IsSuccessStatusCode)
8588
{
8689
_log.LogWarning("Heartbeat returned {Status}.", (int)resp.StatusCode);
90+
ApplyLocalPolicy();
8791
return;
8892
}
8993

94+
_lastSuccessfulHeartbeat = DateTime.UtcNow;
95+
9096
var body = await resp.Content.ReadFromJsonAsync<HeartbeatResponseDto>(JsonOpts, ct);
9197
if (body?.Restrictions is { } r)
9298
{
@@ -105,7 +111,7 @@ private async Task SendHeartbeatAsync(CancellationToken ct)
105111
}
106112
else
107113
{
108-
// Hub returned no restrictions — clear any previously active ones
114+
// Hub returned no restrictions — clear any previously active local policy
109115
_restrictions.Update(RestrictionSnapshot.None);
110116
}
111117

@@ -115,9 +121,34 @@ private async Task SendHeartbeatAsync(CancellationToken ct)
115121
catch (Exception ex)
116122
{
117123
_log.LogWarning("Heartbeat failed: {Msg}", ex.Message);
124+
ApplyLocalPolicy();
118125
}
119126
}
120127

128+
// ── Local policy ──────────────────────────────────────────────────────────
129+
130+
private void ApplyLocalPolicy()
131+
{
132+
// If a valid license is present, missed heartbeats are tolerated —
133+
// the heartbeat is telemetry-only for licensed instances.
134+
if (_license.Result == LicenseLoadResult.Ok)
135+
return;
136+
137+
var missedDays = _lastSuccessfulHeartbeat.HasValue
138+
? (int)(DateTime.UtcNow - _lastSuccessfulHeartbeat.Value).TotalDays
139+
: (int)(DateTime.UtcNow - _startedAt).TotalDays;
140+
141+
var snapshot = RestrictionService.FromMissedHeartbeatDays(missedDays);
142+
143+
if (snapshot.HasAny)
144+
_log.LogWarning(
145+
"Hub unreachable for {Days} day(s). Applying {Level} restrictions.",
146+
missedDays,
147+
missedDays >= 30 ? "SEVERE" : "MEDIUM-SEVERE");
148+
149+
_restrictions.Update(snapshot);
150+
}
151+
121152
private static string GetVersion()
122153
=> typeof(HeartbeatWorker).Assembly.GetName().Version?.ToString() ?? "0.0.0";
123154

src/BLite.Server/License/RestrictionService.cs

Lines changed: 55 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,54 @@
44
// Restrictions are set by the LicenseHub heartbeat response and applied
55
// across all gRPC calls, REST calls, query cache, and the Studio UI.
66
// All fields default to zero / false (no restrictions active).
7+
//
8+
// Local overrides (applied regardless of the Hub response):
9+
// • AGPL §13 violation — SourceUrl not configured → Severe
10+
// • Hub unreachable 7d — no successful heartbeat in 7 days → MediumSevere
11+
// • Hub unreachable 30d — no successful heartbeat in 30 days → Severe
712

813
namespace BLite.Server.License;
914

1015
/// <summary>
11-
/// Thread-safe holder for the operational restrictions received from LicenseHub.
12-
/// Updated by <see cref="HeartbeatWorker"/> after every successful heartbeat.
16+
/// Thread-safe holder for the operational restrictions.
17+
/// May be updated by:
18+
/// 1. <see cref="HeartbeatWorker"/> with restrictions received from LicenseHub.
19+
/// 2. Local policy checks (SourceUrl missing, prolonged Hub unreachability).
1320
/// </summary>
1421
public sealed class RestrictionService
1522
{
23+
// ── Preset snapshots ─────────────────────────────────────────────────────
24+
25+
/// <summary>
26+
/// Medium-severe: applied after 7 consecutive days without a successful
27+
/// heartbeat to the LicenseHub.
28+
/// 50 ms per call is painful at any real-world RPS.
29+
/// </summary>
30+
public static readonly RestrictionSnapshot MediumSevere = new()
31+
{
32+
OperationDelayMs = 50,
33+
DisableQueryCache = true,
34+
WarnBannerMessage = "⚠ This server has been unable to contact the BLite LicenseHub for 7+ days. " +
35+
"Please ensure the server can reach licensehub.blitedb.com.",
36+
};
37+
38+
/// <summary>
39+
/// Severe: applied after 30 consecutive days without heartbeat, OR when
40+
/// the AGPL-3.0 §13 source URL is not configured.
41+
/// 200 ms per call makes the server effectively unusable at production load.
42+
/// </summary>
43+
public static readonly RestrictionSnapshot Severe = new()
44+
{
45+
OperationDelayMs = 200,
46+
QueryResultLimit = 100,
47+
DisableQueryCache = true,
48+
WarnBannerMessage = "🚨 Critical compliance issue detected. " +
49+
"This server is subject to severe operational restrictions. " +
50+
"Contact support@blitedb.com.",
51+
};
52+
53+
// ── State ────────────────────────────────────────────────────────────────
54+
1655
// Volatile read is safe for reading a reference type on all current .NET
1756
// memory models; writes go through Interlocked.Exchange for atomicity.
1857
private volatile RestrictionSnapshot _current = RestrictionSnapshot.None;
@@ -22,6 +61,20 @@ public sealed class RestrictionService
2261
/// <summary>Atomically replaces the active restriction set.</summary>
2362
public void Update(RestrictionSnapshot snapshot) =>
2463
Interlocked.Exchange(ref _current, snapshot);
64+
65+
// ── Local policy helpers ──────────────────────────────────────────────────
66+
67+
/// <summary>
68+
/// Returns the restriction snapshot that must be applied locally due to
69+
/// prolonged Hub unreachability, ignoring any Hub-driven snapshot.
70+
/// Returns <see cref="RestrictionSnapshot.None"/> when within tolerance.
71+
/// </summary>
72+
public static RestrictionSnapshot FromMissedHeartbeatDays(int days) => days switch
73+
{
74+
>= 30 => Severe,
75+
>= 7 => MediumSevere,
76+
_ => RestrictionSnapshot.None,
77+
};
2578
}
2679

2780
/// <summary>

src/BLite.Server/Program.cs

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -279,6 +279,30 @@ static string ResolveAppPath(string path) =>
279279
if (app.Environment.IsDevelopment())
280280
app.MapGrpcReflectionService();
281281

282+
// ── AGPL §13 compliance check ────────────────────────────────────────────────
283+
// If no SourceUrl is configured the server is violating AGPL-3.0 §13
284+
// (network use = distribution; source must be disclosed).
285+
// Apply severe restrictions immediately so the issue is hard to ignore.
286+
{
287+
var configuredSourceUrl = app.Configuration.GetValue<string>("License:SourceUrl");
288+
if (string.IsNullOrWhiteSpace(configuredSourceUrl))
289+
{
290+
var restrictions = app.Services.GetRequiredService<RestrictionService>();
291+
restrictions.Update(new RestrictionSnapshot
292+
{
293+
OperationDelayMs = 200,
294+
QueryResultLimit = 100,
295+
DisableQueryCache = true,
296+
WarnBannerMessage = "🚨 AGPL-3.0 §13 violation: License:SourceUrl is not configured. " +
297+
"Set LICENSE__SOURCEURL to a publicly accessible URL that serves the " +
298+
"source code of this running server. Severe restrictions are active.",
299+
});
300+
app.Logger.LogCritical(
301+
"AGPL-3.0 §13 violation: License:SourceUrl is not set. " +
302+
"Severe operational restrictions have been applied automatically.");
303+
}
304+
}
305+
282306
// Forward headers from reverse proxy (X-Forwarded-For, X-Forwarded-Proto, X-Forwarded-Host).
283307
// Required for correct host matching (RequireHost) and HTTPS detection when behind nginx/Plesk.
284308
app.UseForwardedHeaders(new ForwardedHeadersOptions

0 commit comments

Comments
 (0)