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
Build and Push Docker Image to Gitea Container Registry / build-and-push (push) Successful in 1m52s
Reviewed-on: #7
This commit was merged in pull request #7.
This commit is contained in:
@@ -34,5 +34,7 @@ services:
|
|||||||
- RADIUS_SECRET=radpass
|
- RADIUS_SECRET=radpass
|
||||||
- ConnectionStrings__DefaultConnection=Host=postgres;Port=5432;Database=radius;Username=radius;Password=radpass;
|
- ConnectionStrings__DefaultConnection=Host=postgres;Port=5432;Database=radius;Username=radius;Password=radpass;
|
||||||
- ASPNETCORE_ENVIRONMENT=Production
|
- 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:
|
volumes:
|
||||||
- /mnt/user/appdata/radiuscontroller/certs:/etc/freeradius/3.0/certs
|
- /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 IRadiusService RadiusService
|
||||||
@inject AdminAuthService AuthService
|
@inject AdminAuthService AuthService
|
||||||
@inject NavigationManager NavManager
|
@inject NavigationManager NavManager
|
||||||
|
@implements IDisposable
|
||||||
|
|
||||||
<div style="max-width: 1280px; margin: 0 auto;">
|
<div style="max-width: 1280px; margin: 0 auto;">
|
||||||
@if (!AuthService.IsAuthenticated)
|
@if (!AuthService.IsAuthenticated)
|
||||||
@@ -172,7 +173,7 @@
|
|||||||
<td>
|
<td>
|
||||||
<strong>@user.SessionTimeMinutes mins</strong>
|
<strong>@user.SessionTimeMinutes mins</strong>
|
||||||
<div style="font-size: 0.75rem; color: var(--text-dim);">
|
<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>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td style="min-width: 180px;">
|
<td style="min-width: 180px;">
|
||||||
@@ -232,7 +233,6 @@
|
|||||||
class="form-control-glass"
|
class="form-control-glass"
|
||||||
@bind="editConfigSessionTime"
|
@bind="editConfigSessionTime"
|
||||||
min="1"
|
min="1"
|
||||||
max="10080"
|
|
||||||
required />
|
required />
|
||||||
<span style="font-size: 0.775rem; color: var(--text-dim);">
|
<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.
|
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"
|
class="form-control-glass"
|
||||||
@bind="editConfigDataLimit"
|
@bind="editConfigDataLimit"
|
||||||
min="1"
|
min="1"
|
||||||
max="1048576"
|
|
||||||
required />
|
required />
|
||||||
<span style="font-size: 0.775rem; color: var(--text-dim);">
|
<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.
|
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 activeTab = "users";
|
||||||
private string searchQuery = string.Empty;
|
private string searchQuery = string.Empty;
|
||||||
private string? notificationMessage;
|
private string? notificationMessage;
|
||||||
|
private System.Threading.Timer? autoRefreshTimer;
|
||||||
|
|
||||||
private SystemConfigDto? config;
|
private SystemConfigDto? config;
|
||||||
private List<UserUsageDto> users = new();
|
private List<UserUsageDto> users = new();
|
||||||
@@ -436,9 +436,26 @@
|
|||||||
|
|
||||||
await LoadDataAsync();
|
await LoadDataAsync();
|
||||||
StateHasChanged();
|
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()
|
private async Task LogoutAdmin()
|
||||||
{
|
{
|
||||||
await AuthService.LogoutAsync();
|
await AuthService.LogoutAsync();
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ public class RadiusDbContext : DbContext
|
|||||||
public DbSet<RadReply> RadReply { get; set; } = null!;
|
public DbSet<RadReply> RadReply { get; set; } = null!;
|
||||||
public DbSet<RadAcct> RadAcct { get; set; } = null!;
|
public DbSet<RadAcct> RadAcct { get; set; } = null!;
|
||||||
public DbSet<GuestProfile> GuestProfiles { 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<AdminUser> AdminUsers { get; set; } = null!;
|
||||||
public DbSet<SystemSetting> SystemSettings { 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<RadReply>().ToTable("radreply");
|
||||||
modelBuilder.Entity<RadAcct>().ToTable("radacct");
|
modelBuilder.Entity<RadAcct>().ToTable("radacct");
|
||||||
modelBuilder.Entity<GuestProfile>().ToTable("guest_profiles");
|
modelBuilder.Entity<GuestProfile>().ToTable("guest_profiles");
|
||||||
|
modelBuilder.Entity<GuestDataUsage>().ToTable("guest_data_usage");
|
||||||
modelBuilder.Entity<AdminUser>().ToTable("admin_users");
|
modelBuilder.Entity<AdminUser>().ToTable("admin_users");
|
||||||
modelBuilder.Entity<SystemSetting>().ToTable("system_settings");
|
modelBuilder.Entity<SystemSetting>().ToTable("system_settings");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -105,11 +105,11 @@ public class RadAcct
|
|||||||
[Column("acctauthentic")]
|
[Column("acctauthentic")]
|
||||||
public string? AcctAuthentic { get; set; }
|
public string? AcctAuthentic { get; set; }
|
||||||
|
|
||||||
[Column("connectinfo_in")]
|
[Column("connectinfo_start")]
|
||||||
public string? ConnectInfoIn { get; set; }
|
public string? ConnectInfoStart { get; set; }
|
||||||
|
|
||||||
[Column("connectinfo_out")]
|
[Column("connectinfo_stop")]
|
||||||
public string? ConnectInfoOut { get; set; }
|
public string? ConnectInfoStop { get; set; }
|
||||||
|
|
||||||
[Column("acctinputoctets")]
|
[Column("acctinputoctets")]
|
||||||
public long? AcctInputOctets { get; set; }
|
public long? AcctInputOctets { get; set; }
|
||||||
@@ -134,6 +134,21 @@ public class RadAcct
|
|||||||
|
|
||||||
[Column("framedipaddress")]
|
[Column("framedipaddress")]
|
||||||
public string? FramedIpAddress { get; set; }
|
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")]
|
[Table("guest_profiles")]
|
||||||
@@ -164,6 +179,20 @@ public class GuestProfile
|
|||||||
public string Status { get; set; } = "Active";
|
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")]
|
[Table("admin_users")]
|
||||||
public class AdminUser
|
public class AdminUser
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ builder.Services.AddDbContext<RadiusDbContext>(options =>
|
|||||||
// Add services
|
// Add services
|
||||||
builder.Services.AddScoped<IRadiusService, RadiusService>();
|
builder.Services.AddScoped<IRadiusService, RadiusService>();
|
||||||
builder.Services.AddScoped<AdminAuthService>();
|
builder.Services.AddScoped<AdminAuthService>();
|
||||||
|
builder.Services.AddHostedService<AccountEnforcementService>();
|
||||||
|
|
||||||
// Add Razor components
|
// Add Razor components
|
||||||
builder.Services.AddRazorComponents()
|
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
|
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;
|
long sessionTimeoutSeconds = (long)config.DefaultSessionTimeMinutes * 60;
|
||||||
_db.RadReply.Add(new RadReply
|
_db.RadReply.Add(new RadReply
|
||||||
{
|
{
|
||||||
@@ -126,6 +126,17 @@ public class RadiusService : IRadiusService
|
|||||||
Value = maxOctetsBytes.ToString()
|
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
|
// Create Guest Profile record
|
||||||
var profile = new GuestProfile
|
var profile = new GuestProfile
|
||||||
{
|
{
|
||||||
@@ -153,30 +164,32 @@ public class RadiusService : IRadiusService
|
|||||||
|
|
||||||
public async Task<List<UserUsageDto>> GetAllUsersWithUsageAsync()
|
public async Task<List<UserUsageDto>> GetAllUsersWithUsageAsync()
|
||||||
{
|
{
|
||||||
var profiles = await _db.GuestProfiles.OrderByDescending(p => p.CreatedAt).ToListAsync();
|
var profiles = await _db.GuestProfiles.AsNoTracking().OrderByDescending(p => p.CreatedAt).ToListAsync();
|
||||||
var passwords = await _db.RadCheck
|
var passwords = await _db.RadCheck.AsNoTracking()
|
||||||
.Where(rc => rc.Attribute == "Cleartext-Password")
|
.Where(rc => rc.Attribute == "Cleartext-Password")
|
||||||
.ToDictionaryAsync(rc => rc.Username, rc => rc.Value);
|
.ToDictionaryAsync(rc => rc.Username, rc => rc.Value);
|
||||||
|
|
||||||
// Group accounting data by username
|
// Group accounting data by username (for session time and connection status)
|
||||||
var acctStats = await _db.RadAcct
|
var acctStats = await _db.RadAcct.AsNoTracking()
|
||||||
.GroupBy(ra => ra.Username)
|
.GroupBy(ra => ra.Username)
|
||||||
.Select(g => new
|
.Select(g => new
|
||||||
{
|
{
|
||||||
Username = g.Key,
|
Username = g.Key,
|
||||||
TotalInput = g.Sum(x => x.AcctInputOctets ?? 0),
|
|
||||||
TotalOutput = g.Sum(x => x.AcctOutputOctets ?? 0),
|
|
||||||
TotalSessionTime = g.Sum(x => x.AcctSessionTime ?? 0),
|
TotalSessionTime = g.Sum(x => x.AcctSessionTime ?? 0),
|
||||||
IsConnected = g.Any(x => x.AcctStopTime == null)
|
IsConnected = g.Any(x => x.AcctStopTime == null)
|
||||||
})
|
})
|
||||||
.ToDictionaryAsync(g => g.Username);
|
.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>();
|
var result = new List<UserUsageDto>();
|
||||||
|
|
||||||
foreach (var p in profiles)
|
foreach (var p in profiles)
|
||||||
{
|
{
|
||||||
passwords.TryGetValue(p.Username, out var pwd);
|
passwords.TryGetValue(p.Username, out var pwd);
|
||||||
acctStats.TryGetValue(p.Username, out var acct);
|
acctStats.TryGetValue(p.Username, out var acct);
|
||||||
|
dataUsage.TryGetValue(p.Username, out var usage);
|
||||||
|
|
||||||
result.Add(new UserUsageDto
|
result.Add(new UserUsageDto
|
||||||
{
|
{
|
||||||
@@ -188,8 +201,8 @@ public class RadiusService : IRadiusService
|
|||||||
SessionTimeMinutes = p.SessionTimeMinutes,
|
SessionTimeMinutes = p.SessionTimeMinutes,
|
||||||
DataLimitMb = p.DataLimitMb,
|
DataLimitMb = p.DataLimitMb,
|
||||||
Status = p.Status,
|
Status = p.Status,
|
||||||
TotalInputOctets = acct?.TotalInput ?? 0,
|
TotalInputOctets = usage?.TotalInputOctets ?? 0,
|
||||||
TotalOutputOctets = acct?.TotalOutput ?? 0,
|
TotalOutputOctets = usage?.TotalOutputOctets ?? 0,
|
||||||
TotalSessionTimeSeconds = acct?.TotalSessionTime ?? 0,
|
TotalSessionTimeSeconds = acct?.TotalSessionTime ?? 0,
|
||||||
IsCurrentlyConnected = acct?.IsConnected ?? false
|
IsCurrentlyConnected = acct?.IsConnected ?? false
|
||||||
});
|
});
|
||||||
@@ -200,7 +213,7 @@ public class RadiusService : IRadiusService
|
|||||||
|
|
||||||
public async Task<List<RadAcct>> GetActiveSessionsAsync()
|
public async Task<List<RadAcct>> GetActiveSessionsAsync()
|
||||||
{
|
{
|
||||||
return await _db.RadAcct
|
return await _db.RadAcct.AsNoTracking()
|
||||||
.Where(ra => ra.AcctStopTime == null)
|
.Where(ra => ra.AcctStopTime == null)
|
||||||
.OrderByDescending(ra => ra.AcctStartTime)
|
.OrderByDescending(ra => ra.AcctStartTime)
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
@@ -214,7 +227,7 @@ public class RadiusService : IRadiusService
|
|||||||
profile.SessionTimeMinutes = newSessionTimeMinutes;
|
profile.SessionTimeMinutes = newSessionTimeMinutes;
|
||||||
profile.DataLimitMb = newDataLimitMb;
|
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");
|
var sessionReply = await _db.RadReply.FirstOrDefaultAsync(rr => rr.Username == profile.Username && rr.Attribute == "Session-Timeout");
|
||||||
if (sessionReply != null)
|
if (sessionReply != null)
|
||||||
{
|
{
|
||||||
@@ -272,6 +285,9 @@ public class RadiusService : IRadiusService
|
|||||||
});
|
});
|
||||||
|
|
||||||
await _db.SaveChangesAsync();
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
|
// Disconnect active sessions immediately
|
||||||
|
await DisconnectActiveSessionsAsync(profile.Username);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task DeleteUserAsync(int profileId)
|
public async Task DeleteUserAsync(int profileId)
|
||||||
@@ -289,6 +305,9 @@ public class RadiusService : IRadiusService
|
|||||||
_db.GuestProfiles.Remove(profile);
|
_db.GuestProfiles.Remove(profile);
|
||||||
|
|
||||||
await _db.SaveChangesAsync();
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
|
// Disconnect active sessions immediately
|
||||||
|
await DisconnectActiveSessionsAsync(username);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task ResetUserPasswordAsync(int profileId, string newPassword)
|
public async Task ResetUserPasswordAsync(int profileId, string newPassword)
|
||||||
@@ -355,4 +374,55 @@ public class RadiusService : IRadiusService
|
|||||||
}
|
}
|
||||||
return new string(result);
|
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 {
|
post-proxy {
|
||||||
eap
|
eap
|
||||||
}
|
}
|
||||||
|
preacct {
|
||||||
|
preprocess
|
||||||
|
acct_unique
|
||||||
|
}
|
||||||
|
|
||||||
accounting {
|
accounting {
|
||||||
detail
|
detail
|
||||||
|
|||||||
+69
-8
@@ -48,10 +48,10 @@ CREATE INDEX IF NOT EXISTS radusergroup_username ON radusergroup (username);
|
|||||||
CREATE TABLE IF NOT EXISTS radacct (
|
CREATE TABLE IF NOT EXISTS radacct (
|
||||||
radacctid BIGSERIAL PRIMARY KEY,
|
radacctid BIGSERIAL PRIMARY KEY,
|
||||||
acctsessionid VARCHAR(64) NOT NULL DEFAULT '',
|
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 '',
|
username VARCHAR(64) NOT NULL DEFAULT '',
|
||||||
realm VARCHAR(64) DEFAULT '',
|
realm VARCHAR(64) DEFAULT '',
|
||||||
nasipaddress VARCHAR(15) NOT NULL DEFAULT '',
|
nasipaddress VARCHAR(46) NOT NULL DEFAULT '',
|
||||||
nasportid VARCHAR(32) DEFAULT NULL,
|
nasportid VARCHAR(32) DEFAULT NULL,
|
||||||
nasporttype VARCHAR(32) DEFAULT NULL,
|
nasporttype VARCHAR(32) DEFAULT NULL,
|
||||||
acctstarttime TIMESTAMP WITH TIME ZONE DEFAULT NULL,
|
acctstarttime TIMESTAMP WITH TIME ZONE DEFAULT NULL,
|
||||||
@@ -60,19 +60,25 @@ CREATE TABLE IF NOT EXISTS radacct (
|
|||||||
acctinterval INT DEFAULT NULL,
|
acctinterval INT DEFAULT NULL,
|
||||||
acctsessiontime BIGINT DEFAULT NULL,
|
acctsessiontime BIGINT DEFAULT NULL,
|
||||||
acctauthentic VARCHAR(32) DEFAULT NULL,
|
acctauthentic VARCHAR(32) DEFAULT NULL,
|
||||||
connectinfo_in VARCHAR(50) DEFAULT NULL,
|
connectinfo_start VARCHAR(50) DEFAULT NULL,
|
||||||
connectinfo_out VARCHAR(50) DEFAULT NULL,
|
connectinfo_stop VARCHAR(50) DEFAULT NULL,
|
||||||
acctinputoctets BIGINT DEFAULT NULL,
|
acctinputoctets BIGINT DEFAULT NULL,
|
||||||
acctoutputoctets BIGINT DEFAULT NULL,
|
acctoutputoctets BIGINT DEFAULT NULL,
|
||||||
calledstationid VARCHAR(50) NOT NULL DEFAULT '',
|
calledstationid VARCHAR(50) DEFAULT '',
|
||||||
callingstationid VARCHAR(50) NOT NULL DEFAULT '',
|
callingstationid VARCHAR(50) DEFAULT '',
|
||||||
acctterminatecause VARCHAR(32) NOT NULL DEFAULT '',
|
acctterminatecause VARCHAR(32) DEFAULT NULL,
|
||||||
servicetype VARCHAR(32) DEFAULT NULL,
|
servicetype VARCHAR(32) DEFAULT NULL,
|
||||||
framedprotocol 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_username ON radacct (username);
|
||||||
CREATE INDEX IF NOT EXISTS radacct_active ON radacct (acctstoptime) WHERE acctstoptime IS NULL;
|
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 (
|
CREATE TABLE IF NOT EXISTS nas (
|
||||||
id SERIAL PRIMARY KEY,
|
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
|
-- System settings initialized on startup
|
||||||
-- Admin user is created by administrator during first startup via the web interface
|
-- 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