mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 12:42:38 +00:00
Session-Aware Auto-Unblock for new session and blocked devices
This commit is contained in:
@@ -1 +1 @@
|
||||
879
|
||||
880
|
||||
|
||||
+51
-2
@@ -118,11 +118,38 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
}
|
||||
|
||||
public async Task BlockDeviceAsync(string tenantId, string serialNumber, int durationHours,
|
||||
string blockedByEmail, string? reason = null, string action = "Block")
|
||||
string blockedByEmail, string? reason = null, string action = "Block", string? blockedSessionId = null)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
var unblockAt = now.AddHours(durationHours);
|
||||
|
||||
// If a session-aware block already exists, merge session IDs
|
||||
string? blockedSessionIds = blockedSessionId;
|
||||
if (!string.IsNullOrEmpty(blockedSessionId))
|
||||
{
|
||||
try
|
||||
{
|
||||
var existing = await _blockedDevicesTable.GetEntityAsync<TableEntity>(tenantId, EncodeRowKey(serialNumber));
|
||||
var existingSessionIds = existing?.Value?.GetString("BlockedSessionIds");
|
||||
|
||||
if (existingSessionIds == null)
|
||||
{
|
||||
// Existing whole-device block takes precedence — don't downgrade to session-aware
|
||||
if (existing?.Value != null)
|
||||
blockedSessionIds = null;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Merge: append new session ID if not already present
|
||||
blockedSessionIds = MergeSessionId(existingSessionIds, blockedSessionId);
|
||||
}
|
||||
}
|
||||
catch (RequestFailedException ex) when (ex.Status == 404)
|
||||
{
|
||||
// No existing record — use single session ID
|
||||
}
|
||||
}
|
||||
|
||||
var entity = new TableEntity(tenantId, EncodeRowKey(serialNumber))
|
||||
{
|
||||
["SerialNumber"] = serialNumber,
|
||||
@@ -134,10 +161,31 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
["Action"] = action ?? "Block"
|
||||
};
|
||||
|
||||
if (blockedSessionIds != null)
|
||||
entity["BlockedSessionIds"] = blockedSessionIds;
|
||||
|
||||
await _blockedDevicesTable.UpsertEntityAsync(entity);
|
||||
await _publisher.PublishAsync("device.blocked", new { tenantId, serialNumber, action, durationHours }, tenantId);
|
||||
}
|
||||
|
||||
internal static string MergeSessionId(string? existingList, string newSessionId)
|
||||
{
|
||||
if (string.IsNullOrEmpty(existingList)) return newSessionId;
|
||||
if (SessionIdListContains(existingList, newSessionId)) return existingList;
|
||||
return $"{existingList},{newSessionId}";
|
||||
}
|
||||
|
||||
internal static bool SessionIdListContains(string? sessionIdList, string sessionId)
|
||||
{
|
||||
if (string.IsNullOrEmpty(sessionIdList)) return false;
|
||||
foreach (var id in sessionIdList.Split(','))
|
||||
{
|
||||
if (string.Equals(id.Trim(), sessionId, StringComparison.OrdinalIgnoreCase))
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public async Task UnblockDeviceAsync(string tenantId, string serialNumber)
|
||||
{
|
||||
try
|
||||
@@ -333,7 +381,8 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
BlockedByEmail = entity.GetString("BlockedByEmail"),
|
||||
DurationHours = entity.GetInt32("DurationHours") ?? 12,
|
||||
Reason = entity.GetString("Reason"),
|
||||
Action = entity.GetString("Action") ?? "Block"
|
||||
Action = entity.GetString("Action") ?? "Block",
|
||||
BlockedSessionIds = entity.GetString("BlockedSessionIds")
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+50
-23
@@ -30,38 +30,64 @@ namespace AutopilotMonitor.Functions.Functions.Ingest
|
||||
// Check if this device has been administratively blocked (e.g. rogue device sending excessive data).
|
||||
// We read the serial number from the header (same header used by AutopilotDeviceValidator).
|
||||
// Using HTTP 200 with DeviceBlocked=true so the agent does not trigger its auth-failure circuit breaker.
|
||||
//
|
||||
// Session-aware blocking: maintenance auto-blocks store BlockedSessionIds. If the current
|
||||
// ingest is from a DIFFERENT session (new enrollment), the device is auto-unblocked.
|
||||
// Kill actions and manual (whole-device) blocks are never auto-unblocked.
|
||||
var serialNumberHeader = req.Headers.Contains("X-Device-SerialNumber")
|
||||
? req.Headers.GetValues("X-Device-SerialNumber").FirstOrDefault()
|
||||
: null;
|
||||
|
||||
IngestEventsRequest? earlyParsedRequest = null;
|
||||
|
||||
if (!string.IsNullOrEmpty(serialNumberHeader))
|
||||
{
|
||||
var (isBlocked, unblockAt, blockAction) = await _blockedDeviceService.IsBlockedAsync(tenantId, serialNumberHeader);
|
||||
var (isBlocked, unblockAt, blockAction, blockedSessionIds) = await _blockedDeviceService.IsBlockedAsync(tenantId, serialNumberHeader);
|
||||
if (isBlocked)
|
||||
{
|
||||
var isKill = string.Equals(blockAction, "Kill", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
_logger.LogWarning(
|
||||
"{Action} ingest from device: TenantId={TenantId}, SerialNumber={SerialNumber}, UnblockAt={UnblockAt}",
|
||||
isKill ? "KILL signal for" : "Rejected", tenantId, serialNumberHeader, unblockAt);
|
||||
|
||||
var blockedHttpResponse = req.CreateResponse(System.Net.HttpStatusCode.OK);
|
||||
await blockedHttpResponse.WriteAsJsonAsync(new IngestEventsResponse
|
||||
// Session-aware block: parse body to check if this is a different (new) session
|
||||
if (!string.IsNullOrEmpty(blockedSessionIds))
|
||||
{
|
||||
Success = false,
|
||||
DeviceBlocked = true,
|
||||
DeviceKillSignal = isKill,
|
||||
UnblockAt = unblockAt,
|
||||
Message = isKill
|
||||
? "Device has been issued a remote kill signal."
|
||||
: "Device is temporarily blocked by an administrator.",
|
||||
ProcessedAt = DateTime.UtcNow
|
||||
});
|
||||
return new IngestEventsOutput
|
||||
earlyParsedRequest = await ParseNdjsonGzipRequest(req.Body, tenantId);
|
||||
if (earlyParsedRequest != null && !string.IsNullOrEmpty(earlyParsedRequest.SessionId))
|
||||
{
|
||||
var (stillBlocked, _, _, _) = await _blockedDeviceService.IsBlockedAsync(
|
||||
tenantId, serialNumberHeader, earlyParsedRequest.SessionId);
|
||||
|
||||
if (!stillBlocked)
|
||||
{
|
||||
// Auto-unblocked — fall through to normal processing with already-parsed request
|
||||
isBlocked = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isBlocked)
|
||||
{
|
||||
HttpResponse = blockedHttpResponse,
|
||||
SignalRMessages = Array.Empty<SignalRMessageAction>()
|
||||
};
|
||||
var isKill = string.Equals(blockAction, "Kill", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
_logger.LogWarning(
|
||||
"{Action} ingest from device: TenantId={TenantId}, SerialNumber={SerialNumber}, UnblockAt={UnblockAt}",
|
||||
isKill ? "KILL signal for" : "Rejected", tenantId, serialNumberHeader, unblockAt);
|
||||
|
||||
var blockedHttpResponse = req.CreateResponse(System.Net.HttpStatusCode.OK);
|
||||
await blockedHttpResponse.WriteAsJsonAsync(new IngestEventsResponse
|
||||
{
|
||||
Success = false,
|
||||
DeviceBlocked = true,
|
||||
DeviceKillSignal = isKill,
|
||||
UnblockAt = unblockAt,
|
||||
Message = isKill
|
||||
? "Device has been issued a remote kill signal."
|
||||
: "Device is temporarily blocked by an administrator.",
|
||||
ProcessedAt = DateTime.UtcNow
|
||||
});
|
||||
return new IngestEventsOutput
|
||||
{
|
||||
HttpResponse = blockedHttpResponse,
|
||||
SignalRMessages = Array.Empty<SignalRMessageAction>()
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,7 +127,8 @@ namespace AutopilotMonitor.Functions.Functions.Ingest
|
||||
}
|
||||
|
||||
// --- Parse NDJSON+gzip request body (only after security is cleared) ---
|
||||
var request = await ParseNdjsonGzipRequest(req.Body, tenantId);
|
||||
// If session-aware block check already parsed the body, reuse that result (stream is consumed)
|
||||
var request = earlyParsedRequest ?? await ParseNdjsonGzipRequest(req.Body, tenantId);
|
||||
|
||||
if (request?.Events == null || request.Events.Count == 0)
|
||||
{
|
||||
|
||||
@@ -2,6 +2,7 @@ using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Functions.DataAccess.TableStorage;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
@@ -35,11 +36,15 @@ namespace AutopilotMonitor.Functions.Services
|
||||
/// Checks whether a device is currently blocked.
|
||||
/// Fast path: in-memory cache (loaded lazily per tenant from storage).
|
||||
/// Returns the action type ("Block" or "Kill") so callers can differentiate.
|
||||
/// When <paramref name="currentSessionId"/> is provided and the block is session-aware
|
||||
/// (BlockedSessionIds is set), auto-unblocks if the session is different.
|
||||
/// Kill actions are never auto-unblocked.
|
||||
/// </summary>
|
||||
public async Task<(bool isBlocked, DateTime? unblockAt, string action)> IsBlockedAsync(string tenantId, string serialNumber)
|
||||
public async Task<(bool isBlocked, DateTime? unblockAt, string action, string? blockedSessionIds)> IsBlockedAsync(
|
||||
string tenantId, string serialNumber, string? currentSessionId = null)
|
||||
{
|
||||
if (string.IsNullOrEmpty(tenantId) || string.IsNullOrEmpty(serialNumber))
|
||||
return (false, null, "Block");
|
||||
return (false, null, "Block", null);
|
||||
|
||||
// Lazy-load block list for this tenant if not yet done
|
||||
if (!_loadedTenants.ContainsKey(tenantId))
|
||||
@@ -50,28 +55,71 @@ namespace AutopilotMonitor.Functions.Services
|
||||
var cacheKey = BuildCacheKey(tenantId, serialNumber);
|
||||
if (_cache.TryGetValue(cacheKey, out var entry))
|
||||
{
|
||||
if (DateTime.UtcNow < entry.UnblockAt)
|
||||
return (true, entry.UnblockAt, entry.Action);
|
||||
if (DateTime.UtcNow >= entry.UnblockAt)
|
||||
{
|
||||
// Block has expired — remove from cache
|
||||
_cache.TryRemove(cacheKey, out _);
|
||||
return (false, null, "Block", null);
|
||||
}
|
||||
|
||||
// Block has expired — remove from cache
|
||||
_cache.TryRemove(cacheKey, out _);
|
||||
// Kill actions are never auto-unblocked
|
||||
if (string.Equals(entry.Action, "Kill", StringComparison.OrdinalIgnoreCase))
|
||||
return (true, entry.UnblockAt, entry.Action, null);
|
||||
|
||||
// Whole-device block (no session IDs) — always blocked
|
||||
if (string.IsNullOrEmpty(entry.BlockedSessionIds))
|
||||
return (true, entry.UnblockAt, entry.Action, null);
|
||||
|
||||
// Session-aware block: caller hasn't provided session ID yet — return blocked with session IDs
|
||||
// so the caller can parse the body and call again with the actual session ID
|
||||
if (string.IsNullOrEmpty(currentSessionId))
|
||||
return (true, entry.UnblockAt, entry.Action, entry.BlockedSessionIds);
|
||||
|
||||
// Session-aware block: check if the current session is one of the blocked ones
|
||||
if (TableDeviceSecurityRepository.SessionIdListContains(entry.BlockedSessionIds, currentSessionId))
|
||||
return (true, entry.UnblockAt, entry.Action, entry.BlockedSessionIds);
|
||||
|
||||
// Different session — auto-unblock: new enrollment on same device
|
||||
_logger.LogWarning(
|
||||
"Auto-unblocked device (new session): TenantId={TenantId}, SerialNumber={SerialNumber}, " +
|
||||
"BlockedSessionIds={BlockedSessionIds}, NewSessionId={NewSessionId}",
|
||||
tenantId, serialNumber, entry.BlockedSessionIds, currentSessionId);
|
||||
|
||||
// Remove block from storage and cache
|
||||
_ = UnblockDeviceAsync(tenantId, serialNumber);
|
||||
return (false, null, "Block", null);
|
||||
}
|
||||
|
||||
return (false, null, "Block");
|
||||
return (false, null, "Block", null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Blocks a device for the specified duration. Updates both storage and the in-memory cache.
|
||||
/// <paramref name="action"/> is "Block" (stop uploads) or "Kill" (remote self-destruct).
|
||||
/// </summary>
|
||||
public async Task BlockDeviceAsync(string tenantId, string serialNumber, int durationHours, string blockedByEmail, string? reason = null, string action = "Block")
|
||||
public async Task BlockDeviceAsync(string tenantId, string serialNumber, int durationHours,
|
||||
string blockedByEmail, string? reason = null, string action = "Block", string? blockedSessionId = null)
|
||||
{
|
||||
await _securityRepo.BlockDeviceAsync(tenantId, serialNumber, durationHours, blockedByEmail, reason, action);
|
||||
await _securityRepo.BlockDeviceAsync(tenantId, serialNumber, durationHours, blockedByEmail, reason, action, blockedSessionId);
|
||||
|
||||
// Update cache immediately
|
||||
// Update cache immediately — merge session IDs if needed
|
||||
var unblockAt = DateTime.UtcNow.AddHours(durationHours);
|
||||
var cacheKey = BuildCacheKey(tenantId, serialNumber);
|
||||
_cache[cacheKey] = new BlockCacheEntry { UnblockAt = unblockAt, Action = action ?? "Block" };
|
||||
|
||||
_cache.AddOrUpdate(cacheKey,
|
||||
_ => new BlockCacheEntry { UnblockAt = unblockAt, Action = action ?? "Block", BlockedSessionIds = blockedSessionId },
|
||||
(_, existing) =>
|
||||
{
|
||||
existing.UnblockAt = unblockAt;
|
||||
existing.Action = action ?? "Block";
|
||||
// Merge session IDs; whole-device block (null) takes precedence
|
||||
if (blockedSessionId != null && existing.BlockedSessionIds != null)
|
||||
existing.BlockedSessionIds = TableDeviceSecurityRepository.MergeSessionId(existing.BlockedSessionIds, blockedSessionId);
|
||||
else if (blockedSessionId == null)
|
||||
existing.BlockedSessionIds = null; // Manual/whole-device block overrides session-aware
|
||||
// else: existing is null (whole-device) — keep it null
|
||||
return existing;
|
||||
});
|
||||
|
||||
_logger.LogWarning(
|
||||
"Device {Action}: TenantId={TenantId}, SerialNumber={SerialNumber}, BlockedBy={BlockedBy}, Until={UnblockAt}, Reason={Reason}",
|
||||
@@ -128,7 +176,8 @@ namespace AutopilotMonitor.Functions.Services
|
||||
_cache[BuildCacheKey(tenantId, entry.SerialNumber)] = new BlockCacheEntry
|
||||
{
|
||||
UnblockAt = entry.UnblockAt.Value,
|
||||
Action = entry.Action
|
||||
Action = entry.Action,
|
||||
BlockedSessionIds = entry.BlockedSessionIds
|
||||
};
|
||||
}
|
||||
|
||||
@@ -146,6 +195,7 @@ namespace AutopilotMonitor.Functions.Services
|
||||
{
|
||||
public DateTime UnblockAt { get; set; }
|
||||
public string Action { get; set; } = "Block";
|
||||
public string? BlockedSessionIds { get; set; }
|
||||
}
|
||||
|
||||
private static string BuildCacheKey(string tenantId, string serialNumber)
|
||||
|
||||
@@ -307,7 +307,7 @@ namespace AutopilotMonitor.Functions.Services
|
||||
if (string.IsNullOrEmpty(session.SerialNumber))
|
||||
continue;
|
||||
|
||||
var (isBlocked, _, _) = await _blockedDeviceService.IsBlockedAsync(tenantId, session.SerialNumber);
|
||||
var (isBlocked, _, _, _) = await _blockedDeviceService.IsBlockedAsync(tenantId, session.SerialNumber);
|
||||
if (isBlocked)
|
||||
continue;
|
||||
|
||||
@@ -320,7 +320,8 @@ namespace AutopilotMonitor.Functions.Services
|
||||
session.SerialNumber,
|
||||
durationHours: blockDurationHours,
|
||||
blockedByEmail: "System.Maintenance",
|
||||
reason: reason);
|
||||
reason: reason,
|
||||
blockedSessionId: session.SessionId);
|
||||
|
||||
blockedCount++;
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ namespace AutopilotMonitor.Shared.DataAccess
|
||||
Task<List<BlockedDeviceEntry>> GetBlockedDevicesAsync(string tenantId);
|
||||
Task<List<BlockedDeviceEntry>> GetAllBlockedDevicesAsync();
|
||||
Task BlockDeviceAsync(string tenantId, string serialNumber, int durationHours,
|
||||
string blockedByEmail, string? reason = null, string action = "Block");
|
||||
string blockedByEmail, string? reason = null, string action = "Block", string? blockedSessionId = null);
|
||||
Task UnblockDeviceAsync(string tenantId, string serialNumber);
|
||||
|
||||
// --- Blocked Versions ---
|
||||
@@ -35,6 +35,12 @@ namespace AutopilotMonitor.Shared.DataAccess
|
||||
public int DurationHours { get; set; }
|
||||
public string? Reason { get; set; }
|
||||
public string Action { get; set; } = "Block";
|
||||
/// <summary>
|
||||
/// Comma-separated session IDs that triggered this block (maintenance auto-block).
|
||||
/// Null = whole-device block (manual or legacy). When set, only these specific sessions are blocked;
|
||||
/// a new session on the same device will auto-unblock.
|
||||
/// </summary>
|
||||
public string? BlockedSessionIds { get; set; }
|
||||
}
|
||||
|
||||
public class BlockedVersionEntry
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user