Compare commits
33 Commits
c636794e5e
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
| f8951feab9 | |||
| 3b20b7481e | |||
| 87d6d7e764 | |||
| d88e03ea09 | |||
| 6371826f5e | |||
| be26d3f461 | |||
| f5d2144f79 | |||
| 74326870f7 | |||
| 4b8e4639e5 | |||
| 926d2a1fc5 | |||
| 63082d0791 | |||
| b730dced16 | |||
| 0e4189985e | |||
| 6e065cf47c | |||
| 63b9a97cdf | |||
| 9c2cbfa7b9 | |||
| d1b93191e1 | |||
| d7d493367f | |||
| 6d95c12304 | |||
| aebf757f26 | |||
| c85b598b82 | |||
| 77a4e31626 | |||
| c4ed400ac6 | |||
| 44290fa89c | |||
| 9dc029e32f | |||
| 1054dfb61a | |||
| 9a6b1131b1 | |||
| c7e18c76ac | |||
| 574bcbe69a | |||
| 0bb36ea502 | |||
| f7663a284e | |||
| fd133bf9f3 | |||
| 824f0554e4 |
@@ -7,24 +7,21 @@ This project provides a complete **RADIUS Authentication & Accounting Controller
|
||||
## 🏗️ System Architecture
|
||||
|
||||
```
|
||||
┌─────────────────┐ UDP 1812/1813 ┌──────────────────┐
|
||||
│ UniFi AP / │ ◄───────────────────────► │ FreeRADIUS │
|
||||
│ Dream Machine │ │ (Port 1812/13) │
|
||||
└────────┬────────┘ └────────┬─────────┘
|
||||
│ │
|
||||
│ Wi-Fi 802.1X / │ SQL Queries
|
||||
│ Guest Auth ▼
|
||||
│ ┌──────────────────┐
|
||||
│ │ PostgreSQL │
|
||||
│ │ (Radius DB) │
|
||||
│ └────────▲─────────┘
|
||||
│ │
|
||||
│ HTTP/8080 │ EF Core
|
||||
▼ │
|
||||
┌─────────────────┐ │
|
||||
│ Guests / Admins │ ───────────────────────────────────┘
|
||||
│ (Web Portal) │ RadiusController Web App
|
||||
└─────────────────┘
|
||||
┌─────────────────┐ UDP 1812/1813 (RADIUS)
|
||||
│ UniFi AP / │ ◄────────────────────────────────┐
|
||||
│ Dream Machine │ │
|
||||
└─────────────────┘ │
|
||||
▼
|
||||
┌─────────────────┐ HTTP 8080 (Web UI) ┌────────────────────────────────┐
|
||||
│ Guests / Admins │ ◄────────────────────────► │ Combined Application Container │
|
||||
│ (Browser) │ │ (FreeRADIUS + Web Controller) │
|
||||
└─────────────────┘ └───────────────┬────────────────┘
|
||||
│ PostgreSQL
|
||||
▼
|
||||
┌────────────────┐
|
||||
│ PostgreSQL │
|
||||
│ (Database) │
|
||||
└────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
@@ -51,21 +48,6 @@ services:
|
||||
- /mnt/user/appdata/radiuscontroller/pgdata:/var/lib/postgresql/data
|
||||
- /mnt/user/appdata/radiuscontroller/sql/init.sql:/docker-entrypoint-initdb.d/init.sql:ro
|
||||
|
||||
freeradius:
|
||||
image: freeradius/freeradius-server:latest
|
||||
container_name: radius_freeradius
|
||||
restart: always
|
||||
depends_on:
|
||||
- postgres
|
||||
ports:
|
||||
- "1812:1812/udp"
|
||||
- "1813:1813/udp"
|
||||
environment:
|
||||
- DB_NAME=radius
|
||||
- DB_HOST=postgres
|
||||
- DB_USER=radius
|
||||
- DB_PASS=radpass
|
||||
|
||||
radiuscontroller:
|
||||
image: cablon.vanolst.tech/tygozwolle/radius:latest
|
||||
container_name: radius_web_controller
|
||||
@@ -74,9 +56,19 @@ services:
|
||||
- postgres
|
||||
ports:
|
||||
- "8080:8080"
|
||||
- "1812:1812/udp"
|
||||
- "1813:1813/udp"
|
||||
environment:
|
||||
- DB_HOST=postgres
|
||||
- DB_PORT=5432
|
||||
- DB_USER=radius
|
||||
- DB_PASS=radpass
|
||||
- DB_NAME=radius
|
||||
- RADIUS_SECRET=radpass
|
||||
- ConnectionStrings__DefaultConnection=Host=postgres;Port=5432;Database=radius;Username=radius;Password=radpass;
|
||||
- ASPNETCORE_ENVIRONMENT=Production
|
||||
volumes:
|
||||
- /mnt/user/appdata/radiuscontroller/certs:/etc/freeradius/3.0/certs
|
||||
```
|
||||
|
||||
2. Start the stack:
|
||||
|
||||
+12
-16
@@ -15,21 +15,6 @@ services:
|
||||
- /mnt/user/appdata/radiuscontroller/pgdata:/var/lib/postgresql/data
|
||||
- /mnt/user/appdata/radiuscontroller/sql/init.sql:/docker-entrypoint-initdb.d/init.sql:ro
|
||||
|
||||
freeradius:
|
||||
image: freeradius/freeradius-server:latest
|
||||
container_name: radius_freeradius
|
||||
restart: always
|
||||
depends_on:
|
||||
- postgres
|
||||
ports:
|
||||
- "1812:1812/udp"
|
||||
- "1813:1813/udp"
|
||||
environment:
|
||||
- DB_NAME=radius
|
||||
- DB_HOST=postgres
|
||||
- DB_USER=radius
|
||||
- DB_PASS=radpass
|
||||
|
||||
radiuscontroller:
|
||||
image: cablon.vanolst.tech/tygozwolle/radius:latest
|
||||
container_name: radius_web_controller
|
||||
@@ -38,7 +23,18 @@ services:
|
||||
- postgres
|
||||
ports:
|
||||
- "8080:8080"
|
||||
- "1812:1812/udp"
|
||||
- "1813:1813/udp"
|
||||
environment:
|
||||
- DB_HOST=postgres
|
||||
- DB_PORT=5432
|
||||
- DB_USER=radius
|
||||
- DB_PASS=radpass
|
||||
- DB_NAME=radius
|
||||
- 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");
|
||||
}
|
||||
|
||||
@@ -1,8 +1,22 @@
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS base
|
||||
USER $APP_UID
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS base
|
||||
USER root
|
||||
|
||||
# Install FreeRADIUS, PostgreSQL module, and utility packages
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
freeradius \
|
||||
freeradius-postgresql \
|
||||
freeradius-utils \
|
||||
ca-certificates \
|
||||
openssl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Create symlink so both /etc/raddb and /etc/freeradius/3.0 work
|
||||
RUN ln -sf /etc/freeradius/3.0 /etc/raddb
|
||||
|
||||
WORKDIR /app
|
||||
EXPOSE 8080
|
||||
EXPOSE 8081
|
||||
EXPOSE 1812/udp
|
||||
EXPOSE 1813/udp
|
||||
|
||||
FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build
|
||||
ARG BUILD_CONFIGURATION=Release
|
||||
@@ -19,5 +33,21 @@ RUN dotnet publish "./radiuscontroller.csproj" -c $BUILD_CONFIGURATION -o /app/p
|
||||
|
||||
FROM base AS final
|
||||
WORKDIR /app
|
||||
|
||||
# Copy pre-configured FreeRADIUS PostgreSQL rules & EAP authentication config
|
||||
COPY radiuscontroller/raddb/clients.conf /etc/freeradius/3.0/clients.conf
|
||||
COPY radiuscontroller/raddb/mods-enabled/sql /etc/freeradius/3.0/mods-enabled/sql
|
||||
COPY radiuscontroller/raddb/mods-enabled/eap /etc/freeradius/3.0/mods-enabled/eap
|
||||
COPY radiuscontroller/raddb/sites-enabled/default /etc/freeradius/3.0/sites-enabled/default
|
||||
COPY radiuscontroller/raddb/sites-enabled/inner-tunnel /etc/freeradius/3.0/sites-enabled/inner-tunnel
|
||||
|
||||
# Add custom dictionary for Max-Octets attribute
|
||||
COPY radiuscontroller/raddb/dictionary.custom /etc/freeradius/3.0/dictionary.custom
|
||||
RUN echo '$INCLUDE /etc/freeradius/3.0/dictionary.custom' >> /etc/freeradius/3.0/dictionary
|
||||
|
||||
# Copy published application and entrypoint script
|
||||
COPY --from=publish /app/publish .
|
||||
ENTRYPOINT ["dotnet", "radiuscontroller.dll"]
|
||||
COPY radiuscontroller/entrypoint.sh /app/entrypoint.sh
|
||||
RUN chmod +x /app/entrypoint.sh
|
||||
|
||||
ENTRYPOINT ["/app/entrypoint.sh"]
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
# Default environment variables
|
||||
DB_HOST="${DB_HOST:-postgres}"
|
||||
DB_PORT="${DB_PORT:-5432}"
|
||||
DB_USER="${DB_USER:-radius}"
|
||||
DB_PASS="${DB_PASS:-radpass}"
|
||||
DB_NAME="${DB_NAME:-radius}"
|
||||
RADIUS_SECRET="${RADIUS_SECRET:-radpass}"
|
||||
|
||||
echo "================================================="
|
||||
echo " Starting RADIUS Controller & FreeRADIUS Stack"
|
||||
echo " DB Host: $DB_HOST:$DB_PORT | Database: $DB_NAME"
|
||||
echo " RADIUS Secret: $RADIUS_SECRET (Allows all IP addresses)"
|
||||
echo "================================================="
|
||||
|
||||
# Dynamically update FreeRADIUS SQL configuration with DB settings
|
||||
SQL_CONF="/etc/freeradius/3.0/mods-enabled/sql"
|
||||
if [ -f "$SQL_CONF" ]; then
|
||||
sed -i "s/server = .*/server = \"$DB_HOST\"/" "$SQL_CONF"
|
||||
sed -i "s/port = .*/port = $DB_PORT/" "$SQL_CONF"
|
||||
sed -i "s/login = .*/login = \"$DB_USER\"/" "$SQL_CONF"
|
||||
sed -i "s/password = .*/password = \"$DB_PASS\"/" "$SQL_CONF"
|
||||
sed -i "s/radius_db = .*/radius_db = \"$DB_NAME\"/" "$SQL_CONF"
|
||||
fi
|
||||
|
||||
# Dynamically update RADIUS shared secret for all client networks from env
|
||||
CLIENTS_CONF="/etc/freeradius/3.0/clients.conf"
|
||||
if [ -f "$CLIENTS_CONF" ]; then
|
||||
sed -i "s/secret = .*/secret = $RADIUS_SECRET/" "$CLIENTS_CONF"
|
||||
fi
|
||||
|
||||
# Generate FreeRADIUS EAP certificates if missing
|
||||
CERT_DIR="/etc/freeradius/3.0/certs"
|
||||
if [ ! -f "$CERT_DIR/server.pem" ]; then
|
||||
echo "Generating FreeRADIUS EAP certificates..."
|
||||
mkdir -p "$CERT_DIR"
|
||||
|
||||
# Generate CA key and certificate
|
||||
openssl req -new -x509 -keyout "$CERT_DIR/ca.key" -out "$CERT_DIR/ca.pem" \
|
||||
-days 3650 -nodes -subj "/CN=RADIUS CA" 2>/dev/null
|
||||
|
||||
# Generate server key and CSR
|
||||
openssl req -new -keyout "$CERT_DIR/server.key" -out "$CERT_DIR/server.csr" \
|
||||
-nodes -subj "/CN=RADIUS Server" 2>/dev/null
|
||||
|
||||
# Sign the server certificate with the CA
|
||||
openssl x509 -req -in "$CERT_DIR/server.csr" -CA "$CERT_DIR/ca.pem" \
|
||||
-CAkey "$CERT_DIR/ca.key" -CAcreateserial -out "$CERT_DIR/server.crt" \
|
||||
-days 3650 2>/dev/null
|
||||
|
||||
# Combine server key + cert into server.pem
|
||||
cat "$CERT_DIR/server.key" "$CERT_DIR/server.crt" > "$CERT_DIR/server.pem"
|
||||
|
||||
# Generate DH parameters (small size for speed, adequate for internal use)
|
||||
openssl dhparam -out "$CERT_DIR/dh" 1024 2>/dev/null
|
||||
|
||||
# Clean up intermediate files
|
||||
rm -f "$CERT_DIR/server.csr" "$CERT_DIR/server.key" "$CERT_DIR/server.crt" "$CERT_DIR/ca.key" "$CERT_DIR/ca.srl"
|
||||
|
||||
echo "EAP certificates generated successfully."
|
||||
fi
|
||||
|
||||
# Ensure correct permissions for FreeRADIUS config directory
|
||||
chown -R freerad:freerad /etc/freeradius/3.0/ || true
|
||||
|
||||
# Start FreeRADIUS daemon in background
|
||||
echo "Starting FreeRADIUS service..."
|
||||
freeradius -x &
|
||||
|
||||
# Wait briefly for FreeRADIUS to initialize
|
||||
sleep 2
|
||||
|
||||
# Start ASP.NET Core Application
|
||||
echo "Starting ASP.NET Core Web Controller..."
|
||||
exec dotnet radiuscontroller.dll
|
||||
@@ -0,0 +1,17 @@
|
||||
client localhost {
|
||||
ipaddr = 127.0.0.1
|
||||
secret = radpass
|
||||
shortname = localhost
|
||||
}
|
||||
|
||||
client local_net {
|
||||
ipaddr = 0.0.0.0/0
|
||||
secret = radpass
|
||||
shortname = all_clients
|
||||
}
|
||||
|
||||
client local_net_v6 {
|
||||
ipv6addr = ::/0
|
||||
secret = radpass
|
||||
shortname = all_clients_v6
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
# Custom RADIUS attributes for Radius Controller
|
||||
# Max-Octets: Limits total bytes (upload + download) per session
|
||||
ATTRIBUTE Max-Octets 227 integer
|
||||
@@ -0,0 +1,42 @@
|
||||
eap {
|
||||
default_eap_type = peap
|
||||
timer_expire = 60
|
||||
ignore_unknown_eap_types = no
|
||||
cisco_accounting_username_bug = no
|
||||
max_sessions = ${max_requests}
|
||||
|
||||
# TLS configuration section named "certs"
|
||||
tls-config certs {
|
||||
private_key_password = whatever
|
||||
private_key_file = ${certdir}/server.pem
|
||||
certificate_file = ${certdir}/server.pem
|
||||
ca_file = ${cadir}/ca.pem
|
||||
dh_file = ${certdir}/dh
|
||||
cipher_list = "DEFAULT@SECLEVEL=1"
|
||||
cipher_server_preference = no
|
||||
tls_min_version = "1.0"
|
||||
tls_max_version = "1.2"
|
||||
}
|
||||
|
||||
# PEAP configuration referencing tls-config certs
|
||||
peap {
|
||||
tls = certs
|
||||
default_eap_type = mschapv2
|
||||
copy_request_to_tunnel = yes
|
||||
use_tunneled_reply = yes
|
||||
virtual_server = "inner-tunnel"
|
||||
}
|
||||
|
||||
# TTLS configuration referencing tls-config certs
|
||||
ttls {
|
||||
tls = certs
|
||||
default_eap_type = mschapv2
|
||||
copy_request_to_tunnel = yes
|
||||
use_tunneled_reply = yes
|
||||
virtual_server = "inner-tunnel"
|
||||
}
|
||||
|
||||
# MSCHAPv2 configuration
|
||||
mschapv2 {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
sql {
|
||||
driver = "rlm_sql_postgresql"
|
||||
dialect = "postgresql"
|
||||
|
||||
server = "postgres"
|
||||
port = 5432
|
||||
login = "radius"
|
||||
password = "radpass"
|
||||
radius_db = "radius"
|
||||
|
||||
# Standard FreeRADIUS SQL Table Mappings
|
||||
authcheck_table = "radcheck"
|
||||
authreply_table = "radreply"
|
||||
groupcheck_table = "radgroupcheck"
|
||||
groupreply_table = "radgroupreply"
|
||||
usergroup_table = "radusergroup"
|
||||
|
||||
# Accounting Table Mappings
|
||||
acct_table1 = "radacct"
|
||||
acct_table2 = "radacct"
|
||||
|
||||
# Post-auth Table Mapping
|
||||
postauth_table = "radpostauth"
|
||||
|
||||
# NAS Client Table Mapping
|
||||
client_table = "nas"
|
||||
read_clients = yes
|
||||
|
||||
group_attribute = "SQL-Group"
|
||||
|
||||
pool {
|
||||
start = 2
|
||||
min = 1
|
||||
max = 10
|
||||
spare = 3
|
||||
uses = 0
|
||||
retry_delay = 30
|
||||
lifetime = 0
|
||||
idle_timeout = 60
|
||||
}
|
||||
|
||||
read_groups = yes
|
||||
|
||||
$INCLUDE ${modconfdir}/${.:name}/main/${dialect}/queries.conf
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
server default {
|
||||
listen {
|
||||
type = auth
|
||||
ipaddr = *
|
||||
port = 1812
|
||||
limit {
|
||||
max_connections = 16
|
||||
lifetime = 0
|
||||
idle_timeout = 30
|
||||
}
|
||||
}
|
||||
|
||||
listen {
|
||||
type = acct
|
||||
ipaddr = *
|
||||
port = 1813
|
||||
limit {
|
||||
max_connections = 16
|
||||
lifetime = 0
|
||||
idle_timeout = 30
|
||||
}
|
||||
}
|
||||
|
||||
authorize {
|
||||
filter_username
|
||||
preprocess
|
||||
sql
|
||||
chap
|
||||
mschap
|
||||
digest
|
||||
pap
|
||||
eap {
|
||||
ok = return
|
||||
}
|
||||
expiration
|
||||
logintime
|
||||
}
|
||||
|
||||
authenticate {
|
||||
Auth-Type PAP {
|
||||
pap
|
||||
}
|
||||
Auth-Type CHAP {
|
||||
chap
|
||||
}
|
||||
Auth-Type MS-CHAP {
|
||||
mschap
|
||||
}
|
||||
mschap
|
||||
eap
|
||||
}
|
||||
|
||||
pre-proxy {
|
||||
}
|
||||
|
||||
post-proxy {
|
||||
eap
|
||||
}
|
||||
preacct {
|
||||
preprocess
|
||||
acct_unique
|
||||
}
|
||||
|
||||
accounting {
|
||||
detail
|
||||
sql
|
||||
exec
|
||||
}
|
||||
|
||||
session {
|
||||
sql
|
||||
}
|
||||
|
||||
post-auth {
|
||||
sql
|
||||
exec
|
||||
Post-Auth-Type REJECT {
|
||||
sql
|
||||
attr_filter.access_reject
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
server inner-tunnel {
|
||||
authorize {
|
||||
filter_username
|
||||
sql
|
||||
chap
|
||||
mschap
|
||||
pap
|
||||
eap {
|
||||
ok = return
|
||||
}
|
||||
expiration
|
||||
logintime
|
||||
}
|
||||
|
||||
authenticate {
|
||||
Auth-Type PAP {
|
||||
pap
|
||||
}
|
||||
Auth-Type CHAP {
|
||||
chap
|
||||
}
|
||||
Auth-Type MS-CHAP {
|
||||
mschap
|
||||
}
|
||||
mschap
|
||||
eap
|
||||
}
|
||||
|
||||
post-auth {
|
||||
sql
|
||||
Post-Auth-Type REJECT {
|
||||
sql
|
||||
attr_filter.access_reject
|
||||
}
|
||||
}
|
||||
}
|
||||
+78
-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,
|
||||
@@ -86,6 +92,15 @@ CREATE TABLE IF NOT EXISTS nas (
|
||||
description VARCHAR(200) DEFAULT 'RADIUS Client'
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS radpostauth (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
username VARCHAR(64) NOT NULL DEFAULT '',
|
||||
pass VARCHAR(64) NOT NULL DEFAULT '',
|
||||
reply VARCHAR(32) NOT NULL DEFAULT '',
|
||||
authdate TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS radpostauth_username ON radpostauth (username);
|
||||
|
||||
-- Application Management Tables
|
||||
CREATE TABLE IF NOT EXISTS guest_profiles (
|
||||
id SERIAL PRIMARY KEY,
|
||||
@@ -116,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