2026-07-23 11:12:10 +02:00
using System.Security.Cryptography ;
using System.Text.RegularExpressions ;
using Microsoft.EntityFrameworkCore ;
using radiuscontroller.Data ;
using radiuscontroller.Models ;
namespace radiuscontroller.Services ;
public interface IRadiusService
{
Task < SystemConfigDto > GetSystemConfigAsync ();
Task UpdateSystemConfigAsync ( int defaultSessionTimeMinutes , long defaultDataLimitMb );
Task < RegistrationResultDto > RegisterGuestAsync ( string guestName );
Task < List < UserUsageDto >> GetAllUsersWithUsageAsync ();
Task < List < RadAcct >> GetActiveSessionsAsync ();
Task UpdateUserLimitsAsync ( int profileId , int newSessionTimeMinutes , long newDataLimitMb );
Task RevokeUserAsync ( int profileId );
Task DeleteUserAsync ( int profileId );
Task ResetUserPasswordAsync ( int profileId , string newPassword );
Task EnsureDatabaseCreatedAndSeededAsync ();
}
public class RadiusService : IRadiusService
{
private readonly RadiusDbContext _db ;
private readonly ILogger < RadiusService > _logger ;
public RadiusService ( RadiusDbContext db , ILogger < RadiusService > logger )
{
_db = db ;
_logger = logger ;
}
public async Task < SystemConfigDto > GetSystemConfigAsync ()
{
var settings = await _db . SystemSettings . ToDictionaryAsync ( s => s . Key , s => s . Value );
int sessionTime = 60 ; // 60 minutes default
if ( settings . TryGetValue ( "default_session_time" , out var sessionStr ) && int . TryParse ( sessionStr , out var parsedSession ))
{
sessionTime = parsedSession ;
}
long dataLimit = 500 ; // 500 MB default
if ( settings . TryGetValue ( "default_data_limit" , out var dataStr ) && long . TryParse ( dataStr , out var parsedData ))
{
dataLimit = parsedData ;
}
return new SystemConfigDto
{
DefaultSessionTimeMinutes = sessionTime ,
DefaultDataLimitMb = dataLimit
};
}
public async Task UpdateSystemConfigAsync ( int defaultSessionTimeMinutes , long defaultDataLimitMb )
{
await SetOrUpdateSettingAsync ( "default_session_time" , defaultSessionTimeMinutes . ToString ());
await SetOrUpdateSettingAsync ( "default_data_limit" , defaultDataLimitMb . ToString ());
await _db . SaveChangesAsync ();
}
private async Task SetOrUpdateSettingAsync ( string key , string value )
{
var existing = await _db . SystemSettings . FirstOrDefaultAsync ( s => s . Key == key );
if ( existing != null )
{
existing . Value = value ;
existing . UpdatedAt = DateTime . UtcNow ;
}
else
{
_db . SystemSettings . Add ( new SystemSetting
{
Key = key ,
Value = value ,
UpdatedAt = DateTime . UtcNow
});
}
}
public async Task < RegistrationResultDto > RegisterGuestAsync ( string guestName )
{
if ( string . IsNullOrWhiteSpace ( guestName ))
{
throw new ArgumentException ( "Guest name cannot be empty." , nameof ( guestName ));
}
var config = await GetSystemConfigAsync ();
// Format clean username base
string cleanName = Regex . Replace ( guestName . Trim (). ToLowerInvariant (), @"[^a-z0-9]" , "" );
if ( string . IsNullOrEmpty ( cleanName )) cleanName = "guest" ;
if ( cleanName . Length > 12 ) cleanName = cleanName [.. 12 ];
string username = $"{cleanName}_{RandomNumberGenerator.GetInt32(1000, 9999)}" ;
string password = GenerateReadablePassword ( 8 );
// Save Cleartext-Password for FreeRADIUS
_db . RadCheck . Add ( new RadCheck
{
Username = username ,
Attribute = "Cleartext-Password" ,
Op = ":=" ,
Value = password
});
2026-07-24 18:20:50 +02:00
// Set Session-Timeout to the full allowed time. When this expires, the AP will drop the user natively.
long sessionTimeoutSeconds = ( long ) config . DefaultSessionTimeMinutes * 60 ;
2026-07-23 11:12:10 +02:00
_db . RadReply . Add ( new RadReply
{
Username = username ,
Attribute = "Session-Timeout" ,
Op = "=" ,
2026-07-24 18:20:50 +02:00
Value = sessionTimeoutSeconds . ToString ()
2026-07-23 11:12:10 +02:00
});
// Set Max-Octets (in bytes)
long maxOctetsBytes = config . DefaultDataLimitMb * 1024 * 1024 ;
_db . RadReply . Add ( new RadReply
{
Username = username ,
Attribute = "Max-Octets" ,
Op = "=" ,
Value = maxOctetsBytes . ToString ()
});
2026-07-24 17:13:44 +02:00
// Force NAS to send accounting interim-updates every 60 seconds
_db . RadReply . Add ( new RadReply
{
Username = username ,
Attribute = "Acct-Interim-Interval" ,
Op = "=" ,
Value = "60"
});
2026-07-24 18:20:50 +02:00
2026-07-24 17:13:44 +02:00
2026-07-23 11:12:10 +02:00
// Create Guest Profile record
var profile = new GuestProfile
{
GuestName = guestName . Trim (),
Username = username ,
CreatedAt = DateTime . UtcNow ,
SessionTimeMinutes = config . DefaultSessionTimeMinutes ,
DataLimitMb = config . DefaultDataLimitMb ,
Status = "Active"
};
_db . GuestProfiles . Add ( profile );
await _db . SaveChangesAsync ();
return new RegistrationResultDto
{
GuestName = profile . GuestName ,
Username = username ,
Password = password ,
SessionTimeMinutes = config . DefaultSessionTimeMinutes ,
DataLimitMb = config . DefaultDataLimitMb ,
ExpirationEstimate = DateTime . UtcNow . AddMinutes ( config . DefaultSessionTimeMinutes )
};
}
public async Task < List < UserUsageDto >> GetAllUsersWithUsageAsync ()
{
var profiles = await _db . GuestProfiles . OrderByDescending ( p => p . CreatedAt ). ToListAsync ();
var passwords = await _db . RadCheck
. Where ( rc => rc . Attribute == "Cleartext-Password" )
. ToDictionaryAsync ( rc => rc . Username , rc => rc . Value );
2026-07-24 17:51:04 +02:00
// Group accounting data by username (for session time and connection status)
2026-07-23 11:12:10 +02:00
var acctStats = await _db . RadAcct
. GroupBy ( ra => ra . Username )
. Select ( g => new
{
Username = g . Key ,
TotalSessionTime = g . Sum ( x => x . AcctSessionTime ?? 0 ),
IsConnected = g . Any ( x => x . AcctStopTime == null )
})
. ToDictionaryAsync ( g => g . Username );
2026-07-24 17:51:04 +02:00
// Fetch data usage from the new dedicated table
var dataUsage = await _db . GuestDataUsage . ToDictionaryAsync ( du => du . Username );
2026-07-23 11:12:10 +02:00
var result = new List < UserUsageDto >();
foreach ( var p in profiles )
{
passwords . TryGetValue ( p . Username , out var pwd );
acctStats . TryGetValue ( p . Username , out var acct );
2026-07-24 17:51:04 +02:00
dataUsage . TryGetValue ( p . Username , out var usage );
2026-07-23 11:12:10 +02:00
result . Add ( new UserUsageDto
{
ProfileId = p . Id ,
GuestName = p . GuestName ,
Username = p . Username ,
Password = pwd ?? "••••••••" ,
CreatedAt = p . CreatedAt ,
SessionTimeMinutes = p . SessionTimeMinutes ,
DataLimitMb = p . DataLimitMb ,
Status = p . Status ,
2026-07-24 17:51:04 +02:00
TotalInputOctets = usage ?. TotalInputOctets ?? 0 ,
TotalOutputOctets = usage ?. TotalOutputOctets ?? 0 ,
2026-07-23 11:12:10 +02:00
TotalSessionTimeSeconds = acct ?. TotalSessionTime ?? 0 ,
IsCurrentlyConnected = acct ?. IsConnected ?? false
});
}
return result ;
}
public async Task < List < RadAcct >> GetActiveSessionsAsync ()
{
return await _db . RadAcct
. Where ( ra => ra . AcctStopTime == null )
. OrderByDescending ( ra => ra . AcctStartTime )
. ToListAsync ();
}
public async Task UpdateUserLimitsAsync ( int profileId , int newSessionTimeMinutes , long newDataLimitMb )
{
var profile = await _db . GuestProfiles . FindAsync ( profileId );
if ( profile == null ) return ;
profile . SessionTimeMinutes = newSessionTimeMinutes ;
profile . DataLimitMb = newDataLimitMb ;
2026-07-24 18:20:50 +02:00
// Update RadReply Session-Timeout to the full allowed time
2026-07-23 11:12:10 +02:00
var sessionReply = await _db . RadReply . FirstOrDefaultAsync ( rr => rr . Username == profile . Username && rr . Attribute == "Session-Timeout" );
if ( sessionReply != null )
{
2026-07-24 18:20:50 +02:00
sessionReply . Value = ( newSessionTimeMinutes * 60 ). ToString ();
2026-07-23 11:12:10 +02:00
}
else
{
_db . RadReply . Add ( new RadReply
{
Username = profile . Username ,
Attribute = "Session-Timeout" ,
Op = "=" ,
2026-07-24 18:20:50 +02:00
Value = ( newSessionTimeMinutes * 60 ). ToString ()
2026-07-23 11:12:10 +02:00
});
}
// Update RadReply Max-Octets
var dataReply = await _db . RadReply . FirstOrDefaultAsync ( rr => rr . Username == profile . Username && rr . Attribute == "Max-Octets" );
long maxOctetsBytes = newDataLimitMb * 1024 * 1024 ;
if ( dataReply != null )
{
dataReply . Value = maxOctetsBytes . ToString ();
}
else
{
_db . RadReply . Add ( new RadReply
{
Username = profile . Username ,
Attribute = "Max-Octets" ,
Op = "=" ,
Value = maxOctetsBytes . ToString ()
});
}
await _db . SaveChangesAsync ();
}
public async Task RevokeUserAsync ( int profileId )
{
var profile = await _db . GuestProfiles . FindAsync ( profileId );
if ( profile == null ) return ;
profile . Status = "Revoked" ;
// Remove Cleartext-Password and insert Auth-Type := Reject
var checks = await _db . RadCheck . Where ( rc => rc . Username == profile . Username ). ToListAsync ();
_db . RadCheck . RemoveRange ( checks );
_db . RadCheck . Add ( new RadCheck
{
Username = profile . Username ,
Attribute = "Auth-Type" ,
Op = ":=" ,
Value = "Reject"
});
await _db . SaveChangesAsync ();
2026-07-24 18:20:50 +02:00
// Disconnect active sessions immediately
await DisconnectActiveSessionsAsync ( profile . Username );
2026-07-23 11:12:10 +02:00
}
public async Task DeleteUserAsync ( int profileId )
{
var profile = await _db . GuestProfiles . FindAsync ( profileId );
if ( profile == null ) return ;
var username = profile . Username ;
var checks = await _db . RadCheck . Where ( rc => rc . Username == username ). ToListAsync ();
var replies = await _db . RadReply . Where ( rr => rr . Username == username ). ToListAsync ();
_db . RadCheck . RemoveRange ( checks );
_db . RadReply . RemoveRange ( replies );
_db . GuestProfiles . Remove ( profile );
await _db . SaveChangesAsync ();
2026-07-24 18:20:50 +02:00
// Disconnect active sessions immediately
await DisconnectActiveSessionsAsync ( username );
2026-07-23 11:12:10 +02:00
}
public async Task ResetUserPasswordAsync ( int profileId , string newPassword )
{
var profile = await _db . GuestProfiles . FindAsync ( profileId );
if ( profile == null ) return ;
var check = await _db . RadCheck . FirstOrDefaultAsync ( rc => rc . Username == profile . Username && rc . Attribute == "Cleartext-Password" );
if ( check != null )
{
check . Value = newPassword ;
}
else
{
_db . RadCheck . Add ( new RadCheck
{
Username = profile . Username ,
Attribute = "Cleartext-Password" ,
Op = ":=" ,
Value = newPassword
});
}
if ( profile . Status == "Revoked" )
{
profile . Status = "Active" ;
var rejectCheck = await _db . RadCheck . FirstOrDefaultAsync ( rc => rc . Username == profile . Username && rc . Attribute == "Auth-Type" );
if ( rejectCheck != null )
{
_db . RadCheck . Remove ( rejectCheck );
}
}
await _db . SaveChangesAsync ();
}
public async Task EnsureDatabaseCreatedAndSeededAsync ()
{
try
{
await _db . Database . EnsureCreatedAsync ();
if (! await _db . SystemSettings . AnyAsync ())
{
_db . SystemSettings . Add ( new SystemSetting { Key = "default_session_time" , Value = "60" , UpdatedAt = DateTime . UtcNow });
_db . SystemSettings . Add ( new SystemSetting { Key = "default_data_limit" , Value = "500" , UpdatedAt = DateTime . UtcNow });
}
await _db . SaveChangesAsync ();
}
catch ( Exception ex )
{
_logger . LogError ( ex , "Database initialization warning (Database might be populated via Docker init.sql script)." );
}
}
private static string GenerateReadablePassword ( int length )
{
const string chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" ;
var result = new char [ length ];
for ( int i = 0 ; i < length ; i ++)
{
result [ i ] = chars [ RandomNumberGenerator . GetInt32 ( chars . Length )];
}
return new string ( result );
}
2026-07-24 18:20:50 +02:00
private async Task DisconnectActiveSessionsAsync ( string username )
{
var activeSessions = await _db . RadAcct
. Where ( ra => ra . Username == username && ra . AcctStopTime == null )
2026-07-24 18:47:45 +02:00
. Select ( ra => new { ra . NasIpAddress , ra . CallingStationId , ra . AcctSessionId })
2026-07-24 18:20:50 +02:00
. ToListAsync ();
var radiusSecret = Environment . GetEnvironmentVariable ( "RADIUS_SECRET" ) ?? "radpass" ;
2026-07-24 18:47:45 +02:00
foreach ( var session in activeSessions )
2026-07-24 18:20:50 +02:00
{
2026-07-24 18:47:45 +02:00
if ( string . IsNullOrWhiteSpace ( session . NasIpAddress )) continue ;
2026-07-24 18:20:50 +02:00
try
{
2026-07-24 19:45:26 +02:00
var payload = $"User-Name=\\\" { username } \\\ "\\nCalling-Station-Id=\\\"{session.CallingStationId}\\\"\\nAcct-Session-Id=\\\"{session.AcctSessionId}\\\"\\nNAS-IP-Address=\\\"{session.NasIpAddress}\\\"\\n" ;
2026-07-24 18:20:50 +02:00
var process = new System . Diagnostics . Process
{
StartInfo = new System . Diagnostics . ProcessStartInfo
{
FileName = "sh" ,
2026-07-24 18:47:45 +02:00
Arguments = $"-c \" printf ' { payload } ' | radclient - x { session . NasIpAddress }: 3799 disconnect ' { radiusSecret } '\ "" ,
2026-07-24 18:20:50 +02:00
RedirectStandardOutput = true ,
RedirectStandardError = true ,
UseShellExecute = false ,
CreateNoWindow = true
}
};
process . Start ();
2026-07-24 19:04:02 +02:00
string output = await process . StandardOutput . ReadToEndAsync ();
string error = await process . StandardError . ReadToEndAsync ();
2026-07-24 18:20:50 +02:00
await process . WaitForExitAsync ();
2026-07-24 19:04:02 +02:00
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 );
}
2026-07-24 18:20:50 +02:00
}
catch ( Exception ex )
{
2026-07-24 19:04:02 +02:00
_logger . LogError ( ex , "Exception sending manual RADIUS Disconnect-Request to NAS {NasIp}" , session . NasIpAddress );
2026-07-24 18:20:50 +02:00
}
}
}
2026-07-23 11:12:10 +02:00
}