2026-07-24 15:50:28 +02:00
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 ;
2026-07-24 17:51:04 +02:00
var changesMade = false ;
2026-07-24 15:50:28 +02:00
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" ;
}
2026-07-24 17:51:04 +02:00
// 2. Check data cap: read from guest_data_usage table
2026-07-24 15:50:28 +02:00
if ( reason == null && profile . DataLimitMb > 0 )
{
2026-07-24 17:51:04 +02:00
var usage = await db . GuestDataUsage . FirstOrDefaultAsync ( du => du . Username == profile . Username , ct );
var totalOctets = ( usage ?. TotalInputOctets ?? 0 ) + ( usage ?. TotalOutputOctets ?? 0 );
2026-07-24 15:50:28 +02:00
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" ;
// Remove Cleartext-Password from radcheck so FreeRADIUS rejects future auth
var checks = await db . RadCheck
. Where ( rc => rc . Username == profile . Username )
. ToListAsync ( ct );
db . RadCheck . RemoveRange ( checks );
// Insert Auth-Type := Reject to explicitly deny
db . RadCheck . Add ( new RadCheck
{
Username = profile . Username ,
Attribute = "Auth-Type" ,
Op = ":=" ,
Value = "Reject"
});
2026-07-24 17:51:04 +02:00
_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 )
2026-07-24 18:47:45 +02:00
. Select ( ra => new { ra . NasIpAddress , ra . CallingStationId , ra . AcctSessionId })
2026-07-24 17:51:04 +02:00
. ToListAsync ( ct );
var radiusSecret = Environment . GetEnvironmentVariable ( "RADIUS_SECRET" ) ?? "radpass" ;
2026-07-24 18:47:45 +02:00
foreach ( var session in activeSessions )
2026-07-24 17:51:04 +02:00
{
2026-07-24 18:47:45 +02:00
if ( string . IsNullOrWhiteSpace ( session . NasIpAddress )) continue ;
2026-07-24 17:51:04 +02:00
try
{
2026-07-24 18:47:45 +02:00
var payload = $"User-Name=\\\" { profile . Username } \\\ "\\nCalling-Station-Id=\\\"{session.CallingStationId}\\\"\\nAcct-Session-Id=\\\"{session.AcctSessionId}\\\"\\n" ;
2026-07-24 17:51:04 +02:00
var process = new System . Diagnostics . Process
{
StartInfo = new System . Diagnostics . ProcessStartInfo
{
FileName = "sh" ,
2026-07-24 18:47:45 +02:00
Arguments = $"-c \" printf ' { payload } ' | radclient - x { session . NasIpAddress }: 3799 disconnect ' { radiusSecret } '\ "" ,
2026-07-24 17:51:04 +02:00
RedirectStandardOutput = true ,
RedirectStandardError = true ,
UseShellExecute = false ,
CreateNoWindow = true
}
};
process . Start ();
await process . WaitForExitAsync ( ct );
2026-07-24 18:47:45 +02:00
_logger . LogInformation ( "Sent RADIUS Disconnect-Request to NAS {NasIp} for user {Username} (MAC: {Mac})" , session . NasIpAddress , profile . Username , session . CallingStationId );
2026-07-24 17:51:04 +02:00
}
catch ( Exception ex )
{
2026-07-24 18:47:45 +02:00
_logger . LogError ( ex , "Failed to send RADIUS Disconnect-Request to NAS {NasIp}" , session . NasIpAddress );
2026-07-24 17:51:04 +02:00
}
}
2026-07-24 15:50:28 +02:00
disabledCount ++;
2026-07-24 17:51:04 +02:00
changesMade = true ;
2026-07-24 15:50:28 +02:00
}
}
2026-07-24 17:51:04 +02:00
if ( changesMade )
2026-07-24 15:50:28 +02:00
{
await db . SaveChangesAsync ( ct );
2026-07-24 17:51:04 +02:00
if ( disabledCount > 0 )
{
_logger . LogInformation ( "Enforcement check complete. {Count} account(s) disabled." , disabledCount );
}
2026-07-24 15:50:28 +02:00
}
}
}