From c85b598b82b0154896565b116d4eb822be99e546 Mon Sep 17 00:00:00 2001 From: Tygozwolle Date: Fri, 24 Jul 2026 15:50:28 +0200 Subject: [PATCH 01/20] 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); + } + } +} -- 2.52.0 From aebf757f26dd631951760b01bdf1830dd254175b Mon Sep 17 00:00:00 2001 From: Tygozwolle Date: Fri, 24 Jul 2026 16:44:44 +0200 Subject: [PATCH 02/20] feat: define RADIUS database entities and initialize PostgreSQL schema with FreeRADIUS support --- radiuscontroller/Models/RadiusEntities.cs | 23 +++++++++++++++++++---- sql/init.sql | 12 +++++++++--- 2 files changed, 28 insertions(+), 7 deletions(-) diff --git a/radiuscontroller/Models/RadiusEntities.cs b/radiuscontroller/Models/RadiusEntities.cs index 206fcb6..1bbd726 100644 --- a/radiuscontroller/Models/RadiusEntities.cs +++ b/radiuscontroller/Models/RadiusEntities.cs @@ -105,11 +105,11 @@ public class RadAcct [Column("acctauthentic")] public string? AcctAuthentic { get; set; } - [Column("connectinfo_in")] - public string? ConnectInfoIn { get; set; } + [Column("connectinfo_start")] + public string? ConnectInfoStart { get; set; } - [Column("connectinfo_out")] - public string? ConnectInfoOut { get; set; } + [Column("connectinfo_stop")] + public string? ConnectInfoStop { get; set; } [Column("acctinputoctets")] public long? AcctInputOctets { get; set; } @@ -134,6 +134,21 @@ public class RadAcct [Column("framedipaddress")] public string? FramedIpAddress { get; set; } + + [Column("framedipv6address")] + public string? FramedIpV6Address { get; set; } + + [Column("framedipv6prefix")] + public string? FramedIpV6Prefix { get; set; } + + [Column("framedinterfaceid")] + public string? FramedInterfaceId { get; set; } + + [Column("delegatedipv6prefix")] + public string? DelegatedIpV6Prefix { get; set; } + + [Column("class")] + public string? Class { get; set; } } [Table("guest_profiles")] diff --git a/sql/init.sql b/sql/init.sql index 077db02..9204dfe 100644 --- a/sql/init.sql +++ b/sql/init.sql @@ -60,8 +60,8 @@ CREATE TABLE IF NOT EXISTS radacct ( acctinterval INT DEFAULT NULL, acctsessiontime BIGINT DEFAULT NULL, acctauthentic VARCHAR(32) DEFAULT NULL, - connectinfo_in VARCHAR(50) DEFAULT NULL, - connectinfo_out VARCHAR(50) DEFAULT NULL, + connectinfo_start VARCHAR(50) DEFAULT NULL, + connectinfo_stop VARCHAR(50) DEFAULT NULL, acctinputoctets BIGINT DEFAULT NULL, acctoutputoctets BIGINT DEFAULT NULL, calledstationid VARCHAR(50) NOT NULL DEFAULT '', @@ -69,10 +69,16 @@ CREATE TABLE IF NOT EXISTS radacct ( acctterminatecause VARCHAR(32) NOT NULL DEFAULT '', servicetype VARCHAR(32) DEFAULT NULL, framedprotocol VARCHAR(32) DEFAULT NULL, - framedipaddress VARCHAR(15) NOT NULL DEFAULT '' + framedipaddress VARCHAR(15) NOT NULL DEFAULT '', + framedipv6address VARCHAR(45) NOT NULL DEFAULT '', + framedipv6prefix VARCHAR(45) NOT NULL DEFAULT '', + framedinterfaceid VARCHAR(44) NOT NULL DEFAULT '', + delegatedipv6prefix VARCHAR(45) NOT NULL DEFAULT '', + class VARCHAR(64) DEFAULT NULL ); CREATE INDEX IF NOT EXISTS radacct_username ON radacct (username); CREATE INDEX IF NOT EXISTS radacct_active ON radacct (acctstoptime) WHERE acctstoptime IS NULL; +CREATE INDEX IF NOT EXISTS radacct_acctuniqueid ON radacct (acctuniqueid); CREATE TABLE IF NOT EXISTS nas ( id SERIAL PRIMARY KEY, -- 2.52.0 From 6d95c12304b0de81d6328faa6cc7fcf253b6a101 Mon Sep 17 00:00:00 2001 From: Tygozwolle Date: Fri, 24 Jul 2026 17:02:59 +0200 Subject: [PATCH 03/20] feat: add PostgreSQL initialization script for FreeRADIUS schema and application management tables --- sql/init.sql | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/sql/init.sql b/sql/init.sql index 9204dfe..20d3416 100644 --- a/sql/init.sql +++ b/sql/init.sql @@ -48,10 +48,10 @@ CREATE INDEX IF NOT EXISTS radusergroup_username ON radusergroup (username); CREATE TABLE IF NOT EXISTS radacct ( radacctid BIGSERIAL PRIMARY KEY, acctsessionid VARCHAR(64) NOT NULL DEFAULT '', - acctuniqueid VARCHAR(32) NOT NULL DEFAULT '', + acctuniqueid VARCHAR(32) NOT NULL UNIQUE DEFAULT '', username VARCHAR(64) NOT NULL DEFAULT '', realm VARCHAR(64) DEFAULT '', - nasipaddress VARCHAR(15) NOT NULL DEFAULT '', + nasipaddress VARCHAR(46) NOT NULL DEFAULT '', nasportid VARCHAR(32) DEFAULT NULL, nasporttype VARCHAR(32) DEFAULT NULL, acctstarttime TIMESTAMP WITH TIME ZONE DEFAULT NULL, @@ -69,7 +69,7 @@ CREATE TABLE IF NOT EXISTS radacct ( acctterminatecause VARCHAR(32) NOT NULL DEFAULT '', servicetype VARCHAR(32) DEFAULT NULL, framedprotocol VARCHAR(32) DEFAULT NULL, - framedipaddress VARCHAR(15) NOT NULL DEFAULT '', + framedipaddress VARCHAR(46) NOT NULL DEFAULT '', framedipv6address VARCHAR(45) NOT NULL DEFAULT '', framedipv6prefix VARCHAR(45) NOT NULL DEFAULT '', framedinterfaceid VARCHAR(44) NOT NULL DEFAULT '', -- 2.52.0 From d7d493367f7aaf68bca76452685ac01d71ec8cf1 Mon Sep 17 00:00:00 2001 From: Tygozwolle Date: Fri, 24 Jul 2026 17:11:23 +0200 Subject: [PATCH 04/20] working --- sql/init.sql | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/sql/init.sql b/sql/init.sql index 20d3416..279fcb5 100644 --- a/sql/init.sql +++ b/sql/init.sql @@ -64,16 +64,16 @@ CREATE TABLE IF NOT EXISTS radacct ( connectinfo_stop VARCHAR(50) DEFAULT NULL, acctinputoctets BIGINT DEFAULT NULL, acctoutputoctets BIGINT DEFAULT NULL, - calledstationid VARCHAR(50) NOT NULL DEFAULT '', - callingstationid VARCHAR(50) NOT NULL DEFAULT '', - acctterminatecause VARCHAR(32) NOT NULL DEFAULT '', + calledstationid VARCHAR(50) DEFAULT '', + callingstationid VARCHAR(50) DEFAULT '', + acctterminatecause VARCHAR(32) DEFAULT NULL, servicetype VARCHAR(32) DEFAULT NULL, framedprotocol VARCHAR(32) DEFAULT NULL, - framedipaddress VARCHAR(46) NOT NULL DEFAULT '', - framedipv6address VARCHAR(45) NOT NULL DEFAULT '', - framedipv6prefix VARCHAR(45) NOT NULL DEFAULT '', - framedinterfaceid VARCHAR(44) NOT NULL DEFAULT '', - delegatedipv6prefix VARCHAR(45) NOT NULL DEFAULT '', + framedipaddress VARCHAR(46) DEFAULT '', + framedipv6address VARCHAR(45) DEFAULT '', + framedipv6prefix VARCHAR(45) DEFAULT '', + framedinterfaceid VARCHAR(44) DEFAULT '', + delegatedipv6prefix VARCHAR(45) DEFAULT '', class VARCHAR(64) DEFAULT NULL ); CREATE INDEX IF NOT EXISTS radacct_username ON radacct (username); -- 2.52.0 From d1b93191e153df23246fd66ad2e3cc179fbd3db8 Mon Sep 17 00:00:00 2001 From: Tygozwolle Date: Fri, 24 Jul 2026 17:13:44 +0200 Subject: [PATCH 05/20] feat: implement RadiusService for guest user management, session monitoring, and configuration handling --- radiuscontroller/Services/RadiusService.cs | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/radiuscontroller/Services/RadiusService.cs b/radiuscontroller/Services/RadiusService.cs index 442a0fb..fc696c9 100644 --- a/radiuscontroller/Services/RadiusService.cs +++ b/radiuscontroller/Services/RadiusService.cs @@ -126,6 +126,24 @@ public class RadiusService : IRadiusService Value = maxOctetsBytes.ToString() }); + // Force NAS to send accounting interim-updates every 60 seconds + _db.RadReply.Add(new RadReply + { + Username = username, + Attribute = "Acct-Interim-Interval", + Op = "=", + Value = "60" + }); + + // Re-authenticate (instead of disconnect) when Session-Timeout fires + _db.RadReply.Add(new RadReply + { + Username = username, + Attribute = "Termination-Action", + Op = "=", + Value = "1" + }); + // Create Guest Profile record var profile = new GuestProfile { -- 2.52.0 From 9c2cbfa7b96346662c440711bf4aa79d23c91d4a Mon Sep 17 00:00:00 2001 From: Tygozwolle Date: Fri, 24 Jul 2026 17:36:23 +0200 Subject: [PATCH 06/20] feat: implement RadiusService for guest registration, session management, and RADIUS attribute configuration --- radiuscontroller/Services/RadiusService.cs | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/radiuscontroller/Services/RadiusService.cs b/radiuscontroller/Services/RadiusService.cs index fc696c9..466b2cd 100644 --- a/radiuscontroller/Services/RadiusService.cs +++ b/radiuscontroller/Services/RadiusService.cs @@ -106,14 +106,13 @@ public class RadiusService : IRadiusService Value = password }); - // Set Session-Timeout (in seconds) - long sessionTimeoutSeconds = (long)config.DefaultSessionTimeMinutes * 60; + // Set Session-Timeout to 60 seconds to force frequent re-authentication (validation) _db.RadReply.Add(new RadReply { Username = username, Attribute = "Session-Timeout", Op = "=", - Value = sessionTimeoutSeconds.ToString() + Value = "60" }); // Set Max-Octets (in bytes) @@ -232,11 +231,11 @@ public class RadiusService : IRadiusService profile.SessionTimeMinutes = newSessionTimeMinutes; profile.DataLimitMb = newDataLimitMb; - // Update RadReply Session-Timeout + // Ensure Session-Timeout is strictly 60 seconds for frequent validation var sessionReply = await _db.RadReply.FirstOrDefaultAsync(rr => rr.Username == profile.Username && rr.Attribute == "Session-Timeout"); if (sessionReply != null) { - sessionReply.Value = (newSessionTimeMinutes * 60).ToString(); + sessionReply.Value = "60"; } else { @@ -245,7 +244,7 @@ public class RadiusService : IRadiusService Username = profile.Username, Attribute = "Session-Timeout", Op = "=", - Value = (newSessionTimeMinutes * 60).ToString() + Value = "60" }); } -- 2.52.0 From 63b9a97cdfe38637d5cc1706022115ea1f856cc5 Mon Sep 17 00:00:00 2001 From: Tygozwolle Date: Fri, 24 Jul 2026 17:51:04 +0200 Subject: [PATCH 07/20] 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(); -- 2.52.0 From 6e065cf47c203b9bdfa22652a430e048e24deb0e Mon Sep 17 00:00:00 2001 From: Tygozwolle Date: Fri, 24 Jul 2026 18:02:35 +0200 Subject: [PATCH 08/20] feat: add admin dashboard page for user management and RADIUS monitoring --- radiuscontroller/Components/Pages/Admin/Dashboard.razor | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/radiuscontroller/Components/Pages/Admin/Dashboard.razor b/radiuscontroller/Components/Pages/Admin/Dashboard.razor index 665db23..923ee14 100644 --- a/radiuscontroller/Components/Pages/Admin/Dashboard.razor +++ b/radiuscontroller/Components/Pages/Admin/Dashboard.razor @@ -172,7 +172,7 @@ @user.SessionTimeMinutes mins
- RADIUS Session-Timeout + Expires: @user.CreatedAt.AddMinutes(user.SessionTimeMinutes).ToLocalTime().ToString("MMM dd, HH:mm")
-- 2.52.0 From 0e4189985ec07b617247d05dfd644a39c3f57309 Mon Sep 17 00:00:00 2001 From: Tygozwolle Date: Fri, 24 Jul 2026 18:15:31 +0200 Subject: [PATCH 09/20] feat: implement administrative dashboard page for monitoring and managing guest RADIUS sessions --- radiuscontroller/Components/Pages/Admin/Dashboard.razor | 2 -- 1 file changed, 2 deletions(-) diff --git a/radiuscontroller/Components/Pages/Admin/Dashboard.razor b/radiuscontroller/Components/Pages/Admin/Dashboard.razor index 923ee14..c1df37e 100644 --- a/radiuscontroller/Components/Pages/Admin/Dashboard.razor +++ b/radiuscontroller/Components/Pages/Admin/Dashboard.razor @@ -232,7 +232,6 @@ class="form-control-glass" @bind="editConfigSessionTime" min="1" - max="10080" required /> Translates to RADIUS attribute Session-Timeout (in seconds) for FreeRADIUS reply. @@ -245,7 +244,6 @@ class="form-control-glass" @bind="editConfigDataLimit" min="1" - max="1048576" required /> Translates to RADIUS attribute Max-Octets (in bytes) for FreeRADIUS reply. -- 2.52.0 From b730dced1662d3657d46a44c73fbdd14b9353e31 Mon Sep 17 00:00:00 2001 From: Tygozwolle Date: Fri, 24 Jul 2026 18:20:50 +0200 Subject: [PATCH 10/20] feat: implement RadiusService to manage guest user registration, session limits, and RADIUS authentication attributes --- radiuscontroller/Services/RadiusService.cs | 65 +++++++++++++++++----- 1 file changed, 52 insertions(+), 13 deletions(-) diff --git a/radiuscontroller/Services/RadiusService.cs b/radiuscontroller/Services/RadiusService.cs index 5bb14c1..425f89b 100644 --- a/radiuscontroller/Services/RadiusService.cs +++ b/radiuscontroller/Services/RadiusService.cs @@ -106,13 +106,14 @@ public class RadiusService : IRadiusService Value = password }); - // Set Session-Timeout to 60 seconds to force frequent re-authentication (validation) + // Set Session-Timeout to the full allowed time. When this expires, the AP will drop the user natively. + long sessionTimeoutSeconds = (long)config.DefaultSessionTimeMinutes * 60; _db.RadReply.Add(new RadReply { Username = username, Attribute = "Session-Timeout", Op = "=", - Value = "60" + Value = sessionTimeoutSeconds.ToString() }); // Set Max-Octets (in bytes) @@ -134,14 +135,7 @@ public class RadiusService : IRadiusService Value = "60" }); - // Re-authenticate (instead of disconnect) when Session-Timeout fires - _db.RadReply.Add(new RadReply - { - Username = username, - Attribute = "Termination-Action", - Op = "=", - Value = "1" - }); + // Create Guest Profile record var profile = new GuestProfile @@ -233,11 +227,11 @@ public class RadiusService : IRadiusService profile.SessionTimeMinutes = newSessionTimeMinutes; profile.DataLimitMb = newDataLimitMb; - // Ensure Session-Timeout is strictly 60 seconds for frequent validation + // Update RadReply Session-Timeout to the full allowed time var sessionReply = await _db.RadReply.FirstOrDefaultAsync(rr => rr.Username == profile.Username && rr.Attribute == "Session-Timeout"); if (sessionReply != null) { - sessionReply.Value = "60"; + sessionReply.Value = (newSessionTimeMinutes * 60).ToString(); } else { @@ -246,7 +240,7 @@ public class RadiusService : IRadiusService Username = profile.Username, Attribute = "Session-Timeout", Op = "=", - Value = "60" + Value = (newSessionTimeMinutes * 60).ToString() }); } @@ -291,6 +285,9 @@ public class RadiusService : IRadiusService }); await _db.SaveChangesAsync(); + + // Disconnect active sessions immediately + await DisconnectActiveSessionsAsync(profile.Username); } public async Task DeleteUserAsync(int profileId) @@ -308,6 +305,9 @@ public class RadiusService : IRadiusService _db.GuestProfiles.Remove(profile); await _db.SaveChangesAsync(); + + // Disconnect active sessions immediately + await DisconnectActiveSessionsAsync(username); } public async Task ResetUserPasswordAsync(int profileId, string newPassword) @@ -374,4 +374,43 @@ public class RadiusService : IRadiusService } return new string(result); } + + private async Task DisconnectActiveSessionsAsync(string username) + { + var activeSessions = await _db.RadAcct + .Where(ra => ra.Username == username && ra.AcctStopTime == null) + .Select(ra => ra.NasIpAddress) + .Distinct() + .ToListAsync(); + + 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={username}' | radclient -x {nasIp}:3799 disconnect '{radiusSecret}'\"", + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + } + }; + process.Start(); + await process.WaitForExitAsync(); + _logger.LogInformation("Sent manual RADIUS Disconnect-Request to NAS {NasIp} for user {Username}", nasIp, username); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to send manual RADIUS Disconnect-Request to NAS {NasIp}", nasIp); + } + } + } } -- 2.52.0 From 63082d0791ea71985e85846625d03f605208cd23 Mon Sep 17 00:00:00 2001 From: Tygozwolle Date: Fri, 24 Jul 2026 18:37:33 +0200 Subject: [PATCH 11/20] feat: initialize FreeRADIUS default site configuration with standard auth, acct, and processing modules --- radiuscontroller/raddb/sites-enabled/default | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/radiuscontroller/raddb/sites-enabled/default b/radiuscontroller/raddb/sites-enabled/default index 893b71a..61c8129 100644 --- a/radiuscontroller/raddb/sites-enabled/default +++ b/radiuscontroller/raddb/sites-enabled/default @@ -56,6 +56,10 @@ pre-proxy { post-proxy { eap } +preacct { + preprocess + acct_unique +} accounting { detail -- 2.52.0 From 926d2a1fc51e7173110dde2fbdd7fe0a3995e2ab Mon Sep 17 00:00:00 2001 From: Tygozwolle Date: Fri, 24 Jul 2026 18:47:45 +0200 Subject: [PATCH 12/20] feat: implement background account enforcement service and RADIUS management service for guest access control --- .../Services/AccountEnforcementService.cs | 14 +++++++------- radiuscontroller/Services/RadiusService.cs | 14 +++++++------- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/radiuscontroller/Services/AccountEnforcementService.cs b/radiuscontroller/Services/AccountEnforcementService.cs index 53dc6b2..d1940a6 100644 --- a/radiuscontroller/Services/AccountEnforcementService.cs +++ b/radiuscontroller/Services/AccountEnforcementService.cs @@ -107,24 +107,24 @@ public class AccountEnforcementService : BackgroundService // 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() + .Select(ra => new { ra.NasIpAddress, ra.CallingStationId, ra.AcctSessionId }) .ToListAsync(ct); var radiusSecret = Environment.GetEnvironmentVariable("RADIUS_SECRET") ?? "radpass"; - foreach (var nasIp in activeSessions) + foreach (var session in activeSessions) { - if (string.IsNullOrWhiteSpace(nasIp)) continue; + if (string.IsNullOrWhiteSpace(session.NasIpAddress)) continue; try { + var payload = $"User-Name=\\\"{profile.Username}\\\"\\nCalling-Station-Id=\\\"{session.CallingStationId}\\\"\\nAcct-Session-Id=\\\"{session.AcctSessionId}\\\"\\n"; 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}'\"", + Arguments = $"-c \"printf '{payload}' | radclient -x {session.NasIpAddress}:3799 disconnect '{radiusSecret}'\"", RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false, @@ -133,11 +133,11 @@ public class AccountEnforcementService : BackgroundService }; process.Start(); await process.WaitForExitAsync(ct); - _logger.LogInformation("Sent RADIUS Disconnect-Request to NAS {NasIp} for user {Username}", nasIp, profile.Username); + _logger.LogInformation("Sent RADIUS Disconnect-Request to NAS {NasIp} for user {Username} (MAC: {Mac})", session.NasIpAddress, profile.Username, session.CallingStationId); } catch (Exception ex) { - _logger.LogError(ex, "Failed to send RADIUS Disconnect-Request to NAS {NasIp}", nasIp); + _logger.LogError(ex, "Failed to send RADIUS Disconnect-Request to NAS {NasIp}", session.NasIpAddress); } } diff --git a/radiuscontroller/Services/RadiusService.cs b/radiuscontroller/Services/RadiusService.cs index 425f89b..8741fea 100644 --- a/radiuscontroller/Services/RadiusService.cs +++ b/radiuscontroller/Services/RadiusService.cs @@ -379,24 +379,24 @@ public class RadiusService : IRadiusService { var activeSessions = await _db.RadAcct .Where(ra => ra.Username == username && ra.AcctStopTime == null) - .Select(ra => ra.NasIpAddress) - .Distinct() + .Select(ra => new { ra.NasIpAddress, ra.CallingStationId, ra.AcctSessionId }) .ToListAsync(); var radiusSecret = Environment.GetEnvironmentVariable("RADIUS_SECRET") ?? "radpass"; - foreach (var nasIp in activeSessions) + foreach (var session in activeSessions) { - if (string.IsNullOrWhiteSpace(nasIp)) continue; + if (string.IsNullOrWhiteSpace(session.NasIpAddress)) continue; try { + var payload = $"User-Name=\\\"{username}\\\"\\nCalling-Station-Id=\\\"{session.CallingStationId}\\\"\\nAcct-Session-Id=\\\"{session.AcctSessionId}\\\"\\n"; var process = new System.Diagnostics.Process { StartInfo = new System.Diagnostics.ProcessStartInfo { FileName = "sh", - Arguments = $"-c \"echo 'User-Name={username}' | radclient -x {nasIp}:3799 disconnect '{radiusSecret}'\"", + Arguments = $"-c \"printf '{payload}' | radclient -x {session.NasIpAddress}:3799 disconnect '{radiusSecret}'\"", RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false, @@ -405,11 +405,11 @@ public class RadiusService : IRadiusService }; process.Start(); await process.WaitForExitAsync(); - _logger.LogInformation("Sent manual RADIUS Disconnect-Request to NAS {NasIp} for user {Username}", nasIp, username); + _logger.LogInformation("Sent manual RADIUS Disconnect-Request to NAS {NasIp} for user {Username} (MAC: {Mac})", session.NasIpAddress, username, session.CallingStationId); } catch (Exception ex) { - _logger.LogError(ex, "Failed to send manual RADIUS Disconnect-Request to NAS {NasIp}", nasIp); + _logger.LogError(ex, "Failed to send manual RADIUS Disconnect-Request to NAS {NasIp}", session.NasIpAddress); } } } -- 2.52.0 From 4b8e4639e555b91458e5bacad742b53e7f6ed451 Mon Sep 17 00:00:00 2001 From: Tygozwolle Date: Fri, 24 Jul 2026 19:04:02 +0200 Subject: [PATCH 13/20] feat: implement RadiusService for guest management and AccountEnforcementService for session monitoring --- .../Services/AccountEnforcementService.cs | 14 ++++++++++++-- radiuscontroller/Services/RadiusService.cs | 14 ++++++++++++-- 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/radiuscontroller/Services/AccountEnforcementService.cs b/radiuscontroller/Services/AccountEnforcementService.cs index d1940a6..2b34f8a 100644 --- a/radiuscontroller/Services/AccountEnforcementService.cs +++ b/radiuscontroller/Services/AccountEnforcementService.cs @@ -132,12 +132,22 @@ public class AccountEnforcementService : BackgroundService } }; process.Start(); + string output = await process.StandardOutput.ReadToEndAsync(ct); + string error = await process.StandardError.ReadToEndAsync(ct); await process.WaitForExitAsync(ct); - _logger.LogInformation("Sent RADIUS Disconnect-Request to NAS {NasIp} for user {Username} (MAC: {Mac})", session.NasIpAddress, profile.Username, session.CallingStationId); + + 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, "Failed to send RADIUS Disconnect-Request to NAS {NasIp}", session.NasIpAddress); + _logger.LogError(ex, "Exception sending RADIUS Disconnect-Request to NAS {NasIp}", session.NasIpAddress); } } diff --git a/radiuscontroller/Services/RadiusService.cs b/radiuscontroller/Services/RadiusService.cs index 8741fea..b24bb5b 100644 --- a/radiuscontroller/Services/RadiusService.cs +++ b/radiuscontroller/Services/RadiusService.cs @@ -404,12 +404,22 @@ public class RadiusService : IRadiusService } }; process.Start(); + string output = await process.StandardOutput.ReadToEndAsync(); + string error = await process.StandardError.ReadToEndAsync(); await process.WaitForExitAsync(); - _logger.LogInformation("Sent manual RADIUS Disconnect-Request to NAS {NasIp} for user {Username} (MAC: {Mac})", session.NasIpAddress, username, session.CallingStationId); + + if (process.ExitCode == 0) + { + _logger.LogInformation("Sent manual RADIUS Disconnect-Request to NAS {NasIp} for user {Username} (MAC: {Mac}). Response: {Output}", session.NasIpAddress, 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, "Failed to send manual RADIUS Disconnect-Request to NAS {NasIp}", session.NasIpAddress); + _logger.LogError(ex, "Exception sending manual RADIUS Disconnect-Request to NAS {NasIp}", session.NasIpAddress); } } } -- 2.52.0 From 74326870f76c5e55d0c8b5ae692fb7f80ed11033 Mon Sep 17 00:00:00 2001 From: Tygozwolle Date: Fri, 24 Jul 2026 19:45:26 +0200 Subject: [PATCH 14/20] feat: implement automated account enforcement service for session time and data usage limits with RADIUS disconnect support --- docker-compose.yml | 11 +++++------ .../Services/AccountEnforcementService.cs | 2 +- radiuscontroller/Services/RadiusService.cs | 2 +- 3 files changed, 7 insertions(+), 8 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index 5a6fd08..fd42699 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -19,20 +19,19 @@ services: image: cablon.vanolst.tech/tygozwolle/radius:latest container_name: radius_web_controller restart: always + network_mode: "host" depends_on: - postgres - ports: - - "8080:8080" - - "1812:1812/udp" - - "1813:1813/udp" environment: - - DB_HOST=postgres + - DB_HOST=127.0.0.1 - DB_PORT=5432 - DB_USER=radius - DB_PASS=radpass - DB_NAME=radius - RADIUS_SECRET=radpass - - ConnectionStrings__DefaultConnection=Host=postgres;Port=5432;Database=radius;Username=radius;Password=radpass; + - ConnectionStrings__DefaultConnection=Host=127.0.0.1;Port=5432;Database=radius;Username=radius;Password=radpass; - ASPNETCORE_ENVIRONMENT=Production + # Change 8080 below to whatever port you want the web UI to run on (e.g., 8090) + - ASPNETCORE_URLS=http://+:8080 volumes: - /mnt/user/appdata/radiuscontroller/certs:/etc/freeradius/3.0/certs diff --git a/radiuscontroller/Services/AccountEnforcementService.cs b/radiuscontroller/Services/AccountEnforcementService.cs index 2b34f8a..293a64f 100644 --- a/radiuscontroller/Services/AccountEnforcementService.cs +++ b/radiuscontroller/Services/AccountEnforcementService.cs @@ -118,7 +118,7 @@ public class AccountEnforcementService : BackgroundService try { - var payload = $"User-Name=\\\"{profile.Username}\\\"\\nCalling-Station-Id=\\\"{session.CallingStationId}\\\"\\nAcct-Session-Id=\\\"{session.AcctSessionId}\\\"\\n"; + var payload = $"User-Name=\\\"{profile.Username}\\\"\\nCalling-Station-Id=\\\"{session.CallingStationId}\\\"\\nAcct-Session-Id=\\\"{session.AcctSessionId}\\\"\\nNAS-IP-Address=\\\"{session.NasIpAddress}\\\"\\n"; var process = new System.Diagnostics.Process { StartInfo = new System.Diagnostics.ProcessStartInfo diff --git a/radiuscontroller/Services/RadiusService.cs b/radiuscontroller/Services/RadiusService.cs index b24bb5b..9562e6f 100644 --- a/radiuscontroller/Services/RadiusService.cs +++ b/radiuscontroller/Services/RadiusService.cs @@ -390,7 +390,7 @@ public class RadiusService : IRadiusService try { - var payload = $"User-Name=\\\"{username}\\\"\\nCalling-Station-Id=\\\"{session.CallingStationId}\\\"\\nAcct-Session-Id=\\\"{session.AcctSessionId}\\\"\\n"; + var payload = $"User-Name=\\\"{username}\\\"\\nCalling-Station-Id=\\\"{session.CallingStationId}\\\"\\nAcct-Session-Id=\\\"{session.AcctSessionId}\\\"\\nNAS-IP-Address=\\\"{session.NasIpAddress}\\\"\\n"; var process = new System.Diagnostics.Process { StartInfo = new System.Diagnostics.ProcessStartInfo -- 2.52.0 From f5d2144f79ae321e1c9f53c14b2ce440493da91b Mon Sep 17 00:00:00 2001 From: Tygozwolle Date: Fri, 24 Jul 2026 19:55:59 +0200 Subject: [PATCH 15/20] feat: add account enforcement background service and update docker-compose for network containerization --- docker-compose.yml | 9 ++++++--- radiuscontroller/Services/AccountEnforcementService.cs | 2 +- radiuscontroller/Services/RadiusService.cs | 2 +- 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index fd42699..831d2f5 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -19,17 +19,20 @@ services: image: cablon.vanolst.tech/tygozwolle/radius:latest container_name: radius_web_controller restart: always - network_mode: "host" depends_on: - postgres + ports: + - "8080:8080" + - "1812:1812/udp" + - "1813:1813/udp" environment: - - DB_HOST=127.0.0.1 + - DB_HOST=postgres - DB_PORT=5432 - DB_USER=radius - DB_PASS=radpass - DB_NAME=radius - RADIUS_SECRET=radpass - - ConnectionStrings__DefaultConnection=Host=127.0.0.1;Port=5432;Database=radius;Username=radius;Password=radpass; + - ConnectionStrings__DefaultConnection=Host=postgres;Port=5432;Database=radius;Username=radius;Password=radpass; - ASPNETCORE_ENVIRONMENT=Production # Change 8080 below to whatever port you want the web UI to run on (e.g., 8090) - ASPNETCORE_URLS=http://+:8080 diff --git a/radiuscontroller/Services/AccountEnforcementService.cs b/radiuscontroller/Services/AccountEnforcementService.cs index 293a64f..de20b74 100644 --- a/radiuscontroller/Services/AccountEnforcementService.cs +++ b/radiuscontroller/Services/AccountEnforcementService.cs @@ -118,7 +118,7 @@ public class AccountEnforcementService : BackgroundService try { - var payload = $"User-Name=\\\"{profile.Username}\\\"\\nCalling-Station-Id=\\\"{session.CallingStationId}\\\"\\nAcct-Session-Id=\\\"{session.AcctSessionId}\\\"\\nNAS-IP-Address=\\\"{session.NasIpAddress}\\\"\\n"; + var payload = $"User-Name=\\\"{profile.Username}\\\"\\nCalling-Station-Id=\\\"{session.CallingStationId}\\\"\\nAcct-Session-Id=\\\"{session.AcctSessionId}\\\"\\nNAS-IP-Address=\\\"{session.NasIpAddress}\\\"\\nMessage-Authenticator=0x00\\n"; var process = new System.Diagnostics.Process { StartInfo = new System.Diagnostics.ProcessStartInfo diff --git a/radiuscontroller/Services/RadiusService.cs b/radiuscontroller/Services/RadiusService.cs index 9562e6f..4b2cc04 100644 --- a/radiuscontroller/Services/RadiusService.cs +++ b/radiuscontroller/Services/RadiusService.cs @@ -390,7 +390,7 @@ public class RadiusService : IRadiusService try { - var payload = $"User-Name=\\\"{username}\\\"\\nCalling-Station-Id=\\\"{session.CallingStationId}\\\"\\nAcct-Session-Id=\\\"{session.AcctSessionId}\\\"\\nNAS-IP-Address=\\\"{session.NasIpAddress}\\\"\\n"; + var payload = $"User-Name=\\\"{username}\\\"\\nCalling-Station-Id=\\\"{session.CallingStationId}\\\"\\nAcct-Session-Id=\\\"{session.AcctSessionId}\\\"\\nNAS-IP-Address=\\\"{session.NasIpAddress}\\\"\\nMessage-Authenticator=0x00\\n"; var process = new System.Diagnostics.Process { StartInfo = new System.Diagnostics.ProcessStartInfo -- 2.52.0 From be26d3f4615c6cf20920c3ca6c164701c332ec65 Mon Sep 17 00:00:00 2001 From: Tygozwolle Date: Fri, 24 Jul 2026 20:07:16 +0200 Subject: [PATCH 16/20] feat: add account enforcement background service and radius management service for automated session control and user configuration --- radiuscontroller/Services/AccountEnforcementService.cs | 6 ++++-- radiuscontroller/Services/RadiusService.cs | 6 ++++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/radiuscontroller/Services/AccountEnforcementService.cs b/radiuscontroller/Services/AccountEnforcementService.cs index de20b74..50a33af 100644 --- a/radiuscontroller/Services/AccountEnforcementService.cs +++ b/radiuscontroller/Services/AccountEnforcementService.cs @@ -107,7 +107,7 @@ public class AccountEnforcementService : BackgroundService // 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 }) + .Select(ra => new { ra.NasIpAddress, ra.CallingStationId, ra.AcctSessionId, ra.CalledStationId }) .ToListAsync(ct); var radiusSecret = Environment.GetEnvironmentVariable("RADIUS_SECRET") ?? "radpass"; @@ -118,7 +118,9 @@ public class AccountEnforcementService : BackgroundService try { - var payload = $"User-Name=\\\"{profile.Username}\\\"\\nCalling-Station-Id=\\\"{session.CallingStationId}\\\"\\nAcct-Session-Id=\\\"{session.AcctSessionId}\\\"\\nNAS-IP-Address=\\\"{session.NasIpAddress}\\\"\\nMessage-Authenticator=0x00\\n"; + 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 diff --git a/radiuscontroller/Services/RadiusService.cs b/radiuscontroller/Services/RadiusService.cs index 4b2cc04..5c3822f 100644 --- a/radiuscontroller/Services/RadiusService.cs +++ b/radiuscontroller/Services/RadiusService.cs @@ -379,7 +379,7 @@ public class RadiusService : IRadiusService { var activeSessions = await _db.RadAcct .Where(ra => ra.Username == username && ra.AcctStopTime == null) - .Select(ra => new { ra.NasIpAddress, ra.CallingStationId, ra.AcctSessionId }) + .Select(ra => new { ra.NasIpAddress, ra.CallingStationId, ra.AcctSessionId, ra.CalledStationId }) .ToListAsync(); var radiusSecret = Environment.GetEnvironmentVariable("RADIUS_SECRET") ?? "radpass"; @@ -390,7 +390,9 @@ public class RadiusService : IRadiusService try { - var payload = $"User-Name=\\\"{username}\\\"\\nCalling-Station-Id=\\\"{session.CallingStationId}\\\"\\nAcct-Session-Id=\\\"{session.AcctSessionId}\\\"\\nNAS-IP-Address=\\\"{session.NasIpAddress}\\\"\\nMessage-Authenticator=0x00\\n"; + var timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); + var nasIdentifier = string.IsNullOrWhiteSpace(session.CalledStationId) ? "" : session.CalledStationId.Split(':')[0].Replace("-", "").ToLowerInvariant(); + var payload = $"User-Name=\\\"{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 -- 2.52.0 From 6371826f5e551cf518ddd83dd7ae51e367241744 Mon Sep 17 00:00:00 2001 From: Tygozwolle Date: Fri, 24 Jul 2026 20:13:16 +0200 Subject: [PATCH 17/20] auto update --- .../Components/Pages/Admin/Dashboard.razor | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/radiuscontroller/Components/Pages/Admin/Dashboard.razor b/radiuscontroller/Components/Pages/Admin/Dashboard.razor index c1df37e..228754d 100644 --- a/radiuscontroller/Components/Pages/Admin/Dashboard.razor +++ b/radiuscontroller/Components/Pages/Admin/Dashboard.razor @@ -3,6 +3,7 @@ @inject IRadiusService RadiusService @inject AdminAuthService AuthService @inject NavigationManager NavManager +@implements IDisposable
@if (!AuthService.IsAuthenticated) @@ -406,6 +407,7 @@ private string activeTab = "users"; private string searchQuery = string.Empty; private string? notificationMessage; + private System.Threading.Timer? autoRefreshTimer; private SystemConfigDto? config; private List users = new(); @@ -434,9 +436,26 @@ await LoadDataAsync(); StateHasChanged(); + + autoRefreshTimer = new System.Threading.Timer(async _ => + { + await InvokeAsync(async () => + { + if (AuthService.IsAuthenticated) + { + await LoadDataAsync(); + StateHasChanged(); + } + }); + }, null, TimeSpan.FromSeconds(10), TimeSpan.FromSeconds(10)); } } + public void Dispose() + { + autoRefreshTimer?.Dispose(); + } + private async Task LogoutAdmin() { await AuthService.LogoutAsync(); -- 2.52.0 From d88e03ea099e5f4e152129c8c2c47a71fa2c3e02 Mon Sep 17 00:00:00 2001 From: Tygozwolle Date: Fri, 24 Jul 2026 20:18:41 +0200 Subject: [PATCH 18/20] feat: implement RadiusService for guest user management and authentication configurations --- radiuscontroller.sln.DotSettings.user | 2 ++ radiuscontroller/Services/RadiusService.cs | 10 +++++----- 2 files changed, 7 insertions(+), 5 deletions(-) create mode 100644 radiuscontroller.sln.DotSettings.user diff --git a/radiuscontroller.sln.DotSettings.user b/radiuscontroller.sln.DotSettings.user new file mode 100644 index 0000000..1475425 --- /dev/null +++ b/radiuscontroller.sln.DotSettings.user @@ -0,0 +1,2 @@ + + ForceIncluded \ No newline at end of file diff --git a/radiuscontroller/Services/RadiusService.cs b/radiuscontroller/Services/RadiusService.cs index 5c3822f..ac1bca5 100644 --- a/radiuscontroller/Services/RadiusService.cs +++ b/radiuscontroller/Services/RadiusService.cs @@ -164,13 +164,13 @@ public class RadiusService : IRadiusService public async Task> GetAllUsersWithUsageAsync() { - var profiles = await _db.GuestProfiles.OrderByDescending(p => p.CreatedAt).ToListAsync(); - var passwords = await _db.RadCheck + var profiles = await _db.GuestProfiles.AsNoTracking().OrderByDescending(p => p.CreatedAt).ToListAsync(); + var passwords = await _db.RadCheck.AsNoTracking() .Where(rc => rc.Attribute == "Cleartext-Password") .ToDictionaryAsync(rc => rc.Username, rc => rc.Value); // Group accounting data by username (for session time and connection status) - var acctStats = await _db.RadAcct + var acctStats = await _db.RadAcct.AsNoTracking() .GroupBy(ra => ra.Username) .Select(g => new { @@ -181,7 +181,7 @@ public class RadiusService : IRadiusService .ToDictionaryAsync(g => g.Username); // Fetch data usage from the new dedicated table - var dataUsage = await _db.GuestDataUsage.ToDictionaryAsync(du => du.Username); + var dataUsage = await _db.GuestDataUsage.AsNoTracking().ToDictionaryAsync(du => du.Username); var result = new List(); @@ -213,7 +213,7 @@ public class RadiusService : IRadiusService public async Task> GetActiveSessionsAsync() { - return await _db.RadAcct + return await _db.RadAcct.AsNoTracking() .Where(ra => ra.AcctStopTime == null) .OrderByDescending(ra => ra.AcctStartTime) .ToListAsync(); -- 2.52.0 From 87d6d7e764b372d66cd5de6e2491b47492885f74 Mon Sep 17 00:00:00 2001 From: Tygozwolle Date: Fri, 24 Jul 2026 20:29:11 +0200 Subject: [PATCH 19/20] feat: implement automated account enforcement service with RADIUS disconnect capabilities and add RADIUS management service --- .../Services/AccountEnforcementService.cs | 25 +++++++++---------- radiuscontroller/Services/RadiusService.cs | 23 +++++++++-------- 2 files changed, 24 insertions(+), 24 deletions(-) diff --git a/radiuscontroller/Services/AccountEnforcementService.cs b/radiuscontroller/Services/AccountEnforcementService.cs index 50a33af..c6ea89f 100644 --- a/radiuscontroller/Services/AccountEnforcementService.cs +++ b/radiuscontroller/Services/AccountEnforcementService.cs @@ -87,20 +87,19 @@ public class AccountEnforcementService : BackgroundService // 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 + // 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) { - Username = profile.Username, - Attribute = "Auth-Type", - Op = ":=", - Value = "Reject" - }); + 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); diff --git a/radiuscontroller/Services/RadiusService.cs b/radiuscontroller/Services/RadiusService.cs index ac1bca5..cf0be54 100644 --- a/radiuscontroller/Services/RadiusService.cs +++ b/radiuscontroller/Services/RadiusService.cs @@ -272,17 +272,18 @@ public class RadiusService : IRadiusService profile.Status = "Revoked"; - // Remove Cleartext-Password and insert Auth-Type := Reject - var checks = await _db.RadCheck.Where(rc => rc.Username == profile.Username).ToListAsync(); - _db.RadCheck.RemoveRange(checks); - - _db.RadCheck.Add(new RadCheck + // Scramble the password instead of Auth-Type := Reject so devices prompt for new credentials + var check = await _db.RadCheck.FirstOrDefaultAsync(rc => rc.Username == profile.Username && rc.Attribute == "Cleartext-Password"); + if (check != null) { - Username = profile.Username, - Attribute = "Auth-Type", - Op = ":=", - Value = "Reject" - }); + check.Value = "REJECT_" + Guid.NewGuid().ToString("N").Substring(0, 8); + } + + var rejectCheck = await _db.RadCheck.FirstOrDefaultAsync(rc => rc.Username == profile.Username && rc.Attribute == "Auth-Type"); + if (rejectCheck != null) + { + _db.RadCheck.Remove(rejectCheck); + } await _db.SaveChangesAsync(); @@ -331,7 +332,7 @@ public class RadiusService : IRadiusService }); } - if (profile.Status == "Revoked") + if (profile.Status == "Revoked" || profile.Status == "Expired") { profile.Status = "Active"; var rejectCheck = await _db.RadCheck.FirstOrDefaultAsync(rc => rc.Username == profile.Username && rc.Attribute == "Auth-Type"); -- 2.52.0 From 3b20b7481e4b7798925f4856697c1d346b6fcd14 Mon Sep 17 00:00:00 2001 From: Tygozwolle Date: Fri, 24 Jul 2026 20:33:08 +0200 Subject: [PATCH 20/20] feat: implement RadiusService for guest registration, session management, and usage tracking with AccountEnforcementService support. --- .../Services/AccountEnforcementService.cs | 23 ++++++++++--------- radiuscontroller/Services/RadiusService.cs | 21 ++++++++--------- 2 files changed, 22 insertions(+), 22 deletions(-) diff --git a/radiuscontroller/Services/AccountEnforcementService.cs b/radiuscontroller/Services/AccountEnforcementService.cs index c6ea89f..50a33af 100644 --- a/radiuscontroller/Services/AccountEnforcementService.cs +++ b/radiuscontroller/Services/AccountEnforcementService.cs @@ -87,19 +87,20 @@ public class AccountEnforcementService : BackgroundService // 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); - } + // 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); - // 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) + // Insert Auth-Type := Reject to explicitly deny + db.RadCheck.Add(new RadCheck { - db.RadCheck.Remove(rejectCheck); - } + Username = profile.Username, + Attribute = "Auth-Type", + Op = ":=", + Value = "Reject" + }); _logger.LogInformation("Account '{Username}' disabled. Reason: {Reason}", profile.Username, reason); diff --git a/radiuscontroller/Services/RadiusService.cs b/radiuscontroller/Services/RadiusService.cs index cf0be54..ac1bca5 100644 --- a/radiuscontroller/Services/RadiusService.cs +++ b/radiuscontroller/Services/RadiusService.cs @@ -272,18 +272,17 @@ public class RadiusService : IRadiusService profile.Status = "Revoked"; - // Scramble the password instead of Auth-Type := Reject so devices prompt for new credentials - var check = await _db.RadCheck.FirstOrDefaultAsync(rc => rc.Username == profile.Username && rc.Attribute == "Cleartext-Password"); - if (check != null) - { - check.Value = "REJECT_" + Guid.NewGuid().ToString("N").Substring(0, 8); - } + // Remove Cleartext-Password and insert Auth-Type := Reject + var checks = await _db.RadCheck.Where(rc => rc.Username == profile.Username).ToListAsync(); + _db.RadCheck.RemoveRange(checks); - var rejectCheck = await _db.RadCheck.FirstOrDefaultAsync(rc => rc.Username == profile.Username && rc.Attribute == "Auth-Type"); - if (rejectCheck != null) + _db.RadCheck.Add(new RadCheck { - _db.RadCheck.Remove(rejectCheck); - } + Username = profile.Username, + Attribute = "Auth-Type", + Op = ":=", + Value = "Reject" + }); await _db.SaveChangesAsync(); @@ -332,7 +331,7 @@ public class RadiusService : IRadiusService }); } - if (profile.Status == "Revoked" || profile.Status == "Expired") + if (profile.Status == "Revoked") { profile.Status = "Active"; var rejectCheck = await _db.RadCheck.FirstOrDefaultAsync(rc => rc.Username == profile.Username && rc.Attribute == "Auth-Type"); -- 2.52.0