Compare commits
39 Commits
a5d57d3202
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| f8951feab9 | |||
| 3b20b7481e | |||
| 87d6d7e764 | |||
| d88e03ea09 | |||
| 6371826f5e | |||
| be26d3f461 | |||
| f5d2144f79 | |||
| 74326870f7 | |||
| 4b8e4639e5 | |||
| 926d2a1fc5 | |||
| 63082d0791 | |||
| b730dced16 | |||
| 0e4189985e | |||
| 6e065cf47c | |||
| 63b9a97cdf | |||
| 9c2cbfa7b9 | |||
| d1b93191e1 | |||
| d7d493367f | |||
| 6d95c12304 | |||
| aebf757f26 | |||
| c85b598b82 | |||
| 77a4e31626 | |||
| c4ed400ac6 | |||
| 44290fa89c | |||
| 9dc029e32f | |||
| 1054dfb61a | |||
| 9a6b1131b1 | |||
| c7e18c76ac | |||
| 574bcbe69a | |||
| 0bb36ea502 | |||
| f7663a284e | |||
| fd133bf9f3 | |||
| 824f0554e4 | |||
| c636794e5e | |||
| cd2ee7bddd | |||
| 4b8396b6a6 | |||
| 09de033d70 | |||
| 91a2aeb3fa | |||
| 21118838c0 |
@@ -0,0 +1,167 @@
|
||||
# RadiusController & FreeRADIUS - UniFi Setup Guide
|
||||
|
||||
This project provides a complete **RADIUS Authentication & Accounting Controller** for Wi-Fi networks, integrated with **FreeRADIUS**, **PostgreSQL**, and a **Blazor Web Application** for Guest Portal and Admin management.
|
||||
|
||||
---
|
||||
|
||||
## 🏗️ System Architecture
|
||||
|
||||
```
|
||||
┌─────────────────┐ UDP 1812/1813 (RADIUS)
|
||||
│ UniFi AP / │ ◄────────────────────────────────┐
|
||||
│ Dream Machine │ │
|
||||
└─────────────────┘ │
|
||||
▼
|
||||
┌─────────────────┐ HTTP 8080 (Web UI) ┌────────────────────────────────┐
|
||||
│ Guests / Admins │ ◄────────────────────────► │ Combined Application Container │
|
||||
│ (Browser) │ │ (FreeRADIUS + Web Controller) │
|
||||
└─────────────────┘ └───────────────┬────────────────┘
|
||||
│ PostgreSQL
|
||||
▼
|
||||
┌────────────────┐
|
||||
│ PostgreSQL │
|
||||
│ (Database) │
|
||||
└────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Step 1: Deploying with Docker Compose (Unraid / Docker)
|
||||
|
||||
1. Ensure your `docker-compose.yml` is configured with persistent volume paths (e.g. for Unraid):
|
||||
|
||||
```yaml
|
||||
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:
|
||||
- /mnt/user/appdata/radiuscontroller/pgdata:/var/lib/postgresql/data
|
||||
- /mnt/user/appdata/radiuscontroller/sql/init.sql:/docker-entrypoint-initdb.d/init.sql:ro
|
||||
|
||||
radiuscontroller:
|
||||
image: cablon.vanolst.tech/tygozwolle/radius:latest
|
||||
container_name: radius_web_controller
|
||||
restart: always
|
||||
depends_on:
|
||||
- postgres
|
||||
ports:
|
||||
- "8080:8080"
|
||||
- "1812:1812/udp"
|
||||
- "1813:1813/udp"
|
||||
environment:
|
||||
- DB_HOST=postgres
|
||||
- DB_PORT=5432
|
||||
- DB_USER=radius
|
||||
- DB_PASS=radpass
|
||||
- DB_NAME=radius
|
||||
- RADIUS_SECRET=radpass
|
||||
- ConnectionStrings__DefaultConnection=Host=postgres;Port=5432;Database=radius;Username=radius;Password=radpass;
|
||||
- ASPNETCORE_ENVIRONMENT=Production
|
||||
volumes:
|
||||
- /mnt/user/appdata/radiuscontroller/certs:/etc/freeradius/3.0/certs
|
||||
```
|
||||
|
||||
2. Start the stack:
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔑 Step 2: Register UniFi as a NAS (RADIUS Client)
|
||||
|
||||
FreeRADIUS requires every Network Access Server (NAS) — such as your UniFi Dream Machine, Security Gateway, or Access Points — to be registered in the `nas` database table with a **shared secret**.
|
||||
|
||||
Connect to your PostgreSQL database (e.g. via `psql` or database manager) and insert your UniFi device:
|
||||
|
||||
```sql
|
||||
INSERT INTO nas (nasname, shortname, type, secret, description)
|
||||
VALUES ('192.168.1.1', 'unifi-gw', 'other', 'YourSharedSecret123', 'UniFi Gateway / Access Point');
|
||||
```
|
||||
|
||||
> 💡 **Tip:** If you have multiple UniFi Access Points or a subnet, you can add each AP's IP or register a CIDR subnet (e.g., `192.168.1.0/24`).
|
||||
|
||||
---
|
||||
|
||||
## 📶 Step 3: Configure UniFi Network Application
|
||||
|
||||
### A. Create a RADIUS Profile in UniFi
|
||||
|
||||
1. Open your **UniFi Network Application** (e.g. `https://192.168.1.1`).
|
||||
2. Go to **Settings** ⚙️ → **Profiles** → **RADIUS**.
|
||||
3. Click **Create New RADIUS Profile**.
|
||||
4. Configure the settings:
|
||||
- **Profile Name**: `RadiusController`
|
||||
- **VLAN Support**: Enable if using dynamic VLAN assignment via RADIUS.
|
||||
- **Authentication Servers**:
|
||||
- **IP Address**: Server IP running FreeRADIUS (e.g. `192.168.1.50`).
|
||||
- **Port**: `1812`
|
||||
- **Shared Secret**: Matches the secret in the `nas` table (`YourSharedSecret123`).
|
||||
- **Accounting Servers**:
|
||||
- **Enable Accounting**: `Checked`
|
||||
- **IP Address**: Server IP (`192.168.1.50`).
|
||||
- **Port**: `1813`
|
||||
- **Shared Secret**: Matches the secret in the `nas` table (`YourSharedSecret123`).
|
||||
5. Click **Save Changes**.
|
||||
|
||||
---
|
||||
|
||||
### B. Configure WPA Enterprise Wi-Fi Network
|
||||
|
||||
1. Go to **Settings** ⚙️ → **WiFi**.
|
||||
2. Click **Create New WiFi Network** (or edit an existing one, e.g. `Guest-WiFi`).
|
||||
3. Set the configuration:
|
||||
- **Name (SSID)**: e.g. `Secure Guest WiFi`
|
||||
- **Security Protocol**: **WPA2 Enterprise** or **WPA3 Enterprise**.
|
||||
- **RADIUS Profile**: Select `RadiusController`.
|
||||
4. Click **Save**.
|
||||
|
||||
---
|
||||
|
||||
## 📱 Step 4: User Authentication & Portal Workflow
|
||||
|
||||
1. **Guest Registration / Portal**:
|
||||
- Guests connect to the Web Portal at `http://<server-ip>:8080/portal` (or via reverse proxy).
|
||||
- Enter guest name to receive generated **Username** and **Password** (with session and data limits automatically assigned).
|
||||
|
||||
2. **Connecting to Wi-Fi**:
|
||||
- On their device (phone/laptop), select the `Secure Guest WiFi` network.
|
||||
- Select **EAP Method**: `PEAP` or `TTLS` (Phase 2 Auth: `MSCHAPv2` or `GTC`).
|
||||
- CA Certificate: `Do not validate` / `Unvalidated` (or install your custom CA if using custom EAP certs).
|
||||
- Enter the **Username** and **Password** generated from the portal.
|
||||
|
||||
3. **Admin Management & First-Time Setup**:
|
||||
- Access the Admin Dashboard at `http://<server-ip>:8080/admin`.
|
||||
- **First-Time Setup**: On initial startup, accessing the admin panel automatically presents the setup screen to create your custom administrator username and password.
|
||||
- Monitor active RADIUS sessions, data usage, enforce data limits, or revoke users.
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ Troubleshooting & Verification
|
||||
|
||||
### Test RADIUS Server locally
|
||||
Run `radtest` from another machine or inside the container to test authentication:
|
||||
```bash
|
||||
docker exec -it radius_freeradius radtest <username> <password> 127.0.0.1 0 testing123
|
||||
```
|
||||
|
||||
### View Live FreeRADIUS Logs
|
||||
```bash
|
||||
docker logs -f radius_freeradius
|
||||
```
|
||||
|
||||
### Verify Firewall Ports on Host / Unraid
|
||||
Ensure the following ports are open on your host firewall / router:
|
||||
- **`1812/UDP`**: RADIUS Authentication
|
||||
- **`1813/UDP`**: RADIUS Accounting
|
||||
- **`8080/TCP`**: Web Controller / Portal
|
||||
+12
-16
@@ -15,21 +15,6 @@ services:
|
||||
- /mnt/user/appdata/radiuscontroller/pgdata:/var/lib/postgresql/data
|
||||
- /mnt/user/appdata/radiuscontroller/sql/init.sql:/docker-entrypoint-initdb.d/init.sql:ro
|
||||
|
||||
freeradius:
|
||||
image: freeradius/freeradius-server:latest
|
||||
container_name: radius_freeradius
|
||||
restart: always
|
||||
depends_on:
|
||||
- postgres
|
||||
ports:
|
||||
- "1812:1812/udp"
|
||||
- "1813:1813/udp"
|
||||
environment:
|
||||
- DB_NAME=radius
|
||||
- DB_HOST=postgres
|
||||
- DB_USER=radius
|
||||
- DB_PASS=radpass
|
||||
|
||||
radiuscontroller:
|
||||
image: cablon.vanolst.tech/tygozwolle/radius:latest
|
||||
container_name: radius_web_controller
|
||||
@@ -38,7 +23,18 @@ services:
|
||||
- postgres
|
||||
ports:
|
||||
- "8080:8080"
|
||||
- "1812:1812/udp"
|
||||
- "1813:1813/udp"
|
||||
environment:
|
||||
- DB_HOST=postgres
|
||||
- DB_PORT=5432
|
||||
- DB_USER=radius
|
||||
- DB_PASS=radpass
|
||||
- DB_NAME=radius
|
||||
- RADIUS_SECRET=radpass
|
||||
- ConnectionStrings__DefaultConnection=Host=postgres;Port=5432;Database=radius;Username=radius;Password=radpass;
|
||||
- ASPNETCORE_ENVIRONMENT=Production
|
||||
|
||||
# Change 8080 below to whatever port you want the web UI to run on (e.g., 8090)
|
||||
- ASPNETCORE_URLS=http://+:8080
|
||||
volumes:
|
||||
- /mnt/user/appdata/radiuscontroller/certs:/etc/freeradius/3.0/certs
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
<wpf:ResourceDictionary xml:space="preserve" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:s="clr-namespace:System;assembly=mscorlib" xmlns:ss="urn:shemas-jetbrains-com:settings-storage-xaml" xmlns:wpf="http://schemas.microsoft.com/winfx/2006/xaml/presentation">
|
||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AComponentBase_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003FAppData_003FRoaming_003FJetBrains_003FRider2026_002E1_003Fresharper_002Dhost_003FSourcesCache_003F2b3cf01714fd98ff7f12316ce766442f76ff6a46a8ffb780ec8550953d47b94c_003FComponentBase_002Ecs/@EntryIndexedValue">ForceIncluded</s:String></wpf:ResourceDictionary>
|
||||
@@ -45,10 +45,18 @@
|
||||
</div>
|
||||
|
||||
@code {
|
||||
protected override async Task OnInitializedAsync()
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
AuthService.OnAuthStateChanged += StateHasChanged;
|
||||
await AuthService.InitializeAsync();
|
||||
}
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
if (firstRender)
|
||||
{
|
||||
await AuthService.InitializeAsync();
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task LogoutAdmin()
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
@inject IRadiusService RadiusService
|
||||
@inject AdminAuthService AuthService
|
||||
@inject NavigationManager NavManager
|
||||
@implements IDisposable
|
||||
|
||||
<div style="max-width: 1280px; margin: 0 auto;">
|
||||
@if (!AuthService.IsAuthenticated)
|
||||
@@ -23,9 +24,14 @@
|
||||
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 style="display: flex; gap: 0.5rem; align-items: center;">
|
||||
<button class="btn-sm btn-secondary" @onclick="RefreshData" disabled="@isLoading">
|
||||
<i class="bi bi-arrow-clockwise"></i> Refresh Data
|
||||
</button>
|
||||
<button class="btn-sm btn-danger" @onclick="LogoutAdmin">
|
||||
<i class="bi bi-box-arrow-right"></i> Logout (@AuthService.CurrentAdminUsername)
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (notificationMessage != null)
|
||||
@@ -167,7 +173,7 @@
|
||||
<td>
|
||||
<strong>@user.SessionTimeMinutes mins</strong>
|
||||
<div style="font-size: 0.75rem; color: var(--text-dim);">
|
||||
RADIUS Session-Timeout
|
||||
Expires: @user.CreatedAt.AddMinutes(user.SessionTimeMinutes).ToLocalTime().ToString("MMM dd, HH:mm")
|
||||
</div>
|
||||
</td>
|
||||
<td style="min-width: 180px;">
|
||||
@@ -196,7 +202,7 @@
|
||||
<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)">
|
||||
<button class="btn-sm btn-secondary" style="color: var(--accent-rose);" title="Delete User" @onclick="() => OpenDeleteModal(user)">
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
@@ -227,7 +233,6 @@
|
||||
class="form-control-glass"
|
||||
@bind="editConfigSessionTime"
|
||||
min="1"
|
||||
max="10080"
|
||||
required />
|
||||
<span style="font-size: 0.775rem; color: var(--text-dim);">
|
||||
Translates to RADIUS attribute <code style="color: var(--accent-teal);">Session-Timeout</code> (in seconds) for FreeRADIUS reply.
|
||||
@@ -240,7 +245,6 @@
|
||||
class="form-control-glass"
|
||||
@bind="editConfigDataLimit"
|
||||
min="1"
|
||||
max="1048576"
|
||||
required />
|
||||
<span style="font-size: 0.775rem; color: var(--text-dim);">
|
||||
Translates to RADIUS attribute <code style="color: var(--accent-teal);">Max-Octets</code> (in bytes) for FreeRADIUS reply.
|
||||
@@ -364,6 +368,37 @@
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Delete Confirmation Modal Overlay -->
|
||||
@if (selectedUserForDelete != 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: 480px; width: 100%; border-color: rgba(244, 63, 94, 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; color: var(--accent-rose);">
|
||||
<i class="bi bi-exclamation-triangle-fill"></i> Delete Guest Account
|
||||
</h3>
|
||||
<button class="btn-sm btn-secondary" @onclick="() => selectedUserForDelete = null">
|
||||
<i class="bi bi-x-lg"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p style="color: var(--text-muted); font-size: 0.95rem; margin-bottom: 1rem;">
|
||||
Are you sure you want to permanently delete guest account <strong style="color: var(--text-primary);">@selectedUserForDelete.GuestName</strong> (<code style="color: var(--accent-teal);">@selectedUserForDelete.Username</code>)?
|
||||
</p>
|
||||
<p style="color: var(--text-dim); font-size: 0.825rem; margin-bottom: 1.5rem;">
|
||||
This will remove their RADIUS credentials from <code style="color: var(--accent-teal);">radcheck</code> and <code style="color: var(--accent-teal);">radreply</code> tables. This action cannot be undone.
|
||||
</p>
|
||||
|
||||
<div style="display: flex; gap: 0.75rem; justify-content: flex-end;">
|
||||
<button class="btn-sm btn-secondary" @onclick="() => selectedUserForDelete = null">Cancel</button>
|
||||
<button class="btn-sm btn-danger" style="padding: 0.5rem 1.25rem;" @onclick="ConfirmDeleteUser">
|
||||
<i class="bi bi-trash-fill"></i> Delete User
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -372,6 +407,7 @@
|
||||
private string activeTab = "users";
|
||||
private string searchQuery = string.Empty;
|
||||
private string? notificationMessage;
|
||||
private System.Threading.Timer? autoRefreshTimer;
|
||||
|
||||
private SystemConfigDto? config;
|
||||
private List<UserUsageDto> users = new();
|
||||
@@ -385,16 +421,45 @@
|
||||
private long editModalDataLimit;
|
||||
private string editModalNewPassword = string.Empty;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await AuthService.InitializeAsync();
|
||||
if (!AuthService.IsAuthenticated)
|
||||
{
|
||||
NavManager.NavigateTo("/admin/login");
|
||||
return;
|
||||
}
|
||||
private UserUsageDto? selectedUserForDelete;
|
||||
|
||||
await LoadDataAsync();
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
if (firstRender)
|
||||
{
|
||||
await AuthService.InitializeAsync();
|
||||
if (!AuthService.IsAuthenticated)
|
||||
{
|
||||
NavManager.NavigateTo("/admin/login");
|
||||
return;
|
||||
}
|
||||
|
||||
await LoadDataAsync();
|
||||
StateHasChanged();
|
||||
|
||||
autoRefreshTimer = new System.Threading.Timer(async _ =>
|
||||
{
|
||||
await InvokeAsync(async () =>
|
||||
{
|
||||
if (AuthService.IsAuthenticated)
|
||||
{
|
||||
await LoadDataAsync();
|
||||
StateHasChanged();
|
||||
}
|
||||
});
|
||||
}, null, TimeSpan.FromSeconds(10), TimeSpan.FromSeconds(10));
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
autoRefreshTimer?.Dispose();
|
||||
}
|
||||
|
||||
private async Task LogoutAdmin()
|
||||
{
|
||||
await AuthService.LogoutAsync();
|
||||
NavManager.NavigateTo("/admin/login");
|
||||
}
|
||||
|
||||
private async Task LoadDataAsync()
|
||||
@@ -495,13 +560,23 @@
|
||||
}
|
||||
}
|
||||
|
||||
private async Task DeleteUser(int profileId)
|
||||
private void OpenDeleteModal(UserUsageDto user)
|
||||
{
|
||||
selectedUserForDelete = user;
|
||||
}
|
||||
|
||||
private async Task ConfirmDeleteUser()
|
||||
{
|
||||
if (selectedUserForDelete == null) return;
|
||||
|
||||
int profileId = selectedUserForDelete.ProfileId;
|
||||
selectedUserForDelete = null;
|
||||
|
||||
try
|
||||
{
|
||||
await RadiusService.DeleteUserAsync(profileId);
|
||||
await LoadDataAsync();
|
||||
ShowNotification("User deleted.");
|
||||
ShowNotification("User account deleted successfully.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
||||
@@ -8,72 +8,205 @@
|
||||
<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>
|
||||
@if (isFirstRun)
|
||||
{
|
||||
<h1 class="portal-title">First-Time Setup</h1>
|
||||
<p class="portal-subtitle">Create your primary administrator account to secure the RADIUS Controller panel</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<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 />
|
||||
@if (isCheckingFirstRun)
|
||||
{
|
||||
<div style="text-align: center; padding: 2rem;">
|
||||
<div class="spinner-border text-primary" role="status"></div>
|
||||
<p style="margin-top: 1rem; color: var(--text-muted); font-size: 0.9rem;">Checking system configuration...</p>
|
||||
</div>
|
||||
}
|
||||
else if (isFirstRun)
|
||||
{
|
||||
<div style="background: rgba(20, 184, 166, 0.1); border: 1px solid rgba(20, 184, 166, 0.3); border-radius: 8px; padding: 0.85rem 1rem; margin-bottom: 1.5rem; font-size: 0.85rem; color: var(--accent-teal);">
|
||||
<i class="bi bi-info-circle-fill"></i> <strong>Welcome!</strong> No admin user exists in the database. Please create your administrator credentials below.
|
||||
</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
|
||||
<EditForm Model="this" OnValidSubmit="HandleFirstTimeSetup">
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="setupUsername">Administrator Username</label>
|
||||
<input id="setupUsername"
|
||||
type="text"
|
||||
class="form-control-glass"
|
||||
placeholder="e.g. admin"
|
||||
@bind="username"
|
||||
required />
|
||||
</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 class="form-group">
|
||||
<label class="form-label" for="setupPassword">Password</label>
|
||||
<input id="setupPassword"
|
||||
type="password"
|
||||
class="form-control-glass"
|
||||
placeholder="••••••••"
|
||||
@bind="password"
|
||||
required />
|
||||
</div>
|
||||
|
||||
<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 class="form-group">
|
||||
<label class="form-label" for="confirmPassword">Confirm Password</label>
|
||||
<input id="confirmPassword"
|
||||
type="password"
|
||||
class="form-control-glass"
|
||||
placeholder="••••••••"
|
||||
@bind="confirmPassword"
|
||||
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>Creating Administrator Account...</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<i class="bi bi-person-check-fill"></i>
|
||||
<span>Create Admin Account & Log In</span>
|
||||
}
|
||||
</button>
|
||||
</EditForm>
|
||||
}
|
||||
else
|
||||
{
|
||||
<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="Username"
|
||||
@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>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
private string username = string.Empty;
|
||||
private string password = string.Empty;
|
||||
private string confirmPassword = string.Empty;
|
||||
|
||||
private bool isCheckingFirstRun = true;
|
||||
private bool isFirstRun = false;
|
||||
private bool isSubmitting = false;
|
||||
private string errorMessage = string.Empty;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
await AuthService.InitializeAsync();
|
||||
if (AuthService.IsAuthenticated)
|
||||
if (firstRender)
|
||||
{
|
||||
try
|
||||
{
|
||||
await AuthService.InitializeAsync();
|
||||
if (AuthService.IsAuthenticated)
|
||||
{
|
||||
NavManager.NavigateTo("/admin");
|
||||
return;
|
||||
}
|
||||
|
||||
isFirstRun = !await AuthService.HasAnyAdminAsync();
|
||||
}
|
||||
catch
|
||||
{
|
||||
isFirstRun = false;
|
||||
}
|
||||
finally
|
||||
{
|
||||
isCheckingFirstRun = false;
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandleFirstTimeSetup()
|
||||
{
|
||||
isSubmitting = true;
|
||||
errorMessage = string.Empty;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(username) || username.Trim().Length < 3)
|
||||
{
|
||||
errorMessage = "Username must be at least 3 characters long.";
|
||||
isSubmitting = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(password) || password.Length < 6)
|
||||
{
|
||||
errorMessage = "Password must be at least 6 characters long.";
|
||||
isSubmitting = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (password != confirmPassword)
|
||||
{
|
||||
errorMessage = "Password and Confirm Password do not match.";
|
||||
isSubmitting = false;
|
||||
return;
|
||||
}
|
||||
|
||||
bool success = await AuthService.RegisterInitialAdminAsync(username, password);
|
||||
if (success)
|
||||
{
|
||||
NavManager.NavigateTo("/admin");
|
||||
}
|
||||
else
|
||||
{
|
||||
errorMessage = "Failed to create administrator account. An admin user may already exist.";
|
||||
}
|
||||
|
||||
isSubmitting = false;
|
||||
}
|
||||
|
||||
private async Task HandleLogin()
|
||||
|
||||
@@ -14,6 +14,7 @@ public class RadiusDbContext : DbContext
|
||||
public DbSet<RadReply> RadReply { get; set; } = null!;
|
||||
public DbSet<RadAcct> RadAcct { get; set; } = null!;
|
||||
public DbSet<GuestProfile> GuestProfiles { get; set; } = null!;
|
||||
public DbSet<GuestDataUsage> GuestDataUsage { get; set; } = null!;
|
||||
public DbSet<AdminUser> AdminUsers { get; set; } = null!;
|
||||
public DbSet<SystemSetting> SystemSettings { get; set; } = null!;
|
||||
|
||||
@@ -25,6 +26,7 @@ public class RadiusDbContext : DbContext
|
||||
modelBuilder.Entity<RadReply>().ToTable("radreply");
|
||||
modelBuilder.Entity<RadAcct>().ToTable("radacct");
|
||||
modelBuilder.Entity<GuestProfile>().ToTable("guest_profiles");
|
||||
modelBuilder.Entity<GuestDataUsage>().ToTable("guest_data_usage");
|
||||
modelBuilder.Entity<AdminUser>().ToTable("admin_users");
|
||||
modelBuilder.Entity<SystemSetting>().ToTable("system_settings");
|
||||
}
|
||||
|
||||
@@ -1,8 +1,22 @@
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS base
|
||||
USER $APP_UID
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS base
|
||||
USER root
|
||||
|
||||
# Install FreeRADIUS, PostgreSQL module, and utility packages
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
freeradius \
|
||||
freeradius-postgresql \
|
||||
freeradius-utils \
|
||||
ca-certificates \
|
||||
openssl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Create symlink so both /etc/raddb and /etc/freeradius/3.0 work
|
||||
RUN ln -sf /etc/freeradius/3.0 /etc/raddb
|
||||
|
||||
WORKDIR /app
|
||||
EXPOSE 8080
|
||||
EXPOSE 8081
|
||||
EXPOSE 1812/udp
|
||||
EXPOSE 1813/udp
|
||||
|
||||
FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build
|
||||
ARG BUILD_CONFIGURATION=Release
|
||||
@@ -19,5 +33,21 @@ RUN dotnet publish "./radiuscontroller.csproj" -c $BUILD_CONFIGURATION -o /app/p
|
||||
|
||||
FROM base AS final
|
||||
WORKDIR /app
|
||||
|
||||
# Copy pre-configured FreeRADIUS PostgreSQL rules & EAP authentication config
|
||||
COPY radiuscontroller/raddb/clients.conf /etc/freeradius/3.0/clients.conf
|
||||
COPY radiuscontroller/raddb/mods-enabled/sql /etc/freeradius/3.0/mods-enabled/sql
|
||||
COPY radiuscontroller/raddb/mods-enabled/eap /etc/freeradius/3.0/mods-enabled/eap
|
||||
COPY radiuscontroller/raddb/sites-enabled/default /etc/freeradius/3.0/sites-enabled/default
|
||||
COPY radiuscontroller/raddb/sites-enabled/inner-tunnel /etc/freeradius/3.0/sites-enabled/inner-tunnel
|
||||
|
||||
# Add custom dictionary for Max-Octets attribute
|
||||
COPY radiuscontroller/raddb/dictionary.custom /etc/freeradius/3.0/dictionary.custom
|
||||
RUN echo '$INCLUDE /etc/freeradius/3.0/dictionary.custom' >> /etc/freeradius/3.0/dictionary
|
||||
|
||||
# Copy published application and entrypoint script
|
||||
COPY --from=publish /app/publish .
|
||||
ENTRYPOINT ["dotnet", "radiuscontroller.dll"]
|
||||
COPY radiuscontroller/entrypoint.sh /app/entrypoint.sh
|
||||
RUN chmod +x /app/entrypoint.sh
|
||||
|
||||
ENTRYPOINT ["/app/entrypoint.sh"]
|
||||
|
||||
@@ -105,11 +105,11 @@ public class RadAcct
|
||||
[Column("acctauthentic")]
|
||||
public string? AcctAuthentic { get; set; }
|
||||
|
||||
[Column("connectinfo_in")]
|
||||
public string? ConnectInfoIn { get; set; }
|
||||
[Column("connectinfo_start")]
|
||||
public string? ConnectInfoStart { get; set; }
|
||||
|
||||
[Column("connectinfo_out")]
|
||||
public string? ConnectInfoOut { get; set; }
|
||||
[Column("connectinfo_stop")]
|
||||
public string? ConnectInfoStop { get; set; }
|
||||
|
||||
[Column("acctinputoctets")]
|
||||
public long? AcctInputOctets { get; set; }
|
||||
@@ -134,6 +134,21 @@ public class RadAcct
|
||||
|
||||
[Column("framedipaddress")]
|
||||
public string? FramedIpAddress { get; set; }
|
||||
|
||||
[Column("framedipv6address")]
|
||||
public string? FramedIpV6Address { get; set; }
|
||||
|
||||
[Column("framedipv6prefix")]
|
||||
public string? FramedIpV6Prefix { get; set; }
|
||||
|
||||
[Column("framedinterfaceid")]
|
||||
public string? FramedInterfaceId { get; set; }
|
||||
|
||||
[Column("delegatedipv6prefix")]
|
||||
public string? DelegatedIpV6Prefix { get; set; }
|
||||
|
||||
[Column("class")]
|
||||
public string? Class { get; set; }
|
||||
}
|
||||
|
||||
[Table("guest_profiles")]
|
||||
@@ -164,6 +179,20 @@ public class GuestProfile
|
||||
public string Status { get; set; } = "Active";
|
||||
}
|
||||
|
||||
[Table("guest_data_usage")]
|
||||
public class GuestDataUsage
|
||||
{
|
||||
[Key]
|
||||
[Column("username")]
|
||||
public string Username { get; set; } = string.Empty;
|
||||
|
||||
[Column("total_input_octets")]
|
||||
public long TotalInputOctets { get; set; }
|
||||
|
||||
[Column("total_output_octets")]
|
||||
public long TotalOutputOctets { get; set; }
|
||||
}
|
||||
|
||||
[Table("admin_users")]
|
||||
public class AdminUser
|
||||
{
|
||||
|
||||
@@ -15,6 +15,7 @@ builder.Services.AddDbContext<RadiusDbContext>(options =>
|
||||
// Add services
|
||||
builder.Services.AddScoped<IRadiusService, RadiusService>();
|
||||
builder.Services.AddScoped<AdminAuthService>();
|
||||
builder.Services.AddHostedService<AccountEnforcementService>();
|
||||
|
||||
// Add Razor components
|
||||
builder.Services.AddRazorComponents()
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using radiuscontroller.Data;
|
||||
using radiuscontroller.Models;
|
||||
|
||||
namespace radiuscontroller.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Background service that periodically checks all active guest accounts
|
||||
/// and disables them if their time has expired or data cap has been reached.
|
||||
/// Time starts from the moment the user was created (guest_profiles.created_at).
|
||||
/// </summary>
|
||||
public class AccountEnforcementService : BackgroundService
|
||||
{
|
||||
private readonly IServiceScopeFactory _scopeFactory;
|
||||
private readonly ILogger<AccountEnforcementService> _logger;
|
||||
private static readonly TimeSpan CheckInterval = TimeSpan.FromSeconds(30);
|
||||
|
||||
public AccountEnforcementService(IServiceScopeFactory scopeFactory, ILogger<AccountEnforcementService> logger)
|
||||
{
|
||||
_scopeFactory = scopeFactory;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
_logger.LogInformation("AccountEnforcementService started. Checking every {Interval}s.", CheckInterval.TotalSeconds);
|
||||
|
||||
// Wait a bit for the app to fully start
|
||||
await Task.Delay(TimeSpan.FromSeconds(10), stoppingToken);
|
||||
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
await EnforceAccountLimitsAsync(stoppingToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error during account enforcement check.");
|
||||
}
|
||||
|
||||
await Task.Delay(CheckInterval, stoppingToken);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task EnforceAccountLimitsAsync(CancellationToken ct)
|
||||
{
|
||||
using var scope = _scopeFactory.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<RadiusDbContext>();
|
||||
|
||||
var activeProfiles = await db.GuestProfiles
|
||||
.Where(p => p.Status == "Active")
|
||||
.ToListAsync(ct);
|
||||
|
||||
if (activeProfiles.Count == 0) return;
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
var disabledCount = 0;
|
||||
var changesMade = false;
|
||||
|
||||
foreach (var profile in activeProfiles)
|
||||
{
|
||||
string? reason = null;
|
||||
|
||||
// 1. Check time expiration: created_at + session_time_minutes
|
||||
var expiresAt = profile.CreatedAt.AddMinutes(profile.SessionTimeMinutes);
|
||||
if (now >= expiresAt)
|
||||
{
|
||||
reason = "Time expired";
|
||||
}
|
||||
|
||||
// 2. Check data cap: read from guest_data_usage table
|
||||
if (reason == null && profile.DataLimitMb > 0)
|
||||
{
|
||||
var usage = await db.GuestDataUsage.FirstOrDefaultAsync(du => du.Username == profile.Username, ct);
|
||||
var totalOctets = (usage?.TotalInputOctets ?? 0) + (usage?.TotalOutputOctets ?? 0);
|
||||
|
||||
long dataLimitBytes = profile.DataLimitMb * 1024 * 1024;
|
||||
if (totalOctets >= dataLimitBytes)
|
||||
{
|
||||
reason = $"Data cap reached ({totalOctets / (1024 * 1024)} MB / {profile.DataLimitMb} MB)";
|
||||
}
|
||||
}
|
||||
|
||||
if (reason != null)
|
||||
{
|
||||
// Disable the account
|
||||
profile.Status = "Expired";
|
||||
|
||||
// Remove Cleartext-Password from radcheck so FreeRADIUS rejects future auth
|
||||
var checks = await db.RadCheck
|
||||
.Where(rc => rc.Username == profile.Username)
|
||||
.ToListAsync(ct);
|
||||
db.RadCheck.RemoveRange(checks);
|
||||
|
||||
// Insert Auth-Type := Reject to explicitly deny
|
||||
db.RadCheck.Add(new RadCheck
|
||||
{
|
||||
Username = profile.Username,
|
||||
Attribute = "Auth-Type",
|
||||
Op = ":=",
|
||||
Value = "Reject"
|
||||
});
|
||||
|
||||
_logger.LogInformation("Account '{Username}' disabled. Reason: {Reason}", profile.Username, reason);
|
||||
|
||||
// Find active sessions to disconnect them instantly via RADIUS CoA (PoD)
|
||||
var activeSessions = await db.RadAcct
|
||||
.Where(ra => ra.Username == profile.Username && ra.AcctStopTime == null)
|
||||
.Select(ra => new { ra.NasIpAddress, ra.CallingStationId, ra.AcctSessionId, ra.CalledStationId })
|
||||
.ToListAsync(ct);
|
||||
|
||||
var radiusSecret = Environment.GetEnvironmentVariable("RADIUS_SECRET") ?? "radpass";
|
||||
|
||||
foreach (var session in activeSessions)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(session.NasIpAddress)) continue;
|
||||
|
||||
try
|
||||
{
|
||||
var timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
|
||||
var nasIdentifier = string.IsNullOrWhiteSpace(session.CalledStationId) ? "" : session.CalledStationId.Split(':')[0].Replace("-", "").ToLowerInvariant();
|
||||
var payload = $"User-Name=\\\"{profile.Username}\\\"\\nCalling-Station-Id=\\\"{session.CallingStationId}\\\"\\nAcct-Session-Id=\\\"{session.AcctSessionId}\\\"\\nNAS-IP-Address=\\\"{session.NasIpAddress}\\\"\\nNAS-Identifier=\\\"{nasIdentifier}\\\"\\nEvent-Timestamp={timestamp}\\n";
|
||||
var process = new System.Diagnostics.Process
|
||||
{
|
||||
StartInfo = new System.Diagnostics.ProcessStartInfo
|
||||
{
|
||||
FileName = "sh",
|
||||
Arguments = $"-c \"printf '{payload}' | radclient -x {session.NasIpAddress}:3799 disconnect '{radiusSecret}'\"",
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true
|
||||
}
|
||||
};
|
||||
process.Start();
|
||||
string output = await process.StandardOutput.ReadToEndAsync(ct);
|
||||
string error = await process.StandardError.ReadToEndAsync(ct);
|
||||
await process.WaitForExitAsync(ct);
|
||||
|
||||
if (process.ExitCode == 0)
|
||||
{
|
||||
_logger.LogInformation("Sent RADIUS Disconnect-Request to NAS {NasIp} for user {Username} (MAC: {Mac}). Response: {Output}", session.NasIpAddress, profile.Username, session.CallingStationId, output);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning("Failed RADIUS Disconnect-Request to NAS {NasIp}. Exit Code: {Code}, Error: {Error}, Output: {Output}", session.NasIpAddress, process.ExitCode, error, output);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Exception sending RADIUS Disconnect-Request to NAS {NasIp}", session.NasIpAddress);
|
||||
}
|
||||
}
|
||||
|
||||
disabledCount++;
|
||||
changesMade = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (changesMade)
|
||||
{
|
||||
await db.SaveChangesAsync(ct);
|
||||
if (disabledCount > 0)
|
||||
{
|
||||
_logger.LogInformation("Enforcement check complete. {Count} account(s) disabled.", disabledCount);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
using Microsoft.AspNetCore.Components.Server.ProtectedBrowserStorage;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using radiuscontroller.Data;
|
||||
using radiuscontroller.Models;
|
||||
|
||||
namespace radiuscontroller.Services;
|
||||
|
||||
@@ -39,6 +40,39 @@ public class AdminAuthService
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<bool> HasAnyAdminAsync()
|
||||
{
|
||||
return await _db.AdminUsers.AnyAsync();
|
||||
}
|
||||
|
||||
public async Task<bool> RegisterInitialAdminAsync(string username, string password)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password))
|
||||
return false;
|
||||
|
||||
// Ensure first-time setup is only allowed if no admin users exist
|
||||
if (await _db.AdminUsers.AnyAsync())
|
||||
return false;
|
||||
|
||||
string cleanUsername = username.Trim().ToLowerInvariant();
|
||||
string hash = BCrypt.Net.BCrypt.HashPassword(password.Trim());
|
||||
|
||||
var admin = new AdminUser
|
||||
{
|
||||
Username = cleanUsername,
|
||||
PasswordHash = hash,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
_db.AdminUsers.Add(admin);
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
CurrentAdminUsername = admin.Username;
|
||||
await _sessionStorage.SetAsync(SessionKey, admin.Username);
|
||||
NotifyStateChanged();
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<bool> LoginAsync(string username, string password)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password))
|
||||
|
||||
@@ -106,7 +106,7 @@ public class RadiusService : IRadiusService
|
||||
Value = password
|
||||
});
|
||||
|
||||
// Set Session-Timeout (in seconds)
|
||||
// Set Session-Timeout to the full allowed time. When this expires, the AP will drop the user natively.
|
||||
long sessionTimeoutSeconds = (long)config.DefaultSessionTimeMinutes * 60;
|
||||
_db.RadReply.Add(new RadReply
|
||||
{
|
||||
@@ -126,6 +126,17 @@ public class RadiusService : IRadiusService
|
||||
Value = maxOctetsBytes.ToString()
|
||||
});
|
||||
|
||||
// Force NAS to send accounting interim-updates every 60 seconds
|
||||
_db.RadReply.Add(new RadReply
|
||||
{
|
||||
Username = username,
|
||||
Attribute = "Acct-Interim-Interval",
|
||||
Op = "=",
|
||||
Value = "60"
|
||||
});
|
||||
|
||||
|
||||
|
||||
// Create Guest Profile record
|
||||
var profile = new GuestProfile
|
||||
{
|
||||
@@ -153,30 +164,32 @@ public class RadiusService : IRadiusService
|
||||
|
||||
public async Task<List<UserUsageDto>> GetAllUsersWithUsageAsync()
|
||||
{
|
||||
var profiles = await _db.GuestProfiles.OrderByDescending(p => p.CreatedAt).ToListAsync();
|
||||
var passwords = await _db.RadCheck
|
||||
var profiles = await _db.GuestProfiles.AsNoTracking().OrderByDescending(p => p.CreatedAt).ToListAsync();
|
||||
var passwords = await _db.RadCheck.AsNoTracking()
|
||||
.Where(rc => rc.Attribute == "Cleartext-Password")
|
||||
.ToDictionaryAsync(rc => rc.Username, rc => rc.Value);
|
||||
|
||||
// Group accounting data by username
|
||||
var acctStats = await _db.RadAcct
|
||||
// Group accounting data by username (for session time and connection status)
|
||||
var acctStats = await _db.RadAcct.AsNoTracking()
|
||||
.GroupBy(ra => ra.Username)
|
||||
.Select(g => new
|
||||
{
|
||||
Username = g.Key,
|
||||
TotalInput = g.Sum(x => x.AcctInputOctets ?? 0),
|
||||
TotalOutput = g.Sum(x => x.AcctOutputOctets ?? 0),
|
||||
TotalSessionTime = g.Sum(x => x.AcctSessionTime ?? 0),
|
||||
IsConnected = g.Any(x => x.AcctStopTime == null)
|
||||
})
|
||||
.ToDictionaryAsync(g => g.Username);
|
||||
|
||||
// Fetch data usage from the new dedicated table
|
||||
var dataUsage = await _db.GuestDataUsage.AsNoTracking().ToDictionaryAsync(du => du.Username);
|
||||
|
||||
var result = new List<UserUsageDto>();
|
||||
|
||||
foreach (var p in profiles)
|
||||
{
|
||||
passwords.TryGetValue(p.Username, out var pwd);
|
||||
acctStats.TryGetValue(p.Username, out var acct);
|
||||
dataUsage.TryGetValue(p.Username, out var usage);
|
||||
|
||||
result.Add(new UserUsageDto
|
||||
{
|
||||
@@ -188,8 +201,8 @@ public class RadiusService : IRadiusService
|
||||
SessionTimeMinutes = p.SessionTimeMinutes,
|
||||
DataLimitMb = p.DataLimitMb,
|
||||
Status = p.Status,
|
||||
TotalInputOctets = acct?.TotalInput ?? 0,
|
||||
TotalOutputOctets = acct?.TotalOutput ?? 0,
|
||||
TotalInputOctets = usage?.TotalInputOctets ?? 0,
|
||||
TotalOutputOctets = usage?.TotalOutputOctets ?? 0,
|
||||
TotalSessionTimeSeconds = acct?.TotalSessionTime ?? 0,
|
||||
IsCurrentlyConnected = acct?.IsConnected ?? false
|
||||
});
|
||||
@@ -200,7 +213,7 @@ public class RadiusService : IRadiusService
|
||||
|
||||
public async Task<List<RadAcct>> GetActiveSessionsAsync()
|
||||
{
|
||||
return await _db.RadAcct
|
||||
return await _db.RadAcct.AsNoTracking()
|
||||
.Where(ra => ra.AcctStopTime == null)
|
||||
.OrderByDescending(ra => ra.AcctStartTime)
|
||||
.ToListAsync();
|
||||
@@ -214,7 +227,7 @@ public class RadiusService : IRadiusService
|
||||
profile.SessionTimeMinutes = newSessionTimeMinutes;
|
||||
profile.DataLimitMb = newDataLimitMb;
|
||||
|
||||
// Update RadReply Session-Timeout
|
||||
// Update RadReply Session-Timeout to the full allowed time
|
||||
var sessionReply = await _db.RadReply.FirstOrDefaultAsync(rr => rr.Username == profile.Username && rr.Attribute == "Session-Timeout");
|
||||
if (sessionReply != null)
|
||||
{
|
||||
@@ -272,6 +285,9 @@ public class RadiusService : IRadiusService
|
||||
});
|
||||
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
// Disconnect active sessions immediately
|
||||
await DisconnectActiveSessionsAsync(profile.Username);
|
||||
}
|
||||
|
||||
public async Task DeleteUserAsync(int profileId)
|
||||
@@ -289,6 +305,9 @@ public class RadiusService : IRadiusService
|
||||
_db.GuestProfiles.Remove(profile);
|
||||
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
// Disconnect active sessions immediately
|
||||
await DisconnectActiveSessionsAsync(username);
|
||||
}
|
||||
|
||||
public async Task ResetUserPasswordAsync(int profileId, string newPassword)
|
||||
@@ -331,18 +350,6 @@ public class RadiusService : IRadiusService
|
||||
{
|
||||
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 });
|
||||
@@ -367,4 +374,55 @@ public class RadiusService : IRadiusService
|
||||
}
|
||||
return new string(result);
|
||||
}
|
||||
|
||||
private async Task DisconnectActiveSessionsAsync(string username)
|
||||
{
|
||||
var activeSessions = await _db.RadAcct
|
||||
.Where(ra => ra.Username == username && ra.AcctStopTime == null)
|
||||
.Select(ra => new { ra.NasIpAddress, ra.CallingStationId, ra.AcctSessionId, ra.CalledStationId })
|
||||
.ToListAsync();
|
||||
|
||||
var radiusSecret = Environment.GetEnvironmentVariable("RADIUS_SECRET") ?? "radpass";
|
||||
|
||||
foreach (var session in activeSessions)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(session.NasIpAddress)) continue;
|
||||
|
||||
try
|
||||
{
|
||||
var timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
|
||||
var nasIdentifier = string.IsNullOrWhiteSpace(session.CalledStationId) ? "" : session.CalledStationId.Split(':')[0].Replace("-", "").ToLowerInvariant();
|
||||
var payload = $"User-Name=\\\"{username}\\\"\\nCalling-Station-Id=\\\"{session.CallingStationId}\\\"\\nAcct-Session-Id=\\\"{session.AcctSessionId}\\\"\\nNAS-IP-Address=\\\"{session.NasIpAddress}\\\"\\nNAS-Identifier=\\\"{nasIdentifier}\\\"\\nEvent-Timestamp={timestamp}\\n";
|
||||
var process = new System.Diagnostics.Process
|
||||
{
|
||||
StartInfo = new System.Diagnostics.ProcessStartInfo
|
||||
{
|
||||
FileName = "sh",
|
||||
Arguments = $"-c \"printf '{payload}' | radclient -x {session.NasIpAddress}:3799 disconnect '{radiusSecret}'\"",
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true
|
||||
}
|
||||
};
|
||||
process.Start();
|
||||
string output = await process.StandardOutput.ReadToEndAsync();
|
||||
string error = await process.StandardError.ReadToEndAsync();
|
||||
await process.WaitForExitAsync();
|
||||
|
||||
if (process.ExitCode == 0)
|
||||
{
|
||||
_logger.LogInformation("Sent manual RADIUS Disconnect-Request to NAS {NasIp} for user {Username} (MAC: {Mac}). Response: {Output}", session.NasIpAddress, username, session.CallingStationId, output);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning("Failed RADIUS Disconnect-Request to NAS {NasIp}. Exit Code: {Code}, Error: {Error}, Output: {Output}", session.NasIpAddress, process.ExitCode, error, output);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Exception sending manual RADIUS Disconnect-Request to NAS {NasIp}", session.NasIpAddress);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
# Default environment variables
|
||||
DB_HOST="${DB_HOST:-postgres}"
|
||||
DB_PORT="${DB_PORT:-5432}"
|
||||
DB_USER="${DB_USER:-radius}"
|
||||
DB_PASS="${DB_PASS:-radpass}"
|
||||
DB_NAME="${DB_NAME:-radius}"
|
||||
RADIUS_SECRET="${RADIUS_SECRET:-radpass}"
|
||||
|
||||
echo "================================================="
|
||||
echo " Starting RADIUS Controller & FreeRADIUS Stack"
|
||||
echo " DB Host: $DB_HOST:$DB_PORT | Database: $DB_NAME"
|
||||
echo " RADIUS Secret: $RADIUS_SECRET (Allows all IP addresses)"
|
||||
echo "================================================="
|
||||
|
||||
# Dynamically update FreeRADIUS SQL configuration with DB settings
|
||||
SQL_CONF="/etc/freeradius/3.0/mods-enabled/sql"
|
||||
if [ -f "$SQL_CONF" ]; then
|
||||
sed -i "s/server = .*/server = \"$DB_HOST\"/" "$SQL_CONF"
|
||||
sed -i "s/port = .*/port = $DB_PORT/" "$SQL_CONF"
|
||||
sed -i "s/login = .*/login = \"$DB_USER\"/" "$SQL_CONF"
|
||||
sed -i "s/password = .*/password = \"$DB_PASS\"/" "$SQL_CONF"
|
||||
sed -i "s/radius_db = .*/radius_db = \"$DB_NAME\"/" "$SQL_CONF"
|
||||
fi
|
||||
|
||||
# Dynamically update RADIUS shared secret for all client networks from env
|
||||
CLIENTS_CONF="/etc/freeradius/3.0/clients.conf"
|
||||
if [ -f "$CLIENTS_CONF" ]; then
|
||||
sed -i "s/secret = .*/secret = $RADIUS_SECRET/" "$CLIENTS_CONF"
|
||||
fi
|
||||
|
||||
# Generate FreeRADIUS EAP certificates if missing
|
||||
CERT_DIR="/etc/freeradius/3.0/certs"
|
||||
if [ ! -f "$CERT_DIR/server.pem" ]; then
|
||||
echo "Generating FreeRADIUS EAP certificates..."
|
||||
mkdir -p "$CERT_DIR"
|
||||
|
||||
# Generate CA key and certificate
|
||||
openssl req -new -x509 -keyout "$CERT_DIR/ca.key" -out "$CERT_DIR/ca.pem" \
|
||||
-days 3650 -nodes -subj "/CN=RADIUS CA" 2>/dev/null
|
||||
|
||||
# Generate server key and CSR
|
||||
openssl req -new -keyout "$CERT_DIR/server.key" -out "$CERT_DIR/server.csr" \
|
||||
-nodes -subj "/CN=RADIUS Server" 2>/dev/null
|
||||
|
||||
# Sign the server certificate with the CA
|
||||
openssl x509 -req -in "$CERT_DIR/server.csr" -CA "$CERT_DIR/ca.pem" \
|
||||
-CAkey "$CERT_DIR/ca.key" -CAcreateserial -out "$CERT_DIR/server.crt" \
|
||||
-days 3650 2>/dev/null
|
||||
|
||||
# Combine server key + cert into server.pem
|
||||
cat "$CERT_DIR/server.key" "$CERT_DIR/server.crt" > "$CERT_DIR/server.pem"
|
||||
|
||||
# Generate DH parameters (small size for speed, adequate for internal use)
|
||||
openssl dhparam -out "$CERT_DIR/dh" 1024 2>/dev/null
|
||||
|
||||
# Clean up intermediate files
|
||||
rm -f "$CERT_DIR/server.csr" "$CERT_DIR/server.key" "$CERT_DIR/server.crt" "$CERT_DIR/ca.key" "$CERT_DIR/ca.srl"
|
||||
|
||||
echo "EAP certificates generated successfully."
|
||||
fi
|
||||
|
||||
# Ensure correct permissions for FreeRADIUS config directory
|
||||
chown -R freerad:freerad /etc/freeradius/3.0/ || true
|
||||
|
||||
# Start FreeRADIUS daemon in background
|
||||
echo "Starting FreeRADIUS service..."
|
||||
freeradius -x &
|
||||
|
||||
# Wait briefly for FreeRADIUS to initialize
|
||||
sleep 2
|
||||
|
||||
# Start ASP.NET Core Application
|
||||
echo "Starting ASP.NET Core Web Controller..."
|
||||
exec dotnet radiuscontroller.dll
|
||||
@@ -0,0 +1,17 @@
|
||||
client localhost {
|
||||
ipaddr = 127.0.0.1
|
||||
secret = radpass
|
||||
shortname = localhost
|
||||
}
|
||||
|
||||
client local_net {
|
||||
ipaddr = 0.0.0.0/0
|
||||
secret = radpass
|
||||
shortname = all_clients
|
||||
}
|
||||
|
||||
client local_net_v6 {
|
||||
ipv6addr = ::/0
|
||||
secret = radpass
|
||||
shortname = all_clients_v6
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
# Custom RADIUS attributes for Radius Controller
|
||||
# Max-Octets: Limits total bytes (upload + download) per session
|
||||
ATTRIBUTE Max-Octets 227 integer
|
||||
@@ -0,0 +1,42 @@
|
||||
eap {
|
||||
default_eap_type = peap
|
||||
timer_expire = 60
|
||||
ignore_unknown_eap_types = no
|
||||
cisco_accounting_username_bug = no
|
||||
max_sessions = ${max_requests}
|
||||
|
||||
# TLS configuration section named "certs"
|
||||
tls-config certs {
|
||||
private_key_password = whatever
|
||||
private_key_file = ${certdir}/server.pem
|
||||
certificate_file = ${certdir}/server.pem
|
||||
ca_file = ${cadir}/ca.pem
|
||||
dh_file = ${certdir}/dh
|
||||
cipher_list = "DEFAULT@SECLEVEL=1"
|
||||
cipher_server_preference = no
|
||||
tls_min_version = "1.0"
|
||||
tls_max_version = "1.2"
|
||||
}
|
||||
|
||||
# PEAP configuration referencing tls-config certs
|
||||
peap {
|
||||
tls = certs
|
||||
default_eap_type = mschapv2
|
||||
copy_request_to_tunnel = yes
|
||||
use_tunneled_reply = yes
|
||||
virtual_server = "inner-tunnel"
|
||||
}
|
||||
|
||||
# TTLS configuration referencing tls-config certs
|
||||
ttls {
|
||||
tls = certs
|
||||
default_eap_type = mschapv2
|
||||
copy_request_to_tunnel = yes
|
||||
use_tunneled_reply = yes
|
||||
virtual_server = "inner-tunnel"
|
||||
}
|
||||
|
||||
# MSCHAPv2 configuration
|
||||
mschapv2 {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
sql {
|
||||
driver = "rlm_sql_postgresql"
|
||||
dialect = "postgresql"
|
||||
|
||||
server = "postgres"
|
||||
port = 5432
|
||||
login = "radius"
|
||||
password = "radpass"
|
||||
radius_db = "radius"
|
||||
|
||||
# Standard FreeRADIUS SQL Table Mappings
|
||||
authcheck_table = "radcheck"
|
||||
authreply_table = "radreply"
|
||||
groupcheck_table = "radgroupcheck"
|
||||
groupreply_table = "radgroupreply"
|
||||
usergroup_table = "radusergroup"
|
||||
|
||||
# Accounting Table Mappings
|
||||
acct_table1 = "radacct"
|
||||
acct_table2 = "radacct"
|
||||
|
||||
# Post-auth Table Mapping
|
||||
postauth_table = "radpostauth"
|
||||
|
||||
# NAS Client Table Mapping
|
||||
client_table = "nas"
|
||||
read_clients = yes
|
||||
|
||||
group_attribute = "SQL-Group"
|
||||
|
||||
pool {
|
||||
start = 2
|
||||
min = 1
|
||||
max = 10
|
||||
spare = 3
|
||||
uses = 0
|
||||
retry_delay = 30
|
||||
lifetime = 0
|
||||
idle_timeout = 60
|
||||
}
|
||||
|
||||
read_groups = yes
|
||||
|
||||
$INCLUDE ${modconfdir}/${.:name}/main/${dialect}/queries.conf
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
server default {
|
||||
listen {
|
||||
type = auth
|
||||
ipaddr = *
|
||||
port = 1812
|
||||
limit {
|
||||
max_connections = 16
|
||||
lifetime = 0
|
||||
idle_timeout = 30
|
||||
}
|
||||
}
|
||||
|
||||
listen {
|
||||
type = acct
|
||||
ipaddr = *
|
||||
port = 1813
|
||||
limit {
|
||||
max_connections = 16
|
||||
lifetime = 0
|
||||
idle_timeout = 30
|
||||
}
|
||||
}
|
||||
|
||||
authorize {
|
||||
filter_username
|
||||
preprocess
|
||||
sql
|
||||
chap
|
||||
mschap
|
||||
digest
|
||||
pap
|
||||
eap {
|
||||
ok = return
|
||||
}
|
||||
expiration
|
||||
logintime
|
||||
}
|
||||
|
||||
authenticate {
|
||||
Auth-Type PAP {
|
||||
pap
|
||||
}
|
||||
Auth-Type CHAP {
|
||||
chap
|
||||
}
|
||||
Auth-Type MS-CHAP {
|
||||
mschap
|
||||
}
|
||||
mschap
|
||||
eap
|
||||
}
|
||||
|
||||
pre-proxy {
|
||||
}
|
||||
|
||||
post-proxy {
|
||||
eap
|
||||
}
|
||||
preacct {
|
||||
preprocess
|
||||
acct_unique
|
||||
}
|
||||
|
||||
accounting {
|
||||
detail
|
||||
sql
|
||||
exec
|
||||
}
|
||||
|
||||
session {
|
||||
sql
|
||||
}
|
||||
|
||||
post-auth {
|
||||
sql
|
||||
exec
|
||||
Post-Auth-Type REJECT {
|
||||
sql
|
||||
attr_filter.access_reject
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
server inner-tunnel {
|
||||
authorize {
|
||||
filter_username
|
||||
sql
|
||||
chap
|
||||
mschap
|
||||
pap
|
||||
eap {
|
||||
ok = return
|
||||
}
|
||||
expiration
|
||||
logintime
|
||||
}
|
||||
|
||||
authenticate {
|
||||
Auth-Type PAP {
|
||||
pap
|
||||
}
|
||||
Auth-Type CHAP {
|
||||
chap
|
||||
}
|
||||
Auth-Type MS-CHAP {
|
||||
mschap
|
||||
}
|
||||
mschap
|
||||
eap
|
||||
}
|
||||
|
||||
post-auth {
|
||||
sql
|
||||
Post-Auth-Type REJECT {
|
||||
sql
|
||||
attr_filter.access_reject
|
||||
}
|
||||
}
|
||||
}
|
||||
+80
-12
@@ -48,10 +48,10 @@ CREATE INDEX IF NOT EXISTS radusergroup_username ON radusergroup (username);
|
||||
CREATE TABLE IF NOT EXISTS radacct (
|
||||
radacctid BIGSERIAL PRIMARY KEY,
|
||||
acctsessionid VARCHAR(64) NOT NULL DEFAULT '',
|
||||
acctuniqueid VARCHAR(32) NOT NULL DEFAULT '',
|
||||
acctuniqueid VARCHAR(32) NOT NULL UNIQUE DEFAULT '',
|
||||
username VARCHAR(64) NOT NULL DEFAULT '',
|
||||
realm VARCHAR(64) DEFAULT '',
|
||||
nasipaddress VARCHAR(15) NOT NULL DEFAULT '',
|
||||
nasipaddress VARCHAR(46) NOT NULL DEFAULT '',
|
||||
nasportid VARCHAR(32) DEFAULT NULL,
|
||||
nasporttype VARCHAR(32) DEFAULT NULL,
|
||||
acctstarttime TIMESTAMP WITH TIME ZONE DEFAULT NULL,
|
||||
@@ -60,19 +60,25 @@ CREATE TABLE IF NOT EXISTS radacct (
|
||||
acctinterval INT DEFAULT NULL,
|
||||
acctsessiontime BIGINT DEFAULT NULL,
|
||||
acctauthentic VARCHAR(32) DEFAULT NULL,
|
||||
connectinfo_in VARCHAR(50) DEFAULT NULL,
|
||||
connectinfo_out VARCHAR(50) DEFAULT NULL,
|
||||
connectinfo_start VARCHAR(50) DEFAULT NULL,
|
||||
connectinfo_stop VARCHAR(50) DEFAULT NULL,
|
||||
acctinputoctets BIGINT DEFAULT NULL,
|
||||
acctoutputoctets BIGINT DEFAULT NULL,
|
||||
calledstationid VARCHAR(50) NOT NULL DEFAULT '',
|
||||
callingstationid VARCHAR(50) NOT NULL DEFAULT '',
|
||||
acctterminatecause VARCHAR(32) NOT NULL DEFAULT '',
|
||||
calledstationid VARCHAR(50) DEFAULT '',
|
||||
callingstationid VARCHAR(50) DEFAULT '',
|
||||
acctterminatecause VARCHAR(32) DEFAULT NULL,
|
||||
servicetype VARCHAR(32) DEFAULT NULL,
|
||||
framedprotocol VARCHAR(32) DEFAULT NULL,
|
||||
framedipaddress VARCHAR(15) NOT NULL DEFAULT ''
|
||||
framedipaddress VARCHAR(46) DEFAULT '',
|
||||
framedipv6address VARCHAR(45) DEFAULT '',
|
||||
framedipv6prefix VARCHAR(45) DEFAULT '',
|
||||
framedinterfaceid VARCHAR(44) DEFAULT '',
|
||||
delegatedipv6prefix VARCHAR(45) DEFAULT '',
|
||||
class VARCHAR(64) DEFAULT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS radacct_username ON radacct (username);
|
||||
CREATE INDEX IF NOT EXISTS radacct_active ON radacct (acctstoptime) WHERE acctstoptime IS NULL;
|
||||
CREATE INDEX IF NOT EXISTS radacct_acctuniqueid ON radacct (acctuniqueid);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS nas (
|
||||
id SERIAL PRIMARY KEY,
|
||||
@@ -86,6 +92,15 @@ CREATE TABLE IF NOT EXISTS nas (
|
||||
description VARCHAR(200) DEFAULT 'RADIUS Client'
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS radpostauth (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
username VARCHAR(64) NOT NULL DEFAULT '',
|
||||
pass VARCHAR(64) NOT NULL DEFAULT '',
|
||||
reply VARCHAR(32) NOT NULL DEFAULT '',
|
||||
authdate TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS radpostauth_username ON radpostauth (username);
|
||||
|
||||
-- Application Management Tables
|
||||
CREATE TABLE IF NOT EXISTS guest_profiles (
|
||||
id SERIAL PRIMARY KEY,
|
||||
@@ -114,7 +129,60 @@ CREATE TABLE IF NOT EXISTS system_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;
|
||||
-- System settings initialized on startup
|
||||
-- Admin user is created by administrator during first startup via the web interface
|
||||
|
||||
-- Dedicated table for robust, per-user data tracking that survives AP counter resets
|
||||
CREATE TABLE IF NOT EXISTS guest_data_usage (
|
||||
username VARCHAR(64) PRIMARY KEY,
|
||||
total_input_octets BIGINT NOT NULL DEFAULT 0,
|
||||
total_output_octets BIGINT NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
-- Trigger function to safely accumulate data usage from radacct
|
||||
CREATE OR REPLACE FUNCTION update_guest_data_usage()
|
||||
RETURNS TRIGGER AS $$
|
||||
DECLARE
|
||||
delta_input BIGINT := 0;
|
||||
delta_output BIGINT := 0;
|
||||
BEGIN
|
||||
-- Handle INSERT (new session)
|
||||
IF (TG_OP = 'INSERT') THEN
|
||||
delta_input := COALESCE(NEW.acctinputoctets, 0);
|
||||
delta_output := COALESCE(NEW.acctoutputoctets, 0);
|
||||
-- Handle UPDATE (interim updates)
|
||||
ELSIF (TG_OP = 'UPDATE') THEN
|
||||
-- Only add if the new value is greater than the old value (protects against counter resets)
|
||||
IF (COALESCE(NEW.acctinputoctets, 0) > COALESCE(OLD.acctinputoctets, 0)) THEN
|
||||
delta_input := NEW.acctinputoctets - COALESCE(OLD.acctinputoctets, 0);
|
||||
ELSIF (COALESCE(NEW.acctinputoctets, 0) < COALESCE(OLD.acctinputoctets, 0)) THEN
|
||||
-- Counter reset mid-session! Just add the new value as the delta.
|
||||
delta_input := COALESCE(NEW.acctinputoctets, 0);
|
||||
END IF;
|
||||
|
||||
IF (COALESCE(NEW.acctoutputoctets, 0) > COALESCE(OLD.acctoutputoctets, 0)) THEN
|
||||
delta_output := NEW.acctoutputoctets - COALESCE(OLD.acctoutputoctets, 0);
|
||||
ELSIF (COALESCE(NEW.acctoutputoctets, 0) < COALESCE(OLD.acctoutputoctets, 0)) THEN
|
||||
-- Counter reset mid-session!
|
||||
delta_output := COALESCE(NEW.acctoutputoctets, 0);
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
-- Upsert the calculated deltas into the usage table per-user
|
||||
IF (delta_input > 0 OR delta_output > 0) THEN
|
||||
INSERT INTO guest_data_usage (username, total_input_octets, total_output_octets)
|
||||
VALUES (NEW.username, delta_input, delta_output)
|
||||
ON CONFLICT (username) DO UPDATE
|
||||
SET total_input_octets = guest_data_usage.total_input_octets + EXCLUDED.total_input_octets,
|
||||
total_output_octets = guest_data_usage.total_output_octets + EXCLUDED.total_output_octets;
|
||||
END IF;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- Attach trigger to radacct table
|
||||
DROP TRIGGER IF EXISTS trg_update_guest_data_usage ON radacct;
|
||||
CREATE TRIGGER trg_update_guest_data_usage
|
||||
AFTER INSERT OR UPDATE OF acctinputoctets, acctoutputoctets ON radacct
|
||||
FOR EACH ROW EXECUTE FUNCTION update_guest_data_usage();
|
||||
|
||||
Reference in New Issue
Block a user