using Microsoft.EntityFrameworkCore; using radiuscontroller.Data; using radiuscontroller.Models; namespace radiuscontroller.Services; /// /// 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). /// public class AccountEnforcementService : BackgroundService { private readonly IServiceScopeFactory _scopeFactory; private readonly ILogger _logger; private static readonly TimeSpan CheckInterval = TimeSpan.FromSeconds(30); public AccountEnforcementService(IServiceScopeFactory scopeFactory, ILogger 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(); 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"; // 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" }); _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); } } } }