feat: implement core radius service and database schema for guest access management

This commit is contained in:
Tygozwolle
2026-07-23 11:12:10 +02:00
commit f186708943
30 changed files with 2594 additions and 0 deletions
+24
View File
@@ -0,0 +1,24 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Guest Wi-Fi RADIUS Controller</title>
<base href="/"/>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@300;400;500;600;700;800&family=JetBrains+Mono:wght@400;600&display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">
<link rel="stylesheet" href="@Assets["app.css"]"/>
<link rel="stylesheet" href="@Assets["radiuscontroller.styles.css"]"/>
<ImportMap/>
<HeadOutlet/>
</head>
<body>
<Routes/>
<script src="_framework/blazor.web.js"></script>
</body>
</html>
@@ -0,0 +1,59 @@
@inherits LayoutComponentBase
@inject AdminAuthService AuthService
@inject NavigationManager NavManager
<div class="app-container">
<header class="app-header">
<a href="/" class="brand-logo">
<div class="brand-icon">
<i class="bi bi-wifi"></i>
</div>
<span>Guest Radius</span>
</a>
<nav class="nav-links">
<NavLink href="" Match="NavLinkMatch.All" class="nav-item">
<i class="bi bi-qr-code-scan"></i> Guest Registration
</NavLink>
<NavLink href="admin" class="nav-item admin-btn">
<i class="bi bi-shield-lock"></i> Admin Panel
</NavLink>
@if (AuthService.IsAuthenticated)
{
<button class="nav-item btn-secondary" style="border:none; cursor:pointer;" @onclick="LogoutAdmin">
<i class="bi bi-box-arrow-right"></i> Logout (@AuthService.CurrentAdminUsername)
</button>
}
</nav>
</header>
<main class="main-content">
@Body
</main>
<footer class="app-footer">
RADIUS Guest Portal & Database Manager &bull; Powered by FreeRADIUS & PostgreSQL &bull; &copy; @DateTime.UtcNow.Year
</footer>
</div>
<div id="blazor-error-ui" data-nosnippet style="display:none;">
An unhandled error has occurred.
<a href="." class="reload">Reload</a>
<span class="dismiss">🗙</span>
</div>
@code {
protected override async Task OnInitializedAsync()
{
AuthService.OnAuthStateChanged += StateHasChanged;
await AuthService.InitializeAsync();
}
private async Task LogoutAdmin()
{
await AuthService.LogoutAsync();
NavManager.NavigateTo("/");
}
}
@@ -0,0 +1,20 @@
#blazor-error-ui {
color-scheme: light only;
background: lightyellow;
bottom: 0;
box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.2);
box-sizing: border-box;
display: none;
left: 0;
padding: 0.6rem 1.25rem 0.7rem 1.25rem;
position: fixed;
width: 100%;
z-index: 1000;
}
#blazor-error-ui .dismiss {
cursor: pointer;
position: absolute;
right: 0.75rem;
top: 0.5rem;
}
@@ -0,0 +1,540 @@
@page "/admin"
@rendermode InteractiveServer
@inject IRadiusService RadiusService
@inject AdminAuthService AuthService
@inject NavigationManager NavManager
<div style="max-width: 1280px; margin: 0 auto;">
@if (!AuthService.IsAuthenticated)
{
<div class="glass-card" style="text-align: center; padding: 3rem;">
<div class="spinner-border text-primary" role="status"></div>
<p style="margin-top: 1rem; color: var(--text-muted);">Verifying Administrator Session...</p>
</div>
}
else
{
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 2rem; flex-wrap: wrap; gap: 1rem;">
<div>
<h1 style="font-size: 2rem; font-weight: 800; letter-spacing: -0.02em;">
<i class="bi bi-speedometer2" style="color: var(--primary);"></i> RADIUS Controller Dashboard
</h1>
<p style="color: var(--text-muted); font-size: 0.95rem;">
Manage guest bandwidth caps, session durations, and monitor live PostgreSQL accounting.
</p>
</div>
<button class="btn-sm btn-secondary" @onclick="RefreshData" disabled="@isLoading">
<i class="bi bi-arrow-clockwise"></i> Refresh Data
</button>
</div>
@if (notificationMessage != null)
{
<div class="glass-card" style="padding: 1rem 1.25rem; margin-bottom: 1.5rem; border-color: rgba(20, 184, 166, 0.4); background: rgba(20, 184, 166, 0.1);">
<i class="bi bi-info-circle-fill" style="color: var(--accent-teal);"></i> @notificationMessage
</div>
}
<!-- Top Statistics Cards -->
<div class="stats-grid">
<div class="stat-card">
<div class="stat-icon primary">
<i class="bi bi-people-fill"></i>
</div>
<div>
<div class="stat-number">@users.Count</div>
<div class="stat-label">Total Registered Guests</div>
</div>
</div>
<div class="stat-card">
<div class="stat-icon emerald">
<i class="bi bi-wifi"></i>
</div>
<div>
<div class="stat-number" style="color: var(--accent-emerald);">@activeSessions.Count</div>
<div class="stat-label">Active Sessions Now</div>
</div>
</div>
<div class="stat-card">
<div class="stat-icon teal">
<i class="bi bi-cloud-arrow-down-fill"></i>
</div>
<div>
<div class="stat-number">@FormatTotalDataUsed()</div>
<div class="stat-label">Total Data Consumed</div>
</div>
</div>
<div class="stat-card">
<div class="stat-icon amber">
<i class="bi bi-sliders"></i>
</div>
<div>
<div class="stat-number">@(config?.DefaultSessionTimeMinutes ?? 0)m / @(config?.DefaultDataLimitMb ?? 0)MB</div>
<div class="stat-label">Default Policy (Time / Cap)</div>
</div>
</div>
</div>
<!-- Dashboard Navigation Tabs -->
<div class="tab-nav">
<button class="tab-btn @(activeTab == "users" ? "active" : "")" @onclick='() => activeTab = "users"'>
<i class="bi bi-person-lines-fill"></i> Guest Users & Usage (@users.Count)
</button>
<button class="tab-btn @(activeTab == "config" ? "active" : "")" @onclick='() => activeTab = "config"'>
<i class="bi bi-gear-wide-connected"></i> Global RADIUS Settings
</button>
<button class="tab-btn @(activeTab == "active" ? "active" : "")" @onclick='() => activeTab = "active"'>
<i class="bi bi-activity"></i> Live Connections (@activeSessions.Count)
</button>
</div>
<!-- TAB 1: User Management & Accounting Data -->
@if (activeTab == "users")
{
<div class="glass-card">
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 1.5rem; flex-wrap: wrap; gap: 1rem;">
<div style="position: relative; max-width: 320px; width: 100%;">
<input type="text"
class="form-control-glass"
placeholder="Search guest name or username..."
@bind="searchQuery"
@bind:event="oninput"
style="padding-left: 2.5rem; font-size: 0.9rem;" />
<i class="bi bi-search" style="position: absolute; left: 1rem; top: 50%; transform: translateY(-50%); color: var(--text-muted);"></i>
</div>
</div>
<div class="table-responsive">
<table class="custom-table">
<thead>
<tr>
<th>Guest Name</th>
<th>RADIUS Credentials</th>
<th>Status</th>
<th>Session Duration Limit</th>
<th>Data Used / Cap</th>
<th>Total Online Time</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
@if (FilteredUsers.Count == 0)
{
<tr>
<td colspan="7" style="text-align: center; color: var(--text-muted); padding: 3rem;">
<i class="bi bi-inbox" style="font-size: 2rem; display: block; margin-bottom: 0.5rem;"></i>
No guest accounts found.
</td>
</tr>
}
@foreach (var user in FilteredUsers)
{
double percentage = user.DataLimitMb > 0 ? Math.Min(100, Math.Round((user.UsedDataMb / user.DataLimitMb) * 100, 1)) : 0;
string fillClass = percentage >= 85 ? "warning" : "normal";
<tr>
<td>
<div style="font-weight: 700;">@user.GuestName</div>
<div style="font-size: 0.75rem; color: var(--text-dim);">
Registered: @user.CreatedAt.ToLocalTime().ToString("MMM dd, yyyy HH:mm")
</div>
</td>
<td>
<div style="font-family: var(--font-mono); font-weight: 600; color: var(--accent-teal);">
@user.Username
</div>
<div style="font-family: var(--font-mono); font-size: 0.8rem; color: var(--text-muted);">
Pwd: <span style="color: var(--accent-purple);">@user.Password</span>
</div>
</td>
<td>
@if (user.IsCurrentlyConnected)
{
<span class="badge badge-emerald"><i class="bi bi-broadcast"></i> Online</span>
}
else if (user.Status == "Revoked")
{
<span class="badge badge-rose"><i class="bi bi-x-circle"></i> Revoked</span>
}
else
{
<span class="badge badge-primary"><i class="bi bi-check-circle"></i> @user.Status</span>
}
</td>
<td>
<strong>@user.SessionTimeMinutes mins</strong>
<div style="font-size: 0.75rem; color: var(--text-dim);">
RADIUS Session-Timeout
</div>
</td>
<td style="min-width: 180px;">
<div style="display: flex; justify-content: space-between; font-size: 0.85rem; font-weight: 600;">
<span>@user.UsedDataMb MB</span>
<span style="color: var(--text-muted);">/ @user.DataLimitMb MB</span>
</div>
<div class="progress-bar-bg">
<div class="progress-bar-fill @fillClass" style="width: @percentage%;"></div>
</div>
<div style="font-size: 0.725rem; color: var(--text-dim); margin-top: 0.2rem;">
Upload: @FormatMb(user.TotalInputOctets) | Down: @FormatMb(user.TotalOutputOctets)
</div>
</td>
<td>
@FormatSeconds(user.TotalSessionTimeSeconds)
</td>
<td>
<div style="display: flex; gap: 0.35rem;">
<button class="btn-sm btn-secondary" title="Edit Session & Data Limits" @onclick="() => OpenEditModal(user)">
<i class="bi bi-pencil"></i>
</button>
@if (user.Status == "Active")
{
<button class="btn-sm btn-danger" title="Revoke Access" @onclick="() => RevokeUser(user.ProfileId)">
<i class="bi bi-slash-circle"></i>
</button>
}
<button class="btn-sm btn-secondary" style="color: var(--accent-rose);" title="Delete User" @onclick="() => DeleteUser(user.ProfileId)">
<i class="bi bi-trash"></i>
</button>
</div>
</td>
</tr>
}
</tbody>
</table>
</div>
</div>
}
<!-- TAB 2: Global Configuration Settings -->
@if (activeTab == "config")
{
<div class="glass-card" style="max-width: 680px;">
<h3 style="font-size: 1.25rem; font-weight: 700; margin-bottom: 1rem;">
<i class="bi bi-sliders" style="color: var(--primary);"></i> Default RADIUS Policy Settings
</h3>
<p style="color: var(--text-muted); font-size: 0.9rem; margin-bottom: 1.5rem;">
These parameters determine the initial session duration limit and total bandwidth quota assigned when a new guest registers.
</p>
<EditForm Model="this" OnValidSubmit="SaveGlobalConfig">
<div class="form-group">
<label class="form-label">Default Session Time (Minutes)</label>
<input type="number"
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.
</span>
</div>
<div class="form-group">
<label class="form-label">Default Data Usage Cap (Megabytes)</label>
<input type="number"
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.
</span>
</div>
<button type="submit" class="btn-gradient-primary" style="width: auto; padding: 0.8rem 2rem;">
<i class="bi bi-save-fill"></i> Save Default Policy
</button>
</EditForm>
</div>
}
<!-- TAB 3: Live Active Sessions -->
@if (activeTab == "active")
{
<div class="glass-card">
<h3 style="font-size: 1.25rem; font-weight: 700; margin-bottom: 1rem;">
<i class="bi bi-broadcast" style="color: var(--accent-emerald);"></i> Live Connected Devices
</h3>
<p style="color: var(--text-muted); font-size: 0.9rem; margin-bottom: 1.5rem;">
Real-time active RADIUS sessions recorded in PostgreSQL <code style="color: var(--accent-teal);">radacct</code> table.
</p>
<div class="table-responsive">
<table class="custom-table">
<thead>
<tr>
<th>Username</th>
<th>NAS IP Address</th>
<th>Calling Station (MAC)</th>
<th>Framed IP</th>
<th>Start Time</th>
<th>Online Duration</th>
<th>Downloaded / Uploaded</th>
</tr>
</thead>
<tbody>
@if (activeSessions.Count == 0)
{
<tr>
<td colspan="7" style="text-align: center; color: var(--text-muted); padding: 3rem;">
<i class="bi bi-wifi-off" style="font-size: 2rem; display: block; margin-bottom: 0.5rem;"></i>
No active sessions currently online.
</td>
</tr>
}
@foreach (var session in activeSessions)
{
<tr>
<td>
<span style="font-family: var(--font-mono); font-weight: 700; color: var(--accent-teal);">
@session.Username
</span>
</td>
<td>@session.NasIpAddress</td>
<td>
<span style="font-family: var(--font-mono); font-size: 0.85rem;">
@(string.IsNullOrEmpty(session.CallingStationId) ? "N/A" : session.CallingStationId)
</span>
</td>
<td>
<span style="font-family: var(--font-mono); font-size: 0.85rem;">
@(string.IsNullOrEmpty(session.FramedIpAddress) ? "DHCP Assigned" : session.FramedIpAddress)
</span>
</td>
<td>@session.AcctStartTime?.ToLocalTime().ToString("HH:mm:ss MMM dd")</td>
<td>@FormatSeconds(session.AcctSessionTime ?? 0)</td>
<td>
<span style="color: #34d399;">&darr; @FormatMb(session.AcctOutputOctets ?? 0) MB</span> /
<span style="color: #60a5fa;">&uarr; @FormatMb(session.AcctInputOctets ?? 0) MB</span>
</td>
</tr>
}
</tbody>
</table>
</div>
</div>
}
<!-- Edit User Limits Modal Overlay -->
@if (selectedUserForEdit != null)
{
<div style="position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.75); backdrop-filter: blur(8px); display: flex; align-items: center; justify-content: center; z-index: 1000; padding: 1rem;">
<div class="glass-card" style="max-width: 500px; width: 100%; border-color: rgba(99, 102, 241, 0.4);">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1.5rem;">
<h3 style="font-size: 1.25rem; font-weight: 700;">
<i class="bi bi-pencil-square" style="color: var(--primary);"></i> Edit Guest Policy
</h3>
<button class="btn-sm btn-secondary" @onclick="() => selectedUserForEdit = null">
<i class="bi bi-x-lg"></i>
</button>
</div>
<div style="background: rgba(255,255,255,0.03); padding: 0.85rem; border-radius: var(--radius-sm); margin-bottom: 1.5rem; font-size: 0.9rem;">
<div>Guest: <strong>@selectedUserForEdit.GuestName</strong></div>
<div style="font-family: var(--font-mono); color: var(--accent-teal);">Username: @selectedUserForEdit.Username</div>
</div>
<div class="form-group">
<label class="form-label">Session Time Limit (Minutes)</label>
<input type="number" class="form-control-glass" @bind="editModalSessionTime" min="1" required />
</div>
<div class="form-group">
<label class="form-label">Data Usage Limit (MB)</label>
<input type="number" class="form-control-glass" @bind="editModalDataLimit" min="1" required />
</div>
<div class="form-group">
<label class="form-label">Reset Password (Optional)</label>
<input type="text" class="form-control-glass" placeholder="Leave blank to keep existing password" @bind="editModalNewPassword" />
</div>
<div style="display: flex; gap: 0.75rem; justify-content: flex-end; margin-top: 1.5rem;">
<button class="btn-sm btn-secondary" @onclick="() => selectedUserForEdit = null">Cancel</button>
<button class="btn-gradient-primary" style="width: auto;" @onclick="SaveUserEdit">
<i class="bi bi-check-lg"></i> Save Changes
</button>
</div>
</div>
</div>
}
}
</div>
@code {
private bool isLoading = true;
private string activeTab = "users";
private string searchQuery = string.Empty;
private string? notificationMessage;
private SystemConfigDto? config;
private List<UserUsageDto> users = new();
private List<RadAcct> activeSessions = new();
private int editConfigSessionTime;
private long editConfigDataLimit;
private UserUsageDto? selectedUserForEdit;
private int editModalSessionTime;
private long editModalDataLimit;
private string editModalNewPassword = string.Empty;
protected override async Task OnInitializedAsync()
{
await AuthService.InitializeAsync();
if (!AuthService.IsAuthenticated)
{
NavManager.NavigateTo("/admin/login");
return;
}
await LoadDataAsync();
}
private async Task LoadDataAsync()
{
isLoading = true;
try
{
config = await RadiusService.GetSystemConfigAsync();
editConfigSessionTime = config.DefaultSessionTimeMinutes;
editConfigDataLimit = config.DefaultDataLimitMb;
users = await RadiusService.GetAllUsersWithUsageAsync();
activeSessions = await RadiusService.GetActiveSessionsAsync();
}
catch (Exception ex)
{
notificationMessage = "Error loading data: " + ex.Message;
}
finally
{
isLoading = false;
}
}
private async Task RefreshData()
{
notificationMessage = null;
await LoadDataAsync();
ShowNotification("Data refreshed successfully.");
}
private List<UserUsageDto> FilteredUsers
{
get
{
if (string.IsNullOrWhiteSpace(searchQuery)) return users;
string q = searchQuery.Trim().ToLowerInvariant();
return users.Where(u => u.GuestName.ToLowerInvariant().Contains(q) || u.Username.ToLowerInvariant().Contains(q)).ToList();
}
}
private async Task SaveGlobalConfig()
{
try
{
await RadiusService.UpdateSystemConfigAsync(editConfigSessionTime, editConfigDataLimit);
config = await RadiusService.GetSystemConfigAsync();
ShowNotification("Global default session time & data cap policy updated.");
}
catch (Exception ex)
{
ShowNotification("Failed to update policy: " + ex.Message);
}
}
private void OpenEditModal(UserUsageDto user)
{
selectedUserForEdit = user;
editModalSessionTime = user.SessionTimeMinutes;
editModalDataLimit = user.DataLimitMb;
editModalNewPassword = string.Empty;
}
private async Task SaveUserEdit()
{
if (selectedUserForEdit == null) return;
try
{
await RadiusService.UpdateUserLimitsAsync(selectedUserForEdit.ProfileId, editModalSessionTime, editModalDataLimit);
if (!string.IsNullOrWhiteSpace(editModalNewPassword))
{
await RadiusService.ResetUserPasswordAsync(selectedUserForEdit.ProfileId, editModalNewPassword.Trim());
}
selectedUserForEdit = null;
await LoadDataAsync();
ShowNotification("User limits updated successfully.");
}
catch (Exception ex)
{
ShowNotification("Error updating user: " + ex.Message);
}
}
private async Task RevokeUser(int profileId)
{
try
{
await RadiusService.RevokeUserAsync(profileId);
await LoadDataAsync();
ShowNotification("User access revoked.");
}
catch (Exception ex)
{
ShowNotification("Error revoking user: " + ex.Message);
}
}
private async Task DeleteUser(int profileId)
{
try
{
await RadiusService.DeleteUserAsync(profileId);
await LoadDataAsync();
ShowNotification("User deleted.");
}
catch (Exception ex)
{
ShowNotification("Error deleting user: " + ex.Message);
}
}
private void ShowNotification(string message)
{
notificationMessage = message;
StateHasChanged();
}
private string FormatTotalDataUsed()
{
long totalOctets = users.Sum(u => u.TotalOctets);
double gb = (double)totalOctets / (1024 * 1024 * 1024);
if (gb >= 1.0)
return $"{Math.Round(gb, 2)} GB";
double mb = (double)totalOctets / (1024 * 1024);
return $"{Math.Round(mb, 1)} MB";
}
private static double FormatMb(long bytes) => Math.Round((double)bytes / (1024 * 1024), 2);
private static string FormatSeconds(long seconds)
{
if (seconds <= 0) return "0s";
TimeSpan t = TimeSpan.FromSeconds(seconds);
if (t.TotalHours >= 1)
return $"{Math.Floor(t.TotalHours)}h {t.Minutes}m";
if (t.TotalMinutes >= 1)
return $"{t.Minutes}m {t.Seconds}s";
return $"{t.Seconds}s";
}
}
@@ -0,0 +1,96 @@
@page "/admin/login"
@rendermode InteractiveServer
@inject AdminAuthService AuthService
@inject NavigationManager NavManager
<div class="portal-wrapper">
<div class="portal-header">
<div class="brand-icon" style="margin: 0 auto 1rem auto; width: 60px; height: 60px; font-size: 1.8rem; background: linear-gradient(135deg, var(--accent-purple), var(--primary));">
<i class="bi bi-shield-lock-fill"></i>
</div>
<h1 class="portal-title">Administrator Login</h1>
<p class="portal-subtitle">Access RADIUS server policies, session limits, and data accounting</p>
</div>
<div class="glass-card">
<EditForm Model="this" OnValidSubmit="HandleLogin">
<div class="form-group">
<label class="form-label" for="adminUsername">Username</label>
<input id="adminUsername"
type="text"
class="form-control-glass"
placeholder="admin"
@bind="username"
required />
</div>
<div class="form-group">
<label class="form-label" for="adminPassword">Password</label>
<input id="adminPassword"
type="password"
class="form-control-glass"
placeholder="••••••••"
@bind="password"
required />
</div>
@if (!string.IsNullOrEmpty(errorMessage))
{
<div style="color: var(--accent-rose); font-size: 0.875rem; margin-bottom: 1rem;">
<i class="bi bi-exclamation-triangle-fill"></i> @errorMessage
</div>
}
<button type="submit" class="btn-gradient-primary" disabled="@isSubmitting">
@if (isSubmitting)
{
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
<span>Authenticating...</span>
}
else
{
<i class="bi bi-box-arrow-in-right"></i>
<span>Sign In to Admin Panel</span>
}
</button>
</EditForm>
<div style="margin-top: 1.5rem; text-align: center; padding-top: 1rem; border-top: 1px solid var(--bg-card-border); font-size: 0.8rem; color: var(--text-dim);">
Default credentials: <code style="color: var(--accent-teal);">admin</code> / <code style="color: var(--accent-teal);">admin123</code>
</div>
</div>
</div>
@code {
private string username = string.Empty;
private string password = string.Empty;
private bool isSubmitting = false;
private string errorMessage = string.Empty;
protected override async Task OnInitializedAsync()
{
await AuthService.InitializeAsync();
if (AuthService.IsAuthenticated)
{
NavManager.NavigateTo("/admin");
}
}
private async Task HandleLogin()
{
isSubmitting = true;
errorMessage = string.Empty;
bool success = await AuthService.LoginAsync(username, password);
if (success)
{
NavManager.NavigateTo("/admin");
}
else
{
errorMessage = "Invalid admin username or password.";
}
isSubmitting = false;
}
}
@@ -0,0 +1,36 @@
@page "/Error"
@using System.Diagnostics
<PageTitle>Error</PageTitle>
<h1 class="text-danger">Error.</h1>
<h2 class="text-danger">An error occurred while processing your request.</h2>
@if (ShowRequestId)
{
<p>
<strong>Request ID:</strong> <code>@RequestId</code>
</p>
}
<h3>Development Mode</h3>
<p>
Swapping to <strong>Development</strong> environment will display more detailed information about the error that occurred.
</p>
<p>
<strong>The Development environment shouldn't be enabled for deployed applications.</strong>
It can result in displaying sensitive information from exceptions to end users.
For local debugging, enable the <strong>Development</strong> environment by setting the <strong>ASPNETCORE_ENVIRONMENT</strong> environment variable to <strong>Development</strong>
and restarting the app.
</p>
@code{
[CascadingParameter] private HttpContext? HttpContext { get; set; }
private string? RequestId { get; set; }
private bool ShowRequestId => !string.IsNullOrEmpty(RequestId);
protected override void OnInitialized() =>
RequestId = Activity.Current?.Id ?? HttpContext?.TraceIdentifier;
}
@@ -0,0 +1,175 @@
@page "/"
@rendermode InteractiveServer
@inject IRadiusService RadiusService
@inject IJSRuntime JS
<div class="portal-wrapper">
<div class="portal-header">
<div class="brand-icon" style="margin: 0 auto 1rem auto; width: 60px; height: 60px; font-size: 1.8rem;">
<i class="bi bi-wifi"></i>
</div>
<h1 class="portal-title">Guest Wi-Fi Access</h1>
<p class="portal-subtitle">Enter your name to generate your instant high-speed network credentials</p>
</div>
<div class="glass-card">
@if (config != null)
{
<div style="display: flex; gap: 1rem; background: rgba(255,255,255,0.03); border: 1px solid var(--bg-card-border); padding: 0.85rem 1rem; border-radius: var(--radius-sm); margin-bottom: 1.5rem; font-size: 0.875rem;">
<div>
<i class="bi bi-clock-history text-primary" style="color: var(--primary);"></i>
<span style="color: var(--text-muted); margin-left: 0.35rem;">Session Limit:</span> <strong>@config.DefaultSessionTimeMinutes mins</strong>
</div>
<div>
<i class="bi bi-database text-teal" style="color: var(--accent-teal);"></i>
<span style="color: var(--text-muted); margin-left: 0.35rem;">Data Cap:</span> <strong>@config.DefaultDataLimitMb MB</strong>
</div>
</div>
}
<EditForm Model="this" OnValidSubmit="HandleRegistration">
<div class="form-group">
<label class="form-label" for="guestNameInput">Full Name or Guest Identifier</label>
<input id="guestNameInput"
type="text"
class="form-control-glass"
placeholder="e.g. Alex Taylor"
@bind="guestName"
required
disabled="@isSubmitting" />
</div>
@if (!string.IsNullOrEmpty(errorMessage))
{
<div style="color: var(--accent-rose); font-size: 0.875rem; margin-bottom: 1rem;">
<i class="bi bi-exclamation-circle-fill"></i> @errorMessage
</div>
}
<button type="submit" class="btn-gradient-primary" disabled="@(isSubmitting || string.IsNullOrWhiteSpace(guestName))">
@if (isSubmitting)
{
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
<span>Generating Access...</span>
}
else
{
<i class="bi bi-key-fill"></i>
<span>Get Wi-Fi Password</span>
}
</button>
</EditForm>
@if (result != null)
{
<div class="credentials-box">
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 1rem;">
<h3 style="font-size: 1.1rem; font-weight: 700; color: white;">
<i class="bi bi-check-circle-fill" style="color: var(--accent-teal);"></i> Access Granted!
</h3>
<span class="badge badge-emerald">Ready to Connect</span>
</div>
<p style="font-size: 0.875rem; color: var(--text-muted); margin-bottom: 1rem;">
Connect to the <strong>Guest-WiFi</strong> network on your device and enter the credentials below:
</p>
<div class="cred-row">
<div>
<div class="cred-label">Username</div>
<div class="cred-value">@result.Username</div>
</div>
<button class="copy-btn" @onclick='() => CopyToClipboard(result.Username, "Username")'>
<i class="bi bi-clipboard"></i> Copy
</button>
</div>
<div class="cred-row">
<div>
<div class="cred-label">Password</div>
<div class="cred-value" style="color: var(--accent-purple);">@result.Password</div>
</div>
<button class="copy-btn" @onclick='() => CopyToClipboard(result.Password, "Password")'>
<i class="bi bi-clipboard"></i> Copy
</button>
</div>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 0.75rem; margin-top: 1rem; font-size: 0.8rem; color: var(--text-muted);">
<div style="background: rgba(11,15,25,0.4); padding: 0.6rem; border-radius: var(--radius-sm);">
<i class="bi bi-hourglass-split" style="color: var(--accent-amber);"></i> Session: <strong>@result.SessionTimeMinutes Mins</strong>
</div>
<div style="background: rgba(11,15,25,0.4); padding: 0.6rem; border-radius: var(--radius-sm);">
<i class="bi bi-wifi-off" style="color: var(--accent-rose);"></i> Data Cap: <strong>@result.DataLimitMb MB</strong>
</div>
</div>
@if (!string.IsNullOrEmpty(copyNotification))
{
<div style="text-align: center; color: var(--accent-teal); font-size: 0.8rem; font-weight: 600; margin-top: 0.75rem; animation: fadeIn 0.2s;">
<i class="bi bi-check2"></i> @copyNotification
</div>
}
</div>
}
</div>
</div>
@code {
private string guestName = string.Empty;
private bool isSubmitting = false;
private string errorMessage = string.Empty;
private RegistrationResultDto? result;
private SystemConfigDto? config;
private string copyNotification = string.Empty;
protected override async Task OnInitializedAsync()
{
try
{
config = await RadiusService.GetSystemConfigAsync();
}
catch (Exception ex)
{
errorMessage = "Database connection offline or initializing: " + ex.Message;
}
}
private async Task HandleRegistration()
{
if (string.IsNullOrWhiteSpace(guestName)) return;
isSubmitting = true;
errorMessage = string.Empty;
copyNotification = string.Empty;
try
{
result = await RadiusService.RegisterGuestAsync(guestName);
}
catch (Exception ex)
{
errorMessage = "Failed to create credentials: " + ex.Message;
}
finally
{
isSubmitting = false;
}
}
private async Task CopyToClipboard(string text, string label)
{
try
{
await JS.InvokeVoidAsync("navigator.clipboard.writeText", text);
copyNotification = $"{label} copied to clipboard!";
StateHasChanged();
await Task.Delay(2500);
copyNotification = string.Empty;
StateHasChanged();
}
catch
{
copyNotification = $"Failed to copy {label} automatically.";
}
}
}
@@ -0,0 +1,3 @@
@page "/home_deprecated"
<p>Redirecting...</p>
+6
View File
@@ -0,0 +1,6 @@
<Router AppAssembly="typeof(Program).Assembly">
<Found Context="routeData">
<RouteView RouteData="routeData" DefaultLayout="typeof(Layout.MainLayout)"/>
<FocusOnNavigate RouteData="routeData" Selector="h1"/>
</Found>
</Router>
@@ -0,0 +1,13 @@
@using System.Net.Http
@using System.Net.Http.Json
@using Microsoft.AspNetCore.Components.Forms
@using Microsoft.AspNetCore.Components.Routing
@using Microsoft.AspNetCore.Components.Web
@using static Microsoft.AspNetCore.Components.Web.RenderMode
@using Microsoft.AspNetCore.Components.Web.Virtualization
@using Microsoft.JSInterop
@using radiuscontroller
@using radiuscontroller.Components
@using radiuscontroller.Models
@using radiuscontroller.Services
@using radiuscontroller.Data
+31
View File
@@ -0,0 +1,31 @@
using Microsoft.EntityFrameworkCore;
using radiuscontroller.Models;
namespace radiuscontroller.Data;
public class RadiusDbContext : DbContext
{
public RadiusDbContext(DbContextOptions<RadiusDbContext> options)
: base(options)
{
}
public DbSet<RadCheck> RadCheck { get; set; } = null!;
public DbSet<RadReply> RadReply { get; set; } = null!;
public DbSet<RadAcct> RadAcct { get; set; } = null!;
public DbSet<GuestProfile> GuestProfiles { get; set; } = null!;
public DbSet<AdminUser> AdminUsers { get; set; } = null!;
public DbSet<SystemSetting> SystemSettings { get; set; } = null!;
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
modelBuilder.Entity<RadCheck>().ToTable("radcheck");
modelBuilder.Entity<RadReply>().ToTable("radreply");
modelBuilder.Entity<RadAcct>().ToTable("radacct");
modelBuilder.Entity<GuestProfile>().ToTable("guest_profiles");
modelBuilder.Entity<AdminUser>().ToTable("admin_users");
modelBuilder.Entity<SystemSetting>().ToTable("system_settings");
}
}
+23
View File
@@ -0,0 +1,23 @@
FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS base
USER $APP_UID
WORKDIR /app
EXPOSE 8080
EXPOSE 8081
FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build
ARG BUILD_CONFIGURATION=Release
WORKDIR /src
COPY ["radiuscontroller/radiuscontroller.csproj", "radiuscontroller/"]
RUN dotnet restore "radiuscontroller/radiuscontroller.csproj"
COPY . .
WORKDIR "/src/radiuscontroller"
RUN dotnet build "./radiuscontroller.csproj" -c $BUILD_CONFIGURATION -o /app/build
FROM build AS publish
ARG BUILD_CONFIGURATION=Release
RUN dotnet publish "./radiuscontroller.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false
FROM base AS final
WORKDIR /app
COPY --from=publish /app/publish .
ENTRYPOINT ["dotnet", "radiuscontroller.dll"]
+235
View File
@@ -0,0 +1,235 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace radiuscontroller.Models;
[Table("radcheck")]
public class RadCheck
{
[Key]
[Column("id")]
public int Id { get; set; }
[Required]
[Column("username")]
[StringLength(64)]
public string Username { get; set; } = string.Empty;
[Required]
[Column("attribute")]
[StringLength(64)]
public string Attribute { get; set; } = string.Empty;
[Required]
[Column("op")]
[StringLength(2)]
public string Op { get; set; } = ":=";
[Required]
[Column("value")]
[StringLength(253)]
public string Value { get; set; } = string.Empty;
}
[Table("radreply")]
public class RadReply
{
[Key]
[Column("id")]
public int Id { get; set; }
[Required]
[Column("username")]
[StringLength(64)]
public string Username { get; set; } = string.Empty;
[Required]
[Column("attribute")]
[StringLength(64)]
public string Attribute { get; set; } = string.Empty;
[Required]
[Column("op")]
[StringLength(2)]
public string Op { get; set; } = "=";
[Required]
[Column("value")]
[StringLength(253)]
public string Value { get; set; } = string.Empty;
}
[Table("radacct")]
public class RadAcct
{
[Key]
[Column("radacctid")]
public long RadAcctId { get; set; }
[Column("acctsessionid")]
public string AcctSessionId { get; set; } = string.Empty;
[Column("acctuniqueid")]
public string AcctUniqueId { get; set; } = string.Empty;
[Column("username")]
public string Username { get; set; } = string.Empty;
[Column("realm")]
public string? Realm { get; set; }
[Column("nasipaddress")]
public string NasIpAddress { get; set; } = string.Empty;
[Column("nasportid")]
public string? NasPortId { get; set; }
[Column("nasporttype")]
public string? NasPortType { get; set; }
[Column("acctstarttime")]
public DateTime? AcctStartTime { get; set; }
[Column("acctupdatetime")]
public DateTime? AcctUpdateTime { get; set; }
[Column("acctstoptime")]
public DateTime? AcctStopTime { get; set; }
[Column("acctinterval")]
public int? AcctInterval { get; set; }
[Column("acctsessiontime")]
public long? AcctSessionTime { get; set; }
[Column("acctauthentic")]
public string? AcctAuthentic { get; set; }
[Column("connectinfo_in")]
public string? ConnectInfoIn { get; set; }
[Column("connectinfo_out")]
public string? ConnectInfoOut { get; set; }
[Column("acctinputoctets")]
public long? AcctInputOctets { get; set; }
[Column("acctoutputoctets")]
public long? AcctOutputOctets { get; set; }
[Column("calledstationid")]
public string? CalledStationId { get; set; }
[Column("callingstationid")]
public string? CallingStationId { get; set; }
[Column("acctterminatecause")]
public string? AcctTerminateCause { get; set; }
[Column("servicetype")]
public string? ServiceType { get; set; }
[Column("framedprotocol")]
public string? FramedProtocol { get; set; }
[Column("framedipaddress")]
public string? FramedIpAddress { get; set; }
}
[Table("guest_profiles")]
public class GuestProfile
{
[Key]
[Column("id")]
public int Id { get; set; }
[Required]
[Column("guest_name")]
public string GuestName { get; set; } = string.Empty;
[Required]
[Column("username")]
public string Username { get; set; } = string.Empty;
[Column("created_at")]
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
[Column("session_time_minutes")]
public int SessionTimeMinutes { get; set; }
[Column("data_limit_mb")]
public long DataLimitMb { get; set; }
[Column("status")]
public string Status { get; set; } = "Active";
}
[Table("admin_users")]
public class AdminUser
{
[Key]
[Column("id")]
public int Id { get; set; }
[Required]
[Column("username")]
public string Username { get; set; } = string.Empty;
[Required]
[Column("password_hash")]
public string PasswordHash { get; set; } = string.Empty;
[Column("created_at")]
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
}
[Table("system_settings")]
public class SystemSetting
{
[Key]
[Column("key")]
public string Key { get; set; } = string.Empty;
[Required]
[Column("value")]
public string Value { get; set; } = string.Empty;
[Column("updated_at")]
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
}
// DTOs
public class UserUsageDto
{
public int ProfileId { get; set; }
public string GuestName { get; set; } = string.Empty;
public string Username { get; set; } = string.Empty;
public string Password { get; set; } = string.Empty;
public DateTime CreatedAt { get; set; }
public int SessionTimeMinutes { get; set; }
public long DataLimitMb { get; set; }
public string Status { get; set; } = "Active";
public long TotalInputOctets { get; set; } // Upload
public long TotalOutputOctets { get; set; } // Download
public long TotalOctets => TotalInputOctets + TotalOutputOctets;
public double UsedDataMb => Math.Round((double)TotalOctets / (1024 * 1024), 2);
public long TotalSessionTimeSeconds { get; set; }
public bool IsCurrentlyConnected { get; set; }
}
public class RegistrationResultDto
{
public string GuestName { get; set; } = string.Empty;
public string Username { get; set; } = string.Empty;
public string Password { get; set; } = string.Empty;
public int SessionTimeMinutes { get; set; }
public long DataLimitMb { get; set; }
public DateTime ExpirationEstimate { get; set; }
}
public class SystemConfigDto
{
public int DefaultSessionTimeMinutes { get; set; } = 60;
public long DefaultDataLimitMb { get; set; } = 500;
}
+46
View File
@@ -0,0 +1,46 @@
using Microsoft.EntityFrameworkCore;
using radiuscontroller.Components;
using radiuscontroller.Data;
using radiuscontroller.Services;
var builder = WebApplication.CreateBuilder(args);
// Add database context
var connectionString = builder.Configuration.GetConnectionString("DefaultConnection")
?? "Host=localhost;Port=5432;Database=radius;Username=radius;Password=radpass";
builder.Services.AddDbContext<RadiusDbContext>(options =>
options.UseNpgsql(connectionString));
// Add services
builder.Services.AddScoped<IRadiusService, RadiusService>();
builder.Services.AddScoped<AdminAuthService>();
// Add Razor components
builder.Services.AddRazorComponents()
.AddInteractiveServerComponents();
var app = builder.Build();
// Auto-seed admin user and default settings if DB available
using (var scope = app.Services.CreateScope())
{
var radiusService = scope.ServiceProvider.GetRequiredService<IRadiusService>();
await radiusService.EnsureDatabaseCreatedAndSeededAsync();
}
// Configure HTTP request pipeline
if (!app.Environment.IsDevelopment())
{
app.UseExceptionHandler("/Error", createScopeForErrors: true);
app.UseHsts();
}
app.UseHttpsRedirection();
app.UseAntiforgery();
app.MapStaticAssets();
app.MapRazorComponents<App>()
.AddInteractiveServerRenderMode();
app.Run();
@@ -0,0 +1,23 @@
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"applicationUrl": "http://localhost:5126",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"applicationUrl": "https://localhost:7044;http://localhost:5126",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}
@@ -0,0 +1,78 @@
using Microsoft.AspNetCore.Components.Server.ProtectedBrowserStorage;
using Microsoft.EntityFrameworkCore;
using radiuscontroller.Data;
namespace radiuscontroller.Services;
public class AdminAuthService
{
private readonly RadiusDbContext _db;
private readonly ProtectedSessionStorage _sessionStorage;
private const string SessionKey = "AdminUsername";
public string? CurrentAdminUsername { get; private set; }
public bool IsAuthenticated => !string.IsNullOrEmpty(CurrentAdminUsername);
public event Action? OnAuthStateChanged;
public AdminAuthService(RadiusDbContext db, ProtectedSessionStorage sessionStorage)
{
_db = db;
_sessionStorage = sessionStorage;
}
public async Task InitializeAsync()
{
try
{
var result = await _sessionStorage.GetAsync<string>(SessionKey);
if (result.Success && !string.IsNullOrEmpty(result.Value))
{
CurrentAdminUsername = result.Value;
NotifyStateChanged();
}
}
catch
{
// Session storage might fail during prerendering or early socket connection
CurrentAdminUsername = null;
}
}
public async Task<bool> LoginAsync(string username, string password)
{
if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password))
return false;
var admin = await _db.AdminUsers.FirstOrDefaultAsync(u => u.Username == username.Trim().ToLowerInvariant());
if (admin == null)
return false;
bool isValid = BCrypt.Net.BCrypt.Verify(password, admin.PasswordHash);
if (isValid)
{
CurrentAdminUsername = admin.Username;
await _sessionStorage.SetAsync(SessionKey, admin.Username);
NotifyStateChanged();
return true;
}
return false;
}
public async Task LogoutAsync()
{
CurrentAdminUsername = null;
try
{
await _sessionStorage.DeleteAsync(SessionKey);
}
catch
{
// Ignore storage errors during logout
}
NotifyStateChanged();
}
private void NotifyStateChanged() => OnAuthStateChanged?.Invoke();
}
+370
View File
@@ -0,0 +1,370 @@
using System.Security.Cryptography;
using System.Text.RegularExpressions;
using Microsoft.EntityFrameworkCore;
using radiuscontroller.Data;
using radiuscontroller.Models;
namespace radiuscontroller.Services;
public interface IRadiusService
{
Task<SystemConfigDto> GetSystemConfigAsync();
Task UpdateSystemConfigAsync(int defaultSessionTimeMinutes, long defaultDataLimitMb);
Task<RegistrationResultDto> RegisterGuestAsync(string guestName);
Task<List<UserUsageDto>> GetAllUsersWithUsageAsync();
Task<List<RadAcct>> GetActiveSessionsAsync();
Task UpdateUserLimitsAsync(int profileId, int newSessionTimeMinutes, long newDataLimitMb);
Task RevokeUserAsync(int profileId);
Task DeleteUserAsync(int profileId);
Task ResetUserPasswordAsync(int profileId, string newPassword);
Task EnsureDatabaseCreatedAndSeededAsync();
}
public class RadiusService : IRadiusService
{
private readonly RadiusDbContext _db;
private readonly ILogger<RadiusService> _logger;
public RadiusService(RadiusDbContext db, ILogger<RadiusService> logger)
{
_db = db;
_logger = logger;
}
public async Task<SystemConfigDto> GetSystemConfigAsync()
{
var settings = await _db.SystemSettings.ToDictionaryAsync(s => s.Key, s => s.Value);
int sessionTime = 60; // 60 minutes default
if (settings.TryGetValue("default_session_time", out var sessionStr) && int.TryParse(sessionStr, out var parsedSession))
{
sessionTime = parsedSession;
}
long dataLimit = 500; // 500 MB default
if (settings.TryGetValue("default_data_limit", out var dataStr) && long.TryParse(dataStr, out var parsedData))
{
dataLimit = parsedData;
}
return new SystemConfigDto
{
DefaultSessionTimeMinutes = sessionTime,
DefaultDataLimitMb = dataLimit
};
}
public async Task UpdateSystemConfigAsync(int defaultSessionTimeMinutes, long defaultDataLimitMb)
{
await SetOrUpdateSettingAsync("default_session_time", defaultSessionTimeMinutes.ToString());
await SetOrUpdateSettingAsync("default_data_limit", defaultDataLimitMb.ToString());
await _db.SaveChangesAsync();
}
private async Task SetOrUpdateSettingAsync(string key, string value)
{
var existing = await _db.SystemSettings.FirstOrDefaultAsync(s => s.Key == key);
if (existing != null)
{
existing.Value = value;
existing.UpdatedAt = DateTime.UtcNow;
}
else
{
_db.SystemSettings.Add(new SystemSetting
{
Key = key,
Value = value,
UpdatedAt = DateTime.UtcNow
});
}
}
public async Task<RegistrationResultDto> RegisterGuestAsync(string guestName)
{
if (string.IsNullOrWhiteSpace(guestName))
{
throw new ArgumentException("Guest name cannot be empty.", nameof(guestName));
}
var config = await GetSystemConfigAsync();
// Format clean username base
string cleanName = Regex.Replace(guestName.Trim().ToLowerInvariant(), @"[^a-z0-9]", "");
if (string.IsNullOrEmpty(cleanName)) cleanName = "guest";
if (cleanName.Length > 12) cleanName = cleanName[..12];
string username = $"{cleanName}_{RandomNumberGenerator.GetInt32(1000, 9999)}";
string password = GenerateReadablePassword(8);
// Save Cleartext-Password for FreeRADIUS
_db.RadCheck.Add(new RadCheck
{
Username = username,
Attribute = "Cleartext-Password",
Op = ":=",
Value = password
});
// Set Session-Timeout (in seconds)
long sessionTimeoutSeconds = (long)config.DefaultSessionTimeMinutes * 60;
_db.RadReply.Add(new RadReply
{
Username = username,
Attribute = "Session-Timeout",
Op = "=",
Value = sessionTimeoutSeconds.ToString()
});
// Set Max-Octets (in bytes)
long maxOctetsBytes = config.DefaultDataLimitMb * 1024 * 1024;
_db.RadReply.Add(new RadReply
{
Username = username,
Attribute = "Max-Octets",
Op = "=",
Value = maxOctetsBytes.ToString()
});
// Create Guest Profile record
var profile = new GuestProfile
{
GuestName = guestName.Trim(),
Username = username,
CreatedAt = DateTime.UtcNow,
SessionTimeMinutes = config.DefaultSessionTimeMinutes,
DataLimitMb = config.DefaultDataLimitMb,
Status = "Active"
};
_db.GuestProfiles.Add(profile);
await _db.SaveChangesAsync();
return new RegistrationResultDto
{
GuestName = profile.GuestName,
Username = username,
Password = password,
SessionTimeMinutes = config.DefaultSessionTimeMinutes,
DataLimitMb = config.DefaultDataLimitMb,
ExpirationEstimate = DateTime.UtcNow.AddMinutes(config.DefaultSessionTimeMinutes)
};
}
public async Task<List<UserUsageDto>> GetAllUsersWithUsageAsync()
{
var profiles = await _db.GuestProfiles.OrderByDescending(p => p.CreatedAt).ToListAsync();
var passwords = await _db.RadCheck
.Where(rc => rc.Attribute == "Cleartext-Password")
.ToDictionaryAsync(rc => rc.Username, rc => rc.Value);
// Group accounting data by username
var acctStats = await _db.RadAcct
.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);
var result = new List<UserUsageDto>();
foreach (var p in profiles)
{
passwords.TryGetValue(p.Username, out var pwd);
acctStats.TryGetValue(p.Username, out var acct);
result.Add(new UserUsageDto
{
ProfileId = p.Id,
GuestName = p.GuestName,
Username = p.Username,
Password = pwd ?? "••••••••",
CreatedAt = p.CreatedAt,
SessionTimeMinutes = p.SessionTimeMinutes,
DataLimitMb = p.DataLimitMb,
Status = p.Status,
TotalInputOctets = acct?.TotalInput ?? 0,
TotalOutputOctets = acct?.TotalOutput ?? 0,
TotalSessionTimeSeconds = acct?.TotalSessionTime ?? 0,
IsCurrentlyConnected = acct?.IsConnected ?? false
});
}
return result;
}
public async Task<List<RadAcct>> GetActiveSessionsAsync()
{
return await _db.RadAcct
.Where(ra => ra.AcctStopTime == null)
.OrderByDescending(ra => ra.AcctStartTime)
.ToListAsync();
}
public async Task UpdateUserLimitsAsync(int profileId, int newSessionTimeMinutes, long newDataLimitMb)
{
var profile = await _db.GuestProfiles.FindAsync(profileId);
if (profile == null) return;
profile.SessionTimeMinutes = newSessionTimeMinutes;
profile.DataLimitMb = newDataLimitMb;
// Update RadReply Session-Timeout
var sessionReply = await _db.RadReply.FirstOrDefaultAsync(rr => rr.Username == profile.Username && rr.Attribute == "Session-Timeout");
if (sessionReply != null)
{
sessionReply.Value = (newSessionTimeMinutes * 60).ToString();
}
else
{
_db.RadReply.Add(new RadReply
{
Username = profile.Username,
Attribute = "Session-Timeout",
Op = "=",
Value = (newSessionTimeMinutes * 60).ToString()
});
}
// Update RadReply Max-Octets
var dataReply = await _db.RadReply.FirstOrDefaultAsync(rr => rr.Username == profile.Username && rr.Attribute == "Max-Octets");
long maxOctetsBytes = newDataLimitMb * 1024 * 1024;
if (dataReply != null)
{
dataReply.Value = maxOctetsBytes.ToString();
}
else
{
_db.RadReply.Add(new RadReply
{
Username = profile.Username,
Attribute = "Max-Octets",
Op = "=",
Value = maxOctetsBytes.ToString()
});
}
await _db.SaveChangesAsync();
}
public async Task RevokeUserAsync(int profileId)
{
var profile = await _db.GuestProfiles.FindAsync(profileId);
if (profile == null) return;
profile.Status = "Revoked";
// Remove Cleartext-Password and insert Auth-Type := Reject
var checks = await _db.RadCheck.Where(rc => rc.Username == profile.Username).ToListAsync();
_db.RadCheck.RemoveRange(checks);
_db.RadCheck.Add(new RadCheck
{
Username = profile.Username,
Attribute = "Auth-Type",
Op = ":=",
Value = "Reject"
});
await _db.SaveChangesAsync();
}
public async Task DeleteUserAsync(int profileId)
{
var profile = await _db.GuestProfiles.FindAsync(profileId);
if (profile == null) return;
var username = profile.Username;
var checks = await _db.RadCheck.Where(rc => rc.Username == username).ToListAsync();
var replies = await _db.RadReply.Where(rr => rr.Username == username).ToListAsync();
_db.RadCheck.RemoveRange(checks);
_db.RadReply.RemoveRange(replies);
_db.GuestProfiles.Remove(profile);
await _db.SaveChangesAsync();
}
public async Task ResetUserPasswordAsync(int profileId, string newPassword)
{
var profile = await _db.GuestProfiles.FindAsync(profileId);
if (profile == null) return;
var check = await _db.RadCheck.FirstOrDefaultAsync(rc => rc.Username == profile.Username && rc.Attribute == "Cleartext-Password");
if (check != null)
{
check.Value = newPassword;
}
else
{
_db.RadCheck.Add(new RadCheck
{
Username = profile.Username,
Attribute = "Cleartext-Password",
Op = ":=",
Value = newPassword
});
}
if (profile.Status == "Revoked")
{
profile.Status = "Active";
var rejectCheck = await _db.RadCheck.FirstOrDefaultAsync(rc => rc.Username == profile.Username && rc.Attribute == "Auth-Type");
if (rejectCheck != null)
{
_db.RadCheck.Remove(rejectCheck);
}
}
await _db.SaveChangesAsync();
}
public async Task EnsureDatabaseCreatedAndSeededAsync()
{
try
{
await _db.Database.EnsureCreatedAsync();
if (!await _db.AdminUsers.AnyAsync())
{
// Default admin user: admin / admin123
string hash = BCrypt.Net.BCrypt.HashPassword("admin123");
_db.AdminUsers.Add(new AdminUser
{
Username = "admin",
PasswordHash = hash,
CreatedAt = DateTime.UtcNow
});
}
if (!await _db.SystemSettings.AnyAsync())
{
_db.SystemSettings.Add(new SystemSetting { Key = "default_session_time", Value = "60", UpdatedAt = DateTime.UtcNow });
_db.SystemSettings.Add(new SystemSetting { Key = "default_data_limit", Value = "500", UpdatedAt = DateTime.UtcNow });
}
await _db.SaveChangesAsync();
}
catch (Exception ex)
{
_logger.LogError(ex, "Database initialization warning (Database might be populated via Docker init.sql script).");
}
}
private static string GenerateReadablePassword(int length)
{
const string chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
var result = new char[length];
for (int i = 0; i < length; i++)
{
result[i] = chars[RandomNumberGenerator.GetInt32(chars.Length)];
}
return new string(result);
}
}
@@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}
+12
View File
@@ -0,0 +1,12 @@
{
"ConnectionStrings": {
"DefaultConnection": "Host=localhost;Port=5432;Database=radius;Username=radius;Password=radpass"
},
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
}
+21
View File
@@ -0,0 +1,21 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
</PropertyGroup>
<ItemGroup>
<Content Include="..\.dockerignore">
<Link>.dockerignore</Link>
</Content>
</ItemGroup>
<ItemGroup>
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.3" />
<PackageReference Include="BCrypt.Net-Next" Version="4.0.3" />
</ItemGroup>
</Project>
+528
View File
@@ -0,0 +1,528 @@
:root {
--bg-dark: #0b0f19;
--bg-card: rgba(19, 26, 43, 0.7);
--bg-card-border: rgba(255, 255, 255, 0.08);
--bg-card-hover: rgba(30, 41, 67, 0.8);
--primary: #6366f1;
--primary-hover: #4f46e5;
--primary-glow: rgba(99, 102, 241, 0.35);
--accent-teal: #14b8a6;
--accent-purple: #a855f7;
--accent-amber: #f59e0b;
--accent-rose: #f43f5e;
--accent-emerald: #10b981;
--text-main: #f8fafc;
--text-muted: #94a3b8;
--text-dim: #64748b;
--radius-sm: 8px;
--radius-md: 14px;
--radius-lg: 20px;
--radius-xl: 28px;
--shadow-glow: 0 0 25px rgba(99, 102, 241, 0.15);
--shadow-card: 0 10px 30px -5px rgba(0, 0, 0, 0.5);
--font-sans: 'Plus Jakarta Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
--font-mono: 'JetBrains Mono', monospace;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: var(--font-sans);
background-color: var(--bg-dark);
background-image:
radial-gradient(at 0% 0%, rgba(99, 102, 241, 0.12) 0px, transparent 50%),
radial-gradient(at 100% 0%, rgba(168, 85, 247, 0.1) 0px, transparent 50%),
radial-gradient(at 50% 100%, rgba(20, 184, 166, 0.08) 0px, transparent 50%);
background-attachment: fixed;
color: var(--text-main);
min-height: 100vh;
line-height: 1.6;
-webkit-font-smoothing: antialiased;
}
/* Scrollbar */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: rgba(11, 15, 25, 0.8);
}
::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.15);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.25);
}
/* App Container */
.app-container {
display: flex;
flex-direction: column;
min-height: 100vh;
}
/* Header Navbar */
.app-header {
background: rgba(11, 15, 25, 0.85);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border-bottom: 1px solid var(--bg-card-border);
position: sticky;
top: 0;
z-index: 100;
padding: 0.85rem 2rem;
display: flex;
align-items: center;
justify-content: space-between;
}
.brand-logo {
display: flex;
align-items: center;
gap: 0.75rem;
text-decoration: none;
color: var(--text-main);
font-weight: 800;
font-size: 1.25rem;
letter-spacing: -0.02em;
}
.brand-icon {
width: 40px;
height: 40px;
border-radius: var(--radius-sm);
background: linear-gradient(135deg, var(--primary), var(--accent-purple));
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 1.25rem;
box-shadow: 0 0 15px var(--primary-glow);
}
.nav-links {
display: flex;
align-items: center;
gap: 1rem;
}
.nav-item {
color: var(--text-muted);
text-decoration: none;
font-weight: 600;
font-size: 0.9rem;
padding: 0.5rem 1rem;
border-radius: var(--radius-sm);
transition: all 0.2s ease;
display: flex;
align-items: center;
gap: 0.5rem;
}
.nav-item:hover, .nav-item.active {
color: var(--text-main);
background: rgba(255, 255, 255, 0.05);
}
.nav-item.admin-btn {
background: linear-gradient(135deg, rgba(99, 102, 241, 0.2), rgba(168, 85, 247, 0.2));
border: 1px solid rgba(99, 102, 241, 0.4);
color: #a5b4fc;
}
.nav-item.admin-btn:hover {
background: linear-gradient(135deg, rgba(99, 102, 241, 0.35), rgba(168, 85, 247, 0.35));
color: white;
box-shadow: var(--shadow-glow);
}
/* Layout Body */
.main-content {
flex: 1;
padding: 2.5rem 2rem;
max-width: 1320px;
width: 100%;
margin: 0 auto;
}
/* Glassmorphism Cards */
.glass-card {
background: var(--bg-card);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border: 1px solid var(--bg-card-border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-card);
padding: 2rem;
transition: transform 0.25s ease, border-color 0.25s ease, box-shadow 0.25s ease;
}
.glass-card.hover-lift:hover {
transform: translateY(-4px);
border-color: rgba(99, 102, 241, 0.3);
box-shadow: var(--shadow-card), var(--shadow-glow);
}
/* Hero & Guest Portal Styling */
.portal-wrapper {
max-width: 580px;
margin: 2rem auto;
}
.portal-header {
text-align: center;
margin-bottom: 2rem;
}
.portal-title {
font-size: 2.2rem;
font-weight: 800;
letter-spacing: -0.03em;
background: linear-gradient(135deg, #ffffff 30%, var(--text-muted));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
margin-bottom: 0.5rem;
}
.portal-subtitle {
color: var(--text-muted);
font-size: 1rem;
}
/* Forms */
.form-group {
margin-bottom: 1.5rem;
}
.form-label {
display: block;
font-size: 0.875rem;
font-weight: 600;
color: var(--text-muted);
margin-bottom: 0.5rem;
}
.form-control-glass {
width: 100%;
background: rgba(11, 15, 25, 0.6);
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: var(--radius-md);
padding: 0.9rem 1.2rem;
color: var(--text-main);
font-size: 1rem;
font-family: inherit;
transition: all 0.2s ease;
}
.form-control-glass:focus {
outline: none;
border-color: var(--primary);
box-shadow: 0 0 0 3px var(--primary-glow);
background: rgba(11, 15, 25, 0.85);
}
.btn-gradient-primary {
width: 100%;
background: linear-gradient(135deg, var(--primary), var(--primary-hover));
color: white;
border: none;
border-radius: var(--radius-md);
padding: 0.95rem 1.5rem;
font-size: 1rem;
font-weight: 700;
cursor: pointer;
transition: all 0.25s ease;
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
box-shadow: 0 4px 15px var(--primary-glow);
}
.btn-gradient-primary:hover {
transform: translateY(-2px);
box-shadow: 0 8px 25px rgba(99, 102, 241, 0.45);
}
.btn-gradient-primary:disabled {
opacity: 0.6;
cursor: not-allowed;
transform: none;
}
/* Credentials Output Box */
.credentials-box {
background: linear-gradient(135deg, rgba(20, 184, 166, 0.08), rgba(99, 102, 241, 0.12));
border: 1px solid rgba(20, 184, 166, 0.3);
border-radius: var(--radius-md);
padding: 1.75rem;
margin-top: 1.5rem;
animation: fadeIn 0.4s ease;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
.cred-row {
display: flex;
align-items: center;
justify-content: space-between;
background: rgba(11, 15, 25, 0.7);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: var(--radius-sm);
padding: 0.75rem 1rem;
margin-bottom: 0.75rem;
}
.cred-label {
font-size: 0.8rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-muted);
}
.cred-value {
font-family: var(--font-mono);
font-size: 1.15rem;
font-weight: 700;
color: var(--accent-teal);
letter-spacing: 0.05em;
}
.copy-btn {
background: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(255, 255, 255, 0.1);
color: var(--text-main);
padding: 0.35rem 0.75rem;
border-radius: var(--radius-sm);
font-size: 0.8rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
}
.copy-btn:hover {
background: rgba(255, 255, 255, 0.18);
}
/* Stats Cards Grid */
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 1.5rem;
margin-bottom: 2rem;
}
.stat-card {
background: var(--bg-card);
border: 1px solid var(--bg-card-border);
border-radius: var(--radius-md);
padding: 1.5rem;
display: flex;
align-items: center;
gap: 1.25rem;
}
.stat-icon {
width: 52px;
height: 52px;
border-radius: var(--radius-sm);
display: flex;
align-items: center;
justify-content: center;
font-size: 1.5rem;
}
.stat-icon.primary { background: rgba(99, 102, 241, 0.15); color: var(--primary); }
.stat-icon.teal { background: rgba(20, 184, 166, 0.15); color: var(--accent-teal); }
.stat-icon.amber { background: rgba(245, 158, 11, 0.15); color: var(--accent-amber); }
.stat-icon.emerald { background: rgba(16, 185, 129, 0.15); color: var(--accent-emerald); }
.stat-number {
font-size: 1.75rem;
font-weight: 800;
line-height: 1.2;
}
.stat-label {
font-size: 0.85rem;
color: var(--text-muted);
font-weight: 500;
}
/* Data Table */
.table-responsive {
overflow-x: auto;
border-radius: var(--radius-md);
border: 1px solid var(--bg-card-border);
}
.custom-table {
width: 100%;
border-collapse: collapse;
text-align: left;
font-size: 0.925rem;
}
.custom-table th {
background: rgba(15, 22, 38, 0.95);
color: var(--text-muted);
font-weight: 700;
text-transform: uppercase;
font-size: 0.75rem;
letter-spacing: 0.06em;
padding: 1rem 1.25rem;
border-bottom: 1px solid var(--bg-card-border);
}
.custom-table td {
padding: 1rem 1.25rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.04);
color: var(--text-main);
vertical-align: middle;
}
.custom-table tbody tr {
transition: background 0.15s ease;
}
.custom-table tbody tr:hover {
background: rgba(255, 255, 255, 0.03);
}
/* Badges */
.badge {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.3rem 0.75rem;
border-radius: 50px;
font-size: 0.75rem;
font-weight: 700;
letter-spacing: 0.02em;
}
.badge-emerald { background: rgba(16, 185, 129, 0.15); color: #34d399; border: 1px solid rgba(16, 185, 129, 0.3); }
.badge-amber { background: rgba(245, 158, 11, 0.15); color: #fbbf24; border: 1px solid rgba(245, 158, 11, 0.3); }
.badge-rose { background: rgba(244, 63, 94, 0.15); color: #fda4af; border: 1px solid rgba(244, 63, 94, 0.3); }
.badge-primary { background: rgba(99, 102, 241, 0.15); color: #a5b4fc; border: 1px solid rgba(99, 102, 241, 0.3); }
/* Progress Bar */
.progress-bar-bg {
background: rgba(255, 255, 255, 0.08);
height: 8px;
border-radius: 4px;
overflow: hidden;
width: 100%;
margin-top: 0.35rem;
}
.progress-bar-fill {
height: 100%;
border-radius: 4px;
transition: width 0.4s ease;
}
.progress-bar-fill.normal { background: linear-gradient(90deg, var(--accent-teal), var(--primary)); }
.progress-bar-fill.warning { background: linear-gradient(90deg, var(--accent-amber), var(--accent-rose)); }
/* Tabs */
.tab-nav {
display: flex;
gap: 0.5rem;
border-bottom: 1px solid var(--bg-card-border);
margin-bottom: 2rem;
}
.tab-btn {
background: none;
border: none;
color: var(--text-muted);
font-size: 0.95rem;
font-weight: 600;
padding: 0.85rem 1.5rem;
cursor: pointer;
position: relative;
transition: color 0.2s ease;
display: flex;
align-items: center;
gap: 0.5rem;
}
.tab-btn:hover {
color: var(--text-main);
}
.tab-btn.active {
color: var(--primary);
}
.tab-btn.active::after {
content: '';
position: absolute;
bottom: -1px;
left: 0;
right: 0;
height: 2px;
background: var(--primary);
border-radius: 2px;
box-shadow: var(--shadow-glow);
}
/* Action Buttons */
.btn-sm {
padding: 0.4rem 0.75rem;
font-size: 0.8rem;
border-radius: var(--radius-sm);
font-weight: 600;
cursor: pointer;
border: none;
transition: all 0.2s ease;
display: inline-flex;
align-items: center;
gap: 0.35rem;
}
.btn-secondary {
background: rgba(255, 255, 255, 0.08);
color: var(--text-main);
border: 1px solid rgba(255, 255, 255, 0.1);
}
.btn-secondary:hover {
background: rgba(255, 255, 255, 0.16);
}
.btn-danger {
background: rgba(244, 63, 94, 0.15);
color: #fda4af;
border: 1px solid rgba(244, 63, 94, 0.3);
}
.btn-danger:hover {
background: rgba(244, 63, 94, 0.3);
color: white;
}
/* Footer */
.app-footer {
border-top: 1px solid var(--bg-card-border);
padding: 1.5rem 2rem;
text-align: center;
color: var(--text-dim);
font-size: 0.85rem;
}