yikes,
This commit is contained in:
@@ -0,0 +1,60 @@
|
||||
namespace VanadiumGuard.Server.Hosting
|
||||
{
|
||||
/// <summary>Bound from the Guard section of appsettings.json.</summary>
|
||||
public sealed class GuardServerOptions
|
||||
{
|
||||
public string PolicyDirectory { get; set; } = "policies";
|
||||
|
||||
public string ReferenceImageDirectory { get; set; } = "builds";
|
||||
|
||||
/// <summary>Where the machine link table and hardware ban list are kept.</summary>
|
||||
public string BanDirectory { get; set; } = "bans";
|
||||
|
||||
/// <summary>
|
||||
/// Whether a client that cannot identify its machine may open a session. Left on by
|
||||
/// default: locked-down and unusual machines legitimately fail to produce an
|
||||
/// identity, and refusing them would turn a hardware ban feature into a hardware
|
||||
/// allow-list nobody asked for. The refusal is still recorded either way.
|
||||
/// </summary>
|
||||
public bool AllowUnidentifiedMachines { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Whether a session may open without a TPM-backed device key. Left on by default:
|
||||
/// plenty of machines have no TPM, have it switched off in firmware, or run a
|
||||
/// provider that refuses to create keys, and none of that is cheating. Turning this
|
||||
/// off is a hardware requirement for playing, not an anti-cheat tweak.
|
||||
/// </summary>
|
||||
public bool RequireDeviceAttestation { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Shared secret for the internal endpoints the game server calls. These endpoints
|
||||
/// expose per-player risk data, so they must never be reachable by a game client.
|
||||
/// </summary>
|
||||
public string InternalApiKey { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Shared HMAC secret for the game's JWTs. The AC service validates the player's
|
||||
/// game token at session open with the SAME secret and issuer the game server uses,
|
||||
/// so it authenticates without a callback. Must match the game server exactly.
|
||||
/// </summary>
|
||||
public string JwtSecret { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Issuer the game server stamps on its JWTs. Must match exactly.</summary>
|
||||
public string JwtIssuer { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Allow a session to open with no valid player token, tied to the machine instead of
|
||||
/// an account. This exists because the guard mod is not yet wired to hand over the
|
||||
/// player's game token; with it on, the guard still connects, reports, and is subject
|
||||
/// to hardware bans - it just cannot be attributed to a specific account until the
|
||||
/// token is supplied. Turn it OFF once the client sends a token, so every session is
|
||||
/// player-attributed and no one can report anonymously.
|
||||
/// </summary>
|
||||
public bool AllowAnonymousSessions { get; set; } = false;
|
||||
|
||||
/// <summary>Silence beyond this is treated as the guard having gone away.</summary>
|
||||
public int SessionIdleTimeoutSeconds { get; set; } = 90;
|
||||
|
||||
public int MissedHeartbeatsBeforeSilent { get; set; } = 3;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using VanadiumGuard.Protocol;
|
||||
using VanadiumGuard.Server.Challenges;
|
||||
using VanadiumGuard.Server.Policy;
|
||||
using VanadiumGuard.Server.Sessions;
|
||||
|
||||
namespace VanadiumGuard.Server.Hosting
|
||||
{
|
||||
/// <summary>
|
||||
/// Notices guards that have stopped talking, and challenges that were never answered.
|
||||
/// <para>
|
||||
/// This closes the hole that made the old design pointless. Every check the old build
|
||||
/// ran was inside the game process, so the cheapest possible defeat was to stop the
|
||||
/// guard from running - and a guard that is not running reports nothing, which looked
|
||||
/// exactly like a clean player. Here, silence is the signal: the service knows a match
|
||||
/// is live because the game server told it, so a guard that stops checking in is a
|
||||
/// detection in its own right.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class LivenessSweeper : BackgroundService
|
||||
{
|
||||
private static readonly TimeSpan SweepInterval = TimeSpan.FromSeconds(15);
|
||||
|
||||
private readonly ILogger<LivenessSweeper> _logger;
|
||||
private readonly ISessionStore _sessions;
|
||||
private readonly PolicyEngine _policy;
|
||||
private readonly GuardServerOptions _options;
|
||||
|
||||
public LivenessSweeper(
|
||||
ILogger<LivenessSweeper> logger,
|
||||
ISessionStore sessions,
|
||||
PolicyEngine policy,
|
||||
GuardServerOptions options)
|
||||
{
|
||||
_logger = logger;
|
||||
_sessions = sessions;
|
||||
_policy = policy;
|
||||
_options = options;
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
_logger.LogInformation("Liveness sweeper started");
|
||||
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
Sweep();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Liveness sweep failed");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await Task.Delay(SweepInterval, stoppingToken);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void Sweep()
|
||||
{
|
||||
DateTimeOffset now = DateTimeOffset.UtcNow;
|
||||
var idleTimeout = TimeSpan.FromSeconds(_options.SessionIdleTimeoutSeconds);
|
||||
|
||||
foreach (GuardSession session in _sessions.All())
|
||||
{
|
||||
// Challenges that aged out. A guard that answers some challenges but not
|
||||
// others is more interesting than one that answers none.
|
||||
IReadOnlyList<MemoryChallenge> expired = session.ExpiredChallenges(ChallengeService.AnswerWindow);
|
||||
foreach (MemoryChallenge challenge in expired)
|
||||
{
|
||||
_policy.RecordServerSide(
|
||||
session, DetectionCode.ChallengeUnreadable, challenge.Module, 50,
|
||||
new Dictionary<string, string> { ["reason"] = "no answer within the window" });
|
||||
}
|
||||
|
||||
TimeSpan silence = now - session.LastSeenUtc;
|
||||
|
||||
if (silence < idleTimeout)
|
||||
continue;
|
||||
|
||||
// Only meaningful while the match is still running. Without a game session
|
||||
// id we cannot tell a killed guard from a player who closed the game, and
|
||||
// accusing the second group would be a false positive machine.
|
||||
if (string.IsNullOrEmpty(session.GameSessionId))
|
||||
{
|
||||
_sessions.Remove(session.SessionId);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!session.Terminated)
|
||||
{
|
||||
_policy.RecordServerSide(
|
||||
session, DetectionCode.GuardSilent, session.PlayerId, 80,
|
||||
new Dictionary<string, string>
|
||||
{
|
||||
["silentSeconds"] = ((int)silence.TotalSeconds).ToString(),
|
||||
["lastSeen"] = session.LastSeenUtc.ToString("O"),
|
||||
});
|
||||
|
||||
_logger.LogWarning(
|
||||
"Guard silent for {Seconds}s player={Player} gameSession={GameSession}",
|
||||
(int)silence.TotalSeconds, session.PlayerId, session.GameSessionId);
|
||||
}
|
||||
|
||||
// Keep the session around a while longer so the game server can still query
|
||||
// its status; drop it once it is thoroughly stale.
|
||||
if (silence > idleTimeout + TimeSpan.FromMinutes(10))
|
||||
_sessions.Remove(session.SessionId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user