Count9 #7

Merged
tygozwolle merged 20 commits from count9 into master 2026-08-03 12:17:13 +02:00
10 changed files with 384 additions and 26 deletions
+2
View File
@@ -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
+2
View File
@@ -0,0 +1,2 @@
<wpf:ResourceDictionary xml:space="preserve" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:s="clr-namespace:System;assembly=mscorlib" xmlns:ss="urn:shemas-jetbrains-com:settings-storage-xaml" xmlns:wpf="http://schemas.microsoft.com/winfx/2006/xaml/presentation">
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AComponentBase_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003FAppData_003FRoaming_003FJetBrains_003FRider2026_002E1_003Fresharper_002Dhost_003FSourcesCache_003F2b3cf01714fd98ff7f12316ce766442f76ff6a46a8ffb780ec8550953d47b94c_003FComponentBase_002Ecs/@EntryIndexedValue">ForceIncluded</s:String></wpf:ResourceDictionary>
@@ -3,6 +3,7 @@
@inject IRadiusService RadiusService
@inject AdminAuthService AuthService
@inject NavigationManager NavManager
@implements IDisposable
<div style="max-width: 1280px; margin: 0 auto;">
@if (!AuthService.IsAuthenticated)
@@ -172,7 +173,7 @@
<td>
<strong>@user.SessionTimeMinutes mins</strong>
<div style="font-size: 0.75rem; color: var(--text-dim);">
RADIUS Session-Timeout
Expires: @user.CreatedAt.AddMinutes(user.SessionTimeMinutes).ToLocalTime().ToString("MMM dd, HH:mm")
</div>
</td>
<td style="min-width: 180px;">
@@ -232,7 +233,6 @@
class="form-control-glass"
@bind="editConfigSessionTime"
min="1"
max="10080"
required />
<span style="font-size: 0.775rem; color: var(--text-dim);">
Translates to RADIUS attribute <code style="color: var(--accent-teal);">Session-Timeout</code> (in seconds) for FreeRADIUS reply.
@@ -245,7 +245,6 @@
class="form-control-glass"
@bind="editConfigDataLimit"
min="1"
max="1048576"
required />
<span style="font-size: 0.775rem; color: var(--text-dim);">
Translates to RADIUS attribute <code style="color: var(--accent-teal);">Max-Octets</code> (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<UserUsageDto> 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();
+2
View File
@@ -14,6 +14,7 @@ public class RadiusDbContext : DbContext
public DbSet<RadReply> RadReply { get; set; } = null!;
public DbSet<RadAcct> RadAcct { get; set; } = null!;
public DbSet<GuestProfile> GuestProfiles { get; set; } = null!;
public DbSet<GuestDataUsage> GuestDataUsage { get; set; } = null!;
public DbSet<AdminUser> AdminUsers { get; set; } = null!;
public DbSet<SystemSetting> SystemSettings { get; set; } = null!;
@@ -25,6 +26,7 @@ public class RadiusDbContext : DbContext
modelBuilder.Entity<RadReply>().ToTable("radreply");
modelBuilder.Entity<RadAcct>().ToTable("radacct");
modelBuilder.Entity<GuestProfile>().ToTable("guest_profiles");
modelBuilder.Entity<GuestDataUsage>().ToTable("guest_data_usage");
modelBuilder.Entity<AdminUser>().ToTable("admin_users");
modelBuilder.Entity<SystemSetting>().ToTable("system_settings");
}
+33 -4
View File
@@ -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
{
+1
View File
@@ -15,6 +15,7 @@ builder.Services.AddDbContext<RadiusDbContext>(options =>
// Add services
builder.Services.AddScoped<IRadiusService, RadiusService>();
builder.Services.AddScoped<AdminAuthService>();
builder.Services.AddHostedService<AccountEnforcementService>();
// Add Razor components
builder.Services.AddRazorComponents()
@@ -0,0 +1,170 @@
using Microsoft.EntityFrameworkCore;
using radiuscontroller.Data;
using radiuscontroller.Models;
namespace radiuscontroller.Services;
/// <summary>
/// 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).
/// </summary>
public class AccountEnforcementService : BackgroundService
{
private readonly IServiceScopeFactory _scopeFactory;
private readonly ILogger<AccountEnforcementService> _logger;
private static readonly TimeSpan CheckInterval = TimeSpan.FromSeconds(30);
public AccountEnforcementService(IServiceScopeFactory scopeFactory, ILogger<AccountEnforcementService> 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<RadiusDbContext>();
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);
}
}
}
}
+81 -11
View File
@@ -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<List<UserUsageDto>> 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<UserUsageDto>();
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<List<RadAcct>> 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);
}
}
}
}
@@ -56,6 +56,10 @@ pre-proxy {
post-proxy {
eap
}
preacct {
preprocess
acct_unique
}
accounting {
detail
+69 -8
View File
@@ -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();