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
+25
View File
@@ -0,0 +1,25 @@
**/.dockerignore
**/.env
**/.git
**/.gitignore
**/.project
**/.settings
**/.toolstarget
**/.vs
**/.vscode
**/.idea
**/*.*proj.user
**/*.dbmdl
**/*.jfm
**/azds.yaml
**/bin
**/charts
**/docker-compose*
**/Dockerfile*
**/node_modules
**/npm-debug.log
**/obj
**/secrets.dev.yaml
**/values.dev.yaml
LICENSE
README.md
+5
View File
@@ -0,0 +1,5 @@
bin/
obj/
/packages/
riderModule.iml
/_ReSharper.Caches/
+15
View File
@@ -0,0 +1,15 @@
# Default ignored files
/shelf/
/workspace.xml
# Rider ignored files
/.idea.radiuscontroller.iml
/modules.xml
/projectSettingsUpdater.xml
/contentModel.xml
# Editor-based HTTP Client requests
/httpRequests/
# Ignored default folder with query files
/queries/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml
+4
View File
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="Encoding" addBOMForNewFiles="with BOM under Windows, with no BOM otherwise" />
</project>
+8
View File
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="UserContentModel">
<attachedFolders />
<explicitIncludes />
<explicitExcludes />
</component>
</project>
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" />
</component>
</project>
+48
View File
@@ -0,0 +1,48 @@
version: '3.8'
services:
postgres:
image: postgres:16-alpine
container_name: radius_postgres
restart: always
environment:
POSTGRES_DB: radius
POSTGRES_USER: radius
POSTGRES_PASSWORD: radpass
ports:
- "5432:5432"
volumes:
- pgdata:/var/lib/postgresql/data
- ./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:
build:
context: .
dockerfile: radiuscontroller/Dockerfile
container_name: radius_web_controller
restart: always
depends_on:
- postgres
ports:
- "8080:8080"
environment:
- ConnectionStrings__DefaultConnection=Host=postgres;Port=5432;Database=radius;Username=radius;Password=radpass;
- ASPNETCORE_ENVIRONMENT=Production
volumes:
pgdata:
+16
View File
@@ -0,0 +1,16 @@
Microsoft Visual Studio Solution File, Format Version 12.00
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "radiuscontroller", "radiuscontroller\radiuscontroller.csproj", "{A5CA2077-CB9E-4A1F-A7D4-00F69BCD99E3}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{A5CA2077-CB9E-4A1F-A7D4-00F69BCD99E3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{A5CA2077-CB9E-4A1F-A7D4-00F69BCD99E3}.Debug|Any CPU.Build.0 = Debug|Any CPU
{A5CA2077-CB9E-4A1F-A7D4-00F69BCD99E3}.Release|Any CPU.ActiveCfg = Release|Any CPU
{A5CA2077-CB9E-4A1F-A7D4-00F69BCD99E3}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
EndGlobal
+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;
}
+120
View File
@@ -0,0 +1,120 @@
-- Initialize PostgreSQL database for FreeRADIUS and RadiusController App
-- Standard FreeRADIUS PostgreSQL Schema
CREATE TABLE IF NOT EXISTS radcheck (
id SERIAL PRIMARY KEY,
username VARCHAR(64) NOT NULL DEFAULT '',
attribute VARCHAR(64) NOT NULL DEFAULT '',
op VARCHAR(2) NOT NULL DEFAULT ':=',
value VARCHAR(253) NOT NULL DEFAULT ''
);
CREATE INDEX IF NOT EXISTS radcheck_username ON radcheck (username);
CREATE TABLE IF NOT EXISTS radreply (
id SERIAL PRIMARY KEY,
username VARCHAR(64) NOT NULL DEFAULT '',
attribute VARCHAR(64) NOT NULL DEFAULT '',
op VARCHAR(2) NOT NULL DEFAULT '=',
value VARCHAR(253) NOT NULL DEFAULT ''
);
CREATE INDEX IF NOT EXISTS radreply_username ON radreply (username);
CREATE TABLE IF NOT EXISTS radgroupcheck (
id SERIAL PRIMARY KEY,
groupname VARCHAR(64) NOT NULL DEFAULT '',
attribute VARCHAR(64) NOT NULL DEFAULT '',
op VARCHAR(2) NOT NULL DEFAULT ':=',
value VARCHAR(253) NOT NULL DEFAULT ''
);
CREATE INDEX IF NOT EXISTS radgroupcheck_groupname ON radgroupcheck (groupname);
CREATE TABLE IF NOT EXISTS radgroupreply (
id SERIAL PRIMARY KEY,
groupname VARCHAR(64) NOT NULL DEFAULT '',
attribute VARCHAR(64) NOT NULL DEFAULT '',
op VARCHAR(2) NOT NULL DEFAULT '=',
value VARCHAR(253) NOT NULL DEFAULT ''
);
CREATE INDEX IF NOT EXISTS radgroupreply_groupname ON radgroupreply (groupname);
CREATE TABLE IF NOT EXISTS radusergroup (
id SERIAL PRIMARY KEY,
username VARCHAR(64) NOT NULL DEFAULT '',
groupname VARCHAR(64) NOT NULL DEFAULT '',
priority INT NOT NULL DEFAULT 1
);
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 '',
username VARCHAR(64) NOT NULL DEFAULT '',
realm VARCHAR(64) DEFAULT '',
nasipaddress VARCHAR(15) NOT NULL DEFAULT '',
nasportid VARCHAR(32) DEFAULT NULL,
nasporttype VARCHAR(32) DEFAULT NULL,
acctstarttime TIMESTAMP WITH TIME ZONE DEFAULT NULL,
acctupdatetime TIMESTAMP WITH TIME ZONE DEFAULT NULL,
acctstoptime TIMESTAMP WITH TIME ZONE DEFAULT NULL,
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,
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 '',
servicetype VARCHAR(32) DEFAULT NULL,
framedprotocol VARCHAR(32) DEFAULT NULL,
framedipaddress VARCHAR(15) NOT NULL DEFAULT ''
);
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 TABLE IF NOT EXISTS nas (
id SERIAL PRIMARY KEY,
nasname VARCHAR(128) NOT NULL,
shortname VARCHAR(32),
type VARCHAR(30) DEFAULT 'other',
ports INT,
secret VARCHAR(60) NOT NULL,
server VARCHAR(64),
community VARCHAR(50),
description VARCHAR(200) DEFAULT 'RADIUS Client'
);
-- Application Management Tables
CREATE TABLE IF NOT EXISTS guest_profiles (
id SERIAL PRIMARY KEY,
guest_name VARCHAR(255) NOT NULL,
username VARCHAR(64) NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
session_time_minutes INT NOT NULL DEFAULT 60,
data_limit_mb BIGINT NOT NULL DEFAULT 500,
status VARCHAR(32) NOT NULL DEFAULT 'Active'
);
CREATE TABLE IF NOT EXISTS admin_users (
id SERIAL PRIMARY KEY,
username VARCHAR(64) UNIQUE NOT NULL,
password_hash VARCHAR(255) NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS system_settings (
key VARCHAR(64) PRIMARY KEY,
value VARCHAR(255) NOT NULL,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
-- Seed Default Settings
INSERT INTO system_settings (key, value) VALUES ('default_session_time', '60') ON CONFLICT (key) DO NOTHING;
INSERT INTO system_settings (key, value) VALUES ('default_data_limit', '500') ON CONFLICT (key) DO NOTHING;
-- Seed Default Admin User: admin / admin123 (BCrypt hash)
INSERT INTO admin_users (username, password_hash)
VALUES ('admin', '$2a$11$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy')
ON CONFLICT (username) DO NOTHING;