21 Commits

Author SHA1 Message Date
tygozwolle f8951feab9 Merge pull request 'Count9' (#7) from count9 into master
Build and Push Docker Image to Gitea Container Registry / build-and-push (push) Successful in 1m52s
Reviewed-on: #7
2026-08-03 12:17:13 +02:00
Tygozwolle 3b20b7481e feat: implement RadiusService for guest registration, session management, and usage tracking with AccountEnforcementService support.
Build and Push Docker Image to Gitea Container Registry / build-and-push (push) Successful in 2m21s
Build and Push Docker Image to Gitea Container Registry / build-and-push (pull_request) Successful in 1m38s
2026-07-24 20:33:08 +02:00
Tygozwolle 87d6d7e764 feat: implement automated account enforcement service with RADIUS disconnect capabilities and add RADIUS management service
Build and Push Docker Image to Gitea Container Registry / build-and-push (push) Successful in 1m15s
2026-07-24 20:29:11 +02:00
Tygozwolle d88e03ea09 feat: implement RadiusService for guest user management and authentication configurations
Build and Push Docker Image to Gitea Container Registry / build-and-push (push) Successful in 1m10s
2026-07-24 20:18:41 +02:00
Tygozwolle 6371826f5e auto update
Build and Push Docker Image to Gitea Container Registry / build-and-push (push) Successful in 1m8s
2026-07-24 20:13:16 +02:00
Tygozwolle be26d3f461 feat: add account enforcement background service and radius management service for automated session control and user configuration
Build and Push Docker Image to Gitea Container Registry / build-and-push (push) Successful in 1m3s
2026-07-24 20:07:16 +02:00
Tygozwolle f5d2144f79 feat: add account enforcement background service and update docker-compose for network containerization
Build and Push Docker Image to Gitea Container Registry / build-and-push (push) Successful in 1m10s
2026-07-24 19:55:59 +02:00
Tygozwolle 74326870f7 feat: implement automated account enforcement service for session time and data usage limits with RADIUS disconnect support
Build and Push Docker Image to Gitea Container Registry / build-and-push (push) Successful in 1m8s
2026-07-24 19:45:26 +02:00
Tygozwolle 4b8e4639e5 feat: implement RadiusService for guest management and AccountEnforcementService for session monitoring
Build and Push Docker Image to Gitea Container Registry / build-and-push (push) Successful in 1m10s
2026-07-24 19:04:02 +02:00
Tygozwolle 926d2a1fc5 feat: implement background account enforcement service and RADIUS management service for guest access control
Build and Push Docker Image to Gitea Container Registry / build-and-push (push) Successful in 1m3s
2026-07-24 18:47:45 +02:00
Tygozwolle 63082d0791 feat: initialize FreeRADIUS default site configuration with standard auth, acct, and processing modules
Build and Push Docker Image to Gitea Container Registry / build-and-push (push) Successful in 1m11s
2026-07-24 18:37:33 +02:00
Tygozwolle b730dced16 feat: implement RadiusService to manage guest user registration, session limits, and RADIUS authentication attributes
Build and Push Docker Image to Gitea Container Registry / build-and-push (push) Successful in 1m4s
2026-07-24 18:20:50 +02:00
Tygozwolle 0e4189985e feat: implement administrative dashboard page for monitoring and managing guest RADIUS sessions
Build and Push Docker Image to Gitea Container Registry / build-and-push (push) Successful in 1m6s
2026-07-24 18:15:31 +02:00
Tygozwolle 6e065cf47c feat: add admin dashboard page for user management and RADIUS monitoring
Build and Push Docker Image to Gitea Container Registry / build-and-push (push) Successful in 1m5s
2026-07-24 18:02:35 +02:00
Tygozwolle 63b9a97cdf feat: initialize database schema and implement core Radius entities, EF context, and account enforcement service.
Build and Push Docker Image to Gitea Container Registry / build-and-push (push) Successful in 1m12s
2026-07-24 17:51:04 +02:00
Tygozwolle 9c2cbfa7b9 feat: implement RadiusService for guest registration, session management, and RADIUS attribute configuration
Build and Push Docker Image to Gitea Container Registry / build-and-push (push) Successful in 1m10s
2026-07-24 17:36:23 +02:00
Tygozwolle d1b93191e1 feat: implement RadiusService for guest user management, session monitoring, and configuration handling
Build and Push Docker Image to Gitea Container Registry / build-and-push (push) Successful in 1m12s
2026-07-24 17:13:44 +02:00
Tygozwolle d7d493367f working
Build and Push Docker Image to Gitea Container Registry / build-and-push (push) Successful in 1m29s
2026-07-24 17:11:23 +02:00
Tygozwolle 6d95c12304 feat: add PostgreSQL initialization script for FreeRADIUS schema and application management tables
Build and Push Docker Image to Gitea Container Registry / build-and-push (push) Successful in 1m13s
2026-07-24 17:02:59 +02:00
Tygozwolle aebf757f26 feat: define RADIUS database entities and initialize PostgreSQL schema with FreeRADIUS support
Build and Push Docker Image to Gitea Container Registry / build-and-push (push) Successful in 1m11s
2026-07-24 16:44:44 +02:00
Tygozwolle c85b598b82 feat: initialize web host and implement background AccountEnforcementService for RADIUS account monitoring
Build and Push Docker Image to Gitea Container Registry / build-and-push (push) Successful in 1m13s
2026-07-24 15:50:28 +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();