49 Commits

Author SHA1 Message Date
Tygozwolle ab703ae15e feat: add mschap module configuration and inner-tunnel site definition for RADIUS authentication
Build and Push Docker Image to Gitea Container Registry / build-and-push (push) Successful in 2m6s
2026-07-27 12:30:58 +02:00
Tygozwolle a5f5d55a26 feat: add Dockerfile with FreeRADIUS integration and .NET 9 runtime configuration
Build and Push Docker Image to Gitea Container Registry / build-and-push (push) Successful in 1m51s
2026-07-27 12:23:35 +02:00
Tygozwolle da66ba5239 feat: implement RadiusService for guest registration, session management, and usage tracking
Build and Push Docker Image to Gitea Container Registry / build-and-push (push) Successful in 1m41s
2026-07-27 12:17:55 +02:00
Tygozwolle bf261923e2 feat: implement account enforcement background service and RADIUS disconnect support for expired sessions
Build and Push Docker Image to Gitea Container Registry / build-and-push (push) Successful in 1m46s
2026-07-27 12:13:02 +02:00
Tygozwolle cc04d8abea feat: enable MS-CHAP error reporting to improve authentication feedback on Windows and iOS
Build and Push Docker Image to Gitea Container Registry / build-and-push (push) Successful in 1m45s
2026-07-27 12:04:54 +02:00
Tygozwolle f8b96853f4 feat: implement RadiusService for guest management and configure mschap to return explicit authentication error codes
Build and Push Docker Image to Gitea Container Registry / build-and-push (push) Successful in 1m58s
2026-07-27 11:59:14 +02:00
Tygozwolle 3b20b7481e feat: implement RadiusService for guest registration, session management, and usage tracking with AccountEnforcementService support.
Build and Push Docker Image to Gitea Container Registry / build-and-push (push) Successful in 2m21s
Build and Push Docker Image to Gitea Container Registry / build-and-push (pull_request) Successful in 1m38s
2026-07-24 20:33:08 +02:00
Tygozwolle 87d6d7e764 feat: implement automated account enforcement service with RADIUS disconnect capabilities and add RADIUS management service
Build and Push Docker Image to Gitea Container Registry / build-and-push (push) Successful in 1m15s
2026-07-24 20:29:11 +02:00
Tygozwolle d88e03ea09 feat: implement RadiusService for guest user management and authentication configurations
Build and Push Docker Image to Gitea Container Registry / build-and-push (push) Successful in 1m10s
2026-07-24 20:18:41 +02:00
Tygozwolle 6371826f5e auto update
Build and Push Docker Image to Gitea Container Registry / build-and-push (push) Successful in 1m8s
2026-07-24 20:13:16 +02:00
Tygozwolle be26d3f461 feat: add account enforcement background service and radius management service for automated session control and user configuration
Build and Push Docker Image to Gitea Container Registry / build-and-push (push) Successful in 1m3s
2026-07-24 20:07:16 +02:00
Tygozwolle f5d2144f79 feat: add account enforcement background service and update docker-compose for network containerization
Build and Push Docker Image to Gitea Container Registry / build-and-push (push) Successful in 1m10s
2026-07-24 19:55:59 +02:00
Tygozwolle 74326870f7 feat: implement automated account enforcement service for session time and data usage limits with RADIUS disconnect support
Build and Push Docker Image to Gitea Container Registry / build-and-push (push) Successful in 1m8s
2026-07-24 19:45:26 +02:00
Tygozwolle 4b8e4639e5 feat: implement RadiusService for guest management and AccountEnforcementService for session monitoring
Build and Push Docker Image to Gitea Container Registry / build-and-push (push) Successful in 1m10s
2026-07-24 19:04:02 +02:00
Tygozwolle 926d2a1fc5 feat: implement background account enforcement service and RADIUS management service for guest access control
Build and Push Docker Image to Gitea Container Registry / build-and-push (push) Successful in 1m3s
2026-07-24 18:47:45 +02:00
Tygozwolle 63082d0791 feat: initialize FreeRADIUS default site configuration with standard auth, acct, and processing modules
Build and Push Docker Image to Gitea Container Registry / build-and-push (push) Successful in 1m11s
2026-07-24 18:37:33 +02:00
Tygozwolle b730dced16 feat: implement RadiusService to manage guest user registration, session limits, and RADIUS authentication attributes
Build and Push Docker Image to Gitea Container Registry / build-and-push (push) Successful in 1m4s
2026-07-24 18:20:50 +02:00
Tygozwolle 0e4189985e feat: implement administrative dashboard page for monitoring and managing guest RADIUS sessions
Build and Push Docker Image to Gitea Container Registry / build-and-push (push) Successful in 1m6s
2026-07-24 18:15:31 +02:00
Tygozwolle 6e065cf47c feat: add admin dashboard page for user management and RADIUS monitoring
Build and Push Docker Image to Gitea Container Registry / build-and-push (push) Successful in 1m5s
2026-07-24 18:02:35 +02:00
Tygozwolle 63b9a97cdf feat: initialize database schema and implement core Radius entities, EF context, and account enforcement service.
Build and Push Docker Image to Gitea Container Registry / build-and-push (push) Successful in 1m12s
2026-07-24 17:51:04 +02:00
Tygozwolle 9c2cbfa7b9 feat: implement RadiusService for guest registration, session management, and RADIUS attribute configuration
Build and Push Docker Image to Gitea Container Registry / build-and-push (push) Successful in 1m10s
2026-07-24 17:36:23 +02:00
Tygozwolle d1b93191e1 feat: implement RadiusService for guest user management, session monitoring, and configuration handling
Build and Push Docker Image to Gitea Container Registry / build-and-push (push) Successful in 1m12s
2026-07-24 17:13:44 +02:00
Tygozwolle d7d493367f working
Build and Push Docker Image to Gitea Container Registry / build-and-push (push) Successful in 1m29s
2026-07-24 17:11:23 +02:00
Tygozwolle 6d95c12304 feat: add PostgreSQL initialization script for FreeRADIUS schema and application management tables
Build and Push Docker Image to Gitea Container Registry / build-and-push (push) Successful in 1m13s
2026-07-24 17:02:59 +02:00
Tygozwolle aebf757f26 feat: define RADIUS database entities and initialize PostgreSQL schema with FreeRADIUS support
Build and Push Docker Image to Gitea Container Registry / build-and-push (push) Successful in 1m11s
2026-07-24 16:44:44 +02:00
Tygozwolle c85b598b82 feat: initialize web host and implement background AccountEnforcementService for RADIUS account monitoring
Build and Push Docker Image to Gitea Container Registry / build-and-push (push) Successful in 1m13s
2026-07-24 15:50:28 +02:00
tygozwolle 77a4e31626 Merge pull request 'Radius inside' (#6) from radius_inside into master
Build and Push Docker Image to Gitea Container Registry / build-and-push (push) Successful in 1m8s
Reviewed-on: #6
2026-07-24 15:46:57 +02:00
Tygozwolle c4ed400ac6 c
Build and Push Docker Image to Gitea Container Registry / build-and-push (push) Successful in 1m8s
Build and Push Docker Image to Gitea Container Registry / build-and-push (pull_request) Successful in 3m36s
2026-07-24 15:40:26 +02:00
Tygozwolle 44290fa89c chore: add RADIUS_SECRET environment variable and certs volume mount to docker-compose configuration
Build and Push Docker Image to Gitea Container Registry / build-and-push (push) Successful in 1m14s
2026-07-24 15:37:19 +02:00
Tygozwolle 9dc029e32f feat: add Dockerfile and entrypoint script to containerize RADIUS controller and FreeRADIUS stack
Build and Push Docker Image to Gitea Container Registry / build-and-push (push) Successful in 1m14s
2026-07-24 15:29:07 +02:00
Tygozwolle 1054dfb61a feat: add FreeRADIUS EAP module configuration and container entrypoint script for dynamic service orchestration
Build and Push Docker Image to Gitea Container Registry / build-and-push (push) Successful in 3m17s
2026-07-24 15:18:22 +02:00
Tygozwolle 9a6b1131b1 feat: add Dockerfile, entrypoint script, and EAP configuration for FreeRADIUS containerization
Build and Push Docker Image to Gitea Container Registry / build-and-push (push) Successful in 4m19s
2026-07-24 15:09:42 +02:00
Tygozwolle c7e18c76ac feat: initialize default and inner-tunnel RADIUS server configurations
Build and Push Docker Image to Gitea Container Registry / build-and-push (push) Successful in 1m11s
2026-07-24 15:04:46 +02:00
Tygozwolle 574bcbe69a feat: add FreeRADIUS client configuration, environment-based secret management, and custom entrypoint script
Build and Push Docker Image to Gitea Container Registry / build-and-push (push) Successful in 1m13s
2026-07-24 14:53:01 +02:00
Tygozwolle 0bb36ea502 feat: add FreeRADIUS PostgreSQL configuration module to mods-enabled
Build and Push Docker Image to Gitea Container Registry / build-and-push (push) Successful in 6m17s
2026-07-24 14:22:38 +02:00
Tygozwolle f7663a284e feat: add PostgreSQL schema initialization and FreeRADIUS SQL module configuration
Build and Push Docker Image to Gitea Container Registry / build-and-push (push) Successful in 3m51s
2026-07-24 14:10:25 +02:00
Tygozwolle fd133bf9f3 feat: add FreeRADIUS configuration and containerization support with PostgreSQL integration
Build and Push Docker Image to Gitea Container Registry / build-and-push (push) Successful in 4m12s
2026-07-24 12:58:07 +02:00
Tygozwolle 824f0554e4 feat: consolidate FreeRADIUS and web controller into a single Docker image with unified entrypoint orchestration
Build and Push Docker Image to Gitea Container Registry / build-and-push (push) Failing after 32s
2026-07-24 12:51:51 +02:00
tygozwolle c636794e5e Merge pull request 'feat: implement admin dashboard with authentication, session monitoring, and user management UI' (#5) from admin into master
Build and Push Docker Image to Gitea Container Registry / build-and-push (push) Successful in 1m31s
Reviewed-on: #5
2026-07-24 12:49:17 +02:00
Tygozwolle cd2ee7bddd feat: implement admin dashboard with authentication, session monitoring, and user management UI
Build and Push Docker Image to Gitea Container Registry / build-and-push (push) Successful in 1m27s
Build and Push Docker Image to Gitea Container Registry / build-and-push (pull_request) Successful in 1m19s
2026-07-24 12:09:46 +02:00
tygozwolle 4b8396b6a6 Merge pull request 'create user' (#4) from configuration into master
Build and Push Docker Image to Gitea Container Registry / build-and-push (push) Successful in 1m33s
Reviewed-on: #4
2026-07-24 12:03:03 +02:00
Tygozwolle 09de033d70 create user
Build and Push Docker Image to Gitea Container Registry / build-and-push (push) Successful in 1m24s
Build and Push Docker Image to Gitea Container Registry / build-and-push (pull_request) Successful in 1m19s
2026-07-24 11:56:40 +02:00
tygozwolle 91a2aeb3fa Merge pull request 'docs: add detailed setup guide for RadiusController and UniFi integration' (#3) from readme into master
Build and Push Docker Image to Gitea Container Registry / build-and-push (push) Successful in 1m25s
Reviewed-on: #3
2026-07-24 11:53:56 +02:00
Tygozwolle 21118838c0 docs: add detailed setup guide for RadiusController and UniFi integration
Build and Push Docker Image to Gitea Container Registry / build-and-push (push) Successful in 1m25s
Build and Push Docker Image to Gitea Container Registry / build-and-push (pull_request) Successful in 1m16s
2026-07-24 11:44:29 +02:00
tygozwolle a5d57d3202 Merge pull request 'removed sha' (#2) from remove-sha into master
Build and Push Docker Image to Gitea Container Registry / build-and-push (push) Successful in 1m28s
Reviewed-on: #2
2026-07-24 11:39:34 +02:00
Tygozwolle a94daaa888 removed sha
Build and Push Docker Image to Gitea Container Registry / build-and-push (push) Successful in 1m52s
Build and Push Docker Image to Gitea Container Registry / build-and-push (pull_request) Successful in 1m16s
2026-07-24 11:39:21 +02:00
tygozwolle 15fb9d4b01 Merge pull request 'Workflow' (#1) from workflow into master
Build and Push Docker Image to Gitea Container Registry / build-and-push (push) Successful in 2m38s
Reviewed-on: #1
2026-07-24 11:35:51 +02:00
Tygozwolle 6a8e5a1080 chore: update docker-compose paths to host volumes and switch to pre-built radius image
Build and Push Docker Image to Gitea Container Registry / build-and-push (push) Successful in 1m21s
Build and Push Docker Image to Gitea Container Registry / build-and-push (pull_request) Successful in 1m19s
2026-07-24 11:30:10 +02:00
Tygozwolle 76aa6e4b2d feat: add Gitea Actions workflow to build and push Docker image to registry
Build and Push Docker Image to Gitea Container Registry / build-and-push (push) Successful in 3m6s
2026-07-24 11:22:05 +02:00
23 changed files with 1277 additions and 137 deletions
+55
View File
@@ -0,0 +1,55 @@
name: Build and Push Docker Image to Gitea Container Registry
on:
workflow_dispatch:
push:
# branches:
# - master
# - main
# tags:
# - 'v*'
pull_request:
branches:
- master
- main
env:
REGISTRY: cablon.vanolst.tech
IMAGE_NAME: ${{ gitea.repository }}
jobs:
build-and-push:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Gitea Container Registry
if: github.event_name != 'pull_request'
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ gitea.actor }}
password: ${{ secrets.PACKAGEKEY }}
- name: Extract Docker metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/master' || github.ref == 'refs/heads/main' }}
type=ref,event=branch
type=ref,event=tag
- name: Build and push Docker image
uses: docker/build-push-action@v6
with:
context: .
file: ./radiuscontroller/Dockerfile
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
+167
View File
@@ -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
+15 -23
View File
@@ -12,37 +12,29 @@ services:
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
- /mnt/user/appdata/radiuscontroller/pgdata:/var/lib/postgresql/data
- /mnt/user/appdata/radiuscontroller/sql/init.sql:/docker-entrypoint-initdb.d/init.sql:ro
radiuscontroller:
build:
context: .
dockerfile: radiuscontroller/Dockerfile
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:
pgdata:
# 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
+2
View File
@@ -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()
+2
View File
@@ -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");
}
+35 -4
View File
@@ -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,22 @@ 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/mods-enabled/mschap /etc/freeradius/3.0/mods-enabled/mschap
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"]
+33 -4
View File
@@ -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
{
+1
View File
@@ -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,171 @@
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";
// Replace password with a zeroed NT-Password hash so mschap runs
// but always fails — returns MS-CHAP-Error E=691, prompting iOS
// to show "Incorrect Password" instead of "Unable to join network".
var checks = await db.RadCheck
.Where(rc => rc.Username == profile.Username)
.ToListAsync(ct);
db.RadCheck.RemoveRange(checks);
db.RadCheck.Add(new RadCheck
{
Username = profile.Username,
Attribute = "NT-Password",
Op = ":=",
Value = "0x00000000000000000000000000000000"
});
_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))
+91 -29
View File
@@ -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)
{
@@ -259,19 +272,24 @@ public class RadiusService : IRadiusService
profile.Status = "Revoked";
// Remove Cleartext-Password and insert Auth-Type := Reject
// Replace password with a zeroed NT-Password hash so mschap runs
// but always fails — returns MS-CHAP-Error E=691, prompting iOS to show
// "Incorrect Password" instead of "Unable to join network".
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",
Attribute = "NT-Password",
Op = ":=",
Value = "Reject"
Value = "0x00000000000000000000000000000000"
});
await _db.SaveChangesAsync();
// Disconnect active sessions immediately
await DisconnectActiveSessionsAsync(profile.Username);
}
public async Task DeleteUserAsync(int profileId)
@@ -289,6 +307,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)
@@ -315,10 +336,12 @@ public class RadiusService : IRadiusService
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)
// Remove the zeroed NT-Password that was set on revoke
var ntPasswordCheck = await _db.RadCheck.FirstOrDefaultAsync(rc => rc.Username == profile.Username && rc.Attribute == "NT-Password");
if (ntPasswordCheck != null)
{
_db.RadCheck.Remove(rejectCheck);
_db.RadCheck.Remove(ntPasswordCheck);
}
}
@@ -331,18 +354,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 +378,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);
}
}
}
}
+77
View File
@@ -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
+17
View File
@@ -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
}
+3
View File
@@ -0,0 +1,3 @@
# Custom RADIUS attributes for Radius Controller
# Max-Octets: Limits total bytes (upload + download) per session
ATTRIBUTE Max-Octets 227 integer
+42
View File
@@ -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,9 @@
mschap {
# Send MS-CHAP-Error with E=691 on authentication failure,
# so iOS / Windows show "Incorrect Password" instead of "Unable to join network".
send_error = yes
use_mppe = yes
require_encryption = yes
require_strong = yes
with_ntdomain_hack = yes
}
+45
View File
@@ -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,35 @@
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
}
}
}
+80 -12
View File
@@ -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();