Files
radius/radiuscontroller/Services/AccountEnforcementService.cs
T
Tygozwolle 87d6d7e764
Build and Push Docker Image to Gitea Container Registry / build-and-push (push) Successful in 1m15s
feat: implement automated account enforcement service with RADIUS disconnect capabilities and add RADIUS management service
2026-07-24 20:29:11 +02:00

170 lines
7.4 KiB
C#

using Microsoft.EntityFrameworkCore;
using radiuscontroller.Data;
using radiuscontroller.Models;
namespace radiuscontroller.Services;
/// <summary>
/// Background service that periodically checks all active guest accounts
/// and disables them if their time has expired or data cap has been reached.
/// Time starts from the moment the user was created (guest_profiles.created_at).
/// </summary>
public class AccountEnforcementService : BackgroundService
{
private readonly IServiceScopeFactory _scopeFactory;
private readonly ILogger<AccountEnforcementService> _logger;
private static readonly TimeSpan CheckInterval = TimeSpan.FromSeconds(30);
public AccountEnforcementService(IServiceScopeFactory scopeFactory, ILogger<AccountEnforcementService> logger)
{
_scopeFactory = scopeFactory;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.LogInformation("AccountEnforcementService started. Checking every {Interval}s.", CheckInterval.TotalSeconds);
// Wait a bit for the app to fully start
await Task.Delay(TimeSpan.FromSeconds(10), stoppingToken);
while (!stoppingToken.IsCancellationRequested)
{
try
{
await EnforceAccountLimitsAsync(stoppingToken);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error during account enforcement check.");
}
await Task.Delay(CheckInterval, stoppingToken);
}
}
private async Task EnforceAccountLimitsAsync(CancellationToken ct)
{
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<RadiusDbContext>();
var activeProfiles = await db.GuestProfiles
.Where(p => p.Status == "Active")
.ToListAsync(ct);
if (activeProfiles.Count == 0) return;
var now = DateTime.UtcNow;
var disabledCount = 0;
var changesMade = false;
foreach (var profile in activeProfiles)
{
string? reason = null;
// 1. Check time expiration: created_at + session_time_minutes
var expiresAt = profile.CreatedAt.AddMinutes(profile.SessionTimeMinutes);
if (now >= expiresAt)
{
reason = "Time expired";
}
// 2. Check data cap: read from guest_data_usage table
if (reason == null && profile.DataLimitMb > 0)
{
var usage = await db.GuestDataUsage.FirstOrDefaultAsync(du => du.Username == profile.Username, ct);
var totalOctets = (usage?.TotalInputOctets ?? 0) + (usage?.TotalOutputOctets ?? 0);
long dataLimitBytes = profile.DataLimitMb * 1024 * 1024;
if (totalOctets >= dataLimitBytes)
{
reason = $"Data cap reached ({totalOctets / (1024 * 1024)} MB / {profile.DataLimitMb} MB)";
}
}
if (reason != null)
{
// Disable the account
profile.Status = "Expired";
// Scramble the password so the device prompts for new credentials
var check = await db.RadCheck.FirstOrDefaultAsync(rc => rc.Username == profile.Username && rc.Attribute == "Cleartext-Password", ct);
if (check != null)
{
check.Value = "REJECT_" + Guid.NewGuid().ToString("N").Substring(0, 8);
}
// Cleanup any old Auth-Type Reject entries
var rejectCheck = await db.RadCheck.FirstOrDefaultAsync(rc => rc.Username == profile.Username && rc.Attribute == "Auth-Type", ct);
if (rejectCheck != null)
{
db.RadCheck.Remove(rejectCheck);
}
_logger.LogInformation("Account '{Username}' disabled. Reason: {Reason}", profile.Username, reason);
// Find active sessions to disconnect them instantly via RADIUS CoA (PoD)
var activeSessions = await db.RadAcct
.Where(ra => ra.Username == profile.Username && ra.AcctStopTime == null)
.Select(ra => new { ra.NasIpAddress, ra.CallingStationId, ra.AcctSessionId, ra.CalledStationId })
.ToListAsync(ct);
var radiusSecret = Environment.GetEnvironmentVariable("RADIUS_SECRET") ?? "radpass";
foreach (var session in activeSessions)
{
if (string.IsNullOrWhiteSpace(session.NasIpAddress)) continue;
try
{
var timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
var nasIdentifier = string.IsNullOrWhiteSpace(session.CalledStationId) ? "" : session.CalledStationId.Split(':')[0].Replace("-", "").ToLowerInvariant();
var payload = $"User-Name=\\\"{profile.Username}\\\"\\nCalling-Station-Id=\\\"{session.CallingStationId}\\\"\\nAcct-Session-Id=\\\"{session.AcctSessionId}\\\"\\nNAS-IP-Address=\\\"{session.NasIpAddress}\\\"\\nNAS-Identifier=\\\"{nasIdentifier}\\\"\\nEvent-Timestamp={timestamp}\\n";
var process = new System.Diagnostics.Process
{
StartInfo = new System.Diagnostics.ProcessStartInfo
{
FileName = "sh",
Arguments = $"-c \"printf '{payload}' | radclient -x {session.NasIpAddress}:3799 disconnect '{radiusSecret}'\"",
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true
}
};
process.Start();
string output = await process.StandardOutput.ReadToEndAsync(ct);
string error = await process.StandardError.ReadToEndAsync(ct);
await process.WaitForExitAsync(ct);
if (process.ExitCode == 0)
{
_logger.LogInformation("Sent RADIUS Disconnect-Request to NAS {NasIp} for user {Username} (MAC: {Mac}). Response: {Output}", session.NasIpAddress, profile.Username, session.CallingStationId, output);
}
else
{
_logger.LogWarning("Failed RADIUS Disconnect-Request to NAS {NasIp}. Exit Code: {Code}, Error: {Error}, Output: {Output}", session.NasIpAddress, process.ExitCode, error, output);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Exception sending RADIUS Disconnect-Request to NAS {NasIp}", session.NasIpAddress);
}
}
disabledCount++;
changesMade = true;
}
}
if (changesMade)
{
await db.SaveChangesAsync(ct);
if (disabledCount > 0)
{
_logger.LogInformation("Enforcement check complete. {Count} account(s) disabled.", disabledCount);
}
}
}
}