Count9 #7
@@ -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
|
||||
|
||||
@@ -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,7 +436,24 @@
|
||||
|
||||
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()
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user