diff --git a/docker-compose.yml b/docker-compose.yml index 5a6fd08..831d2f5 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -34,5 +34,7 @@ services: - RADIUS_SECRET=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 volumes: - /mnt/user/appdata/radiuscontroller/certs:/etc/freeradius/3.0/certs 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/Components/Pages/Admin/Dashboard.razor b/radiuscontroller/Components/Pages/Admin/Dashboard.razor index 665db23..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) @@ -172,7 +173,7 @@ @user.SessionTimeMinutes mins
- RADIUS Session-Timeout + Expires: @user.CreatedAt.AddMinutes(user.SessionTimeMinutes).ToLocalTime().ToString("MMM dd, HH:mm")
@@ -232,7 +233,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 +245,6 @@ class="form-control-glass" @bind="editConfigDataLimit" min="1" - max="1048576" required /> Translates to RADIUS attribute Max-Octets (in bytes) for FreeRADIUS reply. @@ -408,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(); @@ -436,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(); 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 206fcb6..2a83d46 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")] @@ -164,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/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..50a33af --- /dev/null +++ b/radiuscontroller/Services/AccountEnforcementService.cs @@ -0,0 +1,170 @@ +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); + } + } + } +} diff --git a/radiuscontroller/Services/RadiusService.cs b/radiuscontroller/Services/RadiusService.cs index 442a0fb..ac1bca5 100644 --- a/radiuscontroller/Services/RadiusService.cs +++ b/radiuscontroller/Services/RadiusService.cs @@ -106,7 +106,7 @@ public class RadiusService : IRadiusService Value = password }); - // Set Session-Timeout (in seconds) + // 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 { @@ -126,6 +126,17 @@ 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" + }); + + + // Create Guest Profile record var profile = new GuestProfile { @@ -153,30 +164,32 @@ 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 - var acctStats = await _db.RadAcct + // Group accounting data by username (for session time and connection status) + var acctStats = await _db.RadAcct.AsNoTracking() .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.AsNoTracking().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 { @@ -188,8 +201,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 }); @@ -200,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(); @@ -214,7 +227,7 @@ public class RadiusService : IRadiusService profile.SessionTimeMinutes = newSessionTimeMinutes; profile.DataLimitMb = newDataLimitMb; - // Update RadReply Session-Timeout + // 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) { @@ -272,6 +285,9 @@ public class RadiusService : IRadiusService }); await _db.SaveChangesAsync(); + + // Disconnect active sessions immediately + await DisconnectActiveSessionsAsync(profile.Username); } public async Task DeleteUserAsync(int profileId) @@ -289,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) @@ -355,4 +374,55 @@ 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 => new { ra.NasIpAddress, ra.CallingStationId, ra.AcctSessionId, ra.CalledStationId }) + .ToListAsync(); + + 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=\\\"{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(); + string error = await process.StandardError.ReadToEndAsync(); + await process.WaitForExitAsync(); + + 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, "Exception sending manual RADIUS Disconnect-Request to NAS {NasIp}", session.NasIpAddress); + } + } + } } 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 diff --git a/sql/init.sql b/sql/init.sql index 077db02..a18fbcd 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, @@ -60,19 +60,25 @@ 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 '', - 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(15) 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); 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, @@ -125,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();