From c85b598b82b0154896565b116d4eb822be99e546 Mon Sep 17 00:00:00 2001 From: Tygozwolle Date: Fri, 24 Jul 2026 15:50:28 +0200 Subject: [PATCH] feat: initialize web host and implement background AccountEnforcementService for RADIUS account monitoring --- radiuscontroller/Program.cs | 1 + .../Services/AccountEnforcementService.cs | 119 ++++++++++++++++++ 2 files changed, 120 insertions(+) create mode 100644 radiuscontroller/Services/AccountEnforcementService.cs diff --git a/radiuscontroller/Program.cs b/radiuscontroller/Program.cs index 2dea438..ab8552b 100644 --- a/radiuscontroller/Program.cs +++ b/radiuscontroller/Program.cs @@ -15,6 +15,7 @@ builder.Services.AddDbContext(options => // Add services builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddHostedService(); // Add Razor components builder.Services.AddRazorComponents() diff --git a/radiuscontroller/Services/AccountEnforcementService.cs b/radiuscontroller/Services/AccountEnforcementService.cs new file mode 100644 index 0000000..f224a77 --- /dev/null +++ b/radiuscontroller/Services/AccountEnforcementService.cs @@ -0,0 +1,119 @@ +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; + + 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: sum acctinputoctets + acctoutputoctets from radacct + if (reason == null && profile.DataLimitMb > 0) + { + var totalOctets = await db.RadAcct + .Where(ra => ra.Username == profile.Username) + .SumAsync(ra => (ra.AcctInputOctets ?? 0) + (ra.AcctOutputOctets ?? 0), ct); + + 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); + + disabledCount++; + } + } + + if (disabledCount > 0) + { + await db.SaveChangesAsync(ct); + _logger.LogInformation("Enforcement check complete. {Count} account(s) disabled.", disabledCount); + } + } +}