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();
|