From 63b9a97cdfe38637d5cc1706022115ea1f856cc5 Mon Sep 17 00:00:00 2001 From: Tygozwolle Date: Fri, 24 Jul 2026 17:51:04 +0200 Subject: [PATCH] feat: initialize database schema and implement core Radius entities, EF context, and account enforcement service. --- radiuscontroller/Data/RadiusDbContext.cs | 2 + radiuscontroller/Models/RadiusEntities.cs | 14 +++++ .../Services/AccountEnforcementService.cs | 57 ++++++++++++++++--- radiuscontroller/Services/RadiusService.cs | 12 ++-- sql/init.sql | 55 ++++++++++++++++++ 5 files changed, 126 insertions(+), 14 deletions(-) diff --git a/radiuscontroller/Data/RadiusDbContext.cs b/radiuscontroller/Data/RadiusDbContext.cs index 5c1401b..0b860c3 100644 --- a/radiuscontroller/Data/RadiusDbContext.cs +++ b/radiuscontroller/Data/RadiusDbContext.cs @@ -14,6 +14,7 @@ public class RadiusDbContext : DbContext public DbSet RadReply { get; set; } = null!; public DbSet RadAcct { get; set; } = null!; public DbSet GuestProfiles { get; set; } = null!; + public DbSet GuestDataUsage { get; set; } = null!; public DbSet AdminUsers { get; set; } = null!; public DbSet SystemSettings { get; set; } = null!; @@ -25,6 +26,7 @@ public class RadiusDbContext : DbContext modelBuilder.Entity().ToTable("radreply"); modelBuilder.Entity().ToTable("radacct"); modelBuilder.Entity().ToTable("guest_profiles"); + modelBuilder.Entity().ToTable("guest_data_usage"); modelBuilder.Entity().ToTable("admin_users"); modelBuilder.Entity().ToTable("system_settings"); } diff --git a/radiuscontroller/Models/RadiusEntities.cs b/radiuscontroller/Models/RadiusEntities.cs index 1bbd726..2a83d46 100644 --- a/radiuscontroller/Models/RadiusEntities.cs +++ b/radiuscontroller/Models/RadiusEntities.cs @@ -179,6 +179,20 @@ public class GuestProfile public string Status { get; set; } = "Active"; } +[Table("guest_data_usage")] +public class GuestDataUsage +{ + [Key] + [Column("username")] + public string Username { get; set; } = string.Empty; + + [Column("total_input_octets")] + public long TotalInputOctets { get; set; } + + [Column("total_output_octets")] + public long TotalOutputOctets { get; set; } +} + [Table("admin_users")] public class AdminUser { diff --git a/radiuscontroller/Services/AccountEnforcementService.cs b/radiuscontroller/Services/AccountEnforcementService.cs index f224a77..53dc6b2 100644 --- a/radiuscontroller/Services/AccountEnforcementService.cs +++ b/radiuscontroller/Services/AccountEnforcementService.cs @@ -56,6 +56,7 @@ public class AccountEnforcementService : BackgroundService var now = DateTime.UtcNow; var disabledCount = 0; + var changesMade = false; foreach (var profile in activeProfiles) { @@ -68,12 +69,11 @@ public class AccountEnforcementService : BackgroundService reason = "Time expired"; } - // 2. Check data cap: sum acctinputoctets + acctoutputoctets from radacct + // 2. Check data cap: read from guest_data_usage table 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); + 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) @@ -102,18 +102,57 @@ public class AccountEnforcementService : BackgroundService Value = "Reject" }); - _logger.LogInformation( - "Account '{Username}' disabled. Reason: {Reason}", - profile.Username, reason); + _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 => ra.NasIpAddress) + .Distinct() + .ToListAsync(ct); + + var radiusSecret = Environment.GetEnvironmentVariable("RADIUS_SECRET") ?? "radpass"; + + foreach (var nasIp in activeSessions) + { + if (string.IsNullOrWhiteSpace(nasIp)) continue; + + try + { + var process = new System.Diagnostics.Process + { + StartInfo = new System.Diagnostics.ProcessStartInfo + { + FileName = "sh", + Arguments = $"-c \"echo 'User-Name={profile.Username}' | radclient -x {nasIp}:3799 disconnect '{radiusSecret}'\"", + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + } + }; + process.Start(); + await process.WaitForExitAsync(ct); + _logger.LogInformation("Sent RADIUS Disconnect-Request to NAS {NasIp} for user {Username}", nasIp, profile.Username); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to send RADIUS Disconnect-Request to NAS {NasIp}", nasIp); + } + } disabledCount++; + changesMade = true; } } - if (disabledCount > 0) + if (changesMade) { await db.SaveChangesAsync(ct); - _logger.LogInformation("Enforcement check complete. {Count} account(s) disabled.", disabledCount); + if (disabledCount > 0) + { + _logger.LogInformation("Enforcement check complete. {Count} account(s) disabled.", disabledCount); + } } } } diff --git a/radiuscontroller/Services/RadiusService.cs b/radiuscontroller/Services/RadiusService.cs index 466b2cd..5bb14c1 100644 --- a/radiuscontroller/Services/RadiusService.cs +++ b/radiuscontroller/Services/RadiusService.cs @@ -175,25 +175,27 @@ public class RadiusService : IRadiusService .Where(rc => rc.Attribute == "Cleartext-Password") .ToDictionaryAsync(rc => rc.Username, rc => rc.Value); - // Group accounting data by username + // Group accounting data by username (for session time and connection status) var acctStats = await _db.RadAcct .GroupBy(ra => ra.Username) .Select(g => new { Username = g.Key, - TotalInput = g.Sum(x => x.AcctInputOctets ?? 0), - TotalOutput = g.Sum(x => x.AcctOutputOctets ?? 0), TotalSessionTime = g.Sum(x => x.AcctSessionTime ?? 0), IsConnected = g.Any(x => x.AcctStopTime == null) }) .ToDictionaryAsync(g => g.Username); + // Fetch data usage from the new dedicated table + var dataUsage = await _db.GuestDataUsage.ToDictionaryAsync(du => du.Username); + var result = new List(); foreach (var p in profiles) { passwords.TryGetValue(p.Username, out var pwd); acctStats.TryGetValue(p.Username, out var acct); + dataUsage.TryGetValue(p.Username, out var usage); result.Add(new UserUsageDto { @@ -205,8 +207,8 @@ public class RadiusService : IRadiusService SessionTimeMinutes = p.SessionTimeMinutes, DataLimitMb = p.DataLimitMb, Status = p.Status, - TotalInputOctets = acct?.TotalInput ?? 0, - TotalOutputOctets = acct?.TotalOutput ?? 0, + TotalInputOctets = usage?.TotalInputOctets ?? 0, + TotalOutputOctets = usage?.TotalOutputOctets ?? 0, TotalSessionTimeSeconds = acct?.TotalSessionTime ?? 0, IsCurrentlyConnected = acct?.IsConnected ?? false }); diff --git a/sql/init.sql b/sql/init.sql index 279fcb5..a18fbcd 100644 --- a/sql/init.sql +++ b/sql/init.sql @@ -131,3 +131,58 @@ INSERT INTO system_settings (key, value) VALUES ('default_data_limit', '500') ON -- System settings initialized on startup -- Admin user is created by administrator during first startup via the web interface + +-- Dedicated table for robust, per-user data tracking that survives AP counter resets +CREATE TABLE IF NOT EXISTS guest_data_usage ( + username VARCHAR(64) PRIMARY KEY, + total_input_octets BIGINT NOT NULL DEFAULT 0, + total_output_octets BIGINT NOT NULL DEFAULT 0 +); + +-- Trigger function to safely accumulate data usage from radacct +CREATE OR REPLACE FUNCTION update_guest_data_usage() +RETURNS TRIGGER AS $$ +DECLARE + delta_input BIGINT := 0; + delta_output BIGINT := 0; +BEGIN + -- Handle INSERT (new session) + IF (TG_OP = 'INSERT') THEN + delta_input := COALESCE(NEW.acctinputoctets, 0); + delta_output := COALESCE(NEW.acctoutputoctets, 0); + -- Handle UPDATE (interim updates) + ELSIF (TG_OP = 'UPDATE') THEN + -- Only add if the new value is greater than the old value (protects against counter resets) + IF (COALESCE(NEW.acctinputoctets, 0) > COALESCE(OLD.acctinputoctets, 0)) THEN + delta_input := NEW.acctinputoctets - COALESCE(OLD.acctinputoctets, 0); + ELSIF (COALESCE(NEW.acctinputoctets, 0) < COALESCE(OLD.acctinputoctets, 0)) THEN + -- Counter reset mid-session! Just add the new value as the delta. + delta_input := COALESCE(NEW.acctinputoctets, 0); + END IF; + + IF (COALESCE(NEW.acctoutputoctets, 0) > COALESCE(OLD.acctoutputoctets, 0)) THEN + delta_output := NEW.acctoutputoctets - COALESCE(OLD.acctoutputoctets, 0); + ELSIF (COALESCE(NEW.acctoutputoctets, 0) < COALESCE(OLD.acctoutputoctets, 0)) THEN + -- Counter reset mid-session! + delta_output := COALESCE(NEW.acctoutputoctets, 0); + END IF; + END IF; + + -- Upsert the calculated deltas into the usage table per-user + IF (delta_input > 0 OR delta_output > 0) THEN + INSERT INTO guest_data_usage (username, total_input_octets, total_output_octets) + VALUES (NEW.username, delta_input, delta_output) + ON CONFLICT (username) DO UPDATE + SET total_input_octets = guest_data_usage.total_input_octets + EXCLUDED.total_input_octets, + total_output_octets = guest_data_usage.total_output_octets + EXCLUDED.total_output_octets; + END IF; + + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +-- Attach trigger to radacct table +DROP TRIGGER IF EXISTS trg_update_guest_data_usage ON radacct; +CREATE TRIGGER trg_update_guest_data_usage +AFTER INSERT OR UPDATE OF acctinputoctets, acctoutputoctets ON radacct +FOR EACH ROW EXECUTE FUNCTION update_guest_data_usage();